Further cleaning

This commit is contained in:
felix
2026-09-27 00:57:17 +02:00
parent 57292fa052
commit 80097d3130
7 changed files with 180 additions and 206 deletions

View File

@@ -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, The LLM's own reads during refinement (`describe_profile`'s code lists,
`lookup_values`, `explore_data` and `sample_spec`) run under a service `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 `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 that scope read access to `http://mu.semte.ch/graphs/public` only. So
refinement reads only the public graph, regardless of who calls. Execution 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 | | `SEED_PAGE_SIZE` | `5000` | seed page size |
| `SUBJECT_CHUNK_SIZE` | `100` | see above | | `SUBJECT_CHUNK_SIZE` | `100` | see above |
| `SHARE_DIR` | `/share` | where CSVs are written | | `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 | | `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. | | `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. |

15
app.js
View File

@@ -1,12 +1,14 @@
import { app, errorHandler } from 'mu'; import { app, errorHandler } from 'mu';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { loadProfiles } from './src/runner/profile.js'; 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 { wantsExecution } from './src/llm/mode.js';
import { executeSpec, refineSpec } from './src/report-assistant.js'; import { executeSpec, refineSpec } from './src/report-assistant.js';
const PROFILE_DIR = process.env.PROFILE_DIR || '/config/profiles'; 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 TITLE_LENGTH = 80;
const FAILED = 'Er ging iets mis. Probeer het opnieuw.'; 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(); const content = String(req.body?.content ?? '').trim();
if (!content) return res.status(400).json({ error: 'content is required' }); if (!content) return res.status(400).json({ error: 'content is required' });
let conversation, assistant, history, question; let conversation, history, question;
try { try {
// the access check: the caller's session decides what is readable // the access check: the caller's session decides what is readable
conversation = await readConversation(req.params.id); conversation = await readConversation(req.params.id);
if (!conversation) return res.status(404).json({ error: 'no such conversation' }); if (!conversation) return res.status(404).json({ error: 'no such conversation' });
assistant = CHAT_ASSISTANT_URI || await findAssistant(); history = await readHistory(conversation.uri, CHAT_ASSISTANT_URI);
history = await readHistory(conversation.uri, assistant);
question = await writeMessage({ question = await writeMessage({
conversationUri: conversation.uri, conversationUri: conversation.uri,
content, content,
@@ -50,13 +51,13 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function
await writeMessage({ await writeMessage({
conversationUri: conversation.uri, conversationUri: conversation.uri,
content: answer.text, content: answer.text,
maker: assistant, maker: CHAT_ASSISTANT_URI,
attachments: answer.attachments, attachments: answer.attachments,
}); });
} catch (e) { } catch (e) {
console.error('[chat] turn failed:', e); console.error('[chat] turn failed:', e);
try { try {
await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: assistant }); await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: CHAT_ASSISTANT_URI });
} catch (e2) { } catch (e2) {
console.error('[chat] could not write the failure message:', e2); console.error('[chat] could not write the failure message:', e2);
} }

View File

@@ -25,7 +25,6 @@ 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',
softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent',
file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject', file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject',
fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName', fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName',
fileSize: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize', 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); 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 ------------------------------------------------------------------- // --- writes -------------------------------------------------------------------
// One message, both types, its bijlagen, and the conversation touched, in // One message, both types, its bijlagen, and the conversation touched, in

View File

@@ -1,32 +1,31 @@
// A profile as a plain text menu: entities, fields, code lists. Code lists up // 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 // to INLINE_VALUES_MAX are inlined (and cached), so the LLM never has to
// search the short ones. // 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) { export async function describeProfile(profile) {
const lines = []; const lines = [`A spec is Turtle, in exactly this shape (copy these prefixes):
lines.push('A spec is Turtle, in exactly this shape (copy these prefixes):'); @prefix rep: <http://mu.semte.ch/vocabularies/reporting/> .
lines.push('@prefix rep: <http://mu.semte.ch/vocabularies/reporting/> .'); @prefix sh: <http://www.w3.org/ns/shacl#> .
lines.push('@prefix sh: <http://www.w3.org/ns/shacl#> .'); @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
lines.push('@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .'); @prefix dct: <http://purl.org/dc/terms/> .
lines.push('@prefix dct: <http://purl.org/dc/terms/> .');
lines.push(''); <http://data.lblod.info/id/report-specs/<your-id>> a rep:ReportSpec , sh:NodeShape ;
lines.push('<http://data.lblod.info/id/report-specs/<your-id>> a rep:ReportSpec , sh:NodeShape ;'); dct:title "..." ;
lines.push(' dct:title "..." ;'); rep:profile <${profile.uri}> ;
lines.push(` rep:profile <${profile.uri}> ;`); sh:targetClass <the rdf:type from the list below> ;
lines.push(' sh:targetClass <the rdf:type from the list below> ;'); sh:property [ ... ] ; # filters, optional
lines.push(' sh:property [ ... ] ; # filters, optional'); rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .
lines.push(' rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .'); Every column ends on a value field. Use rep:self as sh:path for the subject URI.
lines.push('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.
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(''); sh:targetClass is the full URI in brackets after the entity below.
lines.push('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.
lines.push('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 <predicate> ] .
lines.push('An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath <predicate> ] .'); When two entities share one sh:targetClass, pick one with rep:entity <the URI after the entity>.
lines.push('When two entities share one sh:targetClass, pick one with rep:entity <the URI after the entity>.'); A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime
lines.push('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: <http://www.w3.org/2001/XMLSchema#>).
lines.push('(the xsd prefix above is not copied: add @prefix xsd: <http://www.w3.org/2001/XMLSchema#>).'); `];
lines.push('');
for (const shape of profile.shapes) { for (const shape of profile.shapes) {
lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`); lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`);
for (const f of shape.fields) { for (const f of shape.fields) {

View File

@@ -1,17 +1,24 @@
// The LLM's exploration of the public data, beyond the code lists. Both // Everything the LLM reads from the database in refine mode. Every query
// read under the service scope only (publicQuery, see lookup.js) and count // goes through publicQuery: the service scope, not the caller's session.
// nothing: // 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; // - explore_data: the predicates, values and links of one target class;
// - sample_spec: the queries of a draft spec, for a few subjects. // - 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 // A suggestion, not a check: the report runs as the user and may find other
// data. // data.
import { sparqlEscapeUri } from 'mu'; import { query, sparqlEscapeUri, sparqlEscapeString } from 'mu';
import { publicQuery } from './lookup.js';
import { startShape } from '../runner/profile.js'; import { startShape } from '../runner/profile.js';
import { seedPageQuery } from '../runner/seed.js'; import { seedPageQuery } from '../runner/seed.js';
import { groupColumns, groupQuery, collect } from '../runner/columns.js'; import { groupColumns, groupQuery, collect } from '../runner/columns.js';
import { assemble } from '../runner/assemble.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 SAMPLE_SIZE = 10;
const MAX_ROWS = 30; const MAX_ROWS = 30;
const EXAMPLES = 3; 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 RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
const XSD = 'http://www.w3.org/2001/XMLSchema#'; 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 <http://www.w3.org/2004/02/skos/core#prefLabel> ?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'} ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?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 <http://mu.semte.ch/vocabularies/ext/supervision/nomenclatureCode> ?code ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?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 // exploreClass(profile, cls) → text for the LLM. cls must be a
// sh:targetClass of the profile. // sh:targetClass of the profile.
export async function exploreClass(profile, cls) { export async function exploreClass(profile, cls) {
@@ -47,14 +176,12 @@ export async function exploreClass(profile, cls) {
OPTIONAL { ?x a ?type . } OPTIONAL { ?x a ?type . }
}`); }`);
const lines = [ const lines = [`A sample of ${subjects.length} <${cls}> from the public data. It shows what the data looks like;
`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.
'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.
'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.
'→ / ← 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.
'A predicate "not in the profile" cannot be used in a spec.', `];
'',
];
const out = new Map(); const out = new Map();
for (const b of outgoing.results.bindings) { for (const b of outgoing.results.bindings) {
@@ -99,14 +226,12 @@ export async function sampleSpec(spec, profile) {
} }
const [header, ...rows] = assemble(subjects, values, spec); const [header, ...rows] = assemble(subjects, values, spec);
return [ const table = [header.join(' | '), ...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | '))];
`A sample from the public data: rows for up to ${SAMPLE_SIZE} subjects the spec finds there.`, 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', 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.', land where you expect, not to tell the user what the report will hold.
'',
header.join(' | '), ${table.join('\n')}`;
...rows.slice(0, MAX_ROWS).map(row => row.map(showCell).join(' | ')),
].join('\n');
} }
function showCell(cell) { function showCell(cell) {

View File

@@ -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 <http://www.w3.org/2004/02/skos/core#prefLabel> ?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'} ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?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 <http://mu.semte.ch/vocabularies/ext/supervision/nomenclatureCode> ?code ;
<http://www.w3.org/2004/02/skos/core#prefLabel> ?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, '\\$&');
}

View File

@@ -5,8 +5,7 @@
import { parseSpec } from '../runner/spec.js'; import { parseSpec } from '../runner/spec.js';
import { checkSpec, whereNotes, profileError } from '../runner/check.js'; import { checkSpec, whereNotes, profileError } from '../runner/check.js';
import { describeProfile } from './describe.js'; import { describeProfile } from './describe.js';
import { lookupValues } from './lookup.js'; import { lookupValues, exploreClass, sampleSpec } from './explore.js';
import { exploreClass, sampleSpec } from './explore.js';
import { readSpecFile } from '../chat.js'; import { readSpecFile } from '../chat.js';
// The tool definitions sent to the model. The descriptions are what the LLM // The tool definitions sent to the model. The descriptions are what the LLM