Compare commits

..

7 Commits

11 changed files with 878 additions and 341 deletions

21
.env.siliconflow.example Normal file
View File

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

111
AGENTS.md
View File

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

@@ -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 ViewView 只做请求处理和页面渲染,复杂业务逻辑放到 `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 快速成型。

View File

@@ -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
@@ -74,6 +74,7 @@ class OpenAICompatibleProvider:
} }
if response_format: if response_format:
payload["response_format"] = response_format payload["response_format"] = response_format
try:
try: try:
data = _post_json( data = _post_json(
base_url=self.base_url, base_url=self.base_url,
@@ -81,6 +82,21 @@ class OpenAICompatibleProvider:
api_key=self.api_key, api_key=self.api_key,
payload=payload, 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

View File

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

View File

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

View File

@@ -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")),

View File

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

View File

@@ -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 %}
</div>
</article>
</section>
{% endif %}
<section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;"> .chat-layout:has(.history-toggle:checked) {
<div class="stack"> grid-template-columns: 54px minmax(760px, 1fr) 340px;
<article class="panel"> }
.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> <h2 class="section-title">会话历史</h2>
<p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称</p> <p class="section-copy">按会话快速切换资料包和知识库问答</p>
<ul class="detail-list"> </div>
<label class="button history-collapse" for="history-collapsed" title="收起或展开会话历史"> <span>收起</span></label>
</div>
<div class="history-content">
<ul class="detail-list history-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,36 +355,84 @@
</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 %}
@@ -99,26 +441,19 @@
<span>{{ checkbox.choice_label }}</span> <span>{{ checkbox.choice_label }}</span>
</label> </label>
{% empty %} {% empty %}
<div class="notice">当前资料包还没有可选文档</div> <div class="notice">未选择文档时Agent 会按问题检索当前知识库</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="button-row"> <div class="composer-actions">
<button type="submit">提交审核任务</button> <span class="scope-summary">默认检索知识库;聚焦输入框可选择文档范围。</span>
<button class="send-button" type="submit">发送问题</button>
</div>
</div> </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">
<div class="right-rail-head">
<div>
<h2 class="section-title">上传区</h2> <h2 class="section-title">上传区</h2>
<p class="section-copy">右侧保留资料包上传入口和当前会话的资料上下文</p> <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 %}

View File

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

View File

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