feat: add trusted ticket intake backend

This commit is contained in:
hailin
2026-07-16 19:37:32 +08:00
parent b592dc219d
commit bcd6de4609
28 changed files with 1966 additions and 42 deletions

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