feat(regulatory): 增加适用条件确认暂停恢复
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 5.2.14 on 2026-06-07 01:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("review_agent", "0003_regulatoryartifact_regulatoryissue_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="regulatoryreviewbatch",
|
||||||
|
name="condition_json",
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="regulatoryreviewbatch",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "待执行"),
|
||||||
|
("running", "执行中"),
|
||||||
|
("waiting_user", "等待用户确认"),
|
||||||
|
("success", "成功"),
|
||||||
|
("failed", "失败"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflownoderun",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "等待中"),
|
||||||
|
("running", "执行中"),
|
||||||
|
("waiting_user", "等待用户确认"),
|
||||||
|
("retrying", "重试中"),
|
||||||
|
("success", "成功"),
|
||||||
|
("failed", "失败"),
|
||||||
|
("skipped", "跳过"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -253,6 +253,7 @@ class WorkflowNodeRun(models.Model):
|
|||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "等待中"
|
PENDING = "pending", "等待中"
|
||||||
RUNNING = "running", "执行中"
|
RUNNING = "running", "执行中"
|
||||||
|
WAITING_USER = "waiting_user", "等待用户确认"
|
||||||
RETRYING = "retrying", "重试中"
|
RETRYING = "retrying", "重试中"
|
||||||
SUCCESS = "success", "成功"
|
SUCCESS = "success", "成功"
|
||||||
FAILED = "failed", "失败"
|
FAILED = "failed", "失败"
|
||||||
@@ -402,6 +403,7 @@ class RegulatoryReviewBatch(models.Model):
|
|||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "待执行"
|
PENDING = "pending", "待执行"
|
||||||
RUNNING = "running", "执行中"
|
RUNNING = "running", "执行中"
|
||||||
|
WAITING_USER = "waiting_user", "等待用户确认"
|
||||||
SUCCESS = "success", "成功"
|
SUCCESS = "success", "成功"
|
||||||
FAILED = "failed", "失败"
|
FAILED = "failed", "失败"
|
||||||
|
|
||||||
@@ -436,6 +438,7 @@ class RegulatoryReviewBatch(models.Model):
|
|||||||
)
|
)
|
||||||
batch_no = models.CharField(max_length=64, unique=True)
|
batch_no = models.CharField(max_length=64, unique=True)
|
||||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
||||||
|
condition_json = models.JSONField(default=dict, blank=True)
|
||||||
risk_summary = models.JSONField(default=dict, blank=True)
|
risk_summary = models.JSONField(default=dict, blank=True)
|
||||||
work_dir = models.CharField(max_length=500, blank=True, default="")
|
work_dir = models.CharField(max_length=500, blank=True, default="")
|
||||||
error_message = models.TextField(blank=True, default="")
|
error_message = models.TextField(blank=True, default="")
|
||||||
|
|||||||
81
review_agent/regulatory_review/services/info_extract.py
Normal file
81
review_agent/regulatory_review/services/info_extract.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from review_agent.models import FileSummaryBatch
|
||||||
|
|
||||||
|
|
||||||
|
OPTION_FIELDS = {
|
||||||
|
"product_category": ["体外诊断试剂", "医疗器械", "其他"],
|
||||||
|
"registration_type": ["首次注册", "变更注册", "延续注册"],
|
||||||
|
"clinical_evaluation_path": ["临床试验", "免临床", "同品种比对", "待确认"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_regulatory_condition_candidates(summary_batch: FileSummaryBatch) -> dict[str, dict[str, object]]:
|
||||||
|
"""Infers review-scope conditions from the summary batch and file names."""
|
||||||
|
|
||||||
|
corpus_parts = [summary_batch.product_name or ""]
|
||||||
|
for item in summary_batch.items.order_by("file_index"):
|
||||||
|
corpus_parts.extend([item.directory_level, item.file_name, item.relative_path])
|
||||||
|
corpus = "\n".join(part for part in corpus_parts if part)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"product_category": {
|
||||||
|
"label": "产品类别",
|
||||||
|
"input_type": "select",
|
||||||
|
"options": OPTION_FIELDS["product_category"],
|
||||||
|
"suggested": _detect_product_category(corpus),
|
||||||
|
},
|
||||||
|
"registration_type": {
|
||||||
|
"label": "注册类型",
|
||||||
|
"input_type": "select",
|
||||||
|
"options": OPTION_FIELDS["registration_type"],
|
||||||
|
"suggested": _detect_registration_type(corpus),
|
||||||
|
},
|
||||||
|
"clinical_evaluation_path": {
|
||||||
|
"label": "临床评价路径",
|
||||||
|
"input_type": "select",
|
||||||
|
"options": OPTION_FIELDS["clinical_evaluation_path"],
|
||||||
|
"suggested": _detect_clinical_path(corpus),
|
||||||
|
},
|
||||||
|
"product_name": {
|
||||||
|
"label": "产品名称",
|
||||||
|
"input_type": "text",
|
||||||
|
"suggested": summary_batch.product_name or "",
|
||||||
|
},
|
||||||
|
"model_spec": {
|
||||||
|
"label": "型号规格",
|
||||||
|
"input_type": "text",
|
||||||
|
"suggested": "",
|
||||||
|
},
|
||||||
|
"intended_use": {
|
||||||
|
"label": "预期用途",
|
||||||
|
"input_type": "text",
|
||||||
|
"suggested": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_product_category(corpus: str) -> str:
|
||||||
|
if any(keyword in corpus for keyword in ["体外诊断", "检测试剂", "试剂盒", "IVD"]):
|
||||||
|
return "体外诊断试剂"
|
||||||
|
if "医疗器械" in corpus:
|
||||||
|
return "医疗器械"
|
||||||
|
return "其他"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_registration_type(corpus: str) -> str:
|
||||||
|
if "延续" in corpus:
|
||||||
|
return "延续注册"
|
||||||
|
if "变更" in corpus:
|
||||||
|
return "变更注册"
|
||||||
|
return "首次注册"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_clinical_path(corpus: str) -> str:
|
||||||
|
if "免临床" in corpus or "免于临床" in corpus:
|
||||||
|
return "免临床"
|
||||||
|
if "同品种" in corpus or "同类" in corpus:
|
||||||
|
return "同品种比对"
|
||||||
|
if "临床试验" in corpus:
|
||||||
|
return "临床试验"
|
||||||
|
return "待确认"
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.http import Http404, JsonResponse
|
from django.http import Http404, JsonResponse
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
from review_agent.models import RegulatoryReviewBatch, WorkflowNodeRun
|
from review_agent.models import RegulatoryReviewBatch, WorkflowNodeRun
|
||||||
|
from review_agent.regulatory_review.events import record_event
|
||||||
|
from review_agent.regulatory_review.workflow import start_regulatory_review_workflow
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
@@ -43,6 +48,59 @@ def batch_status(request, batch_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
def confirm_conditions(request, batch_id: int):
|
||||||
|
batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first()
|
||||||
|
if not batch:
|
||||||
|
raise Http404("批次不存在。")
|
||||||
|
try:
|
||||||
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({"error": "请求体不是有效 JSON。"}, status=400)
|
||||||
|
conditions = payload.get("conditions")
|
||||||
|
if not isinstance(conditions, dict):
|
||||||
|
return JsonResponse({"error": "conditions 必须是对象。"}, status=400)
|
||||||
|
|
||||||
|
batch.condition_json = {
|
||||||
|
**(batch.condition_json or {}),
|
||||||
|
"confirmed": True,
|
||||||
|
"confirmed_conditions": _normalize_conditions(conditions),
|
||||||
|
}
|
||||||
|
batch.status = RegulatoryReviewBatch.Status.RUNNING
|
||||||
|
batch.save(update_fields=["condition_json", "status"])
|
||||||
|
WorkflowNodeRun.objects.filter(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
node_code="condition_confirm",
|
||||||
|
).update(
|
||||||
|
status=WorkflowNodeRun.Status.SUCCESS,
|
||||||
|
progress=100,
|
||||||
|
message="适用条件已确认",
|
||||||
|
)
|
||||||
|
record_event(
|
||||||
|
batch,
|
||||||
|
"condition_confirmed",
|
||||||
|
{"conditions": batch.condition_json["confirmed_conditions"], "resume_from": "rule_scope"},
|
||||||
|
)
|
||||||
|
start_regulatory_review_workflow(
|
||||||
|
batch,
|
||||||
|
async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True),
|
||||||
|
)
|
||||||
|
batch.refresh_from_db()
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"batch": {
|
||||||
|
"id": batch.pk,
|
||||||
|
"workflow_type": "regulatory_review",
|
||||||
|
"batch_no": batch.batch_no,
|
||||||
|
"status": batch.status,
|
||||||
|
"condition_json": batch.condition_json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _format_risk_summary(risk_summary: dict) -> str:
|
def _format_risk_summary(risk_summary: dict) -> str:
|
||||||
labels = [
|
labels = [
|
||||||
("blocking", "阻断项"),
|
("blocking", "阻断项"),
|
||||||
@@ -56,3 +114,15 @@ def _format_risk_summary(risk_summary: dict) -> str:
|
|||||||
for key, label in labels
|
for key, label in labels
|
||||||
if int(risk_summary.get(key) or 0)
|
if int(risk_summary.get(key) or 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_conditions(conditions: dict) -> dict[str, str]:
|
||||||
|
allowed = [
|
||||||
|
"product_category",
|
||||||
|
"registration_type",
|
||||||
|
"clinical_evaluation_path",
|
||||||
|
"product_name",
|
||||||
|
"model_spec",
|
||||||
|
"intended_use",
|
||||||
|
]
|
||||||
|
return {key: str(conditions.get(key) or "").strip() for key in allowed}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from review_agent.models import (
|
|||||||
from review_agent.regulatory_review.services.completeness_check import run_completeness_check
|
from review_agent.regulatory_review.services.completeness_check import run_completeness_check
|
||||||
from review_agent.regulatory_review.services.consistency_check import run_consistency_check
|
from review_agent.regulatory_review.services.consistency_check import run_consistency_check
|
||||||
from review_agent.regulatory_review.services.export import build_assistant_summary, export_review_results
|
from review_agent.regulatory_review.services.export import build_assistant_summary, export_review_results
|
||||||
|
from review_agent.regulatory_review.services.info_extract import detect_regulatory_condition_candidates
|
||||||
from review_agent.regulatory_review.services.risk_assess import persist_findings
|
from review_agent.regulatory_review.services.risk_assess import persist_findings
|
||||||
from review_agent.regulatory_review.services.rule_loader import load_rule_file
|
from review_agent.regulatory_review.services.rule_loader import load_rule_file
|
||||||
from review_agent.regulatory_review.services.structure_check import run_structure_check
|
from review_agent.regulatory_review.services.structure_check import run_structure_check
|
||||||
@@ -29,6 +30,7 @@ from .events import record_event
|
|||||||
|
|
||||||
NODE_DEFINITIONS = [
|
NODE_DEFINITIONS = [
|
||||||
("prepare", "准备", "prepare"),
|
("prepare", "准备", "prepare"),
|
||||||
|
("condition_confirm", "适用条件确认", "condition_confirm"),
|
||||||
("rule_scope", "规则范围", "rule_scope"),
|
("rule_scope", "规则范围", "rule_scope"),
|
||||||
("completeness_check", "完整性核查", "completeness_check"),
|
("completeness_check", "完整性核查", "completeness_check"),
|
||||||
("text_extract", "文本抽取", "text_extract"),
|
("text_extract", "文本抽取", "text_extract"),
|
||||||
@@ -43,6 +45,10 @@ NODE_DEFINITIONS = [
|
|||||||
logger = logging.getLogger("review_agent.regulatory_review.workflow")
|
logger = logging.getLogger("review_agent.regulatory_review.workflow")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowPausedForUser(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def build_batch_no() -> str:
|
def build_batch_no() -> str:
|
||||||
return f"RR-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}"
|
return f"RR-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}"
|
||||||
|
|
||||||
@@ -108,7 +114,11 @@ class RegulatoryWorkflowExecutor:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for node in self._nodes():
|
for node in self._nodes():
|
||||||
|
if node.status == WorkflowNodeRun.Status.SUCCESS:
|
||||||
|
continue
|
||||||
self._run_node(node)
|
self._run_node(node)
|
||||||
|
except WorkflowPausedForUser:
|
||||||
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Regulatory workflow failed", extra={"batch_id": self.batch.pk})
|
logger.exception("Regulatory workflow failed", extra={"batch_id": self.batch.pk})
|
||||||
self.batch.status = RegulatoryReviewBatch.Status.FAILED
|
self.batch.status = RegulatoryReviewBatch.Status.FAILED
|
||||||
@@ -155,6 +165,9 @@ class RegulatoryWorkflowExecutor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute_node(self, node_code: str) -> None:
|
def _execute_node(self, node_code: str) -> None:
|
||||||
|
if node_code == "condition_confirm":
|
||||||
|
self._pause_for_condition_confirmation()
|
||||||
|
return
|
||||||
if node_code == "rule_scope":
|
if node_code == "rule_scope":
|
||||||
self.rule_set = load_rule_file()
|
self.rule_set = load_rule_file()
|
||||||
return
|
return
|
||||||
@@ -181,6 +194,34 @@ class RegulatoryWorkflowExecutor:
|
|||||||
content=build_assistant_summary(self.batch, exports),
|
content=build_assistant_summary(self.batch, exports),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _pause_for_condition_confirmation(self) -> None:
|
||||||
|
if self.batch.condition_json.get("confirmed"):
|
||||||
|
return
|
||||||
|
candidates = detect_regulatory_condition_candidates(self.batch.source_summary_batch)
|
||||||
|
self.batch.condition_json = {
|
||||||
|
**(self.batch.condition_json or {}),
|
||||||
|
"confirmed": False,
|
||||||
|
"resume_from": "rule_scope",
|
||||||
|
"candidates": candidates,
|
||||||
|
}
|
||||||
|
self.batch.status = RegulatoryReviewBatch.Status.WAITING_USER
|
||||||
|
self.batch.save(update_fields=["status", "condition_json"])
|
||||||
|
node = WorkflowNodeRun.objects.get(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=self.batch.pk,
|
||||||
|
node_code="condition_confirm",
|
||||||
|
)
|
||||||
|
node.status = WorkflowNodeRun.Status.WAITING_USER
|
||||||
|
node.progress = 50
|
||||||
|
node.message = "请确认产品类别、注册类型、临床评价路径等适用条件"
|
||||||
|
node.save(update_fields=["status", "progress", "message"])
|
||||||
|
record_event(
|
||||||
|
self.batch,
|
||||||
|
"waiting_user",
|
||||||
|
{"node_code": "condition_confirm", "candidates": candidates, "resume_from": "rule_scope"},
|
||||||
|
)
|
||||||
|
raise WorkflowPausedForUser()
|
||||||
|
|
||||||
def _rules(self) -> dict:
|
def _rules(self) -> dict:
|
||||||
if self.rule_set is None:
|
if self.rule_set is None:
|
||||||
self.rule_set = load_rule_file()
|
self.rule_set = load_rule_file()
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from .file_summary.views import (
|
|||||||
conversation_messages,
|
conversation_messages,
|
||||||
export_download,
|
export_download,
|
||||||
)
|
)
|
||||||
from .regulatory_review.views import batch_status as regulatory_review_batch_status
|
from .regulatory_review.views import (
|
||||||
|
batch_status as regulatory_review_batch_status,
|
||||||
|
confirm_conditions as regulatory_review_confirm_conditions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -64,4 +67,9 @@ urlpatterns = [
|
|||||||
regulatory_review_batch_status,
|
regulatory_review_batch_status,
|
||||||
name="regulatory_review_batch_status",
|
name="regulatory_review_batch_status",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"api/review-agent/regulatory-review/<int:batch_id>/conditions/",
|
||||||
|
regulatory_review_confirm_conditions,
|
||||||
|
name="regulatory_review_confirm_conditions",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]:
|
|||||||
"status": batch.status,
|
"status": batch.status,
|
||||||
"error_message": batch.error_message,
|
"error_message": batch.error_message,
|
||||||
"risk_label": _format_risk_label(batch.risk_summary or {}),
|
"risk_label": _format_risk_label(batch.risk_summary or {}),
|
||||||
|
"condition_json": batch.condition_json or {},
|
||||||
|
"condition_candidates": (batch.condition_json or {}).get("candidates") or {},
|
||||||
"created_at": batch.created_at,
|
"created_at": batch.created_at,
|
||||||
"nodes": list(
|
"nodes": list(
|
||||||
WorkflowNodeRun.objects.filter(
|
WorkflowNodeRun.objects.filter(
|
||||||
|
|||||||
@@ -796,6 +796,60 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindConditionConfirmForms() {
|
||||||
|
document.querySelectorAll("[data-condition-confirm-form]").forEach(function (form) {
|
||||||
|
if (form.dataset.bound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.dataset.bound = "true";
|
||||||
|
form.addEventListener("submit", async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
var batchId = form.getAttribute("data-batch-id");
|
||||||
|
var status = form.querySelector("[data-condition-confirm-status]");
|
||||||
|
var submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
var formData = new FormData(form);
|
||||||
|
var conditions = {};
|
||||||
|
formData.forEach(function (value, key) {
|
||||||
|
if (key !== "csrfmiddlewaretoken") {
|
||||||
|
conditions[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "正在恢复法规核查...";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var response = await fetch(form.getAttribute("data-confirm-url"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRFToken": formData.get("csrfmiddlewaretoken"),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ conditions: conditions }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("确认失败。");
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "已确认,工作流继续执行。";
|
||||||
|
}
|
||||||
|
form.classList.add("confirmed");
|
||||||
|
startWorkflowPolling(batchId);
|
||||||
|
await refreshWorkflowCard(batchId, "regulatory_review");
|
||||||
|
} catch (error) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "确认失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function streamChat(event) {
|
async function streamChat(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!composer || !promptInput || !sendButton || !chatStage) {
|
if (!composer || !promptInput || !sendButton || !chatStage) {
|
||||||
@@ -955,6 +1009,7 @@
|
|||||||
renderExistingAssistantMessages();
|
renderExistingAssistantMessages();
|
||||||
refreshWorkflowBatchCarousel(0);
|
refreshWorkflowBatchCarousel(0);
|
||||||
bindWorkflowBatchCarouselControls();
|
bindWorkflowBatchCarouselControls();
|
||||||
|
bindConditionConfirmForms();
|
||||||
refreshRunningWorkflowCards();
|
refreshRunningWorkflowCards();
|
||||||
|
|
||||||
if (chatScroll) {
|
if (chatScroll) {
|
||||||
|
|||||||
@@ -240,6 +240,33 @@
|
|||||||
{% if batch.error_message %}
|
{% if batch.error_message %}
|
||||||
<p class="workflow-error">{{ batch.error_message }}</p>
|
<p class="workflow-error">{{ batch.error_message }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if batch.workflow_type == "regulatory_review" and batch.status == "waiting_user" and batch.condition_candidates %}
|
||||||
|
<form
|
||||||
|
class="condition-confirm-form"
|
||||||
|
data-condition-confirm-form
|
||||||
|
data-batch-id="{{ batch.id }}"
|
||||||
|
data-confirm-url="/api/review-agent/regulatory-review/{{ batch.id }}/conditions/"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<strong>适用条件确认</strong>
|
||||||
|
{% for field, config in batch.condition_candidates.items %}
|
||||||
|
<label>
|
||||||
|
<span>{{ config.label }}</span>
|
||||||
|
{% if config.input_type == "select" %}
|
||||||
|
<select name="{{ field }}">
|
||||||
|
{% for option in config.options %}
|
||||||
|
<option value="{{ option }}"{% if option == config.suggested %} selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% else %}
|
||||||
|
<input type="text" name="{{ field }}" value="{{ config.suggested|default:'' }}">
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit">确认并继续</button>
|
||||||
|
<p class="condition-confirm-status" data-condition-confirm-status></p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<ol>
|
<ol>
|
||||||
{% for node in batch.nodes %}
|
{% for node in batch.nodes %}
|
||||||
<li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}">
|
<li class="node-status status-{{ node.status }}" data-node-code="{{ node.node_code }}">
|
||||||
|
|||||||
139
tests/test_regulatory_condition.py
Normal file
139
tests/test_regulatory_condition.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from review_agent.models import (
|
||||||
|
Conversation,
|
||||||
|
FileSummaryBatch,
|
||||||
|
FileSummaryItem,
|
||||||
|
RegulatoryReviewBatch,
|
||||||
|
WorkflowEvent,
|
||||||
|
WorkflowNodeRun,
|
||||||
|
)
|
||||||
|
from review_agent.regulatory_review.services.info_extract import detect_regulatory_condition_candidates
|
||||||
|
from review_agent.regulatory_review.workflow import (
|
||||||
|
create_regulatory_review_batch,
|
||||||
|
start_regulatory_review_workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_regulatory_condition_candidates_from_summary_items(django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-COND",
|
||||||
|
status=FileSummaryBatch.Status.SUCCESS,
|
||||||
|
product_name="甲胎蛋白检测试剂盒",
|
||||||
|
)
|
||||||
|
FileSummaryItem.objects.create(
|
||||||
|
batch=summary,
|
||||||
|
file_index=1,
|
||||||
|
directory_level="临床评价资料",
|
||||||
|
file_name="免临床评价资料.docx",
|
||||||
|
file_type="docx",
|
||||||
|
relative_path="4.临床评价资料/免临床评价资料.docx",
|
||||||
|
storage_path="missing.docx",
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = detect_regulatory_condition_candidates(summary)
|
||||||
|
|
||||||
|
assert candidates["product_category"]["suggested"] == "体外诊断试剂"
|
||||||
|
assert candidates["registration_type"]["suggested"] == "首次注册"
|
||||||
|
assert candidates["clinical_evaluation_path"]["suggested"] == "免临床"
|
||||||
|
assert candidates["product_name"]["suggested"] == "甲胎蛋白检测试剂盒"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_pauses_before_rule_scope_until_conditions_confirmed(settings, tmp_path, django_user_model):
|
||||||
|
settings.MEDIA_ROOT = tmp_path
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-COND",
|
||||||
|
status=FileSummaryBatch.Status.SUCCESS,
|
||||||
|
product_name="甲胎蛋白检测试剂盒",
|
||||||
|
)
|
||||||
|
batch = create_regulatory_review_batch(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
source_summary_batch=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_regulatory_review_workflow(batch, async_run=False)
|
||||||
|
|
||||||
|
batch.refresh_from_db()
|
||||||
|
condition_node = WorkflowNodeRun.objects.get(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
node_code="condition_confirm",
|
||||||
|
)
|
||||||
|
rule_scope_node = WorkflowNodeRun.objects.get(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
node_code="rule_scope",
|
||||||
|
)
|
||||||
|
assert batch.status == RegulatoryReviewBatch.Status.WAITING_USER
|
||||||
|
assert condition_node.status == WorkflowNodeRun.Status.WAITING_USER
|
||||||
|
assert rule_scope_node.status == WorkflowNodeRun.Status.PENDING
|
||||||
|
assert batch.condition_json["candidates"]["product_category"]["suggested"] == "体外诊断试剂"
|
||||||
|
assert WorkflowEvent.objects.filter(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
event_type="waiting_user",
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_conditions_endpoint_resumes_workflow(client, settings, tmp_path, django_user_model):
|
||||||
|
settings.MEDIA_ROOT = tmp_path
|
||||||
|
settings.REGULATORY_REVIEW_ASYNC = False
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-COND",
|
||||||
|
status=FileSummaryBatch.Status.SUCCESS,
|
||||||
|
product_name="甲胎蛋白检测试剂盒",
|
||||||
|
)
|
||||||
|
batch = create_regulatory_review_batch(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
source_summary_batch=summary,
|
||||||
|
)
|
||||||
|
start_regulatory_review_workflow(batch, async_run=False)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("regulatory_review_confirm_conditions", args=[batch.pk]),
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"product_category": "体外诊断试剂",
|
||||||
|
"registration_type": "首次注册",
|
||||||
|
"clinical_evaluation_path": "免临床",
|
||||||
|
"product_name": "甲胎蛋白检测试剂盒",
|
||||||
|
"model_spec": "卡型",
|
||||||
|
"intended_use": "用于甲胎蛋白检测",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
batch.refresh_from_db()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["batch"]["status"] == RegulatoryReviewBatch.Status.SUCCESS
|
||||||
|
assert batch.condition_json["confirmed"] is True
|
||||||
|
assert batch.condition_json["confirmed_conditions"]["model_spec"] == "卡型"
|
||||||
|
assert WorkflowNodeRun.objects.get(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=batch.pk,
|
||||||
|
node_code="condition_confirm",
|
||||||
|
).status == WorkflowNodeRun.Status.SUCCESS
|
||||||
@@ -50,9 +50,63 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model):
|
|||||||
assert "data-regulatory-status-url-template" in content
|
assert "data-regulatory-status-url-template" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_renders_condition_confirmation_form(client, django_user_model):
|
||||||
|
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||||
|
conversation = Conversation.objects.create(user=user, title="会话")
|
||||||
|
summary = FileSummaryBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
batch_no="FS-OK",
|
||||||
|
status=FileSummaryBatch.Status.SUCCESS,
|
||||||
|
)
|
||||||
|
regulatory = RegulatoryReviewBatch.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
user=user,
|
||||||
|
source_summary_batch=summary,
|
||||||
|
batch_no="RR-WAIT",
|
||||||
|
status=RegulatoryReviewBatch.Status.WAITING_USER,
|
||||||
|
condition_json={
|
||||||
|
"confirmed": False,
|
||||||
|
"candidates": {
|
||||||
|
"product_category": {
|
||||||
|
"label": "产品类别",
|
||||||
|
"input_type": "select",
|
||||||
|
"options": ["体外诊断试剂", "医疗器械", "其他"],
|
||||||
|
"suggested": "体外诊断试剂",
|
||||||
|
},
|
||||||
|
"product_name": {
|
||||||
|
"label": "产品名称",
|
||||||
|
"input_type": "text",
|
||||||
|
"suggested": "甲胎蛋白检测试剂盒",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
WorkflowNodeRun.objects.create(
|
||||||
|
workflow_type="regulatory_review",
|
||||||
|
workflow_batch_id=regulatory.pk,
|
||||||
|
node_group="condition_confirm",
|
||||||
|
node_code="condition_confirm",
|
||||||
|
node_name="适用条件确认",
|
||||||
|
status=WorkflowNodeRun.Status.WAITING_USER,
|
||||||
|
progress=50,
|
||||||
|
)
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||||
|
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
assert "适用条件确认" in content
|
||||||
|
assert "data-condition-confirm-form" in content
|
||||||
|
assert "体外诊断试剂" in content
|
||||||
|
assert "甲胎蛋白检测试剂盒" in content
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_selects_status_url_by_workflow_type():
|
def test_frontend_selects_status_url_by_workflow_type():
|
||||||
script = open("static/js/app.js", encoding="utf-8").read()
|
script = open("static/js/app.js", encoding="utf-8").read()
|
||||||
|
|
||||||
assert "workflow_type" in script
|
assert "workflow_type" in script
|
||||||
assert "data-regulatory-status-url-template" in script
|
assert "data-regulatory-status-url-template" in script
|
||||||
assert "statusUrlForWorkflow" in script
|
assert "statusUrlForWorkflow" in script
|
||||||
|
assert "bindConditionConfirmForms" in script
|
||||||
|
assert "data-condition-confirm-form" in script
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ def test_start_regulatory_review_workflow_runs_synchronously(django_user_model):
|
|||||||
user=user,
|
user=user,
|
||||||
source_summary_batch=summary,
|
source_summary_batch=summary,
|
||||||
)
|
)
|
||||||
|
batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}}
|
||||||
|
batch.save(update_fields=["condition_json"])
|
||||||
|
|
||||||
start_regulatory_review_workflow(batch, async_run=False)
|
start_regulatory_review_workflow(batch, async_run=False)
|
||||||
|
|
||||||
@@ -187,6 +189,8 @@ def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_p
|
|||||||
user=user,
|
user=user,
|
||||||
source_summary_batch=summary,
|
source_summary_batch=summary,
|
||||||
)
|
)
|
||||||
|
batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}}
|
||||||
|
batch.save(update_fields=["condition_json"])
|
||||||
|
|
||||||
start_regulatory_review_workflow(batch, async_run=False)
|
start_regulatory_review_workflow(batch, async_run=False)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user