Compare commits

101 Commits
master ... dev

Author SHA1 Message Date
1f5f0a968b fix(chat): remove workspace hero card 2026-06-04 23:05:13 +08:00
f2c1e3cfa1 docs(config): add siliconflow demo env template 2026-06-04 22:56:38 +08:00
5a6e7698e4 fix(chat): simplify header cards and llm fallback 2026-06-04 22:42:39 +08:00
fecaee0b03 feat(chat): refine workspace chat layout 2026-06-04 22:30:37 +08:00
efb06519d8 feat(chat): allow knowledge chat before upload 2026-06-04 22:16:54 +08:00
zhiye.sun
1d8a526770 docs(demo-agent): 同步当前实现状态与协作约定 2026-06-04 16:38:18 +08:00
5aa7b5f3d0 fix(routing): 默认进入审核聊天工作台 2026-06-04 08:47:27 +08:00
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
7836690303 feat(原型设计): 关联资料包与对话记录 2026-06-03 23:48:53 +08:00
aa5d4d77f8 refactor(原型设计): 调整用户信息展示位置 2026-06-03 23:44:40 +08:00
12a92ad278 refactor(原型设计): 调整导航并合并法规依据入口 2026-06-03 23:43:16 +08:00
134e0fb5ff feat(原型设计): 精简导航并补充资料与法规页 2026-06-03 23:32:56 +08:00
f53d5a1902 refactor(原型设计): 简化顶部导航结构 2026-06-03 23:28:26 +08:00
c303a2fcc6 style(原型设计): 调整为蓝白助手工作台风格 2026-06-03 23:24:16 +08:00
e75f0a0356 refactor(原型设计): 重构为Agent对话优先原型 2026-06-03 23:15:24 +08:00
3da774e537 feat(原型设计): 新增知识库管理与流程状态卡片 2026-06-03 23:00:22 +08:00
54fc1baa4c feat(原型设计): 增强强Agent对话工作台原型 2026-06-03 22:31:23 +08:00
5a137b5b45 fix(原型设计): 修复演示站内容空白问题 2026-06-03 22:01:00 +08:00
08251ae5e6 docs(原型设计): 同步原型交付入口索引 2026-06-03 21:44:31 +08:00
cace6cb941 feat(原型设计): 新增注册审核演示站 HTML 原型 2026-06-03 21:41:27 +08:00
78b841131b docs(原型设计): 补充注册审核平台分页原型方案 2026-06-03 21:34:36 +08:00
7a60af0485 docs(详细设计): 新增飞书通知设计 2026-06-03 21:15:34 +08:00
cc200a32c4 docs(详细设计): 新增Word回填导出设计 2026-06-03 21:12:04 +08:00
2876a1b028 docs(详细设计): 新增风险预警设计 2026-06-03 21:08:15 +08:00
0e49eea683 docs(详细设计): 新增一致性核查设计 2026-06-03 21:04:54 +08:00
4208f29d77 docs(详细设计): 新增字段抽取与字段池设计 2026-06-03 21:00:28 +08:00
759939b446 docs(详细设计): 新增法规完整性检查设计 2026-06-03 20:55:38 +08:00
18428e75fd docs(详细设计): 新增资料包导入与目录汇总设计 2026-06-03 20:50:27 +08:00
zhiye.sun
11c20593d5 docs(requirements): 明确核心信息自动回填目标 2026-06-03 14:13:53 +08:00
zhiye.sun
56a332a7dd docs(requirements): 固化资料包解析确认口径 2026-06-03 14:10:20 +08:00
zhiye.sun
5125f79037 feat(platform-ui): 新增审核指挥台V2原型 2026-06-03 14:01:23 +08:00
zhiye.sun
d670c51d43 chore(registration): 更新注册审核场景输出契约 2026-06-03 14:00:58 +08:00
zhiye.sun
4017151218 docs(requirements): 梳理注册资料审核Agent需求 2026-06-03 14:00:33 +08:00
b2c1da3f02 feat(ui): 重构注册审核平台原型界面 2026-06-03 08:41:48 +08:00
77166b5cd3 docs(requirements): 收紧报送版式与飞书闭环要求 2026-06-03 00:01:59 +08:00
dc4c605723 docs(requirements): 补充飞书接入与法规规则源口径 2026-06-02 23:49:25 +08:00
59d522be0c docs(project): 同步注册审核系统的项目定位说明 2026-06-02 23:08:39 +08:00
e64dca551c docs: 重构真实题目下的需求与资料文档体系 2026-06-02 23:08:15 +08:00
182 changed files with 21240 additions and 4522 deletions

21
.env.siliconflow.example Normal file
View File

@@ -0,0 +1,21 @@
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=*
# SiliconFlow OpenAI-compatible API
# Fill these two keys manually before demo.
LLM_PROVIDER=openai_compatible
LLM_API_KEY=your_siliconflow_api_key
LLM_BASE_URL=https://api.siliconflow.cn/v1
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
# SiliconFlow embedding model for RAG.
# You can reuse the same SiliconFlow key here.
EMBEDDING_API_KEY=your_siliconflow_api_key
EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1
EMBEDDING_MODEL=BAAI/bge-m3
SCENARIO_CONFIG_DIR=configs
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma

123
AGENTS.md
View File

@@ -1,17 +1,21 @@
# AGENTS.md # AGENTS.md
本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格实现顺序。 本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格实现顺序和文档同步要求
## 项目定位 ## 项目定位
Universal Agent Demo Framework 是一个用于复试展示的通用 AI Agent Demo 框架。 当前项目已根据真实笔试题切换为:
```text
试剂盒临床注册文件准备与审核智能体平台
```
优先目标: 优先目标:
- 快速适配未知复试题 - 围绕 NMPA 体外诊断试剂注册申报资料场景完成可演示闭环
- 保证本地可运行。 - 保证本地可运行、可测试、可讲解
- 保证代码结构清楚,方便讲解 - 保证代码结构清楚,业务流程能从页面、服务层、Agent Core 和审计日志串起来
- 避免为了平台完整性牺牲改题速度 - 允许在保留主架构边界前提下进行大幅度业务重构
## 架构原则 ## 架构原则
@@ -23,59 +27,68 @@ Django 单体 + 独立 Agent Core + Docker Compose
核心边界: 核心边界:
- Django 负责页面、数据库、文件上传、审计日志和后台管理。 - Django 负责页面、数据库、文件上传、导出文件、审计日志、通知留痕和后台管理。
- Agent Core 负责 RAG、Prompt、工具调用、模型适配和结构化输出。 - Agent Core 负责 RAG、Prompt、工具调用、治理配置、模型适配和结构化输出。
- Django View 不直接写大模型调用、向量检索和工具执行细节。 - Django View 不直接写大模型调用、向量检索和工具执行细节。
- Agent Core 不依赖 Django View。 - Agent Core 不依赖 Django View。
- 业务流程优先放在各模块 `services.py`View 只负责请求处理、消息提示和模板渲染。
## 模块边界 ## 模块边界
### config ### config
负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径和部署配置。 负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径、Chroma 路径和部署配置。
### apps.scenarios ### apps.scenarios
负责场景列表、场景配置读取、场景元信息展示。 负责注册审核任务配置读取、场景元信息展示和非法 YAML 配置容错
### apps.documents ### apps.documents
负责文件上传文件记录、文件状态和触发 RAG 入库。 负责资料包导入、上传文件记录、压缩包展开、文本抽取、章节点归类、页数统计、资料包搜索、异常提示、触发 RAG 入库和导出记录维护
### apps.chat ### apps.chat
负责对话页面、用户输入表单、调用 Agent Core展示结 负责审核工作台、会话列表、用户输入表单、文档范围选择、调用 Agent Core展示结构化审核结果、补传资料和触发 Word 导出
### apps.audit ### apps.audit
负责审计日志模型、日志写入服务、日志列表和详情页 负责审计日志模型、日志写入服务、通知留痕、处理历史列表和详情页,以及审核留痕展示
### apps.platform_ui
负责知识库治理台、MCP 中心、Skill 工作室和审核指挥台等演示型平台页面。该模块可以展示治理对象和 mock 业务态势,但不要把主业务执行逻辑写进这里。
### agent_core ### agent_core
负责 Agent 编排、RAG、工具注册、LLM Provider结构化输出和 Adapter 扩展 负责注册审核 Agent 编排、RAG、工具注册、治理配置读取、LLM / Embedding Provider结构化输出。
## 开发顺序 ## 当前实现状态
建议按以下顺序推进: - Django 单体骨架已完成,根路径 `/` 默认进入审核智能体。
- 当前主入口为 `审核智能体 / 资料包 / 知识库治理台 / 处理历史`,底层场景列表保留在 `/scenarios/`
1. 创建 Django 项目骨架 - 通用场景 YAML、Chat、Documents、Audit、Platform UI 和 Agent Core 已具备可重构基础
2. 完成 Config 模块 - 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR
3. 完成 Scenarios 模块,先展示 5 个场景 - 资料包会自动绑定会话,标题优先使用解析出的产品名称
4. 完成 Agent Core 最小闭环,先返回模拟结果 - 审核智能体允许在未上传资料包时直接发起知识库问答,会话保持未绑定资料包状态并走 RAG 检索链路
5. 完成 Chat 页面,打通对话链路 - Agent Core 已具备 Prompt 编排、结构化解析、工具注册、RAG fallback / Chroma 双路径和 OpenAI 兼容 Provider
6. 完成 Audit 模块,记录每次对话 - Word 导出已支持生成最小 `.docx`,并按风险状态形成正式版或草稿版
7. 完成 Documents 模块,支持上传文件 - 飞书通知当前为离线通知留痕,不直接发送真实飞书消息
8. 完成 RAG 入库和检索。
9. 完成内置工具系统。
10. 补 Docker Compose 一键启动。
当前仓库状态说明:
- Django 单体骨架已完成。
- 5 个预置场景 YAML 已接通首页和对话页。
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册和 RAG fallback / Chroma 双路径。
- Chat、Documents、Audit 页面已经可以形成完整演示闭环。
- 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。 - 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。
- 当前需求文档已按真实笔试题重写到 `docs/需求分析/`
- 当前详细设计文档放在 `docs/详细设计/`,原型资料放在 `docs/原型设计/`
## 推荐开发顺序
后续新增或重构功能时,建议按以下顺序推进:
1. 先确认需求文档、详细设计或当前页面是否需要同步调整。
2. 补或调整服务层测试、Agent Core 测试或页面关键展示测试。
3. 在对应模块的 `services.py``agent_core` 中实现核心逻辑。
4. View 只接入服务层结果,模板只做直接展示。
5. 若涉及用户可见入口同步更新模板、README 和相关需求/设计文档。
6. 运行相关测试,再运行核心回归验证。
7. 按逻辑分组使用 Conventional Commit 风格提交到本地。
## 编码约定 ## 编码约定
@@ -85,6 +98,7 @@ Django 单体 + 独立 Agent Core + Docker Compose
- 配置化优先,业务场景不要写死在代码中。 - 配置化优先,业务场景不要写死在代码中。
- 工具函数必须通过 Tool Registry 注册。 - 工具函数必须通过 Tool Registry 注册。
- 模型调用必须通过 LLM Provider不允许散落在业务代码中。 - 模型调用必须通过 LLM Provider不允许散落在业务代码中。
- RAG 入库、检索和 Embedding 逻辑必须留在 Agent Core 或 Documents 服务边界内。
- 审计日志要记录成功和失败两种情况。 - 审计日志要记录成功和失败两种情况。
- 不在日志中保存 API Key、密钥或敏感环境变量。 - 不在日志中保存 API Key、密钥或敏感环境变量。
- 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。 - 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。
@@ -95,20 +109,20 @@ Django 单体 + 独立 Agent Core + Docker Compose
需求文档放在: 需求文档放在:
```text
docs/
```
需求分析文档放在:
```text ```text
docs/需求分析/ docs/需求分析/
``` ```
设计文档放在: 详细设计文档放在:
```text ```text
docs/设计文档/ docs/详细设计/
```
原型设计文档放在:
```text
docs/原型设计/
``` ```
场景配置放在: 场景配置放在:
@@ -122,26 +136,28 @@ configs/
- `README.md` - `README.md`
- `docs/需求分析/1.V1总需求文档.md` - `docs/需求分析/1.V1总需求文档.md`
- 相关模块需求文档 - 相关模块需求文档
- 相关详细设计文档
- `AGENTS.md` 中的协作边界与当前实现状态 - `AGENTS.md` 中的协作边界与当前实现状态
推荐同步文档的场景: 推荐同步文档的场景:
- 新增用户可见页面或流程。 - 新增用户可见页面或流程。
- 调整环境变量、生效方式或部署命令。 - 调整根路径、URL、环境变量、生效方式或部署命令。
- 修改 Agent Core 的输入输出合约 - 修改资料包、会话、审计、通知或导出模型字段
- 新增工具、审计字段或场景配置字段 - 修改 Agent Core 的输入输出合约、结构化输出类型或节点状态口径
- 新增工具、治理配置字段、场景配置字段或模板映射字段。
- 改变测试隔离策略、真实模型调用策略或 Docker 启动方式。
## 测试与验证约定 ## 测试与验证约定
每个阶段至少验证: 每个阶段至少验证:
- Django 可以启动。 - Django 可以启动`python manage.py check` 通过
- 首页可以访问。 - 根路径和审核智能体页面可以访问。
- 场景列表可显示 - 资料包导入流程可执行
- 对话流程可执行。 - 对话流程可执行,出错时页面有清晰提示
- 出错时页面有清晰提示 - 审计日志能记录成功和失败
- 审计日志能记录 - Docker Compose 配置有效
- Docker Compose 可以启动。
当前默认验证命令: 当前默认验证命令:
@@ -155,6 +171,8 @@ docker compose config
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。 - 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。 - 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
- 涉及页面结构时,至少补或更新对应页面测试。
- 涉及导出文件时,需要验证导出记录和下载路径。
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。 - 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
## 不优先做的事项 ## 不优先做的事项
@@ -168,5 +186,6 @@ docker compose config
- 深度 Dify 集成。 - 深度 Dify 集成。
- 微服务拆分。 - 微服务拆分。
- 分布式任务队列。 - 分布式任务队列。
- 真实飞书发送链路。
这些内容可以作为后续增强,不应影响 V1 快速成型。 这些内容可以作为后续增强,不应影响 V1 快速成型。

262
README.md
View File

@@ -1,13 +1,13 @@
# Universal Agent Demo Framework # 试剂盒临床注册文件准备与审核智能体平台
用于复试展示的通用 AI Agent Demo 框架 用于复试展示的体外诊断试剂注册申报资料准备与审核系统
项目目标不是提前猜中某一个具体业务题,而是先准备一个可快速改题的基础平台。拿到复试题目后,可以通过修改场景配置、上传知识库、补充少量工具函数,快速完成一个可演示的企业业务 Agent 项目已按真实笔试题收口为 NMPA 境内第三类体外诊断试剂注册申报资料场景,重点演示“资料包导入 -> 审核智能体执行 -> 结构化结果 -> Word 导出 -> 通知与审计留痕”的本地闭环
## 核心理念 ## 核心理念
```text ```text
业务 Agent = 场景配置 + 知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器 注册审核 Agent = 任务配置 + 资料包 + 法规/业务知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
``` ```
## 技术路线 ## 技术路线
@@ -17,25 +17,40 @@ V1 采用:
- Django 单体应用 - Django 单体应用
- 独立 Agent Core 模块 - 独立 Agent Core 模块
- SQLite - SQLite
- Chroma - Chroma / fallback 检索双路径
- Django Templates - Django Templates
- Docker Compose - Docker Compose
- OpenAI API 兼容的 LLM 与 Embedding 接口 - OpenAI API 兼容的 LLM 与 Embedding 接口
默认不强依赖 Dify。系统留 Adapter 设计,后续可接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。 默认不强依赖 Dify。系统 Provider / Adapter 边界,后续可接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。
## 适用复试题型 ## 当前业务主线
| 题型 | 推荐场景模板 | 1. 进入审核智能体后,可以先不上传资料,直接通过对话查询法规和业务知识库。
|---|---| 2. 导入注册资料包,支持单文件、多文件和压缩包。
| SOP 问答 | `knowledge_qa` | 3. 解析文件元数据、页数、章节点和产品名称。
| 制度问答 | `knowledge_qa` | 4. 自动创建资料包批次,并绑定审核会话。
| 文档审核 | `document_review` | 5. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告。
| 客服工单 | `ticket_assistant` | 6. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析。
| 质量异常分析 | `quality_analysis` | 7. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口。
| 财务审核 | `risk_audit` | 8. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史。
| 采购审核 | `risk_audit` | 9. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕。
| 合同风险分析 | `document_review``risk_audit` |
## 当前产品入口
当前根路径 `/` 会直接进入审核智能体工作台,便于复试演示聚焦主链路。
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 审核智能体 | `/``/chat/``/chat/<conversation_id>/` | 无资料包知识库问答、会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 |
| 资料包 | `/documents/``/documents/upload/` | 导入资料包、搜索产品或批次、查看解析状态、异常提示、最近导出和处理链路 |
| 处理历史 | `/audit/``/audit/<log_id>/` | 按批次、产品、风险状态、通知状态回看执行快照、原始输出、导出摘要和通知回执 |
| 知识库治理台 | `/platform/knowledge-base/` | 查看法规规则包、RAG 文档源、切片、字段 Schema、Word 模板、责任人映射和飞书配置 |
| MCP 中心演示页 | `/platform/mcp-center/` | 展示外部连接器治理视图 |
| Skill 工作室演示页 | `/platform/skills/` | 展示审核 Skill 编排和发布状态 |
| 审核指挥台 | `/platform/command-center-v2/` | 面向讲解的大屏式审核流程与风险状态视图 |
| 底层场景列表 | `/scenarios/` | 展示 YAML 场景配置和非法配置错误摘要 |
| Django Admin | `/admin/` | 维护后台模型数据 |
## 模块划分 ## 模块划分
@@ -45,19 +60,26 @@ apps.scenarios
apps.documents apps.documents
apps.chat apps.chat
apps.audit apps.audit
apps.platform_ui
agent_core agent_core
``` ```
职责边界: 职责边界:
- Django Apps 负责页面、数据、文件、日志等企业应用外壳 - `config` 负责 Django 配置、路由入口、环境变量、静态资源和上传路径
- Agent Core 负责 RAG、工具调用、模型适配、结构化输出和 Agent 编排 - `apps.scenarios` 负责读取 YAML 场景配置,非法配置可被跳过并展示错误摘要
- RAG、工具调用和模型调用不直接写进 Django View - `apps.documents` 负责资料包、上传文件、章节点识别、页数统计、文本抽取、RAG 入库触发和导出记录
- `apps.chat` 负责审核工作台、会话绑定、用户输入、调用 Agent Core、补传资料和 Word 导出编排。
- `apps.audit` 负责审计日志、通知留痕、处理历史列表和详情回看。
- `apps.platform_ui` 负责知识库治理台、MCP 中心、Skill 工作室和指挥台等演示型治理页面。
- `agent_core` 负责 RAG、工具注册、治理配置、LLM Provider、Prompt 编排和结构化输出。
## 推荐项目结构 约束RAG、工具调用和模型调用不直接写进 Django ViewView 只做请求处理和页面渲染,复杂业务逻辑放到 `services.py``agent_core`
## 项目结构
```text ```text
universal-agent-demo/ DEMO-AGENT/
manage.py manage.py
requirements.txt requirements.txt
Dockerfile Dockerfile
@@ -68,94 +90,62 @@ universal-agent-demo/
config/ config/
apps/ apps/
scenarios/
documents/
chat/
audit/ audit/
chat/
documents/
platform_ui/
scenarios/
agent_core/ agent_core/
rag/ rag/
tools/
schemas/ schemas/
tools/
configs/ configs/
knowledge_qa.yaml
document_review.yaml document_review.yaml
ticket_assistant.yaml governance.yaml
knowledge_qa.yaml
quality_analysis.yaml quality_analysis.yaml
risk_audit.yaml risk_audit.yaml
ticket_assistant.yaml
data/ data/
uploads/ uploads/
chroma/ chroma/
db.sqlite3
docs/ docs/
需求分析/
详细设计/
原型设计/
原始材料/
templates/
tests/
``` ```
## V1 功能范围 ## 已落地能力
V1 需要完成: - 根路径已重定向到审核智能体,降低演示入口复杂度。
- 审核工作台允许未上传资料时直接发起知识库问答,后续再通过右侧上传区导入资料包。
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR压缩包内仅导入支持格式其他文件会生成提示。
- 导入时会创建 `SubmissionBatch``UploadedDocument` 和绑定的 `Conversation`
- 文档解析覆盖文本抽取、PDF 页数统计、DOCX 页数元数据读取、章节点识别、文档角色识别和人工复核标记。
- 审核工作台支持会话历史、资料范围选择、预设问题、节点状态、结构化能力卡、补传资料、Word 导出和通知回看。
- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索、工具注册和治理配置读取。
- Word 导出会生成最小可下载 `.docx`,按风险状态区分正式版或草稿版,并写入导出记录。
- 审计日志记录输入、检索片段、工具调用、结构化输出、原始输出、模型名、耗时、状态和错误信息。
- 飞书通知首版为离线留痕,不直接依赖真实飞书网络发送;支持 `task_completed``task_failed` 两类原因。
- 知识库治理台展示法规规则、RAG 源、切片、字段 Schema、Word 模板、责任人映射和飞书配置。
- 自动化测试默认使用 Mock Provider避免本地真实模型密钥导致测试走网络。
- 场景列表。 ## 启动方式
- Agent 对话页。
- 文件上传。
- 文档入库。
- RAG 检索。
- 内置工具调用。
- 结构化输出展示。
- 审计日志。
- 模型 API 可配置。
- Docker 一键启动。
当前代码基线已经落地的能力 推荐首次本地启动
- 首页支持展示场景摘要、适用题型、RAG 状态、工具数量。
- 非法 YAML 场景配置会被自动跳过,并在首页展示错误摘要。
- 对话页支持问题输入、文档范围选择、结构化结果、引用片段、工具调用和审计入口展示。
- 文档页支持上传、列表查看、手动入库、失败原因提示和重试。
- 审计页支持列表摘要、按场景筛选、详情查看、原始输出展示和敏感信息脱敏。
- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索和工具注册机制。
- 测试环境默认固定使用 Mock Provider避免误调用本地真实模型配置。
V1 暂不重点做:
- 多租户。
- 复杂权限。
- 完整工作流引擎。
- 前后端分离。
- 深度 Dify 集成。
- 生产级高并发优化。
## 复试改题流程
拿到题目后:
1. 判断题目属于哪类模板。
2. 复制最接近的 YAML 场景配置。
3. 修改 Agent 角色、目标、指令和输出模板。
4. 上传题目材料。
5. 如需业务计算,新增一个工具函数。
6. 用 2 到 3 个问题测试效果。
7. 演示场景配置、知识库引用、工具调用、结构化输出和审计日志。
## 当前页面概览
当前项目包含以下主要页面:
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 场景首页 | `/` | 展示场景名称、描述、适用题型、RAG 状态、工具数和配置异常摘要 |
| 对话页 | `/chat/<scenario_id>/` | 输入问题、勾选已入库文档、查看结构化结果、引用片段、工具调用和审计入口 |
| 文档列表页 | `/documents/` | 查看文档状态、错误信息、上传时间并手动触发入库 |
| 文档上传页 | `/documents/upload/` | 选择场景并上传 `.txt``.md``.pdf``.docx` 文件 |
| 审计列表页 | `/audit/` | 查看执行摘要并按场景筛选 |
| 审计详情页 | `/audit/<log_id>/` | 查看输入、最终回答、结构化输出、引用、工具调用、原始输出和错误信息 |
## 计划启动方式
本地启动:
```bash ```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
python manage.py migrate python manage.py migrate
python manage.py runserver python manage.py runserver
@@ -167,21 +157,12 @@ Docker 启动:
docker compose up --build docker compose up --build
``` ```
当前文档目标已统一为完整 V1 闭环:真实 Chroma RAG、OpenAI 兼容 LLM、OpenAI 兼容 Embedding、工具注册和审计日志。开发阶段可以用测试桩验证页面和边界但不作为 V1 验收结果 Docker Compose 会读取根目录 `.env`,并挂载 `./data``./configs`
推荐首次启动步骤:
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
## 环境变量 ## 环境变量
项目当前通过 `os.environ` 读取配置,核心变量如下: 项目通过根目录 `.env` 和系统环境变量读取配置。`.env.example` 只作为模板,不应提交真实密钥。
若复试演示使用硅基流动,可复制 `.env.siliconflow.example``.env`,再手动填入 `LLM_API_KEY``EMBEDDING_API_KEY`
```env ```env
DJANGO_SECRET_KEY=replace-with-a-local-secret-key DJANGO_SECRET_KEY=replace-with-a-local-secret-key
@@ -197,35 +178,21 @@ EMBEDDING_BASE_URL=
EMBEDDING_MODEL=text-embedding-3-small EMBEDDING_MODEL=text-embedding-3-small
SCENARIO_CONFIG_DIR=configs SCENARIO_CONFIG_DIR=configs
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
UPLOAD_ROOT=data/uploads UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma CHROMA_PATH=data/chroma
``` ```
说明: 说明:
- `EMBEDDING_API_KEY` 为空时,代码会自动复用 `LLM_API_KEY` - `EMBEDDING_API_KEY` 为空时自动复用 `LLM_API_KEY`
- `EMBEDDING_BASE_URL` 为空时,代码会自动复用 `LLM_BASE_URL` - `EMBEDDING_BASE_URL` 为空时自动复用 `LLM_BASE_URL`
- `.env.example` 只作为模板,不应填写真实密钥并提交到仓库 - `.env.siliconflow.example` 内置硅基流动 `base_url`、Qwen 对话模型和 `BAAI/bge-m3` Embedding 配置
- 当前代码会在 Django settings 初始化时自动加载根目录 `.env`,本地 `python manage.py runserver``pytest` 和 Docker Compose 可以复用同一套配置 - Django settings 初始化时自动加载根目录 `.env`
- Docker Compose 当前在 `docker-compose.yml` 中通过 `env_file` 读取 `.env` - 测试环境会在 `tests/conftest.py` 中固定 Mock Provider避免误调用真实 LLM
常见做法:
- 本地开发:复制 `.env.example``.env`,填入真实参数后运行。
- Docker 演示:确认 `.env` 已配置后,再执行 `docker compose up --build`
## 测试与验证 ## 测试与验证
当前项目已经补有较完整的模块级测试,覆盖:
- 场景配置读取、非法配置容错和首页展示。
- 对话提交、文档范围传递、结构化结果展示。
- 文档上传、文本抽取、入库成功与失败提示。
- 审计日志落库、筛选、原始输出展示和 API Key 脱敏。
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback 检索。
- Tool Registry 和内置工具行为。
- LLM / Embedding Provider 的配置与请求构造。
常用验证命令: 常用验证命令:
```bash ```bash
@@ -234,15 +201,60 @@ python manage.py check
docker compose config docker compose config
``` ```
说明 当前测试覆盖
- 测试环境默认通过 `tests/conftest.py` 固定 `LLM_PROVIDER=mock`,避免回归测试误走真实网络请求 - 项目配置、根路由和核心页面可访问性
- 当前本地 `.env` 可能包含真实模型配置,但不会影响自动化测试稳定性 - 场景配置读取、非法 YAML 容错和场景列表展示
- 资料包导入、压缩包展开、文档解析、入库状态和异常提示。
- 会话创建、对话提交、文档范围传递、结构化结果展示和 Word 导出。
- 审计日志落库、筛选、详情展示、通知留痕和敏感信息脱敏。
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback、工具注册、LLM / Embedding Provider 请求构造。
- 平台治理页、指挥台、知识库、MCP 中心和 Skill 工作室展示。
## 文档入口 ## 文档入口
- [V1 总需求文档](docs/需求分析/1.V1总需求文档.md) - [V1 总需求文档](docs/需求分析/1.V1总需求文档.md)
- [模块需求文档索引](docs/需求分析/2.模块需求索引.md) - [需求重构总览与待确认事项](docs/需求分析/0.需求重构总览与待确认事项.md)
- [智能体总体设计](docs/设计文档/1.智能体总体设计.md) - [Config 模块需求分析](docs/需求分析/1.config模块需求分析.md)
- [设计文档索引](docs/设计文档/0.设计文档索引.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)
- [注册审核平台整体原型设计](docs/原型设计/1.整体原型设计.md)
- [单文件演示站 HTML](docs/原型设计/registration-prototype-demo.html)
- [协作与编码约定](AGENTS.md) - [协作与编码约定](AGENTS.md)
## 复试改题流程
拿到新题目后:
1. 判断资料包、规则依据和核心审核链路。
2. 调整最接近的 YAML 场景配置,优先从 `configs/document_review.yaml` 入手。
3. 修改 Agent 角色、目标、指令和输出模板。
4. 上传题目材料并生成资料包。
5. 确认产品名称解析、资料包绑定和会话标题是否正确。
6. 如需业务计算,新增工具函数并通过 Tool Registry 注册。
7. 用 2 到 3 个预设问题测试目录汇总、完整性检查、字段抽取和风险报告。
8. 演示节点结果、知识库引用、结构化输出、Word 导出、通知留痕和审计日志。
## V1 不优先做
- React / Vue 前端。
- 多租户。
- 复杂 RBAC。
- 完整工作流引擎。
- 深度 Dify 集成。
- 微服务拆分。
- 分布式任务队列。
- 真实飞书发送链路。
这些内容可以作为后续增强,不应影响 V1 快速成型。

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,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import os import os
from urllib.error import URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -74,6 +74,7 @@ class OpenAICompatibleProvider:
} }
if response_format: if response_format:
payload["response_format"] = response_format payload["response_format"] = response_format
try:
try: try:
data = _post_json( data = _post_json(
base_url=self.base_url, base_url=self.base_url,
@@ -81,6 +82,21 @@ class OpenAICompatibleProvider:
api_key=self.api_key, api_key=self.api_key,
payload=payload, payload=payload,
) )
except RuntimeError as exc:
# 部分 OpenAI 兼容供应商或模型不支持 response_format。
# 保留结构化优先,遇到 400 时退回普通对话,避免演示链路被接口能力差异阻断。
if not response_format or "HTTP Error 400" not in str(exc):
raise
fallback_payload = {
"model": self.model_name,
"messages": messages,
}
data = _post_json(
base_url=self.base_url,
endpoint="chat/completions",
api_key=self.api_key,
payload=fallback_payload,
)
choice = data.get("choices", [{}])[0] choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "") content = choice.get("message", {}).get("content", "")
return LLMResponse( return LLMResponse(
@@ -197,5 +213,11 @@ def _post_json(base_url: str, endpoint: str, api_key: str, payload: dict) -> dic
try: try:
with urlopen(request, timeout=60) as response: with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8")) return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
error_detail = f"{exc}"
if error_body:
error_detail = f"{error_detail} {error_body}"
raise RuntimeError(f"OpenAI 兼容接口调用失败:{error_detail}") from exc
except URLError as exc: except URLError as exc:
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc

View File

@@ -1,6 +1,7 @@
import json import json
import time import time
from .governance import load_governance_config
from .llm_provider import create_llm_provider, get_runtime_llm_config from .llm_provider import create_llm_provider, get_runtime_llm_config
from .results import AgentResult from .results import AgentResult
from .structured_output import ( 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, latency_ms=latency_ms,
status="failed", status="failed",
error=str(llm_response.error or "未知模型错误"), 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) 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", model_name=llm_response.model_name or "unknown-model",
latency_ms=latency_ms, latency_ms=latency_ms,
status="success", 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', '未知错误')}" f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}"
) )
return "\n".join(lines) 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 latency_ms: int = 0
status: str = "success" status: str = "success"
error: str = "" 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

