@adia-ai/a2ui-compose 0.0.1 → 0.1.0
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 +86 -1
- package/README.md +5 -6
- package/engines/zettel/chunk-composer.js +182 -0
- package/engines/zettel/chunk-refiner.js +514 -0
- package/engines/zettel/chunk-synthesizer.js +235 -0
- package/engines/zettel/issue-reporter.js +380 -0
- package/engines/zettel/state-cache.js +153 -0
- package/package.json +1 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chunk-refiner — multi-turn refinement engine for chunk-composed UIs.
|
|
3
|
+
*
|
|
4
|
+
* Sibling to chunk-synthesizer.js. Where the synthesizer produces fresh
|
|
5
|
+
* compositions from intent, the refiner takes a prior composition + intent
|
|
6
|
+
* and emits ops that mutate it.
|
|
7
|
+
*
|
|
8
|
+
* Phase A scope (per docs/specs/genui-multiturn-architecture.md §4):
|
|
9
|
+
* - Two-pass synthesis (locator → modifier).
|
|
10
|
+
* - Validator-driven retry loop, default maxAttempts=2.
|
|
11
|
+
* - Op format: chunk-plan-level ops internally; A2UI-message-shaped on output.
|
|
12
|
+
* - Auto-fires on engine failure paths through the IssueAccumulator passed
|
|
13
|
+
* in via opts (issueAccumulator).
|
|
14
|
+
*
|
|
15
|
+
* Phase A simplification: refinements operate on the chunk binding plan
|
|
16
|
+
* (rebindSlot / appendToSlot / removeFromSlot / replacePage). The
|
|
17
|
+
* `opsToA2UI()` translator wraps each plan-mutation in a standard
|
|
18
|
+
* `updateComponents` A2UI message so the wire-format contract holds.
|
|
19
|
+
* Phase B may introduce richer component-tree refinements; this Phase A
|
|
20
|
+
* approach honors the wire format while keeping engine code focused on the
|
|
21
|
+
* chunk corpus.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
getChunk,
|
|
26
|
+
listChunksByKind,
|
|
27
|
+
searchChunksAsync,
|
|
28
|
+
} from '../../../corpus/scripts/chunk-library.js';
|
|
29
|
+
import { composeFromPlan } from './chunk-composer.js';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
32
|
+
const PRE_SEARCH_LIMIT = 30;
|
|
33
|
+
const SURFACE_ID = 'main';
|
|
34
|
+
|
|
35
|
+
const VALID_OP_TYPES = new Set(['rebindSlot', 'appendToSlot', 'removeFromSlot', 'replacePage']);
|
|
36
|
+
|
|
37
|
+
const LOCATOR_SYSTEM_PROMPT = `You identify which slots in an existing UI need modification.
|
|
38
|
+
|
|
39
|
+
You are given:
|
|
40
|
+
- An "intent" describing what the user wants changed.
|
|
41
|
+
- A "component map" listing the slots in the prior UI, each with its name
|
|
42
|
+
and the chunks currently bound to it.
|
|
43
|
+
|
|
44
|
+
Decide if the intent is:
|
|
45
|
+
- "targeted" — the user named a specific slot/element to change, OR the
|
|
46
|
+
intent's verb implies a localized change (e.g. "change the title",
|
|
47
|
+
"remove the funnel", "add a country list to page-content").
|
|
48
|
+
- "untargeted" — broad changes touching many slots (e.g. "make everything
|
|
49
|
+
more compact", "use teal not blue").
|
|
50
|
+
|
|
51
|
+
If targeted, return target_slots: list of slot names to modify.
|
|
52
|
+
If untargeted, return targeted: false and target_slots: [].
|
|
53
|
+
|
|
54
|
+
Return ONLY a JSON object: { "targeted": bool, "target_slots": string[] }.
|
|
55
|
+
No prose. Begin with {.`;
|
|
56
|
+
|
|
57
|
+
const MODIFIER_SYSTEM_PROMPT = `You emit refinement ops on an existing chunk-composed UI.
|
|
58
|
+
|
|
59
|
+
Op types:
|
|
60
|
+
- rebindSlot: replace a slot's binding entirely.
|
|
61
|
+
{ "type": "rebindSlot", "slot": "<name>", "chunks": ["<chunk-name>"...] }
|
|
62
|
+
- appendToSlot: append chunk(s) to the END of a slot's bound list.
|
|
63
|
+
{ "type": "appendToSlot", "slot": "<name>", "chunks": ["<chunk-name>"...] }
|
|
64
|
+
- removeFromSlot: remove chunks from a slot's bound list by index.
|
|
65
|
+
{ "type": "removeFromSlot", "slot": "<name>", "indices": [0, 2] }
|
|
66
|
+
- replacePage: swap the page chunk entirely.
|
|
67
|
+
{ "type": "replacePage", "page": "<page-chunk-name>", "slot_bindings": { ... } }
|
|
68
|
+
|
|
69
|
+
Rules:
|
|
70
|
+
- Mutate only the slots you've been told to target (when target_slots provided).
|
|
71
|
+
- Every chunk name must exist in the catalog.
|
|
72
|
+
- Multi-clause intents → emit multiple ops.
|
|
73
|
+
|
|
74
|
+
Return ONLY a JSON object: { "ops": [...], "delta_summary": "<one-line>" }.
|
|
75
|
+
No prose. Begin with {.`;
|
|
76
|
+
|
|
77
|
+
function buildComponentMap(priorState) {
|
|
78
|
+
const plan = priorState?.plan;
|
|
79
|
+
if (!plan?.slot_bindings) return [];
|
|
80
|
+
return Object.entries(plan.slot_bindings).map(([slot, bound]) => ({
|
|
81
|
+
slot,
|
|
82
|
+
chunks: Array.isArray(bound) ? bound : [bound],
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildLocatorPrompt(intent, componentMap) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
lines.push(`Intent: ${intent}`);
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('## Component map');
|
|
91
|
+
for (const { slot, chunks } of componentMap) {
|
|
92
|
+
lines.push(`- ${slot}: [${chunks.join(', ')}]`);
|
|
93
|
+
}
|
|
94
|
+
lines.push('');
|
|
95
|
+
lines.push('Return JSON: { "targeted": bool, "target_slots": string[] }.');
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildModifierPrompt(intent, priorPlan, targetedSlots, catalog) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
lines.push(`Intent: ${intent}`);
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push('## Prior plan');
|
|
104
|
+
lines.push(`Page: ${priorPlan?.page || '(unknown)'}`);
|
|
105
|
+
lines.push('Slot bindings:');
|
|
106
|
+
for (const [slot, bound] of Object.entries(priorPlan?.slot_bindings || {})) {
|
|
107
|
+
const arr = Array.isArray(bound) ? bound : [bound];
|
|
108
|
+
const focus = targetedSlots.includes(slot) ? ' ← TARGETED' : '';
|
|
109
|
+
lines.push(` ${slot}: [${arr.join(', ')}]${focus}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push('## Available chunks (filtered)');
|
|
113
|
+
for (const c of catalog) {
|
|
114
|
+
const slots = c.slots?.length ? ` slots=[${c.slots.join(', ')}]` : '';
|
|
115
|
+
lines.push(`- ${c.name} (kind=${c.kind}, primary=${c.primary})${slots}`);
|
|
116
|
+
}
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push('Return JSON: { "ops": [...], "delta_summary": "..." }');
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function extractJSON(raw) {
|
|
123
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
124
|
+
const trimmed = raw.trim();
|
|
125
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
126
|
+
const candidate = fenced ? fenced[1].trim() : trimmed;
|
|
127
|
+
try { return JSON.parse(candidate); } catch {}
|
|
128
|
+
const m = candidate.match(/\{[\s\S]*\}/);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
try { return JSON.parse(m[0]); } catch { return null; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Validate ops against the prior state.
|
|
135
|
+
*
|
|
136
|
+
* Checks:
|
|
137
|
+
* - Op shape (type field is one of four known types; required fields present).
|
|
138
|
+
* - Slot references exist in the prior plan.
|
|
139
|
+
* - Chunk references exist in the corpus.
|
|
140
|
+
* - Page references (replacePage) are kind=page or kind=panel.
|
|
141
|
+
*
|
|
142
|
+
* @param {object[]} ops
|
|
143
|
+
* @param {object} priorState — must have .plan.{page, slot_bindings}
|
|
144
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
145
|
+
*/
|
|
146
|
+
export function validateOps(ops, priorState) {
|
|
147
|
+
const errors = [];
|
|
148
|
+
if (!Array.isArray(ops)) {
|
|
149
|
+
return { ok: false, errors: ['ops must be an array'] };
|
|
150
|
+
}
|
|
151
|
+
if (!priorState?.plan) {
|
|
152
|
+
return { ok: false, errors: ['priorState must have a plan with page+slot_bindings'] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const declaredSlots = new Set(Object.keys(priorState.plan.slot_bindings || {}));
|
|
156
|
+
|
|
157
|
+
ops.forEach((op, i) => {
|
|
158
|
+
if (!op || typeof op !== 'object') {
|
|
159
|
+
errors.push(`ops[${i}]: not an object`);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const T = op.type;
|
|
163
|
+
if (!VALID_OP_TYPES.has(T)) {
|
|
164
|
+
errors.push(`ops[${i}]: unknown op type "${T}"`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (T === 'replacePage') {
|
|
169
|
+
if (!op.page || typeof op.page !== 'string') {
|
|
170
|
+
errors.push(`ops[${i}]: replacePage requires a "page" string`);
|
|
171
|
+
} else {
|
|
172
|
+
const rec = getChunk(op.page);
|
|
173
|
+
if (!rec) {
|
|
174
|
+
errors.push(`ops[${i}]: page chunk "${op.page}" not found`);
|
|
175
|
+
} else if (rec.kind !== 'page' && rec.kind !== 'panel') {
|
|
176
|
+
errors.push(`ops[${i}]: replacePage chunk "${op.page}" must be kind page|panel; got ${rec.kind}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const bindings = op.slot_bindings || {};
|
|
180
|
+
for (const [slot, bound] of Object.entries(bindings)) {
|
|
181
|
+
const names = Array.isArray(bound) ? bound : [bound];
|
|
182
|
+
for (const name of names) {
|
|
183
|
+
if (!getChunk(name)) {
|
|
184
|
+
errors.push(`ops[${i}]: bound chunk "${name}" (slot "${slot}") not found`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!op.slot || typeof op.slot !== 'string') {
|
|
192
|
+
errors.push(`ops[${i}]: ${T} requires a "slot" string`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!declaredSlots.has(op.slot)) {
|
|
196
|
+
errors.push(`ops[${i}]: slot "${op.slot}" not in prior plan (declared: ${[...declaredSlots].join(',')})`);
|
|
197
|
+
}
|
|
198
|
+
if (T === 'rebindSlot' || T === 'appendToSlot') {
|
|
199
|
+
if (!Array.isArray(op.chunks) || op.chunks.length === 0) {
|
|
200
|
+
errors.push(`ops[${i}]: ${T} requires a non-empty "chunks" array`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const c of op.chunks) {
|
|
204
|
+
if (!getChunk(c)) errors.push(`ops[${i}]: chunk "${c}" not found`);
|
|
205
|
+
}
|
|
206
|
+
} else if (T === 'removeFromSlot') {
|
|
207
|
+
if (!Array.isArray(op.indices) || op.indices.length === 0) {
|
|
208
|
+
errors.push(`ops[${i}]: removeFromSlot requires a non-empty "indices" array`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const slotBound = priorState.plan.slot_bindings[op.slot];
|
|
212
|
+
const slotArr = Array.isArray(slotBound) ? slotBound : (slotBound ? [slotBound] : []);
|
|
213
|
+
for (const idx of op.indices) {
|
|
214
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= slotArr.length) {
|
|
215
|
+
errors.push(`ops[${i}]: index ${idx} out of range for slot "${op.slot}" (length ${slotArr.length})`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return { ok: errors.length === 0, errors };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Apply ops to the prior state.
|
|
226
|
+
*
|
|
227
|
+
* Op-level failures are non-fatal — the apply phase continues with subsequent
|
|
228
|
+
* ops. Returns { newState, ops_applied, ops_failed }. The `newState` includes
|
|
229
|
+
* the mutated plan + freshly materialized HTML.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} input
|
|
232
|
+
* @param {object} input.priorState — { plan, html, ... }
|
|
233
|
+
* @param {object[]} input.ops
|
|
234
|
+
* @returns {Promise<{
|
|
235
|
+
* newState: { plan, html, warnings },
|
|
236
|
+
* ops_applied: object[],
|
|
237
|
+
* ops_failed: { op, reason }[],
|
|
238
|
+
* }>}
|
|
239
|
+
*/
|
|
240
|
+
export async function applyOps({ priorState, ops }) {
|
|
241
|
+
if (!priorState?.plan) {
|
|
242
|
+
return {
|
|
243
|
+
newState: priorState,
|
|
244
|
+
ops_applied: [],
|
|
245
|
+
ops_failed: (ops || []).map((op) => ({ op, reason: 'priorState has no plan' })),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Deep-clone the plan so we mutate a copy
|
|
250
|
+
const plan = {
|
|
251
|
+
page: priorState.plan.page,
|
|
252
|
+
slot_bindings: {},
|
|
253
|
+
};
|
|
254
|
+
for (const [slot, bound] of Object.entries(priorState.plan.slot_bindings || {})) {
|
|
255
|
+
plan.slot_bindings[slot] = Array.isArray(bound) ? [...bound] : (bound ? [bound] : []);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ops_applied = [];
|
|
259
|
+
const ops_failed = [];
|
|
260
|
+
|
|
261
|
+
for (const op of (ops || [])) {
|
|
262
|
+
try {
|
|
263
|
+
if (!op || typeof op !== 'object') {
|
|
264
|
+
ops_failed.push({ op, reason: 'op is not an object' });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (op.type === 'rebindSlot') {
|
|
268
|
+
if (!Array.isArray(op.chunks)) {
|
|
269
|
+
ops_failed.push({ op, reason: 'rebindSlot.chunks must be an array' });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
plan.slot_bindings[op.slot] = [...op.chunks];
|
|
273
|
+
ops_applied.push(op);
|
|
274
|
+
} else if (op.type === 'appendToSlot') {
|
|
275
|
+
if (!Array.isArray(op.chunks)) {
|
|
276
|
+
ops_failed.push({ op, reason: 'appendToSlot.chunks must be an array' });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const cur = plan.slot_bindings[op.slot];
|
|
280
|
+
const arr = Array.isArray(cur) ? [...cur] : (cur ? [cur] : []);
|
|
281
|
+
arr.push(...op.chunks);
|
|
282
|
+
plan.slot_bindings[op.slot] = arr;
|
|
283
|
+
ops_applied.push(op);
|
|
284
|
+
} else if (op.type === 'removeFromSlot') {
|
|
285
|
+
if (!Array.isArray(op.indices)) {
|
|
286
|
+
ops_failed.push({ op, reason: 'removeFromSlot.indices must be an array' });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const cur = plan.slot_bindings[op.slot];
|
|
290
|
+
const arr = Array.isArray(cur) ? [...cur] : (cur ? [cur] : []);
|
|
291
|
+
const dropSet = new Set(op.indices);
|
|
292
|
+
plan.slot_bindings[op.slot] = arr.filter((_, i) => !dropSet.has(i));
|
|
293
|
+
ops_applied.push(op);
|
|
294
|
+
} else if (op.type === 'replacePage') {
|
|
295
|
+
plan.page = op.page;
|
|
296
|
+
plan.slot_bindings = {};
|
|
297
|
+
for (const [slot, bound] of Object.entries(op.slot_bindings || {})) {
|
|
298
|
+
plan.slot_bindings[slot] = Array.isArray(bound) ? [...bound] : (bound ? [bound] : []);
|
|
299
|
+
}
|
|
300
|
+
ops_applied.push(op);
|
|
301
|
+
} else {
|
|
302
|
+
ops_failed.push({ op, reason: `unknown op type "${op.type}"` });
|
|
303
|
+
}
|
|
304
|
+
} catch (e) {
|
|
305
|
+
ops_failed.push({ op, reason: e.message });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Re-materialize the HTML
|
|
310
|
+
const composed = composeFromPlan(plan);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
newState: {
|
|
314
|
+
plan,
|
|
315
|
+
html: composed.html,
|
|
316
|
+
warnings: composed.warnings || [],
|
|
317
|
+
},
|
|
318
|
+
ops_applied,
|
|
319
|
+
ops_failed,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Translate chunk-plan-level ops to A2UI message arrays for wire-format
|
|
325
|
+
* compatibility (spec §2.3 + §3.4).
|
|
326
|
+
*
|
|
327
|
+
* Phase A approach: each chunk-plan op produces one `updateComponents`
|
|
328
|
+
* message targeting the affected slot. Components carry a `chunk_op` echo
|
|
329
|
+
* + a materialized `html` string. Phase B may upgrade `html` to a proper
|
|
330
|
+
* component-tree shape.
|
|
331
|
+
*
|
|
332
|
+
* @param {object[]} ops — chunk-plan ops (post-validation)
|
|
333
|
+
* @param {object} newState — { html, plan, warnings } from applyOps
|
|
334
|
+
* @returns {object[]} A2UI messages
|
|
335
|
+
*/
|
|
336
|
+
export function opsToA2UI(ops, newState) {
|
|
337
|
+
const messages = [];
|
|
338
|
+
for (const op of (ops || [])) {
|
|
339
|
+
if (op.type === 'replacePage') {
|
|
340
|
+
messages.push({
|
|
341
|
+
type: 'updateComponents',
|
|
342
|
+
surfaceId: SURFACE_ID,
|
|
343
|
+
components: [
|
|
344
|
+
{
|
|
345
|
+
id: SURFACE_ID,
|
|
346
|
+
chunk_op: op,
|
|
347
|
+
html: newState?.html ?? null,
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
messages.push({
|
|
353
|
+
type: 'updateComponents',
|
|
354
|
+
surfaceId: SURFACE_ID,
|
|
355
|
+
components: [
|
|
356
|
+
{
|
|
357
|
+
id: `slot-${op.slot}`,
|
|
358
|
+
chunk_op: op,
|
|
359
|
+
html: newState?.html ?? null,
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return messages;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Refine a prior composition based on a natural-language intent.
|
|
370
|
+
*
|
|
371
|
+
* Two-pass synthesis:
|
|
372
|
+
* Pass 1 (locator) — LLM identifies which slots to modify.
|
|
373
|
+
* Pass 2 (modifier) — LLM emits chunk-plan ops on the targeted slots.
|
|
374
|
+
*
|
|
375
|
+
* Validator-driven retry: if modifier ops fail validation, the engine
|
|
376
|
+
* re-prompts with errors, up to maxAttempts.
|
|
377
|
+
*
|
|
378
|
+
* Auto-fires on these failure paths (when issueAccumulator is provided):
|
|
379
|
+
* - locator-empty-targets: targeted=true but target_slots is empty.
|
|
380
|
+
* - validator-exhausted: maxAttempts reached without valid ops.
|
|
381
|
+
*
|
|
382
|
+
* @param {object} opts
|
|
383
|
+
* @param {object} opts.priorState — { state_id, plan, html, ... }
|
|
384
|
+
* @param {string} opts.intent
|
|
385
|
+
* @param {object} opts.llmAdapter — { complete: async ({messages, systemPrompt}) }
|
|
386
|
+
* @param {number} [opts.maxAttempts=2]
|
|
387
|
+
* @param {object} [opts.issueAccumulator] — IssueAccumulator from issue-reporter.js
|
|
388
|
+
* @returns {Promise<{
|
|
389
|
+
* ops: object[],
|
|
390
|
+
* delta_summary?: string,
|
|
391
|
+
* warnings: string[],
|
|
392
|
+
* synthesis: { attempts, attemptsLog?, locatedTargets, targeted },
|
|
393
|
+
* }>}
|
|
394
|
+
*/
|
|
395
|
+
export async function refineFromIntent({
|
|
396
|
+
priorState,
|
|
397
|
+
intent,
|
|
398
|
+
llmAdapter,
|
|
399
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
400
|
+
issueAccumulator = null,
|
|
401
|
+
catalog = null,
|
|
402
|
+
}) {
|
|
403
|
+
if (!priorState?.plan) {
|
|
404
|
+
return {
|
|
405
|
+
ops: [],
|
|
406
|
+
warnings: ['priorState has no plan; cannot refine'],
|
|
407
|
+
synthesis: { attempts: 0, locatedTargets: null, targeted: false },
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (!llmAdapter?.complete) {
|
|
411
|
+
return {
|
|
412
|
+
ops: [],
|
|
413
|
+
warnings: ['no llmAdapter provided'],
|
|
414
|
+
synthesis: { attempts: 0, locatedTargets: null, targeted: false },
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Pass 1 — locate
|
|
419
|
+
const componentMap = buildComponentMap(priorState);
|
|
420
|
+
const locatorPrompt = buildLocatorPrompt(intent, componentMap);
|
|
421
|
+
const locatorResp = await llmAdapter.complete({
|
|
422
|
+
messages: [{ role: 'user', content: locatorPrompt }],
|
|
423
|
+
systemPrompt: LOCATOR_SYSTEM_PROMPT,
|
|
424
|
+
});
|
|
425
|
+
const locatorRaw = locatorResp?.content || locatorResp?.text || '';
|
|
426
|
+
const locatorParsed = extractJSON(locatorRaw);
|
|
427
|
+
|
|
428
|
+
let targeted = false;
|
|
429
|
+
let target_slots = [];
|
|
430
|
+
if (locatorParsed && typeof locatorParsed === 'object') {
|
|
431
|
+
targeted = !!locatorParsed.targeted;
|
|
432
|
+
target_slots = Array.isArray(locatorParsed.target_slots) ? locatorParsed.target_slots : [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (targeted && target_slots.length === 0) {
|
|
436
|
+
issueAccumulator?.add('locator-empty-targets', {
|
|
437
|
+
intent,
|
|
438
|
+
state_id: priorState.state_id,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Pass 2 — modify. Pre-search the catalog if not provided.
|
|
443
|
+
let resolvedCatalog = catalog;
|
|
444
|
+
if (!resolvedCatalog) {
|
|
445
|
+
const blockHits = await searchChunksAsync(intent, { kind: 'block', limit: PRE_SEARCH_LIMIT });
|
|
446
|
+
const blockChunks = blockHits.map((h) => getChunk(h.name)).filter(Boolean);
|
|
447
|
+
const pageChunks = listChunksByKind('page');
|
|
448
|
+
const panelChunks = listChunksByKind('panel');
|
|
449
|
+
const filtered = [...pageChunks, ...panelChunks, ...blockChunks];
|
|
450
|
+
resolvedCatalog = filtered.map((c) => ({
|
|
451
|
+
name: c.name,
|
|
452
|
+
kind: c.kind,
|
|
453
|
+
primary: c.primary || c.instances?.[0]?.primary,
|
|
454
|
+
slots: (c.slots || c.instances?.[0]?.slots || []).map((s) => s.name),
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const modifierUserPrompt = buildModifierPrompt(intent, priorState.plan, target_slots, resolvedCatalog);
|
|
459
|
+
const attemptsLog = [];
|
|
460
|
+
let lastError = null;
|
|
461
|
+
|
|
462
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
463
|
+
const retryNudge = lastError
|
|
464
|
+
? `\n\nPREVIOUS ATTEMPT FAILED VALIDATION: ${lastError}. Re-emit ops without invalid references.`
|
|
465
|
+
: '';
|
|
466
|
+
const modifierResp = await llmAdapter.complete({
|
|
467
|
+
messages: [{ role: 'user', content: modifierUserPrompt + retryNudge }],
|
|
468
|
+
systemPrompt: MODIFIER_SYSTEM_PROMPT,
|
|
469
|
+
});
|
|
470
|
+
const modifierRaw = modifierResp?.content || modifierResp?.text || '';
|
|
471
|
+
attemptsLog.push({ attempt: i + 1, raw: modifierRaw });
|
|
472
|
+
|
|
473
|
+
const parsed = extractJSON(modifierRaw);
|
|
474
|
+
if (!parsed || !Array.isArray(parsed.ops)) {
|
|
475
|
+
lastError = 'response was not valid JSON with { ops: [...] } shape';
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const validation = validateOps(parsed.ops, priorState);
|
|
480
|
+
if (!validation.ok) {
|
|
481
|
+
lastError = validation.errors.slice(0, 3).join('; ');
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
ops: parsed.ops,
|
|
487
|
+
delta_summary: parsed.delta_summary || '',
|
|
488
|
+
warnings: [],
|
|
489
|
+
synthesis: {
|
|
490
|
+
attempts: i + 1,
|
|
491
|
+
attemptsLog,
|
|
492
|
+
locatedTargets: target_slots,
|
|
493
|
+
targeted,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
issueAccumulator?.add('validator-exhausted', {
|
|
499
|
+
state_id: priorState.state_id,
|
|
500
|
+
tool: 'refine_composition',
|
|
501
|
+
intent,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
ops: [],
|
|
506
|
+
warnings: [`refinement failed after ${maxAttempts} attempts: ${lastError}`],
|
|
507
|
+
synthesis: {
|
|
508
|
+
attempts: maxAttempts,
|
|
509
|
+
attemptsLog,
|
|
510
|
+
locatedTargets: target_slots,
|
|
511
|
+
targeted,
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|