Skip to content

Commit 135a76d

Browse files
bloveclaude
andauthored
feat(c-a2ui): Modify search prefills form with prior submit context (#454)
After Select → confirmation surface → Modify search, the booking form re-renders with the user's prior origin/dest/date/passengers/fare_class already populated as the field defaults — instead of starting blank. Today's flow: Modify search routes back through build_form which emits the spec with hardcoded blank data_model. The render is correct but forces the user to re-enter every field. Now build_form walks the message history via the existing _extract_prior_submit_context helper and seeds the form's data_model with whatever values the most recent bookingSubmit carried. Implementation: - Convert _BUILD_FORM_SYSTEM (constant) to _BUILD_FORM_SYSTEM_TMPL with a non-brace sentinel __DATA_MODEL_DEFAULTS__ that build_form() substitutes per call via str.replace (.format() would conflict with the many literal-brace JSON examples in the prompt). - Convert _SENTINEL_BOOKING_FORM (constant) to _build_sentinel_booking_form(defaults), so the sentinel honors prefilled values too — Modify search shouldn't blank the form if the LLM happens to retry-exhaust. - New _form_defaults_from_prior helper projects the prior context dict onto the form's data_model schema, falling back to _BLANK_FORM_DEFAULTS for any missing keys and normalizing passengers to int. - build_form now: extract prior → compute defaults → render prompt with those defaults → emit spec. Verified via 3-turn programmatic real-LLM smoke: submit(LAX→JFK, 2 pax, Business) → flightSelect(UA123) → modifySearch → form data_model = {origin:LAX, dest:JFK, date:2026-06-15, passengers:2, fare_class:Business} First-turn blank case still emits blank defaults (regression check). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5928653 commit 135a76d

1 file changed

Lines changed: 80 additions & 37 deletions

File tree

cockpit/chat/a2ui/python/src/graph.py

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,20 @@ async def _emit_with_retry(
265265
_AIRPORT_OPTIONS = [{"label": c, "value": c} for c in AIRPORT_CODES]
266266
_FARE_OPTIONS = [{"label": c, "value": c} for c in FARE_CLASSES]
267267

268-
_BUILD_FORM_SYSTEM = f"""You are an aviation booking-form designer. Emit an A2UI v1 booking form using the structured output schema.
268+
# The form's data_model carries the field default values. On first turn this
269+
# is blank; on a "Modify search" turn (after Select → confirmation surface →
270+
# Modify search) the user expects to see their prior origin/dest/date/
271+
# passengers/fare_class already populated. `build_form` walks message history
272+
# via `_extract_prior_submit_context` and substitutes those values into
273+
# {data_model_json} below.
274+
_BLANK_FORM_DEFAULTS: dict[str, Any] = {
275+
"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy",
276+
}
277+
278+
# `__DATA_MODEL_DEFAULTS__` is a non-brace sentinel substituted at call-time
279+
# by `build_form()` via `str.replace()` — using `.format()` would conflict
280+
# with the many literal-brace JSON examples below.
281+
_BUILD_FORM_SYSTEM_TMPL = f"""You are an aviation booking-form designer. Emit an A2UI v1 booking form using the structured output schema.
269282
270283
A2UI FORMAT (CRITICAL): each component is `{{"id": "...", "component": {{"<ComponentName>": {{<props>}}}}}}`. The component name is the SINGLE KEY of the inner dict. ComponentName must be one of:
271284
Column, Row, Card, Text, TextField, MultipleChoice, DateTimeInput, CheckBox, Button, Divider, List, Image, Icon, Modal, Slider, Tabs
@@ -282,7 +295,7 @@ async def _emit_with_retry(
282295
283296
Required form composition for THIS task:
284297
surface_id MUST be "booking"
285-
data_model MUST be {{"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"}}
298+
data_model MUST be __DATA_MODEL_DEFAULTS__ ← use these values verbatim as the field defaults
286299
287300
Build this component tree:
288301
root (Column, children=[card])
@@ -312,48 +325,78 @@ def _comp(id_: str, name: str, props: dict[str, Any]) -> A2uiComponent:
312325
return A2uiComponent(id=id_, component={name: props})
313326

314327

315-
_SENTINEL_BOOKING_FORM = BookingFormSpec(
316-
surface_id="booking",
317-
data_model={"origin": "", "dest": "", "date": "", "passengers": 1, "fare_class": "Economy"},
318-
components=[
319-
_comp("root", "Column", {"children": {"explicitList": ["card"]}}),
320-
_comp("card", "Card", {"child": "card_col"}),
321-
_comp("card_col", "Column", {"children": {"explicitList": [
322-
"title", "origin", "dest", "date", "passengers", "fare", "submit",
323-
]}}),
324-
_comp("title", "Text", {"text": "Book a flight (fallback)", "usageHint": "h2"}),
325-
_comp("origin", "MultipleChoice", {"label": "Origin", "options": _AIRPORT_OPTIONS,
326-
"selections": {"path": "/origin"}, "maxAllowedSelections": 1}),
327-
_comp("dest", "MultipleChoice", {"label": "Destination", "options": _AIRPORT_OPTIONS,
328-
"selections": {"path": "/dest"}, "maxAllowedSelections": 1}),
329-
_comp("date", "TextField", {"label": "Departure date (YYYY-MM-DD)",
330-
"text": {"path": "/date"}, "textFieldType": "date"}),
331-
_comp("passengers", "TextField", {"label": "Passengers",
332-
"text": {"path": "/passengers"}, "textFieldType": "number"}),
333-
_comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS,
334-
"selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}),
335-
_comp("submit", "Button", {"child": "submit_label", "primary": True,
336-
"action": {"name": "bookingSubmit", "context": [
337-
{"key": "formId", "value": "booking"},
338-
{"key": "origin", "value": {"path": "/origin"}},
339-
{"key": "dest", "value": {"path": "/dest"}},
340-
{"key": "date", "value": {"path": "/date"}},
341-
{"key": "passengers", "value": {"path": "/passengers"}},
342-
{"key": "fare_class", "value": {"path": "/fare_class"}},
343-
]}}),
344-
_comp("submit_label", "Text", {"text": "Search flights"}),
345-
],
346-
)
328+
def _form_defaults_from_prior(prior: dict[str, Any]) -> dict[str, Any]:
329+
"""Project prior bookingSubmit context onto the form's data_model schema.
330+
Falls back to blanks for any missing key so the returned dict always has
331+
the full {origin, dest, date, passengers, fare_class} shape."""
332+
defaults = dict(_BLANK_FORM_DEFAULTS)
333+
for key in defaults:
334+
if key in prior and prior[key] not in (None, ""):
335+
defaults[key] = prior[key]
336+
# Normalize passengers to an int (prior context may carry it as float).
337+
p = defaults.get("passengers")
338+
if isinstance(p, (int, float)):
339+
defaults["passengers"] = int(p)
340+
return defaults
341+
342+
343+
def _build_sentinel_booking_form(defaults: dict[str, Any]) -> BookingFormSpec:
344+
"""Hardcoded fallback form when LLM emit retry exhausts. Accepts the same
345+
`defaults` dict that the LLM-prompt path uses, so the sentinel respects
346+
Modify-search prefill too."""
347+
return BookingFormSpec(
348+
surface_id="booking",
349+
data_model=defaults,
350+
components=[
351+
_comp("root", "Column", {"children": {"explicitList": ["card"]}}),
352+
_comp("card", "Card", {"child": "card_col"}),
353+
_comp("card_col", "Column", {"children": {"explicitList": [
354+
"title", "origin", "dest", "date", "passengers", "fare", "submit",
355+
]}}),
356+
_comp("title", "Text", {"text": "Book a flight (fallback)", "usageHint": "h2"}),
357+
_comp("origin", "MultipleChoice", {"label": "Origin", "options": _AIRPORT_OPTIONS,
358+
"selections": {"path": "/origin"}, "maxAllowedSelections": 1}),
359+
_comp("dest", "MultipleChoice", {"label": "Destination", "options": _AIRPORT_OPTIONS,
360+
"selections": {"path": "/dest"}, "maxAllowedSelections": 1}),
361+
_comp("date", "TextField", {"label": "Departure date (YYYY-MM-DD)",
362+
"text": {"path": "/date"}, "textFieldType": "date"}),
363+
_comp("passengers", "TextField", {"label": "Passengers",
364+
"text": {"path": "/passengers"}, "textFieldType": "number"}),
365+
_comp("fare", "MultipleChoice", {"label": "Fare class", "options": _FARE_OPTIONS,
366+
"selections": {"path": "/fare_class"}, "maxAllowedSelections": 1}),
367+
_comp("submit", "Button", {"child": "submit_label", "primary": True,
368+
"action": {"name": "bookingSubmit", "context": [
369+
{"key": "formId", "value": "booking"},
370+
{"key": "origin", "value": {"path": "/origin"}},
371+
{"key": "dest", "value": {"path": "/dest"}},
372+
{"key": "date", "value": {"path": "/date"}},
373+
{"key": "passengers", "value": {"path": "/passengers"}},
374+
{"key": "fare_class", "value": {"path": "/fare_class"}},
375+
]}}),
376+
_comp("submit_label", "Text", {"text": "Search flights"}),
377+
],
378+
)
347379

348380

349381
async def build_form(state: MessagesState) -> dict:
350-
"""First-turn node: LLM authors the booking form."""
351-
base_messages = [SystemMessage(content=_BUILD_FORM_SYSTEM)] + state["messages"]
382+
"""First-turn AND Modify-search node: LLM authors the booking form.
383+
384+
On a Modify-search turn (last action.name == 'modifySearch'), walks
385+
message history to recover the user's prior bookingSubmit context and
386+
pre-fills the form's data_model with those values. On a true first turn
387+
(no prior submit in history), uses blank defaults.
388+
"""
389+
prior = _extract_prior_submit_context(state["messages"])
390+
defaults = _form_defaults_from_prior(prior)
391+
system_prompt = _BUILD_FORM_SYSTEM_TMPL.replace(
392+
"__DATA_MODEL_DEFAULTS__", json.dumps(defaults)
393+
)
394+
base_messages = [SystemMessage(content=system_prompt)] + state["messages"]
352395
try:
353396
spec = await _emit_with_retry(BookingFormSpec, base_messages)
354397
except RuntimeError as err:
355398
_logger.error("Falling back to sentinel booking form: %s", err)
356-
spec = _SENTINEL_BOOKING_FORM
399+
spec = _build_sentinel_booking_form(defaults)
357400
return {"messages": [AIMessage(content=_wrap_envelopes(spec))]}
358401

359402

0 commit comments

Comments
 (0)