@zerotal/arch 1.7.0 → 1.7.3
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 +57 -0
- package/docs/about.md +4 -4
- package/docs/broadcasting/channels.md +1 -1
- package/docs/broadcasting/client.md +17 -4
- package/docs/broadcasting/events.md +2 -2
- package/docs/broadcasting/index.md +1 -1
- package/docs/changelog.md +106 -4
- package/docs/commands.md +21 -12
- package/docs/contributing.md +2 -2
- package/docs/errors.md +22 -0
- package/docs/flow/components.md +27 -8
- package/docs/flow/decorators.md +32 -16
- package/docs/flow/events.md +53 -19
- package/docs/flow/index.md +18 -7
- package/docs/flow/layouts.md +16 -0
- package/docs/flow/lifecycle.md +31 -16
- package/docs/flow/models.md +284 -0
- package/docs/flow/performance.md +27 -1
- package/docs/flow/references.md +16 -1
- package/docs/flow/routing.md +14 -2
- package/docs/flow/testing.md +32 -0
- package/docs/getting-started.md +26 -1
- package/docs/i18n.md +247 -144
- package/docs/notifications.md +1 -1
- package/docs/queue.md +11 -3
- package/docs/routing.md +89 -7
- package/docs/seeding.md +7 -0
- package/docs/support-policy.md +13 -5
- package/docs/upgrade.md +2 -0
- package/package.json +3 -3
- package/src/install/guidelines.ts +5 -1
- package/src/install/markers.ts +11 -2
- package/src/install/mcpConfig.ts +14 -2
- package/src/probe/topics.ts +19 -4
- package/src/tools/apiSurface.ts +115 -20
- package/src/tools/baselines.ts +33 -4
- package/src/tools/logs.ts +77 -7
- package/src/tools/searchDocs.ts +196 -29
package/src/tools/logs.ts
CHANGED
|
@@ -32,8 +32,37 @@ export interface LogEntry {
|
|
|
32
32
|
channel?: string;
|
|
33
33
|
scope?: string;
|
|
34
34
|
context?: unknown;
|
|
35
|
+
/**
|
|
36
|
+
* Every other field on the line — `error`, `stack`, `requestId`, and whatever
|
|
37
|
+
* a package writes.
|
|
38
|
+
*
|
|
39
|
+
* Carried wholesale rather than enumerated. Naming the six fields this tool
|
|
40
|
+
* knew about meant an error entry arrived as its bare message: the framework
|
|
41
|
+
* logs the exception class in `error` and the trace in `stack`, and
|
|
42
|
+
* `last_error` — whose entire job is saying *why* something failed — reported
|
|
43
|
+
* "Unhandled error" and dropped both. A tool that lists the fields it
|
|
44
|
+
* understands will always lag the logger; passing the rest through cannot.
|
|
45
|
+
*/
|
|
46
|
+
fields: Record<string, unknown>;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Fields identical on every line from a process, so worth nothing in a report
|
|
51
|
+
* and not free — this output goes into a context window.
|
|
52
|
+
*/
|
|
53
|
+
const AMBIENT = new Set([
|
|
54
|
+
"level",
|
|
55
|
+
"message",
|
|
56
|
+
"timestamp",
|
|
57
|
+
"channel",
|
|
58
|
+
"scope",
|
|
59
|
+
"context",
|
|
60
|
+
"app",
|
|
61
|
+
"env",
|
|
62
|
+
"hostname",
|
|
63
|
+
"pid",
|
|
64
|
+
]);
|
|
65
|
+
|
|
37
66
|
// ── Reading ───────────────────────────────────────────────────────────────────
|
|
38
67
|
|
|
39
68
|
/** Day-files newest first, `YYYY-MM-DD.log`. */
|
|
@@ -71,6 +100,12 @@ export async function readTail(path: string): Promise<LogEntry[]> {
|
|
|
71
100
|
if (typeof parsed !== "object" || parsed === null) continue;
|
|
72
101
|
const record = parsed as Record<string, unknown>;
|
|
73
102
|
if (typeof record["message"] !== "string") continue;
|
|
103
|
+
|
|
104
|
+
const fields: Record<string, unknown> = {};
|
|
105
|
+
for (const [key, value] of Object.entries(record)) {
|
|
106
|
+
if (!AMBIENT.has(key) && value !== undefined) fields[key] = value;
|
|
107
|
+
}
|
|
108
|
+
|
|
74
109
|
entries.push({
|
|
75
110
|
level: typeof record["level"] === "string" ? record["level"] : "info",
|
|
76
111
|
message: record["message"],
|
|
@@ -78,6 +113,7 @@ export async function readTail(path: string): Promise<LogEntry[]> {
|
|
|
78
113
|
...(typeof record["channel"] === "string" ? { channel: record["channel"] } : {}),
|
|
79
114
|
...(typeof record["scope"] === "string" ? { scope: record["scope"] } : {}),
|
|
80
115
|
...(record["context"] !== undefined ? { context: record["context"] } : {}),
|
|
116
|
+
fields,
|
|
81
117
|
});
|
|
82
118
|
} catch {
|
|
83
119
|
/* a truncated or hand-edited line is skipped, not fatal */
|
|
@@ -129,11 +165,37 @@ function clampLimit(raw: unknown): number {
|
|
|
129
165
|
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
|
|
130
166
|
}
|
|
131
167
|
|
|
132
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Render one entry.
|
|
170
|
+
*
|
|
171
|
+
* `stack` is included only when asked for. `last_error` returns a single entry
|
|
172
|
+
* and the trace is the answer; `logs` can return two hundred, and a trace on
|
|
173
|
+
* each would bury the sequence the caller asked to see under its own detail.
|
|
174
|
+
*/
|
|
175
|
+
function renderEntry(entry: LogEntry, options: { stack?: boolean } = {}): string {
|
|
133
176
|
const scope = entry.scope ? ` [${entry.scope}]` : "";
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
const lines = [`${entry.timestamp} ${entry.level.toUpperCase()}${scope} ${entry.message}`];
|
|
178
|
+
|
|
179
|
+
const { error, stack, requestId, ...rest } = entry.fields as {
|
|
180
|
+
error?: unknown;
|
|
181
|
+
stack?: unknown;
|
|
182
|
+
requestId?: unknown;
|
|
183
|
+
} & Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
// The exception first: for an error entry it is the thing being reported, and
|
|
186
|
+
// `message` is often only the generic "Unhandled error" wrapping it.
|
|
187
|
+
if (typeof error === "string" && error !== entry.message) lines.push(` error: ${error}`);
|
|
188
|
+
if (typeof requestId === "string") lines.push(` request: ${requestId}`);
|
|
189
|
+
if (entry.context !== undefined) lines.push(` ${JSON.stringify(entry.context)}`);
|
|
190
|
+
|
|
191
|
+
const extra = Object.entries(rest).filter(([, value]) => value !== undefined);
|
|
192
|
+
if (extra.length > 0) lines.push(` ${JSON.stringify(Object.fromEntries(extra))}`);
|
|
193
|
+
|
|
194
|
+
if (options.stack && typeof stack === "string") {
|
|
195
|
+
lines.push(...stack.split("\n").map((line) => ` ${line.trim()}`));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return lines.join("\n");
|
|
137
199
|
}
|
|
138
200
|
|
|
139
201
|
const noTrail = (dir: string): string =>
|
|
@@ -202,7 +264,7 @@ export function logsTool(ctx: ToolContext): ArchTool {
|
|
|
202
264
|
}
|
|
203
265
|
|
|
204
266
|
return {
|
|
205
|
-
text: entries.map(renderEntry).join("\n"),
|
|
267
|
+
text: entries.map((entry) => renderEntry(entry)).join("\n"),
|
|
206
268
|
data: { total: entries.length, entries },
|
|
207
269
|
};
|
|
208
270
|
},
|
|
@@ -244,7 +306,8 @@ export function lastErrorTool(ctx: ToolContext): ArchTool {
|
|
|
244
306
|
};
|
|
245
307
|
}
|
|
246
308
|
|
|
247
|
-
|
|
309
|
+
// The trace is the answer here, so it is included.
|
|
310
|
+
return { text: renderEntry(entry, { stack: true }), data: { found: true, entry } };
|
|
248
311
|
},
|
|
249
312
|
};
|
|
250
313
|
}
|
|
@@ -259,7 +322,14 @@ function entrySchema(): Record<string, unknown> {
|
|
|
259
322
|
channel: { type: "string" },
|
|
260
323
|
scope: { type: "string" },
|
|
261
324
|
context: {},
|
|
325
|
+
fields: {
|
|
326
|
+
type: "object",
|
|
327
|
+
description:
|
|
328
|
+
"Everything else the logger recorded on this line — `error` and `stack` on an " +
|
|
329
|
+
"exception, `requestId` to correlate it with a request, and whatever a package adds.",
|
|
330
|
+
additionalProperties: true,
|
|
331
|
+
},
|
|
262
332
|
},
|
|
263
|
-
required: ["level", "message", "timestamp"],
|
|
333
|
+
required: ["level", "message", "timestamp", "fields"],
|
|
264
334
|
};
|
|
265
335
|
}
|
package/src/tools/searchDocs.ts
CHANGED
|
@@ -8,9 +8,22 @@
|
|
|
8
8
|
* running. A semantic search over a hosted corpus buys ranking; being unable to
|
|
9
9
|
* be wrong about the version buys more.
|
|
10
10
|
*
|
|
11
|
-
* Ranking is
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Ranking is BM25 over a small inverted index built when the corpus is first
|
|
12
|
+
* read, with matches in the title, description and headings scored as separate
|
|
13
|
+
* fields on top of the body.
|
|
14
|
+
*
|
|
15
|
+
* It started as plain term frequency weighted by field, on the reasoning that
|
|
16
|
+
* 126 curated pages are not a web index. Measured against real questions, that
|
|
17
|
+
* ranked `components.md` — one generated page covering 53 components, and so
|
|
18
|
+
* long that it mentions nearly everything — first for both "send an email" and
|
|
19
|
+
* "how do I write a test for a controller". Length normalisation, inverse
|
|
20
|
+
* document frequency and term saturation are each load-bearing, and the field
|
|
21
|
+
* bonuses have to sit outside the saturation or BM25 flattens them into noise.
|
|
22
|
+
*
|
|
23
|
+
* Judged on a set of questions an agent would actually ask: top-1 relevance went
|
|
24
|
+
* from roughly three in ten to twelve in fourteen. The remaining two return
|
|
25
|
+
* pages that are related but not the best one, which is where this stops —
|
|
26
|
+
* further tuning against a list this size is fitting the list, not the corpus.
|
|
14
27
|
*/
|
|
15
28
|
import { basename } from "node:path";
|
|
16
29
|
import type { ArchTool, ToolOutcome } from "../mcp/types.ts";
|
|
@@ -23,7 +36,65 @@ const MAX_LIMIT = 20;
|
|
|
23
36
|
/** How much of a matching section to return, in characters. */
|
|
24
37
|
const EXCERPT_BUDGET = 1200;
|
|
25
38
|
|
|
26
|
-
|
|
39
|
+
/** Weights for picking which section of a chosen page to quote. */
|
|
40
|
+
const WEIGHT = { heading: 3, body: 1 } as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a match in each field adds, on top of the body's BM25 term.
|
|
44
|
+
*
|
|
45
|
+
* Added *outside* the saturation rather than multiplied into the frequency.
|
|
46
|
+
* Folded in, BM25 compresses them: a title hit came out worth about twice one
|
|
47
|
+
* passing mention in prose, when a page titled "Testing" is the answer to a
|
|
48
|
+
* question about testing more or less by definition. The body term saturates at
|
|
49
|
+
* `K1 + 1` = 2.2, so a title match at 3 is decisive and a heading match is a
|
|
50
|
+
* strong nudge.
|
|
51
|
+
*/
|
|
52
|
+
const FIELD = { title: 3, description: 1.5, heading: 1 } as const;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Words that carry no signal in a question and add noise to the excerpt pick.
|
|
56
|
+
*
|
|
57
|
+
* Kept deliberately short — IDF already discounts anything common, and a long
|
|
58
|
+
* stop list starts removing terms that matter ("set", "get", "use" are all real
|
|
59
|
+
* API vocabulary here).
|
|
60
|
+
*/
|
|
61
|
+
const STOP = new Set([
|
|
62
|
+
"how",
|
|
63
|
+
"do",
|
|
64
|
+
"does",
|
|
65
|
+
"the",
|
|
66
|
+
"and",
|
|
67
|
+
"for",
|
|
68
|
+
"with",
|
|
69
|
+
"you",
|
|
70
|
+
"your",
|
|
71
|
+
"can",
|
|
72
|
+
"what",
|
|
73
|
+
"when",
|
|
74
|
+
"where",
|
|
75
|
+
"why",
|
|
76
|
+
"this",
|
|
77
|
+
"that",
|
|
78
|
+
"from",
|
|
79
|
+
"into",
|
|
80
|
+
"are",
|
|
81
|
+
"was",
|
|
82
|
+
"will",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* BM25 term-saturation. Above this, more occurrences of the same term add
|
|
87
|
+
* almost nothing — the tenth mention of "route" does not make a page ten times
|
|
88
|
+
* more about routing.
|
|
89
|
+
*/
|
|
90
|
+
const K1 = 1.2;
|
|
91
|
+
/**
|
|
92
|
+
* BM25 length normalisation, 0 (off) to 1 (full). At 0 this ranking degenerates
|
|
93
|
+
* into the raw term count it replaced, and `components.md` — one generated page
|
|
94
|
+
* covering 53 components — outranked the right answer for most queries simply by
|
|
95
|
+
* being long enough to mention everything.
|
|
96
|
+
*/
|
|
97
|
+
const B = 0.75;
|
|
27
98
|
|
|
28
99
|
interface DocSection {
|
|
29
100
|
heading: string;
|
|
@@ -38,6 +109,12 @@ interface DocPage {
|
|
|
38
109
|
title: string;
|
|
39
110
|
description: string;
|
|
40
111
|
sections: DocSection[];
|
|
112
|
+
/** Term counts in section bodies only — the field BM25 normalises. */
|
|
113
|
+
body: Map<string, number>;
|
|
114
|
+
/** Terms appearing in the title, description and headings: matched, not counted. */
|
|
115
|
+
fields: { title: Set<string>; description: Set<string>; heading: Set<string> };
|
|
116
|
+
/** Body token count — the document length BM25 normalises against. */
|
|
117
|
+
length: number;
|
|
41
118
|
}
|
|
42
119
|
|
|
43
120
|
export interface DocHit {
|
|
@@ -114,15 +191,78 @@ export function parsePage(path: string, raw: string): DocPage {
|
|
|
114
191
|
}
|
|
115
192
|
flush();
|
|
116
193
|
|
|
194
|
+
const title = frontmatter["title"] ?? basename(path, ".md");
|
|
195
|
+
const description = frontmatter["description"] ?? "";
|
|
196
|
+
|
|
197
|
+
// Counted once, here, rather than scanned per query: the corpus is fixed for
|
|
198
|
+
// the life of the process, and scoring reads these instead of the text.
|
|
199
|
+
const bodyTerms = new Map<string, number>();
|
|
200
|
+
let length = 0;
|
|
201
|
+
for (const section of sections) {
|
|
202
|
+
for (const token of tokenize(section.text)) {
|
|
203
|
+
bodyTerms.set(token, (bodyTerms.get(token) ?? 0) + 1);
|
|
204
|
+
length++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const fields = {
|
|
209
|
+
title: new Set(tokenize(title)),
|
|
210
|
+
description: new Set(tokenize(description)),
|
|
211
|
+
heading: new Set(sections.flatMap((section) => tokenize(section.heading))),
|
|
212
|
+
};
|
|
213
|
+
|
|
117
214
|
return {
|
|
118
215
|
path,
|
|
119
216
|
slug: `/docs/${path.replace(/\.md$/, "").replace(/\/index$/, "")}`,
|
|
120
|
-
title
|
|
121
|
-
description
|
|
217
|
+
title,
|
|
218
|
+
description,
|
|
122
219
|
sections,
|
|
220
|
+
body: bodyTerms,
|
|
221
|
+
fields,
|
|
222
|
+
length,
|
|
123
223
|
};
|
|
124
224
|
}
|
|
125
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Reduce a word to a form a query and a page can agree on.
|
|
228
|
+
*
|
|
229
|
+
* Conservative on purpose — enough to join "test"/"testing" and
|
|
230
|
+
* "delete"/"deletes", which were the whole of the remaining miss rate, without
|
|
231
|
+
* the over-stemming a full algorithm brings to a corpus this technical. The
|
|
232
|
+
* `ss` guard keeps "class" and "process" intact, and the length floors stop
|
|
233
|
+
* short words being ground down to something that matches everything.
|
|
234
|
+
*/
|
|
235
|
+
function stem(token: string): string {
|
|
236
|
+
if (token.length > 5 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
|
|
237
|
+
if (token.length > 5 && token.endsWith("ing")) return token.slice(0, -3);
|
|
238
|
+
if (token.length > 4 && token.endsWith("ed")) return token.slice(0, -2);
|
|
239
|
+
if (token.length > 4 && token.endsWith("es") && !token.endsWith("ses")) return token.slice(0, -2);
|
|
240
|
+
if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1);
|
|
241
|
+
return token;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Split text into the tokens both the index and {@link terms} produce.
|
|
246
|
+
*
|
|
247
|
+
* A hyphenated or dotted word is emitted whole *and* in parts. `soft-delete` in
|
|
248
|
+
* a heading has to be findable by someone typing "soft deletes", and `Bun.sql`
|
|
249
|
+
* by someone typing "sql" — while an exact search for the compound still
|
|
250
|
+
* matches it directly.
|
|
251
|
+
*/
|
|
252
|
+
function tokenize(text: string): string[] {
|
|
253
|
+
const out: string[] = [];
|
|
254
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9_.-]+/)) {
|
|
255
|
+
if (raw.length < MIN_TERM_LENGTH) continue;
|
|
256
|
+
out.push(stem(raw));
|
|
257
|
+
if (/[.-]/.test(raw)) {
|
|
258
|
+
for (const part of raw.split(/[.-]+/)) {
|
|
259
|
+
if (part.length >= MIN_TERM_LENGTH) out.push(stem(part));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
126
266
|
/**
|
|
127
267
|
* Read the leading `---` block.
|
|
128
268
|
*
|
|
@@ -153,14 +293,9 @@ function splitFrontmatter(raw: string): { frontmatter: Record<string, string>; b
|
|
|
153
293
|
// ── Ranking ───────────────────────────────────────────────────────────────────
|
|
154
294
|
|
|
155
295
|
export function terms(query: string): string[] {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
.toLowerCase()
|
|
160
|
-
.split(/[^a-z0-9_.-]+/)
|
|
161
|
-
.filter((term) => term.length >= MIN_TERM_LENGTH),
|
|
162
|
-
),
|
|
163
|
-
];
|
|
296
|
+
const kept = [...new Set(tokenize(query))].filter((term) => !STOP.has(term));
|
|
297
|
+
// A query made entirely of stop words still has to search for something.
|
|
298
|
+
return kept.length > 0 ? kept : [...new Set(tokenize(query))];
|
|
164
299
|
}
|
|
165
300
|
|
|
166
301
|
function occurrences(haystack: string, needle: string): number {
|
|
@@ -174,29 +309,65 @@ function occurrences(haystack: string, needle: string): number {
|
|
|
174
309
|
return count;
|
|
175
310
|
}
|
|
176
311
|
|
|
177
|
-
/**
|
|
312
|
+
/**
|
|
313
|
+
* Rank the corpus against a query, best first.
|
|
314
|
+
*
|
|
315
|
+
* BM25 over field-weighted term counts, which is three corrections to the raw
|
|
316
|
+
* count this started as — and every one of them was load-bearing:
|
|
317
|
+
*
|
|
318
|
+
* - **Length normalisation.** `components.md` is one generated page covering 53
|
|
319
|
+
* components, so it mentions nearly every word in the framework at least once.
|
|
320
|
+
* Unnormalised, it was the top hit for "send an email" and "how do I write a
|
|
321
|
+
* test for a controller", above the Notifications and Testing pages.
|
|
322
|
+
* - **Inverse document frequency.** "route" appears on most pages and separates
|
|
323
|
+
* nothing; "middleware" appears on few and separates a lot. Weighting every
|
|
324
|
+
* term equally let the common half of a query drown the informative half.
|
|
325
|
+
* - **Term saturation.** The tenth mention of a word does not make a page ten
|
|
326
|
+
* times more about it, which is exactly what a linear count claims.
|
|
327
|
+
*/
|
|
178
328
|
export function search(pages: DocPage[], query: string, limit: number): DocHit[] {
|
|
179
329
|
const wanted = terms(query);
|
|
180
330
|
if (wanted.length === 0) return [];
|
|
181
331
|
|
|
332
|
+
const count = pages.length;
|
|
333
|
+
const averageLength =
|
|
334
|
+
count === 0 ? 1 : Math.max(1, pages.reduce((sum, page) => sum + page.length, 0) / count);
|
|
335
|
+
|
|
336
|
+
// ln(1 + (N − n + 0.5) / (n + 0.5)) — always positive, so a term on every page
|
|
337
|
+
// contributes little rather than going negative and penalising a match.
|
|
338
|
+
const mentions = (page: DocPage, term: string): boolean =>
|
|
339
|
+
page.body.has(term) ||
|
|
340
|
+
page.fields.title.has(term) ||
|
|
341
|
+
page.fields.description.has(term) ||
|
|
342
|
+
page.fields.heading.has(term);
|
|
343
|
+
|
|
344
|
+
const idf = new Map<string, number>();
|
|
345
|
+
for (const term of wanted) {
|
|
346
|
+
const withTerm = pages.reduce((n, page) => n + (mentions(page, term) ? 1 : 0), 0);
|
|
347
|
+
idf.set(term, Math.log(1 + (count - withTerm + 0.5) / (withTerm + 0.5)));
|
|
348
|
+
}
|
|
349
|
+
|
|
182
350
|
const hits: DocHit[] = [];
|
|
183
351
|
|
|
184
352
|
for (const page of pages) {
|
|
185
|
-
const
|
|
186
|
-
const description = page.description.toLowerCase();
|
|
353
|
+
const norm = K1 * (1 - B + (B * page.length) / averageLength);
|
|
187
354
|
|
|
188
|
-
let
|
|
355
|
+
let total = 0;
|
|
189
356
|
for (const term of wanted) {
|
|
190
|
-
|
|
191
|
-
|
|
357
|
+
const frequency = page.body.get(term) ?? 0;
|
|
358
|
+
let contribution = frequency > 0 ? (frequency * (K1 + 1)) / (frequency + norm) : 0;
|
|
359
|
+
if (page.fields.title.has(term)) contribution += FIELD.title;
|
|
360
|
+
if (page.fields.description.has(term)) contribution += FIELD.description;
|
|
361
|
+
if (page.fields.heading.has(term)) contribution += FIELD.heading;
|
|
362
|
+
if (contribution === 0) continue;
|
|
363
|
+
total += (idf.get(term) ?? 0) * contribution;
|
|
192
364
|
}
|
|
365
|
+
if (total === 0) continue;
|
|
193
366
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
367
|
+
// Which section to quote. Scored the plain way on purpose: this picks the
|
|
368
|
+
// excerpt from a page already chosen, where density is exactly the right
|
|
369
|
+
// signal and there is no long-document bias left to correct.
|
|
197
370
|
let best: { section: DocSection; score: number } | undefined;
|
|
198
|
-
let bodyScore = 0;
|
|
199
|
-
|
|
200
371
|
for (const section of page.sections) {
|
|
201
372
|
const heading = section.heading.toLowerCase();
|
|
202
373
|
const text = section.text.toLowerCase();
|
|
@@ -205,13 +376,9 @@ export function search(pages: DocPage[], query: string, limit: number): DocHit[]
|
|
|
205
376
|
score += occurrences(heading, term) * WEIGHT.heading;
|
|
206
377
|
score += occurrences(text, term) * WEIGHT.body;
|
|
207
378
|
}
|
|
208
|
-
bodyScore += score;
|
|
209
379
|
if (score > 0 && (best === undefined || score > best.score)) best = { section, score };
|
|
210
380
|
}
|
|
211
381
|
|
|
212
|
-
const total = pageScore + bodyScore;
|
|
213
|
-
if (total === 0) continue;
|
|
214
|
-
|
|
215
382
|
const section = best?.section ?? page.sections[0];
|
|
216
383
|
hits.push({
|
|
217
384
|
path: page.path,
|