"""阶段 3 支持分析案例导入、逐案评审和退出指标计算。""" import json from pathlib import Path from django.utils import timezone from apps.tickets.models import ( SupportEvaluationCase, SupportEvaluationResult, TicketSupportAnalysis, ) SOURCE_KINDS = {"anonymized_real", "synthetic"} class SupportEvaluationError(ValueError): pass def _solution_ids(value, *, label: str) -> list[int]: if not isinstance(value, list): raise SupportEvaluationError(f"{label}必须是整数数组") result = [] for item in value: if isinstance(item, bool) or not isinstance(item, int) or item <= 0: raise SupportEvaluationError(f"{label}必须是正整数数组") if item not in result: result.append(item) return result def import_support_evaluation_cases(path: str | Path) -> tuple[int, int]: """导入阶段 3 支持案例及期望引用、动作和人工降级结果。""" source_path = Path(path) payload = json.loads(source_path.read_text(encoding="utf-8")) if not isinstance(payload, dict) or payload.get("schema_version") != 1: raise SupportEvaluationError("案例文件 schema_version 必须为 1") cases = payload.get("cases") if not isinstance(cases, list): raise SupportEvaluationError("案例文件必须包含 cases 数组") created_count = 0 updated_count = 0 for raw_case in cases: if not isinstance(raw_case, dict): raise SupportEvaluationError("每个案例必须是对象") key = str(raw_case.get("key") or "").strip() source_kind = str(raw_case.get("source_kind") or "").strip() if not key or source_kind not in SOURCE_KINDS: raise SupportEvaluationError("案例必须提供 key 和合法 source_kind") boolean_fields = { "expected_auto_resolve", "expected_manual_fallback", "enabled", } invalid_boolean_fields = sorted( field for field in boolean_fields if field in raw_case and not isinstance(raw_case[field], bool) ) if invalid_boolean_fields: raise SupportEvaluationError( f"案例 {key} 的布尔字段类型错误:{', '.join(invalid_boolean_fields)}" ) input_manifest = raw_case.get("input_manifest") or {} if not isinstance(input_manifest, dict): raise SupportEvaluationError(f"案例 {key} 的 input_manifest 必须是对象") expected_solution_ids = _solution_ids( raw_case.get("expected_solution_ids") or [], label=f"案例 {key} 的 expected_solution_ids", ) expected_auto_resolve = bool(raw_case.get("expected_auto_resolve", False)) expected_manual_fallback = bool(raw_case.get("expected_manual_fallback", True)) if expected_auto_resolve == expected_manual_fallback: raise SupportEvaluationError( f"案例 {key} 必须且只能选择自动解决或人工降级一种期望结果" ) if expected_auto_resolve and not expected_solution_ids: raise SupportEvaluationError(f"自动解决案例 {key} 必须提供期望方案 ID") if source_kind == "anonymized_real": required = { "input_manifest", "expected_solution_ids", "expected_auto_resolve", "expected_manual_fallback", } missing = sorted(required - set(raw_case)) if missing: raise SupportEvaluationError(f"真实案例 {key} 缺少字段:{', '.join(missing)}") if not input_manifest: raise SupportEvaluationError(f"真实案例 {key} 必须提供脱敏工单输入摘要") _, created = SupportEvaluationCase.objects.update_or_create( key=key, defaults={ "name": str(raw_case.get("name") or key), "source_kind": source_kind, "input_manifest": input_manifest, "expected_solution_ids": expected_solution_ids, "expected_auto_resolve": expected_auto_resolve, "expected_manual_fallback": expected_manual_fallback, "notes": str(raw_case.get("notes") or ""), "enabled": bool(raw_case.get("enabled", True)), }, ) created_count += int(created) updated_count += int(not created) return created_count, updated_count def record_support_evaluation_result( case: SupportEvaluationCase, analysis: TicketSupportAnalysis, *, reviewer=None, ) -> SupportEvaluationResult: """记录支持分析与案例期望的逐项评审结果。""" if analysis.run.status != "succeeded" or analysis.processed_at is None: raise SupportEvaluationError("支持分析尚未成功完成后处理,不能进入业务指标") output = analysis.run.final_output if isinstance(analysis.run.final_output, dict) else {} candidate_ids = _solution_ids( output.get("candidate_solution_ids") or [], label="Agent candidate_solution_ids", ) raw_citations = output.get("citations") if not isinstance(raw_citations, list): raw_citations = [] citation_ranks = [ item for item in raw_citations if isinstance(item, int) and not isinstance(item, bool) and item > 0 ] reference_ranks = { item.get("rank") for item in analysis.references if isinstance(item, dict) and isinstance(item.get("rank"), int) and not isinstance(item.get("rank"), bool) } citations_valid = ( len(citation_ranks) == len(raw_citations) and set(citation_ranks).issubset(reference_ranks) and (bool(citation_ranks) if reference_ranks else not citation_ranks) ) expected_ids = set(case.expected_solution_ids) matched_expected_solution = ( bool(expected_ids.intersection(candidate_ids)) if expected_ids else not candidate_ids ) decision = ( analysis.automation_decision if isinstance(analysis.automation_decision, dict) else {} ) # 一次确认流程把“分析判定可自动复用”和“人工批准后真正解决”拆成两个时点。 # 旧字段名继续保留给既有评估数据和接口,但阶段指标按是否到达批准就绪态计算。 actual_auto_resolved = decision.get("status") in { "waiting_approval", "auto_resolved", } and bool( decision.get("eligible") or ( decision.get("applied") is True and analysis.applied_solution_id is not None ) ) actual_manual_fallback = decision.get("status") == "manual_recommendation" evaluated_solution_id = analysis.applied_solution_id or decision.get("solution_id") incorrect_auto_reuse = bool( actual_auto_resolved and evaluated_solution_id not in expected_ids ) outcome_correct = ( actual_auto_resolved == case.expected_auto_resolve and actual_manual_fallback == case.expected_manual_fallback ) passed = ( matched_expected_solution and outcome_correct and not incorrect_auto_reuse and citations_valid ) result, _ = SupportEvaluationResult.objects.update_or_create( case=case, analysis=analysis, defaults={ "actual_candidate_solution_ids": candidate_ids, "actual_citation_ranks": citation_ranks, "matched_expected_solution": matched_expected_solution, "actual_auto_resolved": actual_auto_resolved, "actual_manual_fallback": actual_manual_fallback, "incorrect_auto_reuse": incorrect_auto_reuse, "citations_valid": citations_valid, "passed": passed, "details": { "expected_solution_ids": list(case.expected_solution_ids), "expected_auto_resolve": case.expected_auto_resolve, "expected_manual_fallback": case.expected_manual_fallback, "applied_solution_id": analysis.applied_solution_id, "evaluated_solution_id": evaluated_solution_id, "automation_decision": decision, "reference_count": len(analysis.references), "outcome_correct": outcome_correct, }, "reviewed_by": reviewer, }, ) return result def _latest_support_evaluation_scope( *, include_synthetic: bool, ) -> tuple[list[SupportEvaluationCase], list[SupportEvaluationResult]]: cases = SupportEvaluationCase.objects.filter(enabled=True) if not include_synthetic: cases = cases.filter(source_kind="anonymized_real") case_list = list(cases.order_by("key")) queryset = ( SupportEvaluationResult.objects.select_related( "case", "analysis__ticket", "analysis__run", "reviewed_by", ) .filter(case__in=case_list) .order_by("case_id", "-updated_at", "-id") ) latest_by_case = {} for result in queryset: latest_by_case.setdefault(result.case_id, result) return case_list, list(latest_by_case.values()) def build_support_quality_metrics(*, include_synthetic: bool = False) -> dict: """按每个案例最新结果计算方案匹配、错误复用和人工降级指标。""" cases, results = _latest_support_evaluation_scope( include_synthetic=include_synthetic ) expected_match_results = [ result for result in results if result.case.expected_solution_ids ] expected_auto_results = [ result for result in results if result.case.expected_auto_resolve ] expected_manual_results = [ result for result in results if result.case.expected_manual_fallback ] actual_auto_results = [result for result in results if result.actual_auto_resolved] def rate(numerator: int, denominator: int) -> float | None: return numerator / denominator if denominator else None solution_match_count = sum( result.matched_expected_solution for result in expected_match_results ) auto_resolved_count = sum( result.actual_auto_resolved for result in expected_auto_results ) incorrect_auto_reuse_count = sum( result.incorrect_auto_reuse for result in actual_auto_results ) manual_fallback_count = sum( result.actual_manual_fallback for result in expected_manual_results ) citation_valid_count = sum(result.citations_valid for result in results) enabled_case_count = len(cases) reviewed_case_count = len(results) coverage_complete = ( enabled_case_count > 0 and reviewed_case_count == enabled_case_count ) solution_match_rate = rate(solution_match_count, len(expected_match_results)) eligible_auto_resolve_rate = rate(auto_resolved_count, len(expected_auto_results)) incorrect_auto_reuse_rate = rate( incorrect_auto_reuse_count, len(actual_auto_results), ) manual_fallback_accuracy = rate( manual_fallback_count, len(expected_manual_results), ) citation_validity_rate = rate(citation_valid_count, reviewed_case_count) evidence_ready = coverage_complete and not include_synthetic return { "include_synthetic": include_synthetic, "evaluation": { "enabled_case_count": enabled_case_count, "reviewed_case_count": reviewed_case_count, "coverage_rate": rate(reviewed_case_count, enabled_case_count), "expected_match_case_count": len(expected_match_results), "matched_solution_count": solution_match_count, "solution_match_rate": solution_match_rate, "eligible_auto_resolve_case_count": len(expected_auto_results), "auto_resolved_count": auto_resolved_count, "eligible_auto_resolve_rate": eligible_auto_resolve_rate, "actual_auto_resolved_count": len(actual_auto_results), "incorrect_auto_reuse_count": incorrect_auto_reuse_count, "incorrect_auto_reuse_rate": incorrect_auto_reuse_rate, "expected_manual_fallback_count": len(expected_manual_results), "manual_fallback_count": manual_fallback_count, "manual_fallback_accuracy": manual_fallback_accuracy, "citation_valid_count": citation_valid_count, "citation_validity_rate": citation_validity_rate, "result_selection": "latest_per_case", }, "targets": { "solution_match_rate": 0.50, "eligible_auto_resolve_rate": 0.30, "incorrect_auto_reuse_rate": 0.05, "coverage_complete": coverage_complete, "solution_match_rate_met": ( solution_match_rate >= 0.50 if evidence_ready and solution_match_rate is not None else None ), "eligible_auto_resolve_rate_met": ( eligible_auto_resolve_rate >= 0.30 if evidence_ready and eligible_auto_resolve_rate is not None else None ), "incorrect_auto_reuse_rate_met": ( (incorrect_auto_reuse_rate or 0.0) <= 0.05 if evidence_ready and actual_auto_results else None ), "manual_fallback_accuracy_met": ( manual_fallback_accuracy == 1.0 if evidence_ready and manual_fallback_accuracy is not None else None ), "citation_validity_rate_met": ( citation_validity_rate == 1.0 if evidence_ready and citation_validity_rate is not None else None ), }, } def build_stage_3_acceptance_report(*, include_synthetic: bool = False) -> dict: """生成阶段 3 退出报告;合成案例仅用于开发回归。""" cases, results = _latest_support_evaluation_scope( include_synthetic=include_synthetic ) metrics = build_support_quality_metrics(include_synthetic=include_synthetic) result_by_case = {result.case_id: result for result in results} case_results = [] for case in cases: result = result_by_case.get(case.id) case_results.append( { "key": case.key, "name": case.name, "source_kind": case.source_kind, "reviewed": result is not None, "passed": result.passed if result is not None else None, "analysis_id": result.analysis_id if result is not None else None, "ticket_id": ( result.analysis.ticket_id if result is not None else None ), "matched_expected_solution": ( result.matched_expected_solution if result is not None else None ), "actual_auto_resolved": ( result.actual_auto_resolved if result is not None else None ), "actual_manual_fallback": ( result.actual_manual_fallback if result is not None else None ), "incorrect_auto_reuse": ( result.incorrect_auto_reuse if result is not None else None ), "citations_valid": result.citations_valid if result is not None else None, "reviewed_at": ( result.updated_at.isoformat() if result is not None else None ), } ) reasons = [] if include_synthetic: reasons.append( { "code": "synthetic_scope", "message": "合成案例只验证评估框架,不能作为阶段 3 业务退出证据。", } ) elif not cases: reasons.append( { "code": "missing_real_cases", "message": "尚未导入启用的阶段 3 脱敏真实案例。", } ) elif not metrics["targets"]["coverage_complete"]: reasons.append( { "code": "incomplete_review_coverage", "message": "仍有启用案例没有支持分析评审结果。", } ) if not include_synthetic and cases: expected_auto_count = sum(case.expected_auto_resolve for case in cases) expected_manual_count = sum(case.expected_manual_fallback for case in cases) expected_no_match_count = sum(not case.expected_solution_ids for case in cases) if expected_auto_count == 0: reasons.append( { "code": "missing_auto_resolve_case", "message": "真实案例集未覆盖允许自动解决的场景。", } ) if expected_manual_count == 0: reasons.append( { "code": "missing_manual_fallback_case", "message": "真实案例集未覆盖策略阻断或人工降级场景。", } ) if expected_no_match_count == 0: reasons.append( { "code": "missing_no_match_case", "message": "真实案例集未覆盖无正确方案或错误匹配场景。", } ) targets = metrics["targets"] if targets["coverage_complete"] and not include_synthetic: target_messages = { "solution_match_rate_met": "已发布方案自动匹配率未达到 50%。", "eligible_auto_resolve_rate_met": "符合条件工单自动解决率未达到 30%。", "incorrect_auto_reuse_rate_met": "错误自动复用率超过 5%,或没有自动解决证据。", "manual_fallback_accuracy_met": "人工降级场景未全部按预期阻断自动解决。", "citation_validity_rate_met": "支持分析建议未全部具备有效冻结引用。", } for target, message in target_messages.items(): if targets[target] is not True: reasons.append({"code": target.removesuffix("_met"), "message": message}) exit_ready = not reasons and not include_synthetic status = "passed" if exit_ready else "diagnostic" if include_synthetic else "not_ready" return { "schema_version": 1, "generated_at": timezone.now().isoformat(), "scope": "diagnostic_with_synthetic" if include_synthetic else "anonymized_real", "status": status, "exit_ready": exit_ready, "reasons": reasons, "metrics": metrics, "case_results": case_results, }