"""Stage 7c — Multi-API executor. Given a locked variant + filled slots, builds the request payloads for the main-screen API and every touched sub-screen API, then fires them in dependency order. For Stage 7 we MOCK the HTTP layer: the executor returns synthesized responses keyed off the expected response schema, with success/error simulation paths so the eval suite can drive both. Dependency rules (for the create flow): 1. The MAIN screen save fires FIRST. Its response provides identity values (po_no, amendment_no) that sub-screen calls need. 2. Sub-screen APIs fire AFTER main, in any order. Sub-screens that the user touched (any slot from that sub-screen is filled) OR that a pre-variant rule marked mandatory for the locked variant get fired. 3. If any API errors, halt and surface the workbook-attached message. """ from __future__ import annotations import time import uuid from dataclasses import asdict, dataclass, field from typing import Any, Optional from backend.agent.kb import JourneyKB @dataclass class PayloadField: api_property: str schema: str segment: str value: Any source_slot: Optional[str] = None # which btsynonym provided the value source_kind: str = "user" # user | default | derived | identity @dataclass class ApiCall: screen: str # ui_name owning this API api_file: str # e.g. CreatePO3.0.json endpoint: str # /CreatePO method: str # POST service_name: str role: str # save_submit (always for now) payload: dict[str, Any] # nested dict keyed by schema -> property field_records: list[PayloadField] = field(default_factory=list) # Filled in after firing: fired_at: Optional[float] = None success: Optional[bool] = None response: Optional[dict] = None error: Optional[dict] = None @dataclass class ExecutionResult: journey: str calls: list[ApiCall] success: bool error_summary: Optional[str] = None elapsed_ms: float = 0.0 # ============================================================================ # Payload assembly # ============================================================================ def _resolve_value( journey_kb: JourneyKB, slots: dict[str, Any], api_property: str, api_data_item: str, schema: str, ) -> tuple[Any, Optional[str], str]: """Given the user's slots dict + an API field, return (value, source_btsynonym, source_kind). Search order: 1. Direct match on data_item == btsynonym (lowercased). 2. Alias-map lookup (slot_alias_map.json) for cross-name binding. 3. Property name → snake-case match against btsynonyms. 4. Default for context fields (ctxt_*, timestamps, GUIDs). Returns (None, None, "missing") if nothing matched.""" di = (api_data_item or "").lower() if di in slots: return slots[di], di, "user" # Try alias-map alias_idx = journey_kb.alias_map.get("screens", []) for screen_aliases in alias_idx: for a in screen_aliases.get("aliases", []): if (a.get("api_property") == api_property and (a.get("api_schema") or "").lower() == schema.lower()): bt = (a.get("btsynonym") or "").lower() if bt in slots: return slots[bt], bt, "user" # Snake-case property fallback import re snake = re.sub(r"(? tuple[dict[str, Any], list[PayloadField]]: """Walk the API's request_bindings and assemble a nested dict matching the request schema. The payload is shaped as `{schema: {segment: {property: value}}}` for header bindings and `{schema: [{property: value}, ...]}` for grid/details segments (which carry _grd_mseg). We keep it loose for now — the mock executor just inspects fields, not the exact shape. SKIP-sentinel handling: when the user explicitly declined to provide a value for a slot during the conversation, the resolver stores the SKIP sentinel in state. We must NEVER send the literal "__SKIP__" string to the API — strip the field instead so the API sees it as null/missing (which is what the user intended). """ from backend.agent.state import is_skip payload_grouped: dict[str, dict[str, Any]] = {} records: list[PayloadField] = [] for b in api_spec.get("request_bindings", []) or []: prop = b.get("property") di = b.get("data_item") or "" schema = b.get("schema") or "(unknown)" if not prop: continue val, src_slot, src_kind = _resolve_value(journey_kb, slots, prop, di, schema) if val is None and src_kind == "missing": # leave the field out of the payload (the API may treat missing as null) continue if is_skip(val): # user declined this slot — emit a trace-only record (source_kind=skipped_by_user) # so we keep an audit trail, but DON'T put it in the actual payload. records.append(PayloadField( api_property=prop, schema=schema, segment=b.get("segment") or "", value=None, source_slot=src_slot, source_kind="skipped_by_user", )) continue payload_grouped.setdefault(schema, {})[prop] = val records.append(PayloadField( api_property=prop, schema=schema, segment=b.get("segment") or "", value=val, source_slot=src_slot, source_kind=src_kind, )) return payload_grouped, records # ============================================================================ # Mock HTTP layer # ============================================================================ def _validate_against_master_data(slots: dict[str, Any], master_data: dict | None) -> dict | None: """If any user-provided slot value is NOT in its master_data table's valid_values list, return a mock error response. Otherwise return None to signal "all good, proceed". The error response includes a `valid_options_sample` — a short list of real, valid values the user can pick from. This is what powers the agent's "I couldn't find X. The valid suppliers are A, B, C — which would you like?" reply, plus the UI dropdown. """ if not master_data: return None from backend.agent.state import is_skip tables = master_data.get("tables") or {} for slot_name, table in tables.items(): v = slots.get(slot_name.lower()) if v is None or is_skip(v): continue valid_values = list(table.get("valid_values") or []) valid_lower = {str(x).lower() for x in valid_values} if not valid_lower: continue if str(v).lower() not in valid_lower: tmpl = table.get("error_message") or "{value} is not valid for {slot}" try: msg = tmpl.format(value=v, slot=slot_name) except Exception: msg = f"{slot_name}={v}: {tmpl}" return { "ok": False, "error": { "code": "MASTER_DATA_INVALID", "slot": slot_name, "value": v, "message": msg, # Top-N valid values so the agent can suggest + the UI can render a dropdown "valid_options_sample": valid_values[:8], "total_valid_count": len(valid_values), }, } return None def _mock_fire(call: ApiCall, master_data: dict | None = None, slots: dict[str, Any] | None = None) -> dict: """Simulated API response. If a `master_data_stub` is present for the journey, we validate every user-provided slot against it BEFORE pretending the API succeeded. A slot value not in its master-data list returns the Ramco-style error message verbatim — same as a real call would. For the main create call, success returns a synthetic po_no + amendment_no. For sub-screen calls, success returns ok. """ # Master-data validation runs ONCE on the main call (the one that has the headline po_no # output). Sub-screens trust whatever the main call validated. name = call.api_file.lower() is_main_create = "createpo" in name if is_main_create and slots is not None: md_err = _validate_against_master_data(slots, master_data) if md_err is not None: return md_err if is_main_create: return { "ok": True, "po_no": f"PO/MOCK/{uuid.uuid4().hex[:6].upper()}", "amendment_no": 0, "ou_instance": 1001, "status": "FR", } if name.startswith("specify"): return {"ok": True} return {"ok": True} # ============================================================================ # Main execution # ============================================================================ def _touched_subscreens(journey_kb: JourneyKB, slots: dict[str, Any]) -> set[str]: """A sub-screen is 'touched' iff the user has filled a slot that BINDS to that sub-screen's save API (per Stage 4's slot_alias_map). Header fields like `buyerhdr` that appear visually on every sub-screen but bind to the main API DON'T touch those sub-screens. """ # Map: api_file → ui_name (from api_map.json) api_to_ui: dict[str, str] = {} for s in journey_kb.api_map.get("screens") or []: for a in s.get("apis") or []: if a.get("role") == "save_submit": api_to_ui[a["api_file"]] = s["ui_name"] touched: set[str] = set() # Walk alias map for each filled slot; if the slot binds to a sub-screen's API, that # sub-screen is touched. filled = {k.lower() for k in slots.keys()} for screen_aliases in journey_kb.alias_map.get("screens") or []: for a in screen_aliases.get("aliases") or []: bt = (a.get("btsynonym") or "").lower() if bt not in filled: continue api_file = a.get("api_file") if not api_file: continue ui = api_to_ui.get(api_file) if ui and ui != journey_kb.main_screen_name: touched.add(ui) return touched def execute( journey_kb: JourneyKB, slots: dict[str, Any], *, fire: bool = True, inject_error_on: Optional[str] = None, ) -> ExecutionResult: """Build and fire all required API calls for the locked variant. inject_error_on: ui_name of a sub-screen whose call should be simulated to fail (for testing).""" t0 = time.time() main_ui = journey_kb.main_screen_name save_apis = journey_kb.save_apis_by_screen calls: list[ApiCall] = [] # 1) Main screen first if main_ui in save_apis: # Prefer the canonical "create" API (named like Create<...>) main_api_list = save_apis[main_ui] # Prefer api_file matching Create main_api = next( (a for a in main_api_list if "create" in a["api_file"].lower() and a["spec_version"] == "v3"), main_api_list[0], ) payload, records = build_payload_for_api(journey_kb, main_api, slots) calls.append(ApiCall( screen=main_ui, api_file=main_api["api_file"], endpoint=main_api["endpoint"], method=main_api["method"], service_name=main_api["service_name"], role=main_api["role"], payload=payload, field_records=records, )) # 2) Sub-screens that were touched touched = _touched_subscreens(journey_kb, slots) for ui in sorted(touched): if ui not in save_apis: continue # Prefer the "Specify*" save_submit for the create flow specify_apis = [a for a in save_apis[ui] if "specify" in a["api_file"].lower()] sub_api = (specify_apis or save_apis[ui])[0] payload, records = build_payload_for_api(journey_kb, sub_api, slots) calls.append(ApiCall( screen=ui, api_file=sub_api["api_file"], endpoint=sub_api["endpoint"], method=sub_api["method"], service_name=sub_api["service_name"], role=sub_api["role"], payload=payload, field_records=records, )) # 3) Fire them success = True error_summary: Optional[str] = None if fire: for call in calls: call.fired_at = time.time() if inject_error_on == call.screen: call.success = False call.error = {"code": "MOCK_001", "message": f"Mock error on {call.screen}"} success = False error_summary = f"{call.screen}: {call.error['message']}" break call.response = _mock_fire(call, master_data=journey_kb.master_data, slots=slots) call.success = bool(call.response.get("ok")) if not call.success: success = False err = call.response.get("error") or {} err_msg = err.get("message") if isinstance(err, dict) else str(err) error_summary = f"{call.screen}: {err_msg or 'unknown error'}" break elapsed = (time.time() - t0) * 1000 return ExecutionResult( journey=journey_kb.meta.activity_desc, calls=calls, success=success, error_summary=error_summary, elapsed_ms=elapsed, ) def execution_to_dict(result: ExecutionResult) -> dict: return { "journey": result.journey, "success": result.success, "error_summary": result.error_summary, "elapsed_ms": result.elapsed_ms, "call_count": len(result.calls), "calls": [ { "screen": c.screen, "api_file": c.api_file, "endpoint": c.endpoint, "service_name": c.service_name, "method": c.method, "payload": c.payload, "field_count": len(c.field_records), "field_sources": { "user": sum(1 for f in c.field_records if f.source_kind == "user"), "default": sum(1 for f in c.field_records if f.source_kind == "default"), "derived": sum(1 for f in c.field_records if f.source_kind == "derived"), }, "fired_at": c.fired_at, "success": c.success, "response": c.response, "error": c.error, } for c in result.calls ], }