diff --git a/website_next/ask/tools/direct/chart.js b/website_next/ask/tools/direct/chart.js new file mode 100644 index 000000000..8914c7417 --- /dev/null +++ b/website_next/ask/tools/direct/chart.js @@ -0,0 +1,24 @@ +import { normalize } from "../text.js"; + +const CHART_REQUEST = /\b(?:chart|graph|plot)\b/; +const ADD_REQUEST = /\b(?:add|include|overlay)\b/; +const REMOVE_REQUEST = /\b(?:remove|drop)\b/; + +/** + * @param {string} request + * @param {boolean} hasActiveChart + */ +export function directChartCommand(request, hasActiveChart) { + const text = normalize(request); + + if (hasActiveChart && ADD_REQUEST.test(text)) { + return { kind: /** @type {const} */ ("edit"), operation: /** @type {const} */ ("add") }; + } + if (hasActiveChart && REMOVE_REQUEST.test(text)) { + return { kind: /** @type {const} */ ("edit"), operation: /** @type {const} */ ("remove") }; + } + if (CHART_REQUEST.test(text) && !ADD_REQUEST.test(text) && !REMOVE_REQUEST.test(text)) { + return { kind: /** @type {const} */ ("build"), operation: /** @type {const} */ ("add") }; + } + return undefined; +} diff --git a/website_next/ask/tools/metrics/index.js b/website_next/ask/tools/metrics/index.js index 63aca4cae..278acd26f 100644 --- a/website_next/ask/tools/metrics/index.js +++ b/website_next/ask/tools/metrics/index.js @@ -64,7 +64,7 @@ class MetricIndex { /** @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] */ + /** @param {"search" | "mentions" | "categories" | "byName" | "variants"} type @param {Record} data @param {(() => void) | undefined} [onProgress] */ request(type, data, onProgress) { this.#ensureWorker(); const id = crypto.randomUUID(); @@ -123,6 +123,11 @@ export function searchMetrics(queries, limit = 16, prefixes = [], onProgress) { return index.request("search", { queries, limit, prefixes }, onProgress); } +/** @param {string} query @param {(() => void) | undefined} [onProgress] @returns {Promise} */ +export function mentionedMetrics(query, onProgress) { + return index.request("mentions", { query }, onProgress); +} + /** @returns {Promise<{ path: string, label: string, count: number, examples: string[] }[]>} */ export function metricCategories() { return index.request("categories", {}); diff --git a/website_next/ask/tools/metrics/worker.js b/website_next/ask/tools/metrics/worker.js index 80fa8e738..b93bb6c5f 100644 --- a/website_next/ask/tools/metrics/worker.js +++ b/website_next/ask/tools/metrics/worker.js @@ -56,6 +56,8 @@ function buildState() { const items = []; /** @type {Map} */ const byName = new Map(); + /** @type {Map} */ + const bySearchableName = new Map(); /** @type {Map} */ const byDocument = new Map(); const seen = new WeakSet(); @@ -76,6 +78,10 @@ function buildState() { const metric = { path, name, endpoint, document }; items.push(metric); if (!byName.has(name)) byName.set(name, metric); + const nameKey = searchable(name); + const named = bySearchableName.get(nameKey) ?? []; + named.push(metric); + bySearchableName.set(nameKey, named); byDocument.set(document, metric); } else { for (const [key, child] of Object.entries(value)) { @@ -88,7 +94,7 @@ function buildState() { const matcher = new QuickMatch(items.map(({ document }) => document), config); /** @type {Map} */ const scoped = new Map(); - return { items, byName, byDocument, matcher, config, scoped }; + return { items, byName, bySearchableName, byDocument, matcher, config, scoped }; } /** @type {Promise> | undefined} */ @@ -199,6 +205,31 @@ function search(index, queries, limit, prefixes) { return output; } +/** @param {ReturnType} index @param {string} query */ +function mentions(index, query) { + const words = searchable(query).match(/[a-z0-9]+/g) ?? []; + /** @type {{ start: number, end: number, metric: CatalogMetric }[]} */ + const matches = []; + + for (let start = 0; start < words.length; start += 1) { + for (let end = start + 1; end <= words.length; end += 1) { + const named = index.bySearchableName.get(words.slice(start, end).join(" ")); + if (named?.length === 1) matches.push({ start, end, metric: named[0] }); + } + } + + const maximal = matches.filter((match) => + !matches.some((candidate) => + candidate.start <= match.start && + candidate.end >= match.end && + candidate.end - candidate.start > match.end - match.start + ) + ); + return [...new Map( + maximal.map(({ metric }) => [metric.path, publicMetric(metric)]), + ).values()]; +} + /** @param {ReturnType} index */ function categories(index) { /** @type {Map[] }>} */ @@ -301,6 +332,8 @@ self.addEventListener("message", async (event) => { let result; if (type === "search") { result = search(index, data.queries, data.limit, data.prefixes); + } else if (type === "mentions") { + result = mentions(index, data.query); } else if (type === "categories") { result = categories(index); } else if (type === "byName") { diff --git a/website_next/ask/tools/session.js b/website_next/ask/tools/session.js index 1c83afec0..4bf1c7a3a 100644 --- a/website_next/ask/tools/session.js +++ b/website_next/ask/tools/session.js @@ -1,10 +1,12 @@ import { createChartArtifact } from "./chart.js"; import { readMetric } from "./data.js"; +import { directChartCommand } from "./direct/chart.js"; import { searchLearn } from "./learn.js"; import { metricByName, metricCategories, metricVariants, + mentionedMetrics, searchMetrics, } from "./metrics/index.js"; import { ASK_STAGE_PROMPTS } from "./prompts.js"; @@ -101,6 +103,15 @@ function isDirectDefinition(request) { return asks && !needsRouting; } +/** @param {string[]} topics @param {() => void} onProgress */ +async function exactTopicMetrics(topics, onProgress) { + if (!topics.length) return []; + + const names = new Set(topics.map(normalize)); + const metrics = await searchMetrics(topics, MAX_OPTIONS, [], onProgress); + return metrics.filter((metric) => names.has(normalize(metric.name))); +} + /** @param {string} request */ function directReadAction(request) { const block = request.match(/\bblock\s+(\d{4,})\b/i)?.[1]; @@ -251,22 +262,50 @@ export class AskToolSession { /** @param {(status: string) => void} onStatus */ async tryDirect(onStatus) { + const chart = directChartCommand(this.request, Boolean(this.activeChart)); + if (chart) { + let metrics = await mentionedMetrics( + this.request, + () => onStatus("Indexing metrics…"), + ); + if (!metrics.length) { + metrics = await exactTopicMetrics( + this.previousTopics, + () => onStatus("Indexing metrics…"), + ); + } + + if (metrics.length) { + const refs = metrics.map((metric) => this.refs.issue("metric", metric, metric.path)); + this.outcome = chart.kind === "edit" + ? "edit_existing_chart" + : "build_requested_chart"; + this.rememberMetrics(refs); + onStatus("Building chart…"); + const result = this.buildChart({ refs, operation: chart.operation }); + return { output: result.output, artifacts: result.artifacts }; + } + } + if (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, - [], + let metrics = await mentionedMetrics( + this.request, () => onStatus("Indexing metrics…"), - )).filter((metric) => normalize(metric.name) === normalize(topic)); + ); + if (!metrics.length) { + metrics = await exactTopicMetrics( + this.previousTopics, + () => onStatus("Indexing metrics…"), + ); + } - if (metrics.length === 1) { + if (metrics.length) { onStatus("Reading data…"); - const result = await readMetric(metrics[0], action); - this.previousTopics = [label(metrics[0].name)]; - return { output: renderData([result]), artifacts: [] }; + const results = await Promise.all(metrics.map((metric) => readMetric(metric, action))); + this.previousTopics = metrics.map((metric) => label(metric.name)); + return { output: renderData(results), artifacts: [] }; } } }