Compare commits

...

64 Commits

Author SHA1 Message Date
e701b4502e refactor: 下沉 Word 导出执行链到 chat 服务层 2026-06-04 04:44:47 +08:00
47f887647b test: 补强审核智能体关键上下文字段证据 2026-06-04 04:41:09 +08:00
1b6a09f786 test: 补强资料包关联会话跳转证据 2026-06-04 04:38:33 +08:00
80dc10ce6d refactor: 下沉资料包列表上下文到 documents 服务层 2026-06-04 04:36:41 +08:00
a49524fd93 refactor: 下沉处理历史筛选到 audit 服务层 2026-06-04 04:34:20 +08:00
1b6c54fe78 refactor: 下沉会话执行编排到 chat 服务层 2026-06-04 04:31:55 +08:00
de2bd2956f style: 清理平台页面旧工作台叙事 2026-06-04 04:28:36 +08:00
b30ba19dcc feat: 收口审核指挥台旧入口到 Agent 原型页 2026-06-04 04:26:15 +08:00
961615b88c style: 收口审核指挥台四入口主导航 2026-06-04 04:24:32 +08:00
3ffb6f23b0 style: 统一审核指挥台四入口标签口径 2026-06-04 04:20:19 +08:00
1e08f24cd5 fix: 修复审核指挥台智能审核入口路由 2026-06-04 04:17:00 +08:00
47ca116937 style: 统一飞书通知节点状态语义 2026-06-04 04:11:00 +08:00
87f674cece style: 统一审核智能体状态展示口径 2026-06-04 04:00:44 +08:00
8ec254c393 style: 统一处理历史状态展示口径 2026-06-04 03:57:03 +08:00
7c0dfe14d5 feat: 动态生成资料包异常提示 2026-06-04 03:50:12 +08:00
dc86fc0e58 feat: 收口通知原因语义与留痕校验 2026-06-04 03:44:04 +08:00
742d5e9a42 feat: 增强会话历史风险与绑定状态展示 2026-06-04 03:38:51 +08:00
9bca08001f feat: 补齐DOCX精确页数识别与待复核策略 2026-06-04 03:35:34 +08:00
e9cf964a3f docs: 同步四入口产品形态与知识库入口口径 2026-06-04 03:30:03 +08:00
5d1598f5c1 style: 统一四入口导航当前态与首页入口命名 2026-06-04 03:27:16 +08:00
7b5d968fb4 style: 收口首页四入口主叙事与场景配置层级 2026-06-04 03:23:50 +08:00
e3a54b874d feat: 增强知识库治理台动作总览与通知策略展示 2026-06-04 03:19:50 +08:00
b2af88f870 feat: 增强资料包页导出回看与处理链路展示 2026-06-04 03:15:56 +08:00
5a4a176108 feat: 增强处理历史页指标与上下文跳转 2026-06-04 03:13:28 +08:00
0f989f4c95 feat: 补齐风险卡责任人飞书字段展示 2026-06-04 03:09:13 +08:00
b8501a680d feat: 增强审核智能体页完整性与字段抽取能力卡展示 2026-06-04 03:07:34 +08:00
af36ec460d feat: 增强审核智能体页目录与一致性能力卡展示 2026-06-04 03:03:37 +08:00
a7cee4aa27 feat: 增强审核智能体页顶部上下文与提问模板 2026-06-04 02:59:17 +08:00
5fdcc31c74 feat: 增强审核智能体页风险与通知能力卡展示 2026-06-04 02:54:39 +08:00
20f3883b8c feat: 增强审核智能体页Word导出能力卡展示 2026-06-04 02:49:27 +08:00
08dcb62834 feat: 补齐Word导出报告结构化字段口径 2026-06-04 02:44:50 +08:00
ab3d520642 feat: 持久化资料包导出记录与列表摘要 2026-06-04 02:41:49 +08:00
0e49466746 feat: 增强处理历史导出记录摘要展示 2026-06-04 02:35:23 +08:00
e9b3f13eec feat: 增强知识库治理台前台入口与维护导航 2026-06-04 02:33:17 +08:00
0250bd360a feat: 打通Word导出文件生成与下载闭环 2026-06-04 02:28:42 +08:00
e81f0f891e feat: 明确导出报告正式导出口径 2026-06-04 02:18:36 +08:00
5808dd794d feat: 支持治理配置通过admin维护 2026-06-04 02:14:07 +08:00
0d4e02b9dc feat: 对齐导出节点口径与下载信息展示 2026-06-04 02:07:58 +08:00
007bf43e15 feat: 支持rar资料包导入 2026-06-04 02:04:27 +08:00
3280186625 feat: 打通通知回执与消息状态留痕 2026-06-04 02:00:41 +08:00
a663543b37 feat: 增强处理历史详情导出与通知回执展示 2026-06-04 01:57:21 +08:00
d2a4907561 feat: 增强处理历史资料规模与会话状态展示 2026-06-04 01:54:11 +08:00
96f710ea13 feat: 支持会话内补传资料并保持绑定 2026-06-04 01:51:48 +08:00
1e18fd2be9 feat: 统一治理配置与通知责任人映射 2026-06-04 01:45:27 +08:00
4914ee3a75 feat: 收口知识库治理配置与模板映射 2026-06-04 01:42:12 +08:00
0b7322aa65 feat: 增强资料包压缩导入异常提示 2026-06-04 01:37:11 +08:00
e7e3202714 feat: 支持处理历史按风险状态筛选 2026-06-04 01:32:35 +08:00
73c6336600 feat: 支持7z资料包导入 2026-06-04 01:28:28 +08:00
24446658ad feat: 增强处理历史风险与通知状态展示 2026-06-04 01:21:02 +08:00
72409e9652 feat: 打通通知回链与历史节点回看 2026-06-04 01:15:44 +08:00
11caa6c908 feat: 增强注册审核节点式结果输出 2026-06-04 01:10:40 +08:00
aa0a24fe5a feat: 支持资料包多文件与zip导入 2026-06-04 01:07:15 +08:00
2b40ddc487 feat: 持久化会话节点结果与失败通知留痕 2026-06-04 01:02:06 +08:00
b8381b3ba1 style: 对齐审核智能体节点与信息卡原型 2026-06-04 00:58:11 +08:00
cac9a4aaeb feat: 收口知识库治理入口与配置口径 2026-06-04 00:54:51 +08:00
77d9420d43 feat: 重构处理历史与通知留痕追踪 2026-06-04 00:49:33 +08:00
d0841e533f feat: 重构资料包模型与会话绑定主链路 2026-06-04 00:43:13 +08:00
ddf5e7d15c docs(清理): 移除废弃的superpowers过程文档 2026-06-04 00:23:37 +08:00
7f96e94c21 docs(设计对齐): 统一飞书责任人与通知口径 2026-06-04 00:16:42 +08:00
89ea02ee33 docs(原型设计): 统一分页节点视图表述 2026-06-04 00:14:17 +08:00
70d58c62fe docs(原型设计): 重构Agent化原型文档主线 2026-06-04 00:11:33 +08:00
b489dc1b37 docs(需求分析): 同步Agent原型与资料包会话绑定 2026-06-04 00:07:20 +08:00
fe7f1e8855 docs(详细设计): 按Agent原型重写流程设计 2026-06-03 23:58:42 +08:00
ea9ad57a5f docs(需求分析): 按Agent原型重写核心需求 2026-06-03 23:55:27 +08:00
80 changed files with 8461 additions and 6428 deletions

View File

@@ -28,13 +28,31 @@ V1 采用:
当前系统围绕以下注册申报审核闭环展开:
1. 导入注册资料。
2. 汇总文件目录与页数
3. 对照法规要求检查完整性
4. 抽取产品关键信息
5. 自动填入注册申报表格或对照清单
6. 核查跨文档一致性
7. 输出风险预警与处理建议
1. 导入注册资料
2. 解析资料包并识别产品名称
3. 以解析后的产品名称创建或绑定对话会话
4. 汇总文件目录与页数
5. 对照法规要求检查完整性
6. 抽取产品关键信息
7. 自动填入注册申报表格或对照清单
8. 核查跨文档一致性。
9. 输出风险预警、处理建议和飞书通知。
## 当前产品形态
当前原型和需求文档已经统一为 Agent 化产品形态,顶层入口固定为:
1. `审核智能体`
2. `资料包`
3. `知识库`
4. `处理历史`
对应关系如下:
1. `审核智能体` 是主执行入口,承载对话、模板提问、节点跳转和结构化结果。
2. `资料包` 是主业务对象,资料包与会话绑定,对话标题采用解析后的产品名称,并支持按产品名称或批次号搜索。
3. `知识库` 负责法规资料、业务资料、RAG 切片、字段 Schema、模板映射和飞书配置治理。
4. `处理历史` 用于按批次回看历史任务、关联会话、风险状态和通知留痕。
## 模块划分
@@ -107,13 +125,14 @@ V1 需要完成:
- 模型 API 可配置。
- Docker 一键启动。
当前代码基线已经落地的通用能力:
当前代码基线已经落地的主链能力:
- 首页支持展示场景摘要、RAG 状态、工具数量
- 非法 YAML 场景配置会被自动跳过,并在首页展示错误摘要。
- 对话页支持问题输入、文档范围选择、结构化结果、引用片段、工具调用和审计入口展示
- 文档页支持上传、列表查看、手动入库、失败原因提示和重试
- 审计页支持列表摘要、按场景筛选、详情查看、原始输出展示和敏感信息脱敏
- 首页已收口为 `审核智能体 / 资料包 / 知识库 / 处理历史` 四入口平台总览
- 非法 YAML 场景配置会被自动跳过,并在首页展示错误摘要,但场景仅作为底层执行配置参考
- 审核智能体页已采用三栏结构,支持会话历史、文档范围选择、节点式结果、结构化能力卡、导出与通知回看
- 资料包页支持资料包导入、产品名称 / 批次号搜索、会话跳转、导出记录回看和处理链路展示
- 处理历史页支持按批次、产品、风险状态、通知状态回看执行快照,并展示通知留痕
- 知识库页支持治理对象导航、模板映射、责任人映射、飞书配置和跨入口治理动作总览。
- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索和工具注册机制。
- 测试环境默认固定使用 Mock Provider避免误调用本地真实模型配置。
@@ -137,13 +156,14 @@ V1 暂不重点做:
拿到题目后:
1. 判断题目属于哪类模板
2. 复制最接近的 YAML 场景配置。
1. 判断资料包、规则依据和核心审核链路
2. 调整最接近的任务 YAML 配置。
3. 修改 Agent 角色、目标、指令和输出模板。
4. 上传题目材料。
5. 如需业务计算,新增一个工具函数
6. 用 2 到 3 个问题测试效果
7. 演示场景配置、知识库引用、工具调用、结构化输出和审计日志
4. 上传题目材料并生成资料包
5. 确认产品名称解析、资料包绑定和会话标题是否正确
6. 如需业务计算,新增一个工具函数
7. 用 2 到 3 个预设问题测试目录汇总、完整性检查和字段抽取
8. 演示对话节点、知识库引用、结构化输出、飞书通知和审计日志。
## 当前页面概览
@@ -151,12 +171,11 @@ V1 暂不重点做:
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 场景首页 | `/` | 展示场景名称、描述、适用题型、RAG 状态、工具数和配置异常摘要 |
| 对话页 | `/chat/<scenario_id>/` | 输入问题、勾选已入库文档、查看结构化结果、引用片段、工具调用和审计入口 |
| 文档列表页 | `/documents/` | 查看文档状态、错误信息、上传时间并手动触发入库 |
| 文档上传页 | `/documents/upload/` | 选择场景并上传 `.txt``.md``.pdf``.docx` 文件 |
| 审计列表页 | `/audit/` | 查看执行摘要并按场景筛选 |
| 审计详情页 | `/audit/<log_id>/` | 查看输入、最终回答、结构化输出、引用、工具调用、原始输出和错误信息 |
| 首页 / 平台总览 | `/` | 展示四入口主叙事、产品指标、风险摘要和底层场景配置参考 |
| 审核智能体 | `/chat/``/chat/<conversation_id>/` | 会话驱动审核、节点式结果、上传补传、Word 导出、通知与审计回看 |
| 资料包 | `/documents/` 及相关上传入口 | 查看资料包、按产品名称搜索、跳转会话、查看最近导出和处理链路 |
| 知识库 | `/platform/knowledge-base/` | 管理法规资料、业务资料、切片、字段 Schema、模板映射、责任人映射和飞书配置 |
| 处理历史 | `/audit/` 及详情页 | 查看执行摘要、批次 / 会话 / 产品链路、通知状态、导出摘要和错误信息 |
## 计划启动方式
@@ -249,9 +268,21 @@ docker compose config
## 文档入口
- [V1 总需求文档](docs/需求分析/1.V1总需求文档.md)
- [模块需求文档索引](docs/需求分析/2.模块需求索引.md)
- [智能体总体设计](docs/设计文档/1.智能体总体设计.md)
- [设计文档索引](docs/设计文档/0.设计文档索引.md)
- [需求重构总览与待确认事项](docs/需求分析/0.需求重构总览与待确认事项.md)
- [Config 模块需求分析](docs/需求分析/1.config模块需求分析.md)
- [Scenarios 模块需求分析](docs/需求分析/2.scenarios模块需求分析.md)
- [Documents 模块需求分析](docs/需求分析/3.documents模块需求分析.md)
- [Chat 模块需求分析](docs/需求分析/4.chat模块需求分析.md)
- [Audit 模块需求分析](docs/需求分析/5.audit模块需求分析.md)
- [Agent Core 模块需求分析](docs/需求分析/6.agent_core模块需求分析.md)
- [业务确认问答清单](docs/需求分析/9.业务确认问答清单.md)
- [资料包导入与目录汇总详细设计](docs/详细设计/1.资料包导入与目录汇总.md)
- [法规完整性检查详细设计](docs/详细设计/2.法规完整性检查.md)
- [字段抽取与统一字段池详细设计](docs/详细设计/3.字段抽取与统一字段池.md)
- [一致性核查详细设计](docs/详细设计/4.一致性核查.md)
- [风险预警详细设计](docs/详细设计/5.风险预警.md)
- [Word 回填导出详细设计](docs/详细设计/6.Word回填导出.md)
- [飞书通知详细设计](docs/详细设计/7.飞书通知.md)
- [注册审核平台整体原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.整体原型设计.md)
- [原型设计目录](F:\PyCharm\DEMO-AGENT\docs\原型设计)
- [单文件演示站 HTML](F:\PyCharm\DEMO-AGENT\docs\原型设计\registration-prototype-demo.html)
@@ -262,5 +293,6 @@ docker compose config
当前仓库已补充一套围绕注册申报审核主线的原型设计资产,供复试讲解、方案评审和后续页面实现直接参考:
- 原型文档采用“总览 + 分页细设计”方式组织覆盖资料包导入、审核任务工作台、法规完整性检查、字段抽取与字段池、一致性核查、风险预警、Word 回填导出、飞书通知视图和知识库治理台。
- `docs/原型设计/registration-prototype-demo.html` 提供单文件可交互 mock 演示站,内含 8 个主页面视图和知识库 / 治理台 CRUD 抽屉
- `docs/原型设计/registration-prototype-demo.html` 提供单文件可交互 mock 演示站,当前已重构为 Agent 化界面,顶层为 `审核智能体 / 资料包 / 知识库 / 处理历史`
- 资料包与对话会话已在原型中绑定,对话标题采用解析后的产品名称,资料包页支持按产品名称搜索并跳转对应会话。
- 该演示站仅使用 mock 数据,不依赖 Django 路由或真实 Agent Core 执行结果。

142
agent_core/governance.py Normal file
View File

@@ -0,0 +1,142 @@
from pathlib import Path
import yaml
from django.conf import settings
from django.db.utils import OperationalError, ProgrammingError
def governance_defaults() -> dict:
return {
"owner_mappings": [
{
"owner_role": "注册资料负责人",
"owner_name": "张三",
"department": "注册事务部",
"chapter_scope": "CH1",
"risk_scope": "字段冲突 / 缺失项",
"feishu_user_id": "ou_demo_1",
"feishu_open_id": "on_demo_1",
"feishu_name": "张三",
"notify_enabled": "",
},
{
"owner_role": "注册申报负责人",
"owner_name": "李四",
"department": "临床注册组",
"chapter_scope": "CH2-CH6",
"risk_scope": "完整性风险 / 导出阻断",
"feishu_user_id": "ou_demo_2",
"feishu_open_id": "on_demo_2",
"feishu_name": "李四",
"notify_enabled": "",
},
],
"feishu_configs": [
{
"config_name": "注册审核完成通知",
"notify_reason": "task_completed",
"channel": "群机器人",
"message_template": "审核完成摘要 + @处理人",
"status": "启用",
},
{
"config_name": "注册审核异常通知",
"notify_reason": "task_failed",
"channel": "群机器人",
"message_template": "异常摘要 + @处理人",
"status": "启用",
},
],
"template_mappings": [
{
"template_name": "注册证导出模板",
"output_type": "registration_word_export_report",
"version": "V1.0",
"placeholder_count": 18,
"status": "启用",
"field_mapping_summary": "产品名称 / 注册人 / 适用机型 / 储存条件",
},
{
"template_name": "风险摘要导出模板",
"output_type": "registration_word_export_report",
"version": "V0.9",
"placeholder_count": 10,
"status": "待校验",
"field_mapping_summary": "风险等级 / 批次号 / 责任人 / 证据摘要",
},
],
}
def read_governance_yaml() -> dict:
raw_path = getattr(settings, "GOVERNANCE_CONFIG_PATH", "")
if not raw_path:
return {}
config_path = Path(raw_path)
if not config_path.exists() or not config_path.is_file():
return {}
with config_path.open("r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def load_governance_config() -> dict:
defaults = governance_defaults()
config = read_governance_yaml()
db_config = load_governance_config_from_db()
for key, default_value in defaults.items():
configured_value = db_config.get(key) or config.get(key)
if isinstance(default_value, list) and configured_value:
defaults[key] = configured_value
return defaults
def load_governance_config_from_db() -> dict:
try:
from apps.platform_ui.models import FeishuNotifyConfig, OwnerMapping, WordTemplateMapping
except Exception:
return {}
try:
owner_mappings = [
{
"owner_role": item.owner_role,
"owner_name": item.owner_name,
"department": item.department,
"chapter_scope": item.chapter_scope,
"risk_scope": item.risk_scope,
"feishu_user_id": item.feishu_user_id,
"feishu_open_id": item.feishu_open_id,
"feishu_name": item.feishu_name,
"notify_enabled": "" if item.notify_enabled else "",
}
for item in OwnerMapping.objects.filter(is_active=True)
]
feishu_configs = [
{
"config_name": item.config_name,
"notify_reason": item.notify_reason,
"channel": item.channel,
"message_template": item.message_template,
"status": item.status,
}
for item in FeishuNotifyConfig.objects.filter(is_active=True)
]
template_mappings = [
{
"template_name": item.template_name,
"output_type": item.output_type,
"version": item.version,
"placeholder_count": item.placeholder_count,
"status": item.status,
"field_mapping_summary": item.field_mapping_summary,
}
for item in WordTemplateMapping.objects.filter(is_active=True)
]
except (OperationalError, ProgrammingError, RuntimeError):
return {}
return {
"owner_mappings": owner_mappings,
"feishu_configs": feishu_configs,
"template_mappings": template_mappings,
}

View File

@@ -1,6 +1,7 @@
import json
import time
from .governance import load_governance_config
from .llm_provider import create_llm_provider, get_runtime_llm_config
from .results import AgentResult
from .structured_output import (
@@ -57,6 +58,14 @@ def run_agent(scenario_config: dict, user_input: str, options: dict | None = Non
latency_ms=latency_ms,
status="failed",
error=str(llm_response.error or "未知模型错误"),
conversation_id=str(options.get("conversation_id", "")),
batch_id=str(options.get("batch_id", "")),
product_name=str(options.get("product_name", "")),
notification_payload=_build_notification_payload(
{"notify_reason": "task_failed", "owner_roles": []},
options=options,
status="failed",
),
)
structured_output, _ = parse_structured_output(llm_response.content, output_type)
@@ -70,6 +79,11 @@ def run_agent(scenario_config: dict, user_input: str, options: dict | None = Non
model_name=llm_response.model_name or "unknown-model",
latency_ms=latency_ms,
status="success",
conversation_id=str(options.get("conversation_id", "")),
batch_id=str(options.get("batch_id", "")),
product_name=str(options.get("product_name", "")),
node_results=_build_node_results(output_type, structured_output),
notification_payload=_build_notification_payload(structured_output, options=options, status="success"),
)
@@ -151,3 +165,103 @@ def _format_tool_calls(tool_calls: list[dict]) -> str:
f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}"
)
return "\n".join(lines)
def _build_node_results(output_type: str, structured_output: dict) -> list[dict]:
if output_type.startswith("registration_") or output_type == "feishu_notification_report":
return _build_registration_node_results(output_type, structured_output)
return [
{
"code": output_type,
"label": output_type,
"status": "已完成",
"summary": structured_output.get("summary") or structured_output.get("answer", ""),
}
]
def _build_notification_payload(structured_output: dict, options: dict, status: str) -> dict:
notify_reason = _normalize_notify_reason(
structured_output.get("notify_reason"),
status=status,
)
owners = structured_output.get("owner_roles") or []
if not owners:
owners = load_governance_config()["owner_mappings"]
return {
"batch_id": str(options.get("batch_id", "")),
"conversation_id": str(options.get("conversation_id", "")),
"product_name": str(options.get("product_name", "")),
"notify_reason": notify_reason,
"owners": owners,
"mentioned_users": structured_output.get("mentioned_users") or [],
"message_status": structured_output.get("message_status")
or ("sent" if status == "success" else "failed"),
"web_detail_url": structured_output.get("web_detail_url", ""),
"receipt": structured_output.get("receipt") or {},
"status": status,
}
def _normalize_notify_reason(notify_reason: str | None, *, status: str) -> str:
"""
将通知原因收口到 Demo 固定支持的两类语义。
"""
if notify_reason in {"task_completed", "task_failed"}:
return notify_reason
return "task_completed" if status == "success" else "task_failed"
def _build_registration_node_results(output_type: str, structured_output: dict) -> list[dict]:
nodes = [
{"code": "package_import", "label": "资料包导入", "status": "已完成"},
{"code": "overview", "label": "目录汇总", "status": "待处理"},
{"code": "completeness", "label": "法规完整性检查", "status": "待处理"},
{"code": "field_extraction", "label": "字段抽取", "status": "待处理"},
{"code": "consistency", "label": "一致性核查", "status": "待处理"},
{"code": "risk", "label": "风险预警", "status": "待处理"},
{"code": "word_export", "label": "Word 回填导出", "status": "待处理"},
{"code": "feishu_notify", "label": "飞书通知", "status": "待处理"},
]
progression_map = {
"registration_overview_report": 1,
"registration_completeness_report": 2,
"registration_field_extraction_report": 3,
"registration_consistency_report": 4,
"registration_risk_report": 5,
"registration_word_export_report": 6,
"feishu_notification_report": 7,
}
completed_index = progression_map.get(output_type, 0)
for index in range(1, completed_index + 1):
nodes[index]["status"] = "已完成"
if output_type == "registration_risk_report":
pass_status = structured_output.get("pass_status", "")
if pass_status in {"blocked", "failed"}:
nodes[5]["status"] = "已阻断"
elif pass_status in {"review_required", "manual_review"}:
nodes[5]["status"] = "待复核"
else:
nodes[5]["status"] = "已完成"
return nodes
if output_type == "registration_word_export_report":
export_status = structured_output.get("export_status", "")
if export_status in {"blocked", "draft_only"}:
nodes[6]["status"] = "已阻断" if export_status == "blocked" else "待复核"
else:
nodes[6]["status"] = "已完成"
return nodes
if output_type == "feishu_notification_report":
message_status = structured_output.get("message_status", "")
if message_status in {"failed", "error"}:
nodes[7]["status"] = "失败"
elif message_status in {"sent", "success"}:
nodes[7]["status"] = "已发送"
else:
nodes[7]["status"] = "待处理"
return nodes
return nodes

View File

@@ -20,3 +20,8 @@ class AgentResult:
latency_ms: int = 0
status: str = "success"
error: str = ""
conversation_id: str = ""
batch_id: str = ""
product_name: str = ""
node_results: list = field(default_factory=list)
notification_payload: dict = field(default_factory=dict)

View File

@@ -6,6 +6,8 @@ SUPPORTED_OUTPUT_TYPES = {
"registration_field_extraction_report",
"registration_consistency_report",
"registration_risk_report",
"registration_word_export_report",
"feishu_notification_report",
"ticket_response",
"quality_report",
"risk_audit_report",

View File

@@ -41,6 +41,61 @@ OUTPUT_FIELD_TEMPLATES = {
"suggestions": [],
"references": [],
},
"registration_overview_report": {
"batch_id": "",
"product_name": "",
"file_count": 0,
"total_page_count": 0,
"chapter_summary": [],
"documents": [],
"warnings": [],
},
"registration_completeness_report": {
"summary": "",
"missing_items": [],
"misplaced_items": [],
"risk_level": "medium",
"references": [],
},
"registration_field_extraction_report": {
"summary": "",
"field_items": [],
"low_confidence_items": [],
"references": [],
},
"registration_consistency_report": {
"summary": "",
"conflict_items": [],
"mixed_document_risks": [],
"risk_level": "medium",
"references": [],
},
"registration_risk_report": {
"summary": "",
"risk_items": [],
"highest_risk_level": "medium",
"pass_status": "review_required",
"manual_review_items": [],
"owner_roles": [],
"suggestions": [],
"notify_reason": "task_completed",
},
"registration_word_export_report": {
"summary": "",
"export_status": "draft_only",
"can_export_formally": False,
"blocked_items": [],
"download_url": "",
},
"feishu_notification_report": {
"batch_id": "",
"conversation_id": "",
"notify_reason": "task_completed",
"mentioned_users": [],
"message_status": "pending",
"web_detail_url": "",
"receipt": {},
},
}

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.2.14 on 2026-06-03 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("audit", "0002_demobusinessrecord"),
]
operations = [
migrations.CreateModel(
name="NotificationRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("batch_id", models.CharField(db_index=True, max_length=64)),
("conversation_id", models.CharField(db_index=True, max_length=64)),
(
"product_name",
models.CharField(blank=True, db_index=True, max_length=255),
),
("trigger_source", models.CharField(blank=True, max_length=64)),
("notify_reason", models.CharField(db_index=True, max_length=32)),
("owner_role", models.CharField(blank=True, max_length=100)),
("feishu_user_id", models.CharField(blank=True, max_length=100)),
(
"message_status",
models.CharField(db_index=True, default="pending", max_length=32),
),
("web_detail_url", models.URLField(blank=True)),
("receipt", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddField(
model_name="agentauditlog",
name="batch_id",
field=models.CharField(blank=True, db_index=True, max_length=64),
),
migrations.AddField(
model_name="agentauditlog",
name="conversation_id",
field=models.CharField(blank=True, db_index=True, max_length=64),
),
migrations.AddField(
model_name="agentauditlog",
name="product_name",
field=models.CharField(blank=True, db_index=True, max_length=255),
),
]

View File

@@ -16,6 +16,9 @@ class AgentAuditLog(models.Model):
scenario_id = models.CharField(max_length=100, db_index=True)
scenario_name = models.CharField(max_length=200, blank=True)
batch_id = models.CharField(max_length=64, blank=True, db_index=True)
conversation_id = models.CharField(max_length=64, blank=True, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
user_input = models.TextField()
retrieved_chunks = models.JSONField(default=list, blank=True)
tool_calls = models.JSONField(default=list, blank=True)
@@ -66,3 +69,41 @@ class DemoBusinessRecord(models.Model):
def __str__(self) -> str:
return self.title
class NotificationRecord(models.Model):
"""
飞书通知留痕。
首版只保存离线通知载荷与结果状态,不直接依赖真实飞书网络。
"""
STATUS_PENDING = "pending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
batch_id = models.CharField(max_length=64, db_index=True)
conversation_id = models.CharField(max_length=64, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
trigger_source = models.CharField(max_length=64, blank=True)
notify_reason = models.CharField(max_length=32, db_index=True)
owner_role = models.CharField(max_length=100, blank=True)
feishu_user_id = models.CharField(max_length=100, blank=True)
message_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
web_detail_url = models.URLField(blank=True)
receipt = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.notify_reason}:{self.batch_id}"
def get_message_status_display_text(self) -> str:
"""返回通知状态的中文展示文案。"""
return {
self.STATUS_PENDING: "处理中",
self.STATUS_SENT: "已发送",
self.STATUS_FAILED: "失败",
}.get(self.message_status, self.message_status)

View File

@@ -1,6 +1,41 @@
from agent_core.results import AgentResult
from apps.chat.models import Conversation
from apps.documents.models import SubmissionBatch
from .models import AgentAuditLog
from .models import AgentAuditLog, NotificationRecord
SUPPORTED_NOTIFY_REASONS = {"task_completed", "task_failed"}
RISK_STATUS_DISPLAY = {
"high": "已阻断",
"medium": "待复核",
"low": "已完成",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
EXPORT_STATUS_DISPLAY = {
"completed": "已完成",
"draft_only": "待复核",
"review_required": "待复核",
"manual_review": "待复核",
"blocked": "已阻断",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
CONVERSATION_STATUS_DISPLAY = {
"success": "已完成",
"completed": "已完成",
"review_required": "待复核",
"blocked": "已阻断",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
def create_audit_log(
@@ -8,6 +43,9 @@ def create_audit_log(
scenario_name: str,
user_input: str,
agent_result: AgentResult,
batch_id: str = "",
conversation_id: str = "",
product_name: str = "",
) -> AgentAuditLog:
"""
将一次 Agent 执行结果落库为审计日志。
@@ -20,6 +58,9 @@ def create_audit_log(
return AgentAuditLog.objects.create(
scenario_id=scenario_id,
scenario_name=scenario_name,
batch_id=batch_id,
conversation_id=conversation_id,
product_name=product_name,
user_input=user_input,
retrieved_chunks=agent_result.references,
tool_calls=agent_result.tool_calls,
@@ -55,3 +96,227 @@ def _mask_token_after_marker(value: str, marker: str) -> str:
secret, separator, rest = suffix.partition(" ")
masked_secret = "sk-***" if secret.startswith("sk-") else "***"
return f"{prefix}{marker}{masked_secret}{separator}{rest}"
def create_notification_record(
*,
batch_id: str,
conversation_id: str,
product_name: str,
trigger_source: str,
notify_reason: str,
owner_role: str,
feishu_user_id: str,
message_status: str,
web_detail_url: str,
receipt: dict,
) -> NotificationRecord:
"""
保存通知留痕。
V1 先把通知载荷和结果状态稳定落库,
真实飞书发送可在后续阶段接入。
"""
if notify_reason not in SUPPORTED_NOTIFY_REASONS:
raise ValueError(f"notify_reason 不受支持:{notify_reason}")
return NotificationRecord.objects.create(
batch_id=batch_id,
conversation_id=conversation_id,
product_name=product_name,
trigger_source=trigger_source,
notify_reason=notify_reason,
owner_role=owner_role,
feishu_user_id=feishu_user_id,
message_status=message_status,
web_detail_url=web_detail_url,
receipt=receipt,
)
def build_history_list_context(
*,
scenario_id: str = "",
keyword: str = "",
notify_status: str = "",
risk_status: str = "",
) -> dict:
"""
组装处理历史列表页所需的筛选结果与展示上下文。
View 只负责读取 query params筛选逻辑和列表聚合统一在服务层完成。
"""
logs = AgentAuditLog.objects.all()
if scenario_id:
logs = logs.filter(scenario_id=scenario_id)
if keyword:
logs = logs.filter(product_name__icontains=keyword) | logs.filter(batch_id__icontains=keyword)
if notify_status:
matched_pairs = list(
NotificationRecord.objects.filter(message_status=notify_status).values_list(
"batch_id",
"conversation_id",
)
)
logs = [
log
for log in logs
if (log.batch_id, log.conversation_id) in matched_pairs
]
if risk_status:
logs = [
log
for log in logs
if (log.structured_output or {}).get("highest_risk_level") == risk_status
or (log.structured_output or {}).get("risk_level") == risk_status
]
history_rows = build_history_rows(logs)
return {
"history_rows": history_rows,
"history_metrics": build_history_metrics(history_rows),
"selected_scenario_id": scenario_id,
"keyword": keyword,
"notify_status": notify_status,
"risk_status": risk_status,
}
def build_history_rows(logs) -> list[dict]:
"""
为处理历史列表补齐风险状态和通知状态。
View 只负责收集筛选条件,列表展示所需的聚合字段统一在服务层完成。
"""
notification_map = {
(item.batch_id, item.conversation_id): item
for item in NotificationRecord.objects.order_by("-created_at")
}
batch_map = {
item.batch_id: item
for item in SubmissionBatch.objects.filter(
batch_id__in=[log.batch_id for log in logs if log.batch_id]
)
}
conversation_map = {
item.conversation_id: item
for item in Conversation.objects.filter(
conversation_id__in=[log.conversation_id for log in logs if log.conversation_id]
)
}
rows = []
for log in logs:
notification = notification_map.get((log.batch_id, log.conversation_id))
batch = batch_map.get(log.batch_id)
conversation = conversation_map.get(log.conversation_id)
structured_output = log.structured_output or {}
rows.append(
{
"log": log,
"batch": batch,
"conversation": conversation,
"batch_scale": f"{batch.file_count} 份 / {batch.page_count}" if batch else "-",
"batch_status": batch.get_import_status_display_text() if batch else "-",
"conversation_status": _get_conversation_status_display_text(
conversation.task_status if conversation else "-"
),
"risk_status": _get_risk_status_display_text(
structured_output.get("highest_risk_level")
or structured_output.get("risk_level")
or "-"
),
"notify_status": notification.get_message_status_display_text() if notification else "-",
"notify_reason": notification.notify_reason if notification else "-",
}
)
return rows
def build_history_metrics(history_rows: list[dict]) -> list[dict]:
"""
为处理历史页生成顶部指标卡。
口径保持前台可讲解:
- 处理任务数:当前筛选结果中的执行记录数
- 成功执行:状态为 success 的记录数
- 通知已发送:通知状态为 sent 的记录数
- 高风险阻断:风险等级为 high 的记录数
"""
total_count = len(history_rows)
success_count = sum(1 for row in history_rows if row["log"].status == "success")
notify_sent_count = sum(1 for row in history_rows if row.get("notify_status") == "已发送")
blocked_count = sum(1 for row in history_rows if row.get("risk_status") == "已阻断")
return [
{"label": "处理任务数", "value": total_count, "note": "按当前筛选条件回看执行留痕。"},
{"label": "成功执行", "value": success_count, "note": "执行完成并写入审计快照。"},
{"label": "通知已发送", "value": notify_sent_count, "note": "已生成已发送状态的通知留痕。"},
{"label": "高风险阻断", "value": blocked_count, "note": "当前风险状态为已阻断的处理记录。"},
]
def build_detail_summary(log: AgentAuditLog, conversation, notifications) -> dict:
"""
组装处理历史详情页的导出摘要与通知回执信息。
详情页模板只负责展示,字段拼装与优先级判断统一放在服务层。
"""
structured_output = log.structured_output or {}
output_file = structured_output.get("output_file") or {}
export_node = None
if conversation and conversation.node_results:
export_node = next(
(node for node in conversation.node_results if node.get("label") == "Word 回填导出"),
None,
)
latest_notification = notifications.first() if hasattr(notifications, "first") else None
return {
"export_status": _get_export_status_display_text(
structured_output.get("export_status") or (export_node or {}).get("status", "-")
),
"download_url": structured_output.get("download_url", ""),
"output_file_name": output_file.get("file_name", ""),
"output_file_relative_path": output_file.get("relative_path", ""),
"export_mode": output_file.get("export_mode", ""),
"template_name": structured_output.get("template_name", ""),
"template_version": structured_output.get("template_version", ""),
"draft_export_status": _get_export_status_display_text(
structured_output.get("draft_export_status", "")
),
"formal_export_status": _get_export_status_display_text(
structured_output.get("formal_export_status", "")
),
"blocked_items": structured_output.get("blocked_items") or [],
"notification_receipt": latest_notification.receipt if latest_notification else {},
}
def normalize_conversation_node_results(node_results: list[dict] | None) -> list[dict]:
"""
统一处理历史详情页的节点状态展示口径。
兼容历史上遗留的“飞书通知 / 已完成”节点状态,
页面展示时统一映射为“已发送”。
"""
normalized = []
for node in node_results or []:
item = dict(node)
if item.get("label") == "飞书通知":
status = item.get("status", "")
if status in {"已完成", "success", "sent"}:
item["status"] = "已发送"
elif status in {"failed", "error"}:
item["status"] = "失败"
elif status in {"pending", "processing"}:
item["status"] = "待处理"
normalized.append(item)
return normalized
def _get_risk_status_display_text(status: str) -> str:
return RISK_STATUS_DISPLAY.get(status, status or "-")
def _get_export_status_display_text(status: str) -> str:
return EXPORT_STATUS_DISPLAY.get(status, status or "-")
def _get_conversation_status_display_text(status: str) -> str:
return CONVERSATION_STATUS_DISPLAY.get(status, status or "-")

View File

@@ -1,26 +1,45 @@
from django.shortcuts import get_object_or_404, render
from .models import AgentAuditLog
from .models import AgentAuditLog, NotificationRecord
from apps.chat.models import Conversation
from .services import (
build_detail_summary,
build_history_list_context,
normalize_conversation_node_results,
)
def log_list(request):
# 列表页支持按场景筛选,方便演示时快速定位同一类场景的执行记录
scenario_id = (request.GET.get("scenario_id") or "").strip()
logs = AgentAuditLog.objects.all()
if scenario_id:
logs = logs.filter(scenario_id=scenario_id)
return render(
request,
"audit/log_list.html",
{
"logs": logs,
"selected_scenario_id": scenario_id,
},
# 处理历史页支持按批次、产品和状态筛选
context = build_history_list_context(
scenario_id=(request.GET.get("scenario_id") or "").strip(),
keyword=(request.GET.get("keyword") or "").strip(),
notify_status=(request.GET.get("notify_status") or "").strip(),
risk_status=(request.GET.get("risk_status") or "").strip(),
)
return render(request, "audit/log_list.html", context)
def log_detail(request, log_id: int):
# 详情页只负责按主键加载审计快照并渲染;
# 所有脱敏和字段映射都应在服务层完成。
audit_log = get_object_or_404(AgentAuditLog, pk=log_id)
return render(request, "audit/log_detail.html", {"log": audit_log})
notifications = NotificationRecord.objects.filter(
conversation_id=audit_log.conversation_id,
batch_id=audit_log.batch_id,
)
conversation = Conversation.objects.filter(conversation_id=audit_log.conversation_id).first()
detail_summary = build_detail_summary(audit_log, conversation, notifications)
return render(
request,
"audit/log_detail.html",
{
"log": audit_log,
"notifications": notifications,
"conversation": conversation,
"conversation_node_results": normalize_conversation_node_results(
conversation.node_results if conversation else []
),
"detail_summary": detail_summary,
},
)

316
apps/chat/export_service.py Normal file
View File

@@ -0,0 +1,316 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from xml.sax.saxutils import escape
import zipfile
from django.conf import settings
from django.utils import timezone
from agent_core.governance import load_governance_config
from apps.documents.services import create_export_record
def generate_registration_export(*, batch, conversation, upstream_summary: dict | None = None) -> dict:
"""
基于当前会话上下文生成最小可下载的 Word 导出结果。
这里故意保持为 Django 服务层职责:
- 根据风险结果判断正式版/草稿版
- 选择治理台中启用的模板摘要
- 生成离线可演示的 `.docx` 文件与下载链接
"""
upstream_summary = upstream_summary or {}
template_mapping = _resolve_template_mapping()
blocked_items = _collect_blocked_items(upstream_summary)
can_export_formally = _can_export_formally(upstream_summary, blocked_items)
export_mode = "formal" if can_export_formally else "draft"
export_status = "completed" if can_export_formally else "draft_only"
relative_path = _build_relative_export_path(batch.batch_id, export_mode)
absolute_path = Path(settings.MEDIA_ROOT) / relative_path
absolute_path.parent.mkdir(parents=True, exist_ok=True)
fillable_items = _build_fillable_items(batch, conversation)
filled_fields = _build_filled_fields(fillable_items)
blocked_fields = _build_blocked_fields(blocked_items)
_write_minimal_docx(
absolute_path,
product_name=batch.product_name or conversation.product_name or "未命名资料包",
batch_id=batch.batch_id,
export_mode=export_mode,
summary=upstream_summary.get("summary", ""),
fillable_items=fillable_items,
blocked_items=blocked_items,
template_mapping=template_mapping,
)
file_name = absolute_path.name
download_url = f"/{settings.MEDIA_URL.strip('/')}/{relative_path.as_posix()}"
summary = (
"已生成正式版导出文件。"
if can_export_formally
else "已生成草稿导出文件,正式版仍被风险项阻断。"
)
report = {
"output_type": "registration_word_export_report",
"summary": summary,
"template_name": template_mapping["template_name"],
"template_version": template_mapping["version"],
"export_status": export_status,
"draft_export_status": "completed",
"formal_export_status": "completed" if can_export_formally else "blocked",
"can_export_formally": can_export_formally,
"fillable_items": fillable_items,
"filled_fields": filled_fields,
"fillable_field_count": len(fillable_items),
"filled_field_count": len(filled_fields),
"blocked_items": blocked_items,
"blocked_fields": blocked_fields,
"blocked_field_count": len(blocked_fields),
"manual_review_field_count": len(blocked_fields),
"layout_check_status": "passed",
"download_url": download_url,
"output_file": {
"file_name": file_name,
"relative_path": relative_path.as_posix(),
"absolute_path": str(absolute_path),
"export_mode": export_mode,
"output_version": export_mode,
"generated_at": timezone.now().isoformat(),
},
}
create_export_record(
batch=batch,
conversation_id=conversation.conversation_id,
product_name=batch.product_name or conversation.product_name,
template_name=report["template_name"],
template_version=report["template_version"],
export_mode=export_mode,
output_type=report["output_type"],
file_name=file_name,
relative_path=relative_path.as_posix(),
download_url=download_url,
)
return report
def update_conversation_with_export_report(conversation, export_report: dict) -> None:
latest_summary = dict(conversation.latest_summary or {})
previous_structured_output = latest_summary.get("structured_output") or {}
if previous_structured_output.get("output_type") != "registration_word_export_report":
latest_summary["upstream_structured_output"] = previous_structured_output
latest_summary["structured_output"] = export_report
latest_summary["answer"] = export_report.get("summary", "")
latest_summary["status"] = "success"
conversation.latest_summary = latest_summary
conversation.node_results = _update_word_export_node(conversation.node_results, export_report)
conversation.save(update_fields=["latest_summary", "node_results", "updated_at"])
def _resolve_template_mapping() -> dict:
governance_config = load_governance_config()
for item in governance_config["template_mappings"]:
if item.get("status") == "启用":
return item
return {
"template_name": "注册证导出模板",
"version": "V1.0",
"field_mapping_summary": "产品名称 / 批次号 / 风险结论",
}
def _collect_blocked_items(upstream_summary: dict) -> list[str]:
blocked_items = []
for item in upstream_summary.get("manual_review_items") or []:
if isinstance(item, str) and item.strip():
blocked_items.append(item.strip())
for item in upstream_summary.get("risk_items") or []:
if isinstance(item, dict):
title = (item.get("title") or item.get("issue") or "").strip()
if title:
blocked_items.append(title)
unique_items = []
for item in blocked_items:
if item not in unique_items:
unique_items.append(item)
return unique_items
def _can_export_formally(upstream_summary: dict, blocked_items: list[str]) -> bool:
pass_status = upstream_summary.get("pass_status")
if pass_status in {"blocked", "failed", "review_required", "manual_review"}:
return False
highest_risk_level = str(upstream_summary.get("highest_risk_level", "")).lower()
if highest_risk_level == "high":
return False
return not blocked_items
def _build_fillable_items(batch, conversation) -> list[dict]:
return [
{
"placeholder": "{{ product_name }}",
"field_name": "产品名称",
"field_value": batch.product_name,
"source": "资料包主信息",
"fill_status": "filled",
"required": True,
},
{
"placeholder": "{{ batch_id }}",
"field_name": "批次号",
"field_value": batch.batch_id,
"source": "资料包主信息",
"fill_status": "filled",
"required": True,
},
{
"placeholder": "{{ conversation_id }}",
"field_name": "会话编号",
"field_value": conversation.conversation_id,
"source": "会话主信息",
"fill_status": "filled",
"required": True,
},
{
"placeholder": "{{ file_count }}",
"field_name": "文件数",
"field_value": str(batch.file_count),
"source": "资料包统计",
"fill_status": "filled",
"required": False,
},
{
"placeholder": "{{ page_count }}",
"field_name": "页数",
"field_value": str(batch.page_count),
"source": "资料包统计",
"fill_status": "filled",
"required": False,
},
]
def _build_filled_fields(fillable_items: list[dict]) -> list[dict]:
return [
{
"placeholder": item["placeholder"],
"field_name": item["field_name"],
"field_value": item["field_value"],
"source": item["source"],
"fill_status": item["fill_status"],
"required": item["required"],
}
for item in fillable_items
]
def _build_blocked_fields(blocked_items: list[str]) -> list[dict]:
return [
{
"field_name": item,
"block_reason": "待人工复核",
"risk_source": "registration_risk_report",
}
for item in blocked_items
]
def _build_relative_export_path(batch_id: str, export_mode: str) -> Path:
file_name = f"{batch_id}-{export_mode}.docx"
return Path("exports") / date.today().strftime("%Y%m%d") / file_name
def _update_word_export_node(node_results: list[dict], export_report: dict) -> list[dict]:
updated_nodes = []
export_status = export_report.get("export_status")
node_status = "已完成" if export_status == "completed" else "待复核"
for node in node_results or []:
current = dict(node)
if current.get("label") == "Word 回填导出":
current["status"] = node_status
current["summary"] = export_report.get("summary", "")
updated_nodes.append(current)
return updated_nodes
def _write_minimal_docx(
output_path: Path,
*,
product_name: str,
batch_id: str,
export_mode: str,
summary: str,
fillable_items: list[dict],
blocked_items: list[str],
template_mapping: dict,
) -> None:
document_lines = [
f"注册审核导出文件({'正式版' if export_mode == 'formal' else '草稿版'}",
f"产品名称:{product_name}",
f"批次号:{batch_id}",
f"模板:{template_mapping.get('template_name', '')} {template_mapping.get('version', '')}".strip(),
f"风险摘要:{summary or ''}",
"回填字段:",
]
document_lines.extend(
f"- {item['field_name']}{item['field_value']}" for item in fillable_items
)
if blocked_items:
document_lines.append("阻断项:")
document_lines.extend(f"- {item}" for item in blocked_items)
else:
document_lines.append("阻断项:无")
document_xml = _build_document_xml(document_lines)
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", _content_types_xml())
archive.writestr("_rels/.rels", _root_rels_xml())
archive.writestr("word/document.xml", document_xml)
archive.writestr("word/_rels/document.xml.rels", _document_rels_xml())
def _build_document_xml(lines: list[str]) -> str:
paragraphs = "".join(
f"<w:p><w:r><w:t xml:space='preserve'>{escape(line)}</w:t></w:r></w:p>" for line in lines
)
return (
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>"
"<w:document xmlns:w='http://schemas.openxmlformats.org/wordprocessingml/2006/main'>"
"<w:body>"
f"{paragraphs}"
"<w:sectPr/>"
"</w:body>"
"</w:document>"
)
def _content_types_xml() -> str:
return (
"<?xml version='1.0' encoding='UTF-8'?>"
"<Types xmlns='http://schemas.openxmlformats.org/package/2006/content-types'>"
"<Default Extension='rels' ContentType='application/vnd.openxmlformats-package.relationships+xml'/>"
"<Default Extension='xml' ContentType='application/xml'/>"
"<Override PartName='/word/document.xml' "
"ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml'/>"
"</Types>"
)
def _root_rels_xml() -> str:
return (
"<?xml version='1.0' encoding='UTF-8'?>"
"<Relationships xmlns='http://schemas.openxmlformats.org/package/2006/relationships'>"
"<Relationship Id='rId1' "
"Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument' "
"Target='word/document.xml'/>"
"</Relationships>"
)
def _document_rels_xml() -> str:
return (
"<?xml version='1.0' encoding='UTF-8'?>"
"<Relationships xmlns='http://schemas.openxmlformats.org/package/2006/relationships'/>"
)

View File

@@ -1,4 +1,7 @@
from django import forms
from pathlib import Path
from apps.documents.forms import MultipleFileField, SUPPORTED_EXTENSIONS
class ChatForm(forms.Form):
@@ -38,3 +41,23 @@ class ChatForm(forms.Form):
def clean_document_ids(self):
# View 与 Agent Core 都使用整型文档 ID统一在表单层完成转换。
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])]
class ConversationUploadForm(forms.Form):
# 会话右侧上传区只负责继续补传资料,不修改会话绑定关系。
files = MultipleFileField(label="补充文件或资料包", required=False)
file = forms.FileField(label="兼容单文件上传", required=False)
def clean(self):
cleaned_data = super().clean()
files = list(cleaned_data.get("files") or [])
file = cleaned_data.get("file")
if file:
files.append(file)
if not files:
raise forms.ValidationError("请至少上传一个文件或资料包。")
for uploaded_file in files:
if Path(uploaded_file.name).suffix.lower() not in SUPPORTED_EXTENSIONS:
raise forms.ValidationError("仅支持 .txt、.md、.pdf、.docx、.zip 和 .7z 文件")
cleaned_data["uploaded_files"] = files
return cleaned_data

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.14 on 2026-06-03 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Conversation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"conversation_id",
models.CharField(db_index=True, max_length=64, unique=True),
),
("title", models.CharField(max_length=255)),
(
"product_name",
models.CharField(blank=True, db_index=True, max_length=255),
),
(
"batch_id",
models.CharField(blank=True, db_index=True, max_length=64),
),
(
"task_status",
models.CharField(db_index=True, default="pending", max_length=32),
),
("node_results", models.JSONField(blank=True, default=list)),
("latest_summary", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("last_run_at", models.DateTimeField(blank=True, null=True)),
],
options={
"ordering": ["-updated_at", "-created_at"],
},
),
]

View File

46
apps/chat/models.py Normal file
View File

@@ -0,0 +1,46 @@
from django.db import models
class Conversation(models.Model):
"""
审核智能体会话主对象。
会话与资料包一一绑定,标题默认使用解析出的产品名称,
节点结果使用 JSON 挂载,便于页面按节点展示。
"""
STATUS_PENDING = "pending"
STATUS_PROCESSING = "processing"
STATUS_COMPLETED = "completed"
STATUS_REVIEW_REQUIRED = "review_required"
STATUS_BLOCKED = "blocked"
STATUS_FAILED = "failed"
conversation_id = models.CharField(max_length=64, unique=True, db_index=True)
title = models.CharField(max_length=255)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
batch_id = models.CharField(max_length=64, blank=True, db_index=True)
task_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
node_results = models.JSONField(default=list, blank=True)
latest_summary = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
last_run_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-updated_at", "-created_at"]
def __str__(self) -> str:
return self.title
def get_task_status_display_text(self) -> str:
"""返回会话阶段的中文展示文案。"""
return {
self.STATUS_PENDING: "处理中",
self.STATUS_PROCESSING: "处理中",
self.STATUS_COMPLETED: "已完成",
self.STATUS_REVIEW_REQUIRED: "待复核",
self.STATUS_BLOCKED: "已阻断",
self.STATUS_FAILED: "失败",
"success": "已完成",
}.get(self.task_status, self.task_status)

180
apps/chat/services.py Normal file
View File

@@ -0,0 +1,180 @@
from collections.abc import Callable
from django.utils import timezone
from agent_core.orchestrator import run_agent
from agent_core.results import AgentResult
from apps.audit.services import create_audit_log, create_notification_record
from apps.scenarios.services import get_scenario
from .models import Conversation
def create_conversation_for_batch(batch_id: str, product_name: str) -> Conversation:
"""
为资料包创建主会话。
会话标题固定优先使用解析出的产品名称,
缺失时回退到批次号,确保前台始终有稳定标题。
"""
conversation = Conversation.objects.create(
conversation_id=_generate_conversation_id(),
title=product_name or f"未命名资料包-{batch_id}",
product_name=product_name,
batch_id=batch_id,
task_status=Conversation.STATUS_PENDING,
node_results=_build_initial_node_results(),
)
return conversation
def execute_conversation_agent(
*,
conversation: Conversation,
message: str,
document_ids: list[int],
detail_url_builder: Callable[[int], str] | None = None,
) -> tuple[AgentResult, object]:
"""
在服务层串起会话执行、审计留痕与通知落库。
View 只负责收集请求参数和渲染结果,不直接承载 Agent Core 编排。
"""
scenario = get_scenario("document_review")
try:
result = run_agent(
scenario,
message,
options={
"conversation_id": conversation.conversation_id,
"batch_id": conversation.batch_id,
"product_name": conversation.product_name,
"document_ids": document_ids,
},
)
except Exception as exc:
result = AgentResult(status="failed", error=str(exc), answer="")
audit_log = create_audit_log(
"document_review",
"注册审核智能体",
message,
result,
batch_id=conversation.batch_id,
conversation_id=conversation.conversation_id,
product_name=conversation.product_name,
)
_apply_agent_result_to_conversation(conversation, result)
detail_url = detail_url_builder(audit_log.id) if detail_url_builder else ""
_persist_notification_records(result, web_detail_url=detail_url)
return result, audit_log
def execute_conversation_export(*, batch, conversation: Conversation) -> dict:
"""
在服务层串起 Word 导出、会话摘要更新和审计留痕。
View 只负责提示成功/失败消息,不直接承载导出编排细节。
"""
from .export_service import (
generate_registration_export,
update_conversation_with_export_report,
)
upstream_summary = (
(conversation.latest_summary or {}).get("upstream_structured_output")
or (conversation.latest_summary or {}).get("structured_output")
or {}
)
export_report = generate_registration_export(
batch=batch,
conversation=conversation,
upstream_summary=upstream_summary,
)
update_conversation_with_export_report(conversation, export_report)
audit_log = create_audit_log(
"document_review",
"Word 回填导出",
"生成 Word 导出文件",
AgentResult(
answer=export_report.get("summary", ""),
structured_output=export_report,
status="success",
conversation_id=conversation.conversation_id,
batch_id=conversation.batch_id,
product_name=conversation.product_name,
node_results=conversation.node_results,
),
batch_id=conversation.batch_id,
conversation_id=conversation.conversation_id,
product_name=conversation.product_name,
)
return {
"export_report": export_report,
"audit_log": audit_log,
}
def _generate_conversation_id() -> str:
return f"conv-{Conversation.objects.count() + 1:03d}"
def _build_initial_node_results() -> list[dict]:
return [
{"code": "package_import", "label": "资料包导入", "status": "已完成"},
{"code": "overview", "label": "目录汇总", "status": "处理中"},
{"code": "completeness", "label": "法规完整性检查", "status": "待处理"},
{"code": "field_extraction", "label": "字段抽取", "status": "待处理"},
{"code": "consistency", "label": "一致性核查", "status": "待处理"},
{"code": "risk", "label": "风险预警", "status": "待处理"},
{"code": "word_export", "label": "Word 回填导出", "status": "待处理"},
{"code": "feishu_notify", "label": "飞书通知", "status": "待处理"},
]
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
payload = result.notification_payload or {}
owners = payload.get("owners") or []
if not owners:
return
resolved_detail_url = payload.get("web_detail_url") or web_detail_url
resolved_message_status = payload.get("message_status") or (
"sent" if result.status == "success" else "failed"
)
resolved_receipt = payload.get("receipt") or {"status": result.status}
for owner in owners:
create_notification_record(
batch_id=payload.get("batch_id", ""),
conversation_id=payload.get("conversation_id", ""),
product_name=payload.get("product_name", ""),
trigger_source="agent_execution",
notify_reason=payload.get("notify_reason", "task_completed"),
owner_role=owner.get("owner_role", ""),
feishu_user_id=owner.get("feishu_user_id", ""),
message_status=resolved_message_status,
web_detail_url=resolved_detail_url,
receipt=resolved_receipt,
)
def _apply_agent_result_to_conversation(conversation: Conversation, result: AgentResult) -> None:
conversation.task_status = result.status
if result.node_results:
conversation.node_results = result.node_results
conversation.latest_summary = {
"answer": result.answer,
"status": result.status,
"error": result.error,
"structured_output": result.structured_output,
"notification_payload": result.notification_payload,
}
conversation.last_run_at = timezone.now()
conversation.save(
update_fields=[
"task_status",
"node_results",
"latest_summary",
"last_run_at",
"updated_at",
]
)

View File

@@ -5,7 +5,10 @@ from . import views
app_name = "chat"
# 当前 V1 仅保留一个场景对话入口,场景详情合并在对话页中展示
# 审核智能体前台以会话为中心
urlpatterns = [
path("<str:scenario_id>/", views.index, name="index"),
path("", views.index, name="index"),
path("<str:conversation_id>/", views.detail, name="detail"),
path("<str:conversation_id>/upload/", views.upload_documents, name="upload-documents"),
path("<str:conversation_id>/export-word/", views.export_word, name="export-word"),
]

View File

@@ -1,38 +1,79 @@
from django.shortcuts import render
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.decorators.http import require_POST
from agent_core.orchestrator import run_agent
from agent_core.results import AgentResult
from apps.audit.services import create_audit_log
from apps.documents.models import UploadedDocument
from apps.scenarios.services import ScenarioNotFound, get_scenario
from apps.documents.models import SubmissionBatch, UploadedDocument
from apps.documents.services import append_documents_to_batch
from .forms import ChatForm
from .forms import ChatForm, ConversationUploadForm
from .models import Conversation
from .services import execute_conversation_agent, execute_conversation_export
RISK_LEVEL_DISPLAY = {
"high": "",
"medium": "",
"low": "",
}
PASS_STATUS_DISPLAY = {
"blocked": "已阻断",
"failed": "失败",
"review_required": "待复核",
"manual_review": "待复核",
"completed": "已完成",
"passed": "已完成",
}
EXPORT_STATUS_DISPLAY = {
"completed": "已完成",
"draft_only": "待复核",
"review_required": "待复核",
"manual_review": "待复核",
"blocked": "已阻断",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
NOTIFY_MESSAGE_STATUS_DISPLAY = {
"sent": "已发送",
"failed": "失败",
"pending": "处理中",
}
def index(request, scenario_id: str):
# View 只负责请求编排、表单校验和模板渲染。
# 具体 Agent 执行、审计写入和文档筛选规则分别交给独立模块处理。
try:
scenario = get_scenario(scenario_id)
except ScenarioNotFound:
return render(
request,
"chat/index.html",
{
"scenario": None,
"form": ChatForm(),
"error": "场景不存在,请返回首页检查配置。",
},
status=404,
)
def index(request):
conversations = Conversation.objects.all()
if conversations.exists():
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
return render(
request,
"chat/index.html",
{
"conversation": None,
"conversations": [],
"conversation_history": [],
"form": ChatForm(),
"documents": [],
"result": None,
"audit_log": None,
"node_results": [],
"active_node": None,
},
)
def detail(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
documents = UploadedDocument.objects.filter(batch=batch)
form = ChatForm(request.POST or None, documents=documents)
upload_form = ConversationUploadForm()
result = None
audit_log = None
documents = UploadedDocument.objects.filter(
scenario_id=scenario["id"],
status=UploadedDocument.STATUS_INDEXED,
)
form = ChatForm(request.POST or None, documents=documents)
active_node = None
task_modes = [
{"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"},
{"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"},
@@ -42,27 +83,323 @@ def index(request, scenario_id: str):
]
if request.method == "POST" and form.is_valid():
message = form.cleaned_data["message"]
try:
# 只把必要的运行选项传给 Agent Core避免在 View 中散落模型细节。
result = run_agent(
scenario,
message,
options={"document_ids": form.cleaned_data["document_ids"]},
)
except Exception as exc:
result = AgentResult(status="failed", error=str(exc), answer="")
audit_log = create_audit_log(scenario["id"], scenario["name"], message, result)
result, audit_log = execute_conversation_agent(
conversation=conversation,
message=message,
document_ids=form.cleaned_data["document_ids"],
detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
)
active_node = "risk"
conversation.refresh_from_db()
display_node_results = _normalize_node_results(conversation.node_results)
workspace_summary = _build_workspace_summary(conversation, batch, display_node_results)
conversation_context = _build_conversation_context(conversation, batch, workspace_summary)
prompt_templates = _build_prompt_templates()
analysis_card = _build_analysis_card(result, conversation)
export_card = _build_export_card(result, conversation)
risk_card = _build_risk_card(result, conversation)
notify_card = _build_notify_card(result, conversation)
conversation_history = _build_conversation_history(Conversation.objects.all())
return render(
request,
"chat/index.html",
{
"scenario": scenario,
"conversation": conversation,
"conversations": Conversation.objects.all(),
"conversation_history": conversation_history,
"batch": batch,
"form": form,
"documents": documents,
"document_count": documents.count(),
"result": result,
"audit_log": audit_log,
"task_modes": task_modes,
"node_results": display_node_results,
"active_node": active_node,
"workspace_summary": workspace_summary,
"conversation_context": conversation_context,
"prompt_templates": prompt_templates,
"analysis_card": analysis_card,
"upload_form": upload_form,
"export_card": export_card,
"risk_card": risk_card,
"notify_card": notify_card,
},
)
@require_POST
def upload_documents(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = get_object_or_404(SubmissionBatch, batch_id=conversation.batch_id)
upload_form = ConversationUploadForm(request.POST, request.FILES)
if upload_form.is_valid():
result = append_documents_to_batch(
"document_review",
batch,
upload_form.cleaned_data["uploaded_files"],
)
warning_count = len(result["registration_overview_report"]["warnings"])
message = "资料已补充到当前资料包。"
if warning_count:
message += f" 当前有 {warning_count} 条待复核提示。"
messages.success(request, message)
else:
messages.error(
request,
"补充资料失败:" + " ".join(upload_form.non_field_errors()) if upload_form.non_field_errors() else "补充资料失败。",
)
return redirect("chat:detail", conversation_id=conversation.conversation_id)
@require_POST
def export_word(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = get_object_or_404(SubmissionBatch, batch_id=conversation.batch_id)
try:
execute_conversation_export(
batch=batch,
conversation=conversation,
)
messages.success(request, "已生成新的 Word 导出文件。")
except Exception as exc:
messages.error(request, f"Word 导出失败:{exc}")
return redirect("chat:detail", conversation_id=conversation.conversation_id)
def _build_workspace_summary(
conversation: Conversation,
batch: SubmissionBatch | None,
display_node_results: list[dict] | None = None,
) -> dict:
normalized_nodes = display_node_results or _normalize_node_results(conversation.node_results)
node_status_map = {node.get("label"): node.get("status", "") for node in normalized_nodes}
risk_status = node_status_map.get("风险预警", "待处理")
notify_status = node_status_map.get("飞书通知", "待处理")
export_status = node_status_map.get("Word 回填导出", "待处理")
highest_risk_level = "" if risk_status in {"已阻断", "待复核"} else ""
latest_summary = conversation.latest_summary or {}
structured_output = latest_summary.get("structured_output") or {}
explicit_export_flag = structured_output.get("can_export_formally")
export_allowed = (
""
if explicit_export_flag is True
else ""
if explicit_export_flag is False
else ""
if risk_status in {"已阻断", "待复核"} or export_status in {"已阻断", "待复核", "失败"}
else ""
)
return {
"highest_risk_level": highest_risk_level,
"export_allowed": export_allowed,
"notify_status": notify_status,
"export_status": export_status,
"download_url": structured_output.get("download_url", ""),
"file_count": batch.file_count if batch else 0,
"page_count": batch.page_count if batch else 0,
}
def _build_conversation_context(
conversation: Conversation,
batch: SubmissionBatch | None,
workspace_summary: dict,
) -> dict:
return {
"batch_id": conversation.batch_id,
"product_name": conversation.product_name,
"workflow_type": batch.workflow_type if batch else "registration",
"task_status": conversation.get_task_status_display_text(),
"highest_risk_level": workspace_summary.get("highest_risk_level", "-"),
"export_allowed": workspace_summary.get("export_allowed", "-"),
}
def _build_prompt_templates() -> list[str]:
return [
"请汇总当前资料包的章节点、页数和目录覆盖情况",
"请检查当前资料包缺失了哪些必交项和错放项",
"请抽取当前资料包的核心字段并标记低置信度项",
"请给出当前资料包的高风险项、责任人和整改建议",
]
def _build_conversation_history(conversations) -> list[dict]:
"""
组装左栏会话历史摘要。
左栏只展示稳定摘要字段,不在模板里拼风险判断逻辑。
"""
history = []
for item in conversations:
node_status_map = {node.get("label"): node.get("status", "") for node in item.node_results}
risk_status = node_status_map.get("风险预警", "待处理")
history.append(
{
"conversation_id": item.conversation_id,
"title": item.title,
"product_name": item.product_name,
"batch_id": item.batch_id,
"risk_level": "" if risk_status in {"已阻断", "待复核"} else "",
"updated_at": item.updated_at,
"batch_binding_label": "已绑定资料包" if item.batch_id else "未绑定资料包",
}
)
return history
def _build_analysis_card(result: AgentResult | None, conversation: Conversation) -> dict:
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
output_type = structured_output.get("output_type")
if output_type == "registration_overview_report":
return {
"kind": "overview",
"title": "目录汇总能力卡",
"summary": structured_output.get("product_name", ""),
"stats": [
{"label": "资料文件数", "value": structured_output.get("file_count", 0)},
{"label": "总页数", "value": structured_output.get("total_page_count", 0)},
],
"items": structured_output.get("chapter_summary") or [],
"warnings": structured_output.get("warnings") or [],
}
if output_type == "registration_completeness_report":
return {
"kind": "completeness",
"title": "完整性检查能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "风险等级", "value": _get_risk_level_display_text(structured_output.get("risk_level", "-"))}],
"items": structured_output.get("missing_items") or [],
"warnings": structured_output.get("misplaced_items") or [],
}
if output_type == "registration_field_extraction_report":
return {
"kind": "field_extraction",
"title": "字段抽取能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "字段数", "value": len(structured_output.get("field_items") or [])}],
"items": structured_output.get("field_items") or [],
"warnings": structured_output.get("low_confidence_items") or [],
}
if output_type == "registration_consistency_report":
return {
"kind": "consistency",
"title": "一致性核查能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "风险等级", "value": _get_risk_level_display_text(structured_output.get("risk_level", "-"))}],
"items": structured_output.get("conflict_items") or [],
"warnings": structured_output.get("mixed_document_risks") or [],
}
return {}
def _build_export_card(result: AgentResult | None, conversation: Conversation) -> dict:
"""
统一组装 Word 导出能力卡上下文。
优先使用本次执行结果;若本次未执行,则回退到会话最新摘要。
"""
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
if structured_output.get("output_type") != "registration_word_export_report":
return {}
return {
"template_name": structured_output.get("template_name", ""),
"template_version": structured_output.get("template_version", ""),
"export_status": _get_export_status_display_text(structured_output.get("export_status", "")),
"filled_fields": structured_output.get("filled_fields") or [],
"blocked_fields": structured_output.get("blocked_fields") or [],
"download_url": structured_output.get("download_url", ""),
}
def _build_risk_card(result: AgentResult | None, conversation: Conversation) -> dict:
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
if structured_output.get("output_type") != "registration_risk_report":
return {}
return {
"summary": structured_output.get("summary", ""),
"highest_risk_level": _get_risk_level_display_text(
structured_output.get("highest_risk_level", "")
),
"pass_status": _get_pass_status_display_text(structured_output.get("pass_status", "")),
"manual_review_items": structured_output.get("manual_review_items") or [],
"risk_items": structured_output.get("risk_items") or [],
"owner_roles": structured_output.get("owner_roles") or [],
}
def _build_notify_card(result: AgentResult | None, conversation: Conversation) -> dict:
latest_summary = conversation.latest_summary or {}
structured_output = latest_summary.get("structured_output") or {}
notification_payload = latest_summary.get("notification_payload") or {}
if result and result.structured_output:
structured_output = result.structured_output
if result and result.notification_payload:
notification_payload = result.notification_payload
notify_reason = (
structured_output.get("notify_reason")
or notification_payload.get("notify_reason")
or ""
)
mentioned_users = structured_output.get("mentioned_users") or notification_payload.get("mentioned_users") or []
message_status = structured_output.get("message_status") or notification_payload.get("message_status") or ""
web_detail_url = structured_output.get("web_detail_url") or notification_payload.get("web_detail_url") or ""
owners = structured_output.get("owner_roles") or notification_payload.get("owners") or []
if not any([notify_reason, mentioned_users, message_status, web_detail_url, owners]):
return {}
return {
"notify_reason": notify_reason,
"mentioned_users": mentioned_users,
"message_status": _get_notify_message_status_display_text(message_status),
"web_detail_url": web_detail_url,
"owners": owners,
}
def _normalize_node_results(node_results: list[dict]) -> list[dict]:
normalized = []
for node in node_results or []:
item = dict(node)
label = item.get("label", "")
status = item.get("status", "")
if label == "飞书通知":
if status in {"已完成", "success", "sent"}:
item["status"] = "已发送"
elif status in {"failed", "error"}:
item["status"] = "失败"
elif status in {"pending", "processing"}:
item["status"] = "待处理"
normalized.append(item)
return normalized
def _get_risk_level_display_text(level: str) -> str:
return RISK_LEVEL_DISPLAY.get(level, level)
def _get_pass_status_display_text(status: str) -> str:
return PASS_STATUS_DISPLAY.get(status, status)
def _get_export_status_display_text(status: str) -> str:
return EXPORT_STATUS_DISPLAY.get(status, status)
def _get_notify_message_status_display_text(status: str) -> str:
return NOTIFY_MESSAGE_STATUS_DISPLAY.get(status, status)

View File

@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import UploadedDocument
from .models import ExportedDocument, UploadedDocument
@admin.register(UploadedDocument)
@@ -9,3 +9,19 @@ class UploadedDocumentAdmin(admin.ModelAdmin):
list_display = ("id", "original_name", "scenario_id", "file_type", "status", "created_at")
list_filter = ("status", "scenario_id", "file_type")
search_fields = ("original_name", "scenario_id")
@admin.register(ExportedDocument)
class ExportedDocumentAdmin(admin.ModelAdmin):
"""管理导出记录,便于按批次、会话和产品回看导出产物。"""
list_display = (
"id",
"file_name",
"batch",
"conversation_id",
"product_name",
"export_mode",
"created_at",
)
list_filter = ("export_mode", "output_type", "template_name")
search_fields = ("file_name", "batch__batch_id", "conversation_id", "product_name")

View File

@@ -5,14 +5,31 @@ from django import forms
from apps.scenarios.services import ScenarioNotFound, get_scenario
from apps.scenarios.services import list_scenarios
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".zip", ".7z", ".rar"}
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
widget = MultipleFileInput
def clean(self, data, initial=None):
single_file_clean = super().clean
if not data:
return []
if isinstance(data, (list, tuple)):
return [single_file_clean(item, initial) for item in data]
return [single_file_clean(data, initial)]
class DocumentUploadForm(forms.Form):
# 使用 ChoiceField 让表单自己维护场景选项,
# 这样模板、校验和后续扩展都能围绕一个入口完成。
scenario_id = forms.ChoiceField(label="场景", choices=())
file = forms.FileField(label="文件")
files = MultipleFileField(label="文件或资料包", required=False)
file = forms.FileField(label="兼容单文件上传", required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -31,7 +48,28 @@ class DocumentUploadForm(forms.Form):
def clean_file(self):
uploaded_file = self.cleaned_data["file"]
if not uploaded_file:
return uploaded_file
extension = Path(uploaded_file.name).suffix.lower()
if extension not in SUPPORTED_EXTENSIONS:
raise forms.ValidationError("仅支持 .txt、.md、.pdf.docx 文件")
raise forms.ValidationError("仅支持 .txt、.md、.pdf.docx、.zip、.7z 和 .rar 文件")
return uploaded_file
def clean_files(self):
uploaded_files = self.cleaned_data.get("files") or []
for uploaded_file in uploaded_files:
extension = Path(uploaded_file.name).suffix.lower()
if extension not in SUPPORTED_EXTENSIONS:
raise forms.ValidationError("仅支持 .txt、.md、.pdf、.docx、.zip、.7z 和 .rar 文件")
return uploaded_files
def clean(self):
cleaned_data = super().clean()
files = list(cleaned_data.get("files") or [])
file = cleaned_data.get("file")
if file:
files.append(file)
if not files:
raise forms.ValidationError("请至少上传一个文件或一个 zip 资料包。")
cleaned_data["uploaded_files"] = files
return cleaned_data

View File

@@ -0,0 +1,103 @@
# Generated by Django 5.2.14 on 2026-06-03 16:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="SubmissionBatch",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"batch_id",
models.CharField(db_index=True, max_length=64, unique=True),
),
(
"product_name",
models.CharField(blank=True, db_index=True, max_length=255),
),
(
"workflow_type",
models.CharField(default="registration", max_length=64),
),
(
"conversation_id",
models.CharField(blank=True, db_index=True, max_length=64),
),
("file_count", models.PositiveIntegerField(default=0)),
("page_count", models.PositiveIntegerField(default=0)),
("chapter_summary", models.JSONField(blank=True, default=list)),
(
"import_status",
models.CharField(db_index=True, default="pending", max_length=32),
),
("exception_count", models.PositiveIntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddField(
model_name="uploadeddocument",
name="chapter_code",
field=models.CharField(blank=True, max_length=32),
),
migrations.AddField(
model_name="uploadeddocument",
name="chapter_match_status",
field=models.CharField(blank=True, max_length=32),
),
migrations.AddField(
model_name="uploadeddocument",
name="document_role",
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name="uploadeddocument",
name="needs_manual_review",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="uploadeddocument",
name="page_count",
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name="uploadeddocument",
name="page_count_confidence",
field=models.CharField(blank=True, max_length=32),
),
migrations.AddField(
model_name="uploadeddocument",
name="relative_path",
field=models.CharField(blank=True, max_length=500),
),
migrations.AddField(
model_name="uploadeddocument",
name="batch",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="documents",
to="documents.submissionbatch",
),
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.2.14 on 2026-06-04 18:20
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "0002_submissionbatch_uploadeddocument_chapter_code_and_more"),
]
operations = [
migrations.CreateModel(
name="ExportedDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("conversation_id", models.CharField(db_index=True, max_length=64)),
("product_name", models.CharField(blank=True, db_index=True, max_length=255)),
("template_name", models.CharField(blank=True, max_length=100)),
("template_version", models.CharField(blank=True, max_length=50)),
("export_mode", models.CharField(db_index=True, max_length=32)),
(
"output_type",
models.CharField(default="registration_word_export_report", max_length=100),
),
("file_name", models.CharField(max_length=255)),
("relative_path", models.CharField(max_length=500)),
("download_url", models.CharField(blank=True, max_length=500)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"batch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="export_records",
to="documents.submissionbatch",
),
),
],
options={
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,6 +1,48 @@
from django.db import models
class SubmissionBatch(models.Model):
"""
资料包主对象,承接导入、会话绑定和目录汇总结果。
Documents 模块负责维护资料包与文件的关系,
不在模型层耦合 Agent 执行细节。
"""
STATUS_PENDING = "pending"
STATUS_PROCESSING = "processing"
STATUS_COMPLETED = "completed"
STATUS_REVIEW_REQUIRED = "review_required"
STATUS_FAILED = "failed"
batch_id = models.CharField(max_length=64, unique=True, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
workflow_type = models.CharField(max_length=64, default="registration")
conversation_id = models.CharField(max_length=64, blank=True, db_index=True)
file_count = models.PositiveIntegerField(default=0)
page_count = models.PositiveIntegerField(default=0)
chapter_summary = models.JSONField(default=list, blank=True)
import_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
exception_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.product_name or self.batch_id
def get_import_status_display_text(self) -> str:
return {
self.STATUS_PENDING: "待导入",
self.STATUS_PROCESSING: "处理中",
self.STATUS_COMPLETED: "已完成",
self.STATUS_REVIEW_REQUIRED: "待复核",
self.STATUS_FAILED: "失败",
}.get(self.import_status, self.import_status)
class UploadedDocument(models.Model):
"""
保存用户上传文档的元数据和入库状态。
@@ -13,11 +55,25 @@ class UploadedDocument(models.Model):
STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed"
batch = models.ForeignKey(
SubmissionBatch,
related_name="documents",
null=True,
blank=True,
on_delete=models.CASCADE,
)
scenario_id = models.CharField(max_length=100, db_index=True)
original_name = models.CharField(max_length=255)
file = models.FileField(upload_to="documents/%Y%m%d/")
file_type = models.CharField(max_length=20)
size = models.PositiveIntegerField(default=0)
relative_path = models.CharField(max_length=500, blank=True)
chapter_code = models.CharField(max_length=32, blank=True)
document_role = models.CharField(max_length=64, blank=True)
page_count = models.PositiveIntegerField(default=0)
page_count_confidence = models.CharField(max_length=32, blank=True)
chapter_match_status = models.CharField(max_length=32, blank=True)
needs_manual_review = models.BooleanField(default=False)
status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -36,3 +92,36 @@ class UploadedDocument(models.Model):
self.STATUS_INDEXED: "已入库,可检索",
self.STATUS_FAILED: "入库失败",
}.get(self.status, self.status)
class ExportedDocument(models.Model):
"""
导出文件记录。
该对象属于资料包治理范围:
- Documents 维护导出产物与资料包关系
- Chat 只负责触发导出动作
- Audit 负责回看执行痕迹
"""
batch = models.ForeignKey(
SubmissionBatch,
related_name="export_records",
on_delete=models.CASCADE,
)
conversation_id = models.CharField(max_length=64, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
template_name = models.CharField(max_length=100, blank=True)
template_version = models.CharField(max_length=50, blank=True)
export_mode = models.CharField(max_length=32, db_index=True)
output_type = models.CharField(max_length=100, default="registration_word_export_report")
file_name = models.CharField(max_length=255)
relative_path = models.CharField(max_length=500)
download_url = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.file_name

View File

@@ -1,14 +1,24 @@
from pathlib import Path
from io import BytesIO
import re
import tempfile
import xml.etree.ElementTree as ET
from zipfile import BadZipFile, ZipFile
from agent_core.rag.ingest import ingest_document
from apps.chat.services import create_conversation_for_batch
from django.core.files.uploadedfile import SimpleUploadedFile
from .models import UploadedDocument
from .models import ExportedDocument, SubmissionBatch, UploadedDocument
def create_uploaded_document(scenario_id: str, uploaded_file) -> UploadedDocument:
def create_uploaded_document(
scenario_id: str,
uploaded_file,
batch: SubmissionBatch | None = None,
*,
relative_path: str | None = None,
) -> UploadedDocument:
"""
保存上传文件的元数据记录。
@@ -17,15 +27,323 @@ def create_uploaded_document(scenario_id: str, uploaded_file) -> UploadedDocumen
"""
extension = _detect_extension(uploaded_file.name)
return UploadedDocument.objects.create(
batch=batch,
scenario_id=scenario_id,
original_name=uploaded_file.name,
original_name=Path(relative_path or uploaded_file.name).name,
file=uploaded_file,
file_type=extension,
size=uploaded_file.size,
relative_path=relative_path or uploaded_file.name,
status=UploadedDocument.STATUS_UPLOADED,
)
def import_submission_batch(scenario_id: str, uploaded_files: list) -> dict:
"""
导入资料包并建立批次、文档、目录汇总和主会话。
当前实现保持离线稳定,重点保证:
- 资料包记录可落库
- 产品名称可解析
- 会话可自动绑定
- 可直接产出 overview report
"""
batch = SubmissionBatch.objects.create(
batch_id=_generate_batch_id(),
workflow_type="registration",
import_status=SubmissionBatch.STATUS_PROCESSING,
)
ingest_result = _ingest_files_into_batch(
batch=batch,
scenario_id=scenario_id,
uploaded_files=uploaded_files,
)
documents = ingest_result["documents"]
warnings = ingest_result["warnings"]
product_name = ingest_result["product_name"]
conversation = create_conversation_for_batch(batch.batch_id, product_name)
if not documents:
warnings.append("未发现可导入的支持文件,请检查资料包格式或补充 PDF/DOCX/MD/TXT 文件。")
batch.product_name = product_name
batch.conversation_id = conversation.conversation_id
batch.file_count = len(documents)
batch.page_count = ingest_result["page_count"]
batch.chapter_summary = ingest_result["chapter_summary"]
batch.exception_count = len(warnings)
if not documents:
batch.import_status = SubmissionBatch.STATUS_FAILED
elif warnings:
batch.import_status = SubmissionBatch.STATUS_REVIEW_REQUIRED
else:
batch.import_status = SubmissionBatch.STATUS_COMPLETED
batch.save(
update_fields=[
"product_name",
"conversation_id",
"file_count",
"page_count",
"chapter_summary",
"exception_count",
"import_status",
"updated_at",
]
)
return {
"batch_id": batch.batch_id,
"conversation_id": conversation.conversation_id,
"product_name": batch.product_name,
"registration_overview_report": {
"batch_id": batch.batch_id,
"product_name": batch.product_name,
"file_count": batch.file_count,
"total_page_count": batch.page_count,
"chapter_summary": batch.chapter_summary,
"documents": [
{
"document_id": document.id,
"original_name": document.original_name,
"chapter_code": document.chapter_code,
"page_count": document.page_count,
"document_role": document.document_role,
}
for document in documents
],
"warnings": warnings,
},
}
def append_documents_to_batch(
scenario_id: str,
batch: SubmissionBatch,
uploaded_files: list,
) -> dict:
"""
在既有资料包下继续补传文件,并保持会话绑定不变。
该服务只负责 Documents 侧的数据更新:
- 新文件继续归属原 batch
- conversation_id 不变
- 如原产品名为空,可用新增文件补齐
- 如新增文件产品名与原产品名冲突,则转为待复核
"""
ingest_result = _ingest_files_into_batch(
batch=batch,
scenario_id=scenario_id,
uploaded_files=uploaded_files,
keep_existing_product_name=True,
)
warnings = list(ingest_result["warnings"])
all_documents = list(batch.documents.order_by("id"))
if not all_documents:
warnings.append("未发现可导入的支持文件,请检查资料包格式或补充 PDF/DOCX/MD/TXT 文件。")
batch.import_status = SubmissionBatch.STATUS_FAILED
elif warnings:
batch.import_status = SubmissionBatch.STATUS_REVIEW_REQUIRED
else:
batch.import_status = SubmissionBatch.STATUS_COMPLETED
batch.product_name = ingest_result["product_name"]
batch.file_count = len(all_documents)
batch.page_count = ingest_result["page_count"]
batch.chapter_summary = ingest_result["chapter_summary"]
batch.exception_count = len(warnings)
batch.save(
update_fields=[
"product_name",
"file_count",
"page_count",
"chapter_summary",
"exception_count",
"import_status",
"updated_at",
]
)
if batch.conversation_id:
from apps.chat.models import Conversation
conversation = Conversation.objects.filter(conversation_id=batch.conversation_id).first()
if conversation:
conversation.product_name = batch.product_name
if batch.product_name:
conversation.title = batch.product_name
conversation.save(update_fields=["product_name", "title", "updated_at"])
return {
"batch_id": batch.batch_id,
"conversation_id": batch.conversation_id,
"product_name": batch.product_name,
"registration_overview_report": {
"batch_id": batch.batch_id,
"product_name": batch.product_name,
"file_count": batch.file_count,
"total_page_count": batch.page_count,
"chapter_summary": batch.chapter_summary,
"documents": [
{
"document_id": document.id,
"original_name": document.original_name,
"chapter_code": document.chapter_code,
"page_count": document.page_count,
"document_role": document.document_role,
}
for document in all_documents
],
"warnings": warnings,
},
}
def create_export_record(
*,
batch: SubmissionBatch,
conversation_id: str,
product_name: str,
template_name: str,
template_version: str,
export_mode: str,
output_type: str,
file_name: str,
relative_path: str,
download_url: str,
) -> ExportedDocument:
"""
保存导出文件记录,供资料包与处理历史统一回看。
"""
return ExportedDocument.objects.create(
batch=batch,
conversation_id=conversation_id,
product_name=product_name,
template_name=template_name,
template_version=template_version,
export_mode=export_mode,
output_type=output_type,
file_name=file_name,
relative_path=relative_path,
download_url=download_url,
)
def build_batch_rows(batches) -> list[dict]:
"""
为资料包列表补齐最近导出摘要。
"""
batch_ids = [batch.id for batch in batches]
latest_exports = {}
for record in ExportedDocument.objects.filter(batch_id__in=batch_ids).order_by("batch_id", "-created_at"):
latest_exports.setdefault(record.batch_id, record)
rows = []
for batch in batches:
rows.append(
{
"batch": batch,
"latest_export": latest_exports.get(batch.id),
}
)
return rows
def build_document_list_context(*, keyword: str = "") -> dict:
"""
组装资料包列表页所需的筛选结果与展示上下文。
View 只负责读取 query params批次搜索、统计和异常聚合统一放到服务层。
"""
batches = SubmissionBatch.objects.all()
if keyword:
batches = batches.filter(product_name__icontains=keyword) | batches.filter(
batch_id__icontains=keyword
)
batches = list(batches)
documents = list(UploadedDocument.objects.all())
status_counts = {
"pending": sum(
1 for batch in batches if batch.import_status == SubmissionBatch.STATUS_PENDING
),
"completed": sum(
1 for batch in batches if batch.import_status == SubmissionBatch.STATUS_COMPLETED
),
"review_required": sum(
1
for batch in batches
if batch.import_status == SubmissionBatch.STATUS_REVIEW_REQUIRED
),
"total": len(batches),
}
return {
"documents": documents,
"batches": batches,
"batch_rows": build_batch_rows(batches),
"keyword": keyword,
"status_counts": status_counts,
"processing_pipeline": [
{"title": "原始文件接收", "detail": "校验格式、大小和场景归属后保存原件。"},
{"title": "文本与表格抽取", "detail": "按 PDF / DOCX / MD / TXT 使用不同解析策略。"},
{"title": "页数统计与可信度评估", "detail": "对 Word 页数采用估算与可信度标记。"},
{"title": "章节点归类", "detail": "基于文件名、标题和正文线索识别 CH 节点。"},
{"title": "切片与索引入库", "detail": "生成知识切片,供 RAG、规则定位和审计引用使用。"},
],
"exception_items": build_exception_items(batches, documents),
}
def build_exception_items(batches, documents) -> list[dict]:
"""
聚合资料包页需要关注的异常提示。
只返回真实存在的异常来源,避免页面继续展示静态 demo 文案:
- 批次级待复核
- 文档级待人工复核
- 文档级处理失败
"""
items = []
for document in documents:
if document.status == UploadedDocument.STATUS_FAILED:
items.append(
{
"level": "失败",
"title": f"文档处理失败:{document.original_name}",
"detail": document.error_message or "文档处理异常,请重新上传或稍后重试。",
}
)
continue
if document.needs_manual_review:
review_reasons = []
if document.file_type.lower() == "docx" and document.page_count_confidence != "exact":
review_reasons.append("页数为估算值,建议人工确认")
if not document.chapter_code or document.chapter_match_status != "matched":
review_reasons.append("章节点未识别,建议人工确认归类")
items.append(
{
"level": "待确认",
"title": f"文档待人工复核:{document.original_name}",
"detail": "".join(review_reasons) or "资料存在待确认项,建议人工复核。",
}
)
for batch in batches:
if batch.import_status != SubmissionBatch.STATUS_REVIEW_REQUIRED:
continue
items.append(
{
"level": "待确认",
"title": f"资料包待复核:{batch.batch_id}",
"detail": (
f"{batch.product_name or '未识别产品名称'} 当前存在 "
f"{batch.exception_count} 项异常,请进入关联会话或处理历史继续复核。"
),
}
)
return items
def extract_text(document: UploadedDocument) -> str:
"""
根据文档类型选择合适的文本抽取策略。
@@ -83,6 +401,332 @@ def _detect_extension(file_name: str) -> str:
return Path(file_name).suffix.lower().lstrip(".")
def _generate_batch_id() -> str:
return f"SUB-20260604-{SubmissionBatch.objects.count() + 1:03d}"
def _estimate_page_count(text: str) -> int:
stripped = text.strip()
if not stripped:
return 0
line_count = len([line for line in stripped.splitlines() if line.strip()])
return max(1, line_count)
def _resolve_page_count(document: UploadedDocument, text: str) -> tuple[int, str]:
"""
按文件类型返回页数与可信度。
- PDF优先统计真实页数
- DOCX优先读取 Word 页数元数据
- 其他类型:退回估算
"""
extension = document.file_type.lower()
if extension == "pdf":
page_count = _extract_pdf_page_count(Path(document.file.path))
if page_count > 0:
return page_count, "exact"
return _estimate_page_count(text), "estimated"
if extension == "docx":
page_count = _extract_docx_page_count(Path(document.file.path))
if page_count > 0:
return page_count, "exact"
return _estimate_page_count(text), "estimated"
return _estimate_page_count(text), "estimated"
def _expand_uploaded_files(uploaded_files: list) -> list[dict]:
expanded_files = []
warnings = []
for uploaded_file in uploaded_files:
extension = Path(uploaded_file.name).suffix.lower()
if extension == ".zip":
extraction = _extract_zip_entries(uploaded_file)
expanded_files.extend(extraction["files"])
warnings.extend(extraction["warnings"])
continue
if extension == ".7z":
extraction = _extract_7z_entries(uploaded_file)
expanded_files.extend(extraction["files"])
warnings.extend(extraction["warnings"])
continue
if extension == ".rar":
extraction = _extract_rar_entries(uploaded_file)
expanded_files.extend(extraction["files"])
warnings.extend(extraction["warnings"])
continue
expanded_files.append(
{
"relative_path": uploaded_file.name,
"uploaded_file": uploaded_file,
}
)
return {"files": expanded_files, "warnings": warnings}
def _ingest_files_into_batch(
*,
batch: SubmissionBatch,
scenario_id: str,
uploaded_files: list,
keep_existing_product_name: bool = False,
) -> dict:
expanded_result = _expand_uploaded_files(uploaded_files)
expanded_files = expanded_result["files"]
warnings = list(expanded_result["warnings"])
new_documents = []
new_candidates = []
for uploaded_item in expanded_files:
uploaded_file = uploaded_item["uploaded_file"]
relative_path = uploaded_item["relative_path"]
document = create_uploaded_document(
scenario_id,
uploaded_file,
batch=batch,
relative_path=relative_path,
)
text = extract_text(document)
page_count, page_count_confidence = _resolve_page_count(document, text)
document.page_count = page_count
document.page_count_confidence = page_count_confidence
document.document_role = _detect_document_role(document.relative_path)
document.chapter_code = _detect_chapter_code(document.relative_path, text)
document.chapter_match_status = "matched" if document.chapter_code else "unknown"
document.needs_manual_review = (
not bool(document.chapter_code)
or (document.file_type.lower() == "docx" and page_count_confidence != "exact")
)
if document.file_type.lower() == "docx" and page_count_confidence != "exact":
warnings.append(f"DOCX 页数无法精确统计:{document.relative_path}")
document.save(
update_fields=[
"page_count",
"page_count_confidence",
"document_role",
"chapter_code",
"chapter_match_status",
"needs_manual_review",
"updated_at",
]
)
new_documents.append(document)
new_candidates.extend(_extract_product_candidates(document.relative_path, text))
all_documents = list(batch.documents.order_by("id"))
chapter_summary = {}
total_pages = 0
for document in all_documents:
total_pages += document.page_count
chapter_key = document.chapter_code or "UNCLASSIFIED"
chapter_summary[chapter_key] = chapter_summary.get(chapter_key, 0) + 1
product_name = batch.product_name
if keep_existing_product_name and batch.product_name:
conflict_names = {
item["product_name"] for item in new_candidates if item["product_name"] != batch.product_name
}
if conflict_names:
warnings.append(
"新增文件与当前资料包产品名称不一致:"
+ " / ".join([batch.product_name, *sorted(conflict_names)])
)
else:
product_name, product_warnings = _select_product_name(new_candidates)
warnings.extend(product_warnings)
if keep_existing_product_name and not product_name:
product_name = batch.product_name
return {
"documents": all_documents if keep_existing_product_name else new_documents,
"new_documents": new_documents,
"warnings": warnings,
"product_name": product_name,
"page_count": total_pages if keep_existing_product_name else total_pages,
"chapter_summary": [
{"chapter_code": chapter_code, "document_count": count}
for chapter_code, count in sorted(chapter_summary.items())
],
}
def _extract_zip_entries(uploaded_file) -> dict:
archive_bytes = uploaded_file.read()
uploaded_file.seek(0)
entries = []
warnings = []
with ZipFile(BytesIO(archive_bytes)) as archive:
for info in archive.infolist():
if info.is_dir():
continue
relative_path = info.filename.replace("\\", "/")
extension = Path(relative_path).suffix.lower()
if extension not in {".txt", ".md", ".pdf", ".docx"}:
warnings.append(f"跳过不支持的文件:{relative_path}")
continue
file_data = archive.read(info.filename)
extracted_file = SimpleUploadedFile(
Path(relative_path).name,
file_data,
)
entries.append(
{
"relative_path": relative_path,
"uploaded_file": extracted_file,
}
)
return {"files": entries, "warnings": warnings}
def _extract_7z_entries(uploaded_file) -> dict:
try:
import py7zr
except ImportError as exc:
raise RuntimeError("处理 .7z 资料包需要安装 py7zr。") from exc
archive_bytes = uploaded_file.read()
uploaded_file.seek(0)
entries = []
warnings = []
with tempfile.TemporaryDirectory() as temp_dir:
with py7zr.SevenZipFile(BytesIO(archive_bytes), mode="r") as archive:
archive.extractall(path=temp_dir)
base_path = Path(temp_dir)
for file_path in sorted(base_path.rglob("*")):
if not file_path.is_file():
continue
relative_path = file_path.relative_to(base_path).as_posix()
extension = Path(relative_path).suffix.lower()
if extension not in {".txt", ".md", ".pdf", ".docx"}:
warnings.append(f"跳过不支持的文件:{relative_path}")
continue
extracted_file = SimpleUploadedFile(
file_path.name,
file_path.read_bytes(),
)
entries.append(
{
"relative_path": relative_path,
"uploaded_file": extracted_file,
}
)
return {"files": entries, "warnings": warnings}
def _extract_rar_entries(uploaded_file) -> dict:
try:
import rarfile
except ImportError as exc:
raise RuntimeError("处理 .rar 资料包需要安装 rarfile。") from exc
archive_bytes = uploaded_file.read()
uploaded_file.seek(0)
entries = []
warnings = []
with rarfile.RarFile(BytesIO(archive_bytes)) as archive:
for info in archive.infolist():
if info.is_dir():
continue
relative_path = info.filename.replace("\\", "/")
extension = Path(relative_path).suffix.lower()
if extension not in {".txt", ".md", ".pdf", ".docx"}:
warnings.append(f"跳过不支持的文件:{relative_path}")
continue
file_data = archive.read(info.filename)
extracted_file = SimpleUploadedFile(
Path(relative_path).name,
file_data,
)
entries.append(
{
"relative_path": relative_path,
"uploaded_file": extracted_file,
}
)
return {"files": entries, "warnings": warnings}
def _detect_document_role(file_name: str) -> str:
normalized = file_name.lower()
if "申请表" in file_name:
return "application_form"
if "说明书" in file_name:
return "product_manual"
if "产品列表" in file_name:
return "product_list"
if "声明" in file_name:
return "declaration"
if normalized.endswith(".pdf"):
return "pdf_document"
return "general_document"
def _detect_chapter_code(file_name: str, text: str) -> str:
for source in (file_name, text):
match = re.search(r"(CH\d+(?:\.\d+)*)", source, flags=re.IGNORECASE)
if match:
return match.group(1).upper()
if "监管" in file_name or "申请表" in file_name or "说明书" in file_name:
return "CH1"
return ""
def _extract_product_candidates(file_name: str, text: str) -> list[dict]:
source_type = _detect_candidate_source(file_name)
if not source_type:
return []
patterns = [
r"产品名称[:]\s*([^\n\r]+)",
r"名称[:]\s*([^\n\r]+检测试剂盒[^\n\r]*)",
]
for pattern in patterns:
match = re.search(pattern, text)
if match:
return [{"source_type": source_type, "product_name": match.group(1).strip()}]
cleaned = Path(file_name).stem.replace("目标产品", "").replace("说明书", "").strip("-_ ")
if cleaned and "申请表" not in cleaned and "产品列表" not in cleaned:
return [{"source_type": source_type, "product_name": cleaned}]
return []
def _detect_candidate_source(file_name: str) -> str:
if "申请表" in file_name:
return "application_form"
if "说明书" in file_name:
return "product_manual"
if "产品列表" in file_name:
return "product_list"
return ""
def _select_product_name(candidates: list[dict]) -> tuple[str, list[str]]:
if not candidates:
return "", ["未识别到产品名称,建议人工补录。"]
priority = {
"application_form": 1,
"product_manual": 2,
"product_list": 3,
}
sorted_candidates = sorted(
candidates,
key=lambda item: priority.get(item["source_type"], 99),
)
top_candidate = sorted_candidates[0]
warnings = []
conflict_names = {
item["product_name"]
for item in sorted_candidates
if item["product_name"] != top_candidate["product_name"]
}
if conflict_names:
warnings.append(
"产品名称来源冲突:"
+ " / ".join([top_candidate["product_name"], *sorted(conflict_names)])
)
return top_candidate["product_name"], warnings
def _read_text_file(path: Path) -> str:
"""优先按 UTF-8 读取;失败时回退到系统默认编码。"""
try:
@@ -102,6 +746,17 @@ def _extract_pdf_text(path: Path) -> str:
return _read_binary_text_fallback(path)
def _extract_pdf_page_count(path: Path) -> int:
"""优先使用 pypdf 统计 PDF 真实页数。"""
try:
import pypdf
reader = pypdf.PdfReader(str(path))
return len(reader.pages)
except Exception:
return 0
def _extract_docx_text(path: Path) -> str:
"""提取 Word XML 中的可见文字内容,不追求保留样式。"""
try:
@@ -115,6 +770,26 @@ def _extract_docx_text(path: Path) -> str:
return _read_binary_text_fallback(path)
def _extract_docx_page_count(path: Path) -> int:
"""
从 Word 扩展属性中提取真实页数。
常见 docx 会在 `docProps/app.xml` 中写入 `<Pages>`。
若缺失该元数据,则由上层回退为估算并进入待复核。
"""
try:
with ZipFile(path) as archive:
app_xml = archive.read("docProps/app.xml")
root = ET.fromstring(app_xml)
namespace = {"ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"}
pages_node = root.find(".//ep:Pages", namespace)
if pages_node is None or not (pages_node.text or "").strip():
return 0
return int((pages_node.text or "").strip())
except (BadZipFile, KeyError, ET.ParseError, ValueError):
return 0
def _read_binary_text_fallback(path: Path) -> str:
"""
当结构化抽取失败时,退回到“尽可能保留纯文本”的保底方案。

View File

@@ -6,49 +6,32 @@ from apps.scenarios.services import list_scenarios
from .forms import DocumentUploadForm
from .models import UploadedDocument
from .services import create_uploaded_document, index_document
from .services import (
build_document_list_context,
import_submission_batch,
index_document,
)
def document_list(request):
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节
documents = UploadedDocument.objects.all()
status_counts = {
"uploaded": documents.filter(status=UploadedDocument.STATUS_UPLOADED).count(),
"indexed": documents.filter(status=UploadedDocument.STATUS_INDEXED).count(),
"failed": documents.filter(status=UploadedDocument.STATUS_FAILED).count(),
"total": documents.count(),
}
processing_pipeline = [
{"title": "原始文件接收", "detail": "校验格式、大小和场景归属后保存原件。"},
{"title": "文本与表格抽取", "detail": "按 PDF / DOCX / MD / TXT 使用不同解析策略。"},
{"title": "页数统计与可信度评估", "detail": "对 Word 页数采用估算与可信度标记。"},
{"title": "章节点归类", "detail": "基于文件名、标题和正文线索识别 CH 节点。"},
{"title": "切片与索引入库", "detail": "生成知识切片,供 RAG、规则定位和审计引用使用。"},
]
exception_items = [
{"level": "待确认", "title": "CH1.2 监管信息目录.docx", "detail": "目录页码与正文页数存在偏差,建议人工复核。"},
{"level": "低可信度", "title": "目标产品说明书.docx", "detail": "Word 页数为估算值,表格抽取质量良好。"},
{"level": "失败", "title": "沟通记录扫描件.pdf", "detail": "疑似扫描件,需补做 OCR 或重新上传清晰版。"},
]
return render(
request,
"documents/document_list.html",
{
"documents": documents,
"status_counts": status_counts,
"processing_pipeline": processing_pipeline,
"exception_items": exception_items,
},
)
# 资料包页展示批次、会话绑定和关键异常,同时保留文档级明细便于演示
context = build_document_list_context(keyword=(request.GET.get("keyword") or "").strip())
return render(request, "documents/document_list.html", context)
def upload(request):
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发
# 上传成功后直接创建资料包并绑定主会话
if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid():
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"])
messages.success(request, "文件已上传,可继续执行入库。")
result = import_submission_batch(
form.cleaned_data["scenario_id"],
form.cleaned_data["uploaded_files"],
)
messages.success(
request,
f"资料包已导入,已绑定会话 {result['conversation_id']}",
)
return redirect("documents:list")
else:
form = DocumentUploadForm()
@@ -59,8 +42,9 @@ def upload(request):
"form": form,
"scenarios": list_scenarios(),
"upload_checks": [
"文件格式支持 PDF、DOCX、MD、TXT",
"文件格式支持 PDF、DOCX、MD、TXT、ZIP、7Z 与 RAR 资料包",
"业务资料与法规依据资料需分开归属",
"支持一次上传多份文件并归并到同一个资料包",
"目录类文件会优先参与完整性校验",
"上传完成后建议立即进入解析与入库流程",
],

40
apps/platform_ui/admin.py Normal file
View File

@@ -0,0 +1,40 @@
from django.contrib import admin
from .models import FeishuNotifyConfig, OwnerMapping, WordTemplateMapping
@admin.register(OwnerMapping)
class OwnerMappingAdmin(admin.ModelAdmin):
list_display = (
"owner_role",
"owner_name",
"department",
"chapter_scope",
"risk_scope",
"feishu_user_id",
"notify_enabled",
"is_active",
)
list_filter = ("notify_enabled", "is_active", "department")
search_fields = ("owner_role", "owner_name", "department", "chapter_scope", "risk_scope")
@admin.register(FeishuNotifyConfig)
class FeishuNotifyConfigAdmin(admin.ModelAdmin):
list_display = ("config_name", "notify_reason", "channel", "status", "is_active")
list_filter = ("notify_reason", "status", "is_active")
search_fields = ("config_name", "channel", "message_template")
@admin.register(WordTemplateMapping)
class WordTemplateMappingAdmin(admin.ModelAdmin):
list_display = (
"template_name",
"output_type",
"version",
"placeholder_count",
"status",
"is_active",
)
list_filter = ("output_type", "status", "is_active")
search_fields = ("template_name", "version", "field_mapping_summary")

View File

@@ -0,0 +1,108 @@
# Generated by Django 5.2.14 on 2026-06-03 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="FeishuNotifyConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("config_name", models.CharField(max_length=100)),
(
"notify_reason",
models.CharField(
choices=[
("task_completed", "task_completed"),
("task_failed", "task_failed"),
],
db_index=True,
max_length=32,
),
),
("channel", models.CharField(blank=True, max_length=100)),
("message_template", models.CharField(blank=True, max_length=255)),
("status", models.CharField(blank=True, max_length=32)),
("is_active", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["notify_reason", "id"],
},
),
migrations.CreateModel(
name="OwnerMapping",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("owner_role", models.CharField(db_index=True, max_length=100)),
("owner_name", models.CharField(max_length=100)),
("department", models.CharField(blank=True, max_length=100)),
("chapter_scope", models.CharField(blank=True, max_length=100)),
("risk_scope", models.CharField(blank=True, max_length=255)),
("feishu_user_id", models.CharField(blank=True, max_length=100)),
("feishu_open_id", models.CharField(blank=True, max_length=100)),
("feishu_name", models.CharField(blank=True, max_length=100)),
("notify_enabled", models.BooleanField(default=True)),
("is_active", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["owner_role", "id"],
},
),
migrations.CreateModel(
name="WordTemplateMapping",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("template_name", models.CharField(max_length=100)),
(
"output_type",
models.CharField(
default="registration_word_export_report", max_length=100
),
),
("version", models.CharField(blank=True, max_length=50)),
("placeholder_count", models.PositiveIntegerField(default=0)),
("status", models.CharField(blank=True, max_length=32)),
("field_mapping_summary", models.CharField(blank=True, max_length=255)),
("is_active", models.BooleanField(db_index=True, default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["template_name", "id"],
},
),
]

View File

View File

@@ -0,0 +1,78 @@
from django.db import models
class OwnerMapping(models.Model):
"""
责任人映射。
首版以 Django Admin 作为手工维护入口,字段口径与通知载荷保持一致。
"""
owner_role = models.CharField(max_length=100, db_index=True)
owner_name = models.CharField(max_length=100)
department = models.CharField(max_length=100, blank=True)
chapter_scope = models.CharField(max_length=100, blank=True)
risk_scope = models.CharField(max_length=255, blank=True)
feishu_user_id = models.CharField(max_length=100, blank=True)
feishu_open_id = models.CharField(max_length=100, blank=True)
feishu_name = models.CharField(max_length=100, blank=True)
notify_enabled = models.BooleanField(default=True)
is_active = models.BooleanField(default=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["owner_role", "id"]
def __str__(self) -> str:
return f"{self.owner_role}-{self.owner_name}"
class FeishuNotifyConfig(models.Model):
"""
飞书通知配置。
"""
NOTIFY_REASON_COMPLETED = "task_completed"
NOTIFY_REASON_FAILED = "task_failed"
NOTIFY_REASON_CHOICES = [
(NOTIFY_REASON_COMPLETED, "task_completed"),
(NOTIFY_REASON_FAILED, "task_failed"),
]
config_name = models.CharField(max_length=100)
notify_reason = models.CharField(max_length=32, choices=NOTIFY_REASON_CHOICES, db_index=True)
channel = models.CharField(max_length=100, blank=True)
message_template = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=32, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["notify_reason", "id"]
def __str__(self) -> str:
return self.config_name
class WordTemplateMapping(models.Model):
"""
Word 模板与字段映射摘要。
"""
template_name = models.CharField(max_length=100)
output_type = models.CharField(max_length=100, default="registration_word_export_report")
version = models.CharField(max_length=50, blank=True)
placeholder_count = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=32, blank=True)
field_mapping_summary = models.CharField(max_length=255, blank=True)
is_active = models.BooleanField(default=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["template_name", "id"]
def __str__(self) -> str:
return self.template_name

View File

@@ -1,3 +1,8 @@
from django.urls import reverse
from agent_core.governance import load_governance_config
def get_platform_demo_context():
batch = {
"name": "2026Q2-呼吸道多联检测试剂注册批次",
@@ -28,8 +33,8 @@ def get_platform_demo_context():
quick_links = [
{"title": "知识库配置", "url_name": "platform_ui:knowledge-base", "desc": "维护法规规则树与切片策略"},
{"title": "文件中心", "url_name": "documents:list", "desc": "查看上传、解析、切片与异常状态"},
{"title": "审核工作台", "url_name": "chat:index", "url_arg": "document_review", "desc": "发起审核、抽取与一致性核查演示"},
{"title": "工作台大屏", "url_name": "platform_ui:command-center", "desc": "面向演示的 Agent 流程解释大屏"},
{"title": "审核智能体", "url_name": "chat:index", "desc": "发起审核、抽取与一致性核查演示"},
{"title": "审核指挥台", "url_name": "platform_ui:command-center-v2", "desc": "面向演示的 Agent 流程解释大屏"},
]
knowledge_sources = [
{
@@ -109,6 +114,52 @@ def get_platform_demo_context():
{"label": "业务资料切片", "value": "342"},
{"label": "最近人工校订", "value": "2 次"},
]
governance_sections = [
{"title": "法规规则包", "desc": "维护章节规则、要求项与模板字段映射。"},
{"title": "RAG 文档源", "desc": "统一管理法规资料、业务资料和模板来源。"},
{"title": "RAG 切片", "desc": "查看切片摘要、召回状态和证据命中历史。"},
{"title": "字段 Schema", "desc": "维护强一致字段、回填字段和来源优先级。"},
{"title": "责任人映射", "desc": "按章节和风险类型维护飞书责任人实体。"},
{"title": "飞书通知配置", "desc": "固定支持 task_completed / task_failed 两类通知。"},
]
rag_chunks = [
{
"chunk_id": "chunk-001",
"document_name": "资料要求说明",
"chapter": "CH1",
"summary": "CH1.11.5 沟通记录需保留可追溯留痕。",
"status": "已启用",
},
{
"chunk_id": "chunk-002",
"document_name": "批准证明文件格式要求",
"chapter": "CH1",
"summary": "注册证输出模板需满足固定版式字段映射。",
"status": "待重建",
},
]
field_schemas = [
{
"field_code": "product_name",
"field_name": "产品名称",
"field_type": "string",
"fillable": "",
"strict_consistency": "",
"status": "启用",
},
{
"field_code": "storage_condition",
"field_name": "储存条件",
"field_type": "string",
"fillable": "",
"strict_consistency": "",
"status": "待校订",
},
]
governance_config = load_governance_config()
owner_mappings = governance_config["owner_mappings"]
feishu_configs = governance_config["feishu_configs"]
template_mappings = governance_config["template_mappings"]
mcp_connectors = [
{"name": "飞书任务通知", "kind": "协同办公", "auth": "App Token", "status": "已连接", "sync": "5 分钟前"},
{"name": "法规规则源导入", "kind": "法规服务", "auth": "文件轮询", "status": "待验证", "sync": "今天 08:50"},
@@ -333,6 +384,12 @@ def get_platform_demo_context():
"knowledge_sources": knowledge_sources,
"rule_tree": rule_tree,
"knowledge_stats": knowledge_stats,
"governance_sections": governance_sections,
"rag_chunks": rag_chunks,
"field_schemas": field_schemas,
"owner_mappings": owner_mappings,
"feishu_configs": feishu_configs,
"template_mappings": template_mappings,
"knowledge_filters": knowledge_filters,
"knowledge_form": knowledge_form,
"rule_form": rule_form,
@@ -349,3 +406,124 @@ def get_platform_demo_context():
"owners": owners,
"operation_logs": operation_logs,
}
def build_knowledge_base_context(selected_view: str) -> dict:
"""
组装知识库治理台上下文。
页面层只负责展示,治理对象导航、当前对象说明和 CRUD 入口统一由服务层提供。
"""
context = get_platform_demo_context()
governance_objects = _build_governance_objects()
active_object = next(
(item for item in governance_objects if item["key"] == selected_view),
governance_objects[0],
)
context.update(
{
"governance_objects": governance_objects,
"active_governance_object": active_object,
"governance_action_hub": _build_governance_action_hub(active_object, context),
}
)
return context
def _build_governance_objects() -> list[dict]:
return [
{
"key": "rule_packages",
"title": "法规规则包",
"summary": "按章-条-要求项-模板字段维护规则包版本和启停状态。",
"detail_title": "法规规则包详情",
"detail_copy": "支持新增、编辑、复制新版本、启停和查看章节要求详情。",
"actions": ["新增规则包", "编辑规则包", "复制新版本", "启用 / 停用", "查看章节要求详情"],
"admin_url": reverse("admin:index"),
},
{
"key": "knowledge_sources",
"title": "RAG 文档源",
"summary": "维护法规资料、模板资料和业务资料的入库版本。",
"detail_title": "RAG 文档源详情",
"detail_copy": "支持上传新文档源、替换版本、编辑元数据、停用和重新入库。",
"actions": ["上传新文档源", "替换版本", "编辑元数据", "停用文档源", "重新入库"],
"admin_url": reverse("admin:index"),
},
{
"key": "rag_chunks",
"title": "RAG 切片",
"summary": "查看切片摘要、章节、召回状态和证据命中历史。",
"detail_title": "RAG 切片详情",
"detail_copy": "支持手工切片、摘要编辑、合并拆分、删除和重建向量。",
"actions": ["新增手工切片", "编辑切片摘要", "合并切片", "拆分切片", "重建向量"],
"admin_url": reverse("admin:index"),
},
{
"key": "field_schemas",
"title": "字段 Schema",
"summary": "维护回填字段、强一致字段和来源优先级。",
"detail_title": "字段 Schema 详情",
"detail_copy": "支持新增字段、编辑字段、版本复制和启停管理。",
"actions": ["新增字段", "编辑字段", "启停字段", "复制 schema 版本", "查看来源优先级"],
"admin_url": reverse("admin:index"),
},
{
"key": "template_mappings",
"title": "Word 模板与字段映射",
"summary": "管理输出模板版本、占位符映射和阻断条件影响范围。",
"detail_title": "Word 模板与字段映射详情",
"detail_copy": "支持上传模板、编辑占位符映射、启停版本和模板预览。",
"actions": ["上传模板", "编辑模板元数据", "编辑占位符映射", "启用 / 停用版本", "预览模板"],
"admin_url": reverse("admin:platform_ui_wordtemplatemapping_changelist"),
},
{
"key": "owner_mappings",
"title": "责任人映射",
"summary": "按章节和风险类型维护责任角色、责任人和飞书标识。",
"detail_title": "责任人映射详情",
"detail_copy": "支持新增、编辑、启停、删除以及批量导入责任人映射。",
"actions": ["新增映射", "编辑映射", "启停映射", "删除映射", "批量导入映射"],
"admin_url": reverse("admin:platform_ui_ownermapping_changelist"),
},
{
"key": "feishu_configs",
"title": "飞书通知配置",
"summary": "固定支持 task_completed / task_failed 两类通知并维护消息模板。",
"detail_title": "飞书通知配置详情",
"detail_copy": "支持新增配置、编辑消息模板、启停配置和发送测试消息。",
"actions": ["新增配置", "编辑配置", "切换消息模板", "启用 / 停用", "发送测试消息"],
"admin_url": reverse("admin:platform_ui_feishunotifyconfig_changelist"),
},
]
def _build_governance_action_hub(active_object: dict, context: dict) -> dict:
"""
为治理台提供当前对象聚焦信息与跨入口动作。
目标是把知识库从“静态配置展示页”收口为“治理入口”:
- 明确当前治理对象
- 提供固定通知策略口径
- 提供跳转到审核智能体 / 资料包 / 处理历史的快速入口
"""
owner_count = len(context.get("owner_mappings") or [])
template_count = len(context.get("template_mappings") or [])
feishu_count = len(context.get("feishu_configs") or [])
return {
"title": "治理动作总览",
"current_object": active_object["title"],
"current_summary": active_object["summary"],
"status_items": [
{"label": "责任人映射", "value": f"{owner_count}"},
{"label": "Word 模板版本", "value": f"{template_count}"},
{"label": "通知配置", "value": f"{feishu_count}"},
],
"quick_actions": [
{"label": "进入审核智能体", "url": reverse("chat:index")},
{"label": "查看资料包", "url": reverse("documents:list")},
{"label": "查看处理历史", "url": reverse("audit:list")},
{"label": "进入后台维护", "url": active_object["admin_url"]},
],
"notify_reasons": ["task_completed", "task_failed"],
}

View File

@@ -1,10 +1,10 @@
from django.shortcuts import render
from django.shortcuts import redirect, render
from .services import get_platform_demo_context
from .services import build_knowledge_base_context, get_platform_demo_context
def knowledge_base(request):
context = get_platform_demo_context()
context = build_knowledge_base_context(request.GET.get("view", ""))
return render(request, "platform_ui/knowledge_base.html", context)
@@ -19,8 +19,7 @@ def skill_studio(request):
def command_center(request):
context = get_platform_demo_context()
return render(request, "platform_ui/command_center.html", context)
return redirect("platform_ui:command-center-v2")
def command_center_v2(request):

View File

@@ -108,6 +108,9 @@ MEDIA_ROOT = Path(os.environ.get("UPLOAD_ROOT", BASE_DIR / "data" / "uploads"))
# 配置目录和 Chroma 数据目录都允许外部覆盖,方便复试现场快速切换。
SCENARIO_CONFIG_DIR = Path(os.environ.get("SCENARIO_CONFIG_DIR", BASE_DIR / "configs"))
GOVERNANCE_CONFIG_PATH = Path(
os.environ.get("GOVERNANCE_CONFIG_PATH", BASE_DIR / "configs" / "governance.yaml")
)
CHROMA_PATH = Path(os.environ.get("CHROMA_PATH", BASE_DIR / "data" / "chroma"))
# LLM 与 Embedding 默认遵循“尽量少配置也能跑”的策略:

45
configs/governance.yaml Normal file
View File

@@ -0,0 +1,45 @@
owner_mappings:
- owner_role: 注册资料负责人
owner_name: 张三
department: 注册事务部
chapter_scope: CH1
risk_scope: 字段冲突 / 缺失项
feishu_user_id: ou_demo_1
feishu_open_id: on_demo_1
feishu_name: 张三
notify_enabled:
- owner_role: 注册申报负责人
owner_name: 李四
department: 临床注册组
chapter_scope: CH2-CH6
risk_scope: 完整性风险 / 导出阻断
feishu_user_id: ou_demo_2
feishu_open_id: on_demo_2
feishu_name: 李四
notify_enabled:
feishu_configs:
- config_name: 注册审核完成通知
notify_reason: task_completed
channel: 群机器人
message_template: 审核完成摘要 + @处理人
status: 启用
- config_name: 注册审核异常通知
notify_reason: task_failed
channel: 群机器人
message_template: 异常摘要 + @处理人
status: 启用
template_mappings:
- template_name: 注册证导出模板
output_type: registration_word_export_report
version: V1.0
placeholder_count: 18
status: 启用
field_mapping_summary: 产品名称 / 注册人 / 适用机型 / 储存条件
- template_name: 风险摘要导出模板
output_type: registration_word_export_report
version: V0.9
placeholder_count: 10
status: 待校验
field_mapping_summary: 风险等级 / 批次号 / 责任人 / 证据摘要

View File

@@ -1,376 +0,0 @@
# Registration Agent Prototype Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a new high-fidelity, demo-ready prototype UI for the registration review agent platform across homepage, knowledge base, document processing, agent workspace, MCP, Skills, and leadership dashboard screens.
**Architecture:** Keep the existing Django monolith and template routing boundaries, but replace the current visual system with a unified prototype shell and add a dedicated platform app for new governance pages. Use shared presentation data in service/view code so the pages tell one coherent business story without coupling design code to the existing agent execution internals.
**Tech Stack:** Django templates, Django views/URLs, Python service helpers, shared inline CSS in base template, existing forms/models where useful.
---
## File Structure
- Modify: `F:\PyCharm\DEMO-AGENT\templates\base.html`
- Modify: `F:\PyCharm\DEMO-AGENT\templates\scenarios\index.html`
- Modify: `F:\PyCharm\DEMO-AGENT\templates\documents\document_list.html`
- Modify: `F:\PyCharm\DEMO-AGENT\templates\documents\upload.html`
- Modify: `F:\PyCharm\DEMO-AGENT\templates\chat\index.html`
- Create: `F:\PyCharm\DEMO-AGENT\apps\platform_ui\__init__.py`
- Create: `F:\PyCharm\DEMO-AGENT\apps\platform_ui\apps.py`
- Create: `F:\PyCharm\DEMO-AGENT\apps\platform_ui\views.py`
- Create: `F:\PyCharm\DEMO-AGENT\apps\platform_ui\urls.py`
- Create: `F:\PyCharm\DEMO-AGENT\apps\platform_ui\services.py`
- Create: `F:\PyCharm\DEMO-AGENT\templates\platform_ui\knowledge_base.html`
- Create: `F:\PyCharm\DEMO-AGENT\templates\platform_ui\mcp_center.html`
- Create: `F:\PyCharm\DEMO-AGENT\templates\platform_ui\skill_studio.html`
- Create: `F:\PyCharm\DEMO-AGENT\templates\platform_ui\command_center.html`
- Modify: `F:\PyCharm\DEMO-AGENT\config\settings.py`
- Modify: `F:\PyCharm\DEMO-AGENT\config\urls.py`
- Test: `F:\PyCharm\DEMO-AGENT\tests\`
### Task 1: Register the new platform prototype app
**Files:**
- Create: `apps/platform_ui/__init__.py`
- Create: `apps/platform_ui/apps.py`
- Create: `apps/platform_ui/views.py`
- Create: `apps/platform_ui/urls.py`
- Modify: `config/settings.py`
- Modify: `config/urls.py`
- [ ] **Step 1: Add the Django app module skeleton**
```python
from django.apps import AppConfig
class PlatformUiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.platform_ui"
```
- [ ] **Step 2: Register the app in settings**
```python
INSTALLED_APPS = [
# ...
"apps.platform_ui",
]
```
- [ ] **Step 3: Add prototype page routes**
```python
urlpatterns = [
path("platform/", include("apps.platform_ui.urls")),
]
```
- [ ] **Step 4: Define view names for prototype pages**
```python
urlpatterns = [
path("knowledge-base/", views.knowledge_base, name="knowledge-base"),
path("mcp-center/", views.mcp_center, name="mcp-center"),
path("skills/", views.skill_studio, name="skills"),
path("command-center/", views.command_center, name="command-center"),
]
```
- [ ] **Step 5: Run framework validation**
Run: `python manage.py check`
Expected: PASS with no URL or app import errors
### Task 2: Create shared presentation data for the new prototype
**Files:**
- Create: `apps/platform_ui/services.py`
- Modify: `apps/platform_ui/views.py`
- [ ] **Step 1: Define one coherent demo dataset**
```python
def get_platform_demo_context():
return {
"batch": {...},
"knowledge_sources": [...],
"mcp_connectors": [...],
"skills": [...],
"workflow_steps": [...],
}
```
- [ ] **Step 2: Keep each page view thin**
```python
def knowledge_base(request):
context = get_platform_demo_context()
return render(request, "platform_ui/knowledge_base.html", context)
```
- [ ] **Step 3: Run framework validation**
Run: `python manage.py check`
Expected: PASS with importable views and service helpers
### Task 3: Replace the global visual system and navigation shell
**Files:**
- Modify: `templates/base.html`
- [ ] **Step 1: Rewrite the global shell**
```html
<body>
<div class="app-shell">
<aside class="sidebar">...</aside>
<main class="main-shell">
<header class="topbar">...</header>
{% block content %}{% endblock %}
</main>
</div>
</body>
```
- [ ] **Step 2: Define the new design tokens and shared components**
```css
:root {
--bg: #eef3f7;
--surface: #f8fbfd;
--panel: #ffffff;
--ink: #102033;
--accent: #1e5eff;
--signal: #d77a2b;
}
```
- [ ] **Step 3: Add global helpers for panels, metric cards, timelines, tables, pills, and section headers**
```css
.panel { ... }
.metric-card { ... }
.section-heading { ... }
.timeline-step { ... }
.data-table { ... }
```
- [ ] **Step 4: Render shared navigation links to all prototype surfaces**
```html
<a href="{% url 'scenarios:index' %}">任务总览</a>
<a href="{% url 'platform_ui:knowledge-base' %}">知识库配置</a>
<a href="{% url 'documents:list' %}">文件中心</a>
<a href="{% url 'platform_ui:command-center' %}">工作台大屏</a>
```
- [ ] **Step 5: Open a few pages manually**
Run: `python manage.py runserver`
Expected: the shell renders and every nav item resolves
### Task 4: Redesign the homepage as the business-closure entry point
**Files:**
- Modify: `templates/scenarios/index.html`
- Modify: `apps/scenarios/views.py` if extra presentation fields are needed
- [ ] **Step 1: Add a structured homepage context if needed**
```python
return render(
request,
"scenarios/index.html",
{
"scenarios": list_scenarios(),
"scenario_issues": list_scenario_issues(),
"hero_metrics": [...],
"workflow_overview": [...],
},
)
```
- [ ] **Step 2: Replace the page body with hero, metrics, workflow strip, risk board, and quick-entry modules**
```html
<section class="hero-band">...</section>
<section class="metrics-grid">...</section>
<section class="workflow-strip">...</section>
<section class="two-column-board">...</section>
```
- [ ] **Step 3: Verify the homepage**
Run: `python manage.py runserver`
Expected: `/` shows the new dashboard-like homepage
### Task 5: Redesign the document pages around parsing and slicing workflow
**Files:**
- Modify: `templates/documents/document_list.html`
- Modify: `templates/documents/upload.html`
- Optionally modify: `apps/documents/views.py`
- [ ] **Step 1: Add any lightweight display-only summary fields in the view if needed**
```python
return render(
request,
"documents/document_list.html",
{
"documents": documents,
"processing_summary": {...},
"exception_items": [...],
},
)
```
- [ ] **Step 2: Rebuild the list page into upload stats, pipeline board, anomaly box, and structured directory table**
```html
<section class="metrics-grid">...</section>
<section class="tri-column">...</section>
<section class="panel">
<table class="data-table">...</table>
</section>
```
- [ ] **Step 3: Rebuild the upload page into a guided import experience**
```html
<section class="dropzone-panel">...</section>
<section class="checklist-panel">...</section>
```
- [ ] **Step 4: Verify both pages**
Run: `python manage.py runserver`
Expected: `/documents/` and `/documents/upload/` match the new prototype style
### Task 6: Redesign the chat page as a controlled audit workspace
**Files:**
- Modify: `templates/chat/index.html`
- Optionally modify: `apps/chat/views.py`
- [ ] **Step 1: Add presentation-only helper blocks if the existing context is too sparse**
```python
return render(
request,
"chat/index.html",
{
...
"task_modes": [...],
"result_highlights": [...],
},
)
```
- [ ] **Step 2: Replace the template with a three-zone workspace**
```html
<section class="workspace-grid">
<div class="left-rail">...</div>
<div class="conversation-stage">...</div>
<div class="result-rail">...</div>
</section>
```
- [ ] **Step 3: Preserve the real form submission path while upgrading the visual output areas**
```html
<form method="post">...</form>
{% if result %} ... {% endif %}
```
- [ ] **Step 4: Verify one scenario page**
Run: `python manage.py runserver`
Expected: `/chat/<scenario_id>/` still submits and now renders as a workbench
### Task 7: Implement the governance and platform pages
**Files:**
- Create: `templates/platform_ui/knowledge_base.html`
- Create: `templates/platform_ui/mcp_center.html`
- Create: `templates/platform_ui/skill_studio.html`
- Create: `templates/platform_ui/command_center.html`
- Modify: `apps/platform_ui/views.py`
- Modify: `apps/platform_ui/services.py`
- [ ] **Step 1: Build the knowledge base page**
```html
<section class="three-column-board">...</section>
<section class="panel">...</section>
```
- [ ] **Step 2: Build the MCP center page**
```html
<section class="connector-grid">...</section>
<section class="import-flow">...</section>
```
- [ ] **Step 3: Build the Skill studio page**
```html
<section class="editor-layout">...</section>
<section class="version-board">...</section>
```
- [ ] **Step 4: Build the leadership command center page**
```html
<section class="command-stage">...</section>
<section class="risk-heatmap">...</section>
<section class="evidence-matrix">...</section>
```
- [ ] **Step 5: Verify page routing**
Run: `python manage.py check`
Expected: PASS and all `/platform/...` routes resolve
### Task 8: Regression, polish, and documentation sync
**Files:**
- Modify: `README.md` if navigation or page descriptions need updating
- Modify: `AGENTS.md` only if current implementation status needs sync
- [ ] **Step 1: Run core validation**
Run: `python manage.py check`
Expected: PASS
- [ ] **Step 2: Run targeted tests if template/view assumptions changed**
Run: `pytest`
Expected: PASS or clearly identified existing failures unrelated to the prototype
- [ ] **Step 3: Review the new screens for consistent terminology**
Check: page titles, batch name, product name, risk labels, and workflow terms all match the new design spec
- [ ] **Step 4: Update high-level documentation if needed**
```markdown
## 当前原型页面
- 任务总览
- 知识库配置
- 文件中心
- 审核工作台
- MCP 中心
- Skill Studio
- 工作台大屏
```
## Self-Review
- Spec coverage: the plan covers homepage, knowledge base, document center, chat workspace, MCP, Skill studio, and leadership dashboard.
- Placeholder scan: no TODO/TBD markers remain.
- Type consistency: route names, app names, and page concepts are consistent across tasks.

View File

@@ -1,185 +0,0 @@
# 注册审核智能体平台产品原型设计
## 目标
本次原型围绕“试剂盒临床注册文件准备与审核智能体平台”重新设计一套高保真、可点击、适合复试演示的 Web 产品界面。设计不沿用当前前端视觉而是以新版需求分析为准突出资料治理、法规知识底座、Agent 审核闭环、平台治理能力和可解释工作台。
## 设计范围
本次原型聚焦一条完整演示主线:
1. 首页进入批次级任务总览。
2. 查看法规知识库与结构化规则配置。
3. 上传并解析申报资料,查看切片、页数、章节点归类和异常状态。
4. 进入 AI Agent 审核工作台执行完整性检查、字段抽取和一致性核查。
5. 查看外部 MCP 能力接入情况。
6. 查看和编辑 Skill 编排能力。
7. 进入领导演示型工作台大屏查看 Agent 工作流程、解释依据和风险态势。
## 设计原则
### 1. 业务闭环优先
页面组织必须服务“资料准备到审核闭环”,而不是把系统包装成泛化 Agent 工具箱。
### 2. 高层能看懂,操作员也能讲清
首页与大屏承担讲故事职责知识库、文件中心、Agent 工作台承担业务可信度职责MCP 与 Skill 页面承担平台能力说明职责。
### 3. 可解释性强于炫技
任何页面都应尽量展示来源、阶段、规则命中、风险等级、人工复核状态,而不是堆叠模型术语。
### 4. 信息密度高但不压抑
整体风格走“专业监管科技工作台”,采用明亮底色、深色信息层、铜橙风险强调、清晰分栏和大面积结构化留白。
## 视觉方向
### 色彩
- 主背景采用浅米白与冷灰蓝混合渐变,强调专业与稳定。
- 主强调色采用深青蓝,承担导航、激活态、关键信息。
- 风险强调采用铜橙与深红,用于高风险、缺失、待处理。
- 成功状态采用低饱和绿色,用于已完成、已入库、规则已生效。
### 排版
- 页级标题使用强对比的中文大标题,突出“阶段感”。
- 模块级标题使用中等字号与清晰副标题。
- 指标数字使用紧凑等宽风格,强调可视化汇报感。
### 组件风格
- 顶部为平台导航与当前批次状态条。
- 页面内部以大面板、信息条、时间线、矩阵卡片、指标舱和结构树为主。
- 尽量避免传统后台里大量相同卡片堆叠,强化带状布局、流程图感和分析板感。
## 页面设计
### 1. 首页 / 任务总览
**目标:**
让评委在 10 秒内理解系统价值和当前批次进展。
**核心内容:**
- 批次概览 Hero当前申报批次、产品名、流程阶段、批次状态。
- 四个关键指标:资料齐套率、法规命中率、字段抽取完成度、高风险项数量。
- 演示主流程带:资料进入、规则配置、解析入库、审核执行、结果输出。
- 今日待办与高风险问题板。
- 快速入口:知识库、文件中心、审核工作台、大屏。
### 2. 知识库搭建与配置页
**目标:**
把“双层知识底座”讲透。
**核心内容:**
- 左侧法规规则树:按章、条、要求项、模板字段展示。
- 中部知识源列表:法规依据、业务资料、模板库、公告附件包。
- 右侧切片与生效配置:切片策略、召回阈值、适用流程、最近更新时间。
- 底部人工校订记录与知识更新入口。
### 3. 文件上传、切片与解析中心
**目标:**
展示 Documents 模块是“资料治理中心”。
**核心内容:**
- 顶部上传拖拽区与批次选择。
- 左侧批量导入队列:文件名、类型、页数、识别状态。
- 中部处理流水:保存原件、提取文本、识别表格、页数统计、章节点归类、切片入库。
- 右侧异常箱:疑似扫描件、归类待确认、页数低可信度、切片失败。
- 下方目录总览表:章节点、资料名称、状态、模板命中情况。
### 4. AI Agent 审核工作台
**目标:**
把自由问答升级为受控审核任务工作台。
**核心内容:**
- 顶部任务切换:目录汇总、完整性检查、字段抽取、一致性核查、综合风险报告。
- 左侧自然语言输入与资料范围选择。
- 中间 Agent 对话流与执行阶段条。
- 右侧结构化结果舱:结论摘要、字段表、缺失项、冲突项、风险建议。
- 下方证据区:法规条款引用、文档片段、来源页码、人工复核提示。
### 5. 外部 MCP 导入页
**目标:**
说明平台可扩展但不喧宾夺主。
**核心内容:**
- MCP 连接卡:法规源、飞书通知、模板服务、文档转换、企业数据源。
- 接入状态、鉴权方式、最近同步时间。
- 输入输出能力摘要。
- 一个简单的导入向导区块。
### 6. Skill 编辑与使用页
**目标:**
展示 Agent 可配置、可维护、可复用。
**核心内容:**
- Skill 列表完整性检查、字段抽取、一致性核查、Word 回填、飞书通知。
- 中部编辑区:角色说明、工具绑定、输入输出约束、启停配置。
- 右侧运行预览:最近测试结果、命中工具、失败原因。
- 底部版本历史和发布状态。
### 7. Agent 工作台大屏
**目标:**
作为演示高潮页,向领导/评委解释“这一轮审核发生了什么”。
**核心内容:**
- 顶部大标题与批次状态。
- 左侧流程总览:资料进入、规则装载、字段池建立、一致性比对、风险汇总。
- 中央主舞台Agent 工作流时间轴,展示每一步的输入、动作、输出和解释。
- 右侧风险热力与关键告警。
- 底部证据矩阵:法规依据、命中文档、责任人、待补动作。
## 跳转与演示动线
推荐演示顺序:
1. 首页说明平台定位。
2. 进入知识库页说明法规与规则如何维护。
3. 进入文件中心说明资料如何入库、切片、归类。
4. 进入审核工作台发起一次完整性或一致性检查。
5. 进入 Skill 页解释为何 Agent 行为可控。
6. 进入 MCP 页说明可接飞书与外部能力。
7. 最后切到大屏汇总整轮审核过程。
## 数据策略
本轮原型以高保真演示为优先,允许使用页面级演示数据,但必须满足:
- 术语与字段名称来自新版需求分析。
- 状态设计贴近真实业务流程。
- 页面间批次名称、产品名称、风险项和规则口径保持一致。
## 实现策略
基于当前 Django 模板工程直接重构前端:
1. 保留现有 Django 应用与路由边界。
2. 新增平台页应用承接知识库、MCP、Skill、大屏页面。
3. 重做 `base.html` 及所有主要模板的布局和样式。
4. 使用共享演示数据构造页面内容,保证视觉统一和讲解一致。
## 测试与验证
原型完成后至少验证:
1. Django 路由可访问。
2. 首页、知识库、文件中心、审核工作台、MCP、Skill、大屏页面均可打开。
3. 页面导航互通。
4. `python manage.py check` 通过。
5. 如无模板语法问题,关键页面可在本地服务下正常渲染。

View File

@@ -4,6 +4,8 @@
把注册申报资料的导入、解包、扫描、目录汇总和章节点识别结果集中展示出来,让用户第一眼就明白本平台的输入对象是“资料包”,不是单篇文档。
本页还需要明确表达:资料包不是孤立文件集合,而是会与一个审核会话绑定,对话标题默认采用解析后的产品名称。
## 2. 适用角色
- 注册资料专员
@@ -69,6 +71,8 @@
表格列建议:
- 产品名称
- 关联会话
- 原始相对路径
- 文件名
- 文件类型
@@ -86,6 +90,8 @@
关键字段:
- `batch_id`
- `product_name`
- `conversation_id`
- `file_count`
- `supported_file_count`
- `failed_file_count`
@@ -112,7 +118,9 @@
- 点击目录树节点,可在右侧高亮对应文件。
- 点击异常项,可筛选下方表格。
- 点击单个文件行,可打开“文档详情抽屉”,展示页数统计方式和章节点识别结果。
- 点击“进入法规完整性检查”,跳转下一页
- 点击资料包标题或“查看对话”,跳转到关联会话
- 页面顶部搜索框支持按产品名称或批次号搜索资料包。
- 点击“在对话中查看完整性节点”,切回 `审核智能体` 中的对应节点结果。
## 7. 与上下游页面的数据关系
@@ -127,6 +135,7 @@
## 8. 演示话术重点
- 强调本平台处理的是整套注册资料,不是单文档聊天。
- 强调资料包会与会话绑定,用户后续是围绕该产品资料持续对话和追溯。
- 强调压缩包、目录层级、页数和章节点是后续审核的事实基础。
- 强调异常箱的价值在于把“资料问题”前置,而不是等到审核后才发现。

View File

@@ -1,120 +0,0 @@
# 审核任务工作台原型设计
## 1. 页面目标
作为整套原型的首页,先把当前批次的审核进度、任务状态、关键风险和操作入口集中展示,让用户在 10 秒内理解平台价值和当前处理位置。
## 2. 适用角色
- 注册申报负责人
- 项目经理
- 复试演示讲解人
## 3. 页面布局分区
页面建议采用“总览头图 + 流程卡片 + 风险与待办双栏”。
分区如下:
- 顶部 Hero批次、产品、流程、总体结论
- 中部七卡任务区
- 下方左侧:风险总览
- 下方右侧:待办动作与推荐下一步
## 4. 核心卡片 / 表格 / 状态区
### 4.1 批次总览 Hero
展示:
- 批次编号
- 产品名称
- 当前流程类型
- 当前审核阶段
- 最高风险等级
- 是否允许正式导出
### 4.2 七个流程任务卡片
固定七张卡片:
1. 资料包导入
2. 目录汇总
3. 法规完整性检查
4. 字段抽取
5. 一致性核查
6. 风险预警
7. Word 回填导出
每张卡片展示:
- 任务名称
- 当前状态
- 关键指标
- 最近执行时间
- 进入页面按钮
### 4.3 风险总览区
展示:
- 高 / 中 / 低风险数量
- 当前阻断原因
- 需要人工复核的字段数
### 4.4 待办动作区
展示:
- 缺失项补充建议
- 字段冲突待确认项
- 推荐下一步操作
## 5. 关键字段定义
建议聚合使用以下对象:
- `registration_overview_report`
- `registration_completeness_report`
- `registration_field_extraction_report`
- `registration_consistency_report`
- `registration_risk_report`
- `registration_word_export_report`
工作台摘要字段:
- `task_name`
- `task_status`
- `last_run_at`
- `summary_label`
- `summary_value`
- `entry_target`
## 6. 关键交互
- 点击任务卡片进入对应页面。
- 点击“查看总体风险”滚动到风险区。
- 点击“打开治理台”查看规则版本、字段 Schema、模板版本。
- 鼠标悬浮卡片显示“本任务输入来自哪里,输出流向哪里”。
## 7. 与上下游页面的数据关系
上游:汇总所有页面结果。
下游:为各页面提供统一入口,不产生独立业务结果。
## 8. 演示话术重点
- 先讲“流程闭环”,再讲某个点上的智能能力。
- 突出这不是自由问答,而是受控的任务执行工作台。
- 强调每个任务卡片都有状态、有输入输出、有结构化结果。
## 9. 与知识库 / 治理台的关联入口
工作台页右上角提供:
- `规则版本总览`
- `模板版本总览`
- `责任人映射总览`
适合作为讲解治理能力的总入口。

View File

@@ -0,0 +1,162 @@
# 审核智能体页原型设计
## 1. 页面目标
作为整套原型的主入口,以 Agent 对话为核心承接资料上传、任务模板触发、节点式审核推进和结构化结果讲解,让用户在 10 秒内理解“这是一个审核智能体,而不是传统报表后台”。
## 2. 适用角色
- 注册申报负责人
- 项目经理
- 复试演示讲解人
## 3. 页面布局分区
页面建议采用“顶部对话上下文 + 三栏工作区”。
分区如下:
- 顶部:产品定位、提示词模板、当前会话摘要
- 左栏:对话历史
- 中栏:对话区 + 节点导航
- 右栏上部:上传栏
- 右栏下部:任务能力卡 / 结构化结果卡
## 4. 核心卡片 / 表格 / 状态区
### 4.1 顶部对话上下文区
展示:
- 批次编号
- 产品名称
- 当前流程类型
- 当前审核阶段
- 当前最高风险等级
- 是否允许正式导出
- 推荐提问模板
### 4.2 左栏对话历史
展示:
- 历史会话标题
- 会话对应产品名称
- 风险状态
- 最近更新时间
- 资料包绑定标记
交互:
- 点击某条会话,切换到对应产品资料的审核上下文。
- 会话标题默认采用解析后的 `product_name`
### 4.3 中栏对话区与节点导航
展示:
- 用户问题
- Agent 执行计划
- 节点结果消息
- 结构化摘要片段
- RAG 命中依据提示
节点条固定包含:
1. 资料包导入
2. 目录汇总
3. 法规完整性检查
4. 字段抽取
5. 一致性核查
6. 风险预警
7. Word 回填导出
8. 飞书通知
交互:
- 点击节点,定位到该阶段的消息和结构化结果。
- 当前执行节点状态实时变化为 `处理中 / 已完成 / 待复核 / 已阻断`
### 4.4 右栏上传区
展示:
- 当前资料包上传入口
- 最近上传文件
- 文件解析状态
- 支持格式说明
### 4.5 右栏任务能力卡
根据当前选中的节点或任务模板,展示不同信息:
- 目录汇总时展示文件数、页数、章节点覆盖
- 完整性检查时展示缺失项、错放项、法规依据摘要
- 字段抽取时展示字段数、低置信度、待复核状态
- 一致性核查时展示冲突字段、混档风险
- 风险预警时展示总风险等级、是否允许导出、整改建议
- Word 导出时展示回填字段、拦截项和导出状态
- 飞书通知时展示 `@` 处理人、通知原因和 Web 详情链接
## 5. 关键字段定义
建议聚合使用以下对象:
- `registration_overview_report`
- `registration_completeness_report`
- `registration_field_extraction_report`
- `registration_consistency_report`
- `registration_risk_report`
- `registration_word_export_report`
页面摘要字段:
- `conversation_id`
- `product_name`
- `batch_id`
- `task_name`
- `task_status`
- `last_run_at`
- `summary_label`
- `summary_value`
- `entry_target`
- `notify_reason`
## 6. 关键交互
- 点击历史会话切换产品审核上下文。
- 点击预设提问模板,自动填充对话输入框。
- 点击节点条跳转到对应执行阶段。
- 上传附件后,右栏任务卡片按 `文档解析中 -> 解析完成 -> 数据处理中 -> 处理完成` 实时更新。
- 点击能力卡查看来源证据、结构化结果或治理入口。
- 点击“打开治理台”查看规则版本、字段 Schema、模板版本和责任人映射。
## 7. 与上下游页面的数据关系
上游:
- 资料包页提供当前 `batch_id``product_name` 和会话绑定关系。
- 知识库页提供法规、模板、字段和飞书配置能力。
下游:
- 本页串联所有审核节点结果,是主执行页面。
- 处理历史页回看本页产生的节点消息、结构化结论和通知记录。
## 8. 演示话术重点
- 先讲“对话驱动的审核闭环”,再讲某个点上的智能能力。
- 突出这不是传统分页后台,而是带任务节点和结构化结果的审核智能体。
- 强调每个节点都有状态、有输入输出、有结构化结果,并且能追溯到知识库依据和资料包。
## 9. 与知识库 / 治理台的关联入口
本页右侧或右上角提供:
- `规则版本总览`
- `模板版本总览`
- `责任人映射总览`
- `飞书配置总览`
适合作为讲解治理能力的总入口。

View File

@@ -4,6 +4,8 @@
对照 NMPA 法规要求,展示当前资料包的齐套性、错放情况、法规依据、风险等级和处理建议,让用户知道“缺了什么、为什么缺、依据是什么”。
在最新版原型中,本页更准确地说是 `审核智能体` 中“法规完整性检查”节点的展开视图,而不是独立执行入口。
## 2. 适用角色
- 注册申报负责人
@@ -93,7 +95,7 @@
- 点击目录树章节,筛选中部问题列表。
- 点击缺失项,右侧法规依据自动切换。
- 点击“查看证据片段”,展开 RAG 命中内容。
- 点击“进入字段抽取”,继续主线。
- 点击“在对话中查看字段抽取节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系

View File

@@ -4,6 +4,8 @@
展示从说明书、申请表、产品列表等资料中抽取出的结构化字段,并把来源、置信度、标准值、待复核状态和是否可回填统一展示出来。
在最新版原型中,本页对应 `审核智能体` 中“字段抽取”节点的细化视图。
## 2. 适用角色
- 注册资料专员
@@ -92,7 +94,7 @@
- 点击字段行打开来源详情。
- 点击“查看原文片段”展开证据区。
- 点击“标记推荐值”模拟人工确认。
- 点击“进入一致性核查”继续主线。
- 点击“在对话中查看一致性核查节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系

View File

@@ -4,6 +4,8 @@
基于统一字段池,对同一审核范围内不同文档的关键字段做完全一致比对,清楚展示冲突字段、来源对比和混档风险。
在最新版原型中,本页对应 `审核智能体` 中“一致性核查”节点的细化视图。
## 2. 适用角色
- 注册资料负责人
@@ -85,7 +87,7 @@
- 点击字段行,在下方切换来源对比视图。
- 点击“仅看冲突字段”过滤表格。
- 点击混档告警可跳到相关文档范围说明。
- 点击“进入风险预警页”继续主线。
- 点击“在对话中查看风险预警节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系

View File

@@ -4,6 +4,8 @@
把完整性、字段抽取和一致性核查的结果统一归并成综合风险清单,给出总风险等级、是否通过、整改建议和责任角色。
在最新版原型中,本页对应 `审核智能体` 中“风险预警”节点的细化视图。
## 2. 适用角色
- 注册申报负责人

View File

@@ -4,6 +4,8 @@
展示字段如何回填到注册申报表格或对照清单中,并说明哪些字段已回填、哪些字段被风险或冲突拦截、当前导出状态如何以及用户从哪里下载文件。
在最新版原型中,本页对应 `审核智能体` 中“Word 回填导出”节点的细化视图。
## 2. 适用角色
- 注册资料专员

View File

@@ -2,7 +2,9 @@
## 1. 页面目标
把风险预警和导出状态转成一张可发送的飞书消息卡片,展示责任人 `@`、消息摘要、Web 详情链接和发送回执,形成工作台外部协同闭环。
把风险预警和导出状态转成一张可发送的飞书消息卡片,展示责任人 `@`、消息摘要、Web 详情链接和发送回执,形成审核智能体之外的协同闭环。
当前 Demo 固定口径为:任务执行完成后直接通知,或在执行异常后直接通知,并 `@` 对应处理人。
## 2. 适用角色
@@ -37,6 +39,7 @@
展示:
- 触发来源
- 通知原因
- 飞书群聊 ID
- 消息类型
- 是否包含责任人通知
@@ -58,6 +61,7 @@
关键字段:
- `send_status`
- `notify_reason`
- `message_type`
- `mentioned_users[]`
- `web_detail_url`
@@ -76,6 +80,7 @@
- 点击“发送通知”切换为已发送状态。
- 点击责任人标签,查看角色映射详情。
- 点击 Web 详情按钮,跳转到 mock 审计详情链接。
- 点击“切换通知原因”,在 `task_completed / task_failed` 两类消息之间切换。
## 7. 与上下游页面的数据关系
@@ -89,7 +94,7 @@
## 8. 演示话术重点
- 强调平台不止有 Web 界面,还有飞书协同入口。
- 强调系统可以把风险结果直接转换成责任人可执行的通知载荷。
- 强调系统可以在任务完成或异常后,直接把结果转换成责任人可执行的通知载荷。
- 强调通知里保留 Web 详情链接,便于追溯。
## 9. 与知识库 / 治理台的关联入口

View File

@@ -4,7 +4,7 @@
作为整套原型的治理能力承载层统一展示法规知识、RAG 证据、字段标准、模板映射、责任人和飞书配置如何被维护,并明确支持完整 CRUD。
本页在 HTML 中不独立占据主导航,而是以“治理台抽屉 / 配置弹窗 / 详情侧栏”的形式从各业务页面打开
在最新版产品形态中,`知识库` 已升级为四个一级入口之一;同时仍允许从审核智能体、资料包和处理历史页通过治理入口快速打开对应对象详情
## 2. 适用角色
@@ -162,7 +162,12 @@
- 映射类型
- 章节 / 风险类型
- 责任角色
- 责任人姓名
- 部门
- 飞书用户 ID
- 飞书 Open ID
- 飞书昵称
- 是否启用通知
- 启用状态
- 备注
@@ -185,6 +190,7 @@
- 配置名称
- 群聊 / 机器人标识
- 消息模板
- 触发原因
- Web 链接模板
- 启用状态
- 最近测试状态
@@ -202,6 +208,7 @@
- 支持查看 interactive card 模板预览。
- 支持查看最近一次回执记录。
- 固定展示两类触发原因:`task_completed``task_failed`
## 11. 与业务页面的入口关系

View File

@@ -19,18 +19,17 @@
## 2. 演示总动线
推荐演示顺序:
最新版原型采用“顶层 4 个 Tab + 对话内节点推进”的组织方式,推荐演示顺序如下
1. 从“审核任务工作台”进入,先看 7 个流程任务卡片和当前批次状态
2. 跳到“资料包导入页”,解释资料包、目录、页数和章节点识别
3. 进入“法规完整性检查页”,展示缺失项、错放项和法规依据
4. 进入“字段抽取与字段池页”,展示统一字段池、置信度和待复核字段
5. 进入“一致性核查页”,展示字段冲突和混档风险
6. 进入“风险预警页”,说明总风险等级、是否通过、整改建议和责任角色
7. 进入“Word 回填导出页”,说明自动回填、导出拦截和下载入口
8. 最后展示“飞书通知视图”,形成协同闭环。
1. 先进入 `审核智能体`,展示左侧会话历史、中间对话区、右侧上传区和任务卡片区
2. 在对话页上传资料包,触发目录汇总、完整性检查、字段抽取、一致性核查和风险结论节点
3. 点击对话节点,解释每一步的结构化结果如何在右侧卡片实时更新状态
4. 切换到 `资料包`,展示资料包列表、按产品名称搜索、资料包与会话绑定关系,以及目录、页数、章节点结果
5. 切换到 `知识库`说明法规资料、业务资料、RAG 切片、字段 Schema、模板映射、责任人映射和飞书配置如何治理
6. 切换到 `处理历史`,说明如何按批次、产品和风险状态回看历史会话、资料规模和通知留痕
7. 返回对话中的风险与导出节点,最后展示飞书完成通知或异常通知如何直接 `@` 处理人并携带 Web 详情链接
知识库与治理台不作为独立主线页面插入,而是作为上述页面的配置入口、侧边抽屉或管理弹层出现,用来回答“规则从哪里来、谁来维护、如何增删改查”
法规依据不再以单独一级页面存在,而是作为知识库中的法规资料,由 RAG 在对话节点中返回依据说明
## 3. 统一视觉风格
@@ -44,8 +43,8 @@
### 3.2 色彩建议
- 主背景:浅灰蓝与米白渐变,形成轻量但不空泛的工作台底色
- 主强调色:深青蓝,用于导航、主按钮、激活态和重点数值。
- 主背景:以白色为主,辅以极浅灰蓝,保证整体简洁和专业
- 主强调色:克制的深青蓝,用于导航、主按钮、激活态和重点数值。
- 风险色:铜橙、深红,用于缺失、冲突、高风险、阻断状态。
- 成功色:低饱和绿色,用于完成、可通过、已同步状态。
- 中性色:冷灰,用于说明文字、边框、标签和禁用态。
@@ -60,16 +59,19 @@
### 4.1 全局导航
- 顶部固定展示:平台名称、当前批次、产品名称、流程类型、全局风险状态
- 左侧流程导航固定展示 8 个主页面入口
- 每个页面右上角保留“打开治理台”入口
- 顶部固定展示:平台名称、顶层 Tab、右上角用户信息
- 顶层 Tab 固定为:`审核智能体 / 资料包 / 知识库 / 处理历史`
- `审核智能体` 内部采用三栏布局,不再使用左侧 8 页面流程导航
- 每个页面保留“打开治理台”或同等治理入口。
### 4.2 统一交互规则
- 所有结果页都支持“查看来源证据”
- 对话是主执行入口,页面切换是不同信息视角,而不是不同执行系统
- 所有结果区都支持“查看来源证据”。
- 所有关键对象都支持“打开详情抽屉”。
- 所有治理对象都遵循“列表 -> 详情 -> 新增 / 编辑 / 启停 / 删除”的统一 CRUD 结构。
- 风险阻断项必须在 Word 导出和飞书通知视图里续可见,保证前后呼应。
- 风险阻断项必须在对话节点、Word 导出和飞书通知视图里续可见,保证前后呼应。
- 资料包必须可跳转到关联会话,会话标题默认采用解析后的 `product_name`
### 4.3 状态规范
@@ -99,6 +101,7 @@
- `batch_id`: `SUB-20260603-001`
- `workflow_type`: `registration`
- `product_name`: `新型冠状病毒 2019-nCoV 核酸检测试剂盒`
- `conversation_id`: `conv-001`
- `applicant_name`: `示例生物科技(上海)有限公司`
- `chapter_scope`: `CH1 ~ CH6`
@@ -111,7 +114,7 @@
3. 说明书与申请表中的产品名称存在文本不一致。
4. 储存条件字段存在待人工复核状态。
5. 风险等级为高,当前批次不允许正式导出,仅允许生成草稿。
6. 飞书通知需要 `@注册资料负责人``@注册申报负责人`
6. 飞书通知在任务完成或出现异常后直接 `@注册资料负责人``@注册申报负责人`
### 5.3 共用对象定义
@@ -137,17 +140,23 @@
## 6. 页面跳转关系
主页面跳转关系如下
主页面关系调整为
```text
审核任务工作台
-> 资料包导入页
-> 法规完整性检查页
-> 字段抽取与字段池页
-> 一致性核查页
-> 风险预警页
-> Word 回填导出页
-> 飞书通知视图
顶部导航
-> 审核智能体
-> 资料包
-> 知识库
-> 处理历史
审核智能体内部节点
-> 资料包导入
-> 法规完整性检查
-> 字段抽取与字段池
-> 一致性核查
-> 风险预警
-> Word 回填导出
-> 飞书通知
```
跨页关系约束:
@@ -173,16 +182,19 @@
### 7.2 HTML 结构要求
- 一个全局 App Shell
- 八个主页面 section
- 四个顶层页面 section
- 一个以对话节点为核心的审核智能体 section
- 一个治理台抽屉层
- 一份统一 mock 数据对象
- 一组轻量 JavaScript 交互
### 7.3 必备交互
- 切换 8 个页面视图
- 切换 `审核智能体 / 资料包 / 知识库 / 处理历史` 视图
- 切换会话历史
- 点击对话节点定位到对应执行阶段
- 展开 / 收起目录树
- 切换流程任务卡片选中态
- 资料包按产品名称或批次号搜索
- 打开治理台抽屉
- 切换治理对象 CRUD 子视图
- 模拟 Word 导出状态切换
@@ -193,7 +205,7 @@
本轮分页文档如下:
1. [1.1.资料包导入页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.1.资料包导入页原型设计.md)
2. [1.2.审核任务工作台原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.2.审核任务工作台原型设计.md)
2. [1.2.审核智能体页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.2.审核智能体页原型设计.md)
3. [1.3.法规完整性检查页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.3.法规完整性检查页原型设计.md)
4. [1.4.字段抽取与字段池页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.4.字段抽取与字段池页原型设计.md)
5. [1.5.一致性核查页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.5.一致性核查页原型设计.md)
@@ -225,7 +237,12 @@
这套原型的核心讲法应统一为:
```text
资料包治理 -> 法规完整性核查 -> 字段池沉淀 -> 一致性检查 -> 风险预警 -> Word 回填导出 -> 飞书协同闭环
以对话为核心的审核智能体
-> 资料包解析与会话绑定
-> 节点式审核推进
-> 知识库 / RAG 解释
-> 风险与导出决策
-> 飞书协同闭环
```
治理台负责回答“规则和知识如何维护”,工作台负责回答“这一批资料现在审核到了哪里、为什么这样判断、下一步谁来处理”。
治理台负责回答“规则和知识如何维护”,审核智能体负责回答“这一批资料现在审核到了哪里、为什么这样判断、下一步谁来处理”。

View File

@@ -2,599 +2,149 @@
## 1. 设计目标
本步骤是注册申报资料审核工作流的入口,目标是把用户上传的注册申报资料包转化为可供后续法规完整性核查、字段抽取、一致性检查和风险预警使用的结构化文档底座。
本步骤对应最新版原型中的 `资料包` 页面与 `审核智能体` 上传入口,目标是把用户导入的注册申报资料转为:
本步骤需要完成以下业务结果:
1. 可绑定会话的资料包对象
2. 可展示目录和页数的结构化结果
3. 可进入后续审核链路的文档底座
1. 支持单文件、多文件、文件夹和压缩包导入。
2. 支持 `zip``rar``7z` 压缩包解包,并保留压缩包内原始相对路径。
3. 为每个文件建立注册资料记录,包含文件名、类型、大小、原始路径、所属批次和处理状态。
4. 统计文件页数,`PDF``DOCX` 必须精确统计,`DOC` 无法精确统计时标记为待人工复核。
5. 初步识别章节点、资料名称、资料类别和目录类文档。
6. 生成资料目录汇总结果,供页面展示和 Agent Core 后续任务使用。
本步骤不负责最终法规完整性判定,不负责字段抽取,不负责 RAG 入库。但它需要产出足够稳定的文档主数据,作为后续所有审核任务的事实输入。
## 2. 所属模块与边界
### 2.1 Django Documents
`apps.documents` 负责接收上传请求、保存原始文件、创建资料包批次、维护文档记录和展示目录汇总。
本步骤中 Django 侧建议提供:
1. 上传页和资料包导入入口。
2. 资料包批次模型。
3. 文档主数据模型。
4. 文档处理状态字段。
5. 目录汇总服务。
6. 目录汇总页面。
### 2.2 Agent Core
`agent_core` 负责沉淀可复用的资料包处理 Skill 和 Tool Registry 注册项。
本步骤中 Agent Core 建议提供:
1. `资料包导入Skill`
2. `压缩包解包Skill`
3. `资料包扫描Skill`
4. `文档页数统计Skill`
5. `章节点识别Skill`
6. `目录汇总Skill`
这些 Skill 不直接依赖 Django View。Django View 只负责收集参数并调用服务层,服务层再调用 Agent Core 的 Skill 或 Tool。
### 2.3 Audit
`apps.audit` 在本步骤中只记录资料包导入过程,不做审核结论留痕。
建议记录:
1. 导入入口。
2. 导入文件数量。
3. 解包结果。
4. 页数统计结果。
5. 失败文件数量。
6. 待人工复核文件数量。
## 3. 输入输出
### 3.1 输入
用户可以通过以下方式导入资料:
1. 单文件上传。
2. 多文件批量上传。
3. 文件夹上传,前端传递每个文件的相对路径。
4. 压缩包上传,后端解压后还原相对路径。
支持文件类型:
1. `pdf`
2. `docx`
3. `doc`
4. `txt`
5. `md`
6. `zip`
7. `rar`
8. `7z`
### 3.2 输出
本步骤输出 `registration_overview_report` 的第一版结构,核心字段如下:
```json
{
"batch_id": "SUB-20260603-001",
"workflow_type": "registration",
"source_role": "submission",
"file_count": 8,
"supported_file_count": 7,
"failed_file_count": 1,
"total_page_count": 36,
"page_count_status": "partial_review_required",
"chapter_summary": [
{
"chapter_code": "CH1.2",
"chapter_name": "监管信息目录",
"file_count": 1,
"page_count": 3
}
],
"documents": [],
"warnings": []
}
```
每个 `documents` 元素至少包含:
```json
{
"document_id": 1001,
"original_filename": "CH1.4 申请表.docx",
"relative_path": "第1章 监管信息/CH1.4 申请表.docx",
"file_type": "docx",
"file_size": 1048576,
"chapter_code": "CH1.4",
"chapter_name": "申请表",
"document_role": "application_form",
"page_count": 5,
"page_count_method": "docx_exact",
"page_count_confidence": "exact",
"processing_status": "summarized",
"review_status": "normal",
"needs_manual_review": false
}
```
## 4. 主工作流
```text
用户选择导入资料
-> 创建资料包批次
-> 校验上传文件
-> 判断是否压缩包
-> 解包或登记原始文件
-> 扫描文件树
-> 过滤不支持文件
-> 创建文档主数据
-> 统计页数
-> 初步识别章节点
-> 识别目录类/声明类/沟通类文件
-> 汇总目录与页数
-> 写入导入审计
-> 返回目录汇总页面
```
## 5. 节点详细设计
### 5.1 节点一:创建资料包批次
业务功能:
1. 为一次导入创建独立批次。
2. 记录导入来源、导入人、任务类型和资料来源角色。
3. 建立隔离存储目录,避免不同项目或不同批次文件混在一起。
建议模型:
```python
class SubmissionBatch(models.Model):
batch_no = models.CharField(max_length=64, unique=True)
name = models.CharField(max_length=255)
workflow_type = models.CharField(max_length=32, default="registration")
source_role = models.CharField(max_length=32, default="submission")
import_status = models.CharField(max_length=32, default="created")
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
created_at = models.DateTimeField(auto_now_add=True)
```
使用技术:
1. Django ORM
2. Django Storage
3. `uuid` 或业务编号生成器
产生方法:
1. `create_submission_batch(params) -> SubmissionBatch`
2. `build_batch_storage_path(batch) -> Path`
对应 Skill
1. `资料包导入Skill`
### 5.2 节点二:上传文件校验
业务功能:
1. 校验文件扩展名是否支持。
2. 校验文件大小是否超过配置限制。
3. 校验文件名是否包含危险路径。
4. 识别普通文件和压缩包。
使用技术:
1. Django uploaded file API
2. `pathlib.PurePath`
3. `python-magic``mimetypes`
校验规则:
1. 禁止绝对路径。
2. 禁止 `..` 路径穿越。
3. 禁止空文件。
4. 允许中文文件名。
5. 对扩展名和 MIME 不一致的文件标记为待复核。
产生方法:
1. `validate_uploaded_file(uploaded_file) -> FileValidationResult`
2. `detect_upload_kind(uploaded_file) -> Literal["document", "archive"]`
对应 Skill
1. `资料包导入Skill`
### 5.3 节点三:压缩包解包
业务功能:
1. 支持 `zip``rar``7z`
2. 解包到批次隔离目录。
3. 保留压缩包内多层相对路径。
4. 记录每个解包文件来自哪个压缩包。
5. 对空包、损坏包、加密包、嵌套异常给出业务状态。
使用技术:
1. `zipfile`
2. `rarfile` 或纯 Python rar 解析依赖
3. `py7zr`
4. `pathlib`
5. 文件路径安全检查函数
关键状态:
1. `extracted`
2. `empty_archive`
3. `encrypted_archive`
4. `unsupported_archive`
5. `extract_failed`
6. `path_rejected`
产生方法:
1. `extract_archive(archive_path, target_dir) -> ArchiveExtractionResult`
2. `safe_extract_member(member_name, target_dir) -> Path`
3. `normalize_relative_path(raw_path) -> str`
对应 Skill
1. `压缩包解包Skill`
### 5.4 节点四:扫描文件树
业务功能:
1. 遍历本次批次目录。
2. 过滤临时文件、系统隐藏文件和不支持文件。
3. 生成待处理文件清单。
4. 保留相对于资料包根目录的路径。
使用技术:
1. `pathlib.Path.rglob`
2. 文件哈希 `hashlib.sha256`
3. 后缀白名单
过滤规则:
1. 跳过 `__MACOSX`
2. 跳过 `.DS_Store`
3. 跳过 Office 临时文件,如 `~$申请表.docx`
4.`.exe` 等不支持文件记录为 `unsupported`,不进入后续页数统计。
产生方法:
1. `scan_submission_package(root_dir) -> PackageScanResult`
2. `build_file_fingerprint(file_path) -> str`
3. `is_supported_document(file_path) -> bool`
对应 Skill
1. `资料包扫描Skill`
### 5.5 节点五:创建文档主数据
业务功能:
1. 为每个文件创建文档记录。
2. 写入文件名、相对路径、文件类型、大小、哈希、来源压缩包。
3. 标记初始处理状态。
4. 区分业务申报资料和法规依据资料。
建议模型:
```python
class RegistrationDocument(models.Model):
batch = models.ForeignKey(SubmissionBatch, on_delete=models.CASCADE)
original_filename = models.CharField(max_length=255)
relative_path = models.CharField(max_length=1024)
file_type = models.CharField(max_length=32)
file_size = models.PositiveBigIntegerField(default=0)
file_hash = models.CharField(max_length=128, blank=True)
source_archive_name = models.CharField(max_length=255, blank=True)
source_role = models.CharField(max_length=32, default="submission")
workflow_type = models.CharField(max_length=32, default="registration")
processing_status = models.CharField(max_length=32, default="created")
```
使用技术:
1. Django ORM
2. 批量创建 `bulk_create`
3. 唯一性约束:`batch + relative_path`
产生方法:
1. `create_document_records(batch, scanned_files) -> list[RegistrationDocument]`
2. `mark_unsupported_file(batch, scanned_file, reason) -> RegistrationDocument`
对应 Skill
1. `资料包导入Skill`
2. `资料包扫描Skill`
### 5.6 节点六:页数统计
业务功能:
1. 为每份文档统计页数。
2. 写入页数统计方法和可信度。
3. 对无法精确统计的文档标记待人工复核。
使用技术:
1. PDF`pypdf``PyMuPDF`
2. DOCX优先读取 Word 兼容统计结果,必要时通过 LibreOffice headless 转 PDF 后精确统计
3. DOC尝试转换或解析失败时标记 `manual_review_required`
4. TXT/MD页数不作为强制指标可按 `not_applicable` 或导出预览后统计
页数状态:
1. `exact`
2. `not_applicable`
3. `manual_review_required`
4. `failed`
产生方法:
1. `count_document_pages(document) -> PageCountResult`
2. `count_pdf_pages(path) -> PageCountResult`
3. `count_docx_pages(path) -> PageCountResult`
4. `count_doc_pages(path) -> PageCountResult`
对应 Skill
1. `文档页数统计Skill`
### 5.7 节点七:章节点与资料名称识别
业务功能:
1. 从文件名、相对路径和标题文本中识别章节点。
2. 识别资料名称。
3. 标记目录类、声明类、申请表、产品列表、历史沟通说明等文档角色。
4. 对无法确认的文件标记待人工确认。
使用技术:
1. 正则表达式
2. 本地章节点规则 YAML
3. `python-docx` 抽取首页标题
4. 简单关键词规则
识别优先级:
1. 相对路径中的章名,如 `第1章 监管信息`
2. 文件名中的章节点编码,如 `CH1.4`
3. 文件名中的标准资料名称,如 `申请表`
4. 文档首页标题
5. 用户手动选择的章节点
产生方法:
1. `classify_chapter_node(document) -> ChapterClassificationResult`
2. `extract_chapter_code_from_path(relative_path) -> str | None`
3. `detect_document_role(filename, title_text) -> str`
对应 Skill
1. `章节点识别Skill`
### 5.8 节点八:目录汇总生成
业务功能:
1. 汇总当前批次所有文档。
2. 计算文件数量、支持文件数量、失败数量、总页数。
3. 按章节点聚合文件和页数。
4. 输出待人工复核清单。
5. 生成页面展示和 Agent Core 输入使用的统一结构。
使用技术:
1. Django ORM 聚合查询
2. Python dataclass 或 Pydantic schema
3. JSONField 存储结构化结果
产生方法:
1. `build_directory_summary(batch_id) -> RegistrationOverviewReport`
2. `summarize_chapters(documents) -> list[ChapterSummary]`
3. `collect_import_warnings(documents) -> list[ImportWarning]`
对应 Skill
1. `目录汇总Skill`
### 5.9 节点九:审计留痕
业务功能:
1. 记录资料包导入执行过程。
2. 记录失败文件和待人工复核文件。
3. 为后续完整性核查提供可追溯基础。
使用技术:
1. `apps.audit` 服务层
2. JSONField
3. 敏感信息脱敏函数
产生方法:
1. `record_import_audit(batch, overview_report) -> AuditLog`
2. `sanitize_import_context(context) -> dict`
对应 Skill
1. 本步骤不单独产生 Audit Skill由 Django 服务层调用 Audit 服务完成。
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [资料包导入Skill](skill/资料包导入Skill.md)
2. [压缩包解包Skill](skill/压缩包解包Skill.md)
3. [资料包扫描Skill](skill/资料包扫描Skill.md)
4. [文档页数统计Skill](skill/文档页数统计Skill.md)
5. [章节点识别Skill](skill/章节点识别Skill.md)
6. [目录汇总Skill](skill/目录汇总Skill.md)
## 7. 数据状态设计
### 7.1 批次状态
| 状态 | 含义 |
|---|---|
| `created` | 已创建批次,尚未完成文件登记 |
| `importing` | 正在导入或解包 |
| `summarizing` | 正在统计页数和目录 |
| `completed` | 导入与目录汇总完成 |
| `partial_completed` | 部分文件失败或需复核 |
| `failed` | 整体导入失败 |
### 7.2 文档状态
| 状态 | 含义 |
|---|---|
| `created` | 已创建记录 |
| `unsupported` | 文件类型不支持 |
| `page_counted` | 已完成页数统计 |
| `classified` | 已完成章节点识别 |
| `summarized` | 已进入目录汇总 |
| `manual_review_required` | 需要人工复核 |
| `failed` | 处理失败 |
### 7.3 页数可信度
| 状态 | 含义 |
|---|---|
| `exact` | 精确页数 |
| `not_applicable` | 不适用 |
| `manual_review_required` | 无法精确统计,需人工复核 |
| `failed` | 统计失败 |
## 8. 异常处理
本步骤需要业务化处理以下异常:
1. 上传空文件:拒绝导入,提示文件为空。
2. 文件名路径穿越:拒绝该文件,记录安全拦截。
3. 压缩包损坏:批次标记为 `failed``partial_completed`
4. 压缩包加密:标记为 `manual_review_required`
5. 解包后无支持文件:批次标记为 `partial_completed`,页面提示未发现可处理资料。
6. DOC 页数无法精确统计:文档标记待人工复核,不阻断目录汇总。
7. DOCX 页数无法精确统计:文档标记失败或待复核,并在汇总结果中突出提示。
8. 文件内容与扩展名不一致:记录风险提示,仍可尝试解析。
9. 同名相对路径重复:保留第一份,后续文件加版本后缀或标记重复。
10. 混入不支持文件:记录为 `unsupported`,不进入页数统计。
## 9. 页面展示
资料包导入完成后Documents 页面建议展示:
1. 批次名称和导入时间。
2. 导入文件总数。
3. 支持文件数量。
4. 页数合计。
5. 待人工复核数量。
6. 解包失败或不支持文件数量。
7. 按章节点聚合的目录表。
8. 文件明细表。
文件明细表字段:
1. 原始相对路径。
2. 文件名。
3. 文件类型。
4. 页数。
5. 页数可信度。
6. 章节点。
7. 资料名称。
8. 处理状态。
9. 是否需人工复核。
## 10. 与后续步骤的接口
后续法规完整性核查需要读取:
本步骤执行完成后,系统应至少产出:
1. `batch_id`
2. `workflow_type`
3. `source_role`
4. `documents[].document_id`
5. `documents[].relative_path`
6. `documents[].chapter_code`
7. `documents[].document_role`
8. `documents[].page_count`
9. `documents[].processing_status`
10. `documents[].needs_manual_review`
2. `product_name`
3. `conversation_id`
4. `registration_overview_report`
后续 RAG 入库需要读取:
## 2. 页面与模块对应
1. 文件路径。
2. 文件类型。
3. 章节点。
4. 资料名称。
5. 业务资料或法规资料角色。
6. 文档处理状态。
### 2.1 `资料包` 页面
## 11. 测试设计
用于展示:
### 11.1 单元测试
1. 资料包列表
2. 产品名称搜索框
3. 资料包与会话关联关系
4. 文件目录、页数、章节点和异常
1. 上传文件校验。
2. 路径安全校验。
3. 压缩包解包。
4. 文件扫描过滤。
5. 页数统计。
6. 章节点识别。
7. 目录汇总聚合。
### 2.2 `审核智能体` 页面
### 11.2 服务层测试
用于触发:
1. 单文件导入生成文档记录。
2. 多文件导入生成同一批次。
3. 压缩包导入保留相对路径。
4. 解包失败时批次状态正确。
5. DOC 无法统计页数时标记待复核。
6. 目录汇总结果包含文件数量、页数、章节点和警告。
1. 上传资料包
2. 自动创建或绑定会话
3. 在对话中插入“目录汇总”节点结果
### 11.3 页面测试
### 2.3 `apps.documents`
1. 上传成功后跳转到目录汇总页。
2. 页面展示页数可信度。
3. 页面展示待人工复核提示。
4. 不支持文件有清晰提示。
负责:
## 12. V1 实现建议
1. 资料包模型
2. 文档模型
3. 页数统计
4. 章节点识别
5. 目录汇总服务
V1 建议先完成以下最小闭环:
## 3. 核心数据结构
1. 支持单文件和多文件上传。
2. 支持 `zip` 解包,并预留 `rar``7z` 依赖接入点。
3. 支持 `PDF``DOCX` 页数统计。
4. `DOC` 文件先标记待人工复核。
5. 基于文件名和路径识别 `CH1.*` 章节点。
6. 生成目录汇总页面和结构化 `registration_overview_report`
### 3.1 SubmissionBatch
在 V1 后续增强中补齐
建议包含
1. `rar``7z` 纯 Python 解包。
2. DOC 转换后精确统计。
3. 目录类文档内容解析。
4. 文件夹上传前端体验。
5. 后台人工修正章节点和资料名称。
1. `batch_no`
2. `product_name`
3. `workflow_type`
4. `conversation_id`
5. `file_count`
6. `page_count`
7. `import_status`
8. `exception_count`
### 3.2 UploadedDocument
建议增加:
1. `batch_id`
2. `relative_path`
3. `chapter_code`
4. `document_role`
5. `page_count`
6. `page_count_confidence`
7. `chapter_match_status`
8. `needs_manual_review`
## 4. 主流程
```text
上传资料包
-> 创建批次
-> 保存原始文件
-> 解包 / 扫描目录
-> 统计页数
-> 识别产品名称
-> 识别章节点
-> 生成目录汇总
-> 创建或绑定会话
-> 返回资料包页与对话节点
```
## 5. 关键节点设计
### 5.1 产品名称解析
优先来源:
1. 申请表
2. 目标产品说明书
3. 产品列表
结果用途:
1. 作为资料包主标题
2. 作为会话标题
3. 作为资料包搜索主字段
### 5.2 资料包与会话绑定
规则固定为:
1. 新导入资料包默认生成一个主会话
2. 主会话标题使用解析后的 `product_name`
3. 资料包页“查看对话”跳转到 `conversation_id`
### 5.3 目录汇总输出
输出对象 `registration_overview_report` 至少包含:
1. `batch_id`
2. `product_name`
3. `file_count`
4. `total_page_count`
5. `chapter_summary`
6. `documents`
7. `warnings`
## 6. 异常策略
以下情况标记为待复核:
1. DOC 页数无法精确统计
2. 章节点无法确定
3. 产品名称来源冲突
4. 目录路径疑似错放
## 7. 与后续步骤的接口
本步骤向后续步骤提供:
1. `batch_id`
2. `conversation_id`
3. `product_name`
4. `document_scope`
5. `chapter_summary`
## 8. 验收标准
1. 资料包导入后形成批次记录。
2. 会话标题使用解析出的产品名称。
3. 资料包页支持按产品名称搜索。
4. 目录汇总结果可直接进入 Agent 节点展示。

View File

@@ -2,609 +2,65 @@
## 1. 设计目标
本步骤承接“资料包导入与目录汇总”的输出,目标是对照 NMPA 体外诊断试剂注册申报资料要求,检查当前资料包是否齐套、章节点是否匹配、资料是否错放、是否存在需要人工复核的完整性问题
本步骤对应 `审核智能体` 中的“完整性检查”节点,目标是基于结构化规则和法规 RAG 证据判断资料包是否齐套、是否错放、是否存在高风险缺失项
本步骤需要完成以下业务结果:
1. 识别当前审核任务适用的法规流程V1 默认使用 `registration` 注册申报流程。
2. 装载本地法规规则包,默认以公告附件包为主规则来源。
3. 将资料包目录结果与法规目录模板进行匹配。
4. 区分已提供、缺失、疑似提供、错放、待人工复核等状态。
5. 对缺失、错放和不确定项生成完整性风险等级和处理建议。
6. 通过 RAG 检索法规原文证据,但不把 RAG 命中结果作为最终裁判。
7. 输出结构化 `registration_completeness_report`,供 Chat 页面、Audit 和后续风险预警使用。
本步骤不负责产品字段抽取,不负责跨文档字段一致性检查,不负责最终综合风险报告。它只产出法规完整性维度的结论和证据。
## 2. 所属模块与边界
### 2.1 Scenarios
`apps.scenarios` 负责定义“法规完整性核查”任务入口。
建议配置项:
1. `scenario_id`: `registration_completeness_check`
2. `workflow_type`: `registration`
3. `required_input`: `registration_overview_report`
4. `rule_package`: `nmpa_ivd_registration_v1`
5. `enable_rag_evidence`: `true`
6. `output_schema`: `registration_completeness_report`
### 2.2 Documents
`apps.documents` 提供第一步生成的文档主数据和目录汇总结果。
本步骤读取:
1. 批次信息。
2. 文件相对路径。
3. 文件名。
4. 文件类型。
5. 页数。
6. 章节点。
7. 文档角色。
8. 处理状态。
9. 待人工复核标记。
### 2.3 Agent Core
`agent_core` 是本步骤的执行主体,负责编排规则包加载、流程识别、规则匹配、完整性判定、法规证据检索和报告生成。
本步骤建议产生以下中文 Skill
1. `法规完整性检查Skill`
2. `法规规则包加载Skill`
3. `法规流程识别Skill`
4. `资料要求匹配Skill`
5. `缺失错放判定Skill`
6. `法规证据检索Skill`
7. `完整性报告生成Skill`
### 2.4 RAG
RAG 只负责证据定位和解释引用。
RAG 可以回答:
1. 当前缺失项对应公告附件中的哪段要求。
2. 当前章节点属于哪类资料要求。
3. 当前格式要求来源于哪份附件。
RAG 不负责决定:
1. 某资料是否必交。
2. 当前资料是否合规。
3. 缺失项是否高风险。
这些结论必须来自结构化规则。
### 2.5 Audit
`apps.audit` 记录本次完整性检查的输入、规则版本、匹配结果、缺失项、证据和错误。
审计中必须保留:
## 2. 输入
1. `batch_id`
2. `scenario_id`
3. `workflow_type`
4. `rule_package_id`
5. `rule_version`
6. `selected_document_ids`
7. `matched_items`
8. `missing_items`
9. `misplaced_items`
10. `manual_review_items`
11. `evidence_refs`
2. `conversation_id`
3. `product_name`
4. `chapter_summary`
5. `document_scope`
6. 规则包与法规知识索引
## 3. 输入输出
## 3. 规则与 RAG 分工
### 3.1 输入
### 3.1 规则负责
```json
{
"batch_id": 1001,
"scenario_id": "registration_completeness_check",
"workflow_type": "registration",
"rule_package_id": "nmpa_ivd_registration_v1",
"chapter_scope": ["CH1"],
"selected_document_ids": [1, 2, 3],
"enable_rag_evidence": true
}
```
1. 必交项判断
2. 章节点匹配
3. 缺失/错放分类
4. 风险等级映射
其中 `chapter_scope` 可为空。为空时默认检查当前批次已识别章节点及 V1 默认范围;演示首版建议优先检查 `CH1`,同时保留全六章规则结构。
### 3.2 RAG 负责
### 3.2 输出
1. 定位法规依据片段
2. 提供条款解释
3. 为对话输出引用依据
本步骤输出 `registration_completeness_report`
```json
{
"report_type": "registration_completeness_report",
"batch_id": 1001,
"workflow_type": "registration",
"rule_package_id": "nmpa_ivd_registration_v1",
"rule_version": "2026-06-03",
"chapter_scope": ["CH1"],
"summary": {
"required_item_count": 8,
"provided_item_count": 6,
"missing_item_count": 1,
"suspected_item_count": 1,
"misplaced_item_count": 0,
"manual_review_item_count": 1,
"highest_risk_level": "high",
"pass_status": "failed"
},
"matched_items": [],
"missing_items": [],
"misplaced_items": [],
"manual_review_items": [],
"evidence_refs": [],
"suggestions": []
}
```
### 3.3 结果状态
| 状态 | 含义 |
|---|---|
| `provided` | 已提供,命中文档和章节点 |
| `missing` | 必交资料未找到 |
| `suspected` | 疑似提供,但命名、章节点或文档角色不确定 |
| `misplaced` | 文件存在,但章节点或路径位置不匹配 |
| `manual_review_required` | 规则无法直接判断,需人工复核 |
| `not_applicable` | 当前流程或适用条件下不要求 |
## 4. 主工作流
## 4. 主流程
```text
用户发起法规完整性检查
-> 读取资料包目录汇总结果
-> 确认法规流程类型
-> 装载法规规则包
-> 展开章节和必交项规则
-> 将实际文档与规则项匹配
-> 判定已提供/缺失/疑似/错放/待复核
-> 映射风险等级与处理建议
-> 检索法规原文证据
-> 生成完整性检查报告
-> 写入审计留痕
-> 返回 Web/飞书可展示结果
读取资料包目录
-> 加载完整性规则包
-> 比对章节点与资料要求
-> 输出缺失项 / 错放项 / 待复核项
-> 通过 RAG 补充法规依据
-> 生成完整性节点结果
```
## 5. 节点详细设计
## 5. 输出对象
### 5.1 节点一:读取目录汇总结果
`registration_completeness_report` 至少包含:
业务功能:
1. `missing_items`
2. `misplaced_items`
3. `manual_review_items`
4. `risk_level`
5. `rule_hits`
6. `rag_evidence`
1. 根据 `batch_id` 读取第一步产出的 `registration_overview_report`
2. 校验资料包是否完成目录汇总。
3. 过滤当前检查范围内的文档。
4. 收集待人工复核文档,作为完整性检查的不确定性输入。
## 6. 对话节点要求
使用技术
完整性检查节点在会话中应展示
1. Django ORM
2. JSONField
3. Pydantic 或 dataclass schema 校验
1. 缺失项摘要
2. 错放项摘要
3. 风险等级
4. 法规依据来源
产生方法:
1. `load_overview_report(batch_id) -> RegistrationOverviewReport`
2. `select_documents_for_completeness(report, chapter_scope, selected_document_ids) -> list[DocumentFact]`
3. `collect_document_uncertainties(documents) -> list[DocumentUncertainty]`
对应 Skill
1. `法规完整性检查Skill`
### 5.2 节点二:法规流程识别
业务功能:
1. 确认当前任务属于注册申报、变更备案、变更注册还是延续注册。
2. V1 默认使用 `registration`
3. 如果用户选择了不支持流程,返回业务化提示。
4. 防止把变更或延续规则误用于首次注册申报。
使用技术:
1. 场景配置 YAML
2. 批次字段 `workflow_type`
3. 规则包元数据
产生方法:
1. `resolve_workflow_type(batch, scenario_config, user_input) -> WorkflowTypeResult`
2. `validate_workflow_supported(workflow_type, rule_package) -> bool`
对应 Skill
1. `法规流程识别Skill`
### 5.3 节点三:法规规则包加载
业务功能:
1. 加载本地结构化法规规则文件。
2. 校验规则版本、适用流程和章节结构。
3. 按“章 -> 条 -> 要求项 -> 模板字段”四级结构展开规则。
4. 区分资料要求层、结构目录层和格式模板层。
使用技术:
1. YAML 规则文件
2. Pydantic schema
3. Django cache 或内存缓存
4. 规则版本号
建议规则目录:
```text
configs/registration/rules/
nmpa_ivd_registration_v1.yaml
chapter_catalog.yaml
risk_mapping.yaml
```
规则项示例:
```yaml
rule_package_id: nmpa_ivd_registration_v1
workflow_type: registration
version: "2026-06-03"
chapters:
- chapter_code: CH1
chapter_name: 监管信息
requirements:
- requirement_id: CH1.4
requirement_name: 申请表
required: true
expected_document_roles:
- application_form
risk_level_if_missing: high
evidence_query: "体外诊断试剂 注册申报 申请表 监管信息"
```
产生方法:
1. `load_rule_package(rule_package_id) -> RulePackage`
2. `validate_rule_package(rule_package) -> RulePackageValidationResult`
3. `expand_requirement_items(rule_package, workflow_type, chapter_scope) -> list[RequirementItem]`
对应 Skill
1. `法规规则包加载Skill`
### 5.4 节点四:资料要求匹配
业务功能:
1. 将当前资料包文档与法规要求项进行匹配。
2. 优先使用章节点编码匹配。
3. 其次使用文档角色匹配。
4. 再使用文件名和资料名称关键词匹配。
5. 保留匹配证据和匹配置信度。
匹配优先级:
1. `document.chapter_code == requirement.requirement_id`
2. `document.document_role in requirement.expected_document_roles`
3. 文件名命中规则关键词。
4. 相对路径命中章节名称。
5. 人工修正字段命中。
使用技术:
1. Python 规则匹配
2. 字符串标准化
3. 简单中文关键词匹配
4. 可选 rapidfuzz 模糊匹配V1 谨慎使用
产生方法:
1. `match_documents_to_requirements(documents, requirements) -> RequirementMatchSet`
2. `match_by_chapter_code(document, requirement) -> MatchEvidence | None`
3. `match_by_document_role(document, requirement) -> MatchEvidence | None`
4. `match_by_keywords(document, requirement) -> MatchEvidence | None`
5. `calculate_match_confidence(evidences) -> str`
对应 Skill
1. `资料要求匹配Skill`
### 5.5 节点五:缺失、错放与待复核判定
业务功能:
1. 对每个法规要求项生成完整性状态。
2. 判断必交项是否缺失。
3. 判断文件是否存在但错放章节。
4. 判断疑似提供但证据不足的情况。
5. 将第一步处理失败或页数待复核文件纳入不确定性。
判定规则:
| 条件 | 状态 |
|---|---|
| 必交项有高置信匹配文档 | `provided` |
| 必交项无任何匹配文档 | `missing` |
| 有低置信匹配文档 | `suspected` |
| 文档角色匹配但章节点不匹配 | `misplaced` |
| 文档本身待人工复核 | `manual_review_required` |
| 规则项不适用于当前流程 | `not_applicable` |
使用技术:
1. Python 规则引擎
2. 风险映射 YAML
3. dataclass/Pydantic 结果对象
产生方法:
1. `evaluate_requirement_status(requirement, matches, document_uncertainties) -> CompletenessItemResult`
2. `detect_missing_items(requirements, matches) -> list[MissingItem]`
3. `detect_misplaced_items(requirements, matches) -> list[MisplacedItem]`
4. `detect_manual_review_items(requirements, matches, uncertainties) -> list[ManualReviewItem]`
对应 Skill
1. `缺失错放判定Skill`
### 5.6 节点六:完整性风险映射
业务功能:
1. 根据规则项定义映射风险等级。
2. 缺失高风险必交项时,本维度判定不通过。
3. 对待复核项不直接判失败,但标记结果可信度下降。
4. 生成基础处理建议。
风险等级:
1. `high`
2. `medium`
3. `low`
准入规则:
1. 任一高风险缺失项:`pass_status = failed`
2. 无高风险但存在中风险缺失项:`pass_status = review_required`
3. 只有低风险或待复核项:`pass_status = conditional_pass`
4. 无缺失、无错放、无待复核:`pass_status = passed`
使用技术:
1. 规则包内风险配置
2. 本地建议模板
3. Python 枚举
产生方法:
1. `map_completeness_risk(item_result, risk_rules) -> RiskMappingResult`
2. `calculate_pass_status(item_results) -> str`
3. `build_completeness_suggestion(item_result) -> str`
对应 Skill
1. `缺失错放判定Skill`
### 5.7 节点七:法规证据检索
业务功能:
1. 针对缺失项、错放项和待复核项检索法规原文。
2. 返回法规来源文档、章节、片段和匹配查询。
3. 将证据附加到完整性结果和审计记录。
使用技术:
1. Chroma 或 fallback 检索
2. 本地法规原文切片
3. metadata 过滤
4. 关键词查询兜底
检索过滤条件:
1. `source_role = regulation`
2. `workflow_type = registration`
3. `rule_package_id = nmpa_ivd_registration_v1`
4. `chapter_code``requirement_id`
产生方法:
1. `retrieve_regulation_evidence(requirement_item, query_context) -> list[EvidenceRef]`
2. `build_evidence_query(requirement_item) -> str`
3. `filter_evidence_by_metadata(results, requirement_item) -> list[EvidenceRef]`
对应 Skill
1. `法规证据检索Skill`
### 5.8 节点八:完整性报告生成
业务功能:
1. 汇总完整性检查结果。
2. 生成结构化报告。
3. 生成页面展示摘要。
4. 生成飞书摘要所需的短消息结构。
5. 写入审计记录。
使用技术:
1. Pydantic 或 dataclass schema
2. JSONField
3. Django template 或页面组件消费 JSON
4. Audit 服务层
产生方法:
1. `build_completeness_report(context, item_results, evidence_refs) -> RegistrationCompletenessReport`
2. `build_completeness_summary(item_results) -> CompletenessSummary`
3. `build_display_sections(report) -> list[DisplaySection]`
4. `record_completeness_audit(report, execution_context) -> AuditLog`
对应 Skill
1. `完整性报告生成Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [法规完整性检查Skill](skill/法规完整性检查Skill.md)
2. [法规规则包加载Skill](skill/法规规则包加载Skill.md)
3. [法规流程识别Skill](skill/法规流程识别Skill.md)
4. [资料要求匹配Skill](skill/资料要求匹配Skill.md)
5. [缺失错放判定Skill](skill/缺失错放判定Skill.md)
6. [法规证据检索Skill](skill/法规证据检索Skill.md)
7. [完整性报告生成Skill](skill/完整性报告生成Skill.md)
## 7. 规则数据设计
### 7.1 规则包元数据
| 字段 | 说明 |
|---|---|
| `rule_package_id` | 规则包标识 |
| `version` | 规则版本 |
| `workflow_type` | 适用流程 |
| `source_documents` | 法规来源文档 |
| `effective_scope` | 适用范围 |
| `chapter_count` | 章节数量 |
### 7.2 要求项字段
| 字段 | 说明 |
|---|---|
| `requirement_id` | 要求项编码,如 `CH1.4` |
| `chapter_code` | 章编码,如 `CH1` |
| `requirement_name` | 要求项名称 |
| `required` | 是否必交 |
| `condition` | 适用条件 |
| `expected_document_roles` | 期望文档角色 |
| `keywords` | 匹配关键词 |
| `risk_level_if_missing` | 缺失风险等级 |
| `evidence_query` | 法规证据检索查询 |
| `template_field_refs` | 后续模板字段映射 |
### 7.3 匹配结果字段
| 字段 | 说明 |
|---|---|
| `requirement_id` | 要求项编码 |
| `status` | 完整性状态 |
| `matched_document_ids` | 命中文档 ID |
| `match_confidence` | 匹配置信度 |
| `match_evidence` | 匹配证据 |
| `risk_level` | 风险等级 |
| `suggestion` | 处理建议 |
| `evidence_refs` | 法规证据引用 |
## 8. 页面展示
Chat/工作台页面建议展示:
1. 当前规则包名称和版本。
2. 当前检查流程类型。
3. 当前检查章节点范围。
4. 总体结论。
5. 缺失项数量。
6. 错放项数量。
7. 待人工复核数量。
8. 高风险缺失项。
9. 完整性检查明细表。
10. 法规证据引用。
11. 审计入口。
明细表字段:
1. 章节点。
2. 法规要求项。
3. 当前状态。
4. 命中文档。
5. 风险等级。
6. 处理建议。
7. 法规依据。
## 9. 异常处理
1. 未找到批次:返回“当前资料包不存在或已删除”。
2. 目录汇总未完成:提示先完成资料包导入与目录汇总。
3. 规则包不存在:任务不可执行,记录失败审计。
4. 规则包版本不支持当前流程:提示流程配置错误。
5. 文档章节点大量缺失:可执行,但报告标记低可信。
6. RAG 检索失败:不阻断规则判断,报告中标记证据检索不可用。
7. 所选文档为空:返回空范围检查结果,并提示用户选择资料范围。
8. 文档仍待人工复核:完整性报告保留不确定状态。
## 10. 与后续步骤的接口
后续风险预警读取:
1. `pass_status`
2. `highest_risk_level`
3. `missing_items`
4. `misplaced_items`
5. `manual_review_items`
6. `suggestions`
7. `evidence_refs`
后续字段抽取可读取:
1. 已命中的申请表、产品列表、说明书等文档 ID。
2. 待人工复核文档清单。
3. 当前规则包中声明的模板字段映射。
## 11. 测试设计
### 11.1 单元测试
1. 规则包加载成功。
2. 不支持流程被拦截。
3. 章节点编码匹配成功。
4. 文档角色匹配成功。
5. 必交项缺失被识别。
6. 错放文件被识别。
7. 风险等级映射正确。
8. RAG 失败不影响规则判断。
### 11.2 服务层测试
1. 基于第一步目录汇总执行完整性检查。
2. 缺失 `CH1.4` 时输出高风险。
3. `CH1.2` 目录类文档能作为命中文档进入报告。
4. 待复核文档会进入 `manual_review_items`
5. 规则包缺失时写入失败审计。
### 11.3 页面测试
1. 页面展示规则包版本。
2. 页面展示缺失项和风险等级。
3. 页面展示法规证据。
4. 页面展示审计入口。
5. RAG 不可用时页面仍显示规则判断结果。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 基于本地 YAML 规则包实现 `CH1` 完整性检查。
2. 检查 `CH1.2``CH1.4``CH1.5``CH1.9``CH1.11.1``CH1.11.5``CH1.11.6`
3. 基于第一步的 `chapter_code``document_role` 做规则匹配。
4. 输出缺失项、疑似项和待复核项。
5. RAG 证据检索先使用 fallback 检索,后续接 Chroma。
6. 完整性报告写入审计。
增强阶段再补齐:
1. 第 2 至第 6 章完整规则。
2. 资料格式要求层检查。
3. 安全和性能基本原则清单映射检查。
4. 后台规则人工校订。
5. 在线法规更新能力。
## 7. 验收标准
1. 能区分缺失、错放和待复核。
2. 规则结论与法规依据分层清晰。
3. 可直接用于后续风险预警和飞书通知。

View File

@@ -2,657 +2,57 @@
## 1. 设计目标
本步骤承接“资料包导入与目录汇总”和“法规完整性检查”的输出,目标是从说明书、申请表、产品列表、声明类文件等注册申报资料中抽取产品核心字段,形成可复用、可追溯、可回填、可一致性核查的统一字段池
本步骤负责从说明书、申请表、产品列表等资料中抽取关键字段,并写入统一字段池,供一致性核查、风险判断和 Word 回填复用
本步骤需要完成以下业务结果:
1. 明确本轮字段抽取的资料范围和目标字段范围。
2. 加载注册申报通用字段 schema。
3. 按字段来源优先级选择候选文档。
4. 对固定格式、标题段落、表格字段执行规则抽取。
5. 对长文本字段使用 LLM 辅助归纳。
6. 对字段值进行标准化、去噪和来源绑定。
7. 将字段结果写入统一字段池。
8. 标记字段置信度、冲突状态和待人工确认状态。
9. 输出结构化 `registration_field_extraction_report`
本步骤不负责最终一致性判定,不负责 Word 文件生成。字段池会为后续“一致性核查”和“Word 回填导出”提供输入。
## 2. 所属模块与边界
### 2.1 Documents
`apps.documents` 提供文档主数据、正文文本、标题结构、表格结构和处理状态。
本步骤读取:
1. 文档 ID。
2. 文件名和相对路径。
3. 章节点。
4. 文档角色。
5. 正文文本。
6. 表格结构。
7. 文档处理状态。
8. 是否待人工复核。
如果某文档尚未完成文本或表格抽取,本步骤应给出业务提示,而不是默认字段缺失。
### 2.2 Agent Core
`agent_core` 是本步骤的执行主体,负责编排字段 schema 加载、抽取范围确认、规则抽取、表格抽取、LLM 归纳、字段标准化、字段池写入和报告生成。
本步骤建议产生以下中文 Skill
1. `字段抽取编排Skill`
2. `字段抽取范围确认Skill`
3. `字段Schema加载Skill`
4. `规则字段抽取Skill`
5. `表格字段抽取Skill`
6. `长文本字段归纳Skill`
7. `字段标准化Skill`
8. `统一字段池写入Skill`
9. `字段抽取报告生成Skill`
### 2.3 LLM Provider
LLM 只用于长文本归纳和无法通过规则稳定提取的字段。
LLM 可以处理:
1. 适用范围 / 预期用途归纳。
2. 性能指标摘要。
3. 储存条件段落归纳。
4. 检测靶标从说明书长段落中提取。
LLM 不应处理:
1. 明确表格字段的直接读取。
2. 申请表中固定字段的直接抽取。
3. 字段冲突最终裁判。
4. 没有来源证据的字段编造。
所有 LLM 调用必须经过 Provider并支持 Mock Provider 离线测试。
### 2.4 RAG
RAG 在本步骤中只作为来源片段定位能力使用。
可用于:
1. 从长文档中定位字段候选段落。
2. 为 LLM 归纳提供限定上下文。
3. 为字段来源证据提供片段引用。
RAG 不负责最终字段值裁判。
### 2.5 Audit
`apps.audit` 记录字段抽取任务的执行范围、目标字段、抽取结果、来源证据、LLM 使用情况和失败原因。
审计中必须保留:
## 2. 输入
1. `batch_id`
2. `scenario_id`
3. `selected_document_ids`
4. `field_schema_version`
5. `extracted_fields`
6. `manual_review_fields`
7. `llm_provider_name`
8. `tool_calls`
9. `evidence_refs`
2. `conversation_id`
3. `product_name`
4. `document_scope`
5. `field_schema`
6. `source_priority`
## 3. 输入输出
## 3. 字段池模型
### 3.1 输入
字段项至少包含:
```json
{
"batch_id": 1001,
"scenario_id": "registration_field_extraction",
"field_schema_id": "ivd_registration_fields_v1",
"selected_document_ids": [11, 12, 13],
"target_field_keys": [
"product_name",
"detection_target",
"intended_use",
"storage_condition",
"performance_index"
],
"enable_llm_fallback": true,
"enable_rag_context": true
}
```
### 3.2 输出
本步骤输出 `registration_field_extraction_report`
```json
{
"report_type": "registration_field_extraction_report",
"batch_id": 1001,
"field_schema_id": "ivd_registration_fields_v1",
"field_schema_version": "2026-06-03",
"summary": {
"target_field_count": 5,
"extracted_field_count": 4,
"manual_review_field_count": 1,
"conflict_candidate_count": 1,
"field_pool_status": "partial_completed"
},
"field_pool_items": [],
"manual_review_fields": [],
"evidence_refs": [],
"tool_calls": []
}
```
### 3.3 字段池条目结构
```json
{
"field_key": "product_name",
"field_label": "产品名称",
"standard_value": "新型冠状病毒 2019-nCoV 核酸检测试剂盒",
"raw_value": "新型冠状病毒2019-nCoV核酸检测试剂盒",
"source_document_id": 11,
"source_document_name": "目标产品说明书.docx",
"source_location": {
"chapter_title": "一、产品名称",
"table_index": null,
"page_no": null
},
"extract_method": "rule_heading",
"confidence": "high",
"conflict_status": "not_checked",
"manual_review_required": false,
"fillable": true
}
```
## 4. 主工作流
```text
用户发起字段抽取任务
-> 读取资料包和完整性检查上下文
-> 确认抽取文档范围
-> 加载字段 schema
-> 加载字段来源优先级
-> 读取文档文本和表格结构
-> 执行规则字段抽取
-> 执行表格字段抽取
-> 对长文本字段执行 RAG 定位与 LLM 归纳
-> 标准化字段值
-> 绑定字段来源证据
-> 写入统一字段池
-> 生成字段抽取报告
-> 写入审计留痕
-> 返回字段池视图
```
## 5. 节点详细设计
### 5.1 节点一:抽取任务上下文加载
业务功能:
1. 读取资料包批次。
2. 读取第一步目录汇总。
3. 读取第二步完整性检查报告。
4. 获取命中的申请表、产品列表、说明书等候选文档。
5. 确认当前资料是否满足字段抽取前置条件。
使用技术:
1. Django ORM
2. JSONField 报告快照
3. dataclass/Pydantic schema
产生方法:
1. `load_field_extraction_context(batch_id, scenario_id) -> FieldExtractionContext`
2. `load_candidate_documents(context, selected_document_ids) -> list[DocumentFact]`
3. `validate_extraction_prerequisites(context) -> ExtractionPrerequisiteResult`
对应 Skill
1. `字段抽取编排Skill`
### 5.2 节点二:字段抽取范围确认
业务功能:
1. 确认参与字段抽取的文档范围。
2. 按文档角色筛选候选资料。
3. 排除法规资料和待处理失败资料。
4. 对待人工复核文档保留可用但低可信状态。
默认候选来源:
1. 申请表。
2. 产品说明书。
3. 产品列表。
4. 声明类文件。
5. 历史沟通说明。
使用技术:
1. 文档角色规则
2. 来源优先级 YAML
3. 文档状态过滤
产生方法:
1. `resolve_extraction_scope(documents, selected_document_ids, target_field_keys) -> ExtractionScope`
2. `filter_extractable_documents(documents) -> list[DocumentFact]`
3. `rank_documents_by_field_source(field_key, documents) -> list[DocumentFact]`
对应 Skill
1. `字段抽取范围确认Skill`
### 5.3 节点三:字段 Schema 加载
业务功能:
1. 加载注册申报字段 schema。
2. 确认目标字段、字段类型、来源优先级、抽取方式和回填属性。
3. 为后续 Word 回填建立字段映射基础。
建议 schema 目录:
```text
configs/registration/fields/
ivd_registration_fields_v1.yaml
```
字段 schema 示例:
```yaml
field_schema_id: ivd_registration_fields_v1
version: "2026-06-03"
fields:
- field_key: product_name
field_label: 产品名称
value_type: text
fillable: true
consistency_required: true
source_priority:
- application_form
- product_instruction
- product_list
extraction_methods:
- rule_heading
- table_cell
```
使用技术:
1. YAML
2. Pydantic schema
3. Django cache
产生方法:
1. `load_field_schema(field_schema_id) -> FieldSchema`
2. `validate_field_schema(schema) -> FieldSchemaValidationResult`
3. `select_target_fields(schema, target_field_keys) -> list[FieldDefinition]`
对应 Skill
1. `字段Schema加载Skill`
### 5.4 节点四:规则字段抽取
业务功能:
1. 从标题、段落、固定标签中提取字段。
2. 优先处理产品名称、申请人名称、储存条件等明确字段。
3. 记录抽取方法和来源片段。
适用字段:
1. 产品名称。
2. 申请人名称。
3. 包装规格。
4. 储存条件。
5. 申报日期。
使用技术:
1. 正则表达式
2. 标题层级解析
3. 标签后取值规则
4. 中文标点标准化
产生方法:
1. `extract_fields_by_rules(document, field_definitions) -> list[FieldCandidate]`
2. `extract_by_heading(text_structure, field_definition) -> FieldCandidate | None`
3. `extract_by_label(text, labels) -> FieldCandidate | None`
4. `build_source_location(document, match) -> SourceLocation`
对应 Skill
1. `规则字段抽取Skill`
### 5.5 节点五:表格字段抽取
业务功能:
1. 从申请表、产品列表、标准清单等表格中提取字段。
2. 识别表头和字段标签。
3. 抽取规格型号、分类编码、标准清单等结构化字段。
适用字段:
1. 产品名称。
2. 包装规格。
3. 分类编码。
4. 申请人名称。
5. 生产地址。
6. 标准清单。
使用技术:
1. `python-docx` 表格解析
2. PDF 表格解析可选 `pdfplumber`
3. 表头标准化
4. 单元格坐标记录
产生方法:
1. `extract_fields_from_tables(document, field_definitions) -> list[FieldCandidate]`
2. `normalize_table_headers(table) -> NormalizedTable`
3. `match_table_field(table, field_definition) -> FieldCandidate | None`
4. `build_table_source_location(table_index, row_index, col_index) -> SourceLocation`
对应 Skill
1. `表格字段抽取Skill`
### 5.6 节点六:长文本字段归纳
业务功能:
1. 对规则和表格无法稳定抽取的长文本字段进行归纳。
2. 先用 RAG 或关键词定位候选片段。
3. 将有限上下文交给 LLM Provider。
4. 要求 LLM 返回结构化字段值和引用片段。
适用字段:
1. 检测靶标。
2. 适用范围 / 预期用途。
3. 性能指标。
4. 临床评价路径。
使用技术:
1. RAG fallback / Chroma
2. LLM Provider
3. JSON schema 输出约束
4. Mock Provider 测试
产生方法:
1. `locate_field_context(document, field_definition) -> list[EvidenceChunk]`
2. `summarize_long_text_field(field_definition, chunks) -> FieldCandidate`
3. `call_llm_for_field_extraction(prompt, schema) -> dict`
4. `validate_llm_field_output(output) -> FieldCandidate`
对应 Skill
1. `长文本字段归纳Skill`
### 5.7 节点七:字段标准化
业务功能:
1. 对抽取候选值做清洗和标准化。
2. 合并空格、全半角、中文标点差异。
3. 标准化单位、日期、枚举值。
4. 计算字段置信度。
5. 标记疑似冲突候选,但不做最终一致性裁判。
使用技术:
1. Python 字符串标准化
2. 字段类型规则
3. 日期解析
4. 单位标准化表
产生方法:
1. `normalize_field_candidate(candidate, field_definition) -> NormalizedFieldCandidate`
2. `normalize_text_value(value) -> str`
3. `normalize_date_value(value) -> str`
4. `calculate_field_confidence(candidate, source_priority) -> str`
5. `detect_conflict_candidates(candidates) -> list[ConflictCandidate]`
对应 Skill
1. `字段标准化Skill`
### 5.8 节点八:统一字段池写入
业务功能:
1. 将字段候选写入统一字段池。
2. 按字段来源优先级选择推荐值。
3. 保留所有候选值和来源证据。
4. 标记字段是否可回填。
5. 标记字段是否需要一致性核查。
建议模型:
```python
class RegistrationFieldPoolItem(models.Model):
batch = models.ForeignKey(SubmissionBatch, on_delete=models.CASCADE)
field_key = models.CharField(max_length=128)
field_label = models.CharField(max_length=255)
standard_value = models.TextField(blank=True)
raw_value = models.TextField(blank=True)
source_document_id = models.IntegerField(null=True)
source_location = models.JSONField(default=dict)
extract_method = models.CharField(max_length=64)
confidence = models.CharField(max_length=32)
conflict_status = models.CharField(max_length=32, default="not_checked")
manual_review_required = models.BooleanField(default=False)
fillable = models.BooleanField(default=False)
```
使用技术:
1. Django ORM
2. JSONField
3. 批量写入
4. 字段池版本号
产生方法:
1. `write_field_pool(batch_id, normalized_candidates, field_schema) -> FieldPoolWriteResult`
2. `select_recommended_field_value(field_key, candidates, source_priority) -> FieldPoolItem`
3. `persist_field_candidates(field_pool_item, candidates) -> None`
4. `mark_manual_review_fields(field_pool_items) -> list[FieldPoolItem]`
对应 Skill
1. `统一字段池写入Skill`
### 5.9 节点九:字段抽取报告生成
业务功能:
1. 汇总字段抽取结果。
2. 输出字段池表格。
3. 输出待人工复核字段。
4. 输出字段来源证据。
5. 生成页面展示和飞书摘要载荷。
6. 写入审计记录。
使用技术:
1. dataclass/Pydantic
2. JSONField
3. Audit 服务
4. 页面展示 schema
产生方法:
1. `build_field_extraction_report(context, field_pool_items) -> RegistrationFieldExtractionReport`
2. `build_field_pool_display_rows(field_pool_items) -> list[dict]`
3. `build_field_extraction_audit_payload(report) -> dict`
4. `record_field_extraction_audit(report, context) -> AuditLog`
对应 Skill
1. `字段抽取报告生成Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [字段抽取编排Skill](skill/字段抽取编排Skill.md)
2. [字段抽取范围确认Skill](skill/字段抽取范围确认Skill.md)
3. [字段Schema加载Skill](skill/字段Schema加载Skill.md)
4. [规则字段抽取Skill](skill/规则字段抽取Skill.md)
5. [表格字段抽取Skill](skill/表格字段抽取Skill.md)
6. [长文本字段归纳Skill](skill/长文本字段归纳Skill.md)
7. [字段标准化Skill](skill/字段标准化Skill.md)
8. [统一字段池写入Skill](skill/统一字段池写入Skill.md)
9. [字段抽取报告生成Skill](skill/字段抽取报告生成Skill.md)
## 7. 字段 Schema 设计
### 7.1 V1 目标字段
| 字段编码 | 中文名 | 是否回填 | 是否强一致 |
|---|---|---|---|
| `product_name` | 产品名称 | 是 | 是 |
| `detection_target` | 检测靶标 | 是 | 是 |
| `intended_use` | 适用范围 / 预期用途 | 是 | 是 |
| `storage_condition` | 储存条件 | 是 | 是 |
| `performance_index` | 性能指标 | 是 | 否 |
| `package_specification` | 包装规格 | 是 | 是 |
| `applicant_name` | 申请人名称 | 是 | 是 |
| `classification_code` | 分类编码 | 是 | 是 |
### 7.2 字段来源优先级
| 字段 | 来源优先级 |
|---|---|
| 产品名称 | 申请表 > 说明书 > 产品列表 |
| 检测靶标 | 说明书 > 产品列表 > 申请表 |
| 适用范围 | 说明书 > 申请表 |
| 储存条件 | 说明书 > 标签样稿 |
| 性能指标 | 说明书 > 性能研究资料 |
| 包装规格 | 产品列表 > 申请表 > 说明书 |
## 8. 页面展示
字段抽取结果页面建议展示:
1. 当前字段 schema 版本。
2. 抽取文档范围。
3. 字段总数。
4. 已抽取字段数。
5. 待人工复核字段数。
6. 字段池表格。
7. 字段来源证据。
8. 工具调用记录。
9. 审计入口。
字段池表格字段:
1. 字段名。
2. 推荐值。
3. 原始值。
4. 来源文档。
5. 来源位置。
6. 抽取方法。
7. 置信度。
8. 是否待人工复核。
9. 是否可回填。
## 9. 异常处理
1. 无可抽取文档:返回业务提示,不写空字段池。
2. 文档未完成文本抽取:标记前置条件不足。
3. 字段 schema 缺失:任务不可执行,写失败审计。
4. 表格解析失败:跳过表格抽取,保留规则抽取和 LLM 归纳。
5. LLM 不可用:仅输出规则和表格抽取结果。
6. LLM 输出非法 JSON丢弃该候选并记录工具失败。
7. 多候选值不一致:写入候选值,字段状态标记 `conflict_candidate`
8. 来源文档待复核:字段置信度不超过 `medium`
## 10. 与后续步骤的接口
后续一致性核查读取:
1. `field_key`
2. `standard_value`
3. `raw_value`
1. `field_code`
2. `field_name`
3. `field_value`
4. `source_document_id`
5. `source_location`
6. `confidence`
7. `conflict_status`
8. `manual_review_required`
7. `review_status`
8. `fillable`
后续 Word 回填读取:
## 4. 抽取策略
1. `field_key`
2. `standard_value`
3. `fillable`
4. `manual_review_required`
5. `conflict_status`
6. `template_field_refs`
1. 规则抽取显式字段
2. 表格抽取规格与结构化字段
3. LLM 归纳长文本字段
4. 来源优先级合并同名字段
## 11. 测试设计
## 5. 输出对象
### 11.1 单元测试
`registration_field_extraction_report` 至少包含:
1. 字段 schema 加载成功。
2. 字段来源优先级排序正确。
3. 标题字段抽取正确。
4. 表格字段抽取正确。
5. LLM 输出 schema 校验正确。
6. 字段标准化正确。
7. 推荐值选择正确。
1. `field_items`
2. `source_documents`
3. `low_confidence_items`
4. `fillable_items`
### 11.2 服务层测试
## 6. 对话节点要求
1. 基于说明书抽取产品名称。
2. 基于说明书抽取检测靶标。
3. 基于申请表抽取申请人名称。
4. 多来源候选写入字段池。
5. LLM 不可用时任务仍能完成部分结果。
6. 字段池报告写入审计。
字段抽取节点应展示:
### 11.3 页面测试
1. 已抽取字段数
2. 待复核字段数
3. 关键字段来源
4. 是否可回填
1. 页面展示字段池表格。
2. 页面展示字段来源文档。
3. 页面展示待人工复核字段。
4. 页面展示工具调用记录。
5. 页面展示审计入口。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 建立字段 schema YAML。
2.`目标产品说明书.docx` 抽取产品名称、检测靶标、适用范围、储存条件、性能指标。
3.`CH1.4 申请表.docx``CH1.5 产品列表.docx` 抽取可比对字段。
4. 写入统一字段池。
5. 输出字段抽取报告。
6. 支持 Mock Provider 离线测试。
增强阶段再补齐:
1. 更多字段类型。
2. PDF 表格抽取。
3. OCR 兜底。
4. 后台人工修正字段池。
5. 字段池版本管理。
## 7. 验收标准
1. 统一字段池可支撑后续一致性核查和回填。
2. 低置信度字段有明确待复核标记。
3. 对话中可解释字段来源和采用逻辑。

View File

@@ -2,527 +2,55 @@
## 1. 设计目标
本步骤承接“字段抽取与统一字段池”的输出,目标是核查同一审核范围内不同文档之间的关键字段是否完全一致,并识别跨产品资料混入、字段冲突、来源不确定和需要人工复核的问题
本步骤负责基于统一字段池和资料包上下文,识别跨文档字段冲突、混档风险和待人工确认项
本步骤需要完成以下业务结果:
1. 明确本次一致性核查的审核范围,避免把不同产品样例直接合并审核。
2. 加载强一致字段规则V1 默认按完全一致处理。
3. 从统一字段池读取字段候选值和来源证据。
4. 按字段和来源文档分组。
5. 对强一致字段执行完全一致比对。
6. 识别字段冲突、疑似混档、待人工复核和一致通过项。
7. 生成冲突明细、风险建议和证据链。
8. 输出结构化 `registration_consistency_report`
本步骤不负责重新抽取字段,不负责综合风险汇总,不负责自动修正字段池。它只对字段池中已有的字段事实进行一致性判断。
## 2. 所属模块与边界
### 2.1 Documents
`apps.documents` 提供文档主数据,用于确认字段来源文档是否属于同一批次、同一审核范围和相同业务资料角色。
本步骤读取:
1. 文档 ID。
2. 文档名称。
3. 章节点。
4. 文档角色。
5. 批次 ID。
6. 处理状态。
7. 是否待人工复核。
### 2.2 Agent Core
`agent_core` 是本步骤的执行主体,负责编排审核范围确认、强一致规则加载、字段池读取、字段分组、完全一致比对、混档识别、报告生成和审计留痕。
本步骤建议产生以下中文 Skill
1. `一致性核查编排Skill`
2. `审核范围确认Skill`
3. `强一致规则加载Skill`
4. `字段分组Skill`
5. `字段完全一致比对Skill`
6. `混档风险识别Skill`
7. `一致性报告生成Skill`
### 2.3 LLM Provider
本步骤默认不使用 LLM 作为判断引擎。
LLM 只可用于:
1. 将规则结论组织成自然语言说明。
2. 生成处理建议文案。
3. 对复杂冲突进行解释性摘要。
LLM 不可用于:
1. 将不一致字段判为一致。
2. 覆盖强一致规则结论。
3. 无证据地解释跨产品资料混入。
### 2.4 Audit
`apps.audit` 记录一致性核查的审核范围、字段规则、冲突字段、来源文档、处理建议和最终状态。
审计中必须保留:
## 2. 输入
1. `batch_id`
2. `selected_document_ids`
3. `scope_confirmation`
4. `field_rule_version`
5. `consistent_fields`
6. `conflict_fields`
7. `mixed_package_warnings`
8. `manual_review_fields`
9. `evidence_refs`
2. `conversation_id`
3. `product_name`
4. `field_pool`
5. `strong_consistency_rules`
## 3. 输入输出
## 3. 核查维度
### 3.1 输入
1. 产品名称
2. 检测靶标
3. 适用范围
4. 储存条件
5. 规格/型号
```json
{
"batch_id": 1001,
"scenario_id": "registration_consistency_review",
"selected_document_ids": [11, 12, 13],
"field_rule_id": "ivd_strict_consistency_v1",
"target_field_keys": [
"product_name",
"applicant_name",
"package_specification",
"classification_code"
],
"strict_mode": true
}
```
### 3.2 输出
本步骤输出 `registration_consistency_report`
```json
{
"report_type": "registration_consistency_report",
"batch_id": 1001,
"field_rule_id": "ivd_strict_consistency_v1",
"summary": {
"checked_field_count": 4,
"consistent_field_count": 2,
"conflict_field_count": 1,
"manual_review_field_count": 1,
"mixed_package_warning_count": 1,
"highest_risk_level": "high",
"pass_status": "failed"
},
"consistent_fields": [],
"conflict_fields": [],
"manual_review_fields": [],
"mixed_package_warnings": [],
"suggestions": []
}
```
### 3.3 一致性状态
| 状态 | 含义 |
|---|---|
| `consistent` | 同一字段在审核范围内完全一致 |
| `conflict` | 同一字段出现不同标准值或原始值 |
| `single_source` | 只有一个来源,无法跨文档比对 |
| `manual_review_required` | 来源文档或字段值不确定 |
| `not_checked` | 字段不在本次核查范围 |
| `mixed_package_suspected` | 疑似跨产品资料混入 |
## 4. 主工作流
## 4. 主流程
```text
用户发起一致性核查
-> 读取统一字段池
-> 确认审核范围
-> 加载强一致字段规则
-> 筛选目标字段
-> 按字段分组候选值
-> 按完全一致规则比对
-> 识别字段冲突
-> 识别疑似混档风险
-> 生成处理建议
-> 写入字段池冲突状态
-> 生成一致性报告
-> 写入审计留痕
-> 返回 Web/飞书可展示结果
加载字段池
-> 读取强一致规则
-> 对比多来源字段值
-> 识别冲突字段
-> 判断是否存在混档风险
-> 输出一致性节点结果
```
## 5. 节点详细设计
## 5. 输出对象
### 5.1 节点一:读取统一字段池
`registration_consistency_report` 至少包含:
业务功能:
1. `conflict_items`
2. `consistent_items`
3. `mixed_package_risk`
4. `recommended_value`
1. 根据 `batch_id` 读取字段池。
2. 过滤本次目标字段。
3. 读取每个字段的所有候选值和推荐值。
4. 加载字段来源文档信息。
## 6. 对话节点要求
使用技术
一致性节点应展示
1. Django ORM
2. JSONField
3. dataclass/Pydantic schema
1. 冲突字段
2. 来源对比
3. 是否疑似混档
4. 建议采用值
产生方法:
1. `load_field_pool(batch_id) -> list[FieldPoolItem]`
2. `load_field_candidates(batch_id, field_keys) -> list[FieldCandidateRecord]`
3. `attach_source_documents(candidates) -> list[FieldCandidateWithDocument]`
对应 Skill
1. `一致性核查编排Skill`
### 5.2 节点二:审核范围确认
业务功能:
1. 确认哪些文档属于同一项目、批次和本次审核范围。
2. 如果用户选择了文档范围,只对选中范围执行。
3. 如果未选择范围,默认使用当前批次业务资料。
4. 对疑似不同产品、不同来源、不同流程资料给出范围警告。
范围确认规则:
1. 同一 `batch_id` 是最低要求。
2. `source_role` 必须为 `submission`
3. 法规资料不进入一致性核查。
4. 待人工复核文档可以进入,但相关字段不直接判通过。
5. 疑似跨产品文档进入混档风险提示。
使用技术:
1. 文档主数据
2. 资料包批次
3. 文档角色规则
4. 用户选中文档范围
产生方法:
1. `resolve_review_scope(batch_id, selected_document_ids) -> ReviewScope`
2. `validate_scope_documents(documents) -> ScopeValidationResult`
3. `detect_scope_uncertainties(documents) -> list[ScopeWarning]`
对应 Skill
1. `审核范围确认Skill`
### 5.3 节点三:强一致规则加载
业务功能:
1. 加载强一致字段规则。
2. 确认字段是否要求完全一致。
3. 定义冲突严重度。
4. 定义字段缺少多个来源时的处理方式。
规则示例:
```yaml
field_rule_id: ivd_strict_consistency_v1
version: "2026-06-03"
strict_mode: true
fields:
- field_key: product_name
field_label: 产品名称
consistency_required: true
compare_mode: exact
risk_level_if_conflict: high
- field_key: performance_index
consistency_required: false
compare_mode: not_checked
```
使用技术:
1. YAML
2. Pydantic schema
3. Django cache
产生方法:
1. `load_consistency_rules(field_rule_id) -> ConsistencyRuleSet`
2. `validate_consistency_rules(rule_set) -> RuleValidationResult`
3. `select_consistency_fields(rule_set, target_field_keys) -> list[ConsistencyFieldRule]`
对应 Skill
1. `强一致规则加载Skill`
### 5.4 节点四:字段分组
业务功能:
1.`field_key` 分组字段候选。
2. 按来源文档分组同一字段。
3. 保留标准值、原始值、来源位置、置信度。
4. 标记单来源字段和多来源字段。
使用技术:
1. Python 分组
2. 字段池候选表
3. 文档元数据
产生方法:
1. `group_candidates_by_field(candidates) -> dict[str, list[FieldCandidateWithDocument]]`
2. `group_candidates_by_document(candidates) -> dict[int, list[FieldCandidateWithDocument]]`
3. `build_field_compare_units(grouped_candidates) -> list[FieldCompareUnit]`
对应 Skill
1. `字段分组Skill`
### 5.5 节点五:字段完全一致比对
业务功能:
1. 对强一致字段执行完全一致比对。
2. 对标准化值不同的字段判定冲突。
3. 对标准化值相同但原始值差异较大的字段标记待复核。
4. 对单来源字段标记无法跨文档比对。
比对规则:
1. `compare_mode = exact` 时,标准值必须完全一致。
2. 不采用语义相近判定。
3. 空值不参与通过判定。
4. 待人工复核来源不作为通过证据。
使用技术:
1. Python 严格字符串比较
2. 字段标准化结果
3. 风险映射规则
产生方法:
1. `compare_exact(field_compare_unit, rule) -> FieldCompareResult`
2. `detect_value_conflict(values) -> bool`
3. `detect_raw_value_variation(values) -> bool`
4. `build_conflict_detail(result) -> ConflictDetail`
对应 Skill
1. `字段完全一致比对Skill`
### 5.6 节点六:混档风险识别
业务功能:
1. 基于产品名称、检测靶标、申请人等核心字段识别疑似跨产品资料混入。
2. 当同一审核范围内出现明显不同产品名称时,输出混档风险。
3. 区分“字段冲突”和“疑似资料包范围错误”。
典型规则:
1. 产品名称出现两个不同值:高风险混档。
2. 检测靶标与产品名称指向明显不同产品:高风险混档。
3. 申请人不同:高风险或待复核。
4. 同一文档角色出现多个版本:中风险或待复核。
使用技术:
1. 字段比对结果
2. 文档角色分析
3. 批次范围校验
4. 规则映射 YAML
产生方法:
1. `detect_mixed_package_risk(compare_results, scope) -> list[MixedPackageWarning]`
2. `detect_product_identity_conflict(field_results) -> MixedPackageWarning | None`
3. `classify_mixed_package_risk(warning) -> str`
对应 Skill
1. `混档风险识别Skill`
### 5.7 节点七:字段池冲突状态回写
业务功能:
1. 将一致性核查结果回写到字段池。
2. 对冲突字段标记 `conflict`
3. 对待复核字段标记 `manual_review_required`
4. 对一致字段标记 `consistent`
使用技术:
1. Django ORM
2. 批量更新
3. 字段池版本记录
产生方法:
1. `update_field_pool_consistency_status(compare_results) -> FieldPoolUpdateResult`
2. `mark_conflict_field(field_key, conflict_detail) -> None`
3. `mark_consistent_field(field_key) -> None`
对应 Skill
1. `一致性核查编排Skill`
### 5.8 节点八:一致性报告生成
业务功能:
1. 汇总一致字段、冲突字段、待复核字段和混档风险。
2. 生成结构化报告。
3. 生成页面展示区块。
4. 生成飞书摘要载荷。
5. 写入审计记录。
使用技术:
1. dataclass/Pydantic
2. JSONField
3. Audit 服务
4. 页面展示 schema
产生方法:
1. `build_consistency_report(context, compare_results, mixed_warnings) -> RegistrationConsistencyReport`
2. `build_consistency_summary(compare_results, mixed_warnings) -> dict`
3. `build_consistency_display_sections(report) -> list[dict]`
4. `record_consistency_audit(report, context) -> AuditLog`
对应 Skill
1. `一致性报告生成Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [一致性核查编排Skill](skill/一致性核查编排Skill.md)
2. [审核范围确认Skill](skill/审核范围确认Skill.md)
3. [强一致规则加载Skill](skill/强一致规则加载Skill.md)
4. [字段分组Skill](skill/字段分组Skill.md)
5. [字段完全一致比对Skill](skill/字段完全一致比对Skill.md)
6. [混档风险识别Skill](skill/混档风险识别Skill.md)
7. [一致性报告生成Skill](skill/一致性报告生成Skill.md)
## 7. 强一致字段设计
V1 建议强一致字段:
| 字段编码 | 中文名 | 风险等级 |
|---|---|---|
| `product_name` | 产品名称 | 高 |
| `applicant_name` | 申请人名称 | 高 |
| `package_specification` | 包装规格 | 中 |
| `classification_code` | 分类编码 | 高 |
| `detection_target` | 检测靶标 | 高 |
| `intended_use` | 适用范围 / 预期用途 | 中 |
## 8. 页面展示
一致性核查页面建议展示:
1. 当前审核范围。
2. 参与核查文档。
3. 强一致字段规则版本。
4. 一致字段数量。
5. 冲突字段数量。
6. 待人工复核字段数量。
7. 混档风险提示。
8. 字段冲突明细表。
9. 来源证据。
10. 审计入口。
冲突明细表字段:
1. 字段名称。
2. 冲突值。
3. 来源文档。
4. 来源位置。
5. 风险等级。
6. 建议动作。
## 9. 异常处理
1. 字段池不存在:提示先执行字段抽取。
2. 未选择文档且批次为空:返回业务错误。
3. 审核范围内只有单个文档:输出单来源提示,不判冲突。
4. 强一致规则缺失:任务不可执行,写失败审计。
5. 字段全部为空:标记待人工复核。
6. 来源文档待复核:字段不直接判通过。
7. 混档风险存在:本步骤 `pass_status` 直接失败。
## 10. 与后续步骤的接口
后续风险预警读取:
1. `pass_status`
2. `highest_risk_level`
3. `conflict_fields`
4. `mixed_package_warnings`
5. `manual_review_fields`
6. `suggestions`
后续 Word 回填读取:
1. 字段池中的 `conflict_status`
2. 冲突字段清单。
3. 待人工复核字段清单。
4. 可安全回填字段清单。
## 11. 测试设计
### 11.1 单元测试
1. 审核范围确认正确。
2. 强一致规则加载成功。
3. 字段按 `field_key` 分组正确。
4. 完全一致比对正确。
5. 字段冲突识别正确。
6. 单来源字段不判冲突。
7. 混档风险识别正确。
### 11.2 服务层测试
1. 产品名称不同输出高风险冲突。
2. 产品名称相同输出一致。
3. 申请人不同输出冲突。
4. 待复核字段进入人工复核清单。
5. 冲突状态回写字段池。
6. 一致性报告写入审计。
### 11.3 页面测试
1. 页面展示审核范围。
2. 页面展示冲突字段来源。
3. 页面展示混档风险。
4. 页面展示待人工复核状态。
5. 页面展示审计入口。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 从统一字段池读取产品名称、申请人名称、包装规格、分类编码。
2. 按完全一致规则比对。
3. 识别样例中可能出现的产品名称冲突。
4. 输出冲突明细和混档风险。
5. 回写字段池 `conflict_status`
6. 生成一致性核查报告并写审计。
增强阶段再补齐:
1. 更多强一致字段。
2. 版本重复识别。
3. 文档结构一致性检查。
4. 人工确认后重新判定。
5. 冲突字段修正建议。
## 7. 验收标准
1. 能对产品名称等强一致字段给出明确冲突结论。
2. 能说明冲突来自哪些文档。
3. 能把冲突结果直接传递给风险预警和飞书通知。

View File

@@ -2,476 +2,65 @@
## 1. 设计目标
本步骤承接法规完整性检查、字段抽取一致性核查结果,目标是把缺失项、错放项、字段冲突、混档风险、待人工复核项和交付风险统一汇总成可执行的合规风险清单,并给出是否通过、整改优先级和责任建议
本步骤汇总完整性检查、字段抽取一致性核查结果,形成统一风险报告,并决定是否允许正式导出
本步骤需要完成以下业务结果:
## 2. 输入
1. 汇总前序任务报告。
2. 加载风险分级和准入规则。
3. 将完整性、字段抽取、一致性等结果映射为风险项。
4. 合并重复风险和关联风险。
5. 计算最高风险等级和是否通过。
6. 生成整改建议、处理优先级和责任角色。
7. 输出结构化 `registration_risk_report`
8. 为飞书通知步骤生成风险摘要载荷。
1. `registration_completeness_report`
2. `registration_field_extraction_report`
3. `registration_consistency_report`
4. `risk_rule_set`
本步骤不重新执行完整性检查、字段抽取或一致性核查。它只消费前序结构化报告,并在规则基础上生成综合风险预警。
## 3. 风险类型
## 2. 所属模块与边界
1. 缺失必交资料
2. 资料错放
3. 字段低置信度
4. 字段冲突
5. 混档风险
6. 导出阻断风险
### 2.1 Agent Core
## 4. 输出对象
`agent_core` 是本步骤的执行主体,负责编排报告加载、风险规则加载、风险项生成、风险归并、准入判定、整改建议生成和报告输出。
`registration_risk_report` 至少包含:
本步骤建议产生以下中文 Skill
1. `风险预警编排Skill`
2. `前序报告汇总Skill`
3. `风险规则加载Skill`
4. `风险项生成Skill`
5. `风险归并Skill`
6. `准入判定Skill`
7. `整改建议生成Skill`
8. `风险报告生成Skill`
### 2.2 LLM Provider
LLM 可以用于将规则风险结论整理为自然语言建议,但不能改变风险等级、是否通过和整改优先级。
LLM 可以处理:
1. 风险摘要润色。
2. 整改建议文案组织。
3. 飞书通知摘要表达。
LLM 不可处理:
1. 修改规则判定的风险等级。
2. 将高风险降级。
3. 将不通过改为通过。
### 2.3 Audit
`apps.audit` 记录风险预警的输入报告、风险规则版本、风险项、准入结论和建议动作。
审计中必须保留:
1. `batch_id`
2. `source_report_ids`
3. `risk_rule_version`
4. `risk_items`
5. `pass_status`
6. `highest_risk_level`
7. `manual_review_items`
8. `owner_roles`
9. `suggestions`
## 3. 输入输出
### 3.1 输入
```json
{
"batch_id": 1001,
"scenario_id": "registration_risk_report",
"risk_rule_id": "ivd_registration_risk_v1",
"include_reports": [
"registration_completeness_report",
"registration_field_extraction_report",
"registration_consistency_report"
],
"enable_llm_summary": true
}
```
### 3.2 输出
本步骤输出 `registration_risk_report`
```json
{
"report_type": "registration_risk_report",
"batch_id": 1001,
"risk_rule_id": "ivd_registration_risk_v1",
"summary": {
"risk_item_count": 6,
"high_risk_count": 2,
"medium_risk_count": 2,
"low_risk_count": 1,
"manual_review_count": 1,
"highest_risk_level": "high",
"pass_status": "failed"
},
"risk_items": [],
"manual_review_items": [],
"suggestions": [],
"owner_notifications": []
}
```
## 4. 主工作流
```text
用户发起风险预警
-> 读取前序任务报告
-> 加载风险规则
-> 从完整性报告生成缺失/错放风险
-> 从字段抽取报告生成抽取不确定风险
-> 从一致性报告生成冲突/混档风险
-> 合并重复和关联风险
-> 计算风险等级和准入状态
-> 生成整改建议和责任角色
-> 生成综合风险报告
-> 写入审计留痕
-> 返回 Web/飞书可展示结果
```
## 5. 节点详细设计
### 5.1 节点一:前序报告汇总
业务功能:
1. 读取当前批次的完整性检查报告。
2. 读取字段抽取报告。
3. 读取一致性核查报告。
4. 判断哪些报告缺失或过期。
5. 将报告统一转为风险输入上下文。
使用技术:
1. Django ORM
2. JSONField
3. 报告版本号
4. dataclass/Pydantic
产生方法:
1. `load_source_reports(batch_id, include_reports) -> SourceReportBundle`
2. `validate_source_reports(bundle) -> SourceReportValidationResult`
3. `build_risk_context(bundle) -> RiskEvaluationContext`
对应 Skill
1. `前序报告汇总Skill`
### 5.2 节点二:风险规则加载
业务功能:
1. 加载风险分级规则。
2. 加载准入规则。
3. 加载风险类型和责任角色映射。
4. 加载整改建议模板。
建议规则目录:
```text
configs/registration/risk/
ivd_registration_risk_v1.yaml
```
规则示例:
```yaml
risk_rule_id: ivd_registration_risk_v1
version: "2026-06-03"
admission:
high_risk_policy: fail
multiple_medium_policy: review_required
risk_types:
missing_required_document:
default_level: high
owner_role: 注册申报负责人
field_conflict:
default_level: high
owner_role: 注册资料负责人
```
使用技术:
1. YAML
2. Pydantic schema
3. Django cache
产生方法:
1. `load_risk_rules(risk_rule_id) -> RiskRuleSet`
2. `validate_risk_rules(rule_set) -> RiskRuleValidationResult`
3. `load_owner_role_mapping(rule_set) -> dict`
对应 Skill
1. `风险规则加载Skill`
### 5.3 节点三:风险项生成
业务功能:
1. 从完整性报告生成缺失、错放、待复核风险。
2. 从字段抽取报告生成字段缺失、低可信、抽取失败风险。
3. 从一致性报告生成字段冲突、混档风险。
4. 记录风险来源报告和证据。
风险类型:
1. `missing_required_document`
2. `misplaced_document`
3. `field_missing`
4. `field_low_confidence`
5. `field_conflict`
6. `mixed_package`
7. `manual_review_required`
8. `delivery_blocker`
使用技术:
1. Python 规则映射
2. 风险规则 YAML
3. 前序报告 schema
产生方法:
1. `build_risks_from_completeness(report, rules) -> list[RiskItem]`
2. `build_risks_from_field_extraction(report, rules) -> list[RiskItem]`
3. `build_risks_from_consistency(report, rules) -> list[RiskItem]`
4. `attach_risk_evidence(risk_item, source_report) -> RiskItem`
对应 Skill
1. `风险项生成Skill`
### 5.4 节点四:风险归并
业务功能:
1. 合并重复风险。
2. 关联同一根因风险。
3. 避免同一字段冲突重复出现在多个列表。
4. 保留风险来源链路。
归并示例:
1. 产品名称冲突和混档风险可以关联。
2. 缺失申请表和字段缺失申请人名称可以关联。
3. 文档待复核导致的字段低可信可以关联。
使用技术:
1. 风险指纹
2. `risk_type + field_key + document_id`
3. Python 分组
产生方法:
1. `build_risk_fingerprint(risk_item) -> str`
2. `merge_duplicate_risks(risk_items) -> list[RiskItem]`
3. `link_related_risks(risk_items) -> list[RiskGroup]`
对应 Skill
1. `风险归并Skill`
### 5.5 节点五:准入判定
业务功能:
1. 计算最高风险等级。
2. 根据规则判断是否通过。
3. 区分风险等级和人工复核状态。
4. 输出最终准入结论。
准入规则:
1. 任一高风险项:`pass_status = failed`
2. 无高风险但多个中风险:`pass_status = review_required`
3. 仅低风险:`pass_status = conditional_pass`
4. 无风险但有人工复核项:`pass_status = review_required`
5. 无风险无复核:`pass_status = passed`
使用技术:
1. 风险规则
2. Python 枚举
3. 评分配置
产生方法:
1. `calculate_highest_risk_level(risk_items) -> str`
2. `calculate_pass_status(risk_items, manual_review_items, rules) -> str`
3. `build_admission_detail(risk_items, status) -> AdmissionDecision`
对应 Skill
1. `准入判定Skill`
### 5.6 节点六:整改建议生成
业务功能:
1. 为每个风险项生成建议动作。
2. 按高、中、低风险排序。
3. 映射责任角色。
4. 生成可用于飞书通知的摘要。
使用技术:
1. 本地建议模板
2. 责任角色映射
3. 可选 LLM Provider 生成自然语言摘要
产生方法:
1. `build_risk_suggestion(risk_item, rules) -> Suggestion`
2. `sort_suggestions_by_priority(suggestions) -> list[Suggestion]`
3. `build_owner_notification_payload(risk_items) -> list[OwnerNotificationPayload]`
4. `summarize_risk_report_with_llm(report) -> str`
对应 Skill
1. `整改建议生成Skill`
### 5.7 节点七:风险报告生成
业务功能:
1. 汇总风险项和建议。
2. 生成结构化风险报告。
3. 生成页面展示区块。
4. 生成飞书通知载荷。
5. 写入审计。
使用技术:
1. dataclass/Pydantic
2. JSONField
3. Audit 服务
4. 飞书摘要 payload schema
产生方法:
1. `build_risk_report(context, risk_items, admission, suggestions) -> RegistrationRiskReport`
2. `build_risk_summary(risk_items, admission) -> dict`
3. `build_risk_display_sections(report) -> list[dict]`
4. `record_risk_audit(report, context) -> AuditLog`
对应 Skill
1. `风险报告生成Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [风险预警编排Skill](skill/风险预警编排Skill.md)
2. [前序报告汇总Skill](skill/前序报告汇总Skill.md)
3. [风险规则加载Skill](skill/风险规则加载Skill.md)
4. [风险项生成Skill](skill/风险项生成Skill.md)
5. [风险归并Skill](skill/风险归并Skill.md)
6. [准入判定Skill](skill/准入判定Skill.md)
7. [整改建议生成Skill](skill/整改建议生成Skill.md)
8. [风险报告生成Skill](skill/风险报告生成Skill.md)
## 7. 风险项结构
```json
{
"risk_id": "RISK-001",
"risk_type": "field_conflict",
"risk_level": "high",
"source_report_type": "registration_consistency_report",
"related_documents": [11, 12],
"related_field_key": "product_name",
"description": "说明书与申请表中的产品名称不一致。",
"suggestion": "请先确认当前审核范围是否混入其他产品资料。",
"owner_role": "注册资料负责人",
"manual_review_required": false
}
```
## 8. 页面展示
风险预警页面建议展示:
1. 综合结论。
2. 是否通过。
3. 最高风险等级。
4. 高/中/低风险数量。
5. 待人工复核数量。
6. 风险清单。
7. 整改建议。
8. 责任角色。
9. 审计入口。
## 9. 异常处理
1. 前序报告缺失:生成“报告不完整”风险或提示先执行对应任务。
2. 风险规则缺失:任务不可执行,写失败审计。
3. LLM 不可用:使用本地模板生成建议。
4. 责任角色未配置:使用默认责任角色并提示维护映射。
5. 风险项为空:输出通过或待复核状态。
## 10. 与后续步骤的接口
后续 Word 回填读取:
1. `pass_status`
2. `high_risk_count`
3. `field_conflict` 风险项。
1. `risk_items`
2. `highest_risk_level`
3. `pass_status`
4. `manual_review_items`
5. 可回填风险拦截状态。
5. `owner_roles`
6. `suggestions`
后续飞书通知读取:
## 5. 责任角色输出
1. 风险摘要。
2. 高风险清单。
3. 责任角色。
4. 责任人通知载荷。
5. Web 详情页链接。
风险结果必须产出责任角色,为飞书协同做准备。责任角色实体至少包含:
## 11. 测试设计
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
### 11.1 单元测试
风险结果还应直接给出通知触发语义,供飞书步骤复用:
1. 风险规则加载成功。
2. 完整性缺失项生成风险。
3. 字段冲突生成高风险。
4. 混档风险生成高风险。
5. 风险归并正确。
6. 任一高风险判不通过。
1. 正常完成时使用 `task_completed`
2. 异常失败时使用 `task_failed`
### 11.2 服务层测试
## 6. 对话节点要求
1. 缺少完整性报告时提示前序任务缺失。
2. 有高风险时 `pass_status = failed`
3. 只有低风险时 `conditional_pass`
4. 待复核项进入人工复核清单。
5. 风险报告写入审计。
风险节点应展示:
### 11.3 页面测试
1. 总风险等级
2. 是否通过
3. 整改建议
4. 对应责任角色
1. 页面展示最高风险等级。
2. 页面展示风险清单。
3. 页面展示整改建议。
4. 页面展示责任角色。
5. 页面展示审计入口。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 汇总完整性检查和一致性核查报告。
2. 生成缺失风险、字段冲突风险、混档风险。
3. 实现任一高风险即不通过。
4. 生成整改建议和责任角色。
5. 输出 `registration_risk_report`
6. 写入审计。
增强阶段再补齐:
1. 多维度风险评分。
2. 历史申报事项风险。
3. 版本一致性风险。
4. 飞书责任人通知联动。
5. 后台风险规则维护。
## 7. 验收标准
1. 风险结论可直接决定是否允许正式导出。
2. 输出中包含责任角色和通知所需字段。
3. 能向飞书通知步骤直接提供摘要与责任人。

View File

@@ -2,455 +2,40 @@
## 1. 设计目标
本步骤承接字段抽取、统一字段池、一致性核查和风险预警结果,目标是将可回填字段按模板映射写入注册申报表格或对照清单,并生成新的 Word 文件供用户下载、复核和归档
本步骤负责把统一字段池中的可回填字段写入注册申报表格或对照清单,并在风险允许时生成导出文件
本步骤需要完成以下业务结果:
## 2. 输入
1. 选择适用的 Word 模板。
2. 加载模板字段映射。
3. 从统一字段池读取可回填字段。
4. 根据风险报告和冲突状态执行回填拦截。
5. 生成回填数据集。
6. 按模板写入 Word 文档。
7. 校验输出文档版式、字段落位和导出状态。
8. 生成导出记录和下载入口。
9. 写入审计留痕。
1. `field_pool`
2. `template_mapping`
3. `registration_risk_report`
本步骤不重新抽取字段,不修改法规判断和风险结论。若字段冲突或高风险未处理,应阻止自动生成可报送文件,或者生成“待复核草稿版”并明确标记。
## 3. 导出策略
## 2. 所属模块与边界
1. 始终允许生成草稿版
2. 命中高风险阻断正式版
3. 字段冲突或关键缺失时给出拦截原因
### 2.1 Documents
## 4. 输出对象
`apps.documents` 负责保存生成后的 Word 文件、记录导出文件元数据,并提供下载入口。
`registration_word_export_report` 至少包含:
### 2.2 Agent Core
1. `fillable_items`
2. `blocked_items`
3. `draft_export_status`
4. `formal_export_status`
5. `download_url`
`agent_core` 负责模板选择、字段映射、回填数据集构建、Word 渲染、版式校验和导出报告生成。
## 5. 页面与会话要求
本步骤建议产生以下中文 Skill
在对话或结果展示中应能看到
1. `Word回填导出编排Skill`
2. `模板选择Skill`
3. `模板字段映射加载Skill`
4. `回填字段集构建Skill`
5. `回填拦截检查Skill`
6. `Word模板渲染Skill`
7. `导出版式校验Skill`
8. `导出记录生成Skill`
1. 哪些字段已回填
2. 哪些字段被拦截
3. 为什么正式版不能导出
### 2.3 Audit
`apps.audit` 记录本次回填导出的模板版本、字段映射、回填字段、拦截状态、输出文件和失败原因。
审计中必须保留:
1. `batch_id`
2. `template_id`
3. `template_version`
4. `field_mapping_version`
5. `filled_fields`
6. `blocked_fields`
7. `output_file_id`
8. `export_status`
9. `layout_check_result`
## 3. 输入输出
### 3.1 输入
```json
{
"batch_id": 1001,
"scenario_id": "registration_word_fill_export",
"template_id": "registration_application_form_v1",
"target_output_type": "application_form",
"allow_draft_when_blocked": true,
"selected_field_keys": [
"product_name",
"detection_target",
"intended_use",
"storage_condition",
"performance_index"
]
}
```
### 3.2 输出
本步骤输出 `registration_word_export_report`
```json
{
"report_type": "registration_word_export_report",
"batch_id": 1001,
"template_id": "registration_application_form_v1",
"export_status": "draft_generated",
"summary": {
"fillable_field_count": 5,
"filled_field_count": 4,
"blocked_field_count": 1,
"manual_review_field_count": 1,
"layout_check_status": "passed"
},
"filled_fields": [],
"blocked_fields": [],
"output_file": {
"file_id": 2001,
"filename": "注册申报表格_回填草稿.docx",
"download_url": "/documents/exports/2001/download/"
}
}
```
## 4. 主工作流
```text
用户发起 Word 回填导出
-> 读取字段池和风险报告
-> 选择目标模板
-> 加载模板字段映射
-> 构建回填字段集
-> 执行冲突和风险拦截检查
-> 渲染 Word 模板
-> 校验字段落位和版式
-> 保存导出文件
-> 生成导出报告
-> 写入审计留痕
-> 返回下载入口
```
## 5. 节点详细设计
### 5.1 节点一:导出上下文加载
业务功能:
1. 读取资料包批次。
2. 读取统一字段池。
3. 读取一致性核查报告。
4. 读取风险预警报告。
5. 确认是否允许生成正式版或草稿版。
使用技术:
1. Django ORM
2. JSONField 报告快照
3. dataclass/Pydantic
产生方法:
1. `load_export_context(batch_id, template_id) -> WordExportContext`
2. `load_fillable_field_pool(batch_id) -> list[FieldPoolItem]`
3. `load_export_blockers(batch_id) -> ExportBlockerContext`
对应 Skill
1. `Word回填导出编排Skill`
### 5.2 节点二:模板选择
业务功能:
1. 根据输出目标选择模板。
2. 校验模板是否存在。
3. 校验模板适用流程。
4. 校验模板版本是否启用。
模板类型:
1. 注册申报表格模板。
2. 法规对照清单模板。
3. 注册证或批准证明文件格式模板。
使用技术:
1. 模板库模型
2. Django Storage
3. YAML/JSON 模板元数据
产生方法:
1. `select_word_template(template_id, target_output_type) -> WordTemplate`
2. `validate_template_applicability(template, workflow_type) -> TemplateValidationResult`
3. `resolve_template_file_path(template) -> Path`
对应 Skill
1. `模板选择Skill`
### 5.3 节点三:模板字段映射加载
业务功能:
1. 加载模板字段和统一字段池字段的映射关系。
2. 校验模板占位符是否完整。
3. 标记必填字段、可选字段和人工复核字段。
映射示例:
```yaml
template_id: registration_application_form_v1
version: "2026-06-03"
mappings:
- placeholder: "{{ product_name }}"
field_key: product_name
required: true
- placeholder: "{{ intended_use }}"
field_key: intended_use
required: true
```
使用技术:
1. YAML
2. Pydantic
3. 模板占位符扫描
产生方法:
1. `load_template_field_mapping(template_id) -> TemplateFieldMapping`
2. `validate_mapping_against_template(template, mapping) -> MappingValidationResult`
3. `scan_template_placeholders(template_file) -> list[str]`
对应 Skill
1. `模板字段映射加载Skill`
### 5.4 节点四:回填字段集构建
业务功能:
1. 从字段池读取映射字段。
2. 选择推荐值。
3. 过滤不可回填字段。
4. 生成待回填字段集。
使用技术:
1. Django ORM
2. 字段池数据
3. 映射规则
产生方法:
1. `build_fill_dataset(field_pool, mapping) -> FillDataset`
2. `resolve_field_value(field_key, field_pool) -> FillValue`
3. `collect_missing_fill_values(mapping, field_pool) -> list[MissingFillValue]`
对应 Skill
1. `回填字段集构建Skill`
### 5.5 节点五:回填拦截检查
业务功能:
1. 检查字段是否存在冲突。
2. 检查字段是否待人工复核。
3. 检查风险报告是否存在高风险。
4. 判断是否允许生成正式版或草稿版。
拦截规则:
1. 高风险未处理:禁止生成正式版。
2. 字段冲突未处理:禁止自动正式回填。
3. 必填字段缺失:禁止正式版。
4. 允许生成草稿版时,输出文件必须标记“待复核草稿”。
使用技术:
1. 风险报告
2. 字段池冲突状态
3. 回填策略规则
产生方法:
1. `check_fill_blockers(fill_dataset, risk_report) -> FillBlockerResult`
2. `determine_export_mode(blockers, allow_draft) -> str`
3. `build_blocked_field_list(blockers) -> list[BlockedField]`
对应 Skill
1. `回填拦截检查Skill`
### 5.6 节点六Word 模板渲染
业务功能:
1. 打开 Word 模板。
2. 替换占位符。
3. 写入表格单元格。
4. 保留标题层级、表格、页眉页脚、盖章位和原始样式。
5. 输出新的 Word 文件。
使用技术:
1. `python-docx`
2. `docxtpl` 可选
3. Django Storage
4. 临时文件目录
产生方法:
1. `render_word_template(template_file, fill_dataset, export_mode) -> RenderedWordFile`
2. `replace_paragraph_placeholders(document, values) -> None`
3. `replace_table_placeholders(document, values) -> None`
4. `save_rendered_document(document, output_path) -> Path`
对应 Skill
1. `Word模板渲染Skill`
### 5.7 节点七:导出版式校验
业务功能:
1. 校验输出文件是否存在。
2. 校验必填占位符是否已替换。
3. 校验是否仍残留模板占位符。
4. 校验基础版式元素是否存在。
5. 必要时渲染预览用于人工确认。
使用技术:
1. `python-docx`
2. 文档占位符扫描
3. 可选 LibreOffice 转 PDF
4. 可选 PDF 渲染检查
产生方法:
1. `check_export_layout(output_file, mapping) -> LayoutCheckResult`
2. `detect_unfilled_placeholders(output_file) -> list[str]`
3. `validate_required_sections(output_file) -> list[LayoutWarning]`
对应 Skill
1. `导出版式校验Skill`
### 5.8 节点八:导出记录生成
业务功能:
1. 保存输出文件记录。
2. 生成下载地址。
3. 生成导出报告。
4. 写入审计。
使用技术:
1. Django ORM
2. Django Storage
3. JSONField
4. Audit 服务
产生方法:
1. `create_export_file_record(batch, output_file) -> ExportedDocument`
2. `build_download_url(export_file) -> str`
3. `build_word_export_report(context, result) -> RegistrationWordExportReport`
4. `record_word_export_audit(report, context) -> AuditLog`
对应 Skill
1. `导出记录生成Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [Word回填导出编排Skill](skill/Word回填导出编排Skill.md)
2. [模板选择Skill](skill/模板选择Skill.md)
3. [模板字段映射加载Skill](skill/模板字段映射加载Skill.md)
4. [回填字段集构建Skill](skill/回填字段集构建Skill.md)
5. [回填拦截检查Skill](skill/回填拦截检查Skill.md)
6. [Word模板渲染Skill](skill/Word模板渲染Skill.md)
7. [导出版式校验Skill](skill/导出版式校验Skill.md)
8. [导出记录生成Skill](skill/导出记录生成Skill.md)
## 7. 导出模式
| 模式 | 含义 |
|---|---|
| `formal` | 正式导出,字段无冲突且无高风险 |
| `draft` | 草稿导出,存在待复核项但允许预览 |
| `blocked` | 拦截导出,不生成文件 |
## 8. 页面展示
Word 回填导出页面建议展示:
1. 模板名称和版本。
2. 回填字段数量。
3. 已回填字段。
4. 被拦截字段。
5. 导出模式。
6. 版式校验结果。
7. Word 下载入口。
8. 审计入口。
## 9. 异常处理
1. 模板不存在:任务失败。
2. 模板字段映射缺失:任务失败。
3. 必填字段缺失:正式版导出拦截。
4. 字段冲突:正式版导出拦截。
5. 高风险未处理:正式版导出拦截。
6. Word 渲染失败:记录导出失败。
7. 版式校验失败:生成文件标记待复核。
## 10. 与后续步骤的接口
飞书通知读取:
1. 导出状态。
2. 下载链接。
3. 被拦截字段。
4. 待复核字段。
5. 责任角色。
## 11. 测试设计
### 11.1 单元测试
1. 模板选择正确。
2. 字段映射加载正确。
3. 必填字段缺失被拦截。
4. 冲突字段被拦截。
5. 占位符替换正确。
6. 残留占位符可识别。
### 11.2 服务层测试
1. 能基于字段池生成 Word 草稿。
2. 高风险存在时禁止正式导出。
3. 导出文件记录生成。
4. 导出报告写入审计。
### 11.3 页面测试
1. 页面展示导出状态。
2. 页面展示下载入口。
3. 页面展示拦截原因。
4. 页面展示版式校验结果。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 维护一个注册申报表格或对照清单模板。
2. 支持字段占位符回填。
3. 支持冲突字段和高风险拦截。
4. 生成新的 `.docx` 文件。
5. 提供下载入口。
6. 写入审计。
增强阶段再补齐:
1. 多模板库管理。
2. PDF 归档件导出。
3. 高保真版式自动校验。
4. 模板版本审批。
5. 后台模板字段映射维护。
## 6. 验收标准
1. 草稿版和正式版策略区分明确。
2. 风险结果能阻断正式导出。
3. 导出结果可直接提供给飞书消息摘要使用。

View File

@@ -2,406 +2,130 @@
## 1. 设计目标
本步骤承接风险预警和 Word 回填导出结果,目标是在飞书群聊或应用会话中完成任务触发、结果摘要查看、责任人通知和 Web 详情跳转,让注册申报审核流程具备 Web 工作台以外的协同入口
本步骤负责在审核任务执行完成或执行异常时,通过飞书 `@` 对应处理人,并回传 Web 详情链接
本步骤需要完成以下业务结果
V1 当前 Demo 固定通知策略
1. 支持飞书群聊机器人触发审核任务。
2. 支持在飞书内选择任务或查看任务摘要。
3. 根据风险项和章节点映射责任角色。
4. 根据责任角色解析飞书账号。
5. 生成飞书消息摘要和 @ 通知载荷。
6. 调用飞书 OpenAPI 或 MCP 工具发送消息。
7. 记录发送结果、失败原因和回传链接。
8. 写入审计留痕。
1. 执行完成后发送结果摘要并 `@` 处理人
2. 执行异常后发送异常摘要并 `@` 处理人
本步骤不执行具体审核规则,不改变风险结论,不直接修改字段池或导出文件。它只负责飞书入口和通知协作。
## 2. 角色信息模型
## 2. 所属模块与边界
责任人信息不再只保留角色名,必须扩展为可通知实体,至少包含:
### 2.1 Chat
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
`apps.chat` 提供 Web 详情页链接和任务执行结果页面,飞书消息中应回传 Web 详情入口。
### 2.2 Agent Core
`agent_core` 负责飞书通知编排、摘要生成、责任人映射和发送载荷构建。
本步骤建议产生以下中文 Skill
1. `飞书通知编排Skill`
2. `飞书任务入口解析Skill`
3. `飞书责任人映射Skill`
4. `飞书消息摘要生成Skill`
5. `飞书消息发送Skill`
6. `飞书回执记录Skill`
### 2.3 MCP / OpenAPI
飞书能力通过两类方式接入:
1. 飞书机器人 / Channel SDK作为群聊触发和消息回传入口。
2. 飞书 OpenAPI MCP作为 Agent 调用飞书消息、文档、群聊和用户信息的工具层。
MCP 只作为外部工具接入层,不承载业务审核逻辑。
### 2.4 Audit
`apps.audit` 记录飞书触发来源、消息载荷、通知对象、发送状态和失败原因。
审计中必须保留:
## 3. 输入
1. `batch_id`
2. `scenario_id`
3. `trigger_source`
4. `feishu_chat_id`
5. `feishu_message_id`
6. `mentioned_user_ids`
7. `notification_payload`
8. `send_status`
9. `error_message`
2. `conversation_id`
3. `product_name`
4. `notify_reason`
5. `registration_risk_report`
6. `registration_word_export_report`
7. `owner_mapping`
## 3. 输入输出
其中 `notify_reason` 固定支持:
### 3.1 输入
1. `task_completed`
2. `task_failed`
```json
{
"batch_id": 1001,
"scenario_id": "registration_risk_report",
"trigger_source": "feishu_group_bot",
"feishu_chat_id": "oc_demo_chat",
"feishu_message_id": "om_demo_message",
"notify_owner": true,
"include_web_link": true
}
```
## 4. 输出对象
### 3.2 输出
`feishu_notification_report` 至少包含:
本步骤输出 `feishu_notification_report`
1. `batch_id`
2. `conversation_id`
3. `notify_reason`
4. `mentioned_users`
5. `message_status`
6. `web_detail_url`
7. `receipt`
```json
{
"report_type": "feishu_notification_report",
"batch_id": 1001,
"send_status": "sent",
"message_type": "interactive_card",
"mentioned_users": [
{
"owner_role": "注册资料负责人",
"feishu_user_id": "ou_demo_owner"
}
],
"web_detail_url": "http://localhost:8000/audit/1001/",
"receipt": {
"message_id": "om_demo_message",
"sent_at": "2026-06-03T10:30:00+08:00"
}
}
```
## 4. 主工作流
## 5. 主流程
```text
飞书群聊或 Web 触发通知
-> 解析飞书触发上下文
-> 读取风险报告和导出报告
-> 解析责任角色和飞书账号
-> 生成飞书消息摘要
-> 构建消息卡片和 @ 通知
-> 调用飞书 OpenAPI / MCP 发送
-> 记录发送回执
-> 写入审计留痕
-> 返回发送结果
任务完成或异常
-> 读取责任角色与飞书账号
-> 构建飞书摘要
-> 构建 @ 处理人载荷
-> 发送飞书消息
-> 写回发送回执
-> 写入处理历史和审计
```
## 5. 节点详细设计
## 6. 通知内容要求
### 5.1 节点一:飞书任务入口解析
飞书消息至少应包含:
业务功能:
1. 任务名称
2. 产品名称
3. 批次号
4. 结果状态
5. 风险等级或异常摘要
6. `@` 处理人
7. Web 详情链接
1. 解析飞书消息事件。
2. 识别任务指令。
3. 识别批次 ID 或项目上下文。
4. 将飞书触发请求映射到系统场景。
## 7. 处理完成通知
支持指令示例
触发条件
1. `检查资料完整性`
2. `生成风险报告`
3. `查看导出结果`
4. `通知责任人`
1. 目录汇总完成
2. 风险报告完成
3. 导出状态已生成
使用技术
输出重点
1. 飞书事件订阅
2. 群聊机器人消息
3. Django webhook view
4. 场景配置 YAML
1. 风险等级
2. 是否允许正式导出
3. 责任人
产生方法:
## 8. 执行异常通知
1. `parse_feishu_event(event_payload) -> FeishuTriggerContext`
2. `resolve_scenario_from_command(text) -> str`
3. `resolve_batch_from_message(text, chat_context) -> int | None`
触发条件:
对应 Skill
1. 资料解析失败
2. 规则执行失败
3. 回填导出失败
4. 外部依赖异常
1. `飞书任务入口解析Skill`
输出重点:
### 5.2 节点二:通知上下文加载
1. 异常阶段
2. 异常摘要
3. 责任人
4. 是否建议人工介入
业务功能:
## 9. 与页面关系
1. 读取风险报告。
2. 读取 Word 导出报告。
3. 读取审计详情链接。
4. 读取任务状态。
### 9.1 审核智能体
使用技术
可展示
1. Django ORM
2. JSONField
3. URL reverse
1. 本次是否已触发飞书通知
2. 飞书发送状态
3. `@` 的处理人
产生方法:
### 9.2 处理历史
1. `load_notification_context(batch_id, scenario_id) -> NotificationContext`
2. `build_web_detail_url(report_or_audit) -> str`
3. `collect_notification_sources(context) -> list[dict]`
可回看:
对应 Skill
1. 通知原因
2. 接收人
3. 消息状态
4. Web 回链
1. `飞书通知编排Skill`
## 10. 验收标准
### 5.3 节点三:责任人映射
业务功能:
1. 根据风险项、章节点和任务类型确定责任角色。
2. 根据责任角色查找飞书账号。
3. 支持后台或配置文件手动维护。
4. 对未配置账号的责任角色给出提示。
建议配置:
```yaml
owners:
CH1:
owner_role: 注册资料负责人
feishu_user_id: ou_demo_owner
field_conflict:
owner_role: 注册资料负责人
feishu_user_id: ou_demo_owner
missing_required_document:
owner_role: 注册申报负责人
feishu_user_id: ou_demo_registration_owner
```
使用技术:
1. YAML 配置
2. Django Admin 维护表
3. 飞书用户 ID
产生方法:
1. `resolve_owner_roles(risk_items) -> list[OwnerRole]`
2. `load_owner_mapping() -> OwnerMapping`
3. `resolve_feishu_user_ids(owner_roles, mapping) -> list[FeishuMentionTarget]`
对应 Skill
1. `飞书责任人映射Skill`
### 5.4 节点四:飞书消息摘要生成
业务功能:
1. 生成风险摘要。
2. 生成高风险列表。
3. 生成导出结果摘要。
4. 生成 Web 详情链接。
5. 生成飞书交互卡片或纯文本消息。
消息内容:
1. 当前任务名称。
2. 批次名称。
3. 是否通过。
4. 最高风险等级。
5. 高风险数量。
6. 待复核数量。
7. 责任人 @。
8. Web 详情入口。
9. 导出文件链接。
使用技术:
1. 飞书互动卡片 JSON
2. 文本消息模板
3. 可选 LLM 摘要
产生方法:
1. `build_feishu_summary(context) -> FeishuSummary`
2. `build_interactive_card(summary, mentions) -> dict`
3. `build_text_message(summary, mentions) -> dict`
对应 Skill
1. `飞书消息摘要生成Skill`
### 5.5 节点五:飞书消息发送
业务功能:
1. 调用飞书 OpenAPI 或 MCP 发送消息。
2. 支持群聊消息。
3. 支持 @ 指定责任人。
4. 捕获发送结果和失败原因。
使用技术:
1. 飞书 OpenAPI
2. 飞书机器人
3. MCP 工具封装
4. 请求重试
产生方法:
1. `send_feishu_message(chat_id, payload) -> FeishuSendResult`
2. `send_feishu_card(chat_id, card_payload) -> FeishuSendResult`
3. `retry_send_on_transient_error(request) -> FeishuSendResult`
对应 Skill
1. `飞书消息发送Skill`
### 5.6 节点六:飞书回执记录
业务功能:
1. 保存发送状态。
2. 保存消息 ID。
3. 保存通知对象。
4. 保存失败原因。
5. 写入审计。
使用技术:
1. Django ORM
2. Audit 服务
3. JSONField
产生方法:
1. `record_feishu_receipt(result, context) -> FeishuNotificationRecord`
2. `build_feishu_audit_payload(record, context) -> dict`
3. `record_feishu_audit(payload) -> AuditLog`
对应 Skill
1. `飞书回执记录Skill`
## 6. Skill 清单
本步骤产生以下 Skill 设计文档:
1. [飞书通知编排Skill](skill/飞书通知编排Skill.md)
2. [飞书任务入口解析Skill](skill/飞书任务入口解析Skill.md)
3. [飞书责任人映射Skill](skill/飞书责任人映射Skill.md)
4. [飞书消息摘要生成Skill](skill/飞书消息摘要生成Skill.md)
5. [飞书消息发送Skill](skill/飞书消息发送Skill.md)
6. [飞书回执记录Skill](skill/飞书回执记录Skill.md)
## 7. 消息类型设计
| 类型 | 使用场景 |
|---|---|
| `text` | 简单摘要和失败提示 |
| `interactive_card` | 风险报告摘要、按钮和详情链接 |
| `mention_text` | @ 责任人 |
## 8. 页面与飞书展示
飞书消息建议展示:
1. 任务名称。
2. 批次名称。
3. 最高风险等级。
4. 是否通过。
5. 高风险摘要。
6. 待人工复核事项。
7. 责任人 @。
8. Web 详情链接。
9. 导出文件链接。
## 9. 异常处理
1. 飞书事件验签失败:拒绝请求。
2. 无法识别任务指令:返回帮助提示。
3. 未找到批次:返回业务提示。
4. 责任人未配置:发送群消息但不 @。
5. 飞书 API 失败:记录失败回执,可重试。
6. Web 链接生成失败:消息中省略链接并记录警告。
## 10. 与全流程接口
本步骤读取:
1. `registration_risk_report`
2. `registration_word_export_report`
3. 审计详情链接
4. 责任人映射配置
本步骤输出:
1. `feishu_notification_report`
2. 飞书消息 ID
3. 通知状态
4. 审计记录
## 11. 测试设计
### 11.1 单元测试
1. 飞书事件解析。
2. 任务指令解析。
3. 责任人映射。
4. 消息摘要构建。
5. 发送 payload 构建。
### 11.2 服务层测试
1. 风险报告可生成飞书卡片。
2. 责任人可被 @。
3. 未配置责任人时仍能发送摘要。
4. API 失败时记录失败回执。
5. 审计记录包含飞书来源。
### 11.3 集成测试
1. 群聊机器人触发风险报告查看。
2. 群聊机器人发送责任人通知。
3. Web 链接可跳转到详情页。
## 12. V1 实现建议
V1 建议先完成以下最小闭环:
1. 支持飞书群聊机器人接收指令。
2. 支持发送风险报告摘要。
3. 支持责任人手动配置和 @。
4. 支持 Web 详情链接。
5. 支持发送回执和审计记录。
增强阶段再补齐:
1. 飞书应用会话菜单。
2. 文档评论区 @bot
3. 多维表格同步。
4. 飞书文档写回。
5. 消息卡片交互按钮。
1. 角色信息包含飞书账号相关字段。
2. 执行完成与执行异常两类通知链路完整。
3. 飞书消息支持直接 `@` 对应处理人。
4. 通知结果可在处理历史和审计中回溯。

View File

@@ -123,19 +123,33 @@
为了后续 Demo 更贴合复试陈述,建议整个系统的业务主线固定为:
1. 导入一批注册资料
2. 系统自动形成申报目录与页数清单
3. 系统根据法规目录检查缺失项、错放项、待补项
4. 系统抽取产品核心信息并形成字段总表
5. 系统检查说明书、申请表、产品列表、声明文件之间的一致性
6. 系统输出风险清单、建议动作以及需要人工确认的问题
如果演示时间允许,建议再增加一个“法规依据展示”环节:
7. 系统展示当前完整性结论所依据的公告附件、资料要求和模板来源。
1. `审核智能体` 中上传一批注册资料或直接进入一个已有资料包
2. 系统解析资料包,识别产品名称,并以该产品名称创建或绑定对话会话
3. 系统自动形成申报目录、页数清单和章节点识别结果
4. 系统根据法规目录检查缺失项、错放项、待补项
5. 系统抽取产品核心信息并形成字段总表
6. 系统检查说明书、申请表、产品列表、声明文件之间的一致性
7. 系统输出风险清单、建议动作以及需要人工确认的问题。
8. 在对话中返回命中的法规依据、模板约束和资料来源说明。
导入环节已确认支持两种演示路径:一是直接批量上传样例文件,二是上传包含多级目录的压缩包并由系统自动解压。压缩包格式覆盖 `zip``rar``7z`,其中 `rar``7z` 必须采用纯 Python 实现,允许增加第三方 Python 包依赖,以便后续部署到服务器时不依赖系统级解压工具。
## 6.1 顶层产品形态补充说明
最新版原型已固定顶层 4 个入口:
1. `审核智能体`
2. `资料包`
3. `知识库`
4. `处理历史`
其中:
1. `审核智能体` 是唯一主执行入口,以对话和节点式结果承接完整审核流程。
2. `资料包` 负责按产品名称管理资料集合,并支持按产品名称或批次号搜索。
3. `知识库` 统一承载法规资料、业务资料和 RAG 治理能力,因此“法规依据”不再单独成页。
4. `处理历史` 负责按批次、按产品回看任务执行与通知留痕。
## 7. 已确认事项
以下内容已根据最新沟通结果确认,并已同步进入后续模块需求:
@@ -180,6 +194,9 @@
- `rar``7z` 解压必须采用纯 Python 实现,允许增加第三方依赖包。
- 责任人先通过后台或配置文件手动维护,按资料章节配置责任人。
- 系统需要自动提取产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息,并自动填入注册申报表格或对照清单。
- 资料包记录必须与对话会话绑定。
- 对话标题默认采用解析后的 `product_name`
- `资料包` 页面必须提供按产品名称搜索入口,并支持从资料包跳转到关联会话。
### 7.6 输出文档形式
@@ -257,6 +274,9 @@
16. 责任人首版按资料章节手动配置。
17. 第 2 至第 6 章首版不补充企业样本,按公告附件包做规则级初步确认。
18. 产品核心信息抽取后必须自动填入注册申报表格或对照清单。
19. 顶层导航固定为 `审核智能体 / 资料包 / 知识库 / 处理历史`
20. 资料包与会话一一关联,对话标题取解析后的产品名称。
21. “法规依据”作为知识库中的法规资料与 RAG 命中结果呈现,不再单列顶级页面。
## 10. 结论

View File

@@ -2,199 +2,252 @@
## 1. 文档目的
本文档作为当前项目 V1 阶段的需求索引文档,用于统一说明本轮笔试题对应的产品定位、目标用户、核心业务闭环、模块拆分方式和后续阅读路径
本文档用于基于最新版演示原型、现有原始材料和项目边界,重新定义 V1 阶段的需求基线
与历史“通用 AI Agent Demo 框架”定位不同,本轮 V1 需求以 `docs/` 目录中的真实题面与资料样本为准,系统目标已经切换为
当前系统不再按“传统审核系统多页面菜单”叙事,而是明确采用
> 试剂盒临床注册文件准备与审核智能体平台
> 以 Agent 对话为核心的试剂盒临床注册文件准备与审核平台
V1 需要同时满足三类目标:
1. 能用对话驱动审核任务。
2. 能把资料包、知识库、审核记录和飞书通知纳入统一闭环。
3. 能以可讲解、可追溯、可扩展的方式支撑后续 Django 实现。
## 2. 产品定位
本系统面向 **NMPA 境内第三类体外诊断试剂注册申报资料准备与审核** 场景,服务于需要整理、核查、抽取、回填和追踪注册资料的业务人员。
本系统面向 NMPA 境内第三类体外诊断试剂注册申报资料准备与审核场景,服务对象包括:
系统主体围绕注册申报审核场景展开,但能力目标是沉淀为“通用的试剂盒临床注册文件准备与审核智能体”,而不是只绑定某一个具体试剂盒产品。
1. 注册申报专员
2. 注册资料负责人
3. 质量/法规专员
4. 审核复核人员
5. 平台治理管理员
系统不再以“适配任意业务题”的通用 Demo 作为对外主叙事,而是聚焦以下业务价值:
产品不再只是“上传文件后生成报表”的审核系统,也不只是“自由聊天的问答机器人”,而是一个围绕资料包审核任务编排的 Agent 工作台。
1. 自动汇总注册资料目录与页数。
2. 对照法规要求检查资料完整性。
3. 抽取产品关键信息并形成统一字段池。
4. 支持将产品核心信息自动填入注册申报表格或对照清单。
5. 核查跨文档信息一致性与章节规范性。
6. 输出合规风险预警和处理建议。
## 3. 最新版原型对应的产品形态
## 3. 原始材料依据
基于当前最新版原型V1 顶层产品形态固定为 4 个一级入口:
当前需求分析主要基于以下材料整理:
1. `审核智能体`
2. `资料包`
3. `知识库`
4. `处理历史`
1. `docs/【模拟题二】试剂盒临床注册文件准备与审核Agent/【模拟题二】试剂盒临床注册文件准备与审核Agent.md`
2. `docs/目标产品说明书.docx`
3. `docs/附件 4 体外诊断试剂注册申报资料要求及说明.doc`
4. `docs/第1章 监管信息/` 下的监管目录、申请表、产品列表、声明与沟通记录样例
5. `docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/` 下的公告附件包
这 4 个入口分别承担:
其中新增公告附件包使法规依据不再只是单篇“资料要求说明”,而是扩展为一组正式规则来源,包括:
### 3.1 审核智能体
1. 体外诊断试剂注册申报资料要求及说明
2. 医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂)
3. 体外诊断试剂安全和性能基本原则清单
4. 中华人民共和国医疗器械注册证(体外诊断试剂)格式
5. 体外诊断试剂变更备案 / 变更注册申报资料要求及说明
6. 体外诊断试剂延续注册申报资料要求及说明
作为统一任务入口,承接:
当前 V1 默认以“公告附件包”作为主规则来源,并将 `附件 4 体外诊断试剂注册申报资料要求及说明` 视作同源补充材料,而不是独立的第二套规则来源。
1. 资料上传
2. 自然语言提问
3. 任务模板选择
4. 节点式审核执行
5. 结构化结论查看
6. 风险与整改建议输出
## 4. V1 范围
### 3.2 资料包
V1 聚焦“可运行、可讲解、可演示”的注册资料审核闭环,不追求一次性做成完整商业平台。
作为资料治理入口,承接:
### 4.1 V1 必须覆盖
1. 资料包导入
2. 按产品名称建立资料包记录
3. 资料包与对话会话关联
4. 目录、页数、章节点、异常结果查看
5. 按产品名称或批次号搜索资料包
1. 资料上传与管理
2. 资料包导入、压缩包解包、文件目录与页数汇总
3. 法规完整性检查
4. 产品关键信息抽取
5. 跨文档一致性核查
6. 风险预警输出
7. 审计留痕
8. 本地可运行与 Docker 演示启动
### 3.3 知识库
其中第 3 项“法规完整性检查”在 V1 中建议明确分为三层
作为治理入口,承接
1. 资料齐套性检查
2. 章节点和结构合规性检查
3. 批准证明文件格式与输出映射检查
1. 法规资料上传
2. 业务资料上传
3. RAG 文档源管理
4. 切片解析与向量入库
5. 字段 Schema 管理
6. 模板映射管理
7. 飞书通知配置与责任人映射
### 4.2 V1 可接受的简化
其中“法规依据”不再单独作为顶级页面存在,而是作为知识库中的法规资料,通过 RAG 命中在 Agent 对话中返回。
1. 首版可优先覆盖第 1 章监管信息,并为全章扩展预留结构。
2. 首版即要求具备“生成新的 Word 文档并支持导出”的能力,且输出版式必须达到可直接报送级别。
3. 首版法规校验可以本地规则为主,不强依赖联网抓取最新法规。
4. 首版需要支持飞书内完成任务选择、结果查看和责任人通知,并支持群聊机器人入口及手动维护责任人 / 飞书账号映射。
5. 首版法规任务边界以“注册申报”主流程为核心,变更备案和延续注册暂作为规则扩展方向。
6. DOCX 页数必须精确统计,不能以估算页数作为 V1 验收结果DOC 如受格式限制无法精确统计,应标记为待复核。
7. 回填目标已确认为注册申报表格或对照清单,首版应输出结构化回填结果,并支持按模板生成 Word 文件。
### 3.4 处理历史
## 5. 业务闭环
作为留痕入口,承接:
建议按以下业务闭环理解整套系统:
1. 按批次回看历史任务
2. 查看资料规模和任务类型
3. 查看最终结论和风险状态
4. 查看关联会话、附件和摘要
5. 支撑审计与复核
1. 导入注册申报资料。
2. 识别文档、统计页数、构建目录。
3. 依据法规目录进行完整性核查。
4. 从说明书、申请表、产品列表等材料中抽取统一字段。
5. 将产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息自动填入注册申报表格或对照清单。
6. 对同名字段进行跨文档一致性比对。
7. 形成风险清单、回填结果和审计记录。
## 4. 原始材料依据
在规则执行层,建议采用“双层知识底座”
当前需求分析以以下资料为主
1. 结构化规则文件负责完整性判断、强一致比对和风险映射。
2. 公告附件原文切片入 RAG负责条款引用、证据检索和解释说明。
1. `docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx`
2. `docs/原始材料/目标产品说明书.docx`
3. `docs/原始材料/附件 4 体外诊断试剂注册申报资料要求及说明.doc`
4. `docs/原始材料/第1章 监管信息/` 下样例资料
5. `docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/` 下公告附件包
6. 最新版原型文件 `docs/原型设计/registration-prototype-demo.html`
其中法规知识维护方式应固定为:
## 5. V1 核心业务闭环
1. 按“章 -> 条 -> 要求项 -> 模板字段”四级结构维护。
2. 同步建设结构化规则文件,避免让完整性校验完全依赖检索文本。
3. 提供后台管理页面,支持人工校订和知识库更新。
V1 需要围绕以下闭环展开:
资料导入层需要按“资料包”而不是“单文件”设计。V1 至少应支持批量文件上传、文件夹导入和压缩包导入能力。压缩包导入支持 `zip``rar``7z`,解包后保留原始相对路径,并将压缩包内多层目录按原目录作为章节点识别依据。`rar``7z` 解压必须采用纯 Python 实现,允许增加第三方依赖包,避免服务器部署时依赖系统级解压工具
1. 导入资料包
2. 解析资料包并识别产品名称。
3. 以解析后的产品名称建立资料包记录和对话会话。
4. 汇总目录、页数和章节点。
5. 基于法规规则和 RAG 证据执行完整性检查。
6. 抽取统一字段池并执行一致性核查。
7. 汇总风险并给出是否允许正式导出的结论。
8. 在任务完成或出现异常时,通过飞书 `@` 对应处理人。
9. 将过程结果写入处理历史与审计留痕。
第 2 至第 6 章首版不补充企业真实样本,先以公告附件包进行资料要求、章节点结构和模板口径的规则级初步确认。责任人首版通过后台或配置文件手动维护,并按资料章节配置。
## 6. V1 必须覆盖的能力
在法规维度上,建议把完整流程理解为:
### 6.1 Agent 对话驱动
1. 识别当前审核任务属于“注册申报”主流程
2. 匹配对应的资料要求与章节点模板
3. 检查资料齐套性与章节结构
4. 对需要回填或输出的批准证明文件格式做字段映射准备
1. 支持通过对话发起审核任务
2. 支持上传附件并结合当前会话继续执行
3. 支持推荐提示词模板
4. 支持以节点方式展示目录汇总、完整性检查、字段抽取、一致性核查和风险结论
## 6. 模块拆分
### 6.2 资料包中心
V1 需求分析按项目现有主模块拆分,不做过度细分:
1. 支持批量文件、文件夹、压缩包导入。
2. 支持资料包与会话一一关联。
3. 对话名称默认采用解析后的产品名称。
4. 支持按产品名称和批次号搜索资料包。
5. 支持从资料包跳转回对应对话。
1. [1.config模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\1.config模块需求分析.md)
2. [2.scenarios模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\2.scenarios模块需求分析.md)
3. [3.documents模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\3.documents模块需求分析.md)
4. [4.chat模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\4.chat模块需求分析.md)
5. [5.audit模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\5.audit模块需求分析.md)
6. [6.agent_core模块需求分析.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\6.agent_core模块需求分析.md)
### 6.3 知识库与 RAG
另附一份待确认事项文档,供与需求方沟通时直接使用:
1. 支持法规资料和业务资料分层管理。
2. 支持 RAG 文档源 CRUD。
3. 支持切片解析、重建向量和召回状态管理。
4. 支持在对话中返回法规命中依据。
5. 支持字段 Schema、模板映射、责任人映射和飞书配置治理。
- [0.需求重构总览与待确认事项.md](F:\PyCharm\DEMO-AGENT\docs\需求分析\0.需求重构总览与待确认事项.md)
### 6.4 风险与导出
## 7. 当前识别出的关键业务特征
1. 支持完整性风险、字段冲突风险、混档风险、低置信度风险统一汇总。
2. 支持草稿导出与正式导出阻断。
3. 支持结构化整改建议。
### 7.1 审核对象是“资料包”
### 6.5 飞书协同
本题输入对象是整套注册申报资料,不是单篇文档问答
1. 支持飞书群聊/机器人通知
2. 风险任务执行完成或执行异常时,可直接 `@` 处理人。
3. 责任人信息中必须包含飞书账号相关字段。
4. 支持通过 Web 详情链接回到平台查看完整结果。
### 7.2 审核标准是“法规目录 + 资料内容”
## 7. 关键业务对象
系统既要看是否有文件,也要看是否放对章节点、内容是否对应。
### 7.1 资料包
### 7.3 系统必须具备“冲突识别”
资料包是 V1 的一级业务对象,不再把“单文件”视为默认审核对象。
当前样例中已经存在不同产品资料混入的迹象。这不要求系统默认把全部材料视为同一个产品,而是要求系统具备以下能力
资料包至少包含
1. 支持按项目批次或文档范围界定审核对象。
2. 对被划入同一审核对象的资料执行严格一致性检查。
3. 对混档、错归类和跨产品资料混入给出风险提示。
1. `batch_id`
2. `product_name`
3. `conversation_id`
4. `workflow_type`
5. `file_count`
6. `page_count`
7. `chapter_summary`
8. `import_status`
### 7.4 系统必须具备“可解释性”
### 7.2 对话会话
所有缺失判断、字段抽取和风险预警都应尽量有证据、有来源、有审计记录。
会话与资料包绑定,至少包含:
### 7.5 系统必须具备“法规分层引用”
1. `conversation_id`
2. `product_name`
3. `batch_id`
4. `task_status`
5. `messages`
6. `node_results`
结合新增公告附件包,系统应能区分并引用不同层级的规则来源,而不是把所有法规依据混成一条说明:
### 7.3 角色信息
1. 资料要求类依据
2. 格式要求类依据
3. 安全和性能基本原则类依据
4. 批准证明文件格式类依据
角色信息需要从“只有角色名”升级为“可通知的责任人实体”,至少包含:
### 7.6 系统需要具备“多入口访问能力”
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
V1 除 Web 工作台外,还需要实际支持飞书入口能力,使审核任务可以从浏览器工作台扩展到飞书会话和飞书群聊机器人场景。
### 7.4 飞书通知任务
### 7.7 系统需要具备“后台治理能力”
飞书通知对象至少包含:
除前台审核能力外V1 还需要提供后台管理能力,用于维护规则包、模板库、责任人映射和知识库更新入口。
1. `batch_id`
2. `conversation_id`
3. `trigger_source`
4. `notify_reason`
5. `owner_role`
6. `feishu_user_id`
7. `message_status`
8. `web_detail_url`
## 8. 后续文档与实现衔接建议
## 8. 需求约束
后续若继续推进设计与开发,建议按如下顺序展开:
### 8.1 规则优先
1. 先确认待确认事项中的产品范围、回填目标和法规范围
2. 基于模块需求文档输出设计文档。
3.`config -> scenarios -> documents -> agent_core -> chat -> audit` 顺序推进重构。
4. 同步更新 README、AGENTS 和场景配置命名。
完整性检查、强一致判断、导出阻断必须规则优先LLM 只做解释和补充
当前仓库已补充一组演示型原型设计文档与单文件 HTML作为设计到实现之间的中间层交付
### 8.2 RAG 职责边界
- [整体原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.整体原型设计.md)
- [分页原型设计目录](F:\PyCharm\DEMO-AGENT\docs\原型设计)
- [单文件演示站 HTML](F:\PyCharm\DEMO-AGENT\docs\原型设计\registration-prototype-demo.html)
RAG 负责:
该组原型资产覆盖:
1. 法规依据定位
2. 业务资料证据定位
3. 对话解释支撑
1. 资料包导入页
2. 审核任务工作台
3. 法规完整性检查页
4. 字段抽取与字段池页
5. 一致性核查页
6. 风险预警页
7. Word 回填导出页
8. 飞书通知视图
9. 知识库与治理台 CRUD 设计
RAG 不直接代替规则引擎输出最终合规结论。
## 9. 结论
### 8.3 飞书通知触发方式
当前 V1 需求已经从“通用 Agent Demo 基座”重构为“注册申报资料审核系统”。后续所有设计、实现和讲解,建议都围绕以下四个关键词展开
当前 Demo 要求固定支持两类通知时机
1. 文件夹级资料治理
2. 法规目录级完整性校验
3. 统一字段池与跨文档一致性检查
4. 可追溯的风险预警与审计留痕
1. 任务执行完成后发送结果摘要并 `@` 处理人。
2. 任务执行异常后发送异常摘要并 `@` 处理人。
### 8.4 本地可演示
V1 必须保持:
1. Web 可访问
2. 关键流程可离线演示
3. 不依赖真实大模型在线调用才能完成基础演示
## 9. 模块拆分建议
仍按现有模块边界推进,但职责解释需要更新:
1. `config`:配置、静态资源、环境变量、飞书集成基础配置
2. `documents`:资料包、文档、页数、目录、章节点、资料搜索
3. `chat`Agent 对话工作台、节点执行区、提示词模板、会话历史
4. `audit`:处理历史、审计链路、通知留痕
5. `agent_core`审核编排、规则执行、RAG 检索、结构化输出
## 10. 本轮重写结论
V1 现在的正确叙事应是:
1. 以资料包为审核对象。
2. 以 Agent 对话为主要执行入口。
3. 以知识库/RAG 为法规和业务依据底座。
4. 以处理历史和飞书协同保证可追溯和可协同。
这意味着后续需求分析和详细设计都应围绕“Agent 工作台式产品”继续重写,而不再沿用旧的固定报表式多页面系统思路。

View File

@@ -28,6 +28,13 @@
- 压缩包处理链路需要支持 `zip``rar``7z`,其中 `rar``7z` 必须使用纯 Python 依赖实现,不能依赖服务器系统级解压工具。
- 审计数据不能只保留“问答日志”,还要能关联具体资料批次和审核任务。
但与此同时,最新版原型已经明确本系统不是“传统多页面审核后台”,而是“以 Agent 对话为核心、资料包为主要业务对象”的产品形态。因此配置层不仅要支撑资料处理和规则加载,还要支撑以下对象级配置:
- 资料包与会话绑定规则
- 会话标题生成规则
- 资料包列表默认搜索字段
- 顶层导航与功能开关
### 3.2 Demo 与真实业务之间要有明确边界
复试时允许 Demo 版做适度简化,但配置层必须明确什么是“演示默认值”,什么是“未来真实化扩展口”。否则系统很容易出现代码里写死演示路径、文档目录、模型名称、法规版本的情况,后续一改题就要整体返工。
@@ -130,6 +137,17 @@
- `CHROMA_PATH`
向量库目录。
建议增加:
- `CONVERSATION_TITLE_SOURCE`
会话标题来源。V1 默认使用解析后的 `product_name`
- `MATERIAL_SEARCH_FIELDS`
资料包列表默认搜索字段。V1 默认包含 `product_name,batch_id`
- `TOP_LEVEL_TABS`
顶层页面开关。V1 默认固定为 `chat,materials,knowledge,history`
### 5.2 模型级配置项
- `LLM_PROVIDER`
@@ -266,11 +284,11 @@ admin/
### 7.1 与 Documents 模块的边界
`config` 只负责定义上传目录、抽取目录、导出目录和允许格式,不负责具体解析逻辑。
`config` 只负责定义上传目录、抽取目录、导出目录、搜索字段和允许格式,不负责具体解析逻辑。
### 7.2 与 Agent Core 的边界
`config` 负责提供模型参数、向量库参数、规则目录和功能开关,不负责具体提示词、抽取模板和审核规则实现。
`config` 负责提供模型参数、向量库参数、规则目录、会话标题规则和功能开关,不负责具体提示词、抽取模板和审核规则实现。
### 7.3 与 Audit 模块的边界
@@ -333,6 +351,7 @@ admin/
6. 增加 Word 导出目录和飞书应用接入相关配置。
7. 增加模板库目录、规则管理目录和责任人映射配置。
8. 增加纯 Python 压缩包解包依赖与策略配置,覆盖 `zip``rar``7z`
9. 增加资料包与会话绑定、会话命名和资料包搜索字段相关配置。
## 11. 本模块验收标准

View File

@@ -8,6 +8,8 @@
也就是说,系统仍然可以保留配置化入口,但配置的对象不再是“知识问答、工单助手、财务审核”等通用 Agent而是围绕注册申报业务拆分的若干子任务或工作模式。
需要特别说明的是:最新版原型已经把顶层导航收敛为 `审核智能体 / 资料包 / 知识库 / 处理历史`,因此 `apps.scenarios` 不再承担“首页大卡片导航中心”的产品角色,而是退回为 Agent 工作台中的任务模板与流程节点配置中心。
## 2. 重构目标
本模块需要完成以下目标:
@@ -16,6 +18,7 @@
2. 用配置化方式定义不同审核任务的目标、输入、输出和可调用能力。
3. 支撑后续 Demo 演示时快速切换不同任务视角,而不是频繁改代码。
4. 保持与 `agent_core` 的边界清晰,即场景模块只定义任务,不实现任务逻辑。
5. 支撑 `审核智能体` 页面中的预设提问模板、流程节点和执行卡片实时更新。
结合新增公告材料,场景模块还应承担“法规流程类型选择器”的角色,避免把注册申报、变更备案、延续注册三类任务混成一个入口。
@@ -55,11 +58,11 @@
### 4.2 是否需要多个场景还是一个总场景
建议首版保留“一个主场景 + 若干子任务配置”的思路:
建议首版保留“一个主 Agent 会话 + 若干子任务配置”的思路:
- 页面上可以展示多个任务入口。
- 顶层页面不再展示多个独立业务页面,而是在 `审核智能体`展示多个任务模板与节点入口。
- 底层可共享同一个项目资料池和统一字段池。
- Demo 演示时可以先“完整性检查”,再点“一致性核查”,展示系统分工明确
- Demo 演示时可以先触发“目录汇总”,再继续执行“完整性检查”“一致性核查”,展示 Agent 沿节点推进
## 5. 场景配置应包含的内容
@@ -106,9 +109,9 @@
## 6. 页面层需求
### 6.1 首页需要表达的内容
### 6.1 `审核智能体` 页面需要表达的内容
页不应只展示“这是几个 YAML 场景”,而应展示当前系统已支持的注册审核任务。建议每个任务卡片至少包含:
`审核智能体` 页不应只展示“这是几个 YAML 场景”,而应展示当前系统已支持的注册审核任务。建议每个任务卡片至少包含:
- 任务名称
- 任务目标
@@ -125,7 +128,7 @@
- 已上传未入库目录汇总可执行RAG 相关任务应提示需先入库。
- 规则文件缺失:完整性检查场景显示不可执行或部分能力不可用。
这类状态说明非常适合复试演示,因为它体现了系统不是“死调用大模型”,而是有明确任务前置条件。
这类状态说明非常适合复试演示,因为它体现了系统不是“死调用大模型”,而是有明确任务前置条件,并能驱动右侧任务卡片从“待执行”实时更新为“解析中”“已完成”“待复核”等状态
## 7. 配置驱动的业务能力
@@ -206,7 +209,7 @@
- 责任归属字段
- 飞书通知目标字段
### 7.6 法规依据展示任务或结果区
### 7.6 法规依据展示结果区
基于新增公告材料,建议在任务配置中增加法规依据展示能力,至少能让系统说明:
@@ -214,6 +217,12 @@
2. 使用了哪份资料要求说明。
3. 使用了哪份格式要求或批准证明文件模板。
但该能力不应再通过单独一级“法规依据页”承载,而应:
1. 作为知识库中的法规资料被维护。
2. 在对话结果中以 RAG 命中依据卡片返回。
3. 在任务节点详情或右侧能力卡中展示引用来源。
## 8. 与原始材料对应的任务设计要点
### 8.1 要能体现“资料结构化目录”的任务价值
@@ -249,7 +258,7 @@
### 9.1 与 Chat 模块的边界
场景模块定义任务卡片和执行入口Chat 模块负责用户进入任务后的具体交互与结果展示。
场景模块定义任务模板、节点配置和执行入口Chat 模块负责用户进入任务后的具体交互与结果展示。
### 9.2 与 Documents 模块的边界
@@ -294,6 +303,7 @@ configs/registration/
3. 任务配置变更主要通过 YAML 完成,不需要频繁改 Python 代码。
4. 至少能清楚区分“目录汇总、完整性检查、字段抽取、一致性核查、风险预警”五类任务。
5. 字段抽取任务必须能表达“抽取核心信息并自动填入注册申报表格或对照清单”的输出目标。
6. 任务配置能够驱动会话中的节点式执行和右侧状态卡实时更新。
## 12. 当前代码基线下的重构建议
@@ -313,7 +323,7 @@ configs/registration/
如果场景模块重构到位,复试时可以很自然地说明:
1. 为什么不是把所有能力塞进一个聊天机器人
1. 为什么虽然以对话为核心,但仍然保留配置化任务模板和节点编排
2. 为什么用配置驱动的任务入口更适合监管审核场景。
3. 如何在不推翻现有系统结构的情况下,快速从通用 Demo 切换到具体业务题。

View File

@@ -2,406 +2,117 @@
## 1. 模块定位
`apps.documents` 是本题最关键的业务入口之一。对于“试剂盒临床注册文件准备与审核”场景,它不只是一个上传附件页面,而是:
`apps.documents` 不再只是“文件上传页”,而是:
> 注册申报资料治理中心
> 资料包中心
该模块需要承接从资料接收、文件识别、内容抽取、章节点归类、页数统计、入库索引到状态反馈的完整过程
它负责让资料包成为可查询、可绑定会话、可参与审核、可进入知识库和可触发通知的一级业务对象
## 2. 业务目标
## 2. 模块目标
本模块需要支撑以下真实业务目标:
本模块需要支撑以下目标:
1. 接收注册申报资料包中的各类文件
2. 建立每份文件的结构化档案
3. 自动形成目录汇总和页数统计结果
4. 为法规完整性核查和一致性核查提供可靠的文档底座
5. 为抽取、回填、审计和导出提供统一的文档主数据
1. 接收资料包导入
2. 识别资料包产品名称
3. 统计文件数、页数和章节点
4. 将资料包与对话记录绑定
5. 支持按产品名称和批次号搜索资料包
6. 为后续完整性检查、字段抽取和一致性核查提供文档底座。
结合新增公告材料,本模块还应承担“法规原文资料资产管理”的基础职责,即把上传的业务资料与平台内置的法规依据材料区分管理。
## 3. 资料包优先原则
## 3. 为什么 Documents 模块是本题核心
V1 以资料包为主对象,而不是单文件。
题面第一条就要求“自动汇总注册申报文件夹中的所有文件及页数”,第二条要求“对照 NMPA 法规要求核查文件完整性”。这两个要求都建立在一个前提上
资料包记录至少需要
系统必须先“看懂当前资料包里到底有什么”。
1. `batch_id`
2. `product_name`
3. `conversation_id`
4. `workflow_type`
5. `file_count`
6. `page_count`
7. `chapter_summary`
8. `import_status`
9. `exception_count`
因此Documents 模块不是配角,而是全流程的第一责任模块。
## 4. 产品名称解析要求
## 4. 核心职责
资料包导入后需要尽早解析产品名称,用于:
### 4.1 原始文件接收
1. 生成会话标题
2. 建立资料包搜索索引
3. 支持同产品批次管理
4. 作为后续字段抽取和一致性核查的主键之一
支持上传和保存
首版产品名称优先来源
- PDF
- DOCX
- DOC
- MD
- TXT
1. 申请表
2. 说明书
3. 产品列表
必要时为后续 OCR 或图片扫描件预留扩展位
若多个来源冲突,可先取主来源值建立会话标题,同时把冲突标记给后续一致性核查处理
结合题面“自动汇总文件夹文件目录与页数”的要求Documents 模块还需要支持资料包级导入,而不是只支持单文件:
## 5. 支持的导入能力
- 多文件批量上传
- 文件夹选择或拖拽上传
- 压缩包上传并自动解包
1. 多文件上传
2. 文件夹导入
3. 压缩包导入
4. 批次追加上传
压缩包覆盖 `zip``rar``7z` 等常见格式。解包后应保留压缩包内的原始相对路径,并将多层目录按原目录作为章节点识别依据,用于还原资料目录、识别章节点和判断是否存在目录层级异常。
压缩包仍要求支持:
`rar``7z` 解压必须采用纯 Python 实现,允许新增第三方 Python 包依赖,避免服务器部署时依赖系统级解压工具。
1. `zip`
2. `rar`
3. `7z`
除用户上传的申报资料外,系统还需要支持管理平台内置法规资料,例如:
并保留原始相对路径。
- 注册申报资料要求及说明
- 批准证明文件格式要求
- 安全和性能基本原则清单
- 注册证 / 变更注册(备案)文件格式
## 6. 页面与交互要求
### 4.2 文件基础信息管理
基于最新版原型,资料包页需要支持:
每份资料至少要记录:
1. 资料包总览表
2. 产品名称搜索框
3. 按批次号辅助查询
4. 从资料包跳转回对应对话
5. 文件清单与章节点展示
- 文件 ID
- 原始文件名
- 文件类型
- 文件大小
- 上传时间
- 所属项目 / 批次
- 所属任务或场景
- 当前处理状态
## 7. 搜索需求
对于法规资料,建议额外记录
资料包页至少应支持
- 法规类型
- 法规流程类型
- 版本来源
- 是否为系统内置规则依据
1. 按产品名称搜索
2. 按批次号搜索
### 4.3 页数统计与目录归属
搜索结果应直接返回资料包记录,而不是只返回文件。
系统要能为每份文件识别:
## 8. 与对话记录的关系
- 页数
- 章节归属
- 资料名称
- 是否匹配法规目录项
Documents 模块需要对外提供:
这部分是题面要求中的显式能力,不能只靠 Chat 页面临时回答。
1. `conversation_id`
2. `product_name`
3. `batch_id`
### 4.4 文本与表格抽取
确保:
为后续规则比对和字段提取,需要抽取:
1. 一个资料包可以定位到一个主会话
2. 一个主会话默认绑定一个资料包
3. 用户可以从资料包页直接跳到会话页
- 正文文本
- 标题层级
- 表格内容
- 可能的关键信息段落
## 9. 与知识库的关系
例如 `目标产品说明书.docx` 中大量关键信息位于结构化段落和表格中,若只做粗暴全文提取,会显著影响抽取质量
Documents 管理的是审核资料包;知识库管理的是法规资料、模板资料和业务知识资料
### 4.5 入库索引
二者都属于文档资产,但职责不同:
对适合检索的内容建立索引,供 `agent_core` 的 RAG 或规则定位使用。
1. 资料包用于当前审核任务事实输入
2. 知识库用于 RAG 检索、法规依据和模板治理
对法规原文资料,建议单独建立“法规知识索引”,切片时优先保留以下结构语义:
## 10. 验收标准
- 所属法规文档
- 适用流程类型
- 章 / 条 / 清单项编号
- 模板字段或格式要求类型
### 4.6 状态反馈与异常处理
文件处理流程要有明确状态,例如:
- 已上传
- 已解析
- 已入库
- 解析失败
- 待人工确认
不能只记录一个“成功 / 失败”。
## 5. 注册资料业务下的文件模型需求
### 5.1 现有上传模型的不足
当前 `UploadedDocument` 更像一个通用文档记录,适合简单 RAG Demo但对本题不够。至少还缺少以下业务字段
- `project_id``submission_batch_id`
- `chapter_code`
- `chapter_name`
- `document_code`
- `declared_document_name`
- `page_count`
- `source_version`
- `extraction_status`
- `index_status`
- `consistency_status`
- `completeness_match_status`
### 5.2 建议新增的业务语义字段
#### 5.2.1 章节点字段
需要支持类似:
- `CH1.2`
- `CH1.4`
- `CH1.5`
- `CH1.11.5`
这样系统才能把法规模板、样例目录和实际文件真正对齐。
#### 5.2.2 文档类别字段
例如:
- 监管信息
- 综述资料
- 非临床资料
- 临床评价资料
- 产品说明书和标签样稿
- 质量管理体系文件
#### 5.2.3 处理质量字段
建议记录:
- 是否成功提取文本
- 是否成功提取表格
- 是否页数统计可信
- 是否疑似扫描件
- 是否需要 OCR
这些字段会直接影响后续审核可信度。
#### 5.2.4 规则来源字段
建议增加:
- `source_role`
区分“业务申报资料”与“法规依据资料”。
- `workflow_type`
区分 `registration``change``renewal` 等流程类型。
- `format_template_type`
标记该文件是否属于批准证明文件格式模板。
## 6. 关键业务流程需求
### 6.1 文件上传流程
用户上传文件后,系统应完成:
1. 基础校验:格式、大小、文件名合法性。
2. 保存原始文件。
3. 创建文档记录。
4. 返回上传结果与下一步动作提示。
如果是批量导入,系统还应支持一次性上传多个资料。
如果上传的是压缩包,流程应扩展为:
1. 保存原始压缩包。
2. 校验压缩包格式和大小。
3. 解包到当前项目 / 批次的隔离目录。
4. 遍历解包后的文件并创建文档记录。
5. 保留每个文件的原始相对路径和所属压缩包来源。
6. 对解包失败、空包、嵌套异常或不支持格式给出业务化提示。
### 6.2 文件识别与归类流程
上传后,系统应尽量自动识别文件属于哪个章节点。识别依据可以包括:
- 文件名中的章节点编码
- 标题中的资料名称
- 正文中出现的标准标题
- 用户手工选择的类别
如果自动识别不确定,应标记为“待人工确认”,而不是强行归类。
对于法规资料,还应进一步识别其所属层级,例如:
1. 资料要求说明
2. 格式要求说明
3. 安全和性能基本原则
4. 批准证明文件格式
### 6.3 页数统计流程
页数统计是本题显式要求,需支持:
- PDF 精确页数统计
- DOCX 精确页数统计
- DOC 文件页数统计或待人工复核策略
- 目录页码与实际文件页数比对
DOCX 页数必须精确,不能以估算页数作为 V1 验收结果。DOC 如受格式限制无法精确统计,应标记为“待人工复核”。
页数结果建议拆分为:
- `page_count`
- `page_count_method`
- `page_count_confidence`
例如 PDF 和 DOCX 解析应标记为“精确”DOC 或解析失败文件可标记为“待人工复核”。
### 6.4 文本抽取与索引流程
系统应按文档类型采用不同策略:
- PDF优先文本解析必要时 OCR 兜底
- DOCX提取段落和表格
- DOC首版可使用兼容方式提取正文异常时提醒
- MD/TXT直接读取
抽取成功后,生成:
- 全文文本
- 标题/章节结构
- 表格结构
- 可供索引的切片
### 6.5 目录汇总输出流程
Documents 模块应能直接输出一份“资料目录总览”,字段建议包括:
- 文件名
- 资料名称
- 章节点
- 文件类型
- 页数
- 上传时间
- 处理状态
- 是否命中法规模板
这份目录总览既可作为页面列表,也可作为后续报告输入。
## 7. 与题面和样例资料的强关联需求
### 7.1 要能识别目录型文档
`CH1.2 监管信息目录.docx`,它本身不是普通资料正文,而是全章目录。系统需要把这类文件识别为“目录类文档”,并作为后续完整性比对的重要基准。
### 7.2 要能识别声明类文档
如:
- `CH1.11.1 符合标准的清单.docx`
- `CH1.11.5 真实性声明.docx`
- `CH1.11.6 符合性声明.docx`
这些文件看起来篇幅短但在法规齐套性里往往是必需项Documents 模块需要保留它们的业务属性,而不是简单按长度或内容量弱化其价值。
### 7.3 要能识别历史沟通说明类文档
`CH1.9 产品申报前沟通的说明.doc` 体现出历史申报背景和监管沟通信息,这类文件在合规审查中重要性很高,应单独分类标记。
### 7.4 要能区分业务资料与法规依据资料
结合新增公告材料Documents 模块应把以下两类材料明确分开管理:
1. 待审核的业务申报资料
2. 用于审核的法规依据与模板资料
否则后续在索引、引用和一致性检查时,容易把法规模板错误混入业务资料集合。
## 8. 列表页与上传页需求
### 8.1 文档列表页需求
文档列表页不应只是“文件上传记录”,而应成为资料治理面板。建议展示:
- 文件名
- 原始相对路径
- 章节点
- 资料名称
- 页数
- 页数可信度
- 所属项目 / 批次
- 解析状态
- 入库状态
- 风险提示
- 最后更新时间
### 8.2 上传页需求
上传页应支持:
- 选择所属项目或申报批次
- 选择任务类型或章节点
- 上传单文件或多文件
- 上传后立即触发解析或稍后批量处理
如果首版只保留单文件上传,也应在需求中明确“后续需要支持批量导入资料包”。
## 9. 与其他模块的协作边界
### 9.1 与 Scenarios 模块
Scenarios 负责定义当前任务需要什么资料Documents 负责把资料真正落地并结构化。
### 9.2 与 Agent Core 模块
Agent Core 负责对文档内容做审核与抽取Documents 提供可靠的原始内容、切片和元数据。
### 9.3 与 Audit 模块
Documents 不负责审计结论,但应为审计提供文档 ID、处理过程和失败原因等基础事实。
## 10. 异常与边界情况
本模块必须考虑以下情况:
1. 文件存在但正文为空。
2. 文件格式伪装,例如后缀为 `.docx` 但内容异常。
3. Word / PDF 无法正常抽取文本。
4. 文件内容与文件名章节点不一致。
5. 同一资料重复上传多个版本。
6. 同一批次中混入其他产品资料。
第 6 点尤其重要,因为当前样例材料已经体现出不同产品信息混杂的问题。
此外,还应避免把法规依据资料误当成业务申报资料写入同一业务索引集合。
## 11. 首版建议的可交付结果
首版建议 Documents 模块至少能产出三类结果:
1. 文档主数据列表
2. 文档解析结果
3. 目录汇总表
其中目录汇总表是本题最关键的页面成果之一,建议作为可单独展示的功能输出。
## 12. 当前代码基线下的重构建议
### 12.1 可以保留的部分
- 上传记录模型的基本思路
- 文档列表与上传页的页面骨架
- 入库和失败提示机制
### 12.2 需要增强的部分
1. 从“文档上传记录”升级为“注册资料记录”。
2. 增加章节点、页数、资料名称、项目批次等字段。
3. 增加表格抽取和目录类文件识别。
4. 增加文档归类与页数统计能力。
5. 增加重复版本识别和疑似混档识别。
6. 增加法规资料类型识别与业务资料 / 法规资料隔离管理。
7. 增加资料包导入、压缩包解包、原始相对路径记录和解包异常提示。
## 13. 验收标准
本模块验收时,应至少满足:
1. 能上传并管理题目中涉及的 Word、PDF、文本类资料。
2. 能为每份资料建立结构化记录。
3. 能输出文件目录与页数汇总结果。
4. 能为后续完整性核查和一致性核查提供可靠输入。
5. 出现解析失败时,页面有明确可理解的错误提示。
1. 导入资料包后能形成批次记录。
2. 能解析出产品名称并用于会话标题。
3. 资料包页可按产品名称搜索。
4. 资料包记录能跳转回对应对话。
5. 文件清单、页数和章节点可用于后续审核任务消费。

View File

@@ -2,307 +2,156 @@
## 1. 模块定位
`apps.chat` 在当前项目中是用户输入问题并查看 Agent 返回结果的页面。对于本题,它依然是核心交互入口,但定位需要从“自由问答页面”升级为:
`apps.chat` 已从传统“问答页面”升级为:
> 注册申报审核工作台
> 审核智能体工作台
也就是说Chat 模块不只是让用户随便问一句话,而是要承接“选择任务、限定资料范围、发起审核、查看结构化结论、查看证据和建议”的完整操作流程
它是 V1 的主入口,负责把资料包、对话、任务模板、节点结果、结构化结论、飞书通知结果串成一个完整的人机协作界面
## 2. 模块目标
本模块需要实现以下目标:
1. 为注册审核人员提供统一的任务执行入口
2.用户能明确知道自己当前在执行哪类审核任务
3.系统输出不仅有自然语言回答,还有结构化结论、引用证据、回填字段、风险建议
4. 保证结果可追溯、可解释、可复核,而不是只给一个“大模型说了什么”
5. 支持通过 Web 工作台与飞书入口两类方式访问智能体、触发审核和查看结果,并保证核心流程可在飞书内闭环完成
1. 让用户通过对话发起审核任务
2.对话始终绑定到具体资料包和具体产品名称
3.任务结果按节点展示,而不是只返回一段自然语言
4. 让用户能直接看到 RAG 命中的法规依据、风险摘要和导出建议
5. 支持执行完成或异常后触发飞书 `@` 处理人
## 3. 为什么 Chat 模块仍然必要
## 3. 页面形态要求
虽然本题也可以做成一组固定报表,但保留 Chat / 工作台交互有三个价值
基于最新版原型Chat 模块页面形态固定为三栏
1. 复试演示更直观,容易展示 Agent 的编排能力。
2. 用户可以用自然语言提出临时核查要求,例如“只检查 CH1 监管信息”“比较说明书和申请表中的产品名称是否一致”。
3. Chat 页面可以作为多个任务的统一结果承载层,而不需要为每个任务都单独写一套复杂页面。
### 3.1 左栏:对话历史
## 4. 交互定位建议
1. 按资料包会话展示历史记录。
2. 会话名称直接使用解析后的产品名称。
3. 会话至少显示时间、状态、摘要。
4. 点击会话可切换当前资料包上下文。
### 4.1 不建议保持纯聊天式体验
### 3.2 中栏:主对话区
如果只保留一个输入框,让用户手工描述所有操作,体验会过于依赖 prompt不像一个业务系统
1. 展示欢迎区、推荐提示词和节点导航
2. 展示用户消息、Agent 计划、节点结果和最终结论。
3. 支持节点点击跳转。
4. 支持底部输入框继续追问。
建议采用“任务工作台 + 辅助对话”的模式,页面中同时提供:
### 3.3 右栏:上传栏 + 功能卡片
- 当前任务名称
- 输入问题框
- 资料范围选择
- 建议提问模板
- 结构化结果区
- 证据引用区
- 风险列表区
- 审计入口
1. 上半区展示上传资料入口和当前会话已上传附件。
2. 下半区展示能力卡片。
3. 能力卡片随任务模板变化展示不同内容。
### 4.2 建议突出“任务上下文”
## 4. 关键业务约束
用户进入页面后,应明确看到:
### 4.1 对话必须绑定资料包
- 当前任务是什么
- 当前使用了哪些资料
- 当前是否启用了法规规则
- 当前是否启用了字段池 / RAG / 工具
用户不能在无上下文状态下执行审核。每次任务都应绑定:
这对复试讲解非常重要,因为它能体现系统是“受控执行”而不是“随便问模型”。
1. `conversation_id`
2. `batch_id`
3. `product_name`
4. `document_scope`
## 5. 典型使用场景
### 4.2 对话名称必须来源于解析结果
### 5.1 发起完整性检查
会话标题默认使用资料包解析出的产品名称,而不是“新对话”或“注册批次审核主线”。
用户输入类似:
### 4.3 结果必须结构化
- “检查当前上传资料是否满足第 1 章监管信息要求”
- “列出 CH1 缺失文件和风险等级”
Chat 模块至少要展示:
系统返回:
1. 目录汇总结果
2. 完整性检查结果
3. 字段抽取结果
4. 一致性核查结果
5. 风险结论
6. 飞书通知结果
- 已识别文件数
- 命中法规目录项
- 缺失项清单
- 错放项清单
- 处理建议
## 5. 输入需求
### 5.2 发起字段抽取
### 5.1 输入类型
用户输入类似:
1. 自然语言任务指令
2. 资料上传
3. 节点模板选择
4. 建议提示词点击
- “从说明书和产品列表抽取产品名称、检测靶标、适用范围、储存条件、性能指标,并填入申报表或对照清单”
### 5.2 建议提示词
系统返回
V1 推荐固定提供
- 统一字段表
- 字段来源文档
- 置信度或待确认状态
- 注册申报表格或对照清单的回填结果
- 字段冲突时的拦截提示
1. 自动汇总文件夹目录与页数
2. 执行法规完整性检查
3. 抽取字段并生成字段池
4. 解释冲突字段原因
5. 给出整改与导出建议
### 5.3 发起一致性核查
### 5.3 附件上传
用户输入类似
Chat 模块中的上传不是独立附件功能,而是资料包流程的会话入口,因此上传后应自动触发
- “检查申请表、说明书、产品列表里的产品名称和规格是否一致”
1. 资料包批次创建或追加
2. 产品名称解析
3. 对话上下文绑定
系统返回:
## 6. 输出需求
- 一致字段
- 冲突字段
- 冲突来源
- 判定依据
- 建议处理动作
### 6.1 节点式结果
### 5.4 发起综合风险报告
结果不能只有长文本,必须形成节点:
用户输入类似:
1. 目录汇总
2. 完整性检查
3. 字段抽取
4. 风险结论
- “生成本批申报资料的综合风险预警”
### 6.2 RAG 依据展示
系统返回
当 Agent 在对话中引用法规或业务依据时,应展示
- 风险摘要
- 高优先级问题
- 待补文件
- 需人工确认事项
- 建议整改顺序
1. 依据来源文档
2. 命中条款或切片标签
3. 解释摘要
## 6. 输入层需求
### 6.3 飞书通知结果展示
### 6.1 用户输入类型
当执行完成或发生异常后Chat 模块应能展示:
本模块应支持三类输入:
1. 是否已触发飞书通知
2. 通知原因
3.`@` 的处理人
4. Web 详情链接
5. 发送状态或失败原因
1. 自然语言问题
2. 结构化参数选择
3. 资料范围选择
## 7. 飞书相关需求
### 6.2 结构化参数选择
Chat 模块需要消费角色信息中的飞书字段。责任人实体至少包含:
建议用户在页面上可选:
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
- 审核任务类型
- 资料章节点范围
- 指定文档范围
- 输出模式
当前 Demo 通知策略固定为:
输出模式可包括:
1. 审核任务执行完成后,飞书摘要卡片 `@` 处理人。
2. 审核任务执行异常后,飞书异常消息 `@` 处理人。
- 简洁结论
- 结构化清单
- 回填字段视图
- 风险报告视图
## 8. 与其他模块边界
### 6.3 建议提示词模板
1. `documents` 提供资料包、产品名称、文件范围和目录事实。
2. `agent_core` 提供任务编排、RAG 检索和结构化输出。
3. `audit` 提供处理历史和通知留痕。
页面上可给出快捷操作示例,例如:
## 9. 验收标准
- “汇总当前资料目录及页数”
- “检查 CH1 监管信息是否齐套”
- “抽取说明书中的核心产品信息并填入对照清单”
- “检查说明书与申请表是否一致”
这样能降低演示时的自由输入风险。
### 6.4 飞书访问入口
飞书交互不应只是消息转发,而应支持在飞书内完成关键流程。交互设计上至少应纳入以下能力:
1. 通过飞书消息或群聊机器人入口触发某个审核任务。
2. 在飞书内完成任务选择、结果查看和责任人通知。
3. 支持手动维护后的责任人 / 飞书账号映射生效。
飞书开放平台接入路线中,群聊机器人属于 Demo 必备入口;文档评论区 `@bot` 或应用会话能力可作为后续扩展。
## 7. 输出层需求
### 7.1 自然语言结论
仍然需要保留总体回答,用于快速概括结果。
### 7.2 结构化结果
结构化结果是本题的重点,建议至少支持以下几类:
- 目录汇总结果
- 完整性检查结果
- 字段抽取结果
- 一致性核查结果
- 风险预警结果
### 7.3 引用证据
每个关键结论尽量附带来源,例如:
- 来源文档名
- 来源章节
- 引用片段
- 引用页码或位置
对于法规完整性核查,还应尽量附带命中的法规条目或模板条目。
### 7.4 回填结果展示
对于“自动填写至目标文件”的题面要求Chat 页面建议至少支持:
- 展示待回填字段
- 展示字段值
- 展示来源文档
- 展示是否存在冲突
- 展示已填入的注册申报表格或对照清单字段
- 展示是否已生成新的 Word 文档
- 展示导出入口
输出结果不仅要展示回填数据,还应明确展示“已自动填入注册申报表格 / 对照清单”“已按模板生成可直接报送版 Word”及其导出入口。
### 7.5 飞书端结果展示
飞书端至少应能展示:
- 当前任务名称
- 结果摘要
- 风险等级
- 关键缺失项或冲突项
- 责任人通知结果
- Web 详情页或导出文件链接
### 7.6 风险提示
风险输出不应混在普通回答里,建议单独展示:
- 风险等级
- 风险类型
- 涉及文档
- 问题描述
- 建议动作
- 是否需人工复核
## 8. 页面展示要求
### 8.1 结果要适合讲解
复试场景下,页面展示必须清楚,不适合只显示一个 JSON。建议将结果拆成几个清晰区块
- 执行摘要
- 结构化结果
- 证据引用
- 工具调用记录
- 风险预警
- 审计入口
### 8.2 异常提示要业务化
不能只提示“调用失败”。应该尽量说明:
- 当前无可用文档
- 资料未完成入库
- 未找到目标章节点资料
- 字段抽取结果存在冲突,需人工确认
- 法规规则未配置,无法执行完整性检查
### 8.3 支持“只看选中文档”
当前测试已覆盖按文档 ID 传递范围,这在本题里非常有用。因为注册审核人员往往只想检查某一章或某几个文件。
## 9. 结果可信度与人工复核
本题不应把系统塑造成“自动替代注册专员”的黑盒工具,因此 Chat 页面必须支持“需人工复核”的输出状态。
适合标记人工复核的情况包括:
1. 文档抽取失败或疑似扫描件。
2. 字段在不同文档中出现冲突。
3. 章节归类不确定。
4. 规则无法直接判断是否缺失。
5. 审核范围界定不清,无法确认哪些资料属于同一项目批次。
## 10. 与其他模块的边界
### 10.1 与 Scenarios 模块
Scenarios 定义任务入口Chat 承担任务执行界面。
### 10.2 与 Documents 模块
Documents 提供资料和元数据Chat 负责让用户选择资料并展示结果。
### 10.3 与 Agent Core 模块
Agent Core 生成审核结果Chat 只负责参数组织和结果呈现,不负责规则实现。
### 10.4 与 Audit 模块
Chat 是大多数审计记录的触发入口,应把每次关键执行与审计日志关联起来。
## 11. 当前代码基线下的重构建议
### 11.1 建议保留
- 用户输入表单和提交流程
- 结构化结果、引用片段、工具调用展示能力
- 审计入口跳转
- 选中文档范围传递机制
### 11.2 建议增强
1. 从“通用对话页”升级为“注册审核工作台”。
2. 增加任务上下文展示和建议操作模板。
3. 增加字段回填视图和风险清单视图。
4. 增加资料范围、章节点范围选择。
5. 增加人工复核标记的展示。
## 12. 验收标准
本模块验收时,应达到以下状态:
1. 用户能清楚知道当前执行的是哪项注册审核任务。
2. 结果输出同时包含自然语言总结和结构化内容。
3. 能查看引用证据、风险项和工具调用过程。
4. 能基于选中文档或章节点做定向审核。
5. 对失败、冲突和不确定情况给出清楚的人工复核提示。
1. 用户能在一个会话中完成资料上传、任务发起和结果查看。
2. 对话历史以产品名称展示,而不是泛化标题。
3. 节点式结果、RAG 依据和飞书通知结果都能在页面上承载。
4. 执行完成或异常的飞书通知逻辑有明确展示位和字段依赖。

View File

@@ -2,261 +2,107 @@
## 1. 模块定位
`apps.audit`本题中绝不能只被理解为“对话历史”。对于注册申报资料准备与审核系统,它的定位应当是
`apps.audit`最新版产品形态中对应
> 合规审查留痕与复核中心
> 处理历史与审计留痕中心
因为本题输出的是“资料是否齐套、字段是否一致、哪里有合规风险”,这类结果天然需要留痕、可回溯、可解释
它不仅保存对话历史,还要保存资料包、节点执行、风险结论和飞书通知的完整链路
## 2. 模块目标
本模块需要实现以下目标:
1. 对每一次资料审核、字段抽取、完整性检查和风险预警形成可查询记录
2. 保留输入条件、处理范围、输出结果、证据来源和失败原因
3. 为演示“系统不是黑盒”提供直接支撑
4. 在不泄露敏感信息的前提下,支持问题追溯和结果复核。
5. 为 Web 端与飞书端的多入口执行留存统一审计链路。
## 3. 为什么本题对审计要求更高
在普通问答 Demo 中,审计模块往往只是锦上添花。但在本题里,审计本身就是业务可信度的一部分,原因包括:
1. 注册申报属于强监管场景,任何自动结论都应能追溯。
2. 题面明确要求输出风险预警和处理建议,这些建议后续可能影响资料补正方向。
3. 当前样例中已经出现跨文档冲突、二次申报、历史临床资料替换等复杂情境,没有审计留痕会很难解释系统为何得出某个结论。
## 4. 核心职责
### 4.1 执行留痕
记录每次任务执行的基本信息:
- 执行时间
- 操作人
- 任务类型
- 使用场景
- 输入问题
- 选中文档范围
- 申报批次
### 4.2 处理结果留痕
记录:
- 最终自然语言回答
- 结构化结果
- 风险清单
- 回填字段结果
- 缺失文件清单
### 4.3 证据留痕
记录:
- 引用文档
- 引用片段
- 命中的法规条目或目录项
- 使用的规则版本
- 工具调用过程
### 4.4 异常留痕
记录:
- 解析失败
- 入库失败
- 规则缺失
- 模型调用失败
- 输出冲突待人工确认
本题尤其需要保留失败和不确定状态,而不只是保存成功记录。
## 5. 审计对象定义
建议将审计对象扩展为以下几类
1. 资料目录汇总任务
2. 完整性检查任务
3. 字段抽取任务
4. 一致性核查任务
5. 风险预警任务
6. 手工重试、重新入库、重新核查等操作
这样可以避免审计模块只能记录“聊天问答”,却看不到文件处理和重跑过程。
## 6. 审计记录字段需求
### 6.1 基础字段
- `audit_id`
- `task_type`
- `scenario_id`
- `project_id`
- `submission_batch_id`
- `created_at`
- `status`
### 6.2 输入字段
- 用户输入问题
- 执行参数
- 选中文档 ID 列表
- 章节点范围
- 规则版本
- 模型名称
- 触发入口类型Web / 飞书群聊 / 飞书评论 / 飞书应用会话)
- 飞书会话或消息标识
### 6.3 输出字段
- 最终摘要
- 结构化输出 JSON
- 风险等级
- 是否通过
- 是否存在人工复核项
- 缺失项数量
- 冲突项数量
### 6.4 证据字段
- 引用文档信息
- 引用片段
- 工具调用结果
- 命中规则项
- 风险评分明细
### 6.5 错误字段
- 错误类型
- 错误信息
- 失败阶段
- 是否可重试
## 7. 与题面强相关的审计需求
### 7.1 对完整性检查结果留痕
当系统判断“缺少临床评估报告”或“缺少某项监管声明”时,应能回查:
- 是依据哪一版规则判断的
- 当前已识别到哪些资料
- 哪些资料被判定未命中
### 7.2 对一致性冲突留痕
当系统判定:
> 说明书与申请表中的产品名称不一致
则审计中必须保留:
- 冲突字段名
- 冲突值
- 对应来源文档
- 判定时间
- 所属审核范围
当前项目目标是通用试剂盒注册审核智能体,因此审计还必须能够说明“这些文档为什么会被纳入同一轮一致性核查”,否则冲突结论容易失真。
### 7.3 对历史申报说明的审计价值
`CH1.9` 涉及历史受理号、撤回、临床数据替换等事项,若系统在风险报告中引用这部分内容,应在审计中保留相关证据链,方便后续说明“为什么标记为历史事项风险”。
## 8. 页面需求
### 8.1 审计列表页
列表页不应仅展示“问了什么问题”,还应体现业务摘要。建议展示:
- 执行时间
- 任务类型
- 项目 / 批次
- 状态
- 风险等级
- 缺失项数
- 冲突项数
- 是否需人工复核
### 8.2 审计详情页
详情页建议展示:
- 输入问题与参数
- 结果摘要
- 结构化结果
- 引用证据
- 工具调用
- 原始输出
- 错误信息
- 脱敏后的上下文信息
## 9. 脱敏与安全要求
### 9.1 不能写入敏感密钥
这一点与 AGENTS 约定一致,日志中不能保存:
- API Key
- 密钥类环境变量
- 不必要的鉴权头
### 9.2 业务敏感信息控制
虽然当前题目材料以产品注册资料为主,但后续真实环境中可能包含:
- 企业联系人
- 手机号 / 邮箱
- 临床机构信息
- 受理号
首版至少要具备“展示层脱敏”的设计意识。
### 9.3 原始输出保留边界
如果 LLM 原始输出中包含大量无效 prompt 内容或潜在敏感字段,应允许:
- 存摘要,不存完整原文
- 或仅对管理员展示原始输出
## 10. 与其他模块的边界
### 10.1 与 Chat 模块
Chat 是主要触发入口Audit 负责把执行结果沉淀为可追踪记录。
### 10.2 与 Documents 模块
Documents 提供文档处理事实Audit 负责记录这些事实如何被某次审核任务引用。
### 10.3 与 Agent Core 模块
Agent Core 负责产出结论与证据Audit 负责记录这些产出及其上下文。
## 11. 当前代码基线下的重构建议
### 11.1 建议保留
- 审计列表与详情页骨架
- 原始输出展示能力
- 敏感信息脱敏思路
- 成功与失败均记录的机制
### 11.2 建议增强
1. 将“对话日志”扩展为“任务执行审计”。
2. 增加项目批次、任务类型、章节点范围、规则版本等字段。
3. 增加缺失项数、冲突项数、人工复核标记等业务指标。
4. 增加法规命中项、字段来源和风险依据的留痕。
5. 增加飞书触发来源、回传状态和责任人通知记录。
## 12. 验收标准
本模块验收时,应达到以下状态:
1. 每次关键审核任务都能形成完整审计记录。
2. 审计详情足以解释“系统为什么得出这个结论”。
3. 成功、失败和待人工复核都可记录。
4. 页面层可快速筛选高风险或异常记录。
5. 敏感密钥不会进入审计内容。
6. 审计中能够明确体现“任一高风险即不通过”的最终判定依据。
1. 记录每次 Agent 审核任务。
2. 记录资料包与会话绑定关系。
3. 记录节点执行结果与最终风险状态
4. 记录飞书通知触发、接收人和回执
5. 支持处理历史页按批次回看任务
## 3. 审计对象
V1 至少需要覆盖:
1. 资料包导入任务
2. 目录汇总任务
3. 完整性检查任务
4. 字段抽取任务
5. 一致性核查任务
6. 风险预警任务
7. 飞书通知任务
## 4. 关键字段
### 4.1 基础字段
1. `audit_id`
2. `batch_id`
3. `conversation_id`
4. `product_name`
5. `task_type`
6. `status`
7. `created_at`
### 4.2 节点结果字段
1. `node_name`
2. `node_status`
3. `node_summary`
4. `source_report_ids`
### 4.3 飞书留痕字段
1. `trigger_source`
2. `notify_reason`
3. `owner_role`
4. `feishu_user_id`
5. `feishu_open_id`
6. `feishu_name`
7. `feishu_message_id`
8. `message_status`
9. `error_message`
## 5. 处理历史页要求
处理历史页应展示:
1. 批次号
2. 产品名称
3. 任务名称
4. 时间
5. 风险状态
6. 资料规模
7. 最终结果
8. 节点链路
## 6. 飞书协同留痕要求
在 V1 中,飞书通知有两个固定触发时机
1. 审核任务执行完成
2. 审核任务执行异常
两类通知都必须在审计中保留:
1. 谁被 `@`
2. 为什么被 `@`
3. 发送是否成功
4. 是否有 Web 详情页回链
## 7. 与角色信息的关系
审计模块必须能记录责任人实体中的飞书字段,至少包括:
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
## 8. 验收标准
1. 处理历史页能回看历史审核任务。
2. 能从历史中看出资料包、产品名称和对话的对应关系。
3. 飞书通知的完成态和异常态都能留痕。
4. 审计详情足以解释“为什么通知了这个处理人”。

View File

@@ -2,550 +2,111 @@
## 1. 模块定位
`agent_core` 是整套系统的能力中枢。在本题中,它不应再被描述为“一个通用 Prompt + RAG + Tool 的抽象核心”,而应被明确定位为:
`agent_core` 的定位更新为:
> 注册申报资料审核编排引擎
> 资料审核 Agent 编排引擎
负责把法规规则、文档解析结果、字段抽取逻辑、一致性核查逻辑、风险输出模板和大模型能力组织成一个可执行的审核流程。
不再只服务固定报表页面,而是直接服务:
## 2. 模块总目标
1. 对话任务编排
2. 资料包上下文绑定
3. RAG 法规与业务依据检索
4. 节点式结构化输出
5. 飞书通知触发载荷生成
本模块需要完成以下目标:
## 2. 核心目标
1. 基于题面要求完成文件目录汇总、完整性查、字段抽取、自动回填、一致性查和风险预警。
2. 形成规则优先、模型辅助的审核框架,而不是完全依赖自由生成
3. 提供结构化、可追溯、可测试的输出
4. 保持与 Django 页面层和数据层的边界清晰
1. 在会话上下文中执行目录汇总、完整性查、字段抽取、一致性查和风险预警。
2. 使用规则优先、RAG 取证、LLM 辅助解释的工作模式
3. 输出适合对话节点展示的结构化结果
4. 在任务完成或异常时生成飞书通知载荷
## 3. 为什么 Agent Core 是本题真正的“答题核心”
## 3. 输入上下文要求
本题的难点不在“接个大模型接口”,而在于以下几点如何落到一个统一编排里
Agent Core 执行时必须显式接收
1. 资料目录与法规目录如何比对。
2. 产品说明书、申请表、产品列表、声明文件之间如何抽取统一字段。
3. 不同字段的“一致”与“不一致”如何定义。
4. 风险预警如何从规则结果和模型解释中生成。
1. `conversation_id`
2. `batch_id`
3. `product_name`
4. `document_scope`
5. `task_template`
6. `knowledge_scope`
这些都属于 `agent_core` 的职责范围。
## 4. 编排能力拆分
## 4. 核心能力拆分
### 4.1 对话入口编排
建议将 `agent_core` 的能力拆成以下几个子域理解。
根据用户指令和模板,决定当前执行:
### 4.1 任务编排
1. 目录汇总
2. 完整性检查
3. 字段抽取
4. 一致性核查
5. 风险结论
根据不同任务入口,组织不同处理链路。例如:
### 4.2 RAG 检索能力
- 目录汇总链路
- 完整性检查链路
- 字段抽取链路
- 一致性核查链路
- 综合风险链路
RAG 负责:
### 4.2 规则引擎
1. 命中法规依据
2. 命中业务依据
3. 为对话解释提供证据
对以下事项优先使用规则处理:
### 4.3 节点结果输出
- 章节点完整性
- 必交文件判断
- 文件归类
- 固定字段抽取
- 强一致字段比对
每一步结果要输出为可对话展示的节点,而不是单纯 JSON。
### 4.3 LLM 辅助推理
### 4.4 通知上下文生成
对以下事项由 LLM 作为辅助
在任务完成或异常时,生成
- 长段文本中的字段归纳
- 风险说明文案生成
- 处理建议生成
- 无法通过简单规则覆盖的异常解释
1. 通知原因
2. 责任角色
3. 飞书目标账号
4. 摘要内容
5. Web 详情链接
### 4.4 RAG 检索
## 5. 飞书相关需求
用于在文档较长、规则或用户问题较细时,从已入库资料中定位证据片段,为回答和审计提供支撑。
Agent Core 需要消费角色信息中的飞书字段:
对本题而言RAG 不仅要覆盖业务申报资料,也要覆盖公告附件包等法规原文资料。但它的职责应限定为:
1. `owner_role`
2. `owner_name`
3. `department`
4. `chapter_scope`
5. `risk_scope`
6. `feishu_user_id`
7. `feishu_open_id`
8. `feishu_name`
9. `notify_enabled`
1. 为规则判断提供证据定位。
2. 为结果解释提供法规引用。
3. 为审计留痕提供可追溯片段。
V1 固定通知策略:
不能把 RAG 检索命中的段落直接等同于最终合规判断。
1. 执行完成后 `@` 处理人
2. 执行异常后 `@` 处理人
### 4.5 结构化输出
## 6. 结构化输出要求
将每类任务输出为明确 schema而不是一段随意文本。
除原有报告外,还应新增对话友好型输出对象:
## 5. 按题面要求拆解的能力需求
1. `conversation_node_result`
2. `rag_evidence_item`
3. `owner_notification_payload`
## 5.1 文件目录汇总能力
## 7. 与知识库的关系
### 目标
知识库不只是配置后台,而是 Agent Core 的运行底座,至少包括:
自动汇总注册申报文件夹中的所有文件及页数。
1. 法规资料切片
2. 业务资料切片
3. 字段 Schema
4. 模板映射
5. 责任人映射
6. 飞书通知配置
### 需要的输入
## 8. 验收标准
- Documents 模块提供的文档记录
- 文件页数
- 文档归类信息
### 处理逻辑
1. 接收 Documents 模块提供的资料包、批量文件或压缩包解包结果。
2. 遍历当前项目 / 批次所有资料。
3. 保留原始相对路径、文件名、文件类型、页数、页数可信度和处理状态。
4. 将压缩包内多层目录按原目录作为章节点识别依据。
5. 识别目录类文档与普通文档。
6. 识别章节点、资料名称和是否命中法规目录项。
7. 输出目录总表。
### 输出要求
结构化输出中至少包含:
- 文件清单
- 文件数量
- 总页数
- 页数统计可信度
- 已识别章节点
- 待确认文档
DOCX 页数必须精确统计,不能以估算页数作为 V1 验收结果。DOC 如受格式限制无法精确统计,应标记为待人工复核。
## 5.2 法规完整性核查能力
### 目标
对照 NMPA 法规要求,检查所需资料是否齐全,并识别缺失项。
### 规则依据
当前材料已明确可用依据包括:
- `附件 4 体外诊断试剂注册申报资料要求及说明`
- `CH1.2 监管信息目录`
- 题面中提及的 NMPA / CMDE 法规来源
- `关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告` 附件包
V1 默认以 `docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告/` 下的公告附件包作为主规则源。NMPA / CMDE 官网链接用于说明法规来源和后续在线更新方向,不作为当前演示时的唯一实时依赖。
结合新增公告附件包,法规规则来源建议分层管理:
1. 注册申报资料要求及说明
2. 医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂)
3. 体外诊断试剂安全和性能基本原则清单
4. 中华人民共和国医疗器械注册证(体外诊断试剂)格式
5. 变更备案 / 变更注册申报资料要求及说明
6. 延续注册申报资料要求及说明
### 处理逻辑
1. 装载法规目录模板。
2. 装载当前资料实际清单。
3. 以章节点和资料名称进行匹配。
4. 区分:
- 已提供
- 缺失
- 疑似已提供但命名不规范
- 需人工判断
5. 生成缺失项清单和建议动作。
### 关键难点
不是所有缺失都等价。需要区分:
- 监管强制项缺失
- 目录中声明有但实际文件找不到
- 文件存在但内容不符合该章节点用途
此外,还需要区分:
- 资料要求缺失
- 文件格式要求不满足
- 安全和性能基本原则映射不完整
### 输出要求
- 命中项列表
- 缺失项列表
- 风险等级
- 建议补充动作
- 规则依据
## 5.3 产品关键信息抽取能力
### 目标
从产品文件中提取关键信息,并自动填写到注册申报表格或对照清单中。
### 目标字段建议
至少包括题面点名字段:
- 产品名称
- 检测靶标
- 适用范围 / 预期用途
- 储存条件
- 性能指标
结合样例材料,建议进一步扩展:
- 包装规格
- 适用样本类型
- 适用仪器
- 分类编码
- 临床评价路径
- 申请人名称
- 生产地址
- 标准清单
- 申报日期
考虑到系统目标是“通用的试剂盒临床注册文件准备与审核智能体”,字段 schema 应优先沉淀通用注册字段,而不是只对某一具体产品定制。
### 字段来源优先级
需要明确来源优先级,例如:
1. 申请表
2. 产品说明书
3. 产品列表
4. 声明类文件
5. 其他说明材料
或根据字段类型分别设定优先级。
### 抽取逻辑
1. 规则抽取显式字段。
2. 表格抽取规格、组分、标准清单等。
3. 对长文本字段使用 LLM 归纳。
4. 将结果写入统一字段池。
5. 标记字段来源和置信状态。
### 输出要求
- 字段名
- 字段值
- 来源文档
- 来源片段
- 是否冲突
- 是否已填入注册申报表格或对照清单
## 5.4 自动回填能力
### 目标
将抽取得到的产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息填入注册申报表格或对照清单。
### 首版建议范围
首版即需要满足以下交付目标:
- 申请表字段回填数据集
- 对照清单字段回填数据集
- 页面可视化回填结果
- 新的 Word 文档生成与导出能力
- 基于模板库的高保真版式回填能力
当前已确认回填目标为注册申报表格或对照清单。V1 默认先把 `目标产品说明书` 中抽取的产品名称、检测靶标、适用范围、储存条件、性能指标等字段写入统一字段池,再按申请表 / 对照清单字段映射自动生成回填结果。若后续提供专用 Word 模板,则通过模板库字段映射生成具体 Word 文件。
### 处理逻辑
1. 根据目标模板定义字段映射。
2. 从统一字段池读取值。
3. 对冲突字段进行拦截或提示。
4. 写入注册申报表格或对照清单的目标字段。
5. 生成回填结果、导出文件和审计记录。
### 后续扩展
Word 输出必须满足以下要求:
1. 支持将用户新增模板纳入模板库。
2. 回填时按模板字段映射写入指定模板。
3. 输出文档的标题层级、表格、页眉页脚、盖章位和整体版式需达到可直接报送级别。
结合新增公告附件包中的批准证明文件格式材料,回填能力的后续扩展方向应进一步明确为:
1. 按注册证 / 批准证明文件格式模板生成字段映射。
2. 按不同法规流程类型切换不同输出模板。
## 5.5 一致性核查能力
### 目标
核查不同文档间相同信息是否一致,检测章节结构和规范性问题。
### 强一致字段
建议首版按强一致处理的字段包括:
- 产品名称
- 申请人名称
- 规格型号 / 包装规格
- 分类编码
- 申报产品名称对应的章节点标题
结合最新确认,当前阶段不采用“语义一致即可通过”的宽松规则。对于被纳入同一审核范围的相同字段,默认按完全一致处理;如出现措辞差异,也应先判为冲突或待复核。
### 审核范围前置规则
一致性核查前必须先明确:
1. 哪些文档属于同一项目 / 批次 / 审核范围。
2. 哪些文档只是通用样本材料,不能直接混入同一轮一致性比对。
因此,一致性核查链路应包含“审核范围确认”这一步,而不是直接对全部文档做全量比较。
### 结构核查
除字段一致性外,还应检查:
- 说明书是否包含关键章节
- 目录页是否覆盖当前章节点
- 文档标题是否规范
- 是否存在不属于本产品的资料混入
### 典型异常示例
根据当前样例,系统应能识别:
- 若这些文档被划入同一审核范围则“2019-nCoV”与“呼吸道合胞病毒、肺炎支原体”构成明确冲突。
- 若这些文档本身被认定属于不同资料组,则系统应提示“存在跨产品样例混入,不应直接合并审核”。
### 输出要求
- 一致字段列表
- 冲突字段列表
- 冲突明细
- 风险等级
- 处理建议
## 5.6 合规风险预警能力
### 目标
把完整性检查、字段抽取和一致性核查的结果汇总成可执行的风险清单。
### 风险类型建议
- 缺失风险
- 混档风险
- 字段冲突风险
- 章节不规范风险
- 历史申报事项风险
- 资料真实性 / 版本一致性风险
- 法规适用情形错误风险
### 风险分级建议
首版可采用:
- 高风险
- 中风险
- 低风险
另行保留“待人工复核”状态,但它不是风险等级,而是处理状态。
### 风险准入规则
风险判定应采用综合分析机制,对至少以下维度分别评分:
1. 法规完整性
2. 跨文档字段一致性
3. 文档结构与章节规范性
4. 历史事项与版本风险
5. 法规流程适用性风险
综合规则如下:
1. 任一维度出现高风险项,则本次审核直接判定为不通过。
2. 无高风险但存在多个中风险项时,应给出“待整改后复核”的建议。
3. 低风险项可进入整改建议清单,但不单独阻断。
### 处理建议生成逻辑
规则部分负责给出基础动作,例如:
- “补充缺失文件”
- “核对产品名称”
- “重新确认临床资料版本”
LLM 负责把这些动作组织成自然语言建议,但不能改变底层规则结论。
## 6. 统一字段池设计需求
为支撑抽取、回填和一致性核查,建议在 `agent_core` 内形成统一字段池概念。
字段池至少记录:
- 字段名
- 标准化字段值
- 原始字段值
- 来源文档
- 来源位置
- 置信度
- 冲突状态
- 最终推荐值
这是本题从“简单聊天 Demo”走向“资料审核系统”的关键能力之一。
## 7. 规则体系需求
### 7.1 完整性规则
用于判断:
- 某章节点是否必交
- 当前资料是否命中
- 缺失是否构成高风险
这里应进一步拆为三个子层:
1. 资料要求层完整性规则
2. 结构目录层完整性规则
3. 格式模板层完整性规则
同时,这三层规则都应能映射回“章 -> 条 -> 要求项 -> 模板字段”四级知识结构。
### 7.2 抽取规则
用于:
- 标题识别
- 表格字段映射
- 固定格式声明提取
对于法规资料本身,还应支持抽取:
- 附件编号
- 法规流程类型
- 适用范围说明
- 批准证明文件格式字段
并为后台管理页面提供人工校订后的结构化回写入口。
### 7.3 一致性规则
用于定义:
- 哪些字段必须完全一致
- 如何判断冲突严重度
- 如何在执行前确认审核范围
### 7.4 风险映射规则
用于把缺失、冲突、不确定结果映射为风险级别、综合得分、是否通过和处理建议。
新增公告材料后,风险映射还应能够体现“适用情形错误”的风险,例如:
1. 把变更备案规则误用于首次注册申报
2. 把延续注册格式误用于注册申报输出
同时,若系统生成 Word 输出失败、模板字段无法落位或导出格式破坏严重,也应形成独立的交付风险提示。
## 8. 工具体系需求
题面附加要求提到需要展示实际调用的关键工具/库。因此 `agent_core.tools` 中应逐步沉淀出与本题强相关的工具,而不是只保留通用样例工具。
建议工具方向包括:
1. 资料包扫描工具
2. `zip` / `rar` / `7z` 压缩包解包工具,`rar``7z` 必须采用纯 Python 依赖实现
3. 文档页数统计工具DOCX 页数必须精确统计
4. 章节点识别工具
5. 必交项检查工具
6. 字段抽取工具
7. 字段一致性比对工具
8. 文档结构规范检查工具
9. 风险汇总工具
10. 审核范围确认工具
11. 法规流程识别工具
12. 格式模板映射工具
13. Word 模板回填与导出工具
14. 飞书消息摘要生成与通知载荷组装工具
15. 责任人映射解析工具,首版按资料章节手动配置
16. 规则切片与结构化回写工具
这些工具都应通过 Tool Registry 注册,符合项目既有边界要求。
## 9. LLM Provider 需求
### 9.1 不允许业务代码散落模型调用
所有模型调用继续通过 Provider 统一处理。
### 9.2 模型在本题中的使用原则
本题应坚持:
1. 规则优先
2. 证据优先
3. 模型负责解释、补充和归纳
4. 模型不应凭空判断法规完整性
### 9.3 测试要求
所有核心编排逻辑应继续支持 Mock Provider以保证回归测试离线可跑。
## 10. 结构化输出 Schema 需求
建议至少定义以下输出类型:
1. `registration_overview_report`
2. `registration_completeness_report`
3. `registration_field_extraction_report`
4. `registration_consistency_report`
5. `registration_risk_report`
每种输出都应有稳定字段,便于页面展示与测试覆盖。
## 11. 与其他模块的边界
### 11.1 与 Documents 模块
Documents 负责提供资料事实Agent Core 负责把这些事实转化为审核结论。
### 11.2 与 Chat 模块
Chat 负责接收用户意图和展示结果Agent Core 负责执行任务链路。
### 11.3 与 Audit 模块
Audit 负责记录过程和结果Agent Core 负责产出可记录的结构化执行信息。
## 12. 当前代码基线下的重构建议
### 12.1 建议保留
- Prompt 编排机制
- 结构化结果对象
- Tool Registry
- RAG fallback / Chroma 双路径思路
- Mock Provider 测试策略
### 12.2 建议增强
1. 从通用场景输出转向注册审核专用输出 schema。
2. 增加法规完整性规则和目录模板匹配逻辑。
3. 增加统一字段池。
4. 增加一致性核查与风险汇总工具。
5. 将“注册申报表格 / 对照清单回填结果”纳入正式输出结构。
6. 增加“是否通过”和“风险评分明细”输出字段。
7. 增加法规分层规则管理,以及注册申报 / 变更 / 延续三类流程的扩展边界。
8. 增加模板库驱动的高保真 Word 生成链路。
9. 增加后端管理入口所需的规则回写、人工校订和责任人映射能力。
## 13. 验收标准
本模块完成后,应至少满足:
1. 能支持目录汇总、完整性检查、字段抽取、一致性核查、风险预警五类核心任务。
2. 核心结论有结构化输出,不依赖随意文本。
3. 规则和模型分工清晰,法规判断不完全依赖大模型生成。
4. 输出能关联到具体文档和证据片段。
5. 测试环境下可以通过 Mock Provider 验证主要编排逻辑。
6. 法规原文可切片入 RAG但最终完整性与准入判断仍由规则链路主导。
7. Word 输出结果能够基于模板库生成可直接报送级版式文档。
1. Agent Core 能在会话上下文中完成完整审核链路。
2. 能输出对话节点结果和 RAG 依据。
3. 能生成执行完成/异常两类飞书通知上下文。
4. 能基于责任人飞书字段构建 `@` 处理人载荷。

View File

@@ -38,6 +38,8 @@
13. `rar``7z` 解压必须纯 Python 实现,允许增加第三方依赖包。
14. 责任人先手动配置,按资料章节维护。
15. 第 2 至第 6 章不补充企业真实样本,先按公告附件包进行规则级初步确认。
16. 顶层入口按 `审核智能体 / 资料包 / 知识库 / 处理历史` 组织,法规依据纳入知识库并通过 RAG 在对话中返回。
17. 资料包必须和会话绑定,会话标题默认采用解析后的产品名称,资料包页支持按产品名称搜索。
---
@@ -189,6 +191,38 @@
- 决定 Documents 模块的上传、解包、目录还原和异常提示实现。
### Q6-2 资料包与会话是否固定绑定?
建议提问方式:
> 当前我们准备采用“一个资料包绑定一个主会话”的方式,并把会话标题直接使用解析后的产品名称。请确认这个口径是否符合您期望的使用方式?
建议记录答案:
- 是否一包一会话:
- 会话标题是否使用产品名称:
- 是否允许后续在同一资料包下追加子会话:
为什么要问:
- 这会直接影响资料包页、审核智能体页和处理历史页的主键设计。
### Q6-3 资料包页是否以产品名称作为主搜索字段?
建议提问方式:
> 在资料包列表里,您更习惯通过产品名称、批次号还是项目编号来查找资料?我们当前默认以产品名称和批次号作为主搜索条件。
建议记录答案:
- 主搜索字段:
- 次搜索字段:
- 是否需要模糊搜索:
为什么要问:
- 这会影响资料包列表页和历史页的默认检索体验。
---
## 4.3 自动审核与人工复核边界
@@ -354,6 +388,22 @@
- 决定知识库首版范围。
### Q14-1 法规依据是否可以完全收敛到知识库页面维护?
建议提问方式:
> 当前原型里我们不再单独做“法规依据”一级页面,而是把法规资料统一放进知识库,由 Agent 对话通过 RAG 命中后返回依据说明。请确认这种呈现方式是否符合您的预期?
建议记录答案:
- 是否接受:
- 是否仍需单独法规页:
- 是否需要在结果里展示来源版本:
为什么要问:
- 这会影响顶层导航是否继续精简,以及法规解释的展示形态。
### Q15 后台知识库更新入口由谁使用?
建议提问方式:

View File

@@ -3,3 +3,4 @@ PyYAML>=6.0,<7.0
chromadb>=0.5,<1.0
pytest>=8.0,<9.0
pytest-django>=4.9,<5.0
py7zr>=0.20,<1.0

View File

@@ -1,73 +1,113 @@
{% extends "base.html" %}
{% block title %}审计日志详情{% endblock %}
{% block title %}处理历史详情{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Audit Snapshot</span>
<h1 class="page-title">审计日志 #{{ log.id }}</h1>
<p class="page-lead">详情页集中展示当前请求的输入、结构化输出、引用来源、工具调用和原始输出,用来解释这一轮 Agent 执行到底做了什么</p>
<div class="button-row">
<a class="button" href="{% url 'audit:list' %}">返回审计列表</a>
<a class="button" href="{% url 'platform_ui:command-center' %}">返回工作台大屏</a>
<span class="eyebrow">History Detail</span>
<h1 class="page-title">处理历史 #{{ log.id }}</h1>
<p class="page-lead">集中展示本次执行的业务上下文、结构化结果、引用来源与通知留痕</p>
<div class="badge-row">
<span class="pill pill-accent">批次:{{ log.batch_id|default:"-" }}</span>
<span class="pill">会话:{{ log.conversation_id|default:"-" }}</span>
<span class="pill">产品:{{ log.product_name|default:"-" }}</span>
</div>
</section>
<section class="hero-metrics">
<article class="metric-card">
<div class="metric-label">场景</div>
<div class="metric-value">{{ log.scenario_name }}</div>
<section class="grid-2">
<article class="panel">
<h2 class="section-title">执行上下文</h2>
<ul class="detail-list">
<li class="detail-item"><strong>用户输入</strong><div>{{ log.user_input|linebreaksbr }}</div></li>
<li class="detail-item"><strong>最终回答</strong><div>{{ log.final_answer|linebreaksbr }}</div></li>
<li class="detail-item"><strong>结构化输出</strong><pre class="code-block">{{ log.structured_output }}</pre></li>
</ul>
</article>
<article class="metric-card">
<div class="metric-label">状态</div>
<div class="metric-value">{{ log.get_status_display_text }}</div>
</article>
<article class="metric-card">
<div class="metric-label">耗时</div>
<div class="metric-value">{{ log.latency_ms }} ms</div>
<article class="panel">
<h2 class="section-title">执行证据</h2>
<ul class="detail-list">
<li class="detail-item"><strong>引用来源</strong><pre class="code-block">{{ log.retrieved_chunks }}</pre></li>
<li class="detail-item"><strong>工具调用</strong><pre class="code-block">{{ log.tool_calls }}</pre></li>
<li class="detail-item"><strong>原始输出</strong><pre class="code-block">{{ log.raw_output }}</pre></li>
</ul>
</article>
</section>
<section class="layout-two-columns">
<div class="stack">
<article class="panel">
<h2 class="section-title">用户输入</h2>
<div class="detail-item">{{ log.user_input|linebreaksbr }}</div>
</article>
<article class="panel">
<h2 class="section-title">最终回答</h2>
<div class="detail-item">{{ log.final_answer|linebreaksbr }}</div>
</article>
<article class="panel">
<h2 class="section-title">结构化输出</h2>
<pre class="code-block">{{ log.structured_output }}</pre>
</article>
</div>
<div class="stack">
<article class="panel">
<h2 class="section-title">引用来源</h2>
<pre class="code-block">{{ log.retrieved_chunks }}</pre>
</article>
<article class="panel">
<h2 class="section-title">工具调用</h2>
<pre class="code-block">{{ log.tool_calls }}</pre>
</article>
<article class="panel">
<h2 class="section-title">原始输出</h2>
<pre class="code-block">{{ log.raw_output }}</pre>
</article>
{% if log.error_message %}
<article class="panel">
<h2 class="section-title">错误信息</h2>
<pre class="code-block">{{ log.error_message }}</pre>
</article>
<section class="panel">
<h2 class="section-title">会话节点结果</h2>
<ul class="detail-list">
{% if conversation_node_results %}
{% for node in conversation_node_results %}
<li class="detail-item">
<strong>{{ node.label }} / {{ node.status }}</strong>
{% if node.summary %}
<div>{{ node.summary }}</div>
{% endif %}
</li>
{% endfor %}
{% else %}
<li class="detail-item">当前会话暂无节点结果。</li>
{% endif %}
</ul>
</section>
<section class="grid-2">
<article class="panel">
<h2 class="section-title">导出状态摘要</h2>
<ul class="detail-list">
<li class="detail-item"><strong>导出状态</strong><div>{{ detail_summary.export_status|default:"-" }}</div></li>
<li class="detail-item"><strong>输出文件名</strong><div>{{ detail_summary.output_file_name|default:"-" }}</div></li>
<li class="detail-item"><strong>导出模式</strong><div>{{ detail_summary.export_mode|default:"-" }}</div></li>
<li class="detail-item"><strong>模板名称</strong><div>{{ detail_summary.template_name|default:"-" }}</div></li>
<li class="detail-item"><strong>模板版本</strong><div>{{ detail_summary.template_version|default:"-" }}</div></li>
<li class="detail-item"><strong>草稿导出状态</strong><div>{{ detail_summary.draft_export_status|default:"-" }}</div></li>
<li class="detail-item"><strong>正式导出状态</strong><div>{{ detail_summary.formal_export_status|default:"-" }}</div></li>
<li class="detail-item"><strong>下载地址</strong><div>{{ detail_summary.download_url|default:"-" }}</div></li>
<li class="detail-item">
<strong>阻断项</strong>
{% if detail_summary.blocked_items %}
<div>{{ detail_summary.blocked_items|join:" / " }}</div>
{% else %}
<div>-</div>
{% endif %}
</li>
</ul>
</article>
<article class="panel">
<h2 class="section-title">通知回执</h2>
<pre class="code-block">{{ detail_summary.notification_receipt }}</pre>
</article>
</section>
<section class="panel">
<h2 class="section-title">通知留痕</h2>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>触发原因</th>
<th>责任角色</th>
<th>飞书用户</th>
<th>消息状态</th>
<th>详情链接</th>
</tr>
</thead>
<tbody>
{% for item in notifications %}
<tr>
<td>{{ item.notify_reason }}</td>
<td>{{ item.owner_role }}</td>
<td>{{ item.feishu_user_id }}</td>
<td>{{ item.get_message_status_display_text }}</td>
<td>{{ item.web_detail_url|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">当前执行尚无通知留痕。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}

View File

@@ -1,43 +1,65 @@
{% extends "base.html" %}
{% block title %}审计日志{% endblock %}
{% block title %}处理历史{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Audit Trail</span>
<h1 class="page-title">审计日志与执行留痕中心</h1>
<p class="page-lead">每次 Agent 执行都会保留输入、结构化结果、引用片段、工具调用和最终输出。这个页面用于说明系统为何可追溯、可复核、可解释</p>
{% if selected_scenario_id %}
<div class="badge-row">
<span class="pill pill-accent">当前筛选场景:{{ selected_scenario_id }}</span>
<a class="button" href="{% url 'audit:list' %}">清空筛选</a>
</div>
{% endif %}
<span class="eyebrow">Processing History</span>
<h1 class="page-title">处理历史</h1>
<p class="page-lead">按批次、产品和会话回看审核执行、结构化结论与通知留痕</p>
</section>
<section class="hero-metrics">
<article class="metric-card">
<div class="metric-label">日志总数</div>
<div class="metric-value">{{ logs|length }}</div>
<div class="metric-note">当前页面加载的执行快照数量。</div>
</article>
<article class="metric-card">
<div class="metric-label">最近状态</div>
<div class="metric-value">{% if logs %}{{ logs.0.get_status_display_text }}{% else %}暂无{% endif %}</div>
<div class="metric-note">默认按时间倒序展示最近一次 Agent 执行。</div>
</article>
<article class="metric-card">
<div class="metric-label">最近场景</div>
<div class="metric-value">{% if logs %}{{ logs.0.scenario_name }}{% else %}暂无{% endif %}</div>
<div class="metric-note">便于快速定位当前复试演示对应的执行记录</div>
</article>
<section class="metric-grid">
{% for item in history_metrics %}
<article class="metric-card">
<div class="metric-label">{{ item.label }}</div>
<div class="metric-value">{{ item.value }}</div>
<div class="metric-note">{{ item.note }}</div>
</article>
{% endfor %}
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">历史筛选</h2>
<p class="section-copy">支持按产品名称或批次号搜索</p>
</div>
</div>
<form method="get" class="grid-2">
<div>
<label for="id_keyword">产品名称 / 批次号</label>
<input id="id_keyword" type="text" name="keyword" value="{{ keyword }}" placeholder="例如:新型冠状病毒 或 SUB-20260604-001">
</div>
<div>
<label for="id_risk_status">风险状态</label>
<select id="id_risk_status" name="risk_status">
<option value="">全部风险</option>
<option value="high"{% if risk_status == "high" %} selected{% endif %}>已阻断</option>
<option value="medium"{% if risk_status == "medium" %} selected{% endif %}>待复核</option>
<option value="low"{% if risk_status == "low" %} selected{% endif %}>已完成</option>
</select>
</div>
<div>
<label for="id_notify_status">通知状态</label>
<select id="id_notify_status" name="notify_status">
<option value="">全部状态</option>
<option value="sent"{% if notify_status == "sent" %} selected{% endif %}>已发送</option>
<option value="failed"{% if notify_status == "failed" %} selected{% endif %}>失败</option>
</select>
</div>
<div class="button-row">
<button type="submit">筛选历史</button>
<a class="button" href="{% url 'audit:list' %}">清空</a>
</div>
</form>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">执行快照列表</h2>
<p class="section-copy">保留真实审计数据列表,同时把展示形式升级为与首页、大屏一致的分析板风格</p>
<p class="section-copy">围绕 `batch_id / conversation_id / product_name` 展示处理历史</p>
</div>
</div>
<div class="table-wrap">
@@ -46,30 +68,82 @@
<tr>
<th>ID</th>
<th>场景</th>
<th>产品名称</th>
<th>批次号</th>
<th>会话</th>
<th>资料规模</th>
<th>资料包状态</th>
<th>会话状态</th>
<th>输入摘要</th>
<th>状态</th>
<th>风险状态</th>
<th>通知原因</th>
<th>通知状态</th>
<th>模型</th>
<th></th>
<th>创建时间</th>
<th></th>
<th>详情</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
{% for row in history_rows %}
<tr>
<td>{{ log.id }}</td>
<td>{{ log.scenario_name }}</td>
<td>{{ log.get_user_input_summary }}</td>
<td>
<span class="pill {% if log.status == 'success' %}pill-success{% else %}pill-danger{% endif %}">{{ log.get_status_display_text }}</span>
<td>{{ row.log.id }}</td>
<td>{{ row.log.scenario_name }}</td>
<td class="cell-min-220">
{% if row.log.product_name %}
<a href="{% url 'audit:list' %}?keyword={{ row.log.product_name }}">{{ row.log.product_name }}</a>
{% else %}
-
{% endif %}
</td>
<td>{{ log.model_name }}</td>
<td>{{ log.latency_ms }} ms</td>
<td>{{ log.created_at|date:"Y-m-d H:i" }}</td>
<td><a class="button" href="{% url 'audit:detail' log.id %}">查看详情</a></td>
<td class="nowrap">
{% if row.log.batch_id %}
<a href="{% url 'documents:list' %}?keyword={{ row.log.batch_id }}">{{ row.log.batch_id }}</a>
{% else %}
-
{% endif %}
</td>
<td class="cell-min-220">
{% if row.log.conversation_id %}
<a class="button" href="{% url 'chat:detail' row.log.conversation_id %}">查看会话 {{ row.log.conversation_id }}</a>
{% else %}
-
{% endif %}
</td>
<td>{{ row.batch_scale }}</td>
<td>
<span class="pill {% if row.batch_status == '已完成' %}pill-success{% elif row.batch_status == '待复核' %}pill-signal{% else %}pill-danger{% endif %}">
{{ row.batch_status }}
</span>
</td>
<td>
<span class="pill {% if row.conversation_status == '已完成' %}pill-success{% elif row.conversation_status == '失败' or row.conversation_status == '已阻断' %}pill-danger{% else %}pill-signal{% endif %}">
{{ row.conversation_status }}
</span>
</td>
<td>{{ row.log.get_user_input_summary }}</td>
<td>
<span class="pill {% if row.log.status == 'success' %}pill-success{% else %}pill-danger{% endif %}">{{ row.log.get_status_display_text }}</span>
</td>
<td>
<span class="pill {% if row.risk_status == '已阻断' or row.risk_status == '失败' %}pill-danger{% elif row.risk_status == '待复核' or row.risk_status == '处理中' %}pill-signal{% elif row.risk_status == '已完成' %}pill-success{% endif %}">
{{ row.risk_status }}
</span>
</td>
<td>
<span class="pill pill-accent">{{ row.notify_reason }}</span>
</td>
<td>
<span class="pill {% if row.notify_status == '已发送' %}pill-success{% elif row.notify_status == '失败' %}pill-danger{% else %}pill-signal{% endif %}">
{{ row.notify_status }}
</span>
</td>
<td>{{ row.log.model_name }}</td>
<td>{{ row.log.created_at|date:"Y-m-d H:i" }}</td>
<td><a class="button" href="{% url 'audit:detail' row.log.id %}">查看详情</a></td>
</tr>
{% empty %}
<tr><td colspan="8">暂无审计日志,先去执行一次审核工作台任务。</td></tr>
<tr><td colspan="16">暂无处理历史,先去执行一次审核任务。</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -92,6 +92,11 @@
color: var(--muted);
font-size: 0.92rem;
}
.topnav a.active {
background: var(--primary-soft);
color: var(--primary);
border: 1px solid #d8e5ff;
}
.topnav a:hover {
background: var(--surface-soft);
color: var(--text);
@@ -363,14 +368,10 @@
</div>
</div>
<nav class="topnav">
<a href="{% url 'scenarios:index' %}">总览</a>
<a href="{% url 'documents:list' %}">文件中心</a>
<a href="{% url 'chat:index' 'document_review' %}">审核工作台</a>
<a href="{% url 'platform_ui:knowledge-base' %}">知识库</a>
<a href="{% url 'platform_ui:mcp-center' %}">MCP</a>
<a href="{% url 'platform_ui:skills' %}">Skills</a>
<a href="{% url 'platform_ui:command-center' %}">工作台</a>
<a href="{% url 'audit:list' %}">审计</a>
<a href="{% url 'chat:index' %}"{% if request.resolver_match.namespace == 'chat' %} class="active"{% endif %}>审核智能体</a>
<a href="{% url 'documents:list' %}"{% if request.resolver_match.namespace == 'documents' %} class="active"{% endif %}>资料包</a>
<a href="{% url 'platform_ui:knowledge-base' %}"{% if request.resolver_match.namespace == 'platform_ui' %} class="active"{% endif %}>知识库</a>
<a href="{% url 'audit:list' %}"{% if request.resolver_match.namespace == 'audit' %} class="active"{% endif %}>处理历史</a>
</nav>
</div>
</header>

View File

@@ -1,31 +1,85 @@
{% extends "base.html" %}
{% block title %}{{ scenario.name|default:"Agent 审核工作台" }}{% endblock %}
{% block title %}审核智能体{% endblock %}
{% block content %}
{% if error %}
<section class="notice notice-error">{{ error }}</section>
<section class="page-header">
<span class="eyebrow">Agent Workspace</span>
<h1 class="page-title">审核智能体</h1>
<p class="page-lead">以会话为中心组织资料包上传、节点式审核结果和动态任务信息卡。</p>
{% if conversation %}
<div class="badge-row">
<span class="pill pill-accent">批次:{{ conversation.batch_id }}</span>
<span class="pill">产品:{{ conversation.product_name|default:"未识别产品名称" }}</span>
<span class="pill">阶段:{{ conversation_context.task_status }}</span>
</div>
{% endif %}
</section>
{% if conversation %}
<section class="grid-2">
<article class="panel">
<h2 class="section-title">顶部对话上下文</h2>
<p class="section-copy">进入会话后,先用当前批次、产品和风险状态快速建立审核上下文。</p>
<ul class="detail-list">
<li class="detail-item"><strong>批次编号</strong><div>{{ conversation_context.batch_id }}</div></li>
<li class="detail-item"><strong>产品名称</strong><div>{{ conversation_context.product_name|default:"未识别产品名称" }}</div></li>
<li class="detail-item"><strong>当前流程类型</strong><div>{{ conversation_context.workflow_type }}</div></li>
<li class="detail-item"><strong>当前审核阶段</strong><div>{{ conversation_context.task_status }}</div></li>
<li class="detail-item"><strong>当前最高风险等级</strong><div>{{ conversation_context.highest_risk_level }}</div></li>
<li class="detail-item"><strong>是否允许正式导出</strong><div>{{ conversation_context.export_allowed }}</div></li>
</ul>
</article>
<article class="panel">
<h2 class="section-title">推荐提问模板</h2>
<p class="section-copy">用这些提问模板快速进入目录汇总、完整性检查、字段抽取和风险分析。</p>
<div class="button-row">
{% for item in prompt_templates %}
<span class="pill pill-accent">{{ item }}</span>
{% endfor %}
</div>
</article>
</section>
{% endif %}
{% if scenario %}
<section class="page-header">
<span class="eyebrow">Workspace</span>
<h1 class="page-title">{{ scenario.name }}</h1>
<p class="page-lead">左侧输入问题和选择文档,右侧查看执行结果</p>
<div class="badge-row">
<span class="pill pill-accent">已入库文档:{{ document_count }}</span>
<span class="pill">输出:{{ scenario.output.type }}</span>
</div>
</section>
<section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;">
<div class="stack">
<article class="panel">
<h2 class="section-title">会话历史</h2>
<p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称</p>
<ul class="detail-list">
{% for item in conversation_history %}
<li class="detail-item">
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
<div class="muted">批次:{{ item.batch_id }}</div>
<div class="muted">风险:{{ item.risk_level }}</div>
<div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div>
<div class="badge-row" style="margin-top: 8px;">
<span class="pill pill-accent">{{ item.batch_binding_label }}</span>
</div>
</li>
{% empty %}
<li class="detail-item">暂无会话,请先从资料包页面导入资料。</li>
{% endfor %}
</ul>
</article>
</div>
<section class="workspace-grid">
<div class="stack">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">任务输入与资料范围</h2>
<p class="section-copy">左侧突出受控输入:先描述审核目标,再限定本轮使用的文档范围。</p>
</div>
<div class="stack">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">对话区与节点导航</h2>
<p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要。</p>
</div>
</div>
{% if conversation %}
<div class="badge-row" style="margin-bottom: 14px;">
{% for node in node_results %}
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
{% endfor %}
</div>
<form method="post" class="stack">
{% csrf_token %}
@@ -38,7 +92,6 @@
</div>
<div>
{{ form.document_ids.label_tag }}
<p class="help-text">不勾选时默认使用全部已入库文档。</p>
<div class="checkbox-list">
{% for checkbox in form.document_ids %}
<label class="checkbox-item">
@@ -46,136 +99,369 @@
<span>{{ checkbox.choice_label }}</span>
</label>
{% empty %}
<div class="notice">当前场景还没有已入库文档,系统将仅依赖工具和模型能力生成结果</div>
<div class="notice">当前资料包还没有可选文档</div>
{% endfor %}
</div>
{% if form.document_ids.errors %}
<p class="notice notice-error">{{ form.document_ids.errors|join:" " }}</p>
{% endif %}
</div>
<div class="button-row">
<button type="submit">提交问题并执行 Agent</button>
<button type="submit">提交审核任务</button>
</div>
</form>
</article>
<article class="panel">
<h2 class="section-title">快捷示例</h2>
<ul class="detail-list">
<li class="detail-item">检查当前资料是否存在缺失项</li>
<li class="detail-item">抽取说明书中的关键字段</li>
<li class="detail-item">比较两份文档中的产品名称是否一致</li>
</ul>
</article>
</div>
<div class="stack">
<article class="panel">
<h2 class="section-title">结果</h2>
{% if result %}
<ul class="meta-list">
<li class="meta-badge">模型:{{ result.model_name }}</li>
<li class="meta-badge {% if result.status == 'success' %}status-success{% else %}status-failed{% endif %}">状态:{{ result.status }}</li>
<li class="meta-badge">耗时:{{ result.latency_ms }} ms</li>
</ul>
<div class="detail-item" style="margin-top: 16px;">
<strong>回答</strong>
<strong>Agent 回答</strong>
<div>{{ result.answer|linebreaksbr }}</div>
</div>
{% else %}
<div class="notice">提交任务后,这里会展示 Agent 的执行状态、主回答和过程摘要。</div>
{% endif %}
</article>
{% if result %}
<article class="panel">
<h2 class="section-title">证据引用与工具调用</h2>
<p class="muted" style="margin-bottom: 14px;">引用片段与工具调用用于支撑结果可解释性。</p>
{% if result.references %}
<h3 style="margin-top: 0;">引用片段</h3>
<ul class="detail-list" style="margin-bottom: 16px;">
{% for reference in result.references %}
<li class="detail-item">
<strong>{{ reference.source }}</strong>
<div>{{ reference.content|default:"无正文内容"|linebreaksbr }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="notice" style="margin-bottom: 16px;">当前回答没有引用知识库片段。</div>
{% endif %}
{% if result.tool_calls %}
<h3>工具调用</h3>
<ul class="detail-list">
{% for tool_call in result.tool_calls %}
<li class="detail-item">
<strong>{{ tool_call.tool_name }}</strong>
<p class="muted">执行状态:{{ tool_call.success }}</p>
{% if tool_call.error %}
<p class="notice notice-error">{{ tool_call.error }}</p>
{% endif %}
<pre class="code-block">{{ tool_call.result }}</pre>
</li>
{% endfor %}
</ul>
{% else %}
<div class="notice">当前场景没有声明工具,或本次执行无需调用工具。</div>
{% endif %}
</article>
{% if result.error %}
<article class="panel">
<h2 class="section-title">错误信息</h2>
<pre class="code-block">{{ result.error }}</pre>
</article>
{% endif %}
{% else %}
<div class="notice">暂无会话,请先导入资料包。</div>
{% endif %}
</div>
</article>
<div class="stack">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">结构化审核结果</h2>
<p class="section-copy">右侧结果舱用于展示缺失项、冲突项、字段池结果或风险清单。</p>
<article class="panel">
<h2 class="section-title">节点式结果</h2>
{% if analysis_card or export_card or risk_card or notify_card %}
<div class="stack">
{% if analysis_card %}
<div class="detail-item">
<strong>{{ analysis_card.title }}</strong>
{% if analysis_card.summary %}
<div>{{ analysis_card.summary }}</div>
{% endif %}
</div>
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">摘要指标</h3>
<div class="badge-row">
{% for item in analysis_card.stats %}
<span class="pill pill-accent">{{ item.label }}{{ item.value }}</span>
{% endfor %}
</div>
</div>
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">节点结果明细</h3>
<ul class="detail-list">
{% if analysis_card.kind == "overview" %}
{% for item in analysis_card.items %}
<li class="detail-item">{{ item.chapter_code }} / {{ item.document_count }} 份</li>
{% empty %}
<li class="detail-item">当前无目录汇总结果。</li>
{% endfor %}
{% elif analysis_card.kind == "completeness" %}
{% for item in analysis_card.items %}
<li class="detail-item">
<strong>{{ item.document_name|default:"必交项" }}</strong>
<div>章节点:{{ item.chapter_code|default:"-" }}</div>
<div>{{ item.reason|default:"当前资料包未提供该资料。" }}</div>
</li>
{% empty %}
<li class="detail-item">当前无缺失项。</li>
{% endfor %}
{% elif analysis_card.kind == "consistency" %}
{% for item in analysis_card.items %}
<li class="detail-item">
<strong>{{ item.field_name|default:"冲突字段" }}</strong>
<div>{{ item.issue|default:item }}</div>
</li>
{% empty %}
<li class="detail-item">当前无一致性冲突。</li>
{% endfor %}
{% elif analysis_card.kind == "field_extraction" %}
{% for item in analysis_card.items %}
<li class="detail-item">
<strong>{{ item.field_name|default:"抽取字段" }}</strong>
<div>{{ item.field_value|default:"-" }}</div>
{% if item.source_document %}
<div class="muted">来源:{{ item.source_document }}</div>
{% endif %}
</li>
{% empty %}
<li class="detail-item">当前无抽取字段。</li>
{% endfor %}
{% else %}
{% for item in analysis_card.items %}
<li class="detail-item">{{ item }}</li>
{% empty %}
<li class="detail-item">当前无明细结果。</li>
{% endfor %}
{% endif %}
</ul>
</div>
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">提示与异常</h3>
<ul class="detail-list">
{% for item in analysis_card.warnings %}
<li class="detail-item">
{% if analysis_card.kind == "field_extraction" %}
<strong>{{ item.field_name|default:"低置信度字段" }}</strong>
<div>{{ item.field_value|default:"-" }}</div>
{% if item.source_document %}
<div class="muted">来源:{{ item.source_document }}</div>
{% endif %}
{% elif analysis_card.kind == "completeness" %}
<strong>{{ item.document_name|default:"错放项" }}</strong>
<div>目标章节点:{{ item.chapter_code|default:"-" }}</div>
<div>当前归类:{{ item.current_location|default:"未识别" }}</div>
{% else %}
{{ item }}
{% endif %}
</li>
{% empty %}
<li class="detail-item">当前无额外提示。</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if risk_card %}
<div class="detail-item">
<strong>风险预警能力卡</strong>
<div>总风险等级:{{ risk_card.highest_risk_level|default:"-" }}</div>
<div>是否通过:{{ risk_card.pass_status|default:"-" }}</div>
{% if risk_card.summary %}
<div>{{ risk_card.summary }}</div>
{% endif %}
</div>
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">待复核与风险项</h3>
<ul class="detail-list">
{% for item in risk_card.manual_review_items %}
<li class="detail-item">{{ item }}</li>
{% empty %}
{% for item in risk_card.risk_items %}
<li class="detail-item">
<strong>{{ item.title|default:item.issue }}</strong>
<div class="muted">{{ item.risk_level|default:"-" }}</div>
</li>
{% empty %}
<li class="detail-item">当前无待复核项。</li>
{% endfor %}
{% endfor %}
</ul>
</div>
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">责任角色</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>owner_role</th>
<th>owner_name</th>
<th>department</th>
<th>chapter_scope</th>
<th>risk_scope</th>
<th>feishu_user_id</th>
<th>feishu_open_id</th>
<th>feishu_name</th>
<th>notify_enabled</th>
</tr>
</thead>
<tbody>
{% for item in risk_card.owner_roles %}
<tr>
<td>{{ item.owner_role }}</td>
<td>{{ item.owner_name }}</td>
<td>{{ item.department }}</td>
<td>{{ item.chapter_scope }}</td>
<td>{{ item.risk_scope }}</td>
<td>{{ item.feishu_user_id }}</td>
<td>{{ item.feishu_open_id }}</td>
<td>{{ item.feishu_name }}</td>
<td>{{ item.notify_enabled|yesno:"True,False" }}</td>
</tr>
{% empty %}
<tr><td colspan="9">当前无责任角色。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="detail-item">
{% if export_card %}
<strong>Word 导出能力卡</strong>
<div>模板:{{ export_card.template_name }} / {{ export_card.template_version|default:"-" }}</div>
<div>当前导出状态:{{ export_card.export_status|default:"-" }}</div>
{% elif notify_card %}
<strong>飞书通知能力卡</strong>
<div>通知原因:{{ notify_card.notify_reason|default:"-" }}</div>
<div>消息状态:{{ notify_card.message_status|default:"-" }}</div>
{% endif %}
</div>
{% if export_card %}
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">回填字段表</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>占位符</th>
<th>字段名</th>
<th>字段值</th>
<th>来源</th>
<th>回填状态</th>
<th>是否必填</th>
</tr>
</thead>
<tbody>
{% for item in export_card.filled_fields %}
<tr>
<td>{{ item.placeholder }}</td>
<td>{{ item.field_name }}</td>
<td>{{ item.field_value }}</td>
<td>{{ item.source }}</td>
<td>{{ item.fill_status }}</td>
<td>{{ item.required|yesno:"是,否" }}</td>
</tr>
{% empty %}
<tr><td colspan="6">当前暂无回填字段。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if export_card %}
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">拦截项区</h3>
<ul class="detail-list">
{% for item in export_card.blocked_fields %}
<li class="detail-item">
<strong>{{ item.field_name }}</strong>
<div>拦截原因:{{ item.block_reason }}</div>
<div class="muted">来源:{{ item.risk_source }}</div>
</li>
{% empty %}
<li class="detail-item">当前无拦截项。</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if notify_card %}
<div class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 1rem;">飞书通知能力卡</h3>
<ul class="detail-list">
<li class="detail-item"><strong>通知原因</strong><div>{{ notify_card.notify_reason|default:"-" }}</div></li>
<li class="detail-item"><strong>消息状态</strong><div>{{ notify_card.message_status|default:"-" }}</div></li>
<li class="detail-item"><strong>被 @ 处理人</strong><div>{{ notify_card.mentioned_users|join:" / "|default:"-" }}</div></li>
<li class="detail-item"><strong>Web 详情链接</strong><div>{{ notify_card.web_detail_url|default:"-" }}</div></li>
</ul>
</div>
{% endif %}
<div class="button-row">
{% if export_card %}
<a class="button" href="{% url 'platform_ui:knowledge-base' %}?view=template_mappings">维护 Word 模板</a>
<a class="button" href="{% url 'platform_ui:knowledge-base' %}?view=field_schemas">维护字段映射</a>
<a class="button" href="{% url 'audit:list' %}">查看导出记录</a>
{% endif %}
{% if risk_card %}
<a class="button" href="{% url 'platform_ui:knowledge-base' %}?view=owner_mappings">查看责任人映射</a>
{% endif %}
{% if notify_card %}
<a class="button" href="{% url 'platform_ui:knowledge-base' %}?view=feishu_configs">查看飞书配置</a>
{% endif %}
</div>
</div>
{% if result %}
<table class="kv-table">
<caption style="text-align:left; padding-bottom:12px; color:var(--ink-soft);">结构化结果</caption>
<tbody>
{% for key, value in result.structured_output.items %}
<tr>
<th>{{ key }}</th>
<td>
{% if key == "answer" or key == "summary" or key == "reply" %}
{{ value|linebreaksbr }}
{% else %}
<pre class="code-block">{{ value }}</pre>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="notice">执行任务后,这里会展示结构化审核结果和回填准备信息。</div>
{% endif %}
</article>
{% elif result and result.structured_output %}
<table class="kv-table">
<tbody>
{% for key, value in result.structured_output.items %}
<tr>
<th>{{ key }}</th>
<td><pre class="code-block">{{ value }}</pre></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="notice">执行任务后,这里会展示结构化节点结果。</div>
{% endif %}
</article>
</div>
<article class="panel">
<h2 class="section-title">引用与审计</h2>
<div class="stack">
<article class="panel">
<h2 class="section-title">上传区</h2>
<p class="section-copy">右侧保留资料包上传入口和当前会话的资料上下文。</p>
{% if batch %}
<ul class="detail-list">
<li class="detail-item">可查看引用片段、工具调用和本次审计日志。</li>
<li class="detail-item">
<strong>当前资料包</strong>
<div>批次:{{ batch.batch_id }}</div>
<div>文件数:{{ batch.file_count }}</div>
<div>页数:{{ batch.page_count }}</div>
<div>导入状态:{{ batch.get_import_status_display_text }}</div>
</li>
</ul>
{% if audit_log %}
<div class="button-row" style="margin-top: 16px;">
<a class="button" href="{% url 'audit:detail' audit_log.id %}">查看本次审计日志</a>
<form method="post" action="{% url 'chat:upload-documents' conversation.conversation_id %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
{% csrf_token %}
<div>
{{ upload_form.files.label_tag }}
{{ upload_form.files }}
</div>
<div class="button-row">
<button type="submit">继续上传资料</button>
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
</div>
</form>
<div class="button-row" style="margin-top: 16px;">
<a class="button" href="{% url 'documents:upload' %}">导入新资料包</a>
</div>
<form method="post" action="{% url 'chat:export-word' conversation.conversation_id %}" class="stack" style="margin-top: 16px;">
{% csrf_token %}
<div class="button-row">
<button type="submit">生成导出草稿</button>
</div>
</form>
{% else %}
<div class="notice">暂无绑定资料包。</div>
{% endif %}
</article>
<article class="panel">
<h2 class="section-title">动态信息卡</h2>
<ul class="detail-list">
<li class="detail-item">
<strong>最高风险等级</strong>
<div>{{ workspace_summary.highest_risk_level }}</div>
</li>
<li class="detail-item">
<strong>是否允许正式导出</strong>
<div>{{ workspace_summary.export_allowed }}</div>
</li>
<li class="detail-item">
<strong>通知状态</strong>
<div>{{ workspace_summary.notify_status }}</div>
</li>
<li class="detail-item">
<strong>导出状态</strong>
<div>{{ workspace_summary.export_status }}</div>
</li>
<li class="detail-item">
<strong>导出下载地址</strong>
<div>
{% if workspace_summary.download_url %}
<a href="{{ workspace_summary.download_url }}">下载导出文件</a>
{% else %}
-
{% endif %}
</div>
</li>
<li class="detail-item">当前会话围绕 `conversation_id / batch_id / product_name` 串联。</li>
{% if audit_log %}
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
{% endif %}
</article>
</div>
</section>
{% endif %}
</ul>
</article>
</div>
</section>
{% endblock %}

View File

@@ -1,36 +1,128 @@
{% extends "base.html" %}
{% block title %}文件中心{% endblock %}
{% block title %}资料包{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Documents</span>
<h1 class="page-title">文件中心</h1>
<p class="page-lead">上传资料、查看状态、执行入库。页面只保留最常用操作</p>
<span class="eyebrow">Submission Batches</span>
<h1 class="page-title">资料包</h1>
<p class="page-lead">按产品名称管理资料包,并查看会话绑定、目录概览和待复核状态</p>
<div class="button-row">
<a class="button button-primary" href="{% url 'documents:upload' %}">上传文件</a>
<a class="button button-primary" href="{% url 'documents:upload' %}">导入资料包</a>
</div>
</section>
<section class="metric-grid">
<article class="metric-card">
<div class="metric-label">文件总数</div>
<div class="metric-label">资料包总数</div>
<div class="metric-value">{{ status_counts.total }}</div>
</article>
<article class="metric-card">
<div class="metric-label">已完成入库</div>
<div class="metric-value">{{ status_counts.indexed }}</div>
<div class="metric-label">已完成</div>
<div class="metric-value">{{ status_counts.completed }}</div>
</article>
<article class="metric-card">
<div class="metric-label">入库</div>
<div class="metric-value">{{ status_counts.uploaded }}</div>
<div class="metric-label">复核</div>
<div class="metric-value">{{ status_counts.review_required }}</div>
</article>
<article class="metric-card">
<div class="metric-label">失败</div>
<div class="metric-value">{{ status_counts.failed }}</div>
<div class="metric-label">待导入</div>
<div class="metric-value">{{ status_counts.pending }}</div>
</article>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">按产品名称搜索</h2>
<p class="section-copy">支持按产品名称定位资料包,并跳转到关联会话。</p>
</div>
</div>
<form method="get" class="grid-2">
<div>
<label for="id_keyword">产品名称</label>
<input id="id_keyword" type="text" name="keyword" value="{{ keyword }}" placeholder="请输入产品名称关键词">
</div>
<div class="button-row" style="align-items: end;">
<button type="submit">搜索资料包</button>
<a class="button" href="{% url 'documents:list' %}">清空</a>
</div>
</form>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">资料包列表</h2>
<p class="section-copy">资料包与会话一一绑定,会话标题默认采用解析后的产品名称。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>批次号</th>
<th>产品名称</th>
<th>会话</th>
<th>文件数</th>
<th>页数</th>
<th>状态</th>
<th>最近导出</th>
<th>章节点概览</th>
</tr>
</thead>
<tbody>
{% for row in batch_rows %}
{% with batch=row.batch latest_export=row.latest_export %}
<tr>
<td class="nowrap">{{ batch.batch_id }}</td>
<td>{{ batch.product_name|default:"未识别产品名称" }}</td>
<td class="cell-min-220">
{% if batch.conversation_id %}
<a class="button" href="{% url 'chat:detail' batch.conversation_id %}">查看对话 {{ batch.conversation_id }}</a>
{% else %}
<span class="muted">尚未绑定</span>
{% endif %}
</td>
<td>{{ batch.file_count }}</td>
<td>{{ batch.page_count }}</td>
<td>
<span class="pill {% if batch.import_status == 'completed' %}pill-success{% elif batch.import_status == 'review_required' %}pill-signal{% else %}pill-danger{% endif %}">
{{ batch.get_import_status_display_text }}
</span>
</td>
<td class="cell-min-220">
{% if latest_export %}
<div>{{ latest_export.file_name }}</div>
<div class="muted">{{ latest_export.export_mode }} / {{ latest_export.template_version|default:"-" }}</div>
<div class="button-row" style="margin-top: 8px;">
<a class="button" href="{% url 'audit:list' %}?keyword={{ batch.batch_id }}">查看导出记录</a>
</div>
{% else %}
<span class="muted">暂无导出记录</span>
{% endif %}
</td>
<td class="cell-min-280">
{% if batch.chapter_summary %}
{% for chapter in batch.chapter_summary %}
<div>{{ chapter.chapter_code }} / {{ chapter.document_count }} 份</div>
{% endfor %}
{% else %}
<span class="muted">暂无目录汇总</span>
{% endif %}
</td>
</tr>
{% endwith %}
{% empty %}
<tr>
<td colspan="8">暂无资料包,请先导入申报资料。</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel">
<div class="section-heading">
<div>
@@ -48,11 +140,43 @@
</ul>
</section>
<section class="grid-2">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">处理链路总览</h2>
<p class="section-copy">资料包导入后,会沿固定链路进入解析、归类、切片和审核上下文准备。</p>
</div>
</div>
<ul class="detail-list">
{% for item in processing_pipeline %}
<li class="detail-item">
<strong>{{ item.title }}</strong>
<div class="muted">{{ item.detail }}</div>
</li>
{% endfor %}
</ul>
</article>
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">导出与处理回看</h2>
<p class="section-copy">围绕 `batch_id / conversation_id / product_name` 回看审核、导出和通知留痕。</p>
</div>
</div>
<div class="button-row">
<a class="button" href="{% url 'audit:list' %}">查看处理历史</a>
<a class="button" href="{% url 'platform_ui:knowledge-base' %}?view=template_mappings">维护导出模板</a>
</div>
</article>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">资料目录总览</h2>
<p class="section-copy">页面下方保留真实文件记录与手动入库动作,保证演示原型仍基于当前系统能力运行</p>
<p class="section-copy">保留文件明细,便于说明目录识别、页数统计和异常定位</p>
</div>
</div>
<div class="table-wrap">
@@ -60,7 +184,9 @@
<thead>
<tr>
<th>文件名</th>
<th>批次</th>
<th>场景</th>
<th>章节点</th>
<th>类型</th>
<th>大小</th>
<th>状态</th>
@@ -71,7 +197,9 @@
{% for document in documents %}
<tr>
<td>{{ document.original_name }}</td>
<td>{{ document.batch.batch_id|default:"-" }}</td>
<td>{{ document.scenario_id }}</td>
<td>{{ document.chapter_code|default:"待识别" }}</td>
<td>{{ document.file_type }}</td>
<td>{{ document.size }}</td>
<td>
@@ -98,7 +226,7 @@
</tr>
{% empty %}
<tr>
<td colspan="6">暂无文件,请先导入申报资料或法规原文。</td>
<td colspan="8">暂无文件,请先导入申报资料或法规原文。</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -14,7 +14,7 @@
<div class="section-heading">
<div>
<h2 class="section-title">资料导入向导</h2>
<p class="section-copy">当前支持 `.txt`、`.md`、`.pdf``.docx`。上传成功后即可回到文件中心执行解析与入库</p>
<p class="section-copy">当前支持多文件上传,以及 `.txt`、`.md`、`.pdf``.docx`、`.zip`、`.7z` 与 `.rar` 资料包。上传成功后会直接形成一个资料包并绑定会话</p>
</div>
</div>
<form method="post" enctype="multipart/form-data" class="stack">
@@ -27,18 +27,19 @@
{% endif %}
</div>
<div>
{{ form.file.label_tag }}
{{ form.file }}
{% if form.file.errors %}
<p class="notice notice-error">{{ form.file.errors|join:" " }}</p>
{{ form.files.label_tag }}
{{ form.files }}
{% if form.files.errors %}
<p class="notice notice-error">{{ form.files.errors|join:" " }}</p>
{% endif %}
<p class="help-text">可一次选择多份文件,或上传一个 zip / 7z / rar 资料包。</p>
</div>
{% if form.errors %}
<div class="notice notice-error">{{ form.errors }}</div>
{% endif %}
<div class="button-row">
<button type="submit">确认导入</button>
<a class="button" href="{% url 'documents:list' %}">返回文件中心</a>
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
</div>
</form>
</article>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}工作台大屏{% endblock %}
{% block title %}审核指挥台概览{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Workbench</span>
<h1 class="page-title">工作台</h1>
<p class="page-lead">用简洁视图展示当前批次状态、主要问题和下一步动作。</p>
<span class="eyebrow">Command View</span>
<h1 class="page-title">审核指挥台概览</h1>
<p class="page-lead">用简洁视图展示当前批次状态、主要问题和下一步动作,作为审核指挥台的轻量概览页</p>
</section>
<section class="metric-grid">

View File

@@ -61,6 +61,22 @@
padding: 8px 0;
}
.nav-group {
display: grid;
gap: 4px;
}
.nav-group + .nav-group {
margin-top: 12px;
}
.nav-group-label {
padding: 10px 14px 6px;
color: rgba(220, 236, 255, 0.72);
font-size: 0.76rem;
letter-spacing: 0.08em;
}
.side-link {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
@@ -897,16 +913,22 @@
<div>
<button class="side-menu-button" type="button" data-action="toast" data-message="菜单已收起为当前原型导航。">=</button>
<nav class="side-nav">
<a class="side-link active" href="{% url 'platform_ui:command-center-v2' %}"><span class="side-icon">W</span><span>工作台</span></a>
<a class="side-link" href="{% url 'scenarios:index' %}"><span class="side-icon">T</span><span>任务中心</span><span class="nav-badge">8</span></a>
<a class="side-link" href="{% url 'documents:list' %}"><span class="side-icon">D</span><span>资料包管理</span></a>
<a class="side-link" href="{% url 'platform_ui:knowledge-base' %}"><span class="side-icon">R</span><span>法规库</span></a>
<a class="side-link" href="{% url 'chat:index' 'document_review' %}"><span class="side-icon">A</span><span>智能审核</span></a>
<a class="side-link" href="#"><span class="side-icon">!</span><span>风险管理</span></a>
<a class="side-link" href="#"><span class="side-icon">Q</span><span>问题清单</span></a>
<a class="side-link" href="{% url 'platform_ui:mcp-center' %}"><span class="side-icon">F</span><span>飞书通知</span></a>
<a class="side-link" href="{% url 'audit:list' %}"><span class="side-icon">S</span><span>统计分析</span></a>
<a class="side-link" href="#"><span class="side-icon">G</span><span>系统设置</span></a>
<div class="nav-group" aria-label="一级产品入口">
<div class="nav-group-label">一级产品</div>
<a class="side-link" href="{% url 'chat:index' %}"><span class="side-icon">A</span><span>审核智能体</span></a>
<a class="side-link" href="{% url 'documents:list' %}"><span class="side-icon">D</span><span>资料包</span></a>
<a class="side-link" href="{% url 'platform_ui:knowledge-base' %}"><span class="side-icon">R</span><span>知识库</span></a>
<a class="side-link" href="{% url 'audit:list' %}"><span class="side-icon">S</span><span>处理历史</span></a>
</div>
<div class="nav-group" aria-label="协同与治理入口">
<div class="nav-group-label">协同治理</div>
<a class="side-link active" href="{% url 'platform_ui:command-center-v2' %}"><span class="side-icon">W</span><span>审核指挥台</span></a>
<a class="side-link" href="{% url 'platform_ui:mcp-center' %}"><span class="side-icon">F</span><span>飞书通知</span></a>
<a class="side-link" href="{% url 'scenarios:index' %}"><span class="side-icon">T</span><span>场景配置参考</span><span class="nav-badge">8</span></a>
<a class="side-link" href="#"><span class="side-icon">!</span><span>风险预警</span></a>
<a class="side-link" href="#"><span class="side-icon">Q</span><span>问题清单</span></a>
<a class="side-link" href="#"><span class="side-icon">G</span><span>系统设置</span></a>
</div>
</nav>
</div>
<a class="collapse-link" href="#" data-action="toast" data-message="已模拟收起菜单。">&lt; 收起菜单</a>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}知识库配置{% endblock %}
{% block title %}知识库{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Knowledge Base</span>
<h1 class="page-title">知识库配置</h1>
<p class="page-lead">支持传统增删改查:新增知识源、编辑规则项、删除无效配置、筛选和搜索当前知识内容</p>
<span class="eyebrow">Knowledge Governance</span>
<h1 class="page-title">知识库</h1>
<p class="page-lead">统一承载法规资料、业务资料、RAG 治理、字段标准、责任人映射和飞书通知配置</p>
</section>
<section class="metric-grid">
@@ -18,49 +18,150 @@
{% endfor %}
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">操作栏</h2>
<p class="section-copy">把知识库做成传统后台,而不是只读展示页。</p>
</div>
<div class="button-row">
<a class="button button-primary" href="#">新增知识源</a>
<a class="button" href="#">新增规则项</a>
<a class="button" href="#">批量删除</a>
</div>
</div>
<div class="grid-3">
<div>
<label for="knowledge-search">搜索</label>
<input id="knowledge-search" type="text" value="体外诊断试剂" />
</div>
<div>
<label for="knowledge-type">知识类型</label>
<select id="knowledge-type">
{% for filter in knowledge_filters %}
<option{% if filter.active %} selected{% endif %}>{{ filter.label }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="knowledge-status">状态</label>
<select id="knowledge-status">
<option selected>全部状态</option>
<option>已生效</option>
<option>待人工校订</option>
<option>已入库</option>
</select>
</div>
</div>
</section>
<section class="workspace-grid" style="grid-template-columns: 280px minmax(0, 1fr);">
<article class="panel">
<h2 class="section-title">治理对象导航</h2>
<p class="section-copy">按治理对象切换当前列表和维护入口保持法规、RAG、字段、模板和通知口径统一。</p>
<ul class="detail-list">
{% for item in governance_objects %}
<li class="detail-item">
<strong>
<a href="?view={{ item.key }}">{{ item.title }}</a>
</strong>
<div class="muted">{{ item.summary }}</div>
{% if active_governance_object.key == item.key %}
<div class="badge-row" style="margin-top: 8px;">
<span class="pill pill-accent">当前对象:{{ item.title }}</span>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</article>
<section class="workspace-grid-wide">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">知识源管理</h2>
<p class="section-copy">传统 CRUD 列表,直接在表格里做查看、编辑、删除。</p>
<h2 class="section-title">{{ active_governance_object.detail_title }}</h2>
<p class="section-copy">{{ active_governance_object.detail_copy }}</p>
</div>
<div class="button-row">
<a class="button" href="{{ active_governance_object.admin_url }}">进入后台维护</a>
</div>
</div>
<div class="badge-row" style="margin-bottom: 14px;">
<span class="pill pill-accent">当前对象:{{ active_governance_object.title }}</span>
</div>
<div class="grid-3">
{% for action in active_governance_object.actions %}
<article class="panel" style="padding: 14px;">
<h3 class="section-title" style="font-size: 0.98rem;">{{ action }}</h3>
<p class="section-copy">该操作在 V1 通过后台维护与治理台入口统一承接。</p>
</article>
{% endfor %}
</div>
</article>
</section>
<section class="grid-2">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">{{ governance_action_hub.title }}</h2>
<p class="section-copy">用当前治理对象作为焦点,快速进入审核、资料包和处理历史入口。</p>
</div>
</div>
<ul class="detail-list">
<li class="detail-item">
<strong>当前治理对象</strong>
<div>{{ governance_action_hub.current_object }}</div>
<div class="muted">{{ governance_action_hub.current_summary }}</div>
</li>
</ul>
<div class="badge-row" style="margin-top: 12px;">
{% for item in governance_action_hub.status_items %}
<span class="pill pill-accent">{{ item.label }}{{ item.value }}</span>
{% endfor %}
</div>
<div class="button-row" style="margin-top: 16px;">
{% for action in governance_action_hub.quick_actions %}
<a class="button" href="{{ action.url }}">{{ action.label }}</a>
{% endfor %}
</div>
</article>
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">固定通知策略</h2>
<p class="section-copy">Demo 首版固定仅支持执行完成或执行异常两类飞书通知口径。</p>
</div>
</div>
<ul class="detail-list">
{% for reason in governance_action_hub.notify_reasons %}
<li class="detail-item">
<strong>{{ reason }}</strong>
<div class="muted">
{% if reason == "task_completed" %}
审核执行完成后直接通知处理人。
{% else %}
审核执行异常后直接通知处理人。
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</article>
</section>
<section class="grid-3">
{% for section in governance_sections %}
<article class="panel">
<h2 class="section-title">{{ section.title }}</h2>
<p class="section-copy">{{ section.desc }}</p>
</article>
{% endfor %}
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">法规规则包</h2>
<p class="section-copy">按章-条-要求项-模板字段维护规则包,并向审核节点输出结构化依据。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>编码</th>
<th>章节</th>
<th>规则名称</th>
<th>模板字段</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for item in rule_tree %}
<tr>
<td>{{ item.code }}</td>
<td>{{ item.chapter }}</td>
<td>{{ item.item }}</td>
<td>{{ item.field }}</td>
<td>{{ item.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="grid-2">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">RAG 文档源</h2>
<p class="section-copy">支持法规、模板与业务资料分层管理。</p>
</div>
</div>
<div class="table-wrap">
@@ -72,32 +173,16 @@
<th>类型</th>
<th>范围</th>
<th>状态</th>
<th>负责人</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for source in knowledge_sources %}
<tr>
<td class="nowrap">{{ source.code }}</td>
<td><div class="cell-min-280">{{ source.name }}</div></td>
<td class="nowrap">{{ source.type }}</td>
<td class="nowrap">{{ source.scope }}</td>
<td>
<span class="pill {% if source.status == '已生效' or source.status == '已入库' %}pill-success{% else %}pill-signal{% endif %}">
{{ source.status }}
</span>
</td>
<td class="nowrap">{{ source.owner }}</td>
<td class="nowrap">{{ source.updated_at }}</td>
<td>
<div class="button-row">
<a class="button" href="#">查看</a>
<a class="button" href="#">编辑</a>
<a class="button" href="#">删除</a>
</div>
</td>
<td>{{ source.code }}</td>
<td>{{ source.name }}</td>
<td>{{ source.type }}</td>
<td>{{ source.scope }}</td>
<td>{{ source.status }}</td>
</tr>
{% endfor %}
</tbody>
@@ -105,73 +190,69 @@
</div>
</article>
<div class="stack">
<article class="panel">
<h2 class="section-title">{{ knowledge_form.title }}</h2>
<div class="stack" style="margin-top: 14px;">
{% for field in knowledge_form.fields %}
<div>
<label>{{ field.label }}</label>
<input type="text" value="{{ field.value }}" />
</div>
{% endfor %}
<div class="button-row">
<a class="button button-primary" href="#">保存知识源</a>
<a class="button" href="#">重置</a>
</div>
</div>
</article>
<article class="panel">
<h2 class="section-title">批量操作</h2>
<ul class="detail-list">
<li class="detail-item">支持批量启用 / 停用知识源</li>
<li class="detail-item">支持批量重建切片</li>
<li class="detail-item">支持批量删除过期规则</li>
</ul>
</article>
</div>
</section>
<section class="grid-2">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">规则项管理</h2>
<p class="section-copy">规则项也用传统表格管理,支持新增、编辑、删除</p>
<h2 class="section-title">RAG 切片</h2>
<p class="section-copy">展示切片摘要、章节与召回状态</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>编码</th>
<th>切片 ID</th>
<th>文档</th>
<th>章节</th>
<th>规则名称</th>
<th>模板字段</th>
<th>摘要</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for item in rule_tree %}
{% for chunk in rag_chunks %}
<tr>
<td class="nowrap">{{ item.code }}</td>
<td class="nowrap">{{ item.chapter }}</td>
<td><div class="cell-min-220">{{ item.item }}</div></td>
<td><div class="cell-min-220">{{ item.field }}</div></td>
<td>
<span class="pill {% if item.status == '启用' %}pill-success{% else %}pill-signal{% endif %}">
{{ item.status }}
</span>
</td>
<td>
<div class="button-row">
<a class="button" href="#">查看</a>
<a class="button" href="#">编辑</a>
<a class="button" href="#">删除</a>
</div>
</td>
<td>{{ chunk.chunk_id }}</td>
<td>{{ chunk.document_name }}</td>
<td>{{ chunk.chapter }}</td>
<td>{{ chunk.summary }}</td>
<td>{{ chunk.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
<section class="grid-2">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">字段 Schema</h2>
<p class="section-copy">维护强一致字段、回填字段和来源优先级。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>字段编码</th>
<th>字段名称</th>
<th>类型</th>
<th>可回填</th>
<th>强一致</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for field in field_schemas %}
<tr>
<td>{{ field.field_code }}</td>
<td>{{ field.field_name }}</td>
<td>{{ field.field_type }}</td>
<td>{{ field.fillable }}</td>
<td>{{ field.strict_consistency }}</td>
<td>{{ field.status }}</td>
</tr>
{% endfor %}
</tbody>
@@ -180,19 +261,114 @@
</article>
<article class="panel">
<h2 class="section-title">{{ rule_form.title }}</h2>
<div class="stack" style="margin-top: 14px;">
{% for field in rule_form.fields %}
<div>
<label>{{ field.label }}</label>
<input type="text" value="{{ field.value }}" />
</div>
{% endfor %}
<div class="button-row">
<a class="button button-primary" href="#">保存规则项</a>
<a class="button" href="#">删除规则项</a>
<div class="section-heading">
<div>
<h2 class="section-title">Word 模板与字段映射</h2>
<p class="section-copy">维护输出模板版本、占位符数量和字段映射摘要。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>模板名称</th>
<th>输出类型</th>
<th>版本</th>
<th>占位符数量</th>
<th>字段映射摘要</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for item in template_mappings %}
<tr>
<td>{{ item.template_name }}</td>
<td>{{ item.output_type }}</td>
<td>{{ item.version }}</td>
<td>{{ item.placeholder_count }}</td>
<td>{{ item.field_mapping_summary }}</td>
<td>{{ item.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
<section class="grid-1">
<article class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">责任人映射</h2>
<p class="section-copy">责任人实体字段直接按通知载荷口径展示。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>owner_role</th>
<th>owner_name</th>
<th>department</th>
<th>chapter_scope</th>
<th>risk_scope</th>
<th>feishu_user_id</th>
<th>feishu_open_id</th>
<th>feishu_name</th>
<th>notify_enabled</th>
</tr>
</thead>
<tbody>
{% for item in owner_mappings %}
<tr>
<td>{{ item.owner_role }}</td>
<td>{{ item.owner_name }}</td>
<td>{{ item.department }}</td>
<td>{{ item.chapter_scope }}</td>
<td>{{ item.risk_scope }}</td>
<td>{{ item.feishu_user_id }}</td>
<td>{{ item.feishu_open_id }}</td>
<td>{{ item.feishu_name }}</td>
<td>{{ item.notify_enabled }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</article>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">飞书通知配置</h2>
<p class="section-copy">V1 Demo 固定支持 `task_completed` 与 `task_failed` 两类通知。</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>配置名称</th>
<th>notify_reason</th>
<th>渠道</th>
<th>消息模板</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for item in feishu_configs %}
<tr>
<td>{{ item.config_name }}</td>
<td>{{ item.notify_reason }}</td>
<td>{{ item.channel }}</td>
<td>{{ item.message_template }}</td>
<td>{{ item.status }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}

View File

@@ -6,7 +6,7 @@
<section class="page-header">
<span class="eyebrow">MCP Connectors</span>
<h1 class="page-title">外部 MCP 能力导入与协同接入中心</h1>
<p class="page-lead">MCP 页面说明平台不是封闭工作台,它可以导入法规源、飞书通知、模板服务和企业数据源,但仍服务于注册审核这一条主线。</p>
<p class="page-lead">MCP 页面说明平台不是封闭单体页面,它可以导入法规源、飞书通知、模板服务和企业数据源,但仍服务于注册审核这一条主线。</p>
</section>
<section class="card-grid">

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block title %}任务总览{% endblock %}
{% block title %}平台总览{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Overview</span>
<h1 class="page-title">批次总览</h1>
<p class="page-lead">从这里直接进入知识库、文件中心、审核工作台和审计页。保留必要信息,不堆大段说明</p>
<h1 class="page-title">平台总览</h1>
<p class="page-lead">围绕四个一级入口组织 Agent 化产品形态:审核智能体、资料包、知识库、处理历史。首页只负责快速分流,不再以旧场景列表作为主叙事</p>
</section>
<section class="metric-grid">
@@ -18,30 +18,45 @@
{% endfor %}
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">四个一级入口</h2>
<p class="section-copy">优先从产品入口进入资料导入、Agent 审核、知识治理和处理回看;底层场景配置仅作为执行能力参考。</p>
</div>
</div>
<div class="button-row">
<a class="button" href="{% url 'chat:index' %}">进入审核智能体</a>
<a class="button" href="{% url 'documents:list' %}">查看资料包</a>
<a class="button" href="{% url 'platform_ui:knowledge-base' %}">进入知识库</a>
<a class="button" href="{% url 'audit:list' %}">查看处理历史</a>
</div>
</section>
<section class="grid-2">
<a class="link-card" href="{% url 'platform_ui:knowledge-base' %}">
<h3>知识库配置</h3>
<h3>知识库</h3>
<p>查看规则树、知识源和切片策略。</p>
</a>
<a class="link-card" href="{% url 'documents:list' %}">
<h3>文件中心</h3>
<p>上传资料、执行入库、查看状态</p>
<h3>资料包</h3>
<p>导入资料包,按产品名称搜索并跳转关联会话</p>
</a>
<a class="link-card" href="{% url 'chat:index' 'document_review' %}">
<h3>审核工作台</h3>
<p>输入问题、选择文档、查看结果。</p>
<a class="link-card" href="{% url 'chat:index' %}">
<h3>审核智能体</h3>
<p>进入会话页,查看节点式审核结果。</p>
</a>
<a class="link-card" href="{% url 'audit:list' %}">
<h3>审计日志</h3>
<p>查看每次执行的输入、输出和引用</p>
<h3>处理历史</h3>
<p>查看每次执行的输入、输出和通知留痕</p>
</a>
</section>
<section class="panel">
<div class="section-heading">
<div>
<h2 class="section-title">已配置审核场景</h2>
<p class="section-copy">保留现有场景列表,直接进入使用</p>
<h2 class="section-title">底层审核场景配置</h2>
<p class="section-copy">场景 YAML 继续作为 Agent Core 的底层执行配置,这里仅保留场景配置参考,不作为一级产品入口</p>
</div>
</div>
@@ -71,7 +86,7 @@
{% endif %}
</p>
<div class="button-row" style="margin-top: 16px;">
<a class="button button-primary" href="{% url 'chat:index' scenario.id %}">进入审核工作台</a>
<a class="button button-primary" href="{% url 'chat:index' %}">场景配置参考</a>
</div>
</article>
{% empty %}

View File

@@ -1,6 +1,8 @@
from agent_core.orchestrator import build_messages, run_agent
from agent_core.rag.ingest import _split_text, ingest_document
from agent_core.rag.retriever import retrieve
from agent_core.schemas.outputs import SUPPORTED_OUTPUT_TYPES
from django.test import override_settings
def test_run_agent_returns_structured_result_from_llm_output():
@@ -248,3 +250,344 @@ def test_retrieve_returns_empty_when_query_has_no_overlap(tmp_path):
)
assert chunks == []
def test_registration_risk_result_includes_owner_fields_and_notification_payload():
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出风险结果",
"instructions": ["输出结构化风险结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "registration_risk_report"},
}
provider_response = """
{
"summary": "存在高风险项,需人工复核。",
"highest_risk_level": "high",
"pass_status": "blocked",
"owner_roles": [
{
"owner_role": "注册资料负责人",
"owner_name": "张三",
"department": "注册事务部",
"chapter_scope": "CH1",
"risk_scope": "字段冲突",
"feishu_user_id": "ou_demo_1",
"feishu_open_id": "on_demo_1",
"feishu_name": "张三",
"notify_enabled": true
}
],
"notify_reason": "task_completed"
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
result = run_agent(scenario, "请输出风险结果", options={"llm_provider": FakeProvider()})
owner = result.notification_payload["owners"][0]
assert result.structured_output["output_type"] == "registration_risk_report"
assert owner["owner_role"] == "注册资料负责人"
assert owner["feishu_user_id"] == "ou_demo_1"
assert owner["feishu_open_id"] == "on_demo_1"
assert result.notification_payload["notify_reason"] == "task_completed"
def test_supported_output_types_include_word_export_and_feishu_notification():
assert "registration_word_export_report" in SUPPORTED_OUTPUT_TYPES
assert "feishu_notification_report" in SUPPORTED_OUTPUT_TYPES
def test_registration_risk_report_builds_eight_business_nodes():
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出风险结果",
"instructions": ["输出结构化风险结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "registration_risk_report"},
}
provider_response = """
{
"summary": "存在高风险项,需人工复核。",
"highest_risk_level": "high",
"pass_status": "blocked",
"owner_roles": [],
"notify_reason": "task_completed"
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
result = run_agent(scenario, "请输出风险结果", options={"llm_provider": FakeProvider()})
assert len(result.node_results) == 8
assert [node["label"] for node in result.node_results] == [
"资料包导入",
"目录汇总",
"法规完整性检查",
"字段抽取",
"一致性核查",
"风险预警",
"Word 回填导出",
"飞书通知",
]
assert result.node_results[5]["status"] == "已阻断"
assert result.node_results[6]["status"] == "待处理"
assert result.node_results[7]["status"] == "待处理"
@override_settings(GOVERNANCE_CONFIG_PATH="")
def test_registration_risk_payload_falls_back_to_governance_owner_mappings(tmp_path):
config_path = tmp_path / "governance.yaml"
config_path.write_text(
"""
owner_mappings:
- owner_role: 法规负责人
owner_name: 赵六
department: 法规事务部
chapter_scope: CH1
risk_scope: 高风险缺失
feishu_user_id: ou_governance_1
feishu_open_id: on_governance_1
feishu_name: 赵六
notify_enabled: 是
""".strip(),
encoding="utf-8",
)
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出风险结果",
"instructions": ["输出结构化风险结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "registration_risk_report"},
}
provider_response = """
{
"summary": "存在高风险项,需人工复核。",
"highest_risk_level": "high",
"pass_status": "blocked",
"owner_roles": [],
"notify_reason": "task_completed"
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
with override_settings(GOVERNANCE_CONFIG_PATH=config_path):
result = run_agent(scenario, "请输出风险结果", options={"llm_provider": FakeProvider()})
owner = result.notification_payload["owners"][0]
assert owner["owner_role"] == "法规负责人"
assert owner["feishu_user_id"] == "ou_governance_1"
assert result.notification_payload["notify_reason"] == "task_completed"
@override_settings(GOVERNANCE_CONFIG_PATH="")
def test_failed_agent_result_uses_governance_owner_mappings_for_failed_notification(tmp_path):
config_path = tmp_path / "governance.yaml"
config_path.write_text(
"""
owner_mappings:
- owner_role: 注册资料负责人
owner_name: 孙七
department: 注册事务部
chapter_scope: CH2
risk_scope: 执行异常
feishu_user_id: ou_failed_1
feishu_open_id: on_failed_1
feishu_name: 孙七
notify_enabled: 是
""".strip(),
encoding="utf-8",
)
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出风险结果",
"instructions": ["输出结构化风险结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "registration_risk_report"},
}
class FailedProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(
content="",
model_name="demo-model",
success=False,
error="provider down",
)
with override_settings(GOVERNANCE_CONFIG_PATH=config_path):
result = run_agent(scenario, "请输出风险结果", options={"llm_provider": FailedProvider()})
owner = result.notification_payload["owners"][0]
assert result.status == "failed"
assert result.notification_payload["notify_reason"] == "task_failed"
assert owner["owner_name"] == "孙七"
assert owner["feishu_open_id"] == "on_failed_1"
def test_feishu_notification_report_builds_notification_payload_with_receipt_and_node_status():
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出通知结果",
"instructions": ["输出结构化通知结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "feishu_notification_report"},
}
provider_response = """
{
"batch_id": "SUB-20260604-003",
"conversation_id": "conv-003",
"notify_reason": "task_completed",
"mentioned_users": ["ou_demo_1"],
"message_status": "sent",
"web_detail_url": "https://example.com/audit/3",
"receipt": {
"message_id": "msg-3",
"status": "sent"
}
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
result = run_agent(
scenario,
"请生成通知结果",
options={
"llm_provider": FakeProvider(),
"batch_id": "SUB-20260604-003",
"conversation_id": "conv-003",
"product_name": "产品C",
},
)
assert result.node_results[7]["status"] == "已发送"
assert result.notification_payload["message_status"] == "sent"
assert result.notification_payload["web_detail_url"] == "https://example.com/audit/3"
assert result.notification_payload["receipt"]["message_id"] == "msg-3"
def test_notification_payload_normalizes_unsupported_notify_reason():
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出通知结果",
"instructions": ["输出结构化通知结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "feishu_notification_report"},
}
provider_response = """
{
"batch_id": "SUB-20260604-004",
"conversation_id": "conv-004",
"notify_reason": "custom_reason",
"mentioned_users": ["ou_demo_1"],
"message_status": "sent"
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
result = run_agent(
scenario,
"请生成通知结果",
options={
"llm_provider": FakeProvider(),
"batch_id": "SUB-20260604-004",
"conversation_id": "conv-004",
"product_name": "产品D",
},
)
assert result.notification_payload["notify_reason"] == "task_completed"
def test_registration_word_export_report_preserves_formal_export_flag_and_blocked_items():
scenario = {
"id": "document_review",
"name": "注册审核智能体",
"agent": {
"role": "注册审核助手",
"goal": "输出导出结果",
"instructions": ["输出结构化导出结果"],
},
"rag": {"enabled": False},
"tools": [],
"output": {"type": "registration_word_export_report"},
}
provider_response = """
{
"summary": "已生成草稿导出包。",
"export_status": "draft_only",
"can_export_formally": false,
"blocked_items": ["风险项未清零", "需人工复核后再导出"],
"download_url": "/downloads/registration-draft.docx"
}
"""
class FakeProvider:
def generate(self, messages, response_format=None):
from agent_core.llm_provider import LLMResponse
return LLMResponse(content=provider_response, model_name="demo-model", success=True)
result = run_agent(scenario, "请输出导出结果", options={"llm_provider": FakeProvider()})
assert result.structured_output["can_export_formally"] is False
assert result.structured_output["download_url"] == "/downloads/registration-draft.docx"
assert result.structured_output["blocked_items"] == ["风险项未清零", "需人工复核后再导出"]
assert result.node_results[6]["status"] == "待复核"

View File

@@ -1,8 +1,10 @@
from django.urls import reverse
from agent_core.results import AgentResult
from apps.audit.models import AgentAuditLog, DemoBusinessRecord
from apps.audit.services import create_audit_log
from apps.audit.models import AgentAuditLog, DemoBusinessRecord, NotificationRecord
from apps.audit.services import build_history_list_context, create_audit_log, create_notification_record
from apps.chat.models import Conversation
from apps.documents.models import SubmissionBatch
from agent_core.tools.builtin_tools import query_demo_records
@@ -117,3 +119,544 @@ def test_query_demo_records_reads_demo_business_record_table(db):
assert result["records"][0]["title"] == "A线缺陷"
assert result["records"][0]["payload"] == {"rate": 0.12}
def test_audit_log_records_batch_conversation_and_product_context(db):
result = AgentResult(answer="回答", status="success")
log = create_audit_log(
"document_review",
"注册审核智能体",
"开始审核",
result,
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="新型冠状病毒 2019-nCoV 核酸检测试剂盒",
)
assert log.batch_id == "SUB-20260604-001"
assert log.conversation_id == "conv-001"
assert log.product_name == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
def test_create_notification_record_persists_task_completed_and_task_failed(db):
completed = create_notification_record(
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
trigger_source="risk_report",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_1",
message_status="sent",
web_detail_url="https://example.com/detail/1",
receipt={"message_id": "msg-1"},
)
failed = create_notification_record(
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
trigger_source="risk_report",
notify_reason="task_failed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_1",
message_status="failed",
web_detail_url="https://example.com/detail/1",
receipt={"message_id": "msg-2"},
)
assert NotificationRecord.objects.count() == 2
assert completed.notify_reason == "task_completed"
assert failed.notify_reason == "task_failed"
def test_create_notification_record_rejects_unsupported_notify_reason(db):
try:
create_notification_record(
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
trigger_source="risk_report",
notify_reason="custom_reason",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_1",
message_status="sent",
web_detail_url="https://example.com/detail/1",
receipt={"message_id": "msg-1"},
)
assert False, "expected ValueError"
except ValueError as exc:
assert "notify_reason" in str(exc)
def test_audit_list_supports_batch_and_product_filters(client, db):
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(answer="回答一", status="success"),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
create_audit_log(
"document_review",
"注册审核智能体",
"问题二",
AgentResult(answer="回答二", status="success"),
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
)
response = client.get(reverse("audit:list"), {"keyword": "产品A"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "产品A" in content
assert "产品B" not in content
def test_audit_list_shows_risk_and_notification_status(client, db):
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(
answer="回答一",
status="success",
structured_output={"highest_risk_level": "high"},
),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
create_notification_record(
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
trigger_source="risk_report",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_1",
message_status="sent",
web_detail_url="https://example.com/detail/1",
receipt={"message_id": "msg-1"},
)
response = client.get(reverse("audit:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "风险状态" in content
assert "已阻断" in content
assert "通知状态" in content
assert "已发送" in content
assert "通知原因" in content
assert "task_completed" in content
def test_audit_list_can_filter_by_notification_status(client, db):
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(answer="回答一", status="success"),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
create_audit_log(
"document_review",
"注册审核智能体",
"问题二",
AgentResult(answer="回答二", status="failed"),
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
)
create_notification_record(
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
trigger_source="risk_report",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_1",
message_status="sent",
web_detail_url="https://example.com/detail/1",
receipt={"message_id": "msg-1"},
)
create_notification_record(
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
trigger_source="risk_report",
notify_reason="task_failed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_2",
message_status="failed",
web_detail_url="https://example.com/detail/2",
receipt={"message_id": "msg-2"},
)
response = client.get(reverse("audit:list"), {"notify_status": "failed"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "产品B" in content
assert "产品A" not in content
def test_audit_list_can_filter_by_risk_status(client, db):
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(
answer="回答一",
status="success",
structured_output={"highest_risk_level": "high"},
),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
create_audit_log(
"document_review",
"注册审核智能体",
"问题二",
AgentResult(
answer="回答二",
status="success",
structured_output={"highest_risk_level": "low"},
),
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
)
response = client.get(reverse("audit:list"), {"risk_status": "high"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "产品A" in content
assert "产品B" not in content
def test_build_history_list_context_filters_by_keyword_notify_and_risk(db):
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(
answer="回答一",
status="success",
structured_output={"highest_risk_level": "high"},
),
batch_id="SUB-20260604-011",
conversation_id="conv-011",
product_name="产品A",
)
create_audit_log(
"document_review",
"注册审核智能体",
"问题二",
AgentResult(
answer="回答二",
status="success",
structured_output={"highest_risk_level": "low"},
),
batch_id="SUB-20260604-012",
conversation_id="conv-012",
product_name="产品B",
)
create_notification_record(
batch_id="SUB-20260604-011",
conversation_id="conv-011",
product_name="产品A",
trigger_source="risk_report",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_11",
message_status="sent",
web_detail_url="https://example.com/detail/11",
receipt={"message_id": "msg-11"},
)
create_notification_record(
batch_id="SUB-20260604-012",
conversation_id="conv-012",
product_name="产品B",
trigger_source="risk_report",
notify_reason="task_failed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_12",
message_status="failed",
web_detail_url="https://example.com/detail/12",
receipt={"message_id": "msg-12"},
)
context = build_history_list_context(
keyword="产品A",
notify_status="sent",
risk_status="high",
)
assert context["keyword"] == "产品A"
assert context["notify_status"] == "sent"
assert context["risk_status"] == "high"
assert len(context["history_rows"]) == 1
assert context["history_rows"][0]["log"].product_name == "产品A"
def test_audit_detail_page_shows_conversation_node_results(client, db):
Conversation.objects.create(
conversation_id="conv-001",
title="产品A",
product_name="产品A",
batch_id="SUB-20260604-001",
task_status="failed",
node_results=[
{"label": "资料包导入", "status": "已完成"},
{"label": "风险预警", "status": "已阻断"},
{"label": "飞书通知", "status": "失败"},
],
)
log = create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(answer="回答一", status="failed"),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
response = client.get(reverse("audit:detail", args=[log.id]))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "会话节点结果" in content
assert "风险预警 / 已阻断" in content
assert "飞书通知 / 失败" in content
def test_audit_list_shows_batch_scale_and_conversation_status(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-001",
product_name="产品A",
workflow_type="registration",
conversation_id="conv-001",
file_count=4,
page_count=26,
import_status="review_required",
)
Conversation.objects.create(
conversation_id="conv-001",
title="产品A",
product_name="产品A",
batch_id="SUB-20260604-001",
task_status="failed",
node_results=[
{"label": "风险预警", "status": "已阻断"},
{"label": "飞书通知", "status": "失败"},
],
)
create_audit_log(
"document_review",
"注册审核智能体",
"问题一",
AgentResult(answer="回答一", status="failed"),
batch_id="SUB-20260604-001",
conversation_id="conv-001",
product_name="产品A",
)
response = client.get(reverse("audit:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "资料规模" in content
assert "4 份 / 26 页" in content
assert "会话状态" in content
assert "失败" in content
assert "待复核" in content
def test_audit_list_shows_history_metrics_and_context_links(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-009",
product_name="产品C",
workflow_type="registration",
conversation_id="conv-009",
file_count=5,
page_count=31,
import_status="completed",
)
Conversation.objects.create(
conversation_id="conv-009",
title="产品C",
product_name="产品C",
batch_id="SUB-20260604-009",
task_status="success",
node_results=[
{"label": "风险预警", "status": "已完成"},
{"label": "飞书通知", "status": "已完成"},
],
)
create_audit_log(
"document_review",
"注册审核智能体",
"产品C 审核",
AgentResult(
answer="完成",
status="success",
structured_output={"highest_risk_level": "medium"},
),
batch_id="SUB-20260604-009",
conversation_id="conv-009",
product_name="产品C",
)
create_notification_record(
batch_id="SUB-20260604-009",
conversation_id="conv-009",
product_name="产品C",
trigger_source="risk_report",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_9",
message_status="sent",
web_detail_url="https://example.com/detail/9",
receipt={"message_id": "msg-9"},
)
response = client.get(reverse("audit:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "处理任务数" in content
assert "成功执行" in content
assert "通知已发送" in content
assert "高风险阻断" in content
assert reverse("chat:detail", args=["conv-009"]) in content
assert f"{reverse('documents:list')}?keyword=SUB-20260604-009" in content
assert f"{reverse('audit:list')}?keyword=产品C" in content
assert "已发送" in content
assert "待复核" in content
def test_audit_detail_page_shows_export_summary_and_notification_receipt(client, db):
Conversation.objects.create(
conversation_id="conv-002",
title="产品B",
product_name="产品B",
batch_id="SUB-20260604-002",
task_status="success",
node_results=[
{"label": "Word 回填导出", "status": "待复核"},
{"label": "飞书通知", "status": "已完成"},
],
)
log = create_audit_log(
"document_review",
"注册审核智能体",
"导出任务",
AgentResult(
answer="已生成导出草稿",
status="success",
structured_output={
"export_status": "draft_only",
"download_url": "/downloads/registration-report.docx",
"blocked_items": ["风险项未清零"],
},
),
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
)
create_notification_record(
batch_id="SUB-20260604-002",
conversation_id="conv-002",
product_name="产品B",
trigger_source="word_export",
notify_reason="task_completed",
owner_role="注册资料负责人",
feishu_user_id="ou_demo_9",
message_status="sent",
web_detail_url="https://example.com/detail/9",
receipt={"message_id": "msg-9", "status": "sent"},
)
response = client.get(reverse("audit:detail", args=[log.id]))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "导出状态摘要" in content
assert "待复核" in content
assert "/downloads/registration-report.docx" in content
assert "风险项未清零" in content
assert "通知回执" in content
assert "msg-9" in content
assert "飞书通知 / 已发送" in content
assert "已发送" in content
def test_audit_detail_page_shows_export_file_name_and_mode(client, db):
Conversation.objects.create(
conversation_id="conv-003",
title="产品C",
product_name="产品C",
batch_id="SUB-20260604-003",
task_status="success",
node_results=[
{"label": "Word 回填导出", "status": "待复核"},
{"label": "飞书通知", "status": "已完成"},
],
)
log = create_audit_log(
"document_review",
"Word 回填导出",
"导出任务",
AgentResult(
answer="已生成导出草稿",
status="success",
structured_output={
"export_status": "draft_only",
"template_name": "注册证导出模板",
"template_version": "V1.0",
"download_url": "/media/exports/20260604/SUB-20260604-003-draft.docx",
"output_file": {
"file_name": "SUB-20260604-003-draft.docx",
"relative_path": "exports/20260604/SUB-20260604-003-draft.docx",
"export_mode": "draft",
},
},
),
batch_id="SUB-20260604-003",
conversation_id="conv-003",
product_name="产品C",
)
response = client.get(reverse("audit:detail", args=[log.id]))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "输出文件名" in content
assert "SUB-20260604-003-draft.docx" in content
assert "导出模式" in content
assert "draft" in content
assert "模板版本" in content
assert "V1.0" in content
assert "待复核" in content
def test_audit_list_uses_chinese_filter_labels_for_risk_and_notification_status(client, db):
response = client.get(reverse("audit:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert ">已阻断<" in content
assert ">待复核<" in content
assert ">已完成<" in content
assert ">已发送<" in content
assert ">失败<" in content

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,21 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from io import BytesIO
from pathlib import Path
import sys
import types
import zipfile
from zipfile import ZipFile
from apps.documents.forms import DocumentUploadForm
from apps.documents.models import UploadedDocument
from apps.documents.services import extract_text, index_document
from apps.documents.models import ExportedDocument, SubmissionBatch, UploadedDocument
from apps.documents.services import (
build_document_list_context,
extract_text,
import_submission_batch,
index_document,
)
from apps.chat.models import Conversation
def test_upload_txt_document_creates_uploaded_record(client, db):
@@ -31,7 +43,7 @@ def test_upload_redirect_shows_success_message(client, db):
)
assert response.status_code == 200
assert "文件已上传,可继续执行入库" in response.content.decode("utf-8")
assert "资料包已导入,已绑定会话" in response.content.decode("utf-8")
def test_upload_accepts_pdf_and_docx_documents(client, db):
@@ -105,6 +117,21 @@ def test_document_upload_form_builds_scenario_choices():
assert "quality_analysis" in choice_values
def test_document_upload_form_accepts_rar_package():
form = DocumentUploadForm(
data={"scenario_id": "knowledge_qa"},
files={
"file": SimpleUploadedFile(
"registration-package.rar",
b"fake-rar-bytes",
content_type="application/vnd.rar",
)
},
)
assert form.is_valid()
def test_index_failure_message_is_visible_on_document_list(client, db, monkeypatch):
document = UploadedDocument.objects.create(
scenario_id="knowledge_qa",
@@ -145,3 +172,610 @@ def test_index_document_marks_failed_when_extracted_text_is_empty(db, monkeypatc
assert updated_document.status == UploadedDocument.STATUS_FAILED
assert "文档内容为空" in updated_document.error_message
def test_upload_creates_submission_batch_and_bound_conversation(client, db):
file = SimpleUploadedFile(
"目标产品说明书.txt",
"产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒".encode("utf-8"),
content_type="text/plain",
)
response = client.post(
reverse("documents:upload"),
{"scenario_id": "document_review", "file": file},
)
assert response.status_code == 302
batch = SubmissionBatch.objects.get()
conversation = Conversation.objects.get()
assert batch.product_name == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
assert batch.conversation_id == conversation.conversation_id
assert conversation.title == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
assert batch.file_count == 1
def test_document_list_supports_product_name_search(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-001",
product_name="新型冠状病毒 2019-nCoV 核酸检测试剂盒",
workflow_type="registration",
conversation_id="conv-001",
file_count=2,
page_count=12,
import_status="completed",
)
SubmissionBatch.objects.create(
batch_id="SUB-20260604-002",
product_name="呼吸道合胞病毒核酸检测试剂盒",
workflow_type="registration",
conversation_id="conv-002",
file_count=3,
page_count=20,
import_status="completed",
)
response = client.get(reverse("documents:list"), {"keyword": "新型冠状病毒"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "新型冠状病毒 2019-nCoV 核酸检测试剂盒" in content
assert "呼吸道合胞病毒核酸检测试剂盒" not in content
def test_document_list_supports_batch_id_search(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-001",
product_name="产品A",
workflow_type="registration",
conversation_id="conv-001",
file_count=2,
page_count=12,
import_status="completed",
)
SubmissionBatch.objects.create(
batch_id="SUB-20260604-002",
product_name="产品B",
workflow_type="registration",
conversation_id="conv-002",
file_count=3,
page_count=20,
import_status="completed",
)
response = client.get(reverse("documents:list"), {"keyword": "SUB-20260604-002"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "SUB-20260604-002" in content
assert "SUB-20260604-001" not in content
def test_document_list_shows_link_to_bound_conversation(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-201",
product_name="产品C",
workflow_type="registration",
conversation_id="conv-201",
file_count=2,
page_count=12,
import_status="completed",
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "查看对话 conv-201" in content
assert reverse("chat:detail", args=["conv-201"]) in content
def test_build_document_list_context_filters_batches_by_keyword(db):
target_batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-101",
product_name="产品A",
workflow_type="registration",
conversation_id="conv-101",
file_count=2,
page_count=12,
import_status="completed",
)
SubmissionBatch.objects.create(
batch_id="SUB-20260604-102",
product_name="产品B",
workflow_type="registration",
conversation_id="conv-102",
file_count=3,
page_count=20,
import_status="review_required",
)
context = build_document_list_context(keyword="产品A")
assert context["keyword"] == "产品A"
assert len(context["batches"]) == 1
assert context["batches"][0].id == target_batch.id
assert context["status_counts"]["total"] == 1
def test_import_submission_batch_marks_manual_review_when_product_names_conflict(db):
files = [
SimpleUploadedFile(
"注册申请表.txt",
"产品名称产品A".encode("utf-8"),
content_type="text/plain",
),
SimpleUploadedFile(
"目标产品说明书.txt",
"产品名称产品B".encode("utf-8"),
content_type="text/plain",
),
]
result = import_submission_batch("document_review", files)
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
assert batch.import_status == "review_required"
assert result["registration_overview_report"]["warnings"]
assert "产品名称来源冲突" in result["registration_overview_report"]["warnings"][0]
def test_upload_multiple_files_creates_single_submission_batch_and_multiple_documents(client, db):
application = SimpleUploadedFile(
"注册申请表.txt",
"产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒".encode("utf-8"),
content_type="text/plain",
)
manual = SimpleUploadedFile(
"目标产品说明书.txt",
"产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒".encode("utf-8"),
content_type="text/plain",
)
response = client.post(
reverse("documents:upload"),
{"scenario_id": "document_review", "files": [application, manual]},
)
assert response.status_code == 302
batch = SubmissionBatch.objects.get()
assert batch.file_count == 2
assert UploadedDocument.objects.filter(batch=batch).count() == 2
assert Conversation.objects.get().title == "新型冠状病毒 2019-nCoV 核酸检测试剂盒"
def test_import_submission_batch_supports_zip_package_and_preserves_relative_paths(db):
archive = BytesIO()
with ZipFile(archive, "w") as zip_file:
zip_file.writestr("CH1/注册申请表.txt", "产品名称产品A")
zip_file.writestr("CH1/目标产品说明书.txt", "产品名称产品A")
archive.seek(0)
package = SimpleUploadedFile(
"registration-package.zip",
archive.read(),
content_type="application/zip",
)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
documents = list(UploadedDocument.objects.filter(batch=batch).order_by("relative_path"))
assert batch.file_count == 2
assert [document.relative_path for document in documents] == [
"CH1/注册申请表.txt",
"CH1/目标产品说明书.txt",
]
def test_import_submission_batch_supports_7z_package_and_preserves_relative_paths(db, monkeypatch, tmp_path):
package = SimpleUploadedFile(
"registration-package.7z",
b"fake-7z-bytes",
content_type="application/x-7z-compressed",
)
class FakeSevenZipFile:
def __init__(self, _file_obj, mode="r"):
self.mode = mode
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def extractall(self, path):
target = Path(path)
(target / "CH1").mkdir(parents=True, exist_ok=True)
(target / "CH1" / "注册申请表.txt").write_text("产品名称产品A", encoding="utf-8")
(target / "CH1" / "目标产品说明书.txt").write_text("产品名称产品A", encoding="utf-8")
fake_module = types.SimpleNamespace(SevenZipFile=FakeSevenZipFile)
monkeypatch.setitem(sys.modules, "py7zr", fake_module)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
documents = list(UploadedDocument.objects.filter(batch=batch).order_by("relative_path"))
assert batch.file_count == 2
assert [document.relative_path for document in documents] == [
"CH1/注册申请表.txt",
"CH1/目标产品说明书.txt",
]
def test_import_submission_batch_supports_rar_package_and_preserves_relative_paths(db, monkeypatch):
package = SimpleUploadedFile(
"registration-package.rar",
b"fake-rar-bytes",
content_type="application/vnd.rar",
)
class FakeRarInfo:
def __init__(self, filename, is_dir=False):
self.filename = filename
self._is_dir = is_dir
def is_dir(self):
return self._is_dir
class FakeRarFile:
def __init__(self, _file_obj):
self.entries = {
"CH1/注册申请表.txt": "产品名称产品A".encode("utf-8"),
"CH1/目标产品说明书.txt": "产品名称产品A".encode("utf-8"),
}
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def infolist(self):
return [FakeRarInfo(name) for name in self.entries]
def read(self, name):
return self.entries[name]
fake_module = types.SimpleNamespace(RarFile=FakeRarFile)
monkeypatch.setitem(sys.modules, "rarfile", fake_module)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
documents = list(UploadedDocument.objects.filter(batch=batch).order_by("relative_path"))
assert batch.file_count == 2
assert [document.relative_path for document in documents] == [
"CH1/注册申请表.txt",
"CH1/目标产品说明书.txt",
]
def test_import_submission_batch_records_warnings_for_unsupported_zip_entries(db):
archive = BytesIO()
with ZipFile(archive, "w") as zip_file:
zip_file.writestr("CH1/注册申请表.txt", "产品名称产品A")
zip_file.writestr("CH1/忽略图片.png", b"binary-image-data")
archive.seek(0)
package = SimpleUploadedFile(
"registration-package.zip",
archive.read(),
content_type="application/zip",
)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
warnings = result["registration_overview_report"]["warnings"]
assert batch.file_count == 1
assert batch.exception_count == 1
assert "跳过不支持的文件" in warnings[0]
def test_import_submission_batch_uses_exact_docx_page_count_from_metadata(db):
archive = BytesIO()
with zipfile.ZipFile(archive, "w") as docx_file:
docx_file.writestr(
"word/document.xml",
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒</w:t></w:r></w:p>
</w:body>
</w:document>""",
)
docx_file.writestr(
"docProps/app.xml",
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Pages>7</Pages>
</Properties>""",
)
archive.seek(0)
file = SimpleUploadedFile(
"CH1-目标产品说明书.docx",
archive.read(),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
result = import_submission_batch("document_review", [file])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
document = UploadedDocument.objects.get(batch=batch)
assert batch.page_count == 7
assert document.page_count == 7
assert document.page_count_confidence == "exact"
assert batch.import_status == "completed"
def test_import_submission_batch_marks_review_when_docx_page_count_cannot_be_precisely_detected(db):
archive = BytesIO()
with zipfile.ZipFile(archive, "w") as docx_file:
docx_file.writestr(
"word/document.xml",
"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>产品名称:新型冠状病毒 2019-nCoV 核酸检测试剂盒</w:t></w:r></w:p>
</w:body>
</w:document>""",
)
archive.seek(0)
file = SimpleUploadedFile(
"CH1-目标产品说明书.docx",
archive.read(),
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
result = import_submission_batch("document_review", [file])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
document = UploadedDocument.objects.get(batch=batch)
warnings = result["registration_overview_report"]["warnings"]
assert document.page_count_confidence == "estimated"
assert document.needs_manual_review is True
assert batch.import_status == "review_required"
assert any("DOCX 页数无法精确统计" in warning for warning in warnings)
def test_import_submission_batch_marks_failed_when_zip_has_no_supported_files(db):
archive = BytesIO()
with ZipFile(archive, "w") as zip_file:
zip_file.writestr("assets/readme.png", b"binary-image-data")
archive.seek(0)
package = SimpleUploadedFile(
"empty-registration-package.zip",
archive.read(),
content_type="application/zip",
)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
warnings = result["registration_overview_report"]["warnings"]
assert batch.file_count == 0
assert batch.import_status == SubmissionBatch.STATUS_FAILED
assert batch.exception_count == len(warnings)
assert any("未发现可导入的支持文件" in warning for warning in warnings)
def test_import_submission_batch_records_warnings_for_unsupported_7z_entries(db, monkeypatch):
package = SimpleUploadedFile(
"registration-package.7z",
b"fake-7z-bytes",
content_type="application/x-7z-compressed",
)
class FakeSevenZipFile:
def __init__(self, _file_obj, mode="r"):
self.mode = mode
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def extractall(self, path):
target = Path(path)
(target / "CH1").mkdir(parents=True, exist_ok=True)
(target / "CH1" / "注册申请表.txt").write_text("产品名称产品A", encoding="utf-8")
(target / "CH1" / "忽略图片.png").write_bytes(b"binary-image-data")
fake_module = types.SimpleNamespace(SevenZipFile=FakeSevenZipFile)
monkeypatch.setitem(sys.modules, "py7zr", fake_module)
result = import_submission_batch("document_review", [package])
batch = SubmissionBatch.objects.get(batch_id=result["batch_id"])
warnings = result["registration_overview_report"]["warnings"]
assert batch.file_count == 1
assert batch.exception_count == 1
assert any("CH1/忽略图片.png" in warning for warning in warnings)
def test_create_export_record_persists_batch_conversation_and_file_metadata(db):
from apps.documents.services import create_export_record
batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-010",
product_name="产品X",
workflow_type="registration",
conversation_id="conv-010",
file_count=2,
page_count=12,
import_status="completed",
)
record = create_export_record(
batch=batch,
conversation_id="conv-010",
product_name="产品X",
template_name="注册证导出模板",
template_version="V1.0",
export_mode="draft",
output_type="registration_word_export_report",
file_name="SUB-20260604-010-draft.docx",
relative_path="exports/20260604/SUB-20260604-010-draft.docx",
download_url="/media/exports/20260604/SUB-20260604-010-draft.docx",
)
assert ExportedDocument.objects.count() == 1
assert record.batch == batch
assert record.conversation_id == "conv-010"
assert record.product_name == "产品X"
assert record.template_name == "注册证导出模板"
assert record.export_mode == "draft"
def test_document_list_shows_latest_export_record_for_batch(client, db):
batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-011",
product_name="产品Y",
workflow_type="registration",
conversation_id="conv-011",
file_count=2,
page_count=12,
import_status="completed",
)
ExportedDocument.objects.create(
batch=batch,
conversation_id="conv-011",
product_name="产品Y",
template_name="注册证导出模板",
template_version="V1.0",
export_mode="draft",
output_type="registration_word_export_report",
file_name="SUB-20260604-011-draft.docx",
relative_path="exports/20260604/SUB-20260604-011-draft.docx",
download_url="/media/exports/20260604/SUB-20260604-011-draft.docx",
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "最近导出" in content
assert "SUB-20260604-011-draft.docx" in content
def test_document_list_shows_export_history_links_and_processing_pipeline(client, db):
batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-012",
product_name="产品Z",
workflow_type="registration",
conversation_id="conv-012",
file_count=3,
page_count=18,
import_status="review_required",
)
ExportedDocument.objects.create(
batch=batch,
conversation_id="conv-012",
product_name="产品Z",
template_name="注册证导出模板",
template_version="V1.1",
export_mode="draft",
output_type="registration_word_export_report",
file_name="SUB-20260604-012-draft.docx",
relative_path="exports/20260604/SUB-20260604-012-draft.docx",
download_url="/media/exports/20260604/SUB-20260604-012-draft.docx",
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "处理链路总览" in content
assert "原始文件接收" in content
assert "切片与索引入库" in content
assert reverse("audit:list") in content
assert "查看导出记录" in content
assert f"{reverse('audit:list')}?keyword=SUB-20260604-012" in content
def test_document_list_shows_batch_level_exception_items(client, db):
SubmissionBatch.objects.create(
batch_id="SUB-20260604-101",
product_name="甲型流感病毒抗原检测试剂盒",
workflow_type="registration",
conversation_id="conv-101",
file_count=4,
page_count=28,
import_status=SubmissionBatch.STATUS_REVIEW_REQUIRED,
exception_count=2,
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "资料包待复核SUB-20260604-101" in content
assert "甲型流感病毒抗原检测试剂盒 当前存在 2 项异常" in content
def test_document_list_shows_manual_review_document_exception_items(client, db):
batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-102",
product_name="乙型流感病毒抗原检测试剂盒",
workflow_type="registration",
conversation_id="conv-102",
file_count=1,
page_count=9,
import_status=SubmissionBatch.STATUS_REVIEW_REQUIRED,
exception_count=1,
)
UploadedDocument.objects.create(
batch=batch,
scenario_id="document_review",
original_name="CH1-产品说明书.docx",
file="documents/20260604/manual-review.docx",
file_type="docx",
size=128,
page_count=9,
page_count_confidence="estimated",
chapter_code="CH1",
chapter_match_status="matched",
needs_manual_review=True,
status=UploadedDocument.STATUS_UPLOADED,
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "文档待人工复核CH1-产品说明书.docx" in content
assert "页数为估算值,建议人工确认" in content
def test_document_list_shows_failed_document_exception_items(client, db):
batch = SubmissionBatch.objects.create(
batch_id="SUB-20260604-103",
product_name="呼吸道病原体多重核酸检测试剂盒",
workflow_type="registration",
conversation_id="conv-103",
file_count=1,
page_count=5,
import_status=SubmissionBatch.STATUS_COMPLETED,
exception_count=0,
)
UploadedDocument.objects.create(
batch=batch,
scenario_id="document_review",
original_name="沟通记录扫描件.pdf",
file="documents/20260604/failed.pdf",
file_type="pdf",
size=256,
page_count=5,
chapter_match_status="unknown",
status=UploadedDocument.STATUS_FAILED,
error_message="OCR 识别失败,请重新上传清晰版。",
)
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "文档处理失败:沟通记录扫描件.pdf" in content
assert "OCR 识别失败,请重新上传清晰版。" in content

250
tests/test_platform_ui.py Normal file
View File

@@ -0,0 +1,250 @@
from django.urls import reverse
from django.test import override_settings
from django.contrib import admin
from apps.platform_ui.services import get_platform_demo_context
from apps.platform_ui.models import FeishuNotifyConfig, OwnerMapping, WordTemplateMapping
def test_knowledge_base_page_shows_governance_sections(client):
response = client.get(reverse("platform_ui:knowledge-base"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "法规规则包" in content
assert "RAG 文档源" in content
assert "RAG 切片" in content
assert "字段 Schema" in content
assert "责任人映射" in content
assert "飞书通知配置" in content
def test_knowledge_base_page_shows_owner_mapping_fields_and_notify_reasons(client):
response = client.get(reverse("platform_ui:knowledge-base"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "owner_role" in content
assert "owner_name" in content
assert "department" in content
assert "chapter_scope" in content
assert "risk_scope" in content
assert "feishu_user_id" in content
assert "feishu_open_id" in content
assert "feishu_name" in content
assert "notify_enabled" in content
assert "task_completed" in content
assert "task_failed" in content
@override_settings(GOVERNANCE_CONFIG_PATH="")
def test_get_platform_demo_context_reads_governance_yaml(tmp_path):
config_path = tmp_path / "governance.yaml"
config_path.write_text(
"""
owner_mappings:
- owner_role: 临床注册负责人
owner_name: 王五
department: 临床事务部
chapter_scope: CH3-CH6
risk_scope: 高风险阻断
feishu_user_id: ou_test_owner
feishu_open_id: on_test_owner
feishu_name: 王五
notify_enabled: 是
feishu_configs:
- config_name: Demo 完成通知
notify_reason: task_completed
channel: 群机器人
message_template: 审核完成摘要 + @处理人
status: 启用
template_mappings:
- template_name: 注册证导出模板
output_type: registration_word_export_report
version: V1.0
placeholder_count: 12
status: 启用
field_mapping_summary: 产品名称 / 注册人 / 适用机型
""".strip(),
encoding="utf-8",
)
with override_settings(GOVERNANCE_CONFIG_PATH=config_path):
context = get_platform_demo_context()
assert context["owner_mappings"][0]["owner_name"] == "王五"
assert context["feishu_configs"][0]["notify_reason"] == "task_completed"
assert context["template_mappings"][0]["template_name"] == "注册证导出模板"
assert context["template_mappings"][0]["field_mapping_summary"] == "产品名称 / 注册人 / 适用机型"
@override_settings(GOVERNANCE_CONFIG_PATH="")
def test_knowledge_base_page_shows_template_mappings_from_governance_config(client, tmp_path):
config_path = tmp_path / "governance.yaml"
config_path.write_text(
"""
template_mappings:
- template_name: 风险摘要导出模板
output_type: registration_word_export_report
version: V2.0
placeholder_count: 8
status: 灰度中
field_mapping_summary: 风险等级 / 产品名称 / 责任人
""".strip(),
encoding="utf-8",
)
with override_settings(GOVERNANCE_CONFIG_PATH=config_path):
response = client.get(reverse("platform_ui:knowledge-base"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "Word 模板与字段映射" in content
assert "风险摘要导出模板" in content
assert "风险等级 / 产品名称 / 责任人" in content
def test_get_platform_demo_context_prefers_database_governance_entries(db):
OwnerMapping.objects.create(
owner_role="数据库负责人",
owner_name="周八",
department="注册事务部",
chapter_scope="CH5",
risk_scope="数据库覆盖",
feishu_user_id="ou_db_1",
feishu_open_id="on_db_1",
feishu_name="周八",
notify_enabled=True,
is_active=True,
)
FeishuNotifyConfig.objects.create(
config_name="数据库通知配置",
notify_reason="task_failed",
channel="群机器人",
message_template="异常摘要 + @处理人",
status="启用",
is_active=True,
)
WordTemplateMapping.objects.create(
template_name="数据库模板",
output_type="registration_word_export_report",
version="V3.0",
placeholder_count=16,
status="启用",
field_mapping_summary="产品名称 / 规格 / 风险等级",
is_active=True,
)
context = get_platform_demo_context()
assert context["owner_mappings"][0]["owner_name"] == "周八"
assert context["feishu_configs"][0]["config_name"] == "数据库通知配置"
assert context["template_mappings"][0]["template_name"] == "数据库模板"
def test_governance_models_are_registered_in_admin():
assert OwnerMapping in admin.site._registry
assert FeishuNotifyConfig in admin.site._registry
assert WordTemplateMapping in admin.site._registry
def test_knowledge_base_page_shows_governance_object_navigation_and_active_panel(client):
response = client.get(reverse("platform_ui:knowledge-base"), {"view": "owner_mappings"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "治理对象导航" in content
assert "法规规则包" in content
assert "RAG 文档源" in content
assert "Word 模板与字段映射" in content
assert "责任人映射详情" in content
assert "当前对象:责任人映射" in content
assert "批量导入映射" in content
def test_knowledge_base_page_exposes_governance_crud_entry_links(client):
response = client.get(reverse("platform_ui:knowledge-base"), {"view": "template_mappings"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "上传模板" in content
assert "编辑占位符映射" in content
assert "预览模板" in content
assert reverse("admin:platform_ui_wordtemplatemapping_changelist") in content
def test_knowledge_base_page_shows_governance_action_hub_and_fixed_notify_policy(client):
response = client.get(reverse("platform_ui:knowledge-base"), {"view": "feishu_configs"})
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "治理动作总览" in content
assert "当前治理对象" in content
assert "进入审核智能体" in content
assert "查看资料包" in content
assert "查看处理历史" in content
assert "固定通知策略" in content
assert "task_completed" in content
assert "task_failed" in content
assert reverse("audit:list") in content
assert reverse("documents:list") in content
def test_command_center_v2_renders_and_links_to_chat_index(client):
response = client.get(reverse("platform_ui:command-center-v2"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "试剂盒临床注册文件准备与审核智能体平台" in content
assert reverse("chat:index") in content
def test_command_center_v2_uses_unified_four_entry_labels(client):
response = client.get(reverse("platform_ui:command-center-v2"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "审核智能体" in content
assert "资料包" in content
assert "知识库" in content
assert "处理历史" in content
assert "资料包管理" not in content
assert "法规库" not in content
assert "智能审核" not in content
assert "统计分析" not in content
def test_command_center_v2_uses_four_product_entries_as_primary_navigation(client):
response = client.get(reverse("platform_ui:command-center-v2"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert 'aria-label="一级产品入口"' in content
assert "一级产品" in content
assert "协同治理" in content
assert "审核指挥台" in content
assert "场景配置参考" in content
assert "任务中心" not in content
assert "工作台</span>" not in content
def test_platform_demo_context_uses_agent_product_labels_for_quick_links():
context = get_platform_demo_context()
quick_links = context["quick_links"]
titles = [item["title"] for item in quick_links]
assert "审核智能体" in titles
assert "审核指挥台" in titles
assert "审核工作台" not in titles
assert "工作台大屏" not in titles
assert any(
item["title"] == "审核指挥台" and item["url_name"] == "platform_ui:command-center-v2"
for item in quick_links
)
def test_command_center_legacy_route_redirects_to_v2(client):
response = client.get(reverse("platform_ui:command-center"))
assert response.status_code == 302
assert response.url == reverse("platform_ui:command-center-v2")

View File

@@ -1,4 +1,5 @@
import pytest
from django.urls import reverse
from apps.scenarios.services import (
ScenarioNotFound,
@@ -127,3 +128,56 @@ audit:
assert "有效场景" in content
assert "配置异常" in content
assert "invalid.yaml" in content
def test_home_page_uses_four_top_level_product_entries(client):
response = client.get("/")
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "审核智能体" in content
assert "资料包" in content
assert "知识库" in content
assert "处理历史" in content
def test_home_page_uses_platform_overview_copy_and_deemphasizes_scenario_entry(client):
response = client.get("/")
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "平台总览" in content
assert "四个一级入口" in content
assert "底层审核场景配置" in content
assert "场景配置参考" in content
def test_base_layout_exposes_four_topnav_links(client):
response = client.get("/")
content = response.content.decode("utf-8")
assert response.status_code == 200
assert reverse("chat:index") in content
assert reverse("documents:list") in content
assert reverse("platform_ui:knowledge-base") in content
assert reverse("audit:list") in content
def test_home_page_uses_unified_four_entry_labels(client):
response = client.get("/")
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "<h3>知识库</h3>" in content
assert "<h3>资料包</h3>" in content
assert "<h3>审核智能体</h3>" in content
assert "<h3>处理历史</h3>" in content
assert "会话工作台" not in content
def test_base_layout_marks_current_topnav_entry_active(client, db):
response = client.get(reverse("documents:list"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert 'href="/documents/" class="active"' in content