Further cleaning
This commit is contained in:
@@ -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
15
app.js
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/chat.js
12
src/chat.js
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, '\\$&');
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user