Files
natural-language-report-ser…/src/llm/tools.js
2026-09-26 17:05:19 +02:00

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;
},
};
}