Skip to content
Merged
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to Web Algebra are recorded here. The format follows
[Semantic Versioning](https://semver.org/). The normative definition of every
operation is [formal-semantics.md](formal-semantics.md).

## [Unreleased]

### Added

- **`Reconcile`** (formal-semantics.md §4.7): the candidates a reconciliation
service offers for a name, as a `Result` of `entity`, `name`,
`description`, `score` and `match`, best first. It speaks the W3C Entity
Reconciliation Service API to any `endpoint` — Wikidata's is one — with an
optional `type` id and `limit`; the manifest's `identifierSpace` resolves the
candidate ids to URIs. The XML serialization's executor has it too, so the
two serializations keep the same catalog.

### Fixed

- **`GET` asks for N-Triples, then Turtle**: the RDF media types it accepts
are ranked (`application/n-triples, text/turtle;q=0.9,
application/ld+json;q=0.8, application/rdf+xml;q=0.7`). Unranked, a server
choosing among equals picked freely, and Wikidata picked RDF/XML, whose
hashed `rdf:nodeID`s start with a digit and so are not the NCNames RDF/XML
requires: rdflib refused every Wikidata entity (Wikimedia T252731). A `GET`
of `http://www.wikidata.org/entity/Q…` now reads it, as `Reconcile`'s
candidates need.

## [2.0.3] - 2026-10-29

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ The operations cover read-write Linked Data, SPARQL queries, URI manipulation, a
- `ExtractDatatypeProperties`
- `ExtractObjectProperties`
- `ExtractOntology`
- Reconciliation
- `Reconcile`
- URI & String Operations
- `ResolveURI`
- `EncodeForURI`
Expand Down
33 changes: 33 additions & 0 deletions formal-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,39 @@ property.
datatype and object properties) as one graph, each scoped by the same
`bindings`.

### 4.7 Reconciliation

**Reconcile** — the entities a reconciliation service offers for a name, as
a `Result`. *Query* effect.
```
Abstract: URI × Literal × Maybe Literal × Maybe Literal → Result
Python: def execute(self, endpoint: URIRef, query: Literal,
type: Literal | None = None, limit: Literal | None = None) -> Result
JSON: endpoint: URI · query: string Literal · type: Maybe string Literal
· limit: Maybe xsd:integer Literal
```

The service at `endpoint` speaks the W3C Entity Reconciliation Service API
(https://www.w3.org/community/reports/reconciliation/CG-FINAL-specs-0.2-20230410/),
a JSON protocol, as SPARQL is spoken to an endpoint by `SELECT`: Wikidata's
service (`https://wikidata-reconciliation.wmcloud.org/en/api`) is one
`endpoint` of many. The service's manifest — a `GET` of `endpoint` — gives the
`identifierSpace` its candidate ids are local to; a manifest without one
raises `ValueError`. The query is sent as the protocol's `queries` form field,
with `type` (one of the service's type ids, Wikidata's `Q515` for a city)
restricting the candidates and `limit` capping how many come back.

The `Result` has the variables `entity`, `name`, `description`, `score` and
`match`, and one row per candidate in the service's order, best first:
`entity` is the candidate's id resolved against the identifier space (RFC
3986), `name` its name, `description` its description when the service gives
one (unbound otherwise), `score` an `xsd:decimal` and `match` an
`xsd:boolean`, whether the service takes the candidate as the match. A name
nothing reconciles to is the empty `Result`. A response that is not JSON
raises `ValueError`; an answer outside 2xx propagates as the transport failure
it is (§3.7), as a `GET`'s does. What the chosen entity says is a `GET` of its
`entity` away, as a row of `SELECT` is dereferenced (§4.4).

## 5. Conformance notes

- The JSON serialization here and the XML serialization used by REST-VKG are
Expand Down
73 changes: 70 additions & 3 deletions src/web_algebra/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
"application/rdf+xml": "xml",
}

# What a GET asks for: the RDF media types in MEDIA_TYPES' order, ranked by
# quality value, so a server choosing among them takes N-Triples, then Turtle.
# Unranked, they are equally acceptable and the server picks: Wikidata picks
# RDF/XML, whose rdf:nodeIDs it writes as hashes that start with a digit - not
# XML NCNames, so not RDF/XML rdflib will parse (Wikimedia T252731, declined
# 2020 in favour of T244341, still open). The same nodes are fine in N-Triples
# and Turtle, whose blank node labels may start with a digit.
RDF_ACCEPT = ", ".join(
media_type if rank == 0 else f"{media_type};q={1 - rank / 10:.1f}"
for rank, media_type in enumerate(MEDIA_TYPES)
)

