@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,446 @@
1
+ /**
2
+ * SyntheticDataGenerator — Fills coverage gaps in training data by
3
+ * generating A2UI JSON examples for uncovered patterns.
4
+ *
5
+ * Uses the LLM adapter to generate schemas, validates them via the
6
+ * generative validator, scores quality via anti-pattern checks,
7
+ * and stores results as training pairs (prompt -> schema).
8
+ *
9
+ * Spec: A003 section 6 — Synthetic Data Generation
10
+ */
11
+
12
+ import { getCatalog } from './catalog.js';
13
+ import { getAntiPatterns } from './anti-patterns.js';
14
+ import { getAllPatterns } from './pattern-library.js';
15
+ import { serializeEntry } from './component-entry.js';
16
+
17
+ // ── Coverage targets (spec section 6.2) ──
18
+
19
+ const COVERAGE_TARGETS = [
20
+ { id: 'sidebar-main', description: 'Sidebar navigation with main content area', complexity: 'medium' },
21
+ { id: 'modal-form', description: 'Modal dialog containing a form with validation', complexity: 'medium' },
22
+ { id: 'drawer-nav', description: 'Drawer panel with navigation links', complexity: 'medium' },
23
+ { id: 'toast-sequence', description: 'Sequence of toast notifications (success, error, info)', complexity: 'low' },
24
+ { id: 'tabs-content', description: 'Tabbed interface with different content per tab', complexity: 'medium' },
25
+ { id: 'accordion-faq', description: 'Accordion with FAQ-style question/answer pairs', complexity: 'low' },
26
+ { id: 'wizard-steps', description: 'Multi-step wizard with progress indicator and form fields', complexity: 'high' },
27
+ { id: 'data-grid', description: 'Data table with sort, filter, and pagination', complexity: 'high' },
28
+ { id: 'card-grid', description: 'Grid of cards with different content types', complexity: 'medium' },
29
+ { id: 'auth-form', description: 'Login form with email, password, and social buttons', complexity: 'medium' },
30
+ { id: 'profile-card', description: 'User profile card with avatar, name, stats, and actions', complexity: 'medium' },
31
+ { id: 'empty-state', description: 'Empty state with illustration, heading, and CTA button', complexity: 'low' },
32
+ ];
33
+
34
+ // ── Default generation options ──
35
+
36
+ const DEFAULT_OPTIONS = {
37
+ temperature: 0.7,
38
+ maxRetries: 2,
39
+ batchSize: 4,
40
+ };
41
+
42
+ /**
43
+ * SyntheticDataGenerator — generates A2UI training examples for coverage gaps.
44
+ */
45
+ export class SyntheticDataGenerator {
46
+ #llmAdapter;
47
+ #catalog;
48
+ #patternLibrary;
49
+ #validator;
50
+ #antiPatterns;
51
+
52
+ /**
53
+ * @param {object} deps
54
+ * @param {object} deps.llmAdapter — LLM adapter with complete() method
55
+ * @param {object} [deps.catalog] — Component catalog (defaults to built-in)
56
+ * @param {object} [deps.patternLibrary] — Pattern library (defaults to built-in)
57
+ * @param {object} [deps.validator] — Schema validator with validateSchema()
58
+ * @param {object} [deps.antiPatterns] — Anti-patterns checker
59
+ */
60
+ constructor({ llmAdapter, catalog, patternLibrary, validator, antiPatterns }) {
61
+ this.#llmAdapter = llmAdapter;
62
+ this.#catalog = catalog || null;
63
+ this.#patternLibrary = patternLibrary || null;
64
+ this.#validator = validator || null;
65
+ this.#antiPatterns = antiPatterns || null;
66
+ }
67
+
68
+ /**
69
+ * Analyze coverage gaps — which target patterns are missing from existing examples.
70
+ *
71
+ * @param {object[]} existingExamples — Array of { name, template } pattern objects
72
+ * @returns {{ covered: string[], missing: string[], coverage: number }}
73
+ */
74
+ analyzeCoverage(existingExamples) {
75
+ const existingNames = new Set(
76
+ (existingExamples || getAllPatterns()).map(p => p.name)
77
+ );
78
+
79
+ const covered = [];
80
+ const missing = [];
81
+
82
+ for (const target of COVERAGE_TARGETS) {
83
+ if (existingNames.has(target.id)) {
84
+ covered.push(target.id);
85
+ } else {
86
+ missing.push(target.id);
87
+ }
88
+ }
89
+
90
+ const total = COVERAGE_TARGETS.length;
91
+ const coverage = total === 0 ? 1 : covered.length / total;
92
+
93
+ return { covered, missing, coverage };
94
+ }
95
+
96
+ /**
97
+ * Generate synthetic examples for missing patterns.
98
+ *
99
+ * For each gap: builds a prompt, calls the LLM, validates, scores, and stores.
100
+ *
101
+ * @param {string[]} gaps — Pattern IDs to generate (from analyzeCoverage().missing)
102
+ * @param {object} [options]
103
+ * @param {string} [options.model] — Model override for the LLM adapter
104
+ * @param {number} [options.temperature] — Sampling temperature (default 0.7)
105
+ * @param {number} [options.maxRetries] — Max retries per pattern (default 2)
106
+ * @param {number} [options.batchSize] — Concurrent generation batch size (default 4)
107
+ * @returns {Promise<{ generated: object[], failed: string[], stats: object }>}
108
+ */
109
+ async generateExamples(gaps, options = {}) {
110
+ const opts = { ...DEFAULT_OPTIONS, ...options };
111
+ const generated = [];
112
+ const failed = [];
113
+ let totalTokens = 0;
114
+ let totalAttempts = 0;
115
+
116
+ // Process in batches
117
+ for (let i = 0; i < gaps.length; i += opts.batchSize) {
118
+ const batch = gaps.slice(i, i + opts.batchSize);
119
+
120
+ const results = await Promise.allSettled(
121
+ batch.map(gapId => this.#generateWithRetry(gapId, opts))
122
+ );
123
+
124
+ for (let j = 0; j < results.length; j++) {
125
+ const result = results[j];
126
+ const gapId = batch[j];
127
+
128
+ if (result.status === 'fulfilled' && result.value) {
129
+ generated.push(result.value);
130
+ totalTokens += result.value.tokenUsage || 0;
131
+ totalAttempts += result.value.attempts || 1;
132
+ } else {
133
+ failed.push(gapId);
134
+ totalAttempts += opts.maxRetries + 1;
135
+ }
136
+ }
137
+ }
138
+
139
+ return {
140
+ generated,
141
+ failed,
142
+ stats: {
143
+ total: gaps.length,
144
+ succeeded: generated.length,
145
+ failed: failed.length,
146
+ totalTokens,
147
+ totalAttempts,
148
+ averageQuality: generated.length > 0
149
+ ? generated.reduce((sum, g) => sum + g.quality.overall, 0) / generated.length
150
+ : 0,
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Generate a single training pair for a pattern description.
157
+ *
158
+ * @param {string} patternDescription — Natural language description of the pattern
159
+ * @returns {Promise<{ prompt: string, schema: object[], quality: object }>}
160
+ */
161
+ async generateOne(patternDescription) {
162
+ const systemPrompt = await this.#buildSystemPrompt();
163
+ const userPrompt = this.#buildUserPrompt(patternDescription);
164
+
165
+ const response = await this.#llmAdapter.complete({
166
+ messages: [{ role: 'user', content: userPrompt }],
167
+ systemPrompt,
168
+ });
169
+
170
+ const schema = this.#parseResponse(response.content);
171
+ const quality = this.scoreExample(schema);
172
+
173
+ return {
174
+ prompt: patternDescription,
175
+ schema,
176
+ quality,
177
+ tokenUsage: (response.usage?.inputTokens || 0) + (response.usage?.outputTokens || 0),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Score a generated example against quality criteria.
183
+ *
184
+ * Uses the anti-patterns checker from the intelligence system to detect
185
+ * structural issues, missing props, anti-patterns, and unnecessary wrappers.
186
+ *
187
+ * @param {object[]} schema — A2UI messages array
188
+ * @returns {{ structural: number, completeness: number, idiomatic: number, minimal: number, overall: number }}
189
+ */
190
+ scoreExample(schema) {
191
+ // Collect all components across messages
192
+ const allComponents = [];
193
+ for (const msg of schema) {
194
+ if (msg.type === 'updateComponents' && Array.isArray(msg.components)) {
195
+ allComponents.push(...msg.components);
196
+ }
197
+ }
198
+
199
+ // Run validation if available
200
+ let validationIssues = [];
201
+ if (this.#validator) {
202
+ const validation = this.#validator(schema);
203
+ validationIssues = (validation.checks || []).filter(c => !c.passed);
204
+ }
205
+
206
+ // Run anti-pattern checks (HTML-based)
207
+ // Serialize components to a minimal HTML representation for pattern matching
208
+ const html = this.#componentsToHtml(allComponents);
209
+ const antiPatternChecks = this.#antiPatterns
210
+ ? this.#antiPatterns(html)
211
+ : [];
212
+
213
+ // Structural: no orphaned children, valid message format, root exists
214
+ const structuralIssues = validationIssues.filter(c =>
215
+ ['hasRootComponent', 'noOrphanedChildren', 'validMessageFormat', 'flatAdjacency'].includes(c.name)
216
+ );
217
+ const structural = structuralIssues.length === 0 ? 1 : 0.5;
218
+
219
+ // Completeness: text content set, all types registered
220
+ const completenessIssues = validationIssues.filter(c =>
221
+ ['textContentSet', 'allTypesRegistered'].includes(c.name)
222
+ );
223
+ const completeness = Math.max(0, 1 - (completenessIssues.length * 0.1));
224
+
225
+ // Idiomatic: no anti-patterns (bare divs, inline styles, wrong nesting)
226
+ const idiomaticViolations = antiPatternChecks.filter(ap =>
227
+ ['noBareDivs', 'noBareInputs', 'cardStructure', 'noInventedComponents'].includes(ap.name)
228
+ );
229
+ const idiomatic = idiomaticViolations.length === 0 ? 1 : 0.5;
230
+
231
+ // Minimal: no unnecessary wrappers or inline layout/colors
232
+ const minimalViolations = antiPatternChecks.filter(ap =>
233
+ ['noHardcodedColors', 'noInlineLayout'].includes(ap.name)
234
+ );
235
+ const minimal = minimalViolations.length === 0 ? 1 : 0.5;
236
+
237
+ // Overall weighted average
238
+ const overall = (structural * 0.3) + (completeness * 0.25) + (idiomatic * 0.25) + (minimal * 0.2);
239
+
240
+ return { structural, completeness, idiomatic, minimal, overall };
241
+ }
242
+
243
+ // ── Private helpers ──
244
+
245
+ /**
246
+ * Generate with retry logic.
247
+ * @param {string} gapId — Target pattern ID
248
+ * @param {object} opts — Generation options
249
+ * @returns {Promise<object|null>}
250
+ */
251
+ async #generateWithRetry(gapId, opts) {
252
+ const target = COVERAGE_TARGETS.find(t => t.id === gapId);
253
+ if (!target) return null;
254
+
255
+ let lastError;
256
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
257
+ try {
258
+ const result = await this.generateOne(target.description);
259
+
260
+ // Require minimum quality threshold
261
+ if (result.quality.overall >= 0.5) {
262
+ return {
263
+ id: gapId,
264
+ ...result,
265
+ attempts: attempt + 1,
266
+ };
267
+ }
268
+
269
+ lastError = new Error(`Quality too low: ${result.quality.overall}`);
270
+ } catch (err) {
271
+ lastError = err;
272
+ }
273
+ }
274
+
275
+ throw lastError;
276
+ }
277
+
278
+ /**
279
+ * Build the system prompt with catalog, anti-patterns, and format rules.
280
+ * @returns {string}
281
+ */
282
+ async #buildSystemPrompt() {
283
+ const parts = [];
284
+
285
+ // Role
286
+ parts.push('You are an A2UI training data generator for the AdiaUI design system. Output ONLY a JSON array of A2UI messages.');
287
+
288
+ // Output format
289
+ parts.push('Output format: [{ "type": "updateComponents", "surfaceId": "default", "components": [...] }]');
290
+ parts.push('Components use flat adjacency: each has { id, component, children?: [string ids], ...props }.');
291
+ parts.push('The root component must have id "root".');
292
+
293
+ // Anti-patterns (rules)
294
+ const antiPatterns = getAntiPatterns();
295
+ if (antiPatterns.length > 0) {
296
+ const rules = antiPatterns.map(ap => `- ${ap.description}`).join('\n');
297
+ parts.push(`Rules:\n${rules}`);
298
+ }
299
+
300
+ // Available components (summary level)
301
+ const catalog = this.#catalog || await getCatalog();
302
+ const entries = catalog.entries || new Map();
303
+ if (entries.size > 0) {
304
+ const lines = [];
305
+ for (const entry of entries.values()) {
306
+ const serialized = serializeEntry(entry, 'index');
307
+ lines.push(`- ${serialized.type} (${serialized.tag}): ${serialized.description || ''}`);
308
+ }
309
+ parts.push(`Available components:\n${lines.join('\n')}`);
310
+ }
311
+
312
+ // Quality criteria
313
+ parts.push([
314
+ 'Quality criteria:',
315
+ '- Structural: valid root, all children resolve, flat adjacency list',
316
+ '- Completeness: all Text components have textContent, all types are registered',
317
+ '- Idiomatic: use Card > Header + Section + Footer, no bare divs, no invented components',
318
+ '- Minimal: no inline styles, no hardcoded colors, use semantic props and variants',
319
+ ].join('\n'));
320
+
321
+ return parts.join('\n\n');
322
+ }
323
+
324
+ /**
325
+ * Build the user prompt for a specific pattern.
326
+ * @param {string} patternDescription
327
+ * @returns {string}
328
+ */
329
+ #buildUserPrompt(patternDescription) {
330
+ return [
331
+ `Generate an A2UI component tree for this UI pattern:`,
332
+ ``,
333
+ `Pattern: ${patternDescription}`,
334
+ ``,
335
+ `Requirements:`,
336
+ `- Use realistic content (names, values, labels)`,
337
+ `- Follow Card > Header + Section + Footer anatomy where appropriate`,
338
+ `- Use layout components (Row, Column, Grid) for composition`,
339
+ `- Include all necessary props (text, variant, label, placeholder, etc.)`,
340
+ `- Output valid JSON — no markdown, no explanation`,
341
+ ].join('\n');
342
+ }
343
+
344
+ /**
345
+ * Parse an LLM response into A2UI messages.
346
+ * Handles raw JSON, markdown code fences, bare component arrays.
347
+ *
348
+ * @param {string} content — Raw LLM response
349
+ * @returns {object[]}
350
+ */
351
+ #parseResponse(content) {
352
+ if (!content || typeof content !== 'string') {
353
+ return [];
354
+ }
355
+
356
+ let json = content.trim();
357
+
358
+ // Strip markdown code fences
359
+ const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*?)```/);
360
+ if (fenceMatch) {
361
+ json = fenceMatch[1].trim();
362
+ }
363
+
364
+ try {
365
+ const parsed = JSON.parse(json);
366
+
367
+ // Array of messages
368
+ if (Array.isArray(parsed)) {
369
+ if (parsed.length > 0 && parsed[0].type === 'updateComponents') {
370
+ return parsed;
371
+ }
372
+ // Bare components array — wrap
373
+ if (parsed.length > 0 && parsed[0].id && parsed[0].component) {
374
+ return [{ type: 'updateComponents', surfaceId: 'default', components: parsed }];
375
+ }
376
+ return parsed;
377
+ }
378
+
379
+ // Single message object
380
+ if (parsed && typeof parsed === 'object' && parsed.type === 'updateComponents') {
381
+ return [parsed];
382
+ }
383
+
384
+ // Single component — wrap
385
+ if (parsed && typeof parsed === 'object' && parsed.id && parsed.component) {
386
+ return [{ type: 'updateComponents', surfaceId: 'default', components: [parsed] }];
387
+ }
388
+
389
+ return [];
390
+ } catch {
391
+ return [];
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Convert a flat component list to a minimal HTML-like string for anti-pattern checking.
397
+ * The anti-patterns module uses regex/function checks on HTML strings.
398
+ *
399
+ * @param {object[]} components
400
+ * @returns {string}
401
+ */
402
+ #componentsToHtml(components) {
403
+ const byId = new Map(components.map(c => [c.id, c]));
404
+ const lines = [];
405
+
406
+ for (const comp of components) {
407
+ const type = comp.component;
408
+ if (!type) continue;
409
+
410
+ // Map A2UI types to their AdiaUI tag names for anti-pattern checking
411
+ const tag = type.toLowerCase().replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
412
+ const tagName = tag.endsWith('-n') ? tag : `${tag}-n`;
413
+
414
+ // Build a minimal HTML representation
415
+ const attrs = [];
416
+ if (comp.style) {
417
+ attrs.push(`style="${typeof comp.style === 'object' ? Object.entries(comp.style).map(([k, v]) => `${k}:${v}`).join(';') : comp.style}"`);
418
+ }
419
+ const attrStr = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
420
+
421
+ // Represent nesting for structural checks
422
+ if (Array.isArray(comp.children)) {
423
+ const childTypes = comp.children
424
+ .map(id => byId.get(id))
425
+ .filter(Boolean)
426
+ .map(c => {
427
+ const ct = c.component?.toLowerCase().replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() || '';
428
+ return ct.endsWith('-n') ? ct : ct;
429
+ });
430
+ lines.push(`<${tagName}${attrStr}>${childTypes.map(ct => `<${ct}>`).join('')}</${tagName}>`);
431
+ } else {
432
+ lines.push(`<${tagName}${attrStr}></${tagName}>`);
433
+ }
434
+ }
435
+
436
+ return lines.join('\n');
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Get the list of coverage targets.
442
+ * @returns {Array<{ id: string, description: string, complexity: string }>}
443
+ */
444
+ export function getCoverageTargets() {
445
+ return [...COVERAGE_TARGETS];
446
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Web Research — Enriches generation context with web search results.
3
+ *
4
+ * Detects reference mentions in intents ("like Stripe", "Notion-style"),
5
+ * generates targeted search queries, fetches results, and extracts
6
+ * UI-relevant patterns to feed into the generation prompt.
7
+ *
8
+ * Works with any search function that matches:
9
+ * search(query) → Promise<{ results: { title, snippet, url }[] }>
10
+ *
11
+ * Falls back to LLM knowledge when no search function is provided.
12
+ */
13
+
14
+ // ── Reference detection ──────────────────────────────────────────────────
15
+
16
+ /** Patterns that indicate the user is referencing a specific product/design */
17
+ const REFERENCE_PATTERNS = [
18
+ /\blike\s+(\w[\w\s]*?)(?:\s*['']s|\s+style|\s+design|\s*$)/i,
19
+ /\b(\w[\w\s]*?)[\s-]style\b/i,
20
+ /\b(\w[\w\s]*?)[\s-]inspired\b/i,
21
+ /\bsimilar\s+to\s+(\w[\w\s]*?)(?:\s|$|,)/i,
22
+ /\bbased\s+on\s+(\w[\w\s]*?)(?:\s|$|,)/i,
23
+ /\bcopy\s+(\w[\w\s]*?)(?:\s|$|,)/i,
24
+ /\bclone\s+(\w[\w\s]*?)(?:\s|$|,)/i,
25
+ /\b(\w+)\s+(?:pricing|dashboard|landing|login|settings|profile)\s+page\b/i,
26
+ ];
27
+
28
+ /** Well-known product/brand names for UI reference */
29
+ const KNOWN_REFERENCES = new Set([
30
+ 'stripe', 'notion', 'linear', 'figma', 'slack', 'discord', 'spotify',
31
+ 'github', 'vercel', 'netlify', 'supabase', 'firebase', 'tailwind',
32
+ 'shadcn', 'radix', 'chakra', 'material', 'ant', 'bootstrap',
33
+ 'shopify', 'airbnb', 'uber', 'twitter', 'instagram', 'dribbble',
34
+ 'apple', 'google', 'microsoft', 'amazon', 'netflix',
35
+ ]);
36
+
37
+ /**
38
+ * Detect product/brand references in an intent.
39
+ *
40
+ * @param {string} intent
41
+ * @returns {{ references: string[], queries: string[] }}
42
+ */
43
+ export function detectReferences(intent) {
44
+ const references = [];
45
+ const lower = intent.toLowerCase();
46
+
47
+ // Pattern matching
48
+ for (const pattern of REFERENCE_PATTERNS) {
49
+ const match = intent.match(pattern);
50
+ if (match) {
51
+ let ref = (match[1] || '').trim();
52
+ // Strip leading articles
53
+ ref = ref.replace(/^(the|a|an)\s+/i, '');
54
+ if (ref && ref.length > 1 && ref.length < 30) {
55
+ references.push(ref);
56
+ }
57
+ }
58
+ }
59
+
60
+ // Direct brand name detection (only as whole words)
61
+ for (const brand of KNOWN_REFERENCES) {
62
+ const re = new RegExp(`\\b${brand}\\b`, 'i');
63
+ if (re.test(lower) && !references.some(r => r.toLowerCase() === brand)) {
64
+ references.push(brand);
65
+ }
66
+ }
67
+
68
+ // Filter out non-brand captures (e.g., "inspired" from "X-inspired")
69
+ const NON_BRANDS = new Set(['inspired', 'style', 'based', 'like', 'similar', 'the']);
70
+ for (let i = references.length - 1; i >= 0; i--) {
71
+ if (NON_BRANDS.has(references[i].toLowerCase())) references.splice(i, 1);
72
+ }
73
+
74
+ // Generate search queries from references + intent context
75
+ const queries = [];
76
+ const uniqueRefs = [...new Set(references.map(r => r.toLowerCase()))];
77
+
78
+ for (const ref of uniqueRefs.slice(0, 2)) {
79
+ // Extract the UI type from the intent
80
+ const uiType = extractUIType(intent);
81
+ if (uiType) {
82
+ queries.push(`${ref} ${uiType} UI design components`);
83
+ } else {
84
+ queries.push(`${ref} UI design pattern layout`);
85
+ }
86
+ }
87
+
88
+ // If no references but intent mentions specific UI patterns
89
+ if (queries.length === 0) {
90
+ const uiType = extractUIType(intent);
91
+ if (uiType && intent.split(/\s+/).length < 6) {
92
+ // Short intent about a UI type — search for best practices
93
+ queries.push(`best ${uiType} UI design patterns 2024`);
94
+ }
95
+ }
96
+
97
+ return { references: uniqueRefs, queries };
98
+ }
99
+
100
+ /**
101
+ * Extract the UI type/pattern from an intent (pricing, dashboard, login, etc.)
102
+ */
103
+ function extractUIType(intent) {
104
+ const lower = intent.toLowerCase();
105
+ const types = [
106
+ 'pricing', 'dashboard', 'landing page', 'login', 'signup', 'settings',
107
+ 'profile', 'onboarding', 'checkout', 'notification', 'kanban', 'calendar',
108
+ 'chat', 'sidebar', 'navbar', 'hero', 'footer', 'table', 'form',
109
+ 'modal', 'card', 'timeline', 'analytics', 'admin',
110
+ ];
111
+ return types.find(t => lower.includes(t)) || null;
112
+ }
113
+
114
+ /**
115
+ * Research UI patterns for an intent using web search.
116
+ *
117
+ * @param {string} intent — User's generation intent
118
+ * @param {object} [options]
119
+ * @param {(query: string) => Promise<{ results: { title: string, snippet: string, url: string }[] }>} [options.search] — Search function
120
+ * @param {object} [options.llmAdapter] — LLM adapter for summarization
121
+ * @returns {Promise<{ references: string[], insights: string[], searchResults: object[], context: string }>}
122
+ */
123
+ export async function researchIntent(intent, options = {}) {
124
+ const { search, llmAdapter } = options;
125
+ const { references, queries } = detectReferences(intent);
126
+
127
+ // No references and no search → return empty
128
+ if (references.length === 0 && queries.length === 0) {
129
+ return { references: [], insights: [], searchResults: [], context: '' };
130
+ }
131
+
132
+ // ── Fetch search results ──
133
+ const searchResults = [];
134
+ if (search && queries.length > 0) {
135
+ for (const query of queries.slice(0, 2)) {
136
+ try {
137
+ const result = await search(query);
138
+ if (result?.results?.length) {
139
+ searchResults.push(...result.results.slice(0, 3));
140
+ }
141
+ } catch {
142
+ // Search failed — continue without it
143
+ }
144
+ }
145
+ }
146
+
147
+ // ── Build context from results ──
148
+ const insights = [];
149
+
150
+ if (searchResults.length > 0) {
151
+ // Extract UI-relevant snippets
152
+ for (const result of searchResults) {
153
+ if (result.snippet) {
154
+ insights.push(`${result.title}: ${result.snippet}`);
155
+ }
156
+ }
157
+ }
158
+
159
+ // ── LLM-powered reference description (when search has no results) ──
160
+ if (insights.length === 0 && llmAdapter && references.length > 0) {
161
+ try {
162
+ const refList = references.join(', ');
163
+ const uiType = extractUIType(intent) || 'UI';
164
+ const response = await llmAdapter.complete({
165
+ messages: [{ role: 'user', content: `Briefly describe the ${uiType} design of ${refList}. Focus on: layout structure, key components, visual hierarchy, and interaction patterns. 3-4 sentences max.` }],
166
+ systemPrompt: 'You are a UI design expert. Give concise, factual descriptions of well-known product UIs. Focus on structure and components, not opinions.',
167
+ });
168
+ if (response.content) {
169
+ insights.push(response.content);
170
+ }
171
+ } catch {
172
+ // LLM failed — continue without it
173
+ }
174
+ }
175
+
176
+ // ── Compile context string for the generation prompt ──
177
+ let context = '';
178
+ if (references.length > 0) {
179
+ context += `Reference designs: ${references.join(', ')}\n`;
180
+ }
181
+ if (insights.length > 0) {
182
+ context += `Design insights:\n${insights.map(i => `- ${i}`).join('\n')}`;
183
+ }
184
+
185
+ return { references, insights, searchResults, context };
186
+ }