feat: consolidate ticket domain baseline

This commit is contained in:
hailin
2026-07-27 18:57:51 +08:00
parent 5015e4407d
commit 96452934a3
25 changed files with 4236 additions and 218 deletions

View File

@@ -10,26 +10,82 @@ 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.tickets.models import Ticket, TicketAttachment, TicketCategory, TicketComment, TicketEvent
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.evaluation import build_intake_quality_metrics, record_evaluation_result
from apps.tickets.models import (
CustomerReplyTask,
IntakeEvaluationCase,
SupportEvaluationCase,
Ticket,
TicketAttachment,
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,
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"])
@@ -63,6 +119,7 @@ def serialize_ticket_category(category):
def serialize_ticket(ticket: Ticket) -> dict:
resolution = getattr(ticket, "resolution", None)
return {
"id": ticket.id,
"ticket_no": ticket.ticket_no,
@@ -76,17 +133,36 @@ def serialize_ticket(ticket: Ticket) -> dict:
"problem_object": ticket.problem_object,
"submitter_name": ticket.submitter_name,
"submitter_org": ticket.submitter_org,
"ai_solution": ticket.ai_solution,
"resolution": ticket.resolution_summary,
"is_resolved": ticket.is_resolved,
"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,
"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,
}
@@ -100,10 +176,207 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
"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,
"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")
@@ -128,9 +401,19 @@ def list_tickets(
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").all()
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:
@@ -146,6 +429,11 @@ def list_tickets(
| 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)
@@ -192,7 +480,7 @@ def export_tickets(request):
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.all().order_by("ticket_no"):
for ticket in Ticket.objects.filter(archived_at__isnull=True).order_by("ticket_no"):
writer.writerow(
[
ticket.ticket_no,
@@ -213,14 +501,267 @@ def export_tickets(request):
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}", response=TicketOut)
def get_ticket(request, ticket_id: int):
require_permission(request, "ticket.view")
return serialize_ticket(
Ticket.objects.select_related("requester", "assignee", "category").get(id=ticket_id)
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")
@@ -230,52 +771,33 @@ def list_ticket_events(request, ticket_id: int):
@router.patch("/{ticket_id}", response=TicketOut)
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
require_permission(request, "ticket.edit")
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)
before = {
"title": ticket.title,
"category_code": ticket.category.code if ticket.category_id else None,
"priority": ticket.priority,
"problem_performance": ticket.problem_performance,
"problem_object": ticket.problem_object,
"submitter_name": ticket.submitter_name,
"submitter_org": ticket.submitter_org,
"resolution": ticket.resolution_summary,
"is_resolved": ticket.is_resolved,
"replied_to_customer": ticket.replied_to_customer,
"impact_scope": ticket.impact_scope,
"environment": ticket.environment,
"related_system": ticket.related_system,
}
for field, value in payload.dict(exclude_unset=True).items():
if field == "assignee_id":
user = get_user_model().objects.filter(id=value).first() if value else None
ticket.assignee = user
elif field == "category_code":
category = (
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
)
if value and category is None:
raise HttpError(400, "Unknown ticket category")
ticket.category = category
elif field == "resolution":
ticket.resolution_summary = value or ""
else:
setattr(ticket, field, value)
ticket.save()
TicketEvent.objects.create(
ticket=ticket,
event_type="updated",
from_value=before,
to_value=payload.dict(exclude_unset=True),
operator=request.user if request.user.is_authenticated else None,
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)
)
return serialize_ticket(ticket)
@router.post("/{ticket_id}/status", response=TicketOut)
def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
require_permission(request, "ticket.edit")
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())
@@ -294,21 +816,154 @@ def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn):
payload.remark,
request.user if request.user.is_authenticated else None,
resolution=payload.resolution.strip(),
is_resolved=payload.is_resolved,
replied_to_customer=payload.replied_to_customer,
)
except TicketStatusError as error:
except (TicketDataError, TicketStatusError) as error:
raise HttpError(400, str(error)) from error
return serialize_ticket(updated)
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=payload.content,
content=content,
comment_type=payload.comment_type,
visibility=payload.visibility,
created_by=request.user if request.user.is_authenticated else None,
@@ -319,7 +974,7 @@ def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn):
to_value={"comment_id": comment.id, "comment_type": comment.comment_type},
operator=request.user if request.user.is_authenticated else None,
)
return comment
return serialize_ticket_comment(comment)
@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut)
@@ -334,3 +989,9 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
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)

View File

@@ -0,0 +1,142 @@
# Generated by Django 5.2.15 on 2026-07-25 03:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0004_ticketdraft_ticketdraftimage_ticketdraftmessage_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TicketCollaborator',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('collaborator', 'Collaborator'), ('watcher', 'Watcher')], default='collaborator', max_length=24)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='TicketFieldEvidence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field_name', models.CharField(max_length=80)),
('value', models.JSONField(blank=True, null=True)),
('source', models.CharField(choices=[('human', 'Human'), ('agent', 'Agent'), ('channel', 'Channel'), ('system', 'System')], default='human', max_length=24)),
('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True)),
('evidence', models.JSONField(blank=True, default=dict)),
('agent_run_id', models.BigIntegerField(blank=True, null=True)),
('is_protected', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['field_name', '-created_at'],
},
),
migrations.RemoveField(
model_name='ticket',
name='ai_solution',
),
migrations.RemoveField(
model_name='ticket',
name='is_resolved',
),
migrations.RemoveField(
model_name='ticket',
name='resolution_summary',
),
migrations.AddField(
model_name='ticket',
name='archived_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='ticket',
name='archived_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_tickets', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticket',
name='customer_last_message_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='ticket',
name='problem_started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='ticket',
name='support_first_response_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='ticket',
name='support_last_reply_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='ticketevent',
name='agent_run_id',
field=models.BigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='ticketevent',
name='metadata',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='ticketevent',
name='source',
field=models.CharField(choices=[('human', 'Human'), ('agent', 'Agent'), ('channel', 'Channel'), ('system', 'System')], default='human', max_length=24),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['archived_at'], name='tickets_tic_archive_f5fa74_idx'),
),
migrations.AddField(
model_name='ticketcollaborator',
name='added_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_ticket_collaborators', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticketcollaborator',
name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collaborators', to='tickets.ticket'),
),
migrations.AddField(
model_name='ticketcollaborator',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_collaborations', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticketfieldevidence',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_field_evidence', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='ticketfieldevidence',
name='ticket',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_evidence', to='tickets.ticket'),
),
migrations.AddConstraint(
model_name='ticketcollaborator',
constraint=models.UniqueConstraint(fields=('ticket', 'user', 'role'), name='unique_ticket_user_collaborator_role'),
),
migrations.AddIndex(
model_name='ticketfieldevidence',
index=models.Index(fields=['ticket', 'field_name', 'created_at'], name='tickets_tic_ticket__e9538c_idx'),
),
migrations.AddIndex(
model_name='ticketfieldevidence',
index=models.Index(fields=['source', 'agent_run_id'], name='tickets_tic_source_c062b8_idx'),
),
]

View File

