diff --git a/AGENTS.md b/AGENTS.md index 55661a6..759f75f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`,并按风险状态形成正式版或草稿版。 - 飞书通知当前为离线通知留痕,不直接发送真实飞书消息。 diff --git a/README.md b/README.md index 91c6e25..255a097 100644 --- a/README.md +++ b/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//` | 会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 | +| 审核智能体 | `/`、`/chat/`、`/chat//` | 无资料包知识库问答、会话驱动审核、文档范围选择、节点式结果、能力卡、补传资料、Word 导出、通知与审计回看 | | 资料包 | `/documents/`、`/documents/upload/` | 导入资料包、搜索产品或批次、查看解析状态、异常提示、最近导出和处理链路 | | 处理历史 | `/audit/`、`/audit//` | 按批次、产品、风险状态、通知状态回看执行快照、原始输出、导出摘要和通知回执 | | 知识库治理台 | `/platform/knowledge-base/` | 查看法规规则包、RAG 文档源、切片、字段 Schema、Word 模板、责任人映射和飞书配置 | @@ -126,6 +127,7 @@ DEMO-AGENT/ ## 已落地能力 - 根路径已重定向到审核智能体,降低演示入口复杂度。 +- 审核工作台允许未上传资料时直接发起知识库问答,后续再通过右侧上传区导入资料包。 - 资料包导入支持 PDF、DOCX、MD、TXT、ZIP、7Z、RAR;压缩包内仅导入支持格式,其他文件会生成提示。 - 导入时会创建 `SubmissionBatch`、`UploadedDocument` 和绑定的 `Conversation`。 - 文档解析覆盖文本抽取、PDF 页数统计、DOCX 页数元数据读取、章节点识别、文档角色识别和人工复核标记。 diff --git a/apps/chat/services.py b/apps/chat/services.py index 102abc3..d1d9fc8 100644 --- a/apps/chat/services.py +++ b/apps/chat/services.py @@ -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 [] diff --git a/apps/chat/views.py b/apps/chat/views.py index b00a355..06dd7cb 100644 --- a/apps/chat/views.py +++ b/apps/chat/views.py @@ -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, diff --git a/templates/chat/index.html b/templates/chat/index.html index fc84e9e..f17057d 100644 --- a/templates/chat/index.html +++ b/templates/chat/index.html @@ -53,7 +53,7 @@
  • {{ item.title }}
    产品:{{ item.product_name|default:"未识别" }}
    -
    批次:{{ item.batch_id }}
    +
    批次:{{ item.batch_id|default:"未绑定" }}
    风险:{{ item.risk_level }}
    最近更新:{{ item.updated_at|date:"Y-m-d H:i" }}
    @@ -61,7 +61,10 @@
  • {% empty %} -
  • 暂无会话,请先从资料包页面导入资料。
  • +
  • + 暂无历史会话 +
    可以直接在中间区域提问,Agent 会优先检索知识库。
    +
  • {% endfor %} @@ -75,13 +78,14 @@

    中间区域承接用户问题、Agent 回答和节点式结果摘要。

    - {% if conversation %} + {% if node_results %}
    {% for node in node_results %} {{ node.label }} / {{ node.status }} {% endfor %}
    -
    + {% endif %} + {% csrf_token %}
    {{ form.message.label_tag }} @@ -99,12 +103,12 @@ {{ checkbox.choice_label }} {% empty %} -
    当前资料包还没有可选文档。
    +
    未选择文档时,Agent 会按问题检索当前知识库。
    {% endfor %}
    - +
    {% if result %} @@ -112,10 +116,9 @@ Agent 回答
    {{ result.answer|linebreaksbr }}
    + {% elif not conversation %} +
    可以先不上传资料,直接询问注册法规、资料清单、模板字段或历史知识库内容。
    {% endif %} - {% else %} -
    暂无会话,请先导入资料包。
    - {% endif %}
    @@ -423,7 +426,20 @@ {% else %} -
    暂无绑定资料包。
    +
    暂无绑定资料包,仍可先通过中间对话区查询知识库。
    +
    + {% csrf_token %} + +
    + {{ upload_form.files.label_tag }} + {{ upload_form.files }} +

    支持 PDF、DOCX、MD、TXT、ZIP、7Z 与 RAR。上传后会自动形成资料包并绑定新的审核会话。

    +
    +
    + + 打开导入向导 +
    +
    {% endif %}
    @@ -456,7 +472,7 @@ {% endif %} -
  • 当前会话围绕 `conversation_id / batch_id / product_name` 串联。
  • +
  • 当前会话围绕 conversation_id / batch_id / product_name 串联;未绑定资料包时以知识库问答方式执行。
  • {% if audit_log %}
  • 查看本次处理历史
  • {% endif %} diff --git a/tests/test_chat.py b/tests/test_chat.py index 9f39a73..e369f63 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -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(