@@ -1,6 +1,13 @@
SUPPORTED_OUTPUT_TYPES = { SUPPORTED_OUTPUT_TYPES = {
"general_answer", "general_answer",
"document_review_report", "document_review_report",
"registration_overview_report",
"registration_completeness_report",
"registration_field_extraction_report",
"registration_consistency_report",
"registration_risk_report",
"registration_word_export_report",
"feishu_notification_report",
"ticket_response", "ticket_response",
"quality_report", "quality_report",
"risk_audit_report", "risk_audit_report",

View File

@@ -41,6 +41,61 @@ OUTPUT_FIELD_TEMPLATES = {
"suggestions": [], "suggestions": [],
"references": [], "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_id = models.CharField(max_length=100, db_index=True)
scenario_name = models.CharField(max_length=200, blank=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() user_input = models.TextField()
retrieved_chunks = models.JSONField(default=list, blank=True) retrieved_chunks = models.JSONField(default=list, blank=True)
tool_calls = 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: def __str__(self) -> str:
return self.title 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 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( def create_audit_log(
@@ -8,6 +43,9 @@ def create_audit_log(
scenario_name: str, scenario_name: str,
user_input: str, user_input: str,
agent_result: AgentResult, agent_result: AgentResult,
batch_id: str = "",
conversation_id: str = "",
product_name: str = "",
) -> AgentAuditLog: ) -> AgentAuditLog:
""" """
将一次 Agent 执行结果落库为审计日志。 将一次 Agent 执行结果落库为审计日志。
@@ -20,6 +58,9 @@ def create_audit_log(
return AgentAuditLog.objects.create( return AgentAuditLog.objects.create(
scenario_id=scenario_id, scenario_id=scenario_id,
scenario_name=scenario_name, scenario_name=scenario_name,
batch_id=batch_id,
conversation_id=conversation_id,
product_name=product_name,
user_input=user_input, user_input=user_input,
retrieved_chunks=agent_result.references, retrieved_chunks=agent_result.references,
tool_calls=agent_result.tool_calls, 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(" ") secret, separator, rest = suffix.partition(" ")
masked_secret = "sk-***" if secret.startswith("sk-") else "***" masked_secret = "sk-***" if secret.startswith("sk-") else "***"
return f"{prefix}{marker}{masked_secret}{separator}{rest}" 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 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): def log_list(request):
# 列表页支持按场景筛选,方便演示时快速定位同一类场景的执行记录 # 处理历史页支持按批次、产品和状态筛选
scenario_id = (request.GET.get("scenario_id") or "").strip() context = build_history_list_context(
logs = AgentAuditLog.objects.all() scenario_id=(request.GET.get("scenario_id") or "").strip(),
if scenario_id: keyword=(request.GET.get("keyword") or "").strip(),
logs = logs.filter(scenario_id=scenario_id) notify_status=(request.GET.get("notify_status") or "").strip(),
return render( risk_status=(request.GET.get("risk_status") or "").strip(),
request,
"audit/log_list.html",
{
"logs": logs,
"selected_scenario_id": scenario_id,
},
) )
return render(request, "audit/log_list.html", context)
def log_detail(request, log_id: int): def log_detail(request, log_id: int):
# 详情页只负责按主键加载审计快照并渲染; # 详情页只负责按主键加载审计快照并渲染;
# 所有脱敏和字段映射都应在服务层完成。 # 所有脱敏和字段映射都应在服务层完成。
audit_log = get_object_or_404(AgentAuditLog, pk=log_id) 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 django import forms
from pathlib import Path
from apps.documents.forms import MultipleFileField, SUPPORTED_EXTENSIONS
class ChatForm(forms.Form): class ChatForm(forms.Form):
@@ -38,3 +41,23 @@ class ChatForm(forms.Form):
def clean_document_ids(self): def clean_document_ids(self):
# View 与 Agent Core 都使用整型文档 ID统一在表单层完成转换。 # View 与 Agent Core 都使用整型文档 ID统一在表单层完成转换。
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])] 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)

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

@@ -0,0 +1,205 @@
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 create_knowledge_conversation() -> Conversation:
"""
创建未绑定资料包的知识库问答会话。
该会话用于用户尚未上传资料时直接向 RAG 知识库提问,
因此 batch_id 与 product_name 保持为空Agent Core 通过空范围执行全局检索。
"""
return Conversation.objects.create(
conversation_id=_generate_conversation_id(),
title="知识库问答会话",
product_name="",
batch_id="",
task_status=Conversation.STATUS_PENDING,
node_results=_build_knowledge_node_results(),
)
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 _build_knowledge_node_results() -> list[dict]:
return [
{"code": "knowledge_retrieval", "label": "知识库检索", "status": "待处理"},
{"code": "answer_generation", "label": "问答生成", "status": "待处理"},
{"code": "risk", "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" app_name = "chat"
# 当前 V1 仅保留一个场景对话入口,场景详情合并在对话页中展示 # 审核智能体前台以会话为中心
urlpatterns = [ 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,60 +1,450 @@
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 agent_core.results import AgentResult
from apps.audit.services import create_audit_log from apps.documents.models import SubmissionBatch, UploadedDocument
from apps.documents.models import UploadedDocument from apps.documents.services import append_documents_to_batch
from apps.scenarios.services import ScenarioNotFound, get_scenario
from .forms import ChatForm from .forms import ChatForm, ConversationUploadForm
from .models import Conversation
from .services import (
create_knowledge_conversation,
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): def index(request):
# View 只负责请求编排、表单校验和模板渲染。 conversations = Conversation.objects.all()
# 具体 Agent 执行、审计写入和文档筛选规则分别交给独立模块处理。 if conversations.exists():
try: return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
scenario = get_scenario(scenario_id) documents = UploadedDocument.objects.filter(batch__isnull=True)
except ScenarioNotFound: form = ChatForm(request.POST or None, documents=documents)
return render( upload_form = ConversationUploadForm()
request,
"chat/index.html",
{
"scenario": None,
"form": ChatForm(),
"error": "场景不存在,请返回首页检查配置。",
},
status=404,
)
result = None result = None
audit_log = None audit_log = None
documents = UploadedDocument.objects.filter( conversation = None
scenario_id=scenario["id"],
status=UploadedDocument.STATUS_INDEXED,
)
form = ChatForm(request.POST or None, documents=documents)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
message = form.cleaned_data["message"] conversation = create_knowledge_conversation()
try: result, audit_log = execute_conversation_agent(
# 只把必要的运行选项传给 Agent Core避免在 View 中散落模型细节。 conversation=conversation,
result = run_agent( message=form.cleaned_data["message"],
scenario, document_ids=form.cleaned_data["document_ids"],
message, detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
options={"document_ids": form.cleaned_data["document_ids"]},
) )
except Exception as exc: conversation.refresh_from_db()
result = AgentResult(status="failed", error=str(exc), answer="") documents = UploadedDocument.objects.filter(batch__isnull=True)
audit_log = create_audit_log(scenario["id"], scenario["name"], message, result)
display_node_results = _normalize_node_results(conversation.node_results if conversation else [])
workspace_summary = _build_workspace_summary(conversation, None, display_node_results) if conversation else _build_empty_workspace_summary()
return render( return render(
request, request,
"chat/index.html", "chat/index.html",
{ {
"scenario": scenario, "conversation": conversation,
"conversations": [],
"conversation_history": [],
"batch": None,
"form": form, "form": form,
"documents": documents, "documents": documents,
"document_count": documents.count(), "document_count": documents.count(),
"result": result, "result": result,
"audit_log": audit_log, "audit_log": audit_log,
"node_results": display_node_results,
"active_node": None,
"workspace_summary": workspace_summary,
"conversation_context": _build_conversation_context(conversation, None, workspace_summary) if conversation else {},
"prompt_templates": _build_prompt_templates(),
"analysis_card": _build_analysis_card(result, conversation) if conversation else {},
"upload_form": upload_form,
"export_card": _build_export_card(result, conversation) if conversation else {},
"risk_card": _build_risk_card(result, conversation) if conversation else {},
"notify_card": _build_notify_card(result, conversation) if conversation else {},
}, },
) )
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
active_node = None
task_modes = [
{"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"},
{"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"},
{"name": "字段抽取", "description": "抽取产品名称、规格、适用范围、储存条件等核心字段。"},
{"name": "一致性核查", "description": "比较申请表、说明书和产品列表的字段一致性。"},
{"name": "综合风险报告", "description": "形成高优先级问题、建议动作和责任人通知。"},
]
if request.method == "POST" and form.is_valid():
message = form.cleaned_data["message"]
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",
{
"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_empty_workspace_summary() -> dict:
return {
"highest_risk_level": "-",
"export_allowed": "",
"notify_status": "待处理",
"export_status": "待处理",
"download_url": "",
"file_count": 0,
"page_count": 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 django.contrib import admin
from .models import UploadedDocument from .models import ExportedDocument, UploadedDocument
@admin.register(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_display = ("id", "original_name", "scenario_id", "file_type", "status", "created_at")
list_filter = ("status", "scenario_id", "file_type") list_filter = ("status", "scenario_id", "file_type")
search_fields = ("original_name", "scenario_id") 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 ScenarioNotFound, get_scenario
from apps.scenarios.services import list_scenarios 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): class DocumentUploadForm(forms.Form):
# 使用 ChoiceField 让表单自己维护场景选项, # 使用 ChoiceField 让表单自己维护场景选项,
# 这样模板、校验和后续扩展都能围绕一个入口完成。 # 这样模板、校验和后续扩展都能围绕一个入口完成。
scenario_id = forms.ChoiceField(label="场景", choices=()) 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -31,7 +48,28 @@ class DocumentUploadForm(forms.Form):
def clean_file(self): def clean_file(self):
uploaded_file = self.cleaned_data["file"] uploaded_file = self.cleaned_data["file"]
if not uploaded_file:
return uploaded_file
extension = Path(uploaded_file.name).suffix.lower() extension = Path(uploaded_file.name).suffix.lower()
if extension not in SUPPORTED_EXTENSIONS: 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 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 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): class UploadedDocument(models.Model):
""" """
保存用户上传文档的元数据和入库状态。 保存用户上传文档的元数据和入库状态。
@@ -13,11 +55,25 @@ class UploadedDocument(models.Model):
STATUS_INDEXED = "indexed" STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed" 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) scenario_id = models.CharField(max_length=100, db_index=True)
original_name = models.CharField(max_length=255) original_name = models.CharField(max_length=255)
file = models.FileField(upload_to="documents/%Y%m%d/") file = models.FileField(upload_to="documents/%Y%m%d/")
file_type = models.CharField(max_length=20) file_type = models.CharField(max_length=20)
size = models.PositiveIntegerField(default=0) 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) status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
error_message = models.TextField(blank=True) error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -36,3 +92,36 @@ class UploadedDocument(models.Model):
self.STATUS_INDEXED: "已入库,可检索", self.STATUS_INDEXED: "已入库,可检索",
self.STATUS_FAILED: "入库失败", self.STATUS_FAILED: "入库失败",
}.get(self.status, self.status) }.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 pathlib import Path
from io import BytesIO
import re import re
import tempfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from zipfile import BadZipFile, ZipFile from zipfile import BadZipFile, ZipFile
from agent_core.rag.ingest import ingest_document 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) extension = _detect_extension(uploaded_file.name)
return UploadedDocument.objects.create( return UploadedDocument.objects.create(
batch=batch,
scenario_id=scenario_id, scenario_id=scenario_id,
original_name=uploaded_file.name, original_name=Path(relative_path or uploaded_file.name).name,
file=uploaded_file, file=uploaded_file,
file_type=extension, file_type=extension,
size=uploaded_file.size, size=uploaded_file.size,
relative_path=relative_path or uploaded_file.name,
status=UploadedDocument.STATUS_UPLOADED, 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: def extract_text(document: UploadedDocument) -> str:
""" """
根据文档类型选择合适的文本抽取策略。 根据文档类型选择合适的文本抽取策略。
@@ -83,6 +401,332 @@ def _detect_extension(file_name: str) -> str:
return Path(file_name).suffix.lower().lstrip(".") 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: def _read_text_file(path: Path) -> str:
"""优先按 UTF-8 读取;失败时回退到系统默认编码。""" """优先按 UTF-8 读取;失败时回退到系统默认编码。"""
try: try:
@@ -102,6 +746,17 @@ def _extract_pdf_text(path: Path) -> str:
return _read_binary_text_fallback(path) 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: def _extract_docx_text(path: Path) -> str:
"""提取 Word XML 中的可见文字内容,不追求保留样式。""" """提取 Word XML 中的可见文字内容,不追求保留样式。"""
try: try:
@@ -115,6 +770,26 @@ def _extract_docx_text(path: Path) -> str:
return _read_binary_text_fallback(path) 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: def _read_binary_text_fallback(path: Path) -> str:
""" """
当结构化抽取失败时,退回到“尽可能保留纯文本”的保底方案。 当结构化抽取失败时,退回到“尽可能保留纯文本”的保底方案。

View File

@@ -6,29 +6,49 @@ from apps.scenarios.services import list_scenarios
from .forms import DocumentUploadForm from .forms import DocumentUploadForm
from .models import UploadedDocument 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): def document_list(request):
# 列表页只负责展示文档元数据和可执行操作,不处理入库细节 # 资料包页展示批次、会话绑定和关键异常,同时保留文档级明细便于演示
documents = UploadedDocument.objects.all() context = build_document_list_context(keyword=(request.GET.get("keyword") or "").strip())
return render(request, "documents/document_list.html", {"documents": documents}) return render(request, "documents/document_list.html", context)
def upload(request): def upload(request):
# 上传成功后仅保存文件和元数据,是否入库由用户显式触发 # 上传成功后直接创建资料包并绑定主会话
if request.method == "POST": if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES) form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
create_uploaded_document(form.cleaned_data["scenario_id"], form.cleaned_data["file"]) result = import_submission_batch(
messages.success(request, "文件已上传,可继续执行入库。") form.cleaned_data["scenario_id"],
form.cleaned_data["uploaded_files"],
)
messages.success(
request,
f"资料包已导入,已绑定会话 {result['conversation_id']}",
)
return redirect("documents:list") return redirect("documents:list")
else: else:
form = DocumentUploadForm() form = DocumentUploadForm()
return render( return render(
request, request,
"documents/upload.html", "documents/upload.html",
{"form": form, "scenarios": list_scenarios()}, {
"form": form,
"scenarios": list_scenarios(),
"upload_checks": [
"文件格式支持 PDF、DOCX、MD、TXT、ZIP、7Z 与 RAR 资料包",
"业务资料与法规依据资料需分开归属",
"支持一次上传多份文件并归并到同一个资料包",
"目录类文件会优先参与完整性校验",
"上传完成后建议立即进入解析与入库流程",
],
},
) )

View File

@@ -0,0 +1 @@

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

6
apps/platform_ui/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PlatformUiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.platform_ui"

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

@@ -0,0 +1,529 @@
from django.urls import reverse
from agent_core.governance import load_governance_config
def get_platform_demo_context():
batch = {
"name": "2026Q2-呼吸道多联检测试剂注册批次",
"product_name": "呼吸道病原体核酸联合检测试剂盒PCR-荧光探针法)",
"owner": "临床注册事务组",
"stage": "法规完整性复核中",
"completion": "74%",
"next_action": "补齐 CH1.11.5 沟通记录并确认产品列表版本",
}
metrics = [
{"label": "资料齐套率", "value": "82%", "note": "42 / 51 个目录项已命中"},
{"label": "法规命中率", "value": "89%", "note": "公告附件包 6 类规则已生效"},
{"label": "字段抽取完成度", "value": "67%", "note": "48 个核心字段已进入统一字段池"},
{"label": "高风险问题", "value": "03", "note": "2 个缺失项1 个跨文档冲突"},
]
workflow_overview = [
{"title": "资料进入系统", "detail": "批量上传 18 份申报资料与 6 份法规原文"},
{"title": "规则与知识装载", "detail": "按章-条-要求项-模板字段建立知识底座"},
{"title": "解析与切片", "detail": "页数统计、目录识别、表格抽取、Chroma 入库"},
{"title": "Agent 审核执行", "detail": "完整性检查、字段抽取、一致性核查"},
{"title": "结论输出", "detail": "形成风险清单、证据引用、责任人动作建议"},
]
risk_board = [
{"level": "", "title": "CH1.11.5 沟通记录缺失", "owner": "监管信息专员", "action": "补充 NMPA 沟通留痕"},
{"level": "", "title": "产品名称跨文档表述不一致", "owner": "产品资料负责人", "action": "统一申请表与说明书命名"},
{"level": "", "title": "2 份 Word 页数为估算值", "owner": "文控支持", "action": "补做版式校验"},
]
quick_links = [
{"title": "知识库配置", "url_name": "platform_ui:knowledge-base", "desc": "维护法规规则树与切片策略"},
{"title": "文件中心", "url_name": "documents:list", "desc": "查看上传、解析、切片与异常状态"},
{"title": "审核智能体", "url_name": "chat:index", "desc": "发起审核、抽取与一致性核查演示"},
{"title": "审核指挥台", "url_name": "platform_ui:command-center-v2", "desc": "面向演示的 Agent 流程解释大屏"},
]
knowledge_sources = [
{
"code": "KB-001",
"name": "公告附件包 / 资料要求说明",
"type": "法规依据",
"scope": "registration",
"updated_at": "今天 09:20",
"status": "已生效",
"owner": "法规专员",
},
{
"code": "KB-002",
"name": "批准证明文件格式要求",
"type": "模板规则",
"scope": "registration",
"updated_at": "今天 09:35",
"status": "已生效",
"owner": "模板管理员",
},
{
"code": "KB-003",
"name": "安全和性能基本原则清单",
"type": "原则规则",
"scope": "registration",
"updated_at": "今天 10:02",
"status": "待人工校订",
"owner": "法规专员",
},
{
"code": "KB-004",
"name": "CH1 监管信息目录样例",
"type": "业务资料",
"scope": "batch",
"updated_at": "今天 10:30",
"status": "已入库",
"owner": "文控专员",
},
]
rule_tree = [
{
"code": "RULE-001",
"chapter": "CH1 监管信息",
"item": "CH1.2 监管信息目录",
"requirement": "必须提供目录与页码映射",
"field": "目录文件 / 页码可信度",
"status": "启用",
},
{
"code": "RULE-002",
"chapter": "CH1 监管信息",
"item": "CH1.4 申请表",
"requirement": "申请表字段需与说明书一致",
"field": "产品名称 / 规格 / 申请人",
"status": "启用",
},
{
"code": "RULE-003",
"chapter": "CH1 监管信息",
"item": "CH1.11.5 沟通记录",
"requirement": "涉及沟通事项时需补齐记录",
"field": "沟通对象 / 时间 / 结论",
"status": "待校订",
},
{
"code": "RULE-004",
"chapter": "批准证明文件格式",
"item": "注册证输出模板",
"requirement": "字段映射需满足版式模板",
"field": "注册证字段池 / Word 模板",
"status": "启用",
},
]
knowledge_stats = [
{"label": "法规知识源", "value": "06"},
{"label": "结构化规则项", "value": "128"},
{"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"},
{"name": "Word 模板服务", "kind": "文档服务", "auth": "API Key", "status": "已连接", "sync": "刚刚"},
{"name": "企业主数据源", "kind": "业务系统", "auth": "MCP Bridge", "status": "未启用", "sync": "未同步"},
]
skills = [
{"name": "完整性检查 Skill", "trigger": "目录齐套性 / 章节点核查", "tools": "规则树 + RAG + 风险映射", "status": "发布中"},
{"name": "字段抽取 Skill", "trigger": "申请表 / 说明书 / 产品列表抽取", "tools": "表格抽取 + 字段池", "status": "已发布"},
{"name": "一致性核查 Skill", "trigger": "跨文档字段冲突检查", "tools": "字段比对 + 解释生成", "status": "灰度测试"},
{"name": "Word 回填 Skill", "trigger": "报送版 Word 输出", "tools": "模板映射 + 导出服务", "status": "待校验"},
]
workflow_steps = [
{"time": "09:32", "title": "载入批次资料", "detail": "识别 18 份业务资料与 6 份法规原文,建立本轮审核上下文。"},
{"time": "09:34", "title": "规则树装载", "detail": "按注册申报主流程装载 CH1 监管信息与批准证明文件格式规则。"},
{"time": "09:36", "title": "字段池初始化", "detail": "从申请表、说明书、产品列表抽取统一字段并建立来源映射。"},
{"time": "09:39", "title": "一致性检查", "detail": "检测到产品名称和样本类型存在跨文档冲突,升级为人工复核。"},
{"time": "09:42", "title": "风险输出", "detail": "生成 3 条风险项、2 条补件建议与 1 条责任人通知任务。"},
]
command_batch = {
"id": "2025IVD-CL-0520-001",
"status": "进行中",
"workflow": "境内第三类",
"class": "III 类",
"created_at": "2025-05-20",
"applicant": "某某生物科技有限公司",
"reviewer": "张审评员",
"role": "审评专家",
"standard": "《体外诊断试剂注册与备案管理办法》及配套技术指导原则",
"version": "V2.12025-04-01",
}
command_metrics = [
{
"key": "completeness",
"label": "资料包完整性得分",
"value": "68",
"suffix": "/100",
"level": "较差",
"detail": "上次得分 612025-05-19",
},
{
"key": "health",
"label": "资料包健康度",
"value": "62",
"suffix": "项检查项",
"segments": [
{"label": "完整", "value": "28", "hint": "45%"},
{"label": "部分缺失", "value": "18", "hint": "29%"},
{"label": "缺失", "value": "16", "hint": "26%"},
],
},
{
"key": "risk",
"label": "风险分布",
"value": "22",
"suffix": "项风险",
"segments": [
{"label": "高风险", "value": "3", "tone": "danger"},
{"label": "中风险", "value": "7", "tone": "warning"},
{"label": "低风险", "value": "12", "tone": "success"},
],
},
{
"key": "progress",
"label": "审核进度",
"value": "46",
"suffix": "%",
"detail": "已完成 24 / 52 项任务",
},
]
command_flow = [
{"step": "1", "title": "资料准备", "date": "2025-05-20", "state": "done"},
{"step": "2", "title": "形式审查", "date": "2025-05-21", "state": "done"},
{"step": "3", "title": "技术审评", "date": "进行中", "state": "active"},
{"step": "4", "title": "核查检验", "date": "待开始", "state": "todo"},
{"step": "5", "title": "综合评审", "date": "待开始", "state": "todo"},
{"step": "6", "title": "行政审批", "date": "待开始", "state": "todo"},
{"step": "7", "title": "制证发证", "date": "待开始", "state": "todo"},
]
command_tabs = [
{"id": "completeness", "label": "注册完整性核查"},
{"id": "consistency", "label": "字段一致性"},
{"id": "risk", "label": "风险准入结论"},
{"id": "evidence", "label": "证据引用"},
{"id": "feishu", "label": "飞书通知状态"},
]
command_checks = [
{
"chapter": "1.产品基本信息",
"item": "产品名称",
"rule": "办法 第十条",
"status": "完整",
"risk": "低风险",
"problem": "-",
},
{
"chapter": "1.产品基本信息",
"item": "预期用途",
"rule": "办法 第十条",
"status": "完整",
"risk": "低风险",
"problem": "-",
},
{
"chapter": "2.综述资料",
"item": "产品描述",
"rule": "指导原则4.2.1",
"status": "完整",
"risk": "低风险",
"problem": "-",
},
{
"chapter": "2.综述资料",
"item": "作用原理",
"rule": "指导原则4.2.2",
"status": "部分缺失",
"risk": "中风险",
"problem": "缺少关键原理图及验证数据说明",
},
{
"chapter": "3.研究资料",
"item": "分析性能评估",
"rule": "指导原则5.3.1",
"status": "部分缺失",
"risk": "中风险",
"problem": "线性范围验证数据不完整",
},
{
"chapter": "3.研究资料",
"item": "阳性判断值",
"rule": "指导原则5.3.2",
"status": "缺失",
"risk": "高风险",
"problem": "未提供阳性判断值确定依据",
},
{
"chapter": "4.临床评价资料",
"item": "临床试验方案",
"rule": "指导原则6.2.1",
"status": "完整",
"risk": "低风险",
"problem": "-",
},
{
"chapter": "4.临床评价资料",
"item": "临床试验报告",
"rule": "指导原则6.2.2",
"status": "部分缺失",
"risk": "中风险",
"problem": "有效性结果分析不完整",
},
{
"chapter": "4.临床评价资料",
"item": "不良事件汇总分析",
"rule": "指导原则6.2.4",
"status": "缺失",
"risk": "高风险",
"problem": "未提供不良事件汇总分析报告",
},
{
"chapter": "5.生产资料",
"item": "生产工艺验证",
"rule": "指导原则7.2.3",
"status": "完整",
"risk": "低风险",
"problem": "-",
},
]
key_risks = [
{"title": "阳性判断值确定依据缺失", "level": "高风险"},
{"title": "不良事件汇总分析报告缺失", "level": "高风险"},
{"title": "临床试验方案偏离未充分说明", "level": "中风险"},
]
next_actions = [
{"title": "退回企业补充资料", "detail": "需企业补充 3 项高风险资料", "state": "待处理", "tone": "danger"},
{"title": "补充说明或澄清", "detail": "需企业说明 7 项中风险问题", "state": "待处理", "tone": "warning"},
{"title": "进入核查检验环节(可选)", "detail": "风险可控后进入下一环节", "state": "待处理", "tone": "success"},
]
owners = [
{"role": "审评专家", "name": "张审评员", "status": "当前处理人"},
{"role": "审核组长", "name": "李组长", "status": "待确认"},
{"role": "法规专员", "name": "王法规", "status": "协同处理"},
{"role": "临床专家", "name": "陈专家", "status": "协同处理"},
]
operation_logs = [
{"time": "2025-05-21 10:24", "actor": "张审评员", "action": "发起完整性核查"},
{"time": "2025-05-21 10:26", "actor": "Agent Core", "action": "命中 62 项检查规则"},
{"time": "2025-05-21 10:28", "actor": "Agent Core", "action": "生成风险准入结论"},
]
knowledge_filters = [
{"label": "全部", "active": True},
{"label": "法规依据", "active": False},
{"label": "模板规则", "active": False},
{"label": "业务资料", "active": False},
]
knowledge_form = {
"title": "新增 / 编辑知识源",
"fields": [
{"label": "知识源名称", "value": "体外诊断试剂注册申报资料要求及说明"},
{"label": "知识源类型", "value": "法规依据"},
{"label": "适用流程", "value": "registration"},
{"label": "状态", "value": "已生效"},
{"label": "切片策略", "value": "按章条切片,保留条款编号"},
],
}
rule_form = {
"title": "新增 / 编辑规则项",
"fields": [
{"label": "规则编码", "value": "RULE-005"},
{"label": "所属章节", "value": "CH1 监管信息"},
{"label": "规则名称", "value": "申请表签章完整性检查"},
{"label": "模板字段", "value": "签章页 / 申请人签字"},
{"label": "规则说明", "value": "若申请表缺少签章,则标记为高优先级缺失项"},
],
}
return {
"batch": batch,
"metrics": metrics,
"workflow_overview": workflow_overview,
"risk_board": risk_board,
"quick_links": quick_links,
"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,
"mcp_connectors": mcp_connectors,
"skills": skills,
"workflow_steps": workflow_steps,
"command_batch": command_batch,
"command_metrics": command_metrics,
"command_flow": command_flow,
"command_tabs": command_tabs,
"command_checks": command_checks,
"key_risks": key_risks,
"next_actions": next_actions,
"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"],
}

14
apps/platform_ui/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "platform_ui"
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"),
path("command-center-v2/", views.command_center_v2, name="command-center-v2"),
]

27
apps/platform_ui/views.py Normal file
View File

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

View File

@@ -12,5 +12,23 @@ def index(request):
{ {
"scenarios": list_scenarios(), "scenarios": list_scenarios(),
"scenario_issues": list_scenario_issues(), "scenario_issues": list_scenario_issues(),
"hero_metrics": [
{"label": "资料齐套率", "value": "82%", "note": "已识别 42 / 51 个法规模板项"},
{"label": "法规命中率", "value": "89%", "note": "公告附件包 6 类规则已加载"},
{"label": "字段抽取完成度", "value": "67%", "note": "48 个核心字段进入统一字段池"},
{"label": "高风险问题", "value": "03", "note": "含 1 个缺失项与 1 个命名冲突"},
],
"workflow_overview": [
{"title": "资料进入系统", "detail": "导入本批次注册申报资料、法规原文与模板文件。"},
{"title": "知识底座装载", "detail": "以章-条-要求项-模板字段组织法规规则。"},
{"title": "解析与切片", "detail": "完成页数统计、表格抽取、章节点归类和向量入库。"},
{"title": "Agent 审核执行", "detail": "发起完整性检查、字段抽取和一致性比对。"},
{"title": "结论输出与留痕", "detail": "形成风险清单、证据引用、责任人动作和审计日志。"},
],
"risk_board": [
{"level": "高风险", "title": "CH1.11.5 沟通记录缺失", "detail": "监管沟通事项未形成可追溯文件,无法支撑本轮章节齐套性。"},
{"level": "高风险", "title": "产品名称在申请表与说明书中不一致", "detail": "Agent 判定为跨文档命名冲突,需进入人工复核。"},
{"level": "中风险", "title": "2 份 Word 文档页数可信度不足", "detail": "版式页数使用估算值,建议复核目录页码与正文总页数。"},
],
}, },
) )

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"apps.documents", "apps.documents",
"apps.chat", "apps.chat",
"apps.audit", "apps.audit",
"apps.platform_ui",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -107,6 +108,9 @@ MEDIA_ROOT = Path(os.environ.get("UPLOAD_ROOT", BASE_DIR / "data" / "uploads"))
# 配置目录和 Chroma 数据目录都允许外部覆盖,方便复试现场快速切换。 # 配置目录和 Chroma 数据目录都允许外部覆盖,方便复试现场快速切换。
SCENARIO_CONFIG_DIR = Path(os.environ.get("SCENARIO_CONFIG_DIR", BASE_DIR / "configs")) 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")) CHROMA_PATH = Path(os.environ.get("CHROMA_PATH", BASE_DIR / "data" / "chroma"))
# LLM 与 Embedding 默认遵循“尽量少配置也能跑”的策略: # LLM 与 Embedding 默认遵循“尽量少配置也能跑”的策略:

