@adia-ai/a2ui-compose 0.3.1 → 0.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,4 +39,4 @@
39
39
  "@adia-ai/a2ui-validator": "^0.3.0",
40
40
  "@adia-ai/llm": "^0.3.0"
41
41
  }
42
- }
42
+ }
@@ -118,11 +118,39 @@ async function generateZettelAdapter(ctx) {
118
118
  };
119
119
  }
120
120
 
121
+ async function generateChunkZettelAdapter(ctx) {
122
+ const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
123
+ const result = await composeFromIntent({
124
+ intent: ctx.intent,
125
+ llmAdapter: ctx.llmAdapter || null,
126
+ maxAttempts: 2,
127
+ });
128
+
129
+ // Convert chunk-composition result to A2UI message shape.
130
+ const messages = result.html
131
+ ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
132
+ : [];
133
+
134
+ return {
135
+ executionId: ctx.executionId,
136
+ messages,
137
+ validation: { score: result.html ? 70 : 0 },
138
+ strategy: result.source === 'retrieval' ? 'chunk-retrieval' : 'chunk-synthesis',
139
+ engine: 'chunk-zettel',
140
+ _debug: {
141
+ plan: result.plan || null,
142
+ warnings: result.warnings || [],
143
+ scopeDrift: result.scopeDrift || null,
144
+ },
145
+ };
146
+ }
147
+
121
148
  export const ENGINES = {
122
149
  'monolithic-instant': generateInstantAdapter,
123
150
  'monolithic-pro': generateProAdapter,
124
151
  'monolithic-thinking': generateThinkingAdapter,
125
152
  'zettel': generateZettelAdapter,
153
+ 'chunk-zettel': generateChunkZettelAdapter,
126
154
  };
127
155
 
128
156
  /**
@@ -147,7 +175,7 @@ export const ENGINES = {
147
175
  * });
148
176
  * generateUI({ engine: 'my-hybrid', intent: '...' });
149
177
  */
150
- const RESERVED = new Set(['monolithic', 'monolithic-instant', 'monolithic-pro', 'monolithic-thinking', 'zettel']);
178
+ const RESERVED = new Set(['monolithic', 'monolithic-instant', 'monolithic-pro', 'monolithic-thinking', 'zettel', 'chunk-zettel']);
151
179
 
