handhold-sdk 1.0.0
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/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - Action Verb Map
|
|
3
|
+
* Maps common button/link text to canonical action verbs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ACTION_VERB_MAP = {
|
|
7
|
+
// Approval actions
|
|
8
|
+
'approve': 'approve',
|
|
9
|
+
'confirm': 'confirm',
|
|
10
|
+
'accept': 'approve',
|
|
11
|
+
'authorize': 'approve',
|
|
12
|
+
'validate': 'approve',
|
|
13
|
+
'verify': 'approve',
|
|
14
|
+
'ok': 'confirm',
|
|
15
|
+
'yes': 'confirm',
|
|
16
|
+
|
|
17
|
+
// Creation actions
|
|
18
|
+
'create': 'create',
|
|
19
|
+
'add': 'create',
|
|
20
|
+
'new': 'create',
|
|
21
|
+
'insert': 'create',
|
|
22
|
+
'plus': 'create',
|
|
23
|
+
'+': 'create',
|
|
24
|
+
|
|
25
|
+
// Submission actions
|
|
26
|
+
'submit': 'submit',
|
|
27
|
+
'send': 'submit',
|
|
28
|
+
'post': 'submit',
|
|
29
|
+
'publish': 'submit',
|
|
30
|
+
'apply': 'submit',
|
|
31
|
+
|
|
32
|
+
// Save actions
|
|
33
|
+
'save': 'save',
|
|
34
|
+
'store': 'save',
|
|
35
|
+
'keep': 'save',
|
|
36
|
+
'preserve': 'save',
|
|
37
|
+
|
|
38
|
+
// Update actions
|
|
39
|
+
'update': 'update',
|
|
40
|
+
'edit': 'edit',
|
|
41
|
+
'modify': 'edit',
|
|
42
|
+
'change': 'edit',
|
|
43
|
+
'revise': 'edit',
|
|
44
|
+
|
|
45
|
+
// Delete actions
|
|
46
|
+
'delete': 'delete',
|
|
47
|
+
'remove': 'delete',
|
|
48
|
+
'trash': 'delete',
|
|
49
|
+
'discard': 'delete',
|
|
50
|
+
'clear': 'delete',
|
|
51
|
+
'erase': 'delete',
|
|
52
|
+
|
|
53
|
+
// Download/Export actions
|
|
54
|
+
'download': 'download',
|
|
55
|
+
'export': 'export',
|
|
56
|
+
'extract': 'export',
|
|
57
|
+
'get': 'download',
|
|
58
|
+
|
|
59
|
+
// Upload/Import actions
|
|
60
|
+
'upload': 'upload',
|
|
61
|
+
'import': 'import',
|
|
62
|
+
'load': 'import',
|
|
63
|
+
|
|
64
|
+
// Execution actions
|
|
65
|
+
'run': 'run',
|
|
66
|
+
'execute': 'run',
|
|
67
|
+
'process': 'run',
|
|
68
|
+
'start': 'run',
|
|
69
|
+
'begin': 'run',
|
|
70
|
+
'launch': 'run',
|
|
71
|
+
'trigger': 'run',
|
|
72
|
+
|
|
73
|
+
// View actions
|
|
74
|
+
'view': 'view',
|
|
75
|
+
'see': 'view',
|
|
76
|
+
'show': 'view',
|
|
77
|
+
'display': 'view',
|
|
78
|
+
'open': 'view',
|
|
79
|
+
'preview': 'view',
|
|
80
|
+
'look': 'view',
|
|
81
|
+
|
|
82
|
+
// Search actions
|
|
83
|
+
'search': 'search',
|
|
84
|
+
'find': 'search',
|
|
85
|
+
'lookup': 'search',
|
|
86
|
+
'query': 'search',
|
|
87
|
+
|
|
88
|
+
// Filter actions
|
|
89
|
+
'filter': 'filter',
|
|
90
|
+
'sort': 'filter',
|
|
91
|
+
'refine': 'filter',
|
|
92
|
+
|
|
93
|
+
// Cancel actions
|
|
94
|
+
'cancel': 'cancel',
|
|
95
|
+
'abort': 'cancel',
|
|
96
|
+
'stop': 'cancel',
|
|
97
|
+
'close': 'cancel',
|
|
98
|
+
'dismiss': 'cancel',
|
|
99
|
+
'back': 'cancel',
|
|
100
|
+
'exit': 'cancel',
|
|
101
|
+
'no': 'cancel',
|
|
102
|
+
|
|
103
|
+
// Print actions
|
|
104
|
+
'print': 'print',
|
|
105
|
+
|
|
106
|
+
// Review actions
|
|
107
|
+
'review': 'review',
|
|
108
|
+
'check': 'review',
|
|
109
|
+
'inspect': 'review',
|
|
110
|
+
'examine': 'review',
|
|
111
|
+
|
|
112
|
+
// Generate actions
|
|
113
|
+
'generate': 'generate',
|
|
114
|
+
'produce': 'generate',
|
|
115
|
+
'build': 'generate',
|
|
116
|
+
'make': 'generate',
|
|
117
|
+
|
|
118
|
+
// Copy actions
|
|
119
|
+
'copy': 'copy',
|
|
120
|
+
'duplicate': 'copy',
|
|
121
|
+
'clone': 'copy',
|
|
122
|
+
|
|
123
|
+
// Share actions
|
|
124
|
+
'share': 'share',
|
|
125
|
+
'invite': 'share',
|
|
126
|
+
|
|
127
|
+
// Refresh actions
|
|
128
|
+
'refresh': 'refresh',
|
|
129
|
+
'reload': 'refresh',
|
|
130
|
+
'sync': 'refresh',
|
|
131
|
+
|
|
132
|
+
// Navigate actions
|
|
133
|
+
'navigate': 'navigate',
|
|
134
|
+
'go': 'navigate',
|
|
135
|
+
'next': 'navigate',
|
|
136
|
+
'previous': 'navigate',
|
|
137
|
+
'prev': 'navigate',
|
|
138
|
+
'continue': 'navigate',
|
|
139
|
+
|
|
140
|
+
// Help actions
|
|
141
|
+
'help': 'help',
|
|
142
|
+
'support': 'help',
|
|
143
|
+
'assist': 'help',
|
|
144
|
+
|
|
145
|
+
// Login/Logout actions
|
|
146
|
+
'login': 'login',
|
|
147
|
+
'signin': 'login',
|
|
148
|
+
'sign-in': 'login',
|
|
149
|
+
'logout': 'logout',
|
|
150
|
+
'signout': 'logout',
|
|
151
|
+
'sign-out': 'logout',
|
|
152
|
+
|
|
153
|
+
// Enable/Disable actions
|
|
154
|
+
'enable': 'enable',
|
|
155
|
+
'activate': 'enable',
|
|
156
|
+
'disable': 'disable',
|
|
157
|
+
'deactivate': 'disable',
|
|
158
|
+
|
|
159
|
+
// Connect/Disconnect actions
|
|
160
|
+
'connect': 'connect',
|
|
161
|
+
'link': 'connect',
|
|
162
|
+
'disconnect': 'disconnect',
|
|
163
|
+
'unlink': 'disconnect',
|
|
164
|
+
|
|
165
|
+
// Schedule actions
|
|
166
|
+
'schedule': 'schedule',
|
|
167
|
+
'plan': 'schedule',
|
|
168
|
+
'book': 'schedule',
|
|
169
|
+
'reserve': 'schedule',
|
|
170
|
+
|
|
171
|
+
// Assign actions
|
|
172
|
+
'assign': 'assign',
|
|
173
|
+
'delegate': 'assign',
|
|
174
|
+
'transfer': 'assign',
|
|
175
|
+
|
|
176
|
+
// Archive actions
|
|
177
|
+
'archive': 'archive',
|
|
178
|
+
'store': 'archive',
|
|
179
|
+
|
|
180
|
+
// Restore actions
|
|
181
|
+
'restore': 'restore',
|
|
182
|
+
'recover': 'restore',
|
|
183
|
+
'undo': 'restore',
|
|
184
|
+
|
|
185
|
+
// Compare actions
|
|
186
|
+
'compare': 'compare',
|
|
187
|
+
'diff': 'compare',
|
|
188
|
+
|
|
189
|
+
// Select actions
|
|
190
|
+
'select': 'select',
|
|
191
|
+
'choose': 'select',
|
|
192
|
+
'pick': 'select',
|
|
193
|
+
|
|
194
|
+
// Pay/Purchase actions
|
|
195
|
+
'pay': 'pay',
|
|
196
|
+
'purchase': 'pay',
|
|
197
|
+
'buy': 'pay',
|
|
198
|
+
'checkout': 'pay',
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Reverse map for label generation
|
|
202
|
+
const VERB_LABELS = {
|
|
203
|
+
'approve': 'Approve',
|
|
204
|
+
'confirm': 'Confirm',
|
|
205
|
+
'create': 'Create',
|
|
206
|
+
'submit': 'Submit',
|
|
207
|
+
'save': 'Save',
|
|
208
|
+
'update': 'Update',
|
|
209
|
+
'edit': 'Edit',
|
|
210
|
+
'delete': 'Delete',
|
|
211
|
+
'download': 'Download',
|
|
212
|
+
'export': 'Export',
|
|
213
|
+
'upload': 'Upload',
|
|
214
|
+
'import': 'Import',
|
|
215
|
+
'run': 'Run',
|
|
216
|
+
'view': 'View',
|
|
217
|
+
'search': 'Search',
|
|
218
|
+
'filter': 'Filter',
|
|
219
|
+
'cancel': 'Cancel',
|
|
220
|
+
'print': 'Print',
|
|
221
|
+
'review': 'Review',
|
|
222
|
+
'generate': 'Generate',
|
|
223
|
+
'copy': 'Copy',
|
|
224
|
+
'share': 'Share',
|
|
225
|
+
'refresh': 'Refresh',
|
|
226
|
+
'navigate': 'Navigate',
|
|
227
|
+
'help': 'Help',
|
|
228
|
+
'login': 'Login',
|
|
229
|
+
'logout': 'Logout',
|
|
230
|
+
'enable': 'Enable',
|
|
231
|
+
'disable': 'Disable',
|
|
232
|
+
'connect': 'Connect',
|
|
233
|
+
'disconnect': 'Disconnect',
|
|
234
|
+
'schedule': 'Schedule',
|
|
235
|
+
'assign': 'Assign',
|
|
236
|
+
'archive': 'Archive',
|
|
237
|
+
'restore': 'Restore',
|
|
238
|
+
'compare': 'Compare',
|
|
239
|
+
'select': 'Select',
|
|
240
|
+
'pay': 'Pay',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
class ActionVerbMap {
|
|
244
|
+
/**
|
|
245
|
+
* Get canonical verb for a word
|
|
246
|
+
* @param {string} word - Input word
|
|
247
|
+
* @returns {string|null} - Canonical verb or null
|
|
248
|
+
*/
|
|
249
|
+
static get(word) {
|
|
250
|
+
if (!word) return null;
|
|
251
|
+
return ACTION_VERB_MAP[word.toLowerCase()] || null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Get label for a canonical verb
|
|
256
|
+
* @param {string} verb - Canonical verb
|
|
257
|
+
* @returns {string} - Human-readable label
|
|
258
|
+
*/
|
|
259
|
+
static getLabel(verb) {
|
|
260
|
+
return VERB_LABELS[verb] || verb.charAt(0).toUpperCase() + verb.slice(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Check if a word is an action verb
|
|
265
|
+
* @param {string} word - Input word
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
static isActionVerb(word) {
|
|
269
|
+
if (!word) return false;
|
|
270
|
+
return ACTION_VERB_MAP.hasOwnProperty(word.toLowerCase());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get all action verbs
|
|
275
|
+
* @returns {Object} - Map of verbs
|
|
276
|
+
*/
|
|
277
|
+
static getAll() {
|
|
278
|
+
return { ...ACTION_VERB_MAP };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get all canonical verbs
|
|
283
|
+
* @returns {string[]} - Array of canonical verbs
|
|
284
|
+
*/
|
|
285
|
+
static getCanonicalVerbs() {
|
|
286
|
+
return Object.keys(VERB_LABELS);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Find matching verb in a text string
|
|
291
|
+
* @param {string} text - Text to search
|
|
292
|
+
* @returns {Object|null} - { verb, position } or null
|
|
293
|
+
*/
|
|
294
|
+
static findInText(text) {
|
|
295
|
+
if (!text) return null;
|
|
296
|
+
|
|
297
|
+
const words = text.toLowerCase().split(/\s+/);
|
|
298
|
+
|
|
299
|
+
// Check first word (most likely to be the action)
|
|
300
|
+
if (words[0] && ACTION_VERB_MAP[words[0]]) {
|
|
301
|
+
return {
|
|
302
|
+
word: words[0],
|
|
303
|
+
verb: ACTION_VERB_MAP[words[0]],
|
|
304
|
+
position: 0,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Check other words
|
|
309
|
+
for (let i = 1; i < words.length; i++) {
|
|
310
|
+
if (ACTION_VERB_MAP[words[i]]) {
|
|
311
|
+
return {
|
|
312
|
+
word: words[i],
|
|
313
|
+
verb: ACTION_VERB_MAP[words[i]],
|
|
314
|
+
position: i,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export default ActionVerbMap;
|
|
324
|
+
export { ACTION_VERB_MAP, VERB_LABELS };
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - Intent Extractor
|
|
3
|
+
* Rule-based intent extraction from UI elements (no LLM required)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ActionVerbMap from './ActionVerbMap.js';
|
|
7
|
+
import { toSnakeCase, toTitleCase } from '../utils/stringUtils.js';
|
|
8
|
+
|
|
9
|
+
class IntentExtractor {
|
|
10
|
+
/**
|
|
11
|
+
* Extract intents from UI elements
|
|
12
|
+
* @param {Array} uiElements - Array of UI element objects
|
|
13
|
+
* @param {string} route - Current page route
|
|
14
|
+
* @param {Object} options - { maxIntents: 6 }
|
|
15
|
+
* @returns {Array} - Array of intent objects
|
|
16
|
+
*/
|
|
17
|
+
static extract(uiElements, route, options = {}) {
|
|
18
|
+
const maxIntents = options.maxIntents || 6;
|
|
19
|
+
const intents = [];
|
|
20
|
+
const routeContext = this._extractRouteContext(route);
|
|
21
|
+
|
|
22
|
+
// Process buttons first (highest priority)
|
|
23
|
+
const buttons = uiElements.filter(el => el.role === 'button' && !el.disabled);
|
|
24
|
+
for (const button of buttons) {
|
|
25
|
+
const intent = this._extractIntentFromElement(button, routeContext);
|
|
26
|
+
if (intent) {
|
|
27
|
+
intents.push({
|
|
28
|
+
...intent,
|
|
29
|
+
source_element: button.selector,
|
|
30
|
+
element_position: button.position,
|
|
31
|
+
element_type: 'button',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Process links with action text (medium priority)
|
|
37
|
+
const links = uiElements.filter(el => el.role === 'link');
|
|
38
|
+
for (const link of links) {
|
|
39
|
+
const intent = this._extractIntentFromElement(link, routeContext);
|
|
40
|
+
if (intent) {
|
|
41
|
+
intent.confidence *= 0.85; // Lower confidence for links
|
|
42
|
+
intents.push({
|
|
43
|
+
...intent,
|
|
44
|
+
source_element: link.selector,
|
|
45
|
+
element_position: link.position,
|
|
46
|
+
element_type: 'link',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Process other interactive elements
|
|
52
|
+
const others = uiElements.filter(el =>
|
|
53
|
+
!['button', 'link'].includes(el.role) &&
|
|
54
|
+
['menuitem', 'tab', 'checkbox', 'radio'].includes(el.role)
|
|
55
|
+
);
|
|
56
|
+
for (const element of others) {
|
|
57
|
+
const intent = this._extractIntentFromElement(element, routeContext);
|
|
58
|
+
if (intent) {
|
|
59
|
+
intent.confidence *= 0.75;
|
|
60
|
+
intents.push({
|
|
61
|
+
...intent,
|
|
62
|
+
source_element: element.selector,
|
|
63
|
+
element_position: element.position,
|
|
64
|
+
element_type: element.role,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sort by confidence and position
|
|
70
|
+
intents.sort((a, b) => {
|
|
71
|
+
// Primary sort by confidence
|
|
72
|
+
if (Math.abs(a.confidence - b.confidence) > 0.1) {
|
|
73
|
+
return b.confidence - a.confidence;
|
|
74
|
+
}
|
|
75
|
+
// Secondary sort by vertical position (higher on page = more important)
|
|
76
|
+
const aY = a.element_position?.y || 9999;
|
|
77
|
+
const bY = b.element_position?.y || 9999;
|
|
78
|
+
return aY - bY;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Deduplicate by intent key
|
|
82
|
+
const uniqueIntents = this._deduplicateIntents(intents);
|
|
83
|
+
|
|
84
|
+
// Return top N intents
|
|
85
|
+
return uniqueIntents.slice(0, maxIntents);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract route context (module name from URL)
|
|
90
|
+
* @param {string} route - Page route
|
|
91
|
+
* @returns {string} - Context word
|
|
92
|
+
*/
|
|
93
|
+
static _extractRouteContext(route) {
|
|
94
|
+
// Extract meaningful segments
|
|
95
|
+
const segments = route.split('/').filter(s => s.length > 0 && !s.match(/^\d+$/));
|
|
96
|
+
|
|
97
|
+
if (segments.length === 0) {
|
|
98
|
+
return 'page';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Use the first meaningful segment as context
|
|
102
|
+
// e.g., "/payroll/processing" -> "payroll"
|
|
103
|
+
// e.g., "/employees/123/edit" -> "employees"
|
|
104
|
+
return segments[0].toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract intent from a single UI element
|
|
109
|
+
* @param {Object} element - UI element object
|
|
110
|
+
* @param {string} routeContext - Route context word
|
|
111
|
+
* @returns {Object|null} - Intent object or null
|
|
112
|
+
*/
|
|
113
|
+
static _extractIntentFromElement(element, routeContext) {
|
|
114
|
+
// Get text from element (prefer aria-label for accessibility)
|
|
115
|
+
const text = (element.aria_label || element.text || element.title || '').trim();
|
|
116
|
+
|
|
117
|
+
if (!text || text.length < 2 || text.length > 50) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Skip generic/navigation elements
|
|
122
|
+
const skipPatterns = [
|
|
123
|
+
/^home$/i, /^menu$/i, /^settings$/i, /^profile$/i,
|
|
124
|
+
/^\d+$/, /^[<>]$/, /^more$/i, /^options$/i,
|
|
125
|
+
];
|
|
126
|
+
if (skipPatterns.some(p => p.test(text))) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Find action verb in text
|
|
131
|
+
const verbMatch = ActionVerbMap.findInText(text);
|
|
132
|
+
|
|
133
|
+
if (!verbMatch) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Extract object (what the action applies to)
|
|
138
|
+
const words = text.toLowerCase().split(/\s+/);
|
|
139
|
+
let objectName = '';
|
|
140
|
+
|
|
141
|
+
if (verbMatch.position === 0 && words.length > 1) {
|
|
142
|
+
// Action verb is first word, rest is the object
|
|
143
|
+
objectName = words.slice(1).join('_');
|
|
144
|
+
} else if (verbMatch.position > 0) {
|
|
145
|
+
// Action verb is not first, object might be before
|
|
146
|
+
objectName = words.slice(0, verbMatch.position).join('_');
|
|
147
|
+
} else {
|
|
148
|
+
// Single word action, use route context
|
|
149
|
+
objectName = routeContext;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Clean up object name
|
|
153
|
+
objectName = objectName.replace(/[^\w_]/g, '').replace(/_+/g, '_');
|
|
154
|
+
|
|
155
|
+
if (!objectName) {
|
|
156
|
+
objectName = routeContext;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Generate intent key and label
|
|
160
|
+
const intentKey = `${verbMatch.verb}_${objectName}`;
|
|
161
|
+
const intentLabel = this._generateLabel(verbMatch.verb, objectName);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
key: intentKey,
|
|
165
|
+
label: intentLabel,
|
|
166
|
+
verb: verbMatch.verb,
|
|
167
|
+
object: objectName,
|
|
168
|
+
confidence: this._calculateConfidence(element, verbMatch),
|
|
169
|
+
original_text: text,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate human-readable label for intent
|
|
175
|
+
* @param {string} verb - Canonical verb
|
|
176
|
+
* @param {string} object - Object name in snake_case
|
|
177
|
+
* @returns {string} - Human-readable label
|
|
178
|
+
*/
|
|
179
|
+
static _generateLabel(verb, object) {
|
|
180
|
+
const verbLabel = ActionVerbMap.getLabel(verb);
|
|
181
|
+
const objectLabel = toTitleCase(object);
|
|
182
|
+
return `${verbLabel} ${objectLabel}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Calculate confidence score for an intent
|
|
187
|
+
* @param {Object} element - UI element
|
|
188
|
+
* @param {Object} verbMatch - Verb match result
|
|
189
|
+
* @returns {number} - Confidence score (0-1)
|
|
190
|
+
*/
|
|
191
|
+
static _calculateConfidence(element, verbMatch) {
|
|
192
|
+
let confidence = 0.85; // Base confidence for rule-based extraction
|
|
193
|
+
|
|
194
|
+
// Boost if verb is first word
|
|
195
|
+
if (verbMatch.position === 0) {
|
|
196
|
+
confidence += 0.05;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Boost if element has data-testid (indicates well-structured UI)
|
|
200
|
+
if (element.data_testid) {
|
|
201
|
+
confidence += 0.03;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Boost if element has aria-label
|
|
205
|
+
if (element.aria_label) {
|
|
206
|
+
confidence += 0.02;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Slight penalty for very long text (might be descriptive, not actionable)
|
|
210
|
+
const textLength = (element.text || '').length;
|
|
211
|
+
if (textLength > 30) {
|
|
212
|
+
confidence -= 0.05;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Cap at 0.95
|
|
216
|
+
return Math.min(0.95, confidence);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Deduplicate intents by key, keeping highest confidence
|
|
221
|
+
* @param {Array} intents - Array of intents
|
|
222
|
+
* @returns {Array} - Deduplicated array
|
|
223
|
+
*/
|
|
224
|
+
static _deduplicateIntents(intents) {
|
|
225
|
+
const seen = new Map();
|
|
226
|
+
|
|
227
|
+
for (const intent of intents) {
|
|
228
|
+
const existing = seen.get(intent.key);
|
|
229
|
+
if (!existing || intent.confidence > existing.confidence) {
|
|
230
|
+
seen.set(intent.key, intent);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return Array.from(seen.values());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Match user query to available intents
|
|
239
|
+
* @param {string} query - User's free-text query
|
|
240
|
+
* @param {Array} availableIntents - Array of intent objects
|
|
241
|
+
* @returns {Object|null} - Best matching intent or null
|
|
242
|
+
*/
|
|
243
|
+
static matchQueryToIntent(query, availableIntents) {
|
|
244
|
+
if (!query || !availableIntents?.length) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const queryLower = query.toLowerCase();
|
|
249
|
+
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
250
|
+
|
|
251
|
+
let bestMatch = null;
|
|
252
|
+
let bestScore = 0;
|
|
253
|
+
|
|
254
|
+
for (const intent of availableIntents) {
|
|
255
|
+
let score = 0;
|
|
256
|
+
|
|
257
|
+
// Check if query contains the verb
|
|
258
|
+
if (queryLower.includes(intent.verb)) {
|
|
259
|
+
score += 0.4;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check if query contains object words
|
|
263
|
+
const objectWords = intent.object.split('_');
|
|
264
|
+
const objectMatchCount = objectWords.filter(w => queryLower.includes(w)).length;
|
|
265
|
+
score += (objectMatchCount / objectWords.length) * 0.4;
|
|
266
|
+
|
|
267
|
+
// Check if label matches
|
|
268
|
+
if (queryLower.includes(intent.label.toLowerCase())) {
|
|
269
|
+
score += 0.3;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check keyword overlap
|
|
273
|
+
const intentKeywords = [intent.verb, ...objectWords];
|
|
274
|
+
const keywordMatches = queryWords.filter(w =>
|
|
275
|
+
intentKeywords.some(k => k.includes(w) || w.includes(k))
|
|
276
|
+
).length;
|
|
277
|
+
score += (keywordMatches / Math.max(queryWords.length, 1)) * 0.2;
|
|
278
|
+
|
|
279
|
+
if (score > bestScore) {
|
|
280
|
+
bestScore = score;
|
|
281
|
+
bestMatch = { ...intent, match_score: score };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Return match only if confidence is reasonable
|
|
286
|
+
if (bestScore >= 0.3) {
|
|
287
|
+
return bestMatch;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get suggested clarification when query is ambiguous
|
|
295
|
+
* @param {string} query - User's query
|
|
296
|
+
* @param {Array} candidates - Possible matching intents
|
|
297
|
+
* @returns {Object} - { question, options }
|
|
298
|
+
*/
|
|
299
|
+
static getClarification(query, candidates) {
|
|
300
|
+
if (!candidates || candidates.length < 2) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Take top 2-3 candidates
|
|
305
|
+
const topCandidates = candidates.slice(0, 3);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
question: 'Did you mean one of these?',
|
|
309
|
+
options: topCandidates.map(c => ({
|
|
310
|
+
key: c.key,
|
|
311
|
+
label: c.label,
|
|
312
|
+
})),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default IntentExtractor;
|