Compare commits

..

18 Commits

Author SHA1 Message Date
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
67 changed files with 5576 additions and 19 deletions

6
.env
View File

@@ -17,3 +17,9 @@ SCENARIO_CONFIG_DIR=configs
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
UPLOAD_ROOT=data/uploads
CHROMA_PATH=data/chroma
FEISHU_NOTIFY_ENABLED=true
FEISHU_APP_ID=cli_aaafcc59f4b85bc2
FEISHU_APP_SECRET=OO8GKpjqTO3bHAUwCiSmRgW4FqsNB5Qa
FEISHU_DEFAULT_USER_OPEN_ID=ou_a6015773781a117eb7d8995efa5e4590
FEISHU_DEFAULT_TARGET_NAME=bruce
PUBLIC_BASE_URL=http://127.0.0.1:8000

View File

@@ -35,3 +35,43 @@ LibreOffice 不是必需依赖,仅作为未来增强老格式文档解析的
上传原始文件、批次工作目录和导出文件默认存储在 Django `MEDIA_ROOT` 下的
`file_summary/users/<user_id>/<conversation_id>/` 或批次 `work_dir` 目录中。生产环境
需要把 `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

@@ -126,6 +126,24 @@ SILICONFLOW_EMBEDDING_MODEL = os.environ.get(
)
SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024"))
FEISHU_NOTIFY_ENABLED = os.environ.get("FEISHU_NOTIFY_ENABLED", "false").lower() == "true"
FEISHU_NOTIFY_CHANNEL = os.environ.get("FEISHU_NOTIFY_CHANNEL", "feishu_api")
FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "")
FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "")
FEISHU_DEFAULT_USER_OPEN_ID = os.environ.get("FEISHU_DEFAULT_USER_OPEN_ID", "")
FEISHU_DEFAULT_USER_ID = os.environ.get("FEISHU_DEFAULT_USER_ID", "")
FEISHU_DEFAULT_TARGET_NAME = os.environ.get("FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人")
FEISHU_TENANT_TOKEN_CACHE_SECONDS = int(os.environ.get("FEISHU_TENANT_TOKEN_CACHE_SECONDS", "6600"))
FEISHU_TOKEN_API_URL = os.environ.get(
"FEISHU_TOKEN_API_URL",
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
)
FEISHU_MESSAGE_API_URL = os.environ.get(
"FEISHU_MESSAGE_API_URL",
"https://open.feishu.cn/open-apis/im/v1/messages",
)
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://127.0.0.1:8000")
LOGGING = {
"version": 1,
"disable_existing_loggers": False,

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,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,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,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,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 |

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 = [

View File

@@ -15,6 +15,37 @@ from review_agent.models import ApplicationFormFillArtifact, ApplicationFormFill
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]:
texts: dict[str, str] = {}
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]:
fields: list[dict[str, Any]] = []
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():
source_role = detect_source_role(file_name, text)
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:
continue
fields.append(
@@ -142,9 +173,45 @@ def _field_defs(specs: list[TemplateSpec]) -> list[dict[str, str]]:
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]:
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)
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)
match = pattern.search(text or "")
if not match:
@@ -156,6 +223,30 @@ def _extract_label_value(text: str, label: str, labels: list[str]) -> tuple[str,
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:
path = Path(__file__).resolve().parents[1] / "prompts" / "field_extract.md"
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 "按来源优先级采用最高优先级字段",
}
)
_apply_agent_company_fallbacks(merged)
return merged, conflicts
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")}
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,
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(
@@ -33,6 +35,13 @@ def notify_completion(
retry_count=1,
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(
batch=batch,
recipient=batch.user,
@@ -41,5 +50,6 @@ def notify_completion(
export_ids=export_ids,
message_summary=message_summary,
send_status=ApplicationFormFillNotificationRecord.SendStatus.SUCCESS,
error_message=unified_error,
sent_at=timezone.now(),
)

View File

@@ -22,10 +22,11 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor
lines.extend(["", "| 冲突字段 | 采用值 | 冲突来源 | 处理 |", "| --- | --- | --- | --- |"])
for item in conflicts:
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(
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:
@@ -33,3 +34,10 @@ def build_assistant_summary(batch: ApplicationFormFillBatch, exports: list[Expor
for export in trace_exports:
lines.append(f"[下载{export.file_name}](/api/review-agent/file-summary/exports/{export.pk}/download/)")
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,
) -> Path:
document = Document(str(template_path))
remove_fill_instructions(document)
conflict_keys = {item.get("field_key") for item in conflicts or []}
for field_config in spec.fields:
target = field_config.get("target") or {}
@@ -43,6 +44,25 @@ def fill_template(
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:
normalized_label = _normalize_label(row_label)
for table in document.tables:
@@ -71,6 +91,15 @@ def apply_cell_shading(cell, fill: str) -> None:
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(
batch: ApplicationFormFillBatch,
spec: TemplateSpec,
@@ -107,5 +136,6 @@ def _normalize_label(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"

View File

@@ -36,6 +36,24 @@ templates:
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
label: 产品名称
target:

View File

@@ -11,6 +11,7 @@ from review_agent.application_form_fill.workflow import (
start_application_form_fill_workflow,
)
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun
from review_agent.notifications.presenter import serialize_notification_records
@require_http_methods(["GET"])
@@ -75,6 +76,7 @@ def batch_status(request, batch_id: int):
workflow_type="application_form_fill",
workflow_batch_id=batch.pk,
).order_by("id")
notifications = serialize_notification_records("application_form_fill", batch.pk)
return JsonResponse(
{
"batch": {
@@ -112,6 +114,8 @@ def batch_status(request, batch_id: int):
}
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 logging
from tempfile import TemporaryDirectory
from dataclasses import asdict, dataclass, field
from pathlib import Path
from django.conf import settings
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"}
SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"}
SUPPORTED_EXTENSIONS = TEXT_EXTENSIONS | {"pdf", "docx", "xlsx", "pptx"} | ARCHIVE_EXTENSIONS
MAX_PREVIEW_CHARS = 3000
MAX_ROWS_PER_SHEET = 20
@@ -72,6 +74,8 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult:
sections = _read_pptx(file_path)
elif file_type == "csv":
sections = _read_csv(file_path)
elif file_type in ARCHIVE_EXTENSIONS:
sections = _read_archive(file_path)
else:
sections = _read_text(file_path)
except Exception as exc:
@@ -208,6 +212,44 @@ def _read_pptx(path: Path) -> list[dict[str, object]]:
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:
parts: list[str] = []
for section in sections:

View File

@@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods
from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message
from review_agent.models import FileSummaryBatch, WorkflowEvent
from review_agent.notifications.presenter import serialize_notification_records
from .events import serialize_event
from .paths import resolve_storage_path
@@ -225,6 +226,7 @@ def batch_status(request, batch_id: int):
batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first()
if not batch:
raise Http404("批次不存在。")
notifications = serialize_notification_records("file_summary", batch.pk)
return JsonResponse(
{
"batch": {
@@ -249,6 +251,8 @@ def batch_status(request, batch_id: int):
}
for node in batch.node_runs.order_by("id")
],
"notifications": notifications,
"latest_notification": notifications[0] if notifications else None,
}
)

View File

@@ -17,6 +17,8 @@ from review_agent.models import (
Message,
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 .services.archive import ARCHIVE_EXTENSIONS
@@ -154,14 +156,25 @@ class WorkflowExecutor:
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)})
self._dispatch_completion_notification()
return
self.batch.status = FileSummaryBatch.Status.SUCCESS
self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "finished_at"])
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})
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:
logger.info(
"Workflow node started",

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

@@ -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

@@ -754,3 +754,202 @@ class ApplicationFormFillNotificationRecord(models.Model):
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"),
]
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

@@ -9,6 +9,10 @@ from pathlib import Path
from django.conf import settings
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 pypdf import PdfReader
from pptx import Presentation
@@ -49,7 +53,7 @@ def extract_text_from_path(path: Path) -> str:
if suffix == ".pdf":
return "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages)
if suffix == ".docx":
return "\n".join(paragraph.text for paragraph in Document(str(path)).paragraphs)
return _extract_docx_text(path)
if suffix == ".pptx":
presentation = Presentation(str(path))
lines = []
@@ -72,6 +76,31 @@ def extract_text_from_path(path: Path) -> str:
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:
with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir)

View File

@@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
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.services.info_extract import ensure_regulatory_condition_candidates
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_batch_id=batch.pk,
).order_by("id")
notifications = serialize_notification_records("regulatory_review", batch.pk)
payload = {
"batch": {
"id": batch.pk,
@@ -46,6 +48,8 @@ def batch_status(request, batch_id: int):
}
for node in nodes
],
"notifications": notifications,
"latest_notification": notifications[0] if notifications else None,
}
if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates:
payload["condition_confirmation"] = {

View File

@@ -18,6 +18,8 @@ from review_agent.models import (
RegulatoryReviewBatch,
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.consistency_check import run_consistency_check
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.save(update_fields=["status", "error_message", "finished_at"])
record_event(self.batch, "workflow_failed", {"message": str(exc)})
self._dispatch_completion_notification()
return
self.batch.status = RegulatoryReviewBatch.Status.SUCCESS
self.batch.finished_at = timezone.now()
self.batch.save(update_fields=["status", "finished_at"])
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))
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):
return WorkflowNodeRun.objects.filter(
workflow_type="regulatory_review",

View File

@@ -10,7 +10,7 @@ from django.utils import timezone
from .file_summary.skills.attachment_reader import AttachmentReaderSkill
from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
from .models import Conversation, FileAttachment, FileSummaryBatch, Message
from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, Message
from .application_form_fill.workflow import (
create_application_form_fill_batch,
find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch,
@@ -231,6 +231,8 @@ def stream_message(conversation: Conversation, content: str):
if route.starts_application_form_fill:
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 _has_active_attachments(conversation):
reply_content = "请先在当前对话右侧上传需要填表的产品资料或压缩包,我会先自动汇总再继续生成申报模板。"
@@ -480,6 +482,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:
if not attachments:
return message or "当前对话没有可读取的附件。"

View File

@@ -51,6 +51,10 @@ class SkillRoute:
def route_message_intent(conversation: Conversation, content: str) -> SkillRoute:
deterministic_route = _deterministic_workflow_route(conversation, content)
if deterministic_route:
return deterministic_route
attachments = list(_active_attachments(conversation))
try:
route = _route_with_llm(conversation, content, attachments)
@@ -75,6 +79,35 @@ def route_message_intent(conversation: Conversation, content: str) -> SkillRoute
return _route_with_rules(conversation, content)
def _deterministic_workflow_route(conversation: Conversation, content: str) -> SkillRoute | None:
if _matches_application_form_fill(content):
return SkillRoute(
action=FORM_FILL_WORKFLOW_TYPE,
workflow_type=FORM_FILL_WORKFLOW_TYPE,
confidence=0.9,
reason="命中明确申报文件自动填表关键词。",
source="rule_preflight",
)
if _matches_regulatory_review(content):
return SkillRoute(
action="regulatory_review",
workflow_type="regulatory_review",
confidence=0.9,
reason="命中明确法规核查关键词。",
source="rule_preflight",
)
file_summary = evaluate_file_summary_trigger(conversation, content)
if file_summary.should_start or file_summary.reason == "missing_attachment":
return SkillRoute(
action="file_summary",
workflow_type="file_summary",
confidence=0.8,
reason=file_summary.reason,
source="rule_preflight",
)
return None
def _route_with_llm(
conversation: Conversation,
content: str,

View File

@@ -887,7 +887,8 @@ input:focus {
.attachment-item span,
.workflow-card em,
.workflow-card small,
.workflow-error {
.workflow-error,
.workflow-notification {
color: var(--muted);
font-size: 12px;
}
@@ -1042,18 +1043,33 @@ input:focus {
.node-status span,
.node-status small,
.workflow-error {
.workflow-error,
.workflow-notification {
overflow-wrap: anywhere;
word-break: break-word;
}
.workflow-error {
.workflow-error,
.workflow-notification {
margin: 0;
padding: 8px 10px;
border-radius: 6px;
line-height: 1.5;
}
.workflow-error {
background: #fff1f0;
color: #b42318;
}
.workflow-notification {
background: #f5fbf7;
color: #166534;
}
.workflow-notification[data-notification-status="failed"] {
background: #fff1f0;
color: #b42318;
line-height: 1.5;
}
.status-running,

View File

@@ -734,6 +734,34 @@
return html;
}
function notificationLabel(notification) {
if (!notification) {
return "暂无飞书通知记录";
}
return notification.status_label || notification.send_status || "飞书通知状态未知";
}
function renderNotificationSummary(card, notification) {
var panel = card.querySelector(".workflow-notification");
if (!panel) {
panel = document.createElement("p");
panel.className = "workflow-notification";
card.insertBefore(panel, card.querySelector("ol"));
}
var text = notificationLabel(notification);
if (notification && notification.receiver) {
text += " · " + notification.receiver;
}
if (notification && notification.sent_at) {
text += " · " + notification.sent_at;
}
if (notification && notification.error_message) {
text += " · " + notification.error_message;
}
panel.textContent = text;
panel.setAttribute("data-notification-status", notification ? notification.send_status || "" : "none");
}
async function refreshWorkflowCard(batchId, workflow_type) {
if (!summaryPanel || !batchId) {
return "";
@@ -788,6 +816,7 @@
} else if (riskSummary) {
riskSummary.remove();
}
renderNotificationSummary(card, payload.latest_notification);
var list = card.querySelector("ol");
list.innerHTML = "";
(payload.nodes || []).forEach(function (node) {
@@ -965,6 +994,26 @@
});
}
function bindPromptTemplateButtons() {
document.querySelectorAll("[data-prompt-template]").forEach(function (button) {
if (button.dataset.bound === "true") {
return;
}
button.dataset.bound = "true";
button.addEventListener("click", function () {
if (!promptInput) {
return;
}
var template = button.getAttribute("data-prompt-template") || "";
promptInput.value = template;
promptInput.focus();
if (typeof promptInput.setSelectionRange === "function") {
promptInput.setSelectionRange(promptInput.value.length, promptInput.value.length);
}
});
});
}
async function streamChat(event) {
event.preventDefault();
if (!composer || !promptInput || !sendButton || !chatStage) {
@@ -1126,6 +1175,7 @@
bindWorkflowBatchCarouselControls();
bindConditionConfirmForms();
bindRectificationActionButtons();
bindPromptTemplateButtons();
refreshRunningWorkflowCards();
if (chatScroll) {

View File

@@ -198,9 +198,21 @@
<textarea id="prompt" name="prompt" rows="1" placeholder="输入审核问题、法规条款、说明书疑点或上传需求"></textarea>
<div class="composer-actions">
<div class="composer-tools">
<span class="tool-chip passive-chip">法规核查</span>
<span class="tool-chip passive-chip">说明书审核</span>
<span class="tool-chip passive-chip">风险识别</span>
<button
class="tool-chip"
type="button"
data-prompt-template="请对当前对话已上传的文件或压缩包自动汇总文件目录、文件类型和页数,并生成可下载的汇总报告。"
>目录自动汇总</button>
<button
class="tool-chip"
type="button"
data-prompt-template="请对当前对话最近成功汇总的注册资料发起 NMPA 法规核查与风险预警,检查完整性、章节结构、一致性、高风险问题、阻断项、证据来源和整改建议。"
>法规核查与风险预警</button>
<button
class="tool-chip"
type="button"
data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"
>申报文件填表</button>
</div>
<button class="send-button" type="submit" id="sendButton">发送</button>
</div>

View File

@@ -48,6 +48,102 @@ def test_rule_extracts_registration_certificate_fields():
assert values["package_specification"]["extractor"] == "rule"
def test_rule_extracts_bracket_sections_from_instructions():
texts = {
"目标产品说明书.docx": "\n".join(
[
"【产品名称】",
"新型冠状病毒2019-nCoV核酸检测试剂盒荧光PCR法",
"【包装规格】",
"规格A24人份/盒、48人份/盒、96人份/盒。",
"规格B24人份/盒、48人份/盒、96人份/盒。",
"【预期用途】",
"本试剂盒用于体外定性检测咽拭子、痰液样本中新型冠状病毒2019-nCoVORF1ab和N基因。",
"【检测原理】",
"本段不应进入预期用途。",
"【主要组成成分】",
"表1 规格A大包装试剂盒组成成分",
"组分\t规格\t数量",
"PCR反应液\t24人份/盒\t1管",
"【储存条件及有效期】",
"-20±5℃的避光条件有效期12个月。",
"反复冻融次数不得超过4次。",
"【样本要求】",
"适用样本类型:咽拭子、痰液。",
]
)
}
result = extract_by_rules(texts, _registration_specs())
values = {field["key"]: field["value"] for field in result["fields"]}
assert values["product_name"] == "新型冠状病毒2019-nCoV核酸检测试剂盒荧光PCR法"
assert "规格A" in values["package_specification"]
assert "检测原理" not in values["intended_use"]
assert "PCR反应液" in values["main_components"]
assert "-20±5℃" in values["storage_condition_and_validity"]
def test_rule_maps_agent_fields_to_manufacturer_company_for_now():
texts = {
"目标产品说明书.docx": "\n".join(
[
"生产企业名称:卡尤迪生物科技宜兴有限公司",
"生产企业住所江苏省宜兴经济技术开发区杏里路10号",
"生产地址江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室",
]
)
}
result = extract_by_rules(texts, _registration_specs())
values = {field["key"]: field["value"] for field in result["fields"]}
assert values["agent_name"] == "卡尤迪生物科技宜兴有限公司"
assert values["agent_address"] == "江苏省宜兴经济技术开发区杏里路10号"
assert values["manufacturer_address"] == "江苏省宜兴经济技术开发区杏里路10号宜兴光电产业园4幢102室"
def test_rule_stops_product_name_before_application_form_instructions():
texts = {
"境内体外诊断试剂注册申请表.docx": "\n".join(
[
"产品名称呼吸道合胞病毒、肺炎支原体核酸检测试剂盒荧光PCR法",
"申请人:",
"卡尤迪生物科技宜兴有限公司",
"国家药品监督管理局",
"填表说明",
"1. 本表依据《体外诊断注册与备案管理办法》制定。",
]
)
}
result = extract_by_rules(texts, _registration_specs())
values = {field["key"]: field["value"] for field in result["fields"]}
assert values["product_name"] == "呼吸道合胞病毒、肺炎支原体核酸检测试剂盒荧光PCR法"
assert "填表说明" not in values["product_name"]
def test_rule_ignores_generic_enterprise_name_from_application_form():
texts = {
"CH1.4 申请表.docx": "\n".join(
[
"注册人制度\t是 企业名称:否",
"优先通道申请 应急通道 同品种首个产品首次申报",
"临床试验",
"临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院",
"应附资料",
]
)
}
result = extract_by_rules(texts, _registration_specs())
values = {field["key"]: field["value"] for field in result["fields"]}
assert "applicant_name" not in values
assert "agent_name" not in values
def test_llm_extract_parses_structured_json(monkeypatch):
monkeypatch.setattr(
"review_agent.application_form_fill.services.field_extract.generate_completion",

View File

@@ -77,3 +77,35 @@ def test_merge_fields_combines_consistent_values_without_conflict():
assert merged["product_name"].value == "甲胎蛋白检测试剂盒"
assert merged["product_name"].has_conflict is False
assert conflicts == []
def test_merge_fields_fills_agent_from_applicant_for_now():
regex_results = {
"fields": [
{
"key": "applicant_name",
"label": "注册人名称",
"value": "卡尤迪生物科技宜兴有限公司",
"source_file": "目标产品说明书.docx",
"source_role": "说明书",
"evidence": "生产企业名称:卡尤迪生物科技宜兴有限公司",
"confidence": 0.75,
},
{
"key": "applicant_address",
"label": "注册人住所",
"value": "江苏省宜兴经济技术开发区杏里路10号",
"source_file": "目标产品说明书.docx",
"source_role": "说明书",
"evidence": "生产企业住所江苏省宜兴经济技术开发区杏里路10号",
"confidence": 0.75,
},
]
}
merged, conflicts = merge_fields(regex_results, {"fields": []})
assert merged["agent_name"].value == "卡尤迪生物科技宜兴有限公司"
assert merged["agent_name"].label == "代理人名称"
assert merged["agent_address"].value == "江苏省宜兴经济技术开发区杏里路10号"
assert conflicts == []

View File

@@ -46,3 +46,32 @@ def test_frontend_selects_application_form_fill_status_url_and_terminal_status()
assert 'workflow_type === "application_form_fill"' in script
assert "data-application-form-fill-status-url-template" in script
assert 'status === "partial_success"' in script
def test_application_form_fill_status_includes_no_feishu_notification(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF")
batch = ApplicationFormFillBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="AFF-FEISHU",
)
client.force_login(user)
response = client.get(f"/api/review-agent/application-form-fill/{batch.pk}/status/")
payload = response.json()
assert payload["latest_notification"] is None
assert payload["notifications"] == []
def test_frontend_renders_feishu_notification_status():
script = open("static/js/app.js", encoding="utf-8").read()
css = open("static/css/login.css", encoding="utf-8").read()
assert "renderNotificationSummary" in script
assert "暂无飞书通知记录" in script
assert "workflow-notification" in script
assert ".workflow-notification" in css

View File

@@ -13,7 +13,7 @@ from review_agent.models import (
pytestmark = pytest.mark.django_db
def test_notify_completion_records_success(django_user_model):
def test_notify_completion_records_success(django_user_model, monkeypatch):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-NOTIFY")
@@ -33,6 +33,16 @@ def test_notify_completion_records_success(django_user_model):
file_name="filled.docx",
storage_path="filled.docx",
)
calls = []
fake_record = type(
"Record",
(),
{"send_status": "success", "SendStatus": type("SendStatus", (), {"FAILED": "failed"}), "error_message": ""},
)()
monkeypatch.setattr(
"review_agent.application_form_fill.services.notifier.dispatch_workflow_notification",
lambda context: calls.append(context) or fake_record,
)
record = notify_completion(batch, [exported])
@@ -40,6 +50,7 @@ def test_notify_completion_records_success(django_user_model):
assert record.export_ids == [exported.pk]
assert record.template_codes == ["registration_certificate"]
assert record.sent_at is not None
assert calls[0].workflow_type == "application_form_fill"
def test_notify_completion_records_failure_without_raising(django_user_model):

View File

@@ -0,0 +1,39 @@
import pytest
from review_agent.application_form_fill.services.summary import build_assistant_summary
from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch
pytestmark = pytest.mark.django_db
def test_assistant_summary_compacts_long_conflict_values(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-SUMMARY")
batch = ApplicationFormFillBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="AFF-SUMMARY",
conflict_summary=[
{
"field_key": "applicant_name",
"field_label": "注册人名称",
"selected_value": "卡尤迪生物科技宜兴有限公司",
"conflict_values": [
{
"source_file": "CH1.4 申请表.docx",
"value": "\n临床试验\n临床试验机构名称: 中国医学科学院北京协和医院、晋中市第一人民医院、北京市疾病预防控制中心 临床数据库.zip\n应附资料",
}
],
"handling": "说明书优先,模板内黄底红字高亮",
}
],
)
content = build_assistant_summary(batch, [])
assert "临床试验机构名称" in content
assert len([line for line in content.splitlines() if "临床试验机构名称" in line][0]) < 220
assert "\n临床试验\n" not in content

View File

@@ -1,6 +1,6 @@
import pytest
from review_agent.models import Conversation
from review_agent.models import Conversation, FileAttachment
from review_agent.skill_router import route_message_intent
@@ -43,3 +43,31 @@ def test_rule_router_does_not_misroute_normal_chat(monkeypatch, django_user_mode
route = route_message_intent(conversation, "你好,解释一下法规背景")
assert route.action == "normal_chat"
def test_application_form_fill_prompt_preempts_attachment_reader_llm(monkeypatch, tmp_path, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
archive_path = tmp_path / "第1章_监管信息.rar"
archive_path.write_bytes(b"rar")
FileAttachment.objects.create(
conversation=conversation,
user=user,
original_name="第1章_监管信息.rar",
storage_path=str(archive_path),
file_size=archive_path.stat().st_size,
)
monkeypatch.setattr(
"review_agent.skill_router._route_with_llm",
lambda conversation, content, attachments: (_ for _ in ()).throw(
AssertionError("明确自动填表意图不应进入 LLM 路由")
),
)
route = route_message_intent(
conversation,
"请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板,优先生成注册证 Word 和字段来源追溯清单。",
)
assert route.action == "application_form_fill"
assert route.source == "rule_preflight"

View File

@@ -1,4 +1,5 @@
import zipfile
from pathlib import Path
import pytest
from docx import Document
@@ -40,6 +41,17 @@ def _template(path):
document.save(path)
def _template_with_instructions(path):
document = Document()
table = document.add_table(rows=2, cols=2)
table.rows[0].cells[0].text = "产品名称"
table.rows[1].cells[0].text = "预期用途"
document.add_paragraph("填表说明")
document.add_paragraph("1. 本表依据《体外诊断注册与备案管理办法》制定。")
document.add_paragraph("2. 本表可从国家药品监督管理局网站下载。")
document.save(path)
def test_word_fill_writes_table_rows(tmp_path):
template_path = tmp_path / "template.docx"
output_path = tmp_path / "filled.docx"
@@ -60,6 +72,27 @@ def test_word_fill_writes_table_rows(tmp_path):
assert document.tables[0].rows[1].cells[1].text == "用于体外检测"
def test_word_fill_removes_template_fill_instructions(tmp_path):
template_path = tmp_path / "template.docx"
output_path = tmp_path / "filled.docx"
_template_with_instructions(template_path)
fill_template(
template_path,
output_path,
_spec(),
{
"product_name": MergedField("product_name", "产品名称", "甲胎蛋白检测试剂盒", "说明书.txt", "证据", 0.8),
},
)
document = Document(output_path)
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
assert "填表说明" not in text
assert "本表依据" not in text
assert document.tables[0].rows[0].cells[1].text == "甲胎蛋白检测试剂盒"
def test_word_fill_highlights_conflict_in_docx_xml(tmp_path):
template_path = tmp_path / "template.docx"
output_path = tmp_path / "filled.docx"
@@ -119,3 +152,31 @@ def test_create_word_export_records_artifact_and_export(settings, tmp_path, djan
batch=batch,
artifact_type=ApplicationFormFillArtifact.ArtifactType.FILLED_TEMPLATE,
).exists()
def test_create_word_export_sanitizes_product_name_newlines(settings, tmp_path, django_user_model):
settings.MEDIA_ROOT = tmp_path
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-WORD-NL")
batch = ApplicationFormFillBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="AFF-WORD-NL",
product_name="原体核酸检测试剂盒(荧\n光PCR法",
work_dir=str(tmp_path / "aff" / "AFF-WORD-NL"),
)
template_path = tmp_path / "template.docx"
_template(template_path)
exported = create_word_export(
batch,
_spec(),
template_path,
{"product_name": MergedField("product_name", "产品名称", "原体核酸检测试剂盒", "说明书.txt", "证据", 0.8)},
)
assert "\n" not in exported.file_name
assert "\r" not in exported.file_name
assert Path(exported.storage_path).exists()

View File

@@ -11,6 +11,7 @@ from review_agent.models import (
Conversation,
FileAttachment,
FileSummaryBatch,
FileSummaryBatchAttachment,
Message,
WorkflowEvent,
WorkflowNodeRun,
@@ -270,3 +271,63 @@ def test_stream_message_auto_runs_summary_before_application_form_fill(
assert "汇总完成后继续自动填表" in joined
assert FileSummaryBatch.objects.filter(conversation=conversation, status=FileSummaryBatch.Status.SUCCESS).exists()
assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists()
def test_stream_message_reruns_summary_when_new_attachment_not_in_latest_batch(
monkeypatch, settings, tmp_path, django_user_model
):
settings.MEDIA_ROOT = tmp_path
settings.APPLICATION_FORM_FILL_ASYNC = False
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
old_path = tmp_path / "old.txt"
old_path.write_text("旧资料", encoding="utf-8")
old_attachment = FileAttachment.objects.create(
conversation=conversation,
user=user,
original_name="旧资料.txt",
storage_path=str(old_path),
file_size=old_path.stat().st_size,
is_active=True,
)
old_summary = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-OLD",
status=FileSummaryBatch.Status.SUCCESS,
)
FileSummaryBatchAttachment.objects.create(batch=old_summary, attachment=old_attachment)
new_path = tmp_path / "ifu.txt"
new_path.write_text("【产品名称】\n新型冠状病毒2019-nCoV核酸检测试剂盒荧光PCR法", encoding="utf-8")
FileAttachment.objects.create(
conversation=conversation,
user=user,
original_name="目标产品说明书.docx",
storage_path=str(new_path),
file_size=new_path.stat().st_size,
is_active=True,
)
monkeypatch.setattr(
"review_agent.services.route_message_intent",
lambda conversation, content: SkillRoute(
action="application_form_fill",
workflow_type="application_form_fill",
confidence=0.9,
),
)
def finish_summary(batch, async_run=True):
batch.status = FileSummaryBatch.Status.SUCCESS
batch.save(update_fields=["status"])
monkeypatch.setattr("review_agent.services.start_file_summary_workflow", finish_summary)
frames = list(stream_message(conversation, "请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"))
joined = "".join(frames)
assert '"workflow_type": "file_summary"' in joined
assert "汇总完成后继续自动填表" in joined
latest_summary = FileSummaryBatch.objects.order_by("-id").first()
form_batch = ApplicationFormFillBatch.objects.get(conversation=conversation)
assert latest_summary != old_summary
assert form_batch.source_summary_batch == latest_summary

View File

@@ -109,3 +109,38 @@ def test_attachment_reader_skill_returns_structured_details(settings, tmp_path,
assert result.success is True
assert result.data["attachments"][0]["filename"] == "readme.txt"
assert "请读取这个附件" in result.data["attachments"][0]["preview_text"]
def test_read_attachment_extracts_files_inside_rar(monkeypatch, settings, tmp_path, django_user_model):
from review_agent.file_summary.services.attachment_reader import read_attachment_details
settings.MEDIA_ROOT = tmp_path
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
archive_path = tmp_path / "uploads" / "第1章_监管信息.rar"
archive_path.parent.mkdir(parents=True)
archive_path.write_bytes(b"rar")
attachment = FileAttachment.objects.create(
conversation=conversation,
user=user,
original_name="第1章_监管信息.rar",
storage_path="uploads/第1章_监管信息.rar",
file_size=archive_path.stat().st_size,
)
def fake_extract_archive(path: Path, target_dir: Path):
extracted = target_dir / "说明书.txt"
extracted.write_text("产品名称:甲胎蛋白检测试剂盒", encoding="utf-8")
return [extracted]
monkeypatch.setattr(
"review_agent.file_summary.services.attachment_reader.extract_archive",
fake_extract_archive,
)
result = read_attachment_details(attachment)
assert result.status == "success"
assert result.file_type == "rar"
assert "说明书.txt" in result.sections[0]["name"]
assert "甲胎蛋白检测试剂盒" in result.preview_text

View File

@@ -0,0 +1,202 @@
import json
from django.utils import timezone
import pytest
from review_agent.models import FeishuAccessTokenCache
from review_agent.notifications.context import NotificationContext
from review_agent.notifications.feishu_message_api import send_personal_message
from review_agent.notifications.feishu_token import app_id_hash, get_tenant_access_token
from review_agent.notifications.message_builder import build_feishu_post_message
from review_agent.notifications.recipient import resolve_configured_personal_recipient
pytestmark = pytest.mark.django_db
class FakeResponse:
def __init__(self, payload, status_code=200):
self.payload = payload
self.status_code = status_code
self.text = json.dumps(payload, ensure_ascii=False)
def json(self):
return self.payload
def test_token_service_fetches_and_caches(monkeypatch, settings):
settings.FEISHU_APP_ID = "cli_a"
settings.FEISHU_APP_SECRET = "secret"
calls = []
def fake_post(*args, **kwargs):
calls.append(kwargs)
return FakeResponse({"code": 0, "tenant_access_token": "tenant-token", "expire": 7200})
monkeypatch.setattr("review_agent.notifications.feishu_token.httpx.post", fake_post)
first = get_tenant_access_token()
second = get_tenant_access_token()
assert first.ok
assert second.tenant_access_token == "tenant-token"
assert len(calls) == 1
assert FeishuAccessTokenCache.objects.get(app_id_hash=app_id_hash("cli_a")).is_valid()
def test_token_service_refreshes_expired_cache(monkeypatch, settings):
settings.FEISHU_APP_ID = "cli_a"
settings.FEISHU_APP_SECRET = "secret"
FeishuAccessTokenCache.objects.create(
app_id_hash=app_id_hash("cli_a"),
tenant_access_token="old",
expires_at=timezone.now() - timezone.timedelta(minutes=1),
)
monkeypatch.setattr(
"review_agent.notifications.feishu_token.httpx.post",
lambda *args, **kwargs: FakeResponse({"code": 0, "tenant_access_token": "new", "expire": 7200}),
)
assert get_tenant_access_token().tenant_access_token == "new"
def test_token_service_returns_error_for_api_failure(monkeypatch, settings):
settings.FEISHU_APP_ID = "cli_a"
settings.FEISHU_APP_SECRET = "secret"
monkeypatch.setattr(
"review_agent.notifications.feishu_token.httpx.post",
lambda *args, **kwargs: FakeResponse({"code": 1, "msg": "bad secret"}),
)
result = get_tenant_access_token()
assert not result.ok
assert result.error_message == "bad secret"
def test_recipient_prefers_open_id(settings):
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
settings.FEISHU_DEFAULT_USER_ID = "user_xxx"
settings.FEISHU_DEFAULT_TARGET_NAME = "负责人"
target = resolve_configured_personal_recipient()
assert target.ok
assert target.identifier_type == "open_id"
assert target.identifier_value == "ou_xxx"
def test_recipient_uses_user_id_when_open_id_missing(settings):
settings.FEISHU_DEFAULT_USER_OPEN_ID = ""
settings.FEISHU_DEFAULT_USER_ID = "user_xxx"
target = resolve_configured_personal_recipient()
assert target.ok
assert target.identifier_type == "user_id"
def test_recipient_missing(settings):
settings.FEISHU_DEFAULT_USER_OPEN_ID = ""
settings.FEISHU_DEFAULT_USER_ID = ""
target = resolve_configured_personal_recipient()
assert not target.ok
assert target.error_code == "recipient_missing"
def test_build_feishu_post_message_contains_summary(settings):
settings.PUBLIC_BASE_URL = "http://example.test"
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
target = resolve_configured_personal_recipient()
context = NotificationContext(
workflow_type="file_summary",
workflow_name="自动汇总",
workflow_batch_id=1,
workflow_batch_no="FS-001",
workflow_status="success",
trigger_user_id=1,
trigger_username="owner",
title="自动汇总完成",
summary_lines=("文件 3 个", "异常 0 个"),
next_step="查看汇总结果",
result_path="/summary/1/",
)
payload = build_feishu_post_message(context, target)
assert payload["receive_id"] == "ou_xxx"
content = json.loads(payload["content"])
assert content["zh_cn"]["title"] == "自动汇总完成"
assert "http://example.test/summary/1/" in payload["content"]
def test_send_personal_message_success(monkeypatch, settings):
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
requests = []
def fake_post(*args, **kwargs):
requests.append(kwargs)
return FakeResponse({"code": 0, "data": {"message_id": "om_xxx"}})
monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_post)
result = send_personal_message(
tenant_access_token="token",
receive_id_type="open_id",
payload={"receive_id": "ou_xxx"},
)
assert result.ok
assert result.external_message_id == "om_xxx"
assert requests[0]["headers"]["Authorization"] == "Bearer token"
def test_send_personal_message_api_error(monkeypatch, settings):
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
monkeypatch.setattr(
"review_agent.notifications.feishu_message_api.httpx.post",
lambda *args, **kwargs: FakeResponse({"code": 230001, "msg": "bad receive_id"}),
)
result = send_personal_message(
tenant_access_token="token",
receive_id_type="open_id",
payload={"receive_id": "bad"},
)
assert not result.ok
assert result.error_code == "230001"
def test_send_personal_message_refreshes_token_once(monkeypatch, settings):
settings.FEISHU_MESSAGE_API_URL = "http://feishu/messages"
calls = {"message": 0}
def fake_message_post(*args, **kwargs):
calls["message"] += 1
if calls["message"] == 1:
return FakeResponse({"code": 99991663, "msg": "token expired"})
return FakeResponse({"code": 0, "data": {"message_id": "om_retry"}})
monkeypatch.setattr("review_agent.notifications.feishu_message_api.httpx.post", fake_message_post)
monkeypatch.setattr(
"review_agent.notifications.feishu_message_api.get_tenant_access_token",
lambda force_refresh=False: type(
"TokenResult",
(),
{"ok": True, "tenant_access_token": "fresh", "error_code": "", "error_message": ""},
)(),
)
result = send_personal_message(
tenant_access_token="stale",
receive_id_type="open_id",
payload={"receive_id": "ou_xxx"},
)
assert result.ok
assert result.refreshed_token
assert calls["message"] == 2

View File

@@ -0,0 +1,39 @@
from dataclasses import dataclass
from io import StringIO
from django.core.management import call_command
from django.core.management.base import CommandError
import pytest
pytestmark = pytest.mark.django_db
@dataclass(frozen=True)
class FakeRecord:
send_status: str = "success"
target: str = "负责人"
error_message: str = ""
def test_send_test_feishu_notification_calls_dispatcher(monkeypatch, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
calls = []
monkeypatch.setattr(
"review_agent.management.commands.send_test_feishu_notification.dispatch_workflow_notification",
lambda context: calls.append(context) or FakeRecord(),
)
output = StringIO()
call_command("send_test_feishu_notification", "--username", user.username, stdout=output)
assert calls
assert calls[0].workflow_type == "manual_test"
assert calls[0].trigger_user_id == user.pk
assert "send_status=success" in output.getvalue()
assert "target=负责人" in output.getvalue()
def test_send_test_feishu_notification_missing_user_raises():
with pytest.raises(CommandError):
call_command("send_test_feishu_notification", "--username", "missing")

104
tests/test_feishu_models.py Normal file
View File

@@ -0,0 +1,104 @@
from django.utils import timezone
import pytest
from review_agent.models import (
Conversation,
FeishuAccessTokenCache,
FeishuQuestionLog,
FeishuUserMapping,
FileSummaryBatch,
WorkflowNotificationRecord,
)
pytestmark = pytest.mark.django_db
def test_feishu_user_mapping_preferred_identifier(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
mapping = FeishuUserMapping.objects.create(
system_user=user,
feishu_display_name="负责人",
feishu_open_id="ou_open",
feishu_user_id="user_id",
feishu_mobile="13800000000",
)
assert mapping.preferred_identifier() == ("open_id", "ou_open")
mapping.feishu_open_id = ""
assert mapping.preferred_identifier() == ("user_id", "user_id")
mapping.feishu_user_id = ""
assert mapping.preferred_identifier() == ("mobile", "13800000000")
def test_feishu_access_token_cache_expiry():
now = timezone.now()
cache = FeishuAccessTokenCache.objects.create(
app_id_hash="hash",
tenant_access_token="token",
expires_at=now + timezone.timedelta(minutes=5),
)
assert cache.is_valid(now=now)
cache.expires_at = now - timezone.timedelta(seconds=1)
assert not cache.is_valid(now=now)
def test_workflow_notification_success_dedupe_only_success(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="飞书")
batch = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-FEISHU",
status=FileSummaryBatch.Status.SUCCESS,
)
dedupe_key = WorkflowNotificationRecord.build_dedupe_key("file_summary", batch.pk, "success")
WorkflowNotificationRecord.objects.create(
workflow_type="file_summary",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status="success",
dedupe_key=dedupe_key,
trigger_user=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
message_title="失败通知",
)
assert not WorkflowNotificationRecord.already_successfully_sent(dedupe_key)
WorkflowNotificationRecord.objects.create(
workflow_type="file_summary",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status="success",
dedupe_key=dedupe_key,
trigger_user=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
message_title="成功通知",
)
assert WorkflowNotificationRecord.already_successfully_sent(dedupe_key)
def test_feishu_question_log_records_summary_without_full_answer(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
log = FeishuQuestionLog.objects.create(
system_user=user,
source_type=FeishuQuestionLog.SourceType.SIMULATE,
question_text="查最新法规核查",
intent="batch_status",
query_object={"workflow_type": "regulatory_review", "latest": True},
answer_summary="RR-001 成功,阻断项 0高风险 1。",
permission_result="allowed",
status=FeishuQuestionLog.Status.SUCCESS,
processed_at=timezone.now(),
)
assert "完整回答" not in log.answer_summary
assert log.query_object["latest"] is True

View File

@@ -0,0 +1,160 @@
from dataclasses import dataclass
import pytest
from review_agent.models import Conversation, FileSummaryBatch, WorkflowNotificationRecord
from review_agent.notifications.context import NotificationContext
from review_agent.notifications.dispatcher import dispatch_workflow_notification
pytestmark = pytest.mark.django_db
@dataclass(frozen=True)
class FakeTokenResult:
ok: bool
tenant_access_token: str = ""
error_code: str = ""
error_message: str = ""
@dataclass(frozen=True)
class FakeSendResult:
ok: bool
external_message_id: str = ""
error_code: str = ""
error_message: str = ""
request_duration_ms: int | None = None
def _context(user, batch):
return NotificationContext(
workflow_type="file_summary",
workflow_name="自动汇总",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=batch.status,
trigger_user_id=user.pk,
trigger_username=user.username,
title="自动汇总完成",
summary_lines=("文件 1 个",),
next_step="查看汇总",
result_path=f"/file-summary/{batch.pk}/",
)
def _batch(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="飞书")
batch = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-DISPATCH",
status=FileSummaryBatch.Status.SUCCESS,
)
return user, batch
def test_dispatch_disabled_writes_record_without_api_call(django_user_model, settings, monkeypatch):
user, batch = _batch(django_user_model)
settings.FEISHU_NOTIFY_ENABLED = False
def fail_call(*args, **kwargs):
raise AssertionError("should not call external service")
monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call)
record = dispatch_workflow_notification(_context(user, batch))
assert record.send_status == WorkflowNotificationRecord.SendStatus.DISABLED
assert record.channel == WorkflowNotificationRecord.Channel.DISABLED
def test_dispatch_success_writes_success_record(django_user_model, settings, monkeypatch):
user, batch = _batch(django_user_model)
settings.FEISHU_NOTIFY_ENABLED = True
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
monkeypatch.setattr(
"review_agent.notifications.dispatcher.get_tenant_access_token",
lambda: FakeTokenResult(ok=True, tenant_access_token="token"),
)
monkeypatch.setattr(
"review_agent.notifications.dispatcher.send_personal_message",
lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_xxx", request_duration_ms=12),
)
record = dispatch_workflow_notification(_context(user, batch))
assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS
assert record.external_message_id == "om_xxx"
assert record.sent_at is not None
def test_dispatch_existing_success_skips_api(django_user_model, settings, monkeypatch):
user, batch = _batch(django_user_model)
settings.FEISHU_NOTIFY_ENABLED = True
context = _context(user, batch)
existing = 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=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
message_title=context.title,
)
def fail_call(*args, **kwargs):
raise AssertionError("duplicate should not call API")
monkeypatch.setattr("review_agent.notifications.dispatcher.send_personal_message", fail_call)
assert dispatch_workflow_notification(context).pk == existing.pk
def test_dispatch_existing_failed_allows_retry(django_user_model, settings, monkeypatch):
user, batch = _batch(django_user_model)
settings.FEISHU_NOTIFY_ENABLED = True
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
context = _context(user, batch)
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=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
message_title=context.title,
)
monkeypatch.setattr(
"review_agent.notifications.dispatcher.get_tenant_access_token",
lambda: FakeTokenResult(ok=True, tenant_access_token="token"),
)
monkeypatch.setattr(
"review_agent.notifications.dispatcher.send_personal_message",
lambda **kwargs: FakeSendResult(ok=True, external_message_id="om_retry"),
)
record = dispatch_workflow_notification(context)
assert record.send_status == WorkflowNotificationRecord.SendStatus.SUCCESS
assert WorkflowNotificationRecord.objects.filter(dedupe_key=context.dedupe_key).count() == 2
def test_dispatch_token_failure_writes_failed(django_user_model, settings, monkeypatch):
user, batch = _batch(django_user_model)
settings.FEISHU_NOTIFY_ENABLED = True
settings.FEISHU_DEFAULT_USER_OPEN_ID = "ou_xxx"
monkeypatch.setattr(
"review_agent.notifications.dispatcher.get_tenant_access_token",
lambda: FakeTokenResult(ok=False, error_code="token_error", error_message="bad secret"),
)
record = dispatch_workflow_notification(_context(user, batch))
assert record.send_status == WorkflowNotificationRecord.SendStatus.FAILED
assert record.error_code == "token_error"

View File

@@ -0,0 +1,92 @@
from io import StringIO
from django.core.management import call_command
import pytest
from review_agent.feishu_questions.intent import parse_question_intent
from review_agent.feishu_questions.query import query_batch_summary
from review_agent.feishu_questions.service import answer_question
from review_agent.models import Conversation, FeishuQuestionLog, FileSummaryBatch, RegulatoryReviewBatch
pytestmark = pytest.mark.django_db
def test_query_latest_regulatory_batch_for_owner(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-001")
RegulatoryReviewBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="RR-001",
status=RegulatoryReviewBatch.Status.SUCCESS,
risk_summary={"blocking": 0, "high": 1},
)
result = query_batch_summary(user, workflow_type="regulatory_review", latest=True)
assert result["ok"]
assert result["batch_no"] == "RR-001"
assert "高风险 1" in result["answer_summary"]
def test_query_denies_other_users_batch(django_user_model):
owner = django_user_model.objects.create_user(username="owner", password="pass")
other = django_user_model.objects.create_user(username="other", password="pass")
conversation = Conversation.objects.create(user=owner, title="会话")
batch = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-PRIVATE")
result = query_batch_summary(other, batch_no=batch.batch_no)
assert not result["ok"]
assert result["permission_result"] == "denied"
def test_query_admin_can_access_other_users_batch(django_user_model):
owner = django_user_model.objects.create_user(username="owner", password="pass")
admin = django_user_model.objects.create_user(username="admin", password="pass", is_staff=True)
conversation = Conversation.objects.create(user=owner, title="会话")
FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-ADMIN")
result = query_batch_summary(admin, batch_no="FS-ADMIN")
assert result["ok"]
assert result["permission_result"] == "allowed"
def test_parse_question_intent_recognizes_batch_latest_and_workflow():
parsed = parse_question_intent("查最新法规核查")
assert parsed["workflow_type"] == "regulatory_review"
assert parsed["latest"] is True
parsed = parse_question_intent("AFF-20260607-001 的 Word 在哪里")
assert parsed["workflow_type"] == "application_form_fill"
assert parsed["batch_no"] == "AFF-20260607-001"
assert parsed["intent"] == "export_summary"
def test_answer_question_records_log_without_full_answer(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-LOG")
result = answer_question(user, "查最新自动汇总")
log = FeishuQuestionLog.objects.get(pk=result["log_id"])
assert log.intent == "batch_status"
assert log.query_object["workflow_type"] == "file_summary"
assert log.answer_summary
assert len(log.answer_summary) <= 500
def test_feishu_question_simulate_command_outputs_summary(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-CMD")
output = StringIO()
call_command("feishu_question_simulate", "--username", user.username, "查最新自动汇总", stdout=output)
assert "FS-CMD" in output.getvalue()

View File

@@ -0,0 +1,96 @@
import pytest
from review_agent.models import (
ApplicationFormFillBatch,
Conversation,
ExportedSummaryFile,
FileSummaryBatch,
RegulatoryIssue,
RegulatoryReviewBatch,
)
from review_agent.notifications.message_builder import absolute_result_url
from review_agent.notifications.workflow_adapters import (
build_application_form_fill_context,
build_file_summary_context,
build_regulatory_review_context,
)
pytestmark = pytest.mark.django_db
def test_file_summary_adapter_builds_summary(settings, django_user_model):
settings.PUBLIC_BASE_URL = "http://example.test"
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
batch = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-001",
status=FileSummaryBatch.Status.SUCCESS,
total_files=3,
success_files=2,
failed_files=1,
total_pages=15,
)
context = build_file_summary_context(batch)
assert context.workflow_type == "file_summary"
assert context.workflow_batch_no == "FS-001"
assert "异常" in "\n".join(context.summary_lines)
assert absolute_result_url(context.result_path).endswith(f"/api/review-agent/file-summary/{batch.pk}/status/")
def test_regulatory_review_adapter_builds_risk_summary(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary_batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR")
batch = RegulatoryReviewBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary_batch,
batch_no="RR-001",
status=RegulatoryReviewBatch.Status.SUCCESS,
)
RegulatoryIssue.objects.create(
batch=batch,
category=RegulatoryIssue.Category.COMPLETENESS,
severity=RegulatoryIssue.Severity.BLOCKING,
title="缺少资料",
)
context = build_regulatory_review_context(batch)
assert context.workflow_type == "regulatory_review"
assert "阻断项 1" in "\n".join(context.summary_lines)
def test_application_form_fill_adapter_builds_export_and_conflict_summary(django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary_batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF")
batch = ApplicationFormFillBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary_batch,
batch_no="AFF-001",
status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
selected_templates=["registration_certificate"],
conflict_summary=[{"field": "product_name"}],
)
ExportedSummaryFile.objects.create(
batch=summary_batch,
workflow_type="application_form_fill",
workflow_batch_id=batch.pk,
export_category="filled_template",
export_type=ExportedSummaryFile.ExportType.WORD,
file_name="filled.docx",
storage_path="filled.docx",
)
context = build_application_form_fill_context(batch)
assert context.workflow_type == "application_form_fill"
assert "导出文件 1" in "\n".join(context.summary_lines)
assert "冲突字段 1" in "\n".join(context.summary_lines)

View File

@@ -1,7 +1,7 @@
import pytest
from django.urls import reverse
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun
from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord
pytestmark = pytest.mark.django_db
@@ -223,6 +223,31 @@ def test_frontend_renders_workflow_error_messages():
assert ".workflow-error" in css
def test_file_summary_status_includes_feishu_notification(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FEISHU")
WorkflowNotificationRecord.objects.create(
workflow_type="file_summary",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=batch.status,
dedupe_key=f"file_summary:{batch.pk}:{batch.status}",
trigger_user=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
target="负责人",
send_status=WorkflowNotificationRecord.SendStatus.SUCCESS,
message_title="自动汇总完成",
)
client.force_login(user)
response = client.get(f"/api/review-agent/file-summary/{batch.pk}/status/")
payload = response.json()
assert payload["latest_notification"]["status_label"] == "飞书通知已发送"
assert payload["notifications"][0]["target"] == "负责人"
def test_frontend_renders_workflow_batches_as_carousel():
script = open("static/js/app.js", encoding="utf-8").read()
css = open("static/css/login.css", encoding="utf-8").read()
@@ -233,3 +258,25 @@ def test_frontend_renders_workflow_batches_as_carousel():
assert "workflow-batch-carousel" in script
assert ".workflow-batch-controls" in css
assert ".workflow-card.active" in css
def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
client.force_login(user)
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
content = response.content.decode("utf-8")
script = open("static/js/app.js", encoding="utf-8").read()
assert "目录自动汇总" in content
assert "法规核查与风险预警" in content
assert "申报文件填表" in content
assert "说明书审查" not in content
assert ">风险预警</button>" not in content
assert 'data-prompt-template="请对当前对话已上传的文件或压缩包自动汇总文件目录' in content
assert 'data-prompt-template="请对当前对话最近成功汇总的注册资料发起 NMPA 法规核查与风险预警' in content
assert 'data-prompt-template="请基于当前对话最近成功汇总的产品资料,自动提取产品关键信息并填入申报文件模板"' in content
assert "优先生成注册证 Word 和字段来源追溯清单" not in content
assert "bindPromptTemplateButtons" in script
assert "promptInput.value = template" in script

View File

@@ -71,6 +71,31 @@ def test_start_file_summary_workflow_runs_synchronously_for_tests(django_user_mo
assert WorkflowEvent.objects.filter(batch=batch, event_type="workflow_completed").exists()
def test_file_summary_workflow_dispatches_completion_notification(monkeypatch, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
FileAttachment.objects.create(
conversation=conversation,
user=user,
original_name="a.docx",
storage_path="x/a.docx",
file_size=1,
)
batch = create_file_summary_batch(conversation=conversation, user=user)
calls = []
def fake_dispatch(context):
calls.append(context)
monkeypatch.setattr("review_agent.file_summary.workflow.dispatch_workflow_notification", fake_dispatch)
start_file_summary_workflow(batch, async_run=False)
assert calls
assert calls[-1].workflow_type == "file_summary"
assert calls[-1].workflow_batch_id == batch.pk
def test_workflow_extracts_archive_and_scans_extracted_files(settings, tmp_path, django_user_model):
settings.MEDIA_ROOT = tmp_path
user = django_user_model.objects.create_user(username="owner", password="pass")

View File

@@ -8,6 +8,7 @@ from review_agent.models import (
RegulatoryArtifact,
RegulatoryNotificationRecord,
RegulatoryReviewBatch,
WorkflowNotificationRecord,
WorkflowNodeRun,
)
@@ -230,3 +231,35 @@ def test_frontend_keeps_single_condition_confirmation_prompt():
assert "data-condition-confirmation-card" in script
assert "removeStaleConditionConfirmationCards" in script
assert '[data-condition-confirmation-card]' in script
def test_regulatory_status_includes_failed_feishu_notification(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR")
batch = RegulatoryReviewBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="RR-FEISHU",
)
WorkflowNotificationRecord.objects.create(
workflow_type="regulatory_review",
workflow_batch_id=batch.pk,
workflow_batch_no=batch.batch_no,
workflow_status=batch.status,
dedupe_key=f"regulatory_review:{batch.pk}:{batch.status}",
trigger_user=user,
channel=WorkflowNotificationRecord.Channel.FEISHU_API,
target="负责人",
send_status=WorkflowNotificationRecord.SendStatus.FAILED,
message_title="法规核查完成",
error_message="bad receive_id",
)
client.force_login(user)
response = client.get(f"/api/review-agent/regulatory-review/{batch.pk}/status/")
payload = response.json()
assert payload["latest_notification"]["status_label"] == "飞书通知失败"
assert payload["latest_notification"]["error_message"] == "bad receive_id"

View File

@@ -9,6 +9,7 @@ from review_agent.models import (
)
from review_agent.regulatory_review.services.export import build_markdown_report, build_result_payload
from review_agent.regulatory_review.services.feishu_notifier import create_mock_notifications
from review_agent.regulatory_review.workflow import RegulatoryWorkflowExecutor
pytestmark = pytest.mark.django_db
@@ -77,3 +78,32 @@ def test_notification_records_enter_reports(django_user_model):
assert "通知记录" in build_markdown_report(batch)
assert build_result_payload(batch)["notifications"][0]["channel"] == "mock"
def test_regulatory_completion_notification_uses_dispatcher(monkeypatch, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话")
summary = FileSummaryBatch.objects.create(
conversation=conversation,
user=user,
batch_no="FS-NOTIFY-DISPATCH",
status=FileSummaryBatch.Status.SUCCESS,
)
batch = RegulatoryReviewBatch.objects.create(
conversation=conversation,
user=user,
source_summary_batch=summary,
batch_no="RR-NOTIFY-DISPATCH",
status=RegulatoryReviewBatch.Status.SUCCESS,
)
calls = []
monkeypatch.setattr(
"review_agent.regulatory_review.workflow.dispatch_workflow_notification",
lambda context: calls.append(context),
)
RegulatoryWorkflowExecutor(batch)._dispatch_completion_notification()
assert calls
assert calls[0].workflow_type == "regulatory_review"

View File

@@ -37,3 +37,25 @@ def test_extract_text_reports_unsupported_file(tmp_path):
assert result.status == "unsupported"
assert result.text == ""
def test_extract_text_from_docx_preserves_table_text(tmp_path):
from docx import Document
path = tmp_path / "说明书.docx"
document = Document()
document.add_paragraph("【主要组成成分】")
table = document.add_table(rows=2, cols=2)
table.rows[0].cells[0].text = "组分"
table.rows[0].cells[1].text = "数量"
table.rows[1].cells[0].text = "PCR反应液"
table.rows[1].cells[1].text = "1管"
document.add_paragraph("【储存条件及有效期】")
document.add_paragraph("-20±5℃保存有效期12个月。")
document.save(path)
result = extract_text(path)
assert result.status == "success"
assert "组分\t数量" in result.text
assert result.text.index("PCR反应液") < result.text.index("【储存条件及有效期】")