@hevmind/ask 0.1.0
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 +116 -0
- package/bin/ask-launcher.mjs +110 -0
- package/bin/ask.mjs +4 -0
- package/openapi.yaml +363 -0
- package/package.json +61 -0
- package/skills/build-digest/SKILL.md +164 -0
- package/src/components/SearchOverlay.astro +1375 -0
- package/src/components/markdown.ts +107 -0
- package/src/digest/build.ts +432 -0
- package/src/digest/cli.ts +148 -0
- package/src/digest/expand.ts +24 -0
- package/src/digest/facts.ts +77 -0
- package/src/digest/frontmatter.ts +41 -0
- package/src/digest/read.ts +63 -0
- package/src/digest/schema.ts +185 -0
- package/src/digest/verify.ts +116 -0
- package/src/endpoint.ts +247 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +146 -0
- package/src/llm.ts +239 -0
- package/src/observability.ts +213 -0
- package/src/search/chunk.ts +137 -0
- package/src/search/index.ts +44 -0
- package/src/search/loop.ts +525 -0
- package/src/search/prefilter.ts +93 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import {
|
|
2
|
+
callClaude,
|
|
3
|
+
streamClaude,
|
|
4
|
+
type AnthropicMessage,
|
|
5
|
+
type AnthropicResponse,
|
|
6
|
+
type AnthropicTextBlock,
|
|
7
|
+
type AnthropicTool,
|
|
8
|
+
type AnthropicToolResultBlock,
|
|
9
|
+
type AnthropicUsage,
|
|
10
|
+
type CallClaudeOptions,
|
|
11
|
+
type StreamEvent,
|
|
12
|
+
} from '../llm.ts';
|
|
13
|
+
import type { Digest, DigestNode } from '../digest/schema';
|
|
14
|
+
import type { Source } from '../components/markdown.ts';
|
|
15
|
+
import type { Chunk } from './chunk';
|
|
16
|
+
import { prefilter, type Candidate } from './prefilter.ts';
|
|
17
|
+
import { makeTelemetry, type Telemetry } from '../observability.ts';
|
|
18
|
+
|
|
19
|
+
export interface SearchLoopConfig {
|
|
20
|
+
model: string;
|
|
21
|
+
maxIterations: number;
|
|
22
|
+
candidatePerSearch: number;
|
|
23
|
+
perDocCap: number;
|
|
24
|
+
maxResults: number;
|
|
25
|
+
answerMaxTokens: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type { Source };
|
|
29
|
+
|
|
30
|
+
/** High-level events the endpoint forwards to the SSE stream. */
|
|
31
|
+
export type AgenticEvent =
|
|
32
|
+
| { type: 'search'; query: string }
|
|
33
|
+
| { type: 'sources'; sources: Source[] }
|
|
34
|
+
| { type: 'token'; text: string }
|
|
35
|
+
| { type: 'done' };
|
|
36
|
+
|
|
37
|
+
export type CallClaude = typeof callClaude;
|
|
38
|
+
export type StreamClaude = typeof streamClaude;
|
|
39
|
+
|
|
40
|
+
export interface AnswerLoopArgs {
|
|
41
|
+
apiKey: string;
|
|
42
|
+
query: string;
|
|
43
|
+
chunks: Chunk[];
|
|
44
|
+
digest: Digest;
|
|
45
|
+
config: SearchLoopConfig;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
call?: CallClaude;
|
|
48
|
+
stream?: StreamClaude;
|
|
49
|
+
/** PostHog LLM observability sink. Defaults to a no-op. */
|
|
50
|
+
telemetry?: Telemetry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function randomSpanId(): string {
|
|
54
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
55
|
+
return c?.randomUUID ? c.randomUUID() : `span-${Date.now()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run one non-streaming tool turn and emit an `$ai_generation` for it. The input
|
|
60
|
+
* snapshot is taken before the call so the trace shows exactly what the model saw.
|
|
61
|
+
*/
|
|
62
|
+
async function tracedCall(
|
|
63
|
+
call: CallClaude,
|
|
64
|
+
opts: CallClaudeOptions,
|
|
65
|
+
telemetry: Telemetry,
|
|
66
|
+
step: number,
|
|
67
|
+
): Promise<AnthropicResponse> {
|
|
68
|
+
const startedAt = Date.now();
|
|
69
|
+
const input = opts.messages.slice();
|
|
70
|
+
const response = await call(opts);
|
|
71
|
+
telemetry.generation({
|
|
72
|
+
spanId: randomSpanId(),
|
|
73
|
+
spanName: `turn ${step + 1}`,
|
|
74
|
+
model: opts.model,
|
|
75
|
+
input,
|
|
76
|
+
output: response.content,
|
|
77
|
+
usage: response.usage,
|
|
78
|
+
latencyMs: Date.now() - startedAt,
|
|
79
|
+
httpStatus: 200,
|
|
80
|
+
});
|
|
81
|
+
return response;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wrap the streamed answer turn: forward every event untouched, accumulate the
|
|
86
|
+
* answer text and token usage, then emit a single `$ai_generation` at the end.
|
|
87
|
+
*/
|
|
88
|
+
async function* tracedStream(
|
|
89
|
+
stream: StreamClaude,
|
|
90
|
+
opts: CallClaudeOptions,
|
|
91
|
+
telemetry: Telemetry,
|
|
92
|
+
): AsyncGenerator<StreamEvent> {
|
|
93
|
+
const startedAt = Date.now();
|
|
94
|
+
const input = opts.messages.slice();
|
|
95
|
+
let text = '';
|
|
96
|
+
let usage: AnthropicUsage | undefined;
|
|
97
|
+
for await (const event of stream(opts)) {
|
|
98
|
+
if (event.type === 'text') text += event.text;
|
|
99
|
+
else if (event.type === 'stop') usage = event.usage;
|
|
100
|
+
yield event;
|
|
101
|
+
}
|
|
102
|
+
telemetry.generation({
|
|
103
|
+
spanId: randomSpanId(),
|
|
104
|
+
spanName: 'answer',
|
|
105
|
+
model: opts.model,
|
|
106
|
+
input,
|
|
107
|
+
output: [{ type: 'text', text }],
|
|
108
|
+
usage,
|
|
109
|
+
latencyMs: Date.now() - startedAt,
|
|
110
|
+
httpStatus: 200,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Entry point. When the committed digest carries distilled `nodes`, the
|
|
116
|
+
* agent navigates that shadow digest (digest path). A node-less (v1 / degraded)
|
|
117
|
+
* digest falls back to the original keyword-search loop, unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export async function* runAgenticAnswerLoop(args: AnswerLoopArgs): AsyncGenerator<AgenticEvent> {
|
|
120
|
+
if (args.digest.nodes && args.digest.nodes.length > 0) {
|
|
121
|
+
yield* digestAnswerLoop(args);
|
|
122
|
+
} else {
|
|
123
|
+
yield* legacyAnswerLoop(args);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Graph path: navigate the distilled shadow digest and answer from it.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
const OPEN_SECTION_TOOL: AnthropicTool = {
|
|
132
|
+
name: 'open_section',
|
|
133
|
+
description:
|
|
134
|
+
'Open a documentation section by its id (taken from the map) to read its distilled summary, its exact facts (flags, code, identifiers), and — for reference sections — its source text. Open every section you draw your answer from; you may only cite sections you have opened.',
|
|
135
|
+
input_schema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
id: {
|
|
139
|
+
type: 'string',
|
|
140
|
+
description: 'The exact section id from the map, e.g. "concepts#kubernetes-autoscaling".',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
required: ['id'],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
async function* digestAnswerLoop({
|
|
148
|
+
apiKey,
|
|
149
|
+
query,
|
|
150
|
+
chunks,
|
|
151
|
+
digest,
|
|
152
|
+
config,
|
|
153
|
+
signal,
|
|
154
|
+
call = callClaude,
|
|
155
|
+
stream = streamClaude,
|
|
156
|
+
telemetry = makeTelemetry(),
|
|
157
|
+
}: AnswerLoopArgs): AsyncGenerator<AgenticEvent> {
|
|
158
|
+
const byId = new Map(chunks.map((chunk) => [chunk.id, chunk]));
|
|
159
|
+
const nodesById = new Map(digest.nodes.map((node) => [node.id, node]));
|
|
160
|
+
const opened = new Map<string, DigestNode>();
|
|
161
|
+
const messages: AnthropicMessage[] = [{ role: 'user', content: `Query: ${query}` }];
|
|
162
|
+
const system = buildDigestSystemPrompt(digest);
|
|
163
|
+
|
|
164
|
+
const open = (id: string): DigestNode | null => {
|
|
165
|
+
const node = nodesById.get(id);
|
|
166
|
+
if (node) opened.set(id, node);
|
|
167
|
+
return node ?? null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Phase 1: bounded loop of section opens (non-streaming tool turns).
|
|
171
|
+
for (let i = 0; i < config.maxIterations; i += 1) {
|
|
172
|
+
const response = await tracedCall(
|
|
173
|
+
call,
|
|
174
|
+
{
|
|
175
|
+
apiKey,
|
|
176
|
+
model: config.model,
|
|
177
|
+
system,
|
|
178
|
+
messages,
|
|
179
|
+
tools: [OPEN_SECTION_TOOL],
|
|
180
|
+
toolChoice: { type: 'auto' },
|
|
181
|
+
maxTokens: 1024,
|
|
182
|
+
signal,
|
|
183
|
+
},
|
|
184
|
+
telemetry,
|
|
185
|
+
i,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
189
|
+
const toolResults: AnthropicToolResultBlock[] = [];
|
|
190
|
+
|
|
191
|
+
for (const block of response.content) {
|
|
192
|
+
if (block.type !== 'tool_use' || block.name !== 'open_section') continue;
|
|
193
|
+
const id = normalizeId(block.input);
|
|
194
|
+
const node = open(id);
|
|
195
|
+
if (node) yield { type: 'search', query: node.heading ?? node.title };
|
|
196
|
+
toolResults.push({
|
|
197
|
+
type: 'tool_result',
|
|
198
|
+
tool_use_id: block.id,
|
|
199
|
+
content: node
|
|
200
|
+
? JSON.stringify(openSectionResult(node, byId))
|
|
201
|
+
: JSON.stringify({ error: `No section "${id}". Use an exact id from the map.` }),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!toolResults.length) break; // model is ready to answer
|
|
206
|
+
messages.push({ role: 'user', content: toolResults });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Fallback: ground the answer even if the model opened nothing, by opening the
|
|
210
|
+
// best keyword matches over the map.
|
|
211
|
+
if (!opened.size) {
|
|
212
|
+
for (const candidate of prefilter(chunks, query, digest.glossary, config.maxResults, config.perDocCap, digest.nodes)) {
|
|
213
|
+
const node = open(candidate.id);
|
|
214
|
+
if (node) yield { type: 'search', query: node.heading ?? node.title };
|
|
215
|
+
}
|
|
216
|
+
if (opened.size && lastRole(messages) !== 'user') {
|
|
217
|
+
const sections = [...opened.values()].map((node) => openSectionResult(node, byId));
|
|
218
|
+
messages.push({ role: 'user', content: `Opened sections:\n${JSON.stringify(sections)}` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The answer turn must start a fresh assistant response. If the loop ended on
|
|
223
|
+
// an assistant turn, nudge with a final user message so it isn't a prefill.
|
|
224
|
+
if (lastRole(messages) === 'assistant') {
|
|
225
|
+
messages.push({
|
|
226
|
+
role: 'user',
|
|
227
|
+
content:
|
|
228
|
+
'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.',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sources = sourcesFromNodes(opened, config.maxResults);
|
|
233
|
+
yield { type: 'sources', sources };
|
|
234
|
+
|
|
235
|
+
// Phase 2: streamed answer turn — no tools, so the model can only answer.
|
|
236
|
+
for await (const event of tracedStream(
|
|
237
|
+
stream,
|
|
238
|
+
{
|
|
239
|
+
apiKey,
|
|
240
|
+
model: config.model,
|
|
241
|
+
system: answerSystem(system, sources),
|
|
242
|
+
messages,
|
|
243
|
+
maxTokens: config.answerMaxTokens,
|
|
244
|
+
signal,
|
|
245
|
+
},
|
|
246
|
+
telemetry,
|
|
247
|
+
)) {
|
|
248
|
+
if (event.type === 'text' && event.text) yield { type: 'token', text: event.text };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
yield { type: 'done' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function openSectionResult(node: DigestNode, byId: Map<string, Chunk>) {
|
|
255
|
+
const base = {
|
|
256
|
+
id: node.id,
|
|
257
|
+
url: node.url,
|
|
258
|
+
heading: node.heading,
|
|
259
|
+
group: node.group,
|
|
260
|
+
mode: node.mode,
|
|
261
|
+
summary: node.summary,
|
|
262
|
+
facts: node.facts.map((fact) => ({ kind: fact.kind, literal: fact.literal })),
|
|
263
|
+
};
|
|
264
|
+
// Reference sections carry dense literals; hand the model the source text so it
|
|
265
|
+
// reads exact wording rather than trusting a paraphrase.
|
|
266
|
+
if (node.mode === 'source-primary') {
|
|
267
|
+
const text = byId.get(node.id)?.text ?? '';
|
|
268
|
+
return { ...base, text: text.length > 1200 ? text.slice(0, 1200) + '…' : text };
|
|
269
|
+
}
|
|
270
|
+
return base;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildDigestSystemPrompt(digest: Digest): AnthropicTextBlock[] {
|
|
274
|
+
return [
|
|
275
|
+
{
|
|
276
|
+
type: 'text',
|
|
277
|
+
text: `You are the documentation assistant for this site. Answer the user's question using ONLY the documentation sections you open with the open_section tool.
|
|
278
|
+
|
|
279
|
+
You are given a map of the documentation below: every section, its id, and a short summary. Open the sections you need (open_section), reading their summary and exact facts, then write your answer. You may run up to a few opens. Open every section your answer draws on — you may only link to sections you opened.
|
|
280
|
+
|
|
281
|
+
Write a short, direct answer in Markdown:
|
|
282
|
+
- 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.
|
|
283
|
+
- 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 (---).
|
|
284
|
+
- For exact strings (flags, commands, identifiers, versions), quote the section's \`facts\` verbatim — never reword them.
|
|
285
|
+
- 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.
|
|
286
|
+
- If the documentation does not cover the question, say so plainly in one sentence and do not fabricate an answer.`,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
type: 'text',
|
|
290
|
+
text: `<map>\n${digest.overview || renderNodeMap(digest.nodes)}\n</map>\n\n<summaries>\n${digest.nodes
|
|
291
|
+
.map((node) => `- \`${node.id}\`${node.mode === 'source-primary' ? ' (reference)' : ''}: ${node.summary}`)
|
|
292
|
+
.join('\n')}\n</summaries>`,
|
|
293
|
+
cache_control: { type: 'ephemeral' },
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Fallback map when a digest predates the stored `overview`. */
|
|
299
|
+
function renderNodeMap(nodes: DigestNode[]): string {
|
|
300
|
+
return nodes.map((node) => `- ${node.heading ?? node.title} — \`${node.id}\``).join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sourcesFromNodes(opened: Map<string, DigestNode>, maxResults: number): Source[] {
|
|
304
|
+
const sources: Source[] = [];
|
|
305
|
+
const urls = new Set<string>();
|
|
306
|
+
for (const node of opened.values()) {
|
|
307
|
+
if (urls.has(node.url)) continue;
|
|
308
|
+
urls.add(node.url);
|
|
309
|
+
sources.push({
|
|
310
|
+
title: node.title,
|
|
311
|
+
heading: node.heading ?? undefined,
|
|
312
|
+
url: node.url,
|
|
313
|
+
group: node.group ?? undefined,
|
|
314
|
+
terms: node.terms,
|
|
315
|
+
});
|
|
316
|
+
if (sources.length >= maxResults) break;
|
|
317
|
+
}
|
|
318
|
+
return sources;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeId(input: unknown): string {
|
|
322
|
+
return typeof (input as { id?: unknown })?.id === 'string' ? (input as { id: string }).id.trim() : '';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Legacy path: original keyword-search loop, used for node-less graphs.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
interface SeenCandidate {
|
|
330
|
+
chunk: Chunk;
|
|
331
|
+
snippet: string;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const SEARCH_TOOL: AnthropicTool = {
|
|
335
|
+
name: 'search',
|
|
336
|
+
description: 'Search the documentation heading chunks with a focused sub-query.',
|
|
337
|
+
input_schema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
query: { type: 'string', description: 'Focused keyword query or synonym expansion to search for.' },
|
|
341
|
+
},
|
|
342
|
+
required: ['query'],
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
async function* legacyAnswerLoop({
|
|
347
|
+
apiKey,
|
|
348
|
+
query,
|
|
349
|
+
chunks,
|
|
350
|
+
digest,
|
|
351
|
+
config,
|
|
352
|
+
signal,
|
|
353
|
+
call = callClaude,
|
|
354
|
+
stream = streamClaude,
|
|
355
|
+
telemetry = makeTelemetry(),
|
|
356
|
+
}: AnswerLoopArgs): AsyncGenerator<AgenticEvent> {
|
|
357
|
+
const byId = new Map(chunks.map((chunk) => [chunk.id, chunk]));
|
|
358
|
+
const seen = new Map<string, SeenCandidate>();
|
|
359
|
+
const messages: AnthropicMessage[] = [{ role: 'user', content: `Query: ${query}` }];
|
|
360
|
+
const system = buildSystemPrompt(digest);
|
|
361
|
+
|
|
362
|
+
// Phase 1: bounded, non-streaming search loop.
|
|
363
|
+
for (let i = 0; i < config.maxIterations; i += 1) {
|
|
364
|
+
const response = await tracedCall(
|
|
365
|
+
call,
|
|
366
|
+
{
|
|
367
|
+
apiKey,
|
|
368
|
+
model: config.model,
|
|
369
|
+
system,
|
|
370
|
+
messages,
|
|
371
|
+
tools: [SEARCH_TOOL],
|
|
372
|
+
toolChoice: { type: 'auto' },
|
|
373
|
+
maxTokens: 1024,
|
|
374
|
+
signal,
|
|
375
|
+
},
|
|
376
|
+
telemetry,
|
|
377
|
+
i,
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
messages.push({ role: 'assistant', content: response.content });
|
|
381
|
+
const toolResults: AnthropicToolResultBlock[] = [];
|
|
382
|
+
|
|
383
|
+
for (const block of response.content) {
|
|
384
|
+
if (block.type !== 'tool_use' || block.name !== 'search') continue;
|
|
385
|
+
const searchQuery = normalizeToolQuery(block.input) || query;
|
|
386
|
+
yield { type: 'search', query: searchQuery };
|
|
387
|
+
const fresh = runSearchTool(searchQuery, chunks, byId, seen, digest, config);
|
|
388
|
+
toolResults.push({
|
|
389
|
+
type: 'tool_result',
|
|
390
|
+
tool_use_id: block.id,
|
|
391
|
+
content: JSON.stringify(fresh.map((candidate) => candidateForToolResult(candidate, byId))),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!toolResults.length) break;
|
|
396
|
+
messages.push({ role: 'user', content: toolResults });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!seen.size) {
|
|
400
|
+
const fresh = runSearchTool(query, chunks, byId, seen, digest, config);
|
|
401
|
+
yield { type: 'search', query };
|
|
402
|
+
if (lastRole(messages) !== 'user') {
|
|
403
|
+
messages.push({
|
|
404
|
+
role: 'user',
|
|
405
|
+
content: `Search results:\n${JSON.stringify(fresh.map((candidate) => candidateForToolResult(candidate, byId)))}`,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (lastRole(messages) === 'assistant') {
|
|
411
|
+
messages.push({
|
|
412
|
+
role: 'user',
|
|
413
|
+
content:
|
|
414
|
+
'Write the answer now. Begin directly with the answer itself — no preamble, no "based on…" opener, no headings. Link only with the provided URLs.',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const sources = sourcesFromSeen(seen, config.maxResults);
|
|
419
|
+
yield { type: 'sources', sources };
|
|
420
|
+
|
|
421
|
+
for await (const event of tracedStream(
|
|
422
|
+
stream,
|
|
423
|
+
{
|
|
424
|
+
apiKey,
|
|
425
|
+
model: config.model,
|
|
426
|
+
system: answerSystem(system, sources),
|
|
427
|
+
messages,
|
|
428
|
+
maxTokens: config.answerMaxTokens,
|
|
429
|
+
signal,
|
|
430
|
+
},
|
|
431
|
+
telemetry,
|
|
432
|
+
)) {
|
|
433
|
+
if (event.type === 'text' && event.text) yield { type: 'token', text: event.text };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
yield { type: 'done' };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function runSearchTool(
|
|
440
|
+
searchQuery: string,
|
|
441
|
+
chunks: Chunk[],
|
|
442
|
+
byId: Map<string, Chunk>,
|
|
443
|
+
seen: Map<string, SeenCandidate>,
|
|
444
|
+
digest: Digest,
|
|
445
|
+
config: SearchLoopConfig,
|
|
446
|
+
): Candidate[] {
|
|
447
|
+
return prefilter(chunks, searchQuery, digest.glossary, config.candidatePerSearch, config.perDocCap, digest.nodes)
|
|
448
|
+
.filter((candidate) => !seen.has(candidate.id))
|
|
449
|
+
.map((candidate) => {
|
|
450
|
+
const chunk = byId.get(candidate.id);
|
|
451
|
+
if (chunk) seen.set(candidate.id, { chunk, snippet: candidate.snippet });
|
|
452
|
+
return candidate;
|
|
453
|
+
})
|
|
454
|
+
.filter((candidate) => byId.has(candidate.id));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function candidateForToolResult(candidate: Candidate, byId: Map<string, Chunk>) {
|
|
458
|
+
return {
|
|
459
|
+
id: candidate.id,
|
|
460
|
+
docTitle: candidate.docTitle,
|
|
461
|
+
heading: candidate.heading,
|
|
462
|
+
url: byId.get(candidate.id)?.url ?? '',
|
|
463
|
+
snippet: candidate.snippet,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function buildSystemPrompt(digest: Digest): AnthropicTextBlock[] {
|
|
468
|
+
return [
|
|
469
|
+
{
|
|
470
|
+
type: 'text',
|
|
471
|
+
text: `You are the documentation assistant for this site. Answer the user's question using ONLY the documentation sections returned by the search tool.
|
|
472
|
+
|
|
473
|
+
You decide how many searches to run. Issue focused sub-queries with the search tool: vary terms, try synonyms, and decompose multi-part questions. When you have gathered enough context, stop calling the search tool and write your answer.
|
|
474
|
+
|
|
475
|
+
Write a short, direct answer in Markdown:
|
|
476
|
+
- 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.
|
|
477
|
+
- 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 (---).
|
|
478
|
+
- Ground every claim in the retrieved sections.
|
|
479
|
+
- When you reference a section, link to it inline using its exact \`url\` from the search results, for example: [autoscaling](/docs/concepts#kubernetes-autoscaling). Never invent a URL or anchor — only link to URLs that appear in the search results.
|
|
480
|
+
- If the documentation does not cover the question, say so plainly in one sentence and do not fabricate an answer.`,
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
type: 'text',
|
|
484
|
+
text: `<domain_context>\n${digest.context || 'No digest context is available.'}\n</domain_context>`,
|
|
485
|
+
cache_control: { type: 'ephemeral' },
|
|
486
|
+
},
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Appends the grounding allow-list to the system prompt for the answer turn. */
|
|
491
|
+
function answerSystem(system: AnthropicTextBlock[], sources: Source[]): AnthropicTextBlock[] {
|
|
492
|
+
if (!sources.length) return system;
|
|
493
|
+
const list = sources.map((source) => `- ${source.url} (${source.heading ?? source.title})`).join('\n');
|
|
494
|
+
return [
|
|
495
|
+
...system,
|
|
496
|
+
{
|
|
497
|
+
type: 'text',
|
|
498
|
+
text: `You have finished gathering context. Write the answer now. Use only these URLs when linking:\n${list}`,
|
|
499
|
+
},
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function sourcesFromSeen(seen: Map<string, SeenCandidate>, maxResults: number): Source[] {
|
|
504
|
+
const sources: Source[] = [];
|
|
505
|
+
const urls = new Set<string>();
|
|
506
|
+
for (const { chunk } of seen.values()) {
|
|
507
|
+
if (urls.has(chunk.url)) continue;
|
|
508
|
+
urls.add(chunk.url);
|
|
509
|
+
sources.push({ title: chunk.docTitle, heading: chunk.heading, url: chunk.url, group: chunk.group });
|
|
510
|
+
if (sources.length >= maxResults) break;
|
|
511
|
+
}
|
|
512
|
+
return sources;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function lastRole(messages: AnthropicMessage[]): AnthropicMessage['role'] | undefined {
|
|
516
|
+
return messages[messages.length - 1]?.role;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function normalizeToolQuery(input: unknown): string {
|
|
520
|
+
return typeof (input as { query?: unknown })?.query === 'string' ? (input as { query: string }).query.trim() : '';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function toolUse(id: string, name: string, input: unknown): AnthropicResponse['content'][number] {
|
|
524
|
+
return { type: 'tool_use', id, name, input };
|
|
525
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { GlossaryEntry, DigestNode } from '../digest/schema';
|
|
2
|
+
import { expandQueryTerms } from '../digest/expand.ts';
|
|
3
|
+
import { tokenize } from './chunk.ts';
|
|
4
|
+
import type { Chunk } from './chunk';
|
|
5
|
+
|
|
6
|
+
export interface Candidate {
|
|
7
|
+
id: string;
|
|
8
|
+
docTitle: string;
|
|
9
|
+
group?: string;
|
|
10
|
+
heading?: string;
|
|
11
|
+
snippet: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Distinctive tokens the digest carries for a section: its `terms`, the
|
|
16
|
+
* tokens of its distilled `summary`, and the tokens of its verbatim `facts`. A
|
|
17
|
+
* query term hitting any of these means the digest considers that section central
|
|
18
|
+
* to the term, so it earns a ranking boost over an incidental body mention.
|
|
19
|
+
*/
|
|
20
|
+
function nodeSignal(nodes: DigestNode[] | undefined): Map<string, Set<string>> {
|
|
21
|
+
const signal = new Map<string, Set<string>>();
|
|
22
|
+
if (!nodes) return signal;
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
const tokens = new Set<string>(node.terms);
|
|
25
|
+
for (const token of tokenize(node.summary)) tokens.add(token);
|
|
26
|
+
for (const fact of node.facts) for (const token of tokenize(fact.literal)) tokens.add(token);
|
|
27
|
+
signal.set(node.id, tokens);
|
|
28
|
+
}
|
|
29
|
+
return signal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Keyword prefilter over heading chunks. Query terms are widened through the
|
|
34
|
+
* digest glossary, scored by token overlap — boosted by the digest's
|
|
35
|
+
* distilled per-section signal when `nodes` are present — then capped per
|
|
36
|
+
* document so one page cannot crowd out the rest of the result set. With no
|
|
37
|
+
* nodes it degrades to plain token overlap over the raw chunk text.
|
|
38
|
+
*/
|
|
39
|
+
export function prefilter(
|
|
40
|
+
chunks: Chunk[],
|
|
41
|
+
query: string,
|
|
42
|
+
glossary: GlossaryEntry[],
|
|
43
|
+
pool: number,
|
|
44
|
+
perDocCap: number,
|
|
45
|
+
nodes?: DigestNode[],
|
|
46
|
+
): Candidate[] {
|
|
47
|
+
const terms = expandQueryTerms(query, glossary);
|
|
48
|
+
if (!terms.length) return [];
|
|
49
|
+
|
|
50
|
+
const signal = nodeSignal(nodes);
|
|
51
|
+
const scored = chunks
|
|
52
|
+
.map((chunk) => {
|
|
53
|
+
const boost = signal.get(chunk.id);
|
|
54
|
+
let score = 0;
|
|
55
|
+
for (const term of terms) {
|
|
56
|
+
if (chunk.tokens.has(term)) score += 1;
|
|
57
|
+
if (boost?.has(term)) score += 1;
|
|
58
|
+
}
|
|
59
|
+
return { chunk, score };
|
|
60
|
+
})
|
|
61
|
+
.filter((candidate) => candidate.score > 0)
|
|
62
|
+
.sort((a, b) => b.score - a.score || a.chunk.id.localeCompare(b.chunk.id));
|
|
63
|
+
|
|
64
|
+
const perDoc = new Map<string, number>();
|
|
65
|
+
const capped = [];
|
|
66
|
+
for (const item of scored) {
|
|
67
|
+
const count = perDoc.get(item.chunk.docSlug) ?? 0;
|
|
68
|
+
if (count >= perDocCap) continue;
|
|
69
|
+
perDoc.set(item.chunk.docSlug, count + 1);
|
|
70
|
+
capped.push(item);
|
|
71
|
+
if (capped.length >= pool) break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return capped.map(({ chunk }) => ({
|
|
75
|
+
id: chunk.id,
|
|
76
|
+
docTitle: chunk.docTitle,
|
|
77
|
+
group: chunk.group,
|
|
78
|
+
heading: chunk.heading,
|
|
79
|
+
snippet: excerpt(chunk.text, terms),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function excerpt(text: string, terms: string[], radius = 200): string {
|
|
84
|
+
const lower = text.toLowerCase();
|
|
85
|
+
let pos = -1;
|
|
86
|
+
for (const term of terms) {
|
|
87
|
+
const i = lower.indexOf(term);
|
|
88
|
+
if (i !== -1 && (pos === -1 || i < pos)) pos = i;
|
|
89
|
+
}
|
|
90
|
+
const start = pos === -1 ? 0 : Math.max(0, pos - 40);
|
|
91
|
+
const slice = text.slice(start, start + radius).replace(/\s+/g, ' ').trim();
|
|
92
|
+
return (start > 0 ? '...' : '') + slice + (start + radius < text.length ? '...' : '');
|
|
93
|
+
}
|