@333eco/corpus 2.4.0 → 2.4.1
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 +19 -0
- package/dist/corpus.json +1 -1
- package/package.json +1 -1
- package/src/base-tools.mjs +3 -3
- package/src/read-tools.mjs +20 -2
- package/src/search.mjs +92 -7
- package/src/server.mjs +7 -31
package/README.md
CHANGED
|
@@ -83,6 +83,18 @@ verbatim. **A zero result was recording the matcher's limits while being read as
|
|
|
83
83
|
⭐ On a zero result the response now names **which terms appear in no document at all**
|
|
84
84
|
(`absent_terms`), so a dead end says something instead of nothing.
|
|
85
85
|
|
|
86
|
+
⭐ **Diacritics are optional.** `metta` finds what `mettā` finds, and `Tonle Sap` reaches the
|
|
87
|
+
text that writes `Tonlé Sap` — before 2.4.1 a plain-ASCII query found 6 documents where the
|
|
88
|
+
marked one found 20. ⛔ **Fold to match, serve verbatim:** the fold touches only what is
|
|
89
|
+
compared, and every excerpt carries the corpus's own spelling, because a Pāli or Sanskrit
|
|
90
|
+
spelling is a tradition marker rather than a typo. Only Latin combining diacritics are folded;
|
|
91
|
+
Khmer and Burmese vowel signs are marks too, and are left exactly as written.
|
|
92
|
+
|
|
93
|
+
⭐ **Every result says it is an excerpt.** The text ends with a line naming how many of the
|
|
94
|
+
matches it shows and the `read_documents` call that reads those documents in full. It sits in
|
|
95
|
+
the result rather than only in the server's instructions, because a result reaches the model in
|
|
96
|
+
every client, at the moment it decides whether to keep reading.
|
|
97
|
+
|
|
86
98
|
### Reading a whole shelf
|
|
87
99
|
|
|
88
100
|
⭐ **Search answers from excerpts, and a model answers from what it opened.** A frontier
|
|
@@ -103,6 +115,13 @@ corpus already fits one — but a way to read the shelf.
|
|
|
103
115
|
split** — half a document verifies nothing — so one larger than the page arrives alone and
|
|
104
116
|
says `over_budget`. Follow `next_cursor`; a cursor from a different corpus version is refused
|
|
105
117
|
rather than serving page 2 of another corpus.
|
|
118
|
+
- **`list_documents` says how to read what it listed:** `read_with` carries the same filters as
|
|
119
|
+
`read_documents` arguments.
|
|
120
|
+
|
|
121
|
+
⚠️ **The server can point; it cannot make a model read.** Its instructions say *for a broad
|
|
122
|
+
question, read the relevant shelf in full* — deliberately conditional, because a whole shelf read
|
|
123
|
+
to answer a one-fact question is slower, costlier and often worse. Clients differ in whether they
|
|
124
|
+
show server instructions at all, which is why the pointers also live in the results.
|
|
106
125
|
|
|
107
126
|
⭐ **Every document ends with an `[END OF DOCUMENT — <slug> · <n> words]` line**, by every
|
|
108
127
|
route. The server never cuts a document, but a client may, and a cut document reads as a
|
package/dist/corpus.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@333eco/corpus",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "An MCP server for an open-licensed corpus, served with verifiable provenance — every document carries its sha256, DOI and OpenTimestamps proof so a consuming agent can check its own citation.",
|
|
5
5
|
"license": "CC0-1.0",
|
|
6
6
|
"author": "Thon Ly",
|
package/src/base-tools.mjs
CHANGED
|
@@ -27,9 +27,9 @@ export const BASE_TOOLS = [
|
|
|
27
27
|
title: "Search the corpus",
|
|
28
28
|
annotations: { ...READ_ONLY, title: "Search the corpus" },
|
|
29
29
|
description:
|
|
30
|
-
"Full-text search across the open-licensed corpus. Returns matching documents with
|
|
31
|
-
"and a short excerpt around each match — not the full text;
|
|
32
|
-
"be
|
|
30
|
+
"Full-text search across the open-licensed corpus; diacritics are optional. Returns matching documents with " +
|
|
31
|
+
"a provenance envelope and a short excerpt around each match — not the full text; read_documents or " +
|
|
32
|
+
"get_document return that. Every result can be verified via its sha256, DOI and OpenTimestamps proof.",
|
|
33
33
|
inputSchema: {
|
|
34
34
|
type: "object",
|
|
35
35
|
properties: {
|
package/src/read-tools.mjs
CHANGED
|
@@ -65,9 +65,27 @@ export const READ_TOOLS = [
|
|
|
65
65
|
|
|
66
66
|
export const READ_TOOL_NAMES = READ_TOOLS.map((t) => t.name);
|
|
67
67
|
|
|
68
|
+
// ⚠️ CONDITIONAL ON PURPOSE (2.4.1). "Prefer read_documents" unqualified would send a model to
|
|
69
|
+
// read a 260,000-token shelf to answer a one-fact question — slower, costlier and often worse.
|
|
70
|
+
// ⛔ It names shelves by their facets (list_documents' own vocabulary), never by what the
|
|
71
|
+
// documents say: describing the API, not the subject matter.
|
|
68
72
|
export const READ_INSTRUCTIONS =
|
|
69
|
-
"
|
|
70
|
-
"
|
|
73
|
+
" For a broad question, read the relevant shelf in full: choose it with list_documents, read it with " +
|
|
74
|
+
"read_documents. Search returns excerpts, suited to finding one passage. Every document ends with an " +
|
|
75
|
+
"[END OF DOCUMENT] line; if it is missing, the text was cut short.";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* list_documents' pointer to reading what it just listed: the same filters, as arguments
|
|
79
|
+
* read_documents accepts. Only the facets actually given, so the pointer reads back exactly.
|
|
80
|
+
*/
|
|
81
|
+
export const readWith = (args, count) =>
|
|
82
|
+
count
|
|
83
|
+
? {
|
|
84
|
+
tool: "read_documents",
|
|
85
|
+
arguments: Object.fromEntries(["genre", "category", "voice", "licence"].filter((k) => args?.[k]).map((k) => [k, args[k]])),
|
|
86
|
+
note: "Reads every document listed here in full, paged by size."
|
|
87
|
+
}
|
|
88
|
+
: null;
|
|
71
89
|
|
|
72
90
|
// ⚠️ The cursor carries the INDEX VERSION as well as the offset. Pages are offsets
|
|
73
91
|
// into a list, and a list read across a redeploy is two lists: without the version
|
package/src/search.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// How `search_corpus` decides a document matches.
|
|
2
2
|
//
|
|
3
|
-
// ⭐⭐ SHARED, AND IT HAS TO BE
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// ⭐⭐ SHARED, AND IT HAS TO BE — matching and, since 2.4.1, the whole search_corpus
|
|
4
|
+
// tool (see searchCorpus below). Matching is ranking, tokenising and window-finding, and
|
|
5
|
+
// if the stdio server and the worker
|
|
6
6
|
// ever disagreed about which documents answer a query, the corpus would have two
|
|
7
7
|
// opinions about itself. That is the same failure `sync.mjs` exists to prevent,
|
|
8
8
|
// arriving through the search path instead of the data path.
|
|
@@ -33,15 +33,49 @@
|
|
|
33
33
|
// any document containing "b" and "heart" separately — precision traded away for
|
|
34
34
|
// nothing, in the corpus where those marks matter most.
|
|
35
35
|
|
|
36
|
+
import { structuredWithText } from "./results.mjs";
|
|
37
|
+
|
|
36
38
|
const TOKEN = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
|
|
37
39
|
|
|
40
|
+
// ⭐⭐ DIACRITICS ARE OPTIONAL IN A QUERY (2.4.1). A model types `metta`; the corpus writes
|
|
41
|
+
// `mettā`. Without a fold that search found 6 documents against 20, and `Tonle Sap` found none
|
|
42
|
+
// — a reader typing plain ASCII, which is most of them, silently missed most of the Pāli
|
|
43
|
+
// material. ⛔ FOLD TO MATCH, SERVE VERBATIM: the spelling is a tradition marker (Pāli for a
|
|
44
|
+
// Theravāda citation, Sanskrit for a Hindu one), so the fold touches only what is COMPARED —
|
|
45
|
+
// every excerpt is cut from the original text.
|
|
46
|
+
// ⚠️ LENGTH-PRESERVING, character by character, so a position in the folded text is the same
|
|
47
|
+
// position in the original and the excerpt window lands where the match is. Only Latin
|
|
48
|
+
// combining diacritics (U+0300–U+036F) are removed, and only where the base letter survives
|
|
49
|
+
// as one character: Khmer and Burmese vowel signs are marks too, and stripping them would
|
|
50
|
+
// make Khmer text match what it does not say.
|
|
51
|
+
const LATIN_MARKS = /[\u0300-\u036f]/g;
|
|
52
|
+
const foldChar = (c) => {
|
|
53
|
+
const f = c.normalize("NFD").replace(LATIN_MARKS, "");
|
|
54
|
+
return f.length === c.length ? f : c;
|
|
55
|
+
};
|
|
56
|
+
export const fold = (s) => String(s ?? "").replace(/[^\x00-\x7f]/gu, foldChar);
|
|
57
|
+
|
|
58
|
+
// The folded, lowercased body, computed once per document text rather than on every search.
|
|
59
|
+
// Keyed by the string itself: the corpus holds one string per document for the life of the
|
|
60
|
+
// process, so the cache is bounded by the corpus.
|
|
61
|
+
const HAY = new Map();
|
|
62
|
+
const hayOf = (body) => {
|
|
63
|
+
let h = HAY.get(body);
|
|
64
|
+
if (h === undefined) {
|
|
65
|
+
h = fold(body.toLowerCase());
|
|
66
|
+
HAY.set(body, h);
|
|
67
|
+
}
|
|
68
|
+
return h;
|
|
69
|
+
};
|
|
70
|
+
const foldQuery = (query) => fold(String(query ?? "").normalize("NFC").toLowerCase());
|
|
71
|
+
|
|
38
72
|
// Dropped only when something survives the dropping. These are words present in
|
|
39
73
|
// essentially every document, so they never narrow the result set — but they do
|
|
40
74
|
// wreck the tightest-window calculation, because an "and" is always close by.
|
|
41
75
|
const STOP = new Set(["a", "an", "and", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "the", "to", "with"]);
|
|
42
76
|
|
|
43
77
|
export const terms = (query) => {
|
|
44
|
-
const all =
|
|
78
|
+
const all = foldQuery(query).match(TOKEN) ?? [];
|
|
45
79
|
const kept = all.filter((t) => !STOP.has(t));
|
|
46
80
|
return kept.length ? kept : all;
|
|
47
81
|
};
|
|
@@ -138,8 +172,8 @@ const tightestWindow = (lists) => {
|
|
|
138
172
|
// term match's excerpt may NOT contain the literal query, and a caller reading
|
|
139
173
|
// an excerpt deserves to know which question it answered.
|
|
140
174
|
export const match = (body, query, span = 320) => {
|
|
141
|
-
const hay = body
|
|
142
|
-
const phrase =
|
|
175
|
+
const hay = hayOf(body);
|
|
176
|
+
const phrase = foldQuery(query).trim();
|
|
143
177
|
|
|
144
178
|
if (phrase) {
|
|
145
179
|
const i = hay.indexOf(phrase);
|
|
@@ -175,6 +209,57 @@ export const rank = (hits, limit) =>
|
|
|
175
209
|
export const absentTerms = (documents, query) => {
|
|
176
210
|
const ts = [...new Set(terms(query))];
|
|
177
211
|
if (ts.length === 0) return [];
|
|
178
|
-
const hays = documents.map((d) => d.text
|
|
212
|
+
const hays = documents.map((d) => hayOf(d.text));
|
|
179
213
|
return ts.filter((t) => !hays.some((h) => h.includes(t)));
|
|
180
214
|
};
|
|
215
|
+
|
|
216
|
+
/* ---------------------------------------------------------- search_corpus ---
|
|
217
|
+
⭐ THE WHOLE TOOL, SHARED (2.4.1). It lived twice, once in each surface's callTool, and
|
|
218
|
+
its guidance line was about to be written twice too — the shape in which the envelope
|
|
219
|
+
drifted. One function, imported by both. */
|
|
220
|
+
|
|
221
|
+
// A search returns EXCERPTS, and a model that reads only excerpts answers from them. So the
|
|
222
|
+
// text a model reads says so at the moment it decides what to do next — in the result, where
|
|
223
|
+
// every client delivers it, rather than only in server instructions a client may drop.
|
|
224
|
+
const readingLine = (results, matches) => {
|
|
225
|
+
const slugs = results.map((r) => r.slug);
|
|
226
|
+
return (
|
|
227
|
+
`[Showing ${results.length} of ${matches} matching document${matches === 1 ? "" : "s"}, as excerpts. ` +
|
|
228
|
+
`To answer from the full texts, call read_documents with slugs ${JSON.stringify(slugs)}` +
|
|
229
|
+
(matches > results.length ? `; raise limit to see more of the ${matches} matches` : "") +
|
|
230
|
+
".]"
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const searchCorpus = ({ documents, envelope }, args) => {
|
|
235
|
+
const q = String(args?.query ?? "");
|
|
236
|
+
if (!q) throw new Error("query is required");
|
|
237
|
+
const pool = documents.filter((d) => !args?.genre || d.genre === args.genre);
|
|
238
|
+
const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
|
|
239
|
+
const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
|
|
240
|
+
...envelope(h.d),
|
|
241
|
+
genre: h.d.genre,
|
|
242
|
+
excerpt: h.m.excerpt,
|
|
243
|
+
// ⭐ HOW it matched, not just that it did: a "terms" excerpt need not
|
|
244
|
+
// contain the literal query, and a caller reading it should know
|
|
245
|
+
// which question the excerpt is answering.
|
|
246
|
+
match: h.m.mode
|
|
247
|
+
}));
|
|
248
|
+
// ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
|
|
249
|
+
// capped at `limit`, which made "10 matches" and "at least 10 matches"
|
|
250
|
+
// indistinguishable. `returned` carries the capped count.
|
|
251
|
+
const absent = hits.length ? [] : absentTerms(pool, q);
|
|
252
|
+
const readable = results.length
|
|
253
|
+
? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n") +
|
|
254
|
+
"\n\n" + readingLine(results, hits.length)
|
|
255
|
+
: absent.length
|
|
256
|
+
? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
|
|
257
|
+
: `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
|
|
258
|
+
return structuredWithText(readable, {
|
|
259
|
+
query: q,
|
|
260
|
+
matches: hits.length,
|
|
261
|
+
returned: results.length,
|
|
262
|
+
...(absent.length ? { absent_terms: absent } : {}),
|
|
263
|
+
results: results
|
|
264
|
+
});
|
|
265
|
+
};
|
package/src/server.mjs
CHANGED
|
@@ -40,9 +40,9 @@ import { documentText } from "./resources.mjs";
|
|
|
40
40
|
import { envelope } from "./envelope.mjs";
|
|
41
41
|
import { PROMPTS, getPrompt } from "./prompts.mjs";
|
|
42
42
|
import { BASE_TOOLS, withFacets } from "./base-tools.mjs";
|
|
43
|
-
import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, completenessOf, manifestProblems } from "./read-tools.mjs";
|
|
43
|
+
import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, readWith, completenessOf, manifestProblems } from "./read-tools.mjs";
|
|
44
44
|
import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
|
|
45
|
-
import {
|
|
45
|
+
import { searchCorpus } from "./search.mjs";
|
|
46
46
|
|
|
47
47
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
48
48
|
const INDEX = resolve(HERE, "..", "dist", "corpus.json");
|
|
@@ -121,35 +121,9 @@ const text = (value) => ({ content: [{ type: "text", text: typeof value === "str
|
|
|
121
121
|
|
|
122
122
|
const callTool = (name, args) => {
|
|
123
123
|
if (name === "search_corpus") {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
|
|
128
|
-
const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
|
|
129
|
-
...envelope(h.d),
|
|
130
|
-
genre: h.d.genre,
|
|
131
|
-
excerpt: h.m.excerpt,
|
|
132
|
-
// ⭐ HOW it matched, not just that it did: a "terms" excerpt need not
|
|
133
|
-
// contain the literal query, and a caller reading it should know
|
|
134
|
-
// which question the excerpt is answering.
|
|
135
|
-
match: h.m.mode
|
|
136
|
-
}));
|
|
137
|
-
// ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
|
|
138
|
-
// capped at `limit`, which made "10 matches" and "at least 10 matches"
|
|
139
|
-
// indistinguishable. `returned` carries the capped count.
|
|
140
|
-
const absent = hits.length ? [] : absentTerms(pool, q);
|
|
141
|
-
const readable = results.length
|
|
142
|
-
? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n")
|
|
143
|
-
: absent.length
|
|
144
|
-
? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
|
|
145
|
-
: `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
|
|
146
|
-
return structuredWithText(readable, {
|
|
147
|
-
query: q,
|
|
148
|
-
matches: hits.length,
|
|
149
|
-
returned: results.length,
|
|
150
|
-
...(absent.length ? { absent_terms: absent } : {}),
|
|
151
|
-
results: results
|
|
152
|
-
});
|
|
124
|
+
// Shared with the other surface (search.mjs): the answer, the excerpts and the
|
|
125
|
+
// reading line cannot differ between them.
|
|
126
|
+
return searchCorpus({ documents: corpus.documents, envelope }, args);
|
|
153
127
|
}
|
|
154
128
|
|
|
155
129
|
if (name === "get_document") {
|
|
@@ -233,6 +207,8 @@ const callTool = (name, args) => {
|
|
|
233
207
|
...(corpus.voices ? { voices: corpus.voices } : {}),
|
|
234
208
|
// A87: the manifest's counts and reasons beside the listing they account for.
|
|
235
209
|
...(corpus.manifest ? { completeness: completenessOf(corpus) } : {}),
|
|
210
|
+
// 2.4.1: what to call to read this listing in full — the same filters, as arguments.
|
|
211
|
+
...(readWith(args, list.length) ? { read_with: readWith(args, list.length) } : {}),
|
|
236
212
|
documents: list
|
|
237
213
|
});
|
|
238
214
|
}
|