rapport spec as attachment

This commit is contained in:
felix
2026-09-26 16:54:32 +02:00
parent aec7dc457c
commit b1dad3c6e2
10 changed files with 267 additions and 131 deletions

View File

@@ -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/<uuid>/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', '<http://www.w3.org/2001/XMLSchema#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 };
}

View File

@@ -4,7 +4,7 @@ import express from 'express';
import { query as muQuery, update as muUpdate } from 'mu'; import { query as muQuery, update as muUpdate } from 'mu';
import { import {
readConversation, readHistory, findAssistant, readConversation, readHistory, findAssistant,
writeMessage, setDocumentUrl, dropDocument, writeMessage,
} from './store.js'; } from './store.js';
const TEXT = { const TEXT = {
@@ -58,13 +58,18 @@ export function mountChat(app, { path = '/assistant', answer }) {
say: (text, attachments = []) => writeMessage({ say: (text, attachments = []) => writeMessage({
conversationUri: conversation.uri, content: text, maker: assistant, attachments, conversationUri: conversation.uri, content: text, maker: assistant, attachments,
}), }),
setUrl: (documentUri, url) => setDocumentUrl(documentUri, url, conversation.uri),
drop: (documentUri) => dropDocument(documentUri),
}; };
try { try {
const text = await answer(turn); // answer may return a string, or { text, attachments } for answers the
if (typeof text === 'string' && text.trim()) await turn.say(text.trim()); // 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) { } catch (e) {
console.error('[chat] turn failed:', e); console.error('[chat] turn failed:', e);
try { try {

View File

@@ -1,6 +1,12 @@
// The chat's reads and writes. Nothing here knows the service it lives in. // 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 // Full URIs everywhere: the auth layer rejects a prefixed name with no
// PREFIX line with an opaque 500. // PREFIX line with an opaque 500.
//
// A bijlage is a real file: a logical nfo:FileDataObject the file service
// serves at /files/<uuid>/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 { randomUUID } from 'crypto';
import { query as muQuery, update as muUpdate, import { query as muQuery, update as muUpdate,
sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from 'mu'; sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from 'mu';
@@ -20,14 +26,9 @@ const T = {
created: 'http://purl.org/dc/terms/created', created: 'http://purl.org/dc/terms/created',
maker: 'http://xmlns.com/foaf/0.1/maker', maker: 'http://xmlns.com/foaf/0.1/maker',
attachment: 'https://www.w3.org/ns/activitystreams#attachment', 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', softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent',
}; };
const MESSAGE_BASE = 'http://data.lblod.info/id/chat-messages/'; const MESSAGE_BASE = 'http://data.lblod.info/id/chat-messages/';
const DOCUMENT_BASE = 'http://data.lblod.info/id/chat-documents/';
const u = sparqlEscapeUri; const u = sparqlEscapeUri;
const s = sparqlEscapeString; 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 }; 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) { export async function readHistory(conversationUri, assistantUri) {
const json = await muQuery(` 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)} ; ?message ${u(T.hasContainer)} ${u(conversationUri)} ;
${u(T.content)} ?content ; ${u(T.content)} ?content ;
${u(T.maker)} ?maker ; ${u(T.maker)} ?maker ;
${u(T.created)} ?created . ${u(T.created)} ?created .
} ORDER BY DESC(?created) LIMIT ${HISTORY_LIMIT}`); OPTIONAL {
return bindings(json).reverse().map(b => ({ ?message ${u(T.attachment)} ?doc .
role: b.maker.value === assistantUri ? 'assistant' : 'user', OPTIONAL { ?doc ${u('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ?docName . }
content: b.content.value, 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. // The one assistant the caller can read. Throws unless there is exactly one.
@@ -81,17 +109,14 @@ export async function findAssistant() {
// --- writes ------------------------------------------------------------------- // --- 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 // 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 }) { export async function writeMessage({ conversationUri, content, maker, attachments = [], title }) {
const id = randomUUID(); const id = randomUUID();
const uri = `${MESSAGE_BASE}${id}`; const uri = `${MESSAGE_BASE}${id}`;
const now = dt(new Date()); 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. // Predicate pairs joined with ';', never a trailing one: it breaks the Turtle.
const pairs = [ const pairs = [
@@ -103,7 +128,10 @@ export async function writeMessage({ conversationUri, content, maker, attachment
`${u(T.created)} ${now}`, `${u(T.created)} ${now}`,
]; ];
if (maker) pairs.push(`${u(T.maker)} ${u(maker)}`); 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}`]; const conversationPairs = [`${u(T.lastActivity)} ${now}`];
if (title) conversationPairs.push(`${u(T.title)} ${s(title)}`); if (title) conversationPairs.push(`${u(T.title)} ${s(title)}`);
@@ -114,50 +142,11 @@ export async function writeMessage({ conversationUri, content, maker, attachment
} }
INSERT { INSERT {
${u(uri)} ${pairs.join(' ;\n ')} . ${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 ')} . ${u(conversationUri)} ${conversationPairs.join(' ;\n ')} .
} }
WHERE { WHERE {
OPTIONAL { ${u(conversationUri)} ${u(T.lastActivity)} ?old . } OPTIONAL { ${u(conversationUri)} ${u(T.lastActivity)} ?old . }
}`); }`);
return { uri, id, documents: documents.map(({ uri, id }) => ({ uri, id })) }; return { 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)} . }
}`);
} }

8
src/chat/vocab.js Normal file
View File

@@ -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';

View File

@@ -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 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 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. 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 Iterate with the user on the spec itself: paths, filters, columns. When the
run_report; there is no such tool here. When the spec is ready, present it in user wants an earlier spec changed, open it with read_spec and change what
Dutch and ask the user to confirm execution. Then stop. 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 The spec itself does not go in your text. validate_spec stores a spec that
block, so the next turn can reuse it verbatim. Put the Dutch explanation first, checks out as an attachment ("bijlage", a spec file) on your answer. So in
then the Turtle block, then the question asking to confirm. 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 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 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, - One question makes one report. Write the agreed spec, validate it, run it,
then stop. Do not write a second report. then stop. Do not write a second report.
The agreed spec may already be in an earlier assistant message (a Turtle The agreed spec may already be attached ("bijlage") to an earlier assistant
block). Reuse it verbatim when it fits the user's confirmed intent; only message in the conversation, a ttl file typed as a rapportspecificatie. Use
adjust it when the user asked for a change. 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 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 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' }); trace.push({ name: 'classify', args: { execute }, result: execute ? 'execute' : 'refine' });
if (execute) { 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 // runLoop drives the agent loop for one mode. The mode picks the system

View File

@@ -1,7 +1,7 @@
// The JSON schemas of the tools. The descriptions are what the LLM reads to // 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, // decide which tool to call. Two modes share four tools (list_profiles,
// describe_profile, validate_spec); refine adds lookup_values, execute adds // describe_profile, validate_spec, read_spec); refine adds lookup_values,
// run_report. // execute adds run_report.
const COMMON = [ 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/<uuid>' } },
required: ['file_uri'],
},
};
export function toolSchemas(mode = 'refine') { export function toolSchemas(mode = 'refine') {
if (mode === 'execute') return [...COMMON, RUN_REPORT]; if (mode === 'execute') return [...COMMON, READ_SPEC, RUN_REPORT];
return [...COMMON, LOOKUP_VALUES]; return [...COMMON, READ_SPEC, LOOKUP_VALUES];
} }

View File

@@ -1,11 +1,11 @@
// The tools the LLM calls. Two modes: // The tools the LLM calls. Two modes:
// - refine: list_profiles, describe_profile, validate_spec, lookup_values. // - refine: list_profiles, describe_profile, validate_spec, lookup_values,
// lookup_values searches a code list under the service scope (public // read_spec. lookup_values searches a code list under the service scope
// graph) and returns candidate values, so the LLM can suggest concrete // (public graph) and returns candidate values, so the LLM can suggest
// filter values. No report is created, no spec is evaluated. // concrete filter values. No report is created, no spec is evaluated.
// - execute: list_profiles, describe_profile, validate_spec, run_report. // - execute: list_profiles, describe_profile, validate_spec, read_spec,
// run_report runs as the caller and returns only counts and URIs; no cell // run_report. run_report runs as the caller and returns only counts and
// value ever reaches the model. // URIs; no cell value ever reaches the model.
// //
// The shape every tool returns is { content: [{ type: 'text', text }] }, // The shape every tool returns is { content: [{ type: 'text', text }] },
// which the loop passes straight back to the model. // 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 { lookupValues } from './lookup.js';
import { run, slug } from '../runner/run.js'; import { run, slug } from '../runner/run.js';
import { scopedQuery } from '../db.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 MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
const RUN_TIMEOUT = Number(process.env.RUN_TIMEOUT || 60) * 1000; 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); const p = findProfile(parsed.profileUri);
if (!p) return text(`no profile <${parsed.profileUri}>. Available: ${profileList().map(x => x.title).join(', ')}.`); if (!p) return text(`no profile <${parsed.profileUri}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles); 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'); 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}>.`); if (!p) return text(`no profile <${parsed.profileUri}>.`);
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles); const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
if (errors.length) return text(`the spec is not valid: ${errors[0]}`); 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 { try {
// The spec's dct:title names the report. // The spec's dct:title names the report.
const name = parsed.title || 'report'; const name = parsed.title || 'report';
await session.onReportStart?.({ title: name, fileName: `${slug(name)}.csv` }); await session.onReportStart?.({ title: name, fileName: `${slug(name)}.csv` });
const waiter = newRunWaiter(RUN_TIMEOUT); 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 // 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 // cancelled, the file arrives when it arrives. The .catch keeps a late
// failure from crashing the process. // 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( const handlers = new Map(
mode === 'execute' [
? [ ['list_profiles', list_profiles],
['list_profiles', list_profiles], ['describe_profile', describe_profile],
['describe_profile', describe_profile], ['validate_spec', validate_spec],
['validate_spec', validate_spec], ['read_spec', read_spec],
['run_report', run_report], ...(mode === 'execute' ? [['run_report', run_report]] : [['lookup_values', lookup_values]]),
] ],
: [
['list_profiles', list_profiles],
['describe_profile', describe_profile],
['validate_spec', validate_spec],
['lookup_values', lookup_values],
],
); );
return { handlers }; return { handlers };

View File

@@ -1,48 +1,51 @@
// What this service answers a chat turn with: the LLM loop with the // 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 // conversation so far. The service is synchronous per turn: when the answer
// pending file. // 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 { ask } from './llm/loop.js';
import { storeSpecFile } from './chat/attachment-file.js';
import { SPEC_MEDIA_TYPE } from './chat/vocab.js';
const TEXT = { const TEXT = {
making: 'Ik maak het rapport. Het verschijnt hier zodra het klaar is.', 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. // The chat mounts before the profiles are in; whenProfiles waits for the load.
export function reportAssistant(whenProfiles) { export function reportAssistant(whenProfiles) {
return async function answer(turn) { return async function answer(turn) {
// One pending file per turn: a second run_report overwrites it before the // The file uri of the executed report, set by the end callback.
// first end callback fires. let fileUri = null;
let pendingDocument = null; // The last spec that checked out; stored as a file for this turn's
// final message.
let spec = null;
const session = { const session = {
query: turn.query, query: turn.query,
update: turn.update, update: turn.update,
async onReportStart({ fileName }) { onSpecValidated(validated) { spec = validated; },
const said = await turn.say(TEXT.making, [{ name: fileName, mediaType: 'text/csv' }]); async onReportStart() {
pendingDocument = said.documents[0].uri; // 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) { async onReportEnd(error, result) {
const documentUri = pendingDocument; if (!error && result) fileUri = result.fileUri;
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));
}, },
}; };
const profiles = await whenProfiles(); const profiles = await whenProfiles();
const { text } = await ask([...turn.history, { role: 'user', content: turn.content }], profiles, session); 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/<uuid>/download; the logical file URI ends
// in that uuid.
function downloadHref(fileUri) {
return `/files/${fileUri.split('/').pop()}/download`;
}

View File

@@ -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/<uuid>) plus // Register the CSV: a logical file (http://data.lblod.info/files/<uuid>) plus
// a physical share:// file pointing back with nie:dataSource. The logical // a physical share:// file pointing back with nie:dataSource. The logical
// URI is what you link to. // URI is what you link to. When given, the file gets dct:type.
export async function registerFile(sessionUpdateFn, fileName, filePath) { export async function registerFile(sessionUpdateFn, fileName, filePath, { type } = {}) {
const size = statSync(filePath).size; const size = statSync(filePath).size;
const uuid = randomUUID(); const uuid = randomUUID();
const uuid2 = 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://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ;
${sparqlEscapeUri('http://purl.org/dc/terms/format')} ${sparqlEscapeString('text/csv')} ; ${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#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(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://mu.semte.ch/vocabularies/core/uuid')} ${sparqlEscapeString(uuid2)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName')} ${sparqlEscapeString(fileName)} ; ${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://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#fileSize')} ${sparqlEscapeInt(size)} ;
${sparqlEscapeUri('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileCreated')} ${sparqlEscapeDateTime(now)} ; ${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; return logicalUri;

View File

@@ -25,7 +25,7 @@ export async function run(query, update, parsed, profile, title, extra = {}, onP
const filePath = writeCsv(fileName, csv); const filePath = writeCsv(fileName, csv);
const specFile = extra.spec && writeSpec(fileName, extra.spec); 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, const reportUri = await registerReport(update, title, fileUri,
specFile ? { ...extra, specFile } : extra); specFile ? { ...extra, specFile } : extra);