@333eco/corpus 1.2.5 → 2.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 +184 -0
- package/dist/corpus.json +1420 -127
- package/package.json +3 -2
- package/src/base-tools.mjs +72 -0
- package/src/program-tools.mjs +181 -0
- package/src/prompts.mjs +134 -0
- package/src/report.mjs +104 -0
- package/src/resources.mjs +215 -0
- package/src/results.mjs +40 -0
- package/src/search.mjs +176 -0
- package/src/server.mjs +143 -65
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// MCP resources over the corpus — shared by both surfaces.
|
|
2
|
+
//
|
|
3
|
+
// ⭐⭐ THE ONE DECISION THAT SHAPES THIS FILE: A RESOURCE CARRIES ITS PROVENANCE
|
|
4
|
+
// IN THE TEXT, NOT BESIDE IT. A tool response wraps a document in an envelope and
|
|
5
|
+
// a caller reads the envelope. A resource is different in kind — clients hand its
|
|
6
|
+
// contents straight to a model as context, and a `mimeType` field does not travel
|
|
7
|
+
// with a quotation. Serving bare text here would hand out corpus material stripped
|
|
8
|
+
// of the one property this server exists to provide.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ This is not a new judgement; it is the letters' rule applied a second time.
|
|
11
|
+
// The letters mark voice INLINE — "[VERBATIM — Thon Ly]" / "[SCAFFOLD …]" — rather
|
|
12
|
+
// than in a metadata field, because *with a field an agent must LOOK to know; with
|
|
13
|
+
// a marker it must STRIP not to*. The same asymmetry decides this: a header the
|
|
14
|
+
// model must delete is safe, a field it must consult is not.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️⚠️ AND THE HEADER MUST SAY WHAT THE HASH DOES NOT COVER. `provenance.sha256`
|
|
17
|
+
// is computed over the whole source file, front matter included; `text` is the
|
|
18
|
+
// body. Prepending a header makes the served bytes hash to nothing at all — so
|
|
19
|
+
// the header states plainly that it is not part of the hashed artifact and points
|
|
20
|
+
// at the file that is. A "verifiable" resource that quietly cannot be verified
|
|
21
|
+
// would be worse than one that never claimed it.
|
|
22
|
+
|
|
23
|
+
const CORPUS_SCHEME = "corpus://";
|
|
24
|
+
const PAGE = 50;
|
|
25
|
+
|
|
26
|
+
export const RESOURCE_TEMPLATES = [
|
|
27
|
+
{
|
|
28
|
+
uriTemplate: "corpus://{slug}",
|
|
29
|
+
name: "corpus-document",
|
|
30
|
+
title: "Corpus document by slug",
|
|
31
|
+
description:
|
|
32
|
+
"Any document in the corpus, addressed by its slug — e.g. corpus://co-presence-gated-redemption. " +
|
|
33
|
+
"Returns the canonical text prefixed with a provenance header carrying its licence, sha256, DOI and " +
|
|
34
|
+
"a runnable verification command. Slugs come from resources/list or the list_documents tool.",
|
|
35
|
+
mimeType: "text/markdown"
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const mime = (d) => (d.metadata_convention === "html" ? "text/plain" : "text/markdown");
|
|
40
|
+
|
|
41
|
+
// The catalogue entry. Deliberately terse: a client renders this in a picker, and
|
|
42
|
+
// the licence is the one thing a person choosing a document needs to see.
|
|
43
|
+
export const resourceEntry = (d) => ({
|
|
44
|
+
uri: CORPUS_SCHEME + d.slug,
|
|
45
|
+
name: d.slug,
|
|
46
|
+
title: d.title,
|
|
47
|
+
description:
|
|
48
|
+
`${d.genre} · ${d.licence.id}` +
|
|
49
|
+
(d.date ? ` · ${d.date}` : "") +
|
|
50
|
+
(d.provenance.doi ? ` · doi:${d.provenance.doi}` : "") +
|
|
51
|
+
(d.subtitle ? ` — ${d.subtitle}` : ""),
|
|
52
|
+
mimeType: mime(d),
|
|
53
|
+
// ⚠️ `lastModified` carries the document's own date and NOTHING MORE PRECISE.
|
|
54
|
+
// ISO 8601 permits a date alone, and inventing "T00:00:00Z" would assert a
|
|
55
|
+
// time this corpus does not record — a small lie in a provenance server.
|
|
56
|
+
annotations: {
|
|
57
|
+
audience: ["user", "assistant"],
|
|
58
|
+
// Anchored papers rank above unanchored scaffold: a consumer choosing
|
|
59
|
+
// among 140 documents should meet the ones with proofs first.
|
|
60
|
+
priority: d.provenance.doi ? 0.9 : d.provenance.opentimestamps ? 0.6 : 0.4,
|
|
61
|
+
...(d.date ? { lastModified: d.date } : {})
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const listResources = (documents, cursor) => {
|
|
66
|
+
// Cursor-based paging, because a corpus grows and a client should not have to
|
|
67
|
+
// take 140 entries to find one. The cursor is an offset encoded as a string —
|
|
68
|
+
// opaque to the client, which is all the protocol asks of it.
|
|
69
|
+
// ⚠️ btoa/atob, NOT Buffer. The worker runs without nodejs_compat, so Buffer is
|
|
70
|
+
// undefined there and only there — a break that passes every local test and
|
|
71
|
+
// fails once, in production, on the surface nobody runs by hand.
|
|
72
|
+
let start = 0;
|
|
73
|
+
if (cursor) {
|
|
74
|
+
try {
|
|
75
|
+
start = Number(atob(String(cursor)));
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`invalid cursor: ${cursor}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!Number.isInteger(start) || start < 0 || start > documents.length) {
|
|
81
|
+
throw new Error(`invalid cursor: ${cursor}`);
|
|
82
|
+
}
|
|
83
|
+
const page = documents.slice(start, start + PAGE);
|
|
84
|
+
const next = start + PAGE < documents.length ? btoa(String(start + PAGE)) : undefined;
|
|
85
|
+
return { resources: page.map(resourceEntry), ...(next ? { nextCursor: next } : {}) };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ⚠️ Attribution is stated as an instruction, not a licence id. CC-BY on seven of
|
|
89
|
+
// these documents means a real obligation, and "CC-BY-4.0" alone leaves an agent
|
|
90
|
+
// to know what that entails.
|
|
91
|
+
const licenceLine = (d) =>
|
|
92
|
+
d.licence.attribution_required
|
|
93
|
+
? `${d.licence.id} — ATTRIBUTION REQUIRED. Attribute to: ${d.authors ?? "⚠️ UNKNOWN — the index carries no author for this document; do not quote it until that is fixed"}. ${d.licence.url}`
|
|
94
|
+
: `${d.licence.id} — no attribution required, though citation is welcome. ${d.licence.url}`;
|
|
95
|
+
|
|
96
|
+
export const provenanceHeader = (d) => {
|
|
97
|
+
const p = d.provenance;
|
|
98
|
+
const L = [];
|
|
99
|
+
L.push("[PROVENANCE — corpus.333.eco. This header is NOT part of the document; the document begins below.]");
|
|
100
|
+
L.push(`title: ${d.title}`);
|
|
101
|
+
L.push(`slug: ${d.slug} (${d.genre}${d.date ? `, ${d.date}` : ""})`);
|
|
102
|
+
L.push(`licence: ${licenceLine(d)}`);
|
|
103
|
+
if (p.source_url) L.push(`source: ${p.source_url}`);
|
|
104
|
+
if (p.canonical_url) L.push(`canonical: ${p.canonical_url}`);
|
|
105
|
+
L.push(`sha256: ${p.sha256}`);
|
|
106
|
+
// The single most misreadable field, so it gets a full sentence.
|
|
107
|
+
L.push(" ⚠️ covers the COMPLETE SOURCE FILE at `source`, metadata block included —");
|
|
108
|
+
L.push(" NOT the text below, and NOT this header. Hashing what you were served");
|
|
109
|
+
L.push(" will not reproduce it. Fetch `source` to check.");
|
|
110
|
+
if (p.doi) L.push(`doi: https://doi.org/${p.doi} (this exact version)`);
|
|
111
|
+
if (p.concept_doi) L.push(`concept: https://doi.org/${p.concept_doi} (follows the document across versions)`);
|
|
112
|
+
if (p.opentimestamps) L.push(`proof: ots verify ${d.path}.ots — in the source repository, anchored in Bitcoin`);
|
|
113
|
+
if (p.source_url) L.push(`verify: curl -sL ${p.source_url} | shasum -a 256 # compare with sha256 above`);
|
|
114
|
+
|
|
115
|
+
if (p.deposited_matches_current === false) {
|
|
116
|
+
L.push("status: ⚠️ REVISED SINCE ITS DEPOSIT. The DOI above resolves to the deposited");
|
|
117
|
+
L.push(" version; the text below is newer. They differ legitimately.");
|
|
118
|
+
}
|
|
119
|
+
if (d.status === "living" && p.concept_doi) {
|
|
120
|
+
L.push("living: This document is revised on purpose. CITE the concept DOI, which always");
|
|
121
|
+
L.push(" resolves to the newest version; VERIFY against the version DOI and");
|
|
122
|
+
L.push(" sha256 above, which pin these exact bytes.");
|
|
123
|
+
}
|
|
124
|
+
// ⚠️ The letters' inline voice markers are meaningless without the sentence
|
|
125
|
+
// that explains them, and that sentence lives in `editorial` — a field a
|
|
126
|
+
// resource read would otherwise drop, reintroducing the exact bug the tool
|
|
127
|
+
// responses were once shipped with.
|
|
128
|
+
if (d.editorial?.annotation) {
|
|
129
|
+
L.push(`editorial: ${d.editorial.annotation.replace(/\s+/g, " ")}`);
|
|
130
|
+
}
|
|
131
|
+
L.push("[END PROVENANCE — everything below this line is the document, verbatim.]");
|
|
132
|
+
return L.join("\n");
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const readResource = (uri, bySlug) => {
|
|
136
|
+
const raw = String(uri ?? "");
|
|
137
|
+
if (!raw.startsWith(CORPUS_SCHEME)) {
|
|
138
|
+
throw new Error(`unsupported resource uri "${raw}". Corpus documents are addressed as corpus://<slug>.`);
|
|
139
|
+
}
|
|
140
|
+
const slug = raw.slice(CORPUS_SCHEME.length);
|
|
141
|
+
const d = bySlug.get(slug);
|
|
142
|
+
if (!d) throw new Error(`no document with slug "${slug}". Call resources/list to see what is available.`);
|
|
143
|
+
return {
|
|
144
|
+
contents: [
|
|
145
|
+
{
|
|
146
|
+
uri: raw,
|
|
147
|
+
mimeType: mime(d),
|
|
148
|
+
// Header, blank line, then the document exactly as the tools return it.
|
|
149
|
+
text: provenanceHeader(d) + "\n\n" + d.text
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/* ------------------------------------------------------------- completions ---
|
|
156
|
+
⭐ A URI TEMPLATE WITHOUT COMPLETION IS A TEMPLATE YOU MUST ALREADY KNOW THE
|
|
157
|
+
ANSWER TO USE. `corpus://{slug}` is only usable by someone who already has the
|
|
158
|
+
slug; this is what turns it into something a person can discover by typing.
|
|
159
|
+
|
|
160
|
+
⚠️ Prefix matches rank above substring matches, because a slug is a name and a
|
|
161
|
+
person typing one is almost always typing its beginning. Within each group the
|
|
162
|
+
order is the corpus's own (alphabetical by slug) — stable, so the same keystroke
|
|
163
|
+
never reorders the list under the user's cursor. */
|
|
164
|
+
|
|
165
|
+
const COMPLETION_MAX = 100; // the spec's ceiling
|
|
166
|
+
|
|
167
|
+
const slugCompletion = (value, documents) => {
|
|
168
|
+
const q = String(value ?? "").toLowerCase();
|
|
169
|
+
const slugs = documents.map((d) => d.slug);
|
|
170
|
+
const starts = slugs.filter((s) => s.startsWith(q));
|
|
171
|
+
const contains = q ? slugs.filter((s) => !s.startsWith(q) && s.includes(q)) : [];
|
|
172
|
+
const all = [...starts, ...contains];
|
|
173
|
+
return {
|
|
174
|
+
completion: {
|
|
175
|
+
values: all.slice(0, COMPLETION_MAX),
|
|
176
|
+
total: all.length,
|
|
177
|
+
hasMore: all.length > COMPLETION_MAX
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const completeArgument = (ref, argument, documents) => {
|
|
183
|
+
// ⭐ Prompts and completions compose: `verify_a_quote` takes a slug, and the
|
|
184
|
+
// same slug list that completes corpus://{slug} completes it here. A prompt
|
|
185
|
+
// argument nobody can autocomplete is a prompt you must already know how to
|
|
186
|
+
// fill in — the same defect the resource template had before completions.
|
|
187
|
+
if (ref?.type === "ref/prompt") {
|
|
188
|
+
if (ref.name === "verify_a_quote" && argument?.name === "slug") {
|
|
189
|
+
return slugCompletion(argument?.value, documents);
|
|
190
|
+
}
|
|
191
|
+
const e = new Error(
|
|
192
|
+
`no completions for prompt "${ref?.name}" argument "${argument?.name}". ` +
|
|
193
|
+
"The completable prompt argument is verify_a_quote(slug)."
|
|
194
|
+
);
|
|
195
|
+
e.code = -32602;
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
if (ref?.type !== "ref/resource") {
|
|
199
|
+
const e = new Error(`unsupported completion reference type "${ref?.type}"`);
|
|
200
|
+
e.code = -32602;
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
if (ref.uri !== "corpus://{slug}") {
|
|
204
|
+
const e = new Error(`no completions for "${ref.uri}". The completable template is corpus://{slug}.`);
|
|
205
|
+
e.code = -32602;
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
if (argument?.name !== "slug") {
|
|
209
|
+
const e = new Error(`corpus://{slug} has one argument, "slug"; got "${argument?.name}"`);
|
|
210
|
+
e.code = -32602;
|
|
211
|
+
throw e;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return slugCompletion(argument?.value, documents);
|
|
215
|
+
};
|
package/src/results.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// How a tool result is shaped.
|
|
2
|
+
//
|
|
3
|
+
// ⭐⭐ JSON IS THE PAYLOAD; THE TEXT BLOCK IS THE HUMAN FORM. Until 2026-09-02 every
|
|
4
|
+
// tool returned pretty-printed JSON inside a text block, which meant a consuming
|
|
5
|
+
// program had to parse a string to reach data the server already had as an object.
|
|
6
|
+
// `structuredContent` is where that object belongs.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ THE SPEC'S BACK-COMPAT ADVICE — also serialise the JSON into `content` — is a
|
|
9
|
+
// SHOULD, and following it literally DOUBLES every response (measured: 1.76×–2.00×
|
|
10
|
+
// across the six tools). So this file does not duplicate. It SPLITS BY ROLE:
|
|
11
|
+
//
|
|
12
|
+
// content — what a reader reads: document text, excerpts, a listing
|
|
13
|
+
// structuredContent — what a program validates: the typed envelope and metadata
|
|
14
|
+
//
|
|
15
|
+
// Nothing appears in both. For the document tools that makes `content` the text
|
|
16
|
+
// WITH ITS PROVENANCE HEADER — identical to what resources/read returns, so the two
|
|
17
|
+
// ways into the same document finally agree — and `structuredContent` the envelope
|
|
18
|
+
// without the body. Measured cost: 1.00× on the large tools, 0.76×–0.78× on the
|
|
19
|
+
// metadata tools, which are smaller than what they replaced.
|
|
20
|
+
//
|
|
21
|
+
// ⛔ NO `outputSchema` YET, and that is a decision rather than an omission. The spec
|
|
22
|
+
// makes a declared schema binding — "servers MUST provide structured results that
|
|
23
|
+
// conform" — and the envelope changed twice on the day this was written. A schema
|
|
24
|
+
// is a promise kept on every future change; publishing one over a shape still in
|
|
25
|
+
// motion buys validation now and breaks validating clients later.
|
|
26
|
+
|
|
27
|
+
// A payload that is data all the way down: the object, plus a COMPACT serialisation
|
|
28
|
+
// for clients that read only `content`. Compact, not pretty — indentation is the
|
|
29
|
+
// one part of the old shape that was pure cost.
|
|
30
|
+
export const structured = (value) => ({
|
|
31
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
32
|
+
structuredContent: value
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// A payload with a human form: `text` is read, `value` is validated, and the two
|
|
36
|
+
// carry different things rather than the same thing twice.
|
|
37
|
+
export const structuredWithText = (text, value) => ({
|
|
38
|
+
content: [{ type: "text", text }],
|
|
39
|
+
structuredContent: value
|
|
40
|
+
});
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// How `search_corpus` decides a document matches.
|
|
2
|
+
//
|
|
3
|
+
// ⭐⭐ SHARED, AND IT HAS TO BE. The two surfaces keep their own `callTool` on
|
|
4
|
+
// purpose — twenty lines each, diffable by eye. Matching is not that: it is
|
|
5
|
+
// ranking, tokenising and window-finding, and if the stdio server and the worker
|
|
6
|
+
// ever disagreed about which documents answer a query, the corpus would have two
|
|
7
|
+
// opinions about itself. That is the same failure `sync.mjs` exists to prevent,
|
|
8
|
+
// arriving through the search path instead of the data path.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ WHY THIS REPLACED A BARE `indexOf`, and the evidence was a real caller.
|
|
11
|
+
// The first external client to reach the hosted endpoint searched
|
|
12
|
+
// `"gratitude alignment human wellbeing kindness"` and got ZERO results — while
|
|
13
|
+
// `gratitude` alone returns 50, `alignment` 50, `kindness` 50, `wellbeing` 16.
|
|
14
|
+
// The corpus was not missing the material; the matcher required that exact
|
|
15
|
+
// five-word string to appear verbatim, which of course it never does. A literal
|
|
16
|
+
// substring search silently punishes anyone who types a sentence, and it makes
|
|
17
|
+
// the zero-result signal MEAN THE WRONG THING: those queries were recording the
|
|
18
|
+
// matcher's limits while being read as gaps in the corpus.
|
|
19
|
+
//
|
|
20
|
+
// ⭐ PHRASE FIRST, THEN TERMS — the order is what keeps precision. This corpus is
|
|
21
|
+
// full of hyphenated marks (`B-Heart`, `Re-Tip`, `B-Sey`), and an exact phrase is
|
|
22
|
+
// always the strongest evidence a document is about the thing asked for. So an
|
|
23
|
+
// exact hit wins and is ranked above every term match. Only when the phrase is
|
|
24
|
+
// absent do we ask the weaker question: does this document contain ALL of these
|
|
25
|
+
// words, anywhere?
|
|
26
|
+
//
|
|
27
|
+
// ⚠️ The tokeniser KEEPS intra-word hyphens and apostrophes, so `B-Heart` is one
|
|
28
|
+
// token and not two. Splitting them would have made a search for `B-Heart` match
|
|
29
|
+
// any document containing "b" and "heart" separately — precision traded away for
|
|
30
|
+
// nothing, in the corpus where those marks matter most.
|
|
31
|
+
|
|
32
|
+
const TOKEN = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
|
|
33
|
+
|
|
34
|
+
// Dropped only when something survives the dropping. These are words present in
|
|
35
|
+
// essentially every document, so they never narrow the result set — but they do
|
|
36
|
+
// wreck the tightest-window calculation, because an "and" is always close by.
|
|
37
|
+
const STOP = new Set(["a", "an", "and", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "the", "to", "with"]);
|
|
38
|
+
|
|
39
|
+
export const terms = (query) => {
|
|
40
|
+
const all = String(query ?? "").toLowerCase().match(TOKEN) ?? [];
|
|
41
|
+
const kept = all.filter((t) => !STOP.has(t));
|
|
42
|
+
return kept.length ? kept : all;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ⚠️ THE EDGES SNAP TO WHITESPACE, because a fixed-width slice cuts words in
|
|
46
|
+
// half — `…ratitude is immen…` — and a half-word at an excerpt boundary is not a
|
|
47
|
+
// cosmetic problem here. These excerpts are read by agents deciding whether a
|
|
48
|
+
// document is worth fetching, and a truncated token is a token that can be
|
|
49
|
+
// matched, quoted or reasoned about as if it were a word.
|
|
50
|
+
//
|
|
51
|
+
// ⚠️ THE SNAP IS BUDGETED, AND THE BUDGET IS THE WHOLE SAFETY OF IT. A corpus
|
|
52
|
+
// document can contain a 200-character unbroken run — a base64 blob, a sha256, a
|
|
53
|
+
// long URL — and an unbounded search for whitespace would eat the entire excerpt
|
|
54
|
+
// looking for a space that is not there. Past LOOK characters we accept the hard
|
|
55
|
+
// cut: a slightly ugly excerpt beats an empty one.
|
|
56
|
+
const LOOK = 40;
|
|
57
|
+
|
|
58
|
+
const clip = (body, centre, span) => {
|
|
59
|
+
if (body.length <= span) return body.trim();
|
|
60
|
+
|
|
61
|
+
let start = Math.max(0, Math.min(Math.round(centre - span / 2), body.length - span));
|
|
62
|
+
let end = start + span;
|
|
63
|
+
|
|
64
|
+
// ⭐ EACH EDGE TRIES BOTH DIRECTIONS, and the second direction is what fixes
|
|
65
|
+
// the long-token case. Snapping INWARD is preferred (it never grows the
|
|
66
|
+
// excerpt), but this corpus is full of runs that exceed LOOK with no space
|
|
67
|
+
// in them at all — `project_future_kindness_operating_noun`, bare URLs,
|
|
68
|
+
// markdown link targets, sha256 hex. Inward alone gave up on exactly those
|
|
69
|
+
// and left the half-word it was meant to prevent. Snapping OUTWARD instead
|
|
70
|
+
// costs at most LOOK extra characters and always lands on a real boundary.
|
|
71
|
+
if (start > 0) {
|
|
72
|
+
const ahead = body.slice(start, start + LOOK).search(/\s/);
|
|
73
|
+
if (ahead !== -1) {
|
|
74
|
+
start += ahead + 1;
|
|
75
|
+
} else {
|
|
76
|
+
const back = /\s\S*$/.exec(body.slice(Math.max(0, start - LOOK), start));
|
|
77
|
+
if (back) start = Math.max(0, start - LOOK) + back.index + 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (end < body.length) {
|
|
81
|
+
const from = Math.max(start + 1, end - LOOK);
|
|
82
|
+
const back = /\s\S*$/.exec(body.slice(from, end));
|
|
83
|
+
if (back) {
|
|
84
|
+
end = from + back.index;
|
|
85
|
+
} else {
|
|
86
|
+
const ahead = body.slice(end, end + LOOK).search(/\s/);
|
|
87
|
+
end = ahead !== -1 ? end + ahead : Math.min(body.length, end + LOOK);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (end <= start) end = Math.min(body.length, start + span); // pathological input
|
|
91
|
+
|
|
92
|
+
return (start > 0 ? "…" : "") + body.slice(start, end).trim() + (end < body.length ? "…" : "");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Positions of a term, capped: a common word can appear thousands of times and
|
|
96
|
+
// the window sweep only needs enough of them to find a tight one.
|
|
97
|
+
const positions = (hay, term, cap = 200) => {
|
|
98
|
+
const out = [];
|
|
99
|
+
let i = hay.indexOf(term);
|
|
100
|
+
while (i !== -1 && out.length < cap) {
|
|
101
|
+
out.push(i);
|
|
102
|
+
i = hay.indexOf(term, i + term.length);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Smallest span containing at least one occurrence of every term. Classic sweep
|
|
108
|
+
// over the merged, sorted occurrence list — linear in the number of positions.
|
|
109
|
+
const tightestWindow = (lists) => {
|
|
110
|
+
const merged = [];
|
|
111
|
+
lists.forEach((ps, t) => ps.forEach((p) => merged.push([p, t])));
|
|
112
|
+
merged.sort((a, b) => a[0] - b[0]);
|
|
113
|
+
const need = lists.length;
|
|
114
|
+
const seen = new Map();
|
|
115
|
+
let best = null;
|
|
116
|
+
let left = 0;
|
|
117
|
+
for (let right = 0; right < merged.length; right++) {
|
|
118
|
+
seen.set(merged[right][1], (seen.get(merged[right][1]) ?? 0) + 1);
|
|
119
|
+
while (seen.size === need) {
|
|
120
|
+
const width = merged[right][0] - merged[left][0];
|
|
121
|
+
if (!best || width < best.width) best = { width, from: merged[left][0], to: merged[right][0] };
|
|
122
|
+
const t = merged[left][1];
|
|
123
|
+
const n = seen.get(t) - 1;
|
|
124
|
+
if (n === 0) seen.delete(t);
|
|
125
|
+
else seen.set(t, n);
|
|
126
|
+
left++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// null when the document does not match; otherwise the excerpt, how it matched,
|
|
133
|
+
// and a score for ranking. ⭐ `mode` is returned rather than hidden because a
|
|
134
|
+
// term match's excerpt may NOT contain the literal query, and a caller reading
|
|
135
|
+
// an excerpt deserves to know which question it answered.
|
|
136
|
+
export const match = (body, query, span = 320) => {
|
|
137
|
+
const hay = body.toLowerCase();
|
|
138
|
+
const phrase = String(query ?? "").toLowerCase().trim();
|
|
139
|
+
|
|
140
|
+
if (phrase) {
|
|
141
|
+
const i = hay.indexOf(phrase);
|
|
142
|
+
// An exact hit always wins: score is above anything a window can score.
|
|
143
|
+
if (i !== -1) return { mode: "phrase", excerpt: clip(body, i + phrase.length / 2, span), score: 1e9 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ts = [...new Set(terms(query))];
|
|
147
|
+
// A single term has already been tried as a phrase; there is no weaker
|
|
148
|
+
// question left to ask, so a miss is a miss.
|
|
149
|
+
if (ts.length < 2) return null;
|
|
150
|
+
|
|
151
|
+
const lists = ts.map((t) => positions(hay, t));
|
|
152
|
+
if (lists.some((l) => l.length === 0)) return null; // AND, not OR
|
|
153
|
+
|
|
154
|
+
const win = tightestWindow(lists);
|
|
155
|
+
if (!win) return null;
|
|
156
|
+
// Tighter is better; every term match ranks below every phrase match.
|
|
157
|
+
return { mode: "terms", excerpt: clip(body, Math.floor((win.from + win.to) / 2), span), score: 1e6 / (1 + win.width) };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Rank and cut. Phrase matches first, then tighter term windows, corpus order
|
|
161
|
+
// breaking ties (Array.prototype.sort is stable).
|
|
162
|
+
export const rank = (hits, limit) =>
|
|
163
|
+
hits.slice().sort((a, b) => b.m.score - a.m.score).slice(0, Math.max(0, limit));
|
|
164
|
+
|
|
165
|
+
// ⭐⭐ WHICH TERMS APPEAR IN NO DOCUMENT AT ALL. Under AND-matching a zero result
|
|
166
|
+
// means at least one term is missing, and saying WHICH turns a dead end into a
|
|
167
|
+
// finding: `unicorn` absent is a fact about the corpus, while `wellbeing` absent
|
|
168
|
+
// would be a fact about the query. Computed only when a search returns nothing,
|
|
169
|
+
// so the cost never lands on a successful call — and it is exactly the sentence
|
|
170
|
+
// a caller would otherwise have to write to us by hand.
|
|
171
|
+
export const absentTerms = (documents, query) => {
|
|
172
|
+
const ts = [...new Set(terms(query))];
|
|
173
|
+
if (ts.length === 0) return [];
|
|
174
|
+
const hays = documents.map((d) => d.text.toLowerCase());
|
|
175
|
+
return ts.filter((t) => !hays.some((h) => h.includes(t)));
|
|
176
|
+
};
|