From f0871d895a5a0ecebb88e97c6c3bb9bd689e482d Mon Sep 17 00:00:00 2001 From: nym21 Date: Wed, 22 Jul 2026 16:50:32 +0200 Subject: [PATCH] next: ai part 3 --- website_next/ask/context.js | 18 +- website_next/ask/conversation/index.js | 15 +- .../ask/conversation/message/chart/index.js | 32 + .../ask/conversation/message/chart/style.css | 20 + .../ask/conversation/message/index.js | 74 +- .../ask/conversation/message/style.css | 26 + website_next/ask/index.js | 41 +- website_next/ask/model.js | 37 +- website_next/ask/models.js | 24 +- website_next/ask/storage.js | 151 +++- website_next/ask/timing.js | 68 ++ website_next/ask/tools/chart.js | 66 ++ website_next/ask/tools/data.js | 107 +++ website_next/ask/tools/index.js | 118 +++ website_next/ask/tools/learn.js | 66 ++ website_next/ask/tools/metrics/index.js | 143 ++++ website_next/ask/tools/metrics/worker.js | 322 +++++++ website_next/ask/tools/parse.js | 51 ++ website_next/ask/tools/prompts.js | 45 + website_next/ask/tools/refs.js | 53 ++ website_next/ask/tools/render.js | 45 + website_next/ask/tools/schemas.js | 145 ++++ website_next/ask/tools/session.js | 790 ++++++++++++++++++ website_next/ask/tools/source/formula.js | 167 ++++ website_next/ask/tools/source/index.js | 91 ++ website_next/ask/tools/source/worker.js | 353 ++++++++ website_next/ask/tools/text.js | 52 ++ website_next/ask/worker.js | 229 ++--- website_next/index.html | 1 + 29 files changed, 3207 insertions(+), 143 deletions(-) create mode 100644 website_next/ask/conversation/message/chart/index.js create mode 100644 website_next/ask/conversation/message/chart/style.css create mode 100644 website_next/ask/timing.js create mode 100644 website_next/ask/tools/chart.js create mode 100644 website_next/ask/tools/data.js create mode 100644 website_next/ask/tools/index.js create mode 100644 website_next/ask/tools/learn.js create mode 100644 website_next/ask/tools/metrics/index.js create mode 100644 website_next/ask/tools/metrics/worker.js create mode 100644 website_next/ask/tools/parse.js create mode 100644 website_next/ask/tools/prompts.js create mode 100644 website_next/ask/tools/refs.js create mode 100644 website_next/ask/tools/render.js create mode 100644 website_next/ask/tools/schemas.js create mode 100644 website_next/ask/tools/session.js create mode 100644 website_next/ask/tools/source/formula.js create mode 100644 website_next/ask/tools/source/index.js create mode 100644 website_next/ask/tools/source/worker.js create mode 100644 website_next/ask/tools/text.js diff --git a/website_next/ask/context.js b/website_next/ask/context.js index 7cff87495..623e3aa64 100644 --- a/website_next/ask/context.js +++ b/website_next/ask/context.js @@ -1,8 +1,12 @@ -const MAX_INPUT_TOKENS = 5_600; +const MAX_INPUT_TOKENS = 2_200; const KEEP_RECENT_MESSAGES = 5; const BASE_INSTRUCTIONS = - "You are a helpful assistant. Answer clearly and concisely."; + `You are the Bitview assistant for the BRK Bitcoin research project. +Answer clearly and concisely. +For BRK questions, current repository evidence supplied by tools is authoritative. Use only that evidence for source-defined behavior, APIs, terminology, and metrics. Mention relevant repository paths. +In BRK source evidence, sats means Bitcoin satoshis, not products, sales, or shares. Do not reinterpret formulas or add financial meaning that the evidence does not establish. +Use metric search before building charts. Build an inline chart when it materially helps answer a quantitative Bitcoin question.`; const MEMORY_INSTRUCTIONS = `You maintain compact memory for a conversation. Merge the existing memory with the newly provided dialogue. @@ -21,7 +25,10 @@ function messagesFor(chat) { return [ { role: /** @type {const} */ ("system"), content: instructions }, - ...chat.messages.slice(chat.compactedCount), + ...chat.messages.slice(chat.compactedCount).map(({ role, content }) => ({ + role, + content, + })), ]; } @@ -46,11 +53,12 @@ function compactionPrompt(memory, messages) { /** * @param {StoredChat} chat * @param {AskModel} model + * @param {readonly unknown[]} tools * @param {() => void} onCompacting */ -export async function prepareContext(chat, model, onCompacting) { +export async function prepareContext(chat, model, tools, onCompacting) { const messages = messagesFor(chat); - const tokenCount = await model.countTokens(messages); + const tokenCount = await model.countTokens(messages, tools); const compactThrough = chat.messages.length - KEEP_RECENT_MESSAGES; if ( diff --git a/website_next/ask/conversation/index.js b/website_next/ask/conversation/index.js index b12c4f9d8..10d0a2bbd 100644 --- a/website_next/ask/conversation/index.js +++ b/website_next/ask/conversation/index.js @@ -50,9 +50,10 @@ export function createAskConversation(options) { /** * @param {"user" | "assistant"} role * @param {string} text + * @param {import("../storage.js").StoredArtifact[]} [artifacts] */ - append(role, text) { - const message = createAskMessage(role, text); + append(role, text, artifacts = []) { + const message = createAskMessage(role, text, { artifacts }); transcript.append(message.item); sync(); @@ -70,7 +71,15 @@ export function createAskConversation(options) { render(messages) { transcript.replaceChildren( ...messages.map((message) => - createAskMessage(message.role, message.content).item + createAskMessage( + message.role, + message.content, + { + artifacts: message.artifacts, + elapsedMs: message.elapsedMs, + steps: message.steps, + }, + ).item ), ); sync(); diff --git a/website_next/ask/conversation/message/chart/index.js b/website_next/ask/conversation/message/chart/index.js new file mode 100644 index 000000000..57421d636 --- /dev/null +++ b/website_next/ask/conversation/message/chart/index.js @@ -0,0 +1,32 @@ +import { createChart } from "../../../../chart/index.js"; +import { units } from "../../../../chart/units.js"; +import { colors } from "../../../../utils/colors.js"; +import { createMetric } from "../../../tools/metrics/index.js"; + +/** @typedef {import("../../../storage.js").ChartArtifact} ChartArtifact */ + +/** @param {ChartArtifact} artifact */ +export function createAskChart(artifact) { + const section = document.createElement("section"); + const spec = artifact.chart; + + section.dataset.askChart = ""; + try { + const chart = { + title: spec.title, + unit: units[spec.unit], + defaultType: spec.view, + defaultScale: spec.scale, + series: spec.series.map((item) => ({ + label: item.label, + color: colors[item.color], + metric: createMetric(item.path), + })), + }; + section.append(createChart(chart, `ask-${artifact.id}`)); + } catch { + section.dataset.state = "unavailable"; + section.textContent = "Chart unavailable"; + } + return section; +} diff --git a/website_next/ask/conversation/message/chart/style.css b/website_next/ask/conversation/message/chart/style.css new file mode 100644 index 000000000..d2614e60c --- /dev/null +++ b/website_next/ask/conversation/message/chart/style.css @@ -0,0 +1,20 @@ +main[data-page="ask"] [data-ask-chart] { + margin-top: 1rem; + + &[data-state="unavailable"] { + color: var(--gray); + } + + figure[data-chart="series"] { + --chart-plot-height: 16rem; + --chart-reserved-ui-height: 5rem; + + margin: 0; + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] [data-ask-chart] figure[data-chart="series"] { + --chart-plot-height: 13rem; + } +} diff --git a/website_next/ask/conversation/message/index.js b/website_next/ask/conversation/message/index.js index 3f6d1cb60..4157faac3 100644 --- a/website_next/ask/conversation/message/index.js +++ b/website_next/ask/conversation/message/index.js @@ -1,4 +1,6 @@ import { renderMarkdown } from "../../markdown.js"; +import { formatDuration } from "../../timing.js"; +import { createAskChart } from "./chart/index.js"; /** @typedef {"user" | "assistant"} MessageRole */ @@ -15,16 +17,84 @@ export function setMessageContent(content, role, text) { /** * @param {MessageRole} role * @param {string} text + * @param {Object} [metadata] + * @param {import("../../storage.js").StoredArtifact[]} [metadata.artifacts] + * @param {number} [metadata.elapsedMs] + * @param {import("../../timing.js").ResponseStep[]} [metadata.steps] */ -export function createAskMessage(role, text) { +export function createAskMessage(role, text, metadata = {}) { + const { artifacts = [], elapsedMs, steps = [] } = metadata; const item = document.createElement("li"); const label = document.createElement("strong"); const content = document.createElement("div"); + const responseSteps = role === "assistant" + ? document.createElement("ol") + : undefined; + const responseTime = role === "assistant" + ? document.createElement("small") + : undefined; + + /** @param {import("../../storage.js").StoredArtifact[]} nextArtifacts */ + function setArtifacts(nextArtifacts) { + item.querySelectorAll(":scope > [data-ask-chart]").forEach((chart) => { + chart.remove(); + }); + const charts = nextArtifacts + .filter((artifact) => artifact.type === "chart") + .map(createAskChart); + + if (responseSteps) responseSteps.before(...charts); + else item.append(...charts); + } + + /** @param {import("../../timing.js").ResponseStep[]} nextSteps */ + function setSteps(nextSteps) { + if (!responseSteps) return; + + responseSteps.replaceChildren(...nextSteps.map((step) => { + const item = document.createElement("li"); + const name = document.createElement("span"); + const duration = document.createElement("time"); + + name.textContent = step.label; + if (step.elapsedMs === undefined) item.dataset.active = ""; + else duration.textContent = formatDuration(step.elapsedMs); + item.append(name, duration); + return item; + })); + responseSteps.hidden = !nextSteps.length; + } + + /** @param {number | undefined} nextElapsedMs */ + function setElapsed(nextElapsedMs) { + if (!responseTime) return; + + const visible = + typeof nextElapsedMs === "number" && + Number.isFinite(nextElapsedMs) && + nextElapsedMs >= 0; + responseTime.hidden = !visible; + responseTime.textContent = visible + ? `${formatDuration(nextElapsedMs)} total` + : ""; + } item.dataset.role = role; label.append(role === "user" ? "You" : "Assistant"); setMessageContent(content, role, text); item.append(label, content); + if (responseSteps) { + responseSteps.dataset.responseSteps = ""; + item.append(responseSteps); + } + if (responseTime) { + responseTime.dataset.responseTime = ""; + responseTime.title = "Response time"; + item.append(responseTime); + } + setArtifacts(artifacts); + setSteps(steps); + setElapsed(elapsedMs); - return { item, content }; + return { item, content, setArtifacts, setSteps, setElapsed }; } diff --git a/website_next/ask/conversation/message/style.css b/website_next/ask/conversation/message/style.css index a4d4de864..8c6be903e 100644 --- a/website_next/ask/conversation/message/style.css +++ b/website_next/ask/conversation/message/style.css @@ -107,6 +107,32 @@ main[data-page="ask"] [data-ask-conversation] > ol > li { color: color-mix(in oklch, var(--white) 72%, transparent); } } + + > :is(ol[data-response-steps], small[data-response-time]) { + color: color-mix(in oklch, var(--white) 38%, transparent); + font-size: var(--font-size-xs); + font-variant-numeric: tabular-nums; + line-height: var(--line-height-xs); + } + + > ol[data-response-steps] { + display: grid; + width: min(100%, 18rem); + gap: 0.1rem; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + + &[data-active] > span::after { + content: "…"; + } + } + } } @media (max-width: 48rem) { diff --git a/website_next/ask/index.js b/website_next/ask/index.js index 15345ebf7..46dc70b9c 100644 --- a/website_next/ask/index.js +++ b/website_next/ask/index.js @@ -7,6 +7,8 @@ import { createAskLoader } from "./loader/index.js"; import { AskModel } from "./model.js"; import { createAskSidebar } from "./sidebar/index.js"; import { askStorage } from "./storage.js"; +import { createAskTools } from "./tools/index.js"; +import { createResponseTimer } from "./timing.js"; const SIDEBAR_PREFERENCE_KEY = "bitview.ask.sidebar-collapsed.v1"; @@ -17,6 +19,7 @@ function errorMessage(error) { export function createAskPage() { const model = new AskModel(); + const assistant = createAskTools(); let workspace = askStorage.load(); let chat = workspace.activeChat; let loaded = false; @@ -49,6 +52,7 @@ export function createAskPage() { onSubmit: submitQuestion, onStop() { model.stop(); + assistant.stop(); }, }); const loader = createAskLoader({ @@ -280,6 +284,7 @@ export function createAskPage() { composer.value = ""; const questionMessage = conversation.append("user", question); const answerMessage = conversation.append("assistant", ""); + const timer = createResponseTimer(answerMessage.setSteps); const draft = { ...chat, messages: [ @@ -297,22 +302,45 @@ export function createAskPage() { conversation.scrollToBottom(); try { - const prepared = await prepareContext(draft, model, () => {}); - const output = await model.generate( - prepared.messages, - ({ text }) => { + 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({ + chatId: chat.id, + history: prepared.chat.messages, + model, + messages: prepared.messages, + onToken({ text }) { answerMessage.content.append(text); if (followingOutput) conversation.scrollToBottom(); }, - ); + onStatus(status) { + timer.set(status); + if (followingOutput) conversation.scrollToBottom(); + }, + }); + const { elapsedMs, steps } = timer.finish(); conversation.setContent(answerMessage.content, "assistant", output); + answerMessage.setArtifacts(artifacts); + answerMessage.setElapsed(elapsedMs); if (followingOutput) conversation.scrollToBottom(); workspace = askStorage.save({ ...prepared.chat, messages: [ ...prepared.chat.messages, - { role: /** @type {const} */ ("assistant"), content: output }, + { + role: /** @type {const} */ ("assistant"), + content: output, + elapsedMs, + steps, + ...(artifacts.length ? { artifacts } : {}), + }, ], }); chat = workspace.activeChat; @@ -350,6 +378,7 @@ export function createAskPage() { main.addEventListener("pageactive", () => void activateModel()); main.addEventListener("pageinactive", () => { model.terminate(); + assistant.terminate(); setSidebarOpen(false); setUnloaded(); syncSidebar(); diff --git a/website_next/ask/model.js b/website_next/ask/model.js index 855814c6f..022f6d6ab 100644 --- a/website_next/ask/model.js +++ b/website_next/ask/model.js @@ -1,9 +1,12 @@ const WORKER_URL = import.meta.resolve("./worker.js"); /** - * @typedef {{ role: "system" | "user" | "assistant", content: string }} ChatMessage + * @typedef {{ name: string, arguments: Record }} ToolCall + * @typedef {{ role: "system" | "user" | "assistant" | "tool", content: string, tool_calls?: ToolCall[] }} ChatMessage * @typedef {{ progress: number, loaded: number, total: number }} LoadProgress * @typedef {{ text: string, tokensPerSecond?: number }} TokenUpdate + * @typedef {{ text: string, toolCalls: ToolCall[], finishReason: string, tokensPerSecond?: number }} GenerationResult + * @typedef {"auto" | "none" | { name: string }} ToolChoice */ export class AskModel { @@ -56,24 +59,27 @@ export class AskModel { /** * @param {ChatMessage[]} messages * @param {(update: TokenUpdate) => void} onToken + * @param {readonly unknown[]} [tools] + * @param {ToolChoice} [toolChoice] */ - generate(messages, onToken) { - return /** @type {Promise} */ ( - this.#request("generate", messages, onToken) + generate(messages, onToken, tools = [], toolChoice = "auto") { + return /** @type {Promise} */ ( + this.#request("generate", messages, onToken, tools, toolChoice) ); } /** @param {ChatMessage[]} messages */ - compact(messages) { - return /** @type {Promise} */ ( - this.#request("compact", messages) + async compact(messages) { + const result = /** @type {GenerationResult} */ ( + await this.#request("compact", messages) ); + return result.text; } - /** @param {ChatMessage[]} messages */ - countTokens(messages) { + /** @param {ChatMessage[]} messages @param {readonly unknown[]} [tools] */ + countTokens(messages, tools = []) { return /** @type {Promise} */ ( - this.#request("count", messages) + this.#request("count", messages, undefined, tools) ); } @@ -97,15 +103,20 @@ export class AskModel { * @param {"generate" | "compact" | "count"} type * @param {ChatMessage[]} messages * @param {((update: TokenUpdate) => void) | undefined} [onToken] + * @param {readonly unknown[]} [tools] + * @param {ToolChoice} [toolChoice] */ - #request(type, messages, onToken) { + #request(type, messages, onToken, tools = [], toolChoice = "auto") { if (!this.#worker) throw new Error("Model is not loaded"); this.#onToken = onToken; return new Promise((resolve, reject) => { this.#resolve = resolve; this.#reject = reject; - this.#worker?.postMessage({ type, data: messages }); + this.#worker?.postMessage({ + type, + data: { messages, tools, toolChoice }, + }); }); } @@ -146,7 +157,7 @@ export class AskModel { }); break; case "complete": - this.#settle(message.output); + this.#settle(message.result); break; case "counted": this.#settle(message.count); diff --git a/website_next/ask/models.js b/website_next/ask/models.js index d5b4f4ebb..c49d85cae 100644 --- a/website_next/ask/models.js +++ b/website_next/ask/models.js @@ -1,7 +1,21 @@ +const BITGPU_VERSION = "0.19.1"; +const MODEL_REVISION = "78f2c2bacd0904ffaba24b4873ed975e5818354a"; +const TOKENIZER_REVISION = "584f02f4ca20c85ff8b5d38d2323cef2d24decd0"; +const MODEL_FILES = + `https://cdn.jsdelivr.net/gh/stfurkan/bitgpu@v${BITGPU_VERSION}/models/bonsai-4b-gguf`; +const TOKENIZER_FILES = + `https://huggingface.co/onnx-community/Bonsai-4B-ONNX/resolve/${TOKENIZER_REVISION}`; + export const ASK_MODEL = /** @type {const} */ ({ - name: "Ternary Bonsai 1.7B", - modelId: "onnx-community/Ternary-Bonsai-1.7B-ONNX", - revision: "8beb5ca724ad1489e02c7834d065cfce1a759628", - dtype: "q2", - size: "~507 MB", + name: "Bonsai 4B", + size: "~580 MB", + runtimeUrl: `https://esm.sh/bitgpu@${BITGPU_VERSION}`, + chatUrl: `https://esm.sh/bitgpu@${BITGPU_VERSION}/chat`, + manifestUrl: `${MODEL_FILES}/manifest.json`, + auxUrl: `${MODEL_FILES}/Bonsai-4B-Q1_0.aux.bin`, + dataUrl: + `https://huggingface.co/prism-ml/Bonsai-4B-gguf/resolve/${MODEL_REVISION}/Bonsai-4B-Q1_0.gguf`, + tokenizerJsonUrl: `${TOKENIZER_FILES}/tokenizer.json`, + tokenizerConfigUrl: `${TOKENIZER_FILES}/tokenizer_config.json`, + cacheName: "bitview-ask-bonsai-4b-v1", }); diff --git a/website_next/ask/storage.js b/website_next/ask/storage.js index dd7f360ab..9d32d450f 100644 --- a/website_next/ask/storage.js +++ b/website_next/ask/storage.js @@ -2,11 +2,62 @@ 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_VIEWS = new Set(["line", "area", "stacked", "bar", "dots"]); +const CHART_SCALES = new Set(["linear", "log"]); +const CHART_COLORS = new Set([ + "orange", + "white", + "gray", + "sky", + "cyan", + "teal", + "yellow", + "avocado", + "amber", + "green", + "emerald", + "lime", + "rose", + "pink", + "fuchsia", + "purple", + "violet", + "indigo", + "blue", + "red", +]); /** * @typedef {Object} StoredMessage * @property {"user" | "assistant"} role * @property {string} content + * @property {number} [elapsedMs] + * @property {StoredResponseStep[]} [steps] + * @property {StoredArtifact[]} [artifacts] + * + * @typedef {Object} StoredResponseStep + * @property {string} label + * @property {number} elapsedMs + * + * @typedef {Object} ChartSeriesSpec + * @property {string} path + * @property {string} label + * @property {"orange" | "white" | "gray" | "sky" | "cyan" | "teal" | "yellow" | "avocado" | "amber" | "green" | "emerald" | "lime" | "rose" | "pink" | "fuchsia" | "purple" | "violet" | "indigo" | "blue" | "red"} color + * + * @typedef {Object} ChartSpec + * @property {string} title + * @property {"addresses" | "blocks" | "btc" | "percent" | "utxos" | "usd"} unit + * @property {"line" | "area" | "stacked" | "bar" | "dots"} view + * @property {"linear" | "log"} scale + * @property {ChartSeriesSpec[]} series + * + * @typedef {Object} ChartArtifact + * @property {"chart"} type + * @property {string} id + * @property {ChartSpec} chart + * + * @typedef {ChartArtifact} StoredArtifact * * @typedef {Object} ChatMeta * @property {string} id @@ -30,15 +81,97 @@ const NEW_CHAT_TITLE = "New chat"; * @property {ChatMeta[]} chats */ -/** @param {unknown} value */ -function isStoredMessage(value) { - if (!value || typeof value !== "object") return false; +/** @param {unknown} value @returns {StoredArtifact | undefined} */ +function readArtifact(value) { + if (!value || typeof value !== "object") return undefined; + const artifact = /** @type {Record} */ (value); + const chart = artifact.chart; + if ( + artifact.type !== "chart" || + typeof artifact.id !== "string" || + !chart || + typeof chart !== "object" || + typeof chart.title !== "string" || + typeof chart.unit !== "string" || + typeof chart.view !== "string" || + typeof chart.scale !== "string" || + !CHART_UNITS.has(chart.unit) || + !CHART_VIEWS.has(chart.view) || + !CHART_SCALES.has(chart.scale) || + !Array.isArray(chart.series) + ) return undefined; + + const series = chart.series.filter( + (/** @type {any} */ item) => + item && + typeof item.path === "string" && + typeof item.label === "string" && + typeof item.color === "string" && + CHART_COLORS.has(item.color), + ); + if (!series.length) return undefined; + + return /** @type {StoredArtifact} */ ({ + type: "chart", + id: artifact.id, + chart: { + title: chart.title, + unit: chart.unit, + view: chart.view, + scale: chart.scale, + series, + }, + }); +} + +/** @param {unknown} value @returns {StoredResponseStep | undefined} */ +function readResponseStep(value) { + if (!value || typeof value !== "object") return undefined; + + const step = /** @type {Record} */ (value); + if ( + typeof step.label !== "string" || + typeof step.elapsedMs !== "number" || + !Number.isFinite(step.elapsedMs) || + step.elapsedMs < 0 + ) return undefined; + + return { label: step.label, elapsedMs: step.elapsedMs }; +} + +/** @param {unknown} value @returns {StoredMessage | undefined} */ +function readMessage(value) { + if (!value || typeof value !== "object") return undefined; const message = /** @type {Record} */ (value); - return ( - (message.role === "user" || message.role === "assistant") && - typeof message.content === "string" - ); + if ( + (message.role !== "user" && message.role !== "assistant") || + typeof message.content !== "string" + ) return undefined; + + const artifacts = Array.isArray(message.artifacts) + ? /** @type {StoredArtifact[]} */ ( + message.artifacts.map(readArtifact).filter(Boolean) + ) + : []; + const elapsedMs = + typeof message.elapsedMs === "number" && + Number.isFinite(message.elapsedMs) && + message.elapsedMs >= 0 + ? message.elapsedMs + : undefined; + const steps = Array.isArray(message.steps) + ? /** @type {StoredResponseStep[]} */ ( + message.steps.map(readResponseStep).filter(Boolean) + ) + : []; + return { + role: message.role, + content: message.content, + ...(elapsedMs !== undefined ? { elapsedMs } : {}), + ...(steps.length ? { steps } : {}), + ...(artifacts.length ? { artifacts } : {}), + }; } /** @param {string} question */ @@ -114,7 +247,7 @@ function readChat(meta) { const stored = JSON.parse(value); const messages = Array.isArray(stored.messages) - ? stored.messages.filter(isStoredMessage) + ? stored.messages.map(readMessage).filter(Boolean) : []; const compactedCount = Math.min( Number(stored.compactedCount) || 0, @@ -135,7 +268,7 @@ function initialize() { const meta = createMeta(); const legacy = localStorage.getItem(LEGACY_KEY); const messages = /** @type {StoredMessage[]} */ ( - legacy ? JSON.parse(legacy).filter(isStoredMessage) : [] + legacy ? JSON.parse(legacy).map(readMessage).filter(Boolean) : [] ); if (messages.length) { diff --git a/website_next/ask/timing.js b/website_next/ask/timing.js new file mode 100644 index 000000000..4a7260229 --- /dev/null +++ b/website_next/ask/timing.js @@ -0,0 +1,68 @@ +/** + * @typedef {Object} ResponseStep + * @property {string} label + * @property {number} [elapsedMs] + */ + +/** @param {number} elapsedMs */ +export function formatDuration(elapsedMs) { + if (elapsedMs < 100) return "<0.1s"; + if (elapsedMs < 60_000) return `${(elapsedMs / 1_000).toFixed(1)}s`; + + const minutes = Math.floor(elapsedMs / 60_000); + const seconds = Math.floor((elapsedMs % 60_000) / 1_000); + return `${minutes}m ${seconds}s`; +} + +/** @param {string} label */ +function normalizeLabel(label) { + return label + .replace(/\s*·.*$/, "") + .replace(/(?:\.\.\.|…)$/, "") + .trim(); +} + +/** @param {(steps: ResponseStep[]) => void} onUpdate */ +export function createResponseTimer(onUpdate) { + const startedAt = performance.now(); + /** @type {{ label: string, startedAt: number } | undefined} */ + let active; + /** @type {{ label: string, elapsedMs: number }[]} */ + const completed = []; + + function publish() { + onUpdate(active + ? [...completed, { label: active.label }] + : [...completed]); + } + + function completeActive() { + if (!active) return; + + completed.push({ + label: active.label, + elapsedMs: Math.round(performance.now() - active.startedAt), + }); + active = undefined; + } + + return { + /** @param {string} nextLabel */ + set(nextLabel) { + const label = normalizeLabel(nextLabel); + if (label === active?.label) return; + + completeActive(); + if (label) active = { label, startedAt: performance.now() }; + publish(); + }, + finish() { + completeActive(); + publish(); + return { + elapsedMs: Math.round(performance.now() - startedAt), + steps: [...completed], + }; + }, + }; +} diff --git a/website_next/ask/tools/chart.js b/website_next/ask/tools/chart.js new file mode 100644 index 000000000..444a46d11 --- /dev/null +++ b/website_next/ask/tools/chart.js @@ -0,0 +1,66 @@ +import { colors } from "../../utils/colors.js"; +import { units } from "../../chart/units.js"; +import { createMetric } from "./metrics/index.js"; + +/** @typedef {import("../storage.js").ChartArtifact} ChartArtifact */ + +const VIEWS = new Set(["line", "area", "stacked", "bar", "dots"]); +const SCALES = new Set(["linear", "log"]); +const PALETTE = ["orange", "blue", "green", "violet", "yellow", "red"]; + +/** @param {unknown} value @param {string} name */ +function requiredString(value, name) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Chart ${name} is required`); + } + return value.trim(); +} + +/** @param {Record} args @returns {ChartArtifact} */ +export function createChartArtifact(args) { + const title = requiredString(args.title, "title"); + const unit = requiredString(args.unit, "unit"); + const view = args.view === undefined ? "line" : requiredString(args.view, "view"); + const scale = args.scale === undefined + ? "linear" + : requiredString(args.scale, "scale"); + + if (!Object.hasOwn(units, unit)) throw new Error(`Unknown chart unit: ${unit}`); + if (!VIEWS.has(view)) throw new Error(`Unknown chart view: ${view}`); + if (!SCALES.has(scale)) throw new Error(`Unknown chart scale: ${scale}`); + if (!Array.isArray(args.series) || !args.series.length || args.series.length > 6) { + throw new Error("A chart needs between one and six series"); + } + + const series = args.series.map((value, index) => { + if (!value || typeof value !== "object") throw new Error("Invalid chart series"); + const item = /** @type {Record} */ (value); + const path = requiredString(item.path, "series path") + .replace(/^brk\.series\./, "") + .replace(/^series\./, ""); + const label = requiredString(item.label, "series label"); + const color = item.color === undefined + ? PALETTE[index] + : requiredString(item.color, "series color"); + + if (!Object.hasOwn(colors, color)) throw new Error(`Unknown chart color: ${color}`); + createMetric(path); + return { + path, + label, + color: /** @type {ChartArtifact["chart"]["series"][number]["color"]} */ (color), + }; + }); + + return { + type: /** @type {const} */ ("chart"), + id: crypto.randomUUID(), + chart: { + title, + unit: /** @type {ChartArtifact["chart"]["unit"]} */ (unit), + view: /** @type {ChartArtifact["chart"]["view"]} */ (view), + scale: /** @type {ChartArtifact["chart"]["scale"]} */ (scale), + series, + }, + }; +} diff --git a/website_next/ask/tools/data.js b/website_next/ask/tools/data.js new file mode 100644 index 000000000..7c6e5e375 --- /dev/null +++ b/website_next/ask/tools/data.js @@ -0,0 +1,107 @@ +import { brk } from "../../utils/client.js"; + +const RANGE_POINTS = 120; + +/** @type {Map>} */ +const infoCache = new Map(); + +/** @param {string} name */ +function seriesInfo(name) { + let request = infoCache.get(name); + if (!request) { + request = brk.getSeriesInfo(name); + infoCache.set(name, request); + } + return request; +} + +/** @param {string} type */ +export function unitFromType(type) { + const value = type.toLowerCase(); + if (value.includes("dollar") || value.includes("usd") || value.includes("cents")) return "usd"; + if (value.includes("bitcoin") || value === "btc") return "btc"; + if (value.includes("percent") || value.includes("ratio")) return "percent"; + if (value.includes("address")) return "addresses"; + if (value.includes("utxo") || value.includes("output")) return "utxos"; + if (value.includes("block") || value.includes("height")) return "blocks"; + return undefined; +} + +/** @param {string} unit */ +function displayedUnit(unit) { + if (unit === "usd") return "$"; + if (unit === "percent") return "%"; + if (unit === "btc") return " BTC"; + if (unit === "addresses") return " addresses"; + if (unit === "utxos") return " UTXOs"; + if (unit === "blocks") return " blocks"; + return ""; +} + +/** @param {number} value @param {string | undefined} unit */ +export function formatValue(value, unit) { + const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 8 }).format(value); + const affix = displayedUnit(unit ?? ""); + return affix === "$" ? `$${number}` : `${number}${affix}`; +} + +/** @param {{ name: string, suggestedUnit?: string }} metric @param {Record} action */ +export async function readMetric(metric, action) { + const info = await seriesInfo(metric.name); + const rawIndex = typeof action.index === "string" ? action.index : ""; + const indexLooksLikeValue = /^-?\d+$/.test(rawIndex) || /^\d{4}-\d{2}-\d{2}$/.test(rawIndex); + const at = action.at ?? (indexLooksLikeValue ? rawIndex : undefined); + const dateLike = typeof at === "string" && !/^-?\d+$/.test(at); + const preferredIndex = indexLooksLikeValue ? "" : rawIndex; + const index = info.indexes.includes(preferredIndex) + ? preferredIndex + : dateLike && info.indexes.includes("day1") + ? "day1" + : info.indexes.includes("height") + ? "height" + : info.indexes[0]; + const mode = typeof action.mode === "string" ? action.mode : "latest"; + let response; + + if (mode === "at") { + if (at === undefined) throw new Error("A block height or date is required"); + response = await brk.getSeries( + metric.name, + /** @type {any} */ (index), + /** @type {any} */ (at), + undefined, + 1, + ); + } else if (mode === "range") { + const points = Math.min(Number(action.points) || RANGE_POINTS, RANGE_POINTS); + response = await brk.getSeries( + metric.name, + /** @type {any} */ (index), + /** @type {any} */ (action.start ?? -points), + /** @type {any} */ (action.end), + points, + ); + } else { + response = await brk.getSeries( + metric.name, + /** @type {any} */ (index), + -1, + undefined, + 1, + ); + } + + if (!response || typeof response !== "object" || !Array.isArray(response.data)) { + throw new Error(`No data returned for ${metric.name}`); + } + return { + name: metric.name, + label: metric.name.replaceAll("_", " "), + unit: metric.suggestedUnit ?? unitFromType(info.type), + index: response.index, + start: response.start, + end: response.end, + stamp: response.stamp, + values: response.data, + }; +} diff --git a/website_next/ask/tools/index.js b/website_next/ask/tools/index.js new file mode 100644 index 000000000..432538283 --- /dev/null +++ b/website_next/ask/tools/index.js @@ -0,0 +1,118 @@ +import { searchTool } from "./schemas.js"; +import { AskToolSession } from "./session.js"; +import { AskSource } from "./source/index.js"; +import { terminateMetricIndex } from "./metrics/index.js"; + +const MAX_TOOL_ROUNDS = 8; + +/** + * @typedef {Object} ToolOutcome + * @property {boolean} done + * @property {boolean} [general] + * @property {string} [output] + * @property {import("../storage.js").StoredArtifact[]} [artifacts] + */ + +/** @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} */ + const sessions = new Map(); + /** @type {AbortController | undefined} */ + let controller; + + /** @param {string} chatId */ + function sessionFor(chatId) { + let session = sessions.get(chatId); + if (!session) { + session = new AskToolSession(source); + sessions.set(chatId, session); + } + return session; + } + + return { + toolsFor() { + return [searchTool()]; + }, + + /** + * @param {Object} options + * @param {string} options.chatId + * @param {import("../storage.js").StoredMessage[]} options.history + * @param {import("../model.js").AskModel} options.model + * @param {import("../model.js").ChatMessage[]} options.messages + * @param {(update: import("../model.js").TokenUpdate) => void} options.onToken + * @param {(status: string) => void} options.onStatus + */ + async answer({ chatId, history, model, messages, onStatus }) { + controller = new AbortController(); + const { signal } = controller; + const question = latestQuestion(messages); + const session = sessionFor(chatId); + session.begin(question, history); + + try { + const direct = await session.tryDirect(onStatus); + if (direct) return direct; + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + signal.throwIfAborted(); + onStatus("Thinking…"); + const newestUser = messages.findLast((message) => message.role === "user"); + const stagedMessages = [ + { role: /** @type {const} */ ("system"), content: session.instruction() }, + ...(newestUser ? [newestUser] : []), + ...(session.observation + ? [{ + role: /** @type {const} */ ("user"), + content: `Available source-derived result:\n${JSON.stringify(session.observation)}`, + }] + : []), + ]; + const result = await model.generate( + stagedMessages, + () => {}, + [await session.tool()], + { name: "next_action" }, + ); + const call = result.toolCalls[0]; + if (!call || call.name !== "next_action") { + throw new Error("The AI did not choose a valid action"); + } + + signal.throwIfAborted(); + const outcome = /** @type {ToolOutcome} */ ( + await session.execute(call.arguments, onStatus) + ); + if (!outcome.done) continue; + return { + output: outcome.output ?? "", + artifacts: outcome.artifacts ?? [], + }; + } + throw new Error("The AI used too many tool steps. Try a more specific question."); + } finally { + controller = undefined; + onStatus(""); + } + }, + + stop() { + controller?.abort(); + source.terminate(); + }, + + terminate() { + controller?.abort(); + controller = undefined; + sessions.clear(); + source.terminate(); + terminateMetricIndex(); + }, + }; +} diff --git a/website_next/ask/tools/learn.js b/website_next/ask/tools/learn.js new file mode 100644 index 000000000..61ef0da0a --- /dev/null +++ b/website_next/ask/tools/learn.js @@ -0,0 +1,66 @@ +import { brk } from "../../utils/client.js"; +import { relevance } from "./text.js"; + +/** @type {Promise | undefined} */ +let catalogPromise; + +/** @param {any} node @param {string[]} breadcrumbs */ +function record(node, breadcrumbs) { + if (!node.chart) return undefined; + const series = node.chart.series.flatMap((/** @type {any} */ entry) => { + try { + const metric = entry.metric(brk); + return metric?.name + ? [{ name: metric.name, label: entry.label }] + : []; + } catch { + return []; + } + }); + return { + title: node.chart.title ?? node.title, + sectionTitle: node.title, + breadcrumbs, + description: node.description ?? "", + unit: node.chart.unit?.id, + series, + }; +} + +/** @param {any[]} nodes @param {string[]} ancestors @param {any[]} output */ +function walk(nodes, ancestors, output) { + for (const node of nodes) { + const breadcrumbs = [...ancestors, node.title]; + const item = record(node, breadcrumbs); + if (item) output.push(item); + if (node.children) walk(node.children, breadcrumbs, output); + } +} + +async function learnCatalog() { + catalogPromise ??= import("../../learn/data/index.js").then(({ sections }) => { + /** @type {any[]} */ + const output = []; + walk(sections, [], output); + return output; + }); + return catalogPromise; +} + +/** @param {string} query @param {number} [limit] */ +export async function searchLearn(query, limit = 12) { + return (await learnCatalog()) + .map((/** @type {any} */ item) => ({ + ...item, + score: + relevance(query, `${item.title} ${item.sectionTitle}`) * 3 + + relevance(query, item.breadcrumbs.join(" ")) * 2 + + relevance( + query, + `${item.description} ${item.series.flatMap((/** @type {any} */ series) => [series.label, series.name]).join(" ")}`, + ), + })) + .filter(({ score }) => score >= 16) + .sort((left, right) => right.score - left.score) + .slice(0, limit); +} diff --git a/website_next/ask/tools/metrics/index.js b/website_next/ask/tools/metrics/index.js new file mode 100644 index 000000000..63aca4cae --- /dev/null +++ b/website_next/ask/tools/metrics/index.js @@ -0,0 +1,143 @@ +import { brk } from "../../../utils/client.js"; + +const WORKER_URL = import.meta.resolve("./worker.js"); +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** @typedef {{ path: string, name: string, endpoint: string, suggestedUnit?: string, matchedQuery?: string, score?: number }} CatalogMetric */ + +/** @param {unknown} value */ +function isMetric(value) { + if (!value || typeof value !== "object") return false; + const by = /** @type {{ by?: Record }} */ (value).by; + return Boolean( + by && + Object.values(by).some( + (endpoint) => + endpoint && + typeof endpoint === "object" && + typeof /** @type {{ fetch?: unknown }} */ (endpoint).fetch === "function", + ), + ); +} + +/** @param {string} path */ +function normalizePath(path) { + const normalized = path + .trim() + .replace(/^brk\.series\./, "") + .replace(/^series\./, ""); + const keys = normalized.split(".").filter(Boolean); + + if (!keys.length || keys.some((key) => FORBIDDEN_KEYS.has(key))) { + throw new Error(`Invalid metric path: ${path}`); + } + return keys; +} + +/** @param {typeof brk} client @param {string} path */ +export function resolveMetric(client, path) { + const keys = normalizePath(path); + /** @type {unknown} */ + let value = client.series; + + for (const key of keys) { + if (!value || typeof value !== "object" || !Object.hasOwn(value, key)) { + throw new Error(`Metric not found: ${path}`); + } + value = /** @type {Record} */ (value)[key]; + } + + if (!isMetric(value)) throw new Error(`Metric not found: ${path}`); + return /** @type {TimeframeMetric} */ (value); +} + +/** @param {string} path */ +export function createMetric(path) { + resolveMetric(brk, path); + return (/** @type {typeof brk} */ client) => resolveMetric(client, path); +} + +class MetricIndex { + /** @type {Worker | undefined} */ + #worker; + + /** @type {Map void, reject: (error: Error) => void, onProgress?: () => void }>} */ + #pending = new Map(); + + /** @param {"search" | "categories" | "byName" | "variants"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ + request(type, data, onProgress) { + this.#ensureWorker(); + const id = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject, onProgress }); + this.#worker?.postMessage({ id, type, data }); + }); + } + + #ensureWorker() { + if (this.#worker) return; + this.#worker = new Worker(WORKER_URL, { type: "module" }); + this.#worker.addEventListener("message", this.#handleMessage); + this.#worker.addEventListener("error", this.#handleError); + } + + terminate() { + const error = new Error("Metric search stopped"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + } + + /** @param {MessageEvent} event */ + #handleMessage = (event) => { + const message = event.data; + const request = this.#pending.get(message.id); + if (!request) return; + + if (message.status === "progress") { + request.onProgress?.(); + return; + } + + this.#pending.delete(message.id); + if (message.status === "complete") request.resolve(message.data); + else request.reject(new Error(message.data)); + }; + + /** @param {ErrorEvent} event */ + #handleError = (event) => { + const error = new Error(event.message || "The metric index failed"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + }; +} + +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); +} + +/** @returns {Promise<{ path: string, label: string, count: number, examples: string[] }[]>} */ +export function metricCategories() { + return index.request("categories", {}); +} + +/** @param {string} name @returns {Promise} */ +export function metricByName(name) { + return index.request("byName", { name }); +} + +/** @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 }); +} + +export function terminateMetricIndex() { + index.terminate(); +} diff --git a/website_next/ask/tools/metrics/worker.js b/website_next/ask/tools/metrics/worker.js new file mode 100644 index 000000000..80fa8e738 --- /dev/null +++ b/website_next/ask/tools/metrics/worker.js @@ -0,0 +1,322 @@ +import { QuickMatch, QuickMatchConfig } from "../../../modules/quickmatch-js/0.5.0/src/index.js"; +import { brk } from "../../../utils/client.js"; + +const SEARCH_CANDIDATES = 1_024; + +/** @typedef {{ path: string, name: string, endpoint: string, document: string }} CatalogMetric */ + +/** @param {unknown} value */ +function isMetric(value) { + if (!value || typeof value !== "object") return false; + const by = /** @type {{ by?: Record }} */ (value).by; + return Boolean( + by && + Object.values(by).some( + (endpoint) => + endpoint && + typeof endpoint === "object" && + typeof /** @type {{ fetch?: unknown }} */ (endpoint).fetch === "function", + ), + ); +} + +/** @param {string} value */ +function searchable(value) { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_./|:-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +/** @param {string} path */ +function suggestedUnit(path) { + if (/(percent|ratio|dominance|rate)/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; +} + +/** @param {number} limit */ +function createConfig(limit = SEARCH_CANDIDATES) { + // The model supplies semantic wording, where unmatched words are usually context rather than typos. + return new QuickMatchConfig() + .withLimit(limit) + .withTrigramBudget(0) + .withMinScore(2) + .withSeparators("_- :/.|"); +} + +function buildState() { + /** @type {CatalogMetric[]} */ + const items = []; + /** @type {Map} */ + const byName = new Map(); + /** @type {Map} */ + const byDocument = new Map(); + const seen = new WeakSet(); + /** @type {{ value: unknown, keys: string[] }[]} */ + const pending = [{ value: brk.series, keys: [] }]; + + while (pending.length) { + const { value, keys } = /** @type {{ value: unknown, keys: string[] }} */ (pending.pop()); + if (!value || typeof value !== "object" || seen.has(value)) continue; + seen.add(value); + + if (isMetric(value)) { + const raw = /** @type {{ name?: string, by: Record }} */ (value); + const path = keys.join("."); + const name = raw.name ?? keys.at(-1) ?? ""; + const endpoint = Object.values(raw.by).find((item) => item.path)?.path ?? ""; + const document = searchable(`${name} | ${path} | ${endpoint}`); + const metric = { path, name, endpoint, document }; + items.push(metric); + if (!byName.has(name)) byName.set(name, metric); + byDocument.set(document, metric); + } else { + for (const [key, child] of Object.entries(value)) { + if (key !== "by") pending.push({ value: child, keys: [...keys, key] }); + } + } + } + + const config = createConfig(); + const matcher = new QuickMatch(items.map(({ document }) => document), config); + /** @type {Map} */ + const scoped = new Map(); + return { items, byName, byDocument, matcher, config, scoped }; +} + +/** @type {Promise> | undefined} */ +let statePromise; + +/** @param {string} id */ +function state(id) { + if (!statePromise) { + self.postMessage({ id, status: "progress" }); + statePromise = Promise.resolve().then(buildState); + } + return statePromise; +} + +/** @param {CatalogMetric} metric */ +function publicMetric(metric) { + return { + path: metric.path, + name: metric.name, + endpoint: metric.endpoint, + suggestedUnit: suggestedUnit(metric.path), + }; +} + +/** @param {ReturnType} index @param {string[]} prefixes */ +function scopedIndex(index, prefixes) { + const inScope = (/** @type {CatalogMetric} */ metric) => + !prefixes.length || prefixes.some((prefix) => + metric.path === prefix || metric.path.startsWith(`${prefix}.`) + ); + if (!prefixes.length) return { matcher: index.matcher, config: index.config, inScope }; + + const key = [...prefixes].sort().join("|"); + let scoped = index.scoped.get(key); + if (!scoped) { + const config = createConfig(); + const documents = index.items.filter(inScope).map(({ document }) => document); + scoped = { matcher: new QuickMatch(documents, config), config }; + index.scoped.set(key, scoped); + } + return { ...scoped, inScope }; +} + +/** @param {ReturnType} index @param {string} query @param {number} limit @param {string[]} prefixes */ +function searchOne(index, query, limit, prefixes) { + const scope = scopedIndex(index, prefixes); + const normalizedQuery = searchable(query); + const full = scope.matcher.matchesWith( + normalizedQuery, + scope.config.withLimit(SEARCH_CANDIDATES), + ); + const fullRanks = new Map(full.map((document, rank) => [document, rank])); + const scores = new Map(); + for (const word of [...new Set(normalizedQuery.split(" "))]) { + if (!word) continue; + const matches = scope.matcher.matchesWith( + word, + scope.config.withLimit(SEARCH_CANDIDATES), + ); + for (const [rank, document] of matches.entries()) { + const score = scores.get(document) ?? { matched: 0, ranks: 0 }; + score.matched += 1; + score.ranks += rank; + scores.set(document, score); + } + } + const documents = [...new Set([...full, ...scores.keys()])] + .sort((left, right) => { + const a = scores.get(left) ?? { matched: 0, ranks: SEARCH_CANDIDATES }; + const b = scores.get(right) ?? { matched: 0, ranks: SEARCH_CANDIDATES }; + return b.matched - a.matched || + (fullRanks.get(left) ?? SEARCH_CANDIDATES) - + (fullRanks.get(right) ?? SEARCH_CANDIDATES) || + a.ranks - b.ranks || + left.length - right.length || + left.localeCompare(right); + }); + + return documents + .map((document) => index.byDocument.get(document)) + .filter((metric) => metric && scope.inScope(metric)) + .slice(0, limit) + .map((metric, rank) => ({ + ...publicMetric(/** @type {CatalogMetric} */ (metric)), + matchedQuery: query, + score: 1_000 - rank, + })); +} + +/** @param {ReturnType} index @param {string[]} queries @param {number} limit @param {string[]} prefixes */ +function search(index, queries, limit, prefixes) { + const groups = queries.map((query) => searchOne(index, query, limit, prefixes)); + const output = []; + const seen = new Set(); + + for (let rank = 0; output.length < limit; rank += 1) { + let added = false; + for (const group of groups) { + const metric = group[rank]; + if (!metric || seen.has(metric.path)) continue; + seen.add(metric.path); + output.push(metric); + added = true; + if (output.length === limit) break; + } + if (!added) break; + } + return output; +} + +/** @param {ReturnType} index */ +function categories(index) { + /** @type {Map[] }>} */ + const values = new Map(); + for (const metric of index.items) { + const [root, branch] = metric.path.split("."); + if (!root) continue; + const category = values.get(root) ?? { + path: root, + label: searchable(root), + count: 0, + branches: [new Map(), new Map()], + }; + category.count += 1; + if (branch) { + category.branches[0].set(branch, (category.branches[0].get(branch) ?? 0) + 1); + const selector = metric.path.split(".").slice(1, 3).join(" / "); + if (selector !== branch) { + category.branches[1].set(selector, (category.branches[1].get(selector) ?? 0) + 1); + } + } + values.set(root, category); + } + return [...values.values()] + .map(({ branches, ...category }) => ({ + ...category, + examples: branches.flatMap((level, index) => [...level] + .sort((left, right) => right[1] - left[1]) + .slice(0, index === 0 ? 4 : 8) + .map(([name]) => searchable(name))) + .slice(0, 8), + })) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +/** @param {ReturnType} index @param {string} name @param {string} query */ +function variants(index, name, query) { + const suffix = `_${name}`; + const candidates = index.items.filter((candidate) => + candidate.name === name || candidate.name.endsWith(suffix) + ); + if (candidates.length <= 1) return undefined; + + const preferredPaths = new Map( + index.matcher.matchesWith(searchable(query), index.config.withLimit(SEARCH_CANDIDATES)) + .map((document, rank) => [index.byDocument.get(document)?.path, rank]), + ); + const ranked = candidates + .map((candidate) => ({ + ...publicMetric(candidate), + rank: preferredPaths.get(candidate.path) ?? SEARCH_CANDIDATES, + })) + .sort((left, right) => + Number(right.name === name) - Number(left.name === name) || + left.rank - right.rank || + left.path.localeCompare(right.path) + ); + + let commonSuffix = candidates[0].path.split("."); + for (const candidate of candidates.slice(1)) { + const path = candidate.path.split("."); + let count = 0; + while ( + count < commonSuffix.length && + count < path.length && + commonSuffix[commonSuffix.length - 1 - count] === path[path.length - 1 - count] + ) count += 1; + commonSuffix = count ? commonSuffix.slice(-count) : []; + } + + const groups = new Map(); + for (const candidate of ranked) { + const selector = candidate.path.split(".").slice(0, -commonSuffix.length); + const cohortIndex = selector.indexOf("cohorts"); + const cohort = selector.slice(cohortIndex < 0 ? 0 : cohortIndex + 1); + const family = cohort.length > 2 ? cohort.slice(0, 2).join(" / ") : cohort[0] ?? "root"; + const value = cohort.length > 2 ? cohort.slice(2).join(" / ") : cohort[1] ?? "all"; + const group = groups.get(family) ?? { family, count: 0, examples: [] }; + group.count += 1; + if (group.examples.length < 5) group.examples.push(value); + groups.set(family, group); + } + + return { + totalSeries: ranked.length, + groups: [...groups.values()].slice(0, 8), + series: ranked.slice(0, 16).map(({ path, name: metricName, endpoint, suggestedUnit }) => ({ + path, + name: metricName, + endpoint, + suggestedUnit, + })), + }; +} + +self.addEventListener("message", async (event) => { + const { id, type, data } = event.data; + try { + const index = await state(id); + let result; + if (type === "search") { + result = search(index, data.queries, data.limit, data.prefixes); + } else if (type === "categories") { + result = categories(index); + } else if (type === "byName") { + const metric = index.byName.get(data.name); + result = metric ? publicMetric(metric) : undefined; + } else if (type === "variants") { + result = variants(index, data.name, data.query); + } else { + throw new Error(`Unknown metric request: ${type}`); + } + self.postMessage({ id, status: "complete", data: result }); + } catch (error) { + self.postMessage({ + id, + status: "error", + data: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/website_next/ask/tools/parse.js b/website_next/ask/tools/parse.js new file mode 100644 index 000000000..0b6a5cb97 --- /dev/null +++ b/website_next/ask/tools/parse.js @@ -0,0 +1,51 @@ +const TOOL_CALL_PATTERN = /\s*([\s\S]*?)\s*<\/tool_call>/g; + +/** @param {unknown} value */ +function normalizeCall(value) { + if (!value || typeof value !== "object") { + throw new Error("The AI returned an invalid tool call"); + } + + const call = /** @type {Record} */ (value); + const name = call.name ?? + (call.function && typeof call.function === "object" + ? /** @type {Record} */ (call.function).name + : undefined); + let args = call.arguments ?? call.args ?? + (call.function && typeof call.function === "object" + ? /** @type {Record} */ (call.function).arguments + : undefined); + + if (typeof args === "string") args = JSON.parse(args); + if (typeof name !== "string" || !args || typeof args !== "object") { + throw new Error("The AI returned an invalid tool call"); + } + + return { name, arguments: /** @type {Record} */ (args) }; +} + +/** @param {string} output */ +export function parseToolCalls(output) { + const matches = [...output.matchAll(TOOL_CALL_PATTERN)]; + if (matches.length) { + return matches.flatMap((match) => { + const value = JSON.parse(match[1]); + return (Array.isArray(value) ? value : [value]).map(normalizeCall); + }); + } + + if (output.includes("} */ + #counts = new Map(); + + /** @type {Map} */ + #items = new Map(); + + /** @type {Map} */ + #keys = new Map(); + + /** + * @param {keyof typeof PREFIX} kind + * @param {any} value + * @param {string} stableKey + */ + issue(kind, value, stableKey) { + const key = `${kind}:${stableKey}`; + const existing = this.#keys.get(key); + if (existing) return existing; + + const count = (this.#counts.get(kind) ?? 0) + 1; + const ref = `${PREFIX[kind]}${count}`; + this.#counts.set(kind, count); + this.#items.set(ref, { kind, value }); + this.#keys.set(key, ref); + return ref; + } + + /** @param {string} ref @param {keyof typeof PREFIX} [expectedKind] */ + get(ref, expectedKind) { + const item = this.#items.get(ref); + if (!item) throw new Error(`Unknown reference ${ref}`); + if (expectedKind && item.kind !== expectedKind) { + throw new Error(`${ref} is not a ${expectedKind} reference`); + } + return item.value; + } + + /** @param {string} ref */ + kind(ref) { + const item = this.#items.get(ref); + if (!item) throw new Error(`Unknown reference ${ref}`); + return item.kind; + } +} diff --git a/website_next/ask/tools/render.js b/website_next/ask/tools/render.js new file mode 100644 index 000000000..28ef10557 --- /dev/null +++ b/website_next/ask/tools/render.js @@ -0,0 +1,45 @@ +import { formatValue } from "./data.js"; + +/** + * @typedef {Object} MetricRead + * @property {string} label + * @property {string | undefined} unit + * @property {string} index + * @property {number | string} start + * @property {string | undefined} stamp + * @property {unknown[]} values + */ + +/** @param {{ facts: string[], sources: string[], excerpts: { path: string, startLine: number, content: string }[] }} evidence */ +export function renderEvidence(evidence) { + const sections = [...new Set(evidence.facts)].filter(Boolean); + for (const excerpt of evidence.excerpts) { + sections.push(`\`${excerpt.path}:${excerpt.startLine}\`\n\n\`\`\`\n${excerpt.content}\n\`\`\``); + } + const sources = [...new Set(evidence.sources)].filter( + (source) => !sections.some((section) => section.includes(source)), + ); + if (sources.length) { + sections.push(`Source${sources.length === 1 ? "" : "s"}: ${sources.map((source) => `\`${source}\``).join(", ")}`); + } + return sections.join("\n\n") || "I could not find enough verified evidence to answer that."; +} + +/** @param {MetricRead[]} results */ +export function renderData(results) { + return results.map((result) => { + if (result.values.length === 1 && typeof result.values[0] === "number") { + const position = result.index === "height" + ? ` at block ${result.start}` + : result.stamp + ? ` at ${result.stamp}` + : ""; + return `**${result.label}**: ${formatValue(result.values[0], result.unit)}${position}`; + } + const values = /** @type {number[]} */ ( + result.values.filter((value) => typeof value === "number") + ); + if (!values.length) return `**${result.label}**: no values returned.`; + return `**${result.label}**: ${values.length} values; latest ${formatValue(values[values.length - 1], result.unit)}.`; + }).join("\n"); +} diff --git a/website_next/ask/tools/schemas.js b/website_next/ask/tools/schemas.js new file mode 100644 index 000000000..b42f8dea4 --- /dev/null +++ b/website_next/ask/tools/schemas.js @@ -0,0 +1,145 @@ +/** @param {Record} properties @param {string[]} required */ +function actionTool(properties, required) { + return { + type: "function", + function: { + name: "next_action", + description: "Choose exactly one allowed next step.", + parameters: { + type: "object", + properties, + required, + additionalProperties: false, + }, + }, + }; +} + +/** @param {boolean} hasActiveChart @param {boolean} hasPrevious @param {{ path: string, label: string }[]} [categories] */ +export function searchTool(hasActiveChart = false, hasPrevious = false, categories = []) { + return actionTool({ + action: { type: "string", enum: ["search"] }, + context: { + type: "string", + enum: hasPrevious + ? ["new_topic", "reuse_previous", "extend_previous"] + : ["new_topic"], + description: "Reuse the previous verified topic for dependent follow-ups. Extend it only when the user adds another distinct metric. Otherwise start a new topic.", + }, + queries: { + type: "array", + minItems: 1, + maxItems: 4, + items: { type: "string" }, + description: "One terse catalog-style Bitcoin metric or technical noun phrase per distinct topic. Translate the user's meaning. Never copy a question or include request verbs, pronouns, time words, or punctuation. For X vs Y, return separate complete X and Y metric phrases.", + }, + cardinality: { + type: "string", + 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: [ + "read_requested_value", + "build_requested_chart", + ...(hasActiveChart ? ["edit_existing_chart"] : []), + "explain_from_verified_facts", + ], + }, + 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"]); +} + +/** @param {{ ref: string, label: string }[]} options */ +export function navigateTool(options) { + return actionTool({ + action: { type: "string", enum: ["navigate"] }, + refs: { + type: "array", + minItems: 1, + maxItems: 3, + items: { type: "string", enum: options.map(({ ref }) => ref) }, + description: `Metric families: ${options.map(({ ref, label }) => `${ref}=${label}`).join("; ")}`, + }, + }, ["action", "refs"]); +} + +/** @param {string[]} queries */ +export function rewriteTool(queries) { + return actionTool({ + action: { type: "string", enum: ["rewrite"] }, + queries: { + type: "array", + 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.`, + }, + }, ["action", "queries"]); +} + +/** + * @param {{ ref: string, label: string }[]} options + * @param {string} outcome + * @param {number} [maxItems] + */ +export function resolveTool(options, outcome, maxItems = 3) { + const refs = { + type: "array", + minItems: 1, + maxItems, + items: { type: "string", enum: options.map(({ ref }) => ref) }, + description: `Available references: ${options.map(({ ref, label }) => `${ref}=${label}`).join("; ")}`, + }; + + if (outcome === "explain_from_verified_facts") { + return actionTool({ + action: { type: "string", enum: ["answer"] }, + refs, + }, ["action", "refs"]); + } + + if (outcome === "read_requested_value") { + return actionTool({ + action: { type: "string", enum: ["read_data"] }, + refs, + mode: { type: "string", enum: ["latest", "at", "range"] }, + index: { type: "string", description: "Index such as height or day1." }, + at: { type: "string", description: "Block height or date for at mode." }, + start: { type: "string" }, + end: { type: "string" }, + points: { type: "integer", minimum: 1, maximum: 120 }, + }, ["action", "refs", "mode"]); + } + + const editing = outcome === "edit_existing_chart"; + return actionTool({ + action: { type: "string", enum: [editing ? "edit_chart" : "build_chart"] }, + refs, + title: { type: "string" }, + operation: { + type: "string", + enum: ["add", "remove", "replace"], + description: "For edit_chart, make exactly the requested change.", + }, + }, ["action", "refs"]); +} + +export function clarifyTool() { + return actionTool({ + action: { type: "string", enum: ["clarify"] }, + text: { type: "string", description: "One necessary clarification question." }, + }, ["action", "text"]); +} diff --git a/website_next/ask/tools/session.js b/website_next/ask/tools/session.js new file mode 100644 index 000000000..1c83afec0 --- /dev/null +++ b/website_next/ask/tools/session.js @@ -0,0 +1,790 @@ +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} 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} 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} 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); + } +} diff --git a/website_next/ask/tools/source/formula.js b/website_next/ask/tools/source/formula.js new file mode 100644 index 000000000..cc24acf46 --- /dev/null +++ b/website_next/ask/tools/source/formula.js @@ -0,0 +1,167 @@ +const DEFINITION = /\b([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.+)$/; +const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]*$/; + +/** @param {string} value */ +function cleanExpression(value) { + return value + .replace(/[`.;]+$/g, "") + .replace(/[·×]/g, "*") + .replace(/([A-Za-z][A-Za-z0-9_]*)²/g, "$1^2") + .replace(/\s+/g, "") + .replace(/^sum/i, "Σ"); +} + +/** @param {string} value @param {string} operator */ +function splitTopLevel(value, operator) { + let depth = 0; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === "(") depth += 1; + else if (character === ")") depth -= 1; + else if (character === operator && depth === 0) { + return [value.slice(0, index), value.slice(index + 1)]; + } + } + + return undefined; +} + +/** @param {string} value */ +function unwrapSum(value) { + return value.match(/^Σ\((.+)\)$/)?.[1]; +} + +/** @param {string} value */ +function factorMap(value) { + /** @type {Map} */ + const factors = new Map(); + + for (const rawFactor of value.split("*")) { + const match = rawFactor.match(/^([A-Za-z][A-Za-z0-9_]*)(?:\^(\d+))?$/); + if (!match) return undefined; + + const [, name, rawPower] = match; + const power = Number(rawPower ?? 1); + if (!IDENTIFIER.test(name) || power < 1) return undefined; + factors.set(name, (factors.get(name) ?? 0) + power); + } + + return factors; +} + +/** @param {Map} total @param {Map} part */ +function subtractFactors(total, part) { + const result = new Map(total); + + for (const [name, power] of part) { + const remaining = (result.get(name) ?? 0) - power; + if (remaining < 0) return undefined; + if (remaining === 0) result.delete(name); + else result.set(name, remaining); + } + + return result; +} + +/** @param {Map} factors */ +function factorsText(factors) { + return [...factors] + .flatMap(([name, power]) => Array.from({ length: power }, () => name)) + .join(" × "); +} + +/** @param {string} value */ +function title(value) { + const words = value.replace(/_/g, " "); + return words[0].toUpperCase() + words.slice(1); +} + +/** @param {string} value */ +function plural(value) { + if (value.includes(" × ")) return `${value} values`; + return value.endsWith("s") ? value : `${value}s`; +} + +/** @param {string} metric @param {string} rawExpression */ +function deriveFormula(metric, rawExpression) { + const expression = cleanExpression(rawExpression); + const division = splitTopLevel(expression, "/"); + if (!division) return undefined; + + const numeratorExpression = unwrapSum(division[0]); + const denominatorExpression = unwrapSum(division[1]); + if (!numeratorExpression || !denominatorExpression) return undefined; + + const numerator = factorMap(numeratorExpression); + const weight = factorMap(denominatorExpression); + if (!numerator || !weight) return undefined; + + const value = subtractFactors(numerator, weight); + if (!value?.size) return undefined; + + const otherWeight = subtractFactors(weight, value); + return { + metric, + formula: `${metric} = ${rawExpression.trim()}`, + value: factorsText(value), + weight: factorsText(weight), + otherWeight: otherWeight ? factorsText(otherWeight) : "", + }; +} + +/** @param {{ path: string, text: string }} file */ +function formulasInFile(file) { + return file.text.split("\n").flatMap((line, index) => { + const comment = line.replace(/^\s*(?:\/\/\/?|#)\s?/, "").trim(); + const definition = comment.match(DEFINITION); + if (!definition) return []; + + const fact = deriveFormula(definition[1], definition[2]); + return fact ? [{ ...fact, path: file.path, line: index + 1 }] : []; + }); +} + +/** @param {string} question @param {string} metric */ +function questionScore(question, metric) { + const normalizedQuestion = question.toLowerCase().replace(/[_-]+/g, " "); + const normalizedMetric = metric.toLowerCase().replace(/_/g, " "); + if (normalizedQuestion.includes(normalizedMetric)) return normalizedMetric.length + 10; + + const words = normalizedMetric.split(" "); + return words.every((word) => normalizedQuestion.includes(word)) ? words.length : 0; +} + +/** @param {{ path: string, text: string }[]} files */ +export function createFormulaIndex(files) { + return files.flatMap(formulasInFile); +} + +/** + * @param {string} question + * @param {ReturnType} formulas + */ +export function explainFormula(question, formulas) { + const fact = formulas + .map((candidate) => ({ + candidate, + score: questionScore(question, candidate.metric), + })) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score)[0]?.candidate; + if (!fact) return undefined; + + const metric = title(fact.metric); + const values = plural(fact.value); + const consequence = fact.otherWeight + ? ` For the same ${fact.otherWeight}, higher ${values} have more influence on the result.` + : ""; + + return { + answer: + `${metric} is a weighted average of ${values}. ` + + `Each ${fact.value} is weighted by \`${fact.weight}\`.${consequence}\n\n` + + `Source: \`${fact.path}:${fact.line}\``, + fact, + }; +} diff --git a/website_next/ask/tools/source/index.js b/website_next/ask/tools/source/index.js new file mode 100644 index 000000000..6eb6df873 --- /dev/null +++ b/website_next/ask/tools/source/index.js @@ -0,0 +1,91 @@ +const WORKER_URL = import.meta.resolve("./worker.js"); + +export class AskSource { + /** @type {Worker | undefined} */ + #worker; + + /** @type {Map void, reject: (error: Error) => void, onProgress?: (progress: { loaded: number, total: number }) => void }>} */ + #pending = new Map(); + + /** + * @param {string} query + * @param {string | undefined} path + * @param {(progress: { loaded: number, total: number }) => void} onProgress + */ + search(query, path, onProgress) { + return this.#request("search", { query, path }, onProgress); + } + + /** + * @param {string} question + * @param {(progress: { loaded: number, total: number }) => void} onProgress + */ + explain(question, onProgress) { + return this.#request("explain", { question }, onProgress); + } + + /** + * @param {string} path + * @param {number} startLine + * @param {number} endLine + */ + read(path, startLine, endLine) { + return this.#request("read", { path, startLine, endLine }); + } + + /** + * @param {"search" | "read" | "explain"} type + * @param {Record} data + * @param {((progress: { loaded: number, total: number }) => void) | undefined} [onProgress] + */ + #request(type, data, onProgress) { + this.#ensureWorker(); + const id = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject, onProgress }); + this.#worker?.postMessage({ id, type, data }); + }); + } + + #ensureWorker() { + if (this.#worker) return; + + this.#worker = new Worker(WORKER_URL, { type: "module" }); + this.#worker.addEventListener("message", this.#handleMessage); + this.#worker.addEventListener("error", this.#handleWorkerError); + } + + terminate() { + const error = new Error("Source search stopped"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + } + + /** @param {MessageEvent} event */ + #handleMessage = (event) => { + const message = event.data; + const request = this.#pending.get(message.id); + if (!request) return; + + if (message.status === "progress") { + request.onProgress?.({ loaded: message.loaded, total: message.total }); + return; + } + + this.#pending.delete(message.id); + if (message.status === "complete") request.resolve(message.data); + else request.reject(new Error(message.data)); + }; + + /** @param {ErrorEvent} event */ + #handleWorkerError = (event) => { + const error = new Error(event.message || "The source worker failed"); + for (const request of this.#pending.values()) request.reject(error); + this.#pending.clear(); + this.#worker?.terminate(); + this.#worker = undefined; + }; +} diff --git a/website_next/ask/tools/source/worker.js b/website_next/ask/tools/source/worker.js new file mode 100644 index 000000000..c9f3d2d04 --- /dev/null +++ b/website_next/ask/tools/source/worker.js @@ -0,0 +1,353 @@ +import { createFormulaIndex, explainFormula } from "./formula.js"; + +const REPOSITORY = "bitcoinresearchkit/brk"; +const TREE_URL = `https://api.github.com/repos/${REPOSITORY}/git/trees/main?recursive=1`; +const RAW_URL = `https://raw.githubusercontent.com/${REPOSITORY}`; +const DATABASE_NAME = "bitview-ask-source-v1"; +const STORE_NAME = "snapshots"; +const MAX_FILE_SIZE = 128_000; +const MAX_READ_LINES = 120; +const MAX_READ_CHARACTERS = 2_500; +const MAX_EXCERPT_CHARACTERS = 500; +const CONCURRENCY = 12; +const SEARCH_STOPWORDS = new Set([ + "a", + "about", + "and", + "bitview", + "bitcoin", + "brk", + "code", + "define", + "does", + "explain", + "for", + "how", + "in", + "is", + "mean", + "of", + "repo", + "repository", + "source", + "the", + "what", + "where", + "why", + "work", + "works", +]); +const EXTENSIONS = new Set([ + "css", + "html", + "js", + "json", + "md", + "mjs", + "py", + "rs", + "sh", + "toml", + "ts", + "yaml", + "yml", +]); +const EXCLUDED_PREFIXES = [ + ".git/", + ".github/", + "modules/", + "packages/brk_client/brk_client/", + "target/", + "website/assets/", + "website_next/modules/", +]; +const EXCLUDED_FILES = new Set([ + "docs/CHANGELOG.md", + "website/scripts/options/scalar.js", +]); + +/** @type {Promise<{ sha: string, entries: { path: string, size: number }[] }> | undefined} */ +let treePromise; + +/** @type {Promise<{ sha: string, files: { path: string, text: string }[] }> | undefined} */ +let snapshotPromise; + +/** @type {ReturnType | undefined} */ +let formulaIndex; + +/** @param {unknown} error */ +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +/** @param {string} path */ +function extension(path) { + return path.slice(path.lastIndexOf(".") + 1).toLowerCase(); +} + +/** @param {{ type: string, path: string, size?: number }} item */ +function isSource(item) { + return ( + item.type === "blob" && + Number(item.size ?? 0) <= MAX_FILE_SIZE && + EXTENSIONS.has(extension(item.path)) && + !EXCLUDED_FILES.has(item.path) && + !EXCLUDED_PREFIXES.some((prefix) => item.path.startsWith(prefix)) + ); +} + +async function loadTree() { + treePromise ??= fetch(TREE_URL, { + headers: { Accept: "application/vnd.github+json" }, + }).then(async (response) => { + if (!response.ok) { + throw new Error(`GitHub source tree unavailable (${response.status})`); + } + + const data = await response.json(); + if (data.truncated) throw new Error("GitHub returned a truncated source tree"); + + const tree = /** @type {{ type: string, path: string, size?: number }[]} */ ( + data.tree + ); + return { + sha: data.sha, + entries: tree.filter(isSource).map((item) => ({ + path: item.path, + size: Number(item.size ?? 0), + })), + }; + }); + + return treePromise; +} + +/** @param {string} sha @param {string} path */ +async function fetchSource(sha, path) { + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + const response = await fetch(`${RAW_URL}/${sha}/${encodedPath}`); + if (!response.ok) throw new Error(`Could not fetch ${path}`); + return response.text(); +} + +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => { + request.result.createObjectStore(STORE_NAME, { keyPath: "sha" }); + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + }); +} + +/** @param {IDBDatabase} database @param {string} sha */ +function readSnapshot(database, sha) { + return new Promise((resolve, reject) => { + const request = database + .transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .get(sha); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + }); +} + +/** @param {IDBDatabase} database @param {{ sha: string, files: { path: string, text: string }[] }} snapshot */ +function writeSnapshot(database, snapshot) { + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + store.clear(); + store.put(snapshot); + transaction.addEventListener("complete", () => resolve(undefined)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +/** @param {(loaded: number, total: number) => void} reportProgress */ +async function loadSnapshot(reportProgress) { + const tree = await loadTree(); + const database = /** @type {IDBDatabase} */ (await openDatabase()); + + try { + const cached = /** @type {{ sha: string, files: { path: string, text: string }[] } | undefined} */ ( + await readSnapshot(database, tree.sha) + ); + if (cached) { + reportProgress(cached.files.length, cached.files.length); + return cached; + } + + const files = new Array(tree.entries.length); + let cursor = 0; + let loaded = 0; + reportProgress(loaded, tree.entries.length); + + async function download() { + while (cursor < tree.entries.length) { + const index = cursor; + cursor += 1; + const entry = tree.entries[index]; + files[index] = { + path: entry.path, + text: await fetchSource(tree.sha, entry.path), + }; + loaded += 1; + if (loaded % 20 === 0 || loaded === tree.entries.length) { + reportProgress(loaded, tree.entries.length); + } + } + } + + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, tree.entries.length) }, download), + ); + const snapshot = { sha: tree.sha, files }; + await writeSnapshot(database, snapshot); + return snapshot; + } finally { + database.close(); + } +} + +/** @param {(loaded: number, total: number) => void} reportProgress */ +function ensureSnapshot(reportProgress) { + snapshotPromise ??= loadSnapshot(reportProgress).catch((error) => { + snapshotPromise = undefined; + throw error; + }); + return snapshotPromise; +} + +/** @param {string} value */ +function normalize(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** @param {string} text @param {number} index */ +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +/** @param {string} text @param {number} line */ +function excerptAt(text, line) { + const lines = text.split("\n"); + const start = Math.max(1, line - 3); + const end = Math.min(lines.length, line + 3); + return { + startLine: start, + endLine: end, + content: lines + .slice(start - 1, end) + .join("\n") + .slice(0, MAX_EXCERPT_CHARACTERS), + }; +} + +/** + * @param {{ query: string, path?: string }} args + * @param {(loaded: number, total: number) => void} reportProgress + */ +async function search(args, reportProgress) { + const rawQuery = normalize(args.query); + if (!rawQuery) throw new Error("Search query is empty"); + const pathPrefix = String(args.path ?? "").replace(/^\/+/, ""); + const rawTokens = rawQuery.split(" "); + const tokens = rawTokens.filter((token) => !SEARCH_STOPWORDS.has(token)); + const searchTokens = tokens.length ? tokens : rawTokens; + const query = searchTokens.join(" "); + const snapshot = await ensureSnapshot(reportProgress); + const matches = []; + + for (const file of snapshot.files) { + if (pathPrefix && !file.path.startsWith(pathPrefix)) continue; + const normalized = normalize(file.text); + let index = normalized.indexOf(query); + let score = 2; + + if (index < 0 && searchTokens.every((token) => normalized.includes(token))) { + index = normalized.indexOf(searchTokens[0]); + score = 1; + } + if (index < 0) continue; + + const rawIndex = file.text.toLowerCase().indexOf(searchTokens[0]); + const line = lineAt(file.text, Math.max(0, rawIndex)); + matches.push({ + path: file.path, + score: score + (normalize(file.path).includes(query) ? 2 : 0), + ...excerptAt(file.text, line), + }); + } + + matches.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); + return { + revision: snapshot.sha, + matches: matches.slice(0, 4).map(({ score, ...match }) => match), + }; +} + +/** + * @param {{ question: string }} args + * @param {(loaded: number, total: number) => void} reportProgress + */ +async function explain(args, reportProgress) { + const snapshot = await ensureSnapshot(reportProgress); + formulaIndex ??= createFormulaIndex(snapshot.files); + const result = explainFormula(String(args.question ?? ""), formulaIndex); + return result ? { revision: snapshot.sha, ...result } : undefined; +} + +/** @param {{ path: string, startLine: number, endLine: number }} args */ +async function read(args) { + const path = String(args.path).replace(/^\/+/, ""); + const startLine = Math.max(1, Math.floor(Number(args.startLine))); + const requestedEnd = Math.max(startLine, Math.floor(Number(args.endLine))); + const endLine = Math.min(requestedEnd, startLine + MAX_READ_LINES - 1); + const tree = await loadTree(); + if (!tree.entries.some((entry) => entry.path === path)) { + throw new Error(`Source file not found: ${path}`); + } + + const snapshot = await snapshotPromise; + const text = snapshot?.files.find((file) => file.path === path)?.text ?? + (await fetchSource(tree.sha, path)); + const lines = text.split("\n"); + if (startLine > lines.length) { + throw new Error(`${path} only has ${lines.length} lines`); + } + const lastLine = Math.min(endLine, lines.length); + const content = lines.slice(startLine - 1, lastLine).join("\n"); + + return { + revision: tree.sha, + path, + startLine, + endLine: lastLine, + content: content.slice(0, MAX_READ_CHARACTERS), + truncated: content.length > MAX_READ_CHARACTERS, + }; +} + +self.addEventListener("message", async (event) => { + const { id, type, data } = event.data; + const reportProgress = (/** @type {number} */ loaded, /** @type {number} */ total) => { + self.postMessage({ id, status: "progress", loaded, total }); + }; + + try { + const result = type === "search" + ? await search(data, reportProgress) + : type === "explain" + ? await explain(data, reportProgress) + : await read(data); + self.postMessage({ id, status: "complete", data: result }); + } catch (error) { + self.postMessage({ id, status: "error", data: errorMessage(error) }); + } +}); diff --git a/website_next/ask/tools/text.js b/website_next/ask/tools/text.js new file mode 100644 index 000000000..b7e5f52f2 --- /dev/null +++ b/website_next/ask/tools/text.js @@ -0,0 +1,52 @@ +const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g; +const NON_WORD = /[^a-z0-9%]+/g; + +/** @param {unknown} value */ +export function normalize(value) { + return String(value) + .replace(CAMEL_BOUNDARY, "$1 $2") + .toLowerCase() + .replace(NON_WORD, " ") + .trim() + .replace(/\s+/g, " "); +} + +/** @param {unknown} value */ +export function tokens(value) { + return normalize(value).split(" ").filter((token) => token.length > 1); +} + +/** @param {unknown} value */ +function trigrams(value) { + const padded = ` ${normalize(value)} `; + const values = new Set(); + for (let index = 0; index < padded.length - 2; index += 1) { + values.add(padded.slice(index, index + 3)); + } + return values; +} + +/** @param {unknown} left @param {unknown} right */ +export function similarity(left, right) { + const a = trigrams(left); + const b = trigrams(right); + if (!a.size || !b.size) return 0; + + let overlap = 0; + for (const value of a) if (b.has(value)) overlap += 1; + return (2 * overlap) / (a.size + b.size); +} + +/** @param {unknown} query @param {unknown} document @param {number} [boost] */ +export function relevance(query, document, boost = 0) { + const needle = normalize(query); + const haystack = normalize(document); + if (!needle || !haystack) return 0; + + const words = tokens(needle); + const exact = haystack.includes(needle) ? 30 : 0; + const coverage = words.length + ? words.filter((word) => haystack.includes(word)).length / words.length + : 0; + return boost + exact + coverage * 20 + similarity(needle, haystack) * 10; +} diff --git a/website_next/ask/worker.js b/website_next/ask/worker.js index e583df02b..eb49a79b1 100644 --- a/website_next/ask/worker.js +++ b/website_next/ask/worker.js @@ -1,140 +1,162 @@ import { ASK_MODEL } from "./models.js"; -const TRANSFORMERS_URL = - "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.1.0"; +/** @type {any} bitgpu has no local declarations. */ +let engine; -/** @type {any} CDN module without local declarations. */ -let generator; +/** @type {any} bitgpu/chat has no local declarations. */ +let chat; -/** @type {any} CDN module without local declarations. */ -let stoppingCriteria; - -/** @type {any} CDN module without local declarations. */ -let transformers; +/** @type {AbortController | undefined} */ +let generationController; /** @param {unknown} error */ function errorMessage(error) { return error instanceof Error ? error.message : String(error); } +/** @param {string} url */ +async function cachedResponse(url) { + const cache = await caches.open(ASK_MODEL.cacheName); + const cached = await cache.match(url); + if (cached) return cached; + + const response = await fetch(url); + if (!response.ok) throw new Error(`Could not download model file (${response.status})`); + + void cache.put(url, response.clone()).catch(() => {}); + return response; +} + +/** @param {string} url */ +async function fetchJson(url) { + return (await cachedResponse(url)).json(); +} + +/** @param {string} url */ +async function fetchArrayBuffer(url) { + return (await cachedResponse(url)).arrayBuffer(); +} + +/** @param {string} url */ +async function fetchStream(url) { + const body = (await cachedResponse(url)).body; + if (!body) throw new Error("The model download did not provide a stream"); + return body; +} + async function load() { - const adapter = await navigator.gpu?.requestAdapter(); - if (!adapter) throw new Error("WebGPU is unavailable or no adapter was found"); + if (chat) { + self.postMessage({ status: "ready" }); + return; + } + + if (!navigator.gpu) throw new Error("WebGPU is unavailable in this browser"); self.postMessage({ status: "loading", data: "Loading AI runtime..." }); - await loadRuntime(); - stoppingCriteria = new transformers.InterruptableStoppingCriteria(); + const [{ createEngine }, { createChat }] = await Promise.all([ + import(ASK_MODEL.runtimeUrl), + import(ASK_MODEL.chatUrl), + ]); self.postMessage({ status: "loading", data: `Loading ${ASK_MODEL.name}...` }); - generator = await transformers.pipeline("text-generation", ASK_MODEL.modelId, { - device: "webgpu", - dtype: ASK_MODEL.dtype, - revision: ASK_MODEL.revision, - /** @param {{ status: string, progress?: number, loaded?: number, total?: number }} info */ - progress_callback: (info) => { - if (info.status !== "progress_total") return; + engine = await createEngine({ + manifestUrl: ASK_MODEL.manifestUrl, + auxUrl: ASK_MODEL.auxUrl, + dataUrl: ASK_MODEL.dataUrl, + kvCache: "q8", + activation: "f16", + maxSeqLen: 4_096, + syncSteps: 1, + fetchJson, + fetchArrayBuffer, + fetchStream, + /** @param {{ phase: string, loaded?: number, total?: number }} progress */ + onProgress(progress) { + const loaded = Number(progress.loaded ?? 0); + const total = Number(progress.total ?? 0); + if (progress.phase !== "weights" || !total) return; self.postMessage({ status: "progress_total", - progress: Number(info.progress ?? 0), - loaded: Number(info.loaded ?? 0), - total: Number(info.total ?? 0), + progress: (loaded / total) * 100, + loaded, + total, + }); + }, + /** @param {{ message?: string }} info */ + onDeviceLost(info) { + engine = undefined; + chat = undefined; + self.postMessage({ + status: "error", + data: info.message || "The GPU device was lost", }); }, }); + chat = await createChat(engine, { + tokenizerJsonUrl: ASK_MODEL.tokenizerJsonUrl, + tokenizerConfigUrl: ASK_MODEL.tokenizerConfigUrl, + fetchJson, + }); - self.postMessage({ status: "loading", data: "Warming up WebGPU..." }); - const inputs = generator.tokenizer("a"); - await generator.model.generate({ ...inputs, max_new_tokens: 1 }); self.postMessage({ status: "ready" }); } -async function loadRuntime() { - transformers ??= await import(TRANSFORMERS_URL); - transformers.env.allowLocalModels = false; -} - async function checkCache() { - await loadRuntime(); - const cached = await transformers.ModelRegistry.is_pipeline_cached( - "text-generation", - ASK_MODEL.modelId, - { - device: "webgpu", - dtype: ASK_MODEL.dtype, - revision: ASK_MODEL.revision, - }, - ); - self.postMessage({ status: "cache-status", cached }); + const cache = await caches.open(ASK_MODEL.cacheName); + self.postMessage({ + status: "cache-status", + cached: Boolean(await cache.match(ASK_MODEL.dataUrl)), + }); } /** - * @param {{ role: string, content: string }[]} messages - * @param {{ maxNewTokens: number, stream: boolean }} options + * @param {{ role: string, content: string, tool_calls?: { name: string, arguments: Record }[] }[]} messages + * @param {{ maxTokens: number, stream: boolean, tools: readonly any[], toolChoice?: "auto" | "none" | { name: string } }} options */ async function generate(messages, options) { - if (!generator || !stoppingCriteria || !transformers) { - throw new Error("Model is not loaded"); - } + if (!chat) throw new Error("Model is not loaded"); - let startedAt; - let tokenCount = 0; - /** @type {number | undefined} */ - let tokensPerSecond; - const streamer = options.stream - ? new transformers.TextStreamer(generator.tokenizer, { - skip_prompt: true, - skip_special_tokens: true, - /** @param {string} output */ - callback_function: (output) => { - self.postMessage({ status: "update", output, tokensPerSecond }); - }, - token_callback_function: () => { - startedAt ??= performance.now(); - tokenCount += 1; - - if (tokenCount > 1) { - tokensPerSecond = - (tokenCount / (performance.now() - startedAt)) * 1_000; - } - }, - }) - : undefined; - const cache = new transformers.DynamicCache(); + generationController = new AbortController(); + const tools = options.tools.length ? options.tools : undefined; + const stream = options.stream && (!tools || options.toolChoice === "none"); try { - const output = await generator(messages, { - max_new_tokens: options.maxNewTokens, - do_sample: false, - streamer, - stopping_criteria: stoppingCriteria, - past_key_values: cache, + const result = await chat.send(messages, { + maxTokens: options.maxTokens, + temperature: 0, + repetitionPenalty: 1.05, + signal: generationController.signal, + tools, + toolChoice: tools ? options.toolChoice : undefined, + onText: stream + ? (/** @type {string} */ output) => { + self.postMessage({ status: "update", output }); + } + : undefined, }); self.postMessage({ status: "complete", - output: output[0].generated_text.at(-1).content, + result: { + text: result.text, + toolCalls: result.toolCalls, + finishReason: result.finishReason, + tokensPerSecond: result.tokensPerSecond, + }, }); } finally { - cache.dispose?.(); + generationController = undefined; } } -function reset() { - stoppingCriteria?.reset(); -} - -/** @param {{ role: string, content: string }[]} messages */ -function countTokens(messages) { - if (!generator) throw new Error("Model is not loaded"); - - const tokens = generator.tokenizer.apply_chat_template(messages, { - add_generation_prompt: true, - tokenize: true, - return_tensor: false, - return_dict: false, +/** @param {{ role: string, content: string }[]} messages @param {readonly any[]} tools */ +function countTokens(messages, tools) { + if (!chat) throw new Error("Model is not loaded"); + self.postMessage({ + status: "counted", + count: chat.countTokens(messages, { tools: tools.length ? tools : undefined }), }); - self.postMessage({ status: "counted", count: tokens.length }); } self.addEventListener("message", async (event) => { @@ -149,21 +171,28 @@ self.addEventListener("message", async (event) => { await load(); break; case "generate": - stoppingCriteria?.reset(); - await generate(data, { maxNewTokens: 384, stream: true }); + await generate(data.messages, { + maxTokens: 256, + stream: true, + tools: data.tools, + toolChoice: data.toolChoice, + }); break; case "compact": - stoppingCriteria?.reset(); - await generate(data, { maxNewTokens: 512, stream: false }); + await generate(data.messages, { + maxTokens: 256, + stream: false, + tools: [], + }); break; case "count": - countTokens(data); + countTokens(data.messages, data.tools); break; case "interrupt": - stoppingCriteria?.interrupt(); + generationController?.abort(); break; case "reset": - reset(); + chat?.reset(); break; } } catch (error) { diff --git a/website_next/index.html b/website_next/index.html index 1ee927514..b972d79ac 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -114,6 +114,7 @@ +