Compare commits
7 Commits
v0.3.0
...
1f80fdc815
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f80fdc815 | ||
|
|
538a2d949e | ||
|
|
b1fa1d1cd2 | ||
|
|
0802b75346 | ||
|
|
78a7c42a37 | ||
|
|
80097d3130 | ||
|
|
57292fa052 |
345
README.md
345
README.md
@@ -41,26 +41,330 @@ Two documents drive everything:
|
|||||||
| **profile** | the app | once, committed | `config/report-profiles/*.ttl`, mounted at `/config/profiles/` |
|
| **profile** | the app | once, committed | `config/report-profiles/*.ttl`, mounted at `/config/profiles/` |
|
||||||
| **spec** | the LLM | one per question | in the request, not stored |
|
| **spec** | the LLM | one per question | in the request, not stored |
|
||||||
|
|
||||||
A **profile** is a SHACL shapes graph: one `sh:NodeShape` per entity, fields
|
The profile says what the data looks like. The spec says what one report
|
||||||
as `sh:property`, code lists via `sh:class`, links via `sh:node`. The service
|
holds. Both are Turtle, and both borrow the words of SHACL, the W3C language
|
||||||
ships no profile and knows no vocabulary; profiles live in the app repo.
|
to describe RDF data. Neither is used the way a SHACL tool uses it: see "How
|
||||||
Profiles load at boot; a broken profile stops the service.
|
this relates to SHACL" below.
|
||||||
|
|
||||||
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:`).
|
|
||||||
|
|
||||||
A filter tests only the end of its own path. To test a node in the middle of
|
|
||||||
a path ("the mandate whose role is Burgemeester"), a filter or column carries
|
|
||||||
`rep:where`: a condition written like a filter, from the row, that shares the
|
|
||||||
nodes of every step it has in common with its host. On a column it drops
|
|
||||||
values from the cell, never rows. The app's `report-spec-readme.md` has the
|
|
||||||
details and an example.
|
|
||||||
|
|
||||||
The run: check → seed (`SELECT DISTINCT ?s`, paged) → columns (per group, per
|
The run: check → seed (`SELECT DISTINCT ?s`, paged) → columns (per group, per
|
||||||
chunk of subjects) → assemble (dedup + `rep:collect`) → CSV → register file
|
chunk of subjects, with the nodes on each path) → assemble (pair by the
|
||||||
|
nodes columns share, on every step; `rep:collect`) → CSV → register file
|
||||||
+ report resource. One run per confirmed spec.
|
+ report resource. One run per confirmed spec.
|
||||||
|
|
||||||
|
## The profile: a map of the data
|
||||||
|
|
||||||
|
A profile (using SHACL + some extensions) lists the kinds of things in the data (entities), what each one
|
||||||
|
has (fields), and which fields lead to other entities (links). The app
|
||||||
|
writes it; the service ships no profile and knows no vocabulary.
|
||||||
|
|
||||||
|
Three parts of the service read it:
|
||||||
|
|
||||||
|
- the validator checks every step of a spec's paths against it;
|
||||||
|
- the LLM gets it as a menu (`describe_profile`), its only picture of the
|
||||||
|
data;
|
||||||
|
- the seed takes the class of the rows from it.
|
||||||
|
|
||||||
|
Nothing checks the data against a profile.
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
<http://data.lblod.info/id/report-profiles/mandaten> a owl:Ontology ;
|
||||||
|
dct:title "Mandaten" .
|
||||||
|
|
||||||
|
ent:Mandataris a sh:NodeShape ;
|
||||||
|
rdfs:label "mandataris" ;
|
||||||
|
sh:targetClass mandaat:Mandataris ;
|
||||||
|
sh:property [ sh:path mandaat:start ; sh:name "start" ; sh:datatype xsd:dateTime ] ;
|
||||||
|
sh:property [ sh:path org:holds ; sh:name "bekleedt mandaat" ; sh:node ent:Mandaat ] ;
|
||||||
|
sh:property [ sh:path mandaat:isBestuurlijkeAliasVan ; sh:name "persoon" ; sh:node ent:Persoon ] .
|
||||||
|
```
|
||||||
|
|
||||||
|
| You write | It means |
|
||||||
|
|---|---|
|
||||||
|
| `owl:Ontology` + `dct:title` | the profile. A spec names this URI in `rep:profile`. |
|
||||||
|
| `sh:NodeShape` | an entity. A spec names this URI in `rep:entity` when it must pick one. |
|
||||||
|
| `rdfs:label` on the entity | its name in the menu and in error messages. Keep it unique in the profile. |
|
||||||
|
| `sh:targetClass` | the `rdf:type` of the entity's subjects |
|
||||||
|
| `sh:property` + `sh:path` | a field: one step, a predicate or `[ sh:inversePath p ]` |
|
||||||
|
| `sh:name` | the field's name in the menu and in messages |
|
||||||
|
| `sh:datatype` | a value field: a column may end here |
|
||||||
|
| `sh:class` | a code-list field: a column may end here. Lists up to `INLINE_VALUES_MAX` values show in the menu. |
|
||||||
|
| `sh:node` | a link to another entity: a spec may take this step |
|
||||||
|
| `sh:or ( [ sh:node A ] [ sh:node B ] )` | a link that reaches A or B |
|
||||||
|
|
||||||
|
The service does not read `sh:description` (write it for people; the LLM
|
||||||
|
never sees it), cardinality (every link counts as "many"), or a field path
|
||||||
|
of more than one step.
|
||||||
|
|
||||||
|
At boot each profile must parse, name itself with `owl:Ontology` and hold at
|
||||||
|
least one entity; a broken profile stops the service. A `sh:node` to an
|
||||||
|
entity that does not exist only shows when a spec takes that step.
|
||||||
|
|
||||||
|
### A link to several kinds
|
||||||
|
|
||||||
|
When one step can reach entities of different kinds, say so with `sh:or`:
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
sh:property [ sh:path [ sh:inversePath besluit:bestuurt ] ; sh:name "bestuursorganen" ;
|
||||||
|
sh:or ( [ sh:node ent:Bestuursorgaan ] [ sh:node ent:LeidinggevendBestuursorgaan ] ) ] .
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator follows all of them, and the next steps decide. A spec that
|
||||||
|
wants one kind adds a `rep:where` on a step only that kind has. Write one
|
||||||
|
field with `sh:or`, not two fields on one path: in SHACL, two fields mean a
|
||||||
|
value must fit both. (The validator reads two fields like `sh:or`, but a
|
||||||
|
SHACL reader would not.)
|
||||||
|
|
||||||
|
### Entities that share a class
|
||||||
|
|
||||||
|
A profile may hold several entities of one class. The portal's
|
||||||
|
`mandaten.ttl` has four kinds of `besluit:Bestuursorgaan`: political and
|
||||||
|
leidinggevend, each fixed (vast) and in time. A spec picks one with
|
||||||
|
`rep:entity`; `rep:discriminator` tells the seed which subjects belong to
|
||||||
|
it.
|
||||||
|
|
||||||
|
A discriminator is written like a spec filter (see "Which rows" below): a
|
||||||
|
`sh:path` from the subject, of any length, and a test on where it ends.
|
||||||
|
Several on one entity must all hold. `sh:and ( ... )`, `sh:or ( ... )` and
|
||||||
|
`sh:not ...` combine them, and may nest.
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
# fixed, and its classification is in the list
|
||||||
|
ent:LeidinggevendBestuursorgaan
|
||||||
|
rep:discriminator [ sh:path generiek:isTijdspecialisatieVan ; sh:maxCount 0 ] ;
|
||||||
|
rep:discriminator [ sh:path org:classification ; sh:in ent:LeidinggevendeOrgaanClassificaties ] .
|
||||||
|
|
||||||
|
# in time, and the classification of its fixed organ is not in the list
|
||||||
|
ent:BestuursorgaanInTijd
|
||||||
|
rep:discriminator [ sh:path generiek:isTijdspecialisatieVan ; sh:minCount 1 ] ;
|
||||||
|
rep:discriminator [ sh:path ( generiek:isTijdspecialisatieVan org:classification ) ;
|
||||||
|
sh:in ent:LeidinggevendeOrgaanClassificaties ; sh:maxCount 0 ] .
|
||||||
|
```
|
||||||
|
|
||||||
|
A rule or a list can be a named node, written once and used in several
|
||||||
|
places. `sh:not` on a named rule gives its exact opposite, so two entities
|
||||||
|
split a class with no overlap and no gap:
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
ent:IsLeidinggevendOrgaan sh:path org:classification ;
|
||||||
|
sh:in ent:LeidinggevendeOrgaanClassificaties .
|
||||||
|
|
||||||
|
ent:LeidinggevendBestuursorgaan rep:discriminator ent:IsLeidinggevendOrgaan .
|
||||||
|
ent:Bestuursorgaan rep:discriminator [ sh:not ent:IsLeidinggevendOrgaan ] .
|
||||||
|
```
|
||||||
|
|
||||||
|
Turtle cannot name a `( ... )` list, so write a named list out:
|
||||||
|
`ent:X rdf:first <a> ; rdf:rest ( <b> <c> ) .` Do not give a named rule the
|
||||||
|
type `sh:NodeShape`: it would become an entity.
|
||||||
|
|
||||||
|
Test what a thing is, not what hangs off it. The classification says what
|
||||||
|
an organ is. "Has a period with a bestuursfunctie" needs no code list, but
|
||||||
|
it trusts the data: an organ that lost its functie would move to the other
|
||||||
|
entity, and no report could find it. `sh:or` of both catches the most.
|
||||||
|
|
||||||
|
The seed turns a plain discriminator into the same SPARQL as a filter.
|
||||||
|
`sh:and`, `sh:or` and `sh:not` become one `FILTER(...)` over `EXISTS` tests;
|
||||||
|
that form is not yet tested against mu-authorization or sparql-parser. A
|
||||||
|
broken discriminator stops the service at boot and names the entity.
|
||||||
|
|
||||||
|
## The spec: one report
|
||||||
|
|
||||||
|
The LLM writes one spec per question. The user reads the proposal and
|
||||||
|
confirms; the service then checks the spec again and runs it. A spec has two
|
||||||
|
halves: which rows, and what the CSV shows about each row.
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
<http://data.lblod.info/id/report-specs/burgemeesters> a rep:ReportSpec , sh:NodeShape ;
|
||||||
|
dct:title "Burgemeesters sinds 2025" ;
|
||||||
|
rep:profile <http://data.lblod.info/id/report-profiles/mandaten> ;
|
||||||
|
sh:targetClass mandaat:Mandataris ;
|
||||||
|
sh:property [ sh:path ( org:holds org:role ) ;
|
||||||
|
sh:hasValue <http://data.vlaanderen.be/id/concept/BestuursfunctieCode/5ab0e9b8a3b2ca7c5e000013> ] ;
|
||||||
|
sh:property [ sh:path mandaat:start ;
|
||||||
|
sh:minInclusive "2025-01-01T00:00:00"^^xsd:dateTime ] ;
|
||||||
|
rep:columns (
|
||||||
|
[ sh:path rep:self ; rdfs:label "mandataris" ]
|
||||||
|
[ sh:path ( mandaat:isBestuurlijkeAliasVan foaf:givenName ) ; rdfs:label "voornaam" ]
|
||||||
|
[ sh:path ( mandaat:isBestuurlijkeAliasVan foaf:familyName ) ; rdfs:label "achternaam" ]
|
||||||
|
[ sh:path ( org:holds [ sh:inversePath org:hasPost ] generiek:isTijdspecialisatieVan
|
||||||
|
besluit:bestuurt skos:prefLabel ) ; rdfs:label "gemeente" ]
|
||||||
|
) .
|
||||||
|
```
|
||||||
|
|
||||||
|
One row per mandataris whose mandate has the role Burgemeester and who
|
||||||
|
started on or after 1 January 2025, with name and gemeente.
|
||||||
|
|
||||||
|
### Which rows
|
||||||
|
|
||||||
|
| You write | It means |
|
||||||
|
|---|---|
|
||||||
|
| `rep:ReportSpec` + `sh:NodeShape` | this is a spec |
|
||||||
|
| `rep:profile` | the profile the spec was written against |
|
||||||
|
| `sh:targetClass` | one row per subject of this class |
|
||||||
|
| `rep:entity` | which entity, when more than one has this class |
|
||||||
|
| `sh:property` | a filter: keep only the subjects that pass |
|
||||||
|
|
||||||
|
A filter has a `sh:path` from the row and a test. When the path reaches
|
||||||
|
several values, the test passes if one of them passes.
|
||||||
|
|
||||||
|
| Test | Keeps the row when |
|
||||||
|
|---|---|
|
||||||
|
| `sh:hasValue x` | the path reaches x |
|
||||||
|
| `sh:in ( x y )` | the path reaches x or y (all URIs, or all text) |
|
||||||
|
| `sh:minInclusive`, `sh:maxInclusive`, `sh:minExclusive`, `sh:maxExclusive` | a date or number at the end lies in range; the path must end on a value field |
|
||||||
|
| `sh:pattern` (+ `sh:flags`) | a value matches the regex |
|
||||||
|
| `rep:anyOf ( "woord" ... )` | a value holds one of these words, upper or lower case; at most 24 words, no URIs |
|
||||||
|
| `sh:minCount 1` | the path leads somewhere |
|
||||||
|
| `sh:minCount 2` | the path reaches two different values |
|
||||||
|
| `sh:maxCount 0` | the path leads nowhere |
|
||||||
|
| `sh:maxCount 0` with `sh:in` or `sh:hasValue` | the path reaches none of these values |
|
||||||
|
|
||||||
|
The service cannot count: `sh:minCount` above 2 and `sh:maxCount` above 0 are
|
||||||
|
refused.
|
||||||
|
|
||||||
|
### A test on the way: `rep:where`
|
||||||
|
|
||||||
|
A filter tests the end of its path. `rep:where` tests a node on the way.
|
||||||
|
Write it like a filter, with its path from the row. The steps it shares with
|
||||||
|
its column or filter are the same nodes; its test applies where its own path
|
||||||
|
ends. On a filter it drops rows; on a column it drops values from the cell,
|
||||||
|
never rows.
|
||||||
|
|
||||||
|
```turtle
|
||||||
|
<http://data.lblod.info/id/report-specs/eenheden-burgemeester> a rep:ReportSpec , sh:NodeShape ;
|
||||||
|
rep:profile <http://data.lblod.info/id/report-profiles/mandaten> ;
|
||||||
|
sh:targetClass besluit:Bestuurseenheid ;
|
||||||
|
rep:columns (
|
||||||
|
[ sh:path skos:prefLabel ; rdfs:label "eenheid" ]
|
||||||
|
[ sh:path ( [ sh:inversePath besluit:bestuurt ] [ sh:inversePath generiek:isTijdspecialisatieVan ]
|
||||||
|
org:hasPost [ sh:inversePath org:holds ] mandaat:isBestuurlijkeAliasVan foaf:familyName ) ;
|
||||||
|
rdfs:label "burgemeester" ;
|
||||||
|
rep:where [ sh:path ( [ sh:inversePath besluit:bestuurt ] [ sh:inversePath generiek:isTijdspecialisatieVan ]
|
||||||
|
org:hasPost org:role ) ;
|
||||||
|
sh:hasValue <http://data.vlaanderen.be/id/concept/BestuursfunctieCode/5ab0e9b8a3b2ca7c5e000013> ] ]
|
||||||
|
) .
|
||||||
|
```
|
||||||
|
|
||||||
|
Per eenheid, the family name of its mayors. The `rep:where` shares three
|
||||||
|
steps with the column, so it tests the mandate, not the organ or the
|
||||||
|
person. `validate_spec` says so: `rep:where 1 on column "burgemeester"
|
||||||
|
applies at "mandaat", after step 3`. The proposal repeats that line to the
|
||||||
|
user.
|
||||||
|
|
||||||
|
A `rep:where` must share at least one step with its column or filter, and
|
||||||
|
holds no `rep:where` of its own.
|
||||||
|
|
||||||
|
### What the CSV shows
|
||||||
|
|
||||||
|
`rep:columns` is a list; each item becomes a column, in that order.
|
||||||
|
|
||||||
|
| You write | It means |
|
||||||
|
|---|---|
|
||||||
|
| `sh:path ( ... )` | the steps from the row to the value |
|
||||||
|
| `sh:path rep:self` | the row's own URI |
|
||||||
|
| `rdfs:label` | the column header; required and unique |
|
||||||
|
| `sh:nodeKind sh:IRI` | show the URI of the node the path ends on (only then may a path end on a link) |
|
||||||
|
| no `rep:collect`, or `rep:collect rep:row` | each value gets its own row; at most one column may say `rep:row` |
|
||||||
|
| `rep:collect sh:groupConcat` (+ `sh:separator`) | all values in one cell |
|
||||||
|
| `rep:collect sh:min` / `sh:max` | the lowest / highest date or number |
|
||||||
|
|
||||||
|
A column holds no tests; those go in `sh:property`, or in the column's
|
||||||
|
`rep:where`.
|
||||||
|
|
||||||
|
### How values become rows
|
||||||
|
|
||||||
|
Columns that walk the same steps (with the same `rep:where`) share the nodes
|
||||||
|
on those steps, and their values pair by those nodes: one mayor's first and
|
||||||
|
last name stay on one row. Where paths split and both sides have several
|
||||||
|
values, every combination becomes a row. A missing value is an empty cell;
|
||||||
|
identical rows show once. `sh:groupConcat`, `sh:min` and `sh:max` fold a
|
||||||
|
column into one cell per node it shares with the other columns.
|
||||||
|
|
||||||
|
### What the validator checks
|
||||||
|
|
||||||
|
The validator (`src/runner/check.js`) runs on every proposal and again
|
||||||
|
before every run. It returns the first problem, in words the LLM can act on.
|
||||||
|
On top of the rules above, it checks that:
|
||||||
|
|
||||||
|
- the Turtle holds one `rep:ReportSpec`, no more: one answer is one report;
|
||||||
|
- every step of every path exists in the profile;
|
||||||
|
- no path is longer than `MAX_PATH_DEPTH` steps;
|
||||||
|
- a date or number test ends on a value field, not on a link;
|
||||||
|
- a class that several entities share comes with `rep:entity`.
|
||||||
|
|
||||||
|
## How this relates to SHACL
|
||||||
|
|
||||||
|
SHACL is the W3C language to check RDF data against shapes: a SHACL tool
|
||||||
|
takes data and shapes, and reports which nodes fail. We borrow its words and
|
||||||
|
its layout, so both documents are valid SHACL syntax and a SHACL tool can
|
||||||
|
read them. We use them for other jobs.
|
||||||
|
|
||||||
|
### The profile: SHACL, with one extension
|
||||||
|
|
||||||
|
Read as SHACL, a profile says what good data looks like: nodes of this
|
||||||
|
class, values of this datatype, links to nodes of that shape. We read the
|
||||||
|
same statements as a map, and never check data with them. That is a use of
|
||||||
|
SHACL, not a change to it. One thing does change the meaning:
|
||||||
|
|
||||||
|
- **`rep:discriminator` narrows `sh:targetClass`.** In SHACL, four shapes
|
||||||
|
with `sh:targetClass besluit:Bestuursorgaan` mean that every organ must
|
||||||
|
fit all four. Here each organ belongs only to the shapes whose
|
||||||
|
discriminators it meets. A SHACL tool skips `rep:` terms, so to it the
|
||||||
|
profile still says "all four". SHACL-AF has a tool for this job, custom
|
||||||
|
targets (see below); we keep `sh:targetClass` so a spec can still name its
|
||||||
|
class, and write the rule in the same terms as a spec filter.
|
||||||
|
|
||||||
|
Two smaller points:
|
||||||
|
|
||||||
|
- In SHACL, a node reached through `sh:node B` must pass B's field rules. We
|
||||||
|
go one step further and treat it as "a B" for the next steps. If the data
|
||||||
|
breaks that, the query finds nothing there; it never finds a wrong row.
|
||||||
|
- Links run in circles (bestuursorgaan to orgaan in tijd and back). SHACL
|
||||||
|
leaves such shapes to each tool. That only matters if someone checks data
|
||||||
|
with a profile.
|
||||||
|
|
||||||
|
### The spec: SHACL syntax, our own meaning
|
||||||
|
|
||||||
|
A spec is valid SHACL syntax, but it does not mean what SHACL says. A SHACL
|
||||||
|
tool reads a spec as a check and reports which subjects fail it. We read it
|
||||||
|
as a query: the rows are the subjects that pass. For some terms the two
|
||||||
|
readings agree; for others they do not.
|
||||||
|
|
||||||
|
| Term | In SHACL | In a spec | Same? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `sh:targetClass`, `sh:path` | which nodes; which values | same | yes |
|
||||||
|
| `sh:hasValue` | one value equals | same | yes |
|
||||||
|
| `sh:minCount 1` / `2` | at least 1 / 2 values | same | yes |
|
||||||
|
| `sh:maxCount 0` | no values | same | yes |
|
||||||
|
| `sh:in`, ranges, `sh:pattern` | every value passes, and a node without values passes too | one value passes | no |
|
||||||
|
| `sh:maxCount 0` with a test | no values at all | no value that passes | no |
|
||||||
|
| `rep:anyOf`, `rep:where` | skipped | tests of our own | added |
|
||||||
|
| `rep:columns`, `rep:self`, `rep:collect`, `sh:nodeKind` and `rdfs:label` on a column | skipped | the CSV | added |
|
||||||
|
|
||||||
|
So a spec means the same in both readings only if it uses the "yes" terms
|
||||||
|
alone; then its rows are exactly the subjects a SHACL tool finds good. Most
|
||||||
|
specs use `sh:in` or a date range, so most specs do not.
|
||||||
|
|
||||||
|
Why "one value passes": reports ask for "organs with classification X" or
|
||||||
|
"persons with a burgemeester mandate". With SHACL's `sh:in`, the first
|
||||||
|
would also keep organs with no classification, and the second would drop a
|
||||||
|
person who holds a second mandate too. SHACL can say "one value"
|
||||||
|
(`sh:qualifiedValueShape` with `sh:qualifiedMinCount 1`), but the LLM writes
|
||||||
|
the specs, and each extra construct is one more way to get it wrong.
|
||||||
|
|
||||||
|
The CSV half has no SHACL meaning at all: SHACL has no notion of output.
|
||||||
|
|
||||||
|
### SHACL-AF
|
||||||
|
|
||||||
|
SHACL Advanced Features is a W3C Working Group Note from 2017: a proposal,
|
||||||
|
not a standard. It adds custom targets (`sh:SPARQLTarget`: pick nodes with a
|
||||||
|
SPARQL query), values computed from the data, rules and functions. A later
|
||||||
|
draft adds `sh:count`, `sh:sum`, `sh:min`, `sh:max`, `sh:groupConcat` and
|
||||||
|
`sh:orderBy`. We borrow `sh:groupConcat`, `sh:min`, `sh:max` and
|
||||||
|
`sh:separator` as values of `rep:collect`, and keep `sh:count`, `sh:sum` and
|
||||||
|
`sh:orderBy` for when counting and sorting arrive. We do not use its custom
|
||||||
|
targets: a `sh:SPARQLTarget` is a SPARQL string that the service could not
|
||||||
|
check or combine with a spec's filters.
|
||||||
|
|
||||||
## One identity: mu-authorization's
|
## One identity: mu-authorization's
|
||||||
|
|
||||||
All SPARQL goes through the template's own helpers (`mu`'s `query` and
|
All SPARQL goes through the template's own helpers (`mu`'s `query` and
|
||||||
@@ -89,10 +393,11 @@ node. That check stays on the profile: `validate_spec` says where each
|
|||||||
### The service scope
|
### The service scope
|
||||||
|
|
||||||
The LLM's own reads during refinement (`describe_profile`'s code lists and
|
The LLM's own reads during refinement (`describe_profile`'s code lists and
|
||||||
`lookup_values`) run under a service scope, not the caller's session. mu's
|
`lookup_values`) run under a service scope, not the caller's session:
|
||||||
|
`publicQuery` in `src/llm/explore.js`. mu's
|
||||||
`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 the public graph, regardless of who calls. Execution
|
||||||
still runs as the caller, so the report contains what they may see, and the
|
still runs as the caller, so the report contains what they may see, and the
|
||||||
LLM sees none of it.
|
LLM sees none of it.
|
||||||
|
|
||||||
@@ -144,7 +449,7 @@ user sees is Dutch.
|
|||||||
| `SEED_PAGE_SIZE` | `5000` | seed page size |
|
| `SEED_PAGE_SIZE` | `5000` | seed page size |
|
||||||
| `SUBJECT_CHUNK_SIZE` | `100` | see above |
|
| `SUBJECT_CHUNK_SIZE` | `100` | see above |
|
||||||
| `SHARE_DIR` | `/share` | where CSVs are written |
|
| `SHARE_DIR` | `/share` | where CSVs are written |
|
||||||
| `CHAT_ASSISTANT_URI` | — | the `prov:SoftwareAgent` that signs assistant messages; found by lookup when unset |
|
| `CHAT_ASSISTANT_URI` | `http://data.lblod.info/id/chat-agents/rapportassistent` | the `prov:SoftwareAgent` that signs assistant messages (the portal's chat-assistant migration seeds it) |
|
||||||
| `CHAT_HISTORY_LIMIT` | `20` | messages of history a turn reads |
|
| `CHAT_HISTORY_LIMIT` | `20` | messages of history a turn reads |
|
||||||
| `SERVICE_SCOPE` | `http://services.semantic.works/natural-language-report` | the mu-auth-scope for the LLM's own reads (code lists, `lookup_values`). Must match the `with-scope` grant in the app's sparql-parser config. |
|
| `SERVICE_SCOPE` | `http://services.semantic.works/natural-language-report` | the mu-auth-scope for the LLM's own reads (code lists, `lookup_values`). Must match the `with-scope` grant in the app's sparql-parser config. |
|
||||||
|
|
||||||
@@ -200,4 +505,4 @@ non-admin callers. Opening this up means adding the type to their group first.
|
|||||||
## What it will not do
|
## What it will not do
|
||||||
|
|
||||||
No counting, no sorting, no top-N, no comparing two subjects, one class per
|
No counting, no sorting, no top-N, no comparing two subjects, one class per
|
||||||
report, suggestions stop at the public graph.
|
report, suggestions stop at the public graph.
|
||||||
|
|||||||
15
app.js
15
app.js
@@ -1,12 +1,14 @@
|
|||||||
import { app, errorHandler } from 'mu';
|
import { app, errorHandler } from 'mu';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import { loadProfiles } from './src/runner/profile.js';
|
import { loadProfiles } from './src/runner/profile.js';
|
||||||
import { readConversation, readHistory, findAssistant, writeMessage } from './src/chat.js';
|
import { readConversation, readHistory, writeMessage } from './src/chat.js';
|
||||||
import { wantsExecution } from './src/llm/mode.js';
|
import { wantsExecution } from './src/llm/mode.js';
|
||||||
import { executeSpec, refineSpec } from './src/report-assistant.js';
|
import { executeSpec, refineSpec } from './src/report-assistant.js';
|
||||||
|
|
||||||
const PROFILE_DIR = process.env.PROFILE_DIR || '/config/profiles';
|
const PROFILE_DIR = process.env.PROFILE_DIR || '/config/profiles';
|
||||||
const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI;
|
// The agent that signs the assistant's messages, seeded by the portal's
|
||||||
|
// chat-assistant migration.
|
||||||
|
const CHAT_ASSISTANT_URI = process.env.CHAT_ASSISTANT_URI || 'http://data.lblod.info/id/chat-agents/rapportassistent';
|
||||||
const TITLE_LENGTH = 80;
|
const TITLE_LENGTH = 80;
|
||||||
const FAILED = 'Er ging iets mis. Probeer het opnieuw.';
|
const FAILED = 'Er ging iets mis. Probeer het opnieuw.';
|
||||||
|
|
||||||
@@ -22,13 +24,12 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function
|
|||||||
const content = String(req.body?.content ?? '').trim();
|
const content = String(req.body?.content ?? '').trim();
|
||||||
if (!content) return res.status(400).json({ error: 'content is required' });
|
if (!content) return res.status(400).json({ error: 'content is required' });
|
||||||
|
|
||||||
let conversation, assistant, history, question;
|
let conversation, history, question;
|
||||||
try {
|
try {
|
||||||
// the access check: the caller's session decides what is readable
|
// the access check: the caller's session decides what is readable
|
||||||
conversation = await readConversation(req.params.id);
|
conversation = await readConversation(req.params.id);
|
||||||
if (!conversation) return res.status(404).json({ error: 'no such conversation' });
|
if (!conversation) return res.status(404).json({ error: 'no such conversation' });
|
||||||
assistant = CHAT_ASSISTANT_URI || await findAssistant();
|
history = await readHistory(conversation.uri, CHAT_ASSISTANT_URI);
|
||||||
history = await readHistory(conversation.uri, assistant);
|
|
||||||
question = await writeMessage({
|
question = await writeMessage({
|
||||||
conversationUri: conversation.uri,
|
conversationUri: conversation.uri,
|
||||||
content,
|
content,
|
||||||
@@ -50,13 +51,13 @@ app.post('/assistant/conversations/:id/turns', bodyParser.json(), async function
|
|||||||
await writeMessage({
|
await writeMessage({
|
||||||
conversationUri: conversation.uri,
|
conversationUri: conversation.uri,
|
||||||
content: answer.text,
|
content: answer.text,
|
||||||
maker: assistant,
|
maker: CHAT_ASSISTANT_URI,
|
||||||
attachments: answer.attachments,
|
attachments: answer.attachments,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[chat] turn failed:', e);
|
console.error('[chat] turn failed:', e);
|
||||||
try {
|
try {
|
||||||
await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: assistant });
|
await writeMessage({ conversationUri: conversation.uri, content: FAILED, maker: CHAT_ASSISTANT_URI });
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
console.error('[chat] could not write the failure message:', e2);
|
console.error('[chat] could not write the failure message:', e2);
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/chat.js
12
src/chat.js
@@ -25,7 +25,6 @@ const T = {
|
|||||||
created: 'http://purl.org/dc/terms/created',
|
created: 'http://purl.org/dc/terms/created',
|
||||||
maker: 'http://xmlns.com/foaf/0.1/maker',
|
maker: 'http://xmlns.com/foaf/0.1/maker',
|
||||||
attachment: 'https://www.w3.org/ns/activitystreams#attachment',
|
attachment: 'https://www.w3.org/ns/activitystreams#attachment',
|
||||||
softwareAgent: 'http://www.w3.org/ns/prov#SoftwareAgent',
|
|
||||||
file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject',
|
file: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject',
|
||||||
fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName',
|
fileName: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileName',
|
||||||
fileSize: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize',
|
fileSize: 'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#fileSize',
|
||||||
@@ -109,17 +108,6 @@ export async function readHistory(conversationUri, assistantUri) {
|
|||||||
return messages.slice(-HISTORY_LIMIT);
|
return messages.slice(-HISTORY_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The one assistant the caller can read. Throws unless there is exactly one.
|
|
||||||
export async function findAssistant() {
|
|
||||||
const json = await query(`
|
|
||||||
SELECT ?agent WHERE { ?agent ${u(T.type)} ${u(T.softwareAgent)} . } LIMIT 2`);
|
|
||||||
const rows = bindings(json);
|
|
||||||
if (rows.length !== 1) {
|
|
||||||
throw new Error(`expected one prov:SoftwareAgent, found ${rows.length}; seed one or set CHAT_ASSISTANT_URI`);
|
|
||||||
}
|
|
||||||
return rows[0].agent.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- writes -------------------------------------------------------------------
|
// --- writes -------------------------------------------------------------------
|
||||||
|
|
||||||
// One message, both types, its bijlagen, and the conversation touched, in
|
// One message, both types, its bijlagen, and the conversation touched, in
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
// A profile as a plain text menu: entities, fields, code lists. Code lists up
|
// A profile as a plain text menu: entities, fields, code lists. Code lists up
|
||||||
// to INLINE_VALUES_MAX are inlined (and cached), so the LLM never has to
|
// to INLINE_VALUES_MAX are inlined (and cached), so the LLM never has to
|
||||||
// search the short ones.
|
// search the short ones.
|
||||||
import { codeList, INLINE_VALUES_MAX } from './lookup.js';
|
import { codeList, INLINE_VALUES_MAX } from './explore.js';
|
||||||
|
|
||||||
export async function describeProfile(profile) {
|
export async function describeProfile(profile) {
|
||||||
const lines = [];
|
const lines = [`A spec is Turtle, in exactly this shape (copy these prefixes):
|
||||||
lines.push('A spec is Turtle, in exactly this shape (copy these prefixes):');
|
@prefix rep: <http://mu.semte.ch/vocabularies/reporting/> .
|
||||||
lines.push('@prefix rep: <http://mu.semte.ch/vocabularies/reporting/> .');
|
@prefix sh: <http://www.w3.org/ns/shacl#> .
|
||||||
lines.push('@prefix sh: <http://www.w3.org/ns/shacl#> .');
|
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
|
||||||
lines.push('@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .');
|
@prefix dct: <http://purl.org/dc/terms/> .
|
||||||
lines.push('@prefix dct: <http://purl.org/dc/terms/> .');
|
|
||||||
lines.push('');
|
<http://data.lblod.info/id/report-specs/<your-id>> a rep:ReportSpec , sh:NodeShape ;
|
||||||
lines.push('<http://data.lblod.info/id/report-specs/<your-id>> a rep:ReportSpec , sh:NodeShape ;');
|
dct:title "..." ;
|
||||||
lines.push(' dct:title "..." ;');
|
rep:profile <${profile.uri}> ;
|
||||||
lines.push(` rep:profile <${profile.uri}> ;`);
|
sh:targetClass <the rdf:type from the list below> ;
|
||||||
lines.push(' sh:targetClass <the rdf:type from the list below> ;');
|
sh:property [ ... ] ; # filters, optional
|
||||||
lines.push(' sh:property [ ... ] ; # filters, optional');
|
rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .
|
||||||
lines.push(' rep:columns ( [ sh:path ( ... ) ; rdfs:label "..." ] ... ) .');
|
Every column ends on a value field. Use rep:self as sh:path for the subject URI.
|
||||||
lines.push('Every column ends on a value field. Use rep:self as sh:path for the subject URI.');
|
For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column.
|
||||||
lines.push('For the URI of a linked node, end the column on that link and add sh:nodeKind sh:IRI to the column.');
|
|
||||||
lines.push('');
|
sh:targetClass is the full URI in brackets after the entity below.
|
||||||
lines.push('sh:targetClass is the full URI in brackets after the entity below.');
|
In sh:path, use the predicate URI shown after each field name, not the field name itself.
|
||||||
lines.push('In sh:path, use the predicate URI shown after each field name, not the field name itself.');
|
An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath <predicate> ] .
|
||||||
lines.push('An inverse hop (the field shows "(inverse)") goes in the list as [ sh:inversePath <predicate> ] .');
|
When two entities share one sh:targetClass, pick one with rep:entity <the URI after the entity>.
|
||||||
lines.push('When two entities share one sh:targetClass, pick one with rep:entity <the URI after the entity>.');
|
A field that links to several entities ("→ a or b") reaches all of them; the steps after it decide. To keep one kind, add a rep:where on a step only that kind has.
|
||||||
lines.push('A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime');
|
A date filter is sh:minInclusive or sh:maxInclusive with "yyyy-mm-dd"^^xsd:dateTime
|
||||||
lines.push('(the xsd prefix above is not copied: add @prefix xsd: <http://www.w3.org/2001/XMLSchema#>).');
|
(the xsd prefix above is not copied: add @prefix xsd: <http://www.w3.org/2001/XMLSchema#>).
|
||||||
lines.push('');
|
`];
|
||||||
for (const shape of profile.shapes) {
|
for (const shape of profile.shapes) {
|
||||||
lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`);
|
lines.push(`${shape.label || shape.targetClass} — sh:targetClass <${shape.targetClass}> ; rep:entity <${shape.uri}>`);
|
||||||
for (const f of shape.fields) {
|
for (const f of shape.fields) {
|
||||||
const kind = f.datatype ? datatypeName(f.datatype)
|
const kind = f.datatype ? datatypeName(f.datatype)
|
||||||
: f.class ? `→ ${conceptLabel(f.class, await codeList(f.class))}`
|
: f.class ? `→ ${conceptLabel(f.class, await codeList(f.class))}`
|
||||||
: f.node ? `→ ${linkedLabel(profile, f.node)}`
|
: f.nodes.length ? `→ ${f.nodes.map(n => linkedLabel(profile, n)).join(' or ')}`
|
||||||
: '?';
|
: '?';
|
||||||
const dir = f.inverse ? ' (inverse)' : '';
|
const dir = f.inverse ? ' (inverse)' : '';
|
||||||
const pathShown = f.path || '?';
|
const pathShown = f.path || '?';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// The LLM's own reads: code lists. They run under the service scope, not
|
// Everything the LLM reads from the database in refine mode: the code
|
||||||
// the caller's session; the sparql-parser config grants that scope read
|
// lists, inlined in describe_profile and searched by lookup_values. Every
|
||||||
// access to the public graph only, so they read the same for every caller.
|
// query goes through publicQuery: the service scope, not the caller's
|
||||||
// Execution still runs as the caller.
|
// session. The sparql-parser config grants that scope read access to the
|
||||||
|
// public graph only, so these reads are the same for every caller. Nothing
|
||||||
|
// here counts, and execution still runs as the caller.
|
||||||
//
|
//
|
||||||
// lookup_values is a suggest helper, not a spec check. It reads candidate
|
// lookup_values is a suggest helper, not a spec check. It reads candidate
|
||||||
// values the LLM can put in a filter; it never runs the spec and never sees
|
// values the LLM can put in a filter; it never runs the spec and never sees
|
||||||
@@ -12,7 +14,7 @@ import { query, sparqlEscapeString } from 'mu';
|
|||||||
const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
|
const SERVICE_SCOPE = process.env.SERVICE_SCOPE || 'http://services.semantic.works/natural-language-report';
|
||||||
export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50);
|
export const INLINE_VALUES_MAX = Number(process.env.INLINE_VALUES_MAX || 50);
|
||||||
const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
|
const VALUES_TTL = Number(process.env.VALUES_TTL || 3600) * 1000;
|
||||||
const LIMIT = 25;
|
const LOOKUP_LIMIT = 25;
|
||||||
|
|
||||||
// class → { values, at }. Refreshed lazily when stale.
|
// class → { values, at }. Refreshed lazily when stale.
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
@@ -45,7 +47,7 @@ export async function codeList(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lookupValues(profile, field, term) → the matches as text for the LLM: up
|
// lookupValues(profile, field, term) → the matches as text for the LLM: up
|
||||||
// to LIMIT labels and URIs, an exact label (case aside) first. field is
|
// to LOOKUP_LIMIT labels and URIs, an exact label (case aside) first. field is
|
||||||
// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to
|
// "shapeLabel.fieldLabel" as the LLM writes it; it resolves to the class to
|
||||||
// search.
|
// search.
|
||||||
export async function lookupValues(profile, field, term) {
|
export async function lookupValues(profile, field, term) {
|
||||||
@@ -62,12 +64,12 @@ export async function lookupValues(profile, field, term) {
|
|||||||
// "Gent" must surface before "AGB Erfgoed Gent".
|
// "Gent" must surface before "AGB Erfgoed Gent".
|
||||||
const lower = term.toLowerCase();
|
const lower = term.toLowerCase();
|
||||||
const exactRow = rows.find(r => r.label.toLowerCase() === lower);
|
const exactRow = rows.find(r => r.label.toLowerCase() === lower);
|
||||||
let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LIMIT);
|
let matches = (exactRow ? [exactRow, ...rows.filter(r => r !== exactRow)] : rows).slice(0, LOOKUP_LIMIT);
|
||||||
if (!matches.length) matches = await codeQuery(term);
|
if (!matches.length) matches = await codeQuery(term);
|
||||||
if (!matches.length) return `no value matches "${term}" in ${field}.`;
|
if (!matches.length) return `no value matches "${term}" in ${field}.`;
|
||||||
|
|
||||||
const lines = matches.map(m => `${m.label} <${m.uri}>`);
|
const lines = matches.map(m => `${m.label} <${m.uri}>`);
|
||||||
if (matches.length >= LIMIT) lines.push(`(${LIMIT}+ matches, narrow the term)`);
|
if (matches.length >= LOOKUP_LIMIT) lines.push(`(${LOOKUP_LIMIT}+ matches, narrow the term)`);
|
||||||
const exact = matches.find(m => m.label.toLowerCase() === lower);
|
const exact = matches.find(m => m.label.toLowerCase() === lower);
|
||||||
if (exact) lines.push(`exact: <${exact.uri}>`);
|
if (exact) lines.push(`exact: <${exact.uri}>`);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
@@ -79,7 +81,7 @@ async function labelQuery(term, type) {
|
|||||||
?uri a ${type ? `<${type}>` : '?anyType'} ;
|
?uri a ${type ? `<${type}>` : '?anyType'} ;
|
||||||
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
|
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
|
||||||
FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i"))
|
FILTER(REGEX(str(?label), ${sparqlEscapeString(escapeRegex(term))}, "i"))
|
||||||
} ORDER BY ?label LIMIT ${LIMIT * 8}`;
|
} ORDER BY ?label LIMIT ${LOOKUP_LIMIT * 8}`;
|
||||||
const r = await publicQuery(q);
|
const r = await publicQuery(q);
|
||||||
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
|
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
|
||||||
}
|
}
|
||||||
@@ -92,7 +94,7 @@ async function codeQuery(term) {
|
|||||||
?uri <http://mu.semte.ch/vocabularies/ext/supervision/nomenclatureCode> ?code ;
|
?uri <http://mu.semte.ch/vocabularies/ext/supervision/nomenclatureCode> ?code ;
|
||||||
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
|
<http://www.w3.org/2004/02/skos/core#prefLabel> ?label .
|
||||||
FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)})))
|
FILTER(CONTAINS(LCASE(str(?code)), LCASE(${sparqlEscapeString(term)})))
|
||||||
} ORDER BY ?label LIMIT ${LIMIT}`;
|
} ORDER BY ?label LIMIT ${LOOKUP_LIMIT}`;
|
||||||
try {
|
try {
|
||||||
const r = await publicQuery(q);
|
const r = await publicQuery(q);
|
||||||
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
|
return r.results.bindings.map(b => ({ label: b.label.value, uri: b.uri.value }));
|
||||||
@@ -114,9 +116,10 @@ function resolveClass(profile, field) {
|
|||||||
if (!shape) return null;
|
if (!shape) return null;
|
||||||
const f = shape.fields.find(x => x.name === fieldLabel);
|
const f = shape.fields.find(x => x.name === fieldLabel);
|
||||||
if (f?.class) return f.class;
|
if (f?.class) return f.class;
|
||||||
if (f?.node) {
|
if (f?.nodes.length) {
|
||||||
const linked = profile.shapes.find(s => s.uri === f.node);
|
// sh:or of several entities: only when they share one class
|
||||||
return linked?.targetClass || null;
|
const classes = new Set(f.nodes.map(n => profile.shapes.find(s => s.uri === n)?.targetClass || null));
|
||||||
|
return classes.size === 1 ? [...classes][0] : null;
|
||||||
}
|
}
|
||||||
// The model sometimes writes shape.field on a linked shape
|
// The model sometimes writes shape.field on a linked shape
|
||||||
// ("bestuurseenheid.naam"); resolve it to that shape's target class.
|
// ("bestuurseenheid.naam"); resolve it to that shape's target class.
|
||||||
@@ -48,6 +48,9 @@ Rules:
|
|||||||
applies. Say that in Dutch in your proposal, so the user can check it.
|
applies. Say that in Dutch in your proposal, so the user can check it.
|
||||||
- Only offer what validate_spec accepted. Before you suggest an alternative,
|
- Only offer what validate_spec accepted. Before you suggest an alternative,
|
||||||
write it and validate it; if it does not validate, say it cannot be done.
|
write it and validate it; if it does not validate, say it cannot be done.
|
||||||
|
- One answer carries one spec, and one spec is one report on one profile. If
|
||||||
|
the question needs two reports, say so, propose the first, and offer the
|
||||||
|
second as the next step.
|
||||||
- You cannot count, sort, take the first N, or compare two subjects. Say so.
|
- 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
|
- The report's title is the dct:title you write in the spec. There is no other
|
||||||
title input.
|
title input.
|
||||||
@@ -101,7 +104,13 @@ export async function ask(turns, profiles) {
|
|||||||
}
|
}
|
||||||
// validate_spec answers "ok" (plus notes) when the spec checks out;
|
// validate_spec answers "ok" (plus notes) when the spec checks out;
|
||||||
// that spec becomes the bijlage of the answer.
|
// that spec becomes the bijlage of the answer.
|
||||||
if (call.function.name === 'validate_spec' && /^ok(\n|$)/.test(result)) spec = args.spec;
|
// One answer carries one spec: say so when this one replaces another.
|
||||||
|
if (call.function.name === 'validate_spec' && /^ok(\n|$)/.test(result)) {
|
||||||
|
if (spec && spec !== args.spec) {
|
||||||
|
result += '\nThis spec replaces the one you validated before. Your answer carries only this one.';
|
||||||
|
}
|
||||||
|
spec = args.spec;
|
||||||
|
}
|
||||||
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// The tools the LLM calls, refinement only: list_profiles,
|
// The tools the LLM calls, refinement only: list_profiles,
|
||||||
// describe_profile, validate_spec, read_spec and lookup_values. Each returns
|
// describe_profile, validate_spec, read_spec and lookup_values. Each returns
|
||||||
// plain text for the model. No report is created, no spec is evaluated;
|
// plain text for the model. No report is created, no spec is evaluated; the
|
||||||
// the code-list reads run under the service scope (public graph).
|
// database reads run under the service scope (public graph).
|
||||||
import { parseSpec } from '../runner/spec.js';
|
import { parseSpec } from '../runner/spec.js';
|
||||||
import { checkSpec, whereNotes, profileError } from '../runner/check.js';
|
import { checkSpec, whereNotes, profileError } from '../runner/check.js';
|
||||||
import { describeProfile } from './describe.js';
|
import { describeProfile } from './describe.js';
|
||||||
import { lookupValues } from './lookup.js';
|
import { lookupValues } from './explore.js';
|
||||||
import { readSpecFile } from '../chat.js';
|
import { readSpecFile } from '../chat.js';
|
||||||
|
|
||||||
// The tool definitions sent to the model. The descriptions are what the LLM
|
// The tool definitions sent to the model. The descriptions are what the LLM
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
// Values to rows. Subjects in stable order, dedup on term, collect per cell.
|
import { sharedSteps } from './spec.js';
|
||||||
// rep:self columns take the subject URI; a column with no values is an
|
|
||||||
// empty cell, never a dropped row. A column without rep:collect joins its
|
// Values to rows, paired by URI on every step. Two columns that walk the
|
||||||
// values only when there is one; with several, each value gets its own row
|
// same steps (with the same rep:where on them) share the nodes on those
|
||||||
// (the other columns repeat theirs). rep:row forces that expansion; at most
|
// steps, the way a rep:where shares nodes with its column. Per subject the
|
||||||
// one column per spec may do that explicitly (checked in check.js).
|
// paths of the columns make a tree of the nodes found; every row is one way
|
||||||
|
// down that tree. Columns through the same node stay on one row; branches
|
||||||
|
// under one node multiply. A column with no value is an empty cell, never a
|
||||||
|
// dropped row. sh:groupConcat, sh:min and sh:max fold a column into one cell
|
||||||
|
// per node it shares with the other columns. rep:self takes the subject
|
||||||
|
// URI. Subjects in stable order; identical rows appear once.
|
||||||
|
|
||||||
const SH = 'http://www.w3.org/ns/shacl#';
|
const SH = 'http://www.w3.org/ns/shacl#';
|
||||||
const REP = 'http://mu.semte.ch/vocabularies/reporting/';
|
|
||||||
const XSD = 'http://www.w3.org/2001/XMLSchema#';
|
const XSD = 'http://www.w3.org/2001/XMLSchema#';
|
||||||
|
const FOLDING = [SH + 'groupConcat', SH + 'min', SH + 'max'];
|
||||||
|
|
||||||
export const NUMERIC_DATATYPES = new Set([
|
export const NUMERIC_DATATYPES = new Set([
|
||||||
'integer', 'decimal', 'double', 'float', 'long', 'int', 'short', 'byte',
|
'integer', 'decimal', 'double', 'float', 'long', 'int', 'short', 'byte',
|
||||||
@@ -15,45 +20,98 @@ export const NUMERIC_DATATYPES = new Set([
|
|||||||
'unsignedLong', 'unsignedInt', 'unsignedShort', 'unsignedByte',
|
'unsignedLong', 'unsignedInt', 'unsignedShort', 'unsignedByte',
|
||||||
].map(d => XSD + d));
|
].map(d => XSD + d));
|
||||||
|
|
||||||
|
// assemble(subjects, values, spec) → rows, the header first. values:
|
||||||
|
// subject → column index → chains, one term per step (see columns.js).
|
||||||
export function assemble(subjects, values, spec) {
|
export function assemble(subjects, values, spec) {
|
||||||
const sorted = [...subjects].sort();
|
const tree = columnTree(spec.columns);
|
||||||
const rows = [spec.columns.map(c => c.label)];
|
const rows = [spec.columns.map(c => c.label)];
|
||||||
for (const subject of sorted) {
|
for (const subject of [...subjects].sort()) {
|
||||||
const perColumn = values.get(subject) || new Map();
|
const top = { term: { value: subject }, below: new Map() };
|
||||||
const cells = spec.columns.map((col, i) => {
|
for (const [index, chains] of values.get(subject) || []) {
|
||||||
if (!col.path.length) return [subject]; // rep:self
|
for (const chain of chains) addChain(top, tree.paths[index], chain);
|
||||||
const list = dedup(perColumn.get(i) || []);
|
}
|
||||||
return collect(list, col);
|
const seen = new Set();
|
||||||
});
|
for (const cells of rowsAt(tree.root, top, tree, spec.columns)) {
|
||||||
const lines = Math.max(1, ...cells.map(c => c.length));
|
const row = spec.columns.map((col, i) => cells.get(i) ?? '');
|
||||||
for (let r = 0; r < lines; r++) {
|
const key = JSON.stringify(row);
|
||||||
// the expanding cell cycles its values, every other cell keeps its
|
if (!seen.has(key)) {
|
||||||
// value on each line
|
seen.add(key);
|
||||||
rows.push(cells.map(c => c[Math.min(r, c.length - 1)]));
|
rows.push(row);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collect(terms, col) {
|
// The steps of all columns as one tree of branches. Two columns share a
|
||||||
switch (col.collect) {
|
// branch as long as they took the same steps with the same rep:where on
|
||||||
case SH + 'groupConcat':
|
// them. paths[i] lists the branches column i goes through; a branch knows
|
||||||
return [terms.map(t => t.value).join(col.separator ?? ',')];
|
// the columns that end on it and the columns that go through it.
|
||||||
case undefined:
|
function columnTree(columns) {
|
||||||
case null:
|
const newBranch = () => ({ next: new Map(), ends: [], columns: [] });
|
||||||
// no rep:collect: one value is a normal cell, several values each
|
const root = newBranch();
|
||||||
// get their own row instead of a joined string
|
const paths = columns.map((col, index) => {
|
||||||
if (terms.length <= 1) return [terms.map(t => t.value).join(',')];
|
let branch = root;
|
||||||
return terms.map(t => t.value);
|
const branches = col.path.map((hop, k) => {
|
||||||
case SH + 'min':
|
const where = col.where.filter(w => sharedSteps(col.path, w.path) === k + 1);
|
||||||
return [extreme(terms, col, -1)];
|
const key = `${hop.inverse ? '^' : ''}${hop.predicate} ${JSON.stringify(where)}`;
|
||||||
case SH + 'max':
|
if (!branch.next.has(key)) branch.next.set(key, newBranch());
|
||||||
return [extreme(terms, col, 1)];
|
branch = branch.next.get(key);
|
||||||
case REP + 'row':
|
branch.columns.push(index);
|
||||||
return terms.length ? terms.map(t => t.value) : [''];
|
return branch;
|
||||||
default:
|
});
|
||||||
throw new Error(`column "${col.label}" has an unknown rep:collect <${col.collect}>. Use sh:groupConcat, rep:row, sh:min or sh:max.`);
|
branch.ends.push(index);
|
||||||
|
return branches;
|
||||||
|
});
|
||||||
|
return { root, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One path the store returned, subject to value, into the tree of nodes
|
||||||
|
// found for that subject. Nodes are keyed by value: a node reached twice is
|
||||||
|
// one node, a value found twice under one node is one value.
|
||||||
|
function addChain(found, branches, chain) {
|
||||||
|
branches.forEach((branch, k) => {
|
||||||
|
if (!found.below.has(branch)) found.below.set(branch, new Map());
|
||||||
|
const byValue = found.below.get(branch);
|
||||||
|
if (!byValue.has(chain[k].value)) byValue.set(chain[k].value, { term: chain[k], below: new Map() });
|
||||||
|
found = byValue.get(chain[k].value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows under one found node: its own cells, times the rows of each
|
||||||
|
// branch below it. A branch where nothing was found adds empty cells.
|
||||||
|
function rowsAt(branch, found, tree, columns) {
|
||||||
|
let rows = [new Map(branch.ends.map(i => [i, found.term.value]))];
|
||||||
|
for (const next of branch.next.values()) {
|
||||||
|
const nodes = [...(found.below.get(next)?.values() || [])];
|
||||||
|
const below = next.columns.every(i => FOLDING.includes(columns[i].collect))
|
||||||
|
? [fold(next, nodes, tree, columns)]
|
||||||
|
: nodes.length ? nodes.flatMap(node => rowsAt(next, node, tree, columns)) : [new Map()];
|
||||||
|
rows = rows.flatMap(row => below.map(cells => new Map([...row, ...cells])));
|
||||||
}
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A branch where every column folds gives one set of cells: per column, all
|
||||||
|
// its values in the branch, folded into one cell.
|
||||||
|
function fold(branch, nodes, tree, columns) {
|
||||||
|
const cells = new Map();
|
||||||
|
for (const i of branch.columns) {
|
||||||
|
const rest = tree.paths[i].slice(tree.paths[i].indexOf(branch) + 1);
|
||||||
|
const terms = nodes.flatMap(node => termsBelow(node, rest));
|
||||||
|
const unique = [...new Map(terms.map(t => [t.value, t])).values()];
|
||||||
|
const col = columns[i];
|
||||||
|
cells.set(i, col.collect === SH + 'groupConcat'
|
||||||
|
? unique.map(t => t.value).join(col.separator ?? ',')
|
||||||
|
: extreme(unique, col, col.collect === SH + 'min' ? -1 : 1));
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function termsBelow(node, rest) {
|
||||||
|
if (!rest.length) return [node.term];
|
||||||
|
const byValue = node.below.get(rest[0]);
|
||||||
|
return byValue ? [...byValue.values()].flatMap(next => termsBelow(next, rest.slice(1))) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// min/max. Numbers compare as numbers. Dates and times compare as text:
|
// min/max. Numbers compare as numbers. Dates and times compare as text:
|
||||||
@@ -72,17 +130,3 @@ function extreme(terms, col, direction) {
|
|||||||
const best = terms.reduce((a, b) => (cmp(key(b), key(a)) * direction > 0 ? b : a));
|
const best = terms.reduce((a, b) => (cmp(key(b), key(a)) * direction > 0 ? b : a));
|
||||||
return best.value;
|
return best.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two terms are the same when the URIs match, or lexical form, datatype
|
|
||||||
// and language tag all match.
|
|
||||||
export function dedup(terms) {
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const t of terms) {
|
|
||||||
const key = t.termType === 'NamedNode'
|
|
||||||
? `u:${t.value}`
|
|
||||||
: `l:${t.value}|${t.datatype?.value || ''}|${t.language || ''}`;
|
|
||||||
if (!seen.has(key)) { seen.add(key); out.push(t); }
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { startShape, fieldsOf, field } from './profile.js';
|
import { startShape, fieldsOf } from './profile.js';
|
||||||
import { NUMERIC_DATATYPES } from './assemble.js';
|
import { NUMERIC_DATATYPES } from './assemble.js';
|
||||||
import { sharedSteps } from './spec.js';
|
import { sharedSteps } from './spec.js';
|
||||||
|
|
||||||
@@ -119,14 +119,10 @@ function collectMinMaxTyped(spec, profile) {
|
|||||||
const name = col.collect === SH + 'min' ? 'sh:min' : 'sh:max';
|
const name = col.collect === SH + 'min' ? 'sh:min' : 'sh:max';
|
||||||
if (!col.path.length) return `${name} on a rep:self column does not apply. A subject URI is not a number or date.`;
|
if (!col.path.length) return `${name} on a rep:self column does not apply. A subject URI is not a number or date.`;
|
||||||
const last = col.path[col.path.length - 1];
|
const last = col.path[col.path.length - 1];
|
||||||
const shapeUri = shapeAfter(profile, startShapeUri, col.path);
|
// no end field: pathsResolve already reported the real error
|
||||||
if (!shapeUri) continue; // pathsResolve already reported the real error
|
const f = endFields(profile, startShapeUri, col.path)
|
||||||
const f = field(profile, shapeUri, last);
|
.find(x => x.datatype !== XSD_DATETIME && x.datatype !== XSD_DATE && !NUMERIC_DATATYPES.has(x.datatype));
|
||||||
if (!f) continue;
|
if (f) {
|
||||||
const kind = f.datatype === XSD_DATETIME || f.datatype === XSD_DATE ? 'date'
|
|
||||||
: NUMERIC_DATATYPES.has(f.datatype) ? 'number'
|
|
||||||
: null;
|
|
||||||
if (!kind) {
|
|
||||||
return `"${f.name || last.predicate}" is text, so ${name} does not apply. Use rep:row (same as leaving rep:collect out: every value gets its own row).`;
|
return `"${f.name || last.predicate}" is text, so ${name} does not apply. Use rep:row (same as leaving rep:collect out: every value gets its own row).`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,15 +287,15 @@ function rangeOnValue(spec, profile) {
|
|||||||
const key = ['minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive']
|
const key = ['minInclusive', 'maxInclusive', 'minExclusive', 'maxExclusive']
|
||||||
.find(k => cond.constraints[k]);
|
.find(k => cond.constraints[k]);
|
||||||
if (!key || !cond.path.length) continue;
|
if (!key || !cond.path.length) continue;
|
||||||
const last = cond.path[cond.path.length - 1];
|
// pathsResolve reports a missing step
|
||||||
const f = field(profile, shapeAfter(profile, startShapeUri, cond.path), last);
|
const f = endFields(profile, startShapeUri, cond.path).find(x => !x.datatype);
|
||||||
if (!f || f.datatype) continue; // pathsResolve reports a missing step
|
if (!f) continue;
|
||||||
const target = f.node
|
const target = f.nodes.length
|
||||||
? `"${profile.shapes.find(s => s.uri === f.node)?.label || f.node}"`
|
? f.nodes.map(uri => shapeName(profile, uri)).join(' or ')
|
||||||
: `the code "${f.name}"`;
|
: `the code "${f.name}"`;
|
||||||
const steps = fieldsOf(profile, f.node)
|
const steps = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri))
|
||||||
.filter(x => x.datatype === XSD_DATE || x.datatype === XSD_DATETIME || NUMERIC_DATATYPES.has(x.datatype))
|
.filter(x => x.datatype === XSD_DATE || x.datatype === XSD_DATETIME || NUMERIC_DATATYPES.has(x.datatype))
|
||||||
.map(x => `${x.name} (${x.path})`);
|
.map(x => `${x.name} (${x.path})`))];
|
||||||
const add = steps.length ? `Add a step: ${steps.join(' or ')}.` : 'End the path on a date or number field.';
|
const add = steps.length ? `Add a step: ${steps.join(' or ')}.` : 'End the path on a date or number field.';
|
||||||
return `${what} ends on ${target}, not on a value, so sh:${key} never matches. ${add}`;
|
return `${what} ends on ${target}, not on a value, so sh:${key} never matches. ${add}`;
|
||||||
}
|
}
|
||||||
@@ -316,15 +312,18 @@ export function whereNotes(spec, profile) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The entity a path lands on, or the value field when it ends on one.
|
// The entity a path lands on, or the value field when it ends on one. A
|
||||||
|
// step that leads to several entities names them all.
|
||||||
function nodeName(profile, startShapeUri, hops) {
|
function nodeName(profile, startShapeUri, hops) {
|
||||||
let shapeUri = startShapeUri;
|
if (!hops.length) return shapeName(profile, startShapeUri);
|
||||||
for (const [i, hop] of hops.entries()) {
|
const ends = endFields(profile, startShapeUri, hops);
|
||||||
const f = field(profile, shapeUri, hop);
|
if (!ends.length) return `step ${hops.length}`;
|
||||||
if (!f) return `step ${hops.length}`;
|
const value = ends.find(f => !f.nodes.length);
|
||||||
if (!f.node) return i === hops.length - 1 ? `the value "${f.name}"` : `step ${hops.length}`;
|
if (value) return `the value "${value.name}"`;
|
||||||
shapeUri = f.node;
|
return [...new Set(ends.flatMap(f => f.nodes))].map(uri => shapeName(profile, uri)).join(' or ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shapeName(profile, shapeUri) {
|
||||||
return `"${profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri}"`;
|
return `"${profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,17 +366,24 @@ export function checkSpec(spec, profiles) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shape a path lands in: follow sh:node through every hop but the
|
// The fields a hop takes from any of the shapes a path has reached. A field
|
||||||
// last. walk() has already validated the path, so a gap here means the walk
|
// may link to several entities (sh:or of sh:node: an eenheid's political and
|
||||||
// reported the real error and this can bail.
|
// leidinggevende organen): the walk follows them all and the next steps
|
||||||
function shapeAfter(profile, startShapeUri, hops) {
|
// decide. The query follows predicates only, so this is what the data does.
|
||||||
let shapeUri = startShapeUri;
|
function hopFields(profile, shapeUris, hop) {
|
||||||
for (let i = 0; i < hops.length - 1; i++) {
|
return shapeUris.flatMap(uri => fieldsOf(profile, uri)
|
||||||
const f = field(profile, shapeUri, hops[i]);
|
.filter(f => f.path === hop.predicate && !!f.inverse === !!hop.inverse));
|
||||||
if (!f || !f.node) return null;
|
}
|
||||||
shapeUri = f.node;
|
|
||||||
|
// The fields a path can end on: follow sh:node through every hop but the
|
||||||
|
// last. walk() has already validated the path, so none here means the walk
|
||||||
|
// reported the real error and the caller can bail.
|
||||||
|
function endFields(profile, startShapeUri, hops) {
|
||||||
|
let shapeUris = [startShapeUri];
|
||||||
|
for (const hop of hops.slice(0, -1)) {
|
||||||
|
shapeUris = [...new Set(hopFields(profile, shapeUris, hop).flatMap(f => f.nodes))];
|
||||||
}
|
}
|
||||||
return shapeUri;
|
return hopFields(profile, shapeUris, hops[hops.length - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function walk(profile, startShapeUri, hops, what, isColumn, wantsIri = false) {
|
function walk(profile, startShapeUri, hops, what, isColumn, wantsIri = false) {
|
||||||
@@ -385,30 +391,34 @@ function walk(profile, startShapeUri, hops, what, isColumn, wantsIri = false) {
|
|||||||
return `${what} walks ${hops.length} hops. The limit is ${MAX_PATH_DEPTH}.`;
|
return `${what} walks ${hops.length} hops. The limit is ${MAX_PATH_DEPTH}.`;
|
||||||
}
|
}
|
||||||
if (!hops.length) return null;
|
if (!hops.length) return null;
|
||||||
let shapeUri = startShapeUri;
|
let shapeUris = [startShapeUri];
|
||||||
for (let i = 0; i < hops.length; i++) {
|
for (let i = 0; i < hops.length; i++) {
|
||||||
const hop = hops[i];
|
const hop = hops[i];
|
||||||
const fields = fieldsOf(profile, shapeUri);
|
const found = hopFields(profile, shapeUris, hop);
|
||||||
const f = fields.find(x => x.path === hop.predicate && !!x.inverse === !!hop.inverse);
|
if (!found.length) {
|
||||||
if (!f) {
|
const have = [...new Set(shapeUris.flatMap(uri => fieldsOf(profile, uri))
|
||||||
const have = fields.map(x => (x.inverse ? `${x.name} (${x.path}, inverse)` : `${x.name} (${x.path})`)).join(', ');
|
.map(x => (x.inverse ? `${x.name} (${x.path}, inverse)` : `${x.name} (${x.path})`)))].join(', ');
|
||||||
const shapeLabel = profile.shapes.find(s => s.uri === shapeUri)?.label || shapeUri;
|
const shapeLabel = shapeUris.map(uri => shapeName(profile, uri)).join(' or ');
|
||||||
return `at "${shapeLabel}" there is no "${hop.predicate}". It has: ${have || '(nothing)'}.`;
|
return `at ${shapeLabel} there is no "${hop.predicate}". It has: ${have || '(nothing)'}.`;
|
||||||
}
|
}
|
||||||
if (i < hops.length - 1) {
|
if (i < hops.length - 1) {
|
||||||
if (!f.node) {
|
const links = found.filter(f => f.nodes.length);
|
||||||
|
if (!links.length) {
|
||||||
return `${what} hop ${i + 1} <${hop.predicate}> holds a value, not a link. You cannot walk past it.`;
|
return `${what} hop ${i + 1} <${hop.predicate}> holds a value, not a link. You cannot walk past it.`;
|
||||||
}
|
}
|
||||||
shapeUri = f.node;
|
shapeUris = [...new Set(links.flatMap(f => f.nodes))];
|
||||||
} else if (isColumn) {
|
} else if (isColumn) {
|
||||||
if (wantsIri && f.datatype) {
|
// every field the path can end on must suit the column
|
||||||
return `${what} has sh:nodeKind sh:IRI but ends on the value "${f.name}", which is no URI. Drop sh:nodeKind.`;
|
for (const f of found) {
|
||||||
}
|
if (wantsIri && f.datatype) {
|
||||||
if (!wantsIri && !f.datatype && !f.class) {
|
return `${what} has sh:nodeKind sh:IRI but ends on the value "${f.name}", which is no URI. Drop sh:nodeKind.`;
|
||||||
const valueFields = fieldsOf(profile, f.node)
|
}
|
||||||
.filter(x => x.datatype || x.class)
|
if (!wantsIri && !f.datatype && !f.class) {
|
||||||
.map(x => `${x.name} (${x.path})`);
|
const valueFields = [...new Set(f.nodes.flatMap(uri => fieldsOf(profile, uri))
|
||||||
return `${what} ends on a link. Add a hop: ${valueFields.join(' or ')}. Only if the user asked for the URI of that node itself, add sh:nodeKind sh:IRI to the column instead.`;
|
.filter(x => x.datatype || x.class)
|
||||||
|
.map(x => `${x.name} (${x.path})`))];
|
||||||
|
return `${what} ends on a link. Add a hop: ${valueFields.join(' or ')}. Only if the user asked for the URI of that node itself, add sh:nodeKind sh:IRI to the column instead.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { hopsPattern, wherePatterns } from './seed.js';
|
|||||||
// Fetch column values for known subjects. Columns are grouped by their
|
// Fetch column values for known subjects. Columns are grouped by their
|
||||||
// shared non-leaf hops; two columns share a query only when every hop
|
// shared non-leaf hops; two columns share a query only when every hop
|
||||||
// before the leaf is identical, and the leaf is read the same way.
|
// before the leaf is identical, and the leaf is read the same way.
|
||||||
// rep:self columns (empty path) are filled by assemble.js.
|
// rep:self columns (empty path) are filled by assemble.js. Every query
|
||||||
|
// returns the nodes on the path too, so assemble.js can pair columns by the
|
||||||
|
// nodes they share.
|
||||||
|
|
||||||
const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100);
|
const SUBJECT_CHUNK_SIZE = Math.max(1, Number(process.env.SUBJECT_CHUNK_SIZE) || 100);
|
||||||
|
|
||||||
// fetchColumns(subjects, spec) → subject → column index → [term]
|
// fetchColumns(subjects, spec) → subject → column index → [chain], a chain
|
||||||
|
// being the terms on the path, one per step, the value last.
|
||||||
export async function fetchColumns(subjects, spec) {
|
export async function fetchColumns(subjects, spec) {
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
const groups = groupColumns(spec.columns);
|
const groups = groupColumns(spec.columns);
|
||||||
@@ -49,17 +52,19 @@ export function groupQuery(group, chunk) {
|
|||||||
// the end.
|
// the end.
|
||||||
const nodeVar = (k) => (k === group.hops.length ? '?v' : `?x_${k}`);
|
const nodeVar = (k) => (k === group.hops.length ? '?v' : `?x_${k}`);
|
||||||
lines.push(...wherePatterns(group.where, group.hops, nodeVar, 'c'));
|
lines.push(...wherePatterns(group.where, group.hops, nodeVar, 'c'));
|
||||||
return `SELECT ?s ?v WHERE {\n VALUES ?s { ${subjects} }\n ${lines.join('\n ')}\n}`;
|
const vars = ['?s', ...group.hops.slice(1).map((hop, i) => `?x_${i + 1}`), '?v'];
|
||||||
|
return `SELECT DISTINCT ${vars.join(' ')} WHERE {\n VALUES ?s { ${subjects} }\n ${lines.join('\n ')}\n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collect(result, group, values) {
|
function collect(result, group, values) {
|
||||||
for (const b of result.results.bindings) {
|
for (const b of result.results.bindings) {
|
||||||
|
const chain = [...group.hops.slice(1).map((hop, i) => b[`x_${i + 1}`]), b.v];
|
||||||
let perColumn = values.get(b.s.value);
|
let perColumn = values.get(b.s.value);
|
||||||
if (!perColumn) { perColumn = new Map(); values.set(b.s.value, perColumn); }
|
if (!perColumn) { perColumn = new Map(); values.set(b.s.value, perColumn); }
|
||||||
for (const col of group.columns) {
|
for (const col of group.columns) {
|
||||||
let list = perColumn.get(col.index);
|
let list = perColumn.get(col.index);
|
||||||
if (!list) { list = []; perColumn.set(col.index, list); }
|
if (!list) { list = []; perColumn.set(col.index, list); }
|
||||||
list.push(b.v);
|
list.push(chain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import N3 from 'n3';
|
import N3 from 'n3';
|
||||||
import { readFileSync, readdirSync } from 'fs';
|
import { readFileSync, readdirSync } from 'fs';
|
||||||
|
import { parseFilter, readRdfList } from './spec.js';
|
||||||
|
|
||||||
// Parse a profile (a SHACL shapes graph) and answer questions about it.
|
// Parse a profile (a SHACL shapes graph) and answer questions about it.
|
||||||
|
|
||||||
@@ -41,8 +42,8 @@ function loadProfile(file) {
|
|||||||
const targetClass = store.getQuads(shape, SH + 'targetClass', null)[0]?.object.value || null;
|
const targetClass = store.getQuads(shape, SH + 'targetClass', null)[0]?.object.value || null;
|
||||||
const label = store.getQuads(shape, 'http://www.w3.org/2000/01/rdf-schema#label', null)[0]?.object.value || null;
|
const label = store.getQuads(shape, 'http://www.w3.org/2000/01/rdf-schema#label', null)[0]?.object.value || null;
|
||||||
const fields = store.getQuads(shape, SH + 'property', null).map(q => parseField(store, q.object));
|
const fields = store.getQuads(shape, SH + 'property', null).map(q => parseField(store, q.object));
|
||||||
const discriminator = parseDiscriminator(store, shape);
|
const discriminators = parseDiscriminators(store, shape);
|
||||||
return { uri: shape.value, targetClass, label, fields, discriminator };
|
return { uri: shape.value, targetClass, label, fields, discriminators };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { uri, title, shapes: shapeList };
|
return { uri, title, shapes: shapeList };
|
||||||
@@ -64,25 +65,49 @@ function parseField(store, p) {
|
|||||||
name: one(SH + 'name'),
|
name: one(SH + 'name'),
|
||||||
datatype: one(SH + 'datatype'),
|
datatype: one(SH + 'datatype'),
|
||||||
class: one(SH + 'class'),
|
class: one(SH + 'class'),
|
||||||
node: one(SH + 'node'),
|
nodes: linkedShapes(store, p, one(SH + 'name')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// rep:discriminator tells shapes that share a targetClass apart. sh:minCount 1
|
// The entities a field links to: its sh:node, or the sh:node of each item of
|
||||||
// becomes a plain triple in the seed, sh:maxCount 0 a FILTER NOT EXISTS.
|
// its sh:or (SHACL for "each value is one of these").
|
||||||
function parseDiscriminator(store, shape) {
|
function linkedShapes(store, p, name) {
|
||||||
const node = store.getQuads(shape, REP + 'discriminator', null)[0]?.object;
|
const node = store.getQuads(p, SH + 'node', null)[0]?.object;
|
||||||
if (!node) return null;
|
if (node) return [node.value];
|
||||||
const { path, inverse } = readPath(store, node);
|
const or = store.getQuads(p, SH + 'or', null)[0]?.object;
|
||||||
const one = (pred) => store.getQuads(node, pred, null)[0]?.object?.value || null;
|
if (!or) return [];
|
||||||
const minCount = one(SH + 'minCount');
|
return readRdfList(store, or).map(item => {
|
||||||
const maxCount = one(SH + 'maxCount');
|
const n = store.getQuads(item, SH + 'node', null)[0]?.object;
|
||||||
if (!path || (minCount === null && maxCount === null)) return null;
|
if (!n) throw new Error(`field "${name}": every sh:or item needs a sh:node.`);
|
||||||
return {
|
return n.value;
|
||||||
path, inverse,
|
});
|
||||||
minCount: minCount !== null ? Number(minCount) : null,
|
}
|
||||||
maxCount: maxCount !== null ? Number(maxCount) : null,
|
|
||||||
|
// rep:discriminator tells shapes that share a targetClass apart. A shape may
|
||||||
|
// carry several; a subject meets them all.
|
||||||
|
function parseDiscriminators(store, shape) {
|
||||||
|
return store.getQuads(shape, REP + 'discriminator', null)
|
||||||
|
.map(q => parseDiscriminator(store, q.object, shape.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One discriminator: a condition written like a spec filter (sh:path plus
|
||||||
|
// sh:minCount, sh:maxCount, sh:in, sh:hasValue, ...), or sh:and / sh:or (a
|
||||||
|
// list) or sh:not (one) over discriminators, as in SHACL.
|
||||||
|
function parseDiscriminator(store, node, shapeUri) {
|
||||||
|
const one = (pred) => store.getQuads(node, SH + pred, null)[0]?.object;
|
||||||
|
const list = (pred) => {
|
||||||
|
const items = readRdfList(store, one(pred)).map(x => parseDiscriminator(store, x, shapeUri));
|
||||||
|
if (!items.length) throw new Error(`rep:discriminator on <${shapeUri}> has an empty sh:${pred}.`);
|
||||||
|
return items;
|
||||||
};
|
};
|
||||||
|
if (one('and')) return { and: list('and') };
|
||||||
|
if (one('or')) return { or: list('or') };
|
||||||
|
if (one('not')) return { not: parseDiscriminator(store, one('not'), shapeUri) };
|
||||||
|
const d = parseFilter(store, node);
|
||||||
|
if (!d.path.length || !Object.keys(d.constraints).length) {
|
||||||
|
throw new Error(`rep:discriminator on <${shapeUri}> needs a sh:path with a condition, or sh:and, sh:or or sh:not.`);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The shape a spec starts from. rep:entity wins; a targetClass only resolves
|
// The shape a spec starts from. rep:entity wins; a targetClass only resolves
|
||||||
@@ -101,8 +126,3 @@ export function shape(profile, shapeUri) {
|
|||||||
export function fieldsOf(profile, shapeUri) {
|
export function fieldsOf(profile, shapeUri) {
|
||||||
return shape(profile, shapeUri)?.fields || [];
|
return shape(profile, shapeUri)?.fields || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The field a hop takes from a shape, or null.
|
|
||||||
export function field(profile, shapeUri, hop) {
|
|
||||||
return fieldsOf(profile, shapeUri).find(f => f.path === hop.predicate && !!f.inverse === !!hop.inverse) || null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export function seedPageQuery(spec, limit, offset, shape = null) {
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
const targetClass = spec.targetClass || shape?.targetClass;
|
const targetClass = spec.targetClass || shape?.targetClass;
|
||||||
parts.push(`?s a ${sparqlEscapeUri(targetClass)} .`);
|
parts.push(`?s a ${sparqlEscapeUri(targetClass)} .`);
|
||||||
if (shape?.discriminator) parts.push(discriminatorPattern(shape.discriminator));
|
(shape?.discriminators || []).forEach((d, di) => {
|
||||||
|
parts.push(discriminatorPattern(d, `disc${di}`));
|
||||||
|
});
|
||||||
spec.filters.forEach((filter, fi) => {
|
spec.filters.forEach((filter, fi) => {
|
||||||
parts.push(filterPattern(filter, `f${fi}`));
|
parts.push(filterPattern(filter, `f${fi}`));
|
||||||
});
|
});
|
||||||
@@ -36,13 +38,20 @@ LIMIT ${limit} OFFSET ${offset}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A shape that shares its class with another says here what sets its
|
// A shape that shares its class with another says here what sets its
|
||||||
// subjects apart: sh:minCount 1 is a plain triple, sh:maxCount 0 a NOT EXISTS.
|
// subjects apart. A plain discriminator is a filter; sh:and, sh:or and sh:not
|
||||||
function discriminatorPattern(d) {
|
// become one FILTER over EXISTS tests, which see ?s from outside.
|
||||||
const p = sparqlEscapeUri(d.path);
|
function discriminatorPattern(d, varPrefix) {
|
||||||
const triple = d.inverse ? `?disc ${p} ?s .` : `?s ${p} ?disc .`;
|
if (!d.and && !d.or && !d.not) return filterPattern(d, varPrefix);
|
||||||
if (d.minCount !== null && d.minCount >= 1) return triple;
|
return `FILTER(${discriminatorTest(d, varPrefix)})`;
|
||||||
if (d.maxCount === 0) return `FILTER NOT EXISTS { ${triple} }`;
|
}
|
||||||
return null;
|
|
||||||
|
function discriminatorTest(d, varPrefix) {
|
||||||
|
if (d.and) return `(${d.and.map((x, i) => discriminatorTest(x, `${varPrefix}_${i}`)).join(' && ')})`;
|
||||||
|
if (d.or) return `(${d.or.map((x, i) => discriminatorTest(x, `${varPrefix}_${i}`)).join(' || ')})`;
|
||||||
|
if (d.not) return `!(${discriminatorTest(d.not, `${varPrefix}_n`)})`;
|
||||||
|
const { maxCount, ...test } = d.constraints;
|
||||||
|
if (maxCount === 0) return `NOT EXISTS { ${filterPattern({ ...d, constraints: test }, varPrefix)} }`;
|
||||||
|
return `EXISTS { ${filterPattern(d, varPrefix)} }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes out every hop as its own triple pattern.
|
// Writes out every hop as its own triple pattern.
|
||||||
@@ -66,13 +75,15 @@ export function hopsPattern(hops, varPrefix, startVar, lastVar = `?${varPrefix}_
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One filter: its condition, plus its rep:where conditions on the nodes it
|
// One filter: its condition, plus its rep:where conditions on the nodes it
|
||||||
// walks through. With sh:maxCount 0 the whole of it becomes "none such".
|
// walks through. With sh:maxCount 0 the whole of it becomes "none such";
|
||||||
|
// with a value test too (sh:in, sh:hasValue, ...), "none with such a value".
|
||||||
function filterPattern(filter, varPrefix) {
|
function filterPattern(filter, varPrefix) {
|
||||||
|
const { maxCount, ...test } = filter.constraints;
|
||||||
|
if (maxCount === 0) {
|
||||||
|
return `FILTER NOT EXISTS {\n ${filterPattern({ ...filter, constraints: test }, varPrefix)}\n }`;
|
||||||
|
}
|
||||||
const nodeVar = (k) => (k === filter.path.length ? `?${varPrefix}_v` : `?${varPrefix}_${k}`);
|
const nodeVar = (k) => (k === filter.path.length ? `?${varPrefix}_v` : `?${varPrefix}_${k}`);
|
||||||
const wheres = wherePatterns(filter.where, filter.path, nodeVar, varPrefix);
|
const wheres = wherePatterns(filter.where, filter.path, nodeVar, varPrefix);
|
||||||
if (filter.constraints.maxCount === 0) {
|
|
||||||
return `FILTER NOT EXISTS {\n ${[hopsPattern(filter.path, varPrefix, '?s'), ...wheres].join('\n ')}\n }`;
|
|
||||||
}
|
|
||||||
return [conditionPattern(filter.constraints, varPrefix, filter.path, '?s'), ...wheres].join('\n ');
|
return [conditionPattern(filter.constraints, varPrefix, filter.path, '?s'), ...wheres].join('\n ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +100,11 @@ export function wherePatterns(where, hostHops, nodeVar, varPrefix) {
|
|||||||
// A condition: the hops from startVar and the test on where they end. A
|
// A condition: the hops from startVar and the test on where they end. A
|
||||||
// filter starts at ?s; a rep:where starts at a node of its host and, with no
|
// filter starts at ?s; a rep:where starts at a node of its host and, with no
|
||||||
// hops left, tests that node itself. sh:maxCount 0 means "none such", so its
|
// hops left, tests that node itself. sh:maxCount 0 means "none such", so its
|
||||||
// hops only live inside the NOT EXISTS.
|
// hops and any value test only live inside the NOT EXISTS.
|
||||||
export function conditionPattern(c, varPrefix, hops, startVar) {
|
export function conditionPattern(c, varPrefix, hops, startVar) {
|
||||||
if (c.maxCount === 0) {
|
if (c.maxCount === 0) {
|
||||||
return `FILTER NOT EXISTS { ${hopsPattern(hops, varPrefix + '_nex', startVar)} }`;
|
const { maxCount, ...test } = c;
|
||||||
|
return `FILTER NOT EXISTS { ${conditionPattern(test, varPrefix + '_nex', hops, startVar)} }`;
|
||||||
}
|
}
|
||||||
const v = hops.length ? `?${varPrefix}_v` : startVar;
|
const v = hops.length ? `?${varPrefix}_v` : startVar;
|
||||||
const parts = hops.length ? [hopsPattern(hops, varPrefix, startVar)] : [];
|
const parts = hops.length ? [hopsPattern(hops, varPrefix, startVar)] : [];
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ export function parseSpec(turtle) {
|
|||||||
const quads = parser.parse(turtle);
|
const quads = parser.parse(turtle);
|
||||||
const store = new N3.Store(quads);
|
const store = new N3.Store(quads);
|
||||||
|
|
||||||
const specNode = store.getQuads(null, RDF + 'type', REP + 'ReportSpec')[0]?.subject;
|
const specNodes = store.getQuads(null, RDF + 'type', REP + 'ReportSpec').map(q => q.subject);
|
||||||
if (!specNode) throw new Error('spec has no rep:ReportSpec');
|
if (!specNodes.length) throw new Error('spec has no rep:ReportSpec');
|
||||||
|
// One spec is one report. A second one would pass unchecked and never run.
|
||||||
|
if (specNodes.length > 1) {
|
||||||
|
throw new Error(`one spec per answer. This Turtle has ${specNodes.length} rep:ReportSpec. Keep one, and offer the other as the next report.`);
|
||||||
|
}
|
||||||
|
const specNode = specNodes[0];
|
||||||
|
|
||||||
const one = (s, pred) => store.getQuads(s, pred, null)[0]?.object || null;
|
const one = (s, pred) => store.getQuads(s, pred, null)[0]?.object || null;
|
||||||
|
|
||||||
@@ -31,7 +36,7 @@ export function parseSpec(turtle) {
|
|||||||
return { uri: specNode.value, title, profileUri, targetClass, entity, filters, columns };
|
return { uri: specNode.value, title, profileUri, targetClass, entity, filters, columns };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFilter(store, p) {
|
export function parseFilter(store, p) {
|
||||||
let path = [];
|
let path = [];
|
||||||
const pathQuad = store.getQuads(p, SH + 'path', null)[0]?.object;
|
const pathQuad = store.getQuads(p, SH + 'path', null)[0]?.object;
|
||||||
if (pathQuad) path = parsePath(store, pathQuad);
|
if (pathQuad) path = parsePath(store, pathQuad);
|
||||||
@@ -134,7 +139,7 @@ function readConstraints(store, p, { withLabel = false } = {}) {
|
|||||||
return constraints;
|
return constraints;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRdfList(store, node) {
|
export function readRdfList(store, node) {
|
||||||
const items = [];
|
const items = [];
|
||||||
let cur = node;
|
let cur = node;
|
||||||
while (cur && cur.value !== RDF + 'nil') {
|
while (cur && cur.value !== RDF + 'nil') {
|
||||||
|
|||||||
Reference in New Issue
Block a user