import { renderApiAnswer } from "../render.js"; import { normalize, relevance, tokenAffinity } 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"; if (value.includes("vsize")) return "vB"; if (value.includes("feerate")) return "sat/vB"; return value; } /** @param {string} type */ function displayedUnit(type) { const value = dimension(type); if (value === "sats") return " sats"; if (value === "vB") return " vB"; if (value === "sat/vB") return " sat/vB"; if (value.includes("timestamp")) return ""; if (value === "number" || value === "integer" || value === "float") return ""; return ` ${type}`; } /** @param {string} question */ function languageHints(question) { const words = new Set(question.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)); return [ ...(words.has("when") ? ["time timestamp date"] : []), ...(words.has("many") ? ["count number total"] : []), ...(words.has("much") ? ["amount value total"] : []), ]; } /** @param {string} query @param {string} document */ function lexicalAffinity(query, document) { const queryWords = normalize(query).split(" ").filter(Boolean); const documentWords = normalize(document).split(" ").filter(Boolean); return Math.max( 0, ...queryWords.flatMap((queryWord) => documentWords.map((documentWord) => tokenAffinity(queryWord, documentWord) ) ), ); } /** * @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[]} related * @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} */ (current)[key]; } return current; } /** @param {unknown} value @param {string} [type] */ function formattedValue(value, type = "") { if (typeof value === "number") { if (dimension(type).includes("timestamp")) { return new Date(value * 1_000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "medium", }); } 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 ?? [] ); if (Array.isArray(data)) { const rows = data.slice(0, 4); const fields = responseFields.slice(0, 4); const total = grounding.data && typeof grounding.data === "object" && !Array.isArray(grounding.data) && typeof grounding.data.count === "number" ? grounding.data.count : data.length; const primitiveRows = rows.every((row) => typeof row === "string" || typeof row === "number" || typeof row === "boolean" ); if (primitiveRows) { const itemType = grounding.operation.response.type.replace(/\[\]$/, ""); return { output: renderApiAnswer( [ `${total.toLocaleString()} record${total === 1 ? "" : "s"}${ total > rows.length ? ` · showing ${rows.length}` : "" }`, ...rows.map((row, index) => `${index + 1}. ${formattedValue(row, itemType)}` ), ].join("\n\n"), grounding.operation, ), fields: [], }; } if (!rows.length || !fields.length) { return { output: renderApiAnswer( "The API returned no compact records to display.", grounding.operation, ), fields: [], }; } const output = [ `${total.toLocaleString()} record${total === 1 ? "" : "s"}${ total > rows.length ? ` · showing ${rows.length}` : "" }`, ...rows.map((row, index) => { const values = fields .map((field) => ({ field, value: valueAt(row, field.name.split(".")), })) .filter(({ value }) => typeof value === "string" || typeof value === "number" || typeof value === "boolean" ) .map(({ field, value }) => `**${field.name.replaceAll(".", " · ").replaceAll("_", " ")}**: ${ formattedValue(value, field.type) }${typeof value === "number" ? displayedUnit(field.type) : ""}` ); return `${index + 1}. ${values.join(" · ")}`; }), ].join("\n\n"); return { output: renderApiAnswer(output, grounding.operation), fields: fields.map(({ name }) => name), }; } 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, field.type) }${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(".") ).filter(Boolean), ); 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) + lexicalAffinity(grounding.question, field.name) * 8 + relevance( grounding.question, field.ownDescription || field.description || "", ) + Math.max( 0, ...languageHints(grounding.question).map((hint) => relevance( hint, `${field.name} ${field.type} ${ field.ownDescription || field.description || "" }`, ) ), ) - Math.max(0, field.name.split(".").length - 1) * 2 + ( previousParent && field.name.split(".").slice(0, -1).join(".") === previousParent ? 2 : 0 ), })) .sort((left, right) => { return right.score - left.score || 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 related = best ? fields.filter((field) => field.score >= 6 && best.score - field.score < 3 ) : []; 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} 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 select_many when several raw primitive fields were requested.", "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"] : []), ...(fields.length > 1 ? ["select_many"] : []), ...(calculationFields.length >= 2 ? ["calculate"] : []), ...(previous ? ["continue"] : []), "text", ], }, ...(fields.length ? { field: reference, fields: { type: "array", minItems: 2, maxItems: Math.min(10, fields.length), items: reference, description: "Ordered verified field refs when several raw fields were requested.", }, ...(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, related, ambiguous: fields.filter(({ name }) => ambiguousNames.has(name)), tools, }; } /** @param {string} name @param {Record} 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 === "calculate_api_rate") { const numerator = byRef.get(String(action.left)); const denominator = byRef.get(String(action.right)); if ( !numerator || !denominator || typeof numerator.value !== "number" || typeof denominator.value !== "number" ) { throw new Error("The AI returned invalid rate fields"); } const denominatorDimension = dimension(denominator.type); const divisor = denominatorDimension === "vB" ? denominator.value : denominatorDimension === "weight" ? Math.ceil(denominator.value / 4) : 0; if (!divisor) throw new Error("Cannot calculate this rate"); const value = dimension(numerator.type) === "sats" ? Math.ceil(numerator.value * 1_000 / divisor) / 1_000 : numerator.value / divisor; const label = typeof action.label === "string" && action.label.trim() ? action.label.trim().replaceAll("_", " ") : "rate"; const unit = dimension(numerator.type) === "sats" ? " sat/vB" : ""; return renderApiAnswer( `**${label}**: ${ new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value) }${unit}`, grounding.operation, ); } 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, field.type); return renderApiAnswer( `**${label}**: ${formatted}${ typeof field.value === "number" ? displayedUnit(field.type) : "" }`, grounding.operation, ); } if (name === "select_api_fields") { const refs = Array.isArray(action.fields) ? action.fields : []; const selected = refs.map((ref) => byRef.get(String(ref))).filter(Boolean); if (selected.length < 2) { throw new Error("The AI selected too few API fields"); } const output = selected.map((field) => { const formatted = formattedValue(field.value, field.type); return `- **${ field.name.replaceAll(".", " · ").replaceAll("_", " ") }**: ${formatted}${ typeof field.value === "number" ? displayedUnit(field.type) : "" }`; }).join("\n"); return renderApiAnswer(output, 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"); }