Compare commits

..

119 Commits

Author SHA1 Message Date
8f7d1482ed docs: 更新V2项目与agent协作文档 2026-06-11 00:20:40 +08:00
1d0b5338a8 chore(master): 对齐V2忽略规则 2026-06-11 00:15:56 +08:00
bb2fbe272f chore(master): 保留V2环境配置文件 2026-06-11 00:15:20 +08:00
ccd6e8ef4d chore(master): 清理V2合并后的旧版遗留文件 2026-06-11 00:09:54 +08:00
64d09ec30f merge: 合并V2到master
# Conflicts:
#	.gitignore
#	README.md
#	config/asgi.py
#	config/settings.py
#	config/urls.py
#	config/wsgi.py
#	manage.py
#	requirements.txt
#	templates/base.html
#	tests/conftest.py
2026-06-11 00:08:00 +08:00
7def60f1b6 merge: 合并监管信息材料包最新代码到V2 2026-06-10 23:58:24 +08:00
9c6cad481c test(regulatory-info-package): 补充模板生成回归覆盖 2026-06-10 23:56:51 +08:00
1bf8634373 feat(regulatory-info-package): 完善目录页码与组成成分填充 2026-06-10 23:56:40 +08:00
3bcf9647a1 docs(regulatory-info-package): 更新材料包生成设计决策 2026-06-10 23:56:20 +08:00
cf4f4456c4 fix(regulatory-info-package): 使用干净字段模板生成材料包 2026-06-10 20:23:06 +08:00
b728703e67 fix(regulatory-info-package): 完成后追加下载摘要 2026-06-10 19:56:50 +08:00
6d4b519f83 test(regulatory-info-package): 覆盖材料包主链路 2026-06-10 19:50:22 +08:00
dcd829e821 feat(regulatory-info-package): 接入对话和前端卡片 2026-06-10 19:50:03 +08:00
dac8ce3c14 feat(regulatory-info-package): 实现材料包生成工作流 2026-06-10 19:49:44 +08:00
f0286264e2 feat(regulatory-info-package): 增加材料包数据模型 2026-06-10 19:49:25 +08:00
zhiye.sun
a060c23ba7 docs(docs): 调整数据库与详细设计目录编号 2026-06-10 15:15:02 +08:00
zhiye.sun
db0e94cf26 docs(regulatory-info-package): 补充第1章监管信息开发计划 2026-06-10 14:59:24 +08:00
zhiye.sun
dce7045a46 docs(regulatory-info-package): 新增第1章监管信息设计文档 2026-06-10 14:57:40 +08:00
8548b6d2b4 docs:原始材料内容补充 2026-06-10 08:41:50 +08:00
26e675e5d3 fix(chat): 拦截无依据的非业务问题 2026-06-09 08:23:08 +08:00
42187bf8e9 fix(knowledge-base): 停用文档时同步清理索引 2026-06-09 08:22:57 +08:00
18548eb78f fix(file-summary): 修复删除对话时受保护批次阻塞 2026-06-09 08:22:45 +08:00
2b5093040d fix(kb): 完善知识库入库和重建索引 2026-06-08 23:45:34 +08:00
d8cd95e590 docs(report): 更新架构汇报材料 2026-06-08 23:45:06 +08:00
681cb03eb9 chore(config): 切换演示模型配置 2026-06-08 23:44:50 +08:00
ccfa43645e feat(dashboard): 增加首页工作台并调整聊天入口 2026-06-08 22:25:16 +08:00
ef0a9ee13e feat(conversations): 支持删除对话并优化侧栏 2026-06-08 21:39:38 +08:00
2244b69d62 feat(chat): 接入全局知识库上下文 2026-06-08 21:38:12 +08:00
5ecf78c5d6 feat(knowledge-base): 增加全局知识库管理 2026-06-08 21:37:32 +08:00
e6fa738fd5 docs: 补充产品说明和汇报材料 2026-06-08 21:35:13 +08:00
1f56247978 test(feishu): 修正 token 刷新测试 mock 2026-06-07 22:43:40 +08:00
90144c42ac chore(feishu): 提交飞书接入文档和本地配置 2026-06-07 22:36:04 +08:00
f23e403eb8 docs: document feishu configuration 2026-06-07 22:17:40 +08:00
bd9b2e872e feat: add feishu question preview services 2026-06-07 22:16:36 +08:00
be7fbab0a0 feat: add feishu notification test command 2026-06-07 22:14:51 +08:00
1a1b3ee9d4 feat: show feishu notification status 2026-06-07 22:13:56 +08:00
cbc7493df8 feat: wire feishu notifications into workflows 2026-06-07 22:09:47 +08:00
820069f558 feat: add workflow notification dispatcher 2026-06-07 22:07:00 +08:00
bdc1d58c22 feat: add feishu api notification services 2026-06-07 22:05:20 +08:00
da81ce24d0 feat: add feishu notification data models 2026-06-07 22:03:05 +08:00
003ff59268 fix(application-form-fill): 过滤申请表噪声冲突内容 2026-06-07 20:34:24 +08:00
d640ced748 fix(application-form-fill): 清理填表说明并收窄按钮话术 2026-06-07 20:26:32 +08:00
30bdcdbc9c fix(application-form-fill): 代理人字段暂用生产企业信息 2026-06-07 20:19:52 +08:00
57f9181d58 fix(application-form-fill): 新附件先汇总再填表 2026-06-07 20:15:08 +08:00
0ccd69d3f4 fix(application-form-fill): 抽取说明书章节和表格字段 2026-06-07 20:14:53 +08:00
13b543c99d fix(application-form-fill): 清洗填表Word文件名 2026-06-07 20:14:37 +08:00
ac5cf8bf7e fix(application-form-fill): 优先路由填表提示并支持rar预览 2026-06-07 20:14:23 +08:00
82c33e513f feat(frontend): 启用工作流快捷提示按钮 2026-06-07 20:14:04 +08:00
228f30a697 feat(application-form-fill): 合并自动填表完整链路 2026-06-07 18:45:15 +08:00
4ac9c04dbf feat(application-form-fill): 展示自动填表工作流卡片 2026-06-07 18:43:39 +08:00
9be10ef990 feat(application-form-fill): 串联填表工作流产物输出 2026-06-07 18:40:04 +08:00
f35a3ba9b4 feat(application-form-fill): 生成填表 Word 和追溯清单 2026-06-07 18:33:59 +08:00
a48f778e09 feat(application-form-fill): 实现字段抽取与冲突合并 2026-06-07 18:31:34 +08:00
72890783b3 feat(application-form-fill): 实现自动填表模板选择 2026-06-07 18:28:48 +08:00
8694f2d43e feat(application-form-fill): 接入自动填表工作流触发 2026-06-07 18:26:37 +08:00
e48d44f832 feat(application-form-fill): 新增模板配置骨架 2026-06-07 18:23:06 +08:00
74cbe349a8 feat(application-form-fill): 新增自动填表批次模型 2026-06-07 18:20:14 +08:00
f48277aea7 chore(application-form-fill): 记录自动填表开发基线 2026-06-07 18:16:45 +08:00
56225f40d9 docs(application-form-fill): 完善申报文件自动填表设计文档 2026-06-07 17:05:08 +08:00
3e8720e521 feat(regulatory): 按实际处理数量更新节点进度 2026-06-07 13:32:06 +08:00
32d258bb75 feat(regulatory): 输出法规核查过程日志 2026-06-07 13:23:55 +08:00
0f9fb980f2 fix(regulatory): 为LLM复核超时增加重试 2026-06-07 13:03:24 +08:00
9e27c4c684 fix(regulatory): 修复无标签文档适用条件回显 2026-06-07 12:29:22 +08:00
1b4a10b5ba fix(regulatory): 自动执行法规核查前置汇总 2026-06-07 12:17:20 +08:00
911e5378e8 fix(regulatory): 修复条件确认实时轮询和重复提示 2026-06-07 12:09:02 +08:00
8f16675a92 feat(regulatory): 为核查流程增加LLM复核记录 2026-06-07 11:52:54 +08:00
945669b9c2 feat(regulatory): 增加条件字段LLM复核 2026-06-07 11:46:55 +08:00
a34684e490 fix(regulatory): 修复换行产品名称提取不全 2026-06-07 11:30:48 +08:00
72f18167c5 fix(regulatory): 轮询时加载条件确认卡 2026-06-07 11:27:12 +08:00
b8d711729d feat(regulatory): 支持按附件4章节核查 2026-06-07 11:17:57 +08:00
f46d9c5be6 fix(regulatory): 缺失问题标题显示章节序号 2026-06-07 11:12:19 +08:00
462d3ec5f5 fix(regulatory): 优先从附件字段识别适用条件 2026-06-07 10:40:05 +08:00
12b476a8ef fix(regulatory): 将条件确认移入对话区 2026-06-07 10:37:12 +08:00
48d94884b9 docs(regulatory): 补充第二批附件4开发计划 2026-06-07 10:31:39 +08:00
4e46f27c28 feat(regulatory): 完善复核前端入口 2026-06-07 09:40:18 +08:00
d39e3fe2d5 feat(regulatory): 增加mock通知留痕 2026-06-07 09:35:24 +08:00
d88d642f6a feat(regulatory): 增加整改复核闭环 2026-06-07 09:32:39 +08:00
1bdc7322cf feat(regulatory): 对齐附件4目录核查规则 2026-06-07 09:27:42 +08:00
bbd2d3532a feat(regulatory): 增加适用条件确认暂停恢复 2026-06-07 09:19:31 +08:00
bd805203f1 feat(regulatory): 展示法规核查工作流卡片 2026-06-07 00:43:18 +08:00
4c28466fe4 feat(regulatory): 增加风险归并与核查报告导出 2026-06-07 00:39:33 +08:00
ec89e62661 feat(regulatory): 增加法规核查基础服务 2026-06-07 00:36:18 +08:00
44d31d2a14 feat(regulatory): 接入法规核查触发与工作流骨架 2026-06-07 00:34:12 +08:00
26490f7c46 feat(regulatory): 增加本地法规RAG索引检索 2026-06-07 00:30:53 +08:00
2a4dd6cfab feat(regulatory): 增加法规规则版本检查 2026-06-07 00:26:19 +08:00
f52dcc197d feat(regulatory): 新增法规核查模型与工作流通用字段 2026-06-07 00:23:58 +08:00
665403735a chore: 确认法规核查基线回归通过 2026-06-07 00:20:16 +08:00
f179749cfb docs(todo): 补充第二阶段暂缓事项 2026-06-07 00:11:24 +08:00
e58da66853 docs(regulatory-review): 拆分法规核查开发计划 2026-06-07 00:11:01 +08:00
df3f393dd2 feat(attachments): 新增附件管理页面 2026-06-06 22:45:48 +08:00
0fca20756b feat(attachments): 补充附件管理接口 2026-06-06 22:45:14 +08:00
3c6ec67371 fix(file-summary): 调整工作流批次轮播展示 2026-06-06 22:20:26 +08:00
7e561ea213 fix(file-summary): 同步压缩包工作流状态与结果刷新 2026-06-06 19:45:49 +08:00
daa0642142 fix(agent): 增强 LLM 流式回复兜底 2026-06-06 19:45:13 +08:00
c78ff3a1fd fix(file-summary): 避免无效 Office 文件触发 COM 统计 2026-06-06 19:44:42 +08:00
460d418921 fix(file-summary): 补强 Office 页数统计 2026-06-06 17:57:08 +08:00
54c37edf19 fix(frontend): 修复会话补充与工作流刷新 2026-06-06 17:56:54 +08:00
fa77c68d77 feat(agent): 增加 LLM 路由与诊断日志 2026-06-06 17:56:41 +08:00
47b5ad1054 feat(attachments): 增加附件阅读解析能力 2026-06-06 16:37:54 +08:00
fd88ff4652 fix(frontend): 调整审核页布局与报告渲染 2026-06-06 16:37:40 +08:00
b1a336d019 fix(file-summary): 修复报告导出下载 2026-06-06 16:37:29 +08:00
311eb1b129 test(file-summary): 增加 Playwright 端到端测试 2026-06-06 10:32:18 +08:00
77db0d978a merge: 自动汇总文件目录页数 2026-06-06 10:28:14 +08:00
684682f86d docs(file-summary): 补充部署存储说明 2026-06-06 10:27:23 +08:00
a917a18ca1 feat(file-summary): 增加前端汇总面板 2026-06-06 10:25:11 +08:00
61bd31790b feat(file-summary): 生成汇总报告和导出下载 2026-06-06 01:22:49 +08:00
18d045d487 feat(file-summary): 实现文件处理技能链路 2026-06-06 01:20:26 +08:00
51e7c0c007 feat(file-summary): 接入文件汇总工作流触发 2026-06-06 01:16:22 +08:00
eb87d9040d feat(file-summary): 实现对话附件上传接口 2026-06-06 01:13:23 +08:00
855afcdee3 feat(file-summary): 添加文件汇总数据模型 2026-06-06 01:11:11 +08:00
b96ab1303a docs(file-summary): 对齐文档路径和附件模型命名 2026-06-06 00:56:41 +08:00
ce574048a4 docs(regulatory-review): 增加法规核查设计文档 2026-06-06 00:55:54 +08:00
f1534b6165 docs(requirements): 增加法规核查整改闭环需求 2026-06-05 23:40:18 +08:00
b7a3d512c0 docs(materials): 整理文档目录并补充法规材料 2026-06-05 23:39:38 +08:00
zhiye.sun
38529393cc docs(file-summary): 完善自动汇总流程设计与开发计划 2026-06-05 16:48:31 +08:00
933799a882 chore(config): 纳入本地硅基流动环境配置 2026-06-05 00:13:20 +08:00
a0e5e4c301 docs(source): 补充试题原始材料转换稿 2026-06-05 00:12:47 +08:00
7ab5aad938 feat(chat): 支持流式回复与用户节点导航 2026-06-05 00:11:53 +08:00
84e045f5ab feat(demo): 初始化审核智能体演示基线 2026-06-04 23:42:37 +08:00
450 changed files with 45349 additions and 24057 deletions

27
.env Normal file
View File

@@ -0,0 +1,27 @@
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=*
# SiliconFlow OpenAI-compatible API
LLM_PROVIDER=openai_compatible
LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
LLM_BASE_URL=https://api.siliconflow.cn/v1
LLM_MODEL=deepseek-ai/DeepSeek-V4-Pro
SILICONFLOW_EMBEDDING_MODEL=Qwen/Qwen3-Embedding-8B
SILICONFLOW_EMBEDDING_DIMENSIONS=4096
# SiliconFlow embedding model for RAG
EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
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
FEISHU_NOTIFY_ENABLED=true
FEISHU_APP_ID=cli_aaafcc59f4b85bc2
FEISHU_APP_SECRET=OO8GKpjqTO3bHAUwCiSmRgW4FqsNB5Qa
FEISHU_DEFAULT_USER_OPEN_ID=ou_a6015773781a117eb7d8995efa5e4590
FEISHU_DEFAULT_TARGET_NAME=bruce
PUBLIC_BASE_URL=http://127.0.0.1:8000

View File

@@ -1,18 +0,0 @@
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=*
# OpenAI-compatible LLM API
LLM_API_KEY=your_llm_api_key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4.1-mini
# Embedding model for RAG
# Leave EMBEDDING_API_KEY empty to reuse LLM_API_KEY if desired.
EMBEDDING_API_KEY=
EMBEDDING_BASE_URL=
EMBEDDING_MODEL=text-embedding-3-small
SCENARIO_CONFIG_DIR=configs
UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma

35
.gitignore vendored
View File

@@ -1,37 +1,10 @@
# IDE
.idea/
.vscode/
# Python
.venv/
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# Virtual environments
.venv/
venv/
env/
# Django / local data
*.sqlite3
db.sqlite3
data/uploads/
data/chroma/
media/
staticfiles/
# Environment variables
.env
.env.local
# Test / coverage
media/
.pytest_cache/
.coverage
htmlcov/
# OS
.DS_Store
Thumbs.db
.tmp/
.idea/

217
AGENTS.md
View File

@@ -1,190 +1,65 @@
# AGENTS.md
# Agent Collaboration Guide
本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格、实现顺序和文档同步要求。
This guide is for Codex or other coding agents working in this repository.
## 项目定位
## Project Summary
当前项目已根据真实笔试题切换为:
DEMO-AGENT V2 is a Django application for IVD registration document review. The main app is `review_agent`, with workflow modules for file summaries, regulatory review, application form filling, regulatory information package generation, knowledge-base management, and Feishu notification/question handling.
```text
试剂盒临床注册文件准备与审核智能体平台
```
The current `master` branch is intended to match `V2`.
优先目标:
## Important Paths
- 围绕 NMPA 体外诊断试剂注册申报资料场景完成可演示闭环。
- 保证本地可运行、可测试、可讲解。
- 保证代码结构清楚业务流程能从页面、服务层、Agent Core 和审计日志串起来。
- 允许在保留主架构边界前提下进行大幅度业务重构。
| Path | Purpose |
| --- | --- |
| `config/settings.py` | Django settings and environment loading |
| `config/urls.py` | Page routes and included API routes |
| `review_agent/models.py` | Shared Django models |
| `review_agent/urls.py` | Review-agent API routes |
| `review_agent/file_summary/` | Attachment handling, file inventory, page count, exports |
| `review_agent/regulatory_review/` | NMPA review workflow, rules, RAG, risk and issue review |
| `review_agent/application_form_fill/` | Application form field extraction and Word filling |
| `review_agent/regulatory_info_package/` | Chapter 1 regulatory information package generation |
| `review_agent/notifications/` | Notification dispatch and Feishu adapters |
| `templates/` | Django templates |
| `static/` | Frontend CSS and JavaScript |
| `docs/` | Requirements, designs, plans, source materials |
| `tests/` | pytest suite |
## 架构原则
## Development Rules
采用:
- Prefer the existing Django patterns in `review_agent` before introducing new abstractions.
- Keep workflow modules independent. Do not fold regulatory package, application form fill, or regulatory review logic into unrelated modules.
- Preserve user data and generated artifacts. Do not delete `media/`, `.tmp/`, `db.sqlite3`, or `.env` unless explicitly asked.
- Treat `.env` as environment-specific configuration. It is currently tracked because this project needs a complete V2 state, but do not print secret values in logs or docs.
- For Word/PDF/Excel handling, use structured libraries already in the project instead of ad hoc text parsing when possible.
- For frontend work, keep the current workbench style: restrained, task-focused, evidence-first, and consistent with existing templates and CSS.
```text
Django 单体 + 独立 Agent Core + Docker Compose
```
核心边界:
- Django 负责页面、数据库、文件上传、导出文件、审计日志、通知留痕和后台管理。
- Agent Core 负责 RAG、Prompt、工具调用、治理配置、模型适配和结构化输出。
- Django View 不直接写大模型调用、向量检索和工具执行细节。
- Agent Core 不依赖 Django View。
- 业务流程优先放在各模块 `services.py`View 只负责请求处理、消息提示和模板渲染。
## 模块边界
### config
负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径、Chroma 路径和部署配置。
### apps.scenarios
负责注册审核任务配置读取、场景元信息展示和非法 YAML 配置容错。
### apps.documents
负责资料包导入、上传文件记录、压缩包展开、文本抽取、章节点归类、页数统计、资料包搜索、异常提示、触发 RAG 入库和导出记录维护。
### apps.chat
负责审核工作台、会话列表、用户输入表单、文档范围选择、调用 Agent Core、展示结构化审核结果、补传资料和触发 Word 导出。
### apps.audit
负责审计日志模型、日志写入服务、通知留痕、处理历史列表和详情页,以及审核留痕展示。
### apps.platform_ui
负责知识库治理台、MCP 中心、Skill 工作室和审核指挥台等演示型平台页面。该模块可以展示治理对象和 mock 业务态势,但不要把主业务执行逻辑写进这里。
### agent_core
负责注册审核 Agent 编排、RAG、工具注册、治理配置读取、LLM / Embedding Provider 和结构化输出。
## 当前实现状态
- Django 单体骨架已完成,根路径 `/` 默认进入审核智能体。
- 当前主入口为 `审核智能体 / 资料包 / 知识库治理台 / 处理历史`,底层场景列表保留在 `/scenarios/`
- 通用场景 YAML、Chat、Documents、Audit、Platform UI 和 Agent Core 已具备可重构基础。
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR。
- 资料包会自动绑定会话,标题优先使用解析出的产品名称。
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册、RAG fallback / Chroma 双路径和 OpenAI 兼容 Provider。
- Word 导出已支持生成最小 `.docx`,并按风险状态形成正式版或草稿版。
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。
- 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。
- 当前需求文档已按真实笔试题重写到 `docs/需求分析/`
- 当前详细设计文档放在 `docs/详细设计/`,原型资料放在 `docs/原型设计/`
## 推荐开发顺序
后续新增或重构功能时,建议按以下顺序推进:
1. 先确认需求文档、详细设计或当前页面是否需要同步调整。
2. 补或调整服务层测试、Agent Core 测试或页面关键展示测试。
3. 在对应模块的 `services.py``agent_core` 中实现核心逻辑。
4. View 只接入服务层结果,模板只做直接展示。
5. 若涉及用户可见入口同步更新模板、README 和相关需求/设计文档。
6. 运行相关测试,再运行核心回归验证。
7. 按逻辑分组使用 Conventional Commit 风格提交到本地。
## 编码约定
- Python 代码优先保持简单、直观、可讲解。
- 不为了抽象而抽象。
- View 只做请求处理和页面渲染,复杂逻辑放到 `services.py``agent_core`
- 配置化优先,业务场景不要写死在代码中。
- 工具函数必须通过 Tool Registry 注册。
- 模型调用必须通过 LLM Provider不允许散落在业务代码中。
- RAG 入库、检索和 Embedding 逻辑必须留在 Agent Core 或 Documents 服务边界内。
- 审计日志要记录成功和失败两种情况。
- 不在日志中保存 API Key、密钥或敏感环境变量。
- 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。
- 页面模板优先直接表达业务信息,不在模板中堆积复杂逻辑判断。
- 测试优先覆盖服务层和核心编排逻辑,再由页面测试补齐关键展示行为。
## 文档约定
需求文档放在:
```text
docs/需求分析/
```
详细设计文档放在:
```text
docs/详细设计/
```
原型设计文档放在:
```text
docs/原型设计/
```
场景配置放在:
```text
configs/
```
重要设计变更需要同步更新:
- `README.md`
- `docs/需求分析/1.V1总需求文档.md`
- 相关模块需求文档
- 相关详细设计文档
- `AGENTS.md` 中的协作边界与当前实现状态
推荐同步文档的场景:
- 新增用户可见页面或流程。
- 调整根路径、URL、环境变量、生效方式或部署命令。
- 修改资料包、会话、审计、通知或导出模型字段。
- 修改 Agent Core 的输入输出合约、结构化输出类型或节点状态口径。
- 新增工具、治理配置字段、场景配置字段或模板映射字段。
- 改变测试隔离策略、真实模型调用策略或 Docker 启动方式。
## 测试与验证约定
每个阶段至少验证:
- Django 可以启动或 `python manage.py check` 通过。
- 根路径和审核智能体页面可以访问。
- 资料包导入流程可执行。
- 对话流程可执行,出错时页面有清晰提示。
- 审计日志能记录成功和失败。
- Docker Compose 配置有效。
当前默认验证命令:
## Common Commands
```bash
pytest
python manage.py check
docker compose config
python manage.py migrate
python manage.py runserver
pytest
pytest tests -k regulatory_info_package
pytest tests/test_feishu_*.py
```
补充约定:
## Verification Notes
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
- 涉及页面结构时,至少补或更新对应页面测试。
- 涉及导出文件时,需要验证导出记录和下载路径。
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
Before claiming a code change is complete, run at least the narrow test set for the touched workflow. For broad changes, run `python manage.py check` and `pytest`.
## 不优先做的事项
Known current state:
第一版不要优先做:
- `python manage.py check` passes.
- `pytest tests -k regulatory_info_package` passes.
- Full `pytest` may still include a few historical failures unrelated to the latest regulatory-info-package merge; report exact failures if they remain.
- React / Vue 前端。
- 多租户。
- 复杂 RBAC。
- 完整工作流引擎。
- 深度 Dify 集成。
- 微服务拆分。
- 分布式任务队列。
- 真实飞书发送链路。
## Git Notes
- Check `git status --short --branch` before editing.
- Do not reset or revert user changes unless explicitly asked.
- Keep commits grouped by logical concern: docs, feature behavior, tests, cleanup.
- When merging `V2` and `master`, remember these histories were unrelated before the merge. Prefer preserving the V2 tree when the goal is to keep `master` as the complete V2 state.
这些内容可以作为后续增强,不应影响 V1 快速成型。

