Files
ai-ticket-platform/backend/apps/tickets/support_analysis.py
2026-09-18 15:26:19 +08:00

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