@browserstack/mcp-server 1.4.0-beta.3 → 1.5.0-beta.10
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/capability/loadtesting.capability-index.json +1792 -0
- package/capability/tm.capability-index.json +20094 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +2 -23
- package/dist/index.js +2 -5
- package/dist/server-factory.js +5 -5
- package/dist/tools/accessibility.js +2 -5
- package/dist/tools/capability-registry/bind.d.ts +29 -0
- package/dist/tools/capability-registry/bind.js +134 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +128 -0
- package/dist/tools/capability-registry/index-loader.d.ts +133 -0
- package/dist/tools/capability-registry/index-loader.js +369 -0
- package/dist/tools/capability-registry/register.d.ts +34 -0
- package/dist/tools/capability-registry/register.js +396 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +45 -0
- package/dist/tools/capability-registry/search.d.ts +97 -0
- package/dist/tools/capability-registry/search.js +527 -0
- package/dist/tools/capability-registry/types.d.ts +232 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/get-failure-logs.js +1 -3
- package/dist/tools/rca-agent.js +2 -5
- package/dist/tools/selfheal.js +2 -5
- package/dist/tools/testmanagement.js +15 -37
- package/package.json +3 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
- package/dist/tools/ask-browserstack/central-oauth.js +0 -277
- package/dist/tools/ask-browserstack/config.d.ts +0 -102
- package/dist/tools/ask-browserstack/config.js +0 -140
- package/dist/tools/ask-browserstack/egress.d.ts +0 -34
- package/dist/tools/ask-browserstack/egress.js +0 -31
- package/dist/tools/ask-browserstack/register.d.ts +0 -61
- package/dist/tools/ask-browserstack/register.js +0 -416
- package/dist/tools/ask-browserstack/relay.d.ts +0 -201
- package/dist/tools/ask-browserstack/relay.js +0 -577
- package/dist/tools/ask-browserstack/stream.d.ts +0 -116
- package/dist/tools/ask-browserstack/stream.js +0 -236
- package/dist/tools/ask-browserstack/types.d.ts +0 -196
- package/dist/tools/ask-browserstack/types.js +0 -14
- package/dist/tools/tool-handoff.d.ts +0 -62
- package/dist/tools/tool-handoff.js +0 -75
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking capabilities against a plain-language query.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `discover._score`, including the two properties that were each
|
|
5
|
+
* fixed after a live mis-ranking:
|
|
6
|
+
*
|
|
7
|
+
* * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is
|
|
8
|
+
* what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40
|
|
9
|
+
* legitimate matches outright, because a cardinality penalty took an otherwise-valid
|
|
10
|
+
* score to zero and the caller saw "no such capability".
|
|
11
|
+
* * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
|
|
12
|
+
* capability needing an id it cannot possibly have yet.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Every non-alphanumeric character separates, `_` included.
|
|
16
|
+
*
|
|
17
|
+
* `_` used to be a word character, which made `test_case` a single token while every
|
|
18
|
+
* haystack rendered it as "test case" — so the two could never match. That is the exact
|
|
19
|
+
* string `listEntities` hands back, so a caller following the documented flow searched with
|
|
20
|
+
* a term guaranteed to score zero: "list test_runs" matched 19 capabilities and put an
|
|
21
|
+
* admin settings endpoint first, where "list test runs" matched 103 and put the test-runs
|
|
22
|
+
* listing first.
|
|
23
|
+
*/
|
|
24
|
+
const WORD = /[a-z0-9]+/g;
|
|
25
|
+
const STOPWORDS = new Set([
|
|
26
|
+
"a",
|
|
27
|
+
"an",
|
|
28
|
+
"and",
|
|
29
|
+
"are",
|
|
30
|
+
"as",
|
|
31
|
+
"at",
|
|
32
|
+
"be",
|
|
33
|
+
"by",
|
|
34
|
+
"can",
|
|
35
|
+
"do",
|
|
36
|
+
"for",
|
|
37
|
+
"from",
|
|
38
|
+
"how",
|
|
39
|
+
"i",
|
|
40
|
+
"in",
|
|
41
|
+
"is",
|
|
42
|
+
"it",
|
|
43
|
+
"me",
|
|
44
|
+
"my",
|
|
45
|
+
"of",
|
|
46
|
+
"on",
|
|
47
|
+
"or",
|
|
48
|
+
"has",
|
|
49
|
+
"have",
|
|
50
|
+
"that",
|
|
51
|
+
"the",
|
|
52
|
+
"these",
|
|
53
|
+
"this",
|
|
54
|
+
"those",
|
|
55
|
+
"to",
|
|
56
|
+
"want",
|
|
57
|
+
"what",
|
|
58
|
+
"which",
|
|
59
|
+
"with",
|
|
60
|
+
"you",
|
|
61
|
+
]);
|
|
62
|
+
// Verbs that reveal what the caller means to DO. A preference, not a filter — an explicit
|
|
63
|
+
// `mode` argument is the filter.
|
|
64
|
+
const READ_VERBS = new Set([
|
|
65
|
+
"list",
|
|
66
|
+
"get",
|
|
67
|
+
"show",
|
|
68
|
+
"find",
|
|
69
|
+
"fetch",
|
|
70
|
+
"read",
|
|
71
|
+
"count",
|
|
72
|
+
"search",
|
|
73
|
+
"view",
|
|
74
|
+
"which",
|
|
75
|
+
"how",
|
|
76
|
+
]);
|
|
77
|
+
const WRITE_VERBS = new Set([
|
|
78
|
+
"create",
|
|
79
|
+
"add",
|
|
80
|
+
"update",
|
|
81
|
+
"edit",
|
|
82
|
+
"delete",
|
|
83
|
+
"remove",
|
|
84
|
+
"move",
|
|
85
|
+
"copy",
|
|
86
|
+
"archive",
|
|
87
|
+
"assign",
|
|
88
|
+
"restore",
|
|
89
|
+
"reorder",
|
|
90
|
+
"bulk",
|
|
91
|
+
"set",
|
|
92
|
+
"upload",
|
|
93
|
+
"import",
|
|
94
|
+
"clone",
|
|
95
|
+
]);
|
|
96
|
+
// Words that mean "give me many", which is what makes a single-record getter the wrong answer.
|
|
97
|
+
const PLURAL_INTENT = new Set([
|
|
98
|
+
"list",
|
|
99
|
+
"all",
|
|
100
|
+
"every",
|
|
101
|
+
"many",
|
|
102
|
+
"count",
|
|
103
|
+
"search",
|
|
104
|
+
"find",
|
|
105
|
+
"which",
|
|
106
|
+
"each",
|
|
107
|
+
]);
|
|
108
|
+
/**
|
|
109
|
+
* Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
|
|
110
|
+
*
|
|
111
|
+
* camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
|
|
112
|
+
* all tokenize alike.
|
|
113
|
+
*/
|
|
114
|
+
export function terms(text) {
|
|
115
|
+
return [
|
|
116
|
+
...(text || "")
|
|
117
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
118
|
+
.toLowerCase()
|
|
119
|
+
.matchAll(WORD),
|
|
120
|
+
]
|
|
121
|
+
.map((match) => match[0])
|
|
122
|
+
.filter((word) => !STOPWORDS.has(word));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A term plus its naive singular variants.
|
|
126
|
+
*
|
|
127
|
+
* QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
|
|
128
|
+
* substring containment, so indexed `attachments` already contains a query of `attachment`;
|
|
129
|
+
* only the reverse — a plural query against singular text — needs help. Stemming the
|
|
130
|
+
* indexed side too would mean rewriting the product's own vocabulary to guess at English,
|
|
131
|
+
* for no additional match.
|
|
132
|
+
*/
|
|
133
|
+
export function termForms(term) {
|
|
134
|
+
const forms = [term];
|
|
135
|
+
// A stripped form must still be three characters. `has` -> `ha` matched more than half
|
|
136
|
+
// the surface as a substring and pushed a correct answer out of the top 8 entirely;
|
|
137
|
+
// short fragments are noise, not variants.
|
|
138
|
+
const add = (form) => {
|
|
139
|
+
if (form.length >= 3)
|
|
140
|
+
forms.push(form);
|
|
141
|
+
};
|
|
142
|
+
if (term.endsWith("es"))
|
|
143
|
+
add(term.slice(0, -2));
|
|
144
|
+
if (term.endsWith("s") && !term.endsWith("ss"))
|
|
145
|
+
add(term.slice(0, -1));
|
|
146
|
+
return forms;
|
|
147
|
+
}
|
|
148
|
+
/** A haystack as one lowercased, space-separated string, ready for containment tests. */
|
|
149
|
+
function haystack(text) {
|
|
150
|
+
return terms(text).join(" ");
|
|
151
|
+
}
|
|
152
|
+
export function modeHint(query) {
|
|
153
|
+
const words = new Set(terms(query));
|
|
154
|
+
const wantsWrite = [...words].some((word) => WRITE_VERBS.has(word));
|
|
155
|
+
if (wantsWrite)
|
|
156
|
+
return "write";
|
|
157
|
+
const wantsRead = [...words].some((word) => READ_VERBS.has(word));
|
|
158
|
+
return wantsRead ? "read" : "";
|
|
159
|
+
}
|
|
160
|
+
export function wantsCollection(query) {
|
|
161
|
+
return [...(query || "").toLowerCase().matchAll(WORD)].some((match) => PLURAL_INTENT.has(match[0]));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* True when a capability answers with many records rather than one.
|
|
165
|
+
*
|
|
166
|
+
* Pagination is the reliable signal — a paged operation is a listing by construction. The
|
|
167
|
+
* plural terminal path segment is a weaker fallback for unpaged collections. (The Python
|
|
168
|
+
* side used the capability NAME here. tm now publishes one, but its verbs are not
|
|
169
|
+
* consistent — the endpoint that lists folders is `get_root_folders_v1` — so the path's
|
|
170
|
+
* terminal noun remains the better signal.)
|
|
171
|
+
*/
|
|
172
|
+
export function isCollection(capability) {
|
|
173
|
+
if (capability.paginated)
|
|
174
|
+
return true;
|
|
175
|
+
const segments = capability.path
|
|
176
|
+
.split("/")
|
|
177
|
+
.filter((s) => s && !s.startsWith("{"));
|
|
178
|
+
const tail = segments[segments.length - 1] || "";
|
|
179
|
+
return tail.endsWith("s") && !tail.endsWith("ss");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Does this capability's mode answer what the caller asked to DO?
|
|
183
|
+
*
|
|
184
|
+
* `destructive` counts as a write. It has to: `modeHint` reads "delete" and "remove" as
|
|
185
|
+
* WRITE verbs, but a delete endpoint's mode is `destructive`, so a strict equality check
|
|
186
|
+
* penalised every destructive capability by 20 FOR BEING A DELETE — on exactly the queries
|
|
187
|
+
* that wanted one. "bulk delete test cases" scored 15.8 on terms, the highest of any missed
|
|
188
|
+
* query in the eval, and still fell out of the top 8.
|
|
189
|
+
*
|
|
190
|
+
* That accounted for 15 of the eval's 36 misses. The mode hint exists to separate reading
|
|
191
|
+
* from changing; destructive is changing.
|
|
192
|
+
*
|
|
193
|
+
* A read hint still refuses destructive, which is the useful half: "show me the test plans"
|
|
194
|
+
* must not surface a delete.
|
|
195
|
+
*/
|
|
196
|
+
function satisfiesHint(mode, hint) {
|
|
197
|
+
if (hint === "write")
|
|
198
|
+
return mode === "write" || mode === "destructive";
|
|
199
|
+
return mode === hint;
|
|
200
|
+
}
|
|
201
|
+
/** Everything a caller might say that lives on a parameter rather than in the prose. */
|
|
202
|
+
function parameterText(capability) {
|
|
203
|
+
const parts = [];
|
|
204
|
+
for (const group of [
|
|
205
|
+
capability.path_params,
|
|
206
|
+
capability.query,
|
|
207
|
+
capability.body,
|
|
208
|
+
]) {
|
|
209
|
+
for (const param of group || []) {
|
|
210
|
+
parts.push(param.name);
|
|
211
|
+
if (param.description)
|
|
212
|
+
parts.push(param.description);
|
|
213
|
+
if (param.values)
|
|
214
|
+
parts.push(param.values.map(String).join(" "));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return parts.join(" ");
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Path words are the identity haystack. The published `name` is deliberately NOT scored.
|
|
221
|
+
*
|
|
222
|
+
* That is a measured result, not an oversight. tm now names every capability, and adding
|
|
223
|
+
* the name to the ranking was tried three ways against tests/fixtures/search-eval.json,
|
|
224
|
+
* which the pre-names index serves 18/18:
|
|
225
|
+
*
|
|
226
|
+
* folded into this field, weight 6 17/18
|
|
227
|
+
* its own field, weights 1 / 2 / 3 / 4 / 6 17 / 17 / 17 / 17 / 16
|
|
228
|
+
* only the words the path lacks, 1..6 17 / 16 / 15 / 15 / 14
|
|
229
|
+
*
|
|
230
|
+
* Every variant loses, for two reasons. The nouns in a name are the route restated — the
|
|
231
|
+
* name is snake-cased from the operationId, itself derived from the path — so scoring them
|
|
232
|
+
* again rewards verbose names for repeating themselves: `get_test_cases_for_v1_test_run`
|
|
233
|
+
* displaced `create_test_result_for_test_case` on "record a pass or fail for a test case in
|
|
234
|
+
* a run", putting a read above the write that answers it. And what a name adds beyond the
|
|
235
|
+
* route is mostly its verb, which tm applies inconsistently (`get_root_folders_v1` lists,
|
|
236
|
+
* `list_folder_test_cases_v1` also lists), so the verb is noise as often as signal.
|
|
237
|
+
*
|
|
238
|
+
* The query this was meant to fix, "list all projects", only went 7 -> 4 even where it
|
|
239
|
+
* helped: `projects` is in 156 of 173 paths as a scope prefix, so rarity correctly values it
|
|
240
|
+
* near zero and no amount of name weighting recovers it. That one is fixed instead by the
|
|
241
|
+
* terminal-segment bonus in `score`, which tells "is that thing" from "is scoped by it".
|
|
242
|
+
*
|
|
243
|
+
* Revisit when a product ships a consistent verb convention — then the verb becomes signal.
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* The terminal path segment — the thing this endpoint is actually ABOUT.
|
|
247
|
+
*
|
|
248
|
+
* A REST path mixes two different things: the resources it is SCOPED BY, and the resource it
|
|
249
|
+
* ADDRESSES. Only the last segment is the latter. See the bonus in `score` for why that
|
|
250
|
+
* distinction matters and why it is applied flat rather than weighted.
|
|
251
|
+
*
|
|
252
|
+
* Trailing placeholders are skipped, so `/test-cases/{id}` is still about test cases.
|
|
253
|
+
* Action tails (`close`, `edit`, `delete`) are kept rather than skipped: for "close a test
|
|
254
|
+
* run" the tail IS the most specific thing the caller said.
|
|
255
|
+
*/
|
|
256
|
+
function resourceText(capability) {
|
|
257
|
+
const segments = capability.path
|
|
258
|
+
.split("/")
|
|
259
|
+
.filter((s) => s && !s.startsWith("{") && s !== "api");
|
|
260
|
+
return (segments[segments.length - 1] || "").replace(/[-_]/g, " ");
|
|
261
|
+
}
|
|
262
|
+
function identityText(capability) {
|
|
263
|
+
return capability.path
|
|
264
|
+
.split("/")
|
|
265
|
+
.filter((segment) => segment && !segment.startsWith("{") && segment !== "api")
|
|
266
|
+
.join(" ")
|
|
267
|
+
.replace(/[-_]/g, " ");
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* How much one term is worth, by how rare it is.
|
|
271
|
+
*
|
|
272
|
+
* Containment made every project-scoped endpoint match the term `project` — ~150 of tm's
|
|
273
|
+
* 173 capabilities — so that word carried as much weight as `access`, which appears in
|
|
274
|
+
* exactly one. Rarity is what separates them: a term matching everything scores near zero,
|
|
275
|
+
* a term matching one capability scores near one.
|
|
276
|
+
*
|
|
277
|
+
* This is the IDF idea alone, not BM25. The term-frequency saturation and length
|
|
278
|
+
* normalisation BM25 adds would rescale every score, and the mode and cardinality
|
|
279
|
+
* adjustments below are absolute constants fitted against live mis-rankings. Bounding the
|
|
280
|
+
* factor to 0..1 keeps those constants meaningful.
|
|
281
|
+
*/
|
|
282
|
+
function rarity(documents, forms) {
|
|
283
|
+
let df = 0;
|
|
284
|
+
for (const text of documents) {
|
|
285
|
+
if (forms.some((form) => text.includes(form)))
|
|
286
|
+
df += 1;
|
|
287
|
+
}
|
|
288
|
+
const total = documents.length || 1;
|
|
289
|
+
return Math.log((total + 1) / (df + 1)) / Math.log(total + 1);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Sized to sit alongside the mode (+6) and cardinality (+8) constants, not to dwarf them.
|
|
293
|
+
* The pinned eval is unchanged at every value from 2 to 14 — the bonus only ever fires on
|
|
294
|
+
* queries it does not cover — so this was chosen on the wider sweep: at 10, "close a test
|
|
295
|
+
* run" starts pulling `close_exploratory_session` into second place on the tail match alone.
|
|
296
|
+
*/
|
|
297
|
+
const RESOURCE_BONUS = 6;
|
|
298
|
+
/**
|
|
299
|
+
* How much a cardinality guess is worth. It used to be 8, and it should not have been.
|
|
300
|
+
*
|
|
301
|
+
* `isCollection` is a GUESS read off the URL, and the index carries nothing better: of 88
|
|
302
|
+
* read capabilities, zero declare an array in their response schema, and `paginated` — the
|
|
303
|
+
* one signal that looks trustworthy — is true for `get_report_detail`, a single-record
|
|
304
|
+
* endpoint. There is no derived cardinality in this artifact to appeal to.
|
|
305
|
+
*
|
|
306
|
+
* At ±8 that guess swung 16 points and buried 11 correct answers: `/test-runs/closed` and
|
|
307
|
+
* `/{entity}/search` are listings whose last path segment is not a plural noun, `users-v2`
|
|
308
|
+
* is plural with a version suffix in the way, and the count endpoints answer "how many"
|
|
309
|
+
* with a scalar. All were penalised for what they are called.
|
|
310
|
+
*
|
|
311
|
+
* The fix is not a better classifier — a qualifier word-list would encode tm's route
|
|
312
|
+
* conventions into generic code and rot on the next product. It is to stop betting so much
|
|
313
|
+
* on an unreliable signal. Swept against the eval, holding top1 at 138 with no ceiling
|
|
314
|
+
* violations: ±7 retires 1, ±6 retires 2, ±5 retires 5, ±4 retires 6. Below that it starts
|
|
315
|
+
* costing more than it returns — ±3.5 retires 8 but breaks a case, ±3 breaks two.
|
|
316
|
+
*/
|
|
317
|
+
const CARDINALITY = 4;
|
|
318
|
+
/**
|
|
319
|
+
* The mode penalty stays at 20, unlike the cardinality one — it is earned.
|
|
320
|
+
*
|
|
321
|
+
* Where `isCollection` is a guess off the URL, `modeHint` is measured right: across the
|
|
322
|
+
* eval it agrees with the correct answer's mode 125 times and disagrees 3. A signal that
|
|
323
|
+
* accurate deserves to be decisive.
|
|
324
|
+
*
|
|
325
|
+
* Sweeping it 20 -> 6 retires none of the three misses filed against it and regresses
|
|
326
|
+
* nothing, which says the penalty is not what holds them back. It isn't: they sit at ranks
|
|
327
|
+
* 41, 62 and 78 of the matched set, far below anything a constant could lift. All three
|
|
328
|
+
* are vocabulary gaps wearing a mode-penalty label — "what gets removed" wants a path
|
|
329
|
+
* spelled `rm-summary`, "get rid of" wants `delete`, and "the option set" wants what the
|
|
330
|
+
* product calls a `dataset`. The target barely matches on TERMS; the hint is incidental.
|
|
331
|
+
*
|
|
332
|
+
* Left at 20 deliberately. There was no evidence for moving it, and an unjustified constant
|
|
333
|
+
* is how this scorer got into trouble in the first place.
|
|
334
|
+
*/
|
|
335
|
+
const MODE_PENALTY = 20;
|
|
336
|
+
function score(capability, wanted, weights, aliases, hint, plural) {
|
|
337
|
+
if (wanted.length === 0)
|
|
338
|
+
return { matched: 1, ranked: 1 };
|
|
339
|
+
const haystacks = [
|
|
340
|
+
[identityText(capability), 6],
|
|
341
|
+
[capability.entity, 4],
|
|
342
|
+
[(aliases[capability.entity] || []).join(" "), 4],
|
|
343
|
+
[capability.intent || "", 2],
|
|
344
|
+
// `returns` is scored BELOW identity, not gated on it. At parity with intent it put a
|
|
345
|
+
// projects listing at #2 for "list test cases in a project" (its returns carries
|
|
346
|
+
// `test_cases_count`); gating it on an identity match instead made a field reachable
|
|
347
|
+
// only through returns unreachable, which is worse.
|
|
348
|
+
[(capability.returns || []).join(" "), 1],
|
|
349
|
+
[(capability.guidance || []).join(" "), 1],
|
|
350
|
+
// Parameter names, their descriptions, and their enum values — 330 descriptions and 34
|
|
351
|
+
// value lists that the artifact already carries and nothing was reading. The vocabulary
|
|
352
|
+
// a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere
|
|
353
|
+
// else in the index, only as the `status` enum on the test-result writes.
|
|
354
|
+
[parameterText(capability), 1],
|
|
355
|
+
];
|
|
356
|
+
// CONTAINMENT, not set membership. A query of `attachment` has to reach an endpoint whose
|
|
357
|
+
// path says `attachments`; under exact token equality it did not, and that endpoint fell
|
|
358
|
+
// out of the results entirely. A term scores its field once however many forms match.
|
|
359
|
+
let ranked = 0;
|
|
360
|
+
for (const [text, weight] of haystacks) {
|
|
361
|
+
const blob = haystack(text);
|
|
362
|
+
if (!blob)
|
|
363
|
+
continue;
|
|
364
|
+
for (let i = 0; i < wanted.length; i += 1) {
|
|
365
|
+
if (wanted[i].some((form) => blob.includes(form)))
|
|
366
|
+
ranked += weight * weights[i];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// PHRASE. Adjacent query terms occurring together say more than the same two words
|
|
370
|
+
// scattered: "test case" is one noun in this vocabulary, "test" and "case" separately
|
|
371
|
+
// are two of the commonest words in the index. Scored at half the field's weight and
|
|
372
|
+
// still scaled by rarity, so it sharpens an existing match rather than creating one.
|
|
373
|
+
for (const [text, weight] of haystacks) {
|
|
374
|
+
const blob = haystack(text);
|
|
375
|
+
if (!blob)
|
|
376
|
+
continue;
|
|
377
|
+
for (let i = 0; i + 1 < wanted.length; i += 1) {
|
|
378
|
+
if (blob.includes(`${wanted[i][0]} ${wanted[i + 1][0]}`)) {
|
|
379
|
+
ranked += weight * 0.5 * (weights[i] + weights[i + 1]);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const matched = ranked;
|
|
384
|
+
// THE ENDPOINT IS THAT THING, not merely scoped by it.
|
|
385
|
+
//
|
|
386
|
+
// A REST path mixes the resources it is SCOPED BY with the one it ADDRESSES. `projects` is
|
|
387
|
+
// in 156 of tm's 173 paths but is the terminal segment in 3, so whole-corpus rarity —
|
|
388
|
+
// correctly — values it near nothing, and every project-scoped listing scored the same as
|
|
389
|
+
// the projects listing itself. The top 8 for "list all projects" spanned 15.9 to 14.8,
|
|
390
|
+
// where +8 collection and +6 mode already account for 14: the term signal was ~1 point of
|
|
391
|
+
// noise and the right answer sat 7th.
|
|
392
|
+
//
|
|
393
|
+
// Flat, and deliberately NOT rarity-scaled. Rarity would reintroduce the same problem in
|
|
394
|
+
// reverse — a rare scope noun outranking the real target, which is exactly how a weighted
|
|
395
|
+
// version of this put `/projects/{id}/folders` above `/folder/{id}/test-cases` for "tc list
|
|
396
|
+
// for a folder". This asks one yes/no question instead: is the caller's own word the last
|
|
397
|
+
// thing in the path?
|
|
398
|
+
//
|
|
399
|
+
// Equality is against the QUERY's forms, never the haystack's — the same one-directional
|
|
400
|
+
// rule as containment. `projects` is in forms("projects"), so the projects listing hits;
|
|
401
|
+
// `folders` is not in forms("folder"), so a folder-scoped query does not drag in the
|
|
402
|
+
// folders listing.
|
|
403
|
+
const tail = terms(resourceText(capability));
|
|
404
|
+
if (tail.length &&
|
|
405
|
+
wanted.some((forms) => tail.every((word) => forms.includes(word)))) {
|
|
406
|
+
ranked += RESOURCE_BONUS;
|
|
407
|
+
}
|
|
408
|
+
if (hint)
|
|
409
|
+
ranked += satisfiesHint(capability.mode, hint) ? 6 : -MODE_PENALTY;
|
|
410
|
+
if (plural)
|
|
411
|
+
ranked += isCollection(capability) ? CARDINALITY : -CARDINALITY;
|
|
412
|
+
return { matched, ranked };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Below this, the caller is probably not speaking the product's language.
|
|
416
|
+
*
|
|
417
|
+
* Measured on tm: vocabulary misses top out at 1.98–2.15 ("where do my things live", "make a
|
|
418
|
+
* new bucket for my tests") while queries that work reach 6.5–12. The two ranges do NOT
|
|
419
|
+
* separate cleanly — "list all projects" scores 1.58 and is nonetheless answered correctly
|
|
420
|
+
* at rank 1, because `projects` is in 156 of 173 paths and worth almost nothing.
|
|
421
|
+
*
|
|
422
|
+
* So this is set generously and false positives are accepted, which is sound only because
|
|
423
|
+
* the vocabulary block is ADDITIVE: it arrives next to the results, never instead of them.
|
|
424
|
+
* Over-triggering costs ~1.5KB against a response that is routinely 38KB; under-triggering
|
|
425
|
+
* costs the caller a wrong answer with no hint that it is wrong. Those are not symmetric.
|
|
426
|
+
*/
|
|
427
|
+
const WEAK_MATCH = 3;
|
|
428
|
+
/**
|
|
429
|
+
* The product's vocabulary, for a caller whose words are not the product's words.
|
|
430
|
+
*
|
|
431
|
+
* THE ONE FAILURE LEXICAL SEARCH CANNOT FIX. "make a new bucket for my tests" wants the
|
|
432
|
+
* folder-create capability, and `bucket` appears nowhere in the index — no scoring change
|
|
433
|
+
* reaches it, because the word is simply absent. What CAN reach it is the caller: it is a
|
|
434
|
+
* language model, and given tm's entity list it maps bucket -> folder without effort. It
|
|
435
|
+
* just cannot guess the list unprompted.
|
|
436
|
+
*
|
|
437
|
+
* Aliases only, deliberately. They are the vocabulary map — 19 entities in ~1.5KB, against a
|
|
438
|
+
* response that is routinely 38KB. The entity `key_facts` are richer prose but ten times the
|
|
439
|
+
* size, and a caller who needs them can ask describeEntity once it knows which entity to ask
|
|
440
|
+
* about — which is exactly what this hands over.
|
|
441
|
+
*/
|
|
442
|
+
export function vocabularyOf(products, only) {
|
|
443
|
+
const out = {};
|
|
444
|
+
for (const [name, bundle] of Object.entries(products)) {
|
|
445
|
+
if (only && name !== only)
|
|
446
|
+
continue;
|
|
447
|
+
const entries = [];
|
|
448
|
+
for (const [entity, doc] of Object.entries(bundle.entities || {})) {
|
|
449
|
+
const aliases = (doc.aliases || []);
|
|
450
|
+
entries.push({
|
|
451
|
+
entity,
|
|
452
|
+
...(doc.title
|
|
453
|
+
? { title: doc.title }
|
|
454
|
+
: {}),
|
|
455
|
+
...(aliases.length ? { aliases } : {}),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (entries.length)
|
|
459
|
+
out[name] = entries;
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
export function searchCapabilities(products, query, options = {}) {
|
|
464
|
+
const limit = options.limit && options.limit > 0 ? options.limit : 8;
|
|
465
|
+
// Forms are computed once per query, not per capability: 173 capabilities x 6 haystacks
|
|
466
|
+
// would otherwise rebuild the same handful of strings a thousand times.
|
|
467
|
+
const wanted = terms(query).map(termForms);
|
|
468
|
+
const hint = options.mode ? "" : modeHint(query);
|
|
469
|
+
const plural = wantsCollection(query);
|
|
470
|
+
const scored = [];
|
|
471
|
+
const searched = Object.entries(products).filter(([name]) => !options.product || name === options.product);
|
|
472
|
+
const aliasesByProduct = {};
|
|
473
|
+
for (const [name, bundle] of searched) {
|
|
474
|
+
const aliases = {};
|
|
475
|
+
for (const [entity, doc] of Object.entries(bundle.entities)) {
|
|
476
|
+
aliases[entity] = (doc.aliases || []);
|
|
477
|
+
}
|
|
478
|
+
aliasesByProduct[name] = aliases;
|
|
479
|
+
}
|
|
480
|
+
// ONE CORPUS ACROSS EVERY SEARCHED PRODUCT, not one per product.
|
|
481
|
+
//
|
|
482
|
+
// Measured per product, a term's rarity inverts across them: `load` appears in nearly
|
|
483
|
+
// every Load Testing capability, so it scored as noise there, while it appears in 16 of
|
|
484
|
+
// tm's 173 (`upload`, `download`, reached by containment), so it scored as gold there.
|
|
485
|
+
// The word that identifies a product was worth least inside it, and "list load tests"
|
|
486
|
+
// returned five tm results and no Load Testing ones at all.
|
|
487
|
+
//
|
|
488
|
+
// It is also measured against EVERY capability, not the entity- or mode-filtered subset:
|
|
489
|
+
// narrowing a search must not make a common word look rare.
|
|
490
|
+
const corpus = searched.flatMap(([name, bundle]) => bundle.capabilities.map((capability) => [
|
|
491
|
+
identityText(capability),
|
|
492
|
+
capability.entity,
|
|
493
|
+
(aliasesByProduct[name][capability.entity] || []).join(" "),
|
|
494
|
+
capability.intent || "",
|
|
495
|
+
(capability.returns || []).join(" "),
|
|
496
|
+
parameterText(capability),
|
|
497
|
+
]
|
|
498
|
+
.map(haystack)
|
|
499
|
+
.join(" ")));
|
|
500
|
+
const weights = wanted.map((forms) => rarity(corpus, forms));
|
|
501
|
+
for (const [name, bundle] of searched) {
|
|
502
|
+
const aliases = aliasesByProduct[name];
|
|
503
|
+
for (const capability of bundle.capabilities) {
|
|
504
|
+
if (options.entity && capability.entity !== options.entity)
|
|
505
|
+
continue;
|
|
506
|
+
if (options.mode && capability.mode !== options.mode)
|
|
507
|
+
continue;
|
|
508
|
+
const { matched, ranked } = score(capability, wanted, weights, aliases, hint, plural);
|
|
509
|
+
if (matched > 0)
|
|
510
|
+
scored.push({ matched, ranked, product: name, capability });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
scored.sort((a, b) => b.ranked - a.ranked || a.capability.path.localeCompare(b.capability.path));
|
|
514
|
+
// Ranked order decides the page; the best TERM score decides confidence. They are
|
|
515
|
+
// different questions: the mode and cardinality constants can lift a weakly-matched
|
|
516
|
+
// capability to the top of a page that is entirely wrong.
|
|
517
|
+
const topMatched = scored.reduce((best, s) => Math.max(best, s.matched), 0);
|
|
518
|
+
return {
|
|
519
|
+
hits: scored
|
|
520
|
+
.slice(0, limit)
|
|
521
|
+
.map(({ product, capability }) => ({ product, capability })),
|
|
522
|
+
truncated: scored.length > limit,
|
|
523
|
+
total_matched: scored.length,
|
|
524
|
+
top_matched: topMatched,
|
|
525
|
+
weak: wanted.length > 0 && topMatched < WEAK_MATCH,
|
|
526
|
+
};
|
|
527
|
+
}
|