View File

@@ -1,15 +0,0 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
EXPOSE 8000
CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]

55
PRODUCT.md Normal file
View File

@@ -0,0 +1,55 @@
# Product
## Product Name
DEMO-AGENT V2
## Users
注册资料准备人员、法规审核人员、项目管理人员和演示评审人员。用户通常需要在资料量大、文件格式复杂、法规要求多、证据链容易断裂的情况下快速完成资料整理、核查、整改和申报文件准备。
## Product Purpose
DEMO-AGENT V2 是一个体外诊断试剂注册资料审核工作台。它把上传资料、文件汇总、法规规则核查、RAG 依据检索、风险预警、整改复核、申报表填充和第 1 章监管信息材料包生成组织成可追溯的工作流。
产品目标不是替代法规负责人作最终判断,而是把机械整理、跨文件检索、字段预填、问题归类和证据追溯做扎实,让负责人把精力放在判断和确认上。
## Core Workflows
| 工作流 | 目标产物 |
| --- | --- |
| 文件汇总 | 文件目录、页数、类型、批次状态、Markdown/Excel 导出 |
| 法规核查 | 缺失项、风险项、一致性问题、整改建议、复核记录 |
| 知识库管理 | 用户资料索引、内置法规资料检索、引用片段 |
| 申报表填充 | 预填申报表、字段来源、冲突和缺失提示 |
| 第 1 章监管信息材料包 | CH1.2、CH1.4、CH1.5、CH1.11 等 docx 文件和 zip |
| 飞书通知与问答 | 批次完成通知、问题模拟查询、系统入口链接 |
## Brand Personality
克制、可信、清晰。界面应服务审核任务,优先呈现状态、证据和下一步动作。
## Anti-References
避免营销页式大标题、装饰性卡片堆叠、过度动画、过亮渐变和不必要的视觉噪声。不要把审核工作台做成展示型官网,也不要隐藏关键状态或证据来源。
## Design Principles
- 证据优先:每个结论都应能回到来源文件、规则或检索片段。
- 状态清楚:批次、节点、风险、异常和导出结果要一眼可辨。
- 操作克制:页面提供必要动作,不把审核工作做成复杂后台。
- 人工确认:系统负责预处理和提示,法规负责人保留最终确认权。
- 可追溯:导出文件、消息、节点事件和问题状态都应能回到批次。
- 复用现有模式:新增页面沿用当前工作台导航、面板、表格和按钮体系。
## Accessibility & Inclusion
默认按 WCAG AA 方向处理对比度、键盘可访问和清晰标签。动效仅用于状态反馈,并尊重减少动态效果需求。
## Operational Boundaries
- `.env` 可用于本地和演示环境,但包含密钥时应限制分发范围。
- LLM、飞书、Word COM、7z、RAG 索引等外部能力必须允许 mock 或降级。
- 生成的申报和监管信息文件是预生成结果,需要人工复核后再用于正式申报。
- 默认存储使用 SQLite 和本地 `media/`,生产环境应迁移到持久化卷和受控备份。

319
README.md
View File

