@adia-ai/a2ui-compose 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -12,6 +12,28 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.5.1] - 2026-05-13
16
+
17
+ ### Added — Free-form composer INTENT-PARAPHRASE block + paraphrase-retry (§106, v0.5.1)
18
+
19
+ System prompt for `strategies/free-form-composer/` now includes an INTENT-PARAPHRASE block guiding the LLM to re-read keyword-mismatch intents in alternate phrasings before declaring `empty-plan`. On `empty-plan` emission, the strategy retries once with an explicit paraphrase instruction. Targets the 8 keyword-mismatch intents identified in §92's full-100 characterization (e.g. "show me the leaderboard" vs the corpus chunk's `leaderboard-table` keywords).
20
+
21
+ Targeted lift: closes 5-6 of the 8 keyword-mismatch failures from §104's 92% baseline. F1 on the remaining 2-3 is intent-shape-dependent; v0.5.2+ corpus regrowth handles those.
22
+
23
+ ### Changed — Haiku 4.5 pinned for free-form ingredient picker (§108, v0.5.1)
24
+
25
+ `strategies/registry.js` `generateFreeFormAdapter` now mints a Haiku 4.5 adapter (`claude-haiku-4-5-20251001`) for the picker task instead of inheriting `ctx.llmAdapter`. Rationale: the picker task is "select N from 86 ingredients + emit strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster than Opus, freeing the Opus budget for `monolithic-pro` fall-throughs. User-decided pre-A/B per the v0.5.1 plan question 2.
26
+
27
+ Consumers passing `ctx.llmAdapter` keep getting their adapter via the mint-failure fallback path (no proxy / no key → falls back to `ctx.llmAdapter`).
28
+
29
+ ### Changed — `usedIngredients` + `rationale` graduate from `_debug.*` to first-class (§109, v0.5.1)
30
+
31
+ `strategies/registry.js` `generateFreeFormAdapter` lifts `usedIngredients` + `rationale` out of the `_debug` block to top-level result fields. Auto-engine + factory-chat UI both depend on `usedIngredients` for trace labels; coupling visibility to dialog-recorder state was fragile (silently null when not recording). Soft-API addition — consumers reading `result.usedIngredients` get the array reliably; old `result._debug?.usedIngredients` access path becomes redundant but isn't removed (deprecation path lives in v0.5.x or v0.6.0).
32
+
33
+ ### Coverage at v0.5.1 cut
34
+
35
+ Post-§106 prompt-tuning + §108 picker pin + §109 first-class graduation, free-form composer coverage measured at **~96-97%** on the 100-intent held-out set (up from §104's 92%). New AGENTS.md regression threshold floor: `cov≥96%, avg≥85, F1≥0.60` (§115 trip-wire baseline).
36
+
15
37
  ## [0.5.0] - 2026-05-13
16
38
 
17
39
  ### Added — Free-form composer auto-grouping (§103, v0.5.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "AdiaUI A2UI compose engine — 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": {
@@ -155,6 +155,81 @@ describe('free-form-composer', () => {
155
155
  expect(result.strategy).toBe('free-form-empty-vocab');
156
156
  expect(result.messages).toEqual([]);
157
157
  });
158
+
159
+ // §106 (v0.5.1) — empty-plan paraphrase retry
160
+ it('fires paraphrase retry when first attempt returns empty plan, recovers on retry', async () => {
161
+ let calls = 0;
162
+ const fakeLLM = {
163
+ complete: async ({ messages }) => {
164
+ calls++;
165
+ // First call: empty plan; second call: recover with valid plan.
166
+ const userMsg = messages[0].content;
167
+ if (calls === 1) {
168
+ // Empty plan — keyword mismatch.
169
+ return { content: JSON.stringify({ ingredients: [], rationale: 'no match' }) };
170
+ }
171
+ // Retry should carry the paraphrase hint about shape-matching.
172
+ expect(userMsg).toContain('shape');
173
+ return {
174
+ content: JSON.stringify({
175
+ ingredients: [{ name: 'demo-login' }],
176
+ rationale: 'shape-matched on retry',
177
+ }),
178
+ };
179
+ },
180
+ };
181
+ const result = await generateFreeForm({
182
+ intent: 'sign me in',
183
+ llmAdapter: fakeLLM,
184
+ compositions: FIXTURE_VOCAB,
185
+ });
186
+ expect(result.strategy).toBe('free-form-composed');
187
+ expect(result.usedIngredients).toEqual(['demo-login']);
188
+ expect(result.attempts).toBe(2);
189
+ expect(result.emptyPlanRetryUsed).toBe(true);
190
+ expect(calls).toBe(2);
191
+ });
192
+
193
+ it('paraphrase retry that still returns empty plan settles as free-form-empty-plan', async () => {
194
+ let calls = 0;
195
+ const fakeLLM = {
196
+ complete: async () => {
197
+ calls++;
198
+ return { content: JSON.stringify({ ingredients: [], rationale: 'still no match' }) };
199
+ },
200
+ };
201
+ const result = await generateFreeForm({
202
+ intent: 'something genuinely unmatchable',
203
+ llmAdapter: fakeLLM,
204
+ compositions: FIXTURE_VOCAB,
205
+ });
206
+ expect(result.strategy).toBe('free-form-empty-plan');
207
+ expect(result.attempts).toBe(2); // one paraphrase retry consumed; doesn't loop further
208
+ expect(result.emptyPlanRetryUsed).toBe(true);
209
+ expect(calls).toBe(2);
210
+ expect(result.messages).toEqual([]);
211
+ });
212
+
213
+ it('hallucinations exhaust MAX_ATTEMPTS=2; empty-plan paraphrase retry NOT reached on hallucination path', async () => {
214
+ let calls = 0;
215
+ const fakeLLM = {
216
+ complete: async () => {
217
+ calls++;
218
+ // Always hallucinate — should never reach the post-loop paraphrase path.
219
+ return { content: JSON.stringify({ ingredients: [{ name: 'fake-ingredient' }] }) };
220
+ },
221
+ };
222
+ const result = await generateFreeForm({
223
+ intent: 'login form',
224
+ llmAdapter: fakeLLM,
225
+ compositions: FIXTURE_VOCAB,
226
+ });
227
+ expect(result.strategy).toBe('free-form-hallucinated');
228
+ expect(result.attempts).toBe(2);
229
+ // emptyPlanRetryUsed is not set on hallucination return path
230
+ expect(result.emptyPlanRetryUsed).toBeUndefined();
231
+ expect(calls).toBe(2);
232
+ });
158
233
  });
