705 lines
26 KiB
Python
705 lines
26 KiB
Python
"""技术支持分析闭环。
|
|
|
|
本模块冻结检索引用,校验 Agent 输出,并在策略和权限共同允许时执行受控自动解决。
|
|
"""
|
|
|
|
import re
|
|
from copy import deepcopy
|
|
from difflib import SequenceMatcher
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.agents.models import AgentDefinition, AgentRun, AgentVersion
|
|
from apps.agents.services import queue_agent_run
|
|
from apps.audit.models import OperationAuditLog
|
|
from apps.knowledge.embeddings import KnowledgeEmbeddingError
|
|
from apps.knowledge.models import KnowledgeItem
|
|
from apps.knowledge.services import retrieve_knowledge
|
|
from apps.solutions.models import Solution
|
|
from apps.tickets.models import Ticket, TicketActorSource, TicketStatus, TicketSupportAnalysis
|
|
from apps.tickets.replies import ensure_customer_reply_task
|
|
from apps.tickets.services import get_allowed_transitions
|
|
from apps.tickets.support_contract import (
|
|
SUPPORT_OUTPUT_FIELDS,
|
|
missing_support_output_fields,
|
|
)
|
|
|
|
SUPPORT_AGENT_CODE = "support-analysis"
|
|
RETRIEVAL_POLICY_VERSION = "published_scope_hybrid_rerank_v2"
|
|
|
|
|
|
class SupportAnalysisError(ValueError):
|
|
pass
|
|
|
|
|
|
def _normalize(value: str) -> str:
|
|
return re.sub(r"\s+", " ", value.casefold()).strip()
|
|
|
|
|
|
def _terms(value: str) -> set[str]:
|
|
normalized = _normalize(value)
|
|
words = set(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}|[\u4e00-\u9fff]{2,}", normalized))
|
|
chinese = "".join(re.findall(r"[\u4e00-\u9fff]", normalized))
|
|
words.update(chinese[index : index + 2] for index in range(max(len(chinese) - 1, 0)))
|
|
return {term for term in words if term}
|
|
|
|
|
|
def _text_score(query: str, content: str) -> float:
|
|
query_terms = _terms(query)
|
|
content_terms = _terms(content)
|
|
keyword_score = (
|
|
len(query_terms & content_terms) / len(query_terms | content_terms)
|
|
if query_terms and content_terms
|
|
else 0.0
|
|
)
|
|
title_score = SequenceMatcher(None, _normalize(query[:200]), _normalize(content[:200])).ratio()
|
|
return min(keyword_score * 0.7 + title_score * 0.3, 1.0)
|
|
|
|
|
|
def _excerpt(content: str, query: str, limit: int = 240) -> str:
|
|
normalized = content.strip()
|
|
if len(normalized) <= limit:
|
|
return normalized
|
|
terms = sorted(_terms(query), key=len, reverse=True)
|
|
lowered = normalized.casefold()
|
|
position = next((lowered.find(term) for term in terms if term in lowered), 0)
|
|
start = max(position - limit // 3, 0)
|
|
end = min(start + limit, len(normalized))
|
|
return f"{'…' if start else ''}{normalized[start:end]}{'…' if end < len(normalized) else ''}"
|
|
|
|
|
|
def build_ticket_support_query(ticket: Ticket) -> str:
|
|
return "\n".join(
|
|
value
|
|
for value in [
|
|
ticket.title,
|
|
ticket.description,
|
|
ticket.problem_performance,
|
|
ticket.error_information,
|
|
ticket.related_system,
|
|
ticket.product_name,
|
|
ticket.module_name,
|
|
ticket.environment,
|
|
ticket.attempted_actions,
|
|
ticket.customer_request,
|
|
*ticket.tags,
|
|
]
|
|
if value
|
|
).strip()
|
|
|
|
|
|
def _scope_matches(
|
|
ticket: Ticket,
|
|
*,
|
|
category_id: int | None = None,
|
|
category_code: str = "",
|
|
related_system: str = "",
|
|
environment: str = "",
|
|
) -> bool:
|
|
if ticket.category_id and category_id and ticket.category_id != category_id:
|
|
return False
|
|
if category_code and ticket.category_id:
|
|
if _normalize(category_code) != _normalize(ticket.category.code):
|
|
return False
|
|
if related_system and ticket.related_system:
|
|
if _normalize(related_system) != _normalize(ticket.related_system):
|
|
return False
|
|
if environment and ticket.environment:
|
|
if _normalize(environment) != _normalize(ticket.environment):
|
|
return False
|
|
return True
|
|
|
|
|
|
def retrieve_support_references(ticket: Ticket, *, limit: int = 8) -> tuple[str, list[dict]]:
|
|
"""检索并冻结支持分析可引用的知识、方案和历史工单候选。"""
|
|
|
|
query = build_ticket_support_query(ticket)
|
|
if not query:
|
|
raise SupportAnalysisError("工单缺少可用于检索的问题信息")
|
|
candidates = []
|
|
try:
|
|
hybrid_results = retrieve_knowledge(
|
|
query,
|
|
limit=max(limit * 8, 64),
|
|
published_only=True,
|
|
scope={
|
|
"category_code": ticket.category.code if ticket.category_id else "",
|
|
"related_system": ticket.related_system,
|
|
"environment": ticket.environment,
|
|
},
|
|
)
|
|
except KnowledgeEmbeddingError as error:
|
|
raise SupportAnalysisError(str(error)) from error
|
|
hybrid_by_solution = {
|
|
item["metadata"].get("solution_id"): item
|
|
for item in hybrid_results
|
|
if item["metadata"].get("solution_id")
|
|
}
|
|
hybrid_by_item = {
|
|
item["metadata"].get("item_id"): item
|
|
for item in hybrid_results
|
|
if item["metadata"].get("item_id")
|
|
}
|
|
hybrid_by_history = {
|
|
item["metadata"].get("historical_ticket_id"): item
|
|
for item in hybrid_results
|
|
if item["metadata"].get("historical_ticket_id")
|
|
}
|
|
|
|
solutions = Solution.objects.filter(status="published").select_related("category")
|
|
for solution in solutions[:500]:
|
|
if not _scope_matches(
|
|
ticket,
|
|
category_id=solution.category_id,
|
|
related_system=solution.related_system,
|
|
environment=solution.environment_scope,
|
|
):
|
|
continue
|
|
content = "\n".join(
|
|
[
|
|
solution.title,
|
|
solution.problem_signature,
|
|
solution.root_cause,
|
|
solution.solution,
|
|
solution.risk_note,
|
|
]
|
|
)
|
|
hybrid_result = hybrid_by_solution.get(solution.id)
|
|
score = (
|
|
float(hybrid_result["score"])
|
|
if hybrid_result is not None
|
|
else _text_score(query, content)
|
|
)
|
|
if score <= 0:
|
|
continue
|
|
candidates.append(
|
|
{
|
|
"source_type": "solution",
|
|
"source_id": solution.id,
|
|
"source_version": solution.updated_at.isoformat(),
|
|
"title": solution.title,
|
|
"excerpt": _excerpt(content, query),
|
|
"score": score,
|
|
"applicable_scope": {
|
|
"category_code": solution.category.code if solution.category_id else None,
|
|
"related_system": solution.related_system,
|
|
"environment": solution.environment_scope,
|
|
},
|
|
"auto_resolve_policy": {
|
|
"allow_auto_resolve": solution.allow_auto_resolve,
|
|
"requires_human_action": solution.requires_human_action,
|
|
"requires_external_confirmation": solution.requires_external_confirmation,
|
|
"requires_production_change": solution.requires_production_change,
|
|
},
|
|
"retrieval": hybrid_result["metadata"]["retrieval"]
|
|
if hybrid_result is not None
|
|
else {
|
|
"channels": ["unindexed_text_fallback"],
|
|
"text_score": round(score, 4),
|
|
"vector_score": 0.0,
|
|
"scope_score": 1.0,
|
|
},
|
|
}
|
|
)
|
|
|
|
items = KnowledgeItem.objects.filter(
|
|
status="published",
|
|
knowledge_base__enabled=True,
|
|
).select_related("knowledge_base")
|
|
for item in items[:500]:
|
|
if not _scope_matches(
|
|
ticket,
|
|
category_code=item.category_code,
|
|
related_system=item.related_system,
|
|
environment=item.environment_scope,
|
|
):
|
|
continue
|
|
content = "\n".join(
|
|
[
|
|
item.title,
|
|
item.problem,
|
|
item.applicable_scope,
|
|
item.error_code,
|
|
item.root_cause,
|
|
item.troubleshooting_steps,
|
|
item.solution,
|
|
item.risk_note,
|
|
]
|
|
)
|
|
hybrid_result = hybrid_by_item.get(item.id)
|
|
score = (
|
|
float(hybrid_result["score"])
|
|
if hybrid_result is not None
|
|
else _text_score(query, content)
|
|
)
|
|
if score <= 0:
|
|
continue
|
|
candidates.append(
|
|
{
|
|
"source_type": "knowledge",
|
|
"source_id": item.id,
|
|
"source_version": item.updated_at.isoformat(),
|
|
"title": item.title,
|
|
"excerpt": _excerpt(content, query),
|
|
"score": score,
|
|
"applicable_scope": {"description": item.applicable_scope},
|
|
"retrieval": hybrid_result["metadata"]["retrieval"]
|
|
if hybrid_result is not None
|
|
else {
|
|
"channels": ["unindexed_text_fallback"],
|
|
"text_score": round(score, 4),
|
|
"vector_score": 0.0,
|
|
"scope_score": 1.0,
|
|
},
|
|
}
|
|
)
|
|
|
|
history = (
|
|
Ticket.objects.filter(
|
|
status__in=["resolved", "closed"],
|
|
archived_at__isnull=True,
|
|
)
|
|
.exclude(id=ticket.id)
|
|
.select_related("category", "resolution")
|
|
)
|
|
for historical in history[:500]:
|
|
if not _scope_matches(
|
|
ticket,
|
|
category_id=historical.category_id,
|
|
related_system=historical.related_system,
|
|
environment=historical.environment,
|
|
):
|
|
continue
|
|
resolution = getattr(historical, "resolution", None)
|
|
content = "\n".join(
|
|
[
|
|
historical.title,
|
|
historical.description,
|
|
historical.problem_performance,
|
|
historical.error_information,
|
|
resolution.root_cause if resolution else "",
|
|
resolution.solution_text if resolution else "",
|
|
]
|
|
)
|
|
hybrid_result = hybrid_by_history.get(historical.id)
|
|
score = (
|
|
float(hybrid_result["score"])
|
|
if hybrid_result is not None
|
|
else _text_score(query, content)
|
|
)
|
|
if score <= 0:
|
|
continue
|
|
candidates.append(
|
|
{
|
|
"source_type": "historical_ticket",
|
|
"source_id": historical.id,
|
|
"source_version": historical.updated_at.isoformat(),
|
|
"title": historical.title,
|
|
"excerpt": _excerpt(content, query),
|
|
"score": score,
|
|
"applicable_scope": {
|
|
"category_code": historical.category.code
|
|
if historical.category_id
|
|
else None,
|
|
"related_system": historical.related_system,
|
|
"environment": historical.environment,
|
|
},
|
|
"retrieval": hybrid_result["metadata"]["retrieval"]
|
|
if hybrid_result is not None
|
|
else {
|
|
"channels": ["unindexed_text_fallback"],
|
|
"text_score": round(score, 4),
|
|
"vector_score": 0.0,
|
|
"scope_score": 1.0,
|
|
},
|
|
}
|
|
)
|
|
|
|
minimum_score = min(max(float(settings.SUPPORT_REFERENCE_MIN_SCORE), 0.0), 1.0)
|
|
candidates = [
|
|
item for item in candidates if float(item.get("score", 0)) >= minimum_score
|
|
]
|
|
candidates.sort(key=lambda item: (item["score"], item["source_version"]), reverse=True)
|
|
references = []
|
|
for rank, item in enumerate(candidates[:limit], start=1):
|
|
reference = deepcopy(item)
|
|
reference["rank"] = rank
|
|
reference["score"] = round(float(reference["score"]), 4)
|
|
references.append(reference)
|
|
return query, references
|
|
|
|
|
|
def validate_support_agent_contract(version: AgentVersion) -> None:
|
|
last_step = version.steps.select_related("skill_version").order_by("-order").first()
|
|
if last_step is None:
|
|
raise SupportAnalysisError("支持分析 Agent 没有可执行步骤")
|
|
output_schema = last_step.skill_version.output_schema
|
|
if not isinstance(output_schema, dict) or output_schema.get("type") != "object":
|
|
raise SupportAnalysisError("支持分析 Agent 的最终输出必须是对象")
|
|
missing = missing_support_output_fields(output_schema)
|
|
if missing:
|
|
raise SupportAnalysisError(
|
|
f"支持分析 Agent 最终输出契约缺少字段:{', '.join(missing)}"
|
|
)
|
|
|
|
|
|
def validate_support_analysis_output(run: AgentRun, output) -> None:
|
|
if run.definition.code != SUPPORT_AGENT_CODE:
|
|
return
|
|
if not isinstance(output, dict):
|
|
raise SupportAnalysisError("支持分析结果必须是对象")
|
|
missing = sorted(SUPPORT_OUTPUT_FIELDS - set(output))
|
|
if missing:
|
|
raise SupportAnalysisError(f"支持分析结果缺少字段:{', '.join(missing)}")
|
|
try:
|
|
analysis = run.ticket_support_analysis
|
|
except TicketSupportAnalysis.DoesNotExist as error:
|
|
raise SupportAnalysisError("支持分析运行未关联真实工单") from error
|
|
valid_ranks = {item.get("rank") for item in analysis.references}
|
|
citations = output.get("citations")
|
|
if not isinstance(citations, list) or any(
|
|
not isinstance(item, int) or item not in valid_ranks for item in citations
|
|
):
|
|
raise SupportAnalysisError("支持分析结果包含无效引用")
|
|
if analysis.references and not citations:
|
|
raise SupportAnalysisError("支持分析结果必须引用至少一条检索依据")
|
|
valid_solution_ids = {
|
|
item.get("source_id")
|
|
for item in analysis.references
|
|
if item.get("source_type") == "solution"
|
|
}
|
|
candidate_solution_ids = output.get("candidate_solution_ids")
|
|
if not isinstance(candidate_solution_ids, list) or any(
|
|
not isinstance(item, int) or item not in valid_solution_ids
|
|
for item in candidate_solution_ids
|
|
):
|
|
raise SupportAnalysisError("支持分析结果引用了检索范围外的解决方案")
|
|
if output.get("auto_resolve_recommended"):
|
|
eligible_solution_ids = {
|
|
item["source_id"]
|
|
for item in analysis.references
|
|
if item.get("source_type") == "solution"
|
|
and item.get("score", 0) >= 0.85
|
|
and item.get("auto_resolve_policy", {}).get("allow_auto_resolve")
|
|
and not item.get("auto_resolve_policy", {}).get("requires_human_action")
|
|
and not item.get("auto_resolve_policy", {}).get("requires_external_confirmation")
|
|
and not item.get("auto_resolve_policy", {}).get("requires_production_change")
|
|
}
|
|
if not eligible_solution_ids.intersection(candidate_solution_ids):
|
|
raise SupportAnalysisError("自动解决建议不满足阈值、范围或风险策略")
|
|
|
|
|
|
def _user_has_permission(user, permission: str) -> bool:
|
|
return bool(
|
|
user
|
|
and user.is_authenticated
|
|
and (user.is_superuser or user.has_perm(f"system.{permission}"))
|
|
)
|
|
|
|
|
|
def _automation_decision(
|
|
analysis: TicketSupportAnalysis,
|
|
output: dict,
|
|
) -> tuple[dict, Solution | None]:
|
|
ticket = analysis.ticket
|
|
run = analysis.run
|
|
threshold = min(max(float(settings.TICKET_AUTO_RESOLVE_MIN_SCORE), 0.0), 1.0)
|
|
candidate_ids = output.get("candidate_solution_ids", [])
|
|
references = sorted(
|
|
(
|
|
item
|
|
for item in analysis.references
|
|
if item.get("source_type") == "solution"
|
|
and item.get("source_id") in candidate_ids
|
|
),
|
|
key=lambda item: float(item.get("score", 0)),
|
|
reverse=True,
|
|
)
|
|
reference = references[0] if references else None
|
|
solution = (
|
|
Solution.objects.select_related("category").filter(id=reference["source_id"]).first()
|
|
if reference
|
|
else None
|
|
)
|
|
existing_resolution = getattr(ticket, "resolution", None)
|
|
checks = {
|
|
"agent_recommended": output.get("auto_resolve_recommended") is True,
|
|
"global_policy_enabled": bool(settings.TICKET_AUTO_RESOLVE_ENABLED),
|
|
"initiator_can_resolve": _user_has_permission(run.created_by, "ticket.resolve"),
|
|
"agent_action_allowed": "ticket.resolve"
|
|
in run.config_snapshot.get("agent", {}).get("allowed_actions", []),
|
|
"candidate_present": solution is not None,
|
|
"published": bool(solution and solution.status == "published"),
|
|
"score_at_least_threshold": bool(
|
|
reference and float(reference.get("score", 0)) >= threshold
|
|
),
|
|
"scope_matches": bool(
|
|
solution
|
|
and _scope_matches(
|
|
ticket,
|
|
category_id=solution.category_id,
|
|
related_system=solution.related_system,
|
|
environment=solution.environment_scope,
|
|
)
|
|
),
|
|
"version_unchanged": bool(
|
|
solution
|
|
and reference
|
|
and reference.get("source_version") == solution.updated_at.isoformat()
|
|
),
|
|
"risk_policy_allows": bool(
|
|
solution
|
|
and solution.allow_auto_resolve
|
|
and not solution.requires_human_action
|
|
and not solution.requires_external_confirmation
|
|
and not solution.requires_production_change
|
|
),
|
|
"ticket_not_archived": ticket.archived_at is None,
|
|
"status_transition_allowed": (
|
|
"resolved"
|
|
in get_allowed_transitions(
|
|
ticket.status,
|
|
ticket.category.code if ticket.category_id else None,
|
|
)
|
|
or (
|
|
"pending_confirm"
|
|
in get_allowed_transitions(
|
|
ticket.status,
|
|
ticket.category.code if ticket.category_id else None,
|
|
)
|
|
and "resolved"
|
|
in get_allowed_transitions(
|
|
TicketStatus.PENDING_CONFIRM,
|
|
ticket.category.code if ticket.category_id else None,
|
|
)
|
|
)
|
|
),
|
|
"no_conflicting_resolution": existing_resolution is None
|
|
or existing_resolution.linked_solution_id in {None, solution.id if solution else None},
|
|
}
|
|
reason_labels = {
|
|
"agent_recommended": "agent_not_recommended",
|
|
"global_policy_enabled": "global_policy_disabled",
|
|
"initiator_can_resolve": "initiator_permission_denied",
|
|
"agent_action_allowed": "agent_action_not_allowed",
|
|
"candidate_present": "candidate_solution_missing",
|
|
"published": "solution_not_published",
|
|
"score_at_least_threshold": "score_below_threshold",
|
|
"scope_matches": "scope_mismatch",
|
|
"version_unchanged": "solution_version_changed",
|
|
"risk_policy_allows": "risk_policy_blocked",
|
|
"ticket_not_archived": "ticket_archived",
|
|
"status_transition_allowed": "status_transition_blocked",
|
|
"no_conflicting_resolution": "existing_resolution_conflict",
|
|
}
|
|
reasons = [reason_labels[key] for key, passed in checks.items() if not passed]
|
|
decision = {
|
|
"policy_version": "support_auto_resolve_v1",
|
|
"status": "eligible" if not reasons else "manual_recommendation",
|
|
"eligible": not reasons,
|
|
"threshold": threshold,
|
|
"solution_id": solution.id if solution else None,
|
|
"score": round(float(reference.get("score", 0)), 4) if reference else 0.0,
|
|
"checks": checks,
|
|
"reasons": reasons,
|
|
"applied": False,
|
|
"evaluated_at": timezone.now().isoformat(),
|
|
}
|
|
return decision, solution
|
|
|
|
|
|
@transaction.atomic
|
|
def process_support_analysis_result(run: AgentRun) -> TicketSupportAnalysis | None:
|
|
"""验证运行产物并生成可信分析,自动动作仍需通过权限与策略判定。"""
|
|
|
|
if run.definition.code != SUPPORT_AGENT_CODE or run.status != "succeeded":
|
|
return None
|
|
analysis = (
|
|
TicketSupportAnalysis.objects.select_for_update(of=("self",))
|
|
.select_related(
|
|
"ticket__category",
|
|
"ticket__resolution",
|
|
"run__created_by",
|
|
"run__agent_version",
|
|
)
|
|
.get(run=run)
|
|
)
|
|
if analysis.processed_at is not None:
|
|
return analysis
|
|
output = run.final_output if isinstance(run.final_output, dict) else {}
|
|
reply_task = ensure_customer_reply_task(
|
|
analysis,
|
|
str(output.get("customer_reply_draft") or ""),
|
|
)
|
|
if reply_task is None:
|
|
raise SupportAnalysisError("支持分析未生成可发送的客户回复")
|
|
decision, solution = _automation_decision(analysis, output)
|
|
analysis.result_mode = "trusted" if analysis.references else "manual_only"
|
|
if decision["status"] == "eligible" and solution is not None:
|
|
# 方案只在技术支持执行“一次批准发送”后采纳,分析阶段不提前改变工单事实。
|
|
decision["status"] = "waiting_approval"
|
|
analysis.automation_decision = decision
|
|
analysis.processed_at = timezone.now()
|
|
analysis.save(
|
|
update_fields=[
|
|
"result_mode",
|
|
"automation_decision",
|
|
"applied_solution",
|
|
"processed_at",
|
|
]
|
|
)
|
|
analysis.ticket.events.create(
|
|
event_type="support_automation_evaluated",
|
|
to_value={
|
|
"status": decision["status"],
|
|
"solution_id": decision["solution_id"],
|
|
"applied": decision["applied"],
|
|
"reasons": decision["reasons"],
|
|
},
|
|
operator=run.created_by,
|
|
source=TicketActorSource.AGENT,
|
|
agent_run_id=run.id,
|
|
metadata={"policy_version": decision["policy_version"]},
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=run.created_by,
|
|
module="tickets",
|
|
action="support_automation_evaluated",
|
|
target_type="ticket",
|
|
target_id=str(analysis.ticket_id),
|
|
request_snapshot={
|
|
"agent_run_public_id": str(run.public_id),
|
|
"decision": decision,
|
|
},
|
|
result="success",
|
|
)
|
|
return analysis
|
|
|
|
|
|
@transaction.atomic
|
|
def start_ticket_support_analysis(
|
|
ticket: Ticket,
|
|
*,
|
|
created_by=None,
|
|
source: str = TicketActorSource.HUMAN,
|
|
) -> TicketSupportAnalysis:
|
|
"""为工单创建唯一活动分析并投递绑定正式 Agent 版本的运行。"""
|
|
|
|
ticket = (
|
|
Ticket.objects.select_for_update(of=("self",))
|
|
.select_related("category")
|
|
.get(id=ticket.id)
|
|
)
|
|
if ticket.archived_at is not None:
|
|
raise SupportAnalysisError("已归档工单不能启动支持分析")
|
|
if ticket.status in {"resolved", "closed"}:
|
|
raise SupportAnalysisError("已结束工单不能启动新的支持分析")
|
|
active = (
|
|
TicketSupportAnalysis.objects.select_related("run")
|
|
.filter(ticket=ticket, run__status__in=["queued", "running"])
|
|
.first()
|
|
)
|
|
if active is not None:
|
|
return active
|
|
definition = AgentDefinition.objects.select_related(
|
|
"active_version__model_deployment__provider"
|
|
).filter(code=SUPPORT_AGENT_CODE).first()
|
|
if definition is None or definition.active_version_id is None:
|
|
raise SupportAnalysisError("尚未发布支持分析 Agent")
|
|
version = definition.active_version
|
|
validate_support_agent_contract(version)
|
|
query, references = retrieve_support_references(ticket)
|
|
retrieval_channels = sorted(
|
|
{
|
|
channel
|
|
for reference in references
|
|
for channel in reference.get("retrieval", {}).get("channels", [])
|
|
}
|
|
)
|
|
embedding_models = sorted(
|
|
{
|
|
reference.get("retrieval", {}).get("query_embedding_model")
|
|
for reference in references
|
|
if reference.get("retrieval", {}).get("query_embedding_model")
|
|
}
|
|
)
|
|
input_payload = {
|
|
"ticket": {
|
|
"id": ticket.id,
|
|
"ticket_no": ticket.ticket_no,
|
|
"title": ticket.title,
|
|
"description": ticket.description,
|
|
"category_code": ticket.category.code if ticket.category_id else None,
|
|
"environment": ticket.environment,
|
|
"related_system": ticket.related_system,
|
|
"product_name": ticket.product_name,
|
|
"module_name": ticket.module_name,
|
|
"attempted_actions": ticket.attempted_actions,
|
|
"error_information": ticket.error_information,
|
|
"customer_request": ticket.customer_request,
|
|
"current_result": ticket.current_result,
|
|
},
|
|
"query": query,
|
|
"references": references,
|
|
}
|
|
run = queue_agent_run(
|
|
version,
|
|
input_payload,
|
|
created_by=created_by,
|
|
config_snapshot_extra={
|
|
"business_object": {"type": "ticket", "id": ticket.id},
|
|
"retrieval": {
|
|
"policy_version": RETRIEVAL_POLICY_VERSION,
|
|
"allowed_statuses": {
|
|
"knowledge": ["published"],
|
|
"solution": ["published"],
|
|
"historical_ticket": ["resolved", "closed"],
|
|
},
|
|
"reference_count": len(references),
|
|
"channels": retrieval_channels,
|
|
"embedding_models": embedding_models,
|
|
},
|
|
"automation": {
|
|
"policy_version": "support_auto_resolve_v1",
|
|
"enabled": bool(settings.TICKET_AUTO_RESOLVE_ENABLED),
|
|
"minimum_score": float(settings.TICKET_AUTO_RESOLVE_MIN_SCORE),
|
|
},
|
|
},
|
|
defer_until_commit=True,
|
|
)
|
|
analysis = TicketSupportAnalysis.objects.create(
|
|
ticket=ticket,
|
|
run=run,
|
|
query=query,
|
|
references=references,
|
|
retrieval_policy=run.config_snapshot["retrieval"],
|
|
created_by=created_by,
|
|
)
|
|
ticket.events.create(
|
|
event_type="support_analysis_started",
|
|
to_value={
|
|
"run_public_id": str(run.public_id),
|
|
"reference_count": len(references),
|
|
},
|
|
operator=created_by,
|
|
source=source,
|
|
agent_run_id=run.id,
|
|
metadata={"retrieval_policy_version": RETRIEVAL_POLICY_VERSION},
|
|
)
|
|
OperationAuditLog.objects.create(
|
|
user=created_by,
|
|
module="tickets",
|
|
action="support_analysis_started",
|
|
target_type="ticket",
|
|
target_id=str(ticket.id),
|
|
request_snapshot={
|
|
"agent_run_public_id": str(run.public_id),
|
|
"reference_count": len(references),
|
|
"retrieval_policy_version": RETRIEVAL_POLICY_VERSION,
|
|
},
|
|
result="success",
|
|
)
|
|
return analysis
|