@@ -1,256 +1,123 @@
# 试剂盒临床注册文件准备与审核智能体平台
# DEMO-AGENT V2
用于复试展示的体外诊断试剂注册申报资料准备与审核系统
DEMO-AGENT V2 是一个面向体外诊断试剂注册资料准备与审核的 Django 工作台。系统把资料上传、文件目录汇总、法规核查、知识库检索、风险提示、整改复核、申报表自动填充和第 1 章监管信息材料包生成组织到同一个可追溯的审核流程中
项目已按真实笔试题收口为 NMPA 境内第三类体外诊断试剂注册申报资料场景,重点演示“资料包导入 -> 审核智能体执行 -> 结构化结果 -> Word 导出 -> 通知与审计留痕”的本地闭环
当前 `master` 已与 `V2` 内容对齐,是项目主线
## 核心理念
## 核心能力
```text
注册审核 Agent = 任务配置 + 资料包 + 法规/业务知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
```
| 能力 | 说明 |
| --- | --- |
| 审核工作台 | 登录后进入首页,查看对话、附件、知识库、批次和处理状态 |
| 对话式工作流 | 在 `/chat/` 中围绕当前对话上传资料、触发汇总、法规核查和生成任务 |
| 文件汇总 | 读取 PDF、Word、Excel、PowerPoint、压缩包等资料生成目录、页数、类型和导出结果 |
| NMPA 法规核查 | 基于规则、文本抽取、RAG 检索和 LLM 复核生成问题、风险和整改建议 |
| 知识库管理 | 上传管理资料、重建索引、检索引用片段,并过滤已停用或删除文档 |
| 申报表填充 | 从说明书和资料中抽取关键字段,生成预填申报表和追溯结果 |
| 第 1 章监管信息材料包 | 生成 CH1.2、CH1.4、CH1.5、CH1.11 等监管信息文件和 zip 产物 |
| 飞书通知与问答 | 支持企业自建应用消息通知,并预留飞书问答模拟命令 |
## 技术路线
## 页面入口
V1 采用:
- Django 单体应用
- 独立 Agent Core 模块
- SQLite
- Chroma / fallback 检索双路径
- Django Templates
- Docker Compose
- OpenAI API 兼容的 LLM 与 Embedding 接口
默认不强依赖 Dify。系统保留 Provider / Adapter 边界,后续可接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。
## 当前业务主线
1. 导入注册资料包,支持单文件、多文件和压缩包。
2. 解析文件元数据、页数、章节点和产品名称。
3. 自动创建资料包批次,并绑定审核会话。
4. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告。
5. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析。
6. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口。
7. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史。
8. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕。
## 当前产品入口
当前根路径 `/` 会直接进入审核智能体工作台,便于复试演示聚焦主链路。
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 审核智能体 | `/``/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/` | 维护后台模型数据 |
## 模块划分
```text
config
apps.scenarios
apps.documents
apps.chat
apps.audit
apps.platform_ui
agent_core
```
职责边界:
- `config` 负责 Django 配置、路由入口、环境变量、静态资源和上传路径。
- `apps.scenarios` 负责读取 YAML 场景配置,非法配置可被跳过并展示错误摘要。
- `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`
| 页面 | 路径 |
| --- | --- |
| 登录页 | `http://127.0.0.1:8000/login/` |
| 首页 | `http://127.0.0.1:8000/` |
| 审核智能体 | `http://127.0.0.1:8000/chat/` |
| 知识库管理 | `http://127.0.0.1:8000/knowledge-base/` |
| 附件管理 | `http://127.0.0.1:8000/attachments/` |
| 管理后台 | `http://127.0.0.1:8000/admin/` |
## 项目结构
```text
DEMO-AGENT/
manage.py
requirements.txt
Dockerfile
docker-compose.yml
.env.example
README.md
AGENTS.md
config/
apps/
audit/
chat/
documents/
platform_ui/
scenarios/
agent_core/
rag/
schemas/
tools/
configs/
document_review.yaml
governance.yaml
knowledge_qa.yaml
quality_analysis.yaml
risk_audit.yaml
ticket_assistant.yaml
data/
uploads/
chroma/
db.sqlite3
docs/
需求分析/
详细设计/
原型设计/
原始材料/
templates/
tests/
config/ Django 配置和总路由
review_agent/ 核心业务应用
application_form_fill/ 申报表自动填充
file_summary/ 文件汇总、附件和导出
regulatory_review/ 法规核查与整改复核
regulatory_info_package/ 第 1 章监管信息材料包生成
notifications/ 飞书通知和消息适配
feishu_questions/ 飞书问答预留能力
static/ 前端脚本和样式
templates/ Django 模板
docs/ 需求、设计、开发计划和原始材料
tests/ pytest 测试
```
## 已落地能力
- 根路径已重定向到审核智能体,降低演示入口复杂度。
- 资料包导入支持 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避免本地真实模型密钥导致测试走网络。
## 启动方式
推荐首次本地启动:
## 本地运行
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver
```
Docker 启动:
项目会自动读取仓库根目录 `.env`。当前仓库保留了 V2 的 `.env` 文件;后续如果要面向外部协作,请先确认其中没有不应公开的密钥。
## 常用环境变量
| 变量名 | 用途 |
| --- | --- |
| `DJANGO_SECRET_KEY` | Django secret key |
| `DJANGO_DEBUG` | 是否开启调试模式 |
| `DJANGO_ALLOWED_HOSTS` | 允许访问的主机列表 |
| `LLM_PROVIDER` | LLM provider 选择 |
| `LLM_API_KEY` | LLM API key |
| `LLM_BASE_URL` | OpenAI 兼容 LLM API 地址 |
| `LLM_MODEL` | 默认对话/抽取模型 |
| `SILICONFLOW_API_KEY` | SiliconFlow API key默认可复用 `LLM_API_KEY` |
| `SILICONFLOW_EMBEDDING_MODEL` | 法规 RAG 使用的 embedding 模型 |
| `SILICONFLOW_EMBEDDING_DIMENSIONS` | embedding 维度 |
| `REGULATORY_RAG_CHROMA_PATH` | 法规 RAG Chroma 存储路径 |
| `REGULATORY_RAG_COLLECTION` | 法规 RAG collection 名称 |
| `FEISHU_NOTIFY_ENABLED` | 是否启用真实飞书通知 |
| `FEISHU_APP_ID` | 飞书应用 App ID |
| `FEISHU_APP_SECRET` | 飞书应用 App Secret |
| `FEISHU_DEFAULT_USER_OPEN_ID` | 默认飞书接收人 open_id |
| `PUBLIC_BASE_URL` | 飞书消息中的系统入口根地址 |
## 外部依赖
Python 依赖见 `requirements.txt`,主要包括:
- Django
- PyYAML
- httpx
- chromadb
- pypdf
- python-docx
- python-pptx
- openpyxl / xlrd
- py7zr
- playwright
文件汇总支持 `.7z``.rar` 时,运行环境还需要可用的 `7z`/`p7zip` 命令。LibreOffice 不是必需依赖,仅作为后续增强老格式文档处理能力的可选项。
## 常用命令
```bash
docker compose up --build
```
Docker Compose 会读取根目录 `.env`,并挂载 `./data``./configs`
## 环境变量
项目通过根目录 `.env` 和系统环境变量读取配置。`.env.example` 只作为模板,不应提交真实密钥。
```env
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=*
LLM_API_KEY=your_llm_api_key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4.1-mini
EMBEDDING_API_KEY=
EMBEDDING_BASE_URL=
EMBEDDING_MODEL=text-embedding-3-small
SCENARIO_CONFIG_DIR=configs
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma
```
说明:
- `EMBEDDING_API_KEY` 为空时自动复用 `LLM_API_KEY`
- `EMBEDDING_BASE_URL` 为空时自动复用 `LLM_BASE_URL`
- Django settings 初始化时会自动加载根目录 `.env`
- 测试环境会在 `tests/conftest.py` 中固定 Mock Provider避免误调用真实 LLM。
## 测试与验证
常用验证命令:
```bash
pytest
python manage.py check
docker compose config
pytest
pytest tests -k regulatory_info_package
pytest tests/test_feishu_*.py
python manage.py send_test_feishu_notification --username owner
python manage.py feishu_question_simulate --username owner "查最新法规核查"
```
当前测试覆盖:
- 项目配置、根路由和核心页面可访问性。
- 场景配置读取、非法 YAML 容错和场景列表展示。
- 资料包导入、压缩包展开、文档解析、入库状态和异常提示。
- 会话创建、对话提交、文档范围传递、结构化结果展示和 Word 导出。
- 审计日志落库、筛选、详情展示、通知留痕和敏感信息脱敏。
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback、工具注册、LLM / Embedding Provider 请求构造。
- 平台治理页、指挥台、知识库、MCP 中心和 Skill 工作室展示。
已知情况:当前全量 `pytest` 中仍有少量历史测试与当前页面/LLM 调用策略不完全一致;监管信息材料包主链路测试已通过。
## 文档入口
- [V1 总需求文档](docs/需求分析/1.V1总需求文档.md)
- [需求重构总览与待确认事项](docs/需求分析/0.需求重构总览与待确认事项.md)
- [Config 模块需求分析](docs/需求分析/1.config模块需求分析.md)
- [Scenarios 模块需求分析](docs/需求分析/2.scenarios模块需求分析.md)
- [Documents 模块需求分析](docs/需求分析/3.documents模块需求分析.md)
- [Chat 模块需求分析](docs/需求分析/4.chat模块需求分析.md)
- [Audit 模块需求分析](docs/需求分析/5.audit模块需求分析.md)
- [Agent Core 模块需求分析](docs/需求分析/6.agent_core模块需求分析.md)
- [业务确认问答清单](docs/需求分析/9.业务确认问答清单.md)
- [资料包导入与目录汇总详细设计](docs/详细设计/1.资料包导入与目录汇总.md)
- [法规完整性检查详细设计](docs/详细设计/2.法规完整性检查.md)
- [字段抽取与统一字段池详细设计](docs/详细设计/3.字段抽取与统一字段池.md)
- [一致性核查详细设计](docs/详细设计/4.一致性核查.md)
- [风险预警详细设计](docs/详细设计/5.风险预警.md)
- [Word 回填导出详细设计](docs/详细设计/6.Word回填导出.md)
- [飞书通知详细设计](docs/详细设计/7.飞书通知.md)
- [注册审核平台整体原型设计](docs/原型设计/1.整体原型设计.md)
- [单文件演示站 HTML](docs/原型设计/registration-prototype-demo.html)
- [协作与编码约定](AGENTS.md)
- [产品说明](PRODUCT.md)
- [Agent 协作约定](AGENTS.md)
- [docs 文档索引](docs/README.md)
- [需求分析](docs/1.需求分析)
- [功能设计](docs/2.功能设计)
- [数据库设计](docs/3.数据库设计)
- [详细设计](docs/4.详细设计)
- [开发计划](docs/5.开发计划)
## 复试改题流程
拿到新题目后:
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 快速成型。

View File

@@ -1,142 +0,0 @@
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,201 +0,0 @@
from dataclasses import dataclass
import json
import os
from urllib.error import URLError
from urllib.request import Request, urlopen
class LLMConfigurationError(ValueError):
"""LLM 调用缺少关键配置时抛出的业务异常。"""
class EmbeddingConfigurationError(ValueError):
"""Embedding 调用缺少关键配置时抛出的业务异常。"""
@dataclass
class LLMResponse:
"""
统一的模型响应对象。
Agent Core 的 Orchestrator 只依赖这一个结构,而不直接感知底层供应商差异。
"""
content: str = ""
model_name: str = ""
success: bool = True
error: Exception | None = None
class MockLLMProvider:
"""
本地和测试默认使用的 Mock Provider。
设计目标不是拟真对话,而是提供一个稳定、可断言、可结构化解析的响应,
让前后端在未接入真实模型时也能完整演示链路。
"""
def __init__(self, model_name: str = "mock-model"):
self.model_name = model_name or "mock-model"
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
user_content = _find_last_user_message(messages)
return LLMResponse(
content=json.dumps(
{
"answer": f"模拟回答:{user_content}",
"confidence": "medium",
"references": [],
},
ensure_ascii=False,
),
model_name=self.model_name,
success=True,
)
class OpenAICompatibleProvider:
"""调用 OpenAI Chat Completions 兼容接口的 Provider。"""
def __init__(self, api_key: str, base_url: str, model_name: str):
self.api_key = api_key
self.base_url = base_url
self.model_name = model_name
def generate(self, messages: list[dict], response_format: dict | None = None) -> LLMResponse:
if not self.api_key:
return LLMResponse(
model_name=self.model_name,
success=False,
error=LLMConfigurationError("LLM_API_KEY 未配置,无法调用 OpenAI 兼容模型接口"),
)
payload = {
"model": self.model_name,
"messages": messages,
}
if response_format:
payload["response_format"] = response_format
try:
data = _post_json(
base_url=self.base_url,
endpoint="chat/completions",
api_key=self.api_key,
payload=payload,
)
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
return LLMResponse(
content=content,
model_name=data.get("model", self.model_name),
success=True,
)
except Exception as exc:
return LLMResponse(model_name=self.model_name, success=False, error=exc)
class OpenAICompatibleEmbeddingProvider:
"""调用 OpenAI Embeddings 兼容接口的 Provider。"""
def __init__(self, api_key: str, base_url: str, model_name: str):
self.api_key = api_key
self.base_url = base_url
self.model_name = model_name
def embed_texts(self, texts: list[str]) -> list[list[float]]:
if not self.api_key:
raise EmbeddingConfigurationError("EMBEDDING_API_KEY 未配置,无法调用 OpenAI 兼容 Embedding 接口")
data = _post_json(
base_url=self.base_url,
endpoint="embeddings",
api_key=self.api_key,
payload={"model": self.model_name, "input": texts},
)
return [item.get("embedding", []) for item in data.get("data", [])]
def create_llm_provider(config: dict | None = None):
"""
根据配置创建 LLM Provider。
默认策略:
- 明确指定 `LLM_PROVIDER=mock` 时使用 Mock
- 未指定但存在 `LLM_API_KEY` 时默认走 OpenAI 兼容接口
- 否则回退到 Mock保证页面仍可闭环
"""
config = config or {}
provider_name = _resolve_provider_name(config)
model_name = config.get("LLM_MODEL", "mock-model")
if provider_name == "mock":
return MockLLMProvider(model_name=model_name)
return OpenAICompatibleProvider(
api_key=config.get("LLM_API_KEY", ""),
base_url=config.get("LLM_BASE_URL", "https://api.openai.com/v1"),
model_name=model_name,
)
def create_embedding_provider(config: dict | None = None):
"""
创建 Embedding Provider。
当未单独配置 Embedding Key 或 Base URL 时,会自动复用 LLM 配置,
以减少复试演示时的环境变量负担。
"""
config = config or {}
return OpenAICompatibleEmbeddingProvider(
api_key=config.get("EMBEDDING_API_KEY", config.get("LLM_API_KEY", "")),
base_url=config.get("EMBEDDING_BASE_URL", config.get("LLM_BASE_URL", "https://api.openai.com/v1")),
model_name=config.get("EMBEDDING_MODEL", "text-embedding-3-small"),
)
def get_runtime_llm_config(overrides: dict | None = None) -> dict:
"""
从环境变量读取运行时配置。
Agent Core 通过这一层读取模型配置,避免直接依赖 Django settings
这样本模块在独立脚本、测试和 Django 环境中都可复用。
"""
config = {
"LLM_PROVIDER": os.environ.get("LLM_PROVIDER", ""),
"LLM_API_KEY": os.environ.get("LLM_API_KEY", ""),
"LLM_BASE_URL": os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
"LLM_MODEL": os.environ.get("LLM_MODEL", "mock-model"),
}
if overrides:
config.update(overrides)
return config
def _resolve_provider_name(config: dict) -> str:
"""统一推导当前应启用的 Provider 名称。"""
provider_name = config.get("LLM_PROVIDER")
if provider_name:
return provider_name
return "openai_compatible" if config.get("LLM_API_KEY") else "mock"
def _find_last_user_message(messages: list[dict]) -> str:
"""从消息列表中提取最后一条用户输入,用于 Mock Provider 回显。"""
for message in reversed(messages):
if message.get("role") == "user":
return message.get("content", "")
return ""
def _post_json(base_url: str, endpoint: str, api_key: str, payload: dict) -> dict:
"""向 OpenAI 兼容接口发送 JSON POST 请求并解析响应。"""
url = f"{base_url.rstrip('/')}/{endpoint}"
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except URLError as exc:
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc

View File

@@ -1,267 +0,0 @@
import json
import time
from .governance import load_governance_config
from .llm_provider import create_llm_provider, get_runtime_llm_config
from .results import AgentResult
from .structured_output import (
build_response_schema_hint,
extract_answer_from_structured_output,
parse_structured_output,
)
from .tool_registry import run_declared_tools
from .rag.retriever import retrieve
def run_agent(scenario_config: dict, user_input: str, options: dict | None = None) -> AgentResult:
"""
执行当前场景的最小 Agent 闭环。
处理顺序保持和设计文档一致:
1. 读取场景配置
2. 执行 RAG 检索
3. 执行声明式工具
4. 构造 Prompt 并调用 LLM
5. 解析结构化结果
6. 统一返回 AgentResult
"""
started_at = time.perf_counter()
options = options or {}
output_type = scenario_config.get("output", {}).get("type", "general_answer")
references = _collect_references(scenario_config=scenario_config, user_input=user_input, options=options)
tool_calls = run_declared_tools(scenario_config.get("tools", []), user_input)
messages = build_messages(
scenario_config=scenario_config,
user_input=user_input,
references=references,
tool_calls=tool_calls,
)
provider = options.get("llm_provider") or create_llm_provider(
get_runtime_llm_config(options.get("llm_config"))
)
llm_response = provider.generate(
messages,
response_format=build_response_schema_hint(output_type),
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
if not llm_response.success:
return AgentResult(
answer="模型调用失败,请检查配置或稍后重试。",
structured_output={},
references=references,
tool_calls=tool_calls,
raw_output="",
model_name=llm_response.model_name or "unknown-model",
latency_ms=latency_ms,
status="failed",
error=str(llm_response.error or "未知模型错误"),
conversation_id=str(options.get("conversation_id", "")),
batch_id=str(options.get("batch_id", "")),
product_name=str(options.get("product_name", "")),
notification_payload=_build_notification_payload(
{"notify_reason": "task_failed", "owner_roles": []},
options=options,
status="failed",
),
)
structured_output, _ = parse_structured_output(llm_response.content, output_type)
answer = extract_answer_from_structured_output(structured_output, llm_response.content)
return AgentResult(
answer=answer,
structured_output=structured_output,
references=references,
tool_calls=tool_calls,
raw_output=llm_response.content,
model_name=llm_response.model_name or "unknown-model",
latency_ms=latency_ms,
status="success",
conversation_id=str(options.get("conversation_id", "")),
batch_id=str(options.get("batch_id", "")),
product_name=str(options.get("product_name", "")),
node_results=_build_node_results(output_type, structured_output),
notification_payload=_build_notification_payload(structured_output, options=options, status="success"),
)
def build_messages(
scenario_config: dict,
user_input: str,
references: list[dict],
tool_calls: list[dict],
) -> list[dict]:
"""将场景配置、检索结果和工具结果整合为最小可解释 Prompt。"""
agent_config = scenario_config.get("agent", {})
system_message = "\n".join(
[
f"你当前扮演的角色:{agent_config.get('role', '通用业务助手')}",
f"当前任务目标:{agent_config.get('goal', '根据输入生成结构化结果')}",
"执行要求:",
_format_instructions(agent_config.get("instructions", [])),
f"输出类型:{scenario_config.get('output', {}).get('type', 'general_answer')}",
"请优先输出 JSON 对象,字段必须贴近约定输出结构。",
]
)
context_message = "\n".join(
[
f"当前场景:{scenario_config.get('name', '未命名场景')}",
_format_references(references),
_format_tool_calls(tool_calls),
]
)
return [
{"role": "system", "content": system_message},
{"role": "assistant", "content": context_message},
{"role": "user", "content": user_input},
]
def _collect_references(scenario_config: dict, user_input: str, options: dict) -> list[dict]:
"""按场景配置执行检索,并保持无 RAG 场景也能正常返回空列表。"""
rag_config = scenario_config.get("rag", {})
if not rag_config.get("enabled"):
return []
return retrieve(
scenario_id=scenario_config.get("id", ""),
query=user_input,
collection=rag_config.get("collection", scenario_config.get("id", "")),
top_k=rag_config.get("top_k", 5),
document_ids=options.get("document_ids"),
store_path=options.get("rag_store_path"),
)
def _format_instructions(instructions: list[str]) -> str:
if not instructions:
return "1. 结合知识库和工具结果回答。\n2. 信息不足时明确说明。"
return "\n".join(f"{index}. {item}" for index, item in enumerate(instructions, start=1))
def _format_references(references: list[dict]) -> str:
if not references:
return "知识库引用:当前没有检索到可用片段。"
lines = ["知识库引用:"]
for index, reference in enumerate(references, start=1):
lines.append(
f"{index}. 来源={reference.get('source', '未知来源')} 内容={reference.get('content', '')}"
)
return "\n".join(lines)
def _format_tool_calls(tool_calls: list[dict]) -> str:
if not tool_calls:
return "工具结果:当前场景未声明工具或无需调用工具。"
lines = ["工具结果:"]
for index, tool_call in enumerate(tool_calls, start=1):
if tool_call.get("success"):
lines.append(
f"{index}. 工具={tool_call.get('tool_name')} 结果={json.dumps(tool_call.get('result', {}), ensure_ascii=False)}"
)
else:
lines.append(
f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}"
)
return "\n".join(lines)
def _build_node_results(output_type: str, structured_output: dict) -> list[dict]:
if output_type.startswith("registration_") or output_type == "feishu_notification_report":
return _build_registration_node_results(output_type, structured_output)
return [
{
"code": output_type,
"label": output_type,
"status": "已完成",
"summary": structured_output.get("summary") or structured_output.get("answer", ""),
}
]
def _build_notification_payload(structured_output: dict, options: dict, status: str) -> dict:
notify_reason = _normalize_notify_reason(
structured_output.get("notify_reason"),
status=status,
)
owners = structured_output.get("owner_roles") or []
if not owners:
owners = load_governance_config()["owner_mappings"]
return {
"batch_id": str(options.get("batch_id", "")),
"conversation_id": str(options.get("conversation_id", "")),
"product_name": str(options.get("product_name", "")),
"notify_reason": notify_reason,
"owners": owners,
"mentioned_users": structured_output.get("mentioned_users") or [],
"message_status": structured_output.get("message_status")
or ("sent" if status == "success" else "failed"),
"web_detail_url": structured_output.get("web_detail_url", ""),
"receipt": structured_output.get("receipt") or {},
"status": status,
}
def _normalize_notify_reason(notify_reason: str | None, *, status: str) -> str:
"""
将通知原因收口到 Demo 固定支持的两类语义。
"""
if notify_reason in {"task_completed", "task_failed"}:
return notify_reason
return "task_completed" if status == "success" else "task_failed"
def _build_registration_node_results(output_type: str, structured_output: dict) -> list[dict]:
nodes = [
{"code": "package_import", "label": "资料包导入", "status": "已完成"},
{"code": "overview", "label": "目录汇总", "status": "待处理"},
{"code": "completeness", "label": "法规完整性检查", "status": "待处理"},
{"code": "field_extraction", "label": "字段抽取", "status": "待处理"},
{"code": "consistency", "label": "一致性核查", "status": "待处理"},
{"code": "risk", "label": "风险预警", "status": "待处理"},
{"code": "word_export", "label": "Word 回填导出", "status": "待处理"},
{"code": "feishu_notify", "label": "飞书通知", "status": "待处理"},
]
progression_map = {
"registration_overview_report": 1,
"registration_completeness_report": 2,
"registration_field_extraction_report": 3,
"registration_consistency_report": 4,
"registration_risk_report": 5,
"registration_word_export_report": 6,
"feishu_notification_report": 7,
}
completed_index = progression_map.get(output_type, 0)
for index in range(1, completed_index + 1):
nodes[index]["status"] = "已完成"
if output_type == "registration_risk_report":
pass_status = structured_output.get("pass_status", "")
if pass_status in {"blocked", "failed"}:
nodes[5]["status"] = "已阻断"
elif pass_status in {"review_required", "manual_review"}:
nodes[5]["status"] = "待复核"
else:
nodes[5]["status"] = "已完成"
return nodes
if output_type == "registration_word_export_report":
export_status = structured_output.get("export_status", "")
if export_status in {"blocked", "draft_only"}:
nodes[6]["status"] = "已阻断" if export_status == "blocked" else "待复核"
else:
nodes[6]["status"] = "已完成"
return nodes
if output_type == "feishu_notification_report":
message_status = structured_output.get("message_status", "")
if message_status in {"failed", "error"}:
nodes[7]["status"] = "失败"
elif message_status in {"sent", "success"}:
nodes[7]["status"] = "已发送"
else:
nodes[7]["status"] = "待处理"
return nodes
return nodes

View File

@@ -1,104 +0,0 @@
from pathlib import Path
from django.conf import settings
from agent_core.llm_provider import create_embedding_provider
def _client(path: str | Path | None = None):
"""按给定路径初始化 Chroma 持久化客户端。"""
import chromadb
resolved_path = str(path or settings.CHROMA_PATH)
return chromadb.PersistentClient(path=resolved_path)
def _embedding_provider():
"""从 Django settings 构造 Embedding Provider避免在业务层散落配置读取。"""
return create_embedding_provider(
{
"EMBEDDING_API_KEY": settings.EMBEDDING_API_KEY,
"EMBEDDING_BASE_URL": settings.EMBEDDING_BASE_URL,
"EMBEDDING_MODEL": settings.EMBEDDING_MODEL,
}
)
def upsert_chunks(
collection: str,
chunks: list[dict],
store_path: str | Path | None = None,
) -> None:
"""
将 chunk 写入 Chroma。
同一 document_id 重新入库前会先删除旧记录,保证一次文档只有一份有效向量数据。
"""
client = _client(store_path)
chroma_collection = client.get_or_create_collection(collection)
document_ids = {chunk["document_id"] for chunk in chunks if chunk.get("document_id") is not None}
for document_id in document_ids:
chroma_collection.delete(where={"document_id": document_id})
texts = [chunk["content"] for chunk in chunks]
embeddings = _embedding_provider().embed_texts(texts)
chroma_collection.upsert(
ids=[chunk["chunk_id"] for chunk in chunks],
documents=texts,
embeddings=embeddings,
metadatas=[
{
"scenario_id": chunk["scenario_id"],
"document_id": chunk["document_id"],
"source": chunk["source"],
"chunk_id": chunk["chunk_id"],
"created_at": chunk["created_at"],
}
for chunk in chunks
],
)
def query_chunks(
scenario_id: str,
query: str,
collection: str,
top_k: int = 5,
document_ids: list[int] | None = None,
store_path: str | Path | None = None,
) -> list[dict]:
"""执行向量检索,并把 Chroma 原始结果转换为统一引用结构。"""
client = _client(store_path)
chroma_collection = client.get_or_create_collection(collection)
where: dict = {"scenario_id": scenario_id}
if document_ids:
where = {
"$and": [
{"scenario_id": scenario_id},
{"document_id": {"$in": document_ids}},
]
}
embedding = _embedding_provider().embed_texts([query])[0]
result = chroma_collection.query(
query_embeddings=[embedding],
n_results=top_k,
where=where,
include=["documents", "metadatas", "distances"],
)
chunks = []
documents = result.get("documents", [[]])[0]
metadatas = result.get("metadatas", [[]])[0]
distances = result.get("distances", [[]])[0]
for content, metadata, distance in zip(documents, metadatas, distances):
chunks.append(
{
"scenario_id": metadata.get("scenario_id"),
"document_id": metadata.get("document_id"),
"collection": collection,
"source": metadata.get("source"),
"chunk_id": metadata.get("chunk_id"),
"content": content,
"created_at": metadata.get("created_at"),
"score": round(1 / (1 + float(distance)), 4),
}
)
return chunks

View File

@@ -1,171 +0,0 @@
import importlib.util
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from django.conf import settings
from .chroma_store import upsert_chunks
@dataclass
class IngestResult:
"""RAG 入库统一返回结构,供 Documents 模块稳定消费。"""
success: bool
chunks_count: int = 0
error: str = ""
def ingest_document(
scenario_id: str,
source_file: str,
text: str,
collection: str,
document_id: int | None = None,
store_path: str | Path | None = None,
) -> IngestResult:
"""
将单个文档文本切分后写入知识库。
运行策略:
- 如果显式传入 `store_path`,说明当前是测试或降级模式,走本地 JSON 存储。
- 如果未传入且环境可用 chromadb则走真实 Chroma 持久化。
"""
if not text.strip():
return IngestResult(success=False, error="文档内容为空")
if _should_use_chroma(store_path):
return _ingest_chroma_document(
document_id=document_id,
scenario_id=scenario_id,
source_file=source_file,
text=text,
collection=collection,
)
resolved_store_path = Path(store_path) if store_path else _default_store_path()
chunks = _build_chunks(
scenario_id=scenario_id,
source_file=source_file,
text=text,
collection=collection,
document_id=document_id,
chunk_id_prefix=source_file,
)
persisted_chunks = _filter_out_same_document_chunks(
_load_store(resolved_store_path),
scenario_id=scenario_id,
collection=collection,
document_id=document_id,
)
_save_store(resolved_store_path, [*persisted_chunks, *chunks])
return IngestResult(success=True, chunks_count=len(chunks))
def _should_use_chroma(store_path: str | Path | None) -> bool:
"""只在未指定测试存储路径且安装 chromadb 时启用真实向量库。"""
return store_path is None and importlib.util.find_spec("chromadb") is not None
def _default_store_path() -> Path:
return Path(settings.CHROMA_PATH) / "rag_store.json"
def _load_store(store_path: Path) -> list[dict]:
if not store_path.exists():
return []
with store_path.open("r", encoding="utf-8") as file:
return json.load(file)
def _save_store(store_path: Path, chunks: list[dict]) -> None:
store_path.parent.mkdir(parents=True, exist_ok=True)
with store_path.open("w", encoding="utf-8") as file:
json.dump(chunks, file, ensure_ascii=False, indent=2)
def _split_text(text: str, chunk_size: int = 800, overlap: int = 120) -> list[str]:
"""
使用固定窗口 + overlap 切分文本。
该策略简单但稳定,便于解释:
- chunk_size 控制每个片段最大长度
- overlap 保证相邻片段共享上下文,降低边界信息丢失
"""
normalized = re.sub(r"\s+", " ", text).strip()
if not normalized:
return []
chunks = []
start = 0
while start < len(normalized):
end = start + chunk_size
chunks.append(normalized[start:end])
if end >= len(normalized):
break
start = max(end - overlap, start + 1)
return chunks
def _build_chunks(
scenario_id: str,
source_file: str,
text: str,
collection: str,
document_id: int | None,
chunk_id_prefix: str,
) -> list[dict]:
"""把原始文本切分并封装为统一 chunk 结构。"""
created_at = datetime.now(timezone.utc).isoformat()
return [
{
"scenario_id": scenario_id,
"document_id": document_id,
"collection": collection,
"source": source_file,
"chunk_id": f"{scenario_id}:{chunk_id_prefix}:{index}",
"content": chunk_text,
"created_at": created_at,
}
for index, chunk_text in enumerate(_split_text(text), start=1)
]
def _filter_out_same_document_chunks(
chunks: list[dict],
scenario_id: str,
collection: str,
document_id: int | None,
) -> list[dict]:
"""重新入库同一 document_id 时,先删除旧 chunk避免重复检索。"""
return [
chunk
for chunk in chunks
if not (
chunk.get("document_id") == document_id
and chunk.get("scenario_id") == scenario_id
and chunk.get("collection") == collection
)
]
def _ingest_chroma_document(
document_id: int | None,
scenario_id: str,
source_file: str,
text: str,
collection: str,
) -> IngestResult:
"""真实 Chroma 模式的入库分支。"""
chunks = _build_chunks(
scenario_id=scenario_id,
source_file=source_file,
text=text,
collection=collection,
document_id=document_id,
chunk_id_prefix=str(document_id or source_file),
)
try:
upsert_chunks(collection=collection, chunks=chunks)
except Exception as exc:
return IngestResult(success=False, error=str(exc))
return IngestResult(success=True, chunks_count=len(chunks))

View File

@@ -1,105 +0,0 @@
import importlib.util
import json
import re
from pathlib import Path
from django.conf import settings
from .chroma_store import query_chunks
def retrieve(
scenario_id: str,
query: str,
collection: str,
top_k: int = 5,
document_ids: list[int] | None = None,
store_path: str | Path | None = None,
) -> list[dict]:
"""
统一对外提供检索入口。
与 ingest_document 保持一致:
- 真实运行优先走 Chroma
- 测试或降级模式走本地 JSON + 轻量文本打分
"""
if _should_use_chroma(store_path):
return query_chunks(
scenario_id=scenario_id,
query=query,
collection=collection,
top_k=top_k,
document_ids=document_ids,
)
resolved_store_path = Path(store_path) if store_path else _default_store_path()
query_tokens = _tokens(query)
allowed_document_ids = set(document_ids or [])
scored_chunks = []
for chunk in _load_store(resolved_store_path):
if not _matches_scope(
chunk=chunk,
scenario_id=scenario_id,
collection=collection,
allowed_document_ids=allowed_document_ids,
):
continue
score = _score(query_tokens, chunk.get("content", ""))
if score <= 0:
continue
scored_chunks.append({**chunk, "score": score})
return sorted(scored_chunks, key=lambda item: item["score"], reverse=True)[:top_k]
def _should_use_chroma(store_path: str | Path | None) -> bool:
return store_path is None and importlib.util.find_spec("chromadb") is not None
def _default_store_path() -> Path:
return Path(settings.CHROMA_PATH) / "rag_store.json"
def _load_store(store_path: Path) -> list[dict]:
if not store_path.exists():
return []
with store_path.open("r", encoding="utf-8") as file:
return json.load(file)
def _matches_scope(
chunk: dict,
scenario_id: str,
collection: str,
allowed_document_ids: set[int],
) -> bool:
"""先按场景、collection 和可选文档范围过滤,再进行相关性打分。"""
if chunk.get("scenario_id") != scenario_id:
return False
if chunk.get("collection") != collection:
return False
if allowed_document_ids and chunk.get("document_id") not in allowed_document_ids:
return False
return True
def _tokens(text: str) -> set[str]:
"""
兼容中英文的轻量分词策略。
该分词仅用于 fallback 模式,不替代真实向量检索:
- 英文/数字按词提取
- 中文按连续词片段和单字同时保留,提升短查询命中率
"""
lowered = text.lower()
ascii_tokens = set(re.findall(r"[a-z0-9_]+", lowered))
cjk_tokens = set(re.findall(r"[\u4e00-\u9fff]{2,}", lowered))
chars = {char for char in lowered if "\u4e00" <= char <= "\u9fff"}
return ascii_tokens | cjk_tokens | chars
def _score(query_tokens: set[str], content: str) -> float:
"""使用交集占比计算一个便于排序的简化相关性分数。"""
content_tokens = _tokens(content)
if not query_tokens or not content_tokens:
return 0.0
overlap = query_tokens & content_tokens
return round(len(overlap) / len(query_tokens), 4)

View File

@@ -1,27 +0,0 @@
from dataclasses import dataclass, field
@dataclass
class AgentResult:
"""
Agent Core 对 Django 层暴露的统一结果对象。
任何底层编排实现都必须返回这一结构,确保:
- Chat 页面有稳定字段可展示
- Audit 模块有稳定字段可落库
- 未来替换编排引擎时不影响 Django 业务层
"""
answer: str = ""
structured_output: dict = field(default_factory=dict)
references: list = field(default_factory=list)
tool_calls: list = field(default_factory=list)
raw_output: str = ""
model_name: str = "mock-model"
latency_ms: int = 0
status: str = "success"
error: str = ""
conversation_id: str = ""
batch_id: str = ""
product_name: str = ""
node_results: list = field(default_factory=list)
notification_payload: dict = field(default_factory=dict)

View File

@@ -1,14 +0,0 @@
SUPPORTED_OUTPUT_TYPES = {
"general_answer",
"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",
"quality_report",
"risk_audit_report",
}

View File

@@ -1,197 +0,0 @@
import json
from .schemas.outputs import SUPPORTED_OUTPUT_TYPES
# 按输出类型声明页面和审计日志真正需要消费的结构化字段。
# 这里不追求复杂 schema 框架,优先保证字段稳定、可读、易讲解。
OUTPUT_FIELD_TEMPLATES = {
"general_answer": {
"answer": "",
"confidence": "medium",
"references": [],
},
"document_review_report": {
"summary": "",
"issues": [],
"risk_level": "medium",
"suggestions": [],
"missing_items": [],
"references": [],
},
"ticket_response": {
"reply": "",
"category": "general",
"priority": "medium",
"suggested_action": "",
"need_human_review": False,
},
"quality_report": {
"summary": "",
"possible_causes": [],
"evidence": [],
"risk_level": "medium",
"suggested_actions": [],
"references": [],
},
"risk_audit_report": {
"summary": "",
"risk_points": [],
"risk_level": "medium",
"suggestions": [],
"references": [],
},
"registration_overview_report": {
"batch_id": "",
"product_name": "",
"file_count": 0,
"total_page_count": 0,
"chapter_summary": [],
"documents": [],
"warnings": [],
},
"registration_completeness_report": {
"summary": "",
"missing_items": [],
"misplaced_items": [],
"risk_level": "medium",
"references": [],
},
"registration_field_extraction_report": {
"summary": "",
"field_items": [],
"low_confidence_items": [],
"references": [],
},
"registration_consistency_report": {
"summary": "",
"conflict_items": [],
"mixed_document_risks": [],
"risk_level": "medium",
"references": [],
},
"registration_risk_report": {
"summary": "",
"risk_items": [],
"highest_risk_level": "medium",
"pass_status": "review_required",
"manual_review_items": [],
"owner_roles": [],
"suggestions": [],
"notify_reason": "task_completed",
},
"registration_word_export_report": {
"summary": "",
"export_status": "draft_only",
"can_export_formally": False,
"blocked_items": [],
"download_url": "",
},
"feishu_notification_report": {
"batch_id": "",
"conversation_id": "",
"notify_reason": "task_completed",
"mentioned_users": [],
"message_status": "pending",
"web_detail_url": "",
"receipt": {},
},
}
def build_response_schema_hint(output_type: str) -> dict:
"""返回给 LLM 的结构化提示,帮助模型尽量输出稳定 JSON。"""
normalized_output_type = normalize_output_type(output_type)
return {
"output_type": normalized_output_type,
"fields": list(OUTPUT_FIELD_TEMPLATES[normalized_output_type].keys()),
}
def normalize_output_type(output_type: str) -> str:
"""对外部配置做轻量归一化,避免拼写差异导致解析分支混乱。"""
if output_type in SUPPORTED_OUTPUT_TYPES:
return output_type
return "general_answer"
def parse_structured_output(raw_content: str, output_type: str) -> tuple[dict, str]:
"""
优先将模型输出解析为 JSON。
返回值:
- structured_output: 页面和审计日志可直接消费的标准结构
- parse_mode: `json` 表示成功解析,`fallback` 表示降级处理
"""
normalized_output_type = normalize_output_type(output_type)
parsed = _try_parse_json_object(raw_content)
if parsed is None:
return build_fallback_structured_output(
output_type=normalized_output_type,
raw_content=raw_content,
), "fallback"
template = {
"output_type": normalized_output_type,
"parse_mode": "json",
}
template.update(OUTPUT_FIELD_TEMPLATES[normalized_output_type])
template.update(parsed)
return template, "json"
def build_fallback_structured_output(output_type: str, raw_content: str) -> dict:
"""当模型没有输出合法 JSON 时,仍然构造一个稳定的展示结构。"""
normalized_output_type = normalize_output_type(output_type)
structured_output = {
"output_type": normalized_output_type,
"parse_mode": "fallback",
}
structured_output.update(OUTPUT_FIELD_TEMPLATES[normalized_output_type])
if normalized_output_type == "general_answer":
structured_output["answer"] = raw_content
return structured_output
if normalized_output_type == "document_review_report":
structured_output["summary"] = raw_content
return structured_output
if normalized_output_type == "ticket_response":
structured_output["reply"] = raw_content
return structured_output
if normalized_output_type == "quality_report":
structured_output["summary"] = raw_content
return structured_output
structured_output["summary"] = raw_content
return structured_output
def extract_answer_from_structured_output(structured_output: dict, raw_content: str) -> str:
"""从结构化结果里提取页面主回答,保证不同输出类型有统一入口。"""
for field_name in ("answer", "reply", "summary"):
value = structured_output.get(field_name)
if isinstance(value, str) and value.strip():
return value.strip()
return raw_content.strip()
def _try_parse_json_object(raw_content: str) -> dict | None:
"""支持纯 JSON 或被 Markdown 代码块包裹的 JSON。"""
content = raw_content.strip()
if not content:
return None
candidates = [content]
if content.startswith("```"):
stripped = content.strip("`").strip()
if stripped.lower().startswith("json"):
stripped = stripped[4:].strip()
candidates.append(stripped)
for candidate in candidates:
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
return parsed
return None

View File

