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

@@ -1,9 +1,5 @@
// The turn endpoint. Mount it on the service that answers:
//
// mountChat(app, { path: '/assistant', answer });
//
// answer(turn) is the only thing the service writes. See the plan, §3, for
// the turn object. This file knows nothing about what the answer is.
// The turn endpoint. Mount it with mountChat(app, { path, answer });
// answer(turn) is the only thing the mounting service writes.
import express from 'express';
import { query as muQuery, update as muUpdate } from 'mu';
import {
@@ -23,8 +19,7 @@ export function mountChat(app, { path = '/assistant', answer }) {
const content = String(req.body?.content ?? '').trim();
if (!content) return res.status(400).json({ error: 'content is required' });
// 1. the access check: the query carries the caller's session, and the
// auth layer answers with what they may read
// 1. the access check: the caller's session decides what is readable
let conversation;
try {
conversation = await readConversation(req.params.id);
@@ -49,10 +44,8 @@ export function mountChat(app, { path = '/assistant', answer }) {
return res.status(500).json({ error: e.message });
}
// 2. answer now; the assistant's message comes when it comes. The
// template's helpers keep attaching this request's session for the
// whole continuation, so the writes below stay the caller's;
// mu-authorization is in charge of what lands where.
// 2. answer 202 now; the assistant's message comes when it comes. The
// continuation still runs as the caller.
res.status(202).json({ id: userMessage.id });
const turn = {

View File

@@ -1,6 +1,6 @@
// The chat's reads and writes. Copy the folder as is; nothing here knows the
// service it lives in. Full URIs everywhere: the auth layer rejects a prefixed
// name with no PREFIX line with an opaque 500.
// The chat's reads and writes. Nothing here knows the service it lives in.
// Full URIs everywhere: the auth layer rejects a prefixed name with no
// PREFIX line with an opaque 500.
import { randomUUID } from 'crypto';
import { query as muQuery, update as muUpdate,
sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime } from 'mu';
@@ -31,8 +31,7 @@ const DOCUMENT_BASE = 'http://data.lblod.info/id/chat-documents/';
const u = sparqlEscapeUri;
const s = sparqlEscapeString;
// The template helper writes ^^xsd:dateTime; the auth layer happens to bind
// xsd, but the rule here is full URIs, so spell the type out.
// Full URIs, so spell the dateTime type out.
const dt = (value) => sparqlEscapeDateTime(value)
.replace('xsd:dateTime', '<http://www.w3.org/2001/XMLSchema#dateTime>');
@@ -40,9 +39,7 @@ const bindings = (json) => json.results.bindings;
// --- reads --------------------------------------------------------------------
// The conversation by uuid, as the caller sees it (the query carries the
// caller's session; the auth layer answers with what they may read).
// null when not readable.
// The conversation by uuid, null when the caller may not read it.
export async function readConversation(id) {
const json = await muQuery(`
SELECT ?conversation ?title ?creator WHERE {
@@ -96,7 +93,7 @@ export async function writeMessage({ conversationUri, content, maker, attachment
return { ...a, id, uri: `${DOCUMENT_BASE}${id}` };
});
// Predicate pairs are joined with a semicolon, so no list ever ends on one.
// Predicate pairs joined with ';', never a trailing one: it breaks the Turtle.
const pairs = [
`${u(T.type)} ${u(T.post)}`,
`${u(T.type)} ${u(T.instantMessage)}`,
@@ -152,7 +149,7 @@ export async function setDocumentUrl(documentUri, url, conversationUri) {
}`);
}
// The file failed. The card disappears; the caller says why in a message.
// The file failed; the card disappears, the caller says why in a message.
export async function dropDocument(documentUri) {
await muUpdate(`
DELETE {

View File

@@ -5,11 +5,6 @@ import { query as muQuery, update as muUpdate,
export { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeInt,
sparqlEscapeDateTime, sparqlEscapeDate, sparqlEscapeBool };
// All SPARQL goes through the template's own helpers, like
// ipdc-bookmarks-service. They attach the caller's session
// (mu-session-id, mu-call-id) from the request context, and
// mu-authorization decides graphs and visibility. The service never
// touches an auth header itself.
export async function sessionQuery(sparql) { return muQuery(sparql); }
export async function sessionUpdate(sparql) { return muUpdate(sparql); }
@@ -18,10 +13,8 @@ export function stripStoreError(text) {
return String(text).split('\n')[0].slice(0, 300);
}
// The per-request bundle the tools use: the template's query and update.
// The request context lives for the whole run, also past the 202 the chat
// turn answers with, so mu-authorization keeps rewriting with the caller's
// session. We add nothing to it.
// The request context lives for the whole run, also past the 202 a chat turn
// answers with, so every write stays the caller's.
export async function buildSession() {
return { query: sessionQuery, update: sessionUpdate };
}

View File

@@ -1,8 +1,6 @@
// Turn a shapes graph into the flat list an LLM reads well (plan §7.3). Not
// Turtle: a plain text menu of entities, their fields and the code lists.
// Code lists under INLINE_VALUES_MAX are inlined at request time
// (sessionQuery; mu-authorization decides what is visible) and refreshed
// every VALUES_TTL, so the LLM never has to search the short ones.
// A profile as a plain text menu: entities, fields, code lists. Code lists up
// to INLINE_VALUES_MAX are inlined and cached for VALUES_TTL, so the LLM
// never has to search the short ones.
import { fieldsOf, entityForClass } from '../runner/profile.js';
import { sessionQuery } from '../db.js';

View File

@@ -1,8 +1,6 @@
// lookup_values: search a code list too long to inline. One query through
// the template helper, regex on str(?label) so language-tagged labels still
// match. Returns the matches, the total, and an `exact` flag when a label
// equals the term (case aside) so the LLM can settle disambiguation on its
// own.
// Search a code list too long to inline: regex on str(?label), so
// language-tagged labels match. Returns the matches, the total, and an
// `exact` flag when a label equals the term (case aside).
import { sessionQuery, sparqlEscapeString } from '../db.js';
import { fieldsOf, entityForClass } from '../runner/profile.js';
@@ -24,7 +22,7 @@ export async function lookupValues(profile, term, fieldSpec) {
// The profile's sh:class is the type the data should carry, but code lists
// sometimes type their entries differently (MAR codes are
// ext:supervision/Nomenclature, the profile says skos:Concept). Prefer rows
// matching the asked class; fall back to all rows. The query reads more
// matching the asked class, fall back to all rows. The query reads more
// rows than LIMIT so an exact hit deeper in the alphabet still surfaces.
const wanted = bindings.filter(b => type && b.type?.value === type);
const rows = wanted.length ? wanted : bindings;
@@ -92,9 +90,8 @@ function resolveClass(profile, fieldSpec) {
const linked = profile.shapes.find(s => s.uri === f.node);
return linked?.targetClass || null;
}
// "bestuurseenheid.naam": the model chained link→value (shape.field on the
// linked shape). Resolve it: find the shape whose *name or label* matches,
// and whose rdf:type is in the data, then search that type.
// 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;

View File

@@ -1,16 +1,6 @@
// The agent loop. One code path, one provider: POST /v1/chat/completions
// with tool calls, OpenAI-compatible (OpenAI, Azure, Ollama, LiteLLM, vLLM,
// Mistral). No provider SDKs. Three variables: LLM_BASE_URL, LLM_MODEL,
// LLM_API_KEY.
//
// The loop is the only stateful party: it keeps the message history, calls the
// MCP tools through the same handlers a real MCP client would, and stops when
// the model writes no more tool calls. validate_spec is driven up to
// MAX_REPAIR_ROUNDS, then run_report.
//
// This module is what the ten-question check runs against. It does not import
// from 'mu' or sessionQuery; it receives the tool handlers and a session
// bundle from the caller, so it works against any profile set and any session.
// The agent loop: POST /v1/chat/completions with tool calls,
// OpenAI-compatible (LLM_BASE_URL, LLM_MODEL, LLM_API_KEY). No provider
// SDKs. Stops when the model answers without tool calls.
import { buildTools } from './tools.js';
@@ -44,13 +34,9 @@ up to three rounds. Then call run_report.
Earlier turns are context only. Answer the last one.`;
// ask(questionOrMessages, profiles, session) → { text, trace }
// A string is one question, as before. An array is the conversation so far,
// [{ role: 'user' | 'assistant', content }], newest last. The system prompt
// is always this file's own; a system message in the input is dropped, so a
// caller cannot widen it. The loop appends every model reply and tool result
// to the messages, so a caller can keep one conversation across many reports.
// Returns the model's final text plus a trace of every tool call it made (for
// the leak test and for debugging).
// A string is one question; an array is the conversation so far,
// [{ role, content }], newest last. The system prompt is always this file's
// own; a system message in the input is dropped.
export async function ask(questionOrMessages, profiles, session) {
const { handlers } = buildTools(profiles, session);
const trace = [];
@@ -90,8 +76,7 @@ export async function ask(questionOrMessages, profiles, session) {
}
async function chat(messages) {
// Providers echo extra fields (litellm adds provider_specific_fields, Mistral
// rejects them on the way back). Send only the portable wire shape.
// Send only the fields every provider accepts back.
const wire = messages.map(m => {
const out = { role: m.role, content: m.content ?? null };
if (m.tool_calls) {
@@ -139,8 +124,7 @@ function parseArgs(s) {
}
// messagesFor(question) — a fresh conversation: system prompt plus the user
// question. Turn this into your own history array to follow up in the same
// conversation.
// question.
export function messagesFor(question) {
return [
{ role: 'system', content: SYSTEM_PROMPT },
@@ -148,8 +132,7 @@ export function messagesFor(question) {
];
}
// The tool definitions sent to the model. Same names and args as the MCP
// schemas, trimmed to what the model needs.
// The tool definitions sent to the model.
import { toolSchemas } from './schemas.js';
function toolDefs() {
return toolSchemas().map(t => ({

View File

@@ -1,6 +1,5 @@
// The JSON schemas for the eight tools, in the shape the MCP ListTools call
// returns. Keep these short and Dutch-aware: the descriptions are what the
// LLM reads to decide which tool to call.
// The JSON schemas of the eight tools. The descriptions are what the LLM
// reads to decide which tool to call.
export function toolSchemas() {
return [

View File

@@ -1,10 +1,7 @@
// The eight MCP tools the LLM calls. Each maps to a runner/ function or a
// profile/db read. What they return is always a job URI, a status, a count,
// a link, column names or an error we wrote, never a cell value (the §8
// guarantee).
//
// The shape every tool returns is { content: [{ type: 'text', text }] } so the
// loop can pass it straight back to the model.
// The eight tools the LLM calls. They return a job URI, a status, a count, a
// link, column names or an error we wrote — never a cell value. The shape
// every tool returns is { content: [{ type: 'text', text }] }, which the loop
// passes straight back to the model.
import { checkSpec } from '../runner/check.js';
import { parseSpec } from '../runner/spec.js';
@@ -16,12 +13,8 @@ import { lookupValues } from './lookup.js';
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
const RUN_TIMEOUT = Number(process.env.RUN_TIMEOUT || 60) * 1000;
// buildTools(profiles, session) → { tools: Tool[], handlers: Map<name, fn> }
// `session` is the request's { query, update } bundle: the template's own
// helpers, which attach the caller's session from the request context.
// run_report starts the run and waits up to RUN_TIMEOUT so the LLM gets the
// result in one turn when it is quick.
// buildTools(profiles, session) → { handlers }
// run_report waits up to RUN_TIMEOUT so a quick run returns in one turn.
export function buildTools(profiles, session) {
const profileList = () => [...profiles.values()].map(p => ({ id: p.uri, title: p.title }));
@@ -75,13 +68,13 @@ export function buildTools(profiles, session) {
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
if (errors.length) return text(`the spec is not valid: ${errors[0]}`);
try {
// The spec's own dct:title names the report; there is no other input.
// The spec's dct:title names the report.
const name = parsed.title || 'report';
await session.onReportStart?.({ title: name, fileName: `${slug(name)}.csv` });
const running = run(session.query, session.update, parsed, p, name);
// The end callback fires even when withTimeout below has given up: the
// run is not cancelled, and the file arrives when it arrives. The
// rejection handler also keeps a late failure from crashing Node 20.
// The end callback also fires after the timeout gave up: the run is not
// cancelled, the file arrives when it arrives. The .catch keeps a late
// failure from crashing the process.
running
.then((r) => session.onReportEnd?.(null, r), (e) => session.onReportEnd?.(e))
.catch((e) => console.error('[reports] end callback failed:', e));

View File

@@ -1,6 +1,6 @@
// The report assistant: what the chat says when this service answers a turn.
// Runs the iteration-1 loop with the conversation so far, and turns a report
// run into an interim message with a pending file.
// What this service answers a chat turn with: the LLM loop with the
// conversation so far, and a report run becomes an interim message with a
// pending file.
import { ask } from './llm/loop.js';
const TEXT = {
@@ -8,13 +8,11 @@ const TEXT = {
failed: 'Het rapport is mislukt. Stel de vraag opnieuw, of anders geformuleerd.',
};
// whenProfiles() resolves once the SHACL profiles are loaded (app.js keeps
// the load promise), so the chat mounts before the profiles are in.
// The chat mounts before the profiles are in; whenProfiles waits for the load.
export function reportAssistant(whenProfiles) {
return async function answer(turn) {
// the pending file of the current run; the end callback needs the one
// the start callback made. One report per turn: a second run_report in
// the same turn overwrites this before the first end callback fires.
// One pending file per turn: a second run_report overwrites it before the
// first end callback fires.
let pendingDocument = null;
const session = {
@@ -43,8 +41,8 @@ export function reportAssistant(whenProfiles) {
};
}
// The file service serves /files/<uuid>/download, and the logical file URI
// that registerFile returns ends in that uuid.
// The file service serves /files/<uuid>/download; the logical file URI ends
// in that uuid.
function downloadHref(fileUri) {
return `/files/${fileUri.split('/').pop()}/download`;
}

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);