@@ -26,6 +26,18 @@ class TicketPriority(models.TextChoices):
URGENT = "urgent", "Urgent"
class TicketActorSource(models.TextChoices):
HUMAN = "human", "Human"
AGENT = "agent", "Agent"
CHANNEL = "channel", "Channel"
SYSTEM = "system", "System"
class TicketCollaboratorRole(models.TextChoices):
COLLABORATOR = "collaborator", "Collaborator"
WATCHER = "watcher", "Watcher"
class TicketCategory(models.Model):
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL)
name = models.CharField(max_length=120)
@@ -60,13 +72,21 @@ class Ticket(models.Model):
problem_object = models.CharField(max_length=160, blank=True)
submitter_name = models.CharField(max_length=80, blank=True)
submitter_org = models.CharField(max_length=160, blank=True)
ai_solution = models.TextField(blank=True)
resolution_summary = models.TextField(blank=True)
is_resolved = models.BooleanField(default=False)
replied_to_customer = models.BooleanField(default=False)
impact_scope = models.CharField(max_length=80, blank=True)
environment = models.CharField(max_length=80, blank=True)
related_system = models.CharField(max_length=120, blank=True)
product_name = models.CharField(max_length=120, blank=True)
module_name = models.CharField(max_length=120, blank=True)
tags = models.JSONField(default=list, blank=True)
attempted_actions = models.TextField(blank=True)
error_information = models.TextField(blank=True)
customer_request = models.TextField(blank=True)
current_result = models.TextField(blank=True)
problem_started_at = models.DateTimeField(null=True, blank=True)
customer_last_message_at = models.DateTimeField(null=True, blank=True)
support_first_response_at = models.DateTimeField(null=True, blank=True)
support_last_reply_at = models.DateTimeField(null=True, blank=True)
requester = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
@@ -92,6 +112,14 @@ class Ticket(models.Model):
updated_at = models.DateTimeField(auto_now=True)
resolved_at = models.DateTimeField(null=True, blank=True)
closed_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
archived_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="archived_tickets",
)
class Meta:
ordering = ["-created_at"]
@@ -99,12 +127,107 @@ class Ticket(models.Model):
models.Index(fields=["ticket_no"]),
models.Index(fields=["status", "priority"]),
models.Index(fields=["created_at"]),
models.Index(fields=["archived_at"]),
]
def __str__(self) -> str:
return f"{self.ticket_no} {self.title}"
class TicketCollaborator(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="collaborators")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="ticket_collaborations",
)
role = models.CharField(
max_length=24,
choices=TicketCollaboratorRole.choices,
default=TicketCollaboratorRole.COLLABORATOR,
)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="added_ticket_collaborators",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
constraints = [
models.UniqueConstraint(
fields=["ticket", "user", "role"],
name="unique_ticket_user_collaborator_role",
)
]
class TicketFieldEvidence(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="field_evidence")
field_name = models.CharField(max_length=80)
value = models.JSONField(null=True, blank=True)
source = models.CharField(
max_length=24,
choices=TicketActorSource.choices,
default=TicketActorSource.HUMAN,
)
confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True)
evidence = models.JSONField(default=dict, blank=True)
agent_run_id = models.BigIntegerField(null=True, blank=True)
is_protected = models.BooleanField(default=False)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ticket_field_evidence",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["field_name", "-created_at"]
indexes = [
models.Index(fields=["ticket", "field_name", "created_at"]),
models.Index(fields=["source", "agent_run_id"]),
]
class TicketSimilaritySuggestion(models.Model):
source_ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="similarity_suggestions",
)
candidate_ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="suggested_for_tickets",
)
score = models.DecimalField(max_digits=6, decimal_places=5)
title_similarity = models.DecimalField(max_digits=6, decimal_places=5, default=0)
keyword_score = models.DecimalField(max_digits=6, decimal_places=5, default=0)
matched_excerpt = models.TextField(blank=True)
algorithm_version = models.CharField(max_length=40, default="fts_trigram_v2_context")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-score", "candidate_ticket_id"]
constraints = [
models.UniqueConstraint(
fields=["source_ticket", "candidate_ticket", "algorithm_version"],
name="unique_ticket_similarity_suggestion",
),
models.CheckConstraint(
condition=~models.Q(source_ticket=models.F("candidate_ticket")),
name="ticket_similarity_not_self",
),
]
indexes = [models.Index(fields=["source_ticket", "algorithm_version", "score"])]
class TicketComment(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments")
content = models.TextField()
@@ -129,6 +252,11 @@ class TicketAttachment(models.Model):
mime_type = models.CharField(max_length=120, blank=True)
file_size = models.PositiveBigIntegerField(default=0)
storage_path = models.CharField(max_length=500)
sha256 = models.CharField(max_length=64, blank=True)
scan_status = models.CharField(max_length=24, default="pending")
scan_result = models.JSONField(default=dict, blank=True)
parse_status = models.CharField(max_length=24, default="pending")
parse_error = models.TextField(blank=True)
ocr_text = models.TextField(blank=True)
parsed_text = models.TextField(blank=True)
created_by = models.ForeignKey(
@@ -147,11 +275,15 @@ class TicketDraft(models.Model):
on_delete=models.CASCADE,
related_name="ticket_drafts",
)
status = models.CharField(max_length=24, default="editing")
status = models.CharField(max_length=24, default="collecting")
revision = models.PositiveIntegerField(default=1)
source_text = models.TextField(blank=True)
problem_valid = models.BooleanField(null=True, blank=True)
invalid_reason = models.TextField(blank=True)
current_fields = models.JSONField(default=dict)
last_ai_fields = models.JSONField(default=dict)
field_evidence = models.JSONField(default=dict)
message_timeline = models.JSONField(default=list)
missing_fields = models.JSONField(default=list)
questions = models.JSONField(default=list)
guided_rounds = models.PositiveSmallIntegerField(default=0)
@@ -191,7 +323,6 @@ class TicketDraft(models.Model):
models.Index(fields=["status", "expires_at"]),
]
class TicketDraftMessage(models.Model):
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages")
role = models.CharField(max_length=24)
@@ -203,21 +334,75 @@ class TicketDraftMessage(models.Model):
ordering = ["created_at"]
class TicketDraftImage(models.Model):
class TicketMessageTimelineItem(models.Model):
draft = models.ForeignKey(
TicketDraft,
on_delete=models.CASCADE,
related_name="timeline_items",
)
ticket = models.ForeignKey(
Ticket,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="message_timeline_items",
)
source_material = models.ForeignKey(
"TicketDraftMaterial",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="message_timeline_items",
)
sequence = models.PositiveIntegerField()
speaker = models.CharField(max_length=120, blank=True)
role = models.CharField(max_length=24, default="unknown")
content = models.TextField()
occurred_at = models.DateTimeField(null=True, blank=True)
occurred_at_raw = models.CharField(max_length=120, blank=True)
timezone = models.CharField(max_length=80, blank=True)
source_ref = models.CharField(max_length=160, blank=True)
confidence = models.DecimalField(max_digits=5, decimal_places=4, null=True, blank=True)
conflict = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["sequence"]
constraints = [
models.UniqueConstraint(
fields=["draft", "sequence"],
name="unique_ticket_draft_timeline_sequence",
)
]
indexes = [
models.Index(fields=["ticket", "sequence"]),
models.Index(fields=["draft", "sequence"]),
]
class TicketDraftMaterial(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="images")
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="materials")
file_name = models.CharField(max_length=255)
material_type = models.CharField(max_length=24, default="image")
extension = models.CharField(max_length=16, blank=True)
claimed_mime_type = models.CharField(max_length=120, blank=True)
mime_type = models.CharField(max_length=120)
file_size = models.PositiveBigIntegerField()
sha256 = models.CharField(max_length=64)
storage_path = models.CharField(max_length=500)
status = models.CharField(max_length=24, default="temporary")
scan_status = models.CharField(max_length=24, default="pending")
scan_result = models.JSONField(default=dict, blank=True)
parse_status = models.CharField(max_length=24, default="pending")
parse_error = models.TextField(blank=True)
parsed_text = models.TextField(blank=True)
confirmed_attachment = models.OneToOneField(
TicketAttachment,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="source_draft_image",
related_name="source_draft_material",
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -225,6 +410,265 @@ class TicketDraftImage(models.Model):
ordering = ["created_at"]
class IntakeEvaluationCase(models.Model):
key = models.SlugField(max_length=120, unique=True)
name = models.CharField(max_length=200)
source_kind = models.CharField(max_length=32, default="synthetic")
valid_input = models.BooleanField(default=True)
input_manifest = models.JSONField(default=dict)
expected_fields = models.JSONField(default=dict)
expected_timeline = models.JSONField(default=list)
expected_auto_created = models.BooleanField(default=True)
expected_similar_ticket = models.BooleanField(default=False)
notes = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["key"]
indexes = [models.Index(fields=["source_kind", "enabled"])]
class IntakeEvaluationResult(models.Model):
case = models.ForeignKey(
IntakeEvaluationCase,
on_delete=models.PROTECT,
related_name="results",
)
draft = models.ForeignKey(
TicketDraft,
on_delete=models.CASCADE,
related_name="evaluation_results",
)
ticket = models.ForeignKey(
Ticket,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="intake_evaluation_results",
)
actual_fields = models.JSONField(default=dict)
actual_auto_created = models.BooleanField(default=False)
correct_field_count = models.PositiveIntegerField(default=0)
evaluated_field_count = models.PositiveIntegerField(default=0)
field_accuracy = models.DecimalField(max_digits=6, decimal_places=5, null=True, blank=True)
passed = models.BooleanField(default=False)
details = models.JSONField(default=dict, blank=True)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="reviewed_intake_evaluations",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["case", "draft"],
name="unique_intake_case_draft_result",
)
]
indexes = [models.Index(fields=["case", "updated_at"])]
class SupportEvaluationCase(models.Model):
key = models.SlugField(max_length=120, unique=True)
name = models.CharField(max_length=200)
source_kind = models.CharField(max_length=32, default="synthetic")
input_manifest = models.JSONField(default=dict)
expected_solution_ids = models.JSONField(default=list)
expected_auto_resolve = models.BooleanField(default=False)
expected_manual_fallback = models.BooleanField(default=True)
notes = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["key"]
indexes = [models.Index(fields=["source_kind", "enabled"])]
class SupportEvaluationResult(models.Model):
case = models.ForeignKey(
SupportEvaluationCase,
on_delete=models.PROTECT,
related_name="results",
)
analysis = models.ForeignKey(
"tickets.TicketSupportAnalysis",
on_delete=models.CASCADE,
related_name="evaluation_results",
)
actual_candidate_solution_ids = models.JSONField(default=list)
actual_citation_ranks = models.JSONField(default=list)
matched_expected_solution = models.BooleanField(default=False)
actual_auto_resolved = models.BooleanField(default=False)
actual_manual_fallback = models.BooleanField(default=False)
incorrect_auto_reuse = models.BooleanField(default=False)
citations_valid = models.BooleanField(default=False)
passed = models.BooleanField(default=False)
details = models.JSONField(default=dict, blank=True)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="reviewed_support_evaluations",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["case", "analysis"],
name="unique_support_case_analysis_result",
)
]
indexes = [models.Index(fields=["case", "updated_at"])]
class TicketSupportAnalysis(models.Model):
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="support_analyses",
)
run = models.OneToOneField(
"agents.AgentRun",
on_delete=models.PROTECT,
related_name="ticket_support_analysis",
)
query = models.TextField()
references = models.JSONField(default=list)
retrieval_policy = models.JSONField(default=dict)
automation_decision = models.JSONField(default=dict, blank=True)
applied_solution = models.ForeignKey(
"solutions.Solution",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="applied_support_analyses",
)
processed_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_ticket_support_analyses",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["ticket", "created_at"])]
class CustomerReplyTask(models.Model):
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="customer_reply_tasks",
)
support_analysis = models.ForeignKey(
TicketSupportAnalysis,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="customer_reply_tasks",
)
status = models.CharField(max_length=24, default="draft")
draft_text = models.TextField()
final_text = models.TextField()
revision = models.PositiveIntegerField(default=1)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_customer_reply_tasks",
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_customer_reply_tasks",
)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_customer_reply_tasks",
)
approved_at = models.DateTimeField(null=True, blank=True)
sent_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="sent_customer_reply_tasks",
)
sent_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=["draft", "approved"]),
name="unique_active_customer_reply_task",
)
]
indexes = [
models.Index(fields=["ticket", "status", "created_at"]),
models.Index(fields=["status", "updated_at"]),
]
class CustomerReplyRevision(models.Model):
task = models.ForeignKey(
CustomerReplyTask,
on_delete=models.CASCADE,
related_name="revisions",
)
version = models.PositiveIntegerField()
content = models.TextField()
source = models.CharField(max_length=24, default=TicketActorSource.HUMAN)
edited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="customer_reply_revisions",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["version"]
constraints = [
models.UniqueConstraint(
fields=["task", "version"],
name="unique_customer_reply_revision",
)
]
# Kept temporarily for imports owned by stage 2.1 integrations.
TicketDraftImage = TicketDraftMaterial
class TicketEvent(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=80)
@@ -236,6 +680,13 @@ class TicketEvent(models.Model):
blank=True,
on_delete=models.SET_NULL,
)
source = models.CharField(
max_length=24,
choices=TicketActorSource.choices,
default=TicketActorSource.HUMAN,
)
agent_run_id = models.BigIntegerField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
remark = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,4 +1,5 @@
from datetime import datetime
from typing import Any
from ninja import Schema
@@ -15,7 +16,18 @@ class TicketIn(Schema):
impact_scope: str = ""
environment: str = ""
related_system: str = ""
product_name: str = ""
module_name: str = ""
tags: list[str] = []
attempted_actions: str = ""
error_information: str = ""
customer_request: str = ""
current_result: str = ""
assignee_id: int | None = None
problem_started_at: datetime | None = None
customer_last_message_at: datetime | None = None
support_first_response_at: datetime | None = None
support_last_reply_at: datetime | None = None
class TicketUpdateIn(Schema):
@@ -27,20 +39,27 @@ class TicketUpdateIn(Schema):
problem_object: str | None = None
submitter_name: str | None = None
submitter_org: str | None = None
resolution: str | None = None
is_resolved: bool | None = None
replied_to_customer: bool | None = None
impact_scope: str | None = None
environment: str | None = None
related_system: str | None = None
product_name: str | None = None
module_name: str | None = None
tags: list[str] | None = None
attempted_actions: str | None = None
error_information: str | None = None
customer_request: str | None = None
current_result: str | None = None
assignee_id: int | None = None
problem_started_at: datetime | None = None
customer_last_message_at: datetime | None = None
support_first_response_at: datetime | None = None
support_last_reply_at: datetime | None = None
class TicketStatusIn(Schema):
status: str
remark: str = ""
resolution: str = ""
is_resolved: bool | None = None
replied_to_customer: bool | None = None
@@ -70,17 +89,36 @@ class TicketOut(Schema):
problem_object: str
submitter_name: str
submitter_org: str
ai_solution: str
resolution: str
resolution_id: int | None
resolution_type: str | None
resolution_source: str | None
resolution_root_cause: str
linked_solution_id: int | None
is_resolved: bool
replied_to_customer: bool
impact_scope: str
environment: str
related_system: str
product_name: str
module_name: str
tags: list[str]
attempted_actions: str
error_information: str
customer_request: str
current_result: str
requester_id: int | None
assignee_id: int | None
requester: TicketUserOut | None = None
assignee: TicketUserOut | None = None
problem_started_at: datetime | None
customer_last_message_at: datetime | None
support_first_response_at: datetime | None
support_last_reply_at: datetime | None
resolved_at: datetime | None
closed_at: datetime | None
archived_at: datetime | None
archived_by: TicketUserOut | None = None
created_at: datetime
updated_at: datetime
@@ -100,6 +138,9 @@ class TicketEventOut(Schema):
to_value: dict
remark: str
operator: TicketUserOut | None = None
source: str
agent_run_id: int | None
metadata: dict[str, Any]
created_at: datetime
@@ -114,6 +155,33 @@ class TicketCommentOut(Schema):
content: str
comment_type: str
visibility: str
created_by: TicketUserOut | None = None
created_at: datetime
class TicketCollaboratorIn(Schema):
user_id: int
role: str = "collaborator"
class TicketCollaboratorOut(Schema):
id: int
user: TicketUserOut
role: str
added_by: TicketUserOut | None = None
created_at: datetime
class TicketFieldEvidenceOut(Schema):
id: int
field_name: str
value: Any
source: str
confidence: float | None
evidence: dict[str, Any]
agent_run_id: int | None
is_protected: bool
created_by: TicketUserOut | None = None
created_at: datetime
@@ -132,4 +200,243 @@ class TicketAttachmentOut(Schema):
mime_type: str
file_size: int
storage_path: str
sha256: str
scan_status: str
scan_result: dict[str, Any]
parse_status: str
parse_error: str
parsed_text: str
created_at: datetime
class TicketIntakeTraceOut(Schema):
available: bool
public_id: str | None = None
status: str = ""
source_text: str = ""
problem_valid: bool | None = None
invalid_reason: str = ""
missing_fields: list[str] = []
message_timeline: list[dict[str, Any]] = []
agent_run_public_id: str | None = None
class TicketSimilaritySuggestionOut(Schema):
ticket_id: int
ticket_no: str
title: str
status: str
category_code: str | None
score: float
title_similarity: float
keyword_score: float
matched_excerpt: str
algorithm_version: str
created_at: datetime | None = None
class TicketSupportAnalysisOut(Schema):
id: int
ticket_id: int
run_public_id: str
agent_version: int
status: str
progress_stage: str
progress_message: str
query: str
retrieval_policy: dict[str, Any]
references: list[dict[str, Any]]
final_output: Any | None
automation_decision: dict[str, Any]
applied_solution_id: int | None
processed_at: datetime | None
error_code: str
error_message: str
created_at: datetime
started_at: datetime | None
finished_at: datetime | None
class TicketKnowledgeDistillationOut(Schema):
id: int
ticket_id: int
run_public_id: str
agent_version: int
status: str
progress_stage: str
progress_message: str
references: list[dict[str, Any]]
retrieval_policy: dict[str, Any]
final_output: Any | None
should_distill: bool | None
reason: str
assessment: dict[str, Any]
knowledge_item_id: int | None
troubleshooting_plan_id: int | None
solution_id: int | None
processed_at: datetime | None
error_code: str
error_message: str
created_at: datetime
started_at: datetime | None
finished_at: datetime | None
class CustomerReplyRevisionOut(Schema):
version: int
content: str
source: str
edited_by_id: int | None
created_at: datetime
class CustomerReplyTaskOut(Schema):
id: int
ticket_id: int
support_analysis_id: int | None
status: str
draft_text: str
final_text: str
revision: int
created_by_id: int | None
updated_by_id: int | None
approved_by_id: int | None
approved_at: datetime | None
sent_by_id: int | None
sent_at: datetime | None
created_at: datetime
updated_at: datetime
revisions: list[CustomerReplyRevisionOut]
class CustomerReplyUpdateIn(Schema):
final_text: str
expected_revision: int
class CustomerReplyConfirmIn(Schema):
expected_revision: int
class IntakeEvaluationRecordIn(Schema):
case_key: str
draft_public_id: str
class IntakeEvaluationResultOut(Schema):
id: int
case_key: str
case_source_kind: str
draft_public_id: str
ticket_id: int | None
actual_auto_created: bool
correct_field_count: int
evaluated_field_count: int
field_accuracy: float | None
passed: bool
details: dict[str, Any]
created_at: datetime
updated_at: datetime
class IntakeRateMetricOut(Schema):
valid_input_count: int
auto_created_count: int
auto_creation_rate: float | None
class IntakeEvaluationMetricOut(Schema):
enabled_case_count: int
reviewed_case_count: int
coverage_rate: float | None
valid_input_count: int
auto_created_count: int
auto_creation_rate: float | None
correct_field_count: int
evaluated_field_count: int
field_accuracy: float | None
evidence_covered_field_count: int
evidence_field_count: int
evidence_coverage_rate: float | None
correct_timeline_item_count: int
evaluated_timeline_item_count: int
timeline_accuracy: float | None
result_selection: str
class IntakeQualityTargetsOut(Schema):
auto_creation_rate: float
field_accuracy: float
coverage_complete: bool
auto_creation_rate_met: bool | None
field_accuracy_met: bool | None
class IntakeQualityMetricsOut(Schema):
include_synthetic: bool
operational: IntakeRateMetricOut
evaluation: IntakeEvaluationMetricOut
targets: IntakeQualityTargetsOut
class SupportEvaluationRecordIn(Schema):
case_key: str
analysis_id: int
class SupportEvaluationResultOut(Schema):
id: int
case_key: str
case_source_kind: str
analysis_id: int
ticket_id: int
actual_candidate_solution_ids: list[int]
actual_citation_ranks: list[int]
matched_expected_solution: bool
actual_auto_resolved: bool
actual_manual_fallback: bool
incorrect_auto_reuse: bool
citations_valid: bool
passed: bool
details: dict[str, Any]
created_at: datetime
updated_at: datetime
class SupportEvaluationMetricOut(Schema):
enabled_case_count: int
reviewed_case_count: int
coverage_rate: float | None
expected_match_case_count: int
matched_solution_count: int
solution_match_rate: float | None
eligible_auto_resolve_case_count: int
auto_resolved_count: int
eligible_auto_resolve_rate: float | None
actual_auto_resolved_count: int
incorrect_auto_reuse_count: int
incorrect_auto_reuse_rate: float | None
expected_manual_fallback_count: int
manual_fallback_count: int
manual_fallback_accuracy: float | None
citation_valid_count: int
citation_validity_rate: float | None
result_selection: str
class SupportQualityTargetsOut(Schema):
solution_match_rate: float
eligible_auto_resolve_rate: float
incorrect_auto_reuse_rate: float
coverage_complete: bool
solution_match_rate_met: bool | None
eligible_auto_resolve_rate_met: bool | None
incorrect_auto_reuse_rate_met: bool | None
manual_fallback_accuracy_met: bool | None
citation_validity_rate_met: bool | None
class SupportQualityMetricsOut(Schema):
include_synthetic: bool
evaluation: SupportEvaluationMetricOut
targets: SupportQualityTargetsOut

