clean up bug in runner and disabled two tools

This commit is contained in:
felix
2026-09-27 02:05:05 +02:00
parent 80097d3130
commit 78a7c42a37
5 changed files with 136 additions and 77 deletions

View File

@@ -61,7 +61,8 @@ values from the cell, never rows. The app's `report-spec-readme.md` has the
details and an example.
The run: check → seed (`SELECT DISTINCT ?s`, paged) → columns (per group, per
chunk of subjects) → assemble (dedup + `rep:collect`) → CSV → register file
chunk of subjects, with the nodes on each path) → assemble (pair by the
nodes columns share, on every step; `rep:collect`) → CSV → register file
+ report resource. One run per confirmed spec.
## One identity: mu-authorization's

View File

@@ -25,6 +25,18 @@ const LOG_LLM = /^(true|1)$/i.test(process.env.LOG_LLM || '');
// all on the public graph under the service scope, but it may not run a
// report and never learns how many subjects the report would match. It ends with a proposal in
// Dutch and asks the user to confirm.
// explore_data and sample_spec are switched off for now (see tools.js). To
// switch them back on, replace "That is the only database read ... as a
// check." in REFINE_PROMPT with:
// Use explore_data to see what the data of a target class holds: its
// predicates, example values and links; call it on a linked class to follow
// a path. A predicate that is not in the profile cannot go in a spec. Use
// sample_spec on a spec that validates to see a few of its rows, and fix
// paths or filters that do not land where you expect. All three read only
// public data. The report runs as the user, on data you may not see, so a
// lookup or a sample is a hint, never a count and never a promise: do not
// tell the user how many rows the report will have, or that it will be
// empty.
const REFINE_PROMPT = `You refine report specs with the user. You never write SPARQL and you never run a report.
The profile lists what can be asked. Compose paths by chaining fields.
@@ -56,15 +68,10 @@ Rules:
You are in refinement mode. You propose a spec and explain in Dutch what it
will list and filter. Use lookup_values to find concrete values (names, codes)
for a filter, so your proposal names real values instead of guesses. Use
explore_data to see what the data of a target class holds: its predicates,
example values and links; call it on a linked class to follow a path. A
predicate that is not in the profile cannot go in a spec. Use sample_spec on
a spec that validates to see a few of its rows, and fix paths or filters that
do not land where you expect. All three read only public data. The report
runs as the user, on data you may not see, so a lookup or a sample is a hint,
never a count and never a promise: do not tell the user how many rows the
report will have, or that it will be empty.
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. When the
user wants an earlier spec changed, open it with read_spec and change what
they asked for. Never call

View File

@@ -30,15 +30,17 @@ export const TOOLS = [
field: 'The field as "entityLabel.fieldLabel", e.g. "bestuurseenheid.naam"',
term: 'The Dutch word or name to search for',
}),
tool('explore_data',
'Show a sample of the public data of one sh:targetClass of the profile: the predicates its instances use (outgoing and incoming), a few example values, the class of linked nodes, and which profile field each predicate is. Call it again on a linked class to follow a path. A predicate not in the profile cannot be used in a spec. A sample, not a check: it never runs a spec and counts nothing.',
{
profile_id: 'The profile URI returned by list_profiles',
class: 'The sh:targetClass URI, as describe_profile shows it',
}),
tool('sample_spec',
'Run a spec that passes validate_spec on the public data and return the rows of up to 10 subjects it finds there. Use it to see whether its paths and filters land where you expect. Public data only: the report runs as the user and may find other rows, so an empty or short sample says nothing about the report. It does not attach the spec; validate_spec does.',
{ spec: 'The spec as Turtle text' }),
// Switched off for now: too much context for the LLM. To switch back on,
// uncomment these two, their cases in runTool, and the text in loop.js.
// tool('explore_data',
// 'Show a sample of the public data of one sh:targetClass of the profile: the predicates its instances use (outgoing and incoming), a few example values, the class of linked nodes, and which profile field each predicate is. Call it again on a linked class to follow a path. A predicate not in the profile cannot be used in a spec. A sample, not a check: it never runs a spec and counts nothing.',
// {
// profile_id: 'The profile URI returned by list_profiles',
// class: 'The sh:targetClass URI, as describe_profile shows it',
// }),
// tool('sample_spec',
// 'Run a spec that passes validate_spec on the public data and return the rows of up to 10 subjects it finds there. Use it to see whether its paths and filters land where you expect. Public data only: the report runs as the user and may find other rows, so an empty or short sample says nothing about the report. It does not attach the spec; validate_spec does.',
// { spec: 'The spec as Turtle text' }),
];
function tool(name, description, params) {
@@ -63,8 +65,8 @@ export async function runTool(name, args, profiles) {
case 'validate_spec': return validateSpec(args, profiles);
case 'read_spec': return readSpec(args);
case 'lookup_values': return lookup(args, profiles);
case 'explore_data': return explore(args, profiles);
case 'sample_spec': return sample(args, profiles);
// case 'explore_data': return explore(args, profiles);
// case 'sample_spec': return sample(args, profiles);
default: return `unknown tool "${name}"`;
}
}

View File

@@ -1,13 +1,18 @@
// Values to rows. Subjects in stable order, dedup on term, collect per cell.
// rep:self columns take the subject URI; a column with no values is an
// empty cell, never a dropped row. A column without rep:collect joins its
// values only when there is one; with several, each value gets its own row
// (the other columns repeat theirs). rep:row forces that expansion; at most
// one column per spec may do that explicitly (checked in check.js).
import { sharedSteps } from './spec.js';
// Values to rows, paired by URI on every step. Two columns that walk the
// same steps (with the same rep:where on them) share the nodes on those
// steps, the way a rep:where shares nodes with its column. Per subject the
// paths of the columns make a tree of the nodes found; every row is one way
// down that tree. Columns through the same node stay on one row; branches
// under one node multiply. A column with no value is an empty cell, never a
// dropped row. sh:groupConcat, sh:min and sh:max fold a column into one cell
// per node it shares with the other columns. rep:self takes the subject
// URI. Subjects in stable order; identical rows appear once.
const SH = 'http://www.w3.org/ns/shacl#';
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
const XSD = 'http://www.w3.org/2001/XMLSchema#';
const FOLDING = [SH + 'groupConcat', SH + 'min', SH + 'max'];
export const NUMERIC_DATATYPES = new Set([
'integer', 'decimal', 'double', 'float', 'long', 'int', 'short', 'byte',
@@ -15,45 +20,98 @@ export const NUMERIC_DATATYPES = new Set([
'unsignedLong', 'unsignedInt', 'unsignedShort', 'unsignedByte',
].map(d => XSD + d));
// assemble(subjects, values, spec) → rows, the header first. values:
// subject → column index → chains, one term per step (see columns.js).
export function assemble(subjects, values, spec) {
const sorted = [...subjects].sort();
const tree = columnTree(spec.columns);
const rows = [spec.columns.map(c => c.label)];
for (const subject of sorted) {
const perColumn = values.get(subject) || new Map();
const cells = spec.columns.map((col, i) => {
if (!col.path.length) return [subject]; // rep:self
const list = dedup(perColumn.get(i) || []);
return collect(list, col);
});
const lines = Math.max(1, ...cells.map(c => c.length));
for (let r = 0; r < lines; r++) {
// the expanding cell cycles its values, every other cell keeps its
// value on each line
rows.push(cells.map(c => c[Math.min(r, c.length - 1)]));
for (const subject of [...subjects].sort()) {
const top = { term: { value: subject }, below: new Map() };
for (const [index, chains] of values.get(subject) || []) {
for (const chain of chains) addChain(top, tree.paths[index], chain);
}
const seen = new Set();
for (const cells of rowsAt(tree.root, top, tree, spec.columns)) {
const row = spec.columns.map((col, i) => cells.get(i) ?? '');
const key = JSON.stringify(row);
if (!seen.has(key)) {
seen.add(key);
rows.push(row);
}
}
}
return rows;
}
function collect(terms, col) {
switch (col.collect) {
case SH + 'groupConcat':
return [terms.map(t => t.value).join(col.separator ?? ',')];
case undefined:
case null:
// no rep:collect: one value is a normal cell, several values each
// get their own row instead of a joined string
if (terms.length <= 1) return [terms.map(t => t.value).join(',')];
return terms.map(t => t.value);
case SH + 'min':
return [extreme(terms, col, -1)];
case SH + 'max':
return [extreme(terms, col, 1)];
case REP + 'row':
return terms.length ? terms.map(t => t.value) : [''];
default:
throw new Error(`column "${col.label}" has an unknown rep:collect <${col.collect}>. Use sh:groupConcat, rep:row, sh:min or sh:max.`);
// The steps of all columns as one tree of branches. Two columns share a
// branch as long as they took the same steps with the same rep:where on
// them. paths[i] lists the branches column i goes through; a branch knows
// the columns that end on it and the columns that go through it.
function columnTree(columns) {
const newBranch = () => ({ next: new Map(), ends: [], columns: [] });
const root = newBranch();
const paths = columns.map((col, index) => {
let branch = root;
const branches = col.path.map((hop, k) => {
const where = col.where.filter(w => sharedSteps(col.path, w.path) === k + 1);
const key = `${hop.inverse ? '^' : ''}${hop.predicate} ${JSON.stringify(where)}`;
if (!branch.next.has(key)) branch.next.set(key, newBranch());
branch = branch.next.get(key);
branch.columns.push(index);
return branch;
});
branch.ends.push(index);
return branches;
});
return { root, paths };
}
// One path the store returned, subject to value, into the tree of nodes
// found for that subject. Nodes are keyed by value: a node reached twice is
// one node, a value found twice under one node is one value.
function addChain(found, branches, chain) {
branches.forEach((branch, k) => {
if (!found.below.has(branch)) found.below.set(branch, new Map());
const byValue = found.below.get(branch);
if (!byValue.has(chain[k].value)) byValue.set(chain[k].value, { term: chain[k], below: new Map() });
found = byValue.get(chain[k].value);
});
}
// The rows under one found node: its own cells, times the rows of each
// branch below it. A branch where nothing was found adds empty cells.
function rowsAt(branch, found, tree, columns) {
let rows = [new Map(branch.ends.map(i => [i, found.term.value]))];
for (const next of branch.next.values()) {
const nodes = [...(found.below.get(next)?.values() || [])];
const below = next.columns.every(i => FOLDING.includes(columns[i].collect))
? [fold(next, nodes, tree, columns)]
: nodes.length ? nodes.flatMap(node => rowsAt(next, node, tree, columns)) : [new Map()];
rows = rows.flatMap(row => below.map(cells => new Map([...row, ...cells])));
}
return rows;
}
// A branch where every column folds gives one set of cells: per column, all
// its values in the branch, folded into one cell.
function fold(branch, nodes, tree, columns) {
const cells = new Map();
for (const i of branch.columns) {
const rest = tree.paths[i].slice(tree.paths[i].indexOf(branch) + 1);
const terms = nodes.flatMap(node => termsBelow(node, rest));
const unique = [...new Map(terms.map(t => [t.value, t])).values()];
const col = columns[i];
cells.set(i, col.collect === SH + 'groupConcat'
? unique.map(t => t.value).join(col.separator ?? ',')
: extreme(unique, col, col.collect === SH + 'min' ? -1 : 1));
}
return cells;
}
function termsBelow(node, rest) {
if (!rest.length) return [node.term];
const byValue = node.below.get(rest[0]);
return byValue ? [...byValue.values()].flatMap(next => termsBelow(next, rest.slice(1))) : [];
}
// min/max. Numbers compare as numbers. Dates and times compare as text:
@@ -72,17 +130,3 @@ function extreme(terms, col, direction) {
const best = terms.reduce((a, b) => (cmp(key(b), key(a)) * direction > 0 ? b : a));
return best.value;
}
// Two terms are the same when the URIs match, or lexical form, datatype
// and language tag all match.
export function dedup(terms) {
const seen = new Set();
const out = [];
for (const t of terms) {
const key = t.termType === 'NamedNode'
? `u:${t.value}`
: `l:${t.value}|${t.datatype?.value || ''}|${t.language || ''}`;
if (!seen.has(key)) { seen.add(key); out.push(t); }
}
return out;
}

View File

@@ -4,11 +4,14 @@ import { hopsPattern, wherePatterns } from './seed.js';
// Fetch column values for known subjects. Columns are grouped by their
// shared non-leaf hops; two columns share a query only when every hop
// before the leaf is identical, and the leaf is read the same way.
// rep:self columns (empty path) are filled by assemble.js.
// rep:self columns (empty path) are filled by assemble.js. Every query
// returns the nodes on the path too, so assemble.js can pair columns by the
// nodes they share.
const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100);
// fetchColumns(subjects, spec) → subject → column index → [term]
// fetchColumns(subjects, spec) → subject → column index → [chain], a chain
// being the terms on the path, one per step, the value last.
export async function fetchColumns(subjects, spec) {
const values = new Map();
const groups = groupColumns(spec.columns);
@@ -49,17 +52,19 @@ export function groupQuery(group, chunk) {
// the end.
const nodeVar = (k) => (k === group.hops.length ? '?v' : `?x_${k}`);
lines.push(...wherePatterns(group.where, group.hops, nodeVar, 'c'));
return `SELECT ?s ?v WHERE {\n VALUES ?s { ${subjects} }\n ${lines.join('\n ')}\n}`;
const vars = ['?s', ...group.hops.slice(1).map((hop, i) => `?x_${i + 1}`), '?v'];
return `SELECT DISTINCT ${vars.join(' ')} WHERE {\n VALUES ?s { ${subjects} }\n ${lines.join('\n ')}\n}`;
}
export function collect(result, group, values) {
for (const b of result.results.bindings) {
const chain = [...group.hops.slice(1).map((hop, i) => b[`x_${i + 1}`]), b.v];
let perColumn = values.get(b.s.value);
if (!perColumn) { perColumn = new Map(); values.set(b.s.value, perColumn); }
for (const col of group.columns) {
let list = perColumn.get(col.index);
if (!list) { list = []; perColumn.set(col.index, list); }
list.push(b.v);
list.push(chain);
}
}
}