101 Commits
master ... dev

Author SHA1 Message Date
1f5f0a968b fix(chat): remove workspace hero card 2026-06-04 23:05:13 +08:00
f2c1e3cfa1 docs(config): add siliconflow demo env template 2026-06-04 22:56:38 +08:00
5a6e7698e4 fix(chat): simplify header cards and llm fallback 2026-06-04 22:42:39 +08:00
fecaee0b03 feat(chat): refine workspace chat layout 2026-06-04 22:30:37 +08:00
efb06519d8 feat(chat): allow knowledge chat before upload 2026-06-04 22:16:54 +08:00
zhiye.sun
1d8a526770 docs(demo-agent): 同步当前实现状态与协作约定 2026-06-04 16:38:18 +08:00
5aa7b5f3d0 fix(routing): 默认进入审核聊天工作台 2026-06-04 08:47:27 +08:00
e701b4502e refactor: 下沉 Word 导出执行链到 chat 服务层 2026-06-04 04:44:47 +08:00
47f887647b test: 补强审核智能体关键上下文字段证据 2026-06-04 04:41:09 +08:00
1b6a09f786 test: 补强资料包关联会话跳转证据 2026-06-04 04:38:33 +08:00
80dc10ce6d refactor: 下沉资料包列表上下文到 documents 服务层 2026-06-04 04:36:41 +08:00
a49524fd93 refactor: 下沉处理历史筛选到 audit 服务层 2026-06-04 04:34:20 +08:00
1b6c54fe78 refactor: 下沉会话执行编排到 chat 服务层 2026-06-04 04:31:55 +08:00
de2bd2956f style: 清理平台页面旧工作台叙事 2026-06-04 04:28:36 +08:00
b30ba19dcc feat: 收口审核指挥台旧入口到 Agent 原型页 2026-06-04 04:26:15 +08:00
961615b88c style: 收口审核指挥台四入口主导航 2026-06-04 04:24:32 +08:00
3ffb6f23b0 style: 统一审核指挥台四入口标签口径 2026-06-04 04:20:19 +08:00
1e08f24cd5 fix: 修复审核指挥台智能审核入口路由 2026-06-04 04:17:00 +08:00
47ca116937 style: 统一飞书通知节点状态语义 2026-06-04 04:11:00 +08:00
87f674cece style: 统一审核智能体状态展示口径 2026-06-04 04:00:44 +08:00
8ec254c393 style: 统一处理历史状态展示口径 2026-06-04 03:57:03 +08:00
7c0dfe14d5 feat: 动态生成资料包异常提示 2026-06-04 03:50:12 +08:00
dc86fc0e58 feat: 收口通知原因语义与留痕校验 2026-06-04 03:44:04 +08:00
742d5e9a42 feat: 增强会话历史风险与绑定状态展示 2026-06-04 03:38:51 +08:00
9bca08001f feat: 补齐DOCX精确页数识别与待复核策略 2026-06-04 03:35:34 +08:00
e9cf964a3f docs: 同步四入口产品形态与知识库入口口径 2026-06-04 03:30:03 +08:00
5d1598f5c1 style: 统一四入口导航当前态与首页入口命名 2026-06-04 03:27:16 +08:00
7b5d968fb4 style: 收口首页四入口主叙事与场景配置层级 2026-06-04 03:23:50 +08:00
e3a54b874d feat: 增强知识库治理台动作总览与通知策略展示 2026-06-04 03:19:50 +08:00
b2af88f870 feat: 增强资料包页导出回看与处理链路展示 2026-06-04 03:15:56 +08:00
5a4a176108 feat: 增强处理历史页指标与上下文跳转 2026-06-04 03:13:28 +08:00
0f989f4c95 feat: 补齐风险卡责任人飞书字段展示 2026-06-04 03:09:13 +08:00
b8501a680d feat: 增强审核智能体页完整性与字段抽取能力卡展示 2026-06-04 03:07:34 +08:00
af36ec460d feat: 增强审核智能体页目录与一致性能力卡展示 2026-06-04 03:03:37 +08:00
a7cee4aa27 feat: 增强审核智能体页顶部上下文与提问模板 2026-06-04 02:59:17 +08:00
5fdcc31c74 feat: 增强审核智能体页风险与通知能力卡展示 2026-06-04 02:54:39 +08:00
20f3883b8c feat: 增强审核智能体页Word导出能力卡展示 2026-06-04 02:49:27 +08:00
08dcb62834 feat: 补齐Word导出报告结构化字段口径 2026-06-04 02:44:50 +08:00
ab3d520642 feat: 持久化资料包导出记录与列表摘要 2026-06-04 02:41:49 +08:00
0e49466746 feat: 增强处理历史导出记录摘要展示 2026-06-04 02:35:23 +08:00
e9b3f13eec feat: 增强知识库治理台前台入口与维护导航 2026-06-04 02:33:17 +08:00
0250bd360a feat: 打通Word导出文件生成与下载闭环 2026-06-04 02:28:42 +08:00
e81f0f891e feat: 明确导出报告正式导出口径 2026-06-04 02:18:36 +08:00
5808dd794d feat: 支持治理配置通过admin维护 2026-06-04 02:14:07 +08:00
0d4e02b9dc feat: 对齐导出节点口径与下载信息展示 2026-06-04 02:07:58 +08:00
007bf43e15 feat: 支持rar资料包导入 2026-06-04 02:04:27 +08:00
3280186625 feat: 打通通知回执与消息状态留痕 2026-06-04 02:00:41 +08:00
a663543b37 feat: 增强处理历史详情导出与通知回执展示 2026-06-04 01:57:21 +08:00
d2a4907561 feat: 增强处理历史资料规模与会话状态展示 2026-06-04 01:54:11 +08:00
96f710ea13 feat: 支持会话内补传资料并保持绑定 2026-06-04 01:51:48 +08:00
1e18fd2be9 feat: 统一治理配置与通知责任人映射 2026-06-04 01:45:27 +08:00
4914ee3a75 feat: 收口知识库治理配置与模板映射 2026-06-04 01:42:12 +08:00
0b7322aa65 feat: 增强资料包压缩导入异常提示 2026-06-04 01:37:11 +08:00
e7e3202714 feat: 支持处理历史按风险状态筛选 2026-06-04 01:32:35 +08:00
73c6336600 feat: 支持7z资料包导入 2026-06-04 01:28:28 +08:00
24446658ad feat: 增强处理历史风险与通知状态展示 2026-06-04 01:21:02 +08:00
72409e9652 feat: 打通通知回链与历史节点回看 2026-06-04 01:15:44 +08:00
11caa6c908 feat: 增强注册审核节点式结果输出 2026-06-04 01:10:40 +08:00
aa0a24fe5a feat: 支持资料包多文件与zip导入 2026-06-04 01:07:15 +08:00
2b40ddc487 feat: 持久化会话节点结果与失败通知留痕 2026-06-04 01:02:06 +08:00
b8381b3ba1 style: 对齐审核智能体节点与信息卡原型 2026-06-04 00:58:11 +08:00
cac9a4aaeb feat: 收口知识库治理入口与配置口径 2026-06-04 00:54:51 +08:00
77d9420d43 feat: 重构处理历史与通知留痕追踪 2026-06-04 00:49:33 +08:00
d0841e533f feat: 重构资料包模型与会话绑定主链路 2026-06-04 00:43:13 +08:00
ddf5e7d15c docs(清理): 移除废弃的superpowers过程文档 2026-06-04 00:23:37 +08:00
7f96e94c21 docs(设计对齐): 统一飞书责任人与通知口径 2026-06-04 00:16:42 +08:00
89ea02ee33 docs(原型设计): 统一分页节点视图表述 2026-06-04 00:14:17 +08:00
70d58c62fe docs(原型设计): 重构Agent化原型文档主线 2026-06-04 00:11:33 +08:00
b489dc1b37 docs(需求分析): 同步Agent原型与资料包会话绑定 2026-06-04 00:07:20 +08:00
fe7f1e8855 docs(详细设计): 按Agent原型重写流程设计 2026-06-03 23:58:42 +08:00
ea9ad57a5f docs(需求分析): 按Agent原型重写核心需求 2026-06-03 23:55:27 +08:00
7836690303 feat(原型设计): 关联资料包与对话记录 2026-06-03 23:48:53 +08:00
aa5d4d77f8 refactor(原型设计): 调整用户信息展示位置 2026-06-03 23:44:40 +08:00
12a92ad278 refactor(原型设计): 调整导航并合并法规依据入口 2026-06-03 23:43:16 +08:00
134e0fb5ff feat(原型设计): 精简导航并补充资料与法规页 2026-06-03 23:32:56 +08:00
f53d5a1902 refactor(原型设计): 简化顶部导航结构 2026-06-03 23:28:26 +08:00
c303a2fcc6 style(原型设计): 调整为蓝白助手工作台风格 2026-06-03 23:24:16 +08:00
e75f0a0356 refactor(原型设计): 重构为Agent对话优先原型 2026-06-03 23:15:24 +08:00
3da774e537 feat(原型设计): 新增知识库管理与流程状态卡片 2026-06-03 23:00:22 +08:00
54fc1baa4c feat(原型设计): 增强强Agent对话工作台原型 2026-06-03 22:31:23 +08:00
5a137b5b45 fix(原型设计): 修复演示站内容空白问题 2026-06-03 22:01:00 +08:00
08251ae5e6 docs(原型设计): 同步原型交付入口索引 2026-06-03 21:44:31 +08:00
cace6cb941 feat(原型设计): 新增注册审核演示站 HTML 原型 2026-06-03 21:41:27 +08:00
78b841131b docs(原型设计): 补充注册审核平台分页原型方案 2026-06-03 21:34:36 +08:00
7a60af0485 docs(详细设计): 新增飞书通知设计 2026-06-03 21:15:34 +08:00
cc200a32c4 docs(详细设计): 新增Word回填导出设计 2026-06-03 21:12:04 +08:00
2876a1b028 docs(详细设计): 新增风险预警设计 2026-06-03 21:08:15 +08:00
0e49eea683 docs(详细设计): 新增一致性核查设计 2026-06-03 21:04:54 +08:00
4208f29d77 docs(详细设计): 新增字段抽取与字段池设计 2026-06-03 21:00:28 +08:00
759939b446 docs(详细设计): 新增法规完整性检查设计 2026-06-03 20:55:38 +08:00
18428e75fd docs(详细设计): 新增资料包导入与目录汇总设计 2026-06-03 20:50:27 +08:00
zhiye.sun
11c20593d5 docs(requirements): 明确核心信息自动回填目标 2026-06-03 14:13:53 +08:00
zhiye.sun
56a332a7dd docs(requirements): 固化资料包解析确认口径 2026-06-03 14:10:20 +08:00
zhiye.sun
5125f79037 feat(platform-ui): 新增审核指挥台V2原型 2026-06-03 14:01:23 +08:00
zhiye.sun
d670c51d43 chore(registration): 更新注册审核场景输出契约 2026-06-03 14:00:58 +08:00
zhiye.sun
4017151218 docs(requirements): 梳理注册资料审核Agent需求 2026-06-03 14:00:33 +08:00
b2c1da3f02 feat(ui): 重构注册审核平台原型界面 2026-06-03 08:41:48 +08:00
77166b5cd3 docs(requirements): 收紧报送版式与飞书闭环要求 2026-06-03 00:01:59 +08:00
dc4c605723 docs(requirements): 补充飞书接入与法规规则源口径 2026-06-02 23:49:25 +08:00
59d522be0c docs(project): 同步注册审核系统的项目定位说明 2026-06-02 23:08:39 +08:00
e64dca551c docs: 重构真实题目下的需求与资料文档体系 2026-06-02 23:08:15 +08:00
451 changed files with 24627 additions and 45357 deletions

27
.env
View File

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

18
.env.example Normal file
View File

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

21
.env.siliconflow.example Normal file
View File

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

35
.gitignore vendored
View File

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

232
AGENTS.md
View File

@@ -1,65 +1,191 @@
# Agent Collaboration Guide # AGENTS.md
This guide is for Codex or other coding agents working in this repository. 本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格、实现顺序和文档同步要求。
## 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. 当前项目已根据真实笔试题切换为:
The current `master` branch is intended to match `V2`. ```text
试剂盒临床注册文件准备与审核智能体平台
## Important Paths
| 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.
## Common Commands
```bash
python manage.py check
python manage.py migrate
python manage.py runserver
pytest
pytest tests -k regulatory_info_package
pytest tests/test_feishu_*.py
``` ```
## Verification Notes 优先目标:
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`. - 围绕 NMPA 体外诊断试剂注册申报资料场景完成可演示闭环。
- 保证本地可运行、可测试、可讲解。
- 保证代码结构清楚业务流程能从页面、服务层、Agent Core 和审计日志串起来。
- 允许在保留主架构边界前提下进行大幅度业务重构。
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.
## Git Notes ```text
Django 单体 + 独立 Agent Core + Docker Compose
```
- 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.
- 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。
- 资料包会自动绑定会话,标题优先使用解析出的产品名称。
- 审核智能体允许在未上传资料包时直接发起知识库问答,会话保持未绑定资料包状态并走 RAG 检索链路。
- 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 配置有效。
当前默认验证命令:
```bash
pytest
python manage.py check
docker compose config
```
补充约定:
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
- 涉及页面结构时,至少补或更新对应页面测试。
- 涉及导出文件时,需要验证导出记录和下载路径。
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
## 不优先做的事项
第一版不要优先做:
- React / Vue 前端。
- 多租户。
- 复杂 RBAC。
- 完整工作流引擎。
- 深度 Dify 集成。
- 微服务拆分。
- 分布式任务队列。
- 真实飞书发送链路。
这些内容可以作为后续增强,不应影响 V1 快速成型。

15
Dockerfile Normal file
View File

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

View File

@@ -1,55 +0,0 @@
# 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/`,生产环境应迁移到持久化卷和受控备份。

325
README.md
View File

