feat: 重构处理历史与通知留痕追踪

This commit is contained in:
2026-06-04 00:49:33 +08:00
parent d0841e533f
commit 77d9420d43
13 changed files with 431 additions and 96 deletions

View File

@@ -57,6 +57,14 @@ def run_agent(scenario_config: dict, user_input: str, options: dict | None = Non
latency_ms=latency_ms, latency_ms=latency_ms,
status="failed", status="failed",
error=str(llm_response.error or "未知模型错误"), 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) 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", model_name=llm_response.model_name or "unknown-model",
latency_ms=latency_ms, latency_ms=latency_ms,
status="success", 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', '未知错误')}" f"{index}. 工具={tool_call.get('tool_name')} 失败={tool_call.get('error', '未知错误')}"
) )
return "\n".join(lines) 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,
}

View File

@@ -20,3 +20,8 @@ class AgentResult:
latency_ms: int = 0 latency_ms: int = 0
status: str = "success" status: str = "success"
error: str = "" 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)

View File

@@ -6,6 +6,8 @@ SUPPORTED_OUTPUT_TYPES = {
"registration_field_extraction_report", "registration_field_extraction_report",
"registration_consistency_report", "registration_consistency_report",
"registration_risk_report", "registration_risk_report",
"registration_word_export_report",
"feishu_notification_report",
"ticket_response", "ticket_response",
"quality_report", "quality_report",
"risk_audit_report", "risk_audit_report",

View File

@@ -41,6 +41,60 @@ OUTPUT_FIELD_TEMPLATES = {
"suggestions": [], "suggestions": [],
"references": [], "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": {},
},
} }

View File

