diff --git a/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py b/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py new file mode 100644 index 0000000..f4b7ac7 --- /dev/null +++ b/review_agent/migrations/0004_regulatoryreviewbatch_condition_json_and_more.py @@ -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, + ), + ), + ] diff --git a/review_agent/models.py b/review_agent/models.py index 4a404e5..7f70902 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -253,6 +253,7 @@ class WorkflowNodeRun(models.Model): class Status(models.TextChoices): PENDING = "pending", "等待中" RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户确认" RETRYING = "retrying", "重试中" SUCCESS = "success", "成功" FAILED = "failed", "失败" @@ -402,6 +403,7 @@ class RegulatoryReviewBatch(models.Model): class Status(models.TextChoices): PENDING = "pending", "待执行" RUNNING = "running", "执行中" + WAITING_USER = "waiting_user", "等待用户确认" SUCCESS = "success", "成功" FAILED = "failed", "失败" @@ -436,6 +438,7 @@ class RegulatoryReviewBatch(models.Model): ) batch_no = models.CharField(max_length=64, unique=True) 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) work_dir = models.CharField(max_length=500, blank=True, default="") error_message = models.TextField(blank=True, default="") diff --git a/review_agent/regulatory_review/services/info_extract.py b/review_agent/regulatory_review/services/info_extract.py new file mode 100644 index 0000000..7e23a3a --- /dev/null +++ b/review_agent/regulatory_review/services/info_extract.py @@ -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 "待确认" diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index d51a249..fb29a1d 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -1,10 +1,15 @@ from __future__ import annotations +import json + +from django.conf import settings from django.http import Http404, JsonResponse from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required 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"]) @@ -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: labels = [ ("blocking", "阻断项"), @@ -56,3 +114,15 @@ def _format_risk_summary(risk_summary: dict) -> str: for key, label in labels 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} diff --git a/review_agent/regulatory_review/workflow.py b/review_agent/regulatory_review/workflow.py index 602da66..264b04a 100644 --- a/review_agent/regulatory_review/workflow.py +++ b/review_agent/regulatory_review/workflow.py @@ -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.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.info_extract import detect_regulatory_condition_candidates 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.structure_check import run_structure_check @@ -29,6 +30,7 @@ from .events import record_event NODE_DEFINITIONS = [ ("prepare", "准备", "prepare"), + ("condition_confirm", "适用条件确认", "condition_confirm"), ("rule_scope", "规则范围", "rule_scope"), ("completeness_check", "完整性核查", "completeness_check"), ("text_extract", "文本抽取", "text_extract"), @@ -43,6 +45,10 @@ NODE_DEFINITIONS = [ logger = logging.getLogger("review_agent.regulatory_review.workflow") +class WorkflowPausedForUser(Exception): + pass + + def build_batch_no() -> str: return f"RR-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}" @@ -108,7 +114,11 @@ class RegulatoryWorkflowExecutor: try: for node in self._nodes(): + if node.status == WorkflowNodeRun.Status.SUCCESS: + continue self._run_node(node) + except WorkflowPausedForUser: + return except Exception as exc: logger.exception("Regulatory workflow failed", extra={"batch_id": self.batch.pk}) self.batch.status = RegulatoryReviewBatch.Status.FAILED @@ -155,6 +165,9 @@ class RegulatoryWorkflowExecutor: ) def _execute_node(self, node_code: str) -> None: + if node_code == "condition_confirm": + self._pause_for_condition_confirmation() + return if node_code == "rule_scope": self.rule_set = load_rule_file() return @@ -181,6 +194,34 @@ class RegulatoryWorkflowExecutor: 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: if self.rule_set is None: self.rule_set = load_rule_file() diff --git a/review_agent/urls.py b/review_agent/urls.py index 1a8c6e8..a2be722 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -10,7 +10,10 @@ from .file_summary.views import ( conversation_messages, 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 = [ @@ -64,4 +67,9 @@ urlpatterns = [ regulatory_review_batch_status, name="regulatory_review_batch_status", ), + path( + "api/review-agent/regulatory-review//conditions/", + regulatory_review_confirm_conditions, + name="regulatory_review_confirm_conditions", + ), ] diff --git a/review_agent/views.py b/review_agent/views.py index b85b86c..a6be16f 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -138,6 +138,8 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: "status": batch.status, "error_message": batch.error_message, "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, "nodes": list( WorkflowNodeRun.objects.filter( diff --git a/static/js/app.js b/static/js/app.js index f1d27bb..b41d8a5 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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) { event.preventDefault(); if (!composer || !promptInput || !sendButton || !chatStage) { @@ -955,6 +1009,7 @@ renderExistingAssistantMessages(); refreshWorkflowBatchCarousel(0); bindWorkflowBatchCarouselControls(); + bindConditionConfirmForms(); refreshRunningWorkflowCards(); if (chatScroll) { diff --git a/templates/home.html b/templates/home.html index 55c425f..98a03e8 100644 --- a/templates/home.html +++ b/templates/home.html @@ -240,6 +240,33 @@ {% if batch.error_message %}

{{ batch.error_message }}

{% endif %} + {% if batch.workflow_type == "regulatory_review" and batch.status == "waiting_user" and batch.condition_candidates %} +
+ {% csrf_token %} + 适用条件确认 + {% for field, config in batch.condition_candidates.items %} + + {% endfor %} + +

+
+ {% endif %}
    {% for node in batch.nodes %}
  1. diff --git a/tests/test_regulatory_condition.py b/tests/test_regulatory_condition.py new file mode 100644 index 0000000..ccfc7a5 --- /dev/null +++ b/tests/test_regulatory_condition.py @@ -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 diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index a89823e..f9a21d0 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -50,9 +50,63 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model): 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(): script = open("static/js/app.js", encoding="utf-8").read() assert "workflow_type" in script assert "data-regulatory-status-url-template" in script assert "statusUrlForWorkflow" in script + assert "bindConditionConfirmForms" in script + assert "data-condition-confirm-form" in script diff --git a/tests/test_regulatory_workflow.py b/tests/test_regulatory_workflow.py index 71a0114..d175a04 100644 --- a/tests/test_regulatory_workflow.py +++ b/tests/test_regulatory_workflow.py @@ -102,6 +102,8 @@ def test_start_regulatory_review_workflow_runs_synchronously(django_user_model): user=user, 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) @@ -187,6 +189,8 @@ def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_p user=user, 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)