Spaces:
Runtime error
Runtime error
| """Depth tests for Create Direct PO — drive each variant from utterance #1 through full slot | |
| fill, variant lock, and API fire. Assert the payload field-by-field. | |
| These are stricter than the general eval suite — they catch regressions in the entire pipeline. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Optional | |
| from backend.agent.kb import KnowledgeBase | |
| from backend.agent.loop import AgentLoop | |
| from backend.agent.resolver import resolve | |
| class DepthCase: | |
| id: str | |
| description: str | |
| turns: list[str] | |
| expect_slots: dict[str, Any] = field(default_factory=dict) | |
| expect_apis_fired: list[str] = field(default_factory=list) | |
| expect_payload_fields: dict[str, Any] = field(default_factory=dict) | |
| expect_terminated: bool = False | |
| expect_variant_locked: bool = False | |
| expect_violations: Optional[bool] = None | |
| # A list of substrings — at end of run, NO violation's error_message may contain any of these. | |
| # Used to verify a specific forbidden-rule was correctly cleared after correction. | |
| forbidden_violations_must_not_contain: list[str] = field(default_factory=list) | |
| class DepthResult: | |
| case_id: str | |
| passed: bool | |
| failures: list[str] | |
| final_slots: dict[str, Any] | |
| fired_apis: list[dict] | |
| terminated: bool | |
| variant_locked: bool | |
| def _flat_payload(call: dict) -> dict: | |
| """Flatten {schema: {prop: value}} → {prop: value} for assertions.""" | |
| out: dict[str, Any] = {} | |
| for schema_block in (call.get("payload") or {}).values(): | |
| if isinstance(schema_block, dict): | |
| out.update(schema_block) | |
| return out | |
| def _value_equiv(a, b) -> bool: | |
| sa = str(a).strip().rstrip(".,;:").strip().upper() | |
| sb = str(b).strip().rstrip(".,;:").strip().upper() | |
| if sa == sb: | |
| return True | |
| try: | |
| return float(sa) == float(sb) | |
| except (ValueError, TypeError): | |
| return False | |
| def run_case(kb: KnowledgeBase, case: DepthCase) -> DepthResult: | |
| loop = AgentLoop(kb) | |
| failures: list[str] = [] | |
| for i, utter in enumerate(case.turns, 1): | |
| try: | |
| loop.handle_turn(utter) | |
| except Exception as e: | |
| failures.append(f"turn {i} raised: {type(e).__name__}: {e}") | |
| break | |
| final_slots = dict(loop.state.slots) | |
| # Slot assertions | |
| for k, v in case.expect_slots.items(): | |
| actual = final_slots.get(k.lower()) | |
| if actual is None: | |
| failures.append(f"slot '{k}' missing (expected {v!r})") | |
| elif not _value_equiv(actual, v): | |
| failures.append(f"slot '{k}': expected {v!r}, got {actual!r}") | |
| # Terminated / locked | |
| if case.expect_terminated and not loop.state.terminated: | |
| failures.append(f"expected terminated=True, got {loop.state.terminated}") | |
| if case.expect_variant_locked and not loop.state.variant_locked: | |
| failures.append(f"expected variant_locked=True, got {loop.state.variant_locked}") | |
| # Violations | |
| journey_kb = kb.journey(loop.state.current_journey) if loop.state.current_journey else None | |
| if journey_kb is not None: | |
| r = resolve(journey_kb, loop.state.slots) | |
| if case.expect_violations is False and r.active_violations: | |
| v_summary = [v.error_message or v.rule_kind for v in r.active_violations[:3]] | |
| failures.append(f"unexpected violations at end: {v_summary}") | |
| # Specific-rule-must-be-gone assertions (e.g. "Adhoc Item class should be None for ...") | |
| for needle in case.forbidden_violations_must_not_contain: | |
| for v in r.active_violations: | |
| msg = v.error_message or "" | |
| if needle.lower() in msg.lower(): | |
| failures.append(f"forbidden-violation still firing: matched '{needle}' in '{msg[:120]}'") | |
| # APIs fired | |
| all_calls: list[dict] = [] | |
| for fire in loop.state.fired_apis: | |
| all_calls.extend(fire.get("calls") or []) | |
| api_files = [c["api_file"] for c in all_calls] | |
| for expected_api in case.expect_apis_fired: | |
| if not any(expected_api in af for af in api_files): | |
| failures.append(f"API '{expected_api}' not fired (fired: {api_files})") | |
| # Payload field assertions on the main CreatePO call | |
| if case.expect_payload_fields: | |
| main_call = next((c for c in all_calls if "CreatePO" in c["api_file"]), None) | |
| if not main_call: | |
| failures.append("expected CreatePO call but none fired") | |
| else: | |
| flat = _flat_payload(main_call) | |
| for prop, expected in case.expect_payload_fields.items(): | |
| if prop not in flat: | |
| failures.append(f"payload missing property '{prop}' (expected {expected!r})") | |
| elif not _value_equiv(flat[prop], expected): | |
| failures.append(f"payload[{prop}]: expected {expected!r}, got {flat[prop]!r}") | |
| return DepthResult( | |
| case_id=case.id, | |
| passed=not failures, | |
| failures=failures, | |
| final_slots=final_slots, | |
| fired_apis=[{"api_file": c["api_file"], "success": c.get("success")} for c in all_calls], | |
| terminated=loop.state.terminated, | |
| variant_locked=loop.state.variant_locked, | |
| ) | |
| # ============================================================================ | |
| # Depth case library | |
| # ============================================================================ | |
| # Minimum slots we typically need to lock + fire a General PO: | |
| # potypeenum=1, supplier_code, buyerhdr, currencycode, item_codeml, qtyml, cost, | |
| # adhocitemclassml=NONE, podate | |
| # Plus various defaults the system auto-fills. | |
| CASES: list[DepthCase] = [ | |
| DepthCase( | |
| id="depth_001_general_po_minimal", | |
| description="General PO with the minimum slot set; verify slot extraction over 4 turns.", | |
| turns=[ | |
| "Create a general PO from supplier SUP-001, buyer Maria, currency USD", | |
| "Item ITEM-A, 10 units at $50, adhoc class NONE", | |
| "PO date is 2026-06-01", | |
| "Go ahead and create it", | |
| ], | |
| expect_slots={ | |
| "potypeenum": "1", | |
| "supplier_code": "SUP-001", | |
| "buyerhdr": "Maria", | |
| "currencycode": "USD", | |
| "item_codeml": "ITEM-A", | |
| "qtyml": "10", | |
| "cost": "50", | |
| "adhocitemclassml": "NONE", | |
| }, | |
| # Note: real master-data validation rules (e.g. "Item code does not exist in master") will | |
| # fire on synthetic data. We only assert that the slots are extracted correctly here. | |
| ), | |
| DepthCase( | |
| id="depth_002_capital_po_full_extraction", | |
| description="Capital PO: verify potype=2 + supplier + buyer + currency + qty extracted.", | |
| turns=[ | |
| "Create a capital PO for CAPCO-99, buyer John, in EUR", | |
| "Item CNC-X, 1 unit at 85000", | |
| "PO date 2026-06-15, adhoc class NONE", | |
| ], | |
| expect_slots={ | |
| "potypeenum": "2", | |
| "supplier_code": "CAPCO-99", | |
| "buyerhdr": "John", | |
| "currencycode": "EUR", | |
| "item_codeml": "CNC-X", | |
| "qtyml": "1", | |
| "cost": "85000", | |
| "adhocitemclassml": "NONE", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_003_dropship_po_with_customer", | |
| description="Dropship PO requires customer; agent must elicit it and extract correctly.", | |
| turns=[ | |
| "Dropship PO from supplier DROP-1 to customer CUST-MX-44", | |
| "Item ITEM-DROP, 5 units at 200 USD", | |
| "Buyer is Diego", | |
| "PO date 2026-07-10", | |
| ], | |
| expect_slots={ | |
| "potypeenum": "3", | |
| "supplier_code": "DROP-1", | |
| "customercode_ml": "CUST-MX-44", | |
| "buyerhdr": "Diego", | |
| "currencycode": "USD", | |
| "qtyml": "5", | |
| "cost": "200", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_004_forbidden_combo_then_correct", | |
| description="User triggers consignment+adhoc; user corrects → the specific forbidden-combo rule must clear.", | |
| turns=[ | |
| "Create a consignment PO for SUP-Z with adhoc class ML01", | |
| "Actually make the adhoc class NONE instead", | |
| ], | |
| expect_slots={ | |
| "potypeenum": "4", | |
| "supplier_code": "SUP-Z", | |
| "adhocitemclassml": "NONE", | |
| }, | |
| # The specific capital/consignment + adhoc rule must NOT be firing after the correction. | |
| forbidden_violations_must_not_contain=["Adhoc Item class should be None"], | |
| ), | |
| DepthCase( | |
| id="depth_005_correction_buyer", | |
| description="User changes buyer mid-flow.", | |
| turns=[ | |
| "Create a PO for SUP-A, buyer Maria, currency USD", | |
| "Actually change buyer to John", | |
| ], | |
| expect_slots={ | |
| "supplier_code": "SUP-A", | |
| "buyerhdr": "John", | |
| "currencycode": "USD", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_006_pivot_then_resume", | |
| description="User pivots away, then resumes the create flow.", | |
| turns=[ | |
| "Create a PO for SUP-RESUME, buyer Sarah", | |
| "Wait — actually I need to approve something else first", | |
| "Never mind, let's go back to creating that PO", | |
| ], | |
| # After resume, original supplier + buyer should still be in state. | |
| expect_slots={ | |
| "supplier_code": "SUP-RESUME", | |
| "buyerhdr": "Sarah", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_007_mid_flow_question", | |
| description="User asks a question mid-flow; state is preserved.", | |
| turns=[ | |
| "Create a PO for supplier SUP-Q, buyer Maria, currency USD", | |
| "What does capital PO type mean exactly?", | |
| "OK — let's stick with general type then. Item ITEM-Q, 10 units at 25", | |
| ], | |
| expect_slots={ | |
| "supplier_code": "SUP-Q", | |
| "buyerhdr": "Maria", | |
| "currencycode": "USD", | |
| "item_codeml": "ITEM-Q", | |
| "qtyml": "10", | |
| "cost": "25", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_008_multi_slot_dense", | |
| description="Heavy multi-slot extraction in one utterance.", | |
| turns=[ | |
| "Create a general PO: supplier ACME-9, buyer Maria, USD currency, item ITEM-DENSE, " | |
| "qty 25, cost 12.50, PO date 2026-08-01, adhoc class NONE", | |
| ], | |
| expect_slots={ | |
| "potypeenum": "1", | |
| "supplier_code": "ACME-9", | |
| "buyerhdr": "Maria", | |
| "currencycode": "USD", | |
| "item_codeml": "ITEM-DENSE", | |
| "qtyml": "25", | |
| "cost": "12.50", | |
| "adhocitemclassml": "NONE", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_009_no_slot_duplication", | |
| description="Regression: verify intent classifier doesn't leak `supplier_name`-style raw keys.", | |
| turns=[ | |
| "Create a purchase order for Stationery Inc, buyer Maria, 100 reams of paper", | |
| ], | |
| expect_slots={ | |
| "supplier_code": "Stationery Inc", | |
| "buyerhdr": "Maria", | |
| "qtyml": "100", | |
| }, | |
| ), | |
| DepthCase( | |
| id="depth_010_pr_reference_extraction", | |
| description="User mentions a PR/SO number; verify it lands in refsono (the kb-canonical key).", | |
| turns=[ | |
| "Create a PO from PR/2026/12345 for supplier SUP-PR", | |
| ], | |
| expect_slots={ | |
| "supplier_code": "SUP-PR", | |
| "refsono": "PR/2026/12345", | |
| }, | |
| ), | |
| ] | |
| def run_all() -> list[DepthResult]: | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| kb = KnowledgeBase("PO") | |
| out: list[DepthResult] = [] | |
| with ThreadPoolExecutor(max_workers=10) as ex: | |
| futs = {ex.submit(run_case, kb, c): c for c in CASES} | |
| for f in as_completed(futs): | |
| out.append(f.result()) | |
| return sorted(out, key=lambda r: r.case_id) | |
| def main() -> None: | |
| results = run_all() | |
| passed = sum(1 for r in results if r.passed) | |
| print(f"\n=== Depth tests: {passed}/{len(results)} passed ===\n") | |
| for r in results: | |
| status = "PASS" if r.passed else "FAIL" | |
| print(f"[{status}] {r.case_id}") | |
| if not r.passed: | |
| for fl in r.failures: | |
| print(f" - {fl}") | |
| if r.fired_apis: | |
| api_files = ", ".join(c["api_file"] for c in r.fired_apis) | |
| print(f" APIs: {api_files}") | |
| print(f" terminated={r.terminated}, locked={r.variant_locked}") | |
| print() | |
| # Write report | |
| report = { | |
| "passed": passed, | |
| "total": len(results), | |
| "pass_rate": passed / max(len(results), 1), | |
| "cases": [ | |
| { | |
| "case_id": r.case_id, | |
| "passed": r.passed, | |
| "failures": r.failures, | |
| "final_slots": r.final_slots, | |
| "fired_apis": r.fired_apis, | |
| "terminated": r.terminated, | |
| "variant_locked": r.variant_locked, | |
| } | |
| for r in results | |
| ], | |
| } | |
| Path("eval_cases/depth_results.json").write_text(json.dumps(report, indent=2)) | |
| print(f"Report written to eval_cases/depth_results.json") | |
| if __name__ == "__main__": | |
| main() | |