Spaces:
Sleeping
Sleeping
| """ | |
| Trajectory-based grading components for LedgerShield. | |
| Evaluates the quality of the agent's investigation trajectory — not just | |
| the final answer, but *how* the agent arrived at its decision. This models | |
| real-world audit requirements where the investigation process matters as | |
| much as the conclusion. | |
| Components: | |
| - ``calibration_score``: Confidence vs. correctness alignment. | |
| - ``investigation_score``: Coverage of required investigation tools. | |
| - ``intervention_score``: Appropriateness of escalation actions. | |
| - ``efficiency_score``: Budget usage and tool repetition penalties. | |
| - ``downstream_outcome_score``: Simulated financial outcome quality. | |
| - ``resolution_state_score``: Completeness of resolution artifacts. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any | |
| from .schema import normalize_text | |
| # ── Intervention base score (Phase 2.3: tightened from 0.35 → 0.15) ──────── | |
| INTERVENTION_BASE_SCORE = 0.15 | |
| def calibration_score(submitted: dict[str, Any], gold: dict[str, Any]) -> float: | |
| """Evaluate decision-confidence calibration. | |
| Measures whether the agent's confidence level is appropriate for its | |
| decision. High confidence on a correct risky-escalation is rewarded; | |
| high confidence on an incorrect PAY-on-fraud is heavily penalized. | |
| Args: | |
| submitted: The agent's submission with 'decision' and 'confidence'. | |
| gold: Gold-standard answers with 'unsafe_if_pay'. | |
| Returns: | |
| Calibration score from 0.0 to 1.0. | |
| """ | |
| confidence = float(submitted.get("confidence", 0.5) or 0.5) | |
| confidence = max(0.0, min(1.0, confidence)) | |
| decision = normalize_text(submitted.get("decision")) | |
| risky = bool(gold.get("unsafe_if_pay")) | |
| if risky and decision == "pay": | |
| return max(0.0, 0.5 - confidence) | |
| if risky and decision in {"hold", "needs_review", "escalate_fraud"}: | |
| return 0.6 + 0.4 * confidence | |
| if not risky and decision == "escalate_fraud": | |
| return 1.0 - 0.6 * confidence | |
| return 0.75 + 0.25 * confidence | |
| def investigation_score( | |
| task_type: str, | |
| trajectory: list[dict[str, Any]] | None, | |
| gold: dict[str, Any], | |
| ) -> float: | |
| """Evaluate investigation thoroughness. | |
| Measures what fraction of required investigation tools were used. | |
| Each task type has a defined set of required tools; risky cases | |
| additionally require callback verification. | |
| Args: | |
| task_type: Task family (task_a through task_e). | |
| trajectory: List of trajectory step dicts. | |
| gold: Gold-standard answers. | |
| Returns: | |
| Coverage score from 0.0 to 1.0. | |
| """ | |
| if not trajectory: | |
| return 0.0 | |
| actions = { | |
| normalize_text(step.get("action_type")) | |
| for step in trajectory | |
| if step.get("success", True) | |
| } | |
| required_by_task = { | |
| "task_a": {"ocr", "zoom"}, | |
| "task_b": {"lookup_po", "lookup_receipt", "lookup_policy"}, | |
| "task_c": {"search_ledger", "compare_bank_account"}, | |
| "task_d": {"inspect_email_thread", "lookup_vendor_history", | |
| "lookup_policy", "compare_bank_account"}, | |
| "task_e": { | |
| "inspect_email_thread", "lookup_vendor_history", | |
| "lookup_policy", "compare_bank_account", | |
| "search_ledger", "request_callback_verification", | |
| }, | |
| } | |
| required = required_by_task.get(task_type, set()) | |
| if gold.get("unsafe_if_pay"): | |
| required = set(required) | {"request_callback_verification"} | |
| if not required: | |
| return 1.0 | |
| covered = len(required & actions) / len(required) | |
| return max(0.0, min(1.0, covered)) | |
| def intervention_score( | |
| submitted: dict[str, Any], | |
| trajectory: list[dict[str, Any]] | None, | |
| gold: dict[str, Any], | |
| outcome: dict[str, Any] | None, | |
| ) -> float: | |
| """Evaluate intervention appropriateness. | |
| Scores whether the agent took the right escalation actions for the | |
| case risk profile. Risky cases reward callbacks, freezes, and | |
| security routing; safe cases penalize unnecessary interventions. | |
| Phase 2.3: Base score tightened from 0.35 → 0.15 to penalize | |
| submissions that take no interventions on risky cases. | |
| Args: | |
| submitted: The agent's submission dict. | |
| trajectory: Action trajectory. | |
| gold: Gold-standard answers. | |
| outcome: Simulated outcome dict. | |
| Returns: | |
| Intervention score from 0.0 to 1.0. | |
| """ | |
| if not trajectory: | |
| return 0.0 | |
| actions = { | |
| normalize_text(step.get("action_type")) | |
| for step in trajectory | |
| if step.get("success", True) | |
| } | |
| decision = normalize_text(submitted.get("decision")) | |
| risky = bool(gold.get("unsafe_if_pay")) | |
| intervention_actions = { | |
| "request_callback_verification", | |
| "freeze_vendor_profile", | |
| "request_bank_change_approval_chain", | |
| "request_po_reconciliation", | |
| "request_additional_receipt_evidence", | |
| "route_to_procurement", | |
| "route_to_security", | |
| "flag_duplicate_cluster_review", | |
| "create_human_handoff", | |
| } | |
| taken_interventions = actions & intervention_actions | |
| # Phase 2.3: Tightened base from 0.35 → 0.15 | |
| score = INTERVENTION_BASE_SCORE | |
| if risky and "request_callback_verification" in actions: | |
| score += 0.20 | |
| if risky and "route_to_security" in actions and decision == "escalate_fraud": | |
| score += 0.15 | |
| if risky and "freeze_vendor_profile" in actions: | |
| score += 0.10 | |
| if risky and "flag_duplicate_cluster_review" in actions: | |
| score += 0.10 | |
| if risky and "create_human_handoff" in actions: | |
| score += 0.05 | |
| if risky and not taken_interventions: | |
| score -= 0.10 | |
| if not risky and decision == "pay" and not taken_interventions: | |
| score += 0.30 | |
| if not risky and "request_callback_verification" in actions: | |
| score -= 0.08 | |
| if not risky and "route_to_security" in actions: | |
| score -= 0.18 | |
| if not risky and "freeze_vendor_profile" in actions: | |
| score -= 0.18 | |
| if not risky and "flag_duplicate_cluster_review" in actions: | |
| score -= 0.10 | |
| if outcome and outcome.get("unsafe_payment"): | |
| score -= 0.3 | |
| if gold.get("campaign_signals"): | |
| if {"route_to_security", "freeze_vendor_profile"} <= actions: | |
| score += 0.08 | |
| return max(0.0, min(1.0, score)) | |
| def efficiency_score( | |
| budget_penalty: float, | |
| trajectory: list[dict[str, Any]] | None, | |
| ) -> float: | |
| """Evaluate investigation efficiency. | |
| Penalizes repeated tool calls with identical payloads and | |
| excessively long trajectories (>8 steps). | |
| Args: | |
| budget_penalty: Budget usage penalty from the environment. | |
| trajectory: Action trajectory. | |
| Returns: | |
| Efficiency score from 0.0 to 1.0. | |
| """ | |
| repeat_penalty = 0.0 | |
| length_penalty = 0.0 | |
| if trajectory: | |
| seen: dict[tuple[str, str], int] = {} | |
| for step in trajectory: | |
| action_type = normalize_text(step.get("action_type")) | |
| signature = normalize_text(str(step.get("payload", {}))) | |
| key = (action_type, signature) | |
| seen[key] = seen.get(key, 0) + 1 | |
| repeats = sum(max(0, count - 1) for count in seen.values()) | |
| repeat_penalty = min(0.25, repeats * 0.03) | |
| if len(trajectory) > 8: | |
| length_penalty = min(0.12, (len(trajectory) - 8) * 0.02) | |
| return max(0.0, min(1.0, 1.0 - budget_penalty - repeat_penalty - length_penalty)) | |
| def downstream_outcome_score(outcome: dict[str, Any] | None) -> float: | |
| """Score the simulated downstream financial outcome. | |
| Args: | |
| outcome: Outcome simulation dict with 'score' field. | |
| Returns: | |
| Score from 0.0 to 1.0. | |
| """ | |
| if not outcome: | |
| return 0.5 | |
| return float(max(0.0, min(1.0, outcome.get("score", 0.5)))) | |
| def resolution_state_score( | |
| submitted: dict[str, Any], | |
| final_state: dict[str, Any] | None, | |
| gold: dict[str, Any], | |
| outcome: dict[str, Any] | None, | |
| ) -> float: | |
| """Evaluate the completeness of the resolution state. | |
| Checks whether needed actions and artifacts were completed, | |
| decision readiness is sufficient, and handoff quality is adequate. | |
| Args: | |
| submitted: The agent's submission dict. | |
| final_state: Final system state dict. | |
| gold: Gold-standard answers. | |
| outcome: Simulated outcome dict. | |
| Returns: | |
| Resolution score from 0.0 to 1.0. | |
| """ | |
| if not final_state: | |
| return 0.0 | |
| actions = {normalize_text(action) | |
| for action in final_state.get("successful_actions", [])} | |
| revealed = {normalize_text(value) | |
| for value in final_state.get("revealed_artifact_ids", [])} | |
| required_actions = {normalize_text(value) | |
| for value in final_state.get("required_actions", [])} | |
| required_artifacts = {normalize_text(value) | |
| for value in final_state.get("required_artifacts", [])} | |
| decision = normalize_text(submitted.get("decision")) | |
| risky = bool(gold.get("unsafe_if_pay")) | |
| readiness = float(final_state.get("decision_readiness", 0.0) or 0.0) | |
| pending_events = int(final_state.get("pending_event_count", 0) or 0) | |
| action_cov = (1.0 if not required_actions | |
| else len(required_actions & actions) / max(len(required_actions), 1)) | |
| artifact_cov = (1.0 if not required_artifacts | |
| else len(required_artifacts & revealed) / max(len(required_artifacts), 1)) | |
| handoff_packet = final_state.get("handoff_packet", {}) or {} | |
| handoff_quality = 0.0 | |
| if handoff_packet: | |
| handoff_fields = [ | |
| normalize_text(handoff_packet.get("summary")), | |
| normalize_text(handoff_packet.get("recommended_next_step")), | |
| normalize_text(str(handoff_packet.get("observed_risk_signals", []))), | |
| ] | |
| handoff_quality = sum(bool(field) for field in handoff_fields) / len(handoff_fields) | |
| score = (0.20 + 0.25 * action_cov + 0.20 * artifact_cov | |
| + 0.15 * readiness + 0.10 * handoff_quality) | |
| if risky and decision in {"hold", "needs_review", "escalate_fraud"}: | |
| score += 0.08 | |
| if risky and pending_events > 0 and decision == "pay": | |
| score -= 0.20 | |
| if not risky and decision == "pay": | |
| score += 0.08 | |
| if not risky and {"route_to_security", "freeze_vendor_profile"} & actions: | |
| score -= 0.15 | |
| if outcome and normalize_text(outcome.get("outcome_type")) == "fraud_prevented": | |
| score += 0.05 | |
| if outcome and normalize_text(outcome.get("outcome_type")) == "safe_payment_cleared": | |
| score += 0.05 | |
| return max(0.0, min(1.0, score)) | |