feat(chat): allow knowledge chat before upload

This commit is contained in:
2026-06-04 22:16:54 +08:00
parent 1d8a526770
commit efb06519d8
6 changed files with 158 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@
<li class="detail-item"> <li class="detail-item">
<strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong> <strong><a href="{% url 'chat:detail' item.conversation_id %}">{{ item.title }}</a></strong>
<div class="muted">产品:{{ item.product_name|default:"未识别" }}</div> <div class="muted">产品:{{ item.product_name|default:"未识别" }}</div>
<div class="muted">批次:{{ item.batch_id }}</div> <div class="muted">批次:{{ item.batch_id|default:"未绑定" }}</div>
<div class="muted">风险:{{ item.risk_level }}</div> <div class="muted">风险:{{ item.risk_level }}</div>
<div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div> <div class="muted">最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}</div>
<div class="badge-row" style="margin-top: 8px;"> <div class="badge-row" style="margin-top: 8px;">
@@ -61,7 +61,10 @@
</div> </div>
</li> </li>
{% empty %} {% empty %}
<li class="detail-item">暂无会话,请先从资料包页面导入资料。</li> <li class="detail-item">
<strong>暂无历史会话</strong>
<div class="muted">可以直接在中间区域提问Agent 会优先检索知识库。</div>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</article> </article>
@@ -75,13 +78,14 @@
<p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要。</p> <p class="section-copy">中间区域承接用户问题、Agent 回答和节点式结果摘要。</p>
</div> </div>
</div> </div>
{% if conversation %} {% if node_results %}
<div class="badge-row" style="margin-bottom: 14px;"> <div class="badge-row" style="margin-bottom: 14px;">
{% for node in node_results %} {% for node in node_results %}
<span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span> <span class="pill {% if node.status == '已完成' %}pill-success{% else %}pill-signal{% endif %}">{{ node.label }} / {{ node.status }}</span>
{% endfor %} {% endfor %}
</div> </div>
<form method="post" class="stack"> {% endif %}
<form method="post" class="stack" action="{% if conversation %}{% url 'chat:detail' conversation.conversation_id %}{% else %}{% url 'chat:index' %}{% endif %}">
{% csrf_token %} {% csrf_token %}
<div> <div>
{{ form.message.label_tag }} {{ form.message.label_tag }}
@@ -99,12 +103,12 @@
<span>{{ checkbox.choice_label }}</span> <span>{{ checkbox.choice_label }}</span>
</label> </label>
{% empty %} {% empty %}
<div class="notice">当前资料包还没有可选文档</div> <div class="notice">未选择文档时Agent 会按问题检索当前知识库</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="button-row"> <div class="button-row">
<button type="submit">提交审核任务</button> <button type="submit">发送问题</button>
</div> </div>
</form> </form>
{% if result %} {% if result %}
@@ -112,10 +116,9 @@
<strong>Agent 回答</strong> <strong>Agent 回答</strong>
<div>{{ result.answer|linebreaksbr }}</div> <div>{{ result.answer|linebreaksbr }}</div>
</div> </div>
{% elif not conversation %}
<div class="notice" style="margin-top: 16px;">可以先不上传资料,直接询问注册法规、资料清单、模板字段或历史知识库内容。</div>
{% endif %} {% endif %}
{% else %}
<div class="notice">暂无会话,请先导入资料包。</div>
{% endif %}
</article> </article>
<article class="panel"> <article class="panel">
@@ -423,7 +426,20 @@
</div> </div>
</form> </form>
{% else %} {% else %}
<div class="notice">暂无绑定资料包。</div> <div class="notice">暂无绑定资料包,仍可先通过中间对话区查询知识库</div>
<form method="post" action="{% url 'documents:upload' %}" enctype="multipart/form-data" class="stack" style="margin-top: 16px;">
{% csrf_token %}
<input type="hidden" name="scenario_id" value="document_review">
<div>
{{ upload_form.files.label_tag }}
{{ upload_form.files }}
<p class="help-text">支持 PDF、DOCX、MD、TXT、ZIP、7Z 与 RAR。上传后会自动形成资料包并绑定新的审核会话。</p>
</div>
<div class="button-row">
<button type="submit">导入资料包</button>
<a class="button" href="{% url 'documents:upload' %}">打开导入向导</a>
</div>
</form>
{% endif %} {% endif %}
</article> </article>
@@ -456,7 +472,7 @@
{% endif %} {% endif %}
</div> </div>
</li> </li>
<li class="detail-item">当前会话围绕 `conversation_id / batch_id / product_name` 串联。</li> <li class="detail-item">当前会话围绕 conversation_id / batch_id / product_name 串联;未绑定资料包时以知识库问答方式执行</li>
{% if audit_log %} {% if audit_log %}
<li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li> <li class="detail-item"><a href="{% url 'audit:detail' audit_log.id %}">查看本次处理历史</a></li>
{% endif %} {% endif %}

View File

@@ -10,6 +10,7 @@ from apps.audit.models import NotificationRecord
from apps.chat.models import Conversation from apps.chat.models import Conversation
from apps.chat.services import ( from apps.chat.services import (
create_conversation_for_batch, create_conversation_for_batch,
create_knowledge_conversation,
execute_conversation_agent, execute_conversation_agent,
execute_conversation_export, execute_conversation_export,
) )
@@ -79,6 +80,47 @@ def test_chat_rejects_empty_message(client, db):
assert "请输入要咨询的问题" in response.content.decode("utf-8") assert "请输入要咨询的问题" in response.content.decode("utf-8")
def test_chat_index_allows_question_before_upload_and_shows_upload_control(client, db):
response = client.get(reverse("chat:index"))
content = response.content.decode("utf-8")
assert response.status_code == 200
assert "发送问题" in content
assert "导入资料包" in content
assert "未选择文档时Agent 会按问题检索当前知识库" in content
assert "暂无绑定资料包,仍可先通过中间对话区查询知识库" in content
def test_chat_index_post_creates_knowledge_conversation_and_runs_agent(client, db, monkeypatch):
captured = {}
def fake_run_agent(scenario_config, user_input, options=None):
captured["scenario_id"] = scenario_config["id"]
captured["user_input"] = user_input
captured["options"] = options or {}
return AgentResult(answer="知识库命中:注册资料目录要求", status="success")
monkeypatch.setattr("apps.chat.services.run_agent", fake_run_agent)
response = client.post(
reverse("chat:index"),
{"message": "注册资料目录有哪些要求?"},
)
content = response.content.decode("utf-8")
conversation = Conversation.objects.get()
assert response.status_code == 200
assert conversation.title == "知识库问答会话"
assert conversation.batch_id == ""
assert captured["scenario_id"] == "document_review"
assert captured["user_input"] == "注册资料目录有哪些要求?"
assert captured["options"]["conversation_id"] == conversation.conversation_id
assert captured["options"]["batch_id"] == ""
assert captured["options"]["document_ids"] == []
assert "知识库命中:注册资料目录要求" in content
assert AgentAuditLog.objects.filter(conversation_id=conversation.conversation_id).count() == 1
def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch): def test_chat_passes_selected_document_ids_to_agent_core(client, db, monkeypatch):
batch, conversation = _create_conversation_with_batch() batch, conversation = _create_conversation_with_batch()
selected = UploadedDocument.objects.create( selected = UploadedDocument.objects.create(