simplification
This commit is contained in:
68
README.md
68
README.md
@@ -22,16 +22,15 @@ a suggestion, not a check. The user must confirm before anything executes.
|
|||||||
As long as the user has not given a clear command, the turn stays in this
|
As long as the user has not given a clear command, the turn stays in this
|
||||||
mode.
|
mode.
|
||||||
|
|
||||||
**Execution (Modus B).** The user has confirmed. The LLM writes the agreed
|
**Execution (Modus B).** The user has confirmed. No LLM runs. The last spec
|
||||||
spec, validates it and calls `run_report` once, then stops. `run_report`
|
bijlage in the conversation is the agreed spec: the service reads it from the
|
||||||
returns only the report URI, the row count and the file URI. No cell value
|
share, checks it against its profile and runs it as the caller. The answer is
|
||||||
ever reaches the model; the service may count results, but no data is fed
|
a fixed Dutch text with the CSV and the spec as bijlagen. No spec yet, a spec
|
||||||
to the LLM.
|
that no longer checks out, or a failed run each give a fixed Dutch failure
|
||||||
|
message. Nothing is retried and no spec is rewritten.
|
||||||
|
|
||||||
The two modes share `list_profiles`, `describe_profile` and `validate_spec`.
|
Only refinement has tools: `list_profiles`, `describe_profile`,
|
||||||
Refinement adds `lookup_values`; execution adds `run_report`. The tool set is
|
`validate_spec`, `read_spec` and `lookup_values`.
|
||||||
the only thing that differs; the code path from the classifier's answer is
|
|
||||||
deterministic.
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -52,8 +51,7 @@ namespace is `http://mu.semte.ch/vocabularies/reporting/` (`rep:`).
|
|||||||
|
|
||||||
The run: check → seed (`SELECT DISTINCT ?s`, paged) → columns (per group, per
|
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) → assemble (dedup + `rep:collect`) → CSV → register file
|
||||||
+ report resource. One run per question; the loop stops after `run_report`
|
+ report resource. One run per confirmed spec.
|
||||||
returns.
|
|
||||||
|
|
||||||
## One identity: mu-authorization's
|
## One identity: mu-authorization's
|
||||||
|
|
||||||
@@ -74,8 +72,8 @@ The LLM's own reads during refinement (`describe_profile`'s code lists and
|
|||||||
`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 public code lists, regardless of who calls. Execution
|
refinement reads only public code lists, regardless of who calls. Execution
|
||||||
(`run_report`) still runs as the caller, so the report contains what they may
|
still runs as the caller, so the report contains what they may see, and the
|
||||||
see, and the LLM sees none of it.
|
LLM sees none of it.
|
||||||
|
|
||||||
The scope URI is `SERVICE_SCOPE` (default
|
The scope URI is `SERVICE_SCOPE` (default
|
||||||
`http://services.semantic.works/natural-language-report`) and must match the
|
`http://services.semantic.works/natural-language-report`) and must match the
|
||||||
@@ -83,42 +81,31 @@ The scope URI is `SERVICE_SCOPE` (default
|
|||||||
`DEFAULT_MU_AUTH_SCOPE` on the service: that would scope every query
|
`DEFAULT_MU_AUTH_SCOPE` on the service: that would scope every query
|
||||||
(including the run and the chat store) and break them.
|
(including the run and the chat store) and break them.
|
||||||
|
|
||||||
## Two endpoints
|
## The endpoint
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /ask { question, history? } → { answer, toolCalls }
|
|
||||||
POST /assistant/conversations/:id/turns { content } → 202 { id }
|
POST /assistant/conversations/:id/turns { content } → 202 { id }
|
||||||
```
|
```
|
||||||
|
|
||||||
`/ask` is synchronous: a Dutch question in, the turn is classified, and the
|
This is the chat. The turn module in
|
||||||
matching mode's loop runs inside the service (profiles, spec, validate,
|
|
||||||
refine or run), and the answer in Dutch comes back with a trace of the tool
|
|
||||||
calls. The CSV lands in `data/files/`. `history` carries the earlier turns
|
|
||||||
for follow-ups; the conversation lives on the caller's side, the service
|
|
||||||
stores nothing. One question makes one report; the execution loop stops
|
|
||||||
after `run_report`.
|
|
||||||
|
|
||||||
`/assistant` is the chat. The turn module in
|
|
||||||
`src/chat/` is generic — it imports nothing from `src/llm/` or `src/runner/`
|
`src/chat/` is generic — it imports nothing from `src/llm/` or `src/runner/`
|
||||||
and moves between services without edits — and `src/report-assistant.js` is
|
and moves between services without edits — and `src/report-assistant.js` is
|
||||||
this service's one `answer` hook. It records the question, answers `202` with
|
this service's one `answer` hook. It records the question, answers `202` with
|
||||||
the message id, then runs the same loop with the conversation's history. A
|
the message id, then classifies the turn and refines or executes with the
|
||||||
report run becomes an interim message with a pending file (`as:Document` with
|
conversation's history. The answer and its bijlagen are written when the turn
|
||||||
no `as:url`); the URL is set when the run ends. The browser polls
|
ends. The browser polls
|
||||||
`/chat-conversations/:id?include=messages.attachments` and finds the answer
|
`/chat-conversations/:id?include=messages.attachments` and finds the answer
|
||||||
that way. Failure is a message, never a silent loader.
|
that way. Failure is a message, never a silent loader.
|
||||||
|
|
||||||
`/ask` needs `LLM_BASE_URL` set, and answers 503 without it. A full-org report
|
The chat needs `LLM_BASE_URL`; without it every turn answers with a failure
|
||||||
run takes minutes.
|
message. A full-org report run takes minutes.
|
||||||
|
|
||||||
### The internal tools
|
### The internal tools
|
||||||
|
|
||||||
The loop drives a tool set per mode (`src/llm/tools.js`). Both modes share
|
The refinement loop drives one tool set (`src/llm/tools.js`). The tools are
|
||||||
`list_profiles`, `describe_profile`, `validate_spec`. Refinement adds
|
machinery, not an external surface. All LLM-facing text (system prompts,
|
||||||
`lookup_values`; execution adds `run_report`. They are machinery, not an
|
profile menu, tool descriptions, validator errors) is English; the answer the
|
||||||
external surface — the tool trace in the answer is the only thing a caller
|
user sees is Dutch.
|
||||||
sees of them. All LLM-facing text (system prompts, profile menu, tool
|
|
||||||
descriptions, validator errors) is English; the answer the user sees is Dutch.
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -130,10 +117,9 @@ descriptions, validator errors) is English; the answer the user sees is Dutch.
|
|||||||
| `SUBJECT_CHUNK_SIZE` | `100` | subjects per column query (VALUES batch size); lower it when the auth layer rejects long queries |
|
| `SUBJECT_CHUNK_SIZE` | `100` | subjects per column query (VALUES batch size); lower it when the auth layer rejects long queries |
|
||||||
| `ROW_LIMIT` | `200000` | seed limit; failing the job beats truncating |
|
| `ROW_LIMIT` | `200000` | seed limit; failing the job beats truncating |
|
||||||
| `MAX_PATH_DEPTH` | `8` | longest allowed spec path |
|
| `MAX_PATH_DEPTH` | `8` | longest allowed spec path |
|
||||||
| `RUN_TIMEOUT` | `60` | seconds of silence before `run_report` gives up. Every answered query (seed page, column batch) restarts the clock, so a big report only needs the window per batch, not for the whole run. The run itself is never cancelled: the file still lands in the chat. |
|
|
||||||
| `INLINE_VALUES_MAX` | `50` | code lists up to this size are inlined in `describe_profile` |
|
| `INLINE_VALUES_MAX` | `50` | code lists up to this size are inlined in `describe_profile` |
|
||||||
| `VALUES_TTL` | `3600` | seconds the inlined lists are cached |
|
| `VALUES_TTL` | `3600` | seconds the inlined lists are cached |
|
||||||
| `LLM_BASE_URL` | — | enables `/ask`; OpenAI-compatible (`https://ollama.com/v1`) |
|
| `LLM_BASE_URL` | — | the chat's LLM; OpenAI-compatible (`https://ollama.com/v1`) |
|
||||||
| `LLM_MODEL` | — | e.g. `gpt-oss:120b` |
|
| `LLM_MODEL` | — | e.g. `gpt-oss:120b` |
|
||||||
| `LLM_API_KEY` | — | bearer token, optional for local Ollama |
|
| `LLM_API_KEY` | — | bearer token, optional for local Ollama |
|
||||||
| `SEED_PAGE_SIZE` | `5000` | seed page size |
|
| `SEED_PAGE_SIZE` | `5000` | seed page size |
|
||||||
@@ -152,7 +138,7 @@ Do not set `ALLOW_MU_AUTH_SUDO`; the template then refuses sudo queries.
|
|||||||
```yaml
|
```yaml
|
||||||
natural-language-report:
|
natural-language-report:
|
||||||
build: ../natural-language-report-service/
|
build: ../natural-language-report-service/
|
||||||
image: lblod/natural-language-report-service:0.1.0
|
image: lblod/natural-language-report-service:0.2.0
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/files:/share
|
- ./data/files:/share
|
||||||
- ./config/report-profiles/:/config/profiles/
|
- ./config/report-profiles/:/config/profiles/
|
||||||
@@ -161,12 +147,12 @@ natural-language-report:
|
|||||||
`config/dispatcher/dispatcher.ex`:
|
`config/dispatcher/dispatcher.ex`:
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
match "/natural-language-reports/*path" do
|
match "/assistant/*path" do
|
||||||
forward conn, path, "http://natural-language-report/"
|
forward conn, path, "http://natural-language-report/assistant/"
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
One route to the service root; `POST /ask` is the endpoint.
|
One route: the chat's turn endpoint.
|
||||||
First deploy: `docker compose build natural-language-report`, then
|
First deploy: `docker compose build natural-language-report`, then
|
||||||
`docker compose up -d natural-language-report dispatcher`.
|
`docker compose up -d natural-language-report dispatcher`.
|
||||||
|
|
||||||
|
|||||||
32
app.js
32
app.js
@@ -1,8 +1,5 @@
|
|||||||
import { app, errorHandler } from 'mu';
|
import { app, errorHandler } from 'mu';
|
||||||
import express from 'express';
|
|
||||||
import { loadProfiles } from './src/runner/profile.js';
|
import { loadProfiles } from './src/runner/profile.js';
|
||||||
import { sessionQuery, sessionUpdate, stripStoreError } from './src/db.js';
|
|
||||||
import { ask } from './src/llm/loop.js';
|
|
||||||
import { mountChat } from './src/chat/index.js';
|
import { mountChat } from './src/chat/index.js';
|
||||||
import { reportAssistant } from './src/report-assistant.js';
|
import { reportAssistant } from './src/report-assistant.js';
|
||||||
|
|
||||||
@@ -21,33 +18,8 @@ async function whenProfiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chat. reportAssistant is the answer hook: it runs the LLM loop with the
|
// The chat. reportAssistant is the answer hook: it refines the spec with the
|
||||||
// conversation so far and turns a report run into an interim message with a
|
// LLM, or runs the agreed spec without it.
|
||||||
// pending file.
|
|
||||||
mountChat(app, { path: '/assistant', answer: reportAssistant(whenProfiles) });
|
mountChat(app, { path: '/assistant', answer: reportAssistant(whenProfiles) });
|
||||||
|
|
||||||
// POST /ask: one question in, the answer in Dutch and a trace of the tool
|
|
||||||
// calls back. Follow-ups carry the earlier turns in `history`.
|
|
||||||
// The template parses application/vnd.api+json only; this route parses the
|
|
||||||
// plain JSON body itself.
|
|
||||||
app.post('/ask', express.json(), async (req, res) => {
|
|
||||||
const { question, history } = req.body || {};
|
|
||||||
if (!question) return res.status(400).json({ error: 'no question in the request body' });
|
|
||||||
if (!process.env.LLM_BASE_URL) return res.status(503).json({ error: 'no LLM configured (set LLM_BASE_URL)' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profiles = await whenProfiles();
|
|
||||||
const turns = [
|
|
||||||
...(Array.isArray(history) ? history : []),
|
|
||||||
{ role: 'user', content: question },
|
|
||||||
];
|
|
||||||
const session = { query: sessionQuery, update: sessionUpdate };
|
|
||||||
const { text, trace } = await ask(turns, profiles, session);
|
|
||||||
res.json({ answer: text, toolCalls: trace });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ask] failed:', e);
|
|
||||||
res.status(502).json({ error: stripStoreError(e.message) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "natural-language-report-service",
|
"name": "natural-language-report-service",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "natural-language-report-service",
|
"name": "natural-language-report-service",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "natural-language-report-service",
|
"name": "natural-language-report-service",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Turns a SHACL report spec into SPARQL queries, joins the results and writes a CSV. The LLM writes specs; this service runs them.",
|
"description": "Turns a SHACL report spec into SPARQL queries, joins the results and writes a CSV. The LLM writes specs; this service runs them.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
12
src/db.js
12
src/db.js
@@ -13,7 +13,6 @@ import {
|
|||||||
sparqlEscapeBool,
|
sparqlEscapeBool,
|
||||||
} from 'mu';
|
} from 'mu';
|
||||||
|
|
||||||
export const sessionQuery = muQuery;
|
|
||||||
export const sessionUpdate = muUpdate;
|
export const sessionUpdate = muUpdate;
|
||||||
export {
|
export {
|
||||||
sparqlEscapeUri,
|
sparqlEscapeUri,
|
||||||
@@ -27,17 +26,12 @@ export {
|
|||||||
// The service's own scope. Reads the LLM drives during refinement (code
|
// The service's own scope. Reads the LLM drives during refinement (code
|
||||||
// lists, lookup_values) run under this scope, not the caller's session. The
|
// lists, lookup_values) run under this scope, not the caller's session. The
|
||||||
// sparql-parser config grants this scope read access to the public graph
|
// sparql-parser config grants this scope read access to the public graph
|
||||||
// only, so refinement reads only public data. Execution (run_report) still
|
// only, so refinement reads only public data. Execution still runs as the
|
||||||
// runs as the caller. Set to match the with-scope grant in config.lisp.
|
// caller. Set to match the with-scope grant in config.lisp.
|
||||||
export const SERVICE_SCOPE =
|
export const SERVICE_SCOPE =
|
||||||
process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
|
process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
|
||||||
|
|
||||||
// Wrap a mu query/update so it carries the service scope. mu's query/update
|
// Wrap a mu query/update so it carries the service scope. mu's query/update
|
||||||
// accept { scope } on mu-javascript-template >= 1.9.0.
|
// accept { scope } on mu-javascript-template >= 1.9.0.
|
||||||
export function scopedQuery(q) { return muQuery(q, { scope: SERVICE_SCOPE }); }
|
export function scopedQuery(q) { return muQuery(q, { scope: SERVICE_SCOPE }); }
|
||||||
export function scopedUpdate(q) { return muUpdate(q, { scope: SERVICE_SCOPE }); }
|
export function scopedUpdate(q) { return muUpdate(q, { scope: SERVICE_SCOPE }); }
|
||||||
|
|
||||||
// The store's error text may name graphs or internals we do not pass on.
|
|
||||||
export function stripStoreError(text) {
|
|
||||||
return String(text).split('\n')[0].slice(0, 300);
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,13 @@
|
|||||||
// OpenAI-compatible (LLM_BASE_URL, LLM_MODEL, LLM_API_KEY). No provider
|
// OpenAI-compatible (LLM_BASE_URL, LLM_MODEL, LLM_API_KEY). No provider
|
||||||
// SDKs. Stops when the model answers without tool calls.
|
// SDKs. Stops when the model answers without tool calls.
|
||||||
//
|
//
|
||||||
// Two modes, picked per turn by a mode classifier (./mode.js):
|
// Refinement only: the LLM proposes a spec, may look up candidate values in
|
||||||
// - refine: the LLM proposes a spec, may look up candidate values in code
|
// code lists (under the service scope) and iterates with the user. It never
|
||||||
// lists (under the service scope) and iterates with the user. It never
|
// runs a report and never learns what a spec would match. The user must
|
||||||
// runs a report and never learns what a spec would match. The user must
|
// confirm before anything executes; execution runs the agreed spec without
|
||||||
// confirm before anything executes.
|
// the LLM (../report-assistant.js).
|
||||||
// - execute: the LLM writes the agreed spec, validates it and runs the
|
|
||||||
// report once. run_report returns only counts and URIs; no cell value ever
|
|
||||||
// reaches the model.
|
|
||||||
|
|
||||||
import { buildTools } from './tools.js';
|
import { buildTools } from './tools.js';
|
||||||
import { classifyMode } from './mode.js';
|
|
||||||
|
|
||||||
const MAX_ROUNDS = Number(process.env.MAX_ROUNDS || 12);
|
const MAX_ROUNDS = Number(process.env.MAX_ROUNDS || 12);
|
||||||
const LLM_BASE_URL = process.env.LLM_BASE_URL;
|
const LLM_BASE_URL = process.env.LLM_BASE_URL;
|
||||||
@@ -67,58 +63,16 @@ what it says, up to three rounds. Then propose and stop.
|
|||||||
|
|
||||||
Reply to the user in Dutch. Earlier turns are context only. Answer the last one.`;
|
Reply to the user in Dutch. Earlier turns are context only. Answer the last one.`;
|
||||||
|
|
||||||
// Execution (Modus B). The user has confirmed. The LLM writes the agreed
|
// ask(questionOrMessages, profiles, session) → { text }
|
||||||
// spec, validates it and runs the report once, then stops. It never sees the
|
|
||||||
// report's data: run_report returns only the report URI, the row count and
|
|
||||||
// the file URI.
|
|
||||||
const EXECUTE_PROMPT = `You write report specs. You never write SPARQL and you never see report data.
|
|
||||||
|
|
||||||
The profile lists what can be asked. Compose paths by chaining fields.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- A column must end on a value, not on a link. Chain one more hop.
|
|
||||||
- When two entities share one sh:targetClass, the spec also needs rep:entity
|
|
||||||
with that entity's URI; describe_profile shows it after each entity.
|
|
||||||
- Filters go in sh:property. Columns go in rep:columns, in the order you want.
|
|
||||||
- To match a topic rather than a value, list the Dutch words and compounds you
|
|
||||||
would expect and put them all in rep:anyOf.
|
|
||||||
- You cannot count, sort, take the first N, or compare two subjects. Say so.
|
|
||||||
- The report's title is the dct:title you write in the spec. There is no other
|
|
||||||
title input.
|
|
||||||
- One question makes one report. Write the agreed spec, validate it, run it,
|
|
||||||
then stop. Do not write a second report.
|
|
||||||
|
|
||||||
The agreed spec may already be attached ("bijlage") to an earlier assistant
|
|
||||||
message in the conversation, a ttl file typed as a rapportspecificatie. Use
|
|
||||||
read_spec on its file uri to bring it back, then reuse it verbatim when it
|
|
||||||
fits the user's confirmed intent; only adjust it when the user asked for a
|
|
||||||
change.
|
|
||||||
|
|
||||||
Always prefix the spec with @prefix lines. Use the prefixes the profile
|
|
||||||
declares. Write the spec as one Turtle block. Then call validate_spec. Fix
|
|
||||||
what it says, up to three rounds. Then call run_report. After run_report
|
|
||||||
returns, stop.
|
|
||||||
|
|
||||||
Reply to the user in Dutch. Earlier turns are context only. Answer the last one.`;
|
|
||||||
|
|
||||||
// ask(questionOrMessages, profiles, session) → { text, trace }
|
|
||||||
// A string is one question; an array is the conversation so far,
|
// A string is one question; an array is the conversation so far,
|
||||||
// [{ role, content }], newest last. The system prompt is always this file's
|
// [{ role, content }], newest last. The system prompt is always this file's
|
||||||
// own; a system message in the input is dropped.
|
// own; a system message in the input is dropped.
|
||||||
export async function ask(questionOrMessages, profiles, session) {
|
export async function ask(questionOrMessages, profiles, session) {
|
||||||
const trace = [];
|
|
||||||
|
|
||||||
const turns = typeof questionOrMessages === 'string'
|
const turns = typeof questionOrMessages === 'string'
|
||||||
? [{ role: 'user', content: questionOrMessages }]
|
? [{ role: 'user', content: questionOrMessages }]
|
||||||
: questionOrMessages.filter(m => m.role !== 'system');
|
: questionOrMessages.filter(m => m.role !== 'system');
|
||||||
|
|
||||||
const execute = await classifyMode(turns);
|
return runLoop(withAttachmentSpecs(turns), profiles, session);
|
||||||
trace.push({ name: 'classify', args: { execute }, result: execute ? 'execute' : 'refine' });
|
|
||||||
|
|
||||||
if (execute) {
|
|
||||||
return runLoop(withAttachmentSpecs(turns), profiles, session, 'execute', trace);
|
|
||||||
}
|
|
||||||
return runLoop(withAttachmentSpecs(turns), profiles, session, 'refine', trace);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The history carries the spec as a bijlage. Paste one hint line per spec
|
// The history carries the spec as a bijlage. Paste one hint line per spec
|
||||||
@@ -136,23 +90,20 @@ function withAttachmentSpecs(turns) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// runLoop drives the agent loop for one mode. The mode picks the system
|
// runLoop drives the agent loop with the refinement prompt and tool set.
|
||||||
// prompt and the tool set; the code path is deterministic from there.
|
async function runLoop(turns, profiles, session) {
|
||||||
async function runLoop(turns, profiles, session, mode, trace) {
|
const { handlers } = buildTools(profiles, session);
|
||||||
const { handlers } = buildTools(profiles, session, mode);
|
const messages = [{ role: 'system', content: REFINE_PROMPT }, ...turns];
|
||||||
const systemPrompt = mode === 'execute' ? EXECUTE_PROMPT : REFINE_PROMPT;
|
|
||||||
const messages = [{ role: 'system', content: systemPrompt }, ...turns];
|
|
||||||
|
|
||||||
for (let round = 0; round < MAX_ROUNDS; round++) {
|
for (let round = 0; round < MAX_ROUNDS; round++) {
|
||||||
const reply = await chat(messages, { tools: toolDefs(mode) });
|
const reply = await chat(messages, { tools: toolDefs() });
|
||||||
messages.push(reply.message);
|
messages.push(reply.message);
|
||||||
const calls = reply.message.tool_calls || [];
|
const calls = reply.message.tool_calls || [];
|
||||||
if (!calls.length) {
|
if (!calls.length) {
|
||||||
return { text: reply.message.content || '', trace };
|
return { text: reply.message.content || '' };
|
||||||
}
|
}
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const args = parseArgs(call.function.arguments);
|
const args = parseArgs(call.function.arguments);
|
||||||
trace.push({ name: call.function.name, args });
|
|
||||||
const fn = handlers.get(call.function.name);
|
const fn = handlers.get(call.function.name);
|
||||||
let resultText;
|
let resultText;
|
||||||
if (!fn) {
|
if (!fn) {
|
||||||
@@ -166,15 +117,14 @@ async function runLoop(turns, profiles, session, mode, trace) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
messages.push({ role: 'tool', tool_call_id: call.id, content: resultText });
|
messages.push({ role: 'tool', tool_call_id: call.id, content: resultText });
|
||||||
trace[trace.length - 1].result = resultText;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { text: '(loop did not finish)', trace };
|
return { text: '(loop did not finish)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// chat(messages, { tools, toolChoice }) → { message }
|
// chat(messages, { tools, toolChoice }) → { message }
|
||||||
// The one place that talks to the provider. tools null (the default) means a
|
// The one place that talks to the provider. tools null (the default) means a
|
||||||
// plain completion (the mode classifier); runLoop passes its mode's tool
|
// plain completion (the mode classifier); runLoop passes the tool
|
||||||
// definitions; toolChoice defaults to 'auto'.
|
// definitions; toolChoice defaults to 'auto'.
|
||||||
export async function chat(messages, { tools = null, toolChoice = 'auto' } = {}) {
|
export async function chat(messages, { tools = null, toolChoice = 'auto' } = {}) {
|
||||||
// Send only the fields every provider accepts back.
|
// Send only the fields every provider accepts back.
|
||||||
@@ -230,8 +180,8 @@ function parseArgs(s) {
|
|||||||
|
|
||||||
// The tool definitions sent to the model.
|
// The tool definitions sent to the model.
|
||||||
import { toolSchemas } from './schemas.js';
|
import { toolSchemas } from './schemas.js';
|
||||||
function toolDefs(mode) {
|
function toolDefs() {
|
||||||
return toolSchemas(mode).map(t => ({
|
return toolSchemas().map(t => ({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: t.name,
|
name: t.name,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// The JSON schemas of the tools. The descriptions are what the LLM reads to
|
// The JSON schemas of the tools. The descriptions are what the LLM reads to
|
||||||
// decide which tool to call. Two modes share four tools (list_profiles,
|
// decide which tool to call. Refinement only: list_profiles,
|
||||||
// describe_profile, validate_spec, read_spec); refine adds lookup_values,
|
// describe_profile, validate_spec, read_spec and lookup_values.
|
||||||
// execute adds run_report.
|
|
||||||
|
|
||||||
const COMMON = [
|
const COMMON = [
|
||||||
{
|
{
|
||||||
@@ -43,16 +42,6 @@ const LOOKUP_VALUES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const RUN_REPORT = {
|
|
||||||
name: 'run_report',
|
|
||||||
description: 'Run a valid spec and return the report URI, the row count and the file URI. Runs as the caller, so it sees only what they may see. Waits as long as the run keeps answering; gives up after RUN_TIMEOUT seconds of silence. The report is named by the dct:title in the spec. Call this once per question, after validate_spec returns ok. You never see the report data, only the count and URIs.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: { spec: { type: 'string' } },
|
|
||||||
required: ['spec'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const READ_SPEC = {
|
const READ_SPEC = {
|
||||||
name: 'read_spec',
|
name: 'read_spec',
|
||||||
description: 'Return the Turtle of a spec bijlage, a ttl file attached to an earlier message in this conversation. Use it to reuse the agreed spec, or to read back an earlier proposal before you change it. Read-only.',
|
description: 'Return the Turtle of a spec bijlage, a ttl file attached to an earlier message in this conversation. Use it to reuse the agreed spec, or to read back an earlier proposal before you change it. Read-only.',
|
||||||
@@ -63,7 +52,6 @@ const READ_SPEC = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toolSchemas(mode = 'refine') {
|
export function toolSchemas() {
|
||||||
if (mode === 'execute') return [...COMMON, READ_SPEC, RUN_REPORT];
|
|
||||||
return [...COMMON, READ_SPEC, LOOKUP_VALUES];
|
return [...COMMON, READ_SPEC, LOOKUP_VALUES];
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
// The tools the LLM calls. Two modes:
|
// The tools the LLM calls, refinement only: list_profiles, describe_profile,
|
||||||
// - refine: list_profiles, describe_profile, validate_spec, lookup_values,
|
// validate_spec, lookup_values, read_spec. lookup_values searches a code list
|
||||||
// read_spec. lookup_values searches a code list under the service scope
|
// under the service scope (public graph) and returns candidate values, so the
|
||||||
// (public graph) and returns candidate values, so the LLM can suggest
|
// LLM can suggest concrete filter values. No report is created, no spec is
|
||||||
// concrete filter values. No report is created, no spec is evaluated.
|
// evaluated.
|
||||||
// - execute: list_profiles, describe_profile, validate_spec, read_spec,
|
|
||||||
// run_report. run_report runs as the caller and returns only counts and
|
|
||||||
// URIs; no cell value ever reaches the model.
|
|
||||||
//
|
//
|
||||||
// The shape every tool returns is { content: [{ type: 'text', text }] },
|
// The shape every tool returns is { content: [{ type: 'text', text }] },
|
||||||
// which the loop passes straight back to the model.
|
// which the loop passes straight back to the model.
|
||||||
@@ -14,16 +11,13 @@ import { checkSpec } from '../runner/check.js';
|
|||||||
import { parseSpec } from '../runner/spec.js';
|
import { parseSpec } from '../runner/spec.js';
|
||||||
import { describeProfile, codeListValues } from './describe.js';
|
import { describeProfile, codeListValues } from './describe.js';
|
||||||
import { lookupValues } from './lookup.js';
|
import { lookupValues } from './lookup.js';
|
||||||
import { run } from '../runner/run.js';
|
|
||||||
import { scopedQuery } from '../db.js';
|
import { scopedQuery } from '../db.js';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { ATTACHMENT_TYPES } from '../chat/vocab.js';
|
|
||||||
|
|
||||||
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
|
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
|
||||||
const RUN_TIMEOUT = Number(process.env.RUN_TIMEOUT || 60) * 1000;
|
|
||||||
|
|
||||||
// buildTools(profiles, session, mode) → { handlers }
|
// buildTools(profiles, session) → { handlers }
|
||||||
export function buildTools(profiles, session, mode = 'refine') {
|
export function buildTools(profiles, session) {
|
||||||
const profileList = () => [...profiles.values()].map(p => ({ id: p.uri, title: p.title }));
|
const profileList = () => [...profiles.values()].map(p => ({ id: p.uri, title: p.title }));
|
||||||
|
|
||||||
const findProfile = (id) => profiles.get(id);
|
const findProfile = (id) => profiles.get(id);
|
||||||
@@ -42,7 +36,7 @@ export function buildTools(profiles, session, mode = 'refine') {
|
|||||||
const p = findProfile(parsed.profileUri);
|
const p = findProfile(parsed.profileUri);
|
||||||
if (!p) return text(`no profile <${parsed.profileUri}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
|
if (!p) return text(`no profile <${parsed.profileUri}>. Available: ${profileList().map(x => x.title).join(', ')}.`);
|
||||||
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
|
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
|
||||||
// In refinement the spec that checks out becomes the attached proposal.
|
// The spec that checks out becomes the attached proposal.
|
||||||
if (!errors.length) session.onSpecValidated?.(spec);
|
if (!errors.length) session.onSpecValidated?.(spec);
|
||||||
return text(errors.length ? errors[0] : 'ok');
|
return text(errors.length ? errors[0] : 'ok');
|
||||||
}
|
}
|
||||||
@@ -68,34 +62,6 @@ export function buildTools(profiles, session, mode = 'refine') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run_report({ spec }) {
|
|
||||||
let parsed;
|
|
||||||
try { parsed = parseSpec(spec); }
|
|
||||||
catch (e) { return text(`the spec did not parse: ${e.message}`); }
|
|
||||||
const p = findProfile(parsed.profileUri);
|
|
||||||
if (!p) return text(`no profile <${parsed.profileUri}>.`);
|
|
||||||
const errors = checkSpec(parsed, p, MAX_PATH_DEPTH, profiles);
|
|
||||||
if (errors.length) return text(`the spec is not valid: ${errors[0]}`);
|
|
||||||
// The executed spec is the one to keep: attach it with the turn's answer.
|
|
||||||
session.onSpecValidated?.(spec);
|
|
||||||
try {
|
|
||||||
// The spec's dct:title names the report.
|
|
||||||
const name = parsed.title || 'report';
|
|
||||||
const waiter = newRunWaiter(RUN_TIMEOUT);
|
|
||||||
const running = run(session.query, session.update, parsed, p, name, { spec, fileType: ATTACHMENT_TYPES.result }, waiter.tick);
|
|
||||||
// The end callback also fires after the wait 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));
|
|
||||||
const result = await waiter.finish(running);
|
|
||||||
return text(`done. report <${result.reportUri}>, ${result.rowCount} rows, file <${result.fileUri}>.`);
|
|
||||||
} catch (e) {
|
|
||||||
return text(`the report failed: ${String(e.message || e).split('\n')[0]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A spec bijlage next to a message. The model opens it with read_spec:
|
// A spec bijlage next to a message. The model opens it with read_spec:
|
||||||
// only files this service made are readable, hte spec files under share.
|
// only files this service made are readable, hte spec files under share.
|
||||||
async function read_spec({ file_uri }) {
|
async function read_spec({ file_uri }) {
|
||||||
@@ -117,7 +83,7 @@ export function buildTools(profiles, session, mode = 'refine') {
|
|||||||
['describe_profile', describe_profile],
|
['describe_profile', describe_profile],
|
||||||
['validate_spec', validate_spec],
|
['validate_spec', validate_spec],
|
||||||
['read_spec', read_spec],
|
['read_spec', read_spec],
|
||||||
...(mode === 'execute' ? [['run_report', run_report]] : [['lookup_values', lookup_values]]),
|
['lookup_values', lookup_values],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -125,32 +91,3 @@ export function buildTools(profiles, session, mode = 'refine') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function text(s) { return { content: [{ type: 'text', text: s }] }; }
|
function text(s) { return { content: [{ type: 'text', text: s }] }; }
|
||||||
|
|
||||||
// Waits for a report run while it makes progress. tick restarts the silence
|
|
||||||
// clock; idleMs without a single tick rejects. The run is never cancelled:
|
|
||||||
// finish's handlers only pass its own settlement on, so a run that answers
|
|
||||||
// after the wait gave up still delivers through the end callback.
|
|
||||||
function newRunWaiter(idleMs) {
|
|
||||||
let deferred;
|
|
||||||
const promise = new Promise((resolve, reject) => { deferred = { resolve, reject }; });
|
|
||||||
let settled = false;
|
|
||||||
let timer = setTimeout(fail, idleMs);
|
|
||||||
function fail() {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
deferred.reject(new Error(`no query answered for ${idleMs / 1000}s`));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
tick() {
|
|
||||||
if (settled) return;
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(fail, idleMs);
|
|
||||||
},
|
|
||||||
finish(running) {
|
|
||||||
running.then(
|
|
||||||
(r) => { settled = true; clearTimeout(timer); deferred.resolve(r); },
|
|
||||||
(e) => { settled = true; clearTimeout(timer); deferred.reject(e); });
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
// Execute turns skip the LLM loop. The mode classifier says the user wants
|
// Execute turns skip the LLM loop. The mode classifier says the user wants
|
||||||
// to run the report now; the agreed spec already sits in the conversation
|
// to run the report now; the agreed spec already sits in the conversation
|
||||||
// as a bijlage, so it is read from disk and fed straight to the runner.
|
// as a bijlage, so it is read from disk and fed straight to the runner.
|
||||||
// The answer is hard-coded: no extra prompt runs for it. When anything on
|
// The answer is hard-coded: no extra prompt runs for it. When the run cannot
|
||||||
// the fast path fails, the turn falls back to the LLM loop as before.
|
// happen or fails, the answer is a hard-coded failure message: nothing is
|
||||||
|
// retried and no spec is rewritten.
|
||||||
import { classifyMode } from './llm/mode.js';
|
import { classifyMode } from './llm/mode.js';
|
||||||
import { ask } from './llm/loop.js';
|
import { ask } from './llm/loop.js';
|
||||||
import { parseSpec } from './runner/spec.js';
|
import { parseSpec } from './runner/spec.js';
|
||||||
@@ -17,6 +18,9 @@ import { SPEC_MEDIA_TYPE, ATTACHMENT_TYPES } from './chat/vocab.js';
|
|||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
executed: 'Het rapport is uitgevoerd. De bijlagen staan erbij.',
|
executed: 'Het rapport is uitgevoerd. De bijlagen staan erbij.',
|
||||||
|
noSpec: 'Er is nog geen voorstel om uit te voeren. Beschrijf eerst welk rapport je wilt.',
|
||||||
|
invalid: 'Het voorstel is niet meer geldig. Vraag een nieuw voorstel.',
|
||||||
|
failed: 'Het rapport kon niet uitgevoerd worden. Probeer het later opnieuw.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
|
const MAX_PATH_DEPTH = Number(process.env.MAX_PATH_DEPTH || 8);
|
||||||
@@ -32,25 +36,32 @@ export function reportAssistant(whenProfiles) {
|
|||||||
try {
|
try {
|
||||||
return await runStoredSpec(turn, profiles);
|
return await runStoredSpec(turn, profiles);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[reports] direct execution failed, falling back to the LLM:', e);
|
console.error('[reports] execution failed:', e);
|
||||||
|
return TEXT.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return llmAnswer(turn, turns, profiles);
|
return llmAnswer(turn, turns, profiles);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fast path: the last spec bijlage in the conversation is the agreed
|
// Execution: the last spec bijlage in the conversation is the agreed spec.
|
||||||
// spec. No prompt runs; the runner gets it straight away.
|
// No prompt runs; the runner gets it straight away.
|
||||||
async function runStoredSpec(turn, profiles) {
|
async function runStoredSpec(turn, profiles) {
|
||||||
const attachment = lastSpecAttachment(turn.history);
|
const attachment = lastSpecAttachment(turn.history);
|
||||||
const content = attachment && readSpecFile(attachment.name);
|
const content = attachment && readSpecFile(attachment.name);
|
||||||
if (!content) throw new Error('no agreed spec in the conversation');
|
if (!content) return TEXT.noSpec;
|
||||||
|
|
||||||
const parsed = parseSpec(content);
|
const parsed = parseSpec(content);
|
||||||
const profile = profiles.get(parsed.profileUri);
|
const profile = profiles.get(parsed.profileUri);
|
||||||
if (!profile) throw new Error(`no profile <${parsed.profileUri}>`);
|
if (!profile) {
|
||||||
|
console.error(`[reports] no profile <${parsed.profileUri}>`);
|
||||||
|
return TEXT.invalid;
|
||||||
|
}
|
||||||
const errors = checkSpec(parsed, profile, MAX_PATH_DEPTH, profiles);
|
const errors = checkSpec(parsed, profile, MAX_PATH_DEPTH, profiles);
|
||||||
if (errors.length) throw new Error(`the agreed spec is not valid: ${errors[0]}`);
|
if (errors.length) {
|
||||||
|
console.error(`[reports] the agreed spec is not valid: ${errors[0]}`);
|
||||||
|
return TEXT.invalid;
|
||||||
|
}
|
||||||
|
|
||||||
// The conversation's title (named by the first question) names the file.
|
// The conversation's title (named by the first question) names the file.
|
||||||
// The report itself keeps the dct:title of the spec.
|
// The report itself keeps the dct:title of the spec.
|
||||||
@@ -96,32 +107,20 @@ function readSpecFile(fileName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The LLM loop, refinement and execute mode. LLM answer with the
|
// The LLM loop, refinement only. The spec that checked out goes on the
|
||||||
// conversation so far; the agreed spec goes on the proposal, and once a
|
// answer as a bijlage. The prompt never carries the Turtle.
|
||||||
// report runs, its CSV (registered by the runner as a real file) and the
|
|
||||||
// spec go on the same turn. The prompt never carries the Turtle.
|
|
||||||
async function llmAnswer(turn, turns, profiles) {
|
async function llmAnswer(turn, turns, profiles) {
|
||||||
// The file uri of the executed report, set by the end callback.
|
|
||||||
let fileUri = null;
|
|
||||||
// The last spec that checked out; stored as a file for this turn's
|
// The last spec that checked out; stored as a file for this turn's
|
||||||
// final message.
|
// final message.
|
||||||
let spec = null;
|
let spec = null;
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
query: turn.query,
|
|
||||||
update: turn.update,
|
|
||||||
onSpecValidated(validated) { spec = validated; },
|
onSpecValidated(validated) { spec = validated; },
|
||||||
async onReportEnd(error, result) {
|
|
||||||
if (!error && result) fileUri = result.fileUri;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { text } = await ask(turns, profiles, session);
|
const { text } = await ask(turns, profiles, session);
|
||||||
|
|
||||||
// The bijlagen of this turn, all with a real file behind them: the
|
|
||||||
// executed report's CSV first, next the validated spec.
|
|
||||||
const attachments = [];
|
const attachments = [];
|
||||||
if (fileUri) attachments.push({ uri: fileUri });
|
|
||||||
if (spec) {
|
if (spec) {
|
||||||
const stored = await storeSpecFile(turn.update, spec);
|
const stored = await storeSpecFile(turn.update, spec);
|
||||||
attachments.push({ ...stored, mediaType: SPEC_MEDIA_TYPE });
|
attachments.push({ ...stored, mediaType: SPEC_MEDIA_TYPE });
|
||||||
|
|||||||
@@ -7,20 +7,13 @@ import { sparqlEscapeUri } from '../db.js';
|
|||||||
|
|
||||||
const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100);
|
const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100);
|
||||||
|
|
||||||
export async function fetchColumns(sessionQuery, subjects, spec, values = new Map(), onProgress = null) {
|
export async function fetchColumns(sessionQuery, subjects, spec, values = new Map()) {
|
||||||
const groups = groupColumns(spec.columns);
|
const groups = groupColumns(spec.columns);
|
||||||
// The subject list is complete and the chunk size is fixed, so the number
|
|
||||||
// of batch queries is known before the first one flies. onProgress fires
|
|
||||||
// after every answered batch with { done, total }.
|
|
||||||
const total = groups.length * Math.ceil(subjects.length / SUBJECT_CHUNK_SIZE);
|
|
||||||
let done = 0;
|
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
for (let i = 0; i < subjects.length; i += SUBJECT_CHUNK_SIZE) {
|
for (let i = 0; i < subjects.length; i += SUBJECT_CHUNK_SIZE) {
|
||||||
const chunk = subjects.slice(i, i + SUBJECT_CHUNK_SIZE);
|
const chunk = subjects.slice(i, i + SUBJECT_CHUNK_SIZE);
|
||||||
const result = await sessionQuery(groupQuery(group, chunk));
|
const result = await sessionQuery(groupQuery(group, chunk));
|
||||||
collect(result, group, values);
|
collect(result, group, values);
|
||||||
done += 1;
|
|
||||||
onProgress?.({ done, total });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
|
|||||||
@@ -6,18 +6,14 @@ import { registerFile, registerReport } from './report.js';
|
|||||||
import { startShape } from './profile.js';
|
import { startShape } from './profile.js';
|
||||||
|
|
||||||
// Ties the steps together: seed → columns → assemble → CSV → register.
|
// Ties the steps together: seed → columns → assemble → CSV → register.
|
||||||
// Returns { reportUri, fileUri, rowCount }. onProgress fires on every
|
// Returns { reportUri, fileUri, rowCount }.
|
||||||
// answered query with what the phase knows — { subjects } per seed page,
|
|
||||||
// { done, total } per column batch — so a waiter can tell a live run from
|
|
||||||
// a stalled one.
|
|
||||||
|
|
||||||
export async function run(query, update, parsed, profile, title, extra = {}, onProgress = null) {
|
export async function run(query, update, parsed, profile, title, extra = {}) {
|
||||||
const shape = startShape(profile, parsed);
|
const shape = startShape(profile, parsed);
|
||||||
const subjects = await seed(query, parsed, shape,
|
const subjects = await seed(query, parsed, shape);
|
||||||
onProgress ? (n) => onProgress({ subjects: n }) : null);
|
|
||||||
|
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
await fetchColumns(query, subjects, parsed, values, null, onProgress);
|
await fetchColumns(query, subjects, parsed, values);
|
||||||
|
|
||||||
const rows = assemble(subjects, values, parsed);
|
const rows = assemble(subjects, values, parsed);
|
||||||
const fileName = extra.fileName || `${slug(title)}.csv`;
|
const fileName = extra.fileName || `${slug(title)}.csv`;
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime, sparqlEscapeDate } from '../db.js';
|
import { sparqlEscapeUri, sparqlEscapeString, sparqlEscapeDateTime, sparqlEscapeDate } from '../db.js';
|
||||||
|
|
||||||
// Subject selection. One paged query per page; stops at ROW_LIMIT.
|
// Subject selection. One paged query per page; stops at ROW_LIMIT.
|
||||||
// onPage(n) fires after every answered page with the subject count so far,
|
|
||||||
// so a caller waiting on this run can tell a live one from a stalled one.
|
|
||||||
const ROW_LIMIT = Number(process.env.ROW_LIMIT || 200000);
|
const ROW_LIMIT = Number(process.env.ROW_LIMIT || 200000);
|
||||||
const PAGE_SIZE = Number(process.env.SEED_PAGE_SIZE || 5000);
|
const PAGE_SIZE = Number(process.env.SEED_PAGE_SIZE || 5000);
|
||||||
|
|
||||||
export async function seed(sessionQuery, spec, shape = null, onPage = null) {
|
export async function seed(sessionQuery, spec, shape = null) {
|
||||||
const subjects = [];
|
const subjects = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -14,7 +12,6 @@ export async function seed(sessionQuery, spec, shape = null, onPage = null) {
|
|||||||
const result = await sessionQuery(query);
|
const result = await sessionQuery(query);
|
||||||
const rows = result.results.bindings.map(b => b.s.value);
|
const rows = result.results.bindings.map(b => b.s.value);
|
||||||
subjects.push(...rows);
|
subjects.push(...rows);
|
||||||
onPage?.(subjects.length);
|
|
||||||
if (subjects.length > ROW_LIMIT) {
|
if (subjects.length > ROW_LIMIT) {
|
||||||
throw new Error(`the question matches more than ${ROW_LIMIT} subjects. Add a filter, for example a date range or a bestuurseenheid.`);
|
throw new Error(`the question matches more than ${ROW_LIMIT} subjects. Add a filter, for example a date range or a bestuurseenheid.`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user