invoiceguard-code / tests /test_grader.py
piyush-mk's picture
Sync InvoiceGuard code for GRPO training job
9a88af0 verified
Raw
History Blame Contribute Delete
7.82 kB
"""Tests for the InvoiceGuard grading system."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from models import (
CaseData,
CaseHistory,
CompanyPolicy,
DecisionType,
Difficulty,
ExceptionType,
GRNLineItem,
GoodsReceiptNote,
GroundTruth,
Invoice,
InvoiceGuardState,
InvoiceLineItem,
POLineItem,
PurchaseOrder,
TaskID,
VendorProfile,
)
from graders.scoring import grade_episode, _score_explanation
def _make_case(
correct_decision=DecisionType.approve_for_payment,
correct_exception=ExceptionType.clean_match,
acceptable_evidence=None,
key_findings=None,
max_steps=10,
):
return CaseData(
case_id="TEST-001",
task_id=TaskID.task_1_clean_match,
difficulty=Difficulty.easy,
max_steps=max_steps,
invoice=Invoice(
invoice_number="INV-001",
supplier_name="Test",
supplier_id="SUP-001",
invoice_date="2024-01-01",
po_reference="PO-001",
line_items=[
InvoiceLineItem(
item_code="A",
description="Widget",
quantity_billed=10,
unit_price_billed=5.0,
line_total_billed=50.0,
)
],
subtotal=50.0,
tax=5.0,
total_amount=55.0,
),
purchase_order=PurchaseOrder(
po_number="PO-001",
supplier_id="SUP-001",
order_date="2024-01-01",
line_items=[
POLineItem(
item_code="A",
description="Widget",
ordered_quantity=10,
unit_price_ordered=5.0,
line_total_ordered=50.0,
)
],
),
goods_receipt_note=GoodsReceiptNote(
grn_number="GRN-001",
po_reference="PO-001",
receipt_date="2024-01-05",
line_items=[
GRNLineItem(
item_code="A",
quantity_received=10,
accepted_quantity=10,
)
],
),
vendor_profile=VendorProfile(
supplier_id="SUP-001",
supplier_name="Test",
risk_tier="low",
),
company_policy=CompanyPolicy(),
case_history=CaseHistory(),
ground_truth=GroundTruth(
correct_decision=correct_decision,
correct_exception_type=correct_exception,
acceptable_evidence=acceptable_evidence or [
"inspect_purchase_order",
"compare_quantity",
"compare_price",
],
key_findings=key_findings or [
"quantities match within tolerance",
"prices match within 5% tolerance",
],
),
)
def _make_state(
decision=None,
exception_type=None,
actions=None,
evidence=None,
explanation="",
step_count=5,
):
s = InvoiceGuardState()
s.step_count = step_count
s.final_decision = decision
s.final_exception_type = exception_type
s.actions_taken = actions or []
s.final_evidence = evidence or []
s.final_explanation = explanation
s.documents_revealed = [
a.replace("inspect_", "").replace("_", " ")
for a in (actions or [])
if a.startswith("inspect_")
]
s.repeated_action_counts = {}
for a in (actions or []):
s.repeated_action_counts[a] = s.repeated_action_counts.get(a, 0) + 1
return s
class TestGradeEpisode:
def test_perfect_score(self):
case = _make_case()
state = _make_state(
decision="approve_for_payment",
exception_type="clean_match",
actions=["inspect_purchase_order", "compare_quantity", "compare_price"],
evidence=["inspect_purchase_order", "compare_quantity", "compare_price"],
explanation="All quantities and prices match within 5% tolerance per company policy.",
step_count=4,
)
result = grade_episode(case, state)
assert result.score >= 0.85
assert result.decision_score == 1.0
assert result.exception_type_score == 1.0
def test_wrong_decision_zero_decision_score(self):
case = _make_case()
state = _make_state(
decision="reject_invoice",
exception_type="clean_match",
actions=["inspect_purchase_order"],
evidence=["inspect_purchase_order"],
explanation="Rejecting.",
)
result = grade_episode(case, state)
assert result.decision_score == 0.0
def test_partial_decision_credit(self):
case = _make_case(correct_decision=DecisionType.place_on_hold)
state = _make_state(
decision="escalate_for_supervisor_review",
exception_type="partial_receipt",
actions=["inspect_purchase_order"],
evidence=["inspect_purchase_order"],
)
result = grade_episode(case, state)
assert 0.0 < result.decision_score < 1.0
def test_no_decision_scores_zero(self):
case = _make_case()
state = _make_state(decision=None, step_count=10)
result = grade_episode(case, state)
assert result.decision_score == 0.0
assert result.score < 0.5
def test_score_in_bounds(self):
case = _make_case()
state = _make_state(
decision="approve_for_payment",
exception_type="clean_match",
actions=["inspect_purchase_order"],
evidence=["inspect_purchase_order"],
)
result = grade_episode(case, state)
assert 0.0 <= result.score <= 1.0
def test_all_rubric_dimensions_present(self):
case = _make_case()
state = _make_state(
decision="approve_for_payment",
exception_type="clean_match",
actions=["inspect_purchase_order"],
evidence=["inspect_purchase_order"],
explanation="Matches.",
)
result = grade_episode(case, state)
assert result.decision_score >= 0
assert result.exception_type_score >= 0
assert result.evidence_score >= 0
assert result.investigation_score >= 0
assert result.explanation_score >= 0
assert result.efficiency_score >= 0
class TestExplanationScoring:
def test_empty_explanation(self):
case = _make_case()
score = _score_explanation("", case, ["quantities match"])
assert score == 0.0
def test_good_explanation(self):
case = _make_case(
key_findings=["quantities match within tolerance", "price variance 3%"],
)
score = _score_explanation(
"All quantities match within the 5% tolerance threshold per company policy. "
"Price variance of 3% is acceptable.",
case,
case.ground_truth.key_findings,
)
assert score >= 0.6
def test_explanation_with_numbers(self):
case = _make_case()
score = _score_explanation(
"The invoice total of $55.00 matches the PO within tolerance.",
case,
[],
)
assert score > 0.0
def test_vague_explanation_scores_low(self):
case = _make_case()
score = _score_explanation("ok", case, ["quantities match"])
assert score <= 0.4