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