@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 +6 -6
- package/src/search/loop.ts +25 -14
- package/src/search/prefilter.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hevmind/ask",
|
|
3
|
-
"version": "0.3.
|
|
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-
|
|
32
|
-
"@hevmind/ask-
|
|
33
|
-
"@hevmind/ask-
|
|
34
|
-
"@hevmind/ask-
|
|
35
|
-
"@hevmind/ask-
|
|
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",
|
package/src/search/loop.ts
CHANGED
|
@@ -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
|
|
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
|
|
372
|
-
- When you reference a section, link to it inline using its exact \`url
|
|
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(
|
|
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:
|
|
476
|
-
//
|
|
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 =
|
|
480
|
-
if (node
|
|
485
|
+
const node = nodesById.get(candidate.id);
|
|
486
|
+
if (node && !seen.has(node.id)) seen.set(node.id, node);
|
|
481
487
|
}
|
|
482
|
-
if (
|
|
483
|
-
const sections = [...
|
|
484
|
-
messages.push({ role: 'user', content: `
|
|
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
|
|
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(
|
|
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.
|
package/src/search/prefilter.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
if (
|
|
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
|
})
|