Compare commits
5 Commits
1f56247978
...
ccfa43645e
| Author | SHA1 | Date | |
|---|---|---|---|
| ccfa43645e | |||
| ef0a9ee13e | |||
| 2244b69d62 | |||
| 5ecf78c5d6 | |||
| e6fa738fd5 |
32
PRODUCT.md
Normal file
32
PRODUCT.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
注册资料准备、法规审核和项目管理人员,在资料整理、法规核查、问题整改和申报文件填表过程中使用。
|
||||
|
||||
## Product Purpose
|
||||
|
||||
DEMO-AGENT 是一个体外诊断试剂注册资料审核工作台。它把上传资料、文件汇总、法规规则核查、RAG 依据检索、风险预警、整改复核和申报表填充组织成可追溯的工作流。
|
||||
|
||||
## Brand Personality
|
||||
|
||||
克制、可信、清晰。界面应服务审核任务,优先呈现状态、证据和下一步动作。
|
||||
|
||||
## Anti-references
|
||||
|
||||
避免营销页式大标题、装饰性卡片堆叠、过度动画、过亮的渐变和不必要的视觉噪声。
|
||||
|
||||
## Design Principles
|
||||
|
||||
- 证据优先:每个结论都应能回到来源文件、规则或检索片段。
|
||||
- 状态清楚:批次、节点、风险、异常和导出结果要一眼可辨。
|
||||
- 操作克制:页面提供必要动作,不把审核工作做成复杂后台。
|
||||
- 复用现有模式:新增页面沿用当前工作台导航、面板、表格和按钮体系。
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
默认按 WCAG AA 方向处理对比度、键盘可访问和清晰标签。动效仅用于状态反馈,并尊重减少动态效果需求。
|
||||
@@ -2,10 +2,12 @@ from django.contrib import admin
|
||||
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
|
||||
from django.urls import include, path
|
||||
|
||||
from review_agent.views import attachment_manager, stream_chat, workspace
|
||||
from review_agent.views import attachment_manager, home_dashboard, knowledge_base_manager, stream_chat, workspace
|
||||
|
||||
urlpatterns = [
|
||||
path("", workspace, name="home"),
|
||||
path("", home_dashboard, name="home"),
|
||||
path("chat/", workspace, name="chat"),
|
||||
path("knowledge-base/", knowledge_base_manager, name="knowledge_base_manager"),
|
||||
path("attachments/", attachment_manager, name="attachment_manager"),
|
||||
path("", include("review_agent.urls")),
|
||||
path("chat/stream/", stream_chat, name="chat_stream"),
|
||||
|
||||
333
docs/7.汇报材料/架构搭建思路汇报稿.md
Normal file
333
docs/7.汇报材料/架构搭建思路汇报稿.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 架构搭建思路汇报稿(基于 Demo 版)
|
||||
|
||||
## 一、汇报开场
|
||||
|
||||
各位老师好,我本次 Demo 搭建的是一个面向体外诊断试剂注册资料准备与审核的智能体原型。
|
||||
|
||||
这个 Demo 的目标不是简单做文件上传、文件解析或问答,而是把注册资料审核中几个高频、耗时、容易出错的环节串成一个可追溯的智能工作流,包括文件目录汇总、法规完整性核查、产品关键信息提取、申报表自动填充,以及异常风险预警。
|
||||
|
||||
从整体定位上看,它更像是一个“注册资料审核助手”:用户上传一批申报资料后,系统能够先把资料包结构化,再对照法规规则做核查,之后输出风险清单和整改建议,并把抽取到的产品信息继续复用到申报模板填表中。
|
||||
|
||||
## 二、Demo 运行结果展示
|
||||
|
||||
本次 Demo 目前可以展示四类核心运行结果。
|
||||
|
||||
### 1. 文件目录汇总表
|
||||
|
||||
用户上传注册资料文件夹、散装文件或压缩包后,系统会自动完成附件固化、压缩包解压、文件扫描和页数统计。
|
||||
|
||||
最终系统会生成 Markdown 汇总报告和 Excel 文件明细表,主要字段包括:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 序号 | 文件在批次中的顺序 |
|
||||
| 目录层级 | 文件所在的相对目录 |
|
||||
| 文件名 | 原始文件名 |
|
||||
| 类型 | PDF、Word、Excel、PPT 等文件类型 |
|
||||
| 页数 | PDF 页数、Word 页数、PPT 幻灯片数或 Excel 工作表数 |
|
||||
| 路径 | 文件在批次工作目录中的相对路径 |
|
||||
| 状态 | success、failed、unsupported、uncertain 等 |
|
||||
| 重试次数 | 页数统计失败时的重试记录 |
|
||||
| 异常说明 | 不支持、不可确定或解析失败的原因 |
|
||||
|
||||
这个结果解决的是资料包进入系统后的第一步问题:先把杂乱的文件夹变成结构化的文件清单。
|
||||
|
||||
### 2. 法规完整性报告
|
||||
|
||||
在文件汇总结果基础上,系统会调用法规核查工作流,对照 NMPA 体外诊断试剂注册申报资料要求进行完整性检查。
|
||||
|
||||
Demo 中使用 `review_agent/regulatory_review/rules/nmpa_ivd_registration_v1.yaml` 作为结构化规则文件。规则文件中配置了附件 4 的资料要求,例如监管信息、综述资料、非临床资料、临床评价资料、说明书和标签样稿、质量管理体系文件等。
|
||||
|
||||
系统会检查是否缺少关键资料,例如:
|
||||
|
||||
| 检查对象 | 风险示例 |
|
||||
| --- | --- |
|
||||
| 注册申请表 | 缺失时生成阻断项或高风险 |
|
||||
| 符合性声明 | 缺失时生成阻断项 |
|
||||
| 产品技术要求 | 缺失时生成阻断项 |
|
||||
| 注册检验报告 | 缺失时生成阻断项 |
|
||||
| 产品说明书 | 缺失或章节不完整时生成高风险 |
|
||||
| 标签样稿 | 缺失时生成高风险 |
|
||||
| 临床评价资料 | 按适用条件生成条件性风险 |
|
||||
| 质量管理体系文件 | 缺失时生成高风险 |
|
||||
|
||||
最终输出包括 Markdown 法规核查报告、Excel 问题清单和 JSON 结构化结果包。
|
||||
|
||||
### 3. 信息提取对照表
|
||||
|
||||
系统会从说明书、产品技术要求、注册检验报告、申请表等文件中抽取产品关键信息。
|
||||
|
||||
当前 Demo 中重点抽取的字段包括:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| 产品名称 | 用于一致性核查和申报表填充 |
|
||||
| 型号规格 | 用于跨文件比对 |
|
||||
| 预期用途 | 用于法规适用条件和模板填充 |
|
||||
| 管理类别 | 用于法规判断 |
|
||||
| 分类编码 | 用于注册资料核对 |
|
||||
| 注册类型 | 用于模板选择和法规规则裁剪 |
|
||||
| 临床评价路径 | 用于临床资料适用性判断 |
|
||||
|
||||
每个抽取结果都会保留来源文件、来源角色、证据片段、抽取方式和置信度。这样后续生成的填表内容不是黑盒结果,而是能够回溯到原始文件。
|
||||
|
||||
### 4. 异常预警列表
|
||||
|
||||
系统会把完整性缺失、章节异常、字段冲突、文本抽取失败、页数不可确定、通知失败等问题统一沉淀为风险项。
|
||||
|
||||
风险等级目前分为:
|
||||
|
||||
| 风险等级 | 含义 |
|
||||
| --- | --- |
|
||||
| 阻断项 | 影响注册资料完整性或关键合规判断,需要优先整改 |
|
||||
| 高风险 | 可能影响审评,需要重点关注 |
|
||||
| 中风险 | 建议整改或补充说明 |
|
||||
| 低风险 | 轻微问题或格式提示 |
|
||||
| 提示项 | 不直接影响结论,但建议人工确认 |
|
||||
|
||||
例如,如果系统发现不同文件中的“产品名称”或“型号规格”不一致,会生成一致性风险;如果缺少注册检验报告,会生成阻断项,并给出补充注册检验报告的整改建议。
|
||||
|
||||
## 三、智能体整体工作流
|
||||
|
||||
结合当前 Demo 的实现,智能体整体工作流可以概括为:
|
||||
|
||||
```text
|
||||
文件扫描
|
||||
-> 目录汇总
|
||||
-> 法规匹配
|
||||
-> 信息提取
|
||||
-> 一致性核查
|
||||
-> 风险预警
|
||||
-> 报告导出
|
||||
-> 通知与整改复核
|
||||
```
|
||||
|
||||
从代码实现上看,系统拆成三条主链路。
|
||||
|
||||
### 1. 文件汇总链路
|
||||
|
||||
对应模块:`review_agent/file_summary`
|
||||
|
||||
主要流程为:
|
||||
|
||||
```text
|
||||
文件上传
|
||||
-> 附件固化
|
||||
-> 压缩包解压
|
||||
-> 文件扫描
|
||||
-> 页数统计
|
||||
-> 产品名识别
|
||||
-> 报告输出
|
||||
```
|
||||
|
||||
这个链路的核心作用是把原始资料包转换成结构化数据。系统会生成 `FileSummaryBatch` 和 `FileSummaryItem`,后续法规核查和自动填表都复用这套文件清单,不再重复扫描文件。
|
||||
|
||||
### 2. 法规核查链路
|
||||
|
||||
对应模块:`review_agent/regulatory_review`
|
||||
|
||||
主要流程为:
|
||||
|
||||
```text
|
||||
准备资料
|
||||
-> 适用条件确认
|
||||
-> 规则范围裁剪
|
||||
-> 完整性核查
|
||||
-> 文本抽取
|
||||
-> 章节核查
|
||||
-> 一致性核查
|
||||
-> 风险评估
|
||||
-> 报告输出
|
||||
```
|
||||
|
||||
这条链路的核心设计原则是:规则优先,RAG 补依据,LLM 做辅助。
|
||||
|
||||
也就是说,法规结论不直接交给大模型自由判断,而是优先由结构化规则文件决定;RAG 负责检索法规依据和原文片段;LLM 主要用于低置信度字段抽取、自然语言条件解析和结果复核。
|
||||
|
||||
### 3. 自动填表链路
|
||||
|
||||
对应模块:`review_agent/application_form_fill`
|
||||
|
||||
主要流程为:
|
||||
|
||||
```text
|
||||
准备资料
|
||||
-> 模板选择
|
||||
-> 模板复制
|
||||
-> 字段抽取
|
||||
-> 冲突归并
|
||||
-> Word 填写
|
||||
-> 追溯清单导出
|
||||
-> 结果通知
|
||||
```
|
||||
|
||||
这条链路会复用前面抽取到的产品信息,自动选择申报模板,并将字段填入 Word 模板。对于冲突字段,Demo 中采用“说明书优先”的策略,同时在结果中保留冲突摘要和来源追溯。
|
||||
|
||||
## 四、Demo 实际调用的关键工具和库
|
||||
|
||||
本 Demo 在工具选型上以轻量、可本地运行、可解释、便于测试为原则。
|
||||
|
||||
### 1. 文件解析类工具
|
||||
|
||||
| 工具/库 | Demo 中的用途 | 选用理由 |
|
||||
| --- | --- | --- |
|
||||
| `pypdf` | PDF 页数统计和文本抽取 | 轻量、安装简单,适合 Demo 阶段快速处理 PDF |
|
||||
| `python-docx` | DOCX 文本读取、Word 模板填充 | 可读取段落和表格,也能写入 Word 模板 |
|
||||
| `python-pptx` | PPTX 幻灯片数量统计和文本读取 | 适合统计幻灯片数量和抽取文本 |
|
||||
| `openpyxl` | XLSX 工作表统计、Excel 报告导出 | 同时支持读取和生成 Excel |
|
||||
| `xlrd` | 旧版 XLS 文件读取 | 补充对历史 Excel 格式的支持 |
|
||||
| `olefile` | 判断老 Office 文件 OLE 结构 | 用于 doc、xls、ppt 等老格式的兜底识别 |
|
||||
| `py7zr` | 7z 压缩包解压 | 支持常见资料包压缩格式 |
|
||||
| Python `zipfile` | ZIP 压缩包解压 | 标准库能力,无额外依赖 |
|
||||
|
||||
Demo 中没有选择重型 OCR 或复杂版式引擎,是因为当前阶段重点是打通审核链路和规则闭环。对于扫描件、图片 PDF、复杂版式 PDF,后续可以再接入 OCR 和更强的版式解析能力。
|
||||
|
||||
### 2. 规则和正则
|
||||
|
||||
系统使用 YAML 维护法规规则,例如 `nmpa_ivd_registration_v1.yaml`。每条规则包含规则编码、附件 4 编码、标题、资料类型、风险等级、匹配关键词、整改建议和 RAG 检索查询词。
|
||||
|
||||
正则表达式用于抽取结构化字段,例如:
|
||||
|
||||
```text
|
||||
产品名称:xxx
|
||||
型号规格:xxx
|
||||
预期用途:xxx
|
||||
管理类别:xxx
|
||||
分类编码:xxx
|
||||
```
|
||||
|
||||
选用规则和正则的原因是:这类注册资料中有大量固定标题和固定字段,使用确定性规则可以提高可解释性,也便于定位问题来源。
|
||||
|
||||
### 3. RAG 和向量检索
|
||||
|
||||
Demo 使用 ChromaDB 构建本地法规 RAG 索引。法规原文材料会被切分为文本块,并保存来源文件、chunk 编号等元数据。
|
||||
|
||||
向量 embedding 支持两种模式:
|
||||
|
||||
| 模式 | 用途 |
|
||||
| --- | --- |
|
||||
| SiliconFlow embedding | 用于真实语义检索 |
|
||||
| deterministic/local embedding | 用于测试和 dry run |
|
||||
|
||||
RAG 在系统中的定位不是直接判断合规,而是为风险问题补充法规依据。例如完整性规则已经判断“缺少注册检验报告”,RAG 再检索相关法规条款,输出来源文件和依据片段,增强报告的可解释性。
|
||||
|
||||
### 4. LLM 调用
|
||||
|
||||
LLM 在 Demo 中主要承担辅助角色,包括:
|
||||
|
||||
| 场景 | LLM 作用 |
|
||||
| --- | --- |
|
||||
| 自然语言适用条件解析 | 将用户输入转换为结构化字段 |
|
||||
| 低置信度字段抽取 | 正则抽取不足时补充结构化 JSON |
|
||||
| 工作流结果复核 | 对中间结果做总结和校验 |
|
||||
| 整改建议润色 | 在规则模板基础上优化表达 |
|
||||
|
||||
风险等级、法规结论和完整性判断不直接交给 LLM 决定,而是由规则引擎和风险评估服务控制。
|
||||
|
||||
### 5. 工作流和状态管理
|
||||
|
||||
系统使用 Django ORM 保存批次、节点、事件和导出文件。
|
||||
|
||||
关键模型包括:
|
||||
|
||||
| 模型 | 作用 |
|
||||
| --- | --- |
|
||||
| `FileSummaryBatch` | 文件汇总批次 |
|
||||
| `FileSummaryItem` | 文件明细 |
|
||||
| `RegulatoryReviewBatch` | 法规核查批次 |
|
||||
| `RegulatoryIssue` | 法规问题和风险项 |
|
||||
| `RegulatoryArtifact` | 法规核查过程产物 |
|
||||
| `ApplicationFormFillBatch` | 自动填表批次 |
|
||||
| `WorkflowNodeRun` | 工作流节点状态 |
|
||||
| `WorkflowEvent` | SSE 事件和进度记录 |
|
||||
| `ExportedSummaryFile` | Markdown、Excel、JSON、Word 等导出文件 |
|
||||
|
||||
前端通过 SSE 事件实时展示工作流卡片状态,使用户能够看到每个节点是否正在执行、是否成功、是否等待确认或失败。
|
||||
|
||||
## 五、难点规则处理方式
|
||||
|
||||
### 1. 文件完整性检测
|
||||
|
||||
文件完整性检测的难点在于:注册资料不是固定文件名,企业可能用不同命名方式组织材料。
|
||||
|
||||
Demo 的处理方式是使用多层匹配:
|
||||
|
||||
```text
|
||||
规则要求项
|
||||
-> 文件名关键词匹配
|
||||
-> 相对路径匹配
|
||||
-> 目录层级匹配
|
||||
-> 必要时结合首页文本和字段候选
|
||||
```
|
||||
|
||||
例如规则中要求“注册检验报告”,系统不仅查找文件名中是否包含“注册检验报告”,也会查找路径和目录中是否包含“检验报告”“检测报告”等别名。
|
||||
|
||||
如果没有匹配到文件,系统会生成 `Finding`,再由风险评估服务转换为 `RegulatoryIssue`。这样完整性问题既能被结构化记录,也能进入最终风险报告。
|
||||
|
||||
### 2. 信息一致性核查
|
||||
|
||||
一致性核查的难点在于:同一个字段可能散落在说明书、注册检验报告、产品技术要求、申请表等多个文件中。
|
||||
|
||||
Demo 的处理方式是:
|
||||
|
||||
```text
|
||||
文本抽取
|
||||
-> 字段正则识别
|
||||
-> 同字段归并
|
||||
-> 不同取值比对
|
||||
-> 生成一致性风险
|
||||
```
|
||||
|
||||
例如系统会从多个文件中抽取“产品名称”“型号规格”“预期用途”等字段。如果同一字段出现多个不同值,系统会生成高风险问题,并在证据中记录每个取值对应的来源文件。
|
||||
|
||||
这类结果可以直接辅助人工审核人员定位冲突来源。
|
||||
|
||||
### 3. 法规条款匹配
|
||||
|
||||
法规条款匹配的难点在于:法规原文长、条款多,直接让大模型判断容易不稳定,纯规则又缺少解释能力。
|
||||
|
||||
Demo 采用“双层法规能力”:
|
||||
|
||||
| 层级 | 职责 |
|
||||
| --- | --- |
|
||||
| 结构化规则库 | 负责判断应有哪些文件、哪些章节、哪些字段,以及风险等级 |
|
||||
| RAG 法规依据索引 | 负责检索法规原文片段,补充依据说明 |
|
||||
|
||||
这种设计的好处是:判断逻辑稳定,报告解释充分,后续规则也可以由法规人员维护。
|
||||
|
||||
### 4. 过程留痕和可追溯
|
||||
|
||||
审核类系统不能只输出一个结论,还必须说明结论从哪里来。
|
||||
|
||||
Demo 中对关键过程都做了留痕:
|
||||
|
||||
| 过程 | 留痕内容 |
|
||||
| --- | --- |
|
||||
| 文件汇总 | 文件路径、页数、统计状态、异常说明 |
|
||||
| 文本抽取 | 文本 hash、首页文本、章节候选、字段候选 |
|
||||
| 完整性核查 | 规则编码、匹配关键词、命中文件或缺失证据 |
|
||||
| 一致性核查 | 字段值、来源文件、冲突取值 |
|
||||
| RAG 检索 | 法规来源、片段文本、检索分数 |
|
||||
| 报告导出 | Markdown、Excel、JSON 结果包 |
|
||||
| 自动填表 | 字段来源、冲突摘要、追溯清单 |
|
||||
|
||||
这保证了 Demo 输出的结果不是一次性回答,而是可以复核、下载、整改和继续追踪的过程资产。
|
||||
|
||||
## 六、总结
|
||||
|
||||
整体来看,本 Demo 的架构搭建思路可以概括为:
|
||||
|
||||
```text
|
||||
先结构化资料
|
||||
再匹配法规
|
||||
再抽取字段
|
||||
再核查一致性
|
||||
再输出风险和报告
|
||||
最后支持填表和整改闭环
|
||||
```
|
||||
|
||||
它体现的是一个“资料输入、规则判断、证据追溯、风险输出、整改闭环”的智能体原型。
|
||||
|
||||
当前 Demo 已经完成了文件汇总、法规完整性核查、信息抽取、风险预警、报告导出和自动填表主链路。后续如果继续增强,可以重点补充 OCR、扫描件识别、复杂 PDF 版式解析、规则后台维护、人工确认界面、飞书真实消息闭环,以及更完整的多智能体编排能力。
|
||||
|
||||
最终希望这个智能体能够从一个 Demo 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。
|
||||
@@ -148,6 +148,14 @@ def conversation_list(request):
|
||||
)
|
||||
|
||||
|
||||
@require_http_methods(["DELETE"])
|
||||
@login_required
|
||||
def conversation_detail(request, conversation_id: int):
|
||||
conversation = _conversation_for_user(request.user, conversation_id)
|
||||
conversation.delete()
|
||||
return JsonResponse({"ok": True, "conversation_id": conversation_id})
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def attachment_download(request, conversation_id: int, attachment_id: int):
|
||||
|
||||
397
review_agent/knowledge_base.py
Normal file
397
review_agent/knowledge_base.py
Normal file
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
|
||||
from review_agent.models import KnowledgeBaseDocument
|
||||
from review_agent.regulatory_review.services.rag_citation import RagIndexUnavailable, retrieve_citations
|
||||
from review_agent.regulatory_review.services.rag_embedding import DeterministicEmbeddingProvider
|
||||
from review_agent.regulatory_review.services.rag_index import chunk_text, extract_text_from_path
|
||||
from review_agent.regulatory_review.services.rule_loader import DEFAULT_RULE_PATH, compute_file_sha256, load_rule_file
|
||||
|
||||
|
||||
SUPPORTED_SOURCE_SUFFIXES = {".doc", ".docx", ".pdf", ".txt", ".md", ".pptx", ".xlsx"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChromaCollectionState:
|
||||
exists: bool
|
||||
count: int = 0
|
||||
error_message: str = ""
|
||||
sample_metadatas: list[dict[str, Any]] | None = None
|
||||
source_chunk_counts: dict[str, int] | None = None
|
||||
|
||||
|
||||
def build_knowledge_base_context() -> dict[str, Any]:
|
||||
rule_info = _rule_info()
|
||||
source_dir = Path(settings.BASE_DIR) / str(rule_info.get("source_material_dir") or "docs/0.原始材料")
|
||||
sources = list_source_documents(source_dir)
|
||||
collection = get_chroma_collection_state()
|
||||
return {
|
||||
"name": "NMPA IVD 注册资料法规库",
|
||||
"description": "用于体外诊断试剂注册资料法规核查的结构化规则和 RAG 依据检索。",
|
||||
"provider": settings.REGULATORY_RAG_PROVIDER,
|
||||
"collection_name": settings.REGULATORY_RAG_COLLECTION,
|
||||
"chroma_path": settings.REGULATORY_RAG_CHROMA_PATH,
|
||||
"rule": rule_info,
|
||||
"source_dir": str(source_dir),
|
||||
"sources": sources,
|
||||
"source_count": len(sources),
|
||||
"supported_source_count": sum(1 for item in sources if item["supported"]),
|
||||
"collection": {
|
||||
"exists": collection.exists,
|
||||
"count": collection.count,
|
||||
"error_message": collection.error_message,
|
||||
"sample_metadatas": collection.sample_metadatas or [],
|
||||
},
|
||||
"status": _status_label(collection),
|
||||
"build_commands": [
|
||||
"python manage.py regulatory_rag_build --provider deterministic",
|
||||
"python manage.py regulatory_rag_build --provider siliconflow",
|
||||
],
|
||||
"managed_documents": [],
|
||||
}
|
||||
|
||||
|
||||
def build_knowledge_base_context_for_user(user) -> dict[str, Any]:
|
||||
context = build_knowledge_base_context()
|
||||
documents = list_documents_for_user(user)
|
||||
context["managed_documents"] = documents
|
||||
context["managed_document_count"] = len(documents)
|
||||
context["active_managed_document_count"] = sum(1 for item in documents if item["is_active"])
|
||||
return context
|
||||
|
||||
|
||||
def list_source_documents(source_dir: Path) -> list[dict[str, Any]]:
|
||||
if not source_dir.exists():
|
||||
return []
|
||||
collection = get_chroma_collection_state()
|
||||
source_chunk_counts = collection.source_chunk_counts or {}
|
||||
documents: list[dict[str, Any]] = []
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
suffix = path.suffix.lower()
|
||||
relative_path = str(path.relative_to(source_dir))
|
||||
indexed_chunk_count = source_chunk_counts.get(relative_path, 0)
|
||||
documents.append(
|
||||
{
|
||||
"name": path.name,
|
||||
"relative_path": relative_path,
|
||||
"suffix": suffix.lstrip(".") or "unknown",
|
||||
"size": path.stat().st_size,
|
||||
"supported": suffix in SUPPORTED_SOURCE_SUFFIXES,
|
||||
"indexed": indexed_chunk_count > 0,
|
||||
"indexed_chunk_count": indexed_chunk_count,
|
||||
"indexed_label": f"已入库 {indexed_chunk_count} 片" if indexed_chunk_count else "未入库",
|
||||
}
|
||||
)
|
||||
return documents
|
||||
|
||||
|
||||
def search_knowledge_base(query: str, *, n_results: int = 3) -> dict[str, Any]:
|
||||
normalized = (query or "").strip()
|
||||
if not normalized:
|
||||
return {"query": normalized, "results": [], "error_message": "请输入检索问题。"}
|
||||
try:
|
||||
results = retrieve_citations(
|
||||
normalized,
|
||||
embedding_provider=DeterministicEmbeddingProvider(),
|
||||
n_results=n_results,
|
||||
)
|
||||
except RagIndexUnavailable as exc:
|
||||
return {"query": normalized, "results": [], "error_message": str(exc)}
|
||||
except Exception as exc:
|
||||
return {"query": normalized, "results": [], "error_message": f"检索失败:{exc}"}
|
||||
return {"query": normalized, "results": filter_active_knowledge_results(results), "error_message": ""}
|
||||
|
||||
|
||||
def list_documents_for_user(user) -> list[dict[str, Any]]:
|
||||
return [
|
||||
serialize_document(document)
|
||||
for document in KnowledgeBaseDocument.objects.filter(user=user).exclude(status=KnowledgeBaseDocument.Status.DELETED)
|
||||
]
|
||||
|
||||
|
||||
def create_document_from_upload(
|
||||
*,
|
||||
user,
|
||||
uploaded_file: UploadedFile,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
is_active: bool = True,
|
||||
) -> KnowledgeBaseDocument:
|
||||
root = Path(settings.MEDIA_ROOT) / "knowledge_base" / "users" / str(user.pk)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
target = _unique_target_path(root, uploaded_file.name)
|
||||
with target.open("wb") as handle:
|
||||
for chunk in uploaded_file.chunks():
|
||||
handle.write(chunk)
|
||||
status = KnowledgeBaseDocument.Status.ACTIVE if is_active else KnowledgeBaseDocument.Status.DISABLED
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name=(display_name or uploaded_file.name).strip(),
|
||||
original_name=uploaded_file.name,
|
||||
storage_path=str(target),
|
||||
file_size=target.stat().st_size,
|
||||
content_type=getattr(uploaded_file, "content_type", "") or "",
|
||||
description=description.strip(),
|
||||
status=status,
|
||||
is_active=is_active,
|
||||
)
|
||||
if is_active:
|
||||
index_managed_document(document)
|
||||
return document
|
||||
|
||||
|
||||
def update_document(document: KnowledgeBaseDocument, payload: dict[str, Any]) -> KnowledgeBaseDocument:
|
||||
update_fields = []
|
||||
if "display_name" in payload:
|
||||
document.display_name = str(payload.get("display_name") or "").strip() or document.original_name
|
||||
update_fields.append("display_name")
|
||||
if "description" in payload:
|
||||
document.description = str(payload.get("description") or "").strip()
|
||||
update_fields.append("description")
|
||||
if "is_active" in payload:
|
||||
document.is_active = bool(payload.get("is_active"))
|
||||
document.status = KnowledgeBaseDocument.Status.ACTIVE if document.is_active else KnowledgeBaseDocument.Status.DISABLED
|
||||
update_fields.extend(["is_active", "status"])
|
||||
if update_fields:
|
||||
update_fields.append("updated_at")
|
||||
document.save(update_fields=update_fields)
|
||||
return document
|
||||
|
||||
|
||||
def delete_document(document: KnowledgeBaseDocument) -> KnowledgeBaseDocument:
|
||||
remove_managed_document_from_index(document)
|
||||
document.status = KnowledgeBaseDocument.Status.DELETED
|
||||
document.is_active = False
|
||||
document.indexed_chunk_count = 0
|
||||
document.metadata = {**(document.metadata or {}), "index_status": "deleted", "index_error": ""}
|
||||
document.save(update_fields=["status", "is_active", "indexed_chunk_count", "metadata", "updated_at"])
|
||||
return document
|
||||
|
||||
|
||||
def serialize_document(document: KnowledgeBaseDocument) -> dict[str, Any]:
|
||||
indexed_label = f"已入库 {document.indexed_chunk_count} 片" if document.indexed_chunk_count else "未入库"
|
||||
return {
|
||||
"id": document.pk,
|
||||
"display_name": document.display_name,
|
||||
"original_name": document.original_name,
|
||||
"description": document.description,
|
||||
"file_size": document.file_size,
|
||||
"content_type": document.content_type,
|
||||
"status": document.status,
|
||||
"is_active": document.is_active,
|
||||
"indexed_chunk_count": document.indexed_chunk_count,
|
||||
"indexed_label": indexed_label,
|
||||
"created_at": document.created_at.isoformat() if document.created_at else "",
|
||||
"updated_at": document.updated_at.isoformat() if document.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
def index_managed_document(document: KnowledgeBaseDocument) -> int:
|
||||
path = Path(document.storage_path)
|
||||
if not path.is_absolute():
|
||||
path = Path(settings.MEDIA_ROOT) / document.storage_path
|
||||
try:
|
||||
text = extract_text_from_path(path)
|
||||
source = f"用户知识库/{document.user_id}/{document.pk}/{document.original_name}"
|
||||
chunks = chunk_text(text, source=source)
|
||||
if not chunks:
|
||||
document.indexed_chunk_count = 0
|
||||
document.metadata = {**(document.metadata or {}), "index_status": "empty", "index_error": ""}
|
||||
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
|
||||
return 0
|
||||
collection = _load_chroma_collection()
|
||||
texts = [chunk.text for chunk in chunks]
|
||||
embeddings = DeterministicEmbeddingProvider()(texts)
|
||||
ids = [
|
||||
hashlib.sha256(f"managed:{document.pk}:{chunk.metadata['chunk_index']}".encode("utf-8")).hexdigest()
|
||||
for chunk in chunks
|
||||
]
|
||||
metadatas = [
|
||||
{
|
||||
**chunk.metadata,
|
||||
"source_type": "managed_document",
|
||||
"document_id": document.pk,
|
||||
"user_id": document.user_id,
|
||||
"original_name": document.original_name,
|
||||
}
|
||||
for chunk in chunks
|
||||
]
|
||||
collection.upsert(ids=ids, documents=texts, metadatas=metadatas, embeddings=embeddings)
|
||||
document.indexed_chunk_count = len(chunks)
|
||||
document.metadata = {**(document.metadata or {}), "index_status": "indexed", "index_error": ""}
|
||||
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
|
||||
return len(chunks)
|
||||
except Exception as exc:
|
||||
document.indexed_chunk_count = 0
|
||||
document.metadata = {**(document.metadata or {}), "index_status": "failed", "index_error": str(exc)}
|
||||
document.save(update_fields=["indexed_chunk_count", "metadata", "updated_at"])
|
||||
return 0
|
||||
|
||||
|
||||
def remove_managed_document_from_index(document: KnowledgeBaseDocument) -> None:
|
||||
try:
|
||||
collection = _load_chroma_collection()
|
||||
collection.delete(where={"document_id": document.pk})
|
||||
except Exception as exc:
|
||||
document.metadata = {**(document.metadata or {}), "index_delete_error": str(exc)}
|
||||
|
||||
|
||||
def filter_active_knowledge_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
managed_ids = {
|
||||
int((item.get("metadata") or {}).get("document_id"))
|
||||
for item in results
|
||||
if (item.get("metadata") or {}).get("source_type") == "managed_document"
|
||||
and (item.get("metadata") or {}).get("document_id") is not None
|
||||
}
|
||||
if not managed_ids:
|
||||
return results
|
||||
active_ids = set(
|
||||
KnowledgeBaseDocument.objects.filter(
|
||||
pk__in=managed_ids,
|
||||
status=KnowledgeBaseDocument.Status.ACTIVE,
|
||||
is_active=True,
|
||||
).values_list("pk", flat=True)
|
||||
)
|
||||
filtered = []
|
||||
for item in results:
|
||||
metadata = item.get("metadata") or {}
|
||||
if metadata.get("source_type") != "managed_document":
|
||||
filtered.append(item)
|
||||
continue
|
||||
try:
|
||||
document_id = int(metadata.get("document_id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if document_id in active_ids:
|
||||
filtered.append(item)
|
||||
return filtered
|
||||
|
||||
|
||||
def _load_chroma_collection():
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("chromadb 未安装。") from exc
|
||||
persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH)
|
||||
persist_path.mkdir(parents=True, exist_ok=True)
|
||||
return chromadb.PersistentClient(path=str(persist_path)).get_or_create_collection(
|
||||
settings.REGULATORY_RAG_COLLECTION
|
||||
)
|
||||
|
||||
|
||||
def get_chroma_collection_state() -> ChromaCollectionState:
|
||||
persist_path = Path(settings.REGULATORY_RAG_CHROMA_PATH)
|
||||
if not persist_path.exists():
|
||||
return ChromaCollectionState(exists=False, error_message="法规 RAG 索引目录不存在。")
|
||||
try:
|
||||
import chromadb
|
||||
except ImportError:
|
||||
return ChromaCollectionState(exists=False, error_message="chromadb 未安装。")
|
||||
try:
|
||||
collection = chromadb.PersistentClient(path=str(persist_path)).get_collection(settings.REGULATORY_RAG_COLLECTION)
|
||||
count = collection.count()
|
||||
metadatas = _load_collection_metadatas(collection, count)
|
||||
return ChromaCollectionState(
|
||||
exists=True,
|
||||
count=count,
|
||||
sample_metadatas=metadatas[:10],
|
||||
source_chunk_counts=_count_chunks_by_source(metadatas),
|
||||
)
|
||||
except Exception as exc:
|
||||
return ChromaCollectionState(exists=False, error_message=f"法规 RAG collection 不可用:{exc}")
|
||||
|
||||
|
||||
def _load_collection_metadatas(collection, count: int) -> list[dict[str, Any]]:
|
||||
metadatas: list[dict[str, Any]] = []
|
||||
if count <= 0:
|
||||
return metadatas
|
||||
page_size = 500
|
||||
for offset in range(0, count, page_size):
|
||||
payload = collection.get(
|
||||
include=["metadatas"],
|
||||
limit=min(page_size, count - offset),
|
||||
offset=offset,
|
||||
)
|
||||
metadatas.extend(payload.get("metadatas") or [])
|
||||
return metadatas
|
||||
|
||||
|
||||
def _count_chunks_by_source(metadatas: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for metadata in metadatas:
|
||||
source = str((metadata or {}).get("source") or "")
|
||||
if source:
|
||||
counts[source] = counts.get(source, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _rule_info() -> dict[str, Any]:
|
||||
try:
|
||||
payload = load_rule_file()
|
||||
requirements = payload.get("requirements") or []
|
||||
severity_counts: dict[str, int] = {}
|
||||
chapter_codes = set()
|
||||
for requirement in requirements:
|
||||
severity = str(requirement.get("severity") or "unknown")
|
||||
severity_counts[severity] = severity_counts.get(severity, 0) + 1
|
||||
attachment4_code = str(requirement.get("attachment4_code") or "")
|
||||
if attachment4_code:
|
||||
chapter_codes.add(attachment4_code.split(".")[0])
|
||||
return {
|
||||
"status": "ok",
|
||||
"code": payload.get("code", ""),
|
||||
"name": payload.get("name", ""),
|
||||
"path": str(DEFAULT_RULE_PATH),
|
||||
"hash": compute_file_sha256(DEFAULT_RULE_PATH),
|
||||
"rag_collection": payload.get("rag_collection", ""),
|
||||
"source_material_dir": payload.get("source_material_dir", "docs/0.原始材料"),
|
||||
"requirement_count": len(requirements),
|
||||
"chapter_count": len(chapter_codes),
|
||||
"severity_counts": severity_counts,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "failed",
|
||||
"code": "",
|
||||
"name": "",
|
||||
"path": str(DEFAULT_RULE_PATH),
|
||||
"hash": "",
|
||||
"rag_collection": "",
|
||||
"source_material_dir": "docs/0.原始材料",
|
||||
"requirement_count": 0,
|
||||
"chapter_count": 0,
|
||||
"severity_counts": {},
|
||||
"error_message": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _status_label(collection: ChromaCollectionState) -> dict[str, str]:
|
||||
if not collection.exists:
|
||||
return {"code": "missing", "label": "未构建", "message": collection.error_message}
|
||||
if collection.count < 20:
|
||||
return {"code": "thin", "label": "索引过少", "message": "RAG 能力已打通,但当前索引内容较少,建议补齐材料后重建。"}
|
||||
return {"code": "ready", "label": "可用", "message": "RAG 索引已构建,可用于法规依据辅助检索。"}
|
||||
|
||||
|
||||
def _unique_target_path(root: Path, original_name: str) -> Path:
|
||||
safe_name = Path(original_name).name or "document"
|
||||
target = root / safe_name
|
||||
if not target.exists():
|
||||
return target
|
||||
stem = target.stem
|
||||
suffix = target.suffix
|
||||
index = 2
|
||||
while True:
|
||||
candidate = root / f"{stem}-{index}{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
index += 1
|
||||
@@ -16,7 +16,7 @@ class LLMRequestError(RuntimeError):
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_reply(conversation, user_message: str) -> str:
|
||||
def generate_reply(conversation, user_message: str, knowledge_context: str = "") -> str:
|
||||
"""Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text."""
|
||||
|
||||
if not settings.LLM_API_KEY:
|
||||
@@ -26,7 +26,7 @@ def generate_reply(conversation, user_message: str) -> str:
|
||||
|
||||
payload = {
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": build_messages(conversation, user_message),
|
||||
"messages": build_messages(conversation, user_message, knowledge_context=knowledge_context),
|
||||
"temperature": 0.3,
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@@ -98,7 +98,7 @@ def generate_completion(messages: list[dict[str, str]], *, temperature: float =
|
||||
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
|
||||
|
||||
|
||||
def stream_reply(conversation, user_message: str):
|
||||
def stream_reply(conversation, user_message: str, knowledge_context: str = ""):
|
||||
"""Streams incremental assistant text from the SiliconFlow chat endpoint."""
|
||||
|
||||
if not settings.LLM_API_KEY:
|
||||
@@ -108,7 +108,7 @@ def stream_reply(conversation, user_message: str):
|
||||
|
||||
payload = {
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": build_messages(conversation, user_message),
|
||||
"messages": build_messages(conversation, user_message, knowledge_context=knowledge_context),
|
||||
"temperature": 0.3,
|
||||
"stream": True,
|
||||
}
|
||||
@@ -153,10 +153,21 @@ def stream_reply(conversation, user_message: str):
|
||||
raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc
|
||||
|
||||
|
||||
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]:
|
||||
def build_messages(conversation, latest_user_message: str, knowledge_context: str = "") -> list[dict[str, str]]:
|
||||
"""Builds system and conversation history messages for the provider call."""
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt()}]
|
||||
if knowledge_context.strip():
|
||||
messages.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"以下是全局知识库检索到的材料片段。回答用户时优先依据这些片段;"
|
||||
"如果片段不足以支持结论,请明确说明信息不足,不要编造。\n\n"
|
||||
f"{knowledge_context.strip()}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
for message in conversation.messages.all():
|
||||
messages.append({"role": message.role, "content": message.content})
|
||||
|
||||
80
review_agent/migrations/0008_knowledgebasedocument.py
Normal file
80
review_agent/migrations/0008_knowledgebasedocument.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-08 11:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("review_agent", "0007_feishuaccesstokencache_feishuusermapping_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="KnowledgeBaseDocument",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("display_name", models.CharField(max_length=255)),
|
||||
("original_name", models.CharField(max_length=255)),
|
||||
("storage_path", models.CharField(max_length=500)),
|
||||
("file_size", models.BigIntegerField(default=0)),
|
||||
(
|
||||
"content_type",
|
||||
models.CharField(blank=True, default="", max_length=120),
|
||||
),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("active", "启用"),
|
||||
("disabled", "停用"),
|
||||
("deleted", "已删除"),
|
||||
],
|
||||
default="active",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("indexed_chunk_count", models.PositiveIntegerField(default=0)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="knowledge_base_documents",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "ra_knowledge_base_document",
|
||||
"ordering": ["-updated_at", "-id"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["user", "status"], name="idx_ra_kb_doc_user_status"
|
||||
),
|
||||
models.Index(
|
||||
fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"
|
||||
),
|
||||
models.Index(
|
||||
fields=["status", "updated_at"],
|
||||
name="idx_ra_kb_doc_status_updated",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -399,6 +399,45 @@ class RegulatoryRuleVersion(models.Model):
|
||||
return self.code
|
||||
|
||||
|
||||
class KnowledgeBaseDocument(models.Model):
|
||||
"""Stores user-managed knowledge-base source documents."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "启用"
|
||||
DISABLED = "disabled", "停用"
|
||||
DELETED = "deleted", "已删除"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="knowledge_base_documents",
|
||||
)
|
||||
display_name = models.CharField(max_length=255)
|
||||
original_name = models.CharField(max_length=255)
|
||||
storage_path = models.CharField(max_length=500)
|
||||
file_size = models.BigIntegerField(default=0)
|
||||
content_type = models.CharField(max_length=120, blank=True, default="")
|
||||
description = models.TextField(blank=True, default="")
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
|
||||
is_active = models.BooleanField(default=True)
|
||||
indexed_chunk_count = models.PositiveIntegerField(default=0)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "ra_knowledge_base_document"
|
||||
ordering = ["-updated_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "status"], name="idx_ra_kb_doc_user_status"),
|
||||
models.Index(fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"),
|
||||
models.Index(fields=["status", "updated_at"], name="idx_ra_kb_doc_status_updated"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.display_name
|
||||
|
||||
|
||||
class ApplicationFormFillBatch(models.Model):
|
||||
"""Tracks one application-form auto-fill workflow run."""
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ def retrieve_citations(
|
||||
"source": metadata.get("source", "法规材料"),
|
||||
"text": document,
|
||||
"score": distance,
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
return citations
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
@@ -102,6 +103,33 @@ def _iter_docx_blocks(document):
|
||||
|
||||
|
||||
def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
|
||||
cached = _cached_docx_path(path)
|
||||
if cached.exists():
|
||||
return extract_text_from_path(cached)
|
||||
try:
|
||||
return _extract_legacy_doc_with_libreoffice_convert(path)
|
||||
except RuntimeError as libreoffice_error:
|
||||
try:
|
||||
return _extract_legacy_doc_with_word_com(path)
|
||||
except RuntimeError as word_error:
|
||||
try:
|
||||
return _extract_legacy_doc_with_powershell_word_com(path)
|
||||
except RuntimeError as powershell_error:
|
||||
raise RuntimeError(
|
||||
f"无法转换法规 .doc 材料:{path.name};"
|
||||
f"LibreOffice 错误:{libreoffice_error};"
|
||||
f"Word COM 错误:{word_error};"
|
||||
f"PowerShell Word COM 错误:{powershell_error}"
|
||||
) from powershell_error
|
||||
|
||||
|
||||
def _cached_docx_path(path: Path) -> Path:
|
||||
digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
|
||||
cache_dir = Path(settings.MEDIA_ROOT) / "regulatory_review" / "docx_cache"
|
||||
return cache_dir / f"{path.stem}-{digest}.docx"
|
||||
|
||||
|
||||
def _extract_legacy_doc_with_libreoffice_convert(path: Path) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target_dir = Path(tmp_dir)
|
||||
try:
|
||||
@@ -128,6 +156,72 @@ def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
|
||||
return extract_text_from_path(converted)
|
||||
|
||||
|
||||
def _extract_legacy_doc_with_word_com(path: Path) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target_dir = Path(tmp_dir)
|
||||
converted = target_dir / f"{path.stem}.docx"
|
||||
word = None
|
||||
try:
|
||||
import pythoncom
|
||||
import win32com.client
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
word.Visible = False
|
||||
document = word.Documents.Open(str(path.resolve()), ReadOnly=True)
|
||||
document.SaveAs(str(converted.resolve()), FileFormat=16)
|
||||
document.Close(False)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"无法通过 Word COM 转换法规 .doc 材料:{path.name}") from exc
|
||||
finally:
|
||||
if word is not None:
|
||||
try:
|
||||
word.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pythoncom.CoUninitialize()
|
||||
except Exception:
|
||||
pass
|
||||
if not converted.exists():
|
||||
raise RuntimeError(f"Word COM 未生成 docx:{path.name}")
|
||||
return extract_text_from_path(converted)
|
||||
|
||||
|
||||
def _extract_legacy_doc_with_powershell_word_com(path: Path) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
target_dir = Path(tmp_dir)
|
||||
converted = target_dir / f"{path.stem}.docx"
|
||||
source_path = str(path.resolve()).replace("'", "''")
|
||||
target_path = str(converted.resolve()).replace("'", "''")
|
||||
script = (
|
||||
"$ErrorActionPreference = 'Stop';"
|
||||
"$word = New-Object -ComObject Word.Application;"
|
||||
"$word.Visible = $false;"
|
||||
"try {"
|
||||
f"$doc = $word.Documents.Open('{source_path}', $false, $true);"
|
||||
f"$doc.SaveAs([ref]'{target_path}', [ref]16);"
|
||||
"$doc.Close([ref]$false);"
|
||||
"} finally { $word.Quit() }"
|
||||
)
|
||||
powershell = shutil.which("powershell") or shutil.which("pwsh")
|
||||
if not powershell:
|
||||
raise RuntimeError("PowerShell 不可用,无法调用 Word COM。")
|
||||
try:
|
||||
subprocess.run(
|
||||
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
raise RuntimeError(f"无法通过 PowerShell Word COM 转换法规 .doc 材料:{path.name}") from exc
|
||||
if not converted.exists():
|
||||
raise RuntimeError(f"PowerShell Word COM 未生成 docx:{path.name}")
|
||||
return extract_text_from_path(converted)
|
||||
|
||||
|
||||
def collect_source_chunks(source_dir: Path) -> list[TextChunk]:
|
||||
chunks: list[TextChunk] = []
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.conf import settings
|
||||
@@ -9,8 +10,10 @@ from django.utils import timezone
|
||||
|
||||
from .file_summary.skills.attachment_reader import AttachmentReaderSkill
|
||||
from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
|
||||
from .knowledge_base import search_knowledge_base
|
||||
from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
|
||||
from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, Message
|
||||
from .models import Conversation, FileAttachment, FileSummaryBatch, FileSummaryBatchAttachment, KnowledgeBaseDocument, Message
|
||||
from .regulatory_review.services.rag_index import extract_text_from_path
|
||||
from .application_form_fill.workflow import (
|
||||
create_application_form_fill_batch,
|
||||
find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch,
|
||||
@@ -104,8 +107,9 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes
|
||||
"""Stores one user message and one provider-backed assistant reply."""
|
||||
|
||||
user_message = append_user_message(conversation, content)
|
||||
knowledge_context = build_knowledge_context(content)
|
||||
try:
|
||||
reply_content = generate_reply(conversation, content)
|
||||
reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context)
|
||||
except (LLMConfigurationError, LLMRequestError) as exc:
|
||||
reply_content = f"模型调用失败:{exc}"
|
||||
|
||||
@@ -391,8 +395,9 @@ def stream_message(conversation: Conversation, content: str):
|
||||
|
||||
stream_failed = False
|
||||
stream_error = ""
|
||||
knowledge_context = build_knowledge_context(content)
|
||||
try:
|
||||
for chunk in stream_reply(conversation, content):
|
||||
for chunk in stream_reply(conversation, content, knowledge_context=knowledge_context):
|
||||
assistant_parts.append(chunk)
|
||||
yield sse_event("chunk", {"delta": chunk})
|
||||
except (LLMConfigurationError, LLMRequestError) as exc:
|
||||
@@ -412,7 +417,7 @@ def stream_message(conversation: Conversation, content: str):
|
||||
|
||||
if stream_failed:
|
||||
try:
|
||||
fallback_reply = generate_reply(conversation, content)
|
||||
fallback_reply = generate_reply(conversation, content, knowledge_context=knowledge_context)
|
||||
assistant_parts = [fallback_reply]
|
||||
logger.info(
|
||||
"Non-stream fallback reply succeeded",
|
||||
@@ -461,6 +466,118 @@ def build_conversation_title(content: str) -> str:
|
||||
return normalized[:24]
|
||||
|
||||
|
||||
def build_knowledge_context(content: str, *, n_results: int = 5) -> str:
|
||||
"""Formats global knowledge-base search hits for normal chat prompts."""
|
||||
|
||||
full_document_context = build_filename_matched_document_context(content)
|
||||
if full_document_context:
|
||||
return full_document_context
|
||||
|
||||
try:
|
||||
payload = search_knowledge_base(content, n_results=n_results)
|
||||
except Exception as exc:
|
||||
logger.warning("Knowledge-base search failed", extra={"error": str(exc)})
|
||||
return ""
|
||||
if payload.get("error_message"):
|
||||
return ""
|
||||
results = [
|
||||
item
|
||||
for item in _rank_knowledge_results(content, payload.get("results") or [])
|
||||
if _is_relevant_knowledge_result(content, item)
|
||||
]
|
||||
lines: list[str] = []
|
||||
for index, item in enumerate(results[:n_results], start=1):
|
||||
text = " ".join(str(item.get("text") or "").split())
|
||||
if not text:
|
||||
continue
|
||||
source = str(item.get("source") or "未知来源")
|
||||
score = item.get("score")
|
||||
score_label = f",score={score:.4f}" if isinstance(score, (int, float)) else ""
|
||||
lines.append(f"[{index}] 来源:{source}{score_label}\n{text[:1200]}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def build_filename_matched_document_context(query: str, *, max_chars: int = 12000) -> str:
|
||||
terms = _knowledge_query_terms(query)
|
||||
if not terms:
|
||||
return ""
|
||||
matches = []
|
||||
for document in KnowledgeBaseDocument.objects.filter(
|
||||
status=KnowledgeBaseDocument.Status.ACTIVE,
|
||||
is_active=True,
|
||||
).order_by("-updated_at", "-id"):
|
||||
filename = f"{document.display_name} {document.original_name}"
|
||||
if any(term and term in filename for term in terms):
|
||||
matches.append(document)
|
||||
if not matches:
|
||||
return ""
|
||||
lines = [
|
||||
"以下材料因用户问题中的关键词命中文档名称,已读取全文供回答前比对和总结。"
|
||||
]
|
||||
for index, document in enumerate(matches[:3], start=1):
|
||||
text = _extract_managed_document_text(document)
|
||||
if not text:
|
||||
continue
|
||||
lines.append(
|
||||
f"[全文材料 {index}] 来源:用户知识库/{document.original_name}\n"
|
||||
f"{' '.join(text.split())[:max_chars]}"
|
||||
)
|
||||
return "\n\n".join(lines).strip()
|
||||
|
||||
|
||||
def _extract_managed_document_text(document: KnowledgeBaseDocument) -> str:
|
||||
try:
|
||||
return extract_text_from_path(Path(document.storage_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Managed document full-text extraction failed",
|
||||
extra={"document_id": document.pk, "error": str(exc)},
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _rank_knowledge_results(query: str, results: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
terms = [term for term in _knowledge_query_terms(query) if term]
|
||||
|
||||
def sort_key(item: dict[str, object]) -> tuple[int, float]:
|
||||
source = str(item.get("source") or "")
|
||||
text = str(item.get("text") or "")
|
||||
haystack = f"{source}\n{text}"
|
||||
direct_hit = any(term in haystack for term in terms)
|
||||
score = item.get("score")
|
||||
numeric_score = float(score) if isinstance(score, (int, float)) else 999999.0
|
||||
return (0 if direct_hit else 1, numeric_score)
|
||||
|
||||
return sorted(results, key=sort_key)
|
||||
|
||||
|
||||
def _is_relevant_knowledge_result(query: str, item: dict[str, object]) -> bool:
|
||||
terms = _knowledge_query_terms(query)
|
||||
if not terms:
|
||||
return False
|
||||
source = str(item.get("source") or "")
|
||||
text = str(item.get("text") or "")
|
||||
haystack = f"{source}\n{text}"
|
||||
if any(term in haystack for term in terms):
|
||||
return True
|
||||
metadata = item.get("metadata") or {}
|
||||
if metadata.get("source_type") == "managed_document":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _knowledge_query_terms(query: str) -> list[str]:
|
||||
normalized = "".join((query or "").split())
|
||||
if not normalized:
|
||||
return []
|
||||
stop_chars = set("是谁什么哪里如何怎么请问一下帮我你能告诉吗??,,。.")
|
||||
compact = "".join(char for char in normalized if char not in stop_chars)
|
||||
terms = [compact] if compact else []
|
||||
if normalized not in terms:
|
||||
terms.append(normalized)
|
||||
return terms
|
||||
|
||||
|
||||
def _select_attachments_for_reader(conversation: Conversation, content: str):
|
||||
attachments = list(
|
||||
FileAttachment.objects.filter(
|
||||
|
||||
@@ -6,6 +6,7 @@ from .file_summary.views import (
|
||||
attachments,
|
||||
batch_events,
|
||||
batch_status,
|
||||
conversation_detail,
|
||||
conversation_list,
|
||||
conversation_messages,
|
||||
export_download,
|
||||
@@ -20,6 +21,13 @@ from .application_form_fill.views import (
|
||||
batch_status as application_form_fill_batch_status,
|
||||
start as application_form_fill_start,
|
||||
)
|
||||
from .views import (
|
||||
knowledge_base_document_detail,
|
||||
knowledge_base_document_index,
|
||||
knowledge_base_documents,
|
||||
knowledge_base_search,
|
||||
knowledge_base_status,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
@@ -28,6 +36,11 @@ urlpatterns = [
|
||||
conversation_list,
|
||||
name="review_agent_conversation_list",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/conversations/<int:conversation_id>/",
|
||||
conversation_detail,
|
||||
name="review_agent_conversation_detail",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/conversations/<int:conversation_id>/attachments/",
|
||||
attachments,
|
||||
@@ -98,4 +111,29 @@ urlpatterns = [
|
||||
application_form_fill_batch_status,
|
||||
name="application_form_fill_batch_status",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/knowledge-base/status/",
|
||||
knowledge_base_status,
|
||||
name="knowledge_base_status",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/knowledge-base/search/",
|
||||
knowledge_base_search,
|
||||
name="knowledge_base_search",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/knowledge-base/documents/",
|
||||
knowledge_base_documents,
|
||||
name="knowledge_base_document_list",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/knowledge-base/documents/<int:document_id>/",
|
||||
knowledge_base_document_detail,
|
||||
name="knowledge_base_document_detail",
|
||||
),
|
||||
path(
|
||||
"api/review-agent/knowledge-base/documents/<int:document_id>/index/",
|
||||
knowledge_base_document_index,
|
||||
name="knowledge_base_document_index",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, Q, Sum
|
||||
import json
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.http import urlencode
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .services import (
|
||||
@@ -12,9 +15,43 @@ from .services import (
|
||||
stream_message,
|
||||
)
|
||||
from .models import ApplicationFormFillBatch, Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun
|
||||
from .knowledge_base import build_knowledge_base_context, search_knowledge_base
|
||||
from .knowledge_base import (
|
||||
build_knowledge_base_context_for_user,
|
||||
create_document_from_upload,
|
||||
delete_document,
|
||||
index_managed_document,
|
||||
list_documents_for_user,
|
||||
serialize_document,
|
||||
update_document,
|
||||
)
|
||||
from .models import KnowledgeBaseDocument
|
||||
from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def home_dashboard(request: HttpRequest) -> HttpResponse:
|
||||
"""Renders the data-first home dashboard for the current user."""
|
||||
|
||||
if request.GET.get("conversation"):
|
||||
query = {"conversation": request.GET["conversation"]}
|
||||
search = (request.GET.get("q") or "").strip()
|
||||
if search:
|
||||
query["q"] = search
|
||||
return redirect(f"/chat/?{urlencode(query)}")
|
||||
|
||||
context = build_home_dashboard_context(request.user)
|
||||
return render(
|
||||
request,
|
||||
"workbench.html",
|
||||
{
|
||||
"page_title": "首页",
|
||||
"dashboard": context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def workspace(request: HttpRequest) -> HttpResponse:
|
||||
@@ -26,7 +63,7 @@ def workspace(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
if action == "new_conversation":
|
||||
conversation = create_conversation(request.user)
|
||||
return redirect(f"/?conversation={conversation.pk}")
|
||||
return redirect(f"/chat/?conversation={conversation.pk}")
|
||||
|
||||
if action == "send_message":
|
||||
content = (request.POST.get("prompt") or "").strip()
|
||||
@@ -34,7 +71,7 @@ def workspace(request: HttpRequest) -> HttpResponse:
|
||||
conversation = create_conversation(request.user)
|
||||
if content:
|
||||
send_message(conversation, content)
|
||||
return redirect(f"/?conversation={conversation.pk}")
|
||||
return redirect(f"/chat/?conversation={conversation.pk}")
|
||||
|
||||
search = (request.GET.get("q") or "").strip()
|
||||
conversations = list_conversations(request.user, search)
|
||||
@@ -94,6 +131,101 @@ def attachment_manager(request: HttpRequest) -> HttpResponse:
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def knowledge_base_manager(request: HttpRequest) -> HttpResponse:
|
||||
context = build_knowledge_base_context_for_user(request.user)
|
||||
return render(
|
||||
request,
|
||||
"knowledge_base.html",
|
||||
{
|
||||
"page_title": "知识库管理",
|
||||
"knowledge_base": context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def knowledge_base_status(request: HttpRequest) -> JsonResponse:
|
||||
return JsonResponse(build_knowledge_base_context_for_user(request.user))
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def knowledge_base_search(request: HttpRequest) -> JsonResponse:
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
query = payload.get("query", "")
|
||||
else:
|
||||
query = request.POST.get("query", "")
|
||||
return JsonResponse(search_knowledge_base(str(query)))
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def knowledge_base_documents(request: HttpRequest) -> JsonResponse:
|
||||
if request.method == "GET":
|
||||
return JsonResponse({"documents": list_documents_for_user(request.user)})
|
||||
uploaded_file = request.FILES.get("file")
|
||||
if uploaded_file is None:
|
||||
return JsonResponse({"error": "请上传知识库材料。"}, status=400)
|
||||
is_active = str(request.POST.get("is_active", "true")).lower() not in {"0", "false", "off"}
|
||||
document = create_document_from_upload(
|
||||
user=request.user,
|
||||
uploaded_file=uploaded_file,
|
||||
display_name=request.POST.get("display_name", ""),
|
||||
description=request.POST.get("description", ""),
|
||||
is_active=is_active,
|
||||
)
|
||||
return JsonResponse({"document": serialize_document(document)})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "PATCH", "DELETE"])
|
||||
def knowledge_base_document_detail(request: HttpRequest, document_id: int) -> JsonResponse:
|
||||
try:
|
||||
document = KnowledgeBaseDocument.objects.get(
|
||||
pk=document_id,
|
||||
user=request.user,
|
||||
)
|
||||
except KnowledgeBaseDocument.DoesNotExist:
|
||||
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
||||
if document.status == KnowledgeBaseDocument.Status.DELETED:
|
||||
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
||||
if request.method == "GET":
|
||||
return JsonResponse({"document": serialize_document(document)})
|
||||
if request.method == "DELETE":
|
||||
delete_document(document)
|
||||
return JsonResponse({"document": serialize_document(document)})
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
update_document(document, payload)
|
||||
return JsonResponse({"document": serialize_document(document)})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def knowledge_base_document_index(request: HttpRequest, document_id: int) -> JsonResponse:
|
||||
try:
|
||||
document = KnowledgeBaseDocument.objects.get(
|
||||
pk=document_id,
|
||||
user=request.user,
|
||||
)
|
||||
except KnowledgeBaseDocument.DoesNotExist:
|
||||
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
||||
if document.status == KnowledgeBaseDocument.Status.DELETED:
|
||||
return JsonResponse({"error": "知识库材料不存在。"}, status=404)
|
||||
chunk_count = index_managed_document(document)
|
||||
document.refresh_from_db()
|
||||
return JsonResponse({"document": serialize_document(document), "chunk_count": chunk_count})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def stream_chat(request: HttpRequest) -> HttpResponse:
|
||||
@@ -217,3 +349,139 @@ def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str:
|
||||
if batch.risk_notes:
|
||||
parts.append(f"提示 {len(batch.risk_notes)}")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def build_home_dashboard_context(user) -> dict[str, object]:
|
||||
conversations = Conversation.objects.filter(user=user)
|
||||
active_attachments = FileAttachment.objects.filter(user=user).exclude(
|
||||
upload_status=FileAttachment.UploadStatus.DELETED
|
||||
)
|
||||
active_knowledge_documents = KnowledgeBaseDocument.objects.filter(user=user).exclude(
|
||||
status=KnowledgeBaseDocument.Status.DELETED
|
||||
)
|
||||
knowledge_context = build_knowledge_base_context_for_user(user)
|
||||
builtin_source_count = int(knowledge_context.get("source_count") or 0)
|
||||
collection_chunk_count = int((knowledge_context.get("collection") or {}).get("count") or 0)
|
||||
managed_document_count = active_knowledge_documents.count()
|
||||
file_batches = FileSummaryBatch.objects.filter(user=user).select_related("conversation")
|
||||
regulatory_batches = RegulatoryReviewBatch.objects.filter(user=user).select_related("conversation")
|
||||
form_fill_batches = ApplicationFormFillBatch.objects.filter(user=user, is_deleted=False).select_related("conversation")
|
||||
|
||||
batch_status_counts = _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches)
|
||||
total_batches = file_batches.count() + regulatory_batches.count() + form_fill_batches.count()
|
||||
successful_batches = batch_status_counts["success"]
|
||||
handled_batches = successful_batches + batch_status_counts["failed"]
|
||||
recent_records = _build_recent_dashboard_records(
|
||||
conversations.order_by("-updated_at", "-id")[:8],
|
||||
file_batches.order_by("-created_at", "-id")[:8],
|
||||
regulatory_batches.order_by("-created_at", "-id")[:8],
|
||||
form_fill_batches.order_by("-created_at", "-id")[:8],
|
||||
)
|
||||
|
||||
return {
|
||||
"metrics": {
|
||||
"conversation_count": conversations.count(),
|
||||
"recent_conversation_count": conversations.filter(messages__isnull=False).distinct().count(),
|
||||
"attachment_count": active_attachments.count(),
|
||||
"active_attachment_count": active_attachments.filter(is_active=True).count(),
|
||||
"knowledge_document_count": managed_document_count + builtin_source_count,
|
||||
"running_batch_count": batch_status_counts["running"],
|
||||
"handled_batch_count": handled_batches,
|
||||
"success_batch_count": successful_batches,
|
||||
"waiting_batch_count": batch_status_counts["waiting"],
|
||||
"failed_batch_count": batch_status_counts["failed"],
|
||||
"total_batch_count": total_batches,
|
||||
},
|
||||
"knowledge": {
|
||||
"document_count": managed_document_count,
|
||||
"builtin_source_count": builtin_source_count,
|
||||
"total_material_count": managed_document_count + builtin_source_count,
|
||||
"active_document_count": active_knowledge_documents.filter(is_active=True).count(),
|
||||
"indexed_document_count": active_knowledge_documents.filter(indexed_chunk_count__gt=0).count(),
|
||||
"managed_chunk_count": active_knowledge_documents.aggregate(total=Sum("indexed_chunk_count"))["total"] or 0,
|
||||
"chunk_count": collection_chunk_count,
|
||||
},
|
||||
"attachments": {
|
||||
"attachment_count": active_attachments.count(),
|
||||
"active_attachment_count": active_attachments.filter(is_active=True).count(),
|
||||
"recent_attachment_count": active_attachments.order_by("-created_at", "-id")[:5].count(),
|
||||
"conversation_count": active_attachments.values("conversation_id").distinct().count(),
|
||||
},
|
||||
"workflow": {
|
||||
"file_summary_count": file_batches.count(),
|
||||
"regulatory_review_count": regulatory_batches.count(),
|
||||
"application_form_fill_count": form_fill_batches.count(),
|
||||
**batch_status_counts,
|
||||
},
|
||||
"recent_records": recent_records,
|
||||
}
|
||||
|
||||
|
||||
def _build_batch_status_counts(file_batches, regulatory_batches, form_fill_batches) -> dict[str, int]:
|
||||
running_statuses = {
|
||||
FileSummaryBatch.Status.PENDING,
|
||||
FileSummaryBatch.Status.RUNNING,
|
||||
ApplicationFormFillBatch.Status.PENDING,
|
||||
ApplicationFormFillBatch.Status.RUNNING,
|
||||
RegulatoryReviewBatch.Status.PENDING,
|
||||
RegulatoryReviewBatch.Status.RUNNING,
|
||||
}
|
||||
waiting_statuses = {
|
||||
ApplicationFormFillBatch.Status.WAITING_USER,
|
||||
RegulatoryReviewBatch.Status.WAITING_USER,
|
||||
}
|
||||
success_statuses = {
|
||||
FileSummaryBatch.Status.SUCCESS,
|
||||
RegulatoryReviewBatch.Status.SUCCESS,
|
||||
ApplicationFormFillBatch.Status.SUCCESS,
|
||||
ApplicationFormFillBatch.Status.PARTIAL_SUCCESS,
|
||||
}
|
||||
failed_statuses = {
|
||||
FileSummaryBatch.Status.FAILED,
|
||||
RegulatoryReviewBatch.Status.FAILED,
|
||||
ApplicationFormFillBatch.Status.FAILED,
|
||||
}
|
||||
statuses = [
|
||||
*file_batches.values_list("status", flat=True),
|
||||
*regulatory_batches.values_list("status", flat=True),
|
||||
*form_fill_batches.values_list("status", flat=True),
|
||||
]
|
||||
return {
|
||||
"running": sum(1 for status in statuses if status in running_statuses),
|
||||
"waiting": sum(1 for status in statuses if status in waiting_statuses),
|
||||
"success": sum(1 for status in statuses if status in success_statuses),
|
||||
"failed": sum(1 for status in statuses if status in failed_statuses),
|
||||
}
|
||||
|
||||
|
||||
def _build_recent_dashboard_records(conversations, file_batches, regulatory_batches, form_fill_batches) -> list[dict[str, object]]:
|
||||
records = []
|
||||
for conversation in conversations:
|
||||
records.append(
|
||||
{
|
||||
"type": "对话",
|
||||
"title": conversation.title or "新对话",
|
||||
"status": "已更新",
|
||||
"updated_at": conversation.updated_at,
|
||||
"url": f"/chat/?conversation={conversation.pk}",
|
||||
}
|
||||
)
|
||||
for batch in file_batches:
|
||||
records.append(_batch_record(batch, "文件汇总"))
|
||||
for batch in regulatory_batches:
|
||||
status = batch.status
|
||||
risk_label = _format_risk_label(batch.risk_summary or {})
|
||||
records.append(_batch_record(batch, "法规核查", status_label=risk_label or status))
|
||||
for batch in form_fill_batches:
|
||||
records.append(_batch_record(batch, "申报填表"))
|
||||
return sorted(records, key=lambda item: item["updated_at"], reverse=True)[:8]
|
||||
|
||||
|
||||
def _batch_record(batch, record_type: str, status_label: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"type": record_type,
|
||||
"title": batch.batch_no,
|
||||
"status": status_label or batch.status,
|
||||
"updated_at": batch.created_at,
|
||||
"url": f"/chat/?conversation={batch.conversation_id}",
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ input:focus {
|
||||
gap: 24px;
|
||||
padding: 18px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%);
|
||||
border-right: 1px solid var(--line);
|
||||
transition: width 180ms ease, padding 180ms ease, transform 180ms ease;
|
||||
@@ -259,19 +259,47 @@ input:focus {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-group {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c4cfdd transparent;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.history-list::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: #c4cfdd;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
grid-template-columns: minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 8px 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
@@ -281,7 +309,18 @@ input:focus {
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.history-link {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -291,6 +330,41 @@ input:focus {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-item .history-delete {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #93a0af;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease, background 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.history-item:hover .history-delete,
|
||||
.history-item.active .history-delete,
|
||||
.history-item .history-delete:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.history-item .history-delete:hover {
|
||||
border-color: #fecdd3;
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: 16px 14px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
@@ -800,11 +874,13 @@ input:focus {
|
||||
.workspace[data-sidebar-state="collapsed"] .search-form,
|
||||
.workspace[data-sidebar-state="collapsed"] .sidebar-label,
|
||||
.workspace[data-sidebar-state="collapsed"] .history-title,
|
||||
.workspace[data-sidebar-state="collapsed"] .history-meta {
|
||||
.workspace[data-sidebar-state="collapsed"] .history-meta,
|
||||
.workspace[data-sidebar-state="collapsed"] .history-delete {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="collapsed"] .history-item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -1402,6 +1478,116 @@ input:focus {
|
||||
background: #eaf2ff;
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.dashboard-hero,
|
||||
.metric-grid,
|
||||
.dashboard-split,
|
||||
.dashboard-panel {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 2px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dashboard-primary-action {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 104px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.metric-card em {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
color: var(--text);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-stat-list div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dashboard-stat-list dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard-stat-list dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.recent-activity-table td {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.table-empty,
|
||||
.attachment-manager-empty {
|
||||
color: var(--muted);
|
||||
@@ -1422,6 +1608,639 @@ input:focus {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.knowledge-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.knowledge-hero,
|
||||
.knowledge-status-panel,
|
||||
.knowledge-grid,
|
||||
.knowledge-content,
|
||||
.knowledge-workbench,
|
||||
.knowledge-summary-row,
|
||||
.knowledge-main-grid,
|
||||
.knowledge-secondary-grid,
|
||||
.knowledge-panel {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.knowledge-hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knowledge-hero h1 {
|
||||
margin: 2px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.knowledge-hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-status.status-ready {
|
||||
background: #ecfdf3;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.knowledge-status.status-thin {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.knowledge-status.status-missing {
|
||||
background: #fff1f2;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.knowledge-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.knowledge-summary-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 68px;
|
||||
padding: 12px 14px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.knowledge-summary-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.knowledge-summary-item span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-summary-item strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.knowledge-summary-item small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.knowledge-status-message {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.knowledge-grid,
|
||||
.knowledge-main-grid,
|
||||
.knowledge-secondary-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-content {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-workbench {
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.knowledge-main-grid {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.knowledge-secondary-grid {
|
||||
grid-template-columns: minmax(340px, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.knowledge-left-stack,
|
||||
.knowledge-right-stack,
|
||||
.knowledge-left-rail,
|
||||
.knowledge-right-display {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.knowledge-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knowledge-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.knowledge-system-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-system-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-system-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.knowledge-system-header p {
|
||||
margin: 0;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.knowledge-system-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.knowledge-document-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knowledge-document-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.knowledge-document-form label span {
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-document-form input[type="text"],
|
||||
.knowledge-document-form input[type="file"],
|
||||
.knowledge-document-form textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.knowledge-upload-dropzone {
|
||||
min-height: 156px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.knowledge-upload-dropzone strong {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.knowledge-document-form textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.knowledge-document-form input:focus,
|
||||
.knowledge-document-form textarea:focus,
|
||||
.knowledge-search-form input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.knowledge-checkbox {
|
||||
display: flex !important;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.knowledge-checkbox input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.knowledge-form-actions,
|
||||
.knowledge-toolbar-actions,
|
||||
.knowledge-inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.knowledge-inline-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.knowledge-inline-actions .knowledge-checkbox {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.knowledge-form-actions button,
|
||||
.knowledge-toolbar-actions button,
|
||||
.knowledge-inline-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-form-actions button[type="submit"],
|
||||
.knowledge-inline-actions button {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.knowledge-toolbar-actions button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.knowledge-definition-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.knowledge-definition-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.knowledge-definition-list dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-definition-list dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-command-box {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.knowledge-command-box strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-command-box code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: #1f2a37;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.knowledge-severity-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.knowledge-severity-list span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #eaf2ff;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-search-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.knowledge-search-form input {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.knowledge-search-form button {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-search-results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.knowledge-panel-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.knowledge-compact-stats div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.knowledge-result {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.knowledge-result header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.knowledge-result header strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.knowledge-result header span,
|
||||
.knowledge-result em {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.knowledge-result p,
|
||||
.knowledge-search-error {
|
||||
margin: 0;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.knowledge-search-error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.knowledge-source-table th:first-child,
|
||||
.knowledge-source-table td:first-child,
|
||||
.knowledge-document-table th:first-child,
|
||||
.knowledge-document-table td:first-child,
|
||||
.knowledge-source-table th:nth-child(3),
|
||||
.knowledge-source-table td:nth-child(3),
|
||||
.knowledge-source-table th:nth-child(4),
|
||||
.knowledge-source-table td:nth-child(4),
|
||||
.knowledge-source-table th:nth-child(5),
|
||||
.knowledge-source-table td:nth-child(5),
|
||||
.knowledge-document-table th:nth-child(4),
|
||||
.knowledge-document-table td:nth-child(4),
|
||||
.knowledge-document-table th:nth-child(5),
|
||||
.knowledge-document-table td:nth-child(5),
|
||||
.knowledge-document-table th:nth-child(6),
|
||||
.knowledge-document-table td:nth-child(6) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-page .summary-subheading h3 {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.knowledge-page input[type="text"],
|
||||
.knowledge-page input[type="search"],
|
||||
.knowledge-page textarea {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.knowledge-page textarea {
|
||||
min-height: 44px;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.knowledge-page input[type="text"]:focus,
|
||||
.knowledge-page input[type="search"]:focus,
|
||||
.knowledge-page textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(58, 114, 216, 0.14);
|
||||
}
|
||||
|
||||
.knowledge-page button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.knowledge-page button[type="submit"],
|
||||
.knowledge-inline-actions button {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.knowledge-page button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: #eaf2ff;
|
||||
}
|
||||
|
||||
.knowledge-page button[type="submit"]:hover:not(:disabled),
|
||||
.knowledge-inline-actions button:hover:not(:disabled) {
|
||||
background: var(--accent-dark);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.knowledge-page button:disabled {
|
||||
border-color: var(--line);
|
||||
background: #f3f6fb;
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.knowledge-page .panel-empty {
|
||||
margin: 0;
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-upload-panel .summary-subheading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-upload-dropzone {
|
||||
background: #f7faff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.knowledge-upload-dropzone:hover {
|
||||
border-color: var(--accent);
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.knowledge-parse-panel .knowledge-status {
|
||||
min-height: 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.knowledge-document-list-panel,
|
||||
.knowledge-source-panel {
|
||||
min-height: 152px;
|
||||
}
|
||||
|
||||
.knowledge-right-display .attachment-table th,
|
||||
.knowledge-right-display .attachment-table td {
|
||||
padding-top: 11px;
|
||||
padding-bottom: 11px;
|
||||
}
|
||||
|
||||
.knowledge-document-list-panel .summary-subheading h3,
|
||||
.knowledge-source-panel .summary-subheading h3 {
|
||||
max-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.knowledge-document-table th:nth-child(5),
|
||||
.knowledge-document-table td:nth-child(5) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tabbar {
|
||||
overflow-x: auto;
|
||||
@@ -1510,9 +2329,97 @@ input:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.knowledge-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.attachment-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.knowledge-page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.knowledge-hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.knowledge-status-panel,
|
||||
.knowledge-summary-row,
|
||||
.knowledge-grid,
|
||||
.knowledge-main-grid,
|
||||
.knowledge-secondary-grid,
|
||||
.knowledge-system-grid,
|
||||
.knowledge-search-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.knowledge-summary-item {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.knowledge-summary-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.knowledge-hero-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.knowledge-toolbar-actions,
|
||||
.knowledge-form-actions,
|
||||
.knowledge-inline-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.knowledge-toolbar-actions .attachment-search,
|
||||
.knowledge-toolbar-actions button,
|
||||
.knowledge-form-actions button,
|
||||
.knowledge-inline-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.knowledge-compact-stats div {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.knowledge-compact-stats div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.knowledge-definition-list div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.dashboard-split,
|
||||
.dashboard-stat-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-caret {
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
if (!composer) {
|
||||
return "";
|
||||
}
|
||||
return new FormData(composer).get("csrfmiddlewaretoken") || "";
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return window.matchMedia("(max-width: 980px)").matches;
|
||||
}
|
||||
@@ -415,19 +422,60 @@
|
||||
empty.remove();
|
||||
}
|
||||
|
||||
var item = document.createElement("a");
|
||||
var item = document.createElement("div");
|
||||
item.className = "history-item active";
|
||||
item.setAttribute("data-conversation-id", conversationId);
|
||||
item.href = "/?conversation=" + conversationId;
|
||||
item.setAttribute("data-delete-url", "/api/review-agent/conversations/" + conversationId + "/");
|
||||
item.innerHTML =
|
||||
'<span class="history-title">' +
|
||||
'<a class="history-link" href="/?conversation=' +
|
||||
encodeURIComponent(conversationId) +
|
||||
'"><span class="history-title">' +
|
||||
escapeHtml(encodedTitle) +
|
||||
'</span><span class="history-meta">' +
|
||||
meta +
|
||||
"</span>";
|
||||
'</span></a><button class="history-delete" type="button" data-conversation-delete aria-label="删除对话 ' +
|
||||
escapeHtml(encodedTitle) +
|
||||
'" title="删除对话">×</button>';
|
||||
list.prepend(item);
|
||||
}
|
||||
|
||||
async function deleteConversation(item) {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
var url = item.getAttribute("data-delete-url");
|
||||
var conversationId = item.getAttribute("data-conversation-id");
|
||||
if (!url || !conversationId) {
|
||||
return;
|
||||
}
|
||||
var titleNode = item.querySelector(".history-title");
|
||||
var title = titleNode ? titleNode.textContent.trim() : "这个对话";
|
||||
if (!window.confirm('确定删除对话“' + title + '”?')) {
|
||||
return;
|
||||
}
|
||||
var response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("删除对话失败");
|
||||
}
|
||||
var isCurrent = currentConversationId() === conversationId;
|
||||
item.remove();
|
||||
var list = document.querySelector(".history-list");
|
||||
if (list && !list.querySelector(".history-item")) {
|
||||
var empty = document.createElement("div");
|
||||
empty.className = "history-empty";
|
||||
empty.innerHTML = "<p>暂无会话记录</p><span>点击上方“新对话”开始审核。</span>";
|
||||
list.appendChild(empty);
|
||||
}
|
||||
if (isCurrent) {
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
||||
function setConversationTitle(title) {
|
||||
if (!title) {
|
||||
return;
|
||||
@@ -1167,6 +1215,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function bindConversationDeleteButtons() {
|
||||
var list = document.querySelector(".history-list");
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.addEventListener("click", function (event) {
|
||||
var button = event.target.closest("[data-conversation-delete]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
var item = button.closest(".history-item");
|
||||
deleteConversation(item).catch(function () {
|
||||
window.alert("删除对话失败,请稍后重试。");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
syncNodeRailVisibility();
|
||||
syncLatestMessageIdFromDom();
|
||||
bindNodeAnchorClicks();
|
||||
@@ -1176,6 +1243,7 @@
|
||||
bindConditionConfirmForms();
|
||||
bindRectificationActionButtons();
|
||||
bindPromptTemplateButtons();
|
||||
bindConversationDeleteButtons();
|
||||
refreshRunningWorkflowCards();
|
||||
|
||||
if (chatScroll) {
|
||||
|
||||
238
static/js/knowledge_base.js
Normal file
238
static/js/knowledge_base.js
Normal file
@@ -0,0 +1,238 @@
|
||||
(function () {
|
||||
var page = document.querySelector(".knowledge-page");
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
var documentForm = document.getElementById("knowledgeDocumentForm");
|
||||
var documentStatus = document.getElementById("knowledgeDocumentStatus");
|
||||
var documentTable = document.getElementById("knowledgeDocumentTable");
|
||||
var documentSearch = document.getElementById("knowledgeDocumentSearch");
|
||||
var searchForm = document.getElementById("knowledgeSearchForm");
|
||||
var queryInput = document.getElementById("knowledgeSearchQuery");
|
||||
var results = document.getElementById("knowledgeSearchResults");
|
||||
var sourceSearch = document.getElementById("knowledgeSourceSearch");
|
||||
var sourceTable = document.getElementById("knowledgeSourceTable");
|
||||
var documentFileInput = document.getElementById("knowledgeDocumentFile");
|
||||
var uploadDropzone = document.getElementById("knowledgeUploadDropzone");
|
||||
|
||||
function csrfToken() {
|
||||
var cookie = document.cookie.split("; ").find(function (item) {
|
||||
return item.indexOf("csrftoken=") === 0;
|
||||
});
|
||||
return cookie ? decodeURIComponent(cookie.split("=")[1]) : "";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchDocument(row, payload) {
|
||||
var response = await fetch(row.getAttribute("data-detail-url"), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken(),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("知识库材料更新失败。");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function deleteDocument(row) {
|
||||
var response = await fetch(row.getAttribute("data-detail-url"), {
|
||||
method: "DELETE",
|
||||
headers: { "X-CSRFToken": csrfToken() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("知识库材料删除失败。");
|
||||
}
|
||||
}
|
||||
|
||||
async function indexDocument(row) {
|
||||
var response = await fetch(row.getAttribute("data-index-url"), {
|
||||
method: "POST",
|
||||
headers: { "X-CSRFToken": csrfToken() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("知识库材料解析入库失败。");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderResults(payload) {
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
if (payload.error_message) {
|
||||
results.innerHTML = '<p class="knowledge-search-error">' + escapeHtml(payload.error_message) + "</p>";
|
||||
return;
|
||||
}
|
||||
if (!payload.results || !payload.results.length) {
|
||||
results.innerHTML = '<p class="panel-empty">未检索到依据片段。</p>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = payload.results
|
||||
.map(function (item, index) {
|
||||
return [
|
||||
'<article class="knowledge-result">',
|
||||
"<header><strong>结果 " + (index + 1) + "</strong><span>" + escapeHtml(item.source || "法规材料") + "</span></header>",
|
||||
"<p>" + escapeHtml(item.text || "").slice(0, 600) + "</p>",
|
||||
item.score === null || item.score === undefined ? "" : "<em>score: " + escapeHtml(item.score) + "</em>",
|
||||
"</article>",
|
||||
].join("");
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (documentForm) {
|
||||
documentForm.addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(documentForm);
|
||||
if (documentStatus) {
|
||||
documentStatus.textContent = "上传并解析入库中...";
|
||||
}
|
||||
try {
|
||||
var response = await fetch(page.getAttribute("data-document-url"), {
|
||||
method: "POST",
|
||||
headers: { "X-CSRFToken": csrfToken() },
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("新增材料失败。");
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
if (documentStatus) {
|
||||
documentStatus.textContent = error.message || "新增材料失败。";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (documentFileInput && documentStatus) {
|
||||
documentFileInput.addEventListener("change", function () {
|
||||
var file = documentFileInput.files && documentFileInput.files[0];
|
||||
documentStatus.textContent = file
|
||||
? "已选择:" + file.name
|
||||
: "上传后会进入当前账号的全局知识库。";
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadDropzone && documentFileInput) {
|
||||
uploadDropzone.addEventListener("click", function () {
|
||||
documentFileInput.click();
|
||||
});
|
||||
uploadDropzone.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
documentFileInput.click();
|
||||
}
|
||||
});
|
||||
["dragenter", "dragover"].forEach(function (eventName) {
|
||||
uploadDropzone.addEventListener(eventName, function (event) {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("dragging");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (eventName) {
|
||||
uploadDropzone.addEventListener(eventName, function (event) {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("dragging");
|
||||
});
|
||||
});
|
||||
uploadDropzone.addEventListener("drop", function (event) {
|
||||
var files = event.dataTransfer && event.dataTransfer.files;
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
documentFileInput.files = files;
|
||||
documentFileInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
if (documentTable) {
|
||||
documentTable.addEventListener("click", async function (event) {
|
||||
var button = event.target.closest("[data-kb-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
var row = button.closest("tr[data-document-id]");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
var action = button.getAttribute("data-kb-action");
|
||||
try {
|
||||
if (action === "edit") {
|
||||
var nameCell = row.querySelector(".attachment-name");
|
||||
var nextName = window.prompt("请输入新的材料名称", nameCell ? nameCell.textContent.trim() : "");
|
||||
if (nextName) {
|
||||
await patchDocument(row, { display_name: nextName });
|
||||
window.location.reload();
|
||||
}
|
||||
} else if (action === "toggle") {
|
||||
await patchDocument(row, { is_active: button.textContent.trim() === "启用" });
|
||||
window.location.reload();
|
||||
} else if (action === "index") {
|
||||
button.disabled = true;
|
||||
button.textContent = "解析中";
|
||||
await indexDocument(row);
|
||||
window.location.reload();
|
||||
} else if (action === "delete" && window.confirm("确认删除该知识库材料?")) {
|
||||
await deleteDocument(row);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error.message || "知识库材料操作失败。");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (searchForm && queryInput) {
|
||||
searchForm.addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
var query = queryInput.value.trim();
|
||||
if (!query) {
|
||||
renderResults({ error_message: "请输入检索问题。" });
|
||||
return;
|
||||
}
|
||||
results.innerHTML = '<p class="panel-empty">检索中...</p>';
|
||||
try {
|
||||
var response = await fetch(page.getAttribute("data-search-url"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRFToken": csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ query: query }),
|
||||
});
|
||||
renderResults(await response.json());
|
||||
} catch (error) {
|
||||
renderResults({ error_message: "检索失败,请稍后重试。" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindTableSearch(input, table, selector) {
|
||||
if (!input || !table) {
|
||||
return;
|
||||
}
|
||||
input.addEventListener("input", function () {
|
||||
var keyword = input.value.trim().toLowerCase();
|
||||
table.querySelectorAll(selector).forEach(function (row) {
|
||||
row.hidden = keyword && row.textContent.toLowerCase().indexOf(keyword) === -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bindTableSearch(documentSearch, documentTable, "tbody tr[data-document-id]");
|
||||
bindTableSearch(sourceSearch, sourceTable, "tbody tr[data-source-name]");
|
||||
})();
|
||||
@@ -9,9 +9,9 @@
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="tabbar" role="tablist" aria-label="页面切换">
|
||||
<a class="tab" href="/" role="tab" aria-selected="false">首页</a>
|
||||
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
|
||||
<a class="tab" href="/" role="tab" aria-selected="false">审核智能体</a>
|
||||
<a class="tab" href="{% url 'home' %}" role="tab" aria-selected="false">首页</a>
|
||||
<a class="tab" href="{% url 'chat' %}" role="tab" aria-selected="false">审核智能体</a>
|
||||
<a class="tab" href="{% url 'knowledge_base_manager' %}" role="tab" aria-selected="false">知识库管理</a>
|
||||
<a class="tab active" href="{% url 'attachment_manager' %}" role="tab" aria-selected="true">附件管理</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if selected_conversation %}
|
||||
<a class="return-chat-link" href="{% url 'home' %}?conversation={{ selected_conversation.pk }}">返回对话</a>
|
||||
<a class="return-chat-link" href="{% url 'chat' %}?conversation={{ selected_conversation.pk }}">返回对话</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}DEMO-AGENT V2{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'css/login.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/login.css' %}?v=20260608-chat-delete1">
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="tabbar" role="tablist" aria-label="页面切换">
|
||||
<a class="tab" href="/" role="tab" aria-selected="false">首页</a>
|
||||
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
|
||||
<a class="tab active" href="/" role="tab" aria-selected="true">审核智能体</a>
|
||||
<a class="tab" href="{% url 'home' %}" role="tab" aria-selected="false">首页</a>
|
||||
<a class="tab active" href="{% url 'chat' %}" role="tab" aria-selected="true">审核智能体</a>
|
||||
<a class="tab" href="{% url 'knowledge_base_manager' %}" role="tab" aria-selected="false">知识库管理</a>
|
||||
<a class="tab" href="{% url 'attachment_manager' %}" role="tab" aria-selected="false">附件管理</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,14 +72,26 @@
|
||||
<p class="sidebar-label">对话记录</p>
|
||||
<nav class="history-list" aria-label="对话历史">
|
||||
{% for conversation in conversations %}
|
||||
<a
|
||||
<div
|
||||
class="history-item{% if current_conversation and current_conversation.pk == conversation.pk %} active{% endif %}"
|
||||
data-conversation-id="{{ conversation.pk }}"
|
||||
href="/?conversation={{ conversation.pk }}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
|
||||
data-delete-url="{% url 'review_agent_conversation_detail' conversation.pk %}"
|
||||
>
|
||||
<span class="history-title">{{ conversation.title|default:"新对话" }}</span>
|
||||
<span class="history-meta">{{ conversation.updated_at|date:"m月d日 H:i" }}</span>
|
||||
</a>
|
||||
<a
|
||||
class="history-link"
|
||||
href="{% url 'chat' %}?conversation={{ conversation.pk }}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
|
||||
>
|
||||
<span class="history-title">{{ conversation.title|default:"新对话" }}</span>
|
||||
<span class="history-meta">{{ conversation.updated_at|date:"m月d日 H:i" }}</span>
|
||||
</a>
|
||||
<button
|
||||
class="history-delete"
|
||||
type="button"
|
||||
data-conversation-delete
|
||||
aria-label="删除对话 {{ conversation.title|default:'新对话' }}"
|
||||
title="删除对话"
|
||||
>×</button>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="history-empty">
|
||||
<p>暂无会话记录</p>
|
||||
@@ -190,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<form class="composer" action="/" method="post" id="chatComposer">
|
||||
<form class="composer" action="{% url 'chat' %}" method="post" id="chatComposer">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="send_message">
|
||||
<input type="hidden" name="conversation_id" id="conversationIdInput" value="{% if current_conversation %}{{ current_conversation.pk }}{% endif %}">
|
||||
@@ -350,5 +362,5 @@
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
|
||||
<script src="{% static 'js/app.js' %}"></script>
|
||||
<script src="{% static 'js/app.js' %}?v=20260608-chat-delete1"></script>
|
||||
{% endblock %}
|
||||
|
||||
213
templates/knowledge_base.html
Normal file
213
templates/knowledge_base.html
Normal file
@@ -0,0 +1,213 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}知识库管理 - DEMO-AGENT V2{% endblock %}
|
||||
{% block body_class %}app-body{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="tabbar" role="tablist" aria-label="页面切换">
|
||||
<a class="tab" href="{% url 'home' %}" role="tab" aria-selected="false">首页</a>
|
||||
<a class="tab" href="{% url 'chat' %}" role="tab" aria-selected="false">审核智能体</a>
|
||||
<a class="tab active" href="{% url 'knowledge_base_manager' %}" role="tab" aria-selected="true">知识库管理</a>
|
||||
<a class="tab" href="{% url 'attachment_manager' %}" role="tab" aria-selected="false">附件管理</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="user-menu">
|
||||
<button class="user-menu-trigger" type="button">
|
||||
<span class="avatar large">{{ request.user.username|slice:":1"|upper }}</span>
|
||||
<div class="user-copy">
|
||||
<strong>{{ request.user.username }}</strong>
|
||||
<span>当前登录用户</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="knowledge-page"
|
||||
data-document-url="{% url 'knowledge_base_document_list' %}"
|
||||
data-search-url="{% url 'knowledge_base_search' %}"
|
||||
>
|
||||
<header class="attachment-manager-hero attachment-manager-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">知识库管理</p>
|
||||
<h1>知识库管理</h1>
|
||||
<p>管理当前账号所有对话可调用的法规、制度、模板和审查依据。</p>
|
||||
</div>
|
||||
<div class="knowledge-hero-actions">
|
||||
<span class="knowledge-status status-{{ knowledge_base.status.code }}">{{ knowledge_base.status.label }}</span>
|
||||
<a class="return-chat-link" href="{% url 'chat' %}">返回对话</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="attachment-manager-content attachment-manager-split knowledge-workbench">
|
||||
<aside class="knowledge-left-rail">
|
||||
<section class="attachment-manager-panel knowledge-panel knowledge-upload-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>上传知识</h3>
|
||||
<span>所有对话可调用</span>
|
||||
</div>
|
||||
<form class="knowledge-document-form" id="knowledgeDocumentForm">
|
||||
{% csrf_token %}
|
||||
<div
|
||||
class="upload-dropzone manager-upload-dropzone knowledge-upload-dropzone"
|
||||
id="knowledgeUploadDropzone"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-controls="knowledgeDocumentFile"
|
||||
>
|
||||
<input id="knowledgeDocumentFile" name="file" type="file" required hidden>
|
||||
<strong>点击选择文件,或拖拽到这里</strong>
|
||||
<span>支持 doc、docx、xls、xlsx、ppt、pptx、pdf、txt、md</span>
|
||||
</div>
|
||||
<div class="knowledge-inline-actions">
|
||||
<label class="knowledge-checkbox">
|
||||
<input name="is_active" type="checkbox" checked>
|
||||
<span>上传后启用</span>
|
||||
</label>
|
||||
<button type="submit">上传并解析</button>
|
||||
</div>
|
||||
<p class="upload-status" id="knowledgeDocumentStatus">上传后会进入当前账号的全局知识库。</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="attachment-manager-panel knowledge-panel knowledge-parse-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>解析与索引</h3>
|
||||
<span class="knowledge-status status-{{ knowledge_base.status.code }}">{{ knowledge_base.status.label }}</span>
|
||||
</div>
|
||||
<dl class="knowledge-compact-stats">
|
||||
<div>
|
||||
<dt>向量片段</dt>
|
||||
<dd>{{ knowledge_base.collection.count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>用户材料</dt>
|
||||
<dd>{{ knowledge_base.managed_document_count|default:0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内置法规</dt>
|
||||
<dd>{{ knowledge_base.source_count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="knowledge-panel-note">{{ knowledge_base.status.message }}</p>
|
||||
<div class="knowledge-form-actions">
|
||||
<button type="button" onclick="window.location.reload()">刷新状态</button>
|
||||
<button type="button" disabled>重建索引</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="attachment-manager-panel knowledge-panel knowledge-search-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>RAG 检索测试</h3>
|
||||
<span>Top 3</span>
|
||||
</div>
|
||||
<form class="knowledge-search-form" id="knowledgeSearchForm">
|
||||
{% csrf_token %}
|
||||
<label class="sr-only" for="knowledgeSearchQuery">检索问题</label>
|
||||
<input id="knowledgeSearchQuery" name="query" type="search" placeholder="输入审查问题或关键词">
|
||||
<button type="submit">测试检索</button>
|
||||
</form>
|
||||
<div class="knowledge-search-results" id="knowledgeSearchResults">
|
||||
<p class="panel-empty">输入问题后查看命中材料、依据片段和相似度。</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="knowledge-right-display">
|
||||
<section class="attachment-manager-panel knowledge-panel knowledge-document-list-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>知识库材料列表</h3>
|
||||
<input class="attachment-search" id="knowledgeDocumentSearch" type="search" placeholder="搜索文件名">
|
||||
</div>
|
||||
<div class="attachment-table-wrap">
|
||||
<table class="attachment-table knowledge-document-table" id="knowledgeDocumentTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>状态</th>
|
||||
<th>材料名称</th>
|
||||
<th>文件名</th>
|
||||
<th>大小</th>
|
||||
<th>入库状态</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for document in knowledge_base.managed_documents %}
|
||||
<tr
|
||||
data-document-id="{{ document.id }}"
|
||||
data-detail-url="/api/review-agent/knowledge-base/documents/{{ document.id }}/"
|
||||
data-index-url="/api/review-agent/knowledge-base/documents/{{ document.id }}/index/"
|
||||
>
|
||||
<td>{% if document.is_active %}启用{% else %}停用{% endif %}</td>
|
||||
<td class="attachment-name">{{ document.display_name }}</td>
|
||||
<td>{{ document.original_name }}</td>
|
||||
<td>{{ document.file_size }} bytes</td>
|
||||
<td>{{ document.indexed_label }}</td>
|
||||
<td>{{ document.updated_at|slice:":19" }}</td>
|
||||
<td class="attachment-actions">
|
||||
<button type="button" data-kb-action="index">解析入库</button>
|
||||
<button type="button" data-kb-action="edit">编辑</button>
|
||||
<button type="button" data-kb-action="toggle">{% if document.is_active %}停用{% else %}启用{% endif %}</button>
|
||||
<button type="button" data-kb-action="delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="table-empty">当前知识库暂无材料</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="attachment-manager-panel knowledge-panel knowledge-source-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>内置法规材料</h3>
|
||||
<input class="attachment-search" id="knowledgeSourceSearch" type="search" placeholder="搜索内置材料">
|
||||
</div>
|
||||
<div class="attachment-table-wrap">
|
||||
<table class="attachment-table knowledge-source-table" id="knowledgeSourceTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>状态</th>
|
||||
<th>文件</th>
|
||||
<th>类型</th>
|
||||
<th>大小</th>
|
||||
<th>索引</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for source in knowledge_base.sources %}
|
||||
<tr data-source-name="{{ source.name }}">
|
||||
<td>{% if source.supported %}可解析{% else %}暂不支持{% endif %}</td>
|
||||
<td class="attachment-name">{{ source.relative_path }}</td>
|
||||
<td>{{ source.suffix }}</td>
|
||||
<td>{{ source.size }} bytes</td>
|
||||
<td>{{ source.indexed_label }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="table-empty">暂无法规材料</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'js/knowledge_base.js' %}?v=20260608-kb5"></script>
|
||||
{% endblock %}
|
||||
173
templates/workbench.html
Normal file
173
templates/workbench.html
Normal file
@@ -0,0 +1,173 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}首页 - DEMO-AGENT V2{% endblock %}
|
||||
{% block body_class %}app-body{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="tabbar" role="tablist" aria-label="页面切换">
|
||||
<a class="tab active" href="{% url 'home' %}" role="tab" aria-selected="true">首页</a>
|
||||
<a class="tab" href="{% url 'chat' %}" role="tab" aria-selected="false">审核智能体</a>
|
||||
<a class="tab" href="{% url 'knowledge_base_manager' %}" role="tab" aria-selected="false">知识库管理</a>
|
||||
<a class="tab" href="{% url 'attachment_manager' %}" role="tab" aria-selected="false">附件管理</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="user-menu">
|
||||
<button class="user-menu-trigger" type="button">
|
||||
<span class="avatar large">{{ request.user.username|slice:":1"|upper }}</span>
|
||||
<div class="user-copy">
|
||||
<strong>{{ request.user.username }}</strong>
|
||||
<span>当前登录用户</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="dashboard-page">
|
||||
<header class="dashboard-hero attachment-manager-toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">首页</p>
|
||||
<h1>注册资料审核工作台</h1>
|
||||
<p>当前账号资料、知识库、附件与审核处理数据总览。</p>
|
||||
</div>
|
||||
<a class="return-chat-link dashboard-primary-action" href="{% url 'chat' %}">进入审核智能体</a>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="首页关键指标">
|
||||
<article class="metric-card">
|
||||
<span>对话总数</span>
|
||||
<strong>{{ dashboard.metrics.conversation_count }}</strong>
|
||||
<em>已处理 {{ dashboard.metrics.recent_conversation_count }}</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>附件总数</span>
|
||||
<strong>{{ dashboard.metrics.attachment_count }}</strong>
|
||||
<em>启用 {{ dashboard.metrics.active_attachment_count }}</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>知识库材料</span>
|
||||
<strong>{{ dashboard.metrics.knowledge_document_count }}</strong>
|
||||
<em>管理 {{ dashboard.knowledge.document_count }} · 内置 {{ dashboard.knowledge.builtin_source_count }}</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>执行中批次</span>
|
||||
<strong>{{ dashboard.metrics.running_batch_count }}</strong>
|
||||
<em>总批次 {{ dashboard.metrics.total_batch_count }}</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>已处理批次</span>
|
||||
<strong>{{ dashboard.metrics.handled_batch_count }}</strong>
|
||||
<em>成功 {{ dashboard.metrics.success_batch_count }}</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>等待确认</span>
|
||||
<strong>{{ dashboard.metrics.waiting_batch_count }}</strong>
|
||||
<em>需人工处理</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>失败批次</span>
|
||||
<strong>{{ dashboard.metrics.failed_batch_count }}</strong>
|
||||
<em>需排查</em>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>申报填表</span>
|
||||
<strong>{{ dashboard.workflow.application_form_fill_count }}</strong>
|
||||
<em>自动填表批次</em>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="dashboard-split">
|
||||
<section class="attachment-manager-panel dashboard-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>知识库概览</h3>
|
||||
</div>
|
||||
<dl class="dashboard-stat-list">
|
||||
<div>
|
||||
<dt>管理文档</dt>
|
||||
<dd>{{ dashboard.knowledge.document_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内置材料</dt>
|
||||
<dd>{{ dashboard.knowledge.builtin_source_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>已索引</dt>
|
||||
<dd>{{ dashboard.knowledge.indexed_document_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>向量片段</dt>
|
||||
<dd>{{ dashboard.knowledge.chunk_count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="attachment-manager-panel dashboard-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>附件与文档概览</h3>
|
||||
</div>
|
||||
<dl class="dashboard-stat-list">
|
||||
<div>
|
||||
<dt>附件总数</dt>
|
||||
<dd>{{ dashboard.attachments.attachment_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>启用附件</dt>
|
||||
<dd>{{ dashboard.attachments.active_attachment_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近上传</dt>
|
||||
<dd>{{ dashboard.attachments.recent_attachment_count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>关联对话</dt>
|
||||
<dd>{{ dashboard.attachments.conversation_count }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="attachment-manager-panel dashboard-panel">
|
||||
<div class="summary-subheading">
|
||||
<h3>最近处理记录</h3>
|
||||
<span>最近 8 条</span>
|
||||
</div>
|
||||
<div class="attachment-table-wrap">
|
||||
<table class="attachment-table recent-activity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>名称或批次号</th>
|
||||
<th>状态</th>
|
||||
<th>更新时间</th>
|
||||
<th>入口</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in dashboard.recent_records %}
|
||||
<tr>
|
||||
<td>{{ record.type }}</td>
|
||||
<td class="attachment-name">{{ record.title }}</td>
|
||||
<td>{{ record.status }}</td>
|
||||
<td>{{ record.updated_at|date:"Y-m-d H:i" }}</td>
|
||||
<td class="attachment-actions">
|
||||
<a href="{{ record.url }}">查看</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="table-empty">暂无处理记录</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -31,7 +31,7 @@ def test_workspace_renders_application_form_fill_workflow_card(client, django_us
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "AFF-CARD" in content
|
||||
|
||||
59
tests/test_chat_knowledge_context.py
Normal file
59
tests/test_chat_knowledge_context.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from review_agent.models import KnowledgeBaseDocument
|
||||
from review_agent.services import build_knowledge_context
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_build_knowledge_context_ignores_irrelevant_rag_chunks(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"source": "附件 4 体外诊断试剂注册申报资料要求及说明.doc",
|
||||
"text": "预期用途应明确产品用于检测的分析物和功能。",
|
||||
"score": 7.636,
|
||||
"metadata": {"source_type": "regulatory_document"},
|
||||
}
|
||||
],
|
||||
"error_message": "",
|
||||
},
|
||||
)
|
||||
|
||||
context = build_knowledge_context("孙之烨是谁")
|
||||
|
||||
assert context == ""
|
||||
|
||||
|
||||
def test_build_knowledge_context_uses_full_document_when_name_matches(settings, tmp_path, monkeypatch, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document_path = tmp_path / "resume.txt"
|
||||
document_path.write_text(
|
||||
"孙之烨,负责审核智能体项目。\n完整经历:曾组织技术分享并带队参加竞赛。",
|
||||
encoding="utf-8",
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.txt",
|
||||
storage_path=str(document_path),
|
||||
file_size=document_path.stat().st_size,
|
||||
status=KnowledgeBaseDocument.Status.ACTIVE,
|
||||
is_active=True,
|
||||
indexed_chunk_count=2,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=5: {"query": query, "results": [], "error_message": ""},
|
||||
)
|
||||
|
||||
context = build_knowledge_context("孙之烨是谁")
|
||||
|
||||
assert "全文材料" in context
|
||||
assert "来源:用户知识库/孙之烨-260510.txt" in context
|
||||
assert "完整经历:曾组织技术分享并带队参加竞赛" in context
|
||||
@@ -17,7 +17,7 @@ def test_workspace_renders_summary_panel(client, django_user_model):
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
@@ -37,7 +37,7 @@ def test_workspace_links_to_attachment_manager(client, django_user_model):
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
@@ -85,7 +85,7 @@ def test_attachment_manager_selects_conversation_and_lists_attachments(client, d
|
||||
assert "编辑" in content
|
||||
assert "删除" in content
|
||||
assert "attachment-manager-split" in content
|
||||
assert reverse("home") + f"?conversation={conversation.pk}" in content
|
||||
assert reverse("chat") + f"?conversation={conversation.pk}" in content
|
||||
|
||||
|
||||
def test_attachment_manager_uses_compact_admin_layout(client, django_user_model):
|
||||
@@ -142,7 +142,7 @@ def test_workspace_renders_workflow_history_as_batch_carousel(client, django_use
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
@@ -265,7 +265,7 @@ def test_workspace_tool_buttons_fill_default_prompts(client, django_user_model):
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
script = open("static/js/app.js", encoding="utf-8").read()
|
||||
|
||||
@@ -254,6 +254,33 @@ def test_conversation_list_api_returns_owned_conversations_with_attachment_count
|
||||
assert payload["conversations"][0]["attachment_count"] == 1
|
||||
|
||||
|
||||
def test_conversation_delete_api_removes_owned_conversation(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
owned = Conversation.objects.create(user=user, title="待删除")
|
||||
other_conversation = Conversation.objects.create(user=other, title="别人的会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("review_agent_conversation_detail", args=[owned.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["ok"] is True
|
||||
assert not Conversation.objects.filter(pk=owned.pk).exists()
|
||||
assert Conversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_conversation_delete_api_rejects_unowned_conversation(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
other_conversation = Conversation.objects.create(user=other, title="别人的会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(reverse("review_agent_conversation_detail", args=[other_conversation.pk]))
|
||||
|
||||
assert response.status_code == 404
|
||||
assert Conversation.objects.filter(pk=other_conversation.pk).exists()
|
||||
|
||||
|
||||
def test_patch_attachment_updates_name_and_active_state(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
@@ -201,17 +201,36 @@ def test_stream_message_returns_workflow_meta_when_triggered(settings, django_us
|
||||
def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
calls = []
|
||||
|
||||
def fake_stream_reply(conversation, content):
|
||||
def fake_stream_reply(conversation, content, knowledge_context=""):
|
||||
calls.append(knowledge_context)
|
||||
yield "普通回复"
|
||||
|
||||
monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply)
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.search_knowledge_base",
|
||||
lambda query, n_results=3: {
|
||||
"query": query,
|
||||
"results": [
|
||||
{
|
||||
"source": "用户知识库/1/2/孙之烨-260510.pdf",
|
||||
"text": "孙之烨负责审核智能体项目。",
|
||||
"score": 0.23,
|
||||
}
|
||||
],
|
||||
"error_message": "",
|
||||
},
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "你好"))
|
||||
frames = list(stream_message(conversation, "孙之烨是谁"))
|
||||
|
||||
joined = "".join(frames)
|
||||
assert "普通回复" in joined
|
||||
assert "workflow_started" not in joined
|
||||
assert calls
|
||||
assert "孙之烨负责审核智能体项目" in calls[0]
|
||||
assert "用户知识库/1/2/孙之烨-260510.pdf" in calls[0]
|
||||
|
||||
|
||||
def test_stream_message_meta_uses_first_prompt_title_for_new_conversation(monkeypatch, django_user_model):
|
||||
@@ -257,12 +276,15 @@ def test_stream_message_falls_back_to_non_stream_reply_when_stream_breaks(monkey
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
def broken_stream_reply(conversation, content):
|
||||
def broken_stream_reply(conversation, content, knowledge_context=""):
|
||||
yield "已生成部分内容"
|
||||
raise RuntimeError("provider connection reset")
|
||||
|
||||
monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply)
|
||||
monkeypatch.setattr("review_agent.services.generate_reply", lambda conversation, content: "非流式完整回复")
|
||||
monkeypatch.setattr(
|
||||
"review_agent.services.generate_reply",
|
||||
lambda conversation, content, knowledge_context="": "非流式完整回复",
|
||||
)
|
||||
|
||||
frames = list(stream_message(conversation, "普通问题"))
|
||||
|
||||
|
||||
146
tests/test_home_dashboard.py
Normal file
146
tests/test_home_dashboard.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.models import (
|
||||
ApplicationFormFillBatch,
|
||||
Conversation,
|
||||
FileAttachment,
|
||||
FileSummaryBatch,
|
||||
KnowledgeBaseDocument,
|
||||
RegulatoryReviewBatch,
|
||||
)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_home_dashboard_renders_current_user_metrics(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="注册资料会话")
|
||||
other_conversation = Conversation.objects.create(user=other, title="其他用户会话")
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="active.docx",
|
||||
storage_path="x/active.docx",
|
||||
file_size=128,
|
||||
is_active=True,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
original_name="deleted.docx",
|
||||
storage_path="x/deleted.docx",
|
||||
file_size=128,
|
||||
is_active=False,
|
||||
upload_status=FileAttachment.UploadStatus.DELETED,
|
||||
)
|
||||
FileAttachment.objects.create(
|
||||
conversation=other_conversation,
|
||||
user=other,
|
||||
original_name="other.docx",
|
||||
storage_path="x/other.docx",
|
||||
file_size=128,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="法规资料",
|
||||
original_name="rule.md",
|
||||
storage_path="kb/rule.md",
|
||||
file_size=64,
|
||||
is_active=True,
|
||||
indexed_chunk_count=3,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="删除资料",
|
||||
original_name="deleted.md",
|
||||
storage_path="kb/deleted.md",
|
||||
file_size=64,
|
||||
status=KnowledgeBaseDocument.Status.DELETED,
|
||||
is_active=False,
|
||||
indexed_chunk_count=5,
|
||||
)
|
||||
KnowledgeBaseDocument.objects.create(
|
||||
user=other,
|
||||
display_name="其他资料",
|
||||
original_name="other.md",
|
||||
storage_path="kb/other.md",
|
||||
file_size=64,
|
||||
indexed_chunk_count=9,
|
||||
)
|
||||
summary = FileSummaryBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
batch_no="FS-RUN",
|
||||
status=FileSummaryBatch.Status.RUNNING,
|
||||
)
|
||||
RegulatoryReviewBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="RR-WAIT",
|
||||
status=RegulatoryReviewBatch.Status.WAITING_USER,
|
||||
risk_summary={"high": 2},
|
||||
)
|
||||
ApplicationFormFillBatch.objects.create(
|
||||
conversation=conversation,
|
||||
user=user,
|
||||
source_summary_batch=summary,
|
||||
batch_no="AFF-OK",
|
||||
status=ApplicationFormFillBatch.Status.SUCCESS,
|
||||
)
|
||||
FileSummaryBatch.objects.create(
|
||||
conversation=other_conversation,
|
||||
user=other,
|
||||
batch_no="FS-OTHER",
|
||||
status=FileSummaryBatch.Status.FAILED,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("home"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "注册资料审核工作台" in content
|
||||
assert "当前账号资料、知识库、附件与审核处理数据总览" in content
|
||||
assert "工作流流程" not in content
|
||||
assert "对话总数" in content
|
||||
assert "附件总数" in content
|
||||
assert "知识库材料" in content
|
||||
assert "内置材料" in content
|
||||
assert f"管理 {1} · 内置" in content
|
||||
assert "向量片段" in content
|
||||
assert "FS-RUN" in content
|
||||
assert "RR-WAIT" in content
|
||||
assert "AFF-OK" in content
|
||||
assert "FS-OTHER" not in content
|
||||
assert "其他用户会话" not in content
|
||||
assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content
|
||||
|
||||
|
||||
def test_chat_route_renders_review_agent_workspace(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="审核会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode("utf-8")
|
||||
assert "审核智能体" in content
|
||||
assert 'id="summaryPanel"' in content
|
||||
assert f'action="{reverse("chat")}"' in content
|
||||
assert f'href="{reverse("chat")}?conversation={conversation.pk}"' in content
|
||||
|
||||
|
||||
def test_legacy_home_conversation_redirects_to_chat(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="旧入口会话")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response["Location"] == f"{reverse('chat')}?conversation={conversation.pk}"
|
||||
220
tests/test_knowledge_base.py
Normal file
220
tests/test_knowledge_base.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
|
||||
from review_agent.knowledge_base import build_knowledge_base_context, delete_document, search_knowledge_base
|
||||
from review_agent.models import KnowledgeBaseDocument
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_knowledge_base_context_reports_rule_and_sources():
|
||||
context = build_knowledge_base_context()
|
||||
|
||||
assert context["rule"]["code"] == "nmpa_ivd_registration_v1"
|
||||
assert context["rule"]["requirement_count"] > 0
|
||||
assert context["source_count"] > 0
|
||||
assert context["collection_name"] == "nmpa_ivd_registration_v1"
|
||||
|
||||
|
||||
def test_knowledge_base_page_requires_login(client):
|
||||
response = client.get(reverse("knowledge_base_manager"))
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
def test_knowledge_base_page_renders_for_user(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("knowledge_base_manager"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "知识库管理" in response.content.decode("utf-8")
|
||||
assert "RAG 检索测试" in response.content.decode("utf-8")
|
||||
content = response.content.decode("utf-8")
|
||||
tabbar = content[content.index('<div class="tabbar"') : content.index("</div>", content.index('<div class="tabbar"'))]
|
||||
assert tabbar.index("审核智能体") < tabbar.index("知识库管理") < tabbar.index("附件管理")
|
||||
|
||||
|
||||
def test_knowledge_base_status_api(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("knowledge_base_status"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rule"]["code"] == "nmpa_ivd_registration_v1"
|
||||
|
||||
|
||||
def test_knowledge_base_search_rejects_blank_query():
|
||||
payload = search_knowledge_base("")
|
||||
|
||||
assert payload["results"] == []
|
||||
assert "请输入" in payload["error_message"]
|
||||
|
||||
|
||||
def test_knowledge_base_search_filters_deleted_managed_documents(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
deleted_document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
status=KnowledgeBaseDocument.Status.DELETED,
|
||||
is_active=False,
|
||||
indexed_chunk_count=7,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"review_agent.knowledge_base.retrieve_citations",
|
||||
lambda *args, **kwargs: [
|
||||
{
|
||||
"source": "用户知识库/1/1/孙之烨-260510.pdf",
|
||||
"text": "孙之烨负责审核智能体项目。",
|
||||
"score": 0.2,
|
||||
"metadata": {"source_type": "managed_document", "document_id": deleted_document.pk},
|
||||
},
|
||||
{
|
||||
"source": "法规材料.doc",
|
||||
"text": "注册检验报告要求。",
|
||||
"score": 0.3,
|
||||
"metadata": {"source_type": "regulatory_document"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
payload = search_knowledge_base("孙之烨是谁")
|
||||
|
||||
assert [item["source"] for item in payload["results"]] == ["法规材料.doc"]
|
||||
|
||||
|
||||
def test_knowledge_base_search_api_returns_payload(client, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(reverse("knowledge_base_search"), {"query": "注册检验报告要求"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert set(response.json()) == {"query", "results", "error_message"}
|
||||
|
||||
|
||||
def test_knowledge_base_document_crud_api(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
|
||||
upload_response = client.post(
|
||||
reverse("knowledge_base_document_list"),
|
||||
{
|
||||
"display_name": "注册检验报告要求",
|
||||
"description": "用于法规依据检索",
|
||||
"is_active": "true",
|
||||
"file": SimpleUploadedFile("report.md", b"# report", content_type="text/markdown"),
|
||||
},
|
||||
)
|
||||
|
||||
assert upload_response.status_code == 200
|
||||
document_id = upload_response.json()["document"]["id"]
|
||||
document = KnowledgeBaseDocument.objects.get(pk=document_id)
|
||||
assert document.display_name == "注册检验报告要求"
|
||||
assert document.indexed_chunk_count > 0
|
||||
|
||||
list_response = client.get(reverse("knowledge_base_document_list"))
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["documents"][0]["display_name"] == "注册检验报告要求"
|
||||
|
||||
detail_response = client.get(reverse("knowledge_base_document_detail", args=[document_id]))
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["document"]["original_name"] == "report.md"
|
||||
assert "已入库" in detail_response.json()["document"]["indexed_label"]
|
||||
|
||||
patch_response = client.patch(
|
||||
reverse("knowledge_base_document_detail", args=[document_id]),
|
||||
data='{"display_name": "更新后的法规材料", "is_active": false}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["document"]["display_name"] == "更新后的法规材料"
|
||||
assert patch_response.json()["document"]["is_active"] is False
|
||||
|
||||
delete_response = client.delete(reverse("knowledge_base_document_detail", args=[document_id]))
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
assert KnowledgeBaseDocument.objects.get(pk=document_id).status == KnowledgeBaseDocument.Status.DELETED
|
||||
|
||||
|
||||
def test_delete_document_removes_managed_chunks_from_index(monkeypatch, django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="孙之烨简历",
|
||||
original_name="孙之烨-260510.pdf",
|
||||
storage_path="knowledge_base/resume.pdf",
|
||||
file_size=1,
|
||||
indexed_chunk_count=7,
|
||||
metadata={"index_status": "indexed", "index_error": ""},
|
||||
)
|
||||
deleted_filters = []
|
||||
|
||||
class FakeCollection:
|
||||
def delete(self, where):
|
||||
deleted_filters.append(where)
|
||||
|
||||
monkeypatch.setattr("review_agent.knowledge_base._load_chroma_collection", lambda: FakeCollection())
|
||||
|
||||
delete_document(document)
|
||||
|
||||
document.refresh_from_db()
|
||||
assert document.status == KnowledgeBaseDocument.Status.DELETED
|
||||
assert document.is_active is False
|
||||
assert document.indexed_chunk_count == 0
|
||||
assert document.metadata["index_status"] == "deleted"
|
||||
assert deleted_filters == [{"document_id": document.pk}]
|
||||
|
||||
|
||||
def test_knowledge_base_document_api_is_scoped_to_owner(client, django_user_model):
|
||||
owner = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
other = django_user_model.objects.create_user(username="other", password="pass")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=owner,
|
||||
display_name="法规材料",
|
||||
original_name="a.md",
|
||||
storage_path="knowledge_base/a.md",
|
||||
file_size=1,
|
||||
)
|
||||
client.force_login(other)
|
||||
|
||||
response = client.patch(
|
||||
reverse("knowledge_base_document_detail", args=[document.pk]),
|
||||
data='{"display_name": "越权修改"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_knowledge_base_document_manual_index_api(client, settings, tmp_path, django_user_model):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
client.force_login(user)
|
||||
source_path = tmp_path / "manual.md"
|
||||
source_path.write_text("# manual\n注册检验报告要求", encoding="utf-8")
|
||||
document = KnowledgeBaseDocument.objects.create(
|
||||
user=user,
|
||||
display_name="manual.md",
|
||||
original_name="manual.md",
|
||||
storage_path=str(source_path),
|
||||
file_size=source_path.stat().st_size,
|
||||
indexed_chunk_count=0,
|
||||
)
|
||||
|
||||
response = client.post(reverse("knowledge_base_document_index", args=[document.pk]))
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.indexed_chunk_count > 0
|
||||
assert "已入库" in response.json()["document"]["indexed_label"]
|
||||
@@ -3,7 +3,7 @@ from urllib import request
|
||||
|
||||
import pytest
|
||||
|
||||
from review_agent.llm import stream_reply
|
||||
from review_agent.llm import build_messages, stream_reply
|
||||
from review_agent.models import Conversation
|
||||
|
||||
|
||||
@@ -39,3 +39,16 @@ def test_stream_reply_skips_malformed_sse_data(monkeypatch, settings, django_use
|
||||
chunks = list(stream_reply(conversation, "你好"))
|
||||
|
||||
assert chunks == ["A", "B"]
|
||||
|
||||
|
||||
def test_build_messages_includes_knowledge_context(django_user_model):
|
||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
||||
conversation = Conversation.objects.create(user=user, title="会话")
|
||||
|
||||
messages = build_messages(conversation, "孙之烨是谁", knowledge_context="来源:简历\n孙之烨负责审核智能体项目。")
|
||||
|
||||
assert messages[0]["role"] == "system"
|
||||
assert messages[1]["role"] == "system"
|
||||
assert "全局知识库" in messages[1]["content"]
|
||||
assert "孙之烨负责审核智能体项目" in messages[1]["content"]
|
||||
assert messages[-1] == {"role": "user", "content": "孙之烨是谁"}
|
||||
|
||||
@@ -44,7 +44,7 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model):
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "RR-CARD" in content
|
||||
@@ -97,7 +97,7 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "适用条件确认" in content
|
||||
@@ -152,7 +152,7 @@ def test_workspace_refreshes_incomplete_condition_confirmation_candidates(client
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "体外诊断试剂" in content
|
||||
@@ -193,7 +193,7 @@ def test_workspace_renders_rectification_actions_and_summaries(client, tmp_path,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"{reverse('home')}?conversation={conversation.pk}")
|
||||
response = client.get(f"{reverse('chat')}?conversation={conversation.pk}")
|
||||
|
||||
content = response.content.decode("utf-8")
|
||||
assert "data-rectification-action=\"full-review\"" in content
|
||||
|
||||
Reference in New Issue
Block a user