Compare commits

...

5 Commits

30 changed files with 3571 additions and 53 deletions

32
PRODUCT.md Normal file
View File

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

View File

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

View 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 原型,逐步演进为注册资料准备和审核过程中的智能协作平台。

View File

@@ -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"]) @require_http_methods(["GET"])
@login_required @login_required
def attachment_download(request, conversation_id: int, attachment_id: int): def attachment_download(request, conversation_id: int, attachment_id: int):

View 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

View File

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

View File

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

View File

@@ -399,6 +399,45 @@ class RegulatoryRuleVersion(models.Model):
return self.code return self.code
class KnowledgeBaseDocument(models.Model):
"""Stores user-managed knowledge-base source documents."""
class Status(models.TextChoices):
ACTIVE = "active", "启用"
DISABLED = "disabled", "停用"
DELETED = "deleted", "已删除"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="knowledge_base_documents",
)
display_name = models.CharField(max_length=255)
original_name = models.CharField(max_length=255)
storage_path = models.CharField(max_length=500)
file_size = models.BigIntegerField(default=0)
content_type = models.CharField(max_length=120, blank=True, default="")
description = models.TextField(blank=True, default="")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
is_active = models.BooleanField(default=True)
indexed_chunk_count = models.PositiveIntegerField(default=0)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "ra_knowledge_base_document"
ordering = ["-updated_at", "-id"]
indexes = [
models.Index(fields=["user", "status"], name="idx_ra_kb_doc_user_status"),
models.Index(fields=["user", "created_at"], name="idx_ra_kb_doc_user_created"),
models.Index(fields=["status", "updated_at"], name="idx_ra_kb_doc_status_updated"),
]
def __str__(self) -> str:
return self.display_name
class ApplicationFormFillBatch(models.Model): class ApplicationFormFillBatch(models.Model):
"""Tracks one application-form auto-fill workflow run.""" """Tracks one application-form auto-fill workflow run."""

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
import shutil
import subprocess import subprocess
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
@@ -102,6 +103,33 @@ def _iter_docx_blocks(document):
def _extract_legacy_doc_with_libreoffice(path: Path) -> str: def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
cached = _cached_docx_path(path)
if cached.exists():
return extract_text_from_path(cached)
try:
return _extract_legacy_doc_with_libreoffice_convert(path)
except RuntimeError as libreoffice_error:
try:
return _extract_legacy_doc_with_word_com(path)
except RuntimeError as word_error:
try:
return _extract_legacy_doc_with_powershell_word_com(path)
except RuntimeError as powershell_error:
raise RuntimeError(
f"无法转换法规 .doc 材料:{path.name}"
f"LibreOffice 错误:{libreoffice_error}"
f"Word COM 错误:{word_error}"
f"PowerShell Word COM 错误:{powershell_error}"
) from powershell_error
def _cached_docx_path(path: Path) -> Path:
digest = hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:12]
cache_dir = Path(settings.MEDIA_ROOT) / "regulatory_review" / "docx_cache"
return cache_dir / f"{path.stem}-{digest}.docx"
def _extract_legacy_doc_with_libreoffice_convert(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir) target_dir = Path(tmp_dir)
try: try:
@@ -128,6 +156,72 @@ def _extract_legacy_doc_with_libreoffice(path: Path) -> str:
return extract_text_from_path(converted) return extract_text_from_path(converted)
def _extract_legacy_doc_with_word_com(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir)
converted = target_dir / f"{path.stem}.docx"
word = None
try:
import pythoncom
import win32com.client
pythoncom.CoInitialize()
word = win32com.client.DispatchEx("Word.Application")
word.Visible = False
document = word.Documents.Open(str(path.resolve()), ReadOnly=True)
document.SaveAs(str(converted.resolve()), FileFormat=16)
document.Close(False)
except Exception as exc:
raise RuntimeError(f"无法通过 Word COM 转换法规 .doc 材料:{path.name}") from exc
finally:
if word is not None:
try:
word.Quit()
except Exception:
pass
try:
pythoncom.CoUninitialize()
except Exception:
pass
if not converted.exists():
raise RuntimeError(f"Word COM 未生成 docx{path.name}")
return extract_text_from_path(converted)
def _extract_legacy_doc_with_powershell_word_com(path: Path) -> str:
with tempfile.TemporaryDirectory() as tmp_dir:
target_dir = Path(tmp_dir)
converted = target_dir / f"{path.stem}.docx"
source_path = str(path.resolve()).replace("'", "''")
target_path = str(converted.resolve()).replace("'", "''")
script = (
"$ErrorActionPreference = 'Stop';"
"$word = New-Object -ComObject Word.Application;"
"$word.Visible = $false;"
"try {"
f"$doc = $word.Documents.Open('{source_path}', $false, $true);"
f"$doc.SaveAs([ref]'{target_path}', [ref]16);"
"$doc.Close([ref]$false);"
"} finally { $word.Quit() }"
)
powershell = shutil.which("powershell") or shutil.which("pwsh")
if not powershell:
raise RuntimeError("PowerShell 不可用,无法调用 Word COM。")
try:
subprocess.run(
[powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
check=True,
capture_output=True,
text=True,
timeout=90,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
raise RuntimeError(f"无法通过 PowerShell Word COM 转换法规 .doc 材料:{path.name}") from exc
if not converted.exists():
raise RuntimeError(f"PowerShell Word COM 未生成 docx{path.name}")
return extract_text_from_path(converted)
def collect_source_chunks(source_dir: Path) -> list[TextChunk]: def collect_source_chunks(source_dir: Path) -> list[TextChunk]:
chunks: list[TextChunk] = [] chunks: list[TextChunk] = []
for path in sorted(source_dir.rglob("*")): for path in sorted(source_dir.rglob("*")):

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
from pathlib import Path
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from django.conf import settings from django.conf import settings
@@ -9,8 +10,10 @@ from django.utils import timezone
from .file_summary.skills.attachment_reader import AttachmentReaderSkill from .file_summary.skills.attachment_reader import AttachmentReaderSkill
from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
from .knowledge_base import search_knowledge_base
from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
from .models import Conversation, FileAttachment, FileSummaryBatch, 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 ( from .application_form_fill.workflow import (
create_application_form_fill_batch, create_application_form_fill_batch,
find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch, find_latest_successful_summary_batch as find_latest_successful_form_fill_summary_batch,
@@ -104,8 +107,9 @@ def send_message(conversation: Conversation, content: str) -> tuple[Message, Mes
"""Stores one user message and one provider-backed assistant reply.""" """Stores one user message and one provider-backed assistant reply."""
user_message = append_user_message(conversation, content) user_message = append_user_message(conversation, content)
knowledge_context = build_knowledge_context(content)
try: try:
reply_content = generate_reply(conversation, content) reply_content = generate_reply(conversation, content, knowledge_context=knowledge_context)
except (LLMConfigurationError, LLMRequestError) as exc: except (LLMConfigurationError, LLMRequestError) as exc:
reply_content = f"模型调用失败:{exc}" reply_content = f"模型调用失败:{exc}"
@@ -391,8 +395,9 @@ def stream_message(conversation: Conversation, content: str):
stream_failed = False stream_failed = False
stream_error = "" stream_error = ""
knowledge_context = build_knowledge_context(content)
try: try:
for chunk in stream_reply(conversation, content): for chunk in stream_reply(conversation, content, knowledge_context=knowledge_context):
assistant_parts.append(chunk) assistant_parts.append(chunk)
yield sse_event("chunk", {"delta": chunk}) yield sse_event("chunk", {"delta": chunk})
except (LLMConfigurationError, LLMRequestError) as exc: except (LLMConfigurationError, LLMRequestError) as exc:
@@ -412,7 +417,7 @@ def stream_message(conversation: Conversation, content: str):
if stream_failed: if stream_failed:
try: try:
fallback_reply = generate_reply(conversation, content) fallback_reply = generate_reply(conversation, content, knowledge_context=knowledge_context)
assistant_parts = [fallback_reply] assistant_parts = [fallback_reply]
logger.info( logger.info(
"Non-stream fallback reply succeeded", "Non-stream fallback reply succeeded",
@@ -461,6 +466,118 @@ def build_conversation_title(content: str) -> str:
return normalized[:24] return normalized[:24]
def build_knowledge_context(content: str, *, n_results: int = 5) -> str:
"""Formats global knowledge-base search hits for normal chat prompts."""
full_document_context = build_filename_matched_document_context(content)
if full_document_context:
return full_document_context
try:
payload = search_knowledge_base(content, n_results=n_results)
except Exception as exc:
logger.warning("Knowledge-base search failed", extra={"error": str(exc)})
return ""
if payload.get("error_message"):
return ""
results = [
item
for item in _rank_knowledge_results(content, payload.get("results") or [])
if _is_relevant_knowledge_result(content, item)
]
lines: list[str] = []
for index, item in enumerate(results[:n_results], start=1):
text = " ".join(str(item.get("text") or "").split())
if not text:
continue
source = str(item.get("source") or "未知来源")
score = item.get("score")
score_label = f"score={score:.4f}" if isinstance(score, (int, float)) else ""
lines.append(f"[{index}] 来源:{source}{score_label}\n{text[:1200]}")
return "\n\n".join(lines)
def build_filename_matched_document_context(query: str, *, max_chars: int = 12000) -> str:
terms = _knowledge_query_terms(query)
if not terms:
return ""
matches = []
for document in KnowledgeBaseDocument.objects.filter(
status=KnowledgeBaseDocument.Status.ACTIVE,
is_active=True,
).order_by("-updated_at", "-id"):
filename = f"{document.display_name} {document.original_name}"
if any(term and term in filename for term in terms):
matches.append(document)
if not matches:
return ""
lines = [
"以下材料因用户问题中的关键词命中文档名称,已读取全文供回答前比对和总结。"
]
for index, document in enumerate(matches[:3], start=1):
text = _extract_managed_document_text(document)
if not text:
continue
lines.append(
f"[全文材料 {index}] 来源:用户知识库/{document.original_name}\n"
f"{' '.join(text.split())[:max_chars]}"
)
return "\n\n".join(lines).strip()
def _extract_managed_document_text(document: KnowledgeBaseDocument) -> str:
try:
return extract_text_from_path(Path(document.storage_path))
except Exception as exc:
logger.warning(
"Managed document full-text extraction failed",
extra={"document_id": document.pk, "error": str(exc)},
)
return ""
def _rank_knowledge_results(query: str, results: list[dict[str, object]]) -> list[dict[str, object]]:
terms = [term for term in _knowledge_query_terms(query) if term]
def sort_key(item: dict[str, object]) -> tuple[int, float]:
source = str(item.get("source") or "")
text = str(item.get("text") or "")
haystack = f"{source}\n{text}"
direct_hit = any(term in haystack for term in terms)
score = item.get("score")
numeric_score = float(score) if isinstance(score, (int, float)) else 999999.0
return (0 if direct_hit else 1, numeric_score)
return sorted(results, key=sort_key)
def _is_relevant_knowledge_result(query: str, item: dict[str, object]) -> bool:
terms = _knowledge_query_terms(query)
if not terms:
return False
source = str(item.get("source") or "")
text = str(item.get("text") or "")
haystack = f"{source}\n{text}"
if any(term in haystack for term in terms):
return True
metadata = item.get("metadata") or {}
if metadata.get("source_type") == "managed_document":
return True
return False
def _knowledge_query_terms(query: str) -> list[str]:
normalized = "".join((query or "").split())
if not normalized:
return []
stop_chars = set("是谁什么哪里如何怎么请问一下帮我你能告诉吗??,。.")
compact = "".join(char for char in normalized if char not in stop_chars)
terms = [compact] if compact else []
if normalized not in terms:
terms.append(normalized)
return terms
def _select_attachments_for_reader(conversation: Conversation, content: str): def _select_attachments_for_reader(conversation: Conversation, content: str):
attachments = list( attachments = list(
FileAttachment.objects.filter( FileAttachment.objects.filter(

View File

@@ -6,6 +6,7 @@ from .file_summary.views import (
attachments, attachments,
batch_events, batch_events,
batch_status, batch_status,
conversation_detail,
conversation_list, conversation_list,
conversation_messages, conversation_messages,
export_download, export_download,
@@ -20,6 +21,13 @@ from .application_form_fill.views import (
batch_status as application_form_fill_batch_status, batch_status as application_form_fill_batch_status,
start as application_form_fill_start, 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 = [ urlpatterns = [
@@ -28,6 +36,11 @@ urlpatterns = [
conversation_list, conversation_list,
name="review_agent_conversation_list", name="review_agent_conversation_list",
), ),
path(
"api/review-agent/conversations/<int:conversation_id>/",
conversation_detail,
name="review_agent_conversation_detail",
),
path( path(
"api/review-agent/conversations/<int:conversation_id>/attachments/", "api/review-agent/conversations/<int:conversation_id>/attachments/",
attachments, attachments,
@@ -98,4 +111,29 @@ urlpatterns = [
application_form_fill_batch_status, application_form_fill_batch_status,
name="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",
),
] ]

View File

@@ -1,7 +1,10 @@
from django.contrib.auth.decorators import login_required 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.http import HttpRequest, HttpResponse, JsonResponse, StreamingHttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.http import urlencode
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from .services import ( from .services import (
@@ -12,9 +15,43 @@ from .services import (
stream_message, stream_message,
) )
from .models import ApplicationFormFillBatch, Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun 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 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 @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def workspace(request: HttpRequest) -> HttpResponse: def workspace(request: HttpRequest) -> HttpResponse:
@@ -26,7 +63,7 @@ def workspace(request: HttpRequest) -> HttpResponse:
if action == "new_conversation": if action == "new_conversation":
conversation = create_conversation(request.user) conversation = create_conversation(request.user)
return redirect(f"/?conversation={conversation.pk}") return redirect(f"/chat/?conversation={conversation.pk}")
if action == "send_message": if action == "send_message":
content = (request.POST.get("prompt") or "").strip() content = (request.POST.get("prompt") or "").strip()
@@ -34,7 +71,7 @@ def workspace(request: HttpRequest) -> HttpResponse:
conversation = create_conversation(request.user) conversation = create_conversation(request.user)
if content: if content:
send_message(conversation, 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() search = (request.GET.get("q") or "").strip()
conversations = list_conversations(request.user, search) 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 @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def stream_chat(request: HttpRequest) -> HttpResponse: def stream_chat(request: HttpRequest) -> HttpResponse:
@@ -217,3 +349,139 @@ def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str:
if batch.risk_notes: if batch.risk_notes:
parts.append(f"提示 {len(batch.risk_notes)}") parts.append(f"提示 {len(batch.risk_notes)}")
return " · ".join(parts) 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}",
}

View File

@@ -147,7 +147,7 @@ input:focus {
gap: 24px; gap: 24px;
padding: 18px; padding: 18px;
min-height: 0; min-height: 0;
overflow-y: auto; overflow: hidden;
background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%); background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
transition: width 180ms ease, padding 180ms ease, transform 180ms ease; transition: width 180ms ease, padding 180ms ease, transform 180ms ease;
@@ -259,19 +259,47 @@ input:focus {
text-transform: uppercase; text-transform: uppercase;
} }
.sidebar-group {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.history-list { .history-list {
display: grid; display: grid;
align-content: start;
gap: 8px; 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 { .history-item {
position: relative;
display: grid; display: grid;
gap: 4px; grid-template-columns: minmax(0, 1fr) 28px;
padding: 14px; align-items: center;
gap: 8px;
padding: 10px 8px 10px 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: 14px;
color: var(--text); color: var(--text);
text-decoration: none;
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.82);
} }
@@ -281,7 +309,18 @@ input:focus {
background: #edf4ff; background: #edf4ff;
} }
.history-link {
display: grid;
min-width: 0;
gap: 4px;
color: inherit;
text-decoration: none;
}
.history-title { .history-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
@@ -291,6 +330,41 @@ input:focus {
font-size: 12px; 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 { .history-empty {
padding: 16px 14px; padding: 16px 14px;
border: 1px dashed var(--line-strong); border: 1px dashed var(--line-strong);
@@ -800,11 +874,13 @@ input:focus {
.workspace[data-sidebar-state="collapsed"] .search-form, .workspace[data-sidebar-state="collapsed"] .search-form,
.workspace[data-sidebar-state="collapsed"] .sidebar-label, .workspace[data-sidebar-state="collapsed"] .sidebar-label,
.workspace[data-sidebar-state="collapsed"] .history-title, .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; display: none;
} }
.workspace[data-sidebar-state="collapsed"] .history-item { .workspace[data-sidebar-state="collapsed"] .history-item {
grid-template-columns: minmax(0, 1fr);
place-items: center; place-items: center;
padding: 12px; padding: 12px;
} }
@@ -1402,6 +1478,116 @@ input:focus {
background: #eaf2ff; 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, .table-empty,
.attachment-manager-empty { .attachment-manager-empty {
color: var(--muted); color: var(--muted);
@@ -1422,6 +1608,639 @@ input:focus {
margin: 0; 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) { @media (max-width: 640px) {
.tabbar { .tabbar {
overflow-x: auto; overflow-x: auto;
@@ -1510,9 +2329,97 @@ input:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.knowledge-workbench {
grid-template-columns: 1fr;
}
.attachment-search { .attachment-search {
width: 100%; 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 { @keyframes pulse-caret {

View File

@@ -26,6 +26,13 @@
return; return;
} }
function getCsrfToken() {
if (!composer) {
return "";
}
return new FormData(composer).get("csrfmiddlewaretoken") || "";
}
function isMobile() { function isMobile() {
return window.matchMedia("(max-width: 980px)").matches; return window.matchMedia("(max-width: 980px)").matches;
} }
@@ -415,19 +422,60 @@
empty.remove(); empty.remove();
} }
var item = document.createElement("a"); var item = document.createElement("div");
item.className = "history-item active"; item.className = "history-item active";
item.setAttribute("data-conversation-id", conversationId); item.setAttribute("data-conversation-id", conversationId);
item.href = "/?conversation=" + conversationId; item.setAttribute("data-delete-url", "/api/review-agent/conversations/" + conversationId + "/");
item.innerHTML = item.innerHTML =
'<span class="history-title">' + '<a class="history-link" href="/?conversation=' +
encodeURIComponent(conversationId) +
'"><span class="history-title">' +
escapeHtml(encodedTitle) + escapeHtml(encodedTitle) +
'</span><span class="history-meta">' + '</span><span class="history-meta">' +
meta + meta +
"</span>"; '</span></a><button class="history-delete" type="button" data-conversation-delete aria-label="删除对话 ' +
escapeHtml(encodedTitle) +
'" title="删除对话">×</button>';
list.prepend(item); 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) { function setConversationTitle(title) {
if (!title) { if (!title) {
return; 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(); syncNodeRailVisibility();
syncLatestMessageIdFromDom(); syncLatestMessageIdFromDom();
bindNodeAnchorClicks(); bindNodeAnchorClicks();
@@ -1176,6 +1243,7 @@
bindConditionConfirmForms(); bindConditionConfirmForms();
bindRectificationActionButtons(); bindRectificationActionButtons();
bindPromptTemplateButtons(); bindPromptTemplateButtons();
bindConversationDeleteButtons();
refreshRunningWorkflowCards(); refreshRunningWorkflowCards();
if (chatScroll) { if (chatScroll) {

238
static/js/knowledge_base.js Normal file
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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]");
})();

View File

@@ -9,9 +9,9 @@
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<div class="tabbar" role="tablist" aria-label="页面切换"> <div class="tabbar" role="tablist" aria-label="页面切换">
<a class="tab" href="/" role="tab" aria-selected="false">首页</a> <a class="tab" href="{% url 'home' %}" role="tab" aria-selected="false">首页</a>
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button> <a class="tab" href="{% url 'chat' %}" role="tab" aria-selected="false">审核智能体</a>
<a class="tab" href="/" 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> <a class="tab active" href="{% url 'attachment_manager' %}" role="tab" aria-selected="true">附件管理</a>
</div> </div>
</div> </div>
@@ -52,7 +52,7 @@
{% endfor %} {% endfor %}
</select> </select>
{% if selected_conversation %} {% 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 %} {% endif %}
</div> </div>
</header> </header>

View File

@@ -5,7 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}DEMO-AGENT V2{% endblock %}</title> <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> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}

View File

@@ -9,9 +9,9 @@
<header class="topbar"> <header class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<div class="tabbar" role="tablist" aria-label="页面切换"> <div class="tabbar" role="tablist" aria-label="页面切换">
<a class="tab" href="/" role="tab" aria-selected="false">首页</a> <a class="tab" href="{% url 'home' %}" role="tab" aria-selected="false">首页</a>
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button> <a class="tab active" href="{% url 'chat' %}" role="tab" aria-selected="true">审核智能体</a>
<a class="tab active" href="/" 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> <a class="tab" href="{% url 'attachment_manager' %}" role="tab" aria-selected="false">附件管理</a>
</div> </div>
</div> </div>
@@ -72,14 +72,26 @@
<p class="sidebar-label">对话记录</p> <p class="sidebar-label">对话记录</p>
<nav class="history-list" aria-label="对话历史"> <nav class="history-list" aria-label="对话历史">
{% for conversation in conversations %} {% for conversation in conversations %}
<a <div
class="history-item{% if current_conversation and current_conversation.pk == conversation.pk %} active{% endif %}" class="history-item{% if current_conversation and current_conversation.pk == conversation.pk %} active{% endif %}"
data-conversation-id="{{ conversation.pk }}" 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> <a
<span class="history-meta">{{ conversation.updated_at|date:"m月d日 H:i" }}</span> class="history-link"
</a> 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 %} {% empty %}
<div class="history-empty"> <div class="history-empty">
<p>暂无会话记录</p> <p>暂无会话记录</p>
@@ -190,7 +202,7 @@
</div> </div>
<div class="composer-wrap"> <div class="composer-wrap">
<form class="composer" action="/" method="post" id="chatComposer"> <form class="composer" action="{% url 'chat' %}" method="post" id="chatComposer">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="send_message"> <input type="hidden" name="action" value="send_message">
<input type="hidden" name="conversation_id" id="conversationIdInput" value="{% if current_conversation %}{{ current_conversation.pk }}{% endif %}"> <input type="hidden" name="conversation_id" id="conversationIdInput" value="{% if current_conversation %}{{ current_conversation.pk }}{% endif %}">
@@ -350,5 +362,5 @@
{% block scripts %} {% 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/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="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 %} {% endblock %}

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

View File

@@ -31,7 +31,7 @@ def test_workspace_renders_application_form_fill_workflow_card(client, django_us
) )
client.force_login(user) 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") content = response.content.decode("utf-8")
assert "AFF-CARD" in content assert "AFF-CARD" in content

View 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

View File

@@ -17,7 +17,7 @@ def test_workspace_renders_summary_panel(client, django_user_model):
) )
client.force_login(user) 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 assert response.status_code == 200
content = response.content.decode("utf-8") 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="会话") conversation = Conversation.objects.create(user=user, title="会话")
client.force_login(user) 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 assert response.status_code == 200
content = response.content.decode("utf-8") 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 "删除" in content assert "删除" in content
assert "attachment-manager-split" 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): 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) 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 assert response.status_code == 200
content = response.content.decode("utf-8") 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="会话") conversation = Conversation.objects.create(user=user, title="会话")
client.force_login(user) 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") content = response.content.decode("utf-8")
script = open("static/js/app.js", encoding="utf-8").read() script = open("static/js/app.js", encoding="utf-8").read()

View File

@@ -254,6 +254,33 @@ def test_conversation_list_api_returns_owned_conversations_with_attachment_count
assert payload["conversations"][0]["attachment_count"] == 1 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): def test_patch_attachment_updates_name_and_active_state(client, django_user_model):
user = django_user_model.objects.create_user(username="owner", password="pass") user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话") conversation = Conversation.objects.create(user=user, title="会话")

View File

@@ -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): 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") user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话") 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 "普通回复" yield "普通回复"
monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply) 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) joined = "".join(frames)
assert "普通回复" in joined assert "普通回复" in joined
assert "workflow_started" not 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): 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") user = django_user_model.objects.create_user(username="owner", password="pass")
conversation = Conversation.objects.create(user=user, title="会话") conversation = Conversation.objects.create(user=user, title="会话")
def broken_stream_reply(conversation, content): def broken_stream_reply(conversation, content, knowledge_context=""):
yield "已生成部分内容" yield "已生成部分内容"
raise RuntimeError("provider connection reset") raise RuntimeError("provider connection reset")
monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply) 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, "普通问题")) frames = list(stream_message(conversation, "普通问题"))

View 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}"

View 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"]

View File

@@ -3,7 +3,7 @@ from urllib import request
import pytest import pytest
from review_agent.llm import stream_reply from review_agent.llm import build_messages, stream_reply
from review_agent.models import Conversation 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, "你好")) chunks = list(stream_reply(conversation, "你好"))
assert chunks == ["A", "B"] 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": "孙之烨是谁"}

View File

@@ -44,7 +44,7 @@ def test_workspace_renders_regulatory_workflow_card(client, django_user_model):
) )
client.force_login(user) 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") content = response.content.decode("utf-8")
assert "RR-CARD" in content assert "RR-CARD" in content
@@ -97,7 +97,7 @@ def test_workspace_renders_condition_confirmation_form(client, django_user_model
) )
client.force_login(user) 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") content = response.content.decode("utf-8")
assert "适用条件确认" in content assert "适用条件确认" in content
@@ -152,7 +152,7 @@ def test_workspace_refreshes_incomplete_condition_confirmation_candidates(client
) )
client.force_login(user) 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") content = response.content.decode("utf-8")
assert "体外诊断试剂" in content assert "体外诊断试剂" in content
@@ -193,7 +193,7 @@ def test_workspace_renders_rectification_actions_and_summaries(client, tmp_path,
) )
client.force_login(user) 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") content = response.content.decode("utf-8")
assert "data-rectification-action=\"full-review\"" in content assert "data-rectification-action=\"full-review\"" in content