intent.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. /**
  2. * intent.test.ts - Tests for the intent feature
  3. *
  4. * Tests cover:
  5. * - extractIntentTerms: stop word filtering, punctuation, acronyms, edge cases
  6. * - extractSnippet with intent: disambiguation across multiple document sections
  7. * - parseStructuredQuery with intent: lines (parsing, validation, error cases)
  8. * - Chunk selection scoring with intent
  9. * - Strong-signal bypass when intent is present
  10. * - Intent constants
  11. *
  12. * Run with: npx vitest run test/intent.test.ts
  13. */
  14. import { describe, test, expect } from "vitest";
  15. import {
  16. extractSnippet,
  17. extractIntentTerms,
  18. INTENT_WEIGHT_SNIPPET,
  19. INTENT_WEIGHT_CHUNK,
  20. type ExpandedQuery,
  21. } from "../src/store.js";
  22. // =============================================================================
  23. // parseStructuredQuery — duplicated from src/qmd.ts for unit testing
  24. // (qmd.ts doesn't export it since it's a CLI internal)
  25. // =============================================================================
  26. interface ParsedStructuredQuery {
  27. searches: ExpandedQuery[];
  28. intent?: string;
  29. }
  30. function parseStructuredQuery(query: string): ParsedStructuredQuery | null {
  31. const rawLines = query.split('\n').map((line, idx) => ({
  32. raw: line,
  33. trimmed: line.trim(),
  34. number: idx + 1,
  35. })).filter(line => line.trimmed.length > 0);
  36. if (rawLines.length === 0) return null;
  37. const prefixRe = /^(lex|vec|hyde):\s*/i;
  38. const expandRe = /^expand:\s*/i;
  39. const intentRe = /^intent:\s*/i;
  40. const typed: ExpandedQuery[] = [];
  41. let intent: string | undefined;
  42. for (const line of rawLines) {
  43. if (expandRe.test(line.trimmed)) {
  44. if (rawLines.length > 1) {
  45. throw new Error(`Line ${line.number} starts with expand:, but query documents cannot mix expand with typed lines. Submit a single expand query instead.`);
  46. }
  47. const text = line.trimmed.replace(expandRe, '').trim();
  48. if (!text) {
  49. throw new Error('expand: query must include text.');
  50. }
  51. return null;
  52. }
  53. if (intentRe.test(line.trimmed)) {
  54. if (intent !== undefined) {
  55. throw new Error(`Line ${line.number}: only one intent: line is allowed per query document.`);
  56. }
  57. const text = line.trimmed.replace(intentRe, '').trim();
  58. if (!text) {
  59. throw new Error(`Line ${line.number}: intent: must include text.`);
  60. }
  61. intent = text;
  62. continue;
  63. }
  64. const match = line.trimmed.match(prefixRe);
  65. if (match) {
  66. const type = match[1]!.toLowerCase() as 'lex' | 'vec' | 'hyde';
  67. const text = line.trimmed.slice(match[0].length).trim();
  68. if (!text) {
  69. throw new Error(`Line ${line.number} (${type}:) must include text.`);
  70. }
  71. if (/\r|\n/.test(text)) {
  72. throw new Error(`Line ${line.number} (${type}:) contains a newline. Keep each query on a single line.`);
  73. }
  74. typed.push({ type, query: text, line: line.number });
  75. continue;
  76. }
  77. if (rawLines.length === 1) {
  78. return null;
  79. }
  80. throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix. Each line in a query document must start with one.`);
  81. }
  82. if (intent && typed.length === 0) {
  83. throw new Error('intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.');
  84. }
  85. return typed.length > 0 ? { searches: typed, intent } : null;
  86. }
  87. // =============================================================================
  88. // extractIntentTerms
  89. // =============================================================================
  90. describe("extractIntentTerms", () => {
  91. test("filters stop words", () => {
  92. // "looking", "for", "notes", "about" are stop words
  93. expect(extractIntentTerms("looking for notes about latency optimization"))
  94. .toEqual(["latency", "optimization"]);
  95. });
  96. test("filters common function words", () => {
  97. // "what", "is", "the", "to", "find" are stop words; "best", "way" survive
  98. expect(extractIntentTerms("what is the best way to find"))
  99. .toEqual(["best", "way"]);
  100. });
  101. test("preserves domain terms", () => {
  102. expect(extractIntentTerms("web performance latency page load times"))
  103. .toEqual(["web", "performance", "latency", "page", "load", "times"]);
  104. });
  105. test("handles surrounding punctuation with Unicode awareness", () => {
  106. expect(extractIntentTerms("personal health, fitness, and endurance"))
  107. .toEqual(["personal", "health", "fitness", "endurance"]);
  108. });
  109. test("preserves internal hyphens", () => {
  110. expect(extractIntentTerms("self-hosted real-time (decision-making)"))
  111. .toEqual(["self-hosted", "real-time", "decision-making"]);
  112. });
  113. test("short domain terms survive (API, SQL, LLM)", () => {
  114. expect(extractIntentTerms("API design for LLM agents"))
  115. .toEqual(["api", "design", "llm", "agents"]);
  116. });
  117. test("returns empty for empty input", () => {
  118. expect(extractIntentTerms("")).toEqual([]);
  119. expect(extractIntentTerms(" ")).toEqual([]);
  120. });
  121. test("filters single-char terms", () => {
  122. const terms = extractIntentTerms("a b c web");
  123. expect(terms).toEqual(["web"]);
  124. });
  125. test("all stop words returns empty", () => {
  126. const terms = extractIntentTerms("the and or but in on at to for of with by");
  127. expect(terms).toEqual([]);
  128. });
  129. test("preserves 2-char domain terms (CI, CD, DB)", () => {
  130. const terms = extractIntentTerms("SQL CI CD DB");
  131. expect(terms).toContain("sql");
  132. expect(terms).toContain("ci");
  133. expect(terms).toContain("cd");
  134. expect(terms).toContain("db");
  135. });
  136. test("lowercases all terms", () => {
  137. const terms = extractIntentTerms("WebSocket HTTP REST");
  138. expect(terms).toContain("websocket");
  139. expect(terms).toContain("http");
  140. expect(terms).toContain("rest");
  141. });
  142. test("handles C++ style punctuation", () => {
  143. const terms = extractIntentTerms("C++, performance! optimization.");
  144. expect(terms).toContain("performance");
  145. expect(terms).toContain("optimization");
  146. });
  147. });
  148. // =============================================================================
  149. // extractSnippet with intent — disambiguation
  150. // =============================================================================
  151. describe("extractSnippet with intent", () => {
  152. // Each section contains "performance" so the query score is tied (1.0 each).
  153. // Intent terms (INTENT_WEIGHT_SNIPPET) then break the tie toward the relevant section.
  154. const body = [
  155. "# Notes on Various Topics",
  156. "",
  157. "## Web Performance Section",
  158. "Web performance means optimizing page load times and Core Web Vitals.",
  159. "Reduce latency, improve rendering speed, and measure performance budgets.",
  160. "",
  161. "## Team Performance Section",
  162. "Team performance depends on trust, psychological safety, and feedback.",
  163. "Build culture where performance reviews drive growth not fear.",
  164. "",
  165. "## Health Performance Section",
  166. "Health performance comes from consistent exercise, sleep, and endurance.",
  167. "Track fitness metrics, optimize recovery, and monitor healthspan.",
  168. ].join("\n");
  169. test("without intent, anchors on query terms only", () => {
  170. const result = extractSnippet(body, "performance", 500);
  171. // "performance" appears in title and multiple sections — should anchor on first match
  172. expect(result.snippet).toContain("Performance");
  173. });
  174. test("with web-perf intent, prefers web performance section", () => {
  175. const result = extractSnippet(
  176. body, "performance", 500,
  177. undefined, undefined,
  178. "Looking for notes about web performance, latency, and page load times"
  179. );
  180. expect(result.snippet).toMatch(/latency|page.*load|Core Web Vitals/i);
  181. });
  182. test("with health intent, prefers health section", () => {
  183. const result = extractSnippet(
  184. body, "performance", 500,
  185. undefined, undefined,
  186. "Looking for notes about personal health, fitness, and endurance"
  187. );
  188. expect(result.snippet).toMatch(/health|fitness|endurance|exercise/i);
  189. });
  190. test("with team intent, prefers team section", () => {
  191. const result = extractSnippet(
  192. body, "performance", 500,
  193. undefined, undefined,
  194. "Looking for notes about building high-performing teams and culture"
  195. );
  196. expect(result.snippet).toMatch(/team|culture|trust|feedback/i);
  197. });
  198. test("intent does not override strong query match", () => {
  199. // Query "Core Web Vitals" is very specific — intent shouldn't pull away from it
  200. const result = extractSnippet(
  201. body, "Core Web Vitals", 500,
  202. undefined, undefined,
  203. "Looking for notes about health and fitness"
  204. );
  205. expect(result.snippet).toContain("Core Web Vitals");
  206. });
  207. test("absent intent produces same result as undefined", () => {
  208. const withoutIntent = extractSnippet(body, "performance", 500);
  209. const withUndefined = extractSnippet(body, "performance", 500, undefined, undefined, undefined);
  210. expect(withoutIntent.line).toBe(withUndefined.line);
  211. expect(withoutIntent.snippet).toBe(withUndefined.snippet);
  212. });
  213. test("intent with no matching terms falls back to query-only scoring", () => {
  214. const result = extractSnippet(
  215. body, "performance", 500,
  216. undefined, undefined,
  217. "quantum computing and entanglement"
  218. );
  219. expect(result.snippet).toContain("Performance");
  220. expect(result.snippet.length).toBeGreaterThan(0);
  221. });
  222. test("intent works with chunk position", () => {
  223. const webPerfStart = body.indexOf("## Web Performance");
  224. const result = extractSnippet(
  225. body, "performance", 500,
  226. webPerfStart, 200,
  227. "web page load times"
  228. );
  229. expect(result.snippet).toMatch(/Web Performance|Core Web Vitals|Page load/i);
  230. });
  231. });
  232. // =============================================================================
  233. // extractSnippet — intent weight verification
  234. // =============================================================================
  235. describe("extractSnippet intent weight behavior", () => {
  236. // Document where query term appears on every line but intent terms differ
  237. const body = [
  238. "performance metrics for team velocity",
  239. "performance metrics for web latency",
  240. "performance metrics for athletic endurance",
  241. ].join("\n");
  242. test("intent breaks tie when query matches all lines equally", () => {
  243. const noIntent = extractSnippet(body, "performance metrics", 500);
  244. // Without intent, first line wins (all equal score)
  245. expect(noIntent.line).toBe(1);
  246. const withIntent = extractSnippet(
  247. body, "performance metrics", 500,
  248. undefined, undefined,
  249. "web latency and page speed"
  250. );
  251. // Intent terms "web", "latency" match line 2
  252. expect(withIntent.snippet).toContain("web latency");
  253. });
  254. });
  255. // =============================================================================
  256. // Chunk selection scoring with intent
  257. // =============================================================================
  258. describe("intent keyword extraction logic", () => {
  259. // Mirrors the chunk selection scoring in hybridQuery, using the shared
  260. // extractIntentTerms helper and INTENT_WEIGHT_CHUNK constant.
  261. function scoreChunk(text: string, query: string, intent?: string): number {
  262. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
  263. const intentTerms = intent ? extractIntentTerms(intent) : [];
  264. const lower = text.toLowerCase();
  265. const qScore = queryTerms.reduce((acc, term) => acc + (lower.includes(term) ? 1 : 0), 0);
  266. const iScore = intentTerms.reduce((acc, term) => acc + (lower.includes(term) ? INTENT_WEIGHT_CHUNK : 0), 0);
  267. return qScore + iScore;
  268. }
  269. const chunks = [
  270. "Web performance: optimize page load times, reduce latency, improve rendering pipeline.",
  271. "Team performance: build trust, give feedback, set clear expectations for the group.",
  272. "Health performance: exercise regularly, sleep 8 hours, manage stress for endurance.",
  273. ];
  274. test("without intent, all chunks score equally on 'performance'", () => {
  275. const scores = chunks.map(c => scoreChunk(c, "performance"));
  276. // All contain "performance", so all score 1
  277. expect(scores[0]).toBe(scores[1]);
  278. expect(scores[1]).toBe(scores[2]);
  279. });
  280. test("with web intent, web chunk scores highest", () => {
  281. const intent = "looking for notes about page load times and latency optimization";
  282. const scores = chunks.map(c => scoreChunk(c, "performance", intent));
  283. expect(scores[0]).toBeGreaterThan(scores[1]!);
  284. expect(scores[0]).toBeGreaterThan(scores[2]!);
  285. });
  286. test("with health intent, health chunk scores highest", () => {
  287. const intent = "looking for notes about exercise, sleep, and endurance";
  288. const scores = chunks.map(c => scoreChunk(c, "performance", intent));
  289. expect(scores[2]).toBeGreaterThan(scores[0]!);
  290. expect(scores[2]).toBeGreaterThan(scores[1]!);
  291. });
  292. test("intent terms have lower weight than query terms (1.0)", () => {
  293. const intent = "looking for latency";
  294. // Chunk 0 has "performance" (query: 1.0) + "latency" (intent: INTENT_WEIGHT_CHUNK) = 1.5
  295. const withBoth = scoreChunk(chunks[0]!, "performance", intent);
  296. const queryOnly = scoreChunk(chunks[0]!, "performance");
  297. expect(withBoth).toBe(queryOnly + INTENT_WEIGHT_CHUNK);
  298. });
  299. test("stop words are filtered, short domain terms survive", () => {
  300. const intent = "the art of web performance";
  301. // "the" (stop word), "art" (survives), "of" (stop word),
  302. // "web" (survives), "performance" (survives)
  303. // intent terms after filtering: ["art", "web", "performance"]
  304. // Chunk 0 has "web" + "performance" → 2 intent hits (no "art")
  305. // Chunks 1,2 have "performance" only → 1 intent hit
  306. const scores = chunks.map(c => scoreChunk(c, "test", intent));
  307. expect(scores[0]).toBe(INTENT_WEIGHT_CHUNK * 2); // "web" + "performance"
  308. expect(scores[1]).toBe(INTENT_WEIGHT_CHUNK); // "performance" only
  309. expect(scores[2]).toBe(INTENT_WEIGHT_CHUNK); // "performance" only
  310. });
  311. });
  312. // =============================================================================
  313. // Strong-signal bypass with intent
  314. // =============================================================================
  315. describe("strong-signal bypass logic", () => {
  316. // Mirrors the logic in hybridQuery:
  317. // const hasStrongSignal = !intent && topScore >= STRONG_SIGNAL_MIN_SCORE && gap >= STRONG_SIGNAL_MIN_GAP
  318. function hasStrongSignal(topScore: number, secondScore: number, intent?: string): boolean {
  319. return !intent
  320. && topScore >= 0.85
  321. && (topScore - secondScore) >= 0.15;
  322. }
  323. test("strong signal detected without intent", () => {
  324. expect(hasStrongSignal(0.90, 0.70)).toBe(true);
  325. });
  326. test("strong signal bypassed when intent provided", () => {
  327. expect(hasStrongSignal(0.90, 0.70, "looking for health performance")).toBe(false);
  328. });
  329. test("weak signal not affected by intent", () => {
  330. expect(hasStrongSignal(0.50, 0.45)).toBe(false);
  331. expect(hasStrongSignal(0.50, 0.45, "some intent")).toBe(false);
  332. });
  333. test("close scores not strong even without intent", () => {
  334. expect(hasStrongSignal(0.90, 0.80)).toBe(false); // gap < 0.15
  335. });
  336. });
  337. // =============================================================================
  338. // parseStructuredQuery with intent
  339. // =============================================================================
  340. describe("parseStructuredQuery with intent", () => {
  341. test("parses intent + lex query", () => {
  342. const result = parseStructuredQuery("intent: web performance\nlex: performance");
  343. expect(result).not.toBeNull();
  344. expect(result!.intent).toBe("web performance");
  345. expect(result!.searches).toHaveLength(1);
  346. expect(result!.searches[0]!.type).toBe("lex");
  347. expect(result!.searches[0]!.query).toBe("performance");
  348. });
  349. test("parses intent + multiple typed lines", () => {
  350. const result = parseStructuredQuery(
  351. "intent: web page load times\nlex: performance\nvec: how to improve performance"
  352. );
  353. expect(result).not.toBeNull();
  354. expect(result!.intent).toBe("web page load times");
  355. expect(result!.searches).toHaveLength(2);
  356. expect(result!.searches[0]!.type).toBe("lex");
  357. expect(result!.searches[1]!.type).toBe("vec");
  358. });
  359. test("intent can appear after typed lines", () => {
  360. const result = parseStructuredQuery(
  361. "lex: performance\nintent: web page load times\nvec: latency"
  362. );
  363. expect(result).not.toBeNull();
  364. expect(result!.intent).toBe("web page load times");
  365. expect(result!.searches).toHaveLength(2);
  366. });
  367. test("intent is case-insensitive prefix", () => {
  368. const result = parseStructuredQuery("Intent: web perf\nlex: performance");
  369. expect(result).not.toBeNull();
  370. expect(result!.intent).toBe("web perf");
  371. });
  372. test("no intent returns undefined", () => {
  373. const result = parseStructuredQuery("lex: performance\nvec: speed");
  374. expect(result).not.toBeNull();
  375. expect(result!.intent).toBeUndefined();
  376. });
  377. test("intent alone throws error", () => {
  378. expect(() => parseStructuredQuery("intent: web performance")).toThrow(
  379. /intent: cannot appear alone/
  380. );
  381. });
  382. test("multiple intent lines throw error", () => {
  383. expect(() =>
  384. parseStructuredQuery("intent: web perf\nintent: team health\nlex: performance")
  385. ).toThrow(/only one intent: line is allowed/);
  386. });
  387. test("empty intent text throws error", () => {
  388. expect(() =>
  389. parseStructuredQuery("intent:\nlex: performance")
  390. ).toThrow(/intent: must include text/);
  391. });
  392. test("intent with whitespace-only text throws error", () => {
  393. expect(() =>
  394. parseStructuredQuery("intent: \nlex: performance")
  395. ).toThrow(/intent: must include text/);
  396. });
  397. test("single plain line still returns null (expand mode)", () => {
  398. const result = parseStructuredQuery("how does auth work");
  399. expect(result).toBeNull();
  400. });
  401. test("expand: line still returns null", () => {
  402. const result = parseStructuredQuery("expand: auth stuff");
  403. expect(result).toBeNull();
  404. });
  405. test("intent with expand throws error (expand can't mix)", () => {
  406. expect(() =>
  407. parseStructuredQuery("intent: web\nexpand: performance")
  408. ).toThrow(/cannot mix expand/);
  409. });
  410. test("empty query returns null", () => {
  411. expect(parseStructuredQuery("")).toBeNull();
  412. expect(parseStructuredQuery(" \n \n ")).toBeNull();
  413. });
  414. test("intent with blank lines is fine", () => {
  415. const result = parseStructuredQuery(
  416. "intent: web perf\n\nlex: performance\n\nvec: speed"
  417. );
  418. expect(result).not.toBeNull();
  419. expect(result!.intent).toBe("web perf");
  420. expect(result!.searches).toHaveLength(2);
  421. });
  422. test("intent preserves full text including colons", () => {
  423. const result = parseStructuredQuery(
  424. "intent: web performance: LCP, FID, CLS\nlex: performance"
  425. );
  426. expect(result).not.toBeNull();
  427. expect(result!.intent).toBe("web performance: LCP, FID, CLS");
  428. });
  429. });
  430. // =============================================================================
  431. // Constants exported
  432. // =============================================================================
  433. describe("intent constants", () => {
  434. test("INTENT_WEIGHT_SNIPPET is 0.3", () => {
  435. expect(INTENT_WEIGHT_SNIPPET).toBe(0.3);
  436. });
  437. test("INTENT_WEIGHT_CHUNK is 0.5", () => {
  438. expect(INTENT_WEIGHT_CHUNK).toBe(0.5);
  439. });
  440. });