View File

@@ -1,8 +1,24 @@
"""工单领域服务。
所有工单写入都应经过本模块,以统一执行字段校验、人工字段保护、状态机、证据记录和审计。
"""
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from apps.audit.models import OperationAuditLog
from apps.tickets.models import Ticket, TicketCategory, TicketEvent, TicketPriority
from apps.tickets.models import (
Ticket,
TicketActorSource,
TicketCategory,
TicketDraft,
TicketEvent,
TicketFieldEvidence,
TicketPriority,
TicketStatus,
)
class TicketStatusError(ValueError):
@@ -13,6 +29,16 @@ class TicketDataError(ValueError):
pass
class TicketFieldProtectedError(TicketDataError):
pass
def schedule_ticket_search_index(ticket_id: int) -> None:
from apps.knowledge.tasks import dispatch_ticket_search_index
transaction.on_commit(lambda: dispatch_ticket_search_index(ticket_id))
REQUIRED_TICKET_FIELDS = (
"title",
"description",
@@ -25,6 +51,145 @@ REQUIRED_TICKET_FIELDS = (
"environment",
)
EDITABLE_TICKET_FIELDS = {
"title",
"description",
"category_code",
"priority",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"impact_scope",
"environment",
"related_system",
"product_name",
"module_name",
"tags",
"attempted_actions",
"error_information",
"customer_request",
"current_result",
"assignee_id",
"problem_started_at",
"customer_last_message_at",
"support_first_response_at",
"support_last_reply_at",
}
BUSINESS_TIME_FIELDS = {
"problem_started_at",
"customer_last_message_at",
"support_first_response_at",
"support_last_reply_at",
}
def normalize_tags(value) -> list[str]:
if not isinstance(value, list):
return []
tags = []
for item in value:
tag = str(item).strip()
if tag and tag not in tags:
tags.append(tag[:50])
if len(tags) >= 20:
break
return tags
def normalize_business_datetime(value):
if value in (None, ""):
return None
parsed = parse_datetime(value.strip()) if isinstance(value, str) else value
if parsed is None or not hasattr(parsed, "utcoffset") or timezone.is_naive(parsed):
return None
return parsed
def _json_value(value):
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _field_value(ticket: Ticket, field_name: str):
if field_name == "category_code":
return ticket.category.code if ticket.category_id else None
if field_name == "assignee_id":
return ticket.assignee_id
return getattr(ticket, field_name)
def get_missing_ticket_fields(ticket: Ticket) -> list[str]:
missing = []
for field_name in REQUIRED_TICKET_FIELDS:
if field_name == "priority":
continue
value = _field_value(ticket, field_name)
if not value:
missing.append(field_name)
continue
if field_name == "title":
evidence = ticket.field_evidence.filter(field_name="title").order_by(
"-created_at"
).first()
if evidence and evidence.evidence.get("generated_from") == "description":
missing.append(field_name)
return missing
def get_outstanding_intake_fields(ticket: Ticket) -> list[str]:
missing = set(get_missing_ticket_fields(ticket))
try:
draft = ticket.source_draft
except TicketDraft.DoesNotExist:
return sorted(missing)
draft_missing = {
code
for code in draft.missing_fields
if code in EDITABLE_TICKET_FIELDS and not _field_value(ticket, code)
}
normalized = sorted(missing | draft_missing)
if normalized != sorted(draft.missing_fields):
draft.missing_fields = normalized
draft.save(update_fields=["missing_fields", "updated_at"])
return normalized
def record_field_evidence(
ticket: Ticket,
field_name: str,
value,
*,
source: str,
operator=None,
confidence=None,
evidence: dict | None = None,
agent_run_id: int | None = None,
is_protected: bool = False,
) -> TicketFieldEvidence:
return TicketFieldEvidence.objects.create(
ticket=ticket,
field_name=field_name,
value=_json_value(value),
source=source,
confidence=confidence,
evidence=evidence or {},
agent_run_id=agent_run_id,
is_protected=is_protected,
created_by=operator,
)
def has_protected_field(ticket: Ticket, field_name: str) -> bool:
latest = (
TicketFieldEvidence.objects.filter(ticket=ticket, field_name=field_name)
.order_by("-created_at", "-id")
.first()
)
return bool(latest and latest.is_protected)
def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
normalized = {
@@ -39,7 +204,24 @@ def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
"impact_scope": str(fields.get("impact_scope") or "").strip(),
"environment": str(fields.get("environment") or "").strip(),
"related_system": str(fields.get("related_system") or "").strip(),
"product_name": str(fields.get("product_name") or "").strip(),
"module_name": str(fields.get("module_name") or "").strip(),
"tags": normalize_tags(fields.get("tags")),
"attempted_actions": str(fields.get("attempted_actions") or "").strip(),
"error_information": str(fields.get("error_information") or "").strip(),
"customer_request": str(fields.get("customer_request") or "").strip(),
"current_result": str(fields.get("current_result") or "").strip(),
"assignee_id": fields.get("assignee_id"),
"problem_started_at": normalize_business_datetime(fields.get("problem_started_at")),
"customer_last_message_at": normalize_business_datetime(
fields.get("customer_last_message_at")
),
"support_first_response_at": normalize_business_datetime(
fields.get("support_first_response_at")
),
"support_last_reply_at": normalize_business_datetime(
fields.get("support_last_reply_at")
),
}
missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
if missing:
@@ -65,7 +247,16 @@ def next_ticket_no() -> str:
return f"{prefix}{count:04d}"
def create_ticket_from_fields(fields: dict, user) -> Ticket:
@transaction.atomic
def create_ticket_from_fields(
fields: dict,
user,
*,
source: str = TicketActorSource.HUMAN,
agent_run_id: int | None = None,
evidence_by_field: dict | None = None,
confidence_by_field: dict | None = None,
) -> Ticket:
normalized, category = validate_ticket_fields(fields)
ticket = Ticket.objects.create(
ticket_no=next_ticket_no(),
@@ -80,7 +271,18 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
impact_scope=normalized["impact_scope"],
environment=normalized["environment"],
related_system=normalized["related_system"],
product_name=normalized["product_name"],
module_name=normalized["module_name"],
tags=normalized["tags"],
attempted_actions=normalized["attempted_actions"],
error_information=normalized["error_information"],
customer_request=normalized["customer_request"],
current_result=normalized["current_result"],
assignee=normalized["assignee"],
problem_started_at=normalized["problem_started_at"],
customer_last_message_at=normalized["customer_last_message_at"],
support_first_response_at=normalized["support_first_response_at"],
support_last_reply_at=normalized["support_last_reply_at"],
requester=user,
created_by=user,
)
@@ -89,10 +291,369 @@ def create_ticket_from_fields(fields: dict, user) -> Ticket:
event_type="created",
to_value={"status": ticket.status, "category_code": normalized["category_code"]},
operator=user,
source=source,
agent_run_id=agent_run_id,
)
evidence_by_field = evidence_by_field or {}
confidence_by_field = confidence_by_field or {}
for field_name in EDITABLE_TICKET_FIELDS:
value = _field_value(ticket, field_name)
if value not in (None, ""):
record_field_evidence(
ticket,
field_name,
value,
source=source,
operator=user,
confidence=confidence_by_field.get(field_name),
evidence=evidence_by_field.get(field_name),
agent_run_id=agent_run_id,
is_protected=source == TicketActorSource.HUMAN,
)
from apps.tickets.similarity import schedule_ticket_similarity_refresh
schedule_ticket_similarity_refresh(ticket)
return ticket
@transaction.atomic
def create_ticket_from_intake(
fields: dict,
user,
*,
agent_run_id: int,
evidence_by_field: dict | None = None,
confidence_by_field: dict | None = None,
human_field_names: set[str] | None = None,
reported_missing_fields: list[str] | None = None,
) -> tuple[Ticket, list[str]]:
"""在问题描述有效后尽早建单,不为缺失业务事实编造默认值。
返回正式工单及仍待补全的字段;Agent 字段保留证据但默认不受人工字段保护。
"""
normalized = {
"title": str(fields.get("title") or "").strip(),
"description": str(fields.get("description") or "").strip(),
"category_code": str(fields.get("category_code") or "").strip(),
"priority": str(fields.get("priority") or "medium").strip(),
"problem_performance": str(fields.get("problem_performance") or "").strip(),
"problem_object": str(fields.get("problem_object") or "").strip(),
"submitter_name": str(fields.get("submitter_name") or "").strip(),
"submitter_org": str(fields.get("submitter_org") or "").strip(),
"impact_scope": str(fields.get("impact_scope") or "").strip(),
"environment": str(fields.get("environment") or "").strip(),
"related_system": str(fields.get("related_system") or "").strip(),
"product_name": str(fields.get("product_name") or "").strip(),
"module_name": str(fields.get("module_name") or "").strip(),
"tags": normalize_tags(fields.get("tags")),
"attempted_actions": str(fields.get("attempted_actions") or "").strip(),
"error_information": str(fields.get("error_information") or "").strip(),
"customer_request": str(fields.get("customer_request") or "").strip(),
"current_result": str(fields.get("current_result") or "").strip(),
"assignee_id": fields.get("assignee_id"),
"problem_started_at": normalize_business_datetime(fields.get("problem_started_at")),
"customer_last_message_at": normalize_business_datetime(
fields.get("customer_last_message_at")
),
"support_first_response_at": normalize_business_datetime(
fields.get("support_first_response_at")
),
"support_last_reply_at": normalize_business_datetime(
fields.get("support_last_reply_at")
),
}
if not normalized["description"]:
raise TicketDataError("尚未形成有效问题描述")
category = TicketCategory.objects.filter(
code=normalized["category_code"],
enabled=True,
).first()
if category is None:
normalized["category_code"] = ""
if normalized["priority"] not in TicketPriority.values:
normalized["priority"] = TicketPriority.MEDIUM
missing_fields = [
code
for code in REQUIRED_TICKET_FIELDS
if code != "priority"
and (
not normalized.get(code)
or (code == "category_code" and category is None)
)
]
missing_fields = sorted(
set(missing_fields)
| {
code
for code in (reported_missing_fields or [])
if code in EDITABLE_TICKET_FIELDS and not normalized.get(code)
}
)
if not normalized["title"]:
normalized["title"] = f"待补全问题:{normalized['description'][:170]}".strip()
assignee = None
if normalized["assignee_id"]:
assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first()
ticket = Ticket.objects.create(
ticket_no=next_ticket_no(),
title=normalized["title"][:200],
description=normalized["description"],
category=category,
status=TicketStatus.PENDING_INFO if missing_fields else TicketStatus.PENDING,
priority=normalized["priority"],
problem_performance=normalized["problem_performance"],
problem_object=normalized["problem_object"],
submitter_name=normalized["submitter_name"],
submitter_org=normalized["submitter_org"],
impact_scope=normalized["impact_scope"],
environment=normalized["environment"],
related_system=normalized["related_system"],
product_name=normalized["product_name"],
module_name=normalized["module_name"],
tags=normalized["tags"],
attempted_actions=normalized["attempted_actions"],
error_information=normalized["error_information"],
customer_request=normalized["customer_request"],
current_result=normalized["current_result"],
assignee=assignee,
problem_started_at=normalized["problem_started_at"],
customer_last_message_at=normalized["customer_last_message_at"],
support_first_response_at=normalized["support_first_response_at"],
support_last_reply_at=normalized["support_last_reply_at"],
requester=user,
created_by=user,
)
TicketEvent.objects.create(
ticket=ticket,
event_type="created",
to_value={
"status": ticket.status,
"category_code": normalized["category_code"] or None,
},
operator=user,
source=TicketActorSource.AGENT,
agent_run_id=agent_run_id,
metadata={"missing_fields": missing_fields, "creation_mode": "auto_intake"},
)
evidence_by_field = evidence_by_field or {}
confidence_by_field = confidence_by_field or {}
human_field_names = human_field_names or set()
for field_name in EDITABLE_TICKET_FIELDS:
value = _field_value(ticket, field_name)
if value in (None, ""):
continue
evidence = evidence_by_field.get(field_name) or {}
if field_name == "title" and "title" in missing_fields:
evidence = {**evidence, "generated_from": "description"}
field_source = (
TicketActorSource.HUMAN
if field_name in human_field_names
else TicketActorSource.AGENT
)
record_field_evidence(
ticket,
field_name,
value,
source=field_source,
operator=user,
confidence=(
None
if field_source == TicketActorSource.HUMAN
else confidence_by_field.get(field_name)
),
evidence=evidence,
agent_run_id=agent_run_id,
is_protected=field_source == TicketActorSource.HUMAN,
)
from apps.tickets.similarity import schedule_ticket_similarity_refresh
schedule_ticket_similarity_refresh(ticket)
return ticket, missing_fields
@transaction.atomic
def update_ticket_fields(
ticket: Ticket,
changes: dict,
operator=None,
*,
source: str = TicketActorSource.HUMAN,
agent_run_id: int | None = None,
evidence_by_field: dict | None = None,
confidence_by_field: dict | None = None,
) -> tuple[Ticket, dict[str, str]]:
"""通过统一入口更新工单字段,并返回被人工保护规则拦截的字段及原因。"""
unknown_fields = set(changes) - EDITABLE_TICKET_FIELDS
if unknown_fields:
raise TicketDataError(f"不允许修改字段: {', '.join(sorted(unknown_fields))}")
ticket = Ticket.objects.select_for_update().select_related("category").get(id=ticket.id)
evidence_by_field = evidence_by_field or {}
confidence_by_field = confidence_by_field or {}
before: dict = {}
applied: dict = {}
skipped: dict[str, str] = {}
update_fields: set[str] = set()
for field_name, value in changes.items():
if source == TicketActorSource.AGENT and has_protected_field(ticket, field_name):
skipped[field_name] = "protected_by_human"
continue
before[field_name] = _json_value(_field_value(ticket, field_name))
if field_name == "tags":
ticket.tags = normalize_tags(value)
update_fields.add("tags")
elif field_name in BUSINESS_TIME_FIELDS:
normalized_datetime = normalize_business_datetime(value)
if value not in (None, "") and normalized_datetime is None:
raise TicketDataError(f"{field_name} 必须包含明确时区")
setattr(ticket, field_name, normalized_datetime)
update_fields.add(field_name)
elif field_name == "assignee_id":
assignee = get_user_model().objects.filter(id=value).first() if value else None
if value and assignee is None:
raise TicketDataError("处理人不存在")
ticket.assignee = assignee
update_fields.add("assignee")
elif field_name == "category_code":
category = (
TicketCategory.objects.filter(code=value, enabled=True).first() if value else None
)
if value and category is None:
raise TicketDataError("工单分类无效或已停用")
ticket.category = category
update_fields.add("category")
else:
setattr(ticket, field_name, value)
update_fields.add(field_name)
applied[field_name] = _json_value(_field_value(ticket, field_name))
if update_fields:
ticket.save(update_fields=[*sorted(update_fields), "updated_at"])
for field_name, value in applied.items():
record_field_evidence(
ticket,
field_name,
value,
source=source,
operator=operator,
confidence=confidence_by_field.get(field_name),
evidence=evidence_by_field.get(field_name),
agent_run_id=agent_run_id,
is_protected=source == TicketActorSource.HUMAN,
)
TicketEvent.objects.create(
ticket=ticket,
event_type="updated",
from_value=before,
to_value=applied,
operator=operator,
source=source,
agent_run_id=agent_run_id,
metadata={"skipped_fields": skipped},
)
OperationAuditLog.objects.create(
user=operator,
module="tickets",
action="update",
target_type="ticket",
target_id=str(ticket.id),
result="success",
request_snapshot={
"fields": sorted(applied),
"skipped_fields": skipped,
"source": source,
"agent_run_id": agent_run_id,
},
)
from apps.workitems.services import sync_ticket_missing_info_work_item
outstanding_fields = get_outstanding_intake_fields(ticket)
if ticket.status == TicketStatus.PENDING_INFO and not outstanding_fields:
previous_status = ticket.status
ticket.status = TicketStatus.PENDING
ticket.save(update_fields=["status", "updated_at"])
TicketEvent.objects.create(
ticket=ticket,
event_type="status_change",
from_value={"status": previous_status},
to_value={"status": ticket.status},
operator=operator,
source=TicketActorSource.SYSTEM,
agent_run_id=agent_run_id,
remark="待补全信息已完成,工单进入待处理",
)
sync_ticket_missing_info_work_item(
ticket,
outstanding_fields,
actor=operator,
)
if {
"title",
"description",
"problem_performance",
"error_information",
"product_name",
"module_name",
"related_system",
"attempted_actions",
"customer_request",
"current_result",
"tags",
} & set(applied):
from apps.tickets.similarity import schedule_ticket_similarity_refresh
schedule_ticket_similarity_refresh(ticket)
if ticket.status in {TicketStatus.RESOLVED, TicketStatus.CLOSED}:
schedule_ticket_search_index(ticket.id)
return ticket, skipped
def get_ticket_resolution(ticket: Ticket):
from apps.solutions.models import TicketResolution
return TicketResolution.objects.filter(ticket=ticket).select_related("linked_solution").first()
def record_ticket_resolution(
ticket: Ticket,
solution_text: str,
operator=None,
*,
resolution_type: str = "resolved",
root_cause: str = "",
linked_solution=None,
source: str = TicketActorSource.HUMAN,
agent_run_id: int | None = None,
):
from apps.solutions.models import TicketResolution
normalized_text = solution_text.strip()
if not normalized_text:
raise TicketDataError("解决方案不能为空")
resolution, _ = TicketResolution.objects.update_or_create(
ticket=ticket,
defaults={
"resolution_type": resolution_type,
"source": source,
"root_cause": root_cause,
"solution_text": normalized_text,
"linked_solution": linked_solution,
"agent_run_id": agent_run_id,
"confirmed_by": operator if source == TicketActorSource.HUMAN else None,
},
)
return resolution
STANDARD_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "closed"},
"pending": {"in_progress", "closed"},
@@ -163,6 +724,7 @@ def transition_requires_remark(
return to_status in {"in_progress", "pending_confirm", "closed"}
@transaction.atomic
def transition_ticket(
ticket: Ticket,
to_status: str,
@@ -170,22 +732,38 @@ def transition_ticket(
operator=None,
*,
resolution: str = "",
is_resolved: bool | None = None,
replied_to_customer: bool | None = None,
resolution_type: str = "resolved",
root_cause: str = "",
linked_solution=None,
source: str = TicketActorSource.HUMAN,
agent_run_id: int | None = None,
) -> Ticket:
"""校验状态机和必需业务结果后完成状态流转、事件记录及后续任务同步。"""
from_status = ticket.status
category_code = get_ticket_category_code(ticket)
validate_status_transition(from_status, to_status, category_code)
resolution_record = get_ticket_resolution(ticket)
if resolution:
resolution_record = record_ticket_resolution(
ticket,
resolution,
operator,
resolution_type=resolution_type,
root_cause=root_cause,
linked_solution=linked_solution,
source=source,
agent_run_id=agent_run_id,
)
if to_status == "resolved" and resolution_record is None:
raise TicketDataError("标记已解决前必须提供解决方案")
ticket.status = to_status
if to_status == "resolved":
ticket.resolved_at = timezone.now()
ticket.is_resolved = True if is_resolved is None else is_resolved
elif to_status != "closed" and is_resolved is not None:
ticket.is_resolved = is_resolved
ticket.resolved_at = ticket.resolved_at or timezone.now()
if to_status == "closed":
ticket.closed_at = timezone.now()
if resolution:
ticket.resolution_summary = resolution
if replied_to_customer is not None:
ticket.replied_to_customer = replied_to_customer
ticket.save(
@@ -193,23 +771,31 @@ def transition_ticket(
"status",
"resolved_at",
"closed_at",
"resolution_summary",
"is_resolved",
"replied_to_customer",
"updated_at",
]
)
if from_status == "pending_info" and to_status != "pending_info":
from apps.workitems.services import complete_ticket_missing_info_work_item
complete_ticket_missing_info_work_item(ticket, actor=operator)
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="status_change",
from_value={"status": from_status},
to_value={
"status": to_status,
"resolution": ticket.resolution_summary,
"is_resolved": ticket.is_resolved,
"resolution_id": resolution_record.id if resolution_record else None,
"resolution": resolution_record.solution_text if resolution_record else "",
"is_resolved": resolution_record is not None,
"replied_to_customer": ticket.replied_to_customer,
},
operator=operator,
source=source,
agent_run_id=agent_run_id,
remark=remark,
)
OperationAuditLog.objects.create(
@@ -219,6 +805,123 @@ def transition_ticket(
target_type="ticket",
target_id=str(ticket.id),
result="success",
request_snapshot={"from": from_status, "to": to_status, "remark": remark},
request_snapshot={
"from": from_status,
"to": to_status,
"remark": remark,
"source": source,
"agent_run_id": agent_run_id,
},
)
if to_status == "resolved":
from apps.knowledge.tasks import dispatch_ticket_knowledge_distillation
actor_id = operator.id if operator is not None else None
transaction.on_commit(
lambda: dispatch_ticket_knowledge_distillation(ticket.id, actor_id)
)
schedule_ticket_search_index(ticket.id)
return ticket
@transaction.atomic
def adopt_solution_for_ticket(
ticket: Ticket,
solution,
operator=None,
*,
remark: str = "采纳解决方案并标记工单已解决",
replied_to_customer: bool = False,
source: str = TicketActorSource.HUMAN,
agent_run_id: int | None = None,
):
from apps.solutions.models import Solution, TicketSolutionLink
locked_solution = Solution.objects.select_for_update().get(id=solution.id)
if locked_solution.status != "published":
raise TicketDataError("只能采纳已发布的解决方案")
locked_ticket = (
Ticket.objects.select_for_update()
.select_related("category", "resolution")
.get(id=ticket.id)
)
from_status = locked_ticket.status
link, _ = TicketSolutionLink.objects.get_or_create(
ticket=locked_ticket,
solution=locked_solution,
link_type="adopted",
defaults={
"agent_run_id": agent_run_id,
"created_by": operator,
},
)
transition_ticket(
locked_ticket,
TicketStatus.RESOLVED,
remark,
operator,
resolution=locked_solution.solution,
replied_to_customer=replied_to_customer,
root_cause=locked_solution.root_cause,
linked_solution=locked_solution,
source=source,
agent_run_id=agent_run_id,
)
locked_solution.usage_count = locked_solution.ticket_links.filter(
link_type="adopted"
).count()
locked_solution.save(update_fields=["usage_count", "updated_at"])
locked_ticket.events.create(
event_type="solution_adopted",
from_value={"status": from_status},
to_value={
"status": TicketStatus.RESOLVED,
"solution_id": locked_solution.id,
"solution_title": locked_solution.title,
},
operator=operator,
source=source,
agent_run_id=agent_run_id,
remark=remark,
)
OperationAuditLog.objects.create(
user=operator,
module="tickets",
action="solution_adopted",
target_type="ticket",
target_id=str(locked_ticket.id),
request_snapshot={
"solution_id": locked_solution.id,
"source": source,
"agent_run_id": agent_run_id,
},
result="success",
)
return locked_ticket, link
@transaction.atomic
def set_ticket_archived(ticket: Ticket, archived: bool, operator=None) -> Ticket:
ticket = Ticket.objects.select_for_update().get(id=ticket.id)
before = ticket.archived_at
ticket.archived_at = timezone.now() if archived else None
ticket.archived_by = operator if archived else None
ticket.save(update_fields=["archived_at", "archived_by", "updated_at"])
TicketEvent.objects.create(
ticket=ticket,
event_type="archived" if archived else "restored",
from_value={"archived_at": _json_value(before)},
to_value={"archived_at": _json_value(ticket.archived_at)},
operator=operator,
)
OperationAuditLog.objects.create(
user=operator,
module="tickets",
action="archive" if archived else "restore",
target_type="ticket",
target_id=str(ticket.id),
result="success",
request_snapshot={},
)
schedule_ticket_search_index(ticket.id)
return ticket

View File

@@ -3,7 +3,14 @@ from datetime import timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone
from apps.tickets.models import Ticket, TicketCategory, TicketEvent
from apps.solutions.models import TicketResolution
from apps.tickets.models import (
Ticket,
TicketCategory,
TicketCollaborator,
TicketComment,
TicketEvent,
)
def test_create_ticket_returns_ticket_detail(admin_client, db):
@@ -205,8 +212,8 @@ def test_business_ticket_can_be_resolved_directly_with_resolution(admin_client,
assert response.status_code == 200
ticket.refresh_from_db()
assert ticket.status == "resolved"
assert ticket.resolution_summary == "已查询采集进度,当前已完成 80%。"
assert ticket.is_resolved is True
assert ticket.resolution.solution_text == "已查询采集进度,当前已完成 80%。"
assert response.json()["is_resolved"] is True
assert ticket.replied_to_customer is True
@@ -238,20 +245,19 @@ def test_closing_resolved_ticket_keeps_resolved_flag(admin_client, db):
title="已解决待关闭",
description="A",
status="resolved",
is_resolved=True,
resolution_summary="已完成处理",
)
TicketResolution.objects.create(ticket=ticket, solution_text="已完成处理")
response = admin_client.post(
f"/api/tickets/{ticket.id}/status",
data={"status": "closed", "remark": "已回复", "is_resolved": False},
data={"status": "closed", "remark": "已回复"},
content_type="application/json",
)
assert response.status_code == 200
ticket.refresh_from_db()
assert ticket.status == "closed"
assert ticket.is_resolved is True
assert response.json()["is_resolved"] is True
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
@@ -287,3 +293,88 @@ def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db)
assert payload[1]["to_value"] == {"status": "in_progress"}
assert payload[1]["remark"] == "开始排查认证链路"
assert payload[1]["operator"]["display_name"] == "admin"
def test_ticket_comments_return_history_author_and_validate_visibility(admin_client, db):
ticket = Ticket.objects.create(
ticket_no="TK202607270301",
title="评论历史",
description="需要协同排查",
)
created = admin_client.post(
f"/api/tickets/{ticket.id}/comments",
data={
"content": " 已检查应用日志,等待数据库同事反馈。 ",
"comment_type": "internal_note",
"visibility": "internal",
},
content_type="application/json",
)
assert created.status_code == 200
assert created.json()["content"] == "已检查应用日志,等待数据库同事反馈。"
assert created.json()["created_by"]["username"] == "admin"
history = admin_client.get(f"/api/tickets/{ticket.id}/comments")
assert history.status_code == 200
assert history.json()[0]["created_by"]["display_name"] == "admin"
assert TicketEvent.objects.filter(ticket=ticket, event_type="commented").count() == 1
mismatched = admin_client.post(
f"/api/tickets/{ticket.id}/comments",
data={
"content": "客户可见内容",
"comment_type": "user_reply",
"visibility": "internal",
},
content_type="application/json",
)
assert mismatched.status_code == 400
assert TicketComment.objects.filter(ticket=ticket).count() == 1
def test_ticket_collaboration_lists_active_users_and_is_idempotent(admin_client, db):
user_model = get_user_model()
active_user = user_model.objects.create_user(
username="collaborator",
first_name="协作成员",
is_active=True,
)
user_model.objects.create_user(username="disabled-collaborator", is_active=False)
ticket = Ticket.objects.create(
ticket_no="TK202607270302",
title="协作管理",
description="需要多人处理",
)
participants = admin_client.get("/api/tickets/participants")
assert participants.status_code == 200
usernames = {item["username"] for item in participants.json()}
assert "collaborator" in usernames
assert "disabled-collaborator" not in usernames
payload = {"user_id": active_user.id, "role": "collaborator"}
first = admin_client.post(
f"/api/tickets/{ticket.id}/collaborators",
data=payload,
content_type="application/json",
)
duplicate = admin_client.post(
f"/api/tickets/{ticket.id}/collaborators",
data=payload,
content_type="application/json",
)
assert first.status_code == duplicate.status_code == 200
assert first.json()["id"] == duplicate.json()["id"]
assert TicketCollaborator.objects.filter(ticket=ticket).count() == 1
assert TicketEvent.objects.filter(ticket=ticket, event_type="collaborator_added").count() == 1
listed = admin_client.get(f"/api/tickets/{ticket.id}/collaborators")
assert listed.status_code == 200
assert listed.json()[0]["user"]["display_name"] == "协作成员"
removed = admin_client.delete(
f"/api/tickets/{ticket.id}/collaborators/{first.json()['id']}"
)
assert removed.status_code == 200
assert TicketCollaborator.objects.filter(ticket=ticket).count() == 0

View File

@@ -0,0 +1,109 @@
from django.contrib.auth import get_user_model
from apps.tickets.models import Ticket, TicketActorSource, TicketFieldEvidence
from apps.tickets.services import update_ticket_fields
def test_agent_update_does_not_overwrite_human_protected_field(db):
user = get_user_model().objects.create_user(username="support-field-owner")
ticket = Ticket.objects.create(
ticket_no="TK202607250001",
title="原始标题",
description="A",
)
ticket, _ = update_ticket_fields(
ticket,
{"title": "人工确认标题"},
user,
source=TicketActorSource.HUMAN,
)
ticket, skipped = update_ticket_fields(
ticket,
{"title": "Agent 建议标题"},
source=TicketActorSource.AGENT,
agent_run_id=42,
)
ticket.refresh_from_db()
assert ticket.title == "人工确认标题"
assert skipped == {"title": "protected_by_human"}
latest = TicketFieldEvidence.objects.filter(ticket=ticket, field_name="title").first()
assert latest.source == TicketActorSource.HUMAN
assert latest.is_protected is True
def test_agent_update_records_evidence_for_unprotected_field(db):
ticket = Ticket.objects.create(
ticket_no="TK202607250002",
title="原始标题",
description="A",
)
ticket, skipped = update_ticket_fields(
ticket,
{"related_system": "CRM"},
source=TicketActorSource.AGENT,
agent_run_id=99,
confidence_by_field={"related_system": 0.93},
evidence_by_field={"related_system": {"quote": "CRM 接口返回 500"}},
)
assert skipped == {}
evidence = TicketFieldEvidence.objects.get(ticket=ticket, field_name="related_system")
assert evidence.value == "CRM"
assert evidence.source == TicketActorSource.AGENT
assert evidence.agent_run_id == 99
assert float(evidence.confidence) == 0.93
assert evidence.is_protected is False
def test_archive_hides_ticket_from_default_list_and_restore_recovers_it(admin_client, db):
ticket = Ticket.objects.create(
ticket_no="TK202607250003",
title="待归档",
description="A",
)
archived = admin_client.post(f"/api/tickets/{ticket.id}/archive")
default_list = admin_client.get("/api/tickets")
archived_list = admin_client.get("/api/tickets?include_archived=true")
assert archived.status_code == 200
assert archived.json()["archived_at"] is not None
assert default_list.json()["total"] == 0
assert archived_list.json()["total"] == 1
restored = admin_client.post(f"/api/tickets/{ticket.id}/restore")
assert restored.status_code == 200
assert restored.json()["archived_at"] is None
assert admin_client.get("/api/tickets").json()["total"] == 1
def test_ticket_collaborators_can_be_added_listed_and_removed(admin_client, db):
collaborator = get_user_model().objects.create_user(username="ticket-collaborator")
ticket = Ticket.objects.create(
ticket_no="TK202607250004",
title="需要协作",
description="A",
)
created = admin_client.post(
f"/api/tickets/{ticket.id}/collaborators",
data={"user_id": collaborator.id, "role": "watcher"},
content_type="application/json",
)
assert created.status_code == 200
assert created.json()["user"]["username"] == "ticket-collaborator"
assert created.json()["role"] == "watcher"
listed = admin_client.get(f"/api/tickets/{ticket.id}/collaborators")
assert [item["id"] for item in listed.json()] == [created.json()["id"]]
removed = admin_client.delete(
f"/api/tickets/{ticket.id}/collaborators/{created.json()['id']}"
)
assert removed.status_code == 200
assert admin_client.get(f"/api/tickets/{ticket.id}/collaborators").json() == []