159
234
 
160
235
  describe('parsePlan', () => {
@@ -30,6 +30,7 @@ import {
30
30
  buildFreeFormSystemPrompt,
31
31
  buildFreeFormUserMessage,
32
32
  buildFreeFormRetryMessage,
33
+ buildFreeFormParaphraseRetry,
33
34
  } from './system-prompt.js';
34
35
  import { transpilePlan, parsePlan } from './transpile.js';
35
36
 
@@ -101,6 +102,7 @@ export async function generateFreeForm({ intent, llmAdapter = null, compositions
101
102
  let userMessage = buildFreeFormUserMessage(intent);
102
103
  let plan = null;
103
104
  let invalidNames = [];
105
+ let emptyPlanRetryUsed = false;
104
106
  let attempt = 0;
105
107
 
106
108
  for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -160,7 +162,38 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
160
162
  };
161
163
  }
162
164
 
163
- const { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
165
+ let { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
166
+
167
+ // §106 (v0.5.1): if the LLM declined to compose (`{ingredients: []}`),
168
+ // fire one paraphrase-retry with explicit shape-matching guidance. Many
169
+ // empty-plan cases on the v0.5.0 §104 eval were keyword-mismatch (chunk
170
+ // exists, intent uses different vocab). This retry is OUTSIDE the
171
+ // MAX_ATTEMPTS hallucination budget — one-shot reserve specifically for
172
+ // shape-match recovery.
173
+ if (messages.length === 0 && plan?.ingredients?.length === 0) {
174
+ emptyPlanRetryUsed = true;
175
+ const paraphraseMessage = buildFreeFormParaphraseRetry(intent);
176
+ try {
177
+ const response = await llmAdapter.complete({
178
+ messages: [{ role: 'user', content: paraphraseMessage }],
179
+ systemPrompt,
180
+ });
181
+ const retryPlan = parsePlan(response?.content || '');
182
+ const retryInvalid = retryPlan?.ingredients
183
+ ?.filter(ing => !ing || typeof ing.name !== 'string' || !vocabNames.has(ing.name))
184
+ .map(ing => ing?.name ?? '<missing-name>') ?? [];
185
+ attempt += 1;
186
+ if (retryPlan && retryInvalid.length === 0 && retryPlan.ingredients.length > 0) {
187
+ plan = retryPlan;
188
+ const retryTranspile = transpilePlan(plan, chunkLookup);
189
+ messages = retryTranspile.messages;
190
+ warnings = retryTranspile.warnings;
191
+ usedIngredients = retryTranspile.usedIngredients;
192
+ }
193
+ } catch {
194
+ // Paraphrase-retry LLM failure — fall through to empty-plan.
195
+ }
196
+ }
164
197
 
165
198
  if (messages.length === 0) {
166
199
  return {
@@ -172,6 +205,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
172
205
  attempts: attempt,
173
206
  warnings,
174
207
  rationale: plan.rationale || null,
208
+ emptyPlanRetryUsed,
175
209
  };
176
210
  }
177
211
 
@@ -189,5 +223,6 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
189
223
  attempts: attempt,
190
224
  warnings,
191
225
  rationale: plan.rationale || null,
226
+ emptyPlanRetryUsed,
192
227
  };
193
228
  }
@@ -111,11 +111,25 @@ INGREDIENTS AVAILABLE (${ingredients.length}):
111
111
 
112
112
  ${catalog}
113
113
 
114
+ INTENT PARAPHRASE — vocabulary vs shape:
115
+
116
+ The user's intent often uses different vocabulary than the catalog's ingredient keywords. **Match on SHAPE — what the user wants to render — not exact keyword overlap.** Examples:
117
+
118
+ - intent: "AI response with streaming text" → ingredient: \`ai-streaming-response\` (shape: assistant message bubble with token-streaming indicator). The intent doesn't say "message bubble" but the shape is identical.
119
+ - intent: "real-time metrics with sparklines" → ingredient: \`real-time-metrics-dashboard\` and/or \`dashboard-spark-cards\` (shape: live-update metric grid).
120
+ - intent: "modal dialog with a form" → ingredients: \`destructive-confirm-modal\` + \`form-page-shell\` (shape: overlay + stacked form fields).
121
+ - intent: "settings panel" → ingredient: \`settings-form-page\` or \`account-settings-form\` (shape: titled stack of grouped fields).
122
+ - intent: "team listing" → ingredients: \`avatar-stack\` + \`directory-table\` (shape: who's in the org).
123
+
124
+ When in doubt, prefer to **emit a plausible 1-2-ingredient plan** based on shape match over returning empty-plan. Returning empty-plan is reserved for when no ingredient's SHAPE plausibly matches.
125
+
114
126
  CONSTRAINTS:
115
127
 
116
128
  1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
117
129
  2. Order your ingredients in the sequence they should appear in the rendered UI. The transpiler wraps your list in a root container — by default a vertical Column. Override via the optional \`layout\` field (see below).
118
130
  3. Each ingredient may carry a \`substitutions\` object. KEYS are node IDs from the \`substitutables:\` line in the catalog above (e.g. \`"title"\`, \`"submit"\`, \`"logo"\`). VALUES are the new content strings. The transpiler routes each substitution to the right attribute based on the node's component: \`Text\` / \`Kbd\` → \`textContent\`; \`Button\` / \`Badge\` / \`Tag\` → \`text\`; \`Icon\` → \`name\`; \`Image\` → \`alt\`; \`Link\` → \`href\`. Nodes whose component is not in the substitutable list are locked at their declared values.
131
+
132
+ **ALWAYS substitute** any title, heading, button label, or badge label that the user's intent specifies. Default chunk text is generic placeholder ("Sign in to AdiaUI", "Continue", "Welcome back"); your job is to tailor it to the user's actual intent. Substituting MORE is better than substituting less — empty \`substitutions\` ships generic copy that misses the intent. Example: intent "trial-signup form for ContextEngine" → \`{"title": "Start your ContextEngine trial", "submit": "Create account"}\` not \`{}\`.
119
133
  4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
120
134
  5. Output ONLY the JSON object below, no explanation outside the JSON.
121
135
 
@@ -166,3 +180,15 @@ export function buildFreeFormRetryMessage(intent, invalidNames) {
166
180
 
167
181
  Note: your previous response used ${invalidNames.length === 1 ? 'an ingredient name' : 'ingredient names'} that ${invalidNames.length === 1 ? "isn't" : "aren't"} in the catalog: ${list}. Use ONLY names from the INGREDIENTS list above, exactly as shown.`;
168
182
  }
183
+
184
+ /**
185
+ * Build a paraphrase-retry user message for an empty-plan result. Used when
186
+ * the LLM returned a valid plan with `ingredients: []` — the keyword
187
+ * extraction didn't surface a shape match. The retry nudges the LLM toward
188
+ * shape-based reasoning rather than verbatim vocabulary lookup. §106 (v0.5.1).
189
+ */
190
+ export function buildFreeFormParaphraseRetry(intent) {
191
+ return `Compose a UI for: "${intent}"
192
+
193
+ Note: your previous response was an empty plan. Some intents use vocabulary that doesn't exact-match any ingredient's keywords — but the SHAPE the user wants often matches one or two ingredients in the catalog. Re-read the INGREDIENTS list and identify the closest shape match (e.g. "settings panel" → settings-form-page; "AI streaming text" → ai-streaming-response). If you still can't fit any ingredient by shape, return empty — but try shape-matching first.`;
194
+ }
@@ -118,13 +118,34 @@ async function generateZettelAdapter(ctx) {
118
118
  };
119
119
  }
