@adia-ai/a2ui-compose 0.7.27 → 0.7.28
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 +80 -0
- package/package.json +1 -1
- package/strategies/free-form-composer/free-form-composer.test.js +23 -1
- package/strategies/free-form-composer/index.js +24 -3
- package/strategies/free-form-composer/transpile.js +6 -1
- package/strategies/monolithic/generate-pro.js +21 -13
- package/strategies/monolithic/generate-thinking.js +26 -30
- package/strategies/registry.js +52 -6
- package/strategies/registry.test.js +82 -0
- package/strategies/zettel/chunk-synthesizer.js +30 -1
- package/strategies/zettel/generator-adapter.js +19 -6
- package/transpiler/transpiler-maps.js +33 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,84 @@
|
|
|
1
1
|
# Changelog — @adia-ai/a2ui-compose
|
|
2
|
+
## [0.7.28] — 2026-07-14
|
|
3
|
+
|
|
4
|
+
A2UI pipeline overhaul (comparing gen-ui-kit's system against a sibling
|
|
5
|
+
project's from-scratch A2UI implementation surfaced a concrete gap: only
|
|
6
|
+
one of six generation strategies ran a genuine closed-loop validate→retry
|
|
7
|
+
before returning; the rest either skipped it, did a single repair shot,
|
|
8
|
+
or validated a narrower plan/grounding contract).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`packages/a2ui/compose/shared/validate-and-repair.js`** — a shared
|
|
13
|
+
closed-loop validate→repair helper, extracted from `generate-thinking`'s
|
|
14
|
+
original inline loop (schema + Ajv catalog + anti-pattern conformance,
|
|
15
|
+
bounded LLM-fed repair attempts). Every LLM-calling engine can now share
|
|
16
|
+
one implementation instead of hand-rolling its own; escalation policy
|
|
17
|
+
(retry vs. give up vs. escalate to a bigger tier) stays entirely with
|
|
18
|
+
the caller — the module only reports `{valid, exhausted, attempts,
|
|
19
|
+
failures}`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`generate-pro` — replaces its single hardcoded repair shot with the
|
|
24
|
+
shared closed loop** (`maxAttempts: 2`, reflecting pro's cheaper cost
|
|
25
|
+
tier vs. thinking's 3) and adds Ajv catalog-schema coverage it never had
|
|
26
|
+
before.
|
|
27
|
+
- **`free-form-composer` — now runs a real schema/catalog/anti-pattern
|
|
28
|
+
check (validate-only) on its transpiled output**, which it previously
|
|
29
|
+
never did at all (a hardcoded `score: 85` regardless of actual
|
|
30
|
+
validity). This surfaced a real, previously-invisible defect: the
|
|
31
|
+
synthesized root component used `id: 'free-form-root'` instead of the
|
|
32
|
+
`'root'` convention every other engine's root uses, which
|
|
33
|
+
`validator.js`'s `checkHasRootComponent` hard-fails on — fixed at the
|
|
34
|
+
source (`transpile.js`). `score` stays 85 on the success path
|
|
35
|
+
(unchanged), so the tuned `eval:diff` floors don't shift as a side
|
|
36
|
+
effect of adding a measurement; new `schemaValid`/`schemaValidation`
|
|
37
|
+
fields are additive.
|
|
38
|
+
- **`monolithic-instant` no longer silently returns a hard-fail's broken
|
|
39
|
+
output.** The dispatcher (`strategies/registry.js`'s
|
|
40
|
+
`generateInstantAdapter`) now escalates a hard-fail to `generate-pro`
|
|
41
|
+
exactly once, only when an `llmAdapter` is available — capped at one
|
|
42
|
+
hop by construction (a pro-mode failure returns straight to the
|
|
43
|
+
original caller, never chains to thinking). A counter +
|
|
44
|
+
`console.warn` ship from day one, since this silently converts a
|
|
45
|
+
0-cost request into a billed LLM call.
|
|
46
|
+
- **`chunk-zettel` now emits a real component graph and qualifies for
|
|
47
|
+
the shared closed loop (TKT-0009).** `composeFromIntent`
|
|
48
|
+
(`chunk-synthesizer.js`) previously returned only a raw HTML string,
|
|
49
|
+
which both callers wrapped into a single node with an unregistered
|
|
50
|
+
`component: 'article'` type — full schema validation could only ever
|
|
51
|
+
report `invalid`. Ratified direction: reuse `transpileHTML()` (the
|
|
52
|
+
same converter `harvest-chunks.mjs` already runs at corpus-admission
|
|
53
|
+
time) to produce a real `template` component array on every path (a
|
|
54
|
+
chunk's own precomputed template when available, else transpiled on
|
|
55
|
+
demand). Both callers (`registry.js`'s `generateChunkZettelAdapter`,
|
|
56
|
+
`generator-adapter.js`'s `bridgeToChunkSynthesis`) now prefer
|
|
57
|
+
`result.template` and route the final messages through
|
|
58
|
+
`validate-and-repair.js` (validate-only). Verified live: Tier-1
|
|
59
|
+
retrieval and Tier-2 synthesis intents both produce `valid:true`
|
|
60
|
+
component graphs (score 90/92); zero regression confirmed via a
|
|
61
|
+
stash-based before/after comparison of `eval:compose-from-chunks`
|
|
62
|
+
(the eval calls `composeFromIntent` directly and never touches the
|
|
63
|
+
caller code this change modified).
|
|
64
|
+
- **`transpiler/transpiler-maps.js` — dynamic array props (`Chart.data`,
|
|
65
|
+
`Table.data`) are now actually parsed, not copied through as raw
|
|
66
|
+
strings (TKT-0010).** Compiled dynamic props are a bare `$ref`
|
|
67
|
+
(`DynamicString`/`DynamicObjectList`/etc.) with no inline `type`, so
|
|
68
|
+
`CATALOG_PROPS`'s `pdef.type || 'string'` silently defaulted every
|
|
69
|
+
dynamic array prop to string extraction — a declarative
|
|
70
|
+
`data='[{...}]'` attribute's JSON text was copied through unparsed,
|
|
71
|
+
never `JSON.parse`d, so it could never satisfy the catalog's
|
|
72
|
+
array-shaped schema. Fixed by resolving `$ref` names to their
|
|
73
|
+
primitive kind (new `REF_TYPE_MAP`) and adding a `JSON.parse`-ing
|
|
74
|
+
array extraction branch (skipping silently on parse failure or a
|
|
75
|
+
non-array result, matching `chart.yaml`'s own documented "non-array
|
|
76
|
+
renders empty" contract). Root-caused via the corpus admission
|
|
77
|
+
validator surfacing the same `Chart`/`Table`.`data` `oneOf` failure
|
|
78
|
+
across 8+ unrelated chunks — a schema-generation/transpiler bug, not
|
|
79
|
+
independent content authoring mistakes. See `@adia-ai/a2ui-corpus`'s
|
|
80
|
+
CHANGELOG for the companion schema-side fix (`DynamicObjectList`).
|
|
81
|
+
|
|
2
82
|
## [0.7.27] — 2026-07-12
|
|
3
83
|
|
|
4
84
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.28",
|
|
4
4
|
"description": "AdiaUI A2UI compose engine \u2014 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": {
|
|
@@ -293,7 +293,7 @@ describe('transpilePlan — root container (§103 auto-grouping)', () => {
|
|
|
293
293
|
}, lookup);
|
|
294
294
|
const root = result.messages[0].components[0];
|
|
295
295
|
expect(root.component).toBe('Column');
|
|
296
|
-
expect(root.id).toBe('
|
|
296
|
+
expect(root.id).toBe('root');
|
|
297
297
|
expect(root.wrap).toBeUndefined();
|
|
298
298
|
expect(root.columns).toBeUndefined();
|
|
299
299
|
});
|
|
@@ -490,3 +490,25 @@ describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () =>
|
|
|
490
490
|
expect(node.text).toBe('42');
|
|
491
491
|
});
|
|
492
492
|
});
|
|
493
|
+
|
|
494
|
+
// A2UI overhaul (Phase 3): full schema/anti-pattern/catalog check now runs
|
|
495
|
+
// on the transpiled result — previously this engine reported a hardcoded
|
|
496
|
+
// score=85 with NO real validation behind it at all.
|
|
497
|
+
describe('free-form-composer — schema validation (Phase 3 addition)', () => {
|
|
498
|
+
it('a validly-transpiled composition reports schemaValid: true, score stays 85 (unchanged eval-floor contract)', async () => {
|
|
499
|
+
const fakeLLM = {
|
|
500
|
+
complete: async () => ({
|
|
501
|
+
content: JSON.stringify({ ingredients: [{ name: 'demo-login' }], rationale: 'ok' }),
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
const result = await generateFreeForm({
|
|
505
|
+
intent: 'sign me in',
|
|
506
|
+
llmAdapter: fakeLLM,
|
|
507
|
+
compositions: FIXTURE_VOCAB,
|
|
508
|
+
});
|
|
509
|
+
expect(result.strategy).toBe('free-form-composed');
|
|
510
|
+
expect(result.validation.score).toBe(85);
|
|
511
|
+
expect(result.schemaValid).toBe(true);
|
|
512
|
+
expect(result.schemaValidation).toBeTruthy();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
buildFreeFormParaphraseRetry,
|
|
34
34
|
} from './system-prompt.js';
|
|
35
35
|
import { transpilePlan, parsePlan } from './transpile.js';
|
|
36
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
36
37
|
|
|
37
38
|
const MAX_ATTEMPTS = 2;
|
|
38
39
|
|
|
@@ -209,9 +210,27 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
213
|
+
// Full schema/catalog/anti-pattern conformance — the same final pass
|
|
214
|
+
// every other LLM-calling engine runs (see validate-and-repair.js) —
|
|
215
|
+
// ADDED here, not previously run at all; this engine used to report a
|
|
216
|
+
// hardcoded score=85 with no real validation behind it whatsoever.
|
|
217
|
+
// Validate-ONLY (no llmAdapter passed): a repair attempt here would mean
|
|
218
|
+
// re-authoring the ingredient PLAN, not the transpiled A2UI JSON
|
|
219
|
+
// directly — this engine's whole paradigm is ingredient selection +
|
|
220
|
+
// deterministic transpilation, not direct JSON authoring, so a schema-
|
|
221
|
+
// failure repair prompt has no natural target to hand back to the LLM.
|
|
222
|
+
// The existing hallucination-guard retry loop above (ingredient-name
|
|
223
|
+
// grounding) is this engine's own repair mechanism.
|
|
224
|
+
//
|
|
225
|
+
// score stays 85 on the success path — unchanged from before — so the
|
|
226
|
+
// eval:diff floors (cov/avg/F1) tuned against that number don't shift as
|
|
227
|
+
// a side effect of adding a measurement; schemaValid/schemaScore/
|
|
228
|
+
// antiPatternFailures are new, additive fields a future increment (or
|
|
229
|
+
// the eval baseline itself, deliberately) can act on.
|
|
230
|
+
const schemaCheck = await validateAndRepair({ messages });
|
|
231
|
+
if (!schemaCheck.valid) {
|
|
232
|
+
warnings = warnings.concat([`schema/anti-pattern check failed (score ${schemaCheck.validation.score}): ${schemaCheck.validation.checks?.filter(c => !c.passed).map(c => c.name).join(', ') || 'see schemaValidation'}`]);
|
|
233
|
+
}
|
|
215
234
|
const score = 85;
|
|
216
235
|
|
|
217
236
|
return {
|
|
@@ -224,5 +243,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
224
243
|
warnings,
|
|
225
244
|
rationale: plan.rationale || null,
|
|
226
245
|
emptyPlanRetryUsed,
|
|
246
|
+
schemaValid: schemaCheck.valid,
|
|
247
|
+
schemaValidation: schemaCheck.validation,
|
|
227
248
|
};
|
|
228
249
|
}
|
|
@@ -220,7 +220,12 @@ function buildRoot(plan, rootChildren, warnings) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
const base = {
|
|
223
|
-
|
|
223
|
+
// 'root' — not 'free-form-root' — matches the convention every other
|
|
224
|
+
// engine's root component uses (validator.js's checkHasRootComponent
|
|
225
|
+
// hard-fails without it: "at least one component with id: 'root'").
|
|
226
|
+
// This engine never ran real schema validation before Phase 3 of the
|
|
227
|
+
// A2UI overhaul plan, so the mismatch went unnoticed until then.
|
|
228
|
+
id: 'root',
|
|
224
229
|
gap: '6',
|
|
225
230
|
children: rootChildren,
|
|
226
231
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* in strategies/monolithic/_shared.js.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
10
10
|
import { getContext, searchBlocks, lookupDomain } from '../../core/reference.js';
|
|
11
11
|
import { decomposeIntent } from '../../../retrieval/intent/decomposer.js';
|
|
12
12
|
import { assessClarity } from '../../../retrieval/intent/clarity.js';
|
|
@@ -403,18 +403,26 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
403
403
|
});
|
|
404
404
|
|
|
405
405
|
// ── Stage 5: Validate + repair ──
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
406
|
+
// Shared with every other LLM-calling engine — see validate-and-repair.js.
|
|
407
|
+
// maxAttempts: 2 (vs. thinking's 3) reflects pro's cheaper cost tier.
|
|
408
|
+
// runCatalogCheck adds Ajv catalog-schema coverage pro never had before —
|
|
409
|
+
// buildRepairPrompt's catalogFailures param already defaults to [] so
|
|
410
|
+
// this is additive, not a signature break.
|
|
411
|
+
const repairResult = await validateAndRepair({
|
|
412
|
+
messages,
|
|
413
|
+
llmAdapter,
|
|
414
|
+
buildRepairPrompt,
|
|
415
|
+
parseResponse: (repairResponse) => parseA2UIResponse(
|
|
416
|
+
repairResponse.content,
|
|
417
|
+
{ executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason },
|
|
418
|
+
),
|
|
419
|
+
systemPrompt,
|
|
420
|
+
validateContext: { intent, context },
|
|
421
|
+
foldInAntiPatterns,
|
|
422
|
+
maxAttempts: 2,
|
|
423
|
+
});
|
|
424
|
+
messages = repairResult.messages;
|
|
425
|
+
const validation = repairResult.validation;
|
|
418
426
|
engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
|
|
419
427
|
|
|
420
428
|
// ── Stage 6: Store ──
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* in strategies/monolithic/_shared.js.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import { validateMessages as validateCatalog } from '../../../validator/catalog-validator.js';
|
|
9
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
11
10
|
import { getContext, searchBlocksSemantic, lookupDomain } from '../../core/reference.js';
|
|
12
11
|
import { assessClarity } from '../../../retrieval/intent/clarity.js';
|
|
13
12
|
import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
|
|
@@ -122,35 +121,32 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
|
|
|
122
121
|
});
|
|
123
122
|
|
|
124
123
|
// ── Stage 5: Validate + repair loop ──
|
|
125
|
-
// Two orthogonal validators run
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
146
|
-
messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
|
|
124
|
+
// Two orthogonal validators run: scored (weighted heuristic checks —
|
|
125
|
+
// intent alignment, card model, etc.) and catalog (AJV against v0.9
|
|
126
|
+
// catalog schema — strict structural correctness). Either failing
|
|
127
|
+
// triggers a repair attempt. Catalog failures are typically small
|
|
128
|
+
// (unknown prop, wrong enum, missing required id) and fix cleanly in one
|
|
129
|
+
// repair round. Three-attempt cap preserved from the original loop.
|
|
130
|
+
// Shared with every other LLM-calling engine — see validate-and-repair.js.
|
|
131
|
+
const repairResult = await validateAndRepair({
|
|
132
|
+
messages,
|
|
133
|
+
llmAdapter,
|
|
134
|
+
buildRepairPrompt,
|
|
135
|
+
parseResponse: (repairResponse) => parseA2UIResponse(
|
|
136
|
+
repairResponse.content,
|
|
137
|
+
{ executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason },
|
|
138
|
+
),
|
|
139
|
+
systemPrompt,
|
|
140
|
+
validateContext: { intent, context },
|
|
141
|
+
foldInAntiPatterns,
|
|
142
|
+
maxAttempts: 3,
|
|
143
|
+
onAttempt: (repairResponse) => {
|
|
147
144
|
if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
messages = repairResult.messages;
|
|
148
|
+
const validation = repairResult.validation;
|
|
149
|
+
const attempts = repairResult.attempts;
|
|
154
150
|
|
|
155
151
|
engine.submitStage(execId, 'validate', {
|
|
156
152
|
...validation,
|
package/strategies/registry.js
CHANGED
|
@@ -52,11 +52,42 @@ export function registerMonolithicEngines({ instant, pro, thinking }) {
|
|
|
52
52
|
_monolithicThinking = thinking;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// A2UI overhaul plan, Phase 4: instant mode never repairs (it makes no LLM
|
|
56
|
+
// calls at all — that's its whole cost/latency identity) and previously
|
|
57
|
+
// returned a hard-fail's broken output silently. Escalation to pro lives
|
|
58
|
+
// HERE, at the dispatcher, not inside generate-instant.js itself — it's the
|
|
59
|
+
// caller's policy decision, matching validate-and-repair.js's "escalation
|
|
60
|
+
// stays with the caller" contract, and this is the one place that already
|
|
61
|
+
// holds both `ctx.llmAdapter` and `_monolithicPro` in scope.
|
|
62
|
+
//
|
|
63
|
+
// Capped at exactly one hop by construction: generateProAdapter has no
|
|
64
|
+
// escalation logic of its own, so a pro-mode failure after an instant
|
|
65
|
+
// escalation returns straight to the original caller — it can never chain
|
|
66
|
+
// into thinking. Only escalates when an llmAdapter is actually available;
|
|
67
|
+
// many instant-mode callers pass none (that's the 0-cost tier's point), and
|
|
68
|
+
// validate-and-repair.js's own contract for that case is "report the
|
|
69
|
+
// failure, don't invent a repair path" — same discipline here.
|
|
70
|
+
//
|
|
71
|
+
// This silently converts a 0-cost request into a billed LLM call, which is
|
|
72
|
+
// a product/cost-visibility concern, not just an engineering one — hence
|
|
73
|
+
// the counter + the warn log, from day one, not added later once someone
|
|
74
|
+
// asks "why did our LLM spend spike".
|
|
75
|
+
let _instantEscalationCount = 0;
|
|
76
|
+
export function getInstantEscalationCount() {
|
|
77
|
+
return _instantEscalationCount;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
// Engine adapters — uniform call signature `(ctx) => Promise<result>` where
|
|
56
81
|
// ctx is prepared by the public generateUI() orchestrator.
|
|
57
82
|
async function generateInstantAdapter(ctx) {
|
|
58
83
|
if (!_monolithicInstant) throw new Error('monolithic-instant engine not registered');
|
|
59
|
-
|
|
84
|
+
const result = await _monolithicInstant(ctx);
|
|
85
|
+
if (!result.validation?.valid && ctx.llmAdapter && _monolithicPro) {
|
|
86
|
+
_instantEscalationCount++;
|
|
87
|
+
console.warn(`[a2ui] monolithic-instant hard-failed validation (score=${result.validation?.score}) for intent "${ctx.intent}" — escalating to monolithic-pro (escalation #${_instantEscalationCount})`);
|
|
88
|
+
return _monolithicPro(ctx);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
60
91
|
}
|
|
61
92
|
async function generateProAdapter(ctx) {
|
|
62
93
|
if (!_monolithicPro) throw new Error('monolithic-pro engine not registered');
|
|
@@ -230,27 +261,42 @@ async function generateFreeFormAdapter(ctx) {
|
|
|
230
261
|
|
|
231
262
|
async function generateChunkZettelAdapter(ctx) {
|
|
232
263
|
const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
|
|
264
|
+
const { validateAndRepair } = await import('../shared/validate-and-repair.js');
|
|
233
265
|
const result = await composeFromIntent({
|
|
234
266
|
intent: ctx.intent,
|
|
235
267
|
llmAdapter: ctx.llmAdapter || null,
|
|
236
268
|
maxAttempts: 2,
|
|
237
269
|
});
|
|
238
270
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
271
|
+
// TKT-0009: composeFromIntent now returns a real component array
|
|
272
|
+
// (`result.template`) whenever it can, instead of only an HTML string —
|
|
273
|
+
// use it directly; the pre-fix fallback (a single node carrying an
|
|
274
|
+
// UNREGISTERED `component: 'article'` type) only fires if transpilation
|
|
275
|
+
// itself somehow produced nothing, which should not happen in practice.
|
|
276
|
+
const messages = result.template
|
|
277
|
+
? [{ type: 'updateComponents', components: result.template }]
|
|
278
|
+
: result.html
|
|
279
|
+
? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
|
|
280
|
+
: [];
|
|
281
|
+
|
|
282
|
+
// Full schema/catalog/anti-pattern conformance — the same final pass
|
|
283
|
+
// every other engine runs (see validate-and-repair.js). Validate-only:
|
|
284
|
+
// no natural repair target here (the LLM authored a slot-binding PLAN,
|
|
285
|
+
// not this A2UI JSON directly) — chunk-composer's own `validatePlan`
|
|
286
|
+
// is this engine's repair-relevant check and already ran upstream.
|
|
287
|
+
const schemaCheck = messages.length ? await validateAndRepair({ messages }) : { valid: false, validation: { score: 0 } };
|
|
243
288
|
|
|
244
289
|
return {
|
|
245
290
|
executionId: ctx.executionId,
|
|
246
291
|
messages,
|
|
247
|
-
validation:
|
|
292
|
+
validation: schemaCheck.validation,
|
|
248
293
|
strategy: result.source === 'retrieval' ? 'chunk-retrieval' : 'chunk-synthesis',
|
|
249
294
|
engine: 'chunk-zettel',
|
|
250
295
|
_debug: {
|
|
251
296
|
plan: result.plan || null,
|
|
252
297
|
warnings: result.warnings || [],
|
|
253
298
|
scopeDrift: result.scopeDrift || null,
|
|
299
|
+
schemaValid: schemaCheck.valid,
|
|
254
300
|
},
|
|
255
301
|
};
|
|
256
302
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.js — instant→pro escalation (A2UI overhaul plan, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Instant mode makes zero LLM calls and previously returned a hard-fail's
|
|
5
|
+
* broken output silently. The dispatcher now escalates to pro exactly once
|
|
6
|
+
* when an llmAdapter is available; escalation policy lives here, not inside
|
|
7
|
+
* generate-instant.js itself (see registry.js's comment above
|
|
8
|
+
* generateInstantAdapter).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
+
import { registerMonolithicEngines, ENGINES, getInstantEscalationCount } from './registry.js';
|
|
12
|
+
|
|
13
|
+
const HARD_FAIL_RESULT = { messages: [], validation: { valid: false, score: 0 }, strategy: 'fallback' };
|
|
14
|
+
const VALID_RESULT = { messages: [{ type: 'updateComponents', components: [{ id: 'root', component: 'Card' }] }], validation: { valid: true, score: 90 }, strategy: 'pattern-match' };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Reset to known fakes before every case — registerMonolithicEngines is
|
|
18
|
+
// the same public injection API core/generator.js uses at boot.
|
|
19
|
+
registerMonolithicEngines({ instant: null, pro: null, thinking: null });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('generateInstantAdapter — escalation to pro on hard-fail', () => {
|
|
23
|
+
it('does NOT escalate when instant mode already returns a valid result', async () => {
|
|
24
|
+
let proCalls = 0;
|
|
25
|
+
registerMonolithicEngines({
|
|
26
|
+
instant: async () => VALID_RESULT,
|
|
27
|
+
pro: async () => { proCalls++; return VALID_RESULT; },
|
|
28
|
+
thinking: async () => VALID_RESULT,
|
|
29
|
+
});
|
|
30
|
+
const before = getInstantEscalationCount();
|
|
31
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
|
|
32
|
+
expect(result).toBe(VALID_RESULT);
|
|
33
|
+
expect(proCalls).toBe(0);
|
|
34
|
+
expect(getInstantEscalationCount()).toBe(before);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('escalates to pro exactly once when instant hard-fails AND an llmAdapter is available', async () => {
|
|
38
|
+
let proCalls = 0;
|
|
39
|
+
registerMonolithicEngines({
|
|
40
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
41
|
+
pro: async (ctx) => { proCalls++; return { ...VALID_RESULT, receivedCtx: ctx }; },
|
|
42
|
+
thinking: async () => VALID_RESULT,
|
|
43
|
+
});
|
|
44
|
+
const before = getInstantEscalationCount();
|
|
45
|
+
const fakeAdapter = { complete: async () => ({}) };
|
|
46
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', executionId: 'exec-1', storeId: 'store-1', llmAdapter: fakeAdapter });
|
|
47
|
+
expect(proCalls).toBe(1);
|
|
48
|
+
expect(result.validation.valid).toBe(true);
|
|
49
|
+
expect(getInstantEscalationCount()).toBe(before + 1);
|
|
50
|
+
// Same ctx (storeId/executionId/llmAdapter) threaded through unmodified —
|
|
51
|
+
// no session/store desync risk from a reconstructed context.
|
|
52
|
+
expect(result.receivedCtx.executionId).toBe('exec-1');
|
|
53
|
+
expect(result.receivedCtx.storeId).toBe('store-1');
|
|
54
|
+
expect(result.receivedCtx.llmAdapter).toBe(fakeAdapter);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does NOT escalate when instant hard-fails but no llmAdapter is available — reports the failure as-is', async () => {
|
|
58
|
+
let proCalls = 0;
|
|
59
|
+
registerMonolithicEngines({
|
|
60
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
61
|
+
pro: async () => { proCalls++; return VALID_RESULT; },
|
|
62
|
+
thinking: async () => VALID_RESULT,
|
|
63
|
+
});
|
|
64
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: null });
|
|
65
|
+
expect(proCalls).toBe(0);
|
|
66
|
+
expect(result).toBe(HARD_FAIL_RESULT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('caps escalation at exactly one hop — a pro-mode failure never chains to thinking', async () => {
|
|
70
|
+
let proCalls = 0;
|
|
71
|
+
let thinkingCalls = 0;
|
|
72
|
+
registerMonolithicEngines({
|
|
73
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
74
|
+
pro: async () => { proCalls++; return HARD_FAIL_RESULT; }, // pro ALSO fails
|
|
75
|
+
thinking: async () => { thinkingCalls++; return VALID_RESULT; },
|
|
76
|
+
});
|
|
77
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
|
|
78
|
+
expect(proCalls).toBe(1);
|
|
79
|
+
expect(thinkingCalls).toBe(0); // never chains further — pro's own failure returns to the original caller
|
|
80
|
+
expect(result.validation.valid).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -27,6 +27,29 @@ import {
|
|
|
27
27
|
getAllChunks,
|
|
28
28
|
} from '../../../corpus/scripts/chunk-library.js';
|
|
29
29
|
import { composeFromPlan, validatePlan } from './chunk-composer.js';
|
|
30
|
+
import { transpileHTML } from '../../transpiler/transpiler.js';
|
|
31
|
+
|
|
32
|
+
// TKT-0009: composeFromIntent used to return only a raw HTML string,
|
|
33
|
+
// which every caller wrapped in a single node carrying an UNREGISTERED
|
|
34
|
+
// A2UI type (`component: 'article'`) — meaning full schema/catalog
|
|
35
|
+
// validation against it always reported invalid, regardless of the
|
|
36
|
+
// composed content's actual quality (registry.has('article') is false).
|
|
37
|
+
// Ratified direction: reuse the SAME transpileHTML() infrastructure the
|
|
38
|
+
// docs-transpiler and harvest-chunks.mjs already run at scale, rather
|
|
39
|
+
// than inventing a new passthrough construct — this makes every
|
|
40
|
+
// composeFromIntent() result a REAL component graph, so it qualifies for
|
|
41
|
+
// the shared validate-and-repair loop like every other engine. A chunk's
|
|
42
|
+
// own precomputed `.template` (from harvest-chunks.mjs) is used directly
|
|
43
|
+
// when available (accurate, no re-transpile cost); multi-instance chunk
|
|
44
|
+
// records don't carry one per-instance, so this falls back to
|
|
45
|
+
// transpiling the HTML on demand either way — every return path leaves
|
|
46
|
+
// with a real template, never just a string.
|
|
47
|
+
async function ensureTemplate(html, precomputedTemplate) {
|
|
48
|
+
if (Array.isArray(precomputedTemplate) && precomputedTemplate.length) return precomputedTemplate;
|
|
49
|
+
if (!html) return null;
|
|
50
|
+
const { messages } = await transpileHTML(html);
|
|
51
|
+
return messages?.[0]?.components ?? null;
|
|
52
|
+
}
|
|
30
53
|
|
|
31
54
|
const STRONG_RETRIEVAL_SCORE = 8; // search-score threshold for fast path
|
|
32
55
|
const PRE_SEARCH_LIMIT = 30; // chunks shown to the LLM in the prompt
|
|
@@ -199,7 +222,11 @@ function extractJSON(raw) {
|
|
|
199
222
|
* @param {string} opts.intent
|
|
200
223
|
* @param {object} opts.llmAdapter — { complete: async ({ messages, systemPrompt }) => { content|text } }
|
|
201
224
|
* @param {number} [opts.maxAttempts=2]
|
|
202
|
-
* @returns {Promise<{ html: string, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
|
|
225
|
+
* @returns {Promise<{ html: string, template: object[]|null, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
|
|
226
|
+
* `template` (TKT-0009) is a real A2UI component array — derived from the
|
|
227
|
+
* chunk's own precomputed template when available, else transpiled from
|
|
228
|
+
* `html` on demand — so callers get a validatable component graph, not
|
|
229
|
+
* just an HTML string to wrap opaquely.
|
|
203
230
|
*/
|
|
204
231
|
export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFAULT_MAX_ATTEMPTS }) {
|
|
205
232
|
// Tier 1 — retrieval. Try semantic-blended hit first; fall back to keyword
|
|
@@ -228,6 +255,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
|
|
|
228
255
|
const scopeDrift = computeScopeDrift(html, [top]);
|
|
229
256
|
return {
|
|
230
257
|
html,
|
|
258
|
+
template: await ensureTemplate(html, top.template),
|
|
231
259
|
plan: null,
|
|
232
260
|
source: 'retrieval',
|
|
233
261
|
score: hit.score,
|
|
@@ -339,6 +367,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
|
|
|
339
367
|
|
|
340
368
|
return {
|
|
341
369
|
html: composed.html,
|
|
370
|
+
template: await ensureTemplate(composed.html, null),
|
|
342
371
|
plan,
|
|
343
372
|
source: 'synthesis',
|
|
344
373
|
warnings: [...composed.warnings, ...driftWarnings],
|
|
@@ -47,11 +47,18 @@ import { validateSchema } from '../../../validator/validator.js';
|
|
|
47
47
|
async function bridgeToChunkSynthesis({ intent, llmAdapter }) {
|
|
48
48
|
const { composeFromIntent } = await import('./chunk-synthesizer.js');
|
|
49
49
|
const result = await composeFromIntent({ intent, llmAdapter, maxAttempts: 2 });
|
|
50
|
-
// Shape-adapt chunk-synthesizer's `{ html, plan, source, ... }`
|
|
51
|
-
// synthesis-branch's prior `{ messages, template, synthesis }`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
// Shape-adapt chunk-synthesizer's `{ html, template, plan, source, ... }`
|
|
51
|
+
// to the synthesis-branch's prior `{ messages, template, synthesis }`
|
|
52
|
+
// contract. TKT-0009: prefer the real component array (`result.template`)
|
|
53
|
+
// over the pre-fix fallback of wrapping raw HTML in a single node with an
|
|
54
|
+
// UNREGISTERED `component: 'article'` type — that fallback made this
|
|
55
|
+
// caller's validate-and-repair pass below structurally unable to ever
|
|
56
|
+
// report a clean pass.
|
|
57
|
+
const messages = result.template
|
|
58
|
+
? [{ type: 'updateComponents', components: result.template }]
|
|
59
|
+
: result.html
|
|
60
|
+
? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
|
|
61
|
+
: [];
|
|
55
62
|
return {
|
|
56
63
|
messages,
|
|
57
64
|
template: result.plan ? [{ $chunk: 'composeFromIntent', plan: result.plan }] : [],
|
|
@@ -172,7 +179,13 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
|
|
|
172
179
|
if (llmAdapter && mode !== 'instant-only') {
|
|
173
180
|
try {
|
|
174
181
|
const synth = await bridgeToChunkSynthesis({ intent, llmAdapter });
|
|
175
|
-
|
|
182
|
+
// Full schema/catalog/anti-pattern conformance (validate-only — see
|
|
183
|
+
// validate-and-repair.js; TKT-0009 made this meaningful for the first
|
|
184
|
+
// time by giving synth.messages a real component graph instead of an
|
|
185
|
+
// opaque HTML wrapper).
|
|
186
|
+
const { validateAndRepair } = await import('../../shared/validate-and-repair.js');
|
|
187
|
+
const schemaCheck = synth.messages.length ? await validateAndRepair({ messages: synth.messages }) : { valid: false, validation: { score: 0 } };
|
|
188
|
+
const validation = schemaCheck.validation;
|
|
176
189
|
// Chunk-bridge produces an html-bearing single component, not
|
|
177
190
|
// resolved fragment instances. `fragments_used` is empty by design;
|
|
178
191
|
// consumers tracking fragment usage should switch to the
|
|
@@ -39,6 +39,21 @@ import { registry } from '@adia-ai/a2ui-runtime';
|
|
|
39
39
|
// alias map below so `extractProps(el, 'CheckBox')` resolves to the
|
|
40
40
|
// `Check` catalog entry.
|
|
41
41
|
|
|
42
|
+
// TKT-0010: dynamic props are compiled to a bare `$ref` (DynamicString /
|
|
43
|
+
// DynamicNumber / DynamicBoolean / DynamicStringList / DynamicObjectList),
|
|
44
|
+
// which carries no inline `type` — so `pdef.type || 'string'` below silently
|
|
45
|
+
// treated EVERY dynamic array prop (Chart.data, Table.data) as a string,
|
|
46
|
+
// copying the raw `data='[...]'` attribute text through unparsed instead of
|
|
47
|
+
// JSON-parsing it into a real array. Resolve the ref name to its primitive
|
|
48
|
+
// kind first so extractCatalogProps' array branch actually fires.
|
|
49
|
+
const REF_TYPE_MAP = {
|
|
50
|
+
DynamicString: 'string',
|
|
51
|
+
DynamicNumber: 'number',
|
|
52
|
+
DynamicBoolean: 'boolean',
|
|
53
|
+
DynamicStringList: 'array',
|
|
54
|
+
DynamicObjectList: 'array',
|
|
55
|
+
};
|
|
56
|
+
|
|
42
57
|
/** @type {Map<string, {props: Map<string, {type: string, enum?: string[]}>, canonical: string}>} */
|
|
43
58
|
const CATALOG_PROPS = (() => {
|
|
44
59
|
const map = new Map();
|
|
@@ -50,8 +65,13 @@ const CATALOG_PROPS = (() => {
|
|
|
50
65
|
// Skip internal/computed props that aren't HTML attributes
|
|
51
66
|
if (pname === 'component' || pname === 'textContent') continue;
|
|
52
67
|
if (typeof pdef !== 'object' || !pdef) continue;
|
|
68
|
+
let type = pdef.type;
|
|
69
|
+
if (!type && typeof pdef.$ref === 'string') {
|
|
70
|
+
const refName = pdef.$ref.split('/').pop();
|
|
71
|
+
type = REF_TYPE_MAP[refName];
|
|
72
|
+
}
|
|
53
73
|
propMap.set(pname, {
|
|
54
|
-
type:
|
|
74
|
+
type: type || 'string',
|
|
55
75
|
enum: pdef.enum || null,
|
|
56
76
|
});
|
|
57
77
|
}
|
|
@@ -153,6 +173,18 @@ function extractCatalogProps(el, a2uiType) {
|
|
|
153
173
|
if (raw === '') continue;
|
|
154
174
|
const n = Number(raw);
|
|
155
175
|
props[pname] = Number.isFinite(n) ? n : raw;
|
|
176
|
+
} else if (pdef.type === 'array') {
|
|
177
|
+
// TKT-0010: declarative JSON-array attribute (e.g. chart-ui/table-ui's
|
|
178
|
+
// data='[{...}]'), hydrated once at connect per those components' own
|
|
179
|
+
// yaml docs. Parse it — the raw string never satisfies the catalog's
|
|
180
|
+
// array schema, and per chart.yaml's own contract a non-array value
|
|
181
|
+
// is meant to render empty, so a parse failure/non-array is skipped,
|
|
182
|
+
// not passed through.
|
|
183
|
+
if (raw === '') continue;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(raw);
|
|
186
|
+
if (Array.isArray(parsed)) props[pname] = parsed;
|
|
187
|
+
} catch { /* not valid JSON — skip */ }
|
|
156
188
|
} else {
|
|
157
189
|
// string (incl. enum) — skip empty
|
|
158
190
|
if (raw === '') continue;
|