From 8ac1c0ae5958192df2532e4717c8c66cff7ca2b0 Mon Sep 17 00:00:00 2001 From: nym21 Date: Mon, 27 Jul 2026 15:16:45 +0200 Subject: [PATCH] next: ai part 7 --- website_next/ask/storage.js | 12 +- website_next/ask/tools/api/answer.js | 93 ++++++++---- website_next/ask/tools/api/index.js | 3 +- website_next/ask/tools/api/openapi.js | 4 +- website_next/ask/tools/api/worker.js | 8 +- website_next/ask/tools/direct/chart.js | 17 ++- website_next/ask/tools/metrics/worker.js | 85 +++++++++-- website_next/ask/tools/render.js | 69 +++++++-- website_next/ask/tools/session.js | 177 +++++++++++++++++++---- website_next/ask/tools/text.js | 9 ++ website_next/chart/units.js | 1 + 11 files changed, 380 insertions(+), 98 deletions(-) diff --git a/website_next/ask/storage.js b/website_next/ask/storage.js index ec69b3918..66725e8e3 100644 --- a/website_next/ask/storage.js +++ b/website_next/ask/storage.js @@ -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 diff --git a/website_next/ask/tools/api/answer.js b/website_next/ask/tools/api/answer.js index 92d2f4cd5..e52be0e6c 100644 --- a/website_next/ask/tools/api/answer.js +++ b/website_next/ask/tools/api/answer.js @@ -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, diff --git a/website_next/ask/tools/api/index.js b/website_next/ask/tools/api/index.js index c0c97fc38..465a51c1f 100644 --- a/website_next/ask/tools/api/index.js +++ b/website_next/ask/tools/api/index.js @@ -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] diff --git a/website_next/ask/tools/api/openapi.js b/website_next/ask/tools/api/openapi.js index f330d9afe..a6d66ee14 100644 --- a/website_next/ask/tools/api/openapi.js +++ b/website_next/ask/tools/api/openapi.js @@ -66,7 +66,7 @@ function compactText(value, limit) { * @param {Record} 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, diff --git a/website_next/ask/tools/api/worker.js b/website_next/ask/tools/api/worker.js index 99b07e090..628bdf0a0 100644 --- a/website_next/ask/tools/api/worker.js +++ b/website_next/ask/tools/api/worker.js @@ -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} */ diff --git a/website_next/ask/tools/direct/chart.js b/website_next/ask/tools/direct/chart.js index 21380d3a2..113d9a789 100644 --- a/website_next/ask/tools/direct/chart.js +++ b/website_next/ask/tools/direct/chart.js @@ -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; diff --git a/website_next/ask/tools/metrics/worker.js b/website_next/ask/tools/metrics/worker.js index 739d1d60b..c6a328957 100644 --- a/website_next/ask/tools/metrics/worker.js +++ b/website_next/ask/tools/metrics/worker.js @@ -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} */ 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>> | 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, + }); + } } } diff --git a/website_next/ask/tools/render.js b/website_next/ask/tools/render.js index d22b91f22..c4f69b73b 100644 --- a/website_next/ask/tools/render.js +++ b/website_next/ask/tools/render.js @@ -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, 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, 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"); diff --git a/website_next/ask/tools/session.js b/website_next/ask/tools/session.js index 40a391fdb..c983cf761 100644 --- a/website_next/ask/tools/session.js +++ b/website_next/ask/tools/session.js @@ -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); diff --git a/website_next/ask/tools/text.js b/website_next/ask/tools/text.js index b7e5f52f2..fa7b4d25d 100644 --- a/website_next/ask/tools/text.js +++ b/website_next/ask/tools/text.js @@ -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); diff --git a/website_next/chart/units.js b/website_next/chart/units.js index e549c56ca..dd64d11fb 100644 --- a/website_next/chart/units.js +++ b/website_next/chart/units.js @@ -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 },