Compare commits
5 Commits
1d8a526770
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5f0a968b | |||
| f2c1e3cfa1 | |||
| 5a6e7698e4 | |||
| fecaee0b03 | |||
| efb06519d8 |
21
.env.siliconflow.example
Normal file
21
.env.siliconflow.example
Normal file
@@ -0,0 +1,21 @@
|
||||
DJANGO_SECRET_KEY=replace-with-a-local-secret-key
|
||||
DJANGO_DEBUG=true
|
||||
DJANGO_ALLOWED_HOSTS=*
|
||||
|
||||
# SiliconFlow OpenAI-compatible API
|
||||
# Fill these two keys manually before demo.
|
||||
LLM_PROVIDER=openai_compatible
|
||||
LLM_API_KEY=your_siliconflow_api_key
|
||||
LLM_BASE_URL=https://api.siliconflow.cn/v1
|
||||
LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
|
||||
|
||||
# SiliconFlow embedding model for RAG.
|
||||
# You can reuse the same SiliconFlow key here.
|
||||
EMBEDDING_API_KEY=your_siliconflow_api_key
|
||||
EMBEDDING_BASE_URL=https://api.siliconflow.cn/v1
|
||||
EMBEDDING_MODEL=BAAI/bge-m3
|
||||
|
||||
SCENARIO_CONFIG_DIR=configs
|
||||
GOVERNANCE_CONFIG_PATH=configs/governance.yaml
|
||||
UPLOAD_ROOT=data/uploads
|
||||
CHROMA_PATH=data/chroma
|
||||
@@ -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`,并按风险状态形成正式版或草稿版。
|
||||
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。
|
||||
|
||||
22
README.md
22
README.md
@@ -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。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user