more elaborate flow

This commit is contained in:
felix
2026-09-16 00:25:40 +02:00
parent 776b50c7d4
commit efa248cfd4
9 changed files with 467 additions and 83 deletions

View File

@@ -1,2 +1,2 @@
FROM semtech/mu-javascript-template:1.8.0
LABEL maintainer="info@redpencil"
FROM semtech/mu-javascript-template:1.9.1
LABEL maintainer="info@redpencil"

View File

@@ -7,6 +7,32 @@ writes a CSV. The LLM writes no SPARQL and sees no report data.
Ad-hoc reports only. The scheduled reports stay in
`loket-report-generation-service`.
## Two modes: refine, then execute
Every turn is classified first. One small LLM call (`src/llm/mode.js`)
returns true/false: does the user's last message clearly say "run it now" or
not. The answer picks one of two deterministic code paths.
**Refinement (Modus A).** The LLM proposes a spec and explains in Dutch what
it will list and filter. It may call `lookup_values` to search code lists for
candidate values, so its filter suggestions name real values instead of
guesses. It never runs a report and never learns what a spec would match: the
database it reads may differ from the one the report runs on, so a lookup is
a suggestion, not a check. The user must confirm before anything executes.
As long as the user has not given a clear command, the turn stays in this
mode.
**Execution (Modus B).** The user has confirmed. The LLM writes the agreed
spec, validates it and calls `run_report` once, then stops. `run_report`
returns only the report URI, the row count and the file URI. No cell value
ever reaches the model; the service may count results, but no data is fed
to the LLM.
The two modes share `list_profiles`, `describe_profile` and `validate_spec`.
Refinement adds `lookup_values`; execution adds `run_report`. The tool set is
the only thing that differs; the code path from the classifier's answer is
deterministic.
## How it works
Two documents drive everything:
@@ -41,6 +67,22 @@ the caller's.
The tools the LLM calls return URIs, counts, statuses and column names —
never a cell value.
### The service scope
The LLM's own reads during refinement (`describe_profile`'s code lists and
`lookup_values`) run under a service scope, not the caller's session. mu's
`query(q, { scope })` sends `mu-auth-scope`; the sparql-parser config grants
that scope read access to `http://mu.semte.ch/graphs/public` only. So
refinement reads only public code lists, regardless of who calls. Execution
(`run_report`) still runs as the caller, so the report contains what they may
see, and the LLM sees none of it.
The scope URI is `SERVICE_SCOPE` (default
`http://services.semantic.works/natural-language-report`) and must match the
`with-scope` grant in the app's sparql-parser `config.lisp`. Do not set
`DEFAULT_MU_AUTH_SCOPE` on the service: that would scope every query
(including the run and the chat store) and break them.
## Two endpoints
```
@@ -48,12 +90,13 @@ POST /ask { question, history? } → { answe
POST /assistant/conversations/:id/turns { content } → 202 { id }
```
`/ask` is synchronous: a Dutch question in, the agent loop runs inside the
service (profiles, lookups, spec, validate, repair, run), and the answer in
Dutch comes back with a trace of the tool calls. The CSV lands in
`data/files/`. `history` carries the earlier turns for follow-ups; the
conversation lives on the caller's side, the service stores nothing. One
question makes one report; the loop stops after `run_report`.
`/ask` is synchronous: a Dutch question in, the turn is classified, and the
matching mode's loop runs inside the service (profiles, spec, validate,
refine or run), and the answer in Dutch comes back with a trace of the tool
calls. The CSV lands in `data/files/`. `history` carries the earlier turns
for follow-ups; the conversation lives on the caller's side, the service
stores nothing. One question makes one report; the execution loop stops
after `run_report`.
`/assistant` is the chat. The turn module in
`src/chat/` is generic — it imports nothing from `src/llm/` or `src/runner/`
@@ -70,10 +113,11 @@ run takes minutes.
### The internal tools
The loop drives four tool handlers (`src/llm/tools.js`): `list_profiles`,
`describe_profile`, `validate_spec`, `run_report`. They are machinery, not an
The loop drives a tool set per mode (`src/llm/tools.js`). Both modes share
`list_profiles`, `describe_profile`, `validate_spec`. Refinement adds
`lookup_values`; execution adds `run_report`. They are machinery, not an
external surface — the tool trace in the answer is the only thing a caller
sees of them. All LLM-facing text (system prompt, profile menu, tool
sees of them. All LLM-facing text (system prompts, profile menu, tool
descriptions, validator errors) is English; the answer the user sees is Dutch.
## Environment
@@ -97,6 +141,7 @@ descriptions, validator errors) is English; the answer the user sees is Dutch.
| `SHARE_DIR` | `/share` | where CSVs are written |
| `CHAT_ASSISTANT_URI` | — | the `prov:SoftwareAgent` that signs assistant messages; found by lookup when unset |
| `CHAT_HISTORY_LIMIT` | `20` | messages of history a turn reads |
| `SERVICE_SCOPE` | `http://services.semantic.works/natural-language-report` | the mu-auth-scope for the LLM's own reads (code lists, `lookup_values`). Must match the `with-scope` grant in the app's sparql-parser config. |
Do not set `ALLOW_MU_AUTH_SUDO`; the template then refuses sudo queries.

