Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ jobs:
# strip the literal prefix
echo "FULL_VERSION=${RAW_REF#linkeddatahub-}" >> $GITHUB_ENV

# The CLI resolves linkeddatahub-rdf at its own version, which the tagged checkout holds. Maven
# Central does not: release.sh deploys the library after pushing the tag, and Central publishes
# it minutes later, while this job runs seconds after the push.
- name: Build the linkeddatahub-rdf library
run: mvn -B install
working-directory: rdf

# release.sh keeps cli/pom.xml at the platform version, so the tagged commit already carries it
# and the jar manifest ldh --version reads gets it from there
- name: Build the ldh CLI
Expand Down
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
2 changes: 1 addition & 1 deletion cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.atomgraph</groupId>
<artifactId>linkeddatahub-cli</artifactId>
<version>6.1.0</version>
<version>6.1.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>LinkedDataHub CLI</name>
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
19 changes: 15 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

<groupId>com.atomgraph</groupId>
<artifactId>linkeddatahub</artifactId>
<version>6.1.0</version>
<version>6.1.1-SNAPSHOT</version>
<packaging>${packaging.type}</packaging>

<name>AtomGraph LinkedDataHub</name>
Expand Down Expand Up @@ -46,7 +46,7 @@
<url>https://github.com/AtomGraph/LinkedDataHub</url>
<connection>scm:git:git://github.com/AtomGraph/LinkedDataHub.git</connection>
<developerConnection>scm:git:git@github.com:AtomGraph/LinkedDataHub.git</developerConnection>
<tag>linkeddatahub-6.1.0</tag>
<tag>linkeddatahub-5.5.4</tag>
</scm>

<repositories>
Expand Down 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
2 changes: 1 addition & 1 deletion rdf/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.atomgraph</groupId>
<artifactId>linkeddatahub-rdf</artifactId>
<version>6.1.0</version>
<version>6.1.1-SNAPSHOT</version>
<packaging>jar</packaging>

<name>LinkedDataHub RDF</name>
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