This commit is contained in:
felix
2026-09-14 16:34:17 +02:00
parent 789a676425
commit f128255226
27 changed files with 108 additions and 588 deletions

View File

@@ -7,17 +7,16 @@ const XSD = 'http://www.w3.org/2001/XMLSchema#';
const XSD_DATE = XSD + 'date';
const XSD_DATETIME = XSD + 'dateTime';
// The validator. checkSpec(spec, profile, maxPathDepth) runs the named checks
// in order and returns the FIRST error as a one-element array (empty if ok).
// Every message names the thing that is wrong in the profile's own words and
// says what to write instead. No database call anywhere in this file — that is
// what lets an LLM iterate for free.
// The validator. checkSpec runs the checks in order and returns the first
// error as a one-element array (empty if ok). Every message names the thing
// that is wrong in the profile's own words and says what to write instead.
// No database call anywhere in this file.
const ANYOF_MAX_TERMS = 24;
const ANYOF_MAX_TERM_LENGTH = 100;
// 1 + 2. the profile names itself and is one we have. knownProfiles is the
// Map app.js loaded at boot; omit it and the check is skipped (fixtures).
// The spec names a profile we have. knownProfiles is the profile Map loaded
// at boot; the check is skipped when it is omitted.
function profileNamed(spec, knownProfiles) {
if (!spec.profileUri) {
return `no rep:profile named. Available: ${knownProfiles ? [...knownProfiles.values()].map(p => p.title).join(', ') : '(none)'} .`;
@@ -28,7 +27,7 @@ function profileNamed(spec, knownProfiles) {
return null;
}
// 3 + 4. the target class is in the profile
// The target class is in the profile
function targetClass(spec, profile) {
if (!spec.targetClass) {
return `no sh:targetClass. In "${profile.title}" you can list: ${shapeList(profile)}.`;
@@ -43,13 +42,13 @@ function shapeList(profile) {
return profile.shapes.map(s => s.label || s.targetClass).join(', ');
}
// 5. columns exist
// Columns exist
function columnsPresent(spec) {
if (!spec.columns.length) return 'no columns. Add at least one rep:columns entry.';
return null;
}
// 6. every column has a label
// Every column has a label
function columnLabel(spec) {
for (const col of spec.columns) {
if (!col.label) return 'a column has no rdfs:label.';
@@ -57,7 +56,7 @@ function columnLabel(spec) {
return null;
}
// 7. labels unique
// Labels are unique
function columnLabelsUnique(spec) {
const seen = new Map();
for (const col of spec.columns) {
@@ -69,7 +68,7 @@ function columnLabelsUnique(spec) {
return null;
}
// 8 + 9 + 10. every path resolves, columns end on a value, depth capped
// Every path resolves, columns end on a value, depth capped
function pathsResolve(spec, profile, maxPathDepth) {
const startShapeUri = entityForClass(profile, spec.targetClass);
for (const col of spec.columns) {
@@ -85,7 +84,7 @@ function pathsResolve(spec, profile, maxPathDepth) {
return null;
}
// 11. sh:min/sh:max only on numbers and dates
// sh:min/sh:max only on numbers and dates
function collectMinMaxTyped(spec, profile) {
const startShapeUri = entityForClass(profile, spec.targetClass);
for (const col of spec.columns) {
@@ -108,7 +107,7 @@ function collectMinMaxTyped(spec, profile) {
return null;
}
// 13. at most one rep:row column
// At most one rep:row column
function oneRowColumn(spec) {
const rowCols = spec.columns.filter(c => c.collect === REP + 'row');
if (rowCols.length > 1) {
@@ -118,7 +117,7 @@ function oneRowColumn(spec) {
return null;
}
// 12 + 14. columns carry no constraints; known collect mode
// Columns carry no constraints; known collect mode
function columnPure(spec) {
for (const col of spec.columns) {
if (Object.keys(col.constraints || {}).length) {
@@ -132,7 +131,7 @@ function columnPure(spec) {
return null;
}
// 15. no label inside a filter
// No label inside a filter
function filterLabel(spec) {
for (const filter of spec.filters) {
if ((filter.constraints || {}).label) {
@@ -142,7 +141,7 @@ function filterLabel(spec) {
return null;
}
// 15b. a filter with a path but no constraint selects everything; the model
// A filter with a path but no constraint selects everything; the model
// almost certainly meant to filter. Refuse and say what to add.
function filterConstraint(spec) {
const MEANINGFUL = ['minCount', 'maxCount', 'hasValue', 'in', 'minInclusive',
@@ -157,7 +156,7 @@ function filterConstraint(spec) {
return null;
}
// 16. sh:maxCount above 0 needs counting
// sh:maxCount above 0 needs counting
function maxCountZero(spec) {
for (const [i, filter] of spec.filters.entries()) {
const c = filter.constraints;
@@ -171,7 +170,7 @@ function maxCountZero(spec) {
return null;
}
// 17 + 18. rep:anyOf size limits
// rep:anyOf size limits
function anyOfLimits(spec) {
for (const [i, filter] of spec.filters.entries()) {
const c = filter.constraints;
@@ -191,7 +190,7 @@ function anyOfLimits(spec) {
return null;
}
// 19. sh:in mixes URIs and literals
// sh:in must not mix URIs and literals
function inHomogeneous(spec) {
for (const filter of spec.filters) {
const c = filter.constraints;
@@ -204,7 +203,7 @@ function inHomogeneous(spec) {
return null;
}
// 20. typed literals that will not parse
// Typed literals must parse
function literalsParse(spec) {
const constraintTerms = [];
for (const filter of spec.filters) {
@@ -259,8 +258,8 @@ export function checkSpec(spec, profile, maxPathDepth, knownProfiles = null) {
return [];
}
// The shape a column's path lands in: follow sh:node through every hop but
// the last. walk() has already validated the path, so any gap means the walk
// The shape a path lands in: follow sh:node through every hop but the
// last. walk() has already validated the path, so a gap here means the walk
// reported the real error and this can bail.
function shapeAfter(profile, startShapeUri, hops) {
let shapeUri = startShapeUri;

View File

@@ -1,9 +1,8 @@
import { mkdirSync, writeFileSync } from 'fs';
// Rows to a CSV file. CSV_SEPARATOR is ';' by default.
// A cell is quoted only when it holds the separator, a quote or a newline —
// this matches the shape of today's loket-report-generation-service output.
// A quote inside a cell is doubled, per RFC 4180.
// Rows to a CSV file. CSV_SEPARATOR is ';' by default. A cell is quoted
// only when it holds the separator, a quote or a newline; a quote inside a
// cell is doubled, per RFC 4180.
const CSV_SEPARATOR = process.env.CSV_SEPARATOR || ';';
const SHARE_DIR = process.env.SHARE_DIR || '/share';

View File

@@ -1,13 +1,9 @@
import { randomUUID } from 'crypto';
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from '../db.js';
// cogs:Job + task:Task, written by us. No other service is involved.
// Triples follow §6 of plan-opus-5.iteration-1-4.md; the shape follows
// config/resources/master-job-domain.lisp.
// Every write carries mu-call-scope-id (REPORT_SCOPE) so the delta notifier
// lets jobs-controller ignore our statuses — it throws on unknown operations.
// All functions take the update function to use: scopedUpdate(sparql, extra),
// which sends the captured groups plus this scope header.
// cogs:Job + task:Task, written by us; no other service is involved. Every
// write carries mu-call-scope-id (REPORT_SCOPE) so the delta notifier lets
// jobs-controller ignore our statuses — it throws on unknown operations.
const REPORT_SCOPE = 'http://redpencil.data.gift/id/concept/muScope/report-generation';
const JOB_BASE = process.env.JOB_URI_BASE || 'http://redpencil.data.gift/id/job/';
@@ -32,9 +28,7 @@ const TASK = 'http://redpencil.data.gift/vocabularies/tasks/';
const OSLC_MESSAGE = 'http://open-services.net/ns/core#message';
const OSLC_ERROR_URI = 'http://open-services.net/ns/core#Error';
// updateFn(sparql, extraHeaders) — app.js passes scopedUpdate, which sends
// the captured groups; the scope header is added here so every job write is
// opt-out-able for the delta notifier.
// Adds the scope header to every job write.
function withScope(updateFn) {
return async sparql => updateFn(sparql, { 'mu-call-scope-id': REPORT_SCOPE });
}

View File

@@ -8,9 +8,7 @@ const RDF = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
const OWL_ONTOLOGY = 'http://www.w3.org/2002/07/owl#Ontology';
export async function loadProfiles(dir) {
// eslint-disable-next-line no-undef
const fs = await import('fs');
// eslint-disable-next-line no-undef
const path = await import('path');
const files = fs.readdirSync(dir).filter(f => f.endsWith('.ttl')).sort();
const profiles = new Map();

View File

@@ -5,9 +5,9 @@ import { sessionUpdate, sparqlEscapeUri, sparqlEscapeString, sparqlEscapeInt,
const REPORT_CLASS = process.env.REPORT_CLASS || 'http://lblod.data.gift/vocabularies/reporting/Report';
// Write the file to /share, then register it once: a logical file
// (http://data.lblod.info/files/<uuid>) plus a physical share:// file
// pointing back with nie:dataSource. The logical URI is what you link to.
// Register the CSV: a logical file (http://data.lblod.info/files/<uuid>) plus
// a physical share:// file pointing back with nie:dataSource. The logical
// URI is what you link to.
export async function registerFile(sessionUpdateFn, fileName, filePath) {
const size = statSync(filePath).size;
const uuid = randomUUID();
@@ -36,8 +36,7 @@ export async function registerFile(sessionUpdateFn, fileName, filePath) {
return logicalUri;
}
// One report pointing at the logical file URI. Insert without naming a graph;
// the auth layer places the triples from the caller's session.
// One report pointing at the logical file URI.
export async function registerReport(sessionUpdateFn, title, fileUri, extra = {}) {
const uuid = randomUUID();
const now = new Date();

View File

@@ -5,11 +5,9 @@ import { rowsToCsv, writeCsv } from './csv.js';
import { registerFile, registerReport } from './report.js';
import { touch } from './job.js';
// Ties the six steps together. `query` and `update` are the template's own
// helpers: they attach the caller's session from the request context, which
// lives for the whole run (also past the 202), so mu-authorization keeps
// deciding graphs and visibility. Returns { reportUri, fileUri, rowCount }.
// The task is touched once per column group so the dashboard shows movement.
// Ties the steps together: seed → columns → assemble → CSV → register.
// Returns { reportUri, fileUri, rowCount }. The task is touched once per
// column group so the dashboard shows movement.
export async function run(query, update, parsed, profile, title, taskUri, extra = {}) {
const subjects = await seed(query, parsed);
@@ -29,8 +27,8 @@ export async function run(query, update, parsed, profile, title, taskUri, extra
return { reportUri, fileUri, rowCount: rows.length - 1, filePath };
}
// Exported for run_report: the file name must be known before the run starts,
// so the chat can show a pending card named after the report.
// The file name must be known before the run starts, so the chat can show a
// pending card named after the report.
export function slug(title) {
return (title || 'report').toLowerCase()
.replace(/[^a-z0-9]+/g, '-')

View File

@@ -1,8 +1,6 @@
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime, sparqlEscapeDate } from '../db.js';
// Subject selection. One paged query per page; stops at ROW_LIMIT.
// No GRAPH, no property paths, every hop its own variable.
const ROW_LIMIT = Number(process.env.ROW_LIMIT || 200000);
const PAGE_SIZE = Number(process.env.SEED_PAGE_SIZE || 5000);