From 00eeb35923e8a1a19f009f0a250de5c2f1b1f7c1 Mon Sep 17 00:00:00 2001 From: felix Date: Sat, 26 Sep 2026 18:56:43 +0200 Subject: [PATCH] simplification --- README.md | 68 +++++++++++++-------------------- app.js | 32 +--------------- package-lock.json | 4 +- package.json | 2 +- src/db.js | 12 ++---- src/llm/loop.js | 84 +++++++++-------------------------------- src/llm/schemas.js | 18 ++------- src/llm/tools.js | 81 +++++---------------------------------- src/report-assistant.js | 43 +++++++++++---------- src/runner/columns.js | 9 +---- src/runner/run.js | 12 ++---- src/runner/seed.js | 5 +-- 12 files changed, 91 insertions(+), 279 deletions(-) diff --git a/README.md b/README.md index 99a8022..73adac8 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,15 @@ 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. +**Execution (Modus B).** The user has confirmed. No LLM runs. The last spec +bijlage in the conversation is the agreed spec: the service reads it from the +share, checks it against its profile and runs it as the caller. The answer is +a fixed Dutch text with the CSV and the spec as bijlagen. No spec yet, a spec +that no longer checks out, or a failed run each give a fixed Dutch failure +message. Nothing is retried and no spec is rewritten. -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. +Only refinement has tools: `list_profiles`, `describe_profile`, +`validate_spec`, `read_spec` and `lookup_values`. ## How it works @@ -52,8 +51,7 @@ namespace is `http://mu.semte.ch/vocabularies/reporting/` (`rep:`). The run: check → seed (`SELECT DISTINCT ?s`, paged) → columns (per group, per chunk of subjects) → assemble (dedup + `rep:collect`) → CSV → register file -+ report resource. One run per question; the loop stops after `run_report` -returns. ++ report resource. One run per confirmed spec. ## One identity: mu-authorization's @@ -74,8 +72,8 @@ The LLM's own reads during refinement (`describe_profile`'s code lists and `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. +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 @@ -83,42 +81,31 @@ The scope URI is `SERVICE_SCOPE` (default `DEFAULT_MU_AUTH_SCOPE` on the service: that would scope every query (including the run and the chat store) and break them. -## Two endpoints +## The endpoint ``` -POST /ask { question, history? } → { answer, toolCalls } POST /assistant/conversations/:id/turns { content } → 202 { id } ``` -`/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 +This is the chat. The turn module in `src/chat/` is generic — it imports nothing from `src/llm/` or `src/runner/` and moves between services without edits — and `src/report-assistant.js` is this service's one `answer` hook. It records the question, answers `202` with -the message id, then runs the same loop with the conversation's history. A -report run becomes an interim message with a pending file (`as:Document` with -no `as:url`); the URL is set when the run ends. The browser polls +the message id, then classifies the turn and refines or executes with the +conversation's history. The answer and its bijlagen are written when the turn +ends. The browser polls `/chat-conversations/:id?include=messages.attachments` and finds the answer that way. Failure is a message, never a silent loader. -`/ask` needs `LLM_BASE_URL` set, and answers 503 without it. A full-org report -run takes minutes. +The chat needs `LLM_BASE_URL`; without it every turn answers with a failure +message. A full-org report run takes minutes. ### The internal tools -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 prompts, profile menu, tool -descriptions, validator errors) is English; the answer the user sees is Dutch. +The refinement loop drives one tool set (`src/llm/tools.js`). The tools are +machinery, not an external surface. All LLM-facing text (system prompts, +profile menu, tool descriptions, validator errors) is English; the answer the +user sees is Dutch. ## Environment @@ -130,10 +117,9 @@ descriptions, validator errors) is English; the answer the user sees is Dutch. | `SUBJECT_CHUNK_SIZE` | `100` | subjects per column query (VALUES batch size); lower it when the auth layer rejects long queries | | `ROW_LIMIT` | `200000` | seed limit; failing the job beats truncating | | `MAX_PATH_DEPTH` | `8` | longest allowed spec path | -| `RUN_TIMEOUT` | `60` | seconds of silence before `run_report` gives up. Every answered query (seed page, column batch) restarts the clock, so a big report only needs the window per batch, not for the whole run. The run itself is never cancelled: the file still lands in the chat. | | `INLINE_VALUES_MAX` | `50` | code lists up to this size are inlined in `describe_profile` | | `VALUES_TTL` | `3600` | seconds the inlined lists are cached | -| `LLM_BASE_URL` | — | enables `/ask`; OpenAI-compatible (`https://ollama.com/v1`) | +| `LLM_BASE_URL` | — | the chat's LLM; OpenAI-compatible (`https://ollama.com/v1`) | | `LLM_MODEL` | — | e.g. `gpt-oss:120b` | | `LLM_API_KEY` | — | bearer token, optional for local Ollama | | `SEED_PAGE_SIZE` | `5000` | seed page size | @@ -152,7 +138,7 @@ Do not set `ALLOW_MU_AUTH_SUDO`; the template then refuses sudo queries. ```yaml natural-language-report: build: ../natural-language-report-service/ - image: lblod/natural-language-report-service:0.1.0 + image: lblod/natural-language-report-service:0.2.0 volumes: - ./data/files:/share - ./config/report-profiles/:/config/profiles/ @@ -161,12 +147,12 @@ natural-language-report: `config/dispatcher/dispatcher.ex`: ```elixir -match "/natural-language-reports/*path" do - forward conn, path, "http://natural-language-report/" +match "/assistant/*path" do + forward conn, path, "http://natural-language-report/assistant/" end ``` -One route to the service root; `POST /ask` is the endpoint. +One route: the chat's turn endpoint. First deploy: `docker compose build natural-language-report`, then `docker compose up -d natural-language-report dispatcher`. diff --git a/app.js b/app.js index c8cfb6e..0af1065 100644 --- a/app.js +++ b/app.js @@ -1,8 +1,5 @@ import { app, errorHandler } from 'mu'; -import express from 'express'; import { loadProfiles } from './src/runner/profile.js'; -import { sessionQuery, sessionUpdate, stripStoreError } from './src/db.js'; -import { ask } from './src/llm/loop.js'; import { mountChat } from './src/chat/index.js'; import { reportAssistant } from './src/report-assistant.js'; @@ -21,33 +18,8 @@ async function whenProfiles() { } } -// The chat. reportAssistant is the answer hook: it runs the LLM loop with the -// conversation so far and turns a report run into an interim message with a -// pending file. +// The chat. reportAssistant is the answer hook: it refines the spec with the +// LLM, or runs the agreed spec without it. mountChat(app, { path: '/assistant', answer: reportAssistant(whenProfiles) }); -// POST /ask: one question in, the answer in Dutch and a trace of the tool -// calls back. Follow-ups carry the earlier turns in `history`. -// The template parses application/vnd.api+json only; this route parses the -// plain JSON body itself. -app.post('/ask', express.json(), async (req, res) => { - const { question, history } = req.body || {}; - if (!question) return res.status(400).json({ error: 'no question in the request body' }); - if (!process.env.LLM_BASE_URL) return res.status(503).json({ error: 'no LLM configured (set LLM_BASE_URL)' }); - - try { - const profiles = await whenProfiles(); - const turns = [ - ...(Array.isArray(history) ? history : []), - { role: 'user', content: question }, - ]; - const session = { query: sessionQuery, update: sessionUpdate }; - const { text, trace } = await ask(turns, profiles, session); - res.json({ answer: text, toolCalls: trace }); - } catch (e) { - console.error('[ask] failed:', e); - res.status(502).json({ error: stripStoreError(e.message) }); - } -}); - app.use(errorHandler); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index df92e3c..a58078d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "natural-language-report-service", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "natural-language-report-service", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "express": "^5.2.1", diff --git a/package.json b/package.json index d72bdd4..2b87e5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "natural-language-report-service", - "version": "0.1.0", + "version": "0.2.0", "description": "Turns a SHACL report spec into SPARQL queries, joins the results and writes a CSV. The LLM writes specs; this service runs them.", "license": "MIT", "type": "module", diff --git a/src/db.js b/src/db.js index e423a74..ccb2522 100644 --- a/src/db.js +++ b/src/db.js @@ -13,7 +13,6 @@ import { sparqlEscapeBool, } from 'mu'; -export const sessionQuery = muQuery; export const sessionUpdate = muUpdate; export { sparqlEscapeUri, @@ -27,17 +26,12 @@ export { // 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. +// only, so refinement reads only public data. Execution 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); -} \ No newline at end of file +export function scopedUpdate(q) { return muUpdate(q, { scope: SERVICE_SCOPE }); } \ No newline at end of file diff --git a/src/llm/loop.js b/src/llm/loop.js index 36d3125..f7b64ce 100644 --- a/src/llm/loop.js +++ b/src/llm/loop.js @@ -2,17 +2,13 @@ // 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. +// Refinement only: 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; execution runs the agreed spec without +// the LLM (../report-assistant.js). 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; @@ -67,58 +63,16 @@ 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 attached ("bijlage") to an earlier assistant -message in the conversation, a ttl file typed as a rapportspecificatie. Use -read_spec on its file uri to bring it back, then 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.`; - -// ask(questionOrMessages, profiles, session) → { text, trace } +// ask(questionOrMessages, profiles, session) → { text } // A string is one question; an array is the conversation so far, // [{ 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 trace = []; - const turns = typeof questionOrMessages === 'string' ? [{ role: 'user', content: questionOrMessages }] : questionOrMessages.filter(m => m.role !== 'system'); - const execute = await classifyMode(turns); - trace.push({ name: 'classify', args: { execute }, result: execute ? 'execute' : 'refine' }); - - if (execute) { - return runLoop(withAttachmentSpecs(turns), profiles, session, 'execute', trace); - } - return runLoop(withAttachmentSpecs(turns), profiles, session, 'refine', trace); + return runLoop(withAttachmentSpecs(turns), profiles, session); } // The history carries the spec as a bijlage. Paste one hint line per spec @@ -136,23 +90,20 @@ function withAttachmentSpecs(turns) { }); } -// 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]; +// runLoop drives the agent loop with the refinement prompt and tool set. +async function runLoop(turns, profiles, session) { + const { handlers } = buildTools(profiles, session); + const messages = [{ role: 'system', content: REFINE_PROMPT }, ...turns]; for (let round = 0; round < MAX_ROUNDS; round++) { - const reply = await chat(messages, { tools: toolDefs(mode) }); + const reply = await chat(messages, { tools: toolDefs() }); messages.push(reply.message); const calls = reply.message.tool_calls || []; if (!calls.length) { - return { text: reply.message.content || '', trace }; + return { text: reply.message.content || '' }; } for (const call of calls) { const args = parseArgs(call.function.arguments); - trace.push({ name: call.function.name, args }); const fn = handlers.get(call.function.name); let resultText; if (!fn) { @@ -166,15 +117,14 @@ async function runLoop(turns, profiles, session, mode, trace) { } } messages.push({ role: 'tool', tool_call_id: call.id, content: resultText }); - trace[trace.length - 1].result = resultText; } } - return { text: '(loop did not finish)', trace }; + return { text: '(loop did not finish)' }; } // chat(messages, { tools, toolChoice }) → { message } // The one place that talks to the provider. tools null (the default) means a -// plain completion (the mode classifier); runLoop passes its mode's tool +// plain completion (the mode classifier); runLoop passes the tool // definitions; toolChoice defaults to 'auto'. export async function chat(messages, { tools = null, toolChoice = 'auto' } = {}) { // Send only the fields every provider accepts back. @@ -230,8 +180,8 @@ function parseArgs(s) { // The tool definitions sent to the model. import { toolSchemas } from './schemas.js'; -function toolDefs(mode) { - return toolSchemas(mode).map(t => ({ +function toolDefs() { + return toolSchemas().map(t => ({ type: 'function', function: { name: t.name, diff --git a/src/llm/schemas.js b/src/llm/schemas.js index 55e4a26..8de9e37 100644 --- a/src/llm/schemas.js +++ b/src/llm/schemas.js @@ -1,7 +1,6 @@ // The JSON schemas of the tools. The descriptions are what the LLM reads to -// decide which tool to call. Two modes share four tools (list_profiles, -// describe_profile, validate_spec, read_spec); refine adds lookup_values, -// execute adds run_report. +// decide which tool to call. Refinement only: list_profiles, +// describe_profile, validate_spec, read_spec and lookup_values. const COMMON = [ { @@ -43,16 +42,6 @@ const LOOKUP_VALUES = { }, }; -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'], - }, -}; - const READ_SPEC = { name: 'read_spec', description: 'Return the Turtle of a spec bijlage, a ttl file attached to an earlier message in this conversation. Use it to reuse the agreed spec, or to read back an earlier proposal before you change it. Read-only.', @@ -63,7 +52,6 @@ const READ_SPEC = { }, }; -export function toolSchemas(mode = 'refine') { - if (mode === 'execute') return [...COMMON, READ_SPEC, RUN_REPORT]; +export function toolSchemas() { return [...COMMON, READ_SPEC, LOOKUP_VALUES]; } \ No newline at end of file diff --git a/src/llm/tools.js b/src/llm/tools.js index c6b3a22..536f33e 100644 --- a/src/llm/tools.js +++ b/src/llm/tools.js @@ -1,11 +1,8 @@ -// 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 tools the LLM calls, refinement only: 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. // // The shape every tool returns is { content: [{ type: 'text', text }] }, // which the loop passes straight back to the model. @@ -14,16 +11,13 @@ 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') { +// buildTools(profiles, session) → { handlers } +export function buildTools(profiles, session) { const profileList = () => [...profiles.values()].map(p => ({ id: p.uri, title: p.title })); const findProfile = (id) => profiles.get(id); @@ -42,7 +36,7 @@ export function buildTools(profiles, session, mode = 'refine') { 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. + // The spec that checks out becomes the attached proposal. if (!errors.length) session.onSpecValidated?.(spec); return text(errors.length ? errors[0] : 'ok'); } @@ -68,34 +62,6 @@ export function buildTools(profiles, session, mode = 'refine') { } } - 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 }) { @@ -117,7 +83,7 @@ export function buildTools(profiles, session, mode = 'refine') { ['describe_profile', describe_profile], ['validate_spec', validate_spec], ['read_spec', read_spec], - ...(mode === 'execute' ? [['run_report', run_report]] : [['lookup_values', lookup_values]]), + ['lookup_values', lookup_values], ], ); @@ -125,32 +91,3 @@ export function buildTools(profiles, session, mode = 'refine') { } 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; - }, - }; -} \ No newline at end of file diff --git a/src/report-assistant.js b/src/report-assistant.js index f67beca..8f1f786 100644 --- a/src/report-assistant.js +++ b/src/report-assistant.js @@ -4,8 +4,9 @@ // Execute turns skip the LLM loop. The mode classifier says the user wants // to run the report now; the agreed spec already sits in the conversation // as a bijlage, so it is read from disk and fed straight to the runner. -// The answer is hard-coded: no extra prompt runs for it. When anything on -// the fast path fails, the turn falls back to the LLM loop as before. +// The answer is hard-coded: no extra prompt runs for it. When the run cannot +// happen or fails, the answer is a hard-coded failure message: nothing is +// retried and no spec is rewritten. import { classifyMode } from './llm/mode.js'; import { ask } from './llm/loop.js'; import { parseSpec } from './runner/spec.js'; @@ -17,6 +18,9 @@ import { SPEC_MEDIA_TYPE, ATTACHMENT_TYPES } from './chat/vocab.js'; const TEXT = { executed: 'Het rapport is uitgevoerd. De bijlagen staan erbij.', + noSpec: 'Er is nog geen voorstel om uit te voeren. Beschrijf eerst welk rapport je wilt.', + invalid: 'Het voorstel is niet meer geldig. Vraag een nieuw voorstel.', + failed: 'Het rapport kon niet uitgevoerd worden. Probeer het later opnieuw.', }; const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8); @@ -32,25 +36,32 @@ export function reportAssistant(whenProfiles) { try { return await runStoredSpec(turn, profiles); } catch (e) { - console.error('[reports] direct execution failed, falling back to the LLM:', e); + console.error('[reports] execution failed:', e); + return TEXT.failed; } } return llmAnswer(turn, turns, profiles); }; } -// The fast path: the last spec bijlage in the conversation is the agreed -// spec. No prompt runs; the runner gets it straight away. +// Execution: the last spec bijlage in the conversation is the agreed spec. +// No prompt runs; the runner gets it straight away. async function runStoredSpec(turn, profiles) { const attachment = lastSpecAttachment(turn.history); const content = attachment && readSpecFile(attachment.name); - if (!content) throw new Error('no agreed spec in the conversation'); + if (!content) return TEXT.noSpec; const parsed = parseSpec(content); const profile = profiles.get(parsed.profileUri); - if (!profile) throw new Error(`no profile <${parsed.profileUri}>`); + if (!profile) { + console.error(`[reports] no profile <${parsed.profileUri}>`); + return TEXT.invalid; + } const errors = checkSpec(parsed, profile, MAX_PATH_DEPTH, profiles); - if (errors.length) throw new Error(`the agreed spec is not valid: ${errors[0]}`); + if (errors.length) { + console.error(`[reports] the agreed spec is not valid: ${errors[0]}`); + return TEXT.invalid; + } // The conversation's title (named by the first question) names the file. // The report itself keeps the dct:title of the spec. @@ -96,32 +107,20 @@ function readSpecFile(fileName) { } } -// The LLM loop, refinement and execute mode. LLM answer with the -// conversation so far; the agreed spec goes on the proposal, and once a -// report runs, its CSV (registered by the runner as a real file) and the -// spec go on the same turn. The prompt never carries the Turtle. +// The LLM loop, refinement only. The spec that checked out goes on the +// answer as a bijlage. The prompt never carries the Turtle. async function llmAnswer(turn, turns, profiles) { - // The file uri of the executed report, set by the end callback. - let fileUri = null; // The last spec that checked out; stored as a file for this turn's // final message. let spec = null; const session = { - query: turn.query, - update: turn.update, onSpecValidated(validated) { spec = validated; }, - async onReportEnd(error, result) { - if (!error && result) fileUri = result.fileUri; - }, }; const { text } = await ask(turns, profiles, session); - // The bijlagen of this turn, all with a real file behind them: the - // executed report's CSV first, next the validated spec. const attachments = []; - if (fileUri) attachments.push({ uri: fileUri }); if (spec) { const stored = await storeSpecFile(turn.update, spec); attachments.push({ ...stored, mediaType: SPEC_MEDIA_TYPE }); diff --git a/src/runner/columns.js b/src/runner/columns.js index a057d98..7a5d981 100644 --- a/src/runner/columns.js +++ b/src/runner/columns.js @@ -7,20 +7,13 @@ import { sparqlEscapeUri } from '../db.js'; const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100); -export async function fetchColumns(sessionQuery, subjects, spec, values = new Map(), onProgress = null) { +export async function fetchColumns(sessionQuery, subjects, spec, values = new Map()) { const groups = groupColumns(spec.columns); - // The subject list is complete and the chunk size is fixed, so the number - // of batch queries is known before the first one flies. onProgress fires - // after every answered batch with { done, total }. - const total = groups.length * Math.ceil(subjects.length / SUBJECT_CHUNK_SIZE); - let done = 0; for (const group of groups) { for (let i = 0; i < subjects.length; i += SUBJECT_CHUNK_SIZE) { const chunk = subjects.slice(i, i + SUBJECT_CHUNK_SIZE); const result = await sessionQuery(groupQuery(group, chunk)); collect(result, group, values); - done += 1; - onProgress?.({ done, total }); } } return values; diff --git a/src/runner/run.js b/src/runner/run.js index 8411da3..e8c3a6c 100644 --- a/src/runner/run.js +++ b/src/runner/run.js @@ -6,18 +6,14 @@ import { registerFile, registerReport } from './report.js'; import { startShape } from './profile.js'; // Ties the steps together: seed → columns → assemble → CSV → register. -// Returns { reportUri, fileUri, rowCount }. onProgress fires on every -// answered query with what the phase knows — { subjects } per seed page, -// { done, total } per column batch — so a waiter can tell a live run from -// a stalled one. +// Returns { reportUri, fileUri, rowCount }. -export async function run(query, update, parsed, profile, title, extra = {}, onProgress = null) { +export async function run(query, update, parsed, profile, title, extra = {}) { const shape = startShape(profile, parsed); - const subjects = await seed(query, parsed, shape, - onProgress ? (n) => onProgress({ subjects: n }) : null); + const subjects = await seed(query, parsed, shape); const values = new Map(); - await fetchColumns(query, subjects, parsed, values, null, onProgress); + await fetchColumns(query, subjects, parsed, values); const rows = assemble(subjects, values, parsed); const fileName = extra.fileName || `${slug(title)}.csv`; diff --git a/src/runner/seed.js b/src/runner/seed.js index 120df3d..0060776 100644 --- a/src/runner/seed.js +++ b/src/runner/seed.js @@ -1,12 +1,10 @@ import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime, sparqlEscapeDate } from '../db.js'; // Subject selection. One paged query per page; stops at ROW_LIMIT. -// onPage(n) fires after every answered page with the subject count so far, -// so a caller waiting on this run can tell a live one from a stalled one. const ROW_LIMIT = Number(process.env.ROW_LIMIT || 200000); const PAGE_SIZE = Number(process.env.SEED_PAGE_SIZE || 5000); -export async function seed(sessionQuery, spec, shape = null, onPage = null) { +export async function seed(sessionQuery, spec, shape = null) { const subjects = []; let offset = 0; for (;;) { @@ -14,7 +12,6 @@ export async function seed(sessionQuery, spec, shape = null, onPage = null) { const result = await sessionQuery(query); const rows = result.results.bindings.map(b => b.s.value); subjects.push(...rows); - onPage?.(subjects.length); if (subjects.length > ROW_LIMIT) { throw new Error(`the question matches more than ${ROW_LIMIT} subjects. Add a filter, for example a date range or a bestuurseenheid.`); }