@hevmind/ask 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hevmind/ask",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "hev ask: a heading-anchored, agentic search overlay for Astro docs sites.",
6
6
  "keywords": [
@@ -28,11 +28,11 @@
28
28
  "ask": "./bin/ask.mjs"
29
29
  },
30
30
  "optionalDependencies": {
31
- "@hevmind/ask-win32-x64": "0.3.0",
32
- "@hevmind/ask-linux-arm64": "0.3.0",
33
- "@hevmind/ask-darwin-arm64": "0.3.0",
34
- "@hevmind/ask-linux-x64": "0.3.0",
35
- "@hevmind/ask-darwin-x64": "0.3.0"
31
+ "@hevmind/ask-darwin-arm64": "0.3.1",
32
+ "@hevmind/ask-darwin-x64": "0.3.1",
33
+ "@hevmind/ask-linux-arm64": "0.3.1",
34
+ "@hevmind/ask-win32-x64": "0.3.1",
35
+ "@hevmind/ask-linux-x64": "0.3.1"
36
36
  },
37
37
  "exports": {
38
38
  ".": "./src/index.ts",
@@ -363,13 +363,13 @@ function routedDigestSystemPrompt(digest: Digest): AnthropicTextBlock[] {
363
363
  type: 'text',
364
364
  text: `You are the documentation assistant for this site. Answer the user's question using ONLY documentation sections you retrieve.
365
365
 
366
- The documentation is large, so it is not all shown here. Use search_sections to find sections relevant to the question, then read the ones you need with open_section for their summary and exact facts. Run a few searches with varied terms if the first does not surface what you need. Open every section your answer draws on you may only link to sections you opened.
366
+ The documentation is large, so it is not all shown here. Use search_sections to find relevant sections each result includes a short summary you can answer from directly. When you need a section's exact facts (flags, commands, identifiers), open_section it. One or two focused searches is plenty: once the results cover the question, STOP searching and answer. Do not keep searching for a perfect match.
367
367
 
368
368
  Write a short, direct answer in Markdown:
369
369
  - Start IMMEDIATELY with the substance. Your first sentence must answer the question. Never open with "Based on…", "Here is…", "Sure", a restatement of the question, or any summary/preamble.
370
370
  - Keep it tight: one or two short paragraphs, plus a short bullet list only if it genuinely helps. This renders in a small search popover, so do NOT use headings (#, ##) or horizontal rules (---).
371
- - For exact strings (flags, commands, identifiers, versions), quote the section's \`facts\` verbatim — never reword them.
372
- - When you reference a section, link to it inline using its exact \`url\`, e.g. [autoscaling](/docs/concepts#kubernetes-autoscaling). Never invent a URL or anchor.
371
+ - For exact strings (flags, commands, identifiers, versions), quote a section's \`facts\` verbatim — never reword them.
372
+ - When you reference a section, link to it inline using its exact \`url\` from your search results or open_section, e.g. [autoscaling](/docs/concepts#kubernetes-autoscaling). Never invent a URL or anchor.
373
373
  - If the documentation does not cover the question, say so plainly in one sentence and do not fabricate an answer.`,
374
374
  },
375
375
  {
@@ -415,6 +415,7 @@ async function* routedDigestAnswerLoop({
415
415
  const byId = new Map(chunks.map((chunk) => [chunk.id, chunk]));
416
416
  const nodesById = new Map(digest.nodes.map((node) => [node.id, node]));
417
417
  const opened = new Map<string, DigestNode>();
418
+ const seen = new Map<string, DigestNode>(); // sections surfaced by search, in rank order
418
419
  const messages: AnthropicMessage[] = [{ role: 'user', content: `Query: ${query}` }];
419
420
  const system = routedDigestSystemPrompt(digest);
420
421
 
@@ -450,10 +451,15 @@ async function* routedDigestAnswerLoop({
450
451
  if (block.name === 'search_sections') {
451
452
  const searchQuery = normalizeToolQuery(block.input) || query;
452
453
  yield { type: 'search', query: searchQuery };
454
+ const results = searchSections(searchQuery, chunks, nodesById, digest, config);
455
+ for (const result of results) {
456
+ const node = nodesById.get(result.id);
457
+ if (node && !seen.has(node.id)) seen.set(node.id, node);
458
+ }
453
459
  toolResults.push({
454
460
  type: 'tool_result',
455
461
  tool_use_id: block.id,
456
- content: JSON.stringify(searchSections(searchQuery, chunks, nodesById, digest, config)),
462
+ content: JSON.stringify(results),
457
463
  });
458
464
  } else if (block.name === 'open_section') {
459
465
  const id = normalizeId(block.input);
@@ -472,28 +478,33 @@ async function* routedDigestAnswerLoop({
472
478
  messages.push({ role: 'user', content: toolResults });
473
479
  }
474
480
 
475
- // Fallback: ground the answer even if the model opened nothing, by opening the
476
- // best keyword matches for the original query.
477
- if (!opened.size) {
481
+ // Fallback: if the model never searched or opened anything, ground on the best
482
+ // keyword matches for the original query so the answer isn't empty.
483
+ if (!opened.size && !seen.size) {
478
484
  for (const candidate of prefilter(chunks, query, digest.glossary, config.maxResults, config.perDocCap, digest.nodes)) {
479
- const node = open(candidate.id);
480
- if (node) yield { type: 'search', query: node.heading ?? node.title };
485
+ const node = nodesById.get(candidate.id);
486
+ if (node && !seen.has(node.id)) seen.set(node.id, node);
481
487
  }
482
- if (opened.size && lastRole(messages) !== 'user') {
483
- const sections = [...opened.values()].map((node) => openSectionResult(node, byId));
484
- messages.push({ role: 'user', content: `Opened sections:\n${JSON.stringify(sections)}` });
488
+ if (seen.size && lastRole(messages) !== 'user') {
489
+ const sections = [...seen.values()].map((node) => openSectionResult(node, byId));
490
+ messages.push({ role: 'user', content: `Relevant sections:\n${JSON.stringify(sections)}` });
485
491
  }
486
492
  }
487
493
 
494
+ // The answer is grounded in everything the model surfaced: sections it opened
495
+ // (full facts) ranked first, then the summaries from its searches. This lets it
496
+ // answer from search results without a separate open per section.
497
+ const grounded = new Map<string, DigestNode>([...opened, ...seen]);
498
+
488
499
  if (lastRole(messages) === 'assistant') {
489
500
  messages.push({
490
501
  role: 'user',
491
502
  content:
492
- 'Write the answer now. Begin directly with the answer itself — no preamble, no "based on…" opener, no headings. Link only to sections you opened, using their exact url.',
503
+ 'Write the answer now, grounded in the sections from your search results and any you opened. Begin directly with the answer — no preamble. Do NOT say you will search, check, or look further: you cannot, and you already have the context you will get. If the sections do not cover the question, say so in one sentence. Link only to section urls you have seen.',
493
504
  });
494
505
  }
495
506
 
496
- const sources = sourcesFromNodes(opened, config.maxResults);
507
+ const sources = sourcesFromNodes(grounded, config.maxResults);
497
508
  yield { type: 'sources', sources };
498
509
 
499
510
  // Phase 2: streamed answer turn — no tools, so the model can only answer.
@@ -48,13 +48,24 @@ export function prefilter(
48
48
  if (!terms.length) return [];
49
49
 
50
50
  const signal = nodeSignal(nodes);
51
+ // Inverse document frequency: down-weight terms common across the corpus
52
+ // (stopwords, ubiquitous words like "authentication" or "setup") so a rare,
53
+ // on-topic term ("oidc") dominates ranking. Without it, plain overlap buries
54
+ // the specific section under pages that merely share several common words —
55
+ // which degrades badly as the corpus grows (hundreds → thousands of sections).
56
+ const df = new Map<string, number>();
57
+ for (const chunk of chunks) for (const token of chunk.tokens) df.set(token, (df.get(token) ?? 0) + 1);
58
+ const total = chunks.length;
59
+ const weights = new Map(terms.map((term) => [term, Math.log(1 + total / (1 + (df.get(term) ?? 0)))]));
60
+
51
61
  const scored = chunks
52
62
  .map((chunk) => {
53
63
  const boost = signal.get(chunk.id);
54
64
  let score = 0;
55
65
  for (const term of terms) {
56
- if (chunk.tokens.has(term)) score += 1;
57
- if (boost?.has(term)) score += 1;
66
+ const weight = weights.get(term) ?? 0;
67
+ if (chunk.tokens.has(term)) score += weight;
68
+ if (boost?.has(term)) score += weight;
58
69
  }
59
70
  return { chunk, score };
60
71
  })