@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,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
+ }