diff --git a/review_agent/migrations/0005_alter_regulatoryissue_status.py b/review_agent/migrations/0005_alter_regulatoryissue_status.py new file mode 100644 index 0000000..d23d744 --- /dev/null +++ b/review_agent/migrations/0005_alter_regulatoryissue_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.14 on 2026-06-07 01:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0004_regulatoryreviewbatch_condition_json_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="regulatoryissue", + name="status", + field=models.CharField( + choices=[ + ("open", "待处理"), + ("resolved", "已整改"), + ("accepted", "已接受"), + ("review_passed", "复核通过"), + ("review_failed", "复核未通过"), + ], + default="open", + max_length=20, + ), + ), + ] diff --git a/review_agent/models.py b/review_agent/models.py index 7f70902..3cb703e 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -479,6 +479,8 @@ class RegulatoryIssue(models.Model): OPEN = "open", "待处理" RESOLVED = "resolved", "已整改" ACCEPTED = "accepted", "已接受" + REVIEW_PASSED = "review_passed", "复核通过" + REVIEW_FAILED = "review_failed", "复核未通过" batch = models.ForeignKey( RegulatoryReviewBatch, diff --git a/review_agent/regulatory_review/services/export.py b/review_agent/regulatory_review/services/export.py index b29a591..c9aba09 100644 --- a/review_agent/regulatory_review/services/export.py +++ b/review_agent/regulatory_review/services/export.py @@ -46,12 +46,19 @@ def build_markdown_report(batch: RegulatoryReviewBatch) -> str: "# NMPA 注册资料法规核查报告", "", f"批次号:{batch.batch_no}", - "", - "## 风险汇总", - "", - "| 风险等级 | 数量 |", - "| --- | --- |", ] + regenerated_from = (batch.condition_json or {}).get("regenerated_from") + if regenerated_from: + lines.extend( + [ + "", + "## 复核来源", + "", + f"- 来源法规核查批次:{regenerated_from.get('batch_no')}", + f"- 来源文件汇总批次:{regenerated_from.get('file_summary_batch_no')}", + ] + ) + lines.extend(["", "## 风险汇总", "", "| 风险等级 | 数量 |", "| --- | --- |"]) summary = batch.risk_summary or {} for severity, label in SEVERITY_LABELS.items(): lines.append(f"| {label} | {summary.get(severity, 0)} |") @@ -60,6 +67,14 @@ def build_markdown_report(batch: RegulatoryReviewBatch) -> str: lines.append( f"| {SEVERITY_LABELS.get(issue.severity, issue.severity)} | {issue.title} | {issue.status} | {issue.suggestion or '-'} |" ) + review_records = _review_records(batch) + if review_records: + lines.extend(["", "## 复核记录", "", "| 补充批次 | 问题数 | 通过数 | 未通过数 |", "| --- | --- | --- | --- |"]) + for record in review_records: + items = record.get("items", []) + passed = sum(1 for item in items if item.get("status") == RegulatoryIssue.Status.REVIEW_PASSED) + failed = sum(1 for item in items if item.get("status") == RegulatoryIssue.Status.REVIEW_FAILED) + lines.append(f"| {record.get('file_summary_batch_no')} | {len(items)} | {passed} | {failed} |") return "\n".join(lines) @@ -67,6 +82,7 @@ def build_result_payload(batch: RegulatoryReviewBatch) -> dict[str, object]: return { "batch_no": batch.batch_no, "source_summary_batch": batch.source_summary_batch.batch_no, + "regenerated_from": (batch.condition_json or {}).get("regenerated_from"), "risk_summary": batch.risk_summary, "issues": [ { @@ -82,6 +98,7 @@ def build_result_payload(batch: RegulatoryReviewBatch) -> dict[str, object]: } for issue in batch.issues.order_by("id") ], + "review_records": _review_records(batch), } @@ -165,3 +182,13 @@ def _create_excel_export(batch: RegulatoryReviewBatch, path: Path) -> ExportedSu file_name=path.name, storage_path=str(path), ) + + +def _review_records(batch: RegulatoryReviewBatch) -> list[dict[str, object]]: + records = [] + for artifact in batch.artifacts.filter(metadata__artifact="review_record").order_by("created_at", "id"): + try: + records.append(json.loads(Path(artifact.storage_path).read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + continue + return records diff --git a/review_agent/regulatory_review/services/rectification_review.py b/review_agent/regulatory_review/services/rectification_review.py new file mode 100644 index 0000000..cc0863f --- /dev/null +++ b/review_agent/regulatory_review/services/rectification_review.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import json +from django.utils import timezone + +from review_agent.models import FileSummaryBatch, RegulatoryIssue, RegulatoryReviewBatch +from review_agent.regulatory_review.services.rule_loader import load_rule_file +from review_agent.regulatory_review.storage import save_artifact + + +def review_missing_issues( + *, + batch: RegulatoryReviewBatch, + issue_ids: list[int], + file_summary_batch: FileSummaryBatch, +) -> dict[str, object]: + rule_set = load_rule_file() + rules_by_code = {rule["code"]: rule for rule in rule_set.get("requirements", [])} + items = list(file_summary_batch.items.order_by("file_index")) + record = { + "type": "review_record", + "reviewed_at": timezone.localtime().isoformat(), + "source_review_batch_id": batch.pk, + "source_review_batch_no": batch.batch_no, + "file_summary_batch_id": file_summary_batch.pk, + "file_summary_batch_no": file_summary_batch.batch_no, + "items": [], + } + issues = RegulatoryIssue.objects.filter(batch=batch, pk__in=issue_ids).order_by("id") + for issue in issues: + rule = rules_by_code.get(issue.rule_code, {}) + matched_files = _match_items(items, [*rule.get("file_keywords", []), issue.title]) + passed = bool(matched_files) + issue.status = RegulatoryIssue.Status.REVIEW_PASSED if passed else RegulatoryIssue.Status.REVIEW_FAILED + issue.evidence = { + **(issue.evidence or {}), + "latest_review": { + "file_summary_batch_id": file_summary_batch.pk, + "file_summary_batch_no": file_summary_batch.batch_no, + "matched_files": matched_files, + }, + } + issue.save(update_fields=["status", "evidence", "updated_at"]) + record["items"].append( + { + "issue_id": issue.pk, + "rule_code": issue.rule_code, + "title": issue.title, + "status": issue.status, + "matched_files": matched_files, + } + ) + artifact = save_artifact( + batch, + name=f"review_record_{timezone.now().strftime('%Y%m%d%H%M%S')}.json", + artifact_type="json", + content=json.dumps(record, ensure_ascii=False, indent=2), + metadata={"artifact": "review_record", "file_summary_batch_id": file_summary_batch.pk}, + ) + record["artifact_id"] = artifact.pk + return record + + +def _match_items(items, keywords: list[str]) -> list[dict[str, str]]: + normalized_keywords = [str(keyword).lower() for keyword in keywords if keyword] + matched = [] + for item in items: + haystack = f"{item.file_name} {item.relative_path} {item.directory_level}".lower() + if any(keyword in haystack for keyword in normalized_keywords): + matched.append( + { + "file_name": item.file_name, + "relative_path": item.relative_path, + "directory_level": item.directory_level, + } + ) + return matched diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index fb29a1d..e4206a8 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -7,9 +7,10 @@ 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.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun from review_agent.regulatory_review.events import record_event -from review_agent.regulatory_review.workflow import start_regulatory_review_workflow +from review_agent.regulatory_review.services.rectification_review import review_missing_issues +from review_agent.regulatory_review.workflow import create_regulatory_review_batch, start_regulatory_review_workflow @require_http_methods(["GET"]) @@ -78,6 +79,87 @@ def confirm_conditions(request, batch_id: int): progress=100, message="适用条件已确认", ) + + +@require_http_methods(["POST"]) +@login_required +def start_full_review(request, batch_id: int): + source_batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not source_batch: + raise Http404("批次不存在。") + payload, error_response = _json_payload(request) + if error_response: + return error_response + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=source_batch.conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if not summary_batch: + return JsonResponse({"error": "file_summary_batch_id 不存在或未成功。"}, status=400) + new_batch = create_regulatory_review_batch( + conversation=source_batch.conversation, + user=request.user, + source_summary_batch=summary_batch, + ) + new_batch.condition_json = { + "source_review_batch_id": source_batch.pk, + "regenerated_from": { + "batch_id": source_batch.pk, + "batch_no": source_batch.batch_no, + "file_summary_batch_id": source_batch.source_summary_batch_id, + "file_summary_batch_no": source_batch.source_summary_batch.batch_no, + }, + "confirmed": True, + "confirmed_conditions": source_batch.condition_json.get("confirmed_conditions", {}), + } + new_batch.save(update_fields=["condition_json"]) + record_event( + new_batch, + "full_package_review_started", + {"source_review_batch_id": source_batch.pk, "source_review_batch_no": source_batch.batch_no}, + ) + start_regulatory_review_workflow( + new_batch, + async_run=getattr(settings, "REGULATORY_REVIEW_ASYNC", True), + ) + new_batch.refresh_from_db() + return JsonResponse( + { + "batch": { + "id": new_batch.pk, + "workflow_type": "regulatory_review", + "batch_no": new_batch.batch_no, + "status": new_batch.status, + "source_review_batch_id": source_batch.pk, + } + } + ) + + +@require_http_methods(["POST"]) +@login_required +def review_issues(request, batch_id: int): + batch = RegulatoryReviewBatch.objects.filter(pk=batch_id, user=request.user).first() + if not batch: + raise Http404("批次不存在。") + payload, error_response = _json_payload(request) + if error_response: + return error_response + issue_ids = payload.get("issue_ids") + if not isinstance(issue_ids, list): + return JsonResponse({"error": "issue_ids 必须是列表。"}, status=400) + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=batch.conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if not summary_batch: + return JsonResponse({"error": "file_summary_batch_id 不存在或未成功。"}, status=400) + record = review_missing_issues(batch=batch, issue_ids=[int(item) for item in issue_ids], file_summary_batch=summary_batch) + return JsonResponse({"review_record": record}) record_event( batch, "condition_confirmed", @@ -126,3 +208,10 @@ def _normalize_conditions(conditions: dict) -> dict[str, str]: "intended_use", ] return {key: str(conditions.get(key) or "").strip() for key in allowed} + + +def _json_payload(request): + try: + return json.loads(request.body.decode("utf-8") or "{}"), None + except json.JSONDecodeError: + return {}, JsonResponse({"error": "请求体不是有效 JSON。"}, status=400) diff --git a/review_agent/urls.py b/review_agent/urls.py index a2be722..50f4c32 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -13,6 +13,8 @@ from .file_summary.views import ( from .regulatory_review.views import ( batch_status as regulatory_review_batch_status, confirm_conditions as regulatory_review_confirm_conditions, + review_issues as regulatory_review_review_issues, + start_full_review as regulatory_review_start_full_review, ) @@ -72,4 +74,14 @@ urlpatterns = [ regulatory_review_confirm_conditions, name="regulatory_review_confirm_conditions", ), + path( + "api/review-agent/regulatory-review//full-review/", + regulatory_review_start_full_review, + name="regulatory_review_start_full_review", + ), + path( + "api/review-agent/regulatory-review//issue-review/", + regulatory_review_review_issues, + name="regulatory_review_review_issues", + ), ] diff --git a/tests/test_regulatory_rectification.py b/tests/test_regulatory_rectification.py new file mode 100644 index 0000000..831c1fc --- /dev/null +++ b/tests/test_regulatory_rectification.py @@ -0,0 +1,133 @@ +import json + +import pytest +from django.urls import reverse + +from review_agent.models import ( + Conversation, + FileSummaryBatch, + FileSummaryItem, + RegulatoryArtifact, + RegulatoryIssue, + RegulatoryReviewBatch, +) +from review_agent.regulatory_review.services.export import build_markdown_report, build_result_payload +from review_agent.regulatory_review.services.rectification_review import review_missing_issues + + +pytestmark = pytest.mark.django_db + + +def _make_review_batch(user): + conversation = Conversation.objects.create(user=user, title="会话") + original_summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-ORIGINAL", + status=FileSummaryBatch.Status.SUCCESS, + ) + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=original_summary, + batch_no="RR-ORIGINAL", + status=RegulatoryReviewBatch.Status.SUCCESS, + ) + return conversation, original_summary, batch + + +def test_start_full_package_review_creates_new_traceable_batch(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, _original_summary, original_batch = _make_review_batch(user) + new_summary = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-NEW", + status=FileSummaryBatch.Status.SUCCESS, + ) + client.force_login(user) + + response = client.post( + reverse("regulatory_review_start_full_review", args=[original_batch.pk]), + data=json.dumps({"file_summary_batch_id": new_summary.pk}), + content_type="application/json", + ) + + assert response.status_code == 200 + new_batch = RegulatoryReviewBatch.objects.exclude(pk=original_batch.pk).get() + assert new_batch.source_summary_batch == new_summary + assert new_batch.condition_json["source_review_batch_id"] == original_batch.pk + assert new_batch.condition_json["regenerated_from"]["batch_no"] == "RR-ORIGINAL" + + +def test_review_missing_issues_updates_status_and_writes_record(settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation, _original_summary, batch = _make_review_batch(user) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="attachment4_5_3_label", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.HIGH, + title="缺少标签样稿", + suggestion="请补充标签样稿。", + ) + supplement = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-SUPPLEMENT", + status=FileSummaryBatch.Status.SUCCESS, + ) + FileSummaryItem.objects.create( + batch=supplement, + file_index=1, + directory_level="5. 产品说明书和标签样稿", + file_name="标签样稿.pdf", + file_type="pdf", + relative_path="5.3 标签样稿/标签样稿.pdf", + storage_path="x/label.pdf", + ) + + record = review_missing_issues(batch=batch, issue_ids=[issue.pk], file_summary_batch=supplement) + + issue.refresh_from_db() + assert issue.status == RegulatoryIssue.Status.REVIEW_PASSED + assert record["items"][0]["status"] == "review_passed" + assert RegulatoryArtifact.objects.filter(batch=batch, name__startswith="review_record").exists() + + +def test_missing_issue_review_endpoint_and_report_output(client, settings, tmp_path, django_user_model): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation, _original_summary, batch = _make_review_batch(user) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="attachment4_6_quality_system", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.HIGH, + title="缺少质量管理体系文件", + suggestion="请补充质量管理体系文件。", + ) + supplement = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-SUPPLEMENT", + status=FileSummaryBatch.Status.SUCCESS, + ) + client.force_login(user) + + response = client.post( + reverse("regulatory_review_review_issues", args=[batch.pk]), + data=json.dumps({"issue_ids": [issue.pk], "file_summary_batch_id": supplement.pk}), + content_type="application/json", + ) + + issue.refresh_from_db() + payload = build_result_payload(batch) + markdown = build_markdown_report(batch) + assert response.status_code == 200 + assert issue.status == RegulatoryIssue.Status.REVIEW_FAILED + assert payload["review_records"][0]["file_summary_batch_no"] == "FS-SUPPLEMENT" + assert "复核记录" in markdown