@adia-ai/a2ui-retrieval 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ComponentEntry — Detail level serialization for catalog entries.
3
+ *
4
+ * Three progressive-disclosure levels:
5
+ * Index (~10 tokens): { type, tag, category }
6
+ * Summary (~50 tokens): index + { description, properties: [names] }
7
+ * Reference (~200 tokens): summary + { properties: [full], slots, events, children, tokens }
8
+ */
9
+
10
+ /** @typedef {'index' | 'summary' | 'reference'} DetailLevel */
11
+
12
+ /**
13
+ * Serialize a component entry to the requested detail level.
14
+ *
15
+ * @param {object} entry — Full component entry from the catalog
16
+ * @param {DetailLevel} level — Detail level
17
+ * @returns {object} — Serialized entry at the requested level
18
+ */
19
+ export function serializeEntry(entry, level = 'index') {
20
+ if (level === 'index') return serializeIndex(entry);
21
+ if (level === 'summary') return serializeSummary(entry);
22
+ return serializeReference(entry);
23
+ }
24
+
25
+ function serializeIndex(entry) {
26
+ return {
27
+ type: entry.type,
28
+ tag: entry.tag,
29
+ category: entry.category,
30
+ };
31
+ }
32
+
33
+ function serializeSummary(entry) {
34
+ const index = serializeIndex(entry);
35
+ const result = {
36
+ ...index,
37
+ description: entry.description || '',
38
+ };
39
+ if (entry.schema?.properties) {
40
+ result.properties = Object.keys(entry.schema.properties);
41
+ }
42
+ return result;
43
+ }
44
+
45
+ function serializeReference(entry) {
46
+ const summary = serializeSummary(entry);
47
+ const schema = entry.schema;
48
+ if (!schema) return summary;
49
+
50
+ const result = { ...summary };
51
+
52
+ // Full property definitions
53
+ if (schema.properties) {
54
+ result.properties = Object.entries(schema.properties).map(([name, def]) => ({
55
+ name,
56
+ type: def.type,
57
+ ...(def.enum ? { enum: def.enum } : {}),
58
+ ...(def.default !== undefined ? { default: def.default } : {}),
59
+ ...(def.description ? { description: def.description } : {}),
60
+ }));
61
+ }
62
+
63
+ if (schema.slots && Object.keys(schema.slots).length > 0) {
64
+ result.slots = schema.slots;
65
+ }
66
+
67
+ if (schema.events && Object.keys(schema.events).length > 0) {
68
+ result.events = schema.events;
69
+ }
70
+
71
+ if (schema.children) {
72
+ result.children = schema.children;
73
+ }
74
+
75
+ if (schema.tokens) {
76
+ result.tokens = schema.tokens;
77
+ }
78
+
79
+ return result;
80
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Concept Mapper — re-rank corpus patterns using analyzer signals.
3
+ *
4
+ * The existing `searchBlocks(query)` does pure keyword matching against
5
+ * pattern.keywords + pattern.description. That's necessary but not sufficient
6
+ * — a prompt about a "user dashboard" might lexically prefer a pattern named
7
+ * "user-profile" over "admin-dashboard" even though the latter is a better
8
+ * conceptual match.
9
+ *
10
+ * This module takes the structured analysis produced by prompt-analyzer.js
11
+ * and combines THREE signals into a final pattern score:
12
+ *
13
+ * lexical: the original keyword score from searchBlocks (steelman → keywords)
14
+ * conceptual: overlap between analysis.concepts and pattern.tags.purpose
15
+ * structural: overlap between analysis.impliedComponents and the set of
16
+ * component types referenced in the pattern's template
17
+ *
18
+ * Patterns missing tags fall back to lexical-only scoring (graceful
19
+ * degradation; the older patterns in the corpus haven't all been retagged).
20
+ */
21
+
22
+ import { searchBlocks } from '../engine/reference.js';
23
+ import { scoreAll as embeddingScoreAll, available as embeddingAvailable } from './embedding-retriever.js';
24
+
25
+ /** Weights for the combined score. Tuned to keep lexical authoritative
26
+ * but let strong conceptual+structural+semantic signals override marginal
27
+ * lexical differences. Embedding semantic similarity scores are in [-1, 1]
28
+ * so their weight is high (30) — cosine of 0.8 → 24 points, comparable to
29
+ * 3 concept-tag hits. Tuned to fix the "product card → ticket form"
30
+ * keyword-collision class of failure. */
31
+ const WEIGHTS = {
32
+ lexical: 1.0, // baseline
33
+ conceptual: 8, // each matching concept-tag adds 8 points
34
+ structural: 1.5, // each matching component-signature element adds 1.5 points
35
+ semantic: 30, // cosine(query_embedding, pattern_embedding) × 30
36
+ };
37
+
38
+ /**
39
+ * Re-rank pattern matches using the analyzer's structured signals.
40
+ *
41
+ * @param {object} opts
42
+ * @param {object} opts.analysis — Output of analyzePrompt() from prompt-analyzer.js
43
+ * @param {string} [opts.domain] — Domain hint (passed through to searchBlocks)
44
+ * @param {number} [opts.limit=10] — Cap on returned results
45
+ * @returns {Array<{ pattern: object, score: number, breakdown: object }>}
46
+ * Patterns ranked by combined score, descending.
47
+ */
48
+ export async function rankByConceptAndSignature({ analysis, domain, limit = 10 }) {
49
+ if (!analysis) return [];
50
+
51
+ // Use the steelmanned brief for lexical search — it's denser than the raw
52
+ // intent and surfaces keywords the user implied but didn't say.
53
+ const query = analysis.steelman || analysis.raw;
54
+ const lexicalHits = searchBlocks(query, { domain });
55
+ if (!Array.isArray(lexicalHits) || lexicalHits.length === 0) return [];
56
+
57
+ const conceptSet = new Set((analysis.concepts || []).map(c => c.toLowerCase()));
58
+ const componentSet = new Set(analysis.impliedComponents || []);
59
+
60
+ // Semantic channel — only run when the index + provider are both available.
61
+ // Returns a Map<patternName, cosineSim>. Silent no-op otherwise.
62
+ const semanticMap = (await embeddingAvailable())
63
+ ? await embeddingScoreAll(query)
64
+ : new Map();
65
+
66
+ const ranked = lexicalHits.map(hit => {
67
+ // Different shapes of hit objects in the corpus — be permissive.
68
+ const pattern = hit.pattern || hit;
69
+ const lexicalScore = hit.score ?? hit.confidence ?? 1;
70
+
71
+ const conceptScore = scoreConceptOverlap(pattern, conceptSet);
72
+ const structuralScore = scoreSignatureOverlap(pattern, componentSet);
73
+ // Clamp negative cosines to 0 — no retrieval value in "most anti-similar".
74
+ const semanticScore = Math.max(0, semanticMap.get(pattern.name) || 0);
75
+
76
+ const combined =
77
+ WEIGHTS.lexical * lexicalScore +
78
+ WEIGHTS.conceptual * conceptScore +
79
+ WEIGHTS.structural * structuralScore +
80
+ WEIGHTS.semantic * semanticScore;
81
+
82
+ return {
83
+ pattern,
84
+ score: combined,
85
+ breakdown: {
86
+ lexical: +(WEIGHTS.lexical * lexicalScore).toFixed(2),
87
+ conceptual: +(WEIGHTS.conceptual * conceptScore).toFixed(2),
88
+ structural: +(WEIGHTS.structural * structuralScore).toFixed(2),
89
+ semantic: +(WEIGHTS.semantic * semanticScore).toFixed(2),
90
+ },
91
+ };
92
+ });
93
+
94
+ ranked.sort((a, b) => b.score - a.score);
95
+ return ranked.slice(0, limit);
96
+ }
97
+
98
+ /** Count how many of the analysis concepts appear in the pattern's tag system. */
99
+ function scoreConceptOverlap(pattern, conceptSet) {
100
+ if (!pattern || conceptSet.size === 0) return 0;
101
+ const tags = pattern.tags || {};
102
+ const flat = []
103
+ .concat(tags.purpose || [])
104
+ .concat(tags.layout || [])
105
+ .concat(tags.interaction || [])
106
+ .concat(pattern.keywords || [])
107
+ .map(t => String(t).toLowerCase());
108
+ let hits = 0;
109
+ for (const c of conceptSet) if (flat.includes(c)) hits++;
110
+ return hits;
111
+ }
112
+
113
+ /** Count how many of the implied components appear in the pattern's template. */
114
+ function scoreSignatureOverlap(pattern, componentSet) {
115
+ if (!pattern || componentSet.size === 0) return 0;
116
+ const template = pattern.template;
117
+ if (!Array.isArray(template)) return 0;
118
+ // Build the pattern's component signature once per call; small templates
119
+ // mean the cost is negligible. Cache later if hot.
120
+ const sig = new Set();
121
+ for (const node of template) {
122
+ if (node && typeof node.component === 'string') sig.add(node.component);
123
+ }
124
+ let hits = 0;
125
+ for (const c of componentSet) if (sig.has(c)) hits++;
126
+ return hits;
127
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Context Assembler — Progressive-disclosure context packaging with token budgets.
3
+ *
4
+ * Assembles context at 5 budget tiers, combining component entries,
5
+ * domain-relevant patterns, and anti-patterns within token limits.
6
+ *
7
+ * Token estimation: ~1 token per 4 characters of JSON output.
8
+ */
9
+
10
+ import { getCatalog, getComponentsByCategory } from './catalog.js';
11
+ import { serializeEntry } from './component-entry.js';
12
+ import { classifyIntent, getDomain } from './domain-router.js';
13
+ import { getAntiPatterns } from './anti-patterns.js';
14
+ import { searchPatterns, getAllPatterns } from './pattern-library.js';
15
+
16
+ /** Token budget per tier */
17
+ const TIER_BUDGETS = {
18
+ 0: 500,
19
+ 1: 2000,
20
+ 2: 5000,
21
+ 3: 10000,
22
+ 4: 20000,
23
+ };
24
+
25
+ /**
26
+ * Estimate token count from a JavaScript value (serialized as JSON).
27
+ * @param {*} value
28
+ * @returns {number}
29
+ */
30
+ function estimateTokens(value) {
31
+ const json = JSON.stringify(value);
32
+ return Math.ceil(json.length / 4);
33
+ }
34
+
35
+ /**
36
+ * Assemble context for a given intent at a specific budget tier.
37
+ *
38
+ * @param {object} options
39
+ * @param {string} [options.intent] — Natural language intent (used for domain classification)
40
+ * @param {number} [options.tier=1] — Budget tier (0-4)
41
+ * @param {string} [options.domain] — Override domain (skips classification)
42
+ * @returns {{ components: object[], patterns: object[], antiPatterns: object[], domain: object, estimatedTokens: number }}
43
+ */
44
+ export async function assembleContext({ intent = '', tier = 1, domain: overrideDomain } = {}) {
45
+ const budget = TIER_BUDGETS[tier] ?? TIER_BUDGETS[1];
46
+
47
+ // Classify domain
48
+ const classification = intent ? classifyIntent(intent) : null;
49
+ const domainName = overrideDomain || classification?.domain || 'layout';
50
+ const domainConfig = getDomain(domainName);
51
+
52
+ let usedTokens = 0;
53
+ const result = {
54
+ components: [],
55
+ patterns: [],
56
+ antiPatterns: [],
57
+ domain: {
58
+ name: domainName,
59
+ confidence: classification?.confidence ?? 1,
60
+ matchedSignals: classification?.matchedSignals ?? [],
61
+ },
62
+ estimatedTokens: 0,
63
+ };
64
+
65
+ // Account for domain metadata
66
+ usedTokens += estimateTokens(result.domain);
67
+
68
+ const catalog = await getCatalog();
69
+
70
+ // ── Tier 0: Component index only ──
71
+ if (tier === 0) {
72
+ for (const entry of catalog.entries.values()) {
73
+ const serialized = serializeEntry(entry, 'index');
74
+ const cost = estimateTokens(serialized);
75
+ if (usedTokens + cost > budget) break;
76
+ result.components.push(serialized);
77
+ usedTokens += cost;
78
+ }
79
+ result.estimatedTokens = usedTokens;
80
+ return result;
81
+ }
82
+
83
+ // ── Tier 1+: Domain-relevant summaries first ──
84
+ const domainComponents = domainConfig?.components || [];
85
+ const addedTypes = new Set();
86
+
87
+ // Add domain-relevant components as summaries
88
+ for (const typeName of domainComponents) {
89
+ const entry = findEntry(catalog, typeName);
90
+ if (!entry) continue;
91
+
92
+ const level = tier >= 2 ? 'reference' : 'summary';
93
+ const serialized = serializeEntry(entry, level);
94
+ const cost = estimateTokens(serialized);
95
+ if (usedTokens + cost > budget) break;
96
+
97
+ result.components.push(serialized);
98
+ addedTypes.add(entry.type);
99
+ usedTokens += cost;
100
+ }
101
+
102
+ // ── Tier 2+: Fill remaining budget with other components ──
103
+ if (tier >= 2) {
104
+ for (const entry of catalog.entries.values()) {
105
+ if (addedTypes.has(entry.type)) continue;
106
+
107
+ const level = tier >= 3 ? 'reference' : 'summary';
108
+ const serialized = serializeEntry(entry, level);
109
+ const cost = estimateTokens(serialized);
110
+ if (usedTokens + cost > budget) break;
111
+
112
+ result.components.push(serialized);
113
+ addedTypes.add(entry.type);
114
+ usedTokens += cost;
115
+ }
116
+ }
117
+
118
+ // ── Tier 1+: Add anti-patterns ──
119
+ if (tier >= 1) {
120
+ const ap = getAntiPatterns();
121
+ const apCost = estimateTokens(ap);
122
+ if (usedTokens + apCost <= budget) {
123
+ result.antiPatterns = ap;
124
+ usedTokens += apCost;
125
+ }
126
+ }
127
+
128
+ // ── Tier 3+: Add matching patterns ──
129
+ if (tier >= 3) {
130
+ const matchedPatterns = intent
131
+ ? searchPatterns(intent)
132
+ : getAllPatterns().filter(p => p.domain === domainName);
133
+
134
+ for (const pattern of matchedPatterns) {
135
+ const cost = estimateTokens(pattern);
136
+ if (usedTokens + cost > budget) break;
137
+ result.patterns.push(pattern);
138
+ usedTokens += cost;
139
+ }
140
+ }
141
+
142
+ // ── Tier 4: Full exploration — add remaining patterns ──
143
+ if (tier >= 4) {
144
+ const addedPatterns = new Set(result.patterns.map(p => p.name));
145
+ for (const pattern of getAllPatterns()) {
146
+ if (addedPatterns.has(pattern.name)) continue;
147
+ const cost = estimateTokens(pattern);
148
+ if (usedTokens + cost > budget) break;
149
+ result.patterns.push(pattern);
150
+ usedTokens += cost;
151
+ }
152
+ }
153
+
154
+ result.estimatedTokens = usedTokens;
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Find an entry in the catalog by type name.
160
+ */
161
+ function findEntry(catalog, typeName) {
162
+ if (catalog.entries.has(typeName)) return catalog.entries.get(typeName);
163
+ // Search by tag fallback
164
+ for (const entry of catalog.entries.values()) {
165
+ if (entry.type === typeName) return entry;
166
+ }
167
+ return null;
168
+ }
package/decomposer.js ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Intent Decomposer — breaks complex intents into independent subtasks.
3
+ *
4
+ * When an intent describes multiple sections, areas, or features,
5
+ * the decomposer splits it into atomic generation units that can be
6
+ * generated independently and composed into a layout.
7
+ *
8
+ * Example:
9
+ * "settings page with profile, notifications, security, and billing"
10
+ * → 4 subtasks + a composition plan (Tabs layout)
11
+ *
12
+ * The decomposition is the single most important capability per the
13
+ * Software Factory Manifesto: "Get decomposition right and generation
14
+ * is almost trivial."
15
+ */
16
+
17
+ import { classifyIntent } from './domain-router.js';
18
+
19
+ // ── Section Detection ────────────────────────────────────────────────────
20
+
21
+ /** Connectors that separate sections in an intent */
22
+ const SECTION_SPLITTERS = /\band\b|\bwith\b|\bplus\b|\balso\b|,\s*(?:and\s+)?/gi;
23
+
24
+ /** Words that signal enumerated sections */
25
+ const SECTION_SIGNALS = [
26
+ /\b(\d+)\s+(sections?|areas?|parts?|panels?|columns?|cards?|tabs?|pages?|views?)\b/i,
27
+ /\bsections?:\s/i,
28
+ /\bincluding\b/i,
29
+ /\beach\s+(with|having|containing)\b/i,
30
+ ];
31
+
32
+ /** Layout containers that imply multi-section structure */
33
+ const LAYOUT_KEYWORDS = {
34
+ tabs: { component: 'Tabs', child: 'Tab' },
35
+ sections: { component: 'Column', child: 'Card' },
36
+ columns: { component: 'Grid', child: 'Column' },
37
+ cards: { component: 'Grid', child: 'Card' },
38
+ panels: { component: 'Accordion', child: 'Panel' },
39
+ pages: { component: 'Tabs', child: 'Tab' },
40
+ areas: { component: 'Grid', child: 'Card' },
41
+ steps: { component: 'Steps', child: 'Step' },
42
+ };
43
+
44
+ /**
45
+ * Analyze an intent for decomposition potential.
46
+ *
47
+ * @param {string} intent
48
+ * @returns {{ shouldDecompose: boolean, subtasks: { intent: string, label: string }[], layout: { component: string, child: string } | null, original: string }}
49
+ */
50
+ export function decomposeIntent(intent) {
51
+ const trimmed = (intent || '').trim();
52
+ if (!trimmed) return { shouldDecompose: false, subtasks: [], layout: null, original: trimmed };
53
+
54
+ // ── Detect explicit section count ──
55
+ for (const pattern of SECTION_SIGNALS) {
56
+ if (pattern.test(trimmed)) {
57
+ const sections = extractSections(trimmed);
58
+ if (sections.length >= 2) {
59
+ const layout = detectLayout(trimmed, sections.length);
60
+ return {
61
+ shouldDecompose: true,
62
+ subtasks: sections,
63
+ layout,
64
+ original: trimmed,
65
+ };
66
+ }
67
+ }
68
+ }
69
+
70
+ // ── Detect enumerated items ──
71
+ const sections = extractSections(trimmed);
72
+ if (sections.length >= 2) {
73
+ // 2+ distinct sections → decompose
74
+ const layout = detectLayout(trimmed, sections.length);
75
+ return {
76
+ shouldDecompose: true,
77
+ subtasks: sections,
78
+ layout,
79
+ original: trimmed,
80
+ };
81
+ }
82
+
83
+ // ── Short or simple intent → don't decompose ──
84
+ return { shouldDecompose: false, subtasks: [], layout: null, original: trimmed };
85
+ }
86
+
87
+ /**
88
+ * Extract individual section descriptions from an intent.
89
+ *
90
+ * @param {string} intent
91
+ * @returns {{ intent: string, label: string }[]}
92
+ */
93
+ function extractSections(intent) {
94
+ // Find the "with X, Y, Z, and W" pattern
95
+ const withMatch = intent.match(/\bwith\s+(.+)$/i);
96
+ const payload = withMatch ? withMatch[1] : intent;
97
+
98
+ // Split on connectors
99
+ const parts = payload.split(SECTION_SPLITTERS)
100
+ .map(s => s.trim())
101
+ .filter(s => s.length > 2);
102
+
103
+ if (parts.length < 2) return [];
104
+
105
+ // Extract the base context (everything before "with")
106
+ const baseContext = withMatch ? intent.slice(0, withMatch.index).trim() : '';
107
+ const domain = classifyIntent(intent).domain;
108
+
109
+ return parts.map(part => {
110
+ // Build a self-contained subtask intent
111
+ const label = part.replace(/\b(section|area|panel|tab|page)\b/gi, '').trim();
112
+ const subtaskIntent = baseContext
113
+ ? `${label} section for a ${baseContext}`
114
+ : `${label}`;
115
+
116
+ return { intent: subtaskIntent, label: capitalizeFirst(label) };
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Detect the best layout container for the decomposed sections.
122
+ *
123
+ * @param {string} intent
124
+ * @param {number} sectionCount
125
+ * @returns {{ component: string, child: string }}
126
+ */
127
+ function detectLayout(intent, sectionCount) {
128
+ const lower = intent.toLowerCase();
129
+
130
+ // Explicit layout keywords in the intent
131
+ for (const [keyword, layout] of Object.entries(LAYOUT_KEYWORDS)) {
132
+ if (lower.includes(keyword)) return layout;
133
+ }
134
+
135
+ // Heuristic: settings/profile pages → Tabs
136
+ if (lower.includes('settings') || lower.includes('preferences') || lower.includes('account')) {
137
+ return { component: 'Tabs', child: 'Tab' };
138
+ }
139
+
140
+ // Heuristic: dashboard with metrics → Grid of Cards
141
+ if (lower.includes('dashboard') || lower.includes('overview') || lower.includes('stat')) {
142
+ return { component: 'Grid', child: 'Card' };
143
+ }
144
+
145
+ // Default: small count → Tabs, large count → Grid
146
+ if (sectionCount <= 4) return { component: 'Tabs', child: 'Tab' };
147
+ return { component: 'Grid', child: 'Card' };
148
+ }
149
+
150
+ /**
151
+ * Compose independently generated subtask results into a layout.
152
+ *
153
+ * @param {{ component: string, child: string }} layout
154
+ * @param {{ label: string, messages: object[] }[]} subtaskResults
155
+ * @returns {object[]} — A2UI messages for the composed layout
156
+ */
157
+ export function composeSubtasks(layout, subtaskResults) {
158
+ const rootChildren = [];
159
+ const allComponents = [];
160
+ let idCounter = 0;
161
+
162
+ for (const { label, messages } of subtaskResults) {
163
+ const prefix = `s${++idCounter}`;
164
+ const subtaskComponents = messages?.[0]?.components || [];
165
+
166
+ // Re-prefix all component IDs to avoid collisions
167
+ const idMap = new Map();
168
+ const remapped = subtaskComponents.map(c => {
169
+ const newId = c.id === 'root' ? prefix : `${prefix}-${c.id}`;
170
+ idMap.set(c.id, newId);
171
+ return { ...c, id: newId };
172
+ });
173
+
174
+ // Fix child references
175
+ for (const c of remapped) {
176
+ if (c.children) {
177
+ c.children = c.children.map(id => idMap.get(id) || id);
178
+ }
179
+ }
180
+
181
+ // Create the layout wrapper for this subtask
182
+ if (layout.child === 'Tab') {
183
+ // Tabs: wrap in a Tab with a label
184
+ const tabId = `${prefix}-tab`;
185
+ allComponents.push({ id: tabId, component: 'Tab', text: label, children: [prefix] });
186
+ rootChildren.push(tabId);
187
+ } else {
188
+ // Grid/Column: subtask root becomes a direct child
189
+ rootChildren.push(prefix);
190
+ }
191
+
192
+ allComponents.push(...remapped);
193
+ }
194
+
195
+ // Build the root layout
196
+ const root = {
197
+ id: 'root',
198
+ component: layout.component,
199
+ children: rootChildren,
200
+ };
201
+
202
+ if (layout.component === 'Grid') {
203
+ root.columns = String(Math.min(subtaskResults.length, 4));
204
+ root.gap = 'md';
205
+ }
206
+
207
+ return [{
208
+ type: 'updateComponents',
209
+ surfaceId: 'default',
210
+ components: [root, ...allComponents],
211
+ }];
212
+ }
213
+
214
+ function capitalizeFirst(str) {
215
+ return str.charAt(0).toUpperCase() + str.slice(1);
216
+ }