Files
2026-07-29 18:42:09 +02:00

921 lines
27 KiB
JavaScript

import { AskRefs } from "../refs.js";
import {
ROUTE_INSTRUCTION,
actionInstruction,
actionTool,
apiArgumentTool,
availableActions,
capabilityMetrics,
directAction,
generalCapabilities,
requestedChartStyles,
routeTools,
} from "./capabilities.js";
import { loadSessionContext } from "./context.js";
import { collectEvidence, collectSourceOptions } from "./evidence.js";
import { CapabilityExecutor } from "./executor.js";
import { normalize, relevance, tokenAffinity } from "../text.js";
import { recordArguments, selectApiRecord } from "../api/records.js";
import {
explicitArguments,
hasRequiredArguments,
reusableArguments,
validatedArguments,
} from "../api/routing.js";
const RETRIEVAL_TERMS = new Set([
"display",
"fetch",
"get",
"inspect",
"lookup",
"read",
"retrieve",
"show",
]);
/** @param {unknown[]} values */
function schemaTokens(values) {
return new Set(
normalize(values.filter(Boolean).join(" "))
.split(" ")
.filter((token) => token.length >= 3),
);
}
/** @param {Set<string>} query @param {Set<string>} document */
function overlapCount(query, document) {
return [...query].filter((token) => document.has(token)).length;
}
/** @param {Set<string>} query @param {Set<string>} document */
function semanticOverlapCount(query, document) {
return [...query].filter((token) =>
[...document].some((candidate) =>
tokenAffinity(token, candidate) >= 0.75
)
).length;
}
/** @param {string} question */
function explicitPosition(question) {
const date = question.match(
/(?<![A-Za-z0-9])\d{4}-\d{2}-\d{2}(?![A-Za-z0-9])/,
)?.[0];
if (date) return date;
const values = [...new Set(
question.match(
/(?<![A-Za-z0-9])[-+]?\d[\d,]*(?:\.\d+)?(?![A-Za-z0-9])/g,
) ?? [],
)];
return values.length === 1 ? values[0].replaceAll(",", "") : undefined;
}
/** @param {any} evidence @param {string} question @param {boolean} [requiresExample] */
function preferredGuide(evidence, question, requiresExample = false) {
return evidence.guideOptions
.filter(({ guide }) => !requiresExample || guide.example)
.map((option) => ({
option,
relevance: Math.max(
relevance(question, option.guide.title),
relevance(evidence.context.knowledge?.title, option.guide.title),
),
}))
.sort((left, right) => right.relevance - left.relevance)[0]?.option;
}
export class AskToolSession {
/** @param {import("../source/index.js").AskSource} source */
constructor(source) {
this.source = source;
}
/** @param {string} question @param {import("../../storage.js").StoredMessage[]} history @param {(status: string) => void} onStatus */
async begin(question, history, onStatus) {
const refs = new AskRefs();
this.refs = refs;
const context = await loadSessionContext(
history,
() => onStatus("Indexing context…"),
);
if (context.api) {
const record = selectApiRecord(context.api.records, question);
if (record !== undefined) {
context.api.arguments = {
...context.api.arguments,
...recordArguments(record, context.api.operation.response.type),
};
}
}
onStatus("Searching tools…");
const evidence = await collectEvidence({
question,
context,
refs,
onStatus,
});
this.question = question;
this.evidence = evidence;
this.executor = new CapabilityExecutor({
question,
evidence,
refs,
source: this.source,
});
}
question = "";
/** @type {Awaited<ReturnType<typeof collectEvidence>> | undefined} */
evidence;
/** @type {CapabilityExecutor | undefined} */
executor;
/** @type {AskRefs | undefined} */
refs;
routeTools() {
if (!this.evidence) throw new Error("Tool session is not ready");
return routeTools(this.evidence);
}
/** @param {unknown} subject */
continuesKnowledge(subject) {
const title = this.evidence?.context.knowledge?.title;
if (typeof subject !== "string" || !title) return false;
return Math.max(
relevance(subject, title),
relevance(title, subject),
) >= 10;
}
/** @param {unknown} explicitSubject @param {unknown} topic */
continuesGeneral(explicitSubject, topic) {
if (!this.evidence?.context.knowledge) return false;
if (this.continuesKnowledge(topic)) return true;
if (typeof explicitSubject === "string") {
return !explicitSubject.trim() ||
this.continuesKnowledge(explicitSubject);
}
return false;
}
routeMessages() {
if (!this.evidence) throw new Error("Tool session is not ready");
const { context, apiOptions } = this.evidence;
const messages = [
{
role: /** @type {const} */ ("system"),
content: ROUTE_INSTRUCTION,
},
];
if (context.knowledge?.description) {
messages.push({
role: /** @type {const} */ ("assistant"),
content: context.knowledge.description,
});
}
messages.push(
{
role: /** @type {const} */ ("user"),
content: JSON.stringify({
request: this.question,
context: {
activeCapability: context.chart
? "chart"
: context.api
? "api"
: context.metrics.length
? "metric"
: context.source.length
? "source"
: context.knowledge
? "general"
: undefined,
...(context.chart
? {
activeChart: {
title: context.chart.chart.title,
series: context.chart.chart.series.map(
(/** @type {any} */ { path, label }) => ({ path, label }),
),
},
}
: {}),
...(context.metrics.length
? {
metrics: context.metrics.map(
(/** @type {any} */ metric) => ({
name: metric.name,
path: metric.path,
}),
),
}
: {}),
...(context.recentMetrics.length
? {
recentMetrics: context.recentMetrics.map(
(/** @type {any} */ metric) => ({
name: metric.name,
path: metric.path,
}),
),
}
: {}),
...(context.api
? {
api: {
operation: context.api.operation.label,
arguments: context.api.arguments,
},
}
: {}),
...(context.source.length
? {
source: context.source.map(
(/** @type {any} */ source) => source.path,
),
sourceSubjects: context.knowledge?.subjects ?? [],
}
: {}),
...(context.knowledge
? {
concept: {
title: context.knowledge.title,
subjects: context.knowledge.subjects ?? [],
},
}
: {}),
},
apiMatches: apiOptions.map(({ label, operation }) => ({
label,
required: operation.parameters
.filter((/** @type {any} */ parameter) => parameter.required)
.map((/** @type {any} */ parameter) => parameter.description),
})),
}),
},
);
return messages;
}
directRoute() {
if (!this.evidence) return undefined;
if (this.evidence.variantMiss) {
const metric = this.evidence.context.metrics[0];
return {
action: "clarify",
call: {
name: "clarify",
arguments: {
question: `I could not find a matching variant of ${
normalize(metric?.name ?? "the active metric").replaceAll("_", " ")
}. Which available cohort or variant should I use?`,
},
},
};
}
let action = directAction(this.evidence, this.question);
if (
action === "explain_metric_calculation" &&
!this.evidence.metricOptions.some(
(/** @type {any} */ { origin }) => origin === "mentioned",
) &&
this.evidence.apiOptions.some(({ operation }) =>
operation.parameters.some((parameter) => parameter.required) &&
hasRequiredArguments(
operation,
explicitArguments(operation, this.question),
)
)
) {
action = undefined;
}
if (action === "search_source") {
if (
this.evidence.context.source.length &&
!this.evidence.context.knowledge?.subjects?.length
) {
return undefined;
}
return {
action,
call: {
name: action,
arguments: { query: this.question },
},
};
}
if (action) {
return {
action,
call: this.directCall(action),
};
}
const at = explicitPosition(this.question);
const metricAt = at
? capabilityMetrics(this.evidence, "read_metric_at")
: [];
if (at && metricAt.length === 1) {
return {
action: "read_metric_at",
call: {
name: "read_metric_at",
arguments: {
refs: [metricAt[0].ref],
at,
},
},
};
}
const supplied = this.evidence.apiOptions
.map(({ ref, operation }) => ({
ref,
operation,
arguments: explicitArguments(operation, this.question),
}))
.filter(({ operation, arguments: arguments_ }) =>
hasRequiredArguments(operation, arguments_) &&
(
operation.parameters.some((parameter) => parameter.required) ||
Number(operation.titleMatchedTerms ?? 0) >= 2
)
)
.sort(({ operation: left }, { operation: right }) =>
Number(right.titleMatchedTerms ?? 0) -
Number(left.titleMatchedTerms ?? 0) ||
right.response.fields.length - left.response.fields.length ||
Number(right.specificity ?? 0) - Number(left.specificity ?? 0) ||
Number(right.score ?? 0) - Number(left.score ?? 0)
);
const priorArguments = new Set(
Object.values(this.evidence.context.api?.arguments ?? {}).map(String),
);
const newResource = !this.evidence.context.api ||
supplied.some(({ arguments: arguments_ }) =>
Object.values(arguments_).some((value) =>
!priorArguments.has(String(value))
)
);
if (newResource && supplied[0]) {
return {
action: "call_api",
call: {
name: "call_api",
arguments: { ref: supplied[0].ref },
},
};
}
if (
this.evidence.context.metrics.length ||
this.evidence.context.chart
) {
return undefined;
}
const questionTokens = schemaTokens([this.question]);
if (
!this.evidence.context.api &&
!this.evidence.metricOptions.some(
(/** @type {any} */ { origin }) => origin === "mentioned",
) &&
[...questionTokens].some((token) => RETRIEVAL_TERMS.has(token))
) {
const records = this.evidence.apiOptions
.map(({ ref, operation }) => ({
ref,
operation,
score: overlapCount(
questionTokens,
schemaTokens([operation.label]),
),
}))
.filter(({ score }) => score > 0)
.sort((left, right) =>
right.score - left.score ||
right.operation.response.fields.length -
left.operation.response.fields.length
);
if (
records[0] &&
records[0].score > (records[1]?.score ?? 0)
) {
return {
action: "call_api",
call: {
name: "call_api",
arguments: { ref: records[0].ref },
},
};
}
}
const query = questionTokens;
const contextKey = this.evidence.context.api?.operation.key;
const contextOperation = this.evidence.context.api?.operation;
const currentFieldNames = schemaTokens(
contextOperation?.response.fields.map(
(/** @type {any} */ field) => field.name,
) ?? [],
);
const detail = contextOperation?.response.type.endsWith("[]") &&
semanticOverlapCount(query, currentFieldNames) === 0
? this.evidence.apiOptions
.filter(({ operation }) =>
operation.key !== contextKey &&
!operation.response.type.endsWith("[]") &&
operation.response.fields.length >
contextOperation.response.fields.length &&
Boolean(reusableArguments(operation, this.evidence.context.api))
)
.sort(({ operation: left }, { operation: right }) =>
Number(right.titleMatchedTerms ?? 0) -
Number(left.titleMatchedTerms ?? 0) ||
Number(right.specificity ?? 0) - Number(left.specificity ?? 0) ||
right.response.fields.length - left.response.fields.length ||
Number(right.score ?? 0) - Number(left.score ?? 0)
)[0]
: undefined;
if (detail) {
return {
action: "call_api",
call: {
name: "call_api",
arguments: { ref: detail.ref },
},
};
}
const apiMatches = [];
for (const { ref, operation } of this.evidence.apiOptions) {
const returned = schemaTokens(
operation.response.fields.flatMap((/** @type {any} */ field) => [
field.name,
field.description,
]),
);
const fieldMatches = semanticOverlapCount(query, returned);
const suppliedResource =
operation.parameters.some((parameter) => parameter.required) &&
hasRequiredArguments(
operation,
explicitArguments(operation, this.question),
);
const inheritedResource = reusableArguments(
operation,
this.evidence.context.api,
);
if (
fieldMatches > 0 &&
(suppliedResource || inheritedResource || operation.key === contextKey)
) {
apiMatches.push({
score: fieldMatches,
context: operation.key === contextKey,
action: "call_api",
call: {
name: "call_api",
arguments: { ref },
},
});
}
}
apiMatches.sort((left, right) => right.score - left.score);
const activeMatch = apiMatches.find(({ context }) => context);
if (activeMatch) return activeMatch;
if (
apiMatches[0] &&
apiMatches[0].score > (apiMatches[1]?.score ?? 0)
) {
return apiMatches[0];
}
if (supplied.length) {
return {
action: "call_api",
call: {
name: "call_api",
arguments: { ref: supplied[0].ref },
},
};
}
if (
this.evidence.context.knowledge &&
!this.evidence.context.knowledge.subjects?.length &&
(
this.evidence.context.source.length ||
this.evidence.guideOptions.some(
(/** @type {any} */ { origin }) => origin === "context",
)
)
) {
return { action: "answer_general" };
}
return undefined;
}
/** @param {string} action @param {(status: string) => void} onStatus */
async prepareAction(action, onStatus) {
if (
action !== "explain_metric_calculation" ||
!this.evidence ||
!this.refs
) return;
this.evidence.sourceOptions = await collectSourceOptions({
question: this.question,
evidence: this.evidence,
source: this.source,
refs: this.refs,
onStatus,
});
}
/** @param {string} action */
actionTool(action) {
const evidence = this.evidence;
if (!evidence) throw new Error("Tool session is not ready");
if (!availableActions(evidence).includes(action)) {
throw new Error(`Unavailable AI capability: ${action}`);
}
return actionTool(evidence, action);
}
/** @param {string} action */
directCall(action) {
const evidence = this.evidence;
if (!evidence) return undefined;
const metrics = capabilityMetrics(evidence, action);
if (action === "search_source") {
return {
name: action,
arguments: { query: this.question },
};
}
if (action === "describe_capabilities") {
return {
name: action,
arguments: { capabilities: generalCapabilities() },
};
}
if (action === "show_guide_example") {
const guide = preferredGuide(evidence, this.question, true);
return guide
? {
name: action,
arguments: { refs: [guide.ref] },
}
: undefined;
}
if (
action === "add_chart_series" ||
action === "remove_chart_series" ||
action === "replace_chart_series"
) {
const activePaths = new Set(
evidence.context.chart?.chart.series.map(
(/** @type {{ path: string }} */ { path }) => path,
) ?? [],
);
const mentioned = evidence.metricOptions.filter(
({ metric, origin }) =>
origin === "mentioned" &&
(
action === "add_chart_series"
? !activePaths.has(metric.path)
: activePaths.has(metric.path)
),
);
if (mentioned.length === 1) {
return {
name: action,
arguments: { refs: [mentioned[0].ref] },
};
}
}
if (
(
action === "read_latest_metric" ||
action === "select_metric_variant" ||
action === "build_metric_chart" ||
action === "add_chart_series" ||
action === "remove_chart_series" ||
action === "replace_chart_series"
) &&
metrics.length === 1
) {
return {
name: action,
arguments: { refs: [metrics[0].ref] },
};
}
if (action === "list_metric_cohorts_variants") {
const variants = evidence.metricOptions.filter(
({ origin }) => origin === "variant",
);
if (variants.length === 1) {
return {
name: action,
arguments: { refs: [variants[0].ref] },
};
}
const bases = evidence.metricOptions.filter(
({ origin }) => origin === "mentioned" || origin === "context",
);
if (!variants.length && bases.length === 1) {
return {
name: action,
arguments: { refs: [bases[0].ref] },
};
}
return undefined;
}
if (action === "set_chart_view_scale") {
const styles = requestedChartStyles(this.question);
if (styles.length) {
return {
name: action,
arguments: { styles },
};
}
}
if (action !== "explain_metric_calculation") return undefined;
const { context, metricOptions, sourceOptions, guideOptions } = evidence;
const contextual = context.metrics[0]
? metricOptions.find(({ metric }) =>
metric.path === context.metrics[0].path
)
: undefined;
const mentioned = metricOptions.find(({ origin }) => origin === "mentioned");
const currentGuide = guideOptions.some(
({ origin }) => origin === "current",
);
const metric = mentioned ?? (currentGuide ? undefined : contextual);
const grounding = sourceOptions[0] ?? guideOptions[0];
if (!grounding) return undefined;
return {
name: action,
arguments: {
refs: [grounding.ref],
...(metric ? { metrics: [metric.ref] } : {}),
},
};
}
contextualGeneralAction(continueContext) {
if (!continueContext || !this.evidence) return undefined;
if (
this.evidence.context.source.length &&
this.evidence.context.knowledge?.subjects?.length
) {
return "search_source";
}
return directAction(this.evidence, this.question, true);
}
/** @param {string} action @param {boolean} [continueContext] */
actionMessages(action, continueContext = true) {
const scopedEvidence = this.evidence;
if (!scopedEvidence) throw new Error("Tool session is not ready");
const { context, apiOptions, sourceOptions, guideOptions } =
scopedEvidence;
const actionMetrics = capabilityMetrics(scopedEvidence, action);
/** @type {Record<string, unknown>} */
const evidence = {
request: this.question,
};
if (
action === "set_chart_view_scale" ||
action === "add_chart_series" ||
action === "remove_chart_series" ||
action === "replace_chart_series"
) {
evidence.activeChart = context.chart?.chart;
}
if ([
"read_latest_metric",
"read_metric_at",
"read_metric_range",
"build_metric_chart",
"add_chart_series",
"remove_chart_series",
"replace_chart_series",
"list_metric_cohorts_variants",
"select_metric_variant",
"explain_metric_calculation",
].includes(action)) {
evidence.metrics = actionMetrics.map(
(/** @type {any} */ { ref, label, metric, origin }) => ({
ref,
label,
origin,
path: metric.path,
indexes: action.startsWith("read_metric") ||
action === "read_latest_metric"
? metric.indexes
: undefined,
unit: metric.suggestedUnit,
}),
);
}
if (
action === "explain_metric_calculation" ||
action === "search_source"
) {
evidence.source = sourceOptions.map(
(/** @type {any} */ { ref, source }) => ({
ref,
path: source.path,
startLine: source.startLine,
content: source.content.slice(0, 220),
}),
);
evidence.guides = guideOptions.map(({ ref, guide }) => ({
ref,
title: guide.title,
description: guide.description,
}));
if (context.source.length) {
evidence.previousSource = context.source.map(
(/** @type {any} */ source) => ({
path: source.path,
startLine: source.startLine,
content: source.content,
}),
);
}
if (context.knowledge) {
evidence.previousContext = context.knowledge;
}
}
if (action === "call_api") {
evidence.api = apiOptions.map(({ ref, operation }) => ({
ref,
operation: `${operation.method} ${operation.path}`,
summary: operation.summary || operation.label,
description: operation.description,
parameters: operation.parameters,
response: {
type: operation.response.type,
description: operation.response.description,
fields: operation.response.fields
.slice(0, 12)
.map((/** @type {any} */ { name, type, description }) => ({
name,
type,
description,
})),
},
}));
if (context.api) {
evidence.previousApi = {
operation: context.api.operation.label,
arguments: context.api.arguments,
};
}
}
if (
(action === "answer_general" || action === "find_chart_metrics") &&
context.knowledge
) {
evidence.context = context.knowledge;
}
if (action === "answer_general") {
if (!context.knowledge || !continueContext) {
return [
{
role: /** @type {const} */ ("system"),
content: actionInstruction(action),
},
{
role: /** @type {const} */ ("user"),
content: this.question,
},
];
}
const verified = Boolean(
context.source.length ||
guideOptions.some(({ origin }) => origin === "context"),
);
const verifiedGuideFacts = guideOptions
.filter(({ origin, guide }) => origin === "context" && guide.description)
.map(({ guide }) => guide.description);
return [
{
role: /** @type {const} */ ("system"),
content: `${actionInstruction(action)}
The immediately preceding assistant message is the active conversation context. Its topic is "${
context.knowledge.title
}" and it is ${verified ? "verified" : "not verified"}. ${
verified
? "Use only that answer and its direct logical consequences when the new request continues it."
: "Do not treat it as verified evidence."
}${
verifiedGuideFacts.length
? `\nVerified supporting facts:\n${
verifiedGuideFacts.map((fact) => `- ${fact}`).join("\n")
}`
: ""
}`,
},
{
role: /** @type {const} */ ("assistant"),
content: context.knowledge.description,
},
{
role: /** @type {const} */ ("user"),
content: this.question,
},
];
}
return [
{
role: /** @type {const} */ ("system"),
content: actionInstruction(action),
},
{
role: /** @type {const} */ ("user"),
content: JSON.stringify(evidence),
},
];
}
/** @param {string} ref */
apiArgumentTool(ref) {
if (!this.refs) throw new Error("Tool session is not ready");
return apiArgumentTool(this.refs.get(ref, "api"));
}
/** @param {string} ref */
apiArgumentMessages(ref) {
if (!this.refs) throw new Error("Tool session is not ready");
const operation = this.refs.get(ref, "api");
return [
{
role: /** @type {const} */ ("system"),
content: "Call provide_api_arguments exactly once. Copy only parameter values explicitly supplied by the newest request or verified API context. Omit everything else.",
},
{
role: /** @type {const} */ ("user"),
content: JSON.stringify({
request: this.question,
operation: {
method: operation.method,
path: operation.path,
parameters: operation.parameters,
},
...(this.evidence?.context.api
? {
previousArguments: this.evidence.context.api.arguments,
}
: {}),
}),
},
];
}
/** @param {string} ref */
apiArguments(ref) {
if (!this.refs) throw new Error("Tool session is not ready");
const operation = this.refs.get(ref, "api");
return {
...(reusableArguments(operation, this.evidence?.context.api) ?? {}),
...explicitArguments(operation, this.question),
};
}
/** @param {string} ref @param {Record<string, unknown>} arguments_ */
hasApiArguments(ref, arguments_) {
if (!this.refs) throw new Error("Tool session is not ready");
return hasRequiredArguments(this.refs.get(ref, "api"), arguments_);
}
/** @param {string} ref @param {Record<string, unknown>} arguments_ */
validateApiArguments(ref, arguments_) {
if (!this.refs) throw new Error("Tool session is not ready");
return validatedArguments(
this.refs.get(ref, "api"),
arguments_,
this.question,
this.evidence?.context.api,
);
}
/** @param {string} ref @param {Record<string, unknown>} arguments_ */
missingApiArguments(ref, arguments_) {
if (!this.refs) throw new Error("Tool session is not ready");
return this.refs.get(ref, "api").parameters
.filter((/** @type {any} */ parameter) =>
parameter.required && !Object.hasOwn(arguments_, parameter.name)
);
}
/** @param {{ name: string, arguments: Record<string, unknown> }} call @param {(status: string) => void} onStatus @param {AbortSignal} signal */
execute(call, onStatus, signal) {
if (!this.executor) throw new Error("Tool session is not ready");
return this.executor.execute(call, onStatus, signal);
}
}