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
        ],
    }