Download inference.py from shxam/openenv: direct link, hf CLI and curl.
- Browser
- Download file 34.1 kB
-
https://huggingface.co/spaces/shxam/openenv/resolve/main/inference.py
- Command line
-
hf download hf://spaces/shxam/openenv/inference.py
-
curl -L -o inference.py https://huggingface.co/spaces/shxam/openenv/resolve/main/inference.py
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() |