Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
11fb9f1
An assistant drawer: a question becomes a Web-Algebra plan, shown bef…
namedgraph Oct 1, 2026
c86fdcb
Serve the agent guide at /AGENTS.md, and say in it that the default g…
namedgraph Oct 1, 2026
e1c567d
The assistant is part of the dataspace's panel, and its rows name wha…
namedgraph Oct 1, 2026
ff8f738
The model key is an optional secret, declared in the override like th…
namedgraph Oct 2, 2026
b08a230
The assistant hands the planner the dataspace's ontology, and the age…
namedgraph Oct 3, 2026
4271ff4
An ASK is answered with a boolean, which the assistant's query check …
namedgraph Oct 3, 2026
1171bdf
A plan the service refused reports as a failure in the drawer, with t…
namedgraph Oct 3, 2026
6083097
A plan that stopped folds out the XML of the steps it did enter
namedgraph Oct 3, 2026
62055e1
The assistant is a block in the content body, with its composer docke…
namedgraph Oct 3, 2026
0b94d9e
The composer is as wide as the content column, and starts where it st…
namedgraph Oct 3, 2026
5b41651
The assistant is a component of the app kit: ldh-chat- classes, the k…
namedgraph Oct 3, 2026
50e870c
The card answers in words, with the trace folded under it
namedgraph Oct 3, 2026
a03ef91
A result is a block in the card, drawn by the plan's hint, and Keep m…
namedgraph Oct 3, 2026
206ea13
A write's report is not a result, and the folded trace shows it opens…
namedgraph Oct 3, 2026
5c39553
Keep goes: a result reaches the document by asking for it
namedgraph Oct 3, 2026
0e91e74
A view lists what an aliased first variable binds, and says when it b…
namedgraph Oct 4, 2026
64c4dec
A conversation is a block: each chat has its own composer, is stored …
namedgraph Oct 4, 2026
2d54190
The assistant reads how to show a result as the client's mode URIs, a…
namedgraph Oct 4, 2026
30bbfd3
An operation's XML fills its code field
namedgraph Oct 5, 2026
5b2c3ab
Merge remote-tracking branch 'origin/develop' into feat-assistant-drawer
namedgraph Oct 5, 2026
e350e47
The assistant spec passes the admin base positionally, as ldh takes i…
namedgraph Oct 5, 2026
5e40999
Core 5.0.6 arrives through Web-Client 6.0.4, so the snapshot pin and …
namedgraph Oct 5, 2026
b286469
A bar chart's value axis starts at zero, a chart's Save hides with it…
namedgraph Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env_sample
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ OWNER_STATE_OR_PROVINCE=Denmark
OWNER_COUNTRY_NAME=DK

MAX_CONTENT_LENGTH=2097152

