Files
ai-ticket-platform/backend/apps/tickets/draft_services.py

1073 lines
38 KiB
Python

"""智能受理编排服务。
负责把临时材料、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"])