"""智能受理编排服务。 负责把临时材料、Agent 结构化结果和人工补充收敛为一次可追溯、幂等的正式建单事务。 """ import base64 from datetime import timedelta from celery.result import AsyncResult from django.conf import settings from django.db import transaction from django.utils import timezone from django.utils.dateparse import parse_datetime from apps.agents.models import AgentArtifact, AgentDefinition, AgentRun, AgentVersion from apps.agents.services import append_run_event, build_config_snapshot, execute_agent_run from apps.audit.models import OperationAuditLog from apps.knowledge.models import KnowledgeItem from apps.knowledge.services import retrieve_knowledge from apps.modelhub.models import ModelDeployment from apps.tickets.materials import SUPPORTED_EXTENSIONS from apps.tickets.models import ( TicketActorSource, TicketAttachment, TicketDraft, TicketDraftMaterial, TicketDraftMessage, TicketEvent, TicketMessageTimelineItem, ) from apps.tickets.services import ( BUSINESS_TIME_FIELDS, TicketDataError, create_ticket_from_fields, create_ticket_from_intake, normalize_business_datetime, normalize_tags, ) from apps.tickets.storage import copy_object, delete_object, get_object from apps.workitems.services import ( complete_ticket_intake_failure_work_item, sync_ticket_intake_failure_work_item, sync_ticket_missing_info_work_item, ) ACTIVE_DRAFT_STATUSES = { "collecting", "analyzing", "needs_input", "failed", # Compatibility while an existing development database is being migrated. "editing", "ready", } FINAL_DRAFT_STATUSES = {"ticket_created", "rejected", "expired", "confirmed"} ALLOWED_FIELD_CODES = { "title", "description", "category_code", "priority", "problem_performance", "problem_object", "submitter_name", "submitter_org", "impact_scope", "environment", "related_system", "product_name", "module_name", "tags", "attempted_actions", "error_information", "customer_request", "current_result", "assignee_id", "problem_started_at", "customer_last_message_at", "support_first_response_at", "support_last_reply_at", } PROVENANCE_FIELD_CODES = { "problem_object", "submitter_name", "submitter_org", "product_name", "module_name", } class TicketDraftError(ValueError): def __init__(self, status_code: int, message: str): super().__init__(message) self.status_code = status_code def _build_knowledge_context(query: str, limit: int = 3) -> list[dict]: """Return only reviewed item guidance; never expose raw document chunks to the Agent.""" if not query.strip(): return [] retrieved = retrieve_knowledge(query=query, limit=max(limit * 3, limit)) item_ids = [ item.get("metadata", {}).get("item_id") for item in retrieved if item.get("metadata", {}).get("source_type") == "knowledge_item" ] published = { item.id: item for item in KnowledgeItem.objects.filter( id__in=[item_id for item_id in item_ids if item_id], status="published", knowledge_base__enabled=True, ) } context = [] seen = set() for result in retrieved: item_id = result.get("metadata", {}).get("item_id") item = published.get(item_id) if item is None or item.id in seen: continue seen.add(item.id) context.append( { "knowledge_item_id": item.id, "title": item.title, "applicable_scope": item.applicable_scope, "root_cause": item.root_cause, "troubleshooting_steps": item.troubleshooting_steps, "solution": item.solution, "risk_note": item.risk_note, "retrieval_score": result.get("score", 0), } ) if len(context) >= limit: break return context 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, "material_count": draft.materials.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 _effective_step_deployments(version: AgentVersion) -> list[ModelDeployment]: return [ step.model_deployment or version.model_deployment for step in version.steps.select_related( "model_deployment__provider", "agent_version__model_deployment__provider", ) ] def draft_availability() -> dict: material_limits = { "max_file_size": settings.TICKET_MATERIAL_MAX_FILE_BYTES, "max_files": settings.TICKET_MATERIAL_MAX_FILES, "max_total_size": settings.TICKET_MATERIAL_MAX_TOTAL_BYTES, "supported_extensions": list(SUPPORTED_EXTENSIONS), } 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", **material_limits, } deployments = _effective_step_deployments(definition.active_version) available = bool( deployments and all(deployment.enabled and deployment.provider.enabled for deployment in deployments) ) vision_deployments = [ deployment for deployment in deployments if deployment.capability == "vision_chat" ] vision_available = bool( available and vision_deployments and all(deployment.provider.image_egress_approved for deployment in vision_deployments) ) if not available: reason = "建单 Agent 的模型配置未启用" else: reason = "" if not vision_deployments: vision_reason = "当前建单模型不支持图片理解" elif not all( deployment.provider.image_egress_approved for deployment in vision_deployments ): vision_reason = "管理员尚未授权图片发送至模型渠道" elif not available: vision_reason = reason else: vision_reason = "" return { "available": available, "reason": reason, "vision_available": vision_available, "vision_reason": vision_reason, **material_limits, } def assert_revision(draft: TicketDraft, expected_revision: int) -> None: if draft.revision != expected_revision: raise TicketDraftError(409, "草稿已在其他位置更新,请刷新后重试") def serialize_material(material: TicketDraftMaterial) -> dict: return { "id": str(material.public_id), "file_name": material.file_name, "material_type": material.material_type, "extension": material.extension, "claimed_mime_type": material.claimed_mime_type, "mime_type": material.mime_type, "file_size": material.file_size, "status": material.status, "scan_status": material.scan_status, "scan_result": material.scan_result, "parse_status": material.parse_status, "parse_error": material.parse_error, "parsed_text_length": len(material.parsed_text), } serialize_image = serialize_material def serialize_agent_progress(draft: TicketDraft) -> dict: run = draft.latest_agent_run if run is None: return { "stage": "idle", "message": "等待开始分析", "current_step": "", "total_steps": 0, "retry_count": 0, "elapsed_ms": 0, "slow": False, } started_at = run.started_at or run.created_at finished_at = run.finished_at or timezone.now() elapsed_ms = max(int((finished_at - started_at).total_seconds() * 1000), 0) active = run.status in {"queued", "running"} slow_threshold_ms = settings.AI_SLOW_RUN_THRESHOLD_SECONDS * 1000 return { "stage": run.progress_stage, "message": run.progress_message or "任务已进入处理队列", "current_step": run.current_step, "total_steps": run.total_steps, "retry_count": run.retry_count, "elapsed_ms": elapsed_ms, "slow": active and elapsed_ms >= slow_threshold_ms, } def serialize_timeline_item(item: TicketMessageTimelineItem) -> dict: return { "sequence": item.sequence, "speaker": item.speaker, "role": item.role, "content": item.content, "occurred_at": item.occurred_at.isoformat() if item.occurred_at else "", "occurred_at_raw": item.occurred_at_raw, "timezone": item.timezone, "source_ref": item.source_ref, "confidence": float(item.confidence) if item.confidence is not None else None, "conflict": item.conflict, } 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) materials = [serialize_material(material) for material in draft.materials.all()] timeline_items = list(draft.timeline_items.all()) normalized_timeline = ( [serialize_timeline_item(item) for item in timeline_items] if timeline_items else draft.message_timeline ) return { "public_id": str(draft.public_id), "status": draft.status, "revision": draft.revision, "source_text": draft.source_text, "problem_valid": draft.problem_valid, "invalid_reason": draft.invalid_reason, "fields": draft.current_fields, "last_ai_fields": draft.last_ai_fields, "field_evidence": draft.field_evidence, "message_timeline": normalized_timeline, "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, "agent_progress": serialize_agent_progress(draft), "materials": materials, "images": [item for item in materials if item["material_type"] == "image"], "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, "created_ticket": confirmed, "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 ready_materials = list(draft.materials.filter(scan_status="clean")) image_refs = [ { "source_ref": f"material:{material.public_id}", "file_name": material.file_name, "object_path": material.storage_path, "mime_type": material.mime_type, } for material in ready_materials if material.material_type == "image" ] material_texts = [ { "source_ref": f"material:{material.public_id}", "file_name": material.file_name, "mime_type": material.mime_type, "text": material.parsed_text, } for material in ready_materials if material.parse_status == "parsed" and material.parsed_text ] material_refs = [ { "source_ref": f"material:{material.public_id}", "file_name": material.file_name, "material_type": material.material_type, "mime_type": material.mime_type, "sha256": material.sha256, } for material in ready_materials ] messages = [ {"role": item.role, "content": item.content, "round": item.round_number} for item in draft.messages.all() ] retrieval_query = " ".join( [ draft.source_text, *[item["content"] for item in messages if item["role"] == "user"], *[item["text"] for item in material_texts], ] ).strip() complete_ticket_intake_failure_work_item(draft, actor=draft.created_by) 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, "material_refs": material_refs, "material_texts": material_texts, "knowledge_context": _build_knowledge_context(retrieval_query), }, config_snapshot=build_config_snapshot(version), total_steps=version.steps.count(), created_by=draft.created_by, ) append_run_event(run, "queued", {"message": "受理任务已进入处理队列"}) draft.latest_agent_run = run draft.status = "analyzing" draft.problem_valid = None draft.invalid_reason = "" 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.materials.filter(material_type="image", scan_status="clean"): 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 _normalize_field_evidence(raw_evidence) -> tuple[dict, dict]: if not isinstance(raw_evidence, dict): return {}, {} normalized = {} confidence_by_field = {} for code, raw_item in raw_evidence.items(): if code not in ALLOWED_FIELD_CODES: continue if isinstance(raw_item, str): item = {"excerpt": raw_item.strip()} if raw_item.strip() else {} elif isinstance(raw_item, dict): item = { "excerpt": str(raw_item.get("excerpt") or "").strip(), "source_refs": [ str(value).strip() for value in raw_item.get("source_refs", []) if str(value).strip() ] if isinstance(raw_item.get("source_refs"), list) else [], "location": str(raw_item.get("location") or "").strip(), "raw_value": str(raw_item.get("raw_value") or "").strip(), "timezone": str(raw_item.get("timezone") or "").strip(), "conflicts": [ str(value).strip() for value in raw_item.get("conflicts", []) if str(value).strip() ] if isinstance(raw_item.get("conflicts"), list) else [], } confidence = raw_item.get("confidence") if isinstance(confidence, (int, float)) and not isinstance(confidence, bool): confidence = max(0.0, min(float(confidence), 1.0)) item["confidence"] = confidence confidence_by_field[code] = confidence else: continue normalized[code] = item return normalized, confidence_by_field def _normalize_message_timeline(raw_timeline) -> list[dict]: if not isinstance(raw_timeline, list): return [] timeline = [] for position, raw_item in enumerate(raw_timeline[:200], start=1): if not isinstance(raw_item, dict): continue content = str(raw_item.get("content") or "").strip() if not content: continue role = str(raw_item.get("role") or "unknown").strip() if role not in {"customer", "support", "unknown"}: role = "unknown" confidence = raw_item.get("confidence") if not isinstance(confidence, (int, float)) or isinstance(confidence, bool): confidence = None else: confidence = max(0.0, min(float(confidence), 1.0)) timeline.append( { "sequence": position, "speaker": str(raw_item.get("speaker") or "").strip(), "role": role, "content": content, "occurred_at": str(raw_item.get("occurred_at") or "").strip(), "occurred_at_raw": str(raw_item.get("occurred_at_raw") or "").strip(), "timezone": str(raw_item.get("timezone") or "").strip(), "source_ref": str(raw_item.get("source_ref") or "").strip(), "confidence": confidence, "conflict": str(raw_item.get("conflict") or "").strip(), } ) return timeline @transaction.atomic def _replace_message_timeline(draft: TicketDraft, timeline: list[dict]) -> None: draft = TicketDraft.objects.select_for_update().get(id=draft.id) materials = { f"material:{material.public_id}": material for material in draft.materials.all() } draft.timeline_items.all().delete() items = [] for item in timeline: occurred_at_raw = item.get("occurred_at", "") occurred_at = parse_datetime(occurred_at_raw) if occurred_at_raw else None if occurred_at is not None and timezone.is_naive(occurred_at): occurred_at = None source_ref = item.get("source_ref", "") items.append( TicketMessageTimelineItem( draft=draft, ticket=draft.confirmed_ticket, source_material=materials.get(source_ref), sequence=item["sequence"], speaker=item.get("speaker", ""), role=item.get("role", "unknown"), content=item["content"], occurred_at=occurred_at, occurred_at_raw=item.get("occurred_at_raw", "") or occurred_at_raw, timezone=item.get("timezone", ""), source_ref=source_ref, confidence=item.get("confidence"), conflict=item.get("conflict", ""), ) ) TicketMessageTimelineItem.objects.bulk_create(items) def _mark_analysis_failed( draft: TicketDraft, *, error_code: str, error_message: str, run: AgentRun | None = None, ) -> None: draft.status = "failed" draft.error_code = error_code draft.error_message = error_message draft.revision += 1 draft.save() if run is not None: run.status = "failed" run.progress_stage = "failed" run.progress_message = error_message[:160] run.error_code = error_code run.error_message = error_message run.finished_at = run.finished_at or timezone.now() run.save( update_fields=[ "status", "progress_stage", "progress_message", "error_code", "error_message", "finished_at", ] ) append_run_event( run, "failed", {"error_code": error_code, "error_message": error_message}, ) sync_ticket_intake_failure_work_item(draft, run=run, actor=draft.created_by) audit_draft(draft, "draft_analysis_failed", "failed") def _mark_analysis_cancelled(draft: TicketDraft, run: AgentRun) -> None: changed = ( draft.status != "failed" or draft.error_code != "analysis_cancelled" or draft.error_message != "智能受理已取消,原始材料和已填字段均已保留" ) draft.status = "failed" draft.error_code = "analysis_cancelled" draft.error_message = "智能受理已取消,原始材料和已填字段均已保留" if changed: draft.revision += 1 draft.save() if run.status != "cancelled": run.status = "cancelled" run.progress_stage = "cancelled" run.progress_message = "运行已取消" run.finished_at = run.finished_at or timezone.now() run.save( update_fields=[ "status", "progress_stage", "progress_message", "finished_at", ] ) append_run_event(run, "cancelled", {"message": "运行已取消"}) sync_ticket_intake_failure_work_item(draft, run=run, actor=draft.created_by) audit_draft(draft, "draft_analysis_cancelled", "cancelled") def _copy_materials_to_ticket(draft: TicketDraft, ticket, user) -> None: """复制材料并创建附件;任一对象失败时回收本次已复制对象。""" copied_paths = [] try: for material in draft.materials.all(): source_path = material.storage_path target_path = f"tickets/{ticket.id}/{material.public_id}-{material.file_name}" copy_object(source_path, target_path) copied_paths.append(target_path) attachment = TicketAttachment.objects.create( ticket=ticket, file_name=material.file_name, file_type=material.material_type, mime_type=material.mime_type, file_size=material.file_size, storage_path=target_path, sha256=material.sha256, scan_status=material.scan_status, scan_result=material.scan_result, parse_status=material.parse_status, parse_error=material.parse_error, parsed_text=material.parsed_text, created_by=user, ) material.status = "attached" material.confirmed_attachment = attachment material.storage_path = target_path material.save(update_fields=["status", "confirmed_attachment", "storage_path"]) # 临时对象只能在数据库提交后删除,回滚时仍要保留原始材料供重试或人工接管。 transaction.on_commit(lambda path=source_path: delete_object(path), robust=True) except Exception: for copied_path in copied_paths: try: delete_object(copied_path) except Exception: pass raise @transaction.atomic def _auto_create_ticket( draft_id: int, run: AgentRun, *, fields: dict, field_evidence: dict, confidence_by_field: dict, human_field_names: set[str], ) -> TicketDraft: """在行锁内幂等创建并关联唯一正式工单。""" # Agent/Celery 重试可能并发进入;锁定受理记录后再判断是否已经完成建单。 draft = ( TicketDraft.objects.select_for_update(of=("self",)) .select_related("created_by", "confirmed_ticket") .prefetch_related("materials") .get(id=draft_id) ) if draft.confirmed_ticket_id: if draft.status not in {"ticket_created", "confirmed"}: draft.status = "ticket_created" draft.revision += 1 draft.save(update_fields=["status", "revision", "updated_at"]) TicketMessageTimelineItem.objects.filter(draft=draft, ticket__isnull=True).update( ticket_id=draft.confirmed_ticket_id ) complete_ticket_intake_failure_work_item(draft, actor=draft.created_by) return draft ticket, service_missing_fields = create_ticket_from_intake( fields, draft.created_by, agent_run_id=run.id, evidence_by_field=field_evidence, confidence_by_field=confidence_by_field, human_field_names=human_field_names, reported_missing_fields=draft.missing_fields, ) _copy_materials_to_ticket(draft, ticket, draft.created_by) combined_missing_fields = sorted( set(draft.missing_fields) | set(service_missing_fields) ) draft.confirmed_ticket = ticket draft.missing_fields = combined_missing_fields draft.status = "ticket_created" draft.decision = "auto_created" draft.confirmed_at = timezone.now() draft.revision += 1 draft.save() TicketMessageTimelineItem.objects.filter(draft=draft).update(ticket=ticket) sync_ticket_missing_info_work_item( ticket, combined_missing_fields, questions=draft.questions, draft_public_id=str(draft.public_id), actor=draft.created_by, ) TicketEvent.objects.create( ticket=ticket, event_type="ai_intake_created", to_value={ "draft_public_id": str(draft.public_id), "agent_run_public_id": str(run.public_id), "missing_fields": combined_missing_fields, }, operator=draft.created_by, source=TicketActorSource.AGENT, agent_run_id=run.id, ) audit_draft(draft, "draft_auto_created") complete_ticket_intake_failure_work_item(draft, actor=draft.created_by) return draft def execute_draft_analysis(draft_id: int, run_id: int) -> None: """执行一次受理分析,并把成功、补充信息、取消和失败统一落回受理状态。""" draft = ( TicketDraft.objects.select_related("created_by") .prefetch_related("materials") .get(id=draft_id) ) run = AgentRun.objects.select_related("agent_version__model_deployment__provider").get( id=run_id ) if run.cancellation_requested_at or run.status == "cancelled": _mark_analysis_cancelled(draft, run) return if draft.confirmed_ticket_id: if run.status in {"queued", "running"}: run.status = "succeeded" run.progress_stage = "completed" run.progress_message = "工单已创建,已跳过重复执行" run.finished_at = timezone.now() run.save( update_fields=[ "status", "progress_stage", "progress_message", "finished_at", ] ) append_run_event(run, "completed", {"ticket_id": draft.confirmed_ticket_id}) return append_run_event(run, "parsing", {"message": "正在解析受理材料"}) image_parts = [] if draft.materials.filter(material_type="image", scan_status="clean").exists(): vision_deployments = [ deployment for deployment in _effective_step_deployments(run.agent_version) if deployment.capability == "vision_chat" ] if not vision_deployments or not all( deployment.enabled and deployment.provider.enabled and deployment.provider.image_egress_approved for deployment in vision_deployments ): _mark_analysis_failed( draft, error_code="vision_not_allowed", error_message="当前模型或渠道不允许图片输入", run=run, ) return image_parts = _image_parts(draft) append_run_event(run, "extracting", {"message": "正在提取问题与工单字段"}) execute_agent_run(run.id, image_parts=image_parts) run.refresh_from_db() draft.refresh_from_db() if run.status == "cancelled": _mark_analysis_cancelled(draft, run) return if run.status != "succeeded" or not isinstance(run.final_output, dict): _mark_analysis_failed( draft, error_code=run.error_code or "analysis_failed", error_message=run.error_message or "AI 分析失败,请重试或转为普通表单", run=run, ) 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) ): _mark_analysis_failed( draft, error_code="invalid_artifact", error_message="AI 产物结构不完整", run=run, ) return fields = {code: value for code, value in fields.items() if code in ALLOWED_FIELD_CODES} fields = { code: value.replace("\r", " ").replace("\n", " ").strip() if isinstance(value, str) else value for code, value in fields.items() } fields["tags"] = normalize_tags(fields.get("tags")) for code in BUSINESS_TIME_FIELDS: raw_value = fields.get(code) normalized_datetime = normalize_business_datetime(raw_value) fields[code] = normalized_datetime.isoformat() if normalized_datetime else "" if raw_value and normalized_datetime is None and code not in missing_fields: missing_fields.append(code) missing_fields = [str(item) for item in missing_fields if str(item) in ALLOWED_FIELD_CODES] source_corpus = " ".join( [ draft.source_text, *draft.messages.values_list("content", flat=True), *draft.materials.filter( scan_status="clean", parse_status="parsed" ).values_list("parsed_text", flat=True), ] ) if not draft.materials.filter(material_type="image", scan_status="clean").exists(): for code in PROVENANCE_FIELD_CODES: value = fields.get(code) if isinstance(value, str) and value and value not in source_corpus: fields[code] = "" if code not in missing_fields: missing_fields.append(code) # A guided re-analysis must not silently overwrite fields that a person changed. manual_fields = { code: value for code, value in draft.current_fields.items() if code in ALLOWED_FIELD_CODES and draft.last_ai_fields.get(code) != value } fields.update(manual_fields) missing_fields = [code for code in missing_fields if not fields.get(code)] questions = [str(item).replace("\r", " ").replace("\n", " ").strip() for item in questions] field_evidence, confidence_by_field = _normalize_field_evidence( run.final_output.get("field_evidence") ) message_timeline = _normalize_message_timeline(run.final_output.get("message_timeline")) _replace_message_timeline(draft, message_timeline) problem_valid_value = run.final_output.get("problem_valid") problem_valid = ( problem_valid_value if isinstance(problem_valid_value, bool) else bool(str(fields.get("description") or "").strip()) ) invalid_reason = str(run.final_output.get("invalid_reason") or "").strip() if not str(fields.get("description") or "").strip(): problem_valid = False invalid_reason = invalid_reason or "尚未形成有效问题描述" artifact = AgentArtifact.objects.create( run=run, artifact_type="ticket_draft", schema_version=2, payload={ "draft_public_id": str(draft.public_id), "problem_valid": problem_valid, "invalid_reason": invalid_reason, "fields": fields, "field_evidence": field_evidence, "message_timeline": message_timeline, "missing_fields": missing_fields, "questions": questions, }, created_by=draft.created_by, ) draft.current_fields = fields draft.last_ai_fields = fields draft.problem_valid = problem_valid draft.invalid_reason = invalid_reason draft.field_evidence = field_evidence draft.message_timeline = message_timeline draft.missing_fields = missing_fields draft.questions = questions draft.latest_artifact = artifact draft.status = "needs_input" draft.error_code = "" draft.error_message = "" draft.revision += 1 draft.save() audit_draft(draft, "draft_analysis_succeeded") complete_ticket_intake_failure_work_item(draft, actor=draft.created_by) if not problem_valid: append_run_event( run, "needs_input", {"invalid_reason": invalid_reason, "questions": questions}, ) audit_draft(draft, "draft_needs_input") return try: append_run_event(run, "creating_ticket", {"message": "正在创建正式工单"}) _auto_create_ticket( draft.id, run, fields=fields, field_evidence=field_evidence, confidence_by_field=confidence_by_field, human_field_names=set(manual_fields), ) draft.refresh_from_db(fields=["confirmed_ticket"]) append_run_event( run, "completed", {"ticket_id": draft.confirmed_ticket_id, "message": "正式工单已创建"}, ) except Exception: draft.refresh_from_db() _mark_analysis_failed( draft, error_code="ticket_creation_failed", error_message="自动建单未完成,原始材料已保留,请重试或转为人工建单", run=run, ) @transaction.atomic def cancel_draft_analysis( draft_public_id, expected_revision: int, user, ) -> TicketDraft: draft = ( TicketDraft.objects.select_for_update(of=("self",)) .select_related("latest_agent_run", "created_by") .get(public_id=draft_public_id, created_by=user) ) assert_revision(draft, expected_revision) if draft.status != "analyzing" or draft.latest_agent_run_id is None: raise TicketDraftError(400, "当前受理记录没有可取消的运行") run = AgentRun.objects.select_for_update().get(id=draft.latest_agent_run_id) if run.status not in {"queued", "running"}: if run.status == "cancelled": _mark_analysis_cancelled(draft, run) return draft raise TicketDraftError(400, "当前运行已经结束") run.cancellation_requested_at = timezone.now() run.progress_stage = "cancelling" run.progress_message = "正在取消智能受理" run_update_fields = [ "cancellation_requested_at", "progress_stage", "progress_message", ] if run.status == "queued": run.status = "cancelled" run.progress_stage = "cancelled" run.progress_message = "运行已取消" run.finished_at = timezone.now() run_update_fields.extend(["status", "finished_at"]) run.save(update_fields=run_update_fields) if run.status == "cancelled": append_run_event(run, "cancelled", {"message": "运行已取消"}) else: append_run_event( run, "cancellation_requested", {"message": "已请求取消,当前模型调用结束后停止"}, ) _mark_analysis_cancelled(draft, run) if run.task_id: task_id = run.task_id transaction.on_commit(lambda: AsyncResult(task_id).revoke(terminate=False)) return draft @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("materials") .get(public_id=draft_public_id, created_by=user) ) if draft.confirmed_ticket_id: return draft assert_revision(draft, expected_revision) if draft.status not in {"needs_input", "ready", "failed"}: 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" _copy_materials_to_ticket(draft, ticket, user) draft.confirmed_ticket = ticket draft.status = "ticket_created" draft.decision = decision draft.confirmed_at = timezone.now() draft.revision += 1 draft.save() TicketMessageTimelineItem.objects.filter(draft=draft).update(ticket=ticket) 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") complete_ticket_intake_failure_work_item(draft, actor=user) 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") complete_ticket_intake_failure_work_item(draft, actor=user, cancelled=True) 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"])