152
180
  export function registerEngine(name, generateFn) {
153
181
  if (typeof name !== 'string' || !name.length) {
@@ -40,31 +40,56 @@ const SCOPE_DRIFT_MIN_ACTUAL = 20;
40
40
 
41
41
  const SYSTEM_PROMPT = `You compose web-app pages by binding training chunks into named slots.
42
42
 
43
- You are given:
44
- - A user intent (what they want to build).
45
- - A catalog of available chunks. Each chunk is a discrete UI sample harvested
46
- from a real page. Chunks have:
47
- - name (stable ID)
48
- - kind ("page", "panel", or "block")
49
- - primary (the chunk's outermost element, e.g. "card-ui", "grid-ui")
50
- - slots (only on page/panel chunks — named regions where blocks plug in)
51
- - 1-3 example bindings as in-context anchors.
43
+ ## YOUR TASK
44
+
45
+ Given a user intent and a catalog of chunks, return a JSON object that:
46
+ 1. SELECTS the most appropriate page-kind shell
47
+ 2. BINDS block/panel chunks into its slots
48
+
49
+ ## STEP 1 SELECT A PAGE SHELL
50
+
51
+ Page shells (kind=page or kind=panel) define the layout topology. Match the
52
+ intent domain to the right shell — NEVER default to dashboard-admin-page for
53
+ non-dashboard intents:
54
+
55
+ | Shell | Domain | Slots |
56
+ |-------|--------|-------|
57
+ | dashboard-admin-page | Admin/analytics dashboards | page-header, page-content |
58
+ | settings-page-shell | Settings, preferences, configuration | page-header, page-tabs, page-content |
59
+ | form-page-shell | Auth forms, sign-in, sign-up, profile | page-header, form-content, page-footer |
60
+ | marketing-page-shell | Landing pages, heroes, features, CTAs | hero, features, cta |
61
+ | error-page-shell | Error states (404, 500, permission denied) | error-content, navigation |
62
+ | editor-page-shell | Split-pane editors (code + preview) | editor-pane, preview-pane |
63
+ | onb-step-shell | Onboarding wizards, multi-step flows | page-story, page-header, page-content, page-footer |
64
+ | reg-step-shell | Registration flows, multi-step sign-up | page-story, page-header, page-content, page-footer |
65
+
66
+ Selection rule: the shell whose DOMAIN matches the intent keywords wins.
67
+
68
+ ## STEP 2 — SELECT BLOCKS TO FILL SLOTS
69
+
70
+ - Search the block catalog for chunks whose name contains intent keywords.
71
+ - Prefer blocks whose primary tag matches the desired component (e.g.
72
+ "stat-ui" for stat cards, "chart-ui" for charts).
73
+ - If a slot is optional and no matching block exists, OMIT it rather
74
+ than forcing a random block.
75
+ - Never invent chunk names. Every bound name must appear in the catalog.
76
+
77
+ ## STEP 3 — RETURN JSON
52
78
 
53
79
  Return ONLY a JSON object shaped exactly like:
54
80
  {
55
- "page": "<name of a page-kind chunk to use as the layout shell>",
81
+ "page": "<name of page-kind chunk>",
56
82
  "slot_bindings": {
57
- "<slot-name>": "<bound chunk name>" // single chunk
83
+ "<slot-name>": "<bound chunk name>"
58
84
  OR
59
- "<slot-name>": ["<chunk1>", "<chunk2>"] // ordered list of chunks
85
+ "<slot-name>": ["<chunk1>", "<chunk2>"]
60
86
  }
61
87
  }
62
88
 
63
89
  Rules:
64
90
  - The "page" must be a chunk with kind="page" or kind="panel".
65
- - Every slot you bind must be declared by the page chunk (do not invent slots).
66
- - Bound chunks should be kind="block" or kind="panel" pages can't nest inside
67
- pages.
91
+ - Every slot key must be declared by the chosen page shell.
92
+ - Every bound value must be a real chunk name from the catalog.
68
93
  - Return valid JSON. No prose, no comments, no markdown fences. Begin with "{".
69
94
  `;
70
95
 
@@ -78,8 +103,8 @@ function buildCatalogSummary(chunks) {
78
103
  }
79
104
 
80
105
  function buildExamples() {
81
- // Hard-coded canonical example: "build me an admin dashboard". This anchors
82
- // the LLM on the expected output shape.
106
+ // Four canonical examples spanning different domains. Diverse examples
107
+ // prevent the LLM from defaulting to dashboard-shaped output.
83
108
  return [
84
109
  {
85
110
  intent: 'admin dashboard with KPIs and a conversion funnel',
@@ -91,6 +116,38 @@ function buildExamples() {
91
116
  },
92
117
  },
93
118
  },
119
+ {
120
+ intent: 'sign-in form with email and password',
121
+ output: {
122
+ page: 'form-page-shell',
123
+ slot_bindings: {
124
+ 'page-header': 'auth-signin-card-password',
125
+ 'form-content': 'auth-email-entry',
126
+ },
127
+ },
128
+ },
129
+ {
130
+ intent: 'settings page with tabs for general and billing',
131
+ output: {
132
+ page: 'settings-page-shell',
133
+ slot_bindings: {
134
+ 'page-header': 'settings-general-form',
135
+ 'page-tabs': 'check-combinations-settings-group',
136
+ 'page-content': 'settings-general-form',
137
+ },
138
+ },
139
+ },
140
+ {
141
+ intent: 'marketing landing hero with feature cards',
142
+ output: {
143
+ page: 'marketing-page-shell',
144
+ slot_bindings: {
145
+ hero: 'hero-cta-simple',
146
+ features: 'empty-state-action',
147
+ cta: 'button-primary',
148
+ },
149
+ },
150
+ },
94
151
  ];
95
152
  }
96
153
 
@@ -150,23 +207,31 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
150
207
  //
151
208
  // Restricted to kind=block: page/panel chunks are SKELETONS that need
152
209
  // slot-binding composition (Tier 2 handles them). Returning a skeleton
153
- // directly from Tier-1 would emit a near-empty page; we only want the
154
- // fast-path for atomic block patterns.
155
- const hits = await searchChunksAsync(intent, { kind: 'block', limit: 5 });
156
- if (hits.length > 0 && hits[0].score >= STRONG_RETRIEVAL_SCORE) {
157
- const top = getChunk(hits[0].name);
210
+ // Fast path deterministic keyword-first; embeddings as tie-breaker only.
211
+ // Embeddings can drift (e.g. "pane" matching "panel" boosting unrelated
212
+ // chunks). Sync keyword search is stable and keyword-discoverability is
213
+ // the baseline guarantee. We run both and prefer the sync result when
214
+ // it meets the threshold; only use async when sync is weak but async
215
+ // is strong (e.g. semantic match on content not captured by keywords).
216
+ const syncHits = searchChunks(intent, { kind: 'block', limit: 5 });
217
+ const asyncHits = await searchChunksAsync(intent, { kind: 'block', limit: 5 });
218
+
219
+ const syncTop = syncHits[0];
220
+ const asyncTop = asyncHits[0];
221
+ const useSync = syncTop && syncTop.score >= STRONG_RETRIEVAL_SCORE;
222
+ const useAsync = !useSync && asyncTop && asyncTop.score >= STRONG_RETRIEVAL_SCORE;
223
+
224
+ if (useSync || useAsync) {
225
+ const hit = useSync ? syncTop : asyncTop;
226
+ const top = getChunk(hit.name);
158
227
  const html = top.html || top.instances?.[0]?.html || '';
159
- // Tier-1 fast path: sole bound chunk is the retrieved block. The gate
160
- // is mostly a no-op here (ratio ≈ 1) but stays for symmetry — and to
161
- // catch a corner case where retrieval returns a block that, post-render,
162
- // expands far beyond its source (shouldn't happen, but worth detecting).
163
228
  const scopeDrift = computeScopeDrift(html, [top]);
164
229
  return {
165
230
  html,
166
231
  plan: null,
167
232
  source: 'retrieval',
168
- score: hits[0].score,
169
- cosineScore: hits[0].cosineScore,
233
+ score: hit.score,
234
+ cosineScore: hit.cosineScore,
170
235
  warnings: scopeDrift.drift
171
236
  ? [`scope drift: ${scopeDrift.actual} components in HTML vs ${scopeDrift.expected} in bound chunk (ratio ${scopeDrift.ratio.toFixed(2)}×)`]
172
237
  : [],
@@ -211,15 +276,16 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
211
276
  // Trace: snapshot of the retrieval log for the issue-reporter to surface
212
277
  // verbatim on bug tickets. Recorded once before the retry loop so it
213
278
  // describes what the LLM actually saw.
279
+ const tier1HitList = useSync ? syncHits : (useAsync ? asyncHits : []);
214
280
  const retrievalTrace = {
215
- tier1Hits: hits.slice(0, 5).map((h) => ({
281
+ tier1Hits: tier1HitList.slice(0, 5).map((h) => ({
216
282
  name: h.name,
217
283
  score: Number(h.score.toFixed(3)),
218
284
  kind: h.kind,
219
285
  cosineScore: h.cosineScore != null ? Number(h.cosineScore.toFixed(3)) : null,
220
286
  })),
221
287
  tier1Threshold: STRONG_RETRIEVAL_SCORE,
222
- tier1Pass: hits.length > 0 && hits[0].score >= STRONG_RETRIEVAL_SCORE,
288
+ tier1Pass: tier1HitList.length > 0 && tier1HitList[0].score >= STRONG_RETRIEVAL_SCORE,
223
289
  catalogSize: filtered.length,
224
290
  catalogPageNames: pageChunks.map((c) => c.name),
225
291
  catalogPanelNames: panelChunks.map((c) => c.name),