@@ -1,6 +1,6 @@
from agent_core.results import AgentResult from agent_core.results import AgentResult
from .models import AgentAuditLog from .models import AgentAuditLog, NotificationRecord
def create_audit_log( def create_audit_log(
@@ -61,3 +61,36 @@ def _mask_token_after_marker(value: str, marker: str) -> str:
secret, separator, rest = suffix.partition(" ") secret, separator, rest = suffix.partition(" ")
masked_secret = "sk-***" if secret.startswith("sk-") else "***" masked_secret = "sk-***" if secret.startswith("sk-") else "***"
return f"{prefix}{marker}{masked_secret}{separator}{rest}" 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,
)

View File

@@ -1,20 +1,24 @@
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from .models import AgentAuditLog from .models import AgentAuditLog, NotificationRecord
def log_list(request): def log_list(request):
# 列表页支持按场景筛选,方便演示时快速定位同一类场景的执行记录 # 处理历史页支持按批次、产品和状态筛选
scenario_id = (request.GET.get("scenario_id") or "").strip() scenario_id = (request.GET.get("scenario_id") or "").strip()
keyword = (request.GET.get("keyword") or "").strip()
logs = AgentAuditLog.objects.all() logs = AgentAuditLog.objects.all()
if scenario_id: if scenario_id:
logs = logs.filter(scenario_id=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( return render(
request, request,
"audit/log_list.html", "audit/log_list.html",
{ {
"logs": logs, "logs": logs,
"selected_scenario_id": scenario_id, "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) 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},
)

View File

View File

@@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from agent_core.orchestrator import run_agent from agent_core.orchestrator import run_agent
from agent_core.results import AgentResult 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.documents.models import SubmissionBatch, UploadedDocument
from apps.scenarios.services import get_scenario from apps.scenarios.services import get_scenario
@@ -70,6 +70,7 @@ def detail(request, conversation_id: str):
conversation_id=conversation.conversation_id, conversation_id=conversation.conversation_id,
product_name=conversation.product_name, product_name=conversation.product_name,
) )
_persist_notification_records(result)
active_node = "risk" active_node = "risk"
return render( return render(
@@ -89,3 +90,23 @@ def detail(request, conversation_id: str):
"active_node": active_node, "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},
)

View File

@@ -1,73 +1,66 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}审计日志详情{% endblock %} {% block title %}处理历史详情{% endblock %}
{% block content %} {% block content %}
<section class="page-header"> <section class="page-header">
<span class="eyebrow">Audit Snapshot</span> <span class="eyebrow">History Detail</span>
<h1 class="page-title">审计日志 #{{ log.id }}</h1> <h1 class="page-title">处理历史 #{{ log.id }}</h1>
<p class="page-lead">详情页集中展示当前请求的输入、结构化输出、引用来源、工具调用和原始输出,用来解释这一轮 Agent 执行到底做了什么</p> <p class="page-lead">集中展示本次执行的业务上下文、结构化结果、引用来源与通知留痕</p>
<div class="button-row"> <div class="badge-row">
<a class="button" href="{% url 'audit:list' %}">返回审计列表</a> <span class="pill pill-accent">批次:{{ log.batch_id|default:"-" }}</span>
<a class="button" href="{% url 'platform_ui:command-center' %}">返回工作台大屏</a> <span class="pill">会话:{{ log.conversation_id|default:"-" }}</span>
<span class="pill">产品:{{ log.product_name|default:"-" }}</span>
</div> </div>
</section> </section>
<section class="hero-metrics"> <section class="grid-2">
<article class="metric-card"> <article class="panel">
<div class="metric-label">场景</div> <h2 class="section-title">执行上下文</h2>
<div class="metric-value">{{ log.scenario_name }}</div> <ul class="detail-list">
<li class="detail-item"><strong>用户输入</strong><div>{{ log.user_input|linebreaksbr }}</div></li>
<li class="detail-item"><strong>最终回答</strong><div>{{ log.final_answer|linebreaksbr }}</div></li>
<li class="detail-item"><strong>结构化输出</strong><pre class="code-block">{{ log.structured_output }}</pre></li>
</ul>
</article> </article>
<article class="metric-card">
<div class="metric-label">状态</div> <article class="panel">
<div class="metric-value">{{ log.get_status_display_text }}</div> <h2 class="section-title">执行证据</h2>
</article> <ul class="detail-list">
<article class="metric-card"> <li class="detail-item"><strong>引用来源</strong><pre class="code-block">{{ log.retrieved_chunks }}</pre></li>
<div class="metric-label">耗时</div> <li class="detail-item"><strong>工具调用</strong><pre class="code-block">{{ log.tool_calls }}</pre></li>
<div class="metric-value">{{ log.latency_ms }} ms</div> <li class="detail-item"><strong>原始输出</strong><pre class="code-block">{{ log.raw_output }}</pre></li>
</ul>
</article> </article>
</section> </section>
<section class="layout-two-columns"> <section class="panel">
<div class="stack"> <h2 class="section-title">通知留痕</h2>
<article class="panel"> <div class="table-wrap">
<h2 class="section-title">用户输入</h2> <table class="data-table">
<div class="detail-item">{{ log.user_input|linebreaksbr }}</div> <thead>
</article> <tr>
<th>触发原因</th>
<article class="panel"> <th>责任角色</th>
<h2 class="section-title">最终回答</h2> <th>飞书用户</th>
<div class="detail-item">{{ log.final_answer|linebreaksbr }}</div> <th>消息状态</th>
</article> <th>详情链接</th>
</tr>
<article class="panel"> </thead>
<h2 class="section-title">结构化输出</h2> <tbody>
<pre class="code-block">{{ log.structured_output }}</pre> {% for item in notifications %}
</article> <tr>
</div> <td>{{ item.notify_reason }}</td>
<td>{{ item.owner_role }}</td>
<div class="stack"> <td>{{ item.feishu_user_id }}</td>
<article class="panel"> <td>{{ item.message_status }}</td>
<h2 class="section-title">引用来源</h2> <td>{{ item.web_detail_url|default:"-" }}</td>
<pre class="code-block">{{ log.retrieved_chunks }}</pre> </tr>
</article> {% empty %}
<tr><td colspan="5">当前执行尚无通知留痕。</td></tr>
<article class="panel"> {% endfor %}
<h2 class="section-title">工具调用</h2> </tbody>
<pre class="code-block">{{ log.tool_calls }}</pre> </table>
</article>
<article class="panel">
<h2 class="section-title">原始输出</h2>
<pre class="code-block">{{ log.raw_output }}</pre>
</article>
{% if log.error_message %}
<article class="panel">
<h2 class="section-title">错误信息</h2>
<pre class="code-block">{{ log.error_message }}</pre>
</article>
{% endif %}
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View File

@@ -1,43 +1,38 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}审计日志{% endblock %} {% block title %}处理历史{% endblock %}
{% block content %} {% block content %}
<section class="page-header"> <section class="page-header">
<span class="eyebrow">Audit Trail</span> <span class="eyebrow">Processing History</span>
<h1 class="page-title">审计日志与执行留痕中心</h1> <h1 class="page-title">处理历史</h1>
<p class="page-lead">每次 Agent 执行都会保留输入、结构化结果、引用片段、工具调用和最终输出。这个页面用于说明系统为何可追溯、可复核、可解释</p> <p class="page-lead">按批次、产品和会话回看审核执行、结构化结论与通知留痕</p>
{% if selected_scenario_id %}
<div class="badge-row">
<span class="pill pill-accent">当前筛选场景:{{ selected_scenario_id }}</span>
<a class="button" href="{% url 'audit:list' %}">清空筛选</a>
</div>
{% endif %}
</section> </section>
<section class="hero-metrics"> <section class="panel">
<article class="metric-card"> <div class="section-heading">
<div class="metric-label">日志总数</div> <div>
<div class="metric-value">{{ logs|length }}</div> <h2 class="section-title">历史筛选</h2>
<div class="metric-note">当前页面加载的执行快照数量</div> <p class="section-copy">支持按产品名称或批次号搜索</p>
</article> </div>
<article class="metric-card"> </div>
<div class="metric-label">最近状态</div> <form method="get" class="grid-2">
<div class="metric-value">{% if logs %}{{ logs.0.get_status_display_text }}{% else %}暂无{% endif %}</div> <div>
<div class="metric-note">默认按时间倒序展示最近一次 Agent 执行。</div> <label for="id_keyword">产品名称 / 批次号</label>
</article> <input id="id_keyword" type="text" name="keyword" value="{{ keyword }}" placeholder="例如:新型冠状病毒 或 SUB-20260604-001">
<article class="metric-card"> </div>
<div class="metric-label">最近场景</div> <div class="button-row" style="align-items: end;">
<div class="metric-value">{% if logs %}{{ logs.0.scenario_name }}{% else %}暂无{% endif %}</div> <button type="submit">筛选历史</button>
<div class="metric-note">便于快速定位当前复试演示对应的执行记录。</div> <a class="button" href="{% url 'audit:list' %}">清空</a>
</article> </div>
</form>
</section> </section>
<section class="panel"> <section class="panel">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2 class="section-title">执行快照列表</h2> <h2 class="section-title">执行快照列表</h2>
<p class="section-copy">保留真实审计数据列表,同时把展示形式升级为与首页、大屏一致的分析板风格</p> <p class="section-copy">围绕 `batch_id / conversation_id / product_name` 展示处理历史</p>
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -46,11 +41,13 @@
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>场景</th> <th>场景</th>
<th>产品名称</th>
<th>批次号</th>
<th>会话</th>
<th>输入摘要</th> <th>输入摘要</th>
<th>状态</th> <th>状态</th>
<th>模型</th> <th>模型</th>
<th></th> <th></th>
<th>创建时间</th>
<th>详情</th> <th>详情</th>
</tr> </tr>
</thead> </thead>
@@ -59,17 +56,19 @@
<tr> <tr>
<td>{{ log.id }}</td> <td>{{ log.id }}</td>
<td>{{ log.scenario_name }}</td> <td>{{ log.scenario_name }}</td>
<td>{{ log.product_name|default:"-" }}</td>
<td>{{ log.batch_id|default:"-" }}</td>
<td>{{ log.conversation_id|default:"-" }}</td>
<td>{{ log.get_user_input_summary }}</td> <td>{{ log.get_user_input_summary }}</td>
<td> <td>
<span class="pill {% if log.status == 'success' %}pill-success{% else %}pill-danger{% endif %}">{{ log.get_status_display_text }}</span> <span class="pill {% if log.status == 'success' %}pill-success{% else %}pill-danger{% endif %}">{{ log.get_status_display_text }}</span>
</td> </td>
<td>{{ log.model_name }}</td> <td>{{ log.model_name }}</td>
<td>{{ log.latency_ms }} ms</td>
<td>{{ log.created_at|date:"Y-m-d H:i" }}</td> <td>{{ log.created_at|date:"Y-m-d H:i" }}</td>
<td><a class="button" href="{% url 'audit:detail' log.id %}">查看详情</a></td> <td><a class="button" href="{% url 'audit:detail' log.id %}">查看详情</a></td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="8">暂无审计日志,先去执行一次审核工作台任务。</td></tr> <tr><td colspan="10">暂无处理历史,先去执行一次审核任务。</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -1,6 +1,7 @@
from agent_core.orchestrator import build_messages, run_agent from agent_core.orchestrator import build_messages, run_agent
from agent_core.rag.ingest import _split_text, ingest_document from agent_core.rag.ingest import _split_text, ingest_document
from agent_core.rag.retriever import retrieve 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(): 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 == [] 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

View File

@@ -1,8 +1,8 @@
from django.urls import reverse from django.urls import reverse
from agent_core.results import AgentResult from agent_core.results import AgentResult
from apps.audit.models import AgentAuditLog, DemoBusinessRecord from apps.audit.models import AgentAuditLog, DemoBusinessRecord, NotificationRecord
from apps.audit.services import create_audit_log from apps.audit.services import create_audit_log, create_notification_record
from agent_core.tools.builtin_tools import query_demo_records 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]["title"] == "A线缺陷"
assert result["records"][0]["payload"] == {"rate": 0.12} 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

View File

@@ -2,6 +2,7 @@ from django.urls import reverse
from agent_core.results import AgentResult from agent_core.results import AgentResult
from apps.audit.models import AgentAuditLog from apps.audit.models import AgentAuditLog
from apps.audit.models import NotificationRecord
from apps.chat.models import Conversation from apps.chat.models import Conversation
from apps.documents.models import SubmissionBatch, UploadedDocument 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 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