feat: init commint

This commit is contained in:
2026-09-18 15:26:19 +08:00
parent 3b61a767a0
commit 9f47dbb5cc
95 changed files with 7712 additions and 1137 deletions

View File

@@ -55,6 +55,8 @@ def test_intake_persists_timeline_creates_unique_task_and_orders_events(monkeypa
],
"missing_fields": ["category_code", "environment"],
"questions": ["请确认受影响机构。"],
"blocking_missing_fields": ["environment"],
"blocking_questions": ["请确认受影响机构。"],
},
)
@@ -90,6 +92,16 @@ def test_missing_info_task_completes_after_required_fields_are_filled():
publish_intake_agent()
user = get_user_model().objects.create_user(username="stage-23-editor")
draft = TicketDraft.objects.create(created_by=user, source_text="无法登录")
draft.blocking_missing_fields = [
"category_code",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"environment",
]
draft.blocking_questions = ["请一次补充问题对象、现象和适用环境。"]
draft.save(update_fields=["blocking_missing_fields", "blocking_questions", "updated_at"])
run = create_agent_run_for_draft(draft)
from apps.tickets.draft_services import _auto_create_ticket
@@ -218,6 +230,9 @@ def test_task_api_filters_by_ticket(admin_client):
publish_intake_agent()
user = get_user_model().objects.create_user(username="stage-23-task-api")
draft = TicketDraft.objects.create(created_by=user, source_text="无法登录")
draft.blocking_missing_fields = ["problem_object"]
draft.blocking_questions = ["请确认发生问题的对象。"]
draft.save(update_fields=["blocking_missing_fields", "blocking_questions", "updated_at"])
run = create_agent_run_for_draft(draft)
from apps.tickets.draft_services import _auto_create_ticket

View File

