merge: 合并V2到master
# Conflicts: # .gitignore # README.md # config/asgi.py # config/settings.py # config/urls.py # config/wsgi.py # manage.py # requirements.txt # templates/base.html # tests/conftest.py
This commit is contained in:
@@ -2,13 +2,7 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def force_mock_llm_provider_for_tests(monkeypatch):
|
||||
"""
|
||||
测试环境固定使用 mock Provider。
|
||||
def mock_regulatory_info_package_page_count(monkeypatch):
|
||||
from review_agent.regulatory_info_package.services import package_generate
|
||||
|
||||
当前项目会从根目录 `.env` 自动读取真实模型配置,这对本地运行很有帮助,
|
||||
但单元测试和页面回归测试不应该依赖外部网络或真实密钥状态。
|
||||
因此这里统一覆盖为 mock,保证测试稳定、可重复。
|
||||
"""
|
||||
monkeypatch.setenv("LLM_PROVIDER", "mock")
|
||||
monkeypatch.setenv("LLM_MODEL", "mock-model")
|
||||
monkeypatch.setattr(package_generate, "count_document_pages", lambda _path: 1)
|
||||
|
||||
8
tests/fixtures/regulatory/attachment4_outline.json
vendored
Normal file
8
tests/fixtures/regulatory/attachment4_outline.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"code": "1", "title": "监管信息", "children": ["章节目录", "申请表", "术语/缩写词列表", "产品列表", "关联文件", "申报前与监管机构的联系情况和沟通记录", "符合性声明"]},
|
||||
{"code": "2", "title": "综述资料", "children": ["章节目录", "概述", "产品描述", "预期用途", "申报产品上市历史", "其他需说明的内容"]},
|
||||
{"code": "3", "title": "非临床资料", "children": ["章节目录", "产品风险管理资料", "体外诊断试剂安全和性能基本原则清单", "产品技术要求及检验报告", "分析性能研究", "稳定性研究", "阳性判断值或参考区间研究", "其他资料"]},
|
||||
{"code": "4", "title": "临床评价资料", "children": ["章节目录", "临床评价资料"]},
|
||||
{"code": "5", "title": "产品说明书和标签样稿", "children": ["章节目录", "产品说明书", "标签样稿", "其他资料"]},
|
||||
{"code": "6", "title": "质量管理体系文件", "children": ["综述", "章节目录", "生产制造信息", "质量管理体系程序", "管理职责程序", "资源管理程序", "产品实现程序", "质量管理体系的测量/分析和改进程序", "其他质量体系程序信息", "质量管理体系核查文件"]}
|
||||
]
|
||||
217
tests/test_application_form_fill_field_extract.py
Normal file
217
tests/test_application_form_fill_field_extract.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.field_extract import (
|
||||
extract_by_llm,
|
||||
extract_by_rules,
|
||||
run_parallel_extract,
|
||||
save_field_extract_result,
|
||||
)
|
||||
from review_agent.application_form_fill.services.template_config import load_template_config
|
||||
from review_agent.application_form_fill.services.template_select import select_templates
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillArtifact,
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
FileSummaryBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _registration_specs():
|
||||
config = load_template_config()
|
||||
specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册")
|
||||
return specs
|
||||
|
||||
|
||||
def test_rule_extracts_registration_certificate_fields():
|
||||
texts = {
|
||||
"产品说明书.txt": "\n".join(
|
||||
[
|
||||
"产品名称:甲胎蛋白检测试剂盒",
|
||||
"包装规格:20人份/盒",
|
||||
"预期用途:用于体外定量检测人血清中甲胎蛋白含量",
|
||||
"产品储存条件及有效期:2-8℃保存,有效期12个月",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result = extract_by_rules(texts, _registration_specs())
|
||||
|
||||
values = {field["key"]: field for field in result["fields"]}
|
||||
assert values["product_name"]["value"] == "甲胎蛋白检测试剂盒"
|
||||
assert values["intended_use"]["source_role"] == "说明书"
|
||||
assert "2-8℃保存" in values["storage_condition_and_validity"]["value"]
|
||||
assert values["package_specification"]["extractor"] == "rule"
|
||||
|
||||
|
||||
def test_rule_extracts_bracket_sections_from_instructions():
|
||||
texts = {
|
||||
"目标产品说明书.docx": "\n".join(
|
||||
[
|
||||
"【产品名称】",
|
||||
"新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)",
|
||||
"【包装规格】",
|
||||
"规格A:24人份/盒、48人份/盒、96人份/盒。",
|
||||
"规格B:24人份/盒、48人份/盒、96人份/盒。",
|
||||
"【预期用途】",
|
||||
"本试剂盒用于体外定性检测咽拭子、痰液样本中新型冠状病毒(2019-nCoV)ORF1ab和N基因。",
|
||||
"【检测原理】",
|
||||
"本段不应进入预期用途。",
|
||||
"【主要组成成分】",
|
||||
"表1 规格A大包装试剂盒组成成分",
|
||||
"组分\t规格\t数量",
|
||||
"PCR反应液\t24人份/盒\t1管",
|
||||
"【储存条件及有效期】",
|
||||
"-20±5℃的避光条件,有效期12个月。",
|
||||
"反复冻融次数不得超过4次。",
|
||||
"【样本要求】",
|
||||
"适用样本类型:咽拭子、痰液。",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result = extract_by_rules(texts, _registration_specs())
|
||||
|
||||
values = {field["key"]: field["value"] for field in result["fields"]}
|
||||
assert values["product_name"] == "新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)"
|
||||
assert "规格A" in values["package_specification"]
|
||||
assert "检测原理" not in values["intended_use"]
|
||||
assert "PCR反应液" in values["main_components"]
|
||||
assert "-20±5℃" in values["storage_condition_and_validity"]
|
||||
|
||||
|
||||
def test_rule_maps_agent_fields_to_manufacturer_company_for_now():
|
||||
texts = {
|
||||
"目标产品说明书.docx": "\n".join(
|
||||
[
|
||||
"生产企业名称:卡尤迪生物科技宜兴有限公司",
|
||||
"生产企业住所:江苏省宜兴经济技术开发区杏里路10号",
|
||||
"生产地址:江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result = extract_by_rules(texts, _registration_specs())
|
||||
|
||||
values = {field["key"]: field["value"] for field in result["fields"]}
|
||||
assert values["agent_name"] == "卡尤迪生物科技宜兴有限公司"
|
||||
assert values["agent_address"] == "江苏省宜兴经济技术开发区杏里路10号"
|
||||
assert values["manufacturer_address"] == "江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室"
|
||||
|
||||
|
||||
def test_rule_stops_product_name_before_application_form_instructions():
|
||||
texts = {
|
||||
"境内体外诊断试剂注册申请表.docx": "\n".join(
|
||||
[
|
||||
"产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)",
|
||||
"申请人:",
|
||||
"卡尤迪生物科技宜兴有限公司",
|
||||
"国家药品监督管理局",
|
||||
"填表说明",
|
||||
"1. 本表依据《体外诊断注册与备案管理办法》制定。",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result = extract_by_rules(texts, _registration_specs())
|
||||
|
||||
values = {field["key"]: field["value"] for field in result["fields"]}
|
||||
assert values["product_name"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"
|
||||
assert "填表说明" not in values["product_name"]
|
||||
|
||||
|
||||
def test_rule_ignores_generic_enterprise_name_from_application_form():
|
||||
texts = {
|
||||
"CH1.4 申请表.docx": "\n".join(
|
||||
[
|
||||
"注册人制度\t是 企业名称:否",
|
||||
"优先通道申请 应急通道 同品种首个产品首次申报",
|
||||
"临床试验",
|
||||
"临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院",
|
||||
"应附资料",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result = extract_by_rules(texts, _registration_specs())
|
||||
|
||||
values = {field["key"]: field["value"] for field in result["fields"]}
|
||||
assert "applicant_name" not in values
|
||||
assert "agent_name" not in values
|
||||
|
||||
|
||||
def test_llm_extract_parses_structured_json(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.application_form_fill.services.field_extract.generate_completion",
|
||||
lambda messages, temperature=0.0: json.dumps(
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"key": "product_name",
|
||||
"label": "产品名称",
|
||||
"value": "甲胎蛋白检测试剂盒",
|
||||
"source_file": "说明书.txt",
|
||||
"source_role": "说明书",
|
||||
"evidence": "产品名称:甲胎蛋白检测试剂盒",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
],
|
||||
"checklist_items": [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = extract_by_llm({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs())
|
||||
|
||||
assert result["fields"][0]["extractor"] == "llm"
|
||||
assert result["fields"][0]["value"] == "甲胎蛋白检测试剂盒"
|
||||
|
||||
|
||||
def test_llm_extract_failure_returns_empty_result(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.application_form_fill.services.field_extract.generate_completion",
|
||||
lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("timeout")),
|
||||
)
|
||||
|
||||
result = extract_by_llm({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs())
|
||||
|
||||
assert result["fields"] == []
|
||||
assert "timeout" in result["error_message"]
|
||||
|
||||
|
||||
def test_parallel_extract_preserves_rule_result_when_llm_fails(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.application_form_fill.services.field_extract.generate_completion",
|
||||
lambda messages, temperature=0.0: (_ for _ in ()).throw(TimeoutError("timeout")),
|
||||
)
|
||||
|
||||
payload = run_parallel_extract({"说明书.txt": "产品名称:甲胎蛋白检测试剂盒"}, _registration_specs())
|
||||
|
||||
assert payload["regex_results"]["fields"]
|
||||
assert payload["llm_results"]["fields"] == []
|
||||
assert payload["selected_templates"] == ["registration_certificate"]
|
||||
|
||||
|
||||
def test_save_field_extract_result_creates_json_artifact(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-FIELD")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-FIELD",
|
||||
work_dir=str(tmp_path / "aff" / "AFF-FIELD"),
|
||||
)
|
||||
|
||||
artifact = save_field_extract_result(batch, {"regex_results": {"fields": []}, "llm_results": {"fields": []}})
|
||||
|
||||
assert artifact.artifact_type == ApplicationFormFillArtifact.ArtifactType.FIELD_EXTRACT_RESULT
|
||||
assert artifact.file_format == ApplicationFormFillArtifact.FileFormat.JSON
|
||||
assert artifact.content_hash
|
||||
111
tests/test_application_form_fill_field_merge.py
Normal file
111
tests/test_application_form_fill_field_merge.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.field_merge import merge_fields, normalize_field_value, rank_source
|
||||
|
||||
|
||||
def test_normalize_field_value_removes_whitespace():
|
||||
assert normalize_field_value(" 2-8℃ 保存 \n 有效期12个月 ") == "2-8℃保存有效期12个月"
|
||||
|
||||
|
||||
def test_rank_source_prefers_instructions():
|
||||
assert rank_source("说明书") < rank_source("产品技术要求")
|
||||
|
||||
|
||||
def test_merge_fields_prefers_instructions_and_marks_conflict():
|
||||
regex_results = {
|
||||
"fields": [
|
||||
{
|
||||
"key": "storage_condition_and_validity",
|
||||
"label": "产品储存条件及有效期",
|
||||
"value": "2-8℃保存,有效期12个月",
|
||||
"source_file": "说明书.txt",
|
||||
"source_role": "说明书",
|
||||
"evidence": "产品储存条件及有效期:2-8℃保存,有效期12个月",
|
||||
"confidence": 0.75,
|
||||
},
|
||||
{
|
||||
"key": "storage_condition_and_validity",
|
||||
"label": "产品储存条件及有效期",
|
||||
"value": "-20℃保存",
|
||||
"source_file": "产品技术要求.txt",
|
||||
"source_role": "产品技术要求",
|
||||
"evidence": "产品储存条件及有效期:-20℃保存",
|
||||
"confidence": 0.8,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
merged, conflicts = merge_fields(regex_results, {"fields": []})
|
||||
|
||||
field = merged["storage_condition_and_validity"]
|
||||
assert field.value == "2-8℃保存,有效期12个月"
|
||||
assert field.has_conflict is True
|
||||
assert conflicts[0]["selected_value"] == "2-8℃保存,有效期12个月"
|
||||
assert conflicts[0]["conflict_values"][0]["value"] == "-20℃保存"
|
||||
|
||||
|
||||
def test_merge_fields_combines_consistent_values_without_conflict():
|
||||
regex_results = {
|
||||
"fields": [
|
||||
{
|
||||
"key": "product_name",
|
||||
"label": "产品名称",
|
||||
"value": "甲胎蛋白检测试剂盒",
|
||||
"source_file": "说明书.txt",
|
||||
"source_role": "说明书",
|
||||
"evidence": "产品名称:甲胎蛋白检测试剂盒",
|
||||
"confidence": 0.75,
|
||||
}
|
||||
]
|
||||
}
|
||||
llm_results = {
|
||||
"fields": [
|
||||
{
|
||||
"key": "product_name",
|
||||
"label": "产品名称",
|
||||
"value": "甲胎蛋白 检测试剂盒",
|
||||
"source_file": "产品技术要求.txt",
|
||||
"source_role": "产品技术要求",
|
||||
"evidence": "产品名称:甲胎蛋白 检测试剂盒",
|
||||
"confidence": 0.9,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
merged, conflicts = merge_fields(regex_results, llm_results)
|
||||
|
||||
assert merged["product_name"].value == "甲胎蛋白检测试剂盒"
|
||||
assert merged["product_name"].has_conflict is False
|
||||
assert conflicts == []
|
||||
|
||||
|
||||
def test_merge_fields_fills_agent_from_applicant_for_now():
|
||||
regex_results = {
|
||||
"fields": [
|
||||
{
|
||||
"key": "applicant_name",
|
||||
"label": "注册人名称",
|
||||
"value": "卡尤迪生物科技宜兴有限公司",
|
||||
"source_file": "目标产品说明书.docx",
|
||||
"source_role": "说明书",
|
||||
"evidence": "生产企业名称:卡尤迪生物科技宜兴有限公司",
|
||||
"confidence": 0.75,
|
||||
},
|
||||
{
|
||||
"key": "applicant_address",
|
||||
"label": "注册人住所",
|
||||
"value": "江苏省宜兴经济技术开发区杏里路10号",
|
||||
"source_file": "目标产品说明书.docx",
|
||||
"source_role": "说明书",
|
||||
"evidence": "生产企业住所:江苏省宜兴经济技术开发区杏里路10号",
|
||||
"confidence": 0.75,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
merged, conflicts = merge_fields(regex_results, {"fields": []})
|
||||
|
||||
assert merged["agent_name"].value == "卡尤迪生物科技宜兴有限公司"
|
||||
assert merged["agent_name"].label == "代理人名称"
|
||||
assert merged["agent_address"].value == "江苏省宜兴经济技术开发区杏里路10号"
|
||||
assert conflicts == []
|
||||
77
tests/test_application_form_fill_frontend.py
Normal file
77
tests/test_application_form_fill_frontend.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, WorkflowNodeRun
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_workspace_renders_application_form_fill_workflow_card(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-CARD")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-CARD",
|
||||
status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
|
||||
selected_templates=["registration_certificate"],
|
||||
risk_notes=[{"type": "pdf_pending"}],
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="form_fill",
|
||||
node_code="word_fill",
|
||||
node_name="填写 Word",
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "AFF-CARD" in content
|
||||
assert 'data-workflow-type="application_form_fill"' in content
|
||||
assert "填写 Word" in content
|
||||
assert "data-application-form-fill-status-url-template" in content
|
||||
|
||||
|
||||
def test_frontend_selects_application_form_fill_status_url_and_terminal_status():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert 'workflow_type === "application_form_fill"' in script
|
||||
assert "data-application-form-fill-status-url-template" in script
|
||||
assert 'status === "partial_success"' in script
|
||||
|
||||
|
||||
def test_application_form_fill_status_includes_no_feishu_notification(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-AFF")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-FEISHU",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/review-agent/application-form-fill/{batch.pk}/status/")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["latest_notification"] is None
|
||||
assert payload["notifications"] == []
|
||||
|
||||
|
||||
def test_frontend_renders_feishu_notification_status():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
css = open("static/css/login.css", encoding="utf-8").read()
|
||||
|
||||
assert "renderNotificationSummary" in script
|
||||
assert "暂无飞书通知记录" in script
|
||||
assert "workflow-notification" in script
|
||||
assert ".workflow-notification" in css
|
||||
109
tests/test_application_form_fill_models.py
Normal file
109
tests/test_application_form_fill_models.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillArtifact,
|
||||
ApplicationFormFillBatch,
|
||||
ApplicationFormFillNotificationRecord,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
Message,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_application_form_fill_models_store_batch_artifact_notification_and_exports(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="帮我填注册证",
|
||||
)
|
||||
summary_batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-AFF-READY",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
regulatory_batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary_batch,
|
||||
batch_no="RR-AFF-SOURCE",
|
||||
condition_json={"confirmed": True, "registration_type": "首次注册"},
|
||||
)
|
||||
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=trigger,
|
||||
source_summary_batch=summary_batch,
|
||||
source_regulatory_batch=regulatory_batch,
|
||||
batch_no="AFF-20260607153000-abcdef",
|
||||
requested_templates=["registration_certificate"],
|
||||
selected_templates=["registration_certificate"],
|
||||
output_types=["word", "excel", "json"],
|
||||
registration_type="首次注册",
|
||||
registration_type_source=ApplicationFormFillBatch.RegistrationTypeSource.USER_MESSAGE,
|
||||
product_name="甲胎蛋白检测试剂盒",
|
||||
conflict_summary=[{"field_key": "storage_condition"}],
|
||||
risk_notes=[{"type": "pdf_pending"}],
|
||||
template_config_version="application_form_templates_v1",
|
||||
template_config_hash="hash",
|
||||
work_dir="media/application_form_fill/1/1/AFF-20260607153000-abcdef",
|
||||
)
|
||||
artifact = ApplicationFormFillArtifact.objects.create(
|
||||
batch=batch,
|
||||
artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE,
|
||||
file_format=ApplicationFormFillArtifact.FileFormat.DOCX,
|
||||
name="注册证格式",
|
||||
file_name="filled.docx",
|
||||
storage_path="media/application_form_fill/filled.docx",
|
||||
file_size=123,
|
||||
content_hash="sha256",
|
||||
metadata={"template_code": "registration_certificate"},
|
||||
created_by_node="word_fill",
|
||||
)
|
||||
notification = ApplicationFormFillNotificationRecord.objects.create(
|
||||
batch=batch,
|
||||
recipient=user,
|
||||
channel=ApplicationFormFillNotificationRecord.Channel.MOCK,
|
||||
template_codes=["registration_certificate"],
|
||||
export_ids=[1],
|
||||
message_summary="自动填表完成",
|
||||
send_status=ApplicationFormFillNotificationRecord.SendStatus.FAILED,
|
||||
retry_count=1,
|
||||
error_message="mock failed",
|
||||
)
|
||||
word_export = ExportedSummaryFile.objects.create(
|
||||
batch=summary_batch,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name="filled.docx",
|
||||
storage_path="media/application_form_fill/filled.docx",
|
||||
)
|
||||
pdf_export = ExportedSummaryFile.objects.create(
|
||||
batch=summary_batch,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.PDF,
|
||||
file_name="filled.pdf",
|
||||
storage_path="media/application_form_fill/filled.pdf",
|
||||
)
|
||||
|
||||
assert batch.status == ApplicationFormFillBatch.Status.PENDING
|
||||
assert batch.source_summary_batch == summary_batch
|
||||
assert batch.source_regulatory_batch == regulatory_batch
|
||||
assert artifact.content_hash == "sha256"
|
||||
assert artifact.metadata["template_code"] == "registration_certificate"
|
||||
assert notification.send_status == ApplicationFormFillNotificationRecord.SendStatus.FAILED
|
||||
assert notification.retry_count == 1
|
||||
assert word_export.export_type == ExportedSummaryFile.ExportType.WORD
|
||||
assert pdf_export.export_type == ExportedSummaryFile.ExportType.PDF
|
||||
72
tests/test_application_form_fill_notification.py
Normal file
72
tests/test_application_form_fill_notification.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.notifier import notify_completion
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
ApplicationFormFillNotificationRecord,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_notify_completion_records_success(django_user_model, monkeypatch):
|
||||
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-NOTIFY")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-NOTIFY",
|
||||
selected_templates=["registration_certificate"],
|
||||
)
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=summary,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name="filled.docx",
|
||||
storage_path="filled.docx",
|
||||
)
|
||||
calls = []
|
||||
fake_record = type(
|
||||
"Record",
|
||||
(),
|
||||
{"send_status": "success", "SendStatus": type("SendStatus", (), {"FAILED": "failed"}), "error_message": ""},
|
||||
)()
|
||||
monkeypatch.setattr(
|
||||
"review_agent.application_form_fill.services.notifier.dispatch_workflow_notification",
|
||||
lambda context: calls.append(context) or fake_record,
|
||||
)
|
||||
|
||||
record = notify_completion(batch, [exported])
|
||||
|
||||
assert record.send_status == ApplicationFormFillNotificationRecord.SendStatus.SUCCESS
|
||||
assert record.export_ids == [exported.pk]
|
||||
assert record.template_codes == ["registration_certificate"]
|
||||
assert record.sent_at is not None
|
||||
assert calls[0].workflow_type == "application_form_fill"
|
||||
|
||||
|
||||
def test_notify_completion_records_failure_without_raising(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-NOTIFY-FAIL")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-NOTIFY-FAIL",
|
||||
selected_templates=["registration_certificate"],
|
||||
)
|
||||
|
||||
record = notify_completion(batch, [], fail=True)
|
||||
|
||||
assert record.send_status == ApplicationFormFillNotificationRecord.SendStatus.FAILED
|
||||
assert record.retry_count == 1
|
||||
assert "mock" in record.error_message
|
||||
39
tests/test_application_form_fill_summary.py
Normal file
39
tests/test_application_form_fill_summary.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.summary import build_assistant_summary
|
||||
from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_assistant_summary_compacts_long_conflict_values(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-SUMMARY")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-SUMMARY",
|
||||
conflict_summary=[
|
||||
{
|
||||
"field_key": "applicant_name",
|
||||
"field_label": "注册人名称",
|
||||
"selected_value": "卡尤迪生物科技宜兴有限公司",
|
||||
"conflict_values": [
|
||||
{
|
||||
"source_file": "CH1.4 申请表.docx",
|
||||
"value": "否\n临床试验\n临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院、北京市疾病预防控制中心 临床数据库.zip\n应附资料",
|
||||
}
|
||||
],
|
||||
"handling": "说明书优先,模板内黄底红字高亮",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
content = build_assistant_summary(batch, [])
|
||||
|
||||
assert "临床试验机构名称" in content
|
||||
assert len([line for line in content.splitlines() if "临床试验机构名称" in line][0]) < 220
|
||||
assert "\n临床试验\n" not in content
|
||||
97
tests/test_application_form_fill_template_config.py
Normal file
97
tests/test_application_form_fill_template_config.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.template_config import (
|
||||
DEFAULT_CONFIG_PATH,
|
||||
compute_config_hash,
|
||||
load_template_config,
|
||||
validate_template_config,
|
||||
)
|
||||
|
||||
|
||||
def test_template_config_loads_and_validates_default_yaml(settings):
|
||||
config = load_template_config()
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert errors == []
|
||||
assert config["version"] == "application_form_templates_v1"
|
||||
registration = next(item for item in config["templates"] if item["code"] == "registration_certificate")
|
||||
assert registration["file_format"] == "docx"
|
||||
assert {field["key"] for field in registration["fields"]} >= {
|
||||
"applicant_name",
|
||||
"product_name",
|
||||
"package_specification",
|
||||
"main_components",
|
||||
"intended_use",
|
||||
"storage_condition_and_validity",
|
||||
"attachments",
|
||||
}
|
||||
assert all(field["target"]["type"] == "table_row" for field in registration["fields"])
|
||||
assert len(compute_config_hash(DEFAULT_CONFIG_PATH)) == 64
|
||||
|
||||
|
||||
def test_template_config_reports_missing_source_dir():
|
||||
config = load_template_config()
|
||||
config["source_dir"] = "docs/not-exists"
|
||||
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert any("source_dir 不存在" in error for error in errors)
|
||||
|
||||
|
||||
def test_template_config_reports_duplicate_code():
|
||||
config = load_template_config()
|
||||
duplicate = copy.deepcopy(config["templates"][0])
|
||||
config["templates"].append(duplicate)
|
||||
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert any("模板 code 重复" in error for error in errors)
|
||||
|
||||
|
||||
def test_template_config_reports_missing_source_file():
|
||||
config = load_template_config()
|
||||
config["templates"][0]["source_file"] = "missing.docx"
|
||||
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert any("source_file 不存在" in error for error in errors)
|
||||
|
||||
|
||||
def test_template_config_reports_unsupported_target_type():
|
||||
config = load_template_config()
|
||||
config["templates"][0]["fields"][0]["target"]["type"] = "content_control"
|
||||
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert any("target.type 不支持" in error for error in errors)
|
||||
|
||||
|
||||
def test_template_config_loads_custom_path(tmp_path):
|
||||
config_path = tmp_path / "templates.yaml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
version: custom
|
||||
source_dir: .
|
||||
templates:
|
||||
- code: custom_template
|
||||
name: Custom
|
||||
source_file: source.docx
|
||||
output_label: Custom
|
||||
file_format: docx
|
||||
fields:
|
||||
- key: product_name
|
||||
label: 产品名称
|
||||
target:
|
||||
type: table_row
|
||||
row_label: 产品名称
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "source.docx").write_bytes(b"docx")
|
||||
|
||||
config = load_template_config(config_path)
|
||||
|
||||
assert validate_template_config(config, base_dir=tmp_path) == []
|
||||
assert compute_config_hash(config_path)
|
||||
60
tests/test_application_form_fill_template_repository.py
Normal file
60
tests/test_application_form_fill_template_repository.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.template_config import load_template_config
|
||||
from review_agent.application_form_fill.services.template_repository import (
|
||||
TemplateUnavailableError,
|
||||
copy_template_to_batch,
|
||||
resolve_source_template,
|
||||
)
|
||||
from review_agent.application_form_fill.services.template_select import select_templates
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillArtifact,
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
FileSummaryBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_resolve_source_template_finds_registration_docx():
|
||||
config = load_template_config()
|
||||
specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册")
|
||||
|
||||
path = resolve_source_template(specs[0], config)
|
||||
|
||||
assert path.exists()
|
||||
assert path.name == "中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx"
|
||||
|
||||
|
||||
def test_copy_template_to_batch_creates_artifact(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-REPO")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-REPO",
|
||||
work_dir=str(tmp_path / "aff" / "AFF-REPO"),
|
||||
)
|
||||
config = load_template_config()
|
||||
specs, _risk_notes = select_templates(config, ["registration_certificate"], "首次注册")
|
||||
|
||||
artifact = copy_template_to_batch(specs[0], batch, config)
|
||||
|
||||
assert artifact.artifact_type == ApplicationFormFillArtifact.ArtifactType.TEMPLATE_COPY
|
||||
assert artifact.file_format == "docx"
|
||||
assert artifact.content_hash
|
||||
assert artifact.metadata["template_code"] == "registration_certificate"
|
||||
assert artifact.storage_path.startswith(batch.work_dir)
|
||||
|
||||
|
||||
def test_doc_template_without_working_docx_is_unavailable():
|
||||
config = load_template_config()
|
||||
specs, _risk_notes = select_templates(config, ["change_registration"], "变更注册")
|
||||
|
||||
with pytest.raises(TemplateUnavailableError):
|
||||
resolve_source_template(specs[0], config)
|
||||
114
tests/test_application_form_fill_template_select.py
Normal file
114
tests/test_application_form_fill_template_select.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.services.template_config import load_template_config
|
||||
from review_agent.application_form_fill.services.template_select import (
|
||||
detect_registration_type,
|
||||
parse_requested_templates,
|
||||
select_templates,
|
||||
)
|
||||
from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, RegulatoryReviewBatch
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
[
|
||||
("帮我填注册证", ["registration_certificate"]),
|
||||
("生成变更注册备案文件", ["change_registration"]),
|
||||
("生成安全和性能基本原则清单", ["essential_principles"]),
|
||||
("请生成全部模板", ["registration_certificate", "change_registration", "essential_principles"]),
|
||||
("普通聊天", []),
|
||||
],
|
||||
)
|
||||
def test_parse_requested_templates(message, expected):
|
||||
assert parse_requested_templates(message) == expected
|
||||
|
||||
|
||||
def test_detect_registration_type_prefers_user_message(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-SEL")
|
||||
regulatory = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-SEL",
|
||||
condition_json={"confirmed_conditions": {"registration_type": "变更注册"}},
|
||||
)
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
source_regulatory_batch=regulatory,
|
||||
batch_no="AFF-SEL",
|
||||
)
|
||||
|
||||
value, source = detect_registration_type(batch=batch, message="首次注册资料,请填注册证")
|
||||
|
||||
assert value == "首次注册"
|
||||
assert source == ApplicationFormFillBatch.RegistrationTypeSource.USER_MESSAGE
|
||||
|
||||
|
||||
def test_detect_registration_type_falls_back_to_regulatory_batch_and_file_candidates(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-SEL-2")
|
||||
regulatory = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-SEL-2",
|
||||
condition_json={"confirmed_conditions": {"registration_type": "变更注册"}},
|
||||
)
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
source_regulatory_batch=regulatory,
|
||||
batch_no="AFF-SEL-2",
|
||||
)
|
||||
|
||||
regulatory_value, regulatory_source = detect_registration_type(batch=batch, message="")
|
||||
file_value, file_source = detect_registration_type(
|
||||
message="",
|
||||
file_candidates={"registration_type": {"suggested": "备案"}},
|
||||
)
|
||||
|
||||
assert (regulatory_value, regulatory_source) == (
|
||||
"变更注册",
|
||||
ApplicationFormFillBatch.RegistrationTypeSource.REGULATORY_BATCH,
|
||||
)
|
||||
assert (file_value, file_source) == (
|
||||
"备案",
|
||||
ApplicationFormFillBatch.RegistrationTypeSource.FILE_EXTRACT,
|
||||
)
|
||||
|
||||
|
||||
def test_select_default_templates_for_initial_registration():
|
||||
config = load_template_config()
|
||||
|
||||
specs, risk_notes = select_templates(config, [], "首次注册")
|
||||
|
||||
assert [spec.code for spec in specs] == ["registration_certificate", "essential_principles"]
|
||||
assert risk_notes == []
|
||||
|
||||
|
||||
def test_select_default_templates_for_change_registration():
|
||||
config = load_template_config()
|
||||
|
||||
specs, risk_notes = select_templates(config, [], "变更注册")
|
||||
|
||||
assert [spec.code for spec in specs] == ["change_registration", "essential_principles"]
|
||||
assert risk_notes == []
|
||||
|
||||
|
||||
def test_select_user_requested_mismatch_is_allowed_with_risk_note():
|
||||
config = load_template_config()
|
||||
|
||||
specs, risk_notes = select_templates(config, ["change_registration"], "首次注册")
|
||||
|
||||
assert [spec.code for spec in specs] == ["change_registration"]
|
||||
assert risk_notes
|
||||
assert risk_notes[0]["type"] == "template_registration_mismatch"
|
||||
85
tests/test_application_form_fill_traceability.py
Normal file
85
tests/test_application_form_fill_traceability.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from review_agent.application_form_fill.schemas import MergedField, TemplateSpec
|
||||
from review_agent.application_form_fill.services.traceability_export import save_traceability_exports
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillArtifact,
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_traceability_exports_excel_json_and_records(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-TRACE")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-TRACE",
|
||||
work_dir=str(tmp_path / "aff" / "AFF-TRACE"),
|
||||
)
|
||||
spec = TemplateSpec(
|
||||
code="registration_certificate",
|
||||
name="注册证格式",
|
||||
source_file="template.docx",
|
||||
output_label="注册证格式",
|
||||
applies_when={},
|
||||
file_format="docx",
|
||||
fields=[{"key": "product_name", "label": "产品名称"}],
|
||||
)
|
||||
merged_fields = {
|
||||
"product_name": MergedField(
|
||||
"product_name",
|
||||
"产品名称",
|
||||
"甲胎蛋白检测试剂盒",
|
||||
"说明书.txt",
|
||||
"产品名称:甲胎蛋白检测试剂盒",
|
||||
0.8,
|
||||
)
|
||||
}
|
||||
conflicts = [
|
||||
{
|
||||
"field_key": "storage_condition",
|
||||
"field_label": "储存条件",
|
||||
"selected_value": "2-8℃",
|
||||
"conflict_values": [{"value": "-20℃", "source_file": "产品技术要求.txt"}],
|
||||
"handling": "说明书优先",
|
||||
}
|
||||
]
|
||||
|
||||
exports = save_traceability_exports(
|
||||
batch,
|
||||
merged_fields,
|
||||
conflicts,
|
||||
[spec],
|
||||
[{"template_label": "注册证格式", "word_status": "success", "pdf_status": "待增强"}],
|
||||
)
|
||||
|
||||
assert {export.export_type for export in exports} == {
|
||||
ExportedSummaryFile.ExportType.EXCEL,
|
||||
ExportedSummaryFile.ExportType.JSON,
|
||||
}
|
||||
excel_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.EXCEL)
|
||||
workbook = load_workbook(excel_export.storage_path)
|
||||
assert workbook.sheetnames == ["字段追溯", "冲突字段", "低置信度条目", "生成结果"]
|
||||
assert workbook["字段追溯"]["B2"].value == "产品名称"
|
||||
assert workbook["冲突字段"]["C2"].value == "-20℃"
|
||||
|
||||
json_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.JSON)
|
||||
payload = json.loads(open(json_export.storage_path, encoding="utf-8").read())
|
||||
assert payload["merged_fields"]["product_name"]["value"] == "甲胎蛋白检测试剂盒"
|
||||
assert ApplicationFormFillArtifact.objects.filter(
|
||||
batch=batch,
|
||||
artifact_type=ApplicationFormFillArtifact.ArtifactType.TRACEABILITY,
|
||||
).exists()
|
||||
73
tests/test_application_form_fill_trigger.py
Normal file
73
tests/test_application_form_fill_trigger.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileAttachment
|
||||
from review_agent.skill_router import route_message_intent
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"帮我填注册证",
|
||||
"给我这个内容对应的表格",
|
||||
"为我该方案生成申报模板",
|
||||
"请自动填表并生成表格",
|
||||
"生成安全和性能基本原则清单",
|
||||
],
|
||||
)
|
||||
def test_rule_router_starts_application_form_fill_for_keywords(monkeypatch, django_user_model, content):
|
||||
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, content)
|
||||
|
||||
assert route.action == "application_form_fill"
|
||||
assert route.workflow_type == "application_form_fill"
|
||||
assert route.starts_application_form_fill
|
||||
|
||||
|
||||
def test_rule_router_does_not_misroute_normal_chat(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, "你好,解释一下法规背景")
|
||||
|
||||
assert route.action == "normal_chat"
|
||||
|
||||
|
||||
def test_application_form_fill_prompt_preempts_attachment_reader_llm(monkeypatch, tmp_path, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
archive_path = tmp_path / "第1章_监管信息.rar"
|
||||
archive_path.write_bytes(b"rar")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="第1章_监管信息.rar",
|
||||
storage_path=str(archive_path),
|
||||
file_size=archive_path.stat().st_size,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.skill_router._route_with_llm",
|
||||
lambda conversation, content, attachments: (_ for _ in ()).throw(
|
||||
AssertionError("明确自动填表意图不应进入 LLM 路由")
|
||||
),
|
||||
)
|
||||
|
||||
route = route_message_intent(
|
||||
conversation,
|
||||
"请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板,优先生成注册证 Word 和字段来源追溯清单。",
|
||||
)
|
||||
|
||||
assert route.action == "application_form_fill"
|
||||
assert route.source == "rule_preflight"
|
||||
113
tests/test_application_form_fill_views.py
Normal file
113
tests/test_application_form_fill_views.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_application_form_fill_start_requires_conversation_owner(client, monkeypatch, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
batch_no="FS-VIEW",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None)
|
||||
client.force_login(other)
|
||||
|
||||
response = client.post(
|
||||
reverse("application_form_fill_start"),
|
||||
data=json.dumps({"conversation_id": conversation.pk}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_application_form_fill_start_creates_batch(client, monkeypatch, django_user_model):
|
||||
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-VIEW-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
reverse("application_form_fill_start"),
|
||||
data=json.dumps({"conversation_id": conversation.pk, "template_codes": ["registration_certificate"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["workflow_type"] == "application_form_fill"
|
||||
assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists()
|
||||
|
||||
|
||||
def test_application_form_fill_status_requires_owner_and_returns_nodes_exports(client, tmp_path, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
summary = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-STATUS")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-STATUS",
|
||||
status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
|
||||
selected_templates=["registration_certificate"],
|
||||
conflict_summary=[{"field_key": "product_name"}],
|
||||
risk_notes=[{"type": "pdf_pending"}],
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="form_fill",
|
||||
node_code=FORM_FILL_NODE_DEFINITIONS[0][0],
|
||||
node_name=FORM_FILL_NODE_DEFINITIONS[0][1],
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
)
|
||||
output = tmp_path / "filled.docx"
|
||||
output.write_bytes(b"word")
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=summary,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name="filled.docx",
|
||||
storage_path=str(output),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("application_form_fill_batch_status", args=[batch.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(reverse("application_form_fill_batch_status", args=[batch.pk]))
|
||||
assert allowed.status_code == 200
|
||||
payload = allowed.json()
|
||||
assert payload["batch"]["workflow_type"] == "application_form_fill"
|
||||
assert payload["batch"]["status"] == ApplicationFormFillBatch.Status.PARTIAL_SUCCESS
|
||||
assert payload["batch"]["conflict_count"] == 1
|
||||
assert payload["nodes"][0]["node_code"] == "prepare"
|
||||
assert payload["exports"][0]["id"] == exported.pk
|
||||
182
tests/test_application_form_fill_word_fill.py
Normal file
182
tests/test_application_form_fill_word_fill.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from docx import Document
|
||||
|
||||
from review_agent.application_form_fill.schemas import MergedField, TemplateSpec
|
||||
from review_agent.application_form_fill.services.word_fill import create_word_export, fill_template
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillArtifact,
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _spec():
|
||||
return TemplateSpec(
|
||||
code="registration_certificate",
|
||||
name="注册证格式",
|
||||
source_file="template.docx",
|
||||
output_label="注册证格式",
|
||||
applies_when={"registration_type": ["首次注册"]},
|
||||
file_format="docx",
|
||||
fields=[
|
||||
{"key": "product_name", "label": "产品名称", "target": {"type": "table_row", "row_label": "产品名称"}},
|
||||
{"key": "intended_use", "label": "预期用途", "target": {"type": "table_row", "row_label": "预期用途"}},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _template(path):
|
||||
document = Document()
|
||||
table = document.add_table(rows=2, cols=2)
|
||||
table.rows[0].cells[0].text = "产品名称"
|
||||
table.rows[1].cells[0].text = "预期用途"
|
||||
document.save(path)
|
||||
|
||||
|
||||
def _template_with_instructions(path):
|
||||
document = Document()
|
||||
table = document.add_table(rows=2, cols=2)
|
||||
table.rows[0].cells[0].text = "产品名称"
|
||||
table.rows[1].cells[0].text = "预期用途"
|
||||
document.add_paragraph("填表说明")
|
||||
document.add_paragraph("1. 本表依据《体外诊断注册与备案管理办法》制定。")
|
||||
document.add_paragraph("2. 本表可从国家药品监督管理局网站下载。")
|
||||
document.save(path)
|
||||
|
||||
|
||||
def test_word_fill_writes_table_rows(tmp_path):
|
||||
template_path = tmp_path / "template.docx"
|
||||
output_path = tmp_path / "filled.docx"
|
||||
_template(template_path)
|
||||
|
||||
fill_template(
|
||||
template_path,
|
||||
output_path,
|
||||
_spec(),
|
||||
{
|
||||
"product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8),
|
||||
"intended_use": MergedField("intended_use", "预期用途", "用于体外检测", "说明书.txt", "证据", 0.8),
|
||||
},
|
||||
)
|
||||
|
||||
document = Document(output_path)
|
||||
assert document.tables[0].rows[0].cells[1].text == "甲胎蛋白检测试剂盒"
|
||||
assert document.tables[0].rows[1].cells[1].text == "用于体外检测"
|
||||
|
||||
|
||||
def test_word_fill_removes_template_fill_instructions(tmp_path):
|
||||
template_path = tmp_path / "template.docx"
|
||||
output_path = tmp_path / "filled.docx"
|
||||
_template_with_instructions(template_path)
|
||||
|
||||
fill_template(
|
||||
template_path,
|
||||
output_path,
|
||||
_spec(),
|
||||
{
|
||||
"product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8),
|
||||
},
|
||||
)
|
||||
|
||||
document = Document(output_path)
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
assert "填表说明" not in text
|
||||
assert "本表依据" not in text
|
||||
assert document.tables[0].rows[0].cells[1].text == "甲胎蛋白检测试剂盒"
|
||||
|
||||
|
||||
def test_word_fill_highlights_conflict_in_docx_xml(tmp_path):
|
||||
template_path = tmp_path / "template.docx"
|
||||
output_path = tmp_path / "filled.docx"
|
||||
_template(template_path)
|
||||
|
||||
fill_template(
|
||||
template_path,
|
||||
output_path,
|
||||
_spec(),
|
||||
{
|
||||
"product_name": MergedField(
|
||||
"product_name",
|
||||
"产品名称",
|
||||
"甲胎蛋白检测试剂盒",
|
||||
"说明书.txt",
|
||||
"证据",
|
||||
0.8,
|
||||
has_conflict=True,
|
||||
)
|
||||
},
|
||||
conflicts=[{"field_key": "product_name"}],
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(output_path) as package:
|
||||
document_xml = package.read("word/document.xml").decode("utf-8")
|
||||
assert 'w:fill="FFFF00"' in document_xml
|
||||
assert 'w:color w:val="FF0000"' in document_xml
|
||||
|
||||
|
||||
def test_create_word_export_records_artifact_and_export(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-WORD")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-WORD",
|
||||
product_name="甲胎蛋白检测试剂盒",
|
||||
work_dir=str(tmp_path / "aff" / "AFF-WORD"),
|
||||
)
|
||||
template_path = tmp_path / "template.docx"
|
||||
_template(template_path)
|
||||
|
||||
exported = create_word_export(
|
||||
batch,
|
||||
_spec(),
|
||||
template_path,
|
||||
{"product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8)},
|
||||
)
|
||||
|
||||
assert exported.export_type == ExportedSummaryFile.ExportType.WORD
|
||||
assert exported.workflow_type == "application_form_fill"
|
||||
assert exported.workflow_batch_id == batch.pk
|
||||
assert ApplicationFormFillArtifact.objects.filter(
|
||||
batch=batch,
|
||||
artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE,
|
||||
).exists()
|
||||
|
||||
|
||||
def test_create_word_export_sanitizes_product_name_newlines(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-WORD-NL")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-WORD-NL",
|
||||
product_name="原体核酸检测试剂盒(荧\n光PCR法)",
|
||||
work_dir=str(tmp_path / "aff" / "AFF-WORD-NL"),
|
||||
)
|
||||
template_path = tmp_path / "template.docx"
|
||||
_template(template_path)
|
||||
|
||||
exported = create_word_export(
|
||||
batch,
|
||||
_spec(),
|
||||
template_path,
|
||||
{"product_name": MergedField("product_name", "产品名称", "原体核酸检测试剂盒", "说明书.txt", "证据", 0.8)},
|
||||
)
|
||||
|
||||
assert "\n" not in exported.file_name
|
||||
assert "\r" not in exported.file_name
|
||||
assert Path(exported.storage_path).exists()
|
||||
333
tests/test_application_form_fill_workflow.py
Normal file
333
tests/test_application_form_fill_workflow.py
Normal file
@@ -0,0 +1,333 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS
|
||||
from review_agent.application_form_fill.workflow import (
|
||||
create_application_form_fill_batch,
|
||||
find_latest_successful_summary_batch,
|
||||
start_application_form_fill_workflow,
|
||||
)
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
FileSummaryBatchAttachment,
|
||||
Message,
|
||||
WorkflowEvent,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
from review_agent.services import stream_message
|
||||
from review_agent.skill_router import SkillRoute
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stub_aff_llm_extract(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.application_form_fill.services.field_extract.generate_completion",
|
||||
lambda messages, temperature=0.0: '{"fields": [], "checklist_items": []}',
|
||||
)
|
||||
|
||||
|
||||
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-AFF-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-AFF-FAILED",
|
||||
status=FileSummaryBatch.Status.FAILED,
|
||||
)
|
||||
|
||||
assert find_latest_successful_summary_batch(conversation) == success
|
||||
|
||||
|
||||
def test_create_application_form_fill_batch_initializes_nodes(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="会话")
|
||||
message = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-AFF-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
|
||||
batch = create_application_form_fill_batch(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=message,
|
||||
source_summary_batch=summary,
|
||||
)
|
||||
|
||||
assert batch.status == ApplicationFormFillBatch.Status.PENDING
|
||||
assert batch.output_types == ["word", "excel", "json"]
|
||||
assert WorkflowNodeRun.objects.filter(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
).count() == len(FORM_FILL_NODE_DEFINITIONS)
|
||||
assert WorkflowEvent.objects.filter(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
event_type="workflow_created",
|
||||
).exists()
|
||||
|
||||
|
||||
def test_application_form_fill_executor_runs_nodes_and_skips_pdf(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-AFF-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
trigger = Message.objects.create(conversation=conversation, role=Message.Role.USER, content="帮我填注册证")
|
||||
batch = create_application_form_fill_batch(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=trigger,
|
||||
source_summary_batch=summary,
|
||||
)
|
||||
|
||||
start_application_form_fill_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert batch.status == ApplicationFormFillBatch.Status.SUCCESS
|
||||
assert WorkflowNodeRun.objects.get(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_code="pdf_convert",
|
||||
).status == WorkflowNodeRun.Status.SKIPPED
|
||||
assert WorkflowEvent.objects.filter(
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
event_type="workflow_completed",
|
||||
).exists()
|
||||
|
||||
|
||||
def test_application_form_fill_workflow_generates_summary_and_exports(settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.APPLICATION_FORM_FILL_ASYNC = False
|
||||
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="帮我填注册证")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-AFF-FULL",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
source = tmp_path / "ifu.txt"
|
||||
source.write_text("产品名称:甲胎蛋白检测试剂盒\n预期用途:用于体外检测", encoding="utf-8")
|
||||
from review_agent.models import FileSummaryItem
|
||||
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
file_name="说明书.txt",
|
||||
file_type="txt",
|
||||
relative_path="说明书.txt",
|
||||
storage_path=str(source),
|
||||
)
|
||||
batch = create_application_form_fill_batch(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=trigger,
|
||||
source_summary_batch=summary,
|
||||
)
|
||||
|
||||
start_application_form_fill_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert batch.status == ApplicationFormFillBatch.Status.SUCCESS
|
||||
assert batch.product_name == "甲胎蛋白检测试剂盒"
|
||||
assert batch.selected_templates == ["registration_certificate"]
|
||||
assert conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains="已生成申报模板自动填表文件").exists()
|
||||
assert batch.notifications.filter(send_status="success").exists()
|
||||
|
||||
|
||||
def test_application_form_fill_status_becomes_partial_when_notification_fails(settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.APPLICATION_FORM_FILL_MOCK_NOTIFY_FAIL = True
|
||||
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="帮我填注册证")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-AFF-PARTIAL",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = create_application_form_fill_batch(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=trigger,
|
||||
source_summary_batch=summary,
|
||||
)
|
||||
|
||||
start_application_form_fill_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert batch.status == ApplicationFormFillBatch.Status.PARTIAL_SUCCESS
|
||||
assert batch.notifications.filter(send_status="failed").exists()
|
||||
|
||||
|
||||
def test_stream_message_prompts_for_upload_when_no_summary_or_attachment(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="application_form_fill",
|
||||
workflow_type="application_form_fill",
|
||||
confidence=0.9,
|
||||
),
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "帮我填注册证"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "请先在当前对话右侧上传需要填表的产品资料或压缩包" in joined
|
||||
assert not ApplicationFormFillBatch.objects.exists()
|
||||
|
||||
|
||||
def test_stream_message_starts_application_form_fill_workflow(monkeypatch, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.APPLICATION_FORM_FILL_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-AFF-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.route_message_intent",
|
||||
lambda conversation, content: SkillRoute(
|
||||
action="application_form_fill",
|
||||
workflow_type="application_form_fill",
|
||||
confidence=0.9,
|
||||
),
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "帮我填注册证"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "workflow_started" in joined
|
||||
assert '"workflow_type": "application_form_fill"' in joined
|
||||
assert "已启动申报文件自动填表工作流" in joined
|
||||
assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists()
|
||||
|
||||
|
||||
def test_stream_message_auto_runs_summary_before_application_form_fill(
|
||||
monkeypatch, settings, tmp_path, django_user_model
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.APPLICATION_FORM_FILL_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="application_form_fill",
|
||||
workflow_type="application_form_fill",
|
||||
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, "为我该方案生成申报模板"))
|
||||
joined = "".join(frames)
|
||||
|
||||
assert '"workflow_type": "file_summary"' in joined
|
||||
assert '"workflow_type": "application_form_fill"' in joined
|
||||
assert "汇总完成后继续自动填表" in joined
|
||||
assert FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS).exists()
|
||||
assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists()
|
||||
|
||||
|
||||
def test_stream_message_reruns_summary_when_new_attachment_not_in_latest_batch(
|
||||
monkeypatch, settings, tmp_path, django_user_model
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.APPLICATION_FORM_FILL_ASYNC = False
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
old_path = tmp_path / "old.txt"
|
||||
old_path.write_text("旧资料", encoding="utf-8")
|
||||
old_attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="旧资料.txt",
|
||||
storage_path=str(old_path),
|
||||
file_size=old_path.stat().st_size,
|
||||
is_active=True,
|
||||
)
|
||||
old_summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-OLD",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryBatchAttachment.objects.create(batch=old_summary, attachment=old_attachment)
|
||||
new_path = tmp_path / "ifu.txt"
|
||||
new_path.write_text("【产品名称】\n新型冠状病毒2019-nCoV核酸检测试剂盒(荧光PCR法)", encoding="utf-8")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="目标产品说明书.docx",
|
||||
storage_path=str(new_path),
|
||||
file_size=new_path.stat().st_size,
|
||||
is_active=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.route_message_intent",
|
||||
lambda conversation, content: SkillRoute(
|
||||
action="application_form_fill",
|
||||
workflow_type="application_form_fill",
|
||||
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, "请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"))
|
||||
joined = "".join(frames)
|
||||
|
||||
assert '"workflow_type": "file_summary"' in joined
|
||||
assert "汇总完成后继续自动填表" in joined
|
||||
latest_summary = FileSummaryBatch.objects.order_by("-id").first()
|
||||
form_batch = ApplicationFormFillBatch.objects.get(conversation=conversation)
|
||||
assert latest_summary != old_summary
|
||||
assert form_batch.source_summary_batch == latest_summary
|
||||
146
tests/test_attachment_reader.py
Normal file
146
tests/test_attachment_reader.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
|
||||
from review_agent.models import Conversation, FileAttachment
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_read_attachment_extracts_text_file_details(settings, tmp_path, django_user_model):
|
||||
from review_agent.file_summary.services.attachment_reader import read_attachment_details
|
||||
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
relative_path = Path("uploads") / "note.txt"
|
||||
absolute_path = tmp_path / relative_path
|
||||
absolute_path.parent.mkdir(parents=True)
|
||||
absolute_path.write_text("产品名称:智能审核\n关键结论:可以解析附件详情", encoding="utf-8")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="note.txt",
|
||||
storage_path=relative_path.as_posix(),
|
||||
file_size=absolute_path.stat().st_size,
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
result = read_attachment_details(attachment)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.filename == "note.txt"
|
||||
assert result.file_type == "txt"
|
||||
assert "智能审核" in result.preview_text
|
||||
assert result.sections[0]["type"] == "text"
|
||||
|
||||
|
||||
def test_read_attachment_extracts_docx_and_xlsx_details(settings, tmp_path, django_user_model):
|
||||
from docx import Document
|
||||
from openpyxl import Workbook
|
||||
|
||||
from review_agent.file_summary.services.attachment_reader import read_attachment_details
|
||||
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
docx_path = tmp_path / "uploads" / "summary.docx"
|
||||
docx_path.parent.mkdir(parents=True)
|
||||
doc = Document()
|
||||
doc.add_heading("项目摘要", level=1)
|
||||
doc.add_paragraph("这是 Word 附件里的正文。")
|
||||
doc.save(docx_path)
|
||||
docx_attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="summary.docx",
|
||||
storage_path="uploads/summary.docx",
|
||||
file_size=docx_path.stat().st_size,
|
||||
)
|
||||
|
||||
workbook_path = tmp_path / "uploads" / "inventory.xlsx"
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "清单"
|
||||
sheet.append(["文件名", "页数"])
|
||||
sheet.append(["a.pdf", 3])
|
||||
workbook.save(workbook_path)
|
||||
xlsx_attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="inventory.xlsx",
|
||||
storage_path="uploads/inventory.xlsx",
|
||||
file_size=workbook_path.stat().st_size,
|
||||
)
|
||||
|
||||
docx_result = read_attachment_details(docx_attachment)
|
||||
xlsx_result = read_attachment_details(xlsx_attachment)
|
||||
|
||||
assert docx_result.status == "success"
|
||||
assert "项目摘要" in docx_result.preview_text
|
||||
assert "Word 附件里的正文" in docx_result.preview_text
|
||||
assert xlsx_result.status == "success"
|
||||
assert xlsx_result.sections[0]["name"] == "清单"
|
||||
assert xlsx_result.sections[0]["rows"][1] == ["a.pdf", "3"]
|
||||
|
||||
|
||||
def test_attachment_reader_skill_returns_structured_details(settings, tmp_path, django_user_model):
|
||||
from review_agent.file_summary.skills.attachment_reader import AttachmentReaderSkill
|
||||
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
file_path = tmp_path / "uploads" / "readme.txt"
|
||||
file_path.parent.mkdir(parents=True)
|
||||
file_path.write_text("请读取这个附件。", encoding="utf-8")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="readme.txt",
|
||||
storage_path="uploads/readme.txt",
|
||||
file_size=file_path.stat().st_size,
|
||||
)
|
||||
|
||||
result = AttachmentReaderSkill().run_for_attachments([attachment])
|
||||
|
||||
assert result.success is True
|
||||
assert result.data["attachments"][0]["filename"] == "readme.txt"
|
||||
assert "请读取这个附件" in result.data["attachments"][0]["preview_text"]
|
||||
|
||||
|
||||
def test_read_attachment_extracts_files_inside_rar(monkeypatch, settings, tmp_path, django_user_model):
|
||||
from review_agent.file_summary.services.attachment_reader import read_attachment_details
|
||||
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
archive_path = tmp_path / "uploads" / "第1章_监管信息.rar"
|
||||
archive_path.parent.mkdir(parents=True)
|
||||
archive_path.write_bytes(b"rar")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="第1章_监管信息.rar",
|
||||
storage_path="uploads/第1章_监管信息.rar",
|
||||
file_size=archive_path.stat().st_size,
|
||||
)
|
||||
|
||||
def fake_extract_archive(path: Path, target_dir: Path):
|
||||
extracted = target_dir / "说明书.txt"
|
||||
extracted.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8")
|
||||
return [extracted]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.attachment_reader.extract_archive",
|
||||
fake_extract_archive,
|
||||
)
|
||||
|
||||
result = read_attachment_details(attachment)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.file_type == "rar"
|
||||
assert "说明书.txt" in result.sections[0]["name"]
|
||||
assert "甲胎蛋白检测试剂盒" in result.preview_text
|
||||
123
tests/test_chat_knowledge_context.py
Normal file
123
tests/test_chat_knowledge_context.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import KnowledgeBaseDocument
|
||||
from review_agent.services import build_knowledge_context, send_message, stream_message
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_build_knowledge_context_ignores_irrelevant_rag_chunks(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"source": "附件 4 体外诊断试剂注册申报资料要求及说明.doc",
|
||||
"text": "预期用途应明确产品用于检测的分析物和功能。",
|
||||
"score": 7.636,
|
||||
"metadata": {"source_type": "regulatory_document"},
|
||||
}
|
||||
],
|
||||
"error_message": "",
|
||||
},
|
||||
)
|
||||
|
||||
context = build_knowledge_context("孙之烨是谁")
|
||||
|
||||
assert context == ""
|
||||
|
||||
|
||||
def test_build_knowledge_context_uses_full_document_when_name_matches(settings, tmp_path, monkeypatch, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document_path = tmp_path / "resume.txt"
|
||||
document_path.write_text(
|
||||
"孙之烨,负责审核智能体项目。\n完整经历:曾组织技术分享并带队参加竞赛。",
|
||||
encoding="utf-8",
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.txt",
|
||||
storage_path=str(document_path),
|
||||
file_size=document_path.stat().st_size,
|
||||
status=KnowledgeBaseDocument.Status.ACTIVE,
|
||||
is_active=True,
|
||||
indexed_chunk_count=2,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {"query": query, "results": [], "error_message": ""},
|
||||
)
|
||||
|
||||
context = build_knowledge_context("孙之烨是谁")
|
||||
|
||||
assert "全文材料" in context
|
||||
assert "来源:用户知识库/孙之烨-260510.txt" in context
|
||||
assert "完整经历:曾组织技术分享并带队参加竞赛" in context
|
||||
|
||||
|
||||
def test_send_message_refuses_out_of_scope_answer_without_knowledge_context(monkeypatch, django_user_model):
|
||||
from review_agent.models import Conversation
|
||||
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {"query": query, "results": [], "error_message": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.generate_reply",
|
||||
lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call LLM"),
|
||||
)
|
||||
|
||||
_, assistant_message = send_message(conversation, "孙之烨是谁")
|
||||
|
||||
assert "没有在当前启用的知识库材料中找到" in assistant_message.content
|
||||
assert "与当前主营业务无关" in assistant_message.content
|
||||
|
||||
|
||||
def test_stream_message_refuses_out_of_scope_answer_without_knowledge_context(monkeypatch, django_user_model):
|
||||
from review_agent.models import Conversation
|
||||
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {"query": query, "results": [], "error_message": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.stream_reply",
|
||||
lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call streaming LLM"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.generate_reply",
|
||||
lambda *args, **kwargs: pytest.fail("out-of-scope answer without knowledge context must not call fallback LLM"),
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "给我一份红烧肉菜谱"))
|
||||
|
||||
assert any("没有在当前启用的知识库材料中找到" in frame for frame in frames)
|
||||
assert any("与当前主营业务无关" in frame for frame in frames)
|
||||
assert any("done" in frame for frame in frames)
|
||||
|
||||
|
||||
def test_business_question_without_knowledge_context_can_use_llm(monkeypatch, django_user_model):
|
||||
from review_agent.models import Conversation
|
||||
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {"query": query, "results": [], "error_message": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.generate_reply",
|
||||
lambda *args, **kwargs: "注册检验报告通常用于证明产品性能符合要求。",
|
||||
)
|
||||
|
||||
_, assistant_message = send_message(conversation, "注册检验报告有什么作用")
|
||||
|
||||
assert "注册检验报告" in assistant_message.content
|
||||
202
tests/test_feishu_api_services.py
Normal file
202
tests/test_feishu_api_services.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import json
|
||||
|
||||
from django.utils import timezone
|
||||
import pytest
|
||||
|
||||
from review_agent.models import FeishuAccessTokenCache
|
||||
from review_agent.notifications.context import NotificationContext
|
||||
from review_agent.notifications.feishu_message_api import send_personal_message
|
||||
from review_agent.notifications.feishu_token import app_id_hash, get_tenant_access_token
|
||||
from review_agent.notifications.message_builder import build_feishu_post_message
|
||||
from review_agent.notifications.recipient import resolve_configured_personal_recipient
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self.payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
def test_token_service_fetches_and_caches(monkeypatch, settings):
|
||||
settings.FEISHU_APP_ID = "cli_a"
|
||||
settings.FEISHU_APP_SECRET = "secret"
|
||||
calls = []
|
||||
|
||||
def fake_post(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return FakeResponse({"code": 0, "tenant_access_token": "tenant-token", "expire": 7200})
|
||||
|
||||
monkeypatch.setattr("review_agent.notifications.feishu_token.httpx.post", fake_post)
|
||||
|
||||
first = get_tenant_access_token()
|
||||
second = get_tenant_access_token()
|
||||
|
||||
assert first.ok
|
||||
assert second.tenant_access_token == "tenant-token"
|
||||
assert len(calls) == 1
|
||||
assert FeishuAccessTokenCache.objects.get(app_id_hash=app_id_hash("cli_a")).is_valid()
|
||||
|
||||
|
||||
def test_token_service_refreshes_expired_cache(monkeypatch, settings):
|
||||
settings.FEISHU_APP_ID = "cli_a"
|
||||
settings.FEISHU_APP_SECRET = "secret"
|
||||
FeishuAccessTokenCache.objects.create(
|
||||
app_id_hash=app_id_hash("cli_a"),
|
||||
tenant_access_token="old",
|
||||
expires_at=timezone.now() - timezone.timedelta(minutes=1),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.feishu_token.httpx.post",
|
||||
lambda *args, **kwargs: FakeResponse({"code": 0, "tenant_access_token": "new", "expire": 7200}),
|
||||
)
|
||||
|
||||
assert get_tenant_access_token().tenant_access_token == "new"
|
||||
|
||||
|
||||
def test_token_service_returns_error_for_api_failure(monkeypatch, settings):
|
||||
settings.FEISHU_APP_ID = "cli_a"
|
||||
settings.FEISHU_APP_SECRET = "secret"
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.feishu_token.httpx.post",
|
||||
lambda *args, **kwargs: FakeResponse({"code": 1, "msg": "bad secret"}),
|
||||
)
|
||||
|
||||
result = get_tenant_access_token()
|
||||
|
||||
assert not result.ok
|
||||
assert result.error_message == "bad secret"
|
||||
|
||||
|
||||
def test_recipient_prefers_open_id(settings):
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
|
||||
settings.FEISHU_DEFAULT_USER_ID = "user_xxx"
|
||||
settings.FEISHU_DEFAULT_TARGET_NAME = "负责人"
|
||||
|
||||
target = resolve_configured_personal_recipient()
|
||||
|
||||
assert target.ok
|
||||
assert target.identifier_type == "open_id"
|
||||
assert target.identifier_value == "ou_xxx"
|
||||
|
||||
|
||||
def test_recipient_uses_user_id_when_open_id_missing(settings):
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = ""
|
||||
settings.FEISHU_DEFAULT_USER_ID = "user_xxx"
|
||||
|
||||
target = resolve_configured_personal_recipient()
|
||||
|
||||
assert target.ok
|
||||
assert target.identifier_type == "user_id"
|
||||
|
||||
|
||||
def test_recipient_missing(settings):
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = ""
|
||||
settings.FEISHU_DEFAULT_USER_ID = ""
|
||||
|
||||
target = resolve_configured_personal_recipient()
|
||||
|
||||
assert not target.ok
|
||||
assert target.error_code == "recipient_missing"
|
||||
|
||||
|
||||
def test_build_feishu_post_message_contains_summary(settings):
|
||||
settings.PUBLIC_BASE_URL = "http://example.test"
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
|
||||
target = resolve_configured_personal_recipient()
|
||||
context = NotificationContext(
|
||||
workflow_type="file_summary",
|
||||
workflow_name="自动汇总",
|
||||
workflow_batch_id=1,
|
||||
workflow_batch_no="FS-001",
|
||||
workflow_status="success",
|
||||
trigger_user_id=1,
|
||||
trigger_username="owner",
|
||||
title="自动汇总完成",
|
||||
summary_lines=("文件 3 个", "异常 0 个"),
|
||||
next_step="查看汇总结果",
|
||||
result_path="/summary/1/",
|
||||
)
|
||||
|
||||
payload = build_feishu_post_message(context, target)
|
||||
|
||||
assert payload["receive_id"] == "ou_xxx"
|
||||
content = json.loads(payload["content"])
|
||||
assert content["zh_cn"]["title"] == "自动汇总完成"
|
||||
assert "http://example.test/summary/1/" in payload["content"]
|
||||
|
||||
|
||||
def test_send_personal_message_success(monkeypatch, settings):
|
||||
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
|
||||
requests = []
|
||||
|
||||
def fake_post(*args, **kwargs):
|
||||
requests.append(kwargs)
|
||||
return FakeResponse({"code": 0, "data": {"message_id": "om_xxx"}})
|
||||
|
||||
monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_post)
|
||||
|
||||
result = send_personal_message(
|
||||
tenant_access_token="token",
|
||||
receive_id_type="open_id",
|
||||
payload={"receive_id": "ou_xxx"},
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.external_message_id == "om_xxx"
|
||||
assert requests[0]["headers"]["Authorization"] == "Bearer token"
|
||||
|
||||
|
||||
def test_send_personal_message_api_error(monkeypatch, settings):
|
||||
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.feishu_message_api.httpx.post",
|
||||
lambda *args, **kwargs: FakeResponse({"code": 230001, "msg": "bad receive_id"}),
|
||||
)
|
||||
|
||||
result = send_personal_message(
|
||||
tenant_access_token="token",
|
||||
receive_id_type="open_id",
|
||||
payload={"receive_id": "bad"},
|
||||
)
|
||||
|
||||
assert not result.ok
|
||||
assert result.error_code == "230001"
|
||||
|
||||
|
||||
def test_send_personal_message_refreshes_token_once(monkeypatch, settings):
|
||||
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
|
||||
calls = {"message": 0}
|
||||
|
||||
def fake_message_post(*args, **kwargs):
|
||||
calls["message"] += 1
|
||||
if calls["message"] == 1:
|
||||
return FakeResponse({"code": 99991663, "msg": "token expired"})
|
||||
return FakeResponse({"code": 0, "data": {"message_id": "om_retry"}})
|
||||
|
||||
monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_message_post)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.feishu_message_api.get_tenant_access_token",
|
||||
lambda force_refresh=False: type(
|
||||
"TokenResult",
|
||||
(),
|
||||
{"ok": True, "tenant_access_token": "fresh", "error_code": "", "error_message": ""},
|
||||
)(),
|
||||
)
|
||||
|
||||
result = send_personal_message(
|
||||
tenant_access_token="stale",
|
||||
receive_id_type="open_id",
|
||||
payload={"receive_id": "ou_xxx"},
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert result.refreshed_token
|
||||
assert calls["message"] == 2
|
||||
39
tests/test_feishu_management_commands.py
Normal file
39
tests/test_feishu_management_commands.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from dataclasses import dataclass
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
import pytest
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeRecord:
|
||||
send_status: str = "success"
|
||||
target: str = "负责人"
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
def test_send_test_feishu_notification_calls_dispatcher(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"review_agent.management.commands.send_test_feishu_notification.dispatch_workflow_notification",
|
||||
lambda context: calls.append(context) or FakeRecord(),
|
||||
)
|
||||
output = StringIO()
|
||||
|
||||
call_command("send_test_feishu_notification", "--username", user.username, stdout=output)
|
||||
|
||||
assert calls
|
||||
assert calls[0].workflow_type == "manual_test"
|
||||
assert calls[0].trigger_user_id == user.pk
|
||||
assert "send_status=success" in output.getvalue()
|
||||
assert "target=负责人" in output.getvalue()
|
||||
|
||||
|
||||
def test_send_test_feishu_notification_missing_user_raises():
|
||||
with pytest.raises(CommandError):
|
||||
call_command("send_test_feishu_notification", "--username", "missing")
|
||||
104
tests/test_feishu_models.py
Normal file
104
tests/test_feishu_models.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from django.utils import timezone
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
FeishuAccessTokenCache,
|
||||
FeishuQuestionLog,
|
||||
FeishuUserMapping,
|
||||
FileSummaryBatch,
|
||||
WorkflowNotificationRecord,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_feishu_user_mapping_preferred_identifier(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
mapping = FeishuUserMapping.objects.create(
|
||||
system_user=user,
|
||||
feishu_display_name="负责人",
|
||||
feishu_open_id="ou_open",
|
||||
feishu_user_id="user_id",
|
||||
feishu_mobile="13800000000",
|
||||
)
|
||||
|
||||
assert mapping.preferred_identifier() == ("open_id", "ou_open")
|
||||
|
||||
mapping.feishu_open_id = ""
|
||||
assert mapping.preferred_identifier() == ("user_id", "user_id")
|
||||
|
||||
mapping.feishu_user_id = ""
|
||||
assert mapping.preferred_identifier() == ("mobile", "13800000000")
|
||||
|
||||
|
||||
def test_feishu_access_token_cache_expiry():
|
||||
now = timezone.now()
|
||||
cache = FeishuAccessTokenCache.objects.create(
|
||||
app_id_hash="hash",
|
||||
tenant_access_token="token",
|
||||
expires_at=now + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
assert cache.is_valid(now=now)
|
||||
|
||||
cache.expires_at = now - timezone.timedelta(seconds=1)
|
||||
assert not cache.is_valid(now=now)
|
||||
|
||||
|
||||
def test_workflow_notification_success_dedupe_only_success(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="飞书")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-FEISHU",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
dedupe_key = WorkflowNotificationRecord.build_dedupe_key("file_summary", batch.pk, "success")
|
||||
WorkflowNotificationRecord.objects.create(
|
||||
workflow_type="file_summary",
|
||||
workflow_batch_id=batch.pk,
|
||||
workflow_batch_no=batch.batch_no,
|
||||
workflow_status="success",
|
||||
dedupe_key=dedupe_key,
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
|
||||
message_title="失败通知",
|
||||
)
|
||||
|
||||
assert not WorkflowNotificationRecord.already_successfully_sent(dedupe_key)
|
||||
|
||||
WorkflowNotificationRecord.objects.create(
|
||||
workflow_type="file_summary",
|
||||
workflow_batch_id=batch.pk,
|
||||
workflow_batch_no=batch.batch_no,
|
||||
workflow_status="success",
|
||||
dedupe_key=dedupe_key,
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
|
||||
message_title="成功通知",
|
||||
)
|
||||
|
||||
assert WorkflowNotificationRecord.already_successfully_sent(dedupe_key)
|
||||
|
||||
|
||||
def test_feishu_question_log_records_summary_without_full_answer(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
log = FeishuQuestionLog.objects.create(
|
||||
system_user=user,
|
||||
source_type=FeishuQuestionLog.SourceType.SIMULATE,
|
||||
question_text="查最新法规核查",
|
||||
intent="batch_status",
|
||||
query_object={"workflow_type": "regulatory_review", "latest": True},
|
||||
answer_summary="RR-001 成功,阻断项 0,高风险 1。",
|
||||
permission_result="allowed",
|
||||
status=FeishuQuestionLog.Status.SUCCESS,
|
||||
processed_at=timezone.now(),
|
||||
)
|
||||
|
||||
assert "完整回答" not in log.answer_summary
|
||||
assert log.query_object["latest"] is True
|
||||
160
tests/test_feishu_notification_dispatcher.py
Normal file
160
tests/test_feishu_notification_dispatcher.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileSummaryBatch, WorkflowNotificationRecord
|
||||
from review_agent.notifications.context import NotificationContext
|
||||
from review_agent.notifications.dispatcher import dispatch_workflow_notification
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeTokenResult:
|
||||
ok: bool
|
||||
tenant_access_token: str = ""
|
||||
error_code: str = ""
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FakeSendResult:
|
||||
ok: bool
|
||||
external_message_id: str = ""
|
||||
error_code: str = ""
|
||||
error_message: str = ""
|
||||
request_duration_ms: int | None = None
|
||||
|
||||
|
||||
def _context(user, batch):
|
||||
return NotificationContext(
|
||||
workflow_type="file_summary",
|
||||
workflow_name="自动汇总",
|
||||
workflow_batch_id=batch.pk,
|
||||
workflow_batch_no=batch.batch_no,
|
||||
workflow_status=batch.status,
|
||||
trigger_user_id=user.pk,
|
||||
trigger_username=user.username,
|
||||
title="自动汇总完成",
|
||||
summary_lines=("文件 1 个",),
|
||||
next_step="查看汇总",
|
||||
result_path=f"/file-summary/{batch.pk}/",
|
||||
)
|
||||
|
||||
|
||||
def _batch(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="飞书")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-DISPATCH",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
return user, batch
|
||||
|
||||
|
||||
def test_dispatch_disabled_writes_record_without_api_call(django_user_model, settings, monkeypatch):
|
||||
user, batch = _batch(django_user_model)
|
||||
settings.FEISHU_NOTIFY_ENABLED = False
|
||||
|
||||
def fail_call(*args, **kwargs):
|
||||
raise AssertionError("should not call external service")
|
||||
|
||||
monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call)
|
||||
|
||||
record = dispatch_workflow_notification(_context(user, batch))
|
||||
|
||||
assert record.send_status == WorkflowNotificationRecord.SendStatus.DISABLED
|
||||
assert record.channel == WorkflowNotificationRecord.Channel.DISABLED
|
||||
|
||||
|
||||
def test_dispatch_success_writes_success_record(django_user_model, settings, monkeypatch):
|
||||
user, batch = _batch(django_user_model)
|
||||
settings.FEISHU_NOTIFY_ENABLED = True
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.dispatcher.get_tenant_access_token",
|
||||
lambda: FakeTokenResult(ok=True, tenant_access_token="token"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.dispatcher.send_personal_message",
|
||||
lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_xxx", request_duration_ms=12),
|
||||
)
|
||||
|
||||
record = dispatch_workflow_notification(_context(user, batch))
|
||||
|
||||
assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS
|
||||
assert record.external_message_id == "om_xxx"
|
||||
assert record.sent_at is not None
|
||||
|
||||
|
||||
def test_dispatch_existing_success_skips_api(django_user_model, settings, monkeypatch):
|
||||
user, batch = _batch(django_user_model)
|
||||
settings.FEISHU_NOTIFY_ENABLED = True
|
||||
context = _context(user, batch)
|
||||
existing = WorkflowNotificationRecord.objects.create(
|
||||
workflow_type=context.workflow_type,
|
||||
workflow_batch_id=context.workflow_batch_id,
|
||||
workflow_batch_no=context.workflow_batch_no,
|
||||
workflow_status=context.workflow_status,
|
||||
dedupe_key=context.dedupe_key,
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
|
||||
message_title=context.title,
|
||||
)
|
||||
|
||||
def fail_call(*args, **kwargs):
|
||||
raise AssertionError("duplicate should not call API")
|
||||
|
||||
monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call)
|
||||
|
||||
assert dispatch_workflow_notification(context).pk == existing.pk
|
||||
|
||||
|
||||
def test_dispatch_existing_failed_allows_retry(django_user_model, settings, monkeypatch):
|
||||
user, batch = _batch(django_user_model)
|
||||
settings.FEISHU_NOTIFY_ENABLED = True
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
|
||||
context = _context(user, batch)
|
||||
WorkflowNotificationRecord.objects.create(
|
||||
workflow_type=context.workflow_type,
|
||||
workflow_batch_id=context.workflow_batch_id,
|
||||
workflow_batch_no=context.workflow_batch_no,
|
||||
workflow_status=context.workflow_status,
|
||||
dedupe_key=context.dedupe_key,
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
|
||||
message_title=context.title,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.dispatcher.get_tenant_access_token",
|
||||
lambda: FakeTokenResult(ok=True, tenant_access_token="token"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.dispatcher.send_personal_message",
|
||||
lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_retry"),
|
||||
)
|
||||
|
||||
record = dispatch_workflow_notification(context)
|
||||
|
||||
assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS
|
||||
assert WorkflowNotificationRecord.objects.filter(dedupe_key=context.dedupe_key).count() == 2
|
||||
|
||||
|
||||
def test_dispatch_token_failure_writes_failed(django_user_model, settings, monkeypatch):
|
||||
user, batch = _batch(django_user_model)
|
||||
settings.FEISHU_NOTIFY_ENABLED = True
|
||||
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
|
||||
monkeypatch.setattr(
|
||||
"review_agent.notifications.dispatcher.get_tenant_access_token",
|
||||
lambda: FakeTokenResult(ok=False, error_code="token_error", error_message="bad secret"),
|
||||
)
|
||||
|
||||
record = dispatch_workflow_notification(_context(user, batch))
|
||||
|
||||
assert record.send_status == WorkflowNotificationRecord.SendStatus.FAILED
|
||||
assert record.error_code == "token_error"
|
||||
92
tests/test_feishu_question_reserved.py
Normal file
92
tests/test_feishu_question_reserved.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
import pytest
|
||||
|
||||
from review_agent.feishu_questions.intent import parse_question_intent
|
||||
from review_agent.feishu_questions.query import query_batch_summary
|
||||
from review_agent.feishu_questions.service import answer_question
|
||||
from review_agent.models import Conversation, FeishuQuestionLog, FileSummaryBatch, RegulatoryReviewBatch
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_query_latest_regulatory_batch_for_owner(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-001")
|
||||
RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-001",
|
||||
status=RegulatoryReviewBatch.Status.SUCCESS,
|
||||
risk_summary={"blocking": 0, "high": 1},
|
||||
)
|
||||
|
||||
result = query_batch_summary(user, workflow_type="regulatory_review", latest=True)
|
||||
|
||||
assert result["ok"]
|
||||
assert result["batch_no"] == "RR-001"
|
||||
assert "高风险 1" in result["answer_summary"]
|
||||
|
||||
|
||||
def test_query_denies_other_users_batch(django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-PRIVATE")
|
||||
|
||||
result = query_batch_summary(other, batch_no=batch.batch_no)
|
||||
|
||||
assert not result["ok"]
|
||||
assert result["permission_result"] == "denied"
|
||||
|
||||
|
||||
def test_query_admin_can_access_other_users_batch(django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
admin = django_user_model.objects.create_user(username="admin", password="pass", is_staff=True)
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-ADMIN")
|
||||
|
||||
result = query_batch_summary(admin, batch_no="FS-ADMIN")
|
||||
|
||||
assert result["ok"]
|
||||
assert result["permission_result"] == "allowed"
|
||||
|
||||
|
||||
def test_parse_question_intent_recognizes_batch_latest_and_workflow():
|
||||
parsed = parse_question_intent("查最新法规核查")
|
||||
assert parsed["workflow_type"] == "regulatory_review"
|
||||
assert parsed["latest"] is True
|
||||
|
||||
parsed = parse_question_intent("AFF-20260607-001 的 Word 在哪里")
|
||||
assert parsed["workflow_type"] == "application_form_fill"
|
||||
assert parsed["batch_no"] == "AFF-20260607-001"
|
||||
assert parsed["intent"] == "export_summary"
|
||||
|
||||
|
||||
def test_answer_question_records_log_without_full_answer(django_user_model):
|
||||
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-LOG")
|
||||
|
||||
result = answer_question(user, "查最新自动汇总")
|
||||
|
||||
log = FeishuQuestionLog.objects.get(pk=result["log_id"])
|
||||
assert log.intent == "batch_status"
|
||||
assert log.query_object["workflow_type"] == "file_summary"
|
||||
assert log.answer_summary
|
||||
assert len(log.answer_summary) <= 500
|
||||
|
||||
|
||||
def test_feishu_question_simulate_command_outputs_summary(django_user_model):
|
||||
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-CMD")
|
||||
output = StringIO()
|
||||
|
||||
call_command("feishu_question_simulate", "--username", user.username, "查最新自动汇总", stdout=output)
|
||||
|
||||
assert "FS-CMD" in output.getvalue()
|
||||
96
tests/test_feishu_workflow_adapters.py
Normal file
96
tests/test_feishu_workflow_adapters.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
RegulatoryIssue,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
from review_agent.notifications.message_builder import absolute_result_url
|
||||
from review_agent.notifications.workflow_adapters import (
|
||||
build_application_form_fill_context,
|
||||
build_file_summary_context,
|
||||
build_regulatory_review_context,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_file_summary_adapter_builds_summary(settings, django_user_model):
|
||||
settings.PUBLIC_BASE_URL = "http://example.test"
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-001",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
total_files=3,
|
||||
success_files=2,
|
||||
failed_files=1,
|
||||
total_pages=15,
|
||||
)
|
||||
|
||||
context = build_file_summary_context(batch)
|
||||
|
||||
assert context.workflow_type == "file_summary"
|
||||
assert context.workflow_batch_no == "FS-001"
|
||||
assert "异常" in "\n".join(context.summary_lines)
|
||||
assert absolute_result_url(context.result_path).endswith(f"/api/review-agent/file-summary/{batch.pk}/status/")
|
||||
|
||||
|
||||
def test_regulatory_review_adapter_builds_risk_summary(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-RR")
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary_batch,
|
||||
batch_no="RR-001",
|
||||
status=RegulatoryReviewBatch.Status.SUCCESS,
|
||||
)
|
||||
RegulatoryIssue.objects.create(
|
||||
batch=batch,
|
||||
category=RegulatoryIssue.Category.COMPLETENESS,
|
||||
severity=RegulatoryIssue.Severity.BLOCKING,
|
||||
title="缺少资料",
|
||||
)
|
||||
|
||||
context = build_regulatory_review_context(batch)
|
||||
|
||||
assert context.workflow_type == "regulatory_review"
|
||||
assert "阻断项 1" in "\n".join(context.summary_lines)
|
||||
|
||||
|
||||
def test_application_form_fill_adapter_builds_export_and_conflict_summary(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-AFF")
|
||||
batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary_batch,
|
||||
batch_no="AFF-001",
|
||||
status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
|
||||
selected_templates=["registration_certificate"],
|
||||
conflict_summary=[{"field": "product_name"}],
|
||||
)
|
||||
ExportedSummaryFile.objects.create(
|
||||
batch=summary_batch,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name="filled.docx",
|
||||
storage_path="filled.docx",
|
||||
)
|
||||
|
||||
context = build_application_form_fill_context(batch)
|
||||
|
||||
assert context.workflow_type == "application_form_fill"
|
||||
assert "导出文件 1" in "\n".join(context.summary_lines)
|
||||
assert "冲突字段 1" in "\n".join(context.summary_lines)
|
||||
25
tests/test_file_summary_archive.py
Normal file
25
tests/test_file_summary_archive.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from zipfile import ZipFile
|
||||
import pytest
|
||||
|
||||
from review_agent.file_summary.services.archive import extract_archive
|
||||
|
||||
|
||||
def test_extract_zip_preserves_safe_paths(tmp_path):
|
||||
archive_path = tmp_path / "safe.zip"
|
||||
with ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("dir/a.txt", "content")
|
||||
|
||||
target = tmp_path / "out"
|
||||
extracted = extract_archive(archive_path, target)
|
||||
|
||||
assert extracted == [target / "dir" / "a.txt"]
|
||||
assert (target / "dir" / "a.txt").read_text(encoding="utf-8") == "content"
|
||||
|
||||
|
||||
def test_extract_zip_rejects_path_traversal(tmp_path):
|
||||
archive_path = tmp_path / "evil.zip"
|
||||
with ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("../evil.txt", "bad")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
extract_archive(archive_path, tmp_path / "out")
|
||||
60
tests/test_file_summary_e2e.py
Normal file
60
tests/test_file_summary_e2e.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, Message
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _browser_path() -> str | None:
|
||||
candidates = [
|
||||
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||||
Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
def test_file_summary_panel_desktop_and_mobile_with_playwright(live_server, django_user_model):
|
||||
playwright_api = pytest.importorskip("playwright.sync_api")
|
||||
executable_path = _browser_path()
|
||||
if not executable_path:
|
||||
pytest.skip("No Chrome or Edge executable available for Playwright E2E.")
|
||||
|
||||
user = django_user_model.objects.create_user(username="e2e_user", password="e2e-pass-123")
|
||||
conversation = Conversation.objects.create(user=user, title="E2E 会话")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.ASSISTANT,
|
||||
content=(
|
||||
"文件目录与页数汇总已完成。\n\n"
|
||||
"| 序号 | 文件名 | 页数 | 状态 |\n"
|
||||
"| --- | --- | --- | --- |\n"
|
||||
"| 1 | a.pdf | 4 | success |\n\n"
|
||||
"[下载 Markdown 报告](/api/review-agent/file-summary/exports/1/download/)"
|
||||
),
|
||||
)
|
||||
|
||||
with playwright_api.sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, executable_path=executable_path)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
page.goto(f"{live_server.url}/login/")
|
||||
page.fill('input[name="username"]', "e2e_user")
|
||||
page.fill('input[name="password"]', "e2e-pass-123")
|
||||
page.click('button[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/")
|
||||
|
||||
playwright_api.expect(page.locator("#summaryPanel")).to_be_visible()
|
||||
playwright_api.expect(page.locator("#uploadDropzone")).to_be_visible()
|
||||
playwright_api.expect(page.locator("#workflowCardList")).to_be_visible()
|
||||
playwright_api.expect(page.locator(".message.assistant table")).to_be_visible()
|
||||
playwright_api.expect(page.locator('.message.assistant a[href="/api/review-agent/file-summary/exports/1/download/"]')).to_be_visible()
|
||||
|
||||
page.set_viewport_size({"width": 390, "height": 844})
|
||||
playwright_api.expect(page.locator("#summaryPanel")).to_be_visible()
|
||||
playwright_api.expect(page.locator("#sidebar")).not_to_be_in_viewport()
|
||||
browser.close()
|
||||
282
tests/test_file_summary_frontend.py
Normal file
282
tests/test_file_summary_frontend.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_workspace_renders_summary_panel(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.ASSISTANT,
|
||||
content="| 序号 | 文件名 |\n| --- | --- |\n| 1 | a.pdf |\n\n[下载](/api/review-agent/file-summary/exports/1/download/)",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert 'id="summaryPanel"' in content
|
||||
assert 'id="uploadDropzone"' in content
|
||||
assert 'id="workflowCardList"' in content
|
||||
assert 'data-conversation-id="' in content
|
||||
assert 'data-message-id="' in content
|
||||
assert 'data-message-url-template="' in content
|
||||
assert 'class="message-content markdown-content"' in content
|
||||
assert 'class="message-raw"' in content
|
||||
assert "自动汇总文件目录与页数" in content
|
||||
|
||||
|
||||
def test_workspace_links_to_attachment_manager(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "附件管理" in content
|
||||
assert "视频实时监测" not in content
|
||||
assert f'href="{reverse("attachment_manager")}?conversation={conversation.pk}"' in content
|
||||
assert 'class="attachment-manager-link"' in content
|
||||
|
||||
|
||||
def test_attachment_manager_requires_conversation_selection(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
Conversation.objects.create(user=user, title="待选择会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("attachment_manager"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "附件管理" in content
|
||||
assert "请选择一个对话查看附件" in content
|
||||
assert "待选择会话" in content
|
||||
assert 'id="attachmentConversationSelect"' in content
|
||||
|
||||
|
||||
def test_attachment_manager_selects_conversation_and_lists_attachments(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="资料会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=128,
|
||||
is_active=True,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('attachment_manager')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "资料会话" in content
|
||||
assert "a.docx" in content
|
||||
assert "下载" in content
|
||||
assert "编辑" in content
|
||||
assert "删除" in content
|
||||
assert "attachment-manager-split" in content
|
||||
assert reverse("chat") + f"?conversation={conversation.pk}" in content
|
||||
|
||||
|
||||
def test_attachment_manager_uses_compact_admin_layout(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
Conversation.objects.create(user=user, title="紧凑会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("attachment_manager"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
css = open("static/css/login.css", encoding="utf-8").read()
|
||||
assert "attachment-manager-toolbar" in content
|
||||
assert "attachment-manager-content" in content
|
||||
assert "attachment-manager-select-control" in content
|
||||
assert ".attachment-manager-page" in css
|
||||
assert "align-content: start" in css
|
||||
assert ".attachment-manager-toolbar" in css
|
||||
assert ".attachment-manager-select-control" in css
|
||||
assert ".attachment-manager-split" in css
|
||||
|
||||
|
||||
def test_workspace_renders_workflow_history_as_batch_carousel(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
older = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-OLDER",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
latest = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-LATEST",
|
||||
status=FileSummaryBatch.Status.FAILED,
|
||||
error_message="解压失败",
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
batch=older,
|
||||
node_code="upload",
|
||||
node_name="附件固化",
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
message="附件固化完成",
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
batch=latest,
|
||||
node_code="extract",
|
||||
node_name="压缩包解压",
|
||||
status=WorkflowNodeRun.Status.FAILED,
|
||||
progress=10,
|
||||
message="压缩包损坏",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "workflow-batch-carousel" in content
|
||||
assert 'class="workflow-card active"' in content
|
||||
assert 'data-workflow-index="0"' in content
|
||||
assert 'data-workflow-action="prev"' in content
|
||||
assert 'data-workflow-action="next"' in content
|
||||
assert content.index("FS-LATEST") < content.index("FS-OLDER")
|
||||
assert "压缩包损坏" in content
|
||||
|
||||
|
||||
def test_frontend_prevents_long_message_overflow():
|
||||
css = open("static/css/login.css", encoding="utf-8").read()
|
||||
|
||||
assert ".message-bubble" in css
|
||||
assert "overflow-wrap: anywhere" in css
|
||||
assert "word-break: break-word" in css
|
||||
|
||||
|
||||
def test_frontend_polls_running_workflow_cards():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "startWorkflowPolling" in script
|
||||
assert "setInterval" in script
|
||||
assert "refreshRunningWorkflowCards" in script
|
||||
|
||||
|
||||
def test_frontend_updates_sidebar_conversation_by_stable_id():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "data-conversation-id" in script
|
||||
assert "setAttribute(\"data-conversation-id\"" in script
|
||||
assert ".history-item[data-conversation-id=" in script
|
||||
|
||||
|
||||
def test_frontend_refreshes_generated_workflow_messages():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "refreshConversationMessages" in script
|
||||
assert "latestMessageId" in script
|
||||
assert "data-message-url-template" in script
|
||||
|
||||
|
||||
def test_frontend_only_scrolls_after_appending_new_messages():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "return false;" in script
|
||||
assert "return true;" in script
|
||||
assert "var appendedCount = 0;" in script
|
||||
assert "if (appendConversationMessage(message))" in script
|
||||
assert "if (appendedCount > 0)" in script
|
||||
|
||||
|
||||
def test_frontend_can_replace_partial_stream_content():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert 'eventName === "replace"' in script
|
||||
assert "assistantText = payload.content" in script
|
||||
|
||||
|
||||
def test_frontend_enter_sends_and_ctrl_enter_inserts_newline():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "bindPromptKeyboardShortcuts" in script
|
||||
assert "event.key === \"Enter\"" in script
|
||||
assert "event.ctrlKey" in script
|
||||
assert "composer.requestSubmit()" in script
|
||||
|
||||
|
||||
def test_frontend_renders_workflow_error_messages():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
css = open("static/css/login.css", encoding="utf-8").read()
|
||||
|
||||
assert "payload.batch.error_message" in script
|
||||
assert "workflow-error" in script
|
||||
assert "node.message" in script
|
||||
assert ".workflow-error" in css
|
||||
|
||||
|
||||
def test_file_summary_status_includes_feishu_notification(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FEISHU")
|
||||
WorkflowNotificationRecord.objects.create(
|
||||
workflow_type="file_summary",
|
||||
workflow_batch_id=batch.pk,
|
||||
workflow_batch_no=batch.batch_no,
|
||||
workflow_status=batch.status,
|
||||
dedupe_key=f"file_summary:{batch.pk}:{batch.status}",
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
target="负责人",
|
||||
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
|
||||
message_title="自动汇总完成",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/review-agent/file-summary/{batch.pk}/status/")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["latest_notification"]["status_label"] == "飞书通知已发送"
|
||||
assert payload["notifications"][0]["target"] == "负责人"
|
||||
|
||||
|
||||
def test_frontend_renders_workflow_batches_as_carousel():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
css = open("static/css/login.css", encoding="utf-8").read()
|
||||
|
||||
assert "selectWorkflowBatchIndex" in script
|
||||
assert "refreshWorkflowBatchCarousel" in script
|
||||
assert "data-workflow-action" in script
|
||||
assert "workflow-batch-carousel" in script
|
||||
assert ".workflow-batch-controls" in css
|
||||
assert ".workflow-card.active" in css
|
||||
|
||||
|
||||
def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
assert "目录自动汇总" in content
|
||||
assert "法规核查与风险预警" in content
|
||||
assert "申报文件填表" in content
|
||||
assert "说明书审查" not in content
|
||||
assert ">风险预警</button>" not in content
|
||||
assert 'data-prompt-template="请对当前对话已上传的文件或压缩包自动汇总文件目录' in content
|
||||
assert 'data-prompt-template="请对当前对话最近成功汇总的注册资料发起 NMPA 法规核查与风险预警' in content
|
||||
assert 'data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"' in content
|
||||
assert "优先生成注册证 Word 和字段来源追溯清单" not in content
|
||||
assert "bindPromptTemplateButtons" in script
|
||||
assert "promptInput.value = template" in script
|
||||
24
tests/test_file_summary_inventory.py
Normal file
24
tests/test_file_summary_inventory.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from review_agent.file_summary.services.inventory import scan_files_to_items
|
||||
from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_scan_files_to_items_preserves_relative_paths(tmp_path, django_user_model):
|
||||
root = tmp_path / "work"
|
||||
(root / "a").mkdir(parents=True)
|
||||
(root / "a" / "one.pdf").write_bytes(b"pdf")
|
||||
(root / "two.txt").write_text("x", encoding="utf-8")
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-I")
|
||||
|
||||
items = scan_files_to_items(batch=batch, roots=[root])
|
||||
|
||||
assert [item.relative_path for item in items] == ["a/one.pdf", "two.txt"]
|
||||
assert FileSummaryItem.objects.filter(batch=batch).count() == 2
|
||||
assert items[0].statistics_status == FileSummaryItem.StatisticsStatus.SKIPPED
|
||||
113
tests/test_file_summary_models.py
Normal file
113
tests/test_file_summary_models.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
FileSummaryBatchAttachment,
|
||||
FileSummaryItem,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def create_user(username="u1"):
|
||||
return get_user_model().objects.create_user(username=username, password="pass")
|
||||
|
||||
|
||||
def test_attachment_versions_are_unique_per_conversation_and_name():
|
||||
user = create_user()
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
first = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="资料.docx",
|
||||
version_no=1,
|
||||
is_active=False,
|
||||
storage_path="media/a.docx",
|
||||
file_size=10,
|
||||
)
|
||||
second = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="资料.docx",
|
||||
version_no=2,
|
||||
storage_path="media/b.docx",
|
||||
file_size=12,
|
||||
)
|
||||
|
||||
assert first.version_no == 1
|
||||
assert second.version_no == 2
|
||||
|
||||
with pytest.raises(IntegrityError), transaction.atomic():
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="资料.docx",
|
||||
version_no=2,
|
||||
storage_path="media/c.docx",
|
||||
file_size=14,
|
||||
)
|
||||
|
||||
|
||||
def test_batch_attachment_and_item_unique_constraints():
|
||||
user = create_user()
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="资料.docx",
|
||||
storage_path="media/a.docx",
|
||||
file_size=10,
|
||||
)
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-001",
|
||||
)
|
||||
|
||||
FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment)
|
||||
with pytest.raises(IntegrityError), transaction.atomic():
|
||||
FileSummaryBatchAttachment.objects.create(batch=batch, attachment=attachment)
|
||||
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
file_name="资料.docx",
|
||||
file_type="docx",
|
||||
relative_path="资料.docx",
|
||||
storage_path="media/a.docx",
|
||||
)
|
||||
with pytest.raises(IntegrityError), transaction.atomic():
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=2,
|
||||
file_name="资料.docx",
|
||||
file_type="docx",
|
||||
relative_path="资料.docx",
|
||||
storage_path="media/a.docx",
|
||||
)
|
||||
|
||||
|
||||
def test_exported_file_traces_to_user_and_conversation():
|
||||
user = create_user()
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-002",
|
||||
)
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=batch,
|
||||
export_type=ExportedSummaryFile.ExportType.MARKDOWN,
|
||||
file_name="summary.md",
|
||||
storage_path="media/summary.md",
|
||||
)
|
||||
|
||||
assert exported.batch.user == user
|
||||
assert exported.batch.conversation == conversation
|
||||
180
tests/test_file_summary_page_count.py
Normal file
180
tests/test_file_summary_page_count.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
import shutil
|
||||
from zipfile import ZipFile
|
||||
from docx import Document
|
||||
from openpyxl import Workbook
|
||||
from pptx import Presentation
|
||||
|
||||
from review_agent.file_summary.services.page_count import count_document_pages
|
||||
from review_agent.file_summary.skills.document_page_count import DocumentPageCountSkill
|
||||
from review_agent.file_summary.skills.base import WorkflowContext
|
||||
from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_count_document_pages_for_office_formats(tmp_path):
|
||||
docx_path = tmp_path / "a.docx"
|
||||
Document().save(docx_path)
|
||||
|
||||
xlsx_path = tmp_path / "a.xlsx"
|
||||
workbook = Workbook()
|
||||
workbook.create_sheet("第二页")
|
||||
workbook.save(xlsx_path)
|
||||
|
||||
pptx_path = tmp_path / "a.pptx"
|
||||
presentation = Presentation()
|
||||
presentation.slides.add_slide(presentation.slide_layouts[6])
|
||||
presentation.save(pptx_path)
|
||||
|
||||
assert count_document_pages(docx_path).status in {"success", "uncertain"}
|
||||
assert count_document_pages(xlsx_path).page_count == 2
|
||||
assert count_document_pages(pptx_path).page_count == 1
|
||||
|
||||
|
||||
def test_count_docx_pages_from_extended_properties(tmp_path):
|
||||
docx_path = tmp_path / "with-pages.docx"
|
||||
Document().save(docx_path)
|
||||
app_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">'
|
||||
"<Pages>7</Pages>"
|
||||
"</Properties>"
|
||||
)
|
||||
rewritten = tmp_path / "rewritten.docx"
|
||||
with ZipFile(docx_path) as source, ZipFile(rewritten, "w") as target:
|
||||
for entry in source.infolist():
|
||||
if entry.filename != "docProps/app.xml":
|
||||
target.writestr(entry, source.read(entry.filename))
|
||||
target.writestr("docProps/app.xml", app_xml)
|
||||
shutil.move(rewritten, docx_path)
|
||||
|
||||
result = count_document_pages(docx_path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.page_count == 7
|
||||
|
||||
|
||||
def test_count_docx_pages_uses_word_com_fallback(monkeypatch, tmp_path):
|
||||
docx_path = tmp_path / "without-pages.docx"
|
||||
Document().save(docx_path)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_docx_pages_from_extended_properties",
|
||||
lambda path: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_word_pages_with_com",
|
||||
lambda path: 22,
|
||||
)
|
||||
|
||||
result = count_document_pages(docx_path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.page_count == 22
|
||||
|
||||
|
||||
def test_count_doc_pages_uses_word_com_fallback(monkeypatch, tmp_path):
|
||||
doc_path = tmp_path / "legacy.doc"
|
||||
doc_path.write_bytes(b"legacy-doc-placeholder")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._can_try_com_fallback",
|
||||
lambda path, ext: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_word_pages_with_com",
|
||||
lambda path: 5,
|
||||
)
|
||||
|
||||
result = count_document_pages(doc_path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.page_count == 5
|
||||
|
||||
|
||||
def test_count_ppt_pages_uses_powerpoint_com_fallback(monkeypatch, tmp_path):
|
||||
ppt_path = tmp_path / "legacy.ppt"
|
||||
ppt_path.write_bytes(b"legacy-ppt-placeholder")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._can_try_com_fallback",
|
||||
lambda path, ext: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_powerpoint_slides_with_com",
|
||||
lambda path: 9,
|
||||
)
|
||||
|
||||
result = count_document_pages(ppt_path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.page_count == 9
|
||||
|
||||
|
||||
def test_count_excel_pages_uses_excel_com_fallback(monkeypatch, tmp_path):
|
||||
xls_path = tmp_path / "legacy.xls"
|
||||
xls_path.write_bytes(b"legacy-xls-placeholder")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._can_try_com_fallback",
|
||||
lambda path, ext: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_excel_sheets_with_com",
|
||||
lambda path: 3,
|
||||
)
|
||||
|
||||
result = count_document_pages(xls_path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.page_count == 3
|
||||
|
||||
|
||||
def test_invalid_xlsx_does_not_start_excel_com(monkeypatch, tmp_path):
|
||||
xlsx_path = tmp_path / "broken.xlsx"
|
||||
xlsx_path.write_bytes(b"not a real workbook")
|
||||
|
||||
def fail_if_called(path):
|
||||
raise AssertionError("Excel COM should not start for invalid xlsx signatures")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.file_summary.services.page_count._count_excel_sheets_with_com",
|
||||
fail_if_called,
|
||||
)
|
||||
|
||||
result = count_document_pages(xlsx_path)
|
||||
|
||||
assert result.status == "uncertain"
|
||||
|
||||
|
||||
def test_document_page_count_skill_marks_unsupported_and_success(tmp_path, django_user_model):
|
||||
xlsx_path = tmp_path / "a.xlsx"
|
||||
workbook = Workbook()
|
||||
workbook.save(xlsx_path)
|
||||
txt_path = tmp_path / "a.txt"
|
||||
txt_path.write_text("x", encoding="utf-8")
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-P")
|
||||
xlsx_item = FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
file_name="a.xlsx",
|
||||
file_type="xlsx",
|
||||
relative_path="a.xlsx",
|
||||
storage_path=str(xlsx_path),
|
||||
)
|
||||
txt_item = FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=2,
|
||||
file_name="a.txt",
|
||||
file_type="txt",
|
||||
relative_path="a.txt",
|
||||
storage_path=str(txt_path),
|
||||
)
|
||||
|
||||
result = DocumentPageCountSkill().run(WorkflowContext(batch=batch))
|
||||
|
||||
xlsx_item.refresh_from_db()
|
||||
txt_item.refresh_from_db()
|
||||
assert result.success is True
|
||||
assert xlsx_item.statistics_status == FileSummaryItem.StatisticsStatus.SUCCESS
|
||||
assert txt_item.statistics_status == FileSummaryItem.StatisticsStatus.UNSUPPORTED
|
||||
29
tests/test_file_summary_product_detect.py
Normal file
29
tests/test_file_summary_product_detect.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.file_summary.services.product_detect import detect_product_name
|
||||
from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_detect_product_name_from_top_level_directory(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="新对话 06-06")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-D")
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
file_name="说明书.docx",
|
||||
file_type="docx",
|
||||
relative_path="甲型试剂盒/说明书.docx",
|
||||
storage_path="x",
|
||||
)
|
||||
|
||||
product_name = detect_product_name(batch)
|
||||
|
||||
batch.refresh_from_db()
|
||||
conversation.refresh_from_db()
|
||||
assert product_name == "甲型试剂盒"
|
||||
assert batch.product_name == "甲型试剂盒"
|
||||
assert conversation.title == "甲型试剂盒-文件汇总"
|
||||
82
tests/test_file_summary_report.py
Normal file
82
tests/test_file_summary_report.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from review_agent.file_summary.services.export_excel import generate_excel_export
|
||||
from review_agent.file_summary.services.report import generate_markdown_report
|
||||
from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem, Message
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def make_batch(tmp_path, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-R",
|
||||
work_dir=str(tmp_path),
|
||||
total_files=1,
|
||||
success_files=1,
|
||||
total_pages=2,
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
file_name="a.xlsx",
|
||||
file_type="xlsx",
|
||||
relative_path="a.xlsx",
|
||||
storage_path=str(tmp_path / "a.xlsx"),
|
||||
page_count=2,
|
||||
statistics_status=FileSummaryItem.StatisticsStatus.SUCCESS,
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
def test_generate_markdown_report_creates_export_and_summary(tmp_path, django_user_model):
|
||||
batch = make_batch(tmp_path, django_user_model)
|
||||
|
||||
exported, summary = generate_markdown_report(batch)
|
||||
|
||||
assert exported.export_type == "markdown"
|
||||
assert Path(exported.storage_path).exists()
|
||||
assert "| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 |" in summary
|
||||
assert "a.xlsx" in Path(exported.storage_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_generate_excel_export_contains_summary_and_items(tmp_path, django_user_model):
|
||||
batch = make_batch(tmp_path, django_user_model)
|
||||
|
||||
exported = generate_excel_export(batch)
|
||||
|
||||
workbook = load_workbook(exported.storage_path)
|
||||
assert workbook.sheetnames == ["汇总信息", "文件明细"]
|
||||
assert workbook["文件明细"]["C2"].value == "a.xlsx"
|
||||
|
||||
|
||||
def test_workflow_report_node_writes_assistant_message(tmp_path, settings, django_user_model):
|
||||
from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
|
||||
from review_agent.models import FileAttachment
|
||||
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.FILE_SUMMARY_ASYNC = False
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
file_path = tmp_path / "a.xlsx"
|
||||
file_path.write_bytes(b"not a real workbook")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.txt",
|
||||
storage_path=str(file_path),
|
||||
file_size=file_path.stat().st_size,
|
||||
)
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user)
|
||||
batch.work_dir = str(tmp_path / "batch")
|
||||
batch.save(update_fields=["work_dir"])
|
||||
|
||||
start_file_summary_workflow(batch, async_run=False)
|
||||
|
||||
assert Message.objects.filter(conversation=conversation, role=Message.Role.ASSISTANT).exists()
|
||||
46
tests/test_file_summary_skills.py
Normal file
46
tests/test_file_summary_skills.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import pytest
|
||||
import logging
|
||||
|
||||
from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext
|
||||
from review_agent.file_summary.skills.registry import SkillRegistry
|
||||
|
||||
|
||||
class EchoSkill(BaseSkill):
|
||||
name = "echo"
|
||||
|
||||
def run(self, context):
|
||||
return SkillResult(success=True, data={"batch_id": context.batch.id})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_skill_registry_executes_registered_skill(django_user_model):
|
||||
from review_agent.models import Conversation, FileSummaryBatch
|
||||
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-X")
|
||||
registry = SkillRegistry()
|
||||
registry.register(EchoSkill())
|
||||
|
||||
result = registry.execute("echo", WorkflowContext(batch=batch))
|
||||
|
||||
assert result.success is True
|
||||
assert result.data == {"batch_id": batch.id}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_skill_registry_logs_skill_lifecycle(caplog, django_user_model):
|
||||
from review_agent.models import Conversation, FileSummaryBatch
|
||||
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-LOG")
|
||||
registry = SkillRegistry()
|
||||
registry.register(EchoSkill())
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="review_agent.file_summary"):
|
||||
registry.execute("echo", WorkflowContext(batch=batch))
|
||||
|
||||
messages = [record.getMessage() for record in caplog.records]
|
||||
assert any("Skill started" in message and "echo" in message for message in messages)
|
||||
assert any("Skill finished" in message and "echo" in message for message in messages)
|
||||
48
tests/test_file_summary_storage.py
Normal file
48
tests/test_file_summary_storage.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
import pytest
|
||||
|
||||
from review_agent.file_summary.storage import save_uploaded_attachment
|
||||
from review_agent.models import Conversation, FileAttachment
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_save_uploaded_attachment_versions_same_name(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="会话")
|
||||
|
||||
first = save_uploaded_attachment(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
uploaded_file=SimpleUploadedFile("资料.docx", b"first"),
|
||||
)
|
||||
second = save_uploaded_attachment(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
uploaded_file=SimpleUploadedFile("资料.docx", b"second"),
|
||||
)
|
||||
|
||||
first.refresh_from_db()
|
||||
assert first.version_no == 1
|
||||
assert first.is_active is False
|
||||
assert second.version_no == 2
|
||||
assert second.is_active is True
|
||||
assert FileAttachment.objects.filter(conversation=conversation).count() == 2
|
||||
assert (tmp_path / second.storage_path).read_bytes() == b"second"
|
||||
|
||||
|
||||
def test_save_uploaded_attachment_rejects_path_traversal(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="会话")
|
||||
|
||||
attachment = save_uploaded_attachment(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
uploaded_file=SimpleUploadedFile("../资料.docx", b"content"),
|
||||
)
|
||||
|
||||
assert ".." not in attachment.storage_path
|
||||
assert (tmp_path / attachment.storage_path).exists()
|
||||
73
tests/test_file_summary_trigger.py
Normal file
73
tests/test_file_summary_trigger.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.file_summary.workflow_trigger import (
|
||||
evaluate_attachment_reader_trigger,
|
||||
evaluate_file_summary_trigger,
|
||||
)
|
||||
from review_agent.models import Conversation, FileAttachment
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_trigger_matches_keywords_only_when_active_attachment_exists(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
no_file = evaluate_file_summary_trigger(conversation, "请自动汇总文件目录与页数")
|
||||
assert no_file.should_start is False
|
||||
assert no_file.reason == "missing_attachment"
|
||||
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
|
||||
matched = evaluate_file_summary_trigger(conversation, "请自动汇总文件目录与页数")
|
||||
assert matched.should_start is True
|
||||
assert matched.workflow_type == "file_summary"
|
||||
|
||||
normal = evaluate_file_summary_trigger(conversation, "你好,帮我解释法规")
|
||||
assert normal.should_start is False
|
||||
assert normal.reason == "not_matched"
|
||||
|
||||
|
||||
def test_attachment_reader_trigger_matches_file_content_phrases(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
missing = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍")
|
||||
assert missing.should_start is False
|
||||
assert missing.reason == "missing_attachment"
|
||||
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="resume.docx",
|
||||
storage_path="x/resume.docx",
|
||||
file_size=1,
|
||||
)
|
||||
|
||||
matched = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍")
|
||||
assert matched.should_start is True
|
||||
assert matched.workflow_type == "attachment_reader"
|
||||
|
||||
|
||||
def test_attachment_reader_trigger_matches_resume_project_experience_request(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="resume.docx",
|
||||
storage_path="x/resume.docx",
|
||||
file_size=1,
|
||||
)
|
||||
|
||||
matched = evaluate_attachment_reader_trigger(conversation, "阅读下附件简历中的项目经历")
|
||||
|
||||
assert matched.should_start is True
|
||||
assert matched.workflow_type == "attachment_reader"
|
||||
370
tests/test_file_summary_views.py
Normal file
370
tests/test_file_summary_views.py
Normal file
@@ -0,0 +1,370 @@
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
Message,
|
||||
RegulatoryReviewBatch,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_upload_attachments_requires_conversation_owner(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
client.force_login(other)
|
||||
|
||||
response = client.post(
|
||||
reverse("file_summary_attachment_upload", args=[conversation.pk]),
|
||||
{"files": [SimpleUploadedFile("a.docx", b"a")]},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_attachment_api_requires_login(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk]))
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
def test_upload_and_list_current_conversation_attachments(client, 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="会话")
|
||||
client.force_login(user)
|
||||
|
||||
upload_response = client.post(
|
||||
reverse("file_summary_attachment_upload", args=[conversation.pk]),
|
||||
{
|
||||
"files": [
|
||||
SimpleUploadedFile("a.docx", b"a", content_type="application/docx"),
|
||||
SimpleUploadedFile("b.zip", b"b", content_type="application/zip"),
|
||||
]
|
||||
},
|
||||
)
|
||||
list_response = client.get(reverse("file_summary_attachment_list", args=[conversation.pk]))
|
||||
|
||||
assert upload_response.status_code == 200
|
||||
assert upload_response.json()["attachments"][0]["original_name"] == "a.docx"
|
||||
assert len(list_response.json()["attachments"]) == 2
|
||||
|
||||
|
||||
def test_delete_attachment_is_logical_and_scoped(client, 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="会话")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk]))
|
||||
|
||||
attachment.refresh_from_db()
|
||||
assert response.status_code == 200
|
||||
assert attachment.upload_status == FileAttachment.UploadStatus.DELETED
|
||||
assert attachment.is_active is False
|
||||
|
||||
|
||||
def test_export_download_requires_batch_owner(client, tmp_path, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-DL")
|
||||
report_path = tmp_path / "summary.md"
|
||||
report_path.write_text("ok", encoding="utf-8")
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=batch,
|
||||
export_type=ExportedSummaryFile.ExportType.MARKDOWN,
|
||||
file_name="summary.md",
|
||||
storage_path=str(report_path),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("file_summary_export_download", args=[exported.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(reverse("file_summary_export_download", args=[exported.pk]))
|
||||
assert allowed.status_code == 200
|
||||
assert "attachment" in allowed["Content-Disposition"]
|
||||
assert "summary.md" in allowed["Content-Disposition"]
|
||||
assert allowed["Content-Type"].startswith("text/markdown")
|
||||
|
||||
|
||||
def test_export_download_checks_application_form_fill_batch_owner(client, tmp_path, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
owner_conversation = Conversation.objects.create(user=owner, title="自动填表")
|
||||
other_conversation = Conversation.objects.create(user=other, title="其他对话")
|
||||
owner_summary = FileSummaryBatch.objects.create(
|
||||
conversation=owner_conversation,
|
||||
user=owner,
|
||||
batch_no="FS-AFF-OWNER",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
other_summary = FileSummaryBatch.objects.create(
|
||||
conversation=other_conversation,
|
||||
user=other,
|
||||
batch_no="FS-AFF-OTHER",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
form_batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=owner_conversation,
|
||||
user=owner,
|
||||
source_summary_batch=owner_summary,
|
||||
batch_no="AFF-DL",
|
||||
)
|
||||
report_path = tmp_path / "filled.docx"
|
||||
report_path.write_bytes(b"word-content")
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=other_summary,
|
||||
workflow_type="application_form_fill",
|
||||
workflow_batch_id=form_batch.pk,
|
||||
export_category="filled_template",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name="filled.docx",
|
||||
storage_path=str(report_path),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("file_summary_export_download", args=[exported.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(reverse("file_summary_export_download", args=[exported.pk]))
|
||||
assert allowed.status_code == 200
|
||||
assert allowed["Content-Type"].startswith(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
assert b"".join(allowed.streaming_content) == b"word-content"
|
||||
|
||||
|
||||
def test_conversation_messages_returns_incremental_messages(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
first = Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.USER,
|
||||
content="用户消息",
|
||||
)
|
||||
second = Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.ASSISTANT,
|
||||
content="报告消息",
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("review_agent_conversation_messages", args=[conversation.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
response = client.get(
|
||||
f"{reverse('review_agent_conversation_messages', args=[conversation.pk])}?after={first.pk}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["latest_message_id"] == second.pk
|
||||
assert payload["messages"] == [
|
||||
{
|
||||
"id": second.pk,
|
||||
"role": Message.Role.ASSISTANT,
|
||||
"content": "报告消息",
|
||||
"created_at": second.created_at.isoformat(),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_batch_status_exposes_batch_and_node_errors(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-ERR",
|
||||
status=FileSummaryBatch.Status.FAILED,
|
||||
error_message="压缩包解压失败",
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
batch=batch,
|
||||
node_code="extract",
|
||||
node_name="压缩包解压",
|
||||
status=WorkflowNodeRun.Status.FAILED,
|
||||
progress=10,
|
||||
message="未解出任何可扫描文件",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("file_summary_batch_status", args=[batch.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["batch"]["error_message"] == "压缩包解压失败"
|
||||
assert payload["nodes"][0]["message"] == "未解出任何可扫描文件"
|
||||
|
||||
|
||||
def test_conversation_list_api_returns_owned_conversations_with_attachment_counts(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
owned = Conversation.objects.create(user=owner, title="有附件会话")
|
||||
Conversation.objects.create(user=other, title="其他用户会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=owned,
|
||||
user=owner,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=owned,
|
||||
user=owner,
|
||||
original_name="deleted.docx",
|
||||
storage_path="x/deleted.docx",
|
||||
file_size=1,
|
||||
upload_status=FileAttachment.UploadStatus.DELETED,
|
||||
is_active=False,
|
||||
)
|
||||
client.force_login(owner)
|
||||
|
||||
response = client.get(reverse("review_agent_conversation_list"))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [item["title"] for item in payload["conversations"]] == ["有附件会话"]
|
||||
assert payload["conversations"][0]["attachment_count"] == 1
|
||||
|
||||
|
||||
def test_conversation_delete_api_removes_owned_conversation(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
owned = Conversation.objects.create(user=user, title="待删除")
|
||||
other_conversation = Conversation.objects.create(user=other, title="别人的会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("review_agent_conversation_detail", args=[owned.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert not Conversation.objects.filter(pk=owned.pk).exists()
|
||||
assert Conversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_conversation_delete_api_removes_protected_workflow_dependents(client, 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-DELETE-PROTECTED",
|
||||
)
|
||||
regulatory_batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary_batch,
|
||||
batch_no="RR-DELETE-PROTECTED",
|
||||
)
|
||||
form_batch = ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary_batch,
|
||||
source_regulatory_batch=regulatory_batch,
|
||||
batch_no="AFF-DELETE-PROTECTED",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("review_agent_conversation_detail", args=[conversation.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert not Conversation.objects.filter(pk=conversation.pk).exists()
|
||||
assert not FileSummaryBatch.objects.filter(pk=summary_batch.pk).exists()
|
||||
assert not RegulatoryReviewBatch.objects.filter(pk=regulatory_batch.pk).exists()
|
||||
assert not ApplicationFormFillBatch.objects.filter(pk=form_batch.pk).exists()
|
||||
|
||||
|
||||
def test_conversation_delete_api_rejects_unowned_conversation(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
other_conversation = Conversation.objects.create(user=other, title="别人的会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("review_agent_conversation_detail", args=[other_conversation.pk]))
|
||||
|
||||
assert response.status_code == 404
|
||||
assert Conversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_patch_attachment_updates_name_and_active_state(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="old.docx",
|
||||
storage_path="x/old.docx",
|
||||
file_size=1,
|
||||
is_active=True,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
reverse("file_summary_attachment_detail", args=[conversation.pk, attachment.pk]),
|
||||
data=json.dumps({"original_name": "new.docx", "is_active": False}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
attachment.refresh_from_db()
|
||||
assert response.status_code == 200
|
||||
assert attachment.original_name == "new.docx"
|
||||
assert attachment.is_active is False
|
||||
assert response.json()["attachment"]["original_name"] == "new.docx"
|
||||
|
||||
|
||||
def test_attachment_download_requires_owner_and_returns_file(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
attachment_path = tmp_path / "uploads" / "a.docx"
|
||||
attachment_path.parent.mkdir(parents=True)
|
||||
attachment_path.write_bytes(b"attachment-content")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
original_name="a.docx",
|
||||
storage_path=str(attachment_path),
|
||||
file_size=attachment_path.stat().st_size,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(reverse("file_summary_attachment_download", args=[conversation.pk, attachment.pk]))
|
||||
assert allowed.status_code == 200
|
||||
assert "attachment" in allowed["Content-Disposition"]
|
||||
assert "a.docx" in allowed["Content-Disposition"]
|
||||
assert b"".join(allowed.streaming_content) == b"attachment-content"
|
||||
366
tests/test_file_summary_workflow.py
Normal file
366
tests/test_file_summary_workflow.py
Normal file
@@ -0,0 +1,366 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
from review_agent.file_summary.services import archive as archive_service
|
||||
from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
|
||||
from review_agent.skill_router import SkillRoute
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
FileSummaryBatchAttachment,
|
||||
Message,
|
||||
WorkflowEvent,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
from review_agent.services import stream_message
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_batch_binds_active_attachments_and_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="自动汇总")
|
||||
active = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="old.docx",
|
||||
is_active=False,
|
||||
storage_path="x/old.docx",
|
||||
file_size=1,
|
||||
)
|
||||
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user, trigger_message=message)
|
||||
|
||||
assert batch.status == FileSummaryBatch.Status.PENDING
|
||||
assert FileSummaryBatchAttachment.objects.get(batch=batch).attachment == active
|
||||
active.refresh_from_db()
|
||||
assert active.upload_status == FileAttachment.UploadStatus.BOUND
|
||||
assert batch.work_dir
|
||||
assert WorkflowNodeRun.objects.filter(batch=batch).count() >= 6
|
||||
assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_created").exists()
|
||||
|
||||
|
||||
def test_start_file_summary_workflow_runs_synchronously_for_tests(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="自动汇总")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user, trigger_message=message)
|
||||
|
||||
start_file_summary_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert batch.status == FileSummaryBatch.Status.SUCCESS
|
||||
assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_completed").exists()
|
||||
|
||||
|
||||
def test_file_summary_workflow_dispatches_completion_notification(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user)
|
||||
calls = []
|
||||
|
||||
def fake_dispatch(context):
|
||||
calls.append(context)
|
||||
|
||||
monkeypatch.setattr("review_agent.file_summary.workflow.dispatch_workflow_notification", fake_dispatch)
|
||||
|
||||
start_file_summary_workflow(batch, async_run=False)
|
||||
|
||||
assert calls
|
||||
assert calls[-1].workflow_type == "file_summary"
|
||||
assert calls[-1].workflow_batch_id == batch.pk
|
||||
|
||||
|
||||
def test_workflow_extracts_archive_and_scans_extracted_files(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="会话")
|
||||
archive_path = tmp_path / "upload.zip"
|
||||
with ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("folder/a.pdf", b"%PDF-1.4\n%%EOF")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="upload.zip",
|
||||
storage_path=str(archive_path),
|
||||
file_size=archive_path.stat().st_size,
|
||||
)
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user)
|
||||
|
||||
start_file_summary_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert batch.total_files == 1
|
||||
assert batch.items.get().file_name == "a.pdf"
|
||||
assert not batch.items.filter(file_type="zip").exists()
|
||||
|
||||
|
||||
def test_workflow_marks_archive_extract_failure_visible(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="会话")
|
||||
archive_path = tmp_path / "empty.zip"
|
||||
with ZipFile(archive_path, "w"):
|
||||
pass
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="empty.zip",
|
||||
storage_path=str(archive_path),
|
||||
file_size=archive_path.stat().st_size,
|
||||
)
|
||||
batch = create_file_summary_batch(conversation=conversation, user=user)
|
||||
|
||||
start_file_summary_workflow(batch, async_run=False)
|
||||
|
||||
batch.refresh_from_db()
|
||||
extract_node = batch.node_runs.get(node_code="extract")
|
||||
assert batch.status == FileSummaryBatch.Status.FAILED
|
||||
assert "未解出任何可扫描文件" in batch.error_message
|
||||
assert extract_node.status == WorkflowNodeRun.Status.FAILED
|
||||
assert "未解出任何可扫描文件" in extract_node.message
|
||||
failed_event = WorkflowEvent.objects.filter(
|
||||
batch=batch,
|
||||
event_type="node_progress",
|
||||
payload__status=WorkflowNodeRun.Status.FAILED,
|
||||
).latest("id")
|
||||
assert "未解出任何可扫描文件" in failed_event.payload["message"]
|
||||
|
||||
|
||||
def test_rar_extract_uses_python_libarchive_before_7z(monkeypatch, tmp_path):
|
||||
archive_path = tmp_path / "sample.rar"
|
||||
archive_path.write_bytes(b"rar")
|
||||
target_dir = tmp_path / "out"
|
||||
calls = []
|
||||
|
||||
def fake_libarchive_extract(path: Path, target: Path):
|
||||
calls.append(("libarchive", path, target))
|
||||
extracted = target / "a.docx"
|
||||
extracted.parent.mkdir(parents=True, exist_ok=True)
|
||||
extracted.write_bytes(b"doc")
|
||||
return [extracted]
|
||||
|
||||
def fake_7z_extract(path: Path, target: Path):
|
||||
calls.append(("7z", path, target))
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(archive_service, "_extract_rar_with_libarchive", fake_libarchive_extract)
|
||||
monkeypatch.setattr(archive_service, "_extract_rar_with_7z", fake_7z_extract)
|
||||
|
||||
extracted = archive_service.extract_archive(archive_path, target_dir)
|
||||
|
||||
assert [path.name for path in extracted] == ["a.docx"]
|
||||
assert calls == [("libarchive", archive_path, target_dir)]
|
||||
|
||||
|
||||
def test_stream_message_returns_workflow_meta_when_triggered(settings, django_user_model):
|
||||
settings.FILE_SUMMARY_ASYNC = False
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "请自动汇总文件目录与页数"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "workflow_started" in joined
|
||||
assert "\"workflow_type\": \"file_summary\"" in joined
|
||||
assert FileSummaryBatch.objects.filter(conversation=conversation).exists()
|
||||
|
||||
|
||||
def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
calls = []
|
||||
|
||||
def fake_stream_reply(conversation, content, knowledge_context=""):
|
||||
calls.append(knowledge_context)
|
||||
yield "普通回复"
|
||||
|
||||
monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=3: {
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"source": "用户知识库/1/2/孙之烨-260510.pdf",
|
||||
"text": "孙之烨负责审核智能体项目。",
|
||||
"score": 0.23,
|
||||
}
|
||||
],
|
||||
"error_message": "",
|
||||
},
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "孙之烨是谁"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "普通回复" in joined
|
||||
assert "workflow_started" not in joined
|
||||
assert calls
|
||||
assert "孙之烨负责审核智能体项目" in calls[0]
|
||||
assert "用户知识库/1/2/孙之烨-260510.pdf" in calls[0]
|
||||
|
||||
|
||||
def test_stream_message_meta_uses_first_prompt_title_for_new_conversation(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="新对话 01-01 10:00")
|
||||
|
||||
def fake_stream_reply(conversation, content):
|
||||
yield "普通回复"
|
||||
|
||||
monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply)
|
||||
|
||||
frames = list(stream_message(conversation, "这是第一条新对话消息"))
|
||||
|
||||
assert '"title": "这是第一条新对话消息"' in frames[0]
|
||||
assert '"title": "这是第一条新对话消息"' in frames[-1]
|
||||
|
||||
|
||||
def test_stream_message_reads_active_attachment_when_requested(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="会话")
|
||||
attachment_path = tmp_path / "uploads" / "detail.txt"
|
||||
attachment_path.parent.mkdir(parents=True)
|
||||
attachment_path.write_text("合同编号:RA-2026\n结论:附件阅读成功", encoding="utf-8")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="detail.txt",
|
||||
storage_path="uploads/detail.txt",
|
||||
file_size=attachment_path.stat().st_size,
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "请阅读附件并给出详情"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "附件解析结果" in joined
|
||||
assert "detail.txt" in joined
|
||||
assert "RA-2026" in joined
|
||||
assert "workflow_started" not in joined
|
||||
|
||||
|
||||
def test_stream_message_falls_back_to_non_stream_reply_when_stream_breaks(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
def broken_stream_reply(conversation, content, knowledge_context=""):
|
||||
yield "已生成部分内容"
|
||||
raise RuntimeError("provider connection reset")
|
||||
|
||||
monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.generate_reply",
|
||||
lambda conversation, content, knowledge_context="": "非流式完整回复",
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "注册检验报告审核要点有哪些"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "已生成部分内容" in joined
|
||||
assert "replace" in joined
|
||||
assert "非流式完整回复" in joined
|
||||
assert "done" in joined
|
||||
assistant_message = Message.objects.get(conversation=conversation, role=Message.Role.ASSISTANT)
|
||||
assert assistant_message.content == "非流式完整回复"
|
||||
|
||||
|
||||
def test_stream_message_uses_llm_router_for_attachment_reader(
|
||||
monkeypatch,
|
||||
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="会话")
|
||||
attachment_path = tmp_path / "uploads" / "resume.txt"
|
||||
attachment_path.parent.mkdir(parents=True)
|
||||
attachment_path.write_text("项目经历:负责审核智能体附件解析模块。", encoding="utf-8")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="resume.txt",
|
||||
storage_path="uploads/resume.txt",
|
||||
file_size=attachment_path.stat().st_size,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.route_message_intent",
|
||||
lambda conversation, content: SkillRoute(
|
||||
action="attachment_reader",
|
||||
skill_name="attachment_reader",
|
||||
confidence=0.91,
|
||||
reason="需要读取上传简历。",
|
||||
source="llm",
|
||||
),
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "帮我整理其中的项目经历"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "附件解析结果" in joined
|
||||
assert "审核智能体附件解析模块" in joined
|
||||
assert "模型调用失败" not in joined
|
||||
|
||||
|
||||
def test_stream_message_uses_llm_router_for_file_summary(monkeypatch, settings, django_user_model):
|
||||
settings.FILE_SUMMARY_ASYNC = False
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="a.docx",
|
||||
storage_path="x/a.docx",
|
||||
file_size=1,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.route_message_intent",
|
||||
lambda conversation, content: SkillRoute(
|
||||
action="file_summary",
|
||||
workflow_type="file_summary",
|
||||
confidence=0.93,
|
||||
reason="需要执行文件目录与页数汇总。",
|
||||
source="llm",
|
||||
),
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "处理一下这批资料"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "workflow_started" in joined
|
||||
assert "\"workflow_type\": \"file_summary\"" in joined
|
||||
assert FileSummaryBatch.objects.filter(conversation=conversation).exists()
|
||||
146
tests/test_home_dashboard.py
Normal file
146
tests/test_home_dashboard.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
KnowledgeBaseDocument,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_home_dashboard_renders_current_user_metrics(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="注册资料会话")
|
||||
other_conversation = Conversation.objects.create(user=other, title="其他用户会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="active.docx",
|
||||
storage_path="x/active.docx",
|
||||
file_size=128,
|
||||
is_active=True,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="deleted.docx",
|
||||
storage_path="x/deleted.docx",
|
||||
file_size=128,
|
||||
is_active=False,
|
||||
upload_status=FileAttachment.UploadStatus.DELETED,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=other_conversation,
|
||||
user=other,
|
||||
original_name="other.docx",
|
||||
storage_path="x/other.docx",
|
||||
file_size=128,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="法规资料",
|
||||
original_name="rule.md",
|
||||
storage_path="kb/rule.md",
|
||||
file_size=64,
|
||||
is_active=True,
|
||||
indexed_chunk_count=3,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="删除资料",
|
||||
original_name="deleted.md",
|
||||
storage_path="kb/deleted.md",
|
||||
file_size=64,
|
||||
status=KnowledgeBaseDocument.Status.DELETED,
|
||||
is_active=False,
|
||||
indexed_chunk_count=5,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=other,
|
||||
display_name="其他资料",
|
||||
original_name="other.md",
|
||||
storage_path="kb/other.md",
|
||||
file_size=64,
|
||||
indexed_chunk_count=9,
|
||||
)
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-RUN",
|
||||
status=FileSummaryBatch.Status.RUNNING,
|
||||
)
|
||||
RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-WAIT",
|
||||
status=RegulatoryReviewBatch.Status.WAITING_USER,
|
||||
risk_summary={"high": 2},
|
||||
)
|
||||
ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-OK",
|
||||
status=ApplicationFormFillBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryBatch.objects.create(
|
||||
conversation=other_conversation,
|
||||
user=other,
|
||||
batch_no="FS-OTHER",
|
||||
status=FileSummaryBatch.Status.FAILED,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("home"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "注册资料审核工作台" in content
|
||||
assert "当前账号资料、知识库、附件与审核处理数据总览" in content
|
||||
assert "工作流流程" not in content
|
||||
assert "对话总数" in content
|
||||
assert "附件总数" in content
|
||||
assert "知识库材料" in content
|
||||
assert "内置材料" in content
|
||||
assert f"管理 {1} · 内置" in content
|
||||
assert "向量片段" in content
|
||||
assert "FS-RUN" in content
|
||||
assert "RR-WAIT" in content
|
||||
assert "AFF-OK" in content
|
||||
assert "FS-OTHER" not in content
|
||||
assert "其他用户会话" not in content
|
||||
assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content
|
||||
|
||||
|
||||
def test_chat_route_renders_review_agent_workspace(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="审核会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "审核智能体" in content
|
||||
assert 'id="summaryPanel"' in content
|
||||
assert f'action="{reverse("chat")}"' in content
|
||||
assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content
|
||||
|
||||
|
||||
def test_legacy_home_conversation_redirects_to_chat(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="旧入口会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response["Location"] == f"{reverse('chat')}?conversation={conversation.pk}"
|
||||
345
tests/test_knowledge_base.py
Normal file
345
tests/test_knowledge_base.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.knowledge_base import (
|
||||
build_knowledge_base_context,
|
||||
delete_document,
|
||||
index_managed_document,
|
||||
search_knowledge_base,
|
||||
update_document,
|
||||
)
|
||||
from review_agent.views import rebuild_knowledge_base_index
|
||||
from review_agent.models import KnowledgeBaseDocument
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_knowledge_base_context_reports_rule_and_sources():
|
||||
context = build_knowledge_base_context()
|
||||
|
||||
assert context["rule"]["code"] == "nmpa_ivd_registration_v1"
|
||||
assert context["rule"]["requirement_count"] > 0
|
||||
assert context["source_count"] > 0
|
||||
assert context["collection_name"] == "nmpa_ivd_registration_v1"
|
||||
assert not any("模拟题二" in source["relative_path"] for source in context["sources"])
|
||||
|
||||
|
||||
def test_knowledge_base_page_requires_login(client):
|
||||
response = client.get(reverse("knowledge_base_manager"))
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
def test_knowledge_base_page_renders_for_user(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("knowledge_base_manager"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "知识库管理" in response.content.decode("utf-8")
|
||||
assert "RAG 检索测试" in response.content.decode("utf-8")
|
||||
content = response.content.decode("utf-8")
|
||||
tabbar = content[content.index('<div class="tabbar"') : content.index("</div>", content.index('<div class="tabbar"'))]
|
||||
assert tabbar.index("审核智能体") < tabbar.index("知识库管理") < tabbar.index("附件管理")
|
||||
assert "data-rebuild-url=" in content
|
||||
assert 'id="knowledgeRebuildIndexButton"' in content
|
||||
assert "重建索引" in content
|
||||
assert 'data-source-action="index"' in content
|
||||
assert "手动入库" in content
|
||||
|
||||
|
||||
def test_knowledge_base_status_api(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("knowledge_base_status"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rule"]["code"] == "nmpa_ivd_registration_v1"
|
||||
|
||||
|
||||
def test_knowledge_base_rebuild_index_api(client, django_user_model, monkeypatch):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.views.rebuild_knowledge_base_index",
|
||||
lambda: calls.append("rebuild") or {"chunk_count": 12},
|
||||
)
|
||||
|
||||
response = client.post(reverse("knowledge_base_rebuild_index"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["chunk_count"] == 12
|
||||
assert response.json()["knowledge_base"]["collection"]["count"] >= 0
|
||||
assert calls == ["rebuild"]
|
||||
|
||||
|
||||
def test_rebuild_knowledge_base_index_requests_reset(settings, tmp_path, monkeypatch):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma"
|
||||
settings.REGULATORY_RAG_CHROMA_PATH.mkdir()
|
||||
stale_file = settings.REGULATORY_RAG_CHROMA_PATH / "chroma.sqlite3"
|
||||
stale_file.write_text("stale", encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("review_agent.views.load_rule_file", lambda: {"source_material_dir": "docs/0.原始材料"})
|
||||
monkeypatch.setattr("review_agent.views.get_embedding_provider", lambda: "provider")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.views.build_chroma_index",
|
||||
lambda source_dir, embedding_provider, reset=False: calls.append(
|
||||
{
|
||||
"source_dir": source_dir,
|
||||
"embedding_provider": embedding_provider,
|
||||
"reset": reset,
|
||||
}
|
||||
)
|
||||
or 8,
|
||||
)
|
||||
|
||||
payload = rebuild_knowledge_base_index()
|
||||
|
||||
assert payload["chunk_count"] == 8
|
||||
assert calls[0]["embedding_provider"] == "provider"
|
||||
assert calls[0]["reset"] is True
|
||||
|
||||
|
||||
def test_knowledge_base_search_rejects_blank_query():
|
||||
payload = search_knowledge_base("")
|
||||
|
||||
assert payload["results"] == []
|
||||
assert "请输入" in payload["error_message"]
|
||||
|
||||
|
||||
def test_knowledge_base_search_filters_deleted_managed_documents(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
deleted_document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
status=KnowledgeBaseDocument.Status.DELETED,
|
||||
is_active=False,
|
||||
indexed_chunk_count=7,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.knowledge_base.retrieve_citations",
|
||||
lambda *args, **kwargs: [
|
||||
{
|
||||
"source": "用户知识库/1/1/孙之烨-260510.pdf",
|
||||
"text": "孙之烨负责审核智能体项目。",
|
||||
"score": 0.2,
|
||||
"metadata": {"source_type": "managed_document", "document_id": deleted_document.pk},
|
||||
},
|
||||
{
|
||||
"source": "法规材料.doc",
|
||||
"text": "注册检验报告要求。",
|
||||
"score": 0.3,
|
||||
"metadata": {"source_type": "regulatory_document"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
payload = search_knowledge_base("孙之烨是谁")
|
||||
|
||||
assert [item["source"] for item in payload["results"]] == ["法规材料.doc"]
|
||||
|
||||
|
||||
def test_knowledge_base_search_api_returns_payload(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(reverse("knowledge_base_search"), {"query": "注册检验报告要求"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {"query", "results", "error_message"}
|
||||
|
||||
|
||||
def test_knowledge_base_document_crud_api(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma"
|
||||
settings.REGULATORY_RAG_PROVIDER = "deterministic"
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
upload_response = client.post(
|
||||
reverse("knowledge_base_document_list"),
|
||||
{
|
||||
"display_name": "注册检验报告要求",
|
||||
"description": "用于法规依据检索",
|
||||
"is_active": "true",
|
||||
"file": SimpleUploadedFile("report.md", b"# report", content_type="text/markdown"),
|
||||
},
|
||||
)
|
||||
|
||||
assert upload_response.status_code == 200
|
||||
document_id = upload_response.json()["document"]["id"]
|
||||
document = KnowledgeBaseDocument.objects.get(pk=document_id)
|
||||
assert document.display_name == "注册检验报告要求"
|
||||
assert document.indexed_chunk_count > 0
|
||||
|
||||
list_response = client.get(reverse("knowledge_base_document_list"))
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["documents"][0]["display_name"] == "注册检验报告要求"
|
||||
|
||||
detail_response = client.get(reverse("knowledge_base_document_detail", args=[document_id]))
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["document"]["original_name"] == "report.md"
|
||||
assert "已入库" in detail_response.json()["document"]["indexed_label"]
|
||||
|
||||
patch_response = client.patch(
|
||||
reverse("knowledge_base_document_detail", args=[document_id]),
|
||||
data='{"display_name": "更新后的法规材料", "is_active": false}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["document"]["display_name"] == "更新后的法规材料"
|
||||
assert patch_response.json()["document"]["is_active"] is False
|
||||
|
||||
delete_response = client.delete(reverse("knowledge_base_document_detail", args=[document_id]))
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
assert KnowledgeBaseDocument.objects.get(pk=document_id).status == KnowledgeBaseDocument.Status.DELETED
|
||||
|
||||
|
||||
def test_delete_document_removes_managed_chunks_from_index(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
indexed_chunk_count=7,
|
||||
metadata={"index_status": "indexed", "index_error": ""},
|
||||
)
|
||||
deleted_filters = []
|
||||
|
||||
class FakeCollection:
|
||||
def delete(self, where):
|
||||
deleted_filters.append(where)
|
||||
|
||||
monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection())
|
||||
|
||||
delete_document(document)
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.status == KnowledgeBaseDocument.Status.DELETED
|
||||
assert document.is_active is False
|
||||
assert document.indexed_chunk_count == 0
|
||||
assert document.metadata["index_status"] == "deleted"
|
||||
assert deleted_filters == [{"document_id": document.pk}]
|
||||
|
||||
|
||||
def test_disabling_document_removes_managed_chunks_from_index(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
status=KnowledgeBaseDocument.Status.ACTIVE,
|
||||
is_active=True,
|
||||
indexed_chunk_count=7,
|
||||
metadata={"index_status": "indexed", "index_error": ""},
|
||||
)
|
||||
deleted_filters = []
|
||||
|
||||
class FakeCollection:
|
||||
def delete(self, where):
|
||||
deleted_filters.append(where)
|
||||
|
||||
monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection())
|
||||
|
||||
update_document(document, {"is_active": False})
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.status == KnowledgeBaseDocument.Status.DISABLED
|
||||
assert document.is_active is False
|
||||
assert document.indexed_chunk_count == 0
|
||||
assert document.metadata["index_status"] == "disabled"
|
||||
assert deleted_filters == [{"document_id": document.pk}]
|
||||
|
||||
|
||||
def test_inactive_document_manual_index_clears_existing_chunks(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
status=KnowledgeBaseDocument.Status.DISABLED,
|
||||
is_active=False,
|
||||
indexed_chunk_count=7,
|
||||
metadata={"index_status": "indexed", "index_error": ""},
|
||||
)
|
||||
deleted_filters = []
|
||||
|
||||
class FakeCollection:
|
||||
def delete(self, where):
|
||||
deleted_filters.append(where)
|
||||
|
||||
monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection())
|
||||
|
||||
chunk_count = index_managed_document(document)
|
||||
|
||||
document.refresh_from_db()
|
||||
assert chunk_count == 0
|
||||
assert document.indexed_chunk_count == 0
|
||||
assert document.metadata["index_status"] == "disabled"
|
||||
assert deleted_filters == [{"document_id": document.pk}]
|
||||
|
||||
|
||||
def test_knowledge_base_document_api_is_scoped_to_owner(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=owner,
|
||||
display_name="法规材料",
|
||||
original_name="a.md",
|
||||
storage_path="knowledge_base/a.md",
|
||||
file_size=1,
|
||||
)
|
||||
client.force_login(other)
|
||||
|
||||
response = client.patch(
|
||||
reverse("knowledge_base_document_detail", args=[document.pk]),
|
||||
data='{"display_name": "越权修改"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_knowledge_base_document_manual_index_api(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "chroma"
|
||||
settings.REGULATORY_RAG_PROVIDER = "deterministic"
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
source_path = tmp_path / "manual.md"
|
||||
source_path.write_text("# manual\n注册检验报告要求", encoding="utf-8")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="manual.md",
|
||||
original_name="manual.md",
|
||||
storage_path=str(source_path),
|
||||
file_size=source_path.stat().st_size,
|
||||
indexed_chunk_count=0,
|
||||
)
|
||||
|
||||
response = client.post(reverse("knowledge_base_document_index", args=[document.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.indexed_chunk_count > 0
|
||||
assert "已入库" in response.json()["document"]["indexed_label"]
|
||||
54
tests/test_llm_streaming.py
Normal file
54
tests/test_llm_streaming.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import io
|
||||
from urllib import request
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.llm import build_messages, stream_reply
|
||||
from review_agent.models import Conversation
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
class FakeStreamingResponse:
|
||||
def __iter__(self):
|
||||
return iter(
|
||||
[
|
||||
b'data: {"choices":[{"delta":{"content":"A"}}]}\n\n',
|
||||
b"data: not-json\n\n",
|
||||
b'data: {"choices":[{"delta":{"content":"B"}}]}\n\n',
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
|
||||
def test_stream_reply_skips_malformed_sse_data(monkeypatch, settings, django_user_model):
|
||||
settings.LLM_API_KEY = "key"
|
||||
settings.LLM_MODEL = "model"
|
||||
settings.LLM_BASE_URL = "https://example.test/v1"
|
||||
monkeypatch.setattr(request, "urlopen", lambda req, timeout: FakeStreamingResponse())
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
chunks = list(stream_reply(conversation, "你好"))
|
||||
|
||||
assert chunks == ["A", "B"]
|
||||
|
||||
|
||||
def test_build_messages_includes_knowledge_context(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
messages = build_messages(conversation, "孙之烨是谁", knowledge_context="来源:简历\n孙之烨负责审核智能体项目。")
|
||||
|
||||
assert messages[0]["role"] == "system"
|
||||
assert messages[1]["role"] == "system"
|
||||
assert "全局知识库" in messages[1]["content"]
|
||||
assert "孙之烨负责审核智能体项目" in messages[1]["content"]
|
||||
assert messages[-1] == {"role": "user", "content": "孙之烨是谁"}
|
||||
31
tests/test_logging_filters.py
Normal file
31
tests/test_logging_filters.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import logging
|
||||
|
||||
from review_agent.logging_filters import SuppressWorkflowStatusPollFilter
|
||||
|
||||
|
||||
def test_suppress_workflow_status_poll_filter_hides_status_poll_requests():
|
||||
record = logging.LogRecord(
|
||||
name="django.server",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=1,
|
||||
msg='"GET /api/review-agent/regulatory-review/7/status/ HTTP/1.1" 200 1660',
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
assert SuppressWorkflowStatusPollFilter().filter(record) is False
|
||||
|
||||
|
||||
def test_suppress_workflow_status_poll_filter_keeps_other_requests():
|
||||
record = logging.LogRecord(
|
||||
name="django.server",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=1,
|
||||
msg='"POST /api/review-agent/regulatory-review/7/conditions/ HTTP/1.1" 200 256',
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
assert SuppressWorkflowStatusPollFilter().filter(record) is True
|
||||
72
tests/test_regulatory_completeness.py
Normal file
72
tests/test_regulatory_completeness.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileSummaryBatch, FileSummaryItem
|
||||
from review_agent.regulatory_review.services.completeness_check import run_completeness_check
|
||||
from review_agent.regulatory_review.services.rule_loader import load_rule_file
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_completeness_check_matches_existing_files_and_reports_missing(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-CHECK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
file_name="产品技术要求.docx",
|
||||
file_type="docx",
|
||||
relative_path="产品技术要求.docx",
|
||||
storage_path="x/product.docx",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=2,
|
||||
file_name="说明书.docx",
|
||||
file_type="docx",
|
||||
relative_path="说明书.docx",
|
||||
storage_path="x/ifu.docx",
|
||||
)
|
||||
|
||||
findings = run_completeness_check(batch, load_rule_file())
|
||||
|
||||
titles = [finding.title for finding in findings]
|
||||
assert "缺少3.4注册检验报告" in titles
|
||||
assert "缺少产品技术要求" not in titles
|
||||
missing = next(finding for finding in findings if finding.rule_code == "registration_test_report")
|
||||
assert missing.severity == "blocking"
|
||||
assert missing.category == "completeness"
|
||||
|
||||
|
||||
def test_completeness_check_matches_attachment4_directory_names(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-A4",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=batch,
|
||||
file_index=1,
|
||||
directory_level="1. 监管信息 / 1.2 申请表",
|
||||
file_name="注册申请表.pdf",
|
||||
file_type="pdf",
|
||||
relative_path="1.监管信息/1.2申请表/注册申请表.pdf",
|
||||
storage_path="x/app.pdf",
|
||||
)
|
||||
|
||||
findings = run_completeness_check(batch, load_rule_file())
|
||||
|
||||
assert not any(finding.rule_code == "attachment4_1_2_application_form" for finding in findings)
|
||||
missing_qms = next(finding for finding in findings if finding.rule_code == "attachment4_6_quality_system")
|
||||
assert missing_qms.title == "缺少6质量管理体系文件"
|
||||
assert missing_qms.severity == "high"
|
||||
assert missing_qms.evidence["searched_fields"] == ["file_name", "relative_path", "directory_level"]
|
||||
306
tests/test_regulatory_condition.py
Normal file
306
tests/test_regulatory_condition.py
Normal file
@@ -0,0 +1,306 @@
|
||||
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_detect_regulatory_condition_prefers_attachment_fields_over_chapter_title(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="第1章 监管信息",
|
||||
)
|
||||
application = tmp_path / "application.txt"
|
||||
application.write_text(
|
||||
"产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒\n预期用途:用于人血清中甲胎蛋白检测\n注册类型:首次注册\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="1. 监管信息 / 1.2 申请表",
|
||||
file_name="申请表.txt",
|
||||
file_type="txt",
|
||||
relative_path="1.监管信息/申请表.txt",
|
||||
storage_path=str(application),
|
||||
)
|
||||
|
||||
candidates = detect_regulatory_condition_candidates(summary)
|
||||
|
||||
assert candidates["product_name"]["suggested"] == "甲胎蛋白检测试剂盒"
|
||||
assert candidates["model_spec"]["suggested"] == "20人份/盒"
|
||||
assert candidates["intended_use"]["suggested"] == "用于人血清中甲胎蛋白检测"
|
||||
|
||||
|
||||
def test_detect_regulatory_condition_keeps_wrapped_product_name(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="第1章 监管信息",
|
||||
)
|
||||
application = tmp_path / "application.txt"
|
||||
application.write_text(
|
||||
"产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n"
|
||||
"(荧光PCR法)\n"
|
||||
"型号规格:24人份/盒\n"
|
||||
"预期用途:用于呼吸道合胞病毒、肺炎支原体核酸检测\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="1. 监管信息 / 1.2 申请表",
|
||||
file_name="申请表.txt",
|
||||
file_type="txt",
|
||||
relative_path="1.监管信息/申请表.txt",
|
||||
storage_path=str(application),
|
||||
)
|
||||
|
||||
candidates = detect_regulatory_condition_candidates(summary)
|
||||
|
||||
assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"
|
||||
assert candidates["model_spec"]["suggested"] == "24人份/盒"
|
||||
|
||||
|
||||
def test_detect_regulatory_condition_uses_llm_review_for_better_product_name(
|
||||
monkeypatch, settings, tmp_path, django_user_model
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
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-COND",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
product_name="第1章 监管信息",
|
||||
)
|
||||
application = tmp_path / "application.txt"
|
||||
application.write_text(
|
||||
"产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n"
|
||||
"型号规格:24人份/盒\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="1. 监管信息 / 1.2 申请表",
|
||||
file_name="申请表.txt",
|
||||
file_type="txt",
|
||||
relative_path="1.监管信息/申请表.txt",
|
||||
storage_path=str(application),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.regulatory_review.services.llm_review.generate_completion",
|
||||
lambda messages, temperature=0.0: json.dumps(
|
||||
{"fields": {"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"}},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
|
||||
candidates = detect_regulatory_condition_candidates(summary)
|
||||
|
||||
assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"
|
||||
assert candidates["product_name"]["source"] == "llm"
|
||||
|
||||
|
||||
def test_detect_regulatory_condition_infers_fields_from_unlabeled_attachment_text(
|
||||
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="第1章 监管信息",
|
||||
)
|
||||
standard_list = tmp_path / "standard_list.txt"
|
||||
standard_list.write_text(
|
||||
"国家药品监督管理局:\n"
|
||||
"卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂"
|
||||
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
product_list = tmp_path / "product_list.txt"
|
||||
product_list.write_text(
|
||||
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n"
|
||||
"(荧光PCR法)\n"
|
||||
"产品的包装规格\n"
|
||||
"24人份/盒、48人份/盒\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="第1章 监管信息",
|
||||
file_name="符合标准的清单.txt",
|
||||
file_type="txt",
|
||||
relative_path="第1章 监管信息/符合标准的清单.txt",
|
||||
storage_path=str(standard_list),
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=2,
|
||||
directory_level="第1章 监管信息",
|
||||
file_name="产品列表.txt",
|
||||
file_type="txt",
|
||||
relative_path="第1章 监管信息/产品列表.txt",
|
||||
storage_path=str(product_list),
|
||||
)
|
||||
|
||||
candidates = detect_regulatory_condition_candidates(summary)
|
||||
|
||||
assert candidates["product_category"]["suggested"] == "体外诊断试剂"
|
||||
assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"
|
||||
assert candidates["product_name"]["source"] == "inferred"
|
||||
assert candidates["model_spec"]["suggested"] == "24人份/盒、48人份/盒"
|
||||
|
||||
|
||||
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
|
||||
27
tests/test_regulatory_consistency.py
Normal file
27
tests/test_regulatory_consistency.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from review_agent.regulatory_review.services.consistency_check import run_consistency_check
|
||||
|
||||
|
||||
def test_consistency_check_reports_product_name_mismatch():
|
||||
document_texts = {
|
||||
"说明书.docx": "产品名称:甲胎蛋白检测试剂盒\n型号规格:20人份/盒\n预期用途:定量检测AFP",
|
||||
"技术要求.docx": "产品名称:乙肝表面抗原检测试剂盒\n型号规格:20人份/盒\n预期用途:定量检测AFP",
|
||||
}
|
||||
|
||||
findings = run_consistency_check(document_texts)
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].category == "consistency"
|
||||
assert "产品名称" in findings[0].title
|
||||
|
||||
|
||||
def test_consistency_check_reports_registration_scope_fields():
|
||||
document_texts = {
|
||||
"申请表.docx": "管理类别:第二类\n分类编码:6840\n注册类型:首次注册\n临床评价路径:免临床",
|
||||
"综述资料.docx": "管理类别:第三类\n分类编码:6840\n注册类型:首次注册\n临床评价路径:临床试验",
|
||||
}
|
||||
|
||||
findings = run_consistency_check(document_texts)
|
||||
titles = [finding.title for finding in findings]
|
||||
|
||||
assert "管理类别在不同文件中不一致" in titles
|
||||
assert "临床评价路径在不同文件中不一致" in titles
|
||||
49
tests/test_regulatory_export.py
Normal file
49
tests/test_regulatory_export.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileSummaryBatch,
|
||||
RegulatoryIssue,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
from review_agent.regulatory_review.services.export import export_review_results
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_export_review_results_creates_markdown_excel_and_json(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")
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-EXPORT",
|
||||
risk_summary={"blocking": 1},
|
||||
)
|
||||
RegulatoryIssue.objects.create(
|
||||
batch=batch,
|
||||
rule_code="registration_test_report",
|
||||
category=RegulatoryIssue.Category.COMPLETENESS,
|
||||
severity=RegulatoryIssue.Severity.BLOCKING,
|
||||
title="缺少注册检验报告",
|
||||
suggestion="请补充注册检验报告并复核。",
|
||||
)
|
||||
|
||||
exports = export_review_results(batch)
|
||||
|
||||
assert {export.export_type for export in exports} == {
|
||||
ExportedSummaryFile.ExportType.MARKDOWN,
|
||||
ExportedSummaryFile.ExportType.EXCEL,
|
||||
ExportedSummaryFile.ExportType.JSON,
|
||||
}
|
||||
json_export = next(export for export in exports if export.export_type == ExportedSummaryFile.ExportType.JSON)
|
||||
payload = json.loads(open(json_export.storage_path, encoding="utf-8").read())
|
||||
assert payload["batch_no"] == "RR-EXPORT"
|
||||
assert payload["issues"][0]["title"] == "缺少注册检验报告"
|
||||
265
tests/test_regulatory_frontend.py
Normal file
265
tests/test_regulatory_frontend.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
FileSummaryBatch,
|
||||
FileSummaryItem,
|
||||
RegulatoryArtifact,
|
||||
RegulatoryNotificationRecord,
|
||||
RegulatoryReviewBatch,
|
||||
WorkflowNotificationRecord,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_workspace_renders_regulatory_workflow_card(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-CARD",
|
||||
status=RegulatoryReviewBatch.Status.SUCCESS,
|
||||
risk_summary={"blocking": 1, "high": 1},
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_review",
|
||||
workflow_batch_id=regulatory.pk,
|
||||
node_group="regulatory_review",
|
||||
node_code="risk_assess",
|
||||
node_name="风险评估",
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "RR-CARD" in content
|
||||
assert 'data-workflow-type="regulatory_review"' in content
|
||||
assert "阻断项 1" in content
|
||||
assert "风险评估" in content
|
||||
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('chat')}?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
|
||||
form_index = content.index("data-condition-confirm-form")
|
||||
summary_index = content.index('id="summaryPanel"')
|
||||
assert form_index < summary_index
|
||||
assert "data-condition-confirm-form" not in content[summary_index:]
|
||||
|
||||
|
||||
def test_workspace_refreshes_incomplete_condition_confirmation_candidates(client, 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,
|
||||
product_name="第1章 监管信息",
|
||||
)
|
||||
application = tmp_path / "application.txt"
|
||||
application.write_text(
|
||||
"卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂"
|
||||
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。",
|
||||
encoding="utf-8",
|
||||
)
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="第1章 监管信息",
|
||||
file_name="符合标准的清单.txt",
|
||||
file_type="txt",
|
||||
relative_path="第1章 监管信息/符合标准的清单.txt",
|
||||
storage_path=str(application),
|
||||
)
|
||||
RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-WAIT-EMPTY",
|
||||
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": ""},
|
||||
},
|
||||
},
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "体外诊断试剂" in content
|
||||
assert "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)" in content
|
||||
|
||||
|
||||
def test_workspace_renders_rectification_actions_and_summaries(client, tmp_path, 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-RECTIFY",
|
||||
status=RegulatoryReviewBatch.Status.SUCCESS,
|
||||
)
|
||||
record_path = tmp_path / "review_record.json"
|
||||
record_path.write_text('{"items":[{"status":"review_passed"}]}', encoding="utf-8")
|
||||
RegulatoryArtifact.objects.create(
|
||||
batch=regulatory,
|
||||
artifact_type=RegulatoryArtifact.ArtifactType.JSON,
|
||||
name="review_record.json",
|
||||
storage_path=str(record_path),
|
||||
metadata={"artifact": "review_record"},
|
||||
)
|
||||
RegulatoryNotificationRecord.objects.create(
|
||||
batch=regulatory,
|
||||
channel=RegulatoryNotificationRecord.Channel.MOCK,
|
||||
target="法规整改负责人",
|
||||
status=RegulatoryNotificationRecord.Status.SENT,
|
||||
payload={"title": "缺少申请表"},
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "data-rectification-action=\"full-review\"" in content
|
||||
assert "data-rectification-action=\"issue-review\"" in content
|
||||
assert "通知 1" in content
|
||||
assert "复核记录 1" 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
|
||||
assert "ensureConditionConfirmationCard" in script
|
||||
assert "condition_confirmation" in script
|
||||
assert "bindRectificationActionButtons" in script
|
||||
assert "data-rectification-action" in script
|
||||
|
||||
|
||||
def test_frontend_polls_regulatory_workflow_with_explicit_workflow_type():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "function startWorkflowPolling(batchId, workflow_type)" in script
|
||||
assert "startWorkflowPolling(payload.batch_id, payload.workflow_type)" in script
|
||||
assert 'startWorkflowPolling(batchId, "regulatory_review")' in script
|
||||
assert 'workflow_type || (card ? card.getAttribute("data-workflow-type") || "file_summary" : "file_summary")' in script
|
||||
|
||||
|
||||
def test_frontend_keeps_single_condition_confirmation_prompt():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert "data-condition-confirmation-card" in script
|
||||
assert "removeStaleConditionConfirmationCards" in script
|
||||
assert '[data-condition-confirmation-card]' in script
|
||||
|
||||
|
||||
def test_regulatory_status_includes_failed_feishu_notification(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-RR")
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-FEISHU",
|
||||
)
|
||||
WorkflowNotificationRecord.objects.create(
|
||||
workflow_type="regulatory_review",
|
||||
workflow_batch_id=batch.pk,
|
||||
workflow_batch_no=batch.batch_no,
|
||||
workflow_status=batch.status,
|
||||
dedupe_key=f"regulatory_review:{batch.pk}:{batch.status}",
|
||||
trigger_user=user,
|
||||
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
|
||||
target="负责人",
|
||||
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
|
||||
message_title="法规核查完成",
|
||||
error_message="bad receive_id",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/review-agent/regulatory-review/{batch.pk}/status/")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["latest_notification"]["status_label"] == "飞书通知失败"
|
||||
assert payload["latest_notification"]["error_message"] == "bad receive_id"
|
||||
88
tests/test_regulatory_info_package_field_extract.py
Normal file
88
tests/test_regulatory_info_package_field_extract.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
|
||||
from review_agent.regulatory_info_package.schemas import InstructionExtractResult
|
||||
from review_agent.regulatory_info_package.services.field_extract import extract_fields_by_rules, run_parallel_extract
|
||||
|
||||
|
||||
def test_extract_fields_by_rules_finds_product_name_and_storage():
|
||||
instruction = InstructionExtractResult(
|
||||
source_file_name="目标产品说明书.docx",
|
||||
paragraphs=["产品名称:新型冠状病毒检测试剂盒", "储存条件:2-8℃保存"],
|
||||
sections={},
|
||||
tables=[],
|
||||
component_tables=[],
|
||||
front_text="产品名称:新型冠状病毒检测试剂盒\n储存条件:2-8℃保存",
|
||||
)
|
||||
|
||||
result = extract_fields_by_rules(instruction)
|
||||
|
||||
assert result["product_name"]["value"] == "新型冠状病毒检测试剂盒"
|
||||
assert result["storage_condition"]["value"] == "2-8℃保存"
|
||||
|
||||
|
||||
def test_extract_fields_by_rules_uses_registrant_or_manufacturer_for_applicant():
|
||||
instruction = InstructionExtractResult(
|
||||
source_file_name="目标产品说明书.docx",
|
||||
paragraphs=[
|
||||
"注册人/售后服务单位名称:卡尤迪生物科技宜兴有限公司",
|
||||
"生产企业名称:卡尤迪生物科技宜兴有限公司",
|
||||
"生产企业住所:宜兴经济技术开发区杏里路10号宜兴光电产业园4幢101室、102室",
|
||||
"联系方式: 0510-80330909, 0510-80330919",
|
||||
"生产地址:江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室",
|
||||
],
|
||||
sections={},
|
||||
tables=[],
|
||||
component_tables=[],
|
||||
front_text="",
|
||||
)
|
||||
|
||||
result = extract_fields_by_rules(instruction)
|
||||
|
||||
assert result["applicant_name"]["value"] == "卡尤迪生物科技宜兴有限公司"
|
||||
assert result["manufacturer_name"]["value"] == "卡尤迪生物科技宜兴有限公司"
|
||||
assert result["applicant_address"]["value"] == "宜兴经济技术开发区杏里路10号宜兴光电产业园4幢101室、102室"
|
||||
assert result["applicant_contact"]["value"] == "0510-80330909, 0510-80330919"
|
||||
assert result["production_address"]["value"] == "江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室"
|
||||
|
||||
|
||||
def test_extract_fields_by_rules_serializes_component_table_and_notes():
|
||||
instruction = InstructionExtractResult(
|
||||
source_file_name="目标产品说明书.docx",
|
||||
paragraphs=[],
|
||||
sections={"【主要组成成分】": "表1 规格A大包装试剂盒组成成分\n注:不同批号试剂盒中各组分不得互换使用。"},
|
||||
tables=[],
|
||||
component_tables=[
|
||||
{
|
||||
"header": ["组分", "主要组成成分", "规格(24人份/盒)", "规格(48人份/盒)"],
|
||||
"rows": [
|
||||
["PCR反应液 I", "逆转录酶、Taq酶", "840μL/管×1管", "840μL/管×2管"],
|
||||
["阳性对照品", "含目的片段的假病毒", "600μL/管×2管", "1200μL/管×2管"],
|
||||
],
|
||||
}
|
||||
],
|
||||
front_text="",
|
||||
)
|
||||
|
||||
result = extract_fields_by_rules(instruction)
|
||||
payload = json.loads(result["component_table"]["value"])
|
||||
|
||||
assert payload["header"][0:2] == ["组分", "主要组成成分"]
|
||||
assert payload["rows"][0][0] == "PCR反应液 I"
|
||||
assert result["component_notes"]["value"] == "表1 规格A大包装试剂盒组成成分\n注:不同批号试剂盒中各组分不得互换使用。"
|
||||
|
||||
|
||||
def test_run_parallel_extract_keeps_rule_result_when_llm_fails():
|
||||
instruction = InstructionExtractResult(
|
||||
source_file_name="目标产品说明书.docx",
|
||||
paragraphs=["产品名称:测试产品"],
|
||||
sections={},
|
||||
tables=[],
|
||||
component_tables=[],
|
||||
front_text="产品名称:测试产品",
|
||||
)
|
||||
|
||||
result = run_parallel_extract(instruction, llm_extract_func=lambda _instruction: (_ for _ in ()).throw(ValueError("bad llm")))
|
||||
|
||||
assert result["regex_results"]["product_name"]["value"] == "测试产品"
|
||||
assert result["llm_results"] == {}
|
||||
assert result["llm_error"]
|
||||
24
tests/test_regulatory_info_package_field_merge.py
Normal file
24
tests/test_regulatory_info_package_field_merge.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from review_agent.regulatory_info_package.services.field_merge import merge_fields
|
||||
|
||||
|
||||
def test_merge_fields_marks_missing_llm_only_and_conflict():
|
||||
merged, summary = merge_fields(
|
||||
{
|
||||
"product_name": {"value": "规则产品", "evidence": "说明书", "confidence": 0.8, "label": "产品名称"},
|
||||
"applicant_name": {"value": "", "evidence": "", "confidence": 0.0, "label": "申请人名称"},
|
||||
"package_specification": {"value": "24人份/盒", "evidence": "表格", "confidence": 0.7, "label": "包装规格"},
|
||||
},
|
||||
{
|
||||
"intended_use": {"value": "用于检测", "evidence": "LLM", "confidence": 0.6, "label": "预期用途"},
|
||||
"package_specification": {"value": "48人份/盒", "evidence": "LLM", "confidence": 0.6, "label": "包装规格"},
|
||||
},
|
||||
)
|
||||
|
||||
assert merged["applicant_name"].value == "/"
|
||||
assert merged["applicant_name"].highlight_reason == "missing"
|
||||
assert merged["intended_use"].highlight_reason == "llm_only"
|
||||
assert merged["package_specification"].value == "24人份/盒"
|
||||
assert merged["package_specification"].highlight_reason == "conflict"
|
||||
assert any(item["field_key"] == "applicant_name" for item in summary["missing_fields"])
|
||||
assert len(summary["llm_only_fields"]) == 1
|
||||
assert len(summary["conflict_fields"]) == 1
|
||||
45
tests/test_regulatory_info_package_frontend.py
Normal file
45
tests/test_regulatory_info_package_frontend.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import Conversation, RegulatoryInfoPackageBatch, WorkflowNodeRun
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_workspace_renders_regulatory_info_package_chip_and_card(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-CARD",
|
||||
status=RegulatoryInfoPackageBatch.Status.SUCCESS,
|
||||
generated_files=[{"status": "success"} for _ in range(7)],
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="regulatory_info_package",
|
||||
node_code="zip_export",
|
||||
node_name="打包下载",
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
content = response.content.decode("utf-8")
|
||||
|
||||
assert "第1章监管信息" in content
|
||||
assert 'data-workflow-type="regulatory_info_package"' in content
|
||||
assert "data-regulatory-info-package-status-url-template" in content
|
||||
assert "RIP-CARD" in content
|
||||
|
||||
|
||||
def test_frontend_selects_regulatory_info_package_status_url():
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
assert 'workflow_type === "regulatory_info_package"' in script
|
||||
assert "data-regulatory-info-package-status-url-template" in script
|
||||
|
||||
48
tests/test_regulatory_info_package_input_select.py
Normal file
48
tests/test_regulatory_info_package_input_select.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileAttachment
|
||||
from review_agent.regulatory_info_package.services.input_select import select_instruction_input
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_select_instruction_input_prefers_message_filename(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
selected = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="目标产品说明书.docx",
|
||||
storage_path="uploads/target.docx",
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="其他说明书.docx",
|
||||
storage_path="uploads/other.docx",
|
||||
)
|
||||
|
||||
result = select_instruction_input(conversation, "请使用目标产品说明书生成第1章监管信息")
|
||||
|
||||
assert result.status == "selected"
|
||||
assert result.attachment == selected
|
||||
assert result.file_name == "目标产品说明书.docx"
|
||||
|
||||
|
||||
def test_select_instruction_input_waits_on_multiple_candidates(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
for name in ["A说明书.docx", "B说明书.docx"]:
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name=name,
|
||||
storage_path=f"uploads/{name}",
|
||||
)
|
||||
|
||||
result = select_instruction_input(conversation, "生成第1章监管信息")
|
||||
|
||||
assert result.status == "waiting_user"
|
||||
assert result.candidates == ["A说明书.docx", "B说明书.docx"]
|
||||
|
||||
16
tests/test_regulatory_info_package_instruction_extract.py
Normal file
16
tests/test_regulatory_info_package_instruction_extract.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
from review_agent.regulatory_info_package.services.instruction_extract import parse_instruction_docx
|
||||
|
||||
|
||||
def test_parse_instruction_docx_extracts_paragraphs_and_tables():
|
||||
path = Path("docs/0.原始材料/目标产品说明书.docx")
|
||||
|
||||
result = parse_instruction_docx(path)
|
||||
|
||||
assert result.source_file_name == "目标产品说明书.docx"
|
||||
assert result.paragraphs
|
||||
assert isinstance(result.sections, dict)
|
||||
assert isinstance(result.tables, list)
|
||||
assert result.front_text
|
||||
|
||||
9
tests/test_regulatory_info_package_legacy_doc.py
Normal file
9
tests/test_regulatory_info_package_legacy_doc.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from review_agent.regulatory_info_package.services.legacy_doc_document import detect_legacy_doc_capability
|
||||
|
||||
|
||||
def test_detect_legacy_doc_capability_is_stable():
|
||||
capability = detect_legacy_doc_capability()
|
||||
|
||||
assert capability.status in {"available", "unavailable"}
|
||||
assert capability.adapter in {"WordComDocAdapter", "UnavailableLegacyDocAdapter"}
|
||||
|
||||
109
tests/test_regulatory_info_package_models.py
Normal file
109
tests/test_regulatory_info_package_models.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
from django.db import IntegrityError
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
FileAttachment,
|
||||
RegulatoryInfoPackageArtifact,
|
||||
RegulatoryInfoPackageBatch,
|
||||
RegulatoryInfoPackageNotificationRecord,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_regulatory_info_package_batch_defaults(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="目标产品说明书.docx",
|
||||
storage_path="uploads/instruction.docx",
|
||||
)
|
||||
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_attachment=attachment,
|
||||
batch_no="RIP-20260610153000-abcdef",
|
||||
source_file_name=attachment.original_name,
|
||||
source_storage_path=attachment.storage_path,
|
||||
)
|
||||
|
||||
assert batch.status == RegulatoryInfoPackageBatch.Status.PENDING
|
||||
assert batch.output_zip_name == "第1章 监管信息(预生成版).zip"
|
||||
assert batch.generated_files == []
|
||||
assert batch.missing_fields == []
|
||||
assert batch.llm_only_fields == []
|
||||
assert batch.conflict_fields == []
|
||||
assert batch.risk_notes == []
|
||||
assert batch.adapter_summary == {}
|
||||
assert str(batch) == "RIP-20260610153000-abcdef"
|
||||
|
||||
|
||||
def test_regulatory_info_package_artifact_and_notification(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610153100-abcdef",
|
||||
)
|
||||
|
||||
artifact = RegulatoryInfoPackageArtifact.objects.create(
|
||||
batch=batch,
|
||||
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.ZIP_PACKAGE,
|
||||
file_format=RegulatoryInfoPackageArtifact.FileFormat.ZIP,
|
||||
name="主下载包",
|
||||
file_name="第1章 监管信息(预生成版).zip",
|
||||
storage_path="media/regulatory_info_package/package.zip",
|
||||
)
|
||||
notification = RegulatoryInfoPackageNotificationRecord.objects.create(
|
||||
batch=batch,
|
||||
recipient=user,
|
||||
export_ids=[1, 2],
|
||||
message_summary="材料包已生成",
|
||||
send_status=RegulatoryInfoPackageNotificationRecord.SendStatus.SUCCESS,
|
||||
)
|
||||
|
||||
assert artifact.metadata == {}
|
||||
assert artifact.is_deleted is False
|
||||
assert notification.channel == RegulatoryInfoPackageNotificationRecord.Channel.MOCK
|
||||
assert notification.retry_count == 0
|
||||
|
||||
|
||||
def test_exported_summary_file_supports_zip_type():
|
||||
values = {value for value, _label in ExportedSummaryFile.ExportType.choices}
|
||||
|
||||
assert "zip" in values
|
||||
|
||||
|
||||
def test_workflow_node_run_unique_for_workflow_batch(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610153200-abcdef",
|
||||
)
|
||||
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="regulatory_info_package",
|
||||
node_code="prepare",
|
||||
node_name="准备资料",
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="regulatory_info_package",
|
||||
node_code="prepare",
|
||||
node_name="准备资料",
|
||||
)
|
||||
17
tests/test_regulatory_info_package_notification.py
Normal file
17
tests/test_regulatory_info_package_notification.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, RegulatoryInfoPackageBatch, RegulatoryInfoPackageNotificationRecord
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_regulatory_info_package_notification_record_defaults(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(conversation=conversation, user=user, batch_no="RIP-NOTIFY")
|
||||
|
||||
record = RegulatoryInfoPackageNotificationRecord.objects.create(batch=batch, recipient=user)
|
||||
|
||||
assert record.channel == RegulatoryInfoPackageNotificationRecord.Channel.MOCK
|
||||
assert record.send_status == RegulatoryInfoPackageNotificationRecord.SendStatus.PENDING
|
||||
281
tests/test_regulatory_info_package_package_generate.py
Normal file
281
tests/test_regulatory_info_package_package_generate.py
Normal file
@@ -0,0 +1,281 @@
|
||||
import json
|
||||
import pytest
|
||||
from docx import Document
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from review_agent.models import Conversation, RegulatoryInfoPackageBatch
|
||||
from review_agent.regulatory_info_package.services.field_merge import merge_fields
|
||||
from review_agent.regulatory_info_package.services import package_generate
|
||||
from review_agent.regulatory_info_package.services.package_generate import generate_package_documents
|
||||
from review_agent.regulatory_info_package.services.template_config import load_template_config
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_template_config_uses_clean_internal_templates():
|
||||
config = load_template_config()
|
||||
source_dir = Path(config["source_dir"])
|
||||
|
||||
assert source_dir == settings.BASE_DIR / "review_agent" / "regulatory_info_package" / "templates" / "clean"
|
||||
assert source_dir.exists()
|
||||
assert len(config["templates"]) == 6
|
||||
assert all((source_dir / item["source_file"]).exists() for item in config["templates"])
|
||||
|
||||
|
||||
def test_clean_templates_expose_stable_fill_placeholders():
|
||||
config = load_template_config()
|
||||
source_dir = Path(config["source_dir"])
|
||||
expected_by_code = {
|
||||
"ch1_2_directory": {"{{product_name}}"},
|
||||
"ch1_4_application_form": {"{{product_name}}", "{{applicant_name}}"},
|
||||
"ch1_5_product_list": {"{{product_name}}"},
|
||||
"ch1_11_1_standards": {"{{product_name}}"},
|
||||
"ch1_11_5_authenticity": {"{{product_name}}"},
|
||||
"ch1_11_6_conformity": {"{{product_name}}"},
|
||||
}
|
||||
|
||||
for item in config["templates"]:
|
||||
document = Document(source_dir / item["source_file"])
|
||||
text = _document_text(document)
|
||||
for placeholder in expected_by_code[item["code"]]:
|
||||
assert placeholder in text
|
||||
|
||||
|
||||
def test_directory_template_includes_page_numbers():
|
||||
config = load_template_config()
|
||||
source_dir = Path(config["source_dir"])
|
||||
item = next(template for template in config["templates"] if template["code"] == "ch1_2_directory")
|
||||
document = Document(source_dir / item["source_file"])
|
||||
page_numbers = [row.cells[4].text.strip() for row in document.tables[0].rows[1:]]
|
||||
|
||||
assert page_numbers == ["1", "1", "1", "1", "1", "1"]
|
||||
|
||||
|
||||
def test_application_form_template_uses_real_checkbox_symbols():
|
||||
config = load_template_config()
|
||||
source_dir = Path(config["source_dir"])
|
||||
item = next(template for template in config["templates"] if template["code"] == "ch1_4_application_form")
|
||||
text = _document_text(Document(source_dir / item["source_file"]))
|
||||
|
||||
assert "{{复选框}}" not in text
|
||||
assert "{{}}" not in text
|
||||
assert "☐" in text
|
||||
assert "☑" in text
|
||||
|
||||
|
||||
def test_generate_package_documents_creates_six_results(django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154000-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {})
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
|
||||
assert len(results) == 6
|
||||
assert all(result.status in {"success", "fallback_success"} for result in results), [
|
||||
(result.template_code, result.status, result.error_message) for result in results
|
||||
]
|
||||
assert all(result.path for result in results)
|
||||
|
||||
|
||||
def test_directory_is_generated_last_with_real_page_counts(django_user_model, tmp_path, monkeypatch):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154010-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {})
|
||||
page_counts = {
|
||||
"CH1.4 申请表.docx": 3,
|
||||
"CH1.5 产品列表.docx": 5,
|
||||
"CH1.11.1 符合标准的清单.docx": 2,
|
||||
"CH1.11.5 真实性声明.docx": 4,
|
||||
"CH1.11.6 符合性声明.docx": 6,
|
||||
}
|
||||
counted_files = []
|
||||
|
||||
def fake_count(path):
|
||||
counted_files.append(Path(path).name)
|
||||
return page_counts[Path(path).name]
|
||||
|
||||
monkeypatch.setattr(package_generate, "count_document_pages", fake_count, raising=False)
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
|
||||
assert results[-1].template_code == "ch1_2_directory"
|
||||
assert set(counted_files) == set(page_counts)
|
||||
directory = Document(results[-1].path)
|
||||
directory_pages = {row.cells[0].text.strip(): row.cells[4].text.strip() for row in directory.tables[0].rows[1:]}
|
||||
assert directory_pages == {
|
||||
"CH1.2": "1",
|
||||
"CH1.4": "3",
|
||||
"CH1.5": "5",
|
||||
"CH1.11.1": "2",
|
||||
"CH1.11.5": "4",
|
||||
"CH1.11.6": "6",
|
||||
}
|
||||
|
||||
|
||||
def test_generated_docx_does_not_add_prefill_or_audit_blocks(django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154100-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
merged, _summary = merge_fields({"product_name": {"value": "测试产品", "label": "产品名称"}}, {})
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
for result in results:
|
||||
document = Document(result.path)
|
||||
text = _document_text(document)
|
||||
|
||||
assert "预生成版" not in text
|
||||
assert "预生成字段" not in text
|
||||
assert "component_table" not in text
|
||||
assert '"header"' not in text
|
||||
assert "测试产品" in text
|
||||
|
||||
|
||||
def test_generated_docx_replaces_sample_case_content(django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154200-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
merged, _summary = merge_fields(
|
||||
{
|
||||
"product_name": {"value": "测试产品", "label": "产品名称"},
|
||||
"package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
docx_results = [result for result in results if result.actual_format == "docx"]
|
||||
for result in docx_results:
|
||||
document = Document(result.path)
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
for table in document.tables:
|
||||
for row in table.rows:
|
||||
text += "\n" + "\t".join(cell.text for cell in row.cells)
|
||||
assert "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒" not in text
|
||||
product_list = next(result for result in results if result.template_code == "ch1_5_product_list")
|
||||
product_doc = Document(product_list.path)
|
||||
table = product_doc.tables[0]
|
||||
assert table.rows[1].cells[0].text == "24人份/盒"
|
||||
assert table.rows[1].cells[1].text == "/"
|
||||
assert "6018003102" not in "\n".join(cell.text for row in table.rows for cell in row.cells)
|
||||
|
||||
|
||||
def test_generated_docs_fill_clean_template_body(django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154300-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
merged, _summary = merge_fields(
|
||||
{
|
||||
"product_name": {"value": "甲型流感病毒核酸检测试剂盒", "label": "产品名称"},
|
||||
"applicant_name": {"value": "星河医疗科技有限公司", "label": "申请人名称"},
|
||||
"package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"},
|
||||
"standard_no": {"value": "GB/T 29791.1-2013", "label": "标准号"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
|
||||
for code in ["ch1_2_directory", "ch1_4_application_form", "ch1_11_5_authenticity", "ch1_11_6_conformity"]:
|
||||
result = next(item for item in results if item.template_code == code)
|
||||
text = _document_text(Document(result.path))
|
||||
assert "甲型流感病毒核酸检测试剂盒" in text
|
||||
if code == "ch1_4_application_form":
|
||||
assert "星河医疗科技有限公司" in text
|
||||
assert "{{" not in text
|
||||
assert "}}" not in text
|
||||
|
||||
today = timezone.localdate().strftime("%Y年%m月%d日")
|
||||
for code in ["ch1_11_1_standards", "ch1_11_5_authenticity", "ch1_11_6_conformity"]:
|
||||
result = next(item for item in results if item.template_code == code)
|
||||
text = _document_text(Document(result.path))
|
||||
assert today in text
|
||||
assert "xxxx年xx月xx日" not in text
|
||||
assert "星河医疗科技有限公司" not in text
|
||||
|
||||
product_list = next(item for item in results if item.template_code == "ch1_5_product_list")
|
||||
product_text = _document_text(Document(product_list.path))
|
||||
assert "24人份/盒" in product_text
|
||||
assert "48人份/盒" in product_text
|
||||
|
||||
|
||||
def test_product_list_uses_component_table_from_instruction(django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610154400-abcdef",
|
||||
work_dir=str(tmp_path),
|
||||
)
|
||||
component_payload = {
|
||||
"header": ["组分", "主要组成成分", "规格(24人份/盒)", "规格(48人份/盒)"],
|
||||
"rows": [
|
||||
["PCR反应液 I", "逆转录酶、Taq酶", "840μL/管×1管", "840μL/管×2管"],
|
||||
["阳性对照品", "含目的片段的假病毒", "600μL/管×2管", "1200μL/管×2管"],
|
||||
],
|
||||
}
|
||||
merged, _summary = merge_fields(
|
||||
{
|
||||
"product_name": {"value": "新型冠状病毒核酸检测试剂盒", "label": "产品名称"},
|
||||
"package_specification": {"value": "24人份/盒;48人份/盒", "label": "包装规格"},
|
||||
"component_table": {
|
||||
"value": json.dumps(component_payload, ensure_ascii=False),
|
||||
"label": "主要组成成分",
|
||||
},
|
||||
"component_notes": {
|
||||
"value": "注:不同批号试剂盒中各组分不得互换使用。",
|
||||
"label": "主要组成成分备注",
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
results = generate_package_documents(batch, load_template_config(), merged)
|
||||
product_list = next(result for result in results if result.template_code == "ch1_5_product_list")
|
||||
document = Document(product_list.path)
|
||||
text = _document_text(document)
|
||||
|
||||
assert "PCR反应液 I" in text
|
||||
assert "840μL/管×1管" in text
|
||||
assert "840μL/管×2管" in text
|
||||
assert "注:不同批号试剂盒中各组分不得互换使用。" in text
|
||||
assert "RSV&MP" not in text
|
||||
assert "6018003102" not in text
|
||||
|
||||
|
||||
def _document_text(document: Document) -> str:
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
for table in document.tables:
|
||||
for row in table.rows:
|
||||
text += "\n" + "\t".join(cell.text for cell in row.cells)
|
||||
return text
|
||||
13
tests/test_regulatory_info_package_summary.py
Normal file
13
tests/test_regulatory_info_package_summary.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from review_agent.regulatory_info_package.services.summary import build_assistant_summary
|
||||
|
||||
|
||||
def test_build_assistant_summary_puts_zip_first():
|
||||
exports = [
|
||||
{"file_name": "CH1.4 申请表.docx", "download_url": "/docx"},
|
||||
{"file_name": "第1章 监管信息(预生成版).zip", "download_url": "/zip", "export_type": "zip"},
|
||||
]
|
||||
|
||||
summary = build_assistant_summary(batch_no="RIP-1", exports=exports, failed_files=[])
|
||||
|
||||
assert summary.index("第1章 监管信息(预生成版).zip") < summary.index("CH1.4 申请表.docx")
|
||||
|
||||
46
tests/test_regulatory_info_package_template_config.py
Normal file
46
tests/test_regulatory_info_package_template_config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.regulatory_info_package.constants import DEFAULT_ZIP_NAME
|
||||
from review_agent.regulatory_info_package.services.template_config import (
|
||||
compute_config_hash,
|
||||
load_template_config,
|
||||
validate_template_config,
|
||||
)
|
||||
|
||||
|
||||
def test_template_config_loads_six_templates():
|
||||
config = load_template_config()
|
||||
|
||||
assert config["version"] == "regulatory_info_package_templates_v1"
|
||||
assert config["zip_name"] == DEFAULT_ZIP_NAME
|
||||
assert len(config["templates"]) == 6
|
||||
assert {template["code"] for template in config["templates"]} == {
|
||||
"ch1_2_directory",
|
||||
"ch1_4_application_form",
|
||||
"ch1_5_product_list",
|
||||
"ch1_11_1_standards",
|
||||
"ch1_11_5_authenticity",
|
||||
"ch1_11_6_conformity",
|
||||
}
|
||||
assert validate_template_config(config) == []
|
||||
assert compute_config_hash()
|
||||
|
||||
|
||||
def test_template_config_rejects_duplicate_codes():
|
||||
config = load_template_config()
|
||||
config["templates"].append(dict(config["templates"][0]))
|
||||
|
||||
errors = validate_template_config(config)
|
||||
|
||||
assert any("重复" in error for error in errors)
|
||||
|
||||
|
||||
def test_template_config_sources_exist():
|
||||
config = load_template_config()
|
||||
source_dir = Path(config["source_dir"])
|
||||
|
||||
assert source_dir.exists()
|
||||
for template in config["templates"]:
|
||||
assert (source_dir / template["source_file"]).exists()
|
||||
28
tests/test_regulatory_info_package_traceability.py
Normal file
28
tests/test_regulatory_info_package_traceability.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from review_agent.regulatory_info_package.schemas import MergedField
|
||||
from review_agent.regulatory_info_package.services.traceability_export import save_traceability_exports
|
||||
|
||||
|
||||
def test_save_traceability_exports_writes_excel_and_json(tmp_path):
|
||||
fields = {
|
||||
"product_name": MergedField(
|
||||
key="product_name",
|
||||
label="产品名称",
|
||||
value="测试产品",
|
||||
source="rule",
|
||||
evidence="说明书",
|
||||
confidence=0.9,
|
||||
)
|
||||
}
|
||||
|
||||
excel_path, json_path = save_traceability_exports(tmp_path, fields)
|
||||
|
||||
assert excel_path.name == "traceability.xlsx"
|
||||
assert json_path.name == "traceability.json"
|
||||
assert json_path.exists()
|
||||
workbook = load_workbook(excel_path)
|
||||
assert workbook.active["A1"].value == "target_file"
|
||||
|
||||
19
tests/test_regulatory_info_package_trigger.py
Normal file
19
tests/test_regulatory_info_package_trigger.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation
|
||||
from review_agent.skill_router import route_message_intent
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_fixed_keyword_routes_to_regulatory_info_package(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
route = route_message_intent(conversation, "请根据说明书生成第1章监管信息")
|
||||
|
||||
assert route.action == "regulatory_info_package"
|
||||
assert route.workflow_type == "regulatory_info_package"
|
||||
assert route.starts_regulatory_info_package is True
|
||||
|
||||
140
tests/test_regulatory_info_package_views.py
Normal file
140
tests/test_regulatory_info_package_views.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
ExportedSummaryFile,
|
||||
RegulatoryInfoPackageBatch,
|
||||
WorkflowNodeRun,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_regulatory_info_package_export_download_checks_owner(client, django_user_model, tmp_path):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
batch_no="RIP-20260610153300-abcdef",
|
||||
)
|
||||
path = tmp_path / "第1章 监管信息(预生成版).zip"
|
||||
path.write_bytes(b"zip-content")
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=None,
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="regulatory_info_package",
|
||||
export_type=ExportedSummaryFile.ExportType.ZIP,
|
||||
file_name=path.name,
|
||||
storage_path=str(path),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/")
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/")
|
||||
assert allowed.status_code == 200
|
||||
assert allowed["Content-Type"] == "application/zip"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "export_type", "expected"),
|
||||
[
|
||||
("CH1.9 产品申报前沟通的说明.doc", ExportedSummaryFile.ExportType.WORD, "application/msword"),
|
||||
(
|
||||
"CH1.4 申请表.docx",
|
||||
ExportedSummaryFile.ExportType.WORD,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
),
|
||||
("第1章 监管信息(预生成版).zip", ExportedSummaryFile.ExportType.ZIP, "application/zip"),
|
||||
],
|
||||
)
|
||||
def test_regulatory_info_package_download_mime_by_extension(
|
||||
client,
|
||||
django_user_model,
|
||||
tmp_path,
|
||||
file_name,
|
||||
export_type,
|
||||
expected,
|
||||
):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no=f"RIP-20260610153400-{Path(file_name).suffix[1:] or 'zip'}",
|
||||
)
|
||||
path = tmp_path / file_name
|
||||
path.write_bytes(b"content")
|
||||
exported = ExportedSummaryFile.objects.create(
|
||||
batch=None,
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="generated_document",
|
||||
export_type=export_type,
|
||||
file_name=file_name,
|
||||
storage_path=str(path),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/review-agent/file-summary/exports/{exported.pk}/download/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == expected
|
||||
|
||||
|
||||
def test_regulatory_info_package_status_returns_nodes_and_zip_first(client, django_user_model, tmp_path):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = RegulatoryInfoPackageBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="RIP-20260610153500-abcdef",
|
||||
status=RegulatoryInfoPackageBatch.Status.SUCCESS,
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="regulatory_info_package",
|
||||
node_code="zip_export",
|
||||
node_name="打包下载",
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
progress=100,
|
||||
)
|
||||
doc = tmp_path / "CH1.4 申请表.docx"
|
||||
zip_file = tmp_path / "第1章 监管信息(预生成版).zip"
|
||||
doc.write_bytes(b"doc")
|
||||
zip_file.write_bytes(b"zip")
|
||||
ExportedSummaryFile.objects.create(
|
||||
batch=None,
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="generated_document",
|
||||
export_type=ExportedSummaryFile.ExportType.WORD,
|
||||
file_name=doc.name,
|
||||
storage_path=str(doc),
|
||||
)
|
||||
ExportedSummaryFile.objects.create(
|
||||
batch=None,
|
||||
workflow_type="regulatory_info_package",
|
||||
workflow_batch_id=batch.pk,
|
||||
export_category="regulatory_info_package",
|
||||
export_type=ExportedSummaryFile.ExportType.ZIP,
|
||||
file_name=zip_file.name,
|
||||
storage_path=str(zip_file),
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/review-agent/regulatory-info-package/{batch.pk}/status/")
|
||||
|
||||
payload = response.json()
|
||||
assert payload["batch"]["workflow_type"] == "regulatory_info_package"
|
||||
assert payload["nodes"][0]["node_code"] == "zip_export"
|
||||
assert payload["exports"][0]["export_type"] == "zip"
|
||||
92
tests/test_regulatory_info_package_workflow.py
Normal file
92
tests/test_regulatory_info_package_workflow.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileAttachment, Message, RegulatoryInfoPackageBatch, WorkflowNodeRun
|
||||
from review_agent.regulatory_info_package.constants import (
|
||||
REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS,
|
||||
WORKFLOW_TYPE,
|
||||
)
|
||||
from review_agent.regulatory_info_package.workflow import (
|
||||
create_regulatory_info_package_batch,
|
||||
start_regulatory_info_package_workflow,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_regulatory_info_package_batch_initializes_nodes(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
batch = create_regulatory_info_package_batch(conversation=conversation, user=user)
|
||||
|
||||
assert batch.batch_no.startswith("RIP-")
|
||||
assert batch.work_dir
|
||||
nodes = WorkflowNodeRun.objects.filter(
|
||||
workflow_type=WORKFLOW_TYPE,
|
||||
workflow_batch_id=batch.pk,
|
||||
).order_by("id")
|
||||
assert [node.node_code for node in nodes] == [
|
||||
code for code, _name, _group in REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS
|
||||
]
|
||||
|
||||
|
||||
def test_create_regulatory_info_package_batch_is_node_idempotent(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = create_regulatory_info_package_batch(conversation=conversation, user=user)
|
||||
|
||||
create_regulatory_info_package_batch(conversation=conversation, user=user, existing_batch=batch)
|
||||
|
||||
assert WorkflowNodeRun.objects.filter(
|
||||
workflow_type=WORKFLOW_TYPE,
|
||||
workflow_batch_id=batch.pk,
|
||||
).count() == len(REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS)
|
||||
|
||||
|
||||
def test_empty_workflow_skeleton_completes(django_user_model, settings):
|
||||
settings.REGULATORY_INFO_PACKAGE_ASYNC = False
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
batch = create_regulatory_info_package_batch(conversation=conversation, user=user)
|
||||
|
||||
start_regulatory_info_package_workflow(batch, async_run=False)
|
||||
batch.refresh_from_db()
|
||||
|
||||
assert batch.status == RegulatoryInfoPackageBatch.Status.SUCCESS
|
||||
assert WorkflowNodeRun.objects.filter(
|
||||
workflow_type=WORKFLOW_TYPE,
|
||||
workflow_batch_id=batch.pk,
|
||||
status=WorkflowNodeRun.Status.SUCCESS,
|
||||
).count() == len(REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS)
|
||||
|
||||
|
||||
def test_completed_workflow_appends_download_summary_message(django_user_model, settings):
|
||||
settings.REGULATORY_INFO_PACKAGE_ASYNC = False
|
||||
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="根据说明书生成第1章监管信息")
|
||||
source = Path("docs/0.原始材料/目标产品说明书.docx").resolve()
|
||||
attachment = FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="目标产品说明书.docx",
|
||||
storage_path=str(source),
|
||||
file_size=source.stat().st_size,
|
||||
)
|
||||
batch = create_regulatory_info_package_batch(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
trigger_message=trigger,
|
||||
source_attachment=attachment,
|
||||
source_file_name=attachment.original_name,
|
||||
source_storage_path=attachment.storage_path,
|
||||
)
|
||||
|
||||
start_regulatory_info_package_workflow(batch, async_run=False)
|
||||
|
||||
message = conversation.messages.filter(role=Message.Role.ASSISTANT, content__contains=batch.batch_no).latest("id")
|
||||
assert "第1章 监管信息(预生成版).zip" in message.content
|
||||
assert "/api/review-agent/file-summary/exports/" in message.content
|
||||
22
tests/test_regulatory_info_package_zip.py
Normal file
22
tests/test_regulatory_info_package_zip.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import zipfile
|
||||
|
||||
from review_agent.regulatory_info_package.schemas import GeneratedFileResult
|
||||
from review_agent.regulatory_info_package.services.zip_export import create_zip_package
|
||||
|
||||
|
||||
def test_create_zip_package_includes_only_success_files(tmp_path):
|
||||
success = tmp_path / "ok.docx"
|
||||
failed = tmp_path / "bad.docx"
|
||||
success.write_bytes(b"ok")
|
||||
failed.write_bytes(b"bad")
|
||||
|
||||
zip_path = create_zip_package(
|
||||
tmp_path,
|
||||
[
|
||||
GeneratedFileResult("ok", "ok.docx", "docx", "docx", "success", path=str(success)),
|
||||
GeneratedFileResult("bad", "bad.docx", "docx", "docx", "failed", path=str(failed)),
|
||||
],
|
||||
)
|
||||
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
assert archive.namelist() == ["ok.docx"]
|
||||
111
tests/test_regulatory_llm_review.py
Normal file
111
tests/test_regulatory_llm_review.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import json
|
||||
|
||||
from review_agent.regulatory_review.services.llm_review import review_condition_fields, review_workflow_payload
|
||||
|
||||
|
||||
def test_review_condition_fields_selects_more_complete_llm_product_name():
|
||||
def completion(messages, temperature=0.0):
|
||||
return json.dumps(
|
||||
{
|
||||
"fields": {
|
||||
"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)",
|
||||
"型号规格": "24人份/盒",
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
result = review_condition_fields(
|
||||
text="产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n(荧光PCR法)\n型号规格:24人份/盒",
|
||||
rule_fields={"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒", "型号规格": "24人份/盒"},
|
||||
file_context="申请表.txt",
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert result["selected_fields"]["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"
|
||||
assert result["selected_sources"]["产品名称"] == "llm"
|
||||
assert result["selected_sources"]["型号规格"] == "rule"
|
||||
|
||||
|
||||
def test_review_condition_fields_falls_back_when_llm_returns_chapter_title():
|
||||
def completion(messages, temperature=0.0):
|
||||
return json.dumps({"fields": {"产品名称": "第1章 监管信息"}}, ensure_ascii=False)
|
||||
|
||||
result = review_condition_fields(
|
||||
text="产品名称:甲胎蛋白检测试剂盒",
|
||||
rule_fields={"产品名称": "甲胎蛋白检测试剂盒"},
|
||||
file_context="申请表.txt",
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert result["selected_fields"]["产品名称"] == "甲胎蛋白检测试剂盒"
|
||||
assert result["selected_sources"]["产品名称"] == "rule"
|
||||
|
||||
|
||||
def test_review_condition_fields_rejects_garbled_llm_product_name():
|
||||
def completion(messages, temperature=0.0):
|
||||
return json.dumps({"fields": {"产品名称": "呼吸道合胞病毒、 <20>肺炎支原体核酸检测试剂盒 (荧光PCR法)"}}, ensure_ascii=False)
|
||||
|
||||
result = review_condition_fields(
|
||||
text="呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)",
|
||||
rule_fields={"产品名称": "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"},
|
||||
file_context="产品列表.txt",
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert result["selected_fields"]["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"
|
||||
assert result["selected_sources"]["产品名称"] == "rule"
|
||||
|
||||
|
||||
def test_review_workflow_payload_handles_timeout_without_raising():
|
||||
def completion(messages, temperature=0.0):
|
||||
raise TimeoutError("The read operation timed out")
|
||||
|
||||
result = review_workflow_payload(
|
||||
stage="completeness_check",
|
||||
payload={"findings": []},
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["stage"] == "completeness_check"
|
||||
assert "timed out" in result["error_message"]
|
||||
|
||||
|
||||
def test_review_workflow_payload_retries_timeout_before_success(settings):
|
||||
settings.REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = 0
|
||||
attempts = {"count": 0}
|
||||
|
||||
def completion(messages, temperature=0.0):
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] < 3:
|
||||
raise TimeoutError("The read operation timed out")
|
||||
return json.dumps({"reviewed": True})
|
||||
|
||||
result = review_workflow_payload(
|
||||
stage="completeness_check",
|
||||
payload={"findings": []},
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert attempts["count"] == 3
|
||||
assert result["status"] == "success"
|
||||
assert result["result"]["reviewed"] is True
|
||||
|
||||
|
||||
def test_review_workflow_payload_passes_configured_timeout(settings):
|
||||
settings.REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = 0
|
||||
settings.REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS = 7
|
||||
observed = {}
|
||||
|
||||
def completion(messages, temperature=0.0, timeout=None):
|
||||
observed["timeout"] = timeout
|
||||
return json.dumps({"reviewed": True})
|
||||
|
||||
review_workflow_payload(
|
||||
stage="completeness_check",
|
||||
payload={"findings": []},
|
||||
completion_func=completion,
|
||||
)
|
||||
|
||||
assert observed["timeout"] == 7
|
||||
137
tests/test_regulatory_models.py
Normal file
137
tests/test_regulatory_models.py
Normal file
@@ -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
|
||||
109
tests/test_regulatory_notification.py
Normal file
109
tests/test_regulatory_notification.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import (
|
||||
Conversation,
|
||||
FileSummaryBatch,
|
||||
RegulatoryIssue,
|
||||
RegulatoryNotificationRecord,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
from review_agent.regulatory_review.services.export import build_markdown_report, build_result_payload
|
||||
from review_agent.regulatory_review.services.feishu_notifier import create_mock_notifications
|
||||
from review_agent.regulatory_review.workflow import RegulatoryWorkflowExecutor
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_mock_notifications_for_medium_and_above(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-NOTIFY",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-NOTIFY",
|
||||
)
|
||||
high = RegulatoryIssue.objects.create(
|
||||
batch=batch,
|
||||
rule_code="attachment4_1_2_application_form",
|
||||
category=RegulatoryIssue.Category.COMPLETENESS,
|
||||
severity=RegulatoryIssue.Severity.HIGH,
|
||||
title="缺少申请表",
|
||||
)
|
||||
RegulatoryIssue.objects.create(
|
||||
batch=batch,
|
||||
rule_code="info",
|
||||
category=RegulatoryIssue.Category.RAG,
|
||||
severity=RegulatoryIssue.Severity.INFO,
|
||||
title="提示项",
|
||||
)
|
||||
|
||||
records = create_mock_notifications(batch)
|
||||
|
||||
assert len(records) == 1
|
||||
assert records[0].channel == RegulatoryNotificationRecord.Channel.MOCK
|
||||
assert records[0].status == RegulatoryNotificationRecord.Status.SENT
|
||||
assert records[0].payload["issue_id"] == high.pk
|
||||
|
||||
|
||||
def test_notification_records_enter_reports(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-NOTIFY",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-NOTIFY",
|
||||
)
|
||||
RegulatoryNotificationRecord.objects.create(
|
||||
batch=batch,
|
||||
channel=RegulatoryNotificationRecord.Channel.MOCK,
|
||||
target="法规整改负责人",
|
||||
status=RegulatoryNotificationRecord.Status.SENT,
|
||||
payload={"title": "缺少申请表", "severity": "high"},
|
||||
)
|
||||
|
||||
assert "通知记录" in build_markdown_report(batch)
|
||||
assert build_result_payload(batch)["notifications"][0]["channel"] == "mock"
|
||||
|
||||
|
||||
def test_regulatory_completion_notification_uses_dispatcher(monkeypatch, 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-NOTIFY-DISPATCH",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-NOTIFY-DISPATCH",
|
||||
status=RegulatoryReviewBatch.Status.SUCCESS,
|
||||
)
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.regulatory_review.workflow.dispatch_workflow_notification",
|
||||
lambda context: calls.append(context),
|
||||
)
|
||||
|
||||
RegulatoryWorkflowExecutor(batch)._dispatch_completion_notification()
|
||||
|
||||
assert calls
|
||||
assert calls[0].workflow_type == "regulatory_review"
|
||||
229
tests/test_regulatory_rag.py
Normal file
229
tests/test_regulatory_rag.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.regulatory_review.services.rag_citation import (
|
||||
RagIndexUnavailable,
|
||||
retrieve_citations,
|
||||
)
|
||||
from review_agent.regulatory_review.services.rag_embedding import SiliconFlowEmbeddingProvider
|
||||
from review_agent.regulatory_review.services.rag_index import chunk_text
|
||||
from review_agent.regulatory_review.services.rag_index import collect_source_chunks
|
||||
from review_agent.regulatory_review.services.rag_index import build_chroma_index
|
||||
|
||||
|
||||
def test_siliconflow_embedding_provider_posts_expected_payload(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]}
|
||||
|
||||
def fake_post(url, headers, json, timeout):
|
||||
calls.append({"url": url, "headers": headers, "json": json, "timeout": timeout})
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("review_agent.regulatory_review.services.rag_embedding.httpx.post", fake_post)
|
||||
|
||||
provider = SiliconFlowEmbeddingProvider(
|
||||
api_key="secret",
|
||||
base_url="https://api.siliconflow.cn/v1",
|
||||
model="Qwen/Qwen3-Embedding-4B",
|
||||
dimensions=1024,
|
||||
)
|
||||
|
||||
assert provider.embed(["法规依据", "注册检验报告"]) == [[0.1, 0.2], [0.3, 0.4]]
|
||||
assert calls[0]["url"] == "https://api.siliconflow.cn/v1/embeddings"
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer secret"
|
||||
assert calls[0]["json"]["model"] == "Qwen/Qwen3-Embedding-4B"
|
||||
assert calls[0]["json"]["dimensions"] == 1024
|
||||
|
||||
|
||||
def test_chunk_text_preserves_source_metadata():
|
||||
chunks = chunk_text(
|
||||
"第一段法规内容。\n" * 20,
|
||||
source="法规.doc",
|
||||
chunk_size=30,
|
||||
overlap=5,
|
||||
)
|
||||
|
||||
assert len(chunks) > 1
|
||||
assert chunks[0].metadata["source"] == "法规.doc"
|
||||
assert chunks[0].text
|
||||
|
||||
|
||||
def test_retrieve_citations_returns_placeholder_when_no_hits():
|
||||
class EmptyCollection:
|
||||
def query(self, query_embeddings, n_results):
|
||||
return {"documents": [[]], "metadatas": [[]], "distances": [[]]}
|
||||
|
||||
citations = retrieve_citations(
|
||||
"注册检验报告",
|
||||
embedding_provider=lambda texts: [[0.1, 0.2]],
|
||||
collection=EmptyCollection(),
|
||||
)
|
||||
|
||||
assert citations[0]["source"] == "原文依据待补充"
|
||||
|
||||
|
||||
def test_retrieve_citations_raises_when_index_missing(settings, tmp_path):
|
||||
settings.REGULATORY_RAG_CHROMA_PATH = tmp_path / "missing"
|
||||
|
||||
with pytest.raises(RagIndexUnavailable):
|
||||
retrieve_citations("注册检验报告", embedding_provider=lambda texts: [[0.1]])
|
||||
|
||||
|
||||
def test_collect_source_chunks_requires_attachment4_extraction(monkeypatch, tmp_path):
|
||||
source_dir = tmp_path / "sources"
|
||||
source_dir.mkdir()
|
||||
attachment4 = source_dir / "附件 4 体外诊断试剂注册申报资料要求及说明.doc"
|
||||
attachment4.write_bytes(b"legacy-doc")
|
||||
|
||||
def fail_extract(path):
|
||||
raise RuntimeError("无法通过 LibreOffice 转换法规 .doc 材料")
|
||||
|
||||
monkeypatch.setattr("review_agent.regulatory_review.services.rag_index.extract_text_from_path", fail_extract)
|
||||
|
||||
with pytest.raises(RuntimeError, match="附件 4"):
|
||||
collect_source_chunks(source_dir)
|
||||
|
||||
|
||||
def test_collect_source_chunks_excludes_demo_agent_materials(monkeypatch, tmp_path):
|
||||
source_dir = tmp_path / "sources"
|
||||
source_dir.mkdir()
|
||||
demo_dir = source_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent"
|
||||
demo_dir.mkdir()
|
||||
(demo_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent.md").write_text("题目材料", encoding="utf-8")
|
||||
(source_dir / "【模拟题二】试剂盒临床注册文件准备与审核Agent.docx").write_bytes(b"demo")
|
||||
real_source = source_dir / "附件 4 体外诊断试剂注册申报资料要求及说明.doc"
|
||||
real_source.write_bytes(b"rule")
|
||||
|
||||
def fake_extract(path):
|
||||
return "附件4 正文" if path == real_source else "不应被抽取"
|
||||
|
||||
monkeypatch.setattr("review_agent.regulatory_review.services.rag_index.extract_text_from_path", fake_extract)
|
||||
|
||||
chunks = collect_source_chunks(source_dir)
|
||||
|
||||
assert chunks
|
||||
assert all("模拟题二" not in chunk.metadata["source"] for chunk in chunks)
|
||||
|
||||
|
||||
def test_build_chroma_index_reset_recreates_collection_without_deleting_index_dir(settings, monkeypatch, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
persist_path = tmp_path / "chroma"
|
||||
persist_path.mkdir()
|
||||
stale_file = persist_path / "chroma.sqlite3"
|
||||
stale_file.write_text("stale", encoding="utf-8")
|
||||
source_dir = tmp_path / "sources"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "rule.md").write_text("注册检验报告要求", encoding="utf-8")
|
||||
client_states = []
|
||||
deleted_collections = []
|
||||
|
||||
class FakeCollection:
|
||||
def upsert(self, **kwargs):
|
||||
return None
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, path):
|
||||
client_states.append({"path": path, "stale_exists": stale_file.exists()})
|
||||
|
||||
def delete_collection(self, name):
|
||||
deleted_collections.append(name)
|
||||
|
||||
def get_or_create_collection(self, name):
|
||||
return FakeCollection()
|
||||
|
||||
class FakeSharedSystemClient:
|
||||
@staticmethod
|
||||
def clear_system_cache():
|
||||
client_states.append({"path": "cache-cleared", "stale_exists": stale_file.exists()})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "chromadb", type("FakeChromaModule", (), {"PersistentClient": FakeClient}))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"chromadb.api.shared_system_client",
|
||||
type("FakeSharedSystemClientModule", (), {"SharedSystemClient": FakeSharedSystemClient}),
|
||||
)
|
||||
|
||||
count = build_chroma_index(
|
||||
source_dir=source_dir,
|
||||
embedding_provider=lambda texts: [[0.1, 0.2] for _ in texts],
|
||||
persist_path=persist_path,
|
||||
collection_name="test",
|
||||
reset=True,
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
assert client_states == [
|
||||
{"path": str(persist_path), "stale_exists": True},
|
||||
{"path": "cache-cleared", "stale_exists": True},
|
||||
{"path": str(persist_path), "stale_exists": True},
|
||||
]
|
||||
assert stale_file.exists()
|
||||
assert deleted_collections == ["test"]
|
||||
|
||||
|
||||
def test_build_chroma_index_reset_clears_bad_index_dir_after_chroma_cache_reset(settings, monkeypatch, tmp_path):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
persist_path = tmp_path / "chroma"
|
||||
persist_path.mkdir()
|
||||
stale_file = persist_path / "chroma.sqlite3"
|
||||
stale_file.write_text("stale", encoding="utf-8")
|
||||
source_dir = tmp_path / "sources"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "rule.md").write_text("注册检验报告要求", encoding="utf-8")
|
||||
events = []
|
||||
|
||||
class FakeCollection:
|
||||
def upsert(self, **kwargs):
|
||||
return None
|
||||
|
||||
class BrokenThenFreshClient:
|
||||
attempts = 0
|
||||
|
||||
def __init__(self, path):
|
||||
BrokenThenFreshClient.attempts += 1
|
||||
events.append(("client", BrokenThenFreshClient.attempts, stale_file.exists()))
|
||||
if BrokenThenFreshClient.attempts == 1:
|
||||
raise ValueError("Could not connect to tenant default_tenant")
|
||||
|
||||
def get_or_create_collection(self, name):
|
||||
return FakeCollection()
|
||||
|
||||
class FakeSharedSystemClient:
|
||||
@staticmethod
|
||||
def clear_system_cache():
|
||||
events.append(("clear_cache", stale_file.exists()))
|
||||
|
||||
fake_chromadb = type(
|
||||
"FakeChromaModule",
|
||||
(),
|
||||
{"PersistentClient": BrokenThenFreshClient},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "chromadb", fake_chromadb)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"chromadb.api.shared_system_client",
|
||||
type("FakeSharedSystemClientModule", (), {"SharedSystemClient": FakeSharedSystemClient}),
|
||||
)
|
||||
|
||||
count = build_chroma_index(
|
||||
source_dir=source_dir,
|
||||
embedding_provider=lambda texts: [[0.1, 0.2] for _ in texts],
|
||||
persist_path=persist_path,
|
||||
collection_name="test",
|
||||
reset=True,
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
assert events == [
|
||||
("client", 1, True),
|
||||
("clear_cache", True),
|
||||
("client", 2, False),
|
||||
]
|
||||
assert not stale_file.exists()
|
||||
133
tests/test_regulatory_rectification.py
Normal file
133
tests/test_regulatory_rectification.py
Normal file
@@ -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
|
||||
35
tests/test_regulatory_risk_assess.py
Normal file
35
tests/test_regulatory_risk_assess.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileSummaryBatch, RegulatoryIssue, RegulatoryReviewBatch
|
||||
from review_agent.regulatory_review.schemas import Finding
|
||||
from review_agent.regulatory_review.services.risk_assess import persist_findings
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_persist_findings_deduplicates_and_updates_risk_summary(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")
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-RISK",
|
||||
)
|
||||
finding = Finding(
|
||||
rule_code="registration_test_report",
|
||||
category="completeness",
|
||||
severity="blocking",
|
||||
title="缺少注册检验报告",
|
||||
suggestion="请补充注册检验报告并复核。",
|
||||
citations=[{"source": "法规.doc", "text": "注册检验报告"}],
|
||||
)
|
||||
|
||||
issues = persist_findings(batch, [finding, finding])
|
||||
|
||||
batch.refresh_from_db()
|
||||
assert len(issues) == 1
|
||||
assert RegulatoryIssue.objects.count() == 1
|
||||
assert batch.risk_summary["blocking"] == 1
|
||||
93
tests/test_regulatory_rule_loader.py
Normal file
93
tests/test_regulatory_rule_loader.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from review_agent.models import RegulatoryRuleVersion
|
||||
from review_agent.regulatory_review.services.rule_loader import (
|
||||
DEFAULT_RULE_CODE,
|
||||
check_rule_version,
|
||||
compute_file_sha256,
|
||||
load_rule_file,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_load_rule_file_reads_demo_requirements():
|
||||
rule_set = load_rule_file()
|
||||
|
||||
codes = {item["code"] for item in rule_set["requirements"]}
|
||||
assert rule_set["code"] == DEFAULT_RULE_CODE
|
||||
assert "product_technical_requirements" in codes
|
||||
assert "instructions_for_use" in codes
|
||||
assert "registration_test_report" in codes
|
||||
assert "clinical_evaluation" in codes
|
||||
assert "essential_principles_checklist" in codes
|
||||
|
||||
|
||||
def test_load_rule_file_covers_attachment4_outline():
|
||||
rule_set = load_rule_file()
|
||||
requirements = rule_set["requirements"]
|
||||
outline = json.loads(Path("tests/fixtures/regulatory/attachment4_outline.json").read_text(encoding="utf-8"))
|
||||
|
||||
for chapter in outline:
|
||||
chapter_rule = next(
|
||||
item for item in requirements if item["title"] == chapter["title"] and item.get("attachment4_code") == chapter["code"]
|
||||
)
|
||||
assert chapter_rule["attachment4_code"] == chapter["code"]
|
||||
assert chapter_rule["severity"] == "high"
|
||||
assert chapter_rule["citation_query"]
|
||||
for child in chapter["children"]:
|
||||
child_rule = next(
|
||||
item
|
||||
for item in requirements
|
||||
if item["title"] == child and str(item.get("attachment4_code", "")).startswith(f"{chapter['code']}.")
|
||||
)
|
||||
assert child_rule["rule_id"]
|
||||
assert child_rule["file_keywords"]
|
||||
assert child_rule["severity"] in {"blocking", "high", "medium"}
|
||||
assert child_rule["citation_query"]
|
||||
|
||||
|
||||
def test_compute_file_sha256_changes_when_file_changes(tmp_path):
|
||||
path = tmp_path / "rule.yaml"
|
||||
path.write_text("code: demo\n", encoding="utf-8")
|
||||
first = compute_file_sha256(path)
|
||||
path.write_text("code: demo2\n", encoding="utf-8")
|
||||
|
||||
assert compute_file_sha256(path) != first
|
||||
|
||||
|
||||
def test_check_rule_version_creates_missing_db_record():
|
||||
result = check_rule_version(update_missing=True)
|
||||
|
||||
record = RegulatoryRuleVersion.objects.get(code=DEFAULT_RULE_CODE)
|
||||
assert result.status == "created"
|
||||
assert result.current_hash == record.yaml_hash
|
||||
assert record.rag_collection == "nmpa_ivd_registration_v1"
|
||||
|
||||
|
||||
def test_check_rule_version_reports_hash_mismatch_without_overwriting():
|
||||
created = check_rule_version(update_missing=True)
|
||||
record = RegulatoryRuleVersion.objects.get(code=DEFAULT_RULE_CODE)
|
||||
record.yaml_hash = "stale"
|
||||
record.save(update_fields=["yaml_hash"])
|
||||
|
||||
result = check_rule_version(update_missing=False)
|
||||
record.refresh_from_db()
|
||||
|
||||
assert result.status == "mismatch"
|
||||
assert result.database_hash == "stale"
|
||||
assert result.current_hash == created.current_hash
|
||||
assert record.yaml_hash == "stale"
|
||||
|
||||
|
||||
def test_regulatory_rules_check_command_reports_status(capsys):
|
||||
call_command("regulatory_rules_check")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert DEFAULT_RULE_CODE in captured.out
|
||||
assert "created" in captured.out or "ok" in captured.out
|
||||
26
tests/test_regulatory_storage.py
Normal file
26
tests/test_regulatory_storage.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import Conversation, FileSummaryBatch, RegulatoryReviewBatch
|
||||
from review_agent.regulatory_review.storage import save_artifact
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_save_artifact_writes_file_and_records_hash(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")
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-ART",
|
||||
)
|
||||
|
||||
artifact = save_artifact(batch, name="raw.json", content='{"ok": true}', artifact_type="json")
|
||||
|
||||
assert artifact.content_hash
|
||||
assert artifact.storage_path.endswith("raw.json")
|
||||
assert (tmp_path / "regulatory_review" / "work" / "RR-ART" / "raw.json").exists()
|
||||
26
tests/test_regulatory_structure.py
Normal file
26
tests/test_regulatory_structure.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from review_agent.regulatory_review.services.rule_loader import load_rule_file
|
||||
from review_agent.regulatory_review.services.structure_check import run_structure_check
|
||||
|
||||
|
||||
def test_structure_check_reports_missing_instruction_sections():
|
||||
document_texts = {
|
||||
"说明书.docx": "产品名称:甲胎蛋白检测试剂盒\n样本要求:血清样本\n有效期:12个月"
|
||||
}
|
||||
|
||||
findings = run_structure_check(document_texts, load_rule_file())
|
||||
|
||||
assert any(finding.rule_code == "instructions_for_use:储存条件" for finding in findings)
|
||||
assert all("样本要求" not in finding.title for finding in findings)
|
||||
|
||||
|
||||
def test_structure_check_reports_missing_attachment4_outline_heading():
|
||||
document_texts = {
|
||||
"申报资料目录.txt": "1. 监管信息\n1.2 申请表\n2. 综述资料\n3. 非临床资料\n"
|
||||
}
|
||||
|
||||
findings = run_structure_check(document_texts, load_rule_file())
|
||||
|
||||
missing = next(finding for finding in findings if finding.rule_code == "attachment4_4_clinical_evaluation")
|
||||
assert missing.category == "structure"
|
||||
assert missing.title == "申报资料目录缺少4临床评价资料章节"
|
||||
assert missing.evidence["expected_title"] == "临床评价资料"
|
||||
61
tests/test_regulatory_text_extract.py
Normal file
61
tests/test_regulatory_text_extract.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from pathlib import Path
|
||||
|
||||
from review_agent.regulatory_review.services.text_extract import extract_text
|
||||
|
||||
|
||||
def test_extract_text_reads_plain_text(tmp_path):
|
||||
path = tmp_path / "说明书.txt"
|
||||
path.write_text("产品名称:甲胎蛋白检测试剂盒\n储存条件:2-8℃", encoding="utf-8")
|
||||
|
||||
result = extract_text(path)
|
||||
|
||||
assert "甲胎蛋白" in result.text
|
||||
assert result.status == "success"
|
||||
assert result.content_hash
|
||||
|
||||
|
||||
def test_extract_text_keeps_wrapped_product_name(tmp_path):
|
||||
path = tmp_path / "申请表.txt"
|
||||
path.write_text(
|
||||
"产品名称:呼吸道合胞病毒、肺炎支原体核酸检测试剂盒\n"
|
||||
"(荧光PCR法)\n"
|
||||
"型号规格:24人份/盒\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = extract_text(path)
|
||||
|
||||
assert result.field_candidates["产品名称"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒 (荧光PCR法)"
|
||||
assert result.field_candidates["型号规格"] == "24人份/盒"
|
||||
|
||||
|
||||
def test_extract_text_reports_unsupported_file(tmp_path):
|
||||
path = tmp_path / "image.png"
|
||||
path.write_bytes(b"png")
|
||||
|
||||
result = extract_text(path)
|
||||
|
||||
assert result.status == "unsupported"
|
||||
assert result.text == ""
|
||||
|
||||
|
||||
def test_extract_text_from_docx_preserves_table_text(tmp_path):
|
||||
from docx import Document
|
||||
|
||||
path = tmp_path / "说明书.docx"
|
||||
document = Document()
|
||||
document.add_paragraph("【主要组成成分】")
|
||||
table = document.add_table(rows=2, cols=2)
|
||||
table.rows[0].cells[0].text = "组分"
|
||||
table.rows[0].cells[1].text = "数量"
|
||||
table.rows[1].cells[0].text = "PCR反应液"
|
||||
table.rows[1].cells[1].text = "1管"
|
||||
document.add_paragraph("【储存条件及有效期】")
|
||||
document.add_paragraph("-20±5℃保存,有效期12个月。")
|
||||
document.save(path)
|
||||
|
||||
result = extract_text(path)
|
||||
|
||||
assert result.status == "success"
|
||||
assert "组分\t数量" in result.text
|
||||
assert result.text.index("PCR反应液") < result.text.index("【储存条件及有效期】")
|
||||
136
tests/test_regulatory_views.py
Normal file
136
tests/test_regulatory_views.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import Conversation, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_regulatory_batch_status_requires_owner(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
batch_no="FS-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-STATUS",
|
||||
)
|
||||
WorkflowNodeRun.objects.create(
|
||||
workflow_type="regulatory_review",
|
||||
workflow_batch_id=batch.pk,
|
||||
node_group="regulatory_review",
|
||||
node_code="prepare",
|
||||
node_name="准备",
|
||||
progress=50,
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
denied = client.get(reverse("regulatory_review_batch_status", args=[batch.pk]))
|
||||
assert denied.status_code == 404
|
||||
|
||||
client.force_login(owner)
|
||||
allowed = client.get(reverse("regulatory_review_batch_status", args=[batch.pk]))
|
||||
assert allowed.status_code == 200
|
||||
payload = allowed.json()
|
||||
assert payload["batch"]["workflow_type"] == "regulatory_review"
|
||||
assert payload["batch"]["batch_no"] == "RR-STATUS"
|
||||
assert payload["nodes"][0]["node_code"] == "prepare"
|
||||
|
||||
|
||||
def test_regulatory_batch_status_exposes_condition_confirmation(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
batch_no="FS-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
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": "体外诊断试剂",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
client.force_login(owner)
|
||||
|
||||
response = client.get(reverse("regulatory_review_batch_status", args=[batch.pk]))
|
||||
|
||||
payload = response.json()
|
||||
assert payload["batch"]["status"] == RegulatoryReviewBatch.Status.WAITING_USER
|
||||
assert payload["condition_confirmation"]["batch_id"] == batch.pk
|
||||
assert payload["condition_confirmation"]["candidates"]["product_category"]["suggested"] == "体外诊断试剂"
|
||||
|
||||
|
||||
def test_regulatory_batch_status_refreshes_incomplete_condition_candidates(
|
||||
client, settings, tmp_path, django_user_model
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=owner, title="会话")
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
batch_no="FS-OK",
|
||||
status=FileSummaryBatch.Status.SUCCESS,
|
||||
product_name="第1章 监管信息",
|
||||
)
|
||||
application = tmp_path / "application.txt"
|
||||
application.write_text(
|
||||
"卡尤迪生物科技宜兴有限公司申请境内第三类体外诊断试剂"
|
||||
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)产品注册。",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from review_agent.models import FileSummaryItem
|
||||
|
||||
FileSummaryItem.objects.create(
|
||||
batch=summary,
|
||||
file_index=1,
|
||||
directory_level="第1章 监管信息",
|
||||
file_name="符合标准的清单.txt",
|
||||
file_type="txt",
|
||||
relative_path="第1章 监管信息/符合标准的清单.txt",
|
||||
storage_path=str(application),
|
||||
)
|
||||
batch = RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=owner,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-WAIT-EMPTY",
|
||||
status=RegulatoryReviewBatch.Status.WAITING_USER,
|
||||
condition_json={
|
||||
"confirmed": False,
|
||||
"candidates": {
|
||||
"product_category": {"suggested": "其他"},
|
||||
"product_name": {"suggested": ""},
|
||||
},
|
||||
},
|
||||
)
|
||||
client.force_login(owner)
|
||||
|
||||
response = client.get(reverse("regulatory_review_batch_status", args=[batch.pk]))
|
||||
|
||||
payload = response.json()
|
||||
candidates = payload["condition_confirmation"]["candidates"]
|
||||
assert candidates["product_category"]["suggested"] == "体外诊断试剂"
|
||||
assert candidates["product_name"]["suggested"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒(荧光PCR法)"
|
||||
502
tests/test_regulatory_workflow.py
Normal file
502
tests/test_regulatory_workflow.py
Normal file
@@ -0,0 +1,502 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user