diff --git a/review_agent/file_summary/events.py b/review_agent/file_summary/events.py index 3d9f80c..384f17c 100644 --- a/review_agent/file_summary/events.py +++ b/review_agent/file_summary/events.py @@ -4,7 +4,14 @@ from review_agent.models import FileSummaryBatch, WorkflowEvent def record_event(batch: FileSummaryBatch, event_type: str, payload: dict | None = None) -> WorkflowEvent: - return WorkflowEvent.objects.create(batch=batch, event_type=event_type, payload=payload or {}) + return WorkflowEvent.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + conversation=batch.conversation, + event_type=event_type, + payload=payload or {}, + ) def serialize_event(event: WorkflowEvent) -> dict[str, object]: diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py index b5b370d..b203cb3 100644 --- a/review_agent/file_summary/services/export_excel.py +++ b/review_agent/file_summary/services/export_excel.py @@ -54,6 +54,9 @@ def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: workbook.save(path) exported = ExportedSummaryFile.objects.create( batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + export_category="summary", export_type=ExportedSummaryFile.ExportType.EXCEL, file_name=path.name, storage_path=str(path), diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py index a1f9fc9..5543daa 100644 --- a/review_agent/file_summary/services/report.py +++ b/review_agent/file_summary/services/report.py @@ -65,6 +65,9 @@ def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFi path.write_text(content, encoding="utf-8") exported = ExportedSummaryFile.objects.create( batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + export_category="summary", export_type=ExportedSummaryFile.ExportType.MARKDOWN, file_name=path.name, storage_path=str(path), diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index a87fee9..8be64f3 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -283,11 +283,12 @@ def export_download(request, export_id: int): extra={"export_id": exported.pk, "storage_path": exported.storage_path}, ) return JsonResponse({"error": "文件不存在。"}, status=404) - content_type = ( - "text/markdown; charset=utf-8" - if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN - else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ) + content_types = { + ExportedSummaryFile.ExportType.MARKDOWN: "text/markdown; charset=utf-8", + ExportedSummaryFile.ExportType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ExportedSummaryFile.ExportType.JSON: "application/json; charset=utf-8", + } + content_type = content_types.get(exported.export_type, "application/octet-stream") logger.info( "Export download started", extra={ diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 5184ad9..fe5378f 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -112,7 +112,14 @@ def create_file_summary_batch( attachment.save(update_fields=["upload_status"]) for code, name, _skill_name in NODE_DEFINITIONS: - WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name) + WorkflowNodeRun.objects.create( + batch=batch, + workflow_type="file_summary", + workflow_batch_id=batch.pk, + node_group="file_summary", + node_code=code, + node_name=name, + ) record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) logger.info( diff --git a/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py b/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py new file mode 100644 index 0000000..606c95b --- /dev/null +++ b/review_agent/migrations/0003_regulatoryartifact_regulatoryissue_and_more.py @@ -0,0 +1,479 @@ +# Generated by Django 5.2.14 on 2026-06-06 16:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "review_agent", + "0002_fileattachment_filesummarybatch_exportedsummaryfile_and_more", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="RegulatoryArtifact", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "artifact_type", + models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ("text", "文本"), + ], + max_length=20, + ), + ), + ("name", models.CharField(max_length=160)), + ("storage_path", models.CharField(max_length=500)), + ( + "content_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "db_table": "ra_regulatory_artifact", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryIssue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("rule_code", models.CharField(blank=True, default="", max_length=120)), + ( + "category", + models.CharField( + choices=[ + ("completeness", "完整性"), + ("structure", "章节"), + ("consistency", "一致性"), + ("rag", "法规依据"), + ], + max_length=40, + ), + ), + ( + "severity", + models.CharField( + choices=[ + ("blocking", "阻断项"), + ("high", "高风险"), + ("medium", "中风险"), + ("low", "低风险"), + ("info", "提示"), + ], + max_length=20, + ), + ), + ("title", models.CharField(max_length=255)), + ("detail", models.TextField(blank=True, default="")), + ("suggestion", models.TextField(blank=True, default="")), + ( + "status", + models.CharField( + choices=[ + ("open", "待处理"), + ("resolved", "已整改"), + ("accepted", "已接受"), + ], + default="open", + max_length=20, + ), + ), + ("evidence", models.JSONField(blank=True, default=dict)), + ("citations", models.JSONField(blank=True, default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_regulatory_issue", + "ordering": ["severity", "id"], + }, + ), + migrations.CreateModel( + name="RegulatoryNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "channel", + models.CharField( + choices=[("mock", "模拟"), ("feishu", "飞书")], + default="mock", + max_length=20, + ), + ), + ("target", models.CharField(blank=True, default="", max_length=160)), + ("payload", models.JSONField(blank=True, default=dict)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("sent", "已发送"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "ra_regulatory_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryReviewBatch", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("batch_no", models.CharField(max_length=64, unique=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "待执行"), + ("running", "执行中"), + ("success", "成功"), + ("failed", "失败"), + ], + default="pending", + max_length=20, + ), + ), + ("risk_summary", models.JSONField(blank=True, default=dict)), + ("work_dir", models.CharField(blank=True, default="", max_length=500)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ], + options={ + "db_table": "ra_regulatory_review_batch", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RegulatoryRuleVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("code", models.CharField(max_length=80, unique=True)), + ("name", models.CharField(max_length=160)), + ("yaml_path", models.CharField(max_length=500)), + ("yaml_hash", models.CharField(max_length=128)), + ( + "rag_collection", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "rag_index_version", + models.CharField(blank=True, default="", max_length=80), + ), + ( + "rag_index_hash", + models.CharField(blank=True, default="", max_length=128), + ), + ( + "status", + models.CharField( + choices=[ + ("active", "启用"), + ("outdated", "待更新"), + ("disabled", "停用"), + ], + default="active", + max_length=20, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_regulatory_rule_version", + "ordering": ["-updated_at", "-id"], + }, + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="export_category", + field=models.CharField(blank=True, default="summary", max_length=40), + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="exportedsummaryfile", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflowevent", + name="conversation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_events", + to="review_agent.conversation", + ), + ), + migrations.AddField( + model_name="workflowevent", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflowevent", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflownoderun", + name="node_group", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AddField( + model_name="workflownoderun", + name="workflow_batch_id", + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflownoderun", + name="workflow_type", + field=models.CharField(blank=True, default="file_summary", max_length=40), + ), + migrations.AlterField( + model_name="exportedsummaryfile", + name="export_type", + field=models.CharField( + choices=[ + ("markdown", "Markdown"), + ("excel", "Excel"), + ("json", "JSON"), + ], + max_length=20, + ), + ), + migrations.AlterField( + model_name="workflowevent", + name="batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="review_agent.filesummarybatch", + ), + ), + migrations.AlterField( + model_name="workflownoderun", + name="batch", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="node_runs", + to="review_agent.filesummarybatch", + ), + ), + migrations.AddIndex( + model_name="exportedsummaryfile", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_export_workflow", + ), + ), + migrations.AddIndex( + model_name="workflowevent", + index=models.Index( + fields=["workflow_type", "workflow_batch_id", "id"], + name="idx_ra_event_workflow_id", + ), + ), + migrations.AddIndex( + model_name="workflownoderun", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_node_workflow", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="conversation", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="regulatory_review_batches", + to="review_agent.conversation", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="source_summary_batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="regulatory_review_batches", + to="review_agent.filesummarybatch", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="trigger_message", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="triggered_regulatory_batches", + to="review_agent.message", + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="review_regulatory_batches", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="regulatorynotificationrecord", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddField( + model_name="regulatoryissue", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issues", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddField( + model_name="regulatoryartifact", + name="batch", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="artifacts", + to="review_agent.regulatoryreviewbatch", + ), + ), + migrations.AddIndex( + model_name="regulatoryruleversion", + index=models.Index( + fields=["code", "status"], name="idx_ra_rule_code_status" + ), + ), + migrations.AddField( + model_name="regulatoryreviewbatch", + name="rule_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="review_batches", + to="review_agent.regulatoryruleversion", + ), + ), + migrations.AddIndex( + model_name="regulatorynotificationrecord", + index=models.Index( + fields=["batch", "status"], name="idx_ra_rr_notify_status" + ), + ), + migrations.AddIndex( + model_name="regulatoryissue", + index=models.Index( + fields=["batch", "severity"], name="idx_ra_rr_issue_severity" + ), + ), + migrations.AddIndex( + model_name="regulatoryissue", + index=models.Index( + fields=["batch", "category"], name="idx_ra_rr_issue_category" + ), + ), + migrations.AddIndex( + model_name="regulatoryartifact", + index=models.Index( + fields=["batch", "artifact_type"], name="idx_ra_rr_artifact_type" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["conversation", "created_at"], name="idx_ra_rr_batch_conv" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["user", "created_at"], name="idx_ra_rr_batch_user" + ), + ), + migrations.AddIndex( + model_name="regulatoryreviewbatch", + index=models.Index( + fields=["status", "created_at"], name="idx_ra_rr_batch_status" + ), + ), + ] diff --git a/review_agent/models.py b/review_agent/models.py index a5af82c..4a404e5 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -261,8 +261,13 @@ class WorkflowNodeRun(models.Model): batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, + null=True, + blank=True, related_name="node_runs", ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + node_group = models.CharField(max_length=40, blank=True, default="file_summary") node_code = models.CharField(max_length=40) node_name = models.CharField(max_length=80) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) @@ -278,6 +283,10 @@ class WorkflowNodeRun(models.Model): ] indexes = [ models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"), + models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_node_workflow", + ), ] @@ -287,8 +296,19 @@ class WorkflowEvent(models.Model): batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, + null=True, + blank=True, related_name="events", ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="workflow_events", + ) event_type = models.CharField(max_length=40) payload = models.JSONField(default=dict) created_at = models.DateTimeField(auto_now_add=True) @@ -299,6 +319,10 @@ class WorkflowEvent(models.Model): indexes = [ models.Index(fields=["batch", "id"], name="idx_ra_event_batch_id"), models.Index(fields=["batch", "created_at"], name="idx_ra_event_batch_created"), + models.Index( + fields=["workflow_type", "workflow_batch_id", "id"], + name="idx_ra_event_workflow_id", + ), ] @@ -308,6 +332,7 @@ class ExportedSummaryFile(models.Model): class ExportType(models.TextChoices): MARKDOWN = "markdown", "Markdown" EXCEL = "excel", "Excel" + JSON = "json", "JSON" class Status(models.TextChoices): SUCCESS = "success", "成功" @@ -318,6 +343,9 @@ class ExportedSummaryFile(models.Model): on_delete=models.CASCADE, related_name="exports", ) + workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") + workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) + export_category = models.CharField(max_length=40, blank=True, default="summary") export_type = models.CharField(max_length=20, choices=ExportType.choices) file_name = models.CharField(max_length=255) storage_path = models.CharField(max_length=500) @@ -331,4 +359,210 @@ class ExportedSummaryFile(models.Model): indexes = [ models.Index(fields=["batch", "export_type"], name="idx_ra_export_batch_type"), models.Index(fields=["batch", "created_at"], name="idx_ra_export_batch_created"), + models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_export_workflow", + ), + ] + + +class RegulatoryRuleVersion(models.Model): + """Tracks the local regulatory rule YAML and its matching RAG index.""" + + class Status(models.TextChoices): + ACTIVE = "active", "启用" + OUTDATED = "outdated", "待更新" + DISABLED = "disabled", "停用" + + code = models.CharField(max_length=80, unique=True) + name = models.CharField(max_length=160) + yaml_path = models.CharField(max_length=500) + yaml_hash = models.CharField(max_length=128) + rag_collection = models.CharField(max_length=120, blank=True, default="") + rag_index_version = models.CharField(max_length=80, blank=True, default="") + rag_index_hash = models.CharField(max_length=128, blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_regulatory_rule_version" + ordering = ["-updated_at", "-id"] + indexes = [ + models.Index(fields=["code", "status"], name="idx_ra_rule_code_status"), + ] + + def __str__(self) -> str: + return self.code + + +class RegulatoryReviewBatch(models.Model): + """Tracks one NMPA regulatory review workflow run.""" + + class Status(models.TextChoices): + PENDING = "pending", "待执行" + RUNNING = "running", "执行中" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="regulatory_review_batches", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="review_regulatory_batches", + ) + trigger_message = models.ForeignKey( + Message, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="triggered_regulatory_batches", + ) + source_summary_batch = models.ForeignKey( + FileSummaryBatch, + on_delete=models.PROTECT, + related_name="regulatory_review_batches", + ) + rule_version = models.ForeignKey( + RegulatoryRuleVersion, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="review_batches", + ) + batch_no = models.CharField(max_length=64, unique=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + 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="") + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_regulatory_review_batch" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["conversation", "created_at"], name="idx_ra_rr_batch_conv"), + models.Index(fields=["user", "created_at"], name="idx_ra_rr_batch_user"), + models.Index(fields=["status", "created_at"], name="idx_ra_rr_batch_status"), + ] + + def __str__(self) -> str: + return self.batch_no + + +class RegulatoryIssue(models.Model): + """Stores one regulatory finding after risk consolidation.""" + + class Severity(models.TextChoices): + BLOCKING = "blocking", "阻断项" + HIGH = "high", "高风险" + MEDIUM = "medium", "中风险" + LOW = "low", "低风险" + INFO = "info", "提示" + + class Category(models.TextChoices): + COMPLETENESS = "completeness", "完整性" + STRUCTURE = "structure", "章节" + CONSISTENCY = "consistency", "一致性" + RAG = "rag", "法规依据" + + class Status(models.TextChoices): + OPEN = "open", "待处理" + RESOLVED = "resolved", "已整改" + ACCEPTED = "accepted", "已接受" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="issues", + ) + rule_code = models.CharField(max_length=120, blank=True, default="") + category = models.CharField(max_length=40, choices=Category.choices) + severity = models.CharField(max_length=20, choices=Severity.choices) + title = models.CharField(max_length=255) + detail = models.TextField(blank=True, default="") + suggestion = models.TextField(blank=True, default="") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) + evidence = models.JSONField(default=dict, blank=True) + citations = models.JSONField(default=list, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_regulatory_issue" + ordering = ["severity", "id"] + indexes = [ + models.Index(fields=["batch", "severity"], name="idx_ra_rr_issue_severity"), + models.Index(fields=["batch", "category"], name="idx_ra_rr_issue_category"), + ] + + def __str__(self) -> str: + return self.title + + +class RegulatoryArtifact(models.Model): + """Stores regulatory review intermediate and exported artifacts.""" + + class ArtifactType(models.TextChoices): + MARKDOWN = "markdown", "Markdown" + EXCEL = "excel", "Excel" + JSON = "json", "JSON" + TEXT = "text", "文本" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="artifacts", + ) + artifact_type = models.CharField(max_length=20, choices=ArtifactType.choices) + name = models.CharField(max_length=160) + storage_path = models.CharField(max_length=500) + content_hash = models.CharField(max_length=128, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_regulatory_artifact" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "artifact_type"], name="idx_ra_rr_artifact_type"), + ] + + +class RegulatoryNotificationRecord(models.Model): + """Stores mock notification records for future Feishu integration.""" + + class Channel(models.TextChoices): + MOCK = "mock", "模拟" + FEISHU = "feishu", "飞书" + + class Status(models.TextChoices): + PENDING = "pending", "待发送" + SENT = "sent", "已发送" + FAILED = "failed", "失败" + + batch = models.ForeignKey( + RegulatoryReviewBatch, + on_delete=models.CASCADE, + related_name="notifications", + ) + channel = models.CharField(max_length=20, choices=Channel.choices, default=Channel.MOCK) + target = models.CharField(max_length=160, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "ra_regulatory_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["batch", "status"], name="idx_ra_rr_notify_status"), ] diff --git a/tests/test_regulatory_models.py b/tests/test_regulatory_models.py new file mode 100644 index 0000000..9ebd390 --- /dev/null +++ b/tests/test_regulatory_models.py @@ -0,0 +1,137 @@ +import pytest + +from review_agent.models import ( + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + Message, + RegulatoryArtifact, + RegulatoryIssue, + RegulatoryNotificationRecord, + RegulatoryReviewBatch, + RegulatoryRuleVersion, + WorkflowEvent, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_regulatory_models_store_batch_issue_artifact_and_notification(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="法规核查") + trigger = Message.objects.create( + conversation=conversation, + role=Message.Role.USER, + content="请做NMPA法规核查", + ) + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-READY", + status=FileSummaryBatch.Status.SUCCESS, + ) + rule_version = RegulatoryRuleVersion.objects.create( + code="nmpa_ivd_registration_v1", + name="NMPA IVD 注册资料 Demo 规则", + yaml_path="review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml", + yaml_hash="abc123", + rag_collection="nmpa_ivd_registration_v1", + rag_index_version="idx-1", + rag_index_hash="hash-1", + status=RegulatoryRuleVersion.Status.ACTIVE, + ) + + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + trigger_message=trigger, + source_summary_batch=summary_batch, + rule_version=rule_version, + batch_no="RR-202606070001-abcdef", + ) + issue = RegulatoryIssue.objects.create( + batch=batch, + rule_code="registration_test_report", + category=RegulatoryIssue.Category.COMPLETENESS, + severity=RegulatoryIssue.Severity.BLOCKING, + title="缺少注册检验报告", + suggestion="请补充注册检验报告并复核。", + evidence={"matched_files": []}, + citations=[{"source": "法规.doc", "text": "注册检验报告"}], + ) + artifact = RegulatoryArtifact.objects.create( + batch=batch, + artifact_type=RegulatoryArtifact.ArtifactType.JSON, + name="结果包", + storage_path="media/regulatory_review/result.json", + content_hash="hash", + ) + notification = RegulatoryNotificationRecord.objects.create( + batch=batch, + channel=RegulatoryNotificationRecord.Channel.MOCK, + target="todo-plan", + payload={"issue_id": issue.pk}, + ) + + assert batch.status == RegulatoryReviewBatch.Status.PENDING + assert batch.source_summary_batch == summary_batch + assert issue.status == RegulatoryIssue.Status.OPEN + assert artifact.artifact_type == RegulatoryArtifact.ArtifactType.JSON + assert notification.status == RegulatoryNotificationRecord.Status.PENDING + + +def test_generic_workflow_fields_support_file_summary_and_regulatory_batches(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary_batch = FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-GENERIC", + ) + regulatory_batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary_batch, + batch_no="RR-GENERIC", + ) + + file_node = WorkflowNodeRun.objects.create( + batch=summary_batch, + workflow_type="file_summary", + workflow_batch_id=summary_batch.pk, + node_group="file_summary", + node_code="inventory", + node_name="文件扫描", + ) + regulatory_node = WorkflowNodeRun.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + node_group="regulatory_review", + node_code="prepare", + node_name="准备", + ) + event = WorkflowEvent.objects.create( + batch=summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + conversation=conversation, + event_type="workflow_created", + payload={"batch_no": regulatory_batch.batch_no}, + ) + exported = ExportedSummaryFile.objects.create( + batch=summary_batch, + workflow_type="regulatory_review", + workflow_batch_id=regulatory_batch.pk, + export_category="result_package", + export_type=ExportedSummaryFile.ExportType.JSON, + file_name="result.json", + storage_path="media/regulatory_review/result.json", + ) + + assert file_node.batch == summary_batch + assert regulatory_node.batch is None + assert regulatory_node.workflow_batch_id == regulatory_batch.pk + assert event.conversation == conversation + assert exported.export_type == ExportedSummaryFile.ExportType.JSON