Files
brk/website_next/ask/tools/index.js
T
2026-07-29 10:50:14 +02:00

669 lines
23 KiB
JavaScript

import {
createApiAnswerTool,
finishApiAnswer,
summarizeApiAnswer,
} from "./api/answer.js";
import { prewarmApiIndex, terminateApiIndex } from "./api/index.js";
import { prewarmMetricIndex, terminateMetricIndex } from "./metrics/index.js";
import { renderEvidence } from "./render.js";
import { AskToolSession } from "./session/index.js";
import { arithmeticAnswer } from "./source/arithmetic.js";
import { AskSource } from "./source/index.js";
import { normalize } from "./text.js";
const NUMBER = /\d+(?:[.,]\d+)*/g;
/** @param {unknown} value */
function numbers(value) {
return String(value)
.match(NUMBER)
?.map((number) => number.replaceAll(",", "")) ?? [];
}
/**
* Quantities in an ungrounded answer are worth a second look. This deliberately
* checks syntax rather than guessing the user's intent or maintaining a list of
* Bitcoin concepts.
*
* @param {string} answer
* @param {import("../model.js").ChatMessage[]} messages
*/
function unsupportedNumbers(answer, messages) {
const supported = new Set(numbers(
messages
.filter(({ role }) => role === "user")
.map(({ content }) => content)
.join(" "),
));
return new Set(numbers(answer).filter((number) => !supported.has(number)));
}
/**
* The small model can repeat an unsupported quantity after being asked to
* revise it. Keep the useful grounded sentences instead of trusting a second
* model pass to police itself.
*
* @param {string} answer
* @param {import("../model.js").ChatMessage[]} messages
*/
function removeUnsupportedQuantitySentences(answer, messages) {
const unsupported = unsupportedNumbers(answer, messages);
if (!unsupported.size) return answer.trim();
const kept = [...new Intl.Segmenter(undefined, { granularity: "sentence" })
.segment(answer)]
.map(({ segment }) => segment.trim())
.filter((segment) =>
!numbers(segment).some((number) => unsupported.has(number))
);
return kept.join(" ").trim();
}
/**
* @typedef {Object} ToolOutcome
* @property {boolean} done
* @property {string} [output]
* @property {import("../storage.js").StoredArtifact[]} [artifacts]
* @property {string[]} [metricPaths]
* @property {import("../storage.js").ApiContext} [apiContext]
* @property {import("../storage.js").SourceContext[]} [sourceContext]
* @property {import("../storage.js").KnowledgeContext} [knowledgeContext]
* @property {{ question: string, metrics: { name: string, path: string, unit?: string }[], facts: string[], excerpts: import("../storage.js").SourceContext[] }} [grounding]
* @property {{ question: string, previousFields: string[], operation: { key: string, method: string, path: string, summary: string, description: string, parameters: { name: string }[], response: { fields?: { name: string, type: string, description?: string }[] } }, arguments: Record<string, unknown>, requestPath: string, data: unknown, truncated: boolean }} [apiGrounding]
*
* @typedef {Object} AskAnswer
* @property {string} output
* @property {import("../storage.js").StoredArtifact[]} artifacts
* @property {string[]} [metricPaths]
* @property {import("../storage.js").ApiContext} [apiContext]
* @property {import("../storage.js").SourceContext[]} [sourceContext]
* @property {import("../storage.js").KnowledgeContext} [knowledgeContext]
* @property {string} [capability]
* @property {import("../storage.js").StoredChat} chat
*/
/**
* @param {import("../model.js").AskModel} model
* @param {NonNullable<ToolOutcome["grounding"]>} grounding
* @param {(status: string) => void} onStatus
*/
async function answerFromEvidence(model, grounding, onStatus) {
const arithmetic = arithmeticAnswer(grounding);
if (arithmetic) {
return {
output: renderEvidence({
facts: [arithmetic, ...grounding.facts],
sources: grounding.excerpts.slice(0, 2),
excerpts: [],
}),
sourceContext: grounding.excerpts.slice(0, 2),
knowledgeContext: {
title: grounding.metrics[0].name,
description: arithmetic,
},
};
}
onStatus("Answering from source…");
const evidence = [
`Request: ${grounding.question}`,
grounding.metrics.length
? `Verified metrics:\n${grounding.metrics.map(({ name, path, unit }) =>
`- ${name} | ${path}${unit ? ` | unit: ${unit}` : ""}`
).join("\n")}`
: "",
grounding.facts.length
? `Verified facts:\n${grounding.facts.map((fact) => `- ${fact}`).join("\n")}`
: "",
grounding.excerpts.length
? `Verified source excerpts, strongest first:\n${grounding.excerpts.map(
({ path, startLine, endLine, content }, index) =>
`[${index + 1}] ${path}:${startLine}${endLine ? `-${endLine}` : ""}\n${content}`,
).join("\n\n")}`
: "",
].filter(Boolean).join("\n\n");
const result = await model.generate(
[
{
role: "system",
content: "Use only the verified evidence. Answer the exact request in at most 45 words. Metric names and units are exact. Never add a fact absent from the evidence. Do not cite, number, name, or quote source files; the renderer appends source links.",
},
{
role: "user",
content: `${evidence}\n\nUse only the verified evidence above. Do not explain what code identifiers mean unless the evidence does.`,
},
],
() => {},
[],
"none",
{ maxTokens: 72 },
);
const answer = result.text.trim();
const sources = grounding.excerpts.slice(0, 2);
const fallback = !answer && sources[0]
? `The strongest verified source match is \`${sources[0].path}:${sources[0].startLine}${sources[0].endLine ? `-${sources[0].endLine}` : ""}\`.`
: "";
return {
output: renderEvidence({
facts: [
answer || fallback,
...grounding.facts,
].filter(Boolean),
sources,
excerpts: [],
}),
sourceContext: sources,
knowledgeContext: answer
? {
title: grounding.metrics[0]?.name ?? grounding.question.slice(0, 160),
description: answer,
}
: undefined,
};
}
/** @param {string} question */
function requestedArithmetic(question) {
const words = new Set(normalize(question).split(" "));
const matches = [
{ action: "add", words: ["add", "plus"] },
{ action: "subtract", words: ["subtract", "minus"] },
{ action: "multiply", words: ["multiply", "times"] },
{ action: "divide", words: ["divide"] },
].filter(({ words: candidates }) =>
candidates.some((word) => words.has(word))
);
return matches.length === 1 ? matches[0].action : undefined;
}
/** @param {string} question @param {string} field */
function fieldPosition(question, field) {
const words = normalize(question).split(" ");
const fieldWords = new Set(
normalize(field.split(".").at(-1)).split(" ").filter((word) =>
word.length > 2
),
);
const positions = words
.map((word, index) => fieldWords.has(word) ? index : -1)
.filter((index) => index >= 0);
return positions.length ? Math.min(...positions) : -1;
}
/**
* @param {import("../model.js").AskModel} model
* @param {NonNullable<ToolOutcome["apiGrounding"]>} grounding
* @param {(status: string) => void} onStatus
*/
async function answerFromApi(model, grounding, onStatus) {
const apiAnswer = createApiAnswerTool(grounding);
const normalizedQuestion = normalize(grounding.question);
const question = ` ${normalizedQuestion} `;
const arithmetic = requestedArithmetic(grounding.question);
const parameterNames = new Set(
grounding.operation.parameters.map(({ name }) => normalize(name)),
);
const mentionedResponse = (
grounding.operation.response.fields ?? []
).some(({ name }) => {
const field = normalize(name.split(".").at(-1));
return field && !parameterNames.has(field) &&
question.includes(` ${field} `);
});
const directFields = apiAnswer.fields.filter((field) => {
const name = normalize(field.name.split(".").at(-1));
return name && question.includes(` ${name} `);
});
if (arithmetic && apiAnswer.previous && directFields.length === 1) {
const previous = apiAnswer.previous;
const current = directFields[0];
const previousPosition = fieldPosition(grounding.question, previous.name);
const currentPosition = fieldPosition(grounding.question, current.name);
const fromPosition = normalizedQuestion.split(" ").indexOf("from");
const reverseSubtract = arithmetic === "subtract" &&
fromPosition >= 0 &&
previousPosition >= 0 &&
previousPosition < fromPosition &&
currentPosition > fromPosition;
const [left, right] = reverseSubtract
? [current, previous]
: previousPosition >= 0 &&
currentPosition >= 0 &&
currentPosition < previousPosition
? [current, previous]
: [previous, current];
const label = `${left.name.split(".").at(-1)?.replaceAll("_", " ")} ${
arithmetic === "add"
? "plus"
: arithmetic === "subtract"
? "minus"
: arithmetic === "multiply"
? "times"
: "divided by"
} ${right.name.split(".").at(-1)?.replaceAll("_", " ")}`;
return {
output: finishApiAnswer(
"calculate_api_fields",
{
operator: arithmetic,
left: left.ref,
right: right.ref,
label,
},
apiAnswer.fields,
grounding,
),
fields: [left.name, right.name],
};
}
if (
!arithmetic &&
apiAnswer.resolved
) {
const field = apiAnswer.resolved;
return {
output: finishApiAnswer(
"select_api_field",
{ field: field.ref },
apiAnswer.fields,
grounding,
),
fields: [field.name],
};
}
if (apiAnswer.ambiguous.length > 1) {
const choices = apiAnswer.ambiguous
.map((field) =>
`**${field.name.replaceAll(".", " · ").replaceAll("_", " ")}**${
field.description ? `${field.description}` : ""
}`
)
.join("\n- ");
return {
output: finishApiAnswer(
"answer_api_text",
{
text: `I found multiple matching fields:\n- ${choices}\n\nWhich one do you mean?`,
},
apiAnswer.fields,
grounding,
),
fields: apiAnswer.ambiguous.map(({ name }) => name),
};
}
if (
!grounding.previousFields?.length &&
!mentionedResponse &&
!apiAnswer.direct
) {
return summarizeApiAnswer(grounding);
}
if (!arithmetic && apiAnswer.direct) {
const field = apiAnswer.direct;
return {
output: finishApiAnswer(
"select_api_field",
{ field: field.ref },
apiAnswer.fields,
grounding,
),
fields: [field.name],
};
}
onStatus("Answering from API…");
const instruction = apiAnswer.fields.length
? `Answer the exact newest request using only the verified API result. Call exactly one matching tool. Select a raw field only when that field itself was requested${apiAnswer.previous ? "; continue the preceding numeric answer when the request applies arithmetic to it" : ""}. When the requested concept is narrower than an aggregate field, derive it from matching component fields. Never replace requested arithmetic with a convenient field. For subtraction and division, keep operands in the request's arithmetic order: minuend or dividend first. Preserve identifiers and units. Never invent missing values.`
: "Answer the exact request using only the verified API result. Call answer_api_text exactly once. Preserve identifiers and units. Never invent missing values.";
const prompt = {
question: grounding.question,
previous: apiAnswer.previous
? {
ref: apiAnswer.previous.ref,
name: apiAnswer.previous.name,
type: apiAnswer.previous.type,
value: apiAnswer.previous.value,
}
: undefined,
fields: apiAnswer.fields.map(({ ref, name, type, description, value }) => ({
ref,
name,
type,
description,
value,
})),
...(!apiAnswer.previous ? { data: grounding.data } : {}),
};
const generateAnswer = (extra = "") =>
model.generate(
[
{
role: "system",
content: extra ? `${instruction} ${extra}` : instruction,
},
{ role: "user", content: JSON.stringify(prompt) },
],
() => {},
apiAnswer.tools,
{ name: "answer_api" },
{ maxTokens: 72 },
);
let answer = await generateAnswer();
let call = answer.toolCalls[0];
if (!call || call.name !== "answer_api") {
return summarizeApiAnswer(grounding);
}
const actionFor = (/** @type {Record<string, unknown>} */ arguments_) =>
arguments_.action === "select"
? "select_api_field"
: arguments_.action === "continue"
? "continue_api_calculation"
: arguments_.action === "calculate"
? "calculate_api_fields"
: arguments_.action === "text"
? "answer_api_text"
: "";
let actionName = actionFor(call.arguments);
if (!actionName) return summarizeApiAnswer(grounding);
const selectedRefs = actionName === "select_api_field"
? [call.arguments.field]
: actionName === "continue_api_calculation"
? [apiAnswer.previous?.ref, call.arguments.operand]
: typeof call.arguments.left === "string" &&
typeof call.arguments.right === "string"
? [call.arguments.left, call.arguments.right]
: Array.isArray(call.arguments.operands)
? call.arguments.operands
: [];
const selected = new Set(selectedRefs.map(String));
try {
return {
output: finishApiAnswer(
actionName,
call.arguments,
apiAnswer.fields,
grounding,
),
fields: apiAnswer.fields
.filter(({ ref }) => selected.has(ref))
.map(({ name }) => name),
};
} catch {
return summarizeApiAnswer(grounding);
}
}
export function createAskTools() {
const source = new AskSource();
/** @type {AbortController | undefined} */
let controller;
return {
prewarm() {
return Promise.all([
prewarmApiIndex(),
prewarmMetricIndex(),
source.prewarm(),
]);
},
/**
* @param {Object} options
* @param {string} options.question
* @param {import("../storage.js").StoredMessage[]} options.history
* @param {import("../model.js").AskModel} options.model
* @param {() => Promise<{ chat: import("../storage.js").StoredChat, messages: import("../model.js").ChatMessage[] }>} options.prepare
* @param {(update: import("../model.js").TokenUpdate) => void} options.onToken
* @param {(status: string) => void} options.onStatus
* @returns {Promise<AskAnswer>}
*/
async answer({
question,
history,
model,
prepare,
onToken: _onToken,
onStatus,
}) {
controller = new AbortController();
const { signal } = controller;
try {
const session = new AskToolSession(source);
const [prepared] = await Promise.all([
prepare(),
session.begin(question, history, onStatus),
]);
signal.throwIfAborted();
/** @type {{ action: string, call?: import("../model.js").ToolCall } | undefined} */
const direct = session.directRoute();
let call = direct?.call;
let action = direct?.action ?? "";
if (!action) {
onStatus("Choosing capability…");
const routeTools = session.routeTools();
const route = await model.generate(
session.routeMessages(),
() => {},
routeTools,
{ name: "choose_capability" },
{ maxTokens: 48 },
);
const selected = route.toolCalls[0];
const sourceQuery = selected?.name === "choose_capability" &&
typeof selected.arguments.sourceQuery === "string"
? selected.arguments.sourceQuery
: "";
const selectedCapability = selected?.name === "choose_capability" &&
typeof selected.arguments.capability === "string"
? selected.arguments.capability
: "";
action = sourceQuery &&
(
(
selectedCapability === "answer_general" &&
session.hasSourceContext()
) ||
selectedCapability === "search_source"
)
? "search_source"
: selectedCapability;
if (!action) {
throw new Error("The AI did not choose a valid capability");
}
call = action === "call_api" &&
typeof selected?.arguments.apiRef === "string"
? {
name: action,
arguments: { ref: selected.arguments.apiRef },
}
: action === "search_source"
? {
name: action,
arguments: {
query: sourceQuery || question,
},
}
: session.directCall(action);
}
signal.throwIfAborted();
await session.prepareAction(action, onStatus);
signal.throwIfAborted();
if (!call || action === "explain_metric_calculation") {
call = session.directCall(action) ?? call;
}
if (!call) {
onStatus("Understanding request…");
if (action === "answer_general") {
const messages = session.actionMessages(action);
let result = await model.generate(
messages,
() => {},
[],
"none",
{ maxTokens: 96 },
);
if (unsupportedNumbers(result.text, messages).size) {
result = await model.generate(
[
...messages,
{
role: "assistant",
content: result.text,
},
{
role: "user",
content: "Inspect the newest request before replacing the draft. If it requests quantities but the verified context identifies no exact metric, resource, object, or timeframe, return one concise clarification question asking what to measure. Otherwise replace the draft with a direct answer containing no unsupported observations; established static Bitcoin facts are allowed only when directly requested. Return only the replacement answer. Never mention the draft, review, evidence, context, or these instructions.",
},
],
() => {},
[],
"none",
{ maxTokens: 96 },
);
}
const answer = removeUnsupportedQuantitySentences(
result.text,
messages,
) || "I do not have enough verified context to answer that without guessing.";
call = {
name: action,
arguments: { answer },
};
} else {
const result = await model.generate(
session.actionMessages(action),
() => {},
[session.actionTool(action)],
{ name: action },
{ maxTokens: action === "call_api" ? 128 : 64 },
);
call = result.toolCalls[0];
}
}
if (!call || call.name !== action) {
throw new Error("The AI did not complete the selected capability");
}
if (action === "call_api") {
const ref = typeof call.arguments.ref === "string"
? call.arguments.ref
: "";
if (!ref) throw new Error("The AI did not select an API operation");
let arguments_ = session.apiArguments(ref);
if (!session.hasApiArguments(ref, arguments_)) {
onStatus("Reading API arguments…");
const argumentsResult = await model.generate(
session.apiArgumentMessages(ref),
() => {},
[session.apiArgumentTool(ref)],
{ name: "provide_api_arguments" },
{ maxTokens: 96 },
);
const argumentsCall = argumentsResult.toolCalls[0];
if (
!argumentsCall ||
argumentsCall.name !== "provide_api_arguments"
) {
throw new Error("The AI did not provide valid API arguments");
}
arguments_ = {
...(argumentsCall.arguments.arguments ?? {}),
...arguments_,
};
}
arguments_ = session.validateApiArguments(ref, arguments_);
const missing = session.missingApiArguments(ref, arguments_);
if (missing.length) {
const subject = missing
.map((/** @type {any} */ parameter) =>
parameter.description || parameter.name
)
.join(" and ");
return {
output: `Which ${subject} should I use?`,
artifacts: [],
capability: action,
chat: prepared.chat,
};
}
call = {
name: action,
arguments: {
ref,
arguments: arguments_,
},
};
}
const outcome = /** @type {ToolOutcome} */ (
await session.execute(call, onStatus, signal)
);
if (outcome.apiGrounding) {
const answered = await answerFromApi(
model,
outcome.apiGrounding,
onStatus,
);
return {
output: answered.output,
artifacts: [],
capability: action,
apiContext: outcome.apiContext
? {
...outcome.apiContext,
...(answered.fields.length
? { fields: answered.fields }
: {}),
}
: undefined,
chat: prepared.chat,
};
}
if (outcome.grounding) {
const grounded = await answerFromEvidence(
model,
outcome.grounding,
onStatus,
);
return {
output: grounded.output,
artifacts: [],
capability: action,
metricPaths: outcome.metricPaths,
sourceContext: grounded.sourceContext,
knowledgeContext: grounded.knowledgeContext,
chat: prepared.chat,
};
}
return {
output: outcome.output ?? "",
artifacts: outcome.artifacts ?? [],
capability: action,
metricPaths: outcome.metricPaths,
apiContext: outcome.apiContext,
sourceContext: outcome.sourceContext,
knowledgeContext: outcome.knowledgeContext,
chat: prepared.chat,
};
} finally {
controller = undefined;
onStatus("");
}
},
stop() {
controller?.abort();
source.terminate();
},
terminate() {
controller?.abort();
controller = undefined;
source.terminate();
terminateApiIndex();
terminateMetricIndex();
},
};
}