Spaces:
Runtime error
Runtime error
File size: 15,379 Bytes
a746f6a 2bd7227 a746f6a 2bd7227 a746f6a 2bd7227 a746f6a 2bd7227 a746f6a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | """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"(?<!^)(?=[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
],
}
|