piyush-mk commited on
Commit
9a88af0
·
verified ·
1 Parent(s): 98115dc

Sync InvoiceGuard code for GRPO training job

Browse files
.openenvignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.example
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ inference.py
7
+ test_live_space.py
8
+ eval_round2.py
9
+ outputs/round2/
10
+ training/
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_environment.py::TestReset::test_all_canonical_tasks_load",
3
+ "tests/test_environment.py::TestReset::test_reset_clean_state",
4
+ "tests/test_environment.py::TestReset::test_reset_has_available_actions",
5
+ "tests/test_environment.py::TestReset::test_reset_provides_goal",
6
+ "tests/test_environment.py::TestReset::test_reset_provides_suggested_actions",
7
+ "tests/test_environment.py::TestReset::test_reset_returns_observation",
8
+ "tests/test_environment.py::TestReset::test_reset_sequential_tasks",
9
+ "tests/test_environment.py::TestState::test_state_accessible",
10
+ "tests/test_environment.py::TestState::test_state_tracks_actions",
11
+ "tests/test_environment.py::TestStep::test_findings_accumulate",
12
+ "tests/test_environment.py::TestStep::test_investigation_action",
13
+ "tests/test_environment.py::TestStep::test_repeated_action_penalty",
14
+ "tests/test_environment.py::TestStep::test_step_decrements_remaining",
15
+ "tests/test_environment.py::TestStep::test_suggested_actions_update_after_step",
16
+ "tests/test_environment.py::TestSubmission::test_correct_resolution",
17
+ "tests/test_environment.py::TestSubmission::test_missing_fields_returns_error",
18
+ "tests/test_environment.py::TestSubmission::test_wrong_decision_lower_score",
19
+ "tests/test_environment.py::TestTaskDiversity::test_scores_in_valid_range",
20
+ "tests/test_grader.py::TestExplanationScoring::test_empty_explanation",
21
+ "tests/test_grader.py::TestExplanationScoring::test_explanation_with_numbers",
22
+ "tests/test_grader.py::TestExplanationScoring::test_good_explanation",
23
+ "tests/test_grader.py::TestExplanationScoring::test_vague_explanation_scores_low",
24
+ "tests/test_grader.py::TestGradeEpisode::test_all_rubric_dimensions_present",
25
+ "tests/test_grader.py::TestGradeEpisode::test_no_decision_scores_zero",
26
+ "tests/test_grader.py::TestGradeEpisode::test_partial_decision_credit",
27
+ "tests/test_grader.py::TestGradeEpisode::test_perfect_score",
28
+ "tests/test_grader.py::TestGradeEpisode::test_score_in_bounds",
29
+ "tests/test_grader.py::TestGradeEpisode::test_wrong_decision_zero_decision_score",
30
+ "tests/test_models.py::TestAction::test_minimal_action",
31
+ "tests/test_models.py::TestAction::test_resolution_action",
32
+ "tests/test_models.py::TestBusinessEntities::test_company_policy_defaults",
33
+ "tests/test_models.py::TestBusinessEntities::test_invoice_creation",
34
+ "tests/test_models.py::TestEnums::test_action_types_count",
35
+ "tests/test_models.py::TestEnums::test_decision_types",
36
+ "tests/test_models.py::TestEnums::test_difficulty_levels",
37
+ "tests/test_models.py::TestEnums::test_exception_types_include_key_variants",
38
+ "tests/test_models.py::TestEnums::test_task_ids_minimum_12_canonical",
39
+ "tests/test_models.py::TestEnums::test_task_ids_minimum_6_canonical",
40
+ "tests/test_models.py::TestGraderResult::test_score_bounds",
41
+ "tests/test_models.py::TestGraderResult::test_valid_score",
42
+ "tests/test_models.py::TestObservation::test_defaults",
43
+ "tests/test_models.py::TestObservation::test_populated",
44
+ "tests/test_models.py::TestState::test_defaults",
45
+ "tests/test_models.py::TestState::test_mutation"
46
+ ]
Dockerfile ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=invoice_guard
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Invoice Guard Environment Server
3
+ emoji: 📋
4
+ colorFrom: purple
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # InvoiceGuard -- Three-Way Invoice Matching Environment
15
+
16
+ An OpenEnv environment that simulates accounts payable exception resolution. An AI agent investigates multi-document business cases -- invoices, purchase orders, goods receipt notes, vendor profiles, and company policies -- to detect discrepancies, classify exception types, and render correct decisions.
17
+
18
+ ## Motivation
19
+
20
+ Three-way invoice matching is one of the most common and error-prone tasks in enterprise finance. Accounts payable teams manually compare invoices against purchase orders and goods receipt notes to detect overbilling, partial shipments, duplicate submissions, and price variances. This environment turns that real-world workflow into a structured evaluation benchmark where an AI agent must gather evidence through sequential investigation actions and reach a correct, policy-compliant decision.
21
+
22
+ ## Tasks
23
+
24
+ | Task ID | Description | Difficulty | Expected Decision | Exception Type |
25
+ |---------|-------------|------------|-------------------|----------------|
26
+ | `task_1_clean_match` | All documents align within tolerance | Easy | `approve_for_payment` | `clean_match` |
27
+ | `task_2_partial_receipt` | Billed quantity exceeds received quantity | Moderate | `place_on_hold` | `partial_receipt` |
28
+ | `task_3_price_variance` | Unit price exceeds PO price beyond tolerance | Moderate | `escalate_for_supervisor_review` | `price_mismatch` |
29
+ | `task_4_duplicate_invoice` | Previously processed invoice resubmitted | Hard | `reject_invoice` | `duplicate_invoice` |
30
+ | `task_5_mixed_discrepancy` | Invoice with both price variance and partial receipt; conflicting signals | Hard | `escalate_for_supervisor_review` | `price_mismatch` |
31
+ | `task_6_false_positive_duplicate` | Invoice looks like a duplicate but is a legitimate recurring order for a different PO | Hard | `approve_for_payment` | `clean_match` |
32
+ | `task_7_retroactive_price` | Vendor applied a price increase retroactively; PO predates the effective date | Hard | `escalate_for_supervisor_review` | `price_mismatch` |
33
+ | `task_8_split_invoice_pattern` | Supplier splits large order into sub-threshold invoices to dodge auto-approval | Hard | `escalate_for_supervisor_review` | `policy_violation` |
34
+ | `task_9_clean_from_risky_vendor` | Clean invoice from high-risk vendor with 5 prior incidents -- false-positive trap | Hard | `approve_for_payment` | `clean_match` |
35
+ | `task_10_rounding_false_alarm` | Invoice total off by $0.01 due to line-item rounding -- all else matches perfectly | Hard | `approve_for_payment` | `clean_match` |
36
+ | `task_11_authorized_overship` | GRN shows 110 received vs 100 ordered, but PO amendment authorized 10% overship | Hard | `approve_for_payment` | `clean_match` |
37
+ | `task_12_corrected_resubmission` | Corrected invoice (INV-R1) looks like a duplicate of rejected original | Hard | `approve_for_payment` | `clean_match` |
38
+
39
+ Each task includes fully synthetic business documents with deterministic ground truth and a multi-criteria grader. Tasks 5-8 test ambiguity, temporal reasoning, and cross-case pattern detection. Tasks 9-12 are false-positive traps where surface signals mislead toward rejection but deeper investigation reveals the correct answer is approval.
40
+
41
+ ## Action Space
42
+
43
+ The agent has 12 available actions divided into investigation, proposal, and terminal categories.
44
+
45
+ ### Investigation Actions (provide `action_type` only)
46
+
47
+ | Action | Description |
48
+ |--------|-------------|
49
+ | `inspect_invoice_line_items` | Reveal detailed invoice line items (codes, quantities, prices, totals) |
50
+ | `inspect_purchase_order` | Reveal purchase order details (ordered quantities, agreed prices) |
51
+ | `inspect_goods_receipt_note` | Reveal goods receipt note (received/accepted/rejected quantities) |
52
+ | `inspect_vendor_profile` | Reveal vendor risk tier, duplicate history, escalation thresholds |
53
+ | `inspect_policy_rules` | Reveal company matching tolerances and escalation rules |
54
+ | `check_for_duplicate_invoice` | Search case history for similar/processed invoices |
55
+ | `compare_quantity` | Compare billed vs ordered vs received quantities per line item |
56
+ | `compare_price` | Compare billed unit prices vs PO-agreed prices per line item |
57
+ | `compare_totals` | Verify subtotal consistency, PO total match, tax, and grand total |
58
+ | `summarize_findings` | Get a numbered summary of all collected findings |
59
+
60
+ ### Proposal Action
61
+
62
+ | Action | Description |
63
+ |--------|-------------|
64
+ | `propose_exception_type` | Declare the suspected exception type (with `exception_type` field) |
65
+
66
+ ### Terminal Action
67
+
68
+ | Action | Required Fields | Description |
69
+ |--------|----------------|-------------|
70
+ | `submit_final_resolution` | `final_decision`, `exception_type`, `evidence_references`, `explanation` | End the episode with a decision |
71
+
72
+ ### Action JSON Format
73
+
74
+ ```json
75
+ {"action_type": "inspect_purchase_order"}
76
+ ```
77
+
78
+ ```json
79
+ {
80
+ "action_type": "submit_final_resolution",
81
+ "final_decision": "escalate_for_supervisor_review",
82
+ "exception_type": "price_mismatch",
83
+ "evidence_references": ["inspect_purchase_order", "compare_price", "inspect_policy_rules"],
84
+ "explanation": "Price variance of 10% exceeds 5% tolerance, requiring supervisor escalation per company policy."
85
+ }
86
+ ```
87
+
88
+ ## Observation Space
89
+
90
+ Each step returns an `InvoiceGuardObservation` with these fields:
91
+
92
+ | Field | Type | Description |
93
+ |-------|------|-------------|
94
+ | `case_id` | `str` | Unique case identifier |
95
+ | `task_id` | `str` | Which task is being evaluated |
96
+ | `difficulty` | `str` | `easy`, `moderate`, or `hard` |
97
+ | `invoice_summary` | `str` | One-line invoice overview (supplier, amount, PO ref) |
98
+ | `goal` | `str` | Natural language description of the agent's objective |
99
+ | `available_actions` | `list[str]` | Actions the agent can take |
100
+ | `revealed_documents` | `list[str]` | Documents the agent has already inspected |
101
+ | `findings` | `list[str]` | Accumulated investigation findings |
102
+ | `remaining_steps` | `int` | Steps left before timeout |
103
+ | `last_action_result` | `str` | Detailed output from the most recent action |
104
+ | `last_action_error` | `bool` | Whether the last action had an error |
105
+ | `warnings` | `list[str]` | System warnings (e.g., low steps remaining) |
106
+ | `reward` | `float` | Reward signal for the last action |
107
+ | `done` | `bool` | Whether the episode has ended |
108
+ | `metadata` | `dict` | Grader results (on episode end) |
109
+
110
+ ## Reward Design
111
+
112
+ The environment provides dense, per-step rewards:
113
+
114
+ | Event | Reward |
115
+ |-------|--------|
116
+ | Reveal a new document | +0.05 |
117
+ | Useful comparison finding discrepancy | +0.10 |
118
+ | Confirm no issue (clean comparison) | +0.02 |
119
+ | Propose correct exception type | +0.15 |
120
+ | Propose wrong exception type | -0.05 |
121
+ | Summarize findings | +0.03 |
122
+ | Repeat an already-seen action | -0.02 |
123
+ | Submit correct final decision | +0.30 |
124
+ | Submit wrong final decision | -0.20 |
125
+ | Correct exception type on resolution | +0.15 |
126
+
127
+ ## Grading
128
+
129
+ Episodes are scored by a deterministic grader on six weighted criteria (total = 1.0):
130
+
131
+ | Criterion | Weight | Description |
132
+ |-----------|--------|-------------|
133
+ | Decision correctness | 0.35 | Exact match = 1.0, partial credit for related decisions |
134
+ | Exception type | 0.20 | Correct classification of the exception |
135
+ | Evidence sufficiency | 0.15 | Did the agent inspect the right documents? |
136
+ | Investigation quality | 0.10 | Breadth of document review and findings |
137
+ | Explanation quality | 0.10 | Cites specific numbers, references policy, uses correct terminology |
138
+ | Efficiency | 0.10 | Completing within step budget without waste |
139
+
140
+ ## Decisions
141
+
142
+ | Decision | When to use |
143
+ |----------|-------------|
144
+ | `approve_for_payment` | All matches are clean and within tolerance |
145
+ | `place_on_hold` | Billed quantity exceeds received quantity |
146
+ | `reject_invoice` | Duplicate invoice or fraudulent submission |
147
+ | `escalate_for_supervisor_review` | Price/total variance exceeds tolerance, high-value invoice |
148
+
149
+ ## Setup & Usage
150
+
151
+ ### Prerequisites
152
+
153
+ - Python 3.10+
154
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
155
+ - Docker (for containerized deployment)
156
+
157
+ ### Local Development
158
+
159
+ ```bash
160
+ cd invoice_guard
161
+
162
+ # Install dependencies with uv
163
+ uv sync
164
+
165
+ # Start the server
166
+ uv run uvicorn server.app:app --host 0.0.0.0 --port 8000
167
+
168
+ # Validate
169
+ openenv validate
170
+ ```
171
+
172
+ ### Running the Baseline Agent
173
+
174
+ ```bash
175
+ cd invoice_guard
176
+
177
+ # Create .env from the example
178
+ cp .env.example .env
179
+ # Edit .env with your API key and model
180
+
181
+ # Run inference
182
+ uv run python inference.py
183
+ ```
184
+
185
+ ### Docker
186
+
187
+ ```bash
188
+ cd invoice_guard
189
+
190
+ # Build
191
+ docker build -t invoiceguard .
192
+
193
+ # Run
194
+ docker run -p 8000:8000 invoiceguard
195
+
196
+ # Run with hackathon resource constraints
197
+ docker run --cpus=2 --memory=8g -p 8000:8000 invoiceguard
198
+
199
+ # Validate against running container
200
+ openenv validate --url http://localhost:8000
201
+ ```
202
+
203
+ ### Deploy to Hugging Face Spaces
204
+
205
+ ```bash
206
+ cd invoice_guard
207
+ openenv push
208
+ # or: openenv push --namespace my-org --private
209
+ ```
210
+
211
+ ## Baseline Scores (12 tasks)
212
+
213
+ | Model | Avg Score | task_1 | task_2 | task_3 | task_4 | task_5 | task_6 | task_7 | task_8 | task_9 | task_10 | task_11 | task_12 |
214
+ |-------|-----------|--------|--------|--------|--------|--------|--------|--------|--------|--------|---------|---------|---------|
215
+ | **gpt-4.1-mini** | **0.87** | 0.95 | 0.78 | 0.75 | 0.95 | 0.78 | 0.95 | 0.75 | 0.75 | 0.95 | 0.95 | 0.98 | 0.95 |
216
+ | **gpt-5.4-mini** | **0.87** | 0.98 | 0.95 | 0.73 | 0.98 | 0.75 | 0.98 | 0.75 | 0.50 | 0.95 | 0.98 | 0.98 | 0.95 |
217
+ | **gpt-4.1** | **0.79** | 0.95 | 0.75 | 0.75 | 0.47 | 0.78 | 0.95 | 0.78 | 0.75 | 0.40 | 0.95 | 0.95 | 0.95 |
218
+ | **gpt-5.4** | **0.78** | 0.95 | 0.75 | 0.70 | 0.47 | 0.75 | 0.95 | 0.78 | 0.75 | 0.40 | 0.95 | 0.95 | 0.95 |
219
+
220
+ Key observations:
221
+ - **Task 9** (clean invoice from risky vendor) is a strong false-positive trap: both gpt-4.1 and gpt-5.4 escalated instead of approving, scoring only 0.40.
222
+ - **Task 8** (split invoice pattern) tripped gpt-5.4-mini, which rejected instead of escalating (0.50).
223
+ - **Task 4** (duplicate invoice) tripped both full-size models, which escalated instead of rejecting (0.47).
224
+ - Mini models consistently outperform their larger counterparts on this benchmark, suggesting the tasks reward focused analysis over verbose reasoning.
225
+
226
+ ## Project Structure
227
+
228
+ ```
229
+ invoice_guard/
230
+ |---- openenv.yaml # OpenEnv manifest
231
+ |---- pyproject.toml # Dependencies (managed by uv)
232
+ |---- uv.lock # Locked dependencies
233
+ |---- Dockerfile # Container image definition
234
+ |---- models.py # All data models (Action, Observation, State, entities)
235
+ |---- client.py # InvoiceGuardEnv client (EnvClient subclass)
236
+ |---- inference.py # Baseline LLM agent script
237
+ |---- .env.example # Environment variable template
238
+ |---- tasks/
239
+ | |---- __init__.py
240
+ | |---- definitions.py # Synthetic case templates and ground truth
241
+ |---- graders/
242
+ | |---- __init__.py
243
+ | |---- scoring.py # Deterministic multi-criteria grader
244
+ |---- server/
245
+ |---- __init__.py
246
+ |---- app.py # FastAPI application (HTTP + WebSocket)
247
+ |---- invoice_guard_environment.py # Core Environment implementation
248
+ ```
__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Invoice Guard Environment."""
8
+
9
+ from .client import InvoiceGuardEnv
10
+ from .models import InvoiceGuardAction, InvoiceGuardObservation
11
+
12
+ __all__ = [
13
+ "InvoiceGuardAction",
14
+ "InvoiceGuardObservation",
15
+ "InvoiceGuardEnv",
16
+ ]
client.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InvoiceGuard Environment Client."""
2
+
3
+ from typing import Dict
4
+
5
+ from openenv.core import EnvClient
6
+ from openenv.core.client_types import StepResult
7
+ from openenv.core.env_server.types import State
8
+
9
+ try:
10
+ from .models import (
11
+ InvoiceGuardAction,
12
+ InvoiceGuardObservation,
13
+ InvoiceGuardState,
14
+ )
15
+ except (ImportError, ModuleNotFoundError):
16
+ from models import (
17
+ InvoiceGuardAction,
18
+ InvoiceGuardObservation,
19
+ InvoiceGuardState,
20
+ )
21
+
22
+
23
+ class InvoiceGuardEnv(
24
+ EnvClient[InvoiceGuardAction, InvoiceGuardObservation, InvoiceGuardState]
25
+ ):
26
+ """
27
+ Client for the InvoiceGuard Environment.
28
+
29
+ Example:
30
+ >>> async with InvoiceGuardEnv(base_url="http://localhost:8000") as client:
31
+ ... result = await client.reset(task_id="task_1_clean_match")
32
+ ... print(result.observation.invoice_summary)
33
+ """
34
+
35
+ def _step_payload(self, action: InvoiceGuardAction) -> Dict:
36
+ return action.model_dump(exclude_none=True)
37
+
38
+ def _parse_result(self, payload: Dict) -> StepResult[InvoiceGuardObservation]:
39
+ obs_data = payload.get("observation", {})
40
+ observation = InvoiceGuardObservation(
41
+ case_id=obs_data.get("case_id", ""),
42
+ task_id=obs_data.get("task_id", ""),
43
+ difficulty=obs_data.get("difficulty", ""),
44
+ invoice_summary=obs_data.get("invoice_summary", ""),
45
+ goal=obs_data.get("goal", ""),
46
+ available_actions=obs_data.get("available_actions", []),
47
+ revealed_documents=obs_data.get("revealed_documents", []),
48
+ findings=obs_data.get("findings", []),
49
+ remaining_steps=obs_data.get("remaining_steps", 0),
50
+ last_action_result=obs_data.get("last_action_result", ""),
51
+ last_action_error=obs_data.get("last_action_error", False),
52
+ warnings=obs_data.get("warnings", []),
53
+ grader_result=obs_data.get("grader_result", {}),
54
+ done=payload.get("done", False),
55
+ reward=payload.get("reward"),
56
+ metadata=obs_data.get("metadata", {}),
57
+ )
58
+
59
+ return StepResult(
60
+ observation=observation,
61
+ reward=payload.get("reward"),
62
+ done=payload.get("done", False),
63
+ )
64
+
65
+ def _parse_state(self, payload: Dict) -> InvoiceGuardState:
66
+ return InvoiceGuardState(
67
+ episode_id=payload.get("episode_id"),
68
+ step_count=payload.get("step_count", 0),
69
+ task_id=payload.get("task_id", ""),
70
+ difficulty=payload.get("difficulty", ""),
71
+ case_id=payload.get("case_id", ""),
72
+ actions_taken=payload.get("actions_taken", []),
73
+ documents_revealed=payload.get("documents_revealed", []),
74
+ findings_collected=payload.get("findings_collected", []),
75
+ is_finalized=payload.get("is_finalized", False),
76
+ cumulative_reward=payload.get("cumulative_reward", 0.0),
77
+ )
eval_round2.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Round 2 evaluation harness for InvoiceGuard.
3
+
4
+ Runs a chosen task slice (canonical / hard / all) against any
5
+ OpenAI-compatible model and writes a structured JSON report containing,
6
+ for every task: grader score, six grader sub-component scores, decision,
7
+ exception type, steps used, shortcut-penalty flag, and the per-step
8
+ reward_components log emitted by the environment.
9
+
10
+ Designed so baseline_*.json (Stage F) and trained_*.json (Stage G) share
11
+ the SAME schema and can be diffed with `--compare A.json B.json`.
12
+
13
+ Usage examples (PowerShell):
14
+ # Baseline run on hard slice with default model from env vars
15
+ uv run python eval_round2.py --slice hard --model-tag baseline
16
+
17
+ # Run both slices, write to outputs/round2/
18
+ uv run python eval_round2.py --slice all --model-tag baseline
19
+
20
+ # Compare baseline vs trained
21
+ uv run python eval_round2.py --compare outputs/round2/hard__baseline.json outputs/round2/hard__trained.json
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ import time
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import List
34
+
35
+ from dotenv import load_dotenv
36
+ from openai import OpenAI
37
+
38
+ # Reuse the existing inference helpers so prompts/parsing stay identical.
39
+ from inference import ( # type: ignore
40
+ API_BASE_URL,
41
+ API_KEY,
42
+ MODEL_NAME,
43
+ run_episode_local,
44
+ )
45
+ from models import TaskID # type: ignore
46
+ from server.invoice_guard_environment import InvoiceGuardEnvironment # type: ignore
47
+ from tasks import TASK_LIST, HARD_TASK_LIST # type: ignore
48
+
49
+
50
+ load_dotenv()
51
+
52
+ OUT_DIR_DEFAULT = Path(__file__).parent / "outputs" / "round2"
53
+
54
+ # Grader sub-component keys we always extract for the report.
55
+ COMPONENT_KEYS = [
56
+ "decision_score",
57
+ "exception_type_score",
58
+ "evidence_score",
59
+ "investigation_score",
60
+ "explanation_score",
61
+ "efficiency_score",
62
+ ]
63
+
64
+
65
+ def _slice_tasks(slice_name: str) -> List[TaskID]:
66
+ if slice_name == "canonical":
67
+ return list(TASK_LIST)
68
+ if slice_name == "hard":
69
+ return list(HARD_TASK_LIST)
70
+ if slice_name == "all":
71
+ return list(TASK_LIST) + list(HARD_TASK_LIST)
72
+ raise SystemExit(f"Unknown --slice: {slice_name!r}")
73
+
74
+
75
+ def _run_slice(
76
+ slice_name: str,
77
+ model_tag: str,
78
+ out_dir: Path,
79
+ ) -> Path:
80
+ """Run one slice end-to-end and write the JSON report. Returns path."""
81
+ out_dir.mkdir(parents=True, exist_ok=True)
82
+ task_ids = _slice_tasks(slice_name)
83
+
84
+ print("=" * 70, flush=True)
85
+ print(f"InvoiceGuard Round 2 eval | slice={slice_name} | tasks={len(task_ids)}", flush=True)
86
+ print(f"Model: {MODEL_NAME} | Base URL: {API_BASE_URL}", flush=True)
87
+ print(f"Tag: {model_tag} | Out dir: {out_dir}", flush=True)
88
+ print("=" * 70, flush=True)
89
+
90
+ llm = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
91
+ env = InvoiceGuardEnvironment()
92
+
93
+ per_task: list[dict] = []
94
+ started_at = datetime.now(timezone.utc).isoformat()
95
+ t0 = time.time()
96
+
97
+ for task_id in task_ids:
98
+ start = time.time()
99
+ # `run_episode_local` already handles the conversation loop and stdout
100
+ # in the hackathon-mandated [START]/[STEP]/[END] format.
101
+ result = run_episode_local(env, llm, task_id)
102
+ elapsed = time.time() - start
103
+
104
+ grader_breakdown = result.get("grader_breakdown") or {}
105
+ components = {k: float(grader_breakdown.get(k, 0.0)) for k in COMPONENT_KEYS}
106
+
107
+ # Pull richer metadata from the env directly (last terminal obs went
108
+ # back through the inference loop but we still hold env.state).
109
+ s = env.state
110
+ reward_components = list(getattr(s, "reward_components", []))
111
+
112
+ per_task.append(
113
+ {
114
+ "task_id": result["task_id"],
115
+ "decision": result.get("decision"),
116
+ "exception_type": result.get("exception_type"),
117
+ "steps": result["steps"],
118
+ "grader_score": result["grader_score"],
119
+ "components": components,
120
+ "shortcut_penalty_applied": any(
121
+ rc.get("penalties", {}).get("shortcut") for rc in reward_components
122
+ ),
123
+ "documents_revealed": list(s.documents_revealed),
124
+ "actions_taken": list(s.actions_taken),
125
+ "reward_components": reward_components,
126
+ "wall_clock_s": round(elapsed, 2),
127
+ }
128
+ )
129
+ print(
130
+ f" >> {result['task_id']:38s} "
131
+ f"score={result['grader_score']:.4f} "
132
+ f"steps={result['steps']:>2} "
133
+ f"decision={result.get('decision')} "
134
+ f"({elapsed:.1f}s)",
135
+ flush=True,
136
+ )
137
+
138
+ total_elapsed = time.time() - t0
139
+ avg_score = (
140
+ sum(t["grader_score"] for t in per_task) / len(per_task) if per_task else 0.0
141
+ )
142
+ component_avgs = {
143
+ k: round(sum(t["components"][k] for t in per_task) / max(len(per_task), 1), 4)
144
+ for k in COMPONENT_KEYS
145
+ }
146
+ decision_correct = sum(
147
+ 1 for t in per_task if t["components"]["decision_score"] >= 0.99
148
+ )
149
+
150
+ report = {
151
+ "schema_version": 1,
152
+ "slice": slice_name,
153
+ "model_tag": model_tag,
154
+ "model_name": MODEL_NAME,
155
+ "api_base_url": API_BASE_URL,
156
+ "started_at": started_at,
157
+ "wall_clock_s": round(total_elapsed, 2),
158
+ "n_tasks": len(per_task),
159
+ "summary": {
160
+ "avg_score": round(avg_score, 4),
161
+ "decision_correct": decision_correct,
162
+ "decision_correct_rate": round(decision_correct / max(len(per_task), 1), 4),
163
+ "component_avgs": component_avgs,
164
+ "shortcut_episodes": sum(
165
+ 1 for t in per_task if t["shortcut_penalty_applied"]
166
+ ),
167
+ },
168
+ "tasks": per_task,
169
+ }
170
+
171
+ out_path = out_dir / f"{slice_name}__{model_tag}.json"
172
+ out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
173
+
174
+ print("-" * 70, flush=True)
175
+ print(f"Avg score: {avg_score:.4f}", flush=True)
176
+ print(f"Decision correct: {decision_correct}/{len(per_task)}", flush=True)
177
+ print(f"Component averages: {component_avgs}", flush=True)
178
+ print(f"Wrote report: {out_path}", flush=True)
179
+ print("=" * 70, flush=True)
180
+
181
+ return out_path
182
+
183
+
184
+ def _compare(a_path: Path, b_path: Path) -> None:
185
+ a = json.loads(a_path.read_text(encoding="utf-8"))
186
+ b = json.loads(b_path.read_text(encoding="utf-8"))
187
+
188
+ print("=" * 78, flush=True)
189
+ print(f"COMPARE A: {a_path.name} ({a['model_tag']})", flush=True)
190
+ print(f" B: {b_path.name} ({b['model_tag']})", flush=True)
191
+ print("=" * 78, flush=True)
192
+
193
+ a_by = {t["task_id"]: t for t in a["tasks"]}
194
+ b_by = {t["task_id"]: t for t in b["tasks"]}
195
+ keys = list(a_by.keys())
196
+
197
+ print(f"{'task':40s} {'A':>7s} {'B':>7s} {'delta':>8s}", flush=True)
198
+ print("-" * 78, flush=True)
199
+ for k in keys:
200
+ if k not in b_by:
201
+ continue
202
+ sa = a_by[k]["grader_score"]
203
+ sb = b_by[k]["grader_score"]
204
+ d = sb - sa
205
+ marker = " +" if d > 0.01 else (" -" if d < -0.01 else " ")
206
+ print(f"{k:40s} {sa:7.4f} {sb:7.4f} {d:+8.4f}{marker}", flush=True)
207
+
208
+ print("-" * 78, flush=True)
209
+ print(
210
+ f"{'AVERAGE':40s} {a['summary']['avg_score']:7.4f} "
211
+ f"{b['summary']['avg_score']:7.4f} "
212
+ f"{b['summary']['avg_score'] - a['summary']['avg_score']:+8.4f}",
213
+ flush=True,
214
+ )
215
+ for ck in COMPONENT_KEYS:
216
+ ac = a["summary"]["component_avgs"][ck]
217
+ bc = b["summary"]["component_avgs"][ck]
218
+ print(f" {ck:38s} {ac:7.4f} {bc:7.4f} {bc - ac:+8.4f}", flush=True)
219
+ print("=" * 78, flush=True)
220
+
221
+
222
+ def main() -> None:
223
+ p = argparse.ArgumentParser(description="InvoiceGuard Round 2 eval harness.")
224
+ p.add_argument("--slice", choices=["canonical", "hard", "all"], default="hard")
225
+ p.add_argument("--model-tag", default="baseline",
226
+ help="Tag used in the output filename (e.g. baseline, trained, qwen3b-grpo).")
227
+ p.add_argument("--out-dir", type=Path, default=OUT_DIR_DEFAULT)
228
+ p.add_argument("--compare", nargs=2, metavar=("A", "B"), type=Path, default=None,
229
+ help="Compare two report JSONs and print a delta table.")
230
+ args = p.parse_args()
231
+
232
+ if args.compare:
233
+ _compare(args.compare[0], args.compare[1])
234
+ return
235
+
236
+ if not API_KEY:
237
+ print(
238
+ "WARNING: no API key found in env (HF_TOKEN / API_KEY / OPENAI_API_KEY). "
239
+ "LLM calls will fail; this run will only verify the harness wiring.",
240
+ file=sys.stderr,
241
+ flush=True,
242
+ )
243
+
244
+ if args.slice == "all":
245
+ _run_slice("canonical", args.model_tag, args.out_dir)
246
+ _run_slice("hard", args.model_tag, args.out_dir)
247
+ else:
248
+ _run_slice(args.slice, args.model_tag, args.out_dir)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
graders/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .scoring import grade_episode
graders/scoring.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic grading logic for InvoiceGuard episodes.
3
+
4
+ Scores 0.0-1.0 with partial credit across six rubric dimensions:
5
+ - Final decision correctness (0.35)
6
+ - Exception type correctness (0.20)
7
+ - Evidence sufficiency (0.15)
8
+ - Investigation quality (0.10)
9
+ - Explanation quality (0.10)
10
+ - Efficiency (0.10)
11
+ """
12
+
13
+ import re
14
+ from typing import List, Optional
15
+
16
+ try:
17
+ from ..models import (
18
+ CaseData, DecisionType, ExceptionType, GraderResult, InvoiceGuardState,
19
+ )
20
+ except ImportError:
21
+ from models import (
22
+ CaseData, DecisionType, ExceptionType, GraderResult, InvoiceGuardState,
23
+ )
24
+
25
+
26
+ W_DECISION = 0.35
27
+ W_EXCEPTION = 0.20
28
+ W_EVIDENCE = 0.15
29
+ W_INVESTIGATION = 0.10
30
+ W_EXPLANATION = 0.10
31
+ W_EFFICIENCY = 0.10
32
+
33
+
34
+ def grade_episode(case: CaseData, env_state: InvoiceGuardState) -> GraderResult:
35
+ """
36
+ Grade a completed episode deterministically.
37
+
38
+ Returns a GraderResult with score in [0.0, 1.0] and rubric breakdown.
39
+ """
40
+ gt = case.ground_truth
41
+ breakdown = {}
42
+
43
+ # -- 1. Decision correctness (0.40) ----------------------------------
44
+ decision_score = 0.0
45
+ if env_state.final_decision:
46
+ agent_decision = env_state.final_decision
47
+ correct_decision = gt.correct_decision.value
48
+
49
+ if agent_decision == correct_decision:
50
+ decision_score = 1.0
51
+ else:
52
+ decision_score = _partial_decision_credit(
53
+ agent_decision, correct_decision
54
+ )
55
+ breakdown["decision"] = {
56
+ "agent": env_state.final_decision,
57
+ "correct": gt.correct_decision.value,
58
+ "score": decision_score,
59
+ }
60
+
61
+ # -- 2. Exception type correctness (0.20) ----------------------------
62
+ exception_score = 0.0
63
+ if env_state.final_exception_type:
64
+ if env_state.final_exception_type == gt.correct_exception_type.value:
65
+ exception_score = 1.0
66
+ elif env_state.proposed_exception == gt.correct_exception_type.value:
67
+ exception_score = 0.5
68
+ elif env_state.proposed_exception == gt.correct_exception_type.value:
69
+ exception_score = 0.3
70
+
71
+ breakdown["exception_type"] = {
72
+ "agent_final": env_state.final_exception_type,
73
+ "agent_proposed": env_state.proposed_exception,
74
+ "correct": gt.correct_exception_type.value,
75
+ "score": exception_score,
76
+ }
77
+
78
+ # -- 3. Evidence sufficiency (0.20) ----------------------------------
79
+ evidence_score = _score_evidence(
80
+ agent_evidence=env_state.final_evidence,
81
+ actions_taken=env_state.actions_taken,
82
+ acceptable_evidence=gt.acceptable_evidence,
83
+ )
84
+ breakdown["evidence"] = {
85
+ "agent_evidence": env_state.final_evidence,
86
+ "actions_taken": env_state.actions_taken,
87
+ "acceptable": gt.acceptable_evidence,
88
+ "score": evidence_score,
89
+ }
90
+
91
+ # -- 4. Investigation quality (0.10) ---------------------------------
92
+ investigation_score = _score_investigation(
93
+ actions_taken=env_state.actions_taken,
94
+ documents_revealed=env_state.documents_revealed,
95
+ acceptable_evidence=gt.acceptable_evidence,
96
+ )
97
+ breakdown["investigation"] = {
98
+ "documents_revealed": env_state.documents_revealed,
99
+ "score": investigation_score,
100
+ }
101
+
102
+ # -- 5. Explanation quality (0.10) -----------------------------------
103
+ explanation_score = _score_explanation(
104
+ explanation=env_state.final_explanation,
105
+ case=case,
106
+ key_findings=gt.key_findings,
107
+ )
108
+ breakdown["explanation"] = {
109
+ "explanation": env_state.final_explanation,
110
+ "score": explanation_score,
111
+ }
112
+
113
+ # -- 6. Efficiency (0.10) --------------------------------------------
114
+ efficiency_score = _score_efficiency(
115
+ steps_used=env_state.step_count,
116
+ max_steps=case.max_steps,
117
+ repeated_counts=env_state.repeated_action_counts,
118
+ )
119
+ breakdown["efficiency"] = {
120
+ "steps_used": env_state.step_count,
121
+ "max_steps": case.max_steps,
122
+ "repeated_actions": env_state.repeated_action_counts,
123
+ "score": efficiency_score,
124
+ }
125
+
126
+ # -- Weighted total --------------------------------------------------
127
+ total = (
128
+ W_DECISION * decision_score
129
+ + W_EXCEPTION * exception_score
130
+ + W_EVIDENCE * evidence_score
131
+ + W_INVESTIGATION * investigation_score
132
+ + W_EXPLANATION * explanation_score
133
+ + W_EFFICIENCY * efficiency_score
134
+ )
135
+ total = round(min(max(total, 0.0), 1.0), 4)
136
+
137
+ return GraderResult(
138
+ score=total,
139
+ decision_score=round(decision_score, 4),
140
+ exception_type_score=round(exception_score, 4),
141
+ evidence_score=round(evidence_score, 4),
142
+ investigation_score=round(investigation_score, 4),
143
+ explanation_score=round(explanation_score, 4),
144
+ efficiency_score=round(efficiency_score, 4),
145
+ breakdown=breakdown,
146
+ )
147
+
148
+
149
+ def _partial_decision_credit(agent: str, correct: str) -> float:
150
+ """
151
+ Give partial credit for 'close' but wrong decisions.
152
+ e.g. escalate when hold was correct is better than approve when hold was correct.
153
+ """
154
+ RELATED_DECISIONS = {
155
+ "place_on_hold": {"escalate_for_supervisor_review": 0.2, "reject_invoice": 0.15},
156
+ "escalate_for_supervisor_review": {"place_on_hold": 0.2, "reject_invoice": 0.15},
157
+ "reject_invoice": {"escalate_for_supervisor_review": 0.2, "place_on_hold": 0.1},
158
+ "approve_for_payment": {},
159
+ }
160
+ related = RELATED_DECISIONS.get(correct, {})
161
+ return related.get(agent, 0.0)
162
+
163
+
164
+ def _score_evidence(
165
+ agent_evidence: List[str],
166
+ actions_taken: List[str],
167
+ acceptable_evidence: List[str],
168
+ ) -> float:
169
+ if not acceptable_evidence:
170
+ return 1.0
171
+
172
+ cited_set = set(agent_evidence)
173
+ taken_set = set(actions_taken)
174
+ total = 0.0
175
+
176
+ for required in acceptable_evidence:
177
+ if required in cited_set:
178
+ total += 1.0
179
+ elif required in taken_set:
180
+ total += 0.7
181
+
182
+ return min(total / len(acceptable_evidence), 1.0)
183
+
184
+
185
+ def _score_investigation(
186
+ actions_taken: List[str],
187
+ documents_revealed: List[str],
188
+ acceptable_evidence: List[str],
189
+ ) -> float:
190
+ if not acceptable_evidence:
191
+ return 1.0
192
+
193
+ relevant_actions = 0
194
+ for action in actions_taken:
195
+ if action in acceptable_evidence:
196
+ relevant_actions += 1
197
+
198
+ coverage = min(relevant_actions / len(acceptable_evidence), 1.0)
199
+
200
+ doc_bonus = min(len(documents_revealed) * 0.15, 0.3)
201
+
202
+ return min(coverage + doc_bonus, 1.0)
203
+
204
+
205
+ def _score_explanation(
206
+ explanation: str,
207
+ case: CaseData,
208
+ key_findings: List[str],
209
+ ) -> float:
210
+ """Score the agent's final explanation for quality signals."""
211
+ if not explanation:
212
+ return 0.0
213
+
214
+ text = explanation.lower()
215
+ score = 0.0
216
+ checks = 0
217
+ hits = 0
218
+
219
+ checks += 1
220
+ if _contains_number(text):
221
+ hits += 1
222
+
223
+ checks += 1
224
+ policy_terms = ["policy", "tolerance", "threshold", "escalat", "within"]
225
+ if any(t in text for t in policy_terms):
226
+ hits += 1
227
+
228
+ checks += 1
229
+ decision_terms = [
230
+ "approve", "reject", "hold", "escalat", "duplicate",
231
+ "mismatch", "variance", "discrepancy", "match",
232
+ ]
233
+ if any(t in text for t in decision_terms):
234
+ hits += 1
235
+
236
+ checks += 1
237
+ if len(explanation.split()) >= 8:
238
+ hits += 1
239
+
240
+ checks += 1
241
+ finding_hits = 0
242
+ for kf in key_findings:
243
+ kf_words = set(kf.lower().split())
244
+ if len(kf_words.intersection(set(text.split()))) >= 2:
245
+ finding_hits += 1
246
+ if finding_hits > 0:
247
+ hits += 1
248
+
249
+ score = hits / checks if checks > 0 else 0.0
250
+ return round(min(score, 1.0), 4)
251
+
252
+
253
+ def _contains_number(text: str) -> bool:
254
+ return bool(re.search(r'\d+\.?\d*', text))
255
+
256
+
257
+ def _score_efficiency(
258
+ steps_used: int,
259
+ max_steps: int,
260
+ repeated_counts: dict,
261
+ ) -> float:
262
+ if max_steps == 0:
263
+ return 1.0
264
+
265
+ step_ratio = steps_used / max_steps
266
+ total_repeats = sum(max(0, v - 1) for v in repeated_counts.values())
267
+
268
+ if step_ratio <= 0.5:
269
+ base = 1.0
270
+ elif step_ratio <= 0.75:
271
+ base = 0.8
272
+ elif step_ratio <= 1.0:
273
+ base = 0.5
274
+ else:
275
+ base = 0.2
276
+
277
+ repeat_penalty = min(total_repeats * 0.1, 0.4)
278
+
279
+ return max(base - repeat_penalty, 0.0)
inference.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ InvoiceGuard -- Baseline Inference Script.
3
+
4
+ Runs a baseline LLM agent against all canonical tasks and reports per-task
5
+ and overall grader scores. Uses the OpenAI API client as required by
6
+ the hackathon guidelines.
7
+
8
+ STDOUT FORMAT (mandatory):
9
+ [START] task=<task_name> env=invoice_guard model=<model_name>
10
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
11
+ [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
12
+
13
+ Environment variables (mandatory):
14
+ API_BASE_URL -- LLM API endpoint (default: https://api.openai.com/v1)
15
+ MODEL_NAME -- model identifier (default: gpt-4.1-mini)
16
+ HF_TOKEN -- Your Hugging Face / API key
17
+ LOCAL_IMAGE_NAME -- Docker image name (uses from_docker_image when set)
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ from typing import List, Optional
26
+
27
+ from dotenv import load_dotenv
28
+ from openai import OpenAI
29
+
30
+ _MODELS_USING_MAX_COMPLETION_TOKENS = {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano",
31
+ "gpt-5", "gpt-5-mini", "gpt-5.1"}
32
+
33
+
34
+ def _token_limit_kwarg(model_name: str, limit: int = 512) -> dict:
35
+ """Return the correct token-limit parameter for the model."""
36
+ for prefix in _MODELS_USING_MAX_COMPLETION_TOKENS:
37
+ if model_name.startswith(prefix):
38
+ return {"max_completion_tokens": limit}
39
+ return {"max_tokens": limit}
40
+
41
+ from models import (
42
+ ActionType, DecisionType, ExceptionType, InvoiceGuardAction, TaskID,
43
+ )
44
+ from tasks import get_task_case, TASK_LIST
45
+
46
+ load_dotenv()
47
+
48
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
49
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
50
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY") or ""
51
+ LOCAL_IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME")
52
+
53
+ BENCHMARK = "invoice_guard"
54
+
55
+
56
+ # -- Mandatory stdout logging --------------------------------------------
57
+
58
+
59
+ def log_start(task: str, env: str, model: str) -> None:
60
+ print(f"[START] task={task} env={env} model={model}", flush=True)
61
+
62
+
63
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
64
+ error_val = error if error else "null"
65
+ done_val = str(done).lower()
66
+ print(
67
+ f"[STEP] step={step} action={action} reward={reward:.2f} "
68
+ f"done={done_val} error={error_val}",
69
+ flush=True,
70
+ )
71
+
72
+
73
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
74
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
75
+ print(
76
+ f"[END] success={str(success).lower()} steps={steps} "
77
+ f"score={score:.2f} rewards={rewards_str}",
78
+ flush=True,
79
+ )
80
+
81
+
82
+ # -- System prompt --------------------------------------------------------
83
+
84
+
85
+ SYSTEM_PROMPT = """You are a senior accounts payable analyst. You will be given an invoice case to investigate and resolve.
86
+
87
+ The environment tells you your goal, available actions, and decision options. Read the goal carefully.
88
+
89
+ WORKFLOW:
90
+ 1. Investigate: inspect documents (PO, GRN, vendor profile, policy rules), run comparisons (quantity, price, totals), check for duplicates.
91
+ 2. Resolve: submit_final_resolution with your decision, exception type, evidence references, and explanation.
92
+
93
+ Complete a thorough investigation before resolving. Inspect at least: purchase order, goods receipt note, compare quantity, compare price, policy rules, duplicate check, and vendor profile.
94
+
95
+ RESPONSE FORMAT:
96
+ - Respond with ONLY a valid JSON object. No markdown, no commentary.
97
+ - Investigation example: {"action_type": "inspect_purchase_order"}
98
+ - Resolution example: {"action_type": "submit_final_resolution", "final_decision": "approve_for_payment", "exception_type": "clean_match", "evidence_references": ["inspect_purchase_order", "compare_quantity"], "explanation": "All documents match within tolerance.", "confidence": 0.9}
99
+
100
+ EXCEPTION TYPE OPTIONS:
101
+ - clean_match: all documents match within tolerance
102
+ - quantity_mismatch: billed quantity exceeds ordered quantity
103
+ - price_mismatch: billed unit price exceeds PO-agreed price
104
+ - total_amount_mismatch: invoice subtotal/total math is inconsistent
105
+ - partial_receipt: invoice bills more than has been received
106
+ - missing_receipt: no valid GRN supports the invoice/period
107
+ - duplicate_invoice: same invoice or already-paid duplicate detected
108
+ - tax_variance: tax amount differs from policy or expected value
109
+ - policy_violation: policy requires hold/escalation despite matching docs
110
+ - mixed_discrepancy: multiple material exception types are present
111
+
112
+ RULES:
113
+ - Pay close attention to POLICY findings -- they tell you when escalation is required.
114
+ - When multiple issues exist, escalation takes priority over hold.
115
+ - Check PO references carefully before concluding an invoice is a duplicate.
116
+ - Include all investigation actions you performed in evidence_references.
117
+ - Cite specific numbers in your explanation.
118
+ - NEVER repeat an action you already took.
119
+ - When remaining_steps is 3 or fewer, submit immediately with what you have.
120
+ """
121
+
122
+
123
+ # -- Prompt building ------------------------------------------------------
124
+
125
+
126
+ def build_observation_prompt(obs, is_first: bool = False) -> str:
127
+ """Format observation as a readable prompt for the LLM."""
128
+ parts = [
129
+ f"Case: {obs.case_id} | Difficulty: {obs.difficulty} | Steps remaining: {obs.remaining_steps}",
130
+ f"Invoice: {obs.invoice_summary}",
131
+ ]
132
+
133
+ if is_first and obs.goal:
134
+ parts.append(f"\n{obs.goal}")
135
+
136
+ if obs.revealed_documents:
137
+ parts.append(f"Documents reviewed: {', '.join(obs.revealed_documents)}")
138
+
139
+ if obs.findings:
140
+ parts.append("Findings:")
141
+ for i, f in enumerate(obs.findings, 1):
142
+ parts.append(f" {i}. {f}")
143
+
144
+ if obs.last_action_result:
145
+ parts.append(f"Last result: {obs.last_action_result}")
146
+
147
+ if obs.warnings:
148
+ parts.append(f"Warnings: {'; '.join(obs.warnings)}")
149
+
150
+ if obs.remaining_steps <= 2:
151
+ parts.append(
152
+ ">>> YOU MUST submit_final_resolution NOW. "
153
+ "No more investigation. Decide based on what you have. <<<"
154
+ )
155
+
156
+ return "\n".join(parts)
157
+
158
+
159
+ # -- LLM response parsing ------------------------------------------------
160
+
161
+
162
+ def parse_llm_response(response_text: str) -> dict:
163
+ """Extract a JSON object from the LLM response."""
164
+ text = response_text.strip()
165
+
166
+ if "```json" in text:
167
+ text = text.split("```json")[1].split("```")[0].strip()
168
+ elif "```" in text:
169
+ text = text.split("```")[1].split("```")[0].strip()
170
+
171
+ try:
172
+ return json.loads(text)
173
+ except json.JSONDecodeError:
174
+ pass
175
+
176
+ for line in text.split("\n"):
177
+ line = line.strip()
178
+ if line.startswith("{"):
179
+ try:
180
+ return json.loads(line)
181
+ except json.JSONDecodeError:
182
+ continue
183
+
184
+ return {"action_type": "summarize_findings"}
185
+
186
+
187
+ def build_action(params: dict) -> InvoiceGuardAction:
188
+ """Build a typed InvoiceGuardAction from parsed LLM output."""
189
+ action_type = params.get("action_type", "summarize_findings")
190
+
191
+ try:
192
+ ActionType(action_type)
193
+ except ValueError:
194
+ action_type = "summarize_findings"
195
+
196
+ kwargs = {"action_type": action_type}
197
+
198
+ if params.get("final_decision"):
199
+ try:
200
+ kwargs["final_decision"] = DecisionType(params["final_decision"])
201
+ except ValueError:
202
+ pass
203
+
204
+ if params.get("exception_type"):
205
+ try:
206
+ kwargs["exception_type"] = ExceptionType(params["exception_type"])
207
+ except ValueError:
208
+ pass
209
+
210
+ if params.get("evidence_references"):
211
+ kwargs["evidence_references"] = list(params["evidence_references"])
212
+
213
+ if params.get("explanation"):
214
+ kwargs["explanation"] = str(params["explanation"])
215
+
216
+ if params.get("confidence") is not None:
217
+ try:
218
+ conf = float(params["confidence"])
219
+ if 0.0 <= conf <= 1.0:
220
+ kwargs["confidence"] = conf
221
+ except (ValueError, TypeError):
222
+ pass
223
+
224
+ return InvoiceGuardAction(**kwargs)
225
+
226
+
227
+ # -- Observation extraction helpers ---------------------------------------
228
+
229
+
230
+ def _obs_from_step_result(result):
231
+ """Extract observation from an EnvClient StepResult, copying reward/done."""
232
+ obs = result.observation
233
+ obs.reward = result.reward
234
+ obs.done = result.done
235
+ return obs
236
+
237
+
238
+ # -- Episode runner (local, synchronous) ----------------------------------
239
+
240
+
241
+ def run_episode_local(env, client: OpenAI, task_id: TaskID) -> dict:
242
+ """Run one full episode against the local environment."""
243
+ obs = env.reset(task_id=task_id.value)
244
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
245
+ rewards: List[float] = []
246
+ steps = 0
247
+ score = 0.0
248
+ success = False
249
+ last_decision = None
250
+ last_exception = None
251
+
252
+ log_start(task=task_id.value, env=BENCHMARK, model=MODEL_NAME)
253
+
254
+ try:
255
+ while not obs.done:
256
+ user_msg = build_observation_prompt(obs, is_first=(steps == 0))
257
+ messages.append({"role": "user", "content": user_msg})
258
+
259
+ try:
260
+ api_kwargs = {
261
+ "model": MODEL_NAME,
262
+ "messages": messages,
263
+ "temperature": 0.0,
264
+ **_token_limit_kwarg(MODEL_NAME),
265
+ }
266
+ try:
267
+ api_kwargs["response_format"] = {"type": "json_object"}
268
+ response = client.chat.completions.create(**api_kwargs)
269
+ except Exception:
270
+ del api_kwargs["response_format"]
271
+ response = client.chat.completions.create(**api_kwargs)
272
+
273
+ assistant_msg = response.choices[0].message.content or ""
274
+
275
+ except Exception as e:
276
+ print(f"[DEBUG] LLM API error: {e}", flush=True)
277
+ assistant_msg = '{"action_type": "summarize_findings"}'
278
+
279
+ messages.append({"role": "assistant", "content": assistant_msg})
280
+
281
+ params = parse_llm_response(assistant_msg)
282
+ if params.get("final_decision"):
283
+ last_decision = params["final_decision"]
284
+ if params.get("exception_type"):
285
+ last_exception = params["exception_type"]
286
+ action = build_action(params)
287
+
288
+ obs = env.step(action)
289
+ reward = obs.reward if obs.reward else 0.0
290
+ rewards.append(reward)
291
+ steps += 1
292
+
293
+ error_str = None
294
+ if obs.last_action_error:
295
+ error_str = obs.last_action_result
296
+
297
+ log_step(
298
+ step=steps,
299
+ action=action.action_type.value,
300
+ reward=reward,
301
+ done=obs.done,
302
+ error=error_str,
303
+ )
304
+
305
+ grader_data = getattr(obs, "grader_result", None) or obs.metadata.get("grader_result", {})
306
+ score = grader_data.get("score", 0.0) if isinstance(grader_data, dict) else 0.0
307
+ score = min(max(score, 0.0), 1.0)
308
+ success = score >= 0.5
309
+
310
+ finally:
311
+ log_end(success=success, steps=steps, score=score, rewards=rewards)
312
+
313
+ return {
314
+ "task_id": task_id.value,
315
+ "steps": steps,
316
+ "grader_score": score,
317
+ "total_reward": sum(rewards),
318
+ "rewards": rewards,
319
+ "decision": last_decision,
320
+ "exception_type": last_exception,
321
+ "grader_breakdown": grader_data,
322
+ }
323
+
324
+
325
+ # -- Episode runner (Docker, asynchronous) --------------------------------
326
+
327
+
328
+ async def run_episode_docker(env, client: OpenAI, task_id: TaskID) -> dict:
329
+ """Run one full episode against a Docker-based environment via EnvClient."""
330
+ result = await env.reset(task_id=task_id.value)
331
+ obs = _obs_from_step_result(result)
332
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
333
+ rewards: List[float] = []
334
+ steps = 0
335
+ score = 0.0
336
+ success = False
337
+ last_decision = None
338
+ last_exception = None
339
+
340
+ log_start(task=task_id.value, env=BENCHMARK, model=MODEL_NAME)
341
+
342
+ try:
343
+ while not obs.done:
344
+ user_msg = build_observation_prompt(obs, is_first=(steps == 0))
345
+ messages.append({"role": "user", "content": user_msg})
346
+
347
+ try:
348
+ api_kwargs = {
349
+ "model": MODEL_NAME,
350
+ "messages": messages,
351
+ "temperature": 0.0,
352
+ **_token_limit_kwarg(MODEL_NAME),
353
+ }
354
+ try:
355
+ api_kwargs["response_format"] = {"type": "json_object"}
356
+ response = client.chat.completions.create(**api_kwargs)
357
+ except Exception:
358
+ del api_kwargs["response_format"]
359
+ response = client.chat.completions.create(**api_kwargs)
360
+
361
+ assistant_msg = response.choices[0].message.content or ""
362
+
363
+ except Exception as e:
364
+ print(f"[DEBUG] LLM API error: {e}", flush=True)
365
+ assistant_msg = '{"action_type": "summarize_findings"}'
366
+
367
+ messages.append({"role": "assistant", "content": assistant_msg})
368
+
369
+ params = parse_llm_response(assistant_msg)
370
+ if params.get("final_decision"):
371
+ last_decision = params["final_decision"]
372
+ if params.get("exception_type"):
373
+ last_exception = params["exception_type"]
374
+ action = build_action(params)
375
+
376
+ result = await env.step(action)
377
+ obs = _obs_from_step_result(result)
378
+ reward = obs.reward if obs.reward else 0.0
379
+ rewards.append(reward)
380
+ steps += 1
381
+
382
+ error_str = None
383
+ if obs.last_action_error:
384
+ error_str = obs.last_action_result
385
+
386
+ log_step(
387
+ step=steps,
388
+ action=action.action_type.value,
389
+ reward=reward,
390
+ done=obs.done,
391
+ error=error_str,
392
+ )
393
+
394
+ grader_data = getattr(obs, "grader_result", None) or obs.metadata.get("grader_result", {})
395
+ score = grader_data.get("score", 0.0) if isinstance(grader_data, dict) else 0.0
396
+ score = min(max(score, 0.0), 1.0)
397
+ success = score >= 0.5
398
+
399
+ finally:
400
+ log_end(success=success, steps=steps, score=score, rewards=rewards)
401
+
402
+ return {
403
+ "task_id": task_id.value,
404
+ "steps": steps,
405
+ "grader_score": score,
406
+ "total_reward": sum(rewards),
407
+ "rewards": rewards,
408
+ "decision": last_decision,
409
+ "exception_type": last_exception,
410
+ "grader_breakdown": grader_data,
411
+ }
412
+
413
+
414
+ # -- Main -----------------------------------------------------------------
415
+
416
+
417
+ def _print_header():
418
+ print("=" * 60, flush=True)
419
+ print("InvoiceGuard -- Baseline Inference", flush=True)
420
+ print("=" * 60, flush=True)
421
+ print(f"API Base URL: {API_BASE_URL}", flush=True)
422
+ print(f"Model: {MODEL_NAME}", flush=True)
423
+ print(f"Tasks: {len(TASK_LIST)}", flush=True)
424
+ mode = f"docker ({LOCAL_IMAGE_NAME})" if LOCAL_IMAGE_NAME else "local"
425
+ print(f"Mode: {mode}", flush=True)
426
+ print(flush=True)
427
+
428
+
429
+ def _print_results(results):
430
+ print(flush=True)
431
+ print("=" * 60, flush=True)
432
+ print("RESULTS SUMMARY", flush=True)
433
+ print("=" * 60, flush=True)
434
+ scores = [r["grader_score"] for r in results]
435
+ for r in results:
436
+ print(
437
+ f" {r['task_id']:30s} score={r['grader_score']:.4f} "
438
+ f"decision={r['decision']}",
439
+ flush=True,
440
+ )
441
+ avg = sum(scores) / len(scores) if scores else 0.0
442
+ print(f"\n Average score: {avg:.4f}", flush=True)
443
+ print(f" Total tasks: {len(scores)}", flush=True)
444
+ print("=" * 60, flush=True)
445
+
446
+
447
+ async def main_docker():
448
+ """Run inference against a Docker container via EnvClient."""
449
+ from client import InvoiceGuardEnv
450
+
451
+ _print_header()
452
+ llm_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
453
+
454
+ env = await InvoiceGuardEnv.from_docker_image(LOCAL_IMAGE_NAME)
455
+
456
+ results = []
457
+ try:
458
+ for task_id in TASK_LIST:
459
+ start = time.time()
460
+ result = await run_episode_docker(env, llm_client, task_id)
461
+ elapsed = time.time() - start
462
+ print(
463
+ f" >> {task_id.value}: score={result['grader_score']:.4f} "
464
+ f"steps={result['steps']} decision={result['decision']} "
465
+ f"time={elapsed:.1f}s",
466
+ flush=True,
467
+ )
468
+ results.append(result)
469
+ finally:
470
+ try:
471
+ await env.close()
472
+ except Exception as e:
473
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
474
+
475
+ _print_results(results)
476
+
477
+
478
+ def main_local():
479
+ """Run inference directly against the local environment."""
480
+ from server.invoice_guard_environment import InvoiceGuardEnvironment
481
+
482
+ _print_header()
483
+ llm_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
484
+ env = InvoiceGuardEnvironment()
485
+
486
+ results = []
487
+ for task_id in TASK_LIST:
488
+ start = time.time()
489
+ result = run_episode_local(env, llm_client, task_id)
490
+ elapsed = time.time() - start
491
+ print(
492
+ f" >> {task_id.value}: score={result['grader_score']:.4f} "
493
+ f"steps={result['steps']} decision={result['decision']} "
494
+ f"time={elapsed:.1f}s",
495
+ flush=True,
496
+ )
497
+ results.append(result)
498
+
499
+ _print_results(results)
500
+
501
+
502
+ if __name__ == "__main__":
503
+ if LOCAL_IMAGE_NAME:
504
+ asyncio.run(main_docker())
505
+ else:
506
+ main_local()
models.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for the InvoiceGuard environment.
3
+
4
+ Three-Way Invoice Matching Exception Resolution Environment.
5
+ All enums, business entities, and OpenEnv interface types.
6
+ """
7
+
8
+ from enum import Enum
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from openenv.core.env_server.types import Action, Observation, State
14
+
15
+
16
+ # -- Enums --------------------------------------------------------------------
17
+
18
+
19
+ class ActionType(str, Enum):
20
+ """Available actions the agent can take."""
21
+
22
+ inspect_invoice_line_items = "inspect_invoice_line_items"
23
+ inspect_purchase_order = "inspect_purchase_order"
24
+ inspect_goods_receipt_note = "inspect_goods_receipt_note"
25
+ inspect_vendor_profile = "inspect_vendor_profile"
26
+ inspect_policy_rules = "inspect_policy_rules"
27
+ check_for_duplicate_invoice = "check_for_duplicate_invoice"
28
+ compare_quantity = "compare_quantity"
29
+ compare_price = "compare_price"
30
+ compare_totals = "compare_totals"
31
+ summarize_findings = "summarize_findings"
32
+ propose_exception_type = "propose_exception_type"
33
+ submit_final_resolution = "submit_final_resolution"
34
+
35
+
36
+ class DecisionType(str, Enum):
37
+ """Final case resolution decisions."""
38
+
39
+ approve_for_payment = "approve_for_payment"
40
+ place_on_hold = "place_on_hold"
41
+ reject_invoice = "reject_invoice"
42
+ escalate_for_supervisor_review = "escalate_for_supervisor_review"
43
+
44
+
45
+ class ExceptionType(str, Enum):
46
+ """Primary exception types for invoice cases."""
47
+
48
+ clean_match = "clean_match"
49
+ quantity_mismatch = "quantity_mismatch"
50
+ price_mismatch = "price_mismatch"
51
+ total_amount_mismatch = "total_amount_mismatch"
52
+ partial_receipt = "partial_receipt"
53
+ missing_receipt = "missing_receipt"
54
+ duplicate_invoice = "duplicate_invoice"
55
+ tax_variance = "tax_variance"
56
+ policy_violation = "policy_violation"
57
+ mixed_discrepancy = "mixed_discrepancy"
58
+
59
+
60
+ class TaskID(str, Enum):
61
+ """Identifiers for evaluation tasks (4 canonical + variants)."""
62
+
63
+ task_1_clean_match = "task_1_clean_match"
64
+ task_1b_multi_line_clean = "task_1b_multi_line_clean"
65
+ task_1c_preferred_vendor_clean = "task_1c_preferred_vendor_clean"
66
+ task_2_partial_receipt = "task_2_partial_receipt"
67
+ task_2b_missing_receipt = "task_2b_missing_receipt"
68
+ task_2c_over_receipt = "task_2c_over_receipt"
69
+ task_3_price_variance = "task_3_price_variance"
70
+ task_3b_within_tolerance = "task_3b_within_tolerance"
71
+ task_3c_total_mismatch = "task_3c_total_mismatch"
72
+ task_4_duplicate_invoice = "task_4_duplicate_invoice"
73
+ task_4b_corrected_invoice_trap = "task_4b_corrected_invoice_trap"
74
+ task_4c_policy_violation = "task_4c_policy_violation"
75
+ task_5_mixed_discrepancy = "task_5_mixed_discrepancy"
76
+ task_6_false_positive_duplicate = "task_6_false_positive_duplicate"
77
+ task_7_retroactive_price = "task_7_retroactive_price"
78
+ task_8_split_invoice_pattern = "task_8_split_invoice_pattern"
79
+ task_9_clean_from_risky_vendor = "task_9_clean_from_risky_vendor"
80
+ task_10_rounding_false_alarm = "task_10_rounding_false_alarm"
81
+ task_11_authorized_overship = "task_11_authorized_overship"
82
+ task_12_corrected_resubmission = "task_12_corrected_resubmission"
83
+ # -- Round 2 hard-mode slice (strong gap, baseline target 40-55%) --
84
+ task_h1_phantom_grn_period = "task_h1_phantom_grn_period"
85
+ task_h2_kickback_inflated_po = "task_h2_kickback_inflated_po"
86
+ task_h3_currency_silent_swap = "task_h3_currency_silent_swap"
87
+ task_h4_threshold_dance = "task_h4_threshold_dance"
88
+ task_h5_retroactive_amendment = "task_h5_retroactive_amendment"
89
+ task_h6_returned_then_rebilled = "task_h6_returned_then_rebilled"
90
+ task_h7_multi_party_pending = "task_h7_multi_party_pending"
91
+ task_h8_rush_premium_authorized = "task_h8_rush_premium_authorized"
92
+ task_h9_supplier_id_mismatch = "task_h9_supplier_id_mismatch"
93
+ task_h10_cross_case_contradiction = "task_h10_cross_case_contradiction"
94
+
95
+
96
+ class Difficulty(str, Enum):
97
+ """Task difficulty levels."""
98
+
99
+ easy = "easy"
100
+ moderate = "moderate"
101
+ hard = "hard"
102
+
103
+
104
+ # -- Business Entities --------------------------------------------------------
105
+
106
+
107
+ class InvoiceLineItem(BaseModel):
108
+ item_code: str
109
+ description: str
110
+ quantity_billed: float
111
+ unit_price_billed: float
112
+ line_total_billed: float
113
+
114
+
115
+ class Invoice(BaseModel):
116
+ invoice_number: str
117
+ supplier_name: str
118
+ supplier_id: str
119
+ invoice_date: str
120
+ currency: str = "USD"
121
+ po_reference: str
122
+ line_items: List[InvoiceLineItem]
123
+ subtotal: float
124
+ tax: float
125
+ total_amount: float
126
+ note: str = ""
127
+
128
+
129
+ class POLineItem(BaseModel):
130
+ item_code: str
131
+ description: str
132
+ ordered_quantity: float
133
+ unit_price_ordered: float
134
+ line_total_ordered: float
135
+
136
+
137
+ class PurchaseOrder(BaseModel):
138
+ po_number: str
139
+ supplier_id: str
140
+ order_date: str
141
+ line_items: List[POLineItem]
142
+ approved: bool = True
143
+ payment_terms: str = "Net 30"
144
+
145
+
146
+ class GRNLineItem(BaseModel):
147
+ item_code: str
148
+ quantity_received: float
149
+ accepted_quantity: float
150
+ rejected_quantity: float = 0.0
151
+
152
+
153
+ class GoodsReceiptNote(BaseModel):
154
+ grn_number: str
155
+ po_reference: str
156
+ receipt_date: str
157
+ line_items: List[GRNLineItem]
158
+ warehouse_note: str = ""
159
+
160
+
161
+ class VendorProfile(BaseModel):
162
+ supplier_id: str
163
+ supplier_name: str
164
+ risk_tier: str
165
+ duplicate_risk_count: int = 0
166
+ preferred_vendor: bool = True
167
+ tolerance_override: Optional[float] = None
168
+ escalation_threshold: Optional[float] = None
169
+
170
+
171
+ class CompanyPolicy(BaseModel):
172
+ quantity_tolerance_pct: float = 5.0
173
+ price_tolerance_pct: float = 5.0
174
+ total_tolerance_amt: float = 100.0
175
+ duplicate_check_enabled: bool = True
176
+ high_value_threshold: float = 50000.0
177
+ mandatory_escalation_above: float = 100000.0
178
+
179
+
180
+ class CaseHistory(BaseModel):
181
+ prior_comments: List[str] = Field(default_factory=list)
182
+ similar_invoice_refs: List[str] = Field(default_factory=list)
183
+ processed_invoice_numbers: List[str] = Field(default_factory=list)
184
+ pending_invoice_numbers: List[str] = Field(default_factory=list)
185
+
186
+
187
+ # -- Ground Truth & Case Data ------------------------------------------------
188
+
189
+
190
+ class GroundTruth(BaseModel):
191
+ correct_decision: DecisionType
192
+ correct_exception_type: ExceptionType
193
+ acceptable_evidence: List[str]
194
+ key_findings: List[str]
195
+
196
+
197
+ class CaseData(BaseModel):
198
+ case_id: str
199
+ task_id: TaskID
200
+ difficulty: Difficulty
201
+ max_steps: int
202
+ invoice: Invoice
203
+ purchase_order: PurchaseOrder
204
+ goods_receipt_note: GoodsReceiptNote
205
+ vendor_profile: VendorProfile
206
+ company_policy: CompanyPolicy
207
+ case_history: CaseHistory
208
+ ground_truth: GroundTruth
209
+
210
+
211
+ # -- OpenEnv Action ----------------------------------------------------------
212
+
213
+
214
+ class InvoiceGuardAction(Action):
215
+ """Agent action for the InvoiceGuard environment."""
216
+
217
+ action_type: ActionType
218
+ final_decision: Optional[DecisionType] = None
219
+ exception_type: Optional[ExceptionType] = None
220
+ evidence_references: List[str] = Field(default_factory=list)
221
+ explanation: str = ""
222
+ confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
223
+
224
+
225
+ # -- OpenEnv Observation -----------------------------------------------------
226
+
227
+
228
+ class InvoiceGuardObservation(Observation):
229
+ """What the agent sees after each step."""
230
+
231
+ case_id: str = ""
232
+ task_id: str = ""
233
+ difficulty: str = ""
234
+ invoice_summary: str = ""
235
+ goal: str = ""
236
+ available_actions: List[str] = Field(default_factory=list)
237
+ suggested_next_actions: List[str] = Field(default_factory=list)
238
+ revealed_documents: List[str] = Field(default_factory=list)
239
+ findings: List[str] = Field(default_factory=list)
240
+ remaining_steps: int = 0
241
+ last_action_result: str = ""
242
+ last_action_error: bool = False
243
+ warnings: List[str] = Field(default_factory=list)
244
+ grader_result: Dict[str, Any] = Field(default_factory=dict)
245
+
246
+
247
+ # -- OpenEnv State -----------------------------------------------------------
248
+
249
+
250
+ class InvoiceGuardState(State):
251
+ """Internal state tracking for the environment."""
252
+
253
+ task_id: str = ""
254
+ difficulty: str = ""
255
+ case_id: str = ""
256
+ max_steps: int = 0
257
+ actions_taken: List[str] = Field(default_factory=list)
258
+ documents_revealed: List[str] = Field(default_factory=list)
259
+ findings_collected: List[str] = Field(default_factory=list)
260
+ proposed_exception: Optional[str] = None
261
+ is_finalized: bool = False
262
+ final_decision: Optional[str] = None
263
+ final_exception_type: Optional[str] = None
264
+ final_evidence: List[str] = Field(default_factory=list)
265
+ final_explanation: str = ""
266
+ final_confidence: Optional[float] = None
267
+ cumulative_reward: float = 0.0
268
+ repeated_action_counts: Dict[str, int] = Field(default_factory=dict)
269
+ # Per-step reward components for transparency / training signals.
270
+ # Each entry: {step, action, base, penalties: {kind: amount, ...}, total, reason}
271
+ reward_components: List[Dict[str, Any]] = Field(default_factory=list)
272
+
273
+
274
+ # -- Grader Result -----------------------------------------------------------
275
+
276
+
277
+ class GraderResult(BaseModel):
278
+ """Deterministic grading result for a completed episode."""
279
+
280
+ score: float = Field(ge=0.0, le=1.0)
281
+ decision_score: float = 0.0
282
+ exception_type_score: float = 0.0
283
+ evidence_score: float = 0.0
284
+ investigation_score: float = 0.0
285
+ explanation_score: float = 0.0
286
+ efficiency_score: float = 0.0
287
+ breakdown: Dict[str, Any] = Field(default_factory=dict)
openenv.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: invoice_guard
3
+ description: Three-way invoice matching exception resolution environment. An AI agent investigates multi-document AP cases to detect discrepancies and render policy-compliant decisions.
4
+ type: space
5
+ runtime: fastapi
6
+ app: server.app:app
7
+ port: 8000
8
+
pyproject.toml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-invoice_guard"
13
+ version = "0.1.0"
14
+ description = "Invoice Guard environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ "openenv-core[core]>=0.2.1",
18
+ "openai>=1.0.0",
19
+ "python-dotenv>=1.0.0",
20
+ "pydantic>=2.0.0",
21
+ "pydantic-settings>=2.0.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.0.0",
27
+ "pytest-cov>=4.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ # Server entry point - enables running via: uv run --project . server
32
+ # or: python -m invoice_guard.server.app
33
+ server = "invoice_guard.server.app:main"
34
+
35
+ [tool.setuptools]
36
+ include-package-data = true
37
+ packages = ["invoice_guard", "invoice_guard.server"]
38
+ package-dir = { "invoice_guard" = ".", "invoice_guard.server" = "server" }
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Invoice Guard environment server components."""
8
+
9
+ from .invoice_guard_environment import InvoiceGuardEnvironment
10
+
11
+ __all__ = ["InvoiceGuardEnvironment"]
server/app.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for the InvoiceGuard Environment.
3
+
4
+ Endpoints:
5
+ - POST /reset: Reset the environment
6
+ - POST /step: Execute an action
7
+ - GET /state: Get current environment state
8
+ - GET /schema: Get action/observation schemas
9
+ - WS /ws: WebSocket endpoint for persistent sessions
10
+ """
11
+
12
+ try:
13
+ from openenv.core.env_server.http_server import create_app
14
+ except Exception as e:
15
+ raise ImportError(
16
+ "openenv is required. Install with: uv sync"
17
+ ) from e
18
+
19
+ try:
20
+ from ..models import InvoiceGuardAction, InvoiceGuardObservation
21
+ from .invoice_guard_environment import InvoiceGuardEnvironment
22
+ except (ImportError, ModuleNotFoundError):
23
+ from models import InvoiceGuardAction, InvoiceGuardObservation
24
+ from server.invoice_guard_environment import InvoiceGuardEnvironment
25
+
26
+
27
+ app = create_app(
28
+ InvoiceGuardEnvironment,
29
+ InvoiceGuardAction,
30
+ InvoiceGuardObservation,
31
+ env_name="invoice_guard",
32
+ max_concurrent_envs=1,
33
+ )
34
+
35
+
36
+ def main(host: str = "0.0.0.0", port: int = 8000):
37
+ """Entry point for uv run server."""
38
+ import uvicorn
39
+ uvicorn.run(app, host=host, port=port)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
server/invoice_guard_environment.py ADDED
@@ -0,0 +1,869 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ InvoiceGuard Environment -- Three-Way Invoice Matching Exception Resolution.
3
+
4
+ An agent reviews one supplier invoice case per episode by comparing the
5
+ invoice, purchase order, and goods receipt note, then resolves the case.
6
+ """
7
+
8
+ from typing import Any, Optional
9
+ from uuid import uuid4
10
+
11
+ from openenv.core.env_server.interfaces import Environment
12
+ from openenv.core.env_server.types import State
13
+
14
+ try:
15
+ from ..models import (
16
+ ActionType, CaseData, DecisionType, Difficulty, ExceptionType,
17
+ InvoiceGuardAction, InvoiceGuardObservation, InvoiceGuardState, TaskID,
18
+ )
19
+ from ..tasks import get_task_case, TASK_LIST
20
+ from ..graders import grade_episode
21
+ except ImportError:
22
+ from models import (
23
+ ActionType, CaseData, DecisionType, Difficulty, ExceptionType,
24
+ InvoiceGuardAction, InvoiceGuardObservation, InvoiceGuardState, TaskID,
25
+ )
26
+ from tasks import get_task_case, TASK_LIST
27
+ from graders import grade_episode
28
+
29
+
30
+ GOAL_TEXT = (
31
+ "You are an accounts payable analyst. Review this supplier invoice by "
32
+ "comparing it against the purchase order (PO) and goods receipt note (GRN).\n"
33
+ "\n"
34
+ "INVESTIGATION ACTIONS (reveal information):\n"
35
+ " inspect_purchase_order -- see what was ordered and at what price\n"
36
+ " inspect_goods_receipt_note -- see what was received at the warehouse\n"
37
+ " inspect_invoice_line_items -- see detailed invoice line items\n"
38
+ " inspect_vendor_profile -- see vendor risk tier, duplicate history, escalation thresholds\n"
39
+ " inspect_policy_rules -- see company tolerance thresholds and escalation rules\n"
40
+ " check_for_duplicate_invoice -- search for previously processed invoices\n"
41
+ " compare_quantity -- compare billed vs ordered vs received quantities\n"
42
+ " compare_price -- compare billed price vs PO-agreed price\n"
43
+ " compare_totals -- verify subtotal and total consistency\n"
44
+ " summarize_findings -- list all findings collected so far\n"
45
+ " propose_exception_type -- declare what type of exception you suspect\n"
46
+ "\n"
47
+ "RESOLUTION ACTION (ends the episode):\n"
48
+ " submit_final_resolution -- requires: final_decision, exception_type, "
49
+ "evidence_references, explanation\n"
50
+ "\n"
51
+ "DECISIONS:\n"
52
+ " reject_invoice -- duplicate invoice or fraudulent submission detected\n"
53
+ " escalate_for_supervisor_review -- price/total variance exceeds tolerance, "
54
+ "or invoice exceeds high-value threshold\n"
55
+ " place_on_hold -- billed quantity exceeds received quantity\n"
56
+ " approve_for_payment -- all documents match within tolerance\n"
57
+ "\n"
58
+ "Investigate thoroughly, then submit your resolution with evidence."
59
+ )
60
+
61
+ ALL_ACTIONS = [a.value for a in ActionType]
62
+
63
+ # Reward constants
64
+ R_NEW_DOCUMENT = 0.05
65
+ R_USEFUL_COMPARISON = 0.08
66
+ R_CONFIRM_NO_ISSUE = 0.03
67
+ R_CORRECT_PROPOSAL = 0.10
68
+ R_SUMMARIZE = 0.02
69
+ R_REPEAT_PENALTY = -0.02
70
+ R_INVALID_ACTION = -0.05
71
+ R_TIMEOUT_PENALTY = -0.10
72
+
73
+ # -- Round 2 anti-gaming constants ------------------------------------------
74
+ # Submitting a final resolution before doing any real investigation is a
75
+ # common shortcut. Penalize it heavily so RL training cannot exploit it.
76
+ R_SHORTCUT_PENALTY = -0.20
77
+ # Minimum distinct documents the agent must have revealed before submit
78
+ # is considered "investigated". Below this, R_SHORTCUT_PENALTY applies.
79
+ MIN_DOCS_BEFORE_SUBMIT = 2
80
+ # Escalating per-action repeat penalty (applied AFTER the first occurrence).
81
+ # index 0 = first repeat (= 2nd occurrence overall), 1 = 2nd repeat, etc.
82
+ REPEAT_PENALTY_LADDER = [-0.02, -0.05, -0.10, -0.15]
83
+
84
+
85
+ def _ladder_penalty(repeat_idx: int) -> float:
86
+ """Return escalating repeat penalty; clamps to last rung."""
87
+ if repeat_idx <= 0:
88
+ return 0.0
89
+ i = min(repeat_idx - 1, len(REPEAT_PENALTY_LADDER) - 1)
90
+ return REPEAT_PENALTY_LADDER[i]
91
+
92
+
93
+ class InvoiceGuardEnvironment(Environment):
94
+ """
95
+ Three-Way Invoice Matching Exception Resolution Environment.
96
+
97
+ Each episode presents one synthetic invoice case. The agent investigates
98
+ by inspecting documents and comparing fields, then submits a resolution.
99
+ """
100
+
101
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
102
+
103
+ def __init__(self):
104
+ self._case: Optional[CaseData] = None
105
+ self._env_state = InvoiceGuardState()
106
+ self._task_index = 0
107
+
108
+ def reset(
109
+ self,
110
+ seed: Optional[int] = None,
111
+ episode_id: Optional[str] = None,
112
+ **kwargs: Any,
113
+ ) -> InvoiceGuardObservation:
114
+ task_id_str = kwargs.get("task_id")
115
+ if task_id_str:
116
+ task_id = TaskID(task_id_str)
117
+ else:
118
+ task_id = TASK_LIST[self._task_index % len(TASK_LIST)]
119
+ self._task_index += 1
120
+
121
+ self._case = get_task_case(task_id)
122
+ c = self._case
123
+
124
+ self._env_state = InvoiceGuardState(
125
+ episode_id=episode_id or str(uuid4()),
126
+ step_count=0,
127
+ task_id=c.task_id.value,
128
+ difficulty=c.difficulty.value,
129
+ case_id=c.case_id,
130
+ max_steps=c.max_steps,
131
+ )
132
+
133
+ inv = c.invoice
134
+ summary = (
135
+ f"Supplier: {inv.supplier_name} ({inv.supplier_id}), "
136
+ f"Invoice: {inv.invoice_number}, Date: {inv.invoice_date}, "
137
+ f"PO Ref: {inv.po_reference}, Total: ${inv.total_amount:,.2f}"
138
+ )
139
+ if inv.note:
140
+ summary += f"\nInvoice note: {inv.note}"
141
+
142
+ initial_suggestions = [
143
+ "inspect_purchase_order",
144
+ "inspect_goods_receipt_note",
145
+ "inspect_invoice_line_items",
146
+ ]
147
+
148
+ return InvoiceGuardObservation(
149
+ case_id=c.case_id,
150
+ task_id=c.task_id.value,
151
+ difficulty=c.difficulty.value,
152
+ invoice_summary=summary,
153
+ goal=GOAL_TEXT,
154
+ available_actions=ALL_ACTIONS,
155
+ suggested_next_actions=initial_suggestions,
156
+ revealed_documents=[],
157
+ findings=[],
158
+ remaining_steps=c.max_steps,
159
+ last_action_result="Case loaded. Begin your investigation.",
160
+ last_action_error=False,
161
+ warnings=[],
162
+ done=False,
163
+ reward=0.0,
164
+ )
165
+
166
+ def step(
167
+ self,
168
+ action: InvoiceGuardAction,
169
+ timeout_s: Optional[float] = None,
170
+ **kwargs: Any,
171
+ ) -> InvoiceGuardObservation:
172
+ if self._case is None:
173
+ return self._error_obs("Environment not initialized. Call reset() first.")
174
+
175
+ s = self._env_state
176
+ c = self._case
177
+
178
+ if s.is_finalized:
179
+ return self._error_obs("Episode already finalized.")
180
+
181
+ s.step_count += 1
182
+ action_name = action.action_type.value
183
+ s.actions_taken.append(action_name)
184
+
185
+ s.repeated_action_counts[action_name] = (
186
+ s.repeated_action_counts.get(action_name, 0) + 1
187
+ )
188
+
189
+ # Check step budget
190
+ remaining = c.max_steps - s.step_count
191
+ if remaining < 0:
192
+ return self._finalize_timeout()
193
+
194
+ # Route action
195
+ reward = 0.0
196
+ is_repeat = s.repeated_action_counts[action_name] > 1
197
+ terminal = False
198
+
199
+ if action_name == ActionType.submit_final_resolution.value:
200
+ return self._handle_submit(action, remaining)
201
+ elif action_name == ActionType.propose_exception_type.value:
202
+ result, reward = self._handle_propose_exception(action, is_repeat)
203
+ elif action_name in _INVESTIGATION_HANDLERS:
204
+ result, reward = _INVESTIGATION_HANDLERS[action_name](self, is_repeat)
205
+ else:
206
+ result = f"Unknown action: {action_name}"
207
+ reward = R_INVALID_ACTION
208
+
209
+ base_reward = reward
210
+ penalties: dict = {}
211
+ if is_repeat and action_name != ActionType.submit_final_resolution.value:
212
+ repeat_idx = s.repeated_action_counts[action_name] - 1
213
+ ladder = _ladder_penalty(repeat_idx)
214
+ penalties["repeat"] = ladder
215
+ reward = min(reward, ladder)
216
+
217
+ s.cumulative_reward += reward
218
+ self._log_component(
219
+ action=action_name,
220
+ base=base_reward,
221
+ penalties=penalties,
222
+ total=reward,
223
+ reason=("repeat" if is_repeat else "step"),
224
+ )
225
+ remaining = c.max_steps - s.step_count
226
+
227
+ if remaining <= 0 and not s.is_finalized:
228
+ return self._finalize_timeout()
229
+
230
+ return InvoiceGuardObservation(
231
+ case_id=c.case_id,
232
+ task_id=c.task_id.value,
233
+ difficulty=c.difficulty.value,
234
+ invoice_summary=self._invoice_summary(),
235
+ goal=GOAL_TEXT,
236
+ available_actions=ALL_ACTIONS,
237
+ suggested_next_actions=self._suggest_next_actions(remaining),
238
+ revealed_documents=list(s.documents_revealed),
239
+ findings=list(s.findings_collected),
240
+ remaining_steps=remaining,
241
+ last_action_result=result,
242
+ last_action_error=False,
243
+ warnings=self._build_warnings(remaining),
244
+ done=False,
245
+ reward=reward,
246
+ )
247
+
248
+ @property
249
+ def state(self) -> InvoiceGuardState:
250
+ return self._env_state
251
+
252
+ # -- Terminal handlers ------------------------------------------------
253
+
254
+ def _handle_submit(
255
+ self, action: InvoiceGuardAction, remaining: int
256
+ ) -> InvoiceGuardObservation:
257
+ s = self._env_state
258
+ c = self._case
259
+
260
+ if not action.final_decision:
261
+ s.cumulative_reward += R_INVALID_ACTION
262
+ error_msg = (
263
+ "ERROR: submit_final_resolution requires all of these fields:\n"
264
+ ' "final_decision": one of "approve_for_payment", '
265
+ '"place_on_hold", "reject_invoice", "escalate_for_supervisor_review"\n'
266
+ ' "exception_type": one of "clean_match", "quantity_mismatch", '
267
+ '"price_mismatch", "partial_receipt", "duplicate_invoice", '
268
+ '"total_amount_mismatch", "missing_receipt", "tax_variance", '
269
+ '"policy_violation", "mixed_discrepancy"\n'
270
+ ' "evidence_references": list of action names you performed\n'
271
+ ' "explanation": one-sentence justification\n'
272
+ "Example: {\"action_type\": \"submit_final_resolution\", "
273
+ "\"final_decision\": \"approve_for_payment\", "
274
+ "\"exception_type\": \"clean_match\", "
275
+ "\"evidence_references\": [\"inspect_purchase_order\", "
276
+ "\"compare_quantity\"], "
277
+ "\"explanation\": \"All documents match within tolerance.\"}"
278
+ )
279
+ return InvoiceGuardObservation(
280
+ case_id=c.case_id,
281
+ task_id=c.task_id.value,
282
+ difficulty=c.difficulty.value,
283
+ invoice_summary=self._invoice_summary(),
284
+ goal=GOAL_TEXT,
285
+ available_actions=ALL_ACTIONS,
286
+ revealed_documents=list(s.documents_revealed),
287
+ findings=list(s.findings_collected),
288
+ remaining_steps=remaining,
289
+ last_action_result=error_msg,
290
+ last_action_error=True,
291
+ warnings=self._build_warnings(remaining),
292
+ done=False,
293
+ reward=R_INVALID_ACTION,
294
+ )
295
+
296
+ s.is_finalized = True
297
+ s.final_decision = action.final_decision.value
298
+ s.final_exception_type = (
299
+ action.exception_type.value if action.exception_type else None
300
+ )
301
+ s.final_evidence = list(action.evidence_references)
302
+ s.final_explanation = action.explanation
303
+ s.final_confidence = action.confidence
304
+
305
+ # Anti-gaming: punish "submit-without-investigation" shortcut.
306
+ shortcut_penalty = 0.0
307
+ if len(s.documents_revealed) < MIN_DOCS_BEFORE_SUBMIT:
308
+ shortcut_penalty = R_SHORTCUT_PENALTY
309
+
310
+ grader_result = grade_episode(c, s)
311
+
312
+ confidence_bonus = 0.0
313
+ if action.confidence is not None:
314
+ is_correct = (s.final_decision == c.ground_truth.correct_decision.value)
315
+ if is_correct and action.confidence >= 0.8:
316
+ confidence_bonus = 0.05
317
+ elif not is_correct and action.confidence >= 0.8:
318
+ confidence_bonus = -0.05
319
+
320
+ terminal_reward = grader_result.score + confidence_bonus + shortcut_penalty
321
+ s.cumulative_reward += terminal_reward
322
+ self._log_component(
323
+ action=ActionType.submit_final_resolution.value,
324
+ base=grader_result.score,
325
+ penalties={
326
+ **({"shortcut": shortcut_penalty} if shortcut_penalty else {}),
327
+ **({"confidence": confidence_bonus} if confidence_bonus else {}),
328
+ },
329
+ total=terminal_reward,
330
+ reason="terminal_submit",
331
+ )
332
+
333
+ return InvoiceGuardObservation(
334
+ case_id=c.case_id,
335
+ task_id=c.task_id.value,
336
+ difficulty=c.difficulty.value,
337
+ invoice_summary=self._invoice_summary(),
338
+ goal=GOAL_TEXT,
339
+ available_actions=[],
340
+ revealed_documents=list(s.documents_revealed),
341
+ findings=list(s.findings_collected),
342
+ remaining_steps=0,
343
+ last_action_result=(
344
+ f"Case resolved. Decision: {s.final_decision}. "
345
+ f"Grader score: {grader_result.score:.4f}"
346
+ ),
347
+ last_action_error=False,
348
+ warnings=[],
349
+ done=True,
350
+ reward=max(terminal_reward, 0.0),
351
+ grader_result=grader_result.model_dump(),
352
+ metadata={
353
+ "grader_result": grader_result.model_dump(),
354
+ "cumulative_reward": s.cumulative_reward,
355
+ "reward_components": list(s.reward_components),
356
+ "shortcut_penalty_applied": shortcut_penalty != 0.0,
357
+ "documents_revealed_count": len(s.documents_revealed),
358
+ },
359
+ )
360
+
361
+ def _finalize_timeout(self) -> InvoiceGuardObservation:
362
+ s = self._env_state
363
+ c = self._case
364
+ s.is_finalized = True
365
+
366
+ grader_result = grade_episode(c, s)
367
+ s.cumulative_reward += grader_result.score + R_TIMEOUT_PENALTY
368
+
369
+ return InvoiceGuardObservation(
370
+ case_id=c.case_id,
371
+ task_id=c.task_id.value,
372
+ difficulty=c.difficulty.value,
373
+ invoice_summary=self._invoice_summary(),
374
+ goal=GOAL_TEXT,
375
+ available_actions=[],
376
+ revealed_documents=list(s.documents_revealed),
377
+ findings=list(s.findings_collected),
378
+ remaining_steps=0,
379
+ last_action_result="Step budget exhausted. Episode ended without resolution.",
380
+ last_action_error=True,
381
+ warnings=[],
382
+ done=True,
383
+ reward=max(grader_result.score + R_TIMEOUT_PENALTY, 0.0),
384
+ grader_result=grader_result.model_dump(),
385
+ metadata={
386
+ "grader_result": grader_result.model_dump(),
387
+ "cumulative_reward": s.cumulative_reward,
388
+ "reward_components": list(s.reward_components),
389
+ "timeout": True,
390
+ },
391
+ )
392
+
393
+ def _handle_propose_exception(
394
+ self, action: InvoiceGuardAction, is_repeat: bool
395
+ ) -> tuple:
396
+ s = self._env_state
397
+ c = self._case
398
+ if not action.exception_type:
399
+ return "ERROR: propose_exception_type requires exception_type.", R_INVALID_ACTION
400
+
401
+ s.proposed_exception = action.exception_type.value
402
+ correct = c.ground_truth.correct_exception_type.value
403
+
404
+ if s.proposed_exception == correct:
405
+ finding = f"Proposed exception type: {s.proposed_exception} (noted)."
406
+ return finding, R_CORRECT_PROPOSAL if not is_repeat else R_REPEAT_PENALTY
407
+ else:
408
+ finding = f"Proposed exception type: {s.proposed_exception} (noted)."
409
+ return finding, 0.0
410
+
411
+ # -- Investigation handlers -------------------------------------------
412
+
413
+ def _inspect_invoice_line_items(self, is_repeat: bool) -> tuple:
414
+ c = self._case
415
+ s = self._env_state
416
+ doc = "invoice_line_items"
417
+
418
+ lines = []
419
+ for li in c.invoice.line_items:
420
+ lines.append(
421
+ f" {li.item_code}: {li.description} | "
422
+ f"Qty: {li.quantity_billed} | "
423
+ f"Unit Price: ${li.unit_price_billed:,.2f} | "
424
+ f"Line Total: ${li.line_total_billed:,.2f}"
425
+ )
426
+ detail = (
427
+ f"Invoice {c.invoice.invoice_number} line items:\n"
428
+ + "\n".join(lines)
429
+ + f"\nSubtotal: ${c.invoice.subtotal:,.2f} | "
430
+ f"Tax: ${c.invoice.tax:,.2f} | "
431
+ f"Total: ${c.invoice.total_amount:,.2f}"
432
+ )
433
+
434
+ if c.invoice.note:
435
+ detail += f"\nInvoice note: {c.invoice.note}"
436
+
437
+ reward = self._reveal_doc(doc, detail)
438
+ return detail, reward
439
+
440
+ def _inspect_purchase_order(self, is_repeat: bool) -> tuple:
441
+ c = self._case
442
+ s = self._env_state
443
+ doc = "purchase_order"
444
+
445
+ lines = []
446
+ for li in c.purchase_order.line_items:
447
+ lines.append(
448
+ f" {li.item_code}: {li.description} | "
449
+ f"Ordered Qty: {li.ordered_quantity} | "
450
+ f"Unit Price: ${li.unit_price_ordered:,.2f} | "
451
+ f"Line Total: ${li.line_total_ordered:,.2f}"
452
+ )
453
+ detail = (
454
+ f"PO {c.purchase_order.po_number} "
455
+ f"(Supplier: {c.purchase_order.supplier_id}, "
456
+ f"Date: {c.purchase_order.order_date}):\n"
457
+ + "\n".join(lines)
458
+ + f"\nApproved: {c.purchase_order.approved} | "
459
+ f"Payment Terms: {c.purchase_order.payment_terms}"
460
+ )
461
+
462
+ reward = self._reveal_doc(doc, detail)
463
+ return detail, reward
464
+
465
+ def _inspect_goods_receipt_note(self, is_repeat: bool) -> tuple:
466
+ c = self._case
467
+ doc = "goods_receipt_note"
468
+
469
+ lines = []
470
+ for li in c.goods_receipt_note.line_items:
471
+ lines.append(
472
+ f" {li.item_code}: "
473
+ f"Received: {li.quantity_received} | "
474
+ f"Accepted: {li.accepted_quantity} | "
475
+ f"Rejected: {li.rejected_quantity}"
476
+ )
477
+ detail = (
478
+ f"GRN {c.goods_receipt_note.grn_number} "
479
+ f"(PO Ref: {c.goods_receipt_note.po_reference}, "
480
+ f"Date: {c.goods_receipt_note.receipt_date}):\n"
481
+ + "\n".join(lines)
482
+ )
483
+ if c.goods_receipt_note.warehouse_note:
484
+ detail += f"\nWarehouse note: {c.goods_receipt_note.warehouse_note}"
485
+
486
+ reward = self._reveal_doc(doc, detail)
487
+ return detail, reward
488
+
489
+ def _inspect_vendor_profile(self, is_repeat: bool) -> tuple:
490
+ c = self._case
491
+ doc = "vendor_profile"
492
+ vp = c.vendor_profile
493
+
494
+ detail = (
495
+ f"Vendor Profile -- {vp.supplier_name} ({vp.supplier_id}):\n"
496
+ f" Risk Tier: {vp.risk_tier}\n"
497
+ f" Preferred Vendor: {vp.preferred_vendor}\n"
498
+ f" Duplicate Risk History: {vp.duplicate_risk_count} incidents"
499
+ )
500
+ if vp.tolerance_override is not None:
501
+ detail += f"\n Tolerance Override: {vp.tolerance_override}%"
502
+ if vp.escalation_threshold is not None:
503
+ detail += f"\n Escalation Threshold: ${vp.escalation_threshold:,.2f}"
504
+ inv_total = c.invoice.total_amount
505
+ if inv_total > vp.escalation_threshold:
506
+ detail += (
507
+ f"\n NOTE: Invoice total ${inv_total:,.2f} exceeds vendor "
508
+ f"escalation threshold ${vp.escalation_threshold:,.2f}."
509
+ )
510
+
511
+ reward = self._reveal_doc(doc, detail)
512
+ return detail, reward
513
+
514
+ def _inspect_policy_rules(self, is_repeat: bool) -> tuple:
515
+ c = self._case
516
+ doc = "policy_rules"
517
+ p = c.company_policy
518
+
519
+ detail = (
520
+ f"Company Matching Policy:\n"
521
+ f" Quantity Tolerance: {p.quantity_tolerance_pct}%\n"
522
+ f" Price Tolerance: {p.price_tolerance_pct}%\n"
523
+ f" Total Amount Tolerance: ${p.total_tolerance_amt:,.2f}\n"
524
+ f" Duplicate Check: {'Enabled' if p.duplicate_check_enabled else 'Disabled'}\n"
525
+ f" High-Value Review Threshold: ${p.high_value_threshold:,.2f}\n"
526
+ f" Mandatory Escalation Above: ${p.mandatory_escalation_above:,.2f}\n"
527
+ f"\n"
528
+ f" Resolution Rules:\n"
529
+ f" - APPROVE: All matches within tolerance, no duplicates, no policy violations.\n"
530
+ f" - HOLD: Billed quantity exceeds received quantity (partial/missing receipt).\n"
531
+ f" - ESCALATE: Price or total variance exceeds tolerance. Invoice above high-value threshold.\n"
532
+ f" - REJECT: Duplicate invoice detected. Fraudulent or invalid submission."
533
+ )
534
+
535
+ inv_total = c.invoice.total_amount
536
+ if inv_total >= p.high_value_threshold:
537
+ detail += (
538
+ f"\n\n NOTE: Invoice total ${inv_total:,.2f} exceeds "
539
+ f"high-value threshold ${p.high_value_threshold:,.2f}. "
540
+ f"Supervisor review required."
541
+ )
542
+ self._add_finding(
543
+ f"High-value invoice: ${inv_total:,.2f} exceeds "
544
+ f"review threshold ${p.high_value_threshold:,.2f}."
545
+ )
546
+
547
+ reward = self._reveal_doc(doc, detail)
548
+ return detail, reward
549
+
550
+ def _check_for_duplicate_invoice(self, is_repeat: bool) -> tuple:
551
+ c = self._case
552
+ doc = "duplicate_check"
553
+ ch = c.case_history
554
+
555
+ if ch.processed_invoice_numbers or ch.similar_invoice_refs:
556
+ refs = ch.similar_invoice_refs or ch.processed_invoice_numbers
557
+ detail = (
558
+ f"Duplicate check for {c.invoice.invoice_number}:\n"
559
+ f" Similar/processed invoices found: {', '.join(refs)}\n"
560
+ f" Prior comments: {'; '.join(ch.prior_comments) if ch.prior_comments else 'None'}\n"
561
+ f" Pending invoices: {', '.join(ch.pending_invoice_numbers) if ch.pending_invoice_numbers else 'None'}"
562
+ )
563
+ finding = (
564
+ f"ALERT: Potential duplicate detected. "
565
+ f"Previously processed invoices: {', '.join(refs)}."
566
+ )
567
+ self._add_finding(finding)
568
+ reward = self._reveal_doc(doc, detail)
569
+ return detail, max(reward, R_USEFUL_COMPARISON)
570
+ else:
571
+ detail = (
572
+ f"Duplicate check for {c.invoice.invoice_number}: "
573
+ f"No similar or duplicate invoices found in case history."
574
+ )
575
+ reward = self._reveal_doc(doc, detail)
576
+ return detail, reward
577
+
578
+ def _compare_quantity(self, is_repeat: bool) -> tuple:
579
+ c = self._case
580
+ results = []
581
+ has_discrepancy = False
582
+
583
+ for inv_li in c.invoice.line_items:
584
+ po_qty = None
585
+ grn_qty = None
586
+ for po_li in c.purchase_order.line_items:
587
+ if po_li.item_code == inv_li.item_code:
588
+ po_qty = po_li.ordered_quantity
589
+ break
590
+ for grn_li in c.goods_receipt_note.line_items:
591
+ if grn_li.item_code == inv_li.item_code:
592
+ grn_qty = grn_li.accepted_quantity
593
+ break
594
+
595
+ line = (
596
+ f" {inv_li.item_code}: "
597
+ f"Billed={inv_li.quantity_billed}, "
598
+ f"Ordered={po_qty}, "
599
+ f"Received={grn_qty}"
600
+ )
601
+
602
+ if grn_qty is not None and inv_li.quantity_billed > grn_qty:
603
+ diff = inv_li.quantity_billed - grn_qty
604
+ pct = (diff / inv_li.quantity_billed) * 100
605
+ line += f" -> DISCREPANCY: Billed exceeds received by {diff:.0f} units ({pct:.1f}%)"
606
+ has_discrepancy = True
607
+ self._add_finding(
608
+ f"Quantity discrepancy on {inv_li.item_code}: "
609
+ f"billed {inv_li.quantity_billed} but only {grn_qty} received "
610
+ f"(difference: {diff:.0f} units, {pct:.1f}%)."
611
+ )
612
+ elif po_qty is not None and inv_li.quantity_billed > po_qty:
613
+ diff = inv_li.quantity_billed - po_qty
614
+ line += f" -> DISCREPANCY: Billed exceeds ordered by {diff:.0f} units"
615
+ has_discrepancy = True
616
+ self._add_finding(
617
+ f"Quantity discrepancy on {inv_li.item_code}: "
618
+ f"billed {inv_li.quantity_billed} but only {po_qty} ordered."
619
+ )
620
+ else:
621
+ line += " -> OK"
622
+
623
+ results.append(line)
624
+
625
+ detail = "Quantity comparison:\n" + "\n".join(results)
626
+ reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE
627
+ return detail, reward
628
+
629
+ def _compare_price(self, is_repeat: bool) -> tuple:
630
+ c = self._case
631
+ results = []
632
+ has_discrepancy = False
633
+ tolerance = c.company_policy.price_tolerance_pct
634
+
635
+ for inv_li in c.invoice.line_items:
636
+ po_price = None
637
+ for po_li in c.purchase_order.line_items:
638
+ if po_li.item_code == inv_li.item_code:
639
+ po_price = po_li.unit_price_ordered
640
+ break
641
+
642
+ if po_price is not None:
643
+ line = (
644
+ f" {inv_li.item_code}: "
645
+ f"Billed=${inv_li.unit_price_billed:,.2f}, "
646
+ f"PO Price=${po_price:,.2f}"
647
+ )
648
+ else:
649
+ line = (
650
+ f" {inv_li.item_code}: "
651
+ f"Billed=${inv_li.unit_price_billed:,.2f}, "
652
+ f"PO Price=N/A"
653
+ )
654
+
655
+ if po_price is not None and po_price > 0:
656
+ variance_pct = (
657
+ (inv_li.unit_price_billed - po_price) / po_price
658
+ ) * 100
659
+ if abs(variance_pct) > tolerance:
660
+ line += (
661
+ f" -> DISCREPANCY: {variance_pct:+.1f}% variance "
662
+ f"(exceeds {tolerance}% tolerance)"
663
+ )
664
+ has_discrepancy = True
665
+ self._add_finding(
666
+ f"Price discrepancy on {inv_li.item_code}: "
667
+ f"billed ${inv_li.unit_price_billed:,.2f} vs "
668
+ f"PO ${po_price:,.2f} "
669
+ f"({variance_pct:+.1f}% variance, "
670
+ f"tolerance is {tolerance}%)."
671
+ )
672
+ self._add_finding(
673
+ f"POLICY: Price variance exceeding {tolerance}% tolerance "
674
+ f"requires escalation for supervisor review."
675
+ )
676
+ else:
677
+ line += f" -> Within tolerance ({variance_pct:+.1f}%)"
678
+
679
+ results.append(line)
680
+
681
+ detail = "Price comparison:\n" + "\n".join(results)
682
+ reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE
683
+ return detail, reward
684
+
685
+ def _compare_totals(self, is_repeat: bool) -> tuple:
686
+ c = self._case
687
+ inv = c.invoice
688
+
689
+ expected_subtotal = sum(li.line_total_billed for li in inv.line_items)
690
+ po_total = sum(li.line_total_ordered for li in c.purchase_order.line_items)
691
+ tolerance_amt = c.company_policy.total_tolerance_amt
692
+
693
+ subtotal_ok = abs(inv.subtotal - expected_subtotal) < 0.01
694
+ total_diff = inv.subtotal - po_total
695
+ within_tolerance = abs(total_diff) <= tolerance_amt
696
+
697
+ lines = [
698
+ f" Invoice subtotal: ${inv.subtotal:,.2f} (line items sum: ${expected_subtotal:,.2f}) "
699
+ f"-> {'Consistent' if subtotal_ok else 'INCONSISTENT'}",
700
+ f" Invoice subtotal vs PO total: ${inv.subtotal:,.2f} vs ${po_total:,.2f} "
701
+ f"(diff: ${total_diff:+,.2f}) -> "
702
+ f"{'Within' if within_tolerance else 'EXCEEDS'} tolerance (${tolerance_amt:,.2f})",
703
+ f" Tax: ${inv.tax:,.2f} | Grand Total: ${inv.total_amount:,.2f}",
704
+ ]
705
+
706
+ has_discrepancy = not subtotal_ok or not within_tolerance
707
+ if has_discrepancy:
708
+ if not subtotal_ok:
709
+ self._add_finding(
710
+ f"Total inconsistency: invoice subtotal ${inv.subtotal:,.2f} "
711
+ f"does not match line item sum ${expected_subtotal:,.2f}."
712
+ )
713
+ if not within_tolerance:
714
+ self._add_finding(
715
+ f"Total discrepancy: invoice subtotal ${inv.subtotal:,.2f} vs "
716
+ f"PO total ${po_total:,.2f} (difference ${total_diff:+,.2f} "
717
+ f"exceeds tolerance ${tolerance_amt:,.2f})."
718
+ )
719
+ self._add_finding(
720
+ "POLICY: Total amount variance exceeding tolerance "
721
+ "requires escalation for supervisor review."
722
+ )
723
+
724
+ detail = "Totals comparison:\n" + "\n".join(lines)
725
+ reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE
726
+ return detail, reward
727
+
728
+ def _summarize_findings(self, is_repeat: bool) -> tuple:
729
+ s = self._env_state
730
+ if not s.findings_collected:
731
+ detail = "No findings collected yet. Investigate further before summarizing."
732
+ return detail, 0.0
733
+
734
+ summary_lines = [f" {i+1}. {f}" for i, f in enumerate(s.findings_collected)]
735
+ detail = (
736
+ f"Summary of findings ({len(s.findings_collected)} items):\n"
737
+ + "\n".join(summary_lines)
738
+ + f"\nDocuments reviewed: {', '.join(s.documents_revealed) if s.documents_revealed else 'None'}"
739
+ )
740
+ return detail, R_SUMMARIZE
741
+
742
+ # -- Helpers ----------------------------------------------------------
743
+
744
+ def _reveal_doc(self, doc_name: str, detail: str) -> float:
745
+ s = self._env_state
746
+ if doc_name not in s.documents_revealed:
747
+ s.documents_revealed.append(doc_name)
748
+ s.findings_collected.append(detail)
749
+ return R_NEW_DOCUMENT
750
+ return R_REPEAT_PENALTY
751
+
752
+ def _add_finding(self, finding: str) -> None:
753
+ s = self._env_state
754
+ if finding not in s.findings_collected:
755
+ s.findings_collected.append(finding)
756
+
757
+ def _log_component(
758
+ self,
759
+ action: str,
760
+ base: float,
761
+ penalties: dict,
762
+ total: float,
763
+ reason: str,
764
+ ) -> None:
765
+ """Append a per-step reward breakdown for transparency / training signals."""
766
+ s = self._env_state
767
+ s.reward_components.append(
768
+ {
769
+ "step": s.step_count,
770
+ "action": action,
771
+ "base": round(float(base), 4),
772
+ "penalties": {k: round(float(v), 4) for k, v in penalties.items()},
773
+ "total": round(float(total), 4),
774
+ "reason": reason,
775
+ }
776
+ )
777
+
778
+ def _invoice_summary(self) -> str:
779
+ if self._case is None:
780
+ return ""
781
+ inv = self._case.invoice
782
+ return (
783
+ f"Supplier: {inv.supplier_name} ({inv.supplier_id}), "
784
+ f"Invoice: {inv.invoice_number}, Date: {inv.invoice_date}, "
785
+ f"PO Ref: {inv.po_reference}, Total: ${inv.total_amount:,.2f}"
786
+ )
787
+
788
+ def _suggest_next_actions(self, remaining: int) -> list:
789
+ """Suggest the most useful next actions based on investigation progress."""
790
+ s = self._env_state
791
+ taken = set(s.actions_taken)
792
+ suggestions = []
793
+
794
+ if remaining <= 2:
795
+ suggestions.append("submit_final_resolution")
796
+ return suggestions
797
+
798
+ core_docs = [
799
+ "inspect_purchase_order",
800
+ "inspect_goods_receipt_note",
801
+ "inspect_invoice_line_items",
802
+ ]
803
+ for a in core_docs:
804
+ if a not in taken:
805
+ suggestions.append(a)
806
+
807
+ comparisons = ["compare_quantity", "compare_price", "compare_totals"]
808
+ docs_seen = set(s.documents_revealed)
809
+ has_po = "purchase_order" in docs_seen
810
+ has_grn = "goods_receipt_note" in docs_seen
811
+ if has_po and has_grn:
812
+ for a in comparisons:
813
+ if a not in taken:
814
+ suggestions.append(a)
815
+
816
+ secondary = [
817
+ "inspect_policy_rules",
818
+ "check_for_duplicate_invoice",
819
+ "inspect_vendor_profile",
820
+ ]
821
+ for a in secondary:
822
+ if a not in taken:
823
+ suggestions.append(a)
824
+
825
+ if not suggestions:
826
+ if "summarize_findings" not in taken:
827
+ suggestions.append("summarize_findings")
828
+ suggestions.append("submit_final_resolution")
829
+
830
+ return suggestions
831
+
832
+ def _build_warnings(self, remaining: int) -> list:
833
+ warnings = []
834
+ s = self._env_state
835
+ if remaining <= 2:
836
+ warnings.append(
837
+ f"CRITICAL: Only {remaining} step(s) remaining! "
838
+ f"You MUST submit_final_resolution NOW."
839
+ )
840
+ elif remaining <= 4 and not s.is_finalized:
841
+ warnings.append(
842
+ f"WARNING: {remaining} steps remaining. "
843
+ f"Submit your final resolution soon."
844
+ )
845
+ return warnings
846
+
847
+ def _error_obs(self, message: str) -> InvoiceGuardObservation:
848
+ return InvoiceGuardObservation(
849
+ last_action_result=f"ERROR: {message}",
850
+ last_action_error=True,
851
+ done=True,
852
+ reward=0.0,
853
+ )
854
+
855
+
856
+ # Handler dispatch table -- maps action type strings to bound methods.
857
+ # Built outside the class to keep the routing clean.
858
+ _INVESTIGATION_HANDLERS = {
859
+ ActionType.inspect_invoice_line_items.value: InvoiceGuardEnvironment._inspect_invoice_line_items,
860
+ ActionType.inspect_purchase_order.value: InvoiceGuardEnvironment._inspect_purchase_order,
861
+ ActionType.inspect_goods_receipt_note.value: InvoiceGuardEnvironment._inspect_goods_receipt_note,
862
+ ActionType.inspect_vendor_profile.value: InvoiceGuardEnvironment._inspect_vendor_profile,
863
+ ActionType.inspect_policy_rules.value: InvoiceGuardEnvironment._inspect_policy_rules,
864
+ ActionType.check_for_duplicate_invoice.value: InvoiceGuardEnvironment._check_for_duplicate_invoice,
865
+ ActionType.compare_quantity.value: InvoiceGuardEnvironment._compare_quantity,
866
+ ActionType.compare_price.value: InvoiceGuardEnvironment._compare_price,
867
+ ActionType.compare_totals.value: InvoiceGuardEnvironment._compare_totals,
868
+ ActionType.summarize_findings.value: InvoiceGuardEnvironment._summarize_findings,
869
+ }
tasks/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .definitions import get_task_case, TASK_LIST, ALL_TASKS
2
+ from .hard_definitions import HARD_TASK_LIST
tasks/definitions.py ADDED
@@ -0,0 +1,2104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task definitions and synthetic case templates for InvoiceGuard.
3
+
4
+ Four tasks with increasing difficulty, each with fully synthetic
5
+ business documents and deterministic ground truth.
6
+ """
7
+
8
+ from typing import Dict, List
9
+
10
+ try:
11
+ from ..models import (
12
+ CaseData, CaseHistory, CompanyPolicy, DecisionType, Difficulty,
13
+ ExceptionType, GRNLineItem, GoodsReceiptNote, GroundTruth,
14
+ Invoice, InvoiceLineItem, POLineItem, PurchaseOrder,
15
+ TaskID, VendorProfile,
16
+ )
17
+ from .hard_definitions import HARD_TASK_BUILDERS, HARD_TASK_LIST
18
+ except ImportError:
19
+ from models import (
20
+ CaseData, CaseHistory, CompanyPolicy, DecisionType, Difficulty,
21
+ ExceptionType, GRNLineItem, GoodsReceiptNote, GroundTruth,
22
+ Invoice, InvoiceLineItem, POLineItem, PurchaseOrder,
23
+ TaskID, VendorProfile,
24
+ )
25
+ from tasks.hard_definitions import HARD_TASK_BUILDERS, HARD_TASK_LIST
26
+
27
+
28
+ TASK_LIST: List[TaskID] = [
29
+ TaskID.task_1_clean_match,
30
+ TaskID.task_2_partial_receipt,
31
+ TaskID.task_3_price_variance,
32
+ TaskID.task_4_duplicate_invoice,
33
+ TaskID.task_5_mixed_discrepancy,
34
+ TaskID.task_6_false_positive_duplicate,
35
+ TaskID.task_7_retroactive_price,
36
+ TaskID.task_8_split_invoice_pattern,
37
+ TaskID.task_9_clean_from_risky_vendor,
38
+ TaskID.task_10_rounding_false_alarm,
39
+ TaskID.task_11_authorized_overship,
40
+ TaskID.task_12_corrected_resubmission,
41
+ ]
42
+
43
+ ALL_TASKS: List[TaskID] = [
44
+ TaskID.task_1_clean_match,
45
+ TaskID.task_1b_multi_line_clean,
46
+ TaskID.task_1c_preferred_vendor_clean,
47
+ TaskID.task_2_partial_receipt,
48
+ TaskID.task_2b_missing_receipt,
49
+ TaskID.task_2c_over_receipt,
50
+ TaskID.task_3_price_variance,
51
+ TaskID.task_3b_within_tolerance,
52
+ TaskID.task_3c_total_mismatch,
53
+ TaskID.task_4_duplicate_invoice,
54
+ TaskID.task_4b_corrected_invoice_trap,
55
+ TaskID.task_4c_policy_violation,
56
+ TaskID.task_5_mixed_discrepancy,
57
+ TaskID.task_6_false_positive_duplicate,
58
+ TaskID.task_7_retroactive_price,
59
+ TaskID.task_8_split_invoice_pattern,
60
+ TaskID.task_9_clean_from_risky_vendor,
61
+ TaskID.task_10_rounding_false_alarm,
62
+ TaskID.task_11_authorized_overship,
63
+ TaskID.task_12_corrected_resubmission,
64
+ *HARD_TASK_LIST,
65
+ ]
66
+
67
+
68
+ def get_task_case(task_id: TaskID) -> CaseData:
69
+ """Return the case data for a given task (canonical or variant)."""
70
+ builders: Dict[TaskID, callable] = {
71
+ TaskID.task_1_clean_match: _build_task_1,
72
+ TaskID.task_1b_multi_line_clean: _build_task_1b,
73
+ TaskID.task_1c_preferred_vendor_clean: _build_task_1c,
74
+ TaskID.task_2_partial_receipt: _build_task_2,
75
+ TaskID.task_2b_missing_receipt: _build_task_2b,
76
+ TaskID.task_2c_over_receipt: _build_task_2c,
77
+ TaskID.task_3_price_variance: _build_task_3,
78
+ TaskID.task_3b_within_tolerance: _build_task_3b,
79
+ TaskID.task_3c_total_mismatch: _build_task_3c,
80
+ TaskID.task_4_duplicate_invoice: _build_task_4,
81
+ TaskID.task_4b_corrected_invoice_trap: _build_task_4b,
82
+ TaskID.task_4c_policy_violation: _build_task_4c,
83
+ TaskID.task_5_mixed_discrepancy: _build_task_5,
84
+ TaskID.task_6_false_positive_duplicate: _build_task_6,
85
+ TaskID.task_7_retroactive_price: _build_task_7,
86
+ TaskID.task_8_split_invoice_pattern: _build_task_8,
87
+ TaskID.task_9_clean_from_risky_vendor: _build_task_9,
88
+ TaskID.task_10_rounding_false_alarm: _build_task_10,
89
+ TaskID.task_11_authorized_overship: _build_task_11,
90
+ TaskID.task_12_corrected_resubmission: _build_task_12,
91
+ **HARD_TASK_BUILDERS,
92
+ }
93
+ return builders[task_id]()
94
+
95
+
96
+ # -- Task 1: Clean Match Approval --------------------------------------------
97
+ # Difficulty: easy | Decision: approve_for_payment | Exception: clean_match
98
+ # All three documents align within tolerance. Tests whether the agent can
99
+ # confirm a clean case without inventing problems.
100
+
101
+
102
+ def _build_task_1() -> CaseData:
103
+ return CaseData(
104
+ case_id="CASE-1001",
105
+ task_id=TaskID.task_1_clean_match,
106
+ difficulty=Difficulty.easy,
107
+ max_steps=10,
108
+ invoice=Invoice(
109
+ invoice_number="INV-2024-0042",
110
+ supplier_name="Apex Office Supplies",
111
+ supplier_id="SUP-1001",
112
+ invoice_date="2024-11-15",
113
+ po_reference="PO-5500",
114
+ line_items=[
115
+ InvoiceLineItem(
116
+ item_code="ITEM-A01",
117
+ description="Premium Copy Paper A4 (500 sheets)",
118
+ quantity_billed=100,
119
+ unit_price_billed=25.00,
120
+ line_total_billed=2500.00,
121
+ ),
122
+ ],
123
+ subtotal=2500.00,
124
+ tax=200.00,
125
+ total_amount=2700.00,
126
+ ),
127
+ purchase_order=PurchaseOrder(
128
+ po_number="PO-5500",
129
+ supplier_id="SUP-1001",
130
+ order_date="2024-10-20",
131
+ line_items=[
132
+ POLineItem(
133
+ item_code="ITEM-A01",
134
+ description="Premium Copy Paper A4 (500 sheets)",
135
+ ordered_quantity=100,
136
+ unit_price_ordered=25.00,
137
+ line_total_ordered=2500.00,
138
+ ),
139
+ ],
140
+ ),
141
+ goods_receipt_note=GoodsReceiptNote(
142
+ grn_number="GRN-7801",
143
+ po_reference="PO-5500",
144
+ receipt_date="2024-11-10",
145
+ line_items=[
146
+ GRNLineItem(
147
+ item_code="ITEM-A01",
148
+ quantity_received=100,
149
+ accepted_quantity=100,
150
+ ),
151
+ ],
152
+ warehouse_note="All items received in good condition.",
153
+ ),
154
+ vendor_profile=VendorProfile(
155
+ supplier_id="SUP-1001",
156
+ supplier_name="Apex Office Supplies",
157
+ risk_tier="low",
158
+ preferred_vendor=True,
159
+ ),
160
+ company_policy=CompanyPolicy(
161
+ quantity_tolerance_pct=5.0,
162
+ price_tolerance_pct=5.0,
163
+ total_tolerance_amt=100.0,
164
+ ),
165
+ case_history=CaseHistory(),
166
+ ground_truth=GroundTruth(
167
+ correct_decision=DecisionType.approve_for_payment,
168
+ correct_exception_type=ExceptionType.clean_match,
169
+ acceptable_evidence=[
170
+ "inspect_purchase_order",
171
+ "inspect_goods_receipt_note",
172
+ ],
173
+ key_findings=[
174
+ "Quantities match across invoice, PO, and GRN",
175
+ "Prices match between invoice and PO",
176
+ "No discrepancies detected",
177
+ ],
178
+ ),
179
+ )
180
+
181
+
182
+ # -- Task 2: Quantity & Partial Receipt Hold ----------------------------------
183
+ # Difficulty: moderate | Decision: place_on_hold | Exception: partial_receipt
184
+ # Invoice bills 100 units, PO ordered 100, but GRN only received 60.
185
+ # Agent must recognize billed qty exceeds received qty.
186
+
187
+
188
+ def _build_task_2() -> CaseData:
189
+ return CaseData(
190
+ case_id="CASE-2002",
191
+ task_id=TaskID.task_2_partial_receipt,
192
+ difficulty=Difficulty.moderate,
193
+ max_steps=10,
194
+ invoice=Invoice(
195
+ invoice_number="INV-2024-0187",
196
+ supplier_name="Delta Industrial Parts",
197
+ supplier_id="SUP-2050",
198
+ invoice_date="2024-12-01",
199
+ po_reference="PO-6200",
200
+ line_items=[
201
+ InvoiceLineItem(
202
+ item_code="ITEM-D10",
203
+ description="Stainless Steel Bearings (Box of 50)",
204
+ quantity_billed=100,
205
+ unit_price_billed=50.00,
206
+ line_total_billed=5000.00,
207
+ ),
208
+ ],
209
+ subtotal=5000.00,
210
+ tax=400.00,
211
+ total_amount=5400.00,
212
+ note="Please process payment promptly.",
213
+ ),
214
+ purchase_order=PurchaseOrder(
215
+ po_number="PO-6200",
216
+ supplier_id="SUP-2050",
217
+ order_date="2024-11-01",
218
+ line_items=[
219
+ POLineItem(
220
+ item_code="ITEM-D10",
221
+ description="Stainless Steel Bearings (Box of 50)",
222
+ ordered_quantity=100,
223
+ unit_price_ordered=50.00,
224
+ line_total_ordered=5000.00,
225
+ ),
226
+ ],
227
+ ),
228
+ goods_receipt_note=GoodsReceiptNote(
229
+ grn_number="GRN-8830",
230
+ po_reference="PO-6200",
231
+ receipt_date="2024-11-25",
232
+ line_items=[
233
+ GRNLineItem(
234
+ item_code="ITEM-D10",
235
+ quantity_received=60,
236
+ accepted_quantity=60,
237
+ rejected_quantity=0,
238
+ ),
239
+ ],
240
+ warehouse_note="Partial shipment. Remaining 40 units expected next week.",
241
+ ),
242
+ vendor_profile=VendorProfile(
243
+ supplier_id="SUP-2050",
244
+ supplier_name="Delta Industrial Parts",
245
+ risk_tier="medium",
246
+ preferred_vendor=True,
247
+ ),
248
+ company_policy=CompanyPolicy(
249
+ quantity_tolerance_pct=5.0,
250
+ price_tolerance_pct=5.0,
251
+ total_tolerance_amt=200.0,
252
+ ),
253
+ case_history=CaseHistory(
254
+ prior_comments=["Supplier has had partial shipments before."],
255
+ ),
256
+ ground_truth=GroundTruth(
257
+ correct_decision=DecisionType.place_on_hold,
258
+ correct_exception_type=ExceptionType.partial_receipt,
259
+ acceptable_evidence=[
260
+ "inspect_purchase_order",
261
+ "inspect_goods_receipt_note",
262
+ "compare_quantity",
263
+ ],
264
+ key_findings=[
265
+ "Invoice billed 100 units but GRN shows only 60 received",
266
+ "Billed quantity exceeds received quantity by 40 units (40%)",
267
+ "Variance exceeds quantity tolerance of 5%",
268
+ ],
269
+ ),
270
+ )
271
+
272
+
273
+ # -- Task 3: Price Variance with Policy Tolerance ----------------------------
274
+ # Difficulty: moderate | Decision: escalate_for_supervisor_review
275
+ # Exception: price_mismatch
276
+ # Invoice unit price is $110 vs PO price of $100 (10% variance).
277
+ # Company tolerance is 5%, so this must be escalated.
278
+
279
+
280
+ def _build_task_3() -> CaseData:
281
+ return CaseData(
282
+ case_id="CASE-3003",
283
+ task_id=TaskID.task_3_price_variance,
284
+ difficulty=Difficulty.moderate,
285
+ max_steps=10,
286
+ invoice=Invoice(
287
+ invoice_number="INV-2024-0315",
288
+ supplier_name="Quantum Tech Solutions",
289
+ supplier_id="SUP-3100",
290
+ invoice_date="2024-12-10",
291
+ po_reference="PO-7100",
292
+ line_items=[
293
+ InvoiceLineItem(
294
+ item_code="ITEM-Q22",
295
+ description="Enterprise SSD 1TB NVMe",
296
+ quantity_billed=50,
297
+ unit_price_billed=110.00,
298
+ line_total_billed=5500.00,
299
+ ),
300
+ ],
301
+ subtotal=5500.00,
302
+ tax=440.00,
303
+ total_amount=5940.00,
304
+ note="Price adjusted due to component shortage surcharge.",
305
+ ),
306
+ purchase_order=PurchaseOrder(
307
+ po_number="PO-7100",
308
+ supplier_id="SUP-3100",
309
+ order_date="2024-11-15",
310
+ line_items=[
311
+ POLineItem(
312
+ item_code="ITEM-Q22",
313
+ description="Enterprise SSD 1TB NVMe",
314
+ ordered_quantity=50,
315
+ unit_price_ordered=100.00,
316
+ line_total_ordered=5000.00,
317
+ ),
318
+ ],
319
+ ),
320
+ goods_receipt_note=GoodsReceiptNote(
321
+ grn_number="GRN-9150",
322
+ po_reference="PO-7100",
323
+ receipt_date="2024-12-05",
324
+ line_items=[
325
+ GRNLineItem(
326
+ item_code="ITEM-Q22",
327
+ quantity_received=50,
328
+ accepted_quantity=50,
329
+ ),
330
+ ],
331
+ warehouse_note="All units received and tested OK.",
332
+ ),
333
+ vendor_profile=VendorProfile(
334
+ supplier_id="SUP-3100",
335
+ supplier_name="Quantum Tech Solutions",
336
+ risk_tier="medium",
337
+ preferred_vendor=True,
338
+ escalation_threshold=5000.00,
339
+ ),
340
+ company_policy=CompanyPolicy(
341
+ quantity_tolerance_pct=5.0,
342
+ price_tolerance_pct=5.0,
343
+ total_tolerance_amt=200.0,
344
+ high_value_threshold=50000.0,
345
+ ),
346
+ case_history=CaseHistory(
347
+ prior_comments=[
348
+ "Supplier occasionally applies surcharges without prior notice.",
349
+ ],
350
+ ),
351
+ ground_truth=GroundTruth(
352
+ correct_decision=DecisionType.escalate_for_supervisor_review,
353
+ correct_exception_type=ExceptionType.price_mismatch,
354
+ acceptable_evidence=[
355
+ "inspect_purchase_order",
356
+ "inspect_policy_rules",
357
+ "compare_price",
358
+ ],
359
+ key_findings=[
360
+ "Invoice unit price $110.00 vs PO unit price $100.00",
361
+ "Price variance of 10% exceeds company tolerance of 5%",
362
+ "Requires supervisor review per escalation policy",
363
+ ],
364
+ ),
365
+ )
366
+
367
+
368
+ # -- Task 4: Duplicate Invoice with Mixed Signals ----------------------------
369
+ # Difficulty: hard | Decision: reject_invoice | Exception: duplicate_invoice
370
+ # Quantities and prices match perfectly, but case history reveals a
371
+ # previously processed invoice with near-identical details. A misleading
372
+ # vendor note tries to distract.
373
+
374
+
375
+ def _build_task_4() -> CaseData:
376
+ return CaseData(
377
+ case_id="CASE-4004",
378
+ task_id=TaskID.task_4_duplicate_invoice,
379
+ difficulty=Difficulty.hard,
380
+ max_steps=12,
381
+ invoice=Invoice(
382
+ invoice_number="INV-2024-0512",
383
+ supplier_name="Redline Logistics Corp",
384
+ supplier_id="SUP-4200",
385
+ invoice_date="2024-12-18",
386
+ po_reference="PO-8800",
387
+ line_items=[
388
+ InvoiceLineItem(
389
+ item_code="ITEM-R05",
390
+ description="Heavy-Duty Shipping Pallets",
391
+ quantity_billed=200,
392
+ unit_price_billed=30.00,
393
+ line_total_billed=6000.00,
394
+ ),
395
+ ],
396
+ subtotal=6000.00,
397
+ tax=480.00,
398
+ total_amount=6480.00,
399
+ note="URGENT: Payment overdue. Please expedite. Ref: follow-up to INV-2024-0511.",
400
+ ),
401
+ purchase_order=PurchaseOrder(
402
+ po_number="PO-8800",
403
+ supplier_id="SUP-4200",
404
+ order_date="2024-11-20",
405
+ line_items=[
406
+ POLineItem(
407
+ item_code="ITEM-R05",
408
+ description="Heavy-Duty Shipping Pallets",
409
+ ordered_quantity=200,
410
+ unit_price_ordered=30.00,
411
+ line_total_ordered=6000.00,
412
+ ),
413
+ ],
414
+ ),
415
+ goods_receipt_note=GoodsReceiptNote(
416
+ grn_number="GRN-9900",
417
+ po_reference="PO-8800",
418
+ receipt_date="2024-12-12",
419
+ line_items=[
420
+ GRNLineItem(
421
+ item_code="ITEM-R05",
422
+ quantity_received=200,
423
+ accepted_quantity=200,
424
+ ),
425
+ ],
426
+ warehouse_note="Full shipment received.",
427
+ ),
428
+ vendor_profile=VendorProfile(
429
+ supplier_id="SUP-4200",
430
+ supplier_name="Redline Logistics Corp",
431
+ risk_tier="high",
432
+ duplicate_risk_count=2,
433
+ preferred_vendor=False,
434
+ escalation_threshold=3000.00,
435
+ ),
436
+ company_policy=CompanyPolicy(
437
+ quantity_tolerance_pct=5.0,
438
+ price_tolerance_pct=5.0,
439
+ total_tolerance_amt=200.0,
440
+ duplicate_check_enabled=True,
441
+ ),
442
+ case_history=CaseHistory(
443
+ prior_comments=[
444
+ "Previous duplicate submission flagged for this supplier in Q3.",
445
+ "Supplier has been warned about duplicate invoicing.",
446
+ ],
447
+ similar_invoice_refs=["INV-2024-0511"],
448
+ processed_invoice_numbers=[
449
+ "INV-2024-0511",
450
+ "INV-2024-0388",
451
+ ],
452
+ ),
453
+ ground_truth=GroundTruth(
454
+ correct_decision=DecisionType.reject_invoice,
455
+ correct_exception_type=ExceptionType.duplicate_invoice,
456
+ acceptable_evidence=[
457
+ "check_for_duplicate_invoice",
458
+ "inspect_vendor_profile",
459
+ ],
460
+ key_findings=[
461
+ "Previously processed INV-2024-0511 has same supplier, same PO, same total",
462
+ "Current invoice INV-2024-0512 is sequential to processed INV-2024-0511",
463
+ "Supplier flagged as high risk with 2 prior duplicate incidents",
464
+ "Invoice note references INV-2024-0511 as 'follow-up'",
465
+ ],
466
+ ),
467
+ )
468
+
469
+
470
+ # ===========================================================================
471
+ # VARIANT TASKS
472
+ # ===========================================================================
473
+
474
+
475
+ # -- Task 1b: Multi-Line Clean Match --------------------------------------
476
+ # Three line items, all matching. Tests multi-item verification.
477
+
478
+ def _build_task_1b() -> CaseData:
479
+ return CaseData(
480
+ case_id="CASE-1002",
481
+ task_id=TaskID.task_1b_multi_line_clean,
482
+ difficulty=Difficulty.easy,
483
+ max_steps=10,
484
+ invoice=Invoice(
485
+ invoice_number="INV-2024-0099",
486
+ supplier_name="Summit Facility Services",
487
+ supplier_id="SUP-1050",
488
+ invoice_date="2024-11-20",
489
+ po_reference="PO-5510",
490
+ line_items=[
491
+ InvoiceLineItem(
492
+ item_code="ITEM-F01",
493
+ description="Industrial Floor Cleaner 5L",
494
+ quantity_billed=20,
495
+ unit_price_billed=35.00,
496
+ line_total_billed=700.00,
497
+ ),
498
+ InvoiceLineItem(
499
+ item_code="ITEM-F02",
500
+ description="Microfiber Mop Heads (Pack of 10)",
501
+ quantity_billed=15,
502
+ unit_price_billed=18.00,
503
+ line_total_billed=270.00,
504
+ ),
505
+ InvoiceLineItem(
506
+ item_code="ITEM-F03",
507
+ description="Nitrile Gloves Box (100ct)",
508
+ quantity_billed=50,
509
+ unit_price_billed=12.00,
510
+ line_total_billed=600.00,
511
+ ),
512
+ ],
513
+ subtotal=1570.00,
514
+ tax=125.60,
515
+ total_amount=1695.60,
516
+ ),
517
+ purchase_order=PurchaseOrder(
518
+ po_number="PO-5510",
519
+ supplier_id="SUP-1050",
520
+ order_date="2024-10-25",
521
+ line_items=[
522
+ POLineItem(
523
+ item_code="ITEM-F01",
524
+ description="Industrial Floor Cleaner 5L",
525
+ ordered_quantity=20,
526
+ unit_price_ordered=35.00,
527
+ line_total_ordered=700.00,
528
+ ),
529
+ POLineItem(
530
+ item_code="ITEM-F02",
531
+ description="Microfiber Mop Heads (Pack of 10)",
532
+ ordered_quantity=15,
533
+ unit_price_ordered=18.00,
534
+ line_total_ordered=270.00,
535
+ ),
536
+ POLineItem(
537
+ item_code="ITEM-F03",
538
+ description="Nitrile Gloves Box (100ct)",
539
+ ordered_quantity=50,
540
+ unit_price_ordered=12.00,
541
+ line_total_ordered=600.00,
542
+ ),
543
+ ],
544
+ ),
545
+ goods_receipt_note=GoodsReceiptNote(
546
+ grn_number="GRN-7820",
547
+ po_reference="PO-5510",
548
+ receipt_date="2024-11-15",
549
+ line_items=[
550
+ GRNLineItem(item_code="ITEM-F01", quantity_received=20, accepted_quantity=20),
551
+ GRNLineItem(item_code="ITEM-F02", quantity_received=15, accepted_quantity=15),
552
+ GRNLineItem(item_code="ITEM-F03", quantity_received=50, accepted_quantity=50),
553
+ ],
554
+ warehouse_note="All items received, inspected, shelved.",
555
+ ),
556
+ vendor_profile=VendorProfile(
557
+ supplier_id="SUP-1050",
558
+ supplier_name="Summit Facility Services",
559
+ risk_tier="low",
560
+ preferred_vendor=True,
561
+ ),
562
+ company_policy=CompanyPolicy(
563
+ quantity_tolerance_pct=5.0,
564
+ price_tolerance_pct=5.0,
565
+ total_tolerance_amt=100.0,
566
+ ),
567
+ case_history=CaseHistory(),
568
+ ground_truth=GroundTruth(
569
+ correct_decision=DecisionType.approve_for_payment,
570
+ correct_exception_type=ExceptionType.clean_match,
571
+ acceptable_evidence=[
572
+ "inspect_purchase_order",
573
+ "inspect_goods_receipt_note",
574
+ ],
575
+ key_findings=[
576
+ "All 3 line items match across invoice, PO, and GRN",
577
+ "No price or quantity discrepancies",
578
+ ],
579
+ ),
580
+ )
581
+
582
+
583
+ # -- Task 1c: Preferred Vendor Clean Match --------------------------------
584
+ # Single item, low-risk preferred vendor, very straightforward.
585
+
586
+ def _build_task_1c() -> CaseData:
587
+ return CaseData(
588
+ case_id="CASE-1003",
589
+ task_id=TaskID.task_1c_preferred_vendor_clean,
590
+ difficulty=Difficulty.easy,
591
+ max_steps=10,
592
+ invoice=Invoice(
593
+ invoice_number="INV-2024-0155",
594
+ supplier_name="BrightStar IT Solutions",
595
+ supplier_id="SUP-1200",
596
+ invoice_date="2024-12-02",
597
+ po_reference="PO-5600",
598
+ line_items=[
599
+ InvoiceLineItem(
600
+ item_code="ITEM-B10",
601
+ description="Wireless Keyboard & Mouse Combo",
602
+ quantity_billed=30,
603
+ unit_price_billed=45.00,
604
+ line_total_billed=1350.00,
605
+ ),
606
+ ],
607
+ subtotal=1350.00,
608
+ tax=108.00,
609
+ total_amount=1458.00,
610
+ ),
611
+ purchase_order=PurchaseOrder(
612
+ po_number="PO-5600",
613
+ supplier_id="SUP-1200",
614
+ order_date="2024-11-10",
615
+ line_items=[
616
+ POLineItem(
617
+ item_code="ITEM-B10",
618
+ description="Wireless Keyboard & Mouse Combo",
619
+ ordered_quantity=30,
620
+ unit_price_ordered=45.00,
621
+ line_total_ordered=1350.00,
622
+ ),
623
+ ],
624
+ ),
625
+ goods_receipt_note=GoodsReceiptNote(
626
+ grn_number="GRN-7850",
627
+ po_reference="PO-5600",
628
+ receipt_date="2024-11-28",
629
+ line_items=[
630
+ GRNLineItem(item_code="ITEM-B10", quantity_received=30, accepted_quantity=30),
631
+ ],
632
+ warehouse_note="Full shipment. All items functional.",
633
+ ),
634
+ vendor_profile=VendorProfile(
635
+ supplier_id="SUP-1200",
636
+ supplier_name="BrightStar IT Solutions",
637
+ risk_tier="low",
638
+ preferred_vendor=True,
639
+ ),
640
+ company_policy=CompanyPolicy(
641
+ quantity_tolerance_pct=5.0,
642
+ price_tolerance_pct=5.0,
643
+ total_tolerance_amt=100.0,
644
+ ),
645
+ case_history=CaseHistory(),
646
+ ground_truth=GroundTruth(
647
+ correct_decision=DecisionType.approve_for_payment,
648
+ correct_exception_type=ExceptionType.clean_match,
649
+ acceptable_evidence=[
650
+ "inspect_purchase_order",
651
+ "inspect_goods_receipt_note",
652
+ ],
653
+ key_findings=[
654
+ "Quantities match (30 ordered, 30 billed, 30 received)",
655
+ "Price matches ($45.00 agreed and billed)",
656
+ ],
657
+ ),
658
+ )
659
+
660
+
661
+ # -- Task 2b: Missing Receipt (GRN shows 0 received) ---------------------
662
+ # GRN was filed but zero units accepted. Decision: hold.
663
+
664
+ def _build_task_2b() -> CaseData:
665
+ return CaseData(
666
+ case_id="CASE-2003",
667
+ task_id=TaskID.task_2b_missing_receipt,
668
+ difficulty=Difficulty.moderate,
669
+ max_steps=10,
670
+ invoice=Invoice(
671
+ invoice_number="INV-2024-0201",
672
+ supplier_name="NorthWind Chemicals",
673
+ supplier_id="SUP-2100",
674
+ invoice_date="2024-12-05",
675
+ po_reference="PO-6300",
676
+ line_items=[
677
+ InvoiceLineItem(
678
+ item_code="ITEM-N05",
679
+ description="Laboratory Grade Ethanol 1L",
680
+ quantity_billed=200,
681
+ unit_price_billed=22.00,
682
+ line_total_billed=4400.00,
683
+ ),
684
+ ],
685
+ subtotal=4400.00,
686
+ tax=352.00,
687
+ total_amount=4752.00,
688
+ note="Shipment dispatched per schedule.",
689
+ ),
690
+ purchase_order=PurchaseOrder(
691
+ po_number="PO-6300",
692
+ supplier_id="SUP-2100",
693
+ order_date="2024-11-05",
694
+ line_items=[
695
+ POLineItem(
696
+ item_code="ITEM-N05",
697
+ description="Laboratory Grade Ethanol 1L",
698
+ ordered_quantity=200,
699
+ unit_price_ordered=22.00,
700
+ line_total_ordered=4400.00,
701
+ ),
702
+ ],
703
+ ),
704
+ goods_receipt_note=GoodsReceiptNote(
705
+ grn_number="GRN-8850",
706
+ po_reference="PO-6300",
707
+ receipt_date="2024-11-30",
708
+ line_items=[
709
+ GRNLineItem(
710
+ item_code="ITEM-N05",
711
+ quantity_received=0,
712
+ accepted_quantity=0,
713
+ rejected_quantity=0,
714
+ ),
715
+ ],
716
+ warehouse_note="Shipment not yet received. GRN opened pending delivery.",
717
+ ),
718
+ vendor_profile=VendorProfile(
719
+ supplier_id="SUP-2100",
720
+ supplier_name="NorthWind Chemicals",
721
+ risk_tier="medium",
722
+ preferred_vendor=True,
723
+ ),
724
+ company_policy=CompanyPolicy(
725
+ quantity_tolerance_pct=5.0,
726
+ price_tolerance_pct=5.0,
727
+ total_tolerance_amt=200.0,
728
+ ),
729
+ case_history=CaseHistory(),
730
+ ground_truth=GroundTruth(
731
+ correct_decision=DecisionType.place_on_hold,
732
+ correct_exception_type=ExceptionType.missing_receipt,
733
+ acceptable_evidence=[
734
+ "inspect_purchase_order",
735
+ "inspect_goods_receipt_note",
736
+ "compare_quantity",
737
+ ],
738
+ key_findings=[
739
+ "GRN shows 0 units received against 200 billed",
740
+ "Shipment has not been delivered or accepted",
741
+ "Cannot pay before goods are received",
742
+ ],
743
+ ),
744
+ )
745
+
746
+
747
+ # -- Task 2c: Over-Receipt (billed more than ordered, received matches ordered) -
748
+ # Billed 120 but PO was 100 and GRN accepted 100. Overbilling.
749
+
750
+ def _build_task_2c() -> CaseData:
751
+ return CaseData(
752
+ case_id="CASE-2004",
753
+ task_id=TaskID.task_2c_over_receipt,
754
+ difficulty=Difficulty.moderate,
755
+ max_steps=10,
756
+ invoice=Invoice(
757
+ invoice_number="INV-2024-0220",
758
+ supplier_name="Pacific Hardware Co",
759
+ supplier_id="SUP-2200",
760
+ invoice_date="2024-12-08",
761
+ po_reference="PO-6400",
762
+ line_items=[
763
+ InvoiceLineItem(
764
+ item_code="ITEM-P15",
765
+ description="Stainless Steel Bolts M10x50 (Box of 100)",
766
+ quantity_billed=120,
767
+ unit_price_billed=8.50,
768
+ line_total_billed=1020.00,
769
+ ),
770
+ ],
771
+ subtotal=1020.00,
772
+ tax=81.60,
773
+ total_amount=1101.60,
774
+ note="Additional 20 boxes included as courtesy stock.",
775
+ ),
776
+ purchase_order=PurchaseOrder(
777
+ po_number="PO-6400",
778
+ supplier_id="SUP-2200",
779
+ order_date="2024-11-10",
780
+ line_items=[
781
+ POLineItem(
782
+ item_code="ITEM-P15",
783
+ description="Stainless Steel Bolts M10x50 (Box of 100)",
784
+ ordered_quantity=100,
785
+ unit_price_ordered=8.50,
786
+ line_total_ordered=850.00,
787
+ ),
788
+ ],
789
+ ),
790
+ goods_receipt_note=GoodsReceiptNote(
791
+ grn_number="GRN-8870",
792
+ po_reference="PO-6400",
793
+ receipt_date="2024-12-03",
794
+ line_items=[
795
+ GRNLineItem(
796
+ item_code="ITEM-P15",
797
+ quantity_received=100,
798
+ accepted_quantity=100,
799
+ ),
800
+ ],
801
+ warehouse_note="Received 100 boxes per PO. No extra stock delivered.",
802
+ ),
803
+ vendor_profile=VendorProfile(
804
+ supplier_id="SUP-2200",
805
+ supplier_name="Pacific Hardware Co",
806
+ risk_tier="medium",
807
+ preferred_vendor=True,
808
+ ),
809
+ company_policy=CompanyPolicy(
810
+ quantity_tolerance_pct=5.0,
811
+ price_tolerance_pct=5.0,
812
+ total_tolerance_amt=100.0,
813
+ ),
814
+ case_history=CaseHistory(),
815
+ ground_truth=GroundTruth(
816
+ correct_decision=DecisionType.place_on_hold,
817
+ correct_exception_type=ExceptionType.quantity_mismatch,
818
+ acceptable_evidence=[
819
+ "inspect_purchase_order",
820
+ "inspect_goods_receipt_note",
821
+ "compare_quantity",
822
+ ],
823
+ key_findings=[
824
+ "Invoice bills 120 units but only 100 were ordered and received",
825
+ "Overbilling of 20 units (20% over received)",
826
+ "Variance far exceeds 5% quantity tolerance",
827
+ ],
828
+ ),
829
+ )
830
+
831
+
832
+ # -- Task 3b: Price Within Tolerance (should approve) ---------------------
833
+ # Price is 2% above PO, which is within the 5% tolerance. Clean approval.
834
+
835
+ def _build_task_3b() -> CaseData:
836
+ return CaseData(
837
+ case_id="CASE-3004",
838
+ task_id=TaskID.task_3b_within_tolerance,
839
+ difficulty=Difficulty.moderate,
840
+ max_steps=10,
841
+ invoice=Invoice(
842
+ invoice_number="INV-2024-0330",
843
+ supplier_name="EcoTech Green Supplies",
844
+ supplier_id="SUP-3200",
845
+ invoice_date="2024-12-12",
846
+ po_reference="PO-7200",
847
+ line_items=[
848
+ InvoiceLineItem(
849
+ item_code="ITEM-E08",
850
+ description="Recycled Paper Reams (500 sheets)",
851
+ quantity_billed=80,
852
+ unit_price_billed=20.40,
853
+ line_total_billed=1632.00,
854
+ ),
855
+ ],
856
+ subtotal=1632.00,
857
+ tax=130.56,
858
+ total_amount=1762.56,
859
+ note="Slight price increase due to recycled material costs.",
860
+ ),
861
+ purchase_order=PurchaseOrder(
862
+ po_number="PO-7200",
863
+ supplier_id="SUP-3200",
864
+ order_date="2024-11-18",
865
+ line_items=[
866
+ POLineItem(
867
+ item_code="ITEM-E08",
868
+ description="Recycled Paper Reams (500 sheets)",
869
+ ordered_quantity=80,
870
+ unit_price_ordered=20.00,
871
+ line_total_ordered=1600.00,
872
+ ),
873
+ ],
874
+ ),
875
+ goods_receipt_note=GoodsReceiptNote(
876
+ grn_number="GRN-9170",
877
+ po_reference="PO-7200",
878
+ receipt_date="2024-12-08",
879
+ line_items=[
880
+ GRNLineItem(item_code="ITEM-E08", quantity_received=80, accepted_quantity=80),
881
+ ],
882
+ warehouse_note="All reams received and stored.",
883
+ ),
884
+ vendor_profile=VendorProfile(
885
+ supplier_id="SUP-3200",
886
+ supplier_name="EcoTech Green Supplies",
887
+ risk_tier="low",
888
+ preferred_vendor=True,
889
+ ),
890
+ company_policy=CompanyPolicy(
891
+ quantity_tolerance_pct=5.0,
892
+ price_tolerance_pct=5.0,
893
+ total_tolerance_amt=200.0,
894
+ ),
895
+ case_history=CaseHistory(),
896
+ ground_truth=GroundTruth(
897
+ correct_decision=DecisionType.approve_for_payment,
898
+ correct_exception_type=ExceptionType.clean_match,
899
+ acceptable_evidence=[
900
+ "inspect_purchase_order",
901
+ "inspect_goods_receipt_note",
902
+ "compare_price",
903
+ ],
904
+ key_findings=[
905
+ "Price variance of 2% is within 5% tolerance",
906
+ "Quantities match (80 ordered, 80 billed, 80 received)",
907
+ "Acceptable per company policy",
908
+ ],
909
+ ),
910
+ )
911
+
912
+
913
+ # -- Task 3c: Total Amount Mismatch --------------------------------------
914
+ # Line-item prices match, but the invoice subtotal is inflated (arithmetic error).
915
+
916
+ def _build_task_3c() -> CaseData:
917
+ return CaseData(
918
+ case_id="CASE-3005",
919
+ task_id=TaskID.task_3c_total_mismatch,
920
+ difficulty=Difficulty.moderate,
921
+ max_steps=10,
922
+ invoice=Invoice(
923
+ invoice_number="INV-2024-0345",
924
+ supplier_name="Vanguard Medical Supplies",
925
+ supplier_id="SUP-3300",
926
+ invoice_date="2024-12-14",
927
+ po_reference="PO-7300",
928
+ line_items=[
929
+ InvoiceLineItem(
930
+ item_code="ITEM-V12",
931
+ description="Disposable Surgical Masks (Box of 50)",
932
+ quantity_billed=500,
933
+ unit_price_billed=6.00,
934
+ line_total_billed=3000.00,
935
+ ),
936
+ ],
937
+ subtotal=3500.00,
938
+ tax=280.00,
939
+ total_amount=3780.00,
940
+ note="Bulk order -- standard pricing applied.",
941
+ ),
942
+ purchase_order=PurchaseOrder(
943
+ po_number="PO-7300",
944
+ supplier_id="SUP-3300",
945
+ order_date="2024-11-20",
946
+ line_items=[
947
+ POLineItem(
948
+ item_code="ITEM-V12",
949
+ description="Disposable Surgical Masks (Box of 50)",
950
+ ordered_quantity=500,
951
+ unit_price_ordered=6.00,
952
+ line_total_ordered=3000.00,
953
+ ),
954
+ ],
955
+ ),
956
+ goods_receipt_note=GoodsReceiptNote(
957
+ grn_number="GRN-9200",
958
+ po_reference="PO-7300",
959
+ receipt_date="2024-12-10",
960
+ line_items=[
961
+ GRNLineItem(item_code="ITEM-V12", quantity_received=500, accepted_quantity=500),
962
+ ],
963
+ warehouse_note="Full shipment received.",
964
+ ),
965
+ vendor_profile=VendorProfile(
966
+ supplier_id="SUP-3300",
967
+ supplier_name="Vanguard Medical Supplies",
968
+ risk_tier="low",
969
+ preferred_vendor=True,
970
+ ),
971
+ company_policy=CompanyPolicy(
972
+ quantity_tolerance_pct=5.0,
973
+ price_tolerance_pct=5.0,
974
+ total_tolerance_amt=200.0,
975
+ ),
976
+ case_history=CaseHistory(),
977
+ ground_truth=GroundTruth(
978
+ correct_decision=DecisionType.escalate_for_supervisor_review,
979
+ correct_exception_type=ExceptionType.total_amount_mismatch,
980
+ acceptable_evidence=[
981
+ "inspect_purchase_order",
982
+ "compare_totals",
983
+ ],
984
+ key_findings=[
985
+ "Invoice subtotal $3,500 does not match line items sum $3,000",
986
+ "Invoice subtotal exceeds PO total by $500 (beyond $200 tolerance)",
987
+ "Possible arithmetic error or hidden charges",
988
+ ],
989
+ ),
990
+ )
991
+
992
+
993
+ # -- Task 4b: Corrected Invoice Trap --------------------------------------
994
+ # Invoice claims to be a "corrected version" of a prior invoice, but the
995
+ # prior invoice was already paid. Still a duplicate.
996
+
997
+ def _build_task_4b() -> CaseData:
998
+ return CaseData(
999
+ case_id="CASE-4005",
1000
+ task_id=TaskID.task_4b_corrected_invoice_trap,
1001
+ difficulty=Difficulty.hard,
1002
+ max_steps=12,
1003
+ invoice=Invoice(
1004
+ invoice_number="INV-2024-0601",
1005
+ supplier_name="Atlas Freight Solutions",
1006
+ supplier_id="SUP-4300",
1007
+ invoice_date="2024-12-20",
1008
+ po_reference="PO-8900",
1009
+ line_items=[
1010
+ InvoiceLineItem(
1011
+ item_code="ITEM-A20",
1012
+ description="Corrugated Shipping Boxes (Large)",
1013
+ quantity_billed=300,
1014
+ unit_price_billed=4.50,
1015
+ line_total_billed=1350.00,
1016
+ ),
1017
+ ],
1018
+ subtotal=1350.00,
1019
+ tax=108.00,
1020
+ total_amount=1458.00,
1021
+ note="CORRECTED INVOICE -- replaces INV-2024-0600. Please disregard previous version.",
1022
+ ),
1023
+ purchase_order=PurchaseOrder(
1024
+ po_number="PO-8900",
1025
+ supplier_id="SUP-4300",
1026
+ order_date="2024-11-22",
1027
+ line_items=[
1028
+ POLineItem(
1029
+ item_code="ITEM-A20",
1030
+ description="Corrugated Shipping Boxes (Large)",
1031
+ ordered_quantity=300,
1032
+ unit_price_ordered=4.50,
1033
+ line_total_ordered=1350.00,
1034
+ ),
1035
+ ],
1036
+ ),
1037
+ goods_receipt_note=GoodsReceiptNote(
1038
+ grn_number="GRN-9920",
1039
+ po_reference="PO-8900",
1040
+ receipt_date="2024-12-15",
1041
+ line_items=[
1042
+ GRNLineItem(item_code="ITEM-A20", quantity_received=300, accepted_quantity=300),
1043
+ ],
1044
+ warehouse_note="All boxes received per PO.",
1045
+ ),
1046
+ vendor_profile=VendorProfile(
1047
+ supplier_id="SUP-4300",
1048
+ supplier_name="Atlas Freight Solutions",
1049
+ risk_tier="medium",
1050
+ duplicate_risk_count=1,
1051
+ preferred_vendor=True,
1052
+ escalation_threshold=5000.00,
1053
+ ),
1054
+ company_policy=CompanyPolicy(
1055
+ quantity_tolerance_pct=5.0,
1056
+ price_tolerance_pct=5.0,
1057
+ total_tolerance_amt=200.0,
1058
+ duplicate_check_enabled=True,
1059
+ ),
1060
+ case_history=CaseHistory(
1061
+ prior_comments=[
1062
+ "INV-2024-0600 was processed and paid on 2024-12-17.",
1063
+ ],
1064
+ similar_invoice_refs=["INV-2024-0600"],
1065
+ processed_invoice_numbers=["INV-2024-0600"],
1066
+ ),
1067
+ ground_truth=GroundTruth(
1068
+ correct_decision=DecisionType.reject_invoice,
1069
+ correct_exception_type=ExceptionType.duplicate_invoice,
1070
+ acceptable_evidence=[
1071
+ "check_for_duplicate_invoice",
1072
+ "inspect_vendor_profile",
1073
+ ],
1074
+ key_findings=[
1075
+ "INV-2024-0600 was already processed and paid",
1076
+ "Current invoice claims to be a 'correction' but has identical amounts",
1077
+ "Supplier has 1 prior duplicate incident",
1078
+ ],
1079
+ ),
1080
+ )
1081
+
1082
+
1083
+ # -- Task 4c: Policy Violation (high-value without authorization) ---------
1084
+ # Invoice total exceeds high-value threshold. Everything else matches,
1085
+ # but policy requires escalation for high-value invoices.
1086
+
1087
+ def _build_task_4c() -> CaseData:
1088
+ return CaseData(
1089
+ case_id="CASE-4006",
1090
+ task_id=TaskID.task_4c_policy_violation,
1091
+ difficulty=Difficulty.hard,
1092
+ max_steps=12,
1093
+ invoice=Invoice(
1094
+ invoice_number="INV-2024-0700",
1095
+ supplier_name="TitanWorks Engineering",
1096
+ supplier_id="SUP-4400",
1097
+ invoice_date="2024-12-22",
1098
+ po_reference="PO-9100",
1099
+ line_items=[
1100
+ InvoiceLineItem(
1101
+ item_code="ITEM-T30",
1102
+ description="CNC Machined Aluminum Housing Unit",
1103
+ quantity_billed=100,
1104
+ unit_price_billed=520.00,
1105
+ line_total_billed=52000.00,
1106
+ ),
1107
+ ],
1108
+ subtotal=52000.00,
1109
+ tax=4160.00,
1110
+ total_amount=56160.00,
1111
+ ),
1112
+ purchase_order=PurchaseOrder(
1113
+ po_number="PO-9100",
1114
+ supplier_id="SUP-4400",
1115
+ order_date="2024-12-01",
1116
+ line_items=[
1117
+ POLineItem(
1118
+ item_code="ITEM-T30",
1119
+ description="CNC Machined Aluminum Housing Unit",
1120
+ ordered_quantity=100,
1121
+ unit_price_ordered=520.00,
1122
+ line_total_ordered=52000.00,
1123
+ ),
1124
+ ],
1125
+ ),
1126
+ goods_receipt_note=GoodsReceiptNote(
1127
+ grn_number="GRN-9950",
1128
+ po_reference="PO-9100",
1129
+ receipt_date="2024-12-18",
1130
+ line_items=[
1131
+ GRNLineItem(item_code="ITEM-T30", quantity_received=100, accepted_quantity=100),
1132
+ ],
1133
+ warehouse_note="All units received. Quality inspection passed.",
1134
+ ),
1135
+ vendor_profile=VendorProfile(
1136
+ supplier_id="SUP-4400",
1137
+ supplier_name="TitanWorks Engineering",
1138
+ risk_tier="medium",
1139
+ preferred_vendor=True,
1140
+ escalation_threshold=40000.00,
1141
+ ),
1142
+ company_policy=CompanyPolicy(
1143
+ quantity_tolerance_pct=5.0,
1144
+ price_tolerance_pct=5.0,
1145
+ total_tolerance_amt=500.0,
1146
+ high_value_threshold=50000.0,
1147
+ ),
1148
+ case_history=CaseHistory(),
1149
+ ground_truth=GroundTruth(
1150
+ correct_decision=DecisionType.escalate_for_supervisor_review,
1151
+ correct_exception_type=ExceptionType.policy_violation,
1152
+ acceptable_evidence=[
1153
+ "inspect_purchase_order",
1154
+ "inspect_policy_rules",
1155
+ "inspect_vendor_profile",
1156
+ ],
1157
+ key_findings=[
1158
+ "Invoice total $56,160 exceeds high-value threshold of $50,000",
1159
+ "All quantities and prices match, but policy requires supervisor review",
1160
+ "Invoice also exceeds vendor escalation threshold of $40,000",
1161
+ ],
1162
+ ),
1163
+ )
1164
+
1165
+
1166
+ # ===========================================================================
1167
+ # GENUINELY HARD TASKS
1168
+ # ===========================================================================
1169
+
1170
+
1171
+ # -- Task 5: Mixed Discrepancy (price over tolerance + partial receipt) ---
1172
+ # Difficulty: hard | Decision: escalate_for_supervisor_review
1173
+ # Exception: mixed_discrepancy
1174
+ #
1175
+ # This case has TWO simultaneous issues:
1176
+ # 1. Price variance: $55 billed vs $50 PO (10%, exceeds 5% tolerance)
1177
+ # 2. Partial receipt: billed 200, only 150 received
1178
+ # Both "hold" and "escalate" seem reasonable. Per policy, price variance
1179
+ # exceeding tolerance triggers escalation, which takes priority over hold.
1180
+ # The agent must read the policy rules and recognize escalation outranks hold.
1181
+
1182
+ def _build_task_5() -> CaseData:
1183
+ return CaseData(
1184
+ case_id="CASE-5001",
1185
+ task_id=TaskID.task_5_mixed_discrepancy,
1186
+ difficulty=Difficulty.hard,
1187
+ max_steps=12,
1188
+ invoice=Invoice(
1189
+ invoice_number="INV-2024-0850",
1190
+ supplier_name="GlobalParts Manufacturing",
1191
+ supplier_id="SUP-5100",
1192
+ invoice_date="2024-12-20",
1193
+ po_reference="PO-9500",
1194
+ line_items=[
1195
+ InvoiceLineItem(
1196
+ item_code="ITEM-G40",
1197
+ description="Precision Titanium Fasteners (Box of 25)",
1198
+ quantity_billed=200,
1199
+ unit_price_billed=55.00,
1200
+ line_total_billed=11000.00,
1201
+ ),
1202
+ ],
1203
+ subtotal=11000.00,
1204
+ tax=880.00,
1205
+ total_amount=11880.00,
1206
+ note="Price reflects updated material costs. Partial shipment billed in full per contract terms.",
1207
+ ),
1208
+ purchase_order=PurchaseOrder(
1209
+ po_number="PO-9500",
1210
+ supplier_id="SUP-5100",
1211
+ order_date="2024-11-25",
1212
+ line_items=[
1213
+ POLineItem(
1214
+ item_code="ITEM-G40",
1215
+ description="Precision Titanium Fasteners (Box of 25)",
1216
+ ordered_quantity=200,
1217
+ unit_price_ordered=50.00,
1218
+ line_total_ordered=10000.00,
1219
+ ),
1220
+ ],
1221
+ ),
1222
+ goods_receipt_note=GoodsReceiptNote(
1223
+ grn_number="GRN-10200",
1224
+ po_reference="PO-9500",
1225
+ receipt_date="2024-12-15",
1226
+ line_items=[
1227
+ GRNLineItem(
1228
+ item_code="ITEM-G40",
1229
+ quantity_received=150,
1230
+ accepted_quantity=150,
1231
+ rejected_quantity=0,
1232
+ ),
1233
+ ],
1234
+ warehouse_note="Partial shipment. 50 boxes backordered, ETA 2 weeks.",
1235
+ ),
1236
+ vendor_profile=VendorProfile(
1237
+ supplier_id="SUP-5100",
1238
+ supplier_name="GlobalParts Manufacturing",
1239
+ risk_tier="medium",
1240
+ preferred_vendor=True,
1241
+ escalation_threshold=8000.00,
1242
+ ),
1243
+ company_policy=CompanyPolicy(
1244
+ quantity_tolerance_pct=5.0,
1245
+ price_tolerance_pct=5.0,
1246
+ total_tolerance_amt=300.0,
1247
+ high_value_threshold=50000.0,
1248
+ ),
1249
+ case_history=CaseHistory(
1250
+ prior_comments=[
1251
+ "Supplier has a history of price adjustments on raw material orders.",
1252
+ ],
1253
+ ),
1254
+ ground_truth=GroundTruth(
1255
+ correct_decision=DecisionType.escalate_for_supervisor_review,
1256
+ correct_exception_type=ExceptionType.mixed_discrepancy,
1257
+ acceptable_evidence=[
1258
+ "inspect_purchase_order",
1259
+ "inspect_goods_receipt_note",
1260
+ "compare_quantity",
1261
+ "compare_price",
1262
+ "inspect_policy_rules",
1263
+ ],
1264
+ key_findings=[
1265
+ "Price variance: $55 billed vs $50 PO (10%, exceeds 5% tolerance)",
1266
+ "Quantity discrepancy: billed 200 but only 150 received (25% short)",
1267
+ "Both price escalation and quantity hold conditions triggered",
1268
+ "Per policy, price variance exceeding tolerance requires escalation",
1269
+ "Escalation takes priority over hold when both apply",
1270
+ ],
1271
+ ),
1272
+ )
1273
+
1274
+
1275
+ # -- Task 6: False Positive Duplicate (legitimate second order) -----------
1276
+ # Difficulty: hard | Decision: approve_for_payment
1277
+ # Exception: clean_match
1278
+ #
1279
+ # This case is designed to trap agents that rely on superficial pattern
1280
+ # matching. The invoice is from the SAME supplier, for the SAME item,
1281
+ # with a SIMILAR amount -- but references a DIFFERENT PO number. The case
1282
+ # history has a processed invoice for PO-9600, but this invoice is for
1283
+ # PO-9700 (a separate legitimate order). The vendor note mentioning
1284
+ # "same as last month" is a red herring.
1285
+ #
1286
+ # The agent must carefully check PO references rather than assuming
1287
+ # duplicate based on supplier + amount similarity.
1288
+
1289
+ def _build_task_6() -> CaseData:
1290
+ return CaseData(
1291
+ case_id="CASE-6001",
1292
+ task_id=TaskID.task_6_false_positive_duplicate,
1293
+ difficulty=Difficulty.hard,
1294
+ max_steps=12,
1295
+ invoice=Invoice(
1296
+ invoice_number="INV-2024-0920",
1297
+ supplier_name="Metro Cleaning Services",
1298
+ supplier_id="SUP-6100",
1299
+ invoice_date="2024-12-28",
1300
+ po_reference="PO-9700",
1301
+ line_items=[
1302
+ InvoiceLineItem(
1303
+ item_code="ITEM-M15",
1304
+ description="Monthly Office Cleaning Service - January",
1305
+ quantity_billed=1,
1306
+ unit_price_billed=4500.00,
1307
+ line_total_billed=4500.00,
1308
+ ),
1309
+ ],
1310
+ subtotal=4500.00,
1311
+ tax=360.00,
1312
+ total_amount=4860.00,
1313
+ note="Monthly service invoice -- same as last month's order.",
1314
+ ),
1315
+ purchase_order=PurchaseOrder(
1316
+ po_number="PO-9700",
1317
+ supplier_id="SUP-6100",
1318
+ order_date="2024-12-15",
1319
+ line_items=[
1320
+ POLineItem(
1321
+ item_code="ITEM-M15",
1322
+ description="Monthly Office Cleaning Service - January",
1323
+ ordered_quantity=1,
1324
+ unit_price_ordered=4500.00,
1325
+ line_total_ordered=4500.00,
1326
+ ),
1327
+ ],
1328
+ ),
1329
+ goods_receipt_note=GoodsReceiptNote(
1330
+ grn_number="GRN-10500",
1331
+ po_reference="PO-9700",
1332
+ receipt_date="2024-12-26",
1333
+ line_items=[
1334
+ GRNLineItem(
1335
+ item_code="ITEM-M15",
1336
+ quantity_received=1,
1337
+ accepted_quantity=1,
1338
+ ),
1339
+ ],
1340
+ warehouse_note="Service confirmed completed for January cycle.",
1341
+ ),
1342
+ vendor_profile=VendorProfile(
1343
+ supplier_id="SUP-6100",
1344
+ supplier_name="Metro Cleaning Services",
1345
+ risk_tier="low",
1346
+ duplicate_risk_count=0,
1347
+ preferred_vendor=True,
1348
+ ),
1349
+ company_policy=CompanyPolicy(
1350
+ quantity_tolerance_pct=5.0,
1351
+ price_tolerance_pct=5.0,
1352
+ total_tolerance_amt=200.0,
1353
+ duplicate_check_enabled=True,
1354
+ ),
1355
+ case_history=CaseHistory(
1356
+ prior_comments=[
1357
+ "Monthly recurring service contract. New PO issued each month.",
1358
+ ],
1359
+ processed_invoice_numbers=[
1360
+ "INV-2024-0880",
1361
+ ],
1362
+ similar_invoice_refs=[],
1363
+ ),
1364
+ ground_truth=GroundTruth(
1365
+ correct_decision=DecisionType.approve_for_payment,
1366
+ correct_exception_type=ExceptionType.clean_match,
1367
+ acceptable_evidence=[
1368
+ "inspect_purchase_order",
1369
+ "inspect_goods_receipt_note",
1370
+ "check_for_duplicate_invoice",
1371
+ ],
1372
+ key_findings=[
1373
+ "Invoice references PO-9700, which is a separate order from prior PO-9600",
1374
+ "Prior invoice INV-2024-0880 was for a different PO (different month)",
1375
+ "Quantities and prices match PO exactly",
1376
+ "No actual duplicate -- this is a legitimate recurring service invoice",
1377
+ "Vendor has 0 duplicate risk incidents",
1378
+ ],
1379
+ ),
1380
+ )
1381
+
1382
+
1383
+ # -- Task 7: Retroactive Price Adjustment -----------------------------------
1384
+ # Difficulty: hard | Decision: escalate_for_supervisor_review | Exception: price_mismatch
1385
+ # Vendor had a contract price change effective 2024-11-01, but the PO was issued
1386
+ # at the old price on 2024-10-20. The invoice uses the NEW (higher) price.
1387
+ # Agent must notice the temporal mismatch: PO predates the price change, so the
1388
+ # PO price is the agreed-upon price. The invoice price exceeds tolerance.
1389
+ # Tests temporal reasoning and policy awareness.
1390
+
1391
+
1392
+ def _build_task_7() -> CaseData:
1393
+ return CaseData(
1394
+ case_id="CASE-7001",
1395
+ task_id=TaskID.task_7_retroactive_price,
1396
+ difficulty=Difficulty.hard,
1397
+ max_steps=12,
1398
+ invoice=Invoice(
1399
+ invoice_number="INV-2024-1150",
1400
+ supplier_name="Precision Parts Ltd",
1401
+ supplier_id="SUP-7100",
1402
+ invoice_date="2024-11-10",
1403
+ po_reference="PO-7500",
1404
+ line_items=[
1405
+ InvoiceLineItem(
1406
+ item_code="ITEM-PP01",
1407
+ description="CNC Bearing Assembly Type-R",
1408
+ quantity_billed=200,
1409
+ unit_price_billed=47.50,
1410
+ line_total_billed=9500.00,
1411
+ ),
1412
+ InvoiceLineItem(
1413
+ item_code="ITEM-PP02",
1414
+ description="Hydraulic Seal Kit Grade-A",
1415
+ quantity_billed=500,
1416
+ unit_price_billed=12.80,
1417
+ line_total_billed=6400.00,
1418
+ ),
1419
+ ],
1420
+ subtotal=15900.00,
1421
+ tax=1272.00,
1422
+ total_amount=17172.00,
1423
+ note="Prices reflect updated catalog effective 2024-11-01.",
1424
+ ),
1425
+ purchase_order=PurchaseOrder(
1426
+ po_number="PO-7500",
1427
+ supplier_id="SUP-7100",
1428
+ order_date="2024-10-20",
1429
+ line_items=[
1430
+ POLineItem(
1431
+ item_code="ITEM-PP01",
1432
+ description="CNC Bearing Assembly Type-R",
1433
+ ordered_quantity=200,
1434
+ unit_price_ordered=42.00,
1435
+ line_total_ordered=8400.00,
1436
+ ),
1437
+ POLineItem(
1438
+ item_code="ITEM-PP02",
1439
+ description="Hydraulic Seal Kit Grade-A",
1440
+ ordered_quantity=500,
1441
+ unit_price_ordered=11.50,
1442
+ line_total_ordered=5750.00,
1443
+ ),
1444
+ ],
1445
+ payment_terms="Net 45",
1446
+ ),
1447
+ goods_receipt_note=GoodsReceiptNote(
1448
+ grn_number="GRN-12500",
1449
+ po_reference="PO-7500",
1450
+ receipt_date="2024-11-05",
1451
+ line_items=[
1452
+ GRNLineItem(
1453
+ item_code="ITEM-PP01",
1454
+ quantity_received=200,
1455
+ accepted_quantity=200,
1456
+ ),
1457
+ GRNLineItem(
1458
+ item_code="ITEM-PP02",
1459
+ quantity_received=500,
1460
+ accepted_quantity=500,
1461
+ ),
1462
+ ],
1463
+ warehouse_note="All items received in good condition. Full order.",
1464
+ ),
1465
+ vendor_profile=VendorProfile(
1466
+ supplier_id="SUP-7100",
1467
+ supplier_name="Precision Parts Ltd",
1468
+ risk_tier="medium",
1469
+ duplicate_risk_count=0,
1470
+ preferred_vendor=True,
1471
+ escalation_threshold=15000.00,
1472
+ ),
1473
+ company_policy=CompanyPolicy(
1474
+ quantity_tolerance_pct=5.0,
1475
+ price_tolerance_pct=5.0,
1476
+ total_tolerance_amt=500.0,
1477
+ duplicate_check_enabled=True,
1478
+ high_value_threshold=50000.0,
1479
+ ),
1480
+ case_history=CaseHistory(
1481
+ prior_comments=[
1482
+ "Vendor announced price increase effective 2024-11-01.",
1483
+ "PO-7500 was placed on 2024-10-20 at the old catalog prices.",
1484
+ "Contract terms: PO price governs unless amended before shipment.",
1485
+ ],
1486
+ ),
1487
+ ground_truth=GroundTruth(
1488
+ correct_decision=DecisionType.escalate_for_supervisor_review,
1489
+ correct_exception_type=ExceptionType.price_mismatch,
1490
+ acceptable_evidence=[
1491
+ "inspect_purchase_order",
1492
+ "inspect_goods_receipt_note",
1493
+ "compare_price",
1494
+ "inspect_policy_rules",
1495
+ "inspect_vendor_profile",
1496
+ ],
1497
+ key_findings=[
1498
+ "ITEM-PP01 billed at $47.50 vs PO price $42.00 (13.1% variance)",
1499
+ "ITEM-PP02 billed at $12.80 vs PO price $11.50 (11.3% variance)",
1500
+ "Both exceed 5% price tolerance",
1501
+ "PO was issued 2024-10-20, before price change effective 2024-11-01",
1502
+ "Per contract terms, PO price governs unless PO is amended",
1503
+ "Invoice total $17,172 exceeds vendor escalation threshold $15,000",
1504
+ ],
1505
+ ),
1506
+ )
1507
+
1508
+
1509
+ # -- Task 8: Adversarial Split-Invoice Pattern --------------------------------
1510
+ # Difficulty: hard | Decision: escalate_for_supervisor_review | Exception: policy_violation
1511
+ # Supplier splits a large order into multiple smaller invoices to stay below
1512
+ # the auto-approval threshold ($50,000). Each individual invoice looks clean,
1513
+ # but case history reveals a pattern of split invoicing from this vendor.
1514
+ # Agent must check vendor profile (high duplicate risk count) and case history
1515
+ # to detect the pattern. Tests cross-case pattern recognition.
1516
+
1517
+
1518
+ def _build_task_8() -> CaseData:
1519
+ return CaseData(
1520
+ case_id="CASE-8001",
1521
+ task_id=TaskID.task_8_split_invoice_pattern,
1522
+ difficulty=Difficulty.hard,
1523
+ max_steps=12,
1524
+ invoice=Invoice(
1525
+ invoice_number="INV-2024-2210",
1526
+ supplier_name="Global Tech Supplies",
1527
+ supplier_id="SUP-8200",
1528
+ invoice_date="2024-12-01",
1529
+ po_reference="PO-8800",
1530
+ line_items=[
1531
+ InvoiceLineItem(
1532
+ item_code="ITEM-GT10",
1533
+ description="Enterprise Server Rack Unit 42U",
1534
+ quantity_billed=3,
1535
+ unit_price_billed=14500.00,
1536
+ line_total_billed=43500.00,
1537
+ ),
1538
+ ],
1539
+ subtotal=43500.00,
1540
+ tax=3480.00,
1541
+ total_amount=46980.00,
1542
+ note="Shipment 3 of 3. Final batch for data center buildout.",
1543
+ ),
1544
+ purchase_order=PurchaseOrder(
1545
+ po_number="PO-8800",
1546
+ supplier_id="SUP-8200",
1547
+ order_date="2024-11-15",
1548
+ line_items=[
1549
+ POLineItem(
1550
+ item_code="ITEM-GT10",
1551
+ description="Enterprise Server Rack Unit 42U",
1552
+ ordered_quantity=3,
1553
+ unit_price_ordered=14500.00,
1554
+ line_total_ordered=43500.00,
1555
+ ),
1556
+ ],
1557
+ payment_terms="Net 30",
1558
+ ),
1559
+ goods_receipt_note=GoodsReceiptNote(
1560
+ grn_number="GRN-15200",
1561
+ po_reference="PO-8800",
1562
+ receipt_date="2024-11-28",
1563
+ line_items=[
1564
+ GRNLineItem(
1565
+ item_code="ITEM-GT10",
1566
+ quantity_received=3,
1567
+ accepted_quantity=3,
1568
+ ),
1569
+ ],
1570
+ warehouse_note="Final batch received. All 9 rack units now in inventory.",
1571
+ ),
1572
+ vendor_profile=VendorProfile(
1573
+ supplier_id="SUP-8200",
1574
+ supplier_name="Global Tech Supplies",
1575
+ risk_tier="high",
1576
+ duplicate_risk_count=4,
1577
+ preferred_vendor=False,
1578
+ escalation_threshold=40000.00,
1579
+ ),
1580
+ company_policy=CompanyPolicy(
1581
+ quantity_tolerance_pct=5.0,
1582
+ price_tolerance_pct=5.0,
1583
+ total_tolerance_amt=500.0,
1584
+ duplicate_check_enabled=True,
1585
+ high_value_threshold=50000.00,
1586
+ mandatory_escalation_above=100000.00,
1587
+ ),
1588
+ case_history=CaseHistory(
1589
+ prior_comments=[
1590
+ "ALERT: Vendor SUP-8200 has submitted 3 invoices this month "
1591
+ "for similar items across 3 separate POs (PO-8600, PO-8700, PO-8800).",
1592
+ "Each invoice is just under the $50,000 auto-approval threshold.",
1593
+ "Combined value: $46,200 + $47,800 + $46,980 = $140,980.",
1594
+ "Finance team flagged potential invoice-splitting pattern.",
1595
+ "Vendor is non-preferred with 4 prior risk incidents.",
1596
+ ],
1597
+ similar_invoice_refs=["INV-2024-2180", "INV-2024-2195"],
1598
+ processed_invoice_numbers=["INV-2024-2180", "INV-2024-2195"],
1599
+ ),
1600
+ ground_truth=GroundTruth(
1601
+ correct_decision=DecisionType.escalate_for_supervisor_review,
1602
+ correct_exception_type=ExceptionType.policy_violation,
1603
+ acceptable_evidence=[
1604
+ "inspect_purchase_order",
1605
+ "inspect_goods_receipt_note",
1606
+ "inspect_vendor_profile",
1607
+ "inspect_policy_rules",
1608
+ "check_for_duplicate_invoice",
1609
+ ],
1610
+ key_findings=[
1611
+ "Invoice $46,980 is just under auto-approval threshold of $50,000",
1612
+ "3 similar invoices from same vendor total $140,980",
1613
+ "Vendor is high-risk tier with 4 prior risk incidents",
1614
+ "Vendor is non-preferred",
1615
+ "Case history explicitly flags split-invoice pattern concern",
1616
+ "Combined value exceeds mandatory escalation threshold of $100,000",
1617
+ "Individual prices and quantities match PO -- no line-level discrepancy",
1618
+ ],
1619
+ ),
1620
+ )
1621
+
1622
+
1623
+ # -- Task 9: Clean Invoice from High-Risk Vendor ----------------------------
1624
+ # Difficulty: hard | Decision: approve_for_payment | Exception: clean_match
1625
+ # Every document matches perfectly. But the vendor is HIGH RISK with 5 prior
1626
+ # incidents and is non-preferred. The trap: agents escalate or reject based
1627
+ # on vendor risk alone. Per policy, risk tier affects escalation thresholds
1628
+ # for discrepancies, not clean invoices. No discrepancy = approve.
1629
+
1630
+
1631
+ def _build_task_9() -> CaseData:
1632
+ return CaseData(
1633
+ case_id="CASE-9001",
1634
+ task_id=TaskID.task_9_clean_from_risky_vendor,
1635
+ difficulty=Difficulty.hard,
1636
+ max_steps=12,
1637
+ invoice=Invoice(
1638
+ invoice_number="INV-2024-3300",
1639
+ supplier_name="Delta Industrial Components",
1640
+ supplier_id="SUP-9300",
1641
+ invoice_date="2024-12-10",
1642
+ po_reference="PO-9200",
1643
+ line_items=[
1644
+ InvoiceLineItem(
1645
+ item_code="ITEM-DC01",
1646
+ description="Steel Flanged Coupling 6-inch",
1647
+ quantity_billed=50,
1648
+ unit_price_billed=85.00,
1649
+ line_total_billed=4250.00,
1650
+ ),
1651
+ InvoiceLineItem(
1652
+ item_code="ITEM-DC02",
1653
+ description="Gasket Kit Industrial Grade",
1654
+ quantity_billed=200,
1655
+ unit_price_billed=12.50,
1656
+ line_total_billed=2500.00,
1657
+ ),
1658
+ ],
1659
+ subtotal=6750.00,
1660
+ tax=540.00,
1661
+ total_amount=7290.00,
1662
+ ),
1663
+ purchase_order=PurchaseOrder(
1664
+ po_number="PO-9200",
1665
+ supplier_id="SUP-9300",
1666
+ order_date="2024-11-25",
1667
+ line_items=[
1668
+ POLineItem(
1669
+ item_code="ITEM-DC01",
1670
+ description="Steel Flanged Coupling 6-inch",
1671
+ ordered_quantity=50,
1672
+ unit_price_ordered=85.00,
1673
+ line_total_ordered=4250.00,
1674
+ ),
1675
+ POLineItem(
1676
+ item_code="ITEM-DC02",
1677
+ description="Gasket Kit Industrial Grade",
1678
+ ordered_quantity=200,
1679
+ unit_price_ordered=12.50,
1680
+ line_total_ordered=2500.00,
1681
+ ),
1682
+ ],
1683
+ ),
1684
+ goods_receipt_note=GoodsReceiptNote(
1685
+ grn_number="GRN-16000",
1686
+ po_reference="PO-9200",
1687
+ receipt_date="2024-12-05",
1688
+ line_items=[
1689
+ GRNLineItem(
1690
+ item_code="ITEM-DC01",
1691
+ quantity_received=50,
1692
+ accepted_quantity=50,
1693
+ ),
1694
+ GRNLineItem(
1695
+ item_code="ITEM-DC02",
1696
+ quantity_received=200,
1697
+ accepted_quantity=200,
1698
+ ),
1699
+ ],
1700
+ warehouse_note="Full order received. Quality inspection passed.",
1701
+ ),
1702
+ vendor_profile=VendorProfile(
1703
+ supplier_id="SUP-9300",
1704
+ supplier_name="Delta Industrial Components",
1705
+ risk_tier="high",
1706
+ duplicate_risk_count=5,
1707
+ preferred_vendor=False,
1708
+ escalation_threshold=5000.00,
1709
+ ),
1710
+ company_policy=CompanyPolicy(
1711
+ quantity_tolerance_pct=5.0,
1712
+ price_tolerance_pct=5.0,
1713
+ total_tolerance_amt=200.0,
1714
+ duplicate_check_enabled=True,
1715
+ ),
1716
+ case_history=CaseHistory(
1717
+ prior_comments=[
1718
+ "Vendor under enhanced monitoring due to past billing errors.",
1719
+ "Last 3 invoices from this vendor were all clean and processed normally.",
1720
+ "Risk tier remains 'high' pending annual vendor review in Q1 2025.",
1721
+ ],
1722
+ ),
1723
+ ground_truth=GroundTruth(
1724
+ correct_decision=DecisionType.approve_for_payment,
1725
+ correct_exception_type=ExceptionType.clean_match,
1726
+ acceptable_evidence=[
1727
+ "inspect_purchase_order",
1728
+ "inspect_goods_receipt_note",
1729
+ "compare_quantity",
1730
+ "compare_price",
1731
+ "inspect_vendor_profile",
1732
+ "inspect_policy_rules",
1733
+ ],
1734
+ key_findings=[
1735
+ "All quantities match exactly: 50 couplings and 200 gasket kits",
1736
+ "All prices match PO exactly: $85.00 and $12.50",
1737
+ "Vendor is high-risk but no actual discrepancy exists",
1738
+ "Risk tier affects escalation thresholds, not clean invoices",
1739
+ "Last 3 invoices from this vendor were processed normally",
1740
+ "No policy rule mandates rejection of clean invoices from high-risk vendors",
1741
+ ],
1742
+ ),
1743
+ )
1744
+
1745
+
1746
+ # -- Task 10: Rounding Error False Alarm ------------------------------------
1747
+ # Difficulty: hard | Decision: approve_for_payment | Exception: clean_match
1748
+ # Invoice total is $0.01 off from calculated sum of line items due to
1749
+ # per-line rounding. All line items match PO exactly. The trivial rounding
1750
+ # difference is well within any tolerance. Trap: compare_totals reports a
1751
+ # "mismatch" finding and agents escalate over one cent.
1752
+
1753
+
1754
+ def _build_task_10() -> CaseData:
1755
+ return CaseData(
1756
+ case_id="CASE-10001",
1757
+ task_id=TaskID.task_10_rounding_false_alarm,
1758
+ difficulty=Difficulty.hard,
1759
+ max_steps=12,
1760
+ invoice=Invoice(
1761
+ invoice_number="INV-2024-4410",
1762
+ supplier_name="Kensington Paper & Print",
1763
+ supplier_id="SUP-10400",
1764
+ invoice_date="2024-12-15",
1765
+ po_reference="PO-10100",
1766
+ line_items=[
1767
+ InvoiceLineItem(
1768
+ item_code="ITEM-KP01",
1769
+ description="A4 Premium Copy Paper (case of 10 reams)",
1770
+ quantity_billed=33,
1771
+ unit_price_billed=42.99,
1772
+ line_total_billed=1418.67,
1773
+ ),
1774
+ InvoiceLineItem(
1775
+ item_code="ITEM-KP02",
1776
+ description="Color Laser Toner Cartridge Black",
1777
+ quantity_billed=7,
1778
+ unit_price_billed=189.99,
1779
+ line_total_billed=1329.93,
1780
+ ),
1781
+ InvoiceLineItem(
1782
+ item_code="ITEM-KP03",
1783
+ description="Binding Covers Glossy A4 (pack of 100)",
1784
+ quantity_billed=15,
1785
+ unit_price_billed=28.33,
1786
+ line_total_billed=424.95,
1787
+ ),
1788
+ ],
1789
+ subtotal=3173.55,
1790
+ tax=253.88,
1791
+ total_amount=3427.43,
1792
+ ),
1793
+ purchase_order=PurchaseOrder(
1794
+ po_number="PO-10100",
1795
+ supplier_id="SUP-10400",
1796
+ order_date="2024-12-01",
1797
+ line_items=[
1798
+ POLineItem(
1799
+ item_code="ITEM-KP01",
1800
+ description="A4 Premium Copy Paper (case of 10 reams)",
1801
+ ordered_quantity=33,
1802
+ unit_price_ordered=42.99,
1803
+ line_total_ordered=1418.67,
1804
+ ),
1805
+ POLineItem(
1806
+ item_code="ITEM-KP02",
1807
+ description="Color Laser Toner Cartridge Black",
1808
+ ordered_quantity=7,
1809
+ unit_price_ordered=189.99,
1810
+ line_total_ordered=1329.93,
1811
+ ),
1812
+ POLineItem(
1813
+ item_code="ITEM-KP03",
1814
+ description="Binding Covers Glossy A4 (pack of 100)",
1815
+ ordered_quantity=15,
1816
+ unit_price_ordered=28.33,
1817
+ line_total_ordered=424.95,
1818
+ ),
1819
+ ],
1820
+ ),
1821
+ goods_receipt_note=GoodsReceiptNote(
1822
+ grn_number="GRN-17500",
1823
+ po_reference="PO-10100",
1824
+ receipt_date="2024-12-10",
1825
+ line_items=[
1826
+ GRNLineItem(
1827
+ item_code="ITEM-KP01",
1828
+ quantity_received=33,
1829
+ accepted_quantity=33,
1830
+ ),
1831
+ GRNLineItem(
1832
+ item_code="ITEM-KP02",
1833
+ quantity_received=7,
1834
+ accepted_quantity=7,
1835
+ ),
1836
+ GRNLineItem(
1837
+ item_code="ITEM-KP03",
1838
+ quantity_received=15,
1839
+ accepted_quantity=15,
1840
+ ),
1841
+ ],
1842
+ ),
1843
+ vendor_profile=VendorProfile(
1844
+ supplier_id="SUP-10400",
1845
+ supplier_name="Kensington Paper & Print",
1846
+ risk_tier="low",
1847
+ preferred_vendor=True,
1848
+ ),
1849
+ company_policy=CompanyPolicy(
1850
+ quantity_tolerance_pct=5.0,
1851
+ price_tolerance_pct=5.0,
1852
+ total_tolerance_amt=100.0,
1853
+ duplicate_check_enabled=True,
1854
+ ),
1855
+ case_history=CaseHistory(),
1856
+ ground_truth=GroundTruth(
1857
+ correct_decision=DecisionType.approve_for_payment,
1858
+ correct_exception_type=ExceptionType.clean_match,
1859
+ acceptable_evidence=[
1860
+ "inspect_purchase_order",
1861
+ "inspect_goods_receipt_note",
1862
+ "compare_quantity",
1863
+ "compare_price",
1864
+ "compare_totals",
1865
+ "inspect_policy_rules",
1866
+ ],
1867
+ key_findings=[
1868
+ "All quantities match exactly",
1869
+ "All unit prices match PO exactly",
1870
+ "Subtotal rounding difference of $0.01 is within tolerance",
1871
+ "Total tolerance is $100.00 -- a $0.01 difference is negligible",
1872
+ "Rounding is a normal artifact of multi-line calculation",
1873
+ "Preferred vendor with low risk tier",
1874
+ ],
1875
+ ),
1876
+ )
1877
+
1878
+
1879
+ # -- Task 11: Authorized Overship -------------------------------------------
1880
+ # Difficulty: hard | Decision: approve_for_payment | Exception: clean_match
1881
+ # GRN shows 110 units received vs 100 ordered. Invoice bills for 110.
1882
+ # Case history contains explicit authorization for up to 10% overship.
1883
+ # Trap: agents see quantity mismatch and hold. But the overship was
1884
+ # pre-authorized, making this a clean match per the authorization.
1885
+
1886
+
1887
+ def _build_task_11() -> CaseData:
1888
+ return CaseData(
1889
+ case_id="CASE-11001",
1890
+ task_id=TaskID.task_11_authorized_overship,
1891
+ difficulty=Difficulty.hard,
1892
+ max_steps=12,
1893
+ invoice=Invoice(
1894
+ invoice_number="INV-2024-5520",
1895
+ supplier_name="Pacific Coast Fasteners",
1896
+ supplier_id="SUP-11500",
1897
+ invoice_date="2024-12-18",
1898
+ po_reference="PO-11300",
1899
+ line_items=[
1900
+ InvoiceLineItem(
1901
+ item_code="ITEM-PF01",
1902
+ description="M8 Hex Bolt Grade 8.8 Zinc Plated (box of 100)",
1903
+ quantity_billed=110,
1904
+ unit_price_billed=24.50,
1905
+ line_total_billed=2695.00,
1906
+ ),
1907
+ ],
1908
+ subtotal=2695.00,
1909
+ tax=215.60,
1910
+ total_amount=2910.60,
1911
+ note="Quantity includes authorized 10% overship per PO amendment.",
1912
+ ),
1913
+ purchase_order=PurchaseOrder(
1914
+ po_number="PO-11300",
1915
+ supplier_id="SUP-11500",
1916
+ order_date="2024-12-01",
1917
+ line_items=[
1918
+ POLineItem(
1919
+ item_code="ITEM-PF01",
1920
+ description="M8 Hex Bolt Grade 8.8 Zinc Plated (box of 100)",
1921
+ ordered_quantity=100,
1922
+ unit_price_ordered=24.50,
1923
+ line_total_ordered=2450.00,
1924
+ ),
1925
+ ],
1926
+ payment_terms="Net 30",
1927
+ ),
1928
+ goods_receipt_note=GoodsReceiptNote(
1929
+ grn_number="GRN-18800",
1930
+ po_reference="PO-11300",
1931
+ receipt_date="2024-12-12",
1932
+ line_items=[
1933
+ GRNLineItem(
1934
+ item_code="ITEM-PF01",
1935
+ quantity_received=110,
1936
+ accepted_quantity=110,
1937
+ ),
1938
+ ],
1939
+ warehouse_note="110 boxes received. Overship authorized per buyer memo dated 2024-12-02.",
1940
+ ),
1941
+ vendor_profile=VendorProfile(
1942
+ supplier_id="SUP-11500",
1943
+ supplier_name="Pacific Coast Fasteners",
1944
+ risk_tier="low",
1945
+ preferred_vendor=True,
1946
+ ),
1947
+ company_policy=CompanyPolicy(
1948
+ quantity_tolerance_pct=5.0,
1949
+ price_tolerance_pct=5.0,
1950
+ total_tolerance_amt=500.0,
1951
+ duplicate_check_enabled=True,
1952
+ ),
1953
+ case_history=CaseHistory(
1954
+ prior_comments=[
1955
+ "PO-11300 Amendment 1 (2024-12-02): Authorized up to 10% overship "
1956
+ "at the same unit price to ensure inventory buffer for Q1.",
1957
+ "Buyer confirmed acceptance of up to 110 boxes at $24.50/box.",
1958
+ "Warehouse confirmed receipt of full 110 boxes in good condition.",
1959
+ ],
1960
+ ),
1961
+ ground_truth=GroundTruth(
1962
+ correct_decision=DecisionType.approve_for_payment,
1963
+ correct_exception_type=ExceptionType.clean_match,
1964
+ acceptable_evidence=[
1965
+ "inspect_purchase_order",
1966
+ "inspect_goods_receipt_note",
1967
+ "compare_quantity",
1968
+ "inspect_policy_rules",
1969
+ ],
1970
+ key_findings=[
1971
+ "Billed 110 vs ordered 100 -- 10% overship",
1972
+ "PO Amendment 1 explicitly authorized up to 10% overship",
1973
+ "Buyer confirmed acceptance of 110 units at $24.50",
1974
+ "GRN confirms all 110 accepted in good condition",
1975
+ "Unit price matches PO exactly",
1976
+ "Overship is within the authorized range",
1977
+ ],
1978
+ ),
1979
+ )
1980
+
1981
+
1982
+ # -- Task 12: Corrected Invoice Resubmission --------------------------------
1983
+ # Difficulty: hard | Decision: approve_for_payment | Exception: clean_match
1984
+ # Invoice number INV-2024-0500-R1 looks like a duplicate of INV-2024-0500.
1985
+ # Case history shows the original was rejected for a $500 overcharge, and
1986
+ # this is the corrected resubmission at the correct PO price. All documents
1987
+ # now match. Trap: duplicate check flags the similar invoice number, and
1988
+ # agents reject as duplicate without reading the correction context.
1989
+
1990
+
1991
+ def _build_task_12() -> CaseData:
1992
+ return CaseData(
1993
+ case_id="CASE-12001",
1994
+ task_id=TaskID.task_12_corrected_resubmission,
1995
+ difficulty=Difficulty.hard,
1996
+ max_steps=12,
1997
+ invoice=Invoice(
1998
+ invoice_number="INV-2024-0500-R1",
1999
+ supplier_name="Westfield Electronics",
2000
+ supplier_id="SUP-12600",
2001
+ invoice_date="2024-12-20",
2002
+ po_reference="PO-12400",
2003
+ line_items=[
2004
+ InvoiceLineItem(
2005
+ item_code="ITEM-WE01",
2006
+ description="Industrial Ethernet Switch 24-Port PoE+",
2007
+ quantity_billed=5,
2008
+ unit_price_billed=1250.00,
2009
+ line_total_billed=6250.00,
2010
+ ),
2011
+ InvoiceLineItem(
2012
+ item_code="ITEM-WE02",
2013
+ description="SFP+ 10G Transceiver Module",
2014
+ quantity_billed=20,
2015
+ unit_price_billed=85.00,
2016
+ line_total_billed=1700.00,
2017
+ ),
2018
+ ],
2019
+ subtotal=7950.00,
2020
+ tax=636.00,
2021
+ total_amount=8586.00,
2022
+ note="Corrected invoice. Replaces INV-2024-0500 which was rejected for pricing error.",
2023
+ ),
2024
+ purchase_order=PurchaseOrder(
2025
+ po_number="PO-12400",
2026
+ supplier_id="SUP-12600",
2027
+ order_date="2024-12-01",
2028
+ line_items=[
2029
+ POLineItem(
2030
+ item_code="ITEM-WE01",
2031
+ description="Industrial Ethernet Switch 24-Port PoE+",
2032
+ ordered_quantity=5,
2033
+ unit_price_ordered=1250.00,
2034
+ line_total_ordered=6250.00,
2035
+ ),
2036
+ POLineItem(
2037
+ item_code="ITEM-WE02",
2038
+ description="SFP+ 10G Transceiver Module",
2039
+ ordered_quantity=20,
2040
+ unit_price_ordered=85.00,
2041
+ line_total_ordered=1700.00,
2042
+ ),
2043
+ ],
2044
+ ),
2045
+ goods_receipt_note=GoodsReceiptNote(
2046
+ grn_number="GRN-20100",
2047
+ po_reference="PO-12400",
2048
+ receipt_date="2024-12-10",
2049
+ line_items=[
2050
+ GRNLineItem(
2051
+ item_code="ITEM-WE01",
2052
+ quantity_received=5,
2053
+ accepted_quantity=5,
2054
+ ),
2055
+ GRNLineItem(
2056
+ item_code="ITEM-WE02",
2057
+ quantity_received=20,
2058
+ accepted_quantity=20,
2059
+ ),
2060
+ ],
2061
+ ),
2062
+ vendor_profile=VendorProfile(
2063
+ supplier_id="SUP-12600",
2064
+ supplier_name="Westfield Electronics",
2065
+ risk_tier="medium",
2066
+ duplicate_risk_count=1,
2067
+ preferred_vendor=True,
2068
+ ),
2069
+ company_policy=CompanyPolicy(
2070
+ quantity_tolerance_pct=5.0,
2071
+ price_tolerance_pct=5.0,
2072
+ total_tolerance_amt=200.0,
2073
+ duplicate_check_enabled=True,
2074
+ ),
2075
+ case_history=CaseHistory(
2076
+ prior_comments=[
2077
+ "INV-2024-0500 was rejected on 2024-12-15: switches billed at $1,350 "
2078
+ "instead of PO price $1,250. Vendor acknowledged error.",
2079
+ "Vendor issued corrected invoice INV-2024-0500-R1 with correct pricing.",
2080
+ "AP team confirmed: process INV-2024-0500-R1 as the replacement invoice.",
2081
+ ],
2082
+ processed_invoice_numbers=[],
2083
+ similar_invoice_refs=["INV-2024-0500"],
2084
+ ),
2085
+ ground_truth=GroundTruth(
2086
+ correct_decision=DecisionType.approve_for_payment,
2087
+ correct_exception_type=ExceptionType.clean_match,
2088
+ acceptable_evidence=[
2089
+ "inspect_purchase_order",
2090
+ "inspect_goods_receipt_note",
2091
+ "compare_price",
2092
+ "check_for_duplicate_invoice",
2093
+ "inspect_policy_rules",
2094
+ ],
2095
+ key_findings=[
2096
+ "INV-2024-0500-R1 is the corrected version of rejected INV-2024-0500",
2097
+ "Original invoice was rejected for $100/unit price overcharge on switches",
2098
+ "Corrected invoice has the correct PO price of $1,250 per switch",
2099
+ "All quantities and prices now match PO exactly",
2100
+ "AP team explicitly approved processing the replacement invoice",
2101
+ "Not a duplicate -- it is a correction of a rejected invoice",
2102
+ ],
2103
+ ),
2104
+ )
tasks/hard_definitions.py ADDED
@@ -0,0 +1,1109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Round 2 hard-mode task slice for InvoiceGuard.
3
+
4
+ These 10 tasks are designed for a *strong gap* baseline (~40-55%) so that
5
+ RL training improvement is visible. Each task targets one of the Round 2
6
+ themes: cross-case fraud, time-dependent policy, contradictory evidence,
7
+ partial observability, multi-party approvals.
8
+
9
+ All tasks reuse the existing CaseData entities so no schema changes are
10
+ required. Adversarial context is conveyed via:
11
+ - invoice.note
12
+ - po.payment_terms
13
+ - grn.warehouse_note
14
+ - vendor_profile fields
15
+ - case_history.prior_comments (surfaced by check_for_duplicate_invoice
16
+ when similar_invoice_refs is non-empty)
17
+ """
18
+
19
+ from typing import Dict
20
+
21
+ try:
22
+ from ..models import (
23
+ CaseData, CaseHistory, CompanyPolicy, DecisionType, Difficulty,
24
+ ExceptionType, GRNLineItem, GoodsReceiptNote, GroundTruth,
25
+ Invoice, InvoiceLineItem, POLineItem, PurchaseOrder,
26
+ TaskID, VendorProfile,
27
+ )
28
+ except ImportError:
29
+ from models import (
30
+ CaseData, CaseHistory, CompanyPolicy, DecisionType, Difficulty,
31
+ ExceptionType, GRNLineItem, GoodsReceiptNote, GroundTruth,
32
+ Invoice, InvoiceLineItem, POLineItem, PurchaseOrder,
33
+ TaskID, VendorProfile,
34
+ )
35
+
36
+
37
+ # Standardized step budget for hard-mode tasks. Slightly tighter than
38
+ # canonical hard tasks (12) to reward efficient investigation.
39
+ HARD_MAX_STEPS = 12
40
+
41
+
42
+ # -- H1: Phantom GRN from a prior delivery cycle ----------------------------
43
+ # Decision: place_on_hold | Exception: missing_receipt
44
+ # The GRN attached to this PO is from an OLD shipment cycle (months earlier).
45
+ # Vendor reuses old GRN numbers. compare_quantity will report all numbers
46
+ # matching, but no goods were actually received for THIS invoice cycle.
47
+ # Trap: agent runs compare_quantity, sees 100=100=100, and approves.
48
+
49
+ def _build_h1() -> CaseData:
50
+ return CaseData(
51
+ case_id="CASE-H1-001",
52
+ task_id=TaskID.task_h1_phantom_grn_period,
53
+ difficulty=Difficulty.hard,
54
+ max_steps=HARD_MAX_STEPS,
55
+ invoice=Invoice(
56
+ invoice_number="INV-2024-9101",
57
+ supplier_name="Helix Industrial Supply",
58
+ supplier_id="SUP-H101",
59
+ invoice_date="2024-12-22",
60
+ po_reference="PO-H101-DEC",
61
+ line_items=[
62
+ InvoiceLineItem(
63
+ item_code="ITEM-HX01",
64
+ description="Industrial Lubricant 20L Drum",
65
+ quantity_billed=100,
66
+ unit_price_billed=180.00,
67
+ line_total_billed=18000.00,
68
+ ),
69
+ ],
70
+ subtotal=18000.00,
71
+ tax=1440.00,
72
+ total_amount=19440.00,
73
+ note="Q4 restocking shipment. Please process per contract.",
74
+ ),
75
+ purchase_order=PurchaseOrder(
76
+ po_number="PO-H101-DEC",
77
+ supplier_id="SUP-H101",
78
+ order_date="2024-12-01",
79
+ line_items=[
80
+ POLineItem(
81
+ item_code="ITEM-HX01",
82
+ description="Industrial Lubricant 20L Drum",
83
+ ordered_quantity=100,
84
+ unit_price_ordered=180.00,
85
+ line_total_ordered=18000.00,
86
+ ),
87
+ ],
88
+ ),
89
+ goods_receipt_note=GoodsReceiptNote(
90
+ grn_number="GRN-H101-JUN",
91
+ po_reference="PO-H101-DEC",
92
+ receipt_date="2024-06-15",
93
+ line_items=[
94
+ GRNLineItem(
95
+ item_code="ITEM-HX01",
96
+ quantity_received=100,
97
+ accepted_quantity=100,
98
+ ),
99
+ ],
100
+ warehouse_note=(
101
+ "WAREHOUSE FLAG: GRN-H101-JUN dated 2024-06-15 belongs to "
102
+ "prior shipment cycle (PO-H101-JUN, already invoiced and paid). "
103
+ "No fresh delivery has been logged against PO-H101-DEC. "
104
+ "Linkage to PO-H101-DEC appears to be a clerical error by vendor."
105
+ ),
106
+ ),
107
+ vendor_profile=VendorProfile(
108
+ supplier_id="SUP-H101",
109
+ supplier_name="Helix Industrial Supply",
110
+ risk_tier="medium",
111
+ duplicate_risk_count=2,
112
+ preferred_vendor=False,
113
+ escalation_threshold=15000.00,
114
+ ),
115
+ company_policy=CompanyPolicy(
116
+ quantity_tolerance_pct=5.0,
117
+ price_tolerance_pct=5.0,
118
+ total_tolerance_amt=200.0,
119
+ ),
120
+ case_history=CaseHistory(
121
+ prior_comments=[
122
+ "PO-H101-JUN was fully received (GRN-H101-JUN) and paid in July 2024.",
123
+ "Vendor has previously attached old GRN numbers to new POs to "
124
+ "accelerate payment before goods arrive.",
125
+ ],
126
+ similar_invoice_refs=["INV-2024-4500"],
127
+ processed_invoice_numbers=["INV-2024-4500"],
128
+ ),
129
+ ground_truth=GroundTruth(
130
+ correct_decision=DecisionType.place_on_hold,
131
+ correct_exception_type=ExceptionType.missing_receipt,
132
+ acceptable_evidence=[
133
+ "inspect_purchase_order",
134
+ "inspect_goods_receipt_note",
135
+ "check_for_duplicate_invoice",
136
+ "inspect_vendor_profile",
137
+ ],
138
+ key_findings=[
139
+ "GRN attached is dated 2024-06-15, predating PO-H101-DEC by 6 months",
140
+ "GRN-H101-JUN was already consumed by prior invoice INV-2024-4500",
141
+ "No fresh receipt exists for the December order",
142
+ "Cannot pay before goods are actually received",
143
+ ],
144
+ ),
145
+ )
146
+
147
+
148
+ # -- H2: Kickback / Inflated PO --------------------------------------------
149
+ # Decision: escalate_for_supervisor_review | Exception: policy_violation
150
+ # Invoice price = PO price exactly. Quantities match. But case history reveals
151
+ # internal audit found PO price ~30% above market and buyer recused. The
152
+ # correct call is escalate, not approve.
153
+
154
+ def _build_h2() -> CaseData:
155
+ return CaseData(
156
+ case_id="CASE-H2-001",
157
+ task_id=TaskID.task_h2_kickback_inflated_po,
158
+ difficulty=Difficulty.hard,
159
+ max_steps=HARD_MAX_STEPS,
160
+ invoice=Invoice(
161
+ invoice_number="INV-2024-9202",
162
+ supplier_name="Crestwave Engineering Pvt Ltd",
163
+ supplier_id="SUP-H202",
164
+ invoice_date="2024-12-19",
165
+ po_reference="PO-H202",
166
+ line_items=[
167
+ InvoiceLineItem(
168
+ item_code="ITEM-CW20",
169
+ description="Custom Aluminum Bracket Type-7",
170
+ quantity_billed=400,
171
+ unit_price_billed=85.00,
172
+ line_total_billed=34000.00,
173
+ ),
174
+ ],
175
+ subtotal=34000.00,
176
+ tax=2720.00,
177
+ total_amount=36720.00,
178
+ ),
179
+ purchase_order=PurchaseOrder(
180
+ po_number="PO-H202",
181
+ supplier_id="SUP-H202",
182
+ order_date="2024-11-10",
183
+ line_items=[
184
+ POLineItem(
185
+ item_code="ITEM-CW20",
186
+ description="Custom Aluminum Bracket Type-7",
187
+ ordered_quantity=400,
188
+ unit_price_ordered=85.00,
189
+ line_total_ordered=34000.00,
190
+ ),
191
+ ],
192
+ ),
193
+ goods_receipt_note=GoodsReceiptNote(
194
+ grn_number="GRN-H202",
195
+ po_reference="PO-H202",
196
+ receipt_date="2024-12-12",
197
+ line_items=[
198
+ GRNLineItem(
199
+ item_code="ITEM-CW20",
200
+ quantity_received=400,
201
+ accepted_quantity=400,
202
+ ),
203
+ ],
204
+ warehouse_note="All units received and inspected.",
205
+ ),
206
+ vendor_profile=VendorProfile(
207
+ supplier_id="SUP-H202",
208
+ supplier_name="Crestwave Engineering Pvt Ltd",
209
+ risk_tier="medium",
210
+ duplicate_risk_count=0,
211
+ preferred_vendor=False,
212
+ escalation_threshold=30000.00,
213
+ ),
214
+ company_policy=CompanyPolicy(
215
+ quantity_tolerance_pct=5.0,
216
+ price_tolerance_pct=5.0,
217
+ total_tolerance_amt=300.0,
218
+ ),
219
+ case_history=CaseHistory(
220
+ prior_comments=[
221
+ "AUDIT FLAG (2024-12-15): PO-H202 unit price $85 is approx 30% "
222
+ "above benchmarked market price of $65 for ITEM-CW20.",
223
+ "Buyer who approved PO-H202 has been recused pending integrity review.",
224
+ "Per policy, all invoices against PO-H202 require supervisor review "
225
+ "before payment release.",
226
+ ],
227
+ similar_invoice_refs=["AUDIT-PO-H202"],
228
+ processed_invoice_numbers=[],
229
+ ),
230
+ ground_truth=GroundTruth(
231
+ correct_decision=DecisionType.escalate_for_supervisor_review,
232
+ correct_exception_type=ExceptionType.policy_violation,
233
+ acceptable_evidence=[
234
+ "inspect_purchase_order",
235
+ "check_for_duplicate_invoice",
236
+ "inspect_vendor_profile",
237
+ "inspect_policy_rules",
238
+ ],
239
+ key_findings=[
240
+ "Invoice and PO numerically match, but PO itself is under audit",
241
+ "PO unit price is ~30% above market benchmark",
242
+ "Approving buyer recused; mandatory supervisor review applies",
243
+ "Escalation required regardless of line-level match",
244
+ ],
245
+ ),
246
+ )
247
+
248
+
249
+ # -- H3: Currency Silent Swap ---------------------------------------------
250
+ # Decision: escalate_for_supervisor_review | Exception: total_amount_mismatch
251
+ # Invoice currency is EUR while PO and historical billing are USD. Numbers
252
+ # match by digit, but the invoiced amount in USD-equivalent is materially
253
+ # different. Trap: compare_price reports a numerical match.
254
+
255
+ def _build_h3() -> CaseData:
256
+ return CaseData(
257
+ case_id="CASE-H3-001",
258
+ task_id=TaskID.task_h3_currency_silent_swap,
259
+ difficulty=Difficulty.hard,
260
+ max_steps=HARD_MAX_STEPS,
261
+ invoice=Invoice(
262
+ invoice_number="INV-2024-9303",
263
+ supplier_name="Northbridge Components GmbH",
264
+ supplier_id="SUP-H303",
265
+ invoice_date="2024-12-17",
266
+ currency="EUR",
267
+ po_reference="PO-H303",
268
+ line_items=[
269
+ InvoiceLineItem(
270
+ item_code="ITEM-NB15",
271
+ description="Precision Linear Actuator 200mm",
272
+ quantity_billed=80,
273
+ unit_price_billed=125.00,
274
+ line_total_billed=10000.00,
275
+ ),
276
+ ],
277
+ subtotal=10000.00,
278
+ tax=800.00,
279
+ total_amount=10800.00,
280
+ note="Standard monthly invoice. EUR billing per updated contract.",
281
+ ),
282
+ purchase_order=PurchaseOrder(
283
+ po_number="PO-H303",
284
+ supplier_id="SUP-H303",
285
+ order_date="2024-11-20",
286
+ line_items=[
287
+ POLineItem(
288
+ item_code="ITEM-NB15",
289
+ description="Precision Linear Actuator 200mm",
290
+ ordered_quantity=80,
291
+ unit_price_ordered=125.00,
292
+ line_total_ordered=10000.00,
293
+ ),
294
+ ],
295
+ payment_terms="Net 30 USD",
296
+ ),
297
+ goods_receipt_note=GoodsReceiptNote(
298
+ grn_number="GRN-H303",
299
+ po_reference="PO-H303",
300
+ receipt_date="2024-12-10",
301
+ line_items=[
302
+ GRNLineItem(
303
+ item_code="ITEM-NB15",
304
+ quantity_received=80,
305
+ accepted_quantity=80,
306
+ ),
307
+ ],
308
+ warehouse_note="All units received in good condition.",
309
+ ),
310
+ vendor_profile=VendorProfile(
311
+ supplier_id="SUP-H303",
312
+ supplier_name="Northbridge Components GmbH",
313
+ risk_tier="medium",
314
+ preferred_vendor=True,
315
+ escalation_threshold=12000.00,
316
+ ),
317
+ company_policy=CompanyPolicy(
318
+ quantity_tolerance_pct=5.0,
319
+ price_tolerance_pct=5.0,
320
+ total_tolerance_amt=200.0,
321
+ ),
322
+ case_history=CaseHistory(
323
+ prior_comments=[
324
+ "Vendor SUP-H303 has historically invoiced in USD against this PO family.",
325
+ "No contract amendment authorizing EUR billing is on file.",
326
+ "Current EUR-to-USD rate would make invoiced amount ~10% higher than PO USD value.",
327
+ ],
328
+ similar_invoice_refs=["INV-2024-7800"],
329
+ processed_invoice_numbers=["INV-2024-7800"],
330
+ ),
331
+ ground_truth=GroundTruth(
332
+ correct_decision=DecisionType.escalate_for_supervisor_review,
333
+ correct_exception_type=ExceptionType.total_amount_mismatch,
334
+ acceptable_evidence=[
335
+ "inspect_invoice_line_items",
336
+ "inspect_purchase_order",
337
+ "check_for_duplicate_invoice",
338
+ "compare_totals",
339
+ ],
340
+ key_findings=[
341
+ "Invoice currency is EUR but PO is USD (no contract amendment)",
342
+ "Numerical line totals match but currency differs",
343
+ "USD-equivalent of EUR invoice is materially above PO total",
344
+ "Currency change requires supervisor approval per contract",
345
+ ],
346
+ ),
347
+ )
348
+
349
+
350
+ # -- H4: Threshold Dance ---------------------------------------------------
351
+ # Decision: escalate_for_supervisor_review | Exception: policy_violation
352
+ # Invoice $49,950 sits just under $50k auto-approval threshold. Vendor has
353
+ # 3 prior invoices in the same window all $49,9xx. Smaller cousin of task_8
354
+ # but with a tighter margin so the pattern is harder to spot.
355
+
356
+ def _build_h4() -> CaseData:
357
+ return CaseData(
358
+ case_id="CASE-H4-001",
359
+ task_id=TaskID.task_h4_threshold_dance,
360
+ difficulty=Difficulty.hard,
361
+ max_steps=HARD_MAX_STEPS,
362
+ invoice=Invoice(
363
+ invoice_number="INV-2024-9404",
364
+ supplier_name="Vector Distribution Co",
365
+ supplier_id="SUP-H404",
366
+ invoice_date="2024-12-21",
367
+ po_reference="PO-H404-D",
368
+ line_items=[
369
+ InvoiceLineItem(
370
+ item_code="ITEM-VD30",
371
+ description="Bulk Packaging Material Lot",
372
+ quantity_billed=1,
373
+ unit_price_billed=49950.00,
374
+ line_total_billed=49950.00,
375
+ ),
376
+ ],
377
+ subtotal=49950.00,
378
+ tax=3996.00,
379
+ total_amount=53946.00,
380
+ note="Final delivery for Q4 packaging contract.",
381
+ ),
382
+ purchase_order=PurchaseOrder(
383
+ po_number="PO-H404-D",
384
+ supplier_id="SUP-H404",
385
+ order_date="2024-12-05",
386
+ line_items=[
387
+ POLineItem(
388
+ item_code="ITEM-VD30",
389
+ description="Bulk Packaging Material Lot",
390
+ ordered_quantity=1,
391
+ unit_price_ordered=49950.00,
392
+ line_total_ordered=49950.00,
393
+ ),
394
+ ],
395
+ ),
396
+ goods_receipt_note=GoodsReceiptNote(
397
+ grn_number="GRN-H404-D",
398
+ po_reference="PO-H404-D",
399
+ receipt_date="2024-12-18",
400
+ line_items=[
401
+ GRNLineItem(
402
+ item_code="ITEM-VD30",
403
+ quantity_received=1,
404
+ accepted_quantity=1,
405
+ ),
406
+ ],
407
+ warehouse_note="Full lot received.",
408
+ ),
409
+ vendor_profile=VendorProfile(
410
+ supplier_id="SUP-H404",
411
+ supplier_name="Vector Distribution Co",
412
+ risk_tier="high",
413
+ duplicate_risk_count=3,
414
+ preferred_vendor=False,
415
+ escalation_threshold=40000.00,
416
+ ),
417
+ company_policy=CompanyPolicy(
418
+ quantity_tolerance_pct=5.0,
419
+ price_tolerance_pct=5.0,
420
+ total_tolerance_amt=300.0,
421
+ high_value_threshold=50000.0,
422
+ mandatory_escalation_above=100000.0,
423
+ ),
424
+ case_history=CaseHistory(
425
+ prior_comments=[
426
+ "ALERT: SUP-H404 submitted 3 invoices in past 14 days at "
427
+ "$49,920 / $49,975 / $49,950 -- each just under $50k threshold.",
428
+ "Combined invoiced value across 4 invoices: ~$199,795.",
429
+ "PO numbering pattern (PO-H404-A/B/C/D) suggests deliberate split.",
430
+ "Finance integrity team flagged for review.",
431
+ ],
432
+ similar_invoice_refs=["INV-2024-9401", "INV-2024-9402", "INV-2024-9403"],
433
+ processed_invoice_numbers=["INV-2024-9401", "INV-2024-9402", "INV-2024-9403"],
434
+ ),
435
+ ground_truth=GroundTruth(
436
+ correct_decision=DecisionType.escalate_for_supervisor_review,
437
+ correct_exception_type=ExceptionType.policy_violation,
438
+ acceptable_evidence=[
439
+ "inspect_purchase_order",
440
+ "check_for_duplicate_invoice",
441
+ "inspect_vendor_profile",
442
+ "inspect_policy_rules",
443
+ ],
444
+ key_findings=[
445
+ "Invoice $49,950 sits $50 under auto-approval threshold",
446
+ "3 prior invoices from same vendor in past 14 days, all just under $50k",
447
+ "Combined value ~$199,795 well above mandatory escalation",
448
+ "High-risk vendor with 3 prior incidents",
449
+ "Pattern consistent with deliberate threshold evasion",
450
+ ],
451
+ ),
452
+ )
453
+
454
+
455
+ # -- H5: Retroactive PO Amendment ------------------------------------------
456
+ # Decision: escalate_for_supervisor_review | Exception: price_mismatch
457
+ # PO line items show $100/unit. Invoice billed $100/unit. compare_price
458
+ # reports a perfect match. But case_history reveals an amendment that
459
+ # retroactively reduced agreed price to $80/unit.
460
+
461
+ def _build_h5() -> CaseData:
462
+ return CaseData(
463
+ case_id="CASE-H5-001",
464
+ task_id=TaskID.task_h5_retroactive_amendment,
465
+ difficulty=Difficulty.hard,
466
+ max_steps=HARD_MAX_STEPS,
467
+ invoice=Invoice(
468
+ invoice_number="INV-2024-9505",
469
+ supplier_name="Solace Pharma Distribution",
470
+ supplier_id="SUP-H505",
471
+ invoice_date="2024-12-20",
472
+ po_reference="PO-H505",
473
+ line_items=[
474
+ InvoiceLineItem(
475
+ item_code="ITEM-SP40",
476
+ description="Diagnostic Reagent Kit Pro",
477
+ quantity_billed=300,
478
+ unit_price_billed=100.00,
479
+ line_total_billed=30000.00,
480
+ ),
481
+ ],
482
+ subtotal=30000.00,
483
+ tax=2400.00,
484
+ total_amount=32400.00,
485
+ ),
486
+ purchase_order=PurchaseOrder(
487
+ po_number="PO-H505",
488
+ supplier_id="SUP-H505",
489
+ order_date="2024-11-05",
490
+ line_items=[
491
+ POLineItem(
492
+ item_code="ITEM-SP40",
493
+ description="Diagnostic Reagent Kit Pro",
494
+ ordered_quantity=300,
495
+ unit_price_ordered=100.00,
496
+ line_total_ordered=30000.00,
497
+ ),
498
+ ],
499
+ ),
500
+ goods_receipt_note=GoodsReceiptNote(
501
+ grn_number="GRN-H505",
502
+ po_reference="PO-H505",
503
+ receipt_date="2024-12-12",
504
+ line_items=[
505
+ GRNLineItem(
506
+ item_code="ITEM-SP40",
507
+ quantity_received=300,
508
+ accepted_quantity=300,
509
+ ),
510
+ ],
511
+ ),
512
+ vendor_profile=VendorProfile(
513
+ supplier_id="SUP-H505",
514
+ supplier_name="Solace Pharma Distribution",
515
+ risk_tier="medium",
516
+ preferred_vendor=True,
517
+ escalation_threshold=25000.00,
518
+ ),
519
+ company_policy=CompanyPolicy(
520
+ quantity_tolerance_pct=5.0,
521
+ price_tolerance_pct=5.0,
522
+ total_tolerance_amt=300.0,
523
+ ),
524
+ case_history=CaseHistory(
525
+ prior_comments=[
526
+ "PO-H505 Amendment 1 (2024-12-08): unit price retroactively reduced "
527
+ "from $100 to $80 per kit as part of annual rebate true-up.",
528
+ "Amendment is binding for all invoices dated after 2024-12-08.",
529
+ "Vendor was notified in writing on 2024-12-09 and acknowledged.",
530
+ "Effective PO total is now $24,000, not $30,000.",
531
+ ],
532
+ similar_invoice_refs=["AMENDMENT-PO-H505"],
533
+ processed_invoice_numbers=[],
534
+ ),
535
+ ground_truth=GroundTruth(
536
+ correct_decision=DecisionType.escalate_for_supervisor_review,
537
+ correct_exception_type=ExceptionType.price_mismatch,
538
+ acceptable_evidence=[
539
+ "inspect_purchase_order",
540
+ "check_for_duplicate_invoice",
541
+ "compare_price",
542
+ "inspect_policy_rules",
543
+ ],
544
+ key_findings=[
545
+ "PO line items show $100, invoice shows $100 -- looks aligned",
546
+ "PO Amendment 1 retroactively set agreed price to $80",
547
+ "Effective overcharge: $20 x 300 = $6,000",
548
+ "Supervisor must reconcile invoice against amended PO",
549
+ ],
550
+ ),
551
+ )
552
+
553
+
554
+ # -- H6: Returned-Then-Rebilled --------------------------------------------
555
+ # Decision: place_on_hold | Exception: quantity_mismatch
556
+ # GRN shows received=100, accepted=100. compare_quantity reports OK.
557
+ # But warehouse_note + case_history record that 30 units were returned
558
+ # post-receipt as defective. Effective net accepted is 70.
559
+
560
+ def _build_h6() -> CaseData:
561
+ return CaseData(
562
+ case_id="CASE-H6-001",
563
+ task_id=TaskID.task_h6_returned_then_rebilled,
564
+ difficulty=Difficulty.hard,
565
+ max_steps=HARD_MAX_STEPS,
566
+ invoice=Invoice(
567
+ invoice_number="INV-2024-9606",
568
+ supplier_name="Ironclad Hardware Co",
569
+ supplier_id="SUP-H606",
570
+ invoice_date="2024-12-22",
571
+ po_reference="PO-H606",
572
+ line_items=[
573
+ InvoiceLineItem(
574
+ item_code="ITEM-IH50",
575
+ description="Heavy-Duty Steel Coupling Set",
576
+ quantity_billed=100,
577
+ unit_price_billed=220.00,
578
+ line_total_billed=22000.00,
579
+ ),
580
+ ],
581
+ subtotal=22000.00,
582
+ tax=1760.00,
583
+ total_amount=23760.00,
584
+ note="Full shipment billing. Per delivery receipt.",
585
+ ),
586
+ purchase_order=PurchaseOrder(
587
+ po_number="PO-H606",
588
+ supplier_id="SUP-H606",
589
+ order_date="2024-11-15",
590
+ line_items=[
591
+ POLineItem(
592
+ item_code="ITEM-IH50",
593
+ description="Heavy-Duty Steel Coupling Set",
594
+ ordered_quantity=100,
595
+ unit_price_ordered=220.00,
596
+ line_total_ordered=22000.00,
597
+ ),
598
+ ],
599
+ ),
600
+ goods_receipt_note=GoodsReceiptNote(
601
+ grn_number="GRN-H606",
602
+ po_reference="PO-H606",
603
+ receipt_date="2024-12-05",
604
+ line_items=[
605
+ GRNLineItem(
606
+ item_code="ITEM-IH50",
607
+ quantity_received=100,
608
+ accepted_quantity=100,
609
+ ),
610
+ ],
611
+ warehouse_note=(
612
+ "POST-RECEIPT NOTE (2024-12-15): 30 units returned to vendor "
613
+ "due to QC failure (cracked welds). Net accepted in inventory: 70. "
614
+ "Return Material Authorization RMA-H606-01 issued."
615
+ ),
616
+ ),
617
+ vendor_profile=VendorProfile(
618
+ supplier_id="SUP-H606",
619
+ supplier_name="Ironclad Hardware Co",
620
+ risk_tier="medium",
621
+ preferred_vendor=True,
622
+ escalation_threshold=20000.00,
623
+ ),
624
+ company_policy=CompanyPolicy(
625
+ quantity_tolerance_pct=5.0,
626
+ price_tolerance_pct=5.0,
627
+ total_tolerance_amt=200.0,
628
+ ),
629
+ case_history=CaseHistory(
630
+ prior_comments=[
631
+ "RMA-H606-01 (2024-12-15): 30 units of ITEM-IH50 returned for QC failure.",
632
+ "Vendor acknowledged return and issued credit memo CM-H606-01.",
633
+ "Net deliverable for this PO: 70 units at $220 = $15,400.",
634
+ "Vendor invoiced full 100 units, ignoring credit memo.",
635
+ ],
636
+ similar_invoice_refs=["RMA-H606-01"],
637
+ processed_invoice_numbers=[],
638
+ ),
639
+ ground_truth=GroundTruth(
640
+ correct_decision=DecisionType.place_on_hold,
641
+ correct_exception_type=ExceptionType.quantity_mismatch,
642
+ acceptable_evidence=[
643
+ "inspect_goods_receipt_note",
644
+ "check_for_duplicate_invoice",
645
+ "compare_quantity",
646
+ ],
647
+ key_findings=[
648
+ "GRN shows 100 accepted at receipt, but 30 were returned post-receipt",
649
+ "Net inventory accepted is 70 units, not 100",
650
+ "Vendor issued credit memo CM-H606-01 acknowledging return",
651
+ "Invoice bills 100 units, ignoring the credit memo",
652
+ "Hold required until vendor reissues with credit applied",
653
+ ],
654
+ ),
655
+ )
656
+
657
+
658
+ # -- H7: Multi-Party Approval Pending --------------------------------------
659
+ # Decision: escalate_for_supervisor_review | Exception: policy_violation
660
+ # Invoice $85,000. Policy: high_value_threshold=50k requires supervisor;
661
+ # mandatory_escalation_above=80k requires CFO co-sign. CFO sign-off not on
662
+ # file. All line-level matches are clean. Trap: agent approves because
663
+ # everything matches.
664
+
665
+ def _build_h7() -> CaseData:
666
+ return CaseData(
667
+ case_id="CASE-H7-001",
668
+ task_id=TaskID.task_h7_multi_party_pending,
669
+ difficulty=Difficulty.hard,
670
+ max_steps=HARD_MAX_STEPS,
671
+ invoice=Invoice(
672
+ invoice_number="INV-2024-9707",
673
+ supplier_name="Skyline Capital Equipment",
674
+ supplier_id="SUP-H707",
675
+ invoice_date="2024-12-21",
676
+ po_reference="PO-H707",
677
+ line_items=[
678
+ InvoiceLineItem(
679
+ item_code="ITEM-SK60",
680
+ description="Industrial Compressor Unit Model X",
681
+ quantity_billed=1,
682
+ unit_price_billed=85000.00,
683
+ line_total_billed=85000.00,
684
+ ),
685
+ ],
686
+ subtotal=85000.00,
687
+ tax=6800.00,
688
+ total_amount=91800.00,
689
+ ),
690
+ purchase_order=PurchaseOrder(
691
+ po_number="PO-H707",
692
+ supplier_id="SUP-H707",
693
+ order_date="2024-11-25",
694
+ line_items=[
695
+ POLineItem(
696
+ item_code="ITEM-SK60",
697
+ description="Industrial Compressor Unit Model X",
698
+ ordered_quantity=1,
699
+ unit_price_ordered=85000.00,
700
+ line_total_ordered=85000.00,
701
+ ),
702
+ ],
703
+ payment_terms="Net 45",
704
+ ),
705
+ goods_receipt_note=GoodsReceiptNote(
706
+ grn_number="GRN-H707",
707
+ po_reference="PO-H707",
708
+ receipt_date="2024-12-15",
709
+ line_items=[
710
+ GRNLineItem(
711
+ item_code="ITEM-SK60",
712
+ quantity_received=1,
713
+ accepted_quantity=1,
714
+ ),
715
+ ],
716
+ warehouse_note="Unit installed and commissioned successfully.",
717
+ ),
718
+ vendor_profile=VendorProfile(
719
+ supplier_id="SUP-H707",
720
+ supplier_name="Skyline Capital Equipment",
721
+ risk_tier="low",
722
+ preferred_vendor=True,
723
+ escalation_threshold=70000.00,
724
+ ),
725
+ company_policy=CompanyPolicy(
726
+ quantity_tolerance_pct=5.0,
727
+ price_tolerance_pct=5.0,
728
+ total_tolerance_amt=500.0,
729
+ high_value_threshold=50000.0,
730
+ mandatory_escalation_above=80000.0,
731
+ ),
732
+ case_history=CaseHistory(
733
+ prior_comments=[
734
+ "Capex policy: invoices >= $80,000 require BOTH supervisor and CFO sign-off.",
735
+ "PO-H707 has supervisor sign-off (S. Patel, 2024-11-25).",
736
+ "CFO sign-off NOT on file; capex committee meets 2024-12-28.",
737
+ "Payment must wait for CFO approval per controls policy.",
738
+ ],
739
+ similar_invoice_refs=["CAPEX-POLICY-2024"],
740
+ processed_invoice_numbers=[],
741
+ ),
742
+ ground_truth=GroundTruth(
743
+ correct_decision=DecisionType.escalate_for_supervisor_review,
744
+ correct_exception_type=ExceptionType.policy_violation,
745
+ acceptable_evidence=[
746
+ "inspect_purchase_order",
747
+ "inspect_policy_rules",
748
+ "check_for_duplicate_invoice",
749
+ "inspect_vendor_profile",
750
+ ],
751
+ key_findings=[
752
+ "Invoice total $91,800 exceeds mandatory escalation threshold $80,000",
753
+ "Capex policy requires both supervisor AND CFO sign-off above $80k",
754
+ "Only supervisor sign-off currently on file",
755
+ "Payment cannot be released until CFO approval logged",
756
+ ],
757
+ ),
758
+ )
759
+
760
+
761
+ # -- H8: Rush Premium Authorized -------------------------------------------
762
+ # Decision: approve_for_payment | Exception: clean_match
763
+ # Invoice price 8% over PO -- normally a price_mismatch escalation.
764
+ # But invoice.note + warehouse_note + case_history all confirm a buyer-
765
+ # approved 8% rush surcharge per a PO amendment. Trap: compare_price flags
766
+ # discrepancy and agent escalates.
767
+
768
+ def _build_h8() -> CaseData:
769
+ return CaseData(
770
+ case_id="CASE-H8-001",
771
+ task_id=TaskID.task_h8_rush_premium_authorized,
772
+ difficulty=Difficulty.hard,
773
+ max_steps=HARD_MAX_STEPS,
774
+ invoice=Invoice(
775
+ invoice_number="INV-2024-9808",
776
+ supplier_name="Falcon Express Components",
777
+ supplier_id="SUP-H808",
778
+ invoice_date="2024-12-20",
779
+ po_reference="PO-H808",
780
+ line_items=[
781
+ InvoiceLineItem(
782
+ item_code="ITEM-FE70",
783
+ description="Aviation-Grade Fastener Pack",
784
+ quantity_billed=200,
785
+ unit_price_billed=54.00,
786
+ line_total_billed=10800.00,
787
+ ),
788
+ ],
789
+ subtotal=10800.00,
790
+ tax=864.00,
791
+ total_amount=11664.00,
792
+ note=(
793
+ "Includes 8% rush surcharge per PO-H808 Amendment 1 dated 2024-12-05 "
794
+ "(buyer-approved expedited shipping). Base price unchanged at $50.00."
795
+ ),
796
+ ),
797
+ purchase_order=PurchaseOrder(
798
+ po_number="PO-H808",
799
+ supplier_id="SUP-H808",
800
+ order_date="2024-12-01",
801
+ line_items=[
802
+ POLineItem(
803
+ item_code="ITEM-FE70",
804
+ description="Aviation-Grade Fastener Pack",
805
+ ordered_quantity=200,
806
+ unit_price_ordered=50.00,
807
+ line_total_ordered=10000.00,
808
+ ),
809
+ ],
810
+ payment_terms="Net 30",
811
+ ),
812
+ goods_receipt_note=GoodsReceiptNote(
813
+ grn_number="GRN-H808",
814
+ po_reference="PO-H808",
815
+ receipt_date="2024-12-12",
816
+ line_items=[
817
+ GRNLineItem(
818
+ item_code="ITEM-FE70",
819
+ quantity_received=200,
820
+ accepted_quantity=200,
821
+ ),
822
+ ],
823
+ warehouse_note=(
824
+ "Expedited delivery received per buyer's rush request. "
825
+ "8% surcharge authorized in PO-H808 Amendment 1."
826
+ ),
827
+ ),
828
+ vendor_profile=VendorProfile(
829
+ supplier_id="SUP-H808",
830
+ supplier_name="Falcon Express Components",
831
+ risk_tier="low",
832
+ preferred_vendor=True,
833
+ ),
834
+ company_policy=CompanyPolicy(
835
+ quantity_tolerance_pct=5.0,
836
+ price_tolerance_pct=5.0,
837
+ total_tolerance_amt=200.0,
838
+ ),
839
+ case_history=CaseHistory(
840
+ prior_comments=[
841
+ "PO-H808 Amendment 1 (2024-12-05): buyer authorized up to 10% rush "
842
+ "surcharge for expedited delivery on this order.",
843
+ "Expedited shipping was specifically requested due to production line outage.",
844
+ "Surcharge of 8% is within authorized range.",
845
+ ],
846
+ similar_invoice_refs=["PO-H808-AMD1"],
847
+ processed_invoice_numbers=[],
848
+ ),
849
+ ground_truth=GroundTruth(
850
+ correct_decision=DecisionType.approve_for_payment,
851
+ correct_exception_type=ExceptionType.clean_match,
852
+ acceptable_evidence=[
853
+ "inspect_invoice_line_items",
854
+ "inspect_purchase_order",
855
+ "inspect_goods_receipt_note",
856
+ "check_for_duplicate_invoice",
857
+ ],
858
+ key_findings=[
859
+ "Unit price $54 vs base PO $50 = 8% above PO line price",
860
+ "PO Amendment 1 explicitly authorized up to 10% rush surcharge",
861
+ "Surcharge applied is within authorized range",
862
+ "Quantities and delivery match exactly",
863
+ "Surface price variance is contractually authorized",
864
+ ],
865
+ ),
866
+ )
867
+
868
+
869
+ # -- H9: Supplier ID Mismatch ----------------------------------------------
870
+ # Decision: reject_invoice | Exception: policy_violation
871
+ # Invoice supplier_id is similar but NOT identical to PO supplier_id.
872
+ # Vendor profile (looked up by invoice's id) shows it's a different entity
873
+ # with high-risk fraud history. Names look similar -- a phishing-style
874
+ # attempt to redirect payment.
875
+
876
+ def _build_h9() -> CaseData:
877
+ return CaseData(
878
+ case_id="CASE-H9-001",
879
+ task_id=TaskID.task_h9_supplier_id_mismatch,
880
+ difficulty=Difficulty.hard,
881
+ max_steps=HARD_MAX_STEPS,
882
+ invoice=Invoice(
883
+ invoice_number="INV-2024-9909",
884
+ supplier_name="Acme Industrial Solutions",
885
+ supplier_id="SUP-H909-X",
886
+ invoice_date="2024-12-22",
887
+ po_reference="PO-H909",
888
+ line_items=[
889
+ InvoiceLineItem(
890
+ item_code="ITEM-AI80",
891
+ description="Industrial Pump Assembly",
892
+ quantity_billed=10,
893
+ unit_price_billed=2200.00,
894
+ line_total_billed=22000.00,
895
+ ),
896
+ ],
897
+ subtotal=22000.00,
898
+ tax=1760.00,
899
+ total_amount=23760.00,
900
+ note="Please remit to updated bank account. Supplier ID corrected per recent re-registration.",
901
+ ),
902
+ purchase_order=PurchaseOrder(
903
+ po_number="PO-H909",
904
+ supplier_id="SUP-H909",
905
+ order_date="2024-11-20",
906
+ line_items=[
907
+ POLineItem(
908
+ item_code="ITEM-AI80",
909
+ description="Industrial Pump Assembly",
910
+ ordered_quantity=10,
911
+ unit_price_ordered=2200.00,
912
+ line_total_ordered=22000.00,
913
+ ),
914
+ ],
915
+ ),
916
+ goods_receipt_note=GoodsReceiptNote(
917
+ grn_number="GRN-H909",
918
+ po_reference="PO-H909",
919
+ receipt_date="2024-12-12",
920
+ line_items=[
921
+ GRNLineItem(
922
+ item_code="ITEM-AI80",
923
+ quantity_received=10,
924
+ accepted_quantity=10,
925
+ ),
926
+ ],
927
+ warehouse_note="Goods received from original vendor (SUP-H909).",
928
+ ),
929
+ vendor_profile=VendorProfile(
930
+ supplier_id="SUP-H909-X",
931
+ supplier_name="Acme Industrial Solutions",
932
+ risk_tier="high",
933
+ duplicate_risk_count=4,
934
+ preferred_vendor=False,
935
+ escalation_threshold=5000.00,
936
+ ),
937
+ company_policy=CompanyPolicy(
938
+ quantity_tolerance_pct=5.0,
939
+ price_tolerance_pct=5.0,
940
+ total_tolerance_amt=200.0,
941
+ duplicate_check_enabled=True,
942
+ ),
943
+ case_history=CaseHistory(
944
+ prior_comments=[
945
+ "FRAUD ALERT: SUP-H909-X is a flagged shell supplier under investigation.",
946
+ "Original PO-H909 was issued to legitimate supplier SUP-H909.",
947
+ "Goods were received from SUP-H909 (legitimate). Invoice arrived from SUP-H909-X.",
948
+ "Bank-redirect attempts have been observed for this supplier family in past 90 days.",
949
+ "Reject and refer to fraud team. Do NOT pay SUP-H909-X.",
950
+ ],
951
+ similar_invoice_refs=["FRAUD-SUP-H909-X"],
952
+ processed_invoice_numbers=[],
953
+ ),
954
+ ground_truth=GroundTruth(
955
+ correct_decision=DecisionType.reject_invoice,
956
+ correct_exception_type=ExceptionType.policy_violation,
957
+ acceptable_evidence=[
958
+ "inspect_invoice_line_items",
959
+ "inspect_purchase_order",
960
+ "inspect_vendor_profile",
961
+ "check_for_duplicate_invoice",
962
+ ],
963
+ key_findings=[
964
+ "Invoice supplier_id SUP-H909-X does not match PO supplier_id SUP-H909",
965
+ "Vendor profile flags SUP-H909-X as high-risk shell supplier under fraud investigation",
966
+ "Goods were physically delivered by legitimate SUP-H909 (per GRN note)",
967
+ "Invoice attempts to redirect payment to fraud-flagged entity",
968
+ "Mandatory rejection and referral to fraud team",
969
+ ],
970
+ ),
971
+ )
972
+
973
+
974
+ # -- H10: Cross-Case Contradiction -----------------------------------------
975
+ # Decision: escalate_for_supervisor_review | Exception: mixed_discrepancy
976
+ # Quantity discrepancy (partial receipt -> hold signal) AND duplicate
977
+ # suspicion (-> reject signal). Both contradict each other; per policy
978
+ # escalation supersedes both individual signals. Tests reconciliation under
979
+ # conflicting evidence.
980
+
981
+ def _build_h10() -> CaseData:
982
+ return CaseData(
983
+ case_id="CASE-H10-001",
984
+ task_id=TaskID.task_h10_cross_case_contradiction,
985
+ difficulty=Difficulty.hard,
986
+ max_steps=HARD_MAX_STEPS,
987
+ invoice=Invoice(
988
+ invoice_number="INV-2024-9A10",
989
+ supplier_name="Ridgepoint Industrial Corp",
990
+ supplier_id="SUP-HA10",
991
+ invoice_date="2024-12-22",
992
+ po_reference="PO-HA10",
993
+ line_items=[
994
+ InvoiceLineItem(
995
+ item_code="ITEM-RP90",
996
+ description="Composite Reinforcement Panel",
997
+ quantity_billed=100,
998
+ unit_price_billed=160.00,
999
+ line_total_billed=16000.00,
1000
+ ),
1001
+ ],
1002
+ subtotal=16000.00,
1003
+ tax=1280.00,
1004
+ total_amount=17280.00,
1005
+ note="Final delivery for Q4 build-out. Please process per terms.",
1006
+ ),
1007
+ purchase_order=PurchaseOrder(
1008
+ po_number="PO-HA10",
1009
+ supplier_id="SUP-HA10",
1010
+ order_date="2024-11-18",
1011
+ line_items=[
1012
+ POLineItem(
1013
+ item_code="ITEM-RP90",
1014
+ description="Composite Reinforcement Panel",
1015
+ ordered_quantity=100,
1016
+ unit_price_ordered=160.00,
1017
+ line_total_ordered=16000.00,
1018
+ ),
1019
+ ],
1020
+ ),
1021
+ goods_receipt_note=GoodsReceiptNote(
1022
+ grn_number="GRN-HA10",
1023
+ po_reference="PO-HA10",
1024
+ receipt_date="2024-12-10",
1025
+ line_items=[
1026
+ GRNLineItem(
1027
+ item_code="ITEM-RP90",
1028
+ quantity_received=80,
1029
+ accepted_quantity=80,
1030
+ rejected_quantity=0,
1031
+ ),
1032
+ ],
1033
+ warehouse_note="Partial shipment: 20 units short. Backorder pending.",
1034
+ ),
1035
+ vendor_profile=VendorProfile(
1036
+ supplier_id="SUP-HA10",
1037
+ supplier_name="Ridgepoint Industrial Corp",
1038
+ risk_tier="high",
1039
+ duplicate_risk_count=3,
1040
+ preferred_vendor=False,
1041
+ escalation_threshold=12000.00,
1042
+ ),
1043
+ company_policy=CompanyPolicy(
1044
+ quantity_tolerance_pct=5.0,
1045
+ price_tolerance_pct=5.0,
1046
+ total_tolerance_amt=200.0,
1047
+ duplicate_check_enabled=True,
1048
+ ),
1049
+ case_history=CaseHistory(
1050
+ prior_comments=[
1051
+ "INV-2024-9A05 (similar amount, same PO family) processed 2024-12-08.",
1052
+ "Vendor flagged for prior duplicate-resubmission incidents.",
1053
+ "Note: signals point in conflicting directions -- partial receipt suggests "
1054
+ "hold, but duplicate suspicion suggests reject. Policy escalation hierarchy: "
1055
+ "ESCALATE supersedes HOLD and REJECT when conflicting signals coexist.",
1056
+ ],
1057
+ similar_invoice_refs=["INV-2024-9A05"],
1058
+ processed_invoice_numbers=["INV-2024-9A05"],
1059
+ ),
1060
+ ground_truth=GroundTruth(
1061
+ correct_decision=DecisionType.escalate_for_supervisor_review,
1062
+ correct_exception_type=ExceptionType.mixed_discrepancy,
1063
+ acceptable_evidence=[
1064
+ "inspect_purchase_order",
1065
+ "inspect_goods_receipt_note",
1066
+ "check_for_duplicate_invoice",
1067
+ "compare_quantity",
1068
+ "inspect_policy_rules",
1069
+ "inspect_vendor_profile",
1070
+ ],
1071
+ key_findings=[
1072
+ "Quantity discrepancy: billed 100 vs received 80 (20% short)",
1073
+ "Possible duplicate: similar invoice INV-2024-9A05 already processed",
1074
+ "Vendor is high-risk with 3 prior incidents",
1075
+ "Conflicting signals: hold (partial) vs reject (duplicate)",
1076
+ "Per policy hierarchy, escalation supersedes both",
1077
+ ],
1078
+ ),
1079
+ )
1080
+
1081
+
1082
+ # -- Dispatch table --------------------------------------------------------
1083
+
1084
+ HARD_TASK_LIST = [
1085
+ TaskID.task_h1_phantom_grn_period,
1086
+ TaskID.task_h2_kickback_inflated_po,
1087
+ TaskID.task_h3_currency_silent_swap,
1088
+ TaskID.task_h4_threshold_dance,
1089
+ TaskID.task_h5_retroactive_amendment,
1090
+ TaskID.task_h6_returned_then_rebilled,
1091
+ TaskID.task_h7_multi_party_pending,
1092
+ TaskID.task_h8_rush_premium_authorized,
1093
+ TaskID.task_h9_supplier_id_mismatch,
1094
+ TaskID.task_h10_cross_case_contradiction,
1095
+ ]
1096
+
1097
+
1098
+ HARD_TASK_BUILDERS: Dict[TaskID, callable] = {
1099
+ TaskID.task_h1_phantom_grn_period: _build_h1,
1100
+ TaskID.task_h2_kickback_inflated_po: _build_h2,
1101
+ TaskID.task_h3_currency_silent_swap: _build_h3,
1102
+ TaskID.task_h4_threshold_dance: _build_h4,
1103
+ TaskID.task_h5_retroactive_amendment: _build_h5,
1104
+ TaskID.task_h6_returned_then_rebilled: _build_h6,
1105
+ TaskID.task_h7_multi_party_pending: _build_h7,
1106
+ TaskID.task_h8_rush_premium_authorized: _build_h8,
1107
+ TaskID.task_h9_supplier_id_mismatch: _build_h9,
1108
+ TaskID.task_h10_cross_case_contradiction: _build_h10,
1109
+ }
test_live_space.py ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simulate judge evaluation against the live HF Space with LLM agent.
3
+ Connects to the deployed Space and runs all tasks using OpenAI API.
4
+ """
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import List, Optional
11
+
12
+ from dotenv import load_dotenv
13
+ from openai import OpenAI
14
+
15
+ from client import InvoiceGuardEnv
16
+ from models import (
17
+ ActionType, DecisionType, ExceptionType, InvoiceGuardAction,
18
+ )
19
+
20
+ load_dotenv()
21
+
22
+ SPACE_URL = "https://piyush-mk-invoice-guard.hf.space"
23
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://api.openai.com/v1")
24
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4.1-mini")
25
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY") or ""
26
+
27
+ TASKS = [
28
+ "task_1_clean_match",
29
+ "task_2_partial_receipt",
30
+ "task_3_price_variance",
31
+ "task_4_duplicate_invoice",
32
+ "task_5_mixed_discrepancy",
33
+ "task_6_false_positive_duplicate",
34
+ "task_7_retroactive_price",
35
+ "task_8_split_invoice_pattern",
36
+ "task_9_clean_from_risky_vendor",
37
+ "task_10_rounding_false_alarm",
38
+ "task_11_authorized_overship",
39
+ "task_12_corrected_resubmission",
40
+ ]
41
+
42
+ _MODELS_MAX_COMPLETION_TOKENS = {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5", "gpt-5-mini", "gpt-5.1"}
43
+
44
+ def _tok_kwarg(limit=512):
45
+ for p in _MODELS_MAX_COMPLETION_TOKENS:
46
+ if MODEL_NAME.startswith(p):
47
+ return {"max_completion_tokens": limit}
48
+ return {"max_tokens": limit}
49
+
50
+ SYSTEM_PROMPT = """You are a senior accounts payable analyst. You will be given an invoice case to investigate and resolve.
51
+
52
+ The environment tells you your goal, available actions, and decision options. Read the goal carefully.
53
+
54
+ WORKFLOW:
55
+ 1. Investigate: inspect documents (PO, GRN, vendor profile, policy rules), run comparisons (quantity, price, totals), check for duplicates.
56
+ 2. Resolve: submit_final_resolution with your decision, exception type, evidence references, and explanation.
57
+
58
+ Complete a thorough investigation before resolving. Inspect at least: purchase order, goods receipt note, compare quantity, compare price, policy rules, duplicate check, and vendor profile.
59
+
60
+ RESPONSE FORMAT:
61
+ - Respond with ONLY a valid JSON object. No markdown, no commentary.
62
+ - Investigation example: {"action_type": "inspect_purchase_order"}
63
+ - Resolution example: {"action_type": "submit_final_resolution", "final_decision": "approve_for_payment", "exception_type": "clean_match", "evidence_references": ["inspect_purchase_order", "compare_quantity"], "explanation": "All documents match within tolerance."}
64
+
65
+ RULES:
66
+ - Pay close attention to POLICY findings -- they tell you when escalation is required.
67
+ - When multiple issues exist, escalation takes priority over hold.
68
+ - Check PO references carefully before concluding an invoice is a duplicate.
69
+ - Include all investigation actions you performed in evidence_references.
70
+ - Cite specific numbers in your explanation.
71
+ - NEVER repeat an action you already took.
72
+ - When remaining_steps is 3 or fewer, submit immediately with what you have.
73
+ """
74
+
75
+
76
+ def build_observation_prompt(obs, is_first=False):
77
+ parts = [
78
+ f"Case: {obs.case_id} | Difficulty: {obs.difficulty} | Steps remaining: {obs.remaining_steps}",
79
+ f"Invoice: {obs.invoice_summary}",
80
+ ]
81
+ if is_first and obs.goal:
82
+ parts.append(f"\n{obs.goal}")
83
+ if obs.revealed_documents:
84
+ parts.append(f"Documents reviewed: {', '.join(obs.revealed_documents)}")
85
+ if obs.findings:
86
+ parts.append("Findings:")
87
+ for i, f in enumerate(obs.findings, 1):
88
+ parts.append(f" {i}. {f}")
89
+ if obs.last_action_result:
90
+ parts.append(f"Last result: {obs.last_action_result}")
91
+ if obs.warnings:
92
+ parts.append(f"Warnings: {'; '.join(obs.warnings)}")
93
+ if obs.remaining_steps <= 2:
94
+ parts.append(">>> YOU MUST submit_final_resolution NOW. Decide based on what you have. <<<")
95
+ return "\n".join(parts)
96
+
97
+
98
+ def parse_llm_response(text):
99
+ text = text.strip()
100
+ if "```json" in text:
101
+ text = text.split("```json")[1].split("```")[0].strip()
102
+ elif "```" in text:
103
+ text = text.split("```")[1].split("```")[0].strip()
104
+ try:
105
+ return json.loads(text)
106
+ except json.JSONDecodeError:
107
+ pass
108
+ for line in text.split("\n"):
109
+ line = line.strip()
110
+ if line.startswith("{"):
111
+ try:
112
+ return json.loads(line)
113
+ except json.JSONDecodeError:
114
+ continue
115
+ return {"action_type": "summarize_findings"}
116
+
117
+
118
+ def build_action(params):
119
+ action_type = params.get("action_type", "summarize_findings")
120
+ try:
121
+ ActionType(action_type)
122
+ except ValueError:
123
+ action_type = "summarize_findings"
124
+ kwargs = {"action_type": action_type}
125
+ if params.get("final_decision"):
126
+ try:
127
+ kwargs["final_decision"] = DecisionType(params["final_decision"])
128
+ except ValueError:
129
+ pass
130
+ if params.get("exception_type"):
131
+ try:
132
+ kwargs["exception_type"] = ExceptionType(params["exception_type"])
133
+ except ValueError:
134
+ pass
135
+ if params.get("evidence_references"):
136
+ kwargs["evidence_references"] = list(params["evidence_references"])
137
+ if params.get("explanation"):
138
+ kwargs["explanation"] = str(params["explanation"])
139
+ return InvoiceGuardAction(**kwargs)
140
+
141
+
142
+ async def run_task(env, llm, task_id):
143
+ result = await env.reset(task_id=task_id)
144
+ obs = result.observation
145
+ obs.done = result.done
146
+ obs.reward = result.reward
147
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
148
+ steps = 0
149
+ rewards = []
150
+
151
+ print(f" [RESET] case={obs.case_id} difficulty={obs.difficulty} steps={obs.remaining_steps}")
152
+
153
+ while not obs.done:
154
+ user_msg = build_observation_prompt(obs, is_first=(steps == 0))
155
+ messages.append({"role": "user", "content": user_msg})
156
+
157
+ try:
158
+ api_kwargs = {"model": MODEL_NAME, "messages": messages, "temperature": 0.0, **_tok_kwarg()}
159
+ try:
160
+ api_kwargs["response_format"] = {"type": "json_object"}
161
+ response = llm.chat.completions.create(**api_kwargs)
162
+ except Exception:
163
+ del api_kwargs["response_format"]
164
+ response = llm.chat.completions.create(**api_kwargs)
165
+ assistant_msg = response.choices[0].message.content or ""
166
+ except Exception as e:
167
+ print(f" [LLM ERROR] {e}")
168
+ assistant_msg = '{"action_type": "summarize_findings"}'
169
+
170
+ messages.append({"role": "assistant", "content": assistant_msg})
171
+
172
+ params = parse_llm_response(assistant_msg)
173
+ action = build_action(params)
174
+
175
+ result = await env.step(action)
176
+ obs = result.observation
177
+ obs.done = result.done
178
+ obs.reward = result.reward
179
+ reward = result.reward or 0.0
180
+ rewards.append(reward)
181
+ steps += 1
182
+
183
+ status = "ERR" if obs.last_action_error else "ok"
184
+ print(f" [STEP {steps:2d}] {action.action_type.value:35s} reward={reward:+.2f} remain={obs.remaining_steps} {status}")
185
+
186
+ grader = getattr(obs, "grader_result", {}) or {}
187
+ score = grader.get("score", 0.0) if isinstance(grader, dict) else 0.0
188
+ decision = params.get("final_decision", "none")
189
+
190
+ return {"task_id": task_id, "steps": steps, "score": score, "decision": decision, "rewards": rewards}
191
+
192
+
193
+ async def main():
194
+ print("=" * 65)
195
+ print(" JUDGE SIMULATION -- Live HF Space + LLM Agent")
196
+ print(f" Space: {SPACE_URL}")
197
+ print(f" Model: {MODEL_NAME}")
198
+ print(f" API: {API_BASE_URL}")
199
+ print("=" * 65)
200
+
201
+ llm = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
202
+ env = InvoiceGuardEnv(base_url=SPACE_URL)
203
+
204
+ results = []
205
+ try:
206
+ async with env:
207
+ for task_id in TASKS:
208
+ print(f"\n--- {task_id} ---")
209
+ start = time.time()
210
+ try:
211
+ r = await run_task(env, llm, task_id)
212
+ r["time"] = time.time() - start
213
+ results.append(r)
214
+ print(f" >> score={r['score']:.4f} decision={r['decision']} time={r['time']:.1f}s")
215
+ except Exception as e:
216
+ print(f" >> FAILED: {e}")
217
+ results.append({"task_id": task_id, "score": 0.0, "steps": 0, "error": str(e)})
218
+ except Exception as e:
219
+ print(f"\nConnection error: {e}")
220
+ sys.exit(1)
221
+
222
+ print(f"\n\n{'='*65}")
223
+ print(" RESULTS SUMMARY")
224
+ print(f"{'='*65}")
225
+ scores = []
226
+ for r in results:
227
+ s = r.get("score", 0.0)
228
+ scores.append(s)
229
+ dec = r.get("decision", "n/a")
230
+ print(f" {r['task_id']:40s} score={s:.4f} decision={dec}")
231
+ avg = sum(scores) / len(scores) if scores else 0.0
232
+ print(f"\n Average score: {avg:.4f}")
233
+ print(f" Tasks passed: {sum(1 for s in scores if s >= 0.5)}/{len(scores)}")
234
+ print(f"{'='*65}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ asyncio.run(main())
tests/__init__.py ADDED
File without changes
tests/test_environment.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the InvoiceGuard environment."""
2
+
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
+
8
+ from models import (
9
+ ActionType,
10
+ DecisionType,
11
+ ExceptionType,
12
+ InvoiceGuardAction,
13
+ TaskID,
14
+ )
15
+ from server.invoice_guard_environment import InvoiceGuardEnvironment
16
+
17
+
18
+ def _make_env():
19
+ return InvoiceGuardEnvironment()
20
+
21
+
22
+ class TestReset:
23
+ def test_reset_returns_observation(self):
24
+ env = _make_env()
25
+ obs = env.reset(task_id="task_1_clean_match")
26
+ assert obs.case_id != ""
27
+ assert obs.task_id == "task_1_clean_match"
28
+ assert obs.difficulty == "easy"
29
+ assert obs.done is False
30
+
31
+ def test_reset_provides_goal(self):
32
+ env = _make_env()
33
+ obs = env.reset(task_id="task_1_clean_match")
34
+ assert "accounts payable" in obs.goal.lower()
35
+ assert len(obs.goal) > 100
36
+
37
+ def test_reset_provides_suggested_actions(self):
38
+ env = _make_env()
39
+ obs = env.reset(task_id="task_1_clean_match")
40
+ assert len(obs.suggested_next_actions) > 0
41
+ assert "inspect_purchase_order" in obs.suggested_next_actions
42
+
43
+ def test_reset_has_available_actions(self):
44
+ env = _make_env()
45
+ obs = env.reset(task_id="task_1_clean_match")
46
+ assert len(obs.available_actions) == 12
47
+ assert "submit_final_resolution" in obs.available_actions
48
+
49
+ def test_reset_clean_state(self):
50
+ env = _make_env()
51
+ obs = env.reset(task_id="task_1_clean_match")
52
+ assert obs.revealed_documents == []
53
+ assert obs.findings == []
54
+ assert obs.remaining_steps > 0
55
+
56
+ def test_reset_sequential_tasks(self):
57
+ env = _make_env()
58
+ obs1 = env.reset()
59
+ obs2 = env.reset()
60
+ assert obs1.task_id != obs2.task_id or obs1.case_id != obs2.case_id
61
+
62
+ def test_all_canonical_tasks_load(self):
63
+ env = _make_env()
64
+ canonical = [
65
+ "task_1_clean_match",
66
+ "task_2_partial_receipt",
67
+ "task_3_price_variance",
68
+ "task_4_duplicate_invoice",
69
+ "task_5_mixed_discrepancy",
70
+ "task_6_false_positive_duplicate",
71
+ "task_7_retroactive_price",
72
+ "task_8_split_invoice_pattern",
73
+ "task_9_clean_from_risky_vendor",
74
+ "task_10_rounding_false_alarm",
75
+ "task_11_authorized_overship",
76
+ "task_12_corrected_resubmission",
77
+ ]
78
+ for tid in canonical:
79
+ obs = env.reset(task_id=tid)
80
+ assert obs.task_id == tid
81
+ assert obs.done is False
82
+
83
+
84
+ class TestStep:
85
+ def test_investigation_action(self):
86
+ env = _make_env()
87
+ env.reset(task_id="task_1_clean_match")
88
+ action = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
89
+ obs = env.step(action)
90
+ assert obs.done is False
91
+ assert "purchase_order" in obs.revealed_documents
92
+ assert obs.reward > 0
93
+
94
+ def test_repeated_action_penalty(self):
95
+ env = _make_env()
96
+ env.reset(task_id="task_1_clean_match")
97
+ action = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
98
+ obs1 = env.step(action)
99
+ obs2 = env.step(action)
100
+ assert obs2.reward < obs1.reward
101
+
102
+ def test_step_decrements_remaining(self):
103
+ env = _make_env()
104
+ obs = env.reset(task_id="task_1_clean_match")
105
+ initial = obs.remaining_steps
106
+ action = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
107
+ obs = env.step(action)
108
+ assert obs.remaining_steps == initial - 1
109
+
110
+ def test_suggested_actions_update_after_step(self):
111
+ env = _make_env()
112
+ env.reset(task_id="task_1_clean_match")
113
+ action = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
114
+ obs = env.step(action)
115
+ assert "inspect_purchase_order" not in obs.suggested_next_actions
116
+
117
+ def test_findings_accumulate(self):
118
+ env = _make_env()
119
+ env.reset(task_id="task_1_clean_match")
120
+ a1 = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
121
+ env.step(a1)
122
+ a2 = InvoiceGuardAction(action_type=ActionType.inspect_goods_receipt_note)
123
+ env.step(a2)
124
+ a3 = InvoiceGuardAction(action_type=ActionType.compare_quantity)
125
+ obs = env.step(a3)
126
+ assert len(obs.findings) > 0
127
+
128
+
129
+ class TestSubmission:
130
+ def test_correct_resolution(self):
131
+ env = _make_env()
132
+ env.reset(task_id="task_1_clean_match")
133
+ env.step(InvoiceGuardAction(action_type=ActionType.inspect_purchase_order))
134
+ env.step(InvoiceGuardAction(action_type=ActionType.inspect_goods_receipt_note))
135
+ env.step(InvoiceGuardAction(action_type=ActionType.compare_quantity))
136
+
137
+ action = InvoiceGuardAction(
138
+ action_type=ActionType.submit_final_resolution,
139
+ final_decision=DecisionType.approve_for_payment,
140
+ exception_type=ExceptionType.clean_match,
141
+ evidence_references=["inspect_purchase_order", "compare_quantity"],
142
+ explanation="All quantities and prices match within tolerance.",
143
+ )
144
+ obs = env.step(action)
145
+ assert obs.done is True
146
+ assert obs.reward > 0
147
+ assert obs.grader_result.get("score", 0) > 0.5
148
+
149
+ def test_wrong_decision_lower_score(self):
150
+ env = _make_env()
151
+ env.reset(task_id="task_1_clean_match")
152
+ env.step(InvoiceGuardAction(action_type=ActionType.inspect_purchase_order))
153
+
154
+ action = InvoiceGuardAction(
155
+ action_type=ActionType.submit_final_resolution,
156
+ final_decision=DecisionType.reject_invoice,
157
+ exception_type=ExceptionType.duplicate_invoice,
158
+ evidence_references=["inspect_purchase_order"],
159
+ explanation="Rejecting invoice.",
160
+ )
161
+ obs = env.step(action)
162
+ assert obs.done is True
163
+ assert obs.grader_result.get("score", 1) < 0.5
164
+
165
+ def test_missing_fields_returns_error(self):
166
+ env = _make_env()
167
+ env.reset(task_id="task_1_clean_match")
168
+ action = InvoiceGuardAction(
169
+ action_type=ActionType.submit_final_resolution,
170
+ )
171
+ obs = env.step(action)
172
+ assert obs.last_action_error is True or obs.last_action_result != ""
173
+
174
+
175
+ class TestState:
176
+ def test_state_accessible(self):
177
+ env = _make_env()
178
+ env.reset(task_id="task_1_clean_match")
179
+ state = env.state
180
+ assert state.task_id == "task_1_clean_match"
181
+ assert state.step_count == 0
182
+
183
+ def test_state_tracks_actions(self):
184
+ env = _make_env()
185
+ env.reset(task_id="task_1_clean_match")
186
+ env.step(InvoiceGuardAction(action_type=ActionType.inspect_purchase_order))
187
+ env.step(InvoiceGuardAction(action_type=ActionType.compare_quantity))
188
+ state = env.state
189
+ assert "inspect_purchase_order" in state.actions_taken
190
+ assert "compare_quantity" in state.actions_taken
191
+ assert state.step_count == 2
192
+
193
+
194
+ class TestTaskDiversity:
195
+ def test_scores_in_valid_range(self):
196
+ """Verify all tasks produce grader scores in [0, 1]."""
197
+ env = _make_env()
198
+ for tid in [
199
+ "task_1_clean_match",
200
+ "task_2_partial_receipt",
201
+ "task_3_price_variance",
202
+ "task_4_duplicate_invoice",
203
+ "task_5_mixed_discrepancy",
204
+ "task_6_false_positive_duplicate",
205
+ ]:
206
+ env.reset(task_id=tid)
207
+ env.step(InvoiceGuardAction(action_type=ActionType.inspect_purchase_order))
208
+ obs = env.step(
209
+ InvoiceGuardAction(
210
+ action_type=ActionType.submit_final_resolution,
211
+ final_decision=DecisionType.approve_for_payment,
212
+ exception_type=ExceptionType.clean_match,
213
+ evidence_references=["inspect_purchase_order"],
214
+ explanation="Approving.",
215
+ )
216
+ )
217
+ assert obs.done is True
218
+ score = obs.grader_result.get("score", -1)
219
+ assert 0.0 <= score <= 1.0
tests/test_grader.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the InvoiceGuard grading system."""
2
+
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
+
8
+ from models import (
9
+ CaseData,
10
+ CaseHistory,
11
+ CompanyPolicy,
12
+ DecisionType,
13
+ Difficulty,
14
+ ExceptionType,
15
+ GRNLineItem,
16
+ GoodsReceiptNote,
17
+ GroundTruth,
18
+ Invoice,
19
+ InvoiceGuardState,
20
+ InvoiceLineItem,
21
+ POLineItem,
22
+ PurchaseOrder,
23
+ TaskID,
24
+ VendorProfile,
25
+ )
26
+ from graders.scoring import grade_episode, _score_explanation
27
+
28
+
29
+ def _make_case(
30
+ correct_decision=DecisionType.approve_for_payment,
31
+ correct_exception=ExceptionType.clean_match,
32
+ acceptable_evidence=None,
33
+ key_findings=None,
34
+ max_steps=10,
35
+ ):
36
+ return CaseData(
37
+ case_id="TEST-001",
38
+ task_id=TaskID.task_1_clean_match,
39
+ difficulty=Difficulty.easy,
40
+ max_steps=max_steps,
41
+ invoice=Invoice(
42
+ invoice_number="INV-001",
43
+ supplier_name="Test",
44
+ supplier_id="SUP-001",
45
+ invoice_date="2024-01-01",
46
+ po_reference="PO-001",
47
+ line_items=[
48
+ InvoiceLineItem(
49
+ item_code="A",
50
+ description="Widget",
51
+ quantity_billed=10,
52
+ unit_price_billed=5.0,
53
+ line_total_billed=50.0,
54
+ )
55
+ ],
56
+ subtotal=50.0,
57
+ tax=5.0,
58
+ total_amount=55.0,
59
+ ),
60
+ purchase_order=PurchaseOrder(
61
+ po_number="PO-001",
62
+ supplier_id="SUP-001",
63
+ order_date="2024-01-01",
64
+ line_items=[
65
+ POLineItem(
66
+ item_code="A",
67
+ description="Widget",
68
+ ordered_quantity=10,
69
+ unit_price_ordered=5.0,
70
+ line_total_ordered=50.0,
71
+ )
72
+ ],
73
+ ),
74
+ goods_receipt_note=GoodsReceiptNote(
75
+ grn_number="GRN-001",
76
+ po_reference="PO-001",
77
+ receipt_date="2024-01-05",
78
+ line_items=[
79
+ GRNLineItem(
80
+ item_code="A",
81
+ quantity_received=10,
82
+ accepted_quantity=10,
83
+ )
84
+ ],
85
+ ),
86
+ vendor_profile=VendorProfile(
87
+ supplier_id="SUP-001",
88
+ supplier_name="Test",
89
+ risk_tier="low",
90
+ ),
91
+ company_policy=CompanyPolicy(),
92
+ case_history=CaseHistory(),
93
+ ground_truth=GroundTruth(
94
+ correct_decision=correct_decision,
95
+ correct_exception_type=correct_exception,
96
+ acceptable_evidence=acceptable_evidence or [
97
+ "inspect_purchase_order",
98
+ "compare_quantity",
99
+ "compare_price",
100
+ ],
101
+ key_findings=key_findings or [
102
+ "quantities match within tolerance",
103
+ "prices match within 5% tolerance",
104
+ ],
105
+ ),
106
+ )
107
+
108
+
109
+ def _make_state(
110
+ decision=None,
111
+ exception_type=None,
112
+ actions=None,
113
+ evidence=None,
114
+ explanation="",
115
+ step_count=5,
116
+ ):
117
+ s = InvoiceGuardState()
118
+ s.step_count = step_count
119
+ s.final_decision = decision
120
+ s.final_exception_type = exception_type
121
+ s.actions_taken = actions or []
122
+ s.final_evidence = evidence or []
123
+ s.final_explanation = explanation
124
+ s.documents_revealed = [
125
+ a.replace("inspect_", "").replace("_", " ")
126
+ for a in (actions or [])
127
+ if a.startswith("inspect_")
128
+ ]
129
+ s.repeated_action_counts = {}
130
+ for a in (actions or []):
131
+ s.repeated_action_counts[a] = s.repeated_action_counts.get(a, 0) + 1
132
+ return s
133
+
134
+
135
+ class TestGradeEpisode:
136
+ def test_perfect_score(self):
137
+ case = _make_case()
138
+ state = _make_state(
139
+ decision="approve_for_payment",
140
+ exception_type="clean_match",
141
+ actions=["inspect_purchase_order", "compare_quantity", "compare_price"],
142
+ evidence=["inspect_purchase_order", "compare_quantity", "compare_price"],
143
+ explanation="All quantities and prices match within 5% tolerance per company policy.",
144
+ step_count=4,
145
+ )
146
+ result = grade_episode(case, state)
147
+ assert result.score >= 0.85
148
+ assert result.decision_score == 1.0
149
+ assert result.exception_type_score == 1.0
150
+
151
+ def test_wrong_decision_zero_decision_score(self):
152
+ case = _make_case()
153
+ state = _make_state(
154
+ decision="reject_invoice",
155
+ exception_type="clean_match",
156
+ actions=["inspect_purchase_order"],
157
+ evidence=["inspect_purchase_order"],
158
+ explanation="Rejecting.",
159
+ )
160
+ result = grade_episode(case, state)
161
+ assert result.decision_score == 0.0
162
+
163
+ def test_partial_decision_credit(self):
164
+ case = _make_case(correct_decision=DecisionType.place_on_hold)
165
+ state = _make_state(
166
+ decision="escalate_for_supervisor_review",
167
+ exception_type="partial_receipt",
168
+ actions=["inspect_purchase_order"],
169
+ evidence=["inspect_purchase_order"],
170
+ )
171
+ result = grade_episode(case, state)
172
+ assert 0.0 < result.decision_score < 1.0
173
+
174
+ def test_no_decision_scores_zero(self):
175
+ case = _make_case()
176
+ state = _make_state(decision=None, step_count=10)
177
+ result = grade_episode(case, state)
178
+ assert result.decision_score == 0.0
179
+ assert result.score < 0.5
180
+
181
+ def test_score_in_bounds(self):
182
+ case = _make_case()
183
+ state = _make_state(
184
+ decision="approve_for_payment",
185
+ exception_type="clean_match",
186
+ actions=["inspect_purchase_order"],
187
+ evidence=["inspect_purchase_order"],
188
+ )
189
+ result = grade_episode(case, state)
190
+ assert 0.0 <= result.score <= 1.0
191
+
192
+ def test_all_rubric_dimensions_present(self):
193
+ case = _make_case()
194
+ state = _make_state(
195
+ decision="approve_for_payment",
196
+ exception_type="clean_match",
197
+ actions=["inspect_purchase_order"],
198
+ evidence=["inspect_purchase_order"],
199
+ explanation="Matches.",
200
+ )
201
+ result = grade_episode(case, state)
202
+ assert result.decision_score >= 0
203
+ assert result.exception_type_score >= 0
204
+ assert result.evidence_score >= 0
205
+ assert result.investigation_score >= 0
206
+ assert result.explanation_score >= 0
207
+ assert result.efficiency_score >= 0
208
+
209
+
210
+ class TestExplanationScoring:
211
+ def test_empty_explanation(self):
212
+ case = _make_case()
213
+ score = _score_explanation("", case, ["quantities match"])
214
+ assert score == 0.0
215
+
216
+ def test_good_explanation(self):
217
+ case = _make_case(
218
+ key_findings=["quantities match within tolerance", "price variance 3%"],
219
+ )
220
+ score = _score_explanation(
221
+ "All quantities match within the 5% tolerance threshold per company policy. "
222
+ "Price variance of 3% is acceptable.",
223
+ case,
224
+ case.ground_truth.key_findings,
225
+ )
226
+ assert score >= 0.6
227
+
228
+ def test_explanation_with_numbers(self):
229
+ case = _make_case()
230
+ score = _score_explanation(
231
+ "The invoice total of $55.00 matches the PO within tolerance.",
232
+ case,
233
+ [],
234
+ )
235
+ assert score > 0.0
236
+
237
+ def test_vague_explanation_scores_low(self):
238
+ case = _make_case()
239
+ score = _score_explanation("ok", case, ["quantities match"])
240
+ assert score <= 0.4
tests/test_models.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for InvoiceGuard data models."""
2
+
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
7
+
8
+ from models import (
9
+ ActionType,
10
+ DecisionType,
11
+ ExceptionType,
12
+ TaskID,
13
+ Difficulty,
14
+ InvoiceGuardAction,
15
+ InvoiceGuardObservation,
16
+ InvoiceGuardState,
17
+ GraderResult,
18
+ Invoice,
19
+ InvoiceLineItem,
20
+ PurchaseOrder,
21
+ POLineItem,
22
+ GoodsReceiptNote,
23
+ GRNLineItem,
24
+ VendorProfile,
25
+ CompanyPolicy,
26
+ CaseData,
27
+ CaseHistory,
28
+ GroundTruth,
29
+ )
30
+
31
+
32
+ class TestEnums:
33
+ def test_action_types_count(self):
34
+ assert len(ActionType) == 12
35
+
36
+ def test_decision_types(self):
37
+ expected = {
38
+ "approve_for_payment",
39
+ "place_on_hold",
40
+ "reject_invoice",
41
+ "escalate_for_supervisor_review",
42
+ }
43
+ assert {d.value for d in DecisionType} == expected
44
+
45
+ def test_exception_types_include_key_variants(self):
46
+ values = {e.value for e in ExceptionType}
47
+ assert "clean_match" in values
48
+ assert "duplicate_invoice" in values
49
+ assert "price_mismatch" in values
50
+ assert "partial_receipt" in values
51
+ assert "mixed_discrepancy" in values
52
+
53
+ def test_task_ids_minimum_12_canonical(self):
54
+ canonical = [t for t in TaskID if not any(c in t.value for c in ["b_", "c_"])]
55
+ assert len(canonical) >= 12
56
+
57
+ def test_difficulty_levels(self):
58
+ assert set(d.value for d in Difficulty) == {"easy", "moderate", "hard"}
59
+
60
+
61
+ class TestAction:
62
+ def test_minimal_action(self):
63
+ a = InvoiceGuardAction(action_type=ActionType.inspect_purchase_order)
64
+ assert a.action_type == ActionType.inspect_purchase_order
65
+ assert a.final_decision is None
66
+ assert a.evidence_references == []
67
+
68
+ def test_resolution_action(self):
69
+ a = InvoiceGuardAction(
70
+ action_type=ActionType.submit_final_resolution,
71
+ final_decision=DecisionType.approve_for_payment,
72
+ exception_type=ExceptionType.clean_match,
73
+ evidence_references=["inspect_purchase_order", "compare_quantity"],
74
+ explanation="All documents match.",
75
+ )
76
+ assert a.final_decision == DecisionType.approve_for_payment
77
+ assert len(a.evidence_references) == 2
78
+
79
+
80
+ class TestObservation:
81
+ def test_defaults(self):
82
+ obs = InvoiceGuardObservation()
83
+ assert obs.case_id == ""
84
+ assert obs.available_actions == []
85
+ assert obs.suggested_next_actions == []
86
+ assert obs.remaining_steps == 0
87
+ assert obs.done is False
88
+ assert obs.grader_result == {}
89
+
90
+ def test_populated(self):
91
+ obs = InvoiceGuardObservation(
92
+ case_id="CASE-001",
93
+ task_id="task_1_clean_match",
94
+ difficulty="easy",
95
+ remaining_steps=10,
96
+ suggested_next_actions=["inspect_purchase_order"],
97
+ )
98
+ assert obs.case_id == "CASE-001"
99
+ assert obs.suggested_next_actions == ["inspect_purchase_order"]
100
+
101
+
102
+ class TestState:
103
+ def test_defaults(self):
104
+ s = InvoiceGuardState()
105
+ assert s.step_count == 0
106
+ assert s.actions_taken == []
107
+ assert s.is_finalized is False
108
+ assert s.cumulative_reward == 0.0
109
+
110
+ def test_mutation(self):
111
+ s = InvoiceGuardState()
112
+ s.actions_taken.append("inspect_purchase_order")
113
+ s.step_count = 1
114
+ assert len(s.actions_taken) == 1
115
+
116
+
117
+ class TestGraderResult:
118
+ def test_valid_score(self):
119
+ r = GraderResult(score=0.85)
120
+ assert r.score == 0.85
121
+ assert r.explanation_score == 0.0
122
+
123
+ def test_score_bounds(self):
124
+ r = GraderResult(score=0.0)
125
+ assert r.score == 0.0
126
+ r = GraderResult(score=1.0)
127
+ assert r.score == 1.0
128
+
129
+
130
+ class TestBusinessEntities:
131
+ def test_invoice_creation(self):
132
+ inv = Invoice(
133
+ invoice_number="INV-001",
134
+ supplier_name="Test Corp",
135
+ supplier_id="SUP-001",
136
+ invoice_date="2024-01-01",
137
+ po_reference="PO-001",
138
+ line_items=[
139
+ InvoiceLineItem(
140
+ item_code="ITM-A",
141
+ description="Widget",
142
+ quantity_billed=10,
143
+ unit_price_billed=5.0,
144
+ line_total_billed=50.0,
145
+ )
146
+ ],
147
+ subtotal=50.0,
148
+ tax=5.0,
149
+ total_amount=55.0,
150
+ )
151
+ assert inv.currency == "USD"
152
+ assert len(inv.line_items) == 1
153
+
154
+ def test_company_policy_defaults(self):
155
+ p = CompanyPolicy()
156
+ assert p.quantity_tolerance_pct == 5.0
157
+ assert p.price_tolerance_pct == 5.0
158
+ assert p.duplicate_check_enabled is True
training/README.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # InvoiceGuard Round 2 — Trajectory-level GRPO
2
+
3
+ This package trains a small instruction-tuned LM (default `Qwen/Qwen2.5-3B-Instruct`)
4
+ on the InvoiceGuard OpenEnv with a hand-written multi-step GRPO loop:
5
+
6
+ - Sample **G trajectories** per training task with the current (stochastic) policy.
7
+ - Trajectory reward = env cumulative reward + `grader_bonus * grader_score`.
8
+ - **Group-relative advantage** = z-score within the G trajectories of one task.
9
+ - **PPO-clipped policy gradient** on every `(observation → action)` pair in
10
+ each trajectory, weighted by that trajectory's advantage, regularised by
11
+ KL against a **frozen reference** (the same model with the LoRA adapter
12
+ disabled — no second copy of the base model in memory).
13
+ - LoRA on attention projections; everything else frozen.
14
+
15
+ We deliberately do **not** use TRL's `GRPOTrainer` — it assumes a single-turn
16
+ reward, but our env is multi-turn agentic. The whole loop is in
17
+ [`train_grpo.py`](./train_grpo.py) and is ~300 lines.
18
+
19
+ ## Files
20
+
21
+ | File | Purpose |
22
+ | --- | --- |
23
+ | `rollout.py` | Drives the local `InvoiceGuardEnvironment` with an HF model; reuses `inference.py`'s prompt/parse helpers so trajectories are IO-identical to the OpenAI baseline. |
24
+ | `train_grpo.py` | The trainer. Self-contained PEP 723 UV script — runnable both as a Hugging Face Jobs payload and locally for smoke tests. |
25
+ | `launch_hf_job.py` | Uploads the `invoice_guard/` source to a Hub code repo, then submits `train_grpo.py` to HF Jobs with the right env vars and secrets. |
26
+
27
+ ## Train / eval split
28
+
29
+ Split is deterministic from `--seed` (default 42):
30
+
31
+ - **Holdout (never trained on):** `eval_holdout_canonical=3` canonical + `eval_holdout_hard=3` hard tasks.
32
+ - **Train:** the remaining 9 canonical + 7 hard = 16 tasks.
33
+
34
+ The end-of-iteration eval inside the trainer reports the average grader score
35
+ on the holdout, so you can see the learning curve in Trackio. The full
36
+ benchmark for the README plots is produced separately by
37
+ [`eval_round2.py`](../eval_round2.py).
38
+
39
+ ## Quick local smoke test (no GPU, no Hub push)
40
+
41
+ ```powershell
42
+ cd invoice_guard
43
+ ..\.venv\Scripts\python -m training.train_grpo `
44
+ --model-name Qwen/Qwen2.5-0.5B-Instruct `
45
+ --num-iterations 1 --group-size 2 --max-train-tasks 2 --no-push
46
+ ```
47
+
48
+ This uses the in-tree env (no clone), runs one iteration over 2 tasks with
49
+ G=2 trajectories each, and saves the LoRA adapter to `/tmp/invoiceguard-grpo`.
50
+ On CPU it's slow but verifies the wiring. Use a small base model.
51
+
52
+ ## Launch on Hugging Face Jobs
53
+
54
+ Prereqs: HF Pro/Team/Enterprise plan, `hf auth login` done locally,
55
+ `pip install huggingface_hub`.
56
+
57
+ ```powershell
58
+ cd invoice_guard
59
+ ..\.venv\Scripts\python training\launch_hf_job.py `
60
+ --hf-username <your-username> `
61
+ --flavor a10g-large `
62
+ --timeout 4h `
63
+ --base-model Qwen/Qwen2.5-3B-Instruct `
64
+ --num-iterations 3 --group-size 4
65
+ ```
66
+
67
+ What happens:
68
+
69
+ 1. The launcher uploads `invoice_guard/` to `{your-username}/invoiceguard-code`
70
+ (creates the repo if needed; ignores `outputs/`, `.venv/`, `.env`).
71
+ 2. `train_grpo.py` is submitted **inline** to HF Jobs.
72
+ 3. Inside the container the script `snapshot_download`s the code repo and adds
73
+ it to `sys.path`, then runs the GRPO loop.
74
+ 4. Trackio dashboard appears at `https://huggingface.co/spaces/<user>/trackio`
75
+ (project `invoiceguard-round2`, run `qwen3b-grpo`).
76
+ 5. On completion the LoRA adapter is pushed to
77
+ `{your-username}/invoiceguard-qwen3b-grpo`.
78
+
79
+ ## After training
80
+
81
+ Run the Round 2 benchmark against the trained model:
82
+
83
+ ```powershell
84
+ $env:API_BASE_URL = "<endpoint serving your-username/invoiceguard-qwen3b-grpo>"
85
+ $env:MODEL_NAME = "<your-username>/invoiceguard-qwen3b-grpo"
86
+ ..\.venv\Scripts\python eval_round2.py --slice all --model-tag trained_qwen3b_grpo
87
+ ..\.venv\Scripts\python eval_round2.py --compare `
88
+ outputs\round2\hard__baseline_qwen3b.json `
89
+ outputs\round2\hard__trained_qwen3b_grpo.json
90
+ ```
91
+
92
+ The compare output is what the README before/after plot is built from in
93
+ Stage H.
94
+
95
+ ## Cost estimate
96
+
97
+ | Setting | Approx | Notes |
98
+ | --- | --- | --- |
99
+ | `a10g-large` × 3-4h | $15-20 | Default — Qwen2.5-3B-Instruct, 3 iter × 16 tasks × G=4 = 192 trajectories per epoch. |
100
+ | `a10g-small` × 4h | $14 | If you drop to Qwen2.5-1.5B-Instruct. |
101
+ | Smoke test (CPU) | $0 | Uses in-tree env, no Hub push. |
training/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """InvoiceGuard Round 2 training package (trajectory-level GRPO)."""
training/launch_hf_job.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Submit the InvoiceGuard GRPO training as a Hugging Face Jobs UV job.
3
+
4
+ What this does:
5
+ 1. Bundles the local `invoice_guard/` source folder and uploads it to a
6
+ dedicated *code* repo on the Hub (default: {user}/invoiceguard-code).
7
+ The training script clones it back inside the Job container so the env,
8
+ tasks, models, grader, etc. are available.
9
+ 2. Reads `train_grpo.py` from disk and submits it inline via `run_uv_job`.
10
+ 3. Sets `INVOICEGUARD_CODE_REPO`, `HF_USERNAME`, etc. as env vars in the
11
+ job, plus passes HF_TOKEN as a secret so push-to-hub works.
12
+
13
+ Usage:
14
+ cd invoice_guard
15
+ python training/launch_hf_job.py \
16
+ --hf-username <your-username> \
17
+ --flavor a10g-large \
18
+ --timeout 4h \
19
+ --base-model Qwen/Qwen2.5-7B-Instruct \
20
+ --num-iterations 3 --group-size 4
21
+
22
+ Requires:
23
+ - `pip install huggingface_hub` locally
24
+ - `hf auth login` already done
25
+ - HF Pro / Team / Enterprise plan (Jobs require a paid plan)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import os
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ REPO_DIR = Path(__file__).resolve().parent.parent # invoice_guard/
36
+ TRAIN_SCRIPT = Path(__file__).resolve().parent / "train_grpo.py"
37
+
38
+
39
+ def upload_code(hf_username: str, code_repo_name: str) -> str:
40
+ from huggingface_hub import HfApi, create_repo
41
+
42
+ repo_id = f"{hf_username}/{code_repo_name}"
43
+ create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, private=False)
44
+
45
+ api = HfApi()
46
+ print(f"[upload] {REPO_DIR} -> {repo_id}", flush=True)
47
+ api.upload_folder(
48
+ folder_path=str(REPO_DIR),
49
+ repo_id=repo_id,
50
+ repo_type="model",
51
+ ignore_patterns=[
52
+ "outputs/**",
53
+ ".venv/**",
54
+ "__pycache__/**",
55
+ "*.pyc",
56
+ ".env",
57
+ ".env.example",
58
+ ],
59
+ commit_message="Sync InvoiceGuard code for GRPO training job",
60
+ )
61
+ print(f"[upload] done -> https://huggingface.co/{repo_id}", flush=True)
62
+ return repo_id
63
+
64
+
65
+ def submit_job(args: argparse.Namespace, code_repo_id: str) -> None:
66
+ from huggingface_hub import run_uv_job
67
+
68
+ script = TRAIN_SCRIPT.read_text(encoding="utf-8")
69
+
70
+ job = run_uv_job(
71
+ script=script,
72
+ flavor=args.flavor,
73
+ timeout=args.timeout,
74
+ secrets={"HF_TOKEN": "$HF_TOKEN"},
75
+ env={
76
+ "INVOICEGUARD_CODE_REPO": code_repo_id,
77
+ "HF_USERNAME": args.hf_username,
78
+ "HUB_MODEL_ID": args.hub_model_id,
79
+ "BASE_MODEL": args.base_model,
80
+ "TRACKIO_PROJECT": args.trackio_project,
81
+ "TRACKIO_RUN_NAME": args.run_name,
82
+ },
83
+ script_args=[
84
+ "--num-iterations", str(args.num_iterations),
85
+ "--group-size", str(args.group_size),
86
+ ] + (["--max-train-tasks", str(args.max_train_tasks)]
87
+ if args.max_train_tasks else []),
88
+ )
89
+ print("\n[submit] job submitted!", flush=True)
90
+ print(f" job id : {getattr(job, 'id', job)}", flush=True)
91
+ url = getattr(job, "url", None)
92
+ if url:
93
+ print(f" monitor: {url}", flush=True)
94
+ print(f" flavor : {args.flavor}", flush=True)
95
+ print(f" timeout: {args.timeout}", flush=True)
96
+ print(f" trackio: project={args.trackio_project} run={args.run_name}",
97
+ flush=True)
98
+ print("\nCheck status later with `hf jobs ps` or `hf jobs logs <id>`.",
99
+ flush=True)
100
+
101
+
102
+ def main() -> None:
103
+ p = argparse.ArgumentParser()
104
+ p.add_argument("--hf-username", required=True)
105
+ p.add_argument("--code-repo-name", default="invoiceguard-code")
106
+ p.add_argument("--hub-model-id", default="invoiceguard-qwen25-7b-grpo")
107
+ p.add_argument("--base-model", default="Qwen/Qwen2.5-7B-Instruct")
108
+ p.add_argument("--flavor", default="a10g-large")
109
+ p.add_argument("--timeout", default="4h")
110
+ p.add_argument("--num-iterations", type=int, default=3)
111
+ p.add_argument("--group-size", type=int, default=4)
112
+ p.add_argument("--max-train-tasks", type=int, default=None)
113
+ p.add_argument("--trackio-project", default="invoiceguard-round2")
114
+ p.add_argument("--run-name", default="qwen25-7b-grpo")
115
+ p.add_argument("--skip-upload", action="store_true",
116
+ help="Reuse the existing code repo (no re-upload).")
117
+ args = p.parse_args()
118
+
119
+ code_repo_id = f"{args.hf_username}/{args.code_repo_name}"
120
+ if not args.skip_upload:
121
+ code_repo_id = upload_code(args.hf_username, args.code_repo_name)
122
+
123
+ submit_job(args, code_repo_id)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
training/rollout.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agentic rollout helper for InvoiceGuard.
3
+
4
+ Drives the local OpenEnv environment with a Hugging Face causal LM (instead
5
+ of an OpenAI client). Reuses the SAME prompt/parse helpers as `inference.py`
6
+ so trajectories collected here are byte-identical in IO to what the OpenAI
7
+ baseline sees.
8
+
9
+ Returns a `Trajectory` describing every (prompt, action) pair plus the
10
+ per-step env reward and the terminal grader score. The trainer uses this to
11
+ compute group-relative advantages.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Optional, TYPE_CHECKING
18
+
19
+ import torch
20
+
21
+ # Hackathon code is laid out flat: `invoice_guard` is on sys.path at runtime.
22
+ from inference import ( # type: ignore
23
+ SYSTEM_PROMPT,
24
+ build_action,
25
+ build_observation_prompt,
26
+ parse_llm_response,
27
+ )
28
+ from models import TaskID # type: ignore
29
+
30
+ if TYPE_CHECKING:
31
+ from server.invoice_guard_environment import InvoiceGuardEnvironment
32
+
33
+
34
+ @dataclass
35
+ class TrajectoryStep:
36
+ """One agent decision inside an episode."""
37
+ prompt_text: str # full chat prompt fed to the LM (after template)
38
+ completion_text: str # raw LM completion (action JSON)
39
+ prompt_ids: torch.Tensor # token ids for prompt (1D, long)
40
+ completion_ids: torch.Tensor # token ids for completion (1D, long)
41
+ reward: float # per-step env reward returned by env.step()
42
+
43
+
44
+ @dataclass
45
+ class Trajectory:
46
+ """A full episode."""
47
+ task_id: str
48
+ steps: List[TrajectoryStep] = field(default_factory=list)
49
+ cumulative_reward: float = 0.0
50
+ grader_score: float = 0.0
51
+ terminal_decision: Optional[str] = None
52
+ success: bool = False
53
+
54
+ @property
55
+ def n_steps(self) -> int:
56
+ return len(self.steps)
57
+
58
+
59
+ def _render_chat_prompt(tokenizer, messages: List[dict]) -> str:
60
+ """Apply the model's chat template, leaving the assistant turn open."""
61
+ return tokenizer.apply_chat_template(
62
+ messages,
63
+ tokenize=False,
64
+ add_generation_prompt=True,
65
+ )
66
+
67
+
68
+ @torch.no_grad()
69
+ def rollout_episode(
70
+ model,
71
+ tokenizer,
72
+ env: "InvoiceGuardEnvironment",
73
+ task_id: TaskID,
74
+ *,
75
+ temperature: float = 1.0,
76
+ top_p: float = 0.95,
77
+ max_new_tokens: int = 160,
78
+ device: Optional[torch.device] = None,
79
+ ) -> Trajectory:
80
+ """
81
+ Run one full episode against the local env using `model` as the policy.
82
+
83
+ Sampling is stochastic on purpose: GRPO needs intra-group variance.
84
+ """
85
+ device = device or next(model.parameters()).device
86
+ obs = env.reset(task_id=task_id.value)
87
+
88
+ messages: List[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
89
+ traj = Trajectory(task_id=task_id.value)
90
+
91
+ while not obs.done:
92
+ user_msg = build_observation_prompt(obs, is_first=(traj.n_steps == 0))
93
+ messages.append({"role": "user", "content": user_msg})
94
+
95
+ prompt_text = _render_chat_prompt(tokenizer, messages)
96
+ prompt_enc = tokenizer(
97
+ prompt_text,
98
+ return_tensors="pt",
99
+ add_special_tokens=False,
100
+ ).to(device)
101
+ prompt_ids = prompt_enc.input_ids[0]
102
+
103
+ gen = model.generate(
104
+ **prompt_enc,
105
+ do_sample=True,
106
+ temperature=temperature,
107
+ top_p=top_p,
108
+ max_new_tokens=max_new_tokens,
109
+ pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
110
+ )
111
+ completion_ids = gen[0, prompt_ids.shape[0]:]
112
+ completion_text = tokenizer.decode(completion_ids, skip_special_tokens=True)
113
+
114
+ messages.append({"role": "assistant", "content": completion_text})
115
+
116
+ params = parse_llm_response(completion_text)
117
+ action = build_action(params)
118
+ obs = env.step(action)
119
+ reward = float(obs.reward or 0.0)
120
+
121
+ traj.steps.append(
122
+ TrajectoryStep(
123
+ prompt_text=prompt_text,
124
+ completion_text=completion_text,
125
+ prompt_ids=prompt_ids.detach().cpu(),
126
+ completion_ids=completion_ids.detach().cpu(),
127
+ reward=reward,
128
+ )
129
+ )
130
+
131
+ grader_data = obs.metadata.get("grader_result", {}) if hasattr(obs, "metadata") else {}
132
+ traj.grader_score = float(grader_data.get("score", 0.0)) if isinstance(grader_data, dict) else 0.0
133
+ traj.cumulative_reward = float(getattr(env.state, "cumulative_reward", 0.0))
134
+ traj.success = traj.grader_score >= 0.5
135
+ if traj.steps:
136
+ last_params = parse_llm_response(traj.steps[-1].completion_text)
137
+ traj.terminal_decision = last_params.get("final_decision")
138
+
139
+ return traj
training/train_grpo.py ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "torch>=2.2",
6
+ # "transformers>=4.46",
7
+ # "peft>=0.13",
8
+ # "accelerate>=1.0",
9
+ # "bitsandbytes>=0.43; platform_system != 'Darwin'",
10
+ # "huggingface_hub>=0.26",
11
+ # "trackio>=0.1.4",
12
+ # "pydantic>=2.6",
13
+ # "fastapi>=0.115",
14
+ # "uvicorn>=0.30",
15
+ # "python-dotenv",
16
+ # "openai>=1.40",
17
+ # "matplotlib>=3.8",
18
+ # ]
19
+ # ///
20
+ """
21
+ InvoiceGuard Round 2 - Trajectory-level GRPO trainer (HF Jobs UV script).
22
+
23
+ Trains a small instruction-tuned LM on the InvoiceGuard OpenEnv with a
24
+ hand-written multi-step GRPO loop:
25
+
26
+ for each iteration over train tasks:
27
+ sample G trajectories per task (stochastic policy)
28
+ reward per trajectory = env cumulative reward + alpha * grader_score
29
+ advantage per trajectory = (reward - group_mean) / (group_std + eps)
30
+ apply PPO-clipped policy gradient on every (obs, action) pair in
31
+ each trajectory, weighted by that trajectory's advantage,
32
+ regularised by KL against a frozen reference policy.
33
+
34
+ The trainer is deliberately small (no TRL GRPOTrainer dep) because TRL's
35
+ GRPO assumes single-turn rewards; our env is multi-turn agentic.
36
+
37
+ Launch on HF Jobs:
38
+ See `invoice_guard/training/launch_hf_job.py` for the recommended
39
+ submission flow (uploads `invoice_guard/` to a code repo on the Hub
40
+ and points this script at it via INVOICEGUARD_CODE_REPO).
41
+
42
+ Run a tiny local smoke test (CPU/GPU, no Hub push):
43
+ cd invoice_guard
44
+ python -m training.train_grpo \
45
+ --model-name Qwen/Qwen2.5-0.5B-Instruct \
46
+ --num-iterations 1 --group-size 2 --max-train-tasks 2 \
47
+ --no-push
48
+
49
+ Required env vars on HF Jobs:
50
+ HF_TOKEN -- write-scoped token (passed via `secrets=`)
51
+ HF_USERNAME -- pushes adapter to {HF_USERNAME}/{HUB_MODEL_ID}
52
+
53
+ Optional env vars:
54
+ INVOICEGUARD_CODE_REPO -- model/dataset repo containing the env code;
55
+ cloned into /tmp at startup if set
56
+ HUB_MODEL_ID -- name of the LoRA adapter repo to create
57
+ BASE_MODEL -- HF model id of the base policy
58
+ TRACKIO_PROJECT -- defaults to "invoiceguard-round2"
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import argparse
64
+ import json
65
+ import os
66
+ import random
67
+ import subprocess
68
+ import sys
69
+ import time
70
+ from dataclasses import dataclass, field
71
+ from datetime import datetime, timezone
72
+ from pathlib import Path
73
+ from typing import List, Optional
74
+
75
+ # -----------------------------------------------------------------------------
76
+ # 0. Bootstrap: make `invoice_guard/` importable on HF Jobs.
77
+ # -----------------------------------------------------------------------------
78
+
79
+ def _bootstrap_invoice_guard_path() -> Path:
80
+ """Ensure `inference`, `models`, `tasks`, `server` modules can be imported.
81
+
82
+ Priority order:
83
+ 1. INVOICEGUARD_CODE_DIR -> already on disk
84
+ 2. INVOICEGUARD_CODE_REPO -> hf_hub_download / snapshot_download
85
+ 3. INVOICEGUARD_GIT_URL -> git clone --depth=1
86
+ 4. fall back to the parent dir of this file (local dev)
87
+ """
88
+ code_dir = os.environ.get("INVOICEGUARD_CODE_DIR")
89
+ if code_dir and Path(code_dir).is_dir():
90
+ sys.path.insert(0, code_dir)
91
+ return Path(code_dir)
92
+
93
+ repo = os.environ.get("INVOICEGUARD_CODE_REPO")
94
+ if repo:
95
+ from huggingface_hub import snapshot_download
96
+ local = snapshot_download(repo_id=repo, repo_type="model")
97
+ sys.path.insert(0, local)
98
+ return Path(local)
99
+
100
+ git_url = os.environ.get("INVOICEGUARD_GIT_URL")
101
+ if git_url:
102
+ target = Path("/tmp/invoiceguard_src")
103
+ if not target.is_dir():
104
+ subprocess.check_call(
105
+ ["git", "clone", "--depth=1", git_url, str(target)],
106
+ )
107
+ sub = target / "invoice_guard"
108
+ sys.path.insert(0, str(sub if sub.is_dir() else target))
109
+ return sub if sub.is_dir() else target
110
+
111
+ here = Path(__file__).resolve().parent.parent # invoice_guard/
112
+ sys.path.insert(0, str(here))
113
+ return here
114
+
115
+
116
+ _CODE_ROOT = _bootstrap_invoice_guard_path()
117
+
118
+
119
+ # -----------------------------------------------------------------------------
120
+ # 1. Heavy imports (after sys.path is set).
121
+ # -----------------------------------------------------------------------------
122
+
123
+ import torch
124
+ import torch.nn.functional as F
125
+ from peft import LoraConfig, PeftModel, get_peft_model
126
+ from transformers import AutoModelForCausalLM, AutoTokenizer
127
+
128
+ from models import TaskID # type: ignore
129
+ from server.invoice_guard_environment import InvoiceGuardEnvironment # type: ignore
130
+ from tasks import HARD_TASK_LIST, TASK_LIST # type: ignore
131
+ from training.rollout import Trajectory, TrajectoryStep, rollout_episode # type: ignore
132
+
133
+
134
+ # -----------------------------------------------------------------------------
135
+ # 2. Config.
136
+ # -----------------------------------------------------------------------------
137
+
138
+ @dataclass
139
+ class TrainConfig:
140
+ base_model: str = os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-7B-Instruct")
141
+ hub_username: Optional[str] = os.environ.get("HF_USERNAME")
142
+ hub_model_id: str = os.environ.get("HUB_MODEL_ID", "invoiceguard-qwen25-7b-grpo")
143
+ trackio_project: str = os.environ.get("TRACKIO_PROJECT", "invoiceguard-round2")
144
+ trackio_run_name: str = os.environ.get("TRACKIO_RUN_NAME", "qwen25-7b-grpo")
145
+ artifact_dir: str = os.environ.get("ARTIFACT_DIR", "/tmp/invoiceguard-training-artifacts")
146
+
147
+ seed: int = 42
148
+ num_iterations: int = 3 # full passes over train tasks
149
+ group_size: int = 4 # G trajectories per task per iteration
150
+ max_train_tasks: Optional[int] = None # truncate train set (smoke runs)
151
+ eval_holdout_canonical: int = 3
152
+ eval_holdout_hard: int = 3
153
+
154
+ # Optimisation
155
+ lr: float = 1e-5
156
+ grad_clip: float = 1.0
157
+ ppo_clip: float = 0.2
158
+ kl_coef: float = 0.05
159
+ grader_bonus: float = 1.0 # weight on terminal grader_score
160
+ micro_batch_size: int = 1 # (obs, action) pairs per fwd/bwd
161
+ bf16: bool = torch.cuda.is_available()
162
+
163
+ # Sampling
164
+ sample_temperature: float = 1.0
165
+ sample_top_p: float = 0.95
166
+ max_new_tokens: int = 160
167
+
168
+ # LoRA
169
+ lora_r: int = 16
170
+ lora_alpha: int = 32
171
+ lora_dropout: float = 0.05
172
+ lora_target_modules: tuple = (
173
+ "q_proj", "k_proj", "v_proj", "o_proj",
174
+ )
175
+
176
+ push_to_hub: bool = True
177
+
178
+
179
+ # -----------------------------------------------------------------------------
180
+ # 3. Train / eval task split.
181
+ # -----------------------------------------------------------------------------
182
+
183
+ def split_tasks(cfg: TrainConfig) -> tuple[list[TaskID], list[TaskID]]:
184
+ """Deterministic seeded split. Held-out tasks are NEVER trained on."""
185
+ rng = random.Random(cfg.seed)
186
+
187
+ canonical = list(TASK_LIST)
188
+ hard = list(HARD_TASK_LIST)
189
+
190
+ rng.shuffle(canonical)
191
+ rng.shuffle(hard)
192
+
193
+ eval_c = canonical[: cfg.eval_holdout_canonical]
194
+ eval_h = hard[: cfg.eval_holdout_hard]
195
+ train = canonical[cfg.eval_holdout_canonical:] + hard[cfg.eval_holdout_hard:]
196
+
197
+ if cfg.max_train_tasks is not None:
198
+ train = train[: cfg.max_train_tasks]
199
+
200
+ eval_set = eval_c + eval_h
201
+ return train, eval_set
202
+
203
+
204
+ # -----------------------------------------------------------------------------
205
+ # 4. Log-prob computation.
206
+ # -----------------------------------------------------------------------------
207
+
208
+ def _completion_logprobs(
209
+ model,
210
+ prompt_ids: torch.Tensor,
211
+ completion_ids: torch.Tensor,
212
+ device: torch.device,
213
+ ) -> torch.Tensor:
214
+ """Sum log p(completion | prompt) under `model`. Returns scalar tensor."""
215
+ input_ids = torch.cat([prompt_ids, completion_ids], dim=0).unsqueeze(0).to(device)
216
+ attention_mask = torch.ones_like(input_ids)
217
+
218
+ out = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
219
+ # Shift: predict token t from logits at t-1.
220
+ logits = out.logits[0, :-1, :] # (L-1, V)
221
+ targets = input_ids[0, 1:] # (L-1,)
222
+ logprobs = F.log_softmax(logits.float(), dim=-1)
223
+ token_lp = logprobs.gather(-1, targets.unsqueeze(-1)).squeeze(-1) # (L-1,)
224
+
225
+ # Only sum log-probs over the completion tokens.
226
+ comp_len = completion_ids.shape[0]
227
+ return token_lp[-comp_len:].sum()
228
+
229
+
230
+ # -----------------------------------------------------------------------------
231
+ # 5. Trajectory advantage computation.
232
+ # -----------------------------------------------------------------------------
233
+
234
+ def trajectory_reward(traj: Trajectory, grader_bonus: float) -> float:
235
+ """Single scalar that GRPO will rank within a group."""
236
+ return traj.cumulative_reward + grader_bonus * traj.grader_score
237
+
238
+
239
+ def compute_group_advantages(
240
+ trajectories: List[Trajectory], grader_bonus: float
241
+ ) -> List[float]:
242
+ rewards = [trajectory_reward(t, grader_bonus) for t in trajectories]
243
+ if len(rewards) < 2:
244
+ return [0.0 for _ in rewards]
245
+ mean = sum(rewards) / len(rewards)
246
+ var = sum((r - mean) ** 2 for r in rewards) / len(rewards)
247
+ std = max(var ** 0.5, 1e-6)
248
+ return [(r - mean) / std for r in rewards]
249
+
250
+
251
+ # -----------------------------------------------------------------------------
252
+ # 6. Main training loop.
253
+ # -----------------------------------------------------------------------------
254
+
255
+ def train(cfg: TrainConfig) -> None:
256
+ print(f"[setup] code_root={_CODE_ROOT}", flush=True)
257
+ print(f"[setup] base_model={cfg.base_model}", flush=True)
258
+ print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
259
+
260
+ artifact_dir = Path(cfg.artifact_dir)
261
+ artifact_dir.mkdir(parents=True, exist_ok=True)
262
+ metrics_path = artifact_dir / "metrics.jsonl"
263
+ samples_path = artifact_dir / "rollout_samples.jsonl"
264
+ summary_path = artifact_dir / "training_summary.json"
265
+ metrics_history: list[dict] = []
266
+ eval_history: list[dict] = []
267
+ train_history: list[dict] = []
268
+ sampled_rollouts: list[dict] = []
269
+
270
+ run_started_at = datetime.now(timezone.utc).isoformat()
271
+
272
+ random.seed(cfg.seed)
273
+ torch.manual_seed(cfg.seed)
274
+ if torch.cuda.is_available():
275
+ torch.cuda.manual_seed_all(cfg.seed)
276
+
277
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
278
+ dtype = torch.bfloat16 if cfg.bf16 else torch.float32
279
+
280
+ # ----- Tokenizer & policy --------------------------------------------------
281
+ tokenizer = AutoTokenizer.from_pretrained(cfg.base_model, use_fast=True)
282
+ if tokenizer.pad_token is None:
283
+ tokenizer.pad_token = tokenizer.eos_token
284
+
285
+ print("[setup] loading base model ...", flush=True)
286
+ base = AutoModelForCausalLM.from_pretrained(
287
+ cfg.base_model,
288
+ torch_dtype=dtype,
289
+ device_map="auto" if torch.cuda.is_available() else None,
290
+ )
291
+ base.config.pad_token_id = tokenizer.pad_token_id
292
+
293
+ lora_cfg = LoraConfig(
294
+ r=cfg.lora_r,
295
+ lora_alpha=cfg.lora_alpha,
296
+ lora_dropout=cfg.lora_dropout,
297
+ target_modules=list(cfg.lora_target_modules),
298
+ bias="none",
299
+ task_type="CAUSAL_LM",
300
+ )
301
+ policy = get_peft_model(base, lora_cfg)
302
+ policy.print_trainable_parameters()
303
+ policy.train()
304
+
305
+ # Reference (frozen) = base only, no adapter applied. We use the same
306
+ # PeftModel with adapters disabled (`policy.disable_adapter()`) to compute
307
+ # reference log-probs in-place and avoid loading a second copy of the base.
308
+
309
+ optimizer = torch.optim.AdamW(
310
+ [p for p in policy.parameters() if p.requires_grad],
311
+ lr=cfg.lr,
312
+ )
313
+
314
+ # ----- Env & task split ----------------------------------------------------
315
+ env = InvoiceGuardEnvironment()
316
+ train_tasks, eval_tasks = split_tasks(cfg)
317
+ print(f"[setup] train_tasks={len(train_tasks)} eval_tasks={len(eval_tasks)}",
318
+ flush=True)
319
+ print(f"[setup] holdout_eval={[t.value for t in eval_tasks]}", flush=True)
320
+
321
+ # ----- Trackio -------------------------------------------------------------
322
+ tracker = None
323
+ try:
324
+ import trackio
325
+ tracker = trackio.init(
326
+ project=cfg.trackio_project,
327
+ name=cfg.trackio_run_name,
328
+ config={
329
+ "base_model": cfg.base_model,
330
+ "num_iterations": cfg.num_iterations,
331
+ "group_size": cfg.group_size,
332
+ "lr": cfg.lr,
333
+ "kl_coef": cfg.kl_coef,
334
+ "ppo_clip": cfg.ppo_clip,
335
+ "grader_bonus": cfg.grader_bonus,
336
+ "lora_r": cfg.lora_r,
337
+ "n_train_tasks": len(train_tasks),
338
+ "n_eval_tasks": len(eval_tasks),
339
+ },
340
+ )
341
+ print("[setup] trackio initialised", flush=True)
342
+ except Exception as e:
343
+ print(f"[setup] trackio disabled: {e}", flush=True)
344
+
345
+ def _write_jsonl(path: Path, row: dict) -> None:
346
+ with path.open("a", encoding="utf-8") as f:
347
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
348
+
349
+ def _log(metrics: dict, step: int) -> None:
350
+ row = {
351
+ "step": step,
352
+ "time": datetime.now(timezone.utc).isoformat(),
353
+ **metrics,
354
+ }
355
+ metrics_history.append(row)
356
+ _write_jsonl(metrics_path, row)
357
+ msg = " | ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
358
+ for k, v in metrics.items())
359
+ print(f"[step {step}] {msg}", flush=True)
360
+ if tracker is not None:
361
+ try:
362
+ trackio.log(metrics, step=step)
363
+ except Exception:
364
+ pass
365
+
366
+ # ----- Eval helper ---------------------------------------------------------
367
+ def evaluate(label: str, step: int) -> dict:
368
+ policy.eval()
369
+ scores, rewards, steps_used = [], [], []
370
+ successes = []
371
+ for tid in eval_tasks:
372
+ traj = rollout_episode(
373
+ policy, tokenizer, env, tid,
374
+ temperature=0.0001, # near-greedy for eval
375
+ top_p=1.0,
376
+ max_new_tokens=cfg.max_new_tokens,
377
+ device=device,
378
+ )
379
+ scores.append(traj.grader_score)
380
+ rewards.append(traj.cumulative_reward)
381
+ steps_used.append(traj.n_steps)
382
+ successes.append(1.0 if traj.success else 0.0)
383
+ policy.train()
384
+ eval_metrics = {
385
+ f"{label}/avg_grader_score": sum(scores) / max(len(scores), 1),
386
+ f"{label}/avg_cum_reward": sum(rewards) / max(len(rewards), 1),
387
+ f"{label}/avg_steps": sum(steps_used) / max(len(steps_used), 1),
388
+ f"{label}/success_rate": sum(successes) / max(len(successes), 1),
389
+ f"{label}/n_tasks": len(eval_tasks),
390
+ }
391
+ _log(eval_metrics, step)
392
+ eval_history.append({"label": label, "step": step, **eval_metrics})
393
+ return eval_metrics
394
+
395
+ def _record_rollout_sample(
396
+ *,
397
+ phase: str,
398
+ global_step: int,
399
+ task_id: TaskID,
400
+ trajectories: List[Trajectory],
401
+ advantages: List[float],
402
+ ) -> None:
403
+ # Keep evidence compact: one high-reward and one low-reward trace per task group.
404
+ if not trajectories:
405
+ return
406
+ scored = [
407
+ (trajectory_reward(t, cfg.grader_bonus), adv, t)
408
+ for t, adv in zip(trajectories, advantages)
409
+ ]
410
+ selected = [max(scored, key=lambda x: x[0]), min(scored, key=lambda x: x[0])]
411
+ seen = set()
412
+ for reward_value, advantage, traj in selected:
413
+ key = id(traj)
414
+ if key in seen:
415
+ continue
416
+ seen.add(key)
417
+ row = {
418
+ "phase": phase,
419
+ "step": global_step,
420
+ "task_id": task_id.value,
421
+ "trajectory_reward": reward_value,
422
+ "advantage": advantage,
423
+ "grader_score": traj.grader_score,
424
+ "cumulative_reward": traj.cumulative_reward,
425
+ "success": traj.success,
426
+ "n_steps": traj.n_steps,
427
+ "terminal_decision": traj.terminal_decision,
428
+ "actions": [s.completion_text[:500] for s in traj.steps],
429
+ "step_rewards": [s.reward for s in traj.steps],
430
+ }
431
+ sampled_rollouts.append(row)
432
+ _write_jsonl(samples_path, row)
433
+
434
+ def _write_plots() -> None:
435
+ try:
436
+ import matplotlib.pyplot as plt
437
+ except Exception as e:
438
+ print(f"[artifacts] plot generation skipped: {e}", flush=True)
439
+ return
440
+
441
+ if train_history:
442
+ xs = [r["step"] for r in train_history]
443
+ fig, ax1 = plt.subplots(figsize=(8, 4.5))
444
+ ax1.plot(xs, [r["train/group_reward_mean"] for r in train_history], label="group reward")
445
+ ax1.plot(xs, [r["train/group_grader_mean"] for r in train_history], label="grader score")
446
+ ax1.set_xlabel("training step")
447
+ ax1.set_ylabel("score")
448
+ ax1.set_title("InvoiceGuard training reward")
449
+ ax1.legend()
450
+ fig.tight_layout()
451
+ fig.savefig(artifact_dir / "training_reward_curve.png", dpi=160)
452
+ plt.close(fig)
453
+
454
+ fig, ax = plt.subplots(figsize=(8, 4.5))
455
+ ax.plot(xs, [r["train/loss"] for r in train_history], label="loss")
456
+ ax.plot(xs, [r["train/kl_loss"] for r in train_history], label="kl loss")
457
+ ax.set_xlabel("training step")
458
+ ax.set_ylabel("loss")
459
+ ax.set_title("InvoiceGuard GRPO losses")
460
+ ax.legend()
461
+ fig.tight_layout()
462
+ fig.savefig(artifact_dir / "training_loss_curve.png", dpi=160)
463
+ plt.close(fig)
464
+
465
+ eval_rows = [
466
+ r for r in eval_history
467
+ if any(k.endswith("/avg_grader_score") for k in r)
468
+ ]
469
+ if eval_rows:
470
+ xs = [r["step"] for r in eval_rows]
471
+ ys = []
472
+ labels = []
473
+ for r in eval_rows:
474
+ key = next(k for k in r if k.endswith("/avg_grader_score"))
475
+ labels.append(r["label"])
476
+ ys.append(r[key])
477
+ fig, ax = plt.subplots(figsize=(8, 4.5))
478
+ ax.plot(xs, ys, marker="o")
479
+ ax.set_xlabel("training step")
480
+ ax.set_ylabel("holdout grader score")
481
+ ax.set_title("InvoiceGuard holdout eval during training")
482
+ for x, y, label in zip(xs, ys, labels):
483
+ ax.annotate(label.replace("eval/", ""), (x, y), textcoords="offset points", xytext=(0, 6), ha="center")
484
+ fig.tight_layout()
485
+ fig.savefig(artifact_dir / "holdout_eval_curve.png", dpi=160)
486
+ plt.close(fig)
487
+
488
+ # ----- Initial eval --------------------------------------------------------
489
+ global_step = 0
490
+ print("\n=== initial eval (LoRA initialised but untrained) ===", flush=True)
491
+ evaluate("eval/init", global_step)
492
+
493
+ # ----- Training loop -------------------------------------------------------
494
+ t_start = time.time()
495
+ for it in range(cfg.num_iterations):
496
+ random.shuffle(train_tasks)
497
+
498
+ for ti, task_id in enumerate(train_tasks):
499
+ # 1. Sample G trajectories on the same task (group).
500
+ policy.eval()
501
+ trajectories: List[Trajectory] = []
502
+ for g in range(cfg.group_size):
503
+ traj = rollout_episode(
504
+ policy, tokenizer, env, task_id,
505
+ temperature=cfg.sample_temperature,
506
+ top_p=cfg.sample_top_p,
507
+ max_new_tokens=cfg.max_new_tokens,
508
+ device=device,
509
+ )
510
+ trajectories.append(traj)
511
+ policy.train()
512
+
513
+ # 2. Group-relative advantages.
514
+ advantages = compute_group_advantages(trajectories, cfg.grader_bonus)
515
+
516
+ # 3. PPO-clipped policy gradient on every (prompt, completion) pair,
517
+ # weighted by that trajectory's advantage, with KL vs. reference.
518
+ optimizer.zero_grad(set_to_none=True)
519
+ total_loss_val = 0.0
520
+ n_pairs = 0
521
+ kl_sum = 0.0
522
+ pg_sum = 0.0
523
+
524
+ for traj, adv in zip(trajectories, advantages):
525
+ if abs(adv) < 1e-8 or not traj.steps:
526
+ continue
527
+ for step in traj.steps:
528
+ # Current policy log-prob (with adapter active).
529
+ cur_lp = _completion_logprobs(
530
+ policy, step.prompt_ids, step.completion_ids, device
531
+ )
532
+ # Reference policy log-prob (adapter disabled).
533
+ with torch.no_grad():
534
+ with policy.disable_adapter():
535
+ ref_lp = _completion_logprobs(
536
+ policy, step.prompt_ids, step.completion_ids, device
537
+ )
538
+
539
+ # PPO-clipped surrogate. The "old" policy here is the same
540
+ # snapshot used to sample (we just rolled out moments ago),
541
+ # so on the first opt step the ratio == 1; the clip becomes
542
+ # active only across multiple opt steps per batch. We still
543
+ # apply it for stability when group_size is large.
544
+ log_ratio = cur_lp - ref_lp.detach() # tiny KL surrogate
545
+ ratio = torch.exp(cur_lp.detach() - ref_lp.detach())
546
+ unclipped = ratio * adv
547
+ clipped = torch.clamp(
548
+ ratio, 1.0 - cfg.ppo_clip, 1.0 + cfg.ppo_clip
549
+ ) * adv
550
+ pg_term = -torch.min(unclipped, clipped) * cur_lp / (cur_lp.detach().abs() + 1e-6)
551
+ # Equivalent to -adv * log_pi (REINFORCE-style) when ratio~1,
552
+ # but bounded for stability.
553
+
554
+ kl_term = cfg.kl_coef * (cur_lp - ref_lp.detach()).pow(2).mean()
555
+
556
+ loss = pg_term + kl_term
557
+ loss.backward()
558
+
559
+ total_loss_val += float(loss.detach().item())
560
+ pg_sum += float(pg_term.detach().item())
561
+ kl_sum += float(kl_term.detach().item())
562
+ n_pairs += 1
563
+
564
+ if n_pairs > 0:
565
+ torch.nn.utils.clip_grad_norm_(
566
+ [p for p in policy.parameters() if p.requires_grad],
567
+ cfg.grad_clip,
568
+ )
569
+ optimizer.step()
570
+
571
+ global_step += 1
572
+ group_rewards = [trajectory_reward(t, cfg.grader_bonus) for t in trajectories]
573
+ group_scores = [t.grader_score for t in trajectories]
574
+ train_metrics = {
575
+ "train/iter": it,
576
+ "train/task_idx": ti,
577
+ "train/task_id": task_id.value,
578
+ "train/group_reward_mean": sum(group_rewards) / len(group_rewards),
579
+ "train/group_reward_std":
580
+ (sum((r - sum(group_rewards) / len(group_rewards)) ** 2
581
+ for r in group_rewards) / len(group_rewards)) ** 0.5,
582
+ "train/group_grader_mean": sum(group_scores) / len(group_scores),
583
+ "train/group_success_rate": sum(1.0 if t.success else 0.0 for t in trajectories) / len(trajectories),
584
+ "train/avg_steps": sum(t.n_steps for t in trajectories) / len(trajectories),
585
+ "train/n_pairs": n_pairs,
586
+ "train/loss": total_loss_val / max(n_pairs, 1),
587
+ "train/pg_loss": pg_sum / max(n_pairs, 1),
588
+ "train/kl_loss": kl_sum / max(n_pairs, 1),
589
+ }
590
+ train_history.append({"step": global_step, **train_metrics})
591
+ _record_rollout_sample(
592
+ phase="train",
593
+ global_step=global_step,
594
+ task_id=task_id,
595
+ trajectories=trajectories,
596
+ advantages=advantages,
597
+ )
598
+ _log(
599
+ train_metrics,
600
+ global_step,
601
+ )
602
+
603
+ # End-of-iteration eval.
604
+ print(f"\n=== eval after iteration {it + 1}/{cfg.num_iterations} ===",
605
+ flush=True)
606
+ evaluate(f"eval/iter{it+1}", global_step)
607
+
608
+ total_wall_clock = time.time() - t_start
609
+ print(f"\n[done] total wall clock: {total_wall_clock:.1f}s", flush=True)
610
+
611
+ _write_plots()
612
+
613
+ summary = {
614
+ "run_started_at": run_started_at,
615
+ "run_finished_at": datetime.now(timezone.utc).isoformat(),
616
+ "base_model": cfg.base_model,
617
+ "hub_model_id": cfg.hub_model_id,
618
+ "num_iterations": cfg.num_iterations,
619
+ "group_size": cfg.group_size,
620
+ "train_tasks": [t.value for t in train_tasks],
621
+ "eval_tasks": [t.value for t in eval_tasks],
622
+ "wall_clock_s": round(total_wall_clock, 2),
623
+ "n_metric_rows": len(metrics_history),
624
+ "n_rollout_samples": len(sampled_rollouts),
625
+ "artifact_files": [
626
+ p.name for p in sorted(artifact_dir.iterdir()) if p.is_file()
627
+ ],
628
+ }
629
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
630
+ print(f"[artifacts] wrote {artifact_dir}", flush=True)
631
+
632
+ # ----- Push LoRA adapter ---------------------------------------------------
633
+ if cfg.push_to_hub and cfg.hub_username:
634
+ from huggingface_hub import HfApi
635
+
636
+ repo_id = f"{cfg.hub_username}/{cfg.hub_model_id}"
637
+ print(f"[push] pushing LoRA adapter to {repo_id}", flush=True)
638
+ policy.push_to_hub(repo_id, private=False)
639
+ tokenizer.push_to_hub(repo_id, private=False)
640
+ print(f"[push] uploading training artifacts to {repo_id}/training_artifacts", flush=True)
641
+ HfApi().upload_folder(
642
+ folder_path=str(artifact_dir),
643
+ repo_id=repo_id,
644
+ repo_type="model",
645
+ path_in_repo="training_artifacts",
646
+ commit_message="Add InvoiceGuard GRPO training artifacts",
647
+ )
648
+ print(f"[push] done -> https://huggingface.co/{repo_id}", flush=True)
649
+ else:
650
+ out_dir = Path(os.environ.get("OUTPUT_DIR", "/tmp/invoiceguard-grpo"))
651
+ out_dir.mkdir(parents=True, exist_ok=True)
652
+ policy.save_pretrained(out_dir)
653
+ tokenizer.save_pretrained(out_dir)
654
+ print(f"[save] LoRA adapter saved locally -> {out_dir}", flush=True)
655
+
656
+
657
+ # -----------------------------------------------------------------------------
658
+ # 7. CLI.
659
+ # -----------------------------------------------------------------------------
660
+
661
+ def _parse_args() -> TrainConfig:
662
+ p = argparse.ArgumentParser()
663
+ p.add_argument("--model-name", dest="base_model", default=None)
664
+ p.add_argument("--num-iterations", type=int, default=None)
665
+ p.add_argument("--group-size", type=int, default=None)
666
+ p.add_argument("--max-train-tasks", type=int, default=None)
667
+ p.add_argument("--lr", type=float, default=None)
668
+ p.add_argument("--no-push", action="store_true")
669
+ p.add_argument("--seed", type=int, default=None)
670
+ args = p.parse_args()
671
+
672
+ cfg = TrainConfig()
673
+ if args.base_model:
674
+ cfg.base_model = args.base_model
675
+ if args.num_iterations is not None:
676
+ cfg.num_iterations = args.num_iterations
677
+ if args.group_size is not None:
678
+ cfg.group_size = args.group_size
679
+ if args.max_train_tasks is not None:
680
+ cfg.max_train_tasks = args.max_train_tasks
681
+ if args.lr is not None:
682
+ cfg.lr = args.lr
683
+ if args.seed is not None:
684
+ cfg.seed = args.seed
685
+ if args.no_push:
686
+ cfg.push_to_hub = False
687
+ return cfg
688
+
689
+
690
+ if __name__ == "__main__":
691
+ cfg = _parse_args()
692
+ train(cfg)
uv.lock ADDED
The diff for this file is too large to render. See raw diff