Sync InvoiceGuard code for GRPO training job
Browse files- .openenvignore +10 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +46 -0
- Dockerfile +80 -0
- README.md +248 -0
- __init__.py +16 -0
- client.py +77 -0
- eval_round2.py +252 -0
- graders/__init__.py +1 -0
- graders/scoring.py +279 -0
- inference.py +506 -0
- models.py +287 -0
- openenv.yaml +8 -0
- pyproject.toml +38 -0
- server/__init__.py +11 -0
- server/app.py +43 -0
- server/invoice_guard_environment.py +869 -0
- tasks/__init__.py +2 -0
- tasks/definitions.py +2104 -0
- tasks/hard_definitions.py +1109 -0
- test_live_space.py +238 -0
- tests/__init__.py +0 -0
- tests/test_environment.py +219 -0
- tests/test_grader.py +240 -0
- tests/test_models.py +158 -0
- training/README.md +101 -0
- training/__init__.py +1 -0
- training/launch_hf_job.py +127 -0
- training/rollout.py +139 -0
- training/train_grpo.py +692 -0
- uv.lock +0 -0
.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
|
|
|