Compare commits

...

5 Commits

10 changed files with 707 additions and 145 deletions

21
.env.siliconflow.example Normal file
View File

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

View File

@@ -70,6 +70,7 @@ Django 单体 + 独立 Agent Core + Docker Compose
- 通用场景 YAML、Chat、Documents、Audit、Platform UI 和 Agent Core 已具备可重构基础。
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR。
- 资料包会自动绑定会话,标题优先使用解析出的产品名称。
- 审核智能体允许在未上传资料包时直接发起知识库问答,会话保持未绑定资料包状态并走 RAG 检索链路。
- Agent Core 已具备 Prompt 编排、结构化解析、工具注册、RAG fallback / Chroma 双路径和 OpenAI 兼容 Provider。
- Word 导出已支持生成最小 `.docx`,并按风险状态形成正式版或草稿版。
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。

View File

@@ -26,14 +26,15 @@ V1 采用:
## 当前业务主线
1. 导入注册资料包,支持单文件、多文件和压缩包
2. 解析文件元数据、页数、章节点和产品名称
3. 自动创建资料包批次,并绑定审核会话
4. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告
5. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析
6. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口
7. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史
8. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕
1. 进入审核智能体后,可以先不上传资料,直接通过对话查询法规和业务知识库
2. 导入注册资料包,支持单文件、多文件和压缩包
3. 解析文件元数据、页数、章节点和产品名称
4. 自动创建资料包批次,并绑定审核会话
5. 在审核智能体工作台选择文档范围并发起目录汇总、完整性检查、字段抽取、一致性核查或风险报告
6. Agent Core 按场景配置执行 RAG 检索、工具调用、Prompt 编排、LLM 调用和结构化输出解析
7. 会话页展示节点状态、能力卡、风险摘要、通知信息和导出入口
8. Word 回填导出生成可下载 `.docx`,并记录到资料包和处理历史
9. 审计模块保存成功与失败两类执行快照,并沉淀飞书通知留痕。
## 当前产品入口
@@ -41,7 +42,7 @@ V1 采用:
| 页面 | 路径 | 当前能力 |
|---|---|---|
| 审核智能体 | `/``/chat/``/chat/<conversation_id>/` | 会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 |
| 审核智能体 | `/``/chat/``/chat/<conversation_id>/` | 无资料包知识库问答、会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 |
| 资料包 | `/documents/``/documents/upload/` | 导入资料包、搜索产品或批次、查看解析状态、异常提示、最近导出和处理链路 |
| 处理历史 | `/audit/``/audit/<log_id>/` | 按批次、产品、风险状态、通知状态回看执行快照、原始输出、导出摘要和通知回执 |
| 知识库治理台 | `/platform/knowledge-base/` | 查看法规规则包、RAG 文档源、切片、字段 Schema、Word 模板、责任人映射和飞书配置 |
@@ -126,6 +127,7 @@ DEMO-AGENT/
## 已落地能力
- 根路径已重定向到审核智能体,降低演示入口复杂度。
- 审核工作台允许未上传资料时直接发起知识库问答,后续再通过右侧上传区导入资料包。
- 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR压缩包内仅导入支持格式其他文件会生成提示。
- 导入时会创建 `SubmissionBatch``UploadedDocument` 和绑定的 `Conversation`
- 文档解析覆盖文本抽取、PDF 页数统计、DOCX 页数元数据读取、章节点识别、文档角色识别和人工复核标记。
@@ -160,6 +162,7 @@ Docker Compose 会读取根目录 `.env`,并挂载 `./data` 与 `./configs`。
## 环境变量
项目通过根目录 `.env` 和系统环境变量读取配置。`.env.example` 只作为模板,不应提交真实密钥。
若复试演示使用硅基流动,可复制 `.env.siliconflow.example``.env`,再手动填入 `LLM_API_KEY``EMBEDDING_API_KEY`
```env
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
@@ -184,6 +187,7 @@ CHROMA_PATH=data/chroma
- `EMBEDDING_API_KEY` 为空时自动复用 `LLM_API_KEY`
- `EMBEDDING_BASE_URL` 为空时自动复用 `LLM_BASE_URL`
- `.env.siliconflow.example` 内置硅基流动 `base_url`、Qwen 对话模型和 `BAAI/bge-m3` Embedding 配置。
- Django settings 初始化时会自动加载根目录 `.env`
- 测试环境会在 `tests/conftest.py` 中固定 Mock Provider避免误调用真实 LLM。

View File

@@ -1,7 +1,7 @@
from dataclasses import dataclass
import json
import os
from urllib.error import URLError
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@@ -74,6 +74,7 @@ class OpenAICompatibleProvider:
}
if response_format:
payload["response_format"] = response_format
try:
try:
data = _post_json(
base_url=self.base_url,
@@ -81,6 +82,21 @@ class OpenAICompatibleProvider:
api_key=self.api_key,
payload=payload,
)
except RuntimeError as exc:
# 部分 OpenAI 兼容供应商或模型不支持 response_format。
# 保留结构化优先,遇到 400 时退回普通对话,避免演示链路被接口能力差异阻断。
if not response_format or "HTTP Error 400" not in str(exc):
raise
fallback_payload = {
"model": self.model_name,
"messages": messages,
}
data = _post_json(
base_url=self.base_url,
endpoint="chat/completions",
api_key=self.api_key,
payload=fallback_payload,
)
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
return LLMResponse(
@@ -197,5 +213,11 @@ def _post_json(base_url: str, endpoint: str, api_key: str, payload: dict) -> dic
try:
with urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
error_detail = f"{exc}"
if error_body:
error_detail = f"{error_detail} {error_body}"
raise RuntimeError(f"OpenAI 兼容接口调用失败:{error_detail}") from exc
except URLError as exc:
raise RuntimeError(f"OpenAI 兼容接口调用失败:{exc}") from exc

View File

@@ -28,6 +28,23 @@ def create_conversation_for_batch(batch_id: str, product_name: str) -> Conversat
return conversation
def create_knowledge_conversation() -> Conversation:
"""
创建未绑定资料包的知识库问答会话。
该会话用于用户尚未上传资料时直接向 RAG 知识库提问,
因此 batch_id 与 product_name 保持为空Agent Core 通过空范围执行全局检索。
"""
return Conversation.objects.create(
conversation_id=_generate_conversation_id(),
title="知识库问答会话",
product_name="",
batch_id="",
task_status=Conversation.STATUS_PENDING,
node_results=_build_knowledge_node_results(),
)
def execute_conversation_agent(
*,
conversation: Conversation,
@@ -132,6 +149,14 @@ def _build_initial_node_results() -> list[dict]:
]
def _build_knowledge_node_results() -> list[dict]:
return [
{"code": "knowledge_retrieval", "label": "知识库检索", "status": "待处理"},
{"code": "answer_generation", "label": "问答生成", "status": "待处理"},
{"code": "risk", "label": "风险预警", "status": "待处理"},
]
def _persist_notification_records(result: AgentResult, *, web_detail_url: str = "") -> None:
payload = result.notification_payload or {}
owners = payload.get("owners") or []

View File

@@ -9,7 +9,11 @@ from apps.documents.services import append_documents_to_batch
from .forms import ChatForm, ConversationUploadForm
from .models import Conversation
from .services import execute_conversation_agent, execute_conversation_export
from .services import (
create_knowledge_conversation,
execute_conversation_agent,
execute_conversation_export,
)
RISK_LEVEL_DISPLAY = {
"high": "",
@@ -48,19 +52,48 @@ def index(request):
conversations = Conversation.objects.all()
if conversations.exists():
return redirect("chat:detail", conversation_id=conversations.first().conversation_id)
documents = UploadedDocument.objects.filter(batch__isnull=True)
form = ChatForm(request.POST or None, documents=documents)
upload_form = ConversationUploadForm()
result = None
audit_log = None
conversation = None
if request.method == "POST" and form.is_valid():
conversation = create_knowledge_conversation()
result, audit_log = execute_conversation_agent(
conversation=conversation,
message=form.cleaned_data["message"],
document_ids=form.cleaned_data["document_ids"],
detail_url_builder=lambda log_id: reverse("audit:detail", args=[log_id]),
)
conversation.refresh_from_db()
documents = UploadedDocument.objects.filter(batch__isnull=True)
display_node_results = _normalize_node_results(conversation.node_results if conversation else [])
workspace_summary = _build_workspace_summary(conversation, None, display_node_results) if conversation else _build_empty_workspace_summary()
return render(
request,
"chat/index.html",
{
"conversation": None,
"conversation": conversation,
"conversations": [],
"conversation_history": [],
"form": ChatForm(),
"documents": [],
"result": None,
"audit_log": None,
"node_results": [],
"batch": None,
"form": form,
"documents": documents,
"document_count": documents.count(),
"result": result,
"audit_log": audit_log,
"node_results": display_node_results,
"active_node": None,
"workspace_summary": workspace_summary,
"conversation_context": _build_conversation_context(conversation, None, workspace_summary) if conversation else {},
"prompt_templates": _build_prompt_templates(),
"analysis_card": _build_analysis_card(result, conversation) if conversation else {},
"upload_form": upload_form,
"export_card": _build_export_card(result, conversation) if conversation else {},
"risk_card": _build_risk_card(result, conversation) if conversation else {},
"notify_card": _build_notify_card(result, conversation) if conversation else {},
},
)
@@ -201,6 +234,18 @@ def _build_workspace_summary(
}
def _build_empty_workspace_summary() -> dict:
return {
"highest_risk_level": "-",
"export_allowed": "",
"notify_status": "待处理",
"export_status": "待处理",
"download_url": "",
"file_count": 0,
"page_count": 0,
}
def _build_conversation_context(
conversation: Conversation,
batch: SubmissionBatch | None,

View File

@@ -376,7 +376,7 @@
</div>
</header>
<main class="page">
<main class="page {% block page_class %}{% endblock %}">
{% if messages %}
<div class="stack">
{% for message in messages %}

View File

@@ -1,59 +1,353 @@
{% extends "base.html" %}
{% block title %}审核智能体{% endblock %}
{% block page_class %}chat-page{% endblock %}
{% block content %}
<section class="page-header">
<span class="eyebrow">Agent Workspace</span>
<h1 class="page-title">审核智能体</h1>
<p class="page-lead">以会话为中心组织资料包上传、节点式审核结果和动态任务信息卡。</p>
{% if conversation %}
<div class="badge-row">
<span class="pill pill-accent">批次:{{ conversation.batch_id }}</span>
<span class="pill">产品:{{ conversation.product_name|default:"未识别产品名称" }}</span>
<span class="pill">阶段:{{ conversation_context.task_status }}</span>
</div>
{% endif %}
</section>
<style>
.chat-page {
width: min(100vw - 32px, 1920px);
margin-top: 12px;
gap: 12px;
}
{% if conversation %}
<section class="grid-2">
<article class="panel">
<h2 class="section-title">顶部对话上下文</h2>
<p class="section-copy">进入会话后,先用当前批次、产品和风险状态快速建立审核上下文。</p>
<ul class="detail-list">
<li class="detail-item"><strong>批次编号</strong><div>{{ conversation_context.batch_id }}</div></li>
<li class="detail-item"><strong>产品名称</strong><div>{{ conversation_context.product_name|default:"未识别产品名称" }}</div></li>
<li class="detail-item"><strong>当前流程类型</strong><div>{{ conversation_context.workflow_type }}</div></li>
<li class="detail-item"><strong>当前审核阶段</strong><div>{{ conversation_context.task_status }}</div></li>
<li class="detail-item"><strong>当前最高风险等级</strong><div>{{ conversation_context.highest_risk_level }}</div></li>
<li class="detail-item"><strong>是否允许正式导出</strong><div>{{ conversation_context.export_allowed }}</div></li>
</ul>
</article>
.chat-layout {
display: grid;
grid-template-columns: 280px minmax(680px, 1fr) 340px;
gap: 12px;
align-items: start;
}
<article class="panel">
<h2 class="section-title">推荐提问模板</h2>
<p class="section-copy">用这些提问模板快速进入目录汇总、完整性检查、字段抽取和风险分析。</p>
<div class="button-row">
{% for item in prompt_templates %}
<span class="pill pill-accent">{{ item }}</span>
{% endfor %}
</div>
</article>
</section>
{% endif %}
.history-toggle {
position: absolute;
opacity: 0;
pointer-events: none;
}
<section class="workspace-grid" style="grid-template-columns: 320px minmax(0, 1fr) 360px;">
<div class="stack">
<article class="panel">
.chat-layout:has(.history-toggle:checked) {
grid-template-columns: 54px minmax(760px, 1fr) 340px;
}
.chat-layout:has(.history-toggle:checked) .history-content {
display: none;
}
.chat-layout:has(.history-toggle:checked) .history-panel {
padding: 12px 8px;
}
.chat-layout:has(.history-toggle:checked) .history-collapse {
width: 36px;
height: 36px;
padding: 0;
}
.chat-layout:has(.history-toggle:checked) .history-collapse span {
display: none;
}
.history-panel,
.right-rail {
position: sticky;
top: 84px;
}
.history-head,
.right-rail-head,
.chat-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.history-collapse {
flex: none;
min-height: 34px;
border-radius: 8px;
font-size: 0.86rem;
background: var(--surface-soft);
color: var(--muted);
}
.history-list {
max-height: calc(100vh - 230px);
overflow: auto;
padding-right: 2px;
}
.history-card {
border-radius: 10px;
background: #fff;
}
.chat-shell {
min-height: calc(100vh - 190px);
display: grid;
grid-template-rows: auto minmax(360px, 1fr) auto;
padding: 0;
overflow: hidden;
}
.chat-head {
padding: 16px 18px 12px;
border-bottom: 1px solid var(--border);
margin: 0;
}
.node-strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
max-width: 52vw;
}
.chat-thread {
display: grid;
align-content: start;
gap: 18px;
padding: 22px min(7vw, 72px);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
overflow: auto;
}
.chat-message {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 12px;
max-width: 980px;
}
.chat-message.user {
justify-self: end;
grid-template-columns: minmax(0, 1fr) 34px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.82rem;
background: var(--primary);
color: #fff;
}
.chat-message.user .avatar {
grid-column: 2;
background: #111827;
}
.bubble {
padding: 13px 15px;
border: 1px solid var(--border);
border-radius: 12px;
background: #fff;
line-height: 1.75;
box-shadow: 0 4px 14px rgba(31, 45, 61, 0.04);
}
.chat-message.user .bubble {
grid-column: 1;
grid-row: 1;
background: #eef4ff;
border-color: #d8e5ff;
}
.bubble-meta {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.prompt-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.composer {
padding: 14px min(7vw, 72px) 18px;
border-top: 1px solid var(--border);
background: rgba(255, 255, 255, 0.96);
}
.composer-box {
display: grid;
gap: 10px;
max-width: 1040px;
margin: 0 auto;
border: 1px solid var(--border);
border-radius: 16px;
background: #fff;
padding: 10px;
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.08);
}
.composer-box label {
display: none;
}
.composer-box textarea {
height: 86px;
min-height: 78px;
max-height: 180px;
border: 0;
padding: 8px 10px;
resize: vertical;
box-shadow: none;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #edf1f5;
padding-top: 10px;
}
.scope-summary {
color: var(--muted);
font-size: 0.86rem;
}
.document-scope {
display: none;
padding: 10px;
border-radius: 10px;
background: var(--surface-soft);
}
.composer-box:focus-within .document-scope {
display: block;
}
.send-button {
border-radius: 999px;
min-width: 92px;
}
.result-panel {
padding: 14px 18px;
}
.upload-card {
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.upload-dropzone {
position: relative;
display: grid;
place-items: center;
min-height: 164px;
padding: 18px;
border: 1.5px dashed #9fb7e8;
border-radius: 12px;
background: #f4f8ff;
text-align: center;
color: var(--text);
overflow: hidden;
}
.upload-dropzone input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.upload-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
background: #ffffff;
color: var(--primary);
box-shadow: 0 6px 18px rgba(47, 111, 236, 0.14);
font-size: 1.3rem;
font-weight: 700;
}
.upload-dropzone strong {
display: block;
margin-bottom: 4px;
}
.rail-card {
border: 1px solid var(--border);
border-radius: 12px;
background: #fff;
padding: 12px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.status-tile {
min-height: 74px;
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
background: var(--surface-soft);
}
.status-tile strong {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
}
@media (max-width: 1200px) {
.chat-layout,
.chat-layout:has(.history-toggle:checked) {
grid-template-columns: 1fr;
}
.history-panel,
.right-rail {
position: static;
}
.node-strip {
max-width: 100%;
}
.chat-thread,
.composer {
padding-left: 18px;
padding-right: 18px;
}
}
</style>
<section class="chat-layout">
<input class="history-toggle" id="history-collapsed" type="checkbox">
<aside class="panel history-panel">
<div class="history-head">
<div class="history-content">
<h2 class="section-title">会话历史</h2>
<p class="section-copy">左侧保留历史会话,标题默认使用解析后的产品名称</p>
<ul class="detail-list">
<p class="section-copy">按会话快速切换资料包和知识库问答</p>
</div>
<label class="button history-collapse" for="history-collapsed" title="收起或展开会话历史"> <span>收起</span></label>
</div>
<div class="history-content">
<ul class="detail-list history-list">
{% for item in conversation_history %}
<li class="detail-item">
<li class="detail-item history-card">
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
<div class="muted">批次:{{ item.batch_id }}</div>
<div class="muted">批次:{{ item.batch_id|default:"未绑定" }}</div>
<div class="muted">风险:{{ item.risk_level }}</div>
<div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div>
<div class="badge-row" style="margin-top: 8px;">
@@ -61,36 +355,84 @@
</div>
</li>
{% empty %}
<li class="detail-item">暂无会话,请先从资料包页面导入资料。</li>
<li class="detail-item">
<strong>暂无历史会话</strong>
<div class="muted">可以直接在中间区域提问Agent 会优先检索知识库。</div>
</li>
{% endfor %}
</ul>
</article>
</div>
</aside>
<div class="stack">
<article class="panel">
<div class="section-heading">
<article class="panel chat-shell">
<div class="chat-head">
<div>
<h2 class="section-title">对话区与节点导航</h2>
<p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要</p>
<p class="section-copy">像聊天一样提问Agent 会结合知识库和所选文档回答</p>
</div>
</div>
{% if conversation %}
<div class="badge-row" style="margin-bottom: 14px;">
{% if node_results %}
<div class="node-strip">
{% for node in node_results %}
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
{% endfor %}
</div>
<form method="post" class="stack">
{% endif %}
</div>
<div class="chat-thread">
{% if conversation %}
<div class="chat-message assistant">
<div class="avatar">RA</div>
<div class="bubble">
<span class="bubble-meta">审核智能体</span>
当前会话已就绪。可以询问资料目录、法规依据、字段抽取、一致性问题或风险整改建议。
</div>
</div>
{% else %}
<div class="chat-message assistant">
<div class="avatar">RA</div>
<div class="bubble">
<span class="bubble-meta">审核智能体</span>
可以先不上传资料,直接询问注册法规、资料清单、模板字段或历史知识库内容。
</div>
</div>
{% endif %}
{% if form.message.value %}
<div class="chat-message user">
<div class="avatar"></div>
<div class="bubble">
<span class="bubble-meta">用户问题</span>
{{ form.message.value|linebreaksbr }}
</div>
</div>
{% endif %}
{% if result %}
<div class="chat-message assistant">
<div class="avatar">RA</div>
<div class="bubble">
<span class="bubble-meta">Agent 回答</span>
{{ result.answer|linebreaksbr }}
</div>
</div>
{% else %}
<div class="prompt-chips">
{% for item in prompt_templates %}
<span class="pill pill-accent">{{ item }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<form method="post" class="composer" action="{% if conversation %}{% url 'chat:detail' conversation.conversation_id %}{% else %}{% url 'chat:index' %}{% endif %}">
{% csrf_token %}
<div>
<div class="composer-box">
{{ form.message.label_tag }}
{{ form.message }}
{% if form.message.errors %}
<p class="notice notice-error">{{ form.message.errors|join:" " }}</p>
{% endif %}
</div>
<div>
<div class="document-scope">
{{ form.document_ids.label_tag }}
<div class="checkbox-list">
{% for checkbox in form.document_ids %}
@@ -99,26 +441,19 @@
<span>{{ checkbox.choice_label }}</span>
</label>
{% empty %}
<div class="notice">当前资料包还没有可选文档</div>
<div class="notice">未选择文档时Agent 会按问题检索当前知识库</div>
{% endfor %}
</div>
</div>
<div class="button-row">
<button type="submit">提交审核任务</button>
<div class="composer-actions">
<span class="scope-summary">默认检索知识库;聚焦输入框可选择文档范围。</span>
<button class="send-button" type="submit">发送问题</button>
</div>
</div>
</form>
{% if result %}
<div class="detail-item" style="margin-top: 16px;">
<strong>Agent 回答</strong>
<div>{{ result.answer|linebreaksbr }}</div>
</div>
{% endif %}
{% else %}
<div class="notice">暂无会话,请先导入资料包。</div>
{% endif %}
</article>
<article class="panel">
<article class="panel result-panel">
<h2 class="section-title">节点式结果</h2>
{% if analysis_card or export_card or risk_card or notify_card %}
<div class="stack">
@@ -388,13 +723,17 @@
</article>
</div>
<div class="stack">
<article class="panel">
<aside class="stack right-rail">
<article class="panel upload-card">
<div class="right-rail-head">
<div>
<h2 class="section-title">上传区</h2>
<p class="section-copy">右侧保留资料包上传入口和当前会话的资料上下文</p>
<p class="section-copy">拖拽或点击导入资料包</p>
</div>
</div>
{% if batch %}
<ul class="detail-list">
<li class="detail-item">
<ul class="detail-list" style="margin-bottom: 14px;">
<li class="detail-item rail-card">
<strong>当前资料包</strong>
<div>批次:{{ batch.batch_id }}</div>
<div>文件数:{{ batch.file_count }}</div>
@@ -404,10 +743,14 @@
</ul>
<form method="post" action="{% url 'chat:upload-documents' conversation.conversation_id %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
{% csrf_token %}
<div>
{{ upload_form.files.label_tag }}
<label class="upload-dropzone">
{{ upload_form.files }}
</div>
<span>
<span class="upload-icon"></span>
<strong>拖拽补充资料到这里</strong>
<span class="muted">或点击选择文件,支持多选和压缩包。</span>
</span>
</label>
<div class="button-row">
<button type="submit">继续上传资料</button>
<a class="button" href="{% url 'documents:list' %}">返回资料包</a>
@@ -423,30 +766,48 @@
</div>
</form>
{% else %}
<div class="notice">暂无绑定资料包。</div>
<div class="notice">暂无绑定资料包,仍可先通过中间对话区查询知识库</div>
<form method="post" action="{% url 'documents:upload' %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
{% csrf_token %}
<input type="hidden" name="scenario_id" value="document_review">
<label class="upload-dropzone">
{{ upload_form.files }}
<span>
<span class="upload-icon"></span>
<strong>拖拽资料包到这里</strong>
<span class="muted">PDF、DOCX、MD、TXT、ZIP、7Z、RAR 均可。</span>
</span>
</label>
<div class="button-row">
<button type="submit">导入资料包</button>
<a class="button" href="{% url 'documents:upload' %}">打开导入向导</a>
</div>
</form>
{% endif %}
</article>
<article class="panel">
<h2 class="section-title">动态信息卡</h2>
<ul class="detail-list">
<li class="detail-item">
<div class="status-grid">
<div class="status-tile">
<strong>最高风险等级</strong>
<div>{{ workspace_summary.highest_risk_level }}</div>
</li>
<li class="detail-item">
</div>
<div class="status-tile">
<strong>是否允许正式导出</strong>
<div>{{ workspace_summary.export_allowed }}</div>
</li>
<li class="detail-item">
</div>
<div class="status-tile">
<strong>通知状态</strong>
<div>{{ workspace_summary.notify_status }}</div>
</li>
<li class="detail-item">
</div>
<div class="status-tile">
<strong>导出状态</strong>
<div>{{ workspace_summary.export_status }}</div>
</li>
<li class="detail-item">
</div>
</div>
<ul class="detail-list" style="margin-top: 10px;">
<li class="detail-item rail-card">
<strong>导出下载地址</strong>
<div>
{% if workspace_summary.download_url %}
@@ -456,12 +817,12 @@
{% endif %}
</div>
</li>
<li class="detail-item">当前会话围绕 `conversation_id / batch_id / product_name` 串联。</li>
<li class="detail-item">当前会话围绕 conversation_id / batch_id / product_name 串联;未绑定资料包时以知识库问答方式执行</li>
{% if audit_log %}
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
{% endif %}
</ul>
</article>
</div>
</aside>
</section>
{% endblock %}

View File

@@ -10,6 +10,7 @@ from apps.audit.models import NotificationRecord
from apps.chat.models import Conversation
from apps.chat.services import (
create_conversation_for_batch,
create_knowledge_conversation,
execute_conversation_agent,
execute_conversation_export,
)
@@ -79,6 +80,47 @@ def test_chat_rejects_empty_message(client, db):
assert "请输入要咨询的问题" in response.content.decode("utf-8")
def test_chat_index_allows_question_before_upload_and_shows_upload_control(client, db):
response = client.get(reverse("chat:index"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "发送问题" in content
assert "导入资料包" in content
assert "未选择文档时Agent 会按问题检索当前知识库" in content
assert "暂无绑定资料包,仍可先通过中间对话区查询知识库" in content
def test_chat_index_post_creates_knowledge_conversation_and_runs_agent(client, db, monkeypatch):
captured = {}
def fake_run_agent(scenario_config, user_input, options=None):
captured["scenario_id"] = scenario_config["id"]
captured["user_input"] = user_input
captured["options"] = options or {}
return AgentResult(answer="知识库命中:注册资料目录要求", status="success")
monkeypatch.setattr("apps.chat.services.run_agent", fake_run_agent)
response = client.post(
reverse("chat:index"),
{"message": "注册资料目录有哪些要求?"},
)
content = response.content.decode("utf-8")
conversation = Conversation.objects.get()
assert response.status_code == 200
assert conversation.title == "知识库问答会话"
assert conversation.batch_id == ""
assert captured["scenario_id"] == "document_review"
assert captured["user_input"] == "注册资料目录有哪些要求?"
assert captured["options"]["conversation_id"] == conversation.conversation_id
assert captured["options"]["batch_id"] == ""
assert captured["options"]["document_ids"] == []
assert "知识库命中:注册资料目录要求" in content
assert AgentAuditLog.objects.filter(conversation_id=conversation.conversation_id).count() == 1
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
batch, conversation = _create_conversation_with_batch()
selected = UploadedDocument.objects.create(
@@ -484,7 +526,7 @@ def test_chat_page_shows_upload_entry_and_dynamic_context_cards(client, db):
assert "飞书通知 / 待处理" in content
def test_chat_page_shows_top_context_and_recommended_prompts(client, db):
def test_chat_page_keeps_prompts_inside_chat_thread_without_top_cards(client, db):
batch, conversation = _create_conversation_with_batch()
conversation.task_status = "processing"
conversation.node_results = [
@@ -503,13 +545,9 @@ def test_chat_page_shows_top_context_and_recommended_prompts(client, db):
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "顶部对话上下文" in content
assert "当前流程类型" in content
assert "registration" in content
assert "当前审核阶段" in content
assert "处理中" in content
assert "当前最高风险等级" in content
assert "推荐提问模板" in content
assert "顶部对话上下文" not in content
assert "推荐提问模板" not in content
assert "对话区与节点导航" in content
assert "请汇总当前资料包的章节点、页数和目录覆盖情况" in content
assert "请给出当前资料包的高风险项、责任人和整改建议" in content
@@ -538,19 +576,15 @@ def test_chat_page_explicitly_shows_batch_product_stage_and_export_context(clien
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "Agent Workspace" not in content
assert f"批次:{batch.batch_id}" in content
assert f"产品:{batch.product_name}" in content
assert "阶段:处理中" in content
assert "批次编号" in content
assert batch.batch_id in content
assert "产品名称" in content
assert batch.product_name in content
assert "当前审核阶段" in content
assert "处理中" in content
assert "当前最高风险等级" in content
assert ">高<" in content
assert "批次编号" not in content
assert "当前审核阶段" not in content
assert "最高风险等级" in content
assert "" in content
assert "是否允许正式导出" in content
assert ">否<" in content
assert "" in content
def test_chat_page_shows_overview_card_from_conversation_summary(client, db):

View File

@@ -5,6 +5,8 @@ from agent_core.llm_provider import (
create_llm_provider,
get_runtime_llm_config,
)
from urllib.error import HTTPError
from io import BytesIO
def test_create_llm_provider_requires_api_key_for_openai_compatible():
@@ -72,6 +74,53 @@ def test_openai_compatible_provider_posts_chat_completion(monkeypatch):
assert captured["headers"]["Authorization"] == "Bearer sk-test"
def test_openai_compatible_provider_falls_back_when_response_format_is_rejected(monkeypatch):
captured_bodies = []
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def read(self):
return b'{"choices":[{"message":{"content":"fallback ok"}}],"model":"demo-model"}'
def fake_urlopen(request, timeout):
body = request.data.decode("utf-8")
captured_bodies.append(body)
if len(captured_bodies) == 1:
raise HTTPError(
request.full_url,
400,
"Bad Request",
hdrs=None,
fp=BytesIO(b'{"error":{"message":"response_format is not supported"}}'),
)
return FakeResponse()
monkeypatch.setattr("agent_core.llm_provider.urlopen", fake_urlopen)
provider = create_llm_provider(
{
"LLM_PROVIDER": "openai_compatible",
"LLM_API_KEY": "sk-test",
"LLM_BASE_URL": "https://api.siliconflow.cn/v1",
"LLM_MODEL": "demo-model",
}
)
response = provider.generate(
[{"role": "user", "content": "hello"}],
response_format={"type": "json_object"},
)
assert response.success is True
assert response.content == "fallback ok"
assert '"response_format"' in captured_bodies[0]
assert '"response_format"' not in captured_bodies[1]
def test_embedding_provider_uses_openai_compatible_embeddings(monkeypatch):
class FakeResponse:
def __enter__(self):