@littlebigbrain/mcp 0.9.0 → 0.11.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 +30 -7
- package/dist/query-tools.d.ts +89 -0
- package/dist/query-tools.js +208 -0
- package/dist/question.d.ts +33 -5
- package/dist/question.js +190 -24
- package/dist/tool-contracts.js +168 -10
- package/dist/tool-runtime.js +1 -1
- package/dist/tools.js +311 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -114,11 +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
|
-
|
|
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"`.
|
|
122
140
|
|
|
123
141
|
For your own data, start with `lbb_inspect` using `action: "guide"` or
|
|
124
142
|
`action: "ontology"`. Imported Resource Description Framework (RDF) data keeps
|
|
@@ -129,14 +147,19 @@ its original identifiers; inspect it with SPARQL when choosing query predicates.
|
|
|
129
147
|
| Tool | Purpose |
|
|
130
148
|
| --- | --- |
|
|
131
149
|
| `lbb_inspect` | Read the schema, graph status, and entities. |
|
|
132
|
-
| `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). |
|
|
133
151
|
| `lbb_rdf` | Import RDF documents or add facts with SPARQL `INSERT DATA`. |
|
|
134
152
|
| `lbb_embeddings` | Inspect search setup and preview the text to embed. |
|
|
135
153
|
| `lbb_embeddings_manage` | Set up or refresh embeddings, or change the embedding model. |
|
|
136
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. |
|
|
137
158
|
| `lbb_commit` | Write or retract JSON facts, or record search feedback. |
|
|
138
|
-
| `lbb_configure` | Define record types and relationships, list and apply ontology starters (CRM, documents, work),
|
|
159
|
+
| `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`). |
|
|
139
160
|
| `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. |
|
|
161
|
+
| `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. |
|
|
162
|
+
| `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. |
|
|
140
163
|
| `lbb_models` | Compare retrieval settings, read model training datasets, or read a month of managed-model activity. |
|
|
141
164
|
|
|
142
165
|
See [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/)
|
|
@@ -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
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
import type { LbbClient } from "@littlebigbrain/client";
|
|
2
2
|
type QuestionRoute = "lookup" | "aggregate" | "search" | "history" | "schema" | "unanswerable";
|
|
3
|
+
/** A date and the commit that holds the graph as it was then. */
|
|
4
|
+
interface TimelinePoint {
|
|
5
|
+
date: string;
|
|
6
|
+
as_of_commit_seq: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
}
|
|
3
9
|
export interface QuestionArgs {
|
|
4
10
|
question: string;
|
|
5
11
|
context?: string;
|
|
6
12
|
route?: QuestionRoute;
|
|
7
13
|
limit?: number;
|
|
8
|
-
|
|
14
|
+
anchor?: string[];
|
|
15
|
+
timeline?: TimelinePoint[];
|
|
9
16
|
detail?: string;
|
|
10
17
|
graph?: string;
|
|
11
18
|
}
|
|
12
19
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
20
|
+
* Progress of one question: the tool call asked for it with a
|
|
21
|
+
* `_meta.progressToken`. `report` sends one MCP progress notification.
|
|
22
|
+
*/
|
|
23
|
+
export interface QuestionProgress {
|
|
24
|
+
/** Stops the server's work when the tool call is cancelled. */
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
report(progress: number, message: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** One event of a streamed question, as this tool reads it. */
|
|
29
|
+
type StreamEvent = {
|
|
30
|
+
event: string;
|
|
31
|
+
data: unknown;
|
|
32
|
+
};
|
|
33
|
+
/** The progress message of a stream event, in short plain words. */
|
|
34
|
+
export declare function progressMessage(event: StreamEvent): string | undefined;
|
|
35
|
+
/**
|
|
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.
|
|
16
44
|
*/
|
|
17
|
-
export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty"): Promise<{
|
|
45
|
+
export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty", progress?: QuestionProgress): Promise<{
|
|
18
46
|
content: {
|
|
19
47
|
type: "text";
|
|
20
48
|
text: string;
|
package/dist/question.js
CHANGED
|
@@ -1,32 +1,189 @@
|
|
|
1
|
+
import { lexicalRow, listLimit } from "./query-tools.js";
|
|
1
2
|
import { QUESTION_MAX_ROWS } from "./tool-contracts.js";
|
|
2
3
|
import { defaultRowLimit, normalizeDetail, queryEnvelope, queryToolResult, rowPageFrom, rowPageNext, scoped, } from "./tool-runtime.js";
|
|
4
|
+
/** The progress message of a stream event, in short plain words. */
|
|
5
|
+
export function progressMessage(event) {
|
|
6
|
+
const data = (event.data ?? {});
|
|
7
|
+
switch (event.event) {
|
|
8
|
+
case "grounding":
|
|
9
|
+
return "Read the graph description";
|
|
10
|
+
case "route": {
|
|
11
|
+
const confidence = typeof data.confidence === "number"
|
|
12
|
+
? `, ${data.confidence.toFixed(2)}`
|
|
13
|
+
: "";
|
|
14
|
+
return `Route: ${String(data.kind)} (${String(data.by)}${confidence})`;
|
|
15
|
+
}
|
|
16
|
+
case "step": {
|
|
17
|
+
// A compare step counts the entries that differ, not rows.
|
|
18
|
+
const unit = data.tool === "compare" ? "difference" : "row";
|
|
19
|
+
const rows = typeof data.rows === "number"
|
|
20
|
+
? `: ${data.rows} ${unit}${data.rows === 1 ? "" : "s"}`
|
|
21
|
+
: "";
|
|
22
|
+
const outcome = data.ok === false ? ": failed" : rows;
|
|
23
|
+
return `Step ${String(data.n)}, ${String(data.tool)} ${String(data.input ?? "")}${outcome}`;
|
|
24
|
+
}
|
|
25
|
+
case "answer":
|
|
26
|
+
return "Answered";
|
|
27
|
+
default:
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The question as a stream of progress events. Each event becomes one
|
|
33
|
+
* progress notification; the `done` event is the same response as the call
|
|
34
|
+
* without a stream. A client release without `query.askStream` answers
|
|
35
|
+
* without progress.
|
|
36
|
+
*/
|
|
37
|
+
async function streamedAnswer(target, body, progress) {
|
|
38
|
+
const query = target.query;
|
|
39
|
+
if (typeof query.askStream !== "function")
|
|
40
|
+
return undefined;
|
|
41
|
+
const { question, ...options } = body;
|
|
42
|
+
let step = 0;
|
|
43
|
+
for await (const event of query.askStream.call(target.query, question, {
|
|
44
|
+
...options,
|
|
45
|
+
signal: progress.signal,
|
|
46
|
+
})) {
|
|
47
|
+
if (event.event === "done")
|
|
48
|
+
return event.data;
|
|
49
|
+
const message = progressMessage(event);
|
|
50
|
+
if (message === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
step += 1;
|
|
53
|
+
// A lost notification never fails the question.
|
|
54
|
+
await progress.report(step, message).catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
throw new Error("the answer stream ended before its done event");
|
|
57
|
+
}
|
|
58
|
+
/** The totals of a comparison: the server's, else the lengths of its lists. */
|
|
59
|
+
function historyTotals(history) {
|
|
60
|
+
return (history.totals ?? {
|
|
61
|
+
added: history.added?.length ?? 0,
|
|
62
|
+
removed: history.removed?.length ?? 0,
|
|
63
|
+
changed: history.changed?.length ?? 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The history the tool returns: where the question read the graph, and for
|
|
68
|
+
* a comparison the rows that differ as lexical values, with the total of
|
|
69
|
+
* each list. Each list shows at most `limit` entries; `shown` says how many
|
|
70
|
+
* when a list is cut.
|
|
71
|
+
*/
|
|
72
|
+
function historyView(history, limit) {
|
|
73
|
+
const view = { ...history };
|
|
74
|
+
if (!history.added && !history.removed && !history.changed)
|
|
75
|
+
return view;
|
|
76
|
+
const totals = historyTotals(history);
|
|
77
|
+
view.totals = totals;
|
|
78
|
+
const shown = {};
|
|
79
|
+
const show = (name, list, map) => {
|
|
80
|
+
if (!list)
|
|
81
|
+
return;
|
|
82
|
+
const kept = list.slice(0, limit);
|
|
83
|
+
view[name] = kept.map(map);
|
|
84
|
+
if (kept.length < (totals[name] ?? list.length))
|
|
85
|
+
shown[name] = kept.length;
|
|
86
|
+
};
|
|
87
|
+
show("added", history.added, lexicalRow);
|
|
88
|
+
show("removed", history.removed, lexicalRow);
|
|
89
|
+
show("changed", history.changed, (change) => ({
|
|
90
|
+
key: lexicalRow(change.key),
|
|
91
|
+
before: change.before.map(lexicalRow),
|
|
92
|
+
after: change.after.map(lexicalRow),
|
|
93
|
+
}));
|
|
94
|
+
if (Object.keys(shown).length)
|
|
95
|
+
view.shown = shown;
|
|
96
|
+
return view;
|
|
97
|
+
}
|
|
98
|
+
/** What the agent should know about the history of the answer. */
|
|
99
|
+
function historyNotes(history, limit, query) {
|
|
100
|
+
if (!history)
|
|
101
|
+
return [];
|
|
102
|
+
const notes = [];
|
|
103
|
+
const point = typeof history.as_of_commit_seq === "number"
|
|
104
|
+
? `commit ${history.as_of_commit_seq}${history.label ? ` (${history.label})` : ""}`
|
|
105
|
+
: undefined;
|
|
106
|
+
if (history.as_of_date && point) {
|
|
107
|
+
const by = history.resolved_by === "timeline"
|
|
108
|
+
? "the timeline"
|
|
109
|
+
: history.resolved_by === "commit_time"
|
|
110
|
+
? "the commit times"
|
|
111
|
+
: "the request";
|
|
112
|
+
notes.push(`The question names ${history.as_of_date}; the server read ${point}, found by ${by}.`);
|
|
113
|
+
}
|
|
114
|
+
else if (history.as_of_date) {
|
|
115
|
+
notes.push(`The server could not place ${history.as_of_date} (see error). Pass timeline=[{date, as_of_commit_seq}] to name the commit of each date, or run the query with lbb_query mode=sparql as_of_commit_seq=<commit>.`);
|
|
116
|
+
}
|
|
117
|
+
if (history.compare && (history.added || history.changed) && point) {
|
|
118
|
+
const totals = historyTotals(history);
|
|
119
|
+
// The compared query reads the later point.
|
|
120
|
+
const later = typeof query?.as_of_commit_seq === "number"
|
|
121
|
+
? query.as_of_commit_seq
|
|
122
|
+
: undefined;
|
|
123
|
+
const keyed = history.key?.length
|
|
124
|
+
? `paired by ${history.key.map((v) => `?${v}`).join(", ")}: history.added and history.removed hold the entities at one point only, history.changed the entities whose values differ`
|
|
125
|
+
: "history.added and history.removed hold the rows that differ (a changed value is a removed row and an added row)";
|
|
126
|
+
notes.push(`A comparison from ${point} to ${later === undefined ? "the latest commit" : `commit ${later}`}; ${keyed}. Totals: added ${totals.added}, removed ${totals.removed}, changed ${totals.changed}.`);
|
|
127
|
+
const cut = ["added", "removed", "changed"].filter((name) => totals[name] > limit);
|
|
128
|
+
if (cut.length && query) {
|
|
129
|
+
const after = later === undefined ? "" : ` after={"as_of_commit_seq": ${later}}`;
|
|
130
|
+
const key = history.key?.length
|
|
131
|
+
? ` key=${JSON.stringify(history.key)}`
|
|
132
|
+
: "";
|
|
133
|
+
notes.push(`Each list shows at most ${limit} entries (${cut.map((name) => `${name}: ${totals[name]}`).join(", ")}; see history.shown). Read them all, page by page, with lbb_query mode=compare query=<the query> before={"as_of_commit_seq": ${history.as_of_commit_seq}}${after}${key}.`);
|
|
134
|
+
}
|
|
135
|
+
if (history.truncated) {
|
|
136
|
+
notes.push("The difference is not complete: a point was not read whole, or a list held more than 500 entries on the server. Use lbb_query mode=compare for the whole difference.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return notes;
|
|
140
|
+
}
|
|
3
141
|
/**
|
|
4
|
-
* `lbb_query mode=question`: the server
|
|
5
|
-
* query
|
|
6
|
-
*
|
|
142
|
+
* `lbb_query mode=question`: the server answers the question in plain words
|
|
143
|
+
* (`POST /v1/query/ask`). Its model runs queries in a bounded loop and
|
|
144
|
+
* reads their rows. The result holds the answer, its citations and chart
|
|
145
|
+
* hint, the steps, and the rows of the query the answer stands on, bounded
|
|
146
|
+
* as `mode=sparql`
|
|
147
|
+
* bounds them; `next` continues that query with `mode=sparql`. With
|
|
148
|
+
* `progress`, the server streams its steps and each step is reported; the
|
|
149
|
+
* result is the same.
|
|
7
150
|
*/
|
|
8
|
-
export async function answerQuestion(client, args, textFormat) {
|
|
151
|
+
export async function answerQuestion(client, args, textFormat, progress) {
|
|
9
152
|
const detail = normalizeDetail(args.detail);
|
|
10
|
-
const run = args.run ?? true;
|
|
11
153
|
const rowLimit = Math.min(args.limit ?? defaultRowLimit(detail), QUESTION_MAX_ROWS);
|
|
12
154
|
const target = scoped(client, args.graph);
|
|
13
155
|
const body = {
|
|
14
156
|
question: args.question,
|
|
15
157
|
...(args.context !== undefined ? { context: args.context } : {}),
|
|
16
158
|
...(args.route !== undefined ? { route: args.route } : {}),
|
|
17
|
-
|
|
18
|
-
...(
|
|
159
|
+
limit: rowLimit,
|
|
160
|
+
...(args.anchor?.length ? { anchor: args.anchor } : {}),
|
|
161
|
+
...(args.timeline?.length ? { timeline: args.timeline } : {}),
|
|
19
162
|
};
|
|
20
|
-
// Each call uses model tokens,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
163
|
+
// Each call uses model tokens, so a failed call is not retried.
|
|
164
|
+
const response = (progress ? await streamedAnswer(target, body, progress) : undefined) ??
|
|
165
|
+
(await target.request("POST", "/v1/query/ask", {
|
|
166
|
+
body,
|
|
167
|
+
retry: false,
|
|
168
|
+
query: { consistency: target.defaultConsistency },
|
|
169
|
+
}));
|
|
27
170
|
const { route, query, result } = response;
|
|
28
171
|
const traceId = result?.trace_id ?? null;
|
|
29
172
|
const answer = {
|
|
173
|
+
answer: response.answer
|
|
174
|
+
? {
|
|
175
|
+
text: response.answer.text,
|
|
176
|
+
citations: response.answer.citations ?? [],
|
|
177
|
+
...(response.answer.chart ? { chart: response.answer.chart } : {}),
|
|
178
|
+
}
|
|
179
|
+
: null,
|
|
180
|
+
steps: (response.steps ?? []).map((step) => ({
|
|
181
|
+
n: step.n,
|
|
182
|
+
tool: step.tool,
|
|
183
|
+
ok: step.ok,
|
|
184
|
+
...(typeof step.rows === "number" ? { rows: step.rows } : {}),
|
|
185
|
+
...(step.error ? { error: step.error } : {}),
|
|
186
|
+
})),
|
|
30
187
|
route: { kind: route.kind, confidence: route.confidence, by: route.by },
|
|
31
188
|
rationale: response.rationale,
|
|
32
189
|
sparql: query?.sparql ?? null,
|
|
@@ -34,20 +191,27 @@ export async function answerQuestion(client, args, textFormat) {
|
|
|
34
191
|
...(typeof query?.as_of_commit_seq === "number"
|
|
35
192
|
? { as_of_commit_seq: query.as_of_commit_seq }
|
|
36
193
|
: {}),
|
|
37
|
-
...(response.history
|
|
194
|
+
...(response.history
|
|
195
|
+
? { history: historyView(response.history, listLimit(detail)) }
|
|
196
|
+
: {}),
|
|
38
197
|
error: response.error ?? null,
|
|
39
198
|
trace_id: traceId,
|
|
40
199
|
attempts: response.attempts,
|
|
200
|
+
...(response.linked?.length ? { linked: response.linked } : {}),
|
|
201
|
+
...(response.anchors?.length ? { anchors: response.anchors } : {}),
|
|
41
202
|
};
|
|
42
203
|
const notes = [];
|
|
204
|
+
for (const anchor of response.anchors ?? []) {
|
|
205
|
+
if (anchor.note) {
|
|
206
|
+
notes.push(`anchor ${anchor.iri}: ${anchor.note}.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
43
209
|
if (traceId) {
|
|
44
210
|
notes.push(`eval trace ${traceId}: to label rows, read their item ids with lbb_evals action=trace trace_id=${traceId}, then call lbb_evals action=label trace_id=${traceId} item=<id> valid=true|false (or items=[…]).`);
|
|
45
211
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (response.error && query) {
|
|
50
|
-
notes.push("The last query failed (see error). Correct it and run it with lbb_query mode=sparql, or ask again with more context.");
|
|
212
|
+
notes.push(...historyNotes(response.history, listLimit(detail), query));
|
|
213
|
+
if (!response.answer) {
|
|
214
|
+
notes.push("The answer loop stopped before it answered (see error); the rows are the best ones it read. Continue with lbb_query mode=sparql, or ask again with more context.");
|
|
51
215
|
}
|
|
52
216
|
const label = `lbb_query.question: route ${route.kind} (${route.by}, confidence ${route.confidence.toFixed(2)})`;
|
|
53
217
|
const data = result
|
|
@@ -71,13 +235,15 @@ export async function answerQuestion(client, args, textFormat) {
|
|
|
71
235
|
};
|
|
72
236
|
return queryToolResult(queryEnvelope(label, { ...answer, ...data }, detail, rowPage, rowPageNext(cursorBase, rowPage), cursorBase, { textFormat, notes }), textFormat);
|
|
73
237
|
}
|
|
74
|
-
// No rows to page:
|
|
238
|
+
// No rows to page: an ASK answer, a comparison, or no query at all. The
|
|
75
239
|
// query text is returned whole.
|
|
76
240
|
const summary = data
|
|
77
241
|
? `${label}: ran the query`
|
|
78
|
-
:
|
|
79
|
-
? `${label}:
|
|
80
|
-
:
|
|
242
|
+
: response.history?.compare
|
|
243
|
+
? `${label}: compared two points`
|
|
244
|
+
: query
|
|
245
|
+
? `${label}: no rows`
|
|
246
|
+
: `${label}: no query`;
|
|
81
247
|
return queryToolResult({
|
|
82
248
|
summary,
|
|
83
249
|
data: { ...answer, ...(data ?? {}) },
|