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):
|
||||
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="")
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<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,
|
||||
"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(
|
||||
|
||||
Reference in New Issue
Block a user