Files
natural-language-report-ser…/src/llm/lookup.js
2026-09-27 00:44:35 +02:00

139 lines
5.6 KiB
JavaScript

// 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, '\\$&');
}