next: ai part 7

This commit is contained in:
nym21
2026-07-27 15:16:45 +02:00
parent 5079f2be41
commit 8ac1c0ae59
11 changed files with 380 additions and 98 deletions
+10 -2
View File
@@ -2,7 +2,15 @@ const INDEX_KEY = "bitview.ask.chats.v2";
const CHAT_KEY_PREFIX = "bitview.ask.chat.v2.";
const LEGACY_KEY = "bitview.ask.v1";
const NEW_CHAT_TITLE = "New chat";
const CHART_UNITS = new Set(["addresses", "blocks", "btc", "percent", "utxos", "usd"]);
const CHART_UNITS = new Set([
"addresses",
"blocks",
"btc",
"number",
"percent",
"utxos",
"usd",
]);
const CHART_VIEWS = new Set(["line", "area", "stacked", "bar", "dots"]);
const CHART_SCALES = new Set(["linear", "log"]);
const CHART_COLORS = new Set([
@@ -53,7 +61,7 @@ const CHART_COLORS = new Set([
*
* @typedef {Object} ChartSpec
* @property {string} title
* @property {"addresses" | "blocks" | "btc" | "percent" | "utxos" | "usd"} unit
* @property {"addresses" | "blocks" | "btc" | "number" | "percent" | "utxos" | "usd"} unit
* @property {"line" | "area" | "stacked" | "bar" | "dots"} view
* @property {"linear" | "log"} scale
* @property {ChartSeriesSpec[]} series
+65 -28
View File
@@ -121,6 +121,7 @@ function fieldScore(phrase, context, field) {
(sum, word) => sum + (new Set(words(name)).has(word) ? 8 : 3),
0,
);
score += phraseWords.length * 10;
const normalizedPhrase = normalize(phrase);
if (name.includes(normalizedPhrase)) score += 12;
if (description.includes(normalizedPhrase)) score += 5;
@@ -139,66 +140,102 @@ function fieldScore(phrase, context, field) {
* @param {any} grounding
*/
export function directApiCalculation(question, fields, grounding) {
/** @type {{ left: string, right: string, context: string, label: string } | undefined} */
/** @type {{ left: string, right: string, context: string } | undefined} */
let expression;
const minus = question.match(/^(.*?)(?:,\s*)?([^,;?.]+?)\s+minus\s+([^,;?.]+)[?.]*$/i);
const cleaned = question.replace(/[?.;]+$/g, "").trim();
const minus = cleaned.match(/^(.*?)\s+minus\s+(.+)$/i);
if (minus) {
const comma = minus[1].lastIndexOf(",");
const context = comma >= 0 ? minus[1].slice(0, comma) : "";
const left = (comma >= 0 ? minus[1].slice(comma + 1) : minus[1]).trim();
expression = {
context: minus[1],
left: minus[2],
right: minus[3],
label: `${minus[2].trim()} minus ${minus[3].trim()}`,
context,
left,
right: minus[2],
};
} else {
const difference = question.match(
/^(.*?)\bdifference\s+between\s+([^,;?.]+?)\s+and\s+([^,;?.]+)[?.]*$/i,
const difference = cleaned.match(
/^(.*?)\bdifference\s+between\s+(.+?)\s+and\s+(.+)$/i,
);
if (difference) {
expression = {
context: difference[1],
left: difference[2],
right: difference[3],
label: `difference between ${difference[2].trim()} and ${difference[3].trim()}`,
};
} else {
const subtract = question.match(
/^(.*?)\bsubtract\s+([^,;?.]+?)\s+from\s+([^,;?.]+)[?.]*$/i,
const subtract = cleaned.match(
/^(.*?)\bsubtract\s+(.+?)\s+from\s+(.+)$/i,
);
if (subtract) {
expression = {
context: subtract[1],
left: subtract[3],
right: subtract[2],
label: `${subtract[3].trim()} minus ${subtract[2].trim()}`,
};
}
}
}
if (!expression) return undefined;
/** @param {string} phrase */
const select = (phrase) => {
const ranked = fields
.map((field) => ({
field,
score: fieldScore(phrase, expression.context, field),
}))
/** @param {string} value */
const phraseVariants = (value) => {
const values = words(value);
const phrases = [];
for (let length = 1; length <= Math.min(values.length, 6); length += 1) {
for (let start = 0; start + length <= values.length; start += 1) {
phrases.push(values.slice(start, start + length).join(" "));
}
}
return phrases;
};
/** @param {string} phrase @param {string} context */
const rank = (phrase, context) =>
fields
.map((field) => phraseVariants(phrase)
.map((variant) => ({
field,
phrase: variant,
score: fieldScore(variant, context, field),
}))
.sort((left, right) => right.score - left.score)[0])
.filter(({ score }) => score > 0)
.sort((left, right) => right.score - left.score);
if (!ranked.length || ranked[1]?.score === ranked[0].score) return undefined;
return ranked[0].field;
};
const left = select(expression.left);
const right = select(expression.right);
if (!left || !right || left.ref === right.ref) return undefined;
const leftCandidates = rank(
expression.left,
`${expression.context} ${expression.right}`,
);
const rightCandidates = rank(
expression.right,
`${expression.context} ${expression.left}`,
);
/** @param {ApiNumericField} field */
const parent = (field) => field.name.split(".").slice(0, -1).join(".");
const pairs = leftCandidates.flatMap((left) =>
rightCandidates
.filter((right) =>
left.field.ref !== right.field.ref &&
normalize(left.field.type) === normalize(right.field.type)
)
.map((right) => ({
left,
right,
score: left.score + right.score +
(parent(left.field) === parent(right.field) ? 5 : 0),
}))
).sort((left, right) => right.score - left.score);
const [pair, second] = pairs;
if (!pair || second?.score === pair.score) return undefined;
return finishApiAnswer(
{
action: "calculate",
label: expression.label,
label: `${pair.left.phrase} minus ${pair.right.phrase}`,
terms: [
{ ref: left.ref, sign: "add" },
{ ref: right.ref, sign: "subtract" },
{ ref: pair.left.field.ref, sign: "add" },
{ ref: pair.right.field.ref, sign: "subtract" },
],
},
fields,
+2 -1
View File
@@ -10,6 +10,7 @@ const OPENAPI_URL = `${BRK_BASE_URL}/openapi.json`;
* @property {boolean} required
* @property {string} type
* @property {string} [valueType]
* @property {string} [format]
* @property {unknown[]} [enum]
* @property {string} description
*
@@ -21,7 +22,7 @@ const OPENAPI_URL = `${BRK_BASE_URL}/openapi.json`;
* @property {string} summary
* @property {string} description
* @property {ApiParameter[]} parameters
* @property {{ contentType: string, type: string, description: string, fields: { name: string, type: string, required: boolean, description: string }[] }} response
* @property {{ contentType: string, type: string, description: string, fields: { name: string, type: string, required: boolean, description: string, ownDescription: string }[] }} response
* @property {string} [matchedQuery]
* @property {number} [matchedTerms]
* @property {number} [score]
+3 -1
View File
@@ -66,7 +66,7 @@ function compactText(value, limit) {
* @param {Record<string, any>} shape
* @param {string} prefix
* @param {number} depth
* @returns {{ name: string, type: string, required: boolean, description: string }[]}
* @returns {{ name: string, type: string, required: boolean, description: string, ownDescription: string }[]}
*/
function schemaFields(spec, shape, prefix = "", depth = 0, context = "") {
const required = new Set(Array.isArray(shape.required) ? shape.required : []);
@@ -92,6 +92,7 @@ function schemaFields(spec, shape, prefix = "", depth = 0, context = "") {
type: schemaName(raw),
required: required.has(name),
description,
ownDescription,
});
if (depth < 1 && isObject(resolved.properties)) {
fields.push(...schemaFields(spec, resolved, path, depth + 1, description));
@@ -121,6 +122,7 @@ function parameterDetails(spec, raw) {
required: location === "path" || parameter.required === true,
type: schemaName(rawSchema),
valueType: schemaName(schema),
...(typeof schema.format === "string" ? { format: schema.format } : {}),
...(Array.isArray(schema.enum) ? { enum: schema.enum.slice(0, 64) } : {}),
description: compactText(
parameter.description !== undefined ? parameter.description : schema.description,
+2 -6
View File
@@ -1,4 +1,5 @@
import { QuickMatch, QuickMatchConfig } from "../../../modules/quickmatch-js/0.5.0/src/index.js";
import { normalize } from "../text.js";
import { operationsFromOpenApi } from "./openapi.js";
const SEARCH_CANDIDATES = 256;
@@ -10,12 +11,7 @@ const SEARCH_CANDIDATES = 256;
/** @param {string} value */
function searchable(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_./{}|:-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
return normalize(value);
}
/** @param {ApiOperation} operation @returns {IndexedOperation} */
+14 -3
View File
@@ -2,8 +2,10 @@ import { normalize } from "../text.js";
const CHART_REQUEST =
/\b(?:chart|graph|plot|trend|visualize|visualise)\b|\b(?:over|through)\s+time\b|\btime\s+series\b/;
const ADD_REQUEST = /\b(?:add|include|overlay)\b/;
const REMOVE_REQUEST = /\b(?:remove|drop)\b/;
const ADD_REQUEST = /\b(?:add|include|overlay|put)\b/;
const REMOVE_REQUEST = /\b(?:remove|drop)\b|\btake\b.+\boff\b/;
const KEEP_REQUEST = /\b(?:only\s+keep|keep\s+only)\b/;
const UNSUPPORTED_EDIT = /\b(?:clear|replace|reset|swap)\b/;
/**
* @param {string} request
@@ -18,7 +20,16 @@ export function directChartCommand(request, hasActiveChart) {
if (hasActiveChart && REMOVE_REQUEST.test(text)) {
return { kind: /** @type {const} */ ("edit"), operation: /** @type {const} */ ("remove") };
}
if (CHART_REQUEST.test(text) && !ADD_REQUEST.test(text) && !REMOVE_REQUEST.test(text)) {
if (hasActiveChart && KEEP_REQUEST.test(text)) {
return { kind: /** @type {const} */ ("edit"), operation: /** @type {const} */ ("replace") };
}
if (
CHART_REQUEST.test(text) &&
!REMOVE_REQUEST.test(text) &&
!KEEP_REQUEST.test(text) &&
!UNSUPPORTED_EDIT.test(text) &&
(!hasActiveChart || !ADD_REQUEST.test(text))
) {
return { kind: /** @type {const} */ ("build"), operation: /** @type {const} */ ("add") };
}
return undefined;
+73 -12
View File
@@ -1,18 +1,15 @@
import { QuickMatch, QuickMatchConfig } from "../../../modules/quickmatch-js/0.5.0/src/index.js";
import { normalize, tokenAffinity } from "../text.js";
import { metricsFromSeries } from "./series.js";
const SEARCH_CANDIDATES = 1_024;
const MAX_MENTION_WORDS = 12;
/** @typedef {{ path: string, name: string, indexes: string[], type: string, document: string }} CatalogMetric */
/** @param {string} value */
function searchable(value) {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[_./|:-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
return normalize(value);
}
/** @param {string} path @param {string} type */
@@ -23,13 +20,13 @@ function suggestedUnit(path, type) {
if (/address/i.test(type)) return "addresses";
if (/(utxo|output)/i.test(type)) return "utxos";
if (/(block|height)/i.test(type)) return "blocks";
if (/(percent|ratio|dominance|rate)/i.test(path)) return "percent";
if (/(percent|ratio|dominance)/i.test(path)) return "percent";
if (/(usd|price|cap)/i.test(path)) return "usd";
if (/(btc|supply|value)/i.test(path)) return "btc";
if (/(address|addr)/i.test(path)) return "addresses";
if (/(utxo|output)/i.test(path)) return "utxos";
if (/(block|height|epoch)/i.test(path)) return "blocks";
return undefined;
return "number";
}
/** @param {number} limit */
@@ -70,9 +67,26 @@ async function buildState(url) {
const config = createConfig();
const matcher = new QuickMatch(items.map(({ document }) => document), config);
const nameConfig = createConfig(12).withTrigramBudget(4);
const nameMatcher = new QuickMatch([...bySearchableName.keys()], nameConfig);
const nameWords = new Set(
[...bySearchableName.keys()].flatMap((name) => name.split(" ")),
);
/** @type {Map<string, { matcher: QuickMatch, config: QuickMatchConfig }>} */
const scoped = new Map();
return { items, byName, byPath, bySearchableName, byDocument, matcher, config, scoped };
return {
items,
byName,
byPath,
bySearchableName,
byDocument,
matcher,
config,
nameMatcher,
nameConfig,
nameWords,
scoped,
};
}
/** @type {Promise<Awaited<ReturnType<typeof buildState>>> | undefined} */
@@ -193,9 +207,56 @@ function mentions(index, query) {
const matches = [];
for (let start = 0; start < words.length; start += 1) {
for (let end = start + 1; end <= words.length; end += 1) {
const named = index.bySearchableName.get(words.slice(start, end).join(" "));
if (named?.length === 1) matches.push({ start, end, metric: named[0] });
for (
let end = start + 1;
end <= Math.min(words.length, start + MAX_MENTION_WORDS);
end += 1
) {
const phraseWords = words.slice(start, end);
const phrase = phraseWords.join(" ");
const named = index.bySearchableName.get(phrase);
if (named?.length === 1) {
matches.push({ start, end, metric: named[0] });
continue;
}
if (
phraseWords.every((word) => index.nameWords.has(word)) ||
phraseWords.length === 1 && phrase.length < 5
) continue;
const fuzzy = index.nameMatcher.matchesWith(phrase, index.nameConfig)
.map((name) => ({
name,
named: index.bySearchableName.get(name),
candidateWords: name.split(" "),
}))
.filter(({ named, candidateWords }) =>
named?.length === 1 && candidateWords.length === phraseWords.length
)
.map((candidate) => {
const affinities = phraseWords.map((word, index_) =>
tokenAffinity(word, candidate.candidateWords[index_])
);
return {
...candidate,
affinities,
score: affinities.reduce((sum, affinity) => sum + affinity, 0) /
affinities.length,
};
})
.filter(({ affinities, score }) =>
score >= 0.82 && affinities.every((affinity) => affinity >= 0.65)
)
.sort((left, right) => right.score - left.score)[0];
if (fuzzy) {
const [metric] = fuzzy.named ?? [];
if (!metric) continue;
matches.push({
start,
end,
metric,
});
}
}
}
+55 -14
View File
@@ -1,6 +1,6 @@
import { formatValue } from "./data.js";
import { focusApiData } from "./api/result.js";
import { normalize } from "./text.js";
import { normalize, tokenAffinity } from "./text.js";
/**
* @typedef {Object} MetricRead
@@ -21,6 +21,7 @@ import { normalize } from "./text.js";
*/
const SOURCE_URL = "https://github.com/bitcoinresearchkit/brk/blob";
const MIN_FIELD_AFFINITY = 0.65;
/** @param {SourceEvidence} source */
function sourceKey(source) {
@@ -124,20 +125,20 @@ function renderApiField(candidate) {
.map((type) => type.trim());
const semanticTypes = types.filter((type) => !genericTypes.has(type.toLowerCase()));
const unit = semanticTypes.length === 1 ? ` ${semanticTypes[0]}` : "";
const label = candidate.field.name.replaceAll("_", " ").replaceAll(".", " · ");
const label = candidate.field.name.split(".").map(normalize).join(" · ");
return `**${label}**: ${displayScalar(candidate.value)}${unit}`;
}
/**
* Render scalar fields selected directly by exact OpenAPI field-name overlap.
* Render scalar fields selected directly from OpenAPI names and descriptions.
* Equal-scoring fields are returned together rather than asking the model to
* choose, which is both faster and safer for questions such as "which block?".
* Field names, parameter names, and units all come from OpenAPI.
* @param {{ question: string, data: unknown, arguments?: Record<string, unknown>, operation: { method: string, path: string, parameters?: { name: string }[], response: { type?: string, fields?: { name: string, type: string, description?: string }[] } } }} grounding
* @param {{ question: string, data: unknown, arguments?: Record<string, unknown>, operation: { method: string, path: string, parameters?: { name: string }[], response: { type?: string, fields?: { name: string, type: string, description?: string, ownDescription?: string }[] } } }} grounding
*/
export function renderDirectApiAnswer(grounding) {
if (
/\b(?:add(?:ed)?|combined?|difference|minus|net|plus|subtract(?:ed)?|sum)\b/i
/\b(?:add(?:ed)?|altogether|combined?|difference|including|minus|net|plus|subtract(?:ed)?|sum|total)\b/i
.test(grounding.question)
) return undefined;
@@ -150,17 +151,26 @@ export function renderDirectApiAnswer(grounding) {
}
const words = new Set(
normalize(grounding.question).match(/[a-z0-9]+/g) ?? [],
(normalize(grounding.question).match(/[a-z0-9]+/g) ?? [])
.filter((word) => word.length >= 3),
);
if (normalize(grounding.question).includes("how many")) {
words.add("count");
words.add("number");
}
const parameters = new Set(
(grounding.operation.parameters ?? []).map(({ name }) => normalize(name)),
);
const fields = responseFields.map((field) => {
const asksForIdentity = words.has("which") || words.has("where");
const fields = responseFields.map((field, index) => {
const nameTokens = new Set(normalize(field.name).match(/[a-z0-9]+/g) ?? []);
const ownTokens = new Set(
normalize(field.ownDescription ?? field.description ?? "").match(/[a-z0-9]+/g) ?? [],
);
const tokens = new Set(
normalize(`${field.name} ${field.description ?? ""}`).match(/[a-z0-9]+/g) ?? [],
);
return { field, nameTokens, tokens };
return { field, index, nameTokens, ownTokens, tokens };
});
const frequencies = new Map();
for (const { tokens } of fields) {
@@ -175,30 +185,61 @@ export function renderDirectApiAnswer(grounding) {
let matches = 0;
let score = 0;
for (const word of words) {
if (!field.tokens.has(word)) continue;
const match = [...field.tokens]
.map((token) => ({
token,
affinity: tokenAffinity(word, token),
}))
.sort((left, right) => right.affinity - left.affinity)[0];
if (!match || match.affinity < MIN_FIELD_AFFINITY) continue;
matches += 1;
const frequency = frequencies.get(word) ?? fields.length;
const frequency = frequencies.get(match.token) ?? fields.length;
const idf = Math.log((fields.length + 1) / (frequency + 1)) + 1;
score += idf * (field.nameTokens.has(word) ? 3 : 1);
const nameMatch = [...field.nameTokens].some((token) =>
tokenAffinity(word, token) >= MIN_FIELD_AFFINITY
);
const ownMatch = [...field.ownTokens].some((token) =>
tokenAffinity(word, token) >= MIN_FIELD_AFFINITY
);
score += idf * (nameMatch ? 3 : ownMatch ? 2 : 1) * match.affinity;
}
if (
asksForIdentity &&
normalize(field.field.type).split(" ").includes("boolean")
) score *= 0.5;
return {
field: field.field,
index: field.index,
path,
value: valueAt(data, path),
matches,
score,
parameter: parameters.has(normalize(leaf)),
unmatchedName: [...field.nameTokens].filter((token) =>
![...words].some((word) =>
tokenAffinity(word, token) >= MIN_FIELD_AFFINITY
)
).length,
};
})
.filter(({ matches, parameter, value }) =>
matches > 0 && !parameter && scalar(value) && value !== null
)
.sort((left, right) => right.score - left.score || right.matches - left.matches);
.sort((left, right) =>
right.score - left.score ||
right.matches - left.matches ||
left.unmatchedName - right.unmatchedName
);
if (!candidates.length) return undefined;
const selected = candidates
.filter(({ score }) => score === candidates[0].score)
.slice(0, 6);
.filter(({ score, matches, unmatchedName }) =>
score === candidates[0].score &&
matches === candidates[0].matches &&
(matches === 1 || unmatchedName === candidates[0].unmatchedName)
)
.slice(0, 6)
.sort((left, right) => left.index - right.index);
const answer = selected.length === 1
? renderApiField(selected[0])
: selected.map((candidate) => `- ${renderApiField(candidate)}`).join("\n");
+146 -31
View File
@@ -24,11 +24,11 @@ import {
rewriteTool,
searchTool,
} from "./schemas.js";
import { normalize } from "./text.js";
import { normalize, tokenAffinity } from "./text.js";
const MAX_OPTIONS = 12;
const MAX_API_OPTIONS = 6;
const MAX_API_HINTS = 12;
const MAX_API_HINTS = 24;
/** @typedef {import("../storage.js").ChartArtifact} ChartArtifact */
@@ -81,7 +81,8 @@ function mayRequestMultiple(value) {
/** @param {string} value */
function referencesPrevious(value) {
return /\b(?:it|its|that|this|they|their|them|those|these|same)\b/i.test(value) ||
/^(?:and|also|what about)\b/i.test(value.trim());
/^(?:and|also|what about)\b/i.test(value.trim()) ||
/^(?:at\s+block\s+\d+|(?:at|on)\s+\d{4}(?:[- ]\d{2}){2})\b/i.test(value.trim());
}
/** @param {string} value */
@@ -102,15 +103,36 @@ function literalArguments(value) {
)];
}
/** @param {string} request */
function apiRequestWords(request) {
const words = new Set(normalize(request).match(/[a-z0-9]+/g) ?? []);
if (normalize(request).includes("how many")) {
words.add("count");
words.add("number");
}
return words;
}
/** @param {string} request @param {import("./api/index.js").ApiOperation} operation */
function apiResponseMatchCount(request, operation) {
const requestWords = apiRequestWords(request);
const nameWords = new Set(operation.response.fields.flatMap((field) =>
normalize(field.name).match(/[a-z0-9]+/g) ?? []
));
const descriptionWords = new Set(operation.response.fields.flatMap((field) =>
(normalize(field.description).match(/[a-z0-9]+/g) ?? [])
.filter((word) => word.length >= 4)
));
return [...requestWords].filter((candidate) =>
[...nameWords].some((word) => tokenAffinity(word, candidate) >= 0.7) ||
candidate.length >= 4 &&
[...descriptionWords].some((word) => tokenAffinity(word, candidate) >= 0.65)
).length;
}
/** @param {string} request @param {import("./api/index.js").ApiOperation} operation */
function matchesApiResponse(request, operation) {
const words = new Set(normalize(request).match(/[a-z0-9]+/g) ?? []);
return operation.response.fields.some((field) => {
const names = normalize(field.name).match(/[a-z0-9]+/g) ?? [];
if (names.some((word) => words.has(word))) return true;
const description = normalize(field.description).match(/[a-z0-9]+/g) ?? [];
return description.some((word) => word.length >= 4 && words.has(word));
});
return apiResponseMatchCount(request, operation) > 0;
}
/** @param {string} request @param {import("./api/index.js").ApiOperation} operation */
@@ -126,8 +148,16 @@ function matchesApiIntent(request, operation) {
return [...words].filter((word) => document.has(word)).length >= 2;
}
/** @param {string} request */
function requestsExplanation(request) {
const text = normalize(request);
return /^(?:why|how(?! many\b| much\b))\b/.test(text) ||
/\b(?:describe|explain|meaning|mean|works?|working)\b/.test(text);
}
/** @param {string} value */
function literalType(value) {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return "date";
if (/^-?\d+(?:\.\d+)?$/.test(value) && value.replace(/[^0-9]/g, "").length <= 15) {
return "number";
}
@@ -137,6 +167,7 @@ function literalType(value) {
/** @param {import("./api/index.js").ApiParameter} parameter */
function parameterType(parameter) {
if (/^date(?:-time)?$/.test(normalize(parameter.format ?? ""))) return "date";
const type = normalize(parameter.valueType ?? parameter.type);
if (/\b(?:integer|number)\b/.test(type)) return "number";
if (/\bboolean\b/.test(type)) return "boolean";
@@ -154,6 +185,11 @@ function parameterType(parameter) {
function argumentAffinity(values, operation, request = "") {
const required = operation.parameters.filter((parameter) => parameter.required);
if (required.length !== values.length) return -1;
if (required.some((parameter, index) => {
const expected = parameterType(parameter);
const actual = literalType(values[index]);
return expected !== "unknown" && expected !== actual;
})) return -1;
const requestTokens = normalize(request).split(" ");
return required.reduce((score, parameter, index) => {
const expected = parameterType(parameter);
@@ -176,10 +212,15 @@ function argumentAffinity(values, operation, request = "") {
* @param {string} request
*/
function directApiCandidate(hints, values, request) {
if (requestsExplanation(request)) return undefined;
if (!values.length) {
return hints.find((operation) =>
operation.parameters.every((parameter) => !parameter.required) &&
(operation.matchedTerms ?? 0) >= 2 &&
(
(operation.matchedTerms ?? 0) >= 2 ||
(operation.matchedTerms ?? 0) >= 1 &&
apiResponseMatchCount(request, operation) >= 1
) &&
matchesApiIntent(request, operation)
);
}
@@ -188,17 +229,25 @@ function directApiCandidate(hints, values, request) {
operation,
rank,
affinity: argumentAffinity(values, operation, request),
responseMatches: apiResponseMatchCount(request, operation),
}))
.map((candidate) => ({
...candidate,
evidence: candidate.affinity + candidate.responseMatches * 2,
}))
.filter(({ operation, affinity }) =>
affinity >= 0 && (operation.matchedTerms ?? 0) > 0
)
.sort((left, right) =>
right.affinity - left.affinity || left.rank - right.rank
right.evidence - left.evidence ||
right.affinity - left.affinity ||
right.responseMatches - left.responseMatches ||
left.rank - right.rank
);
const [first, second] = ranked;
if (!first || !matchesApiIntent(request, first.operation)) return undefined;
const numericAmbiguity = values.some((value) => literalType(value) === "number") &&
second?.affinity === first.affinity &&
second?.evidence === first.evidence &&
second.operation.parameters
.filter((parameter) => parameter.required)
.map((parameter) => parameter.name)
@@ -232,7 +281,7 @@ function isDirectValueFollowup(request) {
const text = normalize(request);
const hasPoint = /\b(?:current|currently|latest|now|today)\b/.test(text) ||
/\bblock\s+\d{4,}\b/.test(text) ||
/\b\d{4}-\d{2}-\d{2}\b/.test(text);
/\b\d{4}-\d{2}-\d{2}\b/.test(request);
const needsInterpretation = /^(?:how|why)\b/.test(text) ||
/\b(?:available|availability|chart|cohorts?|code|explain|formula|graph|history|plot|source|trend|variants?)\b/.test(text);
return referencesPrevious(text) && hasPoint && !needsInterpretation;
@@ -243,7 +292,7 @@ function isDirectValueRequest(request) {
const text = normalize(request);
const hasPoint = /\b(?:current|currently|latest|now|today)\b/.test(text) ||
/\bblock\s+\d{4,}\b/.test(text) ||
/\b\d{4}-\d{2}-\d{2}\b/.test(text);
/\b\d{4}-\d{2}-\d{2}\b/.test(request);
const needsDifferentTool =
/\b(?:available|availability|chart|cohorts?|code|explain|formula|graph|history|plot|source|trend|variants?|visualize|visualise)\b/.test(text) ||
/\b(?:over|through)\s+time\b/.test(text);
@@ -266,7 +315,7 @@ function isDirectDefinition(request) {
/^(?:what is|what are)\b/.test(text);
const needsRouting = /\b(?:available|availability|chart|cohorts?|code|current|file|graph|history|latest|now|path|plot|source|today|trend|variants?)\b/.test(text) ||
/\bblock\s+\d+\b/.test(text) ||
/\b\d{4}-\d{2}-\d{2}\b/.test(text);
/\b\d{4}-\d{2}-\d{2}\b/.test(request);
return asks && !needsRouting;
}
@@ -386,6 +435,12 @@ function recentMetricPaths(history) {
function latestApiContext(history) {
for (const message of [...history].reverse()) {
if (message.apiContext) return message.apiContext;
if (
Array.isArray(message.metricPaths) ||
message.artifacts?.some?.(
(/** @type {any} */ artifact) => artifact.type === "chart",
)
) return undefined;
}
return undefined;
}
@@ -544,28 +599,74 @@ export class AskToolSession {
this.previousApi = undefined;
}
const dependsOnPrevious = Boolean(this.previousApi) && referencesPrevious(this.request);
const apiQuery = dependsOnPrevious
? `${this.request} ${this.previousApi?.operation.summary || this.previousApi?.operation.label}`
: this.request;
const focusPaths = latestMetricPaths(history);
const literals = literalArguments(this.request);
const hasResourceIdentifier = literals.some((value) => literalType(value) === "string");
const dependsOnMetric = Boolean(focusPaths?.length) &&
!this.previousApi &&
referencesPrevious(this.request) &&
!hasResourceIdentifier;
let prefersMetricTool = dependsOnMetric ||
Boolean(directChartCommand(this.request, Boolean(this.activeChart)));
const dependsOnPrevious = Boolean(this.previousApi) && referencesPrevious(this.request);
const previousApiContext = this.previousApi
? [
this.previousApi.operation.summary || this.previousApi.operation.label,
...this.previousApi.operation.parameters.flatMap((parameter) => [
parameter.name,
parameter.type,
parameter.description,
]),
].filter(Boolean).join(" ")
: "";
const apiQuery = dependsOnPrevious
? `${this.request} ${previousApiContext}`
: this.request;
this.apiHints = await searchApi([apiQuery], MAX_API_HINTS, onProgress).catch(() => []);
this.apiCandidates = literals.length
this.apiCandidates = !prefersMetricTool && literals.length
? this.apiHints.filter((operation) =>
argumentAffinity(literals, operation, this.request) >= 0 &&
(operation.matchedTerms ?? 0) > 0
)
: [];
this.directApiHint = directApiCandidate(this.apiHints, literals, this.request);
this.directApiHint = prefersMetricTool
? undefined
: directApiCandidate(this.apiHints, literals, this.request);
if (
!prefersMetricTool &&
!this.directApiHint &&
isDirectValueRequest(this.request) &&
literals.some((value) => literalType(value) !== "string")
) {
const pointMetrics = await mentionedMetrics(this.request, onProgress);
if (pointMetrics.length) {
prefersMetricTool = true;
this.apiCandidates = [];
}
}
this.requiresTools = Boolean(this.directApiHint) || this.apiCandidates.length > 0;
if (this.previousApi && referencesPrevious(this.request)) {
if (
!prefersMetricTool &&
this.previousApi &&
(
referencesPrevious(this.request) ||
matchesApiResponse(this.request, this.previousApi.operation)
)
) {
this.requiresTools = true;
const contextual = this.apiHints.find((operation) =>
(operation.matchedTerms ?? 0) >= 2 &&
matchesApiIntent(this.request, operation) &&
reusableArguments(operation, this.previousApi) !== undefined
);
const contextual = this.apiHints
.filter((operation) =>
(operation.matchedTerms ?? 0) >= 2 &&
matchesApiIntent(this.request, operation) &&
reusableArguments(operation, this.previousApi) !== undefined
)
.sort((left, right) =>
apiResponseMatchCount(this.request, right) -
apiResponseMatchCount(this.request, left) ||
(right.matchedTerms ?? 0) - (left.matchedTerms ?? 0) ||
(right.score ?? 0) - (left.score ?? 0)
)[0];
if (contextual) {
this.directApiHint = contextual;
} else if (matchesApiResponse(this.request, this.previousApi.operation)) {
@@ -573,7 +674,6 @@ export class AskToolSession {
}
}
const focusPaths = latestMetricPaths(history);
if (focusPaths === undefined) return;
const recentPaths = recentMetricPaths(history);
const currentFocus = this.previousMetrics.map((metric) => metric.path);
@@ -655,8 +755,16 @@ export class AskToolSession {
? "edit_existing_chart"
: "build_requested_chart";
onStatus("Building chart…");
const result = this.buildChart({ refs, operation: chart.operation });
return { output: result.output, artifacts: result.artifacts };
try {
const result = this.buildChart({ refs, operation: chart.operation });
return { output: result.output, artifacts: result.artifacts };
} catch (error) {
if (chart.kind !== "edit") throw error;
return {
output: error instanceof Error ? error.message : String(error),
artifacts: [],
};
}
}
this.requiresTools = true;
}
@@ -746,6 +854,10 @@ export class AskToolSession {
);
}
if (metrics.length > 1 && referencesSingular(this.request)) {
this.requiresTools = true;
return undefined;
}
if (metrics.length) {
onStatus("Reading data…");
const results = await Promise.all(metrics.map((metric) => readMetric(metric, action)));
@@ -1156,6 +1268,8 @@ export class AskToolSession {
async callApi(operation, arguments_, onStatus, signal) {
onStatus("Reading API…");
const result = await executeApi(operation, arguments_, signal);
this.previousMetrics = [];
this.previousTopics = [];
this.previousApi = { operation, arguments: result.arguments };
return {
done: true,
@@ -1479,6 +1593,7 @@ export class AskToolSession {
/** @param {any[]} metrics */
rememberMetricValues(metrics) {
this.previousApi = undefined;
this.previousMetrics = [...new Map(
metrics.map((metric) => [metric.path, metric]),
).values()].slice(0, 6);
+9
View File
@@ -37,6 +37,15 @@ export function similarity(left, right) {
return (2 * overlap) / (a.size + b.size);
}
/** @param {unknown} left @param {unknown} right */
export function tokenAffinity(left, right) {
const a = normalize(left);
const b = normalize(right);
if (a === b) return 1;
if (a.length < 4 || b.length < 4) return 0;
return similarity(a, b);
}
/** @param {unknown} query @param {unknown} document @param {number} [boost] */
export function relevance(query, document, boost = 0) {
const needle = normalize(query);
+1
View File
@@ -4,6 +4,7 @@ export const units = /** @type {const} */ ({
addresses: { id: "addresses", name: "Addresses", format: formatNumberValue },
blocks: { id: "blocks", name: "Blocks", format: formatNumberValue },
btc: { id: "btc", name: "Bitcoin", format: formatNumberValue },
number: { id: "number", name: "Number", format: formatNumberValue },
percent: { id: "%", name: "Percent", format: formatPercentValue },
utxos: { id: "utxos", name: "UTXOs", format: formatNumberValue },
usd: { id: "usd", name: "US Dollars", format: formatNumberValue },