a4ui-agent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a4ui_agent-0.1.0/.gitignore +40 -0
- a4ui_agent-0.1.0/PKG-INFO +37 -0
- a4ui_agent-0.1.0/README.md +23 -0
- a4ui_agent-0.1.0/instruction_agent.md +63 -0
- a4ui_agent-0.1.0/pyproject.toml +23 -0
- a4ui_agent-0.1.0/src/a4ui_agent/__init__.py +31 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_catalog.py +66 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_contract.py +42 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_generated/__init__.py +8 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_generated/chart_control.py +17 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_generated/chart_datashape.py +35 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_generated/contract_version.py +4 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_prompt.py +56 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_runtime.py +159 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_surface.py +251 -0
- a4ui_agent-0.1.0/src/a4ui_agent/_trace.py +20 -0
- a4ui_agent-0.1.0/tests/test_surface.py +53 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Local Entra token for the swel copilot (Lever A) — NEVER commit
|
|
2
|
+
.doc/swel/.copilot_token
|
|
3
|
+
.copilot_token
|
|
4
|
+
|
|
5
|
+
# Secrets / credentials
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
!.env.example
|
|
9
|
+
*.pem
|
|
10
|
+
*.key
|
|
11
|
+
*.pfx
|
|
12
|
+
|
|
13
|
+
# Python
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*.egg-info/
|
|
17
|
+
.venv/
|
|
18
|
+
venv/
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
|
|
21
|
+
# Terraform (state + downloaded provider binaries are not source)
|
|
22
|
+
.terraform/
|
|
23
|
+
*.tfstate
|
|
24
|
+
*.tfstate.*
|
|
25
|
+
*.tfvars
|
|
26
|
+
|
|
27
|
+
# Binaries
|
|
28
|
+
*.exe
|
|
29
|
+
*.dll
|
|
30
|
+
*.so
|
|
31
|
+
*.dylib
|
|
32
|
+
|
|
33
|
+
# Node
|
|
34
|
+
node_modules/
|
|
35
|
+
dist/
|
|
36
|
+
build/
|
|
37
|
+
|
|
38
|
+
# OS / editor
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: a4ui-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-side A2UI chart runtime for @haystackedai/a4react clients. One function (answer(prompt)->rows,answer) gives an AgentCore agent a validated, drillable A2UI chart surface — no a2ui-core, catalog assembly, surface-message building, validation or rechart/drill wiring to write. Contract (catalogId + schemas) is baked, overridable.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: a2ui-core<0.3.0,>=0.2.0
|
|
7
|
+
Requires-Dist: bedrock-agentcore>=1.9.1
|
|
8
|
+
Requires-Dist: pydantic>=2.7
|
|
9
|
+
Requires-Dist: strands-agents>=1.15.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# a4ui-agent
|
|
16
|
+
|
|
17
|
+
Agent-side A2UI chart runtime for [`@haystackedai/a4react`](../A4React) clients.
|
|
18
|
+
|
|
19
|
+
Write one function — `answer(prompt) -> (rows, columns, text)` — and
|
|
20
|
+
`make_a4ui_agent(answer)` returns a ready AgentCore app that turns a prompt into a
|
|
21
|
+
validated, drillable A2UI chart surface. No `a2ui-core`, catalog assembly, surface-message
|
|
22
|
+
building, validation, or rechart/drill wiring to write. The contract (catalogId + schemas)
|
|
23
|
+
is baked and overridable.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from a4ui_agent import make_a4ui_agent
|
|
27
|
+
|
|
28
|
+
def answer(prompt: str):
|
|
29
|
+
rows, columns, text = my_data_lookup(prompt)
|
|
30
|
+
return rows, columns, text
|
|
31
|
+
|
|
32
|
+
app = make_a4ui_agent(answer) # deterministic charts, zero config
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
app.run()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
See **instruction_agent.md** for the full DO list, LLM mode, and the contract rules.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# a4ui-agent
|
|
2
|
+
|
|
3
|
+
Agent-side A2UI chart runtime for [`@haystackedai/a4react`](../A4React) clients.
|
|
4
|
+
|
|
5
|
+
Write one function — `answer(prompt) -> (rows, columns, text)` — and
|
|
6
|
+
`make_a4ui_agent(answer)` returns a ready AgentCore app that turns a prompt into a
|
|
7
|
+
validated, drillable A2UI chart surface. No `a2ui-core`, catalog assembly, surface-message
|
|
8
|
+
building, validation, or rechart/drill wiring to write. The contract (catalogId + schemas)
|
|
9
|
+
is baked and overridable.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from a4ui_agent import make_a4ui_agent
|
|
13
|
+
|
|
14
|
+
def answer(prompt: str):
|
|
15
|
+
rows, columns, text = my_data_lookup(prompt)
|
|
16
|
+
return rows, columns, text
|
|
17
|
+
|
|
18
|
+
app = make_a4ui_agent(answer) # deterministic charts, zero config
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
app.run()
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
See **instruction_agent.md** for the full DO list, LLM mode, and the contract rules.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# a4ui-agent
|
|
2
|
+
|
|
3
|
+
The agent-side twin of `@haystackedai/a4react`. a4react hides A2UI from the **frontend**
|
|
4
|
+
dev; this hides it from the **agent** dev.
|
|
5
|
+
|
|
6
|
+
*What the package gives you:*
|
|
7
|
+
- `make_a4ui_agent` — one call returns a ready AgentCore app (the whole turn: prompt → data → validated A2UI chart surface, streamed in the frames a4react parses)
|
|
8
|
+
- `Contract`, `DEFAULT_CONTRACT`, `DEFAULT_CATALOG_ID` — the baked contract + the type to override it
|
|
9
|
+
- `build_catalog`, `SurfaceEngine`, `build_series`, `pick_and_shape`, `format_answer` — lower-level blocks (only if you assemble it yourself)
|
|
10
|
+
|
|
11
|
+
*What it hides (you write none of these):*
|
|
12
|
+
- a2ui-core, catalog assembly, the A2UI_Chart schema, surface-message building, validation, rechart/drill routing, the AgentCore entrypoint.
|
|
13
|
+
|
|
14
|
+
*What it does NOT bring:*
|
|
15
|
+
- your data (you supply `answer()`), a deployment, or an LLM (optional — deterministic by default).
|
|
16
|
+
|
|
17
|
+
*What the agent dev must do:*
|
|
18
|
+
|
|
19
|
+
1. Install:
|
|
20
|
+
`pip install a4ui-agent`
|
|
21
|
+
|
|
22
|
+
2. Write ONE function — your only job — returning the rows for a prompt:
|
|
23
|
+
```python
|
|
24
|
+
def answer(prompt: str):
|
|
25
|
+
rows, columns, text = my_data_lookup(prompt) # SQL, API, anything
|
|
26
|
+
return rows, columns, text # or (rows, text) · or {"rows":…, "columns":…, "answer":…}
|
|
27
|
+
```
|
|
28
|
+
`rows` = list of dicts; `columns` optional (inferred from the first row); `text` = the prose answer. Sync or async both work.
|
|
29
|
+
|
|
30
|
+
3. Build the agent — nothing else to stand up:
|
|
31
|
+
```python
|
|
32
|
+
from a4ui_agent import make_a4ui_agent
|
|
33
|
+
app = make_a4ui_agent(answer)
|
|
34
|
+
# app = make_a4ui_agent(answer, model="global.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
|
35
|
+
```
|
|
36
|
+
No `model` = deterministic chart choice (zero config). A `model` id = the LLM picks chart type + column roles (your data still stays in code, never sent to the model).
|
|
37
|
+
|
|
38
|
+
4. Run it (AgentCore):
|
|
39
|
+
```python
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
app.run()
|
|
42
|
+
```
|
|
43
|
+
The package handles `rechart` (layout-only re-emit) and `drill` (scoped re-query) actions automatically.
|
|
44
|
+
|
|
45
|
+
That is all. The chart renders in any `@haystackedai/a4react` client pointed at this agent — provided the contract byte-matches (step 5).
|
|
46
|
+
|
|
47
|
+
## Contract — must byte-match the client
|
|
48
|
+
The package bakes `DEFAULT_CONTRACT` (catalogId + plotType enum + series shape), so a client on the **same** default just works. For a custom catalog, pass your own and keep BOTH ends in sync:
|
|
49
|
+
```python
|
|
50
|
+
from a4ui_agent import make_a4ui_agent, Contract
|
|
51
|
+
make_a4ui_agent(answer, contract=Contract(
|
|
52
|
+
catalog_id="https://…/catalog.json", # == the client's catalogId
|
|
53
|
+
control_model=MyControl, # pydantic: has a plotType Literal
|
|
54
|
+
datashape_model=MySeries,
|
|
55
|
+
control_version="…", datashape_version="…",
|
|
56
|
+
))
|
|
57
|
+
```
|
|
58
|
+
Both ends are regenerated from one `A2UI_catalog_src` build — don't hand-edit either side.
|
|
59
|
+
|
|
60
|
+
## If no chart shows
|
|
61
|
+
- **Blank client** — the client renders nothing until a surface arrives; it must `send()` a prompt AND reach this agent's endpoint. a4ui-agent is the agent half only.
|
|
62
|
+
- **"Catalog not found" on the client** — the baked contract here and the client's `catalogId` differ. Match them (step 5).
|
|
63
|
+
- **Deployment** — `app.run()` is the AgentCore runtime; it still has to be deployed where the client's `/api/v1/a2ui` gateway can invoke it.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "a4ui-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent-side A2UI chart runtime for @haystackedai/a4react clients. One function (answer(prompt)->rows,answer) gives an AgentCore agent a validated, drillable A2UI chart surface — no a2ui-core, catalog assembly, surface-message building, validation or rechart/drill wiring to write. Contract (catalogId + schemas) is baked, overridable."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"a2ui-core >= 0.2.0, < 0.3.0",
|
|
13
|
+
"bedrock-agentcore >= 1.9.1",
|
|
14
|
+
"strands-agents >= 1.15.0",
|
|
15
|
+
"pydantic >= 2.7",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
# The deterministic path needs none of these; dev installs what their answer() uses.
|
|
20
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/a4ui_agent"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# a4ui-agent — public surface.
|
|
2
|
+
#
|
|
3
|
+
# The agent-side twin of @haystackedai/a4react, WITH the runtime included. A4 hid the
|
|
4
|
+
# whole A2UI loop from the FRONTEND dev; this hides it from the AGENT dev. They write
|
|
5
|
+
# ONE function — answer(prompt) -> (rows, columns, text) — and get a ready AgentCore
|
|
6
|
+
# app that turns a prompt into a validated, drillable A2UI chart surface. No a2ui-core,
|
|
7
|
+
# no catalog assembly, no surface messages, no validation, no rechart/drill wiring.
|
|
8
|
+
#
|
|
9
|
+
# from a4ui_agent import make_a4ui_agent
|
|
10
|
+
# app = make_a4ui_agent(answer) # deterministic charts, zero config
|
|
11
|
+
# app = make_a4ui_agent(answer, model="…") # LLM-chosen charts
|
|
12
|
+
#
|
|
13
|
+
# The contract (catalogId + schemas) is baked (DEFAULT_CONTRACT) so install → it works;
|
|
14
|
+
# pass a custom Contract to match a client on a different catalog.
|
|
15
|
+
|
|
16
|
+
# The one-call runtime.
|
|
17
|
+
from ._runtime import make_a4ui_agent
|
|
18
|
+
|
|
19
|
+
# The contract: the baked default + the type for overriding it.
|
|
20
|
+
from ._contract import Contract, DEFAULT_CONTRACT, DEFAULT_CATALOG_ID
|
|
21
|
+
|
|
22
|
+
# Lower-level building blocks (for an agent that assembles the catalog/surface itself).
|
|
23
|
+
from ._catalog import build_catalog, BuiltCatalog
|
|
24
|
+
from ._surface import SurfaceEngine, build_series, pick_and_shape, format_answer
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"make_a4ui_agent",
|
|
28
|
+
"Contract", "DEFAULT_CONTRACT", "DEFAULT_CATALOG_ID",
|
|
29
|
+
"build_catalog", "BuiltCatalog",
|
|
30
|
+
"SurfaceEngine", "build_series", "pick_and_shape", "format_answer",
|
|
31
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Assemble the A2UI catalog + validator from a Contract — the a2ui-core plumbing.
|
|
2
|
+
|
|
3
|
+
Hidden from the dev entirely. We take the contract's control/datashape models, build
|
|
4
|
+
the custom A2UI_Chart component against a2ui-core's CatalogComponentCommon, add it to
|
|
5
|
+
a2ui-core's BasicCatalog (v0_9), and build the PayloadValidator run on every surface
|
|
6
|
+
component before emit. This is the agent-side mirror of a4react's A4_makeCatalog.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal, get_args
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field, create_model
|
|
14
|
+
|
|
15
|
+
from a2ui.core import PayloadValidator, STRICT_VALIDATION
|
|
16
|
+
from a2ui.core.basic_catalog.v0_9 import BasicCatalog
|
|
17
|
+
from a2ui.core.basic_catalog.v0_9.components import CatalogComponentCommon
|
|
18
|
+
from a2ui.core.catalog import ModelComponentApi
|
|
19
|
+
from a2ui.core.schema.v0_9.common_types import DataBinding, DynamicString
|
|
20
|
+
|
|
21
|
+
from ._contract import Contract
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BuiltCatalog:
|
|
26
|
+
"""Everything the surface engine needs, derived once from the Contract."""
|
|
27
|
+
catalog_id: str
|
|
28
|
+
validator: PayloadValidator
|
|
29
|
+
series_model: type[BaseModel] # the datashape model (A2UIChartSeries)
|
|
30
|
+
plot_types: list[str]
|
|
31
|
+
schema_desc: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_catalog(contract: Contract) -> BuiltCatalog:
|
|
35
|
+
"""Build the full catalog (basic + A2UI_Chart) and its validator from the contract."""
|
|
36
|
+
# plotType vocabulary comes FROM the contract's control model — single-sourced.
|
|
37
|
+
plot_type_annotation = contract.control_model.model_fields["plotType"].annotation
|
|
38
|
+
series_model = contract.datashape_model
|
|
39
|
+
|
|
40
|
+
# Build the custom component dynamically from the injected schemas. create_model
|
|
41
|
+
# lets plotType/series be contract-driven types (a class body can't take type vars).
|
|
42
|
+
chart_component: type[BaseModel] = create_model(
|
|
43
|
+
"A2UIChartComponent",
|
|
44
|
+
__base__=CatalogComponentCommon,
|
|
45
|
+
component=(Literal["A2UI_Chart"], "A2UI_Chart"),
|
|
46
|
+
plotType=(plot_type_annotation, Field(..., description="The chart type to render.")),
|
|
47
|
+
title=(DynamicString | None, Field(None, description="Optional chart title.")),
|
|
48
|
+
series=(series_model | DataBinding, Field(
|
|
49
|
+
..., description="The chart data (a {path} binding, or an inline series object).")),
|
|
50
|
+
)
|
|
51
|
+
chart_api = ModelComponentApi(chart_component)
|
|
52
|
+
|
|
53
|
+
# Start from a2ui-core's basic catalog (standard components); set our catalog id;
|
|
54
|
+
# add the custom A2UI_Chart → the full catalog.
|
|
55
|
+
catalog = BasicCatalog()
|
|
56
|
+
catalog.catalog_id = contract.catalog_id
|
|
57
|
+
catalog.components["A2UI_Chart"] = chart_api
|
|
58
|
+
validator = PayloadValidator(catalog, STRICT_VALIDATION)
|
|
59
|
+
|
|
60
|
+
plot_types = list(get_args(plot_type_annotation))
|
|
61
|
+
required = [n for n, f in chart_component.model_fields.items() if f.is_required()]
|
|
62
|
+
schema_desc = (
|
|
63
|
+
f"A2UI_Chart{{plotType∈{plot_types}, title?:DynamicString, "
|
|
64
|
+
f"series:A2UIChartSeries|DataBinding}} required={required}"
|
|
65
|
+
)
|
|
66
|
+
return BuiltCatalog(contract.catalog_id, validator, series_model, plot_types, schema_desc)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""The contract — catalogId + the two schema models + versions — INJECTED, not hard-coded.
|
|
2
|
+
|
|
3
|
+
The contract is the one thing the agent package and the frontend package
|
|
4
|
+
(@haystackedai/a4react) MUST byte-match: the `catalog_id` the client puts on
|
|
5
|
+
createSurface, the plotType vocabulary, and the series shape. The package ships a
|
|
6
|
+
DEFAULT_CONTRACT (the baked A2UI_catalog_src generation) so install → it works; a host
|
|
7
|
+
with its own catalog passes a custom Contract to make_a4ui_agent(..., contract=...).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ._generated.chart_control import A2UIChartControl
|
|
15
|
+
from ._generated.chart_datashape import A2UIChartDataShape
|
|
16
|
+
from ._generated.contract_version import CONTROL_VERSION, DATASHAPE_VERSION
|
|
17
|
+
|
|
18
|
+
# The default catalog id. MUST byte-match the client's createSurface.catalogId
|
|
19
|
+
# (@haystackedai/a4react's catalogId / the frontend's A2UI_CATALOG_ID).
|
|
20
|
+
DEFAULT_CATALOG_ID = "https://voa.sanofi.com/a2ui/catalogs/voa/v0_9/catalog.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Contract:
|
|
25
|
+
"""What the agent and client agree on. `control_model` must expose a `plotType`
|
|
26
|
+
field (a Literal enum); `datashape_model` is the series shape. Both are Pydantic
|
|
27
|
+
models, mirroring @haystackedai/a4react's injected controlSchema/datashapeSchema."""
|
|
28
|
+
catalog_id: str
|
|
29
|
+
control_model: type[BaseModel]
|
|
30
|
+
datashape_model: type[BaseModel]
|
|
31
|
+
control_version: str
|
|
32
|
+
datashape_version: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# The baked default — the A2UI_catalog_src generation this package ships.
|
|
36
|
+
DEFAULT_CONTRACT = Contract(
|
|
37
|
+
catalog_id=DEFAULT_CATALOG_ID,
|
|
38
|
+
control_model=A2UIChartControl,
|
|
39
|
+
datashape_model=A2UIChartDataShape,
|
|
40
|
+
control_version=CONTROL_VERSION,
|
|
41
|
+
datashape_version=DATASHAPE_VERSION,
|
|
42
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Baked contract artifacts — the package's DEFAULT contract.
|
|
2
|
+
|
|
3
|
+
GENERATED from A2UI_catalog_src/chart.*.json by A2UI_gen_pydantic.py. These are the
|
|
4
|
+
single-source contract pieces the agent and the frontend (@haystackedai/a4react) must
|
|
5
|
+
byte-match: the plotType vocabulary, the series shape, and the contract versions. The
|
|
6
|
+
package SHIPS them so a dev who installs it gets a working contract with no setup;
|
|
7
|
+
override via a4ui_agent.Contract only for a custom catalog.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# GENERATED from A2UI_catalog_src/chart.control.json by A2UI_gen_pydantic.py — DO NOT EDIT.
|
|
2
|
+
# Change the JSON and re-run the script to regenerate.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class A2UIChartControl(BaseModel):
|
|
12
|
+
model_config = ConfigDict(
|
|
13
|
+
extra='forbid',
|
|
14
|
+
)
|
|
15
|
+
plotType: Literal[
|
|
16
|
+
'scatter', 'bar', 'column', 'line', 'area', 'pie', 'donut', 'KPI_CARD', 'table'
|
|
17
|
+
] = Field(..., description='The chart type to render.')
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# GENERATED from A2UI_catalog_src/chart.datashape.json by A2UI_gen_pydantic.py — DO NOT EDIT.
|
|
2
|
+
# Change the JSON and re-run the script to regenerate.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Point(BaseModel):
|
|
12
|
+
model_config = ConfigDict(
|
|
13
|
+
extra='forbid',
|
|
14
|
+
)
|
|
15
|
+
x: float
|
|
16
|
+
y: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Item(BaseModel):
|
|
20
|
+
model_config = ConfigDict(
|
|
21
|
+
extra='forbid',
|
|
22
|
+
)
|
|
23
|
+
label: str
|
|
24
|
+
value: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class A2UIChartDataShape(BaseModel):
|
|
28
|
+
model_config = ConfigDict(
|
|
29
|
+
extra='forbid',
|
|
30
|
+
)
|
|
31
|
+
points: list[Point] | None = None
|
|
32
|
+
items: list[Item] | None = None
|
|
33
|
+
value: float | str | None = None
|
|
34
|
+
columns: list[str] | None = None
|
|
35
|
+
rows: list[dict[str, Any]] | None = None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""System prompt + few-shot for LLM-generated A2UI surfaces — DATA-OUTSIDE variant.
|
|
2
|
+
|
|
3
|
+
Only used when make_a4ui_agent(..., model=...) opts into LLM chart composition. The
|
|
4
|
+
model composes the surface STRUCTURE and picks the chart, but never touches the data:
|
|
5
|
+
it sees only the question + column NAMES and returns {surface, roles}. Code then fills
|
|
6
|
+
updateDataModel (series from the real rows, note from the answer) behind the {path}
|
|
7
|
+
bindings — so the dev's values reach the chart unaltered.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
# The exact OUTPUT shape the model must return: the 2 structure messages (NO data,
|
|
14
|
+
# NO updateDataModel) + a roles map. Code appends updateDataModel afterwards.
|
|
15
|
+
_ROLE = (
|
|
16
|
+
"You are a data-visualization agent. Given a user question and the COLUMN NAMES of a "
|
|
17
|
+
"SQL result, you compose ONE A2UI chart surface. You never receive or emit the data "
|
|
18
|
+
"values — the system fills those in from the real SQL rows behind the {path} bindings."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_RULES = """Output ONLY a JSON object (no prose, no code fences) with exactly two keys:
|
|
22
|
+
|
|
23
|
+
"surface": an array of exactly TWO A2UI messages — createSurface and updateComponents —
|
|
24
|
+
copied EXACTLY from the example (same surfaceId, same catalogId, same component
|
|
25
|
+
tree with Text bound to /note and A2UI_Chart bound to /series). Change ONLY the
|
|
26
|
+
A2UI_Chart `plotType`.
|
|
27
|
+
"roles": which columns feed the chart, chosen EXACTLY from the provided column names.
|
|
28
|
+
|
|
29
|
+
Do NOT emit updateDataModel, data values, series arrays, or note text — the system adds
|
|
30
|
+
updateDataModel (series from the SQL rows, note from the ANSWER) itself.
|
|
31
|
+
|
|
32
|
+
Pick `plotType` to fit the data, and set the matching roles (null for unused):
|
|
33
|
+
* category + number -> "column" (few) or "bar" (many/long labels/ranking); set label_column + value_column
|
|
34
|
+
* one row, one number -> "KPI_CARD"; set value_column
|
|
35
|
+
* time + number -> "line" (trend) or "area"; set x_column=time col + value_column
|
|
36
|
+
* parts of a whole (<=8) -> "pie" / "donut"; set label_column + value_column
|
|
37
|
+
* two numeric columns -> "scatter"; set x_column + y_column
|
|
38
|
+
* doesn't fit a chart -> "table"; roles may be null"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_system_prompt(catalog_id: str) -> str:
|
|
42
|
+
"""Role + rules + the exact output example (structure + roles, no data)."""
|
|
43
|
+
fewshot_output = {
|
|
44
|
+
"surface": [
|
|
45
|
+
{"version": "v0.9", "createSurface": {"surfaceId": "A2UI_dev", "catalogId": catalog_id}},
|
|
46
|
+
{"version": "v0.9", "updateComponents": {"surfaceId": "A2UI_dev", "components": [
|
|
47
|
+
{"id": "root", "component": "Column", "children": ["n", "c"]},
|
|
48
|
+
{"id": "n", "component": "Text", "text": {"path": "/note"}},
|
|
49
|
+
{"id": "c", "component": "A2UI_Chart", "plotType": "bar", "series": {"path": "/series"}},
|
|
50
|
+
]}},
|
|
51
|
+
],
|
|
52
|
+
"roles": {"label_column": "ACTIVITY_SCHEDULE_STATUS", "value_column": "ACTIVITY_COUNT",
|
|
53
|
+
"x_column": None, "y_column": None},
|
|
54
|
+
}
|
|
55
|
+
example = json.dumps(fewshot_output, indent=2)
|
|
56
|
+
return f"{_ROLE}\n\n{_RULES}\n\n---BEGIN EXAMPLE OUTPUT---\n{example}\n---END EXAMPLE OUTPUT---"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""make_a4ui_agent — the full agent runtime, packaged.
|
|
2
|
+
|
|
3
|
+
This is what the package adds over a surface-builder library: the dev no longer wires
|
|
4
|
+
a catalog, builds surface messages, validates, handles rechart/drill, or hand-rolls the
|
|
5
|
+
AgentCore entrypoint. They write ONE function — answer(prompt) -> (rows, columns, text)
|
|
6
|
+
— and get a ready BedrockAgentCoreApp whose turn mirrors nslswel's: prompt → data →
|
|
7
|
+
A2UI surface, streamed in the exact frames @haystackedai/a4react parses.
|
|
8
|
+
|
|
9
|
+
from a4ui_agent import make_a4ui_agent
|
|
10
|
+
|
|
11
|
+
def answer(prompt: str):
|
|
12
|
+
rows, columns, text = my_data_lookup(prompt)
|
|
13
|
+
return rows, columns, text
|
|
14
|
+
|
|
15
|
+
app = make_a4ui_agent(answer) # deterministic charts, zero config
|
|
16
|
+
# app = make_a4ui_agent(answer, model="global.anthropic.claude-sonnet-4-5-...") # LLM-chosen charts
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
app.run()
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import inspect
|
|
24
|
+
import json
|
|
25
|
+
|
|
26
|
+
from ._catalog import build_catalog
|
|
27
|
+
from ._contract import Contract, DEFAULT_CONTRACT
|
|
28
|
+
from ._surface import SurfaceEngine, format_answer, pick_and_shape
|
|
29
|
+
from ._trace import trace_frame
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_dataset(res) -> dict:
|
|
33
|
+
"""Accept the dev's answer() return in any ergonomic form and normalize to
|
|
34
|
+
{ok, rows, columns, answer, error}. Columns are inferred from the first row if
|
|
35
|
+
the dev didn't supply them."""
|
|
36
|
+
rows = columns = answer = error = None
|
|
37
|
+
if isinstance(res, dict):
|
|
38
|
+
rows, columns, answer, error = res.get("rows"), res.get("columns"), res.get("answer"), res.get("error")
|
|
39
|
+
elif isinstance(res, (tuple, list)):
|
|
40
|
+
if len(res) == 3:
|
|
41
|
+
rows, columns, answer = res
|
|
42
|
+
elif len(res) == 2:
|
|
43
|
+
rows, answer = res
|
|
44
|
+
elif res:
|
|
45
|
+
rows = res[0]
|
|
46
|
+
else:
|
|
47
|
+
rows = res
|
|
48
|
+
rows = list(rows or [])
|
|
49
|
+
if not columns:
|
|
50
|
+
columns = list(rows[0].keys()) if rows and isinstance(rows[0], dict) else []
|
|
51
|
+
return {"ok": bool(rows), "rows": rows, "columns": list(columns or []),
|
|
52
|
+
"answer": answer or "", "error": error}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _call_answer(answer, prompt: str) -> dict:
|
|
56
|
+
"""Call the dev's answer() — async or sync (sync runs off-thread) — and normalize."""
|
|
57
|
+
if inspect.iscoroutinefunction(answer):
|
|
58
|
+
res = await answer(prompt)
|
|
59
|
+
else:
|
|
60
|
+
res = await asyncio.to_thread(answer, prompt)
|
|
61
|
+
if inspect.isawaitable(res):
|
|
62
|
+
res = await res
|
|
63
|
+
return _normalize_dataset(res)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def _run_turn(payload, context, *, engine: SurfaceEngine, answer, contract: Contract, use_llm: bool):
|
|
67
|
+
"""The data turn: handle rechart/version/drill actions, else prompt → data → surface."""
|
|
68
|
+
name = engine.agent_name
|
|
69
|
+
action = payload.get("action") if isinstance(payload, dict) else None
|
|
70
|
+
|
|
71
|
+
# rechart — layout-only re-emit; the client already holds the data model.
|
|
72
|
+
if action and action.get("name") == "rechart":
|
|
73
|
+
plot_type = (action.get("context") or {}).get("plotType", "bar")
|
|
74
|
+
yield trace_frame(name, "rechart action — layout-only re-emit (no data)",
|
|
75
|
+
f"action={json.dumps(action)} → plotType={plot_type}")
|
|
76
|
+
yield {"kind": "a2ui", "a2ui_messages": [engine.rechart_message(plot_type)], "usage": {}}
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# version — contract handshake; the client compares to its baked version.
|
|
80
|
+
if action and action.get("name") == "version":
|
|
81
|
+
yield {"kind": "version", "version": {"control": contract.control_version,
|
|
82
|
+
"datashape": contract.datashape_version}}
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
session_id = getattr(context, "session_id", "a2ui-session")
|
|
86
|
+
prompt = payload.get("prompt", "") if isinstance(payload, dict) else ""
|
|
87
|
+
|
|
88
|
+
# drill — a scoped NEW data turn. The click carries the clicked value + the original
|
|
89
|
+
# question (no server memory); compose a scoped follow-up and run it below.
|
|
90
|
+
if action and action.get("name") == "drill":
|
|
91
|
+
ctx = action.get("context") or {}
|
|
92
|
+
value, question = ctx.get("value") or "", ctx.get("question") or ""
|
|
93
|
+
prompt = (f"{question}\n\nNow focus ONLY on '{value}' and show its breakdown one level "
|
|
94
|
+
f"deeper (e.g. by phase, status, or sub-category) as a chart.")
|
|
95
|
+
yield trace_frame(name, f"drill action → scoped new query for '{value}'", f"composed prompt: {prompt!r}")
|
|
96
|
+
|
|
97
|
+
yield trace_frame(f"{name} → data source", "fetch the data for the prompt", f"prompt={prompt!r}")
|
|
98
|
+
dataset = await _call_answer(answer, prompt)
|
|
99
|
+
|
|
100
|
+
if dataset["ok"] and dataset["rows"]:
|
|
101
|
+
yield trace_frame(f"data source → {name}",
|
|
102
|
+
f"returned {len(dataset['rows'])} rows ({len(dataset['columns'])} cols)",
|
|
103
|
+
json.dumps(dataset["rows"][:10], default=str))
|
|
104
|
+
if use_llm:
|
|
105
|
+
yield trace_frame("orchestrator", "split data — columns → LLM, rows → code",
|
|
106
|
+
f"LLM path: columns={dataset['columns']}\n"
|
|
107
|
+
f"DATA path: {json.dumps(dataset['rows'][:10], default=str)}")
|
|
108
|
+
try:
|
|
109
|
+
async for frame in engine.generate(prompt, dataset):
|
|
110
|
+
yield frame
|
|
111
|
+
return
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
yield trace_frame(name, "LLM generate failed → deterministic fallback", str(exc))
|
|
114
|
+
plot_type, series = pick_and_shape(dataset)
|
|
115
|
+
messages = engine.build_surface(plot_type, series, format_answer(dataset["answer"]))
|
|
116
|
+
else:
|
|
117
|
+
plot_type, series = pick_and_shape(dataset)
|
|
118
|
+
messages = engine.build_surface(plot_type, series, format_answer(dataset["answer"]))
|
|
119
|
+
else:
|
|
120
|
+
yield trace_frame(f"data source → {name}", "no rows / error", dataset.get("error") or "")
|
|
121
|
+
messages = engine.build_surface("bar", {"items": []}, format_answer(dataset["answer"]))
|
|
122
|
+
|
|
123
|
+
yield trace_frame(name, "emit A2UI surface", f"{len(messages)} messages")
|
|
124
|
+
yield {"kind": "a2ui", "a2ui_messages": messages, "usage": {}}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def make_a4ui_agent(answer, *, contract: Contract = DEFAULT_CONTRACT, model=None,
|
|
128
|
+
agent_name: str = "a4ui chart agent", chart_type_buttons=None):
|
|
129
|
+
"""Build the ready AgentCore app from the dev's answer() function.
|
|
130
|
+
|
|
131
|
+
answer(prompt) -> (rows, columns, answer_text) | (rows, answer_text)
|
|
132
|
+
| {"rows":..., "columns":..., "answer":...}; sync or async.
|
|
133
|
+
model -> None = deterministic charts (zero config). A model_id str or a
|
|
134
|
+
Strands model = LLM-composed charts (chart type/roles chosen by
|
|
135
|
+
the model; data stays in code).
|
|
136
|
+
contract -> defaults to the baked contract; pass a custom Contract to match
|
|
137
|
+
a client on a different catalog.
|
|
138
|
+
"""
|
|
139
|
+
from bedrock_agentcore.runtime import BedrockAgentCoreApp
|
|
140
|
+
|
|
141
|
+
use_llm = model is not None
|
|
142
|
+
resolved_model = model
|
|
143
|
+
if isinstance(model, str):
|
|
144
|
+
from strands.models.bedrock import BedrockModel
|
|
145
|
+
resolved_model = BedrockModel(model_id=model)
|
|
146
|
+
|
|
147
|
+
built = build_catalog(contract)
|
|
148
|
+
engine = SurfaceEngine(built, model=resolved_model, agent_name=agent_name,
|
|
149
|
+
chart_type_buttons=chart_type_buttons)
|
|
150
|
+
|
|
151
|
+
app = BedrockAgentCoreApp()
|
|
152
|
+
|
|
153
|
+
@app.entrypoint
|
|
154
|
+
async def invoke(payload, context):
|
|
155
|
+
async for frame in _run_turn(payload, context, engine=engine, answer=answer,
|
|
156
|
+
contract=contract, use_llm=use_llm):
|
|
157
|
+
yield frame
|
|
158
|
+
|
|
159
|
+
return app
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Chart/surface producer — turns (rows, columns, answer) into A2UI wire messages.
|
|
2
|
+
|
|
3
|
+
This is the heart of what the package hides. Two paths:
|
|
4
|
+
|
|
5
|
+
PRIMARY (model set): LLM composes the surface STRUCTURE + picks plotType + column roles
|
|
6
|
+
from the question + COLUMN NAMES only (never the rows); then CODE fills updateDataModel
|
|
7
|
+
(series from the real rows, note from the answer) behind the {path} bindings. The model
|
|
8
|
+
never sees a data value, so the dev's numbers reach the chart unaltered.
|
|
9
|
+
|
|
10
|
+
DETERMINISTIC (no model): pick_and_shape() + build_surface() build a surface with no LLM
|
|
11
|
+
— the zero-config default so install → chart.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from ._catalog import BuiltCatalog
|
|
19
|
+
from ._prompt import build_system_prompt
|
|
20
|
+
from ._trace import trace_frame
|
|
21
|
+
|
|
22
|
+
# Chart types offered as a toggle row under the chart. All share the same label/value
|
|
23
|
+
# series, so switching is layout-only (rechart action → updateComponents, same /series).
|
|
24
|
+
# v0.9 Button uses `child` (a Text node) for its label + action.event.{name,context}.
|
|
25
|
+
DEFAULT_CHART_TYPE_BUTTONS = [
|
|
26
|
+
("donut", "Donut"), ("pie", "Pie"), ("bar", "Bar"), ("column", "Column"), ("table", "Table"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_SURFACE_ID = "A2UI_dev"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── pure data helpers (plotType vocabulary is the standard chart set) ──────────────
|
|
33
|
+
def format_answer(text: str) -> str:
|
|
34
|
+
"""Normalize prose into clean, renderable markdown: blank line around a
|
|
35
|
+
'**... Insights:**' header and each spaced-hyphen bullet on its own line."""
|
|
36
|
+
if not text:
|
|
37
|
+
return text
|
|
38
|
+
t = text.strip()
|
|
39
|
+
t = re.sub(r"\s*\*\*([^*]*Insights[^*]*)\*\*\s*", r"\n\n**\1**\n\n", t, flags=re.I)
|
|
40
|
+
t = re.sub(r"[ \t]+-[ \t]+", "\n- ", t)
|
|
41
|
+
return t.strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _col(columns: list, name) -> str | None:
|
|
45
|
+
"""Case-insensitive column match (SQL engines often upper-case)."""
|
|
46
|
+
if name:
|
|
47
|
+
for c in columns:
|
|
48
|
+
if str(c).lower() == str(name).lower():
|
|
49
|
+
return c
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_series(intent: dict, dataset: dict):
|
|
54
|
+
"""Build the chart series from the chosen plotType + column roles + the REAL rows.
|
|
55
|
+
Every value is copied straight from dataset['rows'] — never recomputed. Returns
|
|
56
|
+
(plot_type, series); may fall back to 'table' if the roles don't resolve."""
|
|
57
|
+
cols = dataset.get("columns") or []
|
|
58
|
+
rows = dataset.get("rows") or []
|
|
59
|
+
pt = intent.get("plotType")
|
|
60
|
+
if pt == "table":
|
|
61
|
+
return "table", {"columns": cols, "rows": rows[:50]}
|
|
62
|
+
if pt in ("line", "area"):
|
|
63
|
+
xc = _col(cols, intent.get("x_column")) or _col(cols, intent.get("label_column"))
|
|
64
|
+
vc = _col(cols, intent.get("value_column"))
|
|
65
|
+
if xc and vc:
|
|
66
|
+
return pt, {"items": [{"label": str(r.get(xc)), "value": r.get(vc)} for r in rows[:100]]}
|
|
67
|
+
if pt in ("pie", "donut"):
|
|
68
|
+
lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
|
|
69
|
+
if lc and vc:
|
|
70
|
+
return pt, {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:12]]}
|
|
71
|
+
if pt == "column":
|
|
72
|
+
lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
|
|
73
|
+
if lc and vc:
|
|
74
|
+
return "column", {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:100]]}
|
|
75
|
+
if pt == "KPI_CARD":
|
|
76
|
+
vc = _col(cols, intent.get("value_column")) or (cols[0] if cols else None)
|
|
77
|
+
return "KPI_CARD", {"value": rows[0].get(vc) if rows and vc else None}
|
|
78
|
+
if pt == "scatter":
|
|
79
|
+
xc, yc = _col(cols, intent.get("x_column")), _col(cols, intent.get("y_column"))
|
|
80
|
+
if xc and yc:
|
|
81
|
+
return "scatter", {"points": [{"x": r.get(xc), "y": r.get(yc)} for r in rows[:150]]}
|
|
82
|
+
lc, vc = _col(cols, intent.get("label_column")), _col(cols, intent.get("value_column"))
|
|
83
|
+
if not (lc and vc):
|
|
84
|
+
return "table", {"columns": cols, "rows": rows[:50]} # honest fallback: show rows, don't fake a bar
|
|
85
|
+
return "bar", {"items": [{"label": str(r.get(lc)), "value": r.get(vc)} for r in rows[:100]]}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def pick_and_shape(dataset: dict):
|
|
89
|
+
"""Infer a chart type + series from column shapes (no LLM, no roles)."""
|
|
90
|
+
cols = dataset.get("columns") or []
|
|
91
|
+
rows = dataset.get("rows") or []
|
|
92
|
+
isnum = lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
93
|
+
nums = [c for c in cols if any(isnum(r.get(c)) for r in rows)]
|
|
94
|
+
cats = [c for c in cols if c not in nums]
|
|
95
|
+
if len(rows) == 1 and nums: # single_value → KPI
|
|
96
|
+
return "KPI_CARD", {"value": rows[0].get(nums[0])}
|
|
97
|
+
if len(nums) >= 2 and not cats: # xy_point → scatter
|
|
98
|
+
x, y = nums[0], nums[1]
|
|
99
|
+
return "scatter", {"points": [{"x": r.get(x), "y": r.get(y)} for r in rows[:150]]}
|
|
100
|
+
val = nums[0] if nums else (cols[1] if len(cols) > 1 else (cols[0] if cols else None)) # label_value → bar
|
|
101
|
+
lab = next((c for c in cols if c != val), (cols[0] if cols else None))
|
|
102
|
+
items = [{"label": str(r.get(lab)), "value": r.get(val)} for r in rows[:100]] if (lab and val) else []
|
|
103
|
+
return "bar", {"items": items}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SurfaceEngine:
|
|
107
|
+
"""Builds + validates A2UI surfaces for one Contract. Holds the catalog_id, the
|
|
108
|
+
validator and the series model so the surface functions stay contract-bound."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, built: BuiltCatalog, *, model=None, agent_name="a4ui chart agent",
|
|
111
|
+
chart_type_buttons=None):
|
|
112
|
+
self._built = built
|
|
113
|
+
self.catalog_id = built.catalog_id
|
|
114
|
+
self.validator = built.validator
|
|
115
|
+
self.series_model = built.series_model
|
|
116
|
+
self.schema_desc = built.schema_desc
|
|
117
|
+
self.model = model
|
|
118
|
+
self.agent_name = agent_name
|
|
119
|
+
self.chart_type_buttons = chart_type_buttons or DEFAULT_CHART_TYPE_BUTTONS
|
|
120
|
+
|
|
121
|
+
# ── surface structure ─────────────────────────────────────────────────────────
|
|
122
|
+
def _button_row(self):
|
|
123
|
+
ids, nodes = [], []
|
|
124
|
+
for pt, label in self.chart_type_buttons:
|
|
125
|
+
bid, lid = f"btn_{pt}", f"lbl_{pt}"
|
|
126
|
+
nodes.append({"id": bid, "component": "Button", "child": lid,
|
|
127
|
+
"action": {"event": {"name": "rechart", "context": {"plotType": pt}}}})
|
|
128
|
+
nodes.append({"id": lid, "component": "Text", "text": label})
|
|
129
|
+
ids.append(bid)
|
|
130
|
+
return ids, nodes
|
|
131
|
+
|
|
132
|
+
def _components(self, plot_type: str):
|
|
133
|
+
"""Canonical surface tree: chart on top, a toggle Row of chart-type buttons, then the note."""
|
|
134
|
+
btn_ids, btn_nodes = self._button_row()
|
|
135
|
+
return [
|
|
136
|
+
{"id": "root", "component": "Column", "children": ["c", "btns", "n"]},
|
|
137
|
+
{"id": "c", "component": "A2UI_Chart", "plotType": plot_type, "series": {"path": "/series"}},
|
|
138
|
+
{"id": "btns", "component": "Row", "children": btn_ids},
|
|
139
|
+
{"id": "n", "component": "Text", "text": {"path": "/note"}},
|
|
140
|
+
*btn_nodes,
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
def rechart_message(self, plot_type: str) -> dict:
|
|
144
|
+
"""Layout-only update for the rechart action: same tree, new plotType, same
|
|
145
|
+
bindings (no createSurface, no data — the client already holds the data model)."""
|
|
146
|
+
return {"version": "v0.9", "updateComponents": {"surfaceId": _SURFACE_ID,
|
|
147
|
+
"components": self._components(plot_type)}}
|
|
148
|
+
|
|
149
|
+
def build_surface(self, plot_type: str, series: dict, note: str) -> list[dict]:
|
|
150
|
+
"""Assemble the 3-message surface (createSurface + updateComponents + updateDataModel)."""
|
|
151
|
+
return [
|
|
152
|
+
{"version": "v0.9", "createSurface": {"surfaceId": _SURFACE_ID, "catalogId": self.catalog_id}},
|
|
153
|
+
{"version": "v0.9", "updateComponents": {"surfaceId": _SURFACE_ID, "components": self._components(plot_type)}},
|
|
154
|
+
{"version": "v0.9", "updateDataModel": {"surfaceId": _SURFACE_ID, "path": "/",
|
|
155
|
+
"value": {"note": note, "series": series}}},
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
# ── validation guardrail ────────────────────────────────────────────────────
|
|
159
|
+
def _validate_surface(self, messages: list) -> list[str]:
|
|
160
|
+
"""Every component on-catalog/valid, and the code-built series matches the
|
|
161
|
+
A2UI_Chart series shape. Returns a per-item description for the trace."""
|
|
162
|
+
checked: list[str] = []
|
|
163
|
+
for msg in messages:
|
|
164
|
+
for comp in (msg.get("updateComponents") or {}).get("components") or []:
|
|
165
|
+
self.validator.validate_component(comp)
|
|
166
|
+
d = f"{comp.get('component')}#{comp.get('id')}"
|
|
167
|
+
if comp.get("component") == "A2UI_Chart":
|
|
168
|
+
d += f"(plotType={comp.get('plotType')!r})"
|
|
169
|
+
checked.append(d)
|
|
170
|
+
series = ((msg.get("updateDataModel") or {}).get("value") or {}).get("series")
|
|
171
|
+
if series is not None:
|
|
172
|
+
self.series_model.model_validate(series)
|
|
173
|
+
shape = next((f"{k}×{len(series[k])}" for k in ("items", "points", "rows")
|
|
174
|
+
if series.get(k) is not None), "value")
|
|
175
|
+
checked.append(f"series:{shape}")
|
|
176
|
+
return checked
|
|
177
|
+
|
|
178
|
+
# ── PRIMARY: LLM-generated, data-outside ──────────────────────────────────────
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _extract_object(text: str) -> dict:
|
|
181
|
+
t = (text or "").strip()
|
|
182
|
+
m = re.search(r"```(?:json)?\s*(.*?)```", t, re.S)
|
|
183
|
+
if m:
|
|
184
|
+
t = m.group(1).strip()
|
|
185
|
+
s, e = t.find("{"), t.rfind("}")
|
|
186
|
+
if s != -1 and e != -1:
|
|
187
|
+
t = t[s:e + 1]
|
|
188
|
+
return json.loads(t)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _find_chart(components: list) -> dict:
|
|
192
|
+
for c in components:
|
|
193
|
+
if c.get("component") == "A2UI_Chart":
|
|
194
|
+
return c
|
|
195
|
+
raise ValueError("generated surface has no A2UI_Chart component")
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _usage_from_result(result) -> dict:
|
|
199
|
+
try:
|
|
200
|
+
u = result.metrics.accumulated_usage
|
|
201
|
+
return {"input_tokens": int(u.get("inputTokens", 0)), "output_tokens": int(u.get("outputTokens", 0))}
|
|
202
|
+
except Exception:
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
async def generate(self, prompt: str, dataset: dict):
|
|
206
|
+
"""Async generator: yields a trace frame per sub-step, then the final a2ui frame.
|
|
207
|
+
The model composes STRUCTURE + roles from the question + COLUMN NAMES; CODE fills
|
|
208
|
+
the data. One retry; raises if still invalid (the runtime catches → fallback)."""
|
|
209
|
+
from strands import Agent
|
|
210
|
+
from strands.agent.conversation_manager.null_conversation_manager import NullConversationManager
|
|
211
|
+
|
|
212
|
+
agent = Agent(model=self.model, system_prompt=build_system_prompt(self.catalog_id),
|
|
213
|
+
conversation_manager=NullConversationManager())
|
|
214
|
+
user_msg = f"Question: {prompt}\nColumns: {dataset.get('columns')}"
|
|
215
|
+
note = format_answer(dataset.get("answer") or "")
|
|
216
|
+
last_err = None
|
|
217
|
+
for attempt in range(2):
|
|
218
|
+
msg = user_msg if attempt == 0 else (
|
|
219
|
+
f"{user_msg}\n\nYour previous output was INVALID: {last_err}\n"
|
|
220
|
+
"Return ONLY the corrected JSON object with keys 'surface' and 'roles'."
|
|
221
|
+
)
|
|
222
|
+
result = await agent.invoke_async(msg)
|
|
223
|
+
try:
|
|
224
|
+
obj = self._extract_object(str(result))
|
|
225
|
+
surface = obj["surface"]
|
|
226
|
+
roles = obj.get("roles") or {}
|
|
227
|
+
comps = surface[1]["updateComponents"]["components"]
|
|
228
|
+
chart = self._find_chart(comps)
|
|
229
|
+
sid = surface[0]["createSurface"]["surfaceId"]
|
|
230
|
+
plot_type, series = build_series({**roles, "plotType": chart.get("plotType")}, dataset)
|
|
231
|
+
chart["plotType"] = plot_type
|
|
232
|
+
surface[1]["updateComponents"]["components"] = self._components(plot_type)
|
|
233
|
+
messages = surface + [{"version": "v0.9", "updateDataModel": {
|
|
234
|
+
"surfaceId": sid, "path": "/", "value": {"note": note, "series": series}}}]
|
|
235
|
+
yield trace_frame("A2UI surface", "composed A2UI surface → validate via A2UI catalog",
|
|
236
|
+
f"LLM: plotType={plot_type}, roles={roles}\n"
|
|
237
|
+
f"DATA path: series = {json.dumps(series, default=str)}")
|
|
238
|
+
checked = self._validate_surface(messages)
|
|
239
|
+
yield trace_frame("A2UI catalog", "validated against the A2UI catalog",
|
|
240
|
+
f"schema {self.schema_desc} · checked: {', '.join(checked)} → all OK")
|
|
241
|
+
_emitted = "\n".join(
|
|
242
|
+
f"{next((k for k in ('createSurface', 'updateComponents', 'updateDataModel') if k in m), 'message')}: "
|
|
243
|
+
f"{json.dumps(m, default=str)}" for m in messages)
|
|
244
|
+
yield trace_frame("A2UI surface", "emit A2UI messages → A2UI client creates the surface",
|
|
245
|
+
"agent emits the surface SPEC; the client's processMessages creates it:\n" + _emitted)
|
|
246
|
+
yield {"kind": "a2ui", "a2ui_messages": messages, "usage": self._usage_from_result(result)}
|
|
247
|
+
return
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
last_err = str(exc)[:300]
|
|
250
|
+
yield trace_frame("A2UI catalog", "validation failed — retrying" if attempt == 0 else "validation failed", last_err)
|
|
251
|
+
raise ValueError(f"LLM surface generation failed after retry: {last_err}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Flow-trace frame builder. One entry per pipeline step → forwarded to the browser
|
|
2
|
+
flow panel (@haystackedai/a4react's RightPane reads these). Part of the TRANSPORT
|
|
3
|
+
contract the frontend package parses, so the frame shape is frozen here."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
# Cap each detail string so a frame stays under the AgentCore event-size limit.
|
|
9
|
+
# Large enough to show the emitted A2UI surface (3 messages) in full.
|
|
10
|
+
TRACE_DETAIL_CAP = 3000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def trace_frame(actor: str, action: str, detail: str = "") -> dict:
|
|
14
|
+
"""Build one 'trace' frame (kind:'trace'); the gateway re-wraps it as a meta part."""
|
|
15
|
+
return {"kind": "trace", "entry": {
|
|
16
|
+
"ts": int(time.time() * 1000),
|
|
17
|
+
"actor": actor,
|
|
18
|
+
"action": action,
|
|
19
|
+
"detail": str(detail)[:TRACE_DETAIL_CAP],
|
|
20
|
+
}}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Deterministic-path smoke test: no AWS, no model, no AgentCore — just prove the
|
|
2
|
+
baked contract assembles a valid catalog and that a built surface passes the a2ui-core
|
|
3
|
+
validator. Run with a Python that has a2ui-core installed:
|
|
4
|
+
|
|
5
|
+
python tests/test_surface.py # plain asserts
|
|
6
|
+
pytest tests/test_surface.py # if pytest present
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
12
|
+
|
|
13
|
+
from a4ui_agent import build_catalog, DEFAULT_CONTRACT, SurfaceEngine, pick_and_shape, format_answer
|
|
14
|
+
|
|
15
|
+
DATASET = {
|
|
16
|
+
"columns": ["STATUS", "CNT"],
|
|
17
|
+
"rows": [{"STATUS": "Active", "CNT": 5}, {"STATUS": "Closed", "CNT": 3}],
|
|
18
|
+
"answer": "Two statuses - Active and Closed.",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_deterministic_surface_validates():
|
|
23
|
+
built = build_catalog(DEFAULT_CONTRACT)
|
|
24
|
+
engine = SurfaceEngine(built)
|
|
25
|
+
|
|
26
|
+
plot_type, series = pick_and_shape(DATASET)
|
|
27
|
+
assert plot_type in built.plot_types
|
|
28
|
+
assert series.get("items") # category + number → bar with items
|
|
29
|
+
|
|
30
|
+
messages = engine.build_surface(plot_type, series, format_answer(DATASET["answer"]))
|
|
31
|
+
assert len(messages) == 3
|
|
32
|
+
assert messages[0]["createSurface"]["catalogId"] == DEFAULT_CONTRACT.catalog_id
|
|
33
|
+
|
|
34
|
+
checked = engine._validate_surface(messages) # raises if any component is off-catalog
|
|
35
|
+
assert any("A2UI_Chart" in c for c in checked)
|
|
36
|
+
assert any(c.startswith("series:") for c in checked)
|
|
37
|
+
return plot_type, checked
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_rechart_message_is_layout_only():
|
|
41
|
+
engine = SurfaceEngine(build_catalog(DEFAULT_CONTRACT))
|
|
42
|
+
msg = engine.rechart_message("donut")
|
|
43
|
+
assert "createSurface" not in msg and "updateComponents" in msg
|
|
44
|
+
chart = next(c for c in msg["updateComponents"]["components"] if c.get("component") == "A2UI_Chart")
|
|
45
|
+
assert chart["plotType"] == "donut"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
pt, checked = test_deterministic_surface_validates()
|
|
50
|
+
test_rechart_message_is_layout_only()
|
|
51
|
+
print("plotType:", pt)
|
|
52
|
+
print("validated:", checked)
|
|
53
|
+
print("ALL OK")
|