2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00
WIP
2026-09-14 13:57:42 +02:00
2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00
WIP
2026-09-14 13:57:42 +02:00
2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00
2026-09-14 15:34:29 +02:00

natural-language-report-service

A user asks for a report in Dutch. An LLM turns the question into a spec. A fixed program turns the spec into small SPARQL queries, joins the results and writes a CSV. The LLM writes no SPARQL and sees no report data.

Ad-hoc reports only. The scheduled reports stay in loket-report-generation-service.

How it works

Two documents drive everything (plan §1, §2, §3):

Who writes it When Where
profile the app once, committed config/report-profiles/*.ttl, mounted at /config/profiles/
spec the LLM one per question in the request, not stored

A profile is a SHACL shapes graph: one sh:NodeShape per entity, fields as sh:property, code lists via sh:class, links via sh:node. The service ships no profile and knows no vocabulary; profiles live in the app repo.

A spec is rep:ReportSpec + sh:NodeShape: sh:property selects the subjects, rep:columns says what the CSV holds. The two never interact. The namespace is http://mu.semte.ch/vocabularies/reporting/ (rep:).

The run: check → seed (SELECT DISTINCT ?s, paged) → columns (per group, per chunk of subjects) → assemble (dedup + rep:collect) → CSV → register file

  • report resource. The job is a cogs:Job + task:Task we write ourselves, with mu-call-scope-id set so jobs-controller ignores it.

One identity: mu-authorization's

All SPARQL goes through the template's own helpers (mu's query and update), like ipdc-bookmarks-service. They attach the caller's session from the request context, and mu-authorization decides graphs and visibility; the service never touches an auth header. The context lives for the whole run, also past the 202 the chat turn answers with, so background writes stay the caller's.

The tools the LLM calls return URIs, counts, statuses and column names — never a cell value.

Two endpoints

POST /ask                                     { question, history? } → { answer, toolCalls }
POST /assistant/conversations/:id/turns      { content }           → 202 { id }

/ask is synchronous: a Dutch question in, the agent loop runs inside the service (profiles, lookups, spec, validate, repair, 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.

/assistant is the chat (plan-fable-5-1, steps 1 and 3). The turn module in 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 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 report run becomes an interim message with a pending file (as:Document with no as:url); the URL is set when the run ends. The browser polls /chat-conversations/:id?include=messages.attachments and finds the answer 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 run takes minutes.

The job is a resource: read it at /jobs/:id, or watch the dashboard.

The internal tools

The loop drives eight tool handlers (src/llm/tools.js): list_profiles, describe_profile, validate_spec, lookup_values, preview_queries, run_report, report_status, export_spec. They are machinery, not an external surface — the tool trace in the answer is the only thing a caller sees of them.

Environment

Var Default Meaning
MU_SPARQL_ENDPOINT — the auth layer, e.g. http://database:8890/sparql. Never virtuoso.
REPORT_CLASS …/reporting/Report type of the report resource
CSV_SEPARATOR ; CSV column separator
SUBJECT_CHUNK_SIZE 250 subjects per column query
ROW_LIMIT 200000 seed limit; failing the job beats truncating
MAX_PATH_DEPTH 8 longest allowed spec path
RUN_TIMEOUT 60 seconds run_report waits before giving up. Full-org runs need 300.
INLINE_VALUES_MAX 50 code lists up to this size are inlined in describe_profile
VALUES_TTL 3600 seconds the inlined lists are cached
LLM_BASE_URL — enables /ask; OpenAI-compatible (https://ollama.com/v1)
LLM_MODEL — e.g. gpt-oss:120b
LLM_API_KEY — bearer token, optional for local Ollama
SEED_PAGE_SIZE 5000 seed page size
SUBJECT_CHUNK_SIZE 250 see above
SHARE_DIR /share where CSVs are written
CHAT_ASSISTANT_URI — the prov:SoftwareAgent that signs assistant messages; found by lookup when unset
CHAT_HISTORY_LIMIT 20 messages of history a turn reads

Do not set ALLOW_MU_AUTH_SUDO; the template then refuses sudo queries.

In the stack

docker-compose.yml:

natural-language-report:
  build: ../natural-language-report-service/
  image: lblod/natural-language-report-service:0.1.0
  volumes:
    - ./data/files:/share
    - ./config/report-profiles/:/config/profiles/

config/dispatcher/dispatcher.ex:

match "/natural-language-reports/*path" do
  forward conn, path, "http://natural-language-report/"
end

One route to the service root; POST /ask is the endpoint. First deploy: docker compose build natural-language-report, then docker compose up -d natural-language-report dispatcher.

Authorization

Admin only: config.ex grants reporting:Report to LoketAdmin (o-admin-rwf) alone. A toezicht user can run the seed and read the CSV data, but the reporting:Report triples are silently dropped by the auth layer (their group may not write that type), so the report URI the tools return is dangling for non-admin callers. Opening this up means adding the type to their group first.

Rules the code holds to (plan §9)

  • Query http://database:8890/sparql. Never virtuoso. Never sudo.
  • No GRAPH clauses, no property paths, no aggregates, no inline comments: mu-authorization is a SPARQL 1.0 subset.
  • Generated queries use full URIs, no prefixes (a bare mu:uuid prefixed name without its PREFIX line is an opaque 500).

Libraries (plan §0)

  • n3 — Turtle parsing (profiles, specs)
  • hand-written: CSV writer (quotes only when needed, like today's reports), the query builders, the agent loop (bare fetch, no provider SDK, no MCP SDK — the tools are internal handlers, not an HTTP protocol)

What it will not do

No counting, no sorting, no top-N, no comparing two subjects, one class per report, suggestions stop at the public graph. §10 of the plan has the full list and why.

Description
No description provided
Readme 1.7 MiB
Languages
JavaScript 99.9%