@adia-ai/a2ui-compose 0.4.2 → 0.4.4
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 +27 -0
- package/README.md +12 -1
- package/core/generator.js +8 -1
- 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/generate-pro.js +85 -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/state-cache.js +6 -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,33 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.4.4] - 2026-05-12
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`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.
|
|
20
|
+
- **`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.
|
|
21
|
+
- **`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.
|
|
22
|
+
- **`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.
|
|
23
|
+
- **`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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`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.
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## [0.4.3] - 2026-05-11
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **`process.env` browser-compat guard** — `core/generator.js` and `strategies/zettel/state-cache.js` were accessing `process.env` directly, which throws `ReferenceError: process is not defined` in browser execution paths (e.g. the gen-ui playground importing compose directly). Both sites now guard via `const IS_NODE = typeof process !== 'undefined' && process.versions?.node;` before reading `process.env`. The `A2UI_COMPOSE_TRACE` debug-trace and `STATE_CACHE_DIR` env-lookup paths are Node-only by design; the dynamic `node:fs/promises` + `node:path` imports also short-circuit when `IS_NODE` is false.
|
|
37
|
+
|
|
38
|
+
### Lockstep
|
|
39
|
+
|
|
40
|
+
9-package coordinated PATCH cut to v0.4.3 (per [`docs/specs/package-architecture.md` § 15](../../../docs/specs/package-architecture.md#15-versioning-policy)). Internal `@adia-ai/*` dep ranges stay at `^0.4.0` (patch-cut asymmetry — `^0.4.0` covers `0.4.x` under semver). Source change scoped to `core/generator.js` + `strategies/zettel/state-cache.js`. Rides alongside `@adia-ai/web-components` v0.4.3 (input-ui locale + thousands grouping + hold-to-repeat). See root [CHANGELOG.md `## [0.4.3]`](../../../CHANGELOG.md) for the cut narrative.
|
|
41
|
+
|
|
15
42
|
## [0.4.2] - 2026-05-11
|
|
16
43
|
|
|
17
44
|
### Ride-along (no source changes)
|
package/README.md
CHANGED
|
@@ -14,7 +14,18 @@ ready for a renderer.
|
|
|
14
14
|
>
|
|
15
15
|
> Published to the public `@adia-ai` scope on 2026-04-24 alongside
|
|
16
16
|
> `a2ui-corpus`, `a2ui-mcp`, `a2ui-retrieval`, and `a2ui-validator`.
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @adia-ai/a2ui-compose
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Typically paired with `@adia-ai/a2ui-corpus` (the pattern corpus the engine reads from) and `@adia-ai/llm` (the LLM client; runtime peer-dep):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @adia-ai/a2ui-compose @adia-ai/a2ui-corpus @adia-ai/llm
|
|
28
|
+
```
|
|
18
29
|
|
|
19
30
|
## What it does
|
|
20
31
|
|
package/core/generator.js
CHANGED
|
@@ -191,7 +191,14 @@ export async function generateUI({ intent, engine: engineName = 'monolithic', mo
|
|
|
191
191
|
// a per-request JSON file BEFORE the strip. Useful for debugging
|
|
192
192
|
// strategy-label decisions or reproducing eval failures. Off by default;
|
|
193
193
|
// file writes happen synchronously and add ~1-5ms per request.
|
|
194
|
-
|
|
194
|
+
//
|
|
195
|
+
// Node-only: `process` is undefined in the browser. Guard the env lookup
|
|
196
|
+
// so the post-await codepath doesn't throw `ReferenceError: process is
|
|
197
|
+
// not defined` when generator.js runs in the gen-ui playground. The
|
|
198
|
+
// dynamic node:fs/path imports below would also fail in the browser, but
|
|
199
|
+
// the guard short-circuits before we get there.
|
|
200
|
+
const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
|
|
201
|
+
const traceDir = IS_NODE ? process.env.A2UI_COMPOSE_TRACE : null;
|
|
195
202
|
if (traceDir && result._debug) {
|
|
196
203
|
try {
|
|
197
204
|
const { writeFile, mkdir } = await import('node:fs/promises');
|
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.4",
|
|
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
|
+
});
|
|
@@ -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,44 @@ 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
|
+
const chunkReferenceBlock = chunkRefHtml
|
|
225
|
+
? `\nSTRUCTURAL REFERENCE — a real production block from the codebase matched this intent. Match its 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:
|
|
226
|
+
|
|
227
|
+
CHUNK: ${chunkMatch.name} (${chunkMatch.kind}, primary=${chunkMatch.primary}, score=${chunkMatch.score})
|
|
228
|
+
---
|
|
229
|
+
${chunkRefHtml}
|
|
230
|
+
---
|
|
231
|
+
`
|
|
232
|
+
: '';
|
|
233
|
+
|
|
174
234
|
if (bestPattern && bestPattern.template) {
|
|
175
235
|
// PRO PATH: Adapt the matched pattern via LLM
|
|
176
236
|
systemPrompt = await buildSystemPrompt(context, patterns, '', intent);
|
|
177
237
|
|
|
178
238
|
const adaptPrompt = hasPriorCanvas
|
|
179
239
|
? `Adapt this UI for the intent: "${intent}"${driftGuard}
|
|
180
|
-
|
|
240
|
+
${chunkReferenceBlock}
|
|
181
241
|
${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`
|
|
182
242
|
: hasFragments
|
|
183
243
|
? `Adapt this UI for the intent: "${intent}"
|
|
184
|
-
|
|
244
|
+
${chunkReferenceBlock}
|
|
185
245
|
BASE TEMPLATE (use for overall structure — modify, don't generate from scratch):
|
|
186
|
-
${JSON.stringify(
|
|
246
|
+
${JSON.stringify(expandedPatternTemplate, null, 2)}
|
|
187
247
|
|
|
188
248
|
Pattern: "${bestPattern.name}" — ${bestPattern.description}
|
|
189
249
|
|
|
@@ -201,9 +261,9 @@ Instructions:
|
|
|
201
261
|
- Include realistic text content, labels, and placeholder data — not generic text
|
|
202
262
|
- Output ONLY the JSON array, no explanation`
|
|
203
263
|
: `Adapt this UI for the intent: "${intent}"
|
|
204
|
-
|
|
264
|
+
${chunkReferenceBlock}
|
|
205
265
|
BASE TEMPLATE (modify this, don't generate from scratch):
|
|
206
|
-
${JSON.stringify(
|
|
266
|
+
${JSON.stringify(expandedPatternTemplate, null, 2)}
|
|
207
267
|
|
|
208
268
|
Pattern: "${bestPattern.name}" — ${bestPattern.description}
|
|
209
269
|
|
|
@@ -238,7 +298,7 @@ Instructions:
|
|
|
238
298
|
: '';
|
|
239
299
|
|
|
240
300
|
const composePrompt = `Compose a UI for: "${intent}"
|
|
241
|
-
${canvasContext}
|
|
301
|
+
${chunkReferenceBlock}${canvasContext}
|
|
242
302
|
This intent has ${fragmentPatterns.length} sections. ${layoutHint}
|
|
243
303
|
|
|
244
304
|
FRAGMENT PATTERNS — use these as building blocks for each section:
|
|
@@ -269,7 +329,7 @@ Instructions:
|
|
|
269
329
|
if (hasPriorCanvas) {
|
|
270
330
|
// Multi-turn: inject current canvas as explicit iteration context
|
|
271
331
|
const iteratePrompt = `Modify this UI for the intent: "${intent}"${driftGuard}
|
|
272
|
-
|
|
332
|
+
${chunkReferenceBlock}
|
|
273
333
|
${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
274
334
|
|
|
275
335
|
const response = await llmAdapter.complete({
|
|
@@ -279,7 +339,21 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
279
339
|
messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
|
|
280
340
|
if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
|
|
281
341
|
} else {
|
|
342
|
+
// First-turn fresh generation. When we have a chunk match, prepend
|
|
343
|
+
// the structural reference to the user message so the LLM sees the
|
|
344
|
+
// canonical shape before the standard chat-built prompt.
|
|
282
345
|
const chatMessages = buildChatMessages(intent, storeId || execId);
|
|
346
|
+
if (chunkReferenceBlock && chatMessages.length) {
|
|
347
|
+
// The last message in chatMessages is the user's brief — prepend
|
|
348
|
+
// the chunk reference to its content.
|
|
349
|
+
const lastIdx = chatMessages.length - 1;
|
|
350
|
+
if (chatMessages[lastIdx].role === 'user') {
|
|
351
|
+
chatMessages[lastIdx] = {
|
|
352
|
+
...chatMessages[lastIdx],
|
|
353
|
+
content: `${chunkReferenceBlock}\n${chatMessages[lastIdx].content}`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
283
357
|
const response = await llmAdapter.complete({ messages: chatMessages, systemPrompt });
|
|
284
358
|
messages = parseA2UIResponse(response.content, { executionId: execId, intent, mode: 'pro', stopReason: response.stopReason });
|
|
285
359
|
if (isRecording()) { lastRawResponse = response.content; lastTokens = response.usage || null; }
|
|
@@ -341,6 +415,7 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
341
415
|
feedbackStore.logExecution({
|
|
342
416
|
executionId: artifactId, intent, mode: 'pro',
|
|
343
417
|
domain: domain.domain, patternMatch: bestPattern?.name,
|
|
418
|
+
chunkMatch: chunkMatch ? chunkMatch.name : null,
|
|
344
419
|
composedFrom: hasFragments ? fragmentPatterns.map(f => f.pattern.name) : undefined,
|
|
345
420
|
score: validation?.score, componentCount: messages[0]?.components?.length || 0,
|
|
346
421
|
}).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
|
-
}
|