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:
2026-06-11 00:08:00 +08:00
278 changed files with 45245 additions and 647 deletions

View File

@@ -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)

View 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": ["综述", "章节目录", "生产制造信息", "质量管理体系程序", "管理职责程序", "资源管理程序", "产品实现程序", "质量管理体系的测量/分析和改进程序", "其他质量体系程序信息", "质量管理体系核查文件"]}
]

View 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法",
"【包装规格】",
"规格A24人份/盒、48人份/盒、96人份/盒。",
"规格B24人份/盒、48人份/盒、96人份/盒。",
"【预期用途】",
"本试剂盒用于体外定性检测咽拭子、痰液样本中新型冠状病毒2019-nCoVORF1ab和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

View 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 == []

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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"

View 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()

View 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"

View 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

View 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()

View 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

View 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

View 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

View 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

View 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
View 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

View 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"

View 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()

View 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)

View 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")

View 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()

View 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

View 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

View 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

View 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

View 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 == "甲型试剂盒-文件汇总"

View 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()

View 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)

View 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()

View 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"

View 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"

View 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()

View 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}"

View 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"]

View 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": "孙之烨是谁"}

View 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

View 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"]

View 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

View 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

View 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"] == "缺少注册检验报告"

View 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"

View 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"]

View 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

View 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

View 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"]

View 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

View 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"}

View 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="准备资料",
)

View 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

View 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

View 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")

View 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()

View 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"

View 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

View 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"

View 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

View 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"]

View 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

View 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

View 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"

View 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()

View 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

View 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

View 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

View 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()

View 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"] == "临床评价资料"

View 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("【储存条件及有效期】")

View 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法"

View 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)