Compare commits
7 Commits
e701b4502e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5f0a968b | |||
| f2c1e3cfa1 | |||
| 5a6e7698e4 | |||
| fecaee0b03 | |||
| efb06519d8 | |||
|
|
1d8a526770 | ||
| 5aa7b5f3d0 |
21
.env.siliconflow.example
Normal file
21
.env.siliconflow.example
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||||
|
DJANGO_DEBUG=true
|
||||||
|
DJANGO_ALLOWED_HOSTS=*
|
||||||
|
|
||||||
|
# SiliconFlow OpenAI-compatible API
|
||||||
|
# Fill these two keys manually before demo.
|
||||||
|
LLM_PROVIDER=openai_compatible
|
||||||
|
LLM_API_KEY=your_siliconflow_api_key
|
||||||
|
LLM_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
|
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
||||||
|
|
||||||
|
# SiliconFlow embedding model for RAG.
|
||||||
|
# You can reuse the same SiliconFlow key here.
|
||||||
|
EMBEDDING_API_KEY=your_siliconflow_api_key
|
||||||
|
EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1
|
||||||
|
EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
|
||||||
|
SCENARIO_CONFIG_DIR=configs
|
||||||
|
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
|
||||||
|
UPLOAD_ROOT=data/uploads
|
||||||
|
CHROMA_PATH=data/chroma
|
||||||
111
AGENTS.md
111
AGENTS.md
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格和实现顺序。
|
本文档约定本项目后续由人或编码 Agent 协作开发时需要遵守的边界、风格、实现顺序和文档同步要求。
|
||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
优先目标:
|
优先目标:
|
||||||
|
|
||||||
- 围绕 NMPA 体外诊断试剂注册申报资料场景完成可演示闭环。
|
- 围绕 NMPA 体外诊断试剂注册申报资料场景完成可演示闭环。
|
||||||
- 保证本地可运行。
|
- 保证本地可运行、可测试、可讲解。
|
||||||
- 保证代码结构清楚,方便讲解。
|
- 保证代码结构清楚,业务流程能从页面、服务层、Agent Core 和审计日志串起来。
|
||||||
- 允许在保留主架构边界前提下进行大幅度业务重构。
|
- 允许在保留主架构边界前提下进行大幅度业务重构。
|
||||||
|
|
||||||
## 架构原则
|
## 架构原则
|
||||||
@@ -27,59 +27,68 @@ Django 单体 + 独立 Agent Core + Docker Compose
|
|||||||
|
|
||||||
核心边界:
|
核心边界:
|
||||||
|
|
||||||
- Django 负责页面、数据库、文件上传、审计日志和后台管理。
|
- Django 负责页面、数据库、文件上传、导出文件、审计日志、通知留痕和后台管理。
|
||||||
- Agent Core 负责 RAG、Prompt、工具调用、模型适配和结构化输出。
|
- Agent Core 负责 RAG、Prompt、工具调用、治理配置、模型适配和结构化输出。
|
||||||
- Django View 不直接写大模型调用、向量检索和工具执行细节。
|
- Django View 不直接写大模型调用、向量检索和工具执行细节。
|
||||||
- Agent Core 不依赖 Django View。
|
- Agent Core 不依赖 Django View。
|
||||||
|
- 业务流程优先放在各模块 `services.py`,View 只负责请求处理、消息提示和模板渲染。
|
||||||
|
|
||||||
## 模块边界
|
## 模块边界
|
||||||
|
|
||||||
### config
|
### config
|
||||||
|
|
||||||
负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径和部署配置。
|
负责 Django 项目配置、URL 总入口、环境变量、静态资源、上传路径、Chroma 路径和部署配置。
|
||||||
|
|
||||||
### apps.scenarios
|
### apps.scenarios
|
||||||
|
|
||||||
负责注册审核任务列表、任务配置读取、任务元信息展示。
|
负责注册审核任务配置读取、场景元信息展示和非法 YAML 配置容错。
|
||||||
|
|
||||||
### apps.documents
|
### apps.documents
|
||||||
|
|
||||||
负责注册资料上传、文件记录、章节点归类、页数与文本处理状态和触发 RAG 入库。
|
负责资料包导入、上传文件记录、压缩包展开、文本抽取、章节点归类、页数统计、资料包搜索、异常提示、触发 RAG 入库和导出记录维护。
|
||||||
|
|
||||||
### apps.chat
|
### apps.chat
|
||||||
|
|
||||||
负责审核工作台、用户输入表单、调用 Agent Core 和展示结构化审核结果。
|
负责审核工作台、会话列表、用户输入表单、文档范围选择、调用 Agent Core、展示结构化审核结果、补传资料和触发 Word 导出。
|
||||||
|
|
||||||
### apps.audit
|
### apps.audit
|
||||||
|
|
||||||
负责审计日志模型、日志写入服务、日志列表和详情页,以及审核留痕展示。
|
负责审计日志模型、日志写入服务、通知留痕、处理历史列表和详情页,以及审核留痕展示。
|
||||||
|
|
||||||
|
### apps.platform_ui
|
||||||
|
|
||||||
|
负责知识库治理台、MCP 中心、Skill 工作室和审核指挥台等演示型平台页面。该模块可以展示治理对象和 mock 业务态势,但不要把主业务执行逻辑写进这里。
|
||||||
|
|
||||||
### agent_core
|
### agent_core
|
||||||
|
|
||||||
负责注册审核 Agent 编排、RAG、工具注册、规则执行、LLM Provider 和结构化输出。
|
负责注册审核 Agent 编排、RAG、工具注册、治理配置读取、LLM / Embedding Provider 和结构化输出。
|
||||||
|
|
||||||
## 开发顺序
|
## 当前实现状态
|
||||||
|
|
||||||
建议按以下顺序推进:
|
- Django 单体骨架已完成,根路径 `/` 默认进入审核智能体。
|
||||||
|
- 当前主入口为 `审核智能体 / 资料包 / 知识库治理台 / 处理历史`,底层场景列表保留在 `/scenarios/`。
|
||||||
1. 创建 Django 项目骨架。
|
- 通用场景 YAML、Chat、Documents、Audit、Platform UI 和 Agent Core 已具备可重构基础。
|
||||||
2. 完成 Config 模块。
|
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR。
|
||||||
3. 完成 Scenarios 模块,先展示 5 个场景。
|
- 资料包会自动绑定会话,标题优先使用解析出的产品名称。
|
||||||
4. 完成 Agent Core 最小闭环,先返回模拟结果。
|
- 审核智能体允许在未上传资料包时直接发起知识库问答,会话保持未绑定资料包状态并走 RAG 检索链路。
|
||||||
5. 完成 Chat 页面,打通对话链路。
|
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册、RAG fallback / Chroma 双路径和 OpenAI 兼容 Provider。
|
||||||
6. 完成 Audit 模块,记录每次对话。
|
- Word 导出已支持生成最小 `.docx`,并按风险状态形成正式版或草稿版。
|
||||||
7. 完成 Documents 模块,支持上传文件。
|
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。
|
||||||
8. 完成 RAG 入库和检索。
|
|
||||||
9. 完成内置工具系统。
|
|
||||||
10. 补 Docker Compose 一键启动。
|
|
||||||
|
|
||||||
当前仓库状态说明:
|
|
||||||
|
|
||||||
- Django 单体骨架已完成。
|
|
||||||
- 通用场景 YAML、Chat、Documents、Audit 和 Agent Core 已具备可重构基础。
|
|
||||||
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册和 RAG fallback / Chroma 双路径。
|
|
||||||
- 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。
|
- 全量测试已覆盖主要模块行为,并默认隔离真实 LLM 网络调用。
|
||||||
- 当前需求文档已按真实笔试题重写到 `docs/需求分析/`。
|
- 当前需求文档已按真实笔试题重写到 `docs/需求分析/`。
|
||||||
|
- 当前详细设计文档放在 `docs/详细设计/`,原型资料放在 `docs/原型设计/`。
|
||||||
|
|
||||||
|
## 推荐开发顺序
|
||||||
|
|
||||||
|
后续新增或重构功能时,建议按以下顺序推进:
|
||||||
|
|
||||||
|
1. 先确认需求文档、详细设计或当前页面是否需要同步调整。
|
||||||
|
2. 补或调整服务层测试、Agent Core 测试或页面关键展示测试。
|
||||||
|
3. 在对应模块的 `services.py` 或 `agent_core` 中实现核心逻辑。
|
||||||
|
4. View 只接入服务层结果,模板只做直接展示。
|
||||||
|
5. 若涉及用户可见入口,同步更新模板、README 和相关需求/设计文档。
|
||||||
|
6. 运行相关测试,再运行核心回归验证。
|
||||||
|
7. 按逻辑分组使用 Conventional Commit 风格提交到本地。
|
||||||
|
|
||||||
## 编码约定
|
## 编码约定
|
||||||
|
|
||||||
@@ -89,6 +98,7 @@ Django 单体 + 独立 Agent Core + Docker Compose
|
|||||||
- 配置化优先,业务场景不要写死在代码中。
|
- 配置化优先,业务场景不要写死在代码中。
|
||||||
- 工具函数必须通过 Tool Registry 注册。
|
- 工具函数必须通过 Tool Registry 注册。
|
||||||
- 模型调用必须通过 LLM Provider,不允许散落在业务代码中。
|
- 模型调用必须通过 LLM Provider,不允许散落在业务代码中。
|
||||||
|
- RAG 入库、检索和 Embedding 逻辑必须留在 Agent Core 或 Documents 服务边界内。
|
||||||
- 审计日志要记录成功和失败两种情况。
|
- 审计日志要记录成功和失败两种情况。
|
||||||
- 不在日志中保存 API Key、密钥或敏感环境变量。
|
- 不在日志中保存 API Key、密钥或敏感环境变量。
|
||||||
- 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。
|
- 新增或重构模块时,优先补清晰的中文注释,说明职责边界、输入输出和设计取舍。
|
||||||
@@ -99,20 +109,20 @@ Django 单体 + 独立 Agent Core + Docker Compose
|
|||||||
|
|
||||||
需求文档放在:
|
需求文档放在:
|
||||||
|
|
||||||
```text
|
|
||||||
docs/
|
|
||||||
```
|
|
||||||
|
|
||||||
需求分析文档放在:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/需求分析/
|
docs/需求分析/
|
||||||
```
|
```
|
||||||
|
|
||||||
设计文档放在:
|
详细设计文档放在:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/设计文档/
|
docs/详细设计/
|
||||||
|
```
|
||||||
|
|
||||||
|
原型设计文档放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/原型设计/
|
||||||
```
|
```
|
||||||
|
|
||||||
场景配置放在:
|
场景配置放在:
|
||||||
@@ -126,26 +136,28 @@ configs/
|
|||||||
- `README.md`
|
- `README.md`
|
||||||
- `docs/需求分析/1.V1总需求文档.md`
|
- `docs/需求分析/1.V1总需求文档.md`
|
||||||
- 相关模块需求文档
|
- 相关模块需求文档
|
||||||
|
- 相关详细设计文档
|
||||||
- `AGENTS.md` 中的协作边界与当前实现状态
|
- `AGENTS.md` 中的协作边界与当前实现状态
|
||||||
|
|
||||||
推荐同步文档的场景:
|
推荐同步文档的场景:
|
||||||
|
|
||||||
- 新增用户可见页面或流程。
|
- 新增用户可见页面或流程。
|
||||||
- 调整环境变量、生效方式或部署命令。
|
- 调整根路径、URL、环境变量、生效方式或部署命令。
|
||||||
- 修改 Agent Core 的输入输出合约。
|
- 修改资料包、会话、审计、通知或导出模型字段。
|
||||||
- 新增工具、审计字段或场景配置字段。
|
- 修改 Agent Core 的输入输出合约、结构化输出类型或节点状态口径。
|
||||||
|
- 新增工具、治理配置字段、场景配置字段或模板映射字段。
|
||||||
|
- 改变测试隔离策略、真实模型调用策略或 Docker 启动方式。
|
||||||
|
|
||||||
## 测试与验证约定
|
## 测试与验证约定
|
||||||
|
|
||||||
每个阶段至少验证:
|
每个阶段至少验证:
|
||||||
|
|
||||||
- Django 可以启动。
|
- Django 可以启动或 `python manage.py check` 通过。
|
||||||
- 首页可以访问。
|
- 根路径和审核智能体页面可以访问。
|
||||||
- 场景列表可显示。
|
- 资料包导入流程可执行。
|
||||||
- 对话流程可执行。
|
- 对话流程可执行,出错时页面有清晰提示。
|
||||||
- 出错时页面有清晰提示。
|
- 审计日志能记录成功和失败。
|
||||||
- 审计日志能记录。
|
- Docker Compose 配置有效。
|
||||||
- Docker Compose 可以启动。
|
|
||||||
|
|
||||||
当前默认验证命令:
|
当前默认验证命令:
|
||||||
|
|
||||||
@@ -159,6 +171,8 @@ docker compose config
|
|||||||
|
|
||||||
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
|
- 若本地 `.env` 存在真实模型密钥,测试仍应保持可离线执行。
|
||||||
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
|
- 每完成一项功能或一轮重构后,应先跑相关测试,再跑全量测试或核心回归测试。
|
||||||
|
- 涉及页面结构时,至少补或更新对应页面测试。
|
||||||
|
- 涉及导出文件时,需要验证导出记录和下载路径。
|
||||||
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
|
- 完成改动后,按逻辑分组使用 Conventional Commit 风格提交到本地。
|
||||||
|
|
||||||
## 不优先做的事项
|
## 不优先做的事项
|
||||||
@@ -172,5 +186,6 @@ docker compose config
|
|||||||
- 深度 Dify 集成。
|
- 深度 Dify 集成。
|
||||||
- 微服务拆分。
|
- 微服务拆分。
|
||||||
- 分布式任务队列。
|
- 分布式任务队列。
|
||||||
|
- 真实飞书发送链路。
|
||||||
|
|
||||||
这些内容可以作为后续增强,不应影响 V1 快速成型。
|
这些内容可以作为后续增强,不应影响 V1 快速成型。
|
||||||
|
|||||||
270
README.md
270
README.md
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
用于复试展示的体外诊断试剂注册申报资料准备与审核系统。
|
用于复试展示的体外诊断试剂注册申报资料准备与审核系统。
|
||||||
|
|
||||||
当前项目已根据真实笔试题重构目标定位,重点服务于 NMPA 境内第三类体外诊断试剂注册申报场景,覆盖资料整理、目录汇总、法规完整性检查、关键信息抽取、跨文档一致性核查、风险预警和审计留痕。
|
项目已按真实笔试题收口为 NMPA 境内第三类体外诊断试剂注册申报资料场景,重点演示“资料包导入 -> 审核智能体执行 -> 结构化结果 -> Word 导出 -> 通知与审计留痕”的本地闭环。
|
||||||
|
|
||||||
## 核心理念
|
## 核心理念
|
||||||
|
|
||||||
```text
|
```text
|
||||||
注册审核 Agent = 任务配置 + 资料库 + 法规规则 + 工具集 + 输出模板 + 审计日志 + 模型适配器
|
注册审核 Agent = 任务配置 + 资料包 + 法规/业务知识库 + 工具集 + 输出模板 + 审计日志 + 模型适配器
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术路线
|
## 技术路线
|
||||||
@@ -17,42 +17,40 @@ V1 采用:
|
|||||||
- Django 单体应用
|
- Django 单体应用
|
||||||
- 独立 Agent Core 模块
|
- 独立 Agent Core 模块
|
||||||
- SQLite
|
- SQLite
|
||||||
- Chroma
|
- Chroma / fallback 检索双路径
|
||||||
- Django Templates
|
- Django Templates
|
||||||
- Docker Compose
|
- Docker Compose
|
||||||
- OpenAI API 兼容的 LLM 与 Embedding 接口
|
- OpenAI API 兼容的 LLM 与 Embedding 接口
|
||||||
|
|
||||||
默认不强依赖 Dify。系统预留 Adapter 设计,后续可以接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。
|
默认不强依赖 Dify。系统保留 Provider / Adapter 边界,后续可接入 Dify、OpenAI Agents SDK 或其他 Agent 编排平台。
|
||||||
|
|
||||||
## 当前业务主线
|
## 当前业务主线
|
||||||
|
|
||||||
当前系统围绕以下注册申报审核闭环展开:
|
1. 进入审核智能体后,可以先不上传资料,直接通过对话查询法规和业务知识库。
|
||||||
|
2. 导入注册资料包,支持单文件、多文件和压缩包。
|
||||||
|
3. 解析文件元数据、页数、章节点和产品名称。
|
||||||
|
4. 自动创建资料包批次,并绑定审核会话。
|
||||||
|
5. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告。
|
||||||
|
6. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析。
|
||||||
|
7. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口。
|
||||||
|
8. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史。
|
||||||
|
9. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕。
|
||||||
|
|
||||||
1. 导入注册资料包。
|
## 当前产品入口
|
||||||
2. 解析资料包并识别产品名称。
|
|
||||||
3. 以解析后的产品名称创建或绑定对话会话。
|
|
||||||
4. 汇总文件目录与页数。
|
|
||||||
5. 对照法规要求检查完整性。
|
|
||||||
6. 抽取产品关键信息。
|
|
||||||
7. 自动填入注册申报表格或对照清单。
|
|
||||||
8. 核查跨文档一致性。
|
|
||||||
9. 输出风险预警、处理建议和飞书通知。
|
|
||||||
|
|
||||||
## 当前产品形态
|
当前根路径 `/` 会直接进入审核智能体工作台,便于复试演示聚焦主链路。
|
||||||
|
|
||||||
当前原型和需求文档已经统一为 Agent 化产品形态,顶层入口固定为:
|
| 页面 | 路径 | 当前能力 |
|
||||||
|
|---|---|---|
|
||||||
1. `审核智能体`
|
| 审核智能体 | `/`、`/chat/`、`/chat/<conversation_id>/` | 无资料包知识库问答、会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 |
|
||||||
2. `资料包`
|
| 资料包 | `/documents/`、`/documents/upload/` | 导入资料包、搜索产品或批次、查看解析状态、异常提示、最近导出和处理链路 |
|
||||||
3. `知识库`
|
| 处理历史 | `/audit/`、`/audit/<log_id>/` | 按批次、产品、风险状态、通知状态回看执行快照、原始输出、导出摘要和通知回执 |
|
||||||
4. `处理历史`
|
| 知识库治理台 | `/platform/knowledge-base/` | 查看法规规则包、RAG 文档源、切片、字段 Schema、Word 模板、责任人映射和飞书配置 |
|
||||||
|
| MCP 中心演示页 | `/platform/mcp-center/` | 展示外部连接器治理视图 |
|
||||||
对应关系如下:
|
| Skill 工作室演示页 | `/platform/skills/` | 展示审核 Skill 编排和发布状态 |
|
||||||
|
| 审核指挥台 | `/platform/command-center-v2/` | 面向讲解的大屏式审核流程与风险状态视图 |
|
||||||
1. `审核智能体` 是主执行入口,承载对话、模板提问、节点跳转和结构化结果。
|
| 底层场景列表 | `/scenarios/` | 展示 YAML 场景配置和非法配置错误摘要 |
|
||||||
2. `资料包` 是主业务对象,资料包与会话绑定,对话标题采用解析后的产品名称,并支持按产品名称或批次号搜索。
|
| Django Admin | `/admin/` | 维护后台模型数据 |
|
||||||
3. `知识库` 负责法规资料、业务资料、RAG 切片、字段 Schema、模板映射和飞书配置治理。
|
|
||||||
4. `处理历史` 用于按批次回看历史任务、关联会话、风险状态和通知留痕。
|
|
||||||
|
|
||||||
## 模块划分
|
## 模块划分
|
||||||
|
|
||||||
@@ -62,19 +60,26 @@ apps.scenarios
|
|||||||
apps.documents
|
apps.documents
|
||||||
apps.chat
|
apps.chat
|
||||||
apps.audit
|
apps.audit
|
||||||
|
apps.platform_ui
|
||||||
agent_core
|
agent_core
|
||||||
```
|
```
|
||||||
|
|
||||||
职责边界:
|
职责边界:
|
||||||
|
|
||||||
- Django Apps 负责页面、数据、文件、日志等企业应用外壳。
|
- `config` 负责 Django 配置、路由入口、环境变量、静态资源和上传路径。
|
||||||
- Agent Core 负责 RAG、工具调用、模型适配、结构化输出和 Agent 编排。
|
- `apps.scenarios` 负责读取 YAML 场景配置,非法配置可被跳过并展示错误摘要。
|
||||||
- RAG、工具调用和模型调用不直接写进 Django View。
|
- `apps.documents` 负责资料包、上传文件、章节点识别、页数统计、文本抽取、RAG 入库触发和导出记录。
|
||||||
|
- `apps.chat` 负责审核工作台、会话绑定、用户输入、调用 Agent Core、补传资料和 Word 导出编排。
|
||||||
|
- `apps.audit` 负责审计日志、通知留痕、处理历史列表和详情回看。
|
||||||
|
- `apps.platform_ui` 负责知识库治理台、MCP 中心、Skill 工作室和指挥台等演示型治理页面。
|
||||||
|
- `agent_core` 负责 RAG、工具注册、治理配置、LLM Provider、Prompt 编排和结构化输出。
|
||||||
|
|
||||||
## 推荐项目结构
|
约束:RAG、工具调用和模型调用不直接写进 Django View;View 只做请求处理和页面渲染,复杂业务逻辑放到 `services.py` 或 `agent_core`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
universal-agent-demo/
|
DEMO-AGENT/
|
||||||
manage.py
|
manage.py
|
||||||
requirements.txt
|
requirements.txt
|
||||||
Dockerfile
|
Dockerfile
|
||||||
@@ -85,103 +90,62 @@ universal-agent-demo/
|
|||||||
|
|
||||||
config/
|
config/
|
||||||
apps/
|
apps/
|
||||||
scenarios/
|
|
||||||
documents/
|
|
||||||
chat/
|
|
||||||
audit/
|
audit/
|
||||||
|
chat/
|
||||||
|
documents/
|
||||||
|
platform_ui/
|
||||||
|
scenarios/
|
||||||
|
|
||||||
agent_core/
|
agent_core/
|
||||||
rag/
|
rag/
|
||||||
tools/
|
|
||||||
schemas/
|
schemas/
|
||||||
|
tools/
|
||||||
|
|
||||||
configs/
|
configs/
|
||||||
registration_overview.yaml
|
document_review.yaml
|
||||||
registration_completeness_check.yaml
|
governance.yaml
|
||||||
registration_field_extraction.yaml
|
knowledge_qa.yaml
|
||||||
registration_consistency_review.yaml
|
quality_analysis.yaml
|
||||||
registration_risk_report.yaml
|
risk_audit.yaml
|
||||||
|
ticket_assistant.yaml
|
||||||
|
|
||||||
data/
|
data/
|
||||||
uploads/
|
uploads/
|
||||||
chroma/
|
chroma/
|
||||||
|
db.sqlite3
|
||||||
|
|
||||||
docs/
|
docs/
|
||||||
|
需求分析/
|
||||||
|
详细设计/
|
||||||
|
原型设计/
|
||||||
|
原始材料/
|
||||||
|
|
||||||
|
templates/
|
||||||
|
tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## V1 功能范围
|
## 已落地能力
|
||||||
|
|
||||||
V1 需要完成:
|
- 根路径已重定向到审核智能体,降低演示入口复杂度。
|
||||||
|
- 审核工作台允许未上传资料时直接发起知识库问答,后续再通过右侧上传区导入资料包。
|
||||||
|
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR;压缩包内仅导入支持格式,其他文件会生成提示。
|
||||||
|
- 导入时会创建 `SubmissionBatch`、`UploadedDocument` 和绑定的 `Conversation`。
|
||||||
|
- 文档解析覆盖文本抽取、PDF 页数统计、DOCX 页数元数据读取、章节点识别、文档角色识别和人工复核标记。
|
||||||
|
- 审核工作台支持会话历史、资料范围选择、预设问题、节点状态、结构化能力卡、补传资料、Word 导出和通知回看。
|
||||||
|
- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索、工具注册和治理配置读取。
|
||||||
|
- Word 导出会生成最小可下载 `.docx`,按风险状态区分正式版或草稿版,并写入导出记录。
|
||||||
|
- 审计日志记录输入、检索片段、工具调用、结构化输出、原始输出、模型名、耗时、状态和错误信息。
|
||||||
|
- 飞书通知首版为离线留痕,不直接依赖真实飞书网络发送;支持 `task_completed` 与 `task_failed` 两类原因。
|
||||||
|
- 知识库治理台展示法规规则、RAG 源、切片、字段 Schema、Word 模板、责任人映射和飞书配置。
|
||||||
|
- 自动化测试默认使用 Mock Provider,避免本地真实模型密钥导致测试走网络。
|
||||||
|
|
||||||
- 注册审核任务列表。
|
## 启动方式
|
||||||
- 审核工作台。
|
|
||||||
- 资料上传与管理。
|
|
||||||
- 文档解析与入库。
|
|
||||||
- 目录与页数汇总。
|
|
||||||
- 法规完整性检查。
|
|
||||||
- 关键信息抽取与注册申报表格 / 对照清单自动回填。
|
|
||||||
- 一致性核查。
|
|
||||||
- 风险预警与审计日志。
|
|
||||||
- 模型 API 可配置。
|
|
||||||
- Docker 一键启动。
|
|
||||||
|
|
||||||
当前代码基线已经落地的主链能力:
|
推荐首次本地启动:
|
||||||
|
|
||||||
- 首页已收口为 `审核智能体 / 资料包 / 知识库 / 处理历史` 四入口平台总览。
|
|
||||||
- 非法 YAML 场景配置会被自动跳过,并在首页展示错误摘要,但场景仅作为底层执行配置参考。
|
|
||||||
- 审核智能体页已采用三栏结构,支持会话历史、文档范围选择、节点式结果、结构化能力卡、导出与通知回看。
|
|
||||||
- 资料包页支持资料包导入、产品名称 / 批次号搜索、会话跳转、导出记录回看和处理链路展示。
|
|
||||||
- 处理历史页支持按批次、产品、风险状态、通知状态回看执行快照,并展示通知留痕。
|
|
||||||
- 知识库页支持治理对象导航、模板映射、责任人映射、飞书配置和跨入口治理动作总览。
|
|
||||||
- Agent Core 已具备 Prompt 编排、OpenAI 兼容 Provider、结构化输出解析、RAG 检索和工具注册机制。
|
|
||||||
- 测试环境默认固定使用 Mock Provider,避免误调用本地真实模型配置。
|
|
||||||
|
|
||||||
## 本轮需求文档
|
|
||||||
|
|
||||||
本轮已按模块重写需求分析,详见:
|
|
||||||
|
|
||||||
- [V1 总需求文档](F:\PyCharm\DEMO-AGENT\docs\需求分析\1.V1总需求文档.md)
|
|
||||||
- [需求重构总览与待确认事项](F:\PyCharm\DEMO-AGENT\docs\需求分析\0.需求重构总览与待确认事项.md)
|
|
||||||
|
|
||||||
V1 暂不重点做:
|
|
||||||
|
|
||||||
- 多租户。
|
|
||||||
- 复杂权限。
|
|
||||||
- 完整工作流引擎。
|
|
||||||
- 前后端分离。
|
|
||||||
- 深度 Dify 集成。
|
|
||||||
- 生产级高并发优化。
|
|
||||||
|
|
||||||
## 复试改题流程
|
|
||||||
|
|
||||||
拿到题目后:
|
|
||||||
|
|
||||||
1. 判断资料包、规则依据和核心审核链路。
|
|
||||||
2. 调整最接近的任务 YAML 配置。
|
|
||||||
3. 修改 Agent 角色、目标、指令和输出模板。
|
|
||||||
4. 上传题目材料并生成资料包。
|
|
||||||
5. 确认产品名称解析、资料包绑定和会话标题是否正确。
|
|
||||||
6. 如需业务计算,新增一个工具函数。
|
|
||||||
7. 用 2 到 3 个预设问题测试目录汇总、完整性检查和字段抽取。
|
|
||||||
8. 演示对话节点、知识库引用、结构化输出、飞书通知和审计日志。
|
|
||||||
|
|
||||||
## 当前页面概览
|
|
||||||
|
|
||||||
当前项目包含以下主要页面:
|
|
||||||
|
|
||||||
| 页面 | 路径 | 当前能力 |
|
|
||||||
|---|---|---|
|
|
||||||
| 首页 / 平台总览 | `/` | 展示四入口主叙事、产品指标、风险摘要和底层场景配置参考 |
|
|
||||||
| 审核智能体 | `/chat/` 与 `/chat/<conversation_id>/` | 会话驱动审核、节点式结果、上传补传、Word 导出、通知与审计回看 |
|
|
||||||
| 资料包 | `/documents/` 及相关上传入口 | 查看资料包、按产品名称搜索、跳转会话、查看最近导出和处理链路 |
|
|
||||||
| 知识库 | `/platform/knowledge-base/` | 管理法规资料、业务资料、切片、字段 Schema、模板映射、责任人映射和飞书配置 |
|
|
||||||
| 处理历史 | `/audit/` 及详情页 | 查看执行摘要、批次 / 会话 / 产品链路、通知状态、导出摘要和错误信息 |
|
|
||||||
|
|
||||||
## 计划启动方式
|
|
||||||
|
|
||||||
本地启动:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
python manage.py runserver
|
python manage.py runserver
|
||||||
@@ -193,21 +157,12 @@ Docker 启动:
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
当前文档目标已统一为完整 V1 闭环:真实 Chroma RAG、OpenAI 兼容 LLM、OpenAI 兼容 Embedding、工具注册和审计日志。开发阶段可以用测试桩验证页面和边界,但不作为 V1 验收结果。
|
Docker Compose 会读取根目录 `.env`,并挂载 `./data` 与 `./configs`。
|
||||||
|
|
||||||
推荐首次启动步骤:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
.venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python manage.py migrate
|
|
||||||
python manage.py runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
项目当前通过 `os.environ` 读取配置,核心变量如下:
|
项目通过根目录 `.env` 和系统环境变量读取配置。`.env.example` 只作为模板,不应提交真实密钥。
|
||||||
|
若复试演示使用硅基流动,可复制 `.env.siliconflow.example` 为 `.env`,再手动填入 `LLM_API_KEY` 和 `EMBEDDING_API_KEY`。
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||||
@@ -223,35 +178,21 @@ EMBEDDING_BASE_URL=
|
|||||||
EMBEDDING_MODEL=text-embedding-3-small
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
|
||||||
SCENARIO_CONFIG_DIR=configs
|
SCENARIO_CONFIG_DIR=configs
|
||||||
|
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
|
||||||
UPLOAD_ROOT=data/uploads
|
UPLOAD_ROOT=data/uploads
|
||||||
CHROMA_PATH=data/chroma
|
CHROMA_PATH=data/chroma
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
- `EMBEDDING_API_KEY` 为空时,代码会自动复用 `LLM_API_KEY`。
|
- `EMBEDDING_API_KEY` 为空时自动复用 `LLM_API_KEY`。
|
||||||
- `EMBEDDING_BASE_URL` 为空时,代码会自动复用 `LLM_BASE_URL`。
|
- `EMBEDDING_BASE_URL` 为空时自动复用 `LLM_BASE_URL`。
|
||||||
- `.env.example` 只作为模板,不应填写真实密钥并提交到仓库。
|
- `.env.siliconflow.example` 内置硅基流动 `base_url`、Qwen 对话模型和 `BAAI/bge-m3` Embedding 配置。
|
||||||
- 当前代码会在 Django settings 初始化时自动加载根目录 `.env`,本地 `python manage.py runserver`、`pytest` 和 Docker Compose 可以复用同一套配置。
|
- Django settings 初始化时会自动加载根目录 `.env`。
|
||||||
- Docker Compose 当前在 `docker-compose.yml` 中通过 `env_file` 读取 `.env`。
|
- 测试环境会在 `tests/conftest.py` 中固定 Mock Provider,避免误调用真实 LLM。
|
||||||
|
|
||||||
常见做法:
|
|
||||||
|
|
||||||
- 本地开发:复制 `.env.example` 为 `.env`,填入真实参数后运行。
|
|
||||||
- Docker 演示:确认 `.env` 已配置后,再执行 `docker compose up --build`。
|
|
||||||
|
|
||||||
## 测试与验证
|
## 测试与验证
|
||||||
|
|
||||||
当前项目已经补有较完整的模块级测试,覆盖:
|
|
||||||
|
|
||||||
- 场景配置读取、非法配置容错和首页展示。
|
|
||||||
- 对话提交、文档范围传递、结构化结果展示。
|
|
||||||
- 文档上传、文本抽取、入库成功与失败提示。
|
|
||||||
- 审计日志落库、筛选、原始输出展示和 API Key 脱敏。
|
|
||||||
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback 检索。
|
|
||||||
- Tool Registry 和内置工具行为。
|
|
||||||
- LLM / Embedding Provider 的配置与请求构造。
|
|
||||||
|
|
||||||
常用验证命令:
|
常用验证命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -260,10 +201,15 @@ python manage.py check
|
|||||||
docker compose config
|
docker compose config
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
当前测试覆盖:
|
||||||
|
|
||||||
- 测试环境默认通过 `tests/conftest.py` 固定 `LLM_PROVIDER=mock`,避免回归测试误走真实网络请求。
|
- 项目配置、根路由和核心页面可访问性。
|
||||||
- 当前本地 `.env` 可能包含真实模型配置,但不会影响自动化测试稳定性。
|
- 场景配置读取、非法 YAML 容错和场景列表展示。
|
||||||
|
- 资料包导入、压缩包展开、文档解析、入库状态和异常提示。
|
||||||
|
- 会话创建、对话提交、文档范围传递、结构化结果展示和 Word 导出。
|
||||||
|
- 审计日志落库、筛选、详情展示、通知留痕和敏感信息脱敏。
|
||||||
|
- Agent Core 的 Prompt 编排、结构化解析、RAG fallback、工具注册、LLM / Embedding Provider 请求构造。
|
||||||
|
- 平台治理页、指挥台、知识库、MCP 中心和 Skill 工作室展示。
|
||||||
|
|
||||||
## 文档入口
|
## 文档入口
|
||||||
|
|
||||||
@@ -283,16 +229,32 @@ docker compose config
|
|||||||
- [风险预警详细设计](docs/详细设计/5.风险预警.md)
|
- [风险预警详细设计](docs/详细设计/5.风险预警.md)
|
||||||
- [Word 回填导出详细设计](docs/详细设计/6.Word回填导出.md)
|
- [Word 回填导出详细设计](docs/详细设计/6.Word回填导出.md)
|
||||||
- [飞书通知详细设计](docs/详细设计/7.飞书通知.md)
|
- [飞书通知详细设计](docs/详细设计/7.飞书通知.md)
|
||||||
- [注册审核平台整体原型设计](F:\PyCharm\DEMO-AGENT\docs\原型设计\1.整体原型设计.md)
|
- [注册审核平台整体原型设计](docs/原型设计/1.整体原型设计.md)
|
||||||
- [原型设计目录](F:\PyCharm\DEMO-AGENT\docs\原型设计)
|
- [单文件演示站 HTML](docs/原型设计/registration-prototype-demo.html)
|
||||||
- [单文件演示站 HTML](F:\PyCharm\DEMO-AGENT\docs\原型设计\registration-prototype-demo.html)
|
|
||||||
- [协作与编码约定](AGENTS.md)
|
- [协作与编码约定](AGENTS.md)
|
||||||
|
|
||||||
## 原型设计交付
|
## 复试改题流程
|
||||||
|
|
||||||
当前仓库已补充一套围绕注册申报审核主线的原型设计资产,供复试讲解、方案评审和后续页面实现直接参考:
|
拿到新题目后:
|
||||||
|
|
||||||
- 原型文档采用“总览 + 分页细设计”方式组织,覆盖资料包导入、审核任务工作台、法规完整性检查、字段抽取与字段池、一致性核查、风险预警、Word 回填导出、飞书通知视图和知识库治理台。
|
1. 判断资料包、规则依据和核心审核链路。
|
||||||
- `docs/原型设计/registration-prototype-demo.html` 提供单文件可交互 mock 演示站,当前已重构为 Agent 化界面,顶层为 `审核智能体 / 资料包 / 知识库 / 处理历史`。
|
2. 调整最接近的 YAML 场景配置,优先从 `configs/document_review.yaml` 入手。
|
||||||
- 资料包与对话会话已在原型中绑定,对话标题采用解析后的产品名称,资料包页支持按产品名称搜索并跳转对应会话。
|
3. 修改 Agent 角色、目标、指令和输出模板。
|
||||||
- 该演示站仅使用 mock 数据,不依赖 Django 路由或真实 Agent Core 执行结果。
|
4. 上传题目材料并生成资料包。
|
||||||
|
5. 确认产品名称解析、资料包绑定和会话标题是否正确。
|
||||||
|
6. 如需业务计算,新增工具函数并通过 Tool Registry 注册。
|
||||||
|
7. 用 2 到 3 个预设问题测试目录汇总、完整性检查、字段抽取和风险报告。
|
||||||
|
8. 演示节点结果、知识库引用、结构化输出、Word 导出、通知留痕和审计日志。
|
||||||
|
|
||||||
|
## V1 不优先做
|
||||||
|
|
||||||
|
- React / Vue 前端。
|
||||||
|
- 多租户。
|
||||||
|
- 复杂 RBAC。
|
||||||
|
- 完整工作流引擎。
|
||||||
|
- 深度 Dify 集成。
|
||||||
|
- 微服务拆分。
|
||||||
|
- 分布式任务队列。
|
||||||
|
- 真实飞书发送链路。
|
||||||
|
|
||||||
|
这些内容可以作为后续增强,不应影响 V1 快速成型。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from urllib.error import URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
@@ -75,12 +75,28 @@ class OpenAICompatibleProvider:
|
|||||||
if response_format:
|
if response_format:
|
||||||
payload["response_format"] = response_format
|
payload["response_format"] = response_format
|
||||||
try:
|
try:
|
||||||
data = _post_json(
|
try:
|
||||||
base_url=self.base_url,
|
data = _post_json(
|
||||||
endpoint="chat/completions",
|
base_url=self.base_url,
|
||||||
api_key=self.api_key,
|
endpoint="chat/completions",
|
||||||
payload=payload,
|
api_key=self.api_key,
|
||||||
)
|
payload=payload,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# 部分 OpenAI 兼容供应商或模型不支持 response_format。
|
||||||
|
# 保留结构化优先,遇到 400 时退回普通对话,避免演示链路被接口能力差异阻断。
|
||||||
|
if not response_format or "HTTP Error 400" not in str(exc):
|
||||||
|
raise
|
||||||
|
fallback_payload = {
|
||||||
|
"model": self.model_name,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
data = _post_json(
|
||||||
|
base_url=self.base_url,
|
||||||
|
endpoint="chat/completions",
|
||||||
|
api_key=self.api_key,
|
||||||
|
payload=fallback_payload,
|
||||||
|
)
|
||||||
choice = data.get("choices", [{}])[0]
|
choice = data.get("choices", [{}])[0]
|
||||||
content = choice.get("message", {}).get("content", "")
|
content = choice.get("message", {}).get("content", "")
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
@@ -197,5 +213,11 @@ def _post_json(base_url: str, endpoint: str, api_key: str, payload: dict) -> dic
|
|||||||
try:
|
try:
|
||||||
with urlopen(request, timeout=60) as response:
|
with urlopen(request, timeout=60) as response:
|
||||||
return json.loads(response.read().decode("utf-8"))
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
error_body = exc.read().decode("utf-8", errors="ignore")
|
||||||
|
error_detail = f"{exc}"
|
||||||
|
if error_body:
|
||||||
|
error_detail = f"{error_detail} {error_body}"
|
||||||
|
raise RuntimeError(f"OpenAI 兼容接口调用失败:{error_detail}") from exc
|
||||||
except URLError as exc:
|
except URLError as exc:
|
||||||
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc
|
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ def create_conversation_for_batch(batch_id: str, product_name: str) -> Conversat
|
|||||||
return conversation
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
def create_knowledge_conversation() -> Conversation:
|
||||||
|
"""
|
||||||
|
创建未绑定资料包的知识库问答会话。
|
||||||
|
|
||||||
|
该会话用于用户尚未上传资料时直接向 RAG 知识库提问,
|
||||||
|
因此 batch_id 与 product_name 保持为空,Agent Core 通过空范围执行全局检索。
|
||||||
|
"""
|
||||||
|
return Conversation.objects.create(
|
||||||
|
conversation_id=_generate_conversation_id(),
|
||||||
|
title="知识库问答会话",
|
||||||
|
product_name="",
|
||||||
|
batch_id="",
|
||||||
|
task_status=Conversation.STATUS_PENDING,
|
||||||
|
node_results=_build_knowledge_node_results(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def execute_conversation_agent(
|
def execute_conversation_agent(
|
||||||
*,
|
*,
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
@@ -132,6 +149,14 @@ def _build_initial_node_results() -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_knowledge_node_results() -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"code": "knowledge_retrieval", "label": "知识库检索", "status": "待处理"},
|
||||||
|
{"code": "answer_generation", "label": "问答生成", "status": "待处理"},
|
||||||
|
{"code": "risk", "label": "风险预警", "status": "待处理"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
|
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
|
||||||
payload = result.notification_payload or {}
|
payload = result.notification_payload or {}
|
||||||
owners = payload.get("owners") or []
|
owners = payload.get("owners") or []
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ from apps.documents.services import append_documents_to_batch
|
|||||||
|
|
||||||
from .forms import ChatForm, ConversationUploadForm
|
from .forms import ChatForm, ConversationUploadForm
|
||||||
from .models import Conversation
|
from .models import Conversation
|
||||||
from .services import execute_conversation_agent, execute_conversation_export
|
from .services import (
|
||||||
|
create_knowledge_conversation,
|
||||||
|
execute_conversation_agent,
|
||||||
|
execute_conversation_export,
|
||||||
|
)
|
||||||
|
|
||||||
RISK_LEVEL_DISPLAY = {
|
RISK_LEVEL_DISPLAY = {
|
||||||
"high": "高",
|
"high": "高",
|
||||||
@@ -48,19 +52,48 @@ def index(request):
|
|||||||
conversations = Conversation.objects.all()
|
conversations = Conversation.objects.all()
|
||||||
if conversations.exists():
|
if conversations.exists():
|
||||||
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
|
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
|
||||||
|
documents = UploadedDocument.objects.filter(batch__isnull=True)
|
||||||
|
form = ChatForm(request.POST or None, documents=documents)
|
||||||
|
upload_form = ConversationUploadForm()
|
||||||
|
result = None
|
||||||
|
audit_log = None
|
||||||
|
conversation = None
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
conversation = create_knowledge_conversation()
|
||||||
|
result, audit_log = execute_conversation_agent(
|
||||||
|
conversation=conversation,
|
||||||
|
message=form.cleaned_data["message"],
|
||||||
|
document_ids=form.cleaned_data["document_ids"],
|
||||||
|
detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
|
||||||
|
)
|
||||||
|
conversation.refresh_from_db()
|
||||||
|
documents = UploadedDocument.objects.filter(batch__isnull=True)
|
||||||
|
|
||||||
|
display_node_results = _normalize_node_results(conversation.node_results if conversation else [])
|
||||||
|
workspace_summary = _build_workspace_summary(conversation, None, display_node_results) if conversation else _build_empty_workspace_summary()
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"chat/index.html",
|
"chat/index.html",
|
||||||
{
|
{
|
||||||
"conversation": None,
|
"conversation": conversation,
|
||||||
"conversations": [],
|
"conversations": [],
|
||||||
"conversation_history": [],
|
"conversation_history": [],
|
||||||
"form": ChatForm(),
|
"batch": None,
|
||||||
"documents": [],
|
"form": form,
|
||||||
"result": None,
|
"documents": documents,
|
||||||
"audit_log": None,
|
"document_count": documents.count(),
|
||||||
"node_results": [],
|
"result": result,
|
||||||
|
"audit_log": audit_log,
|
||||||
|
"node_results": display_node_results,
|
||||||
"active_node": None,
|
"active_node": None,
|
||||||
|
"workspace_summary": workspace_summary,
|
||||||
|
"conversation_context": _build_conversation_context(conversation, None, workspace_summary) if conversation else {},
|
||||||
|
"prompt_templates": _build_prompt_templates(),
|
||||||
|
"analysis_card": _build_analysis_card(result, conversation) if conversation else {},
|
||||||
|
"upload_form": upload_form,
|
||||||
|
"export_card": _build_export_card(result, conversation) if conversation else {},
|
||||||
|
"risk_card": _build_risk_card(result, conversation) if conversation else {},
|
||||||
|
"notify_card": _build_notify_card(result, conversation) if conversation else {},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,6 +234,18 @@ def _build_workspace_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_empty_workspace_summary() -> dict:
|
||||||
|
return {
|
||||||
|
"highest_risk_level": "-",
|
||||||
|
"export_allowed": "否",
|
||||||
|
"notify_status": "待处理",
|
||||||
|
"export_status": "待处理",
|
||||||
|
"download_url": "",
|
||||||
|
"file_count": 0,
|
||||||
|
"page_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_conversation_context(
|
def _build_conversation_context(
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
batch: SubmissionBatch | None,
|
batch: SubmissionBatch | None,
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.views.generic import RedirectView
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
|
||||||
# 总路由只承担模块装配职责,不在这里写业务逻辑。
|
# 总路由只承担模块装配职责,不在这里写业务逻辑。
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("", include("apps.scenarios.urls")),
|
# 首页默认进入审核工作台,旧的平台总览改为非默认入口,便于演示聚焦主链路。
|
||||||
|
path("", RedirectView.as_view(pattern_name="chat:index", permanent=False)),
|
||||||
|
path("scenarios/", include("apps.scenarios.urls")),
|
||||||
path("chat/", include("apps.chat.urls")),
|
path("chat/", include("apps.chat.urls")),
|
||||||
path("documents/", include("apps.documents.urls")),
|
path("documents/", include("apps.documents.urls")),
|
||||||
path("audit/", include("apps.audit.urls")),
|
path("audit/", include("apps.audit.urls")),
|
||||||
|
|||||||
@@ -360,13 +360,13 @@
|
|||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-inner">
|
<div class="topbar-inner">
|
||||||
<div class="brand">
|
<a class="brand" href="{% url 'chat:index' %}">
|
||||||
<div class="brand-mark">RA</div>
|
<div class="brand-mark">RA</div>
|
||||||
<div>
|
<div>
|
||||||
<h1>注册审核智能体平台</h1>
|
<h1>注册审核智能体平台</h1>
|
||||||
<p>极简后台原型</p>
|
<p>极简后台原型</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
<nav class="topnav">
|
<nav class="topnav">
|
||||||
<a href="{% url 'chat:index' %}"{% if request.resolver_match.namespace == 'chat' %} class="active"{% endif %}>审核智能体</a>
|
<a href="{% url 'chat:index' %}"{% if request.resolver_match.namespace == 'chat' %} class="active"{% endif %}>审核智能体</a>
|
||||||
<a href="{% url 'documents:list' %}"{% if request.resolver_match.namespace == 'documents' %} class="active"{% endif %}>资料包</a>
|
<a href="{% url 'documents:list' %}"{% if request.resolver_match.namespace == 'documents' %} class="active"{% endif %}>资料包</a>
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page {% block page_class %}{% endblock %}">
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
|
|||||||
@@ -1,59 +1,353 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}审核智能体{% endblock %}
|
{% block title %}审核智能体{% endblock %}
|
||||||
|
{% block page_class %}chat-page{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="page-header">
|
<style>
|
||||||
<span class="eyebrow">Agent Workspace</span>
|
.chat-page {
|
||||||
<h1 class="page-title">审核智能体</h1>
|
width: min(100vw - 32px, 1920px);
|
||||||
<p class="page-lead">以会话为中心组织资料包上传、节点式审核结果和动态任务信息卡。</p>
|
margin-top: 12px;
|
||||||
{% if conversation %}
|
gap: 12px;
|
||||||
<div class="badge-row">
|
}
|
||||||
<span class="pill pill-accent">批次:{{ conversation.batch_id }}</span>
|
|
||||||
<span class="pill">产品:{{ conversation.product_name|default:"未识别产品名称" }}</span>
|
|
||||||
<span class="pill">阶段:{{ conversation_context.task_status }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if conversation %}
|
.chat-layout {
|
||||||
<section class="grid-2">
|
display: grid;
|
||||||
<article class="panel">
|
grid-template-columns: 280px minmax(680px, 1fr) 340px;
|
||||||
<h2 class="section-title">顶部对话上下文</h2>
|
gap: 12px;
|
||||||
<p class="section-copy">进入会话后,先用当前批次、产品和风险状态快速建立审核上下文。</p>
|
align-items: start;
|
||||||
<ul class="detail-list">
|
}
|
||||||
<li class="detail-item"><strong>批次编号</strong><div>{{ conversation_context.batch_id }}</div></li>
|
|
||||||
<li class="detail-item"><strong>产品名称</strong><div>{{ conversation_context.product_name|default:"未识别产品名称" }}</div></li>
|
|
||||||
<li class="detail-item"><strong>当前流程类型</strong><div>{{ conversation_context.workflow_type }}</div></li>
|
|
||||||
<li class="detail-item"><strong>当前审核阶段</strong><div>{{ conversation_context.task_status }}</div></li>
|
|
||||||
<li class="detail-item"><strong>当前最高风险等级</strong><div>{{ conversation_context.highest_risk_level }}</div></li>
|
|
||||||
<li class="detail-item"><strong>是否允许正式导出</strong><div>{{ conversation_context.export_allowed }}</div></li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="panel">
|
.history-toggle {
|
||||||
<h2 class="section-title">推荐提问模板</h2>
|
position: absolute;
|
||||||
<p class="section-copy">用这些提问模板快速进入目录汇总、完整性检查、字段抽取和风险分析。</p>
|
opacity: 0;
|
||||||
<div class="button-row">
|
pointer-events: none;
|
||||||
{% for item in prompt_templates %}
|
}
|
||||||
<span class="pill pill-accent">{{ item }}</span>
|
|
||||||
{% endfor %}
|
.chat-layout:has(.history-toggle:checked) {
|
||||||
|
grid-template-columns: 54px minmax(760px, 1fr) 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout:has(.history-toggle:checked) .history-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout:has(.history-toggle:checked) .history-panel {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout:has(.history-toggle:checked) .history-collapse {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-layout:has(.history-toggle:checked) .history-collapse span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-panel,
|
||||||
|
.right-rail {
|
||||||
|
position: sticky;
|
||||||
|
top: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-head,
|
||||||
|
.right-rail-head,
|
||||||
|
.chat-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-collapse {
|
||||||
|
flex: none;
|
||||||
|
min-height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
max-height: calc(100vh - 230px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell {
|
||||||
|
min-height: calc(100vh - 190px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(360px, 1fr) auto;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-head {
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
max-width: 52vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px min(7vw, 72px);
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
justify-self: end;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user .avatar {
|
||||||
|
grid-column: 2;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
line-height: 1.75;
|
||||||
|
box-shadow: 0 4px 14px rgba(31, 45, 61, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user .bubble {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
background: #eef4ff;
|
||||||
|
border-color: #d8e5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: 14px min(7vw, 72px) 18px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box textarea {
|
||||||
|
height: 86px;
|
||||||
|
min-height: 78px;
|
||||||
|
max-height: 180px;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: vertical;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid #edf1f5;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-summary {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-scope {
|
||||||
|
display: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-box:focus-within .document-scope {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-panel {
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-card {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 164px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1.5px dashed #9fb7e8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow: 0 6px 18px rgba(47, 111, 236, 0.14);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-dropzone strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tile {
|
||||||
|
min-height: 74px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tile strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.chat-layout,
|
||||||
|
.chat-layout:has(.history-toggle:checked) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.history-panel,
|
||||||
|
.right-rail {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.node-strip {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.chat-thread,
|
||||||
|
.composer {
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section class="chat-layout">
|
||||||
|
<input class="history-toggle" id="history-collapsed" type="checkbox">
|
||||||
|
<aside class="panel history-panel">
|
||||||
|
<div class="history-head">
|
||||||
|
<div class="history-content">
|
||||||
|
<h2 class="section-title">会话历史</h2>
|
||||||
|
<p class="section-copy">按会话快速切换资料包和知识库问答。</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<label class="button history-collapse" for="history-collapsed" title="收起或展开会话历史">‹ <span>收起</span></label>
|
||||||
</section>
|
</div>
|
||||||
{% endif %}
|
<div class="history-content">
|
||||||
|
<ul class="detail-list history-list">
|
||||||
<section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;">
|
|
||||||
<div class="stack">
|
|
||||||
<article class="panel">
|
|
||||||
<h2 class="section-title">会话历史</h2>
|
|
||||||
<p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称。</p>
|
|
||||||
<ul class="detail-list">
|
|
||||||
{% for item in conversation_history %}
|
{% for item in conversation_history %}
|
||||||
<li class="detail-item">
|
<li class="detail-item history-card">
|
||||||
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
|
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
|
||||||
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
|
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
|
||||||
<div class="muted">批次:{{ item.batch_id }}</div>
|
<div class="muted">批次:{{ item.batch_id|default:"未绑定" }}</div>
|
||||||
<div class="muted">风险:{{ item.risk_level }}</div>
|
<div class="muted">风险:{{ item.risk_level }}</div>
|
||||||
<div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div>
|
<div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div>
|
||||||
<div class="badge-row" style="margin-top: 8px;">
|
<div class="badge-row" style="margin-top: 8px;">
|
||||||
@@ -61,64 +355,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<li class="detail-item">暂无会话,请先从资料包页面导入资料。</li>
|
<li class="detail-item">
|
||||||
|
<strong>暂无历史会话</strong>
|
||||||
|
<div class="muted">可以直接在中间区域提问,Agent 会优先检索知识库。</div>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<article class="panel">
|
<article class="panel chat-shell">
|
||||||
<div class="section-heading">
|
<div class="chat-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="section-title">对话区与节点导航</h2>
|
<h2 class="section-title">对话区与节点导航</h2>
|
||||||
<p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要。</p>
|
<p class="section-copy">像聊天一样提问,Agent 会结合知识库和所选文档回答。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if node_results %}
|
||||||
{% if conversation %}
|
<div class="node-strip">
|
||||||
<div class="badge-row" style="margin-bottom: 14px;">
|
|
||||||
{% for node in node_results %}
|
{% for node in node_results %}
|
||||||
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
|
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" class="stack">
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-thread">
|
||||||
|
{% if conversation %}
|
||||||
|
<div class="chat-message assistant">
|
||||||
|
<div class="avatar">RA</div>
|
||||||
|
<div class="bubble">
|
||||||
|
<span class="bubble-meta">审核智能体</span>
|
||||||
|
当前会话已就绪。可以询问资料目录、法规依据、字段抽取、一致性问题或风险整改建议。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="chat-message assistant">
|
||||||
|
<div class="avatar">RA</div>
|
||||||
|
<div class="bubble">
|
||||||
|
<span class="bubble-meta">审核智能体</span>
|
||||||
|
可以先不上传资料,直接询问注册法规、资料清单、模板字段或历史知识库内容。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.message.value %}
|
||||||
|
<div class="chat-message user">
|
||||||
|
<div class="avatar">你</div>
|
||||||
|
<div class="bubble">
|
||||||
|
<span class="bubble-meta">用户问题</span>
|
||||||
|
{{ form.message.value|linebreaksbr }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if result %}
|
||||||
|
<div class="chat-message assistant">
|
||||||
|
<div class="avatar">RA</div>
|
||||||
|
<div class="bubble">
|
||||||
|
<span class="bubble-meta">Agent 回答</span>
|
||||||
|
{{ result.answer|linebreaksbr }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="prompt-chips">
|
||||||
|
{% for item in prompt_templates %}
|
||||||
|
<span class="pill pill-accent">{{ item }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="composer" action="{% if conversation %}{% url 'chat:detail' conversation.conversation_id %}{% else %}{% url 'chat:index' %}{% endif %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div>
|
<div class="composer-box">
|
||||||
{{ form.message.label_tag }}
|
{{ form.message.label_tag }}
|
||||||
{{ form.message }}
|
{{ form.message }}
|
||||||
{% if form.message.errors %}
|
{% if form.message.errors %}
|
||||||
<p class="notice notice-error">{{ form.message.errors|join:" " }}</p>
|
<p class="notice notice-error">{{ form.message.errors|join:" " }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<div class="document-scope">
|
||||||
<div>
|
{{ form.document_ids.label_tag }}
|
||||||
{{ form.document_ids.label_tag }}
|
<div class="checkbox-list">
|
||||||
<div class="checkbox-list">
|
{% for checkbox in form.document_ids %}
|
||||||
{% for checkbox in form.document_ids %}
|
<label class="checkbox-item">
|
||||||
<label class="checkbox-item">
|
{{ checkbox.tag }}
|
||||||
{{ checkbox.tag }}
|
<span>{{ checkbox.choice_label }}</span>
|
||||||
<span>{{ checkbox.choice_label }}</span>
|
</label>
|
||||||
</label>
|
{% empty %}
|
||||||
{% empty %}
|
<div class="notice">未选择文档时,Agent 会按问题检索当前知识库。</div>
|
||||||
<div class="notice">当前资料包还没有可选文档。</div>
|
{% endfor %}
|
||||||
{% endfor %}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<span class="scope-summary">默认检索知识库;聚焦输入框可选择文档范围。</span>
|
||||||
|
<button class="send-button" type="submit">发送问题</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
|
||||||
<button type="submit">提交审核任务</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{% if result %}
|
|
||||||
<div class="detail-item" style="margin-top: 16px;">
|
|
||||||
<strong>Agent 回答</strong>
|
|
||||||
<div>{{ result.answer|linebreaksbr }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<div class="notice">暂无会话,请先导入资料包。</div>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel result-panel">
|
||||||
<h2 class="section-title">节点式结果</h2>
|
<h2 class="section-title">节点式结果</h2>
|
||||||
{% if analysis_card or export_card or risk_card or notify_card %}
|
{% if analysis_card or export_card or risk_card or notify_card %}
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -388,13 +723,17 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<aside class="stack right-rail">
|
||||||
<article class="panel">
|
<article class="panel upload-card">
|
||||||
<h2 class="section-title">上传区</h2>
|
<div class="right-rail-head">
|
||||||
<p class="section-copy">右侧保留资料包上传入口和当前会话的资料上下文。</p>
|
<div>
|
||||||
|
<h2 class="section-title">上传区</h2>
|
||||||
|
<p class="section-copy">拖拽或点击导入资料包。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if batch %}
|
{% if batch %}
|
||||||
<ul class="detail-list">
|
<ul class="detail-list" style="margin-bottom: 14px;">
|
||||||
<li class="detail-item">
|
<li class="detail-item rail-card">
|
||||||
<strong>当前资料包</strong>
|
<strong>当前资料包</strong>
|
||||||
<div>批次:{{ batch.batch_id }}</div>
|
<div>批次:{{ batch.batch_id }}</div>
|
||||||
<div>文件数:{{ batch.file_count }}</div>
|
<div>文件数:{{ batch.file_count }}</div>
|
||||||
@@ -404,10 +743,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<form method="post" action="{% url 'chat:upload-documents' conversation.conversation_id %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
|
<form method="post" action="{% url 'chat:upload-documents' conversation.conversation_id %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div>
|
<label class="upload-dropzone">
|
||||||
{{ upload_form.files.label_tag }}
|
|
||||||
{{ upload_form.files }}
|
{{ upload_form.files }}
|
||||||
</div>
|
<span>
|
||||||
|
<span class="upload-icon">↑</span>
|
||||||
|
<strong>拖拽补充资料到这里</strong>
|
||||||
|
<span class="muted">或点击选择文件,支持多选和压缩包。</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button type="submit">继续上传资料</button>
|
<button type="submit">继续上传资料</button>
|
||||||
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
|
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
|
||||||
@@ -423,30 +766,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="notice">暂无绑定资料包。</div>
|
<div class="notice">暂无绑定资料包,仍可先通过中间对话区查询知识库。</div>
|
||||||
|
<form method="post" action="{% url 'documents:upload' %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="scenario_id" value="document_review">
|
||||||
|
<label class="upload-dropzone">
|
||||||
|
{{ upload_form.files }}
|
||||||
|
<span>
|
||||||
|
<span class="upload-icon">↑</span>
|
||||||
|
<strong>拖拽资料包到这里</strong>
|
||||||
|
<span class="muted">PDF、DOCX、MD、TXT、ZIP、7Z、RAR 均可。</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit">导入资料包</button>
|
||||||
|
<a class="button" href="{% url 'documents:upload' %}">打开导入向导</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<h2 class="section-title">动态信息卡</h2>
|
<h2 class="section-title">动态信息卡</h2>
|
||||||
<ul class="detail-list">
|
<div class="status-grid">
|
||||||
<li class="detail-item">
|
<div class="status-tile">
|
||||||
<strong>最高风险等级</strong>
|
<strong>最高风险等级</strong>
|
||||||
<div>{{ workspace_summary.highest_risk_level }}</div>
|
<div>{{ workspace_summary.highest_risk_level }}</div>
|
||||||
</li>
|
</div>
|
||||||
<li class="detail-item">
|
<div class="status-tile">
|
||||||
<strong>是否允许正式导出</strong>
|
<strong>是否允许正式导出</strong>
|
||||||
<div>{{ workspace_summary.export_allowed }}</div>
|
<div>{{ workspace_summary.export_allowed }}</div>
|
||||||
</li>
|
</div>
|
||||||
<li class="detail-item">
|
<div class="status-tile">
|
||||||
<strong>通知状态</strong>
|
<strong>通知状态</strong>
|
||||||
<div>{{ workspace_summary.notify_status }}</div>
|
<div>{{ workspace_summary.notify_status }}</div>
|
||||||
</li>
|
</div>
|
||||||
<li class="detail-item">
|
<div class="status-tile">
|
||||||
<strong>导出状态</strong>
|
<strong>导出状态</strong>
|
||||||
<div>{{ workspace_summary.export_status }}</div>
|
<div>{{ workspace_summary.export_status }}</div>
|
||||||
</li>
|
</div>
|
||||||
<li class="detail-item">
|
</div>
|
||||||
|
<ul class="detail-list" style="margin-top: 10px;">
|
||||||
|
<li class="detail-item rail-card">
|
||||||
<strong>导出下载地址</strong>
|
<strong>导出下载地址</strong>
|
||||||
<div>
|
<div>
|
||||||
{% if workspace_summary.download_url %}
|
{% if workspace_summary.download_url %}
|
||||||
@@ -456,12 +817,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="detail-item">当前会话围绕 `conversation_id / batch_id / product_name` 串联。</li>
|
<li class="detail-item">当前会话围绕 conversation_id / batch_id / product_name 串联;未绑定资料包时以知识库问答方式执行。</li>
|
||||||
{% if audit_log %}
|
{% if audit_log %}
|
||||||
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
|
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from apps.audit.models import NotificationRecord
|
|||||||
from apps.chat.models import Conversation
|
from apps.chat.models import Conversation
|
||||||
from apps.chat.services import (
|
from apps.chat.services import (
|
||||||
create_conversation_for_batch,
|
create_conversation_for_batch,
|
||||||
|
create_knowledge_conversation,
|
||||||
execute_conversation_agent,
|
execute_conversation_agent,
|
||||||
execute_conversation_export,
|
execute_conversation_export,
|
||||||
)
|
)
|
||||||
@@ -79,6 +80,47 @@ def test_chat_rejects_empty_message(client, db):
|
|||||||
assert "请输入要咨询的问题" in response.content.decode("utf-8")
|
assert "请输入要咨询的问题" in response.content.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_index_allows_question_before_upload_and_shows_upload_control(client, db):
|
||||||
|
response = client.get(reverse("chat:index"))
|
||||||
|
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "发送问题" in content
|
||||||
|
assert "导入资料包" in content
|
||||||
|
assert "未选择文档时,Agent 会按问题检索当前知识库" in content
|
||||||
|
assert "暂无绑定资料包,仍可先通过中间对话区查询知识库" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_index_post_creates_knowledge_conversation_and_runs_agent(client, db, monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_run_agent(scenario_config, user_input, options=None):
|
||||||
|
captured["scenario_id"] = scenario_config["id"]
|
||||||
|
captured["user_input"] = user_input
|
||||||
|
captured["options"] = options or {}
|
||||||
|
return AgentResult(answer="知识库命中:注册资料目录要求", status="success")
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.chat.services.run_agent", fake_run_agent)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
reverse("chat:index"),
|
||||||
|
{"message": "注册资料目录有哪些要求?"},
|
||||||
|
)
|
||||||
|
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
conversation = Conversation.objects.get()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert conversation.title == "知识库问答会话"
|
||||||
|
assert conversation.batch_id == ""
|
||||||
|
assert captured["scenario_id"] == "document_review"
|
||||||
|
assert captured["user_input"] == "注册资料目录有哪些要求?"
|
||||||
|
assert captured["options"]["conversation_id"] == conversation.conversation_id
|
||||||
|
assert captured["options"]["batch_id"] == ""
|
||||||
|
assert captured["options"]["document_ids"] == []
|
||||||
|
assert "知识库命中:注册资料目录要求" in content
|
||||||
|
assert AgentAuditLog.objects.filter(conversation_id=conversation.conversation_id).count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
|
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
|
||||||
batch, conversation = _create_conversation_with_batch()
|
batch, conversation = _create_conversation_with_batch()
|
||||||
selected = UploadedDocument.objects.create(
|
selected = UploadedDocument.objects.create(
|
||||||
@@ -484,7 +526,7 @@ def test_chat_page_shows_upload_entry_and_dynamic_context_cards(client, db):
|
|||||||
assert "飞书通知 / 待处理" in content
|
assert "飞书通知 / 待处理" in content
|
||||||
|
|
||||||
|
|
||||||
def test_chat_page_shows_top_context_and_recommended_prompts(client, db):
|
def test_chat_page_keeps_prompts_inside_chat_thread_without_top_cards(client, db):
|
||||||
batch, conversation = _create_conversation_with_batch()
|
batch, conversation = _create_conversation_with_batch()
|
||||||
conversation.task_status = "processing"
|
conversation.task_status = "processing"
|
||||||
conversation.node_results = [
|
conversation.node_results = [
|
||||||
@@ -503,13 +545,9 @@ def test_chat_page_shows_top_context_and_recommended_prompts(client, db):
|
|||||||
|
|
||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "顶部对话上下文" in content
|
assert "顶部对话上下文" not in content
|
||||||
assert "当前流程类型" in content
|
assert "推荐提问模板" not in content
|
||||||
assert "registration" in content
|
assert "对话区与节点导航" in content
|
||||||
assert "当前审核阶段" in content
|
|
||||||
assert "处理中" in content
|
|
||||||
assert "当前最高风险等级" in content
|
|
||||||
assert "推荐提问模板" in content
|
|
||||||
assert "请汇总当前资料包的章节点、页数和目录覆盖情况" in content
|
assert "请汇总当前资料包的章节点、页数和目录覆盖情况" in content
|
||||||
assert "请给出当前资料包的高风险项、责任人和整改建议" in content
|
assert "请给出当前资料包的高风险项、责任人和整改建议" in content
|
||||||
|
|
||||||
@@ -538,19 +576,15 @@ def test_chat_page_explicitly_shows_batch_product_stage_and_export_context(clien
|
|||||||
|
|
||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
assert "Agent Workspace" not in content
|
||||||
assert f"批次:{batch.batch_id}" in content
|
assert f"批次:{batch.batch_id}" in content
|
||||||
assert f"产品:{batch.product_name}" in content
|
|
||||||
assert "阶段:处理中" in content
|
|
||||||
assert "批次编号" in content
|
|
||||||
assert batch.batch_id in content
|
|
||||||
assert "产品名称" in content
|
|
||||||
assert batch.product_name in content
|
assert batch.product_name in content
|
||||||
assert "当前审核阶段" in content
|
assert "批次编号" not in content
|
||||||
assert "处理中" in content
|
assert "当前审核阶段" not in content
|
||||||
assert "当前最高风险等级" in content
|
assert "最高风险等级" in content
|
||||||
assert ">高<" in content
|
assert "高" in content
|
||||||
assert "是否允许正式导出" in content
|
assert "是否允许正式导出" in content
|
||||||
assert ">否<" in content
|
assert "否" in content
|
||||||
|
|
||||||
|
|
||||||
def test_chat_page_shows_overview_card_from_conversation_summary(client, db):
|
def test_chat_page_shows_overview_card_from_conversation_summary(client, db):
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from agent_core.llm_provider import (
|
|||||||
create_llm_provider,
|
create_llm_provider,
|
||||||
get_runtime_llm_config,
|
get_runtime_llm_config,
|
||||||
)
|
)
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
|
||||||
def test_create_llm_provider_requires_api_key_for_openai_compatible():
|
def test_create_llm_provider_requires_api_key_for_openai_compatible():
|
||||||
@@ -72,6 +74,53 @@ def test_openai_compatible_provider_posts_chat_completion(monkeypatch):
|
|||||||
assert captured["headers"]["Authorization"] == "Bearer sk-test"
|
assert captured["headers"]["Authorization"] == "Bearer sk-test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_compatible_provider_falls_back_when_response_format_is_rejected(monkeypatch):
|
||||||
|
captured_bodies = []
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return b'{"choices":[{"message":{"content":"fallback ok"}}],"model":"demo-model"}'
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
body = request.data.decode("utf-8")
|
||||||
|
captured_bodies.append(body)
|
||||||
|
if len(captured_bodies) == 1:
|
||||||
|
raise HTTPError(
|
||||||
|
request.full_url,
|
||||||
|
400,
|
||||||
|
"Bad Request",
|
||||||
|
hdrs=None,
|
||||||
|
fp=BytesIO(b'{"error":{"message":"response_format is not supported"}}'),
|
||||||
|
)
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr("agent_core.llm_provider.urlopen", fake_urlopen)
|
||||||
|
provider = create_llm_provider(
|
||||||
|
{
|
||||||
|
"LLM_PROVIDER": "openai_compatible",
|
||||||
|
"LLM_API_KEY": "sk-test",
|
||||||
|
"LLM_BASE_URL": "https://api.siliconflow.cn/v1",
|
||||||
|
"LLM_MODEL": "demo-model",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = provider.generate(
|
||||||
|
[{"role": "user", "content": "hello"}],
|
||||||
|
response_format={"type": "json_object"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.success is True
|
||||||
|
assert response.content == "fallback ok"
|
||||||
|
assert '"response_format"' in captured_bodies[0]
|
||||||
|
assert '"response_format"' not in captured_bodies[1]
|
||||||
|
|
||||||
|
|
||||||
def test_embedding_provider_uses_openai_compatible_embeddings(monkeypatch):
|
def test_embedding_provider_uses_openai_compatible_embeddings(monkeypatch):
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user