1073 lines
38 KiB
Python
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()
|
|
.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()
|
|
.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"])
|