next: ai part 4

This commit is contained in:
nym21
2026-07-22 17:25:46 +02:00
parent f0871d895a
commit b6940a1ae7
4 changed files with 113 additions and 12 deletions
+24
View File
@@ -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;
}
+6 -1
View File
@@ -64,7 +64,7 @@ class MetricIndex {
/** @type {Map<string, { resolve: (value: any) => void, reject: (error: Error) => void, onProgress?: () => void }>} */
#pending = new Map();
/** @param {"search" | "categories" | "byName" | "variants"} type @param {Record<string, unknown>} data @param {(() => void) | undefined} [onProgress] */
/** @param {"search" | "mentions" | "categories" | "byName" | "variants"} type @param {Record<string, unknown>} 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<CatalogMetric[]>} */
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", {});
+34 -1
View File
@@ -56,6 +56,8 @@ function buildState() {
const items = [];
/** @type {Map<string, CatalogMetric>} */
const byName = new Map();
/** @type {Map<string, CatalogMetric[]>} */
const bySearchableName = new Map();
/** @type {Map<string, CatalogMetric>} */
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<string, { matcher: QuickMatch, config: QuickMatchConfig }>} */
const scoped = new Map();
return { items, byName, byDocument, matcher, config, scoped };
return { items, byName, bySearchableName, byDocument, matcher, config, scoped };
}
/** @type {Promise<ReturnType<typeof buildState>> | undefined} */
@@ -199,6 +205,31 @@ function search(index, queries, limit, prefixes) {
return output;
}
/** @param {ReturnType<typeof buildState>} 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<typeof buildState>} index */
function categories(index) {
/** @type {Map<string, { path: string, label: string, count: number, branches: Map<string, number>[] }>} */
@@ -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") {
+49 -10
View File
@@ -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: [] };
}
}
}