# HTTP methods that change the resource they address. A client reports each one
# it completes to its recorder, and that report is the only source of an
# execution's `affected_documents` — derived from what was actually sent, not
Expand Down Expand Up @@ -260,9 +272,7 @@ def get(self, url: str) -> Graph:
:param url: The URL to fetch RDF data from.
:return: An RDFLib Graph object containing the parsed RDF data.
"""
# Set the Accept header
accept_header = ", ".join(MEDIA_TYPES.keys())
headers = {"Accept": accept_header}
headers = {"Accept": RDF_ACCEPT}
request = urllib.request.Request(url, headers=headers)

# Perform the HTTP request
Expand Down Expand Up @@ -560,3 +570,60 @@ def takes_graph(self, endpoint_url: str) -> bool:

# Endpoints by whether they take GRAPH in a query (`SPARQLClient.takes_graph`).
_TAKES_GRAPH: dict = {}


class ReconciliationClient:
"""A client of a W3C Entity Reconciliation Service
(https://www.w3.org/community/reports/reconciliation/CG-FINAL-specs-0.2-20230410/):
its manifest, and the candidates it offers for a query. JSON in, JSON out.
The constructor is the other clients', so `ClientOperation` builds it the same way."""

def __init__(
self,
cert_pem_path: Optional[str] = None,
cert_password: Optional[str] = None,
verify_ssl: bool = True,
ca_bundle: Optional[str] = None,
recorder: Optional[WriteRecorder] = None,
):
self.recorder = recorder
self.ssl_context = ssl.create_default_context(cafile=ca_bundle)
if cert_pem_path and cert_password:
self.ssl_context.load_cert_chain(certfile=cert_pem_path, password=cert_password)
if not verify_ssl:
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=self.ssl_context),
RetryAfterHandler(),
)
self.opener.addheaders = [
(
"User-Agent",
"Web-Algebra/1.0 (LinkedData Processing System; https://github.com/atomgraph/Web-Algebra)",
)
]

def _json(self, request: urllib.request.Request) -> dict:
response = send(self.opener, request)
try:
return json.loads(response.read().decode("utf-8"))
except ValueError as e:
raise ValueError(f"The reconciliation service at {request.full_url} answered with no JSON: {e}") from None

def manifest(self, endpoint_url: str) -> dict:
"""The service's manifest: a GET of the endpoint."""
return self._json(urllib.request.Request(endpoint_url, headers={"Accept": "application/json"}))

def reconcile(self, endpoint_url: str, queries: dict) -> dict:
"""The candidates for `queries` (keyed as the protocol keys them, q0, q1, ...),
sent as the `queries` form field."""
body = urllib.parse.urlencode({"queries": json.dumps(queries)}).encode("utf-8")
request = urllib.request.Request(
endpoint_url,
data=body,
method="POST",
headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"},
)
return self._json(request)

123 changes: 123 additions & 0 deletions src/web_algebra/operations/reconcile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import json
import logging
from decimal import Decimal
from typing import Any, ClassVar, Optional, Type

from mcp import types
from rdflib import Literal, URIRef
from rdflib.namespace import XSD
from rdflib.query import Result

from web_algebra.client import ReconciliationClient
from web_algebra.client_operation import ClientOperation
from web_algebra.json_result import JSONResult
from web_algebra.mcp_tool import MCPTool
from web_algebra.operation import Operation
from web_algebra.rfc3986 import resolve


class Reconcile(ClientOperation, Operation, MCPTool):
"""The candidates a reconciliation service offers for a name, as a Result
(formal-semantics.md §4.7).

Speaks the W3C Entity Reconciliation Service API to the service at
`endpoint`: its manifest gives the `identifierSpace` the candidate ids are
local to, and the query goes as the `queries` form field. One row per
candidate, best first, binding `entity` (the id resolved against the
identifier space), `name`, `description` (when the service gives one),
`score` (xsd:decimal) and `match` (xsd:boolean).
"""

client_class: ClassVar[Type] = ReconciliationClient

VARS: ClassVar[list] = ["entity", "name", "description", "score", "match"]

