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 {
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 {

View File

@@ -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 => ({
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
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
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

View File

@@ -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];
}

View File

@@ -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,19 +97,28 @@ 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],
['read_spec', read_spec],
...(mode === 'execute' ? [['run_report', run_report]] : [['lookup_values', lookup_values]]),
],
);

View File

@@ -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`;
}

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
// 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;

View File

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