1092 lines
39 KiB
Python
1092 lines
39 KiB
Python
import csv
|
|
from datetime import timedelta
|
|
from math import ceil
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.db.models import Q
|
|
from django.http import HttpResponse
|
|
from django.utils import timezone
|
|
from ninja import Router
|
|
from ninja.errors import HttpError
|
|
|
|
from apps.accounts.api import require_permission
|
|
from apps.agents.services import AgentValidationError
|
|
from apps.audit.models import OperationAuditLog
|
|
from apps.knowledge.distillation import (
|
|
KnowledgeDistillationError,
|
|
start_ticket_knowledge_distillation,
|
|
)
|
|
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,
|
|
IntakeEvaluationCase,
|
|
SupportEvaluationCase,
|
|
Ticket,
|
|
TicketAttachment,
|
|
TicketAutomationRun,
|
|
TicketAutomationRunStatus,
|
|
TicketCollaborator,
|
|
TicketCollaboratorRole,
|
|
TicketComment,
|
|
TicketDraft,
|
|
TicketEvent,
|
|
TicketFieldEvidence,
|
|
TicketSimilaritySuggestion,
|
|
TicketSupportAnalysis,
|
|
)
|
|
from apps.tickets.replies import (
|
|
CustomerReplyError,
|
|
confirm_customer_reply_sent,
|
|
update_customer_reply,
|
|
)
|
|
from apps.tickets.schemas import (
|
|
CustomerReplyConfirmIn,
|
|
CustomerReplyTaskOut,
|
|
CustomerReplyUpdateIn,
|
|
IntakeEvaluationRecordIn,
|
|
IntakeEvaluationResultOut,
|
|
IntakeQualityMetricsOut,
|
|
SupportEvaluationRecordIn,
|
|
SupportEvaluationResultOut,
|
|
SupportQualityMetricsOut,
|
|
TicketAttachmentIn,
|
|
TicketAttachmentOut,
|
|
TicketAutomationActionIn,
|
|
TicketAutomationApproveIn,
|
|
TicketAutomationOut,
|
|
TicketCollaboratorIn,
|
|
TicketCollaboratorOut,
|
|
TicketCommentIn,
|
|
TicketCommentOut,
|
|
TicketEventOut,
|
|
TicketFieldEvidenceOut,
|
|
TicketIn,
|
|
TicketIntakeTraceOut,
|
|
TicketKnowledgeDistillationOut,
|
|
TicketListOut,
|
|
TicketOut,
|
|
TicketSimilaritySuggestionOut,
|
|
TicketStatusIn,
|
|
TicketSupportAnalysisOut,
|
|
TicketUpdateIn,
|
|
TicketUserOut,
|
|
)
|
|
from apps.tickets.services import (
|
|
TicketDataError,
|
|
TicketStatusError,
|
|
create_ticket_from_fields,
|
|
set_ticket_archived,
|
|
transition_requires_remark,
|
|
transition_ticket,
|
|
update_ticket_fields,
|
|
)
|
|
from apps.tickets.similarity import ALGORITHM_VERSION, find_similar_tickets
|
|
from apps.tickets.support_analysis import (
|
|
SupportAnalysisError,
|
|
start_ticket_support_analysis,
|
|
)
|
|
from apps.tickets.support_evaluation import (
|
|
SupportEvaluationError,
|
|
build_support_quality_metrics,
|
|
record_support_evaluation_result,
|
|
)
|
|
|
|
router = Router(tags=["tickets"])
|
|
|
|
|
|
def next_ticket_no() -> str:
|
|
prefix = timezone.localdate().strftime("TK%Y%m%d")
|
|
count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1
|
|
return f"{prefix}{count:04d}"
|
|
|
|
|
|
def serialize_ticket_user(user):
|
|
if user is None:
|
|
return None
|
|
return {
|
|
"id": user.id,
|
|
"username": user.username,
|
|
"display_name": user.get_full_name() or user.username,
|
|
}
|
|
|
|
|
|
def serialize_ticket_category(category):
|
|
if category is None:
|
|
return None
|
|
return {
|
|
"id": category.id,
|
|
"code": category.code,
|
|
"name": category.name,
|
|
"description": category.description,
|
|
}
|
|
|
|
|
|
def serialize_ticket(ticket: Ticket) -> dict:
|
|
resolution = getattr(ticket, "resolution", None)
|
|
return {
|
|
"id": ticket.id,
|
|
"ticket_no": ticket.ticket_no,
|
|
"title": ticket.title,
|
|
"description": ticket.description,
|
|
"category": serialize_ticket_category(ticket.category),
|
|
"category_code": ticket.category.code if ticket.category_id else None,
|
|
"status": ticket.status,
|
|
"priority": ticket.priority,
|
|
"problem_performance": ticket.problem_performance,
|
|
"problem_object": ticket.problem_object,
|
|
"submitter_name": ticket.submitter_name,
|
|
"submitter_org": ticket.submitter_org,
|
|
"resolution": resolution.solution_text if resolution else "",
|
|
"resolution_id": resolution.id if resolution else None,
|
|
"resolution_type": resolution.resolution_type if resolution else None,
|
|
"resolution_source": resolution.source if resolution else None,
|
|
"resolution_root_cause": resolution.root_cause if resolution else "",
|
|
"linked_solution_id": resolution.linked_solution_id if resolution else None,
|
|
"is_resolved": resolution is not None and ticket.status in {"resolved", "closed"},
|
|
"replied_to_customer": ticket.replied_to_customer,
|
|
"impact_scope": ticket.impact_scope,
|
|
"environment": ticket.environment,
|
|
"related_system": ticket.related_system,
|
|
"product_name": ticket.product_name,
|
|
"module_name": ticket.module_name,
|
|
"tags": ticket.tags,
|
|
"attempted_actions": ticket.attempted_actions,
|
|
"error_information": ticket.error_information,
|
|
"customer_request": ticket.customer_request,
|
|
"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,
|
|
"customer_last_message_at": ticket.customer_last_message_at,
|
|
"support_first_response_at": ticket.support_first_response_at,
|
|
"support_last_reply_at": ticket.support_last_reply_at,
|
|
"resolved_at": ticket.resolved_at,
|
|
"closed_at": ticket.closed_at,
|
|
"archived_at": ticket.archived_at,
|
|
"archived_by": serialize_ticket_user(ticket.archived_by),
|
|
"created_at": ticket.created_at,
|
|
"updated_at": ticket.updated_at,
|
|
}
|
|
|
|
|
|
def serialize_ticket_event(event: TicketEvent) -> dict:
|
|
return {
|
|
"id": event.id,
|
|
"event_type": event.event_type,
|
|
"from_value": event.from_value,
|
|
"to_value": event.to_value,
|
|
"remark": event.remark,
|
|
"operator": serialize_ticket_user(event.operator),
|
|
"source": event.source,
|
|
"agent_run_id": event.agent_run_id,
|
|
"metadata": event.metadata,
|
|
"created_at": event.created_at,
|
|
}
|
|
|
|
|
|
def serialize_ticket_collaborator(collaborator: TicketCollaborator) -> dict:
|
|
return {
|
|
"id": collaborator.id,
|
|
"user": serialize_ticket_user(collaborator.user),
|
|
"role": collaborator.role,
|
|
"added_by": serialize_ticket_user(collaborator.added_by),
|
|
"created_at": collaborator.created_at,
|
|
}
|
|
|
|
|
|
def serialize_ticket_comment(comment: TicketComment) -> dict:
|
|
return {
|
|
"id": comment.id,
|
|
"content": comment.content,
|
|
"comment_type": comment.comment_type,
|
|
"visibility": comment.visibility,
|
|
"created_by": serialize_ticket_user(comment.created_by),
|
|
"created_at": comment.created_at,
|
|
}
|
|
|
|
|
|
def serialize_field_evidence(item: TicketFieldEvidence) -> dict:
|
|
return {
|
|
"id": item.id,
|
|
"field_name": item.field_name,
|
|
"value": item.value,
|
|
"source": item.source,
|
|
"confidence": float(item.confidence) if item.confidence is not None else None,
|
|
"evidence": item.evidence,
|
|
"agent_run_id": item.agent_run_id,
|
|
"is_protected": item.is_protected,
|
|
"created_by": serialize_ticket_user(item.created_by),
|
|
"created_at": item.created_at,
|
|
}
|
|
|
|
|
|
def serialize_similarity_suggestion(item: TicketSimilaritySuggestion) -> dict:
|
|
candidate = item.candidate_ticket
|
|
return {
|
|
"ticket_id": candidate.id,
|
|
"ticket_no": candidate.ticket_no,
|
|
"title": candidate.title,
|
|
"status": candidate.status,
|
|
"category_code": candidate.category.code if candidate.category_id else None,
|
|
"score": float(item.score),
|
|
"title_similarity": float(item.title_similarity),
|
|
"keyword_score": float(item.keyword_score),
|
|
"matched_excerpt": item.matched_excerpt,
|
|
"algorithm_version": item.algorithm_version,
|
|
"created_at": item.created_at,
|
|
}
|
|
|
|
|
|
def serialize_dynamic_similarity(item: dict) -> dict:
|
|
candidate = item["ticket"]
|
|
return {
|
|
"ticket_id": candidate.id,
|
|
"ticket_no": candidate.ticket_no,
|
|
"title": candidate.title,
|
|
"status": candidate.status,
|
|
"category_code": candidate.category.code if candidate.category_id else None,
|
|
"score": item["score"],
|
|
"title_similarity": item["title_similarity"],
|
|
"keyword_score": item["keyword_score"],
|
|
"matched_excerpt": item["matched_excerpt"],
|
|
"algorithm_version": ALGORITHM_VERSION,
|
|
"created_at": None,
|
|
}
|
|
|
|
|
|
def serialize_support_analysis(item: TicketSupportAnalysis) -> dict:
|
|
run = item.run
|
|
return {
|
|
"id": item.id,
|
|
"ticket_id": item.ticket_id,
|
|
"run_public_id": str(run.public_id),
|
|
"agent_version": run.agent_version.version,
|
|
"status": run.status,
|
|
"progress_stage": run.progress_stage,
|
|
"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,
|
|
"applied_solution_id": item.applied_solution_id,
|
|
"processed_at": item.processed_at,
|
|
"error_code": run.error_code,
|
|
"error_message": run.error_message,
|
|
"created_at": item.created_at,
|
|
"started_at": run.started_at,
|
|
"finished_at": run.finished_at,
|
|
}
|
|
|
|
|
|
def serialize_knowledge_distillation(item: TicketKnowledgeDistillation) -> dict:
|
|
run = item.run
|
|
return {
|
|
"id": item.id,
|
|
"ticket_id": item.ticket_id,
|
|
"run_public_id": str(run.public_id),
|
|
"agent_version": run.agent_version.version,
|
|
"status": run.status,
|
|
"progress_stage": run.progress_stage,
|
|
"progress_message": run.progress_message,
|
|
"references": item.references,
|
|
"retrieval_policy": item.retrieval_policy,
|
|
"final_output": run.final_output,
|
|
"should_distill": item.should_distill,
|
|
"reason": item.reason,
|
|
"assessment": item.assessment,
|
|
"knowledge_item_id": item.knowledge_item_id,
|
|
"troubleshooting_plan_id": item.troubleshooting_plan_id,
|
|
"solution_id": item.solution_id,
|
|
"processed_at": item.processed_at,
|
|
"error_code": run.error_code,
|
|
"error_message": run.error_message,
|
|
"created_at": item.created_at,
|
|
"started_at": run.started_at,
|
|
"finished_at": run.finished_at,
|
|
}
|
|
|
|
|
|
def serialize_customer_reply_task(item: CustomerReplyTask) -> dict:
|
|
return {
|
|
"id": item.id,
|
|
"ticket_id": item.ticket_id,
|
|
"support_analysis_id": item.support_analysis_id,
|
|
"status": item.status,
|
|
"draft_text": item.draft_text,
|
|
"final_text": item.final_text,
|
|
"revision": item.revision,
|
|
"created_by_id": item.created_by_id,
|
|
"updated_by_id": item.updated_by_id,
|
|
"approved_by_id": item.approved_by_id,
|
|
"approved_at": item.approved_at,
|
|
"sent_by_id": item.sent_by_id,
|
|
"sent_at": item.sent_at,
|
|
"created_at": item.created_at,
|
|
"updated_at": item.updated_at,
|
|
"revisions": [
|
|
{
|
|
"version": revision.version,
|
|
"content": revision.content,
|
|
"source": revision.source,
|
|
"edited_by_id": revision.edited_by_id,
|
|
"created_at": revision.created_at,
|
|
}
|
|
for revision in item.revisions.all()
|
|
],
|
|
}
|
|
|
|
|
|
def serialize_evaluation_result(result) -> dict:
|
|
return {
|
|
"id": result.id,
|
|
"case_key": result.case.key,
|
|
"case_source_kind": result.case.source_kind,
|
|
"draft_public_id": str(result.draft.public_id),
|
|
"ticket_id": result.ticket_id,
|
|
"actual_auto_created": result.actual_auto_created,
|
|
"correct_field_count": result.correct_field_count,
|
|
"evaluated_field_count": result.evaluated_field_count,
|
|
"field_accuracy": (
|
|
float(result.field_accuracy) if result.field_accuracy is not None else None
|
|
),
|
|
"passed": result.passed,
|
|
"details": result.details,
|
|
"created_at": result.created_at,
|
|
"updated_at": result.updated_at,
|
|
}
|
|
|
|
|
|
def serialize_support_evaluation_result(result) -> dict:
|
|
return {
|
|
"id": result.id,
|
|
"case_key": result.case.key,
|
|
"case_source_kind": result.case.source_kind,
|
|
"analysis_id": result.analysis_id,
|
|
"ticket_id": result.analysis.ticket_id,
|
|
"actual_candidate_solution_ids": result.actual_candidate_solution_ids,
|
|
"actual_citation_ranks": result.actual_citation_ranks,
|
|
"matched_expected_solution": result.matched_expected_solution,
|
|
"actual_auto_resolved": result.actual_auto_resolved,
|
|
"actual_manual_fallback": result.actual_manual_fallback,
|
|
"incorrect_auto_reuse": result.incorrect_auto_reuse,
|
|
"citations_valid": result.citations_valid,
|
|
"passed": result.passed,
|
|
"details": result.details,
|
|
"created_at": result.created_at,
|
|
"updated_at": result.updated_at,
|
|
}
|
|
|
|
|
|
@router.post("", response=TicketOut)
|
|
def create_ticket(request, payload: TicketIn):
|
|
require_permission(request, "ticket.create")
|
|
try:
|
|
ticket = create_ticket_from_fields(payload.dict(), request.user)
|
|
except TicketDataError as error:
|
|
raise HttpError(400, str(error)) from error
|
|
return serialize_ticket(ticket)
|
|
|
|
|
|
@router.get("", response=TicketListOut)
|
|
def list_tickets(
|
|
request,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
status: str | None = None,
|
|
priority: str | None = None,
|
|
category_code: str | None = None,
|
|
q: str | None = None,
|
|
related_system: str | None = None,
|
|
assignee_id: int | None = None,
|
|
view: str = "all",
|
|
status_group: str | None = None,
|
|
resolved_within_days: int | None = None,
|
|
include_archived: bool = False,
|
|
):
|
|
require_permission(request, "ticket.view")
|
|
queryset = Ticket.objects.select_related(
|
|
"requester",
|
|
"assignee",
|
|
"category",
|
|
"resolution",
|
|
"resolution__linked_solution",
|
|
"archived_by",
|
|
)
|
|
if not include_archived:
|
|
queryset = queryset.filter(archived_at__isnull=True)
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
if priority:
|
|
queryset = queryset.filter(priority=priority)
|
|
if category_code:
|
|
queryset = queryset.filter(category__code=category_code)
|
|
if q:
|
|
queryset = queryset.filter(
|
|
Q(ticket_no__icontains=q)
|
|
| Q(title__icontains=q)
|
|
| Q(description__icontains=q)
|
|
| Q(problem_performance__icontains=q)
|
|
| Q(problem_object__icontains=q)
|
|
| Q(submitter_name__icontains=q)
|
|
| Q(submitter_org__icontains=q)
|
|
| Q(product_name__icontains=q)
|
|
| Q(module_name__icontains=q)
|
|
| Q(error_information__icontains=q)
|
|
| Q(customer_request__icontains=q)
|
|
| Q(current_result__icontains=q)
|
|
)
|
|
if related_system:
|
|
queryset = queryset.filter(related_system__icontains=related_system)
|
|
if assignee_id:
|
|
queryset = queryset.filter(assignee_id=assignee_id)
|
|
if view == "assigned_to_me":
|
|
queryset = queryset.filter(assignee=request.user)
|
|
if view == "created_by_me":
|
|
queryset = queryset.filter(created_by=request.user)
|
|
if view == "high_priority":
|
|
queryset = queryset.filter(priority__in=["high", "urgent"])
|
|
if status_group == "active":
|
|
queryset = queryset.exclude(status__in=["resolved", "closed"])
|
|
elif status_group == "completed":
|
|
queryset = queryset.filter(status__in=["resolved", "closed"])
|
|
elif status_group:
|
|
raise HttpError(400, "Unknown status group")
|
|
if resolved_within_days is not None:
|
|
days = min(max(resolved_within_days, 1), 365)
|
|
cutoff = timezone.now() - timedelta(days=days)
|
|
queryset = queryset.filter(
|
|
Q(resolved_at__gte=cutoff) | Q(closed_at__gte=cutoff)
|
|
)
|
|
|
|
page = max(page, 1)
|
|
page_size = min(max(page_size, 1), 100)
|
|
total = queryset.count()
|
|
total_pages = ceil(total / page_size) if total else 0
|
|
start = (page - 1) * page_size
|
|
end = start + page_size
|
|
return {
|
|
"items": [serialize_ticket(ticket) for ticket in queryset[start:end]],
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"total_pages": total_pages,
|
|
}
|
|
|
|
|
|
@router.get("/export")
|
|
def export_tickets(request):
|
|
require_permission(request, "ticket.export")
|
|
response = HttpResponse(content_type="text/csv")
|
|
response["Content-Disposition"] = 'attachment; filename="tickets.csv"'
|
|
writer = csv.writer(response)
|
|
writer.writerow(["ticket_no", "title", "status", "priority", "created_at"])
|
|
for ticket in Ticket.objects.filter(archived_at__isnull=True).order_by("ticket_no"):
|
|
writer.writerow(
|
|
[
|
|
ticket.ticket_no,
|
|
ticket.title,
|
|
ticket.status,
|
|
ticket.priority,
|
|
ticket.created_at.isoformat(),
|
|
]
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=request.user if request.user.is_authenticated else None,
|
|
module="tickets",
|
|
action="export",
|
|
target_type="ticket",
|
|
result="success",
|
|
request_snapshot={},
|
|
)
|
|
return response
|
|
|
|
|
|
@router.get("/intake-quality-metrics", response=IntakeQualityMetricsOut)
|
|
def get_intake_quality_metrics(request, include_synthetic: bool = False):
|
|
require_permission(request, "ticket.view")
|
|
return build_intake_quality_metrics(include_synthetic=include_synthetic)
|
|
|
|
|
|
@router.get("/participants", response=list[TicketUserOut])
|
|
def list_ticket_participants(request):
|
|
require_permission(request, "ticket.view")
|
|
users = get_user_model().objects.filter(is_active=True).order_by("first_name", "username", "id")
|
|
return [serialize_ticket_user(user) for user in users]
|
|
|
|
|
|
@router.post("/intake-evaluations", response=IntakeEvaluationResultOut)
|
|
def create_intake_evaluation(request, payload: IntakeEvaluationRecordIn):
|
|
require_permission(request, "ticket.edit")
|
|
case = IntakeEvaluationCase.objects.filter(key=payload.case_key, enabled=True).first()
|
|
if case is None:
|
|
raise HttpError(404, "评估案例不存在或已停用")
|
|
draft = TicketDraft.objects.select_related("confirmed_ticket").filter(
|
|
public_id=payload.draft_public_id
|
|
).first()
|
|
if draft is None:
|
|
raise HttpError(404, "受理记录不存在")
|
|
result = record_evaluation_result(case, draft, reviewer=request.user)
|
|
OperationAuditLog.objects.create(
|
|
user=request.user,
|
|
module="tickets",
|
|
action="intake_quality_evaluated",
|
|
target_type="ticket_draft",
|
|
target_id=str(draft.public_id),
|
|
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
|
|
result="success",
|
|
)
|
|
return serialize_evaluation_result(result)
|
|
|
|
|
|
@router.get("/support-quality-metrics", response=SupportQualityMetricsOut)
|
|
def get_support_quality_metrics(request, include_synthetic: bool = False):
|
|
require_permission(request, "ticket.view")
|
|
require_permission(request, "knowledge.view")
|
|
require_permission(request, "solution.view")
|
|
return build_support_quality_metrics(include_synthetic=include_synthetic)
|
|
|
|
|
|
@router.post("/support-evaluations", response=SupportEvaluationResultOut)
|
|
def create_support_evaluation(request, payload: SupportEvaluationRecordIn):
|
|
require_permission(request, "ticket.edit")
|
|
require_permission(request, "knowledge.view")
|
|
require_permission(request, "solution.view")
|
|
case = SupportEvaluationCase.objects.filter(
|
|
key=payload.case_key,
|
|
enabled=True,
|
|
).first()
|
|
if case is None:
|
|
raise HttpError(404, "支持分析评估案例不存在或已停用")
|
|
analysis = TicketSupportAnalysis.objects.select_related("run", "ticket").filter(
|
|
id=payload.analysis_id
|
|
).first()
|
|
if analysis is None:
|
|
raise HttpError(404, "支持分析记录不存在")
|
|
try:
|
|
result = record_support_evaluation_result(
|
|
case,
|
|
analysis,
|
|
reviewer=request.user,
|
|
)
|
|
except SupportEvaluationError as error:
|
|
raise HttpError(400, str(error)) from error
|
|
OperationAuditLog.objects.create(
|
|
user=request.user,
|
|
module="tickets",
|
|
action="support_quality_evaluated",
|
|
target_type="ticket_support_analysis",
|
|
target_id=str(analysis.id),
|
|
request_snapshot={"case_key": case.key, "source_kind": case.source_kind},
|
|
result="success",
|
|
)
|
|
result = type(result).objects.select_related("case", "analysis__ticket").get(
|
|
id=result.id
|
|
)
|
|
return serialize_support_evaluation_result(result)
|
|
|
|
|
|
@router.get("/{ticket_id}/support-analyses", response=list[TicketSupportAnalysisOut])
|
|
def list_ticket_support_analyses(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
require_permission(request, "knowledge.view")
|
|
require_permission(request, "solution.view")
|
|
queryset = TicketSupportAnalysis.objects.select_related(
|
|
"run",
|
|
"run__agent_version",
|
|
).filter(ticket_id=ticket_id)
|
|
return [serialize_support_analysis(item) for item in queryset[:20]]
|
|
|
|
|
|
@router.post("/{ticket_id}/support-analyses", response=TicketSupportAnalysisOut)
|
|
def create_ticket_support_analysis(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
require_permission(request, "knowledge.view")
|
|
require_permission(request, "solution.view")
|
|
require_permission(request, "agent.use")
|
|
ticket = Ticket.objects.filter(id=ticket_id).first()
|
|
if ticket is None:
|
|
raise HttpError(404, "Ticket not found")
|
|
try:
|
|
analysis = start_ticket_support_analysis(ticket, created_by=request.user)
|
|
except (SupportAnalysisError, AgentValidationError, SkillValidationError) as error:
|
|
raise HttpError(400, str(error)) from error
|
|
analysis = TicketSupportAnalysis.objects.select_related(
|
|
"run",
|
|
"run__agent_version",
|
|
).get(id=analysis.id)
|
|
return serialize_support_analysis(analysis)
|
|
|
|
|
|
@router.get(
|
|
"/{ticket_id}/knowledge-distillations",
|
|
response=list[TicketKnowledgeDistillationOut],
|
|
)
|
|
def list_ticket_knowledge_distillations(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
require_permission(request, "knowledge.view")
|
|
queryset = TicketKnowledgeDistillation.objects.select_related(
|
|
"run",
|
|
"run__agent_version",
|
|
).filter(ticket_id=ticket_id)
|
|
return [serialize_knowledge_distillation(item) for item in queryset[:20]]
|
|
|
|
|
|
@router.post(
|
|
"/{ticket_id}/knowledge-distillations",
|
|
response=TicketKnowledgeDistillationOut,
|
|
)
|
|
def create_ticket_knowledge_distillation(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
require_permission(request, "knowledge.manage")
|
|
require_permission(request, "agent.use")
|
|
ticket = Ticket.objects.filter(id=ticket_id).first()
|
|
if ticket is None:
|
|
raise HttpError(404, "Ticket not found")
|
|
try:
|
|
distillation = start_ticket_knowledge_distillation(
|
|
ticket,
|
|
created_by=request.user,
|
|
)
|
|
except (
|
|
KnowledgeDistillationError,
|
|
AgentValidationError,
|
|
SkillValidationError,
|
|
) as error:
|
|
raise HttpError(400, str(error)) from error
|
|
distillation = TicketKnowledgeDistillation.objects.select_related(
|
|
"run",
|
|
"run__agent_version",
|
|
).get(id=distillation.id)
|
|
return serialize_knowledge_distillation(distillation)
|
|
|
|
|
|
@router.get("/{ticket_id}/reply-tasks", response=list[CustomerReplyTaskOut])
|
|
def list_customer_reply_tasks(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
queryset = (
|
|
CustomerReplyTask.objects.filter(ticket_id=ticket_id)
|
|
.select_related("support_analysis")
|
|
.prefetch_related("revisions")
|
|
)
|
|
return [serialize_customer_reply_task(item) for item in queryset[:20]]
|
|
|
|
|
|
@router.patch(
|
|
"/{ticket_id}/reply-tasks/{reply_task_id}",
|
|
response=CustomerReplyTaskOut,
|
|
)
|
|
def edit_customer_reply_task(
|
|
request,
|
|
ticket_id: int,
|
|
reply_task_id: int,
|
|
payload: CustomerReplyUpdateIn,
|
|
):
|
|
require_permission(request, "ticket.edit")
|
|
task = CustomerReplyTask.objects.filter(
|
|
id=reply_task_id,
|
|
ticket_id=ticket_id,
|
|
).first()
|
|
if task is None:
|
|
raise HttpError(404, "客户回复任务不存在")
|
|
try:
|
|
task = update_customer_reply(
|
|
task,
|
|
payload.final_text,
|
|
expected_revision=payload.expected_revision,
|
|
actor=request.user,
|
|
)
|
|
except CustomerReplyError as error:
|
|
raise HttpError(409, str(error)) from error
|
|
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
|
|
return serialize_customer_reply_task(task)
|
|
|
|
|
|
@router.post(
|
|
"/{ticket_id}/reply-tasks/{reply_task_id}/confirm-sent",
|
|
response=CustomerReplyTaskOut,
|
|
)
|
|
def confirm_customer_reply_task_sent(
|
|
request,
|
|
ticket_id: int,
|
|
reply_task_id: int,
|
|
payload: CustomerReplyConfirmIn,
|
|
):
|
|
require_permission(request, "ticket.reply")
|
|
task = CustomerReplyTask.objects.filter(
|
|
id=reply_task_id,
|
|
ticket_id=ticket_id,
|
|
).first()
|
|
if task is None:
|
|
raise HttpError(404, "客户回复任务不存在")
|
|
try:
|
|
task = confirm_customer_reply_sent(
|
|
task,
|
|
expected_revision=payload.expected_revision,
|
|
actor=request.user,
|
|
)
|
|
except CustomerReplyError as error:
|
|
raise HttpError(409, str(error)) from error
|
|
task = CustomerReplyTask.objects.prefetch_related("revisions").get(id=task.id)
|
|
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")
|
|
return serialize_ticket(
|
|
Ticket.objects.select_related(
|
|
"requester",
|
|
"assignee",
|
|
"category",
|
|
"resolution",
|
|
"resolution__linked_solution",
|
|
"archived_by",
|
|
).get(id=ticket_id)
|
|
)
|
|
|
|
|
|
@router.get("/{ticket_id}/similar", response=list[TicketSimilaritySuggestionOut])
|
|
def list_similar_tickets(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
ticket = Ticket.objects.select_related("category").filter(id=ticket_id).first()
|
|
if ticket is None:
|
|
raise HttpError(404, "Ticket not found")
|
|
suggestions = list(
|
|
TicketSimilaritySuggestion.objects.filter(
|
|
source_ticket=ticket,
|
|
algorithm_version=ALGORITHM_VERSION,
|
|
).select_related("candidate_ticket__category")
|
|
)
|
|
if suggestions:
|
|
return [serialize_similarity_suggestion(item) for item in suggestions]
|
|
return [serialize_dynamic_similarity(item) for item in find_similar_tickets(ticket)]
|
|
|
|
|
|
@router.get("/{ticket_id}/events", response=list[TicketEventOut])
|
|
def list_ticket_events(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
events = TicketEvent.objects.select_related("operator").filter(ticket_id=ticket_id)
|
|
return [serialize_ticket_event(event) for event in events]
|
|
|
|
|
|
@router.patch("/{ticket_id}", response=TicketOut)
|
|
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
|
changes = payload.dict(exclude_unset=True)
|
|
if "assignee_id" in changes:
|
|
require_permission(request, "ticket.assign")
|
|
if set(changes) - {"assignee_id"}:
|
|
require_permission(request, "ticket.edit")
|
|
if not changes:
|
|
require_permission(request, "ticket.edit")
|
|
ticket = Ticket.objects.get(id=ticket_id)
|
|
try:
|
|
updated, _ = update_ticket_fields(ticket, changes, request.user)
|
|
except TicketDataError as error:
|
|
raise HttpError(400, str(error)) from error
|
|
return serialize_ticket(
|
|
Ticket.objects.select_related(
|
|
"requester", "assignee", "category", "resolution", "archived_by"
|
|
).get(id=updated.id)
|
|
)
|
|
|
|
|
|
@router.post("/{ticket_id}/status", response=TicketOut)
|
|
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
|
|
if payload.status == "closed":
|
|
require_permission(request, "ticket.close")
|
|
elif payload.status == "resolved":
|
|
require_permission(request, "ticket.resolve")
|
|
else:
|
|
require_permission(request, "ticket.edit")
|
|
ticket = Ticket.objects.select_related("category").get(id=ticket_id)
|
|
category_code = ticket.category.code if ticket.category_id else None
|
|
has_resolution = bool(payload.resolution.strip())
|
|
if transition_requires_remark(
|
|
ticket.status,
|
|
payload.status,
|
|
category_code,
|
|
) and not payload.remark.strip():
|
|
raise HttpError(400, "Transition remark is required")
|
|
if payload.status == "resolved" and not (payload.remark.strip() or has_resolution):
|
|
raise HttpError(400, "Resolution or transition remark is required")
|
|
try:
|
|
updated = transition_ticket(
|
|
ticket,
|
|
payload.status,
|
|
payload.remark,
|
|
request.user if request.user.is_authenticated else None,
|
|
resolution=payload.resolution.strip(),
|
|
replied_to_customer=payload.replied_to_customer,
|
|
)
|
|
except (TicketDataError, TicketStatusError) as error:
|
|
raise HttpError(400, str(error)) from error
|
|
return serialize_ticket(
|
|
Ticket.objects.select_related(
|
|
"requester", "assignee", "category", "resolution", "archived_by"
|
|
).get(id=updated.id)
|
|
)
|
|
|
|
|
|
@router.post("/{ticket_id}/archive", response=TicketOut)
|
|
def archive_ticket(request, ticket_id: int):
|
|
require_permission(request, "ticket.archive")
|
|
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), True, request.user)
|
|
return serialize_ticket(ticket)
|
|
|
|
|
|
@router.post("/{ticket_id}/restore", response=TicketOut)
|
|
def restore_ticket(request, ticket_id: int):
|
|
require_permission(request, "ticket.archive")
|
|
ticket = set_ticket_archived(Ticket.objects.get(id=ticket_id), False, request.user)
|
|
return serialize_ticket(ticket)
|
|
|
|
|
|
@router.get("/{ticket_id}/collaborators", response=list[TicketCollaboratorOut])
|
|
def list_ticket_collaborators(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
queryset = TicketCollaborator.objects.select_related("user", "added_by").filter(
|
|
ticket_id=ticket_id
|
|
)
|
|
return [serialize_ticket_collaborator(item) for item in queryset]
|
|
|
|
|
|
@router.post("/{ticket_id}/collaborators", response=TicketCollaboratorOut)
|
|
def add_ticket_collaborator(request, ticket_id: int, payload: TicketCollaboratorIn):
|
|
require_permission(request, "ticket.assign")
|
|
if payload.role not in TicketCollaboratorRole.values:
|
|
raise HttpError(400, "Unknown collaborator role")
|
|
user = get_user_model().objects.filter(id=payload.user_id).first()
|
|
if user is None:
|
|
raise HttpError(400, "User not found")
|
|
ticket = Ticket.objects.get(id=ticket_id)
|
|
collaborator, created = TicketCollaborator.objects.get_or_create(
|
|
ticket=ticket,
|
|
user=user,
|
|
role=payload.role,
|
|
defaults={"added_by": request.user},
|
|
)
|
|
if created:
|
|
TicketEvent.objects.create(
|
|
ticket=ticket,
|
|
event_type="collaborator_added",
|
|
to_value={"user_id": user.id, "role": payload.role},
|
|
operator=request.user,
|
|
)
|
|
return serialize_ticket_collaborator(collaborator)
|
|
|
|
|
|
@router.delete("/{ticket_id}/collaborators/{collaborator_id}")
|
|
def remove_ticket_collaborator(request, ticket_id: int, collaborator_id: int):
|
|
require_permission(request, "ticket.assign")
|
|
collaborator = TicketCollaborator.objects.filter(
|
|
id=collaborator_id,
|
|
ticket_id=ticket_id,
|
|
).first()
|
|
if collaborator is None:
|
|
raise HttpError(404, "Collaborator not found")
|
|
event_value = {"user_id": collaborator.user_id, "role": collaborator.role}
|
|
collaborator.delete()
|
|
TicketEvent.objects.create(
|
|
ticket_id=ticket_id,
|
|
event_type="collaborator_removed",
|
|
from_value=event_value,
|
|
operator=request.user,
|
|
)
|
|
return {"success": True}
|
|
|
|
|
|
@router.get("/{ticket_id}/field-evidence", response=list[TicketFieldEvidenceOut])
|
|
def list_ticket_field_evidence(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
queryset = TicketFieldEvidence.objects.select_related("created_by").filter(ticket_id=ticket_id)
|
|
return [serialize_field_evidence(item) for item in queryset]
|
|
|
|
|
|
@router.get("/{ticket_id}/intake-trace", response=TicketIntakeTraceOut)
|
|
def get_ticket_intake_trace(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
ticket = Ticket.objects.filter(id=ticket_id).first()
|
|
if ticket is None:
|
|
raise HttpError(404, "Ticket not found")
|
|
try:
|
|
draft = ticket.source_draft
|
|
except TicketDraft.DoesNotExist:
|
|
return {"available": False}
|
|
from apps.tickets.draft_services import serialize_timeline_item
|
|
|
|
timeline_items = list(draft.timeline_items.all())
|
|
return {
|
|
"available": True,
|
|
"public_id": str(draft.public_id),
|
|
"status": draft.status,
|
|
"source_text": draft.source_text,
|
|
"problem_valid": draft.problem_valid,
|
|
"invalid_reason": draft.invalid_reason,
|
|
"missing_fields": draft.missing_fields,
|
|
"message_timeline": (
|
|
[serialize_timeline_item(item) for item in timeline_items]
|
|
if timeline_items
|
|
else draft.message_timeline
|
|
),
|
|
"agent_run_public_id": str(draft.latest_agent_run.public_id)
|
|
if draft.latest_agent_run_id
|
|
else None,
|
|
}
|
|
|
|
|
|
@router.get("/{ticket_id}/comments", response=list[TicketCommentOut])
|
|
def list_ticket_comments(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
comments = TicketComment.objects.select_related("created_by").filter(ticket_id=ticket_id)
|
|
return [serialize_ticket_comment(comment) for comment in comments]
|
|
|
|
|
|
@router.post("/{ticket_id}/comments", response=TicketCommentOut)
|
|
def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
|
|
require_permission(request, "ticket.edit")
|
|
content = payload.content.strip()
|
|
if not content:
|
|
raise HttpError(400, "Comment content is required")
|
|
if len(content) > 10000:
|
|
raise HttpError(400, "Comment content is too long")
|
|
allowed_comment_types = {"internal_note", "user_reply"}
|
|
allowed_visibilities = {"internal", "public"}
|
|
if payload.comment_type not in allowed_comment_types:
|
|
raise HttpError(400, "Unknown comment type")
|
|
if payload.visibility not in allowed_visibilities:
|
|
raise HttpError(400, "Unknown comment visibility")
|
|
expected_visibility = (
|
|
"public" if payload.comment_type == "user_reply" else "internal"
|
|
)
|
|
if payload.visibility != expected_visibility:
|
|
raise HttpError(400, "Comment type and visibility do not match")
|
|
ticket = Ticket.objects.get(id=ticket_id)
|
|
comment = TicketComment.objects.create(
|
|
ticket=ticket,
|
|
content=content,
|
|
comment_type=payload.comment_type,
|
|
visibility=payload.visibility,
|
|
created_by=request.user if request.user.is_authenticated else None,
|
|
)
|
|
TicketEvent.objects.create(
|
|
ticket=ticket,
|
|
event_type="commented",
|
|
to_value={"comment_id": comment.id, "comment_type": comment.comment_type},
|
|
operator=request.user if request.user.is_authenticated else None,
|
|
)
|
|
return serialize_ticket_comment(comment)
|
|
|
|
|
|
@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut)
|
|
def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
|
|
require_permission(request, "ticket.edit")
|
|
ticket = Ticket.objects.get(id=ticket_id)
|
|
attachment = TicketAttachment.objects.create(ticket=ticket, **payload.dict())
|
|
TicketEvent.objects.create(
|
|
ticket=ticket,
|
|
event_type="attachment_added",
|
|
to_value={"attachment_id": attachment.id, "file_name": attachment.file_name},
|
|
operator=request.user if request.user.is_authenticated else None,
|
|
)
|
|
return attachment
|
|
|
|
|
|
@router.get("/{ticket_id}/attachments", response=list[TicketAttachmentOut])
|
|
def list_ticket_attachments(request, ticket_id: int):
|
|
require_permission(request, "ticket.view")
|
|
return TicketAttachment.objects.filter(ticket_id=ticket_id)
|