mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-08-05 07:03:06 -07:00
474 lines
16 KiB
JavaScript
474 lines
16 KiB
JavaScript
import { renderApiAnswer } from "../render.js";
|
|
import { relevance } from "../text.js";
|
|
import { focusApiData } from "./result.js";
|
|
|
|
const MAX_FIELDS = 10;
|
|
|
|
/** @param {string} type */
|
|
function dimension(type) {
|
|
const value = type.toLowerCase();
|
|
if (value.includes("sats")) return "sats";
|
|
return value;
|
|
}
|
|
|
|
/** @param {string} type */
|
|
function displayedUnit(type) {
|
|
const value = dimension(type);
|
|
if (value === "sats") return " sats";
|
|
if (value === "number" || value === "integer" || value === "float") return "";
|
|
return ` ${type}`;
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} ApiAnswerField
|
|
* @property {string} ref
|
|
* @property {string} name
|
|
* @property {string} type
|
|
* @property {string} [description]
|
|
* @property {string} [ownDescription]
|
|
* @property {string | number | boolean} value
|
|
* @property {number} score
|
|
*
|
|
* @typedef {Object} ApiAnswerSpec
|
|
* @property {ApiAnswerField[]} fields
|
|
* @property {ApiAnswerField} [previous]
|
|
* @property {ApiAnswerField} [resolved]
|
|
* @property {ApiAnswerField} [direct]
|
|
* @property {ApiAnswerField[]} ambiguous
|
|
* @property {any[]} tools
|
|
*/
|
|
|
|
/** @param {unknown} value @param {string[]} path */
|
|
function valueAt(value, path) {
|
|
let current = value;
|
|
for (const key of path) {
|
|
if (!current || typeof current !== "object" || !Object.hasOwn(current, key)) {
|
|
return undefined;
|
|
}
|
|
current = /** @type {Record<string, unknown>} */ (current)[key];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/** @param {unknown} value */
|
|
function formattedValue(value) {
|
|
if (typeof value === "number") {
|
|
return new Intl.NumberFormat("en-US", {
|
|
maximumFractionDigits: 8,
|
|
}).format(value);
|
|
}
|
|
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
return String(value);
|
|
}
|
|
|
|
/** @param {any} grounding */
|
|
export function summarizeApiAnswer(grounding) {
|
|
const data = focusApiData(grounding.data, grounding.arguments);
|
|
const responseFields = /** @type {{ name: string, type: string, description?: string, ownDescription?: string }[]} */ (
|
|
grounding.operation.response.fields ?? []
|
|
);
|
|
const fields = responseFields
|
|
.map((field) => ({
|
|
...field,
|
|
value: valueAt(data, field.name.split(".")),
|
|
}))
|
|
.filter((/** @type {any} */ { value }) =>
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean"
|
|
)
|
|
.slice(0, 8);
|
|
if (!fields.length) {
|
|
return {
|
|
output: renderApiAnswer(
|
|
"The API returned no compact primitive fields to display.",
|
|
grounding.operation,
|
|
),
|
|
fields: [],
|
|
};
|
|
}
|
|
const output = fields
|
|
.map((/** @type {any} */ field) =>
|
|
`- **${field.name.replaceAll(".", " · ").replaceAll("_", " ")}**: ${
|
|
formattedValue(field.value)
|
|
}${typeof field.value === "number" ? displayedUnit(field.type) : ""}`
|
|
)
|
|
.join("\n");
|
|
return {
|
|
output: renderApiAnswer(output, grounding.operation),
|
|
fields: fields.map((/** @type {any} */ { name }) => name),
|
|
};
|
|
}
|
|
|
|
/** @param {any} grounding @returns {ApiAnswerSpec} */
|
|
export function createApiAnswerTool(grounding) {
|
|
const data = focusApiData(grounding.data, grounding.arguments);
|
|
const responseFields = /** @type {{ name: string, type: string, description?: string, ownDescription?: string }[]} */ (
|
|
grounding.operation.response.fields ?? []
|
|
);
|
|
const previousName = grounding.previousFields?.length === 1
|
|
? grounding.previousFields[0]
|
|
: undefined;
|
|
const previousParents = new Set(
|
|
(grounding.previousFields ?? []).map((/** @type {string} */ name) =>
|
|
name.split(".").slice(0, -1).join(".")
|
|
),
|
|
);
|
|
const previousParent = previousParents.size === 1
|
|
? [...previousParents][0]
|
|
: undefined;
|
|
const parameterNames = new Set(
|
|
grounding.operation.parameters.map(
|
|
(/** @type {{ name: string }} */ parameter) => parameter.name,
|
|
),
|
|
);
|
|
const primitive = responseFields
|
|
.map((field) => ({
|
|
...field,
|
|
value: valueAt(data, field.name.split(".")),
|
|
}))
|
|
.filter((field) =>
|
|
typeof field.value === "string" ||
|
|
typeof field.value === "number" ||
|
|
typeof field.value === "boolean"
|
|
)
|
|
.map((field, index) => ({
|
|
...field,
|
|
index,
|
|
score: relevance(
|
|
grounding.question,
|
|
`${field.name} ${field.ownDescription || field.description || ""}`,
|
|
) +
|
|
relevance(grounding.question, field.name) +
|
|
relevance(
|
|
grounding.question,
|
|
field.ownDescription || field.description || "",
|
|
) -
|
|
Math.max(0, field.name.split(".").length - 1) * 2,
|
|
}))
|
|
.sort((left, right) => {
|
|
const leftParent = left.name.split(".").slice(0, -1).join(".");
|
|
const rightParent = right.name.split(".").slice(0, -1).join(".");
|
|
const leftAffinity = previousParent && leftParent === previousParent ? 2 : 0;
|
|
const rightAffinity = previousParent && rightParent === previousParent ? 2 : 0;
|
|
return right.score + rightAffinity - left.score - leftAffinity ||
|
|
left.index - right.index;
|
|
});
|
|
const answerCandidates = primitive.filter(({ name }) =>
|
|
name !== previousName &&
|
|
!parameterNames.has(name.split(".").at(-1) ?? name)
|
|
);
|
|
const best = answerCandidates
|
|
.sort((left, right) => right.score - left.score || left.index - right.index)[0];
|
|
const runnerUp = answerCandidates
|
|
.filter(({ name }) => name !== best?.name)
|
|
.sort((left, right) => right.score - left.score || left.index - right.index)[0];
|
|
const direct = best && best.score >= 6 &&
|
|
best.score >= (runnerUp?.score ?? 0) + 0.5
|
|
? best
|
|
: undefined;
|
|
const siblings = best
|
|
? primitive.filter((field) =>
|
|
field.name.split(".").at(-1) === best.name.split(".").at(-1) &&
|
|
best.score - field.score < 1 &&
|
|
field.score > 0
|
|
)
|
|
: [];
|
|
const matchingParent = previousParent
|
|
? siblings.filter((field) =>
|
|
field.name.split(".").slice(0, -1).join(".") === previousParent
|
|
)
|
|
: [];
|
|
const ambiguousNames = new Set(
|
|
(matchingParent.length === 1 ? [] : siblings).map(({ name }) => name),
|
|
);
|
|
const current = primitive.filter(({ name }) => name !== previousName);
|
|
const previousField = previousName
|
|
? primitive.find(({ name }) => name === previousName)
|
|
: undefined;
|
|
const selected = [
|
|
...current.slice(0, MAX_FIELDS - (previousField ? 1 : 0)),
|
|
...(previousField ? [previousField] : []),
|
|
];
|
|
const fields = selected
|
|
.map((field, index) => ({
|
|
...field,
|
|
value: /** @type {string | number | boolean} */ (field.value),
|
|
ref: `n${index + 1}`,
|
|
}));
|
|
const previousChoices = fields
|
|
.filter(({ name }) => grounding.previousFields?.includes(name))
|
|
.sort((left, right) => right.score - left.score);
|
|
const resolved = previousChoices.length > 1 &&
|
|
previousChoices[0].score >= previousChoices[1].score + 5
|
|
? previousChoices[0]
|
|
: undefined;
|
|
const previous = previousName
|
|
? fields.find((field) =>
|
|
field.name === previousName && typeof field.value === "number"
|
|
)
|
|
: undefined;
|
|
const numericFields = fields.filter((field) => typeof field.value === "number");
|
|
const calculationSplit = numericFields.findIndex((field, index) =>
|
|
index >= 2 && numericFields[index - 1].score - field.score >= 3
|
|
);
|
|
const calculationFields = calculationSplit >= 2
|
|
? numericFields.slice(0, calculationSplit)
|
|
: numericFields;
|
|
/**
|
|
* @param {string} name
|
|
* @param {string} description
|
|
* @param {Record<string, any>} properties
|
|
* @param {string[]} required
|
|
*/
|
|
const functionTool = (name, description, properties, required) => ({
|
|
type: "function",
|
|
function: {
|
|
name,
|
|
description,
|
|
parameters: {
|
|
type: "object",
|
|
properties,
|
|
required,
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
});
|
|
const label = {
|
|
type: "string",
|
|
description: "Short user-facing name for the result.",
|
|
};
|
|
const operator = {
|
|
type: "string",
|
|
enum: ["add", "subtract", "multiply", "divide"],
|
|
description: "The arithmetic operation explicitly requested by the user.",
|
|
};
|
|
const reference = {
|
|
type: "string",
|
|
enum: fields.map(({ ref }) => ref),
|
|
description: "Verified field ref from the user message.",
|
|
};
|
|
const numericReference = {
|
|
type: "string",
|
|
enum: calculationFields.map(({ ref }) => ref),
|
|
description: "Verified numeric field ref from the user message.",
|
|
};
|
|
const tools = [
|
|
functionTool(
|
|
"answer_api",
|
|
[
|
|
"Choose select for one raw primitive field.",
|
|
"Choose calculate to derive the result from component fields, including a narrower concept than an aggregate.",
|
|
previous
|
|
? `Choose continue only to apply arithmetic to preceding ${previous.ref}=${previous.name}.`
|
|
: "",
|
|
"Choose text for a nonnumeric answer copied or summarized from verified data.",
|
|
].filter(Boolean).join(" "),
|
|
{
|
|
action: {
|
|
type: "string",
|
|
enum: [
|
|
...(fields.length ? ["select"] : []),
|
|
...(calculationFields.length >= 2 ? ["calculate"] : []),
|
|
...(previous ? ["continue"] : []),
|
|
"text",
|
|
],
|
|
},
|
|
...(fields.length
|
|
? {
|
|
field: reference,
|
|
...(calculationFields.length === 2
|
|
? {
|
|
operator,
|
|
left: {
|
|
...numericReference,
|
|
description: "Left arithmetic operand: the minuend or dividend for subtract or divide.",
|
|
},
|
|
right: {
|
|
...numericReference,
|
|
description: "Right arithmetic operand: the subtrahend or divisor for subtract or divide.",
|
|
},
|
|
}
|
|
: calculationFields.length > 2
|
|
? {
|
|
operator,
|
|
operands: {
|
|
type: "array",
|
|
minItems: 2,
|
|
maxItems: 10,
|
|
items: numericReference,
|
|
description: "Ordered verified numeric fields for calculate.",
|
|
},
|
|
}
|
|
: {}),
|
|
}
|
|
: {}),
|
|
...(previous
|
|
? {
|
|
operand: {
|
|
type: "string",
|
|
enum: numericFields
|
|
.filter(({ ref }) => ref !== previous.ref)
|
|
.map(({ ref }) => ref),
|
|
description: `Second operand after fixed ${previous.ref}.`,
|
|
},
|
|
}
|
|
: {}),
|
|
label,
|
|
text: {
|
|
type: "string",
|
|
description: "Concise nonnumeric answer containing no invented values.",
|
|
},
|
|
},
|
|
["action"],
|
|
),
|
|
];
|
|
|
|
return {
|
|
fields,
|
|
previous,
|
|
resolved,
|
|
direct: direct ? fields.find(({ name }) => name === direct.name) : undefined,
|
|
ambiguous: fields.filter(({ name }) => ambiguousNames.has(name)),
|
|
tools,
|
|
};
|
|
}
|
|
|
|
/** @param {string} name @param {Record<string, unknown>} action @param {ApiAnswerField[]} fields @param {any} grounding */
|
|
export function finishApiAnswer(name, action, fields, grounding) {
|
|
const byRef = new Map(fields.map((field) => [field.ref, field]));
|
|
if (name === "select_api_field") {
|
|
const field = byRef.get(String(action.field));
|
|
if (!field) throw new Error("The AI selected an unknown API field");
|
|
const label = typeof action.label === "string" && action.label.trim()
|
|
? action.label.trim().replaceAll("_", " ")
|
|
: field.name.replaceAll(".", " · ").replaceAll("_", " ");
|
|
const formatted = formattedValue(field.value);
|
|
return renderApiAnswer(
|
|
`**${label}**: ${formatted}${
|
|
typeof field.value === "number" ? displayedUnit(field.type) : ""
|
|
}`,
|
|
grounding.operation,
|
|
);
|
|
}
|
|
if (name === "answer_api_text") {
|
|
const text = typeof action.text === "string" ? action.text.trim() : "";
|
|
if (!text) throw new Error("The AI returned an empty API answer");
|
|
return renderApiAnswer(text, grounding.operation);
|
|
}
|
|
if (name === "continue_api_calculation") {
|
|
const previousNames = Array.isArray(grounding.previousFields)
|
|
? grounding.previousFields
|
|
: [];
|
|
const previous = previousNames.length === 1
|
|
? fields.find((field) => field.name === previousNames[0])
|
|
: undefined;
|
|
const operand = byRef.get(String(action.operand));
|
|
if (
|
|
!previous ||
|
|
!operand ||
|
|
typeof previous.value !== "number" ||
|
|
typeof operand.value !== "number" ||
|
|
previous.ref === operand.ref
|
|
) {
|
|
throw new Error("The AI returned an invalid API follow-up calculation");
|
|
}
|
|
if (
|
|
typeof action.operator !== "string" ||
|
|
!["add", "subtract", "multiply", "divide"].includes(action.operator)
|
|
) {
|
|
throw new Error("The AI returned an invalid API calculation operator");
|
|
}
|
|
if (action.operator === "divide" && operand.value === 0) {
|
|
throw new Error("Cannot divide by zero");
|
|
}
|
|
const value = action.operator === "add"
|
|
? previous.value + operand.value
|
|
: action.operator === "subtract"
|
|
? previous.value - operand.value
|
|
: action.operator === "multiply"
|
|
? previous.value * operand.value
|
|
: previous.value / operand.value;
|
|
const previousDimension = dimension(previous.type);
|
|
const operandDimension = dimension(operand.type);
|
|
const unit = action.operator === "divide"
|
|
? previousDimension === operandDimension
|
|
? ""
|
|
: ` ${previousDimension}/${operandDimension}`
|
|
: previousDimension === operandDimension
|
|
? displayedUnit(previous.type)
|
|
: "";
|
|
const label = typeof action.label === "string" && action.label.trim()
|
|
? action.label.trim().replaceAll("_", " ")
|
|
: "result";
|
|
const formatted = new Intl.NumberFormat("en-US", {
|
|
maximumFractionDigits: 8,
|
|
}).format(value);
|
|
return renderApiAnswer(`**${label}**: ${formatted}${unit}`, grounding.operation);
|
|
}
|
|
if (name !== "calculate_api_fields") {
|
|
throw new Error("The AI returned an invalid API calculation");
|
|
}
|
|
if (
|
|
typeof action.operator === "string" &&
|
|
["add", "subtract", "multiply", "divide"].includes(action.operator) &&
|
|
(
|
|
(
|
|
typeof action.left === "string" &&
|
|
typeof action.right === "string"
|
|
) ||
|
|
(
|
|
Array.isArray(action.operands) &&
|
|
action.operands.length >= 2
|
|
)
|
|
)
|
|
) {
|
|
const refs = typeof action.left === "string" &&
|
|
typeof action.right === "string"
|
|
? [action.left, action.right]
|
|
: /** @type {unknown[]} */ (action.operands);
|
|
const selected = refs.map((ref) => byRef.get(String(ref)));
|
|
if (selected.some((field) => !field)) throw new Error("Unknown calculation field");
|
|
if (selected.some((field) => typeof field?.value !== "number")) {
|
|
throw new Error("A calculation requires numeric fields");
|
|
}
|
|
const numeric = /** @type {(ApiAnswerField & { value: number })[]} */ (selected);
|
|
const [first, ...rest] = numeric;
|
|
const types = new Set(numeric.map(({ type }) => dimension(type)));
|
|
if (
|
|
(action.operator === "add" || action.operator === "subtract") &&
|
|
types.size > 1
|
|
) {
|
|
const choices = numeric
|
|
.map((field) =>
|
|
`**${field.name.replaceAll(".", " · ").replaceAll("_", " ")}** (${field.type})`
|
|
)
|
|
.join(", ");
|
|
return renderApiAnswer(
|
|
`Those fields use different units: ${choices}. Which one do you want?`,
|
|
grounding.operation,
|
|
);
|
|
}
|
|
const value = rest.reduce((result, field) => {
|
|
if (action.operator === "add") return result + field.value;
|
|
if (action.operator === "subtract") return result - field.value;
|
|
if (action.operator === "multiply") return result * field.value;
|
|
if (field.value === 0) throw new Error("Cannot divide by zero");
|
|
return result / field.value;
|
|
}, first.value);
|
|
const unit = action.operator === "divide" && numeric.length === 2
|
|
? dimension(first.type) === dimension(numeric[1].type)
|
|
? ""
|
|
: ` ${dimension(first.type)}/${dimension(numeric[1].type)}`
|
|
: types.size === 1
|
|
? displayedUnit(first.type)
|
|
: "";
|
|
const label = typeof action.label === "string" && action.label.trim()
|
|
? action.label.trim().replaceAll("_", " ")
|
|
: "result";
|
|
const formatted = new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value);
|
|
return renderApiAnswer(`**${label}**: ${formatted}${unit}`, grounding.operation);
|
|
}
|
|
throw new Error("The AI returned an invalid API calculation");
|
|
}
|