Files
ai-ticket-platform/backend/apps/tickets/tests/test_ticket_draft_trust.py
2026-07-27 18:59:28 +08:00

493 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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