uofa-demo / src /uofa_cli /eval_scoring.py
cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
3.88 kB
"""Extraction-vs-ground-truth scoring (precision / recall / F1 / level accuracy).
Promoted from tests/test_extract_eval.py so that `uofa setup verify`
(REQ-DIST-006) and the existing test suite share one implementation.
The verify command needs the same F1 metric as the eval tests use; if
they drift the install verification stops being a faithful reproduction
of test behavior.
"""
from __future__ import annotations
from typing import Any
from uofa_cli.llm_extractor import ExtractionResult
def score_extraction(result: ExtractionResult, ground_truth: dict) -> dict[str, Any]:
"""Score extraction results against ground truth.
Args:
result: ExtractionResult from the LLM.
ground_truth: Dict with 'expected_factors' list, each having:
- factor_type: str
- expected_status: "assessed" | "mentioned" | "not-found" | "ambiguous"
- expected_level_range: [min, max] (optional)
- evidence_present: bool
Returns:
Dict with precision, recall, F1, level_accuracy, per-factor details.
"""
extracted_factors: dict[str, dict] = {}
for factor in result.credibility_factors:
ft = factor.get("factor_type")
if ft and ft.value:
extracted_factors[ft.value] = factor
tp = fp = fn = tn = 0
level_matches = 0
level_total = 0
per_factor: dict[str, dict] = {}
for gt_factor in ground_truth.get("expected_factors", []):
ft_name = gt_factor["factor_type"]
expected_status = gt_factor["expected_status"]
extracted = extracted_factors.pop(ft_name, None)
if expected_status in ("assessed", "mentioned"):
if extracted is not None:
tp += 1
per_factor[ft_name] = {"status": "TP"}
level_range = gt_factor.get("expected_level_range")
if level_range and extracted.get("achieved_level"):
level_total += 1
achieved = extracted["achieved_level"].value
if achieved is not None:
lo, hi = level_range
if lo - 1 <= achieved <= hi + 1: # ±1 tolerance
level_matches += 1
per_factor[ft_name]["level_match"] = True
else:
per_factor[ft_name]["level_match"] = False
else:
fn += 1
per_factor[ft_name] = {"status": "FN"}
elif expected_status == "not-found":
if extracted is not None:
fp += 1
per_factor[ft_name] = {"status": "FP"}
else:
tn += 1
per_factor[ft_name] = {"status": "TN"}
elif expected_status == "ambiguous":
if extracted is not None:
tp += 1
per_factor[ft_name] = {"status": "TP (ambiguous)"}
else:
tn += 1
per_factor[ft_name] = {"status": "TN (ambiguous)"}
# Any remaining extracted factors not in ground truth are false positives.
for ft_name in extracted_factors:
fp += 1
per_factor[ft_name] = {"status": "FP (extra)"}
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
level_accuracy = level_matches / level_total if level_total > 0 else 0.0
return {
"total_factors": len(ground_truth.get("expected_factors", [])),
"true_positives": tp,
"false_positives": fp,
"false_negatives": fn,
"true_negatives": tn,
"precision": precision,
"recall": recall,
"f1": f1,
"level_accuracy": level_accuracy,
"per_factor": per_factor,
}