View File

@@ -24,6 +24,19 @@ export {
sparqlEscapeBool,
};
// The service's own scope. Reads the LLM drives during refinement (code
// lists, lookup_values) run under this scope, not the caller's session. The
// sparql-parser config grants this scope read access to the public graph
// only, so refinement reads only public data. Execution (run_report) still
// runs as the caller. Set to match the with-scope grant in config.lisp.
export const SERVICE_SCOPE =
process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
// Wrap a mu query/update so it carries the service scope. mu's query/update
// accept { scope } on mu-javascript-template >= 1.9.0.
export function scopedQuery(q) { return muQuery(q, { scope: SERVICE_SCOPE }); }
export function scopedUpdate(q) { return muUpdate(q, { scope: SERVICE_SCOPE }); }
// The store's error text may name graphs or internals we do not pass on.
export function stripStoreError(text) {
return String(text).split('\n')[0].slice(0, 300);

View File

@@ -3,7 +3,6 @@
// never has to search the short ones.
import { fieldsOf, entityForClass } from '../runner/profile.js';
import { sessionQuery } from '../db.js';
const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50);
const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
@@ -11,26 +10,28 @@ const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
// In-memory cache: profile+field → { values, at }. Refreshed lazily when stale.
const cache = new Map();
export async function codeListValues(profile) {
// Returns a map: "shapeUri|predicate|inverse" → [{ uri, label }]
// codeListValues(profile, queryFn) → map "shapeUri|predicate|inverse"
// → [{ uri, label }]. queryFn runs under the service scope (public graph), so
// code lists read the same regardless of the caller's rights.
export async function codeListValues(profile, queryFn) {
const out = {};
for (const shape of profile.shapes) {
for (const f of shape.fields) {
if (!f.class) continue;
const key = `${shape.uri}|${f.path}|${f.inverse ? 1 : 0}`;
out[key] = await loadCodeList(profile, f, key);
out[key] = await loadCodeList(f, key, queryFn);
}
}
return out;
}
async function loadCodeList(profile, f, key) {
async function loadCodeList(f, key, queryFn) {
if (!f.class) return [];
const hit = cache.get(key);
if (hit && Date.now() - hit.at < VALUES_TTL) return hit.values;
let values = [];
try {
values = await queryCodeList(f.class);
values = await queryCodeList(f.class, queryFn);
} catch (e) {
// the read failed; report nothing, the LLM will use rep:anyOf with words
values = [];
@@ -39,12 +40,12 @@ async function loadCodeList(profile, f, key) {
return values;
}
async function queryCodeList(type) {
async function queryCodeList(type, queryFn) {
const q = `SELECT ?uri ?label WHERE {
?uri a <${type}> .
OPTIONAL { ?uri <http://www.w3.org/2004/02/skos/core#prefLabel> ?label . }
} ORDER BY ?label LIMIT ${INLINE_VALUES_MAX + 1}`;
const r = await sessionQuery(q);
const r = await queryFn(q);
const bindings = r.results.bindings;
return bindings.slice(0, INLINE_VALUES_MAX).map(b => ({
uri: b.uri.value,

117
src/llm/lookup.js Normal file
View File

@@ -0,0 +1,117 @@
// Search a code list too long to inline: regex on str(?label), so
// language-tagged labels match. Returns the matches, the total, and an
// `exact` flag when a label equals the term (case aside).
//
// This is a suggest helper, not a spec check. It reads candidate values the
// LLM can put in a filter; it never runs the spec and never sees report
// rows. queryFn runs under the service scope (public graph only), so the
// suggestions come from the same data set the profile describes, regardless
// of who calls.
import { sparqlEscapeString } from '../db.js';
const LIMIT = 25;
// lookupValues(profile, term, fieldSpec, queryFn) →
// { total, exact, matches } or { error }.
// field is "shapeLabel.fieldLabel" as the LLM writes it, or a predicate URI.
// We resolve it to the field's sh:class to know what type to search.
export async function lookupValues(profile, term, fieldSpec, queryFn) {
const type = resolveClass(profile, fieldSpec);
if (!type) return { total: 0, exact: null, matches: [], error: `field "${fieldSpec}" not found in profile "${profile.title}"` };
// Search within the class first. A common word ("gemeente") also sits in
// hundreds of unrelated labels; an unscoped scan drowns the code.
let rows = await labelQuery(term, type, queryFn);
// Code lists sometimes type their entries differently than the profile
// says (MAR codes are ext:supervision/Nomenclature, the profile says
// skos:Concept). Fall back to a scan over everything.
if (!rows.length) rows = await labelQuery(term, null, queryFn);
// Order: an exact label (case aside) first, then the rest as the store
// ordered them. "Gent" must surface before "AGB Erfgoed Gent".
const lower = term.toLowerCase();
const exactRow = rows.find(b => b.label.toLowerCase() === lower);
const rest = rows.filter(b => b !== exactRow);
const ordered = exactRow ? [exactRow, ...rest] : rest;
let matches = ordered.slice(0, LIMIT);
if (!matches.length) {
const byCode = await codeQuery(profile, type, term, queryFn);
matches = byCode;
}
const exact = matches.find(m => m.label.toLowerCase() === term.toLowerCase());
return {
total: matches.length >= LIMIT ? `${LIMIT}+` : matches.length,
exact: exact ? exact.uri : null,
matches,
};
}
async function labelQuery(term, type, queryFn) {
const q = `SELECT DISTINCT ?uri ?label WHERE {
?uri a ${type ? `<${type}>` : '?anyType'} ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i"))
} ORDER BY ?label LIMIT ${LIMIT * 8}`;
const r = await queryFn(q);
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
}
// MAR codes: the label says "MAR7300 - …" but the searchable code sits on
// ext:supervision/nomenclatureCode. One extra query, only when the label
// search came up empty.
async function codeQuery(profile, type, term, queryFn) {
const q = `SELECT ?uri ?label WHERE {
?uri <http://mu.semte.ch/vocabularies/ext/supervision/nomenclatureCode> ?code ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)})))
} ORDER BY ?label LIMIT ${LIMIT}`;
try {
const r = await queryFn(q);
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
} catch {
return [];
}
}
function resolveClass(profile, fieldSpec) {
if (fieldSpec.includes('/')) return null;
// "shapeLabel.fieldLabel" → find the field carrying sh:class
const dot = fieldSpec.lastIndexOf('.');
if (dot < 0) {
// bare predicate URI
for (const shape of profile.shapes) {
for (const f of shape.fields) {
if (f.path === fieldSpec && f.class) return f.class;
}
}
return null;
}
const shapeLabel = fieldSpec.slice(0, dot);
const fieldLabel = fieldSpec.slice(dot + 1);
const shape = profile.shapes.find(s => s.label === shapeLabel);
if (!shape) return null;
const f = shape.fields.find(x => x.name === fieldLabel);
// sh:node marks a link, not a code list; the searchable type is the linked
// shape's sh:targetClass ("melding.bestuurseenheid" → besluit:Bestuurseenheid).
if (f?.class) return f.class;
if (f?.node) {
const linked = profile.shapes.find(s => s.uri === f.node);
return linked?.targetClass || null;
}
// The model sometimes writes shape.field on a linked shape
// ("bestuurseenheid.naam"); resolve it to that shape's target class.
for (const s of profile.shapes) {
if (s.label === shapeLabel || shortType(s.targetClass) === shapeLabel) {
return s.targetClass;
}
}
return null;
}
function shortType(cls) {
return cls.split('#').pop() || cls.split('/').pop();
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -1,8 +1,18 @@
// The agent loop: POST /v1/chat/completions with tool calls,
// OpenAI-compatible (LLM_BASE_URL, LLM_MODEL, LLM_API_KEY). No provider
// SDKs. Stops when the model answers without tool calls.
//
// Two modes, picked per turn by a mode classifier (./mode.js):
// - refine: the LLM proposes a spec, may look up candidate values in code
// lists (under the service scope) and iterates with the user. It never
// runs a report and never learns what a spec would match. The user must
// confirm before anything executes.
// - execute: the LLM writes the agreed spec, validates it and runs the
// report once. run_report returns only counts and URIs; no cell value ever
// reaches the model.
import { buildTools } from './tools.js';
import { classifyMode } from './mode.js';
const MAX_ROUNDS = Number(process.env.MAX_ROUNDS || 12);
const LLM_BASE_URL = process.env.LLM_BASE_URL;
@@ -11,7 +21,12 @@ const LLM_API_KEY = process.env.LLM_API_KEY;
// LOG_LLM=1 dumps every request to and response from the provider, verbatim.
const LOG_LLM = /^(true|1)$/i.test(process.env.LOG_LLM || '');
const SYSTEM_PROMPT = `You write report specs. You never write SPARQL and you never see report data.
// Refinement (Modus A). The LLM and the user iterate on a spec. The LLM may
// look up candidate values in code lists (lookup_values, under the service
// scope) to make concrete suggestions, but it may not run a report and never
// learns how many subjects a spec would match. It ends with a proposal in
// Dutch and asks the user to confirm.
const REFINE_PROMPT = `You refine report specs with the user. You never write SPARQL and you never run a report.
The profile lists what can be asked. Compose paths by chaining fields.
@@ -25,12 +40,56 @@ Rules:
- You cannot count, sort, take the first N, or compare two subjects. Say so.
- The report's title is the dct:title you write in the spec. There is no other
title input.
- One question makes one report. Write one spec, validate it, run it, then stop.
Do not write a second report for the same question.
Always prefix the spec with @prefix lines. Use the prefixes the profile declares.
Write the spec as one Turtle block. Then call validate_spec. Fix what it says,
up to three rounds. Then call run_report. After run_report returns, stop.
You are in refinement mode. You propose a spec and explain in Dutch what it
will list and filter. Use lookup_values to find concrete values (names, codes)
for a filter, so your proposal names real values instead of guesses. That is
the only database read you have, and it only suggests values: it never tells
you whether a spec is good or how much it matches. The database you read may
differ from the one the report runs on, so never treat a lookup as a check.
Iterate with the user on the spec itself: paths, filters, columns. Never call
run_report; there is no such tool here. When the spec is ready, present it in
Dutch and ask the user to confirm execution. Then stop.
Your final answer to the user MUST contain the full agreed spec as one Turtle
block, so the next turn can reuse it verbatim. Put the Dutch explanation first,
then the Turtle block, then the question asking to confirm.
Always prefix the spec with @prefix lines. Use the prefixes the profile
declares. Write the spec as one Turtle block. Then call validate_spec. Fix
what it says, up to three rounds. Then propose and stop.
Reply to the user in Dutch. Earlier turns are context only. Answer the last one.`;
// Execution (Modus B). The user has confirmed. The LLM writes the agreed
// spec, validates it and runs the report once, then stops. It never sees the
// report's data: run_report returns only the report URI, the row count and
// the file URI.
const EXECUTE_PROMPT = `You write report specs. You never write SPARQL and you never see report data.
The profile lists what can be asked. Compose paths by chaining fields.
Rules:
- A column must end on a value, not on a link. Chain one more hop.
- When two entities share one sh:targetClass, the spec also needs rep:entity
with that entity's URI; describe_profile shows it after each entity.
- Filters go in sh:property. Columns go in rep:columns, in the order you want.
- To match a topic rather than a value, list the Dutch words and compounds you
would expect and put them all in rep:anyOf.
- You cannot count, sort, take the first N, or compare two subjects. Say so.
- The report's title is the dct:title you write in the spec. There is no other
title input.
- One question makes one report. Write the agreed spec, validate it, run it,
then stop. Do not write a second report.
The agreed spec may already be in an earlier assistant message (a Turtle
block). Reuse it verbatim when it fits the user's confirmed intent; only
adjust it when the user asked for a change.
Always prefix the spec with @prefix lines. Use the prefixes the profile
declares. Write the spec as one Turtle block. Then call validate_spec. Fix
what it says, up to three rounds. Then call run_report. After run_report
returns, stop.
Reply to the user in Dutch. Earlier turns are context only. Answer the last one.`;
@@ -39,13 +98,27 @@ Reply to the user in Dutch. Earlier turns are context only. Answer the last one.
// [{ role, content }], newest last. The system prompt is always this file's
// own; a system message in the input is dropped.
export async function ask(questionOrMessages, profiles, session) {
const { handlers } = buildTools(profiles, session);
const trace = [];
const turns = typeof questionOrMessages === 'string'
? [{ role: 'user', content: questionOrMessages }]
: questionOrMessages.filter(m => m.role !== 'system');
const messages = [{ role: 'system', content: SYSTEM_PROMPT }, ...turns];
const execute = await classifyMode(turns);
trace.push({ name: 'classify', args: { execute }, result: execute ? 'execute' : 'refine' });
if (execute) {
return runLoop(turns, profiles, session, 'execute', trace);
}
return runLoop(turns, profiles, session, 'refine', trace);
}
// runLoop drives the agent loop for one mode. The mode picks the system
// prompt and the tool set; the code path is deterministic from there.
async function runLoop(turns, profiles, session, mode, trace) {
const { handlers } = buildTools(profiles, session, mode);
const systemPrompt = mode === 'execute' ? EXECUTE_PROMPT : REFINE_PROMPT;
const messages = [{ role: 'system', content: systemPrompt }, ...turns];
for (let round = 0; round < MAX_ROUNDS; round++) {
const reply = await chat(messages);
@@ -76,7 +149,10 @@ export async function ask(questionOrMessages, profiles, session) {
return { text: '(loop did not finish)', trace };
}
async function chat(messages) {
// chat(messages, { tools, toolChoice }) → { message }
// The one place that talks to the provider. tools null means a plain
// completion (used by the mode classifier); toolChoice defaults to 'auto'.
export async function chat(messages, { tools = toolDefs(), toolChoice = 'auto' } = {}) {
// Send only the fields every provider accepts back.
const wire = messages.map(m => {
const out = { role: m.role, content: m.content ?? null };
@@ -93,9 +169,11 @@ async function chat(messages) {
const body = {
model: LLM_MODEL,
messages: wire,
tools: toolDefs(),
tool_choice: 'auto',
};
if (tools) {
body.tools = tools;
body.tool_choice = toolChoice === 'auto' ? 'auto' : toolChoice;
}
if (LOG_LLM) {
console.log(`[llm] POST ${LLM_BASE_URL}/chat/completions request:\n${
JSON.stringify(body, null, 2)}`);

81
src/llm/mode.js Normal file
View File

@@ -0,0 +1,81 @@
// The mode classifier. One LLM call per turn: a guestimate of whether the
// user wants to keep refining the query or has given a clear command to
// execute it now. Returns true (execute) / false (refine). Deterministic
// code branches on the answer; refine is the safe default when the call
// fails or the answer does not parse.
import { chat } from './loop.js';
const MODE_PROMPT = `You read a conversation between a user and a report assistant.
The assistant proposes report specs and asks the user to confirm before
running them.
Decide whether the user's LAST message is a clear instruction to run the
report now (for example "ja", "voer uit", "doe het", "maak het rapport",
"go", "execute", "run it") or is still refining, asking, correcting or
answering a question.
Rules:
- Only return true when the user clearly tells the assistant to execute the
agreed report right now. A short "ja" or "ok" after a proposal counts.
- A new or amended question, a clarification, a "no", a question about the
proposal, or the absence of an earlier proposal means false.
- When in doubt, return false.
Reply with exactly one token: true or false. No other words, no punctuation,
no markdown.`;
// classifyMode(turns) → boolean (true = execute now)
// turns: [{ role, content }], newest last. A leading system message is
// dropped; the assistant's own system prompt is never sent to the model.
export async function classifyMode(turns) {
const convo = (turns || [])
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content ?? '' }));
if (!convo.length) return false;
// Keep the last few turns; the decision is about the latest message.
const recent = convo.slice(-6);
const messages = [{ role: 'system', content: MODE_PROMPT }, ...recent];
let raw;
try {
const reply = await chat(messages, { tools: null });
raw = firstText(reply.message);
} catch (e) {
// The classifier call failed (no LLM, provider down). Default to refine:
// safer than running a report on a guess, and the refine loop will
// surface the real error to the user.
return false;
}
const text = stripMarkdown(raw).trim().toLowerCase();
if (!text) return false;
if (text.startsWith('true')) return true;
if (text.startsWith('false')) return false;
// A JSON {"execute": ...} shape some models emit.
try {
const m = text.match(/\{[\s\S]*\}/);
if (m) {
const v = JSON.parse(m[0]);
if (typeof v === 'boolean') return v;
if (v && typeof v.execute === 'boolean') return v.execute;
if (v && typeof v.refine === 'boolean') return !v.refine;
}
} catch { /* ignore */ }
return false;
}
// Reasoning models may leave `content` empty and put the visible text in a
// separate field (reasoning_content / thinking / reasoning), or may not
// answer at all when the thinking burned the token budget. Read the visible
// fields in order and take the first non-empty one.
function firstText(message) {
for (const key of ['content', 'reasoning_content', 'reasoning', 'thinking']) {
const v = message?.[key];
if (typeof v === 'string' && v.trim()) return v;
}
return '';
}
function stripMarkdown(s) {
return String(s).replace(/[*_`>#]/g, ' ');
}

View File

@@ -1,39 +1,59 @@
// The JSON schemas of the four tools. The descriptions are what the LLM
// reads to decide which tool to call.
// The JSON schemas of the tools. The descriptions are what the LLM reads to
// decide which tool to call. Two modes share three tools (list_profiles,
// describe_profile, validate_spec); refine adds lookup_values, execute adds
// run_report.
export function toolSchemas() {
return [
{
name: 'list_profiles',
description: 'List the report profiles available, with their id and title.',
inputSchema: { type: 'object', properties: {}, required: [] },
const COMMON = [
{
name: 'list_profiles',
description: 'List the report profiles available, with their id and title.',
inputSchema: { type: 'object', properties: {}, required: [] },
},
{
name: 'describe_profile',
description: 'Describe one profile: its entities, fields, relations and labels. Inline the values of short code lists.',
inputSchema: {
type: 'object',
properties: { profile_id: { type: 'string', description: 'The profile URI returned by list_profiles' } },
required: ['profile_id'],
},
{
name: 'describe_profile',
description: 'Describe one profile: its entities, fields, relations and labels. Inline the values of short code lists.',
inputSchema: {
type: 'object',
properties: { profile_id: { type: 'string', description: 'The profile URI returned by list_profiles' } },
required: ['profile_id'],
},
},
{
name: 'validate_spec',
description: 'Validate a report spec (Turtle). Returns "ok" or one clear sentence saying what is wrong and what to write instead. No database is touched.',
inputSchema: {
type: 'object',
properties: { spec: { type: 'string', description: 'The spec as Turtle text' } },
required: ['spec'],
},
{
name: 'validate_spec',
description: 'Validate a report spec (Turtle). Returns "ok" or one clear sentence saying what is wrong and what to write instead. No database is touched.',
inputSchema: {
type: 'object',
properties: { spec: { type: 'string', description: 'The spec as Turtle text' } },
required: ['spec'],
},
},
];
const LOOKUP_VALUES = {
name: 'lookup_values',
description: 'Search a code list for a term so you can suggest concrete values for a filter (for sh:hasValue or sh:in). Returns up to 25 matches with their URIs and labels, a total, and an exact flag when a label equals the term. Reads candidate values only; it never runs the spec and never tells you how many subjects a spec would match. Read-only, no report is created.',
inputSchema: {
type: 'object',
properties: {
profile_id: { type: 'string', description: 'The profile URI returned by list_profiles' },
field: { type: 'string', description: 'The field as "entityLabel.fieldLabel", e.g. "bestuurseenheid.naam"' },
term: { type: 'string', description: 'The Dutch word or name to search for' },
},
{
name: 'run_report',
description: 'Run a valid spec and return the report URI, the row count and the file URI. Runs as the caller, so it sees only what they may see. Waits as long as the run keeps answering; gives up after RUN_TIMEOUT seconds of silence. The report is named by the dct:title in the spec. Call this once per question, after validate_spec returns ok.',
inputSchema: {
type: 'object',
properties: { spec: { type: 'string' } },
required: ['spec'],
},
},
];
required: ['profile_id', 'field', 'term'],
},
};
const RUN_REPORT = {
name: 'run_report',
description: 'Run a valid spec and return the report URI, the row count and the file URI. Runs as the caller, so it sees only what they may see. Waits as long as the run keeps answering; gives up after RUN_TIMEOUT seconds of silence. The report is named by the dct:title in the spec. Call this once per question, after validate_spec returns ok. You never see the report data, only the count and URIs.',
inputSchema: {
type: 'object',
properties: { spec: { type: 'string' } },
required: ['spec'],
},
};
export function toolSchemas(mode = 'refine') {
if (mode === 'execute') return [...COMMON, RUN_REPORT];
return [...COMMON, LOOKUP_VALUES];
}

View File

@@ -1,24 +1,27 @@
// The tools the LLM calls: list_profiles, describe_profile, validate_spec,
// run_report. They return a report URI, a count, or an error we wrote —
// never a cell value. The shape every tool returns is
// { content: [{ type: 'text', text }] }, which the loop passes straight back
// to the model.
// The tools the LLM calls. Two modes:
// - refine: list_profiles, describe_profile, validate_spec, lookup_values.
// 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, 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, slug } from '../runner/run.js';
import { scopedQuery } from '../db.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) → { handlers }
// run_report waits while the run keeps answering: every query that comes
// back (a seed page, a column batch) restarts the RUN_TIMEOUT clock, and
// only that much silence gives up. The batch count is known upfront — the
// subject list is complete after the seed — so a slow run with many small
// batches finishes, however long it takes in total.
export function buildTools(profiles, session) {
// 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);
@@ -26,7 +29,7 @@ export function buildTools(profiles, session) {
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, profiles);
const inlined = await codeListValues(p, scopedQuery);
return text(describeProfile(p, inlined));
}
@@ -44,6 +47,23 @@ export function buildTools(profiles, session) {
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); }
@@ -71,12 +91,21 @@ export function buildTools(profiles, session) {
}
}
const handlers = new Map([
['list_profiles', list_profiles],
['describe_profile', describe_profile],
['validate_spec', validate_spec],
['run_report', run_report],
]);
const handlers = new Map(
mode === 'execute'
? [
['list_profiles', list_profiles],
['describe_profile', describe_profile],
['validate_spec', validate_spec],
['run_report', run_report],
]
: [
['list_profiles', list_profiles],
['describe_profile', describe_profile],
['validate_spec', validate_spec],
['lookup_values', lookup_values],
],
);
return { handlers };
}