From 77d9420d43349a11e111f1cf53acdb5092a5e41e Mon Sep 17 00:00:00 2001 From: bruce Date: Thu, 4 Jun 2026 00:49:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E4=B8=8E=E9=80=9A=E7=9F=A5=E7=95=99=E7=97=95?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent_core/orchestrator.py | 39 +++++++++++ agent_core/results.py | 5 ++ agent_core/schemas/outputs.py | 2 + agent_core/structured_output.py | 54 +++++++++++++++ apps/audit/services.py | 35 +++++++++- apps/audit/views.py | 18 ++++- apps/chat/migrations/__init__.py | 0 apps/chat/views.py | 23 ++++++- templates/audit/log_detail.html | 109 +++++++++++++++---------------- templates/audit/log_list.html | 61 +++++++++-------- tests/test_agent_core.py | 57 ++++++++++++++++ tests/test_audit.py | 81 ++++++++++++++++++++++- tests/test_chat.py | 43 ++++++++++++ 13 files changed, 431 insertions(+), 96 deletions(-) create mode 100644 apps/chat/migrations/__init__.py diff --git a/agent_core/orchestrator.py b/agent_core/orchestrator.py index 287cdb8..9b14e53 100644 --- a/agent_core/orchestrator.py +++ b/agent_core/orchestrator.py @@ -57,6 +57,14 @@ def run_agent(scenario_config: dict, user_input: str, options: dict | None = Non latency_ms=latency_ms, status="failed", error=str(llm_response.error or "未知模型错误"), + conversation_id=str(options.get("conversation_id", "")), + batch_id=str(options.get("batch_id", "")), + product_name=str(options.get("product_name", "")), + notification_payload=_build_notification_payload( + {"notify_reason": "task_failed", "owner_roles": []}, + options=options, + status="failed", + ), ) structured_output, _ = parse_structured_output(llm_response.content, output_type) @@ -70,6 +78,11 @@ def run_agent(scenario_config: dict, user_input: str, options: dict | None = Non model_name=llm_response.model_name or "unknown-model", latency_ms=latency_ms, status="success", + conversation_id=str(options.get("conversation_id", "")), + batch_id=str(options.get("batch_id", "")), + product_name=str(options.get("product_name", "")), + node_results=_build_node_results(output_type, structured_output), + notification_payload=_build_notification_payload(structured_output, options=options, status="success"), ) @@ -151,3 +164,29 @@ def _format_tool_calls(tool_calls: list[dict]) -> str: f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}" ) return "\n".join(lines) + + +def _build_node_results(output_type: str, structured_output: dict) -> list[dict]: + return [ + { + "code": output_type, + "label": output_type, + "status": "已完成", + "summary": structured_output.get("summary") or structured_output.get("answer", ""), + } + ] + + +def _build_notification_payload(structured_output: dict, options: dict, status: str) -> dict: + notify_reason = structured_output.get("notify_reason") or ( + "task_completed" if status == "success" else "task_failed" + ) + owners = structured_output.get("owner_roles") or [] + return { + "batch_id": str(options.get("batch_id", "")), + "conversation_id": str(options.get("conversation_id", "")), + "product_name": str(options.get("product_name", "")), + "notify_reason": notify_reason, + "owners": owners, + "status": status, + } diff --git a/agent_core/results.py b/agent_core/results.py index 84f0ba8..2c28a0e 100644 --- a/agent_core/results.py +++ b/agent_core/results.py @@ -20,3 +20,8 @@ class AgentResult: latency_ms: int = 0 status: str = "success" error: str = "" + conversation_id: str = "" + batch_id: str = "" + product_name: str = "" + node_results: list = field(default_factory=list) + notification_payload: dict = field(default_factory=dict) diff --git a/agent_core/schemas/outputs.py b/agent_core/schemas/outputs.py index d95f64b..bc9de08 100644 --- a/agent_core/schemas/outputs.py +++ b/agent_core/schemas/outputs.py @@ -6,6 +6,8 @@ SUPPORTED_OUTPUT_TYPES = { "registration_field_extraction_report", "registration_consistency_report", "registration_risk_report", + "registration_word_export_report", + "feishu_notification_report", "ticket_response", "quality_report", "risk_audit_report", diff --git a/agent_core/structured_output.py b/agent_core/structured_output.py index 98a16c3..262fb3a 100644 --- a/agent_core/structured_output.py +++ b/agent_core/structured_output.py @@ -41,6 +41,60 @@ OUTPUT_FIELD_TEMPLATES = { "suggestions": [], "references": [], }, + "registration_overview_report": { + "batch_id": "", + "product_name": "", + "file_count": 0, + "total_page_count": 0, + "chapter_summary": [], + "documents": [], + "warnings": [], + }, + "registration_completeness_report": { + "summary": "", + "missing_items": [], + "misplaced_items": [], + "risk_level": "medium", + "references": [], + }, + "registration_field_extraction_report": { + "summary": "", + "field_items": [], + "low_confidence_items": [], + "references": [], + }, + "registration_consistency_report": { + "summary": "", + "conflict_items": [], + "mixed_document_risks": [], + "risk_level": "medium", + "references": [], + }, + "registration_risk_report": { + "summary": "", + "risk_items": [], + "highest_risk_level": "medium", + "pass_status": "review_required", + "manual_review_items": [], + "owner_roles": [], + "suggestions": [], + "notify_reason": "task_completed", + }, + "registration_word_export_report": { + "summary": "", + "export_status": "draft_only", + "blocked_items": [], + "download_url": "", + }, + "feishu_notification_report": { + "batch_id": "", + "conversation_id": "", + "notify_reason": "task_completed", + "mentioned_users": [], + "message_status": "pending", + "web_detail_url": "", + "receipt": {}, + }, } diff --git a/apps/audit/services.py b/apps/audit/services.py index 452a303..7a0ef0e 100644 --- a/apps/audit/services.py +++ b/apps/audit/services.py @@ -1,6 +1,6 @@ from agent_core.results import AgentResult -from .models import AgentAuditLog +from .models import AgentAuditLog, NotificationRecord def create_audit_log( @@ -61,3 +61,36 @@ def _mask_token_after_marker(value: str, marker: str) -> str: secret, separator, rest = suffix.partition(" ") masked_secret = "sk-***" if secret.startswith("sk-") else "***" return f"{prefix}{marker}{masked_secret}{separator}{rest}" + + +def create_notification_record( + *, + batch_id: str, + conversation_id: str, + product_name: str, + trigger_source: str, + notify_reason: str, + owner_role: str, + feishu_user_id: str, + message_status: str, + web_detail_url: str, + receipt: dict, +) -> NotificationRecord: + """ + 保存通知留痕。 + + V1 先把通知载荷和结果状态稳定落库, + 真实飞书发送可在后续阶段接入。 + """ + return NotificationRecord.objects.create( + batch_id=batch_id, + conversation_id=conversation_id, + product_name=product_name, + trigger_source=trigger_source, + notify_reason=notify_reason, + owner_role=owner_role, + feishu_user_id=feishu_user_id, + message_status=message_status, + web_detail_url=web_detail_url, + receipt=receipt, + ) diff --git a/apps/audit/views.py b/apps/audit/views.py index 37824dc..bef925b 100644 --- a/apps/audit/views.py +++ b/apps/audit/views.py @@ -1,20 +1,24 @@ from django.shortcuts import get_object_or_404, render -from .models import AgentAuditLog +from .models import AgentAuditLog, NotificationRecord def log_list(request): - # 列表页支持按场景筛选,方便演示时快速定位同一类场景的执行记录。 + # 处理历史页支持按批次、产品和状态筛选。 scenario_id = (request.GET.get("scenario_id") or "").strip() + keyword = (request.GET.get("keyword") or "").strip() logs = AgentAuditLog.objects.all() if scenario_id: logs = logs.filter(scenario_id=scenario_id) + if keyword: + logs = logs.filter(product_name__icontains=keyword) | logs.filter(batch_id__icontains=keyword) return render( request, "audit/log_list.html", { "logs": logs, "selected_scenario_id": scenario_id, + "keyword": keyword, }, ) @@ -23,4 +27,12 @@ def log_detail(request, log_id: int): # 详情页只负责按主键加载审计快照并渲染; # 所有脱敏和字段映射都应在服务层完成。 audit_log = get_object_or_404(AgentAuditLog, pk=log_id) - return render(request, "audit/log_detail.html", {"log": audit_log}) + notifications = NotificationRecord.objects.filter( + conversation_id=audit_log.conversation_id, + batch_id=audit_log.batch_id, + ) + return render( + request, + "audit/log_detail.html", + {"log": audit_log, "notifications": notifications}, + ) diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/views.py b/apps/chat/views.py index 171b3b9..f6ab9ea 100644 --- a/apps/chat/views.py +++ b/apps/chat/views.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404, redirect, render from agent_core.orchestrator import run_agent from agent_core.results import AgentResult -from apps.audit.services import create_audit_log +from apps.audit.services import create_audit_log, create_notification_record from apps.documents.models import SubmissionBatch, UploadedDocument from apps.scenarios.services import get_scenario @@ -70,6 +70,7 @@ def detail(request, conversation_id: str): conversation_id=conversation.conversation_id, product_name=conversation.product_name, ) + _persist_notification_records(result) active_node = "risk" return render( @@ -89,3 +90,23 @@ def detail(request, conversation_id: str): "active_node": active_node, }, ) + + +def _persist_notification_records(result: AgentResult) -> None: + payload = result.notification_payload or {} + owners = payload.get("owners") or [] + if not owners: + return + for owner in owners: + create_notification_record( + batch_id=payload.get("batch_id", ""), + conversation_id=payload.get("conversation_id", ""), + product_name=payload.get("product_name", ""), + trigger_source="agent_execution", + notify_reason=payload.get("notify_reason", "task_completed"), + owner_role=owner.get("owner_role", ""), + feishu_user_id=owner.get("feishu_user_id", ""), + message_status="sent" if result.status == "success" else "failed", + web_detail_url="", + receipt={"status": result.status}, + ) diff --git a/templates/audit/log_detail.html b/templates/audit/log_detail.html index 9750f6b..261269e 100644 --- a/templates/audit/log_detail.html +++ b/templates/audit/log_detail.html @@ -1,73 +1,66 @@ {% extends "base.html" %} -{% block title %}审计日志详情{% endblock %} +{% block title %}处理历史详情{% endblock %} {% block content %} -
-
-
场景
-
{{ log.scenario_name }}
+
+
+

执行上下文

+
    +
  • 用户输入
    {{ log.user_input|linebreaksbr }}
  • +
  • 最终回答
    {{ log.final_answer|linebreaksbr }}
  • +
  • 结构化输出
    {{ log.structured_output }}
  • +
-
-
状态
-
{{ log.get_status_display_text }}
-
-
-
耗时
-
{{ log.latency_ms }} ms
+ +
+

执行证据

+
    +
  • 引用来源
    {{ log.retrieved_chunks }}
  • +
  • 工具调用
    {{ log.tool_calls }}
  • +
  • 原始输出
    {{ log.raw_output }}
  • +
-
-
-
-

用户输入

-
{{ log.user_input|linebreaksbr }}
-
- -
-

最终回答

-
{{ log.final_answer|linebreaksbr }}
-
- -
-

结构化输出

-
{{ log.structured_output }}
-
-
- -
-
-

引用来源

-
{{ log.retrieved_chunks }}
-
- -
-

工具调用

-
{{ log.tool_calls }}
-
- -
-

原始输出

-
{{ log.raw_output }}
-
- - {% if log.error_message %} -
-

错误信息

-
{{ log.error_message }}
-
- {% endif %} +
+

通知留痕

+
+ + + + + + + + + + + + {% for item in notifications %} + + + + + + + + {% empty %} + + {% endfor %} + +
触发原因责任角色飞书用户消息状态详情链接
{{ item.notify_reason }}{{ item.owner_role }}{{ item.feishu_user_id }}{{ item.message_status }}{{ item.web_detail_url|default:"-" }}
当前执行尚无通知留痕。
{% endblock %} diff --git a/templates/audit/log_list.html b/templates/audit/log_list.html index 9ab102d..85fd354 100644 --- a/templates/audit/log_list.html +++ b/templates/audit/log_list.html @@ -1,43 +1,38 @@ {% extends "base.html" %} -{% block title %}审计日志{% endblock %} +{% block title %}处理历史{% endblock %} {% block content %} -
-
-
日志总数
-
{{ logs|length }}
-
当前页面加载的执行快照数量。
-
-
-
最近状态
-
{% if logs %}{{ logs.0.get_status_display_text }}{% else %}暂无{% endif %}
-
默认按时间倒序展示最近一次 Agent 执行。
-
-
-
最近场景
-
{% if logs %}{{ logs.0.scenario_name }}{% else %}暂无{% endif %}
-
便于快速定位当前复试演示对应的执行记录。
-
+
+
+
+

历史筛选

+

支持按产品名称或批次号搜索。

+
+
+
+
+ + +
+
+ + 清空 +
+

执行快照列表

-

保留真实审计数据列表,同时把展示形式升级为与首页、大屏一致的分析板风格。

+

围绕 `batch_id / conversation_id / product_name` 展示处理历史。

@@ -46,11 +41,13 @@ ID 场景 + 产品名称 + 批次号 + 会话 输入摘要 状态 模型 - 耗时 - 创建时间 + 时间 详情 @@ -59,17 +56,19 @@ {{ log.id }} {{ log.scenario_name }} + {{ log.product_name|default:"-" }} + {{ log.batch_id|default:"-" }} + {{ log.conversation_id|default:"-" }} {{ log.get_user_input_summary }} {{ log.get_status_display_text }} {{ log.model_name }} - {{ log.latency_ms }} ms {{ log.created_at|date:"Y-m-d H:i" }} 查看详情 {% empty %} - 暂无审计日志,先去执行一次审核工作台任务。 + 暂无处理历史,先去执行一次审核任务。 {% endfor %} diff --git a/tests/test_agent_core.py b/tests/test_agent_core.py index f66fb90..502c859 100644 --- a/tests/test_agent_core.py +++ b/tests/test_agent_core.py @@ -1,6 +1,7 @@ from agent_core.orchestrator import build_messages, run_agent from agent_core.rag.ingest import _split_text, ingest_document from agent_core.rag.retriever import retrieve +from agent_core.schemas.outputs import SUPPORTED_OUTPUT_TYPES def test_run_agent_returns_structured_result_from_llm_output(): @@ -248,3 +249,59 @@ def test_retrieve_returns_empty_when_query_has_no_overlap(tmp_path): ) assert chunks == [] + + +def test_registration_risk_result_includes_owner_fields_and_notification_payload(): + scenario = { + "id": "document_review", + "name": "注册审核智能体", + "agent": { + "role": "注册审核助手", + "goal": "输出风险结果", + "instructions": ["输出结构化风险结果"], + }, + "rag": {"enabled": False}, + "tools": [], + "output": {"type": "registration_risk_report"}, + } + provider_response = """ + { + "summary": "存在高风险项,需人工复核。", + "highest_risk_level": "high", + "pass_status": "blocked", + "owner_roles": [ + { + "owner_role": "注册资料负责人", + "owner_name": "张三", + "department": "注册事务部", + "chapter_scope": "CH1", + "risk_scope": "字段冲突", + "feishu_user_id": "ou_demo_1", + "feishu_open_id": "on_demo_1", + "feishu_name": "张三", + "notify_enabled": true + } + ], + "notify_reason": "task_completed" + } + """ + + class FakeProvider: + def generate(self, messages, response_format=None): + from agent_core.llm_provider import LLMResponse + + return LLMResponse(content=provider_response, model_name="demo-model", success=True) + + result = run_agent(scenario, "请输出风险结果", options={"llm_provider": FakeProvider()}) + + owner = result.notification_payload["owners"][0] + assert result.structured_output["output_type"] == "registration_risk_report" + assert owner["owner_role"] == "注册资料负责人" + assert owner["feishu_user_id"] == "ou_demo_1" + assert owner["feishu_open_id"] == "on_demo_1" + assert result.notification_payload["notify_reason"] == "task_completed" + + +def test_supported_output_types_include_word_export_and_feishu_notification(): + assert "registration_word_export_report" in SUPPORTED_OUTPUT_TYPES + assert "feishu_notification_report" in SUPPORTED_OUTPUT_TYPES diff --git a/tests/test_audit.py b/tests/test_audit.py index fc83ee1..75fdcdb 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -1,8 +1,8 @@ from django.urls import reverse from agent_core.results import AgentResult -from apps.audit.models import AgentAuditLog, DemoBusinessRecord -from apps.audit.services import create_audit_log +from apps.audit.models import AgentAuditLog, DemoBusinessRecord, NotificationRecord +from apps.audit.services import create_audit_log, create_notification_record from agent_core.tools.builtin_tools import query_demo_records @@ -117,3 +117,80 @@ def test_query_demo_records_reads_demo_business_record_table(db): assert result["records"][0]["title"] == "A线缺陷" assert result["records"][0]["payload"] == {"rate": 0.12} + + +def test_audit_log_records_batch_conversation_and_product_context(db): + result = AgentResult(answer="回答", status="success") + + log = create_audit_log( + "document_review", + "注册审核智能体", + "开始审核", + result, + batch_id="SUB-20260604-001", + conversation_id="conv-001", + product_name="新型冠状病毒 2019-nCoV 核酸检测试剂盒", + ) + + assert log.batch_id == "SUB-20260604-001" + assert log.conversation_id == "conv-001" + assert log.product_name == "新型冠状病毒 2019-nCoV 核酸检测试剂盒" + + +def test_create_notification_record_persists_task_completed_and_task_failed(db): + completed = create_notification_record( + batch_id="SUB-20260604-001", + conversation_id="conv-001", + product_name="产品A", + trigger_source="risk_report", + notify_reason="task_completed", + owner_role="注册资料负责人", + feishu_user_id="ou_demo_1", + message_status="sent", + web_detail_url="https://example.com/detail/1", + receipt={"message_id": "msg-1"}, + ) + failed = create_notification_record( + batch_id="SUB-20260604-001", + conversation_id="conv-001", + product_name="产品A", + trigger_source="risk_report", + notify_reason="task_failed", + owner_role="注册资料负责人", + feishu_user_id="ou_demo_1", + message_status="failed", + web_detail_url="https://example.com/detail/1", + receipt={"message_id": "msg-2"}, + ) + + assert NotificationRecord.objects.count() == 2 + assert completed.notify_reason == "task_completed" + assert failed.notify_reason == "task_failed" + + +def test_audit_list_supports_batch_and_product_filters(client, db): + create_audit_log( + "document_review", + "注册审核智能体", + "问题一", + AgentResult(answer="回答一", status="success"), + batch_id="SUB-20260604-001", + conversation_id="conv-001", + product_name="产品A", + ) + create_audit_log( + "document_review", + "注册审核智能体", + "问题二", + AgentResult(answer="回答二", status="success"), + batch_id="SUB-20260604-002", + conversation_id="conv-002", + product_name="产品B", + ) + + response = client.get(reverse("audit:list"), {"keyword": "产品A"}) + + content = response.content.decode("utf-8") + assert response.status_code == 200 + assert "产品A" in content + assert "产品B" not in content diff --git a/tests/test_chat.py b/tests/test_chat.py index 52687db..5477878 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -2,6 +2,7 @@ from django.urls import reverse from agent_core.results import AgentResult from apps.audit.models import AgentAuditLog +from apps.audit.models import NotificationRecord from apps.chat.models import Conversation from apps.documents.models import SubmissionBatch, UploadedDocument @@ -126,3 +127,45 @@ def test_chat_renders_three_column_workspace_and_node_results(client, db): assert "上传区" in content assert "资料包导入 / 已完成" in content assert "目录汇总 / 处理中" in content + + +def test_chat_execution_creates_notification_record_from_agent_result(client, db, monkeypatch): + batch, conversation = _create_conversation_with_batch() + UploadedDocument.objects.create( + batch=batch, + scenario_id="document_review", + original_name="说明书.md", + file_type="md", + size=1, + status=UploadedDocument.STATUS_INDEXED, + ) + + monkeypatch.setattr( + "apps.chat.views.run_agent", + lambda *args, **kwargs: AgentResult( + answer="执行完成", + status="success", + notification_payload={ + "batch_id": batch.batch_id, + "conversation_id": conversation.conversation_id, + "product_name": batch.product_name, + "notify_reason": "task_completed", + "owners": [ + { + "owner_role": "注册资料负责人", + "feishu_user_id": "ou_demo_1", + } + ], + }, + ), + ) + + response = client.post( + reverse("chat:detail", args=[conversation.conversation_id]), + {"message": "执行审核"}, + ) + + assert response.status_code == 200 + record = NotificationRecord.objects.get() + assert record.notify_reason == "task_completed" + assert record.batch_id == batch.batch_id