Files
2026-07-27 18:59:28 +08:00

555 lines
22 KiB
Python

"""阶段 2 智能受理案例导入、逐案评审和质量指标计算。"""
import json
from pathlib import Path
from django.utils import timezone
from apps.tickets.models import (
IntakeEvaluationCase,
IntakeEvaluationResult,
TicketDraft,
)
SOURCE_KINDS = {"anonymized_real", "synthetic"}
class IntakeEvaluationError(ValueError):
pass
def _normalize_value(value):
if value is None:
return ""
if isinstance(value, str):
return " ".join(value.casefold().split())
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return str(value).casefold()
def import_evaluation_cases(path: str | Path) -> tuple[int, int]:
"""导入版本化案例清单;真实案例必须提供完整期望与材料清单。"""
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 IntakeEvaluationError("案例文件 schema_version 必须为 1")
cases = payload.get("cases") if isinstance(payload, dict) else None
if not isinstance(cases, list):
raise IntakeEvaluationError("案例文件必须包含 cases 数组")
created_count = 0
updated_count = 0
for raw_case in cases:
if not isinstance(raw_case, dict):
raise IntakeEvaluationError("每个案例必须是对象")
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 IntakeEvaluationError("案例必须提供 key 和合法 source_kind")
boolean_fields = {
"valid_input",
"expected_auto_created",
"expected_similar_ticket",
"enabled",
}
invalid_boolean_fields = sorted(
field_name
for field_name in boolean_fields
if field_name in raw_case and not isinstance(raw_case[field_name], bool)
)
if invalid_boolean_fields:
raise IntakeEvaluationError(
f"案例 {key} 的布尔字段类型错误:{', '.join(invalid_boolean_fields)}"
)
input_manifest = raw_case.get("input_manifest") or {}
expected_fields = raw_case.get("expected_fields") or {}
expected_timeline = raw_case.get("expected_timeline") or []
if not isinstance(input_manifest, dict):
raise IntakeEvaluationError(f"案例 {key} 的 input_manifest 必须是对象")
if not isinstance(expected_fields, dict):
raise IntakeEvaluationError(f"案例 {key} 的 expected_fields 必须是对象")
if not isinstance(expected_timeline, list) or not all(
isinstance(item, dict) for item in expected_timeline
):
raise IntakeEvaluationError(f"案例 {key} 的 expected_timeline 必须是对象数组")
if source_kind == "anonymized_real":
required_keys = {
"valid_input",
"input_manifest",
"expected_fields",
"expected_timeline",
"expected_auto_created",
"expected_similar_ticket",
}
missing_keys = sorted(required_keys - set(raw_case))
if missing_keys:
raise IntakeEvaluationError(
f"真实案例 {key} 缺少字段:{', '.join(missing_keys)}"
)
source_text = str(input_manifest.get("source_text") or "").strip()
materials = input_manifest.get("materials") or []
if not isinstance(materials, list) or not all(
isinstance(item, dict) for item in materials
):
raise IntakeEvaluationError(f"真实案例 {key} 的 materials 必须是对象数组")
if not source_text and not materials:
raise IntakeEvaluationError(f"真实案例 {key} 必须包含文本或材料")
_, created = IntakeEvaluationCase.objects.update_or_create(
key=key,
defaults={
"name": str(raw_case.get("name") or key),
"source_kind": source_kind,
"valid_input": bool(raw_case.get("valid_input", True)),
"input_manifest": input_manifest,
"expected_fields": expected_fields,
"expected_timeline": expected_timeline,
"expected_auto_created": bool(raw_case.get("expected_auto_created", True)),
"expected_similar_ticket": bool(
raw_case.get("expected_similar_ticket", False)
),
"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 _evaluate_timeline(expected_timeline: list, actual_timeline: list) -> tuple[list, int]:
results = []
correct_count = 0
for index, expected in enumerate(expected_timeline):
actual = actual_timeline[index] if index < len(actual_timeline) else None
field_results = {}
correct = isinstance(actual, dict)
for field_name, expected_value in expected.items():
actual_value = actual.get(field_name) if isinstance(actual, dict) else None
field_correct = _normalize_value(actual_value) == _normalize_value(expected_value)
correct = correct and field_correct
field_results[field_name] = {
"expected": expected_value,
"actual": actual_value,
"correct": field_correct,
}
correct_count += int(correct)
results.append(
{
"sequence": index + 1,
"correct": correct,
"field_results": field_results,
}
)
return results, correct_count
def record_evaluation_result(
case: IntakeEvaluationCase,
draft: TicketDraft,
*,
reviewer=None,
) -> IntakeEvaluationResult:
"""把一次受理结果与案例期望逐字段比对,并保留人工评审证据。"""
expected_fields = case.expected_fields if isinstance(case.expected_fields, dict) else {}
actual_fields = draft.current_fields if isinstance(draft.current_fields, dict) else {}
field_results = {}
correct_count = 0
for field_name, expected in expected_fields.items():
actual = actual_fields.get(field_name)
correct = _normalize_value(actual) == _normalize_value(expected)
correct_count += int(correct)
field_results[field_name] = {
"expected": expected,
"actual": actual,
"correct": correct,
}
evaluated_count = len(field_results)
field_accuracy = correct_count / evaluated_count if evaluated_count else None
field_evidence = draft.field_evidence if isinstance(draft.field_evidence, dict) else {}
evidence_results = {}
evidence_count = 0
evidence_covered_count = 0
for field_name, expected in expected_fields.items():
if not _normalize_value(expected):
continue
evidence_count += 1
evidence = field_evidence.get(field_name)
covered = isinstance(evidence, dict) and any(
bool(evidence.get(key))
for key in (
"source_refs",
"excerpt",
"location",
"generated_from",
"raw_value",
)
)
evidence_covered_count += int(covered)
evidence_results[field_name] = {
"covered": covered,
"evidence": evidence if isinstance(evidence, dict) else {},
}
expected_timeline = (
case.expected_timeline if isinstance(case.expected_timeline, list) else []
)
actual_timeline = (
draft.message_timeline if isinstance(draft.message_timeline, list) else []
)
timeline_results, correct_timeline_count = _evaluate_timeline(
expected_timeline,
actual_timeline,
)
timeline_correct = correct_timeline_count == len(expected_timeline)
actual_auto_created = draft.decision == "auto_created" and bool(draft.confirmed_ticket_id)
actual_similar_ticket = bool(
draft.confirmed_ticket_id
and draft.confirmed_ticket.similarity_suggestions.exists()
)
passed = (
draft.problem_valid is case.valid_input
and actual_auto_created == case.expected_auto_created
and actual_similar_ticket == case.expected_similar_ticket
and correct_count == evaluated_count
and timeline_correct
and evidence_covered_count == evidence_count
)
result, _ = IntakeEvaluationResult.objects.update_or_create(
case=case,
draft=draft,
defaults={
"ticket": draft.confirmed_ticket,
"actual_fields": actual_fields,
"actual_auto_created": actual_auto_created,
"correct_field_count": correct_count,
"evaluated_field_count": evaluated_count,
"field_accuracy": field_accuracy,
"passed": passed,
"details": {
"field_results": field_results,
"evidence_results": evidence_results,
"evidence_covered_count": evidence_covered_count,
"evidence_field_count": evidence_count,
"timeline_results": timeline_results,
"correct_timeline_count": correct_timeline_count,
"evaluated_timeline_count": len(expected_timeline),
"timeline_correct": timeline_correct,
"expected_problem_valid": case.valid_input,
"actual_problem_valid": draft.problem_valid,
"expected_similar_ticket": case.expected_similar_ticket,
"actual_similar_ticket": actual_similar_ticket,
},
"reviewed_by": reviewer,
},
)
return result
def _latest_evaluation_scope(*, include_synthetic: bool) -> tuple[list, list]:
cases = IntakeEvaluationCase.objects.filter(enabled=True)
if not include_synthetic:
cases = cases.filter(source_kind="anonymized_real")
case_list = list(cases.order_by("key"))
queryset = (
IntakeEvaluationResult.objects.select_related(
"case",
"draft",
"ticket",
"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_modality_coverage(cases: list[IntakeEvaluationCase]) -> dict:
image_types = {"jpg", "jpeg", "png", "webp", "image"}
document_types = {
"pdf",
"txt",
"log",
"docx",
"xlsx",
"pptx",
"document",
"text",
"spreadsheet",
"presentation",
}
coverage = {
"text_case_count": 0,
"image_case_count": 0,
"multi_image_case_count": 0,
"document_case_count": 0,
"mixed_text_image_case_count": 0,
"valid_input_case_count": 0,
"invalid_input_case_count": 0,
}
for case in cases:
manifest = case.input_manifest if isinstance(case.input_manifest, dict) else {}
has_text = bool(str(manifest.get("source_text") or "").strip())
materials = manifest.get("materials")
materials = materials if isinstance(materials, list) else []
material_types = []
for material in materials:
if not isinstance(material, dict):
continue
raw_type = str(
material.get("type")
or material.get("extension")
or Path(str(material.get("file_name") or "")).suffix.lstrip(".")
)
material_types.append(raw_type.casefold())
image_count = sum(item in image_types for item in material_types)
has_document = any(item in document_types for item in material_types)
coverage["text_case_count"] += int(has_text)
coverage["image_case_count"] += int(image_count > 0)
coverage["multi_image_case_count"] += int(image_count > 1)
coverage["document_case_count"] += int(has_document)
coverage["mixed_text_image_case_count"] += int(has_text and image_count > 0)
coverage["valid_input_case_count"] += int(case.valid_input)
coverage["invalid_input_case_count"] += int(not case.valid_input)
return coverage
def build_intake_quality_metrics(*, include_synthetic: bool = False) -> dict:
"""按每个案例最新评审结果计算覆盖率、自动建单率和字段准确率。"""
operational = TicketDraft.objects.filter(problem_valid=True).exclude(
status__in={"rejected", "expired"}
)
operational_valid_count = operational.count()
operational_auto_count = operational.filter(
decision="auto_created",
confirmed_ticket__isnull=False,
).count()
operational_auto_rate = (
operational_auto_count / operational_valid_count if operational_valid_count else None
)
cases, results = _latest_evaluation_scope(include_synthetic=include_synthetic)
valid_results = [result for result in results if result.case.valid_input]
evaluated_valid_count = len(valid_results)
evaluated_auto_count = sum(result.actual_auto_created for result in valid_results)
evaluated_auto_rate = (
evaluated_auto_count / evaluated_valid_count if evaluated_valid_count else None
)
correct_fields = sum(result.correct_field_count for result in results)
evaluated_fields = sum(result.evaluated_field_count for result in results)
field_accuracy = correct_fields / evaluated_fields if evaluated_fields else None
evidence_covered_fields = 0
evidence_fields = 0
correct_timeline_items = 0
evaluated_timeline_items = 0
for result in results:
details = result.details
if not isinstance(details, dict):
continue
evidence_covered_fields += int(details.get("evidence_covered_count") or 0)
evidence_fields += int(details.get("evidence_field_count") or 0)
correct_timeline_items += int(details.get("correct_timeline_count") or 0)
evaluated_timeline_items += int(details.get("evaluated_timeline_count") or 0)
reviewed_case_count = len(results)
enabled_case_count = len(cases)
coverage_complete = (
enabled_case_count > 0 and reviewed_case_count == enabled_case_count
)
return {
"include_synthetic": include_synthetic,
"operational": {
"valid_input_count": operational_valid_count,
"auto_created_count": operational_auto_count,
"auto_creation_rate": operational_auto_rate,
},
"evaluation": {
"enabled_case_count": enabled_case_count,
"reviewed_case_count": reviewed_case_count,
"coverage_rate": (
reviewed_case_count / enabled_case_count if enabled_case_count else None
),
"valid_input_count": evaluated_valid_count,
"auto_created_count": evaluated_auto_count,
"auto_creation_rate": evaluated_auto_rate,
"correct_field_count": correct_fields,
"evaluated_field_count": evaluated_fields,
"field_accuracy": field_accuracy,
"evidence_covered_field_count": evidence_covered_fields,
"evidence_field_count": evidence_fields,
"evidence_coverage_rate": (
evidence_covered_fields / evidence_fields if evidence_fields else None
),
"correct_timeline_item_count": correct_timeline_items,
"evaluated_timeline_item_count": evaluated_timeline_items,
"timeline_accuracy": (
correct_timeline_items / evaluated_timeline_items
if evaluated_timeline_items
else None
),
"result_selection": "latest_per_case",
},
"targets": {
"auto_creation_rate": 0.70,
"field_accuracy": 0.90,
"coverage_complete": coverage_complete,
"auto_creation_rate_met": (
evaluated_auto_rate >= 0.70
if coverage_complete and evaluated_auto_rate is not None
else None
),
"field_accuracy_met": (
field_accuracy >= 0.90
if coverage_complete and field_accuracy is not None
else None
),
},
}
def build_stage_2_acceptance_report(*, include_synthetic: bool = False) -> dict:
"""生成阶段 2 退出报告;默认只允许脱敏真实案例作为验收依据。"""
cases, results = _latest_evaluation_scope(include_synthetic=include_synthetic)
metrics = build_intake_quality_metrics(include_synthetic=include_synthetic)
modality_coverage = _build_modality_coverage(cases)
result_by_case = {result.case_id: result for result in results}
case_results = []
for case in cases:
result = result_by_case.get(case.id)
details = result.details if result is not None and isinstance(result.details, dict) else {}
case_results.append(
{
"key": case.key,
"name": case.name,
"source_kind": case.source_kind,
"valid_input": case.valid_input,
"reviewed": result is not None,
"passed": result.passed if result is not None else None,
"draft_public_id": str(result.draft.public_id) if result is not None else None,
"ticket_id": result.ticket_id if result is not None else None,
"field_accuracy": (
float(result.field_accuracy)
if result is not None and result.field_accuracy is not None
else None
),
"evidence_coverage_rate": (
details.get("evidence_covered_count", 0)
/ details.get("evidence_field_count", 0)
if details.get("evidence_field_count", 0)
else None
),
"timeline_accuracy": (
details.get("correct_timeline_count", 0)
/ details.get("evaluated_timeline_count", 0)
if details.get("evaluated_timeline_count", 0)
else None
),
"reviewed_at": result.updated_at.isoformat() if result is not None else None,
}
)
reasons = []
readiness_state = "failed"
if include_synthetic:
readiness_state = "diagnostic"
reasons.append(
{
"code": "synthetic_scope",
"message": "合成案例只验证评估框架,不能作为阶段 2 业务退出证据。",
}
)
elif not cases:
readiness_state = "not_ready"
reasons.append(
{
"code": "missing_real_cases",
"message": "尚未导入启用的脱敏真实案例。",
}
)
elif not metrics["targets"]["coverage_complete"]:
readiness_state = "not_ready"
reasons.append(
{
"code": "incomplete_review_coverage",
"message": "仍有启用案例没有评审结果。",
}
)
if not include_synthetic and cases:
required_modalities = {
"image_case_count": "真实案例集未覆盖图片输入。",
"mixed_text_image_case_count": "真实案例集未覆盖文本加图片混合输入。",
"document_case_count": "真实案例集未覆盖文档或日志输入。",
"invalid_input_case_count": "真实案例集未覆盖无效输入与人工追问路径。",
}
for key, message in required_modalities.items():
if modality_coverage[key] == 0:
if readiness_state == "failed":
readiness_state = "not_ready"
reasons.append({"code": f"missing_{key}", "message": message})
if metrics["targets"]["coverage_complete"] and not include_synthetic:
if metrics["targets"]["auto_creation_rate_met"] is not True:
reasons.append(
{
"code": "auto_creation_rate_below_target",
"message": "有效输入自动建单率未达到 70%。",
}
)
if metrics["targets"]["field_accuracy_met"] is not True:
reasons.append(
{
"code": "field_accuracy_below_target",
"message": "自动建单字段准确率未达到 90%。",
}
)
evidence_count = metrics["evaluation"]["evidence_field_count"]
evidence_rate = metrics["evaluation"]["evidence_coverage_rate"]
if not evidence_count or evidence_rate != 1.0:
reasons.append(
{
"code": "incomplete_field_evidence",
"message": "非空评估字段尚未全部具备可追溯证据。",
}
)
if metrics["evaluation"]["evaluated_timeline_item_count"] == 0:
reasons.append(
{
"code": "missing_timeline_baseline",
"message": "真实案例集没有可比对的期望消息时间线。",
}
)
exit_ready = not reasons and not include_synthetic
if exit_ready:
readiness_state = "passed"
elif readiness_state == "failed" and any(
reason["code"]
in {
"missing_real_cases",
"incomplete_review_coverage",
"missing_image_case_count",
"missing_mixed_text_image_case_count",
"missing_document_case_count",
"missing_invalid_input_case_count",
}
for reason in reasons
):
readiness_state = "not_ready"
return {
"schema_version": 1,
"generated_at": timezone.now().isoformat(),
"scope": "diagnostic_with_synthetic" if include_synthetic else "anonymized_real",
"status": readiness_state,
"exit_ready": exit_ready,
"reasons": reasons,
"modality_coverage": modality_coverage,
"metrics": metrics,
"case_results": case_results,
}