@@ -1,70 +0,0 @@
from collections.abc import Callable
from .tools.builtin_tools import BUILTIN_TOOLS
class ToolRegistry:
"""
统一管理工具注册、查询和执行。
设计目标:
- 让 Orchestrator 只关心“声明了哪些工具”,不关心工具如何存放。
- 固化统一的工具调用结果结构,便于页面展示和审计日志保存。
- 后续新增业务工具时,只需要注册函数,不必改调用协议。
"""
def __init__(self, initial_tools: dict[str, Callable] | None = None):
self._tools: dict[str, Callable] = dict(initial_tools or {})
def register(self, tool_name: str, tool_func: Callable) -> None:
"""注册一个可通过名称调用的工具函数。"""
self._tools[tool_name] = tool_func
def get(self, tool_name: str) -> Callable | None:
"""按名称返回工具函数;未注册时返回 None。"""
return self._tools.get(tool_name)
def run(self, tool_name: str, **kwargs) -> dict:
"""
执行单个工具,并返回统一结果结构。
统一返回值是审计日志、页面展示和后续 Agent 编排共享的协议。
即使工具不存在或执行失败,也返回可消费的失败结果,而不是抛异常。
"""
tool = self.get(tool_name)
if tool is None:
return {
"tool_name": tool_name,
"success": False,
"arguments": kwargs,
"result": {},
"error": "工具未注册",
}
try:
return {
"tool_name": tool_name,
"success": True,
"arguments": kwargs,
"result": tool(**kwargs),
"error": "",
}
except Exception as exc:
return {
"tool_name": tool_name,
"success": False,
"arguments": kwargs,
"result": {},
"error": str(exc),
}
# 默认注册表承载项目内置工具,便于当前 V1 直接复用。
DEFAULT_TOOL_REGISTRY = ToolRegistry(BUILTIN_TOOLS)
def run_declared_tools(tool_names: list[str], user_input: str) -> list[dict]:
"""按场景声明顺序执行工具,保证结果顺序与配置顺序一致。"""
return [
DEFAULT_TOOL_REGISTRY.run(tool_name, user_input=user_input)
for tool_name in tool_names
]

View File

@@ -1,124 +0,0 @@
import re
def calculate_rate(user_input: str) -> dict:
"""
从自然语言中提取两个数值并计算比例。
V1 目标不是构建复杂公式引擎,而是提供一个可演示的“业务工具”示例:
只要输入中出现两个数字,就将其解释为“已完成值 / 总数”。
"""
numbers = [float(item) for item in re.findall(r"\d+(?:\.\d+)?", user_input)]
if len(numbers) < 2:
return {
"success": False,
"rate": 0.0,
"numerator": 0.0,
"denominator": 0.0,
"note": "未能从输入中提取两个数字,无法计算比例。",
}
numerator, denominator = numbers[0], numbers[1]
if denominator == 0:
return {
"success": False,
"rate": 0.0,
"numerator": numerator,
"denominator": denominator,
"note": "分母为 0无法计算比例。",
}
return {
"success": True,
"numerator": numerator,
"denominator": denominator,
"rate": round(numerator / denominator, 4),
"note": "已按输入中的前两个数字完成比例计算。",
}
def query_demo_records(user_input: str) -> dict:
"""
查询示例业务记录。
该工具依赖 Audit 模块中的 DemoBusinessRecord 演示表,用于证明
“场景 + 结构化数据 + 工具调用”可以组成更可信的业务 Agent。
"""
try:
from apps.audit.models import DemoBusinessRecord
except Exception as exc:
return {"records": [], "error": str(exc)}
queryset = DemoBusinessRecord.objects.all()
tokens = {token.strip().lower() for token in user_input.split() if token.strip()}
scenario_ids = set(queryset.values_list("scenario_id", flat=True))
record_types = set(queryset.values_list("record_type", flat=True))
matched_scenario_ids = scenario_ids & tokens
matched_record_types = record_types & tokens
if matched_scenario_ids:
queryset = queryset.filter(scenario_id__in=matched_scenario_ids)
if matched_record_types:
queryset = queryset.filter(record_type__in=matched_record_types)
records = [
{
"id": record.id,
"scenario_id": record.scenario_id,
"record_type": record.record_type,
"title": record.title,
"payload": record.payload,
}
for record in queryset[:20]
]
return {"records": records}
def check_required_fields(user_input: str) -> dict:
"""
检查输入中声明的必填项是否全部出现。
约定格式示例:
“请检查必填项:合同编号、供应商、金额。当前只提供了合同编号和金额。”
"""
required_match = re.search(r"必填项[:](.+?)(?:。|\.)", user_input)
provided_match = re.search(r"(?:当前|已|仅)?提供了(.+?)(?:。|\.)", user_input)
required_fields = _split_cn_items(required_match.group(1) if required_match else "")
provided_fields = set(_split_cn_items(provided_match.group(1) if provided_match else ""))
missing_fields = [field for field in required_fields if field not in provided_fields]
return {
"required_fields": required_fields,
"provided_fields": list(provided_fields),
"missing_fields": missing_fields,
"note": "已根据输入中的“必填项/提供了”描述完成检查。",
}
def generate_action_items(user_input: str) -> dict:
"""
生成最小可执行行动项。
该工具主要用于演示“模型回答之外,还可以得到结构化待办建议”。
"""
return {
"items": [
"先确认问题背景和适用场景。",
f"围绕当前问题继续核实:{user_input}",
"根据知识库和审计结果安排下一步处理动作。",
]
}
def _split_cn_items(raw_text: str) -> list[str]:
"""将中文顿号、逗号和连接词分隔的字段串切分为列表。"""
normalized = (
raw_text.replace("", "")
.replace("以及", "")
.replace(",", "")
.replace("", "")
)
return [item.strip(" 。.") for item in normalized.split("") if item.strip(" 。.")]
BUILTIN_TOOLS = {
"calculate_rate": calculate_rate,
"query_demo_records": query_demo_records,
"check_required_fields": check_required_fields,
"generate_action_items": generate_action_items,
}

View File

@@ -1 +0,0 @@

View File

@@ -1,19 +0,0 @@
from django.contrib import admin
from .models import AgentAuditLog, DemoBusinessRecord
@admin.register(AgentAuditLog)
class AgentAuditLogAdmin(admin.ModelAdmin):
"""便于在 Django Admin 中快速查看一次 Agent 执行的关键信息。"""
list_display = ("id", "scenario_name", "status", "model_name", "latency_ms", "created_at")
list_filter = ("status", "scenario_id")
search_fields = ("scenario_id", "scenario_name", "user_input", "final_answer")
@admin.register(DemoBusinessRecord)
class DemoBusinessRecordAdmin(admin.ModelAdmin):
"""管理工具查询依赖的示例业务记录。"""
list_display = ("id", "title", "scenario_id", "record_type", "created_at")
list_filter = ("scenario_id", "record_type")
search_fields = ("title", "scenario_id", "record_type")

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
"""Audit 模块应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.audit"

View File

@@ -1,46 +0,0 @@
# Generated by Django 5.2.14 on 2026-05-29 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="AgentAuditLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("scenario_id", models.CharField(db_index=True, max_length=100)),
("scenario_name", models.CharField(blank=True, max_length=200)),
("user_input", models.TextField()),
("retrieved_chunks", models.JSONField(blank=True, default=list)),
("tool_calls", models.JSONField(blank=True, default=list)),
("structured_output", models.JSONField(blank=True, default=dict)),
("final_answer", models.TextField(blank=True)),
("raw_output", models.TextField(blank=True)),
("model_name", models.CharField(blank=True, max_length=100)),
("latency_ms", models.PositiveIntegerField(default=0)),
(
"status",
models.CharField(db_index=True, default="success", max_length=20),
),
("error_message", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,35 +0,0 @@
# Generated for V1 demo business records.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("audit", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="DemoBusinessRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("scenario_id", models.CharField(db_index=True, max_length=100)),
("record_type", models.CharField(db_index=True, max_length=100)),
("title", models.CharField(max_length=255)),
("payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,62 +0,0 @@
# 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

@@ -1,109 +0,0 @@
from django.db import models
class AgentAuditLog(models.Model):
"""
保存一次 Agent 执行的完整审计快照。
该模型是“系统可解释性”的核心:
- 对话页负责触发执行
- Agent Core 负责生成结果
- Audit 模型负责长期保存输入、引用、工具调用和模型输出
"""
# 审计状态需要同时服务数据库检索和前端展示。
STATUS_SUCCESS = "success"
STATUS_FAILED = "failed"
scenario_id = models.CharField(max_length=100, db_index=True)
scenario_name = models.CharField(max_length=200, blank=True)
batch_id = models.CharField(max_length=64, blank=True, db_index=True)
conversation_id = models.CharField(max_length=64, blank=True, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
user_input = models.TextField()
retrieved_chunks = models.JSONField(default=list, blank=True)
tool_calls = models.JSONField(default=list, blank=True)
structured_output = models.JSONField(default=dict, blank=True)
final_answer = models.TextField(blank=True)
raw_output = models.TextField(blank=True)
model_name = models.CharField(max_length=100, blank=True)
latency_ms = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=20, default=STATUS_SUCCESS, db_index=True)
error_message = models.TextField(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.scenario_name or self.scenario_id} #{self.pk}"
def get_status_display_text(self) -> str:
"""返回更适合页面展示的中文状态。"""
return {
self.STATUS_SUCCESS: "执行成功",
self.STATUS_FAILED: "执行失败",
}.get(self.status, self.status)
def get_user_input_summary(self, max_length: int = 28) -> str:
"""在列表页展示用户输入摘要,避免长文本撑破表格。"""
if len(self.user_input) <= max_length:
return self.user_input
return f"{self.user_input[:max_length]}..."
class DemoBusinessRecord(models.Model):
"""
演示用业务记录表。
该表不直接参与页面主流程,而是供内置工具 `query_demo_records`
查询,证明 Agent 除知识库外也可以结合结构化业务数据。
"""
scenario_id = models.CharField(max_length=100, db_index=True)
record_type = models.CharField(max_length=100, db_index=True)
title = models.CharField(max_length=255)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.title
class NotificationRecord(models.Model):
"""
飞书通知留痕。
首版只保存离线通知载荷与结果状态,不直接依赖真实飞书网络。
"""
STATUS_PENDING = "pending"
STATUS_SENT = "sent"
STATUS_FAILED = "failed"
batch_id = models.CharField(max_length=64, db_index=True)
conversation_id = models.CharField(max_length=64, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
trigger_source = models.CharField(max_length=64, blank=True)
notify_reason = models.CharField(max_length=32, db_index=True)
owner_role = models.CharField(max_length=100, blank=True)
feishu_user_id = models.CharField(max_length=100, blank=True)
message_status = models.CharField(max_length=32, default=STATUS_PENDING, db_index=True)
web_detail_url = models.URLField(blank=True)
receipt = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.notify_reason}:{self.batch_id}"
def get_message_status_display_text(self) -> str:
"""返回通知状态的中文展示文案。"""
return {
self.STATUS_PENDING: "处理中",
self.STATUS_SENT: "已发送",
self.STATUS_FAILED: "失败",
}.get(self.message_status, self.message_status)

View File

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

View File

@@ -1,12 +0,0 @@
from django.urls import path
from . import views
app_name = "audit"
# V1 的审计功能由列表页和详情页组成,暂不拆分页或复杂筛选接口。
urlpatterns = [
path("", views.log_list, name="list"),
path("<int:log_id>/", views.log_detail, name="detail"),
]

View File

@@ -1,45 +0,0 @@
from django.shortcuts import get_object_or_404, render
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):
# 处理历史页支持按批次、产品和状态筛选。
context = build_history_list_context(
scenario_id=(request.GET.get("scenario_id") or "").strip(),
keyword=(request.GET.get("keyword") or "").strip(),
notify_status=(request.GET.get("notify_status") or "").strip(),
risk_status=(request.GET.get("risk_status") or "").strip(),
)
return render(request, "audit/log_list.html", context)
def log_detail(request, log_id: int):
# 详情页只负责按主键加载审计快照并渲染;
# 所有脱敏和字段映射都应在服务层完成。
audit_log = get_object_or_404(AgentAuditLog, pk=log_id)
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,
},
)

View File

@@ -1 +0,0 @@

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
"""Chat 模块应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.chat"

View File

@@ -1,316 +0,0 @@
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,63 +0,0 @@
from django import forms
from pathlib import Path
from apps.documents.forms import MultipleFileField, SUPPORTED_EXTENSIONS
class ChatForm(forms.Form):
# 该表单只负责收集用户问题和可选文档范围,
# 不承载任何 Agent 业务逻辑,便于在 View 层保持轻量。
message = forms.CharField(
label="问题",
max_length=4000,
error_messages={
"required": "请输入要咨询的问题。",
"max_length": "问题过长,请控制在 4000 字以内。",
},
widget=forms.Textarea(
attrs={
"rows": 8,
"placeholder": "例如:请结合已上传 SOP分析当前异常的原因、风险等级和建议动作。",
}
),
)
document_ids = forms.MultipleChoiceField(
label="文档范围",
required=False,
choices=(),
widget=forms.CheckboxSelectMultiple,
error_messages={"invalid_choice": "请选择当前场景下已入库的文档。"},
)
def __init__(self, *args, documents=None, **kwargs):
super().__init__(*args, **kwargs)
documents = documents or []
# 仅允许选择当前场景且已完成入库的文档,
# 避免前端把无效文件范围传入 Agent Core。
self.fields["document_ids"].choices = [
(str(document.id), document.original_name) for document in documents
]
def clean_document_ids(self):
# View 与 Agent Core 都使用整型文档 ID统一在表单层完成转换。
return [int(document_id) for document_id in self.cleaned_data.get("document_ids", [])]
class ConversationUploadForm(forms.Form):
# 会话右侧上传区只负责继续补传资料,不修改会话绑定关系。
files = MultipleFileField(label="补充文件或资料包", required=False)
file = forms.FileField(label="兼容单文件上传", required=False)
def clean(self):
cleaned_data = super().clean()
files = list(cleaned_data.get("files") or [])
file = cleaned_data.get("file")
if file:
files.append(file)
if not files:
raise forms.ValidationError("请至少上传一个文件或资料包。")
for uploaded_file in files:
if Path(uploaded_file.name).suffix.lower() not in SUPPORTED_EXTENSIONS:
raise forms.ValidationError("仅支持 .txt、.md、.pdf、.docx、.zip 和 .7z 文件")
cleaned_data["uploaded_files"] = files
return cleaned_data

View File

@@ -1,52 +0,0 @@
# 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

@@ -1,46 +0,0 @@
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)

View File

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

View File

