From b1dad3c6e2fb70ae7ee5bdaf4a9209d08756d240 Mon Sep 17 00:00:00 2001 From: felix Date: Sat, 26 Sep 2026 16:54:32 +0200 Subject: [PATCH] rapport spec as attachment --- src/chat/attachment-file.js | 81 +++++++++++++++++++++++++++ src/chat/index.js | 15 +++-- src/chat/store.js | 107 ++++++++++++++++-------------------- src/chat/vocab.js | 8 +++ src/llm/loop.js | 45 +++++++++++---- src/llm/schemas.js | 20 +++++-- src/llm/tools.js | 57 ++++++++++++------- src/report-assistant.js | 53 +++++++++--------- src/runner/report.js | 10 ++-- src/runner/run.js | 2 +- 10 files changed, 267 insertions(+), 131 deletions(-) create mode 100644 src/chat/attachment-file.js create mode 100644 src/chat/vocab.js diff --git a/src/chat/attachment-file.js b/src/chat/attachment-file.js new file mode 100644 index 0000000..dbdf284 --- /dev/null +++ b/src/chat/attachment-file.js @@ -0,0 +1,81 @@ +// A file made to sit on a message: written to the share, registered as a +// logical nfo:FileDataObject over a physical share:// object, typed with +// dct:type. The file service serves /files//download for it. +import { writeFileSync } from 'fs'; +import { randomUUID } from 'crypto'; +import { sessionUpdate, sparqlEscapeUri, sparqlEscapeString, + sparqlEscapeInt, sparqlEscapeDateTime } from '../db.js'; +import { ATTACHMENT_TYPES, SPEC_MEDIA_TYPE } from './vocab.js'; + +const SHARE_DIR = process.env.SHARE_DIR || '/share'; +const FILE_BASE = 'http://data.lblod.info/files/'; +const SHARE_BASE = 'share://'; +const UUID = 'http://mu.semte.ch/vocabularies/core/uuid'; + +const NFO = 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#'; +const FILE = `${NFO}FileDataObject`; +const FILE_NAME = `${NFO}fileName`; +const FILE_SIZE = `${NFO}fileSize`; +const FILE_CREATED = `${NFO}fileCreated`; +const FORMAT = 'http://purl.org/dc/terms/format'; +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const FILE_TYPE = 'http://purl.org/dc/terms/type'; +const DATA_SOURCE = 'http://www.semanticdesktop.org/ontologies/2007/01/19/nie#dataSource'; + +const file = sparqlEscapeUri; +const str = sparqlEscapeString; + +// storeSpecFile(update, content) → { uri, name }. One spec, one unique file: +// spec bijlagen keep their name apart from the report's ttl-file. Every +// spec file starts with "specificatie-", the read_spec tool opens only +// those. +export async function storeSpecFile(update, content) { + const fileName = `specificatie-${randomUUID()}.ttl`; + writeFileSync(`${SHARE_DIR}/${fileName}`, content, 'utf8'); + return registerFileObject(update, { + fileName, + format: SPEC_MEDIA_TYPE, + size: Buffer.byteLength(content), + type: ATTACHMENT_TYPES.spec, + }); +} + +// registerFileObject(update, { fileName, format, size, type }) → { uri, name }. +// One logical file over one physical share:// file, both nfo:FileDataObject, +// both carrying the dct:type when given. +export async function registerFileObject(update, { fileName, format, size, type }) { + const id = randomUUID(); + const physicalId = randomUUID(); + const logical = `${FILE_BASE}${id}`; + const physical = `${SHARE_BASE}${fileName}`; + const now = sparqlEscapeDateTime(new Date()) // full URI, xsd spelled out + .replace('xsd:dateTime', ''); + + const logicalPairs = [ + `${file(RDF_TYPE)} ${file(FILE)}`, + `${file(UUID)} ${str(id)}`, + `${file(FILE_NAME)} ${str(fileName)}`, + `${file(FORMAT)} ${str(format)}`, + `${file(FILE_SIZE)} ${sparqlEscapeInt(size)}`, + `${file('http://purl.org/dc/terms/created')} ${now}`, + ...(type ? [`${file(FILE_TYPE)} ${file(type)}`] : []), + ]; + const physicalPairs = [ + `${file(RDF_TYPE)} ${file(FILE)}`, + `${file(UUID)} ${str(physicalId)}`, + `${file(FILE_NAME)} ${str(fileName)}`, + `${file(FORMAT)} ${str(format)}`, + `${file(FILE_SIZE)} ${sparqlEscapeInt(size)}`, + `${file(FILE_CREATED)} ${now}`, + `${file(DATA_SOURCE)} ${file(logical)}`, + ...(type ? [`${file(FILE_TYPE)} ${file(type)}`] : []), + ]; + + await (update || sessionUpdate)(` + INSERT DATA { + ${file(logical)} ${logicalPairs.join(' ;\n ')} . + ${file(physical)} ${physicalPairs.join(' ;\n ')} . + }`); + + return { uri: logical, name: fileName }; +} diff --git a/src/chat/index.js b/src/chat/index.js index a0ee4d3..553bc13 100644 --- a/src/chat/index.js +++ b/src/chat/index.js @@ -4,7 +4,7 @@ import express from 'express'; import { query as muQuery, update as muUpdate } from 'mu'; import { readConversation, readHistory, findAssistant, - writeMessage, setDocumentUrl, dropDocument, + writeMessage, } from './store.js'; const TEXT = { @@ -58,13 +58,18 @@ export function mountChat(app, { path = '/assistant', answer }) { say: (text, attachments = []) => writeMessage({ conversationUri: conversation.uri, content: text, maker: assistant, attachments, }), - setUrl: (documentUri, url) => setDocumentUrl(documentUri, url, conversation.uri), - drop: (documentUri) => dropDocument(documentUri), }; try { - const text = await answer(turn); - if (typeof text === 'string' && text.trim()) await turn.say(text.trim()); + // answer may return a string, or { text, attachments } for answers the + // assistant writes with an attachment, such as the report spec. + const result = await answer(turn); + const text = typeof result === 'string' ? result : result?.text; + const attachments + = typeof result === 'string' ? [] : (result?.attachments ?? []); + if (typeof text === 'string' && text.trim()) { + await turn.say(text.trim(), attachments); + } } catch (e) { console.error('[chat] turn failed:', e); try { diff --git a/src/chat/store.js b/src/chat/store.js index b8f21db..922afd5 100644 --- a/src/chat/store.js +++ b/src/chat/store.js @@ -1,6 +1,12 @@ // The chat's reads and writes. Nothing here knows the service it lives in. // Full URIs everywhere: the auth layer rejects a prefixed name with no // PREFIX line with an opaque 500. +// +// A bijlage is a real file: a logical nfo:FileDataObject the file service +// serves at /files//download, over a physical share:// object with +// nie:dataSource back to the logical one. Every attachment carries the +// file's logical URI (see chat/attachment-file.js how those files are +// made); nothing here writes files. import { randomUUID } from 'crypto'; import { query as muQuery, update as muUpdate, sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from 'mu'; @@ -20,14 +26,9 @@ const T = { created: 'http://purl.org/dc/terms/created', maker: 'http://xmlns.com/foaf/0.1/maker', attachment: 'https://www.w3.org/ns/activitystreams#attachment', - document: 'https://www.w3.org/ns/activitystreams#Document', - name: 'https://www.w3.org/ns/activitystreams#name', - mediaType: 'https://www.w3.org/ns/activitystreams#mediaType', - url: 'https://www.w3.org/ns/activitystreams#url', softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent', }; const MESSAGE_BASE = 'http://data.lblod.info/id/chat-messages/'; -const DOCUMENT_BASE = 'http://data.lblod.info/id/chat-documents/'; const u = sparqlEscapeUri; const s = sparqlEscapeString; @@ -53,19 +54,46 @@ export async function readConversation(id) { return { uri: b.conversation.value, id, title: b.title?.value, creator: b.creator?.value }; } -// The last HISTORY_LIMIT messages, oldest first, as { role, content }. +// The last HISTORY_LIMIT messages, oldest first, as +// { role, content, attachments: [{ uri, name, mediaType, type }] }. export async function readHistory(conversationUri, assistantUri) { const json = await muQuery(` - SELECT ?content ?maker ?created WHERE { + SELECT ?message ?content ?maker ?created ?doc ?docName ?docFormat ?docType WHERE { ?message ${u(T.hasContainer)} ${u(conversationUri)} ; ${u(T.content)} ?content ; ${u(T.maker)} ?maker ; ${u(T.created)} ?created . - } ORDER BY DESC(?created) LIMIT ${HISTORY_LIMIT}`); - return bindings(json).reverse().map(b => ({ - role: b.maker.value === assistantUri ? 'assistant' : 'user', - content: b.content.value, - })); + OPTIONAL { + ?message ${u(T.attachment)} ?doc . + OPTIONAL { ?doc ${u('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ?docName . } + OPTIONAL { ?doc ${u('http://purl.org/dc/terms/format')} ?docFormat . } + OPTIONAL { ?doc ${u('http://purl.org/dc/terms/type')} ?docType . } + } + } ORDER BY DESC(?created) LIMIT ${HISTORY_LIMIT * 4}`); + const messages = []; + const byMessage = new Map(); + for (const b of bindings(json).reverse()) { + const uri = b.message.value; + let message = byMessage.get(uri); + if (!message) { + message = { + role: b.maker.value === assistantUri ? 'assistant' : 'user', + content: b.content.value, + attachments: [], + }; + byMessage.set(uri, message); + messages.push(message); + } + if (b.doc) { + message.attachments.push({ + uri: b.doc.value, + name: b.docName?.value, + mediaType: b.docFormat?.value, + type: b.docType?.value, + }); + } + } + return messages.slice(-HISTORY_LIMIT); } // The one assistant the caller can read. Throws unless there is exactly one. @@ -81,17 +109,14 @@ export async function findAssistant() { // --- writes ------------------------------------------------------------------- -// One message, both types, its attachments, and the conversation touched, in +// One message, both types, its bijlagen, and the conversation touched, in // one update. `title` is only written when given (the first question names -// an unnamed conversation). Returns { uri, id, documents: [{ uri, id }] }. +// an unnamed conversation). Every attachment is the logical uri of a file +// that already exists. export async function writeMessage({ conversationUri, content, maker, attachments = [], title }) { const id = randomUUID(); const uri = `${MESSAGE_BASE}${id}`; const now = dt(new Date()); - const documents = attachments.map(a => { - const id = randomUUID(); - return { ...a, id, uri: `${DOCUMENT_BASE}${id}` }; - }); // Predicate pairs joined with ';', never a trailing one: it breaks the Turtle. const pairs = [ @@ -103,7 +128,10 @@ export async function writeMessage({ conversationUri, content, maker, attachment `${u(T.created)} ${now}`, ]; if (maker) pairs.push(`${u(T.maker)} ${u(maker)}`); - for (const d of documents) pairs.push(`${u(T.attachment)} ${u(d.uri)}`); + for (const attachment of attachments) { + if (!attachment.uri) throw new Error('an attachment without a file uri'); + pairs.push(`${u(T.attachment)} ${u(attachment.uri)}`); + } const conversationPairs = [`${u(T.lastActivity)} ${now}`]; if (title) conversationPairs.push(`${u(T.title)} ${s(title)}`); @@ -114,50 +142,11 @@ export async function writeMessage({ conversationUri, content, maker, attachment } INSERT { ${u(uri)} ${pairs.join(' ;\n ')} . - ${documents.map(d => ` - ${u(d.uri)} ${[ - `${u(T.type)} ${u(T.document)}`, - `${u(T.uuid)} ${s(d.id)}`, - `${u(T.name)} ${s(d.name)}`, - `${u(T.mediaType)} ${s(d.mediaType || 'application/octet-stream')}`, - ...(d.url ? [`${u(T.url)} ${s(d.url)}`] : []), - ].join(' ;\n ')} .`).join('')} ${u(conversationUri)} ${conversationPairs.join(' ;\n ')} . } WHERE { OPTIONAL { ${u(conversationUri)} ${u(T.lastActivity)} ?old . } }`); - return { uri, id, documents: documents.map(({ uri, id }) => ({ uri, id })) }; -} - -// The file is ready. -export async function setDocumentUrl(documentUri, url, conversationUri) { - const now = dt(new Date()); - await muUpdate(` - DELETE { - ${u(documentUri)} ${u(T.url)} ?oldUrl . - ${u(conversationUri)} ${u(T.lastActivity)} ?old . - } - INSERT { - ${u(documentUri)} ${u(T.url)} ${s(url)} . - ${u(conversationUri)} ${u(T.lastActivity)} ${now} . - } - WHERE { - OPTIONAL { ${u(documentUri)} ${u(T.url)} ?oldUrl . } - OPTIONAL { ${u(conversationUri)} ${u(T.lastActivity)} ?old . } - }`); -} - -// The file failed; the card disappears, the caller says why in a message. -export async function dropDocument(documentUri) { - await muUpdate(` - DELETE { - ?message ${u(T.attachment)} ${u(documentUri)} . - ${u(documentUri)} ?p ?o . - } - WHERE { - ${u(documentUri)} ?p ?o . - OPTIONAL { ?message ${u(T.attachment)} ${u(documentUri)} . } - }`); + return { uri, id }; } diff --git a/src/chat/vocab.js b/src/chat/vocab.js new file mode 100644 index 0000000..c383670 --- /dev/null +++ b/src/chat/vocab.js @@ -0,0 +1,8 @@ +// The bijlage types of the report assistant. Match the +// add-chat-attachment-types-codelist migration in the portal. +export const ATTACHMENT_TYPES = { + spec: 'http://lblod.data.gift/concepts/6693014e-7b7d-448c-bb24-80c9758521f3', + result: 'http://lblod.data.gift/concepts/28d97698-e219-4a92-bdbd-7ab367a7524e', +}; +export const SPEC_BASE_NAME = 'specificatie'; +export const SPEC_MEDIA_TYPE = 'text/turtle'; diff --git a/src/llm/loop.js b/src/llm/loop.js index 2d957f4..fd3bacf 100644 --- a/src/llm/loop.js +++ b/src/llm/loop.js @@ -47,13 +47,19 @@ 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. +Iterate with the user on the spec itself: paths, filters, columns. When the +user wants an earlier spec changed, open it with read_spec and change what +they asked for. Never call +run_report; there is no such tool here. When the spec is ready, 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. +The spec itself does not go in your text. validate_spec stores a spec that +checks out as an attachment ("bijlage", a spec file) on your answer. So in +your answer: explain in Dutch in full what the report will list and which +filters it applies, tell the user the actual spec is in the bijlage and to +look there, and ask the user to confirm execution. Do not print the Turtle +yourself; the bijlage shows the validated version, so never rewrite it in +the text either. 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 @@ -82,9 +88,11 @@ Rules: - 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. +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 @@ -108,9 +116,24 @@ export async function ask(questionOrMessages, profiles, session) { trace.push({ name: 'classify', args: { execute }, result: execute ? 'execute' : 'refine' }); if (execute) { - return runLoop(turns, profiles, session, 'execute', trace); + return runLoop(withAttachmentSpecs(turns), profiles, session, 'execute', trace); } - return runLoop(turns, profiles, session, 'refine', trace); + return runLoop(withAttachmentSpecs(turns), profiles, session, 'refine', trace); +} + +// The history carries the spec as a bijlage. Paste one hint line per spec +// under the message text, so the model knows a spec is there and opens it +// with read_spec; the chat itself still keeps the Turtle out of the text. +function withAttachmentSpecs(turns) { + return turns.map(t => { + const specs = (t.attachments || []).filter(a => a.mediaType === 'text/turtle' && a.uri); + if (!specs.length) return t; + return { + ...t, + content: `${t.content}\n\n${specs.map(a => + `Bijlage "${a.name}", a spec file (read it with read_spec): ${a.uri}`).join('\n\n')}`, + }; + }); } // runLoop drives the agent loop for one mode. The mode picks the system diff --git a/src/llm/schemas.js b/src/llm/schemas.js index c860c66..55e4a26 100644 --- a/src/llm/schemas.js +++ b/src/llm/schemas.js @@ -1,7 +1,7 @@ // 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. +// 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. const COMMON = [ { @@ -53,7 +53,17 @@ const RUN_REPORT = { }, }; +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.', + inputSchema: { + type: 'object', + properties: { file_uri: { type: 'string', description: 'The file URI of the spec bijlage, e.g. http://data.lblod.info/files/' } }, + required: ['file_uri'], + }, +}; + export function toolSchemas(mode = 'refine') { - if (mode === 'execute') return [...COMMON, RUN_REPORT]; - return [...COMMON, LOOKUP_VALUES]; + if (mode === 'execute') return [...COMMON, READ_SPEC, RUN_REPORT]; + 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 40b0b77..3b2db3d 100644 --- a/src/llm/tools.js +++ b/src/llm/tools.js @@ -1,11 +1,11 @@ // 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. +// - 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. @@ -16,6 +16,8 @@ import { describeProfile, codeListValues } from './describe.js'; import { lookupValues } from './lookup.js'; import { run, slug } 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; @@ -40,6 +42,8 @@ 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. + if (!errors.length) session.onSpecValidated?.(spec); return text(errors.length ? errors[0] : 'ok'); } @@ -72,12 +76,14 @@ export function buildTools(profiles, session, mode = 'refine') { 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'; 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 }, waiter.tick); + 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. @@ -91,20 +97,29 @@ export function buildTools(profiles, session, mode = 'refine') { } } + // 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( - 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], - ], + [ + ['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 }; diff --git a/src/report-assistant.js b/src/report-assistant.js index 83e6b3d..8fb8906 100644 --- a/src/report-assistant.js +++ b/src/report-assistant.js @@ -1,48 +1,51 @@ // What this service answers a chat turn with: the LLM loop with the -// conversation so far, and a report run becomes an interim message with a -// pending file. +// 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. import { ask } from './llm/loop.js'; +import { storeSpecFile } from './chat/attachment-file.js'; +import { SPEC_MEDIA_TYPE } from './chat/vocab.js'; const TEXT = { making: 'Ik maak het rapport. Het verschijnt hier zodra het klaar is.', - failed: 'Het rapport is mislukt. Stel de vraag opnieuw, of anders geformuleerd.', }; // The chat mounts before the profiles are in; whenProfiles waits for the load. export function reportAssistant(whenProfiles) { return async function answer(turn) { - // One pending file per turn: a second run_report overwrites it before the - // first end callback fires. - let pendingDocument = null; + // 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, - async onReportStart({ fileName }) { - const said = await turn.say(TEXT.making, [{ name: fileName, mediaType: 'text/csv' }]); - pendingDocument = said.documents[0].uri; + 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) { - const documentUri = pendingDocument; - pendingDocument = null; - if (!documentUri) return; - if (error) { - await turn.drop(documentUri); - if (turn.finished) await turn.say(TEXT.failed); - return; - } - await turn.setUrl(documentUri, downloadHref(result.fileUri)); + if (!error && result) fileUri = result.fileUri; }, }; const profiles = await whenProfiles(); const { text } = await ask([...turn.history, { role: 'user', content: turn.content }], profiles, session); - return text; + + // 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 }; }; } - -// The file service serves /files//download; the logical file URI ends -// in that uuid. -function downloadHref(fileUri) { - return `/files/${fileUri.split('/').pop()}/download`; -} diff --git a/src/runner/report.js b/src/runner/report.js index 0fce81d..cd8e99f 100644 --- a/src/runner/report.js +++ b/src/runner/report.js @@ -7,8 +7,8 @@ const REPORT_CLASS = process.env.REPORT_CLASS || 'http://lblod.data.gift/vocabul // Register the CSV: a logical file (http://data.lblod.info/files/) plus // a physical share:// file pointing back with nie:dataSource. The logical -// URI is what you link to. -export async function registerFile(sessionUpdateFn, fileName, filePath) { +// URI is what you link to. When given, the file gets dct:type. +export async function registerFile(sessionUpdateFn, fileName, filePath, { type } = {}) { const size = statSync(filePath).size; const uuid = randomUUID(); const uuid2 = randomUUID(); @@ -23,14 +23,16 @@ export async function registerFile(sessionUpdateFn, fileName, filePath) { ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ; ${sparqlEscapeUri('http://purl.org/dc/terms/format')} ${sparqlEscapeString('text/csv')} ; ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize')} ${sparqlEscapeInt(size)} ; - ${sparqlEscapeUri('http://purl.org/dc/terms/created')} ${sparqlEscapeDateTime(now)} . + ${sparqlEscapeUri('http://purl.org/dc/terms/created')} ${sparqlEscapeDateTime(now)}${type ? ` ; + ${sparqlEscapeUri('http://purl.org/dc/terms/type')} ${sparqlEscapeUri(type)}` : ''} . ${sparqlEscapeUri(physicalUri)} a ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject')} ; ${sparqlEscapeUri('http://mu.semte.ch/vocabularies/core/uuid')} ${sparqlEscapeString(uuid2)} ; ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ; ${sparqlEscapeUri('http://purl.org/dc/terms/format')} ${sparqlEscapeString('text/csv')} ; ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize')} ${sparqlEscapeInt(size)} ; ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileCreated')} ${sparqlEscapeDateTime(now)} ; - ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/01/19/nie#dataSource')} ${sparqlEscapeUri(logicalUri)} . + ${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/01/19/nie#dataSource')} ${sparqlEscapeUri(logicalUri)}${type ? ` ; + ${sparqlEscapeUri('http://purl.org/dc/terms/type')} ${sparqlEscapeUri(type)}` : ''} . }`); return logicalUri; diff --git a/src/runner/run.js b/src/runner/run.js index 7de7d4b..a72e498 100644 --- a/src/runner/run.js +++ b/src/runner/run.js @@ -25,7 +25,7 @@ export async function run(query, update, parsed, profile, title, extra = {}, onP const filePath = writeCsv(fileName, csv); const specFile = extra.spec && writeSpec(fileName, extra.spec); - const fileUri = await registerFile(update, fileName, filePath); + const fileUri = await registerFile(update, fileName, filePath, { type: extra.fileType }); const reportUri = await registerReport(update, title, fileUri, specFile ? { ...extra, specFile } : extra);