YUS200619 commited on
Commit
e12d96c
·
1 Parent(s): 22d90ac

fix: add server module, pyproject.toml scripts, uv.lock

Browse files
Files changed (9) hide show
  1. 0.2.0) +0 -0
  2. README.md +10 -0
  3. app.py +14 -8
  4. inference.py +67 -9
  5. openenv +0 -0
  6. pyproject.toml +31 -0
  7. server/__init__.py +1 -0
  8. server/app.py +25 -0
  9. 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
- task_id = body.get("task_id", None)
36
- obs = env.reset(task_id)
37
- return JSONResponse(obs.model_dump(mode="json"))
 
38
 
39
 
40
  @api.post("/step")
41
  async def http_step(body: dict = {}) -> JSONResponse:
42
  """Execute one action."""
43
- result = env.step(body)
44
- return JSONResponse(result.model_dump(mode="json"))
 
45
 
46
 
47
  @api.get("/state")
48
  async def http_state() -> JSONResponse:
49
  """Return the current state without advancing."""
50
- return JSONResponse(env.state().model_dump(mode="json"))
 
51
 
52
 
53
  @api.post("/grade")
54
  async def http_grade() -> JSONResponse:
55
  """Grade the current episode."""
56
- return JSONResponse(env.grade())
 
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.", "email"),
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
- You have access to a document packet: Purchase Order (PO), Invoice, Goods Receipt Note (GRN), Supplier Master, and an Exception Flag explaining why this invoice was flagged.
 
 
 
 
 
 
 
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
- - If fraud is suspected, NEVER contact the supplier via email use phone only
 
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} — {obs.exception_flag.flag_description}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: {', '.join(c.check_name for c in obs.checks_run)}")
 
 
89
  if obs.queries:
90
- lines.append(f"Queries made: {', '.join(q.target for q in obs.queries)}")
 
 
91
  if obs.inspections:
92
- lines.append(f"Fields inspected: {', '.join(f'{i.document}.{i.field}' for i in obs.inspections)}")
 
 
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, max_steps: int = 20) -> tuple:
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