mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-23 08:58:11 -07:00
791 lines
28 KiB
JavaScript
791 lines
28 KiB
JavaScript
import { createChartArtifact } from "./chart.js";
|
|
import { readMetric } from "./data.js";
|
|
import { searchLearn } from "./learn.js";
|
|
import {
|
|
metricByName,
|
|
metricCategories,
|
|
metricVariants,
|
|
searchMetrics,
|
|
} from "./metrics/index.js";
|
|
import { ASK_STAGE_PROMPTS } from "./prompts.js";
|
|
import { AskRefs } from "./refs.js";
|
|
import { renderData, renderEvidence } from "./render.js";
|
|
import {
|
|
clarifyTool,
|
|
navigateTool,
|
|
resolveTool,
|
|
rewriteTool,
|
|
searchTool,
|
|
} from "./schemas.js";
|
|
import { normalize } from "./text.js";
|
|
|
|
const MAX_OPTIONS = 12;
|
|
const MAX_CATEGORIES = 24;
|
|
|
|
/** @typedef {import("../storage.js").ChartArtifact} ChartArtifact */
|
|
|
|
/**
|
|
* @typedef {Object} MetricOption
|
|
* @property {string} ref
|
|
* @property {string} label
|
|
* @property {any} metric
|
|
*/
|
|
|
|
/** @param {unknown} value @param {string} name */
|
|
function requiredString(value, name) {
|
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`);
|
|
return value.trim();
|
|
}
|
|
|
|
/** @param {unknown} value @param {string} name */
|
|
function requiredStrings(value, name) {
|
|
if (
|
|
!Array.isArray(value) ||
|
|
!value.length ||
|
|
value.some((item) => typeof item !== "string" || !item.trim())
|
|
) throw new Error(`${name} are required`);
|
|
return [...new Set(value.map((item) => item.trim()))];
|
|
}
|
|
|
|
/** @param {unknown} value */
|
|
function uniqueRefs(value) {
|
|
if (!Array.isArray(value) || !value.length || value.some((ref) => typeof ref !== "string")) {
|
|
throw new Error("One or more returned references are required");
|
|
}
|
|
return [...new Set(value)];
|
|
}
|
|
|
|
/** @param {string} value */
|
|
function label(value) {
|
|
return value.replaceAll("_", " ");
|
|
}
|
|
|
|
/** @param {string} value */
|
|
function isExplicitComparison(value) {
|
|
return /\b(?:vs\.?|versus|compare|compared|comparison)\b/i.test(value);
|
|
}
|
|
|
|
/** @param {string} value */
|
|
function referencesPrevious(value) {
|
|
return /\b(?:it|its|that|this|they|their|them|those|these|same)\b/i.test(value);
|
|
}
|
|
|
|
/** @param {string} request */
|
|
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);
|
|
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;
|
|
}
|
|
|
|
/** @param {string} request @param {string} proposed */
|
|
function evidenceFocus(request, proposed) {
|
|
if (/\b(?:cohorts?|variants?|availability|available)\b/i.test(request)) return "variants";
|
|
if (/\b(?:source|code|implemented?|implementation|calculated?|calculation|formula)\b/i.test(request)) {
|
|
return "implementation";
|
|
}
|
|
return proposed;
|
|
}
|
|
|
|
/** @param {string} request */
|
|
function isDirectDefinition(request) {
|
|
const text = normalize(request);
|
|
const asks = /\b(?:define|explain|meaning)\b/.test(text) ||
|
|
/^(?: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);
|
|
return asks && !needsRouting;
|
|
}
|
|
|
|
/** @param {string} request */
|
|
function directReadAction(request) {
|
|
const block = request.match(/\bblock\s+(\d{4,})\b/i)?.[1];
|
|
const dates = [...request.matchAll(/\b\d{4}-\d{2}-\d{2}\b/g)].map(([date]) => date);
|
|
if (block) return { mode: "at", index: "height", at: block };
|
|
if (dates.length === 1) return { mode: "at", index: "day1", at: dates[0] };
|
|
if (dates.length > 1 || /\b(?:ago|before|after|between|from|last|previous|since|yesterday)\b/i.test(request)) {
|
|
return undefined;
|
|
}
|
|
return { mode: "latest" };
|
|
}
|
|
|
|
/** @param {string[]} queries */
|
|
function completeComparisonQueries(queries) {
|
|
if (queries.length < 3) return queries;
|
|
|
|
const shared = queries.at(-1) ?? "";
|
|
const qualifiers = queries.slice(0, -1);
|
|
if (!qualifiers.every((query) => normalize(query).split(" ").length === 1)) return queries;
|
|
|
|
return qualifiers.map((qualifier) => `${qualifier} ${shared}`);
|
|
}
|
|
|
|
/** @param {any[]} history */
|
|
function latestChart(history) {
|
|
for (const message of [...history].reverse()) {
|
|
const chart = message.artifacts?.findLast?.(
|
|
(/** @type {any} */ artifact) => artifact.type === "chart",
|
|
);
|
|
if (chart) return chart;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** @param {any[]} items */
|
|
function uniqueMetricOptions(items) {
|
|
return [...new Map(items.map((/** @type {any} */ item) => [item.ref, item])).values()];
|
|
}
|
|
|
|
/** @param {any[][]} groups */
|
|
function mergeMetricGroups(groups) {
|
|
const output = [];
|
|
const positions = new Map();
|
|
const ranks = Math.max(...groups.map((group) => group.length), 0);
|
|
|
|
for (let rank = 0; rank < ranks && output.length < MAX_OPTIONS; rank += 1) {
|
|
for (const group of groups) {
|
|
const metric = group[rank];
|
|
if (!metric) continue;
|
|
|
|
const position = positions.get(metric.path);
|
|
if (position !== undefined) {
|
|
const current = output[position];
|
|
const exact = normalize(metric.name) === normalize(metric.matchedQuery ?? "");
|
|
const currentExact = normalize(current.name) === normalize(current.matchedQuery ?? "");
|
|
if (exact && !currentExact) output[position] = metric;
|
|
continue;
|
|
}
|
|
positions.set(metric.path, output.length);
|
|
output.push(metric);
|
|
if (output.length === MAX_OPTIONS) break;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
/** @param {any[]} items */
|
|
function balancedOptions(items) {
|
|
const order = ["fact", "guide", "metric", "source"];
|
|
const groups = order
|
|
.map((kind) => items
|
|
.filter((item) => item.kind === kind)
|
|
.sort((left, right) => right.score - left.score))
|
|
.filter((group) => group.length);
|
|
const output = [];
|
|
|
|
for (let rank = 0; output.length < MAX_OPTIONS; rank += 1) {
|
|
let added = false;
|
|
for (const group of groups) {
|
|
const item = group[rank];
|
|
if (!item) continue;
|
|
const { score, ...option } = item;
|
|
output.push(option);
|
|
added = true;
|
|
if (output.length === MAX_OPTIONS) break;
|
|
}
|
|
if (!added) break;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export class AskToolSession {
|
|
/** @param {import("./source/index.js").AskSource} source */
|
|
constructor(source) {
|
|
this.source = source;
|
|
}
|
|
|
|
source;
|
|
refs = new AskRefs();
|
|
request = "";
|
|
/** @type {string[]} */
|
|
previousTopics = [];
|
|
query = "";
|
|
/** @type {string[]} */
|
|
queries = [];
|
|
outcome = "";
|
|
focus = "definition";
|
|
stage = "search";
|
|
directMatch = false;
|
|
rewritten = false;
|
|
rewriteChanged = false;
|
|
comparison = false;
|
|
/** @type {string[]} */
|
|
categoryPaths = [];
|
|
/** @type {any} */
|
|
observation;
|
|
/** @type {any[]} */
|
|
options = [];
|
|
/** @type {MetricOption[]} */
|
|
metricOptions = [];
|
|
/** @type {any} */
|
|
formula;
|
|
sourceSearched = false;
|
|
/** @type {ChartArtifact | undefined} */
|
|
activeChart;
|
|
|
|
/** @param {string} request @param {any[]} history */
|
|
begin(request, history) {
|
|
this.refs = new AskRefs();
|
|
this.request = request.trim();
|
|
this.query = "";
|
|
this.queries = [];
|
|
this.outcome = "";
|
|
this.focus = "definition";
|
|
this.stage = "search";
|
|
this.directMatch = false;
|
|
this.rewritten = false;
|
|
this.rewriteChanged = false;
|
|
this.comparison = false;
|
|
this.categoryPaths = [];
|
|
this.observation = undefined;
|
|
this.options = [];
|
|
this.metricOptions = [];
|
|
this.formula = undefined;
|
|
this.sourceSearched = false;
|
|
this.activeChart ??= latestChart(history);
|
|
}
|
|
|
|
/** @param {(status: string) => void} onStatus */
|
|
async tryDirect(onStatus) {
|
|
if (this.previousTopics.length === 1 && isDirectValueFollowup(this.request)) {
|
|
const action = directReadAction(this.request);
|
|
if (action) {
|
|
const topic = this.previousTopics[0];
|
|
const metrics = (await searchMetrics(
|
|
[topic],
|
|
3,
|
|
[],
|
|
() => onStatus("Indexing metrics…"),
|
|
)).filter((metric) => normalize(metric.name) === normalize(topic));
|
|
|
|
if (metrics.length === 1) {
|
|
onStatus("Reading data…");
|
|
const result = await readMetric(metrics[0], action);
|
|
this.previousTopics = [label(metrics[0].name)];
|
|
return { output: renderData([result]), artifacts: [] };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isDirectDefinition(this.request)) return undefined;
|
|
|
|
const [metric] = await searchMetrics(
|
|
[this.request],
|
|
1,
|
|
[],
|
|
() => onStatus("Indexing metrics…"),
|
|
);
|
|
if (!metric) return undefined;
|
|
|
|
onStatus("Searching source…");
|
|
const formula = await this.source.explain(
|
|
`${this.request}\n${metric.name}`,
|
|
({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`),
|
|
);
|
|
if (!formula || normalize(formula.fact.metric) !== normalize(metric.name)) return undefined;
|
|
|
|
this.previousTopics = [label(formula.fact.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);
|
|
if (this.stage === "navigate") return navigateTool(this.options);
|
|
if (this.stage === "resolve") {
|
|
const maxItems = this.formula
|
|
? 1
|
|
: this.outcome === "explain_from_verified_facts"
|
|
? Math.min(this.queries.length, 3)
|
|
: this.comparison
|
|
? this.outcome === "read_requested_value" ? 3 : 6
|
|
: 1;
|
|
const options = this.outcome === "explain_from_verified_facts"
|
|
? this.options
|
|
: this.metricOptions;
|
|
return resolveTool(options, this.outcome, Math.max(1, maxItems));
|
|
}
|
|
return clarifyTool();
|
|
}
|
|
|
|
instruction() {
|
|
if (this.stage === "search") {
|
|
const previous = this.previousTopics.length
|
|
? `\nPrevious verified topic${this.previousTopics.length === 1 ? "" : "s"}: ${this.previousTopics.join(", ")}. Reuse only when the newest request depends on it.`
|
|
: "";
|
|
const chart = this.activeChart
|
|
? `\nActive chart: ${this.activeChart.chart.title}; series: ${this.activeChart.chart.series.map((item) => item.label).join(", ")}.`
|
|
: "";
|
|
return `${ASK_STAGE_PROMPTS.search}${previous}${chart}`;
|
|
}
|
|
if (this.stage === "navigate") return ASK_STAGE_PROMPTS.navigate;
|
|
if (this.stage === "rewrite") return ASK_STAGE_PROMPTS.rewrite;
|
|
if (this.stage === "resolve") {
|
|
if (this.outcome === "explain_from_verified_facts") {
|
|
return this.directMatch
|
|
? `${ASK_STAGE_PROMPTS.explain}\nA trusted direct match exists. Use only recommendedRefs.`
|
|
: ASK_STAGE_PROMPTS.explain;
|
|
}
|
|
if (this.outcome === "read_requested_value") return ASK_STAGE_PROMPTS.read;
|
|
return this.outcome === "edit_existing_chart"
|
|
? ASK_STAGE_PROMPTS.editChart
|
|
: ASK_STAGE_PROMPTS.chart;
|
|
}
|
|
return ASK_STAGE_PROMPTS.clarify;
|
|
}
|
|
|
|
/** @param {(status: string) => void} onStatus */
|
|
async search(onStatus) {
|
|
const [globalMetrics, rawMetrics, scopedMetrics, guideGroups] = await Promise.all([
|
|
searchMetrics(
|
|
this.queries,
|
|
MAX_OPTIONS,
|
|
[],
|
|
() => onStatus("Indexing metrics…"),
|
|
),
|
|
searchMetrics(
|
|
[this.request],
|
|
MAX_OPTIONS,
|
|
[],
|
|
() => 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 metricNames = new Set(metrics.map((metric) => metric.name));
|
|
const guides = [...new Map(
|
|
guideGroups.flat().map((/** @type {any} */ guide) => [guide.breadcrumbs.join("/"), guide]),
|
|
).values()].filter((/** @type {any} */ guide) =>
|
|
this.outcome === "explain_from_verified_facts" ||
|
|
guide.series.some((/** @type {any} */ series) => metricNames.has(series.name))
|
|
);
|
|
/** @type {any[]} */
|
|
let sources = [];
|
|
|
|
if (
|
|
this.outcome === "explain_from_verified_facts" &&
|
|
this.focus !== "variants" &&
|
|
!this.sourceSearched
|
|
) {
|
|
this.sourceSearched = true;
|
|
onStatus("Searching source…");
|
|
const originalMetric = foundMetrics.find((metric) =>
|
|
normalize(metric.matchedQuery ?? "") === normalize(this.request)
|
|
);
|
|
this.formula = await this.source.explain(
|
|
[this.request, originalMetric?.name, ...this.queries].filter(Boolean).join("\n"),
|
|
({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`),
|
|
);
|
|
if (!this.formula) {
|
|
sources = (await this.source.search(
|
|
this.queries.join(" "),
|
|
undefined,
|
|
({ loaded, total }) => onStatus(`Indexing source · ${loaded} / ${total}`),
|
|
)).matches;
|
|
} else {
|
|
const formulaMetric = await metricByName(this.formula.fact.metric);
|
|
if (formulaMetric && !metrics.some((metric) => metric.path === formulaMetric.path)) {
|
|
metrics.unshift({
|
|
...formulaMetric,
|
|
matchedQuery: label(this.formula.fact.metric),
|
|
score: 2_000,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** @type {any[]} */
|
|
const ranked = [];
|
|
for (const metric of metrics) {
|
|
const ref = this.refs.issue("metric", metric, metric.path);
|
|
ranked.push({
|
|
ref,
|
|
kind: "metric",
|
|
label: label(metric.name),
|
|
detail: metric.path,
|
|
matchedQuery: metric.matchedQuery,
|
|
score: metric.score,
|
|
});
|
|
}
|
|
for (const guide of guides) {
|
|
const key = guide.breadcrumbs.join("/");
|
|
const ref = this.refs.issue("guide", guide, key);
|
|
ranked.push({
|
|
ref,
|
|
kind: "guide",
|
|
label: guide.title,
|
|
detail: guide.description,
|
|
score: guide.score,
|
|
});
|
|
}
|
|
for (const source of sources) {
|
|
const ref = this.refs.issue("source", source, `${source.path}:${source.startLine}`);
|
|
ranked.push({
|
|
ref,
|
|
kind: "source",
|
|
label: `${source.path}:${source.startLine}`,
|
|
detail: source.content,
|
|
score: source.score,
|
|
});
|
|
}
|
|
if (this.formula && !metrics.length) {
|
|
const ref = this.refs.issue("fact", this.formula, this.formula.fact.metric);
|
|
ranked.push({
|
|
ref,
|
|
kind: "fact",
|
|
label: label(this.formula.fact.metric),
|
|
detail: this.formula.answer,
|
|
score: 1_000,
|
|
});
|
|
}
|
|
|
|
this.options = balancedOptions(ranked);
|
|
if (!this.options.length) {
|
|
this.stage = "clarify";
|
|
this.observation = { noMatch: true };
|
|
return;
|
|
}
|
|
|
|
const normalizedQueries = this.queries.map(normalize);
|
|
const exactCandidates = this.options.filter((option) =>
|
|
option.kind === "metric" &&
|
|
normalize(option.label) === normalize(option.matchedQuery ?? "")
|
|
);
|
|
const exact = exactCandidates.filter((option) => {
|
|
const optionLabel = normalize(option.label);
|
|
return !exactCandidates.some((candidate) => {
|
|
const candidateLabel = normalize(candidate.label);
|
|
return candidateLabel !== optionLabel && candidateLabel.includes(optionLabel);
|
|
});
|
|
});
|
|
const exactByQuery = normalizedQueries.map((query) =>
|
|
exact.filter((option) => normalize(option.matchedQuery ?? "") === query)
|
|
);
|
|
this.directMatch = exactByQuery.every((matches) => matches.length === 1);
|
|
const directOptions = exactByQuery.flat();
|
|
const trusted = this.directMatch ||
|
|
this.comparison && metrics.length > 0 ||
|
|
this.rewritten && this.rewriteChanged && metrics.length > 0 ||
|
|
Boolean(this.formula) ||
|
|
sources.length > 0 ||
|
|
this.outcome !== "explain_from_verified_facts" && guides.length > 0;
|
|
if (!trusted && !this.categoryPaths.length) {
|
|
if (!this.rewritten) {
|
|
this.stage = "rewrite";
|
|
this.observation = { unmatchedQueries: this.queries };
|
|
return;
|
|
}
|
|
this.options = (await metricCategories()).slice(0, MAX_CATEGORIES).map((category) => ({
|
|
ref: this.refs.issue("category", category, category.path),
|
|
kind: "category",
|
|
label: category.label,
|
|
detail: `${category.count} series; ${category.examples.join(", ")}`,
|
|
}));
|
|
this.stage = "navigate";
|
|
this.observation = { options: this.options };
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.directMatch &&
|
|
this.outcome === "explain_from_verified_facts" &&
|
|
this.focus === "variants"
|
|
) {
|
|
onStatus("Inspecting results…");
|
|
return this.inspect(directOptions.map(({ ref }) => ref));
|
|
}
|
|
if (this.directMatch && this.outcome === "read_requested_value") {
|
|
const action = directReadAction(this.request);
|
|
if (action) {
|
|
const refs = directOptions.map(({ ref }) => ref);
|
|
this.rememberMetrics(refs);
|
|
onStatus("Reading data…");
|
|
return this.read({ refs, ...action });
|
|
}
|
|
}
|
|
if (this.directMatch && this.outcome === "build_requested_chart") {
|
|
const refs = directOptions.map(({ ref }) => ref);
|
|
this.rememberMetrics(refs);
|
|
onStatus("Building chart…");
|
|
return this.buildChart({ refs });
|
|
}
|
|
|
|
this.stage = "resolve";
|
|
const formulaOption = this.formula
|
|
? this.options.find((option) =>
|
|
option.kind === "metric" &&
|
|
normalize(option.label) === normalize(this.formula.fact.metric)
|
|
)
|
|
: undefined;
|
|
if (this.outcome === "explain_from_verified_facts") {
|
|
if (formulaOption) return this.inspect([formulaOption.ref]);
|
|
|
|
this.observation = {
|
|
recommendedRefs: (directOptions.length ? directOptions : this.options)
|
|
.slice(0, 3)
|
|
.map(({ ref }) => ref),
|
|
options: this.options,
|
|
};
|
|
return;
|
|
}
|
|
|
|
await this.prepareMetricOptions();
|
|
}
|
|
|
|
/** @param {string[]} queries @param {(status: string) => void} onStatus */
|
|
async rewrite(queries, onStatus) {
|
|
this.rewriteChanged = queries.length !== this.queries.length ||
|
|
queries.some((query, index) => normalize(query) !== normalize(this.queries[index] ?? ""));
|
|
this.queries = queries;
|
|
this.query = queries.join(" / ");
|
|
this.rewritten = true;
|
|
return await this.search(onStatus) ?? { done: false };
|
|
}
|
|
|
|
/** @param {string[]} selected @param {(status: string) => void} onStatus */
|
|
async navigate(selected, onStatus) {
|
|
const categories = selected.map((ref) => this.refs.get(ref, "category"));
|
|
this.categoryPaths = categories.map((category) => category.path);
|
|
const prefix = categories.map((category) => category.label).join(" ");
|
|
this.queries = this.queries.map((query) => `${prefix} ${query}`);
|
|
this.query = this.queries.join(" / ");
|
|
return await this.search(onStatus) ?? { done: false };
|
|
}
|
|
|
|
async prepareMetricOptions() {
|
|
/** @type {MetricOption[]} */
|
|
const metrics = [];
|
|
|
|
for (const option of this.options) {
|
|
const kind = this.refs.kind(option.ref);
|
|
if (kind === "metric") {
|
|
const metric = this.refs.get(option.ref, "metric");
|
|
metrics.push({ ref: option.ref, label: metric.label ?? label(metric.name), metric });
|
|
} else if (kind === "guide") {
|
|
const guide = this.refs.get(option.ref, "guide");
|
|
for (const series of guide.series) {
|
|
const metric = await metricByName(series.name);
|
|
if (!metric) continue;
|
|
const value = { ...metric, label: series.label };
|
|
const ref = this.refs.issue("metric", value, metric.path);
|
|
metrics.push({ ref, label: series.label, metric: value });
|
|
}
|
|
}
|
|
}
|
|
|
|
this.metricOptions = uniqueMetricOptions(metrics).slice(0, MAX_OPTIONS);
|
|
if (!this.metricOptions.length) {
|
|
this.stage = "clarify";
|
|
this.observation = { noMetric: true };
|
|
return;
|
|
}
|
|
this.observation = {
|
|
verifiedMetrics: this.metricOptions.map(({ ref, label: metricLabel, metric }) => ({
|
|
ref,
|
|
label: metricLabel,
|
|
path: metric.path,
|
|
unit: metric.suggestedUnit,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** @param {string[]} selected */
|
|
async inspect(selected) {
|
|
const evidence = {
|
|
facts: /** @type {string[]} */ ([]),
|
|
sources: /** @type {string[]} */ ([]),
|
|
excerpts: /** @type {any[]} */ ([]),
|
|
};
|
|
/** @type {string[]} */
|
|
const topics = [];
|
|
|
|
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));
|
|
evidence.facts.push(fact.answer);
|
|
evidence.sources.push(`${fact.fact.path}:${fact.fact.line}`);
|
|
} else if (kind === "source") {
|
|
const match = this.refs.get(ref, "source");
|
|
const excerpt = await this.source.read(match.path, match.startLine, match.endLine);
|
|
evidence.excerpts.push(excerpt);
|
|
evidence.sources.push(`${excerpt.path}:${excerpt.startLine}-${excerpt.endLine}`);
|
|
} else if (kind === "guide") {
|
|
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)));
|
|
} else if (kind === "metric") {
|
|
const metric = this.refs.get(ref, "metric");
|
|
topics.push(label(metric.name));
|
|
const variants = this.focus === "variants"
|
|
? await metricVariants(metric, this.query)
|
|
: undefined;
|
|
if (variants) {
|
|
const groups = variants.groups
|
|
.map((group) => `${group.family}: ${group.examples.join(", ")}`)
|
|
.join("; ");
|
|
evidence.facts.push(
|
|
`${label(metric.name)} has ${variants.totalSeries} available series. Cohorts: ${groups}.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.formula && selected.some((ref) => this.refs.kind(ref) === "metric")) {
|
|
evidence.facts.unshift(this.formula.answer);
|
|
evidence.sources.push(`${this.formula.fact.path}:${this.formula.fact.line}`);
|
|
}
|
|
|
|
this.previousTopics = [...new Set(topics.length ? topics : this.queries)].slice(0, 4);
|
|
return { done: true, output: renderEvidence(evidence) };
|
|
}
|
|
|
|
/** @param {Record<string, unknown>} action */
|
|
async read(action) {
|
|
const selected = uniqueRefs(action.refs);
|
|
const block = this.request.match(/\b(?:block\s*)?(\d{4,})\b/i)?.[1];
|
|
const date = this.request.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0];
|
|
const request = action.mode === "at" && action.at === undefined
|
|
? { ...action, at: block ?? date }
|
|
: action;
|
|
const results = await Promise.all(
|
|
selected.map((ref) => readMetric(this.refs.get(ref, "metric"), request)),
|
|
);
|
|
return { done: true, output: renderData(results) };
|
|
}
|
|
|
|
/** @param {Record<string, unknown>} action */
|
|
buildChart(action) {
|
|
const selected = uniqueRefs(action.refs);
|
|
const chosen = selected.map((ref) => this.refs.get(ref, "metric"));
|
|
const existingChart = this.outcome === "edit_existing_chart"
|
|
? this.activeChart
|
|
: undefined;
|
|
const operation = typeof action.operation === "string" ? action.operation : "add";
|
|
const added = chosen.map((/** @type {any} */ metric) => ({
|
|
path: metric.path,
|
|
label: metric.label ?? label(metric.name),
|
|
}));
|
|
const prior = existingChart?.chart.series ?? [];
|
|
const paths = new Set(added.map(({ path }) => path));
|
|
const series = !existingChart || operation === "replace"
|
|
? added
|
|
: operation === "remove"
|
|
? prior.filter((item) => !paths.has(item.path))
|
|
: [...prior, ...added.filter((item) => !prior.some((old) => old.path === item.path))];
|
|
if (!series.length) throw new Error("A chart needs at least one series");
|
|
|
|
const inferredUnit = chosen.map((metric) => metric.suggestedUnit).find(Boolean);
|
|
const artifact = createChartArtifact({
|
|
title: typeof action.title === "string" && action.title.trim()
|
|
? action.title.trim()
|
|
: existingChart
|
|
? existingChart.chart.title
|
|
: chosen.map((metric) => metric.label ?? label(metric.name)).join(" and "),
|
|
unit: existingChart ? existingChart.chart.unit : inferredUnit,
|
|
series,
|
|
});
|
|
this.activeChart = artifact;
|
|
return {
|
|
done: true,
|
|
output: `${existingChart ? "Updated" : "Built"} **${artifact.chart.title}** with ${artifact.chart.series.map((item) => item.label).join(", ")}.`,
|
|
artifacts: [artifact],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} action
|
|
* @param {(status: string) => void} onStatus
|
|
*/
|
|
async execute(action, onStatus) {
|
|
const name = requiredString(action.action, "action");
|
|
if (this.stage === "search") {
|
|
if (name !== "search") throw new Error("The AI chose an invalid search action");
|
|
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;
|
|
if (effectiveContext !== "new_topic" && !this.previousTopics.length) {
|
|
throw new Error("There is no previous verified topic to reuse");
|
|
}
|
|
const routedQueries = effectiveContext === "reuse_previous"
|
|
? [...this.previousTopics]
|
|
: effectiveContext === "extend_previous"
|
|
? [...new Set([...this.previousTopics, ...proposed])]
|
|
: proposed;
|
|
this.outcome = requiredString(action.outcome, "outcome");
|
|
this.focus = evidenceFocus(this.request, requiredString(action.focus, "focus"));
|
|
this.comparison = cardinality === "multiple" || isExplicitComparison(this.request);
|
|
this.queries = this.comparison
|
|
? completeComparisonQueries(routedQueries)
|
|
: routedQueries.slice(0, 1);
|
|
this.categoryPaths = this.comparison || dependent ? [] : families;
|
|
this.query = this.queries.join(" / ");
|
|
return await this.search(onStatus) ?? { done: false };
|
|
}
|
|
if (this.stage === "resolve" && this.outcome === "explain_from_verified_facts") {
|
|
if (name !== "answer") throw new Error("The AI chose an invalid evidence action");
|
|
onStatus("Inspecting results…");
|
|
return this.inspect(uniqueRefs(action.refs));
|
|
}
|
|
if (this.stage === "navigate") {
|
|
if (name !== "navigate") throw new Error("The AI chose an invalid navigation action");
|
|
onStatus("Narrowing search…");
|
|
return this.navigate(uniqueRefs(action.refs), onStatus);
|
|
}
|
|
if (this.stage === "rewrite") {
|
|
if (name !== "rewrite") throw new Error("The AI chose an invalid rewrite action");
|
|
onStatus("Refining search…");
|
|
return this.rewrite(requiredStrings(action.queries, "queries"), onStatus);
|
|
}
|
|
if (this.stage === "resolve" && this.outcome === "read_requested_value") {
|
|
if (name !== "read_data") throw new Error("The AI chose an invalid data action");
|
|
this.rememberMetrics(uniqueRefs(action.refs));
|
|
onStatus("Reading data…");
|
|
return this.read(action);
|
|
}
|
|
if (this.stage === "resolve") {
|
|
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);
|
|
}
|
|
if (name !== "clarify") throw new Error("The AI chose an invalid clarification action");
|
|
return { done: true, output: requiredString(action.text, "text") };
|
|
}
|
|
|
|
/** @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);
|
|
}
|
|
}
|