@adia-ai/a2ui-retrieval 0.6.1 → 0.6.2

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,267 @@
1
+ /**
2
+ * Intent Alignment Verification — checks whether generated output
3
+ * actually contains what the user asked for.
4
+ *
5
+ * Extracts expectations from the intent (field labels, component types,
6
+ * quantities, action verbs) and verifies them against the A2UI output.
7
+ *
8
+ * Returns a structured alignment report with per-expectation pass/fail,
9
+ * an overall alignment score (0-1), and specific gaps.
10
+ */
11
+
12
+ // ── Expectation Extractors ──────────────────────────────────────────────
13
+
14
+ /** Field/label expectations — "email", "password", "name", etc. */
15
+ const FIELD_PATTERNS = [
16
+ /\b(email|password|username|name|phone|address|city|state|zip|country|company|title|role|bio|url|website|date|time|description|message|subject|amount|price|quantity|search|notes?)\b/gi,
17
+ ];
18
+
19
+ /** Component type expectations — "button", "form", "table", etc. */
20
+ const COMPONENT_PATTERNS = [
21
+ /\b(button|form|table|chart|card|avatar|badge|alert|modal|drawer|tabs?|sidebar|navbar|breadcrumb|pagination|progress|slider|toggle|checkbox|radio|dropdown|select|upload|calendar|timeline|steps?|accordion)\b/gi,
22
+ ];
23
+
24
+ /** Quantity expectations — "3 cards", "two columns", etc. */
25
+ const QUANTITY_MAP: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
26
+ const QUANTITY_PATTERN = /\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(cards?|columns?|items?|fields?|buttons?|sections?|rows?|tiles?|steps?|tabs?|metrics?|stats?)\b/gi;
27
+
28
+ /** Action expectations — "submit", "cancel", "delete", etc. */
29
+ const ACTION_PATTERNS = [
30
+ /\b(submit|cancel|save|delete|edit|close|open|search|filter|sort|login|signup|register|checkout|confirm|reset|send|upload|download|share|copy|print)\b/gi,
31
+ ];
32
+
33
+ /** Specific content expectations — quoted strings, dollar amounts, percentages */
34
+ const CONTENT_PATTERNS = [
35
+ /["']([^"']+)["']/g, // quoted strings
36
+ /\$[\d,.]+/g, // dollar amounts
37
+ /\d+%/g, // percentages
38
+ ];
39
+
40
+ export type QuantityExpectation = { count: number; type: string };
41
+
42
+ export type Expectations = {
43
+ fields: string[];
44
+ componentTypes: string[];
45
+ quantities: QuantityExpectation[];
46
+ actions: string[];
47
+ content: string[];
48
+ };
49
+
50
+ export type AlignmentCheck = {
51
+ category: string;
52
+ expected: string;
53
+ found: boolean;
54
+ detail: string;
55
+ };
56
+
57
+ export type AlignmentResult = {
58
+ score: number;
59
+ checks: AlignmentCheck[];
60
+ gaps: string[];
61
+ };
62
+
63
+ /**
64
+ * Extract expectations from a natural language intent.
65
+ */
66
+ export function extractExpectations(intent: string): Expectations {
67
+ const lower = intent.toLowerCase();
68
+
69
+ // Fields
70
+ const fields: string[] = [];
71
+ for (const pattern of FIELD_PATTERNS) {
72
+ pattern.lastIndex = 0;
73
+ let match;
74
+ while ((match = pattern.exec(lower))) {
75
+ const f = match[1].toLowerCase();
76
+ if (!fields.includes(f)) fields.push(f);
77
+ }
78
+ }
79
+
80
+ // Component types
81
+ const componentTypes: string[] = [];
82
+ for (const pattern of COMPONENT_PATTERNS) {
83
+ pattern.lastIndex = 0;
84
+ let match;
85
+ while ((match = pattern.exec(lower))) {
86
+ const t = match[1].toLowerCase();
87
+ if (!componentTypes.includes(t)) componentTypes.push(t);
88
+ }
89
+ }
90
+
91
+ // Quantities
92
+ const quantities: QuantityExpectation[] = [];
93
+ QUANTITY_PATTERN.lastIndex = 0;
94
+ let qMatch;
95
+ while ((qMatch = QUANTITY_PATTERN.exec(lower))) {
96
+ const raw = qMatch[1];
97
+ const num = QUANTITY_MAP[raw] ?? parseInt(raw, 10);
98
+ const type = qMatch[2].replace(/s$/, ''); // singularize
99
+ if (!isNaN(num)) quantities.push({ count: num, type });
100
+ }
101
+
102
+ // Actions
103
+ const actions: string[] = [];
104
+ for (const pattern of ACTION_PATTERNS) {
105
+ pattern.lastIndex = 0;
106
+ let match;
107
+ while ((match = pattern.exec(lower))) {
108
+ const a = match[1].toLowerCase();
109
+ if (!actions.includes(a)) actions.push(a);
110
+ }
111
+ }
112
+
113
+ // Specific content
114
+ const content: string[] = [];
115
+ for (const pattern of CONTENT_PATTERNS) {
116
+ pattern.lastIndex = 0;
117
+ let match;
118
+ while ((match = pattern.exec(intent))) {
119
+ content.push(match[1] ?? match[0]);
120
+ }
121
+ }
122
+
123
+ return { fields, componentTypes, quantities, actions, content };
124
+ }
125
+
126
+ // ── A2UI Type Mapping ───────────────────────────────────────────────────
127
+
128
+ /** Map intent keywords to A2UI component types */
129
+ const TYPE_MAP: Record<string, string[]> = {
130
+ button: ['Button'],
131
+ form: ['FormContainer', 'TextField', 'Column'],
132
+ table: ['Table'],
133
+ chart: ['Chart'],
134
+ card: ['Card'],
135
+ avatar: ['Avatar'],
136
+ badge: ['Badge'],
137
+ alert: ['Alert'],
138
+ modal: ['Modal', 'Dialog'],
139
+ drawer: ['Drawer'],
140
+ tab: ['Tabs', 'Tab'],
141
+ sidebar: ['Sidebar'],
142
+ navbar: ['Nav'],
143
+ breadcrumb: ['Breadcrumb'],
144
+ pagination: ['Pagination'],
145
+ progress: ['Progress'],
146
+ slider: ['Slider'],
147
+ toggle: ['Toggle'],
148
+ checkbox: ['CheckBox'],
149
+ radio: ['Radio'],
150
+ dropdown: ['ChoicePicker', 'Select'],
151
+ select: ['ChoicePicker', 'Select'],
152
+ upload: ['Upload'],
153
+ calendar: ['CalendarPicker', 'DateTimeInput'],
154
+ timeline: ['Timeline'],
155
+ step: ['Steps'],
156
+ accordion: ['Accordion'],
157
+ };
158
+
159
+ type ComponentRecord = {
160
+ component?: string;
161
+ textContent?: string;
162
+ text?: string;
163
+ label?: string;
164
+ placeholder?: string;
165
+ description?: string;
166
+ name?: string;
167
+ [key: string]: unknown;
168
+ };
169
+
170
+ /**
171
+ * Verify generated A2UI output against extracted expectations.
172
+ */
173
+ export function verifyAlignment(components: ComponentRecord[], expectations: Expectations): AlignmentResult {
174
+ const checks: AlignmentCheck[] = [];
175
+ const gaps: string[] = [];
176
+
177
+ // Collect all text content and properties from components
178
+ const allText = components.map(c => {
179
+ const texts = [c['textContent'], c['text'], c['label'], c['placeholder'], c['description'], c['name']];
180
+ return texts.filter(Boolean).join(' ').toLowerCase();
181
+ }).join(' ');
182
+
183
+ const allTypes = new Set(components.map(c => c.component));
184
+
185
+ // ── Check fields ──
186
+ for (const field of expectations.fields) {
187
+ const found = allText.includes(field) ||
188
+ components.some(c => (c['label'] ?? '').toLowerCase().includes(field) ||
189
+ (c['placeholder'] ?? '').toLowerCase().includes(field) ||
190
+ (c['name'] ?? '').toLowerCase().includes(field));
191
+ checks.push({
192
+ category: 'field',
193
+ expected: field,
194
+ found,
195
+ detail: found ? `Found "${field}" in component properties` : `Missing field: "${field}"`,
196
+ });
197
+ if (!found) gaps.push(`Missing field "${field}"`);
198
+ }
199
+
200
+ // ── Check component types ──
201
+ for (const type of expectations.componentTypes) {
202
+ const mappedTypes = TYPE_MAP[type] ?? [type.charAt(0).toUpperCase() + type.slice(1)];
203
+ const found = mappedTypes.some(t => allTypes.has(t));
204
+ checks.push({
205
+ category: 'componentType',
206
+ expected: type,
207
+ found,
208
+ detail: found ? `Found ${type} component` : `Missing component type: ${type}`,
209
+ });
210
+ if (!found) gaps.push(`Missing ${type} component`);
211
+ }
212
+
213
+ // ── Check quantities ──
214
+ for (const { count, type } of expectations.quantities) {
215
+ const mappedTypes = TYPE_MAP[type] ?? [type.charAt(0).toUpperCase() + type.slice(1)];
216
+ const actual = components.filter(c => mappedTypes.some(t => c.component === t)).length;
217
+ const found = actual >= count;
218
+ checks.push({
219
+ category: 'quantity',
220
+ expected: `${count} ${type}(s)`,
221
+ found,
222
+ detail: found ? `Found ${actual} ${type}(s) (expected ${count})` : `Only ${actual} ${type}(s), expected ${count}`,
223
+ });
224
+ if (!found) gaps.push(`Expected ${count} ${type}(s), found ${actual}`);
225
+ }
226
+
227
+ // ── Check actions ──
228
+ for (const action of expectations.actions) {
229
+ const found = allText.includes(action) ||
230
+ components.some(c => c.component === 'Button' && (c['text'] ?? '').toLowerCase().includes(action));
231
+ checks.push({
232
+ category: 'action',
233
+ expected: action,
234
+ found,
235
+ detail: found ? `Found "${action}" action` : `Missing action: "${action}"`,
236
+ });
237
+ if (!found) gaps.push(`Missing "${action}" action`);
238
+ }
239
+
240
+ // ── Check specific content ──
241
+ for (const text of expectations.content) {
242
+ const found = allText.includes(text.toLowerCase());
243
+ checks.push({
244
+ category: 'content',
245
+ expected: text,
246
+ found,
247
+ detail: found ? `Found content "${text}"` : `Missing content: "${text}"`,
248
+ });
249
+ if (!found) gaps.push(`Missing content "${text}"`);
250
+ }
251
+
252
+ // ── Score ──
253
+ const total = checks.length;
254
+ const passed = checks.filter(c => c.found).length;
255
+ const score = total > 0 ? Math.round((passed / total) * 100) / 100 : 1;
256
+
257
+ return { score, checks, gaps };
258
+ }
259
+
260
+ /**
261
+ * Full intent alignment check — extract + verify in one call.
262
+ */
263
+ export function checkIntentAlignment(intent: string, components: ComponentRecord[]): AlignmentResult & { expectations: Expectations } {
264
+ const expectations = extractExpectations(intent);
265
+ const result = verifyAlignment(components, expectations);
266
+ return { ...result, expectations };
267
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Intent Categorizer
3
+ *
4
+ * Maps free-text intents to a taxonomy of UI categories using keyword matching.
5
+ * Used by the feedback analyzer to aggregate metrics per intent type.
6
+ */
7
+
8
+ type CategoryRule = {
9
+ category: string;
10
+ keywords: string[];
11
+ };
12
+
13
+ const CATEGORY_RULES: CategoryRule[] = [
14
+ // Form categories
15
+ { category: 'form/login', keywords: ['login', 'sign in', 'signin', 'log in', 'authentication'] },
16
+ { category: 'form/signup', keywords: ['signup', 'sign up', 'register', 'registration', 'create account', 'onboarding'] },
17
+ { category: 'form/contact', keywords: ['contact', 'contact us', 'reach out', 'get in touch', 'enquiry', 'inquiry'] },
18
+ { category: 'form/settings', keywords: ['settings', 'preferences', 'config', 'configuration', 'account settings', 'profile edit'] },
19
+ { category: 'form/checkout', keywords: ['checkout', 'check out', 'payment', 'billing', 'purchase'] },
20
+
21
+ // Data categories
22
+ { category: 'data/table', keywords: ['table', 'data table', 'spreadsheet', 'grid view', 'list view', 'datagrid'] },
23
+ { category: 'data/dashboard', keywords: ['dashboard', 'kpi', 'metrics', 'analytics', 'overview', 'summary panel', 'stats'] },
24
+ { category: 'data/chart', keywords: ['chart', 'graph', 'visualization', 'pie chart', 'bar chart', 'line chart', 'histogram'] },
25
+
26
+ // Layout categories
27
+ { category: 'layout/landing', keywords: ['landing', 'landing page', 'homepage', 'hero', 'splash'] },
28
+ { category: 'layout/profile', keywords: ['profile', 'user profile', 'avatar', 'bio', 'about me'] },
29
+ { category: 'layout/pricing', keywords: ['pricing', 'pricing table', 'plans', 'subscription', 'tier'] },
30
+
31
+ // Navigation categories
32
+ { category: 'nav/sidebar', keywords: ['sidebar', 'side nav', 'navigation menu', 'drawer', 'side panel'] },
33
+ { category: 'nav/tabs', keywords: ['tabs', 'tab bar', 'tabbed', 'tab navigation', 'tab panel'] },
34
+
35
+ // Agent categories
36
+ { category: 'agent/chat', keywords: ['chat', 'chatbot', 'messenger', 'conversation', 'messaging', 'chat interface'] },
37
+ { category: 'agent/notification', keywords: ['notification', 'alert', 'toast', 'snackbar', 'banner', 'announcement'] },
38
+
39
+ // Content categories
40
+ { category: 'content/blog', keywords: ['blog', 'article', 'post', 'news', 'editorial', 'content feed'] },
41
+ { category: 'content/faq', keywords: ['faq', 'frequently asked', 'questions', 'help center', 'knowledge base', 'accordion'] },
42
+
43
+ // Commerce categories
44
+ { category: 'commerce/product', keywords: ['product', 'product card', 'product page', 'product detail', 'item detail', 'catalog'] },
45
+ { category: 'commerce/cart', keywords: ['cart', 'shopping cart', 'basket', 'bag'] },
46
+ { category: 'commerce/order', keywords: ['order', 'order history', 'order summary', 'receipt', 'invoice', 'order tracking'] },
47
+
48
+ // Workflow categories
49
+ { category: 'workflow/wizard', keywords: ['wizard', 'stepper', 'multi-step', 'step form', 'onboard flow', 'setup wizard'] },
50
+ { category: 'workflow/kanban', keywords: ['kanban', 'board', 'task board', 'trello', 'project board', 'drag and drop'] },
51
+
52
+ // Status categories
53
+ { category: 'status/error', keywords: ['error', 'error page', '404', '500', 'not found', 'something went wrong'] },
54
+ { category: 'status/empty', keywords: ['empty state', 'no results', 'no data', 'zero state', 'blank slate'] },
55
+ { category: 'status/loading', keywords: ['loading', 'skeleton', 'spinner', 'progress', 'placeholder'] },
56
+ ];
57
+
58
+ export type IntentCategory = {
59
+ category: string;
60
+ confidence: number;
61
+ };
62
+
63
+ /**
64
+ * Categorize a free-text intent into a UI taxonomy category.
65
+ */
66
+ export function categorizeIntent(intent: string): IntentCategory {
67
+ if (!intent || typeof intent !== 'string') {
68
+ return { category: 'other', confidence: 0 };
69
+ }
70
+
71
+ const lower = intent.toLowerCase().trim();
72
+ let bestCategory = 'other';
73
+ let bestScore = 0;
74
+
75
+ for (const rule of CATEGORY_RULES) {
76
+ let matchCount = 0;
77
+ let longestMatch = 0;
78
+
79
+ for (const kw of rule.keywords) {
80
+ if (lower.includes(kw)) {
81
+ matchCount++;
82
+ longestMatch = Math.max(longestMatch, kw.length);
83
+ }
84
+ }
85
+
86
+ if (matchCount === 0) continue;
87
+
88
+ // Score: multi-word keyword matches get higher confidence,
89
+ // more matches within a category = higher confidence
90
+ const kwLenBonus = longestMatch / lower.length; // longer keyword relative to intent = more specific
91
+ const multiMatchBonus = Math.min(matchCount * 0.15, 0.3);
92
+ const score = 0.5 + kwLenBonus * 0.3 + multiMatchBonus;
93
+
94
+ if (score > bestScore) {
95
+ bestScore = score;
96
+ bestCategory = rule.category;
97
+ }
98
+ }
99
+
100
+ return {
101
+ category: bestCategory,
102
+ confidence: Math.min(Math.round(bestScore * 100) / 100, 1),
103
+ };
104
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Intent Gate — Detect whether a user message is conversational (question,
3
+ * greeting, meta-question) vs. a UI generation request.
4
+ *
5
+ * Used by the chat setup to route non-generation messages to a text reply
6
+ * instead of the full 6-stage pipeline.
7
+ */
8
+
9
+ /** Question starters that signal conversational intent */
10
+ const QUESTION_STARTERS = /^\s*(what|how|why|can you|could you|tell me|explain|describe|help|who|where|when|is there|are there|do you|does|did|will|should|would|which|list|show me how)\b/i;
11
+
12
+ /** Greetings and social signals */
13
+ const GREETINGS = /^\s*(hi|hey|hello|thanks|thank you|bye|goodbye|ok|okay|sure|great|nice|cool|awesome|good|perfect)\b/i;
14
+
15
+ /** Explicit meta-questions about the system itself */
16
+ const META_PATTERNS = [
17
+ /what\s+(components?|types?|elements?)\s+(are|do|can)/i,
18
+ /available\s+(components?|types?|elements?)/i,
19
+ /how\s+(does|do)\s+(this|it|the system|gen ui|a2ui)/i,
20
+ /what\s+can\s+(you|this|it)\s+(do|generate|create|build)/i,
21
+ /help\s+me\s+understand/i,
22
+ /list\s+(of\s+)?(components?|types?|elements?)/i,
23
+ /what\s+(is|are)\s+(a2ui|agentui|agent ui|gen ui)/i,
24
+ /show\s+me\s+(the\s+)?(components?|options?|catalog)/i,
25
+ /explain\s+(the|how|what|a)/i,
26
+ /how\s+(to|do\s+I)\s+(use|create|build|make)/i,
27
+ /what\s+would\s+.+\s+look\s+like/i,
28
+ /show\s+me\s+how/i,
29
+ ];
30
+
31
+ /** Content that is clearly not a UI intent (injection, noise, gibberish) */
32
+ const NOISE_PATTERNS = [
33
+ /^<[^>]+>/, // Starts with HTML tag
34
+ /^\s*[^\w\s]{3,}/, // Starts with 3+ non-word chars
35
+ ];
36
+
37
+ /** Vague, motivational, or abstract phrases with no UI-describing language.
38
+ * These are common conversational inputs that the LLM hallucinates UIs from. */
39
+ const VAGUE_PATTERNS = [
40
+ /^(do|just|go|let'?s|get|keep|stay|stop|try|move|make it|finish|start|begin|end|run)\b.{0,30}$/i, // short imperative without UI nouns
41
+ /\b(you gotta|gotta do|no excuses|believe|motivat|inspir|goal|dream|life|love|hate|feel|think about)\b/i,
42
+ /\b(what is preventing|what stops|why can't|why won't)\b/i, // philosophical/motivational questions
43
+ /\b(do it|get it done|let'?s go|make it happen|just do it|never give up)\b/i, // motivational slogans
44
+ ];
45
+
46
+ /** Tool-use and action-oriented intents that should be handled conversationally,
47
+ * not as UI generation requests. These are commands directed at the system. */
48
+ const TOOL_USE_PATTERNS = [
49
+ /\b(create|file|write|open|submit|log)\s+(a\s+)?(ticket|bug|issue|report|feature request)/i,
50
+ /\b(report|flag)\s+(a\s+)?(bug|issue|problem|error|regression)/i,
51
+ /\b(why|how come)\s+(does|did|is|was|are)\b/i, // "why does X do Y" — asking for rationale
52
+ /\bthis\s+(is|looks?)\s+(broken|wrong|buggy|off|weird|strange)/i,
53
+ /\b(fix|repair|debug|investigate)\s+(this|the|that)/i,
54
+ /\bsomething\s+(is|seems|looks?)\s+(wrong|broken|off)/i,
55
+ /\bmark\s+(this|it)\s+as\b/i,
56
+ /\b(save|bookmark|pin)\s+(this|the)\s+(pattern|output|result)/i,
57
+ ];
58
+
59
+ export type IntentGateResult = {
60
+ conversational: boolean;
61
+ reason: string;
62
+ };
63
+
64
+ type ClassifyResult = {
65
+ confidence: number;
66
+ matchedSignals?: string[];
67
+ };
68
+
69
+ /**
70
+ * Determine if a user message is conversational (should get a text reply)
71
+ * or a UI generation request (should go through the pipeline).
72
+ */
73
+ export function isConversational(text: string, classifyResult: ClassifyResult | null | undefined): IntentGateResult {
74
+ const trimmed = (text ?? '').trim();
75
+ let cls: ClassifyResult = classifyResult && typeof classifyResult === 'object'
76
+ ? classifyResult
77
+ : { confidence: 0, matchedSignals: [] };
78
+
79
+ // Very short messages without generation keywords are likely conversational
80
+ if (trimmed.length < 5 && cls.confidence === 0) {
81
+ return { conversational: true, reason: 'short-input' };
82
+ }
83
+
84
+ // Noise/injection detection — not a real intent
85
+ for (const pattern of NOISE_PATTERNS) {
86
+ if (pattern.test(trimmed) && cls.confidence === 0) {
87
+ return { conversational: true, reason: 'noise-input' };
88
+ }
89
+ }
90
+
91
+ // Explicit meta-questions about the system → always conversational
92
+ for (const pattern of META_PATTERNS) {
93
+ if (pattern.test(trimmed)) {
94
+ return { conversational: true, reason: 'meta-question' };
95
+ }
96
+ }
97
+
98
+ // Tool-use commands (create ticket, report bug, etc.) → conversational
99
+ for (const pattern of TOOL_USE_PATTERNS) {
100
+ if (pattern.test(trimmed)) {
101
+ return { conversational: true, reason: 'tool-use' };
102
+ }
103
+ }
104
+
105
+ // Vague / motivational / abstract input with no or weak domain signals
106
+ if (cls.confidence < 0.2) {
107
+ for (const pattern of VAGUE_PATTERNS) {
108
+ if (pattern.test(trimmed)) {
109
+ return { conversational: true, reason: 'vague-or-abstract' };
110
+ }
111
+ }
112
+ }
113
+
114
+ // Greetings with no generation signals
115
+ if (GREETINGS.test(trimmed) && cls.confidence === 0) {
116
+ return { conversational: true, reason: 'greeting' };
117
+ }
118
+
119
+ // High confidence in a domain → generation request
120
+ if (cls.confidence >= 0.3) {
121
+ return { conversational: false, reason: 'high-domain-confidence' };
122
+ }
123
+
124
+ // Has matched at least 2 domain signals → generation
125
+ if ((cls.matchedSignals?.length ?? 0) >= 2) {
126
+ return { conversational: false, reason: 'multiple-domain-signals' };
127
+ }
128
+
129
+ // Question pattern with single low-confidence signal → conversational
130
+ if (QUESTION_STARTERS.test(trimmed) && (cls.matchedSignals?.length ?? 0) <= 1) {
131
+ return { conversational: true, reason: 'question-single-signal' };
132
+ }
133
+
134
+ // Greetings even with some confidence → still conversational
135
+ if (GREETINGS.test(trimmed) && cls.confidence < 0.2) {
136
+ return { conversational: true, reason: 'greeting-low-confidence' };
137
+ }
138
+
139
+ // Default: if there's any domain signal at all, treat as generation
140
+ if (cls.confidence > 0) {
141
+ return { conversational: false, reason: 'has-domain-signal' };
142
+ }
143
+
144
+ // No signals — check for question marks or question patterns
145
+ if (trimmed.endsWith('?')) {
146
+ return { conversational: true, reason: 'ends-with-question-mark' };
147
+ }
148
+
149
+ // No domain signals at all — route to conversational for clarification
150
+ return { conversational: true, reason: 'no-ui-signals' };
151
+ }