blume 1.1.4 → 1.2.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/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/cli/index.js +1286 -63
- package/dist/cli/index.js.map +32 -21
- package/dist/types/core/config-input.d.ts +18 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/data.d.ts +1 -0
- package/dist/types/core/schema.d.ts +132 -17
- package/dist/types/core/types.d.ts +5 -3
- package/dist/types/openapi/references.d.ts +6 -0
- package/docs/advanced/api-reference.mdx +27 -0
- package/docs/advanced/changelog.mdx +10 -0
- package/docs/configuration/ai.mdx +38 -2
- package/docs/configuration/customization.mdx +27 -0
- package/docs/configuration/index.mdx +5 -0
- package/docs/content/navigation.mdx +12 -0
- package/docs/reference/cli.mdx +17 -13
- package/docs/reference/eval.mdx +106 -0
- package/docs/reference/meta.ts +1 -1
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +19 -1
- package/src/ai/llms.ts +9 -4
- package/src/ai/mcp/server.ts +19 -8
- package/src/ai/mcp/stdio.ts +35 -0
- package/src/astro/generate.ts +25 -2
- package/src/astro/templates.ts +114 -22
- package/src/cli/commands/eval.ts +291 -0
- package/src/cli/commands/init.ts +9 -4
- package/src/cli/commands/mcp-stdio.ts +36 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/required-secrets.ts +1 -1
- package/src/components/content/AccordionItem.astro +2 -2
- package/src/components/content/TreeFolder.astro +1 -2
- package/src/components/islands/AskAI.astro +9 -2
- package/src/components/islands/ask-ai.tsx +4 -2
- package/src/components/islands/hooks.ts +10 -4
- package/src/components/layout/NavTree.astro +37 -19
- package/src/components/layout/ReferenceLayout.astro +4 -0
- package/src/components/layout/RootLayout.astro +1 -1
- package/src/components/openapi/SchemaProperty.astro +3 -3
- package/src/core/config-input.ts +18 -0
- package/src/core/config.ts +4 -0
- package/src/core/data.ts +1 -0
- package/src/core/graph.ts +1 -0
- package/src/core/navigation.ts +9 -2
- package/src/core/schema.ts +51 -4
- package/src/core/server-features.ts +1 -1
- package/src/core/types.ts +5 -3
- package/src/eval/agents.ts +340 -0
- package/src/eval/findings.ts +103 -0
- package/src/eval/prompts.ts +78 -0
- package/src/eval/report.ts +214 -0
- package/src/eval/run.ts +290 -0
- package/src/eval/schema.ts +124 -0
- package/src/openapi/references.ts +23 -2
- package/src/openapi/render-mdx.ts +27 -4
- package/src/openapi/scalar.ts +1 -0
- package/src/openapi/source.ts +11 -4
- package/src/registry/eject.ts +23 -1
- package/src/search/build.ts +4 -3
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import type { AgentKind } from "../audit/agent.ts";
|
|
8
|
+
|
|
9
|
+
/** How long the SIGTERM on timeout gets to work before SIGKILL follows. */
|
|
10
|
+
const KILL_GRACE_MS = 5000;
|
|
11
|
+
|
|
12
|
+
/** The MCP tools a reader run may use — nothing else. */
|
|
13
|
+
export const MCP_TOOL_NAMES = [
|
|
14
|
+
"search_docs",
|
|
15
|
+
"get_page",
|
|
16
|
+
"list_pages",
|
|
17
|
+
"get_navigation",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
/** The MCP server name in the generated config; tool ids derive from it. */
|
|
21
|
+
const MCP_SERVER_NAME = "docs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Claude Code built-ins that would let the agent escape the docs-only
|
|
25
|
+
* sandbox: the reader must not read the repo, run commands, or search the
|
|
26
|
+
* web — it sees the documentation the way a stranger does, through MCP.
|
|
27
|
+
*/
|
|
28
|
+
const DISALLOWED_TOOLS = [
|
|
29
|
+
"Bash",
|
|
30
|
+
"Read",
|
|
31
|
+
"Glob",
|
|
32
|
+
"Grep",
|
|
33
|
+
"Write",
|
|
34
|
+
"Edit",
|
|
35
|
+
"NotebookEdit",
|
|
36
|
+
"WebFetch",
|
|
37
|
+
"WebSearch",
|
|
38
|
+
"Task",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** The captured outcome of one headless agent invocation. */
|
|
42
|
+
export interface HeadlessResult {
|
|
43
|
+
code: number;
|
|
44
|
+
stderr: string;
|
|
45
|
+
stdout: string;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HeadlessOptions {
|
|
50
|
+
cwd: string;
|
|
51
|
+
platform?: NodeJS.Platform;
|
|
52
|
+
prompt: string;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run an agent CLI headlessly: prompt over stdin (dodging argv limits and
|
|
58
|
+
* cmd.exe newline quoting alike), stdout and stderr captured, SIGTERM at the
|
|
59
|
+
* deadline with a SIGKILL follow-up. Resolves with the captured result;
|
|
60
|
+
* rejects only when the executable cannot be spawned at all (ENOENT).
|
|
61
|
+
*/
|
|
62
|
+
export const runAgentHeadless = (
|
|
63
|
+
bin: string,
|
|
64
|
+
args: string[],
|
|
65
|
+
options: HeadlessOptions
|
|
66
|
+
): Promise<HeadlessResult> =>
|
|
67
|
+
// oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
|
|
68
|
+
new Promise((resolve, reject) => {
|
|
69
|
+
const platform = options.platform ?? process.platform;
|
|
70
|
+
// npm installs agent CLIs as `.cmd` shims on Windows, which Node refuses
|
|
71
|
+
// to spawn without a shell. Arguments are plain flags and absolute paths,
|
|
72
|
+
// so shell interpolation has nothing to mangle.
|
|
73
|
+
const child = spawn(bin, args, {
|
|
74
|
+
cwd: options.cwd,
|
|
75
|
+
shell: platform === "win32",
|
|
76
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let stdout = "";
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
83
|
+
stdout += chunk.toString("utf-8");
|
|
84
|
+
});
|
|
85
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
86
|
+
stderr += chunk.toString("utf-8");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const deadline = setTimeout(() => {
|
|
90
|
+
timedOut = true;
|
|
91
|
+
child.kill("SIGTERM");
|
|
92
|
+
const hardKill = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
|
|
93
|
+
hardKill.unref();
|
|
94
|
+
}, options.timeoutMs);
|
|
95
|
+
deadline.unref();
|
|
96
|
+
|
|
97
|
+
child.once("error", (error) => {
|
|
98
|
+
clearTimeout(deadline);
|
|
99
|
+
reject(error);
|
|
100
|
+
});
|
|
101
|
+
child.once("close", (code) => {
|
|
102
|
+
clearTimeout(deadline);
|
|
103
|
+
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
104
|
+
});
|
|
105
|
+
// `close` waits for the stdio pipes, which a killed agent's own children
|
|
106
|
+
// (an MCP server, a shell) can hold open past the SIGTERM. A timed-out
|
|
107
|
+
// run's output is discarded anyway, so the process dying is enough.
|
|
108
|
+
child.once("exit", (code) => {
|
|
109
|
+
if (timedOut) {
|
|
110
|
+
clearTimeout(deadline);
|
|
111
|
+
resolve({ code: code ?? 1, stderr, stdout, timedOut });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
child.stdin.end(options.prompt);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** The spawn signature `runEval` accepts, injectable for tests. */
|
|
119
|
+
export type HeadlessRunner = typeof runAgentHeadless;
|
|
120
|
+
|
|
121
|
+
/** How the eval reaches the MCP stdio bridge from a spawned agent. */
|
|
122
|
+
export interface McpLaunch {
|
|
123
|
+
/** The generated MCP config file (claude's `--mcp-config`). */
|
|
124
|
+
configPath: string;
|
|
125
|
+
/** argv for the bridge process (codex's `-c mcp_servers` override). */
|
|
126
|
+
serverArgs: string[];
|
|
127
|
+
/** The executable launching the bridge. */
|
|
128
|
+
serverCommand: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Write the MCP config a reader run points its agent CLI at. The bridge is
|
|
133
|
+
* this same CLI relaunched (`blume mcp-stdio`), which resolves correctly from
|
|
134
|
+
* both a source checkout (bun + src/cli/index.ts) and an installed package
|
|
135
|
+
* (node + bin/blume.mjs).
|
|
136
|
+
*/
|
|
137
|
+
export const writeMcpConfig = async (
|
|
138
|
+
dir: string,
|
|
139
|
+
snapshotPath: string,
|
|
140
|
+
launcher?: { args: string[]; command: string }
|
|
141
|
+
): Promise<McpLaunch> => {
|
|
142
|
+
const resolved = launcher ?? {
|
|
143
|
+
args: [process.argv[1] ?? "", "mcp-stdio", "--data", snapshotPath],
|
|
144
|
+
command: process.execPath,
|
|
145
|
+
};
|
|
146
|
+
const configPath = join(dir, "mcp-config.json");
|
|
147
|
+
const config = {
|
|
148
|
+
mcpServers: {
|
|
149
|
+
[MCP_SERVER_NAME]: { args: resolved.args, command: resolved.command },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
await writeFile(configPath, JSON.stringify(config, null, 2));
|
|
153
|
+
return {
|
|
154
|
+
configPath,
|
|
155
|
+
serverArgs: resolved.args,
|
|
156
|
+
serverCommand: resolved.command,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export interface InvocationContext {
|
|
161
|
+
/** Where codex writes its final message; unused by claude. */
|
|
162
|
+
lastMessagePath: string;
|
|
163
|
+
/** The MCP bridge for reader runs; omitted for the judge. */
|
|
164
|
+
mcp?: McpLaunch;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const CLAUDE_READER_MAX_TURNS = "25";
|
|
168
|
+
const CLAUDE_JUDGE_MAX_TURNS = "1";
|
|
169
|
+
|
|
170
|
+
const claudeArgs = (context: InvocationContext): string[] => {
|
|
171
|
+
const base = ["-p", "--output-format", "json", "--strict-mcp-config"];
|
|
172
|
+
if (context.mcp) {
|
|
173
|
+
const allowed = MCP_TOOL_NAMES.map(
|
|
174
|
+
(tool) => `mcp__${MCP_SERVER_NAME}__${tool}`
|
|
175
|
+
).join(",");
|
|
176
|
+
return [
|
|
177
|
+
...base,
|
|
178
|
+
"--mcp-config",
|
|
179
|
+
context.mcp.configPath,
|
|
180
|
+
"--allowedTools",
|
|
181
|
+
allowed,
|
|
182
|
+
"--disallowedTools",
|
|
183
|
+
DISALLOWED_TOOLS.join(","),
|
|
184
|
+
"--max-turns",
|
|
185
|
+
CLAUDE_READER_MAX_TURNS,
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
...base,
|
|
190
|
+
"--disallowedTools",
|
|
191
|
+
DISALLOWED_TOOLS.join(","),
|
|
192
|
+
"--max-turns",
|
|
193
|
+
CLAUDE_JUDGE_MAX_TURNS,
|
|
194
|
+
];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const codexArgs = (context: InvocationContext): string[] => {
|
|
198
|
+
const base = [
|
|
199
|
+
"exec",
|
|
200
|
+
"--skip-git-repo-check",
|
|
201
|
+
"--ignore-user-config",
|
|
202
|
+
"--ephemeral",
|
|
203
|
+
"--sandbox",
|
|
204
|
+
"read-only",
|
|
205
|
+
"--output-last-message",
|
|
206
|
+
context.lastMessagePath,
|
|
207
|
+
];
|
|
208
|
+
if (context.mcp) {
|
|
209
|
+
// `-c` values parse as TOML; JSON string/array literals are valid TOML
|
|
210
|
+
// values, so JSON.stringify produces exactly the quoting codex expects.
|
|
211
|
+
return [
|
|
212
|
+
...base,
|
|
213
|
+
"-c",
|
|
214
|
+
`mcp_servers.${MCP_SERVER_NAME}.command=${JSON.stringify(
|
|
215
|
+
context.mcp.serverCommand
|
|
216
|
+
)}`,
|
|
217
|
+
"-c",
|
|
218
|
+
`mcp_servers.${MCP_SERVER_NAME}.args=${JSON.stringify(
|
|
219
|
+
context.mcp.serverArgs
|
|
220
|
+
)}`,
|
|
221
|
+
"-",
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
return [...base, "-"];
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Build the argv for one headless run; pass `mcp` for the reader role. */
|
|
228
|
+
export const agentArgs = (
|
|
229
|
+
kind: AgentKind,
|
|
230
|
+
context: InvocationContext
|
|
231
|
+
): string[] => (kind === "claude" ? claudeArgs(context) : codexArgs(context));
|
|
232
|
+
|
|
233
|
+
/** What one headless run produced, normalized across agent CLIs. */
|
|
234
|
+
export interface AgentOutput {
|
|
235
|
+
costUsd?: number;
|
|
236
|
+
detail?: string;
|
|
237
|
+
isError: boolean;
|
|
238
|
+
text: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const claudeResultSchema = z.object({
|
|
242
|
+
is_error: z.boolean().default(false),
|
|
243
|
+
result: z.string().default(""),
|
|
244
|
+
total_cost_usd: z.number().optional(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const tail = (value: string, max = 300): string => {
|
|
248
|
+
const trimmed = value.trim();
|
|
249
|
+
return trimmed.length > max ? trimmed.slice(-max) : trimmed;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Normalize a finished run into the answer text. Claude prints one JSON
|
|
254
|
+
* object on stdout (`--output-format json`); codex writes the final message
|
|
255
|
+
* to `--output-last-message` because its stdout interleaves progress.
|
|
256
|
+
*/
|
|
257
|
+
export const readAgentOutput = async (
|
|
258
|
+
kind: AgentKind,
|
|
259
|
+
result: HeadlessResult,
|
|
260
|
+
lastMessagePath: string
|
|
261
|
+
): Promise<AgentOutput> => {
|
|
262
|
+
if (result.timedOut) {
|
|
263
|
+
return { detail: "timed out", isError: true, text: "" };
|
|
264
|
+
}
|
|
265
|
+
if (result.code !== 0) {
|
|
266
|
+
return {
|
|
267
|
+
detail: tail(result.stderr) || `exited with code ${result.code}`,
|
|
268
|
+
isError: true,
|
|
269
|
+
text: "",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (kind === "claude") {
|
|
274
|
+
let parsed: unknown;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(result.stdout);
|
|
277
|
+
} catch {
|
|
278
|
+
return {
|
|
279
|
+
detail: "unparseable --output-format json payload",
|
|
280
|
+
isError: true,
|
|
281
|
+
text: "",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const payload = claudeResultSchema.safeParse(parsed);
|
|
285
|
+
if (!payload.success) {
|
|
286
|
+
return {
|
|
287
|
+
detail: "unexpected --output-format json shape",
|
|
288
|
+
isError: true,
|
|
289
|
+
text: "",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
costUsd: payload.data.total_cost_usd,
|
|
294
|
+
isError: payload.data.is_error,
|
|
295
|
+
text: payload.data.result,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let text: string;
|
|
300
|
+
try {
|
|
301
|
+
const raw = await readFile(lastMessagePath, "utf-8");
|
|
302
|
+
text = raw.trim();
|
|
303
|
+
} catch {
|
|
304
|
+
return { detail: "no last message written", isError: true, text: "" };
|
|
305
|
+
}
|
|
306
|
+
if (text === "") {
|
|
307
|
+
return { detail: "empty last message", isError: true, text: "" };
|
|
308
|
+
}
|
|
309
|
+
return { isError: false, text };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const verdictSchema = z.object({
|
|
313
|
+
missing: z.array(z.string()).default([]),
|
|
314
|
+
notes: z.string().default(""),
|
|
315
|
+
pass: z.boolean(),
|
|
316
|
+
score: z.number().min(0).max(1).optional(),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/** The judge's grade for one answer. */
|
|
320
|
+
export type Verdict = z.infer<typeof verdictSchema>;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extract the verdict JSON from a judge reply. Tolerates markdown fences and
|
|
324
|
+
* surrounding prose; returns undefined when no valid verdict can be found.
|
|
325
|
+
*/
|
|
326
|
+
export const parseVerdict = (text: string): Verdict | undefined => {
|
|
327
|
+
const start = text.indexOf("{");
|
|
328
|
+
const end = text.lastIndexOf("}");
|
|
329
|
+
if (start === -1 || end <= start) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
let parsed: unknown;
|
|
333
|
+
try {
|
|
334
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
335
|
+
} catch {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const result = verdictSchema.safeParse(parsed);
|
|
339
|
+
return result.success ? result.data : undefined;
|
|
340
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
|
+
import type { Diagnostic } from "../core/types.ts";
|
|
3
|
+
import { locateQuestion } from "./schema.ts";
|
|
4
|
+
import type { EvalQuestion } from "./schema.ts";
|
|
5
|
+
|
|
6
|
+
const DOCS_URL = "https://useblume.dev/docs/reference/eval";
|
|
7
|
+
|
|
8
|
+
/** Where a finding should anchor when no route hint matches a page. */
|
|
9
|
+
export interface EvalsAnchor {
|
|
10
|
+
path: string;
|
|
11
|
+
raw: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The manifest route a hint names, if any. */
|
|
15
|
+
const hintedRoute = (question: EvalQuestion, project: BlumeProject) => {
|
|
16
|
+
for (const hint of question.routes) {
|
|
17
|
+
const route = project.manifest.routes.find(
|
|
18
|
+
(candidate) => candidate.path === hint
|
|
19
|
+
);
|
|
20
|
+
if (route) {
|
|
21
|
+
return route;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Warnings for route hints that name no manifest route — docs move, and a
|
|
28
|
+
* finding that silently anchors to the evals file instead of the page it
|
|
29
|
+
* used to name is a debugging session; a warning is a one-line fix.
|
|
30
|
+
*/
|
|
31
|
+
export const routeFindings = (
|
|
32
|
+
question: EvalQuestion,
|
|
33
|
+
project: BlumeProject,
|
|
34
|
+
anchor: EvalsAnchor
|
|
35
|
+
): Diagnostic[] => {
|
|
36
|
+
const known = new Set(project.manifest.routes.map((route) => route.path));
|
|
37
|
+
return question.routes
|
|
38
|
+
.filter((hint) => !known.has(hint))
|
|
39
|
+
.map((hint) => ({
|
|
40
|
+
code: "BLUME_EVAL_ROUTE_UNKNOWN",
|
|
41
|
+
docsUrl: DOCS_URL,
|
|
42
|
+
file: anchor.path,
|
|
43
|
+
line: locateQuestion(anchor.raw, question.id),
|
|
44
|
+
message: `Question "${question.id}" hints at route "${hint}", which matches no page.`,
|
|
45
|
+
severity: "warning" as const,
|
|
46
|
+
suggestion:
|
|
47
|
+
"Update the question's `routes` to the page's current route, or remove the hint.",
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** What the finding needs to know about how the question went. */
|
|
52
|
+
export interface QuestionOutcome {
|
|
53
|
+
/** Why the run errored, when it did. */
|
|
54
|
+
detail?: string;
|
|
55
|
+
/** The expected facts the judge found absent or contradicted. */
|
|
56
|
+
missing: string[];
|
|
57
|
+
status: "error" | "fail";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The diagnostic for a failed or errored question, anchored to the page that
|
|
62
|
+
* should have answered it (via the first matching route hint) or, failing
|
|
63
|
+
* that, to the question's line in the evals file.
|
|
64
|
+
*/
|
|
65
|
+
export const questionFinding = (
|
|
66
|
+
question: EvalQuestion,
|
|
67
|
+
outcome: QuestionOutcome,
|
|
68
|
+
project: BlumeProject,
|
|
69
|
+
anchor: EvalsAnchor
|
|
70
|
+
): Diagnostic => {
|
|
71
|
+
const route = hintedRoute(question, project);
|
|
72
|
+
const site = route
|
|
73
|
+
? { file: route.sourcePath, url: route.path }
|
|
74
|
+
: { file: anchor.path, line: locateQuestion(anchor.raw, question.id) };
|
|
75
|
+
|
|
76
|
+
if (outcome.status === "error") {
|
|
77
|
+
return {
|
|
78
|
+
code: "BLUME_EVAL_QUESTION_ERROR",
|
|
79
|
+
docsUrl: DOCS_URL,
|
|
80
|
+
message: `Eval run failed for "${question.question}"${
|
|
81
|
+
outcome.detail ? ` — ${outcome.detail}` : ""
|
|
82
|
+
}.`,
|
|
83
|
+
severity: question.severity,
|
|
84
|
+
suggestion:
|
|
85
|
+
"Rerun `blume eval`; if it persists, check the agent CLI installation and the failure detail.",
|
|
86
|
+
...site,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const missing =
|
|
91
|
+
outcome.missing.length > 0
|
|
92
|
+
? ` — missing: ${outcome.missing.join("; ")}`
|
|
93
|
+
: "";
|
|
94
|
+
return {
|
|
95
|
+
code: "BLUME_EVAL_QUESTION_FAILED",
|
|
96
|
+
docsUrl: DOCS_URL,
|
|
97
|
+
message: `Docs could not answer: "${question.question}"${missing}`,
|
|
98
|
+
severity: question.severity,
|
|
99
|
+
suggestion:
|
|
100
|
+
"State the missing facts on this page, then rerun `blume eval`.",
|
|
101
|
+
...site,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { EvalQuestion } from "./schema.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The reader's instructions. The agent is spawned in an empty directory with
|
|
5
|
+
* its file/shell/web tools disabled, so the prompt's job is to direct it at
|
|
6
|
+
* the MCP docs tools and forbid the one escape hatch that remains: answering
|
|
7
|
+
* from prior knowledge of the product.
|
|
8
|
+
*/
|
|
9
|
+
export const readerPrompt = (question: EvalQuestion): string =>
|
|
10
|
+
`You are evaluating whether a product's documentation can answer a user's question.
|
|
11
|
+
|
|
12
|
+
Answer the question below using ONLY the connected documentation tools (search_docs, get_page, list_pages, get_navigation). Rules:
|
|
13
|
+
- Do not use prior knowledge about the product. Do not guess.
|
|
14
|
+
- Do not read files, run commands, or access the network.
|
|
15
|
+
- Search first, then read the most relevant pages with get_page.
|
|
16
|
+
- If the documentation does not contain the answer, say exactly what information is missing instead of inventing one.
|
|
17
|
+
|
|
18
|
+
Question: ${question.question}
|
|
19
|
+
|
|
20
|
+
Reply with a concise answer containing the specific facts the documentation provides. Plain text only.`;
|
|
21
|
+
|
|
22
|
+
/** The judge's instructions: grade the answer against the expected facts. */
|
|
23
|
+
export const judgePrompt = (question: EvalQuestion, answer: string): string => {
|
|
24
|
+
const facts = question.expected.map((fact) => `- ${fact}`).join("\n");
|
|
25
|
+
return `You are grading an answer against expected facts. Do not use any tools.
|
|
26
|
+
|
|
27
|
+
Question: ${question.question}
|
|
28
|
+
|
|
29
|
+
Expected facts — each must be present in substance (paraphrase is fine, contradiction is not):
|
|
30
|
+
${facts}
|
|
31
|
+
|
|
32
|
+
Answer to grade:
|
|
33
|
+
"""
|
|
34
|
+
${answer}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
An answer that states the documentation lacks the information FAILS.
|
|
38
|
+
|
|
39
|
+
Reply with ONLY this JSON object on a single line, no markdown fences:
|
|
40
|
+
{"pass": true|false, "score": 0.0-1.0, "missing": ["expected facts absent or contradicted"], "notes": "one sentence"}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The `--fix` handoff prompt: where the report is and the ground rules. */
|
|
44
|
+
export const evalFixPrompt = (reportPath: string): string =>
|
|
45
|
+
`Fix the documentation gaps found by \`blume eval\` in this project.
|
|
46
|
+
|
|
47
|
+
The full report is at ${reportPath}. It is JSON: each entry in \`eval.results\` with status "fail" is one question the documentation could not answer. Each carries the \`question\`, the \`expected\` facts, the judge's \`missing\` facts, and the reader agent's \`answer\` (what the docs currently convey). The matching \`diagnostics\` entry names the source \`file\` of the page that should answer it.
|
|
48
|
+
|
|
49
|
+
Work through every failed question:
|
|
50
|
+
1. Read the page named in the finding (or choose the best page when none is named).
|
|
51
|
+
2. Edit the documentation so it states the missing facts explicitly. Add prose, not filler; keep the page's voice.
|
|
52
|
+
3. Never delete questions from the evals file or weaken expected facts.
|
|
53
|
+
|
|
54
|
+
When you are done, run \`blume eval\` to verify, and repeat until every question passes.`;
|
|
55
|
+
|
|
56
|
+
/** The `eval init` prompt: draft a starter evals file from the docs. */
|
|
57
|
+
export const initPrompt = (evalsPath: string): string =>
|
|
58
|
+
`Draft a starter evals file for \`blume eval\` in this documentation project.
|
|
59
|
+
|
|
60
|
+
Read the documentation source pages in this project and write ${evalsPath} with about 10 high-value questions a real user would ask — installation, configuration, deployment, and the project's headline features. For each question, list the expected facts a correct answer must state, grounded in what the documentation actually promises (never invent facts the docs don't state).
|
|
61
|
+
|
|
62
|
+
The file format is YAML:
|
|
63
|
+
|
|
64
|
+
questions:
|
|
65
|
+
- id: kebab-case-slug
|
|
66
|
+
question: One user question?
|
|
67
|
+
expected:
|
|
68
|
+
- a fact the answer must contain
|
|
69
|
+
- another required fact
|
|
70
|
+
routes:
|
|
71
|
+
- /route/of/the/page/that/answers/it
|
|
72
|
+
|
|
73
|
+
Rules:
|
|
74
|
+
- Every \`expected\` fact must be verifiable in the docs today.
|
|
75
|
+
- Prefer questions whose answers live on one page; set \`routes\` to that page.
|
|
76
|
+
- Keep ids unique and questions short.
|
|
77
|
+
|
|
78
|
+
When you are done, print the file and suggest running \`blume eval\` to try it.`;
|