@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.
|
|
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
|
-
|
|
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
|
+
}
|
package/strategies/registry.js
CHANGED
|
@@ -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:
|
|
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
|
};
|