156 lines
6.6 KiB
JavaScript
156 lines
6.6 KiB
JavaScript
// The tools the LLM calls. Two modes:
|
|
// - refine: list_profiles, describe_profile, validate_spec, lookup_values,
|
|
// read_spec. lookup_values searches a code list under the service scope
|
|
// (public graph) and returns candidate values, so the LLM can suggest
|
|
// concrete filter values. No report is created, no spec is evaluated.
|
|
// - execute: list_profiles, describe_profile, validate_spec, read_spec,
|
|
// run_report. run_report runs as the caller and returns only counts and
|
|
// URIs; no cell value ever reaches the model.
|
|
//
|
|
// The shape every tool returns is { content: [{ type: 'text', text }] },
|
|
// which the loop passes straight back to the model.
|
|
|
|
import { checkSpec } from '../runner/check.js';
|
|
import { parseSpec } from '../runner/spec.js';
|
|
import { describeProfile, codeListValues } from './describe.js';
|
|
import { lookupValues } from './lookup.js';
|
|
import { run } from '../runner/run.js';
|
|
import { scopedQuery } from '../db.js';
|
|
import { readFileSync } from 'fs';
|
|
import { ATTACHMENT_TYPES } from '../chat/vocab.js';
|
|
|
|
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
|
|
const RUN_TIMEOUT = Number(process.env.RUN_TIMEOUT || 60) * 1000;
|
|
|
|
// buildTools(profiles, session, mode) → { handlers }
|
|
export function buildTools(profiles, session, mode = 'refine') {
|
|
const profileList = () => [...profiles.values()].map(p => ({ id: p.uri, title: p.title }));
|
|
|
|
const findProfile = (id) => profiles.get(id);
|
|
|
|
async function describe_profile({ profile_id }) {
|
|
const p = findProfile(profile_id);
|
|
if (!p) return text(`no profile <${profile_id}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
|
|
const inlined = await codeListValues(p, scopedQuery);
|
|
return text(describeProfile(p, inlined));
|
|
}
|
|
|
|
async function validate_spec({ spec }) {
|
|
let parsed;
|
|
try { parsed = parseSpec(spec); }
|
|
catch (e) { return text(`the spec did not parse: ${e.message}`); }
|
|
const p = findProfile(parsed.profileUri);
|
|
if (!p) return text(`no profile <${parsed.profileUri}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
|
|
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
|
|
// In refinement the spec that checks out becomes the attached proposal.
|
|
if (!errors.length) session.onSpecValidated?.(spec);
|
|
return text(errors.length ? errors[0] : 'ok');
|
|
}
|
|
|
|
async function list_profiles() {
|
|
return text(profileList().map(x => `${x.title} <${x.id}>`).join('\n'));
|
|
}
|
|
|
|
async function lookup_values({ profile_id, field, term }) {
|
|
const p = findProfile(profile_id);
|
|
if (!p) return text(`no profile <${profile_id}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
|
|
if (!term) return text('no term to search for.');
|
|
try {
|
|
const out = await lookupValues(p, term, field, scopedQuery);
|
|
if (out.error) return text(out.error);
|
|
if (!out.matches.length) return text(`no value matches "${term}" in ${field}.`);
|
|
const lines = out.matches.map(m => `${m.label} <${m.uri}>`);
|
|
if (out.total === '25+') lines.push('(25+ matches, narrow the term)');
|
|
if (out.exact) lines.push(`exact: <${out.exact}>`);
|
|
return text(lines.join('\n'));
|
|
} catch (e) {
|
|
return text(`the lookup failed: ${String(e.message || e).split('\n')[0]}`);
|
|
}
|
|
}
|
|
|
|
async function run_report({ spec }) {
|
|
let parsed;
|
|
try { parsed = parseSpec(spec); }
|
|
catch (e) { return text(`the spec did not parse: ${e.message}`); }
|
|
const p = findProfile(parsed.profileUri);
|
|
if (!p) return text(`no profile <${parsed.profileUri}>.`);
|
|
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
|
|
if (errors.length) return text(`the spec is not valid: ${errors[0]}`);
|
|
// The executed spec is the one to keep: attach it with the turn's answer.
|
|
session.onSpecValidated?.(spec);
|
|
try {
|
|
// The spec's dct:title names the report.
|
|
const name = parsed.title || 'report';
|
|
const waiter = newRunWaiter(RUN_TIMEOUT);
|
|
const running = run(session.query, session.update, parsed, p, name, { spec, fileType: ATTACHMENT_TYPES.result }, waiter.tick);
|
|
// The end callback also fires after the wait gave up: the run is not
|
|
// cancelled, the file arrives when it arrives. The .catch keeps a late
|
|
// failure from crashing the process.
|
|
running
|
|
.then((r) => session.onReportEnd?.(null, r), (e) => session.onReportEnd?.(e))
|
|
.catch((e) => console.error('[reports] end callback failed:', e));
|
|
const result = await waiter.finish(running);
|
|
return text(`done. report <${result.reportUri}>, ${result.rowCount} rows, file <${result.fileUri}>.`);
|
|
} catch (e) {
|
|
return text(`the report failed: ${String(e.message || e).split('\n')[0]}`);
|
|
}
|
|
}
|
|
|
|
// A spec bijlage next to a message. The model opens it with read_spec:
|
|
// only files this service made are readable, hte spec files under share.
|
|
async function read_spec({ file_uri }) {
|
|
const fileName = String(file_uri || '').split('/').pop();
|
|
if (!fileName || !/^specificatie-[0-9a-f]{8}-[0-9a-f-]{27}\.ttl$/.test(fileName)) {
|
|
return text('no such spec file.');
|
|
}
|
|
const shareDir = process.env.SHARE_DIR || '/share';
|
|
try {
|
|
return text(readFileSync(`${shareDir}/${fileName}`, 'utf8'));
|
|
} catch (e) {
|
|
return text(`the spec failed to load: ${String(e.message || e).split('\n')[0]}`);
|
|
}
|
|
}
|
|
|
|
const handlers = new Map(
|
|
[
|
|
['list_profiles', list_profiles],
|
|
['describe_profile', describe_profile],
|
|
['validate_spec', validate_spec],
|
|
['read_spec', read_spec],
|
|
...(mode === 'execute' ? [['run_report', run_report]] : [['lookup_values', lookup_values]]),
|
|
],
|
|
);
|
|
|
|
return { handlers };
|
|
}
|
|
|
|
function text(s) { return { content: [{ type: 'text', text: s }] }; }
|
|
|
|
// Waits for a report run while it makes progress. tick restarts the silence
|
|
// clock; idleMs without a single tick rejects. The run is never cancelled:
|
|
// finish's handlers only pass its own settlement on, so a run that answers
|
|
// after the wait gave up still delivers through the end callback.
|
|
function newRunWaiter(idleMs) {
|
|
let deferred;
|
|
const promise = new Promise((resolve, reject) => { deferred = { resolve, reject }; });
|
|
let settled = false;
|
|
let timer = setTimeout(fail, idleMs);
|
|
function fail() {
|
|
if (settled) return;
|
|
settled = true;
|
|
deferred.reject(new Error(`no query answered for ${idleMs / 1000}s`));
|
|
}
|
|
return {
|
|
tick() {
|
|
if (settled) return;
|
|
clearTimeout(timer);
|
|
timer = setTimeout(fail, idleMs);
|
|
},
|
|
finish(running) {
|
|
running.then(
|
|
(r) => { settled = true; clearTimeout(timer); deferred.resolve(r); },
|
|
(e) => { settled = true; clearTimeout(timer); deferred.reject(e); });
|
|
return promise;
|
|
},
|
|
};
|
|
} |