@adia-ai/a2ui-compose 0.4.3 → 0.4.5
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 +33 -0
- package/package.json +1 -1
- package/strategies/_shared/chunk-loader.js +208 -0
- package/strategies/_shared/chunk-loader.test.js +80 -0
- package/strategies/monolithic/_shared.js +26 -0
- package/strategies/monolithic/generate-pro.js +93 -10
- package/strategies/registry.js +1 -1
- package/strategies/zettel/_smoke.js +2 -12
- package/strategies/zettel/composer.js +36 -121
- package/strategies/zettel/composition-library.js +207 -0
- package/strategies/zettel/generator-adapter.js +1 -1
- package/strategies/zettel/synthesizer.js +16 -337
- package/transpiler/transpiler-maps.js +13 -6
- package/strategies/zettel/fragment-library.js +0 -209
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,39 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.4.5] - 2026-05-12
|
|
16
|
+
|
|
17
|
+
### Changed — GenUI overhaul prompt-engineering (§56, v0.4.5)
|
|
18
|
+
|
|
19
|
+
- **`strategies/monolithic/_shared.js` — CORPUS CONTEXT block added to `buildSystemPrompt`** (~30 new lines between role/output-format and CARD-N content model). Surgical insertion explaining the §36-§51 retrieval-augmented pipeline:
|
|
20
|
+
- Pipeline searches PATTERNS / COMPOSITIONS first (full-canvas A2UI templates: login-form, pricing-tiers, dashboard-admin-page)
|
|
21
|
+
- Pipeline also searches ANNOTATED CHUNKS (real production HTML with `metadata.{domain, keywords, description}`)
|
|
22
|
+
- When matched, `MATCHED PATTERN` / `STRUCTURAL REFERENCE` blocks carry the actual reference
|
|
23
|
+
- Only AVAILABLE COMPONENTS are usable — anything else is a hallucination
|
|
24
|
+
|
|
25
|
+
Cost: ~150 tokens per request. Benefit: LLM understands what the pipeline around it IS instead of treating the matched-pattern block as anonymous instructions. Existing rules unchanged; no removed lines.
|
|
26
|
+
|
|
27
|
+
- **`strategies/monolithic/generate-pro.js` — STRUCTURAL REFERENCE prose enriched.** Prior copy: "a real production block from the codebase matched this intent." New copy: "this chunk was retrieved from the AdiaUI training corpus (annotated production HTML, harvested from real app pages). It matched your intent on keyword/domain ranking." Threads chunk `metadata.domain`, `metadata.description`, `metadata.keywords` into the prompt when present. Reframes "do not copy the HTML" as "the chunk represents the SHAPE the user wants; instantiate it with their content" — more actionable framing.
|
|
28
|
+
|
|
29
|
+
See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the v0.4.5 overhaul arc + apps/genui/CHANGELOG.md `[Unreleased]` for the per-§ rollup.
|
|
30
|
+
|
|
31
|
+
## [0.4.4] - 2026-05-12
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **`strategies/zettel/fragment-library.js` → `composition-library.js` rename (§38).** Reflects the actual responsibility post-§37 (fragments retired; library now loads compositions + annotated chunks). API shape unchanged for callers; back-compat shims dropped after grep-verified zero external consumers.
|
|
36
|
+
- **`strategies/zettel/composer.js` simplified (§37).** `resolveComposition` becomes a defensive copy + strip pass now that compositions are flat A2UI templates (no slot machinery). `templateToMessages` preserved for callers.
|
|
37
|
+
- **`strategies/zettel/composition-library.js` — read annotated chunks alongside hand-authored compositions (§41).** `loadAll()` walks both `corpus/compositions/` (hand-authored) AND `corpus/chunks/` (annotated + transpiled). Chunks with a `metadata` field AND a `template` field get normalized to composition shape via `chunkToComposition()`. Name collisions: hand-authored wins with a loud warn. Boot reports `compositionCount=127, handAuthored=100, annotatedChunks=27` post-§50.
|
|
38
|
+
- **`strategies/zettel/synthesizer.js` retired as throwing stub (§37).** Throws `SYNTHESIZER_RETIRED`; generator-adapter's try/catch handles it gracefully. Deferred follow-up: chunk-based synthesizer for zettel iteration.
|
|
39
|
+
- **`compose/transpiler/transpiler-maps.js` — `<a>` → `Link` (was: `Button + variant=ghost`) (§47).** button.yaml line 60 explicitly documents `<link-ui>` is the canonical link primitive: *"Mixing navigation and action affordances under the same primitive is a category error fixed at this junction."* The transpiler had been making this category error since project inception. Added a Link-specific extractor block (parallels the Button extractor at line 154) that pulls `href` / `target` / `rel` from the source attrs.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **`compose/strategies/monolithic/generate-pro.js` + companion `_shared/fragment-expander.js` — fragment machinery removed (§37).** Fragments retired; 85 compositions inlined with previously-referenced fragment content. Expander deleted; its caller's try/catch in `generator-adapter.js` handles the absence gracefully.
|
|
44
|
+
- **MCP server `mcp/server.js` (§37).** Removed `get_fragment` tool, simplified `zettel_stats`, updated imports + boot logs + engine description to reflect the post-fragment world.
|
|
45
|
+
|
|
46
|
+
See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative + [docs/journal/2026/05/2026-05-12.md](../../../docs/journal/2026/05/2026-05-12.md) §§ 37 / 38 / 41 / 47 for per-§ details.
|
|
47
|
+
|
|
15
48
|
## [0.4.3] - 2026-05-11
|
|
16
49
|
|
|
17
50
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
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": {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-environment chunk loader — Node + browser.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the pattern in packages/a2ui/retrieval/pattern-library.js so the
|
|
5
|
+
* gen-UI training chunks at packages/a2ui/corpus/chunks/*.json are reachable
|
|
6
|
+
* from BOTH server-side engines (monolithic-pro via /api/generate's Node
|
|
7
|
+
* process) AND browser-side engines (monolithic-pro called directly from
|
|
8
|
+
* gen-ui playground's IIFE).
|
|
9
|
+
*
|
|
10
|
+
* The existing packages/a2ui/corpus/scripts/chunk-library.js is Node-only
|
|
11
|
+
* (uses node:fs / readdirSync). This module exists so monolithic-pro can
|
|
12
|
+
* search + fetch chunks WITHOUT pulling node:fs into the browser bundle.
|
|
13
|
+
*
|
|
14
|
+
* Surface:
|
|
15
|
+
* - searchChunks(query, { kind, limit }): keyword-ranked chunk matches
|
|
16
|
+
* - getChunk(name): full chunk record by name
|
|
17
|
+
*
|
|
18
|
+
* Both are async because the browser path uses dynamic imports.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const IS_NODE =
|
|
22
|
+
typeof process !== 'undefined' &&
|
|
23
|
+
typeof process.versions?.node === 'string';
|
|
24
|
+
|
|
25
|
+
// ── State ──────────────────────────────────────────────────────────
|
|
26
|
+
let _state = null; // { chunks: Map<name, record>, byKind: Map<kind, Set<name>> }
|
|
27
|
+
let _loadPromise = null;
|
|
28
|
+
|
|
29
|
+
// ── Browser glob ───────────────────────────────────────────────────
|
|
30
|
+
// Vite resolves this at build time; in Node the variable is unused.
|
|
31
|
+
let _globModules = null;
|
|
32
|
+
if (!IS_NODE) {
|
|
33
|
+
try {
|
|
34
|
+
_globModules = import.meta.glob('../../../corpus/chunks/*.json', {
|
|
35
|
+
query: '?raw',
|
|
36
|
+
import: 'default',
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
// Not in a Vite context — fall back to empty corpus
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
44
|
+
async function _load() {
|
|
45
|
+
if (_state) return _state;
|
|
46
|
+
if (_loadPromise) return _loadPromise;
|
|
47
|
+
_loadPromise = (async () => {
|
|
48
|
+
if (IS_NODE) {
|
|
49
|
+
_state = await _loadNode();
|
|
50
|
+
} else {
|
|
51
|
+
_state = await _loadBrowser();
|
|
52
|
+
}
|
|
53
|
+
return _state;
|
|
54
|
+
})();
|
|
55
|
+
return _loadPromise;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function _loadNode() {
|
|
59
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
60
|
+
const path = await import(/* @vite-ignore */ 'node:path');
|
|
61
|
+
const url = await import(/* @vite-ignore */ 'node:url');
|
|
62
|
+
|
|
63
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
64
|
+
const chunksDir = path.resolve(__dirname, '..', '..', '..', 'corpus', 'chunks');
|
|
65
|
+
|
|
66
|
+
const chunks = new Map();
|
|
67
|
+
const byKind = new Map();
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await fs.readdir(chunksDir);
|
|
71
|
+
} catch {
|
|
72
|
+
return { chunks, byKind };
|
|
73
|
+
}
|
|
74
|
+
for (const file of entries) {
|
|
75
|
+
if (file === '_index.json' || !file.endsWith('.json')) continue;
|
|
76
|
+
try {
|
|
77
|
+
const raw = await fs.readFile(path.join(chunksDir, file), 'utf8');
|
|
78
|
+
const rec = JSON.parse(raw);
|
|
79
|
+
if (!rec?.name) continue;
|
|
80
|
+
chunks.set(rec.name, rec);
|
|
81
|
+
const kind = rec.kind || 'block';
|
|
82
|
+
if (!byKind.has(kind)) byKind.set(kind, new Set());
|
|
83
|
+
byKind.get(kind).add(rec.name);
|
|
84
|
+
} catch {
|
|
85
|
+
// Skip malformed chunks rather than fail the whole load
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { chunks, byKind };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function _loadBrowser() {
|
|
92
|
+
const chunks = new Map();
|
|
93
|
+
const byKind = new Map();
|
|
94
|
+
if (!_globModules) return { chunks, byKind };
|
|
95
|
+
|
|
96
|
+
await Promise.all(
|
|
97
|
+
Object.entries(_globModules).map(async ([path, loader]) => {
|
|
98
|
+
if (path.endsWith('/_index.json')) return;
|
|
99
|
+
try {
|
|
100
|
+
const raw = await loader();
|
|
101
|
+
const rec = JSON.parse(raw);
|
|
102
|
+
if (!rec?.name) return;
|
|
103
|
+
chunks.set(rec.name, rec);
|
|
104
|
+
const kind = rec.kind || 'block';
|
|
105
|
+
if (!byKind.has(kind)) byKind.set(kind, new Set());
|
|
106
|
+
byKind.get(kind).add(rec.name);
|
|
107
|
+
} catch {
|
|
108
|
+
// Skip malformed
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return { chunks, byKind };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Keyword-rank chunks against a query. Mirrors the scoring shape of
|
|
118
|
+
* corpus/scripts/chunk-library.js so behavior is consistent regardless
|
|
119
|
+
* of which loader was used.
|
|
120
|
+
*
|
|
121
|
+
* Scoring (per chunk):
|
|
122
|
+
* +3 query word in name (exact part match)
|
|
123
|
+
* +2 query word in primary tag
|
|
124
|
+
* +1 query word in nested chunk name
|
|
125
|
+
* +1 query word in source page path
|
|
126
|
+
*
|
|
127
|
+
* @param {string} query — user intent
|
|
128
|
+
* @param {object} [opts] — { kind?: 'block'|'page'|'panel', limit?: number }
|
|
129
|
+
* @returns {Promise<Array<{ name, score, kind, primary, record }>>}
|
|
130
|
+
*/
|
|
131
|
+
export async function searchChunks(query, { kind, limit = 20 } = {}) {
|
|
132
|
+
if (!query || typeof query !== 'string') return [];
|
|
133
|
+
const { chunks } = await _load();
|
|
134
|
+
if (!chunks.size) return [];
|
|
135
|
+
|
|
136
|
+
const words = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
|
|
137
|
+
if (!words.length) return [];
|
|
138
|
+
|
|
139
|
+
const candidates = [];
|
|
140
|
+
for (const [name, rec] of chunks) {
|
|
141
|
+
if (kind && rec.kind !== kind) continue;
|
|
142
|
+
let score = 0;
|
|
143
|
+
const nameLow = name.toLowerCase();
|
|
144
|
+
const primary = (rec.primary || '').toLowerCase();
|
|
145
|
+
const source = (rec.source || rec.page || '').toLowerCase();
|
|
146
|
+
const nested = (rec.nested || []).map((n) => n.toLowerCase());
|
|
147
|
+
|
|
148
|
+
for (const w of words) {
|
|
149
|
+
if (nameLow.includes(w)) score += 3;
|
|
150
|
+
if (primary && primary.includes(w)) score += 2;
|
|
151
|
+
for (const n of nested) {
|
|
152
|
+
if (n.includes(w)) {
|
|
153
|
+
score += 1;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (source && source.includes(w)) score += 1;
|
|
158
|
+
}
|
|
159
|
+
if (score > 0) {
|
|
160
|
+
candidates.push({ name, score, kind: rec.kind, primary: rec.primary, record: rec });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
165
|
+
return candidates.slice(0, limit);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch a chunk record by name.
|
|
170
|
+
* @param {string} name
|
|
171
|
+
* @returns {Promise<object|null>}
|
|
172
|
+
*/
|
|
173
|
+
export async function getChunk(name) {
|
|
174
|
+
if (!name) return null;
|
|
175
|
+
const { chunks } = await _load();
|
|
176
|
+
return chunks.get(name) || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Recursively expand a chunk's nested-chunk references into a flat array
|
|
181
|
+
* of chunk records. Useful for materializing the "full structural context"
|
|
182
|
+
* of a top-level chunk into a single prompt-injection payload.
|
|
183
|
+
*
|
|
184
|
+
* Cycle-safe: tracks visited names. Depth-limited to prevent runaway.
|
|
185
|
+
*
|
|
186
|
+
* @param {string|object} nameOrRecord
|
|
187
|
+
* @param {object} [opts] — { maxDepth?: 4 }
|
|
188
|
+
* @returns {Promise<Array<object>>} — flat list of resolved chunk records, root first
|
|
189
|
+
*/
|
|
190
|
+
export async function expandChunkGraph(nameOrRecord, { maxDepth = 4 } = {}) {
|
|
191
|
+
const visited = new Set();
|
|
192
|
+
const out = [];
|
|
193
|
+
|
|
194
|
+
async function walk(item, depth) {
|
|
195
|
+
if (depth > maxDepth) return;
|
|
196
|
+
const rec = typeof item === 'string' ? await getChunk(item) : item;
|
|
197
|
+
if (!rec || visited.has(rec.name)) return;
|
|
198
|
+
visited.add(rec.name);
|
|
199
|
+
out.push(rec);
|
|
200
|
+
|
|
201
|
+
for (const childName of rec.nested || []) {
|
|
202
|
+
await walk(childName, depth + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await walk(nameOrRecord, 0);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chunk-loader.js — Node-side tests.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* 1. The dual-environment loader correctly loads chunks from the
|
|
6
|
+
* corpus on the Node path.
|
|
7
|
+
* 2. searchChunks ranks expected chunks for known intents (the
|
|
8
|
+
* ground-truth case from the user's signup-form screenshot).
|
|
9
|
+
* 3. expandChunkGraph traverses the `nested:` graph correctly with
|
|
10
|
+
* cycle safety + depth capping.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { searchChunks, getChunk, expandChunkGraph } from './chunk-loader.js';
|
|
14
|
+
|
|
15
|
+
describe('chunk-loader / Node path', () => {
|
|
16
|
+
it('searchChunks returns top-ranked matches for a sign-in query', async () => {
|
|
17
|
+
const hits = await searchChunks('sign in with email', { limit: 5 });
|
|
18
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
19
|
+
// auth-signin-card-email should be the top match — it's the canonical
|
|
20
|
+
// signin block in the corpus. If this assertion ever flips, look at
|
|
21
|
+
// chunk-loader's scoring weights or the corpus harvest.
|
|
22
|
+
expect(hits[0].name).toBe('auth-signin-card-email');
|
|
23
|
+
expect(hits[0].score).toBeGreaterThanOrEqual(5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('getChunk returns the full record by name', async () => {
|
|
27
|
+
const rec = await getChunk('auth-signin-card-email');
|
|
28
|
+
expect(rec).not.toBeNull();
|
|
29
|
+
expect(rec.name).toBe('auth-signin-card-email');
|
|
30
|
+
expect(rec.kind).toBe('block');
|
|
31
|
+
expect(rec.primary).toBe('card-ui');
|
|
32
|
+
expect(Array.isArray(rec.nested)).toBe(true);
|
|
33
|
+
expect(rec.nested).toContain('auth-card-header');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('getChunk returns null for unknown names', async () => {
|
|
37
|
+
const rec = await getChunk('this-chunk-does-not-exist');
|
|
38
|
+
expect(rec).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('expandChunkGraph traverses nested refs (root first, breadth-ish)', async () => {
|
|
42
|
+
const graph = await expandChunkGraph('auth-signin-card-email', { maxDepth: 3 });
|
|
43
|
+
// Root should be first
|
|
44
|
+
expect(graph[0].name).toBe('auth-signin-card-email');
|
|
45
|
+
// Nested chunks should appear in the output
|
|
46
|
+
const names = graph.map((g) => g.name);
|
|
47
|
+
expect(names).toContain('auth-card-header');
|
|
48
|
+
expect(names).toContain('auth-card-content');
|
|
49
|
+
expect(names).toContain('auth-email-entry');
|
|
50
|
+
expect(names).toContain('auth-social-auths');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('expandChunkGraph respects maxDepth', async () => {
|
|
54
|
+
const shallow = await expandChunkGraph('auth-signin-card-email', { maxDepth: 0 });
|
|
55
|
+
expect(shallow.length).toBe(1); // root only
|
|
56
|
+
expect(shallow[0].name).toBe('auth-signin-card-email');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('expandChunkGraph deduplicates revisits (cycle safety)', async () => {
|
|
60
|
+
const graph = await expandChunkGraph('auth-signin-card-email', { maxDepth: 5 });
|
|
61
|
+
const names = graph.map((g) => g.name);
|
|
62
|
+
const unique = new Set(names);
|
|
63
|
+
// No duplicates — visited set should catch any revisits even on
|
|
64
|
+
// cyclic graphs.
|
|
65
|
+
expect(names.length).toBe(unique.size);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('searchChunks filters by kind when requested', async () => {
|
|
69
|
+
const pageHits = await searchChunks('admin dashboard', { kind: 'page', limit: 5 });
|
|
70
|
+
for (const h of pageHits) {
|
|
71
|
+
expect(h.kind).toBe('page');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('searchChunks returns empty array for empty / non-string queries', async () => {
|
|
76
|
+
expect(await searchChunks('')).toEqual([]);
|
|
77
|
+
expect(await searchChunks(null)).toEqual([]);
|
|
78
|
+
expect(await searchChunks(undefined)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -51,6 +51,32 @@ Output format: [{ "type": "updateComponents", "surfaceId": "default", "component
|
|
|
51
51
|
Each component: { "id": "<unique>", "component": "<Type>", "children": ["<childId>", ...], ...props }
|
|
52
52
|
The root must have id "root". Use short, descriptive IDs (e.g., "hdr", "email-field", "submit-btn").`);
|
|
53
53
|
|
|
54
|
+
// ── Corpus context (§56, v0.4.5) ──
|
|
55
|
+
// Tells the LLM how the system surrounding it is wired so it doesn't
|
|
56
|
+
// hallucinate component names and so it understands what MATCHED PATTERN /
|
|
57
|
+
// STRUCTURAL REFERENCE blocks actually ARE. Keep this section tight — every
|
|
58
|
+
// token costs latency + money.
|
|
59
|
+
parts.push(`CORPUS CONTEXT (the system around you):
|
|
60
|
+
|
|
61
|
+
You are part of a retrieval-augmented pipeline. Before this prompt was built,
|
|
62
|
+
the pipeline searched two corpora for matches to the user's intent:
|
|
63
|
+
|
|
64
|
+
1. PATTERNS / COMPOSITIONS — full-canvas A2UI templates curated for the
|
|
65
|
+
AdiaUI design system. Examples: login-form, pricing-tiers,
|
|
66
|
+
dashboard-admin-page. When a strong match exists, you'll see a
|
|
67
|
+
"MATCHED PATTERN" block below with the canonical template.
|
|
68
|
+
|
|
69
|
+
2. ANNOTATED CHUNKS — real production HTML blocks harvested from the AdiaUI
|
|
70
|
+
apps (auth flows, dashboards, error pages, etc.) with metadata
|
|
71
|
+
(domain, keywords, description). When a strong chunk match exists,
|
|
72
|
+
you'll see a "STRUCTURAL REFERENCE" block with the production HTML.
|
|
73
|
+
Match its component palette + information density, but translate to
|
|
74
|
+
A2UI components — do not copy raw HTML.
|
|
75
|
+
|
|
76
|
+
Both corpora use ONLY components from the AVAILABLE COMPONENTS list further
|
|
77
|
+
down. If you would invent a component name not in that list, you're
|
|
78
|
+
hallucinating — use the closest canonical name instead.`);
|
|
79
|
+
|
|
54
80
|
// ── Card-N content model (critical for quality) ──
|
|
55
81
|
parts.push(`CARD-N CONTENT MODEL (mandatory for any card surface):
|
|
56
82
|
- Card > Header: for title/description. Use Text children with heading variants (h3, h4).
|
|
@@ -14,6 +14,7 @@ import { isConversational } from '../../../retrieval/intent/intent-gate.js';
|
|
|
14
14
|
import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
|
|
15
15
|
import { store, engine } from '../../core/state.js';
|
|
16
16
|
import { isRecording } from '../../../retrieval/feedback/dialog-recorder.js';
|
|
17
|
+
import { searchChunks, expandChunkGraph } from '../_shared/chunk-loader.js';
|
|
17
18
|
import {
|
|
18
19
|
buildSystemPrompt,
|
|
19
20
|
buildChatMessages,
|
|
@@ -94,10 +95,43 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
|
|
|
94
95
|
confidence: 0.85,
|
|
95
96
|
});
|
|
96
97
|
|
|
97
|
-
// ── Stage 3: Plan — search for best pattern ──
|
|
98
|
+
// ── Stage 3: Plan — search for best pattern + best chunk ──
|
|
98
99
|
const patterns = searchBlocks(searchQuery, { domain: domain.domain });
|
|
99
100
|
const bestPattern = patterns[0];
|
|
100
101
|
|
|
102
|
+
// ── Parallel chunk retrieval (training-corpus path) ──
|
|
103
|
+
// The chunks corpus at packages/a2ui/corpus/chunks/*.json captures real
|
|
104
|
+
// page-shape blocks harvested from working apps (auth/sign-in,
|
|
105
|
+
// settings pages, dashboards, etc.). When a query strongly matches a
|
|
106
|
+
// chunk, we inject the chunk's HTML as a STRUCTURAL REFERENCE into the
|
|
107
|
+
// adapt prompt — so the LLM has the canonical shape, not just a
|
|
108
|
+
// description of it. This closes the gap where monolithic-pro was
|
|
109
|
+
// describing chunks (via the score-card's pattern-match telemetry) but
|
|
110
|
+
// never actually using them in generation.
|
|
111
|
+
//
|
|
112
|
+
// Threshold rationale: searchChunks's keyword-score scale is the same
|
|
113
|
+
// family as the existing STRONG_RETRIEVAL_SCORE=8 in zettel's chunk
|
|
114
|
+
// synthesizer. We use a slightly lower threshold (5) for monolithic
|
|
115
|
+
// because the chunk is REFERENCE, not authoritative — the LLM still
|
|
116
|
+
// adapts, so we'd rather over-inject and let the LLM judge relevance.
|
|
117
|
+
let chunkMatch = null;
|
|
118
|
+
let chunkRefHtml = null;
|
|
119
|
+
try {
|
|
120
|
+
const chunkHits = await searchChunks(searchQuery, { limit: 1 });
|
|
121
|
+
if (chunkHits.length && chunkHits[0].score >= 5) {
|
|
122
|
+
chunkMatch = chunkHits[0];
|
|
123
|
+
// Expand the chunk's nested refs so the LLM sees the complete
|
|
124
|
+
// structural context, not just the outer shell. Depth-capped to
|
|
125
|
+
// protect against the recursive nested chain blowing token budget.
|
|
126
|
+
const graph = await expandChunkGraph(chunkMatch.record, { maxDepth: 3 });
|
|
127
|
+
chunkRefHtml = graph
|
|
128
|
+
.map((g) => `<!-- chunk: ${g.name} (${g.kind}/${g.primary}) -->\n${g.html || ''}`)
|
|
129
|
+
.join('\n\n');
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Chunk loader is best-effort — never block generation on its failures
|
|
133
|
+
}
|
|
134
|
+
|
|
101
135
|
// ── Compositional reasoning: decompose multi-section intents ──
|
|
102
136
|
// ALWAYS attempt decomposition for compound intents — even when a single
|
|
103
137
|
// best pattern matches. This lets the LLM receive fragment patterns for
|
|
@@ -128,10 +162,11 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
|
|
|
128
162
|
const hasFragments = fragmentPatterns.length >= 2;
|
|
129
163
|
engine.submitStage(execId, 'plan', {
|
|
130
164
|
strategy: bestPattern?.template ? (hasFragments ? 'adapt-with-fragments' : 'adapt-pattern') : hasFragments ? 'compose-fragments' : 'generate-fresh',
|
|
131
|
-
patternName: bestPattern?.name ?? null,
|
|
165
|
+
patternName: bestPattern?.name ?? (chunkMatch?.name ?? null),
|
|
166
|
+
chunkRef: chunkMatch ? { name: chunkMatch.name, score: chunkMatch.score, kind: chunkMatch.kind, primary: chunkMatch.primary } : null,
|
|
132
167
|
fragments: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
|
|
133
168
|
layout: hasFragments ? decomposition?.layout : undefined,
|
|
134
|
-
confidence: bestPattern?.template ? 0.9 : hasFragments ? 0.85 : 0.7,
|
|
169
|
+
confidence: bestPattern?.template ? 0.9 : hasFragments ? 0.85 : chunkMatch ? 0.8 : 0.7,
|
|
135
170
|
});
|
|
136
171
|
|
|
137
172
|
// ── Stage 4: Generate ──
|
|
@@ -171,19 +206,52 @@ export async function generatePro({ intent, executionId, storeId, llmAdapter, an
|
|
|
171
206
|
// when the recorder actually persists them to disk.
|
|
172
207
|
let lastRawResponse = null;
|
|
173
208
|
let lastTokens = null;
|
|
209
|
+
|
|
210
|
+
// Pattern templates are pre-inlined as of the 2026-05-12 fragment
|
|
211
|
+
// retirement (§37) — compositions/ no longer carry $fragment refs and
|
|
212
|
+
// there's nothing left to expand. We pass the template straight to the
|
|
213
|
+
// LLM. If a stale pattern with $fragment refs sneaks back in, the LLM
|
|
214
|
+
// will see the literal placeholder string and likely include it in
|
|
215
|
+
// output (acceptable failure mode; corpus drift is caught by the
|
|
216
|
+
// verify:corpus script).
|
|
217
|
+
const expandedPatternTemplate = bestPattern?.template ?? null;
|
|
218
|
+
|
|
219
|
+
// ── Chunk reference block — injected into the user prompt when a
|
|
220
|
+
// strong chunk match was found at retrieval time. This is the
|
|
221
|
+
// structural "look like this" hint that lets the LLM materialize the
|
|
222
|
+
// real chunk shape (e.g. auth-signin-card-email) instead of
|
|
223
|
+
// hallucinating a passable-but-wrong shape from the brief alone.
|
|
224
|
+
//
|
|
225
|
+
// §56 (v0.4.5): prompt language enriched with provenance + metadata so
|
|
226
|
+
// the LLM understands the chunk's domain/keywords context. Tested via
|
|
227
|
+
// factory-chat + gen-ui live submit; LLMs produce closer structural
|
|
228
|
+
// matches when told what the chunk is + where it came from.
|
|
229
|
+
const chunkReferenceBlock = chunkRefHtml
|
|
230
|
+
? `\nSTRUCTURAL REFERENCE — this chunk was retrieved from the AdiaUI training corpus (annotated production HTML, harvested from real app pages). It matched your intent on keyword/domain ranking:
|
|
231
|
+
|
|
232
|
+
CHUNK: ${chunkMatch.name}
|
|
233
|
+
kind=${chunkMatch.kind}, primary=${chunkMatch.primary}, score=${chunkMatch.score}${chunkMatch.metadata?.domain ? `, domain=${chunkMatch.metadata.domain}` : ''}${chunkMatch.metadata?.description ? `\n description: ${chunkMatch.metadata.description}` : ''}${chunkMatch.metadata?.keywords ? `\n keywords: ${chunkMatch.metadata.keywords.join(', ')}` : ''}
|
|
234
|
+
|
|
235
|
+
Match this chunk's component palette, information density, and Card/Header/Section/Footer anatomy. Do NOT copy the HTML verbatim into A2UI — translate its semantic structure into the equivalent A2UI components. The chunk represents the SHAPE the user wants; your job is to instantiate that shape with their content:
|
|
236
|
+
---
|
|
237
|
+
${chunkRefHtml}
|
|
238
|
+
---
|
|
239
|
+
`
|
|
240
|
+
: '';
|
|
241
|
+
|
|
174
242
|
if (bestPattern && bestPattern.template) {
|
|
175
243
|
// PRO PATH: Adapt the matched pattern via LLM
|
|
176
244
|
systemPrompt = await buildSystemPrompt(context, patterns, '', intent);
|
|
177
245
|
|
|
178
246
|
const adaptPrompt = hasPriorCanvas
|
|
179
247
|
? `Adapt this UI for the intent: "${intent}"${driftGuard}
|
|
180
|
-
|
|
248
|
+
${chunkReferenceBlock}
|
|
181
249
|
${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`
|
|
182
250
|
: hasFragments
|
|
183
251
|
? `Adapt this UI for the intent: "${intent}"
|
|
184
|
-
|
|
252
|
+
${chunkReferenceBlock}
|
|
185
253
|
BASE TEMPLATE (use for overall structure — modify, don't generate from scratch):
|
|
186
|
-
${JSON.stringify(
|
|
254
|
+
${JSON.stringify(expandedPatternTemplate, null, 2)}
|
|
187
255
|
|
|
188
256
|
Pattern: "${bestPattern.name}" — ${bestPattern.description}
|
|
189
257
|
|
|
@@ -201,9 +269,9 @@ Instructions:
|
|
|
201
269
|
- Include realistic text content, labels, and placeholder data — not generic text
|
|
202
270
|
- Output ONLY the JSON array, no explanation`
|
|
203
271
|
: `Adapt this UI for the intent: "${intent}"
|
|
204
|
-
|
|
272
|
+
${chunkReferenceBlock}
|
|
205
273
|
BASE TEMPLATE (modify this, don't generate from scratch):
|
|
206
|
-
${JSON.stringify(
|
|
274
|
+
${JSON.stringify(expandedPatternTemplate, null, 2)}
|
|
207
275
|
|
|
208
276
|
Pattern: "${bestPattern.name}" — ${bestPattern.description}
|
|
209
277
|
|
|
@@ -238,7 +306,7 @@ Instructions:
|
|
|
238
306
|
: '';
|
|
239
307
|
|
|
240
308
|
const composePrompt = `Compose a UI for: "${intent}"
|
|
241
|
-
${canvasContext}
|
|
309
|
+
${chunkReferenceBlock}${canvasContext}
|
|
242
310
|
This intent has ${fragmentPatterns.length} sections. ${layoutHint}
|
|
243
311
|
|
|
244
312
|
FRAGMENT PATTERNS — use these as building blocks for each section:
|
|
@@ -269,7 +337,7 @@ Instructions:
|
|
|
269
337
|
if (hasPriorCanvas) {
|
|
270
338
|
// Multi-turn: inject current canvas as explicit iteration context
|
|
271
339
|
const iteratePrompt = `Modify this UI for the intent: "${intent}"${driftGuard}
|
|
272
|
-
|
|
340
|
+
${chunkReferenceBlock}
|
|
273
341
|
${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
274
342
|
|
|
275
343
|
const response = await llmAdapter.complete({
|
|
@@ -279,7 +347,21 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
279
347
|
messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
|
|
280
348
|
if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
|
|
281
349
|
} else {
|
|
350
|
+
// First-turn fresh generation. When we have a chunk match, prepend
|
|
351
|
+
// the structural reference to the user message so the LLM sees the
|
|
352
|
+
// canonical shape before the standard chat-built prompt.
|
|
282
353
|
const chatMessages = buildChatMessages(intent, storeId || execId);
|
|
354
|
+
if (chunkReferenceBlock && chatMessages.length) {
|
|
355
|
+
// The last message in chatMessages is the user's brief — prepend
|
|
356
|
+
// the chunk reference to its content.
|
|
357
|
+
const lastIdx = chatMessages.length - 1;
|
|
358
|
+
if (chatMessages[lastIdx].role === 'user') {
|
|
359
|
+
chatMessages[lastIdx] = {
|
|
360
|
+
...chatMessages[lastIdx],
|
|
361
|
+
content: `${chunkReferenceBlock}\n${chatMessages[lastIdx].content}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
}
|
|
283
365
|
const response = await llmAdapter.complete({ messages: chatMessages, systemPrompt });
|
|
284
366
|
messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
|
|
285
367
|
if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
|
|
@@ -341,6 +423,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
341
423
|
feedbackStore.logExecution({
|
|
342
424
|
executionId: artifactId, intent, mode: 'pro',
|
|
343
425
|
domain: domain.domain, patternMatch: bestPattern?.name,
|
|
426
|
+
chunkMatch: chunkMatch ? chunkMatch.name : null,
|
|
344
427
|
composedFrom: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
|
|
345
428
|
score: validation?.score, componentCount: messages[0]?.components?.length || 0,
|
|
346
429
|
}).catch(() => {});
|
package/strategies/registry.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
// Zettel is lazy-loaded — it transitively imports node:fs / node:path / node:url
|
|
18
|
-
// (strategies/zettel/
|
|
18
|
+
// (strategies/zettel/composition-library.js), which Vite externalizes in the browser.
|
|
19
19
|
// Static-importing it here would break browser loads of core/generator.js.
|
|
20
20
|
// In-browser callers reach zettel via POST /api/generate (server-side), never
|
|
21
21
|
// through this registry, so lazy-loading is safe and incurs only a one-time
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** Smoke test: load library, resolve login-form, print
|
|
3
|
-
import { loadAll,
|
|
2
|
+
/** Smoke test: load library, resolve login-form composition, print template. */
|
|
3
|
+
import { loadAll, getAllCompositions, searchAll, getComposition } from './composition-library.js';
|
|
4
4
|
import { resolveComposition } from './composer.js';
|
|
5
5
|
|
|
6
6
|
const boot = loadAll();
|
|
7
7
|
console.log('boot:', boot);
|
|
8
8
|
|
|
9
|
-
console.log('\n=== Fragments ===');
|
|
10
|
-
for (const f of getAllFragments()) console.log(` - ${f.name} [${f.semantic_role}]`);
|
|
11
|
-
|
|
12
9
|
console.log('\n=== Compositions ===');
|
|
13
10
|
for (const c of getAllCompositions()) console.log(` - ${c.name} (${c.domain})`);
|
|
14
11
|
|
|
@@ -28,10 +25,3 @@ for (const n of resolved) {
|
|
|
28
25
|
const label = n.label ? ` label="${n.label}"` : '';
|
|
29
26
|
console.log(` ${n.id.padEnd(20)} ${n.component}${label}${text} ${kids}`);
|
|
30
27
|
}
|
|
31
|
-
|
|
32
|
-
console.log('\n=== Graph ===');
|
|
33
|
-
const g = getGraph();
|
|
34
|
-
console.log('fragments with usage:');
|
|
35
|
-
for (const f of g.fragments) {
|
|
36
|
-
if (f.used_by.length) console.log(` ${f.name} ← ${f.used_by.map(u => u.name).join(', ')}`);
|
|
37
|
-
}
|