@tikoci/rosetta 0.6.8 → 0.7.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.
- package/README.md +2 -5
- package/package.json +1 -1
- package/src/browse.ts +135 -15
- package/src/classify.test.ts +205 -0
- package/src/classify.ts +216 -0
- package/src/db.ts +100 -0
- package/src/mcp-http.test.ts +8 -7
- package/src/mcp.ts +49 -185
- package/src/query.test.ts +129 -0
- package/src/query.ts +427 -4
package/README.md
CHANGED
|
@@ -275,22 +275,19 @@ Ask your AI assistant questions like:
|
|
|
275
275
|
|
|
276
276
|
## MCP Tools
|
|
277
277
|
|
|
278
|
-
The server exposes
|
|
278
|
+
The server exposes 13 tools designed to work together — agents start with `routeros_search` and drill into specific data as needed:
|
|
279
279
|
|
|
280
280
|
| Tool | What it does |
|
|
281
281
|
|------|-------------|
|
|
282
|
-
| `routeros_search` | **Start here.**
|
|
282
|
+
| `routeros_search` | **Start here.** Unified search with input classifier — returns pages + related callouts, videos, properties, changelogs, devices, skills |
|
|
283
283
|
| `routeros_get_page` | Full page content by ID or title, section-aware for large pages |
|
|
284
284
|
| `routeros_lookup_property` | Property by exact name — type, default, description |
|
|
285
|
-
| `routeros_search_properties` | FTS across 4,860 property names and descriptions |
|
|
286
285
|
| `routeros_command_tree` | Browse the command hierarchy (`/ip/firewall/filter` style) |
|
|
287
|
-
| `routeros_search_callouts` | Search warnings, notes, and tips |
|
|
288
286
|
| `routeros_search_changelogs` | Changelogs filtered by version range, category, breaking flag |
|
|
289
287
|
| `routeros_command_version_check` | Which RouterOS versions include a command path |
|
|
290
288
|
| `routeros_command_diff` | Added/removed commands between two RouterOS versions |
|
|
291
289
|
| `routeros_device_lookup` | Hardware specs — filter by architecture, RAM, PoE, wireless, etc. |
|
|
292
290
|
| `routeros_search_tests` | Cross-device ethernet and IPSec benchmarks |
|
|
293
|
-
| `routeros_search_videos` | YouTube transcript search with chapter timestamps |
|
|
294
291
|
| `routeros_dude_search` | FTS across archived Dude wiki docs (separate from RouterOS search) |
|
|
295
292
|
| `routeros_dude_get_page` | Full Dude wiki page by ID or title, with screenshot metadata |
|
|
296
293
|
| `routeros_stats` | Database health and coverage stats |
|
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -20,7 +20,8 @@ import type {
|
|
|
20
20
|
DeviceResult,
|
|
21
21
|
DeviceTestRow,
|
|
22
22
|
DudeSearchResult,
|
|
23
|
-
|
|
23
|
+
GlossaryEntry,
|
|
24
|
+
SearchAllResponse,
|
|
24
25
|
SearchResult,
|
|
25
26
|
SectionTocEntry,
|
|
26
27
|
VideoSearchResult,
|
|
@@ -35,14 +36,16 @@ import {
|
|
|
35
36
|
getPage,
|
|
36
37
|
getSkill,
|
|
37
38
|
getTestResultMeta,
|
|
39
|
+
listGlossary,
|
|
38
40
|
listSkills,
|
|
41
|
+
lookupGlossary,
|
|
39
42
|
lookupProperty,
|
|
43
|
+
searchAll,
|
|
40
44
|
searchCallouts,
|
|
41
45
|
searchChangelogs,
|
|
42
46
|
searchDevices,
|
|
43
47
|
searchDeviceTests,
|
|
44
48
|
searchDude,
|
|
45
|
-
searchPages,
|
|
46
49
|
searchProperties,
|
|
47
50
|
searchVideos,
|
|
48
51
|
} from "./query.ts";
|
|
@@ -192,7 +195,7 @@ function waitForKey(): Promise<string> {
|
|
|
192
195
|
|
|
193
196
|
type Context =
|
|
194
197
|
| { type: "home" }
|
|
195
|
-
| { type: "search"; response:
|
|
198
|
+
| { type: "search"; response: SearchAllResponse; results: SearchResult[] }
|
|
196
199
|
| { type: "page"; pageId: number; title: string; commandPath?: string }
|
|
197
200
|
| { type: "sections"; pageId: number; title: string; sections: SectionTocEntry[] }
|
|
198
201
|
| { type: "commands"; path: string }
|
|
@@ -271,18 +274,22 @@ function renderWelcome(): string {
|
|
|
271
274
|
return box(lines, "rosetta");
|
|
272
275
|
}
|
|
273
276
|
|
|
274
|
-
function renderSearchResults(resp:
|
|
277
|
+
function renderSearchResults(resp: SearchAllResponse): string {
|
|
275
278
|
const out: string[] = [];
|
|
276
|
-
const modeNote = resp.
|
|
277
|
-
out.push(` ${bold(String(resp.
|
|
279
|
+
const modeNote = resp.fallback_mode === "or" ? dim(" (OR fallback)") : "";
|
|
280
|
+
out.push(` ${bold(String(resp.pages.length))} of ${resp.total_pages} page results for ${cyan(`"${resp.query}"`)}${modeNote}`);
|
|
281
|
+
|
|
282
|
+
// Classifier signals — compact one-line summary when anything fired
|
|
283
|
+
const classLine = renderClassified(resp.classified);
|
|
284
|
+
if (classLine) out.push(` ${dim("classified:")} ${classLine}`);
|
|
278
285
|
if (resp.note) {
|
|
279
286
|
out.push(` ${yellow("Hint:")} ${resp.note}`);
|
|
280
287
|
}
|
|
281
288
|
out.push("");
|
|
282
289
|
|
|
283
290
|
const w = termWidth();
|
|
284
|
-
for (let i = 0; i < resp.
|
|
285
|
-
const r = resp.
|
|
291
|
+
for (let i = 0; i < resp.pages.length; i++) {
|
|
292
|
+
const r = resp.pages[i];
|
|
286
293
|
const num = dim(`${String(i + 1).padStart(3)} `);
|
|
287
294
|
const title = bold(truncate(r.title, 30));
|
|
288
295
|
const path = dim(truncate(r.path, Math.max(20, w - 55)));
|
|
@@ -301,6 +308,23 @@ function renderSearchResults(resp: SearchResponse): string {
|
|
|
301
308
|
out.push("");
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
// Related block — compact summary of side-query hits
|
|
312
|
+
const related = renderRelated(resp.related, w);
|
|
313
|
+
if (related.length > 0) {
|
|
314
|
+
out.push(` ${bold("related")}`);
|
|
315
|
+
out.push(...related);
|
|
316
|
+
out.push("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Next-steps block — always render the top 3 suggestions
|
|
320
|
+
if (resp.next_steps.length > 0) {
|
|
321
|
+
out.push(` ${bold("next")}`);
|
|
322
|
+
for (const step of resp.next_steps.slice(0, 3)) {
|
|
323
|
+
out.push(` ${dim("→")} ${step}`);
|
|
324
|
+
}
|
|
325
|
+
out.push("");
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
// Navigation hints
|
|
305
329
|
const hints = [
|
|
306
330
|
`${cyan("[N]")} view page`,
|
|
@@ -312,6 +336,62 @@ function renderSearchResults(resp: SearchResponse): string {
|
|
|
312
336
|
return out.join("\n");
|
|
313
337
|
}
|
|
314
338
|
|
|
339
|
+
/** One-line summary of classifier output — empty string if nothing fired. */
|
|
340
|
+
function renderClassified(c: SearchAllResponse["classified"]): string {
|
|
341
|
+
const parts: string[] = [];
|
|
342
|
+
if (c.command_path) parts.push(`path=${cyan(c.command_path)}`);
|
|
343
|
+
if (c.version) parts.push(`version=${magenta(c.version)}`);
|
|
344
|
+
if (c.device) parts.push(`device=${yellow(c.device)}`);
|
|
345
|
+
if (c.property) parts.push(`property=${green(c.property)}`);
|
|
346
|
+
if (c.topics.length > 0) parts.push(`topics=[${c.topics.join(",")}]`);
|
|
347
|
+
if (c.command_fragment) {
|
|
348
|
+
if (c.command_fragment.verbs.length > 0) parts.push(`verbs=[${c.command_fragment.verbs.join(",")}]`);
|
|
349
|
+
if (c.command_fragment.pairs.length > 0) {
|
|
350
|
+
const pairs = c.command_fragment.pairs.map((p) => `${p.key}=${p.value}`).join(" ");
|
|
351
|
+
parts.push(`args={${pairs}}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return parts.join(" ");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Render related sections as one line each. */
|
|
358
|
+
function renderRelated(related: SearchAllResponse["related"], w: number): string[] {
|
|
359
|
+
const out: string[] = [];
|
|
360
|
+
if (related.command_node) {
|
|
361
|
+
const n = related.command_node;
|
|
362
|
+
const link = n.linked_page ? ` → page #${n.linked_page.id} "${n.linked_page.title}"` : "";
|
|
363
|
+
out.push(` ${dim("cmd")} ${cyan(n.path)} (${n.type})${link}`);
|
|
364
|
+
}
|
|
365
|
+
if (related.commands?.length) {
|
|
366
|
+
const names = related.commands.map((c) => c.name).join(", ");
|
|
367
|
+
out.push(` ${dim("children")} ${truncate(names, w - 16)}`);
|
|
368
|
+
}
|
|
369
|
+
if (related.properties?.length) {
|
|
370
|
+
const props = related.properties.map((p) => p.name).join(", ");
|
|
371
|
+
out.push(` ${dim("props")} ${truncate(props, w - 14)}`);
|
|
372
|
+
}
|
|
373
|
+
if (related.devices?.length) {
|
|
374
|
+
const devs = related.devices.map((d) => d.product_name).join(", ");
|
|
375
|
+
out.push(` ${dim("devices")} ${truncate(devs, w - 16)}`);
|
|
376
|
+
}
|
|
377
|
+
if (related.changelogs?.length) {
|
|
378
|
+
const first = related.changelogs[0];
|
|
379
|
+
out.push(` ${dim("changelog")} ${first.version} ${first.category}: ${truncate(first.description, w - 40)}`);
|
|
380
|
+
}
|
|
381
|
+
if (related.videos?.length) {
|
|
382
|
+
const v = related.videos[0];
|
|
383
|
+
out.push(` ${dim("video")} ${truncate(v.title, w - 14)}`);
|
|
384
|
+
}
|
|
385
|
+
if (related.callouts?.length) {
|
|
386
|
+
const c = related.callouts[0];
|
|
387
|
+
out.push(` ${dim("callout")} ${c.type}: ${truncate(c.excerpt, w - 20)}`);
|
|
388
|
+
}
|
|
389
|
+
if (related.skills?.length) {
|
|
390
|
+
out.push(` ${dim("skill")} ${related.skills[0].name}`);
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
315
395
|
function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
316
396
|
const out: string[] = [];
|
|
317
397
|
const w = termWidth();
|
|
@@ -890,6 +970,7 @@ function renderHelp(): string {
|
|
|
890
970
|
cmd("page <id|title>", "", "View full page", "routeros_get_page");
|
|
891
971
|
cmd("prop <name>", "p", "Look up property (scoped to current page)", "routeros_lookup_property");
|
|
892
972
|
cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
|
|
973
|
+
cmd("glossary [term]", "g", "Look up RouterOS jargon / list glossary");
|
|
893
974
|
cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
|
|
894
975
|
cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
|
|
895
976
|
cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
|
|
@@ -1001,6 +1082,12 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1001
1082
|
return;
|
|
1002
1083
|
}
|
|
1003
1084
|
|
|
1085
|
+
case "g":
|
|
1086
|
+
case "glossary": {
|
|
1087
|
+
await doGlossary(rest);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1004
1091
|
case "cmd":
|
|
1005
1092
|
case "tree": {
|
|
1006
1093
|
const path = rest || (ctx.type === "commands" ? ctx.path : "");
|
|
@@ -1193,14 +1280,9 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
1193
1280
|
// ── Action functions ──
|
|
1194
1281
|
|
|
1195
1282
|
async function doSearch(query: string): Promise<void> {
|
|
1196
|
-
const resp =
|
|
1197
|
-
if (resp.results.length === 0) {
|
|
1198
|
-
const extra = resp.note ? ` ${yellow("Hint:")} ${resp.note}` : "";
|
|
1199
|
-
console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}${extra}`);
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1283
|
+
const resp = searchAll(query);
|
|
1202
1284
|
await paged(renderSearchResults(resp));
|
|
1203
|
-
pushCtx({ type: "search", response: resp, results: resp.
|
|
1285
|
+
pushCtx({ type: "search", response: resp, results: resp.pages });
|
|
1204
1286
|
}
|
|
1205
1287
|
|
|
1206
1288
|
async function doPage(idOrTitle: string, sectionName?: string): Promise<void> {
|
|
@@ -1265,6 +1347,44 @@ async function doSearchProperties(query: string): Promise<void> {
|
|
|
1265
1347
|
pushCtx({ type: "properties", query, results: results.map(p => ({ name: p.name, page_id: p.page_id, page_title: p.page_title })) });
|
|
1266
1348
|
}
|
|
1267
1349
|
|
|
1350
|
+
async function doGlossary(rest: string): Promise<void> {
|
|
1351
|
+
if (!rest) {
|
|
1352
|
+
// List all glossary entries grouped by category
|
|
1353
|
+
const entries = listGlossary();
|
|
1354
|
+
if (entries.length === 0) {
|
|
1355
|
+
console.log(dim(" Glossary is empty."));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const grouped = new Map<string, GlossaryEntry[]>();
|
|
1359
|
+
for (const e of entries) {
|
|
1360
|
+
const list = grouped.get(e.category) || [];
|
|
1361
|
+
list.push(e);
|
|
1362
|
+
grouped.set(e.category, list);
|
|
1363
|
+
}
|
|
1364
|
+
const lines: string[] = [` ${bold("Glossary")} ${dim(`(${entries.length} terms)`)}\n`];
|
|
1365
|
+
for (const [cat, items] of grouped) {
|
|
1366
|
+
lines.push(` ${yellow(cat.toUpperCase())}`);
|
|
1367
|
+
for (const e of items) {
|
|
1368
|
+
const aliases = e.aliases ? ` ${dim(`(${e.aliases})`)}` : "";
|
|
1369
|
+
lines.push(` ${cyan(e.term)}${aliases} — ${e.definition}`);
|
|
1370
|
+
}
|
|
1371
|
+
lines.push("");
|
|
1372
|
+
}
|
|
1373
|
+
await paged(lines.join("\n"));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
// Look up a specific term
|
|
1377
|
+
const entry = lookupGlossary(rest);
|
|
1378
|
+
if (!entry) {
|
|
1379
|
+
console.log(dim(` Term "${rest}" not in glossary.`));
|
|
1380
|
+
console.log(` Try: ${cyan("glossary")} ${dim("(no args)")} to see all terms, or ${cyan("s")} ${rest}`);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const aliases = entry.aliases ? `\n ${dim("Aliases:")} ${entry.aliases}` : "";
|
|
1384
|
+
const hint = entry.search_hint ? `\n ${dim("Search:")} ${cyan(entry.search_hint)}` : "";
|
|
1385
|
+
console.log(`\n ${bold(entry.term)} ${dim(`[${entry.category}]`)}\n ${entry.definition}${aliases}${hint}\n`);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1268
1388
|
async function doCommandTree(path: string): Promise<void> {
|
|
1269
1389
|
// Extract optional @version suffix: "cmd /ip/address @7.15" or "cmd /ip add @7.20"
|
|
1270
1390
|
let version: string | undefined;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify.test.ts — Table-driven unit tests for the input classifier.
|
|
3
|
+
*
|
|
4
|
+
* Pure-function module — no DB required. Covers each detector in the DESIGN.md
|
|
5
|
+
* detector table plus real overlap cases (topic + version, command-path + topic).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import { classifyQuery } from "./classify.ts";
|
|
10
|
+
|
|
11
|
+
type Expectation = {
|
|
12
|
+
version?: string;
|
|
13
|
+
topics?: string[]; // must be a subset of detected topics (order-insensitive)
|
|
14
|
+
topicsExact?: string[]; // exact match on detected topics (order-sensitive)
|
|
15
|
+
command_path?: string;
|
|
16
|
+
fragment_pairs?: Array<{ key: string; value: string }>;
|
|
17
|
+
fragment_verbs?: string[];
|
|
18
|
+
device?: string;
|
|
19
|
+
property?: string;
|
|
20
|
+
none?: boolean; // assert no signals detected
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const CASES: Array<{ name: string; input: string; expect: Expectation }> = [
|
|
24
|
+
// --- Empty / noise ---
|
|
25
|
+
{ name: "empty input", input: "", expect: { none: true } },
|
|
26
|
+
{ name: "whitespace only", input: " \t ", expect: { none: true } },
|
|
27
|
+
|
|
28
|
+
// --- Topic detection (KNOWN_TOPICS) ---
|
|
29
|
+
{ name: "single topic — bgp", input: "bgp neighbor configuration", expect: { topics: ["bgp"] } },
|
|
30
|
+
{ name: "two topics — dhcp + server synonym", input: "dhcp server lease timeout", expect: { topics: ["dhcp"] } },
|
|
31
|
+
{ name: "firewall + filter compound", input: "how do I configure firewall filter", expect: { topics: ["firewall", "filter"] } },
|
|
32
|
+
{ name: "routing + ospf", input: "routing ospf area configuration", expect: { topics: ["routing", "ospf"] } },
|
|
33
|
+
{ name: "wireguard topic", input: "wireguard peer setup", expect: { topics: ["wireguard"] } },
|
|
34
|
+
{ name: "ipv6 topic", input: "ipv6 address configuration", expect: { topics: ["ipv6"] } },
|
|
35
|
+
|
|
36
|
+
// --- Version detection ---
|
|
37
|
+
{ name: "stable version 7.22", input: "what changed in 7.22", expect: { version: "7.22" } },
|
|
38
|
+
{ name: "patch version 7.22.1", input: "7.22.1 release notes", expect: { version: "7.22.1" } },
|
|
39
|
+
{ name: "beta version 7.23beta2", input: "whats new in 7.23beta2", expect: { version: "7.23beta2" } },
|
|
40
|
+
{ name: "rc version 7.22rc1", input: "7.22rc1 bug", expect: { version: "7.22rc1" } },
|
|
41
|
+
{ name: "version with v prefix", input: "v7.22 changelog", expect: { version: "7.22" } },
|
|
42
|
+
{ name: "first-match wins on two versions", input: "compare 7.21 and 7.22", expect: { version: "7.21" } },
|
|
43
|
+
|
|
44
|
+
// --- Topic + version overlap (DESIGN.md canonical example) ---
|
|
45
|
+
{
|
|
46
|
+
name: "topic + version + general",
|
|
47
|
+
input: "bgp 7.22 route reflection",
|
|
48
|
+
expect: { topics: ["bgp", "route"], version: "7.22" },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "changelog-like topic+version",
|
|
52
|
+
input: "firewall raw 7.22 breaking",
|
|
53
|
+
expect: { topics: ["firewall", "raw"], version: "7.22" },
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// --- Command path detection ---
|
|
57
|
+
{
|
|
58
|
+
name: "absolute slash path",
|
|
59
|
+
input: "/ip/firewall/filter",
|
|
60
|
+
expect: { command_path: "/ip/firewall/filter", topics: ["ip", "firewall", "filter"] },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "space-separated absolute path",
|
|
64
|
+
input: "/ip firewall filter",
|
|
65
|
+
expect: { command_path: "/ip/firewall/filter" },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "path without leading slash",
|
|
69
|
+
input: "ip firewall filter",
|
|
70
|
+
expect: { command_path: "/ip/firewall/filter" },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "system scheduler path",
|
|
74
|
+
input: "/system/scheduler",
|
|
75
|
+
expect: { command_path: "/system/scheduler", topics: ["system"] },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "ipv6 firewall raw",
|
|
79
|
+
input: "/ipv6/firewall/raw",
|
|
80
|
+
expect: { command_path: "/ipv6/firewall/raw", topics: ["ipv6", "firewall", "raw"] },
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// --- Command fragment (key=value, verbs) ---
|
|
84
|
+
{
|
|
85
|
+
name: "add with two pairs",
|
|
86
|
+
input: "add chain=forward action=accept",
|
|
87
|
+
expect: {
|
|
88
|
+
fragment_pairs: [{ key: "chain", value: "forward" }, { key: "action", value: "accept" }],
|
|
89
|
+
fragment_verbs: ["add"],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "set disabled=no",
|
|
94
|
+
input: "set disabled=no",
|
|
95
|
+
expect: {
|
|
96
|
+
fragment_pairs: [{ key: "disabled", value: "no" }],
|
|
97
|
+
fragment_verbs: ["set"],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "print without pairs",
|
|
102
|
+
input: "print detail",
|
|
103
|
+
expect: { fragment_verbs: ["print"] },
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
// --- Device model ---
|
|
107
|
+
{ name: "RB model", input: "RB1100AHx4 specs", expect: { device: "RB1100AHx4" } },
|
|
108
|
+
{ name: "CCR with dashes", input: "CCR2216-1G-12XS-2XQ throughput", expect: { device: "CCR2216-1G-12XS-2XQ" } },
|
|
109
|
+
{ name: "CRS switch", input: "CRS354 port count", expect: { device: "CRS354" } },
|
|
110
|
+
{ name: "hEX", input: "hEX S performance", expect: { device: "hEX" } },
|
|
111
|
+
{ name: "hAP ax", input: "hAP ax specs", expect: { device: "hAP" } },
|
|
112
|
+
{ name: "cAP ac", input: "cAP ac indoor", expect: { device: "cAP" } },
|
|
113
|
+
{ name: "SXTsq", input: "SXTsq 5 config", expect: { device: "SXTsq" } },
|
|
114
|
+
|
|
115
|
+
// --- Property name candidate (single short lowercase token) ---
|
|
116
|
+
{ name: "property — chain", input: "chain", expect: { property: "chain" } },
|
|
117
|
+
{ name: "property — fastpath", input: "fastpath", expect: { property: "fastpath" } },
|
|
118
|
+
{
|
|
119
|
+
name: "single known-topic token is NOT classified as property",
|
|
120
|
+
input: "bgp",
|
|
121
|
+
expect: { topics: ["bgp"] },
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// --- Non-exclusive / complex inputs ---
|
|
125
|
+
{
|
|
126
|
+
name: "device + topic",
|
|
127
|
+
input: "RB5009 wireguard performance",
|
|
128
|
+
expect: { device: "RB5009", topics: ["wireguard"] },
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "container add with verb",
|
|
132
|
+
input: "container add remote-image",
|
|
133
|
+
expect: { topics: ["container"], fragment_verbs: ["add"] },
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
describe("classifyQuery", () => {
|
|
138
|
+
for (const { name, input, expect: want } of CASES) {
|
|
139
|
+
test(name, () => {
|
|
140
|
+
const result = classifyQuery(input);
|
|
141
|
+
|
|
142
|
+
if (want.none) {
|
|
143
|
+
expect(result.version).toBeUndefined();
|
|
144
|
+
expect(result.command_path).toBeUndefined();
|
|
145
|
+
expect(result.command_fragment).toBeUndefined();
|
|
146
|
+
expect(result.device).toBeUndefined();
|
|
147
|
+
expect(result.property).toBeUndefined();
|
|
148
|
+
expect(result.topics).toEqual([]);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (want.version !== undefined) expect(result.version).toBe(want.version);
|
|
153
|
+
if (want.command_path !== undefined) expect(result.command_path).toBe(want.command_path);
|
|
154
|
+
if (want.device !== undefined) expect(result.device).toBe(want.device);
|
|
155
|
+
if (want.property !== undefined) expect(result.property).toBe(want.property);
|
|
156
|
+
|
|
157
|
+
if (want.topics !== undefined) {
|
|
158
|
+
for (const t of want.topics) {
|
|
159
|
+
expect(result.topics).toContain(t);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (want.topicsExact !== undefined) expect(result.topics).toEqual(want.topicsExact);
|
|
163
|
+
|
|
164
|
+
if (want.fragment_pairs !== undefined) {
|
|
165
|
+
expect(result.command_fragment?.pairs).toEqual(want.fragment_pairs);
|
|
166
|
+
}
|
|
167
|
+
if (want.fragment_verbs !== undefined) {
|
|
168
|
+
expect(result.command_fragment?.verbs).toEqual(want.fragment_verbs);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("multi-word input never fires property detector", () => {
|
|
174
|
+
const result = classifyQuery("chain forward policy");
|
|
175
|
+
expect(result.property).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("property detector yields to command_path", () => {
|
|
179
|
+
// "interface" is a top-level command and a known topic — path wins over property
|
|
180
|
+
const result = classifyQuery("interface");
|
|
181
|
+
expect(result.property).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("property detector yields to device", () => {
|
|
185
|
+
const result = classifyQuery("RB5009");
|
|
186
|
+
expect(result.property).toBeUndefined();
|
|
187
|
+
expect(result.device).toBe("RB5009");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("version regex doesn't swallow arbitrary decimals", () => {
|
|
191
|
+
const result = classifyQuery("interface stats show 5.0 Gbps");
|
|
192
|
+
expect(result.version).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("topics are deduplicated", () => {
|
|
196
|
+
const result = classifyQuery("firewall firewall filter firewall");
|
|
197
|
+
const firewallCount = result.topics.filter((t) => t === "firewall").length;
|
|
198
|
+
expect(firewallCount).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("topics capped at 5", () => {
|
|
202
|
+
const result = classifyQuery("bgp ospf rip dhcp dns routing wireguard ipsec");
|
|
203
|
+
expect(result.topics.length).toBeLessThanOrEqual(5);
|
|
204
|
+
});
|
|
205
|
+
});
|