# the assistant's plan service image tag (ghcr.io/atomgraph/webalgebra-server-ldh); `local` for an image built from a REST-VKG checkout
# WEBALGEBRA_VERSION=latest
# the OpenAI model the assistant plans and writes queries with; gpt-4o when unset
# OPENAI_MODEL=gpt-4o
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ The dataspace exposes a **read-only SPARQL 1.1 Query** endpoint (advertised via

Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions.

**The default graph is empty.** Every document is a named graph whose name is the document's URL, so a triple pattern outside `GRAPH` matches nothing. Put the patterns inside `GRAPH ?g { ... }` to query across every document, or inside `GRAPH <document URL> { ... }` for one document; a subquery needs its own `GRAPH` as well. A query that returns no rows without a `GRAPH` clause is not evidence that the data is absent.

**One document per resource, so a join crosses graphs.** A resource is described in the document about it (`foaf:primaryTopic`), and the resources it links to - an order's customer, a product's category, an employee's manager - are each described in their own document, in another graph. A pattern that follows such a link therefore needs a `GRAPH` of its own for each resource: `GRAPH ?o { ?order schema:customer ?customer } GRAPH ?c { ?customer schema:legalName ?name }`, never both triples in one `GRAPH ?g`, which asks for them in the same document and matches nothing. Only a resource's own companions - its address, its contact point, an order's line items - share its document. When in doubt, give every subject variable its own graph variable: it costs nothing when two resources do share a document, and it is the only pattern that works when they do not.

**A property is used on instances of its domain.** The ontology (`Link rel=lds:ontology`) declares each property's `rdfs:domain` and `rdfs:range`, and the data follows it: a property declared on `schema:Person` is not on the `schema:Corporation` that employs the person. To reach it, follow the ontology's path - the corporation's `schema:employee`, then the person's `schema:address`.

Outbound `SERVICE` and `LOAD` — from the triplestore and from the platform alike — are routed through the `egress` forward proxy, which refuses loopback, private and link-local destinations. A federated query reaches public endpoints and cannot reach the deployment's own services. With no proxy configured and `ALLOW_INTERNAL_URLS` unset, in-JVM `SERVICE` (in a `PATCH` update or an import mapping) is disabled outright.

## Content & document model
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [Unreleased]
### Added
- An assistant for a signed-in reader: a request in natural language becomes a Web-Algebra plan, shown with its operations and its XML, and runs only on Execute; the steps, the documents the plan wrote and its result are reported as they happen, and the result is answered in words and drawn as a table, a list, a grid or a chart
- A conversation is an `ldh:Chat` block, placed in the document by an object block: the create bar's Assistant button starts one, each ends in its own composer, and every turn is stored as an `ldh:ChatTurn` with its plan and what the execution reported, so a chat is there when the reader comes back
- A `web-algebra` service in the stack (`ghcr.io/atomgraph/webalgebra-server-ldh`), reached through nginx at the reserved `/webalgebra` path with a WebID certificate on the connection; it acts for the reader through the secretary agent's delegation, so a plan may write exactly what its reader may. Needs the `openai_api_key` secret
- The agent guide (`AGENTS.md`) is served at `/AGENTS.md` on every dataspace origin, public and outside the dispatcher, so a client writing a query against the endpoint - the assistant's `SPARQLString` reads it as service documentation - learns that every document is a named graph and the default graph is empty

## [6.1.0] - 2026-10-05
### Migration
- **BREAKING**: `ldh` drops `-b`/`--base`; dataspace-level commands take the base URI as their positional, defaulting to `LDH_BASE`
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The application runs as a multi-container setup:
- **linkeddatahub**: Main Java application (Tomcat)
- **fuseki**: One SPARQL server holding a TDB2 dataset per dataspace role (`config/fuseki/config.ttl`), named after the dataspace origin (deployment host dropped, role appended: `end-user`, `admin`, `northwind-traders.demo.end-user`, …), each under `fuseki/<dataset>/`; bound to apps in `config/system.trig`
- **egress**: Squid forward proxy for the store's and platform's outbound requests (SPARQL `SERVICE`, `LOAD`): public destinations only, so a query cannot reach another dataset, Varnish or the platform
- **web-algebra**: REST-VKG's `webalgebra-server-ldh` image — executes Web-Algebra plans and writes them from natural language for the assistant drawer (`xsl/client/chat.xsl`). nginx forwards `/webalgebra` (a reserved path, like `/static/` and `/uploads/`) to it with the caller's certificate; it presents the secretary's certificate to this instance with `On-Behalf-Of` naming the caller, which `WebIDFilter` honours because every agent's WebID document carries `<secretary> acl:delegates <agent>`. It addresses the instance by its public origin, rewritten to `nginx:9443` (`WEBALGEBRA_PROXY_HOST`), the same trick the platform's own `ClientUriRewriteFilter` plays
- **varnish-frontend/varnish-admin/varnish-end-user**: Caching layers (admin and end-user caches both front the single `fuseki`)

### Data Flow
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ RUN mvn -B -Pstandalone dependency:go-offline

COPY src /usr/src/platform/src

# the agent guide the WAR serves at /AGENTS.md (pom.xml, maven-war-plugin webResources)
COPY AGENTS.md /usr/src/platform/AGENTS.md

RUN mvn -Pstandalone clean install
# ==============================

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The [`ldh` command line interface](#command-line-interface) is attached to every
- `secrets/client_truststore_password.txt`
- `secrets/owner_cert_password.txt`
- `secrets/secretary_cert_password.txt`
- `secrets/openai_api_key.txt` — optional: an OpenAI API key for the assistant drawer, declared in `docker-compose.override.yml` like the OAuth secrets; without it the assistant runs plans but cannot write them
The one you will need to remember in order to authenticate with LinkedDataHub using WebID client certificate is `owner_cert_password`.
5. Launch the application services by running this from command line:
```shell
Expand Down Expand Up @@ -217,6 +218,8 @@ _:warning: Do not use blank nodes to identify applications or services. We recom
<dd>Password of the secretary's WebID certificate</dd>
<dt><code>client_truststore_password</code></dt>
<dd>Password of the client truststore</dd>
<dt><code>openai_api_key</code></dt>
<dd>OpenAI API key the <code>web-algebra</code> service writes the assistant's plans with. Optional, declared in the override together with <code>OPENAI_API_KEY_FILE</code>; the service executes plans without it, and the drawer then reports that a plan could not be written</dd>
<dt><code>google_client_id</code></dt>
<dd>Google's OAuth client ID</dd>
<dd>Login with Google authentication is enabled when this value is provided</dd>
Expand Down
65 changes: 65 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ secrets:
file: ./secrets/secretary_cert_password.txt
client_truststore_password:
file: ./secrets/client_truststore_password.txt
# openai_api_key:
# file: ./secrets/openai_api_key.txt
# google_client_id:
# file: ./secrets/google/client_id.txt
# google_client_secret:
Expand All @@ -28,6 +30,8 @@ services:
depends_on:
linkeddatahub:
condition: service_healthy
web-algebra:
condition: service_started # its upstream has to resolve when nginx starts
ports:
- ${HTTP_PORT}:8080 # allow Tomcat to do HTTP to HTTPS redirect
- ${HTTPS_PORT}:8443 # HTTPS
Expand Down Expand Up @@ -149,6 +153,38 @@ services:
target: /etc/squid/squid.conf
expose:
- 3128
web-algebra: # runs Web-Algebra plans against this instance, and writes them from natural language for the assistant drawer
image: ghcr.io/atomgraph/webalgebra-server-ldh:${WEBALGEBRA_VERSION:-latest}
mem_limit: 1024m
restart: on-failure
depends_on:
- egress
expose:
- 8080 # internal only: nginx is the sole client, and the server trusts the Client-Cert header only because nginx sets it
environment:
# the origin that receives an identity - this instance, with every dataspace subdomain under it. Its public name is
# not a route to it from inside the network, so requests for it go to nginx's client-cert listener under that name
- WEBALGEBRA_TRUSTED_ORIGIN=${PROTOCOL}://${HOST}:${HTTPS_PORT}
- WEBALGEBRA_PROXY_HOST=nginx
- WEBALGEBRA_PROXY_PORT=9443
# the secretary's WebID: the agent every user's WebID document already delegates (root-owner.trig.template, SignUp),
# so a plan submitted by a user runs under that user's own access control, not the secretary's
- WEBALGEBRA_KEYSTORE=/var/webalgebra/ssl/secretary/keystore.p12
- WEBALGEBRA_KEYSTORE_PASSWORD_FILE=/run/secrets/secretary_cert_password
- WEBALGEBRA_TRUSTSTORE=/var/webalgebra/ssl/server/server.crt # the self-signed server certificate, as PEM
# the model key is optional, like the OAuth secrets: a deployment that writes plans declares the openai_api_key
# secret and this variable in its override; without them the service runs plans and declines to write any
# - OPENAI_API_KEY_FILE=/run/secrets/openai_api_key
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
# public destinations (SPARQL endpoints a plan names, the model's API) go through egress like the platform's own; nginx
# is reached directly, since egress refuses internal addresses
- JAVA_TOOL_OPTIONS=-Dhttp.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttp.proxyPort=3128 -Dhttps.proxyHost=${EGRESS_PROXY_HOST:-egress} -Dhttps.proxyPort=3128 -Dhttp.nonProxyHosts=nginx
secrets:
- secretary_cert_password
# - openai_api_key
volumes:
- ./ssl/secretary:/var/webalgebra/ssl/secretary:ro
- ./ssl/server:/var/webalgebra/ssl/server:ro
varnish-frontend:
image: varnish:7.7.3
user: root # otherwise varnish user does not have permissions to the mounted folder which is owner by root
Expand Down Expand Up @@ -200,6 +236,10 @@ configs:
}

http {
upstream web-algebra {
server web-algebra:8080;
}

upstream linkeddatahub {
server ${NGINX_UPSTREAM_SERVER:-varnish-frontend:6060};
}
Expand All @@ -217,6 +257,7 @@ configs:

limit_req_zone $$limit_key zone=linked_data:10m rate=15r/s;
limit_req_zone $$limit_key zone=static_files:10m rate=20r/s;
limit_req_zone $$limit_key zone=web_algebra:10m rate=5r/s; # a plan is a model call; polling is what bursts
limit_req_status 429;

client_max_body_size ${MAX_CONTENT_LENGTH:-2097152};
Expand All @@ -231,6 +272,30 @@ configs:
ssl_prefer_server_ciphers on;
ssl_verify_client ${NGINX_SSL_VERIFY_CLIENT:-optional_no_ca};

# the assistant's plan service. A reserved path, like /static/ and /uploads/: prefix-matched ahead of the
# generic location and never rewritten, so /webalgebra, /webalgebra/plans and /webalgebra/{id}/result reach the
# sidecar as they are. Reachable only with a WebID certificate on the connection: the sidecar acts for the
# WebID in the certificate nginx forwards, and a caller without one has nobody to act for. On-Behalf-Of is
# cleared for the same reason Client-Cert is - identity comes from the connection, never from a header
location ^~ /webalgebra {
if ($$ssl_client_verify = NONE) {
return 401;
}

proxy_pass http://web-algebra;
limit_req zone=web_algebra burst=10 nodelay;
proxy_read_timeout 120s; # writing a plan is a 10-30 s model call

proxy_set_header Host $$host;
proxy_set_header X-Forwarded-Host $$host;
proxy_set_header X-Forwarded-Proto $$scheme;
proxy_set_header X-Forwarded-Port ${HTTPS_PORT};

proxy_set_header Client-Cert '';
proxy_set_header Client-Cert $$ssl_client_escaped_cert;
proxy_set_header On-Behalf-Of '';
}

location / {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" always;
Expand Down
15 changes: 13 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,13 @@
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>client</artifactId>
<version>6.0.3</version>
<version>6.0.4</version>
<classifier>classes</classifier>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>client</artifactId>
<version>6.0.3</version>
<version>6.0.4</version>
<type>war</type>
</dependency>
<dependency>
Expand Down Expand Up @@ -399,6 +399,17 @@
<webappDirectory>${project.build.directory}/${build.warName}</webappDirectory>
<failOnMissingWebXml>true</failOnMissingWebXml>
<attachClasses>true</attachClasses>
<!-- the agent guide is served at /AGENTS.md on every dataspace origin (web.xml maps it to the
default servlet), so a query-writing client can read how the endpoint is laid out; one file,
kept at the repository root where agents expect it, and shipped from there -->
<webResources>
<resource>
<directory>${basedir}</directory>
<includes>
<include>AGENTS.md</include>
</includes>
</resource>
</webResources>
<overlays>
<overlay>
<groupId>${project.groupId}</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ private LDH() { }
public static final Resource CSVImport = ResourceFactory.createResource(NS + "CSVImport");
/** ldh:RDFImport class */
public static final Resource RDFImport = ResourceFactory.createResource(NS + "RDFImport");
/** ldh:Chat class: a conversation with the assistant, its turns the rdf:_N members */
public static final Resource Chat = ResourceFactory.createResource(NS + "Chat");
/** ldh:ChatTurn class: one question and what came of it */
public static final Resource ChatTurn = ResourceFactory.createResource(NS + "ChatTurn");
/** ldh:MissingPropertyValue constraint class */
public static final Resource MissingPropertyValue = ResourceFactory.createResource(NS + "MissingPropertyValue");
/** ldh:ChildrenView resource */
Expand All @@ -62,6 +66,16 @@ private LDH() { }
public static final Property delimiter = ResourceFactory.createProperty(NS + "delimiter");
/** ldh:chartType property */
public static final Property chartType = ResourceFactory.createProperty(NS + "chartType");
/** ldh:question property: what a chat turn asked */
public static final Property question = ResourceFactory.createProperty(NS + "question");
/** ldh:answer property: the assistant's answer in words */
public static final Property answer = ResourceFactory.createProperty(NS + "answer");
/** ldh:plan property: the turn's Web-Algebra plan, an rdf:XMLLiteral */
public static final Property plan = ResourceFactory.createProperty(NS + "plan");
/** ldh:execution property: what running the plan reported, an rdf:XMLLiteral */
public static final Property execution = ResourceFactory.createProperty(NS + "execution");
/** ldh:outcome property: how the turn went, in one line */
public static final Property outcome = ResourceFactory.createProperty(NS + "outcome");
/** ldh:categoryVarName property */
public static final Property categoryVarName = ResourceFactory.createProperty(NS + "categoryVarName");
/** ldh:seriesVarName property */
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import com.atomgraph.core.io.ModelProvider;
import com.atomgraph.core.io.QueryProvider;
import com.atomgraph.core.io.ResultSetProvider;
import com.atomgraph.core.io.SPARQLResultProvider;
import com.atomgraph.core.io.UpdateRequestProvider;
import com.atomgraph.core.mapper.BadGatewayExceptionMapper;
import com.atomgraph.core.provider.QueryParamProvider;
Expand Down Expand Up @@ -1072,6 +1073,7 @@ public void init()

register(new ValidatingModelProvider(getMessageDigest()));
register(new ResultSetProvider());
register(new SPARQLResultProvider()); // the boolean of an ASK, which ResultSetProvider cannot write
register(new QueryProvider());
register(new QueryParamProvider());
register(new UpdateRequestProvider());
Expand Down Expand Up @@ -1723,6 +1725,7 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje
config.register(new ModelProvider());
config.register(new DatasetProvider());
config.register(new ResultSetProvider());
config.register(new SPARQLResultProvider());
config.register(new QueryProvider());
config.register(new UpdateRequestProvider());
config.property(ClientProperties.FOLLOW_REDIRECTS, true);
Expand Down Expand Up @@ -1831,6 +1834,7 @@ public void releaseConnection(final HttpClientConnection managedConn, final Obje
config.register(new ModelProvider());
config.register(new DatasetProvider());
config.register(new ResultSetProvider());
config.register(new SPARQLResultProvider());
config.register(new QueryProvider());
config.register(new UpdateRequestProvider()); // TO-DO: UpdateRequestProvider
config.property(ClientProperties.FOLLOW_REDIRECTS, true);
Expand Down
Loading
Loading