| |
| """ |
| validator.py β CashFlowCareTaker (Layer C) |
| ============================================ |
| Three validation steps before any DB write: |
| |
| 1. FX Normaliser β converts any currency β USD + PKR using risk_factors.json |
| 2. Schema Enforcer β ensures description, amount, date are not null |
| 3. Human-in-the-loop β prints evidence, prompts "yes" to confirm |
| |
| Public API |
| ---------- |
| validate_and_save(analysis_result: dict, source_type: str) -> SaveResult dict |
| """ |
|
|
| from __future__ import annotations |
| import json |
| import os |
| import sys |
| import datetime |
|
|
| sys.path.insert(0, os.path.dirname(__file__)) |
| from db_setup import get_conn |
| from vector_store import add_transaction |
|
|
| RISK_FACTORS_PATH = ( |
| "/Volumes/ssd2/TEXBASE/src/PO:Quotation/Stats_data_collection/risk_factors.json')) |
| ) |
| |
| |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| # FX NORMALISER |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| def _load_fx_rates() -> dict: |
| """Load FX rates from risk_factors.json data_snapshot.""" |
| try: |
| with open(RISK_FACTORS_PATH, encoding="utf-8") as f: |
| data = json.load(f) |
| snap = data.get("data_snapshot", {}) |
| rates = { |
| "USD": 1.0, |
| "PKR": 1.0 / snap.get("usd_pkr", 278.716), # PKR β USD |
| "EUR": snap.get("eur_usd", 1.182), # EUR β USD (approximate) |
| "CNY": 1.0 / (snap.get("cny_pkr", 40.57) / snap.get("usd_pkr", 278.716)), |
| } |
| usd_pkr = snap.get("usd_pkr", 278.716) |
| return {"rates_to_usd": rates, "usd_pkr": usd_pkr, "snapshot": snap} |
| except Exception as e: |
| print(f" [FX] Warning: could not load risk_factors.json: {e}") |
| return {"rates_to_usd": {"USD": 1.0, "PKR": 1/278.716, "EUR": 1.182, "CNY": 0.138}, |
| "usd_pkr": 278.716, "snapshot": {}} |
| |
| |
| def normalise_fx(parsed: dict) -> dict: |
| """ |
| Add amount_usd, amount_pkr, fx_rate_used to parsed dict. |
| Returns updated parsed dict. |
| """ |
| fx = _load_fx_rates() |
| currency = (parsed.get("currency") or "USD").upper() |
| amount = float(parsed.get("amount") or 0) |
| |
| rate_to_usd = fx["rates_to_usd"].get(currency, 1.0) |
| usd_pkr = fx["usd_pkr"] |
| |
| amount_usd = round(amount * rate_to_usd, 4) |
| amount_pkr = round(amount_usd * usd_pkr, 2) |
| |
| snap = fx["snapshot"] |
| print(f"\n[FX] {amount:,.2f} {currency} β " |
| f"${amount_usd:,.4f} USD | β¨{amount_pkr:,.2f} PKR") |
| print(f" Rates from risk_factors.json: usd_pkr={snap.get('usd_pkr')}, " |
| f"eur_pkr={snap.get('eur_pkr')}, cny_pkr={snap.get('cny_pkr')}") |
| |
| return { |
| **parsed, |
| "amount_usd": amount_usd, |
| "amount_pkr": amount_pkr, |
| "fx_rate_used": rate_to_usd, |
| } |
| |
| |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| # SCHEMA ENFORCER |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| def enforce_schema(parsed: dict) -> list[str]: |
| """ |
| Check mandatory fields. Returns list of validation errors (empty = OK). |
| """ |
| errors = [] |
| if not parsed.get("description", "").strip(): |
| errors.append("description is null/empty") |
| if not parsed.get("amount") and parsed.get("amount") != 0: |
| errors.append("amount is null") |
| if not parsed.get("date", "").strip(): |
| errors.append("date is null/empty") |
| return errors |
| |
| |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| # HUMAN-IN-THE-LOOP |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| def _print_evidence(parsed: dict, similar_entries: list, candidates: list) -> None: |
| """Display all evidence before asking the user to confirm.""" |
| print("\n" + "β" * 65) |
| print(" EVIDENCE SUMMARY β Please review before confirming") |
| print("β" * 65) |
| print(f" Type : {parsed.get('transaction_type', '?').upper()}") |
| print(f" Description : {parsed.get('description', '?')}") |
| print(f" Counterparty : {parsed.get('counterparty', '?')}") |
| print(f" Amount (raw) : {parsed.get('amount_raw', '?')}") |
| print(f" Amount (USD) : ${parsed.get('amount_usd', 0):,.4f}") |
| print(f" Amount (PKR) : β¨{parsed.get('amount_pkr', 0):,.2f}") |
| print(f" FX Rate used : {parsed.get('fx_rate_used', '?')}") |
| print(f" Date : {parsed.get('date', '?')}") |
| print(f" Confidence : {parsed.get('confidence', '?')}") |
| if parsed.get("notes"): |
| print(f" Notes : {parsed.get('notes')}") |
| |
| if similar_entries: |
| print(f"\n VECTOR DB MATCHES ({len(similar_entries)}):") |
| for h in similar_entries[:3]: |
| print(f" [{h['distance']:.3f}] {h['text'][:70]}") |
| |
| if candidates: |
| print(f"\n EXISTING DB MATCHES ({len(candidates)}):") |
| for c in candidates: |
| print(f" {c.get('source_table','?')} | {c.get('counterparty','?')} | " |
| f"{c.get('amount_raw','?')} | {c.get('date','?')}") |
| |
| print("β" * 65) |
| |
| |
| def human_confirm(parsed: dict, similar_entries: list, candidates: list, auto_confirm: bool = False) -> bool: |
| """Show evidence and ask user to type 'yes' to confirm save.""" |
| _print_evidence(parsed, similar_entries, candidates) |
| if auto_confirm: |
| print("\n [Validator] Auto-confirmed by Superagent. Proceeding to save.") |
| return True |
| try: |
| answer = input("\n βΆ Type 'yes' to SAVE to database, or anything else to CANCEL: ").strip().lower() |
| return answer in ("yes", "y") |
| except (EOFError, KeyboardInterrupt): |
| print("\n Cancelled.") |
| return False |
| |
| |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| # SAVE TO DB |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| def _save_record(parsed: dict, source_type: str, vector_match: str) -> int: |
| """Insert validated record into intakes or outtakes. Returns row id.""" |
| table = "intakes" if parsed.get("transaction_type") == "intake" else "outtakes" |
| conn = get_conn() |
| cur = conn.execute( |
| f"""INSERT INTO {table} |
| (description, amount_raw, amount_usd, amount_pkr, currency, |
| fx_rate_used, date, source, counterparty, vector_match) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| parsed.get("description", ""), |
| parsed.get("amount_raw", ""), |
| parsed.get("amount_usd"), |
| parsed.get("amount_pkr"), |
| parsed.get("currency", ""), |
| parsed.get("fx_rate_used"), |
| parsed.get("date", ""), |
| source_type, |
| parsed.get("counterparty", ""), |
| vector_match, |
| ), |
| ) |
| conn.commit() |
| row_id = cur.lastrowid |
| conn.close() |
| return row_id |
| |
| |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| # MAIN ENTRY POINT |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| def validate_and_save(analysis_result: dict, source_type: str = "manual", auto_confirm: bool = False) -> dict: |
| """ |
| Layer C entry point. |
|
|
| Parameters |
| ---------- |
| analysis_result : output of gemini_analyst.analyse() |
| source_type : string label for the source column |
|
|
| Returns |
| ------- |
| dict {saved: bool, table: str, row_id: int, errors: list} |
| """ |
| parsed = analysis_result.get("parsed", {}) |
| similar_entries = analysis_result.get("similar_entries", []) |
| candidates = analysis_result.get("candidates", []) |
| |
| if analysis_result.get("conflict"): |
| print(f"\n[Validator] HIGH CONFLICT detected (id={analysis_result.get('conflict_id')}).") |
| print(" This transaction has been saved to the conflicts table for manual review.") |
| return {"saved": False, "table": "conflicts", |
| "row_id": analysis_result.get("conflict_id"), "errors": []} |
| |
| if analysis_result.get("error"): |
| print(f"\n[Validator] Cannot proceed: {analysis_result['error']}") |
| return {"saved": False, "table": None, "row_id": None, |
| "errors": [analysis_result["error"]]} |
| |
| # ββ Step 1: FX Normalise βββββββββββββββββββββββββββββββββββββββββββββββ |
| parsed = normalise_fx(parsed) |
| |
| # ββ Step 2: Schema Enforce βββββββββββββββββββββββββββββββββββββββββββββ |
| errors = enforce_schema(parsed) |
| if errors: |
| print(f"\n[Validator] Schema errors: {errors}") |
| return {"saved": False, "table": None, "row_id": None, "errors": errors} |
| |
| # ββ Step 3: Human-in-the-loop ββββββββββββββββββββββββββββββββββββββββββ |
| confirmed = human_confirm(parsed, similar_entries, candidates, auto_confirm=auto_confirm) |
| if not confirmed: |
| print("\n [Validator] Save cancelled by user.") |
| return {"saved": False, "table": None, "row_id": None, "errors": ["User cancelled"]} |
| |
| # ββ Save βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| vector_match = similar_entries[0]["text"][:120] if similar_entries else "" |
| table = "intakes" if parsed.get("transaction_type") == "intake" else "outtakes" |
| row_id = _save_record(parsed, source_type, vector_match) |
| |
| # ββ Add to vector store for future searches ββββββββββββββββββββββββββββ |
| vec_text = ( |
| f"{table.upper()} | {parsed.get('counterparty','')} | " |
| f"{parsed.get('description','')} | {parsed.get('amount_raw','')} | " |
| f"{parsed.get('date','')}" |
| ) |
| add_transaction(vec_text, { |
| "type": table, |
| "counterparty": parsed.get("counterparty", ""), |
| "currency": parsed.get("currency", ""), |
| "date": parsed.get("date", ""), |
| }) |
| |
| print(f"\n β
Saved to '{table}' (id={row_id})") |
| return {"saved": True, "table": table, "row_id": row_id, "errors": []} |
| |