Spaces:
Runtime error
Runtime error
| """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 | |
| 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 | |
| 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 | |
| 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"(?<!^)(?=[A-Z])", "_", api_property).lower() | |
| if snake in slots: | |
| return slots[snake], snake, "user" | |
| # Context / identity / time fields | |
| name_low = (api_property or "").lower() | |
| if name_low.startswith("ctxt") or "language" in name_low: | |
| return "EN", None, "default" | |
| if "guid" in name_low: | |
| return uuid.uuid4().hex, None, "derived" | |
| if "timestamp" in name_low or "createddate" in name_low or "lastmodifieddate" in name_low: | |
| return int(time.time()), None, "derived" | |
| if name_low in ("createdby", "lastmodifiedby"): | |
| return "ramco-chat", None, "default" | |
| return None, None, "missing" | |
| def build_payload_for_api( | |
| journey_kb: JourneyKB, | |
| api_spec: dict, | |
| slots: dict[str, Any], | |
| ) -> 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 | |
| ], | |
| } | |