feat: add trusted ticket intake backend
This commit is contained in:
@@ -24,7 +24,13 @@ from apps.tickets.schemas import (
|
||||
TicketStatusIn,
|
||||
TicketUpdateIn,
|
||||
)
|
||||
from apps.tickets.services import TicketStatusError, transition_requires_remark, transition_ticket
|
||||
from apps.tickets.services import (
|
||||
TicketDataError,
|
||||
TicketStatusError,
|
||||
create_ticket_from_fields,
|
||||
transition_requires_remark,
|
||||
transition_ticket,
|
||||
)
|
||||
|
||||
router = Router(tags=["tickets"])
|
||||
|
||||
@@ -101,37 +107,10 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
|
||||
@router.post("", response=TicketOut)
|
||||
def create_ticket(request, payload: TicketIn):
|
||||
require_permission(request, "ticket.create")
|
||||
assignee = None
|
||||
if payload.assignee_id:
|
||||
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
|
||||
category = None
|
||||
if payload.category_code:
|
||||
category = TicketCategory.objects.filter(code=payload.category_code, enabled=True).first()
|
||||
if category is None:
|
||||
raise HttpError(400, "Unknown ticket category")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
category=category,
|
||||
priority=payload.priority,
|
||||
problem_performance=payload.problem_performance,
|
||||
problem_object=payload.problem_object,
|
||||
submitter_name=payload.submitter_name,
|
||||
submitter_org=payload.submitter_org,
|
||||
impact_scope=payload.impact_scope,
|
||||
environment=payload.environment,
|
||||
related_system=payload.related_system,
|
||||
assignee=assignee,
|
||||
requester=request.user if request.user.is_authenticated else None,
|
||||
created_by=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="created",
|
||||
to_value={"status": ticket.status, "category_code": payload.category_code},
|
||||
operator=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
try:
|
||||
ticket = create_ticket_from_fields(payload.dict(), request.user)
|
||||
except TicketDataError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_ticket(ticket)
|
||||
|
||||
|
||||
|
||||
244
backend/apps/tickets/draft_api.py
Normal file
244
backend/apps/tickets/draft_api.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from ninja import File, Router, UploadedFile
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.tickets.api import serialize_ticket
|
||||
from apps.tickets.draft_schemas import (
|
||||
TicketDraftActionIn,
|
||||
TicketDraftAnswerIn,
|
||||
TicketDraftAvailabilityOut,
|
||||
TicketDraftCreateIn,
|
||||
TicketDraftOut,
|
||||
TicketDraftPatchIn,
|
||||
)
|
||||
from apps.tickets.draft_services import (
|
||||
ACTIVE_DRAFT_STATUSES,
|
||||
TicketDraftError,
|
||||
add_answer,
|
||||
assert_revision,
|
||||
audit_draft,
|
||||
confirm_draft,
|
||||
create_agent_run_for_draft,
|
||||
draft_availability,
|
||||
reject_draft,
|
||||
serialize_draft,
|
||||
)
|
||||
from apps.tickets.models import TicketDraft, TicketDraftImage
|
||||
from apps.tickets.storage import DraftImageError, delete_object, prepare_image, put_object
|
||||
from apps.tickets.tasks import execute_ticket_draft_analysis
|
||||
|
||||
router = Router(tags=["ticket-drafts"])
|
||||
|
||||
|
||||
def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False) -> TicketDraft:
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
queryset = TicketDraft.objects.select_related(
|
||||
"latest_agent_run", "confirmed_ticket", "created_by"
|
||||
).prefetch_related("images", "messages")
|
||||
draft = queryset.filter(public_id=public_id).first()
|
||||
if draft is None:
|
||||
raise HttpError(404, "草稿不存在")
|
||||
if draft.created_by_id != request.user.id:
|
||||
can_view = allow_admin_view and (
|
||||
request.user.is_superuser or request.user.has_perm("system.agent.run.view")
|
||||
)
|
||||
if not can_view:
|
||||
raise HttpError(403, "不能访问其他人的草稿")
|
||||
return draft
|
||||
|
||||
|
||||
def raise_draft_error(error: TicketDraftError) -> None:
|
||||
raise HttpError(error.status_code, str(error)) from error
|
||||
|
||||
|
||||
@router.get("/availability", response=TicketDraftAvailabilityOut)
|
||||
def get_availability(request):
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
return draft_availability()
|
||||
|
||||
|
||||
@router.get("", response=list[TicketDraftOut])
|
||||
def list_ticket_drafts(request):
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
drafts = (
|
||||
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
|
||||
.select_related("latest_agent_run", "confirmed_ticket")
|
||||
.prefetch_related("images", "messages")[:20]
|
||||
)
|
||||
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
|
||||
|
||||
|
||||
@router.post("", response=TicketDraftOut)
|
||||
def create_ticket_draft(request, payload: TicketDraftCreateIn):
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
availability = draft_availability()
|
||||
if not availability["available"]:
|
||||
raise HttpError(422, availability["reason"])
|
||||
source_text = payload.source_text.strip()
|
||||
draft = TicketDraft.objects.create(created_by=request.user, source_text=source_text)
|
||||
audit_draft(draft, "draft_created")
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.get("/{public_id}", response=TicketDraftOut)
|
||||
def get_ticket_draft(request, public_id: str):
|
||||
draft = get_owned_draft(request, public_id, allow_admin_view=True)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.patch("/{public_id}", response=TicketDraftOut)
|
||||
def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
|
||||
with transaction.atomic():
|
||||
draft = (
|
||||
TicketDraft.objects.select_for_update()
|
||||
.filter(public_id=public_id, created_by=request.user)
|
||||
.first()
|
||||
)
|
||||
if draft is None:
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
raise HttpError(404, "草稿不存在")
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
try:
|
||||
assert_revision(draft, payload.expected_revision)
|
||||
except TicketDraftError as error:
|
||||
raise_draft_error(error)
|
||||
if draft.status not in {"editing", "ready", "failed"}:
|
||||
raise HttpError(400, "当前状态不能编辑")
|
||||
draft.current_fields = payload.fields
|
||||
draft.revision += 1
|
||||
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_updated")
|
||||
draft = get_owned_draft(request, public_id)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.post("/{public_id}/images", response=TicketDraftOut)
|
||||
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
|
||||
draft = get_owned_draft(request, public_id)
|
||||
if draft.status not in {"editing", "ready", "failed"}:
|
||||
raise HttpError(400, "当前状态不能上传图片")
|
||||
if draft.images.count() >= 4:
|
||||
raise HttpError(413, "每个草稿最多上传 4 张图片")
|
||||
raw = file.read()
|
||||
try:
|
||||
prepared = prepare_image(raw, file.name or "image")
|
||||
except DraftImageError as error:
|
||||
raise HttpError(error.status_code, str(error)) from error
|
||||
current_total = sum(image.file_size for image in draft.images.all())
|
||||
if current_total + len(prepared.data) > 20 * 1024 * 1024:
|
||||
raise HttpError(413, "草稿图片总大小不能超过 20 MB")
|
||||
image = TicketDraftImage(
|
||||
draft=draft,
|
||||
file_name=prepared.file_name,
|
||||
mime_type=prepared.mime_type,
|
||||
file_size=len(prepared.data),
|
||||
sha256=prepared.sha256,
|
||||
storage_path="",
|
||||
)
|
||||
image.storage_path = (
|
||||
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
|
||||
f"{image.public_id}.{prepared.extension}"
|
||||
)
|
||||
try:
|
||||
put_object(image.storage_path, prepared.data, prepared.mime_type)
|
||||
except Exception as error:
|
||||
raise HttpError(503, "图片存储暂时不可用") from error
|
||||
image.save()
|
||||
draft.revision += 1
|
||||
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
||||
draft.save(update_fields=["revision", "expires_at", "updated_at"])
|
||||
audit_draft(draft, "draft_image_uploaded")
|
||||
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.delete("/{public_id}/images/{image_id}", response=TicketDraftOut)
|
||||
def delete_ticket_draft_image(request, public_id: str, image_id: str):
|
||||
draft = get_owned_draft(request, public_id)
|
||||
image = draft.images.filter(public_id=image_id, status="temporary").first()
|
||||
if image is None:
|
||||
raise HttpError(404, "图片不存在")
|
||||
try:
|
||||
delete_object(image.storage_path)
|
||||
except Exception as error:
|
||||
raise HttpError(503, "图片存储暂时不可用") from error
|
||||
image.delete()
|
||||
draft.revision += 1
|
||||
draft.save(update_fields=["revision", "updated_at"])
|
||||
audit_draft(draft, "draft_image_deleted")
|
||||
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft:
|
||||
try:
|
||||
assert_revision(draft, expected_revision)
|
||||
if draft.status not in {"editing", "ready", "failed"}:
|
||||
raise TicketDraftError(400, "当前状态不能启动分析")
|
||||
if not draft.source_text.strip() and not draft.current_fields:
|
||||
raise TicketDraftError(400, "请先输入问题描述")
|
||||
if draft.images.exists() and not draft_availability()["vision_available"]:
|
||||
raise TicketDraftError(422, draft_availability()["vision_reason"])
|
||||
run = create_agent_run_for_draft(draft)
|
||||
except TicketDraftError as error:
|
||||
raise_draft_error(error)
|
||||
result = execute_ticket_draft_analysis.delay(draft.id, run.id)
|
||||
run.refresh_from_db()
|
||||
if not run.task_id:
|
||||
run.task_id = result.id or ""
|
||||
run.save(update_fields=["task_id"])
|
||||
draft.refresh_from_db()
|
||||
return draft
|
||||
|
||||
|
||||
@router.post("/{public_id}/analyze", response=TicketDraftOut)
|
||||
def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
||||
draft = get_owned_draft(request, public_id)
|
||||
draft = start_analysis(draft, payload.expected_revision)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.post("/{public_id}/answers", response=TicketDraftOut)
|
||||
def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn):
|
||||
draft = get_owned_draft(request, public_id)
|
||||
try:
|
||||
assert_revision(draft, payload.expected_revision)
|
||||
if not payload.answer.strip():
|
||||
raise TicketDraftError(400, "回答不能为空")
|
||||
add_answer(draft, payload.answer, payload.question)
|
||||
except TicketDraftError as error:
|
||||
raise_draft_error(error)
|
||||
draft.refresh_from_db()
|
||||
draft = start_analysis(draft, draft.revision)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.post("/{public_id}/confirm", response=TicketDraftOut)
|
||||
def confirm_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
require_permission(request, "ticket.create")
|
||||
try:
|
||||
draft = confirm_draft(public_id, payload.expected_revision, request.user)
|
||||
except TicketDraft.DoesNotExist as error:
|
||||
raise HttpError(404, "草稿不存在") from error
|
||||
except TicketDraftError as error:
|
||||
raise_draft_error(error)
|
||||
draft = get_owned_draft(request, public_id)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
|
||||
|
||||
@router.post("/{public_id}/reject", response=TicketDraftOut)
|
||||
def reject_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
||||
require_permission(request, "ticket.ai_draft")
|
||||
try:
|
||||
draft = reject_draft(public_id, payload.expected_revision, request.user)
|
||||
except TicketDraft.DoesNotExist as error:
|
||||
raise HttpError(404, "草稿不存在") from error
|
||||
except TicketDraftError as error:
|
||||
raise_draft_error(error)
|
||||
draft = get_owned_draft(request, public_id)
|
||||
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
||||
66
backend/apps/tickets/draft_schemas.py
Normal file
66
backend/apps/tickets/draft_schemas.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class TicketDraftCreateIn(Schema):
|
||||
source_text: str = ""
|
||||
|
||||
|
||||
class TicketDraftPatchIn(Schema):
|
||||
expected_revision: int
|
||||
fields: dict
|
||||
|
||||
|
||||
class TicketDraftActionIn(Schema):
|
||||
expected_revision: int
|
||||
|
||||
|
||||
class TicketDraftAnswerIn(Schema):
|
||||
expected_revision: int
|
||||
answer: str
|
||||
question: str = ""
|
||||
|
||||
|
||||
class TicketDraftImageOut(Schema):
|
||||
id: str
|
||||
file_name: str
|
||||
mime_type: str
|
||||
file_size: int
|
||||
status: str
|
||||
|
||||
|
||||
class TicketDraftMessageOut(Schema):
|
||||
role: str
|
||||
content: str
|
||||
round_number: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketDraftOut(Schema):
|
||||
public_id: str
|
||||
status: str
|
||||
revision: int
|
||||
source_text: str
|
||||
fields: dict
|
||||
last_ai_fields: dict
|
||||
missing_fields: list[str]
|
||||
questions: list[str]
|
||||
guided_rounds: int
|
||||
agent_run_public_id: str | None = None
|
||||
images: list[TicketDraftImageOut]
|
||||
messages: list[TicketDraftMessageOut]
|
||||
decision: str
|
||||
error_code: str
|
||||
error_message: str
|
||||
confirmed_ticket: dict | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class TicketDraftAvailabilityOut(Schema):
|
||||
available: bool
|
||||
reason: str
|
||||
vision_available: bool
|
||||
vision_reason: str
|
||||
380
backend/apps/tickets/draft_services.py
Normal file
380
backend/apps/tickets/draft_services.py
Normal file
@@ -0,0 +1,380 @@
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentArtifact, AgentDefinition, AgentRun
|
||||
from apps.agents.services import build_config_snapshot, execute_agent_run
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import (
|
||||
TicketAttachment,
|
||||
TicketDraft,
|
||||
TicketDraftImage,
|
||||
TicketDraftMessage,
|
||||
TicketEvent,
|
||||
)
|
||||
from apps.tickets.services import TicketDataError, create_ticket_from_fields
|
||||
from apps.tickets.storage import copy_object, delete_object, get_object
|
||||
|
||||
ACTIVE_DRAFT_STATUSES = {"editing", "analyzing", "ready", "failed"}
|
||||
FINAL_DRAFT_STATUSES = {"confirmed", "rejected", "expired"}
|
||||
ALLOWED_FIELD_CODES = {
|
||||
"title",
|
||||
"description",
|
||||
"category_code",
|
||||
"priority",
|
||||
"problem_performance",
|
||||
"problem_object",
|
||||
"submitter_name",
|
||||
"submitter_org",
|
||||
"impact_scope",
|
||||
"environment",
|
||||
"related_system",
|
||||
"assignee_id",
|
||||
}
|
||||
|
||||
|
||||
class TicketDraftError(ValueError):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def audit_draft(draft: TicketDraft, action: str, result: str = "success") -> None:
|
||||
OperationAuditLog.objects.create(
|
||||
user=draft.created_by,
|
||||
module="tickets",
|
||||
action=action,
|
||||
target_type="ticket_draft",
|
||||
target_id=str(draft.public_id),
|
||||
request_snapshot={
|
||||
"status": draft.status,
|
||||
"revision": draft.revision,
|
||||
"guided_rounds": draft.guided_rounds,
|
||||
"image_count": draft.images.count(),
|
||||
"result": result,
|
||||
},
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def get_ticket_intake_agent() -> AgentDefinition | None:
|
||||
return (
|
||||
AgentDefinition.objects.select_related("active_version__model_deployment__provider")
|
||||
.filter(code="ticket-intake", active_version__status="published")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def draft_availability() -> dict:
|
||||
definition = get_ticket_intake_agent()
|
||||
if definition is None or definition.active_version is None:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "尚未发布建单 Agent",
|
||||
"vision_available": False,
|
||||
"vision_reason": "尚未发布建单 Agent",
|
||||
}
|
||||
deployment = definition.active_version.model_deployment
|
||||
available = bool(deployment.enabled and deployment.provider.enabled)
|
||||
vision_available = bool(
|
||||
available
|
||||
and deployment.capability == "vision_chat"
|
||||
and deployment.provider.image_egress_approved
|
||||
)
|
||||
if not available:
|
||||
reason = "建单 Agent 的模型配置未启用"
|
||||
else:
|
||||
reason = ""
|
||||
if deployment.capability != "vision_chat":
|
||||
vision_reason = "当前建单模型不支持图片理解"
|
||||
elif not deployment.provider.image_egress_approved:
|
||||
vision_reason = "管理员尚未授权图片发送至模型渠道"
|
||||
elif not available:
|
||||
vision_reason = reason
|
||||
else:
|
||||
vision_reason = ""
|
||||
return {
|
||||
"available": available,
|
||||
"reason": reason,
|
||||
"vision_available": vision_available,
|
||||
"vision_reason": vision_reason,
|
||||
}
|
||||
|
||||
|
||||
def assert_revision(draft: TicketDraft, expected_revision: int) -> None:
|
||||
if draft.revision != expected_revision:
|
||||
raise TicketDraftError(409, "草稿已在其他位置更新,请刷新后重试")
|
||||
|
||||
|
||||
def serialize_image(image: TicketDraftImage) -> dict:
|
||||
return {
|
||||
"id": str(image.public_id),
|
||||
"file_name": image.file_name,
|
||||
"mime_type": image.mime_type,
|
||||
"file_size": image.file_size,
|
||||
"status": image.status,
|
||||
}
|
||||
|
||||
|
||||
def serialize_draft(draft: TicketDraft, *, ticket_serializer=None) -> dict:
|
||||
confirmed = None
|
||||
if draft.confirmed_ticket_id and ticket_serializer:
|
||||
confirmed = ticket_serializer(draft.confirmed_ticket)
|
||||
return {
|
||||
"public_id": str(draft.public_id),
|
||||
"status": draft.status,
|
||||
"revision": draft.revision,
|
||||
"source_text": draft.source_text,
|
||||
"fields": draft.current_fields,
|
||||
"last_ai_fields": draft.last_ai_fields,
|
||||
"missing_fields": draft.missing_fields,
|
||||
"questions": draft.questions,
|
||||
"guided_rounds": draft.guided_rounds,
|
||||
"agent_run_public_id": str(draft.latest_agent_run.public_id)
|
||||
if draft.latest_agent_run_id
|
||||
else None,
|
||||
"images": [serialize_image(image) for image in draft.images.all()],
|
||||
"messages": [
|
||||
{
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"round_number": message.round_number,
|
||||
"created_at": message.created_at,
|
||||
}
|
||||
for message in draft.messages.all()
|
||||
],
|
||||
"decision": draft.decision,
|
||||
"error_code": draft.error_code,
|
||||
"error_message": draft.error_message,
|
||||
"confirmed_ticket": confirmed,
|
||||
"created_at": draft.created_at,
|
||||
"updated_at": draft.updated_at,
|
||||
"expires_at": draft.expires_at,
|
||||
}
|
||||
|
||||
|
||||
def create_agent_run_for_draft(draft: TicketDraft) -> AgentRun:
|
||||
definition = get_ticket_intake_agent()
|
||||
if definition is None or definition.active_version is None:
|
||||
raise TicketDraftError(422, "尚未发布可用的建单 Agent")
|
||||
version = definition.active_version
|
||||
image_refs = [
|
||||
{"object_path": image.storage_path, "mime_type": image.mime_type}
|
||||
for image in draft.images.all()
|
||||
]
|
||||
messages = [
|
||||
{"role": item.role, "content": item.content, "round": item.round_number}
|
||||
for item in draft.messages.all()
|
||||
]
|
||||
run = AgentRun.objects.create(
|
||||
definition=definition,
|
||||
agent_version=version,
|
||||
sandbox=False,
|
||||
input_payload={
|
||||
"draft_public_id": str(draft.public_id),
|
||||
"source_text": draft.source_text,
|
||||
"current_fields": draft.current_fields,
|
||||
"messages": messages,
|
||||
"image_refs": image_refs,
|
||||
},
|
||||
config_snapshot=build_config_snapshot(version),
|
||||
total_steps=version.steps.count(),
|
||||
created_by=draft.created_by,
|
||||
)
|
||||
draft.latest_agent_run = run
|
||||
draft.status = "analyzing"
|
||||
draft.revision += 1
|
||||
draft.error_code = ""
|
||||
draft.error_message = ""
|
||||
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_analysis_started")
|
||||
return run
|
||||
|
||||
|
||||
def _image_parts(draft: TicketDraft) -> list[dict]:
|
||||
parts = []
|
||||
for image in draft.images.all():
|
||||
encoded = base64.b64encode(get_object(image.storage_path)).decode("ascii")
|
||||
parts.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{image.mime_type};base64,{encoded}"},
|
||||
}
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
def execute_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
draft = (
|
||||
TicketDraft.objects.select_related("created_by").prefetch_related("images").get(id=draft_id)
|
||||
)
|
||||
run = AgentRun.objects.select_related("agent_version__model_deployment__provider").get(
|
||||
id=run_id
|
||||
)
|
||||
deployment = run.agent_version.model_deployment
|
||||
image_parts = []
|
||||
if draft.images.exists():
|
||||
if deployment.capability != "vision_chat" or not deployment.provider.image_egress_approved:
|
||||
draft.status = "failed"
|
||||
draft.error_code = "vision_not_allowed"
|
||||
draft.error_message = "当前模型或渠道不允许图片输入"
|
||||
draft.revision += 1
|
||||
draft.save()
|
||||
run.status = "failed"
|
||||
run.error_code = draft.error_code
|
||||
run.error_message = draft.error_message
|
||||
run.finished_at = timezone.now()
|
||||
run.save()
|
||||
audit_draft(draft, "draft_analysis_failed", "failed")
|
||||
return
|
||||
image_parts = _image_parts(draft)
|
||||
execute_agent_run(run.id, image_parts=image_parts)
|
||||
run.refresh_from_db()
|
||||
draft.refresh_from_db()
|
||||
if run.status != "succeeded" or not isinstance(run.final_output, dict):
|
||||
draft.status = "failed"
|
||||
draft.error_code = run.error_code or "analysis_failed"
|
||||
draft.error_message = run.error_message or "AI 分析失败,请重试或转为普通表单"
|
||||
draft.revision += 1
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_analysis_failed", "failed")
|
||||
return
|
||||
fields = run.final_output.get("fields")
|
||||
missing_fields = run.final_output.get("missing_fields")
|
||||
questions = run.final_output.get("questions")
|
||||
if (
|
||||
not isinstance(fields, dict)
|
||||
or not isinstance(missing_fields, list)
|
||||
or not isinstance(questions, list)
|
||||
):
|
||||
draft.status = "failed"
|
||||
draft.error_code = "invalid_artifact"
|
||||
draft.error_message = "AI 产物结构不完整"
|
||||
draft.revision += 1
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_analysis_failed", "failed")
|
||||
return
|
||||
fields = {code: value for code, value in fields.items() if code in ALLOWED_FIELD_CODES}
|
||||
artifact = AgentArtifact.objects.create(
|
||||
run=run,
|
||||
artifact_type="ticket_draft",
|
||||
schema_version=1,
|
||||
payload={
|
||||
"draft_public_id": str(draft.public_id),
|
||||
"fields": fields,
|
||||
"missing_fields": [str(item) for item in missing_fields],
|
||||
"questions": [str(item) for item in questions],
|
||||
},
|
||||
created_by=draft.created_by,
|
||||
)
|
||||
draft.current_fields = fields
|
||||
draft.last_ai_fields = fields
|
||||
draft.missing_fields = [str(item) for item in missing_fields]
|
||||
draft.questions = [str(item) for item in questions]
|
||||
draft.latest_artifact = artifact
|
||||
draft.status = "ready"
|
||||
draft.error_code = ""
|
||||
draft.error_message = ""
|
||||
draft.revision += 1
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_analysis_succeeded")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def confirm_draft(draft_public_id, expected_revision: int, user) -> TicketDraft:
|
||||
draft = (
|
||||
TicketDraft.objects.select_for_update()
|
||||
.select_related("created_by")
|
||||
.prefetch_related("images")
|
||||
.get(public_id=draft_public_id, created_by=user)
|
||||
)
|
||||
if draft.confirmed_ticket_id:
|
||||
return draft
|
||||
assert_revision(draft, expected_revision)
|
||||
if draft.status != "ready":
|
||||
raise TicketDraftError(400, "只有审核就绪的草稿可以确认")
|
||||
try:
|
||||
ticket = create_ticket_from_fields(draft.current_fields, user)
|
||||
except TicketDataError as error:
|
||||
raise TicketDraftError(400, str(error)) from error
|
||||
decision = "accepted" if draft.current_fields == draft.last_ai_fields else "modified"
|
||||
for image in draft.images.all():
|
||||
source_path = image.storage_path
|
||||
target_path = f"tickets/{ticket.id}/{image.public_id}-{image.file_name}"
|
||||
copy_object(source_path, target_path)
|
||||
attachment = TicketAttachment.objects.create(
|
||||
ticket=ticket,
|
||||
file_name=image.file_name,
|
||||
file_type="image",
|
||||
mime_type=image.mime_type,
|
||||
file_size=image.file_size,
|
||||
storage_path=target_path,
|
||||
created_by=user,
|
||||
)
|
||||
image.status = "confirmed"
|
||||
image.confirmed_attachment = attachment
|
||||
image.storage_path = target_path
|
||||
image.save(update_fields=["status", "confirmed_attachment", "storage_path"])
|
||||
transaction.on_commit(lambda path=source_path: delete_object(path), robust=True)
|
||||
draft.confirmed_ticket = ticket
|
||||
draft.status = "confirmed"
|
||||
draft.decision = decision
|
||||
draft.confirmed_at = timezone.now()
|
||||
draft.revision += 1
|
||||
draft.save()
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="ai_draft_confirmed",
|
||||
to_value={
|
||||
"draft_public_id": str(draft.public_id),
|
||||
"decision": decision,
|
||||
"agent_run_public_id": str(draft.latest_agent_run.public_id)
|
||||
if draft.latest_agent_run_id
|
||||
else None,
|
||||
},
|
||||
operator=user,
|
||||
)
|
||||
audit_draft(draft, "draft_confirmed")
|
||||
return draft
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def reject_draft(draft_public_id, expected_revision: int, user) -> TicketDraft:
|
||||
draft = TicketDraft.objects.select_for_update().get(public_id=draft_public_id, created_by=user)
|
||||
assert_revision(draft, expected_revision)
|
||||
if draft.status in FINAL_DRAFT_STATUSES:
|
||||
if draft.status == "rejected":
|
||||
return draft
|
||||
raise TicketDraftError(400, "草稿已经结束")
|
||||
draft.status = "rejected"
|
||||
draft.decision = "rejected"
|
||||
draft.revision += 1
|
||||
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
||||
draft.save()
|
||||
audit_draft(draft, "draft_rejected")
|
||||
return draft
|
||||
|
||||
|
||||
def add_answer(draft: TicketDraft, answer: str, question: str) -> None:
|
||||
if draft.guided_rounds >= 5:
|
||||
raise TicketDraftError(400, "逐问模式最多进行 5 轮,请手工补齐剩余字段")
|
||||
round_number = draft.guided_rounds + 1
|
||||
if question.strip():
|
||||
TicketDraftMessage.objects.create(
|
||||
draft=draft,
|
||||
role="assistant",
|
||||
content=question.strip(),
|
||||
round_number=round_number,
|
||||
)
|
||||
TicketDraftMessage.objects.create(
|
||||
draft=draft,
|
||||
role="user",
|
||||
content=answer.strip(),
|
||||
round_number=round_number,
|
||||
)
|
||||
draft.guided_rounds = round_number
|
||||
draft.save(update_fields=["guided_rounds", "updated_at"])
|
||||
0
backend/apps/tickets/management/__init__.py
Normal file
0
backend/apps/tickets/management/__init__.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.tickets.draft_services import (
|
||||
confirm_draft,
|
||||
create_agent_run_for_draft,
|
||||
execute_draft_analysis,
|
||||
)
|
||||
from apps.tickets.models import TicketDraft
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run the fixed real-text ticket intake acceptance workflow."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--username", default="admin")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user = get_user_model().objects.filter(username=options["username"]).first()
|
||||
if user is None:
|
||||
raise CommandError("验收用户不存在")
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text=(
|
||||
"生产环境中,客户 A 的采集任务 JOB-1024 从上午 10 点起没有更新,"
|
||||
"影响单个客户,请技术支持排查。"
|
||||
),
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
draft.refresh_from_db()
|
||||
if draft.status != "ready":
|
||||
raise CommandError(f"真实文本分析失败: {draft.error_code} {draft.error_message}")
|
||||
|
||||
fields = {
|
||||
**draft.current_fields,
|
||||
"title": draft.current_fields.get("title") or "生产采集任务未更新",
|
||||
"description": draft.current_fields.get("description") or draft.source_text,
|
||||
"category_code": "technical_issue",
|
||||
"priority": draft.current_fields.get("priority")
|
||||
if draft.current_fields.get("priority") in {"low", "medium", "high", "urgent"}
|
||||
else "high",
|
||||
"problem_performance": draft.current_fields.get("problem_performance")
|
||||
or "采集进度长时间未更新",
|
||||
"problem_object": draft.current_fields.get("problem_object") or "JOB-1024",
|
||||
"submitter_name": draft.current_fields.get("submitter_name") or "验收用户",
|
||||
"submitter_org": draft.current_fields.get("submitter_org") or "验收机构",
|
||||
"environment": draft.current_fields.get("environment") or "生产环境",
|
||||
"impact_scope": draft.current_fields.get("impact_scope") or "单个客户",
|
||||
"related_system": draft.current_fields.get("related_system") or "数据采集平台",
|
||||
}
|
||||
draft.current_fields = fields
|
||||
draft.revision += 1
|
||||
draft.save(update_fields=["current_fields", "revision", "updated_at"])
|
||||
revision = draft.revision
|
||||
first = confirm_draft(draft.public_id, revision, user)
|
||||
second = confirm_draft(draft.public_id, revision, user)
|
||||
if first.confirmed_ticket_id != second.confirmed_ticket_id:
|
||||
raise CommandError("重复确认创建了不同工单")
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Ticket intake acceptance passed: "
|
||||
f"draft={draft.public_id} run={run.public_id} "
|
||||
f"ticket={first.confirmed_ticket.ticket_no} decision={first.decision}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-16 11:13
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import apps.tickets.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("agents", "0002_agentartifact"),
|
||||
("tickets", "0003_repair_closed_resolved_flags"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TicketDraft",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("status", models.CharField(default="editing", max_length=24)),
|
||||
("revision", models.PositiveIntegerField(default=1)),
|
||||
("source_text", models.TextField(blank=True)),
|
||||
("current_fields", models.JSONField(default=dict)),
|
||||
("last_ai_fields", models.JSONField(default=dict)),
|
||||
("missing_fields", models.JSONField(default=list)),
|
||||
("questions", models.JSONField(default=list)),
|
||||
("guided_rounds", models.PositiveSmallIntegerField(default=0)),
|
||||
("decision", models.CharField(blank=True, max_length=24)),
|
||||
("error_code", models.CharField(blank=True, max_length=80)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
(
|
||||
"expires_at",
|
||||
models.DateTimeField(default=apps.tickets.models.default_ticket_draft_expiry),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("confirmed_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"confirmed_ticket",
|
||||
models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="source_draft",
|
||||
to="tickets.ticket",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ticket_drafts",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"latest_agent_run",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="ticket_drafts",
|
||||
to="agents.agentrun",
|
||||
),
|
||||
),
|
||||
(
|
||||
"latest_artifact",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="ticket_drafts",
|
||||
to="agents.agentartifact",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TicketDraftImage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("file_name", models.CharField(max_length=255)),
|
||||
("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(default="temporary", max_length=24)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"confirmed_attachment",
|
||||
models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="source_draft_image",
|
||||
to="tickets.ticketattachment",
|
||||
),
|
||||
),
|
||||
(
|
||||
"draft",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="images",
|
||||
to="tickets.ticketdraft",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TicketDraftMessage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("role", models.CharField(max_length=24)),
|
||||
("content", models.TextField()),
|
||||
("round_number", models.PositiveSmallIntegerField(default=0)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"draft",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="tickets.ticketdraft",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticketdraft",
|
||||
index=models.Index(
|
||||
fields=["created_by", "status", "updated_at"], name="tickets_tic_created_424ee8_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticketdraft",
|
||||
index=models.Index(
|
||||
fields=["status", "expires_at"], name="tickets_tic_status_3e6dd8_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,13 @@
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def default_ticket_draft_expiry():
|
||||
return timezone.now() + timedelta(hours=24)
|
||||
|
||||
|
||||
class TicketStatus(models.TextChoices):
|
||||
@@ -132,6 +140,91 @@ class TicketAttachment(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class TicketDraft(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ticket_drafts",
|
||||
)
|
||||
status = models.CharField(max_length=24, default="editing")
|
||||
revision = models.PositiveIntegerField(default=1)
|
||||
source_text = models.TextField(blank=True)
|
||||
current_fields = models.JSONField(default=dict)
|
||||
last_ai_fields = models.JSONField(default=dict)
|
||||
missing_fields = models.JSONField(default=list)
|
||||
questions = models.JSONField(default=list)
|
||||
guided_rounds = models.PositiveSmallIntegerField(default=0)
|
||||
latest_agent_run = models.ForeignKey(
|
||||
"agents.AgentRun",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="ticket_drafts",
|
||||
)
|
||||
latest_artifact = models.ForeignKey(
|
||||
"agents.AgentArtifact",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="ticket_drafts",
|
||||
)
|
||||
confirmed_ticket = models.OneToOneField(
|
||||
Ticket,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="source_draft",
|
||||
)
|
||||
decision = models.CharField(max_length=24, blank=True)
|
||||
error_code = models.CharField(max_length=80, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
expires_at = models.DateTimeField(default=default_ticket_draft_expiry)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["created_by", "status", "updated_at"]),
|
||||
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)
|
||||
content = models.TextField()
|
||||
round_number = models.PositiveSmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class TicketDraftImage(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="images")
|
||||
file_name = models.CharField(max_length=255)
|
||||
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")
|
||||
confirmed_attachment = models.OneToOneField(
|
||||
TicketAttachment,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="source_draft_image",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class TicketEvent(models.Model):
|
||||
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
|
||||
event_type = models.CharField(max_length=80)
|
||||
|
||||
@@ -1,13 +1,98 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.tickets.models import Ticket, TicketEvent
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketEvent, TicketPriority
|
||||
|
||||
|
||||
class TicketStatusError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TicketDataError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
REQUIRED_TICKET_FIELDS = (
|
||||
"title",
|
||||
"description",
|
||||
"category_code",
|
||||
"priority",
|
||||
"problem_performance",
|
||||
"problem_object",
|
||||
"submitter_name",
|
||||
"submitter_org",
|
||||
"environment",
|
||||
)
|
||||
|
||||
|
||||
def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
|
||||
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(),
|
||||
"assignee_id": fields.get("assignee_id"),
|
||||
}
|
||||
missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
|
||||
if missing:
|
||||
raise TicketDataError(f"缺少必填字段: {', '.join(missing)}")
|
||||
if normalized["priority"] not in TicketPriority.values:
|
||||
raise TicketDataError("工单优先级无效")
|
||||
category = TicketCategory.objects.filter(code=normalized["category_code"], enabled=True).first()
|
||||
if category is None:
|
||||
raise TicketDataError("工单分类无效或已停用")
|
||||
if normalized["assignee_id"]:
|
||||
assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first()
|
||||
if assignee is None:
|
||||
raise TicketDataError("处理人不存在")
|
||||
else:
|
||||
assignee = None
|
||||
normalized["assignee"] = assignee
|
||||
return normalized, category
|
||||
|
||||
|
||||
def next_ticket_no() -> str:
|
||||
prefix = timezone.localdate().strftime("TK%Y%m%d")
|
||||
count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1
|
||||
return f"{prefix}{count:04d}"
|
||||
|
||||
|
||||
def create_ticket_from_fields(fields: dict, user) -> Ticket:
|
||||
normalized, category = validate_ticket_fields(fields)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=next_ticket_no(),
|
||||
title=normalized["title"],
|
||||
description=normalized["description"],
|
||||
category=category,
|
||||
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"],
|
||||
assignee=normalized["assignee"],
|
||||
requester=user,
|
||||
created_by=user,
|
||||
)
|
||||
TicketEvent.objects.create(
|
||||
ticket=ticket,
|
||||
event_type="created",
|
||||
to_value={"status": ticket.status, "category_code": normalized["category_code"]},
|
||||
operator=user,
|
||||
)
|
||||
return ticket
|
||||
|
||||
|
||||
STANDARD_TRANSITIONS: dict[str, set[str]] = {
|
||||
"pending_info": {"pending", "closed"},
|
||||
"pending": {"in_progress", "closed"},
|
||||
|
||||
112
backend/apps/tickets/storage.py
Normal file
112
backend/apps/tickets/storage.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import hashlib
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from minio import Minio
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
|
||||
class DraftImageError(ValueError):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedImage:
|
||||
data: bytes
|
||||
file_name: str
|
||||
mime_type: str
|
||||
extension: str
|
||||
sha256: str
|
||||
|
||||
|
||||
FORMATS = {
|
||||
"JPEG": ("image/jpeg", "jpg"),
|
||||
"PNG": ("image/png", "png"),
|
||||
"WEBP": ("image/webp", "webp"),
|
||||
}
|
||||
|
||||
|
||||
def prepare_image(raw: bytes, original_name: str) -> PreparedImage:
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise DraftImageError(413, "单张图片不能超过 10 MB")
|
||||
try:
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
image.verify()
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
if image.format not in FORMATS or getattr(image, "is_animated", False):
|
||||
raise DraftImageError(415, "仅支持 JPEG、PNG 和 WebP 静态图片")
|
||||
mime_type, extension = FORMATS[image.format]
|
||||
if image.mode not in {"RGB", "RGBA", "L"}:
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
output = io.BytesIO()
|
||||
save_format = "JPEG" if extension == "jpg" else image.format
|
||||
if save_format == "JPEG" and image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
image.save(output, format=save_format, quality=90, optimize=True)
|
||||
except DraftImageError:
|
||||
raise
|
||||
except (UnidentifiedImageError, OSError, ValueError) as error:
|
||||
raise DraftImageError(415, "图片内容无法识别或格式不受支持") from error
|
||||
data = output.getvalue()
|
||||
stem = original_name.rsplit(".", 1)[0].strip() or "image"
|
||||
return PreparedImage(
|
||||
data=data,
|
||||
file_name=f"{stem}.{extension}",
|
||||
mime_type=mime_type,
|
||||
extension=extension,
|
||||
sha256=hashlib.sha256(data).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def client() -> Minio:
|
||||
return Minio(
|
||||
settings.MINIO_ENDPOINT,
|
||||
access_key=settings.MINIO_ACCESS_KEY,
|
||||
secret_key=settings.MINIO_SECRET_KEY,
|
||||
secure=settings.MINIO_SECURE,
|
||||
)
|
||||
|
||||
|
||||
def ensure_bucket(storage: Minio) -> None:
|
||||
if not storage.bucket_exists(settings.MINIO_BUCKET):
|
||||
storage.make_bucket(settings.MINIO_BUCKET)
|
||||
|
||||
|
||||
def put_object(path: str, data: bytes, mime_type: str) -> None:
|
||||
storage = client()
|
||||
ensure_bucket(storage)
|
||||
storage.put_object(
|
||||
settings.MINIO_BUCKET,
|
||||
path,
|
||||
io.BytesIO(data),
|
||||
len(data),
|
||||
content_type=mime_type,
|
||||
)
|
||||
|
||||
|
||||
def get_object(path: str) -> bytes:
|
||||
response = client().get_object(settings.MINIO_BUCKET, path)
|
||||
try:
|
||||
return response.read()
|
||||
finally:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
|
||||
def delete_object(path: str) -> None:
|
||||
client().remove_object(settings.MINIO_BUCKET, path)
|
||||
|
||||
|
||||
def copy_object(source_path: str, target_path: str) -> None:
|
||||
from minio.commonconfig import CopySource
|
||||
|
||||
storage = client()
|
||||
ensure_bucket(storage)
|
||||
storage.copy_object(
|
||||
settings.MINIO_BUCKET,
|
||||
target_path,
|
||||
CopySource(settings.MINIO_BUCKET, source_path),
|
||||
)
|
||||
55
backend/apps/tickets/tasks.py
Normal file
55
backend/apps/tickets/tasks.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentArtifact, AgentRun
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.models import ModelInvocation
|
||||
from apps.tickets.draft_services import execute_draft_analysis
|
||||
from apps.tickets.models import TicketDraft
|
||||
from apps.tickets.storage import delete_object
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.execute_ticket_draft_analysis")
|
||||
def execute_ticket_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
execute_draft_analysis(draft_id, run_id)
|
||||
|
||||
|
||||
@shared_task(name="apps.tickets.tasks.cleanup_expired_ticket_drafts")
|
||||
def cleanup_expired_ticket_drafts() -> int:
|
||||
queryset = TicketDraft.objects.filter(
|
||||
expires_at__lt=timezone.now(), confirmed_ticket__isnull=True
|
||||
).prefetch_related("images")
|
||||
count = queryset.count()
|
||||
for draft in queryset:
|
||||
public_id = str(draft.public_id)
|
||||
for image in draft.images.all():
|
||||
try:
|
||||
delete_object(image.storage_path)
|
||||
except Exception:
|
||||
pass
|
||||
OperationAuditLog.objects.create(
|
||||
user=draft.created_by,
|
||||
module="tickets",
|
||||
action="draft_expired",
|
||||
target_type="ticket_draft",
|
||||
target_id=str(draft.public_id),
|
||||
request_snapshot={
|
||||
"status": draft.status,
|
||||
"guided_rounds": draft.guided_rounds,
|
||||
"image_count": draft.images.count(),
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
runs = AgentRun.objects.filter(input_payload__draft_public_id=public_id)
|
||||
invocation_ids = list(
|
||||
ModelInvocation.objects.filter(agent_attempts__step__run__in=runs)
|
||||
.values_list("id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
AgentArtifact.objects.filter(
|
||||
artifact_type="ticket_draft", payload__draft_public_id=public_id
|
||||
).delete()
|
||||
runs.delete()
|
||||
ModelInvocation.objects.filter(id__in=invocation_ids).delete()
|
||||
draft.delete()
|
||||
return count
|
||||
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal file
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
|
||||
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.modelhub.models import ModelDeployment, ModelProvider
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
from apps.tickets.draft_services import TicketDraftError, add_answer
|
||||
from apps.tickets.models import Ticket, TicketCategory, TicketDraft, TicketDraftMessage
|
||||
from apps.tickets.tasks import cleanup_expired_ticket_drafts
|
||||
|
||||
|
||||
def ticket_fields(**overrides):
|
||||
fields = {
|
||||
"title": "生产采集任务未更新",
|
||||
"description": "生产环境采集任务已两小时没有更新",
|
||||
"category_code": "technical_issue",
|
||||
"priority": "high",
|
||||
"problem_performance": "采集进度停滞",
|
||||
"problem_object": "采集任务 A",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "华东分公司",
|
||||
"impact_scope": "单个任务",
|
||||
"environment": "生产环境",
|
||||
"related_system": "数据中台",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return fields
|
||||
|
||||
|
||||
def create_ticket_agent(*, capability="text_chat", approved=False):
|
||||
provider = ModelProvider.objects.create(
|
||||
name="建单渠道",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
image_egress_approved=approved,
|
||||
)
|
||||
deployment = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="建单模型",
|
||||
model_name="ticket-model",
|
||||
capability=capability,
|
||||
)
|
||||
skill = SkillDefinition.objects.create(code="ticket-draft", name="建单")
|
||||
skill_version = SkillVersion.objects.create(
|
||||
definition=skill,
|
||||
version=1,
|
||||
status="published",
|
||||
prompt_template="{{ input.source_text }}",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"source_text": {"type": "string"}},
|
||||
"required": ["source_text"],
|
||||
},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
agent = AgentDefinition.objects.create(code="ticket-intake", name="建单 Agent")
|
||||
version = AgentVersion.objects.create(
|
||||
definition=agent,
|
||||
version=1,
|
||||
status="published",
|
||||
model_deployment=deployment,
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
AgentStepBinding.objects.create(
|
||||
agent_version=version,
|
||||
code="draft",
|
||||
order=1,
|
||||
skill_version=skill_version,
|
||||
input_mapping={"source_text": "$input.source_text"},
|
||||
)
|
||||
agent.active_version = version
|
||||
agent.save(update_fields=["active_version"])
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def category(db):
|
||||
return TicketCategory.objects.get(code="technical_issue")
|
||||
|
||||
|
||||
def test_feature_requires_published_ticket_agent(admin_client, db):
|
||||
response = admin_client.get("/api/ticket-drafts/availability")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["available"] is False
|
||||
create_ticket_agent()
|
||||
response = admin_client.get("/api/ticket-drafts/availability")
|
||||
assert response.json()["available"] is True
|
||||
assert response.json()["vision_available"] is False
|
||||
|
||||
|
||||
def test_revision_conflict_and_manual_edit(admin_client, db):
|
||||
create_ticket_agent()
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "任务未更新"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
|
||||
response = admin_client.patch(
|
||||
f"/api/ticket-drafts/{created['public_id']}",
|
||||
data={"expected_revision": created["revision"], "fields": ticket_fields()},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["revision"] == 2
|
||||
|
||||
conflict = admin_client.patch(
|
||||
f"/api/ticket-drafts/{created['public_id']}",
|
||||
data={"expected_revision": 1, "fields": ticket_fields()},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
|
||||
def test_confirm_is_idempotent_and_records_accepted(admin_client, category):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
fields = ticket_fields()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="ready",
|
||||
current_fields=fields,
|
||||
last_ai_fields=fields,
|
||||
)
|
||||
|
||||
first = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": draft.revision},
|
||||
content_type="application/json",
|
||||
)
|
||||
second = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": draft.revision},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["confirmed_ticket"]["id"] == second.json()["confirmed_ticket"]["id"]
|
||||
assert first.json()["decision"] == "accepted"
|
||||
assert Ticket.objects.count() == 1
|
||||
assert Ticket.objects.get().ai_solution == ""
|
||||
|
||||
|
||||
def test_confirm_records_modified_and_reject_records_rejected(admin_client, category):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="ready",
|
||||
current_fields=ticket_fields(title="用户修改后的标题"),
|
||||
last_ai_fields=ticket_fields(),
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/confirm",
|
||||
data={"expected_revision": 1},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.json()["decision"] == "modified"
|
||||
|
||||
rejected = TicketDraft.objects.create(created_by=creator, source_text="放弃")
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{rejected.public_id}/reject",
|
||||
data={"expected_revision": 1},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.json()["decision"] == "rejected"
|
||||
|
||||
|
||||
def test_guided_answers_stop_after_five_rounds(admin_client, db):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=get_user_model().objects.filter(is_superuser=True).first(),
|
||||
guided_rounds=4,
|
||||
)
|
||||
add_answer(draft, "第五轮回答", "第五轮问题")
|
||||
draft.refresh_from_db()
|
||||
assert draft.guided_rounds == 5
|
||||
assert TicketDraftMessage.objects.filter(draft=draft).count() == 2
|
||||
with pytest.raises(TicketDraftError, match="最多进行 5 轮"):
|
||||
add_answer(draft, "第六轮", "")
|
||||
|
||||
|
||||
def test_image_upload_validates_content_and_never_audits_source_text(admin_client, db, monkeypatch):
|
||||
create_ticket_agent(capability="vision_chat", approved=True)
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "包含敏感客户信息的原文"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *args: None)
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color="red").save(output, format="PNG")
|
||||
upload = SimpleUploadedFile("proof.png", output.getvalue(), content_type="text/plain")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/images", {"file": upload}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["images"][0]["mime_type"] == "image/png"
|
||||
snapshots = list(
|
||||
OperationAuditLog.objects.filter(target_id=created["public_id"]).values_list(
|
||||
"request_snapshot", flat=True
|
||||
)
|
||||
)
|
||||
assert "包含敏感客户信息的原文" not in str(snapshots)
|
||||
|
||||
|
||||
def test_text_model_rejects_image_analysis(admin_client, db):
|
||||
create_ticket_agent(capability="text_chat")
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "任务未更新"},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
draft = TicketDraft.objects.get(public_id=created["public_id"])
|
||||
draft.images.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/analyze",
|
||||
data={"expected_revision": created["revision"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "不支持图片" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_cleanup_deletes_unconfirmed_details_but_keeps_confirmed_draft(
|
||||
admin_client, db, monkeypatch
|
||||
):
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
creator = get_user_model().objects.filter(is_superuser=True).first()
|
||||
expired = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
source_text="过期敏感原文",
|
||||
expires_at=timezone.now(),
|
||||
)
|
||||
expired.images.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
)
|
||||
confirmed = TicketDraft.objects.create(
|
||||
created_by=creator,
|
||||
status="confirmed",
|
||||
expires_at=timezone.now(),
|
||||
confirmed_ticket=Ticket.objects.create(
|
||||
ticket_no="TK202607160001", title="已确认", description="保留"
|
||||
),
|
||||
)
|
||||
deleted_paths = []
|
||||
monkeypatch.setattr("apps.tickets.tasks.delete_object", lambda path: deleted_paths.append(path))
|
||||
|
||||
assert cleanup_expired_ticket_drafts() == 1
|
||||
assert not TicketDraft.objects.filter(id=expired.id).exists()
|
||||
assert TicketDraft.objects.filter(id=confirmed.id).exists()
|
||||
assert deleted_paths == ["ticket-drafts/proof.png"]
|
||||
audit = OperationAuditLog.objects.get(action="draft_expired", target_id=str(expired.public_id))
|
||||
assert "过期敏感原文" not in str(audit.request_snapshot)
|
||||
Reference in New Issue
Block a user