rapport spec as attachment
This commit is contained in:
81
src/chat/attachment-file.js
Normal file
81
src/chat/attachment-file.js
Normal 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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<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 { 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 };
|
||||
}
|
||||
|
||||
8
src/chat/vocab.js
Normal file
8
src/chat/vocab.js
Normal 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';
|
||||
@@ -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
|
||||
|
||||
@@ -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/<uuid>' } },
|
||||
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];
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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/<uuid>/download; the logical file URI ends
|
||||
// in that uuid.
|
||||
function downloadHref(fileUri) {
|
||||
return `/files/${fileUri.split('/').pop()}/download`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user