Files
ai-ticket-platform/backend/apps/tickets/support_analysis.py
2026-07-27 19:00:35 +08:00

697 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, TicketSupportAnalysis
from apps.tickets.replies import ensure_customer_reply_task
from apps.tickets.services import (
TicketDataError,
TicketStatusError,
adopt_solution_for_ticket,
get_allowed_transitions,
)
SUPPORT_AGENT_CODE = "support-analysis"
RETRIEVAL_POLICY_VERSION = "published_scope_hybrid_rerank_v2"
SUPPORT_OUTPUT_FIELDS = {
"analysis_summary",
"troubleshooting_steps",
"candidate_solution_ids",
"customer_reply_draft",
"citations",
"auto_resolve_recommended",
}
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,
},
}
)
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 的最终输出必须是对象")
required = set(output_schema.get("required", []))
missing = sorted(SUPPORT_OUTPUT_FIELDS - required)
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,
),
"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",
"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()
.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 {}
ensure_customer_reply_task(
analysis,
str(output.get("customer_reply_draft") or ""),
)
decision, solution = _automation_decision(analysis, output)
if decision["status"] == "eligible" and solution is not None:
try:
adopt_solution_for_ticket(
analysis.ticket,
solution,
run.created_by,
remark="支持分析 Agent 通过统一策略自动采纳解决方案",
source=TicketActorSource.AGENT,
agent_run_id=run.id,
)
except (TicketDataError, TicketStatusError):
decision["status"] = "manual_recommendation"
decision["reasons"].append("domain_service_rejected")
else:
decision["status"] = "auto_resolved"
decision["applied"] = True
analysis.applied_solution = solution
analysis.automation_decision = decision
analysis.processed_at = timezone.now()
analysis.save(
update_fields=[
"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) -> TicketSupportAnalysis:
"""为工单创建唯一活动分析并投递绑定正式 Agent 版本的运行。"""
ticket = Ticket.objects.select_for_update().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=TicketActorSource.HUMAN,
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