@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 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 selects the kind of query, writes the SPARQL query from a
118
- description of the graph, checks it, and runs it. The result holds the route,
119
- the rationale, the query, and its rows. `next` continues the same query with
120
- `mode: "sparql"`. Each question uses model tokens and counts toward a daily
121
- limit of the stack, so the tool does not retry a failed call.
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), or publish validation rules. |
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
+ }
@@ -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
- run?: boolean;
14
+ anchor?: string[];
15
+ timeline?: TimelinePoint[];
9
16
  detail?: string;
10
17
  graph?: string;
11
18
  }
12
19
  /**
13
- * `lbb_query mode=question`: the server turns the question into a SPARQL
14
- * query and, by default, runs it. The rows are bounded as `mode=sparql`
15
- * bounds them, and `next` continues the same query with `mode=sparql`.
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 turns the question into a SPARQL
5
- * query and, by default, runs it. The rows are bounded as `mode=sparql`
6
- * bounds them, and `next` continues the same query with `mode=sparql`.
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
- run,
18
- ...(run ? { limit: rowLimit } : {}),
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, and the server corrects a failing query
21
- // once, so a failed call is not retried.
22
- const response = await target.request("POST", "/v1/query/rewrite", {
23
- body,
24
- retry: false,
25
- query: { consistency: target.defaultConsistency },
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 ? { history: 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
- if (response.history?.as_of_date) {
47
- notes.push(`The question names the date ${response.history.as_of_date}. Find the commit of that date, then run the query with lbb_query mode=sparql as_of_commit_seq=<commit>.`);
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: the query only, an ASK answer, or no query at all. The
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
- : query
79
- ? `${label}: ${response.error ? "the query failed" : "the query, not run"}`
80
- : `${label}: no query`;
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 ?? {}) },