mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-08-04 22:53:05 -07:00
next: ai part 7
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user