feat: add trusted ticket intake backend
This commit is contained in:
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"])
|
||||
Reference in New Issue
Block a user