Spaces:
Sleeping
Sleeping
| """ | |
| baseline.py β Optimal scripted agent baseline against live HF Space. | |
| Runs all 20 runnable tasks via HTTP, records every step, saves JSON + plots. | |
| Usage: python baseline.py | |
| """ | |
| import json, re, time, datetime | |
| import requests | |
| import matplotlib.pyplot as plt | |
| import matplotlib.gridspec as gridspec | |
| import numpy as np | |
| ENV = 'https://pathikreet-ap-clerk-env.hf.space' | |
| SEED = 42 | |
| OUT_JSON = 'baseline_results.json' | |
| OUT_PLOT = 'baseline_plot.png' | |
| # ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def reset(task_id, seed=SEED): | |
| r = requests.post(f'{ENV}/reset', json={'task_id': task_id, 'seed': seed}, timeout=20) | |
| r.raise_for_status() | |
| return r.json() | |
| def step(session_id, action): | |
| r = requests.post(f'{ENV}/step', json={'session_id': session_id, 'action': action}, timeout=20) | |
| r.raise_for_status() | |
| return r.json() | |
| def make_action(decision, amount, reason, explanation): | |
| return {'decision': decision, 'approved_amount': amount, | |
| 'reason_code': reason, 'explanation': explanation} | |
| # ββ Optimal action sequences per task βββββββββββββββββββββββββββββββββββββββββ | |
| def run_task(task_id): | |
| """Returns list of step dicts: {step, action, score, breakdown, done}""" | |
| data = reset(task_id) | |
| sid = data['session_id'] | |
| obs = data['observation'] | |
| steps = [] | |
| def do_step(decision, amount, reason, expl): | |
| r = step(sid, make_action(decision, amount, reason, expl)) | |
| steps.append({ | |
| 'step': len(steps) + 1, | |
| 'action': decision, | |
| 'score': r['reward']['score'], | |
| 'breakdown': r['reward'].get('breakdown', {}), | |
| 'feedback': r['reward'].get('feedback', ''), | |
| 'done': r['done'], | |
| }) | |
| return r | |
| # ββ pre-terminal intermediate steps βββββββββββββββββββββββββββββββββββββββ | |
| inv = obs['invoice'] | |
| total = inv['invoice_total'] | |
| items = inv.get('line_items', [{}]) | |
| pos = obs.get('purchase_orders', [{}]) | |
| grns = obs.get('goods_receipts', [{}]) | |
| notes = obs.get('context_notes', []) | |
| if task_id == 'hard_policy_violation': | |
| do_step('ESCALATE', 0.0, 'POLICY_VIOLATION', | |
| f"Freight ${inv.get('freight_charge',0):.2f} may exceed cap. Escalating to Finance Manager.") | |
| if task_id == 'hard_duplicate_invoice': | |
| do_step('QUERY_VENDOR', 0.0, 'PENDING_CLARIFICATION', | |
| f"Invoice {inv['invoice_id']} found in paid ledger. Querying vendor to confirm.") | |
| if task_id == 'hard_manager_preapproval': | |
| do_step('ESCALATE', 0.0, 'MANAGER_REVIEW', | |
| f"Freight ${inv.get('freight_charge',0):.2f} exceeds cap. Checking manager pre-approval.") | |
| if task_id == 'long_invoice_dispute': | |
| do_step('QUERY_VENDOR', 0.0, 'PENDING_CLARIFICATION', | |
| f"Invoice price ${items[0].get('unit_price',0):.2f} exceeds agreed PO price. Querying vendor.") | |
| do_step('ESCALATE', 0.0, 'MANAGER_REVIEW', | |
| "Vendor acknowledged error. Escalating to Finance Manager.") | |
| if task_id == 'long_policy_migration': | |
| do_step('HOLD', 0.0, 'PENDING_CLARIFICATION', | |
| f"Freight ${inv.get('freight_charge',0):.2f} near policy threshold. Holding for compliance review.") | |
| if task_id == 'long_manager_chain': | |
| do_step('ESCALATE', 0.0, 'MANAGER_REVIEW', | |
| f"Freight ${inv.get('freight_charge',0):.2f} exceeds cap. Escalating to Finance Manager.") | |
| do_step('ESCALATE', 0.0, 'MANAGER_REVIEW', | |
| "Manager out of office. Re-escalating to VP Finance.") | |
| if task_id == 'long_fraud_investigation': | |
| do_step('QUERY_VENDOR', 0.0, 'PENDING_CLARIFICATION', | |
| f"Invoice {inv['invoice_id']} appears in paid ledger. Querying vendor.") | |
| do_step('ESCALATE', 0.0, 'MANAGER_REVIEW', | |
| "Vendor disputes duplicate. Escalating to manager for paid-ledger audit.") | |
| if task_id == 'long_audit_trail': | |
| do_step('HOLD', 0.0, 'PENDING_CLARIFICATION', | |
| "SOX audit requirement detected. Holding for compliance documentation review.") | |
| # ββ terminal decisions βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # re-fetch obs from latest step response if available | |
| po = pos[0] if pos else {} | |
| po_lines = po.get('lines', [{}]) | |
| if task_id == 'easy_perfect_match': | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"Invoice ${total:,.2f}, PO and GRN match exactly. Freight within cap. Approving.") | |
| elif task_id == 'easy_no_po_found': | |
| do_step('REJECT', 0.0, 'NO_PO_FOUND', | |
| f"Invoice references {inv.get('po_reference','N/A')} but no matching OPEN PO found. Rejecting per Rule 5.") | |
| elif task_id == 'medium_quantity_shortfall': | |
| agreed_up = po_lines[0].get('agreed_unit_price', 0) | |
| recv_qty = sum(l.get('received_quantity', 0) for g in grns for l in g.get('lines', [])) | |
| amt = round(recv_qty * agreed_up, 2) | |
| do_step('APPROVE_PARTIAL', amt, 'QUANTITY_MISMATCH', | |
| f"GRN confirms {int(recv_qty)} of {int(items[0].get('quantity',0))} units received. Approving ${amt:,.2f} per Rule 3.") | |
| elif task_id == 'medium_price_discrepancy': | |
| inv_p = items[0].get('unit_price', 0) | |
| po_p = po_lines[0].get('agreed_unit_price', 1) | |
| dev = (inv_p - po_p) / po_p * 100 | |
| do_step('REJECT', 0.0, 'PRICE_DISCREPANCY', | |
| f"Invoice price ${inv_p:.2f} vs PO ${po_p:.2f} β {dev:.1f}% deviation exceeds tolerance. Rejecting per Rule 4.") | |
| elif task_id == 'medium_split_delivery': | |
| recv_qty = sum(l.get('received_quantity', 0) for g in grns for l in g.get('lines', [])) | |
| agreed_up = po_lines[0].get('agreed_unit_price', 0) | |
| amt = round(recv_qty * agreed_up + inv.get('freight_charge', 0), 2) | |
| do_step('APPROVE_FULL', amt, 'MATCH_CONFIRMED', | |
| f"Two GRNs confirm all {int(recv_qty)} units received across split shipments. Approving ${amt:,.2f}.") | |
| elif task_id == 'medium_vendor_mismatch': | |
| inv_v = inv.get('vendor_name', '') | |
| po_v = po.get('vendor_name', '') | |
| do_step('REJECT', 0.0, 'VENDOR_MISMATCH', | |
| f'Invoice vendor "{inv_v}" does not match PO vendor "{po_v}". Rejecting per Rule 7.') | |
| elif task_id == 'hard_policy_violation': | |
| do_step('REJECT', 0.0, 'POLICY_VIOLATION', | |
| f"Freight ${inv.get('freight_charge',0):.2f} exceeds cap. Manager: NOT pre-approved. Rejecting per Rule 2.") | |
| elif task_id == 'hard_duplicate_invoice': | |
| do_step('REJECT', 0.0, 'DUPLICATE_INVOICE', | |
| f"Invoice {inv['invoice_id']} already paid per ledger. Vendor confirmed. Rejecting per Rule 6.") | |
| elif task_id == 'hard_partial_po_match': | |
| po_descs = {pl.get('description','').lower() for p in pos if p.get('status')=='OPEN' for pl in p.get('lines',[])} | |
| amt = round(sum(li.get('line_total',0) for li in items if li.get('description','').lower() in po_descs), 2) | |
| do_step('APPROVE_PARTIAL', amt, 'NO_PO_FOUND', | |
| f"Only PO-covered items payable (${amt:,.2f}). Unapproved items excluded per Rule 8.") | |
| elif task_id == 'hard_tax_discrepancy': | |
| tax = inv.get('tax_amount', 0) | |
| do_step('REJECT', 0.0, 'TAX_DISCREPANCY', | |
| f"Invoice includes ${tax:.2f} tax not in PO authorisation. Unapproved per Rule 8.") | |
| elif task_id == 'hard_currency_conversion': | |
| policy = obs.get('company_policy', '') | |
| fx_match = re.search(r'EUR 1\.00 = USD (\d+\.\d+)', policy) | |
| fx = float(fx_match.group(1)) if fx_match else 1.0 | |
| inv_eur = total | |
| inv_usd = round(inv_eur * fx, 2) | |
| po_usd = po.get('authorized_total', inv_usd) | |
| tol = obs.get('price_tolerance', 0.05) | |
| dev = (inv_usd - po_usd) / po_usd if po_usd > 0 else 1.0 | |
| if dev <= tol: | |
| do_step('APPROVE_FULL', inv_usd, 'MATCH_CONFIRMED', | |
| f"EUR {inv_eur:,.2f} Γ {fx:.2f} = USD {inv_usd:,.2f}. Within PO ${po_usd:,.2f}. Approving.") | |
| else: | |
| do_step('REJECT', 0.0, 'PRICE_DISCREPANCY', | |
| f"EUR {inv_eur:,.2f} Γ {fx:.2f} = USD {inv_usd:,.2f} exceeds PO ${po_usd:,.2f} by {dev*100:.1f}%.") | |
| elif task_id == 'hard_manager_preapproval': | |
| approval = any('[MANAGER]' in n for n in notes) | |
| if approval: | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"Freight override confirmed by Finance Manager. Approving ${total:,.2f}.") | |
| else: | |
| do_step('REJECT', 0.0, 'POLICY_VIOLATION', | |
| f"Freight ${inv.get('freight_charge',0):.2f} exceeds cap. No pre-approval found.") | |
| elif task_id == 'hard_credit_memo': | |
| po_ref = inv.get('po_reference', '') | |
| po_exists = any(p.get('po_number') == po_ref and p.get('status') == 'OPEN' for p in pos) | |
| credit_amt = abs(total) # credit memos have negative invoice_total β approved_amount must be >= 0 | |
| if po_exists: | |
| do_step('APPROVE_PARTIAL', credit_amt, 'MATCH_CONFIRMED', | |
| f"Credit memo {inv['invoice_id']} has valid OPEN PO. Credit ${credit_amt:,.2f} approved per Rule 9.") | |
| else: | |
| do_step('REJECT', 0.0, 'NO_PO_FOUND', | |
| f"Credit memo {inv['invoice_id']} β no valid OPEN PO. Rejecting.") | |
| elif task_id == 'long_invoice_dispute': | |
| agreed = po_lines[0].get('agreed_unit_price', 0) | |
| qty = items[0].get('quantity', 0) | |
| correct_total = round(qty * agreed + inv.get('freight_charge', 0), 2) | |
| do_step('REJECT', 0.0, 'PRICE_DISCREPANCY', | |
| f"Invoice price ${items[0].get('unit_price',0):.2f} vs PO ${agreed:.2f}. Correct amount ${correct_total:,.2f}. Rejecting original per Rule 4.") | |
| elif task_id == 'long_policy_migration': | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"Policy update confirmed new freight cap. Freight now compliant. Three-way match confirmed. Approving ${total:,.2f}.") | |
| elif task_id == 'long_batch_reconciliation': | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"Independent three-way match confirmed in batch context. PO, GRN, invoice ${total:,.2f} all match. Approving.") | |
| elif task_id == 'long_manager_chain': | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"VP Finance pre-approval confirmed via escalation chain. Approving ${total:,.2f}.") | |
| elif task_id == 'long_fraud_investigation': | |
| do_step('REJECT', 0.0, 'DUPLICATE_INVOICE', | |
| f"Invoice {inv['invoice_id']} confirmed duplicate by manager audit. Vendor dispute rejected. Rejecting ${total:,.2f} per Rule 6.") | |
| elif task_id == 'long_audit_trail': | |
| po_num = po.get('po_number', 'N/A') | |
| grn_id = grns[0].get('grn_id', 'N/A') if grns else 'N/A' | |
| do_step('APPROVE_FULL', total, 'MATCH_CONFIRMED', | |
| f"SOX audit trail: PO {po_num} confirmed OPEN; GRN {grn_id} confirms receipt; Invoice ${total:,.2f} matches. Compliance cleared. Approving per Rule 1.") | |
| elif task_id == 'long_multi_vendor_split': | |
| do_step('APPROVE_PARTIAL', total, 'MATCH_CONFIRMED', | |
| f"First tranche: invoice ${total:,.2f} covers {int(items[0].get('quantity',0))} units per split delivery. Approving first tranche only.") | |
| return steps | |
| # ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| RUNNABLE_TASKS = [ | |
| # easy | |
| 'easy_perfect_match', 'easy_no_po_found', | |
| # medium | |
| 'medium_quantity_shortfall', 'medium_price_discrepancy', | |
| 'medium_split_delivery', 'medium_vendor_mismatch', | |
| # hard | |
| 'hard_policy_violation', 'hard_duplicate_invoice', | |
| 'hard_partial_po_match', 'hard_tax_discrepancy', | |
| 'hard_currency_conversion', 'hard_manager_preapproval', 'hard_credit_memo', | |
| # long-horizon | |
| 'long_invoice_dispute', 'long_policy_migration', 'long_batch_reconciliation', | |
| 'long_manager_chain', 'long_fraud_investigation', 'long_audit_trail', | |
| 'long_multi_vendor_split', | |
| ] | |
| DIFFICULTY = { | |
| 'easy_perfect_match': 'easy', 'easy_no_po_found': 'easy', | |
| 'medium_quantity_shortfall': 'medium', 'medium_price_discrepancy': 'medium', | |
| 'medium_split_delivery': 'medium', 'medium_vendor_mismatch': 'medium', | |
| 'hard_policy_violation': 'hard', 'hard_duplicate_invoice': 'hard', | |
| 'hard_partial_po_match': 'hard', 'hard_tax_discrepancy': 'hard', | |
| 'hard_currency_conversion': 'hard', 'hard_manager_preapproval': 'hard', | |
| 'hard_credit_memo': 'hard', | |
| 'long_invoice_dispute': 'long-horizon', 'long_policy_migration': 'long-horizon', | |
| 'long_batch_reconciliation': 'long-horizon', 'long_manager_chain': 'long-horizon', | |
| 'long_fraud_investigation': 'long-horizon', 'long_audit_trail': 'long-horizon', | |
| 'long_multi_vendor_split': 'long-horizon', | |
| } | |
| COLORS = {'easy': '#2ecc71', 'medium': '#f39c12', 'hard': '#e74c3c', 'long-horizon': '#9b59b6'} | |
| def main(): | |
| print(f'Baseline run β {ENV}') | |
| print(f'Started: {datetime.datetime.now().isoformat()}') | |
| print('=' * 70) | |
| all_results = {} | |
| by_diff = {'easy': [], 'medium': [], 'hard': [], 'long-horizon': []} | |
| for task_id in RUNNABLE_TASKS: | |
| diff = DIFFICULTY[task_id] | |
| try: | |
| steps = run_task(task_id) | |
| final_score = steps[-1]['score'] | |
| all_results[task_id] = { | |
| 'difficulty': diff, | |
| 'steps': steps, | |
| 'final_score': final_score, | |
| 'num_steps': len(steps), | |
| } | |
| by_diff[diff].append(final_score) | |
| step_str = ' -> '.join(f"{s['action']}({s['score']:.2f})" for s in steps) | |
| print(f"[{diff[:4]}] {task_id:<38} {final_score:.3f} | {step_str}") | |
| except Exception as e: | |
| print(f" ERROR {task_id}: {e}") | |
| all_results[task_id] = {'difficulty': diff, 'steps': [], 'final_score': 0.01, 'num_steps': 0, 'error': str(e)} | |
| by_diff[diff].append(0.01) | |
| time.sleep(0.3) | |
| # Summary | |
| print('=' * 70) | |
| summary = {} | |
| for diff in ['easy', 'medium', 'hard', 'long-horizon']: | |
| scores = by_diff[diff] | |
| if scores: | |
| mean = sum(scores) / len(scores) | |
| summary[diff] = {'mean': round(mean, 4), 'scores': [round(s, 4) for s in scores], 'n': len(scores)} | |
| print(f" {diff:<14}: mean={mean:.3f} n={len(scores)} scores={[round(s,3) for s in scores]}") | |
| all_scores = [v['final_score'] for v in all_results.values()] | |
| overall_mean = sum(all_scores) / len(all_scores) | |
| print(f" {'OVERALL':<14}: mean={overall_mean:.3f} n={len(all_scores)}") | |
| print('=' * 70) | |
| # Save JSON | |
| output = { | |
| 'run_type': 'optimal_scripted_agent', | |
| 'env_url': ENV, | |
| 'seed': SEED, | |
| 'timestamp': datetime.datetime.now().isoformat(), | |
| 'summary': summary, | |
| 'overall_mean': round(overall_mean, 4), | |
| 'tasks': all_results, | |
| } | |
| with open(OUT_JSON, 'w') as f: | |
| json.dump(output, f, indent=2) | |
| print(f'Saved: {OUT_JSON}') | |
| # ββ Plots ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fig = plt.figure(figsize=(16, 10)) | |
| gs = gridspec.GridSpec(2, 2, figure=fig, hspace=0.4, wspace=0.35) | |
| # Plot 1: Score per task (bar chart) | |
| ax1 = fig.add_subplot(gs[0, :]) | |
| task_names = list(all_results.keys()) | |
| scores = [all_results[t]['final_score'] for t in task_names] | |
| bar_colors = [COLORS[all_results[t]['difficulty']] for t in task_names] | |
| bars = ax1.bar(range(len(task_names)), scores, color=bar_colors, alpha=0.85, edgecolor='white') | |
| ax1.set_xticks(range(len(task_names))) | |
| ax1.set_xticklabels([t.replace('_', '\n') for t in task_names], fontsize=7, rotation=0) | |
| ax1.set_ylim(0, 1.05) | |
| ax1.set_ylabel('Final Score (0.01β0.99)') | |
| ax1.set_title('Optimal Agent Baseline β Score per Task', fontsize=13, fontweight='bold') | |
| ax1.axhline(overall_mean, color='black', linestyle='--', linewidth=1.2, label=f'Overall mean: {overall_mean:.3f}') | |
| for bar, score in zip(bars, scores): | |
| ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01, | |
| f'{score:.2f}', ha='center', va='bottom', fontsize=7) | |
| from matplotlib.patches import Patch | |
| legend_els = [Patch(facecolor=c, label=d) for d, c in COLORS.items()] | |
| legend_els.append(plt.Line2D([0],[0], color='black', linestyle='--', label=f'Mean {overall_mean:.3f}')) | |
| ax1.legend(handles=legend_els, loc='lower right', fontsize=8) | |
| # Plot 2: Mean score by difficulty | |
| ax2 = fig.add_subplot(gs[1, 0]) | |
| diffs = [d for d in ['easy', 'medium', 'hard', 'long-horizon'] if by_diff[d]] | |
| means = [sum(by_diff[d])/len(by_diff[d]) for d in diffs] | |
| colors = [COLORS[d] for d in diffs] | |
| ax2.bar(diffs, means, color=colors, alpha=0.85, edgecolor='white') | |
| for i, (d, m) in enumerate(zip(diffs, means)): | |
| ax2.text(i, m + 0.01, f'{m:.3f}', ha='center', fontsize=9, fontweight='bold') | |
| ax2.set_ylim(0, 1.05) | |
| ax2.set_ylabel('Mean Score') | |
| ax2.set_title('Mean Score by Difficulty', fontsize=11, fontweight='bold') | |
| # Plot 3: Step-level rewards for multi-step tasks | |
| ax3 = fig.add_subplot(gs[1, 1]) | |
| multi_step = {t: v for t, v in all_results.items() if v['num_steps'] > 1} | |
| for task_id, v in multi_step.items(): | |
| step_scores = [s['score'] for s in v['steps']] | |
| label = task_id.replace('_', ' ') | |
| ax3.plot(range(1, len(step_scores)+1), step_scores, marker='o', | |
| color=COLORS[v['difficulty']], alpha=0.7, linewidth=1.5, | |
| label=label if len(label) < 22 else label[:20]+'β¦') | |
| ax3.set_xlabel('Step Number') | |
| ax3.set_ylabel('Score at Step') | |
| ax3.set_title('Per-Step Rewards β Multi-Step Tasks', fontsize=11, fontweight='bold') | |
| ax3.set_ylim(0, 1.05) | |
| ax3.legend(fontsize=6, loc='lower right') | |
| plt.suptitle(f'AP Commander β Optimal Agent Baseline | seed={SEED} | {datetime.datetime.now().strftime("%Y-%m-%d")}', | |
| fontsize=12, y=1.01) | |
| plt.savefig(OUT_PLOT, dpi=130, bbox_inches='tight') | |
| print(f'Saved: {OUT_PLOT}') | |
| plt.close() | |
| if __name__ == '__main__': | |
| main() | |