feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

@@ -0,0 +1,492 @@
from datetime import timedelta
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.utils import timezone
from apps.agents.models import AgentRun
from apps.agents.tests.helpers import create_deployment
from apps.knowledge.models import KnowledgeBase, KnowledgeItem
from apps.knowledge.services import rebuild_item_chunks
from apps.tickets.draft_services import (
_auto_create_ticket,
create_agent_run_for_draft,
execute_draft_analysis,
serialize_agent_progress,
)
from apps.tickets.models import (
Ticket,
TicketActorSource,
TicketAttachment,
TicketDraft,
TicketFieldEvidence,
TicketMessageTimelineItem,
)
def _publish_agent():
deployment = create_deployment()
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
def _user():
return get_user_model().objects.create_user(username="trusted-draft-user")
@pytest.mark.django_db
def test_draft_context_contains_only_published_knowledge_items():
_publish_agent()
base = KnowledgeBase.objects.create(name="采集知识库")
published = KnowledgeItem.objects.create(
knowledge_base=base,
title="采集任务停滞",
problem="采集任务长时间没有更新",
root_cause="调度状态异常",
solution="核查调度状态后重新触发",
status="published",
)
rebuild_item_chunks(published)
draft_item = KnowledgeItem.objects.create(
knowledge_base=base,
title="未审核客户专属方案",
problem="采集任务没有更新",
solution="不得进入 Agent 的方案",
status="draft",
)
rebuild_item_chunks(draft_item)
draft = TicketDraft.objects.create(
created_by=_user(), source_text="生产环境采集任务长时间没有更新"
)
run = create_agent_run_for_draft(draft)
context = run.input_payload["knowledge_context"]
assert [item["knowledge_item_id"] for item in context] == [published.id]
assert context[0]["solution"] == "核查调度状态后重新触发"
assert "不得进入 Agent 的方案" not in str(context)
@pytest.mark.django_db
def test_agent_input_contains_only_scanned_parsed_materials():
_publish_agent()
draft = TicketDraft.objects.create(created_by=_user(), source_text="")
ready = draft.materials.create(
file_name="service.log",
material_type="log",
extension="log",
mime_type="text/plain",
file_size=20,
sha256="1" * 64,
storage_path="ticket-drafts/service.log",
scan_status="clean",
parse_status="parsed",
parsed_text="2026-07-25 ERROR timeout",
)
draft.materials.create(
file_name="blocked.txt",
material_type="text",
extension="txt",
mime_type="text/plain",
file_size=10,
sha256="2" * 64,
storage_path="ticket-drafts/blocked.txt",
scan_status="infected",
parse_status="blocked",
)
run = create_agent_run_for_draft(draft)
assert run.input_payload["material_texts"] == [
{
"source_ref": f"material:{ready.public_id}",
"file_name": "service.log",
"mime_type": "text/plain",
"text": "2026-07-25 ERROR timeout",
}
]
assert [item["file_name"] for item in run.input_payload["material_refs"]] == [
"service.log"
]
@pytest.mark.django_db
def test_analysis_rejects_unproven_entities_and_preserves_manual_fields(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(
created_by=_user(),
source_text="张三反馈生产环境采集任务没有更新",
current_fields={"submitter_org": "人工确认机构"},
last_ai_fields={"submitter_org": "旧 AI 机构"},
)
run = create_agent_run_for_draft(draft)
ai_fields = {
"title": "生产采集任务未更新",
"description": "张三反馈生产环境采集任务未更新。",
"category_code": "technical_issue",
"priority": "medium",
"problem_performance": "采集任务未更新",
"problem_object": "知识库客户企业",
"submitter_name": "知识库联系人",
"submitter_org": "知识库机构",
"impact_scope": "",
"environment": "生产环境",
"related_system": "数据采集平台",
}
def fake_execute(run_id, **_kwargs):
AgentRun.objects.filter(id=run_id).update(
status="succeeded",
final_output={
"problem_valid": True,
"invalid_reason": "",
"fields": ai_fields,
"field_evidence": {
"description": {
"confidence": 0.95,
"source_refs": ["source_text"],
"excerpt": "采集任务没有更新",
"location": "文本",
"conflicts": [],
}
},
"message_timeline": [],
"missing_fields": [],
"questions": [],
},
)
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
execute_draft_analysis(draft.id, run.id)
draft.refresh_from_db()
assert draft.status == "ticket_created"
assert draft.confirmed_ticket.status == "pending_info"
assert draft.current_fields["problem_object"] == ""
assert draft.current_fields["submitter_name"] == ""
assert draft.current_fields["submitter_org"] == "人工确认机构"
assert set(draft.missing_fields) >= {"problem_object", "submitter_name"}
assert TicketFieldEvidence.objects.get(
ticket=draft.confirmed_ticket,
field_name="submitter_org",
).is_protected is True
description_evidence = TicketFieldEvidence.objects.get(
ticket=draft.confirmed_ticket,
field_name="description",
)
assert description_evidence.source == TicketActorSource.AGENT
assert float(description_evidence.confidence) == 0.95
@pytest.mark.django_db
def test_analysis_keeps_invalid_problem_as_intake_without_ticket(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(created_by=_user(), source_text="你好,在吗")
run = create_agent_run_for_draft(draft)
def fake_execute(run_id, **_kwargs):
AgentRun.objects.filter(id=run_id).update(
status="succeeded",
final_output={
"problem_valid": False,
"invalid_reason": "请补充具体问题现象",
"fields": {"description": ""},
"field_evidence": {},
"message_timeline": [],
"missing_fields": ["description"],
"questions": ["请描述需要技术支持处理的具体问题。"],
},
)
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
execute_draft_analysis(draft.id, run.id)
draft.refresh_from_db()
assert draft.status == "needs_input"
assert draft.problem_valid is False
assert draft.invalid_reason == "请补充具体问题现象"
assert draft.confirmed_ticket_id is None
assert Ticket.objects.count() == 0
@pytest.mark.django_db
def test_analysis_persists_structured_context_business_times_and_timezone(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(
created_by=_user(),
source_text=(
"张三代表华南公司反馈财务系统报销模块的报销单在生产环境报错 E500,"
"2026-07-27 10:00(北京时间)开始,已清理缓存但仍未恢复,希望尽快恢复报销。"
),
)
run = create_agent_run_for_draft(draft)
fields = {
"title": "财务系统报销模块 E500",
"description": "华南公司反馈财务系统报销模块持续报错 E500。",
"category_code": "technical_issue",
"priority": "medium",
"problem_performance": "提交报销时持续报错 E500",
"problem_object": "报销单",
"submitter_name": "张三",
"submitter_org": "华南公司",
"impact_scope": "华南公司报销用户",
"environment": "生产环境",
"related_system": "财务系统",
"product_name": "财务系统",
"module_name": "报销模块",
"tags": ["E500", "报销", "E500"],
"attempted_actions": "已清理缓存",
"error_information": "E500",
"customer_request": "尽快恢复报销",
"current_result": "清理缓存后仍未恢复",
"problem_started_at": "2026-07-27T10:00:00+08:00",
"customer_last_message_at": "2026-07-27T10:05:00+08:00",
"support_first_response_at": "2026-07-27T10:02:00+08:00",
"support_last_reply_at": "2026-07-27T10:04:00+08:00",
}
def fake_execute(run_id, **_kwargs):
AgentRun.objects.filter(id=run_id).update(
status="succeeded",
final_output={
"problem_valid": True,
"invalid_reason": "",
"fields": fields,
"field_evidence": {
"problem_started_at": {
"confidence": 0.96,
"source_refs": ["source_text"],
"excerpt": "2026-07-27 10:00(北京时间)开始",
"location": "文本第 1 行",
"raw_value": "2026-07-27 10:00",
"timezone": "Asia/Shanghai",
"conflicts": [],
}
},
"message_timeline": [
{
"speaker": "张三",
"role": "customer",
"content": "报销模块报错 E500",
"occurred_at": "2026-07-27T10:00:00+08:00",
"occurred_at_raw": "上午十点",
"timezone": "Asia/Shanghai",
"source_ref": "source_text",
"confidence": 0.92,
"conflict": "",
}
],
"missing_fields": [],
"questions": [],
},
)
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
execute_draft_analysis(draft.id, run.id)
draft.refresh_from_db()
ticket = Ticket.objects.get(id=draft.confirmed_ticket_id)
assert ticket.status == "pending"
assert ticket.product_name == "财务系统"
assert ticket.module_name == "报销模块"
assert ticket.tags == ["E500", "报销"]
assert ticket.attempted_actions == "已清理缓存"
assert ticket.error_information == "E500"
assert ticket.customer_request == "尽快恢复报销"
assert ticket.current_result == "清理缓存后仍未恢复"
assert ticket.problem_started_at.isoformat() == "2026-07-27T02:00:00+00:00"
time_evidence = TicketFieldEvidence.objects.get(
ticket=ticket,
field_name="problem_started_at",
)
assert time_evidence.evidence["raw_value"] == "2026-07-27 10:00"
assert time_evidence.evidence["timezone"] == "Asia/Shanghai"
timeline = TicketMessageTimelineItem.objects.get(ticket=ticket)
assert timeline.occurred_at_raw == "上午十点"
assert timeline.timezone == "Asia/Shanghai"
assert timeline.occurred_at.isoformat() == "2026-07-27T02:00:00+00:00"
@pytest.mark.django_db
def test_analysis_rejects_business_time_without_explicit_timezone(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(
created_by=_user(),
source_text="张三代表华南公司反馈生产环境报销单无法提交。",
)
run = create_agent_run_for_draft(draft)
def fake_execute(run_id, **_kwargs):
AgentRun.objects.filter(id=run_id).update(
status="succeeded",
final_output={
"problem_valid": True,
"invalid_reason": "",
"fields": {
"title": "报销单无法提交",
"description": "华南公司反馈生产环境报销单无法提交。",
"category_code": "technical_issue",
"priority": "medium",
"problem_performance": "报销单无法提交",
"problem_object": "报销单",
"submitter_name": "张三",
"submitter_org": "华南公司",
"environment": "生产环境",
"problem_started_at": "2026-07-27T10:00:00",
},
"field_evidence": {},
"message_timeline": [],
"missing_fields": [],
"questions": [],
},
)
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
execute_draft_analysis(draft.id, run.id)
draft.refresh_from_db()
ticket = Ticket.objects.get(id=draft.confirmed_ticket_id)
assert ticket.problem_started_at is None
assert ticket.status == "pending_info"
assert "problem_started_at" in draft.missing_fields
@pytest.mark.django_db
def test_auto_create_is_idempotent_for_same_intake(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
run = create_agent_run_for_draft(draft)
fields = {
"description": "采集任务从上午十点起没有更新",
"priority": "medium",
}
first = _auto_create_ticket(
draft.id,
run,
fields=fields,
field_evidence={},
confidence_by_field={},
human_field_names=set(),
)
TicketDraft.objects.filter(id=draft.id).update(status="needs_input")
second = _auto_create_ticket(
draft.id,
run,
fields=fields,
field_evidence={},
confidence_by_field={},
human_field_names=set(),
)
assert first.confirmed_ticket_id == second.confirmed_ticket_id
assert second.status == "ticket_created"
assert Ticket.objects.count() == 1
ticket = Ticket.objects.get()
assert ticket.status == "pending_info"
assert ticket.title.startswith("待补全问题:")
monkeypatch.setattr(
"apps.tickets.draft_services.execute_agent_run",
lambda *_args, **_kwargs: pytest.fail("duplicate run must not call the model"),
)
execute_draft_analysis(draft.id, run.id)
run.refresh_from_db()
assert run.status == "succeeded"
assert Ticket.objects.count() == 1
@pytest.mark.django_db
def test_auto_create_rolls_back_ticket_when_attachment_copy_fails(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
draft.materials.create(
file_name="proof.png",
mime_type="image/png",
file_size=10,
sha256="0" * 64,
storage_path="ticket-drafts/proof.png",
)
run = create_agent_run_for_draft(draft)
monkeypatch.setattr(
"apps.tickets.draft_services.copy_object",
lambda *_args: (_ for _ in ()).throw(RuntimeError("storage unavailable")),
)
with pytest.raises(RuntimeError, match="storage unavailable"):
_auto_create_ticket(
draft.id,
run,
fields={"description": "采集任务没有更新"},
field_evidence={},
confidence_by_field={},
human_field_names=set(),
)
assert Ticket.objects.count() == 0
draft.refresh_from_db()
assert draft.confirmed_ticket_id is None
@pytest.mark.django_db
def test_auto_create_preserves_material_scan_and_parse_metadata(monkeypatch):
_publish_agent()
draft = TicketDraft.objects.create(created_by=_user(), source_text="日志显示请求超时")
material = draft.materials.create(
file_name="service.log",
material_type="log",
extension="log",
mime_type="text/plain",
file_size=24,
sha256="a" * 64,
storage_path="ticket-drafts/service.log",
scan_status="clean",
scan_result={"engine": "clamav", "status": "clean"},
parse_status="parsed",
parsed_text="ERROR upstream timeout",
)
run = create_agent_run_for_draft(draft)
monkeypatch.setattr("apps.tickets.draft_services.copy_object", lambda *_args: None)
result = _auto_create_ticket(
draft.id,
run,
fields={"description": "日志显示上游请求超时"},
field_evidence={},
confidence_by_field={},
human_field_names=set(),
)
attachment = TicketAttachment.objects.get(ticket=result.confirmed_ticket)
assert attachment.file_type == "log"
assert attachment.sha256 == "a" * 64
assert attachment.scan_status == "clean"
assert attachment.parse_status == "parsed"
assert attachment.parsed_text == "ERROR upstream timeout"
material.refresh_from_db()
assert material.status == "attached"
@pytest.mark.django_db
def test_agent_progress_marks_long_running_draft_as_slow(settings):
_publish_agent()
settings.AI_SLOW_RUN_THRESHOLD_SECONDS = 20
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
run = create_agent_run_for_draft(draft)
run.status = "running"
run.started_at = timezone.now() - timedelta(seconds=21)
run.progress_stage = "extracting"
run.progress_message = "正在解析沟通记录"
run.save(
update_fields=["status", "started_at", "progress_stage", "progress_message"]
)
progress = serialize_agent_progress(draft)
assert progress["stage"] == "extracting"
assert progress["message"] == "正在解析沟通记录"
assert progress["elapsed_ms"] >= 20_000
assert progress["slow"] is True