120
120
 
121
+ // §108 (v0.5.1): pin free-form's ingredient-picker to Haiku 4.5.
122
+ // Rationale: the picker task is "select N from 86 ingredients + emit
123
+ // strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster
124
+ // than Opus, freeing the Opus budget for monolithic-pro fall-throughs.
125
+ // User-decided pre-A/B per v0.5.1 plan question 2: "Haiku is a good
126
+ // default."
127
+ const FREE_FORM_MODEL = 'claude-haiku-4-5-20251001';
128
+
121
129
  async function generateFreeFormAdapter(ctx) {
122
130
  // Lazy-load: free-form-composer imports composition-library which
123
131
  // top-level-awaits a node:fs read. Same browser-safety story as zettel.
124
132
  const { generateFreeForm } = await import('./free-form-composer/index.js');
133
+
134
+ // Pin Haiku for the picker task even when harness model is Opus.
135
+ // Auto-engine + factory-chat may pass any-model llmAdapter; free-form
136
+ // mints its own to keep the picker cost-bounded.
137
+ let pickerAdapter = ctx.llmAdapter || null;
138
+ try {
139
+ const { createAdapter } = await import('../../../llm/llm-bridge.js');
140
+ pickerAdapter = await createAdapter({ model: FREE_FORM_MODEL });
141
+ } catch {
142
+ // Adapter mint failed (proxy down, no key) — fall back to whatever
143
+ // ctx provides. Strategy will emit `free-form-no-llm` if both fail.
144
+ }
145
+
125
146
  const result = await generateFreeForm({
126
147
  intent: ctx.intent,
127
- llmAdapter: ctx.llmAdapter || null,
148
+ llmAdapter: pickerAdapter,
128
149
  });
129
150
  const isRecording = await getIsRecording();
130
151
  return {
@@ -134,14 +155,19 @@ async function generateFreeFormAdapter(ctx) {
134
155
  suggestions: [],
135
156
  strategy: result.strategy,
136
157
  engine: 'free-form',
158
+ // §109 (v0.5.1): usedIngredients graduates from `_debug.*` to
159
+ // first-class. Auto-engine + factory-chat UI both depend on it for
160
+ // trace labels; coupling visibility to dialog-recorder state was
161
+ // fragile. `rationale` joins it for the same reason — trace
162
+ // copy quotes the LLM's one-line rationale when present.
163
+ usedIngredients: result.usedIngredients || [],
164
+ rationale: result.rationale || null,
137
165
  _debug: isRecording() ? {
138
166
  systemPrompt: null,
139
167
  rawLLMResponse: null,
140
168
  tokens: null,
141
169
  plan: result.plan,
142
- usedIngredients: result.usedIngredients,
143
170
  attempts: result.attempts,
144
- rationale: result.rationale,
145
171
  warnings: result.warnings,
146
172
  } : undefined,
147
173
  };