feat: init commint
This commit is contained in:
@@ -18,6 +18,12 @@ from apps.knowledge.distillation import (
|
||||
)
|
||||
from apps.knowledge.models import TicketKnowledgeDistillation
|
||||
from apps.skills.services import SkillValidationError
|
||||
from apps.tickets.automation import (
|
||||
TicketAutomationError,
|
||||
apply_automation_action,
|
||||
approve_and_send,
|
||||
serialize_automation_run,
|
||||
)
|
||||
from apps.tickets.evaluation import build_intake_quality_metrics, record_evaluation_result
|
||||
from apps.tickets.models import (
|
||||
CustomerReplyTask,
|
||||
@@ -25,6 +31,8 @@ from apps.tickets.models import (
|
||||
SupportEvaluationCase,
|
||||
Ticket,
|
||||
TicketAttachment,
|
||||
TicketAutomationRun,
|
||||
TicketAutomationRunStatus,
|
||||
TicketCollaborator,
|
||||
TicketCollaboratorRole,
|
||||
TicketComment,
|
||||
@@ -51,6 +59,9 @@ from apps.tickets.schemas import (
|
||||
SupportQualityMetricsOut,
|
||||
TicketAttachmentIn,
|
||||
TicketAttachmentOut,
|
||||
TicketAutomationActionIn,
|
||||
TicketAutomationApproveIn,
|
||||
TicketAutomationOut,
|
||||
TicketCollaboratorIn,
|
||||
TicketCollaboratorOut,
|
||||
TicketCommentIn,
|
||||
@@ -153,6 +164,7 @@ def serialize_ticket(ticket: Ticket) -> dict:
|
||||
"current_result": ticket.current_result,
|
||||
"requester_id": ticket.requester_id,
|
||||
"assignee_id": ticket.assignee_id,
|
||||
"follow_up_of_id": ticket.follow_up_of_id,
|
||||
"requester": serialize_ticket_user(ticket.requester),
|
||||
"assignee": serialize_ticket_user(ticket.assignee),
|
||||
"problem_started_at": ticket.problem_started_at,
|
||||
@@ -265,6 +277,7 @@ def serialize_support_analysis(item: TicketSupportAnalysis) -> dict:
|
||||
"progress_message": run.progress_message,
|
||||
"query": item.query,
|
||||
"retrieval_policy": item.retrieval_policy,
|
||||
"result_mode": item.result_mode,
|
||||
"references": item.references,
|
||||
"final_output": run.final_output,
|
||||
"automation_decision": item.automation_decision,
|
||||
@@ -730,6 +743,87 @@ def confirm_customer_reply_task_sent(
|
||||
return serialize_customer_reply_task(task)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}/automation", response=TicketAutomationOut | None)
|
||||
def get_ticket_automation(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_related("ticket")
|
||||
.filter(ticket_id=ticket_id)
|
||||
.first()
|
||||
)
|
||||
return serialize_automation_run(run) if run is not None else None
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/approve-and-send", response=TicketAutomationOut)
|
||||
def approve_ticket_automation_and_send(
|
||||
request,
|
||||
ticket_id: int,
|
||||
payload: TicketAutomationApproveIn,
|
||||
):
|
||||
require_permission(request, "ticket.reply")
|
||||
require_permission(request, "ticket.resolve")
|
||||
if payload.channel_endpoint_id:
|
||||
require_permission(request, "channel.send")
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_related("ticket", "reply_task")
|
||||
.filter(ticket_id=ticket_id)
|
||||
.first()
|
||||
)
|
||||
if run is None:
|
||||
raise HttpError(404, "工单自动化运行不存在")
|
||||
if run.status in {
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
TicketAutomationRunStatus.COMPLETED,
|
||||
} and run.approved_at:
|
||||
return serialize_automation_run(run)
|
||||
if run.ticket.updated_at != payload.expected_ticket_updated_at:
|
||||
raise HttpError(409, "工单已被更新,请刷新后重新确认")
|
||||
try:
|
||||
run = approve_and_send(
|
||||
run,
|
||||
actor=request.user,
|
||||
expected_reply_revision=payload.expected_reply_revision,
|
||||
final_text=payload.final_text,
|
||||
resolution_text=payload.resolution_text,
|
||||
root_cause=payload.root_cause,
|
||||
solution_id=payload.solution_id,
|
||||
channel_endpoint_id=payload.channel_endpoint_id,
|
||||
external_conversation_id=payload.external_conversation_id,
|
||||
manual_delivery_confirmed=payload.manual_delivery_confirmed,
|
||||
)
|
||||
except (TicketAutomationError, CustomerReplyError) as error:
|
||||
raise HttpError(409, str(error)) from error
|
||||
return serialize_automation_run(
|
||||
TicketAutomationRun.objects.select_related("ticket").get(id=run.id)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/automation/actions", response=TicketAutomationOut)
|
||||
def act_on_ticket_automation(
|
||||
request,
|
||||
ticket_id: int,
|
||||
payload: TicketAutomationActionIn,
|
||||
):
|
||||
require_permission(request, "ticket.edit")
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_related("ticket")
|
||||
.filter(ticket_id=ticket_id)
|
||||
.first()
|
||||
)
|
||||
if run is None:
|
||||
raise HttpError(404, "工单自动化运行不存在")
|
||||
if payload.action == "retry" and run.current_step == "delivery_failed":
|
||||
require_permission(request, "ticket.reply")
|
||||
require_permission(request, "channel.send")
|
||||
try:
|
||||
run = apply_automation_action(run, payload.action, actor=request.user)
|
||||
except TicketAutomationError as error:
|
||||
raise HttpError(409, str(error)) from error
|
||||
return serialize_automation_run(
|
||||
TicketAutomationRun.objects.select_related("ticket").get(id=run.id)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response=TicketOut)
|
||||
def get_ticket(request, ticket_id: int):
|
||||
require_permission(request, "ticket.view")
|
||||
|
||||
950
backend/apps/tickets/automation.py
Normal file
950
backend/apps/tickets/automation.py
Normal file
@@ -0,0 +1,950 @@
|
||||
"""工单从自动建单到一次确认、送达解决和定时关闭的领域编排。"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import (
|
||||
CustomerReplyTask,
|
||||
Ticket,
|
||||
TicketActorSource,
|
||||
TicketAutomationEvent,
|
||||
TicketAutomationRun,
|
||||
TicketAutomationRunStatus,
|
||||
TicketStatus,
|
||||
TicketSupportAnalysis,
|
||||
)
|
||||
from apps.tickets.replies import (
|
||||
confirm_customer_reply_sent,
|
||||
update_customer_reply,
|
||||
)
|
||||
from apps.tickets.services import (
|
||||
TicketDataError,
|
||||
TicketStatusError,
|
||||
adopt_solution_for_ticket,
|
||||
create_follow_up_ticket,
|
||||
reopen_ticket,
|
||||
transition_ticket,
|
||||
update_ticket_fields,
|
||||
)
|
||||
from apps.tickets.support_analysis import (
|
||||
SupportAnalysisError,
|
||||
start_ticket_support_analysis,
|
||||
)
|
||||
from apps.workitems.models import WorkItem, WorkItemStatus, WorkItemType
|
||||
|
||||
ACTIVE_AUTOMATION_STATUSES = {
|
||||
TicketAutomationRunStatus.QUEUED,
|
||||
TicketAutomationRunStatus.RUNNING,
|
||||
TicketAutomationRunStatus.WAITING_INPUT,
|
||||
TicketAutomationRunStatus.WAITING_APPROVAL,
|
||||
TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
}
|
||||
|
||||
|
||||
class TicketAutomationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def ticket_automation_readiness() -> dict:
|
||||
"""检查自动闭环的静态依赖;失败时新工单安全回退现有人工流程。"""
|
||||
|
||||
from apps.agents.models import AgentDefinition
|
||||
from apps.channels.models import ChannelEndpoint
|
||||
|
||||
definitions = {
|
||||
item.code: item
|
||||
for item in AgentDefinition.objects.select_related(
|
||||
"active_version__model_deployment__provider"
|
||||
).filter(code__in={"ticket-intake", "support-analysis"})
|
||||
}
|
||||
|
||||
def agent_ready(code: str) -> bool:
|
||||
definition = definitions.get(code)
|
||||
if definition is None or definition.active_version_id is None:
|
||||
return False
|
||||
deployment = definition.active_version.model_deployment
|
||||
return bool(
|
||||
deployment
|
||||
and deployment.enabled
|
||||
and deployment.archived_at is None
|
||||
and deployment.provider.enabled
|
||||
and deployment.provider.archived_at is None
|
||||
)
|
||||
|
||||
checks = {
|
||||
"ticket_intake_agent": agent_ready("ticket-intake"),
|
||||
"support_analysis_agent": agent_ready("support-analysis"),
|
||||
"celery_broker_configured": bool(settings.CELERY_BROKER_URL),
|
||||
"object_storage_configured": all(
|
||||
[
|
||||
settings.MINIO_ENDPOINT,
|
||||
settings.MINIO_ACCESS_KEY,
|
||||
settings.MINIO_SECRET_KEY,
|
||||
settings.MINIO_BUCKET,
|
||||
]
|
||||
),
|
||||
}
|
||||
channel_configured = ChannelEndpoint.objects.filter(enabled=True).exists()
|
||||
return {
|
||||
"enabled": bool(settings.TICKET_AUTOMATION_ENABLED),
|
||||
"ready": all(checks.values()),
|
||||
"checks": checks,
|
||||
"delivery_mode": "managed_channel" if channel_configured else "manual_confirmation",
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_advance(run_id: int) -> None:
|
||||
from apps.tickets.tasks import advance_ticket_automation_task
|
||||
|
||||
advance_ticket_automation_task.delay(run_id)
|
||||
|
||||
|
||||
def _dispatch_close(run_id: int, eta) -> None:
|
||||
from apps.tickets.tasks import close_ticket_automation_task
|
||||
|
||||
close_ticket_automation_task.apply_async(args=[run_id], eta=eta)
|
||||
|
||||
|
||||
def resume_automation_for_agent_run(agent_run_id: int) -> None:
|
||||
analysis = TicketSupportAnalysis.objects.filter(run_id=agent_run_id).first()
|
||||
if analysis is None:
|
||||
return
|
||||
automation_run = analysis.automation_runs.filter(
|
||||
status__in=ACTIVE_AUTOMATION_STATUSES
|
||||
).first()
|
||||
if automation_run is not None:
|
||||
_dispatch_advance(automation_run.id)
|
||||
|
||||
|
||||
def append_automation_event(
|
||||
run: TicketAutomationRun,
|
||||
event_type: str,
|
||||
payload: dict | None = None,
|
||||
) -> TicketAutomationEvent:
|
||||
"""在锁持有事务中分配单调序号,供页面续传和审计。"""
|
||||
|
||||
latest = (
|
||||
TicketAutomationEvent.objects.filter(run=run).aggregate(value=Max("sequence"))[
|
||||
"value"
|
||||
]
|
||||
or 0
|
||||
)
|
||||
return TicketAutomationEvent.objects.create(
|
||||
run=run,
|
||||
sequence=latest + 1,
|
||||
event_type=event_type,
|
||||
payload=payload or {},
|
||||
)
|
||||
|
||||
|
||||
def automation_policy_snapshot() -> dict:
|
||||
return {
|
||||
"version": "ticket_once_confirm_v1",
|
||||
"max_retries": int(settings.TICKET_AUTOMATION_MAX_RETRIES),
|
||||
"auto_close_hours": int(settings.TICKET_AUTO_CLOSE_HOURS),
|
||||
"support_reference_min_score": float(settings.SUPPORT_REFERENCE_MIN_SCORE),
|
||||
}
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def start_ticket_automation(
|
||||
ticket: Ticket,
|
||||
*,
|
||||
initiated_by=None,
|
||||
blocking_missing_fields: list[str] | None = None,
|
||||
blocking_questions: list[str] | None = None,
|
||||
force: bool = False,
|
||||
) -> TicketAutomationRun | None:
|
||||
"""创建唯一活动编排;默认受功能开关控制,显式重试可强制启动。"""
|
||||
|
||||
if not force and not settings.TICKET_AUTOMATION_ENABLED:
|
||||
return None
|
||||
if not force and not ticket_automation_readiness()["ready"]:
|
||||
return None
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=ticket.id)
|
||||
if ticket.status == TicketStatus.CLOSED or ticket.archived_at is not None:
|
||||
raise TicketAutomationError("已关闭或已归档工单不能启动自动处理")
|
||||
active = (
|
||||
TicketAutomationRun.objects.select_for_update()
|
||||
.filter(ticket=ticket, status__in=ACTIVE_AUTOMATION_STATUSES)
|
||||
.first()
|
||||
)
|
||||
if active is not None:
|
||||
return active
|
||||
run = TicketAutomationRun.objects.create(
|
||||
ticket=ticket,
|
||||
initiated_by=initiated_by,
|
||||
policy_snapshot=automation_policy_snapshot(),
|
||||
blocking_missing_fields=blocking_missing_fields or [],
|
||||
blocking_questions=blocking_questions or [],
|
||||
)
|
||||
append_automation_event(
|
||||
run,
|
||||
"queued",
|
||||
{
|
||||
"ticket_id": ticket.id,
|
||||
"blocking_missing_fields": run.blocking_missing_fields,
|
||||
},
|
||||
)
|
||||
ticket.events.create(
|
||||
event_type="automation_started",
|
||||
to_value={"automation_run_public_id": str(run.public_id)},
|
||||
operator=initiated_by,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
metadata={"policy": run.policy_snapshot},
|
||||
)
|
||||
transaction.on_commit(lambda: _dispatch_advance(run.id))
|
||||
return run
|
||||
|
||||
|
||||
def _mark_failed(
|
||||
run: TicketAutomationRun,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> TicketAutomationRun:
|
||||
run.status = TicketAutomationRunStatus.FAILED
|
||||
run.current_step = "human_takeover"
|
||||
run.last_error_code = code
|
||||
run.last_error_message = message
|
||||
run.finished_at = timezone.now()
|
||||
run.save()
|
||||
append_automation_event(run, "failed", {"error_code": code, "message": message})
|
||||
WorkItem.objects.get_or_create(
|
||||
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
|
||||
object_type="ticket_automation_run",
|
||||
object_id=str(run.public_id),
|
||||
status=WorkItemStatus.ACTIVE,
|
||||
defaults={
|
||||
"ticket": run.ticket,
|
||||
"title": f"工单 {run.ticket.ticket_no} 自动分析待接管",
|
||||
"description": message,
|
||||
"payload": {
|
||||
"automation_run_public_id": str(run.public_id),
|
||||
"error_code": code,
|
||||
},
|
||||
"assignee": run.ticket.assignee,
|
||||
"created_by": run.initiated_by,
|
||||
},
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def advance_ticket_automation(run_id: int) -> TicketAutomationRun:
|
||||
"""幂等推进自动流程;异步边界只保存等待状态,由完成回调继续。"""
|
||||
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.select_related("ticket__category", "initiated_by", "support_analysis__run")
|
||||
.get(id=run_id)
|
||||
)
|
||||
if run.status not in ACTIVE_AUTOMATION_STATUSES:
|
||||
return run
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).select_related("category").get(
|
||||
id=run.ticket_id
|
||||
)
|
||||
if ticket.status == TicketStatus.CLOSED:
|
||||
run.status = TicketAutomationRunStatus.COMPLETED
|
||||
run.current_step = "closed"
|
||||
run.finished_at = run.finished_at or timezone.now()
|
||||
run.save()
|
||||
append_automation_event(run, "completed", {"ticket_status": ticket.status})
|
||||
return run
|
||||
if run.blocking_missing_fields:
|
||||
run.status = TicketAutomationRunStatus.WAITING_INPUT
|
||||
run.current_step = "collect_input"
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"waiting_input",
|
||||
{
|
||||
"missing_fields": run.blocking_missing_fields,
|
||||
"questions": run.blocking_questions,
|
||||
},
|
||||
)
|
||||
return run
|
||||
|
||||
run.status = TicketAutomationRunStatus.RUNNING
|
||||
run.started_at = run.started_at or timezone.now()
|
||||
run.last_error_code = ""
|
||||
run.last_error_message = ""
|
||||
|
||||
try:
|
||||
if ticket.status == TicketStatus.PENDING_INFO:
|
||||
run.current_step = "accept_intake"
|
||||
transition_ticket(
|
||||
ticket,
|
||||
TicketStatus.PENDING,
|
||||
"非阻塞字段允许后续补全,自动进入待处理",
|
||||
run.initiated_by,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
)
|
||||
ticket.refresh_from_db()
|
||||
append_automation_event(run, "intake_accepted")
|
||||
if ticket.status == TicketStatus.PENDING:
|
||||
run.current_step = "start_processing"
|
||||
transition_ticket(
|
||||
ticket,
|
||||
TicketStatus.IN_PROGRESS,
|
||||
"自动编排开始支持分析",
|
||||
run.initiated_by,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
)
|
||||
ticket.refresh_from_db()
|
||||
append_automation_event(run, "processing_started")
|
||||
if ticket.status == TicketStatus.IN_PROGRESS:
|
||||
run.current_step = "support_analysis"
|
||||
analysis = run.support_analysis
|
||||
if analysis is None:
|
||||
analysis = start_ticket_support_analysis(
|
||||
ticket,
|
||||
created_by=run.initiated_by,
|
||||
source=TicketActorSource.AGENT,
|
||||
)
|
||||
run.support_analysis = analysis
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"analysis_started",
|
||||
{"agent_run_public_id": str(analysis.run.public_id)},
|
||||
)
|
||||
return run
|
||||
analysis.run.refresh_from_db()
|
||||
if analysis.run.status in {"queued", "running"}:
|
||||
run.save()
|
||||
return run
|
||||
if analysis.run.status != "succeeded" or analysis.processed_at is None:
|
||||
max_retries = int(run.policy_snapshot.get("max_retries", 2))
|
||||
if run.retry_count < max_retries:
|
||||
run.retry_count += 1
|
||||
run.support_analysis = None
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"analysis_retrying",
|
||||
{
|
||||
"retry_count": run.retry_count,
|
||||
"error_code": analysis.run.error_code,
|
||||
},
|
||||
)
|
||||
transaction.on_commit(lambda: _dispatch_advance(run.id))
|
||||
return run
|
||||
return _mark_failed(
|
||||
run,
|
||||
analysis.run.error_code or "support_analysis_failed",
|
||||
analysis.run.error_message or "支持分析未形成可信结果,请人工接管",
|
||||
)
|
||||
reply_task = (
|
||||
CustomerReplyTask.objects.filter(
|
||||
ticket=ticket,
|
||||
support_analysis=analysis,
|
||||
status__in=["draft", "approved"],
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if reply_task is None:
|
||||
return _mark_failed(
|
||||
run,
|
||||
"reply_task_missing",
|
||||
"支持分析已完成,但未生成正式客户回复任务",
|
||||
)
|
||||
transition_ticket(
|
||||
ticket,
|
||||
TicketStatus.PENDING_CONFIRM,
|
||||
"支持分析和正式回复已准备完成,等待一次批准发送",
|
||||
run.initiated_by,
|
||||
source=TicketActorSource.AGENT,
|
||||
agent_run_id=analysis.run_id,
|
||||
)
|
||||
run.reply_task = reply_task
|
||||
run.status = TicketAutomationRunStatus.WAITING_APPROVAL
|
||||
run.current_step = "approve_and_send"
|
||||
run.save()
|
||||
WorkItem.objects.filter(
|
||||
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
|
||||
object_type="ticket_automation_run",
|
||||
object_id=str(run.public_id),
|
||||
status=WorkItemStatus.ACTIVE,
|
||||
).update(
|
||||
status=WorkItemStatus.COMPLETED,
|
||||
completed_by=run.initiated_by,
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
append_automation_event(
|
||||
run,
|
||||
"waiting_approval",
|
||||
{
|
||||
"support_analysis_id": analysis.id,
|
||||
"reply_task_id": reply_task.id,
|
||||
"result_mode": analysis.result_mode,
|
||||
},
|
||||
)
|
||||
return run
|
||||
if ticket.status == TicketStatus.PENDING_CONFIRM:
|
||||
run.status = TicketAutomationRunStatus.WAITING_APPROVAL
|
||||
run.current_step = "approve_and_send"
|
||||
run.save()
|
||||
return run
|
||||
if ticket.status == TicketStatus.RESOLVED:
|
||||
_schedule_customer_wait(run)
|
||||
return run
|
||||
except (SupportAnalysisError, TicketStatusError, TicketDataError) as error:
|
||||
return _mark_failed(run, "automation_step_failed", str(error))
|
||||
|
||||
run.save()
|
||||
return run
|
||||
|
||||
|
||||
def _approval_resolution(run: TicketAutomationRun) -> tuple[str, str, int | None]:
|
||||
approval = run.policy_snapshot.get("approval", {})
|
||||
return (
|
||||
str(approval.get("resolution_text") or "").strip(),
|
||||
str(approval.get("root_cause") or "").strip(),
|
||||
approval.get("solution_id"),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def approve_and_send(
|
||||
run: TicketAutomationRun,
|
||||
*,
|
||||
actor,
|
||||
expected_reply_revision: int,
|
||||
final_text: str,
|
||||
resolution_text: str,
|
||||
root_cause: str = "",
|
||||
solution_id: int | None = None,
|
||||
channel_endpoint_id: int | None = None,
|
||||
external_conversation_id: str = "",
|
||||
manual_delivery_confirmed: bool = False,
|
||||
) -> TicketAutomationRun:
|
||||
"""一次冻结方案和回复;有渠道则投递,无渠道则确认外部已发送。"""
|
||||
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.select_related("ticket", "reply_task")
|
||||
.get(id=run.id)
|
||||
)
|
||||
if run.status in {
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
TicketAutomationRunStatus.COMPLETED,
|
||||
} and run.approved_at:
|
||||
return run
|
||||
if run.status not in {
|
||||
TicketAutomationRunStatus.WAITING_APPROVAL,
|
||||
TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
}:
|
||||
raise TicketAutomationError("当前工单不等待批准发送")
|
||||
if run.reply_task_id is None:
|
||||
raise TicketAutomationError("正式客户回复任务不存在")
|
||||
if not resolution_text.strip() and not solution_id:
|
||||
raise TicketAutomationError("批准前必须提供解决结论或已发布方案")
|
||||
task = run.reply_task
|
||||
if task.status == "draft" and final_text.strip() != task.final_text:
|
||||
task = update_customer_reply(
|
||||
task,
|
||||
final_text,
|
||||
expected_revision=expected_reply_revision,
|
||||
actor=actor,
|
||||
)
|
||||
expected_reply_revision = task.revision
|
||||
elif task.revision != expected_reply_revision:
|
||||
raise TicketAutomationError("客户回复版本已变化,请刷新后重试")
|
||||
|
||||
snapshot = dict(run.policy_snapshot)
|
||||
snapshot["approval"] = {
|
||||
"resolution_text": resolution_text.strip(),
|
||||
"root_cause": root_cause.strip(),
|
||||
"solution_id": solution_id,
|
||||
"reply_task_id": task.id,
|
||||
"reply_revision": task.revision,
|
||||
"approved_at": timezone.now().isoformat(),
|
||||
}
|
||||
run.policy_snapshot = snapshot
|
||||
run.approved_by = actor
|
||||
run.approved_at = timezone.now()
|
||||
run.reply_task = task
|
||||
|
||||
if channel_endpoint_id:
|
||||
from apps.channels.models import ChannelEndpoint
|
||||
from apps.channels.services import ChannelError, queue_outbound_message
|
||||
|
||||
endpoint = ChannelEndpoint.objects.filter(id=channel_endpoint_id, enabled=True).first()
|
||||
if endpoint is None:
|
||||
raise TicketAutomationError("渠道不存在或已停用")
|
||||
try:
|
||||
message = queue_outbound_message(
|
||||
endpoint,
|
||||
{
|
||||
"external_conversation_id": external_conversation_id,
|
||||
"idempotency_key": f"ticket-automation:{run.public_id}:reply:{task.revision}",
|
||||
"customer_reply_task_id": task.id,
|
||||
"expected_revision": task.revision,
|
||||
"payload": {"automation_run_public_id": str(run.public_id)},
|
||||
},
|
||||
actor=actor,
|
||||
)
|
||||
except ChannelError as error:
|
||||
raise TicketAutomationError(str(error)) from error
|
||||
run.status = TicketAutomationRunStatus.WAITING_DELIVERY
|
||||
run.current_step = "wait_delivery"
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"reply_queued",
|
||||
{"channel_message_public_id": str(message.public_id)},
|
||||
)
|
||||
elif manual_delivery_confirmed:
|
||||
confirm_customer_reply_sent(
|
||||
task,
|
||||
expected_revision=task.revision,
|
||||
actor=actor,
|
||||
)
|
||||
run.status = TicketAutomationRunStatus.WAITING_DELIVERY
|
||||
run.current_step = "wait_delivery_callback"
|
||||
run.save()
|
||||
append_automation_event(run, "manual_delivery_confirmed")
|
||||
else:
|
||||
raise TicketAutomationError("请选择发送渠道,或确认已在外部完成发送")
|
||||
|
||||
OperationAuditLog.objects.create(
|
||||
user=actor,
|
||||
module="tickets",
|
||||
action="automation_approved_and_sent",
|
||||
target_type="ticket_automation_run",
|
||||
target_id=str(run.public_id),
|
||||
request_snapshot={
|
||||
"ticket_id": run.ticket_id,
|
||||
"reply_task_id": task.id,
|
||||
"reply_revision": task.revision,
|
||||
"solution_id": solution_id,
|
||||
"delivery_mode": "channel" if channel_endpoint_id else "manual_confirmed",
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_customer_reply_delivered(reply_task_id: int) -> TicketAutomationRun | None:
|
||||
"""渠道或人工确认送达后记录解决结果并启动无异议关闭计时。"""
|
||||
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.select_related("ticket__category", "approved_by", "support_analysis__run")
|
||||
.filter(
|
||||
reply_task_id=reply_task_id,
|
||||
status__in={
|
||||
TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
TicketAutomationRunStatus.WAITING_APPROVAL,
|
||||
},
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if run is None:
|
||||
return None
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).select_related("category").get(
|
||||
id=run.ticket_id
|
||||
)
|
||||
if ticket.status == TicketStatus.PENDING_CONFIRM:
|
||||
resolution_text, root_cause, solution_id = _approval_resolution(run)
|
||||
if solution_id:
|
||||
from apps.solutions.models import Solution
|
||||
|
||||
solution = Solution.objects.filter(id=solution_id, status="published").first()
|
||||
if solution is None:
|
||||
return _mark_failed(run, "solution_changed", "批准的解决方案已失效")
|
||||
adopt_solution_for_ticket(
|
||||
ticket,
|
||||
solution,
|
||||
run.approved_by,
|
||||
remark="客户回复送达后按一次批准结果标记解决",
|
||||
replied_to_customer=True,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
agent_run_id=run.support_analysis.run_id
|
||||
if run.support_analysis_id
|
||||
else None,
|
||||
)
|
||||
else:
|
||||
transition_ticket(
|
||||
ticket,
|
||||
TicketStatus.RESOLVED,
|
||||
"客户回复送达后按一次批准结果标记解决",
|
||||
run.approved_by,
|
||||
resolution=resolution_text,
|
||||
replied_to_customer=True,
|
||||
root_cause=root_cause,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
agent_run_id=run.support_analysis.run_id
|
||||
if run.support_analysis_id
|
||||
else None,
|
||||
)
|
||||
_schedule_customer_wait(run)
|
||||
return run
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_customer_reply_delivery_failed(
|
||||
reply_task_id: int,
|
||||
error_message: str,
|
||||
) -> TicketAutomationRun | None:
|
||||
"""记录渠道失败但保留批准快照,使同一外发消息可安全重试。"""
|
||||
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.filter(
|
||||
reply_task_id=reply_task_id,
|
||||
status=TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if run is None:
|
||||
return None
|
||||
run.current_step = "delivery_failed"
|
||||
run.last_error_code = "channel_delivery_failed"
|
||||
run.last_error_message = error_message[:2000] or "渠道发送失败"
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"delivery_failed",
|
||||
{"error": run.last_error_message},
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def _schedule_customer_wait(run: TicketAutomationRun) -> None:
|
||||
hours = int(run.policy_snapshot.get("auto_close_hours", 24))
|
||||
eta = timezone.now() + timedelta(hours=hours)
|
||||
# 自动关闭已经接管结束动作,避免工作台继续制造一个人工“关闭工单”待办。
|
||||
WorkItem.objects.filter(
|
||||
ticket_id=run.ticket_id,
|
||||
task_type=WorkItemType.TICKET_CLOSE,
|
||||
status=WorkItemStatus.ACTIVE,
|
||||
).update(
|
||||
status=WorkItemStatus.COMPLETED,
|
||||
completed_by=run.approved_by,
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
run.status = TicketAutomationRunStatus.WAITING_CUSTOMER
|
||||
run.current_step = "wait_customer"
|
||||
run.next_action_at = eta
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"waiting_customer",
|
||||
{"auto_close_at": eta.isoformat(), "hours": hours},
|
||||
)
|
||||
transaction.on_commit(lambda: _dispatch_close(run.id, eta))
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def close_due_ticket_automation(run_id: int, *, force: bool = False) -> bool:
|
||||
"""客户无异议窗口结束后,以系统身份幂等关闭已解决工单。"""
|
||||
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.select_related("ticket")
|
||||
.get(id=run_id)
|
||||
)
|
||||
if run.status != TicketAutomationRunStatus.WAITING_CUSTOMER:
|
||||
return False
|
||||
if not force and (run.next_action_at is None or run.next_action_at > timezone.now()):
|
||||
return False
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=run.ticket_id)
|
||||
if ticket.status != TicketStatus.RESOLVED:
|
||||
return False
|
||||
transition_ticket(
|
||||
ticket,
|
||||
TicketStatus.CLOSED,
|
||||
"客户回复送达后 24 小时无异议,系统按策略自动关闭",
|
||||
None,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
)
|
||||
run.status = TicketAutomationRunStatus.COMPLETED
|
||||
run.current_step = "closed"
|
||||
run.next_action_at = None
|
||||
run.finished_at = timezone.now()
|
||||
run.save()
|
||||
append_automation_event(run, "completed", {"reason": "no_objection_timeout"})
|
||||
return True
|
||||
|
||||
|
||||
POSITIVE_CUSTOMER_PHRASES = (
|
||||
"已解决",
|
||||
"恢复了",
|
||||
"可以了",
|
||||
"好了",
|
||||
"works now",
|
||||
"resolved",
|
||||
)
|
||||
NEGATIVE_CUSTOMER_PHRASES = (
|
||||
"未解决",
|
||||
"还是不行",
|
||||
"仍然不行",
|
||||
"没有恢复",
|
||||
"继续报错",
|
||||
"not resolved",
|
||||
"still broken",
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def handle_customer_feedback(
|
||||
ticket_id: int,
|
||||
text: str,
|
||||
*,
|
||||
channel_message_id: int | None = None,
|
||||
) -> str:
|
||||
"""只对明确反馈自动推进;含糊消息暂停关闭,避免误判客户意图。"""
|
||||
|
||||
normalized = " ".join(text.casefold().split())
|
||||
run = (
|
||||
TicketAutomationRun.objects.select_for_update(of=("self",))
|
||||
.select_related("ticket")
|
||||
.filter(
|
||||
ticket_id=ticket_id,
|
||||
status__in={
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
TicketAutomationRunStatus.WAITING_INPUT,
|
||||
},
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if run is None:
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).filter(id=ticket_id).first()
|
||||
if (
|
||||
ticket is None
|
||||
or ticket.status != TicketStatus.CLOSED
|
||||
or not any(phrase in normalized for phrase in NEGATIVE_CUSTOMER_PHRASES)
|
||||
):
|
||||
return "ignored"
|
||||
follow_up = create_follow_up_ticket(ticket, text)
|
||||
completed_run = (
|
||||
TicketAutomationRun.objects.filter(
|
||||
ticket=ticket,
|
||||
status=TicketAutomationRunStatus.COMPLETED,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if completed_run is not None and not completed_run.events.filter(
|
||||
event_type="follow_up_created"
|
||||
).exists():
|
||||
append_automation_event(
|
||||
completed_run,
|
||||
"follow_up_created",
|
||||
{"follow_up_ticket_id": follow_up.id, "text": text[:500]},
|
||||
)
|
||||
if channel_message_id:
|
||||
from apps.channels.models import ChannelMessage
|
||||
|
||||
message = ChannelMessage.objects.select_related("conversation").filter(
|
||||
id=channel_message_id
|
||||
).first()
|
||||
if message is not None:
|
||||
message.ticket = follow_up
|
||||
message.save(update_fields=["ticket", "updated_at"])
|
||||
if message.conversation_id:
|
||||
message.conversation.ticket = follow_up
|
||||
message.conversation.save(update_fields=["ticket", "updated_at"])
|
||||
return "follow_up_created"
|
||||
if (
|
||||
run.status == TicketAutomationRunStatus.WAITING_INPUT
|
||||
and run.current_step == "collect_input"
|
||||
and normalized
|
||||
):
|
||||
previous_context = run.ticket.current_result.strip()
|
||||
customer_context = f"{previous_context}\n客户补充:{text.strip()}".strip()
|
||||
update_ticket_fields(
|
||||
run.ticket,
|
||||
{
|
||||
"current_result": customer_context,
|
||||
"customer_last_message_at": timezone.now(),
|
||||
},
|
||||
source=TicketActorSource.CHANNEL,
|
||||
)
|
||||
run.blocking_missing_fields = []
|
||||
run.blocking_questions = []
|
||||
run.status = TicketAutomationRunStatus.QUEUED
|
||||
run.current_step = "resume_after_customer_input"
|
||||
run.save()
|
||||
append_automation_event(run, "customer_input_received", {"text": text[:500]})
|
||||
transaction.on_commit(lambda: _dispatch_advance(run.id))
|
||||
return "resumed"
|
||||
if any(phrase in normalized for phrase in NEGATIVE_CUSTOMER_PHRASES):
|
||||
reopen_ticket(run.ticket, source=TicketActorSource.CHANNEL)
|
||||
run.status = TicketAutomationRunStatus.RUNNING
|
||||
run.current_step = "support_analysis"
|
||||
run.support_analysis = None
|
||||
run.reply_task = None
|
||||
run.next_action_at = None
|
||||
run.save()
|
||||
append_automation_event(run, "customer_rejected", {"text": text[:500]})
|
||||
transaction.on_commit(lambda: _dispatch_advance(run.id))
|
||||
return "reopened"
|
||||
if any(phrase in normalized for phrase in POSITIVE_CUSTOMER_PHRASES):
|
||||
append_automation_event(run, "customer_confirmed", {"text": text[:500]})
|
||||
close_due_ticket_automation(run.id, force=True)
|
||||
return "closed"
|
||||
|
||||
run.status = TicketAutomationRunStatus.WAITING_INPUT
|
||||
run.current_step = "confirm_customer_intent"
|
||||
run.blocking_questions = ["请确认客户是否认可当前解决结果"]
|
||||
run.next_action_at = None
|
||||
run.save()
|
||||
append_automation_event(run, "customer_intent_ambiguous", {"text": text[:500]})
|
||||
from apps.workitems.models import WorkItem
|
||||
|
||||
WorkItem.objects.get_or_create(
|
||||
task_type=WorkItemType.TICKET_CUSTOMER_CONFIRMATION,
|
||||
object_type="ticket",
|
||||
object_id=str(ticket_id),
|
||||
status="active",
|
||||
defaults={
|
||||
"ticket": run.ticket,
|
||||
"title": f"确认工单 {run.ticket.ticket_no} 客户反馈",
|
||||
"description": "客户在自动关闭窗口内发送了新消息,需确认是否已经解决。",
|
||||
"payload": {
|
||||
"automation_run_public_id": str(run.public_id),
|
||||
"message": text[:1000],
|
||||
},
|
||||
"assignee": run.ticket.assignee,
|
||||
"created_by": run.approved_by,
|
||||
},
|
||||
)
|
||||
return "needs_confirmation"
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def apply_automation_action(
|
||||
run: TicketAutomationRun,
|
||||
action: str,
|
||||
*,
|
||||
actor,
|
||||
) -> TicketAutomationRun:
|
||||
run = TicketAutomationRun.objects.select_for_update(of=("self",)).get(id=run.id)
|
||||
if (
|
||||
action == "retry"
|
||||
and run.status == TicketAutomationRunStatus.WAITING_DELIVERY
|
||||
and run.current_step == "delivery_failed"
|
||||
):
|
||||
from apps.channels.models import ChannelMessage
|
||||
from apps.channels.services import ChannelError, retry_outbound_message
|
||||
|
||||
message = (
|
||||
ChannelMessage.objects.filter(
|
||||
customer_reply_task_id=run.reply_task_id,
|
||||
direction="outbound",
|
||||
status="failed",
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
raise TicketAutomationError("没有可重试的失败渠道消息")
|
||||
try:
|
||||
retry_outbound_message(message, actor=actor)
|
||||
except ChannelError as error:
|
||||
raise TicketAutomationError(str(error)) from error
|
||||
run.current_step = "wait_delivery"
|
||||
run.last_error_code = ""
|
||||
run.last_error_message = ""
|
||||
run.save()
|
||||
append_automation_event(
|
||||
run,
|
||||
"delivery_retry_queued",
|
||||
{"channel_message_public_id": str(message.public_id)},
|
||||
)
|
||||
return run
|
||||
if action in {"pause", "take_over", "cancel"}:
|
||||
run.status = TicketAutomationRunStatus.CANCELLED
|
||||
run.current_step = "human_takeover" if action == "take_over" else "paused"
|
||||
run.finished_at = timezone.now()
|
||||
run.save()
|
||||
append_automation_event(run, action, {"actor_id": actor.id})
|
||||
return run
|
||||
if action in {"retry", "resume"}:
|
||||
if run.status not in {
|
||||
TicketAutomationRunStatus.FAILED,
|
||||
TicketAutomationRunStatus.CANCELLED,
|
||||
TicketAutomationRunStatus.WAITING_INPUT,
|
||||
}:
|
||||
raise TicketAutomationError("当前自动化运行不允许重试或恢复")
|
||||
if action == "resume" and run.status == TicketAutomationRunStatus.WAITING_INPUT:
|
||||
run.blocking_missing_fields = []
|
||||
run.blocking_questions = []
|
||||
run.status = TicketAutomationRunStatus.QUEUED
|
||||
run.current_step = "resume"
|
||||
run.last_error_code = ""
|
||||
run.last_error_message = ""
|
||||
run.finished_at = None
|
||||
run.save()
|
||||
WorkItem.objects.filter(
|
||||
task_type=WorkItemType.SUPPORT_ANALYSIS_FAILED,
|
||||
object_type="ticket_automation_run",
|
||||
object_id=str(run.public_id),
|
||||
status=WorkItemStatus.ACTIVE,
|
||||
).update(
|
||||
status=WorkItemStatus.COMPLETED,
|
||||
completed_by=actor,
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
append_automation_event(run, action, {"actor_id": actor.id})
|
||||
transaction.on_commit(lambda: _dispatch_advance(run.id))
|
||||
return run
|
||||
raise TicketAutomationError("未知自动化操作")
|
||||
|
||||
|
||||
def serialize_automation_run(run: TicketAutomationRun) -> dict:
|
||||
ticket = run.ticket
|
||||
if run.status in {
|
||||
TicketAutomationRunStatus.QUEUED,
|
||||
TicketAutomationRunStatus.RUNNING,
|
||||
}:
|
||||
display_phase = "processing"
|
||||
next_human_action = ""
|
||||
elif run.status == TicketAutomationRunStatus.WAITING_APPROVAL:
|
||||
display_phase = "confirmation"
|
||||
next_human_action = "approve_and_send"
|
||||
elif run.status in {
|
||||
TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
}:
|
||||
delivery_failed = run.current_step == "delivery_failed"
|
||||
display_phase = "attention" if delivery_failed else "confirmation"
|
||||
next_human_action = "retry" if delivery_failed else ""
|
||||
elif run.status == TicketAutomationRunStatus.COMPLETED:
|
||||
display_phase = "completed"
|
||||
next_human_action = ""
|
||||
else:
|
||||
display_phase = "attention"
|
||||
next_human_action = "take_over"
|
||||
return {
|
||||
"public_id": str(run.public_id),
|
||||
"ticket_id": run.ticket_id,
|
||||
"ticket_status": ticket.status,
|
||||
"status": run.status,
|
||||
"current_step": run.current_step,
|
||||
"display_phase": display_phase,
|
||||
"next_human_action": next_human_action,
|
||||
"blocking_missing_fields": run.blocking_missing_fields,
|
||||
"blocking_questions": run.blocking_questions,
|
||||
"retry_count": run.retry_count,
|
||||
"last_error_code": run.last_error_code,
|
||||
"last_error_message": run.last_error_message,
|
||||
"support_analysis_id": run.support_analysis_id,
|
||||
"reply_task_id": run.reply_task_id,
|
||||
"next_action_at": run.next_action_at,
|
||||
"approved_at": run.approved_at,
|
||||
"started_at": run.started_at,
|
||||
"finished_at": run.finished_at,
|
||||
"created_at": run.created_at,
|
||||
"updated_at": run.updated_at,
|
||||
}
|
||||
67
backend/apps/tickets/automation_api.py
Normal file
67
backend/apps/tickets/automation_api.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.tickets.models import TicketAutomationRun
|
||||
|
||||
router = Router(tags=["ticket-automation-runs"])
|
||||
|
||||
|
||||
def _cursor(request, after: int | None) -> int:
|
||||
raw = request.headers.get("Last-Event-ID") or after or 0
|
||||
try:
|
||||
return max(int(raw), 0)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HttpError(400, "事件游标无效") from error
|
||||
|
||||
|
||||
def _format_event(event) -> str:
|
||||
payload = {
|
||||
"sequence": event.sequence,
|
||||
"event_type": event.event_type,
|
||||
"payload": event.payload,
|
||||
"created_at": event.created_at.isoformat(),
|
||||
}
|
||||
return (
|
||||
f"id: {event.sequence}\n"
|
||||
f"event: {event.event_type}\n"
|
||||
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_id}/events")
|
||||
def stream_ticket_automation_events(request, public_id: str, after: int | None = None):
|
||||
require_permission(request, "ticket.view")
|
||||
run = TicketAutomationRun.objects.filter(public_id=public_id).first()
|
||||
if run is None:
|
||||
raise HttpError(404, "工单自动化运行不存在")
|
||||
cursor = _cursor(request, after)
|
||||
terminal = {"completed", "failed", "cancelled"}
|
||||
max_seconds = getattr(settings, "AGENT_RUN_SSE_MAX_SECONDS", 60)
|
||||
|
||||
def event_stream():
|
||||
current = cursor
|
||||
started = time.monotonic()
|
||||
heartbeat = started
|
||||
while time.monotonic() - started < max_seconds:
|
||||
events = list(run.events.filter(sequence__gt=current)[:100])
|
||||
for event in events:
|
||||
current = event.sequence
|
||||
yield _format_event(event)
|
||||
if events and events[-1].event_type in terminal:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - heartbeat >= 15:
|
||||
yield ": keep-alive\n\n"
|
||||
heartbeat = now
|
||||
time.sleep(0.5)
|
||||
|
||||
response = StreamingHttpResponse(event_stream(), content_type="text/event-stream")
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
@@ -72,6 +72,8 @@ class TicketDraftOut(Schema):
|
||||
message_timeline: list[dict]
|
||||
missing_fields: list[str]
|
||||
questions: list[str]
|
||||
blocking_missing_fields: list[str]
|
||||
blocking_questions: list[str]
|
||||
guided_rounds: int
|
||||
agent_run_public_id: str | None = None
|
||||
agent_progress: TicketDraftAgentProgressOut
|
||||
|
||||
@@ -20,6 +20,7 @@ from apps.knowledge.services import retrieve_knowledge
|
||||
from apps.modelhub.models import ModelDeployment
|
||||
from apps.tickets.materials import SUPPORTED_EXTENSIONS
|
||||
from apps.tickets.models import (
|
||||
Ticket,
|
||||
TicketActorSource,
|
||||
TicketAttachment,
|
||||
TicketDraft,
|
||||
@@ -317,6 +318,8 @@ def serialize_draft(draft: TicketDraft, *, ticket_serializer=None) -> dict:
|
||||
"message_timeline": normalized_timeline,
|
||||
"missing_fields": draft.missing_fields,
|
||||
"questions": draft.questions,
|
||||
"blocking_missing_fields": draft.blocking_missing_fields,
|
||||
"blocking_questions": draft.blocking_questions,
|
||||
"guided_rounds": draft.guided_rounds,
|
||||
"agent_run_public_id": str(draft.latest_agent_run.public_id)
|
||||
if draft.latest_agent_run_id
|
||||
@@ -711,8 +714,8 @@ def _auto_create_ticket(
|
||||
TicketMessageTimelineItem.objects.filter(draft=draft).update(ticket=ticket)
|
||||
sync_ticket_missing_info_work_item(
|
||||
ticket,
|
||||
combined_missing_fields,
|
||||
questions=draft.questions,
|
||||
draft.blocking_missing_fields,
|
||||
questions=draft.blocking_questions,
|
||||
draft_public_id=str(draft.public_id),
|
||||
actor=draft.created_by,
|
||||
)
|
||||
@@ -730,6 +733,25 @@ def _auto_create_ticket(
|
||||
)
|
||||
audit_draft(draft, "draft_auto_created")
|
||||
complete_ticket_intake_failure_work_item(draft, actor=draft.created_by)
|
||||
ticket_id = ticket.id
|
||||
initiated_by_id = draft.created_by_id
|
||||
blocking_missing_fields = list(draft.blocking_missing_fields)
|
||||
blocking_questions = list(draft.blocking_questions)
|
||||
|
||||
def start_automation() -> None:
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from apps.tickets.automation import start_ticket_automation
|
||||
|
||||
actor = get_user_model().objects.filter(id=initiated_by_id).first()
|
||||
start_ticket_automation(
|
||||
Ticket.objects.get(id=ticket_id),
|
||||
initiated_by=actor,
|
||||
blocking_missing_fields=blocking_missing_fields,
|
||||
blocking_questions=blocking_questions,
|
||||
)
|
||||
|
||||
transaction.on_commit(start_automation)
|
||||
return draft
|
||||
|
||||
|
||||
@@ -803,10 +825,14 @@ def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
fields = run.final_output.get("fields")
|
||||
missing_fields = run.final_output.get("missing_fields")
|
||||
questions = run.final_output.get("questions")
|
||||
blocking_missing_fields = run.final_output.get("blocking_missing_fields", [])
|
||||
blocking_questions = run.final_output.get("blocking_questions", [])
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not isinstance(missing_fields, list)
|
||||
or not isinstance(questions, list)
|
||||
or not isinstance(blocking_missing_fields, list)
|
||||
or not isinstance(blocking_questions, list)
|
||||
):
|
||||
_mark_analysis_failed(
|
||||
draft,
|
||||
@@ -830,6 +856,11 @@ def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
if raw_value and normalized_datetime is None and code not in missing_fields:
|
||||
missing_fields.append(code)
|
||||
missing_fields = [str(item) for item in missing_fields if str(item) in ALLOWED_FIELD_CODES]
|
||||
blocking_missing_fields = [
|
||||
str(item)
|
||||
for item in blocking_missing_fields
|
||||
if str(item) in ALLOWED_FIELD_CODES
|
||||
]
|
||||
source_corpus = " ".join(
|
||||
[
|
||||
draft.source_text,
|
||||
@@ -856,6 +887,11 @@ def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
fields.update(manual_fields)
|
||||
missing_fields = [code for code in missing_fields if not fields.get(code)]
|
||||
questions = [str(item).replace("\r", " ").replace("\n", " ").strip() for item in questions]
|
||||
blocking_questions = [
|
||||
str(item).replace("\r", " ").replace("\n", " ").strip()
|
||||
for item in blocking_questions
|
||||
if str(item).strip()
|
||||
]
|
||||
field_evidence, confidence_by_field = _normalize_field_evidence(
|
||||
run.final_output.get("field_evidence")
|
||||
)
|
||||
@@ -884,6 +920,8 @@ def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
"message_timeline": message_timeline,
|
||||
"missing_fields": missing_fields,
|
||||
"questions": questions,
|
||||
"blocking_missing_fields": blocking_missing_fields,
|
||||
"blocking_questions": blocking_questions,
|
||||
},
|
||||
created_by=draft.created_by,
|
||||
)
|
||||
@@ -895,6 +933,8 @@ def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
draft.message_timeline = message_timeline
|
||||
draft.missing_fields = missing_fields
|
||||
draft.questions = questions
|
||||
draft.blocking_missing_fields = blocking_missing_fields
|
||||
draft.blocking_questions = blocking_questions
|
||||
draft.latest_artifact = artifact
|
||||
draft.status = "needs_input"
|
||||
draft.error_code = ""
|
||||
|
||||
201
backend/apps/tickets/migrations/0016_ticket_automation.py
Normal file
201
backend/apps/tickets/migrations/0016_ticket_automation.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("agents", "0006_step_model_deployments"),
|
||||
("tickets", "0015_support_evaluation"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="blocking_missing_fields",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="blocking_questions",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketsupportanalysis",
|
||||
name="result_mode",
|
||||
field=models.CharField(default="trusted", max_length=24),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TicketAutomationRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"public_id",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("waiting_input", "Waiting input"),
|
||||
("waiting_approval", "Waiting approval"),
|
||||
("waiting_delivery", "Waiting delivery"),
|
||||
("waiting_customer", "Waiting customer"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="queued",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("current_step", models.CharField(default="intake", max_length=48)),
|
||||
("policy_snapshot", models.JSONField(blank=True, default=dict)),
|
||||
("blocking_missing_fields", models.JSONField(blank=True, default=list)),
|
||||
("blocking_questions", models.JSONField(blank=True, default=list)),
|
||||
("retry_count", models.PositiveSmallIntegerField(default=0)),
|
||||
("last_error_code", models.CharField(blank=True, max_length=80)),
|
||||
("last_error_message", models.TextField(blank=True)),
|
||||
("next_action_at", models.DateTimeField(blank=True, null=True)),
|
||||
("approved_at", models.DateTimeField(blank=True, null=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("finished_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"approved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="approved_ticket_automation_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"initiated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="initiated_ticket_automation_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"reply_task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="automation_runs",
|
||||
to="tickets.customerreplytask",
|
||||
),
|
||||
),
|
||||
(
|
||||
"support_analysis",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="automation_runs",
|
||||
to="tickets.ticketsupportanalysis",
|
||||
),
|
||||
),
|
||||
(
|
||||
"ticket",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="automation_runs",
|
||||
to="tickets.ticket",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TicketAutomationEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("sequence", models.PositiveIntegerField()),
|
||||
("event_type", models.CharField(max_length=80)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"run",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="events",
|
||||
to="tickets.ticketautomationrun",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["sequence"]},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticketautomationrun",
|
||||
index=models.Index(
|
||||
fields=["ticket", "status", "created_at"],
|
||||
name="tickets_tic_ticket__f9dbaf_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticketautomationrun",
|
||||
index=models.Index(
|
||||
fields=["status", "next_action_at"],
|
||||
name="tickets_tic_status_9ef355_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ticketautomationrun",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(
|
||||
status__in=[
|
||||
"queued",
|
||||
"running",
|
||||
"waiting_input",
|
||||
"waiting_approval",
|
||||
"waiting_delivery",
|
||||
"waiting_customer",
|
||||
]
|
||||
),
|
||||
fields=("ticket",),
|
||||
name="unique_active_ticket_automation_run",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticketautomationevent",
|
||||
index=models.Index(
|
||||
fields=["run", "sequence"],
|
||||
name="tickets_tic_run_id_d6464b_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="ticketautomationevent",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("run", "sequence"),
|
||||
name="unique_ticket_automation_event_sequence",
|
||||
),
|
||||
),
|
||||
]
|
||||
22
backend/apps/tickets/migrations/0017_ticket_follow_up_of.py
Normal file
22
backend/apps/tickets/migrations/0017_ticket_follow_up_of.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tickets", "0016_ticket_automation"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="follow_up_of",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="follow_up_tickets",
|
||||
to="tickets.ticket",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -108,6 +108,13 @@ class Ticket(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_tickets",
|
||||
)
|
||||
follow_up_of = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="follow_up_tickets",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -286,6 +293,8 @@ class TicketDraft(models.Model):
|
||||
message_timeline = models.JSONField(default=list)
|
||||
missing_fields = models.JSONField(default=list)
|
||||
questions = models.JSONField(default=list)
|
||||
blocking_missing_fields = models.JSONField(default=list)
|
||||
blocking_questions = models.JSONField(default=list)
|
||||
guided_rounds = models.PositiveSmallIntegerField(default=0)
|
||||
latest_agent_run = models.ForeignKey(
|
||||
"agents.AgentRun",
|
||||
@@ -549,6 +558,7 @@ class TicketSupportAnalysis(models.Model):
|
||||
query = models.TextField()
|
||||
references = models.JSONField(default=list)
|
||||
retrieval_policy = models.JSONField(default=dict)
|
||||
result_mode = models.CharField(max_length=24, default="trusted")
|
||||
automation_decision = models.JSONField(default=dict, blank=True)
|
||||
applied_solution = models.ForeignKey(
|
||||
"solutions.Solution",
|
||||
@@ -665,6 +675,131 @@ class CustomerReplyRevision(models.Model):
|
||||
]
|
||||
|
||||
|
||||
class TicketAutomationRunStatus(models.TextChoices):
|
||||
QUEUED = "queued", "Queued"
|
||||
RUNNING = "running", "Running"
|
||||
WAITING_INPUT = "waiting_input", "Waiting input"
|
||||
WAITING_APPROVAL = "waiting_approval", "Waiting approval"
|
||||
WAITING_DELIVERY = "waiting_delivery", "Waiting delivery"
|
||||
WAITING_CUSTOMER = "waiting_customer", "Waiting customer"
|
||||
COMPLETED = "completed", "Completed"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
|
||||
class TicketAutomationRun(models.Model):
|
||||
"""工单端到端自动处理的持久化编排状态。"""
|
||||
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="automation_runs",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
choices=TicketAutomationRunStatus.choices,
|
||||
default=TicketAutomationRunStatus.QUEUED,
|
||||
)
|
||||
current_step = models.CharField(max_length=48, default="intake")
|
||||
policy_snapshot = models.JSONField(default=dict, blank=True)
|
||||
blocking_missing_fields = models.JSONField(default=list, blank=True)
|
||||
blocking_questions = models.JSONField(default=list, blank=True)
|
||||
retry_count = models.PositiveSmallIntegerField(default=0)
|
||||
last_error_code = models.CharField(max_length=80, blank=True)
|
||||
last_error_message = models.TextField(blank=True)
|
||||
next_action_at = models.DateTimeField(null=True, blank=True)
|
||||
support_analysis = models.ForeignKey(
|
||||
TicketSupportAnalysis,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="automation_runs",
|
||||
)
|
||||
reply_task = models.ForeignKey(
|
||||
CustomerReplyTask,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="automation_runs",
|
||||
)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="initiated_ticket_automation_runs",
|
||||
)
|
||||
approved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="approved_ticket_automation_runs",
|
||||
)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ticket"],
|
||||
condition=models.Q(
|
||||
status__in=[
|
||||
TicketAutomationRunStatus.QUEUED,
|
||||
TicketAutomationRunStatus.RUNNING,
|
||||
TicketAutomationRunStatus.WAITING_INPUT,
|
||||
TicketAutomationRunStatus.WAITING_APPROVAL,
|
||||
TicketAutomationRunStatus.WAITING_DELIVERY,
|
||||
TicketAutomationRunStatus.WAITING_CUSTOMER,
|
||||
]
|
||||
),
|
||||
name="unique_active_ticket_automation_run",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["ticket", "status", "created_at"],
|
||||
name="tickets_tic_ticket__f9dbaf_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["status", "next_action_at"],
|
||||
name="tickets_tic_status_9ef355_idx",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class TicketAutomationEvent(models.Model):
|
||||
run = models.ForeignKey(
|
||||
TicketAutomationRun,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="events",
|
||||
)
|
||||
sequence = models.PositiveIntegerField()
|
||||
event_type = models.CharField(max_length=80)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sequence"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["run", "sequence"],
|
||||
name="unique_ticket_automation_event_sequence",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(
|
||||
fields=["run", "sequence"],
|
||||
name="tickets_tic_run_id_d6464b_idx",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# Kept temporarily for imports owned by stage 2.1 integrations.
|
||||
TicketDraftImage = TicketDraftMaterial
|
||||
|
||||
|
||||
@@ -276,4 +276,12 @@ def confirm_customer_reply_sent(
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
reply_task_id = task.id
|
||||
|
||||
def resume_ticket_automation() -> None:
|
||||
from apps.tickets.automation import handle_customer_reply_delivered
|
||||
|
||||
handle_customer_reply_delivered(reply_task_id)
|
||||
|
||||
transaction.on_commit(resume_ticket_automation)
|
||||
return task
|
||||
|
||||
@@ -109,6 +109,7 @@ class TicketOut(Schema):
|
||||
current_result: str
|
||||
requester_id: int | None
|
||||
assignee_id: int | None
|
||||
follow_up_of_id: int | None
|
||||
requester: TicketUserOut | None = None
|
||||
assignee: TicketUserOut | None = None
|
||||
problem_started_at: datetime | None
|
||||
@@ -245,6 +246,7 @@ class TicketSupportAnalysisOut(Schema):
|
||||
progress_message: str
|
||||
query: str
|
||||
retrieval_policy: dict[str, Any]
|
||||
result_mode: str
|
||||
references: list[dict[str, Any]]
|
||||
final_output: Any | None
|
||||
automation_decision: dict[str, Any]
|
||||
@@ -318,6 +320,45 @@ class CustomerReplyConfirmIn(Schema):
|
||||
expected_revision: int
|
||||
|
||||
|
||||
class TicketAutomationOut(Schema):
|
||||
public_id: str
|
||||
ticket_id: int
|
||||
ticket_status: str
|
||||
status: str
|
||||
current_step: str
|
||||
display_phase: str
|
||||
next_human_action: str
|
||||
blocking_missing_fields: list[str]
|
||||
blocking_questions: list[str]
|
||||
retry_count: int
|
||||
last_error_code: str
|
||||
last_error_message: str
|
||||
support_analysis_id: int | None
|
||||
reply_task_id: int | None
|
||||
next_action_at: datetime | None
|
||||
approved_at: datetime | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TicketAutomationApproveIn(Schema):
|
||||
expected_ticket_updated_at: datetime
|
||||
expected_reply_revision: int
|
||||
final_text: str
|
||||
resolution_text: str = ""
|
||||
root_cause: str = ""
|
||||
solution_id: int | None = None
|
||||
channel_endpoint_id: int | None = None
|
||||
external_conversation_id: str = ""
|
||||
manual_delivery_confirmed: bool = False
|
||||
|
||||
|
||||
class TicketAutomationActionIn(Schema):
|
||||
action: str
|
||||
|
||||
|
||||
class IntakeEvaluationRecordIn(Schema):
|
||||
case_key: str
|
||||
draft_public_id: str
|
||||
|
||||
@@ -316,6 +316,113 @@ def create_ticket_from_fields(
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_follow_up_ticket(
|
||||
source_ticket: Ticket,
|
||||
customer_feedback: str,
|
||||
*,
|
||||
operator=None,
|
||||
) -> Ticket:
|
||||
"""为已关闭工单的新负向反馈创建唯一活动后续工单。
|
||||
|
||||
已关闭记录保持不可变;后续问题继承必要上下文但不继承处理人,自动化未就绪时
|
||||
仍可按普通工单人工接管。
|
||||
"""
|
||||
|
||||
source_ticket = (
|
||||
Ticket.objects.select_for_update(of=("self",))
|
||||
.select_related("category", "requester", "created_by")
|
||||
.get(id=source_ticket.id)
|
||||
)
|
||||
if source_ticket.status != TicketStatus.CLOSED:
|
||||
raise TicketStatusError("只有已关闭工单才能创建后续工单")
|
||||
existing = (
|
||||
source_ticket.follow_up_tickets.exclude(status=TicketStatus.CLOSED)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
feedback = customer_feedback.strip()
|
||||
if not feedback:
|
||||
raise TicketDataError("客户反馈不能为空")
|
||||
actor = operator or source_ticket.requester or source_ticket.created_by
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
title=f"后续问题:{feedback[:170]}"[:200],
|
||||
description=feedback,
|
||||
category=source_ticket.category,
|
||||
status=TicketStatus.PENDING,
|
||||
priority=source_ticket.priority,
|
||||
problem_performance=feedback,
|
||||
problem_object=source_ticket.problem_object,
|
||||
submitter_name=source_ticket.submitter_name,
|
||||
submitter_org=source_ticket.submitter_org,
|
||||
impact_scope=source_ticket.impact_scope,
|
||||
environment=source_ticket.environment,
|
||||
related_system=source_ticket.related_system,
|
||||
product_name=source_ticket.product_name,
|
||||
module_name=source_ticket.module_name,
|
||||
tags=source_ticket.tags,
|
||||
attempted_actions=source_ticket.attempted_actions,
|
||||
error_information=source_ticket.error_information,
|
||||
customer_request=source_ticket.customer_request,
|
||||
current_result=f"客户在原工单关闭后反馈:{feedback}",
|
||||
requester=source_ticket.requester,
|
||||
created_by=actor,
|
||||
follow_up_of=source_ticket,
|
||||
customer_last_message_at=timezone.now(),
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="created",
|
||||
to_value={
|
||||
"status": ticket.status,
|
||||
"category_code": ticket.category.code if ticket.category_id else None,
|
||||
},
|
||||
operator=operator,
|
||||
source=TicketActorSource.CHANNEL,
|
||||
metadata={
|
||||
"creation_mode": "closed_ticket_follow_up",
|
||||
"source_ticket_id": source_ticket.id,
|
||||
},
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=source_ticket,
|
||||
event_type="follow_up_created",
|
||||
to_value={"follow_up_ticket_id": ticket.id, "ticket_no": ticket.ticket_no},
|
||||
operator=operator,
|
||||
source=TicketActorSource.SYSTEM,
|
||||
metadata={"customer_feedback": feedback[:1000]},
|
||||
)
|
||||
for field_name, value in {
|
||||
"description": feedback,
|
||||
"problem_performance": feedback,
|
||||
"current_result": ticket.current_result,
|
||||
}.items():
|
||||
record_field_evidence(
|
||||
ticket,
|
||||
field_name,
|
||||
value,
|
||||
source=TicketActorSource.CHANNEL,
|
||||
operator=operator,
|
||||
evidence={"source_ticket_id": source_ticket.id},
|
||||
)
|
||||
from apps.tickets.similarity import schedule_ticket_similarity_refresh
|
||||
|
||||
schedule_ticket_similarity_refresh(ticket)
|
||||
ticket_id = ticket.id
|
||||
|
||||
def start_follow_up_automation() -> None:
|
||||
from apps.tickets.automation import start_ticket_automation
|
||||
|
||||
follow_up = Ticket.objects.get(id=ticket_id)
|
||||
start_ticket_automation(follow_up, initiated_by=actor)
|
||||
|
||||
transaction.on_commit(start_follow_up_automation)
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_ticket_from_intake(
|
||||
fields: dict,
|
||||
@@ -828,6 +935,58 @@ def transition_ticket(
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def reopen_ticket(
|
||||
ticket: Ticket,
|
||||
*,
|
||||
operator=None,
|
||||
remark: str = "客户反馈问题仍未解决,恢复处理",
|
||||
source: str = TicketActorSource.SYSTEM,
|
||||
) -> Ticket:
|
||||
"""在保留既有事件和解决记录的前提下开启新一轮处理。"""
|
||||
|
||||
ticket = Ticket.objects.select_for_update(of=("self",)).get(id=ticket.id)
|
||||
if ticket.status != TicketStatus.RESOLVED:
|
||||
raise TicketStatusError("只有已解决且尚未关闭的工单可以恢复处理")
|
||||
from_status = ticket.status
|
||||
ticket.status = TicketStatus.IN_PROGRESS
|
||||
ticket.resolved_at = None
|
||||
ticket.closed_at = None
|
||||
ticket.replied_to_customer = False
|
||||
ticket.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"resolved_at",
|
||||
"closed_at",
|
||||
"replied_to_customer",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
from apps.workitems.services import sync_ticket_close_work_item
|
||||
|
||||
sync_ticket_close_work_item(ticket, actor=operator)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="ticket_reopened",
|
||||
from_value={"status": from_status},
|
||||
to_value={"status": TicketStatus.IN_PROGRESS},
|
||||
operator=operator,
|
||||
source=source,
|
||||
remark=remark,
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
user=operator,
|
||||
module="tickets",
|
||||
action="ticket_reopened",
|
||||
target_type="ticket",
|
||||
target_id=str(ticket.id),
|
||||
request_snapshot={"source": source, "remark": remark},
|
||||
result="success",
|
||||
)
|
||||
schedule_ticket_search_index(ticket.id)
|
||||
return ticket
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def adopt_solution_for_ticket(
|
||||
ticket: Ticket,
|
||||
|
||||
@@ -18,25 +18,16 @@ from apps.knowledge.embeddings import KnowledgeEmbeddingError
|
||||
from apps.knowledge.models import KnowledgeItem
|
||||
from apps.knowledge.services import retrieve_knowledge
|
||||
from apps.solutions.models import Solution
|
||||
from apps.tickets.models import Ticket, TicketActorSource, TicketSupportAnalysis
|
||||
from apps.tickets.models import Ticket, TicketActorSource, TicketStatus, TicketSupportAnalysis
|
||||
from apps.tickets.replies import ensure_customer_reply_task
|
||||
from apps.tickets.services import (
|
||||
TicketDataError,
|
||||
TicketStatusError,
|
||||
adopt_solution_for_ticket,
|
||||
get_allowed_transitions,
|
||||
from apps.tickets.services import get_allowed_transitions
|
||||
from apps.tickets.support_contract import (
|
||||
SUPPORT_OUTPUT_FIELDS,
|
||||
missing_support_output_fields,
|
||||
)
|
||||
|
||||
SUPPORT_AGENT_CODE = "support-analysis"
|
||||
RETRIEVAL_POLICY_VERSION = "published_scope_hybrid_rerank_v2"
|
||||
SUPPORT_OUTPUT_FIELDS = {
|
||||
"analysis_summary",
|
||||
"troubleshooting_steps",
|
||||
"candidate_solution_ids",
|
||||
"customer_reply_draft",
|
||||
"citations",
|
||||
"auto_resolve_recommended",
|
||||
}
|
||||
|
||||
|
||||
class SupportAnalysisError(ValueError):
|
||||
@@ -326,6 +317,10 @@ def retrieve_support_references(ticket: Ticket, *, limit: int = 8) -> tuple[str,
|
||||
}
|
||||
)
|
||||
|
||||
minimum_score = min(max(float(settings.SUPPORT_REFERENCE_MIN_SCORE), 0.0), 1.0)
|
||||
candidates = [
|
||||
item for item in candidates if float(item.get("score", 0)) >= minimum_score
|
||||
]
|
||||
candidates.sort(key=lambda item: (item["score"], item["source_version"]), reverse=True)
|
||||
references = []
|
||||
for rank, item in enumerate(candidates[:limit], start=1):
|
||||
@@ -343,8 +338,7 @@ def validate_support_agent_contract(version: AgentVersion) -> None:
|
||||
output_schema = last_step.skill_version.output_schema
|
||||
if not isinstance(output_schema, dict) or output_schema.get("type") != "object":
|
||||
raise SupportAnalysisError("支持分析 Agent 的最终输出必须是对象")
|
||||
required = set(output_schema.get("required", []))
|
||||
missing = sorted(SUPPORT_OUTPUT_FIELDS - required)
|
||||
missing = missing_support_output_fields(output_schema)
|
||||
if missing:
|
||||
raise SupportAnalysisError(
|
||||
f"支持分析 Agent 最终输出契约缺少字段:{', '.join(missing)}"
|
||||
@@ -463,10 +457,24 @@ def _automation_decision(
|
||||
and not solution.requires_production_change
|
||||
),
|
||||
"ticket_not_archived": ticket.archived_at is None,
|
||||
"status_transition_allowed": "resolved"
|
||||
in get_allowed_transitions(
|
||||
ticket.status,
|
||||
ticket.category.code if ticket.category_id else None,
|
||||
"status_transition_allowed": (
|
||||
"resolved"
|
||||
in get_allowed_transitions(
|
||||
ticket.status,
|
||||
ticket.category.code if ticket.category_id else None,
|
||||
)
|
||||
or (
|
||||
"pending_confirm"
|
||||
in get_allowed_transitions(
|
||||
ticket.status,
|
||||
ticket.category.code if ticket.category_id else None,
|
||||
)
|
||||
and "resolved"
|
||||
in get_allowed_transitions(
|
||||
TicketStatus.PENDING_CONFIRM,
|
||||
ticket.category.code if ticket.category_id else None,
|
||||
)
|
||||
)
|
||||
),
|
||||
"no_conflicting_resolution": existing_resolution is None
|
||||
or existing_resolution.linked_solution_id in {None, solution.id if solution else None},
|
||||
@@ -490,6 +498,7 @@ def _automation_decision(
|
||||
decision = {
|
||||
"policy_version": "support_auto_resolve_v1",
|
||||
"status": "eligible" if not reasons else "manual_recommendation",
|
||||
"eligible": not reasons,
|
||||
"threshold": threshold,
|
||||
"solution_id": solution.id if solution else None,
|
||||
"score": round(float(reference.get("score", 0)), 4) if reference else 0.0,
|
||||
@@ -520,32 +529,22 @@ def process_support_analysis_result(run: AgentRun) -> TicketSupportAnalysis | No
|
||||
if analysis.processed_at is not None:
|
||||
return analysis
|
||||
output = run.final_output if isinstance(run.final_output, dict) else {}
|
||||
ensure_customer_reply_task(
|
||||
reply_task = ensure_customer_reply_task(
|
||||
analysis,
|
||||
str(output.get("customer_reply_draft") or ""),
|
||||
)
|
||||
if reply_task is None:
|
||||
raise SupportAnalysisError("支持分析未生成可发送的客户回复")
|
||||
decision, solution = _automation_decision(analysis, output)
|
||||
analysis.result_mode = "trusted" if analysis.references else "manual_only"
|
||||
if decision["status"] == "eligible" and solution is not None:
|
||||
try:
|
||||
adopt_solution_for_ticket(
|
||||
analysis.ticket,
|
||||
solution,
|
||||
run.created_by,
|
||||
remark="支持分析 Agent 通过统一策略自动采纳解决方案",
|
||||
source=TicketActorSource.AGENT,
|
||||
agent_run_id=run.id,
|
||||
)
|
||||
except (TicketDataError, TicketStatusError):
|
||||
decision["status"] = "manual_recommendation"
|
||||
decision["reasons"].append("domain_service_rejected")
|
||||
else:
|
||||
decision["status"] = "auto_resolved"
|
||||
decision["applied"] = True
|
||||
analysis.applied_solution = solution
|
||||
# 方案只在技术支持执行“一次批准发送”后采纳,分析阶段不提前改变工单事实。
|
||||
decision["status"] = "waiting_approval"
|
||||
analysis.automation_decision = decision
|
||||
analysis.processed_at = timezone.now()
|
||||
analysis.save(
|
||||
update_fields=[
|
||||
"result_mode",
|
||||
"automation_decision",
|
||||
"applied_solution",
|
||||
"processed_at",
|
||||
@@ -580,7 +579,12 @@ def process_support_analysis_result(run: AgentRun) -> TicketSupportAnalysis | No
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def start_ticket_support_analysis(ticket: Ticket, *, created_by=None) -> TicketSupportAnalysis:
|
||||
def start_ticket_support_analysis(
|
||||
ticket: Ticket,
|
||||
*,
|
||||
created_by=None,
|
||||
source: str = TicketActorSource.HUMAN,
|
||||
) -> TicketSupportAnalysis:
|
||||
"""为工单创建唯一活动分析并投递绑定正式 Agent 版本的运行。"""
|
||||
|
||||
ticket = (
|
||||
@@ -680,7 +684,7 @@ def start_ticket_support_analysis(ticket: Ticket, *, created_by=None) -> TicketS
|
||||
"reference_count": len(references),
|
||||
},
|
||||
operator=created_by,
|
||||
source=TicketActorSource.HUMAN,
|
||||
source=source,
|
||||
agent_run_id=run.id,
|
||||
metadata={"retrieval_policy_version": RETRIEVAL_POLICY_VERSION},
|
||||
)
|
||||
|
||||
46
backend/apps/tickets/support_contract.py
Normal file
46
backend/apps/tickets/support_contract.py
Normal file
@@ -0,0 +1,46 @@
|
||||
SUPPORT_OUTPUT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"analysis_summary": {"type": "string"},
|
||||
"troubleshooting_steps": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 12,
|
||||
},
|
||||
"candidate_solution_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"customer_reply_draft": {"type": "string"},
|
||||
"citations": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"auto_resolve_recommended": {"type": "boolean"},
|
||||
},
|
||||
"required": [
|
||||
"analysis_summary",
|
||||
"troubleshooting_steps",
|
||||
"candidate_solution_ids",
|
||||
"customer_reply_draft",
|
||||
"citations",
|
||||
"auto_resolve_recommended",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
SUPPORT_OUTPUT_FIELDS = frozenset(SUPPORT_OUTPUT_SCHEMA["required"])
|
||||
|
||||
|
||||
def missing_support_output_fields(output_schema) -> list[str]:
|
||||
if not isinstance(output_schema, dict) or output_schema.get("type") != "object":
|
||||
return sorted(SUPPORT_OUTPUT_FIELDS)
|
||||
|
||||
required = output_schema.get("required")
|
||||
properties = output_schema.get("properties")
|
||||
required_fields = set(required) if isinstance(required, list) else set()
|
||||
property_fields = set(properties) if isinstance(properties, dict) else set()
|
||||
return sorted(
|
||||
(SUPPORT_OUTPUT_FIELDS - required_fields)
|
||||
| (SUPPORT_OUTPUT_FIELDS - property_fields)
|
||||
)
|
||||
@@ -154,14 +154,22 @@ def record_support_evaluation_result(
|
||||
if isinstance(analysis.automation_decision, dict)
|
||||
else {}
|
||||
)
|
||||
actual_auto_resolved = (
|
||||
decision.get("status") == "auto_resolved"
|
||||
and decision.get("applied") is True
|
||||
and analysis.applied_solution_id is not None
|
||||
# 一次确认流程把“分析判定可自动复用”和“人工批准后真正解决”拆成两个时点。
|
||||
# 旧字段名继续保留给既有评估数据和接口,但阶段指标按是否到达批准就绪态计算。
|
||||
actual_auto_resolved = decision.get("status") in {
|
||||
"waiting_approval",
|
||||
"auto_resolved",
|
||||
} and bool(
|
||||
decision.get("eligible")
|
||||
or (
|
||||
decision.get("applied") is True
|
||||
and analysis.applied_solution_id is not None
|
||||
)
|
||||
)
|
||||
actual_manual_fallback = decision.get("status") == "manual_recommendation"
|
||||
evaluated_solution_id = analysis.applied_solution_id or decision.get("solution_id")
|
||||
incorrect_auto_reuse = bool(
|
||||
actual_auto_resolved and analysis.applied_solution_id not in expected_ids
|
||||
actual_auto_resolved and evaluated_solution_id not in expected_ids
|
||||
)
|
||||
outcome_correct = (
|
||||
actual_auto_resolved == case.expected_auto_resolve
|
||||
@@ -190,6 +198,7 @@ def record_support_evaluation_result(
|
||||
"expected_auto_resolve": case.expected_auto_resolve,
|
||||
"expected_manual_fallback": case.expected_manual_fallback,
|
||||
"applied_solution_id": analysis.applied_solution_id,
|
||||
"evaluated_solution_id": evaluated_solution_id,
|
||||
"automation_decision": decision,
|
||||
"reference_count": len(analysis.references),
|
||||
"outcome_correct": outcome_correct,
|
||||
|
||||
@@ -14,6 +14,34 @@ def execute_ticket_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
execute_draft_analysis(draft_id, run_id)
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.advance_ticket_automation")
|
||||
def advance_ticket_automation_task(run_id: int) -> None:
|
||||
from apps.tickets.automation import advance_ticket_automation
|
||||
|
||||
advance_ticket_automation(run_id)
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.close_ticket_automation")
|
||||
def close_ticket_automation_task(run_id: int) -> bool:
|
||||
from apps.tickets.automation import close_due_ticket_automation
|
||||
|
||||
return close_due_ticket_automation(run_id)
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.close_due_ticket_automations")
|
||||
def close_due_ticket_automations_task() -> int:
|
||||
from apps.tickets.automation import close_due_ticket_automation
|
||||
from apps.tickets.models import TicketAutomationRun
|
||||
|
||||
run_ids = list(
|
||||
TicketAutomationRun.objects.filter(
|
||||
status="waiting_customer",
|
||||
next_action_at__lte=timezone.now(),
|
||||
).values_list("id", flat=True)[:500]
|
||||
)
|
||||
return sum(bool(close_due_ticket_automation(run_id)) for run_id in run_ids)
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.cleanup_expired_ticket_drafts")
|
||||
def cleanup_expired_ticket_drafts() -> int:
|
||||
queryset = TicketDraft.objects.filter(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
204
backend/apps/tickets/tests/test_ticket_automation.py
Normal file
204
backend/apps/tickets/tests/test_ticket_automation.py
Normal 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]
|
||||
Reference in New Issue
Block a user