diff --git a/website_next/ask/index.js b/website_next/ask/index.js index 46dc70b9c..c329204fe 100644 --- a/website_next/ask/index.js +++ b/website_next/ask/index.js @@ -303,18 +303,22 @@ export function createAskPage() { try { timer.set("Preparing context"); - const prepared = await prepareContext( - draft, - model, - assistant.toolsFor(), - () => timer.set("Compacting conversation"), - ); - timer.set("Routing request"); - const { output, artifacts } = await assistant.answer({ + const { output, artifacts, metricPaths, chat: preparedChat } = await assistant.answer({ chatId: chat.id, - history: prepared.chat.messages, + question, + history: draft.messages, model, - messages: prepared.messages, + async prepare() { + timer.set("Preparing context"); + const prepared = await prepareContext( + draft, + model, + assistant.toolsFor(), + () => timer.set("Compacting conversation"), + ); + timer.set("Routing request"); + return prepared; + }, onToken({ text }) { answerMessage.content.append(text); if (followingOutput) conversation.scrollToBottom(); @@ -330,15 +334,17 @@ export function createAskPage() { answerMessage.setArtifacts(artifacts); answerMessage.setElapsed(elapsedMs); if (followingOutput) conversation.scrollToBottom(); + const answeredChat = preparedChat ?? draft; workspace = askStorage.save({ - ...prepared.chat, + ...answeredChat, messages: [ - ...prepared.chat.messages, + ...answeredChat.messages, { role: /** @type {const} */ ("assistant"), content: output, elapsedMs, steps, + metricPaths, ...(artifacts.length ? { artifacts } : {}), }, ], diff --git a/website_next/ask/storage.js b/website_next/ask/storage.js index 9d32d450f..bb885c40e 100644 --- a/website_next/ask/storage.js +++ b/website_next/ask/storage.js @@ -35,6 +35,7 @@ const CHART_COLORS = new Set([ * @property {number} [elapsedMs] * @property {StoredResponseStep[]} [steps] * @property {StoredArtifact[]} [artifacts] + * @property {string[]} [metricPaths] * * @typedef {Object} StoredResponseStep * @property {string} label @@ -165,12 +166,20 @@ function readMessage(value) { message.steps.map(readResponseStep).filter(Boolean) ) : []; + const rawMetricPaths = message.metricPaths; + const hasMetricPaths = Array.isArray(rawMetricPaths); + const metricPaths = hasMetricPaths + ? [...new Set(/** @type {string[]} */ (rawMetricPaths.filter( + (/** @type {unknown} */ path) => typeof path === "string" && path.trim(), + )))].slice(0, 6) + : []; return { role: message.role, content: message.content, ...(elapsedMs !== undefined ? { elapsedMs } : {}), ...(steps.length ? { steps } : {}), ...(artifacts.length ? { artifacts } : {}), + ...(hasMetricPaths ? { metricPaths } : {}), }; } diff --git a/website_next/ask/tools/direct/evidence.js b/website_next/ask/tools/direct/evidence.js new file mode 100644 index 000000000..2fad40d55 --- /dev/null +++ b/website_next/ask/tools/direct/evidence.js @@ -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; +} diff --git a/website_next/ask/tools/index.js b/website_next/ask/tools/index.js index 432538283..9d97e47c8 100644 --- a/website_next/ask/tools/index.js +++ b/website_next/ask/tools/index.js @@ -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} */ @@ -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."); diff --git a/website_next/ask/tools/metrics/index.js b/website_next/ask/tools/metrics/index.js index 278acd26f..0f3d07e32 100644 --- a/website_next/ask/tools/metrics/index.js +++ b/website_next/ask/tools/metrics/index.js @@ -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 void, reject: (error: Error) => void, onProgress?: () => void }>} */ #pending = new Map(); - /** @param {"search" | "mentions" | "categories" | "byName" | "variants"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ + /** @param {"search" | "mentions" | "categories" | "byName" | "byPaths" | "variants"} type @param {Record} 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} */ 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} */ @@ -138,6 +143,11 @@ export function metricByName(name) { return index.request("byName", { name }); } +/** @param {string[]} paths @param {(() => void) | undefined} [onProgress] @returns {Promise} */ +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 }); diff --git a/website_next/ask/tools/metrics/language.js b/website_next/ask/tools/metrics/language.js new file mode 100644 index 000000000..3e8534b34 --- /dev/null +++ b/website_next/ask/tools/metrics/language.js @@ -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]; + }))]; +} diff --git a/website_next/ask/tools/metrics/worker.js b/website_next/ask/tools/metrics/worker.js index b93bb6c5f..6ce96d2de 100644 --- a/website_next/ask/tools/metrics/worker.js +++ b/website_next/ask/tools/metrics/worker.js @@ -56,6 +56,8 @@ function buildState() { const items = []; /** @type {Map} */ const byName = new Map(); + /** @type {Map} */ + const byPath = new Map(); /** @type {Map} */ const bySearchableName = new Map(); /** @type {Map} */ @@ -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} */ const scoped = new Map(); - return { items, byName, bySearchableName, byDocument, matcher, config, scoped }; + return { items, byName, byPath, bySearchableName, byDocument, matcher, config, scoped }; } /** @type {Promise> | 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 { diff --git a/website_next/ask/tools/prompts.js b/website_next/ask/tools/prompts.js index 4ed9f7cf1..add5440ed 100644 --- a/website_next/ask/tools/prompts.js +++ b/website_next/ask/tools/prompts.js @@ -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} diff --git a/website_next/ask/tools/schemas.js b/website_next/ask/tools/schemas.js index b42f8dea4..f9bf6395e 100644 --- a/website_next/ask/tools/schemas.js +++ b/website_next/ask/tools/schemas.js @@ -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"]); } diff --git a/website_next/ask/tools/session.js b/website_next/ask/tools/session.js index 4bf1c7a3a..c27d5b07b 100644 --- a/website_next/ask/tools/session.js +++ b/website_next/ask/tools/session.js @@ -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); } }