@@ -1,14 +0,0 @@
from django.urls import path
from . import views
app_name = "chat"
# 审核智能体前台以会话为中心。
urlpatterns = [
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,405 +0,0 @@
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.results import AgentResult
from apps.documents.models import SubmissionBatch, UploadedDocument
from apps.documents.services import append_documents_to_batch
from .forms import ChatForm, ConversationUploadForm
from .models import Conversation
from .services import execute_conversation_agent, execute_conversation_export
RISK_LEVEL_DISPLAY = {
"high": "",
"medium": "",
"low": "",
}
PASS_STATUS_DISPLAY = {
"blocked": "已阻断",
"failed": "失败",
"review_required": "待复核",
"manual_review": "待复核",
"completed": "已完成",
"passed": "已完成",
}
EXPORT_STATUS_DISPLAY = {
"completed": "已完成",
"draft_only": "待复核",
"review_required": "待复核",
"manual_review": "待复核",
"blocked": "已阻断",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
NOTIFY_MESSAGE_STATUS_DISPLAY = {
"sent": "已发送",
"failed": "失败",
"pending": "处理中",
}
def index(request):
conversations = Conversation.objects.all()
if conversations.exists():
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
return render(
request,
"chat/index.html",
{
"conversation": None,
"conversations": [],
"conversation_history": [],
"form": ChatForm(),
"documents": [],
"result": None,
"audit_log": None,
"node_results": [],
"active_node": None,
},
)
def detail(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
documents = UploadedDocument.objects.filter(batch=batch)
form = ChatForm(request.POST or None, documents=documents)
upload_form = ConversationUploadForm()
result = None
audit_log = None
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_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 +0,0 @@

View File

@@ -1,27 +0,0 @@
from django.contrib import admin
from .models import ExportedDocument, UploadedDocument
@admin.register(UploadedDocument)
class UploadedDocumentAdmin(admin.ModelAdmin):
"""管理上传文档及其入库状态,便于后台排查问题。"""
list_display = ("id", "original_name", "scenario_id", "file_type", "status", "created_at")
list_filter = ("status", "scenario_id", "file_type")
search_fields = ("original_name", "scenario_id")
@admin.register(ExportedDocument)
class ExportedDocumentAdmin(admin.ModelAdmin):
"""管理导出记录,便于按批次、会话和产品回看导出产物。"""
list_display = (
"id",
"file_name",
"batch",
"conversation_id",
"product_name",
"export_mode",
"created_at",
)
list_filter = ("export_mode", "output_type", "template_name")
search_fields = ("file_name", "batch__batch_id", "conversation_id", "product_name")

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class DocumentsConfig(AppConfig):
"""Documents 模块应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.documents"

View File

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

View File

@@ -1,42 +0,0 @@
# Generated by Django 5.2.14 on 2026-05-29 13:41
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="UploadedDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("scenario_id", models.CharField(db_index=True, max_length=100)),
("original_name", models.CharField(max_length=255)),
("file", models.FileField(upload_to="documents/%Y%m%d/")),
("file_type", models.CharField(max_length=20)),
("size", models.PositiveIntegerField(default=0)),
(
"status",
models.CharField(db_index=True, default="uploaded", max_length=20),
),
("error_message", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["-created_at"],
},
),
]

View File

@@ -1,103 +0,0 @@
# 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

@@ -1,52 +0,0 @@
# 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,127 +0,0 @@
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):
"""
保存用户上传文档的元数据和入库状态。
设计上只记录“文件属于哪个场景、当前是否已入库、失败原因是什么”,
不把 RAG 细节耦合进模型层。
"""
# 文档状态用于驱动前端提示和后续可操作项。
STATUS_UPLOADED = "uploaded"
STATUS_INDEXED = "indexed"
STATUS_FAILED = "failed"
batch = models.ForeignKey(
SubmissionBatch,
related_name="documents",
null=True,
blank=True,
on_delete=models.CASCADE,
)
scenario_id = models.CharField(max_length=100, db_index=True)
original_name = models.CharField(max_length=255)
file = models.FileField(upload_to="documents/%Y%m%d/")
file_type = models.CharField(max_length=20)
size = models.PositiveIntegerField(default=0)
relative_path = models.CharField(max_length=500, blank=True)
chapter_code = models.CharField(max_length=32, blank=True)
document_role = models.CharField(max_length=64, blank=True)
page_count = models.PositiveIntegerField(default=0)
page_count_confidence = models.CharField(max_length=32, blank=True)
chapter_match_status = models.CharField(max_length=32, blank=True)
needs_manual_review = models.BooleanField(default=False)
status = models.CharField(max_length=20, default=STATUS_UPLOADED, db_index=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.original_name
def get_status_display_text(self) -> str:
"""为模板提供更适合演示的中文状态文案。"""
return {
self.STATUS_UPLOADED: "已上传,待入库",
self.STATUS_INDEXED: "已入库,可检索",
self.STATUS_FAILED: "入库失败",
}.get(self.status, self.status)
class ExportedDocument(models.Model):
"""
导出文件记录。
该对象属于资料包治理范围:
- Documents 维护导出产物与资料包关系
- Chat 只负责触发导出动作
- Audit 负责回看执行痕迹
"""
batch = models.ForeignKey(
SubmissionBatch,
related_name="export_records",
on_delete=models.CASCADE,
)
conversation_id = models.CharField(max_length=64, db_index=True)
product_name = models.CharField(max_length=255, blank=True, db_index=True)
template_name = models.CharField(max_length=100, blank=True)
template_version = models.CharField(max_length=50, blank=True)
export_mode = models.CharField(max_length=32, db_index=True)
output_type = models.CharField(max_length=100, default="registration_word_export_report")
file_name = models.CharField(max_length=255)
relative_path = models.CharField(max_length=500)
download_url = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ["-created_at"]
def __str__(self) -> str:
return self.file_name

View File

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

View File

@@ -1,13 +0,0 @@
from django.urls import path
from . import views
app_name = "documents"
# 文档模块对外暴露三个基础动作:列表、上传、手动入库。
urlpatterns = [
path("", views.document_list, name="list"),
path("upload/", views.upload, name="upload"),
path("<int:document_id>/index/", views.index, name="index"),
]

View File

@@ -1,63 +0,0 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from apps.scenarios.services import list_scenarios
from .forms import DocumentUploadForm
from .models import UploadedDocument
from .services import (
build_document_list_context,
import_submission_batch,
index_document,
)
def document_list(request):
# 资料包页展示批次、会话绑定和关键异常,同时保留文档级明细便于演示。
context = build_document_list_context(keyword=(request.GET.get("keyword") or "").strip())
return render(request, "documents/document_list.html", context)
def upload(request):
# 上传成功后直接创建资料包并绑定主会话。
if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid():
result = import_submission_batch(
form.cleaned_data["scenario_id"],
form.cleaned_data["uploaded_files"],
)
messages.success(
request,
f"资料包已导入,已绑定会话 {result['conversation_id']}",
)
return redirect("documents:list")
else:
form = DocumentUploadForm()
return render(
request,
"documents/upload.html",
{
"form": form,
"scenarios": list_scenarios(),
"upload_checks": [
"文件格式支持 PDF、DOCX、MD、TXT、ZIP、7Z 与 RAR 资料包",
"业务资料与法规依据资料需分开归属",
"支持一次上传多份文件并归并到同一个资料包",
"目录类文件会优先参与完整性校验",
"上传完成后建议立即进入解析与入库流程",
],
},
)
@require_POST
def index(request, document_id: int):
document = get_object_or_404(UploadedDocument, pk=document_id)
document = index_document(document)
if document.status == UploadedDocument.STATUS_INDEXED:
messages.success(request, "文档入库成功,当前文档已可参与检索。")
else:
messages.error(request, "文档入库失败,请检查错误原因后重试。")
return redirect("documents:list")

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1,108 +0,0 @@
# 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

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

View File

@@ -1,529 +0,0 @@
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"],
}

View File

@@ -1,14 +0,0 @@
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"),
]

View File

@@ -1,27 +0,0 @@
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

@@ -1 +0,0 @@

View File

@@ -1,7 +0,0 @@
from django.apps import AppConfig
class ScenariosConfig(AppConfig):
"""Scenarios 模块应用配置。"""
default_auto_field = "django.db.models.BigAutoField"
name = "apps.scenarios"

View File

@@ -1,110 +0,0 @@
from pathlib import Path
import yaml
from django.conf import settings
REQUIRED_FIELDS = [
("id",),
("name",),
("description",),
("agent", "role"),
("agent", "goal"),
("agent", "instructions"),
("rag", "enabled"),
("tools",),
("output", "type"),
("audit", "enabled"),
]
class ScenarioNotFound(KeyError):
pass
class ScenarioValidationError(ValueError):
pass
def _get_nested(config: dict, path: tuple[str, ...]):
value = config
for key in path:
if not isinstance(value, dict) or key not in value:
raise ScenarioValidationError("缺失必填字段: " + ".".join(path))
value = value[key]
return value
def validate_scenario(config: dict) -> dict:
# 仅校验真正影响运行闭环的必填字段;
# 页面展示字段允许缺失,并在归一化阶段补默认值。
for field_path in REQUIRED_FIELDS:
_get_nested(config, field_path)
return normalize_scenario(config)
def normalize_scenario(config: dict) -> dict:
"""补齐页面和其它模块常用的派生字段,避免模板层重复判断。"""
normalized = dict(config)
normalized["applicable_questions"] = list(config.get("applicable_questions") or [])
normalized["rag"] = dict(config.get("rag", {}))
normalized["rag"]["enabled"] = bool(normalized["rag"].get("enabled"))
normalized["tools"] = list(config.get("tools") or [])
normalized["tool_count"] = len(normalized["tools"])
normalized["is_enabled"] = True
return normalized
def _scenario_files() -> list[Path]:
config_dir = Path(settings.SCENARIO_CONFIG_DIR)
if not config_dir.exists():
return []
return sorted([*config_dir.glob("*.yaml"), *config_dir.glob("*.yml")])
def _read_yaml_file(path: Path) -> dict:
with path.open("r", encoding="utf-8") as file:
return yaml.safe_load(file) or {}
def _collect_scenario_load_result() -> tuple[list[dict], list[dict]]:
"""
统一读取配置目录中的所有场景文件。
返回值:
- scenarios: 校验通过的场景列表
- issues: 非法 YAML / 缺字段等错误摘要,供首页展示
"""
scenarios = []
issues = []
for path in _scenario_files():
try:
config = _read_yaml_file(path)
scenarios.append(validate_scenario(config))
except (yaml.YAMLError, ScenarioValidationError) as exc:
issues.append(
{
"file_name": path.name,
"message": str(exc),
}
)
return scenarios, issues
def list_scenarios() -> list[dict]:
# 首页每次读取最新 YAML便于复试现场快速改题。
scenarios, _issues = _collect_scenario_load_result()
return scenarios
def list_scenario_issues() -> list[dict]:
"""返回配置异常摘要,便于页面明确提示而不是直接 500。"""
_scenarios, issues = _collect_scenario_load_result()
return issues
def get_scenario(scenario_id: str) -> dict:
for scenario in list_scenarios():
if scenario["id"] == scenario_id:
return scenario
raise ScenarioNotFound(f"场景不存在: {scenario_id}")

View File

@@ -1,10 +0,0 @@
from django.urls import path
from . import views
app_name = "scenarios"
urlpatterns = [
path("", views.index, name="index"),
]

View File

@@ -1,34 +0,0 @@
from django.shortcuts import render
from .services import list_scenario_issues, list_scenarios
def index(request):
# 首页只消费服务层给出的场景摘要和错误摘要,
# 不自行读取 YAML更不在 View 里做字段拼装。
return render(
request,
"scenarios/index.html",
{
"scenarios": list_scenarios(),
"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

@@ -1,8 +1,8 @@
"""ASGI config for the project."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

View File

@@ -1,46 +1,33 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
def load_dotenv(dotenv_path: Path) -> None:
"""
读取根目录 `.env` 并注入进程环境。
def load_env_file(file_path: Path) -> None:
"""Loads a simple KEY=VALUE .env file into process env without extra deps."""
这里使用极简解析逻辑,目的是减少额外依赖,
同时让本地 `runserver`、`pytest` 与 Docker Compose 共用一套配置文件。
"""
if not dotenv_path.exists():
if not file_path.exists():
return
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
for raw_line in file_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
os.environ.setdefault(key.strip(), value.strip())
load_dotenv(BASE_DIR / ".env")
load_env_file(BASE_DIR / ".env")
def env_bool(name: str, default: bool = False) -> bool:
"""将常见的字符串布尔值转换为 Python bool。"""
value = os.environ.get(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
# Django 核心运行参数。
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key")
DEBUG = env_bool("DJANGO_DEBUG", True)
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-v2-local-development-key")
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true"
ALLOWED_HOSTS = [
host.strip()
for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
for host in os.environ.get(
"DJANGO_ALLOWED_HOSTS",
"127.0.0.1,localhost,testserver",
).split(",")
if host.strip()
]
@@ -51,11 +38,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"apps.scenarios",
"apps.documents",
"apps.chat",
"apps.audit",
"apps.platform_ui",
"review_agent",
]
MIDDLEWARE = [
@@ -77,6 +60,7 @@ TEMPLATES = [
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
@@ -87,14 +71,20 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application"
# V1 默认使用 SQLite确保本地演示零外部依赖。
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "data" / "db.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
@@ -102,24 +92,88 @@ USE_TZ = True
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "media/"
# 上传根目录可通过环境变量覆盖,便于 Docker 挂载到持久化目录。
MEDIA_ROOT = Path(os.environ.get("UPLOAD_ROOT", BASE_DIR / "data" / "uploads"))
# 配置目录和 Chroma 数据目录都允许外部覆盖,方便复试现场快速切换。
SCENARIO_CONFIG_DIR = Path(os.environ.get("SCENARIO_CONFIG_DIR", BASE_DIR / "configs"))
GOVERNANCE_CONFIG_PATH = Path(
os.environ.get("GOVERNANCE_CONFIG_PATH", BASE_DIR / "configs" / "governance.yaml")
)
CHROMA_PATH = Path(os.environ.get("CHROMA_PATH", BASE_DIR / "data" / "chroma"))
# LLM 与 Embedding 默认遵循“尽量少配置也能跑”的策略:
# Embedding 未单独配置时自动复用 LLM 的 Key 和 Base URL。
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4.1-mini")
EMBEDDING_API_KEY = os.environ.get("EMBEDDING_API_KEY", LLM_API_KEY)
EMBEDDING_BASE_URL = os.environ.get("EMBEDDING_BASE_URL", LLM_BASE_URL)
EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "login"
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "")
REGULATORY_RAG_PROVIDER = os.environ.get("REGULATORY_RAG_PROVIDER", "siliconflow")
REGULATORY_RAG_CHROMA_PATH = os.environ.get(
"REGULATORY_RAG_CHROMA_PATH",
str(MEDIA_ROOT / "regulatory_review" / "rag" / "chroma"),
)
REGULATORY_RAG_COLLECTION = os.environ.get(
"REGULATORY_RAG_COLLECTION",
"nmpa_ivd_registration_v1",
)
REGULATORY_REVIEW_ASYNC = os.environ.get("REGULATORY_REVIEW_ASYNC", "true").lower() == "true"
REGULATORY_LLM_REVIEW_MAX_ATTEMPTS = int(os.environ.get("REGULATORY_LLM_REVIEW_MAX_ATTEMPTS", "3"))
REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = float(os.environ.get("REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS", "0.5"))
REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS = float(os.environ.get("REGULATORY_LLM_REVIEW_TIMEOUT_SECONDS", "15"))
SILICONFLOW_BASE_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1")
SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_API_KEY", LLM_API_KEY)
SILICONFLOW_EMBEDDING_MODEL = os.environ.get(
"SILICONFLOW_EMBEDDING_MODEL",
"Qwen/Qwen3-Embedding-4B",
)
SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024"))
FEISHU_NOTIFY_ENABLED = os.environ.get("FEISHU_NOTIFY_ENABLED", "false").lower() == "true"
FEISHU_NOTIFY_CHANNEL = os.environ.get("FEISHU_NOTIFY_CHANNEL", "feishu_api")
FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
FEISHU_DEFAULT_USER_OPEN_ID = os.environ.get("FEISHU_DEFAULT_USER_OPEN_ID", "")
FEISHU_DEFAULT_USER_ID = os.environ.get("FEISHU_DEFAULT_USER_ID", "")
FEISHU_DEFAULT_TARGET_NAME = os.environ.get("FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人")
FEISHU_TENANT_TOKEN_CACHE_SECONDS = int(os.environ.get("FEISHU_TENANT_TOKEN_CACHE_SECONDS", "6600"))
FEISHU_TOKEN_API_URL = os.environ.get(
"FEISHU_TOKEN_API_URL",
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
)
FEISHU_MESSAGE_API_URL = os.environ.get(
"FEISHU_MESSAGE_API_URL",
"https://open.feishu.cn/open-apis/im/v1/messages",
)
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://127.0.0.1:8000")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {
"suppress_workflow_status_poll": {
"()": "review_agent.logging_filters.SuppressWorkflowStatusPollFilter",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"filters": ["suppress_workflow_status_poll"],
},
},
"formatters": {
"verbose": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
},
},
"loggers": {
"review_agent": {
"handlers": ["console"],
"level": os.environ.get("REVIEW_AGENT_LOG_LEVEL", "INFO"),
"propagate": True,
},
"django.server": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}

View File

@@ -1,22 +1,32 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.views.generic import RedirectView
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
from django.urls import include, path
from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace
# 总路由只承担模块装配职责,不在这里写业务逻辑。
urlpatterns = [
path("", home_dashboard, name="home"),
path("chat/", workspace, name="chat"),
path("knowledge-base/", knowledge_base_manager, name="knowledge_base_manager"),
path("attachments/", attachment_manager, name="attachment_manager"),
path("", include("review_agent.urls")),
path("chat/stream/", stream_chat, name="chat_stream"),
path(
"login/",
LoginView.as_view(
template_name="registration/login.html",
redirect_authenticated_user=True,
),
name="login",
),
path("logout/", LogoutView.as_view(), name="logout"),
path(
"password/change/",
PasswordChangeView.as_view(
template_name="registration/password_change.html",
success_url="/",
),
name="password_change",
),
path("admin/", admin.site.urls),
# 首页默认进入审核工作台,旧的平台总览改为非默认入口,便于演示聚焦主链路。
path("", RedirectView.as_view(pattern_name="chat:index", permanent=False)),
path("scenarios/", include("apps.scenarios.urls")),
path("chat/", include("apps.chat.urls")),
path("documents/", include("apps.documents.urls")),
path("audit/", include("apps.audit.urls")),
path("platform/", include("apps.platform_ui.urls")),
]
if settings.DEBUG:
# 开发环境下直接通过 Django 提供上传文件访问能力,便于本地演示。
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,8 +1,8 @@
"""WSGI config for the project."""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
id: knowledge_qa
name: 知识库问答助手
description: 用于 SOP、制度、客服知识库和内部文档问答
applicable_questions:
- SOP 问答
- 制度问答
agent:
role: 知识库问答专家
goal: 基于知识库内容回答用户问题
instructions:
- 回答必须优先基于检索内容
- 不确定时说明缺失信息
rag:
enabled: true
collection: knowledge_qa
top_k: 5
tools:
- generate_action_items
output:
type: general_answer
audit:
enabled: true

View File

@@ -1,23 +0,0 @@
id: quality_analysis
name: 质量异常分析助手
description: 用于分析生产质量异常、检索 SOP、生成处理建议
applicable_questions:
- 质量异常分析
- 缺陷原因定位
agent:
role: 质量管理专家
goal: 根据用户问题、知识库和工具结果,输出可执行的质量分析报告
instructions:
- 回答必须基于知识库或工具结果
- 涉及质量风险时给出风险等级
rag:
enabled: true
collection: quality_analysis
top_k: 5
tools:
- query_demo_records
- calculate_rate
output:
type: quality_report
audit:
enabled: true

View File

@@ -1,24 +0,0 @@
id: risk_audit
name: 风险审核助手
description: 用于财务、采购、报销和合同风险审核
applicable_questions:
- 财务审核
- 采购审核
- 合同风险分析
agent:
role: 风险审核专家
goal: 识别业务材料中的风险点并给出审核建议
instructions:
- 风险点必须说明依据
- 缺失材料要单独列出
rag:
enabled: true
collection: risk_audit
top_k: 5
tools:
- check_required_fields
- calculate_rate
output:
type: risk_audit_report
audit:
enabled: true

View File

@@ -1,23 +0,0 @@
id: ticket_assistant
name: 工单处理助手
description: 用于客服、售后和运维工单的分类与回复建议
applicable_questions:
- 客服工单
- 售后工单
agent:
role: 工单处理专家
goal: 判断工单类别、优先级并生成处理建议
instructions:
- 需要人工处理时明确标记
- 回复建议要简洁可执行
rag:
enabled: true
collection: ticket_assistant
top_k: 5
tools:
- query_demo_records
- generate_action_items
output:
type: ticket_response
audit:
enabled: true

View File

@@ -1,25 +0,0 @@
**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,10 +0,0 @@
services:
web:
build: .
env_file:
- .env
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./configs:/app/configs

View File

@@ -28,7 +28,7 @@
- 自动识别缺失文件并通知责任人
- 参考法规来源网站:
<https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html>
<https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html>
<https://www.nmpa.gov.cn/>

Binary file not shown.

View File

@@ -0,0 +1,328 @@
# 自动汇总文件夹文件目录与页数流程需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx |
| 功能主题 | 自动汇总注册申报资料文件目录与页数 |
| 分析日期 | 2026-06-05 |
| 分析版本 | V1.0 |
---
## 一、需求背景
试剂盒 NMPA 注册申报过程中,研发或注册人员需要准备大量文件,包括产品技术要求、说明书、检测报告、临床评估资料等。原始材料中明确提出智能体需要具备“自动汇总注册申报文件夹中的所有文件及页数”的能力,作为后续法规完整性检查、缺失文件预警、信息提取和一致性核查的基础数据来源。
本功能目标是:用户在 AI 对话框上传注册申报资料压缩包、文件夹或多个散装文件后,系统自动扫描资料结构,统计各文件的目录层级、文件类型、页数、路径和处理状态,生成 Markdown 汇总报告与 Excel 文件,并在 AI 对话框中展示简表和下载链接,同时将汇总结果存入数据库。
---
## 二、需求范围
### 2.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 文件上传入口 | 用户通过现有 AI 对话框上传压缩包、文件夹或多个散装文件 |
| 2 | 文件解析 | 系统识别上传内容并展开为待扫描文件清单 |
| 3 | 目录汇总 | 保留原始目录层级,散装文件归入默认上传批次目录 |
| 4 | 页数统计 | 默认支持 pdf、doc、docx、xls、xlsx、ppt、pptx |
| 5 | 结果展示 | AI 对话框中展示 Markdown 简表 |
| 6 | 文件导出 | 生成 Markdown 报告与 Excel 汇总表,并在对话框提供下载链接 |
| 7 | 数据存档 | 上传批次、文件明细、页数、异常状态、导出文件信息写入数据库 |
### 2.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | NMPA 法规完整性核查 | 依赖目录汇总结果,属于后续流程 |
| 2 | 产品关键信息抽取 | 依赖具体文档内容解析,属于后续流程 |
| 3 | 文档一致性核查 | 依赖信息抽取结果,属于后续流程 |
| 4 | 合规风险预警 | 本功能仅输出文件扫描异常,不输出法规合规风险 |
---
## 三、用户角色与使用场景
| 角色 | 诉求 | 典型场景 |
| --- | --- | --- |
| 注册人员 | 快速了解申报资料是否完整、页数是否可统计 | 上传供应商或研发团队整理好的注册资料压缩包 |
| 审核人员 | 获得可复核的文件目录和页数清单 | 在审核前获取 Markdown 简表和 Excel 明细 |
| 系统管理员 | 追踪上传批次和处理异常 | 查看数据库存档,定位文件解析失败原因 |
---
## 四、业务流程分析
### 4.1 主流程
```text
用户进入 AI 对话框
-> 上传压缩包、文件夹或多个散装文件
-> 用户发送“自动汇总文件目录与页数”类指令
-> 系统创建上传批次
-> 系统保存原始上传文件
-> 系统识别上传类型
-> 如为压缩包则解压,如为文件夹或散装文件则直接扫描
-> 系统遍历文件并识别目录层级
-> 系统过滤支持的文件类型
-> 系统计数每个支持文件的页数
-> 系统记录不支持或统计失败的文件异常
-> 系统生成 Markdown 报告与 Excel 文件
-> 系统在 AI 对话框展示 Markdown 简表和下载链接
-> 系统将批次和明细数据写入数据库
-> 结束
```
### 4.2 分支流程
| 分支场景 | 流程说明 |
| --- | --- |
| 上传压缩包 | 系统校验压缩包格式,解压至临时工作目录,按解压后的目录结构统计 |
| 上传文件夹 | 系统保留文件夹层级,扫描所有子目录文件 |
| 上传多个散装文件 | 系统生成默认批次目录,将所有文件视为同一层级 |
| 存在不支持类型 | 文件进入明细表,页数为空,统计状态为“不支持”,异常说明记录文件类型 |
| 单个文件页数统计失败 | 不中断整个批次,统计状态为“失败”,异常说明记录失败原因 |
| 重名文件 | 以完整相对路径区分;散装文件重名时使用系统保存路径或自动重命名策略避免覆盖 |
### 4.3 异常流程
| 异常场景 | 处理方式 |
| --- | --- |
| 上传文件为空 | 对话框提示“未检测到可处理文件”,不生成报告 |
| 压缩包损坏或无法解压 | 批次状态标记为失败,对话框展示错误原因 |
| 文件被加密或受保护 | 明细记录该文件,页数统计失败,异常说明标记“文件加密或无法读取” |
| 文件过大或处理超时 | 明细记录超时状态,批次可继续处理其他文件 |
| Excel/Markdown 导出失败 | 对话框提示导出失败,数据库保留扫描明细与失败原因 |
---
## 五、功能模块梳理
### 5.1 核心功能
| 序号 | 功能名称 | 功能描述 | 优先级 |
| --- | --- | --- | --- |
| 1 | 上传资料接收 | 支持用户在 AI 对话框上传压缩包、文件夹或多个散装文件 | P0 |
| 2 | 上传批次管理 | 为每次汇总创建批次编号,记录用户、时间、来源会话和处理状态 | P0 |
| 3 | 压缩包解压 | 支持压缩包上传后的安全解压和目录结构保留 | P0 |
| 4 | 文件遍历扫描 | 递归扫描上传范围内所有文件,生成相对路径和目录层级 | P0 |
| 5 | 文件类型识别 | 根据扩展名和必要的文件头信息识别 pdf、doc、docx、xls、xlsx、ppt、pptx | P0 |
| 6 | 页数统计 | 对支持文件统计页数或页签/幻灯片数量 | P0 |
| 7 | Markdown 简表展示 | 在 AI 对话框展示可解析的 Markdown 表格摘要 | P0 |
| 8 | Markdown 报告导出 | 生成完整 Markdown 汇总报告 | P0 |
| 9 | Excel 明细导出 | 生成 Excel 文件,包含所有文件明细和统计状态 | P0 |
| 10 | 数据库存档 | 保存批次、文件明细、统计结果、异常说明和导出文件记录 | P0 |
### 5.2 辅助功能
| 序号 | 功能名称 | 功能描述 | 优先级 |
| --- | --- | --- | --- |
| 1 | 下载链接生成 | 在 AI 回复中提供 Markdown 报告和 Excel 文件下载链接 | P0 |
| 2 | 处理进度提示 | 对较大批次展示“上传中、解析中、统计中、导出中、已完成”等状态 | P1 |
| 3 | 文件过滤规则 | 支持过滤系统隐藏文件、临时文件和空文件 | P1 |
| 4 | 统计摘要 | 输出文件总数、支持文件数、统计成功数、失败数、总页数 | P1 |
| 5 | 历史记录查询 | 可通过对话记录或批次记录回看历史汇总结果 | P1 |
### 5.3 隐含功能
| 序号 | 功能名称 | 功能描述 | 来源说明 |
| --- | --- | --- | --- |
| 1 | AI 对话框附件上传能力 | 现有对话框需要支持上传附件并关联会话消息 | 用户要求“在 AI 对话框中提供下载连接” |
| 2 | Markdown 渲染能力 | 现有对话框需要能解析表格、链接等 Markdown 内容 | 用户要求“对话框能正常解析 md 格式” |
| 3 | 文件访问权限控制 | 下载链接只能允许当前用户或授权用户访问 | 涉及注册资料敏感文件 |
| 4 | 临时文件清理 | 解压目录和处理中间文件需要定期清理 | 压缩包和大文件处理会产生临时文件 |
---
## 六、数据实体分析
### 6.1 实体列表
| 序号 | 实体名称 | 字段说明 | 关联实体 |
| --- | --- | --- | --- |
| 1 | 汇总批次 | 批次编号、用户、会话、上传类型、文件数量、总页数、状态、创建时间、完成时间 | 会话、文件明细、导出文件 |
| 2 | 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 | 汇总批次 |
| 3 | 导出文件 | 文件类型、文件名、保存路径、下载地址、生成状态、生成时间 | 汇总批次 |
| 4 | 上传原始文件 | 原始文件名、保存路径、大小、文件类型、上传时间 | 汇总批次 |
### 6.2 文件明细字段
| 字段 | 类型建议 | 必填 | 说明 |
| --- | --- | --- | --- |
| file_index | Integer | 是 | 文件序号,按目录遍历顺序生成 |
| directory_level | String | 是 | 目录层级如“1/2/3”或“注册资料/产品技术要求” |
| file_name | String | 是 | 文件名,不含目录 |
| file_type | String | 是 | 文件扩展名或识别后的类型 |
| page_count | Integer | 否 | 页数,统计失败或不支持时为空 |
| relative_path | String | 是 | 相对上传根目录的路径 |
| statistics_status | String | 是 | 成功、失败、不支持、跳过 |
| error_message | Text | 否 | 异常说明 |
### 6.3 实体关系
```text
会话 1:N 汇总批次
汇总批次 1:N 上传原始文件
汇总批次 1:N 文件明细
汇总批次 1:N 导出文件
```
---
## 七、输出要求
### 7.1 AI 对话框简表
AI 回复内容必须使用前端可解析的 Markdown 格式,至少包含统计摘要、文件简表和下载链接。
示例:
```markdown
已完成文件目录与页数汇总。
| 指标 | 数量 |
| --- | --- |
| 文件总数 | 24 |
| 统计成功 | 21 |
| 统计失败 | 2 |
| 不支持 | 1 |
| 总页数 | 386 |
| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | 注册资料/说明书 | 说明书.docx | docx | 12 | 成功 | |
| 2 | 注册资料/检测报告 | 检测报告.pdf | pdf | 38 | 成功 | |
[下载 Markdown 报告](download-url)
[下载 Excel 明细](download-url)
```
### 7.2 Markdown 报告
Markdown 报告应包含:
| 模块 | 内容 |
| --- | --- |
| 汇总信息 | 批次编号、上传用户、上传时间、处理完成时间、上传类型 |
| 统计摘要 | 文件总数、支持文件数、成功数、失败数、不支持数、总页数 |
| 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 |
| 异常清单 | 统计失败、不支持、解压失败、加密文件、超时文件 |
| 处理说明 | 支持范围、页数统计口径、待确认事项 |
### 7.3 Excel 导出
Excel 至少包含两个工作表:
| 工作表 | 内容 |
| --- | --- |
| 汇总信息 | 批次信息、统计摘要 |
| 文件明细 | 文件序号、目录层级、文件名、文件类型、页数、路径、统计状态、异常说明 |
---
## 八、页数统计口径
| 文件类型 | 统计口径 | 备注 |
| --- | --- | --- |
| pdf | PDF 页面数量 | 可使用 pdfplumber 或 PyMuPDF |
| doc | Word 文档页数 | 可通过 LibreOffice 转 PDF 后统计,或读取文档属性;实现方案待技术验证 |
| docx | Word 文档页数 | 可通过 LibreOffice 转 PDF 后统计,或读取文档属性;实现方案待技术验证 |
| xls | Excel 工作表打印页数或页签数量 | 具体口径待确认 |
| xlsx | Excel 工作表打印页数或页签数量 | 具体口径待确认 |
| ppt | 幻灯片数量 | 可转换或读取演示文稿结构 |
| pptx | 幻灯片数量 | 可读取演示文稿结构 |
> 待确认Excel 的“页数”是否按打印分页统计,还是按工作表数量统计。建议 Demo 阶段先按工作表数量统计,并在报告中明确口径。
---
## 九、非功能性需求
### 9.1 性能要求
| 项目 | 要求 |
| --- | --- |
| 小批次文件 | 50 个文件以内,应在 30 秒内完成汇总 |
| 中等批次文件 | 200 个文件以内,应支持异步处理或进度提示 |
| 大文件处理 | 单文件超时不影响其他文件统计 |
### 9.2 安全要求
| 项目 | 要求 |
| --- | --- |
| 上传安全 | 限制可处理类型,避免执行上传文件中的脚本或宏 |
| 解压安全 | 防止路径穿越,限制解压目录在系统工作目录内 |
| 下载权限 | 下载链接需要校验登录用户和会话权限 |
| 数据隔离 | 不同用户上传文件和统计结果不可互相访问 |
| 敏感资料保护 | 原始上传文件、报告和 Excel 文件应存储在受控目录 |
### 9.3 可用性要求
| 项目 | 要求 |
| --- | --- |
| 对话展示 | Markdown 表格、链接在现有 AI 对话框中可正常渲染 |
| 失败可见 | 失败文件需要展示具体原因,便于用户补传或修复 |
| 可追溯 | 每份导出报告能关联到具体上传批次和会话 |
---
## 十、疑问点与待确认事项
### 10.1 功能疑问
| 序号 | 疑问内容 | 建议 | 状态 |
| --- | --- | --- | --- |
| 1 | “文件目录参考附件”尚未上传,无法确认标准目录样例 | 附件上传后补充目录样例和字段映射 | 待确认 |
| 2 | 文件夹上传在浏览器端是否采用目录上传能力 | 前端可使用 webkitdirectory 或改为要求用户上传压缩包 | 待确认 |
| 3 | 散装文件是否需要用户手动指定归属目录 | Demo 阶段统一归入“散装上传”目录 | 待确认 |
### 10.2 业务规则疑问
| 序号 | 疑问内容 | 建议 | 状态 |
| --- | --- | --- | --- |
| 1 | Excel 页数统计口径不明确 | Demo 阶段按工作表数量,正式版可按打印分页 | 待确认 |
| 2 | doc/docx 页数是否必须与 Word 打开后的实际页数完全一致 | 建议通过转换 PDF 后统计,提高一致性 | 待确认 |
| 3 | 是否需要按 NMPA 申报资料目录自动排序 | 本功能先按上传目录顺序,后续法规完整性检查再做标准目录匹配 | 待确认 |
### 10.3 技术方案疑问
| 序号 | 疑问内容 | 可选方案 | 状态 |
| --- | --- | --- | --- |
| 1 | Office 文档页数统计依赖 | LibreOffice 转 PDF、文档属性读取、商业 Office 自动化 | 待确认 |
| 2 | 大文件处理模式 | 同步处理、后台任务、队列任务 | 待确认 |
| 3 | Markdown 渲染方案 | 前端引入 Markdown 渲染库,或后端转换为安全 HTML | 待确认 |
### 10.4 数据定义疑问
| 序号 | 疑问内容 | 建议 | 状态 |
| --- | --- | --- | --- |
| 1 | 导出文件保存周期未明确 | Demo 阶段永久保存,正式版增加过期清理策略 | 待确认 |
| 2 | 原始上传文件是否长期保留 | 建议至少保留到关联审核流程结束 | 待确认 |
| 3 | 下载链接是否需要一次性或有效期控制 | 注册资料敏感,建议正式版增加有效期和权限校验 | 待确认 |
---
## 十一、验收标准
| 序号 | 验收项 | 验收标准 |
| --- | --- | --- |
| 1 | 压缩包上传汇总 | 上传包含多层目录的压缩包后,系统能保留层级并统计支持文件页数 |
| 2 | 散装文件上传汇总 | 上传多个散装文件后,系统能生成同一批次汇总结果 |
| 3 | Markdown 展示 | AI 对话框能展示摘要表和文件简表,表格格式正常 |
| 4 | 下载链接 | AI 回复中提供 Markdown 报告和 Excel 明细下载链接,点击可下载 |
| 5 | 数据存档 | 数据库中能查询到批次记录、文件明细和导出文件记录 |
| 6 | 异常不中断 | 单个文件失败时不影响其他文件统计,失败原因可见 |
| 7 | 支持类型覆盖 | pdf、doc、docx、xls、xlsx、ppt、pptx 均能进入处理流程 |
---
## 十二、下一步建议
1. 上传“文件目录参考附件”,补充标准目录样例和演示数据。
2. 明确 Excel 页数统计口径,决定 Demo 版是否按工作表数量统计。
3. 设计数据库表结构,包括汇总批次、文件明细、上传原始文件和导出文件。
4. 改造 AI 对话框增加附件上传、Markdown 渲染和下载链接展示能力。
5. 实现文件扫描与页数统计服务,优先打通 pdf、docx、xlsx、pptx 的 Demo 流程。

View File

@@ -0,0 +1,330 @@
# NMPA 注册资料法规核查与整改闭环工作流需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx |
| 法规资料来源 | docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 |
| 参考来源 URL | https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html |
| 备用法规入口 | https://www.nmpa.gov.cn/ |
| 工作流名称 | NMPA 注册资料法规核查与整改闭环工作流 |
| 分析日期 | 2026-06-05 |
| 分析版本 | V1.0 |
---
## 一、需求背景
试剂盒及医疗器械 NMPA 注册申报资料准备过程中,注册人员不仅需要确认申报资料文件是否齐全,还需要进一步检查关键文件章节结构是否符合要求、不同文件中的核心信息是否一致,并对发现的问题形成风险预警和整改闭环。
原始任务中的第 2、4、5 条能力本质上属于同一条法规核查工作流:
| 原任务编号 | 能力要求 | 在本工作流中的定位 |
| --- | --- | --- |
| 2 | 按照 NMPA 现行法规要求核查文件完整性 | 法规资料完整性核查 |
| 4 | 核查文档结构、信息一致性与章节规范性 | 章节结构核查与跨文件一致性核查 |
| 5 | 提供合规风险预警与处理建议 | 风险分级、整改建议、通知与复核闭环 |
本工作流目标是:基于上一阶段生成的文件目录与页数汇总结果,结合本地法规资料包中的 CMDE 公告要求,对上传申报资料执行法规适用条件确认、资料完整性核查、章节结构核查、跨文件一致性核查,输出风险清单、法规核查矩阵、整改建议,并通过系统内提示和飞书通知责任人,支持补充资料后的复核与关闭。
---
## 二、需求范围
### 2.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 法规资料来源 | Demo 阶段暂按本地 CMDE 公告资料包执行,不做实时网页检索 |
| 2 | 适用品类 | 建立医疗器械/体外诊断试剂通用核查框架,优先覆盖体外诊断试剂注册申报资料 |
| 3 | 产品信息提取 | 从上传资料中提取产品类别、注册类型、临床评价路径、产品名称、预期用途、检测靶标、样本类型、储存条件等信息 |
| 4 | 适用条件确认 | 系统先自动提取适用条件,再由用户确认后执行完整核查 |
| 5 | 法规资料完整性核查 | 对照法规清单检查必需文件、条件性文件、建议文件是否存在 |
| 6 | 文件项级完整性核查 | 不仅判断文件类别是否存在,还检查关键子项、章节或字段是否齐全 |
| 7 | 章节结构核查 | 检查产品技术要求、说明书、检验报告等关键文件是否包含法规要求章节 |
| 8 | 跨文件一致性核查 | 检查产品名称、型号规格、适用范围、样本类型等核心信息在不同文件中是否一致 |
| 9 | 风险分级 | 按阻断项、高风险、中风险、低风险、提示项五级统一排序 |
| 10 | 整改建议 | 先按规则模板生成标准建议,再由 AI 整理为易读表达 |
| 11 | 责任人通知 | 默认按上传人识别责任人;上传人是当前用户时飞书通知 @ 当前用户 |
| 12 | 整改复核 | 支持重新上传整包复核,也支持仅上传缺失文件合并到原批次后复核 |
| 13 | 输出留痕 | 输出对话框摘要、Markdown 报告、Excel 缺失清单、飞书通知记录 |
### 2.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 官网法规实时更新 | 本期不实时抓取 NMPA/CMDE 官网,后续可扩展为内置清单加定期更新 |
| 2 | 全品类法规覆盖 | 本期建立通用框架,但规则内容优先围绕体外诊断试剂注册资料 |
| 3 | 自动关闭所有风险 | 待确认项和复核通过关闭前需要人工确认 |
| 4 | 企业级责任矩阵 | 本期默认按上传人通知,后续再支持按项目、部门、文件类别分配责任人 |
---
## 三、用户角色与使用场景
| 角色 | 诉求 | 典型场景 |
| --- | --- | --- |
| 注册人员 | 快速发现缺失资料、结构不规范和核心信息冲突 | 上传注册申报资料包后发起法规核查工作流 |
| 审核人员 | 获得可追溯的法规核查矩阵和风险清单 | 在正式申报前复核资料是否达到提交条件 |
| 资料上传人 | 接收缺失项和风险项通知,并完成补充整改 | 系统发现阻断项、高风险或中风险后通过飞书 @ 上传人 |
| 系统管理员 | 维护法规规则、通知配置和核查记录 | 查看核查批次、规则版本、飞书发送记录和复核状态 |
---
## 四、工作流主流程
### 4.1 主流程
```text
用户在 AI 对话框上传申报资料
-> 系统执行文件清单汇总与页数统计
-> 用户发起“NMPA 注册资料法规核查与整改闭环”工作流
-> 系统读取本地法规资料包和内置法规清单
-> 系统从上传资料中提取产品类别、注册类型、临床评价路径和产品关键信息
-> 系统在对话框展示适用条件识别结果,请用户确认或修正
-> 用户确认适用条件
-> 系统裁剪本次适用的法规核查清单
-> 系统并行执行法规资料完整性核查、章节结构核查、跨文件一致性核查
-> 系统汇总问题并按风险等级排序
-> 系统生成整改建议、法规依据和证据说明
-> 系统在对话框展示风险摘要
-> 系统生成 Markdown 报告、Excel 缺失清单和飞书通知记录
-> 对阻断项、高风险、中风险发送飞书通知并 @ 上传人
-> 上传人补充资料后发起整包重核或缺失文件补传复核
-> 系统复核整改项
-> 待确认项和复核通过关闭前由用户人工确认
-> 风险项关闭或驳回
```
### 4.2 工作流节点
| 节点编码 | 节点名称 | 输入 | 输出 |
| --- | --- | --- | --- |
| inventory | 文件清单汇总 | 上传资料、目录扫描结果 | 文件清单、页数、解析状态 |
| info_extract | 产品信息提取 | 文件名、目录名、文档首页或前几页内容 | 产品类别、注册类型、临床评价路径、产品关键信息 |
| condition_confirm | 适用条件确认 | 系统提取结果 | 用户确认后的适用条件 |
| rule_scope | 法规清单裁剪 | 法规规则库、适用条件 | 本次适用的核查清单 |
| completeness_check | 法规资料完整性核查 | 文件清单、法规核查清单 | 缺失文件、条件性缺失、待确认项 |
| structure_check | 章节结构核查 | 关键文件文本、章节模板 | 缺失章节、异常章节、格式问题 |
| consistency_check | 跨文件一致性核查 | 关键字段抽取结果 | 信息冲突项、证据文件、冲突字段 |
| risk_assess | 风险分级与建议生成 | 所有核查问题 | 风险等级、法规依据、整改建议 |
| notify | 系统提示与飞书通知 | 风险清单、责任人 | 对话提示、飞书通知记录 |
| rectify_review | 整改复核 | 补充资料、原风险项 | 复核结论、状态变更 |
---
## 五、核心核查规则
### 5.1 法规资料完整性核查
系统对照法规清单检查本次申报应提交的文件是否齐全。Demo 阶段法规依据暂按本地 CMDE 公告资料包维护,后续可扩展为可配置规则库。
| 检查项 | 检查说明 |
| --- | --- |
| 必需文件 | 缺失后直接影响申报资料完整性的文件,如注册申报资料基本要求、产品技术要求、说明书、注册检验报告等 |
| 条件性文件 | 是否必需取决于产品类别、注册类型、临床评价路径等适用条件 |
| 建议文件 | 有助于完善资料但不一定构成阻断的问题 |
| 文件项级子项 | 检查关键文件内部是否存在必需章节、附件、签章页、结论页或关键字段 |
### 5.2 文件匹配规则
系统采用三层匹配识别文件是否满足法规文件项:
| 匹配层级 | 说明 | 示例 |
| --- | --- | --- |
| 文件名匹配 | 根据文件名关键词识别文件类别 | 文件名包含“产品技术要求” |
| 目录名匹配 | 文件名不明确时参考所在目录 | 文件位于“注册检验/报告”目录 |
| 首页内容匹配 | 文件名和目录名不足以判断时读取首页或前几页文本辅助识别 | 首页标题包含“医疗器械注册检验报告” |
匹配结果需要记录命中证据,包括命中的文件路径、关键词、页码或文本片段,便于人工复核。
### 5.3 章节结构核查
系统对关键文件执行章节结构检查,判断是否存在法规或模板要求的章节。
| 文件类别 | 章节核查示例 |
| --- | --- |
| 产品技术要求 | 检查性能指标、检验方法、术语、附录等章节 |
| 说明书 | 检查产品名称、包装规格、预期用途、检验原理、主要组成成分、储存条件、有效期、样本要求、检验方法、阳性判断值或参考区间等章节 |
| 注册检验报告 | 检查封面、样品信息、检验依据、检验项目、检验结论、签章页等内容 |
| 临床评价资料 | 检查临床评价路径、数据来源、评价结论、适用性说明等内容 |
### 5.4 跨文件一致性核查
系统抽取不同文件中的核心字段并进行一致性比对。
| 字段 | 典型来源文件 | 不一致示例 |
| --- | --- | --- |
| 产品名称 | 申请表、说明书、产品技术要求、检验报告 | 说明书和检验报告中的产品名称不一致 |
| 型号规格 | 说明书、产品技术要求、检验报告 | 规格型号缺失或描述不一致 |
| 预期用途 | 说明书、临床评价资料、注册申报表 | 适用人群或检测目的描述冲突 |
| 检测靶标 | 说明书、产品技术要求、性能研究资料 | 靶标名称、缩写或组合不一致 |
| 样本类型 | 说明书、检验报告、性能研究资料 | 样本类型范围不一致 |
| 储存条件 | 说明书、标签、稳定性研究资料 | 温度条件或有效期描述不一致 |
---
## 六、风险等级与通知策略
### 6.1 风险等级
| 风险等级 | 定义 | 示例 |
| --- | --- | --- |
| 阻断项 | 直接影响资料能否进入有效申报或审核的严重问题 | 缺少法规必需文件;关键文件损坏、加密或页数为 0产品名称/型号规格在关键文件中严重冲突 |
| 高风险 | 可能导致注册审评补正或重大整改的问题 | 关键章节缺失;注册检验报告缺少结论页;临床评价路径资料不完整 |
| 中风险 | 需要补充说明或修改,但不一定阻断整体资料审核的问题 | 个别关键字段缺失;章节标题不规范;证据页码不明确 |
| 低风险 | 对申报完整性影响较小,但建议修正的问题 | 文件命名不规范;目录层级不清晰 |
| 提示项 | 系统无法充分判断或建议人工关注的问题 | 条件性文件是否适用待人工确认 |
### 6.2 通知策略
| 风险等级 | 系统内提示 | 飞书通知 |
| --- | --- | --- |
| 阻断项 | 是 | 是,@ 上传人 |
| 高风险 | 是 | 是,@ 上传人 |
| 中风险 | 是 | 是,@ 上传人 |
| 低风险 | 是 | 否 |
| 提示项 | 是 | 否 |
责任人识别规则:本期默认按上传人识别责任人;上传人是当前用户时,系统内提示归属当前用户,飞书通知中 @ 当前用户。后续可扩展为项目责任人表、文件类别责任矩阵或目录归属规则。
---
## 七、整改建议与闭环状态
### 7.1 整改建议生成
整改建议采用“规则模板 + AI 润色”的方式生成:
| 步骤 | 说明 |
| --- | --- |
| 规则模板 | 根据问题类型、法规依据、风险等级生成标准整改动作 |
| AI 整理 | 将标准动作整理为适合对话框和飞书通知阅读的表达 |
| 人工可复核 | 输出中保留法规依据、证据文件、命中规则和建议动作 |
示例:
```text
问题:缺少注册检验报告。
风险等级:阻断项。
整改建议:请补充与本产品一致的注册检验报告,并确保报告包含样品信息、检验依据、检验项目、检验结论和签章页。
责任人:上传人。
通知方式:系统内提示 + 飞书 @ 上传人。
```
### 7.2 问题状态流转
| 状态 | 含义 | 触发方式 |
| --- | --- | --- |
| 待确认 | 系统发现条件性问题或判断依据不足 | 系统自动生成,等待用户确认 |
| 待处理 | 问题已确认,需要补充或修改资料 | 用户确认或系统自动判定 |
| 已补充 | 责任人已上传补充资料或替换文件 | 用户上传资料后自动进入 |
| 复核通过 | 系统复核后判断问题已解决 | 系统自动判断,关闭前需人工确认 |
| 复核不通过 | 系统复核后判断问题仍存在 | 系统自动判断 |
| 已关闭 | 用户确认问题已解决并关闭 | 人工确认 |
### 7.3 复核方式
| 复核方式 | 适用场景 |
| --- | --- |
| 重新上传整包复核 | 资料包整体结构有较大调整,或需要重新生成完整核查报告 |
| 仅上传缺失文件复核 | 只补充少量缺失文件或替换单个问题文件 |
待确认项和复核通过关闭前需要人工确认;其他状态可由系统根据核查结果自动流转。
---
## 八、输出要求
### 8.1 AI 对话框摘要
对话框摘要应优先展示风险分布和需要处理的事项。
```markdown
已完成 NMPA 注册资料法规核查。
| 风险等级 | 数量 |
| --- | --- |
| 阻断项 | 2 |
| 高风险 | 3 |
| 中风险 | 5 |
| 低风险 | 4 |
| 提示项 | 2 |
| 风险等级 | 问题 | 责任人 | 建议 |
| --- | --- | --- | --- |
| 阻断项 | 缺少注册检验报告 | 上传人 | 请补充注册检验报告并重新复核 |
| 高风险 | 说明书缺少储存条件章节 | 上传人 | 请补充储存条件和有效期说明 |
[下载 Markdown 核查报告](download-url)
[下载 Excel 缺失清单](download-url)
```
### 8.2 Markdown 核查报告
Markdown 报告至少包含:
| 模块 | 内容 |
| --- | --- |
| 核查概览 | 批次编号、上传人、法规资料来源、规则版本、核查时间 |
| 适用条件 | 产品类别、注册类型、临床评价路径、产品关键信息及用户确认记录 |
| 风险清单 | 风险等级、问题描述、法规依据、证据、责任人、整改建议、状态 |
| 法规核查矩阵 | 法规文件项、是否适用、应提交资料、匹配文件、缺失情况、结论 |
| 章节结构核查 | 文件类别、章节要求、识别章节、缺失章节、结论 |
| 一致性核查 | 字段名称、来源文件、字段值、冲突说明、结论 |
| 飞书通知记录 | 通知时间、通知对象、通知等级、发送状态 |
| 复核记录 | 补充资料、复核时间、复核结果、关闭确认人 |
### 8.3 Excel 缺失清单
Excel 至少包含以下工作表:
| 工作表 | 内容 |
| --- | --- |
| 风险清单 | 所有风险项、等级、责任人、状态、建议 |
| 法规核查矩阵 | 应有文件、实际匹配文件、缺失情况、法规依据 |
| 章节结构问题 | 缺失章节、异常章节、文件路径、页码或证据 |
| 一致性问题 | 冲突字段、来源文件、字段值、风险等级 |
| 通知记录 | 飞书发送对象、发送时间、发送状态、通知内容摘要 |
---
## 九、非功能性需求
### 9.1 可追溯性
| 要求 | 说明 |
| --- | --- |
| 规则版本留痕 | 每次核查记录使用的法规资料来源和规则版本 |
| 证据留痕 | 每个问题记录命中文件、路径、页码或文本片段 |
| 通知留痕 | 飞书通知需要记录发送对象、发送状态、发送时间和内容摘要 |
| 状态留痕 | 问题状态变化需要记录操作人、操作时间和变化原因 |
### 9.2 安全要求
| 要求 | 说明 |
| --- | --- |
| 文件访问控制 | 核查报告、Excel 清单和原始资料仅授权用户可访问 |
| 敏感信息保护 | 飞书通知只展示必要摘要,不直接暴露完整敏感文件内容 |
| 人工确认 | 条件适用性和风险关闭前需人工确认,避免系统误判 |
### 9.3 性能要求
| 场景 | 要求 |
| --- | --- |
| 小批次资料 | 50 个文件以内应在 1 分钟内完成初步风险摘要 |
| 中等批次资料 | 200 个文件以内支持后台异步处理和进度提示 |
| 单文件解析失败 | 不阻断整个批次,记录异常并继续其他文件核查 |
---
## 十、待后续确认事项
| 序号 | 待确认项 | 当前建议 |
| --- | --- | --- |
| 1 | 飞书集成方式 | Demo 阶段可使用飞书 CLI、Webhook 或类似命令行工具发送通知 |
| 2 | 当前用户与飞书账号映射 | 需要维护用户账号与飞书 open_id、手机号或邮箱的映射关系 |
| 3 | 法规清单结构 | 功能设计阶段需将本地 CMDE 公告拆解为结构化规则表 |
| 4 | 条件性文件适用规则 | 先由系统提取并让用户确认,后续逐步自动化 |
| 5 | 跨文件一致性字段范围 | Demo 阶段优先覆盖产品名称、型号规格、预期用途、样本类型、储存条件 |

View File

@@ -0,0 +1,394 @@
# 产品关键信息提取与申报文件自动填表需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始材料 | docs/原始材料/【模拟题二】试剂盒临床注册文件准备与审核Agent.docx |
| 法规模板来源 | docs/原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 |
| 功能主题 | 从产品文件中提取关键信息并自动填写至指定申报模板 |
| 分析日期 | 2026-06-07 |
| 分析版本 | V1.0 |
---
## 一、需求背景
试剂盒及体外诊断试剂注册申报过程中,注册人员需要将同一批产品关键信息重复填写到注册证格式文件、变更注册或备案文件、安全和性能基本原则清单等申报材料中。人工复制粘贴容易出现字段遗漏、表述不一致、来源不可追溯和模板误改等问题。
原始任务中的第 3 条能力要求系统能够“从产品文件中提取关键信息并自动填写至目标文件”。本功能目标是:系统基于用户上传的产品说明书、产品技术要求、检测报告、性能研究资料等文件,自动抽取产品名称、检测靶标、适用范围、储存条件、性能指标等核心信息,复制指定法规模板生成可填写副本,将抽取结果写入模板,并输出 Word 与 PDF 两种下载文件。
本功能是前两批能力的后续增强依赖第一批文件汇总结果定位产品文件复用第二批文本抽取、适用条件确认和一致性核查能力同时新增“模板识别、字段映射、模板填充、冲突高亮、PDF 转换、来源追溯”能力。
---
## 二、需求范围
### 2.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 目标模板复制 | 从原始法规资料中复制指定模板,不覆盖原始文件 |
| 2 | 注册类型选择 | 首次注册填写注册证格式;变更注册或备案填写变更注册(备案)文件格式 |
| 3 | 安全和性能基本原则清单填写 | 无论首次注册或变更注册,均生成并填写安全和性能基本原则清单 |
| 4 | 产品信息提取 | 从产品说明书、产品技术要求、检测报告、性能研究资料等文件中抽取模板所需字段 |
| 5 | 模板字段识别 | 读取目标模板中的表格、段落、占位栏位和清单条目,建立字段映射 |
| 6 | 自动填入模板 | 将抽取字段写入模板副本,缺失字段保持留空 |
| 7 | 冲突标记 | 同一字段在多个文件中不一致时,按说明书为准填写,并在模板中黄色底色、红色字体标记 |
| 8 | 冲突摘要展示 | AI 对话框展示冲突字段、采用值、冲突来源和待用户下载确认提示 |
| 9 | Word 导出 | 输出填好的 `.docx` 或可编辑 Word 文件 |
| 10 | PDF 导出 | 将填好的 Word 转换为 PDF尽量保持原 Word 模板版式一致,可用于正式提交前预览 |
| 11 | 来源追溯 | 允许额外输出字段来源追溯清单,记录字段来源文件、文本片段、冲突状态和填入目标 |
### 2.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 直接覆盖原始法规模板 | 原始材料只作为模板来源,不允许被改写 |
| 2 | 自动代替人工最终确认 | 系统生成带标记文件,用户自行下载核对确认 |
| 3 | 在线提交 NMPA 系统 | 本期只生成申报文件,不对接外部申报系统 |
| 4 | 全部法规表单覆盖 | 本期仅覆盖用户指定的三个目标模板 |
| 5 | 复杂版式人工校订 | 系统尽量保持模板版式,复杂错位仍需人工最终复核 |
---
## 三、目标模板
本期一共处理三个目标模板。用户此前重复提到“体外诊断试剂安全和性能基本原则清单”,经确认属于误填,实际只有一个该清单模板。
| 序号 | 模板名称 | 原始文件 | 使用条件 | 输出要求 |
| --- | --- | --- | --- | --- |
| 1 | 中华人民共和国医疗器械注册证(体外诊断试剂)(格式) | 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx | 首次注册 | Word + PDF |
| 2 | 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式) | 中华人民共和国医疗器械变更注册(备案)文件(体外诊断试剂)(格式).doc | 变更注册或备案 | Word + PDF |
| 3 | 体外诊断试剂安全和性能基本原则清单 | 体外诊断试剂安全和性能基本原则清单.doc | 首次注册、变更注册、备案均适用 | Word + PDF |
### 3.1 已识别注册证模板字段
`中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx` 中已识别到以下表格栏目:
| 字段 | 填写规则 |
| --- | --- |
| 注册人名称 | 从申请人、注册人、企业信息类文件中抽取 |
| 注册人住所 | 从申请人、注册人、企业信息类文件中抽取 |
| 生产地址 | 从注册资料、说明书、质量体系或生产信息文件中抽取 |
| 代理人名称 | 进口体外诊断试剂适用,境内产品可留空 |
| 代理人住所 | 进口体外诊断试剂适用,境内产品可留空 |
| 产品名称 | 优先取说明书字段 |
| 包装规格 | 对应型号规格、包装规格 |
| 主要组成成分 | 优先取说明书和产品技术要求 |
| 预期用途 | 对应适用范围、预期用途 |
| 产品储存条件及有效期 | 对应储存条件、有效期 |
| 附件 | 默认包含产品技术要求、说明书,可根据实际文件匹配补充 |
| 其他内容 | 未识别或需人工确认时留空 |
| 备注 | 未识别或需人工确认时留空 |
### 3.2 模板解析约束
变更注册(备案)文件格式和安全和性能基本原则清单当前为 `.doc` 格式。系统实施时需要支持以下任一方案:
| 方案 | 说明 |
| --- | --- |
| LibreOffice 转换 | 使用 LibreOffice/soffice 将 `.doc` 转为 `.docx` 后识别和填写 |
| 预转换模板 | 项目内预先保存经人工确认的 `.docx` 模板副本 |
| OOXML/COM 方案 | 在 Windows 环境通过 Office 自动化读取和转换模板 |
无论采用哪种方式转换后的模板必须保留原文件表格结构、分页、字体和版式PDF 导出需以填好的 Word 为来源。
---
## 四、用户角色与使用场景
| 角色 | 诉求 | 典型场景 |
| --- | --- | --- |
| 注册人员 | 减少重复填表,提高字段一致性 | 上传注册资料包后生成已填注册证格式和基本原则清单 |
| 变更注册负责人 | 根据变更类型生成变更注册或备案文件 | 上传变更资料后生成已填变更注册(备案)文件 |
| 审核人员 | 快速定位字段来源和冲突 | 下载带冲突高亮的 Word/PDF并查看 AI 对话框冲突摘要 |
| 系统管理员 | 维护模板版本和转换能力 | 更新法规模板、检查 PDF 转换服务和导出记录 |
---
## 五、业务流程分析
### 5.1 主流程
```text
用户上传产品注册资料
-> 系统执行文件目录与页数汇总
-> 系统执行法规核查前置文本抽取
-> 系统识别注册类型:首次注册、变更注册或备案
-> 系统选择本次适用目标模板
-> 系统复制原始模板到批次工作目录
-> 系统读取目标模板栏目和清单条目
-> 系统从产品文件中抽取模板所需字段
-> 系统按字段优先级合并抽取结果
-> 如字段存在跨文件冲突,系统按说明书为准填入,并做黄色底色、红色字体标记
-> 缺失字段保持留空
-> 系统逐条判断安全和性能基本原则清单的适用性、符合性证据和证明文件位置
-> 系统生成已填 Word 文件
-> 系统将已填 Word 转换为 PDF
-> 系统生成来源追溯清单
-> AI 对话框展示生成结果、冲突字段摘要和下载链接
-> 用户下载 Word/PDF 自行确认
```
### 5.2 注册类型分支
| 注册类型 | 生成文件 |
| --- | --- |
| 首次注册 | 注册证格式 Word/PDF安全和性能基本原则清单 Word/PDF |
| 变更注册 | 变更注册(备案)文件 Word/PDF安全和性能基本原则清单 Word/PDF |
| 备案 | 变更注册(备案)文件 Word/PDF安全和性能基本原则清单 Word/PDF |
| 注册类型无法识别 | AI 对话框提示待确认;默认不生成注册证或变更文件,只可生成带待确认标记的草稿版本 |
### 5.3 异常流程
| 异常场景 | 处理方式 |
| --- | --- |
| 模板文件不存在 | 批次标记失败,对话框提示缺少目标模板 |
| `.doc` 模板无法转换 | 对应模板导出失败,其他模板继续生成 |
| 字段未提取到 | 目标栏位留空,来源追溯清单记录为空 |
| 字段冲突 | 按说明书为准填入,模板内高亮标记,对话框展示冲突摘要 |
| PDF 转换失败 | 保留 Word 下载,提示 PDF 生成失败原因 |
| 模板版式明显错位 | 标记为需人工复核,不阻断 Word 文件下载 |
---
## 六、信息提取与字段规则
### 6.1 字段范围
字段范围不固定写死应以三个目标模板的实际栏目和清单条目为准动态建立。Demo 阶段优先覆盖以下字段:
| 字段 | 说明 |
| --- | --- |
| 产品名称 | 产品标准名称 |
| 检测靶标 | 被检测物、基因、抗原、抗体、病原体或生物标志物 |
| 适用范围/预期用途 | 适用人群、样本类型、检测目的、临床用途 |
| 储存条件 | 温度、避光、防潮等保存条件 |
| 性能指标 | 分析灵敏度、特异性、重复性、准确度、检出限等 |
| 型号规格/包装规格 | 规格型号、包装规格、人份数或测试数 |
| 样本类型 | 血清、血浆、全血、咽拭子等 |
| 有效期 | 产品有效期或稳定性期限 |
| 主要组成成分 | 试剂、校准品、质控品、耗材等组成 |
| 检验原理 | 反应原理、方法学或检测平台 |
| 注册人/申请人 | 注册申请主体 |
| 生产地址 | 生产场所地址 |
### 6.2 来源文件优先级
| 优先级 | 文件类型 | 说明 |
| --- | --- | --- |
| 1 | 说明书 | 字段冲突时默认以说明书为准 |
| 2 | 产品技术要求 | 用于补充性能指标、检验方法、组成成分等字段 |
| 3 | 注册检验报告/检测报告 | 用于补充性能指标、样本信息、检验依据和结论 |
| 4 | 性能研究资料 | 用于补充安全和性能基本原则清单证据 |
| 5 | 其他注册资料 | 用于补充申请人、生产地址、附件清单等信息 |
### 6.3 冲突处理规则
| 场景 | 处理方式 |
| --- | --- |
| 说明书与其他文件字段不一致 | 按说明书值填入模板 |
| 多个非说明书文件不一致,说明书缺失 | 目标字段留空或取最高优先级来源,具体规则由实现阶段配置 |
| 字段被高亮标记 | 黄色底色、红色字体,提示用户下载后确认 |
| AI 对话框展示 | 展示字段名、采用值、冲突值、来源文件和目标模板 |
---
## 七、安全和性能基本原则清单填写规则
安全和性能基本原则清单不只填写基础产品信息,还需要根据产品文件内容逐条判断清单条目的适用性、符合性证据和证明文件位置。
| 填写项 | 规则 |
| --- | --- |
| 适用/不适用 | 根据产品特性、检测方法、样本类型、是否含仪器/软件/灭菌/生物材料等信息判断 |
| 符合性说明 | 从产品技术要求、说明书、风险管理、性能研究、稳定性研究等文件中提取证据摘要 |
| 证明文件位置 | 填写证据文件名、章节、页码或可定位文本片段 |
| 无法判断 | 留空或标记待人工确认,来源追溯清单记录原因 |
| 冲突证据 | 如不同文件对同一条款适用性或证据描述冲突,保留高亮并在对话框列出 |
逐条判断结果需要可追溯,不能只输出“适用”或“不适用”结论。
---
## 八、输出要求
### 8.1 文件命名
文件命名规则:
```text
批次号-产品名称-注册证格式.docx
批次号-产品名称-注册证格式.pdf
批次号-产品名称-变更注册备案文件.docx
批次号-产品名称-变更注册备案文件.pdf
批次号-产品名称-安全和性能基本原则清单.docx
批次号-产品名称-安全和性能基本原则清单.pdf
批次号-产品名称-字段来源追溯清单.xlsx
```
产品名称为空时,可使用 `未识别产品名称` 作为文件名占位。
### 8.2 AI 对话框摘要
AI 对话框应展示生成结果、下载链接和冲突字段摘要。
```markdown
已生成申报模板自动填表文件。
| 文件 | Word | PDF |
| --- | --- | --- |
| 注册证格式 | 下载 | 下载 |
| 安全和性能基本原则清单 | 下载 | 下载 |
| 冲突字段 | 采用值 | 冲突来源 | 处理 |
| --- | --- | --- | --- |
| 储存条件 | 2-8℃保存 | 产品技术要求:-20℃保存 | 已按说明书填入,并在模板中高亮 |
```
### 8.3 Word 输出
| 要求 | 说明 |
| --- | --- |
| 模板副本 | 从原始法规模板复制生成,不覆盖原始文件 |
| 版式保持 | 保留原模板表格、段落、分页、字体和标题结构 |
| 冲突高亮 | 黄色底色、红色字体 |
| 缺失字段 | 留空,不填“待补充” |
| 可编辑 | 用户可下载后继续人工修改 |
### 8.4 PDF 输出
| 要求 | 说明 |
| --- | --- |
| 来源 | 由填好的 Word 转换生成 |
| 版式 | 尽量与原 Word 模板一致 |
| 用途 | 可作为正式提交前预览 |
| 失败处理 | PDF 失败不影响 Word 下载 |
### 8.5 来源追溯清单
来源追溯清单允许额外生成,建议至少包含:
| 字段 | 说明 |
| --- | --- |
| 目标模板 | 字段填入哪个模板 |
| 目标栏位/条目 | 字段对应的表格栏位或清单条目 |
| 填入值 | 实际写入模板的值 |
| 来源文件 | 取值来源文件 |
| 来源片段 | 支撑取值的文本片段 |
| 是否冲突 | 是/否 |
| 冲突值 | 其他文件中的不同值 |
| 处理方式 | 采用说明书、留空、高亮、待人工确认等 |
---
## 九、功能模块梳理
| 序号 | 功能名称 | 功能描述 | 优先级 |
| --- | --- | --- | --- |
| 1 | 模板管理 | 维护三个目标模板路径、版本和适用注册类型 | P0 |
| 2 | 模板副本生成 | 将原始模板复制到批次工作目录 | P0 |
| 3 | 模板结构识别 | 识别模板中的表格字段、段落占位、清单条目 | P0 |
| 4 | 产品字段抽取 | 从上传文件中抽取模板所需产品字段 | P0 |
| 5 | 字段合并与冲突检测 | 按说明书优先级合并字段,并识别跨文件冲突 | P0 |
| 6 | Word 模板填充 | 将字段写入 Word 模板副本 | P0 |
| 7 | 冲突高亮 | 对冲突字段应用黄色底色和红色字体 | P0 |
| 8 | 基本原则逐条判断 | 判断安全和性能条目的适用性、符合性证据和证明文件位置 | P0 |
| 9 | PDF 转换 | 将填好的 Word 转为 PDF | P0 |
| 10 | 下载链接生成 | 在 AI 对话框提供 Word/PDF 下载链接 | P0 |
| 11 | 来源追溯清单导出 | 输出字段来源、冲突和填入目标 | P1 |
| 12 | 版式 QA | 对 Word/PDF 版式进行自动或人工可见检查 | P1 |
---
## 十、数据实体分析
| 实体名称 | 字段说明 | 关联实体 |
| --- | --- | --- |
| 自动填表批次 | 批次编号、用户、会话、注册类型、产品名称、状态、错误信息、创建时间、完成时间 | 文件汇总批次、法规核查批次 |
| 模板副本 | 模板名称、模板类型、原始模板路径、副本路径、模板版本、适用条件 | 自动填表批次 |
| 提取字段 | 字段名、填入值、来源文件、来源片段、来源优先级、是否冲突、冲突详情 | 自动填表批次 |
| 填表结果文件 | 文件类型、文件名、Word 路径、PDF 路径、下载状态 | 自动填表批次 |
| 清单条目判断 | 条目编号、条目内容、适用性、符合性证据、证明文件位置、判断来源 | 自动填表批次 |
---
## 十一、非功能性需求
### 11.1 可追溯性
| 要求 | 说明 |
| --- | --- |
| 字段来源可追溯 | 每个填入字段应能追溯到来源文件和文本片段 |
| 模板版本可追溯 | 每次生成记录原始模板文件名、版本和路径 |
| 冲突处理可追溯 | 冲突字段记录采用值、冲突值和处理规则 |
| 输出文件可追溯 | Word/PDF 文件关联批次、用户和会话 |
### 11.2 安全要求
| 要求 | 说明 |
| --- | --- |
| 原始模板保护 | 不允许覆盖或修改原始法规资料目录中的模板 |
| 下载权限 | Word/PDF/追溯清单仅允许当前会话授权用户下载 |
| 敏感信息保护 | 对话框只展示必要冲突摘要,不展示大段敏感原文 |
| 文件隔离 | 不同用户、不同批次的模板副本和导出文件隔离存储 |
### 11.3 版式要求
| 要求 | 说明 |
| --- | --- |
| Word 版式 | 尽量保持原模板表格、字体、分页和段落结构 |
| PDF 版式 | 与填好后的 Word 一致,可用于正式提交前预览 |
| 高亮可见 | 冲突字段在 Word 和 PDF 中均应能被用户识别 |
| 缺失字段不污染模板 | 未提取字段留空,不填入系统提示语 |
### 11.4 性能要求
| 场景 | 要求 |
| --- | --- |
| 小批次资料 | 50 个文件以内,应在 1 分钟内完成字段抽取和模板生成 |
| 中等批次资料 | 200 个文件以内支持后台异步处理和进度提示 |
| 单个模板失败 | 不影响其他适用模板生成 |
| 单个字段失败 | 不影响整份模板生成,字段留空并记录原因 |
---
## 十二、待后续确认事项
| 序号 | 待确认项 | 当前建议 |
| --- | --- | --- |
| 1 | `.doc` 模板转换方案 | 优先使用 LibreOffice/soffice 转 docx无法部署时预置人工确认版 docx 模板 |
| 2 | 变更注册(备案)文件字段清单 | 需在模板可解析后补充字段映射 |
| 3 | 安全和性能基本原则清单条目结构 | 需在模板可解析后拆解条目编号、要求、适用性和证据栏 |
| 4 | 说明书识别规则 | 需明确如何从上传资料中判定哪份文件是说明书 |
| 5 | PDF 转换质量标准 | 需明确是否要求逐页渲染检查、页数一致和关键表格不跨页错位 |
| 6 | 注册类型无法识别时是否允许生成草稿 | 建议允许生成安全和性能基本原则清单,注册证或变更文件等待确认 |
---
## 十三、验收标准
| 序号 | 验收项 | 验收标准 |
| --- | --- | --- |
| 1 | 模板复制 | 系统生成模板副本,不修改原始法规模板 |
| 2 | 首次注册文件选择 | 首次注册场景生成注册证格式和安全和性能基本原则清单 |
| 3 | 变更注册/备案文件选择 | 变更注册或备案场景生成变更注册(备案)文件和安全和性能基本原则清单 |
| 4 | 字段自动填写 | 产品名称、预期用途、储存条件、包装规格等字段能自动写入对应栏目 |
| 5 | 缺失字段留空 | 未提取到的字段保持空白 |
| 6 | 冲突字段高亮 | 字段冲突时按说明书值填入,并在 Word/PDF 中黄色底色、红色字体标记 |
| 7 | 冲突摘要展示 | AI 对话框展示冲突字段、采用值、冲突来源和处理方式 |
| 8 | 基本原则清单判断 | 系统能逐条输出适用/不适用、符合性证据和证明文件位置 |
| 9 | Word 下载 | 对话框提供填好后的 Word 下载链接 |
| 10 | PDF 下载 | 对话框提供由 Word 转换生成的 PDF 下载链接 |
| 11 | 来源追溯 | 可导出字段来源追溯清单,记录字段来源和冲突情况 |
| 12 | 异常不中断 | 单个字段、单个模板或 PDF 转换失败时,其他结果仍可正常输出 |
---
## 十四、下一步建议
1. 将两个 `.doc` 原始模板转换为可解析的 `.docx` 工作模板,并人工确认版式无明显变化。
2. 拆解三个模板的字段、表格和清单条目,形成模板字段映射配置。
3. 扩展产品信息抽取字段,优先覆盖注册证模板已识别字段和安全和性能基本原则清单证据字段。
4. 设计冲突高亮写入规则,确保 Word 与 PDF 中均可见。
5. 接入 Word 到 PDF 转换能力,并建立页数、版式和关键表格的转换质量检查。

View File

@@ -0,0 +1,527 @@
# 飞书通知与问答接入需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 功能主题 | 飞书通知链路与飞书内问答能力接入 |
| 关联工作流 | 自动汇总、NMPA 注册资料法规核查与整改闭环、产品关键信息提取与申报文件自动填表 |
| 分析日期 | 2026-06-07 |
| 分析版本 | V1.0 |
| 短期目标 | 流程结束后同步飞书提醒 |
| 终极目标 | 用户可在飞书内向 Agent 提问并获得基于系统数据的回答 |
---
## 一、需求背景
当前系统已经具备多个注册资料处理工作流,包括文件自动汇总、法规核查与整改闭环、产品关键信息提取与申报文件自动填表。系统内已经存在模拟通知记录和通知节点,但尚未接入真实飞书发送链路。
在实际业务协作中,注册人员、审核人员和整改负责人往往以飞书群或飞书私聊作为日常沟通入口。如果工作流只在系统页面内展示结果,用户需要主动返回系统查看状态,容易造成流程完成后无人跟进、整改项遗漏、生成文件未及时下载等问题。
因此需要引入飞书接入能力,分阶段实现:
1. 流程结束后自动向飞书发送提醒,完成从“系统内闭环”到“协作通知闭环”的升级。
2. 后续支持用户在飞书内与 Agent 对话,查询批次状态、风险项、生成文件、整改建议等信息。
---
## 二、接入方案调研摘要
### 2.1 主方案:飞书官方智能体/应用机器人 + 消息 API
飞书开放平台支持创建飞书智能体应用或企业自建应用机器人。系统通过 `App ID``App Secret` 获取 `tenant_access_token`,再调用飞书消息 API 向固定群发送流程完成提醒;后续通过事件订阅接收用户私聊机器人或群内 @ 机器人的消息,实现飞书内问答。
该方案同时覆盖短期“流程结束后提醒”和终极“飞书内问答”,避免先接自定义 Webhook、后续再迁移到应用机器人的重复建设。
核心能力:
| 能力 | 用途 |
| --- | --- |
| 飞书智能体/应用机器人 | 允许 Agent 以机器人身份进入飞书 |
| tenant_access_token | 使用 App ID、App Secret 换取应用访问令牌 |
| 发送消息 API | 主动向用户或群聊发送文本、富文本、卡片、文件等消息 |
| 事件订阅 | 接收用户私聊机器人或群里 @ 机器人的消息 |
| 权限配置 | 申请发送消息、接收消息、读取用户或群组信息等权限 |
### 2.2 备选方案:飞书自定义机器人 Webhook
飞书自定义机器人 Webhook 适合只做固定群主动推送,但不适合飞书内问答、私聊回复和统一身份权限管理。本项目不将 Webhook 作为主接入方案,仅作为后续极简部署或故障降级备选。
### 2.3 参考官方文档
| 主题 | 参考地址 |
| --- | --- |
| 一键创建飞书智能体应用 | https://open.feishu.cn/document/mcp_open_tools/integrating-agents-with-feishu/overview |
| 机器人概述 | https://open.feishu.cn/document/client-docs/bot-v3/bot-overview?lang=zh-CN |
| 自建应用获取 tenant_access_token | https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN |
| 发送消息 API | https://open.feishu.cn/document/server-docs/im-v1/message/create?lang=zh-CN |
| 事件订阅概述 | https://open.feishu.cn/document/server-docs/event-subscription-guide/overview?lang=zh-CN |
---
## 三、总体目标
### 3.1 短期目标:流程结束后同步提醒到指定个人账号
当系统中的工作流执行结束后自动通过飞书智能体向指定个人账号发送一条结构化私聊提醒。Demo 阶段先与当前系统负责人账号单独对接,不接入外部群聊。提醒内容应帮助用户快速判断:
| 信息项 | 说明 |
| --- | --- |
| 哪个流程完成 | 例如自动汇总、法规核查、自动填表 |
| 哪个批次完成 | 展示批次编号、会话标题或上传文件摘要 |
| 当前状态 | 成功、部分成功、失败、需人工确认 |
| 核心结果 | 风险数量、阻断项数量、生成文件数量、冲突字段数量等 |
| 下一步动作 | 查看报告、下载文件、处理整改项、回到系统确认 |
| 系统入口 | 提供可点击链接,跳转到对应批次或会话页面 |
| 被提醒人 | 首期固定发送给已配置的个人飞书账号 |
### 3.2 中期目标:按流程和责任人分发
在个人账号通知跑通后,逐步支持更精细的通知策略:
| 通知策略 | 说明 |
| --- | --- |
| 按发起人私聊 | 根据系统用户映射发送给流程发起人 |
| 按工作流分群 | 不同工作流通知到不同群 |
| 按项目分群 | 同一项目或产品线通知到指定群 |
| 按负责人私聊 | 将待处理事项发送给上传人、审核人或整改负责人 |
| 风险分级通知 | 阻断项和高风险立即通知,低风险可汇总通知 |
### 3.3 终极目标:飞书内问答
用户可以在飞书内向 Agent 提问,系统根据用户消息识别意图,查询本地业务数据和已生成结果,返回回答。
示例问题:
| 问题 | 预期回答 |
| --- | --- |
| “最近一个法规核查批次结果怎么样?” | 返回最近批次状态、风险数量和报告入口 |
| “RR-20260607-001 有哪些阻断项?” | 返回阻断项标题、法规依据、整改建议 |
| “自动填表生成的 Word 在哪里?” | 返回生成文件列表和下载入口 |
| “这个批次还缺哪些资料?” | 返回缺失文件清单和对应建议 |
| “帮我解释第 3 个风险项” | 返回风险说明、证据文件、整改建议和注意事项 |
---
## 四、需求范围
### 4.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 真实飞书通知通道 | 接入飞书官方智能体/应用机器人消息 API |
| 2 | 通知开关 | 通过环境变量控制是否启用真实飞书通知 |
| 3 | 保留 mock 通道 | 默认可回退到 mock不影响本地开发和自动化测试 |
| 4 | 工作流完成通知 | 流程成功、部分成功或失败后发送飞书提醒 |
| 5 | 通知记录落库 | 记录通道、目标、发送状态、发送时间、错误信息和原始 payload |
| 6 | 失败不阻断主流程 | 飞书发送失败只记录错误,不让业务工作流失败,首期不自动重试 |
| 7 | 消息模板 | 输出清晰的富文本消息,包含批次、状态、摘要和系统链接 |
| 8 | 安全配置 | App ID、App Secret、事件订阅密钥等敏感配置不得写入代码库 |
| 9 | 基础测试 | 覆盖成功、失败、未启用、配置缺失、发送超时等场景 |
### 4.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 飞书内问答完整实现 | 本期只为后续问答预留架构,不直接实现复杂对话 |
| 2 | 飞书审批流 | 不接入飞书审批或表单能力 |
| 3 | 飞书文档写入 | 不自动创建或更新飞书文档 |
| 4 | 企业级组织架构同步 | 不做通讯录全量同步 |
| 5 | 多租户飞书应用管理 | Demo 阶段只考虑单企业或单环境配置 |
| 6 | 复杂交互式卡片操作 | 本期优先文本或简单卡片,不实现按钮回调闭环 |
---
## 五、用户角色与使用场景
| 角色 | 诉求 | 典型场景 |
| --- | --- | --- |
| 注册人员 | 及时知道批次完成并下载结果 | 自动汇总或自动填表完成后在飞书收到提醒 |
| 审核人员 | 快速查看法规核查风险摘要 | 法规核查结束后查看阻断项和高风险数量 |
| 整改负责人 | 及时处理缺失资料和风险项 | 飞书提醒中看到整改入口和主要问题 |
| 系统管理员 | 维护通知配置并排查发送失败 | 查看通知记录、错误信息和配置状态 |
| 后续飞书用户 | 不打开系统也能查询结果 | 在飞书中向机器人提问批次状态或风险项 |
---
## 六、业务流程
### 6.1 短期通知流程
```text
用户发起业务工作流
-> 系统执行自动汇总、法规核查或自动填表
-> 工作流进入完成、部分成功或失败状态
-> 系统生成通知摘要
-> 系统判断飞书真实通知是否启用
-> 未启用:写入 mock 通知记录
-> 已启用:使用 App ID/App Secret 获取或复用 tenant_access_token
-> 调用飞书消息 API 向指定个人账号发送富文本消息
-> 发送成功:写入成功通知记录和 sent_at
-> 发送失败:写入失败通知记录、错误信息和重试次数
-> 主工作流继续完成,不因通知失败回滚业务结果
```
### 6.2 终极问答流程
```text
用户在飞书私聊机器人或群里 @ 机器人提问
-> 飞书通过事件订阅将消息推送到系统回调地址
-> 系统校验事件来源和签名
-> 系统解析用户身份、会话位置和消息内容
-> 系统执行意图识别
-> 系统根据意图查询批次、文件、报告、风险项或生成结果
-> 系统组织回答内容
-> 系统通过飞书消息 API 回复用户或群聊
-> 系统记录问答日志、引用数据和错误信息
```
---
## 七、功能需求
### 7.1 通知触发点
| 工作流 | 触发节点 | 通知时机 | 初始优先级 |
| --- | --- | --- | --- |
| 自动汇总 | 文件汇总完成 | 成功、部分成功、失败 | 高 |
| 法规核查与整改闭环 | 风险分级和报告生成后 | 成功、部分成功、失败;阻断项和高风险优先展示 | 高 |
| 自动填表 | Word/PDF 和追溯清单生成后 | 成功、部分成功、失败 | 高 |
首期三个业务工作流均接入飞书通知:自动汇总、法规核查与整改闭环、产品关键信息提取与申报文件自动填表。
### 7.2 通知内容模板
通知消息首期采用富文本格式,需支持换行和重点信息突出展示。通知消息应至少包含:
| 字段 | 说明 |
| --- | --- |
| 标题 | 工作流名称 + 状态 |
| 批次编号 | 例如 RR-NOTIFY、AFF-NOTIFY |
| 发起人 | 当前系统用户 |
| 完成时间 | 工作流完成时间 |
| 结果摘要 | 风险数量、文件数量、导出文件数量、冲突字段数量等 |
| 下一步 | 查看报告、下载结果、处理整改项、重新复核 |
| 系统链接 | 首期使用本地地址拼接系统内批次或会话页面链接,例如 `http://127.0.0.1:8000/...` |
发送策略:
| 策略项 | 要求 |
| --- | --- |
| 通知状态 | 成功、部分成功、失败均发送飞书通知 |
| 重复发送 | 同一批次、同一工作流、同一状态只发送一次,避免重复点击或重复运行造成刷屏 |
| 失败重试 | 首期不自动重试,只记录失败状态和错误信息 |
| 主流程影响 | 通知失败不阻断业务工作流完成 |
消息内容粒度:
| 粒度项 | 要求 |
| --- | --- |
| 基础信息 | 工作流名称、状态、批次编号、发起人、完成时间 |
| 结果摘要 | 自动汇总展示文件数和异常数;法规核查展示风险总数、阻断项、高风险数量;自动填表展示导出文件数、冲突字段数和失败原因概述 |
| 详细清单 | 首期不在飞书私聊中展开完整风险项、缺失项或文件明细,避免消息过长 |
| 系统入口 | 首期使用本地地址拼接系统内批次或会话链接,部署后再升级为可配置外部域名 |
### 7.3 通知状态记录
通知发送后必须落库,便于排查和审计。
| 字段 | 说明 |
| --- | --- |
| channel | mock、feishu_api 等 |
| target | 指定个人 open_id、user_id 等 |
| status | pending、sent、failed 或 success、failed |
| payload | 发送内容摘要和业务上下文 |
| external_message_id | 飞书返回的消息 IDWebhook 无返回时可为空 |
| error_message | 失败原因 |
| retry_count | 重试次数 |
| sent_at | 成功发送时间 |
### 7.4 配置需求
环境变量建议:
| 配置项 | 说明 |
| --- | --- |
| FEISHU_NOTIFY_ENABLED | 是否启用真实飞书通知 |
| FEISHU_NOTIFY_CHANNEL | 通知通道,首期为 feishu_api |
| FEISHU_APP_ID | 飞书智能体/企业自建应用 App ID |
| FEISHU_APP_SECRET | 飞书智能体/企业自建应用 App Secret |
| FEISHU_DEFAULT_USER_OPEN_ID | 首期指定接收人的飞书 open_id |
| FEISHU_DEFAULT_USER_ID | 首期指定接收人的飞书 user_id可作为 open_id 的备选 |
| FEISHU_DEFAULT_TARGET_NAME | 默认通知目标名称,用于记录展示 |
| FEISHU_TENANT_TOKEN_CACHE_SECONDS | tenant_access_token 本地缓存秒数 |
| FEISHU_EVENT_VERIFY_TOKEN | 事件订阅校验 Token后续问答使用 |
| FEISHU_EVENT_ENCRYPT_KEY | 事件订阅加密 Key后续问答使用 |
敏感配置不得提交到代码库,只能通过本地 `.env`、部署环境变量或密钥管理系统注入。
首期配置维护方式:
| 配置类型 | 维护方式 | 说明 |
| --- | --- | --- |
| 飞书 App ID | 环境变量 | 属于敏感信息,不进入数据库和代码库 |
| 飞书 App Secret | 环境变量 | 属于敏感信息,不进入数据库和代码库 |
| 指定接收人 open_id/user_id | 环境变量 | 首期固定发送到一个个人账号 |
| 通知开关 | 环境变量 | 便于本地、测试、部署环境切换 |
| 系统用户与飞书用户映射 | Django Admin | 便于非开发人员维护发起人和飞书用户标识 |
### 7.5 系统用户与飞书用户映射
首期采用手工配置表维护系统用户与飞书用户之间的映射关系。系统在发送固定群通知时,根据批次 `user` 字段找到流程发起人或上传人,再从映射表中读取可用于飞书 @ 的用户标识。
建议字段:
| 字段 | 说明 |
| --- | --- |
| system_username | 系统登录用户名 |
| system_user_id | 系统用户 ID可选 |
| feishu_display_name | 飞书展示名称,便于管理员识别 |
| feishu_mobile | 飞书手机号,可选 |
| feishu_open_id | 飞书 open_id可选 |
| feishu_user_id | 飞书 user_id可选 |
| is_active | 是否启用该映射 |
| remark | 备注 |
首期实现时,系统优先将通知发送给环境变量中配置的指定个人账号。用户映射表仍保留,用于后续从“固定个人账号”升级为“按流程发起人私聊”。若指定接收人未配置,系统不发送真实飞书消息,只记录配置缺失失败。
当同一个系统用户配置了多个飞书标识时,首期按以下优先级选择 @ 标识:
```text
feishu_open_id -> feishu_user_id -> feishu_mobile
```
### 7.6 通知记录展示
首期需要在对应批次详情页展示通知状态,帮助用户和管理员判断飞书提醒是否已发送。
| 展示项 | 说明 |
| --- | --- |
| 通知通道 | mock、feishu_api 等 |
| 通知目标 | 指定个人账号名称或配置名称 |
| 接收人 | 首期指定接收人;后续可展示发起人/上传人的飞书展示名称 |
| 发送状态 | 成功、失败、待发送或未启用 |
| 发送时间 | 成功发送时间 |
| 失败原因 | 发送失败或配置异常时展示摘要 |
---
## 八、飞书内问答需求预留
### 8.1 问答入口
| 入口 | 说明 |
| --- | --- |
| 私聊机器人 | 首期入口,用户直接向机器人询问自己的批次、文件和报告 |
| 群聊 @ 机器人 | 群内成员 @ 机器人询问某个批次或风险项 |
| 通知消息引用 | 用户收到通知后,基于批次编号继续提问 |
### 8.2 问答能力边界
第一阶段飞书问答不应直接执行高风险写操作,只提供查询和解释:
| 能力 | 是否纳入首期问答 |
| --- | --- |
| 查询批次状态 | 是 |
| 查询风险项摘要 | 是 |
| 查询缺失项摘要 | 是 |
| 查询生成文件摘要 | 是 |
| 解释整改建议 | 否,作为后续增强 |
| 重新发起工作流 | 否 |
| 删除文件或记录 | 否 |
| 自动关闭风险项 | 否 |
| 修改申报文件 | 否 |
### 8.3 权限原则
飞书内问答必须解决用户身份和数据权限问题:
| 场景 | 要求 |
| --- | --- |
| 私聊查询 | 普通用户只能查询自己发起或上传的批次;管理员可以查询全部批次 |
| 群内查询 | 只返回适合在群内公开的信息,敏感文件链接需谨慎 |
| 未绑定用户 | 提示先完成系统用户与飞书用户绑定 |
| 无权限数据 | 返回无权限提示,不泄露批次是否存在以外的敏感信息 |
### 8.4 首期问答交互规则
首期私聊问答支持两类批次定位方式:
| 方式 | 示例 | 说明 |
| --- | --- | --- |
| 明确批次号 | “查 RR-20260607-001” | 系统按批次编号精确查询 |
| 自然指代 | “查最近一个法规核查批次”“最新自动填表结果怎么样” | 系统在用户可访问范围内查找最近批次 |
问答回复规则:
| 规则 | 要求 |
| --- | --- |
| 链接返回 | 只有用户具备对应批次访问权限时才返回系统链接 |
| 无权限结果 | 提示无权限或无法访问,不返回敏感摘要和链接 |
| 回答粒度 | 返回批次状态、风险摘要、缺失摘要、导出摘要和下一步建议 |
| 日志留痕 | 记录用户问题、识别意图、查询对象、回答摘要、错误信息和处理时间,不保存完整回答正文 |
---
## 九、异常与安全要求
| 场景 | 处理方式 |
| --- | --- |
| App ID/App Secret 或指定接收人未配置 | 自动回退 mock 或只记录未发送状态 |
| tenant_access_token 获取失败 | 记录失败,不发送消息,不阻断主流程 |
| 飞书接口超时 | 记录失败,不阻断主流程 |
| 飞书返回错误 | 记录错误码和错误信息,便于排查 |
| 消息过长 | 自动截断摘要,系统链接保留完整结果;首期不发送详细风险项或缺失项清单 |
| 重复触发 | 同一批次、同一工作流、同一状态只发送一次 |
| 敏感信息 | 通知正文避免包含完整文件内容、密钥、个人敏感信息 |
| 外部链接 | 首期使用本地地址;部署环境应升级为可信域名配置 |
| 回调伪造 | 后续事件订阅必须校验来源、签名、Token 或加密参数 |
---
## 十、验收标准
### 10.1 短期通知验收
| 序号 | 验收项 | 标准 |
| --- | --- | --- |
| 1 | 配置关闭 | 未启用飞书通知时,工作流仍可正常完成并记录 mock 通知 |
| 2 | 配置开启 | 配置 App ID、App Secret 和指定个人 open_id/user_id 后,流程完成会向个人飞书账号发送提醒 |
| 3 | 成功记录 | 发送成功后通知记录状态为成功,并记录发送时间 |
| 4 | 失败记录 | token 获取失败、消息 API 错误、超时或配置错误时记录失败原因 |
| 5 | 不阻断主流程 | 通知失败不会导致工作流失败 |
| 6 | 内容完整 | 飞书消息包含工作流、批次、状态、摘要和系统入口 |
| 7 | 自动化测试 | 有单元测试覆盖通知构造、发送成功、发送失败、配置关闭 |
| 8 | token 管理 | 系统能获取并缓存 tenant_access_tokentoken 失效后可重新获取 |
| 9 | 后台映射 | 管理员可在 Django Admin 维护系统用户与飞书用户映射 |
### 10.2 终极问答验收
| 序号 | 验收项 | 标准 |
| --- | --- | --- |
| 1 | 消息接收 | 系统能接收飞书私聊或群 @ 机器人消息 |
| 2 | 身份识别 | 能识别飞书用户并关联系统用户 |
| 3 | 意图识别 | 能区分批次查询、风险查询、文件查询、解释类问题 |
| 4 | 权限控制 | 普通用户只能查询自己发起或上传的批次;管理员可查询全部批次 |
| 5 | 消息回复 | 系统能通过飞书消息 API 回复用户 |
| 6 | 日志留痕 | 用户问题、意图、查询对象、回答摘要和错误信息可追溯,不保存完整回答正文 |
| 7 | 批次定位 | 支持明确批次号和“最近一个/最新批次”等自然说法 |
| 8 | 链接控制 | 只有用户有权限访问时才返回系统链接 |
---
## 十一、阶段规划
### 阶段一:指定个人账号完成提醒
目标:使用飞书官方智能体/应用机器人消息 API 将流程完成提醒发送到指定个人账号。Demo 阶段先与当前系统负责人账号单独对接,暂不接入外部群聊。
建议内容:
| 内容 | 说明 |
| --- | --- |
| 通知通道抽象 | 将 mock 和 feishu_api 封装为可切换通道 |
| 消息模板 | 输出流程完成摘要 |
| 指定接收人 | 根据环境变量配置的 open_id/user_id 发送给指定个人账号 |
| token 管理 | 使用 App ID/App Secret 获取并缓存 tenant_access_token |
| 消息 API | 使用指定个人 open_id/user_id 调用飞书发送消息 API |
| 通知记录 | 发送结果落库 |
| 配置开关 | 环境变量控制启用与否 |
| 测试覆盖 | 不依赖真实飞书也能测试发送逻辑 |
| 批次详情展示 | 在批次详情页展示通知状态和失败原因 |
### 阶段一附加:飞书问答预留
目标:在不实现飞书事件回调和私聊问答的前提下,为后续问答 MVP 预留必要的数据结构、服务边界和权限规则。
建议内容:
| 内容 | 说明 |
| --- | --- |
| 用户映射复用 | 飞书用户映射模型同时服务 @ 通知和后续私聊身份识别 |
| 查询服务边界 | 预留按批次号、最近批次、工作流类型查询结果摘要的服务接口 |
| 权限过滤规则 | 查询服务内置管理员全查、普通用户查自己批次的权限规则 |
| 问答日志模型预留 | 可先设计模型或接口,不要求首期接收飞书消息 |
### 阶段二:按流程或项目分群
目标:支持不同流程、项目或业务线配置不同飞书目标。
建议内容:
| 内容 | 说明 |
| --- | --- |
| 通知路由 | 根据 workflow_type、project、batch 等选择目标 |
| 通知策略 | 风险等级、完成状态、失败状态决定是否通知 |
| 消息降噪 | 避免同一批次重复刷屏 |
### 阶段三:事件订阅与私聊问答
建议内容:
| 内容 | 说明 |
| --- | --- |
| 事件回调 | 接收飞书私聊消息事件 |
| 用户绑定 | 使用飞书 open_id/user_id 映射系统用户 |
| 问答处理 | 查询批次状态、风险摘要、缺失摘要和导出摘要 |
| 回复消息 | 继续使用消息 API 回复用户 |
### 阶段四:飞书内问答
目标:通过事件订阅接收用户消息,并调用系统 Agent 能力回答问题。
建议内容:
| 内容 | 说明 |
| --- | --- |
| 事件回调 | 接收私聊和群 @ 消息 |
| 意图识别 | 解析查询对象和问题类型 |
| 数据查询 | 查询批次、风险、文件、报告和通知记录 |
| 回答生成 | 返回简洁、可追溯、带链接的回答 |
| 安全审计 | 记录问答日志和权限判断 |
---
## 十二、待确认问题
| 编号 | 问题 | 推荐选项 |
| --- | --- | --- |
| Q1 | 短期通知发到哪里?固定飞书群、按业务群区分、还是按个人私聊? | 已调整:先发送到指定个人账号,暂不接入外部群聊 |
| Q2 | 首期接入哪些工作流?自动填表、法规核查、自动汇总是否都通知? | 已确认:三个流程都通知 |
| Q3 | 通知格式用普通文本、富文本还是飞书消息卡片? | 已确认:首期使用富文本 |
| Q4 | 系统链接使用本地地址还是部署域名? | Demo 本地,部署后改域名 |
| Q5 | 是否需要 @ 指定人员? | 已调整:首期为个人私聊通知,不需要群内 @ |
| Q6 | 是否需要失败重试? | 已确认:首期不自动重试,只记录失败 |
| Q7 | 飞书内问答优先支持私聊还是群 @ | 先私聊,后群 @ |
---
## 十三、已确认决策
| 编号 | 决策 | 影响 |
| --- | --- | --- |
| D1 | 短期通知发送到指定个人账号,暂不接入外部群聊 | 首期需要配置个人 open_id/user_id后续再扩展群聊、按发起人私聊和责任矩阵 |
| D2 | 首期接收人为配置中的固定个人账号 | 通知服务不再依赖批次 `user` 解析接收人;批次 `user` 仍用于摘要展示和后续按发起人私聊 |
| D3 | 首期采用手工配置表维护系统用户与飞书用户映射 | 避免首期被通讯录权限、用户自动绑定和开放平台审核阻塞;后续可升级为自动绑定 |
| D4 | 首期三个流程均发送飞书完成通知 | 自动汇总、法规核查、自动填表都需要接入统一通知服务;消息发送到指定个人账号 |
| D5 | 首期通知格式采用飞书富文本 | 消息构造需支持富文本结构、换行、重点字段和 @ 用户标签;暂不实现消息卡片按钮 |
| D6 | 成功、部分成功、失败三类状态均发送通知 | 消息模板需要按状态展示不同摘要和下一步动作 |
| D7 | 同一批次、同一工作流、同一状态只发送一次 | 通知记录需要保存可判重的业务键,发送前先检查历史成功或已发送记录 |
| D8 | 首期飞书发送失败不自动重试 | 通知失败只落库并暴露错误信息,不引入异步重试队列 |
| D9 | 飞书消息链接首期使用本地地址 | 满足本机 Demo部署环境后续升级为可信域名配置 |
| D10 | 飞书消息采用摘要级内容粒度 | 私聊通知展示核心结果摘要和入口链接,不展开完整风险项、缺失项或文件明细 |
| D11 | 指定个人接收人未配置时不发送真实飞书消息 | 记录配置缺失失败或回退 mock用户映射缺失不影响首期固定个人通知 |
| D12 | 通知记录只保存发送摘要,不保存完整富文本 payload | 降低记录冗余和敏感信息留存风险;排查时依赖摘要、状态、错误信息和业务上下文 |
| D13 | App ID、App Secret、指定个人 open_id/user_id 等敏感配置通过环境变量维护,用户映射通过 Django Admin 维护 | 兼顾安全性和运维便利性;用户映射服务于后续按发起人私聊和问答身份识别 |
| D14 | 首期使用 tenant_access_token + 飞书消息 API 发送通知 | 通知客户端需要实现 token 获取、缓存、失效重取和消息 API 错误处理 |
| D15 | 飞书内问答首期入口为私聊机器人 | 优先解决个人查询场景,降低群聊权限泄露风险 |
| D16 | 飞书内问答首期回答批次状态、风险摘要、缺失摘要和导出摘要 | 不在首期做具体风险解释和复杂整改建议生成 |
| D17 | 私聊问答支持明确批次号和“最近/最新”自然说法 | 问答解析需要支持批次编号识别和按工作流类型查询最近可访问批次 |
| D18 | 问答权限为管理员可查全部,普通用户只能查自己发起或上传的批次 | 需要识别系统管理员身份,并在查询层统一做权限过滤 |
| D19 | 问答回复仅在用户有权限时返回系统链接 | 链接生成必须在权限校验之后执行 |
| D20 | 问答日志记录问题、意图、查询对象和回答摘要,不保存完整回答 | 兼顾审计排查与敏感信息最小留存 |
| D21 | 首期实现指定个人私聊通知,并预留飞书问答数据模型和服务边界 | 不在首期实现飞书事件回调和交互式问答,降低一次性交付风险 |
| D22 | 批次详情页需要展示通知状态 | 用户无需进入数据库或 Admin 即可确认飞书提醒是否发送成功 |
| D23 | 多个飞书标识的 @ 优先级为 `open_id > user_id > mobile` | 优先使用稳定标识,手机号作为兜底 |
| D24 | 本需求文档版本升级为 V1.0 | 当前决策已足够进入功能设计阶段 |

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