File size: 7,817 Bytes
9a88af0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
"""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