@classmethod
def description(cls) -> str:
return (
"The entities a reconciliation service (the W3C Reconciliation Service API) offers for a "
"name, as a result: one row per candidate, best first, binding ?entity (a URI), ?name, "
"?description, ?score and ?match. `type` is one of the service's type ids, `limit` caps "
"the rows. Wikidata's service is https://wikidata-reconciliation.wmcloud.org/en/api, "
"whose type ids are Wikidata items (Q515 city, Q6256 country, Q43229 organization)."
)

@classmethod
def inputSchema(cls) -> dict:
return {
"type": "object",
"properties": {
"endpoint": {"type": "string", "description": "The reconciliation service's URL."},
"query": {"type": "string", "description": "The name to reconcile."},
"type": {"type": "string", "description": "Optional: one of the service's type ids, e.g. Q515."},
"limit": {"type": "integer", "description": "Optional: at most this many candidates."},
},
"required": ["endpoint", "query"],
}

def execute(
self,
endpoint: URIRef,
query: Literal,
type: Optional[Literal] = None,
limit: Optional[Literal] = None,
) -> Result:
if not isinstance(endpoint, URIRef):
raise TypeError(f"Reconcile.execute expects endpoint to be URIRef, got {endpoint!r}")
if not Operation.is_string_compatible(query):
raise TypeError(f"Reconcile.execute expects query to be a string Literal, got {query!r}")
if type is not None and not Operation.is_string_compatible(type):
raise TypeError(f"Reconcile.execute expects type to be a string Literal, got {type!r}")
if limit is not None and not (isinstance(limit, Literal) and isinstance(limit.toPython(), int)):
raise TypeError(f"Reconcile.execute expects limit to be an xsd:integer Literal, got {limit!r}")

q0: dict = {"query": str(query)}
if type is not None and str(type).strip():
q0["type"] = str(type).strip()
if limit is not None:
q0["limit"] = limit.toPython()

identifier_space = self.client.manifest(str(endpoint)).get("identifierSpace")
if not identifier_space:
raise ValueError(f"The reconciliation service at {endpoint} declares no identifierSpace in its manifest")

logging.info("Reconcile %s against %s", q0, endpoint)
candidates = (self.client.reconcile(str(endpoint), {"q0": q0}).get("q0") or {}).get("result") or []

rows = []
for candidate in candidates:
if candidate.get("id") is None:
continue
row = {"entity": URIRef(resolve(identifier_space, str(candidate["id"])))}
if candidate.get("name") is not None:
row["name"] = Literal(str(candidate["name"]))
if candidate.get("description"):
row["description"] = Literal(str(candidate["description"]))
if candidate.get("score") is not None:
row["score"] = Literal(Decimal(str(candidate["score"])), datatype=XSD.decimal)
row["match"] = Literal(bool(candidate.get("match", False)), datatype=XSD.boolean)
rows.append(row)

return JSONResult(vars=self.VARS, bindings=rows)

def execute_json(self, arguments: dict, variable_stack: list = None) -> Result:
def argument(name: str):
if name not in arguments:
return None
return Operation.json_to_rdflib(
Operation.process_json(self.settings, arguments[name], self.context, variable_stack)
)

endpoint = argument("endpoint")
if not isinstance(endpoint, URIRef):
raise TypeError(f"Reconcile operation expects 'endpoint' to be URIRef, got {type(endpoint)}")
return self.execute(endpoint, argument("query"), argument("type"), argument("limit"))

def mcp_run(self, arguments: dict, context: Any = None) -> Any:
result = self.execute(
URIRef(arguments["endpoint"]),
Literal(arguments["query"], datatype=XSD.string),
Literal(arguments["type"], datatype=XSD.string) if arguments.get("type") else None,
Literal(int(arguments["limit"]), datatype=XSD.integer) if arguments.get("limit") is not None else None,
)
return [types.TextContent(type="text", text=json.dumps(result.to_json()))]
9 changes: 9 additions & 0 deletions src/web_algebra/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,15 @@ The classes, datatype properties and object properties of the data at a SPARQL e
{"@op": "ExtractOntology", "args": {"endpoint": {"@id": "https://dbpedia.org/sparql"}}}
```

## Reconcile(endpoint: URI, query: string, type?: string, limit?: integer) → Result

The entities a reconciliation service (the W3C Reconciliation Service API) offers for a name: one row per candidate, best first, binding `entity` (a URI), `name`, `description` (when there is one), `score` and `match` (whether the service takes it as the match). `type` is one of the service's type ids. Wikidata's service is `https://wikidata-reconciliation.wmcloud.org/en/api`, whose type ids are Wikidata items (`Q515` city, `Q6256` country, `Q43229` organization). The chosen entity's description is a `GET` of its `entity`.