@@ -1,123 +1,260 @@
# DEMO-AGENT V2 # 试剂盒临床注册文件准备与审核智能体平台
DEMO-AGENT V2 是一个面向体外诊断试剂注册资料准备与审核的 Django 工作台。系统把资料上传、文件目录汇总、法规核查、知识库检索、风险提示、整改复核、申报表自动填充和第 1 章监管信息材料包生成组织到同一个可追溯的审核流程中 用于复试展示的体外诊断试剂注册申报资料准备与审核系统
当前 `master` 已与 `V2` 内容对齐,是项目主线 项目已按真实笔试题收口为 NMPA 境内第三类体外诊断试剂注册申报资料场景,重点演示“资料包导入 -> 审核智能体执行 -> 结构化结果 -> Word 导出 -> 通知与审计留痕”的本地闭环
## 核心能力 ## 核心理念
| 能力 | 说明 | ```text
| --- | --- | 注册审核 Agent = 任务配置 + 资料包 + 法规/业务知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
| 审核工作台 | 登录后进入首页,查看对话、附件、知识库、批次和处理状态 | ```
| 对话式工作流 | 在 `/chat/` 中围绕当前对话上传资料、触发汇总、法规核查和生成任务 |
| 文件汇总 | 读取 PDF、Word、Excel、PowerPoint、压缩包等资料生成目录、页数、类型和导出结果 |
| NMPA 法规核查 | 基于规则、文本抽取、RAG 检索和 LLM 复核生成问题、风险和整改建议 |
| 知识库管理 | 上传管理资料、重建索引、检索引用片段,并过滤已停用或删除文档 |
| 申报表填充 | 从说明书和资料中抽取关键字段,生成预填申报表和追溯结果 |
| 第 1 章监管信息材料包 | 生成 CH1.2、CH1.4、CH1.5、CH1.11 等监管信息文件和 zip 产物 |
| 飞书通知与问答 | 支持企业自建应用消息通知,并预留飞书问答模拟命令 |
## 页面入口 ## 技术路线
| 页面 | 路径 | V1 采用:
| --- | --- |
| 登录页 | `http://127.0.0.1:8000/login/` | - Django 单体应用
| 首页 | `http://127.0.0.1:8000/` | - 独立 Agent Core 模块
| 审核智能体 | `http://127.0.0.1:8000/chat/` | - SQLite
| 知识库管理 | `http://127.0.0.1:8000/knowledge-base/` | - Chroma / fallback 检索双路径
| 附件管理 | `http://127.0.0.1:8000/attachments/` | - Django Templates
| 管理后台 | `http://127.0.0.1:8000/admin/` | - Docker Compose
- OpenAI API 兼容的 LLM 与 Embedding 接口
默认不强依赖 Dify。系统保留 Provider / Adapter 边界,后续可接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。
## 当前业务主线
1. 进入审核智能体后,可以先不上传资料,直接通过对话查询法规和业务知识库。
2. 导入注册资料包,支持单文件、多文件和压缩包。
3. 解析文件元数据、页数、章节点和产品名称。
4. 自动创建资料包批次,并绑定审核会话。
5. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告。
6. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析。
7. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口。
8. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史。
9. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕。
## 当前产品入口
当前根路径 `/` 会直接进入审核智能体工作台,便于复试演示聚焦主链路。
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 审核智能体 | `/``/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`
## 项目结构 ## 项目结构
```text ```text
config/ Django 配置和总路由 DEMO-AGENT/
review_agent/ 核心业务应用 manage.py
application_form_fill/ 申报表自动填充 requirements.txt
file_summary/ 文件汇总、附件和导出 Dockerfile
regulatory_review/ 法规核查与整改复核 docker-compose.yml
regulatory_info_package/ 第 1 章监管信息材料包生成 .env.example
notifications/ 飞书通知和消息适配 README.md
feishu_questions/ 飞书问答预留能力 AGENTS.md
static/ 前端脚本和样式
templates/ Django 模板 config/
docs/ 需求、设计、开发计划和原始材料 apps/
tests/ pytest 测试 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/
``` ```
## 本地运行 ## 已落地能力
- 根路径已重定向到审核智能体,降低演示入口复杂度。
- 审核工作台允许未上传资料时直接发起知识库问答,后续再通过右侧上传区导入资料包。
- 资料包导入支持 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 ```bash
python -m venv .venv python -m venv .venv
.venv\Scripts\activate .venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
python manage.py migrate python manage.py migrate
python manage.py createsuperuser
python manage.py runserver python manage.py runserver
``` ```
项目会自动读取仓库根目录 `.env`。当前仓库保留了 V2 的 `.env` 文件;后续如果要面向外部协作,请先确认其中没有不应公开的密钥。 Docker 启动:
## 常用环境变量
| 变量名 | 用途 |
| --- | --- |
| `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 ```bash
python manage.py check docker compose up --build
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 "查最新法规核查"
``` ```
已知情况:当前全量 `pytest` 中仍有少量历史测试与当前页面/LLM 调用策略不完全一致;监管信息材料包主链路测试已通过 Docker Compose 会读取根目录 `.env`,并挂载 `./data``./configs`
## 环境变量
项目通过根目录 `.env` 和系统环境变量读取配置。`.env.example` 只作为模板,不应提交真实密钥。
若复试演示使用硅基流动,可复制 `.env.siliconflow.example``.env`,再手动填入 `LLM_API_KEY``EMBEDDING_API_KEY`
```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`
- `.env.siliconflow.example` 内置硅基流动 `base_url`、Qwen 对话模型和 `BAAI/bge-m3` Embedding 配置。
- Django settings 初始化时会自动加载根目录 `.env`
- 测试环境会在 `tests/conftest.py` 中固定 Mock Provider避免误调用真实 LLM。
## 测试与验证
常用验证命令:
```bash
pytest
python manage.py check
docker compose config
```
当前测试覆盖:
- 项目配置、根路由和核心页面可访问性。
- 场景配置读取、非法 YAML 容错和场景列表展示。
- 资料包导入、压缩包展开、文档解析、入库状态和异常提示。
- 会话创建、对话提交、文档范围传递、结构化结果展示和 Word 导出。
- 审计日志落库、筛选、详情展示、通知留痕和敏感信息脱敏。
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback、工具注册、LLM / Embedding Provider 请求构造。
- 平台治理页、指挥台、知识库、MCP 中心和 Skill 工作室展示。
## 文档入口 ## 文档入口
- [产品说明](PRODUCT.md) - [V1 总需求文档](docs/需求分析/1.V1总需求文档.md)
- [Agent 协作约定](AGENTS.md) - [需求重构总览与待确认事项](docs/需求分析/0.需求重构总览与待确认事项.md)
- [docs 文档索引](docs/README.md) - [Config 模块需求分析](docs/需求分析/1.config模块需求分析.md)
- [需求分析](docs/1.需求分析) - [Scenarios 模块需求分析](docs/需求分析/2.scenarios模块需求分析.md)
- [功能设计](docs/2.功能设计) - [Documents 模块需求分析](docs/需求分析/3.documents模块需求分析.md)
- [数据库设计](docs/3.数据库设计) - [Chat 模块需求分析](docs/需求分析/4.chat模块需求分析.md)
- [详细设计](docs/4.详细设计) - [Audit 模块需求分析](docs/需求分析/5.audit模块需求分析.md)
- [开发计划](docs/5.开发计划) - [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)
## 复试改题流程
拿到新题目后:
1. 判断资料包、规则依据和核心审核链路。
2. 调整最接近的 YAML 场景配置,优先从 `configs/document_review.yaml` 入手。
3. 修改 Agent 角色、目标、指令和输出模板。
4. 上传题目材料并生成资料包。
5. 确认产品名称解析、资料包绑定和会话标题是否正确。
6. 如需业务计算,新增工具函数并通过 Tool Registry 注册。
7. 用 2 到 3 个预设问题测试目录汇总、完整性检查、字段抽取和风险报告。
8. 演示节点结果、知识库引用、结构化输出、Word 导出、通知留痕和审计日志。
## V1 不优先做
- React / Vue 前端。
- 多租户。
- 复杂 RBAC。
- 完整工作流引擎。
- 深度 Dify 集成。
- 微服务拆分。
- 分布式任务队列。
- 真实飞书发送链路。
这些内容可以作为后续增强,不应影响 V1 快速成型。

142
agent_core/governance.py Normal file
View File

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

223
agent_core/llm_provider.py Normal file
View File

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

267
agent_core/orchestrator.py Normal file
View File

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

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

171
agent_core/rag/ingest.py Normal file
View File

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

105
agent_core/rag/retriever.py Normal file
View File

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

27
agent_core/results.py Normal file
View File

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

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

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

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

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

1
apps/audit/__init__.py Normal file
View File

@@ -0,0 +1 @@

19
apps/audit/admin.py Normal file
View File

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

7
apps/audit/apps.py Normal file
View File

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

View File

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

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

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

View File

109
apps/audit/models.py Normal file
View File

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

322
apps/audit/services.py Normal file
View File

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

12
apps/audit/urls.py Normal file
View File

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

45
apps/audit/views.py Normal file
View File

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

1
apps/chat/__init__.py Normal file
View File

@@ -0,0 +1 @@

7
apps/chat/apps.py Normal file
View File

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

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

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

63
apps/chat/forms.py Normal file
View File

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

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

View File

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

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

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

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

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

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

450
apps/chat/views.py Normal file
View File

@@ -0,0 +1,450 @@
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 (
create_knowledge_conversation,
execute_conversation_agent,
execute_conversation_export,
)
RISK_LEVEL_DISPLAY = {
"high": "",
"medium": "",
"low": "",
}
PASS_STATUS_DISPLAY = {
"blocked": "已阻断",
"failed": "失败",
"review_required": "待复核",
"manual_review": "待复核",
"completed": "已完成",
"passed": "已完成",
}
EXPORT_STATUS_DISPLAY = {
"completed": "已完成",
"draft_only": "待复核",
"review_required": "待复核",
"manual_review": "待复核",
"blocked": "已阻断",
"failed": "失败",
"processing": "处理中",
"pending": "处理中",
}
NOTIFY_MESSAGE_STATUS_DISPLAY = {
"sent": "已发送",
"failed": "失败",
"pending": "处理中",
}
def index(request):
conversations = Conversation.objects.all()
if conversations.exists():
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
documents = UploadedDocument.objects.filter(batch__isnull=True)
form = ChatForm(request.POST or None, documents=documents)
upload_form = ConversationUploadForm()
result = None
audit_log = None
conversation = None
if request.method == "POST" and form.is_valid():
conversation = create_knowledge_conversation()
result, audit_log = execute_conversation_agent(
conversation=conversation,
message=form.cleaned_data["message"],
document_ids=form.cleaned_data["document_ids"],
detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
)
conversation.refresh_from_db()
documents = UploadedDocument.objects.filter(batch__isnull=True)
display_node_results = _normalize_node_results(conversation.node_results if conversation else [])
workspace_summary = _build_workspace_summary(conversation, None, display_node_results) if conversation else _build_empty_workspace_summary()
return render(
request,
"chat/index.html",
{
"conversation": conversation,
"conversations": [],
"conversation_history": [],
"batch": None,
"form": form,
"documents": documents,
"document_count": documents.count(),
"result": result,
"audit_log": audit_log,
"node_results": display_node_results,
"active_node": None,
"workspace_summary": workspace_summary,
"conversation_context": _build_conversation_context(conversation, None, workspace_summary) if conversation else {},
"prompt_templates": _build_prompt_templates(),
"analysis_card": _build_analysis_card(result, conversation) if conversation else {},
"upload_form": upload_form,
"export_card": _build_export_card(result, conversation) if conversation else {},
"risk_card": _build_risk_card(result, conversation) if conversation else {},
"notify_card": _build_notify_card(result, conversation) if conversation else {},
},
)
def detail(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = SubmissionBatch.objects.filter(batch_id=conversation.batch_id).first()
documents = UploadedDocument.objects.filter(batch=batch)
form = ChatForm(request.POST or None, documents=documents)
upload_form = ConversationUploadForm()
result = None
audit_log = None
active_node = None
task_modes = [
{"name": "目录汇总", "description": "汇总文件、页数、章节点和目录型文档。"},
{"name": "完整性检查", "description": "对照法规模板检查齐套性、缺失项和错放项。"},
{"name": "字段抽取", "description": "抽取产品名称、规格、适用范围、储存条件等核心字段。"},
{"name": "一致性核查", "description": "比较申请表、说明书和产品列表的字段一致性。"},
{"name": "综合风险报告", "description": "形成高优先级问题、建议动作和责任人通知。"},
]
if request.method == "POST" and form.is_valid():
message = form.cleaned_data["message"]
result, audit_log = execute_conversation_agent(
conversation=conversation,
message=message,
document_ids=form.cleaned_data["document_ids"],
detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
)
active_node = "risk"
conversation.refresh_from_db()
display_node_results = _normalize_node_results(conversation.node_results)
workspace_summary = _build_workspace_summary(conversation, batch, display_node_results)
conversation_context = _build_conversation_context(conversation, batch, workspace_summary)
prompt_templates = _build_prompt_templates()
analysis_card = _build_analysis_card(result, conversation)
export_card = _build_export_card(result, conversation)
risk_card = _build_risk_card(result, conversation)
notify_card = _build_notify_card(result, conversation)
conversation_history = _build_conversation_history(Conversation.objects.all())
return render(
request,
"chat/index.html",
{
"conversation": conversation,
"conversations": Conversation.objects.all(),
"conversation_history": conversation_history,
"batch": batch,
"form": form,
"documents": documents,
"document_count": documents.count(),
"result": result,
"audit_log": audit_log,
"task_modes": task_modes,
"node_results": display_node_results,
"active_node": active_node,
"workspace_summary": workspace_summary,
"conversation_context": conversation_context,
"prompt_templates": prompt_templates,
"analysis_card": analysis_card,
"upload_form": upload_form,
"export_card": export_card,
"risk_card": risk_card,
"notify_card": notify_card,
},
)
@require_POST
def upload_documents(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = get_object_or_404(SubmissionBatch, batch_id=conversation.batch_id)
upload_form = ConversationUploadForm(request.POST, request.FILES)
if upload_form.is_valid():
result = append_documents_to_batch(
"document_review",
batch,
upload_form.cleaned_data["uploaded_files"],
)
warning_count = len(result["registration_overview_report"]["warnings"])
message = "资料已补充到当前资料包。"
if warning_count:
message += f" 当前有 {warning_count} 条待复核提示。"
messages.success(request, message)
else:
messages.error(
request,
"补充资料失败:" + " ".join(upload_form.non_field_errors()) if upload_form.non_field_errors() else "补充资料失败。",
)
return redirect("chat:detail", conversation_id=conversation.conversation_id)
@require_POST
def export_word(request, conversation_id: str):
conversation = get_object_or_404(Conversation, conversation_id=conversation_id)
batch = get_object_or_404(SubmissionBatch, batch_id=conversation.batch_id)
try:
execute_conversation_export(
batch=batch,
conversation=conversation,
)
messages.success(request, "已生成新的 Word 导出文件。")
except Exception as exc:
messages.error(request, f"Word 导出失败:{exc}")
return redirect("chat:detail", conversation_id=conversation.conversation_id)
def _build_workspace_summary(
conversation: Conversation,
batch: SubmissionBatch | None,
display_node_results: list[dict] | None = None,
) -> dict:
normalized_nodes = display_node_results or _normalize_node_results(conversation.node_results)
node_status_map = {node.get("label"): node.get("status", "") for node in normalized_nodes}
risk_status = node_status_map.get("风险预警", "待处理")
notify_status = node_status_map.get("飞书通知", "待处理")
export_status = node_status_map.get("Word 回填导出", "待处理")
highest_risk_level = "" if risk_status in {"已阻断", "待复核"} else ""
latest_summary = conversation.latest_summary or {}
structured_output = latest_summary.get("structured_output") or {}
explicit_export_flag = structured_output.get("can_export_formally")
export_allowed = (
""
if explicit_export_flag is True
else ""
if explicit_export_flag is False
else ""
if risk_status in {"已阻断", "待复核"} or export_status in {"已阻断", "待复核", "失败"}
else ""
)
return {
"highest_risk_level": highest_risk_level,
"export_allowed": export_allowed,
"notify_status": notify_status,
"export_status": export_status,
"download_url": structured_output.get("download_url", ""),
"file_count": batch.file_count if batch else 0,
"page_count": batch.page_count if batch else 0,
}
def _build_empty_workspace_summary() -> dict:
return {
"highest_risk_level": "-",
"export_allowed": "",
"notify_status": "待处理",
"export_status": "待处理",
"download_url": "",
"file_count": 0,
"page_count": 0,
}
def _build_conversation_context(
conversation: Conversation,
batch: SubmissionBatch | None,
workspace_summary: dict,
) -> dict:
return {
"batch_id": conversation.batch_id,
"product_name": conversation.product_name,
"workflow_type": batch.workflow_type if batch else "registration",
"task_status": conversation.get_task_status_display_text(),
"highest_risk_level": workspace_summary.get("highest_risk_level", "-"),
"export_allowed": workspace_summary.get("export_allowed", "-"),
}
def _build_prompt_templates() -> list[str]:
return [
"请汇总当前资料包的章节点、页数和目录覆盖情况",
"请检查当前资料包缺失了哪些必交项和错放项",
"请抽取当前资料包的核心字段并标记低置信度项",
"请给出当前资料包的高风险项、责任人和整改建议",
]
def _build_conversation_history(conversations) -> list[dict]:
"""
组装左栏会话历史摘要。
左栏只展示稳定摘要字段,不在模板里拼风险判断逻辑。
"""
history = []
for item in conversations:
node_status_map = {node.get("label"): node.get("status", "") for node in item.node_results}
risk_status = node_status_map.get("风险预警", "待处理")
history.append(
{
"conversation_id": item.conversation_id,
"title": item.title,
"product_name": item.product_name,
"batch_id": item.batch_id,
"risk_level": "" if risk_status in {"已阻断", "待复核"} else "",
"updated_at": item.updated_at,
"batch_binding_label": "已绑定资料包" if item.batch_id else "未绑定资料包",
}
)
return history
def _build_analysis_card(result: AgentResult | None, conversation: Conversation) -> dict:
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
output_type = structured_output.get("output_type")
if output_type == "registration_overview_report":
return {
"kind": "overview",
"title": "目录汇总能力卡",
"summary": structured_output.get("product_name", ""),
"stats": [
{"label": "资料文件数", "value": structured_output.get("file_count", 0)},
{"label": "总页数", "value": structured_output.get("total_page_count", 0)},
],
"items": structured_output.get("chapter_summary") or [],
"warnings": structured_output.get("warnings") or [],
}
if output_type == "registration_completeness_report":
return {
"kind": "completeness",
"title": "完整性检查能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "风险等级", "value": _get_risk_level_display_text(structured_output.get("risk_level", "-"))}],
"items": structured_output.get("missing_items") or [],
"warnings": structured_output.get("misplaced_items") or [],
}
if output_type == "registration_field_extraction_report":
return {
"kind": "field_extraction",
"title": "字段抽取能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "字段数", "value": len(structured_output.get("field_items") or [])}],
"items": structured_output.get("field_items") or [],
"warnings": structured_output.get("low_confidence_items") or [],
}
if output_type == "registration_consistency_report":
return {
"kind": "consistency",
"title": "一致性核查能力卡",
"summary": structured_output.get("summary", ""),
"stats": [{"label": "风险等级", "value": _get_risk_level_display_text(structured_output.get("risk_level", "-"))}],
"items": structured_output.get("conflict_items") or [],
"warnings": structured_output.get("mixed_document_risks") or [],
}
return {}
def _build_export_card(result: AgentResult | None, conversation: Conversation) -> dict:
"""
统一组装 Word 导出能力卡上下文。
优先使用本次执行结果;若本次未执行,则回退到会话最新摘要。
"""
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
if structured_output.get("output_type") != "registration_word_export_report":
return {}
return {
"template_name": structured_output.get("template_name", ""),
"template_version": structured_output.get("template_version", ""),
"export_status": _get_export_status_display_text(structured_output.get("export_status", "")),
"filled_fields": structured_output.get("filled_fields") or [],
"blocked_fields": structured_output.get("blocked_fields") or [],
"download_url": structured_output.get("download_url", ""),
}
def _build_risk_card(result: AgentResult | None, conversation: Conversation) -> dict:
structured_output = {}
if result and result.structured_output:
structured_output = result.structured_output
else:
structured_output = (conversation.latest_summary or {}).get("structured_output") or {}
if structured_output.get("output_type") != "registration_risk_report":
return {}
return {
"summary": structured_output.get("summary", ""),
"highest_risk_level": _get_risk_level_display_text(
structured_output.get("highest_risk_level", "")
),
"pass_status": _get_pass_status_display_text(structured_output.get("pass_status", "")),
"manual_review_items": structured_output.get("manual_review_items") or [],
"risk_items": structured_output.get("risk_items") or [],
"owner_roles": structured_output.get("owner_roles") or [],
}
def _build_notify_card(result: AgentResult | None, conversation: Conversation) -> dict:
latest_summary = conversation.latest_summary or {}
structured_output = latest_summary.get("structured_output") or {}
notification_payload = latest_summary.get("notification_payload") or {}
if result and result.structured_output:
structured_output = result.structured_output
if result and result.notification_payload:
notification_payload = result.notification_payload
notify_reason = (
structured_output.get("notify_reason")
or notification_payload.get("notify_reason")
or ""
)
mentioned_users = structured_output.get("mentioned_users") or notification_payload.get("mentioned_users") or []
message_status = structured_output.get("message_status") or notification_payload.get("message_status") or ""
web_detail_url = structured_output.get("web_detail_url") or notification_payload.get("web_detail_url") or ""
owners = structured_output.get("owner_roles") or notification_payload.get("owners") or []
if not any([notify_reason, mentioned_users, message_status, web_detail_url, owners]):
return {}
return {
"notify_reason": notify_reason,
"mentioned_users": mentioned_users,
"message_status": _get_notify_message_status_display_text(message_status),
"web_detail_url": web_detail_url,
"owners": owners,
}
def _normalize_node_results(node_results: list[dict]) -> list[dict]:
normalized = []
for node in node_results or []:
item = dict(node)
label = item.get("label", "")
status = item.get("status", "")
if label == "飞书通知":
if status in {"已完成", "success", "sent"}:
item["status"] = "已发送"
elif status in {"failed", "error"}:
item["status"] = "失败"
elif status in {"pending", "processing"}:
item["status"] = "待处理"
normalized.append(item)
return normalized
def _get_risk_level_display_text(level: str) -> str:
return RISK_LEVEL_DISPLAY.get(level, level)
def _get_pass_status_display_text(status: str) -> str:
return PASS_STATUS_DISPLAY.get(status, status)
def _get_export_status_display_text(status: str) -> str:
return EXPORT_STATUS_DISPLAY.get(status, status)
def _get_notify_message_status_display_text(status: str) -> str:
return NOTIFY_MESSAGE_STATUS_DISPLAY.get(status, status)

View File

@@ -0,0 +1 @@

27
apps/documents/admin.py Normal file
View File

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

7
apps/documents/apps.py Normal file
View File

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

75
apps/documents/forms.py Normal file
View File

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

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

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

View File

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

View File

127
apps/documents/models.py Normal file
View File

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

802
apps/documents/services.py Normal file
View File

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

13
apps/documents/urls.py Normal file
View File

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

63
apps/documents/views.py Normal file
View File

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

@@ -0,0 +1 @@

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

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

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

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

View File

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

View File

View File

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

View File

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

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

@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = "platform_ui"
urlpatterns = [
path("knowledge-base/", views.knowledge_base, name="knowledge-base"),
path("mcp-center/", views.mcp_center, name="mcp-center"),
path("skills/", views.skill_studio, name="skills"),
path("command-center/", views.command_center, name="command-center"),
path("command-center-v2/", views.command_center_v2, name="command-center-v2"),
]

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

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

View File

@@ -0,0 +1 @@

7
apps/scenarios/apps.py Normal file
View File

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

110
apps/scenarios/services.py Normal file
View File

@@ -0,0 +1,110 @@
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}")

10
apps/scenarios/urls.py Normal file
View File

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

34
apps/scenarios/views.py Normal file
View File

@@ -0,0 +1,34 @@
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 import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application() application = get_asgi_application()

View File

@@ -1,33 +1,46 @@
import os import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def load_env_file(file_path: Path) -> None: def load_dotenv(dotenv_path: Path) -> None:
"""Loads a simple KEY=VALUE .env file into process env without extra deps.""" """
读取根目录 `.env` 并注入进程环境。
if not file_path.exists(): 这里使用极简解析逻辑,目的是减少额外依赖,
同时让本地 `runserver`、`pytest` 与 Docker Compose 共用一套配置文件。
"""
if not dotenv_path.exists():
return 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() line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
key, value = line.split("=", 1) key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip()) key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
load_env_file(BASE_DIR / ".env") load_dotenv(BASE_DIR / ".env")
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-v2-local-development-key")
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true" 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)
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
host.strip() host.strip()
for host in os.environ.get( for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",")
"DJANGO_ALLOWED_HOSTS",
"127.0.0.1,localhost,testserver",
).split(",")
if host.strip() if host.strip()
] ]
@@ -38,7 +51,11 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"review_agent", "apps.scenarios",
"apps.documents",
"apps.chat",
"apps.audit",
"apps.platform_ui",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -60,7 +77,6 @@ TEMPLATES = [
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
"context_processors": [ "context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request", "django.template.context_processors.request",
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
@@ -71,20 +87,14 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
# V1 默认使用 SQLite确保本地演示零外部依赖。
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3", "NAME": BASE_DIR / "data" / "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" LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai" TIME_ZONE = "Asia/Shanghai"
USE_I18N = True USE_I18N = True
@@ -92,88 +102,24 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_DIRS = [BASE_DIR / "static"]
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "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" 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,32 +1,22 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.views.generic import RedirectView
from django.urls import include, path from django.urls import include, path
from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace
# 总路由只承担模块装配职责,不在这里写业务逻辑。
urlpatterns = [ 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("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 import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

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

45
configs/governance.yaml Normal file
View File

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

22
configs/knowledge_qa.yaml Normal file
View File

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

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

24
configs/risk_audit.yaml Normal file
View File

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

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

25
design-qa.md Normal file
View File

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

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
web:
build: .
env_file:
- .env
ports:
- "8000:8000"
volumes:
- ./data:/app/data
- ./configs:/app/configs

View File

@@ -1,328 +0,0 @@
# 自动汇总文件夹文件目录与页数流程需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始材料 | 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

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

@@ -1,394 +0,0 @@
# 产品关键信息提取与申报文件自动填表需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始材料 | 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

@@ -1,527 +0,0 @@
# 飞书通知与问答接入需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 功能主题 | 飞书通知链路与飞书内问答能力接入 |
| 关联工作流 | 自动汇总、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 | 当前决策已足够进入功能设计阶段 |

View File

@@ -1,450 +0,0 @@
# 第1章监管信息材料包生成需求分析
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 原始输入 | docs/0.原始材料/目标产品说明书.docx |
| 样例模板 | docs/0.原始材料/第1章 监管信息 |
| 法规材料 | docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告 |
| 功能主题 | 从产品说明书生成第1章监管信息材料包 |
| 工作流名称 | 第1章监管信息材料包生成 |
| 工作流编码 | regulatory_info_package |
| 批次号规则 | RIP-YYYYMMDDHHMMSS-abcdef |
| 分析日期 | 2026-06-10 |
| 分析版本 | V1.0 |
---
## 一、需求背景
体外诊断试剂注册申报资料中第1章监管信息包含监管信息目录、申请表、产品列表、申报前沟通说明、符合标准清单、真实性声明和符合性声明等材料。注册人员通常需要根据产品说明书、企业信息和法规要求手工整理这些文件容易出现产品名称、包装规格、组成成分、预期用途等字段重复录入、漏填、格式不一致和待补信息不醒目的问题。
本需求新增独立工作流:用户上传或选择一个产品说明书后,系统以既有 `第1章 监管信息` 样例文件作为模板抽取说明书中的产品关键信息生成一套类似样例目录的第1章监管信息材料包。生成结果以 zip 压缩包作为主下载入口,同时保留单文件辅助下载。
该工作流可以复用现有自动填表工作流中已拆分出的字段抽取、LLM 调用、Word 写入、导出下载、批次事件和通知能力,但不并入 `application_form_fill`,而是作为独立工作流建设。
---
## 二、需求范围
### 2.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 独立工作流 | 新增 `regulatory_info_package`,不复用 `application_form_fill` 的 workflow_type |
| 2 | 单说明书输入 | 本期只支持一个产品说明书作为主输入 |
| 3 | 模板复用 | 以 `docs/0.原始材料/第1章 监管信息` 下的样例文件作为生成模板 |
| 4 | 固定输出文件 | 固定生成 7 个第1章监管信息文件 |
| 5 | 代码抽取与 LLM 抽取并行 | 规则/代码抽取与 LLM 结构化抽取并行处理,合并后写入模板 |
| 6 | 尽量多填 | 对说明书中可识别的产品名称、包装规格、预期用途、组成成分、储存条件、适用仪器、样本类型、检测靶标等字段尽量填入 |
| 7 | 缺失项标记 | 系统新填入的缺失项使用 `/`,并设置黄色底色提醒负责人补充 |
| 8 | LLM-only 标记 | 代码抽取未取到但 LLM 抽取到的字段,也需要在输出文件中高亮提示人工复核 |
| 9 | 模板字段化 | 优先将样例模板整理为 Agent/代码可识别字段模板,使用内容控件 Tag 或稳定占位符,代码只填内容不手改格式 |
| 10 | doc 能力增强 | `.doc` 文档按能力驱动处理:有原生能力时优先原生写入,无原生能力时明确记录并允许 `.docx` 兜底,不静默输出未改写文件 |
| 11 | zip 主输出 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口,单文件作为辅助下载 |
| 12 | 对话唤起提示 | 在对话框底部增加本工作流的唤起提示词 |
| 13 | LLM 意图判断 | 触发判断不能只依赖固定关键词,需要引入 LLM 判断用户是否要生成第1章监管信息材料包 |
### 2.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 多资料综合生成 | 本期不从产品技术要求、检验报告、企业证照等多文件综合生成 |
| 2 | 人工在线编辑 | 本期只生成文件并标记待确认项,不提供网页内字段编辑 |
| 3 | 自动保证法规最终准确 | 标准清单、分类编码、管理类别等无法从说明书确认的信息仍需负责人确认 |
| 4 | 自动提交监管系统 | 本期只生成申报材料包,不对接外部申报平台 |
| 5 | 版式人工校订替代 | 系统尽量保持模板版式,但最终提交前仍需人工核对 |
---
## 三、输入与触发
### 3.1 输入文件规则
| 场景 | 处理规则 |
| --- | --- |
| 用户上传一个 `.docx` 说明书 | 直接作为本次输入 |
| 用户上传多个文件 | 优先选择文件名包含“说明书”的 `.docx` |
| 多个说明书候选 | 工作流进入待确认状态,提示用户选择 |
| 未找到说明书 | 提示用户上传产品说明书 |
| 非 `.docx` 说明书 | 本期可提示格式不支持,后续扩展 `.doc`、PDF 或 OCR |
### 3.2 对话触发规则
固定提示词需要支持:
| 触发表达 | 触发结果 |
| --- | --- |
| 根据说明书生成第1章监管信息 | 启动第1章监管信息材料包生成 |
| 生成监管信息材料包 | 启动第1章监管信息材料包生成 |
| 从说明书生成第1章材料 | 启动第1章监管信息材料包生成 |
除固定表达外,系统需要引入 LLM 意图判断。当用户自然语言表达包含“根据说明书”“第1章”“监管信息”“材料包”“申请表/产品列表/声明”等意图组合时LLM 可判断为 `regulatory_info_package`。规则命中优先,规则未命中时再进入 LLM 路由,避免只靠固定模板。
### 3.3 对话框底部唤起提示
对话框底部快捷提示词新增:
```text
根据说明书生成第1章监管信息
```
后续可追加:
```text
生成监管信息材料包
从说明书生成第1章材料
```
---
## 四、输出文件范围
本期固定生成与样例目录一致的 7 个文件:
| 序号 | 输出文件 | 模板来源 | 生成规则 |
| --- | --- | --- | --- |
| 1 | CH1.2 监管信息目录.docx | 样例 `CH1.2 监管信息目录.docx` | 替换产品名称,目录结构和页码沿用样例 |
| 2 | CH1.4 申请表.docx | 样例 `CH1.4 申请表.docx` | 尽量填入说明书字段,未知项填 `/` 并黄底 |
| 3 | CH1.5 产品列表.docx | 样例 `CH1.5 产品列表.docx` | 按样例表头重建产品列表,货号留空并黄底 |
| 4 | CH1.9 产品申报前沟通的说明.doc | 样例 `CH1.9 产品申报前沟通的说明.doc` | `.doc` 应支持与 `.docx` 等价替换能力 |
| 5 | CH1.11.1 符合标准的清单.docx | 样例 `CH1.11.1 符合标准的清单.docx` | 从说明书和 RAG/法规知识库提取或推荐标准,非明确项需高亮待确认 |
| 6 | CH1.11.5 真实性声明.docx | 样例 `CH1.11.5 真实性声明.docx` | 保留样例正文结构,替换产品名称,公司名位置黄底 `/` |
| 7 | CH1.11.6 符合性声明.docx | 样例 `CH1.11.6 符合性声明.docx` | 保留样例正文结构,替换产品名称,公司名位置黄底 `/` |
### 4.1 下载形态
| 输出类型 | 要求 |
| --- | --- |
| zip 主入口 | 生成 `第1章 监管信息(预生成版).zip`,只包含成功或兜底成功的文件 |
| 单文件下载 | 每个生成文件均可作为辅助下载项展示 |
| 追溯清单 | 建议生成 JSON/Excel记录字段来源、抽取方式、高亮原因和待确认项 |
---
## 五、字段抽取与填写规则
### 5.1 抽取字段范围
系统应从说明书中尽量抽取以下字段:
| 字段 | 示例来源 |
| --- | --- |
| 产品名称 | `【产品名称】` |
| 包装规格 | `【包装规格】` |
| 预期用途 | `【预期用途】` |
| 检测原理/方法原理 | `【检测原理】` |
| 主要组成成分 | `【主要组成成分】` 及其下方表格 |
| 储存条件及有效期 | `【储存条件及有效期】` |
| 样本类型 | `【样本要求】` 中的适用样本类型 |
| 检测靶标 | 预期用途或检测原理中的基因、病原体、抗原、抗体等 |
| 适用仪器 | `【适用仪器】` |
| 检验方法 | `【检验方法】` |
| 生产日期和使用期限描述 | 储存条件章节 |
字段抽取采用规则/代码抽取与 LLM 结构化抽取并行模式:
```text
读取说明书
-> 规则/代码抽取
-> LLM 结构化抽取
-> 字段合并
-> 标记字段来源和置信度
-> 写入模板
```
### 5.2 合并与高亮规则
| 场景 | 处理规则 |
| --- | --- |
| 代码抽取和 LLM 都命中且结果一致 | 正常写入,不强制高亮 |
| 代码抽取和 LLM 都命中但结果不一致 | 优先按规则配置选择,写入值高亮并进入追溯清单 |
| 代码抽取未命中LLM 命中 | 写入 LLM 值,并高亮提示人工复核 |
| 代码抽取命中LLM 未命中 | 正常写入,追溯记录代码抽取来源 |
| 两者均未命中 | 写入 `/` 并设置黄色底色 |
| 企业信息缺失 | 写入 `/` 并设置黄色底色 |
高亮含义:
| 高亮类型 | 视觉要求 | 含义 |
| --- | --- | --- |
| 缺失项高亮 | 黄色底色 | 说明书无法提供,负责人需填写 |
| LLM-only 高亮 | 黄色底色,可在追溯清单标记 `llm_only` | 代码未抽到,仅 LLM 推断,需要复核 |
| 冲突高亮 | 黄色底色,可配合红色字体 | 规则结果与 LLM 结果不一致 |
仅标记系统新填入的缺失项或需复核项。样例模板中原本存在的 `/` 不统一高亮,避免整份文件过度标记。
---
## 六、各文件生成规则
### 6.1 CH1.2 监管信息目录
| 项目 | 规则 |
| --- | --- |
| 产品名称 | 替换为说明书抽取的产品名称 |
| 目录条目 | 沿用样例目录结构 |
| 适用情况 | 沿用样例 |
| 资料名称 | 沿用样例 |
| 页码 | 沿用样例页码 |
### 6.2 CH1.4 申请表
| 字段类型 | 规则 |
| --- | --- |
| 产品名称 | 从说明书抽取 |
| 包装规格 | 从说明书抽取 |
| 主要组成成分 | 优先使用说明书组成成分摘要或附件提示 |
| 预期用途 | 从说明书抽取 |
| 产品储存条件及有效期 | 从说明书抽取 |
| 方法原理 | 从说明书检测原理抽取 |
| 产品类别 | 缺失,填 `/` 并黄底 |
| 分类编码 | 缺失,填 `/` 并黄底 |
| 临床评价路径 | 缺失,填 `/` 并黄底 |
| 申请人信息 | 缺失,填 `/` 并黄底 |
| 联系人、法定代表人、邮箱、组织机构代码 | 缺失,填 `/` 并黄底 |
| 生产地址 | 缺失,填 `/` 并黄底 |
管理类别、分类编码、临床评价路径、UDI、国家标准品/强制标准等不得根据经验自动下结论,全部按待确认处理。
### 6.3 CH1.5 产品列表
产品列表需要转成样例表头:
| 包装规格 | 货号 | 组成 | 组分 | 主要组成成分 | 规格/数量 |
| --- | --- | --- | --- | --- | --- |
生成规则:
| 字段 | 规则 |
| --- | --- |
| 包装规格 | 从说明书组成成分表的规格列或包装规格章节抽取 |
| 货号 | 说明书未提供,填 `/` 并黄底 |
| 组成 | 根据组分名称推断为反应液、质控品、处理液、增强剂等;无法判断则填 `/` 并黄底 |
| 组分 | 使用说明书表格中的组分名称 |
| 主要组成成分 | 使用说明书表格中的主要组成成分 |
| 规格/数量 | 使用说明书表格中的对应规格数量 |
目标产品说明书中存在规格A大包装、规格A分管包装、规格B大管包装等多个组成表系统应尽量展开为多行产品列表。
### 6.4 CH1.9 产品申报前沟通的说明
`CH1.9` 当前为 `.doc` 格式。本工作流要求 `.doc` 文档具备与 `.docx` 等价的原始功能,即模板复制、文本定位、字段替换、高亮标记、导出和打包均应支持 `.doc`
实现上不应只把转换作为唯一方案。可选技术路径包括:
| 路径 | 说明 |
| --- | --- |
| 原生 `.doc` 处理 | 优先探索可直接读取和写入 `.doc` 的库、COM 或二进制文档处理能力 |
| Office/COM 自动化 | Windows 环境下通过 Word COM 直接打开 `.doc` 并原格式写入保存 |
| LibreOffice UNO/API | 通过 LibreOffice API 直接处理旧版 Word而不只作为离线预转换 |
| 转换兜底 | 当原生处理不可用时,可作为兜底手段,但不能作为需求定义中的唯一能力 |
如运行环境不具备 `.doc` 写入能力,工作流应明确失败原因或降级提示,不应静默输出未改写文件。
### 6.5 CH1.11.1 符合标准的清单
生成规则:
| 来源 | 处理方式 |
| --- | --- |
| 说明书明确出现的标准号 | 可直接写入,并记录来源片段 |
| RAG/法规知识库命中的候选标准 | 可作为候选写入或追溯提示,但需高亮待确认 |
| 样例中的标准清单 | 不可无条件沿用 |
| 无法确认的标准 | 填 `/` 并黄底 |
法规材料目录中存在 `医疗器械注册申报资料和批准证明文件格式要求(体外诊断试剂).doc``体外诊断试剂注册申报资料要求及说明.doc``体外诊断试剂安全和性能基本原则清单.doc` 等材料。其中安全和性能基本原则清单属于第3章非临床资料不直接等同于 `CH1.11.1 符合标准的清单`。系统应优先查询已上传 RAG/法规知识库来确认标准清单要求;未命中时不得强行套用样例标准。
### 6.6 CH1.11.5 真实性声明
| 项目 | 规则 |
| --- | --- |
| 正文结构 | 保留样例结构 |
| 产品名称 | 替换为说明书抽取的产品名称 |
| 公司名/申请人 | 填 `/` 并黄底 |
| 日期 | 使用当天日期 |
| 材料列表 | 沿用样例材料列表 |
### 6.7 CH1.11.6 符合性声明
| 项目 | 规则 |
| --- | --- |
| 正文结构 | 保留样例结构 |
| 产品名称 | 替换为说明书抽取的产品名称 |
| 公司名/申请人 | 填 `/` 并黄底 |
| 日期 | 使用当天日期 |
---
## 七、工作流设计
### 7.1 主流程
```text
用户上传或选择产品说明书
-> 用户触发“根据说明书生成第1章监管信息”
-> 系统通过规则和 LLM 判断工作流意图
-> 创建 regulatory_info_package 批次
-> 校验输入说明书
-> 复制第1章监管信息样例模板到批次目录
-> 抽取说明书文本、段落和表格
-> 规则/代码抽取字段
-> LLM 结构化抽取字段
-> 合并字段并识别缺失、LLM-only 和冲突项
-> 生成 7 个目标文件
-> 对缺失项、LLM-only 项和冲突项进行高亮
-> 生成追溯清单
-> 打包第1章监管信息 zip
-> 写入导出记录
-> 对话框展示 zip 主下载入口、单文件下载和待确认摘要
```
### 7.2 节点建议
| 节点编码 | 节点名称 | 成功条件 |
| --- | --- | --- |
| prepare | 准备资料 | 找到唯一说明书输入 |
| template_copy | 复制模板 | 7 个样例模板复制到批次目录 |
| text_extract | 抽取说明书 | 提取说明书段落和表格 |
| field_extract | 抽取字段 | 规则和 LLM 抽取结果均留底 |
| field_merge | 合并字段 | 输出最终字段、缺失项、LLM-only 项和冲突项 |
| generate_docs | 生成材料 | 7 个文件生成完成 |
| highlight_review_items | 标记待确认 | 缺失项、LLM-only、冲突项完成高亮 |
| trace_export | 追溯清单 | 生成 JSON/Excel 追溯清单 |
| zip_export | 打包下载 | 生成 `第1章 监管信息(预生成版).zip` |
| completed | 完成 | 更新批次状态并返回下载摘要 |
### 7.3 状态建议
| 状态 | 含义 |
| --- | --- |
| pending | 已创建,等待执行 |
| running | 执行中 |
| waiting_user | 多个说明书或缺少说明书,等待用户确认 |
| success | zip 和必要单文件生成成功 |
| partial_success | zip 已生成,但部分 `.doc`、追溯清单或高亮处理失败 |
| failed | 关键文件均未生成 |
---
## 八、数据与产物
### 8.1 批次数据
建议新增独立批次模型或等价数据结构,记录:
| 字段 | 说明 |
| --- | --- |
| batch_no | RIP 批次号 |
| workflow_type | regulatory_info_package |
| conversation | 所属对话 |
| user | 发起用户 |
| trigger_message | 触发消息 |
| source_instruction_file | 输入说明书 |
| product_name | 抽取到的产品名称 |
| status | 批次状态 |
| work_dir | 批次工作目录 |
| missing_fields | 缺失字段清单 |
| llm_only_fields | 仅 LLM 命中的字段 |
| conflict_fields | 冲突字段 |
| risk_notes | `.doc` 处理、标准清单待确认等风险提示 |
### 8.2 追溯清单
追溯清单至少记录:
| 字段 | 说明 |
| --- | --- |
| target_file | 目标文件 |
| target_field | 目标字段 |
| final_value | 写入值 |
| extraction_source | rule、llm、missing、rag_candidate |
| evidence | 来源片段 |
| highlight_reason | missing、llm_only、conflict、rag_candidate |
| needs_review | 是否需要负责人确认 |
---
## 九、界面与交互
### 9.1 对话回复
工作流完成后,对话框展示:
| 信息 | 说明 |
| --- | --- |
| 批次号 | RIP 批次号 |
| 产品名称 | 抽取到的产品名称 |
| 主下载 | `第1章 监管信息(预生成版).zip` |
| 单文件下载 | 7 个文件列表 |
| 待确认摘要 | 缺失字段数、LLM-only 字段数、冲突字段数 |
| `.doc` 状态 | CH1.9 是否成功完成 `.doc` 写入 |
| 标准清单提示 | 标准来源和待确认说明 |
### 9.2 工作流卡片
前端需新增 `regulatory_info_package` 工作流卡片,展示节点状态和导出结果。对话框底部新增快捷唤起提示词:
```text
根据说明书生成第1章监管信息
```
---
## 十、异常与降级
| 异常场景 | 处理方式 |
| --- | --- |
| 未上传说明书 | 提示用户上传产品说明书 |
| 多个说明书候选 | 进入 waiting_user提示选择 |
| 产品名称未抽到 | 目标文件产品名位置填 `/` 并黄底 |
| 企业信息缺失 | 相关位置填 `/` 并黄底 |
| LLM 调用失败 | 使用规则抽取结果继续生成,并记录风险提示 |
| 规则抽取失败 | 使用 LLM 结果继续生成LLM-only 字段高亮 |
| RAG/法规知识库不可用 | 标准清单不自动套用样例,写入 `/` 并黄底 |
| `.doc` 原生处理失败 | 批次标记 partial_success 或 failed明确提示 CH1.9 处理失败原因 |
| zip 打包失败 | 保留单文件下载,并提示压缩包生成失败 |
---
## 十一、验收标准
| 序号 | 验收项 | 标准 |
| --- | --- | --- |
| 1 | 触发识别 | 用户输入“根据说明书生成第1章监管信息”可启动 `regulatory_info_package` |
| 2 | LLM 路由 | 非固定话术但语义明确时,可由 LLM 判断进入本工作流 |
| 3 | 输入选择 | 单说明书可直接执行,多说明书进入待确认 |
| 4 | 输出文件 | 生成 7 个与样例同名或同语义的第1章文件 |
| 5 | zip 下载 | 生成 `第1章 监管信息(预生成版).zip` 作为主下载入口 |
| 6 | 单文件下载 | 7 个生成文件均可单独下载 |
| 7 | 产品名称替换 | 目录、申请表、声明类文件中的产品名称替换为说明书产品名称 |
| 8 | 产品列表 | CH1.5 使用样例表头展开说明书组成成分,货号填 `/` 并黄底 |
| 9 | 缺失项高亮 | 系统新填入的 `/` 均有黄色底色 |
| 10 | LLM-only 高亮 | 代码未抽到但 LLM 抽到的字段在文件中高亮 |
| 11 | 标准清单 | 不无条件沿用样例标准;无法确认时填 `/` 并黄底 |
| 12 | 日期 | 声明类文件日期使用当天日期 |
| 13 | `.doc` 支持 | CH1.9 `.doc` 具备与 `.docx` 等价的处理能力,失败时明确提示 |
| 14 | 追溯清单 | 输出字段来源、抽取方式和高亮原因 |
| 15 | 权限隔离 | 用户只能访问自己对话下的批次和导出文件 |
---
## 十二、已确认结论
| 编号 | 结论 |
| --- | --- |
| D1 | 输出范围固定为样例第1章监管信息目录下的 7 个文件 |
| D2 | 样例文件作为模板使用,不只是效果参考 |
| D3 | 企业信息、申请人信息缺失时不沿用样例公司,填 `/` 并黄底 |
| D4 | 管理类别、分类编码、临床评价路径等无法从说明书确认的信息填 `/` 并黄底 |
| D5 | 产品列表货号留空,填 `/` 并黄底 |
| D6 | 标准清单不得无条件沿用样例,优先从说明书和 RAG/法规知识库确认 |
| D7 | 声明日期使用当天日期 |
| D8 | 新建独立工作流,可复用原自动填表工作流拆出的 skill/service |
| D9 | 需求分析文档新增为 `docs/1.需求分析/5.第1章监管信息材料包生成.md` |
| D10 | zip 作为主入口,单文件作为辅助下载 |
| D11 | 对话框底部增加工作流唤起提示词 |
| D12 | 模板优先字段化,使用内容控件 Tag 或稳定占位符服务 Agent/代码填充,行标签定位仅作为兜底 |
| D13 | `.doc` 要按能力驱动实现与 `.docx` 等价能力;原生能力不可用时允许 `.docx` 兜底并明确提示 |
| D14 | 触发判断需要引入 LLM不只依赖固定关键词 |

View File

@@ -1,597 +0,0 @@
# 自动汇总文件夹文件目录与页数流程功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/1.自动汇总.md |
| 功能名称 | 自动汇总文件夹文件目录与页数 |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-05 |
| 设计版本 | V1.0 |
---
## 一、设计目标
本功能面向试剂盒 NMPA 注册申报资料审核场景,支持用户在 AI 对话页上传压缩包或多个文件由后台异步执行文件汇总工作流自动完成解压、文件清单扫描、页数统计、产品名识别、Markdown 报告生成和 Excel 导出。
前台 AI 对话页需要展示一个工作流卡片,实时呈现“上传中、解压中、扫描中、解析中、识别中、输出中、完成/失败”等节点状态工作流完成后AI 对话框展示 Markdown 简表,并提供 Markdown 报告和 Excel 明细下载链接。上传文件、批次、节点状态、文件明细和导出结果均需要与当前对话绑定,一个对话对应一套文件,不能跨对话串用。
---
## 二、总体架构
### 2.1 架构原则
| 原则 | 说明 |
| --- | --- |
| 对话绑定 | 上传文件、处理批次、结果文件均绑定当前 Conversation |
| 按需加载 | 将文件处理流程拆分为多个可单独执行的 Skill按工作流节点调用 |
| 后台异步 | 用户提交后后台执行工作流,前台通过 SSE 接收状态事件 |
| 失败隔离 | 解压失败导致批次失败;单文件解析失败最多重试 3 次后记录异常并继续 |
| 可迁移 MCP | Demo 阶段使用项目内 Skill 注册与调用,后续可迁移为 MCP Tool |
| 可追溯 | 每个节点状态、文件统计结果、导出文件均持久化入库 |
### 2.2 逻辑架构
```mermaid
flowchart TD
A["AI 对话页"] --> B["上传接收接口"]
B --> C["工作流任务表"]
C --> D["后台工作流执行器"]
D --> E["Skill 注册表"]
E --> F1["上传接收 Skill"]
E --> F2["压缩包解压 Skill"]
E --> F3["文件清单扫描 Skill"]
E --> F4["文档页数统计 Skill"]
E --> F5["产品信息识别 Skill"]
E --> F6["汇总报告生成 Skill"]
E --> F7["Excel 导出 Skill"]
D --> G["数据库存档"]
D --> H["导出文件存储"]
D --> I["SSE 状态事件"]
I --> A
```
### 2.3 技术选型
| 设计项 | Demo 方案 | 后续演进 |
| --- | --- | --- |
| 工作流编排 | 项目内 LangGraph 风格节点图执行器 | 接入 LangGraph |
| Skill 形态 | Python 类或函数注册表,按节点名称动态调用 | 封装为 MCP Tool |
| 后台任务 | Django 后台线程 + 数据库状态 | Celery/RQ + Redis |
| 实时更新 | 沿用现有 SSE 流式能力,新增 workflow 事件 | 独立任务事件通道 |
| 文件存储 | 本地 media 目录 | 对象存储或加密文件服务 |
| Markdown 渲染 | 前端引入安全 Markdown 渲染 | 统一富文本渲染组件 |
---
## 三、工作流设计
### 3.1 节点图
```mermaid
flowchart LR
N1["上传中"] --> N2{"是否压缩包"}
N2 -->|"是"| N3["解压中"]
N2 -->|"否"| N4["扫描中"]
N3 --> N4
N4 --> N5["解析页数中"]
N5 --> N6["识别产品名中"]
N6 --> N7["输出中"]
N7 --> N8["完成"]
N3 -->|"解压失败"| F["失败"]
N7 -->|"导出失败"| F
```
### 3.2 节点定义
| 节点编码 | 节点名称 | 触发 Skill | 成功条件 | 失败处理 |
| --- | --- | --- | --- | --- |
| upload | 上传中 | 上传接收 Skill | 原始文件保存成功,批次创建成功 | 批次失败 |
| extract | 解压中 | 压缩包解压 Skill | zip/rar/7z 等压缩包解压成功 | 批次失败 |
| inventory | 扫描中 | 文件清单扫描 Skill | 生成文件清单 | 批次失败或空文件提示 |
| page_count | 解析页数中 | 文档页数统计 Skill | 支持类型完成页数统计或异常记录 | 单文件失败不阻断 |
| product_detect | 识别产品名中 | 产品信息识别 Skill | 识别到产品名或返回空 | 不阻断 |
| report | 输出中 | 汇总报告生成 Skill | Markdown 报告与对话简表生成成功 | 批次失败 |
| export | 输出中 | Excel 导出 Skill | Excel 明细生成成功 | 批次失败或记录导出异常 |
| completed | 完成 | 工作流执行器 | 所有必需产物完成 | 写入完成状态 |
### 3.3 状态机
| 状态 | 含义 |
| --- | --- |
| pending | 已创建,等待执行 |
| running | 执行中 |
| retrying | 单文件解析失败,正在重试 |
| success | 节点执行成功 |
| failed | 节点或批次失败 |
| skipped | 当前节点不需要执行,例如非压缩包跳过解压 |
---
## 四、Skill 设计
### 4.1 Skill 注册与调用
Demo 阶段在项目内定义 Skill 注册表,工作流执行器根据节点编码按需加载并执行对应 Skill。
```text
WorkflowExecutor
-> 根据当前节点读取 Skill 名称
-> 从 SkillRegistry 获取 Skill
-> 执行 skill.run(context)
-> 写入节点状态与结果
-> 发送 SSE 状态事件
-> 进入下一节点
```
后续 MCP 化时,每个 Skill 可映射为独立 MCP Tool输入输出保持稳定 JSON 契约。
### 4.2 上传接收 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 上传接收 Skill |
| 职责 | 接收对话页上传的压缩包或多个文件,保存原始文件,创建上传批次 |
| 输入 | conversation_id、user_id、uploaded_files |
| 输出 | batch_id、upload_file_ids、upload_type、original_storage_paths |
| 数据写入 | FileSummaryBatch、FileAttachment、FileSummaryBatchAttachment |
| 关键规则 | 文件必须绑定当前 Conversation同一对话只使用本对话上传的文件 |
### 4.3 压缩包解压 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 压缩包解压 Skill |
| 职责 | 识别并解压 zip、rar、7z 等常见压缩包,保留目录结构 |
| 输入 | batch_id、source_file_path |
| 输出 | extract_root、extracted_file_count |
| 数据写入 | WorkflowNodeRun、批次工作目录 |
| 关键规则 | 防止路径穿越;解压目录必须限定在批次工作目录内;解压失败批次失败 |
### 4.4 文件清单扫描 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 文件清单扫描 Skill |
| 职责 | 遍历解压目录或散装文件目录,生成文件清单 |
| 输入 | batch_id、scan_root |
| 输出 | inventory_items |
| 数据写入 | FileSummaryItem |
| 关键规则 | 保留目录层级;散装文件归入同一批次根目录;隐藏文件和空文件可标记跳过 |
### 4.5 文档页数统计 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 文档页数统计 Skill |
| 职责 | 对支持类型统计页数或数量 |
| 输入 | batch_id、FileSummaryItem 列表 |
| 输出 | page_count、statistics_status、error_message |
| 数据写入 | FileSummaryItem |
| 关键规则 | 支持 pdf、doc、docx、xls、xlsx、ppt、pptx单文件失败最多重试 3 次,仍失败则记录异常并继续 |
页数统计口径:
| 文件类型 | 统计口径 |
| --- | --- |
| pdf | PDF 页面数量 |
| doc/docx | 优先转 PDF 后统计页面数量 |
| xls/xlsx | Demo 阶段按工作表数量统计 |
| ppt/pptx | 按幻灯片数量统计 |
### 4.6 产品信息识别 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 产品信息识别 Skill |
| 职责 | 尝试识别产品名称,并用于更新对话标题 |
| 输入 | batch_id、文件名、目录名、可读取文本片段 |
| 输出 | product_name、confidence、evidence |
| 数据写入 | FileSummaryBatch.product_name、Conversation.title |
| 关键规则 | 优先从文件名和目录名识别;其次读取文档首页或关键文本;识别失败不阻断流程 |
会话标题规则:
| 场景 | 标题处理 |
| --- | --- |
| 识别到产品名 | 更新为“产品名-文件汇总” |
| 未识别产品名 | 保持原对话标题 |
| 用户已手动命名 | 可保留用户标题,产品名写入批次信息 |
### 4.7 汇总报告生成 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 汇总报告生成 Skill |
| 职责 | 生成完整 Markdown 报告和对话框展示简表 |
| 输入 | batch_id、统计摘要、文件明细、异常清单 |
| 输出 | markdown_report_path、assistant_markdown_summary |
| 数据写入 | ExportedSummaryFile、Message |
| 关键规则 | Markdown 简表需要适合前端对话框渲染;完整报告包含全部文件明细 |
### 4.8 Excel 导出 Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | Excel 导出 Skill |
| 职责 | 生成 Excel 汇总文件 |
| 输入 | batch_id、统计摘要、文件明细 |
| 输出 | excel_path、download_url |
| 数据写入 | ExportedSummaryFile |
| 关键规则 | 至少包含“汇总信息”“文件明细”两个 Sheet |
---
## 五、数据模型设计
### 5.1 FileSummaryBatch
文件汇总批次,表示一次对话内的文件汇总任务。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| conversation | ForeignKey(Conversation) | 绑定对话 |
| user | ForeignKey(User) | 上传用户 |
| batch_no | CharField | 批次编号 |
| product_name | CharField | 识别出的产品名,可为空 |
| upload_type | CharField | archive、multi_file |
| status | CharField | pending、running、success、failed |
| total_files | Integer | 文件总数 |
| supported_files | Integer | 支持统计的文件数 |
| success_files | Integer | 统计成功数 |
| failed_files | Integer | 统计失败数 |
| unsupported_files | Integer | 不支持文件数 |
| total_pages | Integer | 总页数 |
| work_dir | CharField | 批次工作目录 |
| error_message | TextField | 批次异常说明 |
| created_at | DateTimeField | 创建时间 |
| started_at | DateTimeField | 开始时间 |
| finished_at | DateTimeField | 完成时间 |
### 5.2 FileAttachment
上传原始文件记录。用户上传即存储为 `FileAttachment`,批次启动时再通过 `FileSummaryBatchAttachment` 固化本次使用的附件版本。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(FileSummaryBatch) | 所属批次 |
| original_name | CharField | 原始文件名 |
| storage_path | CharField | 保存路径 |
| file_size | BigInteger | 文件大小 |
| content_type | CharField | MIME 类型 |
| created_at | DateTimeField | 上传时间 |
### 5.3 FileSummaryItem
文件明细记录。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(FileSummaryBatch) | 所属批次 |
| file_index | Integer | 文件序号 |
| directory_level | CharField | 目录层级 |
| file_name | CharField | 文件名 |
| file_type | CharField | 文件类型 |
| relative_path | CharField | 相对路径 |
| storage_path | CharField | 实际处理路径 |
| page_count | Integer | 页数,可为空 |
| statistics_status | CharField | success、failed、unsupported、skipped |
| retry_count | Integer | 页数统计重试次数 |
| error_message | TextField | 异常说明 |
| created_at | DateTimeField | 创建时间 |
| updated_at | DateTimeField | 更新时间 |
### 5.4 WorkflowNodeRun
工作流节点运行记录。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(FileSummaryBatch) | 所属批次 |
| node_code | CharField | 节点编码 |
| node_name | CharField | 节点名称 |
| status | CharField | pending、running、retrying、success、failed、skipped |
| progress | Integer | 进度百分比 |
| message | TextField | 节点提示 |
| started_at | DateTimeField | 开始时间 |
| finished_at | DateTimeField | 完成时间 |
### 5.5 ExportedSummaryFile
导出文件记录。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(FileSummaryBatch) | 所属批次 |
| export_type | CharField | markdown、excel |
| file_name | CharField | 文件名 |
| storage_path | CharField | 保存路径 |
| download_url | CharField | 下载链接 |
| status | CharField | success、failed |
| error_message | TextField | 导出异常说明 |
| created_at | DateTimeField | 生成时间 |
---
## 六、后端接口设计
### 6.1 上传并启动工作流
| 项目 | 内容 |
| --- | --- |
| URL | POST /api/review-agent/conversations/{conversation_id}/file-summary/start/ |
| 认证 | 登录用户 |
| 请求类型 | multipart/form-data |
| 请求参数 | files[]、prompt |
| 响应 | batch_id、status、workflow_nodes |
处理逻辑:
```text
校验 conversation 属于当前用户
-> 保存上传文件
-> 创建 FileSummaryBatch
-> 创建 WorkflowNodeRun 初始节点
-> 启动后台线程执行工作流
-> 返回 batch_id 和初始节点状态
```
### 6.2 工作流 SSE 事件流
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/file-summary/{batch_id}/events/ |
| 认证 | 登录用户 |
| 响应类型 | text/event-stream |
事件类型:
| 事件 | 说明 |
| --- | --- |
| workflow_started | 工作流开始 |
| node_started | 节点开始 |
| node_progress | 节点进度更新 |
| node_retrying | 单文件解析重试 |
| node_completed | 节点完成 |
| node_failed | 节点失败 |
| workflow_completed | 工作流完成 |
| workflow_failed | 工作流失败 |
示例:
```json
{
"batch_id": 12,
"node_code": "page_count",
"node_name": "解析页数中",
"status": "retrying",
"progress": 42,
"message": "检测报告.pdf 解析失败,正在第 2 次重试"
}
```
### 6.3 查询批次状态
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/file-summary/{batch_id}/ |
| 认证 | 登录用户 |
| 响应 | 批次摘要、节点状态、文件简表、导出文件链接 |
用途:
| 场景 | 说明 |
| --- | --- |
| 页面刷新恢复 | 前端重新加载后恢复工作流卡片状态 |
| 历史记录查看 | 从会话历史进入后展示已完成汇总结果 |
### 6.4 下载导出文件
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/file-summary/exports/{export_id}/download/ |
| 认证 | 登录用户 |
| 响应 | 文件流 |
权限规则:
```text
export_id -> batch -> conversation -> user
必须等于当前登录用户,才允许下载。
```
---
## 七、前端设计
### 7.1 AI 对话页改造
现有 `templates/home.html``static/js/app.js` 需要增强:
| 改造点 | 说明 |
| --- | --- |
| 附件选择 | 在输入框旁增加文件上传按钮,支持压缩包和多个文件 |
| 工作流卡片 | 用户提交后在对话流中插入工作流状态卡片 |
| SSE 监听 | 监听后台节点事件,实时更新卡片节点状态 |
| Markdown 渲染 | AI 回复支持 Markdown 表格和下载链接渲染 |
| 状态恢复 | 页面刷新后查询批次状态,恢复工作流卡片 |
### 7.2 工作流卡片
卡片包含节点列表:
| 节点 | 前台展示文案 |
| --- | --- |
| upload | 上传中 |
| extract | 解压中 |
| inventory | 扫描中 |
| page_count | 解析页数中 |
| product_detect | 识别产品名中 |
| report/export | 输出中 |
| completed | 已完成 |
节点状态样式:
| 状态 | 展示 |
| --- | --- |
| pending | 灰色等待 |
| running | 高亮进行中 |
| retrying | 黄色重试中 |
| success | 绿色完成 |
| failed | 红色失败 |
| skipped | 灰色跳过 |
### 7.3 对话框结果展示
工作流完成后AI 对话框新增助手消息,内容为 Markdown
```markdown
已完成文件目录与页数汇总。
| 指标 | 数量 |
| --- | --- |
| 文件总数 | 24 |
| 统计成功 | 21 |
| 统计失败 | 2 |
| 不支持 | 1 |
| 总页数 | 386 |
| 序号 | 目录层级 | 文件名 | 类型 | 页数 | 状态 | 异常说明 |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | 注册资料/说明书 | 说明书.docx | docx | 12 | 成功 | |
| 2 | 注册资料/检测报告 | 检测报告.pdf | pdf | 38 | 成功 | |
[下载 Markdown 报告](download-url)
[下载 Excel 明细](download-url)
```
---
## 八、后台服务设计
### 8.1 WorkflowExecutor
负责批次级工作流编排。
| 方法 | 说明 |
| --- | --- |
| start(batch_id) | 启动后台任务 |
| run(batch_id) | 串行执行节点图 |
| run_node(node_code, context) | 执行单个节点 |
| emit_event(batch_id, event_type, payload) | 写入并推送事件 |
| complete(batch_id) | 完成批次 |
| fail(batch_id, error) | 标记批次失败 |
### 8.2 SkillRegistry
负责 Skill 注册与按需加载。
| 方法 | 说明 |
| --- | --- |
| register(name, skill_cls) | 注册 Skill |
| get(name) | 获取 Skill |
| run(name, context) | 执行 Skill |
### 8.3 PageCountService
负责具体文件页数统计。
| 方法 | 说明 |
| --- | --- |
| count_pdf(path) | 统计 PDF 页面数 |
| count_word(path) | doc/docx 转 PDF 后统计页面数 |
| count_excel(path) | 统计工作表数量 |
| count_ppt(path) | 统计幻灯片数量 |
| count_with_retry(item, max_retry=3) | 单文件重试统计 |
### 8.4 ExportService
负责 Markdown 和 Excel 导出。
| 方法 | 说明 |
| --- | --- |
| build_markdown_report(batch) | 生成完整 Markdown 报告 |
| build_chat_summary(batch) | 生成对话简表 |
| build_excel(batch) | 生成 Excel 明细 |
| create_download_record(batch, path, type) | 创建下载记录 |
---
## 九、异常与重试设计
### 9.1 批次级失败
| 场景 | 处理 |
| --- | --- |
| 上传保存失败 | 批次不创建或标记失败 |
| 压缩包无法解压 | 批次失败,工作流终止 |
| 文件清单为空 | 批次失败,提示未检测到可处理文件 |
| 报告导出失败 | 批次失败或标记导出异常 |
### 9.2 文件级失败
| 场景 | 处理 |
| --- | --- |
| 单文件页数解析失败 | 最多重试 3 次 |
| 重试仍失败 | statistics_status=failed记录异常说明继续处理其他文件 |
| 不支持类型 | statistics_status=unsupported不重试 |
| 加密或损坏文件 | statistics_status=failed记录“文件加密或损坏” |
---
## 十、安全设计
| 设计点 | 说明 |
| --- | --- |
| 对话隔离 | 所有批次查询和下载必须校验 conversation.user |
| 防串文件 | 工作流只能读取当前 batch 通过 FileSummaryBatchAttachment 绑定的 FileAttachment |
| 解压安全 | 禁止压缩包内路径跳出批次工作目录 |
| 文件执行安全 | 不执行上传文件中的脚本、宏或外部链接 |
| 下载权限 | 下载接口必须验证当前用户拥有批次所属对话 |
| 存储隔离 | 按 user_id/conversation_id/batch_id 建立存储目录 |
---
## 十一、验收设计
| 序号 | 验收项 | 验收标准 |
| --- | --- | --- |
| 1 | 对话绑定 | A 对话上传的文件不会出现在 B 对话的汇总结果中 |
| 2 | 压缩包处理 | 支持 zip、rar、7z 常见压缩包解压并保留目录结构 |
| 3 | 多文件处理 | 支持一次上传多个散装文件并生成同一批次结果 |
| 4 | 工作流卡片 | 前台能实时展示上传中、解压中、扫描中、解析中、输出中、完成状态 |
| 5 | 解析重试 | 单文件解析失败最多重试 3 次,失败后记录异常并继续 |
| 6 | Markdown 展示 | 对话框能正确渲染 Markdown 表格和下载链接 |
| 7 | 导出下载 | Markdown 报告和 Excel 明细可通过对话框链接下载 |
| 8 | 数据存档 | 数据库保留批次、上传文件、节点状态、文件明细、导出文件记录 |
| 9 | 标题更新 | 识别到产品名后,可将会话标题更新为“产品名-文件汇总” |
---
## 十二、待确认事项
| 序号 | 问题 | 当前建议 | 状态 |
| --- | --- | --- | --- |
| 1 | 是否接入真实 LangGraph 依赖 | Demo 先按 LangGraph 节点图思想自实现轻量编排器 | 待确认 |
| 2 | rar/7z 解压依赖 | 可选 py7zr、rarfile、系统 7z 命令 | 待技术验证 |
| 3 | doc/docx 转 PDF 依赖 | 建议使用 LibreOffice headless | 待技术验证 |
| 4 | 用户手动命名对话时是否允许覆盖 | 建议不覆盖,仅写入产品名字段 | 待确认 |
| 5 | 后台任务是否需要取消能力 | Demo 可不做,正式版建议支持取消 | 待确认 |
---
## 十三、实施建议
1. 先补充数据模型和迁移,建立批次、文件明细、节点状态和导出文件表。
2. 增加上传并启动工作流接口,确保文件和当前对话强绑定。
3. 实现轻量 WorkflowExecutor 和 SkillRegistry先完成 zip、pdf、xlsx、pptx 的主链路。
4. 改造前端对话框,增加附件上传、工作流卡片和 Markdown 渲染。
5. 补齐 doc/docx、rar、7z 等依赖能力,再完善异常重试和下载权限测试。

View File

@@ -1,730 +0,0 @@
# NMPA 注册资料法规核查与整改闭环工作流功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md |
| 依赖功能设计 | docs/2.功能设计/1.自动汇总.md |
| 功能名称 | NMPA 注册资料法规核查与整改闭环 |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-06 |
| 设计版本 | V1.0 |
---
## 一、设计目标
本功能在“自动汇总文件夹文件目录与页数流程”基础上扩展,不重复实现上传、解压、文件扫描、页数统计、基础导出和 SSE 推送能力。法规核查工作流复用已有 `FileSummaryBatch``FileSummaryItem` 作为资料清单输入新增法规规则库、RAG 法规依据索引、法规核查批次、风险问题、过程产物、飞书通知和整改复核能力。
工作流支持两种启动方式:用户可以在已有文件汇总批次完成后发起法规核查;也可以直接在上传资料后发起法规核查,系统内部先执行自动汇总,再串联执行法规核查。若同一对话已存在最近一次成功的文件汇总批次,默认复用该批次。
前端需要新增独立的法规核查工作流卡片。一个对话内可能同时存在“文件汇总”和“法规核查”等多个工作流卡片,卡片区域采用类似轮播图的切换方式展示当前活跃卡片和历史卡片。底层 SSE 事件机制复用现有 `workflow` 事件,通过 `workflow_type` 区分 `file_summary``regulatory_review`
---
## 二、与自动汇总功能的关系
### 2.1 复用边界
| 能力 | 处理方式 | 说明 |
| --- | --- | --- |
| 上传接收 | 复用 | 沿用 `FileAttachment``FileSummaryBatchAttachment` 和上传接收接口 |
| 压缩包解压 | 复用 | 沿用自动汇总的解压 Skill 和工作目录规则 |
| 文件清单扫描 | 复用 | 以 `FileSummaryItem` 作为法规核查文件清单 |
| 页数统计 | 复用 | 法规核查直接读取页数和解析状态 |
| 基础节点状态 | 复用 | 沿用 `WorkflowNodeRun` 事件模型,新增 workflow_type |
| Markdown/Excel 下载 | 部分复用 | 最终报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact` |
| 产品名识别 | 不扩展 | 原 `产品信息识别 Skill` 继续只服务自动汇总标题识别 |
### 2.2 新增边界
| 能力 | 说明 |
| --- | --- |
| 法规适用信息抽取 | 新增 `RegulatoryInfoExtract Skill`,抽取注册类型、临床评价路径、产品关键信息 |
| 适用条件确认 | 通过 AI 对话选择框让用户确认或自由补充 |
| 规则文件与 RAG | 结构化规则文件负责判断RAG 负责法规依据引用和解释 |
| 法规核查批次 | 新增 `RegulatoryReviewBatch`,关联 `FileSummaryBatch` |
| 风险问题与整改状态 | 新增 `RegulatoryIssue`,记录问题、风险、证据、责任人、状态 |
| 过程产物留底 | 新增 `RegulatoryArtifact`,保存条件确认、核查矩阵、风险清单、复核记录等 |
| 飞书通知 | 新增 `FeishuNotifier` 抽象接口Demo 实现接飞书 CLI |
---
## 三、总体架构
### 3.1 架构原则
| 原则 | 说明 |
| --- | --- |
| 依赖汇总批次 | 法规核查必须绑定一个 `FileSummaryBatch`,不能跨对话读取文件 |
| 工作流独立 | 法规核查拥有独立卡片、批次和节点,但事件通道可复用 |
| 规则优先 | 合规判断以结构化规则文件为准RAG 只做法规依据检索和解释增强 |
| 人工确认 | 适用条件缺失时停在待确认,复核通过关闭前需要人工确认 |
| 过程留底 | 所有关键过程文档都要留底,便于复核和 Demo 展示 |
| 通知可替换 | 飞书发送通过接口抽象Demo 接 CLI后续可替换为 Webhook/API |
### 3.2 逻辑架构
```mermaid
flowchart TD
A["AI 对话页"] --> B["工作流卡片轮播区"]
A --> C["法规核查启动接口"]
C --> D{"是否已有成功 FileSummaryBatch"}
D -->|"有"| E["复用最近成功汇总批次"]
D -->|"无"| F["串联执行自动汇总工作流"]
F --> E
E --> G["RegulatoryReviewBatch"]
G --> H["RegulatoryWorkflowExecutor"]
H --> I["SkillRegistry"]
I --> I1["RegulatoryInfoExtract Skill"]
I --> I2["TextExtract Skill"]
I --> I3["CompletenessCheck Skill"]
I --> I4["StructureCheck Skill"]
I --> I5["ConsistencyCheck Skill"]
I --> I6["RiskAssess Skill"]
I --> I7["RegulatoryReportExport Skill"]
H --> J["结构化规则文件"]
H --> K["本地法规 RAG 索引"]
H --> L["RegulatoryIssue"]
H --> M["RegulatoryArtifact"]
H --> N["FeishuNotifier CLI"]
H --> O["workflow SSE 事件"]
O --> B
```
### 3.3 技术选型
| 设计项 | Demo 方案 | 后续演进 |
| --- | --- | --- |
| 工作流编排 | 复用轻量 WorkflowExecutor 思路,新增 RegulatoryWorkflowExecutor | 接入 LangGraph 子图 |
| 事件机制 | 复用 `workflow` SSE新增 `workflow_type=regulatory_review` | 独立工作流事件中心 |
| 规则存储 | 项目内 JSON/YAML 规则文件 | 规则管理后台 + 数据库版本表 |
| 法规依据检索 | 本地 CMDE 文档构建 RAG 索引 | 法规资料定期更新和重建索引 |
| 文本抽取 | 新增统一 TextExtract Skill | 建立文档文本缓存和 OCR 能力 |
| 飞书通知 | `FeishuNotifier` 接飞书 CLI可直接测试发送 | 飞书开放平台 Webhook/API |
| 过程产物 | Markdown、Excel、JSON 留底 | 对象存储 + 证据快照管理 |
---
## 四、工作流设计
### 4.1 启动方式
| 场景 | 处理方式 |
| --- | --- |
| 已有成功文件汇总批次 | 默认复用当前对话最近一次成功 `FileSummaryBatch` |
| 无成功文件汇总批次 | 系统先串联执行自动汇总,再执行法规核查 |
| 用户修改适用条件后重核 | 创建新的 `RegulatoryReviewBatch`,保留旧批次记录 |
| 用户补充缺失文件复核 | 通过对话指令上传补充文件,合并到原问题上下文后复核 |
### 4.2 节点图
```mermaid
flowchart LR
N1["准备资料"] --> N2["识别信息"]
N2 --> N3{"适用条件是否完整"}
N3 -->|"否"| W["待用户确认"]
W --> N4["裁剪规则"]
N3 -->|"是"| N4
N4 --> N5["完整性核查"]
N5 --> N6["文本抽取"]
N6 --> N7["章节核查"]
N6 --> N8["一致性核查"]
N7 --> N9["风险分级"]
N8 --> N9
N9 --> N10["报告导出"]
N10 --> N11["飞书通知"]
N11 --> N12["待整改复核"]
N12 --> N13["完成"]
N4 -->|"规则加载失败"| R["RAG 辅助核查"]
R --> N9
```
### 4.3 主节点与子节点
法规核查卡片展示主节点,主节点可展开查看子节点。
| 主节点 | 子节点 | 说明 |
| --- | --- | --- |
| 准备资料 | 复用批次、检查文件清单、读取规则版本 | 绑定 `FileSummaryBatch` |
| 识别信息 | 产品信息抽取、适用条件识别 | 生成用户确认项 |
| 确认条件 | 对话选择框确认、自由补充 | 卡片只展示等待状态 |
| 法规核查 | 规则裁剪、完整性核查、文本抽取、章节核查、一致性核查 | 完整性先跑,章节和一致性并行 |
| 风险输出 | 风险分级、建议生成、RAG 依据引用、报告导出 | 生成问题和过程产物 |
| 通知复核 | 飞书通知、补充资料、整改复核、关闭确认 | 支持后续闭环 |
### 4.4 节点定义
| 节点编码 | 节点名称 | 触发 Skill/服务 | 成功条件 | 失败或暂停处理 |
| --- | --- | --- | --- | --- |
| prepare | 准备资料 | RegulatoryWorkflowExecutor | 绑定成功的 `FileSummaryBatch` | 无汇总批次则串联自动汇总 |
| info_extract | 识别信息 | RegulatoryInfoExtract Skill | 输出适用条件候选值 | 缺少关键条件则进入待确认 |
| condition_confirm | 确认条件 | Conversation Interaction | 用户确认产品类别、注册类型、临床路径等 | 暂停等待用户输入 |
| rule_scope | 裁剪规则 | RuleLoader | 生成本次适用规则清单 | 规则加载失败则降级 RAG 辅助核查 |
| completeness_check | 完整性核查 | CompletenessCheck Skill | 输出缺失文件和文件项问题 | 单项失败记录待确认 |
| text_extract | 文本抽取 | TextExtract Skill | 抽取关键文件文本和首页内容 | 单文件失败记录问题并继续 |
| structure_check | 章节核查 | StructureCheck Skill | 输出章节缺失和格式问题 | 与一致性核查并行 |
| consistency_check | 一致性核查 | ConsistencyCheck Skill | 输出字段冲突问题 | 低置信度字段可用 LLM 辅助 |
| risk_assess | 风险分级 | RiskAssess Skill | 归并问题、生成风险等级和建议 | 无 RAG 依据时仍输出规则问题 |
| report_export | 报告导出 | RegulatoryReportExport Skill | 生成 Markdown、Excel、JSON 产物 | 导出失败记录批次失败 |
| notify | 飞书通知 | FeishuNotifier | 阻断项、高风险、中风险完成通知 | CLI 失败写入通知失败记录 |
| rectify_review | 整改复核 | RectificationReview Skill | 输出复核通过/不通过 | 关闭前等待人工确认 |
---
## 五、规则库与 RAG 设计
### 5.1 双层法规能力
| 层级 | 职责 | 不承担的职责 |
| --- | --- | --- |
| 结构化规则库 | 判断文件项、章节项、关键字段、一致性字段、风险等级和整改模板 | 不负责自由解释法规 |
| RAG 法规依据索引 | 从本地 CMDE 原文材料检索法规依据片段、来源文件和引用说明 | 不直接决定合规结论 |
### 5.2 规则文件结构
Demo 阶段规则采用项目内 JSON/YAML 文件维护,建议路径:
```text
review_agent/rules/nmpa_ivd_registration_v1.yaml
```
规则文件需要包含版本信息:
| 字段 | 说明 |
| --- | --- |
| version | 规则版本,如 nmpa_ivd_2021_v1 |
| source_url | https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html |
| source_path | 本地 CMDE 法规材料路径 |
| effective_date | 规则生效日期或公告发布日期 |
| rag_index_version | 对应 RAG 索引版本 |
规则项最小结构:
```yaml
version: nmpa_ivd_2021_v1
source_url: https://www.cmde.org.cn/xwdt/zxyw/20210930163300622.html
source_path: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告
effective_date: "2021-09-30"
file_items:
- rule_id: ivd_registration_test_report
title: 注册检验报告
required_type: required
applies_when:
product_category: in_vitro_diagnostic
registration_type: initial_registration
match_keywords:
file_name: ["注册检验报告", "检验报告"]
directory: ["注册检验", "检测报告"]
first_pages: ["医疗器械注册检验报告", "检验结论"]
required_sections: ["样品信息", "检验依据", "检验项目", "检验结论", "签章"]
required_fields: ["产品名称", "型号规格", "样本类型"]
consistency_fields: ["产品名称", "型号规格"]
default_risk_level: blocking
suggestion_template: 请补充与本产品一致的注册检验报告,并确保报告包含样品信息、检验依据、检验项目、检验结论和签章页。
```
### 5.3 规则加载策略
| 场景 | 处理方式 |
| --- | --- |
| 规则文件正常加载 | 按结构化规则执行核查RAG 补充法规依据 |
| 规则文件加载失败 | 降级为 RAG 辅助核查,报告明确标记“仅供参考,不输出正式合规结论” |
| 规则命中但 RAG 无依据 | 仍输出问题,法规依据标记“规则库依据,原文待补充” |
| 规则版本与 RAG 版本不一致 | 批次标记提示项,允许继续但报告记录版本差异 |
### 5.4 RAG 索引设计
| 项目 | 说明 |
| --- | --- |
| 资料来源 | 本地 CMDE 公告目录下的 doc/docx 文档 |
| 索引粒度 | 按标题、段落、表格行或文件项说明切分 |
| 元数据 | source_file、section_title、page_or_row、rule_version、source_url |
| 输出 | matched_snippets、source_file、score、citation_text |
| 用途 | 风险报告中的法规依据、AI 对话解释、飞书通知简要依据 |
---
## 六、Skill 设计
### 6.1 RegulatoryInfoExtract Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 法规适用信息抽取 Skill |
| 职责 | 从 `FileSummaryItem`、文件名、目录名和文本片段中抽取法规适用条件 |
| 输入 | regulatory_batch_id、file_summary_batch_id、file_items |
| 输出 | 产品类别、注册类型、临床评价路径、产品名称、型号规格、预期用途、置信度、证据 |
| 关键规则 | 不修改自动汇总的产品名识别 Skill缺少关键条件时暂停等待用户确认 |
用户确认字段:
| 字段 | 是否必填 | 说明 |
| --- | --- | --- |
| 产品类别 | 是 | 医疗器械/体外诊断试剂等 |
| 注册类型 | 是 | 首次注册、变更注册、延续注册等 |
| 临床评价路径 | 是 | 临床试验、免临床、同品种比对等 |
| 产品名称 | 是 | 用于一致性核查 |
| 型号规格 | 是 | 用于一致性核查 |
| 预期用途 | 是 | 用于规则裁剪和一致性核查 |
### 6.2 TextExtract Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 文本抽取 Skill |
| 职责 | 按需抽取关键文件首页、前几页、章节文本和字段候选值 |
| 输入 | regulatory_batch_id、file_item_ids、extract_scope |
| 输出 | text_blocks、first_page_text、section_candidates、field_candidates |
| 数据写入 | RegulatoryArtifactartifact_type=text_extract_json |
| 关键规则 | 统一抽取,避免完整性、章节、一致性节点重复读取文件 |
### 6.3 CompletenessCheck Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 法规资料完整性核查 Skill |
| 职责 | 对照适用规则清单检查文件项和文件项子项是否存在 |
| 输入 | regulatory_batch_id、file_summary_items、scoped_rules |
| 输出 | missing_items、matched_items、pending_confirm_items |
| 关键规则 | 文件匹配采用文件名、目录名、首页内容三层匹配,记录命中证据 |
### 6.4 StructureCheck Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 章节结构核查 Skill |
| 职责 | 检查关键文件是否包含规则要求章节 |
| 输入 | regulatory_batch_id、text_blocks、required_sections |
| 输出 | missing_sections、abnormal_sections、evidence |
| 关键规则 | 章节缺失按规则初始等级输出,由 RiskAssess 统一归并 |
### 6.5 ConsistencyCheck Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 跨文件一致性核查 Skill |
| 职责 | 抽取并比对产品名称、型号规格、预期用途等核心字段 |
| 输入 | regulatory_batch_id、text_blocks、consistency_fields |
| 输出 | field_values、conflicts、confidence |
| 关键规则 | 规则/正则优先,失败或置信度低时调用 LLM 辅助抽取结构化 JSON |
### 6.6 RiskAssess Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 风险分级与整改建议 Skill |
| 职责 | 归并核查问题,统一风险等级,生成整改建议和法规依据 |
| 输入 | all_check_findings、rules、rag_results |
| 输出 | RegulatoryIssue 列表、risk_summary、suggestions |
| 关键规则 | 核查节点提供初始等级RiskAssess 负责去重、合并、升级或降级 |
### 6.7 RegulatoryReportExport Skill
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 法规核查报告导出 Skill |
| 职责 | 生成最终报告和过程产物 |
| 输入 | regulatory_batch_id、issues、artifacts、notification_records |
| 输出 | Markdown 报告、Excel 清单、JSON 产物、下载链接 |
| 关键规则 | 最终报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact` |
### 6.8 FeishuNotifier
| 项目 | 说明 |
| --- | --- |
| 中文名称 | 飞书通知适配器 |
| 职责 | 对阻断项、高风险、中风险发送飞书通知并 @ 上传人 |
| 输入 | recipient、risk_summary、message_markdown |
| 输出 | send_status、external_message_id、error_message |
| Demo 实现 | 抽象接口接飞书 CLI并支持直接测试发送 |
| 后续演进 | 替换为飞书 Webhook/API |
---
## 七、数据模型设计
### 7.1 RegulatoryReviewBatch
法规核查批次,表示一次法规核查工作流执行。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| conversation | ForeignKey(Conversation) | 绑定对话 |
| user | ForeignKey(User) | 发起用户 |
| file_summary_batch | ForeignKey(FileSummaryBatch) | 关联文件汇总批次 |
| batch_no | CharField | 法规核查批次编号 |
| status | CharField | pending、running、waiting_user、success、failed、reference_only、partial_success、cancelled |
| rule_version | CharField | 使用的结构化规则版本 |
| rule_source_url | URLField | 法规来源 URL |
| rule_source_path | CharField | 本地法规资料路径 |
| rag_index_version | CharField | RAG 索引版本 |
| product_category | CharField | 用户确认后的产品类别 |
| registration_type | CharField | 用户确认后的注册类型 |
| clinical_evaluation_path | CharField | 用户确认后的临床评价路径 |
| product_name | CharField | 产品名称 |
| model_specification | CharField | 型号规格 |
| intended_use | TextField | 预期用途 |
| risk_summary_json | JSONField | 风险数量摘要 |
| error_message | TextField | 异常说明 |
| created_at | DateTimeField | 创建时间 |
| started_at | DateTimeField | 开始时间 |
| finished_at | DateTimeField | 完成时间 |
### 7.2 RegulatoryIssue
法规核查问题和整改状态实体。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 |
| issue_code | CharField | 问题编码 |
| issue_type | CharField | completeness、structure、consistency、notification、review |
| risk_level | CharField | blocking、high、medium、low、info |
| title | CharField | 问题标题 |
| description | TextField | 问题描述 |
| rule_id | CharField | 命中的规则 ID |
| regulation_basis | TextField | 法规依据或规则依据 |
| evidence_json | JSONField | 文件路径、页码、文本片段、字段值等证据 |
| suggestion | TextField | 整改建议 |
| owner | ForeignKey(User) | 默认上传人 |
| status | CharField | 待确认、待处理、已补充、复核通过、复核不通过、已关闭 |
| confirmed_by | ForeignKey(User) | 确认人,可为空 |
| closed_by | ForeignKey(User) | 关闭人,可为空 |
| created_at | DateTimeField | 创建时间 |
| updated_at | DateTimeField | 更新时间 |
### 7.3 RegulatoryArtifact
法规核查过程产物留底实体。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 |
| artifact_type | CharField | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record |
| file_format | CharField | markdown、excel、json |
| file_name | CharField | 文件名 |
| storage_path | CharField | 存储路径 |
| summary | TextField | 产物摘要 |
| created_at | DateTimeField | 创建时间 |
### 7.4 RegulatoryNotificationRecord
飞书通知记录。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(RegulatoryReviewBatch) | 所属法规核查批次 |
| recipient | ForeignKey(User) | 通知对象 |
| channel | CharField | feishu_cli、feishu_api、mock |
| risk_levels | JSONField | 本次通知包含的风险等级 |
| message_summary | TextField | 通知摘要 |
| send_status | CharField | pending、success、failed |
| external_message_id | CharField | 外部消息 ID可为空 |
| error_message | TextField | 失败原因 |
| sent_at | DateTimeField | 发送时间 |
### 7.5 与既有模型关系
```text
Conversation 1:N FileSummaryBatch
FileSummaryBatch 1:N FileSummaryItem
FileSummaryBatch 1:N RegulatoryReviewBatch
RegulatoryReviewBatch 1:N RegulatoryIssue
RegulatoryReviewBatch 1:N RegulatoryArtifact
RegulatoryReviewBatch 1:N RegulatoryNotificationRecord
RegulatoryReviewBatch 1:N ExportedSummaryFile
```
---
## 八、接口设计
### 8.1 发起法规核查
| 项目 | 内容 |
| --- | --- |
| URL | POST /api/review-agent/regulatory-review/start/ |
| 认证 | 登录用户 |
| 请求 | conversation_id、file_summary_batch_id 可选、force_resummary 可选 |
| 响应 | regulatory_batch_id、workflow_type、status |
处理规则:
| 场景 | 说明 |
| --- | --- |
| 传入 file_summary_batch_id | 校验该批次属于当前对话和用户 |
| 未传入 file_summary_batch_id | 默认查找当前对话最近一次成功汇总批次 |
| 无成功汇总批次 | 自动启动文件汇总工作流,完成后继续法规核查 |
### 8.2 提交适用条件确认
| 项目 | 内容 |
| --- | --- |
| URL | POST /api/review-agent/regulatory-review/{batch_id}/confirm-condition/ |
| 认证 | 登录用户 |
| 请求 | product_category、registration_type、clinical_evaluation_path、product_name、model_specification、intended_use |
| 响应 | batch_id、status、next_node |
说明:对话选择框负责收集用户确认结果,接口只接收结构化确认值。用户修改已确认条件时创建新的 `RegulatoryReviewBatch`
### 8.3 查询法规核查状态
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/regulatory-review/{batch_id}/ |
| 认证 | 登录用户 |
| 响应 | 批次状态、主节点状态、风险摘要、导出文件、过程产物 |
用途:
| 场景 | 说明 |
| --- | --- |
| 页面刷新恢复 | 恢复法规核查卡片状态 |
| 卡片轮播切换 | 切换历史工作流卡片时加载详情 |
| 整改复核 | 查看待处理和待确认问题 |
### 8.4 发起整改复核
| 项目 | 内容 |
| --- | --- |
| URL | POST /api/review-agent/regulatory-review/{batch_id}/rectify-review/ |
| 认证 | 登录用户 |
| 请求 | issue_ids、uploaded_files 可选、review_mode |
| 响应 | review_record_id、status、updated_issues |
Demo 阶段主要通过对话指令触发,卡片入口作为设计预留。
### 8.5 下载法规核查文件
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/regulatory-review/artifacts/{artifact_id}/download/ |
| 认证 | 登录用户 |
| 响应 | 文件流 |
权限规则:
```text
artifact_id -> regulatory_batch -> conversation -> user
必须等于当前登录用户,才允许下载。
```
---
## 九、前端设计
### 9.1 多工作流卡片轮播
AI 对话页顶部或对话流内的工作流区域支持多个工作流卡片。
| 设计点 | 说明 |
| --- | --- |
| 展示方式 | 顶部只显示当前活跃卡片,通过左右箭头或点位切换历史卡片 |
| 卡片类型 | file_summary、regulatory_review |
| 事件更新 | 统一监听 `workflow` SSE根据 workflow_type 和 batch_id 更新对应卡片 |
| 卡片职责 | 展示工作流状态,不承载适用条件编辑表单 |
| 历史恢复 | 页面刷新后按对话查询工作流批次并恢复卡片列表 |
### 9.2 法规核查卡片
卡片主节点:
| 主节点 | 展示文案 |
| --- | --- |
| prepare | 准备资料 |
| info_extract | 识别信息 |
| condition_confirm | 确认条件 |
| regulatory_check | 法规核查 |
| risk_output | 风险输出 |
| notify_review | 通知复核 |
| completed | 已完成 |
节点可展开展示子节点,例如法规核查下展开“规则裁剪、完整性核查、文本抽取、章节核查、一致性核查”。
### 9.3 适用条件确认交互
适用条件确认采用 AI 对话选择框,不放在卡片内。交互形式参考计划模式:系统给出识别结果、推荐选项和自由输入能力。
确认字段:
| 字段 | 交互方式 |
| --- | --- |
| 产品类别 | 选项 + 自由输入 |
| 注册类型 | 选项 + 自由输入 |
| 临床评价路径 | 选项 + 自由输入 |
| 产品名称 | 文本确认 |
| 型号规格 | 文本确认 |
| 预期用途 | 文本确认 |
### 9.4 对话框结果展示
工作流完成后新增助手消息,优先展示风险摘要、待处理问题和下载链接。
```markdown
已完成 NMPA 注册资料法规核查。
| 风险等级 | 数量 |
| --- | --- |
| 阻断项 | 2 |
| 高风险 | 1 |
| 中风险 | 3 |
| 低风险 | 4 |
| 提示项 | 2 |
| 等级 | 问题 | 状态 | 建议 |
| --- | --- | --- | --- |
| 阻断项 | 缺少注册检验报告 | 待处理 | 请补充注册检验报告并发起复核 |
[下载 Markdown 核查报告](download-url)
[下载 Excel 缺失清单](download-url)
[下载过程产物 JSON](download-url)
```
---
## 十、事件设计
### 10.1 SSE 事件结构
复用现有 `workflow` 事件,新增字段区分工作流。
```json
{
"event": "workflow",
"workflow_type": "regulatory_review",
"batch_id": 2001,
"conversation_id": 1001,
"node_code": "structure_check",
"node_group": "regulatory_check",
"status": "running",
"message": "正在核查关键文件章节结构",
"progress": 62,
"payload": {
"risk_summary": {
"blocking": 1,
"high": 2
}
}
}
```
### 10.2 状态扩展
| 状态 | 含义 |
| --- | --- |
| pending | 已创建,等待执行 |
| running | 执行中 |
| waiting_user | 等待用户确认适用条件 |
| success | 节点成功 |
| failed | 节点失败 |
| reference_only | 规则库不可用,降级为 RAG 辅助核查 |
| partial_success | 部分节点、通知或非关键过程产物失败,但已输出主要结果 |
| cancelled | 用户或系统取消执行 |
| skipped | 当前节点跳过 |
---
## 十一、输出与留底设计
### 11.1 最终下载文件
最终面向用户下载的报告沿用 `ExportedSummaryFile`
| 文件 | 说明 |
| --- | --- |
| Markdown 核查报告 | 面向人工阅读的完整法规核查报告 |
| Excel 缺失清单 | 面向整改跟踪的风险和缺失清单 |
| JSON 结果包 | 面向后续复核和系统处理的结构化结果 |
### 11.2 过程产物
过程产物进入 `RegulatoryArtifact`
| 产物类型 | 格式 | 说明 |
| --- | --- | --- |
| condition_record | markdown/json | 适用条件识别和用户确认记录 |
| rule_matrix | excel/json | 法规核查矩阵 |
| risk_list | markdown/json | 风险清单和等级归并结果 |
| text_extract_json | json | 关键文件文本抽取结果 |
| rag_result_json | json | RAG 检索依据和引用片段 |
| notification_record | markdown/json | 飞书通知记录 |
| review_record | markdown/json | 整改复核记录 |
---
## 十二、异常与降级设计
| 场景 | 处理 |
| --- | --- |
| 无成功文件汇总批次 | 自动串联执行文件汇总;汇总失败则法规核查不启动 |
| 规则文件加载失败 | 降级为 RAG 辅助核查,标记 `reference_only`,报告声明仅供参考 |
| RAG 检索不到依据 | 规则命中的问题仍输出,依据标记“规则库依据,原文待补充” |
| 关键适用条件缺失 | 工作流进入 `waiting_user`,用户确认后继续 |
| 文本抽取失败 | 记录文件级问题,相关章节或一致性结果标记待确认 |
| LLM 字段抽取失败 | 回退规则/正则结果,低置信度字段进入待确认 |
| 飞书 CLI 发送失败 | 记录通知失败,不阻断报告生成 |
| 过程产物导出失败 | 批次标记失败或部分失败,错误信息写入批次 |
---
## 十三、安全设计
| 设计点 | 说明 |
| --- | --- |
| 对话隔离 | RegulatoryReviewBatch 必须绑定当前 Conversation |
| 文件访问 | 只能读取关联 FileSummaryBatch 下的 FileSummaryItem |
| 下载权限 | 导出文件和过程产物下载必须校验 conversation.user |
| 飞书脱敏 | 飞书通知只展示风险摘要和必要文件名,不直接发送敏感全文 |
| 证据留痕 | 证据片段写入受控存储,不暴露给无权限用户 |
| CLI 安全 | 飞书 CLI 参数使用结构化调用,避免拼接执行用户输入 |
---
## 十四、验收设计
| 序号 | 验收项 | 验收标准 |
| --- | --- | --- |
| 1 | 汇总复用 | 已有成功文件汇总批次时,法规核查默认复用最近批次 |
| 2 | 串联启动 | 无成功汇总批次时,可先自动汇总再执行法规核查 |
| 3 | 多卡片切换 | 同一对话存在多个工作流时,可通过轮播切换卡片 |
| 4 | 适用条件确认 | 系统能识别条件并通过对话选择框让用户确认 |
| 5 | 规则与 RAG | 结构化规则负责判断RAG 能补充法规依据 |
| 6 | 完整性核查 | 能识别缺失文件和文件项级缺失 |
| 7 | 章节核查 | 能识别关键文件章节缺失或异常 |
| 8 | 一致性核查 | 能识别产品名称、型号规格、预期用途等字段冲突 |
| 9 | 风险分级 | 问题能归并为阻断项、高、中、低、提示项 |
| 10 | 飞书通知 | 阻断项、高风险、中风险能通过飞书 CLI @ 上传人 |
| 11 | 过程留底 | 条件确认、核查矩阵、风险清单、RAG 结果、通知记录、复核记录均有产物 |
| 12 | 整改复核 | 用户通过对话指令上传补充资料后,可重新复核问题状态 |
| 13 | 权限隔离 | A 对话的法规核查结果和过程产物不能被 B 对话访问 |
---
## 十五、实施建议
1. 先实现 `RegulatoryReviewBatch``RegulatoryIssue``RegulatoryArtifact``RegulatoryNotificationRecord` 数据模型。
2. 增加规则文件加载器和一版 `nmpa_ivd_registration_v1` 结构化规则。
3. 构建本地 CMDE 法规材料 RAG 索引,确保能按规则项检索依据。
4. 实现法规核查工作流主链路:准备资料、信息抽取、条件确认、规则裁剪、完整性核查。
5. 补齐 `TextExtract`、章节核查、一致性核查、风险归并和报告导出。
6. 接入 `FeishuNotifier` CLI 实现并提供直接测试命令。
7. 改造前端工作流卡片,支持 `workflow_type` 和轮播切换。
8. 最后完善整改复核、过程产物下载和权限校验。
---
## 十六、待确认事项
| 序号 | 问题 | 当前建议 | 状态 |
| --- | --- | --- | --- |
| 1 | 规则文件格式使用 YAML 还是 JSON | 建议 YAML便于人工维护和注释 | 待确认 |
| 2 | 本地 RAG 使用哪种向量库 | 可复用项目依赖中的 ChromaDB | 待技术验证 |
| 3 | 飞书 CLI 具体命令格式 | 需要结合本机飞书 CLI 或企业内部工具确认 | 待确认 |
| 4 | 对话选择框前端能力 | 参考计划模式实现选项 + 自由输入 | 待技术验证 |
| 5 | LLM 抽取是否需要人工确认阈值 | 建议低于置信度阈值进入待确认 | 待确认 |

View File

@@ -1,816 +0,0 @@
# 产品关键信息提取与申报文件自动填表功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md |
| 依赖功能设计 | docs/2.功能设计/1.自动汇总.mddocs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md |
| 功能名称 | 产品关键信息提取与申报文件自动填表 |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-07 |
| 设计版本 | V1.0 |
---
## 一、设计目标
本功能作为独立工作流 `application_form_fill` 建设,由用户在 AI 对话中触发,例如“帮我填注册证”“给我这个内容对应的表格”“为我该方案生成申报模板”“生成安全和性能基本原则清单”“把产品信息填到申报模板里”等。用户可以明确指定目标模板;未指定时,系统根据识别出的注册类型生成当前注册类型适用的全部模板。
本功能复用第一批文件汇总结果作为文件来源复用第二批法规核查中的文本抽取、适用条件识别、LLM 调用、飞书通知和导出下载能力,但拥有独立批次、独立工作流卡片和独立过程产物。系统复制原始法规模板到批次工作目录,不覆盖原始文件;随后按模板配置识别应填字段,使用规则/正则抽取与 LLM 结构化抽取并行处理,合并字段、识别冲突、写入 Word 模板,并在 AI 对话框和飞书通知中提示生成结果与冲突摘要。
Demo 阶段优先保证 Word 模板自动填写和下载。PDF 转换作为待办增强项:功能设计保留 PDF 导出节点和数据结构,实施时可先返回 Word 与追溯清单,并在待办清单记录 PDF 转换能力。
---
## 二、与既有功能的关系
### 2.1 复用边界
| 能力 | 处理方式 | 现有代码/模型 |
| --- | --- | --- |
| 对话与用户权限 | 复用 | `Conversation``Message` |
| 附件上传与文件绑定 | 复用 | `FileAttachment``FileSummaryBatchAttachment` |
| 文件汇总与页数统计 | 复用 | `FileSummaryBatch``FileSummaryItem``file_summary.workflow` |
| 文本抽取 | 复用并扩展 | `regulatory_review/services/text_extract.py``rag_index.py` |
| 适用条件候选 | 复用并扩展 | `regulatory_review/services/info_extract.py` |
| LLM 调用 | 复用 | `review_agent/llm.py``regulatory_review/services/llm_review.py` |
| 导出记录与下载 | 扩展复用 | `ExportedSummaryFile` |
| 过程产物 | 复用 | `RegulatoryArtifact` 或新增填表过程产物 |
| 飞书通知 | 复用并扩展 | `regulatory_review/services/feishu_notifier.py` |
| SSE 工作流事件 | 复用 | `WorkflowNodeRun``WorkflowEvent` |
### 2.2 新增边界
| 能力 | 说明 |
| --- | --- |
| 独立填表批次 | 新增 `ApplicationFormFillBatch`,不强绑法规核查批次 |
| 模板配置 | 新增 YAML 配置,维护模板路径、适用条件、字段映射和输出规则 |
| 模板选择 | 根据用户指定模板和注册类型选择生成范围 |
| 规则/正则与 LLM 并行抽取 | 两路抽取并行执行,最后统一合并 |
| 字段冲突归并 | 按来源文件优先级处理,说明书优先;冲突字段高亮 |
| Word 模板填充 | 使用 `python-docx``.docx` 表格、段落和占位字段写入 |
| `.doc` 模板转换 | 使用 LibreOffice/soffice 或预转换 `.docx` 模板 |
| 字段来源追溯 | 输出 Excel/JSON 追溯清单,记录抽取、合并和冲突证据 |
---
## 三、总体架构
### 3.1 架构原则
| 原则 | 说明 |
| --- | --- |
| 独立工作流 | 填表流程拥有独立批次、节点和卡片workflow_type 为 `application_form_fill` |
| 复用文件汇总 | 填表不重新实现上传扫描,默认使用当前对话最近成功的 `FileSummaryBatch` |
| 用户指令优先 | 用户明确指定模板或注册类型时,优先使用用户指令 |
| 配置驱动 | 模板路径、字段映射、适用条件和输出规则写入 YAML 配置 |
| Word 优先 | Demo 阶段优先生成可编辑 WordPDF 作为增强项进入待办 |
| 可追溯 | 规则抽取、LLM 抽取、合并结果、冲突列表和来源证据均留底 |
| 失败隔离 | 单字段、单模板或 PDF 转换失败不影响其他模板输出 |
| 通知可控 | 填表完成后可通过飞书通知上传人,通知内容只包含摘要和下载提示 |
### 3.2 逻辑架构
```mermaid
flowchart TD
A["AI 对话页"] --> B["意图识别 application_form_fill"]
B --> C{"本次消息是否带附件"}
C -->|"是"| D["先执行文件汇总工作流"]
C -->|"否"| E["查找最近成功 FileSummaryBatch"]
D --> E
E --> F["ApplicationFormFillBatch"]
F --> G["FormFillWorkflowExecutor"]
G --> H["模板配置 YAML"]
G --> I["模板选择服务"]
G --> J["文本抽取服务"]
J --> K1["规则/正则抽取"]
J --> K2["LLM 结构化抽取"]
K1 --> L["字段合并与冲突归并"]
K2 --> L
L --> M["Word 模板填充服务"]
M --> N["追溯清单导出"]
M --> O["PDF 转换服务 P1"]
N --> P["ExportedSummaryFile"]
O --> P
G --> Q["WorkflowEvent/SSE"]
Q --> R["自动填表工作流卡片"]
G --> S["FeishuNotifier"]
S --> T["上传人通知"]
```
### 3.3 技术选型
| 设计项 | Demo 方案 | 后续演进 |
| --- | --- | --- |
| Web 框架 | Django沿用当前 `review_agent` 应用 | 保持 Django必要时拆分独立 app |
| 工作流编排 | 新增轻量 `FormFillWorkflowExecutor` | 接入 LangGraph 子图 |
| 后台执行 | Django 后台线程,沿用现有工作流方式 | Celery/RQ + Redis |
| 工作流状态 | `WorkflowNodeRun` + `WorkflowEvent`,新增 workflow_type | 独立工作流事件中心 |
| 模板配置 | YAML建议路径 `review_agent/application_form_fill/templates/application_form_templates_v1.yaml` | 数据库模板管理后台 |
| Word 处理 | `python-docx` 写入 `.docx` 表格和段落,高亮冲突字段 | OOXML 精细 patch、内容控件 SDT |
| `.doc` 转换 | LibreOffice/soffice headless 转 `.docx`;无法部署时预置 `.docx` 工作模板 | 模板入库前统一转换和人工校验 |
| PDF 导出 | P1 待办LibreOffice/soffice headless 转 PDF | 逐页渲染 QA、版式差异检测 |
| Excel 追溯清单 | `openpyxl` | 增加多 Sheet 审核视图 |
| 文本抽取 | 复用 `text_extract.py``rag_index.py` | OCR、文档文本缓存 |
| 字段抽取 | 规则/正则与 LLM 结构化抽取并行,合并后输出 | 可配置抽取器和置信度模型 |
| 飞书通知 | 复用 `FeishuNotifier`Demo 可 mock 或 CLI | 飞书 Webhook/API |
---
## 四、触发与模板选择设计
### 4.1 意图识别
填表工作流通过用户对话触发。意图识别可先采用关键词规则,必要时调用现有 LLM 路由能力。
| 触发表达 | 触发结果 |
| --- | --- |
| 帮我填注册证 | 触发填表,指定注册证格式 |
| 给我这个内容对应的表格 | 触发填表,未指定模板 |
| 为我该方案生成申报模板 | 触发填表,未指定模板 |
| 生成安全和性能基本原则清单 | 触发填表,指定安全和性能基本原则清单 |
| 把产品信息填到申报模板里 | 触发填表,未指定模板 |
| 只生成变更注册备案文件 | 触发填表,指定变更注册(备案)文件 |
### 4.2 文件来源选择
| 场景 | 处理方式 |
| --- | --- |
| 本次消息带新附件 | 先自动执行文件汇总,汇总成功后启动填表 |
| 本次消息无附件 | 默认使用当前对话最近一次成功 `FileSummaryBatch` |
| 无成功汇总批次 | 对话框提示用户先上传资料或补充附件 |
| 用户明确指定历史批次 | 校验批次属于当前对话和当前用户后使用 |
### 4.3 注册类型识别优先级
注册类型用于决定默认生成哪些模板。优先级如下:
```text
用户话语明确指定
-> 当前对话已确认的法规核查条件
-> 上传文件内容抽取结果
-> 无法识别
```
### 4.4 模板选择规则
| 场景 | 生成模板 |
| --- | --- |
| 用户未指定模板,注册类型为首次注册 | 注册证格式;安全和性能基本原则清单 |
| 用户未指定模板,注册类型为变更注册或备案 | 变更注册(备案)文件;安全和性能基本原则清单 |
| 用户未指定模板,注册类型无法识别 | 安全和性能基本原则清单;注册证/变更文件进入待确认提示 |
| 用户明确指定模板且与注册类型一致 | 只生成用户指定模板 |
| 用户明确指定模板但与注册类型不一致 | 允许生成,并在摘要和追溯清单提示“与识别注册类型不一致,需人工确认” |
| 用户指定“全部模板” | 生成三个目标模板,并提示用户核对注册类型适用性 |
---
## 五、工作流设计
### 5.1 节点图
```mermaid
flowchart LR
N1["准备资料"] --> N2["选择模板"]
N2 --> N3["复制模板"]
N3 --> N4["抽取字段"]
N4 --> N5["冲突归并"]
N5 --> N6["填写 Word"]
N6 --> N7["转换 PDF P1"]
N6 --> N8["追溯清单"]
N7 --> N9["输出下载"]
N8 --> N9
N9 --> N10["飞书通知"]
N10 --> N11["完成"]
```
### 5.2 节点定义
| 节点编码 | 节点名称 | 触发服务 | 成功条件 | 失败处理 |
| --- | --- | --- | --- | --- |
| prepare | 准备资料 | `FormFillWorkflowExecutor` | 找到或生成成功的 `FileSummaryBatch` | 无文件汇总则暂停提示上传 |
| template_select | 选择模板 | `TemplateSelectionService` | 输出本次目标模板列表 | 无适用模板则失败 |
| template_copy | 复制模板 | `TemplateRepository` | 模板副本进入批次工作目录 | 单模板失败不影响其他模板 |
| field_extract | 抽取字段 | `FieldExtractionService` | 规则/正则与 LLM 结果留底 | 单文件失败记录并继续 |
| conflict_merge | 冲突归并 | `FieldMergeService` | 输出最终字段和冲突列表 | 无字段时仍生成空模板 |
| word_fill | 填写 Word | `WordTemplateFillService` | 生成填好后的 Word 文件 | 单模板失败记录失败 |
| pdf_convert | 转换 PDF | `PdfConversionService` | P1生成 PDF 文件 | PDF 失败标记 partial_success |
| trace_export | 追溯清单 | `TraceabilityExportService` | 生成 Excel/JSON 追溯清单 | 失败不影响 Word |
| output_export | 输出下载 | `FormFillExportService` | 写入 `ExportedSummaryFile` 并生成下载链接 | 关键 Word 失败则批次失败 |
| notify | 飞书通知 | `FeishuNotifier` | 通知上传人生成完成 | 通知失败不影响下载 |
| completed | 完成 | 工作流执行器 | 更新批次状态和对话消息 | - |
### 5.3 状态设计
| 状态 | 含义 |
| --- | --- |
| pending | 已创建,等待执行 |
| running | 执行中 |
| waiting_user | 缺少文件或关键条件,等待用户补充 |
| success | Word 和必要追溯产物生成成功 |
| partial_success | Word 已生成但部分模板、PDF、追溯清单或通知失败 |
| failed | 所有目标 Word 模板均生成失败 |
| skipped | 当前节点不适用,例如 Demo 阶段跳过 PDF |
---
## 六、模板配置设计
### 6.1 配置文件路径
建议新增:
```text
review_agent/application_form_fill/templates/application_form_templates_v1.yaml
```
### 6.2 配置结构
```yaml
version: application_form_templates_v1
source_dir: docs/0.原始材料/关于公布体外诊断试剂注册申报资料要求和批准证明文件格式的公告
templates:
- code: registration_certificate
name: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式)
source_file: 中华人民共和国医疗器械注册证(体外诊断试剂)(格式).docx
output_label: 注册证格式
applies_when:
registration_type: ["首次注册"]
file_format: docx
fields:
- key: product_name
label: 产品名称
target:
type: table_row
row_label: 产品名称
sources: ["说明书", "产品技术要求", "注册检验报告"]
- key: package_specification
label: 包装规格
target:
type: table_row
row_label: 包装规格
sources: ["说明书", "产品技术要求"]
```
### 6.3 模板配置项
| 配置项 | 说明 |
| --- | --- |
| code | 模板编码,用于用户指定和导出分类 |
| name | 模板中文名称 |
| source_file | 原始模板文件名 |
| working_template | 可选,预转换 `.docx` 工作模板 |
| output_label | 文件命名中的模板标签 |
| applies_when | 默认适用注册类型 |
| fields | 字段映射列表 |
| checklist_items | 安全和性能基本原则清单条目映射 |
| conversion | `.doc``.docx` 和 PDF 的转换策略 |
### 6.4 已知模板字段
注册证格式当前已从 `.docx` 表格识别到以下字段:注册人名称、注册人住所、生产地址、代理人名称、代理人住所、产品名称、包装规格、主要组成成分、预期用途、产品储存条件及有效期、附件、其他内容、备注。
变更注册(备案)文件和安全和性能基本原则清单当前为 `.doc`,实施前需通过 LibreOffice/soffice 转换或预置人工确认版 `.docx` 工作模板,再补齐字段映射。
---
## 七、字段抽取与合并设计
### 7.1 三层提取链路
```text
模板字段配置
-> 文档字段候选提取
-> 规则/正则抽取与 LLM 结构化抽取并行
-> 字段归一化
-> 来源优先级合并
-> 冲突识别
-> 最终字段包
```
### 7.2 规则/正则抽取
| 能力 | 说明 |
| --- | --- |
| 标签字段识别 | 识别 `产品名称:``预期用途:``储存条件:` 等标签行 |
| 表格字段识别 | 从 Word/Excel 表格中识别左侧字段名、右侧字段值 |
| 章节范围识别 | 从说明书、产品技术要求中按章节提取连续文本 |
| 文件类型识别 | 根据文件名、目录名和首页标题判断说明书、产品技术要求、检验报告 |
| 证据片段截取 | 保存字段前后上下文,用于追溯清单 |
### 7.3 LLM 结构化抽取
LLM 输入为模板字段清单、文件上下文和候选文本片段,输出严格 JSON
```json
{
"fields": [
{
"key": "storage_condition",
"label": "产品储存条件及有效期",
"value": "2-8℃保存有效期12个月",
"source_file": "说明书.docx",
"evidence": "产品储存条件2-8℃保存...",
"confidence": 0.86
}
],
"checklist_items": [
{
"item_code": "A1",
"applicability": "适用",
"compliance_evidence": "产品技术要求中规定了性能指标和检验方法",
"proof_location": "产品技术要求.docx 第2章"
}
]
}
```
### 7.4 并行合并规则
| 场景 | 处理规则 |
| --- | --- |
| 规则和 LLM 值一致 | 合并为同一字段,提高置信度 |
| 规则和 LLM 值不一致,但来源文件不同 | 按来源文件优先级处理,说明书优先 |
| 规则和 LLM 值不一致,来源文件相同 | 标记冲突,模板中高亮 |
| 说明书与其他文件冲突 | 采用说明书值,黄色底色、红色字体标记 |
| 说明书缺失,多个来源冲突 | 取最高优先级文件值并标记冲突;无法判断则留空 |
| 字段缺失 | 模板留空,追溯清单记录未提取 |
### 7.5 过程产物留底
字段抽取结果保存为 `field_extract_result.json`,至少包含:
| 内容 | 说明 |
| --- | --- |
| regex_results | 规则/正则抽取结果 |
| llm_results | LLM 结构化抽取结果 |
| merged_fields | 合并后的最终字段 |
| conflicts | 冲突字段列表 |
| source_evidence | 来源文件和文本片段 |
| selected_templates | 本次选择的模板 |
---
## 八、安全和性能基本原则清单设计
### 8.1 判断策略
安全和性能基本原则清单采用“候选判断 + 高置信度写入”策略。
| 步骤 | 说明 |
| --- | --- |
| 条目拆解 | 从模板配置中读取条目编号、原则内容、适用性栏、证据栏、证明文件位置栏 |
| 候选判断 | 规则和 LLM 均可给出适用/不适用候选 |
| 证据匹配 | 从产品技术要求、说明书、性能研究、稳定性研究、风险管理资料中匹配证明文件 |
| 高置信度写入 | 仅将高置信度判断写入 Word |
| 低置信度留空 | 证据不足或判断不一致时 Word 留空,追溯清单记录候选判断 |
| 冲突提示 | 冲突条目在对话框和追溯清单中提示,不强行填入 |
### 8.2 输出字段
| 字段 | 说明 |
| --- | --- |
| 条目编号 | 基本原则清单中的条目编码 |
| 条目内容 | 原始原则或要求 |
| 适用性 | 适用/不适用,低置信度留空 |
| 符合性证据 | 高置信度证据摘要 |
| 证明文件位置 | 文件名、章节、页码或文本定位 |
| 置信度 | 用于判断是否写入 Word |
| 候选来源 | 规则、LLM 或两者一致 |
---
## 九、Word 与 PDF 生成设计
### 9.1 Word 模板填充
| 能力 | 说明 |
| --- | --- |
| 模板副本 | 原始模板复制到批次工作目录后再写入 |
| 表格行填充 | 根据行首字段名定位目标单元格 |
| 段落占位填充 | 支持 `{{field_key}}` 等占位符 |
| 清单条目填充 | 按条目编号和配置列写入适用性、证据和证明位置 |
| 冲突高亮 | 冲突字段使用黄色底色和红色字体 |
| 缺失字段 | 保持空白,不写“待补充” |
| 版式保持 | 尽量不改变表格结构、分页和字体 |
### 9.2 PDF 转换
PDF 转换作为 P1 待办增强项设计:
| 阶段 | 处理 |
| --- | --- |
| Demo 主链路 | 优先生成 Word不因 PDF 能力缺失阻断工作流 |
| P1 增强 | 使用 LibreOffice/soffice headless 将 Word 转为 PDF |
| 失败处理 | Word 已生成但 PDF 失败时,批次状态为 `partial_success` |
| QA 增强 | 后续增加 PDF 页数非 0、逐页截图或版式差异检查 |
---
## 十、输出与下载设计
### 10.1 输出文件
| 文件 | Demo 阶段 | P1/P2 |
| --- | --- | --- |
| 填好后的 Word | 必须生成 | 持续支持 |
| PDF 预览 | 待办增强 | LibreOffice 转换生成 |
| 字段来源追溯清单 Excel | 允许生成,建议实现 | 增加多 Sheet |
| 字段抽取 JSON | 过程产物留底 | 支持下载或调试查看 |
### 10.2 文件命名
```text
批次号-产品名称-注册证格式.docx
批次号-产品名称-注册证格式.pdf
批次号-产品名称-变更注册备案文件.docx
批次号-产品名称-变更注册备案文件.pdf
批次号-产品名称-安全和性能基本原则清单.docx
批次号-产品名称-安全和性能基本原则清单.pdf
批次号-产品名称-字段来源追溯清单.xlsx
```
### 10.3 ExportedSummaryFile 扩展
继续复用 `ExportedSummaryFile`,但需要扩展 `ExportType`
| export_type | 说明 |
| --- | --- |
| markdown | 既有 Markdown 报告 |
| excel | Excel 追溯清单 |
| json | 字段抽取 JSON 或结果包 |
| word | 填好的 Word 文件,新增 |
| pdf | Word 转换后的 PDF新增 |
填表工作流导出记录建议:
| 字段 | 值 |
| --- | --- |
| workflow_type | `application_form_fill` |
| workflow_batch_id | `ApplicationFormFillBatch.id` |
| export_category | `filled_template``traceability``extract_result` |
| export_type | `word``pdf``excel``json` |
导出服务入参应包含目标输出类型列表,例如:
```json
{
"output_types": ["word", "pdf", "excel"],
"template_codes": ["registration_certificate", "essential_principles"]
}
```
系统根据入参决定生成哪些类型的内容。
---
## 十一、数据模型设计
### 11.1 ApplicationFormFillBatch
新增自动填表批次表。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| conversation | ForeignKey(Conversation) | 绑定对话 |
| user | ForeignKey(User) | 发起用户 |
| source_summary_batch | ForeignKey(FileSummaryBatch) | 文件来源批次 |
| source_regulatory_batch | ForeignKey(RegulatoryReviewBatch, null=True) | 可选,复用已确认法规条件 |
| batch_no | CharField | 填表批次号,如 AFF-YYYYMMDDHHMMSS |
| status | CharField | pending、running、waiting_user、success、partial_success、failed |
| trigger_message | ForeignKey(Message, null=True) | 触发消息 |
| requested_templates | JSONField | 用户指定模板 |
| selected_templates | JSONField | 实际生成模板 |
| output_types | JSONField | 请求输出类型,如 word、pdf、excel |
| registration_type | CharField | 注册类型 |
| product_name | CharField | 产品名称 |
| conflict_summary | JSONField | 冲突摘要 |
| risk_notes | JSONField | 不适用模板、低置信度等提示 |
| work_dir | CharField | 批次工作目录 |
| error_message | TextField | 异常说明 |
| created_at | DateTimeField | 创建时间 |
| started_at | DateTimeField | 开始时间 |
| finished_at | DateTimeField | 完成时间 |
### 11.2 ApplicationFormFillArtifact
可新增独立过程产物表,也可复用 `RegulatoryArtifact`。考虑到这是独立工作流,建议新增轻量产物表,结构与 `RegulatoryArtifact` 保持一致。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | BigAutoField | 主键 |
| batch | ForeignKey(ApplicationFormFillBatch) | 所属填表批次 |
| artifact_type | CharField | template_copy、field_extract_result、merged_fields、traceability、notification_record |
| file_format | CharField | json、excel、docx、pdf |
| name | CharField | 产物名称 |
| storage_path | CharField | 存储路径 |
| metadata | JSONField | 模板编码、输出类型、生成状态等 |
| content_hash | CharField | 文件 hash |
| created_at | DateTimeField | 创建时间 |
### 11.3 与既有模型关系
```text
Conversation 1:N ApplicationFormFillBatch
FileSummaryBatch 1:N ApplicationFormFillBatch
RegulatoryReviewBatch 0:N ApplicationFormFillBatch
ApplicationFormFillBatch 1:N ApplicationFormFillArtifact
ApplicationFormFillBatch 1:N WorkflowNodeRun
ApplicationFormFillBatch 1:N ExportedSummaryFile
```
---
## 十二、后端服务设计
### 12.1 FormFillWorkflowExecutor
| 方法 | 说明 |
| --- | --- |
| run(batch) | 串行执行自动填表节点 |
| run_node(node) | 执行单节点并记录进度 |
| resolve_source_summary_batch() | 根据本次附件或最近成功批次确定来源 |
| emit_event() | 写入 `WorkflowEvent` |
| complete_or_partial() | 根据 Word/PDF/通知结果更新批次状态 |
### 12.2 TemplateSelectionService
| 方法 | 说明 |
| --- | --- |
| parse_requested_templates(message) | 从用户话语中识别指定模板 |
| detect_registration_type() | 按用户话语、法规确认条件、文件抽取识别注册类型 |
| select_templates() | 根据注册类型和用户指令输出模板列表 |
### 12.3 TemplateRepository
| 方法 | 说明 |
| --- | --- |
| load_config() | 读取 YAML 模板配置 |
| resolve_source_template(code) | 找到原始模板或预转换模板 |
| copy_to_work_dir(code, batch) | 复制模板到批次目录 |
| convert_doc_to_docx(path) | `.doc``.docx` |
### 12.4 FieldExtractionService
| 方法 | 说明 |
| --- | --- |
| extract_by_rules(texts, template_fields) | 规则/正则抽取 |
| extract_by_llm(texts, template_fields) | LLM 结构化抽取 |
| run_parallel() | 并行执行两路抽取 |
| save_extract_artifact() | 保存 `field_extract_result.json` |
### 12.5 FieldMergeService
| 方法 | 说明 |
| --- | --- |
| normalize_fields() | 字段名、单位、空白和同义词归一 |
| rank_sources() | 按说明书、产品技术要求、检验报告等来源排序 |
| merge() | 输出最终字段 |
| detect_conflicts() | 输出冲突列表和高亮标记 |
### 12.6 WordTemplateFillService
| 方法 | 说明 |
| --- | --- |
| fill_table_rows() | 根据行名定位表格单元格并写入 |
| fill_placeholders() | 替换段落占位符 |
| fill_checklist_items() | 写入安全和性能基本原则清单 |
| apply_conflict_highlight() | 黄底红字标记冲突字段 |
| save_docx() | 保存填好后的 Word |
### 12.7 TraceabilityExportService
| 方法 | 说明 |
| --- | --- |
| build_excel() | 生成字段来源追溯清单 |
| build_json() | 生成结构化追溯 JSON |
| create_export_records() | 写入 `ExportedSummaryFile` |
### 12.8 FormFillNotifier
复用或包装 `FeishuNotifier`
| 通知场景 | 说明 |
| --- | --- |
| 填表成功 | 通知上传人文件已生成 |
| 部分成功 | 通知 Word 已生成,但 PDF/部分模板失败 |
| 冲突字段存在 | 通知中提示存在冲突字段,需下载核对 |
| 失败 | 可选通知失败原因Demo 可只在对话框展示 |
---
## 十三、接口设计
### 13.1 发起自动填表
| 项目 | 内容 |
| --- | --- |
| URL | POST /api/review-agent/application-form-fill/start/ |
| 认证 | 登录用户 |
| 请求 | conversation_id、message_id、file_summary_batch_id 可选、template_codes 可选、output_types 可选 |
| 响应 | batch_id、workflow_type、status、selected_templates |
处理规则:
```text
校验 conversation 属于当前用户
-> 如本次消息带附件,先执行文件汇总
-> 否则查找当前对话最近成功 FileSummaryBatch
-> 创建 ApplicationFormFillBatch
-> 初始化 WorkflowNodeRun
-> 启动 FormFillWorkflowExecutor
-> 返回工作流卡片初始状态
```
### 13.2 查询自动填表状态
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/application-form-fill/{batch_id}/ |
| 认证 | 登录用户 |
| 响应 | 批次状态、节点状态、选择模板、冲突摘要、导出文件 |
### 13.3 下载导出文件
继续复用:
| 项目 | 内容 |
| --- | --- |
| URL | GET /api/review-agent/file-summary/exports/{export_id}/download/ |
| 认证 | 登录用户 |
| 响应 | 文件流 |
权限规则:
```text
export_id -> workflow_type/workflow_batch_id -> ApplicationFormFillBatch -> conversation -> user
必须等于当前登录用户,才允许下载。
```
---
## 十四、前端设计
### 14.1 自动填表工作流卡片
前端新增独立卡片类型 `application_form_fill`,展示节点:
| 节点 | 展示文案 |
| --- | --- |
| prepare | 准备资料 |
| template_select | 选择模板 |
| template_copy | 复制模板 |
| field_extract | 抽取字段 |
| conflict_merge | 冲突归并 |
| word_fill | 填写 Word |
| pdf_convert | 转换 PDF |
| output_export | 输出下载 |
| notify | 飞书通知 |
| completed | 已完成 |
### 14.2 对话框结果展示
工作流完成后AI 对话框展示 Markdown 摘要:
```markdown
已生成申报模板自动填表文件。
| 文件 | Word | PDF |
| --- | --- | --- |
| 注册证格式 | 下载 | 待生成 |
| 安全和性能基本原则清单 | 下载 | 待生成 |
| 冲突字段 | 采用值 | 冲突来源 | 处理 |
| --- | --- | --- | --- |
| 储存条件 | 2-8℃保存 | 产品技术要求:-20℃保存 | 已按说明书填入,并在模板中高亮 |
[下载字段来源追溯清单](download-url)
```
### 14.3 指定模板交互
用户可以通过自然语言指定模板。前端无需额外表单,后端意图识别后在卡片中展示本次选择模板。
---
## 十五、事件设计
### 15.1 SSE 事件结构
```json
{
"event": "workflow",
"workflow_type": "application_form_fill",
"batch_id": 3001,
"conversation_id": 1001,
"node_code": "field_extract",
"node_group": "form_fill",
"status": "running",
"progress": 55,
"message": "正在并行抽取模板字段",
"payload": {
"selected_templates": ["registration_certificate", "essential_principles"],
"processed_files": 8,
"total_files": 20
}
}
```
### 15.2 节点进度
| 节点 | 进度口径 |
| --- | --- |
| 准备资料 | 是否找到来源批次 |
| 选择模板 | 模板数量 |
| 复制模板 | 已复制模板数/总模板数 |
| 抽取字段 | 已处理文件数/总文件数 |
| 冲突归并 | 字段数量和冲突数量 |
| 填写 Word | 已生成 Word 数/目标 Word 数 |
| 转换 PDF | 已生成 PDF 数/目标 PDF 数 |
| 输出下载 | 已创建下载记录数 |
| 飞书通知 | 通知状态 |
---
## 十六、异常与降级设计
| 场景 | 处理 |
| --- | --- |
| 无成功文件汇总批次 | 进入 waiting_user提示上传资料 |
| 新附件汇总失败 | 填表工作流不启动或标记失败 |
| 用户指定不适用模板 | 允许生成,摘要提示需人工确认 |
| `.doc` 转换失败 | 该模板失败,其他模板继续 |
| 单字段缺失 | Word 留空,追溯清单记录未提取 |
| 规则和 LLM 冲突 | 按来源优先级合并,冲突高亮 |
| 所有 Word 生成失败 | 批次 failed |
| 部分 Word 生成失败 | 批次 partial_success |
| PDF 转换失败 | 批次 partial_success保留 Word 下载 |
| 飞书通知失败 | 不影响文件下载,记录通知失败 |
---
## 十七、安全设计
| 设计点 | 说明 |
| --- | --- |
| 原始模板保护 | 只读原始模板,所有写入发生在批次工作目录副本 |
| 对话隔离 | 填表批次必须绑定当前 Conversation |
| 文件读取权限 | 只能读取关联 `FileSummaryBatch` 下的文件 |
| 下载权限 | 根据 workflow_type 和 workflow_batch_id 校验当前用户 |
| LLM 输入控制 | 只传必要文本片段和字段上下文,避免发送整包敏感资料 |
| 飞书脱敏 | 通知仅包含生成状态、模板名称、冲突数量和系统内下载提示 |
| 命令调用安全 | LibreOffice/飞书 CLI 使用结构化参数,不拼接用户输入 |
---
## 十八、验收设计
| 序号 | 验收项 | 验收标准 |
| --- | --- | --- |
| 1 | 意图触发 | 用户说“帮我填注册证”等语句可触发 `application_form_fill` |
| 2 | 指定模板 | 用户指定模板时只生成指定模板 |
| 3 | 默认模板 | 未指定模板时按注册类型生成适用的全部模板 |
| 4 | 新附件串联 | 本次消息带附件时先自动汇总,再执行填表 |
| 5 | 最近批次复用 | 无附件时复用当前对话最近成功文件汇总批次 |
| 6 | 工作流卡片 | 前端展示准备资料、选择模板、复制模板、抽取字段、填写 Word 等节点 |
| 7 | 字段并行抽取 | 规则/正则和 LLM 抽取结果均进入过程产物 |
| 8 | 冲突归并 | 说明书优先,冲突字段在 Word 中黄底红字 |
| 9 | 缺失字段 | 未提取字段在 Word 中留空 |
| 10 | 基本原则清单 | 高置信度条目写入,低置信度候选留在追溯清单 |
| 11 | Word 下载 | 对话框提供填好后的 Word 下载链接 |
| 12 | PDF 待办 | Demo 阶段 PDF 可展示为待生成,不阻断 Word |
| 13 | 追溯清单 | 生成字段来源追溯清单包含规则、LLM、合并和冲突信息 |
| 14 | 飞书通知 | 填表完成后可通知上传人,失败不影响下载 |
| 15 | 权限隔离 | A 对话生成的 Word/追溯清单不能被 B 对话访问 |
---
## 十九、实施建议
1. 新增 `ApplicationFormFillBatch``ApplicationFormFillArtifact` 数据模型,扩展 `ExportedSummaryFile.ExportType` 支持 `word``pdf`
2. 新增模板配置 `application_form_templates_v1.yaml`,先录入注册证格式 `.docx` 的已识别字段。
3. 将两个 `.doc` 模板转换为 `.docx` 工作模板,或在配置中标记为待转换模板。
4. 实现 `TemplateSelectionService`,支持用户指定模板、注册类型识别和默认模板选择。
5. 实现规则/正则与 LLM 并行字段抽取,并保存 `field_extract_result.json`
6. 实现 `FieldMergeService`,按说明书优先规则处理冲突。
7. 实现 `WordTemplateFillService`,优先支持表格行填充和冲突高亮。
8. 实现追溯清单 Excel 导出和 Word 下载记录。
9. 改造前端工作流卡片,新增 `application_form_fill` 类型。
10. 接入飞书通知摘要。
11. 将 PDF 转换、逐页版式 QA 和更完整的 `.doc` 模板转换纳入后续待办。
---
## 二十、待办与待确认事项
| 序号 | 项目 | 当前建议 |
| --- | --- | --- |
| 1 | PDF 转换 | 放入待办Demo 优先 Word 下载 |
| 2 | `.doc` 模板转换 | 优先 LibreOffice/soffice不可用时预置 `.docx` 工作模板 |
| 3 | 安全和性能基本原则清单条目拆解 | 需转换模板后补齐 YAML 条目配置 |
| 4 | LLM 结构化抽取提示词 | 需约束输出 JSON schema 和置信度 |
| 5 | 飞书通知渠道 | Demo 可 mock 或 CLI正式版接 Webhook/API |
| 6 | 低置信度阈值 | 建议功能实现阶段先配置为 0.75 |
| 7 | 版式验证 | P1 增加 PDF 页数检查和逐页截图 QA |

View File

@@ -1,292 +0,0 @@
# 飞书通知与问答接入功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md |
| 依赖功能设计 | docs/2.功能设计/1.自动汇总.mddocs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.mddocs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md |
| 功能名称 | 飞书通知与问答接入 |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-07 |
| 设计版本 | V1.0 |
---
## 一、设计目标
本功能用于将系统内工作流结果通过飞书官方智能体/应用机器人同步到指定个人账号并为后续飞书内问答能力预留数据模型和服务边界。首期实现重点是自动汇总、NMPA 注册资料法规核查与整改闭环、产品关键信息提取与申报文件自动填表三个流程结束后,使用 App ID/App Secret 获取 `tenant_access_token`,调用飞书消息 API 向指定个人账号发送富文本私聊提醒。
首期不实现飞书事件订阅回调和私聊问答,但需要在设计上预留用户映射、查询服务、权限过滤和问答日志能力,保证后续可以平滑扩展到“用户在飞书私聊机器人中查询批次状态、风险摘要、缺失摘要和导出摘要”。
---
## 二、设计范围
### 2.1 本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 指定个人通知 | 通过飞书官方智能体/应用机器人消息 API 向一个指定个人账号发送通知 |
| 2 | 发起人展示 | 消息正文展示批次发起人或上传人,不做群内 @ |
| 3 | 三流程接入 | 自动汇总、法规核查、自动填表均发送完成通知 |
| 4 | 富文本消息 | 使用飞书富文本格式展示标题、批次、状态、摘要、链接和发起人 |
| 5 | token 管理 | 使用 App ID/App Secret 获取并缓存 tenant_access_token |
| 6 | 通知判重 | 同一批次、同一工作流、同一状态只发送一次 |
| 7 | 通知记录 | 保存摘要、通道、目标、状态、失败原因、发送时间等信息 |
| 8 | 批次详情展示 | 在对应批次详情页展示通知状态和失败原因 |
| 9 | 用户映射管理 | 通过 Django Admin 手工维护系统用户与飞书用户标识,服务后续按发起人私聊和问答身份识别 |
| 10 | 问答预留 | 预留飞书用户映射、查询服务、权限规则和问答日志模型 |
### 2.2 非本期范围
| 序号 | 范围项 | 说明 |
| --- | --- | --- |
| 1 | 飞书私聊问答回调 | 不实现事件订阅接口和问答回复处理 |
| 2 | 群聊 @ 机器人问答 | 不接收群消息,不处理群内权限问题 |
| 3 | 飞书事件订阅回调 | 首期不接收私聊或群聊消息事件 |
| 4 | 复杂消息卡片 | 不做交互式卡片按钮和回调 |
| 5 | 自动后台重试 | 飞书发送失败只记录,不自动重试 |
| 6 | 飞书通讯录同步 | 不自动拉取用户,首期手工维护映射 |
---
## 三、与既有功能的关系
| 既有能力 | 处理方式 | 说明 |
| --- | --- | --- |
| 自动汇总工作流 | 接入通知 | 文件汇总完成后生成摘要通知 |
| 法规核查工作流 | 替换/扩展 mock 通知 | 风险分级和报告生成后发送摘要通知 |
| 自动填表工作流 | 扩展现有 notifier | Word/追溯清单生成后发送摘要通知 |
| 通知记录模型 | 统一扩展 | 现有法规和填表通知记录已存在,本设计建议抽象统一通知服务 |
| 工作流事件 | 复用 | 通知发送结果可作为节点事件或批次附属信息展示 |
| Django Admin | 扩展 | 新增飞书用户映射管理入口 |
---
## 四、总体架构
### 4.1 逻辑架构
```mermaid
flowchart TD
A["业务工作流完成"] --> B["NotificationDispatcher"]
B --> C["WorkflowNotificationBuilder"]
C --> D["ConfiguredPersonalRecipientResolver"]
D --> E["RichTextMessageBuilder"]
E --> F{"通知是否已发送"}
F -->|"已发送"| G["写入/返回重复跳过结果"]
F -->|"未发送"| H{"飞书通知是否启用"}
H -->|"否"| I["写入 mock/未启用记录"]
H -->|"是"| J["FeishuTokenProvider"]
J --> K["获取/复用 tenant_access_token"]
K --> L["FeishuMessageApiClient"]
L --> X["POST /im/v1/messages"]
X --> M["保存通知记录"]
M --> N["批次详情页展示"]
O["后续飞书私聊消息"] -.预留.-> P["FeishuQuestionService"]
P -.预留.-> Q["BatchSummaryQueryService"]
Q -.预留.-> R["权限过滤"]
P -.预留.-> S["FeishuQuestionLog"]
```
### 4.2 模块划分
| 模块 | 责任 |
| --- | --- |
| `notification_dispatcher` | 工作流完成后统一调度通知发送 |
| `workflow_notification_builder` | 将不同工作流批次转换为统一通知上下文 |
| `feishu_recipient_resolver` | 首期读取配置中的个人 open_id/user_id后续支持按系统用户映射解析 |
| `feishu_message_builder` | 构造飞书富文本消息体 |
| `feishu_token_provider` | 使用 App ID/App Secret 获取并缓存 tenant_access_token |
| `feishu_message_api_client` | 调用飞书发送消息 API、处理超时和响应解析 |
| `notification_record_service` | 判重、保存成功/失败/未启用记录 |
| `batch_notification_presenter` | 为批次详情页输出通知状态 |
| `feishu_question_service` | 后续问答预留,解析问题并查询摘要 |
| `batch_summary_query_service` | 后续问答预留,按权限查询批次摘要 |
---
## 五、通知业务流程
### 5.1 主流程
```text
业务工作流进入 success、partial_success 或 failed
-> 工作流调用统一通知服务
-> 通知服务生成 workflow_type、batch_id、status 组成的判重键
-> 检查是否已有同一判重键的成功通知
-> 若已有成功通知,跳过发送并返回 skipped
-> 读取批次、用户、摘要、结果链接
-> 读取配置中的个人 open_id/user_id 作为接收人
-> 构造富文本消息,正文展示批次发起人或上传人
-> 判断 FEISHU_NOTIFY_ENABLED
-> 未启用时写入 mock/disabled 记录
-> 已启用时获取或复用 tenant_access_token
-> 调用飞书消息 API 向指定个人 open_id/user_id 发送消息
-> 发送成功写入 sent/success 记录
-> 发送失败写入 failed 记录,记录错误信息
-> 业务工作流不因通知失败而失败
```
### 5.2 三类工作流通知摘要
| 工作流 | workflow_type | 摘要字段 | 下一步 |
| --- | --- | --- | --- |
| 自动汇总 | `file_summary` | 文件总数、成功解析数、失败/跳过数、导出文件数 | 查看汇总结果或下载 Excel |
| 法规核查 | `regulatory_review` | 风险总数、阻断项数、高风险数、中风险数、报告导出状态 | 查看风险报告和整改建议 |
| 自动填表 | `application_form_fill` | 选中模板数、导出文件数、冲突字段数、失败原因概述 | 下载 Word/追溯清单并人工确认 |
### 5.3 通知状态
| 状态 | 含义 | 是否阻断主流程 |
| --- | --- | --- |
| pending | 已创建记录但未发送 | 否 |
| sent/success | 已成功发送到飞书 | 否 |
| failed | 发送失败或配置异常 | 否 |
| skipped_duplicate | 已存在同一批次、同一流程、同一状态通知 | 否 |
| disabled/mock | 真实通知未启用,记录为模拟或未启用 | 否 |
---
## 六、飞书富文本设计
### 6.1 消息结构
飞书富文本消息建议使用 `post` 类型。首期内容只放摘要,不展开完整风险项和缺失项。
```json
{
"msg_type": "post",
"content": {
"post": {
"zh_cn": {
"title": "自动填表流程已完成",
"content": [
[
{"tag": "text", "text": "状态:成功\n"},
{"tag": "text", "text": "批次AFF-20260607-001\n"},
{"tag": "text", "text": "发起人owner\n"}
],
[
{"tag": "text", "text": "摘要:生成 2 个文件,冲突字段 1 个。\n"},
{"tag": "a", "text": "查看系统结果", "href": "http://127.0.0.1:8000/..."}
]
]
}
}
}
}
```
### 6.2 接收人标识优先级
首期接收人来自环境变量配置。若同时配置多个飞书标识,按以下优先级选取:
```text
FEISHU_DEFAULT_USER_OPEN_ID -> FEISHU_DEFAULT_USER_ID
```
若无可用接收人标识,系统不发送真实飞书消息,并记录配置缺失失败。
用户映射表仍保留,用于后续从“固定个人账号”升级为“按发起人私聊”。
### 6.3 系统链接
首期使用本地地址,例如:
```text
http://127.0.0.1:8000/
```
批次详情链接由各工作流已有页面路由或详情接口拼接。部署环境后续再升级为可信域名配置。
---
## 七、配置设计
| 配置项 | 来源 | 是否敏感 | 说明 |
| --- | --- | --- | --- |
| 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 |
| FEISHU_DEFAULT_TARGET_NAME | 环境变量 | 否 | 固定群展示名称 |
| FEISHU_TENANT_TOKEN_CACHE_SECONDS | 环境变量 | 否 | tenant_access_token 本地缓存秒数 |
| FEISHU_REQUEST_TIMEOUT_SECONDS | 环境变量 | 否 | 默认 5 秒 |
| 系统用户与飞书用户映射 | Django Admin | 部分敏感 | open_id、user_id、mobile |
---
## 八、页面设计
### 8.1 Django Admin
新增飞书用户映射管理:
| 字段 | 列表展示 | 可搜索 | 可过滤 |
| --- | --- | --- | --- |
| system_user | 是 | username | 是 |
| feishu_display_name | 是 | 是 | 否 |
| feishu_open_id | 否 | 是 | 否 |
| feishu_user_id | 否 | 是 | 否 |
| feishu_mobile | 否 | 是 | 否 |
| is_active | 是 | 否 | 是 |
### 8.2 批次详情页
三个流程对应的批次详情或结果区域展示通知状态:
| 展示项 | 说明 |
| --- | --- |
| 通知通道 | mock、feishu_api |
| 通知目标 | 指定个人账号名称或配置名称 |
| 接收人 | 指定个人账号;后续可展示发起人/上传人的飞书展示名 |
| 发送状态 | 成功、失败、未启用、重复跳过 |
| 发送时间 | 成功发送时间 |
| 失败原因 | 配置错误、超时、飞书返回错误等摘要 |
---
## 九、飞书问答预留设计
### 9.1 首期预留能力
| 能力 | 设计说明 |
| --- | --- |
| 用户映射复用 | 后续私聊事件中的飞书用户 ID 可通过映射表关联系统用户 |
| 批次查询服务 | 预留按批次号、工作流类型、最近批次查询摘要的服务 |
| 权限过滤 | 普通用户只查自己发起或上传的批次;管理员可查全部 |
| 问答日志 | 预留日志表或服务接口,记录问题、意图、查询对象和回答摘要 |
### 9.2 后续问答能力边界
| 问题类型 | 首期问答 MVP 是否支持 |
| --- | --- |
| 查最近批次状态 | 是 |
| 查指定批次状态 | 是 |
| 查风险摘要 | 是 |
| 查缺失摘要 | 是 |
| 查导出摘要 | 是 |
| 解释具体整改建议 | 否 |
| 重新发起工作流 | 否 |
---
## 十、验收标准
| 序号 | 验收项 | 标准 |
| --- | --- | --- |
| 1 | 三流程通知 | 自动汇总、法规核查、自动填表完成后均调用统一通知服务 |
| 2 | 个人账号发送 | 配置 App ID、App Secret 和指定个人 open_id/user_id 后,个人飞书账号能收到富文本通知 |
| 3 | 发起人展示 | 消息正文能展示流程发起人或上传人 |
| 4 | 接收人缺失 | 指定接收人缺失时不发送真实消息,并记录配置错误 |
| 5 | token 管理 | 系统能获取并缓存 tenant_access_tokentoken 失效后可重新获取 |
| 6 | 判重 | 同一批次、同一流程、同一状态不会重复发送成功通知 |
| 7 | 失败不阻断 | 飞书接口失败时主工作流仍完成 |
| 8 | 记录落库 | 成功、失败、未启用、重复跳过均可追溯 |
| 9 | 页面展示 | 批次详情页展示通知状态和失败原因 |
| 10 | 问答预留 | 用户映射、查询服务边界和日志设计可支持后续私聊问答 |

View File

@@ -1,873 +0,0 @@
# 第1章监管信息材料包生成功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md |
| 参考功能设计 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md |
| 功能名称 | 第1章监管信息材料包生成 |
| 工作流编码 | regulatory_info_package |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-10 |
| 设计版本 | V1.0 |
---
## 一、设计目标
新增独立工作流 `regulatory_info_package`用于根据产品说明书生成第1章监管信息材料包。用户在对话中上传或选择一个产品说明书发送“根据说明书生成第1章监管信息”等指令后系统复制 `docs/0.原始材料/第1章 监管信息` 下的 7 个样例模板抽取说明书中的产品关键信息生成一套新的第1章监管信息文件并打包为 `第1章 监管信息(预生成版).zip` 作为主下载入口。
本功能与 `application_form_fill` 平级,不复用其 workflow_type 和批次表但复用其已形成的服务思想和部分可拆能力包括字段抽取、LLM 调用、Word 写入、追溯清单、导出下载、通知、工作流事件和前端卡片。
本期重点实现:
| 目标 | 说明 |
| --- | --- |
| 独立工作流 | 新增 `regulatory_info_package` 批次、节点和卡片 |
| 单说明书输入 | 直接从当前对话 active 附件中选择唯一说明书;兼容最近成功文件汇总批次 |
| 模板驱动 | 通过 YAML 配置维护 7 个模板、字段映射和生成策略 |
| 模板字段化 | 优先使用 Word 内容控件 Tag 或稳定占位符,让代码只写字段值,最大限度保留原格式 |
| 规则 + LLM 并行抽取 | 代码抽取与 LLM 抽取并行,合并后写入模板 |
| 待确认高亮 | 系统新填入的 `/`、LLM-only 字段、冲突字段均高亮 |
| `.doc` 等价处理 | 设计 `LegacyWordDocumentService`,按能力驱动提供与 `.docx` 一致的文档操作接口;原生能力不可用时明确兜底 |
| zip 主输出 | 扩展 `ExportedSummaryFile.ExportType.ZIP`,统一下载权限 |
| LLM 意图路由 | 扩展路由 action支持固定话术和 LLM 语义判断 |
---
## 二、规范依据与裁决
| 规范来源 | 命中内容 | 设计处理 |
| --- | --- | --- |
| GYRX 后端开发规范 | 服务层职责清晰、接口响应统一、记录必要日志 | Django 项目沿用现有 JsonResponse/SSE 模式;服务拆入独立模块,记录批次与节点日志 |
| GYRX 前端开发规范 | 前端样式复用、交互一致、下载图标语义 | 当前项目为 Django 模板 + 原生 JS按现有工具 chip、工作流卡片和下载链接风格扩展 |
| 既有自动填表设计 | 独立工作流、YAML 配置、字段抽取、追溯清单、导出记录 | 复用模式,不复用批次表和 workflow_type |
| 需求分析确认 | `.doc` 不只依赖转换、zip 主入口、LLM-only 高亮 | 在服务抽象和验收标准中作为强约束 |
冲突裁决GYRX 规范中部分 Java/Spring 约束不适用于当前 Django 项目,按当前项目既有 Django 架构落地;通用原则如服务拆分、日志、权限和前端交互一致性继续采用。
---
## 三、与既有功能关系
### 3.1 复用边界
| 能力 | 处理方式 | 现有代码/模块 |
| --- | --- | --- |
| 对话与消息 | 复用 | `Conversation``Message``stream_message` |
| 附件上传 | 复用 | `FileAttachment``file_summary.storage` |
| 文件汇总结果 | 兼容复用 | `FileSummaryBatch``FileSummaryItem` |
| 文本抽取 | 复用并扩展 | `regulatory_review/services/text_extract.py``rag_index.py` |
| LLM 调用 | 复用 | `review_agent/llm.py` |
| 知识库搜索 | 复用系统现有能力 | `knowledge_base.py`、法规 RAG 相关服务 |
| 导出下载 | 扩展复用 | `ExportedSummaryFile``file_summary.views.export_download` |
| 工作流事件 | 复用 | `WorkflowNodeRun``WorkflowEvent` |
| 通知 | 复用统一通知链路 | `review_agent.notifications` |
| 前端卡片 | 扩展复用 | `templates/home.html``static/js/app.js` |
### 3.2 新增边界
| 能力 | 说明 |
| --- | --- |
| 独立批次 | 新增 `RegulatoryInfoPackageBatch`,批次号 `RIP-...` |
| 独立产物 | 新增 `RegulatoryInfoPackageArtifact` 记录模板副本、抽取结果、生成文件、zip 和追溯清单 |
| 独立通知记录 | 新增 `RegulatoryInfoPackageNotificationRecord`,结构与自动填表通知保持一致 |
| 模板配置 | 新增 `regulatory_info_package_templates_v1.yaml` |
| 说明书选择 | 新增输入选择服务,优先从 active 附件选择,兼容文件汇总批次 |
| 材料包生成 | 新增 7 个文件的生成策略和 zip 打包服务 |
| `.doc` 适配 | 新增旧版 Word 文档适配层 |
---
## 四、总体架构
### 4.1 目录结构
新增模块:
```text
review_agent/
regulatory_info_package/
__init__.py
constants.py
schemas.py
storage.py
events.py
workflow.py
views.py
services/
__init__.py
input_select.py
template_config.py
template_repository.py
instruction_extract.py
field_extract.py
field_merge.py
standard_candidates.py
document_writer.py
docx_document.py
legacy_doc_document.py
package_generate.py
traceability_export.py
zip_export.py
summary.py
notifier.py
templates/
regulatory_info_package_templates_v1.yaml
prompts/
field_extract.md
router_intent.md
standard_candidate.md
```
### 4.2 逻辑架构
```mermaid
flowchart TD
A["AI 对话页"] --> B["意图路由"]
B --> C{"action = regulatory_info_package"}
C --> D["RegulatoryInfoPackageBatch"]
D --> E["RegulatoryInfoPackageWorkflowExecutor"]
E --> F["输入说明书选择"]
E --> G["模板配置 YAML"]
F --> H["说明书文本与表格抽取"]
H --> I1["规则/代码抽取"]
H --> I2["LLM 结构化抽取"]
I1 --> J["字段合并与高亮决策"]
I2 --> J
J --> K["标准候选服务"]
J --> L["材料包生成服务"]
K --> L
L --> M1["DOCX 文档适配器"]
L --> M2["Legacy DOC 文档适配器"]
M1 --> N["7 个目标文件"]
M2 --> N
N --> O["追溯清单"]
N --> P["ZIP 打包"]
O --> Q["ExportedSummaryFile"]
P --> Q
E --> R["WorkflowEvent/SSE"]
E --> S["通知服务"]
```
### 4.3 技术选型
| 设计项 | 本期方案 | 说明 |
| --- | --- | --- |
| Web 框架 | Django | 沿用当前项目 |
| 工作流执行 | 轻量 Executor + 后台线程 | 与文件汇总、法规核查、自动填表一致 |
| 工作流状态 | `WorkflowNodeRun``WorkflowEvent` | 使用 `workflow_type=regulatory_info_package` |
| 模板配置 | YAML | 便于维护 7 个模板和字段映射 |
| `.docx` 操作 | `python-docx` | 表格、段落、run、底色和字体可控 |
| `.doc` 操作 | 适配器抽象 | Python 标准库不支持 `.doc` 二进制 Word 写入;设计为 COM/UNO/第三方库适配器,能力不可用时使用可追溯的 `.docx` 兜底 |
| zip 打包 | Python `zipfile` 标准库 | 标准库可满足打包需求 |
| Excel 追溯 | `openpyxl` | 复用现有依赖 |
| LLM | `review_agent.llm.generate_completion` | 统一模型调用 |
| 知识库 | 系统现有知识库/RAG | 不新增单独 RAG 模块 |
关于 `.doc`Python 自带库不能实现类似 Apache POI HWPF 的 Word 97-2003 二进制文档完整读写。项目依赖中有 `olefile`,可读取 OLE 复合文档结构,但不足以可靠修改 Word 文本、表格和样式。因此设计上必须使用文档适配器屏蔽实现差异,底层可选 Word COM、LibreOffice UNO、专用第三方库或受控转换兜底。
---
## 五、触发与路由设计
### 5.1 action 扩展
`skill_router.py` 扩展:
| 项 | 设计 |
| --- | --- |
| 新 action | `regulatory_info_package` |
| 新属性 | `starts_regulatory_info_package` |
| ROUTE_ACTIONS | 增加 `regulatory_info_package` |
| LLM prompt | 描述该 action 用于“根据说明书生成第1章监管信息、监管信息材料包、申请表/产品列表/声明材料包” |
### 5.2 固定规则
规则预判关键词:
```python
REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [
"根据说明书生成第1章监管信息",
"生成监管信息材料包",
"从说明书生成第1章材料",
"第1章监管信息",
"监管信息材料包",
]
```
规则命中时直接进入本工作流。规则未命中时,继续走 LLM 路由判断,避免自然表达漏触发。
### 5.3 对话启动
`review_agent/services.py::stream_message` 增加分支:
```text
if route.starts_regulatory_info_package:
-> 选择说明书输入
-> 创建 RegulatoryInfoPackageBatch
-> start_regulatory_info_package_workflow
-> SSE workflow_started
-> 回复“已启动第1章监管信息材料包生成工作流批次号RIP-...”
```
如果没有 active 附件,也没有可复用的最近文件汇总批次,则回复“请先上传产品说明书”。
如果存在多个候选说明书且用户消息无法唯一命中文件名,则不展示选择弹窗,由对话反问用户确认具体文件名后再启动工作流。
---
## 六、输入选择设计
### 6.1 选择优先级
| 优先级 | 来源 | 规则 |
| --- | --- | --- |
| 1 | 用户消息指定文件名 | 按 active 附件名或可复用文件名模糊匹配,唯一命中则使用 |
| 2 | 当前对话 active 附件 | 文件名包含“说明书”且扩展名为 `.docx` |
| 3 | 当前对话 active 附件 | 唯一 `.docx` 文件 |
| 4 | 最近成功 `FileSummaryBatch.items` | 文件名包含“说明书”且扩展名为 `.docx` |
| 5 | 无法唯一选择 | 对话反问用户确认使用哪个说明书;必要时批次进入 `waiting_user` |
本期直接输入只支持 `.docx` 产品说明书。`.doc`、PDF、扫描件说明书作为后续扩展但输出模板中的 `.doc` 必须支持。
### 6.2 输入绑定
批次记录:
| 字段 | 来源 |
| --- | --- |
| source_attachment | 直接选择的 FileAttachment |
| source_summary_batch | 可选,来自最近成功文件汇总 |
| source_summary_item | 可选,来自汇总条目 |
| source_file_name | 原始说明书文件名 |
| source_storage_path | 说明书存储路径 |
---
## 七、模板配置设计
配置路径:
```text
review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml
```
配置结构:
```yaml
version: regulatory_info_package_templates_v1
source_dir: docs/0.原始材料/第1章 监管信息
output_zip_name: 第1章 监管信息(预生成版).zip
templates:
- code: ch1_2_directory
output_name: CH1.2 监管信息目录.docx
source_file: CH1.2 监管信息目录.docx
file_format: docx
strategy: directory
include_in_zip: true
fields:
- key: product_name
targets:
- type: paragraph_contains_replace
match: 呼吸道合胞病毒、肺炎支原体核酸检测试剂盒荧光PCR法
- code: ch1_4_application_form
output_name: CH1.4 申请表.docx
source_file: CH1.4 申请表.docx
file_format: docx
strategy: application_form
include_in_zip: true
- code: ch1_9_pre_submission
output_name: CH1.9 产品申报前沟通的说明.doc
source_file: CH1.9 产品申报前沟通的说明.doc
file_format: doc
strategy: pre_submission
prefer_legacy_doc_native: true
allow_docx_fallback: true
include_in_zip: true
```
字段映射优先级:
| 目标类型 | 说明 |
| --- | --- |
| content_control_tag | 正式模板优先,代码按 Word 内容控件 Tag 写入 |
| placeholder | 过渡方案,替换稳定占位符并保留原 run/段落格式 |
| table_row_label | 未字段化模板的兜底方案,必须保留原单元格格式 |
### 7.1 配置项说明
| 配置项 | 说明 |
| --- | --- |
| version | 配置版本,写入批次 |
| source_dir | 样例模板目录 |
| output_zip_name | zip 主输出文件名 |
| templates | 7 个目标模板 |
| code | 模板编码 |
| output_name | 生成文件名 |
| source_file | 样例文件 |
| file_format | docx/doc |
| strategy | 生成策略 |
| include_in_zip | 是否进入 zip |
| fields | 字段映射与替换目标 |
| prefer_legacy_doc_native | `.doc` 是否优先尝试原生处理能力 |
| allow_docx_fallback | 原生 `.doc` 能力不可用或失败时是否允许 `.docx` 兜底 |
---
## 八、字段抽取设计
### 8.1 说明书解析
`instruction_extract.py` 输出:
| 数据 | 说明 |
| --- | --- |
| paragraphs | 按顺序提取段落 |
| sections | 按 `【章节名】` 切分 |
| tables | 提取表格二维数据 |
| component_tables | 识别主要组成成分表 |
| front_text | 前 4000 字,供 LLM 使用 |
### 8.2 规则抽取
规则抽取覆盖:
| 字段 | 规则 |
| --- | --- |
| product_name | `【产品名称】` 下一段 |
| package_specification | `【包装规格】` 到下一章节 |
| intended_use | `【预期用途】` 到下一章节 |
| detection_principle | `【检测原理】` 到下一章节 |
| main_components | `【主要组成成分】` 表格摘要 |
| storage_condition_and_validity | `【储存条件及有效期】` 到下一章节 |
| sample_type | `样本要求` 中“适用样本类型” |
| detection_targets | 从预期用途/检测原理中抽取基因、病原体、靶标 |
| applicable_instruments | `【适用仪器】` 到下一章节 |
| test_method | `【检验方法】` 摘要 |
| standards | 正则抽取 `GB/T``YY/T``YY``GB` 等标准号 |
### 8.3 LLM 抽取
LLM prompt 要求只输出 JSON
```json
{
"fields": [
{
"key": "product_name",
"label": "产品名称",
"value": "...",
"evidence": "...",
"confidence": 0.9
}
],
"product_list_rows": [
{
"package_specification": "...",
"composition": "...",
"component_name": "...",
"main_component": "...",
"quantity": "..."
}
],
"standards": []
}
```
LLM 不允许填企业信息、分类编码、管理类别、临床评价路径等说明书无法证明的内容。
### 8.4 字段合并
`field_merge.py` 输出 `MergedField`
| 字段 | 说明 |
| --- | --- |
| key | 字段编码 |
| label | 中文名 |
| value | 最终写入值 |
| source | rule、llm、missing、conflict |
| evidence | 来源片段 |
| confidence | 置信度 |
| highlight_reason | none、missing、llm_only、conflict、rag_candidate |
| needs_review | 是否需人工复核 |
合并规则:
| 场景 | 处理 |
| --- | --- |
| rule 与 LLM 一致 | 采用值,不高亮 |
| rule 与 LLM 不一致 | 采用规则优先或配置优先,标记 conflict |
| rule 缺失、LLM 命中 | 采用 LLM 值,标记 llm_only |
| 全部缺失 | 写 `/`,标记 missing |
---
## 九、文档生成设计
### 9.1 文档适配器接口
`document_writer.py` 定义统一接口:
```python
class DocumentAdapter:
def replace_text(self, old: str, new: str, *, highlight: bool = False) -> int: ...
def fill_table_cell(self, row_label: str, value: str, *, highlight: bool = False) -> bool: ...
def replace_table(self, marker: str, rows: list[dict], *, highlight_columns: list[str] = None) -> bool: ...
def highlight_value(self, value: str, reason: str) -> int: ...
def save(self, path: Path) -> Path: ...
```
`.docx` 使用 `DocxDocumentAdapter``.doc` 使用 `LegacyDocDocumentAdapter`
### 9.2 `.docx` 处理
能力:
| 能力 | 实现 |
| --- | --- |
| 段落替换 | 遍历 paragraph runs |
| 表格行填充 | 按首列 label 定位 |
| 单元格高亮 | `w:shd` 黄色底色 |
| 字体颜色 | 冲突项可红色字体 |
| 产品列表重建 | 清空目标表格数据行后追加 |
| 声明日期替换 | 按日期正则或段落末尾替换 |
### 9.3 `.doc` 处理
设计 `LegacyDocDocumentAdapter`,对外提供与 `.docx` 一致能力。底层按可用性选择适配器:
| 适配器 | 定位 |
| --- | --- |
| `WordComDocAdapter` | Windows + Microsoft Word 环境下优先,直接打开 `.doc`、查找替换、设置高亮并保存 `.doc` |
| `LibreOfficeUnoDocAdapter` | LibreOffice UNO/API 环境下使用,直接操作文档模型 |
| `OleDocReadOnlyAdapter` | 仅可读取时用于诊断,不满足写入验收 |
| `ConversionFallbackAdapter` | 兜底路径,可转换为 `.docx` 后处理,但不能作为唯一实现 |
功能设计约束:
| 约束 | 说明 |
| --- | --- |
| 不静默降级 | `.doc` 原生写入失败时必须记录适配器失败原因,随后尝试 `.docx` 兜底;兜底仍失败时该文件失败并触发 partial_success |
| 不只靠转换 | 转换可作为兜底,但设计主路径必须是文档适配器 |
| 能力探测 | 启动时或节点执行时检测适配器可用性 |
| 追溯记录 | 写入 `.doc` 的适配器类型和失败信息写入 artifact metadata |
### 9.4 7 个文件生成策略
| 模板 | 策略服务 | 关键动作 |
| --- | --- | --- |
| CH1.2 监管信息目录 | `generate_directory_doc` | 替换产品名称;页码沿用样例 |
| CH1.4 申请表 | `generate_application_form_doc` | 填表格行;缺失字段 `/` 黄底 |
| CH1.5 产品列表 | `generate_product_list_doc` | 使用样例表头重建产品列表;货号 `/` 黄底 |
| CH1.9 申报前沟通说明 | `generate_pre_submission_doc` | `.doc` 原生替换产品名和公司名;原生失败则输出 `.docx` 兜底文件;两者均失败才不进入 zip |
| CH1.11.1 符合标准清单 | `generate_standard_list_doc` | 说明书标准号直接写;候选/缺失高亮 |
| CH1.11.5 真实性声明 | `generate_authenticity_statement_doc` | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 |
| CH1.11.6 符合性声明 | `generate_compliance_statement_doc` | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 |
`generate_docs` 节点内部允许多线程并发处理 7 个目标文件。每个文档使用独立模板副本,子线程只返回生成结果,数据库 artifact/export 记录由主线程统一写入,避免并发写库和共享文件冲突。
---
## 十、标准清单设计
系统中已有知识库/RAG 能力,不新增单独 RAG 模块。本功能只新增 `standard_candidates.py` 作为业务服务,调用既有知识库搜索能力。
处理规则:
| 来源 | 处理 |
| --- | --- |
| 说明书明确标准号 | 写入标准清单,记录 `source=instruction` |
| 知识库候选标准 | 可写入候选区或追溯清单,标记 `rag_candidate` 并高亮 |
| 无命中 | 写 `/` 并黄底 |
| 样例标准 | 不无条件沿用 |
查询建议:
```text
体外诊断试剂 核酸扩增 检测试剂 标准 清单
新型冠状病毒 2019-nCoV 核酸检测试剂盒 荧光PCR 标准
```
---
## 十一、zip 与导出设计
### 11.1 ExportType 扩展
`ExportedSummaryFile.ExportType` 增加:
```python
ZIP = "zip", "ZIP"
```
下载 content type 增加:
```python
"zip": "application/zip"
```
### 11.2 导出记录
| 文件 | export_category | export_type |
| --- | --- | --- |
| 第1章 监管信息(预生成版).zip | regulatory_info_package | zip |
| 7 个生成文件 | generated_document | word 或 legacy_word |
| 追溯清单 Excel | traceability | excel |
追溯 JSON 和抽取过程 JSON 只保存到后台 `logs/` 目录和 artifact 记录,不作为用户下载入口。用户侧只提供追溯 Excel 下载。
如果不新增 `legacy_word` export_type`.doc` 也可暂用 `word`,通过文件扩展名和 content type 判断下载 MIME。功能设计建议新增 content type 映射时按扩展名兜底,避免 `.doc` 被当作 `.docx`
### 11.3 权限
`file_summary.views._export_for_user` 增加:
```text
if exported.workflow_type == "regulatory_info_package":
查询 RegulatoryInfoPackageBatch
校验 conversation__user == request.user 且 is_deleted=False
```
---
## 十二、数据模型设计
### 12.1 RegulatoryInfoPackageBatch
```python
class RegulatoryInfoPackageBatch(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "待执行"
RUNNING = "running", "执行中"
WAITING_USER = "waiting_user", "等待用户"
SUCCESS = "success", "成功"
PARTIAL_SUCCESS = "partial_success", "部分成功"
FAILED = "failed", "失败"
CANCELLED = "cancelled", "已取消"
```
字段建议:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| conversation | FK Conversation | 所属对话 |
| user | FK User | 发起用户 |
| trigger_message | FK Message | 触发消息 |
| source_attachment | FK FileAttachment | 直接选中的说明书附件 |
| source_summary_batch | FK FileSummaryBatch | 可选文件汇总批次 |
| source_summary_item_id | PositiveBigIntegerField | 可选汇总条目 ID |
| batch_no | CharField unique | RIP 批次号 |
| status | CharField | 状态 |
| source_file_name | CharField | 说明书原文件名 |
| source_storage_path | CharField | 说明书路径 |
| product_name | CharField | 抽取产品名 |
| output_zip_name | CharField | zip 文件名 |
| generated_files | JSONField | 7 个文件状态 |
| missing_fields | JSONField | 缺失项 |
| llm_only_fields | JSONField | LLM-only 项 |
| conflict_fields | JSONField | 冲突项 |
| risk_notes | JSONField | 风险提示 |
| template_config_version | CharField | 配置版本 |
| template_config_hash | CharField | 配置 hash |
| adapter_summary | JSONField | `.doc`/`.docx` 适配器信息 |
| work_dir | CharField | 工作目录 |
| error_message | TextField | 错误信息 |
| started_at/finished_at | DateTimeField | 执行时间 |
| is_deleted | BooleanField | 软删除 |
索引:
| 索引 | 字段 |
| --- | --- |
| idx_ra_rip_batch_conv_status | conversation, status |
| idx_ra_rip_batch_user_created | user, created_at |
| idx_ra_rip_batch_attachment | source_attachment |
| idx_ra_rip_batch_summary | source_summary_batch |
### 12.2 RegulatoryInfoPackageArtifact
产物类型:
| 类型 | 说明 |
| --- | --- |
| template_copy | 模板副本 |
| instruction_extract | 说明书抽取结果 |
| field_extract_result | 字段抽取结果 |
| merged_fields | 合并字段 |
| generated_document | 生成文件 |
| traceability | 追溯清单 |
| zip_package | zip 包 |
| notification_record | 通知记录 |
字段与 `ApplicationFormFillArtifact` 保持一致:`batch``artifact_type``file_format``name``file_name``storage_path``file_size``content_hash``metadata``created_by_node``is_deleted`
`file_format` 增加 `DOC``ZIP`
### 12.3 RegulatoryInfoPackageNotificationRecord
结构对齐 `ApplicationFormFillNotificationRecord`
| 字段 | 说明 |
| --- | --- |
| batch | 所属 RIP 批次 |
| recipient | 通知对象 |
| channel | feishu_cli、feishu_api、mock |
| export_ids | 导出 ID |
| message_summary | 通知摘要 |
| send_status | pending、success、failed |
| retry_count | 重试次数 |
| external_message_id | 外部消息 ID |
| error_message | 错误 |
| sent_at | 发送时间 |
---
## 十三、工作流设计
### 13.1 节点定义
| 节点编码 | 节点名称 | 触发服务 | 成功条件 | 失败处理 |
| --- | --- | --- | --- | --- |
| prepare | 准备资料 | `RegulatoryInfoPackageWorkflowExecutor` | 找到唯一说明书 | 缺失或多候选进入 waiting_user |
| template_copy | 复制模板 | `TemplateRepository` | 7 个模板进入批次目录 | 缺关键模板则 failed |
| text_extract | 抽取说明书 | `InstructionExtractService` | 提取文本、章节和表格 | 失败则 failed |
| field_extract | 抽取字段 | `FieldExtractionService` | 规则/LLM 结果留底 | LLM 失败可继续 |
| field_merge | 合并字段 | `FieldMergeService` | 输出 merged_fields | 无产品名仍继续,产品名 `/` |
| generate_docs | 生成材料 | `PackageGenerateService` | 生成 7 个文件 | 单文件失败可 partial_success |
| highlight_review_items | 标记待确认 | 文档适配器 | 缺失/LLM-only/冲突完成高亮 | 失败则对应文件失败 |
| trace_export | 追溯清单 | `TraceabilityExportService` | 生成 Excel/JSON | 不阻断 zip |
| zip_export | 打包下载 | `ZipExportService` | 生成 zip 并创建导出记录 | zip 失败则保留单文件 |
| notify | 通知 | `Notifier` | 写通知记录 | 不阻断下载 |
| completed | 完成 | Executor | 状态落定、摘要写入对话 | - |
### 13.2 状态落定
| 结果 | 批次状态 |
| --- | --- |
| 7 个文件、zip、追溯清单均成功 | success |
| zip 成功但部分单文件/追溯/通知失败 | partial_success |
| 单文件成功但 zip 失败 | partial_success |
| 关键输入或模板缺失 | failed 或 waiting_user |
| 所有目标文件生成失败 | failed |
---
## 十四、接口设计
### 14.1 URL
```text
GET /api/review-agent/regulatory-info-package/health/
POST /api/review-agent/regulatory-info-package/start/
GET /api/review-agent/regulatory-info-package/<batch_id>/status/
POST /api/review-agent/regulatory-info-package/<batch_id>/select-input/
```
### 14.2 start
请求:
```json
{
"conversation_id": 1,
"attachment_id": 10,
"file_summary_batch_id": 20,
"source_summary_item_id": 30
}
```
响应:
```json
{
"batch_id": 1,
"workflow_type": "regulatory_info_package",
"batch_no": "RIP-20260610153000-abcdef",
"status": "pending"
}
```
### 14.3 status
响应包含:
| 字段 | 说明 |
| --- | --- |
| batch | 批次基础信息、产品名、缺失数、LLM-only 数、冲突数 |
| nodes | 工作流节点 |
| generated_files | 7 个文件状态 |
| exports | zip、单文件、追溯清单下载 |
| missing_fields | 缺失项摘要 |
| llm_only_fields | LLM-only 摘要 |
| conflict_fields | 冲突摘要 |
| risk_notes | 风险提示 |
| notifications | 通知记录 |
---
## 十五、前端设计
### 15.1 对话框底部快捷提示
`templates/home.html` 增加 tool chip
```text
根据说明书生成第1章监管信息
```
点击后填入 prompt不自动发送保持现有交互一致。
### 15.2 工作流卡片
`build_workflow_cards()` 增加 RIP 批次,前端复用现有卡片样式,展示:
| 信息 | 说明 |
| --- | --- |
| 批次号 | RIP-... |
| 状态 | pending/running/success/partial_success/failed |
| 风险摘要 | 缺失字段 N、LLM复核 N、提示 N |
| 节点 | RIP 节点 |
### 15.3 状态轮询
`summaryPanel` 增加:
```html
data-regulatory-info-package-status-url-template="/api/review-agent/regulatory-info-package/__batch_id__/status/"
```
`static/js/app.js` 在工作流类型判断中增加 `regulatory_info_package`
### 15.4 结果展示
状态 payload 中 `exports` 按类别展示:
| 类别 | 展示 |
| --- | --- |
| zip | 主下载按钮 |
| generated_document | 单文件下载列表 |
| traceability | 追溯清单下载 |
---
## 十六、通知设计
复用统一通知服务,新增 `build_regulatory_info_package_context(batch)`
| 摘要项 | 说明 |
| --- | --- |
| 工作流 | 第1章监管信息材料包生成 |
| 批次号 | RIP-... |
| 产品名称 | 抽取产品名 |
| 导出文件 | zip + 单文件数量 |
| 待确认 | 缺失项、LLM-only、冲突项数量 |
| 下载提示 | 进入系统下载 zip |
通知失败不影响下载。
---
## 十七、异常与降级
| 异常 | 处理 |
| --- | --- |
| 未找到说明书 | 返回提示,不创建或创建 waiting_user 批次 |
| 多说明书候选 | waiting_user等待选择 |
| YAML 配置错误 | failed提示配置错误 |
| 样例模板缺失 | failed列出缺失模板 |
| LLM 失败 | 使用规则抽取继续,写 risk_notes |
| 规则抽取为空 | 使用 LLM-only 继续并高亮 |
| 知识库不可用 | 标准清单填 `/` 并高亮,写 risk_notes |
| `.doc` 适配器不可用 | CH1.9 失败,批次 partial_success 或 failed明确原因 |
| zip 打包失败 | 保留单文件下载,状态 partial_success |
| 下载文件不存在 | 返回 404记录日志 |
---
## 十八、安全与权限
| 控制点 | 设计 |
| --- | --- |
| 批次访问 | `conversation__user == request.user` |
| 附件访问 | 附件必须属于当前对话和当前用户 |
| 汇总批次访问 | 批次必须属于当前对话和当前用户 |
| 导出下载 | `workflow_type=regulatory_info_package` 时反查 RIP 批次 |
| 工作目录 | `media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}` |
| 路径安全 | 所有复制/输出路径必须校验位于批次工作目录内 |
| 原始模板保护 | 只读复制,不允许覆盖 `docs/0.原始材料` |
---
## 十九、测试设计
| 测试文件 | 覆盖 |
| --- | --- |
| `tests/test_regulatory_info_package_models.py` | 批次、产物、通知、zip 导出类型 |
| `tests/test_regulatory_info_package_trigger.py` | 固定规则与 LLM 路由 |
| `tests/test_regulatory_info_package_input_select.py` | 说明书选择、多候选 waiting_user |
| `tests/test_regulatory_info_package_template_config.py` | YAML 加载、模板存在性校验 |
| `tests/test_regulatory_info_package_field_extract.py` | 说明书字段、表格、标准号抽取 |
| `tests/test_regulatory_info_package_field_merge.py` | missing、llm_only、conflict 高亮决策 |
| `tests/test_regulatory_info_package_docx_writer.py` | docx 替换、表格填充、黄底 |
| `tests/test_regulatory_info_package_legacy_doc.py` | `.doc` 适配器能力探测和失败提示 |
| `tests/test_regulatory_info_package_zip.py` | zip 只包含 success/fallback_success 文件 |
| `tests/test_regulatory_info_package_workflow.py` | 工作流节点和状态落定 |
| `tests/test_regulatory_info_package_views.py` | start/status/权限 |
| `tests/test_regulatory_info_package_frontend.py` | 卡片、快捷提示、状态 URL |
回归测试:
```bash
python manage.py check
pytest tests/test_application_form_fill_*.py tests/test_file_summary_views.py tests/test_regulatory_*tests.py
```
实际执行时按项目现有测试命名拆分运行。
---
## 二十、实施顺序建议
| 阶段 | 内容 |
| --- | --- |
| RIP-1 | 模型、迁移、ExportType.ZIP、下载权限 |
| RIP-2 | 模块骨架、YAML 配置、输入说明书选择 |
| RIP-3 | 路由 action、对话启动、工作流节点 |
| RIP-4 | 说明书文本/表格抽取、规则 + LLM 字段抽取 |
| RIP-5 | docx 文档生成、黄底高亮、产品列表重建 |
| RIP-6 | `.doc` 适配器、CH1.9 处理能力 |
| RIP-7 | 追溯清单、zip 导出、助手摘要 |
| RIP-8 | 前端卡片、快捷提示、状态轮询 |
| RIP-9 | 通知、权限、全量回归 |
---
## 二十一、待确认与风险
| 风险 | 说明 | 建议 |
| --- | --- | --- |
| `.doc` 原生写入难度 | Python 标准库不支持 Word `.doc` 完整写入 | 优先调研 Word COM 或 LibreOffice UNO无原生能力时允许可追溯 `.docx` 兜底 |
| 模板字段化工作量 | 需要先把样例模板整理为代码可识别字段 | 优先覆盖 CH1.4、CH1.5 和声明类关键字段;缺少 Tag 时通过模板审计提前暴露 |
| 样例模板文本碎片 | Word run 拆分可能导致简单字符串替换失败 | 文档写入服务需支持跨 run 替换 |
| 产品列表结构复杂 | 说明书表格可能存在合并单元格和多规格 | 先覆盖目标说明书结构,再扩展通用表格归一化 |
| 标准清单准确性 | 说明书未必包含标准号,知识库候选不能直接作为结论 | 候选全部高亮并进入追溯清单 |
| LLM-only 风险 | LLM 推断可能过度补全 | 写入但高亮,追溯清单标记需复核 |
---
## 二十二、设计结论
| 编号 | 结论 |
| --- | --- |
| D1 | 功能设计文档新增为 `docs/2.功能设计/5.第1章监管信息材料包生成.md` |
| D2 | 新增独立模块 `review_agent/regulatory_info_package/` |
| D3 | 新建独立批次、产物、通知三张表 |
| D4 | 输入选择以 active 附件为主,兼容最近成功文件汇总批次 |
| D5 | `ExportedSummaryFile.ExportType` 扩展 `zip` |
| D6 | 采用 YAML 配置驱动 7 个模板 |
| D7 | 模板字段优先使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 |
| D8 | `.doc` 通过 `LegacyWordDocumentService` 适配器实现与 `.docx` 等价接口,原生能力不可用时允许可追溯兜底 |
| D9 | 标准候选复用系统已有知识库/RAG不新增独立 RAG |
| D10 | 前端只扩展现有对话页、工作流卡片、快捷提示和状态轮询 |
| D11 | 本轮先产出功能设计;数据库设计先在本文档中给出,后续可拆成正式数据库设计文档 |

View File

@@ -1,651 +0,0 @@
# 自动汇总文件夹文件目录与页数流程数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/1.自动汇总.md |
| 功能设计文档 | docs/2.功能设计/1.自动汇总.md |
| 详细设计文档 | docs/3.详细设计/1.自动汇总.md |
| 数据库类型 | SQLite / Django ORM |
| 表名前缀 | ra_ |
| 设计日期 | 2026-06-05 |
| 设计版本 | V1.0 |
---
## 一、设计原则
| 原则 | 说明 |
| --- | --- |
| ORM 优先 | 当前项目使用 Django实际落地以 Django Model 与 migration 为准 |
| SQLite 兼容 | 字段类型、索引和约束优先保证 SQLite 可运行 |
| 短表名前缀 | 使用 `ra_` 作为审核智能体文件汇总相关表前缀 |
| 不建枚举表 | 状态枚举使用 Django `TextChoices`,数据库存储字符串 |
| 对话隔离 | 所有附件、批次、导出文件均可追溯到 Conversation 和 User |
| 多版本附件 | 同一对话同名附件允许多次上传,以版本号区分 |
| 批次固化 | 每次汇总批次通过中间表绑定本次使用的附件版本,防止串文件 |
| 事件留痕 | 保留 WorkflowEvent用于 SSE 断线续传、页面刷新恢复和排查问题 |
---
## 二、ER 图
```mermaid
erDiagram
AUTH_USER ||--o{ CONVERSATION : owns
CONVERSATION ||--o{ MESSAGE : contains
CONVERSATION ||--o{ RA_FILE_ATTACHMENT : has
CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has
AUTH_USER ||--o{ RA_FILE_ATTACHMENT : uploads
AUTH_USER ||--o{ RA_FILE_SUMMARY_BATCH : runs
MESSAGE ||--o{ RA_FILE_SUMMARY_BATCH : triggers
RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_BATCH_ATTACHMENT : binds
RA_FILE_ATTACHMENT ||--o{ RA_FILE_SUMMARY_BATCH_ATTACHMENT : selected
RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces
RA_FILE_SUMMARY_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks
RA_FILE_SUMMARY_BATCH ||--o{ RA_WORKFLOW_EVENT : emits
RA_FILE_SUMMARY_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports
```
---
## 三、表结构设计
### 3.1 ra_file_attachment
用户在对话右侧上传区上传后的附件记录。上传即存储,不代表已启动工作流。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| conversation_id | ForeignKey | bigint | 是 | 绑定对话 |
| user_id | ForeignKey | bigint | 是 | 上传用户 |
| original_name | CharField(255) | varchar(255) | 是 | 原始文件名 |
| version_no | PositiveIntegerField | integer | 是 | 同一对话同名文件版本号,从 1 递增 |
| is_active | BooleanField | bool | 是 | 是否当前默认版本 |
| storage_path | CharField(500) | varchar(500) | 是 | 文件存储路径 |
| file_size | BigIntegerField | bigint | 是 | 文件大小 |
| content_type | CharField(120) | varchar(120) | 否 | MIME 类型 |
| upload_status | CharField(20) | varchar(20) | 是 | uploaded、bound、deleted |
| created_at | DateTimeField | datetime | 是 | 上传时间 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_attachment_conv_name_version | conversation_id, original_name, version_no |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_attachment_conv_created | conversation_id, created_at | 查询对话附件列表 |
| idx_ra_attachment_user_created | user_id, created_at | 查询用户上传记录 |
| idx_ra_attachment_active | conversation_id, original_name, is_active | 查询当前默认版本 |
---
### 3.2 ra_file_summary_batch
一次文件目录与页数汇总工作流批次。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| conversation_id | ForeignKey | bigint | 是 | 绑定对话 |
| user_id | ForeignKey | bigint | 是 | 执行用户 |
| trigger_message_id | ForeignKey | bigint | 否 | 触发工作流的用户消息 |
| batch_no | CharField(64) | varchar(64) | 是 | 批次编号,唯一 |
| product_name | CharField(200) | varchar(200) | 否 | 识别出的产品名称 |
| status | CharField(20) | varchar(20) | 是 | pending、running、success、failed |
| total_files | IntegerField | integer | 是 | 文件总数 |
| supported_files | IntegerField | integer | 是 | 支持统计文件数 |
| success_files | IntegerField | integer | 是 | 统计成功文件数 |
| failed_files | IntegerField | integer | 是 | 统计失败文件数 |
| unsupported_files | IntegerField | integer | 是 | 不支持文件数 |
| uncertain_files | IntegerField | integer | 是 | 页数不可确定文件数 |
| total_pages | IntegerField | integer | 是 | 总页数 |
| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 |
| error_message | TextField | text | 否 | 批次异常说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| started_at | DateTimeField | datetime | 否 | 开始时间 |
| finished_at | DateTimeField | datetime | 否 | 完成时间 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_batch_no | batch_no |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_batch_conv_created | conversation_id, created_at | 查询对话下批次 |
| idx_ra_batch_user_created | user_id, created_at | 查询用户批次 |
| idx_ra_batch_status | status, created_at | 查询执行中或失败批次 |
---
### 3.3 ra_file_summary_batch_attachment
批次与附件版本绑定表。一个对话可多次上传同名附件,批次必须固化本次使用的附件版本。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 汇总批次 |
| attachment_id | ForeignKey | bigint | 是 | 本次使用的附件版本 |
| source_role | CharField(20) | varchar(20) | 是 | archive、multi_file |
| created_at | DateTimeField | datetime | 是 | 绑定时间 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_batch_attachment | batch_id, attachment_id |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_batch_attachment_batch | batch_id, created_at | 查询批次附件 |
| idx_ra_batch_attachment_attachment | attachment_id | 查询附件被哪些批次使用 |
---
### 3.4 ra_file_summary_item
文件明细表,记录扫描到的每个文件及页数统计结果。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属批次 |
| file_index | PositiveIntegerField | integer | 是 | 文件序号 |
| directory_level | CharField(300) | varchar(300) | 否 | 目录层级 |
| file_name | CharField(255) | varchar(255) | 是 | 文件名 |
| file_type | CharField(20) | varchar(20) | 是 | 文件类型 |
| relative_path | CharField(500) | varchar(500) | 是 | 相对路径,用于展示和导出 |
| storage_path | CharField(500) | varchar(500) | 是 | 实际处理路径 |
| page_count | IntegerField | integer | 否 | 页数,失败或不可确定时为空 |
| statistics_status | CharField(20) | varchar(20) | 是 | success、failed、unsupported、uncertain、skipped |
| retry_count | PositiveIntegerField | integer | 是 | 页数统计重试次数 |
| error_message | TextField | text | 否 | 异常说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_item_batch_relative_path | batch_id, relative_path |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_item_batch_index | batch_id, file_index | 按序展示文件明细 |
| idx_ra_item_batch_status | batch_id, statistics_status | 查询失败/不可确定文件 |
| idx_ra_item_batch_type | batch_id, file_type | 按类型统计 |
---
### 3.5 ra_workflow_node_run
工作流节点运行状态表,用于右侧工作流卡片状态恢复。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属批次 |
| node_code | CharField(40) | varchar(40) | 是 | 节点编码 |
| node_name | CharField(80) | varchar(80) | 是 | 节点名称 |
| status | CharField(20) | varchar(20) | 是 | pending、running、retrying、success、failed、skipped |
| progress | PositiveIntegerField | integer | 是 | 进度百分比0-100 |
| message | TextField | text | 否 | 节点提示 |
| started_at | DateTimeField | datetime | 否 | 开始时间 |
| finished_at | DateTimeField | datetime | 否 | 完成时间 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_node_batch_code | batch_id, node_code |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_node_batch_status | batch_id, status | 查询批次节点状态 |
---
### 3.6 ra_workflow_event
工作流事件表,用于 SSE 事件持久化、断线续传和调试。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键,同时可作为 event_id |
| batch_id | ForeignKey | bigint | 是 | 所属批次 |
| event_type | CharField(40) | varchar(40) | 是 | workflow_started、node_progress 等 |
| payload | JSONField | text/json | 是 | 事件载荷 |
| created_at | DateTimeField | datetime | 是 | 事件时间 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_event_batch_id | batch_id, id | SSE after 续传 |
| idx_ra_event_batch_created | batch_id, created_at | 按时间查询事件 |
---
### 3.7 ra_exported_summary_file
导出文件记录表。下载链接运行时根据 export_id 生成。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属批次 |
| export_type | CharField(20) | varchar(20) | 是 | markdown、excel |
| file_name | CharField(255) | varchar(255) | 是 | 导出文件名 |
| storage_path | CharField(500) | varchar(500) | 是 | 保存路径 |
| status | CharField(20) | varchar(20) | 是 | success、failed |
| error_message | TextField | text | 否 | 导出异常说明 |
| created_at | DateTimeField | datetime | 是 | 生成时间 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_export_batch_type | batch_id, export_type | 查询批次导出文件 |
| idx_ra_export_batch_created | batch_id, created_at | 按生成时间查询 |
---
## 四、枚举设计
本功能不建立枚举表,枚举通过 Django `TextChoices` 定义,数据库存储字符串。
### 4.1 附件状态 upload_status
| 值 | 中文 | 说明 |
| --- | --- | --- |
| uploaded | 已上传 | 上传完成,尚未绑定批次 |
| bound | 已绑定 | 已被某个批次使用 |
| deleted | 已删除 | 用户逻辑删除,不再作为默认候选 |
### 4.2 批次状态 batch.status
| 值 | 中文 | 说明 |
| --- | --- | --- |
| pending | 待执行 | 批次已创建 |
| running | 执行中 | 后台工作流运行中 |
| success | 成功 | 工作流完成 |
| failed | 失败 | 批次级失败 |
### 4.3 节点状态 node.status
| 值 | 中文 | 说明 |
| --- | --- | --- |
| pending | 等待中 | 节点未开始 |
| running | 执行中 | 节点正在执行 |
| retrying | 重试中 | 单文件解析失败后重试 |
| success | 成功 | 节点执行成功 |
| failed | 失败 | 节点失败 |
| skipped | 跳过 | 当前批次不需要执行该节点 |
### 4.4 文件统计状态 statistics_status
| 值 | 中文 | 说明 |
| --- | --- | --- |
| success | 成功 | 页数统计成功 |
| failed | 失败 | 重试后仍失败 |
| unsupported | 不支持 | 文件类型不在支持范围 |
| uncertain | 不确定 | 文件可读,但无可靠页数元数据 |
| skipped | 跳过 | 空文件、隐藏文件或规则跳过 |
### 4.5 导出类型 export_type
| 值 | 中文 | 说明 |
| --- | --- | --- |
| markdown | Markdown | Markdown 汇总报告 |
| excel | Excel | Excel 明细文件 |
### 4.6 导出状态 export.status
| 值 | 中文 | 说明 |
| --- | --- | --- |
| success | 成功 | 导出文件生成成功 |
| failed | 失败 | 导出失败 |
---
## 五、关系与业务规则
### 5.1 对话与附件
```text
Conversation 1:N ra_file_attachment
```
规则:
| 规则 | 说明 |
| --- | --- |
| 上传即存储 | 用户上传后立即创建 FileAttachment |
| 对话隔离 | 附件只能被同一 Conversation 下的批次使用 |
| 多版本 | 同一 conversation + original_name 可存在多个 version_no |
| 默认版本 | is_active=true 的记录作为默认候选版本 |
| 逻辑删除 | 删除附件时设置 upload_status=deleted不立即物理删除 |
### 5.2 对话与批次
```text
Conversation 1:N ra_file_summary_batch
```
规则:
| 规则 | 说明 |
| --- | --- |
| 多次汇总 | 同一对话允许多次触发自动汇总 |
| 提示词触发 | 批次由用户消息触发,可关联 trigger_message_id |
| 批次固化 | 批次启动时固化本次使用的附件版本 |
### 5.3 批次与附件版本
```text
ra_file_summary_batch N:M ra_file_attachment
```
通过 `ra_file_summary_batch_attachment` 实现。
规则:
| 规则 | 说明 |
| --- | --- |
| 不串文件 | 工作流只能读取中间表绑定的附件 |
| 保留历史 | 即使附件后续上传新版本,历史批次仍指向旧版本 |
| 版本选择 | 用户未选择时默认使用同名文件的最新 active 版本 |
### 5.4 批次与文件明细
```text
ra_file_summary_batch 1:N ra_file_summary_item
```
规则:
| 规则 | 说明 |
| --- | --- |
| 相对路径唯一 | 同一批次下 relative_path 唯一 |
| 处理路径保留 | relative_path 用于展示storage_path 用于后台处理 |
| 单文件失败不阻断 | 文件解析失败记录 failed批次继续处理其他文件 |
---
## 六、索引设计汇总
| 表 | 索引/约束 | 字段 | 用途 |
| --- | --- | --- | --- |
| ra_file_attachment | uq_ra_attachment_conv_name_version | conversation_id, original_name, version_no | 同名附件版本唯一 |
| ra_file_attachment | idx_ra_attachment_conv_created | conversation_id, created_at | 对话附件列表 |
| ra_file_attachment | idx_ra_attachment_user_created | user_id, created_at | 用户上传记录 |
| ra_file_attachment | idx_ra_attachment_active | conversation_id, original_name, is_active | 默认版本查询 |
| ra_file_summary_batch | uq_ra_batch_no | batch_no | 批次编号唯一 |
| ra_file_summary_batch | idx_ra_batch_conv_created | conversation_id, created_at | 对话批次列表 |
| ra_file_summary_batch | idx_ra_batch_user_created | user_id, created_at | 用户批次列表 |
| ra_file_summary_batch | idx_ra_batch_status | status, created_at | 查询运行中/失败批次 |
| ra_file_summary_batch_attachment | uq_ra_batch_attachment | batch_id, attachment_id | 批次附件唯一 |
| ra_file_summary_item | uq_ra_item_batch_relative_path | batch_id, relative_path | 批次内文件唯一 |
| ra_file_summary_item | idx_ra_item_batch_index | batch_id, file_index | 文件明细排序 |
| ra_file_summary_item | idx_ra_item_batch_status | batch_id, statistics_status | 查询异常文件 |
| ra_workflow_node_run | uq_ra_node_batch_code | batch_id, node_code | 每批次每节点唯一 |
| ra_workflow_event | idx_ra_event_batch_id | batch_id, id | SSE 断点续传 |
| ra_exported_summary_file | idx_ra_export_batch_type | batch_id, export_type | 查询导出文件 |
---
## 七、SQLite 参考 DDL
> 说明:以下 DDL 为设计参考,实际落地以 Django migration 为准。
```sql
CREATE TABLE ra_file_attachment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
original_name VARCHAR(255) NOT NULL,
version_no INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
storage_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
content_type VARCHAR(120) NOT NULL DEFAULT '',
upload_status VARCHAR(20) NOT NULL DEFAULT 'uploaded',
created_at DATETIME NOT NULL,
UNIQUE (conversation_id, original_name, version_no)
);
CREATE INDEX idx_ra_attachment_conv_created
ON ra_file_attachment (conversation_id, created_at);
CREATE INDEX idx_ra_attachment_user_created
ON ra_file_attachment (user_id, created_at);
CREATE INDEX idx_ra_attachment_active
ON ra_file_attachment (conversation_id, original_name, is_active);
```
```sql
CREATE TABLE ra_file_summary_batch (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
trigger_message_id BIGINT NULL,
batch_no VARCHAR(64) NOT NULL UNIQUE,
product_name VARCHAR(200) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_files INTEGER NOT NULL DEFAULT 0,
supported_files INTEGER NOT NULL DEFAULT 0,
success_files INTEGER NOT NULL DEFAULT 0,
failed_files INTEGER NOT NULL DEFAULT 0,
unsupported_files INTEGER NOT NULL DEFAULT 0,
uncertain_files INTEGER NOT NULL DEFAULT 0,
total_pages INTEGER NOT NULL DEFAULT 0,
work_dir VARCHAR(500) NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL
);
CREATE INDEX idx_ra_batch_conv_created
ON ra_file_summary_batch (conversation_id, created_at);
CREATE INDEX idx_ra_batch_user_created
ON ra_file_summary_batch (user_id, created_at);
CREATE INDEX idx_ra_batch_status
ON ra_file_summary_batch (status, created_at);
```
```sql
CREATE TABLE ra_file_summary_batch_attachment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id BIGINT NOT NULL,
attachment_id BIGINT NOT NULL,
source_role VARCHAR(20) NOT NULL DEFAULT 'multi_file',
created_at DATETIME NOT NULL,
UNIQUE (batch_id, attachment_id)
);
CREATE INDEX idx_ra_batch_attachment_batch
ON ra_file_summary_batch_attachment (batch_id, created_at);
CREATE INDEX idx_ra_batch_attachment_attachment
ON ra_file_summary_batch_attachment (attachment_id);
```
```sql
CREATE TABLE ra_file_summary_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id BIGINT NOT NULL,
file_index INTEGER NOT NULL,
directory_level VARCHAR(300) NOT NULL DEFAULT '',
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(20) NOT NULL,
relative_path VARCHAR(500) NOT NULL,
storage_path VARCHAR(500) NOT NULL,
page_count INTEGER NULL,
statistics_status VARCHAR(20) NOT NULL DEFAULT 'skipped',
retry_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE (batch_id, relative_path)
);
CREATE INDEX idx_ra_item_batch_index
ON ra_file_summary_item (batch_id, file_index);
CREATE INDEX idx_ra_item_batch_status
ON ra_file_summary_item (batch_id, statistics_status);
CREATE INDEX idx_ra_item_batch_type
ON ra_file_summary_item (batch_id, file_type);
```
```sql
CREATE TABLE ra_workflow_node_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id BIGINT NOT NULL,
node_code VARCHAR(40) NOT NULL,
node_name VARCHAR(80) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
started_at DATETIME NULL,
finished_at DATETIME NULL,
UNIQUE (batch_id, node_code)
);
CREATE INDEX idx_ra_node_batch_status
ON ra_workflow_node_run (batch_id, status);
```
```sql
CREATE TABLE ra_workflow_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id BIGINT NOT NULL,
event_type VARCHAR(40) NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL
);
CREATE INDEX idx_ra_event_batch_id
ON ra_workflow_event (batch_id, id);
CREATE INDEX idx_ra_event_batch_created
ON ra_workflow_event (batch_id, created_at);
```
```sql
CREATE TABLE ra_exported_summary_file (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id BIGINT NOT NULL,
export_type VARCHAR(20) NOT NULL,
file_name VARCHAR(255) NOT NULL,
storage_path VARCHAR(500) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'success',
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);
CREATE INDEX idx_ra_export_batch_type
ON ra_exported_summary_file (batch_id, export_type);
CREATE INDEX idx_ra_export_batch_created
ON ra_exported_summary_file (batch_id, created_at);
```
---
## 八、Django ORM 落地注意事项
### 8.1 db_table
每个模型通过 `class Meta: db_table = "ra_xxx"` 固定表名,避免 Django 默认生成较长表名。
### 8.2 JSONField
`WorkflowEvent.payload` 使用 Django `models.JSONField(default=dict)`。SQLite 下实际以文本形式存储Django 负责序列化与反序列化。
### 8.3 版本号生成
同一对话同名文件上传时:
```text
version_no = max(existing version_no) + 1
```
若新版本设为默认版本,需要将旧版本 `is_active` 更新为 false。
### 8.4 逻辑删除
附件删除时:
```text
upload_status = deleted
is_active = false
```
历史批次仍可通过中间表追溯该附件。
### 8.5 批次选择附件
用户发送提示词触发工作流时:
| 场景 | 处理 |
| --- | --- |
| 用户显式选择附件版本 | 使用所选 attachment_id |
| 用户未选择版本 | 使用当前对话下 is_active=true 且未删除的附件 |
| 存在多个同名 active 异常 | 取 created_at 最新,并记录待修复数据异常 |
---
## 九、数据保留策略
| 数据 | Demo 策略 | 正式部署建议 |
| --- | --- | --- |
| 上传附件记录 | 永久保留 | 随会话归档周期清理 |
| 上传原始文件 | 永久保留 | 可按用户/项目配置保留期限 |
| 汇总批次 | 永久保留 | 保留用于审计追溯 |
| 文件明细 | 永久保留 | 保留用于历史报告复现 |
| 工作流事件 | 永久保留 | 可定期清理已完成批次的事件 |
| 导出文件 | 永久保留 | 可设置下载有效期或归档 |
---
## 十、待确认事项
| 序号 | 问题 | 当前设计 | 状态 |
| --- | --- | --- | --- |
| 1 | 正式部署是否从 SQLite 迁移到 PostgreSQL/MySQL | 当前按 SQLite/Django ORM 设计,保留 ORM 兼容性 | 待后续确认 |
| 2 | 同名附件 active 是否允许多个 | 设计上不允许,代码更新时应关闭旧 active | 待开发实现 |
| 3 | 文件物理删除时机 | Demo 不物理删除 | 待后续确认 |
---
## 十一、开发顺序建议
1.`review_agent/models.py` 中新增上述 7 个模型。
2. 为状态字段定义 Django `TextChoices`
3. 配置 `db_table``indexes``constraints`
4. 执行 `python manage.py makemigrations review_agent` 生成迁移。
5. 执行 `python manage.py migrate` 验证 SQLite 可落表。
6. 编写模型级测试,覆盖同名附件版本、批次附件绑定、唯一约束和权限查询。

View File

@@ -1,485 +0,0 @@
# NMPA 注册资料法规核查与整改闭环工作流数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/2.NMPA注册资料法规核查与整改闭环.md |
| 功能设计文档 | docs/2.功能设计/2.NMPA注册资料法规核查与整改闭环.md |
| 数据库类型 | SQLite / Django ORM |
| 表名前缀 | ra_ |
| 设计日期 | 2026-06-06 |
| 设计版本 | V1.0 |
---
## 一、设计原则
| 原则 | 说明 |
| --- | --- |
| 复用汇总批次 | 法规核查不重复保存文件清单,必须关联既有 `ra_file_summary_batch` |
| 独立核查批次 | 同一个文件汇总批次可以产生多次法规核查批次,适用条件变更时创建新批次 |
| 规则版本入库 | 结构化规则版本进入数据库便于追溯规则文件、RAG 索引和启用状态 |
| RAG 不单独建表 | RAG 索引信息挂在规则版本和核查批次字段中,不新增索引表 |
| 枚举存值 | 数据库存英文枚举 value前端或服务层映射为中文展示 |
| 关键字段独立 | 常用查询字段独立存储,其余过程上下文进入 JSON 或文件产物 |
| 大文本不入库 | 过程产物只在数据库保存路径、摘要和 hash大文本内容写入文件 |
| 软删除优先 | 法规核查相关数据采用软删除/归档策略,便于审计和恢复 |
| 过程产物留底 | 条件确认、核查矩阵、风险清单、RAG 结果、通知记录、复核记录均需留底 |
---
## 二、ER 图
```mermaid
erDiagram
AUTH_USER ||--o{ CONVERSATION : owns
CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has
RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces
RA_FILE_SUMMARY_BATCH ||--o{ RA_REGULATORY_REVIEW_BATCH : reviews
AUTH_USER ||--o{ RA_REGULATORY_REVIEW_BATCH : runs
AUTH_USER ||--o{ RA_REGULATORY_ISSUE : owns
RA_REGULATORY_RULE_VERSION ||--o{ RA_REGULATORY_REVIEW_BATCH : used_by
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_ISSUE : produces
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_ARTIFACT : keeps
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_REGULATORY_NOTIFICATION_RECORD : sends
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_WORKFLOW_EVENT : emits
```
说明:`ra_workflow_node_run``ra_workflow_event` 在第一阶段设计中属于文件汇总批次节点记录表。法规核查工作流复用同一套事件机制,采用 `workflow_type``workflow_batch_id` 兼容多工作流;原 `batch_id` 保留用于兼容文件汇总旧逻辑。
---
## 三、表结构设计
### 3.1 ra_regulatory_rule_version
法规结构化规则版本表。规则文件仍以 YAML/JSON 文件形式维护,数据库记录版本元数据、文件 hash、RAG 索引版本和启用状态。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| version | CharField(80) | varchar(80) | 是 | 规则版本,如 nmpa_ivd_2021_v1 |
| source_url | URLField(500) | varchar(500) | 是 | 法规来源 URL |
| source_path | CharField(500) | varchar(500) | 是 | 本地法规资料路径 |
| effective_date | DateField | date | 否 | 规则生效日期或公告日期 |
| rule_file_path | CharField(500) | varchar(500) | 是 | 结构化规则文件路径 |
| rule_file_hash | CharField(128) | varchar(128) | 是 | 规则文件 hash |
| rag_index_version | CharField(80) | varchar(80) | 否 | RAG 索引版本 |
| rag_index_path | CharField(500) | varchar(500) | 否 | RAG 索引存储路径 |
| is_active | BooleanField | bool | 是 | 是否当前启用版本 |
| created_by_id | ForeignKey(User) | bigint | 否 | 创建人 |
| activated_at | DateTimeField | datetime | 否 | 启用时间 |
| description | TextField | text | 否 | 版本说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_reg_rule_version | version |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_reg_rule_active | is_active, is_deleted | 查询当前启用规则 |
| idx_ra_reg_rule_effective | effective_date | 按生效日期追溯 |
| idx_ra_reg_rule_created | created_at | 查看规则版本历史 |
---
### 3.2 ra_regulatory_review_batch
法规核查批次表。一次法规核查工作流对应一条记录。同一个 `ra_file_summary_batch` 可关联多个法规核查批次,用于适用条件变更或重新核查。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| conversation_id | ForeignKey | bigint | 是 | 绑定对话 |
| user_id | ForeignKey | bigint | 是 | 发起用户 |
| file_summary_batch_id | ForeignKey | bigint | 是 | 关联文件汇总批次 |
| rule_version_id | ForeignKey | bigint | 否 | 使用的规则版本 |
| batch_no | CharField(64) | varchar(64) | 是 | 法规核查批次编号,唯一 |
| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、failed、reference_only、partial_success、cancelled |
| product_category | CharField(80) | varchar(80) | 否 | 产品类别 |
| registration_type | CharField(80) | varchar(80) | 否 | 注册类型 |
| clinical_evaluation_path | CharField(120) | varchar(120) | 否 | 临床评价路径 |
| product_name | CharField(200) | varchar(200) | 否 | 产品名称 |
| model_specification | CharField(200) | varchar(200) | 否 | 型号规格 |
| intended_use | TextField | text | 否 | 预期用途 |
| condition_json | JSONField | text/json | 否 | 其他适用条件、用户确认记录和抽取置信度 |
| rule_version_value | CharField(80) | varchar(80) | 否 | 冗余记录规则版本值,便于历史追溯 |
| rule_source_url | URLField(500) | varchar(500) | 否 | 冗余记录法规来源 URL |
| rule_source_path | CharField(500) | varchar(500) | 否 | 冗余记录本地法规资料路径 |
| rag_index_version | CharField(80) | varchar(80) | 否 | 本次使用的 RAG 索引版本 |
| risk_summary_json | JSONField | text/json | 否 | 风险数量摘要 |
| artifact_root | CharField(500) | varchar(500) | 否 | 本批次过程产物根目录 |
| error_message | TextField | text | 否 | 批次异常说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| started_at | DateTimeField | datetime | 否 | 开始时间 |
| finished_at | DateTimeField | datetime | 否 | 完成时间 |
| archived_at | DateTimeField | datetime | 否 | 归档时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_reg_batch_no | batch_no |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_reg_batch_conv_status | conversation_id, status | 查询对话下法规核查批次状态 |
| idx_ra_reg_batch_summary | file_summary_batch_id | 根据文件汇总批次查询法规核查历史 |
| idx_ra_reg_batch_created | created_at | 按创建时间查询 |
| idx_ra_reg_batch_rule | rule_version_value | 规则版本追溯 |
| idx_ra_reg_batch_user_created | user_id, created_at | 查询用户发起记录 |
---
### 3.3 ra_regulatory_issue
法规核查问题表,记录完整性、章节结构、一致性、通知、复核等业务问题及整改状态。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 |
| owner_id | ForeignKey(User) | bigint | 否 | 责任人,默认上传人 |
| issue_code | CharField(100) | varchar(100) | 是 | 问题编码 |
| issue_type | CharField(40) | varchar(40) | 是 | completeness、structure、consistency、notification、review |
| risk_level | CharField(20) | varchar(20) | 是 | blocking、high、medium、low、info |
| status | CharField(30) | varchar(30) | 是 | pending_confirm、pending_fix、fixed、review_passed、review_failed、closed |
| title | CharField(255) | varchar(255) | 是 | 问题标题 |
| description | TextField | text | 否 | 问题描述 |
| rule_id | CharField(120) | varchar(120) | 否 | 命中的规则 ID |
| regulation_basis | TextField | text | 否 | 法规依据或规则依据 |
| file_item_id | ForeignKey(FileSummaryItem) | bigint | 否 | 关联文件明细,可为空 |
| file_path | CharField(500) | varchar(500) | 否 | 常用证据文件路径 |
| page_no | PositiveIntegerField | integer | 否 | 常用证据页码 |
| field_name | CharField(120) | varchar(120) | 否 | 一致性或字段问题名称 |
| evidence_json | JSONField | text/json | 否 | 证据详情如文本片段、多个来源值、RAG 引用等 |
| suggestion | TextField | text | 否 | 整改建议 |
| source_node | CharField(60) | varchar(60) | 否 | 产生问题的工作流节点 |
| confirmed_by_id | ForeignKey(User) | bigint | 否 | 确认人 |
| confirmed_at | DateTimeField | datetime | 否 | 确认时间 |
| closed_by_id | ForeignKey(User) | bigint | 否 | 关闭人 |
| closed_at | DateTimeField | datetime | 否 | 关闭时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_reg_issue_batch_code | batch_id, issue_code |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_reg_issue_batch | batch_id, created_at | 查询批次问题 |
| idx_ra_reg_issue_risk_status | risk_level, status | 风险列表和整改状态筛选 |
| idx_ra_reg_issue_owner_status | owner_id, status | 责任人待办 |
| idx_ra_reg_issue_rule | rule_id | 规则问题追溯 |
| idx_ra_reg_issue_file | file_item_id | 关联文件问题 |
| idx_ra_reg_issue_field | field_name | 字段一致性问题查询 |
---
### 3.4 ra_regulatory_artifact
法规核查过程产物表。只保存文件元数据,不保存大文本全文。文件内容写入受控存储目录,`file_hash` 必填。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 |
| artifact_type | CharField(60) | varchar(60) | 是 | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record |
| file_format | CharField(20) | varchar(20) | 是 | markdown、excel、json |
| file_name | CharField(255) | varchar(255) | 是 | 文件名 |
| storage_path | CharField(500) | varchar(500) | 是 | 存储路径 |
| file_size | BigIntegerField | bigint | 是 | 文件大小 |
| file_hash | CharField(128) | varchar(128) | 是 | 文件 hash用于校验留底文件未被篡改 |
| summary | TextField | text | 否 | 产物摘要 |
| created_by_node | CharField(60) | varchar(60) | 否 | 产生该产物的工作流节点 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_reg_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 |
| idx_ra_reg_artifact_format | file_format | 按格式查询 |
| idx_ra_reg_artifact_created | created_at | 按时间追溯 |
---
### 3.5 ra_regulatory_notification_record
法规核查通知记录表,记录飞书 CLI 发送结果。飞书失败不阻断工作流,但需要留痕。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属法规核查批次 |
| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象 |
| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock |
| risk_levels | JSONField | text/json | 是 | 本次通知包含的风险等级 |
| issue_ids | JSONField | text/json | 是 | 本次通知关联的问题 ID 列表 |
| message_summary | TextField | text | 是 | 通知内容摘要 |
| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed |
| retry_count | PositiveIntegerField | integer | 是 | 已重试次数,最多 3 次 |
| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID |
| error_message | TextField | text | 否 | 失败原因 |
| sent_at | DateTimeField | datetime | 否 | 发送成功时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_reg_notify_batch | batch_id, created_at | 查询批次通知记录 |
| idx_ra_reg_notify_recipient | recipient_id, send_status | 查询用户通知状态 |
| idx_ra_reg_notify_status | send_status, retry_count | 查询待重试通知 |
---
## 四、枚举设计
### 4.1 RegulatoryReviewBatch.status
| value | 中文展示 | 说明 |
| --- | --- | --- |
| pending | 待执行 | 已创建,等待执行 |
| running | 执行中 | 工作流正在执行 |
| waiting_user | 等待用户 | 等待用户确认适用条件或关闭复核 |
| success | 已完成 | 核查完成且无关键失败 |
| failed | 失败 | 关键节点失败,无法输出有效结果 |
| reference_only | 仅供参考 | 规则文件加载失败,降级为 RAG 辅助核查 |
| partial_success | 部分完成 | 部分节点或通知失败,但已输出主要结果 |
| cancelled | 已取消 | 用户或系统取消执行 |
### 4.2 RegulatoryIssue.status
| value | 中文展示 | 说明 |
| --- | --- | --- |
| pending_confirm | 待确认 | 条件性问题或低置信度问题等待人工确认 |
| pending_fix | 待处理 | 已确认需要补充或整改 |
| fixed | 已补充 | 用户已上传补充资料或声明已处理 |
| review_passed | 复核通过 | 系统复核通过,关闭前仍需人工确认 |
| review_failed | 复核不通过 | 系统复核后问题仍存在 |
| closed | 已关闭 | 用户确认问题解决并关闭 |
### 4.3 RegulatoryIssue.risk_level
| value | 中文展示 | 说明 |
| --- | --- | --- |
| blocking | 阻断项 | 直接影响资料能否进入有效申报或审核 |
| high | 高风险 | 可能导致注册审评补正或重大整改 |
| medium | 中风险 | 需要补充说明或修改 |
| low | 低风险 | 建议修正但影响较小 |
| info | 提示项 | 系统无法充分判断或建议人工关注 |
### 4.4 其他枚举
| 字段 | value |
| --- | --- |
| issue_type | completeness、structure、consistency、notification、review |
| artifact_type | condition_record、rule_matrix、risk_list、text_extract_json、rag_result_json、notification_record、review_record |
| file_format | markdown、excel、json |
| send_status | pending、success、failed |
| channel | feishu_cli、feishu_api、mock |
---
## 五、软删除与归档策略
| 对象 | 策略 |
| --- | --- |
| RegulatoryRuleVersion | 使用 `is_deleted` 软删除;已被批次引用的版本不允许物理删除 |
| RegulatoryReviewBatch | 使用 `is_deleted``archived_at` 归档;归档后默认不在对话主列表展示 |
| RegulatoryIssue | 使用 `is_deleted` 软删除;删除时保留批次摘要和过程产物 |
| RegulatoryArtifact | 使用 `is_deleted` 软删除;正式环境可配合对象存储生命周期归档 |
| RegulatoryNotificationRecord | 使用 `is_deleted` 软删除;保留通知失败原因和重试次数 |
删除 Conversation 时,本期不建议物理级联法规核查数据。应先标记相关批次归档或删除,再由后台清理任务处理文件和产物。
---
## 六、过程产物存储设计
### 6.1 存储目录
法规核查过程产物使用独立目录,按用户、对话、法规核查批次隔离:
```text
media/regulatory_review/{user_id}/{conversation_id}/{batch_id}/
```
示例:
```text
media/regulatory_review/12/1001/2001/
condition_record.md
condition_record.json
rule_matrix.xlsx
risk_list.md
risk_list.json
text_extract.json
rag_result.json
notification_record.md
review_record.json
```
### 6.2 文件 hash
`ra_regulatory_artifact.file_hash` 必填。建议使用 SHA-256。
| 场景 | 处理 |
| --- | --- |
| 文件生成成功 | 计算 hash 后写入记录 |
| hash 计算失败 | 产物生成视为失败,节点进入 partial_success 或 failed |
| 下载文件 | 可选重新计算 hash 校验 |
---
## 七、JSON 字段结构建议
### 7.1 condition_json
```json
{
"extracted": {
"product_category": {"value": "in_vitro_diagnostic", "confidence": 0.92},
"registration_type": {"value": "initial_registration", "confidence": 0.76}
},
"confirmed": {
"confirmed_by": 1,
"confirmed_at": "2026-06-06T00:00:00+08:00",
"source": "dialog_choice"
},
"raw_user_input": "按体外诊断试剂首次注册处理"
}
```
### 7.2 risk_summary_json
```json
{
"blocking": 2,
"high": 1,
"medium": 3,
"low": 4,
"info": 2,
"notified": {
"feishu": 6
}
}
```
### 7.3 evidence_json
```json
{
"matched_rule": {
"rule_id": "ivd_registration_test_report",
"rule_title": "注册检验报告"
},
"matched_files": [
{
"file_item_id": 33,
"relative_path": "注册检验/检验报告.pdf",
"matched_by": "directory_keyword"
}
],
"rag_citations": [
{
"source_file": "体外诊断试剂注册申报资料要求及说明.doc",
"section_title": "注册申报资料要求",
"snippet": "..."
}
]
}
```
---
## 八、与现有表的改造建议
### 8.1 ra_workflow_node_run
第一阶段设计中该表通过 `batch_id` 直接关联文件汇总批次。法规核查复用同一套工作流状态机制,采用通用工作流引用:
| 字段 | 说明 |
| --- | --- |
| workflow_type | 新增,用于区分 file_summary 和 regulatory_review |
| workflow_batch_id | 新增,记录对应工作流批次 ID |
| batch_id | 保留,兼容文件汇总旧逻辑 |
### 8.2 ra_workflow_event
同样增加 `workflow_type``workflow_batch_id`,使 SSE 能同时服务文件汇总和法规核查卡片。
### 8.3 ra_exported_summary_file
最终法规核查报告复用导出文件表。现有 `batch_id` 关联文件汇总批次,需要通用化:
| 字段 | 说明 |
| --- | --- |
| workflow_type | 新增,用于区分 file_summary 和 regulatory_review |
| workflow_batch_id | 新增,记录对应工作流批次 ID |
| batch_id | 保留,兼容文件汇总旧逻辑 |
| export_category | 新增,用于区分 summary_report、risk_report、excel_list、json_package |
最终法规核查报告进入 `ExportedSummaryFile`,过程产物进入 `RegulatoryArtifact`
---
## 九、Django Model 命名建议
| 表名 | Model 名称 |
| --- | --- |
| ra_regulatory_rule_version | RegulatoryRuleVersion |
| ra_regulatory_review_batch | RegulatoryReviewBatch |
| ra_regulatory_issue | RegulatoryIssue |
| ra_regulatory_artifact | RegulatoryArtifact |
| ra_regulatory_notification_record | RegulatoryNotificationRecord |
---
## 十、验收检查点
| 序号 | 检查项 | 验收标准 |
| --- | --- | --- |
| 1 | 规则版本可追溯 | 每个法规核查批次能查到 rule_version、source_path、rule_file_hash 和 rag_index_version |
| 2 | 批次可多次核查 | 同一个 FileSummaryBatch 可创建多个 RegulatoryReviewBatch |
| 3 | 软删除可用 | 归档或删除法规核查批次后,默认列表不展示但历史可追溯 |
| 4 | 问题可筛选 | 可按 risk_level、status、owner 查询待处理问题 |
| 5 | 证据可追溯 | Issue 可查到 file_path、page_no、field_name 和 evidence_json |
| 6 | 产物可校验 | 每个 RegulatoryArtifact 都有 file_hash |
| 7 | 飞书可重试 | NotificationRecord 可记录 retry_count、send_status 和失败原因 |
| 8 | 权限可追溯 | 所有法规核查数据可通过 batch -> conversation -> user 校验访问权限 |
---
## 十一、后续实现注意事项
| 序号 | 问题 | 当前建议 |
| --- | --- | --- |
| 1 | WorkflowNodeRun/Event 通用化 | 已确定新增 workflow_type 和 workflow_batch_id保留 batch_id 兼容文件汇总 |
| 2 | ExportedSummaryFile 通用化 | 已确定新增 workflow_type、workflow_batch_id 和 export_category |
| 3 | RegulatoryArtifact 下载接口 | 按 batch -> conversation -> user 校验权限 |
| 4 | 飞书用户映射 | 暂通过 User 扩展字段或配置表映射飞书 CLI 可识别账号 |
| 5 | 规则文件 hash 计算时机 | 规则导入或激活时计算并写入 RegulatoryRuleVersion |

View File

@@ -1,433 +0,0 @@
# 产品关键信息提取与申报文件自动填表数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/3.产品关键信息提取与申报文件自动填表.md |
| 功能设计文档 | docs/2.功能设计/3.产品关键信息提取与申报文件自动填表.md |
| 数据库类型 | SQLite / Django ORM |
| 表名前缀 | ra_ |
| 设计日期 | 2026-06-07 |
| 设计版本 | V1.0 |
---
## 一、设计原则
| 原则 | 说明 |
| --- | --- |
| 独立填表批次 | 自动填表作为独立工作流,使用独立批次表,不强绑法规核查批次 |
| 复用文件来源 | 填表批次必须关联一个成功的 `FileSummaryBatch`,不重复保存文件清单 |
| 可选复用法规条件 | 如当前对话已有已确认法规核查批次,可通过可空外键复用注册类型等条件 |
| 导出记录复用 | Word、Excel、JSON、PDF 等下载文件继续进入 `ExportedSummaryFile` |
| 过程产物独立 | 自动填表过程产物单独建表,避免和法规核查 `RegulatoryArtifact` 混用 |
| 通知记录独立 | 自动填表飞书通知单独建表,字段风格与法规通知记录保持一致 |
| 大文本不入库 | 字段抽取 JSON、追溯清单和模板副本保存为文件数据库仅保存路径、hash 和摘要 |
| 字段明细暂不入库 | 本期不新增字段级明细表;字段结果保存在 JSON/Excel 产物与批次摘要中 |
| SQLite 兼容 | 字段类型、索引和约束优先保证当前 SQLite + Django ORM 可运行 |
---
## 二、ER 图
```mermaid
erDiagram
AUTH_USER ||--o{ CONVERSATION : owns
CONVERSATION ||--o{ RA_FILE_SUMMARY_BATCH : has
RA_FILE_SUMMARY_BATCH ||--o{ RA_FILE_SUMMARY_ITEM : produces
RA_FILE_SUMMARY_BATCH ||--o{ RA_APPLICATION_FORM_FILL_BATCH : feeds
RA_REGULATORY_REVIEW_BATCH ||--o{ RA_APPLICATION_FORM_FILL_BATCH : optionally_confirms
AUTH_USER ||--o{ RA_APPLICATION_FORM_FILL_BATCH : runs
CONVERSATION ||--o{ RA_APPLICATION_FORM_FILL_BATCH : has
MESSAGE ||--o{ RA_APPLICATION_FORM_FILL_BATCH : triggers
RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_APPLICATION_FORM_FILL_ARTIFACT : keeps
RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_APPLICATION_FORM_FILL_NOTIFICATION_RECORD : sends
RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports
RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks
RA_APPLICATION_FORM_FILL_BATCH ||--o{ RA_WORKFLOW_EVENT : emits
```
说明:`ra_workflow_node_run``ra_workflow_event``ra_exported_summary_file` 已在第二批中被通用化,通过 `workflow_type``workflow_batch_id` 支持多工作流。本功能使用 `workflow_type=application_form_fill`
---
## 三、表结构设计
### 3.1 ra_application_form_fill_batch
一次自动填表工作流批次。该表记录本次触发来源、选择模板、输出类型、注册类型、产品名称、冲突摘要、工作目录和状态。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| conversation_id | ForeignKey | bigint | 是 | 绑定对话 |
| user_id | ForeignKey | bigint | 是 | 发起用户 |
| trigger_message_id | ForeignKey | bigint | 否 | 触发填表工作流的用户消息 |
| source_summary_batch_id | ForeignKey | bigint | 是 | 文件来源汇总批次 |
| source_regulatory_batch_id | ForeignKey | bigint | 否 | 可选,复用已确认法规核查批次条件 |
| batch_no | CharField(64) | varchar(64) | 是 | 填表批次编号,唯一 |
| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、partial_success、failed、cancelled |
| requested_templates | JSONField | text/json | 是 | 用户指定模板编码列表;未指定为空数组 |
| selected_templates | JSONField | text/json | 是 | 系统实际选择模板编码列表 |
| output_types | JSONField | text/json | 是 | 请求输出类型,如 word、excel、json、pdf |
| registration_type | CharField(80) | varchar(80) | 否 | 识别出的注册类型 |
| registration_type_source | CharField(40) | varchar(40) | 否 | user_message、regulatory_batch、file_extract、unknown |
| product_name | CharField(200) | varchar(200) | 否 | 产品名称 |
| conflict_summary | JSONField | text/json | 是 | 冲突字段摘要 |
| risk_notes | JSONField | text/json | 是 | 不适用模板、低置信度、PDF 待生成等提示 |
| template_config_version | CharField(80) | varchar(80) | 否 | 模板配置版本 |
| template_config_hash | CharField(128) | varchar(128) | 否 | 模板配置文件 hash |
| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 |
| error_message | TextField | text | 否 | 批次异常说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| started_at | DateTimeField | datetime | 否 | 开始时间 |
| finished_at | DateTimeField | datetime | 否 | 完成时间 |
| archived_at | DateTimeField | datetime | 否 | 归档时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_aff_batch_no | batch_no |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_aff_batch_conv_status | conversation_id, status | 查询对话下填表批次状态 |
| idx_ra_aff_batch_summary | source_summary_batch_id | 根据文件汇总批次查询填表历史 |
| idx_ra_aff_batch_regulatory | source_regulatory_batch_id | 根据法规核查批次查询关联填表历史 |
| idx_ra_aff_batch_user_created | user_id, created_at | 查询用户发起记录 |
| idx_ra_aff_batch_created | created_at | 按创建时间查询 |
---
### 3.2 ra_application_form_fill_artifact
自动填表过程产物表。仅保存文件元数据,不保存字段抽取大 JSON 的全文。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属自动填表批次 |
| artifact_type | CharField(60) | varchar(60) | 是 | template_copy、field_extract_result、merged_fields、traceability、filled_template、notification_record |
| file_format | CharField(20) | varchar(20) | 是 | json、excel、docx、pdf、markdown |
| name | CharField(160) | varchar(160) | 是 | 产物名称 |
| file_name | CharField(255) | varchar(255) | 是 | 文件名 |
| storage_path | CharField(500) | varchar(500) | 是 | 存储路径 |
| file_size | BigIntegerField | bigint | 是 | 文件大小 |
| content_hash | CharField(128) | varchar(128) | 是 | 文件 SHA-256 hash |
| metadata | JSONField | text/json | 是 | 模板编码、输出类型、生成状态、错误摘要等 |
| created_by_node | CharField(60) | varchar(60) | 否 | 产生该产物的节点 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_aff_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 |
| idx_ra_aff_artifact_format | file_format | 按文件格式查询 |
| idx_ra_aff_artifact_created | created_at | 按时间追溯 |
---
### 3.3 ra_application_form_fill_notification_record
自动填表飞书通知记录表。通知失败不阻断文件下载,但需要留痕和支持后续重试。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属自动填表批次 |
| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象,默认上传人/发起人 |
| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock |
| template_codes | JSONField | text/json | 是 | 本次通知涉及模板 |
| export_ids | JSONField | text/json | 是 | 本次通知关联导出文件 ID |
| message_summary | TextField | text | 是 | 通知摘要 |
| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed |
| retry_count | PositiveIntegerField | integer | 是 | 已重试次数 |
| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID |
| error_message | TextField | text | 否 | 失败原因 |
| sent_at | DateTimeField | datetime | 否 | 发送成功时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_aff_notify_batch | batch_id, created_at | 查询批次通知记录 |
| idx_ra_aff_notify_recipient | recipient_id, send_status | 查询用户通知状态 |
| idx_ra_aff_notify_status | send_status, retry_count | 查询待重试通知 |
---
## 四、既有表扩展
### 4.1 ra_exported_summary_file
继续复用导出文件表,需扩展导出类型。
| 字段/枚举 | 处理 |
| --- | --- |
| export_type | 增加 `word``pdf` |
| workflow_type | 使用 `application_form_fill` |
| workflow_batch_id | 记录 `ApplicationFormFillBatch.id` |
| export_category | 使用 `filled_template``traceability``extract_result` |
导出类型枚举:
| value | 中文展示 | 说明 |
| --- | --- | --- |
| markdown | Markdown | 既有报告 |
| excel | Excel | 追溯清单 |
| json | JSON | 字段抽取结果包 |
| word | Word | 填好的 Word 模板 |
| pdf | PDF | Word 转换后的 PDFP1 预留 |
### 4.2 ra_workflow_node_run
本功能使用通用工作流字段:
| 字段 | 值 |
| --- | --- |
| workflow_type | application_form_fill |
| workflow_batch_id | ApplicationFormFillBatch.id |
| node_group | form_fill |
| batch_id | 可为空或兼容性填充 source_summary_batch_id |
### 4.3 ra_workflow_event
本功能事件写入:
| 字段 | 值 |
| --- | --- |
| workflow_type | application_form_fill |
| workflow_batch_id | ApplicationFormFillBatch.id |
| conversation_id | 当前对话 ID |
| payload | 节点状态、模板列表、冲突数量、导出文件等 |
---
## 五、枚举设计
### 5.1 ApplicationFormFillBatch.status
| value | 中文展示 | 说明 |
| --- | --- | --- |
| pending | 待执行 | 批次已创建,等待执行 |
| running | 执行中 | 工作流正在执行 |
| waiting_user | 等待用户 | 缺少文件汇总批次或关键条件 |
| success | 成功 | Word 和必要追溯产物生成成功 |
| partial_success | 部分成功 | 部分模板、PDF、追溯清单或通知失败 |
| failed | 失败 | 所有目标 Word 模板均生成失败 |
| cancelled | 已取消 | 用户或系统取消执行 |
### 5.2 artifact_type
| value | 说明 |
| --- | --- |
| template_copy | 模板副本 |
| field_extract_result | 规则/正则与 LLM 抽取原始结果 |
| merged_fields | 合并后的最终字段和冲突 |
| traceability | 字段来源追溯清单 |
| filled_template | 已填写模板 |
| notification_record | 通知记录产物 |
### 5.3 registration_type_source
| value | 说明 |
| --- | --- |
| user_message | 用户话语明确指定 |
| regulatory_batch | 复用已确认法规核查条件 |
| file_extract | 从文件内容抽取 |
| unknown | 未识别 |
### 5.4 通知枚举
| 字段 | value |
| --- | --- |
| channel | feishu_cli、feishu_api、mock |
| send_status | pending、success、failed |
---
## 六、JSON 字段结构建议
### 6.1 requested_templates / selected_templates
```json
["registration_certificate", "essential_principles"]
```
### 6.2 output_types
```json
["word", "excel", "json"]
```
PDF 作为 P1 预留,可在后续加入:
```json
["word", "pdf", "excel", "json"]
```
### 6.3 conflict_summary
```json
[
{
"field_key": "storage_condition",
"field_label": "产品储存条件及有效期",
"selected_value": "2-8℃保存有效期12个月",
"selected_source": "说明书.docx",
"conflict_values": [
{
"value": "-20℃保存",
"source_file": "产品技术要求.docx",
"evidence": "储存条件:-20℃保存"
}
],
"handling": "说明书优先,模板内黄底红字高亮"
}
]
```
### 6.4 risk_notes
```json
[
{
"type": "template_registration_mismatch",
"message": "用户指定变更注册(备案)文件,但系统识别注册类型为首次注册,需人工确认。"
},
{
"type": "pdf_pending",
"message": "PDF 转换为后续增强项,本次优先生成 Word。"
}
]
```
### 6.5 artifact.metadata
```json
{
"template_code": "registration_certificate",
"output_type": "word",
"node_code": "word_fill",
"status": "success",
"conflict_count": 2
}
```
---
## 七、存储路径设计
自动填表工作目录按用户、对话和批次隔离:
```text
media/application_form_fill/{user_id}/{conversation_id}/{batch_no}/
```
目录结构:
```text
media/application_form_fill/12/1001/AFF-20260607153000-a1b2c3/
templates/
registration_certificate.source.docx
essential_principles.source.docx
filled/
AFF-20260607153000-a1b2c3-甲胎蛋白检测试剂盒-注册证格式.docx
exports/
AFF-20260607153000-a1b2c3-甲胎蛋白检测试剂盒-字段来源追溯清单.xlsx
field_extract_result.json
merged_fields.json
notifications/
notification_record.json
```
所有产物写入 `ApplicationFormFillArtifact` 时必须记录 SHA-256 hash。
---
## 八、权限与查询规则
### 8.1 批次访问权限
```text
ApplicationFormFillBatch -> conversation -> user
必须等于当前 request.user
```
### 8.2 导出下载权限
```text
ExportedSummaryFile.workflow_type == application_form_fill
-> workflow_batch_id
-> ApplicationFormFillBatch.conversation.user
```
`workflow_type=file_summary``regulatory_review`,仍按既有逻辑校验。
### 8.3 文件读取权限
自动填表只能读取 `source_summary_batch.items` 对应的文件,不允许从其他对话或其他批次随意读取文件。
---
## 九、字段级数据库表暂缓说明
本期不新增 `ApplicationFormFillField` 字段级明细表。原因:
| 原因 | 说明 |
| --- | --- |
| Demo 主链路更轻 | 字段结果以 JSON 和 Excel 追溯清单即可满足下载复核 |
| 避免过早建模 | 字段结构依赖模板配置和后续人工修改交互,暂不固化表结构 |
| 查询需求有限 | 本期主要按批次下载文件,不做字段级统计和在线编辑 |
后续如需要在线确认、人工修改、字段级审计或批量统计,再新增字段级表。该事项写入 `docs/6.待办计划/第二阶段暂缓事项.md`
---
## 十、Django Model 命名建议
| 表名 | Model 名称 |
| --- | --- |
| ra_application_form_fill_batch | ApplicationFormFillBatch |
| ra_application_form_fill_artifact | ApplicationFormFillArtifact |
| ra_application_form_fill_notification_record | ApplicationFormFillNotificationRecord |
建议模型仍集中放在 `review_agent/models.py`,与前两批现有模型保持一致;业务逻辑放在 `review_agent/application_form_fill/`
---
## 十一、验收检查点
| 序号 | 检查项 | 验收标准 |
| --- | --- | --- |
| 1 | 独立批次 | 触发填表后生成 `ApplicationFormFillBatch` |
| 2 | 文件来源 | 每个填表批次都关联一个成功的 `FileSummaryBatch` |
| 3 | 可选法规条件 | 如有关联法规核查批次,可记录 `source_regulatory_batch` |
| 4 | 过程产物 | 字段抽取 JSON、合并结果、追溯清单、模板副本均可留底 |
| 5 | 导出复用 | 填好的 Word 和追溯清单进入 `ExportedSummaryFile` |
| 6 | 导出类型 | `ExportedSummaryFile.ExportType` 支持 `word``pdf` |
| 7 | 通知记录 | 飞书通知记录能保存状态、重试次数、失败原因 |
| 8 | 权限隔离 | A 对话的填表批次和导出文件不能被 B 对话访问 |
| 9 | 字段表暂缓 | 字段级结果不入库,但能从 JSON/Excel 追溯产物复核 |
---
## 十二、开发顺序建议
1. 扩展 `ExportedSummaryFile.ExportType`,增加 `word``pdf`
2. 新增 `ApplicationFormFillBatch``ApplicationFormFillArtifact``ApplicationFormFillNotificationRecord`
3. 为新增状态字段定义 Django `TextChoices`
4. 配置表名、索引和唯一约束。
5. 执行 `python manage.py makemigrations review_agent``python manage.py migrate`
6. 编写模型测试,覆盖批次创建、产物 hash、通知重试字段、导出权限查询。
7. 将字段级数据库表和 PDF 转换能力写入待办计划。

View File

@@ -1,302 +0,0 @@
# 飞书通知与问答接入数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md |
| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md |
| 数据库类型 | SQLite / Django ORM |
| 表名前缀 | ra_ |
| 设计日期 | 2026-06-07 |
| 设计版本 | V1.0 |
---
## 一、设计原则
| 原则 | 说明 |
| --- | --- |
| 统一通知抽象 | 三个工作流共用统一通知服务和通用通知记录,减少重复实现 |
| 兼容现有表 | 现有法规通知、填表通知可保留;新增通用表作为后续统一入口 |
| 可判重 | 通知记录必须支持同一批次、同一流程、同一状态只发送一次 |
| 摘要入库 | 只保存发送摘要、状态、错误,不保存完整富文本 payload |
| 映射可维护 | 系统用户与飞书用户映射独立建表,通过 Django Admin 维护 |
| 问答可扩展 | 预留问答日志表,首期可不接事件回调 |
| SQLite 兼容 | 使用 Django ORM 常规字段,避免数据库特有能力 |
---
## 二、ER 图
```mermaid
erDiagram
AUTH_USER ||--o{ RA_FEISHU_USER_MAPPING : maps
AUTH_USER ||--o{ RA_WORKFLOW_NOTIFICATION_RECORD : triggers
RA_FEISHU_USER_MAPPING ||--o{ RA_WORKFLOW_NOTIFICATION_RECORD : resolves
AUTH_USER ||--o{ RA_FEISHU_QUESTION_LOG : asks
RA_WORKFLOW_NOTIFICATION_RECORD {
bigint id
string workflow_type
bigint workflow_batch_id
string workflow_status
string dedupe_key
string channel
string target
string send_status
}
RA_FEISHU_USER_MAPPING {
bigint id
bigint system_user_id
string feishu_open_id
string feishu_user_id
string feishu_mobile
boolean is_active
}
RA_FEISHU_QUESTION_LOG {
bigint id
bigint system_user_id
string feishu_open_id
string intent
string query_object
string status
}
```
---
## 三、表结构设计
### 3.1 ra_feishu_user_mapping
系统用户与飞书用户标识映射表。首期通知发送给环境变量中配置的指定个人账号,本表通过 Django Admin 手工维护,用于后续按发起人私聊通知和飞书私聊问答身份识别。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| system_user_id | ForeignKey | bigint | 是 | 关联 Django 用户 |
| feishu_display_name | CharField(120) | varchar(120) | 否 | 飞书展示名,便于后台识别 |
| feishu_open_id | CharField(120) | varchar(120) | 否 | 飞书 open_id优先用于 @ |
| feishu_user_id | CharField(120) | varchar(120) | 否 | 飞书 user_id第二优先级 |
| feishu_mobile | CharField(40) | varchar(40) | 否 | 飞书手机号,兜底 |
| is_active | BooleanField | bool | 是 | 是否启用 |
| remark | CharField(255) | varchar(255) | 否 | 备注 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
约束:
| 约束名 | 字段 | 说明 |
| --- | --- | --- |
| uq_ra_feishu_mapping_user | system_user_id | 一个系统用户首期只维护一条启用映射 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_feishu_mapping_active | is_active | 后台筛选启用映射 |
| idx_ra_feishu_mapping_open | feishu_open_id | 后续私聊事件反查用户 |
| idx_ra_feishu_mapping_userid | feishu_user_id | 后续私聊事件反查用户 |
| idx_ra_feishu_mapping_mobile | feishu_mobile | 手机号兜底查询 |
校验规则:
| 规则 | 说明 |
| --- | --- |
| 至少一个飞书标识 | `feishu_open_id``feishu_user_id``feishu_mobile` 至少填写一个 |
| @ 优先级 | `feishu_open_id -> feishu_user_id -> feishu_mobile` |
---
### 3.2 ra_workflow_notification_record
通用工作流通知记录表。用于记录自动汇总、法规核查、自动填表的飞书通知发送结果。现有专项通知表可继续保留,后续逐步收敛到本表。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| workflow_type | CharField(40) | varchar(40) | 是 | file_summary、regulatory_review、application_form_fill |
| workflow_batch_id | PositiveBigIntegerField | bigint | 是 | 对应工作流批次 ID |
| workflow_batch_no | CharField(80) | varchar(80) | 是 | 批次编号冗余,便于展示 |
| workflow_status | CharField(40) | varchar(40) | 是 | success、partial_success、failed 等 |
| dedupe_key | CharField(160) | varchar(160) | 是 | 判重键 |
| trigger_user_id | ForeignKey | bigint | 是 | 发起人或上传人 |
| feishu_mapping_id | ForeignKey | bigint | 否 | 命中的飞书用户映射 |
| channel | CharField(40) | varchar(40) | 是 | mock、feishu_api、disabled |
| target | CharField(160) | varchar(160) | 否 | 指定个人账号名称、open_id、user_id 或目标标识 |
| at_display_name | CharField(120) | varchar(120) | 否 | 被 @ 人展示名 |
| at_identifier_type | CharField(30) | varchar(30) | 否 | open_id、user_id、mobile、missing |
| at_identifier_masked | CharField(120) | varchar(120) | 否 | 脱敏后的 @ 标识 |
| send_status | CharField(30) | varchar(30) | 是 | pending、success、failed、skipped_duplicate、disabled |
| message_title | CharField(200) | varchar(200) | 是 | 通知标题 |
| message_summary | TextField | text | 否 | 发送摘要,不保存完整 payload |
| result_url | CharField(500) | varchar(500) | 否 | 系统结果入口 |
| external_message_id | CharField(120) | varchar(120) | 否 | Webhook 一般为空API 发送时保存 |
| error_code | CharField(80) | varchar(80) | 否 | 飞书或客户端错误码 |
| error_message | TextField | text | 否 | 失败原因 |
| request_duration_ms | PositiveIntegerField | integer | 否 | HTTP 请求耗时 |
| sent_at | DateTimeField | datetime | 否 | 成功发送时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
唯一约束:
| 约束名 | 字段 | 说明 |
| --- | --- | --- |
| uq_ra_notify_dedupe_key | dedupe_key | 同一批次、流程、状态只保留一个成功发送意图 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_notify_workflow | workflow_type, workflow_batch_id | 批次详情页查询通知 |
| idx_ra_notify_user_created | trigger_user_id, created_at | 用户通知历史 |
| idx_ra_notify_status | send_status, created_at | 排查失败通知 |
| idx_ra_notify_batch_no | workflow_batch_no | 按批次编号检索 |
dedupe_key 生成规则:
```text
{workflow_type}:{workflow_batch_id}:{workflow_status}
```
---
### 3.3 ra_feishu_question_log
飞书问答日志预留表。首期可创建表但不接入事件回调;后续私聊问答 MVP 使用该表记录问题、意图、查询对象、回答摘要和错误信息。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| system_user_id | ForeignKey | bigint | 否 | 识别出的系统用户 |
| feishu_mapping_id | ForeignKey | bigint | 否 | 命中的飞书映射 |
| feishu_open_id | CharField(120) | varchar(120) | 否 | 事件中的 open_id |
| feishu_user_id | CharField(120) | varchar(120) | 否 | 事件中的 user_id |
| source_type | CharField(30) | varchar(30) | 是 | private_chat、group_mention |
| message_id | CharField(120) | varchar(120) | 否 | 飞书消息 ID |
| question_text | TextField | text | 是 | 用户原始问题 |
| intent | CharField(60) | varchar(60) | 否 | batch_status、risk_summary、export_summary 等 |
| query_object | JSONField | text/json | 是 | 批次号、工作流类型、最近批次等查询对象 |
| answer_summary | TextField | text | 否 | 回答摘要,不保存完整回答正文 |
| permission_result | CharField(40) | varchar(40) | 否 | allowed、denied、unbound |
| status | CharField(30) | varchar(30) | 是 | success、failed、ignored |
| error_message | TextField | text | 否 | 异常说明 |
| processed_at | DateTimeField | datetime | 否 | 处理完成时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_feishu_q_user_created | system_user_id, created_at | 用户问答历史 |
| idx_ra_feishu_q_intent | intent, created_at | 按意图分析 |
| idx_ra_feishu_q_status | status, created_at | 排查失败问答 |
| idx_ra_feishu_q_message | message_id | 消息幂等 |
---
## 四、状态枚举
### 4.1 WorkflowNotificationRecord.channel
| 值 | 说明 |
| --- | --- |
| mock | 模拟通知 |
| disabled | 真实通知未启用 |
| feishu_api | 飞书官方智能体/企业自建应用消息 API |
| feishu_webhook | 备选自定义机器人 Webhook非首期主方案 |
### 4.2 WorkflowNotificationRecord.send_status
| 值 | 说明 |
| --- | --- |
| pending | 待发送 |
| success | 发送成功 |
| failed | 发送失败 |
| skipped_duplicate | 重复通知跳过 |
| disabled | 未启用真实发送 |
### 4.3 FeishuQuestionLog.intent
| 值 | 说明 |
| --- | --- |
| batch_status | 查询批次状态 |
| risk_summary | 查询风险摘要 |
| missing_summary | 查询缺失摘要 |
| export_summary | 查询导出摘要 |
| unknown | 未识别 |
---
## 五、与现有表的兼容关系
| 现有表 | 处理建议 |
| --- | --- |
| `ra_regulatory_notification_record` | 保留现有数据;法规核查真实飞书通知可新增写入通用表,后续再决定是否迁移 |
| `ra_application_form_fill_notification_record` | 保留现有数据;自动填表通知状态展示可优先读通用表,兼容旧表 |
| `ra_exported_summary_file` | 通知摘要中的导出文件数量来自该表 |
| `ra_workflow_event` | 可记录通知节点事件,但不替代通知记录表 |
| `auth_user` | 飞书映射通过外键关联系统用户 |
---
## 六、数据脱敏与安全
| 数据 | 入库策略 |
| --- | --- |
| App ID | 不入库,只在环境变量中维护 |
| App Secret | 不入库,只在环境变量中维护 |
| tenant_access_token | 不持久化入库,仅允许进程内短期缓存 |
| 富文本完整 payload | 不入库 |
| 手机号 | 映射表保存原值;通知记录只保存脱敏值 |
| open_id/user_id | 映射表保存原值;通知记录保存脱敏值 |
| 用户问题 | 问答日志保存原始问题,用于审计;不保存完整回答正文 |
---
## 七、迁移计划
| 步骤 | 说明 |
| --- | --- |
| 1 | 新增 `FeishuUserMapping` 模型和迁移 |
| 2 | 新增 `WorkflowNotificationRecord` 模型和迁移 |
| 3 | 新增 `FeishuQuestionLog` 预留模型和迁移 |
| 4 | 注册 Django Admin 管理入口 |
| 5 | 批次详情页查询通用通知记录展示 |
| 6 | 保留现有专项通知表,不做破坏性迁移 |
---
## 八、验收 SQL 示例
查询某个批次通知状态:
```sql
SELECT workflow_type, workflow_batch_no, workflow_status, channel, send_status, sent_at, error_message
FROM ra_workflow_notification_record
WHERE workflow_type = 'application_form_fill'
AND workflow_batch_no = 'AFF-20260607-001'
ORDER BY created_at DESC;
```
查询未配置飞书映射的失败或降级通知:
```sql
SELECT workflow_type, workflow_batch_no, trigger_user_id, send_status, message_summary
FROM ra_workflow_notification_record
WHERE at_identifier_type = 'missing'
ORDER BY created_at DESC;
```
查询飞书用户映射:
```sql
SELECT u.username, m.feishu_display_name, m.feishu_open_id, m.feishu_user_id, m.feishu_mobile, m.is_active
FROM ra_feishu_user_mapping m
JOIN auth_user u ON u.id = m.system_user_id
ORDER BY u.username;
```

View File

@@ -1,590 +0,0 @@
# 第1章监管信息材料包生成数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md |
| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md |
| 数据库类型 | SQLite / Django ORM |
| 表名前缀 | ra_ |
| 工作流编码 | regulatory_info_package |
| 设计日期 | 2026-06-10 |
| 设计版本 | V1.0 |
---
## 一、设计原则
| 原则 | 说明 |
| --- | --- |
| 独立工作流批次 | 第1章监管信息材料包生成使用独立批次表不复用自动填表批次 |
| 附件优先 | 输入说明书优先绑定 `FileAttachment`,兼容最近成功 `FileSummaryBatch``FileSummaryItem` |
| 过程产物文件化 | 大 JSON、追溯清单、模板副本、生成文件和 zip 均保存为文件数据库只保存路径、hash、摘要 |
| 导出记录复用 | zip、单文件、追溯清单继续写入 `ExportedSummaryFile`,统一下载权限 |
| 工作流通用表复用 | 节点状态和 SSE 事件复用 `WorkflowNodeRun``WorkflowEvent` |
| 通知独立留痕 | 新增专项通知记录表,结构与自动填表通知记录保持一致 |
| SQLite 兼容 | 使用 Django ORM 常规字段和 JSONField避免数据库特定语法 |
| 原始模板保护 | 数据库只记录批次工作目录产物,不记录对原始模板的写操作 |
---
## 二、ER 图
```mermaid
erDiagram
AUTH_USER ||--o{ CONVERSATION : owns
CONVERSATION ||--o{ MESSAGE : contains
CONVERSATION ||--o{ RA_FILE_ATTACHMENT : has
CONVERSATION ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : has
AUTH_USER ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : runs
MESSAGE ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : triggers
RA_FILE_ATTACHMENT ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : provides_instruction
RA_FILE_SUMMARY_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_BATCH : optionally_feeds
RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_ARTIFACT : keeps
RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_REGULATORY_INFO_PACKAGE_NOTIFICATION_RECORD : sends
RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_EXPORTED_SUMMARY_FILE : exports
RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_WORKFLOW_NODE_RUN : tracks
RA_REGULATORY_INFO_PACKAGE_BATCH ||--o{ RA_WORKFLOW_EVENT : emits
```
说明:`ra_workflow_node_run``ra_workflow_event``ra_exported_summary_file` 通过 `workflow_type``workflow_batch_id` 支持多工作流。本功能统一使用 `workflow_type=regulatory_info_package`
现状补充:当前通用节点表已有 `batch + node_code` 唯一约束主要服务文件汇总批次。RIP 批次不应强依赖 `FileSummaryBatch.batch`,因此实现时必须为 `workflow_type + workflow_batch_id + node_code` 增加数据库唯一约束,或在创建节点时使用同等幂等逻辑,避免同一 RIP 批次重复初始化节点。
---
## 三、表结构设计
### 3.1 ra_regulatory_info_package_batch
一次第1章监管信息材料包生成工作流批次。记录触发来源、输入说明书、产品名称、生成状态、待确认摘要、zip 名称、配置版本和工作目录。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| conversation_id | ForeignKey | bigint | 是 | 所属对话 |
| user_id | ForeignKey | bigint | 是 | 发起用户 |
| trigger_message_id | ForeignKey | bigint | 否 | 触发本工作流的用户消息 |
| source_attachment_id | ForeignKey | bigint | 否 | 直接选中的说明书附件 |
| source_summary_batch_id | ForeignKey | bigint | 否 | 可选,最近成功文件汇总批次 |
| source_summary_item_id | PositiveBigIntegerField | integer | 否 | 可选,文件汇总条目 ID |
| batch_no | CharField(64) | varchar(64) | 是 | 批次编号,格式 `RIP-YYYYMMDDHHMMSS-abcdef`,唯一 |
| status | CharField(30) | varchar(30) | 是 | pending、running、waiting_user、success、partial_success、failed、cancelled |
| source_file_name | CharField(255) | varchar(255) | 否 | 说明书原文件名 |
| source_storage_path | CharField(500) | varchar(500) | 否 | 说明书存储路径 |
| product_name | CharField(200) | varchar(200) | 否 | 抽取到的产品名称 |
| output_zip_name | CharField(255) | varchar(255) | 否 | 主输出 zip 文件名,默认 `第1章 监管信息(预生成版).zip` |
| generated_files | JSONField | text/json | 是 | 7 个文件生成状态摘要 |
| missing_fields | JSONField | text/json | 是 | 缺失并填 `/` 的字段 |
| llm_only_fields | JSONField | text/json | 是 | 仅 LLM 命中的字段 |
| conflict_fields | JSONField | text/json | 是 | 规则和 LLM 冲突字段 |
| risk_notes | JSONField | text/json | 是 | `.doc` 适配器、知识库不可用、zip 失败等提示 |
| template_config_version | CharField(80) | varchar(80) | 否 | 模板配置版本 |
| template_config_hash | CharField(128) | varchar(128) | 否 | 模板配置 hash |
| adapter_summary | JSONField | text/json | 是 | docx/doc 适配器使用情况 |
| work_dir | CharField(500) | varchar(500) | 否 | 批次工作目录 |
| error_message | TextField | text | 否 | 批次异常说明 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| started_at | DateTimeField | datetime | 否 | 开始时间 |
| finished_at | DateTimeField | datetime | 否 | 完成时间 |
| archived_at | DateTimeField | datetime | 否 | 归档时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
唯一约束:
| 约束名 | 字段 |
| --- | --- |
| uq_ra_rip_batch_no | batch_no |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_rip_batch_conv_status | conversation_id, status | 查询对话下材料包批次状态 |
| idx_ra_rip_batch_user_created | user_id, created_at | 查询用户发起历史 |
| idx_ra_rip_batch_attachment | source_attachment_id | 查询某说明书附件生成历史 |
| idx_ra_rip_batch_summary | source_summary_batch_id | 查询文件汇总关联的材料包批次 |
| idx_ra_rip_batch_created | created_at | 后台按时间排查 |
---
### 3.2 ra_regulatory_info_package_artifact
第1章监管信息材料包生成过程产物表。仅保存文件元数据不保存大文本正文。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属材料包批次 |
| artifact_type | CharField(60) | varchar(60) | 是 | template_copy、instruction_extract、field_extract_result、merged_fields、generated_document、traceability、zip_package、notification_record |
| file_format | CharField(20) | varchar(20) | 是 | json、excel、docx、doc、zip、markdown |
| name | CharField(160) | varchar(160) | 是 | 产物名称 |
| file_name | CharField(255) | varchar(255) | 是 | 文件名 |
| storage_path | CharField(500) | varchar(500) | 是 | 文件存储路径 |
| file_size | BigIntegerField | bigint | 是 | 文件大小 |
| content_hash | CharField(128) | varchar(128) | 否 | 文件 SHA-256 hash |
| metadata | JSONField | text/json | 是 | 模板编码、生成状态、高亮数量、适配器、错误摘要等 |
| created_by_node | CharField(60) | varchar(60) | 否 | 生成该产物的工作流节点 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_rip_artifact_batch_type | batch_id, artifact_type | 查询批次过程产物 |
| idx_ra_rip_artifact_format | file_format | 按文件格式查询 |
| idx_ra_rip_artifact_created | created_at | 按时间追溯 |
---
### 3.3 ra_regulatory_info_package_notification_record
第1章监管信息材料包生成通知记录表。通知失败不阻断下载但需要留痕和支持后续重试。
| 字段名 | Django 类型 | SQLite 类型 | 必填 | 说明 |
| --- | --- | --- | --- | --- |
| id | BigAutoField | integer | 是 | 主键 |
| batch_id | ForeignKey | bigint | 是 | 所属材料包批次 |
| recipient_id | ForeignKey(User) | bigint | 是 | 通知对象,默认发起人 |
| channel | CharField(30) | varchar(30) | 是 | feishu_cli、feishu_api、mock |
| export_ids | JSONField | text/json | 是 | 本次通知关联导出文件 ID |
| message_summary | TextField | text | 是 | 通知摘要 |
| send_status | CharField(20) | varchar(20) | 是 | pending、success、failed |
| retry_count | PositiveIntegerField | integer | 是 | 已重试次数 |
| external_message_id | CharField(120) | varchar(120) | 否 | 飞书外部消息 ID |
| error_message | TextField | text | 否 | 失败原因 |
| sent_at | DateTimeField | datetime | 否 | 发送成功时间 |
| created_at | DateTimeField | datetime | 是 | 创建时间 |
| updated_at | DateTimeField | datetime | 是 | 更新时间 |
| is_deleted | BooleanField | bool | 是 | 软删除标记 |
索引:
| 索引名 | 字段 | 说明 |
| --- | --- | --- |
| idx_ra_rip_notify_batch | batch_id, created_at | 查询批次通知 |
| idx_ra_rip_notify_recipient | recipient_id, send_status | 查询用户通知状态 |
| idx_ra_rip_notify_status | send_status, retry_count | 查询待重试通知 |
---
## 四、既有表扩展
### 4.1 ra_exported_summary_file
继续复用导出文件表,新增 zip 导出类型,并支持 `regulatory_info_package` 权限反查。
| 字段/枚举 | 处理 |
| --- | --- |
| export_type | 增加 `zip` |
| workflow_type | 使用 `regulatory_info_package` |
| workflow_batch_id | 记录 `RegulatoryInfoPackageBatch.id` |
| export_category | 使用 `regulatory_info_package``generated_document``traceability` |
导出类型枚举:
| value | 中文展示 | 说明 |
| --- | --- | --- |
| markdown | Markdown | 既有报告 |
| excel | Excel | 追溯清单 |
| json | JSON | 抽取结果、合并字段 |
| word | Word | 生成的 Word 文件,包含 `.docx` 和可下载 `.doc` |
| pdf | PDF | 既有预留 |
| zip | ZIP | 第1章监管信息材料包主下载 |
下载 MIME 规则:
| 条件 | content_type |
| --- | --- |
| export_type=zip | application/zip |
| export_type=word 且文件名后缀 `.doc` | application/msword |
| export_type=word 且文件名后缀 `.docx` | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
### 4.2 ra_workflow_node_run
本功能使用通用工作流节点表:
| 字段 | 值 |
| --- | --- |
| workflow_type | regulatory_info_package |
| workflow_batch_id | RegulatoryInfoPackageBatch.id |
| node_group | regulatory_info_package |
| batch_id | 可为空;如为兼容旧查询,不建议绑定文件汇总批次 |
幂等约束建议:
| 约束/策略 | 字段 | 说明 |
| --- | --- | --- |
| uq_ra_node_workflow_batch_code | workflow_type, workflow_batch_id, node_code | 推荐新增数据库唯一约束,防止同一 RIP 批次重复节点 |
| get_or_create 幂等 | workflow_type, workflow_batch_id, node_code | 若暂不改通用表约束,节点初始化必须使用该组合做代码层幂等 |
建议新增节点:
```text
prepare, template_copy, text_extract, field_extract, field_merge,
generate_docs, highlight_review_items, trace_export, zip_export, notify, completed
```
### 4.3 ra_workflow_event
本功能事件写入:
| 字段 | 值 |
| --- | --- |
| workflow_type | regulatory_info_package |
| workflow_batch_id | RegulatoryInfoPackageBatch.id |
| conversation_id | 当前对话 ID |
| payload | 节点状态、文件生成状态、导出 ID、待确认摘要等 |
---
## 五、枚举设计
### 5.1 RegulatoryInfoPackageBatch.status
| value | 中文展示 | 说明 |
| --- | --- | --- |
| pending | 待执行 | 批次已创建,等待执行 |
| running | 执行中 | 工作流正在执行 |
| waiting_user | 等待用户 | 未找到唯一说明书,需要用户选择 |
| success | 成功 | 7 个文件、zip 和必要追溯产物生成成功 |
| partial_success | 部分成功 | zip 或主要文件已生成,但部分单文件、`.doc` 原生处理、`.docx` 兜底、追溯或通知存在失败 |
| failed | 失败 | 关键输入、模板或全部目标文件生成失败 |
| cancelled | 已取消 | 用户或系统取消执行 |
### 5.2 RegulatoryInfoPackageArtifact.artifact_type
| value | 说明 |
| --- | --- |
| template_copy | 模板副本 |
| instruction_extract | 说明书文本、章节、表格抽取结果 |
| field_extract_result | 规则与 LLM 抽取原始结果 |
| merged_fields | 合并字段、高亮决策、标准候选 |
| generated_document | 生成后的单个目标文件 |
| traceability | 追溯清单 |
| zip_package | 主下载 zip 包 |
| notification_record | 通知记录产物 |
### 5.3 RegulatoryInfoPackageArtifact.file_format
| value | 说明 |
| --- | --- |
| json | JSON 产物 |
| excel | Excel 追溯清单 |
| docx | Word OpenXML 文件 |
| doc | Word 97-2003 文件 |
| zip | 压缩包 |
| markdown | Markdown 摘要或报告 |
### 5.4 通知枚举
| 字段 | value |
| --- | --- |
| channel | feishu_cli、feishu_api、mock |
| send_status | pending、success、failed |
---
## 六、JSON 字段结构
### 6.1 generated_files
```json
[
{
"template_code": "ch1_4_application_form",
"file_name": "CH1.4 申请表.docx",
"status": "success",
"artifact_id": 12,
"export_id": 34,
"highlight_count": 8,
"missing_count": 5,
"llm_only_count": 2,
"error_message": ""
}
]
```
### 6.2 missing_fields
```json
[
{
"target_file": "CH1.4 申请表.docx",
"field_key": "applicant_name",
"field_label": "申请人名称",
"final_value": "/",
"highlight_reason": "missing",
"needs_review": true
}
]
```
### 6.3 llm_only_fields
```json
[
{
"target_file": "CH1.4 申请表.docx",
"field_key": "detection_targets",
"field_label": "检测靶标",
"final_value": "ORF1ab、N基因",
"evidence": "预期用途和检测原理章节",
"highlight_reason": "llm_only",
"needs_review": true
}
]
```
### 6.4 conflict_fields
```json
[
{
"field_key": "package_specification",
"field_label": "包装规格",
"rule_value": "规格A24人份/盒、48人份/盒、96人份/盒",
"llm_value": "规格A、规格B均为24/48/96人份",
"selected_value": "规格A24人份/盒、48人份/盒、96人份/盒",
"handling": "规则优先,写入值高亮并进入追溯清单"
}
]
```
### 6.5 risk_notes
```json
[
{
"type": "legacy_doc_adapter_unavailable",
"message": "CH1.9 为 .doc 文件,当前环境未检测到可写入适配器。",
"template_code": "ch1_9_pre_submission"
},
{
"type": "knowledge_base_unavailable",
"message": "标准清单知识库查询不可用,未自动写入候选标准。"
}
]
```
### 6.6 adapter_summary
```json
{
"docx": {
"adapter": "DocxDocumentAdapter",
"status": "available"
},
"doc": {
"adapter": "WordComDocAdapter",
"status": "available",
"fallback_used": false
}
}
```
### 6.7 artifact.metadata
```json
{
"template_code": "ch1_5_product_list",
"strategy": "product_list",
"source_template": "CH1.5 产品列表.docx",
"generated_status": "success",
"highlight_count": 12,
"missing_count": 6,
"llm_only_count": 1,
"adapter": "DocxDocumentAdapter",
"created_by_node": "generate_docs"
}
```
---
## 七、存储路径设计
批次目录:
```text
media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}/
```
目录结构:
```text
media/regulatory_info_package/12/1001/RIP-20260610153000-abcdef/
templates/
ch1_2_directory.source.docx
ch1_9_pre_submission.source.doc
extracted/
instruction_extract.json
field_extract_result.json
merged_fields.json
generated/
CH1.2 监管信息目录.docx
CH1.4 申请表.docx
CH1.5 产品列表.docx
CH1.9 产品申报前沟通的说明.doc
CH1.11.1 符合标准的清单.docx
CH1.11.5 真实性声明.docx
CH1.11.6 符合性声明.docx
exports/
traceability.xlsx
第1章 监管信息(预生成版).zip
logs/
instruction_extract.json
field_extract_result.json
merged_fields.json
traceability.json
doc_adapter_result.json
```
路径安全要求:
| 要求 | 说明 |
| --- | --- |
| 输出目录校验 | 所有输出路径必须位于当前批次 `work_dir` 下 |
| 原始模板只读 | 不允许覆盖 `docs/0.原始材料` |
| 导出路径 | `ExportedSummaryFile.storage_path` 保存实际文件路径,下载时校验权限 |
---
## 八、权限关系
### 8.1 批次权限
```text
RegulatoryInfoPackageBatch.conversation.user_id == request.user.id
```
### 8.2 输入附件权限
```text
FileAttachment.conversation_id == batch.conversation_id
FileAttachment.user_id == batch.user_id
FileAttachment.upload_status != deleted
```
### 8.3 导出下载权限
`ExportedSummaryFile` 下载时按 `workflow_type` 分支:
```text
workflow_type == "regulatory_info_package"
-> workflow_batch_id 反查 RegulatoryInfoPackageBatch
-> conversation__user == request.user
-> is_deleted == false
```
---
## 九、迁移设计
建议新增一个迁移文件,包含:
| 变更 | 说明 |
| --- | --- |
| 新增 `RegulatoryInfoPackageBatch` | 批次表 |
| 新增 `RegulatoryInfoPackageArtifact` | 产物表 |
| 新增 `RegulatoryInfoPackageNotificationRecord` | 通知记录表 |
| 扩展 `ExportedSummaryFile.ExportType` | 增加 `zip` 枚举 |
Django 模型建议仍集中放在 `review_agent/models.py`,业务逻辑放入 `review_agent/regulatory_info_package/`
---
## 十、DDL 参考
以下 DDL 为 SQLite / Django ORM 参考,实际以 migration 生成为准。
```sql
CREATE TABLE ra_regulatory_info_package_batch (
id integer NOT NULL PRIMARY KEY AUTOINCREMENT,
conversation_id bigint NOT NULL REFERENCES review_agent_conversation(id),
user_id bigint NOT NULL REFERENCES auth_user(id),
trigger_message_id bigint NULL REFERENCES review_agent_message(id),
source_attachment_id bigint NULL REFERENCES ra_file_attachment(id),
source_summary_batch_id bigint NULL REFERENCES ra_file_summary_batch(id),
source_summary_item_id integer NULL,
batch_no varchar(64) NOT NULL UNIQUE,
status varchar(30) NOT NULL,
source_file_name varchar(255) NOT NULL DEFAULT '',
source_storage_path varchar(500) NOT NULL DEFAULT '',
product_name varchar(200) NOT NULL DEFAULT '',
output_zip_name varchar(255) NOT NULL DEFAULT '',
generated_files text NOT NULL DEFAULT '[]',
missing_fields text NOT NULL DEFAULT '[]',
llm_only_fields text NOT NULL DEFAULT '[]',
conflict_fields text NOT NULL DEFAULT '[]',
risk_notes text NOT NULL DEFAULT '[]',
template_config_version varchar(80) NOT NULL DEFAULT '',
template_config_hash varchar(128) NOT NULL DEFAULT '',
adapter_summary text NOT NULL DEFAULT '{}',
work_dir varchar(500) NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
created_at datetime NOT NULL,
started_at datetime NULL,
finished_at datetime NULL,
archived_at datetime NULL,
is_deleted bool NOT NULL DEFAULT 0
);
CREATE INDEX idx_ra_rip_batch_conv_status
ON ra_regulatory_info_package_batch(conversation_id, status);
CREATE INDEX idx_ra_rip_batch_user_created
ON ra_regulatory_info_package_batch(user_id, created_at);
CREATE INDEX idx_ra_rip_batch_attachment
ON ra_regulatory_info_package_batch(source_attachment_id);
CREATE INDEX idx_ra_rip_batch_summary
ON ra_regulatory_info_package_batch(source_summary_batch_id);
CREATE INDEX idx_ra_rip_batch_created
ON ra_regulatory_info_package_batch(created_at);
```
---
## 十一、实现注意事项
| 注意事项 | 说明 |
| --- | --- |
| JSONField 默认值 | 使用 `default=list``default=dict`,禁止使用可变对象字面量 |
| 外键删除策略 | conversation/user 使用 CASCADE输入附件和文件汇总批次建议 PROTECT 或 SET_NULL避免历史批次断链 |
| `source_summary_item_id` | 当前没有强制外键到 `FileSummaryItem`,可先保存 ID后续需要强约束时再改 FK |
| 工作流节点幂等 | RIP 节点不得只依赖 `WorkflowNodeRun.batch + node_code` 唯一约束;必须使用 `workflow_type + workflow_batch_id + node_code` 保证幂等 |
| `.doc` 失败记录 | `.doc` 原生适配器不可用或执行失败时必须写入 `risk_notes` 和 artifact metadata`.docx` 兜底成功则 generated_files 状态为 `fallback_success` |
| zip 主入口 | zip 导出记录的 `export_category` 固定为 `regulatory_info_package` |
| 单文件下载 | 7 个生成文件也写入 `ExportedSummaryFile`,作为辅助下载 |
| 软删除 | 批次和产物使用 `is_deleted`,下载权限需过滤软删除批次 |
---
## 十二、验收标准
| 序号 | 验收项 | 标准 |
| --- | --- | --- |
| 1 | 模型创建 | 三张 RIP 专项表可通过 migration 创建 |
| 2 | 批次编号 | `batch_no` 唯一,符合 `RIP-...` 格式 |
| 3 | 附件关联 | 批次可绑定直接说明书附件 |
| 4 | 汇总兼容 | 批次可选绑定 `FileSummaryBatch``source_summary_item_id` |
| 5 | 产物留痕 | 模板副本、抽取结果、生成文件、zip、追溯清单均可写 artifact |
| 6 | zip 导出 | `ExportedSummaryFile` 支持 `export_type=zip` |
| 7 | 下载权限 | 非批次所属用户不能下载 RIP 导出 |
| 8 | 节点事件 | `WorkflowNodeRun``WorkflowEvent` 可通过 `workflow_type=regulatory_info_package` 查询 |
| 9 | 节点幂等 | 同一 `workflow_type + workflow_batch_id + node_code` 不会重复创建节点 |
| 10 | 通知记录 | 通知成功、失败和重试次数可落库 |
| 11 | JSON 摘要 | 缺失项、LLM-only、冲突项、风险提示结构符合本文约定 |
---
## 十三、规范依据与裁决
| 规范来源 | 命中规则 | 本设计裁决 |
| --- | --- | --- |
| GYRX 数据库设计流程 | 项目规范优先,未命中时回退基线规范 | 当前项目为 Django/SQLite沿用既有数据库设计文档风格 |
| 既有自动填表数据库设计 | 独立批次、产物、通知三表;大 JSON 文件化;通用导出表复用 | 本功能按同样模式新增 RIP 三表 |
| 自动汇总数据库设计 | 对话隔离、多版本附件、工作流事件留痕 | 输入附件和批次权限沿用该关系 |
| 飞书通知数据库设计 | 通知摘要入库、失败不阻断主流程 | RIP 通知表结构与自动填表通知对齐 |
冲突裁决:技能规范中的低代码/Java 表达不适用于当前 Django 项目,数据库设计以当前项目 ORM、SQLite 兼容和既有 `ra_` 表风格为准。

View File

@@ -1,930 +0,0 @@
# 自动汇总文件夹文件目录与页数流程详细设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/1.自动汇总.md |
| 功能设计文档 | docs/2.功能设计/1.自动汇总.md |
| 功能名称 | 自动汇总文件夹文件目录与页数 |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-05 |
| 设计版本 | V1.0 |
---
## 一、详细设计目标
本详细设计用于指导“自动汇总文件夹文件目录与页数”功能开发落地覆盖代码目录、数据模型、接口契约、后台工作流、Skill 拆分、轻量依赖、前端三栏布局、SSE 实时状态、异常重试和测试用例。
核心约束:
| 约束 | 说明 |
| --- | --- |
| 对话绑定 | 上传文件与当前 Conversation 绑定,一个对话对应一套文件,不能串文件 |
| 上传即存储 | 用户拖拽或选择文件后立即保存,但不启动工作流 |
| 提示词触发 | 用户发送消息后,根据提示词判断是否启动自动汇总工作流 |
| 后台异步 | 工作流后台执行,右侧第三栏工作流卡片实时更新 |
| 轻量依赖 | 优先使用 Python 内部库和轻量第三方库,不强依赖 LibreOffice |
| 老格式支持 | doc、xls、ppt 进入处理流程,能读到页数则统计,读不到则记录异常 |
| 结果存档 | 批次、文件、节点、事件、明细、导出文件全部入库 |
---
## 二、代码结构设计
### 2.1 目录结构
在现有 `review_agent` 应用内按模块重新划分文件处理能力。Django 模型仍集中放在 `review_agent/models.py`,其余代码放入 `review_agent/file_summary/`
```text
review_agent/
models.py
urls.py
views.py
services.py
file_summary/
__init__.py
constants.py
schemas.py
storage.py
workflow.py
events.py
urls.py
views.py
services/
__init__.py
archive.py
inventory.py
page_count.py
product_detect.py
report.py
export_excel.py
workflow_trigger.py
skills/
__init__.py
base.py
registry.py
upload_intake.py
archive_extract.py
file_inventory.py
document_page_count.py
product_detect.py
summary_report.py
excel_export.py
```
### 2.2 文件职责
| 文件 | 职责 |
| --- | --- |
| review_agent/models.py | 集中定义 Conversation、Message、文件汇总相关模型 |
| file_summary/constants.py | 状态、节点、文件类型、事件类型常量 |
| file_summary/schemas.py | dataclass 入参出参结构,避免业务层直接传散乱 dict |
| file_summary/storage.py | 上传文件、工作目录、导出文件路径生成与保存 |
| file_summary/workflow.py | WorkflowExecutor串行执行节点图 |
| file_summary/events.py | 工作流事件持久化与 SSE 格式化 |
| file_summary/views.py | 上传暂存、启动工作流、状态查询、SSE、下载接口 |
| services/archive.py | 压缩包识别、zip/7z/rar 解压 |
| services/inventory.py | 文件遍历与清单生成 |
| services/page_count.py | 文件页数统计与 3 次重试 |
| services/product_detect.py | 产品名识别 |
| services/report.py | Markdown 报告和对话简表生成 |
| services/export_excel.py | Excel 文件导出 |
| services/workflow_trigger.py | 根据提示词判断是否触发自动汇总工作流 |
| skills/base.py | Skill 基类与统一返回结构 |
| skills/registry.py | Skill 注册与按需加载 |
| skills/*.py | 各工作流节点对应 Skill |
---
## 三、依赖设计
### 3.1 requirements 建议
```text
Django==5.2.14
pypdf
python-docx
python-pptx
openpyxl
xlrd
olefile
py7zr
```
### 3.2 格式处理策略
| 格式 | 处理库 | 统计口径 | 失败策略 |
| --- | --- | --- | --- |
| pdf | pypdf | PDF 页面数 | 重试 3 次,仍失败记录异常 |
| docx | python-docx | 优先读取内置页数属性 | 读不到记录“页数不可确定” |
| doc | olefile | 读取 OLE 元数据页数 | 读不到记录“页数不可确定” |
| pptx | python-pptx | 幻灯片数量 | 重试 3 次,仍失败记录异常 |
| ppt | olefile | 读取 OLE 元数据页数/幻灯片数 | 读不到记录“页数不可确定” |
| xlsx | openpyxl | 工作表数量 | 重试 3 次,仍失败记录异常 |
| xls | xlrd | 工作表数量 | 重试 3 次,仍失败记录异常 |
### 3.3 压缩包处理策略
| 格式 | 处理方式 | 说明 |
| --- | --- | --- |
| zip | Python 标准库 zipfile | 必须支持 |
| 7z | py7zr | 必须支持 |
| rar | 优先系统 7z 命令 | Docker 镜像需安装 7-Zip/p7zip |
### 3.4 Docker 部署说明
Demo 运行不强依赖 LibreOffice。若未来要求 doc/docx/ppt/pptx 页数与 Office 打开后的分页完全一致,可在 Docker 镜像中额外安装 LibreOffice headless再通过“转换 PDF 后统计页数”的增强策略实现。
RAR 解压如需稳定支持Docker 镜像需要安装 7-Zip/p7zip并确保 `7z` 命令在 PATH 中可调用。
---
## 四、数据模型详细设计
模型集中放在 `review_agent/models.py`,按“会话模型”和“文件汇总模型”分段。
### 4.1 FileAttachment
用户上传即存储的文件记录。此时尚未启动工作流。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| conversation | ForeignKey(Conversation) | CASCADE, db_index | 绑定对话 |
| user | ForeignKey(User) | CASCADE, db_index | 上传用户 |
| original_name | CharField(255) | required | 原始文件名 |
| storage_path | CharField(500) | required | 本地保存路径 |
| file_size | BigIntegerField | default=0 | 文件大小 |
| content_type | CharField(120) | blank | MIME 类型 |
| upload_status | CharField(20) | choices | uploaded、bound、deleted |
| created_at | DateTimeField | auto_now_add | 上传时间 |
索引:
```text
(conversation, created_at)
(user, created_at)
```
### 4.2 FileSummaryBatch
一次自动汇总工作流批次。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| conversation | ForeignKey(Conversation) | CASCADE, db_index | 绑定对话 |
| user | ForeignKey(User) | CASCADE, db_index | 执行用户 |
| trigger_message | ForeignKey(Message) | SET_NULL, null | 触发工作流的用户消息 |
| batch_no | CharField(64) | unique | 批次编号 |
| product_name | CharField(200) | blank | 产品名称 |
| status | CharField(20) | choices | pending、running、success、failed |
| total_files | IntegerField | default=0 | 文件总数 |
| supported_files | IntegerField | default=0 | 支持统计数 |
| success_files | IntegerField | default=0 | 成功数 |
| failed_files | IntegerField | default=0 | 失败数 |
| unsupported_files | IntegerField | default=0 | 不支持数 |
| uncertain_files | IntegerField | default=0 | 页数不可确定数 |
| total_pages | IntegerField | default=0 | 总页数 |
| work_dir | CharField(500) | blank | 工作目录 |
| error_message | TextField | blank | 批次错误 |
| created_at | DateTimeField | auto_now_add | 创建时间 |
| started_at | DateTimeField | null | 开始时间 |
| finished_at | DateTimeField | null | 结束时间 |
### 4.3 FileSummaryBatchAttachment
批次与上传文件的绑定表,确保工作流只读取本批次文件。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| batch | ForeignKey(FileSummaryBatch) | CASCADE | 批次 |
| attachment | ForeignKey(FileAttachment) | CASCADE | 上传文件 |
| created_at | DateTimeField | auto_now_add | 绑定时间 |
唯一约束:
```text
unique(batch, attachment)
```
### 4.4 FileSummaryItem
文件明细记录。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 所属批次 |
| file_index | IntegerField | required | 文件序号 |
| directory_level | CharField(300) | blank | 目录层级 |
| file_name | CharField(255) | required | 文件名 |
| file_type | CharField(20) | required | 扩展名 |
| relative_path | CharField(500) | required | 相对路径 |
| storage_path | CharField(500) | required | 实际处理路径 |
| page_count | IntegerField | null | 页数 |
| statistics_status | CharField(20) | choices | success、failed、unsupported、uncertain、skipped |
| retry_count | IntegerField | default=0 | 重试次数 |
| error_message | TextField | blank | 异常说明 |
| created_at | DateTimeField | auto_now_add | 创建时间 |
| updated_at | DateTimeField | auto_now | 更新时间 |
唯一约束:
```text
unique(batch, relative_path)
```
### 4.5 WorkflowNodeRun
工作流节点状态记录。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 |
| node_code | CharField(40) | required | 节点编码 |
| node_name | CharField(80) | required | 节点名称 |
| status | CharField(20) | choices | pending、running、retrying、success、failed、skipped |
| progress | IntegerField | default=0 | 进度百分比 |
| message | TextField | blank | 节点说明 |
| started_at | DateTimeField | null | 开始时间 |
| finished_at | DateTimeField | null | 完成时间 |
唯一约束:
```text
unique(batch, node_code)
```
### 4.6 WorkflowEvent
SSE 事件持久化记录,用于页面刷新后恢复和调试。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 |
| event_type | CharField(40) | required | 事件类型 |
| payload | JSONField | default=dict | 事件载荷 |
| created_at | DateTimeField | auto_now_add | 创建时间 |
### 4.7 ExportedSummaryFile
导出文件记录。
| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| id | BigAutoField | PK | 主键 |
| batch | ForeignKey(FileSummaryBatch) | CASCADE, db_index | 批次 |
| export_type | CharField(20) | choices | markdown、excel |
| file_name | CharField(255) | required | 文件名 |
| storage_path | CharField(500) | required | 保存路径 |
| status | CharField(20) | choices | success、failed |
| error_message | TextField | blank | 异常 |
| created_at | DateTimeField | auto_now_add | 生成时间 |
下载链接运行时根据 `export_id` 生成,不建议长期存储静态 URL。
---
## 五、常量与状态设计
### 5.1 支持格式
```python
SUPPORTED_PAGE_TYPES = {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx"}
ARCHIVE_TYPES = {"zip", "7z", "rar"}
```
### 5.2 工作流节点
```python
WORKFLOW_NODES = [
("upload", "上传中"),
("extract", "解压中"),
("inventory", "扫描中"),
("page_count", "解析页数中"),
("product_detect", "识别产品名中"),
("report", "输出 Markdown 中"),
("excel_export", "输出 Excel 中"),
("completed", "已完成"),
]
```
### 5.3 触发词规则
`workflow_trigger.py` 先用规则判断,后续可升级为 LLM 意图识别。
```python
SUMMARY_TRIGGER_KEYWORDS = [
"自动汇总",
"文件目录",
"页数",
"统计文件",
"汇总目录",
"目录与页数",
]
```
规则:
| 条件 | 结果 |
| --- | --- |
| 当前对话存在未绑定或最近上传文件,且提示词命中关键词 | 启动自动汇总工作流 |
| 未命中关键词 | 走普通 LLM 对话 |
| 命中关键词但没有上传文件 | AI 回复提示“请先上传文件或压缩包” |
---
## 六、服务与方法签名
### 6.1 storage.py
```python
def save_attachment(conversation, user, uploaded_file) -> FileAttachment:
"""保存上传文件并绑定当前对话。"""
def build_batch_work_dir(batch: FileSummaryBatch) -> Path:
"""生成批次工作目录。"""
def build_export_path(batch: FileSummaryBatch, suffix: str) -> Path:
"""生成导出文件路径。"""
```
存储目录:
```text
media/review_agent/
user_{user_id}/
conversation_{conversation_id}/
attachments/
batches/
batch_{batch_id}/
input/
extracted/
exports/
```
### 6.2 archive.py
```python
def is_archive(path: Path) -> bool:
"""判断是否压缩包。"""
def extract_archive(source: Path, target_dir: Path) -> list[Path]:
"""解压 zip、7z、rar返回解压后的文件路径列表。"""
def extract_zip(source: Path, target_dir: Path) -> list[Path]:
"""使用 zipfile 解压。"""
def extract_7z(source: Path, target_dir: Path) -> list[Path]:
"""使用 py7zr 解压。"""
def extract_rar(source: Path, target_dir: Path) -> list[Path]:
"""优先调用系统 7z 命令解压 rar。"""
```
安全规则:
| 规则 | 说明 |
| --- | --- |
| 路径穿越检查 | 解压后的最终路径必须仍在 target_dir 内 |
| 文件名清理 | 保留原名,但禁止绝对路径和上级目录跳转 |
| 解压失败 | 抛出 ArchiveExtractError批次失败 |
### 6.3 inventory.py
```python
def scan_files(batch: FileSummaryBatch, roots: list[Path]) -> list[FileSummaryItem]:
"""扫描目录或散装文件,创建 FileSummaryItem。"""
def build_directory_level(relative_path: Path) -> str:
"""根据相对路径生成目录层级。"""
def normalize_file_type(path: Path) -> str:
"""返回小写扩展名,不含点。"""
```
### 6.4 page_count.py
```python
def count_pages(item: FileSummaryItem) -> PageCountResult:
"""根据文件类型分发页数统计。"""
def count_pages_with_retry(item: FileSummaryItem, max_retry: int = 3) -> PageCountResult:
"""失败最多重试 3 次。"""
def count_pdf(path: Path) -> int:
"""使用 pypdf 统计 PDF 页数。"""
def count_docx(path: Path) -> PageCountResult:
"""使用 python-docx 读取内置页数属性。"""
def count_doc(path: Path) -> PageCountResult:
"""使用 olefile 读取老 doc 的 OLE 元数据页数。"""
def count_xlsx(path: Path) -> int:
"""使用 openpyxl 统计工作表数量。"""
def count_xls(path: Path) -> int:
"""使用 xlrd 统计工作表数量。"""
def count_pptx(path: Path) -> int:
"""使用 python-pptx 统计幻灯片数量。"""
def count_ppt(path: Path) -> PageCountResult:
"""使用 olefile 读取老 ppt 的 OLE 元数据页数或幻灯片数。"""
```
`PageCountResult`
```python
@dataclass
class PageCountResult:
status: str
page_count: int | None = None
error_message: str = ""
```
状态规则:
| 情况 | status | page_count |
| --- | --- | --- |
| 成功读取页数 | success | 整数 |
| 不支持类型 | unsupported | None |
| 文件可读但页数无元数据 | uncertain | None |
| 解析异常且重试失败 | failed | None |
### 6.5 product_detect.py
```python
def detect_product_name(batch: FileSummaryBatch) -> ProductDetectResult:
"""从目录名、文件名和少量元数据中识别产品名。"""
def update_conversation_title(batch: FileSummaryBatch, product_name: str) -> None:
"""按规则更新对话标题。"""
```
产品名识别优先级:
| 优先级 | 来源 |
| --- | --- |
| 1 | 顶层目录名 |
| 2 | 文件名中包含“产品”“试剂盒”“说明书”等关键词的片段 |
| 3 | docx 文档属性 title |
| 4 | PDF 元数据 title |
### 6.6 report.py
```python
def build_summary_stats(batch: FileSummaryBatch) -> dict:
"""汇总统计数据。"""
def build_chat_markdown(batch: FileSummaryBatch) -> str:
"""生成对话框展示 Markdown 简表。"""
def build_full_markdown_report(batch: FileSummaryBatch) -> str:
"""生成完整 Markdown 报告。"""
def save_markdown_report(batch: FileSummaryBatch) -> ExportedSummaryFile:
"""保存 Markdown 报告并创建导出记录。"""
```
### 6.7 export_excel.py
```python
def build_excel_workbook(batch: FileSummaryBatch) -> Workbook:
"""构建 Excel Workbook。"""
def save_excel(batch: FileSummaryBatch) -> ExportedSummaryFile:
"""保存 Excel 并创建导出记录。"""
```
工作表:
| Sheet | 字段 |
| --- | --- |
| 汇总信息 | 批次编号、产品名、文件总数、成功数、失败数、不可确定数、总页数 |
| 文件明细 | 序号、目录层级、文件名、类型、页数、相对路径、状态、重试次数、异常说明 |
---
## 七、Skill 详细设计
### 7.1 BaseSkill
```python
class BaseSkill:
name: str
node_code: str
def run(self, context: WorkflowContext) -> SkillResult:
raise NotImplementedError
```
`WorkflowContext`
```python
@dataclass
class WorkflowContext:
batch_id: int
conversation_id: int
user_id: int
message_id: int | None = None
```
`SkillResult`
```python
@dataclass
class SkillResult:
success: bool
message: str = ""
data: dict = field(default_factory=dict)
```
### 7.2 Skill 列表
| Skill 类名 | 节点 | 调用服务 |
| --- | --- | --- |
| UploadIntakeSkill | upload | storage.py |
| ArchiveExtractSkill | extract | archive.py |
| FileInventorySkill | inventory | inventory.py |
| DocumentPageCountSkill | page_count | page_count.py |
| ProductDetectSkill | product_detect | product_detect.py |
| SummaryReportSkill | report | report.py |
| ExcelExportSkill | excel_export | export_excel.py |
---
## 八、工作流执行器详细设计
### 8.1 执行入口
```python
def start_file_summary_workflow(batch_id: int) -> None:
thread = threading.Thread(
target=WorkflowExecutor().run,
args=(batch_id,),
daemon=True,
)
thread.start()
```
### 8.2 执行伪代码
```python
class WorkflowExecutor:
def run(self, batch_id: int) -> None:
batch = FileSummaryBatch.objects.get(pk=batch_id)
self.mark_batch_running(batch)
self.emit("workflow_started", batch, {"batch_id": batch.id})
try:
for node_code in self.resolve_nodes(batch):
self.run_node(batch, node_code)
self.mark_batch_success(batch)
self.emit("workflow_completed", batch, self.build_completed_payload(batch))
except Exception as exc:
self.mark_batch_failed(batch, str(exc))
self.emit("workflow_failed", batch, {"message": str(exc)})
```
### 8.3 节点跳过规则
| 节点 | 跳过条件 |
| --- | --- |
| extract | 当前批次没有压缩包 |
| product_detect | 没有任何可用于识别的文件名、目录名或元数据 |
---
## 九、接口详细设计
### 9.1 上传暂存接口
```text
POST /api/review-agent/conversations/{conversation_id}/attachments/
Content-Type: multipart/form-data
```
请求:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| files[] | File[] | 是 | 一个或多个文件 |
响应:
```json
{
"attachments": [
{
"id": 101,
"original_name": "注册资料.zip",
"file_size": 204800,
"upload_status": "uploaded"
}
]
}
```
权限:
```text
conversation.user 必须等于 request.user
```
### 9.2 发送消息并按需触发工作流
沿用现有 `POST /chat/stream/` SSE 能力,在 `stream_chat` 中增加判断:
```text
用户发送 prompt
-> 保存 Message
-> 判断 prompt 是否命中自动汇总工作流
-> 命中则创建 FileSummaryBatch 并启动后台工作流
-> SSE 返回 workflow_meta
-> 未命中则走原 LLM 流式回复
```
新增 SSE meta
```json
{
"conversation_id": 1,
"title": "新对话",
"workflow": {
"type": "file_summary",
"batch_id": 12,
"status": "running"
}
}
```
### 9.3 查询批次状态
```text
GET /api/review-agent/file-summary/{batch_id}/
```
响应:
```json
{
"batch": {
"id": 12,
"batch_no": "FS202606050001",
"status": "running",
"product_name": "",
"total_files": 24,
"success_files": 10,
"failed_files": 1,
"uncertain_files": 2,
"total_pages": 180
},
"nodes": [
{
"node_code": "page_count",
"node_name": "解析页数中",
"status": "running",
"progress": 45,
"message": "正在解析 11/24"
}
],
"exports": []
}
```
### 9.4 工作流事件流
```text
GET /api/review-agent/file-summary/{batch_id}/events/?after={event_id}
```
响应类型:`text/event-stream`
事件:
```text
event: node_progress
data: {"event_id": 301, "batch_id": 12, "node_code": "page_count", "status": "running", "progress": 45, "message": "正在解析 11/24"}
```
### 9.5 下载导出文件
```text
GET /api/review-agent/file-summary/exports/{export_id}/download/
```
权限:
```text
ExportedSummaryFile -> batch -> conversation -> user 必须为当前用户
```
---
## 十、前端详细设计
### 10.1 三栏布局
页面调整为三栏:
| 区域 | 内容 |
| --- | --- |
| 左侧栏 | 对话历史 |
| 中间栏 | 聊天消息、输入框 |
| 右侧栏上半部分 | 拖拽式文件导入区 |
| 右侧栏下半部分 | 工作流卡片列表 |
HTML 结构建议:
```html
<main class="workspace three-column">
<aside class="sidebar"></aside>
<section class="chat-shell"></section>
<aside class="workflow-panel">
<section class="upload-dropzone" id="uploadDropzone"></section>
<section class="workflow-card-list" id="workflowCardList"></section>
</aside>
</main>
```
### 10.2 上传交互
JS 方法:
```javascript
function bindUploadDropzone()
function uploadConversationFiles(files)
function renderAttachmentList(attachments)
```
流程:
```text
用户拖拽或选择文件
-> POST attachments 接口
-> 保存成功后右侧上传区展示文件名
-> 不启动工作流
-> 用户发送提示词
-> 命中工作流后创建工作流卡片
```
### 10.3 工作流卡片
JS 方法:
```javascript
function createWorkflowCard(batch)
function updateWorkflowNode(batchId, nodePayload)
function markWorkflowCompleted(batchId, payload)
function markWorkflowFailed(batchId, payload)
function connectWorkflowEvents(batchId)
function restoreWorkflowCards()
```
卡片结构:
```html
<article class="workflow-card" data-batch-id="12">
<header>
<strong>文件目录与页数汇总</strong>
<span class="workflow-status">运行中</span>
</header>
<ol class="workflow-nodes">
<li data-node-code="upload">上传中</li>
<li data-node-code="extract">解压中</li>
<li data-node-code="inventory">扫描中</li>
<li data-node-code="page_count">解析页数中</li>
<li data-node-code="product_detect">识别产品名中</li>
<li data-node-code="report">输出 Markdown 中</li>
<li data-node-code="excel_export">输出 Excel 中</li>
</ol>
</article>
```
### 10.4 Markdown 渲染
现有消息使用 `nl2br`,无法正常渲染 Markdown 表格。需要改造:
| 消息类型 | 渲染策略 |
| --- | --- |
| 普通用户消息 | escapeHtml + nl2br |
| 普通助手消息 | 安全 Markdown 渲染 |
| 文件汇总结果 | 安全 Markdown 渲染,允许 table、a、strong、code |
可选方案:
| 方案 | 说明 |
| --- | --- |
| 前端 marked + DOMPurify | 渲染体验好,但增加前端依赖 |
| 后端 markdown + bleach | 后端输出安全 HTML前端直接展示 |
Demo 建议使用前端 `marked` + `DOMPurify` CDN 或本地静态文件。
---
## 十一、对话标题更新设计
产品名识别成功后更新标题:
```python
def update_conversation_title(batch, product_name):
conversation = batch.conversation
if conversation.title.startswith("新对话"):
conversation.title = f"{product_name}-文件汇总"[:120]
conversation.save(update_fields=["title", "updated_at"])
```
规则:
| 场景 | 处理 |
| --- | --- |
| 新对话默认标题 | 更新为产品名 |
| 用户已有自定义标题 | 不覆盖 |
| 产品名为空 | 不更新 |
---
## 十二、测试设计
### 12.1 单元测试
| 用例 | 目标 |
| --- | --- |
| test_trigger_keywords | 提示词命中时触发自动汇总 |
| test_save_attachment_binds_conversation | 上传文件绑定当前对话 |
| test_zip_extract_safe_path | zip 解压禁止路径穿越 |
| test_scan_files_builds_relative_path | 扫描生成正确相对路径 |
| test_count_pdf_pages | PDF 页数统计 |
| test_count_xlsx_sheets | xlsx 工作表数量统计 |
| test_count_pptx_slides | pptx 幻灯片数量统计 |
| test_retry_three_times | 单文件失败重试 3 次 |
| test_uncertain_old_doc | 老 doc 元数据缺失时标记 uncertain |
### 12.2 接口测试
| 用例 | 目标 |
| --- | --- |
| test_upload_attachment_api | 上传接口返回 attachment_id |
| test_upload_permission_denied | 不能向他人对话上传文件 |
| test_stream_triggers_workflow | 发送命中提示词后返回 workflow meta |
| test_batch_status_permission | 不能查询他人批次 |
| test_export_download_permission | 不能下载他人导出文件 |
### 12.3 集成测试
| 用例 | 目标 |
| --- | --- |
| test_file_summary_zip_workflow | zip 上传后完整工作流成功 |
| test_file_summary_multi_file_workflow | 多文件上传后完整工作流成功 |
| test_single_file_failure_not_blocking | 单文件失败不阻断批次 |
| test_workflow_events_created | 节点事件按顺序写入数据库 |
| test_markdown_and_excel_exports | Markdown 与 Excel 文件生成成功 |
### 12.4 前端验证
| 用例 | 目标 |
| --- | --- |
| 拖拽上传 | 右侧上传区展示文件列表 |
| 提示词触发 | 发送“自动汇总文件目录与页数”后创建工作流卡片 |
| 状态实时更新 | SSE 事件驱动节点状态变化 |
| 页面刷新恢复 | 刷新后右侧卡片恢复当前批次状态 |
| Markdown 表格 | 对话消息中表格和下载链接正常显示 |
---
## 十三、开发顺序
1. 增加依赖与模型字段,生成迁移。
2. 实现文件上传暂存接口和存储目录策略。
3. 实现 workflow_trigger根据提示词决定是否启动工作流。
4. 实现 SkillRegistry、WorkflowExecutor 和 WorkflowEvent。
5. 实现压缩包解压、文件扫描、页数统计服务。
6. 实现 Markdown 报告与 Excel 导出。
7. 改造前端三栏布局、拖拽上传区和工作流卡片。
8. 增加 Markdown 渲染能力。
9. 补齐权限测试、工作流测试和前端手工验证。
---
## 十四、参考依据
本设计采用轻量 Python 库优先方案,依据如下:
| 能力 | 依据 |
| --- | --- |
| PDF 页数 | pypdf 的 PdfReader 可读取 pages |
| docx 元数据 | python-docx 支持 core properties |
| pptx 幻灯片 | python-pptx 可读取 presentation slides |
| xlsx 工作表 | openpyxl 可读取 workbook worksheets |
| xls 工作表 | xlrd 支持读取历史 xls 工作簿 |
| 老 Office 元数据 | olefile 可读取 OLE2 复合文档结构 |
| 7z 解压 | py7zr 支持 7z 压缩格式处理 |
| rar 解压 | rarfile 通常依赖外部 unrar/unar/7z 工具,故本设计优先系统 7z |

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