feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

@@ -1,16 +1,24 @@
"""Agent 版本校验、运行快照和多步骤执行编排。
运行时只使用已冻结的版本配置,确保模型、Skill、输入映射和授权动作可以事后还原。
"""
import json
import re
from copy import deepcopy
from time import monotonic
from typing import Any
from django.conf import settings
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from apps.agents.models import (
AgentDefinition,
AgentRun,
AgentRunAttempt,
AgentRunEvent,
AgentRunStep,
AgentStepBinding,
AgentVersion,
@@ -34,10 +42,70 @@ class AgentValidationError(ValueError):
pass
@transaction.atomic
def append_run_event(
run: AgentRun | int,
event_type: str,
payload: dict | None = None,
) -> AgentRunEvent:
run_id = run.id if isinstance(run, AgentRun) else run
locked_run = AgentRun.objects.select_for_update().get(id=run_id)
if event_type in {"completed", "failed", "cancelled", "timed_out"}:
existing = locked_run.events.order_by("-sequence").first()
if existing is not None and existing.event_type == event_type:
return existing
last_sequence = locked_run.events.aggregate(value=Max("sequence"))["value"] or 0
event_payload = {
"run_status": locked_run.status,
"progress_stage": locked_run.progress_stage,
"progress_message": locked_run.progress_message,
**(payload or {}),
}
return AgentRunEvent.objects.create(
run=locked_run,
sequence=last_sequence + 1,
event_type=event_type,
payload=event_payload,
)
def _set_run_progress(run: AgentRun, stage: str, message: str) -> None:
run.progress_stage = stage
run.progress_message = message
run.save(update_fields=["progress_stage", "progress_message"])
event_type = stage
if stage == "completed" and run.input_payload.get("draft_public_id"):
event_type = "extraction_completed"
append_run_event(
run,
event_type,
{"current_step": run.current_step, "message": message},
)
def _step_progress(code: str, name: str) -> tuple[str, str]:
if code == "facts":
return "extracting", "正在解析沟通记录"
if code == "draft":
return "generating", "正在生成工单草稿"
return "executing", f"正在执行:{name}"
def validate_agent_version(version: AgentVersion, *, publishing: bool = False) -> None:
"""校验步骤顺序、模型能力、Skill 状态、输入映射和授权动作。"""
validate_json_schema(version.input_schema, "Agent 输入 Schema")
if version.example_input:
validate_payload(version.input_schema, version.example_input, "Agent 示例输入")
if (
not isinstance(version.allowed_actions, list)
or any(
not isinstance(action, str) or not action.strip()
for action in version.allowed_actions
)
or len(set(version.allowed_actions)) != len(version.allowed_actions)
):
raise AgentValidationError("Agent 授权动作必须是无重复的非空字符串列表")
if version.temperature_override is not None and not 0 <= version.temperature_override <= 2:
raise AgentValidationError("Temperature 必须在 0 到 2 之间")
if (
@@ -45,13 +113,26 @@ def validate_agent_version(version: AgentVersion, *, publishing: bool = False) -
and not 1 <= version.max_output_tokens_override <= 32768
):
raise AgentValidationError("最大输出 Token 超出允许范围")
steps = list(version.steps.select_related("skill_version__definition"))
steps = list(
version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
"agent_version__model_deployment__provider",
)
)
if not steps:
raise AgentValidationError("Agent 至少需要一个 Skill 步骤")
if len(steps) > 10:
raise AgentValidationError("Agent 最多允许 10 个步骤")
known_steps: set[str] = set()
for step in steps:
deployment = step.model_deployment or version.model_deployment
if deployment.capability not in {"text_chat", "vision_chat"}:
raise AgentValidationError(
f"步骤 {step.code} 必须绑定文本或视觉模型,当前为 {deployment.capability}"
)
if publishing and (not deployment.enabled or not deployment.provider.enabled):
raise AgentValidationError(f"步骤 {step.code} 绑定的模型部署未启用")
if publishing and step.skill_version.status != "published":
raise AgentValidationError(f"步骤 {step.code} 必须绑定已发布 Skill")
for target, source in step.input_mapping.items():
@@ -110,6 +191,7 @@ def ensure_agent_draft(definition: AgentDefinition, user=None) -> AgentVersion:
system_prompt=source.system_prompt,
input_schema=deepcopy(source.input_schema),
example_input=deepcopy(source.example_input),
allowed_actions=deepcopy(source.allowed_actions),
temperature_override=source.temperature_override,
max_output_tokens_override=source.max_output_tokens_override,
created_by=user,
@@ -121,6 +203,7 @@ def ensure_agent_draft(definition: AgentDefinition, user=None) -> AgentVersion:
code=step.code,
order=step.order,
skill_version=step.skill_version,
model_deployment=step.model_deployment,
input_mapping=deepcopy(step.input_mapping),
)
for step in source.steps.all()
@@ -130,6 +213,8 @@ def ensure_agent_draft(definition: AgentDefinition, user=None) -> AgentVersion:
def build_config_snapshot(version: AgentVersion) -> dict:
"""冻结一次运行所需的 Agent、模型、Skill、映射和策略配置。"""
deployment = version.model_deployment
return {
"agent": {
@@ -137,6 +222,7 @@ def build_config_snapshot(version: AgentVersion) -> dict:
"code": version.definition.code,
"version": version.version,
"status": version.status,
"allowed_actions": deepcopy(version.allowed_actions),
},
"model": {
"deployment_id": deployment.id,
@@ -155,12 +241,102 @@ def build_config_snapshot(version: AgentVersion) -> dict:
"skill_code": step.skill_version.definition.code,
"skill_version": step.skill_version.version,
"input_mapping": step.input_mapping,
"model": {
"deployment_id": (
step.model_deployment_id or version.model_deployment_id
),
"inherited": step.model_deployment_id is None,
"provider_name": (
step.model_deployment.provider.name
if step.model_deployment_id
else deployment.provider.name
),
"model_name": (
step.model_deployment.model_name
if step.model_deployment_id
else deployment.model_name
),
"capability": (
step.model_deployment.capability
if step.model_deployment_id
else deployment.capability
),
},
}
for step in version.steps.select_related("skill_version__definition")
for step in version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
)
],
}
def queue_agent_run(
version: AgentVersion,
input_payload: dict,
*,
created_by=None,
sandbox: bool = False,
config_snapshot_extra: dict | None = None,
defer_until_commit: bool = False,
) -> AgentRun:
"""创建运行及初始事件,并按事务边界决定何时投递 Celery 任务。"""
validate_agent_version(version, publishing=not sandbox)
validate_payload(version.input_schema, input_payload, "Agent 输入")
config_snapshot = build_config_snapshot(version)
if config_snapshot_extra:
config_snapshot.update(deepcopy(config_snapshot_extra))
run = AgentRun.objects.create(
definition=version.definition,
agent_version=version,
sandbox=sandbox,
input_payload=deepcopy(input_payload),
config_snapshot=config_snapshot,
total_steps=version.steps.count(),
created_by=created_by,
)
append_run_event(run, "queued", {"message": "Agent 运行已进入处理队列"})
def dispatch() -> None:
from apps.agents.tasks import execute_agent_run
try:
result = execute_agent_run.delay(run.id)
except Exception as error:
run.status = "failed"
run.progress_stage = "failed"
run.progress_message = "Agent 运行入队失败"
run.error_code = "queue_failed"
run.error_message = str(error)
run.finished_at = timezone.now()
run.save(
update_fields=[
"status",
"progress_stage",
"progress_message",
"error_code",
"error_message",
"finished_at",
]
)
append_run_event(
run,
"failed",
{"error_code": run.error_code, "error_message": run.error_message},
)
return
run.task_id = result.id or ""
run.save(update_fields=["task_id"])
if defer_until_commit:
transaction.on_commit(dispatch)
else:
dispatch()
run.refresh_from_db()
return run
def _parse_json_output(raw_output: str) -> Any:
value = raw_output.strip()
if value.startswith("```"):
@@ -172,6 +348,83 @@ def _parse_json_output(raw_output: str) -> Any:
raise SkillValidationError(f"模型输出不是合法 JSON: {error.msg}") from error
def _empty_schema_value(schema: dict) -> Any:
value_type = schema.get("type")
if value_type == "string" and (not schema.get("enum") or "" in schema["enum"]):
return ""
if value_type == "array":
return []
if value_type == "object":
return {}
return None
def _complete_object_shape(value: dict, schema: dict) -> dict:
completed = dict(value)
properties = schema.get("properties", {})
for code in schema.get("required", []):
if code in completed:
continue
empty_value = _empty_schema_value(properties.get(code, {}))
if empty_value is not None:
completed[code] = empty_value
return completed
def _normalize_nested_output(output: Any, output_schema: dict) -> Any:
"""Repair common flattened model output without another model round-trip."""
if not isinstance(output, dict):
return output
properties = output_schema.get("properties", {})
required = set(output_schema.get("required", []))
if {"facts", "evidence", "missing_fields"}.issubset(required):
fact_schema = properties.get("facts", {})
fact_codes = set(fact_schema.get("properties", {}))
nested_facts = output.get("facts")
if isinstance(nested_facts, dict):
facts = _complete_object_shape(nested_facts, fact_schema)
elif fact_codes.intersection(output):
facts = _complete_object_shape(
{code: output[code] for code in fact_codes if code in output},
fact_schema,
)
else:
facts = None
if facts is not None:
evidence = _complete_object_shape(
output.get("evidence", {}) if isinstance(output.get("evidence"), dict) else {},
properties.get("evidence", {}),
)
missing = list(output.get("missing_fields", []))
for code, value in facts.items():
if not value and code not in missing:
missing.append(code)
return {"facts": facts, "evidence": evidence, "missing_fields": missing}
if {"fields", "missing_fields", "questions"}.issubset(required):
field_schema = properties.get("fields", {})
field_codes = set(field_schema.get("properties", {}))
nested_fields = output.get("fields")
if isinstance(nested_fields, dict):
fields = _complete_object_shape(nested_fields, field_schema)
elif field_codes.intersection(output):
fields = _complete_object_shape(
{code: output[code] for code in field_codes if code in output},
field_schema,
)
else:
fields = None
if fields is not None:
questions = output.get("questions", output.get("follow_up_questions", []))
return {
"fields": fields,
"missing_fields": list(output.get("missing_fields", [])),
"questions": list(questions) if isinstance(questions, list) else [],
}
return output
def _record_attempt(
step: AgentRunStep,
invocation: ModelInvocation,
@@ -199,34 +452,58 @@ def _invoke_for_step(
image_parts: list[dict] | None = None,
) -> ModelInvocation:
version = run.agent_version
deployment = step.model_deployment or version.model_deployment
messages = []
if version.system_prompt:
messages.append({"role": "system", "content": version.system_prompt})
user_content: str | list[dict] = prompt
if image_parts:
user_content = [{"type": "text", "text": prompt}, *image_parts]
step_image_parts = None
if image_parts and deployment.capability == "vision_chat":
if not deployment.provider.image_egress_approved:
raise AgentValidationError(
f"步骤 {step.code} 的模型渠道未授权图片出境"
)
step_image_parts = image_parts
if step_image_parts:
user_content = [{"type": "text", "text": prompt}, *step_image_parts]
messages.append({"role": "user", "content": user_content})
snapshot_messages = [
{"role": item["role"], "content": prompt if item["role"] == "user" else item["content"]}
for item in messages
]
invocation = ModelInvocation.objects.create(
deployment=version.model_deployment,
deployment=deployment,
invocation_type="agent_step",
input_snapshot={
"messages": snapshot_messages,
"image_refs": run.input_payload.get("image_refs", []) if image_parts else [],
"image_refs": (
run.input_payload.get("image_refs", []) if step_image_parts else []
),
"run_id": str(run.public_id),
"step": step.code,
},
created_by=run.created_by,
)
if attempt_type == "structure_repair":
_set_run_progress(run, "repairing", "正在修正结构化结果")
def mark_retry(retry_number: int, _error_code: str) -> None:
run.retry_count += 1
run.progress_stage = "retrying"
run.progress_message = f"模型响应暂时不稳定,正在进行第 {retry_number} 次重试"
run.save(update_fields=["retry_count", "progress_stage", "progress_message"])
execute_invocation(
invocation,
messages,
temperature=version.temperature_override,
max_output_tokens=version.max_output_tokens_override,
on_retry=mark_retry,
total_timeout_seconds=settings.AI_AGENT_MODEL_TIMEOUT_SECONDS,
)
if invocation.status == "succeeded":
_set_run_progress(run, "validating", "正在校验结构化结果")
_record_attempt(step, invocation, attempt_type)
if invocation.retry_count:
AgentRunAttempt.objects.create(
@@ -264,6 +541,8 @@ def _repair_output(
def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) -> AgentRun:
"""按冻结配置顺序执行所有步骤,记录尝试、产物、进度和终态事件。"""
run = AgentRun.objects.select_related(
"definition",
"agent_version__model_deployment__provider",
@@ -272,27 +551,41 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
started = monotonic()
run.status = "running"
run.started_at = timezone.now()
run.save(update_fields=["status", "started_at"])
run.progress_stage = "starting"
run.progress_message = "正在准备 Agent 运行"
run.save(update_fields=["status", "started_at", "progress_stage", "progress_message"])
append_run_event(run, "starting", {"message": run.progress_message})
step_outputs: dict[str, dict] = {}
try:
bindings = list(
run.agent_version.steps.select_related(
"skill_version__definition",
"model_deployment__provider",
"agent_version__model_deployment__provider",
)
)
for binding in bindings:
run.refresh_from_db(fields=["cancellation_requested_at"])
if run.cancellation_requested_at:
run.status = "cancelled"
_set_run_progress(run, "cancelled", "运行已取消")
break
run.current_step = binding.code
run.save(update_fields=["current_step"])
progress_stage, progress_message = _step_progress(
binding.code,
binding.skill_version.definition.name,
)
_set_run_progress(run, progress_stage, progress_message)
step = AgentRunStep.objects.create(
run=run,
code=binding.code,
name=binding.skill_version.definition.name,
order=binding.order,
skill_version=binding.skill_version,
model_deployment=(
binding.model_deployment or run.agent_version.model_deployment
),
status="running",
started_at=timezone.now(),
)
@@ -312,6 +605,10 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
raise AgentValidationError(invocation.error_message or "模型调用失败")
try:
output = _parse_json_output(invocation.output_text)
output = _normalize_nested_output(
output,
binding.skill_version.output_schema,
)
validate_payload(binding.skill_version.output_schema, output, "模型输出")
except SkillValidationError as error:
repair = _repair_output(
@@ -326,6 +623,10 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
repair.error_message or "结构修复失败"
) from error
output = _parse_json_output(repair.output_text)
output = _normalize_nested_output(
output,
binding.skill_version.output_schema,
)
validate_payload(binding.skill_version.output_schema, output, "修复输出")
step.output_payload = output
step.status = "succeeded"
@@ -337,6 +638,7 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
run.status = "failed"
run.error_code = "step_failed"
run.error_message = f"步骤 {binding.code} 失败: {error}"
_set_run_progress(run, "failed", "Agent 运行失败")
finally:
attempts = step.attempts.all()
step.input_tokens = sum(item.input_tokens for item in attempts)
@@ -349,14 +651,36 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
run.refresh_from_db(fields=["cancellation_requested_at"])
if run.cancellation_requested_at:
run.status = "cancelled"
_set_run_progress(run, "cancelled", "运行已取消")
break
if run.status == "running":
run.status = "succeeded"
run.final_output = step.output_payload if bindings else None
try:
from apps.tickets.support_analysis import validate_support_analysis_output
validate_support_analysis_output(run, run.final_output)
from apps.knowledge.distillation import (
validate_knowledge_distillation_output,
)
validate_knowledge_distillation_output(run, run.final_output)
except ValueError as error:
run.status = "failed"
run.error_code = (
"untrusted_support_output"
if run.definition.code == "support-analysis"
else "untrusted_knowledge_distillation_output"
)
run.error_message = str(error)
_set_run_progress(run, "failed", "Agent 结果未通过业务信任校验")
else:
run.status = "succeeded"
_set_run_progress(run, "completed", "草稿生成完成")
except Exception as error:
run.status = "failed"
run.error_code = "runtime_error"
run.error_message = "Agent 运行发生未预期错误"
_set_run_progress(run, "failed", "Agent 运行失败")
OperationAuditLog.objects.create(
user=run.created_by,
module="agents",
@@ -371,6 +695,75 @@ def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) ->
run.duration_ms = max(int((monotonic() - started) * 1000), 0)
run.finished_at = timezone.now()
run.save()
if run.definition.code == "support-analysis":
from apps.workitems.services import sync_support_analysis_work_item
if run.status == "succeeded":
try:
from apps.tickets.support_analysis import process_support_analysis_result
process_support_analysis_result(run)
except Exception as error:
OperationAuditLog.objects.create(
user=run.created_by,
module="tickets",
action="support_post_processing_failed",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"error_type": type(error).__name__},
result="failed",
)
sync_support_analysis_work_item(
run,
error_code="post_processing_failed",
error_message="支持分析结果后处理失败,请人工接管",
)
else:
sync_support_analysis_work_item(run)
else:
sync_support_analysis_work_item(run)
if run.definition.code == "knowledge-distillation":
from apps.knowledge.models import TicketKnowledgeDistillation
from apps.workitems.services import sync_knowledge_distillation_work_item
try:
distillation = run.ticket_knowledge_distillation
except TicketKnowledgeDistillation.DoesNotExist:
distillation = None
if distillation is not None:
if run.status == "succeeded":
try:
from apps.knowledge.distillation import (
process_knowledge_distillation_result,
)
process_knowledge_distillation_result(run)
except Exception as error:
OperationAuditLog.objects.create(
user=run.created_by,
module="knowledge",
action="knowledge_distillation_post_processing_failed",
target_type="agent_run",
target_id=str(run.public_id),
request_snapshot={"error_type": type(error).__name__},
result="failed",
)
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
error_code="post_processing_failed",
error_message="知识蒸馏结果后处理失败,请人工接管",
)
else:
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
)
else:
sync_knowledge_distillation_work_item(
distillation.ticket,
run=run,
)
OperationAuditLog.objects.create(
user=run.created_by,
module="agents",
@@ -395,6 +788,7 @@ def replace_agent_steps(version: AgentVersion, steps: list[dict]) -> None:
code=item["code"],
order=index + 1,
skill_version_id=item["skill_version_id"],
model_deployment_id=item.get("model_deployment_id"),
input_mapping=item.get("input_mapping", {}),
)
for index, item in enumerate(steps)