feat(chat): allow knowledge chat before upload
This commit is contained in:
@@ -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`,并按风险状态形成正式版或草稿版。
|
||||||
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。
|
- 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -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 页数元数据读取、章节点识别、文档角色识别和人工复核标记。
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,9 +116,8 @@
|
|||||||
<strong>Agent 回答</strong>
|
<strong>Agent 回答</strong>
|
||||||
<div>{{ result.answer|linebreaksbr }}</div>
|
<div>{{ result.answer|linebreaksbr }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% elif not conversation %}
|
||||||
{% else %}
|
<div class="notice" style="margin-top: 16px;">可以先不上传资料,直接询问注册法规、资料清单、模板字段或历史知识库内容。</div>
|
||||||
<div class="notice">暂无会话,请先导入资料包。</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user