From 41b91e65d9a1eae5b968ba2a436368843e23cee6 Mon Sep 17 00:00:00 2001 From: felix Date: Sat, 26 Sep 2026 17:05:19 +0200 Subject: [PATCH] simplifiaction of the flow --- src/llm/loop.js | 2 + src/llm/tools.js | 3 +- src/report-assistant.js | 155 ++++++++++++++++++++++++++++++---------- src/runner/run.js | 2 +- 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/src/llm/loop.js b/src/llm/loop.js index fd3bacf..36d3125 100644 --- a/src/llm/loop.js +++ b/src/llm/loop.js @@ -201,6 +201,8 @@ export async function chat(messages, { tools = null, toolChoice = 'auto' } = {}) if (LOG_LLM) { console.log(`[llm] POST ${LLM_BASE_URL}/chat/completions request:\n${ JSON.stringify(body, null, 2)}`); + } else { + console.log('[llm] sending instructions to llm'); } const res = await fetch(`${LLM_BASE_URL}/chat/completions`, { method: 'POST', diff --git a/src/llm/tools.js b/src/llm/tools.js index 3b2db3d..c6b3a22 100644 --- a/src/llm/tools.js +++ b/src/llm/tools.js @@ -14,7 +14,7 @@ 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 { run } from '../runner/run.js'; import { scopedQuery } from '../db.js'; import { readFileSync } from 'fs'; import { ATTACHMENT_TYPES } from '../chat/vocab.js'; @@ -81,7 +81,6 @@ export function buildTools(profiles, session, mode = 'refine') { try { // The spec's dct:title names the report. const name = parsed.title || 'report'; - await session.onReportStart?.({ title: name, fileName: `${slug(name)}.csv` }); 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 diff --git a/src/report-assistant.js b/src/report-assistant.js index 8fb8906..f67beca 100644 --- a/src/report-assistant.js +++ b/src/report-assistant.js @@ -1,51 +1,130 @@ -// What this service answers a chat turn with: the LLM loop with the -// conversation so far. The service is synchronous per turn: when the answer -// is written, every bijlage already exists. 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. +// What this service answers a chat turn with. The service is synchronous +// per turn: when the answer is written, every bijlage already exists. +// +// 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. +import { classifyMode } from './llm/mode.js'; import { ask } from './llm/loop.js'; -import { storeSpecFile } from './chat/attachment-file.js'; -import { SPEC_MEDIA_TYPE } from './chat/vocab.js'; +import { parseSpec } from './runner/spec.js'; +import { checkSpec } from './runner/check.js'; +import { run, slug } from './runner/run.js'; +import { readFileSync } from 'fs'; +import { storeSpecFile, registerFileObject } from './chat/attachment-file.js'; +import { SPEC_MEDIA_TYPE, ATTACHMENT_TYPES } from './chat/vocab.js'; const TEXT = { - making: 'Ik maak het rapport. Het verschijnt hier zodra het klaar is.', + executed: 'Het rapport is uitgevoerd. De bijlagen staan erbij.', }; +const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8); +const SHARE_DIR = process.env.SHARE_DIR || '/share'; + // The chat mounts before the profiles are in; whenProfiles waits for the load. export function reportAssistant(whenProfiles) { return async function answer(turn) { - // 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 onReportStart() { - // Feedback while the run goes on; the report itself lands on the - // final message in bijlagen. - await turn.say(TEXT.making); - }, - async onReportEnd(error, result) { - if (!error && result) fileUri = result.fileUri; - }, - }; - + const turns = [...turn.history, { role: 'user', content: turn.content }]; const profiles = await whenProfiles(); - const { text } = await ask([...turn.history, { role: 'user', content: turn.content }], 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 }); + if (await classifyMode(turns)) { + try { + return await runStoredSpec(turn, profiles); + } catch (e) { + console.error('[reports] direct execution failed, falling back to the LLM:', e); + } } - return { text, attachments }; + 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. +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'); + + const parsed = parseSpec(content); + const profile = profiles.get(parsed.profileUri); + if (!profile) throw new Error(`no profile <${parsed.profileUri}>`); + const errors = checkSpec(parsed, profile, MAX_PATH_DEPTH, profiles); + if (errors.length) throw new Error(`the agreed spec is not valid: ${errors[0]}`); + + // The conversation's title (named by the first question) names the file. + // The report itself keeps the dct:title of the spec. + const { fileUri } = await run(turn.query, turn.update, parsed, profile, + parsed.title, { + spec: content, + fileType: ATTACHMENT_TYPES.result, + fileName: `${slug(turn.conversation.title)}.csv`, + }); + + // Same physical spec file, registered as a bijlage for this message. + const specFile = await registerFileObject(turn.update, { + fileName: attachment.name, + format: SPEC_MEDIA_TYPE, + size: Buffer.byteLength(content), + type: ATTACHMENT_TYPES.spec, + }); + + return { + text: TEXT.executed, + attachments: [ + { uri: fileUri }, + { ...specFile, mediaType: SPEC_MEDIA_TYPE }, + ], + }; +} + +function lastSpecAttachment(history) { + for (const message of [...(history || [])].reverse()) { + const specs = (message.attachments || []).filter(a => + a.mediaType === SPEC_MEDIA_TYPE + && /^specificatie-[0-9a-f-]+\.ttl$/.test(a.name || '')); + if (specs.length) return specs[specs.length - 1]; + } + return null; +} + +function readSpecFile(fileName) { + try { + return readFileSync(`${SHARE_DIR}/${fileName}`, 'utf8'); + } catch { + return null; + } +} + +// 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. +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 }); + } + return { text, attachments }; +} diff --git a/src/runner/run.js b/src/runner/run.js index a72e498..8411da3 100644 --- a/src/runner/run.js +++ b/src/runner/run.js @@ -20,7 +20,7 @@ export async function run(query, update, parsed, profile, title, extra = {}, onP await fetchColumns(query, subjects, parsed, values, null, onProgress); const rows = assemble(subjects, values, parsed); - const fileName = `${slug(title)}.csv`; + const fileName = extra.fileName || `${slug(title)}.csv`; const csv = rowsToCsv(rows); const filePath = writeCsv(fileName, csv); const specFile = extra.spec && writeSpec(fileName, extra.spec);