@littlebigbrain/mcp 0.10.0 → 0.12.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 +33 -18
- package/dist/files-tool.d.ts +43 -0
- package/dist/files-tool.js +183 -0
- package/dist/query-tools.d.ts +89 -0
- package/dist/query-tools.js +208 -0
- package/dist/question.d.ts +9 -7
- package/dist/question.js +112 -61
- package/dist/tool-contracts.js +164 -13
- package/dist/tools.js +472 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -114,23 +114,29 @@ An agent without a SPARQL query can pass the question in plain words:
|
|
|
114
114
|
}
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
The server
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
A question
|
|
131
|
-
`
|
|
132
|
-
|
|
133
|
-
|
|
117
|
+
The server answers in plain words (`POST /v1/query/ask`). A router model picks
|
|
118
|
+
the kind of question. A reasoning model runs queries in a bounded loop, reads
|
|
119
|
+
their rows, and answers. The result holds `answer` (`text`, `citations`, the
|
|
120
|
+
IRIs the answer names, and `chart`, how to draw the rows, when the server
|
|
121
|
+
gave one), `steps`, the route, and the query and rows the answer stands on. `next` continues that query with `mode: "sparql"`. A
|
|
122
|
+
question takes about 8 s. Each question uses model tokens and counts toward a
|
|
123
|
+
daily limit of the stack, so the tool does not retry a failed call.
|
|
124
|
+
|
|
125
|
+
The server finds the names in the question in the graph and lists them in
|
|
126
|
+
`linked`. When the question is about records you already know, pass their
|
|
127
|
+
IRIs in `anchor` (at most 10): the queries use them directly. A question
|
|
128
|
+
about a date reads the last commit written by the end of that day; pass
|
|
129
|
+
`timeline` (`[{date, as_of_commit_seq, label?}]`) when the commits stand for
|
|
130
|
+
other dates. A question that asks what changed returns `history.added`,
|
|
131
|
+
`history.removed` and `history.changed`, with `history.totals`.
|
|
132
|
+
|
|
133
|
+
When the tool call carries a `_meta.progressToken`, the server sends a
|
|
134
|
+
progress notification for each step, for example `Route: lookup (router,
|
|
135
|
+
0.92)` or `Step 2, sparql SELECT ?db: 1 row`. The result is the same. Cancel
|
|
136
|
+
the call to stop the server's work.
|
|
137
|
+
|
|
138
|
+
To write the queries yourself, use `names`, `describe`, `commit_at` and
|
|
139
|
+
`compare`, then `mode: "sparql"`.
|
|
134
140
|
|
|
135
141
|
For your own data, start with `lbb_inspect` using `action: "guide"` or
|
|
136
142
|
`action: "ontology"`. Imported Resource Description Framework (RDF) data keeps
|
|
@@ -141,14 +147,23 @@ its original identifiers; inspect it with SPARQL when choosing query predicates.
|
|
|
141
147
|
| Tool | Purpose |
|
|
142
148
|
| --- | --- |
|
|
143
149
|
| `lbb_inspect` | Read the schema, graph status, and entities. |
|
|
144
|
-
| `lbb_query` | Ask a question in plain words, run SPARQL queries, search by meaning (also inside a SPARQL query with `search:similarTo`), or request summary statistics. |
|
|
150
|
+
| `lbb_query` | Ask a question in plain words, run SPARQL queries, search by meaning (also inside a SPARQL query with `search:similarTo`), or request summary statistics. Four modes help an agent write its own queries: `names` (the IRIs of the names in a text), `describe` (the classes and properties a question needs, with how many instances hold each property and the values of small classes), `commit_at` (the commit of a date) and `compare` (one query at two points, the rows paired by entity). |
|
|
145
151
|
| `lbb_rdf` | Import RDF documents or add facts with SPARQL `INSERT DATA`. |
|
|
146
152
|
| `lbb_embeddings` | Inspect search setup and preview the text to embed. |
|
|
147
153
|
| `lbb_embeddings_manage` | Set up or refresh embeddings, or change the embedding model. |
|
|
148
154
|
| `lbb_embeddings_delete` | Delete an embedding and its stored vectors. |
|
|
155
|
+
| `lbb_fit_sources` | Inspect fit from text and preview the text the fit reads. |
|
|
156
|
+
| `lbb_fit_sources_manage` | Declare, dry-run or refresh a fit source. |
|
|
157
|
+
| `lbb_fit_sources_delete` | Delete a fit source; its suggestions stay. |
|
|
158
|
+
| `lbb_workflows` | List the starter workflows this server offers and the workflows created from them on a graph (triggered workflows), read one with its status, and preview a new one. |
|
|
159
|
+
| `lbb_workflows_manage` | Create a workflow from a starter workflow on a class (a developer workflow with its workflow type, owned outputs and batch), ask it to run now, or pause and resume a developer workflow. |
|
|
160
|
+
| `lbb_workflows_delete` | Delete a triggered workflow. What it wrote stays. |
|
|
161
|
+
| `lbb_files` | List the files of a graph, and read the parsed pages of one: each line with its id, its box on the page and its text. |
|
|
149
162
|
| `lbb_commit` | Write or retract JSON facts, or record search feedback. |
|
|
150
163
|
| `lbb_configure` | Define record types and relationships, list and apply ontology starters (CRM, documents, work), publish validation rules, or keep the graph's notes and worked examples for questions in plain words (`get_rewrite_profile`, `set_rewrite_profile`). |
|
|
151
164
|
| `lbb_evals` | Label query results and check whether later queries return the expected answers. Read the model checks of a graph, and agree with or correct a check that the user confirmed. |
|
|
165
|
+
| `lbb_model_choice` | Read which model each use runs (question answers, query routing, search rerank, ontology fit, eval labels), and the trials that test other models against the graph's ground truth. |
|
|
166
|
+
| `lbb_model_choice_manage` | Start or stop a trial, switch a use to a tested model that meets the bar, or revert to LBB's model. |
|
|
152
167
|
| `lbb_models` | Compare retrieval settings, read model training datasets, or read a month of managed-model activity. |
|
|
153
168
|
|
|
154
169
|
See [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { LbbClient, Schemas } from "@littlebigbrain/client";
|
|
3
|
+
/** Lines one `pages` call returns by default, and at most. */
|
|
4
|
+
export declare const FILE_LINES_DEFAULT = 200;
|
|
5
|
+
export declare const FILE_LINES_MAX = 1000;
|
|
6
|
+
/** Characters of one line's text a call shows; a longer text is cut with "…". */
|
|
7
|
+
export declare const FILE_LINE_CHARS = 400;
|
|
8
|
+
/** Characters of lines one call returns at most, so the result fits an MCP answer. */
|
|
9
|
+
export declare const FILE_LINES_BUDGET = 60000;
|
|
10
|
+
type FilePage = Schemas["FilePage"];
|
|
11
|
+
/** One line as the tool shows it: `<id> [x0,y0,x1,y1] <text>`. */
|
|
12
|
+
export declare function fileLineText(line: Schemas["FileLine"]): string;
|
|
13
|
+
export interface PageLinesWindow {
|
|
14
|
+
/** Lines of the first page to skip (they were shown by the call before). */
|
|
15
|
+
lineOffset: number;
|
|
16
|
+
maxLines: number;
|
|
17
|
+
/** Only lines whose text holds this, ignoring case. */
|
|
18
|
+
contains?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PageLinesAnswer {
|
|
21
|
+
pages: Array<{
|
|
22
|
+
page: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
lines_total: number;
|
|
26
|
+
lines_matching: number;
|
|
27
|
+
lines: string[];
|
|
28
|
+
}>;
|
|
29
|
+
shown: number;
|
|
30
|
+
/** Where the next call starts when lines were left out. */
|
|
31
|
+
next?: {
|
|
32
|
+
from: number;
|
|
33
|
+
line_offset: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The lines of parsed pages as short strings, at most `maxLines` and
|
|
38
|
+
* {@link FILE_LINES_BUDGET} characters, from `lineOffset` of the first page.
|
|
39
|
+
* `next` names the page and the offset of the first line left out.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pageLines(pages: FilePage[], window: PageLinesWindow): PageLinesAnswer;
|
|
42
|
+
export declare function registerFilesTool(server: McpServer, client: LbbClient): void;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { READ_ONLY, detailSchema, graphScope } from "./tool-contracts.js";
|
|
3
|
+
import { enrichError, errorResult, run, scoped, toolResult, } from "./tool-runtime.js";
|
|
4
|
+
/** Lines one `pages` call returns by default, and at most. */
|
|
5
|
+
export const FILE_LINES_DEFAULT = 200;
|
|
6
|
+
export const FILE_LINES_MAX = 1_000;
|
|
7
|
+
/** Characters of one line's text a call shows; a longer text is cut with "…". */
|
|
8
|
+
export const FILE_LINE_CHARS = 400;
|
|
9
|
+
/** Characters of lines one call returns at most, so the result fits an MCP answer. */
|
|
10
|
+
export const FILE_LINES_BUDGET = 60_000;
|
|
11
|
+
/** One line as the tool shows it: `<id> [x0,y0,x1,y1] <text>`. */
|
|
12
|
+
export function fileLineText(line) {
|
|
13
|
+
const text = line.text.length > FILE_LINE_CHARS
|
|
14
|
+
? `${line.text.slice(0, FILE_LINE_CHARS - 1)}…`
|
|
15
|
+
: line.text;
|
|
16
|
+
return `${line.id} [${line.box.join(",")}] ${text}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The lines of parsed pages as short strings, at most `maxLines` and
|
|
20
|
+
* {@link FILE_LINES_BUDGET} characters, from `lineOffset` of the first page.
|
|
21
|
+
* `next` names the page and the offset of the first line left out.
|
|
22
|
+
*/
|
|
23
|
+
export function pageLines(pages, window) {
|
|
24
|
+
const needle = window.contains?.trim().toLowerCase();
|
|
25
|
+
const answer = { pages: [], shown: 0 };
|
|
26
|
+
let budget = FILE_LINES_BUDGET;
|
|
27
|
+
for (const [index, page] of pages.entries()) {
|
|
28
|
+
const skip = index === 0 ? window.lineOffset : 0;
|
|
29
|
+
const shown = [];
|
|
30
|
+
let matching = 0;
|
|
31
|
+
let stoppedAt;
|
|
32
|
+
for (const [position, line] of page.lines.entries()) {
|
|
33
|
+
if (position < skip)
|
|
34
|
+
continue;
|
|
35
|
+
if (needle && !line.text.toLowerCase().includes(needle))
|
|
36
|
+
continue;
|
|
37
|
+
matching += 1;
|
|
38
|
+
if (stoppedAt !== undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const text = fileLineText(line);
|
|
41
|
+
if (answer.shown >= window.maxLines || text.length > budget) {
|
|
42
|
+
stoppedAt = position;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
shown.push(text);
|
|
46
|
+
answer.shown += 1;
|
|
47
|
+
budget -= text.length;
|
|
48
|
+
}
|
|
49
|
+
answer.pages.push({
|
|
50
|
+
page: page.page,
|
|
51
|
+
width: page.width,
|
|
52
|
+
height: page.height,
|
|
53
|
+
lines_total: page.lines.length,
|
|
54
|
+
lines_matching: matching,
|
|
55
|
+
lines: shown,
|
|
56
|
+
});
|
|
57
|
+
if (stoppedAt !== undefined) {
|
|
58
|
+
answer.next = { from: page.page, line_offset: stoppedAt };
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return answer;
|
|
63
|
+
}
|
|
64
|
+
const SHA256 = /^[0-9a-fA-F]{64}$/;
|
|
65
|
+
/** The SHA-256 of the file a call names, by `sha256` or by its exact name. */
|
|
66
|
+
async function fileSha(target, args) {
|
|
67
|
+
if (args.sha256) {
|
|
68
|
+
if (!SHA256.test(args.sha256.trim()))
|
|
69
|
+
throw new Error("sha256 is 64 hexadecimal characters");
|
|
70
|
+
return { sha256: args.sha256.trim().toLowerCase() };
|
|
71
|
+
}
|
|
72
|
+
if (!args.name)
|
|
73
|
+
throw new Error("pages needs sha256 or name");
|
|
74
|
+
const { files } = await target.files.list();
|
|
75
|
+
const named = files.filter((file) => file.name === args.name);
|
|
76
|
+
if (named.length === 1)
|
|
77
|
+
return { sha256: named[0].sha256, name: named[0].name };
|
|
78
|
+
if (named.length > 1)
|
|
79
|
+
throw new Error(`several files are named ${args.name}; pass sha256: ${named.map((file) => file.sha256).join(", ")}`);
|
|
80
|
+
const names = files.slice(0, 20).map((file) => file.name);
|
|
81
|
+
throw new Error(`no file is named ${args.name}${names.length ? `; the graph has ${names.join(", ")}` : "; the graph has no files"}`);
|
|
82
|
+
}
|
|
83
|
+
export function registerFilesTool(server, client) {
|
|
84
|
+
server.registerTool("lbb_files", {
|
|
85
|
+
description: "Files of the graph, read only. A file is a document the graph holds (its bytes and a Document entity); the documents.parse workflow (lbb_workflows) reads the text layer of each PDF into pages with every line and its box. list shows the files with their sha256, name, size, Document IRI and parse (status parsed, unsupported or failed, page_count). pages reads parsed pages of one file (sha256, or name for an exact file name): from is the first 0-based page index (the page of a region and of a DocEntry or DocMention), to the page after the last one (default from+1). Each line comes as `<line id> [x0,y0,x1,y1] <text>`: the id is p<page number>-l<line number>, both from 1, and the box is normalized to the page (0 to 1, origin top left). contains keeps the lines that hold a text; max_lines caps the lines (default 200). When lines were left out, next names the from and line_offset of the next call. A file not parsed yet answers file_not_parsed.",
|
|
86
|
+
inputSchema: {
|
|
87
|
+
action: z.enum(["list", "pages"]),
|
|
88
|
+
sha256: z
|
|
89
|
+
.string()
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("pages: the file's SHA-256 (64 hex characters)."),
|
|
92
|
+
name: z
|
|
93
|
+
.string()
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("pages: the file's exact name, when you do not have its sha256."),
|
|
96
|
+
from: z
|
|
97
|
+
.number()
|
|
98
|
+
.int()
|
|
99
|
+
.nonnegative()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe("pages: the first page index, 0-based. Default 0."),
|
|
102
|
+
to: z
|
|
103
|
+
.number()
|
|
104
|
+
.int()
|
|
105
|
+
.positive()
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("pages: the page index after the last one. Default from+1; at most 50 pages per call."),
|
|
108
|
+
line_offset: z
|
|
109
|
+
.number()
|
|
110
|
+
.int()
|
|
111
|
+
.nonnegative()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("pages: lines of the first page to skip, from a previous call's next."),
|
|
114
|
+
max_lines: z
|
|
115
|
+
.number()
|
|
116
|
+
.int()
|
|
117
|
+
.positive()
|
|
118
|
+
.max(FILE_LINES_MAX)
|
|
119
|
+
.optional()
|
|
120
|
+
.describe(`pages: the most lines to return. Default ${FILE_LINES_DEFAULT}.`),
|
|
121
|
+
contains: z
|
|
122
|
+
.string()
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("pages: only the lines whose text holds this, ignoring case."),
|
|
125
|
+
detail: detailSchema,
|
|
126
|
+
...graphScope,
|
|
127
|
+
},
|
|
128
|
+
annotations: READ_ONLY,
|
|
129
|
+
}, async (args) => {
|
|
130
|
+
const target = scoped(client, args.graph);
|
|
131
|
+
if (args.action === "list") {
|
|
132
|
+
return run(client, "lbb_files.list", args.detail, async () => {
|
|
133
|
+
const { files } = await target.files.list();
|
|
134
|
+
return { files };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const file = await fileSha(target, args);
|
|
139
|
+
const from = args.from ?? 0;
|
|
140
|
+
const to = args.to ?? from + 1;
|
|
141
|
+
if (to <= from)
|
|
142
|
+
throw new Error("to must be above from");
|
|
143
|
+
const answer = await target.files.pages(file.sha256, { from, to });
|
|
144
|
+
const lines = pageLines(answer.pages, {
|
|
145
|
+
lineOffset: args.line_offset ?? 0,
|
|
146
|
+
maxLines: args.max_lines ?? FILE_LINES_DEFAULT,
|
|
147
|
+
contains: args.contains,
|
|
148
|
+
});
|
|
149
|
+
const label = file.name ?? answer.sha256.slice(0, 12);
|
|
150
|
+
const range = answer.to - answer.from > 1
|
|
151
|
+
? `pages ${answer.from} to ${answer.to - 1}`
|
|
152
|
+
: `page ${answer.from}`;
|
|
153
|
+
return toolResult({
|
|
154
|
+
summary: `${label}, ${range} of ${answer.page_count} (0-based): ${lines.shown} lines${lines.next ? ", more after next" : ""}`,
|
|
155
|
+
data: {
|
|
156
|
+
sha256: answer.sha256,
|
|
157
|
+
parser: answer.parser,
|
|
158
|
+
page_count: answer.page_count,
|
|
159
|
+
from: answer.from,
|
|
160
|
+
to: answer.to,
|
|
161
|
+
line_format: "<line id> [x0,y0,x1,y1] <text>",
|
|
162
|
+
pages: lines.pages,
|
|
163
|
+
},
|
|
164
|
+
...(lines.next
|
|
165
|
+
? {
|
|
166
|
+
truncated: true,
|
|
167
|
+
next: {
|
|
168
|
+
action: "pages",
|
|
169
|
+
sha256: answer.sha256,
|
|
170
|
+
from: lines.next.from,
|
|
171
|
+
to: answer.to,
|
|
172
|
+
line_offset: lines.next.line_offset,
|
|
173
|
+
...(args.contains ? { contains: args.contains } : {}),
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return errorResult(await enrichError(client, error));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { LbbClient } from "@littlebigbrain/client";
|
|
2
|
+
import { type Detail } from "./tool-contracts.js";
|
|
3
|
+
/** One value of a row, as SPARQL 1.1 Query Results JSON writes it. */
|
|
4
|
+
export interface Term {
|
|
5
|
+
type: string;
|
|
6
|
+
value: string;
|
|
7
|
+
datatype?: string | null;
|
|
8
|
+
"xml:lang"?: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** A row as `{ variable: lexical value }`. */
|
|
11
|
+
export declare function lexicalRow(row: Record<string, Term>): Record<string, string>;
|
|
12
|
+
/** Entries of each list a page shows, by detail. */
|
|
13
|
+
export declare function listLimit(detail: Detail): number;
|
|
14
|
+
export interface NamesArgs {
|
|
15
|
+
text: string;
|
|
16
|
+
limit?: number;
|
|
17
|
+
detail?: string;
|
|
18
|
+
graph?: string;
|
|
19
|
+
}
|
|
20
|
+
/** `lbb_query mode=names`: the entities the text names, per name. */
|
|
21
|
+
export declare function findNames(client: LbbClient, args: NamesArgs): Promise<{
|
|
22
|
+
content: {
|
|
23
|
+
type: "text";
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
structuredContent: Record<string, unknown>;
|
|
27
|
+
}>;
|
|
28
|
+
export interface DescribeArgs {
|
|
29
|
+
question?: string;
|
|
30
|
+
classes?: string[];
|
|
31
|
+
properties?: string[];
|
|
32
|
+
detail?: string;
|
|
33
|
+
graph?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `lbb_query mode=describe`: the parts of the graph a question needs. The
|
|
37
|
+
* compact and standard answers are the server's text; full adds the same
|
|
38
|
+
* description as JSON.
|
|
39
|
+
*/
|
|
40
|
+
export declare function describeGraph(client: LbbClient, args: DescribeArgs): Promise<{
|
|
41
|
+
content: {
|
|
42
|
+
type: "text";
|
|
43
|
+
text: string;
|
|
44
|
+
}[];
|
|
45
|
+
structuredContent: Record<string, unknown>;
|
|
46
|
+
}>;
|
|
47
|
+
export interface CommitAtArgs {
|
|
48
|
+
date?: string;
|
|
49
|
+
moment?: string;
|
|
50
|
+
graph?: string;
|
|
51
|
+
}
|
|
52
|
+
/** `lbb_query mode=commit_at`: the commit of a date or a moment. */
|
|
53
|
+
export declare function commitAt(client: LbbClient, args: CommitAtArgs): Promise<{
|
|
54
|
+
content: {
|
|
55
|
+
type: "text";
|
|
56
|
+
text: string;
|
|
57
|
+
}[];
|
|
58
|
+
structuredContent: Record<string, unknown>;
|
|
59
|
+
}>;
|
|
60
|
+
interface ComparePoint {
|
|
61
|
+
as_of_commit_seq?: number;
|
|
62
|
+
date?: string;
|
|
63
|
+
moment?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface CompareArgs {
|
|
66
|
+
query: string;
|
|
67
|
+
before: ComparePoint;
|
|
68
|
+
after?: ComparePoint;
|
|
69
|
+
key?: string[];
|
|
70
|
+
entailment?: "none" | "subclass" | "rdfs" | "owl";
|
|
71
|
+
max_rows?: number;
|
|
72
|
+
limit?: number;
|
|
73
|
+
compare_cursor?: string;
|
|
74
|
+
detail?: string;
|
|
75
|
+
graph?: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `lbb_query mode=compare`: one SELECT at two points, the rows paired by
|
|
79
|
+
* `key`. Each list shows one page with its total; `next` continues with the
|
|
80
|
+
* server's cursor.
|
|
81
|
+
*/
|
|
82
|
+
export declare function compareQuery(client: LbbClient, args: CompareArgs): Promise<{
|
|
83
|
+
content: {
|
|
84
|
+
type: "text";
|
|
85
|
+
text: string;
|
|
86
|
+
}[];
|
|
87
|
+
structuredContent: Record<string, unknown>;
|
|
88
|
+
}>;
|
|
89
|
+
export {};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { HARD_OUTPUT_CHARS } from "./tool-contracts.js";
|
|
2
|
+
import { compactLimits, normalizeDetail, scoped, toolResult, truncateValue, } from "./tool-runtime.js";
|
|
3
|
+
/** A row as `{ variable: lexical value }`. */
|
|
4
|
+
export function lexicalRow(row) {
|
|
5
|
+
return Object.fromEntries(Object.entries(row).map(([name, term]) => [name, term.value]));
|
|
6
|
+
}
|
|
7
|
+
/** Entries of each list a page shows, by detail. */
|
|
8
|
+
export function listLimit(detail) {
|
|
9
|
+
switch (detail) {
|
|
10
|
+
case "full":
|
|
11
|
+
return 500;
|
|
12
|
+
case "standard":
|
|
13
|
+
return 100;
|
|
14
|
+
default:
|
|
15
|
+
return 20;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Long strings cut by detail; every entry stays, so no cursor skips one. */
|
|
19
|
+
function boundStrings(value, detail) {
|
|
20
|
+
return truncateValue(value, {
|
|
21
|
+
maxItems: Number.MAX_SAFE_INTEGER,
|
|
22
|
+
maxString: compactLimits(detail).maxString,
|
|
23
|
+
}, { truncated: false });
|
|
24
|
+
}
|
|
25
|
+
/** A tool result that must fit the MCP output budget whole. */
|
|
26
|
+
function fitted(value, narrow) {
|
|
27
|
+
const text = JSON.stringify(value, null, 2);
|
|
28
|
+
if (Buffer.byteLength(text, "utf8") > HARD_OUTPUT_CHARS) {
|
|
29
|
+
throw new Error(`The answer exceeds the MCP output budget. ${narrow}`);
|
|
30
|
+
}
|
|
31
|
+
return toolResult(value);
|
|
32
|
+
}
|
|
33
|
+
/** `lbb_query mode=names`: the entities the text names, per name. */
|
|
34
|
+
export async function findNames(client, args) {
|
|
35
|
+
const target = scoped(client, args.graph);
|
|
36
|
+
const response = await target.request("POST", "/v1/query/names", {
|
|
37
|
+
body: {
|
|
38
|
+
text: args.text,
|
|
39
|
+
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
40
|
+
},
|
|
41
|
+
retry: true,
|
|
42
|
+
});
|
|
43
|
+
const names = [];
|
|
44
|
+
for (const { text, ...candidate } of response.candidates) {
|
|
45
|
+
const last = names[names.length - 1];
|
|
46
|
+
if (last && last.text === text)
|
|
47
|
+
last.candidates.push(candidate);
|
|
48
|
+
else
|
|
49
|
+
names.push({ text, candidates: [candidate] });
|
|
50
|
+
}
|
|
51
|
+
const notes = [];
|
|
52
|
+
if (!response.index_ready) {
|
|
53
|
+
notes.push("The graph's name index is still building: ask again in a few seconds.");
|
|
54
|
+
}
|
|
55
|
+
else if (names.length === 0) {
|
|
56
|
+
notes.push('No name of the text matched an entity. Match the name in SPARQL instead, without case and on a short distinctive part, for example FILTER(CONTAINS(LCASE(STR(?label)), "quellm")).');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
notes.push("Use the first candidate of each name unless its class does not fit the question; put its IRI in the query (as a subject, an object or in VALUES) instead of matching the name.");
|
|
60
|
+
}
|
|
61
|
+
const count = response.candidates.length;
|
|
62
|
+
return fitted({
|
|
63
|
+
summary: `lbb_query.names: ${names.length} ${names.length === 1 ? "name" : "names"}, ${count} ${count === 1 ? "candidate" : "candidates"}`,
|
|
64
|
+
data: {
|
|
65
|
+
names,
|
|
66
|
+
index_ready: response.index_ready,
|
|
67
|
+
commit_seq: response.commit_seq,
|
|
68
|
+
},
|
|
69
|
+
notes,
|
|
70
|
+
}, "Pass a shorter text.");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `lbb_query mode=describe`: the parts of the graph a question needs. The
|
|
74
|
+
* compact and standard answers are the server's text; full adds the same
|
|
75
|
+
* description as JSON.
|
|
76
|
+
*/
|
|
77
|
+
export async function describeGraph(client, args) {
|
|
78
|
+
const detail = normalizeDetail(args.detail);
|
|
79
|
+
const target = scoped(client, args.graph);
|
|
80
|
+
const response = await target.request("POST", "/v1/query/describe", {
|
|
81
|
+
body: {
|
|
82
|
+
...(args.question !== undefined ? { question: args.question } : {}),
|
|
83
|
+
...(args.classes?.length ? { classes: args.classes } : {}),
|
|
84
|
+
...(args.properties?.length ? { properties: args.properties } : {}),
|
|
85
|
+
},
|
|
86
|
+
retry: true,
|
|
87
|
+
});
|
|
88
|
+
const notes = [];
|
|
89
|
+
if (response.partial) {
|
|
90
|
+
notes.push("The server is still sampling the graph: coverage and the values of small classes can be missing. Ask again in a minute for the whole description.");
|
|
91
|
+
}
|
|
92
|
+
if (response.unknown?.length) {
|
|
93
|
+
notes.push(`The graph has no class or property ${response.unknown.join(", ")}.`);
|
|
94
|
+
}
|
|
95
|
+
notes.push("A property that few sampled instances hold (n of m) is rare: a filter on it returns few rows. Use the listed values of small classes as they are written.");
|
|
96
|
+
const data = {
|
|
97
|
+
commit_seq: response.commit_seq,
|
|
98
|
+
partial: response.partial,
|
|
99
|
+
text: response.text,
|
|
100
|
+
...(response.unknown?.length ? { unknown: response.unknown } : {}),
|
|
101
|
+
};
|
|
102
|
+
if (detail === "full") {
|
|
103
|
+
data.classes = response.classes;
|
|
104
|
+
data.properties = response.properties;
|
|
105
|
+
data.statements = response.statements;
|
|
106
|
+
data.prefixes = response.prefixes;
|
|
107
|
+
}
|
|
108
|
+
return fitted({
|
|
109
|
+
summary: `lbb_query.describe: ${response.classes.length} classes, ${response.properties.length} properties`,
|
|
110
|
+
data,
|
|
111
|
+
notes,
|
|
112
|
+
}, "Name fewer classes, or use detail=standard.");
|
|
113
|
+
}
|
|
114
|
+
/** `lbb_query mode=commit_at`: the commit of a date or a moment. */
|
|
115
|
+
export async function commitAt(client, args) {
|
|
116
|
+
if ((args.date === undefined) === (args.moment === undefined)) {
|
|
117
|
+
throw new Error("commit_at needs exactly one of date and moment");
|
|
118
|
+
}
|
|
119
|
+
const target = scoped(client, args.graph);
|
|
120
|
+
const response = await target.request("GET", "/v1/graph/commit-at", {
|
|
121
|
+
query: { date: args.date, moment: args.moment },
|
|
122
|
+
retry: true,
|
|
123
|
+
});
|
|
124
|
+
const notes = [];
|
|
125
|
+
if (typeof response.as_of_commit_seq === "number") {
|
|
126
|
+
notes.push(`Read the graph as it was then with lbb_query mode=sparql as_of_commit_seq=${response.as_of_commit_seq}, or compare two points with lbb_query mode=compare.`);
|
|
127
|
+
}
|
|
128
|
+
if (response.note)
|
|
129
|
+
notes.push(response.note);
|
|
130
|
+
return toolResult({
|
|
131
|
+
summary: typeof response.as_of_commit_seq === "number"
|
|
132
|
+
? `lbb_query.commit_at: commit ${response.as_of_commit_seq}`
|
|
133
|
+
: "lbb_query.commit_at: no commit",
|
|
134
|
+
data: response,
|
|
135
|
+
notes,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `lbb_query mode=compare`: one SELECT at two points, the rows paired by
|
|
140
|
+
* `key`. Each list shows one page with its total; `next` continues with the
|
|
141
|
+
* server's cursor.
|
|
142
|
+
*/
|
|
143
|
+
export async function compareQuery(client, args) {
|
|
144
|
+
const detail = normalizeDetail(args.detail);
|
|
145
|
+
const target = scoped(client, args.graph);
|
|
146
|
+
const limit = args.limit ?? listLimit(detail);
|
|
147
|
+
const response = await target.request("POST", "/v1/query/compare", {
|
|
148
|
+
body: {
|
|
149
|
+
query: args.query,
|
|
150
|
+
before: args.before,
|
|
151
|
+
...(args.after ? { after: args.after } : {}),
|
|
152
|
+
...(args.key?.length ? { key: args.key } : {}),
|
|
153
|
+
...(args.entailment ? { entailment: args.entailment } : {}),
|
|
154
|
+
...(args.max_rows !== undefined ? { max_rows: args.max_rows } : {}),
|
|
155
|
+
limit,
|
|
156
|
+
...(args.compare_cursor ? { cursor: args.compare_cursor } : {}),
|
|
157
|
+
},
|
|
158
|
+
retry: true,
|
|
159
|
+
query: { consistency: target.defaultConsistency },
|
|
160
|
+
});
|
|
161
|
+
const notes = [...(response.notes ?? [])];
|
|
162
|
+
const totals = response.totals;
|
|
163
|
+
const shown = {
|
|
164
|
+
added: response.added.length,
|
|
165
|
+
removed: response.removed.length,
|
|
166
|
+
changed: response.changed.length,
|
|
167
|
+
};
|
|
168
|
+
const cut = Object.keys(shown).filter((name) => response.offset + shown[name] < totals[name]);
|
|
169
|
+
if (cut.length) {
|
|
170
|
+
notes.push(`This page shows entries ${response.offset + 1} to ${response.offset + limit} of each list (${cut.map((name) => `${name}: ${totals[name]}`).join(", ")}). Call again with the arguments in next for the next page.`);
|
|
171
|
+
}
|
|
172
|
+
const data = boundStrings({
|
|
173
|
+
before: response.before,
|
|
174
|
+
after: response.after,
|
|
175
|
+
key: response.key ?? [],
|
|
176
|
+
totals,
|
|
177
|
+
added: response.added.map(lexicalRow),
|
|
178
|
+
removed: response.removed.map(lexicalRow),
|
|
179
|
+
changed: response.changed.map((change) => ({
|
|
180
|
+
key: lexicalRow(change.key),
|
|
181
|
+
before: change.before.map(lexicalRow),
|
|
182
|
+
after: change.after.map(lexicalRow),
|
|
183
|
+
})),
|
|
184
|
+
offset: response.offset,
|
|
185
|
+
...(response.truncated ? { truncated: true } : {}),
|
|
186
|
+
}, detail);
|
|
187
|
+
const next = response.next_cursor
|
|
188
|
+
? {
|
|
189
|
+
mode: "compare",
|
|
190
|
+
query: args.query,
|
|
191
|
+
before: args.before,
|
|
192
|
+
...(args.after ? { after: args.after } : {}),
|
|
193
|
+
...(args.key?.length ? { key: args.key } : {}),
|
|
194
|
+
...(args.entailment ? { entailment: args.entailment } : {}),
|
|
195
|
+
...(args.max_rows !== undefined ? { max_rows: args.max_rows } : {}),
|
|
196
|
+
limit,
|
|
197
|
+
compare_cursor: response.next_cursor,
|
|
198
|
+
...(args.detail ? { detail: args.detail } : {}),
|
|
199
|
+
...(args.graph ? { graph: args.graph } : {}),
|
|
200
|
+
}
|
|
201
|
+
: undefined;
|
|
202
|
+
return fitted({
|
|
203
|
+
summary: `lbb_query.compare: added ${totals.added}, removed ${totals.removed}, changed ${totals.changed}${response.truncated ? " [not complete: a point was not read whole]" : ""}`,
|
|
204
|
+
data,
|
|
205
|
+
...(next ? { next } : {}),
|
|
206
|
+
notes,
|
|
207
|
+
}, "Pass a smaller limit, or project fewer variables.");
|
|
208
|
+
}
|
package/dist/question.d.ts
CHANGED
|
@@ -11,7 +11,6 @@ export interface QuestionArgs {
|
|
|
11
11
|
context?: string;
|
|
12
12
|
route?: QuestionRoute;
|
|
13
13
|
limit?: number;
|
|
14
|
-
run?: boolean;
|
|
15
14
|
anchor?: string[];
|
|
16
15
|
timeline?: TimelinePoint[];
|
|
17
16
|
detail?: string;
|
|
@@ -26,7 +25,7 @@ export interface QuestionProgress {
|
|
|
26
25
|
signal?: AbortSignal;
|
|
27
26
|
report(progress: number, message: string): Promise<void>;
|
|
28
27
|
}
|
|
29
|
-
/** One event of a streamed
|
|
28
|
+
/** One event of a streamed question, as this tool reads it. */
|
|
30
29
|
type StreamEvent = {
|
|
31
30
|
event: string;
|
|
32
31
|
data: unknown;
|
|
@@ -34,11 +33,14 @@ type StreamEvent = {
|
|
|
34
33
|
/** The progress message of a stream event, in short plain words. */
|
|
35
34
|
export declare function progressMessage(event: StreamEvent): string | undefined;
|
|
36
35
|
/**
|
|
37
|
-
* `lbb_query mode=question`: the server
|
|
38
|
-
* query
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* `lbb_query mode=question`: the server answers the question in plain words
|
|
37
|
+
* (`POST /v1/query/ask`). Its model runs queries in a bounded loop and
|
|
38
|
+
* reads their rows. The result holds the answer, its citations and chart
|
|
39
|
+
* hint, the steps, and the rows of the query the answer stands on, bounded
|
|
40
|
+
* as `mode=sparql`
|
|
41
|
+
* bounds them; `next` continues that query with `mode=sparql`. With
|
|
42
|
+
* `progress`, the server streams its steps and each step is reported; the
|
|
43
|
+
* result is the same.
|
|
42
44
|
*/
|
|
43
45
|
export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty", progress?: QuestionProgress): Promise<{
|
|
44
46
|
content: {
|