### Example JSON
```json
{"@op": "Reconcile", "args": {"endpoint": {"@id": "https://wikidata-reconciliation.wmcloud.org/en/api"}, "query": "Aarhus", "type": "Q515", "limit": 5}}
```

# LinkedDataHub operations

These write to a LinkedDataHub instance. Every write returns the one-row `status`/`url` result. A document's **content** (what its page shows) is its sequence of blocks: only `ldh-AddXHTMLBlock` and `ldh-AddObjectBlock` add to it. The other `ldh-Add*` operations write a resource *into* the document (a query, a view, a chart, a service, a file) without showing it; to show it, place it with `ldh-AddObjectBlock`, its URI as `value` — so give it a `fragment`.
Expand Down
8 changes: 7 additions & 1 deletion tests/http_stub.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,16 @@ def empty_sparql_answer(request: urllib.request.Request) -> StubResponse:

def default_handler(request: urllib.request.Request) -> StubResponse:
"""HEAD: 200, no ETag. SPARQL query: an empty answer of its form.
GET: an empty Turtle graph. Writes: 200, no Location."""
Reconciliation (formal-semantics.md §4.7): a manifest for a GET asking for
JSON, no candidates for a POST of `queries`. GET: an empty Turtle graph.
Writes: 200, no Location."""
method = request.get_method()
if sparql_query_text(request) is not None:
return empty_sparql_answer(request)
if method == "POST" and "queries" in urllib.parse.parse_qs(request_body_text(request)):
return StubResponse(200, {"Content-Type": "application/json"}, json.dumps({"q0": {"result": []}}).encode())
if method == "GET" and (request_header(request, "Accept") or "") == "application/json":
return StubResponse(200, {"Content-Type": "application/json"}, json.dumps({"name": "Stub", "identifierSpace": "http://example.org/entity/"}).encode())
if method == "HEAD":
return StubResponse(200)
if method == "GET":
Expand Down
7 changes: 5 additions & 2 deletions tests/test_system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@

import json
import re
import urllib.parse
from pathlib import Path

import pytest
from rdflib import BNode, Literal, URIRef
from rdflib.namespace import XSD

from tests.http_stub import StubResponse, default_handler
from tests.http_stub import StubResponse, default_handler, request_body_text
from tests.plan_check import SPEC_ENTRIES, check_static
from web_algebra.operation import Operation

Expand Down Expand Up @@ -176,7 +177,9 @@ def _render(value):


def _offline(request):
if request.get_method() in ("POST", "PUT", "PATCH", "DELETE"):
# a reconciliation query is a POST that reads (§4.7): the default answers it
reconciles = "queries" in urllib.parse.parse_qs(request_body_text(request))
if request.get_method() in ("POST", "PUT", "PATCH", "DELETE") and not reconciles:
return StubResponse(200)
return default_handler(request)

Expand Down
14 changes: 13 additions & 1 deletion tests/unit/test_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pytest
from rdflib import Graph, Literal, URIRef

from tests.http_stub import StubResponse
from tests.http_stub import StubResponse, request_header
from web_algebra.operation import Operation


Expand Down Expand Up @@ -65,3 +65,15 @@ def test_read_answered_non_2xx_propagates_http_error(
op = Operation.get("GET")(settings=settings)
with pytest.raises(urllib.error.HTTPError):
op.execute(URIRef("http://example.org/doc"))


class TestGETAccept:
def test_asks_for_n_triples_then_turtle(self, settings, http_stub):
# ranked, so a server choosing among equals does not pick RDF/XML: Wikidata's
# rdf:nodeIDs are not NCNames and rdflib refuses them (Wikimedia T252731)
http_stub.handler = lambda request: StubResponse(200, {"Content-Type": "application/n-triples"}, b"")
Operation.get("GET")(settings=settings).execute(URIRef("http://example.org/doc"))

accept = request_header(http_stub.requests[0], "Accept")
assert accept == "application/n-triples, text/turtle;q=0.9, application/ld+json;q=0.8, application/rdf+xml;q=0.7"

Loading
Loading