@@ -5,6 +5,7 @@ import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import override_settings
from apps.agents.models import AgentDefinition, AgentRun
from apps.agents.tests.helpers import create_deployment
@@ -22,14 +23,16 @@ from apps.tickets.support_analysis import (
SupportAnalysisError,
validate_support_analysis_output,
)
from apps.tickets.support_contract import SUPPORT_OUTPUT_FIELDS
def publish_support_agent():
deployment = create_deployment()
deployment = create_deployment(structured_output_mode="native_json_schema")
call_command("publish_support_analysis", deployment_id=deployment.id)
@pytest.mark.django_db
@override_settings(SUPPORT_REFERENCE_MIN_SCORE=0)
def test_support_analysis_uses_only_published_scoped_references_and_is_audited(
admin_client,
django_capture_on_commit_callbacks,
@@ -200,6 +203,7 @@ def test_support_analysis_reuses_active_run_and_rejects_closed_ticket(
@pytest.mark.django_db
@override_settings(SUPPORT_REFERENCE_MIN_SCORE=0)
def test_support_analysis_output_cannot_escape_frozen_references(
admin_client,
django_capture_on_commit_callbacks,
@@ -292,6 +296,7 @@ def test_support_analysis_reports_missing_published_agent(admin_client):
@pytest.mark.django_db
@override_settings(SUPPORT_REFERENCE_MIN_SCORE=0)
@pytest.mark.parametrize(
("citation", "expected_status", "expected_error"),
[
@@ -360,3 +365,6 @@ def test_publish_support_analysis_command_is_idempotent():
assert definition.active_version is not None
assert definition.versions.count() == 1
assert definition.active_version.steps.count() == 1
output_schema = definition.active_version.steps.get().skill_version.output_schema
assert set(output_schema["required"]) == SUPPORT_OUTPUT_FIELDS
assert set(output_schema["properties"]) == SUPPORT_OUTPUT_FIELDS

View File

@@ -7,7 +7,7 @@ from django.test import override_settings
from apps.agents.models import AgentDefinition, AgentRun
from apps.agents.services import build_config_snapshot
from apps.agents.tests.helpers import create_deployment
from apps.solutions.models import Solution, TicketResolution, TicketSolutionLink
from apps.solutions.models import Solution
from apps.tickets.models import (
CustomerReplyRevision,
CustomerReplyTask,
@@ -112,11 +112,13 @@ def build_completed_support_analysis(
@pytest.mark.django_db
@override_settings(TICKET_AUTO_RESOLVE_ENABLED=True)
def test_support_analysis_auto_resolves_through_domain_service_and_is_idempotent(
def test_support_analysis_waits_for_one_approval_and_is_idempotent(
admin_client,
):
actor = get_user_model().objects.get(is_superuser=True)
analysis, solution, ticket = build_completed_support_analysis(actor)
ticket.status = "in_progress"
ticket.save(update_fields=["status", "updated_at"])
process_support_analysis_result(analysis.run)
process_support_analysis_result(analysis.run)
@@ -124,21 +126,13 @@ def test_support_analysis_auto_resolves_through_domain_service_and_is_idempotent
analysis.refresh_from_db()
ticket.refresh_from_db()
solution.refresh_from_db()
resolution = TicketResolution.objects.get(ticket=ticket)
assert ticket.status == "resolved"
assert resolution.source == "agent"
assert resolution.linked_solution == solution
assert resolution.agent_run_id == analysis.run_id
assert analysis.applied_solution == solution
assert analysis.automation_decision["status"] == "auto_resolved"
assert analysis.automation_decision["applied"] is True
assert solution.usage_count == 1
assert TicketSolutionLink.objects.filter(
ticket=ticket,
solution=solution,
link_type="adopted",
agent_run_id=analysis.run_id,
).count() == 1
assert ticket.status == "in_progress"
assert not hasattr(ticket, "resolution")
assert analysis.applied_solution_id is None
assert analysis.automation_decision["status"] == "waiting_approval"
assert analysis.automation_decision["applied"] is False
assert CustomerReplyTask.objects.filter(ticket=ticket, status="draft").count() == 1
assert solution.usage_count == 0
assert CustomerReplyTask.objects.filter(ticket=ticket, status="draft").count() == 1
assert WorkItem.objects.filter(
ticket=ticket,

View File

@@ -59,7 +59,7 @@ def build_support_analysis(
title="订单状态未更新",
description="生产环境订单状态长时间未更新",
category=category,
status="pending",
status="in_progress",
related_system="订单系统",
environment="生产环境",
)
@@ -222,7 +222,15 @@ def test_stage_3_real_case_metrics_cover_match_auto_reuse_and_fallback():
)
]
assert all(result.passed for result in results)
for result in results:
assert result.passed, {
"case": result.case.key,
"actual_auto_resolved": result.actual_auto_resolved,
"actual_manual_fallback": result.actual_manual_fallback,
"matched": result.matched_expected_solution,
"citations_valid": result.citations_valid,
"decision": result.details["automation_decision"],
}
metrics = build_support_quality_metrics()
assert metrics["evaluation"]["solution_match_rate"] == 1.0
assert metrics["evaluation"]["eligible_auto_resolve_rate"] == 1.0

View File

@@ -0,0 +1,204 @@
import pytest
from django.contrib.auth import get_user_model
from apps.channels.models import ChannelConversation, ChannelEndpoint, ChannelMessage
from apps.solutions.models import TicketResolution
from apps.tickets.automation import (
apply_automation_action,
approve_and_send,
close_due_ticket_automation,
handle_customer_feedback,
handle_customer_reply_delivery_failed,
)
from apps.tickets.models import (
CustomerReplyTask,
Ticket,
TicketAutomationEvent,
TicketAutomationRun,
TicketAutomationRunStatus,
)
from apps.workitems.models import WorkItem, WorkItemStatus, WorkItemType
@pytest.mark.django_db(transaction=True)
def test_one_approval_resolves_and_closes_without_duplicate_business_facts():
actor = get_user_model().objects.create_superuser(
username="automation-approver",
password="test-password",
)
ticket = Ticket.objects.create(
ticket_no="TK-AUTO-0001",
title="登录回调后仍停留在登录页",
description="生产环境登录回调成功,但页面没有跳转。",
status="pending_confirm",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
status="draft",
draft_text="您好,请重新登录并确认页面已恢复。",
final_text="您好,请重新登录并确认页面已恢复。",
revision=1,
)
run = TicketAutomationRun.objects.create(
ticket=ticket,
status=TicketAutomationRunStatus.WAITING_APPROVAL,
current_step="approve_and_send",
reply_task=reply_task,
policy_snapshot={
"policy_version": "test",
"auto_close_hours": 24,
"max_retries": 2,
},
)
approve_and_send(
run,
actor=actor,
expected_reply_revision=1,
final_text=reply_task.final_text,
resolution_text="认证回调状态已恢复,会话可以正常写入。",
manual_delivery_confirmed=True,
)
ticket.refresh_from_db()
run.refresh_from_db()
reply_task.refresh_from_db()
assert ticket.status == "resolved"
assert reply_task.status == "sent"
assert run.status == TicketAutomationRunStatus.WAITING_CUSTOMER
assert run.next_action_at is not None
assert TicketResolution.objects.filter(ticket=ticket, source="system").count() == 1
assert not WorkItem.objects.filter(
ticket=ticket,
task_type=WorkItemType.TICKET_CLOSE,
status=WorkItemStatus.ACTIVE,
).exists()
# 浏览器重复提交相同批准不会产生第二条回复、解决记录或关闭计划。
approve_and_send(
run,
actor=actor,
expected_reply_revision=1,
final_text=reply_task.final_text,
resolution_text="认证回调状态已恢复,会话可以正常写入。",
manual_delivery_confirmed=True,
)
assert CustomerReplyTask.objects.filter(ticket=ticket).count() == 1
assert TicketResolution.objects.filter(ticket=ticket).count() == 1
assert TicketAutomationEvent.objects.filter(
run=run,
event_type="waiting_customer",
).count() == 1
assert close_due_ticket_automation(run.id, force=True) is True
assert close_due_ticket_automation(run.id, force=True) is False
ticket.refresh_from_db()
run.refresh_from_db()
assert ticket.status == "closed"
assert run.status == TicketAutomationRunStatus.COMPLETED
assert TicketAutomationEvent.objects.filter(
run=run,
event_type="completed",
).count() == 1
@pytest.mark.django_db(transaction=True)
def test_negative_feedback_after_close_creates_one_linked_follow_up_ticket():
source = Ticket.objects.create(
ticket_no="TK-AUTO-0002",
title="登录问题已关闭",
description="登录回调问题已经恢复。",
status="closed",
priority="high",
environment="生产环境",
related_system="统一身份平台",
)
run = TicketAutomationRun.objects.create(
ticket=source,
status=TicketAutomationRunStatus.COMPLETED,
current_step="closed",
policy_snapshot={"policy_version": "test"},
)
assert handle_customer_feedback(source.id, "还是不行,登录后继续报错") == (
"follow_up_created"
)
assert handle_customer_feedback(source.id, "还是不行,登录后继续报错") == (
"follow_up_created"
)
follow_up = Ticket.objects.get(follow_up_of=source)
assert follow_up.status == "pending"
assert follow_up.priority == "high"
assert follow_up.environment == "生产环境"
assert source.follow_up_tickets.count() == 1
assert source.events.filter(event_type="follow_up_created").count() == 1
assert run.events.filter(event_type="follow_up_created").count() == 1
@pytest.mark.django_db(transaction=True)
def test_failed_channel_delivery_retries_the_same_outbound_message(monkeypatch):
actor = get_user_model().objects.create_superuser(
username="automation-retry",
password="test-password",
)
ticket = Ticket.objects.create(
ticket_no="TK-AUTO-0003",
title="渠道重试",
description="验证渠道失败后的幂等重试。",
status="pending_confirm",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
status="approved",
draft_text="请确认恢复结果。",
final_text="请确认恢复结果。",
revision=1,
)
run = TicketAutomationRun.objects.create(
ticket=ticket,
status=TicketAutomationRunStatus.WAITING_DELIVERY,
current_step="wait_delivery",
reply_task=reply_task,
approved_by=actor,
policy_snapshot={"policy_version": "test"},
)
endpoint = ChannelEndpoint.objects.create(name="自动化重试渠道")
conversation = ChannelConversation.objects.create(
endpoint=endpoint,
external_conversation_id="retry-conversation",
ticket=ticket,
)
message = ChannelMessage.objects.create(
endpoint=endpoint,
conversation=conversation,
ticket=ticket,
customer_reply_task=reply_task,
reply_revision=1,
direction="outbound",
idempotency_key="automation-retry-message",
text=reply_task.final_text,
status="failed",
error_message="上游超时",
created_by=actor,
)
queued = []
monkeypatch.setattr(
"apps.channels.tasks.deliver_channel_message_task.delay",
lambda message_id: queued.append(message_id),
)
handle_customer_reply_delivery_failed(reply_task.id, "上游超时")
run.refresh_from_db()
assert run.current_step == "delivery_failed"
assert run.last_error_code == "channel_delivery_failed"
apply_automation_action(run, "retry", actor=actor)
run.refresh_from_db()
message.refresh_from_db()
assert message.status == "queued"
assert message.idempotency_key == "automation-retry-message"
assert run.current_step == "wait_delivery"
assert run.last_error_message == ""
assert queued == [message.id]