docspack 0.2.0 → 0.3.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 +9 -3
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +138 -24
- package/dist/build.js.map +1 -1
- package/dist/cli.js +74 -131
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +20 -1
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +8 -4
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +13 -7
- package/dist/db.js.map +1 -1
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/eval.d.ts +52 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/eval.js +101 -0
- package/dist/eval.js.map +1 -0
- package/dist/help.d.ts +31 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +342 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +4 -2
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +6 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +25 -3
- package/dist/search.js.map +1 -1
- package/dist/spec.d.ts +21 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +44 -5
- package/dist/spec.js.map +1 -1
- package/dist/stopwords.d.ts +14 -0
- package/dist/stopwords.d.ts.map +1 -0
- package/dist/stopwords.js +121 -0
- package/dist/stopwords.js.map +1 -0
- package/dist/sync.js +1 -1
- package/dist/sync.js.map +1 -1
- package/dist/verify.d.ts +8 -2
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +48 -15
- package/dist/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/build.ts +178 -24
- package/src/cli.ts +91 -138
- package/src/config.ts +35 -5
- package/src/db.ts +13 -7
- package/src/doctor.ts +1 -1
- package/src/eval.ts +149 -0
- package/src/help.ts +382 -0
- package/src/index.ts +20 -0
- package/src/preview.ts +4 -1
- package/src/search.ts +33 -3
- package/src/spec.ts +66 -6
- package/src/stopwords.ts +120 -0
- package/src/sync.ts +1 -1
- package/src/verify.ts +69 -19
package/src/cli.ts
CHANGED
|
@@ -7,15 +7,10 @@ import { defaultStorePath, Store, silenceSqliteWarning } from "./db.js";
|
|
|
7
7
|
import { discoverPackages } from "./discovery.js";
|
|
8
8
|
import { type DoctorReport, runDoctor } from "./doctor.js";
|
|
9
9
|
import { DocspackError } from "./errors.js";
|
|
10
|
+
import { commandHelpText, findCommandHelp, globalHelpText } from "./help.js";
|
|
10
11
|
import { renderTree } from "./init/write.js";
|
|
11
12
|
import { previewPackage } from "./preview.js";
|
|
12
|
-
import {
|
|
13
|
-
DEFAULT_LIMIT,
|
|
14
|
-
DEFAULT_MAX_TOKENS,
|
|
15
|
-
type QueryResult,
|
|
16
|
-
queryDocs,
|
|
17
|
-
renderAnswer,
|
|
18
|
-
} from "./search.js";
|
|
13
|
+
import { type QueryResult, queryDocs, renderAnswer } from "./search.js";
|
|
19
14
|
import { AGENTS_SNIPPET, FEEDBACK_SNIPPET } from "./snippet.js";
|
|
20
15
|
import { syncProject } from "./sync.js";
|
|
21
16
|
import { verifyProject } from "./verify.js";
|
|
@@ -42,6 +37,10 @@ const OPTIONS = {
|
|
|
42
37
|
name: { type: "string" },
|
|
43
38
|
"pkg-version": { type: "string" },
|
|
44
39
|
pages: { type: "string" },
|
|
40
|
+
"max-chunk-tokens": { type: "string" },
|
|
41
|
+
"min-chunk-tokens": { type: "string" },
|
|
42
|
+
documents: { type: "string", multiple: true },
|
|
43
|
+
"min-hit-rate": { type: "string" },
|
|
45
44
|
mirror: { type: "string" },
|
|
46
45
|
mode: { type: "string" },
|
|
47
46
|
template: { type: "string" },
|
|
@@ -70,138 +69,14 @@ const OPTIONS = {
|
|
|
70
69
|
const EXIT_NOT_INDEXED = 3;
|
|
71
70
|
const EXIT_NO_MATCH = 4;
|
|
72
71
|
|
|
73
|
-
const HELP =
|
|
74
|
-
|
|
75
|
-
Usage
|
|
76
|
-
docspack <command> [options]
|
|
77
|
-
|
|
78
|
-
Commands
|
|
79
|
-
sync Index the docs packages this project depends on
|
|
80
|
-
ask <question> Answer from the local index — the command to give an agent
|
|
81
|
-
search <query> Same index, formatted for a human reading the terminal
|
|
82
|
-
list Show this project's docs packages and their index state
|
|
83
|
-
verify Check the docs still describe the code you installed
|
|
84
|
-
feedback <sub> Record documentation problems: add, list, submit, remove
|
|
85
|
-
mcp Serve the index over MCP instead, as a long-lived process
|
|
86
|
-
sources List curated sources that \`docspack build\` can fetch
|
|
87
|
-
|
|
88
|
-
Authoring
|
|
89
|
-
init Scaffold a documentation package, then build and check it
|
|
90
|
-
build [source] Generate the .llms/ payload for publishing
|
|
91
|
-
doctor Check a package the way the indexer and a reviewer would
|
|
92
|
-
preview <query> Answer a query from the local package, as an agent would
|
|
93
|
-
|
|
94
|
-
How it works
|
|
95
|
-
Documentation ships as npm packages named @vendor/docspack or
|
|
96
|
-
@docspack-community/<name>. Add them to package.json, run \`docspack sync\`, and
|
|
97
|
-
every chunk is indexed into a shared SQLite database with FTS5. Agents query
|
|
98
|
-
that index locally: no network, no scraping, always the installed version.
|
|
99
|
-
|
|
100
|
-
Giving an agent access
|
|
101
|
-
Any agent with a shell can run \`docspack ask\`, so one line in AGENTS.md or
|
|
102
|
-
CLAUDE.md is the whole setup — no server, no per-agent configuration, and
|
|
103
|
-
nothing resident when nobody is asking:
|
|
104
|
-
|
|
105
|
-
${AGENTS_SNIPPET.map((line) => ` ${line}`).join("\n")}
|
|
106
|
-
|
|
107
|
-
Add the block below as well if you want the agent to record documentation
|
|
108
|
-
problems it runs into. It writes to a local file; nothing is sent:
|
|
109
|
-
|
|
110
|
-
${FEEDBACK_SNIPPET.map((line) => ` ${line}`).join("\n")}
|
|
111
|
-
|
|
112
|
-
\`docspack mcp\` serves the same index over the Model Context Protocol for
|
|
113
|
-
clients that prefer a tool definition. Both return identical text.
|
|
114
|
-
|
|
115
|
-
Recording documentation problems
|
|
116
|
-
\`docspack feedback add\` writes a finding to .docspack/feedback.jsonl in this
|
|
117
|
-
project. Nothing is transmitted: docspack contains no code that can send a
|
|
118
|
-
report anywhere, and it never will.
|
|
119
|
-
|
|
120
|
-
docspack feedback add --chunk @acme/docspack@1.4.0/api-auth \\
|
|
121
|
-
--kind drift --evidence "client.setKey is not exported; setApiKey is"
|
|
122
|
-
|
|
123
|
-
A finding has to be falsifiable. \`drift\` must name the identifier that
|
|
124
|
-
drifted. \`incorrect\` and \`missing\` must carry --expected, --actual and
|
|
125
|
-
--repro, so a claim that cannot show its work cannot be recorded at all.
|
|
126
|
-
Recording the same problem again increments a counter instead of adding a
|
|
127
|
-
second entry.
|
|
128
|
-
|
|
129
|
-
\`docspack feedback submit\` prints a prefilled GitHub issue URL for every
|
|
130
|
-
finding whose vendor asked to receive it, and routes nothing anywhere else.
|
|
131
|
-
A vendor opts in from their own package.json:
|
|
132
|
-
|
|
133
|
-
"docspack": {
|
|
134
|
-
"feedback": {
|
|
135
|
-
"github": "acme/sdk",
|
|
136
|
-
"labels": ["documentation"],
|
|
137
|
-
"accepts": ["drift"]
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
No \`feedback\` block means no channel and no route. \`accepts\` narrows which
|
|
142
|
-
kinds reach them. A human opens the link, reads it, and files it.
|
|
143
|
-
|
|
144
|
-
Options
|
|
145
|
-
-y, --yes init: skip prompts and use flags plus detected defaults
|
|
146
|
-
--dry-run init: print the file tree and write nothing
|
|
147
|
-
--mirror <id|url> init: bootstrap from a published llms.txt
|
|
148
|
-
--community init: scaffold under @docspack-community
|
|
149
|
-
--no-workflow init: skip the release workflow
|
|
150
|
-
--no-build init: scaffold only
|
|
151
|
-
--template <t> init: full (default) or minimal
|
|
152
|
-
--strict doctor: treat structural warnings as failures
|
|
153
|
-
--pedantic doctor: --strict, and fail on prose style as well
|
|
154
|
-
--package-dir <d> doctor, preview, verify: the package to inspect (default: .)
|
|
155
|
-
--chunk <id> feedback add: the chunk the problem is in
|
|
156
|
-
--kind <k> feedback add: drift, incorrect or missing
|
|
157
|
-
--evidence <text> feedback add: the claim, in one line
|
|
158
|
-
--expected <text> feedback add: what the documentation led you to expect
|
|
159
|
-
--actual <text> feedback add: what happened instead
|
|
160
|
-
--repro <code> feedback add: code that demonstrates it
|
|
161
|
-
--force sync: re-index packages already in the store; init: overwrite files
|
|
162
|
-
-p, --package <s> ask, search: only packages whose name contains this text
|
|
163
|
-
--limit <n> ask, search, preview: maximum chunks to return (default ${DEFAULT_LIMIT})
|
|
164
|
-
--max-tokens <n> ask, search, preview: token ceiling for the result set
|
|
165
|
-
(default ${DEFAULT_MAX_TOKENS})
|
|
166
|
-
--all ask, search: the whole store, not just this project;
|
|
167
|
-
feedback remove: every recorded finding
|
|
168
|
-
--from <dir> build: directory of Markdown to package
|
|
169
|
-
--openapi <file> build: OpenAPI JSON to package, one chunk per operation
|
|
170
|
-
--name <name> build: package name, e.g. @acme/docspack
|
|
171
|
-
--pkg-version <v> build: package version
|
|
172
|
-
--out <dir> build: output directory (default: the current directory)
|
|
173
|
-
--pages <n> build: maximum documents to fetch from a remote source
|
|
174
|
-
--store <path> Use a different index (default: ${defaultStorePath()})
|
|
175
|
-
--cwd <dir> Run in a different directory
|
|
176
|
-
--json Machine-readable output
|
|
177
|
-
-q, --quiet Only print errors
|
|
178
|
-
-h, --help Show this help
|
|
179
|
-
-v, --version Show the version
|
|
180
|
-
|
|
181
|
-
Exit codes
|
|
182
|
-
\`ask\` and \`search\` answer with an exit code a script can branch on:
|
|
183
|
-
|
|
184
|
-
0 chunks were returned
|
|
185
|
-
${EXIT_NOT_INDEXED} nothing matched, and a docs package is installed but not indexed —
|
|
186
|
-
run \`docspack sync\`
|
|
187
|
-
${EXIT_NO_MATCH} nothing matched, and everything installed is already indexed
|
|
188
|
-
1 the command failed; 2 the command was used wrongly
|
|
189
|
-
|
|
190
|
-
Examples
|
|
191
|
-
npx docspack sync
|
|
192
|
-
npx docspack ask "how do I verify a webhook signature"
|
|
193
|
-
npx docspack search "webhook signature" --package stripe
|
|
194
|
-
npx docspack init
|
|
195
|
-
npx docspack init --name @acme/docspack --from ./docs --yes
|
|
196
|
-
npx docspack doctor --strict
|
|
197
|
-
npx docspack preview "how do I authenticate"
|
|
198
|
-
npx docspack verify
|
|
199
|
-
npx docspack feedback list
|
|
200
|
-
npx docspack feedback submit
|
|
201
|
-
`;
|
|
72
|
+
const HELP = globalHelpText(defaultStorePath(), EXIT_NOT_INDEXED, EXIT_NO_MATCH);
|
|
202
73
|
|
|
203
74
|
type Values = {
|
|
204
|
-
[K in keyof typeof OPTIONS]?: (typeof OPTIONS)[K]
|
|
75
|
+
[K in keyof typeof OPTIONS]?: (typeof OPTIONS)[K] extends { multiple: true }
|
|
76
|
+
? string[]
|
|
77
|
+
: (typeof OPTIONS)[K]["type"] extends "boolean"
|
|
78
|
+
? boolean
|
|
79
|
+
: string;
|
|
205
80
|
};
|
|
206
81
|
|
|
207
82
|
const color = process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
|
@@ -295,8 +170,21 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
295
170
|
return 0;
|
|
296
171
|
}
|
|
297
172
|
|
|
173
|
+
// `docspack doctor --help` and `docspack help doctor` are the same question, and the answer to
|
|
174
|
+
// both is that command's flags rather than the global page.
|
|
298
175
|
const [command = "help", ...rest] = positionals;
|
|
299
176
|
if (values.help === true || command === "help") {
|
|
177
|
+
const wanted = command === "help" ? rest[0] : command;
|
|
178
|
+
const subject = wanted === undefined ? undefined : findCommandHelp(wanted);
|
|
179
|
+
if (subject !== undefined) {
|
|
180
|
+
process.stdout.write(commandHelpText(subject));
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
if (command === "help" && wanted !== undefined) {
|
|
184
|
+
process.stderr.write(`${red("error")} unknown command "${wanted}"\n\n`);
|
|
185
|
+
process.stderr.write(HELP);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
300
188
|
process.stdout.write(HELP);
|
|
301
189
|
return 0;
|
|
302
190
|
}
|
|
@@ -508,8 +396,13 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
508
396
|
|
|
509
397
|
const mark = pkg.findings.length === 0 ? green("ok") : red("drift");
|
|
510
398
|
process.stdout.write(
|
|
511
|
-
`${mark} ${bold(pkg.id)} ${dim(`documents ${pkg.documents ?? ""} · ${plural(pkg.checked, "name")} checked, ${pkg.matched} declared`)}\n`,
|
|
399
|
+
`${mark} ${bold(pkg.id)} ${dim(`documents ${(pkg.documents ?? []).join(", ")} · ${plural(pkg.checked, "name")} checked, ${pkg.matched} declared`)}\n`,
|
|
512
400
|
);
|
|
401
|
+
if (pkg.unchecked !== undefined && pkg.unchecked.length > 0) {
|
|
402
|
+
process.stderr.write(
|
|
403
|
+
` ${yellow("!")} ${pkg.unchecked.join(", ")} could not be read, so the check ran against less than the package documents\n`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
513
406
|
for (const finding of pkg.findings) {
|
|
514
407
|
process.stderr.write(
|
|
515
408
|
` ${yellow(finding.entity)} is not declared; did the API become ${bold(finding.suggestion)}?\n`,
|
|
@@ -719,6 +612,15 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
719
612
|
const pages = integer(values.pages, "--pages");
|
|
720
613
|
return pages === undefined ? {} : { pages };
|
|
721
614
|
})(),
|
|
615
|
+
...(() => {
|
|
616
|
+
const max = integer(values["max-chunk-tokens"], "--max-chunk-tokens");
|
|
617
|
+
return max === undefined ? {} : { maxChunkTokens: max };
|
|
618
|
+
})(),
|
|
619
|
+
...(() => {
|
|
620
|
+
const min = integer(values["min-chunk-tokens"], "--min-chunk-tokens");
|
|
621
|
+
return min === undefined ? {} : { minChunkTokens: min };
|
|
622
|
+
})(),
|
|
623
|
+
...(values.documents === undefined ? {} : { documents: values.documents }),
|
|
722
624
|
...(quiet || json
|
|
723
625
|
? {}
|
|
724
626
|
: {
|
|
@@ -882,6 +784,57 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
882
784
|
return 0;
|
|
883
785
|
}
|
|
884
786
|
|
|
787
|
+
case "eval": {
|
|
788
|
+
const file = rest[0];
|
|
789
|
+
if (file === undefined) throw new DocspackError("Usage: docspack eval <queries.json>");
|
|
790
|
+
|
|
791
|
+
const { evaluatePackage, readEvalSet } = await import("./eval.js");
|
|
792
|
+
const report = await evaluatePackage({
|
|
793
|
+
dir: values["package-dir"] ?? cwd,
|
|
794
|
+
cases: await readEvalSet(file),
|
|
795
|
+
...(() => {
|
|
796
|
+
const limit = integer(values.limit, "--limit");
|
|
797
|
+
return limit === undefined ? {} : { limit };
|
|
798
|
+
})(),
|
|
799
|
+
...(() => {
|
|
800
|
+
const maxTokens = integer(values["max-tokens"], "--max-tokens");
|
|
801
|
+
return maxTokens === undefined ? {} : { maxTokens };
|
|
802
|
+
})(),
|
|
803
|
+
...(() => {
|
|
804
|
+
const rate = integer(values["min-hit-rate"], "--min-hit-rate");
|
|
805
|
+
return rate === undefined ? {} : { minHitRate: rate };
|
|
806
|
+
})(),
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
if (json) {
|
|
810
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
811
|
+
return report.ok ? 0 : 1;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const entry of report.cases) {
|
|
815
|
+
// A miss is only actionable next to what came back instead of the expected chunk.
|
|
816
|
+
const mark =
|
|
817
|
+
entry.rank === 1 ? green("1") : entry.rank > 0 ? yellow(String(entry.rank)) : red("—");
|
|
818
|
+
process.stdout.write(`${mark} ${entry.query}\n`);
|
|
819
|
+
if (entry.rank !== 1) {
|
|
820
|
+
process.stdout.write(` ${dim(`expected ${entry.expect.join(" | ")}`)}\n`);
|
|
821
|
+
process.stdout.write(
|
|
822
|
+
` ${dim(`returned ${entry.returned.length === 0 ? "nothing" : entry.returned.join(", ")}`)}\n`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const percent = (count: number): string =>
|
|
828
|
+
`${count}/${report.total} (${Math.round((count / report.total) * 100)}%)`;
|
|
829
|
+
process.stdout.write(
|
|
830
|
+
`\n${bold(`top-${report.limit}`)} ${percent(report.hits)} ${bold("top-1")} ${percent(report.top1)} ${dim(`~${report.averageTokens} tokens per answer`)}\n`,
|
|
831
|
+
);
|
|
832
|
+
if (!report.ok) {
|
|
833
|
+
process.stderr.write(`${red("failed")} below the required hit rate\n`);
|
|
834
|
+
}
|
|
835
|
+
return report.ok ? 0 : 1;
|
|
836
|
+
}
|
|
837
|
+
|
|
885
838
|
case "sources": {
|
|
886
839
|
const { entries: registryEntries } = await import("@docspack/registry");
|
|
887
840
|
if (json) {
|
package/src/config.ts
CHANGED
|
@@ -28,10 +28,15 @@ export interface BuildConfig {
|
|
|
28
28
|
readonly from?: string;
|
|
29
29
|
readonly openapi?: string;
|
|
30
30
|
readonly source?: string;
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The libraries this package documents, each `name` or `name@version`. What `docspack verify`
|
|
33
|
+
* checks against, and what the manifest records so a consumer can say which release an answer
|
|
34
|
+
* describes. A monorepo documents several from one surface, so this is a list.
|
|
35
|
+
*/
|
|
36
|
+
readonly documents?: readonly string[];
|
|
33
37
|
readonly feedback?: FeedbackChannel;
|
|
34
38
|
readonly maxChunkTokens?: number;
|
|
39
|
+
readonly minChunkTokens?: number;
|
|
35
40
|
readonly pages?: number;
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -64,7 +69,7 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
64
69
|
}
|
|
65
70
|
const raw = value as Record<string, unknown>;
|
|
66
71
|
|
|
67
|
-
const text = (key: "from" | "openapi" | "source"
|
|
72
|
+
const text = (key: "from" | "openapi" | "source"): string | undefined => {
|
|
68
73
|
const found = raw[key];
|
|
69
74
|
if (found === undefined) return undefined;
|
|
70
75
|
if (typeof found !== "string" || found.length === 0) {
|
|
@@ -73,7 +78,7 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
73
78
|
return found;
|
|
74
79
|
};
|
|
75
80
|
|
|
76
|
-
const count = (key: "maxChunkTokens" | "pages"): number | undefined => {
|
|
81
|
+
const count = (key: "maxChunkTokens" | "minChunkTokens" | "pages"): number | undefined => {
|
|
77
82
|
const found = raw[key];
|
|
78
83
|
if (found === undefined) return undefined;
|
|
79
84
|
if (!Number.isInteger(found) || (found as number) < 1) {
|
|
@@ -85,9 +90,10 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
85
90
|
const from = text("from");
|
|
86
91
|
const openapi = text("openapi");
|
|
87
92
|
const source = text("source");
|
|
88
|
-
const documents =
|
|
93
|
+
const documents = parseDocuments(raw.documents);
|
|
89
94
|
const feedback = parseFeedbackChannel(raw.feedback);
|
|
90
95
|
const maxChunkTokens = count("maxChunkTokens");
|
|
96
|
+
const minChunkTokens = count("minChunkTokens");
|
|
91
97
|
const pages = count("pages");
|
|
92
98
|
|
|
93
99
|
return {
|
|
@@ -97,10 +103,34 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
97
103
|
...(documents === undefined ? {} : { documents }),
|
|
98
104
|
...(feedback === undefined ? {} : { feedback }),
|
|
99
105
|
...(maxChunkTokens === undefined ? {} : { maxChunkTokens }),
|
|
106
|
+
...(minChunkTokens === undefined ? {} : { minChunkTokens }),
|
|
100
107
|
...(pages === undefined ? {} : { pages }),
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* `"acme"`, `"acme@1.4.0"` or a list of either. A single string stays valid: one library is the
|
|
113
|
+
* common case, and every package written against the earlier field shape still reads.
|
|
114
|
+
*/
|
|
115
|
+
function parseDocuments(value: unknown): readonly string[] | undefined {
|
|
116
|
+
if (value === undefined) return undefined;
|
|
117
|
+
const where = `${CONFIG_KEY}.documents`;
|
|
118
|
+
const specs = Array.isArray(value) ? value : [value];
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
specs.length === 0 ||
|
|
122
|
+
specs.some((entry) => typeof entry !== "string" || entry.length === 0)
|
|
123
|
+
) {
|
|
124
|
+
throw new DocspackError(
|
|
125
|
+
`package.json: "${where}" must be a package name, or an array of them`,
|
|
126
|
+
{
|
|
127
|
+
hint: 'Add a version to scope it: "acme@1.4.0".',
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return specs as string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
/** `owner/repo`, the only shape a prefilled issue URL can be built from. */
|
|
105
135
|
const GITHUB_REPO = /^[\w.-]+\/[\w.-]+$/;
|
|
106
136
|
|
package/src/db.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import type { DatabaseSync } from "node:sqlite";
|
|
6
6
|
import { DocspackError } from "./errors.js";
|
|
7
|
+
import { STOPWORDS } from "./stopwords.js";
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
|
|
@@ -102,10 +103,14 @@ export function defaultStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
102
103
|
* Turns free text into an FTS5 MATCH expression. Every term is quoted, so punctuation in a user
|
|
103
104
|
* query can never be interpreted as FTS syntax.
|
|
104
105
|
*
|
|
105
|
-
* Terms of one or two characters are dropped
|
|
106
|
-
* every chunk containing one becomes a candidate — which is
|
|
107
|
-
* example question outranks the chunk that answers it. Two
|
|
108
|
-
* leaves real short names (`id`, `db`) out and keeps
|
|
106
|
+
* Terms of one or two characters are dropped, as are function words of any length. Questions are
|
|
107
|
+
* mostly `how`, `do`, `i`, `in`, and every chunk containing one becomes a candidate — which is
|
|
108
|
+
* how a chunk that merely quotes an example question outranks the chunk that answers it. Two
|
|
109
|
+
* characters is the cut because it leaves real short names (`id`, `db`) out and keeps
|
|
110
|
+
* three-letter ones (`api`, `key`) in; `how` and `what` need the word list as well.
|
|
111
|
+
*
|
|
112
|
+
* Each narrowing falls back to the previous one, so a query made entirely of short or common
|
|
113
|
+
* words still searches for something rather than failing.
|
|
109
114
|
*/
|
|
110
115
|
export function toFtsQuery(raw: string): string {
|
|
111
116
|
const terms = raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
@@ -114,9 +119,10 @@ export function toFtsQuery(raw: string): string {
|
|
|
114
119
|
hint: "Search for words, for example: docspack search webhook signature",
|
|
115
120
|
});
|
|
116
121
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
const long = terms.filter((term) => term.length > 2);
|
|
123
|
+
const meaningful = long.filter((term) => !STOPWORDS.has(term));
|
|
124
|
+
const chosen = meaningful.length > 0 ? meaningful : long.length > 0 ? long : terms;
|
|
125
|
+
return chosen.map((term) => `"${term}"`).join(" OR ");
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/** The global chunk index: one SQLite database shared by every project on the machine. */
|
package/src/doctor.ts
CHANGED
|
@@ -104,7 +104,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
104
104
|
continue;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const size = chunk.tokens
|
|
107
|
+
const size = chunk.tokens ?? estimateTokens(text);
|
|
108
108
|
tokens += size;
|
|
109
109
|
|
|
110
110
|
if (size > MAX_CHUNK_TOKENS) {
|
package/src/eval.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { DocspackError } from "./errors.js";
|
|
3
|
+
import { previewPackage } from "./preview.js";
|
|
4
|
+
import { DEFAULT_LIMIT, DEFAULT_MAX_TOKENS } from "./search.js";
|
|
5
|
+
|
|
6
|
+
/** One question, and the chunk ids that would be a correct answer to it. */
|
|
7
|
+
export interface EvalCase {
|
|
8
|
+
readonly query: string;
|
|
9
|
+
/** Bare chunk ids, as they appear in the manifest. Any one of them counts as a hit. */
|
|
10
|
+
readonly expect: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EvalCaseResult extends EvalCase {
|
|
14
|
+
/** Chunk ids returned, in rank order. */
|
|
15
|
+
readonly returned: readonly string[];
|
|
16
|
+
/** 1-based rank of the first expected chunk, or 0 when none of them came back. */
|
|
17
|
+
readonly rank: number;
|
|
18
|
+
readonly tokens: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EvalReport {
|
|
22
|
+
readonly cases: readonly EvalCaseResult[];
|
|
23
|
+
/** Cases where an expected chunk came back at all, within `limit`. */
|
|
24
|
+
readonly hits: number;
|
|
25
|
+
/** Cases where an expected chunk came back first. */
|
|
26
|
+
readonly top1: number;
|
|
27
|
+
readonly total: number;
|
|
28
|
+
/** Mean tokens per answer — what tuning the chunk budget trades retrieval against. */
|
|
29
|
+
readonly averageTokens: number;
|
|
30
|
+
readonly limit: number;
|
|
31
|
+
readonly ok: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EvalOptions {
|
|
35
|
+
readonly dir: string;
|
|
36
|
+
readonly cases: readonly EvalCase[];
|
|
37
|
+
readonly limit?: number;
|
|
38
|
+
readonly maxTokens?: number;
|
|
39
|
+
/** Percentage of cases that must return an expected chunk. Below it, `ok` is false. */
|
|
40
|
+
readonly minHitRate?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Answers a set of questions from a package on disk and reports how often the right chunk came
|
|
45
|
+
* back.
|
|
46
|
+
*
|
|
47
|
+
* `doctor` cannot see this. Every chunk can be well-formed, well-tagged and the right size while
|
|
48
|
+
* the package still answers the wrong question, and the highest-leverage authoring decision —
|
|
49
|
+
* the chunk budget — trades two things off against each other in a way only measurement settles:
|
|
50
|
+
* bm25 normalizes for length, so retrieval prefers small chunks, while an answer capped at three
|
|
51
|
+
* of them prefers large ones.
|
|
52
|
+
*/
|
|
53
|
+
export async function evaluatePackage(options: EvalOptions): Promise<EvalReport> {
|
|
54
|
+
if (options.cases.length === 0) {
|
|
55
|
+
throw new DocspackError("The evaluation set is empty", {
|
|
56
|
+
hint: "Add at least one { query, expect } entry.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
61
|
+
const cases: EvalCaseResult[] = [];
|
|
62
|
+
|
|
63
|
+
for (const entry of options.cases) {
|
|
64
|
+
const result = await previewPackage({
|
|
65
|
+
dir: options.dir,
|
|
66
|
+
query: entry.query,
|
|
67
|
+
limit,
|
|
68
|
+
maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// A hit is compared on the bare id: an eval set written by the author must not have to
|
|
72
|
+
// restate the package name and version it is already sitting inside.
|
|
73
|
+
const returned = result.hits.map((hit) => bareChunkId(hit.chunkId));
|
|
74
|
+
const expected = new Set(entry.expect);
|
|
75
|
+
const rank = returned.findIndex((id) => expected.has(id)) + 1;
|
|
76
|
+
|
|
77
|
+
cases.push({ ...entry, returned, rank, tokens: result.tokens });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hits = cases.filter((entry) => entry.rank > 0).length;
|
|
81
|
+
const total = cases.length;
|
|
82
|
+
const rate = (hits / total) * 100;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
cases,
|
|
86
|
+
hits,
|
|
87
|
+
top1: cases.filter((entry) => entry.rank === 1).length,
|
|
88
|
+
total,
|
|
89
|
+
averageTokens: Math.round(cases.reduce((sum, entry) => sum + entry.tokens, 0) / total),
|
|
90
|
+
limit,
|
|
91
|
+
ok: options.minHitRate === undefined || rate >= options.minHitRate,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `@acme/docspack@1.4.0/api-auth` -> `api-auth`. */
|
|
96
|
+
function bareChunkId(id: string): string {
|
|
97
|
+
const slash = id.lastIndexOf("/");
|
|
98
|
+
return slash === -1 ? id : id.slice(slash + 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads an evaluation set: a JSON array of `{ query, expect }`, where `expect` is a chunk id or
|
|
103
|
+
* an array of them. Malformed entries are an error rather than a skip — a gate that silently
|
|
104
|
+
* evaluates nine of ten questions reports a hit rate that is not about the set it was given.
|
|
105
|
+
*/
|
|
106
|
+
export async function readEvalSet(file: string): Promise<readonly EvalCase[]> {
|
|
107
|
+
let raw: string;
|
|
108
|
+
try {
|
|
109
|
+
raw = await readFile(file, "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
throw new DocspackError(`Could not read the evaluation set ${file}`, {
|
|
112
|
+
hint: 'It is a JSON array of { "query": "…", "expect": "chunk-id" }.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let parsed: unknown;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(raw);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new DocspackError(`${file} is not valid JSON`, { cause: error });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return parseEvalSet(parsed, file);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseEvalSet(value: unknown, where: string): readonly EvalCase[] {
|
|
127
|
+
const fail = (message: string): never => {
|
|
128
|
+
throw new DocspackError(`${where}: ${message}`, {
|
|
129
|
+
hint: 'Each entry is { "query": "how do I …", "expect": "chunk-id" }.',
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (!Array.isArray(value)) fail("expected a JSON array of evaluation cases");
|
|
134
|
+
|
|
135
|
+
return (value as unknown[]).map((entry, index): EvalCase => {
|
|
136
|
+
if (typeof entry !== "object" || entry === null) return fail(`case #${index} is not an object`);
|
|
137
|
+
const record = entry as { query?: unknown; expect?: unknown };
|
|
138
|
+
|
|
139
|
+
if (typeof record.query !== "string" || record.query.trim().length === 0) {
|
|
140
|
+
return fail(`case #${index} needs a non-empty "query"`);
|
|
141
|
+
}
|
|
142
|
+
const expect = Array.isArray(record.expect) ? record.expect : [record.expect];
|
|
143
|
+
if (expect.length === 0 || expect.some((id) => typeof id !== "string" || id.length === 0)) {
|
|
144
|
+
return fail(`case "${record.query}" needs "expect": a chunk id, or an array of them`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { query: record.query, expect: expect as string[] };
|
|
148
|
+
});
|
|
149
|
+
}
|