381 lines
13 KiB
Python
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"])
|