From 80097d313067b106e5768b4a4cf1856d625826af Mon Sep 17 00:00:00 2001 From: felix Date: Sun, 27 Sep 2026 00:57:17 +0200 Subject: [PATCH] Further cleaning --- README.md | 4 +- app.js | 15 ++-- src/chat.js | 12 ---- src/llm/describe.js | 47 ++++++------- src/llm/explore.js | 167 ++++++++++++++++++++++++++++++++++++++------ src/llm/lookup.js | 138 ------------------------------------ src/llm/tools.js | 3 +- 7 files changed, 180 insertions(+), 206 deletions(-) delete mode 100644 src/llm/lookup.js diff --git a/README.md b/README.md index f1f36b2..c668156 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ only; it is not the report. `validate_spec` still says where each The LLM's own reads during refinement (`describe_profile`'s code lists, `lookup_values`, `explore_data` and `sample_spec`) run under a service -scope, not the caller's session: `publicQuery` in `src/llm/lookup.js`. mu's +scope, not the caller's session: `publicQuery` in `src/llm/explore.js`. mu's `query(q, { scope })` sends `mu-auth-scope`; the sparql-parser config grants that scope read access to `http://mu.semte.ch/graphs/public` only. So refinement reads only the public graph, regardless of who calls. Execution @@ -149,7 +149,7 @@ user sees is Dutch. | `SEED_PAGE_SIZE` | `5000` | seed page size | | `SUBJECT_CHUNK_SIZE` | `100` | see above | | `SHARE_DIR` | `/share` | where CSVs are written | -| `CHAT_ASSISTANT_URI` | — | the `prov:SoftwareAgent` that signs assistant messages; found by lookup when unset | +| `CHAT_ASSISTANT_URI` | `http://data.lblod.info/id/chat-agents/rapportassistent` | the `prov:SoftwareAgent` that signs assistant messages (the portal's chat-assistant migration seeds it) | | `CHAT_HISTORY_LIMIT` | `20` | messages of history a turn reads | | `SERVICE_SCOPE` | `http://services.semantic.works/natural-language-report` | the mu-auth-scope for the LLM's own reads (code lists, `lookup_values`, `explore_data`, `sample_spec`). Must match the `with-scope` grant in the app's sparql-parser config. | diff --git a/app.js b/app.js index d8e5f86..f0fb50e 100644 --- a/app.js +++ b/app.js @@ -1,12 +1,14 @@ import { app, errorHandler } from 'mu'; import bodyParser from 'body-parser'; import { loadProfiles } from './src/runner/profile.js'; -import { readConversation, readHistory, findAssistant, writeMessage } from './src/chat.js'; +import { readConversation, readHistory, writeMessage } from './src/chat.js'; import { wantsExecution } from './src/llm/mode.js'; import { executeSpec, refineSpec } from './src/report-assistant.js'; const PROFILE_DIR = process.env.PROFILE_DIR || '/config/profiles'; -const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI; +// The agent that signs the assistant's messages, seeded by the portal's +// chat-assistant migration. +const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI || 'http://data.lblod.info/id/chat-agents/rapportassistent'; const TITLE_LENGTH = 80; const FAILED = 'Er ging iets mis. Probeer het opnieuw.'; @@ -22,13 +24,12 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function const content = String(req.body?.content ?? '').trim(); if (!content) return res.status(400).json({ error: 'content is required' }); - let conversation, assistant, history, question; + let conversation, history, question; try { // the access check: the caller's session decides what is readable conversation = await readConversation(req.params.id); if (!conversation) return res.status(404).json({ error: 'no such conversation' }); - assistant = CHAT_ASSISTANT_URI || await findAssistant(); - history = await readHistory(conversation.uri, assistant); + history = await readHistory(conversation.uri, CHAT_ASSISTANT_URI); question = await writeMessage({ conversationUri: conversation.uri, content, @@ -50,13 +51,13 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function await writeMessage({ conversationUri: conversation.uri, content: answer.text, - maker: assistant, + maker: CHAT_ASSISTANT_URI, attachments: answer.attachments, }); } catch (e) { console.error('[chat] turn failed:', e); try { - await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: assistant }); + await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: CHAT_ASSISTANT_URI }); } catch (e2) { console.error('[chat] could not write the failure message:', e2); } diff --git a/src/chat.js b/src/chat.js index 33bb989..c2da6a7 100644 --- a/src/chat.js +++ b/src/chat.js @@ -25,7 +25,6 @@ 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', - softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent', file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject', fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName', fileSize: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize', @@ -109,17 +108,6 @@ export async function readHistory(conversationUri, assistantUri) { return messages.slice(-HISTORY_LIMIT); } -// The one assistant the caller can read. Throws unless there is exactly one. -export async function findAssistant() { - const json = await query(` - SELECT ?agent WHERE { ?agent ${u(T.type)} ${u(T.softwareAgent)} . } LIMIT 2`); - const rows = bindings(json); - if (rows.length !== 1) { - throw new Error(`expected one prov:SoftwareAgent, found ${rows.length}; seed one or set CHAT_ASSISTANT_URI`); - } - return rows[0].agent.value; -} - // --- writes ------------------------------------------------------------------- // One message, both types, its bijlagen, and the conversation touched, in diff --git a/src/llm/describe.js b/src/llm/describe.js index 4cf661c..37a49f8 100644 --- a/src/llm/describe.js +++ b/src/llm/describe.js @@ -1,32 +1,31 @@ // A profile as a plain text menu: entities, fields, code lists. Code lists up // to INLINE_VALUES_MAX are inlined (and cached), so the LLM never has to // search the short ones. -import { codeList, INLINE_VALUES_MAX } from './lookup.js'; +import { codeList, INLINE_VALUES_MAX } from './explore.js'; export async function describeProfile(profile) { - const lines = []; - lines.push('A spec is Turtle, in exactly this shape (copy these prefixes):'); - lines.push('@prefix rep: .'); - lines.push('@prefix sh: .'); - lines.push('@prefix rdfs: .'); - lines.push('@prefix dct: .'); - lines.push(''); - lines.push('> a rep:ReportSpec , sh:NodeShape ;'); - lines.push(' dct:title "..." ;'); - lines.push(` rep:profile <${profile.uri}> ;`); - lines.push(' sh:targetClass ;'); - lines.push(' sh:property [ ... ] ; # filters, optional'); - lines.push(' rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .'); - lines.push('Every column ends on a value field. Use rep:self as sh:path for the subject URI.'); - lines.push('For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column.'); - lines.push(''); - lines.push('sh:targetClass is the full URI in brackets after the entity below.'); - lines.push('In sh:path, use the predicate URI shown after each field name, not the field name itself.'); - lines.push('An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath ] .'); - lines.push('When two entities share one sh:targetClass, pick one with rep:entity .'); - lines.push('A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime'); - lines.push('(the xsd prefix above is not copied: add @prefix xsd: ).'); - lines.push(''); + const lines = [`A spec is Turtle, in exactly this shape (copy these prefixes): +@prefix rep: . +@prefix sh: . +@prefix rdfs: . +@prefix dct: . + +> a rep:ReportSpec , sh:NodeShape ; + dct:title "..." ; + rep:profile <${profile.uri}> ; + sh:targetClass ; + sh:property [ ... ] ; # filters, optional + rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) . +Every column ends on a value field. Use rep:self as sh:path for the subject URI. +For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column. + +sh:targetClass is the full URI in brackets after the entity below. +In sh:path, use the predicate URI shown after each field name, not the field name itself. +An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath ] . +When two entities share one sh:targetClass, pick one with rep:entity . +A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime +(the xsd prefix above is not copied: add @prefix xsd: ). +`]; for (const shape of profile.shapes) { lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`); for (const f of shape.fields) { diff --git a/src/llm/explore.js b/src/llm/explore.js index 8a128c6..d1290e3 100644 --- a/src/llm/explore.js +++ b/src/llm/explore.js @@ -1,17 +1,24 @@ -// The LLM's exploration of the public data, beyond the code lists. Both -// read under the service scope only (publicQuery, see lookup.js) and count -// nothing: +// Everything the LLM reads from the database in refine mode. Every query +// goes through publicQuery: the service scope, not the caller's session. +// The sparql-parser config grants that scope read access to the public +// graph only, so these reads are the same for every caller. Nothing here +// counts, and execution still runs as the caller. +// - code lists: inlined in describe_profile, searched by lookup_values; // - explore_data: the predicates, values and links of one target class; // - sample_spec: the queries of a draft spec, for a few subjects. // A suggestion, not a check: the report runs as the user and may find other // data. -import { sparqlEscapeUri } from 'mu'; -import { publicQuery } from './lookup.js'; +import { query, sparqlEscapeUri, sparqlEscapeString } from 'mu'; import { startShape } from '../runner/profile.js'; import { seedPageQuery } from '../runner/seed.js'; import { groupColumns, groupQuery, collect } from '../runner/columns.js'; import { assemble } from '../runner/assemble.js'; +// Must match the with-scope grant in the app's config.lisp. +const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report'; +export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50); +const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000; +const LOOKUP_LIMIT = 25; const SAMPLE_SIZE = 10; const MAX_ROWS = 30; const EXAMPLES = 3; @@ -19,6 +26,128 @@ const MAX_VALUE_LENGTH = 60; const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; const XSD = 'http://www.w3.org/2001/XMLSchema#'; +// class → { values, at }. Refreshed lazily when stale. +const cache = new Map(); + +function publicQuery(q) { + return query(q, { scope: SERVICE_SCOPE }); +} + +// codeList(type) → up to INLINE_VALUES_MAX [{ uri, label }], cached for +// VALUES_TTL. Empty when the read fails; the LLM then uses rep:anyOf with +// words. +export async function codeList(type) { + const hit = cache.get(type); + if (hit && Date.now() - hit.at < VALUES_TTL) return hit.values; + let values = []; + try { + const r = await publicQuery(`SELECT ?uri ?label WHERE { + ?uri a <${type}> . + OPTIONAL { ?uri ?label . } + } ORDER BY ?label LIMIT ${INLINE_VALUES_MAX + 1}`); + values = r.results.bindings.slice(0, INLINE_VALUES_MAX).map(b => ({ + uri: b.uri.value, + label: b.label?.value || b.uri.value, + })); + } catch (e) { + values = []; + } + cache.set(type, { values, at: Date.now() }); + return values; +} + +// lookupValues(profile, field, term) → the matches as text for the LLM: up +// to LOOKUP_LIMIT labels and URIs, an exact label (case aside) first. field is +// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to +// search. +export async function lookupValues(profile, field, term) { + const type = resolveClass(profile, field); + if (!type) return `field "${field}" not found in profile "${profile.title}"`; + + // Search within the class first. A common word ("gemeente") also sits in + // hundreds of unrelated labels; an unscoped scan drowns the code. + let rows = await labelQuery(term, type); + // Code lists sometimes type their entries differently than the profile + // says (MAR codes are ext:supervision/Nomenclature, the profile says + // skos:Concept). Fall back to a scan over everything. + if (!rows.length) rows = await labelQuery(term, null); + // "Gent" must surface before "AGB Erfgoed Gent". + const lower = term.toLowerCase(); + const exactRow = rows.find(r => r.label.toLowerCase() === lower); + let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LOOKUP_LIMIT); + if (!matches.length) matches = await codeQuery(term); + if (!matches.length) return `no value matches "${term}" in ${field}.`; + + const lines = matches.map(m => `${m.label} <${m.uri}>`); + if (matches.length >= LOOKUP_LIMIT) lines.push(`(${LOOKUP_LIMIT}+ matches, narrow the term)`); + const exact = matches.find(m => m.label.toLowerCase() === lower); + if (exact) lines.push(`exact: <${exact.uri}>`); + return lines.join('\n'); +} + +// Regex on str(?label), so language-tagged labels match. +async function labelQuery(term, type) { + const q = `SELECT DISTINCT ?uri ?label WHERE { + ?uri a ${type ? `<${type}>` : '?anyType'} ; + ?label . + FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i")) + } ORDER BY ?label LIMIT ${LOOKUP_LIMIT * 8}`; + const r = await publicQuery(q); + return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value })); +} + +// MAR codes: the label says "MAR7300 - …" but the searchable code sits on +// ext:supervision/nomenclatureCode. One extra query, only when the label +// search came up empty. +async function codeQuery(term) { + const q = `SELECT ?uri ?label WHERE { + ?uri ?code ; + ?label . + FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)}))) + } ORDER BY ?label LIMIT ${LOOKUP_LIMIT}`; + try { + const r = await publicQuery(q); + return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value })); + } catch { + return []; + } +} + +// "shapeLabel.fieldLabel" → the class to search: the field's sh:class, or +// for a link (sh:node) the linked shape's sh:targetClass +// ("melding.bestuurseenheid" → besluit:Bestuurseenheid). +function resolveClass(profile, field) { + if (field.includes('/')) return null; + const dot = field.lastIndexOf('.'); + if (dot < 0) return null; + const shapeLabel = field.slice(0, dot); + const fieldLabel = field.slice(dot + 1); + const shape = profile.shapes.find(s => s.label === shapeLabel); + if (!shape) return null; + const f = shape.fields.find(x => x.name === fieldLabel); + if (f?.class) return f.class; + if (f?.node) { + const linked = profile.shapes.find(s => s.uri === f.node); + return linked?.targetClass || null; + } + // The model sometimes writes shape.field on a linked shape + // ("bestuurseenheid.naam"); resolve it to that shape's target class. + for (const s of profile.shapes) { + if (s.label === shapeLabel || shortType(s.targetClass) === shapeLabel) { + return s.targetClass; + } + } + return null; +} + +function shortType(cls) { + return cls.split('#').pop() || cls.split('/').pop(); +} + +function escapeRegex(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // exploreClass(profile, cls) → text for the LLM. cls must be a // sh:targetClass of the profile. export async function exploreClass(profile, cls) { @@ -47,14 +176,12 @@ export async function exploreClass(profile, cls) { OPTIONAL { ?x a ?type . } }`); - const lines = [ - `A sample of ${subjects.length} <${cls}> from the public data. It shows what the data looks like;`, - 'it is no check of a spec, and the report may run on other data.', - 'Out = the instance points to it, In = something points to the instance.', - '→ / ← give the class of the linked node: explore it to follow the path.', - 'A predicate "not in the profile" cannot be used in a spec.', - '', - ]; + const lines = [`A sample of ${subjects.length} <${cls}> from the public data. It shows what the data looks like; +it is no check of a spec, and the report may run on other data. +Out = the instance points to it, In = something points to the instance. +→ / ← give the class of the linked node: explore it to follow the path. +A predicate "not in the profile" cannot be used in a spec. +`]; const out = new Map(); for (const b of outgoing.results.bindings) { @@ -99,14 +226,12 @@ export async function sampleSpec(spec, profile) { } const [header, ...rows] = assemble(subjects, values, spec); - return [ - `A sample from the public data: rows for up to ${SAMPLE_SIZE} subjects the spec finds there.`, - 'The report runs as the user and may find other rows. Use it to see whether paths and filters', - 'land where you expect, not to tell the user what the report will hold.', - '', - header.join(' | '), - ...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | ')), - ].join('\n'); + const table = [header.join(' | '), ...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | '))]; + return `A sample from the public data: rows for up to ${SAMPLE_SIZE} subjects the spec finds there. +The report runs as the user and may find other rows. Use it to see whether paths and filters +land where you expect, not to tell the user what the report will hold. + +${table.join('\n')}`; } function showCell(cell) { diff --git a/src/llm/lookup.js b/src/llm/lookup.js deleted file mode 100644 index afe4100..0000000 --- a/src/llm/lookup.js +++ /dev/null @@ -1,138 +0,0 @@ -// The LLM's own reads: code lists here, the data samples in explore.js. -// They run under the service scope, not the caller's session; the -// sparql-parser config grants that scope read access to the public graph -// only, so they read the same for every caller. Execution still runs as the -// caller. -// -// lookup_values is a suggest helper, not a spec check. It reads candidate -// values the LLM can put in a filter; it never runs the spec and never sees -// report rows. -import { query, sparqlEscapeString } from 'mu'; - -// Must match the with-scope grant in the app's config.lisp. -const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report'; -export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50); -const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000; -const LIMIT = 25; - -// class → { values, at }. Refreshed lazily when stale. -const cache = new Map(); - -export function publicQuery(q) { - return query(q, { scope: SERVICE_SCOPE }); -} - -// codeList(type) → up to INLINE_VALUES_MAX [{ uri, label }], cached for -// VALUES_TTL. Empty when the read fails; the LLM then uses rep:anyOf with -// words. -export async function codeList(type) { - const hit = cache.get(type); - if (hit && Date.now() - hit.at < VALUES_TTL) return hit.values; - let values = []; - try { - const r = await publicQuery(`SELECT ?uri ?label WHERE { - ?uri a <${type}> . - OPTIONAL { ?uri ?label . } - } ORDER BY ?label LIMIT ${INLINE_VALUES_MAX + 1}`); - values = r.results.bindings.slice(0, INLINE_VALUES_MAX).map(b => ({ - uri: b.uri.value, - label: b.label?.value || b.uri.value, - })); - } catch (e) { - values = []; - } - cache.set(type, { values, at: Date.now() }); - return values; -} - -// lookupValues(profile, field, term) → the matches as text for the LLM: up -// to LIMIT labels and URIs, an exact label (case aside) first. field is -// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to -// search. -export async function lookupValues(profile, field, term) { - const type = resolveClass(profile, field); - if (!type) return `field "${field}" not found in profile "${profile.title}"`; - - // Search within the class first. A common word ("gemeente") also sits in - // hundreds of unrelated labels; an unscoped scan drowns the code. - let rows = await labelQuery(term, type); - // Code lists sometimes type their entries differently than the profile - // says (MAR codes are ext:supervision/Nomenclature, the profile says - // skos:Concept). Fall back to a scan over everything. - if (!rows.length) rows = await labelQuery(term, null); - // "Gent" must surface before "AGB Erfgoed Gent". - const lower = term.toLowerCase(); - const exactRow = rows.find(r => r.label.toLowerCase() === lower); - let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LIMIT); - if (!matches.length) matches = await codeQuery(term); - if (!matches.length) return `no value matches "${term}" in ${field}.`; - - const lines = matches.map(m => `${m.label} <${m.uri}>`); - if (matches.length >= LIMIT) lines.push(`(${LIMIT}+ matches, narrow the term)`); - const exact = matches.find(m => m.label.toLowerCase() === lower); - if (exact) lines.push(`exact: <${exact.uri}>`); - return lines.join('\n'); -} - -// Regex on str(?label), so language-tagged labels match. -async function labelQuery(term, type) { - const q = `SELECT DISTINCT ?uri ?label WHERE { - ?uri a ${type ? `<${type}>` : '?anyType'} ; - ?label . - FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i")) - } ORDER BY ?label LIMIT ${LIMIT * 8}`; - const r = await publicQuery(q); - return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value })); -} - -// MAR codes: the label says "MAR7300 - …" but the searchable code sits on -// ext:supervision/nomenclatureCode. One extra query, only when the label -// search came up empty. -async function codeQuery(term) { - const q = `SELECT ?uri ?label WHERE { - ?uri ?code ; - ?label . - FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)}))) - } ORDER BY ?label LIMIT ${LIMIT}`; - try { - const r = await publicQuery(q); - return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value })); - } catch { - return []; - } -} - -// "shapeLabel.fieldLabel" → the class to search: the field's sh:class, or -// for a link (sh:node) the linked shape's sh:targetClass -// ("melding.bestuurseenheid" → besluit:Bestuurseenheid). -function resolveClass(profile, field) { - if (field.includes('/')) return null; - const dot = field.lastIndexOf('.'); - if (dot < 0) return null; - const shapeLabel = field.slice(0, dot); - const fieldLabel = field.slice(dot + 1); - const shape = profile.shapes.find(s => s.label === shapeLabel); - if (!shape) return null; - const f = shape.fields.find(x => x.name === fieldLabel); - if (f?.class) return f.class; - if (f?.node) { - const linked = profile.shapes.find(s => s.uri === f.node); - return linked?.targetClass || null; - } - // The model sometimes writes shape.field on a linked shape - // ("bestuurseenheid.naam"); resolve it to that shape's target class. - for (const s of profile.shapes) { - if (s.label === shapeLabel || shortType(s.targetClass) === shapeLabel) { - return s.targetClass; - } - } - return null; -} - -function shortType(cls) { - return cls.split('#').pop() || cls.split('/').pop(); -} - -function escapeRegex(s) { - return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/src/llm/tools.js b/src/llm/tools.js index 0ce4ac5..22b0e92 100644 --- a/src/llm/tools.js +++ b/src/llm/tools.js @@ -5,8 +5,7 @@ import { parseSpec } from '../runner/spec.js'; import { checkSpec, whereNotes, profileError } from '../runner/check.js'; import { describeProfile } from './describe.js'; -import { lookupValues } from './lookup.js'; -import { exploreClass, sampleSpec } from './explore.js'; +import { lookupValues, exploreClass, sampleSpec } from './explore.js'; import { readSpecFile } from '../chat.js'; // The tool definitions sent to the model. The descriptions are what the LLM