Spaces:
Sleeping
Sleeping
fix: add server module, pyproject.toml scripts, uv.lock
Browse files- 0.2.0) +0 -0
- README.md +10 -0
- app.py +14 -8
- inference.py +67 -9
- openenv +0 -0
- pyproject.toml +31 -0
- server/__init__.py +1 -0
- server/app.py +25 -0
- uv.lock +0 -0
0.2.0)
ADDED
|
File without changes
|
README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Invoice Exception Handler — OpenEnv
|
| 2 |
|
| 3 |
> An AI agent learning environment that simulates accounts payable exception handling.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Invoice Exception Handler
|
| 3 |
+
emoji: 🧾
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
# Invoice Exception Handler — OpenEnv
|
| 12 |
|
| 13 |
> An AI agent learning environment that simulates accounts payable exception handling.
|
app.py
CHANGED
|
@@ -7,6 +7,7 @@ interactive Gradio UI (for judges and exploration) on port 7860.
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import json
|
|
|
|
| 10 |
from typing import Any, Dict, Optional
|
| 11 |
|
| 12 |
import gradio as gr
|
|
@@ -21,6 +22,7 @@ from env import InvoiceExceptionEnv, Action, ActionType, ALL_TASKS
|
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
env = InvoiceExceptionEnv(seed=42)
|
|
|
|
| 24 |
|
| 25 |
# ---------------------------------------------------------------------------
|
| 26 |
# FastAPI server
|
|
@@ -32,28 +34,32 @@ api = FastAPI(title="Invoice Exception Handler OpenEnv", version="1.0.0")
|
|
| 32 |
@api.post("/reset")
|
| 33 |
async def http_reset(body: dict = {}) -> JSONResponse:
|
| 34 |
"""Reset the environment. Optionally specify task_id."""
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
@api.post("/step")
|
| 41 |
async def http_step(body: dict = {}) -> JSONResponse:
|
| 42 |
"""Execute one action."""
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
@api.get("/state")
|
| 48 |
async def http_state() -> JSONResponse:
|
| 49 |
"""Return the current state without advancing."""
|
| 50 |
-
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
@api.post("/grade")
|
| 54 |
async def http_grade() -> JSONResponse:
|
| 55 |
"""Grade the current episode."""
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
@api.get("/tasks")
|
|
@@ -181,7 +187,7 @@ def run_demo(task_name: str) -> str:
|
|
| 181 |
Action.run_check("tax_calculation_verify"),
|
| 182 |
Action.cross_check("tax_amount", "invoice", "payment_history"),
|
| 183 |
Action.query_internal("finance", "Can you confirm the overpayment on INV-2024-819?"),
|
| 184 |
-
Action.query_supplier("Please clarify the relationship between INV-2024-891 and INV-2024-819.", "
|
| 185 |
Action.apply_rule("partial_approval"),
|
| 186 |
Action.apply_rule("credit_note_request"),
|
| 187 |
Action.make_decision("partial_approve", "Duplicate detected. Tax error on original. Approve only 3,240 INR correction."),
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import json
|
| 10 |
+
import threading
|
| 11 |
from typing import Any, Dict, Optional
|
| 12 |
|
| 13 |
import gradio as gr
|
|
|
|
| 22 |
# ---------------------------------------------------------------------------
|
| 23 |
|
| 24 |
env = InvoiceExceptionEnv(seed=42)
|
| 25 |
+
env_lock = threading.Lock()
|
| 26 |
|
| 27 |
# ---------------------------------------------------------------------------
|
| 28 |
# FastAPI server
|
|
|
|
| 34 |
@api.post("/reset")
|
| 35 |
async def http_reset(body: dict = {}) -> JSONResponse:
|
| 36 |
"""Reset the environment. Optionally specify task_id."""
|
| 37 |
+
with env_lock:
|
| 38 |
+
task_id = body.get("task_id", None)
|
| 39 |
+
obs = env.reset(task_id)
|
| 40 |
+
return JSONResponse(obs.model_dump(mode="json"))
|
| 41 |
|
| 42 |
|
| 43 |
@api.post("/step")
|
| 44 |
async def http_step(body: dict = {}) -> JSONResponse:
|
| 45 |
"""Execute one action."""
|
| 46 |
+
with env_lock:
|
| 47 |
+
result = env.step(body)
|
| 48 |
+
return JSONResponse(result.model_dump(mode="json"))
|
| 49 |
|
| 50 |
|
| 51 |
@api.get("/state")
|
| 52 |
async def http_state() -> JSONResponse:
|
| 53 |
"""Return the current state without advancing."""
|
| 54 |
+
with env_lock:
|
| 55 |
+
return JSONResponse(env.state().model_dump(mode="json"))
|
| 56 |
|
| 57 |
|
| 58 |
@api.post("/grade")
|
| 59 |
async def http_grade() -> JSONResponse:
|
| 60 |
"""Grade the current episode."""
|
| 61 |
+
with env_lock:
|
| 62 |
+
return JSONResponse(env.grade())
|
| 63 |
|
| 64 |
|
| 65 |
@api.get("/tasks")
|
|
|
|
| 187 |
Action.run_check("tax_calculation_verify"),
|
| 188 |
Action.cross_check("tax_amount", "invoice", "payment_history"),
|
| 189 |
Action.query_internal("finance", "Can you confirm the overpayment on INV-2024-819?"),
|
| 190 |
+
Action.query_supplier("Please clarify the relationship between INV-2024-891 and INV-2024-819.", "phone"),
|
| 191 |
Action.apply_rule("partial_approval"),
|
| 192 |
Action.apply_rule("credit_note_request"),
|
| 193 |
Action.make_decision("partial_approve", "Duplicate detected. Tax error on original. Approve only 3,240 INR correction."),
|
inference.py
CHANGED
|
@@ -37,7 +37,14 @@ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
|
|
| 37 |
|
| 38 |
SYSTEM_PROMPT = """You are an expert Accounts Payable (AP) analyst handling flagged invoice exceptions.
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
You must investigate the root cause, apply business rules, make a decision, and close the case.
|
| 43 |
|
|
@@ -56,7 +63,8 @@ You must investigate the root cause, apply business rules, make a decision, and
|
|
| 56 |
**Rules:**
|
| 57 |
- Always investigate before making a decision
|
| 58 |
- Never approve without running checks first
|
| 59 |
-
-
|
|
|
|
| 60 |
- Respond with ONLY a JSON object, no extra text
|
| 61 |
"""
|
| 62 |
|
|
@@ -66,17 +74,60 @@ You must investigate the root cause, apply business rules, make a decision, and
|
|
| 66 |
# ---------------------------------------------------------------------------
|
| 67 |
|
| 68 |
def build_prompt(obs, step: int, max_steps: int, history: list) -> str:
|
| 69 |
-
"""Build the user prompt from the current observation state."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
lines = [
|
| 71 |
f"Step {step} of {max_steps}.",
|
| 72 |
f"",
|
| 73 |
-
f"EXCEPTION FLAG: {obs.exception_flag.flag_code}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
f"",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
f"Available checks: {', '.join(obs.available_checks)}",
|
| 76 |
f"Available rules: {', '.join(obs.available_rules)}",
|
| 77 |
f"",
|
| 78 |
f"Knowledge base:",
|
| 79 |
-
]
|
| 80 |
for entry in obs.knowledge_base:
|
| 81 |
lines.append(f" - {entry}")
|
| 82 |
|
|
@@ -85,11 +136,17 @@ def build_prompt(obs, step: int, max_steps: int, history: list) -> str:
|
|
| 85 |
lines.append(f"Case status: {obs.case_status}")
|
| 86 |
|
| 87 |
if obs.checks_run:
|
| 88 |
-
lines.append(f"Checks already run:
|
|
|
|
|
|
|
| 89 |
if obs.queries:
|
| 90 |
-
lines.append(f"Queries made:
|
|
|
|
|
|
|
| 91 |
if obs.inspections:
|
| 92 |
-
lines.append(f"Fields inspected:
|
|
|
|
|
|
|
| 93 |
if obs.rules_applied:
|
| 94 |
lines.append(f"Rules applied: {', '.join(obs.rules_applied)}")
|
| 95 |
if obs.decision:
|
|
@@ -169,13 +226,14 @@ def parse_action(raw_text: str) -> dict:
|
|
| 169 |
# Task runner
|
| 170 |
# ---------------------------------------------------------------------------
|
| 171 |
|
| 172 |
-
def run_task(client: OpenAI, env: InvoiceExceptionEnv, task_id: str
|
| 173 |
"""Run one task episode and return (steps_taken, score, rewards)."""
|
| 174 |
rewards = []
|
| 175 |
|
| 176 |
print(f"[START] task={task_id} env=invoice-exception-handler model={MODEL_NAME}", flush=True)
|
| 177 |
|
| 178 |
obs = env.reset(task_id)
|
|
|
|
| 179 |
history = []
|
| 180 |
|
| 181 |
for step in range(1, max_steps + 1):
|
|
|
|
| 37 |
|
| 38 |
SYSTEM_PROMPT = """You are an expert Accounts Payable (AP) analyst handling flagged invoice exceptions.
|
| 39 |
|
| 40 |
+
⚠️ CRITICAL RULE: If there is ANY suspicion of bank account fraud, BEC attack, or
|
| 41 |
+
supplier impersonation, you MUST contact the supplier via PHONE (channel="phone"),
|
| 42 |
+
NEVER via email. Emailing a potentially compromised account will contact the fraudster
|
| 43 |
+
and incur a severe penalty.
|
| 44 |
+
|
| 45 |
+
You have access to a document packet: Purchase Order (PO), Invoice, Goods Receipt Note
|
| 46 |
+
(GRN), Supplier Master, and an Exception Flag explaining why this invoice was flagged.
|
| 47 |
+
The actual document values are provided in each prompt — use them to reason.
|
| 48 |
|
| 49 |
You must investigate the root cause, apply business rules, make a decision, and close the case.
|
| 50 |
|
|
|
|
| 63 |
**Rules:**
|
| 64 |
- Always investigate before making a decision
|
| 65 |
- Never approve without running checks first
|
| 66 |
+
- Compare document values carefully — look for mismatches between PO, Invoice, GRN, and Supplier Master
|
| 67 |
+
- If bank account or email domain looks suspicious, use phone channel for supplier queries
|
| 68 |
- Respond with ONLY a JSON object, no extra text
|
| 69 |
"""
|
| 70 |
|
|
|
|
| 74 |
# ---------------------------------------------------------------------------
|
| 75 |
|
| 76 |
def build_prompt(obs, step: int, max_steps: int, history: list) -> str:
|
| 77 |
+
"""Build the user prompt from the current observation state, including document data."""
|
| 78 |
+
|
| 79 |
+
# Build GRN summary safely from the dict-based items_received
|
| 80 |
+
grn_items = obs.grn.items_received
|
| 81 |
+
grn_received = sum(item.get("quantity_received", 0) for item in grn_items)
|
| 82 |
+
grn_pending = sum(item.get("quantity_pending", 0) for item in grn_items)
|
| 83 |
+
grn_details = "; ".join(
|
| 84 |
+
f"{item.get('description', 'item')}: {item.get('quantity_received', '?')} received, {item.get('quantity_pending', 0)} pending"
|
| 85 |
+
for item in grn_items
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
lines = [
|
| 89 |
f"Step {step} of {max_steps}.",
|
| 90 |
f"",
|
| 91 |
+
f"EXCEPTION FLAG: {obs.exception_flag.flag_code}",
|
| 92 |
+
f"{obs.exception_flag.flag_description}",
|
| 93 |
+
f"",
|
| 94 |
+
f"=== DOCUMENT SUMMARY ===",
|
| 95 |
+
f"PO #{obs.purchase_order.po_number} | Total: INR {obs.purchase_order.total_amount:,.2f} | Terms: {obs.purchase_order.payment_terms}",
|
| 96 |
+
f"PO Line Items:",
|
| 97 |
+
]
|
| 98 |
+
for item in obs.purchase_order.line_items:
|
| 99 |
+
lines.append(f" - {item.description}: qty={item.quantity}, unit_price=INR {item.unit_price:,.2f}, total=INR {item.total:,.2f}")
|
| 100 |
+
|
| 101 |
+
lines.extend([
|
| 102 |
+
f"",
|
| 103 |
+
f"Invoice #{obs.invoice.invoice_number} | Date: {obs.invoice.invoice_date} | Total: INR {obs.invoice.total_amount:,.2f}",
|
| 104 |
+
f"Invoice Subtotal: INR {obs.invoice.subtotal:,.2f} | Tax ({obs.invoice.tax_rate}%): INR {obs.invoice.tax_amount:,.2f}",
|
| 105 |
+
f"Invoice Bank Account: {obs.invoice.bank_account} ({obs.invoice.bank_name})",
|
| 106 |
+
f"Invoice GSTIN: {obs.invoice.supplier_gstin}",
|
| 107 |
+
f"Invoice Email: {obs.invoice.supplier_email}",
|
| 108 |
+
f"Invoice Line Items:",
|
| 109 |
+
])
|
| 110 |
+
for item in obs.invoice.line_items:
|
| 111 |
+
lines.append(f" - {item.description}: qty={item.quantity}, unit_price=INR {item.unit_price:,.2f}, total=INR {item.total:,.2f}")
|
| 112 |
+
|
| 113 |
+
lines.extend([
|
| 114 |
+
f"",
|
| 115 |
+
f"GRN #{obs.grn.grn_number} | Date: {obs.grn.receipt_date}",
|
| 116 |
+
f"GRN Items: {grn_details}",
|
| 117 |
+
f"GRN Total received: {grn_received}, pending: {grn_pending}",
|
| 118 |
f"",
|
| 119 |
+
f"Supplier Master: {obs.supplier_master.supplier_name} ({obs.supplier_master.supplier_id})",
|
| 120 |
+
f"Supplier Bank Account: {obs.supplier_master.bank_account} ({obs.supplier_master.bank_name})",
|
| 121 |
+
f"Supplier GSTIN: {obs.supplier_master.gstin}",
|
| 122 |
+
f"Supplier Email Domain: {obs.supplier_master.registered_domain}",
|
| 123 |
+
f"Supplier Phone: {obs.supplier_master.contact_phone}",
|
| 124 |
+
f"",
|
| 125 |
+
f"=== AVAILABLE ACTIONS ===",
|
| 126 |
f"Available checks: {', '.join(obs.available_checks)}",
|
| 127 |
f"Available rules: {', '.join(obs.available_rules)}",
|
| 128 |
f"",
|
| 129 |
f"Knowledge base:",
|
| 130 |
+
])
|
| 131 |
for entry in obs.knowledge_base:
|
| 132 |
lines.append(f" - {entry}")
|
| 133 |
|
|
|
|
| 136 |
lines.append(f"Case status: {obs.case_status}")
|
| 137 |
|
| 138 |
if obs.checks_run:
|
| 139 |
+
lines.append(f"Checks already run:")
|
| 140 |
+
for c in obs.checks_run:
|
| 141 |
+
lines.append(f" - {c.check_name}: {'PASSED' if c.passed else 'FAILED'} — {c.detail[:100]}")
|
| 142 |
if obs.queries:
|
| 143 |
+
lines.append(f"Queries made:")
|
| 144 |
+
for q in obs.queries:
|
| 145 |
+
lines.append(f" - {q.target} (via {q.channel}): {q.response[:100]}...")
|
| 146 |
if obs.inspections:
|
| 147 |
+
lines.append(f"Fields inspected:")
|
| 148 |
+
for i in obs.inspections:
|
| 149 |
+
lines.append(f" - {i.document}.{i.field}: {str(i.value)[:100]}")
|
| 150 |
if obs.rules_applied:
|
| 151 |
lines.append(f"Rules applied: {', '.join(obs.rules_applied)}")
|
| 152 |
if obs.decision:
|
|
|
|
| 226 |
# Task runner
|
| 227 |
# ---------------------------------------------------------------------------
|
| 228 |
|
| 229 |
+
def run_task(client: OpenAI, env: InvoiceExceptionEnv, task_id: str) -> tuple:
|
| 230 |
"""Run one task episode and return (steps_taken, score, rewards)."""
|
| 231 |
rewards = []
|
| 232 |
|
| 233 |
print(f"[START] task={task_id} env=invoice-exception-handler model={MODEL_NAME}", flush=True)
|
| 234 |
|
| 235 |
obs = env.reset(task_id)
|
| 236 |
+
max_steps = env._task.max_steps # read from the task itself
|
| 237 |
history = []
|
| 238 |
|
| 239 |
for step in range(1, max_steps + 1):
|
openenv
ADDED
|
File without changes
|
pyproject.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.backends._legacy:_Backend"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "invoice-exception-handler"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "An AI agent learning environment simulating accounts payable exception handling."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = {text = "MIT"}
|
| 11 |
+
requires-python = ">=3.10"
|
| 12 |
+
authors = [
|
| 13 |
+
{name = "Yusuf"},
|
| 14 |
+
]
|
| 15 |
+
dependencies = [
|
| 16 |
+
"pydantic>=2.7",
|
| 17 |
+
"fastapi>=0.111",
|
| 18 |
+
"uvicorn>=0.29",
|
| 19 |
+
"gradio>=4.36",
|
| 20 |
+
"openai>=1.35",
|
| 21 |
+
"pyyaml>=6.0",
|
| 22 |
+
"httpx>=0.27",
|
| 23 |
+
"python-multipart>=0.0.9",
|
| 24 |
+
"openenv-core>=0.2.0",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
[project.scripts]
|
| 28 |
+
server = "server.app:main"
|
| 29 |
+
|
| 30 |
+
[tool.setuptools.packages.find]
|
| 31 |
+
include = ["env*", "server*"]
|
server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Server package for OpenEnv deployment."""
|
server/app.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Server entry point for OpenEnv deployment.
|
| 3 |
+
|
| 4 |
+
This module re-exports the FastAPI app from the root app module
|
| 5 |
+
and provides a main() function for the [project.scripts] entry point.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
# Add project root to path so env/ package is importable
|
| 13 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
|
| 15 |
+
from app import app # noqa: E402
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main() -> None:
|
| 19 |
+
"""Entry point for the serve script."""
|
| 20 |
+
import uvicorn
|
| 21 |
+
uvicorn.run("server.app:app", host="0.0.0.0", port=7860, reload=False)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|