Download server/invoice_guard_environment.py from piyush-mk/invoiceguard-code: direct link, hf CLI and curl.
- Browser
- Download file 34.7 kB
-
https://huggingface.co/piyush-mk/invoiceguard-code/resolve/main/server/invoice_guard_environment.py
- Command line
-
hf download hf://piyush-mk/invoiceguard-code/server/invoice_guard_environment.py
-
curl -L -o invoice_guard_environment.py https://huggingface.co/piyush-mk/invoiceguard-code/resolve/main/server/invoice_guard_environment.py
34.7 kB
| """ | |
| InvoiceGuard Environment -- Three-Way Invoice Matching Exception Resolution. | |
| An agent reviews one supplier invoice case per episode by comparing the | |
| invoice, purchase order, and goods receipt note, then resolves the case. | |
| """ | |
| from typing import Any, Optional | |
| from uuid import uuid4 | |
| from openenv.core.env_server.interfaces import Environment | |
| from openenv.core.env_server.types import State | |
| try: | |
| from ..models import ( | |
| ActionType, CaseData, DecisionType, Difficulty, ExceptionType, | |
| InvoiceGuardAction, InvoiceGuardObservation, InvoiceGuardState, TaskID, | |
| ) | |
| from ..tasks import get_task_case, TASK_LIST | |
| from ..graders import grade_episode | |
| except ImportError: | |
| from models import ( | |
| ActionType, CaseData, DecisionType, Difficulty, ExceptionType, | |
| InvoiceGuardAction, InvoiceGuardObservation, InvoiceGuardState, TaskID, | |
| ) | |
| from tasks import get_task_case, TASK_LIST | |
| from graders import grade_episode | |
| GOAL_TEXT = ( | |
| "You are an accounts payable analyst. Review this supplier invoice by " | |
| "comparing it against the purchase order (PO) and goods receipt note (GRN).\n" | |
| "\n" | |
| "INVESTIGATION ACTIONS (reveal information):\n" | |
| " inspect_purchase_order -- see what was ordered and at what price\n" | |
| " inspect_goods_receipt_note -- see what was received at the warehouse\n" | |
| " inspect_invoice_line_items -- see detailed invoice line items\n" | |
| " inspect_vendor_profile -- see vendor risk tier, duplicate history, escalation thresholds\n" | |
| " inspect_policy_rules -- see company tolerance thresholds and escalation rules\n" | |
| " check_for_duplicate_invoice -- search for previously processed invoices\n" | |
| " compare_quantity -- compare billed vs ordered vs received quantities\n" | |
| " compare_price -- compare billed price vs PO-agreed price\n" | |
| " compare_totals -- verify subtotal and total consistency\n" | |
| " summarize_findings -- list all findings collected so far\n" | |
| " propose_exception_type -- declare what type of exception you suspect\n" | |
| "\n" | |
| "RESOLUTION ACTION (ends the episode):\n" | |
| " submit_final_resolution -- requires: final_decision, exception_type, " | |
| "evidence_references, explanation\n" | |
| "\n" | |
| "DECISIONS:\n" | |
| " reject_invoice -- duplicate invoice or fraudulent submission detected\n" | |
| " escalate_for_supervisor_review -- price/total variance exceeds tolerance, " | |
| "or invoice exceeds high-value threshold\n" | |
| " place_on_hold -- billed quantity exceeds received quantity\n" | |
| " approve_for_payment -- all documents match within tolerance\n" | |
| "\n" | |
| "Investigate thoroughly, then submit your resolution with evidence." | |
| ) | |
| ALL_ACTIONS = [a.value for a in ActionType] | |
| # Reward constants | |
| R_NEW_DOCUMENT = 0.05 | |
| R_USEFUL_COMPARISON = 0.08 | |
| R_CONFIRM_NO_ISSUE = 0.03 | |
| R_CORRECT_PROPOSAL = 0.10 | |
| R_SUMMARIZE = 0.02 | |
| R_REPEAT_PENALTY = -0.02 | |
| R_INVALID_ACTION = -0.05 | |
| R_TIMEOUT_PENALTY = -0.10 | |
| # -- Round 2 anti-gaming constants ------------------------------------------ | |
| # Submitting a final resolution before doing any real investigation is a | |
| # common shortcut. Penalize it heavily so RL training cannot exploit it. | |
| R_SHORTCUT_PENALTY = -0.20 | |
| # Minimum distinct documents the agent must have revealed before submit | |
| # is considered "investigated". Below this, R_SHORTCUT_PENALTY applies. | |
| MIN_DOCS_BEFORE_SUBMIT = 2 | |
| # Escalating per-action repeat penalty (applied AFTER the first occurrence). | |
| # index 0 = first repeat (= 2nd occurrence overall), 1 = 2nd repeat, etc. | |
| REPEAT_PENALTY_LADDER = [-0.02, -0.05, -0.10, -0.15] | |
| def _ladder_penalty(repeat_idx: int) -> float: | |
| """Return escalating repeat penalty; clamps to last rung.""" | |
| if repeat_idx <= 0: | |
| return 0.0 | |
| i = min(repeat_idx - 1, len(REPEAT_PENALTY_LADDER) - 1) | |
| return REPEAT_PENALTY_LADDER[i] | |
| class InvoiceGuardEnvironment(Environment): | |
| """ | |
| Three-Way Invoice Matching Exception Resolution Environment. | |
| Each episode presents one synthetic invoice case. The agent investigates | |
| by inspecting documents and comparing fields, then submits a resolution. | |
| """ | |
| SUPPORTS_CONCURRENT_SESSIONS: bool = True | |
| def __init__(self): | |
| self._case: Optional[CaseData] = None | |
| self._env_state = InvoiceGuardState() | |
| self._task_index = 0 | |
| def reset( | |
| self, | |
| seed: Optional[int] = None, | |
| episode_id: Optional[str] = None, | |
| **kwargs: Any, | |
| ) -> InvoiceGuardObservation: | |
| task_id_str = kwargs.get("task_id") | |
| if task_id_str: | |
| task_id = TaskID(task_id_str) | |
| else: | |
| task_id = TASK_LIST[self._task_index % len(TASK_LIST)] | |
| self._task_index += 1 | |
| self._case = get_task_case(task_id) | |
| c = self._case | |
| self._env_state = InvoiceGuardState( | |
| episode_id=episode_id or str(uuid4()), | |
| step_count=0, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| case_id=c.case_id, | |
| max_steps=c.max_steps, | |
| ) | |
| inv = c.invoice | |
| summary = ( | |
| f"Supplier: {inv.supplier_name} ({inv.supplier_id}), " | |
| f"Invoice: {inv.invoice_number}, Date: {inv.invoice_date}, " | |
| f"PO Ref: {inv.po_reference}, Total: ${inv.total_amount:,.2f}" | |
| ) | |
| if inv.note: | |
| summary += f"\nInvoice note: {inv.note}" | |
| initial_suggestions = [ | |
| "inspect_purchase_order", | |
| "inspect_goods_receipt_note", | |
| "inspect_invoice_line_items", | |
| ] | |
| return InvoiceGuardObservation( | |
| case_id=c.case_id, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| invoice_summary=summary, | |
| goal=GOAL_TEXT, | |
| available_actions=ALL_ACTIONS, | |
| suggested_next_actions=initial_suggestions, | |
| revealed_documents=[], | |
| findings=[], | |
| remaining_steps=c.max_steps, | |
| last_action_result="Case loaded. Begin your investigation.", | |
| last_action_error=False, | |
| warnings=[], | |
| done=False, | |
| reward=0.0, | |
| ) | |
| def step( | |
| self, | |
| action: InvoiceGuardAction, | |
| timeout_s: Optional[float] = None, | |
| **kwargs: Any, | |
| ) -> InvoiceGuardObservation: | |
| if self._case is None: | |
| return self._error_obs("Environment not initialized. Call reset() first.") | |
| s = self._env_state | |
| c = self._case | |
| if s.is_finalized: | |
| return self._error_obs("Episode already finalized.") | |
| s.step_count += 1 | |
| action_name = action.action_type.value | |
| s.actions_taken.append(action_name) | |
| s.repeated_action_counts[action_name] = ( | |
| s.repeated_action_counts.get(action_name, 0) + 1 | |
| ) | |
| # Check step budget | |
| remaining = c.max_steps - s.step_count | |
| if remaining < 0: | |
| return self._finalize_timeout() | |
| # Route action | |
| reward = 0.0 | |
| is_repeat = s.repeated_action_counts[action_name] > 1 | |
| terminal = False | |
| if action_name == ActionType.submit_final_resolution.value: | |
| return self._handle_submit(action, remaining) | |
| elif action_name == ActionType.propose_exception_type.value: | |
| result, reward = self._handle_propose_exception(action, is_repeat) | |
| elif action_name in _INVESTIGATION_HANDLERS: | |
| result, reward = _INVESTIGATION_HANDLERS[action_name](self, is_repeat) | |
| else: | |
| result = f"Unknown action: {action_name}" | |
| reward = R_INVALID_ACTION | |
| base_reward = reward | |
| penalties: dict = {} | |
| if is_repeat and action_name != ActionType.submit_final_resolution.value: | |
| repeat_idx = s.repeated_action_counts[action_name] - 1 | |
| ladder = _ladder_penalty(repeat_idx) | |
| penalties["repeat"] = ladder | |
| reward = min(reward, ladder) | |
| s.cumulative_reward += reward | |
| self._log_component( | |
| action=action_name, | |
| base=base_reward, | |
| penalties=penalties, | |
| total=reward, | |
| reason=("repeat" if is_repeat else "step"), | |
| ) | |
| remaining = c.max_steps - s.step_count | |
| if remaining <= 0 and not s.is_finalized: | |
| return self._finalize_timeout() | |
| return InvoiceGuardObservation( | |
| case_id=c.case_id, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| invoice_summary=self._invoice_summary(), | |
| goal=GOAL_TEXT, | |
| available_actions=ALL_ACTIONS, | |
| suggested_next_actions=self._suggest_next_actions(remaining), | |
| revealed_documents=list(s.documents_revealed), | |
| findings=list(s.findings_collected), | |
| remaining_steps=remaining, | |
| last_action_result=result, | |
| last_action_error=False, | |
| warnings=self._build_warnings(remaining), | |
| done=False, | |
| reward=reward, | |
| ) | |
| def state(self) -> InvoiceGuardState: | |
| return self._env_state | |
| # -- Terminal handlers ------------------------------------------------ | |
| def _handle_submit( | |
| self, action: InvoiceGuardAction, remaining: int | |
| ) -> InvoiceGuardObservation: | |
| s = self._env_state | |
| c = self._case | |
| if not action.final_decision: | |
| s.cumulative_reward += R_INVALID_ACTION | |
| error_msg = ( | |
| "ERROR: submit_final_resolution requires all of these fields:\n" | |
| ' "final_decision": one of "approve_for_payment", ' | |
| '"place_on_hold", "reject_invoice", "escalate_for_supervisor_review"\n' | |
| ' "exception_type": one of "clean_match", "quantity_mismatch", ' | |
| '"price_mismatch", "partial_receipt", "duplicate_invoice", ' | |
| '"total_amount_mismatch", "missing_receipt", "tax_variance", ' | |
| '"policy_violation", "mixed_discrepancy"\n' | |
| ' "evidence_references": list of action names you performed\n' | |
| ' "explanation": one-sentence justification\n' | |
| "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.\"}" | |
| ) | |
| return InvoiceGuardObservation( | |
| case_id=c.case_id, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| invoice_summary=self._invoice_summary(), | |
| goal=GOAL_TEXT, | |
| available_actions=ALL_ACTIONS, | |
| revealed_documents=list(s.documents_revealed), | |
| findings=list(s.findings_collected), | |
| remaining_steps=remaining, | |
| last_action_result=error_msg, | |
| last_action_error=True, | |
| warnings=self._build_warnings(remaining), | |
| done=False, | |
| reward=R_INVALID_ACTION, | |
| ) | |
| s.is_finalized = True | |
| s.final_decision = action.final_decision.value | |
| s.final_exception_type = ( | |
| action.exception_type.value if action.exception_type else None | |
| ) | |
| s.final_evidence = list(action.evidence_references) | |
| s.final_explanation = action.explanation | |
| s.final_confidence = action.confidence | |
| # Anti-gaming: punish "submit-without-investigation" shortcut. | |
| shortcut_penalty = 0.0 | |
| if len(s.documents_revealed) < MIN_DOCS_BEFORE_SUBMIT: | |
| shortcut_penalty = R_SHORTCUT_PENALTY | |
| grader_result = grade_episode(c, s) | |
| confidence_bonus = 0.0 | |
| if action.confidence is not None: | |
| is_correct = (s.final_decision == c.ground_truth.correct_decision.value) | |
| if is_correct and action.confidence >= 0.8: | |
| confidence_bonus = 0.05 | |
| elif not is_correct and action.confidence >= 0.8: | |
| confidence_bonus = -0.05 | |
| terminal_reward = grader_result.score + confidence_bonus + shortcut_penalty | |
| s.cumulative_reward += terminal_reward | |
| self._log_component( | |
| action=ActionType.submit_final_resolution.value, | |
| base=grader_result.score, | |
| penalties={ | |
| **({"shortcut": shortcut_penalty} if shortcut_penalty else {}), | |
| **({"confidence": confidence_bonus} if confidence_bonus else {}), | |
| }, | |
| total=terminal_reward, | |
| reason="terminal_submit", | |
| ) | |
| return InvoiceGuardObservation( | |
| case_id=c.case_id, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| invoice_summary=self._invoice_summary(), | |
| goal=GOAL_TEXT, | |
| available_actions=[], | |
| revealed_documents=list(s.documents_revealed), | |
| findings=list(s.findings_collected), | |
| remaining_steps=0, | |
| last_action_result=( | |
| f"Case resolved. Decision: {s.final_decision}. " | |
| f"Grader score: {grader_result.score:.4f}" | |
| ), | |
| last_action_error=False, | |
| warnings=[], | |
| done=True, | |
| reward=max(terminal_reward, 0.0), | |
| grader_result=grader_result.model_dump(), | |
| metadata={ | |
| "grader_result": grader_result.model_dump(), | |
| "cumulative_reward": s.cumulative_reward, | |
| "reward_components": list(s.reward_components), | |
| "shortcut_penalty_applied": shortcut_penalty != 0.0, | |
| "documents_revealed_count": len(s.documents_revealed), | |
| }, | |
| ) | |
| def _finalize_timeout(self) -> InvoiceGuardObservation: | |
| s = self._env_state | |
| c = self._case | |
| s.is_finalized = True | |
| grader_result = grade_episode(c, s) | |
| s.cumulative_reward += grader_result.score + R_TIMEOUT_PENALTY | |
| return InvoiceGuardObservation( | |
| case_id=c.case_id, | |
| task_id=c.task_id.value, | |
| difficulty=c.difficulty.value, | |
| invoice_summary=self._invoice_summary(), | |
| goal=GOAL_TEXT, | |
| available_actions=[], | |
| revealed_documents=list(s.documents_revealed), | |
| findings=list(s.findings_collected), | |
| remaining_steps=0, | |
| last_action_result="Step budget exhausted. Episode ended without resolution.", | |
| last_action_error=True, | |
| warnings=[], | |
| done=True, | |
| reward=max(grader_result.score + R_TIMEOUT_PENALTY, 0.0), | |
| grader_result=grader_result.model_dump(), | |
| metadata={ | |
| "grader_result": grader_result.model_dump(), | |
| "cumulative_reward": s.cumulative_reward, | |
| "reward_components": list(s.reward_components), | |
| "timeout": True, | |
| }, | |
| ) | |
| def _handle_propose_exception( | |
| self, action: InvoiceGuardAction, is_repeat: bool | |
| ) -> tuple: | |
| s = self._env_state | |
| c = self._case | |
| if not action.exception_type: | |
| return "ERROR: propose_exception_type requires exception_type.", R_INVALID_ACTION | |
| s.proposed_exception = action.exception_type.value | |
| correct = c.ground_truth.correct_exception_type.value | |
| if s.proposed_exception == correct: | |
| finding = f"Proposed exception type: {s.proposed_exception} (noted)." | |
| return finding, R_CORRECT_PROPOSAL if not is_repeat else R_REPEAT_PENALTY | |
| else: | |
| finding = f"Proposed exception type: {s.proposed_exception} (noted)." | |
| return finding, 0.0 | |
| # -- Investigation handlers ------------------------------------------- | |
| def _inspect_invoice_line_items(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| s = self._env_state | |
| doc = "invoice_line_items" | |
| lines = [] | |
| for li in c.invoice.line_items: | |
| lines.append( | |
| f" {li.item_code}: {li.description} | " | |
| f"Qty: {li.quantity_billed} | " | |
| f"Unit Price: ${li.unit_price_billed:,.2f} | " | |
| f"Line Total: ${li.line_total_billed:,.2f}" | |
| ) | |
| detail = ( | |
| f"Invoice {c.invoice.invoice_number} line items:\n" | |
| + "\n".join(lines) | |
| + f"\nSubtotal: ${c.invoice.subtotal:,.2f} | " | |
| f"Tax: ${c.invoice.tax:,.2f} | " | |
| f"Total: ${c.invoice.total_amount:,.2f}" | |
| ) | |
| if c.invoice.note: | |
| detail += f"\nInvoice note: {c.invoice.note}" | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _inspect_purchase_order(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| s = self._env_state | |
| doc = "purchase_order" | |
| lines = [] | |
| for li in c.purchase_order.line_items: | |
| lines.append( | |
| f" {li.item_code}: {li.description} | " | |
| f"Ordered Qty: {li.ordered_quantity} | " | |
| f"Unit Price: ${li.unit_price_ordered:,.2f} | " | |
| f"Line Total: ${li.line_total_ordered:,.2f}" | |
| ) | |
| detail = ( | |
| f"PO {c.purchase_order.po_number} " | |
| f"(Supplier: {c.purchase_order.supplier_id}, " | |
| f"Date: {c.purchase_order.order_date}):\n" | |
| + "\n".join(lines) | |
| + f"\nApproved: {c.purchase_order.approved} | " | |
| f"Payment Terms: {c.purchase_order.payment_terms}" | |
| ) | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _inspect_goods_receipt_note(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| doc = "goods_receipt_note" | |
| lines = [] | |
| for li in c.goods_receipt_note.line_items: | |
| lines.append( | |
| f" {li.item_code}: " | |
| f"Received: {li.quantity_received} | " | |
| f"Accepted: {li.accepted_quantity} | " | |
| f"Rejected: {li.rejected_quantity}" | |
| ) | |
| detail = ( | |
| f"GRN {c.goods_receipt_note.grn_number} " | |
| f"(PO Ref: {c.goods_receipt_note.po_reference}, " | |
| f"Date: {c.goods_receipt_note.receipt_date}):\n" | |
| + "\n".join(lines) | |
| ) | |
| if c.goods_receipt_note.warehouse_note: | |
| detail += f"\nWarehouse note: {c.goods_receipt_note.warehouse_note}" | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _inspect_vendor_profile(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| doc = "vendor_profile" | |
| vp = c.vendor_profile | |
| detail = ( | |
| f"Vendor Profile -- {vp.supplier_name} ({vp.supplier_id}):\n" | |
| f" Risk Tier: {vp.risk_tier}\n" | |
| f" Preferred Vendor: {vp.preferred_vendor}\n" | |
| f" Duplicate Risk History: {vp.duplicate_risk_count} incidents" | |
| ) | |
| if vp.tolerance_override is not None: | |
| detail += f"\n Tolerance Override: {vp.tolerance_override}%" | |
| if vp.escalation_threshold is not None: | |
| detail += f"\n Escalation Threshold: ${vp.escalation_threshold:,.2f}" | |
| inv_total = c.invoice.total_amount | |
| if inv_total > vp.escalation_threshold: | |
| detail += ( | |
| f"\n NOTE: Invoice total ${inv_total:,.2f} exceeds vendor " | |
| f"escalation threshold ${vp.escalation_threshold:,.2f}." | |
| ) | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _inspect_policy_rules(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| doc = "policy_rules" | |
| p = c.company_policy | |
| detail = ( | |
| f"Company Matching Policy:\n" | |
| f" Quantity Tolerance: {p.quantity_tolerance_pct}%\n" | |
| f" Price Tolerance: {p.price_tolerance_pct}%\n" | |
| f" Total Amount Tolerance: ${p.total_tolerance_amt:,.2f}\n" | |
| f" Duplicate Check: {'Enabled' if p.duplicate_check_enabled else 'Disabled'}\n" | |
| f" High-Value Review Threshold: ${p.high_value_threshold:,.2f}\n" | |
| f" Mandatory Escalation Above: ${p.mandatory_escalation_above:,.2f}\n" | |
| f"\n" | |
| f" Resolution Rules:\n" | |
| f" - APPROVE: All matches within tolerance, no duplicates, no policy violations.\n" | |
| f" - HOLD: Billed quantity exceeds received quantity (partial/missing receipt).\n" | |
| f" - ESCALATE: Price or total variance exceeds tolerance. Invoice above high-value threshold.\n" | |
| f" - REJECT: Duplicate invoice detected. Fraudulent or invalid submission." | |
| ) | |
| inv_total = c.invoice.total_amount | |
| if inv_total >= p.high_value_threshold: | |
| detail += ( | |
| f"\n\n NOTE: Invoice total ${inv_total:,.2f} exceeds " | |
| f"high-value threshold ${p.high_value_threshold:,.2f}. " | |
| f"Supervisor review required." | |
| ) | |
| self._add_finding( | |
| f"High-value invoice: ${inv_total:,.2f} exceeds " | |
| f"review threshold ${p.high_value_threshold:,.2f}." | |
| ) | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _check_for_duplicate_invoice(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| doc = "duplicate_check" | |
| ch = c.case_history | |
| if ch.processed_invoice_numbers or ch.similar_invoice_refs: | |
| refs = ch.similar_invoice_refs or ch.processed_invoice_numbers | |
| detail = ( | |
| f"Duplicate check for {c.invoice.invoice_number}:\n" | |
| f" Similar/processed invoices found: {', '.join(refs)}\n" | |
| f" Prior comments: {'; '.join(ch.prior_comments) if ch.prior_comments else 'None'}\n" | |
| f" Pending invoices: {', '.join(ch.pending_invoice_numbers) if ch.pending_invoice_numbers else 'None'}" | |
| ) | |
| finding = ( | |
| f"ALERT: Potential duplicate detected. " | |
| f"Previously processed invoices: {', '.join(refs)}." | |
| ) | |
| self._add_finding(finding) | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, max(reward, R_USEFUL_COMPARISON) | |
| else: | |
| detail = ( | |
| f"Duplicate check for {c.invoice.invoice_number}: " | |
| f"No similar or duplicate invoices found in case history." | |
| ) | |
| reward = self._reveal_doc(doc, detail) | |
| return detail, reward | |
| def _compare_quantity(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| results = [] | |
| has_discrepancy = False | |
| for inv_li in c.invoice.line_items: | |
| po_qty = None | |
| grn_qty = None | |
| for po_li in c.purchase_order.line_items: | |
| if po_li.item_code == inv_li.item_code: | |
| po_qty = po_li.ordered_quantity | |
| break | |
| for grn_li in c.goods_receipt_note.line_items: | |
| if grn_li.item_code == inv_li.item_code: | |
| grn_qty = grn_li.accepted_quantity | |
| break | |
| line = ( | |
| f" {inv_li.item_code}: " | |
| f"Billed={inv_li.quantity_billed}, " | |
| f"Ordered={po_qty}, " | |
| f"Received={grn_qty}" | |
| ) | |
| if grn_qty is not None and inv_li.quantity_billed > grn_qty: | |
| diff = inv_li.quantity_billed - grn_qty | |
| pct = (diff / inv_li.quantity_billed) * 100 | |
| line += f" -> DISCREPANCY: Billed exceeds received by {diff:.0f} units ({pct:.1f}%)" | |
| has_discrepancy = True | |
| self._add_finding( | |
| f"Quantity discrepancy on {inv_li.item_code}: " | |
| f"billed {inv_li.quantity_billed} but only {grn_qty} received " | |
| f"(difference: {diff:.0f} units, {pct:.1f}%)." | |
| ) | |
| elif po_qty is not None and inv_li.quantity_billed > po_qty: | |
| diff = inv_li.quantity_billed - po_qty | |
| line += f" -> DISCREPANCY: Billed exceeds ordered by {diff:.0f} units" | |
| has_discrepancy = True | |
| self._add_finding( | |
| f"Quantity discrepancy on {inv_li.item_code}: " | |
| f"billed {inv_li.quantity_billed} but only {po_qty} ordered." | |
| ) | |
| else: | |
| line += " -> OK" | |
| results.append(line) | |
| detail = "Quantity comparison:\n" + "\n".join(results) | |
| reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE | |
| return detail, reward | |
| def _compare_price(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| results = [] | |
| has_discrepancy = False | |
| tolerance = c.company_policy.price_tolerance_pct | |
| for inv_li in c.invoice.line_items: | |
| po_price = None | |
| for po_li in c.purchase_order.line_items: | |
| if po_li.item_code == inv_li.item_code: | |
| po_price = po_li.unit_price_ordered | |
| break | |
| if po_price is not None: | |
| line = ( | |
| f" {inv_li.item_code}: " | |
| f"Billed=${inv_li.unit_price_billed:,.2f}, " | |
| f"PO Price=${po_price:,.2f}" | |
| ) | |
| else: | |
| line = ( | |
| f" {inv_li.item_code}: " | |
| f"Billed=${inv_li.unit_price_billed:,.2f}, " | |
| f"PO Price=N/A" | |
| ) | |
| if po_price is not None and po_price > 0: | |
| variance_pct = ( | |
| (inv_li.unit_price_billed - po_price) / po_price | |
| ) * 100 | |
| if abs(variance_pct) > tolerance: | |
| line += ( | |
| f" -> DISCREPANCY: {variance_pct:+.1f}% variance " | |
| f"(exceeds {tolerance}% tolerance)" | |
| ) | |
| has_discrepancy = True | |
| self._add_finding( | |
| f"Price discrepancy on {inv_li.item_code}: " | |
| f"billed ${inv_li.unit_price_billed:,.2f} vs " | |
| f"PO ${po_price:,.2f} " | |
| f"({variance_pct:+.1f}% variance, " | |
| f"tolerance is {tolerance}%)." | |
| ) | |
| self._add_finding( | |
| f"POLICY: Price variance exceeding {tolerance}% tolerance " | |
| f"requires escalation for supervisor review." | |
| ) | |
| else: | |
| line += f" -> Within tolerance ({variance_pct:+.1f}%)" | |
| results.append(line) | |
| detail = "Price comparison:\n" + "\n".join(results) | |
| reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE | |
| return detail, reward | |
| def _compare_totals(self, is_repeat: bool) -> tuple: | |
| c = self._case | |
| inv = c.invoice | |
| expected_subtotal = sum(li.line_total_billed for li in inv.line_items) | |
| po_total = sum(li.line_total_ordered for li in c.purchase_order.line_items) | |
| tolerance_amt = c.company_policy.total_tolerance_amt | |
| subtotal_ok = abs(inv.subtotal - expected_subtotal) < 0.01 | |
| total_diff = inv.subtotal - po_total | |
| within_tolerance = abs(total_diff) <= tolerance_amt | |
| lines = [ | |
| f" Invoice subtotal: ${inv.subtotal:,.2f} (line items sum: ${expected_subtotal:,.2f}) " | |
| f"-> {'Consistent' if subtotal_ok else 'INCONSISTENT'}", | |
| f" Invoice subtotal vs PO total: ${inv.subtotal:,.2f} vs ${po_total:,.2f} " | |
| f"(diff: ${total_diff:+,.2f}) -> " | |
| f"{'Within' if within_tolerance else 'EXCEEDS'} tolerance (${tolerance_amt:,.2f})", | |
| f" Tax: ${inv.tax:,.2f} | Grand Total: ${inv.total_amount:,.2f}", | |
| ] | |
| has_discrepancy = not subtotal_ok or not within_tolerance | |
| if has_discrepancy: | |
| if not subtotal_ok: | |
| self._add_finding( | |
| f"Total inconsistency: invoice subtotal ${inv.subtotal:,.2f} " | |
| f"does not match line item sum ${expected_subtotal:,.2f}." | |
| ) | |
| if not within_tolerance: | |
| self._add_finding( | |
| f"Total discrepancy: invoice subtotal ${inv.subtotal:,.2f} vs " | |
| f"PO total ${po_total:,.2f} (difference ${total_diff:+,.2f} " | |
| f"exceeds tolerance ${tolerance_amt:,.2f})." | |
| ) | |
| self._add_finding( | |
| "POLICY: Total amount variance exceeding tolerance " | |
| "requires escalation for supervisor review." | |
| ) | |
| detail = "Totals comparison:\n" + "\n".join(lines) | |
| reward = R_USEFUL_COMPARISON if has_discrepancy else R_CONFIRM_NO_ISSUE | |
| return detail, reward | |
| def _summarize_findings(self, is_repeat: bool) -> tuple: | |
| s = self._env_state | |
| if not s.findings_collected: | |
| detail = "No findings collected yet. Investigate further before summarizing." | |
| return detail, 0.0 | |
| summary_lines = [f" {i+1}. {f}" for i, f in enumerate(s.findings_collected)] | |
| detail = ( | |
| f"Summary of findings ({len(s.findings_collected)} items):\n" | |
| + "\n".join(summary_lines) | |
| + f"\nDocuments reviewed: {', '.join(s.documents_revealed) if s.documents_revealed else 'None'}" | |
| ) | |
| return detail, R_SUMMARIZE | |
| # -- Helpers ---------------------------------------------------------- | |
| def _reveal_doc(self, doc_name: str, detail: str) -> float: | |
| s = self._env_state | |
| if doc_name not in s.documents_revealed: | |
| s.documents_revealed.append(doc_name) | |
| s.findings_collected.append(detail) | |
| return R_NEW_DOCUMENT | |
| return R_REPEAT_PENALTY | |
| def _add_finding(self, finding: str) -> None: | |
| s = self._env_state | |
| if finding not in s.findings_collected: | |
| s.findings_collected.append(finding) | |
| def _log_component( | |
| self, | |
| action: str, | |
| base: float, | |
| penalties: dict, | |
| total: float, | |
| reason: str, | |
| ) -> None: | |
| """Append a per-step reward breakdown for transparency / training signals.""" | |
| s = self._env_state | |
| s.reward_components.append( | |
| { | |
| "step": s.step_count, | |
| "action": action, | |
| "base": round(float(base), 4), | |
| "penalties": {k: round(float(v), 4) for k, v in penalties.items()}, | |
| "total": round(float(total), 4), | |
| "reason": reason, | |
| } | |
| ) | |
| def _invoice_summary(self) -> str: | |
| if self._case is None: | |
| return "" | |
| inv = self._case.invoice | |
| return ( | |
| f"Supplier: {inv.supplier_name} ({inv.supplier_id}), " | |
| f"Invoice: {inv.invoice_number}, Date: {inv.invoice_date}, " | |
| f"PO Ref: {inv.po_reference}, Total: ${inv.total_amount:,.2f}" | |
| ) | |
| def _suggest_next_actions(self, remaining: int) -> list: | |
| """Suggest the most useful next actions based on investigation progress.""" | |
| s = self._env_state | |
| taken = set(s.actions_taken) | |
| suggestions = [] | |
| if remaining <= 2: | |
| suggestions.append("submit_final_resolution") | |
| return suggestions | |
| core_docs = [ | |
| "inspect_purchase_order", | |
| "inspect_goods_receipt_note", | |
| "inspect_invoice_line_items", | |
| ] | |
| for a in core_docs: | |
| if a not in taken: | |
| suggestions.append(a) | |
| comparisons = ["compare_quantity", "compare_price", "compare_totals"] | |
| docs_seen = set(s.documents_revealed) | |
| has_po = "purchase_order" in docs_seen | |
| has_grn = "goods_receipt_note" in docs_seen | |
| if has_po and has_grn: | |
| for a in comparisons: | |
| if a not in taken: | |
| suggestions.append(a) | |
| secondary = [ | |
| "inspect_policy_rules", | |
| "check_for_duplicate_invoice", | |
| "inspect_vendor_profile", | |
| ] | |
| for a in secondary: | |
| if a not in taken: | |
| suggestions.append(a) | |
| if not suggestions: | |
| if "summarize_findings" not in taken: | |
| suggestions.append("summarize_findings") | |
| suggestions.append("submit_final_resolution") | |
| return suggestions | |
| def _build_warnings(self, remaining: int) -> list: | |
| warnings = [] | |
| s = self._env_state | |
| if remaining <= 2: | |
| warnings.append( | |
| f"CRITICAL: Only {remaining} step(s) remaining! " | |
| f"You MUST submit_final_resolution NOW." | |
| ) | |
| elif remaining <= 4 and not s.is_finalized: | |
| warnings.append( | |
| f"WARNING: {remaining} steps remaining. " | |
| f"Submit your final resolution soon." | |
| ) | |
| return warnings | |
| def _error_obs(self, message: str) -> InvoiceGuardObservation: | |
| return InvoiceGuardObservation( | |
| last_action_result=f"ERROR: {message}", | |
| last_action_error=True, | |
| done=True, | |
| reward=0.0, | |
| ) | |
| # Handler dispatch table -- maps action type strings to bound methods. | |
| # Built outside the class to keep the routing clean. | |
| _INVESTIGATION_HANDLERS = { | |
| ActionType.inspect_invoice_line_items.value: InvoiceGuardEnvironment._inspect_invoice_line_items, | |
| ActionType.inspect_purchase_order.value: InvoiceGuardEnvironment._inspect_purchase_order, | |
| ActionType.inspect_goods_receipt_note.value: InvoiceGuardEnvironment._inspect_goods_receipt_note, | |
| ActionType.inspect_vendor_profile.value: InvoiceGuardEnvironment._inspect_vendor_profile, | |
| ActionType.inspect_policy_rules.value: InvoiceGuardEnvironment._inspect_policy_rules, | |
| ActionType.check_for_duplicate_invoice.value: InvoiceGuardEnvironment._check_for_duplicate_invoice, | |
| ActionType.compare_quantity.value: InvoiceGuardEnvironment._compare_quantity, | |
| ActionType.compare_price.value: InvoiceGuardEnvironment._compare_price, | |
| ActionType.compare_totals.value: InvoiceGuardEnvironment._compare_totals, | |
| ActionType.summarize_findings.value: InvoiceGuardEnvironment._summarize_findings, | |
| } | |