View File

@@ -1,16 +1,20 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.views.generic import RedirectView
from django.urls import include, path from django.urls import include, path
# 总路由只承担模块装配职责,不在这里写业务逻辑。 # 总路由只承担模块装配职责,不在这里写业务逻辑。
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("apps.scenarios.urls")), # 首页默认进入审核工作台,旧的平台总览改为非默认入口,便于演示聚焦主链路。
path("", RedirectView.as_view(pattern_name="chat:index", permanent=False)),
path("scenarios/", include("apps.scenarios.urls")),
path("chat/", include("apps.chat.urls")), path("chat/", include("apps.chat.urls")),
path("documents/", include("apps.documents.urls")), path("documents/", include("apps.documents.urls")),
path("audit/", include("apps.audit.urls")), path("audit/", include("apps.audit.urls")),
path("platform/", include("apps.platform_ui.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

@@ -1,22 +1,27 @@
id: document_review id: document_review
name: 文档审核助手 name: 注册资料审核助手
description: 检查合同、制度或 SOP 中的风险点和缺失项 description: 汇总体外诊断试剂注册申报资料目录与页数,并检查完整性、一致性和合规风险
applicable_questions: applicable_questions:
- 合同审核 - 资料目录与页数汇总
- 制度审核 - NMPA 注册申报资料完整性检查
- 产品关键信息抽取
- 跨文档一致性核查
- 合规风险预警
agent: agent:
role: 文档审核专家 role: 体外诊断试剂注册资料审核专家
goal: 根据审核规则和知识库内容输出结构化审核意见 goal: 根据 NMPA 注册申报资料要求、法规依据和上传资料输出结构化审核结论
instructions: instructions:
- 优先围绕资料目录、页数、章节点、完整性、字段一致性和风险建议回答
- 法规判断应优先依据本地规则和知识库证据,不得凭空补全缺失资料
- 不确定的问题必须标记为需人工复核 - 不确定的问题必须标记为需人工复核
- 输出必须包含风险等级和修改建议 - 输出必须包含风险等级、涉及文件、法规或规则依据和处理建议
rag: rag:
enabled: true enabled: true
collection: document_review collection: registration_documents
top_k: 5 top_k: 5
tools: tools:
- check_required_fields - check_required_fields
output: output:
type: document_review_report type: registration_risk_report
audit: audit:
enabled: true enabled: true

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: 风险等级 / 批次号 / 责任人 / 证据摘要

25
design-qa.md Normal file
View File

@@ -0,0 +1,25 @@
**Findings**
- No actionable P0/P1/P2 findings remain.
**Open Questions**
- The source visual is an Image Gen concept rather than a pixel-locked Figma file, so exact icon glyphs and logo geometry were implemented as code-native UI marks. The final prototype preserves the selected direction's hierarchy, density, risk states, and operational layout.
**Implementation Checklist**
- Source visual truth path: `C:\Users\bruce\.codex\generated_images\019e8bb8-b097-72b3-9c89-97cfca019c7c\ig_0fe578f839e33933016a1fae76771c81918c954bf5cbfe72d2.png`
- Implementation screenshot path: `D:\Code\DEMO-AGENT\output\playwright\command-center-v2-desktop.png`
- Mobile screenshot path: `D:\Code\DEMO-AGENT\output\playwright\command-center-v2-mobile.png`
- Full-view comparison evidence: `D:\Code\DEMO-AGENT\output\playwright\command-center-v2-comparison.png`
- Viewport: desktop `1440 x 1024`, mobile `390 x 844`
- State: default command-center workbench; desktop screenshot shows the selected direction's primary dashboard state.
- Focused region comparison evidence: not separately required; this is a dense dashboard concept and the full-view comparison makes the critical regions visible enough: sidebar, batch strip, metrics, workflow, audit table, and right risk rail.
- Fonts and typography: Segoe UI / PingFang SC / Microsoft YaHei fallback stack matches the professional enterprise SaaS feel; hierarchy and body sizes are readable, with no negative letter spacing.
- Spacing and layout rhythm: Desktop uses the same left rail, top context strip, four metric blocks, workflow strip, table region, and right-side risk rail as the source. Mobile switches to stacked sections to avoid cramped table-dashboard density.
- Colors and visual tokens: Deep navy sidebar, white work surface, cool gray borders, blue primary actions, red/orange/green semantic risk states match the selected visual direction.
- Image quality and asset fidelity: The source concept did not require product photos or decorative raster assets. The implementation uses lightweight UI marks and CSS primitives appropriate for a Django dashboard prototype.
- Copy and content: Chinese labels are aligned to the requirements: NMPA IVD review, registration batch, completeness check, risk gate, evidence, Feishu notification, responsible owners, and audit trail.
- Patches made since previous QA pass: added mobile wrapping for the header and batch strip, converted mobile actions to a single-column grid, adjusted the health legend, and suppressed horizontal overflow.
- final result: passed
**Follow-up Polish**
- P3: Replace letter-based placeholder nav icons with an icon font or library if the project later adds a frontend asset pipeline.
- P3: Add separate interactive tab bodies for consistency, evidence, and Feishu cards if the prototype needs a longer live demo script.

View File

@@ -1,343 +0,0 @@
# V1 Django Baseline 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 the smallest runnable Django baseline that satisfies the current Chinese requirements and design documents.
**Architecture:** Use a Django monolith with four apps (`scenarios`, `documents`, `chat`, `audit`) plus an independent `agent_core` package. The first implementation returns deterministic mock Agent results so the UI, audit, documents and module boundaries can be verified before real RAG/LLM integration.
**Tech Stack:** Python 3.13, Django 5.x, PyYAML, pytest, pytest-django, SQLite, Docker Compose.
---
## File Structure
- Create `requirements.txt`: runtime and test dependencies.
- Create `manage.py`, `config/settings.py`, `config/urls.py`, `config/wsgi.py`, `config/asgi.py`: Django project shell.
- Create `apps/scenarios/`: YAML scenario loading, homepage, tests.
- Create `apps/documents/`: upload model, upload/list/index views, text extraction, tests.
- Create `apps/chat/`: message form, chat view, Agent Core call, audit write, tests.
- Create `apps/audit/`: audit model, service, list/detail views, tests.
- Create `agent_core/`: dataclasses, orchestrator, mock RAG ingest/retrieve, tool registry, structured output parser.
- Create `configs/*.yaml`: five required scenarios.
- Create `templates/`: minimal Django Templates for pages.
- Create `Dockerfile`, `docker-compose.yml`, `.env.example`: one-command startup.
## Task 1: Dependencies and Django Project Shell
**Files:**
- Create: `requirements.txt`
- Create: `manage.py`
- Create: `config/__init__.py`
- Create: `config/settings.py`
- Create: `config/urls.py`
- Create: `config/wsgi.py`
- Create: `config/asgi.py`
- Test: `pytest.ini`
- [ ] **Step 1: Write failing configuration test**
Create `tests/test_project_configuration.py`:
```python
from django.conf import settings
from django.urls import reverse
def test_core_settings_expose_documented_paths():
assert settings.SCENARIO_CONFIG_DIR.name == "configs"
assert settings.CHROMA_PATH.name == "chroma"
assert settings.MEDIA_ROOT.name == "uploads"
def test_home_url_is_registered(client):
response = client.get(reverse("scenarios:index"))
assert response.status_code == 200
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_project_configuration.py -q`
Expected: FAIL because Django project and apps do not exist.
- [ ] **Step 3: Implement minimal project shell**
Add dependencies and project files with settings for installed apps, templates, SQLite, media paths, and URL includes.
- [ ] **Step 4: Run test to verify progress**
Run: `pytest tests/test_project_configuration.py -q`
Expected: either PASS or fail only because `apps.scenarios` is not implemented yet.
## Task 2: Scenarios Module and Five YAML Configs
**Files:**
- Create: `apps/scenarios/services.py`
- Create: `apps/scenarios/views.py`
- Create: `apps/scenarios/urls.py`
- Create: `apps/scenarios/apps.py`
- Create: `configs/knowledge_qa.yaml`
- Create: `configs/document_review.yaml`
- Create: `configs/ticket_assistant.yaml`
- Create: `configs/quality_analysis.yaml`
- Create: `configs/risk_audit.yaml`
- Create: `templates/scenarios/index.html`
- Test: `tests/test_scenarios.py`
- [ ] **Step 1: Write failing scenario tests**
```python
from apps.scenarios.services import get_scenario, list_scenarios
def test_list_scenarios_loads_five_configs():
scenarios = list_scenarios()
assert [scenario["id"] for scenario in scenarios] == [
"knowledge_qa",
"document_review",
"ticket_assistant",
"quality_analysis",
"risk_audit",
]
def test_get_scenario_returns_full_agent_config():
scenario = get_scenario("quality_analysis")
assert scenario["agent"]["role"]
assert scenario["rag"]["enabled"] is True
assert scenario["output"]["type"] == "quality_report"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_scenarios.py -q`
Expected: FAIL because services/configs are missing.
- [ ] **Step 3: Implement scenario loader and homepage**
Use `yaml.safe_load()`, validate required fields, and render scenario cards on `/`.
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_scenarios.py tests/test_project_configuration.py -q`
Expected: PASS.
## Task 3: Agent Core Mock Orchestrator
**Files:**
- Create: `agent_core/results.py`
- Create: `agent_core/orchestrator.py`
- Create: `agent_core/structured_output.py`
- Create: `agent_core/tool_registry.py`
- Create: `agent_core/tools/builtin_tools.py`
- Create: `agent_core/rag/ingest.py`
- Create: `agent_core/rag/retriever.py`
- Test: `tests/test_agent_core.py`
- [ ] **Step 1: Write failing Agent Core tests**
```python
from agent_core.orchestrator import run_agent
def test_run_agent_returns_structured_mock_result():
scenario = {
"id": "knowledge_qa",
"name": "知识库问答助手",
"rag": {"enabled": True, "collection": "knowledge_qa", "top_k": 3},
"tools": ["generate_action_items"],
"output": {"type": "general_answer"},
}
result = run_agent(scenario, "如何处理异常?")
assert result.status == "success"
assert result.answer
assert result.structured_output["output_type"] == "general_answer"
assert result.tool_calls[0]["tool_name"] == "generate_action_items"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_agent_core.py -q`
Expected: FAIL because `agent_core` is missing.
- [ ] **Step 3: Implement deterministic mock AgentResult**
Return stable answer, references, tool calls, model name `mock-model`, and latency.
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_agent_core.py -q`
Expected: PASS.
## Task 4: Audit Module
**Files:**
- Create: `apps/audit/models.py`
- Create: `apps/audit/services.py`
- Create: `apps/audit/views.py`
- Create: `apps/audit/urls.py`
- Create: `apps/audit/admin.py`
- Create: `templates/audit/log_list.html`
- Create: `templates/audit/log_detail.html`
- Test: `tests/test_audit.py`
- [ ] **Step 1: Write failing audit tests**
```python
from apps.audit.models import AgentAuditLog
from apps.audit.services import create_audit_log
from agent_core.results import AgentResult
def test_create_audit_log_records_success_result(db):
result = AgentResult(answer="回答", structured_output={"x": 1}, status="success")
log = create_audit_log("knowledge_qa", "知识库问答助手", "问题", result)
assert AgentAuditLog.objects.count() == 1
assert log.final_answer == "回答"
assert log.structured_output == {"x": 1}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_audit.py -q`
Expected: FAIL because audit app is missing.
- [ ] **Step 3: Implement model, service, admin, views**
Use JSONField defaults and avoid storing sensitive environment values.
- [ ] **Step 4: Run migrations and tests**
Run: `python manage.py makemigrations audit && pytest tests/test_audit.py -q`
Expected: PASS.
## Task 5: Documents Module
**Files:**
- Create: `apps/documents/models.py`
- Create: `apps/documents/services.py`
- Create: `apps/documents/forms.py`
- Create: `apps/documents/views.py`
- Create: `apps/documents/urls.py`
- Create: `apps/documents/admin.py`
- Create: `templates/documents/document_list.html`
- Create: `templates/documents/upload.html`
- Test: `tests/test_documents.py`
- [ ] **Step 1: Write failing document tests**
```python
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from apps.documents.models import UploadedDocument
def test_upload_txt_document_creates_uploaded_record(client, db):
file = SimpleUploadedFile("rules.txt", "hello".encode("utf-8"), content_type="text/plain")
response = client.post(reverse("documents:upload"), {"scenario_id": "knowledge_qa", "file": file})
assert response.status_code == 302
document = UploadedDocument.objects.get()
assert document.status == "uploaded"
assert document.file_type == "txt"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_documents.py -q`
Expected: FAIL because documents app is missing.
- [ ] **Step 3: Implement upload/list/index flow**
Support `.txt` and `.md`; index action calls `agent_core.rag.ingest.ingest_document()` and updates status.
- [ ] **Step 4: Run migrations and tests**
Run: `python manage.py makemigrations documents && pytest tests/test_documents.py -q`
Expected: PASS.
## Task 6: Chat Module
**Files:**
- Create: `apps/chat/forms.py`
- Create: `apps/chat/views.py`
- Create: `apps/chat/urls.py`
- Create: `apps/chat/apps.py`
- Create: `templates/chat/index.html`
- Test: `tests/test_chat.py`
- [ ] **Step 1: Write failing chat tests**
```python
from django.urls import reverse
from apps.audit.models import AgentAuditLog
def test_chat_post_returns_agent_result_and_audit_log(client, db):
response = client.post(reverse("chat:index", args=["knowledge_qa"]), {"message": "如何处理异常?"})
assert response.status_code == 200
assert "mock-model" in response.content.decode("utf-8")
assert AgentAuditLog.objects.count() == 1
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_chat.py -q`
Expected: FAIL because chat app is missing.
- [ ] **Step 3: Implement chat form and view**
Validate message, call `get_scenario()`, `run_agent()`, then `create_audit_log()`.
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_chat.py tests/test_audit.py tests/test_agent_core.py -q`
Expected: PASS.
## Task 7: Docker and Documentation Alignment
**Files:**
- Create: `.env.example`
- Create: `Dockerfile`
- Create: `docker-compose.yml`
- Modify: `README.md`
- Test: all tests and Django checks.
- [ ] **Step 1: Add deployment files**
Use a single web service, install `requirements.txt`, run migrations, and serve `0.0.0.0:8000`.
- [ ] **Step 2: Verify Django and tests**
Run:
```bash
python manage.py check
pytest -q
```
Expected: all checks pass.
- [ ] **Step 3: Verify docs path references**
Run:
```powershell
$patterns = @('docs/需求分析', 'docs/设计文档', 'V1总需求文档', '智能体总体设计')
Get-ChildItem -Recurse -File |
Where-Object {
$_.FullName -notlike '*\.git\*' -and
$_.FullName -notlike '*\.idea\*' -and
$_.FullName -notlike '*docs\superpowers\plans\2026-05-29-v1-django-baseline.md'
} |
Select-String -Pattern $patterns
```
Expected: no matches.
## Self-Review
- Spec coverage: The plan covers Chinese docs, five scenario configs, Django startup, homepage, chat, audit, documents, Agent Core, and Docker baseline.
- Placeholder scan: No implementation step relies on an undefined placeholder; mock LLM/RAG is intentionally scoped as the first runnable baseline.
- Type consistency: Tests use `AgentResult`, `run_agent`, `list_scenarios`, `get_scenario`, `UploadedDocument`, and `AgentAuditLog` consistently.

View File

@@ -0,0 +1,151 @@
# 资料包导入页原型设计
## 1. 页面目标
把注册申报资料的导入、解包、扫描、目录汇总和章节点识别结果集中展示出来,让用户第一眼就明白本平台的输入对象是“资料包”,不是单篇文档。
本页还需要明确表达:资料包不是孤立文件集合,而是会与一个审核会话绑定,对话标题默认采用解析后的产品名称。
## 2. 适用角色
- 注册资料专员
- 文档整理人员
- 演示讲解人
## 3. 页面布局分区
页面建议采用“三段式”:
1. 顶部导入条
2. 中部处理看板
3. 底部目录汇总区
建议分区:
- 顶部:批次信息、上传入口、导入方式切换
- 左侧:文件 / 压缩包导入队列
- 中部:处理流水线
- 右侧:异常与待复核箱
- 底部:目录树与目录汇总表
## 4. 核心卡片 / 表格 / 状态区
### 4.1 导入入口卡
展示:
- 批量文件上传
- 文件夹导入
- 压缩包导入
- 支持格式标签:`pdf / docx / doc / zip / rar / 7z`
### 4.2 处理流水线
按步骤展示:
1. 创建批次
2. 文件校验
3. 解包
4. 文件树扫描
5. 页数统计
6. 章节点识别
7. 目录汇总
每一步显示:
- 当前状态
- 处理数量
- 成功 / 失败数
### 4.3 异常箱
展示以下异常类型:
- 页数待复核
- 扩展名与 MIME 不一致
- 扫描件待 OCR
- 章节点识别失败
- 解包失败
### 4.4 目录汇总表
表格列建议:
- 产品名称
- 关联会话
- 原始相对路径
- 文件名
- 文件类型
- 页数
- 页数可信度
- 章节点
- 资料名称
- 处理状态
- 是否命中法规目录
## 5. 关键字段定义
页面主要消费 `registration_overview_report`
关键字段:
- `batch_id`
- `product_name`
- `conversation_id`
- `file_count`
- `supported_file_count`
- `failed_file_count`
- `total_page_count`
- `page_count_status`
- `chapter_summary`
- `documents[]`
目录条目关键字段:
- `original_filename`
- `relative_path`
- `file_type`
- `chapter_code`
- `chapter_name`
- `page_count`
- `page_count_confidence`
- `processing_status`
- `needs_manual_review`
## 6. 关键交互
- 点击“上传压缩包”后,展示模拟导入进度。
- 点击目录树节点,可在右侧高亮对应文件。
- 点击异常项,可筛选下方表格。
- 点击单个文件行,可打开“文档详情抽屉”,展示页数统计方式和章节点识别结果。
- 点击资料包标题或“查看对话”,跳转到关联会话。
- 页面顶部搜索框支持按产品名称或批次号搜索资料包。
- 点击“在对话中查看完整性节点”,切回 `审核智能体` 中的对应节点结果。
## 7. 与上下游页面的数据关系
上游:无,属于演示主线起点。
下游:
- 法规完整性检查页直接消费目录汇总结果。
- 字段抽取页复用文档主数据、文本状态和章节点结果。
- 治理台中的 RAG 文档源管理可从该页二级入口进入。
## 8. 演示话术重点
- 强调本平台处理的是整套注册资料,不是单文档聊天。
- 强调资料包会与会话绑定,用户后续是围绕该产品资料持续对话和追溯。
- 强调压缩包、目录层级、页数和章节点是后续审核的事实基础。
- 强调异常箱的价值在于把“资料问题”前置,而不是等到审核后才发现。
## 9. 与知识库 / 治理台的关联入口
本页应提供以下治理入口:
- `查看 RAG 入库策略`
- `查看支持文件类型配置`
- `查看章节点识别规则`
- `重跑切片任务`
这些入口统一打开治理台抽屉,不在本页直接展开 CRUD。

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

@@ -0,0 +1,125 @@
# 法规完整性检查页原型设计
## 1. 页面目标
对照 NMPA 法规要求,展示当前资料包的齐套性、错放情况、法规依据、风险等级和处理建议,让用户知道“缺了什么、为什么缺、依据是什么”。
在最新版原型中,本页更准确地说是 `审核智能体` 中“法规完整性检查”节点的展开视图,而不是独立执行入口。
## 2. 适用角色
- 注册申报负责人
- 法规专员
- 质控复核人员
## 3. 页面布局分区
推荐采用“三栏 + 底部证据表”布局:
- 左栏:法规目录树与章节范围
- 中栏:缺失项 / 错放项主列表
- 右栏:法规依据和风险摘要
- 底部:证据与建议表
## 4. 核心卡片 / 表格 / 状态区
### 4.1 法规目录树
展示:
- `CH1 ~ CH6`
- 已匹配数量
- 缺失数量
- 待复核数量
### 4.2 问题列表
分标签展示:
- 缺失项
- 错放项
- 疑似提供
- 待人工复核
每条记录展示:
- 章节
- 资料名称
- 当前状态
- 风险等级
- 命中文档或未命中说明
### 4.3 法规依据舱
展示:
- 规则包名称
- 规则版本
- 法规原文来源
- 证据片段
- 适用流程
### 4.4 处理建议表
列建议:
- 问题类型
- 问题说明
- 法规依据
- 风险等级
- 建议动作
- 责任角色
## 5. 关键字段定义
页面主要消费 `registration_completeness_report`
关键字段:
- `required_item_count`
- `provided_item_count`
- `missing_item_count`
- `suspected_item_count`
- `misplaced_item_count`
- `manual_review_item_count`
- `highest_risk_level`
- `pass_status`
- `missing_items[]`
- `misplaced_items[]`
- `manual_review_items[]`
- `evidence_refs[]`
- `suggestions[]`
## 6. 关键交互
- 点击目录树章节,筛选中部问题列表。
- 点击缺失项,右侧法规依据自动切换。
- 点击“查看证据片段”,展开 RAG 命中内容。
- 点击“在对话中查看字段抽取节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系
上游:消费 `registration_overview_report`
下游:
- 风险预警页复用完整性结论。
- 飞书通知页可引用完整性问题摘要。
- 治理台中的法规规则包和 RAG 文档源由本页进入最自然。
## 8. 演示话术重点
- 强调完整性判断由结构化规则决定,不由大模型自由发挥。
- 强调 RAG 的职责是给证据,不是当裁判。
- 强调风险等级和责任角色已经提前结构化,便于协同。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `查看规则包`
- `查看法规原文切片`
- `新增法规来源`
- `编辑章节要求`
所有操作进入治理台对应 CRUD 视图。

View File

@@ -0,0 +1,126 @@
# 字段抽取与字段池页原型设计
## 1. 页面目标
展示从说明书、申请表、产品列表等资料中抽取出的结构化字段,并把来源、置信度、标准值、待复核状态和是否可回填统一展示出来。
在最新版原型中,本页对应 `审核智能体` 中“字段抽取”节点的细化视图。
## 2. 适用角色
- 注册资料专员
- 数据校对人员
- 模板回填使用人
## 3. 页面布局分区
建议采用“顶部摘要 + 中部字段池表格 + 右侧来源证据抽屉”。
分区如下:
- 顶部:字段抽取统计
- 中部:字段池主表
- 下方:待复核字段区
- 右侧:字段来源详情抽屉
## 4. 核心卡片 / 表格 / 状态区
### 4.1 抽取摘要卡
展示:
- 目标字段数量
- 已抽取数量
- 待复核字段数量
- 冲突候选数量
- 可回填字段数量
### 4.2 字段池主表
列建议:
- 字段编码
- 中文字段名
- 标准值
- 原文值
- 来源文档
- 来源位置
- 抽取方式
- 置信度
- 冲突状态
- 待复核状态
- 是否可回填
### 4.3 待复核区
重点突出:
- 低置信度字段
- 长文本归纳字段
- 来源不唯一字段
## 5. 关键字段定义
页面主要消费 `registration_field_extraction_report`
关键字段:
- `target_field_count`
- `extracted_field_count`
- `manual_review_field_count`
- `conflict_candidate_count`
- `field_pool_status`
- `field_pool_items[]`
- `manual_review_fields[]`
- `evidence_refs[]`
字段池条目关键字段:
- `field_key`
- `field_label`
- `standard_value`
- `raw_value`
- `source_document_name`
- `source_location`
- `extract_method`
- `confidence`
- `conflict_status`
- `manual_review_required`
- `fillable`
## 6. 关键交互
- 支持按“全部 / 可回填 / 待复核 / 高置信度”切换。
- 点击字段行打开来源详情。
- 点击“查看原文片段”展开证据区。
- 点击“标记推荐值”模拟人工确认。
- 点击“在对话中查看一致性核查节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系
上游:
- 来自资料导入页的文档主数据
- 来自完整性检查页的前置校验状态
下游:
- 一致性核查页直接使用字段池
- Word 回填导出页使用可回填字段集
## 8. 演示话术重点
- 强调统一字段池是后续一致性核查和回填导出的中间事实层。
- 强调固定字段优先规则抽取,长文本才交给 LLM 辅助归纳。
- 强调每个字段都有来源,不是“模型猜你要什么”。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `维护字段 Schema`
- `维护字段来源优先级`
- `查看字段映射规则`
- `重跑抽取策略`
这些入口接入治理台的字段 Schema 和模板映射 CRUD。

View File

@@ -0,0 +1,115 @@
# 一致性核查页原型设计
## 1. 页面目标
基于统一字段池,对同一审核范围内不同文档的关键字段做完全一致比对,清楚展示冲突字段、来源对比和混档风险。
在最新版原型中,本页对应 `审核智能体` 中“一致性核查”节点的细化视图。
## 2. 适用角色
- 注册资料负责人
- 复核人员
- 项目经理
## 3. 页面布局分区
建议采用“范围确认条 + 冲突主表 + 对比视图 + 风险侧栏”。
分区如下:
- 顶部:审核范围确认条
- 中部:冲突字段表
- 下部:来源对比面板
- 右侧:混档风险与建议
## 4. 核心卡片 / 表格 / 状态区
### 4.1 范围确认条
展示:
- 当前批次
- 已纳入文档数量
- 排除法规资料说明
- 是否存在疑似跨产品文档
### 4.2 冲突字段主表
列建议:
- 字段名
- 比对结果
- 冲突值数量
- 来源文档数
- 最高风险等级
- 是否疑似混档
### 4.3 来源对比面板
按选中字段展示:
- 文档 A 值
- 文档 B 值
- 来源章节
- 来源页码
- 标准化结果
### 4.4 风险侧栏
展示:
- 冲突字段总数
- 混档告警数量
- 待复核字段数
- 建议处理动作
## 5. 关键字段定义
页面主要消费 `registration_consistency_report`
关键字段:
- `checked_field_count`
- `consistent_field_count`
- `conflict_field_count`
- `manual_review_field_count`
- `mixed_package_warning_count`
- `highest_risk_level`
- `pass_status`
- `conflict_fields[]`
- `manual_review_fields[]`
- `mixed_package_warnings[]`
- `suggestions[]`
## 6. 关键交互
- 点击字段行,在下方切换来源对比视图。
- 点击“仅看冲突字段”过滤表格。
- 点击混档告警可跳到相关文档范围说明。
- 点击“在对话中查看风险预警节点”,回到 `审核智能体` 继续主线。
## 7. 与上下游页面的数据关系
上游:消费字段池。
下游:
- 风险预警页复用冲突结论
- Word 回填导出页依据冲突状态做拦截
## 8. 演示话术重点
- 强调一致性核查不重新抽取字段,只对字段事实做比对。
- 强调系统默认严格一致规则,避免模糊通过。
- 强调混档风险是当前业务场景的关键价值点。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `维护强一致字段规则`
- `查看审核范围配置`
- `查看混档识别规则`
这些入口进入治理台的规则包和字段规则维护视图。

View File

@@ -0,0 +1,112 @@
# 风险预警页原型设计
## 1. 页面目标
把完整性、字段抽取和一致性核查的结果统一归并成综合风险清单,给出总风险等级、是否通过、整改建议和责任角色。
在最新版原型中,本页对应 `审核智能体` 中“风险预警”节点的细化视图。
## 2. 适用角色
- 注册申报负责人
- 项目经理
- 业务主管
## 3. 页面布局分区
建议采用“总风险头图 + 风险矩阵 + 整改建议 + 责任分发区”。
分区如下:
- 顶部:总风险等级与通过结论
- 左侧:风险分布矩阵
- 中部:风险清单
- 右侧:责任角色与建议动作
## 4. 核心卡片 / 表格 / 状态区
### 4.1 总风险头图
展示:
- 当前批次
- 最高风险等级
- 是否通过
- 高 / 中 / 低风险数量
- 待人工复核数量
### 4.2 风险清单表
列建议:
- 风险类型
- 风险等级
- 问题描述
- 来源报告
- 关联文档
- 整改建议
- 责任角色
### 4.3 责任分发区
展示:
- 注册资料负责人
- 注册申报负责人
- 临床注册负责人
并显示每个角色对应待处理风险数。
## 5. 关键字段定义
页面主要消费 `registration_risk_report`
关键字段:
- `risk_item_count`
- `high_risk_count`
- `medium_risk_count`
- `low_risk_count`
- `manual_review_count`
- `highest_risk_level`
- `pass_status`
- `risk_items[]`
- `manual_review_items[]`
- `suggestions[]`
- `owner_notifications[]`
## 6. 关键交互
- 点击风险类型过滤清单。
- 点击责任角色查看该角色负责的风险。
- 点击“生成通知摘要”跳转飞书通知视图。
- 点击“查看导出影响”跳转 Word 回填导出页。
## 7. 与上下游页面的数据关系
上游:
- 完整性报告
- 字段抽取报告
- 一致性报告
下游:
- Word 回填导出页引用是否通过和阻断项
- 飞书通知视图引用责任人和风险摘要
## 8. 演示话术重点
- 强调整个系统不是只找问题,而是把问题归并成“可执行风险”。
- 强调是否通过和责任角色已经直接结构化,可以进入实际协同。
- 强调高风险会影响后续导出和通知动作。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `维护风险规则`
- `维护责任角色映射`
- `查看准入判定规则`
这些入口进入治理台的风险规则和责任人映射 CRUD。

View File

@@ -0,0 +1,120 @@
# Word回填导出页原型设计
## 1. 页面目标
展示字段如何回填到注册申报表格或对照清单中,并说明哪些字段已回填、哪些字段被风险或冲突拦截、当前导出状态如何以及用户从哪里下载文件。
在最新版原型中,本页对应 `审核智能体` 中“Word 回填导出”节点的细化视图。
## 2. 适用角色
- 注册资料专员
- 表格整理人员
- 审核结果使用人
## 3. 页面布局分区
建议采用“模板与导出状态头部 + 回填字段表 + 拦截项区 + 下载区”。
分区如下:
- 顶部:模板选择与导出结论
- 中部:回填字段表
- 右侧:拦截项和版式校验
- 底部:导出记录与下载入口
## 4. 核心卡片 / 表格 / 状态区
### 4.1 导出头部
展示:
- 模板名称
- 模板版本
- 当前导出状态
- 是否允许正式版
- 是否允许草稿版
### 4.2 回填字段表
列建议:
- 占位符
- 字段名
- 字段值
- 来源
- 回填状态
- 是否必填
### 4.3 拦截项区
展示:
- 冲突字段拦截
- 高风险拦截
- 待复核字段拦截
- 缺失必填字段拦截
### 4.4 导出记录区
展示:
- 输出文件名
- 输出版本
- 导出时间
- 版式校验结果
- 下载入口
## 5. 关键字段定义
页面主要消费 `registration_word_export_report`
关键字段:
- `template_id`
- `export_status`
- `fillable_field_count`
- `filled_field_count`
- `blocked_field_count`
- `manual_review_field_count`
- `layout_check_status`
- `filled_fields[]`
- `blocked_fields[]`
- `output_file`
## 6. 关键交互
- 点击模板下拉模拟切换模板。
- 点击“生成草稿”切换导出状态。
- 点击“尝试生成正式版”时展示阻断提示。
- 点击某个拦截项,可查看其来源风险。
- 点击下载按钮,展示 mock 下载反馈。
## 7. 与上下游页面的数据关系
上游:
- 字段池
- 一致性核查报告
- 风险预警报告
下游:
- 飞书通知页可引用“已生成草稿”或“正式导出被阻断”的状态
## 8. 演示话术重点
- 强调平台不仅能抽取字段,还能进入真正的交付动作。
- 强调高风险不会被忽略,系统会阻止直接生成正式报送文件。
- 强调模板、字段映射、拦截条件都可治理,不写死在 Prompt 里。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `维护 Word 模板`
- `维护字段映射`
- `查看导出版式校验规则`
- `查看导出记录`
这些入口进入治理台的模板与映射 CRUD。

View File

@@ -0,0 +1,109 @@
# 飞书通知视图原型设计
## 1. 页面目标
把风险预警和导出状态转成一张可发送的飞书消息卡片,展示责任人 `@`、消息摘要、Web 详情链接和发送回执,形成审核智能体之外的协同闭环。
当前 Demo 固定口径为:任务执行完成后直接通知,或在执行异常后直接通知,并 `@` 对应处理人。
## 2. 适用角色
- 注册申报负责人
- 协同群聊成员
- 演示讲解人
## 3. 页面布局分区
建议采用“双栏布局”:
- 左侧:飞书消息卡片预览
- 右侧:通知配置与发送回执
底部补一条 Web 详情页入口说明。
## 4. 核心卡片 / 表格 / 状态区
### 4.1 飞书消息卡片预览
展示:
- 批次编号
- 风险摘要
- 是否通过
- 责任人 `@`
- 关键风险条目
- Web 详情按钮
### 4.2 通知配置区
展示:
- 触发来源
- 通知原因
- 飞书群聊 ID
- 消息类型
- 是否包含责任人通知
- 是否附带 Web 链接
### 4.3 发送回执区
展示:
- 发送状态
- 消息 ID
- 发送时间
- 失败原因
## 5. 关键字段定义
页面主要消费 `feishu_notification_report`
关键字段:
- `send_status`
- `notify_reason`
- `message_type`
- `mentioned_users[]`
- `web_detail_url`
- `receipt.message_id`
- `receipt.sent_at`
同时复用风险页中的:
- `highest_risk_level`
- `pass_status`
- `owner_notifications[]`
## 6. 关键交互
- 点击“切换卡片样式”模拟不同消息模板。
- 点击“发送通知”切换为已发送状态。
- 点击责任人标签,查看角色映射详情。
- 点击 Web 详情按钮,跳转到 mock 审计详情链接。
- 点击“切换通知原因”,在 `task_completed / task_failed` 两类消息之间切换。
## 7. 与上下游页面的数据关系
上游:
- 风险预警页提供责任角色和风险摘要
- Word 回填导出页提供导出状态
下游:无,属于主线收尾页。
## 8. 演示话术重点
- 强调平台不止有 Web 界面,还有飞书协同入口。
- 强调系统可以在任务完成或异常后,直接把结果转换成责任人可执行的通知载荷。
- 强调通知里保留 Web 详情链接,便于追溯。
## 9. 与知识库 / 治理台的关联入口
本页应提供:
- `维护责任人映射`
- `维护飞书机器人配置`
- `维护消息模板`
- `查看发送日志`
这些入口进入治理台中的责任人映射和飞书通知配置 CRUD。

View File

@@ -0,0 +1,229 @@
# 知识库与治理台原型设计
## 1. 页面目标
作为整套原型的治理能力承载层统一展示法规知识、RAG 证据、字段标准、模板映射、责任人和飞书配置如何被维护,并明确支持完整 CRUD。
在最新版产品形态中,`知识库` 已升级为四个一级入口之一;同时仍允许从审核智能体、资料包和处理历史页通过治理入口快速打开对应对象详情。
## 2. 适用角色
- 规则维护人员
- 平台管理员
- 注册资料平台主管
- 演示讲解人
## 3. 治理台布局分区
建议采用“左侧治理对象导航 + 中部列表 + 右侧详情 / 编辑抽屉”的结构。
左侧对象导航固定包含:
1. 法规规则包
2. RAG 文档源
3. RAG 切片
4. 字段 Schema
5. Word 模板与字段映射
6. 责任人映射
7. 飞书通知配置
## 4. 法规规则包 CRUD
### 4.1 列表字段
- 规则包名称
- 适用流程
- 版本号
- 启用状态
- 最近更新时间
- 维护人
### 4.2 必备操作
- 新增规则包
- 编辑规则包
- 复制新版本
- 启用 / 停用
- 删除未生效草稿
- 查看章节要求详情
### 4.3 原型交互要求
- 点击规则包行,右侧打开章节树与要求项详情。
- 点击“新增规则包”,打开表单弹窗。
## 5. RAG 文档源 CRUD
### 5.1 列表字段
- 文档名称
- 文档类型
- 来源类别
- 版本号
- 入库状态
- 切片数量
- 最近同步时间
### 5.2 必备操作
- 上传新文档源
- 替换版本
- 编辑元数据
- 停用文档源
- 删除失效文档源
- 重新入库
### 5.3 原型交互要求
- 支持按“法规 / 模板 / 业务资料”过滤。
- 点击文档源查看切片预览和召回配置。
## 6. RAG 切片 CRUD
### 6.1 列表字段
- 切片 ID
- 所属文档
- 章节
- 片段摘要
- 切片长度
- 召回状态
- 最近更新时间
### 6.2 必备操作
- 新增手工切片
- 编辑切片摘要
- 合并切片
- 拆分切片
- 删除切片
- 重建向量
- 调整召回阈值
### 6.3 原型交互要求
- 点击切片行,右侧展示原文预览和命中场景。
- 支持“查看证据命中历史”的详情抽屉。
## 7. 字段 Schema CRUD
### 7.1 列表字段
- 字段编码
- 中文名
- 字段类型
- 是否可回填
- 是否强一致
- 启用状态
- 版本号
### 7.2 必备操作
- 新增字段
- 编辑字段
- 启停字段
- 删除草稿字段
- 复制 schema 版本
### 7.3 原型交互要求
- 点击字段查看来源优先级和适用页面。
- 支持切换“回填字段 / 强一致字段 / 全部字段”。
## 8. Word 模板与字段映射 CRUD
### 8.1 列表字段
- 模板名称
- 输出类型
- 版本号
- 占位符数量
- 启用状态
- 最近更新时间
### 8.2 必备操作
- 上传模板
- 编辑模板元数据
- 编辑占位符映射
- 启用 / 停用版本
- 删除未发布版本
- 预览模板
### 8.3 原型交互要求
- 点击模板打开占位符和字段映射详情。
- 支持查看“阻断条件影响哪些占位符”。
## 9. 责任人映射 CRUD
### 9.1 列表字段
- 映射类型
- 章节 / 风险类型
- 责任角色
- 责任人姓名
- 部门
- 飞书用户 ID
- 飞书 Open ID
- 飞书昵称
- 是否启用通知
- 启用状态
- 备注
### 9.2 必备操作
- 新增映射
- 编辑映射
- 启停映射
- 删除映射
- 批量导入映射
### 9.3 原型交互要求
- 点击某条映射可联动查看受影响风险项或飞书卡片预览。
## 10. 飞书通知配置 CRUD
### 10.1 列表字段
- 配置名称
- 群聊 / 机器人标识
- 消息模板
- 触发原因
- Web 链接模板
- 启用状态
- 最近测试状态
### 10.2 必备操作
- 新增配置
- 编辑配置
- 切换消息模板
- 启用 / 停用
- 删除草稿配置
- 发送测试消息
### 10.3 原型交互要求
- 支持查看 interactive card 模板预览。
- 支持查看最近一次回执记录。
- 固定展示两类触发原因:`task_completed``task_failed`
## 11. 与业务页面的入口关系
各主页面的治理入口分配如下:
- 资料包导入页RAG 文档源、章节点规则
- 法规完整性检查页法规规则包、RAG 切片
- 字段抽取页:字段 Schema、字段来源优先级
- 一致性核查页:强一致规则
- 风险预警页:风险规则、责任人映射
- Word 回填页:模板与字段映射
- 飞书通知视图:责任人映射、飞书通知配置
## 12. 演示话术重点
- 强调这套系统不是一次性写死,而是可以被维护和演进。
- 强调法规判断、RAG 证据、字段标准、模板映射和飞书配置都能被可视化治理。
- 强调 CRUD 的存在,说明平台具备从 Demo 走向实际业务平台的治理基础。

View File

@@ -0,0 +1,248 @@
# 注册审核平台整体原型设计
## 1. 产品定位与演示目标
本轮原型面向:
```text
试剂盒临床注册文件准备与审核智能体平台
```
原型不强调“通用 Agent 工具箱”,而是围绕 NMPA 体外诊断试剂注册申报资料场景,展示一条可讲解、可追溯、可协同的审核闭环。
本轮演示目标:
1. 让评委在 1 分钟内理解平台解决什么问题。
2. 让业务人员看到资料包导入、法规核查、字段抽取、一致性核查、风险预警、Word 回填、飞书协同的完整链路。
3. 让技术评委看到系统具备规则优先、RAG 证据解释、结构化输出和治理后台能力。
4. 为后续 Django 页面重构或前端实现提供可直接照做的页面规格。
## 2. 演示总动线
最新版原型采用“顶层 4 个 Tab + 对话内节点推进”的组织方式,推荐演示顺序如下:
1. 先进入 `审核智能体`,展示左侧会话历史、中间对话区、右侧上传区和任务卡片区。
2. 在对话页上传资料包,触发目录汇总、完整性检查、字段抽取、一致性核查和风险结论节点。
3. 点击对话节点,解释每一步的结构化结果如何在右侧卡片实时更新状态。
4. 切换到 `资料包`,展示资料包列表、按产品名称搜索、资料包与会话绑定关系,以及目录、页数、章节点结果。
5. 切换到 `知识库`说明法规资料、业务资料、RAG 切片、字段 Schema、模板映射、责任人映射和飞书配置如何治理。
6. 切换到 `处理历史`,说明如何按批次、产品和风险状态回看历史会话、资料规模和通知留痕。
7. 返回对话中的风险与导出节点,最后展示飞书完成通知或异常通知如何直接 `@` 处理人并携带 Web 详情链接。
法规依据不再以单独一级页面存在,而是作为知识库中的法规资料,由 RAG 在对话节点中返回依据说明。
## 3. 统一视觉风格
### 3.1 视觉关键词
- 监管科技
- 专业可信
- 高信息密度
- 可解释工作台
- 演示友好
### 3.2 色彩建议
- 主背景:以白色为主,辅以极浅灰蓝,保证整体简洁和专业。
- 主强调色:克制的深青蓝,用于导航、主按钮、激活态和重点数值。
- 风险色:铜橙、深红,用于缺失、冲突、高风险、阻断状态。
- 成功色:低饱和绿色,用于完成、可通过、已同步状态。
- 中性色:冷灰,用于说明文字、边框、标签和禁用态。
### 3.3 组件风格
- 使用“带状布局 + 信息舱 + 分析表格”的组合,不做普通后台卡片堆砌。
- 表格、目录树、证据侧栏、步骤时间线是核心表达方式。
- 高风险结论必须同时展示证据来源和责任角色,避免只有颜色没有解释。
## 4. 统一交互规范
### 4.1 全局导航
- 顶部固定展示:平台名称、顶层 Tab、右上角用户信息。
- 顶层 Tab 固定为:`审核智能体 / 资料包 / 知识库 / 处理历史`
- `审核智能体` 内部采用三栏布局,不再使用左侧 8 页面流程导航。
- 每个页面保留“打开治理台”或同等治理入口。
### 4.2 统一交互规则
- 对话是主执行入口,页面切换是不同信息视角,而不是不同执行系统。
- 所有结果区都支持“查看来源证据”。
- 所有关键对象都支持“打开详情抽屉”。
- 所有治理对象都遵循“列表 -> 详情 -> 新增 / 编辑 / 启停 / 删除”的统一 CRUD 结构。
- 风险阻断项必须在对话节点、Word 导出区和飞书通知视图里持续可见,保证前后呼应。
- 资料包必须可跳转到关联会话,会话标题默认采用解析后的 `product_name`
### 4.3 状态规范
统一状态口径:
- `待导入`
- `处理中`
- `已完成`
- `待复核`
- `已阻断`
- `已发送`
- `失败`
统一风险口径:
- `高`
- `中`
- `低`
- `待确认`
## 5. 统一 Mock 数据口径
本轮所有文档和 HTML 共用同一组演示数据,避免页面间口径冲突。
### 5.1 批次口径
- `batch_id`: `SUB-20260603-001`
- `workflow_type`: `registration`
- `product_name`: `新型冠状病毒 2019-nCoV 核酸检测试剂盒`
- `conversation_id`: `conv-001`
- `applicant_name`: `示例生物科技(上海)有限公司`
- `chapter_scope`: `CH1 ~ CH6`
### 5.2 主线风险口径
固定演示问题:
1. `CH1.11.4` 缺少一份必交声明类资料。
2. 一份沟通记录疑似错放到 `CH1.9` 目录。
3. 说明书与申请表中的产品名称存在文本不一致。
4. 储存条件字段存在待人工复核状态。
5. 风险等级为高,当前批次不允许正式导出,仅允许生成草稿。
6. 飞书通知在任务完成或出现异常后直接 `@注册资料负责人``@注册申报负责人`
### 5.3 共用对象定义
文档和 HTML 共用以下结构化对象名称:
- `registration_overview_report`
- `registration_completeness_report`
- `registration_field_extraction_report`
- `registration_consistency_report`
- `registration_risk_report`
- `registration_word_export_report`
- `feishu_notification_report`
治理台对象:
- `knowledge_rule_package`
- `rag_source_document`
- `rag_chunk_item`
- `field_schema_item`
- `template_mapping_item`
- `owner_mapping_item`
- `feishu_channel_config`
## 6. 页面跳转关系
主页面关系调整为:
```text
顶部导航
-> 审核智能体
-> 资料包
-> 知识库
-> 处理历史
审核智能体内部节点
-> 资料包导入
-> 法规完整性检查
-> 字段抽取与字段池
-> 一致性核查
-> 风险预警
-> Word 回填导出
-> 飞书通知
```
跨页关系约束:
- 资料包导入页产出 `registration_overview_report`
- 法规完整性检查页消费 `registration_overview_report`,产出 `registration_completeness_report`
- 字段抽取与字段池页消费导入结果和完整性结果,产出 `registration_field_extraction_report`
- 一致性核查页消费字段池,产出 `registration_consistency_report`
- 风险预警页消费前三类报告,产出 `registration_risk_report`
- Word 回填导出页消费字段池、一致性和风险报告,产出 `registration_word_export_report`
- 飞书通知视图消费风险报告和导出报告,产出 `feishu_notification_report`
## 7. HTML 演示站说明
### 7.1 交付方式
交付一个单文件 HTML
`docs/原型设计/registration-prototype-demo.html`
该文件仅展示 mock 内容,不接真实 Django 路由,不调用真实接口。
### 7.2 HTML 结构要求
- 一个全局 App Shell
- 四个顶层页面 section
- 一个以对话节点为核心的审核智能体 section
- 一个治理台抽屉层
- 一份统一 mock 数据对象
- 一组轻量 JavaScript 交互
### 7.3 必备交互
- 切换 `审核智能体 / 资料包 / 知识库 / 处理历史` 视图
- 切换会话历史
- 点击对话节点定位到对应执行阶段
- 展开 / 收起目录树
- 资料包按产品名称或批次号搜索
- 打开治理台抽屉
- 切换治理对象 CRUD 子视图
- 模拟 Word 导出状态切换
- 模拟飞书消息卡片预览
## 8. 文档拆分说明
本轮分页文档如下:
1. [1.1.资料包导入页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.1.资料包导入页原型设计.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)
6. [1.6.风险预警页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.6.风险预警页原型设计.md)
7. [1.7.Word回填导出页原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.7.Word回填导出页原型设计.md)
8. [1.8.飞书通知视图原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.8.飞书通知视图原型设计.md)
9. [1.9.知识库与治理台原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.9.知识库与治理台原型设计.md)
## 9. 实现边界
本轮原型只解决:
1. 演示表达
2. 页面结构
3. 模块关系
4. 数据口径
5. 治理台 CRUD 展示
本轮原型不直接承诺:
1. 后端真实接口联调
2. Django 模板替换
3. 真实 RAG 召回
4. 真实 Word 文件生成
5. 真实飞书 OpenAPI 调用
## 10. 结论
这套原型的核心讲法应统一为:
```text
以对话为核心的审核智能体
-> 资料包解析与会话绑定
-> 节点式审核推进
-> 知识库 / RAG 解释
-> 风险与导出决策
-> 飞书协同闭环
```
治理台负责回答“规则和知识如何维护”,审核智能体负责回答“这一批资料现在审核到了哪里、为什么这样判断、下一步谁来处理”。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
**试剂盒临床注册文件准备与审核智能体搭建**
**一、背景**
卡尤迪生物研发团队在推进NMPA国家药品监督管理局注册申报时需准备大量合规性文件包括产品技术要求、说明书、检测报告、临床评估资料等。
公司计划组建AI Agent新团队目标为"试剂盒NMPA注册文件准备与审核智能体",实现文件目录自动汇总、法规完整性检查、关键信息自动提取与填写、缺失文件预警、文档一致性核查,提升注册效率并降低合规风险。
**二、任务目标**
请你作为 AI Agent 工程师候选人,设计并实现(或详细描述)一个智能体,能够:
1. 自动汇总注册申报文件夹中的所有文件及页数
2. 对照 NMPA 法规要求核查文件完整性并预警缺失
3. 提取产品关键信息并自动填写至申报文件
4. 核查文档结构与信息一致性
5. 输出合规风险预警与处理建议
**三、具体要求如下**
**1. 自动汇总文件夹文件目录与页数。**
文件目录参考附件。
**2. 按照NMPA现行法规要求核查文件完整性。**
- 对照NMPA法规检查所需文件是否齐全如注册申报资料基本要求、产品技术要求、注册检验报告等
- 自动识别缺失文件并通知责任人
- 参考法规来源网站:
<https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html、>
<https://www.nmpa.gov.cn/>
**3. 从产品文件中提取关键信息并自动填写至目标文件。**
- 自动提取:产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息
- 将提取信息自动填入注册申报表格或对照清单
**4. 核查文档结构、信息一致性与章节规范性。**
- 检测章节是否完整(如分析灵敏度、特异性、重复性等必检项目)
- 不同文档间同一信息是否一致(如产品名称、规格型号等)
- 格式是否符合NMPA要求的规范章节结构
**5. 提供合规风险预警与处理建议。**
例如:"文件X缺少临床评估报告请补充"或"产品Y说明书与检测报告中的适用范围描述不一致请核对"
**附加要求【在复试时陈述,需结合 Demo 演示】**
**1. 架构搭建思路(基于 Demo 版)**
- 展示Demo运行结果文件目录汇总表、法规完整性报告、信息提取对照表、异常预警列表
- 结合你实现的Demo说明智能体的整体工作流文件扫描 → 目录汇总 → 法规匹配 → 信息提取 → 一致性核查 → 风险预警)
- 展示Demo中实际调用的关键工具/库(如 pdfplumber / PyMuPDF、正则表达式、规则引擎、向量检索等并分析选用理由
- 简述Demo中如何体现文件完整性检测、信息一致性核查、法规条款匹配等难点规则的处理
**2. 基于 Demo 版的迭代规划**
- 说明当前Demo实现了哪些核心功能哪些是模拟数据/简化逻辑
- 下一版本最想增加的一个功能以及需要投入的技术资源(如 NMPA 官网 API 对接、文件版本管理、多语言支持等),并说明为什么优先做它

Binary file not shown.

View File

@@ -1,71 +0,0 @@
# 模块详细设计文档索引
## 1. 设计文档说明
本目录存放 Universal Agent Demo Framework V1 的设计文档。需求文档回答“要做什么”,设计文档回答“怎么实现、边界在哪里、如何验证”。
文档命名统一使用中文编号,便于复试讲解和按顺序阅读。
## 2. 模块设计文档列表
| 顺序 | 文档 | 说明 |
|---|---|---|
| 0 | `0.设计文档索引.md` | 当前索引 |
| 1 | `1.智能体总体设计.md` | 智能核心总体链路、配置、输出和 Adapter |
| 2 | `2.功能流程设计.md` | 复试准备、演示、上传、入库、对话和审计流程 |
| 3 | `3.数据库设计.md` | Django 数据模型、字段、索引和初始化策略 |
| 4 | `4.页面与路由设计.md` | 页面结构、URL、跳转和异常状态 |
| 5 | `5.部署设计.md` | 本地、Docker、环境变量和持久化 |
模块详细设计位于 `模块设计/`
| 模块 | 文档 |
|---|---|
| 配置 | `模块设计/1.配置模块详细设计.md` |
| 场景 | `模块设计/2.场景模块详细设计.md` |
| 文档 | `模块设计/3.文档模块详细设计.md` |
| 对话 | `模块设计/4.对话模块详细设计.md` |
| 审计 | `模块设计/5.审计模块详细设计.md` |
| 智能核心 | `模块设计/6.智能核心模块详细设计.md` |
## 3. 模块依赖关系
```text
config
|-- apps.scenarios
|-- apps.documents
|-- apps.chat
|-- apps.audit
apps.scenarios
|-- reads configs/*.yaml
apps.documents
|-- depends on apps.scenarios
|-- calls agent_core.rag.ingest
apps.chat
|-- depends on apps.scenarios
|-- calls agent_core.orchestrator
|-- calls apps.audit.services
apps.audit
|-- stores AgentResult snapshots
agent_core
|-- consumes scenario config
|-- uses RAG, tools, LLM provider and structured output parser
```
## 4. 推荐阅读顺序
1. `docs/需求分析/1.V1总需求文档.md`
2. `docs/需求分析/2.模块需求索引.md`
3. `docs/设计文档/1.智能体总体设计.md`
4. `docs/设计文档/2.功能流程设计.md`
5. `docs/设计文档/3.数据库设计.md`
6. `docs/设计文档/4.页面与路由设计.md`
7. `docs/设计文档/5.部署设计.md`
8. `docs/设计文档/模块设计/*.md`
后续编码时,每个模块应先对照对应需求文档和详细设计,再实现模型、服务、视图和测试。

View File

@@ -1,211 +0,0 @@
# 智能体总体设计文档
## 1. 设计目标
Agent 设计的核心目标是支持未知复试题的快速适配。
系统不针对单一业务写死,而是通过场景配置、知识库、工具和输出模板组合出不同业务 Agent。
```text
业务 Agent = 场景配置 + 知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
```
## 2. Agent 类型
V1 预置 5 类 Agent 场景:
| Agent ID | 名称 | 适用场景 |
|---|---|---|
| `knowledge_qa` | 知识库问答助手 | SOP、制度、客服知识库 |
| `document_review` | 文档审核助手 | 合同、制度、SOP、材料审核 |
| `ticket_assistant` | 工单处理助手 | 客服、售后、运维工单 |
| `quality_analysis` | 质量异常分析助手 | 生产、质检、缺陷分析 |
| `risk_audit` | 风险审核助手 | 财务、采购、报销、合同风险 |
## 3. Agent 执行链路
```text
用户输入
加载场景配置
判断是否启用 RAG
检索知识库片段
加载可用工具
构造 Prompt
调用大模型
解析工具调用和结构化输出
生成 AgentResult
写入审计日志
页面展示
```
## 4. 场景配置结构
场景配置使用 YAMLV1 以配置文件作为场景唯一事实来源,后台管理不作为场景配置入口。
```yaml
id: quality_analysis
name: 质量异常分析助手
description: 用于分析生产质量异常、检索 SOP、生成处理建议
agent:
role: 质量管理专家
goal: 根据用户问题、知识库和工具结果,输出可执行的质量分析报告
system_prompt: ""
instructions:
- 回答必须基于知识库或工具结果
- 不确定时必须说明缺失信息
- 涉及质量风险时给出风险等级
rag:
enabled: true
collection: quality_docs
top_k: 5
tools:
- query_demo_records
- calculate_rate
output:
type: quality_report
audit:
enabled: true
log_retrieval: true
log_tool_calls: true
```
## 5. Prompt 组成
Prompt 建议由以下部分组成:
```text
系统角色
任务目标
行为约束
输出格式要求
知识库检索内容
工具调用结果
用户问题
```
V1 不追求复杂 Prompt 框架,优先保证可读、可改、可解释。
## 6. RAG 策略
RAG 在 V1 中负责给 Agent 提供题目材料和业务知识。
入库流程:
```text
上传文件
抽取文本
文本切分
生成 embedding
写入 Chroma
```
检索流程:
```text
用户问题
按 scenario_id 和可选 document_ids 过滤
向量检索 top_k
返回片段内容、来源和分数
```
## 7. 工具调用策略
工具用于补足大模型不能直接可靠完成的业务动作。
V1 内置工具:
| 工具 | 用途 |
|---|---|
| `calculate_rate` | 计算比例、缺陷率、通过率 |
| `query_demo_records` | 查询模拟业务数据 |
| `check_required_fields` | 检查必填项 |
| `generate_action_items` | 生成行动项 |
工具返回格式:
```json
{
"tool_name": "calculate_rate",
"success": true,
"arguments": {},
"result": {},
"error": ""
}
```
## 8. 结构化输出
V1 支持以下输出类型:
- `general_answer`
- `document_review_report`
- `ticket_response`
- `quality_report`
- `risk_audit_report`
结构化输出优先使用 JSON。
解析失败时:
- 保留模型原始输出。
- 返回解析错误。
- 页面展示原始回答。
- 审计日志记录失败原因。
## 9. AgentResult
Agent Core 统一返回:
```json
{
"answer": "",
"structured_output": {},
"references": [],
"tool_calls": [],
"raw_output": "",
"model_name": "",
"latency_ms": 0,
"status": "success",
"error": ""
}
```
## 10. Adapter 策略
V1 默认使用自研轻量 Orchestrator通过 OpenAI 兼容接口接入 LLM 与 Embedding可自主选择 OpenAI、硅基流动等兼容服务。
后续可以扩展:
- OpenAI Agents SDK Adapter。
- Dify API Adapter。
- LangGraph Adapter。
所有 Adapter 应保持统一接口:
```text
run_agent(scenario_config, user_input, options=None) -> AgentResult
```
这样可以保证 Django 业务层不受底层 Agent 编排实现影响。

View File

@@ -1,169 +0,0 @@
# V1 功能设计文档
## 1. 功能设计目标
V1 的功能设计目标是让复试展示者在本地快速完成一个可讲解、可演示、可改题的 Agent Demo。系统不追求复杂平台能力而是优先保证以下闭环稳定
- 场景配置可选择。
- 文档可上传并入库。
- 用户可在场景下发起对话。
- Agent 可返回结构化结果、引用来源和工具调用记录。
- 每次成功或失败的对话都有审计记录。
- 本地和 Docker 均可启动。
## 2. 用户角色
V1 仅设计一个用户角色Demo 操作者。
该角色负责启动系统、选择场景、上传材料、触发入库、发起对话、查看输出和审计日志。系统不在 V1 中区分管理员、审核员、普通用户等权限角色。
## 3. 核心业务流程
```text
启动系统
查看 5 个预置场景
选择场景
上传题目材料
触发知识库入库
发起 Agent 对话
查看结构化输出、引用和工具调用
查看审计日志
```
任一环节失败时,页面应给出明确提示,并尽量保留用户已完成的上下文。
## 4. 场景选择流程
1. 首页调用 `apps.scenarios.services.list_scenarios()`
2. 服务从 `configs/` 读取 YAML 场景配置。
3. 校验必填字段、工具名称和输出类型。
4. 页面展示场景名称、描述、适用题型、启用状态。
5. 用户点击进入 `/chat/<scenario_id>/`
异常处理:
- 配置目录不存在:展示空状态和配置目录提示。
- 单个配置非法:不阻断其他配置,页面展示该配置错误。
- 场景不存在:跳转或渲染错误页,提示检查场景 ID。
## 5. 文件上传流程
1. 用户进入 `/documents/upload/`
2. 页面加载可用场景下拉框。
3. 用户选择场景并上传 `.txt``.md``.pdf``.docx` 文件。
4. Documents 模块校验文件类型和大小。
5. 保存文件到 `UPLOAD_ROOT/<scenario_id>/`
6. 写入 `UploadedDocument` 记录,状态为 `uploaded`
7. 返回文件列表页并展示上传结果。
V1 文件上传默认手动入库,避免上传大文件时页面阻塞过久。
## 6. 文档入库流程
1. 用户在文件列表点击“入库”。
2. Documents 模块读取文件并抽取文本。
3. 调用 `agent_core.rag.ingest.ingest_document()`
4. Agent Core 按固定长度切分文本。
5. 写入本地 Chroma collection。
6. 入库成功:更新状态为 `indexed`
7. 入库失败:更新状态为 `failed`,保存错误信息。
文本为空、文件丢失、向量库不可写都应进入失败状态,不能让页面报 500。
## 7. Agent 对话流程
```text
用户提交问题
Chat 表单校验
Scenarios 加载场景配置
Agent Core 执行 run_agent()
RAG 按场景和可选文档范围检索知识片段
工具系统执行可用工具
LLM Provider 生成结果
结构化输出解析
Audit 写入日志
Chat 页面展示结果
```
Chat 模块只负责请求处理和页面展示,不直接写 RAG、工具和模型调用细节。
## 8. RAG 检索流程
1. Orchestrator 读取场景配置中的 `rag.enabled``collection``top_k`
2. 若启用 RAG则调用 `agent_core.rag.retriever.retrieve()`
3. 检索必须按 `scenario_id` 过滤,避免跨场景污染。
4. 如果用户在对话页选择了文档,则同时按 `document_ids` 过滤;未选择时使用当前场景全部已入库文档。
5. 返回片段内容、来源文件、chunk ID、分数。
6. 片段进入 Prompt同时随 AgentResult 返回给页面和审计日志。
检索失败时AgentResult 应记录错误或警告;若业务允许,可继续使用非 RAG 上下文回答。
## 9. 工具调用流程
1. 场景配置声明可用工具名称。
2. Orchestrator 从 Tool Registry 查询工具。
3. 对不可用工具记录失败,不中断整个流程。
4. 内置工具按统一参数和返回结构执行。
5. 工具结果进入 Prompt 或结构化输出上下文。
6. 所有工具调用写入 AgentResult 和审计日志。
V1 先采用“配置声明 + Orchestrator 决策”的轻量策略,不实现复杂多轮工具调用协议。
## 10. 审计日志流程
1. Chat 模块在 Agent Core 返回后调用 `apps.audit.services.create_audit_log()`
2. 成功结果记录输入、输出、引用、工具调用、模型名和耗时。
3. 失败结果也记录场景、输入、错误信息和已产生的中间结果。
4. 日志中不得保存 `LLM_API_KEY`、环境变量完整内容或上传文件绝对敏感路径。
5. 审计列表展示摘要,详情页展示完整 JSON 片段。
## 11. 复试改题流程
1. 判断题目最接近的模板。
2. 复制 `configs/` 中相近 YAML。
3. 修改场景名称、角色、目标、指令和输出类型。
4. 上传题目文档并入库。
5. 如题目需要计算或查询,新增一个内置工具并在场景中声明。
6. 用 2 到 3 个问题验证输出和审计链路。
7. 演示时重点展示配置、知识库、工具调用、结构化结果和审计日志。
## 12. 异常处理流程
| 异常 | 处理方式 |
|---|---|
| 场景配置缺失 | 页面展示错误,保留返回首页入口 |
| 场景字段非法 | 标记非法配置,不影响其他场景 |
| 上传文件类型不支持 | 表单错误提示 |
| 文件读取失败 | 文档状态改为 `failed` |
| RAG 入库失败 | 记录错误信息并允许重试 |
| LLM 配置缺失 | AgentResult 返回失败,审计日志记录失败 |
| 工具调用失败 | 记录工具失败,流程尽量继续 |
| 结构化解析失败 | 展示原始输出并记录解析错误 |
## 13. V1 功能验收标准
- 首页可以展示 5 个预置场景。
- 场景配置来自 YAML 文件。
- 可以上传 `.txt``.md``.pdf``.docx` 文件。
- 文件可触发入库,并显示 `uploaded``indexed``failed` 状态。
- 可以进入任一场景对话页并提交问题。
- AgentResult 至少包含回答、结构化输出、引用、工具调用、耗时和状态。
- 成功和失败对话都能生成审计日志。
- 审计详情可以解释一次 Agent 输出的输入、依据和过程。
- 本地启动和 Docker 启动路径清晰可执行。

View File

@@ -1,144 +0,0 @@
# V1 数据库设计文档
## 1. 数据库设计目标
V1 数据库设计优先服务本地演示、讲解清晰和快速改题。数据模型只覆盖文件、对话、审计和简单示例业务数据,不引入复杂权限、多租户或工作流状态机。
## 2. 数据库选型
默认使用 SQLite数据库文件位于 `data/db.sqlite3`。SQLite 适合复试现场单机运行,便于 Docker 挂载和备份。
后续如需多人协作或更正式部署,可通过 Django settings 切换到 PostgreSQL但 V1 不强制实现。
## 3. 表结构总览
| 表 | Django Model | 模块 | 说明 |
|---|---|---|---|
| uploaded_document | `UploadedDocument` | Documents | 上传文件元数据和入库状态 |
| agent_audit_log | `AgentAuditLog` | Audit | Agent 执行审计快照 |
| demo_business_record | `DemoBusinessRecord` | Agent Core / Tools | 内置工具可查询的模拟业务数据 |
| chat_session | `ChatSession` | Chat | 可选,对话会话 |
| chat_message | `ChatMessage` | Chat | 可选,对话消息 |
## 4. UploadedDocument 表设计
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BigAutoField | PK | 主键 |
| scenario_id | CharField(100) | indexed | 关联场景 ID |
| original_name | CharField(255) | required | 原始文件名 |
| file | FileField | required | 文件相对路径 |
| file_type | CharField(20) | required | `txt``md``pdf``docx` 等 |
| size | PositiveIntegerField | default 0 | 字节数 |
| status | CharField(20) | indexed | `uploaded``indexed``failed` |
| error_message | TextField | blank | 入库失败原因 |
| created_at | DateTimeField | auto_now_add | 上传时间 |
| updated_at | DateTimeField | auto_now | 更新时间 |
状态流转:
```text
uploaded -> indexed
uploaded -> failed
failed -> indexed
failed -> failed
```
重新入库时应按文档维度覆盖或清理旧 chunk避免同一文件重复出现在向量检索结果中。文档选择范围由 Chat 表单本次提交的 `document_ids` 传入 Agent CoreV1 不需要为该选择单独建表。
## 5. AgentAuditLog 表设计
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BigAutoField | PK | 主键 |
| scenario_id | CharField(100) | indexed | 场景 ID |
| scenario_name | CharField(200) | blank | 场景名称快照 |
| user_input | TextField | required | 用户输入 |
| retrieved_chunks | JSONField | default list | RAG 引用片段 |
| tool_calls | JSONField | default list | 工具调用记录 |
| structured_output | JSONField | default dict | 结构化输出 |
| final_answer | TextField | blank | 最终回答 |
| raw_output | TextField | blank | 模型原始输出 |
| model_name | CharField(100) | blank | 模型名称 |
| latency_ms | PositiveIntegerField | default 0 | 执行耗时 |
| status | CharField(20) | indexed | `success``failed` |
| error_message | TextField | blank | 错误信息 |
| created_at | DateTimeField | auto_now_add, indexed | 创建时间 |
审计日志保存的是执行快照,不依赖场景配置后续是否被修改。
## 6. DemoBusinessRecord 表设计
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BigAutoField | PK | 主键 |
| scenario_id | CharField(100) | indexed | 适用场景 |
| record_type | CharField(100) | indexed | 记录类型,如 defect、ticket、invoice |
| title | CharField(255) | required | 标题 |
| payload | JSONField | default dict | 模拟业务数据 |
| created_at | DateTimeField | auto_now_add | 创建时间 |
该表为 V1 必需表,用于 `query_demo_records` 工具避免工具只能返回硬编码数据。Django Admin 可以管理该表的数据,场景 YAML 仍不在 Admin 中编辑。
## 7. ChatSession 表设计
V1 可先不实现会话持久化。如果实现,字段建议如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BigAutoField | 主键 |
| scenario_id | CharField(100) | 场景 ID |
| title | CharField(255) | 会话标题 |
| created_at | DateTimeField | 创建时间 |
| updated_at | DateTimeField | 更新时间 |
## 8. ChatMessage 表设计
V1 可通过审计日志满足演示追踪,不强制实现消息表。如果实现,字段建议如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BigAutoField | 主键 |
| session | ForeignKey(ChatSession) | 所属会话 |
| role | CharField(20) | `user``assistant``system` |
| content | TextField | 消息内容 |
| audit_log | ForeignKey(AgentAuditLog, null=True) | 关联审计 |
| created_at | DateTimeField | 创建时间 |
## 9. 表关系设计
```text
Scenario YAML
|-- scenario_id
|-- UploadedDocument.scenario_id
|-- AgentAuditLog.scenario_id
|-- DemoBusinessRecord.scenario_id
|-- ChatSession.scenario_id
ChatSession 1 -- N ChatMessage
ChatMessage 0/1 -- 1 AgentAuditLog
```
场景配置 V1 存在 YAML 中,不建 `Scenario` 数据表。这样更方便复试现场复制和修改配置文件。
## 10. 索引设计
- `UploadedDocument(scenario_id, status)`:用于按场景查看文件和入库状态。
- `AgentAuditLog(scenario_id, created_at)`:用于按场景查看最近日志。
- `AgentAuditLog(status, created_at)`:用于排查失败日志。
- `DemoBusinessRecord(scenario_id, record_type)`:用于工具查询模拟数据。
## 11. 数据初始化策略
- 场景初始化:读取 `configs/*.yaml`,不写数据库。
- 示例业务数据:可提供 Django management command 初始化 `DemoBusinessRecord`
- 超级用户本地演示可手动创建Docker 可通过说明引导创建。
- 上传文件和 Chroma 数据:存放在 `data/` 下,通过 Docker volume 持久化。
## 12. 后续扩展方向
- 增加 `Scenario` 表,实现后台编辑场景。
- 增加 `ToolCallLog` 独立表,用于复杂工具审计。
- 使用 PostgreSQL JSONB 优化 JSON 查询。
- 增加用户和权限模型。
- 增加文档 chunk 元数据表,便于从数据库追踪向量库内容。

View File

@@ -1,179 +0,0 @@
# V1 页面与路由设计文档
## 1. 页面设计目标
V1 页面使用 Django Templates优先保证清晰、稳定、可讲解。页面应围绕复试演示的主路径组织选择场景、上传文档、入库、对话、查看审计。
## 2. 页面列表
| 页面 | 路径 | 模块 | 说明 |
|---|---|---|---|
| 首页/场景列表 | `/` | Scenarios | 展示 5 个预置场景 |
| Agent 对话页 | `/chat/<scenario_id>/` | Chat | 提交问题并展示结果 |
| 文件列表页 | `/documents/` | Documents | 查看上传文件和入库状态 |
| 文件上传页 | `/documents/upload/` | Documents | 上传题目材料 |
| 文档入库动作 | `/documents/<id>/index/` | Documents | POST 触发入库 |
| 审计日志列表 | `/audit/` | Audit | 查看对话记录 |
| 审计日志详情 | `/audit/<log_id>/` | Audit | 查看单次执行详情 |
| Django Admin | `/admin/` | Config | 后台管理 |
## 3. 路由总览
```text
config.urls
|-- "" -> apps.scenarios.urls
|-- "chat/" -> apps.chat.urls
|-- "documents/" -> apps.documents.urls
|-- "audit/" -> apps.audit.urls
|-- "admin/" -> django.contrib.admin
```
各模块只暴露自己的 URL避免把业务路由集中写在 `config.urls` 中。
## 4. 首页与场景列表页
路径:`/`
展示内容:
- 系统名称和简短定位。
- 5 个场景卡片或列表。
- 场景名称、描述、适用题型、启用状态。
- “进入对话”按钮。
- 文件管理和审计日志入口。
错误状态:
- 没有可用场景:展示配置目录提示。
- 配置读取失败:展示失败原因和文件名。
## 5. Agent 对话页
路径:`/chat/<scenario_id>/`
页面区域:
- 场景摘要名称、角色、目标、RAG 状态、工具列表。
- 文档范围:当前场景下状态为 `indexed` 的文档多选框;未选择时默认使用全部已入库文档。
- 输入区:一个 textarea 和提交按钮。
- 结果区:自然语言回答和结构化输出。
- 引用区source、chunk_id、score、content。
- 工具区tool_name、success、arguments、result、error。
- 审计入口:当前对话生成日志后展示详情链接。
POST 成功后仍渲染同一页面,保留用户问题和 AgentResult。
## 6. 文件上传页
路径:`/documents/upload/`
页面元素:
- 场景选择下拉框。
- 文件选择控件。
- 支持类型提示。
- 上传按钮。
- 错误或成功提示。
表单接受 `.txt``.md``.pdf``.docx`。PDF 仅要求纯文本抽取DOCX 仅要求段落和普通文本抽取。
## 7. 文件列表页
路径:`/documents/`
展示字段:
- 原始文件名。
- 所属场景。
- 文件类型。
- 文件大小。
- 入库状态。
- 上传时间。
- 入库按钮。
- 失败原因。
状态为 `indexed` 时可以显示“重新入库”,重新入库需要覆盖或清理该文档旧 chunk。
## 8. 审计日志列表页
路径:`/audit/`
展示字段:
- 日志 ID。
- 场景名称。
- 用户输入摘要。
- 状态。
- 模型名称。
- 执行耗时。
- 创建时间。
- 详情入口。
默认按 `created_at desc` 排序。
## 9. 审计日志详情页
路径:`/audit/<log_id>/`
展示内容:
- 场景信息。
- 用户输入。
- 最终回答。
- 结构化输出 JSON。
- RAG 引用列表。
- 工具调用列表。
- 模型名称和耗时。
- 错误信息。
JSON 内容可以先用 `<pre>` 展示,优先保证可读。
## 10. Django Admin 页面
Admin 注册:
- `UploadedDocument`
- `AgentAuditLog`
- `DemoBusinessRecord`
V1 不要求在 Admin 中编辑 YAML 场景,场景仍以配置文件为准。
## 11. 页面跳转关系
```text
首页
|-- 进入对话页
|-- 文件列表页
|-- 审计日志列表页
文件列表页
|-- 文件上传页
|-- 触发入库后回到文件列表页
对话页
|-- 提交后留在当前对话页
|-- 查看当前审计详情
审计列表页
|-- 审计详情页
```
## 12. 页面异常状态
| 页面 | 异常 | 展示方式 |
|---|---|---|
| 首页 | 场景配置为空 | 空状态和配置目录说明 |
| 对话页 | 场景不存在 | 明确提示并提供返回首页 |
| 对话页 | Agent 执行失败 | 展示错误、保留输入、写入失败审计 |
| 上传页 | 文件类型错误 | 表单错误 |
| 文件列表 | 入库失败 | 状态为 failed 并显示原因 |
| 审计详情 | 日志不存在 | 404 或友好错误页 |
## 13. V1 页面验收标准
- 主要页面可通过浏览器访问。
- 页面之间跳转路径完整。
- POST 表单使用 CSRF 保护。
- 所有用户可见错误都有中文提示。
- Agent 对话结果可以同时看到回答、引用、工具和审计入口。
- 页面不依赖 React/Vue。

View File

@@ -1,111 +0,0 @@
# V1 部署设计文档
## 1. 部署设计目标
V1 部署目标是降低复试现场环境风险。系统应支持本地 Python 方式启动,也支持 Docker Compose 一键启动。默认不依赖外部数据库、Redis 或任务队列。
## 2. 本地运行方式
建议命令:
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
本地运行使用 SQLite、`data/uploads``data/chroma`
当前本地方式会在启动时自动读取根目录 `.env`,因此 `runserver``pytest` 和日常脚本可以共享同一套配置。
## 3. Docker 运行方式
建议命令:
```bash
docker compose up --build
```
V1 Docker Compose 只需要一个 Django Web 服务。Chroma 使用本地持久化目录,不额外启动独立服务。
## 4. 环境变量设计
| 变量 | 默认值 | 说明 |
|---|---|---|
| `DJANGO_SECRET_KEY` | `dev-secret-key` | 开发密钥 |
| `DJANGO_DEBUG` | `true` | 是否开启调试 |
| `DJANGO_ALLOWED_HOSTS` | `*` | 允许主机 |
| `LLM_API_KEY` | 空 | 大模型 API Key |
| `LLM_BASE_URL` | `https://api.openai.com/v1` | OpenAI 兼容接口地址,可接入 OpenAI、硅基流动等兼容服务 |
| `LLM_MODEL` | `gpt-4.1-mini` | 默认模型 |
| `EMBEDDING_API_KEY` | 空 | Embedding API Key为空时可复用 `LLM_API_KEY` |
| `EMBEDDING_BASE_URL` | 空 | Embedding OpenAI 兼容接口地址;为空时可复用 `LLM_BASE_URL` |
| `EMBEDDING_MODEL` | `text-embedding-3-small` | 默认 Embedding 模型 |
| `SCENARIO_CONFIG_DIR` | `configs` | 场景配置目录 |
| `UPLOAD_ROOT` | `data/uploads` | 上传目录 |
| `CHROMA_PATH` | `data/chroma` | 向量库目录 |
`.env.example` 应提供这些变量的样例,不写真实密钥。
当前实现说明:
- 本地 Python 方式启动时,会先加载根目录 `.env`,再读取进程环境中的覆盖值。
- Docker Compose 方式可通过 `env_file` 向容器注入环境变量;当前仓库默认读取 `.env`
- 因此本地运行和容器运行可以默认共用一份 `.env`,但演示前仍应确认密钥和模型参数是否正确。
## 5. 目录挂载设计
Docker 需要持久化以下目录:
```text
./data/db.sqlite3
./data/uploads
./data/chroma
./configs
```
`configs` 挂载后可以在不重建镜像的情况下修改场景配置。
## 6. SQLite 数据持久化
SQLite 文件放在 `data/db.sqlite3`。Docker 中应将 `data/` 作为 volume 挂载,避免容器重建后数据丢失。
## 7. Chroma 数据持久化
Chroma 数据放在 `data/chroma`。RAG 入库后,重启容器不应丢失向量数据。
## 8. 上传文件持久化
上传文件放在 `data/uploads/<scenario_id>/`。数据库只保存相对路径或 Django FileField 路径。
## 9. 启动命令设计
Docker 容器启动时建议执行:
```bash
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
V1 可以先用开发服务器满足演示。后续正式部署可切换到 Gunicorn。
## 10. 常见部署问题
| 问题 | 处理 |
|---|---|
| 端口 8000 被占用 | 修改 compose 端口映射 |
| API Key 缺失 | 页面提示 LLM 或 Embedding 配置缺失 |
| Chroma 目录无权限 | 检查 `data/chroma` 挂载权限 |
| 上传目录不存在 | settings 或启动脚本创建目录 |
| 场景配置读取失败 | 检查 `configs/*.yaml` 格式 |
| Docker 构建慢 | 提前构建镜像或使用本地 Python 方式演示 |
## 11. 后续部署扩展
- 使用 Gunicorn + WhiteNoise。
- 增加 PostgreSQL 服务。
- 增加 Redis 和 Celery 做异步入库。
- 增加 Nginx 反向代理。
- 增加健康检查接口。

View File

@@ -1,112 +0,0 @@
# 配置模块详细设计
## 1. 模块目标
Config 模块负责 Django 项目的启动配置和总装配。它不承载业务逻辑,只为其他模块提供稳定运行环境。
目标:
- 项目本地和 Docker 均可启动。
- 环境变量可覆盖关键配置。
- App、模板、静态资源、上传文件和数据库路径统一配置。
- URL 总入口清晰,模块路由各自维护。
## 2. 职责边界
负责:
- `settings.py``urls.py``wsgi.py``asgi.py`
- 环境变量读取和默认值。
- SQLite、静态文件、媒体文件、Chroma、场景配置目录。
- Django Admin 和模块 URL 装配。
不负责:
- 不读取场景 YAML 业务内容。
- 不调用 Agent Core。
- 不处理上传文件文本抽取。
- 不写审计日志。
## 3. 配置项设计
| 配置 | Django setting | 默认值 |
|---|---|---|
| `DJANGO_SECRET_KEY` | `SECRET_KEY` | `dev-secret-key` |
| `DJANGO_DEBUG` | `DEBUG` | `true` |
| `DJANGO_ALLOWED_HOSTS` | `ALLOWED_HOSTS` | `["*"]` |
| `UPLOAD_ROOT` | `MEDIA_ROOT` | `BASE_DIR / "data" / "uploads"` |
| `SCENARIO_CONFIG_DIR` | `SCENARIO_CONFIG_DIR` | `BASE_DIR / "configs"` |
| `CHROMA_PATH` | `CHROMA_PATH` | `BASE_DIR / "data" / "chroma"` |
| `LLM_API_KEY` | `LLM_API_KEY` | 空 |
| `LLM_BASE_URL` | `LLM_BASE_URL` | `https://api.openai.com/v1` |
| `LLM_MODEL` | `LLM_MODEL` | `gpt-4.1-mini` |
| `EMBEDDING_API_KEY` | `EMBEDDING_API_KEY` | 空,默认可复用 `LLM_API_KEY` |
| `EMBEDDING_BASE_URL` | `EMBEDDING_BASE_URL` | 空,默认可复用 `LLM_BASE_URL` |
| `EMBEDDING_MODEL` | `EMBEDDING_MODEL` | `text-embedding-3-small` |
## 4. 目录路径设计
启动前或初始化时应确保:
```text
data/
uploads/
chroma/
configs/
static/
templates/
```
V1 可以在 `settings.py` 中定义路径,在 management command 或启动脚本中创建目录。生产代码不应在每次请求中反复创建目录。
## 5. URL 总路由设计
`config.urls`
```python
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("apps.scenarios.urls")),
path("chat/", include("apps.chat.urls")),
path("documents/", include("apps.documents.urls")),
path("audit/", include("apps.audit.urls")),
]
```
开发模式下追加 `static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)`,用于访问上传文件。
## 6. 静态资源与上传文件设计
- `STATIC_URL = "static/"`
- `STATICFILES_DIRS = [BASE_DIR / "static"]`
- `MEDIA_URL = "media/"`
- `MEDIA_ROOT = UPLOAD_ROOT`
上传文件路径由 Documents 模块按场景组织Config 只提供根目录。
## 7. 环境变量读取设计
V1 可使用标准库 `os.environ.get()`,不强制引入复杂配置库。
布尔值规则:
```text
"1", "true", "yes", "on" -> True
其他 -> False
```
`DJANGO_ALLOWED_HOSTS` 使用逗号分隔,空值时默认 `["*"]`
当前实现约束:
- 本地直接运行 Django 命令时,会先尝试解析根目录 `.env` 文件,再读取进程环境中的覆盖值。
- Docker Compose 方式可以通过 `env_file` 传入同一批变量;当前仓库默认读取 `.env`
- `.env.example` 只保留占位符示例,不保存真实 API Key。
## 8. 验收标准
- `python manage.py check` 通过。
- `python manage.py migrate` 可执行。
- `/``/admin/` 路由可访问。
- `MEDIA_ROOT``CHROMA_PATH``SCENARIO_CONFIG_DIR` 在 settings 中可被其他模块引用。
- LLM 与 Embedding 配置只从 settings 或环境变量读取,不散落在业务代码中。

View File

@@ -1,134 +0,0 @@
# 场景模块详细设计
## 1. 模块目标
Scenarios 模块是业务 Agent 的入口,负责读取和展示场景配置,并向 Chat、Documents、Agent Core 提供场景上下文。
## 2. 职责边界
负责:
-`configs/*.yaml` 读取场景。
- 校验场景必填字段。
- 展示场景列表和场景摘要。
- 提供 `list_scenarios()``get_scenario()` 等服务。
不负责:
- 不执行 Agent。
- 不做 RAG 检索。
- 不调用工具和大模型。
- 不保存审计日志。
## 3. 场景配置结构
必填结构:
```yaml
id: knowledge_qa
name: 知识库问答助手
description: 用于 SOP、制度和内部知识库问答
applicable_questions:
- SOP 问答
- 制度问答
agent:
role: 知识库问答专家
goal: 基于知识库回答用户问题
system_prompt: ""
instructions:
- 回答必须基于检索内容
rag:
enabled: true
collection: knowledge_qa
top_k: 5
tools:
- generate_action_items
output:
type: general_answer
audit:
enabled: true
```
`agent.system_prompt` 为可选字段。配置了非空值时Agent Core 优先使用该字段作为系统提示词;为空或缺失时,由 `role``goal``instructions` 组合生成系统提示词。
`applicable_questions` 作为页面展示字段,若缺失可显示为空列表。
## 4. 场景加载流程
1. 读取 `settings.SCENARIO_CONFIG_DIR`
2. 遍历 `.yaml``.yml` 文件。
3. 使用 YAML parser 转为 dict。
4. 调用 `validate_scenario()`
5. 转换为 `ScenarioConfig` dataclass 或普通 dict。
6. 按文件名或配置顺序返回。
为了便于复试修改V1 不需要强缓存;若加缓存,应提供清理方式或在 DEBUG 下禁用缓存。
## 5. 场景校验规则
必填字段:
- `id`
- `name`
- `description`
- `agent.role`
- `agent.goal`
- `agent.instructions`
- `rag.enabled`
- `tools`
- `output.type`
- `audit.enabled`
校验失败时返回包含文件名、字段路径、错误原因的结果。列表页可以跳过非法场景并展示错误摘要。
## 6. 页面设计
首页路径:`/`
展示:
- 场景名称。
- 场景描述。
- 适用题型。
- RAG 是否启用。
- 工具数量。
- 进入对话按钮。
可选详情页:`/scenarios/<scenario_id>/`。V1 可以把详情合并到 Chat 页面。
## 7. 服务函数设计
```python
def list_scenarios() -> list[ScenarioConfig]:
"""读取配置目录中的合法场景,非法场景以错误摘要返回给页面。"""
def get_scenario(scenario_id: str) -> ScenarioConfig:
"""按场景 ID 返回完整配置,找不到时抛出 ScenarioNotFound。"""
def validate_scenario(config: dict) -> ValidationResult:
"""校验必填字段、字段类型、工具名称和输出类型。"""
```
`get_scenario()` 找不到时抛出业务异常,例如 `ScenarioNotFound`,由 View 转成中文错误提示。
## 8. 异常处理
| 异常 | 处理 |
|---|---|
| 配置目录不存在 | 返回空列表和错误提示 |
| YAML 语法错误 | 标记该文件无效 |
| ID 重复 | 保留第一个,报告重复错误 |
| 必填字段缺失 | 标记该场景无效 |
| 工具不存在 | 场景仍可展示,但 Chat 执行时记录工具错误 |
## 9. 验收标准
- 首页至少展示 5 个场景。
- 场景配置来自 `configs/` 文件。
- 非法配置有明确错误,不导致首页 500。
- Chat 可通过 `scenario_id` 获取完整配置。

View File

@@ -1,127 +0,0 @@
# 文档模块详细设计
## 1. 模块目标
Documents 模块让用户把复试题材料快速变成 Agent 可检索的知识库。V1 必须支持 `.txt``.md``.pdf``.docx`,保证常见复试材料可以进入 RAG。
## 2. 职责边界
负责:
- 文件上传表单和页面。
- 文件保存与元数据记录。
- 读取文本内容。
- 调用 Agent Core RAG 入库。
- 更新入库状态。
不负责:
- 不实现向量检索算法。
- 不生成模型回答。
- 不直接写审计日志。
## 3. 数据模型设计
模型:`UploadedDocument`
字段见 `docs/设计文档/3.数据库设计.md`
常量:
```python
STATUS_UPLOADED = "uploaded"
STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed"
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
```
文件保存路径建议:
```text
uploads/<scenario_id>/<YYYYMMDD>/<uuid>_<original_name>
```
## 4. 文件上传流程
1. GET `/documents/upload/` 渲染上传表单。
2. POST 校验 `scenario_id` 和文件。
3. 调用 Scenarios 服务确认场景存在。
4. 校验扩展名和文件大小。
5. 保存文件。
6. 创建 `UploadedDocument(status="uploaded")`
7. 跳转文件列表页并展示成功提示。
## 5. 文本抽取流程
抽取函数:
```python
def extract_text(document: UploadedDocument) -> str:
"""按文件类型抽取可入库纯文本,失败时抛出可展示的业务异常。"""
```
规则:
- `.txt`:优先 UTF-8失败时尝试系统默认编码。
- `.md`UTF-8 读取,保留标题、列表和正文。
- `.pdf`:抽取纯文本,不要求 OCR、表格还原和复杂版式理解。
- `.docx`:抽取段落、标题和普通表格文本,不要求完整保留 Word 样式。
- 空文本视为失败。
- 文件不存在视为失败。
XLSX 暂不作为 V1 必须项,可作为后续结构化业务数据导入能力。
## 6. RAG 入库触发流程
POST `/documents/<id>/index/`
1. 获取 `UploadedDocument`
2. 调用 `extract_text()`
3. 调用 `agent_core.rag.ingest.ingest_document()`,传入 `document_id``scenario_id`、文件名和抽取文本。
4. 成功后更新 `status="indexed"`,清空 `error_message`
5. 失败后更新 `status="failed"`,写入 `error_message`
6. 重定向回文件列表页。
入库动作必须使用 POST避免 GET 触发写操作。
已入库或失败文档允许重新入库。重新入库前需要按 `document_id` 清理或覆盖旧 chunk避免重复检索。
## 7. 页面设计
文件列表页展示:
- 文件名。
- 场景 ID。
- 文件类型。
- 文件大小。
- 状态。
- 上传时间。
- 入库按钮。
- 错误信息。
上传页展示:
- 场景下拉框。
- 文件控件。
- 支持类型提示。
- 表单错误。
## 8. 异常处理
| 异常 | 处理 |
|---|---|
| 场景不存在 | 表单错误 |
| 文件为空 | 表单错误 |
| 扩展名不支持 | 表单错误 |
| 文件保存失败 | 页面提示失败 |
| 文本为空 | 状态 failed |
| RAG 入库失败 | 状态 failed 并保存原因 |
## 9. 验收标准
- 可以上传 `.txt``.md``.pdf``.docx`
- 文件列表可看到记录。
- 文件可按场景关联。
- 入库成功状态变为 `indexed`
- 入库失败状态变为 `failed` 且可查看原因。
- 入库失败或已入库文档可重新入库。

View File

@@ -1,118 +0,0 @@
# 对话模块详细设计
## 1. 模块目标
Chat 模块负责复试演示中的主交互:用户选择场景后提交问题,系统展示 Agent 输出、引用、工具调用和审计入口。
## 2. 职责边界
负责:
- 对话页 GET/POST。
- 用户输入表单校验。
- 获取场景配置。
- 调用 Agent Core。
- 调用 Audit 服务写日志。
- 渲染 AgentResult。
不负责:
- 不直接读取 YAML。
- 不直接调用 LLM。
- 不直接执行 RAG 和工具。
- 不实现复杂多轮会话状态。
## 3. 页面设计
路径:`/chat/<scenario_id>/`
GET
- 加载场景配置。
- 展示场景摘要。
- 加载当前场景下状态为 `indexed` 的文档列表。
- 展示空表单。
POST
- 校验输入。
- 执行 Agent。
- 写审计。
- 展示结果和审计链接。
## 4. 表单设计
字段:
| 字段 | 类型 | 规则 |
|---|---|---|
| `message` | textarea | 必填,最大 4000 字 |
| `document_ids` | 多选 | 可选,只能选择当前场景下已入库文档 |
错误提示:
- 空输入:`请输入要咨询的问题。`
- 超长输入:`问题过长,请控制在 4000 字以内。`
- 文档不属于当前场景或未入库:`请选择当前场景下已入库的文档。`
## 5. Agent Core 调用流程
```python
scenario = get_scenario(scenario_id)
result = run_agent(
scenario_config=scenario,
user_input=form.cleaned_data["message"],
options={"document_ids": form.cleaned_data.get("document_ids", [])}
)
```
Chat 只依赖 Agent Core 的统一返回对象,不关心内部是否使用 RAG、工具或真实模型。
未选择文档时,`document_ids` 传空列表或不传,由 Agent Core 默认使用当前场景全部已入库文档。
## 6. 结果展示设计
优先级:
1. 如果 `structured_output` 不为空,展示结构化 JSON 或字段化结果。
2. 展示 `answer`
3. 展示 `references`
4. 展示 `tool_calls`
5. 展示 `latency_ms``model_name``status`
6. 如果有 `error`,展示中文错误提示。
结构化解析失败时,页面仍展示 `raw_output``answer`
## 7. 审计日志写入流程
Agent Core 返回后调用:
```python
audit_log = create_audit_log(
scenario_id=scenario.id,
scenario_name=scenario.name,
user_input=message,
agent_result=result,
)
```
如果 Agent Core 抛异常Chat 应构造失败结果并继续写失败审计。
## 8. 异常处理
| 异常 | 处理 |
|---|---|
| 场景不存在 | 显示错误并返回首页入口 |
| 表单无效 | 留在页面并显示表单错误 |
| Agent Core 抛异常 | 构造 failed AgentResult写审计 |
| 审计写入失败 | 页面提示审计失败,但展示 Agent 输出 |
| LLM 配置缺失 | 展示模型配置缺失 |
## 9. 验收标准
- 从首页可进入对话页。
- 可提交问题并渲染 AgentResult。
- 可选择本次对话使用的文档范围;未选择时默认使用当前场景全部已入库文档。
- 失败时有中文提示。
- 成功和失败都尽量写入审计。
- View 中没有 RAG、工具、LLM 的细节实现。

View File

@@ -1,121 +0,0 @@
# 审计模块详细设计
## 1. 模块目标
Audit 模块记录 Agent 执行过程,使演示者能够解释一次输出的来源、工具调用和模型结果。它是系统从“普通问答页面”变成“可追踪业务 Agent”的关键。
## 2. 职责边界
负责:
- `AgentAuditLog` 模型。
- 审计写入服务。
- 审计列表页。
- 审计详情页。
- 敏感信息过滤。
不负责:
- 不执行 Agent。
- 不执行 RAG。
- 不执行工具。
- 不调用模型。
## 3. 数据模型设计
模型:`AgentAuditLog`
字段见 `docs/设计文档/3.数据库设计.md`
JSON 字段默认值必须使用函数,例如 `default=list``default=dict`,避免多实例共享同一对象。
## 4. 日志写入流程
服务函数:
```python
def create_audit_log(
scenario_id: str,
scenario_name: str,
user_input: str,
agent_result: AgentResult,
) -> AgentAuditLog:
"""将 AgentResult 映射为 AgentAuditLog并在保存前做敏感信息脱敏。"""
```
写入映射:
- `agent_result.references` -> `retrieved_chunks`
- `agent_result.tool_calls` -> `tool_calls`
- `agent_result.structured_output` -> `structured_output`
- `agent_result.answer` -> `final_answer`
- `agent_result.raw_output` -> `raw_output`
- `agent_result.model_name` -> `model_name`
- `agent_result.latency_ms` -> `latency_ms`
- `agent_result.status` -> `status`
- `agent_result.error` -> `error_message`
## 5. 日志列表页设计
路径:`/audit/`
查询:
- 默认按创建时间倒序。
- V1 可不做分页,若日志较多再加 Django Paginator。
展示:
- ID。
- 场景名称。
- 用户输入前 80 字。
- 状态。
- 模型名。
- 耗时。
- 创建时间。
- 详情链接。
## 6. 日志详情页设计
路径:`/audit/<log_id>/`
展示:
- 基础信息。
- 用户输入。
- 最终回答。
- 结构化输出。
- RAG 检索片段。
- 工具调用。
- 原始输出。
- 错误信息。
JSON 可用格式化后的 `<pre>` 展示。
## 7. 敏感信息处理
不得保存:
- `LLM_API_KEY`
- 完整环境变量 dump
- 用户机器上的敏感绝对路径
- Docker secret 或 token
如错误信息来自异常对象,应在保存前做简单脱敏,至少替换 API Key 值。
## 8. 异常处理
| 异常 | 处理 |
|---|---|
| AgentResult 字段缺失 | 使用默认空值 |
| JSON 不可序列化 | 转为字符串或空对象 |
| 日志不存在 | 返回 404 |
| 写入失败 | 抛给 Chat由 Chat 展示审计失败提示 |
## 9. 验收标准
- 每次对话成功后有审计日志。
- Agent 失败也有失败日志。
- 列表页可查看日志摘要。
- 详情页可查看输入、输出、引用和工具调用。
- 日志不包含 API Key。

View File

@@ -1,259 +0,0 @@
# 智能核心模块详细设计
## 1. 模块目标
Agent Core 提供独立于 Django View 的智能编排能力。它消费场景配置,执行 RAG、工具、模型调用和结构化解析最终返回统一 AgentResult。
## 2. 职责边界
负责:
- Agent 编排。
- 场景配置对象消费。
- RAG 入库和检索。
- 工具注册与执行。
- LLM Provider 与 Embedding Provider。
- 结构化输出解析。
- AgentResult 定义。
不负责:
- 不渲染页面。
- 不处理 Django 表单。
- 不保存 Django Model。
- 不管理登录权限。
## 3. 子模块划分
```text
agent_core/
orchestrator.py
scenario_loader.py
llm_provider.py
tool_registry.py
structured_output.py
rag/
ingest.py
retriever.py
tools/
builtin_tools.py
schemas/
outputs.py
```
`scenario_loader.py` 可作为非 Django 环境下加载配置的工具Django 场景展示仍由 `apps.scenarios` 负责。
## 4. Orchestrator 设计
入口:
```python
def run_agent(scenario_config, user_input: str, options: dict | None = None) -> AgentResult:
"""执行一次 Agent 编排options 可包含 document_ids 等运行期约束。"""
```
流程:
1. 记录开始时间。
2. 根据 `rag.enabled``scenario_id` 和可选 `document_ids` 检索引用。
3. 根据 `tools` 执行或准备工具结果。
4. 构造 messages。
5. 调用 LLM Provider。
6. 解析结构化输出。
7. 计算耗时。
8. 返回 `AgentResult(status="success")`
9. 捕获可恢复异常并返回 `status="failed"`
V1 在缺少 LLM 或 Embedding 配置时必须返回清晰失败结果。测试代码可以使用 mock provider但 V1 验收链路必须通过真实 OpenAI 兼容 LLM、Embedding 和 Chroma。
## 5. Scenario Loader 设计
Agent Core 的 Scenario Loader 用于脚本、测试或后续独立服务场景。它不依赖 Django View可以复用 Scenarios 模块的字段规范。
接口:
```python
load_scenario(path: str) -> dict
load_scenarios(directory: str) -> list[dict]
```
## 6. RAG 设计
入库接口:
```python
def ingest_document(
document_id: int,
scenario_id: str,
source_file: str,
text: str,
collection: str,
) -> IngestResult:
"""切分文档、生成 embedding并写入 Chroma。重新入库时覆盖同一 document_id 的旧 chunk。"""
```
检索接口:
```python
def retrieve(
scenario_id: str,
query: str,
collection: str,
top_k: int = 5,
document_ids: list[int] | None = None,
) -> list[ReferenceChunk]:
"""按场景和可选文档范围执行向量检索,返回可审计引用片段。"""
```
切分策略:
- 默认 chunk size 800 到 1000 字。
- overlap 100 到 150 字。
- metadata 包含 `scenario_id``document_id``source_file``chunk_id`
RAG 入库和检索必须使用 Embedding Provider 与 Chroma。单元测试桩或开发阶段临时验证方案不属于 V1 验收设计。
## 7. Tool Registry 设计
工具注册:
```python
registry.register("calculate_rate", calculate_rate)
registry.get("calculate_rate")
registry.run("calculate_rate", **kwargs)
```
工具结果统一:
```json
{
"tool_name": "calculate_rate",
"success": true,
"arguments": {},
"result": {},
"error": ""
}
```
内置工具:
- `calculate_rate`
- `query_demo_records`
- `check_required_fields`
- `generate_action_items`
工具函数不得直接读取 API Key 或执行无审计的外部副作用。
## 8. LLM Provider 设计
接口:
```python
class LLMProvider:
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
"""调用 OpenAI 兼容 Chat Completions 接口并返回统一响应对象。"""
```
配置来源:
- `LLM_API_KEY`
- `LLM_BASE_URL`
- `LLM_MODEL`
Provider 对外隐藏供应商差异Orchestrator 只处理 `LLMResponse.content``LLMResponse.model_name` 和错误信息。供应商可自主选择 OpenAI、硅基流动等 OpenAI 兼容服务。
Embedding Provider 接口:
```python
class EmbeddingProvider:
def embed_texts(self, texts: list[str]) -> list[list[float]]:
"""调用 OpenAI 兼容 Embeddings 接口,返回与输入文本一一对应的向量。"""
```
配置来源:
- `EMBEDDING_API_KEY`
- `EMBEDDING_BASE_URL`
- `EMBEDDING_MODEL`
`EMBEDDING_API_KEY``EMBEDDING_BASE_URL` 为空时,可以复用 `LLM_API_KEY``LLM_BASE_URL`
## 9. Structured Output 设计
接口:
```python
def parse_structured_output(raw_output: str, output_type: str) -> ParseResult:
"""优先解析 JSON并根据输出类型返回结构化结果或解析错误。"""
```
策略:
- 优先解析 JSON。
- 根据 `output_type` 做字段补齐或轻校验。
- 失败时返回 `success=False`,保留 `raw_output`
- 不因结构化解析失败导致整个 Agent 流程崩溃。
## 10. AgentResult 设计
建议 dataclass
```python
@dataclass
class AgentResult:
answer: str
structured_output: dict
references: list
tool_calls: list
raw_output: str
model_name: str
latency_ms: int
status: str
error: str = ""
```
所有字段必须有默认值或构造时明确传入,保证 Audit 模块写入稳定。
## 11. Adapter 扩展设计
统一接口:
```python
class AgentEngine:
def run_agent(self, scenario_config, user_input: str, options: dict | None = None) -> AgentResult:
"""保持与顶层 run_agent 函数一致的输入输出合约。"""
```
V1 实现:
- `LightweightOrchestrator`
后续扩展:
- `DifyAdapter`
- `OpenAIAgentsAdapter`
- `LangGraphAdapter`
Adapter 只能替换编排实现,不能改变 Django 层依赖的 AgentResult 合约。
## 12. 异常处理
| 异常 | 处理 |
|---|---|
| RAG 检索失败 | 记录错误,允许继续或返回 failed |
| 工具不存在 | 记录失败工具调用 |
| 工具执行异常 | 捕获并返回失败工具结果 |
| LLM 配置缺失 | 返回 failed AgentResult |
| LLM 调用失败 | 返回 failed AgentResult |
| JSON 解析失败 | 返回 success 但带解析错误,展示 raw output |
## 13. 验收标准
- Chat 可以调用 `run_agent()`
- 返回对象字段稳定完整。
- RAG 按 `scenario_id` 隔离。
- RAG 支持按 `document_ids` 限定本次对话的文档范围。
- 工具调用结果格式统一。
- LLM 与 Embedding 配置从环境变量读取。
- 结构化解析失败不导致页面崩溃。
- Agent Core 不依赖 Django View。

View File

@@ -0,0 +1,150 @@
# 1. 资料包导入与目录汇总详细设计
## 1. 设计目标
本步骤对应最新版原型中的 `资料包` 页面与 `审核智能体` 上传入口,目标是把用户导入的注册申报资料转为:
1. 可绑定会话的资料包对象
2. 可展示目录和页数的结构化结果
3. 可进入后续审核链路的文档底座
本步骤执行完成后,系统应至少产出:
1. `batch_id`
2. `product_name`
3. `conversation_id`
4. `registration_overview_report`
## 2. 页面与模块对应
### 2.1 `资料包` 页面
用于展示:
1. 资料包列表
2. 产品名称搜索框
3. 资料包与会话关联关系
4. 文件目录、页数、章节点和异常
### 2.2 `审核智能体` 页面
用于触发:
1. 上传资料包
2. 自动创建或绑定会话
3. 在对话中插入“目录汇总”节点结果
### 2.3 `apps.documents`
负责:
1. 资料包模型
2. 文档模型
3. 页数统计
4. 章节点识别
5. 目录汇总服务
## 3. 核心数据结构
### 3.1 SubmissionBatch
建议包含:
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

@@ -0,0 +1,66 @@
# 2. 法规完整性检查详细设计
## 1. 设计目标
本步骤对应 `审核智能体` 中的“完整性检查”节点,目标是基于结构化规则和法规 RAG 证据判断资料包是否齐套、是否错放、是否存在高风险缺失项。
## 2. 输入
1. `batch_id`
2. `conversation_id`
3. `product_name`
4. `chapter_summary`
5. `document_scope`
6. 规则包与法规知识索引
## 3. 规则与 RAG 分工
### 3.1 规则负责
1. 必交项判断
2. 章节点匹配
3. 缺失/错放分类
4. 风险等级映射
### 3.2 RAG 负责
1. 定位法规依据片段
2. 提供条款解释
3. 为对话输出引用依据
## 4. 主流程
```text
读取资料包目录
-> 加载完整性规则包
-> 比对章节点与资料要求
-> 输出缺失项 / 错放项 / 待复核项
-> 通过 RAG 补充法规依据
-> 生成完整性节点结果
```
## 5. 输出对象
`registration_completeness_report` 至少包含:
1. `missing_items`
2. `misplaced_items`
3. `manual_review_items`
4. `risk_level`
5. `rule_hits`
6. `rag_evidence`
## 6. 对话节点要求
完整性检查节点在会话中应展示:
1. 缺失项摘要
2. 错放项摘要
3. 风险等级
4. 法规依据来源
## 7. 验收标准
1. 能区分缺失、错放和待复核。
2. 规则结论与法规依据分层清晰。
3. 可直接用于后续风险预警和飞书通知。

View File

@@ -0,0 +1,58 @@
# 3. 字段抽取与统一字段池详细设计
## 1. 设计目标
本步骤负责从说明书、申请表、产品列表等资料中抽取关键字段,并写入统一字段池,供一致性核查、风险判断和 Word 回填复用。
## 2. 输入
1. `batch_id`
2. `conversation_id`
3. `product_name`
4. `document_scope`
5. `field_schema`
6. `source_priority`
## 3. 字段池模型
字段项至少包含:
1. `field_code`
2. `field_name`
3. `field_value`
4. `source_document_id`
5. `source_location`
6. `confidence`
7. `review_status`
8. `fillable`
## 4. 抽取策略
1. 规则抽取显式字段
2. 表格抽取规格与结构化字段
3. LLM 归纳长文本字段
4. 来源优先级合并同名字段
## 5. 输出对象
`registration_field_extraction_report` 至少包含:
1. `field_items`
2. `source_documents`
3. `low_confidence_items`
4. `fillable_items`
## 6. 对话节点要求
字段抽取节点应展示:
1. 已抽取字段数
2. 待复核字段数
3. 关键字段来源
4. 是否可回填
## 7. 验收标准
1. 统一字段池可支撑后续一致性核查和回填。
2. 低置信度字段有明确待复核标记。
3. 对话中可解释字段来源和采用逻辑。

View File

@@ -0,0 +1,56 @@
# 4. 一致性核查详细设计
## 1. 设计目标
本步骤负责基于统一字段池和资料包上下文,识别跨文档字段冲突、混档风险和待人工确认项。
## 2. 输入
1. `batch_id`
2. `conversation_id`
3. `product_name`
4. `field_pool`
5. `strong_consistency_rules`
## 3. 核查维度
1. 产品名称
2. 检测靶标
3. 适用范围
4. 储存条件
5. 规格/型号
## 4. 主流程
```text
加载字段池
-> 读取强一致规则
-> 对比多来源字段值
-> 识别冲突字段
-> 判断是否存在混档风险
-> 输出一致性节点结果
```
## 5. 输出对象
`registration_consistency_report` 至少包含:
1. `conflict_items`
2. `consistent_items`
3. `mixed_package_risk`
4. `recommended_value`
## 6. 对话节点要求
一致性节点应展示:
1. 冲突字段
2. 来源对比
3. 是否疑似混档
4. 建议采用值
## 7. 验收标准
1. 能对产品名称等强一致字段给出明确冲突结论。
2. 能说明冲突来自哪些文档。
3. 能把冲突结果直接传递给风险预警和飞书通知。

View File

@@ -0,0 +1,66 @@
# 5. 风险预警详细设计
## 1. 设计目标
本步骤汇总完整性检查、字段抽取和一致性核查结果,形成统一风险报告,并决定是否允许正式导出。
## 2. 输入
1. `registration_completeness_report`
2. `registration_field_extraction_report`
3. `registration_consistency_report`
4. `risk_rule_set`
## 3. 风险类型
1. 缺失必交资料
2. 资料错放
3. 字段低置信度
4. 字段冲突
5. 混档风险
6. 导出阻断风险
## 4. 输出对象
`registration_risk_report` 至少包含:
1. `risk_items`
2. `highest_risk_level`
3. `pass_status`
4. `manual_review_items`
5. `owner_roles`
6. `suggestions`
## 5. 责任角色输出
风险结果必须产出责任角色,为飞书协同做准备。责任角色实体至少包含:
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. 正常完成时使用 `task_completed`
2. 异常失败时使用 `task_failed`
## 6. 对话节点要求
风险节点应展示:
1. 总风险等级
2. 是否通过
3. 整改建议
4. 对应责任角色
## 7. 验收标准
1. 风险结论可直接决定是否允许正式导出。
2. 输出中包含责任角色和通知所需字段。
3. 能向飞书通知步骤直接提供摘要与责任人。

View File

@@ -0,0 +1,41 @@
# 6. Word 回填导出详细设计
## 1. 设计目标
本步骤负责把统一字段池中的可回填字段写入注册申报表格或对照清单,并在风险允许时生成导出文件。
## 2. 输入
1. `field_pool`
2. `template_mapping`
3. `registration_risk_report`
## 3. 导出策略
1. 始终允许生成草稿版
2. 命中高风险阻断正式版
3. 字段冲突或关键缺失时给出拦截原因
## 4. 输出对象
`registration_word_export_report` 至少包含:
1. `fillable_items`
2. `blocked_items`
3. `draft_export_status`
4. `formal_export_status`
5. `download_url`
## 5. 页面与会话要求
在对话或结果展示中应能看到:
1. 哪些字段已回填
2. 哪些字段被拦截
3. 为什么正式版不能导出
## 6. 验收标准
1. 草稿版和正式版策略区分明确。
2. 风险结果能阻断正式导出。
3. 导出结果可直接提供给飞书消息摘要使用。

View File

@@ -0,0 +1,131 @@
# 7. 飞书通知详细设计
## 1. 设计目标
本步骤负责在审核任务执行完成或执行异常时,通过飞书 `@` 对应处理人,并回传 Web 详情链接。
V1 当前 Demo 固定通知策略:
1. 执行完成后发送结果摘要并 `@` 处理人
2. 执行异常后发送异常摘要并 `@` 处理人
## 2. 角色信息模型
责任人信息不再只保留角色名,必须扩展为可通知实体,至少包含:
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`
## 3. 输入
1. `batch_id`
2. `conversation_id`
3. `product_name`
4. `notify_reason`
5. `registration_risk_report`
6. `registration_word_export_report`
7. `owner_mapping`
其中 `notify_reason` 固定支持:
1. `task_completed`
2. `task_failed`
## 4. 输出对象
`feishu_notification_report` 至少包含:
1. `batch_id`
2. `conversation_id`
3. `notify_reason`
4. `mentioned_users`
5. `message_status`
6. `web_detail_url`
7. `receipt`
## 5. 主流程
```text
任务完成或异常
-> 读取责任角色与飞书账号
-> 构建飞书摘要
-> 构建 @ 处理人载荷
-> 发送飞书消息
-> 写回发送回执
-> 写入处理历史和审计
```
## 6. 通知内容要求
飞书消息至少应包含:
1. 任务名称
2. 产品名称
3. 批次号
4. 结果状态
5. 风险等级或异常摘要
6. `@` 处理人
7. Web 详情链接
## 7. 处理完成通知
触发条件:
1. 目录汇总完成
2. 风险报告完成
3. 导出状态已生成
输出重点:
1. 风险等级
2. 是否允许正式导出
3. 责任人
## 8. 执行异常通知
触发条件:
1. 资料解析失败
2. 规则执行失败
3. 回填导出失败
4. 外部依赖异常
输出重点:
1. 异常阶段
2. 异常摘要
3. 责任人
4. 是否建议人工介入
## 9. 与页面关系
### 9.1 审核智能体
可展示:
1. 本次是否已触发飞书通知
2. 飞书发送状态
3.`@` 的处理人
### 9.2 处理历史
可回看:
1. 通知原因
2. 接收人
3. 消息状态
4. Web 回链
## 10. 验收标准
1. 角色信息包含飞书账号相关字段。
2. 执行完成与执行异常两类通知链路完整。
3. 飞书消息支持直接 `@` 对应处理人。
4. 通知结果可在处理历史和审计中回溯。

View File

@@ -0,0 +1,90 @@
# Word回填导出编排Skill 设计
## 1. Skill 定位
`Word回填导出编排Skill` 是第六步工作流的总入口 Skill负责组织模板选择、字段映射加载、回填字段集构建、回填拦截检查、Word 渲染、版式校验和导出记录生成。
英文实现标识建议使用 `WordFillExportOrchestrateSkill`
## 2. 输入
```python
@dataclass
class WordFillExportOrchestrateInput:
batch_id: int
template_id: str
target_output_type: str
selected_field_keys: list[str] = field(default_factory=list)
allow_draft_when_blocked: bool = True
```
## 3. 输出
```python
@dataclass
class WordFillExportOrchestrateOutput:
report_type: str
batch_id: int
export_status: str
output_file: dict | None
filled_fields: list[dict]
blocked_fields: list[dict]
audit_id: int | None = None
```
## 4. 依赖 Skill
1. `模板选择Skill`
2. `模板字段映射加载Skill`
3. `回填字段集构建Skill`
4. `回填拦截检查Skill`
5. `Word模板渲染Skill`
6. `导出版式校验Skill`
7. `导出记录生成Skill`
## 5. 核心方法
### 5.1 `run(input) -> WordFillExportOrchestrateOutput`
主入口方法。
### 5.2 `load_export_context(input) -> WordExportContext`
加载字段池、风险报告和一致性报告。
### 5.3 `resolve_export_mode(blockers) -> str`
确认正式、草稿或拦截模式。
## 6. 技术实现
使用技术:
1. Tool Registry
2. Django ORM
3. Django Storage
4. dataclass/Pydantic
建议注册名:
```python
tool_registry.register(
name="word_fill_export_orchestrate",
handler=WordFillExportOrchestrateSkill().run,
)
```
## 7. 异常处理
1. 模板缺失:任务失败。
2. 字段池缺失:任务失败。
3. 正式导出被拦截:按配置生成草稿或直接返回拦截。
4. 渲染失败:写失败审计。
## 8. 测试要点
1. 能按顺序调用依赖 Skill。
2. 冲突字段导致正式导出拦截。
3. 草稿模式可生成文件。
4. 输出报告稳定。

View File

@@ -0,0 +1,67 @@
# Word模板渲染Skill 设计
## 1. Skill 定位
`Word模板渲染Skill` 负责将回填字段集写入 Word 模板,生成新的 `.docx` 文件。
英文实现标识建议使用 `WordTemplateRenderSkill`
## 2. 输入
```python
@dataclass
class WordTemplateRenderInput:
template_file_path: Path
fill_dataset: dict
export_mode: str
output_dir: Path
```
## 3. 输出
```python
@dataclass
class WordTemplateRenderOutput:
output_file_path: Path
rendered_placeholders: list[str]
render_warnings: list[dict]
```
## 4. 核心方法
### 4.1 `run(input) -> WordTemplateRenderOutput`
主入口方法。
### 4.2 `replace_paragraph_placeholders(document, values) -> None`
替换段落占位符。
### 4.3 `replace_table_placeholders(document, values) -> None`
替换表格占位符。
### 4.4 `save_document(document, output_path) -> Path`
保存文档。
## 5. 技术实现
使用技术:
1. `python-docx`
2. 可选 `docxtpl`
3. Django Storage
## 6. 异常处理
1. 模板打不开:任务失败。
2. 占位符替换失败:记录警告。
3. 保存失败:任务失败。
## 7. 测试要点
1. 段落占位符可替换。
2. 表格占位符可替换。
3. 输出文件存在。

View File

@@ -0,0 +1,88 @@
# 一致性报告生成Skill 设计
## 1. Skill 定位
`一致性报告生成Skill` 负责将字段比对结果和混档风险组装成稳定的 `registration_consistency_report`,并生成页面展示、审计和飞书摘要载荷。
英文实现标识建议使用 `ConsistencyReportBuildSkill`
## 2. 输入
```python
@dataclass
class ConsistencyReportBuildInput:
context: ConsistencyReviewContext
compare_results: list[FieldCompareResult]
mixed_package_warnings: list[dict]
```
## 3. 输出
```python
@dataclass
class ConsistencyReportBuildOutput:
report: dict
display_sections: list[dict]
audit_payload: dict
feishu_summary_payload: dict
```
## 4. 报告结构
报告必须包含:
1. `report_type`
2. `batch_id`
3. `field_rule_id`
4. `summary`
5. `consistent_fields`
6. `conflict_fields`
7. `manual_review_fields`
8. `mixed_package_warnings`
9. `suggestions`
## 5. 核心方法
### 5.1 `run(input) -> ConsistencyReportBuildOutput`
主入口方法。
### 5.2 `build_summary(compare_results, warnings) -> dict`
生成汇总。
### 5.3 `split_compare_results(compare_results) -> dict`
拆分一致、冲突、待复核字段。
### 5.4 `build_suggestions(conflicts, warnings) -> list[dict]`
生成处理建议。
### 5.5 `build_audit_payload(report, context) -> dict`
生成审计载荷。
## 6. 技术实现
使用技术:
1. dataclass/Pydantic
2. JSONField
3. Audit 服务
4. 页面展示 schema
## 7. 异常处理
1. 报告字段缺失:任务失败。
2. 没有可比对字段:输出空报告。
3. 飞书摘要构建失败:不影响 Web 报告。
4. 审计写入失败:记录系统警告。
## 8. 测试要点
1. 冲突字段进入 `conflict_fields`
2. 混档风险进入 `mixed_package_warnings`
3. 汇总数量正确。
4. 审计载荷包含审核范围。

View File

@@ -0,0 +1,103 @@
# 一致性核查编排Skill 设计
## 1. Skill 定位
`一致性核查编排Skill` 是第四步工作流的总入口 Skill负责组织审核范围确认、强一致规则加载、字段分组、完全一致比对、混档风险识别、字段池状态回写和报告生成。
英文实现标识建议使用 `ConsistencyReviewOrchestrateSkill`
## 2. 输入
```python
@dataclass
class ConsistencyReviewOrchestrateInput:
batch_id: int
scenario_id: str = "registration_consistency_review"
selected_document_ids: list[int] = field(default_factory=list)
field_rule_id: str = "ivd_strict_consistency_v1"
target_field_keys: list[str] = field(default_factory=list)
strict_mode: bool = True
```
## 3. 输出
```python
@dataclass
class ConsistencyReviewOrchestrateOutput:
report_type: str
batch_id: int
summary: dict
consistent_fields: list[dict]
conflict_fields: list[dict]
manual_review_fields: list[dict]
mixed_package_warnings: list[dict]
audit_id: int | None = None
```
## 4. 依赖 Skill
1. `审核范围确认Skill`
2. `强一致规则加载Skill`
3. `字段分组Skill`
4. `字段完全一致比对Skill`
5. `混档风险识别Skill`
6. `一致性报告生成Skill`
## 5. 核心方法
### 5.1 `run(input) -> ConsistencyReviewOrchestrateOutput`
主入口方法。
执行顺序:
1. 读取统一字段池。
2. 调用 `审核范围确认Skill`
3. 调用 `强一致规则加载Skill`
4. 调用 `字段分组Skill`
5. 调用 `字段完全一致比对Skill`
6. 调用 `混档风险识别Skill`
7. 回写字段池冲突状态。
8. 调用 `一致性报告生成Skill`
9. 写入审计。
### 5.2 `load_field_pool(batch_id) -> list[FieldPoolItem]`
读取字段池主表和候选值。
### 5.3 `update_field_pool_status(compare_results) -> FieldPoolUpdateResult`
回写一致、冲突、待复核状态。
## 6. 技术实现
使用技术:
1. Django ORM
2. Tool Registry
3. dataclass/Pydantic
4. Audit 服务
建议注册名:
```python
tool_registry.register(
name="consistency_review_orchestrate",
handler=ConsistencyReviewOrchestrateSkill().run,
)
```
## 7. 异常处理
1. 字段池不存在:任务失败并提示先执行字段抽取。
2. 审核范围为空:返回业务错误。
3. 规则缺失:任务失败并写审计。
4. 字段池回写失败:报告仍生成,但标记系统警告。
## 8. 测试要点
1. 能按顺序调用依赖 Skill。
2. 字段池缺失时返回清晰错误。
3. 冲突结果能回写字段池。
4. 输出报告 schema 稳定。

View File

@@ -0,0 +1,70 @@
# 准入判定Skill 设计
## 1. Skill 定位
`准入判定Skill` 负责根据风险项、人工复核项和准入规则计算最终是否通过。
英文实现标识建议使用 `AdmissionDecisionSkill`
## 2. 输入
```python
@dataclass
class AdmissionDecisionInput:
risk_items: list[RiskItem]
manual_review_items: list[dict]
admission_rules: dict
```
## 3. 输出
```python
@dataclass
class AdmissionDecisionOutput:
pass_status: str
highest_risk_level: str
decision_reason: str
score_detail: dict
```
## 4. 判定规则
1. 任一高风险:不通过。
2. 多个中风险:待整改后复核。
3. 只有低风险:条件通过。
4. 有人工复核项:待复核。
5. 无风险:通过。
## 5. 核心方法
### 5.1 `run(input) -> AdmissionDecisionOutput`
主入口方法。
### 5.2 `calculate_highest_level(risk_items) -> str`
计算最高风险等级。
### 5.3 `calculate_status(risk_items, manual_review_items, rules) -> str`
计算准入状态。
### 5.4 `build_decision_reason(status, risk_items) -> str`
生成判定理由。
## 6. 技术实现
使用技术:
1. 风险等级枚举
2. 准入规则 YAML
3. Python 规则判断
## 7. 测试要点
1. 高风险导致失败。
2. 中风险过多导致复核。
3. 低风险条件通过。
4. 无风险通过。

View File

@@ -0,0 +1,67 @@
# 前序报告汇总Skill 设计
## 1. Skill 定位
`前序报告汇总Skill` 负责读取当前批次已经生成的完整性、字段抽取和一致性核查报告,并统一转为风险评估上下文。
英文实现标识建议使用 `SourceReportCollectSkill`
## 2. 输入
```python
@dataclass
class SourceReportCollectInput:
batch_id: int
include_reports: list[str]
```
## 3. 输出
```python
@dataclass
class SourceReportCollectOutput:
reports: dict[str, dict]
missing_reports: list[str]
stale_reports: list[str]
validation_warnings: list[dict]
```
## 4. 核心方法
### 4.1 `run(input) -> SourceReportCollectOutput`
主入口方法。
### 4.2 `load_report(batch_id, report_type) -> dict | None`
读取报告快照。
### 4.3 `validate_report_freshness(report) -> bool`
校验报告是否过期。
### 4.4 `build_report_bundle(reports) -> SourceReportBundle`
构建报告集合。
## 5. 技术实现
使用技术:
1. Django ORM
2. JSONField
3. 报告版本号
## 6. 异常处理
1. 报告不存在:加入 `missing_reports`
2. 报告 schema 不完整:加入警告。
3. 报告过期:加入 `stale_reports`
## 7. 测试要点
1. 能读取多个报告。
2. 缺失报告能识别。
3. 过期报告能识别。
4. 输出 bundle 稳定。

View File

@@ -0,0 +1,137 @@
# 压缩包解包Skill 设计
## 1. Skill 定位
`压缩包解包Skill` 负责压缩包解包,是资料包导入链路中的文件展开能力。它需要支持 `zip``rar``7z`,并确保解包过程安全、可追踪、可复核。
本 Skill 的输出是“解包后的文件树”,不是文档审核结论。
英文实现标识建议使用 `ArchiveExtractionSkill`,用于 Python 类名和 Tool Registry 注册处理器。
## 2. 输入
```python
@dataclass
class ArchiveExtractionInput:
archive_path: Path
target_dir: Path
source_archive_name: str
max_file_count: int = 1000
max_total_size: int | None = None
```
## 3. 输出
```python
@dataclass
class ArchiveExtractionOutput:
status: str
archive_type: str
extracted_files: list[ExtractedFile]
skipped_items: list[SkippedArchiveItem]
warnings: list[dict]
```
`ExtractedFile` 字段:
1. `absolute_path`
2. `relative_path`
3. `file_size`
4. `source_archive_name`
5. `file_hash`
## 4. 支持格式与技术
| 格式 | 技术 |
|---|---|
| `zip` | Python 标准库 `zipfile` |
| `rar` | 纯 Python rar 解析依赖,优先选择不依赖系统命令的库 |
| `7z` | `py7zr` |
V1 可以先完成 `zip`,同时保留 `rar``7z` 的接口和失败提示。正式验收前需要补齐纯 Python 解包能力。
## 5. 核心方法
### 5.1 `run(input) -> ArchiveExtractionOutput`
主入口方法。
处理顺序:
1. 识别压缩包类型。
2. 打开压缩包。
3. 遍历成员文件。
4. 校验成员路径安全性。
5. 校验数量和大小限制。
6. 解包到批次隔离目录。
7. 生成解包结果。
### 5.2 `detect_archive_type(path) -> str`
根据扩展名和文件头识别压缩包类型。
返回:
1. `zip`
2. `rar`
3. `7z`
4. `unsupported`
### 5.3 `validate_member_path(member_name) -> str`
路径安全校验。
拒绝:
1. 绝对路径。
2. 包含 `..` 的路径。
3. Windows 盘符路径。
4. 空路径。
5. 控制字符。
### 5.4 `extract_zip(input) -> list[ExtractedFile]`
使用 `zipfile` 解包。
### 5.5 `extract_rar(input) -> list[ExtractedFile]`
使用纯 Python rar 依赖解包。
如果当前环境依赖未安装,返回 `unsupported_archive`,并提示需要安装对应依赖。
### 5.6 `extract_7z(input) -> list[ExtractedFile]`
使用 `py7zr` 解包。
### 5.7 `build_file_hash(path) -> str`
使用 `sha256` 生成文件指纹,用于重复文件识别。
## 6. 状态设计
| 状态 | 含义 |
|---|---|
| `extracted` | 解包成功 |
| `partial_extracted` | 部分成员解包失败 |
| `empty_archive` | 压缩包为空 |
| `encrypted_archive` | 加密压缩包 |
| `unsupported_archive` | 格式不支持 |
| `path_rejected` | 存在危险路径 |
| `extract_failed` | 解包失败 |
## 7. 异常处理
1. 加密压缩包:不尝试暴力解析,标记为待人工处理。
2. 路径穿越:拒绝危险成员并记录安全拦截。
3. 超大压缩包:超过配置限制时停止处理。
4. 成员数量过多:停止处理并提示资料包异常。
5. 文件名乱码:保留原始名称,展示层提示人工复核。
## 8. 测试要点
1. 普通 zip 解包成功。
2. 多层目录相对路径被保留。
3. `../evil.docx` 被拒绝。
4. 空压缩包返回 `empty_archive`
5. 不支持格式返回 `unsupported_archive`
6. 解包后文件哈希生成稳定。

View File

@@ -0,0 +1,62 @@
# 回填字段集构建Skill 设计
## 1. Skill 定位
`回填字段集构建Skill` 负责根据模板字段映射和统一字段池构建实际要写入 Word 模板的字段值集合。
英文实现标识建议使用 `FillDatasetBuildSkill`
## 2. 输入
```python
@dataclass
class FillDatasetBuildInput:
field_pool_items: list[FieldPoolItem]
template_mappings: list[dict]
selected_field_keys: list[str] = field(default_factory=list)
```
## 3. 输出
```python
@dataclass
class FillDatasetBuildOutput:
fill_dataset: dict
missing_required_fields: list[dict]
manual_review_fields: list[dict]
```
## 4. 核心方法
### 4.1 `run(input) -> FillDatasetBuildOutput`
主入口方法。
### 4.2 `resolve_field_value(mapping, field_pool) -> FillValue`
解析字段值。
### 4.3 `build_placeholder_values(mappings, field_pool) -> dict`
生成占位符和值。
## 5. 技术实现
使用技术:
1. 字段池数据
2. 模板映射
3. Python 字典构建
## 6. 异常处理
1. 必填字段缺失:进入缺失列表。
2. 字段待复核:进入待复核列表。
3. 字段不可回填:跳过。
## 7. 测试要点
1. 可回填字段进入 dataset。
2. 必填缺失可识别。
3. 待复核字段可识别。

Some files were not shown because too many files have changed in this diff Show More