import logging import pytest from review_agent.models import ( Conversation, ExportedSummaryFile, FileAttachment, FileSummaryBatch, FileSummaryItem, Message, RegulatoryIssue, RegulatoryArtifact, RegulatoryReviewBatch, WorkflowEvent, WorkflowNodeRun, ) from review_agent.regulatory_review.workflow import ( NODE_DEFINITIONS, RegulatoryWorkflowExecutor, create_regulatory_review_batch, find_latest_successful_summary_batch, start_regulatory_review_workflow, ) from review_agent.services import stream_message from review_agent.skill_router import SkillRoute, route_message_intent pytestmark = pytest.mark.django_db def test_rule_router_starts_regulatory_review_for_nmpa_keywords(monkeypatch, django_user_model): user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") monkeypatch.setattr( "review_agent.skill_router._route_with_llm", lambda conversation, content, attachments: (_ for _ in ()).throw(ValueError("fallback")), ) route = route_message_intent(conversation, "请做NMPA核查和风险预警") assert route.action == "regulatory_review" assert route.workflow_type == "regulatory_review" assert route.starts_regulatory_review def test_find_latest_successful_summary_batch_ignores_failed_batches(django_user_model): user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") success = FileSummaryBatch.objects.create( conversation=conversation, user=user, batch_no="FS-OK", status=FileSummaryBatch.Status.SUCCESS, ) FileSummaryBatch.objects.create( conversation=conversation, user=user, batch_no="FS-FAILED", status=FileSummaryBatch.Status.FAILED, ) assert find_latest_successful_summary_batch(conversation) == success def test_create_regulatory_review_batch_initializes_nodes(django_user_model): user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="法规核查") summary = FileSummaryBatch.objects.create( conversation=conversation, user=user, batch_no="FS-OK", status=FileSummaryBatch.Status.SUCCESS, ) batch = create_regulatory_review_batch( conversation=conversation, user=user, trigger_message=message, source_summary_batch=summary, ) assert batch.status == RegulatoryReviewBatch.Status.PENDING assert WorkflowNodeRun.objects.filter( workflow_type="regulatory_review", workflow_batch_id=batch.pk, ).count() == len(NODE_DEFINITIONS) assert WorkflowEvent.objects.filter( workflow_type="regulatory_review", workflow_batch_id=batch.pk, event_type="workflow_created", ).exists() def test_start_regulatory_review_workflow_runs_synchronously(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, ) batch = create_regulatory_review_batch( conversation=conversation, 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) batch.refresh_from_db() assert batch.status == RegulatoryReviewBatch.Status.SUCCESS assert WorkflowEvent.objects.filter( workflow_type="regulatory_review", workflow_batch_id=batch.pk, event_type="workflow_completed", ).exists() def test_workflow_continues_when_llm_review_times_out(monkeypatch, settings, django_user_model): settings.REGULATORY_LLM_REVIEW_ALLOW_TEST_CALLS = True 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, ) batch = create_regulatory_review_batch( conversation=conversation, user=user, source_summary_batch=summary, ) batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} batch.save(update_fields=["condition_json"]) monkeypatch.setattr( "review_agent.regulatory_review.services.llm_review.generate_completion", lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("The read operation timed out")), ) start_regulatory_review_workflow(batch, async_run=False) batch.refresh_from_db() assert batch.status == RegulatoryReviewBatch.Status.SUCCESS assert batch.error_message == "" def test_regulatory_workflow_logs_node_and_method_details(caplog, 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, ) batch = create_regulatory_review_batch( conversation=conversation, user=user, source_summary_batch=summary, ) batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} batch.save(update_fields=["condition_json"]) with caplog.at_level(logging.INFO, logger="review_agent.regulatory_review.workflow"): start_regulatory_review_workflow(batch, async_run=False) messages = [record.getMessage() for record in caplog.records] assert any("法规核查工作流开始" in message and batch.batch_no in message for message in messages) assert any("节点开始" in message and "完整性核查" in message for message in messages) assert any("方法执行" in message and "run_completeness_check" in message for message in messages) assert any("节点完成" in message and "完整性核查" in message for message in messages) def test_stream_message_prompts_for_summary_when_missing(monkeypatch, django_user_model): user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") monkeypatch.setattr( "review_agent.services.route_message_intent", lambda conversation, content: SkillRoute( action="regulatory_review", workflow_type="regulatory_review", confidence=0.9, ), ) frames = list(stream_message(conversation, "请做法规核查")) joined = "".join(frames) assert "请先在当前对话右侧上传需要核查的文件或压缩包" in joined assert "我会先自动汇总再继续法规核查" in joined assert not RegulatoryReviewBatch.objects.exists() def test_stream_message_auto_runs_summary_before_regulatory_review( monkeypatch, 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="会话") attachment_path = tmp_path / "application.txt" attachment_path.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8") FileAttachment.objects.create( conversation=conversation, user=user, original_name="application.txt", storage_path=str(attachment_path), file_size=attachment_path.stat().st_size, is_active=True, ) monkeypatch.setattr( "review_agent.services.route_message_intent", lambda conversation, content: SkillRoute( action="regulatory_review", workflow_type="regulatory_review", confidence=0.9, ), ) def finish_summary(batch, async_run=True): batch.status = FileSummaryBatch.Status.SUCCESS batch.save(update_fields=["status"]) monkeypatch.setattr("review_agent.services.start_file_summary_workflow", finish_summary) frames = list(stream_message(conversation, "进行第一章NMPA 法规核查")) joined = "".join(frames) assert "\"workflow_type\": \"file_summary\"" in joined assert "\"workflow_type\": \"regulatory_review\"" in joined assert "已先启动文件目录与页数自动汇总工作流" in joined assert FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS).exists() regulatory = RegulatoryReviewBatch.objects.get(conversation=conversation) assert regulatory.condition_json["rule_scope"]["attachment4_chapter"] == "1" def test_stream_message_starts_regulatory_workflow(monkeypatch, settings, django_user_model): settings.REGULATORY_REVIEW_ASYNC = False user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") FileSummaryBatch.objects.create( conversation=conversation, user=user, batch_no="FS-OK", status=FileSummaryBatch.Status.SUCCESS, ) monkeypatch.setattr( "review_agent.services.route_message_intent", lambda conversation, content: SkillRoute( action="regulatory_review", workflow_type="regulatory_review", confidence=0.9, ), ) frames = list(stream_message(conversation, "请做法规核查")) joined = "".join(frames) assert "workflow_started" in joined assert "\"workflow_type\": \"regulatory_review\"" in joined assert RegulatoryReviewBatch.objects.filter(conversation=conversation).exists() def test_stream_message_records_attachment4_chapter_scope(monkeypatch, settings, django_user_model): settings.REGULATORY_REVIEW_ASYNC = False user = django_user_model.objects.create_user(username="owner", password="pass") conversation = Conversation.objects.create(user=user, title="会话") FileSummaryBatch.objects.create( conversation=conversation, user=user, batch_no="FS-OK", status=FileSummaryBatch.Status.SUCCESS, ) monkeypatch.setattr( "review_agent.services.route_message_intent", lambda conversation, content: SkillRoute( action="regulatory_review", workflow_type="regulatory_review", confidence=0.9, ), ) list(stream_message(conversation, "请做第一章 NMPA 法规核查")) batch = RegulatoryReviewBatch.objects.get(conversation=conversation) assert batch.condition_json["rule_scope"]["attachment4_chapter"] == "1" assert batch.condition_json["rule_scope"]["label"] == "第1章 监管信息" def test_workflow_chapter_scope_only_checks_selected_attachment4_chapter(settings, tmp_path, django_user_model): settings.MEDIA_ROOT = tmp_path settings.REGULATORY_REVIEW_ASYNC = False settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" 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, ) batch = create_regulatory_review_batch( conversation=conversation, user=user, source_summary_batch=summary, ) batch.condition_json = { "confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}, "rule_scope": {"attachment4_chapter": "1", "label": "第1章 监管信息"}, } batch.save(update_fields=["condition_json"]) start_regulatory_review_workflow(batch, async_run=False) issue_codes = list(RegulatoryIssue.objects.filter(batch=batch).values_list("rule_code", flat=True)) assert issue_codes assert all(code.startswith("attachment4_1") for code in issue_codes) assert not any(code.startswith("attachment4_2") for code in issue_codes) def test_workflow_generates_issues_exports_and_assistant_summary(settings, tmp_path, django_user_model): settings.MEDIA_ROOT = tmp_path settings.REGULATORY_REVIEW_ASYNC = False settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" 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, ) ifu_path = tmp_path / "ifu.txt" ifu_path.write_text("产品名称:甲胎蛋白检测试剂盒\n样本要求:血清\n有效期:12个月", encoding="utf-8") FileSummaryItem.objects.create( batch=summary, file_index=1, file_name="说明书.txt", file_type="txt", relative_path="说明书.txt", storage_path=str(ifu_path), ) batch = create_regulatory_review_batch( conversation=conversation, 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) batch.refresh_from_db() assert batch.status == RegulatoryReviewBatch.Status.SUCCESS assert RegulatoryIssue.objects.filter(batch=batch, severity="blocking").exists() assert ExportedSummaryFile.objects.filter( workflow_type="regulatory_review", workflow_batch_id=batch.pk, ).count() == 3 assert RegulatoryArtifact.objects.filter(batch=batch, name="text_extract_status.json").exists() assert RegulatoryArtifact.objects.filter(batch=batch, name="rag_result_json.json").exists() assert conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains="已完成 NMPA").exists() def test_workflow_records_llm_review_artifacts_for_review_nodes( monkeypatch, settings, tmp_path, django_user_model ): settings.MEDIA_ROOT = tmp_path settings.REGULATORY_REVIEW_ASYNC = False settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing-rag" 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, ) ifu_path = tmp_path / "ifu.txt" ifu_path.write_text("产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒", encoding="utf-8") FileSummaryItem.objects.create( batch=summary, file_index=1, file_name="说明书.txt", file_type="txt", relative_path="说明书.txt", storage_path=str(ifu_path), ) batch = create_regulatory_review_batch( conversation=conversation, user=user, source_summary_batch=summary, ) batch.condition_json = {"confirmed": True, "confirmed_conditions": {"product_category": "体外诊断试剂"}} batch.save(update_fields=["condition_json"]) monkeypatch.setattr( "review_agent.regulatory_review.workflow.review_workflow_payload", lambda stage, payload: {"status": "success", "stage": stage, "result": {"reviewed": True}, "error_message": ""}, ) start_regulatory_review_workflow(batch, async_run=False) artifact_names = set(RegulatoryArtifact.objects.filter(batch=batch).values_list("name", flat=True)) assert "llm_review_completeness_check.json" in artifact_names assert "llm_review_text_extract.json" in artifact_names assert "llm_review_structure_check.json" in artifact_names assert "llm_review_consistency_check.json" in artifact_names assert "llm_review_risk_assess.json" in artifact_names def test_workflow_progress_uses_processed_file_counts(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-OK", status=FileSummaryBatch.Status.SUCCESS, ) for index, name in enumerate(["注册信息.txt", "说明书.txt", "综述.txt"], start=1): path = tmp_path / name path.write_text(f"产品名称:甲胎蛋白检测试剂盒\n文件:{name}", encoding="utf-8") FileSummaryItem.objects.create( batch=summary, file_index=index, file_name=name, file_type="txt", relative_path=name, storage_path=str(path), ) batch = create_regulatory_review_batch( conversation=conversation, user=user, source_summary_batch=summary, ) node = WorkflowNodeRun.objects.get( workflow_type="regulatory_review", workflow_batch_id=batch.pk, node_code="text_extract", ) executor = RegulatoryWorkflowExecutor(batch) texts = executor._extract_source_texts(node) node.refresh_from_db() assert len(texts) == 3 assert node.progress == 95 assert "文本抽取 3/3" in node.message assert "综述.txt" in node.message assert WorkflowEvent.objects.filter( workflow_type="regulatory_review", workflow_batch_id=batch.pk, event_type="node_progress", payload__node_code="text_extract", payload__processed=3, payload__total=3, ).exists() def test_review_services_emit_actual_workload_progress_callbacks(django_user_model): from review_agent.regulatory_review.services.completeness_check import run_completeness_check from review_agent.regulatory_review.services.consistency_check import FIELDS, run_consistency_check from review_agent.regulatory_review.services.structure_check import run_structure_check 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, ) rule_set = { "requirements": [ {"code": "r1", "title": "注册信息", "type": "required", "file_keywords": ["注册信息"]}, {"code": "r2", "title": "说明书", "type": "required", "file_keywords": ["说明书"]}, ] } completeness_updates = [] structure_updates = [] consistency_updates = [] run_completeness_check(summary, rule_set, progress_callback=completeness_updates.append) run_structure_check({"注册信息.txt": "注册信息"}, rule_set, progress_callback=structure_updates.append) run_consistency_check({"注册信息.txt": "产品名称:A"}, progress_callback=consistency_updates.append) assert completeness_updates[-1]["processed"] == 2 assert completeness_updates[-1]["total"] == 2 assert completeness_updates[-1]["label"] == "说明书" assert structure_updates[-1]["processed"] == 2 assert structure_updates[-1]["total"] == 2 assert consistency_updates[-1]["processed"] == len(FIELDS) assert consistency_updates[-1]["total"] == len(FIELDS)