openenv / inference.py
shxam's picture
feat: Smart Triage + Batched LLM hybrid agent (1 LLM call/task, 25s timeout, 240s budget)
68bd9a2
Raw History Blame Contribute Delete
34.1 kB
#!/usr/bin/env python3
"""
inference.py β€” GST Reconciliation OpenEnv β€” Smart Triage + Batched LLM Agent
Architecture (60-40 Deterministic-First Hybrid):
Phase 1 β€” Deterministic pre-filter on ALL invoices (~1-2s per task)
Checks: tax formula, GSTIN regex, date validity, ITC rules, amount threshold
β†’ high_confidence (~60-70%): final labels locked in
β†’ low_confidence (~30-40%): sent to LLM for review
Phase 2 β€” ONE Groq LLM call per task for low-confidence invoices only
Hard timeout: 25 seconds. Task budget: 240s (4 min Γ— 6 tasks = 24 min total).
Smart prompt includes deterministic_label so LLM only corrects, not re-classifies.
Phase 3 β€” Merge: LLM results override deterministic for low-confidence;
deterministic fallback if LLM times out or fails.
Performance target (per task):
Deterministic pre-filter : ~1-2s
Single LLM batch call : ~15-20s
Merge + ITC recompute : <1s
Total per task : ~22-25s
6 tasks total : ~150s (2.5 min) β€” well under 30-min limit
Environment variables:
API_BASE_URL : LLM API base URL (default: https://api.groq.com/openai/v1)
MODEL_NAME : LLM model name (default: llama-3.3-70b-versatile)
HF_TOKEN : API key / token (Set via HF Space Secrets β€” never hardcode)
BASE_URL : Env server URL (default: http://localhost:7860)
"""
import json
import os
import re
import sys
import time
from collections import defaultdict
from datetime import date as date_type
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
import requests
try:
from dotenv import load_dotenv
load_dotenv()
print("[CONFIG] .env loaded successfully", flush=True)
except ImportError:
print("[CONFIG] python-dotenv not installed, using system env vars", flush=True)
# ── Config ─────────────────────────────────────────────────────────────────
API_BASE_URL = os.getenv("API_BASE_URL", "https://api.groq.com/openai/v1")
MODEL_NAME = os.getenv("MODEL_NAME", "llama-3.3-70b-versatile")
HF_TOKEN = os.getenv("HF_TOKEN", "") # Set via HF Space Secrets β€” never hardcode
BASE_URL = os.getenv("BASE_URL", "http://localhost:7860")
BENCHMARK = "gst-reconciliation"
# Timing budgets (all in seconds)
LLM_CALL_TIMEOUT = 25 # hard cap per LLM call (asyncio.wait_for style via openai timeout)
TASK_BUDGET_SECONDS = 240 # 4 min per task; 6 tasks = 24 min total (under 30-min limit)
TASK_SKIP_BUFFER = 40 # if elapsed > TASK_BUDGET - SKIP_BUFFER, skip LLM call
HTTP_STEP_TIMEOUT = 120 # /step can be slow for 500-invoice payloads
# Gap between tasks to avoid Groq rate-limit windows
INTER_TASK_SLEEP = 1
# ── GSTIN validation regex ──────────────────────────────────────────────────
import re as _re
_GSTIN_REGEX = _re.compile(
r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[A-Z0-9]{1}Z[A-Z0-9]{1}$"
)
# OCR confusion pairs
_OCR_PAIRS: Dict[str, str] = {
"0": "O", "O": "0",
"1": "I", "I": "1",
"8": "B", "B": "8",
"5": "S", "S": "5",
"2": "Z", "Z": "2",
}
# Tax rates by approximate check (GST is 0%, 5%, 12%, 18%, 28%)
_GST_RATES = [Decimal("0.00"), Decimal("0.05"), Decimal("0.12"),
Decimal("0.18"), Decimal("0.28")]
_AMOUNT_THRESHOLD = Decimal("1.00")
_NEAR_MISS_WINDOW = Decimal("10.00") # Rs 1–10: flagged borderline for LLM
# ── OpenAI client ───────────────────────────────────────────────────────────
client = None
try:
from openai import OpenAI
if HF_TOKEN:
client = OpenAI(
api_key=HF_TOKEN,
base_url=API_BASE_URL,
timeout=LLM_CALL_TIMEOUT + 5, # slightly wider than our manual cap
max_retries=0, # zero retries β€” time is critical
)
print(f"[CONFIG] LLM client ready β€” model={MODEL_NAME}", flush=True)
else:
print("[WARN] HF_TOKEN not set β€” LLM disabled, running deterministic-only.", flush=True)
except Exception as e:
print(f"[WARN] OpenAI client init failed: {e} β€” running deterministic-only.", flush=True)
# ── Groq LLM prompt ─────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are a GST compliance checker for Indian invoices.
Deterministic logic has already produced a label for each invoice below.
Your role: ONLY change the label when confident the deterministic result is wrong.
Override when:
- Amount is near-miss (Rs 1–10 from threshold) and you are confident it's still MISMATCH or MATCHED
- GSTIN looks like OCR-corruption (0↔O, 1↔I, 8↔B, 5↔S, 2↔Z) β€” if swap makes it match, it's still MISMATCH (changed GSTIN = MISMATCH)
- Fields are ambiguous or itc_available=False was missed
- document_type is credit_note/debit_note/advance_receipt β†’ MISMATCH
Valid statuses: MATCHED MISMATCH MISSING_IN_2B EXTRA_IN_2B
Valid mismatch_fields: taxable_value invoice_date supplier_gstin cgst sgst igst itc_available
Respond ONLY with this JSON (no markdown, no explanation):
{
"results": {
"<invoice_id>": {
"status": "MATCHED|MISMATCH|MISSING_IN_2B|EXTRA_IN_2B",
"correction_note": "brief plain-text reason",
"mismatch_fields": ["field1", "field2"]
}
}
}
Every invoice_id given to you MUST appear in results.
mismatch_fields must be [] unless status is MISMATCH."""
# ── HTTP helpers ────────────────────────────────────────────────────────────
def _post(endpoint: str, payload: Dict[str, Any], timeout: int = 60) -> Dict[str, Any]:
resp = requests.post(f"{BASE_URL}{endpoint}", json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
def _get(endpoint: str, timeout: int = 15) -> Dict[str, Any]:
resp = requests.get(f"{BASE_URL}{endpoint}", timeout=timeout)
resp.raise_for_status()
return resp.json()
# ── Log helpers ─────────────────────────────────────────────────────────────
def log_start(task: str, env: str, model: str) -> None:
print(f"[START] task={task} env={env} model={model}", flush=True)
def log_step(step: int, action: str, reward: float,
done: bool, error: Optional[str]) -> None:
print(
f"[STEP] step={step} action={action} "
f"reward={reward:.2f} done={str(done).lower()} "
f"error={error if error else 'null'}",
flush=True,
)
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
print(
f"[END] success={str(success).lower()} steps={steps} "
f"score={score:.3f} rewards={','.join(f'{r:.2f}' for r in rewards)}",
flush=True,
)
# ════════════════════════════════════════════════════════════════════════════
# PHASE 1 β€” Deterministic pre-filter
# ════════════════════════════════════════════════════════════════════════════
def _validate_gstin(gstin: str) -> bool:
"""True if GSTIN passes format check."""
return bool(_GSTIN_REGEX.match(gstin.upper()))
def _is_ocr_gstin_error(a: str, b: str) -> bool:
"""True if a and b differ by exactly 1 character that is an OCR confusion pair."""
a, b = a.upper(), b.upper()
if len(a) != 15 or len(b) != 15:
return False
diffs = [(ca, cb) for ca, cb in zip(a, b) if ca != cb]
if len(diffs) != 1:
return False
ca, cb = diffs[0]
return _OCR_PAIRS.get(ca) == cb
def _check_tax_formula(taxable: Decimal, cgst: Decimal, sgst: Decimal, igst: Decimal) -> bool:
"""
Returns True if the tax amounts are consistent with any standard GST rate.
Intra-state: cgst == sgst == taxable * rate/2; inter-state: igst == taxable * rate
"""
tolerance = Decimal("2.00")
for rate in _GST_RATES:
half = (taxable * rate / 2).quantize(Decimal("0.01"))
full = (taxable * rate).quantize(Decimal("0.01"))
# Intra-state
if (abs(cgst - half) <= tolerance and
abs(sgst - half) <= tolerance and
igst == Decimal("0")):
return True
# Inter-state
if (cgst == Decimal("0") and
sgst == Decimal("0") and
abs(igst - full) <= tolerance):
return True
return False
class _ClassifiedInvoice:
"""Result of deterministic classification for one invoice."""
__slots__ = (
"invoice_id", "status", "correction_note", "mismatch_fields",
"high_confidence", "borderline_reason",
)
def __init__(
self,
invoice_id: str,
status: str,
correction_note: Optional[str],
mismatch_fields: List[str],
high_confidence: bool,
borderline_reason: str = "",
):
self.invoice_id = invoice_id
self.status = status
self.correction_note = correction_note
self.mismatch_fields = mismatch_fields
self.high_confidence = high_confidence
self.borderline_reason = borderline_reason
def to_dict(self) -> Dict:
return {
"invoice_id": self.invoice_id,
"status": self.status,
"correction_note": self.correction_note,
"mismatch_fields": self.mismatch_fields,
}
def _deterministic_classify(
inv: Dict, gstr_index: Dict[str, List[Dict]]
) -> _ClassifiedInvoice:
"""
Phase 1 deterministic classifier.
Confidence rules:
- high_confidence=True β†’ label is final, LLM skips this invoice
- high_confidence=False β†’ borderline, send to LLM for review
Borderline triggers:
- Near-miss amount (Rs 1.01–10.00 difference)
- Single OCR-style GSTIN character error
- Multi-field mismatch (amount + date both wrong)
- invoice has suspicious tax formula
"""
iid = inv["invoice_id"]
matches = gstr_index.get(inv["invoice_number"], [])
# ── MISSING_IN_2B (always high-confidence) ──────────────────────────────
if len(matches) == 0:
return _ClassifiedInvoice(
invoice_id=iid,
status="MISSING_IN_2B",
correction_note="Invoice number not found in GSTR-2B portal",
mismatch_fields=[],
high_confidence=True,
)
# ── EXTRA_IN_2B (always high-confidence) ────────────────────────────────
if len(matches) > 1:
return _ClassifiedInvoice(
invoice_id=iid,
status="EXTRA_IN_2B",
correction_note=f"Appears {len(matches)} times in GSTR-2B β€” possible duplicate filing",
mismatch_fields=[],
high_confidence=True,
)
gstr = matches[0]
diff_fields: List[str] = []
notes: List[str] = []
borderline = False
borderline_reasons: List[str] = []
# ── GSTIN check ─────────────────────────────────────────────────────────
inv_gstin = inv.get("vendor_gstin", "").upper()
gstr_gstin = gstr.get("supplier_gstin", "").upper()
if inv_gstin != gstr_gstin:
diff_fields.append("supplier_gstin")
notes.append(f"GSTIN: {inv_gstin} vs {gstr_gstin}")
if _is_ocr_gstin_error(inv_gstin, gstr_gstin):
borderline = True
borderline_reasons.append("OCR GSTIN error (1-char OCR confusion)")
# ── Date check ───────────────────────────────────────────────────────────
inv_date = str(inv.get("invoice_date", ""))
gstr_date = str(gstr.get("invoice_date", ""))
date_differs = inv_date != gstr_date
if date_differs:
diff_fields.append("invoice_date")
notes.append(f"Date: {inv_date} vs {gstr_date}")
# ── Amount checks ────────────────────────────────────────────────────────
amount_fields: List[str] = []
for f in ["taxable_value", "cgst", "sgst", "igst"]:
try:
inv_val = Decimal(str(inv.get(f, 0)))
gstr_val = Decimal(str(gstr.get(f, 0)))
diff_amt = abs(inv_val - gstr_val)
if diff_amt > _AMOUNT_THRESHOLD:
amount_fields.append(f)
diff_fields.append(f)
# Near-miss: Rs 1.01–10.00 β€” still MISMATCH but borderline
if diff_amt <= _NEAR_MISS_WINDOW:
borderline = True
borderline_reasons.append(f"Near-miss amount in {f} (diff Rs {diff_amt:.2f})")
except Exception:
diff_fields.append(f)
if amount_fields:
notes.append(f"Amount mismatch: {', '.join(amount_fields)}")
# Multi-field: both amount and date differ β†’ borderline (LLM writes better note)
if amount_fields and date_differs:
borderline = True
borderline_reasons.append("Multi-field: amount + date both differ")
# ── ITC / document type check ────────────────────────────────────────────
itc_available = gstr.get("itc_available", True)
doc_type = gstr.get("document_type", "invoice")
if not itc_available or doc_type in ("credit_note", "debit_note", "advance_receipt"):
if "itc_available" not in diff_fields:
diff_fields.append("itc_available")
notes.append(f"ITC not available (doc_type={doc_type})")
# ── Tax formula check (flags borderline if suspicious) ───────────────────
try:
taxable = Decimal(str(inv.get("taxable_value", 0)))
cgst = Decimal(str(inv.get("cgst", 0)))
sgst = Decimal(str(inv.get("sgst", 0)))
igst = Decimal(str(inv.get("igst", 0)))
if not _check_tax_formula(taxable, cgst, sgst, igst):
borderline = True
borderline_reasons.append("Suspicious tax formula (rate mismatch)")
except Exception:
pass
# ── Final classification ──────────────────────────────────────────────────
if diff_fields:
correction_note = "; ".join(notes) if notes else "Field mismatch detected"
# High confidence only when diff is large (>Rs 10) and not OCR GSTIN
high_conf = (not borderline)
return _ClassifiedInvoice(
invoice_id=iid,
status="MISMATCH",
correction_note=correction_note,
mismatch_fields=diff_fields,
high_confidence=high_conf,
borderline_reason="; ".join(borderline_reasons),
)
return _ClassifiedInvoice(
invoice_id=iid,
status="MATCHED",
correction_note=None,
mismatch_fields=[],
high_confidence=True,
)
def _recompute_itc(entries: List[Dict], invoices: List[Dict]) -> float:
"""ITC = sum(cgst + sgst + igst) for MATCHED invoices only."""
inv_map = {i["invoice_id"]: i for i in invoices}
total = Decimal("0")
for e in entries:
if e.get("status") == "MATCHED":
inv = inv_map.get(e["invoice_id"], {})
total += (
Decimal(str(inv.get("cgst", 0)))
+ Decimal(str(inv.get("sgst", 0)))
+ Decimal(str(inv.get("igst", 0)))
)
return float(total)
# ════════════════════════════════════════════════════════════════════════════
# PHASE 2 β€” Single LLM batch call per task
# ════════════════════════════════════════════════════════════════════════════
def _build_llm_prompt(
low_conf: List[_ClassifiedInvoice],
invoices: List[Dict],
gstr_index: Dict[str, List[Dict]],
) -> str:
"""Build a compact prompt for only the borderline invoices."""
inv_map = {i["invoice_id"]: i for i in invoices}
seen_gstr: set = set()
inv_lines = []
gstr_lines = []
for clf in low_conf:
inv = inv_map.get(clf.invoice_id, {})
inv_lines.append(
f'{clf.invoice_id}|{inv.get("invoice_number","")}'
f'|{inv.get("vendor_gstin","")}'
f'|{inv.get("invoice_date","")}'
f'|taxable={float(str(inv.get("taxable_value",0))):.2f}'
f'|cgst={float(str(inv.get("cgst",0))):.2f}'
f'|sgst={float(str(inv.get("sgst",0))):.2f}'
f'|igst={float(str(inv.get("igst",0))):.2f}'
f'|det={clf.status}'
f'|why={clf.borderline_reason}'
)
inv_num = inv.get("invoice_number", "")
for g in gstr_index.get(inv_num, []):
key = g["invoice_number"]
if key not in seen_gstr:
seen_gstr.add(key)
gstr_lines.append(
f'{g["invoice_number"]}'
f'|{g.get("supplier_gstin","")}'
f'|{g.get("invoice_date","")}'
f'|taxable={float(str(g.get("taxable_value",0))):.2f}'
f'|cgst={float(str(g.get("cgst",0))):.2f}'
f'|sgst={float(str(g.get("sgst",0))):.2f}'
f'|igst={float(str(g.get("igst",0))):.2f}'
f'|itc={g.get("itc_available",True)}'
f'|doc={g.get("document_type","invoice")}'
)
ids = [c.invoice_id for c in low_conf]
return (
f"Review {len(low_conf)} borderline invoices (deterministic label included).\n"
f"Required invoice_ids (ALL must appear in results): {ids}\n\n"
f"INVOICES (id|number|gstin|date|taxable|cgst|sgst|igst|det_label|reason):\n"
+ "\n".join(inv_lines)
+ "\n\nGSTR2B (number|gstin|date|taxable|cgst|sgst|igst|itc|doc):\n"
+ ("\n".join(gstr_lines) if gstr_lines else "(none)")
+ "\n\nRespond with the JSON object only."
)
def _call_llm_once(
prompt: str,
task_start: float,
) -> Optional[str]:
"""
Single LLM call with hard LLM_CALL_TIMEOUT.
Checks task budget before calling β€” skips if time is running out.
Returns raw string or None on any failure/timeout.
"""
if client is None:
return None
elapsed = time.time() - task_start
remaining = TASK_BUDGET_SECONDS - elapsed
if elapsed > (TASK_BUDGET_SECONDS - TASK_SKIP_BUFFER):
print(
f" [LLM] Skipping β€” only {remaining:.0f}s left in task budget.",
flush=True,
)
return None
print(
f" [LLM] Calling {MODEL_NAME} (timeout={LLM_CALL_TIMEOUT}s, "
f"elapsed={elapsed:.1f}s/{TASK_BUDGET_SECONDS}s) ...",
flush=True,
)
t_llm = time.time()
try:
kwargs: Dict[str, Any] = dict(
model=MODEL_NAME,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0.1,
max_tokens=4096,
)
try:
kwargs["response_format"] = {"type": "json_object"}
except Exception:
pass
completion = client.chat.completions.create(**kwargs)
llm_elapsed = time.time() - t_llm
content = completion.choices[0].message.content or ""
if hasattr(completion, "usage") and completion.usage:
u = completion.usage
print(
f" [LLM] Done in {llm_elapsed:.1f}s | "
f"tokens: prompt={u.prompt_tokens} completion={u.completion_tokens}",
flush=True,
)
return content
except Exception as exc:
llm_elapsed = time.time() - t_llm
err = str(exc)
if "rate_limit" in err or "429" in err:
print(f" [LLM] Rate limited after {llm_elapsed:.1f}s β€” no retry (time critical).", flush=True)
elif "timeout" in err.lower() or "timed out" in err.lower():
print(f" [LLM] Timed out after {llm_elapsed:.1f}s β†’ falling back to deterministic.", flush=True)
else:
print(f" [LLM] Error after {llm_elapsed:.1f}s: {exc} β†’ deterministic.", flush=True)
return None
def _parse_llm_json(text: str) -> Optional[Dict]:
if not text:
return None
text = text.strip()
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
text = re.sub(r"^```[a-z]*\n?", "", text).rstrip("`").strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Extract first JSON object
start = text.find("{")
if start != -1:
depth = 0
for i, ch in enumerate(text[start:], start):
if ch == "{": depth += 1
elif ch == "}": depth -= 1
if depth == 0:
try: return json.loads(text[start:i + 1])
except: break
return None
# ════════════════════════════════════════════════════════════════════════════
# PHASE 3 β€” Merge results
# ════════════════════════════════════════════════════════════════════════════
def _merge_results(
all_classified: List[_ClassifiedInvoice],
llm_json: Optional[Dict],
) -> List[Dict]:
"""
Merge deterministic + LLM results.
- high_confidence invoices always keep deterministic label
- low_confidence invoices: use LLM result if valid, else deterministic
"""
valid_statuses = {"MATCHED", "MISMATCH", "MISSING_IN_2B", "EXTRA_IN_2B"}
llm_results: Dict[str, Dict] = {}
if llm_json:
# Support {"results": {id: {...}}} format
raw = llm_json.get("results") or {}
if isinstance(raw, dict):
llm_results = raw
# Also support list format
elif isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and "invoice_id" in item:
llm_results[item["invoice_id"]] = item
final: List[Dict] = []
n_llm = 0
n_det = 0
for clf in all_classified:
iid = clf.invoice_id
if clf.high_confidence:
final.append(clf.to_dict())
n_det += 1
else:
# Try LLM result
llm_item = llm_results.get(iid)
if isinstance(llm_item, dict):
status = llm_item.get("status")
if status in valid_statuses:
mf = llm_item.get("mismatch_fields", [])
if not isinstance(mf, list):
mf = []
final.append({
"invoice_id": iid,
"status": status,
"correction_note": llm_item.get("correction_note") or clf.correction_note,
"mismatch_fields": mf,
})
n_llm += 1
continue
# Fallback to deterministic
final.append(clf.to_dict())
n_det += 1
print(
f" [MERGE] LLM-used={n_llm} | Deterministic-used={n_det} | Total={len(final)}",
flush=True,
)
return final
# ── Task runner ─────────────────────────────────────────────────────────────
def run_task(task_id: str) -> float:
rewards: List[float] = []
steps_taken = 0
score = 0.0
log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME if client else "deterministic-only")
try:
task_start = time.time()
print(f"\n [RESET] Starting: {task_id}", flush=True)
obs = _post("/reset", {"task_id": task_id}, timeout=60)
invoices = obs.get("invoices", [])
n_invoices = len(invoices)
max_itc = float(obs.get("max_itc_possible", 0))
print(f" [RESET] {n_invoices} invoices | Max ITC: Rs{max_itc:,.2f}", flush=True)
# Build GSTR-2B lookup index
gstr_index: Dict[str, List[Dict]] = defaultdict(list)
for e in obs.get("gstr2b_entries", []):
gstr_index[e["invoice_number"]].append(e)
# ────────────────────────────────────────────────────────────────────
# PHASE 1 β€” Deterministic pre-filter (all invoices)
# ────────────────────────────────────────────────────────────────────
t1 = time.time()
all_classified: List[_ClassifiedInvoice] = [
_deterministic_classify(inv, gstr_index) for inv in invoices
]
low_conf = [c for c in all_classified if not c.high_confidence]
high_conf = [c for c in all_classified if c.high_confidence]
counts_det: Dict[str, int] = defaultdict(int)
for c in all_classified:
counts_det[c.status] += 1
print(
f" [P1] {n_invoices} invoices in {time.time()-t1:.2f}s | "
f"{dict(counts_det)} | "
f"high_conf={len(high_conf)} low_conf={len(low_conf)} "
f"({100*len(low_conf)/max(n_invoices,1):.0f}% to LLM)",
flush=True,
)
# ────────────────────────────────────────────────────────────────────
# PHASE 2 β€” Single LLM call for low-confidence invoices
# ────────────────────────────────────────────────────────────────────
llm_json: Optional[Dict] = None
if low_conf and client is not None:
prompt = _build_llm_prompt(low_conf, invoices, gstr_index)
raw = _call_llm_once(prompt, task_start)
if raw:
llm_json = _parse_llm_json(raw)
if not llm_json:
print(" [LLM] Parse failed β€” all borderline keep deterministic label.", flush=True)
else:
print(f" [LLM] Parsed successfully.", flush=True)
elif not low_conf:
print(f" [P2] No borderline cases β€” LLM call skipped.", flush=True)
else:
print(f" [P2] LLM not available β€” deterministic-only.", flush=True)
# ────────────────────────────────────────────────────────────────────
# PHASE 3 β€” Merge and submit
# ────────────────────────────────────────────────────────────────────
final_entries = _merge_results(all_classified, llm_json)
# Recompute ITC from actual MATCHED invoices
itc = _recompute_itc(final_entries, invoices)
counts_final: Dict[str, int] = defaultdict(int)
for e in final_entries:
counts_final[e["status"]] += 1
print(f" [P3] Final breakdown: {dict(counts_final)}", flush=True)
classification_elapsed = time.time() - task_start
print(
f" [ACTION] ITC: Rs{itc:,.2f} | "
f"Classification: {classification_elapsed:.1f}s/{TASK_BUDGET_SECONDS}s",
flush=True,
)
action = {
"reconciliation_result": final_entries,
"claimable_itc": itc,
"confidence": 0.92 if llm_json else 0.85,
}
result = _post("/step", action, timeout=HTTP_STEP_TIMEOUT)
reward_data = result.get("reward", {})
info_data = result.get("info", {})
reward = float(reward_data.get("total", 0.0))
done = bool(result.get("done", True))
score = float(info_data.get("task_score", reward))
match_score = float(reward_data.get("match_score", 0.0))
itc_score = float(reward_data.get("itc_score", 0.0))
correct = int(info_data.get("correct_matches", 0))
total_inv = int(info_data.get("total_invoices", n_invoices))
rewards.append(reward)
steps_taken = 1
task_elapsed = time.time() - task_start
print(f"\n {'─'*55}", flush=True)
print(f" RESULTS: {task_id.upper()}", flush=True)
print(f" {'─'*55}", flush=True)
print(f" Total Reward : {reward:.4f}", flush=True)
print(f" Match Score : {match_score:.4f} ({correct}/{total_inv})", flush=True)
print(f" ITC Score : {itc_score:.4f}", flush=True)
print(f" Task Score : {score:.4f}", flush=True)
print(f" Task Elapsed : {task_elapsed:.1f}s / {TASK_BUDGET_SECONDS}s budget", flush=True)
print(f" {'─'*55}", flush=True)
log_step(steps_taken, f"submit_{task_id}", reward, done, None)
except Exception as exc:
print(f" [ERROR] {task_id}: {exc}", flush=True)
import traceback; traceback.print_exc()
rewards.append(0.0)
steps_taken = max(steps_taken, 1)
score = 0.0
log_step(steps_taken, f"submit_{task_id}", 0.0, True, str(exc)[:200])
log_end(score >= 0.5, steps_taken, score, rewards)
return score
# ── Entry point ─────────────────────────────────────────────────────────────
def main() -> None:
print("\n" + "="*60, flush=True)
print(" GST Reconciliation β€” Smart Triage + Batched LLM Agent", flush=True)
print(f" Strategy : Deterministic (60-70%) + LLM (30-40% borderline)", flush=True)
print(f" Model : {MODEL_NAME}", flush=True)
print(f" LLM Timeout: {LLM_CALL_TIMEOUT}s per call", flush=True)
print(f" Task Budget: {TASK_BUDGET_SECONDS}s Γ— 6 tasks = {TASK_BUDGET_SECONDS*6}s max", flush=True)
print(f" API URL : {API_BASE_URL}", flush=True)
print(f" Server : {BASE_URL}", flush=True)
print(f" HF Token : {'SET βœ“' if HF_TOKEN else 'NOT SET β€” running deterministic-only'}", flush=True)
print("="*60 + "\n", flush=True)
try:
health = _get("/health", timeout=15)
print(f"[βœ“] Server OK: {health}\n", flush=True)
except Exception as exc:
print(f"[βœ—] Server unreachable at {BASE_URL}: {exc}", flush=True)
sys.exit(1)
task_ids = [
"task1_easy",
"task2_medium",
"task3_hard",
"task4_credit_notes",
"task5_stress",
"task6_mixed_docs",
]
all_scores: List[float] = []
t_total = time.time()
for i, task_id in enumerate(task_ids):
print(f"\n{'='*60}", flush=True)
print(f" RUNNING: {task_id.upper()} ({i+1}/{len(task_ids)})", flush=True)
print(f" Total elapsed so far: {time.time()-t_total:.1f}s", flush=True)
print(f"{'='*60}", flush=True)
try:
s = run_task(task_id)
all_scores.append(s)
except Exception as exc:
print(f"[ERROR] {task_id} crashed: {exc}", flush=True)
import traceback; traceback.print_exc()
all_scores.append(0.0)
# Small sleep between tasks to avoid Groq rate-limit windows
if i < len(task_ids) - 1:
time.sleep(INTER_TASK_SLEEP)
avg = sum(all_scores) / len(all_scores) if all_scores else 0.0
total_elapsed = time.time() - t_total
print(f"\n{'='*60}", flush=True)
print(" FINAL SUMMARY", flush=True)
print(f"{'='*60}", flush=True)
for tid, s in zip(task_ids, all_scores):
print(f" {tid:<25}: {s:.4f}", flush=True)
print(f" {'─'*40}", flush=True)
print(f" {'AVERAGE':<25}: {avg:.4f}", flush=True)
print(f" {'─'*40}", flush=True)
print(f" {'TOTAL TIME':<25}: {total_elapsed:.1f}s", flush=True)
print(f" {'TIME LIMIT':<25}: 1800s", flush=True)
print(f" {'UTILISATION':<25}: {total_elapsed/1800*100:.1f}%", flush=True)
print(f"{'='*60}\n", flush=True)
if __name__ == "__main__":
main()