mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-24 09:28:11 -07:00
next: ai part 5
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { normalize } from "../text.js";
|
||||
|
||||
const VARIANTS = /\b(?:availability|available|cohorts?|variants?)\b/;
|
||||
const IMPLEMENTATION = /\b(?:calculated?|calculation|code|formula|implemented?|implementation|source)\b/;
|
||||
|
||||
/** @param {string} request */
|
||||
export function directEvidenceFocus(request) {
|
||||
const text = normalize(request);
|
||||
if (IMPLEMENTATION.test(text)) return "implementation";
|
||||
if (VARIANTS.test(text)) return "variants";
|
||||
return undefined;
|
||||
}
|
||||
@@ -11,13 +11,9 @@ const MAX_TOOL_ROUNDS = 8;
|
||||
* @property {boolean} [general]
|
||||
* @property {string} [output]
|
||||
* @property {import("../storage.js").StoredArtifact[]} [artifacts]
|
||||
* @property {string[]} [metricPaths]
|
||||
*/
|
||||
|
||||
/** @param {import("../model.js").ChatMessage[]} messages */
|
||||
function latestQuestion(messages) {
|
||||
return messages.findLast((message) => message.role === "user")?.content ?? "";
|
||||
}
|
||||
|
||||
export function createAskTools() {
|
||||
const source = new AskSource();
|
||||
/** @type {Map<string, AskToolSession>} */
|
||||
@@ -43,22 +39,29 @@ export function createAskTools() {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {string} options.chatId
|
||||
* @param {string} options.question
|
||||
* @param {import("../storage.js").StoredMessage[]} options.history
|
||||
* @param {import("../model.js").AskModel} options.model
|
||||
* @param {import("../model.js").ChatMessage[]} options.messages
|
||||
* @param {() => Promise<{ chat: import("../storage.js").StoredChat, messages: import("../model.js").ChatMessage[] }>} options.prepare
|
||||
* @param {(update: import("../model.js").TokenUpdate) => void} options.onToken
|
||||
* @param {(status: string) => void} options.onStatus
|
||||
*/
|
||||
async answer({ chatId, history, model, messages, onStatus }) {
|
||||
async answer({ chatId, question, history, model, prepare, onToken, onStatus }) {
|
||||
controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
const question = latestQuestion(messages);
|
||||
const session = sessionFor(chatId);
|
||||
session.begin(question, history);
|
||||
await session.begin(
|
||||
question,
|
||||
history,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
|
||||
try {
|
||||
const direct = await session.tryDirect(onStatus);
|
||||
if (direct) return direct;
|
||||
if (direct) return { ...direct, metricPaths: session.metricPaths() };
|
||||
|
||||
const prepared = await prepare();
|
||||
const { messages } = prepared;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
signal.throwIfAborted();
|
||||
@@ -90,9 +93,26 @@ export function createAskTools() {
|
||||
await session.execute(call.arguments, onStatus)
|
||||
);
|
||||
if (!outcome.done) continue;
|
||||
if (outcome.general) {
|
||||
onStatus("Answering…");
|
||||
const answer = await model.generate(
|
||||
messages,
|
||||
onToken,
|
||||
[],
|
||||
"none",
|
||||
);
|
||||
return {
|
||||
output: answer.text,
|
||||
artifacts: [],
|
||||
metricPaths: [],
|
||||
chat: prepared.chat,
|
||||
};
|
||||
}
|
||||
return {
|
||||
output: outcome.output ?? "",
|
||||
artifacts: outcome.artifacts ?? [],
|
||||
metricPaths: session.metricPaths(),
|
||||
chat: prepared.chat,
|
||||
};
|
||||
}
|
||||
throw new Error("The AI used too many tool steps. Try a more specific question.");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { brk } from "../../../utils/client.js";
|
||||
import { expandMetricQueries } from "./language.js";
|
||||
|
||||
const WORKER_URL = import.meta.resolve("./worker.js");
|
||||
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
@@ -64,7 +65,7 @@ class MetricIndex {
|
||||
/** @type {Map<string, { resolve: (value: any) => void, reject: (error: Error) => void, onProgress?: () => void }>} */
|
||||
#pending = new Map();
|
||||
|
||||
/** @param {"search" | "mentions" | "categories" | "byName" | "variants"} type @param {Record<string, unknown>} data @param {(() => void) | undefined} [onProgress] */
|
||||
/** @param {"search" | "mentions" | "categories" | "byName" | "byPaths" | "variants"} type @param {Record<string, unknown>} data @param {(() => void) | undefined} [onProgress] */
|
||||
request(type, data, onProgress) {
|
||||
this.#ensureWorker();
|
||||
const id = crypto.randomUUID();
|
||||
@@ -120,7 +121,11 @@ const index = new MetricIndex();
|
||||
|
||||
/** @param {string[]} queries @param {number} [limit] @param {string[]} [prefixes] @param {(() => void) | undefined} [onProgress] @returns {Promise<CatalogMetric[]>} */
|
||||
export function searchMetrics(queries, limit = 16, prefixes = [], onProgress) {
|
||||
return index.request("search", { queries, limit, prefixes }, onProgress);
|
||||
return index.request(
|
||||
"search",
|
||||
{ queries: expandMetricQueries(queries), limit, prefixes },
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} query @param {(() => void) | undefined} [onProgress] @returns {Promise<CatalogMetric[]>} */
|
||||
@@ -138,6 +143,11 @@ export function metricByName(name) {
|
||||
return index.request("byName", { name });
|
||||
}
|
||||
|
||||
/** @param {string[]} paths @param {(() => void) | undefined} [onProgress] @returns {Promise<CatalogMetric[]>} */
|
||||
export function metricsByPaths(paths, onProgress) {
|
||||
return index.request("byPaths", { paths }, onProgress);
|
||||
}
|
||||
|
||||
/** @param {{ name: string }} metric @param {string} query @returns {Promise<{ totalSeries: number, groups: { family: string, examples: string[] }[], series: CatalogMetric[] } | undefined>} */
|
||||
export function metricVariants(metric, query = "") {
|
||||
return index.request("variants", { name: metric.name, query });
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** @type {[RegExp, string][]} */
|
||||
const ALIASES = [
|
||||
[/\bcapitalised\b/g, "capitalized"],
|
||||
[/\ball time high\b/g, "ath"],
|
||||
[/\blong term holders?\b/g, "lth"],
|
||||
[/\bshort term holders?\b/g, "sth"],
|
||||
[/\b(?:one )?(?:bitcoin|btc) worth\b/g, "bitcoin spot price"],
|
||||
];
|
||||
|
||||
/** @param {string} query */
|
||||
function expand(query) {
|
||||
return ALIASES.reduce(
|
||||
(value, [pattern, replacement]) => value.replace(pattern, replacement),
|
||||
query.toLowerCase().replace(/[-_]+/g, " "),
|
||||
).replace(
|
||||
/\b(\d+(?:\.\d+)?)\s+to\s+(\d+(?:\.\d+)?)\s*(btc|sats?)\b/g,
|
||||
"$1$3 to $2$3",
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string[]} queries */
|
||||
export function expandMetricQueries(queries) {
|
||||
return [...new Set(queries.flatMap((query) => {
|
||||
const expanded = expand(query);
|
||||
return expanded === query ? [query] : [expanded, query];
|
||||
}))];
|
||||
}
|
||||
@@ -56,6 +56,8 @@ function buildState() {
|
||||
const items = [];
|
||||
/** @type {Map<string, CatalogMetric>} */
|
||||
const byName = new Map();
|
||||
/** @type {Map<string, CatalogMetric>} */
|
||||
const byPath = new Map();
|
||||
/** @type {Map<string, CatalogMetric[]>} */
|
||||
const bySearchableName = new Map();
|
||||
/** @type {Map<string, CatalogMetric>} */
|
||||
@@ -78,6 +80,7 @@ function buildState() {
|
||||
const metric = { path, name, endpoint, document };
|
||||
items.push(metric);
|
||||
if (!byName.has(name)) byName.set(name, metric);
|
||||
byPath.set(path, metric);
|
||||
const nameKey = searchable(name);
|
||||
const named = bySearchableName.get(nameKey) ?? [];
|
||||
named.push(metric);
|
||||
@@ -94,7 +97,7 @@ function buildState() {
|
||||
const matcher = new QuickMatch(items.map(({ document }) => document), config);
|
||||
/** @type {Map<string, { matcher: QuickMatch, config: QuickMatchConfig }>} */
|
||||
const scoped = new Map();
|
||||
return { items, byName, bySearchableName, byDocument, matcher, config, scoped };
|
||||
return { items, byName, byPath, bySearchableName, byDocument, matcher, config, scoped };
|
||||
}
|
||||
|
||||
/** @type {Promise<ReturnType<typeof buildState>> | undefined} */
|
||||
@@ -339,6 +342,11 @@ self.addEventListener("message", async (event) => {
|
||||
} else if (type === "byName") {
|
||||
const metric = index.byName.get(data.name);
|
||||
result = metric ? publicMetric(metric) : undefined;
|
||||
} else if (type === "byPaths") {
|
||||
result = data.paths
|
||||
.map((/** @type {string} */ path) => index.byPath.get(path))
|
||||
.filter(Boolean)
|
||||
.map(publicMetric);
|
||||
} else if (type === "variants") {
|
||||
result = variants(index, data.name, data.query);
|
||||
} else {
|
||||
|
||||
@@ -6,13 +6,12 @@ export const ASK_STAGE_PROMPTS = /** @type {const} */ ({
|
||||
Choose the requested outcome and translate the user's meaning into terse catalog-style Bitcoin or BRK metric names or technical noun phrases. Never copy a question or include request verbs, pronouns, time words, or punctuation in a query.
|
||||
Use one query for one metric. For X vs Y, return separate complete X and Y metric phrases; never leave vs or both sides inside one query.
|
||||
Set cardinality to multiple for every comparison or request involving more than one distinct metric, even if you accidentally return one query.
|
||||
Use auto for exact metric terminology. Otherwise choose the narrowest source-derived metric family that expresses the user's meaning. Treat the family as a semantic hint, not part of the query text.
|
||||
Choose reuse_previous when the newest request asks another question about the previous verified topic, including its variants, cohorts, source, value, or chart. Choose extend_previous only when it adds a distinct new metric. Do not turn properties of the previous answer into new metric queries.
|
||||
Choose read_requested_value for a current or historical number.
|
||||
Choose build_requested_chart for a graph, trend, history, comparison over time, or a request to show quantitative metrics over time—even when the user does not say chart.
|
||||
When an active chart is supplied, choose edit_existing_chart only to add, remove, or replace series on that chart.
|
||||
Choose explain_from_verified_facts for what or why questions, meaning, availability, cohorts, variants, or source code.
|
||||
Set focus to variants for cohorts or availability, implementation for source code or calculation details, definition for conceptual explanations, and data for values or charts.
|
||||
Choose answer_general only when the request needs no repository evidence, live data, metric lookup, or chart.
|
||||
Interpret ordinary wording by meaning. BRK means the software repository, not a coin.
|
||||
Call next_action exactly once.`,
|
||||
|
||||
@@ -26,7 +25,6 @@ Prefer the narrowest ordinary family whose name directly expresses the request.
|
||||
rewrite: `${COMMON}
|
||||
Rewrite the newest request as concise conventional Bitcoin or BRK metric or source-search phrases. Translate colloquial meaning into standard technical terminology. Keep independently requested metrics as separate queries.
|
||||
Return exactly one rewritten query for every supplied unmatched query, in the same order. Never merge comparison sides.
|
||||
When someone asks what one bitcoin is worth or trades for, use "bitcoin spot price". Use cointime only when the user explicitly asks about cointime.
|
||||
Return only those searches through next_action.`,
|
||||
|
||||
read: `${COMMON}
|
||||
|
||||
@@ -15,8 +15,8 @@ function actionTool(properties, required) {
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {boolean} hasActiveChart @param {boolean} hasPrevious @param {{ path: string, label: string }[]} [categories] */
|
||||
export function searchTool(hasActiveChart = false, hasPrevious = false, categories = []) {
|
||||
/** @param {boolean} hasActiveChart @param {boolean} hasPrevious */
|
||||
export function searchTool(hasActiveChart = false, hasPrevious = false) {
|
||||
return actionTool({
|
||||
action: { type: "string", enum: ["search"] },
|
||||
context: {
|
||||
@@ -38,13 +38,6 @@ export function searchTool(hasActiveChart = false, hasPrevious = false, categori
|
||||
enum: ["single", "multiple"],
|
||||
description: "Use multiple whenever the user requests a comparison or more than one distinct metric, even if queries accidentally contains one item.",
|
||||
},
|
||||
families: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: { type: "string", enum: ["auto", ...categories.map(({ path }) => path)] },
|
||||
description: `Use auto for exact terminology. Otherwise choose the narrowest source-derived family that expresses the user's meaning: ${categories.map(({ path, label }) => `${path}=${label}`).join("; ") || "auto only"}.`,
|
||||
},
|
||||
outcome: {
|
||||
type: "string",
|
||||
enum: [
|
||||
@@ -52,14 +45,10 @@ export function searchTool(hasActiveChart = false, hasPrevious = false, categori
|
||||
"build_requested_chart",
|
||||
...(hasActiveChart ? ["edit_existing_chart"] : []),
|
||||
"explain_from_verified_facts",
|
||||
"answer_general",
|
||||
],
|
||||
},
|
||||
focus: {
|
||||
type: "string",
|
||||
enum: ["definition", "variants", "implementation", "data"],
|
||||
description: "Choose variants for cohorts or availability, implementation for source code or calculation details, definition for conceptual explanations, and data for values or charts.",
|
||||
},
|
||||
}, ["action", "context", "queries", "cardinality", "families", "outcome", "focus"]);
|
||||
}, ["action", "context", "queries", "cardinality", "outcome"]);
|
||||
}
|
||||
|
||||
/** @param {{ ref: string, label: string }[]} options */
|
||||
@@ -85,7 +74,7 @@ export function rewriteTool(queries) {
|
||||
minItems: queries.length,
|
||||
maxItems: queries.length,
|
||||
items: { type: "string" },
|
||||
description: `Rewrite each input independently, in the same order, without merging them: ${queries.map((query, index) => `${index + 1}=${query}`).join("; ")}. For what one bitcoin is worth or trades for, use bitcoin spot price. Never use cointime unless explicitly requested.`,
|
||||
description: `Rewrite each input independently, in the same order, without merging them: ${queries.map((query, index) => `${index + 1}=${query}`).join("; ")}.`,
|
||||
},
|
||||
}, ["action", "queries"]);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createChartArtifact } from "./chart.js";
|
||||
import { readMetric } from "./data.js";
|
||||
import { directChartCommand } from "./direct/chart.js";
|
||||
import { directEvidenceFocus } from "./direct/evidence.js";
|
||||
import { searchLearn } from "./learn.js";
|
||||
import {
|
||||
metricByName,
|
||||
metricCategories,
|
||||
metricVariants,
|
||||
metricsByPaths,
|
||||
mentionedMetrics,
|
||||
searchMetrics,
|
||||
} from "./metrics/index.js";
|
||||
@@ -72,6 +74,11 @@ function referencesPrevious(value) {
|
||||
return /\b(?:it|its|that|this|they|their|them|those|these|same)\b/i.test(value);
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function referencesSingular(value) {
|
||||
return /\b(?:it|its|that|this)\b/i.test(value);
|
||||
}
|
||||
|
||||
/** @param {string} request */
|
||||
function isDirectValueFollowup(request) {
|
||||
const text = normalize(request);
|
||||
@@ -146,6 +153,19 @@ function latestChart(history) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @param {any[]} history @returns {string[] | undefined} */
|
||||
function latestMetricPaths(history) {
|
||||
for (const message of [...history].reverse()) {
|
||||
if (Array.isArray(message.metricPaths)) return message.metricPaths;
|
||||
|
||||
const chart = message.artifacts?.findLast?.(
|
||||
(/** @type {any} */ artifact) => artifact.type === "chart",
|
||||
);
|
||||
if (chart) return chart.chart.series.map((/** @type {any} */ item) => item.path);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @param {any[]} items */
|
||||
function uniqueMetricOptions(items) {
|
||||
return [...new Map(items.map((/** @type {any} */ item) => [item.ref, item])).values()];
|
||||
@@ -214,6 +234,10 @@ export class AskToolSession {
|
||||
request = "";
|
||||
/** @type {string[]} */
|
||||
previousTopics = [];
|
||||
/** @type {any[]} */
|
||||
previousMetrics = [];
|
||||
/** @type {any[]} */
|
||||
contextMetrics = [];
|
||||
query = "";
|
||||
/** @type {string[]} */
|
||||
queries = [];
|
||||
@@ -238,8 +262,8 @@ export class AskToolSession {
|
||||
/** @type {ChartArtifact | undefined} */
|
||||
activeChart;
|
||||
|
||||
/** @param {string} request @param {any[]} history */
|
||||
begin(request, history) {
|
||||
/** @param {string} request @param {any[]} history @param {() => void} onProgress */
|
||||
async begin(request, history, onProgress) {
|
||||
this.refs = new AskRefs();
|
||||
this.request = request.trim();
|
||||
this.query = "";
|
||||
@@ -251,6 +275,7 @@ export class AskToolSession {
|
||||
this.rewritten = false;
|
||||
this.rewriteChanged = false;
|
||||
this.comparison = false;
|
||||
this.contextMetrics = [];
|
||||
this.categoryPaths = [];
|
||||
this.observation = undefined;
|
||||
this.options = [];
|
||||
@@ -258,21 +283,39 @@ export class AskToolSession {
|
||||
this.formula = undefined;
|
||||
this.sourceSearched = false;
|
||||
this.activeChart ??= latestChart(history);
|
||||
|
||||
const paths = latestMetricPaths(history);
|
||||
if (paths === undefined) return;
|
||||
|
||||
const current = this.previousMetrics.map((metric) => metric.path);
|
||||
if (paths.length === current.length && paths.every((path, index) => path === current[index])) {
|
||||
return;
|
||||
}
|
||||
const metrics = paths.length ? await metricsByPaths(paths, onProgress) : [];
|
||||
this.rememberMetricValues(metrics);
|
||||
}
|
||||
|
||||
/** @param {(status: string) => void} onStatus */
|
||||
async tryDirect(onStatus) {
|
||||
const chart = directChartCommand(this.request, Boolean(this.activeChart));
|
||||
const evidence = directEvidenceFocus(this.request);
|
||||
const chart = evidence
|
||||
? undefined
|
||||
: directChartCommand(this.request, Boolean(this.activeChart));
|
||||
if (chart) {
|
||||
let metrics = await mentionedMetrics(
|
||||
this.request,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
if (!metrics.length) {
|
||||
metrics = await exactTopicMetrics(
|
||||
this.previousTopics,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
if (this.previousTopics.length > 1 && referencesSingular(this.request)) {
|
||||
return undefined;
|
||||
}
|
||||
metrics = this.previousMetrics.length
|
||||
? this.previousMetrics
|
||||
: await exactTopicMetrics(
|
||||
this.previousTopics,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.length) {
|
||||
@@ -280,13 +323,52 @@ export class AskToolSession {
|
||||
this.outcome = chart.kind === "edit"
|
||||
? "edit_existing_chart"
|
||||
: "build_requested_chart";
|
||||
this.rememberMetrics(refs);
|
||||
onStatus("Building chart…");
|
||||
const result = this.buildChart({ refs, operation: chart.operation });
|
||||
return { output: result.output, artifacts: result.artifacts };
|
||||
}
|
||||
}
|
||||
|
||||
if (evidence) {
|
||||
let metrics = await mentionedMetrics(
|
||||
this.request,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
if (!metrics.length && this.previousMetrics.length === 1 && referencesPrevious(this.request)) {
|
||||
metrics = this.previousMetrics;
|
||||
}
|
||||
|
||||
if (metrics.length) {
|
||||
if (evidence === "implementation" && metrics.length !== 1) return undefined;
|
||||
if (evidence === "variants") {
|
||||
const supported = await Promise.all(
|
||||
metrics.map(async (metric) =>
|
||||
await metricVariants(metric, this.request) ? metric : undefined
|
||||
),
|
||||
);
|
||||
metrics = supported.filter((metric) => metric !== undefined);
|
||||
if (!metrics.length) return undefined;
|
||||
}
|
||||
if (evidence === "implementation") {
|
||||
onStatus("Searching source…");
|
||||
this.formula = await this.source.explain(
|
||||
[this.request, ...metrics.map((metric) => metric.name)].join("\n"),
|
||||
({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`),
|
||||
);
|
||||
if (
|
||||
!this.formula ||
|
||||
!metrics.some((metric) => normalize(metric.name) === normalize(this.formula.fact.metric))
|
||||
) return undefined;
|
||||
}
|
||||
|
||||
this.focus = evidence;
|
||||
const refs = metrics.map((metric) => this.refs.issue("metric", metric, metric.path));
|
||||
onStatus("Inspecting results…");
|
||||
const result = await this.inspect(refs);
|
||||
return { output: result.output, artifacts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (this.previousTopics.length === 1 && isDirectValueFollowup(this.request)) {
|
||||
const action = directReadAction(this.request);
|
||||
if (action) {
|
||||
@@ -295,16 +377,18 @@ export class AskToolSession {
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
if (!metrics.length) {
|
||||
metrics = await exactTopicMetrics(
|
||||
this.previousTopics,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
metrics = this.previousMetrics.length
|
||||
? this.previousMetrics
|
||||
: await exactTopicMetrics(
|
||||
this.previousTopics,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
}
|
||||
|
||||
if (metrics.length) {
|
||||
onStatus("Reading data…");
|
||||
const results = await Promise.all(metrics.map((metric) => readMetric(metric, action)));
|
||||
this.previousTopics = metrics.map((metric) => label(metric.name));
|
||||
this.rememberMetricValues(metrics);
|
||||
return { output: renderData(results), artifacts: [] };
|
||||
}
|
||||
}
|
||||
@@ -312,12 +396,23 @@ export class AskToolSession {
|
||||
|
||||
if (!isDirectDefinition(this.request)) return undefined;
|
||||
|
||||
const [metric] = await searchMetrics(
|
||||
[this.request],
|
||||
1,
|
||||
[],
|
||||
const mentioned = await mentionedMetrics(
|
||||
this.request,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
if (mentioned.length > 1) return undefined;
|
||||
let [metric] = mentioned;
|
||||
if (!metric && this.previousMetrics.length === 1 && referencesPrevious(this.request)) {
|
||||
[metric] = this.previousMetrics;
|
||||
}
|
||||
if (!metric) {
|
||||
[metric] = await searchMetrics(
|
||||
[this.request],
|
||||
1,
|
||||
[],
|
||||
() => onStatus("Indexing metrics…"),
|
||||
);
|
||||
}
|
||||
if (!metric) return undefined;
|
||||
|
||||
onStatus("Searching source…");
|
||||
@@ -327,17 +422,15 @@ export class AskToolSession {
|
||||
);
|
||||
if (!formula || normalize(formula.fact.metric) !== normalize(metric.name)) return undefined;
|
||||
|
||||
this.previousTopics = [label(formula.fact.metric)];
|
||||
this.rememberMetricValues([metric]);
|
||||
return { output: formula.answer, artifacts: [] };
|
||||
}
|
||||
|
||||
async tool() {
|
||||
if (this.stage === "search") {
|
||||
const categories = await metricCategories();
|
||||
return searchTool(
|
||||
Boolean(this.activeChart),
|
||||
this.previousTopics.length > 0,
|
||||
categories,
|
||||
);
|
||||
}
|
||||
if (this.stage === "rewrite") return rewriteTool(this.queries);
|
||||
@@ -386,7 +479,7 @@ export class AskToolSession {
|
||||
|
||||
/** @param {(status: string) => void} onStatus */
|
||||
async search(onStatus) {
|
||||
const [globalMetrics, rawMetrics, scopedMetrics, guideGroups] = await Promise.all([
|
||||
const [globalMetrics, rawMetrics, guideGroups] = await Promise.all([
|
||||
searchMetrics(
|
||||
this.queries,
|
||||
MAX_OPTIONS,
|
||||
@@ -399,16 +492,17 @@ export class AskToolSession {
|
||||
[],
|
||||
() => onStatus("Indexing metrics…"),
|
||||
),
|
||||
searchMetrics(
|
||||
this.queries,
|
||||
MAX_OPTIONS,
|
||||
this.categoryPaths,
|
||||
() => onStatus("Indexing metrics…"),
|
||||
),
|
||||
Promise.all(this.queries.map((query) => searchLearn(query, MAX_OPTIONS))),
|
||||
]);
|
||||
const foundMetrics = mergeMetricGroups([globalMetrics, rawMetrics, scopedMetrics]);
|
||||
const metrics = [...foundMetrics];
|
||||
const foundMetrics = mergeMetricGroups([globalMetrics, rawMetrics]);
|
||||
const contextualMetrics = this.contextMetrics.map((metric) => ({
|
||||
...metric,
|
||||
matchedQuery: label(metric.name),
|
||||
score: 2_000,
|
||||
}));
|
||||
const metrics = [...new Map(
|
||||
[...foundMetrics, ...contextualMetrics].map((metric) => [metric.path, metric]),
|
||||
).values()];
|
||||
const metricNames = new Set(metrics.map((metric) => metric.name));
|
||||
const guides = [...new Map(
|
||||
guideGroups.flat().map((/** @type {any} */ guide) => [guide.breadcrumbs.join("/"), guide]),
|
||||
@@ -655,12 +749,16 @@ export class AskToolSession {
|
||||
};
|
||||
/** @type {string[]} */
|
||||
const topics = [];
|
||||
/** @type {any[]} */
|
||||
const rememberedMetrics = [];
|
||||
|
||||
for (const ref of selected) {
|
||||
const kind = this.refs.kind(ref);
|
||||
if (kind === "fact") {
|
||||
const fact = this.refs.get(ref, "fact");
|
||||
topics.push(label(fact.fact.metric));
|
||||
const metric = await metricByName(fact.fact.metric);
|
||||
if (metric) rememberedMetrics.push(metric);
|
||||
evidence.facts.push(fact.answer);
|
||||
evidence.sources.push(`${fact.fact.path}:${fact.fact.line}`);
|
||||
} else if (kind === "source") {
|
||||
@@ -672,9 +770,14 @@ export class AskToolSession {
|
||||
const guide = this.refs.get(ref, "guide");
|
||||
if (guide.description) evidence.facts.push(guide.description);
|
||||
topics.push(...guide.series.map((/** @type {any} */ series) => label(series.name)));
|
||||
for (const series of guide.series) {
|
||||
const metric = await metricByName(series.name);
|
||||
if (metric) rememberedMetrics.push(metric);
|
||||
}
|
||||
} else if (kind === "metric") {
|
||||
const metric = this.refs.get(ref, "metric");
|
||||
topics.push(label(metric.name));
|
||||
rememberedMetrics.push(metric);
|
||||
const variants = this.focus === "variants"
|
||||
? await metricVariants(metric, this.query)
|
||||
: undefined;
|
||||
@@ -694,7 +797,12 @@ export class AskToolSession {
|
||||
evidence.sources.push(`${this.formula.fact.path}:${this.formula.fact.line}`);
|
||||
}
|
||||
|
||||
this.previousTopics = [...new Set(topics.length ? topics : this.queries)].slice(0, 4);
|
||||
if (rememberedMetrics.length) {
|
||||
this.rememberMetricValues(rememberedMetrics);
|
||||
} else {
|
||||
this.previousMetrics = [];
|
||||
this.previousTopics = [...new Set(topics.length ? topics : this.queries)].slice(0, 4);
|
||||
}
|
||||
return { done: true, output: renderEvidence(evidence) };
|
||||
}
|
||||
|
||||
@@ -716,6 +824,9 @@ export class AskToolSession {
|
||||
buildChart(action) {
|
||||
const selected = uniqueRefs(action.refs);
|
||||
const chosen = selected.map((ref) => this.refs.get(ref, "metric"));
|
||||
const knownMetrics = new Map(
|
||||
[...this.previousMetrics, ...chosen].map((metric) => [metric.path, metric]),
|
||||
);
|
||||
const existingChart = this.outcome === "edit_existing_chart"
|
||||
? this.activeChart
|
||||
: undefined;
|
||||
@@ -744,6 +855,9 @@ export class AskToolSession {
|
||||
series,
|
||||
});
|
||||
this.activeChart = artifact;
|
||||
this.rememberMetricValues(
|
||||
artifact.chart.series.map((item) => knownMetrics.get(item.path)).filter(Boolean),
|
||||
);
|
||||
return {
|
||||
done: true,
|
||||
output: `${existingChart ? "Updated" : "Built"} **${artifact.chart.title}** with ${artifact.chart.series.map((item) => item.label).join(", ")}.`,
|
||||
@@ -762,27 +876,29 @@ export class AskToolSession {
|
||||
const context = requiredString(action.context, "context");
|
||||
const proposed = requiredStrings(action.queries, "queries");
|
||||
const cardinality = requiredString(action.cardinality, "cardinality");
|
||||
const families = requiredStrings(action.families, "families")
|
||||
.filter((family) => family !== "auto");
|
||||
const dependent = this.previousTopics.length > 0 && referencesPrevious(this.request);
|
||||
const effectiveContext = dependent
|
||||
? isExplicitComparison(this.request) ? "extend_previous" : "reuse_previous"
|
||||
: context;
|
||||
const effectiveContext = context;
|
||||
if (effectiveContext !== "new_topic" && !this.previousTopics.length) {
|
||||
throw new Error("There is no previous verified topic to reuse");
|
||||
}
|
||||
const previousTopics = [...this.previousTopics];
|
||||
const previousMetrics = [...this.previousMetrics];
|
||||
const routedQueries = effectiveContext === "reuse_previous"
|
||||
? [...this.previousTopics]
|
||||
? previousTopics
|
||||
: effectiveContext === "extend_previous"
|
||||
? [...new Set([...this.previousTopics, ...proposed])]
|
||||
? [...new Set([...previousTopics, ...proposed])]
|
||||
: proposed;
|
||||
this.contextMetrics = effectiveContext === "new_topic" ? [] : previousMetrics;
|
||||
if (effectiveContext === "new_topic") this.rememberMetricValues([]);
|
||||
this.outcome = requiredString(action.outcome, "outcome");
|
||||
this.focus = evidenceFocus(this.request, requiredString(action.focus, "focus"));
|
||||
if (this.outcome === "answer_general") {
|
||||
return { done: true, general: true };
|
||||
}
|
||||
this.focus = evidenceFocus(this.request, "definition");
|
||||
this.comparison = cardinality === "multiple" || isExplicitComparison(this.request);
|
||||
this.queries = this.comparison
|
||||
? completeComparisonQueries(routedQueries)
|
||||
: routedQueries.slice(0, 1);
|
||||
this.categoryPaths = this.comparison || dependent ? [] : families;
|
||||
this.categoryPaths = [];
|
||||
this.query = this.queries.join(" / ");
|
||||
return await this.search(onStatus) ?? { done: false };
|
||||
}
|
||||
@@ -811,7 +927,6 @@ export class AskToolSession {
|
||||
if (name !== "build_chart" && name !== "edit_chart") {
|
||||
throw new Error("The AI chose an invalid chart action");
|
||||
}
|
||||
this.rememberMetrics(uniqueRefs(action.refs));
|
||||
onStatus("Building chart…");
|
||||
return this.buildChart(action);
|
||||
}
|
||||
@@ -821,9 +936,18 @@ export class AskToolSession {
|
||||
|
||||
/** @param {string[]} refs */
|
||||
rememberMetrics(refs) {
|
||||
this.previousTopics = refs
|
||||
.map((ref) => label(this.refs.get(ref, "metric").name))
|
||||
.filter((topic, index, topics) => topics.indexOf(topic) === index)
|
||||
.slice(0, 4);
|
||||
this.rememberMetricValues(refs.map((ref) => this.refs.get(ref, "metric")));
|
||||
}
|
||||
|
||||
/** @param {any[]} metrics */
|
||||
rememberMetricValues(metrics) {
|
||||
this.previousMetrics = [...new Map(
|
||||
metrics.map((metric) => [metric.path, metric]),
|
||||
).values()].slice(0, 6);
|
||||
this.previousTopics = this.previousMetrics.map((metric) => label(metric.name));
|
||||
}
|
||||
|
||||
metricPaths() {
|
||||
return this.previousMetrics.map((metric) => metric.path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user