ramco-chat / backend /agent /executor.py
VizuaraAI's picture
deploy: ramco-chat (FastAPI + React, Docker SDK)
2bd7227 verified
Raw
History Blame Contribute Delete
15.4 kB
"""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
],
}