Compare commits

...

43 Commits

Author SHA1 Message Date
7def60f1b6 merge: 合并监管信息材料包最新代码到V2 2026-06-10 23:58:24 +08:00
9c6cad481c test(regulatory-info-package): 补充模板生成回归覆盖 2026-06-10 23:56:51 +08:00
1bf8634373 feat(regulatory-info-package): 完善目录页码与组成成分填充 2026-06-10 23:56:40 +08:00
3bcf9647a1 docs(regulatory-info-package): 更新材料包生成设计决策 2026-06-10 23:56:20 +08:00
cf4f4456c4 fix(regulatory-info-package): 使用干净字段模板生成材料包 2026-06-10 20:23:06 +08:00
b728703e67 fix(regulatory-info-package): 完成后追加下载摘要 2026-06-10 19:56:50 +08:00
6d4b519f83 test(regulatory-info-package): 覆盖材料包主链路 2026-06-10 19:50:22 +08:00
dcd829e821 feat(regulatory-info-package): 接入对话和前端卡片 2026-06-10 19:50:03 +08:00
dac8ce3c14 feat(regulatory-info-package): 实现材料包生成工作流 2026-06-10 19:49:44 +08:00
f0286264e2 feat(regulatory-info-package): 增加材料包数据模型 2026-06-10 19:49:25 +08:00
zhiye.sun
a060c23ba7 docs(docs): 调整数据库与详细设计目录编号 2026-06-10 15:15:02 +08:00
zhiye.sun
db0e94cf26 docs(regulatory-info-package): 补充第1章监管信息开发计划 2026-06-10 14:59:24 +08:00
zhiye.sun
dce7045a46 docs(regulatory-info-package): 新增第1章监管信息设计文档 2026-06-10 14:57:40 +08:00
8548b6d2b4 docs:原始材料内容补充 2026-06-10 08:41:50 +08:00
26e675e5d3 fix(chat): 拦截无依据的非业务问题 2026-06-09 08:23:08 +08:00
42187bf8e9 fix(knowledge-base): 停用文档时同步清理索引 2026-06-09 08:22:57 +08:00
18548eb78f fix(file-summary): 修复删除对话时受保护批次阻塞 2026-06-09 08:22:45 +08:00
2b5093040d fix(kb): 完善知识库入库和重建索引 2026-06-08 23:45:34 +08:00
d8cd95e590 docs(report): 更新架构汇报材料 2026-06-08 23:45:06 +08:00
681cb03eb9 chore(config): 切换演示模型配置 2026-06-08 23:44:50 +08:00
ccfa43645e feat(dashboard): 增加首页工作台并调整聊天入口 2026-06-08 22:25:16 +08:00
ef0a9ee13e feat(conversations): 支持删除对话并优化侧栏 2026-06-08 21:39:38 +08:00
2244b69d62 feat(chat): 接入全局知识库上下文 2026-06-08 21:38:12 +08:00
5ecf78c5d6 feat(knowledge-base): 增加全局知识库管理 2026-06-08 21:37:32 +08:00
e6fa738fd5 docs: 补充产品说明和汇报材料 2026-06-08 21:35:13 +08:00
1f56247978 test(feishu): 修正 token 刷新测试 mock 2026-06-07 22:43:40 +08:00
90144c42ac chore(feishu): 提交飞书接入文档和本地配置 2026-06-07 22:36:04 +08:00
f23e403eb8 docs: document feishu configuration 2026-06-07 22:17:40 +08:00
bd9b2e872e feat: add feishu question preview services 2026-06-07 22:16:36 +08:00
be7fbab0a0 feat: add feishu notification test command 2026-06-07 22:14:51 +08:00
1a1b3ee9d4 feat: show feishu notification status 2026-06-07 22:13:56 +08:00
cbc7493df8 feat: wire feishu notifications into workflows 2026-06-07 22:09:47 +08:00
820069f558 feat: add workflow notification dispatcher 2026-06-07 22:07:00 +08:00
bdc1d58c22 feat: add feishu api notification services 2026-06-07 22:05:20 +08:00
da81ce24d0 feat: add feishu notification data models 2026-06-07 22:03:05 +08:00
003ff59268 fix(application-form-fill): 过滤申请表噪声冲突内容 2026-06-07 20:34:24 +08:00
d640ced748 fix(application-form-fill): 清理填表说明并收窄按钮话术 2026-06-07 20:26:32 +08:00
30bdcdbc9c fix(application-form-fill): 代理人字段暂用生产企业信息 2026-06-07 20:19:52 +08:00
57f9181d58 fix(application-form-fill): 新附件先汇总再填表 2026-06-07 20:15:08 +08:00
0ccd69d3f4 fix(application-form-fill): 抽取说明书章节和表格字段 2026-06-07 20:14:53 +08:00
13b543c99d fix(application-form-fill): 清洗填表Word文件名 2026-06-07 20:14:37 +08:00
ac5cf8bf7e fix(application-form-fill): 优先路由填表提示并支持rar预览 2026-06-07 20:14:23 +08:00
82c33e513f feat(frontend): 启用工作流快捷提示按钮 2026-06-07 20:14:04 +08:00
154 changed files with 16994 additions and 83 deletions

10
.env
View File

@@ -6,7 +6,9 @@ DJANGO_ALLOWED_HOSTS=*
LLM_PROVIDER=openai_compatible LLM_PROVIDER=openai_compatible
LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw LLM_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
LLM_BASE_URL=https://api.siliconflow.cn/v1 LLM_BASE_URL=https://api.siliconflow.cn/v1
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct LLM_MODEL=deepseek-ai/DeepSeek-V4-Pro
SILICONFLOW_EMBEDDING_MODEL=Qwen/Qwen3-Embedding-8B
SILICONFLOW_EMBEDDING_DIMENSIONS=4096
# SiliconFlow embedding model for RAG # SiliconFlow embedding model for RAG
EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw EMBEDDING_API_KEY=sk-pgvkjondmmrlyxmrfhotgpuirgbtgzrpjpweorhwruflxmxw
@@ -17,3 +19,9 @@ SCENARIO_CONFIG_DIR=configs
GOVERNANCE_CONFIG_PATH=configs/governance.yaml GOVERNANCE_CONFIG_PATH=configs/governance.yaml
UPLOAD_ROOT=data/uploads UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma 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

32
PRODUCT.md Normal file
View File

@@ -0,0 +1,32 @@
# Product
## Register
product
## Users
注册资料准备、法规审核和项目管理人员,在资料整理、法规核查、问题整改和申报文件填表过程中使用。
## Product Purpose
DEMO-AGENT 是一个体外诊断试剂注册资料审核工作台。它把上传资料、文件汇总、法规规则核查、RAG 依据检索、风险预警、整改复核和申报表填充组织成可追溯的工作流。
## Brand Personality
克制、可信、清晰。界面应服务审核任务,优先呈现状态、证据和下一步动作。
## Anti-references
避免营销页式大标题、装饰性卡片堆叠、过度动画、过亮的渐变和不必要的视觉噪声。
## Design Principles
- 证据优先:每个结论都应能回到来源文件、规则或检索片段。
- 状态清楚:批次、节点、风险、异常和导出结果要一眼可辨。
- 操作克制:页面提供必要动作,不把审核工作做成复杂后台。
- 复用现有模式:新增页面沿用当前工作台导航、面板、表格和按钮体系。
## Accessibility & Inclusion
默认按 WCAG AA 方向处理对比度、键盘可访问和清晰标签。动效仅用于状态反馈,并尊重减少动态效果需求。

View File

@@ -35,3 +35,43 @@ LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的
上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的 上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的
`file_summary/users/<user_id>/<conversation_id>/` 或批次 `work_dir` 目录中。生产环境 `file_summary/users/<user_id>/<conversation_id>/` 或批次 `work_dir` 目录中。生产环境
需要把 `MEDIA_ROOT` 挂载到持久化卷,并纳入备份或归档策略。 需要把 `MEDIA_ROOT` 挂载到持久化卷,并纳入备份或归档策略。
## 飞书通知与问答预留
飞书接入使用企业自建应用/智能体的消息 API。敏感信息只允许写入本地 `.env`
或部署环境变量,不要提交真实 App Secret、tenant token、open_id 或 user_id。
常用环境变量:
| 变量名 | 用途 |
| --- | --- |
| `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_idopen_id 为空时使用 |
| `FEISHU_DEFAULT_TARGET_NAME` | 默认接收人展示名,用于记录和页面展示 |
| `FEISHU_TENANT_TOKEN_CACHE_SECONDS` | tenant_access_token 缓存秒数 |
| `PUBLIC_BASE_URL` | 飞书消息中的系统入口根地址,默认 `http://127.0.0.1:8000` |
自动化测试会 mock 飞书 token API 和消息 API不请求真实飞书接口。真实发送只通过
本地手动命令验证:
```bash
python manage.py send_test_feishu_notification --username owner
```
问答预留能力可用本地模拟命令验证:
```bash
python manage.py feishu_question_simulate --username owner "查最新法规核查"
```
集中测试建议在补齐 `.env` 后执行:
```bash
python manage.py check
pytest tests/test_feishu_*.py
pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py
```

View File

@@ -119,13 +119,31 @@ REGULATORY_LLM_REVIEW_MAX_ATTEMPTS = int(os.environ.get("REGULATORY_LLM_REVIEW_M
REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS = float(os.environ.get("REGULATORY_LLM_REVIEW_RETRY_DELAY_SECONDS", "0.5")) 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")) 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_BASE_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1")
SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "") SILICONFLOW_API_KEY = os.environ.get("SILICONFLOW_API_KEY", LLM_API_KEY)
SILICONFLOW_EMBEDDING_MODEL = os.environ.get( SILICONFLOW_EMBEDDING_MODEL = os.environ.get(
"SILICONFLOW_EMBEDDING_MODEL", "SILICONFLOW_EMBEDDING_MODEL",
"Qwen/Qwen3-Embedding-4B", "Qwen/Qwen3-Embedding-4B",
) )
SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024")) 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 = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,

View File

@@ -2,10 +2,12 @@ from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
from django.urls import include, path from django.urls import include, path
from review_agent.views import attachment_manager, stream_chat, workspace from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace
urlpatterns = [ urlpatterns = [
path("", workspace, name="home"), 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("attachments/", attachment_manager, name="attachment_manager"),
path("", include("review_agent.urls")), path("", include("review_agent.urls")),
path("chat/stream/", stream_chat, name="chat_stream"), path("chat/stream/", stream_chat, name="chat_stream"),

Binary file not shown.

Binary file not shown.

View File

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

View File

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

@@ -0,0 +1,292 @@
# 飞书通知与问答接入功能设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | 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

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

@@ -0,0 +1,302 @@
# 飞书通知与问答接入数据库设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | 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

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

@@ -0,0 +1,604 @@
# 飞书通知与问答接入详细设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md |
| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md |
| 数据库设计文档 | docs/4.数据库设计/4.飞书通知与问答接入.md |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-07 |
| 设计版本 | V1.0 |
---
## 一、实现目标
首期实现一个统一飞书通知能力,使自动汇总、法规核查、自动填表三个工作流在完成、部分成功或失败时,通过飞书官方智能体/应用机器人消息 API 向指定个人账号发送富文本私聊通知。通知失败不阻断主流程,发送结果落库并在批次详情页展示。
同时预留飞书私聊问答所需的用户映射、查询服务、权限过滤和问答日志模型,但不实现飞书事件订阅回调。
---
## 二、推荐文件结构
| 文件 | 类型 | 责任 |
| --- | --- | --- |
| `review_agent/models.py` | 修改 | 新增 `FeishuUserMapping``WorkflowNotificationRecord``FeishuQuestionLog` |
| `review_agent/admin.py` | 修改/新增 | 注册飞书用户映射和通知记录后台 |
| `review_agent/notifications/__init__.py` | 新增 | 通知模块包 |
| `review_agent/notifications/context.py` | 新增 | 定义统一通知上下文 dataclass |
| `review_agent/notifications/recipient.py` | 新增 | 解析首期指定个人接收人;后续扩展为按系统用户映射解析 |
| `review_agent/notifications/message_builder.py` | 新增 | 构造飞书富文本 payload 和摘要 |
| `review_agent/notifications/feishu_token.py` | 新增 | 使用 App ID/App Secret 获取并缓存 tenant_access_token |
| `review_agent/notifications/feishu_message_api.py` | 新增 | 调用飞书发送消息 API、处理响应解析 |
| `review_agent/notifications/records.py` | 新增 | 判重和通知记录落库 |
| `review_agent/notifications/dispatcher.py` | 新增 | 对外统一发送入口 |
| `review_agent/notifications/workflow_adapters.py` | 新增 | 三个工作流批次到通知上下文的适配 |
| `review_agent/feishu_questions/query.py` | 新增 | 后续问答预留:批次摘要查询 |
| `review_agent/feishu_questions/permissions.py` | 新增 | 后续问答预留:权限过滤 |
| `tests/test_feishu_notification.py` | 新增 | 飞书通知单元测试 |
| `tests/test_feishu_question_reserved.py` | 新增 | 问答预留服务测试 |
---
## 三、数据结构设计
### 3.1 NotificationContext
```python
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class NotificationContext:
workflow_type: str
workflow_batch_id: int
workflow_batch_no: str
workflow_status: str
title: str
trigger_user_id: int
trigger_username: str
result_url: str
summary_lines: list[str] = field(default_factory=list)
next_action: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
@property
def dedupe_key(self) -> str:
return f"{self.workflow_type}:{self.workflow_batch_id}:{self.workflow_status}"
```
### 3.2 ResolvedFeishuTarget
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class ResolvedFeishuTarget:
mapping_id: int | None
display_name: str
identifier_type: str
identifier_value: str
masked_identifier: str
missing: bool = False
```
identifier_type 取值:
| 值 | 说明 |
| --- | --- |
| open_id | 使用飞书 open_id |
| user_id | 使用飞书 user_id |
| mobile | 使用手机号,后续按发起人私聊时使用 |
| missing | 未配置映射 |
---
## 四、模型详细设计
### 4.1 FeishuUserMapping
字段见数据库设计。模型需提供方法:
```python
def preferred_identifier(self) -> tuple[str, str]:
if self.feishu_open_id:
return "open_id", self.feishu_open_id
if self.feishu_user_id:
return "user_id", self.feishu_user_id
if self.feishu_mobile:
return "mobile", self.feishu_mobile
return "missing", ""
```
`clean()` 校验:
```python
def clean(self):
if not (self.feishu_open_id or self.feishu_user_id or self.feishu_mobile):
raise ValidationError("feishu_open_id、feishu_user_id、feishu_mobile 至少填写一个")
```
### 4.2 WorkflowNotificationRecord
字段见数据库设计。建议方法:
```python
@classmethod
def already_sent(cls, dedupe_key: str) -> bool:
return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists()
```
注意:若使用唯一约束限制 `dedupe_key`,重复触发时可以直接返回已有记录;若希望保留 skipped_duplicate 记录,则不能对 dedupe_key 做全局唯一,只能用查询判重。本项目需求是“只发一次”,更推荐保留唯一成功意图,重复触发返回已有记录或创建 skipped 记录需在实现计划中二选一。为了 SQLite 简化,首期建议不创建 skipped 记录,直接返回已有成功记录。
---
## 五、核心服务详细设计
### 5.1 workflow_adapters.py
职责:把不同批次对象转换为 `NotificationContext`
函数:
```python
def build_file_summary_context(batch: FileSummaryBatch) -> NotificationContext: ...
def build_regulatory_review_context(batch: RegulatoryReviewBatch) -> NotificationContext: ...
def build_application_form_fill_context(batch: ApplicationFormFillBatch) -> NotificationContext: ...
```
自动汇总摘要:
| 字段 | 计算方式 |
| --- | --- |
| 文件总数 | `batch.items.count()` |
| 成功解析数 | 解析状态为 success 的 item 数 |
| 异常数 | failed、skipped、unsupported 等状态数量 |
| 导出文件数 | `ExportedSummaryFile` 中 workflow_type=file_summary 或 batch 关联文件数 |
法规核查摘要:
| 字段 | 计算方式 |
| --- | --- |
| 风险总数 | `batch.issues.count()` |
| 阻断项 | severity=blocking |
| 高风险 | severity=high |
| 中风险 | severity=medium |
自动填表摘要:
| 字段 | 计算方式 |
| --- | --- |
| 模板数 | `len(batch.selected_templates)` |
| 导出文件数 | 对应 `ExportedSummaryFile` 数量 |
| 冲突字段数 | `len(batch.conflict_summary or [])` |
| 失败原因 | `batch.error_message` 或节点错误摘要 |
### 5.2 recipient.py
职责:首期根据环境变量解析指定个人接收人;后续可扩展为根据系统用户解析飞书目标。
伪代码:
```python
def resolve_feishu_target(user: User) -> ResolvedFeishuTarget:
if settings.FEISHU_DEFAULT_USER_OPEN_ID:
return ResolvedFeishuTarget(
mapping_id=None,
display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"),
identifier_type="open_id",
identifier_value=settings.FEISHU_DEFAULT_USER_OPEN_ID,
masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_OPEN_ID),
missing=False,
)
if settings.FEISHU_DEFAULT_USER_ID:
return ResolvedFeishuTarget(
mapping_id=None,
display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"),
identifier_type="user_id",
identifier_value=settings.FEISHU_DEFAULT_USER_ID,
masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_ID),
missing=False,
)
return ResolvedFeishuTarget(
mapping_id=None,
display_name=user.get_username(),
identifier_type="missing",
identifier_value="",
masked_identifier="",
missing=True,
)
def resolve_feishu_target_by_user_mapping(user: User) -> ResolvedFeishuTarget:
mapping = (
FeishuUserMapping.objects
.filter(system_user=user, is_active=True)
.first()
)
if mapping is None:
return ResolvedFeishuTarget(
mapping_id=None,
display_name=user.get_username(),
identifier_type="missing",
identifier_value="",
masked_identifier="",
missing=True,
)
identifier_type, identifier_value = mapping.preferred_identifier()
return ResolvedFeishuTarget(
mapping_id=mapping.pk,
display_name=mapping.feishu_display_name or user.get_username(),
identifier_type=identifier_type,
identifier_value=identifier_value,
masked_identifier=mask_identifier(identifier_value),
missing=identifier_type == "missing",
)
```
脱敏规则:
| 类型 | 规则 |
| --- | --- |
| mobile | 保留前三位和后四位,如 `138****1234` |
| open_id/user_id | 保留前 6 位和后 4 位 |
| missing | 空字符串 |
首期调度器使用 `resolve_feishu_target()``resolve_feishu_target_by_user_mapping()` 作为后续“按发起人私聊”能力预留。
### 5.3 message_builder.py
职责:构造富文本 payload 和入库摘要。
函数:
```python
def build_feishu_post_message(
context: NotificationContext,
target: ResolvedFeishuTarget,
) -> dict: ...
def build_message_summary(
context: NotificationContext,
target: ResolvedFeishuTarget,
) -> str: ...
```
富文本规则:
| 场景 | 规则 |
| --- | --- |
| 有映射 | 加入 `at` 标签 |
| 无映射 | 不加入 `at` 标签,增加映射缺失提示 |
| 失败状态 | 标题和下一步动作突出失败原因摘要 |
| 摘要过长 | 每条摘要最多 120 字,总摘要最多 800 字 |
| 链接 | 使用本地地址拼接,后续再切换域名配置 |
### 5.4 feishu_token.py
职责:使用 App ID/App Secret 获取并缓存 `tenant_access_token`
函数:
```python
def get_tenant_access_token() -> FeishuTokenResult: ...
def refresh_tenant_access_token() -> FeishuTokenResult: ...
```
结果结构:
```python
@dataclass(frozen=True)
class FeishuTokenResult:
ok: bool
tenant_access_token: str
expire_seconds: int
code: str
message: str
```
处理规则:
| 场景 | 处理 |
| --- | --- |
| App ID/App Secret 缺失 | 返回 failed错误码 config_missing |
| 缓存 token 未过期 | 直接返回缓存 token |
| token 过期或不存在 | 调用飞书 token API 重新获取 |
| token API 返回失败 | 返回 failed记录 code/message |
| HTTP 超时 | 返回 failed错误码 timeout |
### 5.5 feishu_message_api.py
职责:调用飞书发送消息 API。
函数:
```python
def send_personal_message(
*,
tenant_access_token: str,
receive_id_type: str,
receive_id: str,
payload: dict,
) -> FeishuMessageApiResult: ...
```
结果结构:
```python
@dataclass(frozen=True)
class FeishuMessageApiResult:
ok: bool
status_code: int | None
code: str
message: str
duration_ms: int
message_id: str = ""
```
异常处理:
| 异常 | 处理 |
| --- | --- |
| 指定接收人缺失 | 返回 failed错误码 recipient_missing |
| tenant_access_token 缺失 | 返回 failed错误码 token_missing |
| HTTP 超时 | 返回 failed错误码 timeout |
| 非 2xx | 返回 failed记录 status_code |
| 飞书返回 code 非 0 | 返回 failed记录 code/message |
| token 失效 | 刷新 token 后允许同步重试一次消息 API |
### 5.6 records.py
职责:判重和落库。
流程:
```text
输入 NotificationContext
-> 查询 dedupe_key 是否已有 success
-> 若有,返回已有记录,不发送
-> 若未启用真实飞书,创建 disabled/mock 记录
-> 若发送成功,创建 success 记录
-> 若发送失败,创建 failed 记录
```
字段写入规则:
| 字段 | 来源 |
| --- | --- |
| workflow_type | context.workflow_type |
| workflow_batch_id | context.workflow_batch_id |
| workflow_batch_no | context.workflow_batch_no |
| workflow_status | context.workflow_status |
| dedupe_key | context.dedupe_key |
| trigger_user_id | context.trigger_user_id |
| feishu_mapping_id | target.mapping_id |
| at_identifier_type | target.identifier_type |
| at_identifier_masked | target.masked_identifier |
| message_summary | `build_message_summary()` |
### 5.7 dispatcher.py
对外入口:
```python
def dispatch_workflow_notification(context: NotificationContext) -> WorkflowNotificationRecord:
if WorkflowNotificationRecord.already_sent(context.dedupe_key):
return WorkflowNotificationRecord.objects.get(
dedupe_key=context.dedupe_key,
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
)
user = User.objects.get(pk=context.trigger_user_id)
target = resolve_feishu_target(user)
message = build_feishu_post_message(context, target)
summary = build_message_summary(context, target)
if not settings.FEISHU_NOTIFY_ENABLED:
return create_disabled_record(context, target, summary)
token_result = get_tenant_access_token()
if not token_result.ok:
return create_failed_record(context, target, summary, token_result)
result = send_personal_message(
tenant_access_token=token_result.tenant_access_token,
receive_id_type=target.identifier_type,
receive_id=target.identifier_value,
payload=message,
)
if result.ok:
return create_success_record(context, target, summary, result)
return create_failed_record(context, target, summary, result)
```
---
## 六、工作流接入点
| 工作流 | 推荐接入位置 |
| --- | --- |
| 自动汇总 | 文件汇总批次状态写为 success/partial_success/failed 后 |
| 法规核查 | 报告导出和风险项保存后;替换或并行现有 `create_mock_notifications` |
| 自动填表 | `notify` 节点中替换或扩展现有 `notify_completion` |
接入原则:
| 原则 | 说明 |
| --- | --- |
| 通知异常捕获 | 工作流调用通知服务时捕获异常并记录 non_blocking_errors |
| 不回滚业务结果 | 通知失败不修改业务批次成功状态 |
| 单点适配 | 工作流只负责生成或传入批次,摘要由 adapter 负责 |
---
## 七、批次详情展示设计
### 7.1 后端上下文
为批次详情页提供:
```python
def get_notification_records(workflow_type: str, batch_id: int) -> QuerySet:
return WorkflowNotificationRecord.objects.filter(
workflow_type=workflow_type,
workflow_batch_id=batch_id,
).order_by("-created_at")
```
### 7.2 页面展示规则
| 状态 | 展示 |
| --- | --- |
| success | “飞书通知已发送”,展示 sent_at |
| failed | “飞书通知失败”,展示 error_message |
| disabled | “飞书通知未启用” |
| 无记录 | “暂无通知记录” |
三个工作流结果页可复用同一 partial 模板或上下文字段。
---
## 八、问答预留详细设计
### 8.1 批次摘要查询服务
预留函数:
```python
def query_batch_summary(
user: User,
*,
workflow_type: str | None = None,
batch_no: str | None = None,
latest: bool = False,
) -> dict:
...
```
权限规则:
| 用户 | 可查范围 |
| --- | --- |
| 管理员 | 全部批次 |
| 普通用户 | `batch.user == user` 的批次 |
| 未绑定用户 | 不可查 |
查询对象:
| 类型 | 说明 |
| --- | --- |
| 明确批次号 | 精确匹配 batch_no |
| 最近/最新 | 在有权限范围内按 created_at/finished_at 倒序取第一条 |
| 工作流类型 | file_summary、regulatory_review、application_form_fill |
### 8.2 问答日志服务
预留函数:
```python
def record_feishu_question_log(
*,
user: User | None,
mapping: FeishuUserMapping | None,
source_type: str,
question_text: str,
intent: str,
query_object: dict,
answer_summary: str,
permission_result: str,
status: str,
error_message: str = "",
) -> FeishuQuestionLog:
...
```
首期不需要接飞书事件,但测试可直接调用该服务,确认日志字段与权限规则可用。
---
## 九、测试设计
### 9.1 单元测试
| 测试文件 | 用例 |
| --- | --- |
| `tests/test_feishu_notification.py` | tenant_access_token 获取和缓存 |
| `tests/test_feishu_notification.py` | 指定个人接收人优先级 open_id > user_id |
| `tests/test_feishu_notification.py` | 指定接收人缺失时写 failed 记录 |
| `tests/test_feishu_notification.py` | 真实通知关闭时写 disabled/mock 记录 |
| `tests/test_feishu_notification.py` | 消息 API 成功写 success 记录 |
| `tests/test_feishu_notification.py` | token 获取失败写 failed 记录 |
| `tests/test_feishu_notification.py` | 消息 API 超时写 failed 记录 |
| `tests/test_feishu_notification.py` | 同一 dedupe_key 不重复发送 |
| `tests/test_feishu_question_reserved.py` | 管理员可查询全部批次摘要 |
| `tests/test_feishu_question_reserved.py` | 普通用户只能查询自己的批次 |
| `tests/test_feishu_question_reserved.py` | 问答日志不保存完整回答正文 |
### 9.2 集成测试
| 场景 | 验证 |
| --- | --- |
| 自动汇总完成 | 生成通知上下文并写记录 |
| 法规核查完成 | 风险摘要正确 |
| 自动填表完成 | 导出和冲突摘要正确 |
| 批次详情页 | 展示通知状态和失败原因 |
### 9.3 外部飞书测试
真实飞书 API 测试不进入默认 CI。建议提供手动命令或 Django management command
```text
python manage.py send_test_feishu_notification --username owner
```
该命令只在本地配置 `FEISHU_NOTIFY_ENABLED=true``FEISHU_APP_ID``FEISHU_APP_SECRET``FEISHU_DEFAULT_USER_OPEN_ID``FEISHU_DEFAULT_USER_ID` 后使用。
---
## 十、异常处理
| 异常 | 处理 |
| --- | --- |
| 指定接收人缺失 | 不发送真实消息,记录 recipient_missing |
| App ID/App Secret 未配置 | 写 failed 或 disabled 记录,不发送 |
| tenant_access_token 获取失败 | 写 failed记录 token API 错误 |
| 指定接收人 open_id/user_id 未配置 | 写 failed错误码 recipient_missing |
| HTTP 超时 | 写 failed错误码 timeout |
| 飞书返回错误 | 写 failed记录 code/message |
| 通知记录唯一冲突 | 查询已有记录并返回,不重复发送 |
| 批次链接生成失败 | 发送无链接摘要,记录 warning 到 message_summary |
---
## 十一、日志与安全
| 项 | 要求 |
| --- | --- |
| 日志脱敏 | 不打印 App Secret、tenant_access_token、完整手机号 |
| 入库脱敏 | 通知记录只保存脱敏接收人标识 |
| payload | 不保存完整富文本 payload |
| 错误信息 | 保存飞书错误摘要,避免保存敏感请求头 |
| 问答日志 | 保存问题、意图、对象和回答摘要,不保存完整回答 |
---
## 十二、实施顺序建议
| 顺序 | 内容 |
| --- | --- |
| 1 | 新增模型、迁移和 Admin |
| 2 | 实现用户映射解析和脱敏 |
| 3 | 实现飞书富文本构造 |
| 4 | 实现 tenant_access_token 获取与缓存 |
| 5 | 实现飞书消息 API 发送客户端 |
| 6 | 实现通知记录判重和落库 |
| 7 | 实现三个工作流 adapter |
| 8 | 接入三个工作流完成节点 |
| 9 | 批次详情页展示通知状态 |
| 10 | 实现问答预留查询服务和日志服务 |
| 11 | 补齐单元测试和集成测试 |

View File

@@ -0,0 +1,963 @@
# 第1章监管信息材料包生成详细设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md |
| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md |
| 数据库设计文档 | docs/3.数据库设计/5.第1章监管信息材料包生成.md |
| 参考详细设计 | docs/4.详细设计/3.产品关键信息提取与申报文件自动填表.md |
| 功能名称 | 第1章监管信息材料包生成 |
| 工作流编码 | regulatory_info_package |
| 所属模块 | 审核智能体 review_agent |
| 设计日期 | 2026-06-10 |
| 设计版本 | V1.0 |
---
## 一、详细设计目标
本详细设计用于指导 `regulatory_info_package` 独立工作流开发落地。系统根据用户上传或指定的产品说明书,抽取产品关键信息,基于 `docs/0.原始材料/第1章 监管信息` 下的样例模板生成第1章监管信息材料包并以 `第1章 监管信息(预生成版).zip` 作为对话摘要首位下载入口。
核心约束:
| 约束 | 说明 |
| --- | --- |
| 独立工作流 | 使用 `workflow_type=regulatory_info_package`,拥有独立批次、产物、通知和卡片 |
| 独立模块 | 新增 `review_agent/regulatory_info_package/`,与 `application_form_fill` 平级 |
| 模型集中 | Django 模型仍集中放在 `review_agent/models.py` |
| 节点幂等 | `WorkflowNodeRun` 必须按 `workflow_type + workflow_batch_id + node_code` 幂等创建或加唯一约束 |
| 输入优先级 | 用户消息指定文件名优先;其次 active 附件;再兼容最近成功文件汇总 |
| 模板固定 | 固定处理第1章监管信息 7 个模板 |
| 模板字段化 | 生成逻辑优先写 Word 内容控件 Tag 或稳定占位符,不以手工调整表格格式为前提 |
| 规则优先可演示 | 规则抽取可独立跑通LLM 失败最多重试 3 次,失败后继续 |
| 文档并发生成 | 工作流整体串行,`generate_docs` 节点内部每个文档可独立线程并发处理 |
| `.doc` 兜底 | 能力驱动:有 Word COM/UNO 时优先原生 `.doc`;无原生能力或原生失败时允许生成 `.docx` 兜底文件 |
| zip 只含成功文件 | zip 只打包成功或兜底成功的文件;失败文件不进入 zip |
| 高亮规则 | 缺失和 LLM-only 黄底;冲突黄底红字 |
| 追溯输出 | 用户下载 ExcelJSON 仅保存到后台 logs 目录 |
| 前端最小接入 | 不做多说明书选择 UI不确定时通过对话反问 |
---
## 二、代码结构设计
### 2.1 目录结构
```text
review_agent/
models.py
services.py
skill_router.py
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
```
### 2.2 文件职责
| 文件 | 职责 |
| --- | --- |
| constants.py | 工作流编码、节点定义、触发关键词、模板编码、状态常量 |
| schemas.py | dataclass 数据结构,如 `TemplateSpec``InstructionExtractResult``MergedField``GeneratedFileResult` |
| storage.py | 批次目录、子目录、hash、产物创建、路径安全校验 |
| events.py | 记录与序列化 `WorkflowEvent` |
| workflow.py | `RegulatoryInfoPackageWorkflowExecutor`、批次创建、工作流启动 |
| views.py | health、start、status、select-input 接口 |
| input_select.py | 根据用户消息、active 附件、文件汇总选择说明书 |
| template_config.py | YAML 加载、校验、hash |
| template_repository.py | 定位样例模板、复制到批次目录、审计字段 Tag/占位符 |
| instruction_extract.py | 说明书段落、章节、表格和组成成分表解析 |
| field_extract.py | 规则抽取与 LLM 抽取并行执行LLM 最多 3 次重试 |
| field_merge.py | 合并字段输出缺失、LLM-only、冲突和高亮决策 |
| standard_candidates.py | 从说明书抽标准号,调用现有知识库搜索候选 |
| document_writer.py | 文档适配器接口与通用高亮策略 |
| docx_document.py | `DocxDocumentAdapter`,处理 `.docx` |
| legacy_doc_document.py | `LegacyDocDocumentAdapter`,处理 `.doc` 原生写入与 `.docx` 兜底 |
| package_generate.py | 7 个文档生成策略,多线程生成文件 |
| traceability_export.py | 生成 `exports/traceability.xlsx``logs/traceability.json` |
| zip_export.py | 生成主下载 zip只包含成功文件 |
| summary.py | 构造助手回显zip 链接排首位 |
| notifier.py | 写专项通知记录,并调用统一通知服务 |
---
## 三、数据模型详细设计
模型放在 `review_agent/models.py`
### 3.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 | 所属对话 |
| user | 发起用户 |
| trigger_message | 触发消息 |
| source_attachment | 直接选中的说明书附件,可空 |
| source_summary_batch | 兼容文件汇总批次,可空 |
| source_summary_item_id | 文件汇总条目 ID可空 |
| batch_no | `RIP-YYYYMMDDHHMMSS-abcdef` |
| source_file_name | 说明书原文件名 |
| source_storage_path | 说明书存储路径 |
| product_name | 抽取产品名称 |
| output_zip_name | `第1章 监管信息(预生成版).zip` |
| generated_files | 7 个文件状态 |
| missing_fields | 缺失字段 |
| llm_only_fields | LLM-only 字段 |
| conflict_fields | 冲突字段 |
| risk_notes | 风险和降级提示 |
| adapter_summary | doc/docx 适配器实际执行摘要 |
| template_config_version/hash | 模板配置版本和 hash |
| work_dir | 批次工作目录 |
| is_deleted | 软删除 |
### 3.2 RegulatoryInfoPackageArtifact
```python
class RegulatoryInfoPackageArtifact(models.Model):
class ArtifactType(models.TextChoices):
TEMPLATE_COPY = "template_copy", "模板副本"
INSTRUCTION_EXTRACT = "instruction_extract", "说明书抽取结果"
FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果"
MERGED_FIELDS = "merged_fields", "合并字段"
GENERATED_DOCUMENT = "generated_document", "生成文件"
TRACEABILITY = "traceability", "追溯清单"
ZIP_PACKAGE = "zip_package", "ZIP包"
NOTIFICATION_RECORD = "notification_record", "通知记录"
```
`file_format` 包含:`json``excel``docx``doc``zip``markdown`
### 3.3 RegulatoryInfoPackageNotificationRecord
字段对齐自动填表通知记录:`batch``recipient``channel``export_ids``message_summary``send_status``retry_count``external_message_id``error_message``sent_at``is_deleted`
### 3.4 ExportedSummaryFile 扩展
`ExportedSummaryFile.ExportType` 增加:
```python
ZIP = "zip", "ZIP"
```
下载 MIME 按扩展名兜底:
| 条件 | MIME |
| --- | --- |
| zip | application/zip |
| .doc | application/msword |
| .docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
---
## 四、常量设计
### 4.1 工作流常量
```python
WORKFLOW_TYPE = "regulatory_info_package"
DEFAULT_ZIP_NAME = "第1章 监管信息(预生成版).zip"
REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS = [
("prepare", "准备资料", "regulatory_info_package"),
("template_copy", "复制模板", "regulatory_info_package"),
("text_extract", "抽取说明书", "regulatory_info_package"),
("field_extract", "抽取字段", "regulatory_info_package"),
("field_merge", "合并字段", "regulatory_info_package"),
("generate_docs", "生成材料", "regulatory_info_package"),
("highlight_review_items", "标记待确认", "regulatory_info_package"),
("trace_export", "追溯清单", "regulatory_info_package"),
("zip_export", "打包下载", "regulatory_info_package"),
("notify", "通知", "regulatory_info_package"),
("completed", "完成", "completed"),
]
```
### 4.2 触发关键词
```python
REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [
"根据说明书生成第1章监管信息",
"生成监管信息材料包",
"从说明书生成第1章材料",
"第1章监管信息",
"监管信息材料包",
]
```
### 4.3 文件状态
```python
GENERATED_FILE_SUCCESS = "success"
GENERATED_FILE_FALLBACK_SUCCESS = "fallback_success"
GENERATED_FILE_FAILED = "failed"
GENERATED_FILE_SKIPPED = "skipped"
```
---
## 五、核心数据结构
### 5.1 TemplateSpec
```python
@dataclass(frozen=True)
class TemplateSpec:
code: str
output_name: str
source_file: str
file_format: str
strategy: str
include_in_zip: bool
prefer_legacy_doc_native: bool = False
allow_docx_fallback: bool = True
fields: list[dict[str, Any]] = field(default_factory=list)
```
### 5.2 InstructionExtractResult
```python
@dataclass
class InstructionExtractResult:
source_file_name: str
paragraphs: list[str]
sections: dict[str, str]
tables: list[list[list[str]]]
component_tables: list["ComponentTable"]
front_text: str
```
### 5.3 ProductListRow
```python
@dataclass
class ProductListRow:
package_specification: str
item_no: str
composition: str
component_name: str
main_component: str
quantity: str
source_table_title: str
needs_review_fields: list[str] = field(default_factory=list)
```
其中 `item_no` 对应货号,本期固定 `/` 并黄底。
### 5.4 MergedField
```python
@dataclass
class MergedField:
key: str
label: str
value: str
source: str
evidence: str
confidence: float
highlight_reason: str = "none"
needs_review: bool = False
rule_value: str = ""
llm_value: str = ""
```
### 5.5 GeneratedFileResult
```python
@dataclass
class GeneratedFileResult:
template_code: str
file_name: str
requested_format: str
actual_format: str
status: str
path: str = ""
artifact_id: int | None = None
export_id: int | None = None
highlight_count: int = 0
missing_count: int = 0
llm_only_count: int = 0
error_message: str = ""
```
---
## 六、存储目录设计
```text
media/regulatory_info_package/{user_id}/{conversation_id}/{batch_no}/
templates/
logs/
instruction_extract.json
field_extract_result.json
merged_fields.json
doc_adapter_result.json
traceability.json
generated/
CH1.2 监管信息目录.docx
CH1.4 申请表.docx
CH1.5 产品列表.docx
CH1.9 产品申报前沟通的说明.docx
CH1.11.1 符合标准的清单.docx
CH1.11.5 真实性声明.docx
CH1.11.6 符合性声明.docx
exports/
traceability.xlsx
第1章 监管信息(预生成版).zip
```
说明:
| 目录 | 说明 |
| --- | --- |
| templates | 模板副本 |
| logs | 后台 JSON 产物,不作为用户主下载 |
| generated | 生成成功或兜底成功的单文件 |
| exports | 用户可下载的追溯 Excel 和 zip |
---
## 七、输入选择详细设计
### 7.1 选择优先级
`input_select.py` 的选择顺序:
1. 用户消息显式指定文件名时,按 active 附件名模糊匹配。
2. 当前对话 active 附件中文件名包含“说明书”的 `.docx`
3. 当前对话 active 附件中唯一 `.docx`
4. 最近成功 `FileSummaryBatch.items` 中包含“说明书”的 `.docx`
5. 多候选或无候选时返回 `InputSelectionResult(status="waiting_user")`
### 7.2 多候选处理
本期不新增在线选择弹窗。多候选时:
| 场景 | 处理 |
| --- | --- |
| 用户消息可模糊匹配唯一附件 | 直接选择 |
| 多个候选且无法确定 | 对话反问用户确认哪个说明书 |
| 无说明书 | 提示上传产品说明书 |
反问示例:
```text
我找到多个说明书候选请回复要使用的文件名A.docx、B.docx。
```
---
## 八、模板配置详细设计
配置路径:
```text
review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml
```
必须包含 7 个模板:
| code | source_file | strategy |
| --- | --- | --- |
| ch1_2_directory | CH1.2 监管信息目录.docx | directory |
| ch1_4_application_form | CH1.4 申请表.docx | application_form |
| ch1_5_product_list | CH1.5 产品列表.docx | product_list |
| ch1_9_pre_submission | CH1.9 产品申报前沟通的说明.doc | pre_submission |
| ch1_11_1_standard_list | CH1.11.1 符合标准的清单.docx | standard_list |
| ch1_11_5_authenticity | CH1.11.5 真实性声明.docx | authenticity_statement |
| ch1_11_6_compliance | CH1.11.6 符合性声明.docx | compliance_statement |
校验规则:
| 校验 | 说明 |
| --- | --- |
| version 必填 | 写入批次 |
| source_dir 存在 | 指向样例目录 |
| code 唯一 | 防止覆盖产物 |
| source_file 存在 | 缺失则配置错误 |
| strategy 合法 | 必须命中生成策略 |
| doc 模板标记 | `.doc` 模板需声明 `prefer_legacy_doc_native`,并配置允许 `.docx` 兜底 |
### 8.1 模板字段化约定
为避免生成时破坏 Word 表格、复选框、字号、缩进和合并单元格,本工作流优先使用字段化模板:
| 方式 | 使用场景 | 说明 |
| --- | --- | --- |
| Word 内容控件 Tag | 正式模板优先 | 在 Word 中为产品名、申请人、复选框、日期、说明文字等填写区设置稳定 Tag代码按 Tag 写入 |
| 稳定占位符 | 过渡方案 | 使用 `{{ product_name }}` 等不会影响版式的占位符,代码替换占位符所在 run |
| 行标签定位 | 兜底方案 | 仅用于未字段化的旧模板,必须保留原单元格、段落和 run 格式 |
模板配置中的字段目标优先级:
```yaml
targets:
- type: content_control_tag
tag: product_name
- type: placeholder
marker: "{{ product_name }}"
- type: table_row_label
label: 产品名称
```
模板加载时必须执行字段审计:关键字段缺少 Tag/占位符时给出清晰错误或降级说明;不得静默使用会破坏格式的整格重建策略。
---
## 九、字段抽取详细设计
### 9.1 规则抽取
规则抽取必须独立可用,覆盖:
| 字段 | 规则 |
| --- | --- |
| product_name | `【产品名称】` 下一段 |
| package_specification | `【包装规格】` 至下一章节 |
| intended_use | `【预期用途】` 至下一章节 |
| detection_principle | `【检测原理】` 至下一章节 |
| main_components | `【主要组成成分】` 下方表格摘要 |
| storage_condition_and_validity | `【储存条件及有效期】` 至下一章节 |
| sample_type | 样本要求章节中的“适用样本类型” |
| detection_targets | 预期用途/检测原理中的基因、病原体、靶标 |
| applicable_instruments | `【适用仪器】` 至下一章节 |
| test_method | `【检验方法】` 摘要 |
| standards | 正则抽取标准号 |
### 9.2 LLM 抽取与重试
`field_extract.py` 并行执行规则抽取和 LLM 抽取:
```text
ThreadPoolExecutor(max_workers=2)
-> rule_extract()
-> llm_extract_with_retry(max_attempts=3)
```
LLM 重试策略:
| 次数 | 间隔 |
| --- | --- |
| 第 1 次 | 立即 |
| 第 2 次 | 等待 1 秒 |
| 第 3 次 | 等待 2 秒 |
三次失败后:
| 产物 | 处理 |
| --- | --- |
| risk_notes | 增加 `llm_extract_failed` |
| logs/field_extract_result.json | 记录每次错误摘要 |
| 工作流 | 继续使用规则结果 |
LLM 不允许填企业信息、分类编码、管理类别、临床评价路径等说明书无法证明的内容。
### 9.3 字段合并
| 场景 | 写入值 | 高亮 | needs_review |
| --- | --- | --- | --- |
| rule 与 LLM 一致 | rule/LLM 值 | 否 | 否 |
| rule 与 LLM 冲突 | 规则优先或配置优先 | 黄底红字 | 是 |
| rule 缺失、LLM 命中 | LLM 值 | 黄底 | 是 |
| 全部缺失 | `/` | 黄底 | 是 |
---
## 十、文档适配器详细设计
### 10.1 统一接口
```python
class DocumentAdapter(Protocol):
def replace_text(self, old: str, new: str, *, highlight: bool = False, conflict: bool = False) -> int: ...
def fill_table_cell(self, row_label: str, value: str, *, highlight: bool = False, conflict: bool = False) -> bool: ...
def replace_table(self, marker: str, rows: list[ProductListRow], *, highlight_columns: list[str] | None = None) -> bool: ...
def save(self, path: Path) -> Path: ...
```
高亮规则:
| 类型 | 视觉 |
| --- | --- |
| missing | 黄色底色 |
| llm_only | 黄色底色 |
| conflict | 黄色底色 + 红色字体 |
### 10.2 DocxDocumentAdapter
实现能力:
| 方法 | 说明 |
| --- | --- |
| replace_text | 支持段落与表格中的文本替换,需处理 run 拆分 |
| fill_content_control | 按内容控件 Tag 填写文本、日期或复选框 |
| replace_placeholder | 按稳定占位符替换文本,保留占位符所在 run/段落格式 |
| fill_table_cell | 按行标签定位目标单元格,仅作为未字段化模板的兜底 |
| replace_table | 重建 CH1.5 产品列表表格 |
| apply_highlight | 使用 `w:shd` 设置黄色底色 |
| apply_conflict_style | 黄色底色 + 红字 |
### 10.3 LegacyDocDocumentAdapter
接口:
```python
class AdapterCapability:
adapter_name: str
supports_native_doc_write: bool
supports_docx_fallback: bool
status: str
error_message: str = ""
class LegacyDocDocumentAdapter:
@staticmethod
def detect_available_adapter() -> AdapterCapability: ...
```
执行顺序:
1. 执行能力探测Word COM、LibreOffice UNO 或其他可写 `.doc` 能力。
2. 有原生能力时优先尝试原生打开 `.doc` 并保存 `.doc`
3. 无原生能力或原生失败时,尝试生成同语义 `.docx` 兜底文件,再交给 `DocxDocumentAdapter`
4. 兜底成功时,输出 `CH1.9 产品申报前沟通的说明.docx`,状态为 `fallback_success`
5. 原生和兜底均失败时,该文件状态为 `failed`,不进入 zip。
兜底成功 `adapter_summary.doc`
```json
{
"requested_format": "doc",
"actual_format": "docx",
"adapter": "ConversionFallbackAdapter",
"status": "fallback_success"
}
```
---
## 十一、材料生成详细设计
### 11.1 generate_docs 节点并发
工作流节点仍串行执行,但 `generate_docs` 内部并发生成单文件:
```python
with ThreadPoolExecutor(max_workers=min(7, len(specs))) as executor:
futures = [executor.submit(generate_one_document, spec, context) for spec in specs]
```
并发注意事项:
| 注意事项 | 说明 |
| --- | --- |
| 每个文档使用独立模板副本 | 避免并发写同一文件 |
| 共享字段只读 | `merged_fields``product_list_rows` 不在子线程修改 |
| 数据库写入集中处理 | 子线程返回 `GeneratedFileResult`,主线程统一写 artifact/export |
| 异常隔离 | 单文件失败不影响其他文件 |
### 11.2 7 个生成策略
| 模板 | 输出规则 |
| --- | --- |
| CH1.2 | 替换产品名;页码沿用样例 |
| CH1.4 | 填产品名、包装规格、预期用途、组成、储存有效期、方法原理;企业/分类等缺失项 `/` 黄底 |
| CH1.5 | 按样例表头重建,货号 `/` 黄底 |
| CH1.9 | 优先 `.doc` 原生写入;失败则 `.docx` 兜底;兜底失败则不输出 |
| CH1.11.1 | 说明书标准号直接写;知识库候选只作为待确认高亮/追溯 |
| CH1.11.5 | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 |
| CH1.11.6 | 保留正文,替换产品名,公司名 `/` 黄底,日期当天 |
### 11.3 产品名缺失
规则和 LLM 都抽不到产品名称时:
| 项 | 处理 |
| --- | --- |
| 文件内容 | 产品名位置写 `/` 并黄底 |
| 批次状态 | 至少 `partial_success` |
| zip | 仍生成,包含成功文件 |
| 摘要 | 明确提示产品名称待确认 |
---
## 十二、追溯与 zip 设计
### 12.1 追溯 Excel
用户可下载:
```text
exports/traceability.xlsx
```
创建导出记录:
```text
export_category = traceability
export_type = excel
```
字段:
| 字段 | 说明 |
| --- | --- |
| target_file | 目标文件 |
| target_field | 目标字段 |
| final_value | 写入值 |
| extraction_source | rule、llm、missing、knowledge_candidate |
| evidence | 来源片段 |
| highlight_reason | missing、llm_only、conflict、rag_candidate |
| needs_review | 是否需复核 |
### 12.2 后台 JSON
JSON 产物仅写入 `logs/`,按需从后台查看:
```text
logs/instruction_extract.json
logs/field_extract_result.json
logs/merged_fields.json
logs/traceability.json
logs/doc_adapter_result.json
```
这些 JSON 产物写入 `RegulatoryInfoPackageArtifact`,但不作为用户主下载。
### 12.3 zip 打包
zip 文件名:
```text
第1章 监管信息(预生成版).zip
```
规则:
| 场景 | 是否进入 zip |
| --- | --- |
| 文件状态 `success` | 是 |
| 文件状态 `fallback_success` | 是 |
| 文件状态 `failed` | 否 |
| 文件状态 `skipped` | 否 |
`CH1.9 .doc` 兜底 `.docx` 成功zip 中放入:
```text
CH1.9 产品申报前沟通的说明.docx
```
---
## 十三、工作流详细设计
### 13.1 批次创建
```python
def create_regulatory_info_package_batch(
*,
conversation: Conversation,
user,
trigger_message: Message | None = None,
source_attachment: FileAttachment | None = None,
source_summary_batch: FileSummaryBatch | None = None,
source_summary_item_id: int | None = None,
) -> RegulatoryInfoPackageBatch:
```
创建后初始化 `REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS`
### 13.2 执行器
```python
class RegulatoryInfoPackageWorkflowExecutor:
def run(self) -> None: ...
def _nodes(self): ...
def _run_node(self, node: WorkflowNodeRun) -> None: ...
def _execute_node(self, node: WorkflowNodeRun) -> None: ...
```
节点执行:
| 节点 | 关键动作 |
| --- | --- |
| prepare | 确认说明书,或 waiting_user |
| template_copy | 复制 7 个模板 |
| template_audit | 审计模板字段 Tag/占位符,记录缺失和降级策略 |
| text_extract | 抽取说明书章节和表格 |
| field_extract | 规则 + LLM 并行抽取 |
| field_merge | 合并字段、高亮决策 |
| generate_docs | 多线程生成单文件 |
| highlight_review_items | 若生成策略已完成高亮,该节点记录确认结果即可 |
| trace_export | 写 Excel 和 logs JSON |
| zip_export | 打包成功/兜底成功文件 |
| notify | 写专项通知并调用统一通知 |
| completed | 写助手摘要 |
### 13.3 状态落定
| 条件 | 状态 |
| --- | --- |
| zip 成功且 7 个文件均 success/fallback_success | success |
| zip 成功但有 failed/skipped | partial_success |
| zip 失败但至少一个单文件成功 | partial_success |
| 全部文件失败或关键输入缺失 | failed |
| 多说明书候选等待确认 | waiting_user |
---
## 十四、路由与接口详细设计
### 14.1 skill_router.py
增加:
| 项 | 内容 |
| --- | --- |
| ROUTE_ACTIONS | 加入 `regulatory_info_package` |
| SkillRoute 属性 | `starts_regulatory_info_package` |
| deterministic route | 命中触发关键词直接返回 |
| LLM prompt | action 列表加入 `regulatory_info_package` |
### 14.2 services.py
`stream_message` 增加分支:
1. 调用 `select_instruction_input(conversation, content)`
2. 若多候选,回复反问,不启动工作流。
3. 若无候选,回复请上传说明书。
4. 若唯一候选,创建批次并启动工作流。
5. SSE 发送 `workflow_started`
### 14.3 views.py
接口:
```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/
```
`status` 返回:
| 字段 | 说明 |
| --- | --- |
| batch | 状态、产品名、缺失/LLM-only/冲突数量 |
| nodes | 节点状态 |
| generated_files | 7 个文件成功/失败/兜底状态 |
| exports | zip、单文件、Excel 下载 |
| risk_notes | 风险提示 |
| notifications | 通知 |
zip 不需要 `is_primary` 字段,前端或摘要按返回顺序把 zip 放首位。
---
## 十五、助手摘要设计
完成消息结构:
```markdown
已生成第1章监管信息材料包。
批次号RIP-...
产品名称:...
状态success / partial_success
主下载:[第1章 监管信息(预生成版).zip](...)
| 文件 | 状态 | 下载/原因 |
| --- | --- | --- |
| CH1.2 监管信息目录.docx | 成功 | 下载 |
| CH1.9 产品申报前沟通的说明.docx | 兜底成功 | 下载 |
| CH1.11.1 符合标准的清单.docx | 失败 | 失败原因 |
待确认:缺失项 X 个LLM复核项 Y 个,冲突项 Z 个。
```
要求:
| 要求 | 说明 |
| --- | --- |
| zip 首位 | zip 链接必须在单文件列表之前 |
| 失败可见 | 失败文件展示状态和原因,无下载链接 |
| 兜底提示 | `.doc -> .docx` 时显示“兜底成功” |
| 待确认摘要 | 展示 missing、llm_only、conflict 数量 |
---
## 十六、前端详细设计
### 16.1 模板
`templates/home.html` 增加工具 chip
```html
<button
class="tool-chip"
type="button"
data-prompt-template="根据说明书生成第1章监管信息"
>第1章监管信息</button>
```
`summaryPanel` 增加:
```html
data-regulatory-info-package-status-url-template="/api/review-agent/regulatory-info-package/__batch_id__/status/"
```
### 16.2 app.js
增加:
| 位置 | 处理 |
| --- | --- |
| workflow type 判断 | 支持 `regulatory_info_package` |
| 状态 URL 选择 | 使用 `data-regulatory-info-package-status-url-template` |
| 终态判断 | success、partial_success、failed、waiting_user |
| 导出展示 | 直接按 exports 返回顺序展示zip 在后端排首位 |
### 16.3 不做选择 UI
多说明书候选时,本期不做弹窗。通过对话反问用户确认文件名。
---
## 十七、导出下载权限
`file_summary.views._export_for_user` 增加:
```python
if exported.workflow_type == "regulatory_info_package":
allowed = RegulatoryInfoPackageBatch.objects.filter(
pk=exported.workflow_batch_id,
conversation__user=user,
is_deleted=False,
).exists()
return exported if allowed else None
```
下载 content type 增加 zip 和 `.doc` 后缀判断。
---
## 十八、通知详细设计
`notifier.py`
```python
def notify_completion(batch: RegulatoryInfoPackageBatch, exports: list[ExportedSummaryFile]) -> RegulatoryInfoPackageNotificationRecord:
```
处理:
| 步骤 | 说明 |
| --- | --- |
| 创建专项通知记录 | 写 `RegulatoryInfoPackageNotificationRecord` |
| 调用统一通知 | `dispatch_workflow_notification(build_regulatory_info_package_context(batch))` |
| 捕获异常 | 通知失败写记录和 risk_notes不影响批次下载 |
---
## 十九、测试详细设计
| 测试文件 | 覆盖 |
| --- | --- |
| test_regulatory_info_package_models.py | 三张表、zip export type、基础关联 |
| test_regulatory_info_package_trigger.py | 固定关键词与 LLM action |
| test_regulatory_info_package_input_select.py | 文件名模糊匹配、active 附件、多候选反问 |
| test_regulatory_info_package_template_config.py | YAML 加载、模板缺失、code 唯一 |
| test_regulatory_info_package_instruction_extract.py | 说明书章节和组成表抽取 |
| test_regulatory_info_package_field_extract.py | 规则抽取、LLM 三次重试、失败降级 |
| test_regulatory_info_package_field_merge.py | missing、llm_only、conflict |
| test_regulatory_info_package_docx_writer.py | 替换、表格填充、黄底、红字 |
| test_regulatory_info_package_legacy_doc.py | adapter 探测、docx 兜底、失败状态 |
| test_regulatory_info_package_package_generate.py | 7 文件生成结果、多线程异常隔离 |
| test_regulatory_info_package_traceability.py | Excel 追溯和 logs JSON |
| test_regulatory_info_package_zip.py | zip 只包含 success/fallback_success |
| test_regulatory_info_package_workflow.py | 节点流转、partial_success、waiting_user |
| test_regulatory_info_package_views.py | start/status/download 权限 |
| test_regulatory_info_package_frontend.py | chip、卡片、状态 URL |
---
## 二十、异常处理矩阵
| 异常 | 批次状态 | 处理 |
| --- | --- | --- |
| 无说明书 | waiting_user 或不创建批次 | 提示上传说明书 |
| 多候选无法匹配 | waiting_user 或不创建批次 | 反问确认文件名 |
| 模板缺失 | failed | 列出缺失模板 |
| 规则抽取失败 | partial_success/continue | 使用 LLM 结果 |
| LLM 三次失败 | continue | 使用规则结果,写 risk_notes |
| 产品名缺失 | partial_success | 写 `/` 黄底,继续生成 zip |
| 单个 docx 文件生成失败 | partial_success | 不进入 zip摘要展示失败 |
| CH1.9 doc 原生失败但 docx 兜底成功 | success/partial_success | 状态 fallback_success进入 zip |
| CH1.9 doc 和 docx 兜底均失败 | partial_success | 不进入 zip摘要展示失败 |
| traceability.xlsx 失败 | partial_success | 不阻断 zip |
| zip 失败 | partial_success | 保留单文件下载 |
| 通知失败 | 不影响主状态 | 写通知失败和 risk_notes |
---
## 二十一、设计结论
| 编号 | 结论 |
| --- | --- |
| D1 | 详细设计文档路径为 `docs/4.详细设计/5.第1章监管信息材料包生成.md` |
| D2 | 模型集中在 `review_agent/models.py`,业务模块为 `review_agent/regulatory_info_package/` |
| D3 | `.doc` 采用能力驱动策略:探测 Word COM/UNO 等原生能力,有能力时优先原生处理 |
| D4 | `.doc` 无原生能力或原生失败时允许 `.docx` 兜底;兜底文件名为 `CH1.9 产品申报前沟通的说明.docx` |
| D5 | zip 只包含成功或兜底成功文件,失败文件不进入 zip |
| D6 | LLM 最多重试 3 次,失败后使用规则结果继续 |
| D7 | 缺失和 LLM-only 黄底,冲突黄底红字 |
| D8 | 产品列表使用 `ProductListRow`,货号固定 `/` 黄底 |
| D9 | 标准清单只复用现有知识库能力,不新增独立 RAG 流程 |
| D10 | 前端最小接入,不做说明书选择弹窗 |
| D11 | 追溯 Excel 可下载JSON 只放后台 logs |
| D12 | 本期不新增字段级数据库表 |
| D13 | 工作流串行,文档生成节点内部可多线程 |
| D14 | 模板优先字段化,正式填充路径使用内容控件 Tag 或稳定占位符,行标签定位仅作为兜底 |
| D15 | 本轮只产出详细设计,不写代码、不生成迁移 |

View File

@@ -0,0 +1,583 @@
# 飞书通知与问答接入开发计划
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md |
| 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md |
| 详细设计文档 | docs/3.详细设计/4.飞书通知与问答接入.md |
| 数据库设计文档 | docs/4.数据库设计/4.飞书通知与问答接入.md |
| 功能名称 | 飞书通知与问答接入 |
| 所属模块 | 审核智能体 review_agent |
| 执行方式 | 单人开发 + Codex 自动化执行 |
| 计划日期 | 2026-06-07 |
| 计划版本 | V1.0 |
---
## Codex 自动执行说明
本文件用于 Codex 自动执行开发任务。执行时必须按阶段顺序推进,不得跳过测试、不得直接请求真实飞书接口作为自动化测试、不得把真实 App Secret 或 token 写入代码库。
执行规则:
| 规则 | 要求 |
| --- | --- |
| 执行顺序 | 必须从 FS-0 到 FS-8 顺序执行,前一阶段验证未通过不得进入下一阶段 |
| TDD | 每个服务、模型、命令和页面展示任务必须先写失败测试,再实现代码,再运行测试确认通过 |
| 外部 API | 自动化测试必须 mock 飞书 token API 和消息 API真实飞书只通过 `send_test_feishu_notification` 手动命令验证 |
| 凭证安全 | 不得提交真实 `FEISHU_APP_ID``FEISHU_APP_SECRET``tenant_access_token`、用户 open_id/user_id |
| 失败处理 | 如测试失败,先定位是否由本阶段改动引起;不得修改无关功能绕过测试 |
| 工作区安全 | 不得回滚用户已有变更;如遇到同文件用户改动,先阅读并兼容 |
| 提交节奏 | 每个阶段完成并通过阶段验证后再提交,提交信息参考“建议提交切分” |
| 实现边界 | 首期只做指定个人账号私聊通知和问答预留不得扩展外部群聊、事件订阅、LLM 问答解析 |
自动执行入口建议:
```text
请按 docs/5.开发计划/4.飞书通知与问答接入.md 从 FS-0 开始逐阶段执行。
每个阶段必须先写测试、运行失败、实现、运行通过,再进入下一阶段。
真实飞书 API 只能通过手动 management command 验证pytest 中必须 mock。
```
---
## 一、开发计划目标
本开发计划用于指导“飞书通知与问答接入”首期开发。首期目标是通过飞书官方智能体/应用机器人接口,把系统中三个工作流的结束结果发送到指定个人飞书账号,并为后续飞书内问答建立可测试的最小服务边界。
本期必须完成:
| 类别 | 内容 |
| --- | --- |
| 真实飞书通知 | 使用 App ID/App Secret 获取 `tenant_access_token`,调用飞书消息 API 发送私聊通知 |
| 指定个人账号 | 通过 `.env` 配置 `FEISHU_DEFAULT_USER_OPEN_ID``FEISHU_DEFAULT_USER_ID`,首期优先发给该账号 |
| 三流程接入 | 自动汇总、法规核查、自动填表三个流程完成后均触发通知 |
| 数据库记录 | 新增统一通知记录表、飞书用户映射表、token 缓存表、问答日志表 |
| 页面展示 | 三个流程结果页或详情区域展示飞书通知状态 |
| 问答预留 | 建表、实现批次摘要查询、简单规则意图解析、本地模拟问答命令 |
| 测试策略 | 关键服务严格 TDD自动化测试 mock 飞书 API真实飞书发送通过 management command 手动验证 |
本期明确不做:
| 类别 | 内容 |
| --- | --- |
| 外部群聊接入 | 暂不向群聊发送通知,不做群内 @ |
| 飞书事件订阅 | 暂不接收飞书回调,不实现真实私聊问答事件入口 |
| 手动重发 | 页面和 Admin 暂不提供重发按钮 |
| 自动后台重试 | 通知失败只记录;成功才判重,失败允许后续再次发送 |
| LLM 问答解析 | 问答预留只做简单规则解析,不接 LLM |
---
## 二、已确认开发规则
| 规则项 | 内容 |
| --- | --- |
| 主接入方式 | 飞书官方智能体/应用机器人消息 API |
| 凭证配置 | `.env` 提供 `FEISHU_APP_ID``FEISHU_APP_SECRET` |
| 接收人配置 | `.env` + Django Admin 都做;首期发送优先使用 `.env` 指定个人账号 |
| 接收人优先级 | `FEISHU_DEFAULT_USER_OPEN_ID` > `FEISHU_DEFAULT_USER_ID` |
| token 缓存 | 数据库缓存 `tenant_access_token` 和过期时间 |
| 通知记录 | 新增统一 `WorkflowNotificationRecord`,三个流程都写入 |
| 判重策略 | 同一批次、同一流程、同一状态,只有成功记录才判重;失败后允许再次发送 |
| 系统链接 | 新增 `PUBLIC_BASE_URL`,默认 `http://127.0.0.1:8000` |
| 页面展示 | 三个流程结果页或详情区域展示通知状态 |
| 真实 API 测试 | 自动化测试全部 mock新增 management command 手动发送真实测试消息 |
| TDD | 每个核心模块先写测试再实现 |
| 环境变量说明 | 写变量名和用途,不写真实值 |
| 阶段提交 | 模型、服务、工作流、页面、命令、测试分阶段提交 |
| 问答预留 | 建 `FeishuQuestionLog`,实现摘要查询、规则解析和本地模拟命令 |
---
## 三、总体验收标准
| 类别 | 完成标准 |
| --- | --- |
| 配置 | `.env` 支持 `FEISHU_APP_ID``FEISHU_APP_SECRET``FEISHU_DEFAULT_USER_OPEN_ID` / `FEISHU_DEFAULT_USER_ID``PUBLIC_BASE_URL` |
| token | 系统可获取、缓存、过期刷新 `tenant_access_token`token API 失败会记录失败通知 |
| 发送 | 手动命令可向指定个人账号发送真实测试消息 |
| 通知 | 三个流程完成后均创建通知记录,并在启用配置时调用飞书消息 API |
| 判重 | 成功记录存在时,同一批次/流程/状态不重复发送;失败记录不阻止再次发送 |
| 失败隔离 | 飞书发送失败不影响业务工作流完成 |
| 页面 | 三个流程结果页或详情区域能看到通知通道、接收人、状态、时间、失败原因 |
| 问答预留 | 本地模拟命令可解析“最新/最近/批次号/工作流关键词”,返回批次摘要并记录日志 |
| 权限 | 普通用户只能查询自己的批次摘要;管理员可查全部 |
| 回归 | 文件汇总、法规核查、自动填表既有测试不回归 |
---
## 四、阶段总览
| 阶段 | 名称 | 目标 | 阶段验收 |
| --- | --- | --- | --- |
| FS-0 | 准备与基线 | 确认文档和测试基线 | `python manage.py check` 与关键现有测试通过 |
| FS-1 | 数据模型与配置 | 新增通知、映射、token、问答日志模型和环境配置 | migration、模型测试通过 |
| FS-2 | 飞书 API 基础服务 | token 获取缓存、接收人解析、消息构造、消息 API client | 服务单测通过,全部 mock 外部 HTTP |
| FS-3 | 通知调度与记录 | 统一通知上下文、判重、成功/失败/disabled 落库 | 通知服务测试通过 |
| FS-4 | 三流程接入 | 自动汇总、法规核查、自动填表完成后触发通知 | 三流程通知集成测试通过 |
| FS-5 | 页面展示 | 批次详情或结果区域展示通知状态 | 页面/视图测试通过 |
| FS-6 | 手动真实测试命令 | management command 发送真实飞书测试消息 | 本地配置后可向个人账号发消息 |
| FS-7 | 问答预留能力 | 批次摘要查询、规则意图解析、模拟问答命令、问答日志 | 问答预留测试通过 |
| FS-8 | 文档与全量回归 | 更新环境变量说明,运行全量相关测试 | 回归通过,计划完成 |
---
## 五、FS-0 准备与基线
### FS-0-001 确认开发文档和当前工作区
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 准备 / Git |
| 前置任务 | 无 |
| 涉及文件 | 文档文件,不改代码 |
| 目标 | 确认需求、功能、数据库、详细设计和开发计划均存在,并记录当前工作区状态 |
| 开发步骤 | 1. 检查 `git status --short`2. 确认四份设计文档与本开发计划存在3. 确认当前未提交变更均为文档或用户已有变更4. 不回滚任何用户变更 |
| 验收标准 | 工作区状态清楚,可进入开发 |
| 验证命令 | `git status --short` |
| Codex 执行提示 | 请先确认飞书接入四份设计文档和开发计划存在,检查工作区状态,不要回滚用户已有变更。 |
### FS-0-002 运行基线测试
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 回归 |
| 前置任务 | FS-0-001 |
| 涉及文件 | 无固定文件 |
| 目标 | 确认开发前现有主流程可运行 |
| 开发步骤 | 1. 运行 Django check2. 运行通知相关旧测试3. 运行三个工作流关键测试4. 若失败,判断是否既有问题并记录 |
| 验收标准 | 基线通过,或既有失败已记录且不与本功能冲突 |
| 验证命令 | `python manage.py check`; `pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` |
| Codex 执行提示 | 请运行 Django check 和现有通知/工作流关键测试,确认开发前基线。 |
---
## 六、FS-1 数据模型与配置
### FS-1-001 新增飞书接入 ORM 模型测试
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 数据库 |
| 前置任务 | FS-0 |
| 涉及文件 | `tests/test_feishu_models.py` |
| 目标 | 先写失败测试覆盖飞书用户映射、token 缓存、统一通知记录、问答日志 |
| 开发步骤 | 1. 新增 `test_feishu_user_mapping_preferred_identifier`2. 新增 `test_feishu_access_token_cache_expiry`3. 新增 `test_workflow_notification_success_dedupe_only_success`4. 新增 `test_feishu_question_log_records_summary_without_full_answer` |
| 验收标准 | 新测试因模型不存在而失败 |
| 验证命令 | `pytest tests/test_feishu_models.py -q` |
| Codex 执行提示 | 请先为飞书相关模型写失败测试,覆盖接收人标识优先级、数据库 token 缓存、成功判重和问答日志摘要。 |
### FS-1-002 新增模型
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 数据库 / 后端 |
| 前置任务 | FS-1-001 |
| 涉及文件 | `review_agent/models.py` |
| 目标 | 新增 `FeishuUserMapping``FeishuAccessTokenCache``WorkflowNotificationRecord``FeishuQuestionLog` |
| 开发步骤 | 1. `FeishuUserMapping` 关联系统用户,支持 open_id、user_id、mobile、is_active2. `FeishuAccessTokenCache` 保存 token、expires_at、app_id_hash、error_message3. `WorkflowNotificationRecord` 保存 workflow_type、batch_id、batch_no、status、channel、target、send_status、summary、error、sent_at4. `FeishuQuestionLog` 保存问题、意图、查询对象、回答摘要、权限结果和状态5. 添加索引和模型方法 |
| 验收标准 | `python manage.py check` 通过 |
| 验证命令 | `python manage.py check` |
| Codex 执行提示 | 请按数据库设计新增四个模型。注意 token 需要数据库缓存,通知判重只对 success 生效。 |
### FS-1-003 生成迁移并通过模型测试
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 数据库 / 测试 |
| 前置任务 | FS-1-002 |
| 涉及文件 | `review_agent/migrations/``tests/test_feishu_models.py` |
| 目标 | 生成 migration模型测试全部通过 |
| 开发步骤 | 1. 运行 makemigrations2. 检查 migration 只包含飞书相关表3. 运行 migrate4. 运行模型测试 |
| 验收标准 | migration 可执行,模型测试通过 |
| 验证命令 | `python manage.py makemigrations review_agent`; `python manage.py migrate`; `pytest tests/test_feishu_models.py -q` |
| Codex 执行提示 | 请生成飞书相关模型迁移并运行模型测试。 |
### FS-1-004 注册 Admin 和配置项
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后台 / 配置 |
| 前置任务 | FS-1-003 |
| 涉及文件 | `review_agent/admin.py``config/settings.py``.env.example` 或 README |
| 目标 | Admin 可维护用户映射settings 暴露飞书配置;文档只写变量名不写真实值 |
| 开发步骤 | 1. 注册 `FeishuUserMapping``WorkflowNotificationRecord``FeishuAccessTokenCache``FeishuQuestionLog`2. settings 读取 `FEISHU_NOTIFY_ENABLED``FEISHU_APP_ID``FEISHU_APP_SECRET``FEISHU_DEFAULT_USER_OPEN_ID``FEISHU_DEFAULT_USER_ID``FEISHU_DEFAULT_TARGET_NAME``PUBLIC_BASE_URL`3. 默认 `PUBLIC_BASE_URL=http://127.0.0.1:8000`4. 在说明文件中加入变量名和用途 |
| 验收标准 | Django check 通过Admin 列表可展示字段 |
| 验证命令 | `python manage.py check` |
| Codex 执行提示 | 请注册飞书相关模型到 Admin并新增环境变量配置说明不要写入真实凭证。 |
### FS-1 阶段验证
```bash
python manage.py check
pytest tests/test_feishu_models.py -q
```
---
## 七、FS-2 飞书 API 基础服务
### FS-2-001 token 服务 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 服务 |
| 前置任务 | FS-1 |
| 涉及文件 | `tests/test_feishu_api_services.py` |
| 目标 | 先写 token 获取、缓存、过期刷新、失败记录测试 |
| 开发步骤 | 1. mock 飞书 token HTTP 成功2. 测试首次获取后写数据库缓存3. 测试未过期时不再请求 HTTP4. 测试过期后重新请求5. 测试 token API 失败返回错误对象 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_api_services.py -k token -q` |
| Codex 执行提示 | 请先写飞书 tenant_access_token 服务测试,外部 HTTP 必须 mock。 |
### FS-2-002 实现 token 服务
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 服务 |
| 前置任务 | FS-2-001 |
| 涉及文件 | `review_agent/notifications/feishu_token.py` |
| 目标 | 使用 App ID/App Secret 获取并数据库缓存 `tenant_access_token` |
| 开发步骤 | 1. 定义 `FeishuTokenResult`2. 检查配置缺失3. 查询未过期数据库缓存4. 调用 token API5. 保存 token 和 expires_at6. token 失败时返回错误,不抛出到业务流程 |
| 验收标准 | token 服务测试通过 |
| 验证命令 | `pytest tests/test_feishu_api_services.py -k token -q` |
| Codex 执行提示 | 请实现 token 服务,缓存放数据库,不打印 App Secret 和 token。 |
### FS-2-003 接收人解析和消息构造 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 服务 |
| 前置任务 | FS-2-002 |
| 涉及文件 | `tests/test_feishu_api_services.py` |
| 目标 | 测试指定个人接收人优先级、配置缺失、富文本消息摘要 |
| 开发步骤 | 1. 测试 `FEISHU_DEFAULT_USER_OPEN_ID` 优先2. 测试无 open_id 时使用 `FEISHU_DEFAULT_USER_ID`3. 测试均缺失返回 `recipient_missing`4. 测试消息包含流程、批次、状态、摘要、链接和发起人 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_api_services.py -k 'recipient or message' -q` |
| Codex 执行提示 | 请先写接收人解析和富文本消息构造测试。 |
### FS-2-004 实现接收人解析和消息构造
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 服务 |
| 前置任务 | FS-2-003 |
| 涉及文件 | `review_agent/notifications/recipient.py``review_agent/notifications/message_builder.py``review_agent/notifications/context.py` |
| 目标 | 生成统一通知上下文、指定个人接收人和飞书富文本 payload |
| 开发步骤 | 1. 定义 `NotificationContext`2. 定义 `ResolvedFeishuTarget`3. 实现 `resolve_configured_personal_recipient()`4. 实现 `build_feishu_post_message()`5. 实现 `build_message_summary()`6. 链接使用 `PUBLIC_BASE_URL` |
| 验收标准 | 接收人和消息构造测试通过 |
| 验证命令 | `pytest tests/test_feishu_api_services.py -k 'recipient or message' -q` |
| Codex 执行提示 | 请实现通知上下文、接收人解析和飞书富文本消息构造。首期不需要群 @。 |
### FS-2-005 消息 API client TDD 与实现
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 测试 |
| 前置任务 | FS-2-004 |
| 涉及文件 | `tests/test_feishu_api_services.py``review_agent/notifications/feishu_message_api.py` |
| 目标 | mock 飞书消息 API完成成功、超时、错误码、token 失效重试一次 |
| 开发步骤 | 1. 写成功测试,断言请求携带 Authorization2. 写非 0 code 测试3. 写超时测试4. 写 token 失效后刷新 token 并同步重试一次测试5. 实现 `send_personal_message()` |
| 验收标准 | 消息 API client 测试通过 |
| 验证命令 | `pytest tests/test_feishu_api_services.py -q` |
| Codex 执行提示 | 请用 mock HTTP 实现飞书消息 API client。自动化测试不得请求真实飞书。 |
### FS-2 阶段验证
```bash
pytest tests/test_feishu_api_services.py -q
```
---
## 八、FS-3 通知调度与记录
### FS-3-001 通知记录服务 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 服务 |
| 前置任务 | FS-2 |
| 涉及文件 | `tests/test_feishu_notification_dispatcher.py` |
| 目标 | 先写通知调度、成功判重、失败允许再次发送、disabled 记录测试 |
| 开发步骤 | 1. 测试通知关闭写 disabled2. 测试发送成功写 success3. 测试已有 success 时不再调用 API4. 测试已有 failed 时允许再次调用 API5. 测试 token 失败写 failed |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_notification_dispatcher.py -q` |
| Codex 执行提示 | 请先写统一通知调度测试,重点覆盖成功判重和失败可重试。 |
### FS-3-002 实现通知记录和 dispatcher
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 服务 |
| 前置任务 | FS-3-001 |
| 涉及文件 | `review_agent/notifications/records.py``review_agent/notifications/dispatcher.py` |
| 目标 | 实现统一通知调度入口 |
| 开发步骤 | 1. 实现 `already_successfully_sent(dedupe_key)`2. 实现 disabled、success、failed 记录创建3. 实现 `dispatch_workflow_notification(context)`4. 捕获服务异常并写 failed5. 不让异常冒泡阻断工作流 |
| 验收标准 | dispatcher 测试通过 |
| 验证命令 | `pytest tests/test_feishu_notification_dispatcher.py -q` |
| Codex 执行提示 | 请实现统一通知调度和记录落库。注意 success 才判重failed 不判重。 |
### FS-3 阶段验证
```bash
pytest tests/test_feishu_notification_dispatcher.py -q
```
---
## 九、FS-4 三流程接入
### FS-4-001 工作流 adapter TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 集成 |
| 前置任务 | FS-3 |
| 涉及文件 | `tests/test_feishu_workflow_adapters.py` |
| 目标 | 测试自动汇总、法规核查、自动填表三类批次能生成正确通知上下文 |
| 开发步骤 | 1. 构造 `FileSummaryBatch` 和 items断言文件摘要2. 构造 `RegulatoryReviewBatch` 和 issues断言风险摘要3. 构造 `ApplicationFormFillBatch` 和 exports断言导出/冲突摘要4. 断言 result_url 使用 `PUBLIC_BASE_URL` |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_workflow_adapters.py -q` |
| Codex 执行提示 | 请先写三个工作流 adapter 的测试。 |
### FS-4-002 实现工作流 adapters
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 服务 |
| 前置任务 | FS-4-001 |
| 涉及文件 | `review_agent/notifications/workflow_adapters.py` |
| 目标 | 三个工作流批次转换为 `NotificationContext` |
| 开发步骤 | 1. 实现 `build_file_summary_context()`2. 实现 `build_regulatory_review_context()`3. 实现 `build_application_form_fill_context()`4. 控制摘要长度5. 处理 partial_success 和 failed |
| 验收标准 | adapter 测试通过 |
| 验证命令 | `pytest tests/test_feishu_workflow_adapters.py -q` |
| Codex 执行提示 | 请实现三个工作流通知上下文 adapter。 |
### FS-4-003 接入三个工作流完成节点
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 工作流 |
| 前置任务 | FS-4-002 |
| 涉及文件 | `review_agent/file_summary/workflow.py``review_agent/regulatory_review/workflow.py``review_agent/application_form_fill/workflow.py` |
| 目标 | 三个工作流完成后调用通知 dispatcher |
| 开发步骤 | 1. 自动汇总成功/失败状态落定后触发通知2. 法规核查报告和风险落库后触发通知3. 自动填表 notify 节点改为统一通知服务4. 捕获通知异常并记录非阻断错误5. 保留现有 mock 测试兼容 |
| 验收标准 | 三流程通知集成测试通过 |
| 验证命令 | `pytest tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` |
| Codex 执行提示 | 请把统一通知服务接入三个工作流完成节点,通知失败不得影响业务状态。 |
### FS-4 阶段验证
```bash
pytest tests/test_feishu_workflow_adapters.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py
```
---
## 十、FS-5 页面展示
### FS-5-001 通知状态展示测试
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 前端 |
| 前置任务 | FS-4 |
| 涉及文件 | `tests/test_file_summary_frontend.py``tests/test_regulatory_frontend.py``tests/test_application_form_fill_frontend.py` |
| 目标 | 测试三个流程页面或结果区域展示飞书通知状态 |
| 开发步骤 | 1. 准备 success 通知记录断言页面出现“飞书通知已发送”2. 准备 failed 记录断言出现失败原因3. 无记录时展示“暂无飞书通知记录”或不破坏页面 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu` |
| Codex 执行提示 | 请先写三个流程通知状态展示测试。 |
### FS-5-002 实现通知状态 presenter 和页面展示
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 前端 |
| 前置任务 | FS-5-001 |
| 涉及文件 | `review_agent/notifications/presenter.py``review_agent/*/views.py``templates/home.html` 或相关模板 |
| 目标 | 页面展示通知状态、接收人、发送时间、失败原因 |
| 开发步骤 | 1. 实现 `get_notification_records(workflow_type, batch_id)`2. 在三个流程视图上下文中加入通知记录3. 模板展示最近一条通知状态4. 保持页面无记录时兼容 |
| 验收标准 | 页面展示测试通过 |
| 验证命令 | `pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu` |
| Codex 执行提示 | 请实现通知状态 presenter并在三个流程结果页展示最近飞书通知状态。 |
### FS-5 阶段验证
```bash
pytest tests/test_file_summary_frontend.py tests/test_regulatory_frontend.py tests/test_application_form_fill_frontend.py -k feishu
```
---
## 十一、FS-6 手动真实测试命令
### FS-6-001 测试命令 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 命令 |
| 前置任务 | FS-5 |
| 涉及文件 | `tests/test_feishu_management_commands.py` |
| 目标 | 测试 management command 构造测试通知并调用 dispatcher |
| 开发步骤 | 1. mock dispatcher2. 执行 `send_test_feishu_notification --username owner`3. 断言构造测试上下文4. 测试缺少用户时报错 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_management_commands.py -q` |
| Codex 执行提示 | 请先写真实飞书测试命令的自动化测试dispatcher 要 mock。 |
### FS-6-002 实现发送测试消息命令
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 运维 / 命令 |
| 前置任务 | FS-6-001 |
| 涉及文件 | `review_agent/management/commands/send_test_feishu_notification.py` |
| 目标 | 本地可手动向指定个人飞书账号发送真实测试消息 |
| 开发步骤 | 1. 支持 `--username`2. 构造 workflow_type=`manual_test``NotificationContext`3. 调用 dispatcher4. 输出 send_status、target、error_message5. 不打印 token 和 App Secret |
| 验收标准 | 命令测试通过;本地配置真实凭证后可手动验证 |
| 验证命令 | `pytest tests/test_feishu_management_commands.py -q`; `python manage.py send_test_feishu_notification --username owner` |
| Codex 执行提示 | 请实现发送测试飞书通知的 management command。自动测试 mock dispatcher真实发送只手动运行。 |
### FS-6 阶段验证
```bash
pytest tests/test_feishu_management_commands.py -q
```
手动验证命令:
```bash
python manage.py send_test_feishu_notification --username owner
```
---
## 十二、FS-7 问答预留能力
### FS-7-001 批次摘要查询服务 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 服务 |
| 前置任务 | FS-6 |
| 涉及文件 | `tests/test_feishu_question_reserved.py` |
| 目标 | 测试按批次号、latest、工作流类型查询三个流程摘要 |
| 开发步骤 | 1. 普通用户查询自己的最新法规核查批次2. 普通用户不能查询他人批次3. 管理员可查全部4. 按批次号精确查询5. 返回状态、摘要、链接 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k query -q` |
| Codex 执行提示 | 请先写飞书问答预留的批次摘要查询测试。 |
### FS-7-002 实现批次摘要查询服务
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 服务 |
| 前置任务 | FS-7-001 |
| 涉及文件 | `review_agent/feishu_questions/query.py``review_agent/feishu_questions/permissions.py` |
| 目标 | 支持三个工作流的摘要查询和权限过滤 |
| 开发步骤 | 1. 实现管理员/普通用户权限过滤2. 实现 batch_no 查询3. 实现 latest 查询4. 实现 workflow_type 关键词映射5. 返回统一摘要 dict |
| 验收标准 | 查询服务测试通过 |
| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k query -q` |
| Codex 执行提示 | 请实现问答预留查询服务,普通用户只能查自己的批次,管理员可查全部。 |
### FS-7-003 简单意图解析和日志 TDD
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 服务 |
| 前置任务 | FS-7-002 |
| 涉及文件 | `tests/test_feishu_question_reserved.py` |
| 目标 | 测试规则解析“最新/最近/批次号/工作流关键词”,并记录问答日志 |
| 开发步骤 | 1. 识别 `RR-``AFF-``FS-` 批次号2. 识别“最新/最近”3. 识别“法规核查/自动填表/自动汇总”4. 记录 `FeishuQuestionLog`,不保存完整回答正文 |
| 验收标准 | 测试先失败 |
| 验证命令 | `pytest tests/test_feishu_question_reserved.py -k 'intent or log' -q` |
| Codex 执行提示 | 请先写简单规则意图解析和问答日志测试,不接 LLM。 |
### FS-7-004 实现意图解析、问答服务和模拟命令
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 后端 / 命令 |
| 前置任务 | FS-7-003 |
| 涉及文件 | `review_agent/feishu_questions/intent.py``review_agent/feishu_questions/service.py``review_agent/management/commands/feishu_question_simulate.py` |
| 目标 | 本地模拟飞书问答输入,返回批次摘要并记录日志 |
| 开发步骤 | 1. 实现 `parse_question_intent(text)`2. 实现 `answer_question(user, text)`3. 写入 `FeishuQuestionLog`4. 实现命令 `python manage.py feishu_question_simulate --username owner "查最新法规核查"`5. 输出回答摘要 |
| 验收标准 | 问答预留测试和命令测试通过 |
| 验证命令 | `pytest tests/test_feishu_question_reserved.py -q`; `python manage.py feishu_question_simulate --username owner "查最新法规核查"` |
| Codex 执行提示 | 请实现飞书问答预留的规则解析、服务和本地模拟命令。 |
### FS-7 阶段验证
```bash
pytest tests/test_feishu_question_reserved.py -q
python manage.py feishu_question_simulate --username owner "查最新法规核查"
```
---
## 十三、FS-8 文档与全量回归
### FS-8-001 更新配置说明
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 文档 / 配置 |
| 前置任务 | FS-7 |
| 涉及文件 | `README.md``.env.example` 或项目配置说明文档 |
| 目标 | 说明飞书相关环境变量和手动测试命令 |
| 开发步骤 | 1. 写明变量名和用途2. 标注不要提交真实 App Secret3. 写明 `send_test_feishu_notification` 用法4. 写明自动化测试不请求真实飞书 |
| 验收标准 | 配置说明清楚,无真实密钥 |
| 验证命令 | 手动检查文档 |
| Codex 执行提示 | 请补充飞书配置说明,只写变量名和用途,不写真实值。 |
### FS-8-002 全量相关测试
| 项目 | 内容 |
| --- | --- |
| 任务类型 | 测试 / 回归 |
| 前置任务 | FS-8-001 |
| 涉及文件 | 无固定文件 |
| 目标 | 运行飞书新增测试和三个工作流关键回归 |
| 开发步骤 | 1. 运行 Django check2. 运行飞书新增测试3. 运行三个工作流关键测试4. 修复与本功能相关失败5. 记录无法处理的既有失败 |
| 验收标准 | 新增测试通过,关键回归通过 |
| 验证命令 | `python manage.py check`; `pytest tests/test_feishu_*.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py` |
| Codex 执行提示 | 请运行飞书新增测试和三个工作流关键回归,确保首期飞书接入不破坏既有功能。 |
### FS-8 阶段验证
```bash
python manage.py check
pytest tests/test_feishu_*.py tests/test_file_summary_workflow.py tests/test_regulatory_notification.py tests/test_application_form_fill_notification.py
```
---
## 十四、建议提交切分
| 提交 | 建议提交信息 | 包含内容 |
| --- | --- | --- |
| 1 | `feat: add feishu notification data models` | 模型、迁移、Admin、配置项 |
| 2 | `feat: add feishu api notification services` | token、接收人、消息构造、消息 API client |
| 3 | `feat: add workflow notification dispatcher` | dispatcher、记录判重、三流程 adapter |
| 4 | `feat: wire feishu notifications into workflows` | 三个工作流接入 |
| 5 | `feat: show feishu notification status` | 页面展示 |
| 6 | `feat: add feishu notification test command` | 真实发送测试命令 |
| 7 | `feat: add feishu question preview services` | 问答预留查询、解析、日志、模拟命令 |
| 8 | `docs: document feishu configuration` | 配置说明和回归修正 |
---
## 十五、风险与处理策略
| 风险 | 影响 | 策略 |
| --- | --- | --- |
| 飞书应用权限不足 | 消息 API 返回无权限 | 手动测试命令先验证;错误码入库展示 |
| open_id/user_id 不正确 | 个人账号收不到消息 | 接收人配置缺失或错误时记录 failed命令输出错误 |
| token 缓存过期处理不当 | 偶发发送失败 | token 失效时刷新并允许消息 API 同步重试一次 |
| 三流程状态差异 | 通知触发点不一致 | 用 adapter 隔离各流程摘要生成 |
| 页面展示影响既有模板 | 前端回归失败 | 使用小型通知状态区块,无记录时不改变主流程展示 |
| 问答预留过度设计 | 影响首期交付 | 只做规则解析和摘要查询,不接事件订阅、不接 LLM |

View File

@@ -0,0 +1,622 @@
# 第1章监管信息材料包生成开发计划
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 需求分析文档 | docs/1.需求分析/5.第1章监管信息材料包生成.md |
| 功能设计文档 | docs/2.功能设计/5.第1章监管信息材料包生成.md |
| 数据库设计文档 | docs/3.数据库设计/5.第1章监管信息材料包生成.md |
| 详细设计文档 | docs/4.详细设计/5.第1章监管信息材料包生成.md |
| 参考开发计划 | docs/5.开发计划/3.产品关键信息提取与申报文件自动填表.md |
| 功能名称 | 第1章监管信息材料包生成 |
| 工作流编码 | regulatory_info_package |
| 批次号规则 | RIP-YYYYMMDDHHMMSS-abcdef |
| 计划日期 | 2026-06-10 |
| 计划版本 | V1.0 |
---
## 一、开发计划目标
本开发计划面向 Codex 执行,目标是把 `regulatory_info_package` 独立工作流按可验证、可回滚、可阶段验收的方式落地。计划以现有自动填表工作流 `application_form_fill` 为主要参考,但保持独立模块、独立批次、独立产物、独立通知和独立前端卡片。
现状裁决:当前最新代码中尚未存在 `regulatory_info_package` 正式工作流,本计划按“新建正式材料包工作流”执行;不得把该功能并入或改造 `application_form_fill`
开发完成后用户可在对话中上传或指定产品说明书并通过“根据说明书生成第1章监管信息”触发工作流。系统基于 `docs/0.原始材料/第1章 监管信息` 样例模板生成 7 个监管信息文件,以 `第1章 监管信息(预生成版).zip` 作为首位下载入口,同时提供单文件和追溯 Excel 辅助下载。
---
## 二、已确认开发规则
| 规则 | 内容 |
| --- | --- |
| 工作流独立 | 新增 `workflow_type=regulatory_info_package`,不并入 `application_form_fill` |
| 模块独立 | 新增 `review_agent/regulatory_info_package/`,服务与自动填表平级 |
| 模型集中 | Django 模型继续放在 `review_agent/models.py` |
| 节点幂等 | RIP 节点必须基于 `workflow_type + workflow_batch_id + node_code` 做幂等创建或数据库唯一约束 |
| 单说明书输入 | 用户消息指定文件名优先,其次 active 附件,再兼容最近成功文件汇总 |
| 多候选处理 | 不做选择弹窗,通过对话反问用户确认说明书文件名 |
| 模板固定 | 固定处理第1章监管信息 7 个模板 |
| 模板字段化 | 优先把模板整理为 Agent/代码可识别的字段模板,使用内容控件 Tag 或稳定占位符;代码只填字段,不依赖手工改格式 |
| 抽取策略 | 规则抽取和 LLM 抽取并行LLM 最多重试 3 次,失败后规则结果继续 |
| 文档生成 | 工作流节点串行,`generate_docs` 节点内部每个文档独立线程处理 |
| `.doc` 策略 | CH1.9 能力驱动:探测到 Word COM/UNO 时优先原生 `.doc`,无原生能力时明确记录并允许 `.docx` 兜底 |
| zip 策略 | zip 只包含成功或兜底成功文件,失败文件不进入 zip |
| 高亮策略 | 缺失项 `/` 黄底LLM-only 黄底;冲突黄底红字 |
| 追溯策略 | 用户下载 ExcelJSON 只写后台 logs 目录 |
| 前端策略 | 只做最小接入,不单独建设新页面或独立样式体系 |
| TDD | 新行为先写失败测试,再实现 |
| Git 提交 | 每阶段验证通过后生成提交摘要;是否本地提交由用户确认 |
| 用户变更保护 | 不回滚、不覆盖用户已有未提交变更 |
---
## 三、规范依据与裁决
| 规范来源 | 命中内容 | 本计划裁决 |
| --- | --- | --- |
| GYRX 后端开发规范 | 接口响应、日志、增量规范 | 状态接口、下载权限、异常降级和日志留痕按现有 Django 模式实现 |
| GYRX 前端开发规范 | 样式复用、组件接入、下载图标建议 | 复用现有对话页和工作流卡片样式,必要时只补少量语义化样式 |
| 既有自动填表开发计划 | 阶段拆分、测试先行、每阶段验证 | 本计划沿用阶段结构和 Codex 执行提示粒度 |
| 第1章监管信息详细设计 | 独立模块、7 模板、doc 兜底、zip 首位 | 作为本计划最高优先级依据 |
未发现规范冲突。项目专项设计优先于通用规范。
---
## 四、总体验收标准
| 类别 | 完成标准 |
| --- | --- |
| 触发 | 固定提示词和 LLM 路由均可触发 `regulatory_info_package` |
| 输入选择 | 能按用户指定文件名、active 附件、最近文件汇总选择说明书;多候选可反问 |
| 批次 | 能创建 `RegulatoryInfoPackageBatch`,节点和事件可查询 |
| 模板 | 能加载并校验 7 个模板配置,模板复制只写批次目录 |
| 抽取 | 规则抽取可独立跑通LLM 失败不阻断主链路 |
| 合并 | missing、llm_only、conflict 均有可追溯结构和高亮决策 |
| docx 生成 | 6 个 `.docx` 文件能按模板生成并保留基本版式 |
| doc 处理 | CH1.9 优先 `.doc` 原生处理,失败时 `.docx` 兜底,状态可见 |
| ZIP | `第1章 监管信息(预生成版).zip` 排在助手回显首位,只包含成功/兜底成功文件 |
| 单文件 | 成功文件有辅助下载,失败文件显示原因且无下载链接 |
| 追溯 | 用户可下载 `traceability.xlsx`JSON 写入 `logs/` |
| 前端 | 对话快捷入口、工作流卡片、状态轮询和下载列表正常 |
| 权限 | 非批次所属用户不能下载 RIP 产物 |
| 回归 | `python manage.py check` 和相关 pytest 通过,既有文件汇总/自动填表/法规核查不回归 |
---
## 五、阶段总览
| 阶段 | 名称 | 目标 | 阶段验收 |
| --- | --- | --- | --- |
| RIP-0 | 准备与基线回归 | 创建开发分支,确认依赖和既有测试状态 | 基线命令结果已记录 |
| RIP-1 | 数据模型与导出扩展 | 新增三张模型,扩展 zip 下载能力 | migration、模型和下载权限测试通过 |
| RIP-2 | 模块骨架与模板配置 | 新建模块、schema、YAML 配置和存储服务 | 配置加载和路径安全测试通过 |
| RIP-3 | 触发与工作流骨架 | 接入路由、批次创建、节点流转和状态接口 | 可创建并运行空工作流 |
| RIP-4 | 输入选择与说明书解析 | 选择说明书,解析 docx 段落、章节和表格 | 输入选择和说明书解析测试通过 |
| RIP-5 | 字段抽取与合并 | 规则 + LLM 并行抽取、重试、合并和高亮决策 | 抽取、重试、冲突合并测试通过 |
| RIP-6 | DOCX 文档生成 | 实现 6 个 docx 模板生成、产品列表重建和高亮 | docx 生成和 XML 高亮测试通过 |
| RIP-7 | CH1.9 DOC 适配 | 实现 `.doc` 原生适配探测和 `.docx` 兜底 | doc 兜底、失败隔离测试通过 |
| RIP-8 | 追溯、ZIP 与下载权限 | 生成 Excel、logs JSON、ZIP 和导出记录 | ZIP 内容、追溯、权限测试通过 |
| RIP-9 | 摘要、通知与状态归并 | 生成助手摘要,写通知记录,落定批次状态 | partial_success 等状态测试通过 |
| RIP-10 | 前端接入与总体验收 | 接入快捷入口、卡片、状态轮询和下载展示 | 前端回归和全量后端测试通过 |
---
## 六、RIP-0 准备与基线回归
### RIP-0-001 创建开发分支并确认工作区
| 项 | 内容 |
| --- | --- |
| 目标 | 创建本功能开发分支,确认当前工作区已有变更 |
| 修改范围 | Git 分支,不修改业务代码 |
| 验收标准 | 分支名符合 `codex/` 前缀;记录已有未提交变更,不回滚用户变更 |
| Codex 执行提示 | 请创建 `codex/regulatory-info-package` 开发分支,运行 `git status --short`,确认设计文档和目录重排状态,不要回滚无关变更。 |
### RIP-0-002 确认依赖与基线测试
| 项 | 内容 |
| --- | --- |
| 目标 | 确认 Django、python-docx、openpyxl、PyYAML、可选 Word COM 环境状态 |
| 修改范围 | 不修改业务代码 |
| 验收标准 | `python manage.py check` 可执行;关键依赖可 import既有失败需记录 |
| Codex 执行提示 | 请运行 Django check 和关键回归测试,确认依赖可用。若发现既有失败,只记录并继续按计划隔离,不改无关代码。 |
### RIP-0 阶段验证
```bash
python manage.py check
pytest tests/test_file_summary_views.py -k download
```
---
## 七、RIP-1 数据模型与导出扩展
### RIP-1-001 新增监管信息材料包 ORM 模型
| 项 | 内容 |
| --- | --- |
| 目标 | 新增 `RegulatoryInfoPackageBatch``RegulatoryInfoPackageArtifact``RegulatoryInfoPackageNotificationRecord` |
| 修改范围 | `review_agent/models.py` |
| 验收标准 | 字段、枚举、索引、软删除、关联关系符合数据库设计 |
| Codex 执行提示 | 请按 `docs/3.数据库设计/5.第1章监管信息材料包生成.md` 新增三张模型,模型集中放在 `review_agent/models.py`,不要新增字段级数据库表。 |
### RIP-1-002 扩展导出类型和下载 MIME
| 项 | 内容 |
| --- | --- |
| 目标 | `ExportedSummaryFile.ExportType` 增加 `zip`,下载 MIME 支持 `.zip``.doc``.docx` |
| 修改范围 | `review_agent/models.py``review_agent/file_summary/views.py` |
| 验收标准 | zip 可下载doc/docx MIME 正确;原有导出不回归 |
| Codex 执行提示 | 请扩展 `ExportedSummaryFile` 导出类型,并在下载接口按 workflow_type 和文件后缀处理权限与 content type。 |
### RIP-1-003 生成迁移并补模型测试
| 项 | 内容 |
| --- | --- |
| 目标 | 生成数据库迁移并覆盖基础模型行为 |
| 修改范围 | `review_agent/migrations/``tests/` |
| 验收标准 | migration 可应用模型测试覆盖批次号、状态、artifact、通知、zip export type |
| Codex 执行提示 | 请生成迁移并新增 `tests/test_regulatory_info_package_models.py`,优先覆盖模型字段默认值、导出类型,以及 `WorkflowNodeRun` 在 RIP 批次下的幂等/唯一节点创建。 |
### RIP-1 阶段验证
```bash
python manage.py check
pytest tests/test_regulatory_info_package_models.py tests/test_file_summary_views.py -k download
```
---
## 八、RIP-2 模块骨架与模板配置
### RIP-2-001 创建 regulatory_info_package 模块骨架
| 项 | 内容 |
| --- | --- |
| 目标 | 新增独立模块目录和基础文件 |
| 修改范围 | `review_agent/regulatory_info_package/` |
| 验收标准 | 模块可 import不影响现有 `application_form_fill` |
| Codex 执行提示 | 请创建详细设计中的模块骨架先放常量、schema、storage、events、workflow 空实现和 service 包,不提前写复杂业务。 |
### RIP-2-002 编写模板配置 YAML
| 项 | 内容 |
| --- | --- |
| 目标 | 配置 7 个样例模板、输出文件名、策略、字段 Tag/占位符映射和 `.doc` 标记 |
| 修改范围 | `review_agent/regulatory_info_package/templates/regulatory_info_package_templates_v1.yaml` |
| 验收标准 | 7 个模板完整zip 名称为 `第1章 监管信息(预生成版).zip`;字段映射优先使用内容控件 Tag 或稳定占位符 |
| Codex 执行提示 | 请按详细设计录入模板配置source_dir 指向样例目录,字段 targets 优先写 content_control_tag 或 placeholderCH1.9 声明 `prefer_legacy_doc_native: true` 且允许 docx fallback。 |
### RIP-2-003 实现配置加载、模板仓库和存储目录
| 项 | 内容 |
| --- | --- |
| 目标 | 实现 YAML 加载校验、模板复制、批次目录创建、路径安全检查 |
| 修改范围 | `template_config.py``template_repository.py``storage.py` |
| 验收标准 | 配置错误可返回清晰错误;模板只复制到批次目录;不写原始材料目录;能审计模板是否包含所需 Tag/占位符 |
| Codex 执行提示 | 请实现配置加载、模板复制和模板字段审计服务,所有路径必须校验位于批次工作目录内,原始模板目录只读。 |
### RIP-2-004 模板字段化整理与审计
| 项 | 内容 |
| --- | --- |
| 目标 | 将样例模板升级为代码友好的字段模板,不手工改生成文件格式 |
| 修改范围 | `docs/0.原始材料/第1章 监管信息` 的模板副本或 `review_agent/regulatory_info_package/templates/field_manifest.yaml` |
| 验收标准 | CH1.4 关键字段、复选框、声明类产品名/申请人位置有稳定 Tag 或占位符;审计缺失字段时测试失败 |
| Codex 执行提示 | 请优先使用 Word 内容控件 Tag若暂不具备内容控件编辑能力则使用不会影响版式的稳定占位符并在配置中记录字段与目标位置。 |
### RIP-2 阶段验证
```bash
python manage.py check
pytest tests/test_regulatory_info_package_template_config.py
```
---
## 九、RIP-3 触发与工作流骨架
### RIP-3-001 扩展意图路由
| 项 | 内容 |
| --- | --- |
| 目标 | 新增 `regulatory_info_package` action支持固定关键词和 LLM 路由 |
| 修改范围 | `review_agent/skill_router.py` |
| 验收标准 | 固定提示词直接命中LLM action 列表包含本工作流;原路由不回归 |
| Codex 执行提示 | 请扩展意图路由,新增 `starts_regulatory_info_package` 标记,避免破坏 file_summary、regulatory_review 和 application_form_fill。 |
### RIP-3-002 实现批次创建和节点初始化
| 项 | 内容 |
| --- | --- |
| 目标 | 创建批次、生成节点、记录事件 |
| 修改范围 | `workflow.py``events.py``constants.py` |
| 验收标准 | 可创建 `RIP-...` 批次;节点按定义初始化;事件可查询 |
| Codex 执行提示 | 请实现批次创建和节点初始化workflow_type 必须写 `regulatory_info_package`。 |
### RIP-3-003 实现执行器骨架和状态接口
| 项 | 内容 |
| --- | --- |
| 目标 | 工作流节点可完整流转status 接口可返回批次、节点、导出和风险信息 |
| 修改范围 | `workflow.py``views.py``urls.py` 或现有 URL 注册文件 |
| 验收标准 | 空工作流可从 pending 到 completed状态接口校验用户权限 |
| Codex 执行提示 | 请先实现可运行的空工作流骨架,业务节点可以临时 no-op但状态流转和权限必须真实。 |
### RIP-3-004 接入对话启动逻辑
| 项 | 内容 |
| --- | --- |
| 目标 | `stream_message` 能启动本工作流或返回说明书反问 |
| 修改范围 | `review_agent/services.py` |
| 验收标准 | 触发后发送 `workflow_started`;无输入或多候选时不误启动 |
| Codex 执行提示 | 请在 `stream_message` 增加 regulatory_info_package 分支,先调用输入选择服务,再决定启动、提示上传或反问。 |
### RIP-3 阶段验证
```bash
python manage.py check
pytest tests/test_regulatory_info_package_trigger.py tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_views.py
```
---
## 十、RIP-4 输入选择与说明书解析
### RIP-4-001 实现说明书输入选择
| 项 | 内容 |
| --- | --- |
| 目标 | 按用户消息、active 附件、最近汇总批次选择说明书 |
| 修改范围 | `services/input_select.py` |
| 验收标准 | 文件名模糊匹配、唯一 docx、多个说明书、无说明书均有明确结果 |
| Codex 执行提示 | 请实现 `select_instruction_input`,多候选返回 waiting_user 语义,由对话反问用户确认具体文件名。 |
### RIP-4-002 实现说明书 docx 解析
| 项 | 内容 |
| --- | --- |
| 目标 | 读取说明书段落、章节、表格、组成成分表和 front_text |
| 修改范围 | `services/instruction_extract.py` |
| 验收标准 | 能解析 `目标产品说明书.docx` 的产品名称、章节和主要表格结构 |
| Codex 执行提示 | 请使用结构化 Word 解析能力,不用脆弱的纯字符串拼接;解析结果写入可序列化 schema。 |
### RIP-4-003 写入说明书抽取日志产物
| 项 | 内容 |
| --- | --- |
| 目标 | 保存 `logs/instruction_extract.json` 并创建 artifact |
| 修改范围 | `workflow.py``storage.py``instruction_extract.py` |
| 验收标准 | JSON 只在后台 logs 目录,不进入用户下载列表 |
| Codex 执行提示 | 请在 text_extract 节点保存说明书抽取 JSONartifact 可记录,但不要创建 ExportedSummaryFile。 |
### RIP-4 阶段验证
```bash
pytest tests/test_regulatory_info_package_input_select.py tests/test_regulatory_info_package_instruction_extract.py
```
---
## 十一、RIP-5 字段抽取与合并
### RIP-5-001 实现规则字段抽取
| 项 | 内容 |
| --- | --- |
| 目标 | 从说明书章节和表格中抽取产品名称、包装规格、预期用途、组成、储存条件、样本类型、适用仪器、标准号等 |
| 修改范围 | `services/field_extract.py` |
| 验收标准 | 不依赖 LLM 时可抽取关键字段并支撑 demo |
| Codex 执行提示 | 请优先实现规则抽取,抽取结果包含 value、evidence、confidence 和 source。 |
### RIP-5-002 实现 LLM 抽取封装和三次重试
| 项 | 内容 |
| --- | --- |
| 目标 | LLM 结构化抽取,失败最多重试 3 次,失败后不阻断 |
| 修改范围 | `services/field_extract.py``prompts/field_extract.md` |
| 验收标准 | 0s/1s/2s 重试;解析失败可记录错误;规则结果继续 |
| Codex 执行提示 | 请封装 LLM 调用为可 mock 的函数,测试中不要真实调用外部模型。 |
### RIP-5-003 实现规则与 LLM 并行抽取
| 项 | 内容 |
| --- | --- |
| 目标 | 使用线程并行执行规则抽取和 LLM 抽取 |
| 修改范围 | `services/field_extract.py` |
| 验收标准 | 任一分支失败不影响另一分支结果;输出 `field_extract_result.json` |
| Codex 执行提示 | 请使用 `ThreadPoolExecutor(max_workers=2)`,不要在子线程直接写数据库。 |
### RIP-5-004 实现字段合并和高亮决策
| 项 | 内容 |
| --- | --- |
| 目标 | 输出 missing、llm_only、conflict 和最终写入值 |
| 修改范围 | `services/field_merge.py` |
| 验收标准 | 全缺失写 `/` 黄底LLM-only 黄底;冲突黄底红字;合并结果可追溯 |
| Codex 执行提示 | 请实现 `MergedField` 结构,合并结果写 `logs/merged_fields.json`,并同步批次摘要字段。 |
### RIP-5 阶段验证
```bash
pytest tests/test_regulatory_info_package_field_extract.py tests/test_regulatory_info_package_field_merge.py
```
---
## 十二、RIP-6 DOCX 文档生成
### RIP-6-001 实现 DocxDocumentAdapter
| 项 | 内容 |
| --- | --- |
| 目标 | 支持段落/表格替换、表格单元格填充、黄色底色、红字 |
| 修改范围 | `services/document_writer.py``services/docx_document.py` |
| 验收标准 | 可处理 run 拆分;测试可检查 docx XML 高亮和红字 |
| Codex 执行提示 | 请优先支持本模板需要的替换和表格填充场景,复杂通用 Word 引擎不要过度设计。 |
### RIP-6-002 实现 6 个 DOCX 文件生成策略
| 项 | 内容 |
| --- | --- |
| 目标 | 生成 CH1.2、CH1.4、CH1.5、CH1.11.1、CH1.11.5、CH1.11.6 |
| 修改范围 | `services/package_generate.py``services/standard_candidates.py` |
| 验收标准 | 6 个 docx 文件可生成;缺失/LLM-only/冲突样式正确 |
| Codex 执行提示 | 请先完成 docx 主链路。CH1.5 产品列表必须转成样例表头:包装规格、货号、组成、组分、主要组成成分、规格/数量,其中货号 `/` 黄底。 |
### RIP-6-003 实现 generate_docs 内部并发
| 项 | 内容 |
| --- | --- |
| 目标 | 每个文档独立线程生成,主线程统一写 artifact/export |
| 修改范围 | `services/package_generate.py``workflow.py` |
| 验收标准 | 单个文件失败不影响其他文件;返回 `GeneratedFileResult` 列表 |
| Codex 执行提示 | 请使用独立模板副本,子线程不要写数据库;所有异常转成文件级 failed 状态。 |
### RIP-6 阶段验证
```bash
pytest tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_info_package_package_generate.py
```
---
## 十三、RIP-7 CH1.9 DOC 适配
### RIP-7-001 实现 LegacyDocDocumentAdapter 能力探测
| 项 | 内容 |
| --- | --- |
| 目标 | 探测 Word COM、LibreOffice UNO 或可用兜底能力 |
| 修改范围 | `services/legacy_doc_document.py` |
| 验收标准 | 当前环境无原生能力时返回清晰 capability不崩溃测试不要求本机必须安装 Word 或 LibreOffice |
| Codex 执行提示 | 请先实现能力探测和接口骨架Windows Word COM/LibreOffice UNO 可作为原生能力;不可用时明确进入 docx 兜底。 |
### RIP-7-002 实现 CH1.9 原生写入与 docx 兜底
| 项 | 内容 |
| --- | --- |
| 目标 | CH1.9 优先 `.doc` 输出,失败时生成同语义 `.docx` |
| 修改范围 | `legacy_doc_document.py``package_generate.py` |
| 验收标准 | 有原生能力时原生成功状态 success无原生能力或原生失败但兜底成功时状态 fallback_success两者失败不进入 zip |
| Codex 执行提示 | 请把能力探测、原生失败和兜底失败都写入 `adapter_summary``risk_notes`,不要静默转换。 |
### RIP-7-003 补充 doc 适配器测试
| 项 | 内容 |
| --- | --- |
| 目标 | 覆盖 capability、兜底成功、失败隔离 |
| 修改范围 | `tests/test_regulatory_info_package_legacy_doc.py` |
| 验收标准 | 测试不依赖本机必须安装 Word用 mock 覆盖原生成功/失败 |
| Codex 执行提示 | 请用 mock 模拟 Word COM 可用和不可用场景,保证 CI 或本地无 Word 时测试仍稳定。 |
### RIP-7 阶段验证
```bash
pytest tests/test_regulatory_info_package_legacy_doc.py tests/test_regulatory_info_package_package_generate.py
```
---
## 十四、RIP-8 追溯、ZIP 与下载权限
### RIP-8-001 实现追溯 Excel 和后台 JSON
| 项 | 内容 |
| --- | --- |
| 目标 | 生成 `exports/traceability.xlsx``logs/traceability.json` |
| 修改范围 | `services/traceability_export.py` |
| 验收标准 | Excel 可下载JSON 不进入用户下载列表 |
| Codex 执行提示 | 请用 openpyxl 生成 Excel字段包含 target_file、target_field、final_value、extraction_source、evidence、highlight_reason、needs_review。 |
### RIP-8-002 实现 zip 打包
| 项 | 内容 |
| --- | --- |
| 目标 | 生成 `第1章 监管信息(预生成版).zip` |
| 修改范围 | `services/zip_export.py` |
| 验收标准 | zip 只包含 success/fallback_success 文件;失败文件不入包 |
| Codex 执行提示 | 请用 Python 标准库 `zipfile` 打包zip 中保留最终输出文件名。CH1.9 兜底成功时放入 `.docx` 文件。 |
### RIP-8-003 创建导出记录和下载权限
| 项 | 内容 |
| --- | --- |
| 目标 | zip、单文件、Excel 均写 `ExportedSummaryFile`;下载接口校验用户权限 |
| 修改范围 | `file_summary/views.py``storage.py``zip_export.py` |
| 验收标准 | 非批次用户不能下载zip 在 exports 返回顺序中排首位 |
| Codex 执行提示 | 请按 `workflow_type=regulatory_info_package` 反查批次所属 conversation/user软删除批次不可下载。 |
### RIP-8 阶段验证
```bash
pytest tests/test_regulatory_info_package_traceability.py tests/test_regulatory_info_package_zip.py tests/test_regulatory_info_package_views.py
```
---
## 十五、RIP-9 摘要、通知与状态归并
### RIP-9-001 实现助手 Markdown 摘要
| 项 | 内容 |
| --- | --- |
| 目标 | 完成后返回 zip 首位、单文件列表、失败原因、待确认摘要 |
| 修改范围 | `services/summary.py``workflow.py` |
| 验收标准 | zip 链接在回复首位;失败文件显示原因且无下载;待确认数量准确 |
| Codex 执行提示 | 请严格按详细设计生成助手摘要partial_success 时也要展示可下载 zip 和失败文件原因。 |
### RIP-9-002 实现通知记录和统一通知接入
| 项 | 内容 |
| --- | --- |
| 目标 | 写 `RegulatoryInfoPackageNotificationRecord`,调用统一通知服务 |
| 修改范围 | `services/notifier.py``workflow.py` |
| 验收标准 | 通知失败不阻断下载;失败写 `risk_notes` |
| Codex 执行提示 | 请复用已有通知模式,先保证本地测试可 mock不要让外部通知失败影响批次主状态。 |
### RIP-9-003 完成状态归并
| 项 | 内容 |
| --- | --- |
| 目标 | 根据生成结果、zip、追溯、通知落定 success/partial_success/failed/waiting_user |
| 修改范围 | `workflow.py` |
| 验收标准 | 7 文件成功为 success部分文件失败但有 zip 为 partial_success全部失败为 failed |
| Codex 执行提示 | 请把状态归并集中在一个函数,测试覆盖 docx 兜底、zip 失败、通知失败、产品名缺失。 |
### RIP-9 阶段验证
```bash
pytest tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_notification.py
```
---
## 十六、RIP-10 前端接入与总体验收
### RIP-10-001 增加对话快捷入口
| 项 | 内容 |
| --- | --- |
| 目标 | 对话框底部增加“第1章监管信息”快捷提示 |
| 修改范围 | `templates/home.html` |
| 验收标准 | 点击后填入或发送 `根据说明书生成第1章监管信息` |
| Codex 执行提示 | 请复用现有 tool-chip 样式,不单独创建新前端样式文件,除非现有结构无法展示。 |
### RIP-10-002 工作流卡片和状态轮询支持
| 项 | 内容 |
| --- | --- |
| 目标 | 前端识别 `regulatory_info_package`,使用新 status URL 轮询 |
| 修改范围 | `static/js/app.js``templates/home.html` |
| 验收标准 | 卡片能展示节点、状态、风险和导出列表;终态识别 success/partial_success/failed/waiting_user |
| Codex 执行提示 | 请在现有工作流卡片逻辑中增量接入,不复制一套新卡片实现。 |
### RIP-10-003 下载展示和失败文件展示
| 项 | 内容 |
| --- | --- |
| 目标 | zip 首位展示,单文件辅助下载,失败文件展示原因 |
| 修改范围 | `static/js/app.js` |
| 验收标准 | exports 返回顺序被保留失败文件无下载按钮traceability.xlsx 可下载 |
| Codex 执行提示 | 请以后端 exports 顺序为准,不新增 `is_primary` 字段zip 已由后端排首位。 |
### RIP-10-004 总体验收与回归
| 项 | 内容 |
| --- | --- |
| 目标 | 全链路验证和回归保护 |
| 修改范围 | 测试、必要的 bug fix |
| 验收标准 | Django check、RIP 测试、关键既有测试通过;能用样例说明书生成材料包 |
| Codex 执行提示 | 请用 `docs/0.原始材料/目标产品说明书.docx` 做端到端验证,确认 zip、单文件、Excel、logs 和摘要均符合设计。 |
### RIP-10 阶段验证
```bash
python manage.py check
pytest tests/test_regulatory_info_package_frontend.py
pytest tests/test_regulatory_info_package_models.py tests/test_regulatory_info_package_trigger.py tests/test_regulatory_info_package_input_select.py tests/test_regulatory_info_package_template_config.py tests/test_regulatory_info_package_instruction_extract.py tests/test_regulatory_info_package_field_extract.py tests/test_regulatory_info_package_field_merge.py tests/test_regulatory_info_package_docx_writer.py tests/test_regulatory_info_package_legacy_doc.py tests/test_regulatory_info_package_package_generate.py tests/test_regulatory_info_package_traceability.py tests/test_regulatory_info_package_zip.py tests/test_regulatory_info_package_workflow.py tests/test_regulatory_info_package_views.py tests/test_regulatory_info_package_notification.py
```
---
## 十七、测试分层要求
| 测试层 | 覆盖内容 | 建议文件 |
| --- | --- | --- |
| 模型测试 | 批次、产物、通知、zip 导出类型 | `tests/test_regulatory_info_package_models.py` |
| 路由测试 | 固定关键词、LLM action、对话启动分支 | `tests/test_regulatory_info_package_trigger.py` |
| 输入测试 | 文件名匹配、active 附件、多候选反问 | `tests/test_regulatory_info_package_input_select.py` |
| 配置测试 | YAML 加载、模板缺失、code 唯一 | `tests/test_regulatory_info_package_template_config.py` |
| 解析测试 | 说明书章节、表格、组成成分表 | `tests/test_regulatory_info_package_instruction_extract.py` |
| 抽取测试 | 规则抽取、LLM 重试、失败降级 | `tests/test_regulatory_info_package_field_extract.py` |
| 合并测试 | missing、llm_only、conflict | `tests/test_regulatory_info_package_field_merge.py` |
| 文档测试 | docx 替换、表格、高亮、红字 | `tests/test_regulatory_info_package_docx_writer.py` |
| doc 测试 | adapter 探测、docx 兜底、失败状态 | `tests/test_regulatory_info_package_legacy_doc.py` |
| 生成测试 | 7 文件并发生成、异常隔离 | `tests/test_regulatory_info_package_package_generate.py` |
| 追溯测试 | Excel 下载、logs JSON | `tests/test_regulatory_info_package_traceability.py` |
| zip 测试 | 只打包 success/fallback_success | `tests/test_regulatory_info_package_zip.py` |
| 工作流测试 | 节点流转、状态归并、partial_success | `tests/test_regulatory_info_package_workflow.py` |
| 接口测试 | start/status/download 权限 | `tests/test_regulatory_info_package_views.py` |
| 通知测试 | 通知记录、通知失败降级 | `tests/test_regulatory_info_package_notification.py` |
| 前端测试 | chip、卡片、状态 URL、下载展示 | `tests/test_regulatory_info_package_frontend.py` |
---
## 十八、Codex 自动化执行规则
| 规则 | 内容 |
| --- | --- |
| 顺序执行 | 必须从 RIP-0 到 RIP-10 顺序执行,不得跳阶段 |
| 阶段聚焦 | 当前阶段失败时先修复当前阶段,不继续后续阶段 |
| TDD | 新行为先写失败测试,再实现 |
| 小步修改 | 每次只修改当前阶段相关文件,避免顺手重构 |
| 用户变更保护 | 不得回滚或覆盖用户已有未提交变更 |
| 过程日志 | 每阶段记录关键命令结果和既有失败 |
| 阶段验证 | 每阶段完成后运行对应验证命令 |
| 阶段提交 | 每阶段验证通过后生成提交摘要;是否执行 `git commit` 由用户确认 |
| 回归保护 | 文件汇总、法规核查、自动填表现有测试不得回归 |
| doc 风险隔离 | `.doc` 原生能力不可用或原生处理失败不得阻断其他 6 个 docx 文件生成 |
| 外部依赖隔离 | LLM、通知、Word COM 均需可 mock测试不依赖真实外部服务 |
| 下载安全 | 所有导出下载必须通过所属用户权限校验 |
---
## 十九、推荐目标模式提示词
后续可直接对 Codex 输入:
```text
请按 docs/5.开发计划/5.第1章监管信息材料包生成.md 执行开发。
执行要求:
1. 严格按 RIP-0 到 RIP-10 顺序推进,不跳阶段。
2. 每阶段先读对应需求、功能、数据库、详细设计文档。
3. 每阶段先写或补充测试,再实现代码。
4. 每阶段只修改当前阶段相关文件,不做无关重构。
5. 不回滚、不覆盖用户已有未提交变更。
6. LLM、通知、Word COM 等外部能力必须可 mock。
7. 每阶段完成后运行该阶段验证命令。
8. 验证通过后生成提交摘要,是否本地提交等待用户确认。
9. 最后使用 docs/0.原始材料/目标产品说明书.docx 做端到端验收。
```
---
## 二十、待执行前检查清单
| 检查项 | 状态 |
| --- | --- |
| 需求分析、功能设计、数据库设计、详细设计均已存在 | 待执行时确认 |
| 当前分支是否适合创建开发分支 | 待执行时确认 |
| 是否存在用户未提交变更 | 待执行时确认 |
| `python-docx``openpyxl``PyYAML` 是否可用 | 待执行时确认 |
| Word COM 或 LibreOffice UNO 是否可用 | 待执行时确认,非阻塞 |
| 目标说明书 `docs/0.原始材料/目标产品说明书.docx` 是否存在 | 待执行时确认 |
| 样例模板目录 `docs/0.原始材料/第1章 监管信息` 是否完整 | 待执行时确认 |
| 现有文件汇总、法规核查、自动填表测试是否通过 | 待执行时确认 |

View File

@@ -0,0 +1,311 @@
# 架构搭建思路汇报稿(基于 Demo 版)
## 一、设计路径:先锁规格,再实现代码
各位老师好,我本次 Demo 搭建的是一个面向体外诊断试剂注册资料准备与审核的智能体原型。
这次开发没有直接从代码开始而是采用“文档先行、规格锁定、再实现代码”的路径。原因是注册资料审核不是一个简单问答场景它涉及文件解析、法规规则、RAG 依据、工作流状态、导出文件、人工确认和整改闭环。如果一开始就写代码,很容易出现功能能跑但边界不清、结果不可追溯、后续难维护的问题。
所以整体设计路径分为四步:
```text
需求拆解
-> 生成需求分析、功能设计、详细设计、数据库设计和开发计划
-> 用文档锁定实现规格
-> 按规格实现 Django 代码、工作流、前端页面和测试
```
当前仓库中可以看到完整的规格文档链路:
| 阶段 | 产物 | 作用 |
| --- | --- | --- |
| 需求分析 | `docs/1.需求分析` | 明确业务目标、用户动作、输入输出和异常场景 |
| 功能设计 | `docs/2.功能设计` | 把需求拆成文件汇总、法规核查、自动填表、飞书通知等模块 |
| 详细设计 | `docs/3.详细设计` | 锁定工作流节点、字段结构、状态流转和服务边界 |
| 数据库设计 | `docs/4.数据库设计` | 锁定批次、附件、节点、风险项、导出文件等模型 |
| 开发计划 | `docs/5.开发计划` | 将实现拆成可验证的开发任务和前端线框图 |
因此,这个 Demo 的核心不是“让大模型临时回答一个问题”,而是先用文档定义清楚系统应该如何工作,再把这些规格落实到代码、数据库、前端和测试中。最终形成的是一个可追溯、可复核、可继续扩展的审核工作台。
## 二、系统定位和 Demo 目标
这个 Demo 的目标不是简单做文件上传、文件解析或法规问答,而是把注册资料审核中几个高频、耗时、容易出错的环节串成一个智能工作流,包括:
```text
资料上传
-> 文件目录和页数汇总
-> NMPA 法规完整性核查
-> 法规依据 RAG 检索
-> 产品关键信息抽取
-> 一致性核查和风险预警
-> 申报文件自动填表
-> 报告导出和整改复核
```
从产品形态上看,它更像是一个“注册资料审核工作台”。用户上传一批申报资料后,系统先把资料包结构化,再按法规规则做核查,然后输出风险清单、整改建议、证据来源和导出文件。后续还可以继续复用抽取到的产品信息,自动填入申报模板。
## 三、技术栈和总体架构
本 Demo 采用轻量、可本地运行、便于测试和可解释的技术栈。
| 层级 | 技术/工具 | 作用 |
| --- | --- | --- |
| Web 框架 | Django | 路由、视图、模板、认证、ORM 和后台能力 |
| 数据库 | SQLite / Django ORM | Demo 阶段保存会话、附件、批次、节点、风险项和导出文件 |
| 前端 | Django Template + 原生 JS + CSS | 实现首页工作台、审核智能体、知识库管理、附件管理和流式对话 |
| 文件解析 | `pypdf``python-docx``python-pptx``openpyxl``xlrd``py7zr``zipfile` | 解析 PDF、Word、PPT、Excel、压缩包和旧 Office 文件 |
| 规则配置 | YAML | 维护 NMPA 体外诊断试剂注册资料核查规则 |
| RAG | ChromaDB + embedding provider | 构建法规材料向量索引,检索法规依据片段 |
| LLM | SiliconFlow / 可配置大模型接口 | 做意图路由、低置信度抽取、自然语言总结和辅助复核 |
| 流式交互 | SSE | 将工作流启动、节点进度和模型回复实时推给前端 |
| 自动化验证 | pytest + Django test client | 验证路由、页面、模型、工作流和导出结果 |
整体架构可以概括为:
```text
用户界面
-> Django 视图层
-> 对话服务和 Skill 路由器
-> 文件汇总 / 法规核查 / 自动填表工作流
-> ORM 状态记录和导出文件
-> RAG/LLM/规则服务
-> 前端工作流卡片和报告下载
```
这里的关键设计原则是规则判断要稳定RAG 负责补证据LLM 做辅助,不把高风险合规结论完全交给大模型自由发挥。
## 四、对话流程:先识别意图,再决定 RAG 或工作流
审核智能体页面不是单纯把用户输入直接发给大模型,而是有一层对话编排流程。
一次用户消息进入系统后,大致会经历以下步骤:
```text
用户输入
-> 保存用户消息
-> Skill Router 判断意图
-> 根据意图选择普通问答、附件读取或工作流
-> 必要时先检查附件和前置批次
-> 启动对应工作流或执行 RAG 问答
-> 保存助手回复和工作流事件
-> 前端通过 SSE 展示增量内容和节点状态
```
当前路由动作包括:
| action | 场景 | 后续动作 |
| --- | --- | --- |
| `normal_chat` | 普通法规问答或项目问答 | 先检索知识库,再把 RAG 片段放入大模型上下文 |
| `attachment_reader` | 用户要求阅读、提取、总结上传附件 | 调用附件读取 Skill返回文件内容摘要 |
| `file_summary` | 用户要求汇总文件目录、页数、清单 | 启动文件汇总工作流 |
| `regulatory_review` | 用户要求法规核查、完整性核查、风险预警、整改建议 | 必要时先生成文件汇总批次,再启动法规核查工作流 |
| `application_form_fill` | 用户要求申报文件填表、模板填充、安全和性能清单 | 必要时先生成文件汇总批次,再启动自动填表工作流 |
也就是说,普通问题是“先 RAG再回答”工作流问题是“先路由再检查前置条件再启动工作流”。例如用户问“注册检验报告要求是什么”系统会走 RAG 问答;用户说“请对当前资料做法规核查”,系统会进入法规核查工作流。
## 五、Skill 调用方式:路由器统一调度工具能力
Demo 中的 Skill 不是一个单独页面,而是对话服务后面的工具调用机制。用户不需要手动选择复杂功能,系统会根据用户话语和当前附件状态判断是否调用某个 Skill 或工作流。
当前实现中,`review_agent/skill_router.py` 负责意图路由。它采用两层判断:
```text
确定性规则预判
-> LLM 路由判断
-> 规则兜底
```
第一层是确定性规则。例如用户输入中包含“法规核查”“NMPA 核查”“风险预警”“自动填表”“申报模板”等明确关键词,系统可以直接判断要启动对应工作流。这样可以避免每次都依赖大模型判断。
第二层是 LLM 路由。系统会把用户消息和当前 active 附件列表发给路由模型,让模型只输出结构化 JSON
```json
{
"action": "regulatory_review",
"confidence": 0.9,
"reason": "用户要求对当前注册资料进行法规完整性核查"
}
```
第三层是规则兜底。如果 LLM 不可用、配置缺失或返回异常,系统会退回关键词和附件状态判断,保证 Demo 在本地环境也能稳定运行。
这个设计的好处是:用户体验上像是在和一个智能体对话,技术实现上则是由路由器把对话分发到不同工具、不同工作流和不同数据服务。
## 六、RAG 方式:法规依据和用户知识库共同参与
RAG 在 Demo 中有两类来源:
| 来源 | 说明 |
| --- | --- |
| 内置法规材料 | 来自 `docs/0.原始材料` 和 NMPA 相关法规文件,用于法规依据检索 |
| 用户管理知识库 | 由用户在“知识库管理”页面上传,可作为当前账号所有对话的补充知识 |
法规材料会被切分为文本块,写入 ChromaDB 向量库。每个 chunk 保留来源文件、chunk 编号、文本片段和元数据。embedding 支持真实语义 embedding也支持 deterministic/local embedding后者主要用于测试和 dry run。
RAG 在系统中的定位有两种:
### 1. 普通问答中的 RAG
如果用户提出普通问题,系统会先检索知识库,把命中的法规片段或用户知识库片段拼入上下文,再调用大模型回答。这样回答不会只依赖模型记忆,而是带有本地法规材料和用户资料依据。
```text
用户问题
-> 知识库检索
-> 过滤和排序相关片段
-> 组装为知识上下文
-> 调用 LLM 生成回答
```
### 2. 工作流中的 RAG
在法规核查工作流里RAG 不直接决定是否合规而是为规则判断补充法规依据。例如结构化规则已经判断“缺少注册检验报告”RAG 再检索相关法规要求,给出来源文件和依据片段。
这种方式避免了“让大模型自由判断合规”的不稳定性,同时让报告具备可解释依据。
## 七、三条核心工作流
当前 Demo 拆成三条主链路:文件汇总、法规核查、自动填表。
### 1. 文件汇总链路
对应模块:`review_agent/file_summary`
```text
文件上传
-> 附件固化
-> 压缩包解压
-> 文件扫描
-> 页数统计
-> 产品名识别
-> Markdown/Excel 报告输出
```
这个链路负责把原始资料包转换成结构化文件清单。系统会生成 `FileSummaryBatch``FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描资料。
输出字段包括序号、目录层级、文件名、文件类型、页数、相对路径、统计状态、重试次数和异常说明。
### 2. 法规核查链路
对应模块:`review_agent/regulatory_review`
```text
准备资料
-> 适用条件确认
-> 规则范围裁剪
-> 完整性核查
-> 文本抽取
-> 章节核查
-> 一致性核查
-> RAG 法规依据补充
-> 风险评估
-> 报告输出
-> 整改复核
```
这条链路使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则中配置了附件 4 的资料要求,包括监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。
系统会检查是否缺少关键资料,例如注册申请表、符合性声明、产品技术要求、注册检验报告、说明书、标签样稿、临床评价资料和质量管理体系文件。缺失项会转成 `RegulatoryIssue`,并按阻断项、高风险、中风险、低风险和提示项分级。
### 3. 自动填表链路
对应模块:`review_agent/application_form_fill`
```text
准备资料
-> 模板选择
-> 模板复制
-> 字段抽取
-> 冲突归并
-> Word 填写
-> 追溯清单导出
-> 结果通知
```
这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段Demo 中采用明确的归并策略,同时在结果中保留冲突摘要和来源追溯。
## 八、页面和数据工作台
前端目前包括四个主要页面:
| 页面 | URL | 作用 |
| --- | --- | --- |
| 首页工作台 | `/` | 展示对话、附件、知识库、批次状态和最近处理记录 |
| 审核智能体 | `/chat/` | 对话、上传附件、启动工作流、查看节点进度 |
| 知识库管理 | `/knowledge-base/` | 管理用户上传知识库、查看内置法规材料和索引状态 |
| 附件管理 | `/attachments/` | 管理不同对话下的上传附件、版本、启用状态和下载 |
首页工作台重点不是营销展示,而是运行态数据,包括:
```text
对话总数
附件总数
知识库材料数
执行中批次
已处理批次
成功批次
等待确认批次
失败批次
最近处理记录
```
知识库材料中同时统计用户管理文档和内置法规材料,避免把“知识库”误解成只包含用户上传文件。
## 九、过程留痕和可追溯设计
审核类系统不能只输出一个结论,还必须说明结论从哪里来。因此 Demo 对关键过程都做了结构化留痕。
| 过程 | 留痕内容 |
| --- | --- |
| 对话 | 用户消息、助手消息、会话标题、更新时间 |
| 附件 | 原始文件名、版本号、启用状态、存储路径、文件大小 |
| 文件汇总 | 批次号、文件明细、页数、统计状态、异常说明 |
| 工作流节点 | 节点编码、节点名称、进度、状态、错误信息 |
| 法规核查 | 规则编码、缺失项、风险等级、证据、整改建议 |
| RAG 检索 | 来源文件、片段文本、相似度、chunk 元数据 |
| 自动填表 | 字段来源、冲突摘要、模板选择、追溯清单 |
| 导出文件 | Markdown、Excel、JSON、Word 等结果文件 |
这保证了 Demo 输出的结果不是一次性回答,而是可以复核、下载、整改和继续追踪的过程资产。
## 十、Demo 可展示结果
本次 Demo 可以展示以下核心结果:
### 1. 文件目录汇总表
用户上传注册资料文件夹、散装文件或压缩包后,系统自动完成附件固化、解压、扫描和页数统计,最终生成 Markdown 汇总报告和 Excel 明细表。
### 2. 法规完整性报告
系统基于文件汇总结果和 NMPA 规则库做完整性核查,输出 Markdown 法规核查报告、Excel 问题清单和 JSON 结构化结果包。
### 3. 产品关键信息提取对照表
系统从说明书、产品技术要求、注册检验报告、申请表等文件中抽取产品名称、型号规格、预期用途、管理类别、分类编码、注册类型和临床评价路径,并保留来源文件和证据片段。
### 4. 风险预警列表
系统把完整性缺失、章节异常、字段冲突、文本抽取失败、页数不可确定、通知失败等问题统一沉淀为风险项,并按阻断项、高风险、中风险、低风险和提示项分级。
### 5. 申报文件自动填表结果
系统根据资料内容和适用条件选择模板,自动填充 Word 文件,并导出字段追溯清单,说明每个字段来自哪个文件、哪个证据片段。
## 十一、总结
整体来看,本 Demo 的架构搭建思路可以概括为:
```text
先用文档锁定规格
再用规则结构化审核逻辑
再用 RAG 补充法规依据
再用 Skill Router 调度工具和工作流
再用 ORM 和导出文件沉淀过程资产
最后通过工作台页面呈现状态和结果
```
它体现的是一个“资料输入、规则判断、证据追溯、风险输出、整改闭环”的智能体原型。
当前 Demo 已经完成了首页工作台、审核智能体对话、附件管理、知识库管理、文件汇总、法规核查、RAG 依据检索、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。
最终希望这个智能体能够从一个 Demo 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。

74
review_agent/admin.py Normal file
View File

@@ -0,0 +1,74 @@
from django.contrib import admin
from review_agent.models import (
FeishuAccessTokenCache,
FeishuQuestionLog,
FeishuUserMapping,
WorkflowNotificationRecord,
)
@admin.register(FeishuUserMapping)
class FeishuUserMappingAdmin(admin.ModelAdmin):
list_display = (
"system_user",
"feishu_display_name",
"feishu_open_id",
"feishu_user_id",
"feishu_mobile",
"is_active",
"updated_at",
)
list_filter = ("is_active",)
search_fields = (
"system_user__username",
"feishu_display_name",
"feishu_open_id",
"feishu_user_id",
"feishu_mobile",
)
readonly_fields = ("created_at", "updated_at")
@admin.register(FeishuAccessTokenCache)
class FeishuAccessTokenCacheAdmin(admin.ModelAdmin):
list_display = ("app_id_hash", "expires_at", "updated_at", "has_error")
search_fields = ("app_id_hash", "error_message")
readonly_fields = ("created_at", "updated_at")
@admin.display(boolean=True, description="有错误")
def has_error(self, obj: FeishuAccessTokenCache) -> bool:
return bool(obj.error_message)
@admin.register(WorkflowNotificationRecord)
class WorkflowNotificationRecordAdmin(admin.ModelAdmin):
list_display = (
"workflow_type",
"workflow_batch_no",
"workflow_status",
"channel",
"send_status",
"target",
"sent_at",
"created_at",
)
list_filter = ("workflow_type", "channel", "send_status", "workflow_status")
search_fields = ("workflow_batch_no", "dedupe_key", "target", "error_message")
readonly_fields = ("created_at", "updated_at")
@admin.register(FeishuQuestionLog)
class FeishuQuestionLogAdmin(admin.ModelAdmin):
list_display = (
"system_user",
"source_type",
"intent",
"permission_result",
"status",
"processed_at",
"created_at",
)
list_filter = ("source_type", "intent", "permission_result", "status")
search_fields = ("system_user__username", "question_text", "answer_summary", "message_id")
readonly_fields = ("created_at",)

View File

@@ -14,6 +14,11 @@ FORM_FILL_TRIGGER_KEYWORDS = [
"填到申报模板", "填到申报模板",
"自动填表", "自动填表",
"生成表格", "生成表格",
"申报文件模板",
"申报文件填表",
"产品关键信息",
"字段来源追溯清单",
"注册证 word",
] ]
FORM_FILL_NODE_DEFINITIONS = [ FORM_FILL_NODE_DEFINITIONS = [

View File

@@ -15,6 +15,37 @@ from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFill
from review_agent.regulatory_review.services.text_extract import extract_text from review_agent.regulatory_review.services.text_extract import extract_text
FIELD_ALIASES = {
"product_name": ["产品名称"],
"applicant_name": ["注册人名称", "申请人名称", "生产企业名称"],
"applicant_address": ["注册人住所", "申请人住所", "生产企业住所"],
"manufacturer_address": ["生产地址", "生产企业地址", "生产场所"],
"agent_name": ["代理人名称", "生产企业名称", "注册人名称", "申请人名称"],
"agent_address": ["代理人住所", "生产企业住所", "注册人住所", "申请人住所"],
"package_specification": ["包装规格", "规格"],
"main_components": ["主要组成成分", "主要组成", "组成成分"],
"intended_use": ["预期用途"],
"storage_condition_and_validity": ["产品储存条件及有效期", "储存条件及有效期", "储存条件", "有效期"],
}
STATIC_STOP_LABELS = [
"申请人",
"国家药品监督管理局",
"填表说明",
"",
"保证书",
"应附资料",
"优先通道申请",
"分类编码",
"医疗器械唯一标识",
"注册产品目前是否",
"临床评价路径",
"临床试验",
"其他需要说明的问题",
"国家药监局器审中心医疗器械",
]
def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]: def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]:
texts: dict[str, str] = {} texts: dict[str, str] = {}
for item in summary_batch.items.order_by("file_index"): for item in summary_batch.items.order_by("file_index"):
@@ -32,11 +63,11 @@ def collect_document_texts(summary_batch: FileSummaryBatch) -> dict[str, str]:
def extract_by_rules(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[str, Any]: def extract_by_rules(texts: dict[str, str], specs: list[TemplateSpec]) -> dict[str, Any]:
fields: list[dict[str, Any]] = [] fields: list[dict[str, Any]] = []
field_defs = _field_defs(specs) field_defs = _field_defs(specs)
labels = [field["label"] for field in field_defs if field.get("label")] labels = _all_field_labels(field_defs)
for file_name, text in texts.items(): for file_name, text in texts.items():
source_role = detect_source_role(file_name, text) source_role = detect_source_role(file_name, text)
for field in field_defs: for field in field_defs:
value, evidence = _extract_label_value(text, field["label"], labels) value, evidence = _extract_field_value(text, field, labels)
if not value: if not value:
continue continue
fields.append( fields.append(
@@ -142,9 +173,45 @@ def _field_defs(specs: list[TemplateSpec]) -> list[dict[str, str]]:
return fields return fields
def _extract_field_value(text: str, field: dict[str, str], labels: list[str]) -> tuple[str, str]:
aliases = _field_aliases(field)
for label in aliases:
value, evidence = _extract_colon_label_value(text, label, labels + aliases)
if value:
return value, evidence
value, evidence = _extract_bracket_section_value(text, label)
if value:
return value, evidence
return "", ""
def _field_aliases(field: dict[str, str]) -> list[str]:
aliases = [field["label"]]
aliases.extend(FIELD_ALIASES.get(field["key"], []))
result: list[str] = []
for alias in aliases:
normalized = str(alias or "").strip()
if normalized and normalized not in result:
result.append(normalized)
return result
def _all_field_labels(fields: list[dict[str, str]]) -> list[str]:
labels: list[str] = list(STATIC_STOP_LABELS)
for field in fields:
for label in _field_aliases(field):
if label not in labels:
labels.append(label)
return labels
def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str, str]: def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str, str]:
return _extract_colon_label_value(text, label, labels)
def _extract_colon_label_value(text: str, label: str, labels: list[str]) -> tuple[str, str]:
escaped_labels = "|".join(re.escape(item) for item in labels if item != label) escaped_labels = "|".join(re.escape(item) for item in labels if item != label)
stop_pattern = rf"(?=\n\s*(?:{escaped_labels})\s*[:])" if escaped_labels else r"(?=\Z)" stop_pattern = rf"(?=\n\s*(?:{escaped_labels})(?:\s*[:]|\s*$))" if escaped_labels else r"(?=\Z)"
pattern = re.compile(rf"{re.escape(label)}\s*[:]\s*(.+?)(?:{stop_pattern}|\Z)", re.S) pattern = re.compile(rf"{re.escape(label)}\s*[:]\s*(.+?)(?:{stop_pattern}|\Z)", re.S)
match = pattern.search(text or "") match = pattern.search(text or "")
if not match: if not match:
@@ -156,6 +223,30 @@ def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str,
return value, evidence return value, evidence
def _extract_bracket_section_value(text: str, label: str) -> tuple[str, str]:
heading_pattern = rf"^\s*[【\[]\s*{re.escape(label)}\s*[】\]]\s*$"
lines = (text or "").splitlines()
for index, line in enumerate(lines):
if not re.match(heading_pattern, line.strip()):
continue
value_parts: list[str] = []
for next_line in lines[index + 1 :]:
normalized = next_line.strip()
if not normalized:
continue
if _looks_like_bracket_heading(normalized):
break
value_parts.append(normalized)
value = "\n".join(value_parts).strip()
if value:
return value, f"{label}\n{value}"[:300]
return "", ""
def _looks_like_bracket_heading(line: str) -> bool:
return bool(re.match(r"^\s*[【\[].{1,40}[】\]]\s*$", line))
def _prompt_text() -> str: def _prompt_text() -> str:
path = Path(__file__).resolve().parents[1] / "prompts" / "field_extract.md" path = Path(__file__).resolve().parents[1] / "prompts" / "field_extract.md"
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")

View File

@@ -81,8 +81,30 @@ def merge_fields(regex_results: dict[str, Any], llm_results: dict[str, Any]) ->
"handling": "说明书优先,模板内黄底红字高亮" if rank_source(merged_field.source_file, merged_field.source_file) == 1 else "按来源优先级采用最高优先级字段", "handling": "说明书优先,模板内黄底红字高亮" if rank_source(merged_field.source_file, merged_field.source_file) == 1 else "按来源优先级采用最高优先级字段",
} }
) )
_apply_agent_company_fallbacks(merged)
return merged, conflicts return merged, conflicts
def _distinct_values(candidates: list[dict[str, Any]]) -> set[str]: def _distinct_values(candidates: list[dict[str, Any]]) -> set[str]:
return {normalize_field_value(str(item.get("value") or "")) for item in candidates if item.get("value")} return {normalize_field_value(str(item.get("value") or "")) for item in candidates if item.get("value")}
def _apply_agent_company_fallbacks(merged: dict[str, MergedField]) -> None:
fallback_pairs = {
"agent_name": ("applicant_name", "代理人名称"),
"agent_address": ("applicant_address", "代理人住所"),
}
for target_key, (source_key, target_label) in fallback_pairs.items():
if target_key in merged or source_key not in merged:
continue
source = merged[source_key]
merged[target_key] = MergedField(
key=target_key,
label=target_label,
value=source.value,
source_file=source.source_file,
evidence=source.evidence,
confidence=source.confidence,
has_conflict=source.has_conflict,
conflict_values=source.conflict_values,
)

View File

@@ -7,6 +7,8 @@ from review_agent.models import (
ApplicationFormFillNotificationRecord, ApplicationFormFillNotificationRecord,
ExportedSummaryFile, ExportedSummaryFile,
) )
from review_agent.notifications.dispatcher import dispatch_workflow_notification
from review_agent.notifications.workflow_adapters import build_application_form_fill_context
def notify_completion( def notify_completion(
@@ -33,6 +35,13 @@ def notify_completion(
retry_count=1, retry_count=1,
error_message="mock notification failed", error_message="mock notification failed",
) )
unified_error = ""
try:
unified_record = dispatch_workflow_notification(build_application_form_fill_context(batch))
if unified_record.send_status == unified_record.SendStatus.FAILED:
unified_error = unified_record.error_message
except Exception as exc:
unified_error = str(exc)
return ApplicationFormFillNotificationRecord.objects.create( return ApplicationFormFillNotificationRecord.objects.create(
batch=batch, batch=batch,
recipient=batch.user, recipient=batch.user,
@@ -41,5 +50,6 @@ def notify_completion(
export_ids=export_ids, export_ids=export_ids,
message_summary=message_summary, message_summary=message_summary,
send_status=ApplicationFormFillNotificationRecord.SendStatus.SUCCESS, send_status=ApplicationFormFillNotificationRecord.SendStatus.SUCCESS,
error_message=unified_error,
sent_at=timezone.now(), sent_at=timezone.now(),
) )

View File

@@ -22,10 +22,11 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor
lines.extend(["", "| 冲突字段 | 采用值 | 冲突来源 | 处理 |", "| --- | --- | --- | --- |"]) lines.extend(["", "| 冲突字段 | 采用值 | 冲突来源 | 处理 |", "| --- | --- | --- | --- |"])
for item in conflicts: for item in conflicts:
conflict_sources = "".join( conflict_sources = "".join(
f"{value.get('source_file', '')}{value.get('value', '')}" for value in item.get("conflict_values", []) f"{_compact_table_text(value.get('source_file', ''))}{_compact_table_text(value.get('value', ''))}"
for value in item.get("conflict_values", [])
) )
lines.append( lines.append(
f"| {item.get('field_label', item.get('field_key', ''))} | {item.get('selected_value', '')} | {conflict_sources or '-'} | {item.get('handling', '')} |" f"| {_compact_table_text(item.get('field_label', item.get('field_key', '')))} | {_compact_table_text(item.get('selected_value', ''))} | {_compact_table_text(conflict_sources or '-')} | {_compact_table_text(item.get('handling', ''))} |"
) )
if trace_exports: if trace_exports:
@@ -33,3 +34,10 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor
for export in trace_exports: for export in trace_exports:
lines.append(f"[下载{export.file_name}](/api/review-agent/file-summary/exports/{export.pk}/download/)") lines.append(f"[下载{export.file_name}](/api/review-agent/file-summary/exports/{export.pk}/download/)")
return "\n".join(lines).strip() return "\n".join(lines).strip()
def _compact_table_text(value: object, *, limit: int = 80) -> str:
text = " ".join(str(value or "").replace("|", " ").split())
if len(text) <= limit:
return text
return f"{text[:limit]}..."

View File

@@ -22,6 +22,7 @@ def fill_template(
conflicts: list[dict] | None = None, conflicts: list[dict] | None = None,
) -> Path: ) -> Path:
document = Document(str(template_path)) document = Document(str(template_path))
remove_fill_instructions(document)
conflict_keys = {item.get("field_key") for item in conflicts or []} conflict_keys = {item.get("field_key") for item in conflicts or []}
for field_config in spec.fields: for field_config in spec.fields:
target = field_config.get("target") or {} target = field_config.get("target") or {}
@@ -43,6 +44,25 @@ def fill_template(
return output return output
def remove_fill_instructions(document: Document) -> None:
removing = False
for paragraph in list(document.paragraphs):
text = _normalize_label(paragraph.text)
if text == "填表说明":
removing = True
if removing:
_remove_paragraph(paragraph)
continue
if text.startswith("注填表前") and "填表说明" in text:
_remove_paragraph(paragraph)
for table in document.tables:
for row in list(table.rows):
row_text = _normalize_label("".join(cell.text for cell in row.cells))
if row_text == "填表说明" or row_text.startswith("注填表前"):
_remove_row(row)
def fill_table_row(document: Document, row_label: str, value: str, *, conflict: bool = False) -> bool: def fill_table_row(document: Document, row_label: str, value: str, *, conflict: bool = False) -> bool:
normalized_label = _normalize_label(row_label) normalized_label = _normalize_label(row_label)
for table in document.tables: for table in document.tables:
@@ -71,6 +91,15 @@ def apply_cell_shading(cell, fill: str) -> None:
shading.set(qn("w:fill"), fill) shading.set(qn("w:fill"), fill)
def _remove_paragraph(paragraph) -> None:
element = paragraph._element
element.getparent().remove(element)
def _remove_row(row) -> None:
row._tr.getparent().remove(row._tr)
def create_word_export( def create_word_export(
batch: ApplicationFormFillBatch, batch: ApplicationFormFillBatch,
spec: TemplateSpec, spec: TemplateSpec,
@@ -107,5 +136,6 @@ def _normalize_label(value: str) -> str:
def _safe_filename(value: str) -> str: def _safe_filename(value: str) -> str:
text = re.sub(r'[\\/:*?"<>|]+', "_", value or "") text = re.sub(r"[\x00-\x1f\x7f]+", "", value or "")
text = re.sub(r'[\\/:*?"<>|]+', "_", text)
return text.strip()[:80] or "output" return text.strip()[:80] or "output"

View File

@@ -36,6 +36,24 @@ templates:
source_roles: source_roles:
- 申请表 - 申请表
- 质量管理体系文件 - 质量管理体系文件
- key: agent_name
label: 代理人名称
target:
type: table_row
row_label: 代理人名称
source_roles:
- 说明书
- 企业信息
- 申请表
- key: agent_address
label: 代理人住所
target:
type: table_row
row_label: 代理人住所
source_roles:
- 说明书
- 企业信息
- 申请表
- key: product_name - key: product_name
label: 产品名称 label: 产品名称
target: target:

View File

@@ -11,6 +11,7 @@ from review_agent.application_form_fill.workflow import (
start_application_form_fill_workflow, start_application_form_fill_workflow,
) )
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun
from review_agent.notifications.presenter import serialize_notification_records
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@@ -75,6 +76,7 @@ def batch_status(request, batch_id: int):
workflow_type="application_form_fill", workflow_type="application_form_fill",
workflow_batch_id=batch.pk, workflow_batch_id=batch.pk,
).order_by("id") ).order_by("id")
notifications = serialize_notification_records("application_form_fill", batch.pk)
return JsonResponse( return JsonResponse(
{ {
"batch": { "batch": {
@@ -112,6 +114,8 @@ def batch_status(request, batch_id: int):
} }
for export in exports for export in exports
], ],
"notifications": notifications,
"latest_notification": notifications[0] if notifications else None,
} }
) )

View File

@@ -0,0 +1 @@
"""Reserved Feishu question services."""

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import re
WORKFLOW_KEYWORDS = {
"regulatory_review": ("法规核查", "风险", "整改", "RR-"),
"application_form_fill": ("自动填表", "填表", "申报文件", "AFF-"),
"file_summary": ("自动汇总", "文件汇总", "目录", "页数", "FS-"),
}
def parse_question_intent(text: str) -> dict[str, object]:
normalized = (text or "").strip()
batch_no = _extract_batch_no(normalized)
workflow_type = _detect_workflow_type(normalized, batch_no)
latest = bool(re.search(r"(最新|最近|上一个|最后一个)", normalized))
intent = "batch_status" if batch_no or latest else "unknown"
if workflow_type == "regulatory_review" and any(keyword in normalized for keyword in ["风险", "阻断", "整改"]):
intent = "risk_summary"
if workflow_type == "application_form_fill" and any(keyword in normalized for keyword in ["导出", "文件", "word", "Word"]):
intent = "export_summary"
if workflow_type == "file_summary" and any(keyword in normalized for keyword in ["缺失", "目录", "页数"]):
intent = "missing_summary"
return {
"intent": intent,
"workflow_type": workflow_type,
"batch_no": batch_no,
"latest": latest or not batch_no,
}
def _extract_batch_no(text: str) -> str:
match = re.search(r"\b(?:RR|AFF|FS)-[A-Za-z0-9-]+", text, flags=re.IGNORECASE)
return match.group(0).upper() if match else ""
def _detect_workflow_type(text: str, batch_no: str = "") -> str:
source = f"{text} {batch_no}"
for workflow_type, keywords in WORKFLOW_KEYWORDS.items():
if any(keyword in source for keyword in keywords):
return workflow_type
return ""

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
def can_access_batch(user, batch) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
return getattr(batch, "user_id", None) == user.pk

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from review_agent.models import ApplicationFormFillBatch, ExportedSummaryFile, FileSummaryBatch, RegulatoryReviewBatch
from .permissions import can_access_batch
WORKFLOW_MODELS = {
"file_summary": FileSummaryBatch,
"regulatory_review": RegulatoryReviewBatch,
"application_form_fill": ApplicationFormFillBatch,
}
def query_batch_summary(user, *, workflow_type: str | None = None, batch_no: str | None = None, latest: bool = False) -> dict:
candidates = _candidate_batches(workflow_type)
if batch_no:
for current_workflow_type, model in candidates:
batch = model.objects.filter(batch_no=batch_no).first()
if batch:
return _serialize_allowed_batch(user, current_workflow_type, batch)
return {"ok": False, "permission_result": "not_found", "answer_summary": "未找到对应批次。"}
if latest:
for current_workflow_type, model in candidates:
queryset = model.objects.all().order_by("-finished_at", "-created_at", "-id")
for batch in queryset:
if can_access_batch(user, batch):
return _serialize_batch(current_workflow_type, batch, permission_result="allowed")
return {"ok": False, "permission_result": "not_found", "answer_summary": "未找到可访问的批次。"}
return {"ok": False, "permission_result": "not_found", "answer_summary": "请提供批次号,或询问最新/最近批次。"}
def _candidate_batches(workflow_type: str | None):
if workflow_type and workflow_type in WORKFLOW_MODELS:
return [(workflow_type, WORKFLOW_MODELS[workflow_type])]
return list(WORKFLOW_MODELS.items())
def _serialize_allowed_batch(user, workflow_type: str, batch) -> dict:
if not can_access_batch(user, batch):
return {"ok": False, "permission_result": "denied", "answer_summary": "无权限访问该批次。"}
return _serialize_batch(workflow_type, batch, permission_result="allowed")
def _serialize_batch(workflow_type: str, batch, *, permission_result: str) -> dict:
summary = _summary_for_batch(workflow_type, batch)
result_url = _result_url(workflow_type, batch.pk)
answer = f"{batch.batch_no} 状态 {batch.status}{summary}"
return {
"ok": True,
"permission_result": permission_result,
"workflow_type": workflow_type,
"batch_id": batch.pk,
"batch_no": batch.batch_no,
"status": batch.status,
"summary": summary,
"result_url": result_url,
"answer_summary": answer,
}
def _summary_for_batch(workflow_type: str, batch) -> str:
if workflow_type == "file_summary":
return f"文件 {batch.total_files} 个,成功 {batch.success_files} 个,失败 {batch.failed_files} 个。"
if workflow_type == "regulatory_review":
risk = batch.risk_summary or {}
return f"阻断项 {int(risk.get('blocking') or 0)} 个,高风险 {int(risk.get('high') or 0)} 个。"
if workflow_type == "application_form_fill":
export_count = ExportedSummaryFile.objects.filter(
workflow_type="application_form_fill",
workflow_batch_id=batch.pk,
).count()
return f"导出文件 {export_count} 个,冲突字段 {len(batch.conflict_summary or [])} 个。"
return ""
def _result_url(workflow_type: str, batch_id: int) -> str:
paths = {
"file_summary": f"/api/review-agent/file-summary/{batch_id}/status/",
"regulatory_review": f"/api/review-agent/regulatory-review/{batch_id}/status/",
"application_form_fill": f"/api/review-agent/application-form-fill/{batch_id}/status/",
}
return paths.get(workflow_type, "/")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from django.utils import timezone
from review_agent.models import FeishuQuestionLog
from .intent import parse_question_intent
from .query import query_batch_summary
def answer_question(user, text: str, *, source_type: str = FeishuQuestionLog.SourceType.SIMULATE) -> dict:
parsed = parse_question_intent(text)
result = query_batch_summary(
user,
workflow_type=parsed.get("workflow_type") or None,
batch_no=parsed.get("batch_no") or None,
latest=bool(parsed.get("latest")),
)
status = FeishuQuestionLog.Status.SUCCESS if result.get("ok") else FeishuQuestionLog.Status.FAILED
answer_summary = str(result.get("answer_summary") or "")
log = FeishuQuestionLog.objects.create(
system_user=user if getattr(user, "is_authenticated", False) else None,
source_type=source_type,
question_text=text,
intent=str(parsed.get("intent") or "unknown"),
query_object={
"workflow_type": parsed.get("workflow_type") or "",
"batch_no": parsed.get("batch_no") or "",
"latest": bool(parsed.get("latest")),
},
answer_summary=answer_summary[:500],
permission_result=str(result.get("permission_result") or ""),
status=status,
error_message="" if result.get("ok") else answer_summary,
processed_at=timezone.now(),
)
return {**result, "intent": parsed.get("intent"), "log_id": log.pk}

View File

@@ -2,16 +2,18 @@ from __future__ import annotations
import csv import csv
import logging import logging
from tempfile import TemporaryDirectory
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
from review_agent.models import FileAttachment from review_agent.models import FileAttachment
from review_agent.file_summary.services.archive import ARCHIVE_EXTENSIONS, extract_archive
TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "log"} TEXT_EXTENSIONS = {"txt", "md", "csv", "json", "log"}
SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} | ARCHIVE_EXTENSIONS
MAX_PREVIEW_CHARS = 3000 MAX_PREVIEW_CHARS = 3000
MAX_ROWS_PER_SHEET = 20 MAX_ROWS_PER_SHEET = 20
@@ -72,6 +74,8 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult:
sections = _read_pptx(file_path) sections = _read_pptx(file_path)
elif file_type == "csv": elif file_type == "csv":
sections = _read_csv(file_path) sections = _read_csv(file_path)
elif file_type in ARCHIVE_EXTENSIONS:
sections = _read_archive(file_path)
else: else:
sections = _read_text(file_path) sections = _read_text(file_path)
except Exception as exc: except Exception as exc:
@@ -208,6 +212,44 @@ def _read_pptx(path: Path) -> list[dict[str, object]]:
return sections return sections
def _read_archive(path: Path) -> list[dict[str, object]]:
sections: list[dict[str, object]] = []
with TemporaryDirectory(prefix="attachment-reader-") as temp_dir:
extracted = extract_archive(path, Path(temp_dir))
if not extracted:
return [{"type": "archive", "name": path.name, "text": "压缩包未解出任何可读取文件。"}]
for item in extracted:
file_type = item.suffix.lower().lstrip(".")
if file_type not in SUPPORTED_EXTENSIONS or file_type in ARCHIVE_EXTENSIONS:
sections.append(
{
"type": "file",
"name": item.name,
"text": f"暂不支持预览压缩包内的 .{file_type or 'unknown'} 文件。",
}
)
continue
for section in _read_supported_file(item, file_type):
section = dict(section)
section["name"] = f"{item.name} / {section.get('name', item.name)}"
sections.append(section)
return sections
def _read_supported_file(path: Path, file_type: str) -> list[dict[str, object]]:
if file_type == "pdf":
return _read_pdf(path)
if file_type == "docx":
return _read_docx(path)
if file_type == "xlsx":
return _read_xlsx(path)
if file_type == "pptx":
return _read_pptx(path)
if file_type == "csv":
return _read_csv(path)
return _read_text(path)
def _build_preview(sections: list[dict[str, object]]) -> str: def _build_preview(sections: list[dict[str, object]]) -> str:
parts: list[str] = [] parts: list[str] = []
for section in sections: for section in sections:

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Count, Q from django.db.models import Count, Q
import json import json
import logging import logging
@@ -7,8 +8,17 @@ from pathlib import Path
from django.http import FileResponse, Http404, JsonResponse from django.http import FileResponse, Http404, JsonResponse
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import (
ApplicationFormFillBatch,
Conversation,
ExportedSummaryFile,
FileAttachment,
Message,
RegulatoryInfoPackageBatch,
RegulatoryReviewBatch,
)
from review_agent.models import FileSummaryBatch, WorkflowEvent from review_agent.models import FileSummaryBatch, WorkflowEvent
from review_agent.notifications.presenter import serialize_notification_records
from .events import serialize_event from .events import serialize_event
from .paths import resolve_storage_path from .paths import resolve_storage_path
@@ -147,6 +157,17 @@ def conversation_list(request):
) )
@require_http_methods(["DELETE"])
@login_required
def conversation_detail(request, conversation_id: int):
conversation = _conversation_for_user(request.user, conversation_id)
with transaction.atomic():
ApplicationFormFillBatch.objects.filter(conversation=conversation).delete()
RegulatoryReviewBatch.objects.filter(conversation=conversation).delete()
conversation.delete()
return JsonResponse({"ok": True, "conversation_id": conversation_id})
@require_http_methods(["GET"]) @require_http_methods(["GET"])
@login_required @login_required
def attachment_download(request, conversation_id: int, attachment_id: int): def attachment_download(request, conversation_id: int, attachment_id: int):
@@ -225,6 +246,7 @@ def batch_status(request, batch_id: int):
batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first()
if not batch: if not batch:
raise Http404("批次不存在。") raise Http404("批次不存在。")
notifications = serialize_notification_records("file_summary", batch.pk)
return JsonResponse( return JsonResponse(
{ {
"batch": { "batch": {
@@ -249,6 +271,8 @@ def batch_status(request, batch_id: int):
} }
for node in batch.node_runs.order_by("id") for node in batch.node_runs.order_by("id")
], ],
"notifications": notifications,
"latest_notification": notifications[0] if notifications else None,
} }
) )
@@ -281,14 +305,20 @@ def export_download(request, export_id: int):
extra={"export_id": exported.pk, "storage_path": exported.storage_path}, extra={"export_id": exported.pk, "storage_path": exported.storage_path},
) )
return JsonResponse({"error": "文件不存在。"}, status=404) return JsonResponse({"error": "文件不存在。"}, status=404)
suffix = Path(exported.file_name).suffix.lower()
content_types = { content_types = {
ExportedSummaryFile.ExportType.MARKDOWN: "text/markdown; charset=utf-8", ExportedSummaryFile.ExportType.MARKDOWN: "text/markdown; charset=utf-8",
ExportedSummaryFile.ExportType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ExportedSummaryFile.ExportType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ExportedSummaryFile.ExportType.JSON: "application/json; charset=utf-8", ExportedSummaryFile.ExportType.JSON: "application/json; charset=utf-8",
ExportedSummaryFile.ExportType.WORD: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ExportedSummaryFile.ExportType.WORD: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
ExportedSummaryFile.ExportType.PDF: "application/pdf", ExportedSummaryFile.ExportType.PDF: "application/pdf",
ExportedSummaryFile.ExportType.ZIP: "application/zip",
} }
content_type = content_types.get(exported.export_type, "application/octet-stream") content_type = content_types.get(exported.export_type, "application/octet-stream")
if exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".doc":
content_type = "application/msword"
elif exported.export_type == ExportedSummaryFile.ExportType.WORD and suffix == ".docx":
content_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
logger.info( logger.info(
"Export download started", "Export download started",
extra={ extra={
@@ -319,6 +349,17 @@ def _export_for_user(user, export_id: int) -> ExportedSummaryFile | None:
is_deleted=False, is_deleted=False,
).exists() ).exists()
return exported if allowed else None return exported if allowed else None
if exported.workflow_type == "regulatory_info_package":
if not exported.workflow_batch_id:
return None
allowed = RegulatoryInfoPackageBatch.objects.filter(
pk=exported.workflow_batch_id,
conversation__user=user,
is_deleted=False,
).exists()
return exported if allowed else None
if exported.batch_id is None:
return None
if exported.batch.user_id != user.pk: if exported.batch.user_id != user.pk:
return None return None
return exported return exported

View File

@@ -17,6 +17,8 @@ from review_agent.models import (
Message, Message,
WorkflowNodeRun, WorkflowNodeRun,
) )
from review_agent.notifications.dispatcher import dispatch_workflow_notification
from review_agent.notifications.workflow_adapters import build_file_summary_context
from .events import record_event from .events import record_event
from .services.archive import ARCHIVE_EXTENSIONS from .services.archive import ARCHIVE_EXTENSIONS
@@ -154,14 +156,25 @@ class WorkflowExecutor:
self.batch.finished_at = timezone.now() self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "error_message", "finished_at"]) self.batch.save(update_fields=["status", "error_message", "finished_at"])
record_event(self.batch, "workflow_failed", {"message": str(exc)}) record_event(self.batch, "workflow_failed", {"message": str(exc)})
self._dispatch_completion_notification()
return return
self.batch.status = FileSummaryBatch.Status.SUCCESS self.batch.status = FileSummaryBatch.Status.SUCCESS
self.batch.finished_at = timezone.now() self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "finished_at"]) self.batch.save(update_fields=["status", "finished_at"])
record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk})
self._dispatch_completion_notification()
logger.info("Workflow run completed", extra={"batch_id": self.batch.pk}) logger.info("Workflow run completed", extra={"batch_id": self.batch.pk})
def _dispatch_completion_notification(self) -> None:
try:
dispatch_workflow_notification(build_file_summary_context(self.batch))
except Exception as exc:
logger.warning(
"File summary notification failed without blocking workflow",
extra={"batch_id": self.batch.pk, "error": str(exc)},
)
def _run_node(self, node: WorkflowNodeRun) -> None: def _run_node(self, node: WorkflowNodeRun) -> None:
logger.info( logger.info(
"Workflow node started", "Workflow node started",

View File

@@ -0,0 +1,415 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from review_agent.models import KnowledgeBaseDocument
from review_agent.regulatory_review.services.rag_citation import RagIndexUnavailable, retrieve_citations
from review_agent.regulatory_review.services.rag_embedding import get_embedding_provider
from review_agent.regulatory_review.services.rag_index import chunk_text, extract_text_from_path, is_excluded_source_path
from review_agent.regulatory_review.services.rule_loader import DEFAULT_RULE_PATH, compute_file_sha256, load_rule_file
SUPPORTED_SOURCE_SUFFIXES = {".doc", ".docx", ".pdf", ".txt", ".md", ".pptx", ".xlsx"}
@dataclass(frozen=True)
class ChromaCollectionState:
exists: bool
count: int = 0
error_message: str = ""
sample_metadatas: list[dict[str, Any]] | None = None
source_chunk_counts: dict[str, int] | None = None
def build_knowledge_base_context() -> dict[str, Any]:
rule_info = _rule_info()
source_dir = Path(settings.BASE_DIR) / str(rule_info.get("source_material_dir") or "docs/0.原始材料")
sources = list_source_documents(source_dir)
collection = get_chroma_collection_state()
return {
"name": "NMPA IVD 注册资料法规库",
"description": "用于体外诊断试剂注册资料法规核查的结构化规则和 RAG 依据检索。",
"provider": settings.REGULATORY_RAG_PROVIDER,
"collection_name": settings.REGULATORY_RAG_COLLECTION,
"chroma_path": settings.REGULATORY_RAG_CHROMA_PATH,
"rule": rule_info,
"source_dir": str(source_dir),
"sources": sources,
"source_count": len(sources),
"supported_source_count": sum(1 for item in sources if item["supported"]),
"collection": {
"exists": collection.exists,
"count": collection.count,
"error_message": collection.error_message,
"sample_metadatas": collection.sample_metadatas or [],
},
"status": _status_label(collection),
"build_commands": [
"python manage.py regulatory_rag_build --provider deterministic",
"python manage.py regulatory_rag_build --provider siliconflow",
],
"managed_documents": [],
}
def build_knowledge_base_context_for_user(user) -> dict[str, Any]:
context = build_knowledge_base_context()
documents = list_documents_for_user(user)
context["managed_documents"] = documents
context["managed_document_count"] = len(documents)
context["active_managed_document_count"] = sum(1 for item in documents if item["is_active"])
return context
def list_source_documents(source_dir: Path) -> list[dict[str, Any]]:
if not source_dir.exists():
return []
collection = get_chroma_collection_state()
source_chunk_counts = collection.source_chunk_counts or {}
documents: list[dict[str, Any]] = []
for path in sorted(source_dir.rglob("*")):
if not path.is_file():
continue
suffix = path.suffix.lower()
relative_path = str(path.relative_to(source_dir))
if is_excluded_source_path(relative_path):
continue
indexed_chunk_count = source_chunk_counts.get(relative_path, 0)
documents.append(
{
"name": path.name,
"relative_path": relative_path,
"suffix": suffix.lstrip(".") or "unknown",
"size": path.stat().st_size,
"supported": suffix in SUPPORTED_SOURCE_SUFFIXES,
"indexed": indexed_chunk_count > 0,
"indexed_chunk_count": indexed_chunk_count,
"indexed_label": f"已入库 {indexed_chunk_count}" if indexed_chunk_count else "未入库",
}
)
return documents
def search_knowledge_base(query: str, *, n_results: int = 3) -> dict[str, Any]:
normalized = (query or "").strip()
if not normalized:
return {"query": normalized, "results": [], "error_message": "请输入检索问题。"}
try:
results = retrieve_citations(
normalized,
embedding_provider=get_embedding_provider(),
n_results=n_results,
)
except RagIndexUnavailable as exc:
return {"query": normalized, "results": [], "error_message": str(exc)}
except Exception as exc:
return {"query": normalized, "results": [], "error_message": f"检索失败:{exc}"}
return {"query": normalized, "results": filter_active_knowledge_results(results), "error_message": ""}
def list_documents_for_user(user) -> list[dict[str, Any]]:
return [
serialize_document(document)
for document in KnowledgeBaseDocument.objects.filter(user=user).exclude(status=KnowledgeBaseDocument.Status.DELETED)
]
def create_document_from_upload(
*,
user,
uploaded_file: UploadedFile,
display_name: str = "",
description: str = "",
is_active: bool = True,
) -> KnowledgeBaseDocument:
root = Path(settings.MEDIA_ROOT) / "knowledge_base" / "users" / str(user.pk)
root.mkdir(parents=True, exist_ok=True)
target = _unique_target_path(root, uploaded_file.name)
with target.open("wb") as handle:
for chunk in uploaded_file.chunks():
handle.write(chunk)
status = KnowledgeBaseDocument.Status.ACTIVE if is_active else KnowledgeBaseDocument.Status.DISABLED
document = KnowledgeBaseDocument.objects.create(
user=user,
display_name=(display_name or uploaded_file.name).strip(),
original_name=uploaded_file.name,
storage_path=str(target),
file_size=target.stat().st_size,
content_type=getattr(uploaded_file, "content_type", "") or "",
description=description.strip(),
status=status,
is_active=is_active,
)
if is_active:
index_managed_document(document)
return document
def update_document(document: KnowledgeBaseDocument, payload: dict[str, Any]) -> KnowledgeBaseDocument:
update_fields = []
active_changed = False
if "display_name" in payload:
document.display_name = str(payload.get("display_name") or "").strip() or document.original_name
update_fields.append("display_name")
if "description" in payload:
document.description = str(payload.get("description") or "").strip()
update_fields.append("description")
if "is_active" in payload:
next_is_active = bool(payload.get("is_active"))
active_changed = document.is_active != next_is_active
document.is_active = next_is_active
document.status = KnowledgeBaseDocument.Status.ACTIVE if next_is_active else KnowledgeBaseDocument.Status.DISABLED
update_fields.extend(["is_active", "status"])
if not next_is_active:
remove_managed_document_from_index(document)
document.indexed_chunk_count = 0
document.metadata = {**(document.metadata or {}), "index_status": "disabled", "index_error": ""}
update_fields.extend(["indexed_chunk_count", "metadata"])
if update_fields:
update_fields.append("updated_at")
document.save(update_fields=update_fields)
if active_changed and document.is_active:
index_managed_document(document)
return document
def delete_document(document: KnowledgeBaseDocument) -> KnowledgeBaseDocument:
remove_managed_document_from_index(document)
document.status = KnowledgeBaseDocument.Status.DELETED
document.is_active = False
document.indexed_chunk_count = 0
document.metadata = {**(document.metadata or {}), "index_status": "deleted", "index_error": ""}
document.save(update_fields=["status", "is_active", "indexed_chunk_count", "metadata", "updated_at"])
return document
def serialize_document(document: KnowledgeBaseDocument) -> dict[str, Any]:
indexed_label = f"已入库 {document.indexed_chunk_count}" if document.indexed_chunk_count else "未入库"
return {
"id": document.pk,
"display_name": document.display_name,
"original_name": document.original_name,
"description": document.description,
"file_size": document.file_size,
"content_type": document.content_type,
"status": document.status,
"is_active": document.is_active,
"indexed_chunk_count": document.indexed_chunk_count,
"indexed_label": indexed_label,
"created_at": document.created_at.isoformat() if document.created_at else "",
"updated_at": document.updated_at.isoformat() if document.updated_at else "",
}
def index_managed_document(document: KnowledgeBaseDocument) -> int:
if document.status != KnowledgeBaseDocument.Status.ACTIVE or not document.is_active:
remove_managed_document_from_index(document)
document.indexed_chunk_count = 0
document.metadata = {**(document.metadata or {}), "index_status": "disabled", "index_error": ""}
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
return 0
path = Path(document.storage_path)
if not path.is_absolute():
path = Path(settings.MEDIA_ROOT) / document.storage_path
try:
text = extract_text_from_path(path)
source = f"用户知识库/{document.user_id}/{document.pk}/{document.original_name}"
chunks = chunk_text(text, source=source)
if not chunks:
document.indexed_chunk_count = 0
document.metadata = {**(document.metadata or {}), "index_status": "empty", "index_error": ""}
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
return 0
collection = _load_chroma_collection()
texts = [chunk.text for chunk in chunks]
embeddings = get_embedding_provider()(texts)
ids = [
hashlib.sha256(f"managed:{document.pk}:{chunk.metadata['chunk_index']}".encode("utf-8")).hexdigest()
for chunk in chunks
]
metadatas = [
{
**chunk.metadata,
"source_type": "managed_document",
"document_id": document.pk,
"user_id": document.user_id,
"original_name": document.original_name,
}
for chunk in chunks
]
collection.upsert(ids=ids, documents=texts, metadatas=metadatas, embeddings=embeddings)
document.indexed_chunk_count = len(chunks)
document.metadata = {**(document.metadata or {}), "index_status": "indexed", "index_error": ""}
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
return len(chunks)
except Exception as exc:
document.indexed_chunk_count = 0
document.metadata = {**(document.metadata or {}), "index_status": "failed", "index_error": str(exc)}
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
return 0
def remove_managed_document_from_index(document: KnowledgeBaseDocument) -> None:
try:
collection = _load_chroma_collection()
collection.delete(where={"document_id": document.pk})
except Exception as exc:
document.metadata = {**(document.metadata or {}), "index_delete_error": str(exc)}
def filter_active_knowledge_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
managed_ids = {
int((item.get("metadata") or {}).get("document_id"))
for item in results
if (item.get("metadata") or {}).get("source_type") == "managed_document"
and (item.get("metadata") or {}).get("document_id") is not None
}
if not managed_ids:
return results
active_ids = set(
KnowledgeBaseDocument.objects.filter(
pk__in=managed_ids,
status=KnowledgeBaseDocument.Status.ACTIVE,
is_active=True,
).values_list("pk", flat=True)
)
filtered = []
for item in results:
metadata = item.get("metadata") or {}
if metadata.get("source_type") != "managed_document":
filtered.append(item)
continue
try:
document_id = int(metadata.get("document_id"))
except (TypeError, ValueError):
continue
if document_id in active_ids:
filtered.append(item)
return filtered
def _load_chroma_collection():
try:
import chromadb
except ImportError as exc:
raise RuntimeError("chromadb 未安装。") from exc
persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH)
persist_path.mkdir(parents=True, exist_ok=True)
return chromadb.PersistentClient(path=str(persist_path)).get_or_create_collection(
settings.REGULATORY_RAG_COLLECTION
)
def get_chroma_collection_state() -> ChromaCollectionState:
persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH)
if not persist_path.exists():
return ChromaCollectionState(exists=False, error_message="法规 RAG 索引目录不存在。")
try:
import chromadb
except ImportError:
return ChromaCollectionState(exists=False, error_message="chromadb 未安装。")
try:
collection = chromadb.PersistentClient(path=str(persist_path)).get_collection(settings.REGULATORY_RAG_COLLECTION)
count = collection.count()
metadatas = _load_collection_metadatas(collection, count)
return ChromaCollectionState(
exists=True,
count=count,
sample_metadatas=metadatas[:10],
source_chunk_counts=_count_chunks_by_source(metadatas),
)
except Exception as exc:
return ChromaCollectionState(exists=False, error_message=f"法规 RAG collection 不可用:{exc}")
def _load_collection_metadatas(collection, count: int) -> list[dict[str, Any]]:
metadatas: list[dict[str, Any]] = []
if count <= 0:
return metadatas
page_size = 500
for offset in range(0, count, page_size):
payload = collection.get(
include=["metadatas"],
limit=min(page_size, count - offset),
offset=offset,
)
metadatas.extend(payload.get("metadatas") or [])
return metadatas
def _count_chunks_by_source(metadatas: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for metadata in metadatas:
source = str((metadata or {}).get("source") or "")
if source:
counts[source] = counts.get(source, 0) + 1
return counts
def _rule_info() -> dict[str, Any]:
try:
payload = load_rule_file()
requirements = payload.get("requirements") or []
severity_counts: dict[str, int] = {}
chapter_codes = set()
for requirement in requirements:
severity = str(requirement.get("severity") or "unknown")
severity_counts[severity] = severity_counts.get(severity, 0) + 1
attachment4_code = str(requirement.get("attachment4_code") or "")
if attachment4_code:
chapter_codes.add(attachment4_code.split(".")[0])
return {
"status": "ok",
"code": payload.get("code", ""),
"name": payload.get("name", ""),
"path": str(DEFAULT_RULE_PATH),
"hash": compute_file_sha256(DEFAULT_RULE_PATH),
"rag_collection": payload.get("rag_collection", ""),
"source_material_dir": payload.get("source_material_dir", "docs/0.原始材料"),
"requirement_count": len(requirements),
"chapter_count": len(chapter_codes),
"severity_counts": severity_counts,
}
except Exception as exc:
return {
"status": "failed",
"code": "",
"name": "",
"path": str(DEFAULT_RULE_PATH),
"hash": "",
"rag_collection": "",
"source_material_dir": "docs/0.原始材料",
"requirement_count": 0,
"chapter_count": 0,
"severity_counts": {},
"error_message": str(exc),
}
def _status_label(collection: ChromaCollectionState) -> dict[str, str]:
if not collection.exists:
return {"code": "missing", "label": "未构建", "message": collection.error_message}
if collection.count < 20:
return {"code": "thin", "label": "索引过少", "message": "RAG 能力已打通,但当前索引内容较少,建议补齐材料后重建。"}
return {"code": "ready", "label": "可用", "message": "RAG 索引已构建,可用于法规依据辅助检索。"}
def _unique_target_path(root: Path, original_name: str) -> Path:
safe_name = Path(original_name).name or "document"
target = root / safe_name
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
index = 2
while True:
candidate = root / f"{stem}-{index}{suffix}"
if not candidate.exists():
return candidate
index += 1

View File

@@ -16,7 +16,7 @@ class LLMRequestError(RuntimeError):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def generate_reply(conversation, user_message: str) -> str: def generate_reply(conversation, user_message: str, knowledge_context: str = "") -> str:
"""Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text.""" """Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text."""
if not settings.LLM_API_KEY: if not settings.LLM_API_KEY:
@@ -26,7 +26,7 @@ def generate_reply(conversation, user_message: str) -> str:
payload = { payload = {
"model": settings.LLM_MODEL, "model": settings.LLM_MODEL,
"messages": build_messages(conversation, user_message), "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context),
"temperature": 0.3, "temperature": 0.3,
} }
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@@ -98,7 +98,7 @@ def generate_completion(messages: list[dict[str, str]], *, temperature: float =
raise LLMRequestError("模型接口返回格式不符合预期。") from exc raise LLMRequestError("模型接口返回格式不符合预期。") from exc
def stream_reply(conversation, user_message: str): def stream_reply(conversation, user_message: str, knowledge_context: str = ""):
"""Streams incremental assistant text from the SiliconFlow chat endpoint.""" """Streams incremental assistant text from the SiliconFlow chat endpoint."""
if not settings.LLM_API_KEY: if not settings.LLM_API_KEY:
@@ -108,7 +108,7 @@ def stream_reply(conversation, user_message: str):
payload = { payload = {
"model": settings.LLM_MODEL, "model": settings.LLM_MODEL,
"messages": build_messages(conversation, user_message), "messages": build_messages(conversation, user_message, knowledge_context=knowledge_context),
"temperature": 0.3, "temperature": 0.3,
"stream": True, "stream": True,
} }
@@ -153,10 +153,21 @@ def stream_reply(conversation, user_message: str):
raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]: def build_messages(conversation, latest_user_message: str, knowledge_context: str = "") -> list[dict[str, str]]:
"""Builds system and conversation history messages for the provider call.""" """Builds system and conversation history messages for the provider call."""
messages = [{"role": "system", "content": system_prompt()}] messages = [{"role": "system", "content": system_prompt()}]
if knowledge_context.strip():
messages.append(
{
"role": "system",
"content": (
"以下是全局知识库检索到的材料片段。回答用户时优先依据这些片段;"
"如果片段不足以支持结论,请明确说明信息不足,不要编造。\n\n"
f"{knowledge_context.strip()}"
),
}
)
for message in conversation.messages.all(): for message in conversation.messages.all():
messages.append({"role": message.role, "content": message.content}) messages.append({"role": message.role, "content": message.content})

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from review_agent.feishu_questions.service import answer_question
class Command(BaseCommand):
help = "Simulate a reserved Feishu question against local workflow data."
def add_arguments(self, parser):
parser.add_argument("--username", required=True, help="System username used as asker.")
parser.add_argument("question", help="Question text, for example: 查最新法规核查")
def handle(self, *args, **options):
user = get_user_model().objects.filter(username=options["username"]).first()
if not user:
raise CommandError(f"用户不存在:{options['username']}")
result = answer_question(user, options["question"])
self.stdout.write(result.get("answer_summary") or "无可返回摘要。")

View File

@@ -23,7 +23,7 @@ class Command(BaseCommand):
raise CommandError(f"法规材料目录不存在:{source_dir}") raise CommandError(f"法规材料目录不存在:{source_dir}")
try: try:
provider = get_embedding_provider(options["provider"]) provider = get_embedding_provider(options["provider"])
count = build_chroma_index(source_dir=source_dir, embedding_provider=provider) count = build_chroma_index(source_dir=source_dir, embedding_provider=provider, reset=True)
except Exception as exc: except Exception as exc:
raise CommandError(str(exc)) from exc raise CommandError(str(exc)) from exc
self.stdout.write( self.stdout.write(

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from review_agent.notifications.context import NotificationContext
from review_agent.notifications.dispatcher import dispatch_workflow_notification
class Command(BaseCommand):
help = "Send a manual Feishu test notification through the unified dispatcher."
def add_arguments(self, parser):
parser.add_argument("--username", required=True, help="System username used as trigger user.")
def handle(self, *args, **options):
username = options["username"]
user = get_user_model().objects.filter(username=username).first()
if not user:
raise CommandError(f"用户不存在:{username}")
context = NotificationContext(
workflow_type="manual_test",
workflow_name="飞书测试",
workflow_batch_id=user.pk,
workflow_batch_no=f"MANUAL-{user.pk}",
workflow_status="success",
trigger_user_id=user.pk,
trigger_username=user.get_username(),
title="飞书测试通知",
summary_lines=("这是一条本地手动测试通知。",),
next_step="确认飞书个人账号是否收到消息",
result_path="/",
)
record = dispatch_workflow_notification(context)
self.stdout.write(f"send_status={record.send_status}")
self.stdout.write(f"target={record.target}")
if record.error_message:
self.stdout.write(f"error={record.error_message}")

View File

@@ -0,0 +1,352 @@
# Generated by Django 5.2.14 on 2026-06-07 14:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("review_agent", "0006_alter_exportedsummaryfile_export_type_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="FeishuAccessTokenCache",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("app_id_hash", models.CharField(max_length=128, unique=True)),
("tenant_access_token", models.TextField(blank=True, default="")),
("expires_at", models.DateTimeField(blank=True, null=True)),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "ra_feishu_access_token_cache",
"ordering": ["-updated_at", "-id"],
"indexes": [
models.Index(
fields=["app_id_hash"], name="idx_ra_feishu_token_app"
),
models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"),
],
},
),
migrations.CreateModel(
name="FeishuUserMapping",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"feishu_display_name",
models.CharField(blank=True, default="", max_length=120),
),
(
"feishu_open_id",
models.CharField(blank=True, default="", max_length=120),
),
(
"feishu_user_id",
models.CharField(blank=True, default="", max_length=120),
),
(
"feishu_mobile",
models.CharField(blank=True, default="", max_length=40),
),
("is_active", models.BooleanField(default=True)),
("remark", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"system_user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="feishu_mapping",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "ra_feishu_user_mapping",
"ordering": ["system_user__username", "id"],
},
),
migrations.CreateModel(
name="FeishuQuestionLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"feishu_open_id",
models.CharField(blank=True, default="", max_length=120),
),
(
"feishu_user_id",
models.CharField(blank=True, default="", max_length=120),
),
(
"source_type",
models.CharField(
choices=[
("private_chat", "私聊"),
("group_mention", "群聊 @"),
("simulate", "本地模拟"),
],
default="simulate",
max_length=30,
),
),
(
"message_id",
models.CharField(blank=True, default="", max_length=120),
),
("question_text", models.TextField()),
("intent", models.CharField(blank=True, default="", max_length=60)),
("query_object", models.JSONField(blank=True, default=dict)),
("answer_summary", models.TextField(blank=True, default="")),
(
"permission_result",
models.CharField(blank=True, default="", max_length=40),
),
(
"status",
models.CharField(
choices=[
("success", "成功"),
("failed", "失败"),
("ignored", "忽略"),
],
default="success",
max_length=30,
),
),
("error_message", models.TextField(blank=True, default="")),
("processed_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"system_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="feishu_question_logs",
to=settings.AUTH_USER_MODEL,
),
),
(
"feishu_mapping",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="question_logs",
to="review_agent.feishuusermapping",
),
),
],
options={
"db_table": "ra_feishu_question_log",
"ordering": ["-created_at", "-id"],
},
),
migrations.CreateModel(
name="WorkflowNotificationRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("workflow_type", models.CharField(max_length=40)),
("workflow_batch_id", models.PositiveBigIntegerField()),
("workflow_batch_no", models.CharField(max_length=80)),
("workflow_status", models.CharField(max_length=40)),
("dedupe_key", models.CharField(max_length=160)),
(
"channel",
models.CharField(
choices=[
("mock", "模拟"),
("disabled", "未启用"),
("feishu_api", "飞书 API"),
],
default="mock",
max_length=40,
),
),
("target", models.CharField(blank=True, default="", max_length=160)),
(
"at_display_name",
models.CharField(blank=True, default="", max_length=120),
),
(
"at_identifier_type",
models.CharField(blank=True, default="", max_length=30),
),
(
"at_identifier_masked",
models.CharField(blank=True, default="", max_length=120),
),
(
"send_status",
models.CharField(
choices=[
("pending", "待发送"),
("success", "成功"),
("failed", "失败"),
("skipped_duplicate", "重复跳过"),
("disabled", "未启用"),
],
default="pending",
max_length=30,
),
),
("message_title", models.CharField(max_length=200)),
("message_summary", models.TextField(blank=True, default="")),
(
"result_url",
models.CharField(blank=True, default="", max_length=500),
),
(
"external_message_id",
models.CharField(blank=True, default="", max_length=120),
),
("error_code", models.CharField(blank=True, default="", max_length=80)),
("error_message", models.TextField(blank=True, default="")),
(
"request_duration_ms",
models.PositiveIntegerField(blank=True, null=True),
),
("sent_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"feishu_mapping",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notification_records",
to="review_agent.feishuusermapping",
),
),
(
"trigger_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workflow_notification_records",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "ra_workflow_notification_record",
"ordering": ["-created_at", "-id"],
},
),
migrations.AddIndex(
model_name="feishuusermapping",
index=models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"),
),
migrations.AddIndex(
model_name="feishuusermapping",
index=models.Index(
fields=["feishu_open_id"], name="idx_ra_feishu_map_open"
),
),
migrations.AddIndex(
model_name="feishuusermapping",
index=models.Index(
fields=["feishu_user_id"], name="idx_ra_feishu_map_userid"
),
),
migrations.AddIndex(
model_name="feishuusermapping",
index=models.Index(
fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile"
),
),
migrations.AddIndex(
model_name="feishuquestionlog",
index=models.Index(
fields=["system_user", "created_at"],
name="idx_ra_feishu_q_user_created",
),
),
migrations.AddIndex(
model_name="feishuquestionlog",
index=models.Index(
fields=["intent", "created_at"], name="idx_ra_feishu_q_intent"
),
),
migrations.AddIndex(
model_name="feishuquestionlog",
index=models.Index(
fields=["status", "created_at"], name="idx_ra_feishu_q_status"
),
),
migrations.AddIndex(
model_name="feishuquestionlog",
index=models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"),
),
migrations.AddIndex(
model_name="workflownotificationrecord",
index=models.Index(
fields=["workflow_type", "workflow_batch_id"],
name="idx_ra_notify_workflow",
),
),
migrations.AddIndex(
model_name="workflownotificationrecord",
index=models.Index(
fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created"
),
),
migrations.AddIndex(
model_name="workflownotificationrecord",
index=models.Index(
fields=["send_status", "created_at"], name="idx_ra_notify_status"
),
),
migrations.AddIndex(
model_name="workflownotificationrecord",
index=models.Index(
fields=["workflow_batch_no"], name="idx_ra_notify_batch_no"
),
),
migrations.AddIndex(
model_name="workflownotificationrecord",
index=models.Index(
fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status"
),
),
]

View File

@@ -0,0 +1,80 @@
# Generated by Django 5.2.14 on 2026-06-08 11:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("review_agent", "0007_feishuaccesstokencache_feishuusermapping_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="KnowledgeBaseDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("display_name", models.CharField(max_length=255)),
("original_name", models.CharField(max_length=255)),
("storage_path", models.CharField(max_length=500)),
("file_size", models.BigIntegerField(default=0)),
(
"content_type",
models.CharField(blank=True, default="", max_length=120),
),
("description", models.TextField(blank=True, default="")),
(
"status",
models.CharField(
choices=[
("active", "启用"),
("disabled", "停用"),
("deleted", "已删除"),
],
default="active",
max_length=20,
),
),
("is_active", models.BooleanField(default=True)),
("indexed_chunk_count", models.PositiveIntegerField(default=0)),
("metadata", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="knowledge_base_documents",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "ra_knowledge_base_document",
"ordering": ["-updated_at", "-id"],
"indexes": [
models.Index(
fields=["user", "status"], name="idx_ra_kb_doc_user_status"
),
models.Index(
fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"
),
models.Index(
fields=["status", "updated_at"],
name="idx_ra_kb_doc_status_updated",
),
],
},
),
]

View File

@@ -0,0 +1,388 @@
# Generated by Django 5.2.14 on 2026-06-10 11:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("review_agent", "0008_knowledgebasedocument"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="RegulatoryInfoPackageArtifact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"artifact_type",
models.CharField(
choices=[
("template_copy", "模板副本"),
("instruction_extract", "说明书抽取结果"),
("field_extract_result", "字段抽取结果"),
("merged_fields", "合并字段"),
("generated_document", "生成文件"),
("traceability", "追溯清单"),
("zip_package", "ZIP包"),
("notification_record", "通知记录"),
],
max_length=60,
),
),
(
"file_format",
models.CharField(
choices=[
("json", "JSON"),
("excel", "Excel"),
("docx", "DOCX"),
("doc", "DOC"),
("zip", "ZIP"),
("markdown", "Markdown"),
],
max_length=20,
),
),
("name", models.CharField(max_length=160)),
("file_name", models.CharField(max_length=255)),
("storage_path", models.CharField(max_length=500)),
("file_size", models.BigIntegerField(default=0)),
(
"content_hash",
models.CharField(blank=True, default="", max_length=128),
),
("metadata", models.JSONField(blank=True, default=dict)),
(
"created_by_node",
models.CharField(blank=True, default="", max_length=60),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("is_deleted", models.BooleanField(default=False)),
],
options={
"db_table": "ra_regulatory_info_package_artifact",
"ordering": ["-created_at", "-id"],
},
),
migrations.CreateModel(
name="RegulatoryInfoPackageBatch",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"source_summary_item_id",
models.PositiveBigIntegerField(blank=True, null=True),
),
("batch_no", models.CharField(max_length=64, unique=True)),
(
"status",
models.CharField(
choices=[
("pending", "待执行"),
("running", "执行中"),
("waiting_user", "等待用户"),
("success", "成功"),
("partial_success", "部分成功"),
("failed", "失败"),
("cancelled", "已取消"),
],
default="pending",
max_length=30,
),
),
(
"source_file_name",
models.CharField(blank=True, default="", max_length=255),
),
(
"source_storage_path",
models.CharField(blank=True, default="", max_length=500),
),
(
"product_name",
models.CharField(blank=True, default="", max_length=200),
),
(
"output_zip_name",
models.CharField(
blank=True,
default="第1章 监管信息(预生成版).zip",
max_length=255,
),
),
("generated_files", models.JSONField(blank=True, default=list)),
("missing_fields", models.JSONField(blank=True, default=list)),
("llm_only_fields", models.JSONField(blank=True, default=list)),
("conflict_fields", models.JSONField(blank=True, default=list)),
("risk_notes", models.JSONField(blank=True, default=list)),
(
"template_config_version",
models.CharField(blank=True, default="", max_length=80),
),
(
"template_config_hash",
models.CharField(blank=True, default="", max_length=128),
),
("adapter_summary", models.JSONField(blank=True, default=dict)),
("work_dir", models.CharField(blank=True, default="", max_length=500)),
("error_message", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("started_at", models.DateTimeField(blank=True, null=True)),
("finished_at", models.DateTimeField(blank=True, null=True)),
("archived_at", models.DateTimeField(blank=True, null=True)),
("is_deleted", models.BooleanField(default=False)),
],
options={
"db_table": "ra_regulatory_info_package_batch",
"ordering": ["-created_at", "-id"],
},
),
migrations.CreateModel(
name="RegulatoryInfoPackageNotificationRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"channel",
models.CharField(
choices=[
("feishu_cli", "飞书 CLI"),
("feishu_api", "飞书 API"),
("mock", "模拟"),
],
default="mock",
max_length=30,
),
),
("export_ids", models.JSONField(blank=True, default=list)),
("message_summary", models.TextField(blank=True, default="")),
(
"send_status",
models.CharField(
choices=[
("pending", "待发送"),
("success", "成功"),
("failed", "失败"),
],
default="pending",
max_length=20,
),
),
("retry_count", models.PositiveIntegerField(default=0)),
(
"external_message_id",
models.CharField(blank=True, default="", max_length=120),
),
("error_message", models.TextField(blank=True, default="")),
("sent_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_deleted", models.BooleanField(default=False)),
],
options={
"db_table": "ra_regulatory_info_package_notification_record",
"ordering": ["-created_at", "-id"],
},
),
migrations.AlterField(
model_name="exportedsummaryfile",
name="batch",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="exports",
to="review_agent.filesummarybatch",
),
),
migrations.AlterField(
model_name="exportedsummaryfile",
name="export_type",
field=models.CharField(
choices=[
("markdown", "Markdown"),
("excel", "Excel"),
("json", "JSON"),
("word", "Word"),
("pdf", "PDF"),
("zip", "ZIP"),
],
max_length=20,
),
),
migrations.AddConstraint(
model_name="workflownoderun",
constraint=models.UniqueConstraint(
fields=("workflow_type", "workflow_batch_id", "node_code"),
name="uq_ra_node_workflow_batch_code",
),
),
migrations.AddField(
model_name="regulatoryinfopackagebatch",
name="conversation",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="regulatory_info_package_batches",
to="review_agent.conversation",
),
),
migrations.AddField(
model_name="regulatoryinfopackagebatch",
name="source_attachment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="regulatory_info_package_batches",
to="review_agent.fileattachment",
),
),
migrations.AddField(
model_name="regulatoryinfopackagebatch",
name="source_summary_batch",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="regulatory_info_package_batches",
to="review_agent.filesummarybatch",
),
),
migrations.AddField(
model_name="regulatoryinfopackagebatch",
name="trigger_message",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="triggered_regulatory_info_package_batches",
to="review_agent.message",
),
),
migrations.AddField(
model_name="regulatoryinfopackagebatch",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="review_regulatory_info_package_batches",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="regulatoryinfopackageartifact",
name="batch",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="artifacts",
to="review_agent.regulatoryinfopackagebatch",
),
),
migrations.AddField(
model_name="regulatoryinfopackagenotificationrecord",
name="batch",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="review_agent.regulatoryinfopackagebatch",
),
),
migrations.AddField(
model_name="regulatoryinfopackagenotificationrecord",
name="recipient",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="regulatory_info_package_notifications",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagebatch",
index=models.Index(
fields=["conversation", "status"], name="idx_ra_rip_batch_conv_status"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagebatch",
index=models.Index(
fields=["user", "created_at"], name="idx_ra_rip_batch_user_created"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagebatch",
index=models.Index(
fields=["source_attachment"], name="idx_ra_rip_batch_attachment"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagebatch",
index=models.Index(
fields=["source_summary_batch"], name="idx_ra_rip_batch_summary"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagebatch",
index=models.Index(fields=["created_at"], name="idx_ra_rip_batch_created"),
),
migrations.AddIndex(
model_name="regulatoryinfopackageartifact",
index=models.Index(
fields=["batch", "artifact_type"], name="idx_ra_rip_artifact_batch_type"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackageartifact",
index=models.Index(
fields=["file_format"], name="idx_ra_rip_artifact_format"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackageartifact",
index=models.Index(
fields=["created_at"], name="idx_ra_rip_artifact_created"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagenotificationrecord",
index=models.Index(
fields=["batch", "created_at"], name="idx_ra_rip_notify_batch"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagenotificationrecord",
index=models.Index(
fields=["recipient", "send_status"], name="idx_ra_rip_notify_recipient"
),
),
migrations.AddIndex(
model_name="regulatoryinfopackagenotificationrecord",
index=models.Index(
fields=["send_status", "retry_count"], name="idx_ra_rip_notify_status"
),
),
]

View File

@@ -280,7 +280,11 @@ class WorkflowNodeRun(models.Model):
class Meta: class Meta:
db_table = "ra_workflow_node_run" db_table = "ra_workflow_node_run"
constraints = [ constraints = [
models.UniqueConstraint(fields=["batch", "node_code"], name="uq_ra_node_batch_code") models.UniqueConstraint(fields=["batch", "node_code"], name="uq_ra_node_batch_code"),
models.UniqueConstraint(
fields=["workflow_type", "workflow_batch_id", "node_code"],
name="uq_ra_node_workflow_batch_code",
),
] ]
indexes = [ indexes = [
models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"), models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"),
@@ -336,6 +340,7 @@ class ExportedSummaryFile(models.Model):
JSON = "json", "JSON" JSON = "json", "JSON"
WORD = "word", "Word" WORD = "word", "Word"
PDF = "pdf", "PDF" PDF = "pdf", "PDF"
ZIP = "zip", "ZIP"
class Status(models.TextChoices): class Status(models.TextChoices):
SUCCESS = "success", "成功" SUCCESS = "success", "成功"
@@ -345,6 +350,8 @@ class ExportedSummaryFile(models.Model):
FileSummaryBatch, FileSummaryBatch,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="exports", related_name="exports",
null=True,
blank=True,
) )
workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") workflow_type = models.CharField(max_length=40, blank=True, default="file_summary")
workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True)
@@ -399,6 +406,45 @@ class RegulatoryRuleVersion(models.Model):
return self.code return self.code
class KnowledgeBaseDocument(models.Model):
"""Stores user-managed knowledge-base source documents."""
class Status(models.TextChoices):
ACTIVE = "active", "启用"
DISABLED = "disabled", "停用"
DELETED = "deleted", "已删除"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="knowledge_base_documents",
)
display_name = models.CharField(max_length=255)
original_name = models.CharField(max_length=255)
storage_path = models.CharField(max_length=500)
file_size = models.BigIntegerField(default=0)
content_type = models.CharField(max_length=120, blank=True, default="")
description = models.TextField(blank=True, default="")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
is_active = models.BooleanField(default=True)
indexed_chunk_count = models.PositiveIntegerField(default=0)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ra_knowledge_base_document"
ordering = ["-updated_at", "-id"]
indexes = [
models.Index(fields=["user", "status"], name="idx_ra_kb_doc_user_status"),
models.Index(fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"),
models.Index(fields=["status", "updated_at"], name="idx_ra_kb_doc_status_updated"),
]
def __str__(self) -> str:
return self.display_name
class ApplicationFormFillBatch(models.Model): class ApplicationFormFillBatch(models.Model):
"""Tracks one application-form auto-fill workflow run.""" """Tracks one application-form auto-fill workflow run."""
@@ -485,6 +531,87 @@ class ApplicationFormFillBatch(models.Model):
return self.batch_no return self.batch_no
class RegulatoryInfoPackageBatch(models.Model):
"""Tracks one Chapter 1 regulatory information package workflow run."""
class Status(models.TextChoices):
PENDING = "pending", "待执行"
RUNNING = "running", "执行中"
WAITING_USER = "waiting_user", "等待用户"
SUCCESS = "success", "成功"
PARTIAL_SUCCESS = "partial_success", "部分成功"
FAILED = "failed", "失败"
CANCELLED = "cancelled", "已取消"
conversation = models.ForeignKey(
Conversation,
on_delete=models.CASCADE,
related_name="regulatory_info_package_batches",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="review_regulatory_info_package_batches",
)
trigger_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="triggered_regulatory_info_package_batches",
)
source_attachment = models.ForeignKey(
FileAttachment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="regulatory_info_package_batches",
)
source_summary_batch = models.ForeignKey(
FileSummaryBatch,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="regulatory_info_package_batches",
)
source_summary_item_id = models.PositiveBigIntegerField(null=True, blank=True)
batch_no = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=30, choices=Status.choices, default=Status.PENDING)
source_file_name = models.CharField(max_length=255, blank=True, default="")
source_storage_path = models.CharField(max_length=500, blank=True, default="")
product_name = models.CharField(max_length=200, blank=True, default="")
output_zip_name = models.CharField(max_length=255, blank=True, default="第1章 监管信息(预生成版).zip")
generated_files = models.JSONField(default=list, blank=True)
missing_fields = models.JSONField(default=list, blank=True)
llm_only_fields = models.JSONField(default=list, blank=True)
conflict_fields = models.JSONField(default=list, blank=True)
risk_notes = models.JSONField(default=list, blank=True)
template_config_version = models.CharField(max_length=80, blank=True, default="")
template_config_hash = models.CharField(max_length=128, blank=True, default="")
adapter_summary = models.JSONField(default=dict, blank=True)
work_dir = models.CharField(max_length=500, blank=True, default="")
error_message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
is_deleted = models.BooleanField(default=False)
class Meta:
db_table = "ra_regulatory_info_package_batch"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["conversation", "status"], name="idx_ra_rip_batch_conv_status"),
models.Index(fields=["user", "created_at"], name="idx_ra_rip_batch_user_created"),
models.Index(fields=["source_attachment"], name="idx_ra_rip_batch_attachment"),
models.Index(fields=["source_summary_batch"], name="idx_ra_rip_batch_summary"),
models.Index(fields=["created_at"], name="idx_ra_rip_batch_created"),
]
def __str__(self) -> str:
return self.batch_no
class RegulatoryReviewBatch(models.Model): class RegulatoryReviewBatch(models.Model):
"""Tracks one NMPA regulatory review workflow run.""" """Tracks one NMPA regulatory review workflow run."""
@@ -706,6 +833,54 @@ class ApplicationFormFillArtifact(models.Model):
] ]
class RegulatoryInfoPackageArtifact(models.Model):
"""Stores regulatory information package intermediate and generated files."""
class ArtifactType(models.TextChoices):
TEMPLATE_COPY = "template_copy", "模板副本"
INSTRUCTION_EXTRACT = "instruction_extract", "说明书抽取结果"
FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果"
MERGED_FIELDS = "merged_fields", "合并字段"
GENERATED_DOCUMENT = "generated_document", "生成文件"
TRACEABILITY = "traceability", "追溯清单"
ZIP_PACKAGE = "zip_package", "ZIP包"
NOTIFICATION_RECORD = "notification_record", "通知记录"
class FileFormat(models.TextChoices):
JSON = "json", "JSON"
EXCEL = "excel", "Excel"
DOCX = "docx", "DOCX"
DOC = "doc", "DOC"
ZIP = "zip", "ZIP"
MARKDOWN = "markdown", "Markdown"
batch = models.ForeignKey(
RegulatoryInfoPackageBatch,
on_delete=models.CASCADE,
related_name="artifacts",
)
artifact_type = models.CharField(max_length=60, choices=ArtifactType.choices)
file_format = models.CharField(max_length=20, choices=FileFormat.choices)
name = models.CharField(max_length=160)
file_name = models.CharField(max_length=255)
storage_path = models.CharField(max_length=500)
file_size = models.BigIntegerField(default=0)
content_hash = models.CharField(max_length=128, blank=True, default="")
metadata = models.JSONField(default=dict, blank=True)
created_by_node = models.CharField(max_length=60, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
is_deleted = models.BooleanField(default=False)
class Meta:
db_table = "ra_regulatory_info_package_artifact"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["batch", "artifact_type"], name="idx_ra_rip_artifact_batch_type"),
models.Index(fields=["file_format"], name="idx_ra_rip_artifact_format"),
models.Index(fields=["created_at"], name="idx_ra_rip_artifact_created"),
]
class ApplicationFormFillNotificationRecord(models.Model): class ApplicationFormFillNotificationRecord(models.Model):
"""Stores mock/Feishu notification records for application-form auto-fill.""" """Stores mock/Feishu notification records for application-form auto-fill."""
@@ -754,3 +929,251 @@ class ApplicationFormFillNotificationRecord(models.Model):
models.Index(fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient"), models.Index(fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient"),
models.Index(fields=["send_status", "retry_count"], name="idx_ra_aff_notify_status"), models.Index(fields=["send_status", "retry_count"], name="idx_ra_aff_notify_status"),
] ]
class RegulatoryInfoPackageNotificationRecord(models.Model):
"""Stores mock/Feishu notification records for regulatory info packages."""
class Channel(models.TextChoices):
FEISHU_CLI = "feishu_cli", "飞书 CLI"
FEISHU_API = "feishu_api", "飞书 API"
MOCK = "mock", "模拟"
class SendStatus(models.TextChoices):
PENDING = "pending", "待发送"
SUCCESS = "success", "成功"
FAILED = "failed", "失败"
batch = models.ForeignKey(
RegulatoryInfoPackageBatch,
on_delete=models.CASCADE,
related_name="notifications",
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="regulatory_info_package_notifications",
)
channel = models.CharField(max_length=30, choices=Channel.choices, default=Channel.MOCK)
export_ids = models.JSONField(default=list, blank=True)
message_summary = models.TextField(blank=True, default="")
send_status = models.CharField(
max_length=20,
choices=SendStatus.choices,
default=SendStatus.PENDING,
)
retry_count = models.PositiveIntegerField(default=0)
external_message_id = models.CharField(max_length=120, blank=True, default="")
error_message = models.TextField(blank=True, default="")
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_deleted = models.BooleanField(default=False)
class Meta:
db_table = "ra_regulatory_info_package_notification_record"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["batch", "created_at"], name="idx_ra_rip_notify_batch"),
models.Index(fields=["recipient", "send_status"], name="idx_ra_rip_notify_recipient"),
models.Index(fields=["send_status", "retry_count"], name="idx_ra_rip_notify_status"),
]
class FeishuUserMapping(models.Model):
"""Maps a system user to Feishu identifiers maintained by Admin."""
system_user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="feishu_mapping",
)
feishu_display_name = models.CharField(max_length=120, blank=True, default="")
feishu_open_id = models.CharField(max_length=120, blank=True, default="")
feishu_user_id = models.CharField(max_length=120, blank=True, default="")
feishu_mobile = models.CharField(max_length=40, blank=True, default="")
is_active = models.BooleanField(default=True)
remark = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ra_feishu_user_mapping"
ordering = ["system_user__username", "id"]
indexes = [
models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"),
models.Index(fields=["feishu_open_id"], name="idx_ra_feishu_map_open"),
models.Index(fields=["feishu_user_id"], name="idx_ra_feishu_map_userid"),
models.Index(fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile"),
]
def preferred_identifier(self) -> tuple[str, str]:
if self.feishu_open_id:
return "open_id", self.feishu_open_id
if self.feishu_user_id:
return "user_id", self.feishu_user_id
if self.feishu_mobile:
return "mobile", self.feishu_mobile
return "missing", ""
def __str__(self) -> str:
return self.feishu_display_name or self.system_user.get_username()
class FeishuAccessTokenCache(models.Model):
"""Caches Feishu tenant_access_token until its expiry time."""
app_id_hash = models.CharField(max_length=128, unique=True)
tenant_access_token = models.TextField(blank=True, default="")
expires_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ra_feishu_access_token_cache"
ordering = ["-updated_at", "-id"]
indexes = [
models.Index(fields=["app_id_hash"], name="idx_ra_feishu_token_app"),
models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"),
]
def is_valid(self, now=None) -> bool:
from django.utils import timezone
current = now or timezone.now()
return bool(self.tenant_access_token and self.expires_at and self.expires_at > current)
def __str__(self) -> str:
return f"Feishu token cache {self.app_id_hash[:8]}"
class WorkflowNotificationRecord(models.Model):
"""Stores unified notification send records for all workflow types."""
class Channel(models.TextChoices):
MOCK = "mock", "模拟"
DISABLED = "disabled", "未启用"
FEISHU_API = "feishu_api", "飞书 API"
class SendStatus(models.TextChoices):
PENDING = "pending", "待发送"
SUCCESS = "success", "成功"
FAILED = "failed", "失败"
SKIPPED_DUPLICATE = "skipped_duplicate", "重复跳过"
DISABLED = "disabled", "未启用"
workflow_type = models.CharField(max_length=40)
workflow_batch_id = models.PositiveBigIntegerField()
workflow_batch_no = models.CharField(max_length=80)
workflow_status = models.CharField(max_length=40)
dedupe_key = models.CharField(max_length=160)
trigger_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workflow_notification_records",
)
feishu_mapping = models.ForeignKey(
FeishuUserMapping,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="notification_records",
)
channel = models.CharField(max_length=40, choices=Channel.choices, default=Channel.MOCK)
target = models.CharField(max_length=160, blank=True, default="")
at_display_name = models.CharField(max_length=120, blank=True, default="")
at_identifier_type = models.CharField(max_length=30, blank=True, default="")
at_identifier_masked = models.CharField(max_length=120, blank=True, default="")
send_status = models.CharField(
max_length=30,
choices=SendStatus.choices,
default=SendStatus.PENDING,
)
message_title = models.CharField(max_length=200)
message_summary = models.TextField(blank=True, default="")
result_url = models.CharField(max_length=500, blank=True, default="")
external_message_id = models.CharField(max_length=120, blank=True, default="")
error_code = models.CharField(max_length=80, blank=True, default="")
error_message = models.TextField(blank=True, default="")
request_duration_ms = models.PositiveIntegerField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ra_workflow_notification_record"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["workflow_type", "workflow_batch_id"], name="idx_ra_notify_workflow"),
models.Index(fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created"),
models.Index(fields=["send_status", "created_at"], name="idx_ra_notify_status"),
models.Index(fields=["workflow_batch_no"], name="idx_ra_notify_batch_no"),
models.Index(fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status"),
]
@classmethod
def build_dedupe_key(cls, workflow_type: str, workflow_batch_id: int, workflow_status: str) -> str:
return f"{workflow_type}:{workflow_batch_id}:{workflow_status}"
@classmethod
def already_successfully_sent(cls, dedupe_key: str) -> bool:
return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists()
def __str__(self) -> str:
return f"{self.workflow_type} {self.workflow_batch_no} {self.send_status}"
class FeishuQuestionLog(models.Model):
"""Records reserved Feishu question handling without storing full answers."""
class SourceType(models.TextChoices):
PRIVATE_CHAT = "private_chat", "私聊"
GROUP_MENTION = "group_mention", "群聊 @"
SIMULATE = "simulate", "本地模拟"
class Status(models.TextChoices):
SUCCESS = "success", "成功"
FAILED = "failed", "失败"
IGNORED = "ignored", "忽略"
system_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="feishu_question_logs",
)
feishu_mapping = models.ForeignKey(
FeishuUserMapping,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="question_logs",
)
feishu_open_id = models.CharField(max_length=120, blank=True, default="")
feishu_user_id = models.CharField(max_length=120, blank=True, default="")
source_type = models.CharField(max_length=30, choices=SourceType.choices, default=SourceType.SIMULATE)
message_id = models.CharField(max_length=120, blank=True, default="")
question_text = models.TextField()
intent = models.CharField(max_length=60, blank=True, default="")
query_object = models.JSONField(default=dict, blank=True)
answer_summary = models.TextField(blank=True, default="")
permission_result = models.CharField(max_length=40, blank=True, default="")
status = models.CharField(max_length=30, choices=Status.choices, default=Status.SUCCESS)
error_message = models.TextField(blank=True, default="")
processed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "ra_feishu_question_log"
ordering = ["-created_at", "-id"]
indexes = [
models.Index(fields=["system_user", "created_at"], name="idx_ra_feishu_q_user_created"),
models.Index(fields=["intent", "created_at"], name="idx_ra_feishu_q_intent"),
models.Index(fields=["status", "created_at"], name="idx_ra_feishu_q_status"),
models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"),
]
def __str__(self) -> str:
return f"{self.intent or 'unknown'} {self.status}"

View File

@@ -0,0 +1 @@
"""Unified workflow notification services."""

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class NotificationContext:
workflow_type: str
workflow_name: str
workflow_batch_id: int
workflow_batch_no: str
workflow_status: str
trigger_user_id: int
trigger_username: str
title: str
summary_lines: tuple[str, ...]
next_step: str
result_path: str
@property
def dedupe_key(self) -> str:
return f"{self.workflow_type}:{self.workflow_batch_id}:{self.workflow_status}"

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import logging
from django.conf import settings
from review_agent.models import WorkflowNotificationRecord
from .context import NotificationContext
from .feishu_message_api import send_personal_message
from .feishu_token import get_tenant_access_token
from .message_builder import build_feishu_post_message, build_message_summary
from .recipient import ResolvedFeishuTarget, resolve_configured_personal_recipient
from .records import (
create_disabled_record,
create_failed_record,
create_success_record,
existing_success_record,
)
logger = logging.getLogger("review_agent.notifications.dispatcher")
def dispatch_workflow_notification(context: NotificationContext) -> WorkflowNotificationRecord:
existing = existing_success_record(context)
if existing:
return existing
try:
target = resolve_configured_personal_recipient()
summary = build_message_summary(context, target)
if not getattr(settings, "FEISHU_NOTIFY_ENABLED", False):
return create_disabled_record(context, target, summary)
if not target.ok:
return create_failed_record(
context,
target,
summary,
error_code=target.error_code,
error_message=target.error_message,
)
token_result = get_tenant_access_token()
if not token_result.ok:
return create_failed_record(
context,
target,
summary,
error_code=token_result.error_code,
error_message=token_result.error_message,
)
payload = build_feishu_post_message(context, target)
send_result = send_personal_message(
tenant_access_token=token_result.tenant_access_token,
receive_id_type=target.identifier_type,
payload=payload,
)
if send_result.ok:
return create_success_record(
context,
target,
summary,
external_message_id=send_result.external_message_id,
request_duration_ms=send_result.request_duration_ms,
)
return create_failed_record(
context,
target,
summary,
error_code=send_result.error_code,
error_message=send_result.error_message,
request_duration_ms=send_result.request_duration_ms,
)
except Exception as exc:
logger.exception("Feishu notification dispatch failed", extra={"dedupe_key": context.dedupe_key})
fallback_target = ResolvedFeishuTarget(
ok=False,
identifier_type="missing",
identifier_value="",
display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人"),
masked_identifier="",
error_code="dispatch_exception",
error_message=str(exc),
)
return create_failed_record(
context,
fallback_target,
"\n".join([context.title, *context.summary_lines]),
error_code="dispatch_exception",
error_message=str(exc),
)

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass
import time
from django.conf import settings
import httpx
from .feishu_token import get_tenant_access_token
@dataclass(frozen=True)
class FeishuMessageResult:
ok: bool
external_message_id: str = ""
error_code: str = ""
error_message: str = ""
request_duration_ms: int | None = None
refreshed_token: bool = False
def send_personal_message(
*,
tenant_access_token: str,
receive_id_type: str,
payload: dict,
retry_on_token_expired: bool = True,
) -> FeishuMessageResult:
start = time.monotonic()
try:
response = httpx.post(
getattr(settings, "FEISHU_MESSAGE_API_URL"),
params={"receive_id_type": receive_id_type},
json=payload,
headers={"Authorization": f"Bearer {tenant_access_token}"},
timeout=10,
)
duration_ms = int((time.monotonic() - start) * 1000)
data = response.json()
except httpx.TimeoutException:
return FeishuMessageResult(ok=False, error_code="timeout", error_message="发送飞书消息超时")
except Exception as exc:
return FeishuMessageResult(ok=False, error_code="request_error", error_message=str(exc))
if response.status_code >= 400:
return FeishuMessageResult(
ok=False,
error_code=str(response.status_code),
error_message=response.text[:500],
request_duration_ms=duration_ms,
)
code = int(data.get("code") or 0)
if code == 0:
message_id = str((data.get("data") or {}).get("message_id") or "")
return FeishuMessageResult(ok=True, external_message_id=message_id, request_duration_ms=duration_ms)
if retry_on_token_expired and code in {99991663, 99991664, 99991668, 99991669}:
token_result = get_tenant_access_token(force_refresh=True)
if token_result.ok:
retry_result = send_personal_message(
tenant_access_token=token_result.tenant_access_token,
receive_id_type=receive_id_type,
payload=payload,
retry_on_token_expired=False,
)
return FeishuMessageResult(
ok=retry_result.ok,
external_message_id=retry_result.external_message_id,
error_code=retry_result.error_code,
error_message=retry_result.error_message,
request_duration_ms=retry_result.request_duration_ms,
refreshed_token=True,
)
return FeishuMessageResult(
ok=False,
error_code=token_result.error_code,
error_message=token_result.error_message,
request_duration_ms=duration_ms,
)
return FeishuMessageResult(
ok=False,
error_code=str(code or "api_error"),
error_message=str(data.get("msg") or "飞书消息 API 失败"),
request_duration_ms=duration_ms,
)

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from django.conf import settings
from django.utils import timezone
import httpx
from review_agent.models import FeishuAccessTokenCache
@dataclass(frozen=True)
class FeishuTokenResult:
ok: bool
tenant_access_token: str = ""
error_code: str = ""
error_message: str = ""
def app_id_hash(app_id: str) -> str:
return hashlib.sha256(app_id.encode("utf-8")).hexdigest()
def get_tenant_access_token(*, force_refresh: bool = False) -> FeishuTokenResult:
app_id = getattr(settings, "FEISHU_APP_ID", "")
app_secret = getattr(settings, "FEISHU_APP_SECRET", "")
if not app_id or not app_secret:
return FeishuTokenResult(
ok=False,
error_code="config_missing",
error_message="未配置 FEISHU_APP_ID 或 FEISHU_APP_SECRET",
)
hashed_app_id = app_id_hash(app_id)
now = timezone.now()
cache = FeishuAccessTokenCache.objects.filter(app_id_hash=hashed_app_id).first()
if cache and not force_refresh and cache.is_valid(now=now):
return FeishuTokenResult(ok=True, tenant_access_token=cache.tenant_access_token)
try:
response = httpx.post(
getattr(settings, "FEISHU_TOKEN_API_URL"),
json={"app_id": app_id, "app_secret": app_secret},
timeout=10,
)
data = response.json()
except httpx.TimeoutException:
return _save_token_error(hashed_app_id, "timeout", "获取 tenant_access_token 超时")
except Exception as exc:
return _save_token_error(hashed_app_id, "request_error", str(exc))
if response.status_code >= 400:
return _save_token_error(hashed_app_id, str(response.status_code), response.text[:500])
if int(data.get("code") or 0) != 0:
return _save_token_error(hashed_app_id, str(data.get("code") or "api_error"), str(data.get("msg") or "token API 失败"))
token = str(data.get("tenant_access_token") or "")
expire_seconds = int(data.get("expire") or getattr(settings, "FEISHU_TENANT_TOKEN_CACHE_SECONDS", 6600))
if not token:
return _save_token_error(hashed_app_id, "token_missing", "飞书未返回 tenant_access_token")
FeishuAccessTokenCache.objects.update_or_create(
app_id_hash=hashed_app_id,
defaults={
"tenant_access_token": token,
"expires_at": now + timezone.timedelta(seconds=max(expire_seconds - 60, 60)),
"error_message": "",
},
)
return FeishuTokenResult(ok=True, tenant_access_token=token)
def _save_token_error(app_id_hash_value: str, error_code: str, error_message: str) -> FeishuTokenResult:
FeishuAccessTokenCache.objects.update_or_create(
app_id_hash=app_id_hash_value,
defaults={
"tenant_access_token": "",
"expires_at": None,
"error_message": error_message[:1000],
},
)
return FeishuTokenResult(ok=False, error_code=error_code, error_message=error_message[:1000])

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import json
from django.conf import settings
from .context import NotificationContext
from .recipient import ResolvedFeishuTarget
def absolute_result_url(path: str) -> str:
base_url = getattr(settings, "PUBLIC_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
if not path:
return base_url
if path.startswith("http://") or path.startswith("https://"):
return path
return f"{base_url}/{path.lstrip('/')}"
def build_message_summary(context: NotificationContext, target: ResolvedFeishuTarget) -> str:
lines = [
context.title,
f"批次:{context.workflow_batch_no}",
f"状态:{context.workflow_status}",
f"发起人:{context.trigger_username}",
f"接收人:{target.display_name}",
*context.summary_lines,
f"下一步:{context.next_step}",
]
return "\n".join(line for line in lines if line)
def build_feishu_post_message(context: NotificationContext, target: ResolvedFeishuTarget) -> dict:
result_url = absolute_result_url(context.result_path)
content = [
[{"tag": "text", "text": f"{context.title}\n"}],
[{"tag": "text", "text": f"流程:{context.workflow_name}\n"}],
[{"tag": "text", "text": f"批次:{context.workflow_batch_no}\n"}],
[{"tag": "text", "text": f"状态:{context.workflow_status}\n"}],
[{"tag": "text", "text": f"发起人:{context.trigger_username}\n"}],
]
for line in context.summary_lines:
content.append([{"tag": "text", "text": f"{line}\n"}])
content.extend(
[
[{"tag": "text", "text": f"下一步:{context.next_step}\n"}],
[{"tag": "a", "text": "查看系统结果", "href": result_url}],
]
)
return {
"receive_id": target.identifier_value,
"msg_type": "post",
"content": json.dumps(
{
"zh_cn": {
"title": context.title,
"content": content,
}
},
ensure_ascii=False,
),
}

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from review_agent.models import WorkflowNotificationRecord
def get_notification_records(workflow_type: str, batch_id: int):
return WorkflowNotificationRecord.objects.filter(
workflow_type=workflow_type,
workflow_batch_id=batch_id,
).order_by("-created_at", "-id")
def serialize_notification_record(record: WorkflowNotificationRecord) -> dict[str, object]:
return {
"id": record.pk,
"channel": record.channel,
"target": record.target,
"receiver": record.at_display_name or record.target,
"identifier_type": record.at_identifier_type,
"identifier_masked": record.at_identifier_masked,
"send_status": record.send_status,
"status_label": notification_status_label(record),
"sent_at": record.sent_at.isoformat() if record.sent_at else "",
"created_at": record.created_at.isoformat(),
"error_code": record.error_code,
"error_message": record.error_message,
}
def serialize_notification_records(workflow_type: str, batch_id: int) -> list[dict[str, object]]:
return [serialize_notification_record(record) for record in get_notification_records(workflow_type, batch_id)]
def notification_status_label(record: WorkflowNotificationRecord) -> str:
labels = {
WorkflowNotificationRecord.SendStatus.SUCCESS: "飞书通知已发送",
WorkflowNotificationRecord.SendStatus.FAILED: "飞书通知失败",
WorkflowNotificationRecord.SendStatus.DISABLED: "飞书通知未启用",
WorkflowNotificationRecord.SendStatus.SKIPPED_DUPLICATE: "飞书通知已跳过重复发送",
WorkflowNotificationRecord.SendStatus.PENDING: "飞书通知待发送",
}
return labels.get(record.send_status, record.send_status)

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
@dataclass(frozen=True)
class ResolvedFeishuTarget:
ok: bool
identifier_type: str
identifier_value: str
display_name: str
masked_identifier: str
error_code: str = ""
error_message: str = ""
def mask_identifier(value: str) -> str:
if not value:
return ""
if len(value) <= 8:
return value[:2] + "***"
return f"{value[:4]}***{value[-4:]}"
def resolve_configured_personal_recipient() -> ResolvedFeishuTarget:
open_id = getattr(settings, "FEISHU_DEFAULT_USER_OPEN_ID", "")
user_id = getattr(settings, "FEISHU_DEFAULT_USER_ID", "")
display_name = getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人")
if open_id:
return ResolvedFeishuTarget(
ok=True,
identifier_type="open_id",
identifier_value=open_id,
display_name=display_name,
masked_identifier=mask_identifier(open_id),
)
if user_id:
return ResolvedFeishuTarget(
ok=True,
identifier_type="user_id",
identifier_value=user_id,
display_name=display_name,
masked_identifier=mask_identifier(user_id),
)
return ResolvedFeishuTarget(
ok=False,
identifier_type="missing",
identifier_value="",
display_name=display_name,
masked_identifier="",
error_code="recipient_missing",
error_message="未配置 FEISHU_DEFAULT_USER_OPEN_ID 或 FEISHU_DEFAULT_USER_ID",
)

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
from django.utils import timezone
from review_agent.models import WorkflowNotificationRecord
from .context import NotificationContext
from .message_builder import absolute_result_url
from .recipient import ResolvedFeishuTarget
def existing_success_record(context: NotificationContext) -> WorkflowNotificationRecord | None:
return (
WorkflowNotificationRecord.objects.filter(
dedupe_key=context.dedupe_key,
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
)
.order_by("-created_at", "-id")
.first()
)
def create_disabled_record(
context: NotificationContext,
target: ResolvedFeishuTarget,
message_summary: str,
) -> WorkflowNotificationRecord:
return _create_record(
context,
target,
channel=WorkflowNotificationRecord.Channel.DISABLED,
send_status=WorkflowNotificationRecord.SendStatus.DISABLED,
message_summary=message_summary,
error_code="notify_disabled",
error_message="FEISHU_NOTIFY_ENABLED 未启用",
)
def create_failed_record(
context: NotificationContext,
target: ResolvedFeishuTarget,
message_summary: str,
*,
error_code: str,
error_message: str,
request_duration_ms: int | None = None,
) -> WorkflowNotificationRecord:
return _create_record(
context,
target,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
message_summary=message_summary,
error_code=error_code,
error_message=error_message,
request_duration_ms=request_duration_ms,
)
def create_success_record(
context: NotificationContext,
target: ResolvedFeishuTarget,
message_summary: str,
*,
external_message_id: str,
request_duration_ms: int | None = None,
) -> WorkflowNotificationRecord:
return _create_record(
context,
target,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
message_summary=message_summary,
external_message_id=external_message_id,
request_duration_ms=request_duration_ms,
sent_at=timezone.now(),
)
def _create_record(
context: NotificationContext,
target: ResolvedFeishuTarget,
*,
channel: str,
send_status: str,
message_summary: str,
error_code: str = "",
error_message: str = "",
external_message_id: str = "",
request_duration_ms: int | None = None,
sent_at=None,
) -> WorkflowNotificationRecord:
return WorkflowNotificationRecord.objects.create(
workflow_type=context.workflow_type,
workflow_batch_id=context.workflow_batch_id,
workflow_batch_no=context.workflow_batch_no,
workflow_status=context.workflow_status,
dedupe_key=context.dedupe_key,
trigger_user_id=context.trigger_user_id,
channel=channel,
target=target.display_name,
at_display_name=target.display_name,
at_identifier_type=target.identifier_type,
at_identifier_masked=target.masked_identifier,
send_status=send_status,
message_title=context.title,
message_summary=message_summary,
result_url=absolute_result_url(context.result_path),
external_message_id=external_message_id,
error_code=error_code,
error_message=error_message[:1000],
request_duration_ms=request_duration_ms,
sent_at=sent_at,
)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from review_agent.application_form_fill.constants import WORKFLOW_TYPE as FORM_FILL_WORKFLOW_TYPE
from review_agent.models import (
ApplicationFormFillBatch,
ExportedSummaryFile,
FileSummaryBatch,
RegulatoryIssue,
RegulatoryReviewBatch,
)
from .context import NotificationContext
def build_file_summary_context(batch: FileSummaryBatch) -> NotificationContext:
status = batch.status
abnormal_count = int(batch.failed_files or 0) + int(batch.unsupported_files or 0) + int(batch.uncertain_files or 0)
return NotificationContext(
workflow_type="file_summary",
workflow_name="自动汇总",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=status,
trigger_user_id=batch.user_id,
trigger_username=batch.user.get_username(),
title=f"自动汇总{_status_label(status)}",
summary_lines=(
f"文件总数 {batch.total_files} 个,成功 {batch.success_files}",
f"异常/不支持/不确定 {abnormal_count} 个,总页数 {batch.total_pages}",
_error_line(batch.error_message),
),
next_step="查看文件目录、页数统计和导出结果",
result_path=f"/api/review-agent/file-summary/{batch.pk}/status/",
)
def build_regulatory_review_context(batch: RegulatoryReviewBatch) -> NotificationContext:
summary = batch.risk_summary or _count_regulatory_issues(batch)
return NotificationContext(
workflow_type="regulatory_review",
workflow_name="法规核查",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=batch.status,
trigger_user_id=batch.user_id,
trigger_username=batch.user.get_username(),
title=f"法规核查{_status_label(batch.status)}",
summary_lines=(
f"阻断项 {int(summary.get('blocking') or 0)} 个,高风险 {int(summary.get('high') or 0)}",
f"中风险 {int(summary.get('medium') or 0)} 个,低风险 {int(summary.get('low') or 0)}",
_error_line(batch.error_message),
),
next_step="查看风险报告并处理整改项",
result_path=f"/api/review-agent/regulatory-review/{batch.pk}/status/",
)
def build_application_form_fill_context(batch: ApplicationFormFillBatch) -> NotificationContext:
export_count = ExportedSummaryFile.objects.filter(
workflow_type=FORM_FILL_WORKFLOW_TYPE,
workflow_batch_id=batch.pk,
).count()
return NotificationContext(
workflow_type=FORM_FILL_WORKFLOW_TYPE,
workflow_name="自动填表",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=batch.status,
trigger_user_id=batch.user_id,
trigger_username=batch.user.get_username(),
title=f"自动填表{_status_label(batch.status)}",
summary_lines=(
f"模板 {', '.join(batch.selected_templates or []) or '未识别'}",
f"导出文件 {export_count} 个,冲突字段 {len(batch.conflict_summary or [])}",
_error_line(batch.error_message),
),
next_step="下载生成文件并检查字段冲突",
result_path=f"/api/review-agent/application-form-fill/{batch.pk}/status/",
)
def _count_regulatory_issues(batch: RegulatoryReviewBatch) -> dict[str, int]:
return {
severity: RegulatoryIssue.objects.filter(batch=batch, severity=severity).count()
for severity in ["blocking", "high", "medium", "low", "info"]
}
def _status_label(status: str) -> str:
labels = {
"success": "完成",
"partial_success": "部分完成",
"failed": "失败",
"cancelled": "已取消",
}
return labels.get(status, status)
def _error_line(error_message: str) -> str:
if not error_message:
return ""
return f"失败原因:{error_message[:160]}"

View File

@@ -0,0 +1,2 @@
"""Chapter 1 regulatory information package workflow."""

View File

@@ -0,0 +1,30 @@
WORKFLOW_TYPE = "regulatory_info_package"
DEFAULT_ZIP_NAME = "第1章 监管信息(预生成版).zip"
REGULATORY_INFO_PACKAGE_TRIGGER_KEYWORDS = [
"根据说明书生成第1章监管信息",
"生成监管信息材料包",
"从说明书生成第1章材料",
"第1章监管信息",
"监管信息材料包",
]
REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS = [
("prepare", "准备资料", "regulatory_info_package"),
("template_copy", "复制模板", "regulatory_info_package"),
("text_extract", "抽取说明书", "regulatory_info_package"),
("field_extract", "抽取字段", "regulatory_info_package"),
("field_merge", "合并字段", "regulatory_info_package"),
("generate_docs", "生成材料", "regulatory_info_package"),
("highlight_review_items", "标记待确认", "regulatory_info_package"),
("trace_export", "追溯清单", "regulatory_info_package"),
("zip_export", "打包下载", "regulatory_info_package"),
("notify", "通知", "regulatory_info_package"),
("completed", "完成", "completed"),
]
GENERATED_FILE_SUCCESS = "success"
GENERATED_FILE_FALLBACK_SUCCESS = "fallback_success"
GENERATED_FILE_FAILED = "failed"
GENERATED_FILE_SKIPPED = "skipped"

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from review_agent.regulatory_info_package.constants import WORKFLOW_TYPE
from review_agent.models import RegulatoryInfoPackageBatch, WorkflowEvent
def record_event(batch: RegulatoryInfoPackageBatch, event_type: str, payload: dict | None = None) -> WorkflowEvent:
return WorkflowEvent.objects.create(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=batch.pk,
conversation=batch.conversation,
event_type=event_type,
payload=payload or {},
)

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class TemplateSpec:
code: str
output_name: str
source_file: str
file_format: str
strategy: str
include_in_zip: bool
prefer_legacy_doc_native: bool = False
allow_docx_fallback: bool = True
fields: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class InstructionExtractResult:
source_file_name: str
paragraphs: list[str]
sections: dict[str, str]
tables: list[list[list[str]]]
component_tables: list[dict[str, Any]]
front_text: str
@dataclass
class MergedField:
key: str
label: str
value: str
source: str
evidence: str
confidence: float
highlight_reason: str = "none"
needs_review: bool = False
rule_value: str = ""
llm_value: str = ""
@dataclass
class GeneratedFileResult:
template_code: str
file_name: str
requested_format: str
actual_format: str
status: str
path: str = ""
artifact_id: int | None = None
export_id: int | None = None
highlight_count: int = 0
missing_count: int = 0
llm_only_count: int = 0
error_message: str = ""

View File

@@ -0,0 +1,2 @@
"""Services for the regulatory information package workflow."""

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from docx import Document
from docx.enum.text import WD_COLOR_INDEX
from docx.shared import RGBColor
from django.utils import timezone
from review_agent.regulatory_info_package.schemas import MergedField
PLACEHOLDER_RE = re.compile(r"\{\{([a-zA-Z0-9_]+)\}\}")
def write_docx_from_template(
source_path: str | Path,
output_path: str | Path,
merged_fields: dict[str, MergedField],
*,
template_code: str = "",
directory_page_numbers: dict[str, str] | None = None,
) -> tuple[int, int, int]:
source = Path(source_path)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
if source.exists():
document = Document(source)
else:
document = Document()
replacements = {f"{{{{{key}}}}}": field for key, field in merged_fields.items()}
highlight_count = 0
missing_count = 0
llm_only_count = 0
highlight_count += _apply_known_template_replacements(document, merged_fields, template_code=template_code)
if template_code == "ch1_5_product_list":
_rebuild_product_list_table(document, merged_fields)
if template_code == "ch1_2_directory":
_apply_directory_page_numbers(document, directory_page_numbers or {})
paragraph_counts = _replace_placeholders(document, replacements, merged_fields)
highlight_count += paragraph_counts[0]
missing_count += paragraph_counts[1]
llm_only_count += paragraph_counts[2]
document.save(output)
return highlight_count, missing_count, llm_only_count
def _replace_paragraph_text(paragraph, text: str, field: MergedField) -> None:
for run in paragraph.runs:
run.text = ""
run = paragraph.add_run(text)
if field.highlight_reason != "none":
run.font.highlight_color = WD_COLOR_INDEX.YELLOW
if field.highlight_reason == "conflict":
run.font.color.rgb = RGBColor(255, 0, 0)
def _apply_directory_page_numbers(document, page_numbers: dict[str, str]) -> None:
for table in document.tables:
if not table.rows:
continue
header = [cell.text.strip() for cell in table.rows[0].cells]
if len(header) < 5 or header[0] != "RPS目录" or header[4] != "页码":
continue
for row in table.rows[1:]:
code = row.cells[0].text.strip()
if code in page_numbers:
row.cells[4].text = page_numbers[code]
return
def _replace_placeholders(
document,
replacements: dict[str, MergedField],
merged_fields: dict[str, MergedField],
) -> tuple[int, int, int]:
highlight_count = 0
missing_count = 0
llm_only_count = 0
for paragraph in _iter_paragraphs(document):
text = paragraph.text
if "{{" not in text or "}}" not in text:
continue
used_fields: list[MergedField] = []
def replace(match: re.Match[str]) -> str:
key = match.group(1)
placeholder = match.group(0)
field = replacements.get(placeholder) or _default_placeholder_field(key, merged_fields)
used_fields.append(field)
return field.value
new_text = PLACEHOLDER_RE.sub(replace, text)
if new_text == text:
continue
field_for_style = next((field for field in used_fields if field.highlight_reason != "none"), None) or used_fields[0]
_replace_paragraph_text(paragraph, new_text, field_for_style)
for field in used_fields:
if field.highlight_reason != "none":
highlight_count += 1
if field.highlight_reason == "missing":
missing_count += 1
if field.highlight_reason == "llm_only":
llm_only_count += 1
return highlight_count, missing_count, llm_only_count
def _iter_paragraphs(document):
yield from document.paragraphs
for table in document.tables:
for row in table.rows:
for cell in row.cells:
yield from cell.paragraphs
def _apply_known_template_replacements(document, merged_fields: dict[str, MergedField], *, template_code: str = "") -> int:
product = _field_value(merged_fields, "product_name")
applicant = _field_value(merged_fields, "applicant_name")
today = timezone.localdate().strftime("%Y年%m月%d")
replacements = {
"xxxx年xx月xx日": today,
"XXXX年XX月XX日": today,
"xxxx 年 xx 月 xx 日": today,
"XXXX 年 XX 月 XX 日": today,
"2023年09月20日": today,
"2023 年 10 月": today[:8],
}
if not template_code.startswith("ch1_11"):
replacements.update({
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒荧光PCR法": product,
"呼吸道合胞病毒、肺炎支原体核酸检测试剂盒": product,
"呼吸道合胞病毒 、肺炎支产品名称: 原体核酸检测试剂盒(荧": f"产品名称:{product}",
"光PCR法": "",
"卡尤迪生物科技宜兴有限公司": applicant,
})
changed = 0
for paragraph in document.paragraphs:
changed += _replace_text_in_paragraph(paragraph, replacements, merged_fields)
for table in document.tables:
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
changed += _replace_text_in_paragraph(paragraph, replacements, merged_fields)
return changed
def _default_placeholder_field(key: str, merged_fields: dict[str, MergedField]) -> MergedField:
if key == "declaration_date":
return _plain_field(key, "日期", timezone.localdate().strftime("%Y年%m月%d"))
label = key
for field in merged_fields.values():
if field.key == key:
label = field.label
break
return MergedField(
key=key,
label=label,
value="/",
source="missing",
evidence="模板字段未从说明书中抽取到",
confidence=0.0,
highlight_reason="missing",
needs_review=True,
)
def _replace_text_in_paragraph(paragraph, replacements: dict[str, str], merged_fields: dict[str, MergedField]) -> int:
text = paragraph.text
new_text = text
for old, new in replacements.items():
if old in new_text:
new_text = new_text.replace(old, new)
if new_text == text:
return 0
field = merged_fields.get("product_name") or MergedField(
key="product_name",
label="产品名称",
value=new_text,
source="rule",
evidence="",
confidence=0.0,
)
_replace_paragraph_text(paragraph, new_text, field)
return 1
def _rebuild_product_list_table(document, merged_fields: dict[str, MergedField]) -> None:
product = _field_value(merged_fields, "product_name")
package_specification = _field_value(merged_fields, "package_specification")
component_table = _component_table_payload(merged_fields)
component_notes = _field_value(merged_fields, "component_notes")
for paragraph in document.paragraphs:
if "的包装规格、货号、组分及主要组成成分见下表" in paragraph.text:
_replace_paragraph_text(
paragraph,
f"{product}的包装规格、货号、组分及主要组成成分见下表:",
merged_fields.get("product_name") or _plain_field("product_name", "产品名称", product),
)
if "规格A和规格B的区别" in paragraph.text and component_notes != "/":
_replace_paragraph_text(
paragraph,
component_notes,
merged_fields.get("component_notes") or _plain_field("component_notes", "主要组成成分备注", component_notes),
)
target = None
for table in document.tables:
header = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else []
if header[:6] == ["包装规格", "货号", "组成", "组分", "主要组成成分", "规格/数量"]:
target = table
break
specs = _component_specs(component_table) or [
(spec, None) for spec in [item.strip() for item in package_specification.replace("", ";").split(";") if item.strip()]
]
if target is not None:
_clear_table_body(target)
if component_table:
_fill_product_component_table(target, component_table, specs)
else:
if not specs:
specs = [("/", None)]
for spec, _index in specs[:8]:
cells = target.add_row().cells
cells[0].text = spec
cells[1].text = "/"
cells[2].text = _field_value(merged_fields, "composition")
cells[3].text = _field_value(merged_fields, "component_name")
cells[4].text = _field_value(merged_fields, "main_component")
cells[5].text = _field_value(merged_fields, "quantity")
if component_table:
_rebuild_component_comparison_table(document, component_table, specs)
def _field_value(merged_fields: dict[str, MergedField], key: str) -> str:
field = merged_fields.get(key)
if not field or not field.value:
return "/"
return field.value
def _plain_field(key: str, label: str, value: str) -> MergedField:
return MergedField(key=key, label=label, value=value, source="rule", evidence="", confidence=0.0)
def _component_table_payload(merged_fields: dict[str, MergedField]) -> dict:
field = merged_fields.get("component_table")
if not field or not field.value or field.value == "/":
return {}
try:
payload = json.loads(field.value)
except json.JSONDecodeError:
return {}
if not isinstance(payload, dict):
return {}
rows = payload.get("rows") or []
header = payload.get("header") or []
if not isinstance(header, list) or not isinstance(rows, list):
return {}
return {"header": header, "rows": rows}
def _component_specs(component_table: dict) -> list[tuple[str, int]]:
header = component_table.get("header") or []
specs: list[tuple[str, int]] = []
for index, value in enumerate(header[2:], start=2):
label = str(value or "").strip()
if not label:
continue
label = label.replace("规格(", "").replace("规格(", "").rstrip(")")
specs.append((label, index))
return specs
def _clear_table_body(table) -> None:
while len(table.rows) > 1:
table._tbl.remove(table.rows[-1]._tr)
def _fill_product_component_table(table, component_table: dict, specs: list[tuple[str, int]]) -> None:
rows = component_table.get("rows") or []
for spec_label, spec_index in specs:
for row in rows:
cells = table.add_row().cells
cells[0].text = spec_label
cells[1].text = "/"
cells[2].text = "/"
cells[3].text = _row_value(row, 0)
cells[4].text = _row_value(row, 1)
cells[5].text = _row_value(row, spec_index or 0)
def _rebuild_component_comparison_table(document, component_table: dict, specs: list[tuple[str, int]]) -> None:
target = None
for table in document.tables:
header = [cell.text.strip() for cell in table.rows[0].cells] if table.rows else []
if header and header[0] == "组分名称":
target = table
break
if target is None:
return
_clear_table_body(target)
header_cells = target.rows[0].cells
labels = ["组分名称", *[spec for spec, _index in specs[: len(header_cells) - 1]]]
while len(labels) < len(header_cells):
labels.append("备注")
for index, label in enumerate(labels[: len(header_cells)]):
header_cells[index].text = label
for row in component_table.get("rows") or []:
cells = target.add_row().cells
cells[0].text = _row_value(row, 0)
for cell_index, (_spec_label, spec_index) in enumerate(specs[: len(cells) - 1], start=1):
cells[cell_index].text = _row_value(row, spec_index)
for cell_index in range(len(specs[: len(cells) - 1]) + 1, len(cells)):
cells[cell_index].text = "/"
def _row_value(row, index: int) -> str:
if not isinstance(row, list) or index >= len(row):
return "/"
value = str(row[index] or "").strip()
return value or "/"

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Callable
from review_agent.llm import generate_completion
from review_agent.regulatory_info_package.schemas import InstructionExtractResult
FIELD_PATTERNS = {
"product_name": ("产品名称", r"产品名称[:\s]*([^\n\r]+)"),
"applicant_name": ("申请人名称", r"(?:申请人名称|注册人/售后服务单位名称|注册人名称|售后服务单位名称|生产企业名称)[:\s]*([^\n\r]+)"),
"manufacturer_name": ("生产企业名称", r"生产企业名称[:\s]*([^\n\r]+)"),
"applicant_address": ("申请人住所", r"(?:申请人住所|注册人住所|生产企业住所)[:\s]*([^\n\r]+)"),
"applicant_contact": ("申请人联系方式", r"(?:联系方式|联系电话|电话)[:\s]*([^\n\r]+)"),
"production_address": ("生产地址", r"生产地址[:\s]*([^\n\r]+)"),
"storage_condition": ("储存条件", r"(?:储存条件|贮存条件|保存条件)[:\s]*([^\n\r]+)"),
"intended_use": ("预期用途", r"预期用途[:\s]*([^\n\r]+)"),
"package_specification": ("包装规格", r"(?:包装规格|规格)[:\s]*([^\n\r]+)"),
"sample_type": ("样本类型", r"样本类型[:\s]*([^\n\r]+)"),
"applicable_instrument": ("适用仪器", r"适用仪器[:\s]*([^\n\r]+)"),
"standard_no": ("标准号", r"((?:GB|YY|WS|T/C[A-Z0-9]*)[ /T0-9.\-—]+)"),
}
def extract_fields_by_rules(instruction: InstructionExtractResult) -> dict[str, dict]:
text = "\n".join([instruction.front_text, *instruction.paragraphs, *instruction.sections.values()])
results: dict[str, dict] = {}
for key, (label, pattern) in FIELD_PATTERNS.items():
section_value = _value_after_label_paragraph(instruction.paragraphs, label)
if section_value:
results[key] = {
"label": label,
"value": section_value,
"evidence": f"{label}\n{section_value}",
"confidence": 0.82,
"source": "rule",
}
continue
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
value = _clean_value(match.group(1))
if value:
results[key] = {
"label": label,
"value": value,
"evidence": match.group(0)[:240],
"confidence": 0.75,
"source": "rule",
}
component_table = _best_component_table(instruction.component_tables)
if component_table:
results["component_table"] = {
"label": "主要组成成分",
"value": json.dumps(component_table, ensure_ascii=False),
"evidence": "说明书【主要组成成分】表格",
"confidence": 0.86,
"source": "rule",
}
component_notes = _component_notes(instruction.sections)
if component_notes:
results["component_notes"] = {
"label": "主要组成成分备注",
"value": component_notes,
"evidence": "说明书【主要组成成分】段落",
"confidence": 0.8,
"source": "rule",
}
return results
def extract_fields_with_llm(instruction: InstructionExtractResult) -> dict[str, dict]:
prompt = (
"请从体外诊断试剂产品说明书中抽取字段,输出 JSON 对象,字段包括 "
"product_name、storage_condition、intended_use、package_specification、sample_type、applicable_instrument、standard_no。"
"每个字段值为 {label,value,evidence,confidence}。\n\n"
+ instruction.front_text[:6000]
)
raw = generate_completion([{"role": "user", "content": prompt}], temperature=0.0)
payload = _parse_json_object(raw)
return {key: value for key, value in payload.items() if isinstance(value, dict)}
def run_llm_extract_with_retry(
instruction: InstructionExtractResult,
*,
llm_extract_func: Callable[[InstructionExtractResult], dict[str, dict]] | None = None,
sleep_func: Callable[[float], None] = time.sleep,
) -> dict[str, dict]:
func = llm_extract_func or extract_fields_with_llm
last_exc: Exception | None = None
for delay in [0, 1, 2]:
if delay:
sleep_func(delay)
try:
return func(instruction)
except Exception as exc:
last_exc = exc
if last_exc:
raise last_exc
return {}
def run_parallel_extract(
instruction: InstructionExtractResult,
*,
llm_extract_func: Callable[[InstructionExtractResult], dict[str, dict]] | None = None,
) -> dict:
payload = {"regex_results": {}, "llm_results": {}, "llm_error": ""}
with ThreadPoolExecutor(max_workers=2) as executor:
rule_future = executor.submit(extract_fields_by_rules, instruction)
llm_future = executor.submit(run_llm_extract_with_retry, instruction, llm_extract_func=llm_extract_func)
payload["regex_results"] = rule_future.result()
try:
payload["llm_results"] = llm_future.result()
except Exception as exc:
payload["llm_error"] = str(exc)
return payload
def save_field_extract_result(path: str | Path, payload: dict) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return target
def _clean_value(value: str) -> str:
cleaned = value.strip()
if cleaned in {"", "】】", "】:"}:
return ""
return re.split(r"[。;;]", cleaned)[0].strip()
def _value_after_label_paragraph(paragraphs: list[str], label: str) -> str:
bracketed = {f"{label}", f"[{label}]", label}
for index, text in enumerate(paragraphs):
stripped = text.strip()
if stripped in bracketed and index + 1 < len(paragraphs):
return _clean_value(paragraphs[index + 1])
return ""
def _parse_json_object(raw: str) -> dict:
text = (raw or "").strip()
if text.startswith("```"):
text = text.strip("`").strip()
if text.lower().startswith("json"):
text = text[4:].strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
return {}
return json.loads(text[start : end + 1])
def _best_component_table(component_tables: list[dict]) -> dict:
if not component_tables:
return {}
return max(component_tables, key=lambda table: len(table.get("rows") or []))
def _component_notes(sections: dict[str, str]) -> str:
for key, value in sections.items():
if "主要组成" in key:
return value.strip()
return ""

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from pathlib import Path
from review_agent.regulatory_info_package.schemas import MergedField
REQUIRED_FIELDS = {
"product_name": "产品名称",
"applicant_name": "申请人名称",
"package_specification": "包装规格",
"intended_use": "预期用途",
"storage_condition": "储存条件",
}
def merge_fields(rule_results: dict[str, dict], llm_results: dict[str, dict]) -> tuple[dict[str, MergedField], dict[str, list[dict]]]:
merged: dict[str, MergedField] = {}
missing_fields: list[dict] = []
llm_only_fields: list[dict] = []
conflict_fields: list[dict] = []
keys = set(REQUIRED_FIELDS) | set(rule_results) | set(llm_results)
for key in sorted(keys):
rule = rule_results.get(key) or {}
llm = llm_results.get(key) or {}
rule_value = str(rule.get("value") or "").strip()
llm_value = str(llm.get("value") or "").strip()
label = str(rule.get("label") or llm.get("label") or REQUIRED_FIELDS.get(key) or key)
if rule_value and llm_value and rule_value != llm_value:
field = MergedField(
key=key,
label=label,
value=rule_value,
source="rule_conflict",
evidence=str(rule.get("evidence") or ""),
confidence=float(rule.get("confidence") or 0.0),
highlight_reason="conflict",
needs_review=True,
rule_value=rule_value,
llm_value=llm_value,
)
conflict_fields.append(
{
"field_key": key,
"field_label": label,
"rule_value": rule_value,
"llm_value": llm_value,
"selected_value": rule_value,
"handling": "规则优先,写入值高亮并进入追溯清单",
}
)
elif rule_value:
field = MergedField(
key=key,
label=label,
value=rule_value,
source="rule",
evidence=str(rule.get("evidence") or ""),
confidence=float(rule.get("confidence") or 0.0),
)
elif llm_value:
field = MergedField(
key=key,
label=label,
value=llm_value,
source="llm",
evidence=str(llm.get("evidence") or ""),
confidence=float(llm.get("confidence") or 0.0),
highlight_reason="llm_only",
needs_review=True,
llm_value=llm_value,
)
llm_only_fields.append(_review_dict(field))
else:
field = MergedField(
key=key,
label=label,
value="/",
source="missing",
evidence="",
confidence=0.0,
highlight_reason="missing",
needs_review=True,
)
missing_fields.append(_review_dict(field))
merged[key] = field
return merged, {
"missing_fields": missing_fields,
"llm_only_fields": llm_only_fields,
"conflict_fields": conflict_fields,
}
def save_merged_fields(path: str | Path, merged: dict[str, MergedField], summary: dict[str, list[dict]]) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fields": {key: field.__dict__ for key, field in merged.items()},
**summary,
}
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return target
def _review_dict(field: MergedField) -> dict:
return {
"target_file": "",
"field_key": field.key,
"field_label": field.label,
"final_value": field.value,
"highlight_reason": field.highlight_reason,
"needs_review": field.needs_review,
}

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryItem
@dataclass
class InstructionInputSelection:
status: str
file_name: str = ""
storage_path: str = ""
attachment: FileAttachment | None = None
source_summary_batch: FileSummaryBatch | None = None
source_summary_item_id: int | None = None
candidates: list[str] = field(default_factory=list)
message: str = ""
def select_instruction_input(conversation: Conversation, message: str) -> InstructionInputSelection:
candidates = _active_docx_attachments(conversation)
named = _match_by_message(candidates, message)
if len(named) == 1:
return _selection_from_attachment(named[0])
instruction_candidates = [item for item in candidates if "说明书" in item.original_name]
if len(instruction_candidates) == 1:
return _selection_from_attachment(instruction_candidates[0])
if len(candidates) == 1:
return _selection_from_attachment(candidates[0])
if len(instruction_candidates) > 1 or len(candidates) > 1:
names = [item.original_name for item in (instruction_candidates or candidates)]
return InstructionInputSelection(
status="waiting_user",
candidates=names,
message="请确认用于生成第1章监管信息的说明书文件名" + "".join(names),
)
summary_selection = _select_from_latest_summary(conversation, message)
if summary_selection:
return summary_selection
return InstructionInputSelection(status="missing", message="请先上传产品说明书 docx 文件。")
def _active_docx_attachments(conversation: Conversation) -> list[FileAttachment]:
return list(
FileAttachment.objects.filter(
conversation=conversation,
is_active=True,
)
.exclude(upload_status=FileAttachment.UploadStatus.DELETED)
.filter(original_name__iendswith=".docx")
.order_by("original_name", "-version_no")
)
def _match_by_message(candidates: list[FileAttachment], message: str) -> list[FileAttachment]:
compact = "".join((message or "").lower().split())
matched = []
for attachment in candidates:
stem = Path(attachment.original_name).stem.lower()
name = attachment.original_name.lower()
if stem and stem in compact or name and name in compact:
matched.append(attachment)
return matched
def _selection_from_attachment(attachment: FileAttachment) -> InstructionInputSelection:
return InstructionInputSelection(
status="selected",
file_name=attachment.original_name,
storage_path=attachment.storage_path,
attachment=attachment,
)
def _select_from_latest_summary(conversation: Conversation, message: str) -> InstructionInputSelection | None:
batch = (
FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS)
.order_by("-finished_at", "-created_at", "-id")
.first()
)
if not batch:
return None
items = list(batch.items.filter(file_name__iendswith=".docx").order_by("file_name", "id"))
compact = "".join((message or "").lower().split())
named = [item for item in items if Path(item.file_name).stem.lower() in compact or item.file_name.lower() in compact]
candidates = named or [item for item in items if "说明书" in item.file_name]
if len(candidates) == 1:
item = candidates[0]
return InstructionInputSelection(
status="selected",
file_name=item.file_name,
storage_path=item.storage_path,
source_summary_batch=batch,
source_summary_item_id=item.pk,
)
if len(candidates) > 1:
return InstructionInputSelection(
status="waiting_user",
source_summary_batch=batch,
candidates=[item.file_name for item in candidates],
message="请确认用于生成第1章监管信息的说明书文件名" + "".join(item.file_name for item in candidates),
)
return None

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from pathlib import Path
from docx import Document
from review_agent.regulatory_info_package.schemas import InstructionExtractResult
def parse_instruction_docx(path: str | Path) -> InstructionExtractResult:
file_path = Path(path)
document = Document(file_path)
paragraphs = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
tables = []
for table in document.tables:
rows = []
for row in table.rows:
rows.append([" ".join(cell.text.split()) for cell in row.cells])
if rows:
tables.append(rows)
sections = _build_sections(paragraphs)
front_text = "\n".join(paragraphs[:30])
return InstructionExtractResult(
source_file_name=file_path.name,
paragraphs=paragraphs,
sections=sections,
tables=tables,
component_tables=_component_tables(tables),
front_text=front_text,
)
def save_instruction_extract_json(path: str | Path, result: InstructionExtractResult) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
payload = {
"source_file_name": result.source_file_name,
"paragraphs": result.paragraphs,
"sections": result.sections,
"tables": result.tables,
"component_tables": result.component_tables,
"front_text": result.front_text,
}
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
return target
def _build_sections(paragraphs: list[str]) -> dict[str, str]:
sections: dict[str, list[str]] = {}
current = "front"
for text in paragraphs:
if _looks_like_heading(text):
current = text[:80]
sections.setdefault(current, [])
continue
sections.setdefault(current, []).append(text)
return {key: "\n".join(value).strip() for key, value in sections.items() if value}
def _looks_like_heading(text: str) -> bool:
compact = text.strip()
if len(compact) > 40:
return False
heading_markers = ("一、", "二、", "三、", "四、", "五、", "六、", "", "产品名称", "预期用途", "主要组成")
return compact.startswith(heading_markers)
def _component_tables(tables: list[list[list[str]]]) -> list[dict]:
results = []
for table in tables:
header = table[0] if table else []
joined = "".join(header)
if any(keyword in joined for keyword in ["组成", "组分", "成分"]):
results.append({"header": header, "rows": table[1:]})
return results

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import shutil
from dataclasses import dataclass
from pathlib import Path
from django.conf import settings
from docx import Document
from review_agent.regulatory_info_package.schemas import MergedField
@dataclass(frozen=True)
class LegacyDocCapability:
status: str
adapter: str
message: str = ""
def detect_legacy_doc_capability() -> LegacyDocCapability:
try:
import win32com.client # noqa: F401
return LegacyDocCapability(status="available", adapter="WordComDocAdapter", message="Word COM 可用")
except Exception as exc:
return LegacyDocCapability(
status="unavailable",
adapter="UnavailableLegacyDocAdapter",
message=f"Word COM 不可用:{type(exc).__name__}",
)
def write_legacy_doc_or_fallback(
source_path: str | Path,
output_path: str | Path,
merged_fields: dict[str, MergedField],
) -> tuple[Path, str, dict]:
source = Path(source_path)
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
capability = detect_legacy_doc_capability()
native_enabled = bool(getattr(settings, "REGULATORY_INFO_PACKAGE_ENABLE_WORD_COM_NATIVE", False))
if native_enabled and capability.status == "available" and source.exists():
shutil.copy2(source, output)
try:
_append_doc_summary_with_word_com(output, merged_fields)
return output, "success", {"doc": capability.__dict__, "fallback_used": False, "native_write": True}
except Exception as exc:
capability = LegacyDocCapability(
status="unavailable",
adapter="UnavailableLegacyDocAdapter",
message=f"Word COM 写入失败:{exc}",
)
fallback = output.with_suffix(".docx")
document = Document()
heading = document.add_paragraph()
heading.add_run(output.stem).bold = True
document.add_paragraph("【预生成版】当前未启用 .doc 原生写入,已生成 docx 兜底文件。")
for field in merged_fields.values():
document.add_paragraph(f"{field.label}{field.value}")
document.save(fallback)
return fallback, "fallback_success", {"doc": capability.__dict__, "fallback_used": True, "native_enabled": native_enabled}
def _append_doc_summary_with_word_com(path: Path, merged_fields: dict[str, MergedField]) -> None:
import win32com.client
word = win32com.client.Dispatch("Word.Application")
word.Visible = False
document = None
try:
document = word.Documents.Open(str(path.resolve()))
end_range = document.Range(document.Content.End - 1, document.Content.End - 1)
lines = ["", "【预生成版】以下字段由系统根据说明书预填,请人工复核。"]
lines.extend(f"{field.label}{field.value}" for field in merged_fields.values())
end_range.InsertAfter("\r".join(lines))
document.Save()
finally:
if document is not None:
document.Close(False)
word.Quit()

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from zipfile import ZipFile
from xml.etree import ElementTree
from review_agent.models import RegulatoryInfoPackageBatch
from review_agent.regulatory_info_package.constants import GENERATED_FILE_FAILED
from review_agent.regulatory_info_package.schemas import GeneratedFileResult, MergedField, TemplateSpec
from review_agent.regulatory_info_package.services.docx_document import write_docx_from_template
from review_agent.regulatory_info_package.services.legacy_doc_document import write_legacy_doc_or_fallback
from review_agent.regulatory_info_package.services.template_repository import copy_template_to_batch, template_specs
from review_agent.regulatory_info_package.storage import ensure_batch_subdir
def generate_package_documents(
batch: RegulatoryInfoPackageBatch,
config: dict,
merged_fields: dict[str, MergedField],
) -> list[GeneratedFileResult]:
specs = template_specs(config)
directory_specs = [spec for spec in specs if spec.code == "ch1_2_directory"]
content_specs = [spec for spec in specs if spec.code != "ch1_2_directory"]
results: list[GeneratedFileResult] = []
with ThreadPoolExecutor(max_workers=min(4, len(content_specs) or 1)) as executor:
futures = [executor.submit(_generate_one, batch, config, spec, merged_fields) for spec in content_specs]
results.extend(future.result() for future in as_completed(futures))
page_numbers = _directory_page_numbers(results)
for spec in directory_specs:
results.append(_generate_one(batch, config, spec, merged_fields, directory_page_numbers=page_numbers))
return results
def _generate_one(
batch: RegulatoryInfoPackageBatch,
config: dict,
spec: TemplateSpec,
merged_fields: dict[str, MergedField],
*,
directory_page_numbers: dict[str, str] | None = None,
) -> GeneratedFileResult:
try:
template_path = copy_template_to_batch(batch, config, spec)
generated_dir = ensure_batch_subdir(batch, "generated")
output_path = generated_dir / spec.output_name
adapter_summary = {}
if spec.file_format == "doc":
actual_path, status, adapter_summary = write_legacy_doc_or_fallback(template_path, output_path, merged_fields)
actual_format = actual_path.suffix.lower().lstrip(".")
highlight_count = missing_count = llm_only_count = 0
else:
highlight_count, missing_count, llm_only_count = write_docx_from_template(
template_path,
output_path,
merged_fields,
template_code=spec.code,
directory_page_numbers=directory_page_numbers,
)
actual_path = output_path
actual_format = "docx"
status = "success"
return GeneratedFileResult(
template_code=spec.code,
file_name=actual_path.name,
requested_format=spec.file_format,
actual_format=actual_format,
status=status,
path=str(actual_path),
highlight_count=highlight_count,
missing_count=missing_count,
llm_only_count=llm_only_count,
)
except Exception as exc:
return GeneratedFileResult(
template_code=spec.code,
file_name=spec.output_name,
requested_format=spec.file_format,
actual_format=spec.file_format,
status=GENERATED_FILE_FAILED,
error_message=str(exc),
)
def _directory_page_numbers(results: list[GeneratedFileResult]) -> dict[str, str]:
page_numbers = {"CH1.2": "1"}
for result in results:
if result.status not in {"success", "fallback_success"} or not result.path:
continue
code = _directory_code_from_file_name(result.file_name)
if not code:
continue
page_numbers[code] = str(count_document_pages(result.path))
return page_numbers
def _directory_code_from_file_name(file_name: str) -> str:
stem = Path(file_name).stem.strip()
return stem.split()[0] if stem.startswith("CH") else ""
def count_document_pages(path: str | Path) -> int:
file_path = Path(path)
if not file_path.exists():
return 1
pages = _count_pages_from_docx_properties(file_path)
if pages:
return pages
pages = _count_pages_with_pywin32(file_path)
if pages:
return pages
pages = _count_pages_with_powershell_word(file_path)
if pages:
return pages
return 1
def _count_pages_from_docx_properties(file_path: Path) -> int:
if file_path.suffix.lower() != ".docx":
return 0
try:
with ZipFile(file_path) as archive:
root = ElementTree.fromstring(archive.read("docProps/app.xml"))
namespace = {"ep": "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"}
pages = root.find("ep:Pages", namespace)
return max(int((pages.text or "").strip()), 1) if pages is not None else 0
except Exception:
return 0
def _count_pages_with_pywin32(file_path: Path) -> int:
try:
import win32com.client
word = win32com.client.DispatchEx("Word.Application")
word.Visible = False
document = None
try:
document = word.Documents.Open(str(file_path.resolve()), ReadOnly=True)
document.Repaginate()
return max(int(document.ComputeStatistics(2)), 1)
finally:
if document is not None:
document.Close(False)
word.Quit()
except Exception:
return 0
def _count_pages_with_powershell_word(file_path: Path) -> int:
script = r"""
param([string]$Path)
$word = $null
$doc = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$doc = $word.Documents.Open($Path, $false, $true)
$doc.Repaginate()
[Console]::Out.Write($doc.ComputeStatistics(2))
exit 0
} catch {
[Console]::Error.Write($_.Exception.Message)
exit 1
} finally {
if ($doc -ne $null) { $doc.Close($false) | Out-Null }
if ($word -ne $null) { $word.Quit() | Out-Null }
}
"""
try:
completed = subprocess.run(
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script, str(file_path.resolve())],
capture_output=True,
check=False,
text=True,
timeout=8,
)
except Exception:
return 0
if completed.returncode != 0:
return 0
try:
return max(int(completed.stdout.strip()), 1)
except ValueError:
return 0

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
def build_assistant_summary(*, batch_no: str, exports: list[dict], failed_files: list[dict]) -> str:
zip_exports = [item for item in exports if item.get("export_type") == "zip" or str(item.get("file_name", "")).endswith(".zip")]
other_exports = [item for item in exports if item not in zip_exports]
lines = [f"已完成第1章监管信息材料包生成批次号{batch_no}", ""]
for export in [*zip_exports, *other_exports]:
lines.append(f"- [{export['file_name']}]({export['download_url']})")
for failed in failed_files:
lines.append(f"- {failed.get('file_name')}:生成失败,{failed.get('error_message') or '原因待查看'}")
return "\n".join(lines)

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
import hashlib
from pathlib import Path
import yaml
from django.conf import settings
CONFIG_PATH = Path(__file__).resolve().parents[1] / "templates" / "regulatory_info_package_templates_v1.yaml"
def load_template_config(path: str | Path | None = None) -> dict:
config_path = Path(path) if path else CONFIG_PATH
with config_path.open("r", encoding="utf-8") as handle:
payload = yaml.safe_load(handle) or {}
if payload.get("source_dir"):
payload["source_dir"] = str((Path(settings.BASE_DIR) / payload["source_dir"]).resolve())
return payload
def compute_config_hash(path: str | Path | None = None) -> str:
config_path = Path(path) if path else CONFIG_PATH
digest = hashlib.sha256()
digest.update(config_path.read_bytes())
return digest.hexdigest()
def validate_template_config(config: dict) -> list[str]:
errors: list[str] = []
source_dir = Path(config.get("source_dir") or "")
if not source_dir.exists():
errors.append(f"模板源目录不存在:{source_dir}")
templates = config.get("templates") or []
if len(templates) != 6:
errors.append("第1章监管信息模板配置必须包含 6 个模板。")
seen: set[str] = set()
for template in templates:
code = str(template.get("code") or "")
if not code:
errors.append("模板 code 不能为空。")
elif code in seen:
errors.append(f"模板 code 重复:{code}")
seen.add(code)
source_file = str(template.get("source_file") or "")
output_name = str(template.get("output_name") or "")
if not source_file:
errors.append(f"模板 {code} 缺少 source_file。")
elif source_dir.exists() and not (source_dir / source_file).exists():
errors.append(f"模板源文件不存在:{source_file}")
if not output_name:
errors.append(f"模板 {code} 缺少 output_name。")
return errors

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import shutil
from pathlib import Path
from review_agent.regulatory_info_package.schemas import TemplateSpec
from review_agent.regulatory_info_package.storage import ensure_batch_subdir
from review_agent.models import RegulatoryInfoPackageBatch
def template_specs(config: dict) -> list[TemplateSpec]:
return [
TemplateSpec(
code=item["code"],
output_name=item["output_name"],
source_file=item["source_file"],
file_format=item.get("file_format", "docx"),
strategy=item.get("strategy", item["code"]),
include_in_zip=bool(item.get("include_in_zip", True)),
prefer_legacy_doc_native=bool(item.get("prefer_legacy_doc_native", False)),
allow_docx_fallback=bool(item.get("allow_docx_fallback", True)),
fields=item.get("fields") or [],
)
for item in config.get("templates") or []
]
def copy_template_to_batch(batch: RegulatoryInfoPackageBatch, config: dict, spec: TemplateSpec) -> Path:
source_dir = Path(config["source_dir"])
source = source_dir / spec.source_file
target = ensure_batch_subdir(batch, "templates") / f"{spec.code}.source{source.suffix}"
shutil.copy2(source, target)
return target

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
import json
from pathlib import Path
from openpyxl import Workbook
from review_agent.regulatory_info_package.schemas import MergedField
HEADERS = [
"target_file",
"target_field",
"final_value",
"extraction_source",
"evidence",
"highlight_reason",
"needs_review",
]
def save_traceability_exports(root: str | Path, merged_fields: dict[str, MergedField]) -> tuple[Path, Path]:
root_path = Path(root)
exports_dir = root_path / "exports"
logs_dir = root_path / "logs"
exports_dir.mkdir(parents=True, exist_ok=True)
logs_dir.mkdir(parents=True, exist_ok=True)
rows = [
{
"target_file": "",
"target_field": field.label,
"final_value": field.value,
"extraction_source": field.source,
"evidence": field.evidence,
"highlight_reason": field.highlight_reason,
"needs_review": field.needs_review,
}
for field in merged_fields.values()
]
excel_path = exports_dir / "traceability.xlsx"
workbook = Workbook()
sheet = workbook.active
sheet.title = "traceability"
sheet.append(HEADERS)
for row in rows:
sheet.append([row.get(header, "") for header in HEADERS])
workbook.save(excel_path)
json_path = logs_dir / "traceability.json"
json_path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
return excel_path, json_path

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
from review_agent.regulatory_info_package.constants import DEFAULT_ZIP_NAME, GENERATED_FILE_FALLBACK_SUCCESS, GENERATED_FILE_SUCCESS
from review_agent.regulatory_info_package.schemas import GeneratedFileResult
def create_zip_package(root: str | Path, generated_files: list[GeneratedFileResult], zip_name: str = DEFAULT_ZIP_NAME) -> Path:
root_path = Path(root)
exports_dir = root_path / "exports"
exports_dir.mkdir(parents=True, exist_ok=True)
zip_path = exports_dir / zip_name
allowed = {GENERATED_FILE_SUCCESS, GENERATED_FILE_FALLBACK_SUCCESS}
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive:
for result in generated_files:
if result.status not in allowed or not result.path:
continue
file_path = Path(result.path)
if file_path.exists():
archive.write(file_path, arcname=result.file_name)
return zip_path

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import hashlib
from pathlib import Path
from django.conf import settings
from review_agent.models import RegulatoryInfoPackageArtifact, RegulatoryInfoPackageBatch
def build_batch_work_dir(batch: RegulatoryInfoPackageBatch | None = None, *, batch_no: str = "") -> Path:
if batch:
return (
Path(settings.MEDIA_ROOT)
/ "regulatory_info_package"
/ str(batch.user_id)
/ str(batch.conversation_id)
/ batch.batch_no
)
return Path(settings.MEDIA_ROOT) / "regulatory_info_package" / batch_no
def ensure_batch_subdir(batch: RegulatoryInfoPackageBatch, name: str) -> Path:
root = Path(batch.work_dir) if batch.work_dir else build_batch_work_dir(batch)
target = root / Path(name).name
ensure_within_work_dir(batch, target)
target.mkdir(parents=True, exist_ok=True)
return target
def ensure_within_work_dir(batch: RegulatoryInfoPackageBatch, path: str | Path) -> Path:
root = Path(batch.work_dir).resolve()
target = Path(path).resolve()
if root != target and root not in target.parents:
raise ValueError("输出路径必须位于当前材料包批次工作目录内。")
return target
def compute_file_sha256(path: str | Path) -> str:
file_path = Path(path)
digest = hashlib.sha256()
with file_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def create_artifact_for_file(
batch: RegulatoryInfoPackageBatch,
*,
path: str | Path,
artifact_type: str,
file_format: str,
name: str = "",
metadata: dict | None = None,
created_by_node: str = "",
) -> RegulatoryInfoPackageArtifact:
file_path = ensure_within_work_dir(batch, path)
return RegulatoryInfoPackageArtifact.objects.create(
batch=batch,
artifact_type=artifact_type,
file_format=file_format,
name=name or file_path.stem,
file_name=file_path.name,
storage_path=str(file_path),
file_size=file_path.stat().st_size if file_path.exists() else 0,
content_hash=compute_file_sha256(file_path) if file_path.exists() else "",
metadata=metadata or {},
created_by_node=created_by_node,
)

View File

@@ -0,0 +1,64 @@
version: regulatory_info_package_templates_v1
source_dir: review_agent/regulatory_info_package/templates/clean
zip_name: 第1章 监管信息(预生成版).zip
templates:
- code: ch1_2_directory
source_file: CH1.2 监管信息目录 - 页码版.docx
output_name: CH1.2 监管信息目录.docx
file_format: docx
strategy: directory
include_in_zip: true
fields: []
- code: ch1_4_application_form
source_file: CH1.4 申请表 - 复选框调整版.docx
output_name: CH1.4 申请表.docx
file_format: docx
strategy: application_form
include_in_zip: true
fields:
- key: product_name
label: 产品名称
placeholder: "{{product_name}}"
- key: applicant_name
label: 申请人名称
placeholder: "{{applicant_name}}"
- code: ch1_5_product_list
source_file: CH1.5 产品列表.docx
output_name: CH1.5 产品列表.docx
file_format: docx
strategy: product_list
include_in_zip: true
fields:
- key: package_specification
label: 包装规格
placeholder: "{{package_specification}}"
- code: ch1_11_1_standards
source_file: CH1.11.1 符合标准的清单.docx
output_name: CH1.11.1 符合标准的清单.docx
file_format: docx
strategy: standards
include_in_zip: true
fields:
- key: standard_no
label: 标准号
placeholder: "{{standard_no}}"
- code: ch1_11_5_authenticity
source_file: CH1.11.5 真实性声明.docx
output_name: CH1.11.5 真实性声明.docx
file_format: docx
strategy: authenticity
include_in_zip: true
fields:
- key: product_name
label: 产品名称
placeholder: "{{product_name}}"
- code: ch1_11_6_conformity
source_file: CH1.11.6 符合性声明.docx
output_name: CH1.11.6 符合性声明.docx
file_format: docx
strategy: conformity
include_in_zip: true
fields:
- key: product_name
label: 产品名称
placeholder: "{{product_name}}"

View File

@@ -0,0 +1,127 @@
import json
from django.contrib.auth.decorators import login_required
from django.conf import settings
from django.http import Http404, JsonResponse
from django.views.decorators.http import require_http_methods
from review_agent.models import ExportedSummaryFile, RegulatoryInfoPackageBatch, WorkflowNodeRun
from review_agent.regulatory_info_package.constants import WORKFLOW_TYPE
from review_agent.regulatory_info_package.services.input_select import select_instruction_input
from review_agent.regulatory_info_package.workflow import (
create_regulatory_info_package_batch,
start_regulatory_info_package_workflow,
)
@require_http_methods(["GET"])
def health(request):
return JsonResponse({"workflow_type": WORKFLOW_TYPE, "status": "available"})
@login_required
@require_http_methods(["POST"])
def start(request):
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"error": "JSON 格式错误。"}, status=400)
from review_agent.models import Conversation
conversation = Conversation.objects.filter(pk=payload.get("conversation_id"), user=request.user).first()
if not conversation:
raise Http404("对话不存在。")
selection = select_instruction_input(conversation, str(payload.get("message") or ""))
if selection.status != "selected":
return JsonResponse(
{"status": selection.status, "message": selection.message, "candidates": selection.candidates},
status=400,
)
batch = create_regulatory_info_package_batch(
conversation=conversation,
user=request.user,
source_attachment=selection.attachment,
source_summary_batch=selection.source_summary_batch,
source_summary_item_id=selection.source_summary_item_id,
source_file_name=selection.file_name,
source_storage_path=selection.storage_path,
)
start_regulatory_info_package_workflow(batch, async_run=getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True))
return JsonResponse({"batch_id": batch.pk, "workflow_type": WORKFLOW_TYPE, "status": batch.status})
@login_required
@require_http_methods(["GET"])
def batch_status(request, batch_id: int):
batch = RegulatoryInfoPackageBatch.objects.filter(
pk=batch_id,
conversation__user=request.user,
is_deleted=False,
).first()
if not batch:
raise Http404("材料包批次不存在。")
exports = ExportedSummaryFile.objects.filter(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=batch.pk,
).order_by("-export_type", "id")
sorted_exports = sorted(exports, key=lambda item: 0 if item.export_type == ExportedSummaryFile.ExportType.ZIP else 1)
return JsonResponse(
{
"batch": {
"id": batch.pk,
"workflow_type": WORKFLOW_TYPE,
"batch_no": batch.batch_no,
"status": batch.status,
"product_name": batch.product_name,
"risk_summary_text": _risk_summary_text(batch),
"error_message": batch.error_message,
},
"nodes": [
{
"node_code": node.node_code,
"node_name": node.node_name,
"status": node.status,
"progress": node.progress,
"message": node.message,
}
for node in WorkflowNodeRun.objects.filter(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=batch.pk,
).order_by("id")
],
"exports": [
{
"id": export.pk,
"export_type": export.export_type,
"export_category": export.export_category,
"file_name": export.file_name,
"download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/",
}
for export in sorted_exports
],
"failed_files": [item for item in batch.generated_files if item.get("status") == "failed"],
"notifications": [
{
"id": item.pk,
"channel": item.channel,
"send_status": item.send_status,
"status_label": "通知已记录" if item.send_status == "success" else item.send_status,
"error_message": item.error_message,
}
for item in batch.notifications.filter(is_deleted=False).order_by("-created_at", "-id")
],
}
)
def _risk_summary_text(batch: RegulatoryInfoPackageBatch) -> str:
parts = []
if batch.missing_fields:
parts.append(f"缺失字段 {len(batch.missing_fields)}")
if batch.llm_only_fields:
parts.append(f"LLM-only {len(batch.llm_only_fields)}")
if batch.conflict_fields:
parts.append(f"冲突字段 {len(batch.conflict_fields)}")
if batch.risk_notes:
parts.append(f"提示 {len(batch.risk_notes)}")
return " · ".join(parts)

View File

@@ -0,0 +1,375 @@
from __future__ import annotations
import logging
from threading import Thread
from uuid import uuid4
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from review_agent.file_summary.paths import resolve_storage_path
from review_agent.models import (
Conversation,
ExportedSummaryFile,
Message,
RegulatoryInfoPackageArtifact,
RegulatoryInfoPackageBatch,
RegulatoryInfoPackageNotificationRecord,
WorkflowNodeRun,
)
from review_agent.regulatory_info_package.constants import (
DEFAULT_ZIP_NAME,
REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS,
WORKFLOW_TYPE,
)
from review_agent.regulatory_info_package.events import record_event
from review_agent.regulatory_info_package.services.template_config import (
compute_config_hash,
load_template_config,
validate_template_config,
)
from review_agent.regulatory_info_package.services.field_extract import run_parallel_extract, save_field_extract_result
from review_agent.regulatory_info_package.services.field_merge import merge_fields, save_merged_fields
from review_agent.regulatory_info_package.services.instruction_extract import parse_instruction_docx, save_instruction_extract_json
from review_agent.regulatory_info_package.services.package_generate import generate_package_documents
from review_agent.regulatory_info_package.services.summary import build_assistant_summary
from review_agent.regulatory_info_package.services.traceability_export import save_traceability_exports
from review_agent.regulatory_info_package.services.zip_export import create_zip_package
from review_agent.regulatory_info_package.schemas import GeneratedFileResult, InstructionExtractResult, MergedField
from review_agent.regulatory_info_package.storage import build_batch_work_dir
from review_agent.regulatory_info_package.storage import create_artifact_for_file, ensure_batch_subdir
logger = logging.getLogger("review_agent.regulatory_info_package.workflow")
def build_batch_no() -> str:
return f"RIP-{timezone.localtime().strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:6]}"
@transaction.atomic
def create_regulatory_info_package_batch(
*,
conversation: Conversation,
user,
trigger_message: Message | None = None,
source_attachment=None,
source_summary_batch=None,
source_summary_item_id: int | None = None,
source_file_name: str = "",
source_storage_path: str = "",
existing_batch: RegulatoryInfoPackageBatch | None = None,
) -> RegulatoryInfoPackageBatch:
batch = existing_batch
if batch is None:
batch_no = build_batch_no()
work_dir = build_batch_work_dir(batch_no=batch_no)
work_dir.mkdir(parents=True, exist_ok=True)
batch = RegulatoryInfoPackageBatch.objects.create(
conversation=conversation,
user=user,
trigger_message=trigger_message,
source_attachment=source_attachment,
source_summary_batch=source_summary_batch,
source_summary_item_id=source_summary_item_id,
source_file_name=source_file_name or getattr(source_attachment, "original_name", ""),
source_storage_path=source_storage_path or getattr(source_attachment, "storage_path", ""),
batch_no=batch_no,
output_zip_name=DEFAULT_ZIP_NAME,
work_dir=str(work_dir),
)
for code, name, group in REGULATORY_INFO_PACKAGE_NODE_DEFINITIONS:
WorkflowNodeRun.objects.get_or_create(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=batch.pk,
node_code=code,
defaults={
"node_group": group,
"node_name": name,
},
)
record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no})
return batch
class RegulatoryInfoPackageWorkflowExecutor:
"""Runs the Chapter 1 regulatory information package workflow."""
def __init__(self, batch: RegulatoryInfoPackageBatch):
self.batch = batch
self.template_config: dict = {}
self.instruction: InstructionExtractResult | None = None
self.extract_payload: dict = {}
self.merged_fields: dict[str, MergedField] = {}
self.merge_summary: dict[str, list[dict]] = {}
self.generation_results: list[GeneratedFileResult] = []
self.exports: list[ExportedSummaryFile] = []
def run(self) -> None:
logger.info("监管信息材料包工作流开始 batch_no=%s batch_id=%s", self.batch.batch_no, self.batch.pk)
self.batch.status = RegulatoryInfoPackageBatch.Status.RUNNING
self.batch.started_at = timezone.now()
self.batch.save(update_fields=["status", "started_at"])
record_event(self.batch, "workflow_started", {"batch_id": self.batch.pk})
try:
for node in self._nodes():
if node.status in {WorkflowNodeRun.Status.SUCCESS, WorkflowNodeRun.Status.SKIPPED}:
continue
self._run_node(node)
except Exception as exc:
logger.exception("Regulatory info package workflow failed", extra={"batch_id": self.batch.pk})
self.batch.status = RegulatoryInfoPackageBatch.Status.FAILED
self.batch.error_message = str(exc)
self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "error_message", "finished_at"])
record_event(self.batch, "workflow_failed", {"message": str(exc)})
return
self.batch.status = RegulatoryInfoPackageBatch.Status.SUCCESS
self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "finished_at"])
self._append_completion_message()
record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk})
def _nodes(self):
return WorkflowNodeRun.objects.filter(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=self.batch.pk,
).order_by("id")
def _run_node(self, node: WorkflowNodeRun) -> None:
node.status = WorkflowNodeRun.Status.RUNNING
node.progress = 10
node.started_at = timezone.now()
node.message = f"{node.node_name}处理中"
node.save(update_fields=["status", "progress", "started_at", "message"])
record_event(self.batch, "node_progress", {"node_code": node.node_code, "status": node.status})
self._execute_node(node)
node.status = WorkflowNodeRun.Status.SUCCESS
node.progress = 100
node.finished_at = timezone.now()
node.message = f"{node.node_name}完成"
node.save(update_fields=["status", "progress", "finished_at", "message"])
record_event(self.batch, "node_progress", {"node_code": node.node_code, "status": node.status})
def _execute_node(self, node: WorkflowNodeRun) -> None:
if node.node_code == "prepare":
self.template_config = load_template_config()
errors = validate_template_config(self.template_config)
if errors:
raise ValueError("".join(errors))
self.batch.template_config_version = str(self.template_config.get("version") or "")
self.batch.template_config_hash = compute_config_hash()
self.batch.save(update_fields=["template_config_version", "template_config_hash"])
return
if node.node_code == "template_copy":
return
if node.node_code == "text_extract":
if not self.batch.source_storage_path:
self.instruction = None
return
path = resolve_storage_path(self.batch.source_storage_path)
self.instruction = parse_instruction_docx(path)
json_path = ensure_batch_subdir(self.batch, "logs") / "instruction_extract.json"
save_instruction_extract_json(json_path, self.instruction)
create_artifact_for_file(
self.batch,
path=json_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.INSTRUCTION_EXTRACT,
file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON,
created_by_node=node.node_code,
)
return
if node.node_code == "field_extract":
if not self.instruction:
self.extract_payload = {"regex_results": {}, "llm_results": {}, "llm_error": ""}
return
self.extract_payload = run_parallel_extract(self.instruction, llm_extract_func=lambda _instruction: {})
json_path = ensure_batch_subdir(self.batch, "logs") / "field_extract_result.json"
save_field_extract_result(json_path, self.extract_payload)
create_artifact_for_file(
self.batch,
path=json_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.FIELD_EXTRACT_RESULT,
file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON,
created_by_node=node.node_code,
)
return
if node.node_code == "field_merge":
self.merged_fields, self.merge_summary = merge_fields(
self.extract_payload.get("regex_results") or {},
self.extract_payload.get("llm_results") or {},
)
product = self.merged_fields.get("product_name")
if product and product.value and product.value != "/":
self.batch.product_name = product.value
self.batch.missing_fields = self.merge_summary.get("missing_fields", [])
self.batch.llm_only_fields = self.merge_summary.get("llm_only_fields", [])
self.batch.conflict_fields = self.merge_summary.get("conflict_fields", [])
self.batch.save(update_fields=["product_name", "missing_fields", "llm_only_fields", "conflict_fields"])
json_path = ensure_batch_subdir(self.batch, "logs") / "merged_fields.json"
save_merged_fields(json_path, self.merged_fields, self.merge_summary)
create_artifact_for_file(
self.batch,
path=json_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.MERGED_FIELDS,
file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON,
created_by_node=node.node_code,
)
return
if node.node_code == "generate_docs":
self.generation_results = generate_package_documents(self.batch, self.template_config, self.merged_fields)
generated_files = []
for result in self.generation_results:
if result.path:
artifact = create_artifact_for_file(
self.batch,
path=result.path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.GENERATED_DOCUMENT,
file_format=result.actual_format,
name=result.template_code,
metadata=result.__dict__,
created_by_node=node.node_code,
)
result.artifact_id = artifact.pk
if result.status in {"success", "fallback_success"}:
export = self._create_export(
path=result.path,
export_type=ExportedSummaryFile.ExportType.WORD,
export_category="generated_document",
)
result.export_id = export.pk
self.exports.append(export)
generated_files.append(result.__dict__)
self.batch.generated_files = generated_files
self.batch.save(update_fields=["generated_files"])
return
if node.node_code == "highlight_review_items":
return
if node.node_code == "trace_export":
excel_path, json_path = save_traceability_exports(self.batch.work_dir, self.merged_fields)
create_artifact_for_file(
self.batch,
path=json_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.TRACEABILITY,
file_format=RegulatoryInfoPackageArtifact.FileFormat.JSON,
created_by_node=node.node_code,
)
artifact = create_artifact_for_file(
self.batch,
path=excel_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.TRACEABILITY,
file_format=RegulatoryInfoPackageArtifact.FileFormat.EXCEL,
created_by_node=node.node_code,
)
export = self._create_export(
path=str(excel_path),
export_type=ExportedSummaryFile.ExportType.EXCEL,
export_category="traceability",
)
self.exports.append(export)
artifact.metadata = {"export_id": export.pk}
artifact.save(update_fields=["metadata"])
return
if node.node_code == "zip_export":
zip_path = create_zip_package(self.batch.work_dir, self.generation_results, self.batch.output_zip_name)
artifact = create_artifact_for_file(
self.batch,
path=zip_path,
artifact_type=RegulatoryInfoPackageArtifact.ArtifactType.ZIP_PACKAGE,
file_format=RegulatoryInfoPackageArtifact.FileFormat.ZIP,
created_by_node=node.node_code,
)
export = self._create_export(
path=str(zip_path),
export_type=ExportedSummaryFile.ExportType.ZIP,
export_category="regulatory_info_package",
)
self.exports.insert(0, export)
artifact.metadata = {"export_id": export.pk}
artifact.save(update_fields=["metadata"])
return
if node.node_code == "notify":
RegulatoryInfoPackageNotificationRecord.objects.create(
batch=self.batch,
recipient=self.batch.user,
export_ids=[export.pk for export in self.exports],
message_summary=build_assistant_summary(
batch_no=self.batch.batch_no,
exports=[
{
"file_name": export.file_name,
"download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/",
"export_type": export.export_type,
}
for export in self.exports
],
failed_files=[item for item in self.batch.generated_files if item.get("status") == "failed"],
),
send_status=RegulatoryInfoPackageNotificationRecord.SendStatus.SUCCESS,
)
return
def _append_completion_message(self) -> None:
if (
Message.objects.filter(
conversation=self.batch.conversation,
role=Message.Role.ASSISTANT,
content__contains=self.batch.batch_no,
)
.filter(content__contains=self.batch.output_zip_name)
.exists()
):
return
exports = list(
ExportedSummaryFile.objects.filter(
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=self.batch.pk,
)
)
exports = sorted(exports, key=lambda export: 0 if export.export_type == ExportedSummaryFile.ExportType.ZIP else 1)
content = build_assistant_summary(
batch_no=self.batch.batch_no,
exports=[
{
"file_name": export.file_name,
"download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/",
"export_type": export.export_type,
}
for export in exports
],
failed_files=[item for item in self.batch.generated_files if item.get("status") == "failed"],
)
Message.objects.create(
conversation=self.batch.conversation,
role=Message.Role.ASSISTANT,
content=content,
)
def _create_export(self, *, path: str, export_type: str, export_category: str) -> ExportedSummaryFile:
from pathlib import Path
resolved = Path(path)
return ExportedSummaryFile.objects.create(
batch=None,
workflow_type=WORKFLOW_TYPE,
workflow_batch_id=self.batch.pk,
export_category=export_category,
export_type=export_type,
file_name=resolved.name,
storage_path=str(resolved),
)
def start_regulatory_info_package_workflow(
batch: RegulatoryInfoPackageBatch,
*,
async_run: bool | None = None,
) -> None:
if async_run is None:
async_run = getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True)
executor = RegulatoryInfoPackageWorkflowExecutor(batch)
if async_run:
Thread(target=executor.run, daemon=True).start()
else:
executor.run()

View File

@@ -37,6 +37,7 @@ def retrieve_citations(
"source": metadata.get("source", "法规材料"), "source": metadata.get("source", "法规材料"),
"text": document, "text": document,
"score": distance, "score": distance,
"metadata": metadata,
} }
) )
return citations return citations

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
import shutil
import subprocess import subprocess
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
@@ -9,6 +10,10 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from docx import Document from docx import Document
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table
from docx.text.paragraph import Paragraph
from openpyxl import load_workbook from openpyxl import load_workbook
from pypdf import PdfReader from pypdf import PdfReader
from pptx import Presentation from pptx import Presentation
@@ -18,6 +23,8 @@ from .rag_embedding import EmbeddingFunction
logger = logging.getLogger("review_agent.regulatory_review.rag_index") logger = logging.getLogger("review_agent.regulatory_review.rag_index")
EXCLUDED_SOURCE_KEYWORDS = ("模拟题二", "试剂盒临床注册文件准备与审核Agent")
@dataclass(frozen=True) @dataclass(frozen=True)
class TextChunk: class TextChunk:
@@ -49,7 +56,7 @@ def extract_text_from_path(path: Path) -> str:
if suffix == ".pdf": if suffix == ".pdf":
return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages) return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages)
if suffix == ".docx": if suffix == ".docx":
return "\n".join(paragraph.text for paragraph in Document(str(path)).paragraphs) return _extract_docx_text(path)
if suffix == ".pptx": if suffix == ".pptx":
presentation = Presentation(str(path)) presentation = Presentation(str(path))
lines = [] lines = []
@@ -72,7 +79,59 @@ def extract_text_from_path(path: Path) -> str:
return "" return ""
def _extract_docx_text(path: Path) -> str:
document = Document(str(path))
lines: list[str] = []
for block in _iter_docx_blocks(document):
if isinstance(block, Paragraph):
text = block.text.strip()
if text:
lines.append(text)
elif isinstance(block, Table):
for row in block.rows:
values = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if values:
lines.append("\t".join(values))
return "\n".join(lines)
def _iter_docx_blocks(document):
body = document.element.body
for child in body.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, document)
elif isinstance(child, CT_Tbl):
yield Table(child, document)
def _extract_legacy_doc_with_libreoffice(path: Path) -> str: def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
cached = _cached_docx_path(path)
if cached.exists():
return extract_text_from_path(cached)
try:
return _extract_legacy_doc_with_libreoffice_convert(path)
except RuntimeError as libreoffice_error:
try:
return _extract_legacy_doc_with_word_com(path)
except RuntimeError as word_error:
try:
return _extract_legacy_doc_with_powershell_word_com(path)
except RuntimeError as powershell_error:
raise RuntimeError(
f"无法转换法规 .doc 材料:{path.name}"
f"LibreOffice 错误:{libreoffice_error}"
f"Word COM 错误:{word_error}"
f"PowerShell Word COM 错误:{powershell_error}"
) from powershell_error
def _cached_docx_path(path: Path) -> Path:
digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
cache_dir = Path(settings.MEDIA_ROOT) / "regulatory_review" / "docx_cache"
return cache_dir / f"{path.stem}-{digest}.docx"
def _extract_legacy_doc_with_libreoffice_convert(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir) target_dir = Path(tmp_dir)
try: try:
@@ -99,11 +158,79 @@ def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
return extract_text_from_path(converted) return extract_text_from_path(converted)
def _extract_legacy_doc_with_word_com(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir)
converted = target_dir / f"{path.stem}.docx"
word = None
try:
import pythoncom
import win32com.client
pythoncom.CoInitialize()
word = win32com.client.DispatchEx("Word.Application")
word.Visible = False
document = word.Documents.Open(str(path.resolve()), ReadOnly=True)
document.SaveAs(str(converted.resolve()), FileFormat=16)
document.Close(False)
except Exception as exc:
raise RuntimeError(f"无法通过 Word COM 转换法规 .doc 材料:{path.name}") from exc
finally:
if word is not None:
try:
word.Quit()
except Exception:
pass
try:
pythoncom.CoUninitialize()
except Exception:
pass
if not converted.exists():
raise RuntimeError(f"Word COM 未生成 docx{path.name}")
return extract_text_from_path(converted)
def _extract_legacy_doc_with_powershell_word_com(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir)
converted = target_dir / f"{path.stem}.docx"
source_path = str(path.resolve()).replace("'", "''")
target_path = str(converted.resolve()).replace("'", "''")
script = (
"$ErrorActionPreference = 'Stop';"
"$word = New-Object -ComObject Word.Application;"
"$word.Visible = $false;"
"try {"
f"$doc = $word.Documents.Open('{source_path}', $false, $true);"
f"$doc.SaveAs([ref]'{target_path}', [ref]16);"
"$doc.Close([ref]$false);"
"} finally { $word.Quit() }"
)
powershell = shutil.which("powershell") or shutil.which("pwsh")
if not powershell:
raise RuntimeError("PowerShell 不可用,无法调用 Word COM。")
try:
subprocess.run(
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
check=True,
capture_output=True,
text=True,
timeout=90,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
raise RuntimeError(f"无法通过 PowerShell Word COM 转换法规 .doc 材料:{path.name}") from exc
if not converted.exists():
raise RuntimeError(f"PowerShell Word COM 未生成 docx{path.name}")
return extract_text_from_path(converted)
def collect_source_chunks(source_dir: Path) -> list[TextChunk]: def collect_source_chunks(source_dir: Path) -> list[TextChunk]:
chunks: list[TextChunk] = [] chunks: list[TextChunk] = []
for path in sorted(source_dir.rglob("*")): for path in sorted(source_dir.rglob("*")):
if not path.is_file(): if not path.is_file():
continue continue
if is_excluded_source_path(path.relative_to(source_dir)):
continue
try: try:
text = extract_text_from_path(path) text = extract_text_from_path(path)
except RuntimeError as exc: except RuntimeError as exc:
@@ -115,6 +242,11 @@ def collect_source_chunks(source_dir: Path) -> list[TextChunk]:
return chunks return chunks
def is_excluded_source_path(path: Path | str) -> bool:
normalized = str(path)
return any(keyword in normalized for keyword in EXCLUDED_SOURCE_KEYWORDS)
def _is_attachment4(path: Path) -> bool: def _is_attachment4(path: Path) -> bool:
normalized = path.name.replace(" ", "") normalized = path.name.replace(" ", "")
return "附件4" in normalized and "体外诊断试剂注册申报资料要求及说明" in normalized return "附件4" in normalized and "体外诊断试剂注册申报资料要求及说明" in normalized
@@ -126,6 +258,7 @@ def build_chroma_index(
embedding_provider: EmbeddingFunction, embedding_provider: EmbeddingFunction,
persist_path: Path | None = None, persist_path: Path | None = None,
collection_name: str | None = None, collection_name: str | None = None,
reset: bool = False,
) -> int: ) -> int:
try: try:
import chromadb import chromadb
@@ -136,7 +269,22 @@ def build_chroma_index(
collection_name = collection_name or settings.REGULATORY_RAG_COLLECTION collection_name = collection_name or settings.REGULATORY_RAG_COLLECTION
persist_path.mkdir(parents=True, exist_ok=True) persist_path.mkdir(parents=True, exist_ok=True)
chunks = collect_source_chunks(source_dir) chunks = collect_source_chunks(source_dir)
client = chromadb.PersistentClient(path=str(persist_path)) try:
client = chromadb.PersistentClient(path=str(persist_path))
except Exception:
if not reset:
raise
clear_chroma_system_cache()
clear_chroma_index_dir(persist_path)
persist_path.mkdir(parents=True, exist_ok=True)
client = chromadb.PersistentClient(path=str(persist_path))
if reset:
try:
client.delete_collection(collection_name)
clear_chroma_system_cache()
client = chromadb.PersistentClient(path=str(persist_path))
except Exception:
pass
collection = client.get_or_create_collection(collection_name) collection = client.get_or_create_collection(collection_name)
if not chunks: if not chunks:
return 0 return 0
@@ -153,3 +301,22 @@ def build_chroma_index(
embeddings=embeddings, embeddings=embeddings,
) )
return len(chunks) return len(chunks)
def clear_chroma_index_dir(persist_path: Path | str | None = None) -> None:
chroma_path = Path(persist_path or settings.REGULATORY_RAG_CHROMA_PATH).resolve()
media_root = Path(settings.MEDIA_ROOT).resolve()
try:
chroma_path.relative_to(media_root)
except ValueError as exc:
raise RuntimeError("法规 RAG 索引目录必须位于 MEDIA_ROOT 内。") from exc
if chroma_path.exists():
shutil.rmtree(chroma_path)
def clear_chroma_system_cache() -> None:
try:
from chromadb.api.shared_system_client import SharedSystemClient
except Exception:
return
SharedSystemClient.clear_system_cache()

View File

@@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
from review_agent.notifications.presenter import serialize_notification_records
from review_agent.regulatory_review.events import record_event from review_agent.regulatory_review.events import record_event
from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates
from review_agent.regulatory_review.services.rectification_review import review_missing_issues from review_agent.regulatory_review.services.rectification_review import review_missing_issues
@@ -25,6 +26,7 @@ def batch_status(request, batch_id: int):
workflow_type="regulatory_review", workflow_type="regulatory_review",
workflow_batch_id=batch.pk, workflow_batch_id=batch.pk,
).order_by("id") ).order_by("id")
notifications = serialize_notification_records("regulatory_review", batch.pk)
payload = { payload = {
"batch": { "batch": {
"id": batch.pk, "id": batch.pk,
@@ -46,6 +48,8 @@ def batch_status(request, batch_id: int):
} }
for node in nodes for node in nodes
], ],
"notifications": notifications,
"latest_notification": notifications[0] if notifications else None,
} }
if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates: if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates:
payload["condition_confirmation"] = { payload["condition_confirmation"] = {

View File

@@ -18,6 +18,8 @@ from review_agent.models import (
RegulatoryReviewBatch, RegulatoryReviewBatch,
WorkflowNodeRun, WorkflowNodeRun,
) )
from review_agent.notifications.dispatcher import dispatch_workflow_notification
from review_agent.notifications.workflow_adapters import build_regulatory_review_context
from review_agent.regulatory_review.services.completeness_check import run_completeness_check from review_agent.regulatory_review.services.completeness_check import run_completeness_check
from review_agent.regulatory_review.services.consistency_check import run_consistency_check from review_agent.regulatory_review.services.consistency_check import run_consistency_check
from review_agent.regulatory_review.services.export import build_assistant_summary, export_review_results from review_agent.regulatory_review.services.export import build_assistant_summary, export_review_results
@@ -146,14 +148,25 @@ class RegulatoryWorkflowExecutor:
self.batch.finished_at = timezone.now() self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "error_message", "finished_at"]) self.batch.save(update_fields=["status", "error_message", "finished_at"])
record_event(self.batch, "workflow_failed", {"message": str(exc)}) record_event(self.batch, "workflow_failed", {"message": str(exc)})
self._dispatch_completion_notification()
return return
self.batch.status = RegulatoryReviewBatch.Status.SUCCESS self.batch.status = RegulatoryReviewBatch.Status.SUCCESS
self.batch.finished_at = timezone.now() self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "finished_at"]) self.batch.save(update_fields=["status", "finished_at"])
record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk})
self._dispatch_completion_notification()
logger.info("法规核查工作流完成 batch_no=%s findings=%s", self.batch.batch_no, len(self.findings)) logger.info("法规核查工作流完成 batch_no=%s findings=%s", self.batch.batch_no, len(self.findings))
def _dispatch_completion_notification(self) -> None:
try:
dispatch_workflow_notification(build_regulatory_review_context(self.batch))
except Exception as exc:
logger.warning(
"Regulatory review notification failed without blocking workflow",
extra={"batch_id": self.batch.pk, "error": str(exc)},
)
def _nodes(self): def _nodes(self):
return WorkflowNodeRun.objects.filter( return WorkflowNodeRun.objects.filter(
workflow_type="regulatory_review", workflow_type="regulatory_review",

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
from pathlib import Path
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.conf import settings from django.conf import settings
@@ -9,13 +10,21 @@ from django.utils import timezone
from .file_summary.skills.attachment_reader import AttachmentReaderSkill from .file_summary.skills.attachment_reader import AttachmentReaderSkill
from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
from .knowledge_base import search_knowledge_base
from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
from .models import Conversation, FileAttachment, FileSummaryBatch, Message from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, KnowledgeBaseDocument, Message
from .regulatory_review.services.rag_index import extract_text_from_path
from .application_form_fill.workflow import ( from .application_form_fill.workflow import (
create_application_form_fill_batch, create_application_form_fill_batch,
find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch, find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch,
start_application_form_fill_workflow, start_application_form_fill_workflow,
) )
from .regulatory_info_package.constants import WORKFLOW_TYPE as REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE
from .regulatory_info_package.services.input_select import select_instruction_input
from .regulatory_info_package.workflow import (
create_regulatory_info_package_batch,
start_regulatory_info_package_workflow,
)
from .regulatory_review.workflow import ( from .regulatory_review.workflow import (
create_regulatory_review_batch, create_regulatory_review_batch,
find_latest_successful_summary_batch, find_latest_successful_summary_batch,
@@ -104,10 +113,14 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes
"""Stores one user message and one provider-backed assistant reply.""" """Stores one user message and one provider-backed assistant reply."""
user_message = append_user_message(conversation, content) user_message = append_user_message(conversation, content)
try: knowledge_context = build_knowledge_context(content)
reply_content = generate_reply(conversation, content) if should_refuse_ungrounded_chat(conversation, content, knowledge_context):
except (LLMConfigurationError, LLMRequestError) as exc: reply_content = out_of_scope_reply()
reply_content = f"模型调用失败:{exc}" else:
try:
reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context)
except (LLMConfigurationError, LLMRequestError) as exc:
reply_content = f"模型调用失败:{exc}"
assistant_message = append_assistant_message(conversation, reply_content) assistant_message = append_assistant_message(conversation, reply_content)
@@ -123,6 +136,31 @@ def stream_message(conversation: Conversation, content: str):
user_message = append_user_message(conversation, content) user_message = append_user_message(conversation, content)
assistant_parts: list[str] = [] assistant_parts: list[str] = []
knowledge_context = build_knowledge_context(content)
if should_refuse_ungrounded_chat(conversation, content, knowledge_context):
reply_content = out_of_scope_reply()
assistant_message = append_assistant_message(conversation, reply_content)
yield sse_event(
"meta",
{
"conversation_id": conversation.pk,
"title": conversation.title or build_conversation_title(content),
"user_message_id": user_message.pk,
"user_message": user_message.content,
},
)
yield sse_event("chunk", {"delta": reply_content})
yield sse_event(
"done",
{
"assistant_message_id": assistant_message.pk,
"conversation_id": conversation.pk,
"title": conversation.title,
},
)
return
route = route_message_intent(conversation, content) route = route_message_intent(conversation, content)
logger.info( logger.info(
"Stream message started", "Stream message started",
@@ -231,6 +269,8 @@ def stream_message(conversation: Conversation, content: str):
if route.starts_application_form_fill: if route.starts_application_form_fill:
source_summary_batch = find_latest_successful_form_fill_summary_batch(conversation) source_summary_batch = find_latest_successful_form_fill_summary_batch(conversation)
if source_summary_batch and not _summary_covers_active_attachments(conversation, source_summary_batch):
source_summary_batch = None
if not source_summary_batch: if not source_summary_batch:
if not _has_active_attachments(conversation): if not _has_active_attachments(conversation):
reply_content = "请先在当前对话右侧上传需要填表的产品资料或压缩包,我会先自动汇总再继续生成申报模板。" reply_content = "请先在当前对话右侧上传需要填表的产品资料或压缩包,我会先自动汇总再继续生成申报模板。"
@@ -308,6 +348,56 @@ def stream_message(conversation: Conversation, content: str):
) )
return return
if route.starts_regulatory_info_package:
selection = select_instruction_input(conversation, content)
if selection.status != "selected":
reply_content = selection.message or "请先在当前对话右侧上传产品说明书 docx 文件然后再发送第1章监管信息生成指令。"
assistant_message = append_assistant_message(conversation, reply_content)
yield sse_event("chunk", {"delta": reply_content})
yield sse_event(
"done",
{
"assistant_message_id": assistant_message.pk,
"conversation_id": conversation.pk,
"title": conversation.title,
},
)
return
batch = create_regulatory_info_package_batch(
conversation=conversation,
user=conversation.user,
trigger_message=user_message,
source_attachment=selection.attachment,
source_summary_batch=selection.source_summary_batch,
source_summary_item_id=selection.source_summary_item_id,
source_file_name=selection.file_name,
source_storage_path=selection.storage_path,
)
start_regulatory_info_package_workflow(
batch,
async_run=getattr(settings, "REGULATORY_INFO_PACKAGE_ASYNC", True),
)
reply_content = f"已启动第1章监管信息材料包生成工作流批次号{batch.batch_no}"
assistant_message = append_assistant_message(conversation, reply_content)
yield sse_event(
"workflow_started",
{
"workflow_type": REGULATORY_INFO_PACKAGE_WORKFLOW_TYPE,
"batch_id": batch.pk,
"batch_no": batch.batch_no,
},
)
yield sse_event("chunk", {"delta": reply_content})
yield sse_event(
"done",
{
"assistant_message_id": assistant_message.pk,
"conversation_id": conversation.pk,
"title": conversation.title,
},
)
return
if route.starts_regulatory_review: if route.starts_regulatory_review:
source_summary_batch = find_latest_successful_summary_batch(conversation) source_summary_batch = find_latest_successful_summary_batch(conversation)
if not source_summary_batch: if not source_summary_batch:
@@ -390,7 +480,7 @@ def stream_message(conversation: Conversation, content: str):
stream_failed = False stream_failed = False
stream_error = "" stream_error = ""
try: try:
for chunk in stream_reply(conversation, content): for chunk in stream_reply(conversation, content, knowledge_context=knowledge_context):
assistant_parts.append(chunk) assistant_parts.append(chunk)
yield sse_event("chunk", {"delta": chunk}) yield sse_event("chunk", {"delta": chunk})
except (LLMConfigurationError, LLMRequestError) as exc: except (LLMConfigurationError, LLMRequestError) as exc:
@@ -410,7 +500,7 @@ def stream_message(conversation: Conversation, content: str):
if stream_failed: if stream_failed:
try: try:
fallback_reply = generate_reply(conversation, content) fallback_reply = generate_reply(conversation, content, knowledge_context=knowledge_context)
assistant_parts = [fallback_reply] assistant_parts = [fallback_reply]
logger.info( logger.info(
"Non-stream fallback reply succeeded", "Non-stream fallback reply succeeded",
@@ -459,6 +549,188 @@ def build_conversation_title(content: str) -> str:
return normalized[:24] return normalized[:24]
def build_knowledge_context(content: str, *, n_results: int = 5) -> str:
"""Formats global knowledge-base search hits for normal chat prompts."""
full_document_context = build_filename_matched_document_context(content)
if full_document_context:
return full_document_context
try:
payload = search_knowledge_base(content, n_results=n_results)
except Exception as exc:
logger.warning("Knowledge-base search failed", extra={"error": str(exc)})
return ""
if payload.get("error_message"):
return ""
results = [
item
for item in _rank_knowledge_results(content, payload.get("results") or [])
if _is_relevant_knowledge_result(content, item)
]
lines: list[str] = []
for index, item in enumerate(results[:n_results], start=1):
text = " ".join(str(item.get("text") or "").split())
if not text:
continue
source = str(item.get("source") or "未知来源")
score = item.get("score")
score_label = f"score={score:.4f}" if isinstance(score, (int, float)) else ""
lines.append(f"[{index}] 来源:{source}{score_label}\n{text[:1200]}")
return "\n\n".join(lines)
def should_refuse_ungrounded_chat(
conversation: Conversation,
content: str,
knowledge_context: str = "",
) -> bool:
if (knowledge_context or "").strip():
return False
if _is_business_related_question(content):
return False
if _has_active_attachments(conversation):
return False
return True
def out_of_scope_reply() -> str:
return (
"没有在当前启用的知识库材料中找到可依据的内容,且这个问题与当前主营业务无关。"
"为避免编造,我不能直接回答。请先上传或启用相关知识库材料,或改问体外诊断试剂注册资料审核、"
"文件汇总、法规核查、申报填表等业务范围内的问题。"
)
def _is_business_related_question(content: str) -> bool:
normalized = (content or "").lower()
compact = "".join(normalized.split())
if not compact:
return True
business_keywords = [
"审核智能体",
"体外诊断",
"ivd",
"nmpa",
"cmde",
"医疗器械",
"注册资料",
"注册申报",
"注册检验",
"注册证",
"申报资料",
"申报文件",
"法规",
"核查",
"审评",
"审核",
"整改",
"风险",
"说明书",
"临床",
"性能",
"安全",
"适用范围",
"预期用途",
"附件",
"文件",
"压缩包",
"目录",
"页数",
"清单",
"汇总",
"模板",
"填表",
"知识库",
"检索",
"报告",
"材料",
"资料",
]
return any(keyword in compact for keyword in business_keywords)
def build_filename_matched_document_context(query: str, *, max_chars: int = 12000) -> str:
terms = _knowledge_query_terms(query)
if not terms:
return ""
matches = []
for document in KnowledgeBaseDocument.objects.filter(
status=KnowledgeBaseDocument.Status.ACTIVE,
is_active=True,
).order_by("-updated_at", "-id"):
filename = f"{document.display_name} {document.original_name}"
if any(term and term in filename for term in terms):
matches.append(document)
if not matches:
return ""
lines = [
"以下材料因用户问题中的关键词命中文档名称,已读取全文供回答前比对和总结。"
]
for index, document in enumerate(matches[:3], start=1):
text = _extract_managed_document_text(document)
if not text:
continue
lines.append(
f"[全文材料 {index}] 来源:用户知识库/{document.original_name}\n"
f"{' '.join(text.split())[:max_chars]}"
)
return "\n\n".join(lines).strip()
def _extract_managed_document_text(document: KnowledgeBaseDocument) -> str:
try:
return extract_text_from_path(Path(document.storage_path))
except Exception as exc:
logger.warning(
"Managed document full-text extraction failed",
extra={"document_id": document.pk, "error": str(exc)},
)
return ""
def _rank_knowledge_results(query: str, results: list[dict[str, object]]) -> list[dict[str, object]]:
terms = [term for term in _knowledge_query_terms(query) if term]
def sort_key(item: dict[str, object]) -> tuple[int, float]:
source = str(item.get("source") or "")
text = str(item.get("text") or "")
haystack = f"{source}\n{text}"
direct_hit = any(term in haystack for term in terms)
score = item.get("score")
numeric_score = float(score) if isinstance(score, (int, float)) else 999999.0
return (0 if direct_hit else 1, numeric_score)
return sorted(results, key=sort_key)
def _is_relevant_knowledge_result(query: str, item: dict[str, object]) -> bool:
terms = _knowledge_query_terms(query)
if not terms:
return False
source = str(item.get("source") or "")
text = str(item.get("text") or "")
haystack = f"{source}\n{text}"
if any(term in haystack for term in terms):
return True
metadata = item.get("metadata") or {}
if metadata.get("source_type") == "managed_document":
return True
return False
def _knowledge_query_terms(query: str) -> list[str]:
normalized = "".join((query or "").split())
if not normalized:
return []
stop_chars = set("是谁什么哪里如何怎么请问一下帮我你能告诉吗??,。.")
compact = "".join(char for char in normalized if char not in stop_chars)
terms = [compact] if compact else []
if normalized not in terms:
terms.append(normalized)
return terms
def _select_attachments_for_reader(conversation: Conversation, content: str): def _select_attachments_for_reader(conversation: Conversation, content: str):
attachments = list( attachments = list(
FileAttachment.objects.filter( FileAttachment.objects.filter(
@@ -480,6 +752,20 @@ def _has_active_attachments(conversation: Conversation) -> bool:
) )
def _summary_covers_active_attachments(conversation: Conversation, batch: FileSummaryBatch) -> bool:
active_ids = set(
FileAttachment.objects.filter(conversation=conversation, is_active=True)
.exclude(upload_status=FileAttachment.UploadStatus.DELETED)
.values_list("id", flat=True)
)
if not active_ids:
return True
bound_ids = set(
FileSummaryBatchAttachment.objects.filter(batch=batch).values_list("attachment_id", flat=True)
)
return active_ids.issubset(bound_ids)
def _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str: def _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str:
if not attachments: if not attachments:
return message or "当前对话没有可读取的附件。" return message or "当前对话没有可读取的附件。"

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