Files
ai-ticket-platform/backend/apps/tickets/draft_services.py
2026-07-16 19:37:32 +08:00

381 lines
13 KiB
Python

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"])