@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.
- package/CHANGELOG.md +50 -0
- package/README.md +40 -0
- package/anti-patterns.js +148 -0
- package/catalog.js +215 -0
- package/clarity.js +207 -0
- package/component-entry.js +80 -0
- package/concept-mapper.js +127 -0
- package/context-assembler.js +168 -0
- package/decomposer.js +216 -0
- package/dialog-recorder.js +179 -0
- package/domain-router.js +172 -0
- package/embedding-provider.js +108 -0
- package/embedding-retriever.js +120 -0
- package/feedback-analyzer.js +235 -0
- package/feedback-store.js +175 -0
- package/feedback.js +198 -0
- package/gap-registry.js +121 -0
- package/index.js +16 -0
- package/intent-alignment.js +243 -0
- package/intent-categorizer.js +97 -0
- package/intent-gate.js +155 -0
- package/package.json +29 -0
- package/pattern-library.js +659 -0
- package/pattern-promotion.js +135 -0
- package/prompt-analyzer.js +211 -0
- package/synthetic-data.js +446 -0
- package/web-research.js +186 -0
- package/wiring-catalog.js +195 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern Library — Loads named reference patterns from JSON files on disk.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the former monolithic Map with a lazy-loading corpus that reads
|
|
5
|
+
* from packages/a2ui/compose/patterns/{domain}/*.json at runtime.
|
|
6
|
+
*
|
|
7
|
+
* Works in both Node.js (readdir/readFile) and the browser (Vite import.meta.glob).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Environment detection ────────────────────────────────────────────────
|
|
11
|
+
const IS_NODE =
|
|
12
|
+
typeof process !== 'undefined' &&
|
|
13
|
+
typeof process.versions?.node === 'string';
|
|
14
|
+
|
|
15
|
+
// ── State ────────────────────────────────────────────────────────────────
|
|
16
|
+
const patterns = new Map();
|
|
17
|
+
let _loaded = false;
|
|
18
|
+
let _loadPromise = null;
|
|
19
|
+
let _synonyms = new Map(); // populated from _taxonomy.json
|
|
20
|
+
const _componentData = new Map(); // populated from .a2ui.json files
|
|
21
|
+
|
|
22
|
+
// ── Browser glob (Vite) ──────────────────────────────────────────────────
|
|
23
|
+
// Vite resolves this at build time; at runtime in Node the variable is unused.
|
|
24
|
+
let _globModules = null;
|
|
25
|
+
let _globA2UIModules = null;
|
|
26
|
+
if (!IS_NODE) {
|
|
27
|
+
try {
|
|
28
|
+
_globModules = import.meta.glob('../corpus/patterns/**/*.json', {
|
|
29
|
+
query: '?raw',
|
|
30
|
+
import: 'default',
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
// Not in a Vite context — will fall back to empty corpus
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
_globA2UIModules = import.meta.glob('../../web-components/components/**/*.a2ui.json', {
|
|
37
|
+
query: '?raw',
|
|
38
|
+
import: 'default',
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// Not in a Vite context — no component data
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Corpus loader ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load all pattern JSON files into the in-memory Map.
|
|
49
|
+
* Called lazily on first access. Safe to call multiple times (idempotent).
|
|
50
|
+
*/
|
|
51
|
+
export async function loadCorpus() {
|
|
52
|
+
if (_loaded) return;
|
|
53
|
+
if (_loadPromise) return _loadPromise;
|
|
54
|
+
_loadPromise = _doLoad();
|
|
55
|
+
await _loadPromise;
|
|
56
|
+
_loaded = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function _doLoad() {
|
|
60
|
+
if (IS_NODE) {
|
|
61
|
+
await _loadNode();
|
|
62
|
+
} else {
|
|
63
|
+
await _loadBrowser();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Skip files whose basename starts with _ (index, domain, schema, taxonomy, components). */
|
|
68
|
+
function _shouldSkip(basename) {
|
|
69
|
+
return basename.startsWith('_');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── .a2ui.json processing ────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process a parsed .a2ui.json object: register examples as patterns,
|
|
76
|
+
* merge synonyms, and store the component data.
|
|
77
|
+
*/
|
|
78
|
+
function _processA2UIData(data) {
|
|
79
|
+
// v0.9 shape (YAML-generated, produced by scripts/build-components.mjs):
|
|
80
|
+
// top-level: `title` = PascalCase component name, JSON Schema fields
|
|
81
|
+
// x-adiaui: retrieval signals (keywords, synonyms, examples, related, category)
|
|
82
|
+
const ext = data?.['x-adiaui'] || {};
|
|
83
|
+
const compName = data?.title;
|
|
84
|
+
if (!compName) return;
|
|
85
|
+
|
|
86
|
+
const category = ext.category || 'layout';
|
|
87
|
+
const keywords = ext.keywords || [];
|
|
88
|
+
const synonyms = ext.synonyms || null;
|
|
89
|
+
const related = ext.related || [];
|
|
90
|
+
const examples = Array.isArray(ext.examples) ? ext.examples : [];
|
|
91
|
+
|
|
92
|
+
_componentData.set(compName, data);
|
|
93
|
+
|
|
94
|
+
if (synonyms && typeof synonyms === 'object') {
|
|
95
|
+
for (const [term, syns] of Object.entries(synonyms)) {
|
|
96
|
+
const existing = _synonyms.get(term) || [];
|
|
97
|
+
const merged = [...new Set([...existing, ...syns])];
|
|
98
|
+
_synonyms.set(term, merged);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const example of examples) {
|
|
103
|
+
if (!example.name) continue;
|
|
104
|
+
// Example payload may be a structured template array OR a JSON string
|
|
105
|
+
// under `a2ui`. Normalize to an array.
|
|
106
|
+
let template = null;
|
|
107
|
+
if (Array.isArray(example.template)) {
|
|
108
|
+
template = example.template;
|
|
109
|
+
} else if (typeof example.a2ui === 'string') {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(example.a2ui);
|
|
112
|
+
template = Array.isArray(parsed) ? parsed : [parsed];
|
|
113
|
+
} catch { /* skip malformed */ }
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(template) || template.length === 0) continue;
|
|
116
|
+
registerPattern({
|
|
117
|
+
name: example.name,
|
|
118
|
+
description: example.description || `${compName} example`,
|
|
119
|
+
domain: category,
|
|
120
|
+
template,
|
|
121
|
+
tags: keywords,
|
|
122
|
+
keywords,
|
|
123
|
+
related,
|
|
124
|
+
source: `a2ui:${compName}`,
|
|
125
|
+
}, { replace: false });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Node.js loader ───────────────────────────────────────────────────────
|
|
130
|
+
async function _loadNode() {
|
|
131
|
+
// Dynamic import so Vite never tries to bundle these
|
|
132
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
133
|
+
const path = await import(/* @vite-ignore */ 'node:path');
|
|
134
|
+
const url = await import(/* @vite-ignore */ 'node:url');
|
|
135
|
+
|
|
136
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
137
|
+
const patternsDir = path.resolve(__dirname, '../corpus/patterns');
|
|
138
|
+
|
|
139
|
+
// Load _taxonomy.json for synonyms
|
|
140
|
+
try {
|
|
141
|
+
const taxRaw = await fs.readFile(path.join(patternsDir, '_taxonomy.json'), 'utf8');
|
|
142
|
+
_parseTaxonomy(JSON.parse(taxRaw));
|
|
143
|
+
} catch {
|
|
144
|
+
// _taxonomy.json not present — synonyms stay empty
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Walk domain directories
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = await fs.readdir(patternsDir, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return; // patterns dir doesn't exist yet
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (!entry.isDirectory()) continue;
|
|
157
|
+
const domainDir = path.join(patternsDir, entry.name);
|
|
158
|
+
const files = await fs.readdir(domainDir);
|
|
159
|
+
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
if (!file.endsWith('.json')) continue;
|
|
162
|
+
if (_shouldSkip(file)) continue;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const raw = await fs.readFile(path.join(domainDir, file), 'utf8');
|
|
166
|
+
const pattern = JSON.parse(raw);
|
|
167
|
+
if (pattern.name) {
|
|
168
|
+
// Normalize tags: ensure .tags is always a flat string array for search compatibility
|
|
169
|
+
if (pattern.tags && !Array.isArray(pattern.tags)) {
|
|
170
|
+
pattern.tagsMeta = pattern.tags;
|
|
171
|
+
pattern.tags = pattern.keywords || [];
|
|
172
|
+
}
|
|
173
|
+
// Derive components from template (source of truth)
|
|
174
|
+
pattern.components = [...new Set(pattern.template.map(n => n.component).filter(Boolean))];
|
|
175
|
+
patterns.set(pattern.name, pattern);
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// Malformed JSON — skip silently
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Scan web-components/components/ for .a2ui.json files ──────────────
|
|
184
|
+
// .a2ui.json sidecars live with their components (packages/web-components/components/).
|
|
185
|
+
// These are hand-authored component contracts — part of the component library,
|
|
186
|
+
// not the training corpus — so they stay in web-components.
|
|
187
|
+
const componentsDir = path.resolve(__dirname, '..', '..', 'web-components', 'components');
|
|
188
|
+
try {
|
|
189
|
+
const compEntries = await fs.readdir(componentsDir, { withFileTypes: true });
|
|
190
|
+
for (const compEntry of compEntries) {
|
|
191
|
+
if (!compEntry.isDirectory()) continue;
|
|
192
|
+
const a2uiPath = path.join(componentsDir, compEntry.name, `${compEntry.name}.a2ui.json`);
|
|
193
|
+
try {
|
|
194
|
+
const raw = await fs.readFile(a2uiPath, 'utf8');
|
|
195
|
+
const data = JSON.parse(raw);
|
|
196
|
+
_processA2UIData(data);
|
|
197
|
+
} catch {
|
|
198
|
+
// No .a2ui.json for this component — skip
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// components dir doesn't exist — skip
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
// ── Browser (Vite) loader ────────────────────────────────────────────────
|
|
208
|
+
async function _loadBrowser() {
|
|
209
|
+
if (!_globModules) return;
|
|
210
|
+
|
|
211
|
+
for (const [filePath, loader] of Object.entries(_globModules)) {
|
|
212
|
+
// filePath looks like ../patterns/data/dashboard.json
|
|
213
|
+
const basename = filePath.split('/').pop();
|
|
214
|
+
if (_shouldSkip(basename)) {
|
|
215
|
+
// But still load _taxonomy.json for synonyms
|
|
216
|
+
if (basename === '_taxonomy.json') {
|
|
217
|
+
try {
|
|
218
|
+
const raw = await loader();
|
|
219
|
+
_parseTaxonomy(JSON.parse(raw));
|
|
220
|
+
} catch { /* ignore */ }
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const raw = await loader();
|
|
227
|
+
const pattern = JSON.parse(raw);
|
|
228
|
+
if (pattern.name) {
|
|
229
|
+
if (pattern.tags && !Array.isArray(pattern.tags)) {
|
|
230
|
+
pattern.tagsMeta = pattern.tags;
|
|
231
|
+
pattern.tags = pattern.keywords || [];
|
|
232
|
+
}
|
|
233
|
+
// Derive components from template (source of truth)
|
|
234
|
+
pattern.components = [...new Set(pattern.template.map(n => n.component).filter(Boolean))];
|
|
235
|
+
patterns.set(pattern.name, pattern);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// skip malformed
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Load .a2ui.json files from components/ (Vite glob) ─────────────────
|
|
243
|
+
if (_globA2UIModules) {
|
|
244
|
+
for (const [filePath, loader] of Object.entries(_globA2UIModules)) {
|
|
245
|
+
try {
|
|
246
|
+
const raw = await loader();
|
|
247
|
+
const data = JSON.parse(raw);
|
|
248
|
+
_processA2UIData(data);
|
|
249
|
+
} catch {
|
|
250
|
+
// skip malformed .a2ui.json
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Taxonomy / synonym parsing ───────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function _parseTaxonomy(tax) {
|
|
259
|
+
if (!tax?.synonyms) return;
|
|
260
|
+
// Expected format: { synonyms: { "term": ["syn1", "syn2", ...], ... } }
|
|
261
|
+
_synonyms = new Map(Object.entries(tax.synonyms));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Lazy init guard ──────────────────────────────────────────────────────
|
|
265
|
+
async function _ensureLoaded() {
|
|
266
|
+
if (!_loaded) await loadCorpus();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
// ── Eager load on module import ──────────────────────────────────────
|
|
272
|
+
// Top-level await ensures patterns are available before any export is called.
|
|
273
|
+
// This keeps getAllPatterns/searchPatterns/registerPattern synchronous for callers.
|
|
274
|
+
await loadCorpus();
|
|
275
|
+
|
|
276
|
+
// ── Public API (synchronous after top-level load) ─────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get a pattern by name.
|
|
280
|
+
* @param {string} name
|
|
281
|
+
* @returns {object|null}
|
|
282
|
+
*/
|
|
283
|
+
export function getPattern(name) {
|
|
284
|
+
return patterns.get(name) || null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get .a2ui.json data for a named component.
|
|
289
|
+
* @param {string} name — Component name (e.g., "Swiper")
|
|
290
|
+
* @returns {object|null}
|
|
291
|
+
*/
|
|
292
|
+
export function getComponentData(name) {
|
|
293
|
+
return _componentData.get(name) || null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get all loaded .a2ui.json component data.
|
|
298
|
+
* @returns {Object.<string, object>} — Map of component name → .a2ui.json data
|
|
299
|
+
*/
|
|
300
|
+
export function getAllComponentData() {
|
|
301
|
+
return Object.fromEntries(_componentData);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Get all registered patterns.
|
|
306
|
+
* @returns {object[]}
|
|
307
|
+
*/
|
|
308
|
+
export function getAllPatterns() {
|
|
309
|
+
return [...patterns.values()];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Register a new pattern at runtime.
|
|
314
|
+
* @param {object} pattern — Must have { name, template }
|
|
315
|
+
* @param {object} [opts] — { replace: boolean }
|
|
316
|
+
* @returns {boolean}
|
|
317
|
+
*/
|
|
318
|
+
export function registerPattern(pattern, opts = {}) {
|
|
319
|
+
if (!pattern?.name || !pattern?.template || !Array.isArray(pattern.template)) return false;
|
|
320
|
+
if (patterns.has(pattern.name) && !opts.replace) return false;
|
|
321
|
+
const template = pattern.template;
|
|
322
|
+
patterns.set(pattern.name, {
|
|
323
|
+
name: pattern.name,
|
|
324
|
+
description: pattern.description || '',
|
|
325
|
+
domain: pattern.domain || 'layout',
|
|
326
|
+
components: [...new Set(template.map(c => c.component).filter(Boolean))],
|
|
327
|
+
tags: Array.isArray(pattern.tags) ? pattern.tags : pattern.keywords || [],
|
|
328
|
+
tagsMeta: (!Array.isArray(pattern.tags) && pattern.tags) ? pattern.tags : null,
|
|
329
|
+
keywords: pattern.keywords || [],
|
|
330
|
+
source: pattern.source || null,
|
|
331
|
+
template,
|
|
332
|
+
styling: pattern.styling || null,
|
|
333
|
+
wiring: pattern.wiring || null,
|
|
334
|
+
related: pattern.related || [],
|
|
335
|
+
primitive: pattern.primitive || false,
|
|
336
|
+
});
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Search ───────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const STOP_WORDS = new Set([
|
|
343
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
344
|
+
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'be', 'it',
|
|
345
|
+
'that', 'this', 'my', 'its', 'has', 'have', 'do', 'does', 'did',
|
|
346
|
+
'i', 'me', 'we', 'you', 'he', 'she', 'them', 'so', 'if', 'not',
|
|
347
|
+
'no', 'can', 'will', 'just', 'should', 'would', 'could', 'about',
|
|
348
|
+
'like', 'make', 'show', 'want', 'need', 'give', 'use', 'some',
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Search patterns by query string. Matches against name, description,
|
|
353
|
+
* domain, components, and taxonomy synonyms.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} query — Search query
|
|
356
|
+
* @param {object} [opts]
|
|
357
|
+
* @param {string} [opts.domain] — Boost patterns matching this domain
|
|
358
|
+
* @returns {Promise<object[]>} — Matching patterns sorted by relevance
|
|
359
|
+
*/
|
|
360
|
+
export function searchPatterns(query, { domain } = {}) {
|
|
361
|
+
|
|
362
|
+
const words = query
|
|
363
|
+
.toLowerCase()
|
|
364
|
+
.split(/\s+/)
|
|
365
|
+
.filter(w => w.length > 1 && !STOP_WORDS.has(w));
|
|
366
|
+
|
|
367
|
+
if (!words.length) return [...patterns.values()];
|
|
368
|
+
|
|
369
|
+
const results = [];
|
|
370
|
+
|
|
371
|
+
for (const pattern of patterns.values()) {
|
|
372
|
+
let score = 0;
|
|
373
|
+
const name = pattern.name.toLowerCase();
|
|
374
|
+
const desc = (pattern.description || '').toLowerCase();
|
|
375
|
+
const tags = [
|
|
376
|
+
...(pattern.keywords || []),
|
|
377
|
+
...((pattern.tags?.purpose) || []),
|
|
378
|
+
...((pattern.tags?.interaction) || []),
|
|
379
|
+
].map(t => t.toLowerCase());
|
|
380
|
+
const comps = (pattern.components || []).map(c => c.toLowerCase());
|
|
381
|
+
let nameHits = 0;
|
|
382
|
+
|
|
383
|
+
for (const word of words) {
|
|
384
|
+
// Name match — strongest signal (+3)
|
|
385
|
+
if (name.includes(word)) { score += 3; nameHits++; }
|
|
386
|
+
// Description match (+2)
|
|
387
|
+
if (desc.includes(word)) score += 2;
|
|
388
|
+
// Domain match (+2)
|
|
389
|
+
if (pattern.domain && pattern.domain.toLowerCase().includes(word)) score += 2;
|
|
390
|
+
// Component match (+1)
|
|
391
|
+
if (comps.some(c => c.toLowerCase().includes(word))) score += 1;
|
|
392
|
+
// Tag/keyword exact match
|
|
393
|
+
if (tags.some(t => t === word)) score += 2;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Synonym expansion from loaded taxonomy
|
|
397
|
+
score += synonymScore(words, pattern);
|
|
398
|
+
|
|
399
|
+
// Specificity bonus: reward patterns where query covers a large fraction of the name
|
|
400
|
+
if (nameHits > 0) {
|
|
401
|
+
const nameWords = name.split(/[-_\s]+/).filter(w => w.length > 1);
|
|
402
|
+
const coverage = nameHits / Math.max(nameWords.length, 1);
|
|
403
|
+
score += Math.round(coverage * 5);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Domain boost: same-domain patterns get a bonus (+4)
|
|
407
|
+
if (domain && pattern.domain === domain) {
|
|
408
|
+
score += 4;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Primitive penalty: demote single-component patterns for complex queries
|
|
412
|
+
if (pattern.primitive && words.length > 2) {
|
|
413
|
+
score -= 5;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Require minimum score — lower threshold for short queries
|
|
417
|
+
const minScore = words.length === 1 ? 2 : 3;
|
|
418
|
+
if (score >= minScore) {
|
|
419
|
+
results.push({ ...pattern, _score: score });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
results.sort((a, b) => b._score - a._score);
|
|
424
|
+
return results.map(({ _score, ...rest }) => rest);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Synonym scoring ──────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function synonymScore(words, pattern) {
|
|
430
|
+
if (_synonyms.size === 0) return 0;
|
|
431
|
+
|
|
432
|
+
let score = 0;
|
|
433
|
+
const name = pattern.name.toLowerCase();
|
|
434
|
+
const desc = (pattern.description || '').toLowerCase();
|
|
435
|
+
const comps = (pattern.components || []).map(c => c.toLowerCase());
|
|
436
|
+
const tags = [
|
|
437
|
+
...(pattern.keywords || []),
|
|
438
|
+
...((Array.isArray(pattern.tags) ? pattern.tags : pattern.tags?.purpose) || []),
|
|
439
|
+
...((pattern.tags?.interaction) || []),
|
|
440
|
+
].map(t => t.toLowerCase());
|
|
441
|
+
|
|
442
|
+
for (const word of words) {
|
|
443
|
+
const syns = _synonyms.get(word);
|
|
444
|
+
if (!syns) continue;
|
|
445
|
+
for (const syn of syns) {
|
|
446
|
+
// Synonym name match — nearly as strong as direct name match (+2.5)
|
|
447
|
+
if (name.includes(syn)) score += 2.5;
|
|
448
|
+
// Synonym description match (+1.5)
|
|
449
|
+
if (desc.includes(syn)) score += 1.5;
|
|
450
|
+
// Synonym component match (+1)
|
|
451
|
+
if (comps.some(c => c.includes(syn))) score += 1;
|
|
452
|
+
// Synonym tag/keyword match (+1.5)
|
|
453
|
+
if (tags.some(t => t === syn || t.includes(syn))) score += 1.5;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return score;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Embedding blend ──────────────────────────────────────────────────────
|
|
460
|
+
// Produce a fused ranking from keyword hits + semantic-similarity scores.
|
|
461
|
+
// - Keyword hits keep their order and gain a semantic bump.
|
|
462
|
+
// - New semantic hits (high cosine but no lexical overlap) are PROMOTED
|
|
463
|
+
// into the result set above a threshold. This is where "product card
|
|
464
|
+
// → product-pricing-card" wins over "ticket form" regardless of the
|
|
465
|
+
// keyword word "card" collision.
|
|
466
|
+
//
|
|
467
|
+
// Weights: keyword hits are normalized to [0, 1] then scaled 3×; semantic
|
|
468
|
+
// scores sit in [0, 1] after the cosine clamp. Final score = keywordScaled +
|
|
469
|
+
// 2 × semantic. Patterns below 0.30 semantic and with no lexical match are
|
|
470
|
+
// dropped. Threshold picked to be recall-generous (we're ranking for LLM
|
|
471
|
+
// grounding, not committing to a single answer).
|
|
472
|
+
function _blendEmbedding(keywordHits, semanticMap, allPatterns) {
|
|
473
|
+
const SEM_PROMOTE_THRESHOLD = 0.30;
|
|
474
|
+
const SEM_WEIGHT = 2.0;
|
|
475
|
+
|
|
476
|
+
// Normalize keyword scores. Since searchPatterns strips _score before
|
|
477
|
+
// returning, we work with zero-based rank implying a linear boost.
|
|
478
|
+
const keywordByName = new Map();
|
|
479
|
+
for (let i = 0; i < keywordHits.length; i++) {
|
|
480
|
+
const p = keywordHits[i];
|
|
481
|
+
// Rank-based keyword signal: 1.0 at top, tapering to ~0.1 at 20th.
|
|
482
|
+
const rank = 1.0 - (i / Math.max(keywordHits.length, 20));
|
|
483
|
+
keywordByName.set(p.name, { pattern: p, keyword: Math.max(0.1, rank) });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Collect semantic-only promotions (not in keyword hits but above threshold).
|
|
487
|
+
const promotions = [];
|
|
488
|
+
for (const [name, sem] of semanticMap) {
|
|
489
|
+
if (keywordByName.has(name)) continue;
|
|
490
|
+
if (sem < SEM_PROMOTE_THRESHOLD) continue;
|
|
491
|
+
const p = allPatterns.find(x => x.name === name);
|
|
492
|
+
if (p) promotions.push({ pattern: p, keyword: 0, semantic: sem });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Blend scores: keyword + SEM_WEIGHT × semantic.
|
|
496
|
+
const blended = [];
|
|
497
|
+
for (const { pattern, keyword } of keywordByName.values()) {
|
|
498
|
+
const sem = Math.max(0, semanticMap.get(pattern.name) || 0);
|
|
499
|
+
blended.push({ pattern, score: keyword + SEM_WEIGHT * sem });
|
|
500
|
+
}
|
|
501
|
+
for (const { pattern, semantic } of promotions) {
|
|
502
|
+
blended.push({ pattern, score: SEM_WEIGHT * semantic });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
blended.sort((a, b) => b.score - a.score);
|
|
506
|
+
return blended.map(b => b.pattern);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Semantic search (LLM-powered) ────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
const PATTERN_SEARCH_SYSTEM_PROMPT = `You are a AdiaUI pattern matching engine. Given a user query and a library of UI patterns, you identify which patterns are conceptually relevant — even if they don't share keywords.
|
|
512
|
+
|
|
513
|
+
Output ONLY valid JSON. No explanation, no markdown.`;
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Semantic search using an LLM to find conceptually related patterns and
|
|
517
|
+
* optionally remix multiple patterns into a new composition.
|
|
518
|
+
*
|
|
519
|
+
* Falls back to keyword search if no LLM adapter is provided.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} query — Natural language query
|
|
522
|
+
* @param {object} [options]
|
|
523
|
+
* @param {object} [options.llmAdapter] — LLM adapter with .complete()
|
|
524
|
+
* @param {boolean} [options.remix] — If true, ask LLM to compose a new pattern
|
|
525
|
+
* @returns {Promise<{ matches: object[], remix?: object }>}
|
|
526
|
+
*/
|
|
527
|
+
export async function semanticSearchPatterns(query, options = {}) {
|
|
528
|
+
const { llmAdapter, remix = false } = options;
|
|
529
|
+
|
|
530
|
+
// Keyword channel — always available, sub-ms, authoritative for lexical hits.
|
|
531
|
+
let keywordMatches = await searchPatterns(query);
|
|
532
|
+
|
|
533
|
+
// Embedding channel — blend when the build-time index + provider key are
|
|
534
|
+
// both present. Fixes the "product card → ticket form" class of failures
|
|
535
|
+
// where keyword collisions pick the wrong pattern. Graceful degradation:
|
|
536
|
+
// empty map → no effect, pipeline behaves exactly as pre-embedding.
|
|
537
|
+
try {
|
|
538
|
+
const { scoreAll, available } = await import('./embedding-retriever.js');
|
|
539
|
+
if (await available()) {
|
|
540
|
+
const semanticMap = await scoreAll(query);
|
|
541
|
+
if (semanticMap.size > 0) {
|
|
542
|
+
keywordMatches = _blendEmbedding(keywordMatches, semanticMap, getAllPatterns());
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} catch { /* keep keyword-only path on any failure */ }
|
|
546
|
+
|
|
547
|
+
if (!llmAdapter) {
|
|
548
|
+
return { matches: keywordMatches };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Build a compact pattern index for the LLM
|
|
552
|
+
const allPatterns = await getAllPatterns();
|
|
553
|
+
const index = allPatterns.map(p => ({
|
|
554
|
+
name: p.name,
|
|
555
|
+
description: p.description,
|
|
556
|
+
domain: p.domain,
|
|
557
|
+
components: p.components,
|
|
558
|
+
}));
|
|
559
|
+
|
|
560
|
+
const prompt = remix
|
|
561
|
+
? buildRemixPrompt(query, index, keywordMatches)
|
|
562
|
+
: buildSearchPrompt(query, index, keywordMatches);
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const response = await llmAdapter.complete({
|
|
566
|
+
messages: [{ role: 'user', content: prompt }],
|
|
567
|
+
systemPrompt: PATTERN_SEARCH_SYSTEM_PROMPT,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const parsed = parseSearchResponse(response.content);
|
|
571
|
+
|
|
572
|
+
// Resolve matched pattern names to full pattern objects
|
|
573
|
+
const semanticMatches = (parsed.matches || [])
|
|
574
|
+
.map(name => patterns.get(name))
|
|
575
|
+
.filter(Boolean);
|
|
576
|
+
|
|
577
|
+
// Merge: keyword matches first (known good), then semantic additions
|
|
578
|
+
const seen = new Set(keywordMatches.map(p => p.name));
|
|
579
|
+
const merged = [...keywordMatches];
|
|
580
|
+
for (const p of semanticMatches) {
|
|
581
|
+
if (!seen.has(p.name)) {
|
|
582
|
+
seen.add(p.name);
|
|
583
|
+
merged.push(p);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const result = { matches: merged };
|
|
588
|
+
|
|
589
|
+
// If remix was requested and LLM produced one, include it
|
|
590
|
+
if (remix && parsed.remix) {
|
|
591
|
+
result.remix = parsed.remix;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return result;
|
|
595
|
+
} catch {
|
|
596
|
+
// LLM failed — return keyword results
|
|
597
|
+
return { matches: keywordMatches };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── LLM prompt builders ──────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
function buildSearchPrompt(query, index, keywordMatches) {
|
|
604
|
+
const keywordNames = keywordMatches.map(p => p.name);
|
|
605
|
+
return `Query: "${query}"
|
|
606
|
+
|
|
607
|
+
Available patterns:
|
|
608
|
+
${JSON.stringify(index, null, 2)}
|
|
609
|
+
|
|
610
|
+
Keyword matches already found: ${JSON.stringify(keywordNames)}
|
|
611
|
+
|
|
612
|
+
Which additional patterns are conceptually relevant to this query? Consider:
|
|
613
|
+
- Structural similarity (similar layout/composition)
|
|
614
|
+
- Functional similarity (serves a similar purpose)
|
|
615
|
+
- Component overlap (uses similar building blocks)
|
|
616
|
+
|
|
617
|
+
Output: { "matches": ["pattern-name", ...], "reasoning": "brief explanation" }
|
|
618
|
+
Only include patterns NOT already in keyword matches.`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function buildRemixPrompt(query, index, keywordMatches) {
|
|
622
|
+
const topPatterns = keywordMatches.slice(0, 3).map(p => ({
|
|
623
|
+
name: p.name,
|
|
624
|
+
description: p.description,
|
|
625
|
+
template: p.template,
|
|
626
|
+
}));
|
|
627
|
+
|
|
628
|
+
return `Query: "${query}"
|
|
629
|
+
|
|
630
|
+
Available patterns with templates:
|
|
631
|
+
${JSON.stringify(topPatterns, null, 2)}
|
|
632
|
+
|
|
633
|
+
All available patterns:
|
|
634
|
+
${JSON.stringify(index, null, 2)}
|
|
635
|
+
|
|
636
|
+
Create a new AdiaUI component tree that fulfills the query by remixing elements from the available patterns. The result should:
|
|
637
|
+
1. Use the flat adjacency format: { id, component, children?, ...props }
|
|
638
|
+
2. Root component must have id "root"
|
|
639
|
+
3. Follow Card content model: Card > Header + Section > Column + Footer
|
|
640
|
+
4. Use only registered AdiaUI types
|
|
641
|
+
|
|
642
|
+
Output: { "matches": ["pattern-names-used"], "remix": { "name": "suggested-name", "description": "what this is", "components": [...flat adjacency list...] } }`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function parseSearchResponse(content) {
|
|
646
|
+
if (!content) return { matches: [] };
|
|
647
|
+
let json = content.trim();
|
|
648
|
+
const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
|
|
649
|
+
if (fenceMatch) json = fenceMatch[1].trim();
|
|
650
|
+
try {
|
|
651
|
+
const parsed = JSON.parse(json);
|
|
652
|
+
return {
|
|
653
|
+
matches: Array.isArray(parsed.matches) ? parsed.matches : [],
|
|
654
|
+
remix: parsed.remix || null,
|
|
655
|
+
};
|
|
656
|
+
} catch {
|
|
657
|
+
return { matches: [] };
|
|
658
|
+
}
|
|
659
|
+
}
|