From dc9d3fc37047d0dca19d85e55d761408f6bd0a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 11:46:11 +0200 Subject: [PATCH 1/8] ldh-* operations take the LinkedDataHub CLI's options; a live parity test against the CLI Appendix A: what the operations write is what LinkedDataHub's CLI writes for the same inputs. The options they lacked are added, written as the CLI's rdf module writes them: - ldh-CreateContainer: block (the first content block), mode (a children view in that layout), primary_topic (resolved against the document); - ldh-CreateItem: description, primary_topic; - ldh-AddObjectBlock: graph (ldh:graph); - ldh-AddResultSetChart: series_var_name may be a sequence, one series each; - ldh-AddView: show_when_empty (ldh:showWhenEmpty, from the v6 vocabulary). The mode enums of AddView and AddObjectBlock, which left out ac:TableMode among others, are gone: a mode is any layout URI, as in the CLI. tests/test_ldh_cli_parity.py (marker ldh, needs LDH_CLI) runs every ldh-* operation and the CLI command with the same inputs, every option included, and requires the same graph, setting aside the server's timestamps and the fragment IDs it mints for blank nodes. It passes against a develop build of LinkedDataHub and its CLI, and fails, naming the triple, when an operation leaves one out. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 + CHANGELOG.md | 7 + formal-semantics.md | 28 ++- .../linkeddatahub/add_result_set_chart.py | 33 ++- .../operations/linkeddatahub/add_view.py | 37 ++- .../linkeddatahub/content/add_object_block.py | 45 +++- .../linkeddatahub/create_container.py | 89 ++++++- .../operations/linkeddatahub/create_item.py | 62 ++++- src/web_algebra/prompts/system.md | 20 +- tests/test_ldh_cli_parity.py | 232 ++++++++++++++++++ tests/unit/test_ldh_cli_options.py | 195 +++++++++++++++ 11 files changed, 699 insertions(+), 54 deletions(-) create mode 100644 tests/test_ldh_cli_parity.py create mode 100644 tests/unit/test_ldh_cli_options.py diff --git a/AGENTS.md b/AGENTS.md index 4c65fcb..1a11a56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,11 @@ docs, or a commit — use the variables. - `ldh` — needs a LinkedDataHub v6 instance and the certificate variables. The examples run against `https://localhost:4443/` (`LDH_BASE` selects another) and remove what they wrote unless `WA_KEEP_EXAMPLES=1`. + `tests/test_ldh_cli_parity.py` also needs `LDH_CLI`, the launcher of + LinkedDataHub's CLI (`cli/bin/ldh` of a checkout built with `mvn package`): + it runs every `ldh-*` operation and the CLI command for the same inputs and + requires the two documents to be the same graph. Run it after changing an + `ldh-*` operation, against the LinkedDataHub version you target. - `network`, `sparql` — need live HTTP or SPARQL endpoints. - `unit` and `integration` are applied automatically to `tests/unit/` and `tests/integration/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fc878..8e9033c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,13 @@ many are breaking. ### Added +- **The `ldh-*` operations take the LinkedDataHub CLI's options**, and write + them as it does: `ldh-CreateContainer` `block`, `mode` and `primary_topic`; + `ldh-CreateItem` `description` and `primary_topic`; `ldh-AddObjectBlock` + `graph`; several series for `ldh-AddResultSetChart`; and + `ldh-AddView` `show_when_empty`. A `mode` is any layout URI. + `tests/test_ldh_cli_parity.py` runs each operation against the CLI on a live + LinkedDataHub and requires the same document. - **[formal-semantics.md](formal-semantics.md)**: abstract syntax, value domains, big-step evaluation rules, effects and ordering, the error table, the full catalog with an `Abstract:` signature per operation, and the diff --git a/formal-semantics.md b/formal-semantics.md index 43f8898..bfa7650 100644 --- a/formal-semantics.md +++ b/formal-semantics.md @@ -1197,16 +1197,16 @@ operations' MCP tools, and an MCP tool name allows only ASCII letters, digits, | Operation | JSON args (`Maybe` = optional) | |-----------|--------------------------------| -| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal` | -| `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal` | +| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · block: Maybe URI · mode: Maybe URI · primary_topic: Maybe Literal` | +| `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · primary_topic: Maybe Literal` | | `ldh-List` *(query)* | `url: URI · endpoint: URI` (or `base: Literal`, from which `endpoint` = `base` + `sparql`) | | `ldh-AddFile` | `url: URI · file: Literal (path) · title: Literal · description: Maybe Literal · content_type: Maybe Literal` | | `ldh-AddGenericService` | `url: URI · endpoint: URI · title: Literal · description/fragment: Maybe Literal · graph_store: Maybe URI · auth_user/auth_pwd: Maybe Literal` | -| `ldh-AddResultSetChart` | `url: URI · query: URI · title: Literal · chart_type: URI · category_var_name: Literal · series_var_name: Literal · description/fragment: Maybe Literal` | +| `ldh-AddResultSetChart` | `url: URI · query: URI · title: Literal · chart_type: URI · category_var_name: Literal · series_var_name: Literal + Sequence Literal · description/fragment: Maybe Literal` | | `ldh-AddSelect` | `url: URI · query: Literal · title: Literal · description/fragment: Maybe Literal · service: Maybe URI` | | `ldh-AddConstruct` | `url: URI · query: Literal · title: Literal · description/fragment: Maybe Literal · service: Maybe URI` | -| `ldh-AddView` | `url: URI · query: URI · title: Literal · description/fragment: Maybe Literal · mode: Maybe URI` | -| `ldh-AddObjectBlock` | `url: URI · value: URI · title/description/fragment: Maybe Literal · mode: Maybe URI` | +| `ldh-AddView` | `url: URI · query: URI · title: Literal · description/fragment: Maybe Literal · mode: Maybe URI · show_when_empty: Maybe Literal (xsd:boolean)` | +| `ldh-AddObjectBlock` | `url: URI · value: URI · title/description/fragment: Maybe Literal · mode: Maybe URI · graph: Maybe URI` | | `ldh-AddXHTMLBlock` | `url: URI · value: Literal (XHTML) · title/description/fragment: Maybe Literal` | | `ldh-RemoveBlock` | `url: URI · block: Maybe URI` | | `ldh-GenerateOntologyViews` *(pure)* | `ontology: Graph · base_uri: URI · service_uri: URI` | @@ -1218,6 +1218,24 @@ URI extended by `slug` and `/`; without a `slug` the segment is a fresh lowercase UUID, as in the LinkedDataHub CLI. The write is a `PUT`, so a document already at that URI is replaced. +What the operations write is what LinkedDataHub's CLI writes for the same +inputs (its `rdf` module), so a document made either way is the same: + +- `ldh-CreateContainer` gives the container its first content block, + `rdf:_1`: `block` itself when given; otherwise an `ldh:Object` whose + `rdf:value` is a new `ldh:View` of `ldh:SelectChildren` in `ac:mode` `mode` + when `mode` is given; otherwise an `ldh:Object` of `ldh:ChildrenView`. +- `primary_topic` (`ldh-CreateContainer`, `ldh-CreateItem`) is a URI + reference resolved against the document's URI (RFC 3986), written as the + document's one `foaf:primaryTopic` — `#this` names the conventional + fragment. +- `ldh-AddObjectBlock`'s `graph` is the block's `ldh:graph`: the document + that describes `value`, read in its place. +- `ldh-AddResultSetChart` writes one `ldh:seriesVarName` per name of + `series_var_name`, a name or a sequence of names. +- `ldh-AddView`'s `show_when_empty` is the view's `ldh:showWhenEmpty` + (LinkedDataHub shows an empty view unless it is `false`). + A `fragment` names the resource an operation creates in the document, `url#fragment`; it is given with or without the leading `#`. Without one the resource is a blank node. diff --git a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py index 214ca9f..7470153 100644 --- a/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py +++ b/src/web_algebra/operations/linkeddatahub/add_result_set_chart.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Sequence, Union import logging from rdflib import Literal, URIRef from rdflib.namespace import XSD @@ -67,8 +67,9 @@ def inputSchema(cls) -> dict: "description": "Name of the SPARQL variable used as category (without leading '?').", }, "series_var_name": { - "type": "string", - "description": "Name of the SPARQL variable used as series/value (without leading '?').", + "type": ["string", "array"], + "items": {"type": "string"}, + "description": "Name of the SPARQL variable used as series/value (without leading '?'), or an array of names for several series.", }, "description": { "type": "string", @@ -129,7 +130,11 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - series_var_name_data = Operation.process_json( self.settings, arguments["series_var_name"], self.context, variable_stack ) - series_var_name_literal = self.to_string_literal(series_var_name_data) + # a name, or a sequence of names - one series each + if isinstance(series_var_name_data, list): + series_var_name_literal = [self.to_string_literal(name) for name in series_var_name_data] + else: + series_var_name_literal = self.to_string_literal(series_var_name_data) # Process optional arguments description_literal = None @@ -164,7 +169,7 @@ def execute( title: Literal, chart_type: URIRef, category_var_name: Literal, - series_var_name: Literal, + series_var_name: Union[Literal, Sequence[Literal]], description: Optional[Literal] = None, fragment: Optional[Literal] = None, ) -> Any: @@ -191,11 +196,10 @@ def execute( raise TypeError( f"AddResultSetChart.execute expects category_var_name to be string Literal, got {type(category_var_name)}" ) - if ( - not Operation.is_string_literal(series_var_name) - ): + series_var_names = series_var_name if isinstance(series_var_name, (list, tuple)) else [series_var_name] + if not series_var_names or not all(Operation.is_string_literal(name) for name in series_var_names): raise TypeError( - f"AddResultSetChart.execute expects series_var_name to be string Literal, got {type(series_var_name)}" + f"AddResultSetChart.execute expects series_var_name to be a string Literal or a non-empty sequence of them, got {series_var_name!r}" ) if description is not None and ( not Operation.is_string_compatible(description) @@ -215,7 +219,7 @@ def execute( title_str = Operation.jsonld_text(title) chart_type_str = str(chart_type) category_var_name_str = str(category_var_name) - series_var_name_str = str(series_var_name) + series_var_name_strs = [str(name) for name in series_var_names] description_str = Operation.jsonld_text(description) if description else None fragment_str = fragment_id(fragment) @@ -244,7 +248,7 @@ def execute( "spin:query": {"@id": query_str}, "ldh:chartType": {"@id": chart_type_str}, "ldh:categoryVarName": category_var_name_str, - "ldh:seriesVarName": series_var_name_str, + "ldh:seriesVarName": series_var_name_strs if len(series_var_name_strs) > 1 else series_var_name_strs[0], } # Add optional properties - matching shell script conditional logic @@ -267,7 +271,12 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: title = Literal(arguments["title"], datatype=XSD.string) chart_type = URIRef(arguments["chart_type"]) category_var_name = Literal(arguments["category_var_name"], datatype=XSD.string) - series_var_name = Literal(arguments["series_var_name"], datatype=XSD.string) + names = arguments["series_var_name"] + series_var_name = ( + [Literal(name, datatype=XSD.string) for name in names] + if isinstance(names, list) + else Literal(names, datatype=XSD.string) + ) description = None if "description" in arguments: diff --git a/src/web_algebra/operations/linkeddatahub/add_view.py b/src/web_algebra/operations/linkeddatahub/add_view.py index e99004e..4fb38a0 100644 --- a/src/web_algebra/operations/linkeddatahub/add_view.py +++ b/src/web_algebra/operations/linkeddatahub/add_view.py @@ -66,14 +66,11 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the display mode for the view.", - "enum": [ - "https://w3id.org/atomgraph/client#ReadMode", - "https://w3id.org/atomgraph/client#ListMode", - "https://w3id.org/atomgraph/client#GridMode", - "https://w3id.org/atomgraph/client#GraphMode", - "https://w3id.org/atomgraph/client#MapMode", - ], + "description": "Optional URI of the view's layout mode, e.g. https://w3id.org/atomgraph/client#TableMode, #ListMode, #GridMode, #ReadMode, #GraphMode, #MapMode.", + }, + "show_when_empty": { + "type": "boolean", + "description": "Optional: whether the view is shown when its query returns no results (ldh:showWhenEmpty). LinkedDataHub shows it unless this is false.", }, }, "required": ["url", "query", "title"], @@ -129,6 +126,12 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - ) mode_uri = mode_data + show_when_empty = None + if "show_when_empty" in arguments: + show_when_empty = Operation.json_to_rdflib(Operation.process_json( + self.settings, arguments["show_when_empty"], self.context, variable_stack + )) + return self.execute( url_data, query_data, @@ -136,6 +139,7 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - description_literal, fragment_literal, mode_uri, + show_when_empty, ) def execute( @@ -146,6 +150,7 @@ def execute( description: Optional[Literal] = None, fragment: Optional[Literal] = None, mode: Optional[URIRef] = None, + show_when_empty: Optional[Literal] = None, ) -> Any: """Pure function: create SPARQL result set view with RDFLib terms""" if not isinstance(url, URIRef): @@ -177,6 +182,13 @@ def execute( f"AddView.execute expects mode to be URIRef, got {type(mode)}" ) + if show_when_empty is not None and not ( + isinstance(show_when_empty, Literal) and show_when_empty.datatype == XSD.boolean + ): + raise TypeError( + f"AddView.execute expects show_when_empty to be an xsd:boolean Literal, got {show_when_empty!r}" + ) + url_str = str(url) query_str = str(query) title_str = Operation.jsonld_text(title) @@ -218,6 +230,9 @@ def execute( data["@context"]["ac"] = "https://w3id.org/atomgraph/client#" data["ac:mode"] = {"@id": mode_str} + if show_when_empty is not None: + data["ldh:showWhenEmpty"] = show_when_empty.toPython() + logging.info(f"Posting View with JSON-LD data: {data}") # Convert the JSON-LD content to a Graph and POST to the target URI @@ -245,8 +260,12 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: if "mode" in arguments: mode = URIRef(arguments["mode"]) + show_when_empty = ( + Literal(bool(arguments["show_when_empty"])) if "show_when_empty" in arguments else None + ) + # Call pure function - result = self.execute(url, query, title, description, fragment, mode) + result = self.execute(url, query, title, description, fragment, mode, show_when_empty) # Return status for MCP response status_binding = result.bindings[0]["status"] diff --git a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py index d49765c..c032977 100644 --- a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py +++ b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py @@ -65,14 +65,11 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the block mode. Defaults to ReadMode if not specified.", - "enum": [ - "https://w3id.org/atomgraph/client#GraphMode", - "https://w3id.org/atomgraph/client#MapMode", - "https://w3id.org/atomgraph/client#ChartMode", - "https://w3id.org/atomgraph/client#ReadMode", - ], - "default": "https://w3id.org/atomgraph/client#ReadMode", + "description": "Optional URI of the block's layout mode, e.g. https://w3id.org/atomgraph/client#ReadMode (the default), #GraphMode, #MapMode, #ChartMode, #TableMode, #GridMode, #ListMode.", + }, + "graph": { + "type": "string", + "description": "Optional URI of the document that describes the value, read in place of dereferencing the value's URI (ldh:graph).", }, }, "required": ["url", "value"], @@ -130,6 +127,17 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - ) mode_uri = mode_data + graph_uri = None + if "graph" in arguments: + graph_data = Operation.process_json( + self.settings, arguments["graph"], self.context, variable_stack + ) + if not isinstance(graph_data, URIRef): + raise TypeError( + f"AddObjectBlock operation expects 'graph' to be URIRef, got {type(graph_data)}" + ) + graph_uri = graph_data + return self.execute( url_data, value_data, @@ -137,6 +145,7 @@ def execute_json(self, arguments: dict[str, str], variable_stack: list = None) - description_literal, fragment_literal, mode_uri, + graph_uri, ) def execute( @@ -147,6 +156,7 @@ def execute( description: Literal = None, fragment: Literal = None, mode: URIRef = None, + graph: URIRef = None, ) -> Any: """Pure function: create object block with RDFLib terms""" if not isinstance(url, URIRef): @@ -179,6 +189,10 @@ def execute( raise TypeError( f"AddObjectBlock.execute expects mode to be URIRef, got {type(mode)}" ) + if graph is not None and not isinstance(graph, URIRef): + raise TypeError( + f"AddObjectBlock.execute expects graph to be URIRef, got {type(graph)}" + ) url_str = str(url) value_str = str(value) @@ -195,12 +209,12 @@ def execute( # Step 1: Get current document to find next sequence number get_op = GET(settings=self.settings, context=self.context) - graph = get_op.execute(url) + document = get_op.execute(url) # Convert Graph to JSON-LD for processing import json - jsonld_str = graph.serialize(format="json-ld") + jsonld_str = document.serialize(format="json-ld") doc = json.loads(jsonld_str) # Step 2: Extract sequence numbers from rdf:_N properties in JSON-LD list @@ -267,14 +281,17 @@ def execute( if mode_str: block["ac:mode"] = {"@id": mode_str} + if graph is not None: + # the document describing the value, read in its place (ldh:graph) + block["ldh:graph"] = {"@id": str(graph)} + # Add object block to the data structure data[sequence_property] = block logging.info(f"Posting object block with JSON-LD data: {data}") # Step 6: Convert the JSON-LD content to a Graph and POST to the target URI - graph = self.to_graph(data, base=url_str) - return super().execute(url, graph) + return super().execute(url, self.to_graph(data, base=url_str)) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -300,8 +317,10 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: if "mode" in arguments: mode = URIRef(arguments["mode"]) + graph = URIRef(arguments["graph"]) if "graph" in arguments else None + # Call pure function - result = self.execute(url, value, title, description, fragment, mode) + result = self.execute(url, value, title, description, fragment, mode, graph) # Return status for MCP response status_binding = result.bindings[0]["status"] diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index c39b8b9..1cc217d 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -6,6 +6,7 @@ from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation +from web_algebra.rfc3986 import resolve from web_algebra.operations.linked_data.put import PUT from rdflib.query import Result @@ -48,6 +49,18 @@ def inputSchema(cls) -> dict: "type": "string", "description": "Description (optional)", }, + "block": { + "type": "string", + "description": "URI of the container's first content block (optional). Without it, the block lists the container's children.", + }, + "mode": { + "type": "string", + "description": "URI of the layout mode (e.g. ac:GridMode) the children are listed in (optional; ignored when block is given).", + }, + "primary_topic": { + "type": "string", + "description": "URI reference of what the container is about, resolved against its URI (optional), e.g. #this.", + }, }, "required": ["parent", "title"], } @@ -58,6 +71,9 @@ def execute( title: Literal, slug: Optional[Literal] = None, description: Optional[Literal] = None, + block: Optional[URIRef] = None, + mode: Optional[URIRef] = None, + primary_topic: Optional[Literal] = None, ) -> Result: """Pure function: create container with RDFLib terms""" if not isinstance(parent_uri, URIRef): @@ -76,6 +92,12 @@ def execute( raise TypeError( f"CreateContainer.execute expects description to be Literal or None, got {type(description)}" ) + for name, term, typ in (("block", block, URIRef), ("mode", mode, URIRef), ("primary_topic", primary_topic, Literal)): + if term is not None and not isinstance(term, typ): + raise TypeError( + f"CreateContainer.execute expects {name} to be {typ.__name__} or None, got {type(term)}" + ) + # URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that # leaving it out never lands on an existing document; deriving it from the title did, on a repeated title slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="") @@ -99,15 +121,32 @@ def execute( "dct": "http://purl.org/dc/terms/", "sioc": "http://rdfs.org/sioc/ns#", "xsd": "http://www.w3.org/2001/XMLSchema#", + "spin": "http://spinrdf.org/spin#", + "ac": "https://w3id.org/atomgraph/client#", + "foaf": "http://xmlns.com/foaf/0.1/", }, "@id": url, "@type": "dh:Container", "dct:title": Operation.jsonld_text(title), - "rdf:_1": {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}}, } + # the first content block, as the LinkedDataHub CLI writes it: the given + # block; else a view of the children in the given mode; else the + # children view (formal-semantics.md Appendix A) + if block is not None: + data["rdf:_1"] = {"@id": str(block)} + elif mode is not None: + data["rdf:_1"] = { + "@type": "ldh:Object", + "rdf:value": {"@type": "ldh:View", "spin:query": {"@id": "ldh:SelectChildren"}, "ac:mode": {"@id": str(mode)}}, + } + else: + data["rdf:_1"] = {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}} if description: data["dct:description"] = Operation.jsonld_text(description) + if primary_topic is not None: + # a URI reference resolved against the document (RFC 3986) + data["foaf:primaryTopic"] = {"@id": resolve(url, str(primary_topic))} # Convert the document to a Graph and call parent PUT operation graph = self.to_graph(data, base=url) @@ -161,7 +200,44 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: f"ldh-CreateContainer operation expects 'description' to be Literal, got {type(description)}" ) - return self.execute(parent_uri, title, slug, description) + + # Process optional block + block = None + if "block" in arguments: + block_data = Operation.process_json( + self.settings, arguments["block"], self.context, variable_stack + ) + block = Operation.json_to_rdflib(block_data) + if not isinstance(block, URIRef): + raise TypeError( + f"ldh-CreateContainer operation expects 'block' to be URIRef, got {type(block)}" + ) + + # Process optional mode + mode = None + if "mode" in arguments: + mode_data = Operation.process_json( + self.settings, arguments["mode"], self.context, variable_stack + ) + mode = Operation.json_to_rdflib(mode_data) + if not isinstance(mode, URIRef): + raise TypeError( + f"ldh-CreateContainer operation expects 'mode' to be URIRef, got {type(mode)}" + ) + + # Process optional primary_topic + primary_topic = None + if "primary_topic" in arguments: + primary_topic_data = Operation.process_json( + self.settings, arguments["primary_topic"], self.context, variable_stack + ) + primary_topic = Operation.json_to_rdflib(primary_topic_data) + if not isinstance(primary_topic, Literal): + raise TypeError( + f"ldh-CreateContainer operation expects 'primary_topic' to be Literal, got {type(primary_topic)}" + ) + + return self.execute(parent_uri, title, slug, description, block, mode, primary_topic) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -178,7 +254,14 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: else None ) - result = self.execute(parent_uri, title, slug, description) + block = URIRef(arguments["block"]) if "block" in arguments else None + mode = URIRef(arguments["mode"]) if "mode" in arguments else None + primary_topic = ( + Literal(arguments["primary_topic"], datatype=XSD.string) + if "primary_topic" in arguments + else None + ) + result = self.execute(parent_uri, title, slug, description, block, mode, primary_topic) # Extract URL from PUT result url_binding = result.bindings[0]["url"] diff --git a/src/web_algebra/operations/linkeddatahub/create_item.py b/src/web_algebra/operations/linkeddatahub/create_item.py index 940d86d..974d015 100644 --- a/src/web_algebra/operations/linkeddatahub/create_item.py +++ b/src/web_algebra/operations/linkeddatahub/create_item.py @@ -6,6 +6,7 @@ from rdflib.namespace import XSD from mcp import types from web_algebra.operation import Operation +from web_algebra.rfc3986 import resolve from web_algebra.operations.linked_data.put import PUT from rdflib.query import Result @@ -41,6 +42,11 @@ def inputSchema(cls) -> dict: "type": "string", "description": "URL path segment (optional). Defaults to a random UUID. A document that already exists at this path is replaced.", }, + "description": {"type": "string", "description": "Description (optional)"}, + "primary_topic": { + "type": "string", + "description": "URI reference of what the item is about, resolved against its URI (optional), e.g. #this.", + }, }, "required": ["container", "title"], } @@ -50,6 +56,8 @@ def execute( container_uri: URIRef, title: Literal, slug: Optional[Literal] = None, + description: Optional[Literal] = None, + primary_topic: Optional[Literal] = None, ) -> Result: """Pure function: create item with RDFLib terms""" if not isinstance(container_uri, URIRef): @@ -65,6 +73,15 @@ def execute( f"CreateItem.execute expects slug to be Literal or None, got {type(slug)}" ) + if description is not None and not Operation.is_string_compatible(description): + raise TypeError( + f"CreateItem.execute expects description to be a string Literal or None, got {type(description)}" + ) + if primary_topic is not None and not isinstance(primary_topic, Literal): + raise TypeError( + f"CreateItem.execute expects primary_topic to be Literal or None, got {type(primary_topic)}" + ) + # URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that # leaving it out never lands on an existing document; deriving it from the title did, on a repeated title slug_str = urllib.parse.quote(str(slug) if slug else str(uuid.uuid4()), safe="") @@ -86,11 +103,17 @@ def execute( "dct": "http://purl.org/dc/terms/", "sioc": "http://rdfs.org/sioc/ns#", "xsd": "http://www.w3.org/2001/XMLSchema#", + "foaf": "http://xmlns.com/foaf/0.1/", }, "@id": url, "@type": "dh:Item", "dct:title": Operation.jsonld_text(title), } + if description: + data["dct:description"] = Operation.jsonld_text(description) + if primary_topic is not None: + # a URI reference resolved against the document (RFC 3986) + data["foaf:primaryTopic"] = {"@id": resolve(url, str(primary_topic))} # Convert the document to a Graph and call parent PUT operation graph = self.to_graph(data, base=url) @@ -132,7 +155,32 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: f"ldh-CreateItem operation expects 'slug' to be Literal, got {type(slug)}" ) - return self.execute(container_uri, title, slug) + + # Process optional description + description = None + if "description" in arguments: + description_data = Operation.process_json( + self.settings, arguments["description"], self.context, variable_stack + ) + description = Operation.json_to_rdflib(description_data) + if not isinstance(description, Literal): + raise TypeError( + f"ldh-CreateItem operation expects 'description' to be Literal, got {type(description)}" + ) + + # Process optional primary_topic + primary_topic = None + if "primary_topic" in arguments: + primary_topic_data = Operation.process_json( + self.settings, arguments["primary_topic"], self.context, variable_stack + ) + primary_topic = Operation.json_to_rdflib(primary_topic_data) + if not isinstance(primary_topic, Literal): + raise TypeError( + f"ldh-CreateItem operation expects 'primary_topic' to be Literal, got {type(primary_topic)}" + ) + + return self.execute(container_uri, title, slug, description, primary_topic) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -144,7 +192,17 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: else None ) - result = self.execute(container_uri, title, slug) + description = ( + Literal(arguments["description"], datatype=XSD.string) + if "description" in arguments + else None + ) + primary_topic = ( + Literal(arguments["primary_topic"], datatype=XSD.string) + if "primary_topic" in arguments + else None + ) + result = self.execute(container_uri, title, slug, description, primary_topic) # Extract URL from PUT result url_binding = result.bindings[0]["url"] diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index 9dc3c08..3682264 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -560,18 +560,18 @@ The classes, datatype properties and object properties of the data at a SPARQL e 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`. -## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal) → Result +## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, block?: URI, mode?: URI, primary_topic?: Literal) → Result -Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. +Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block is `block` when given; otherwise its children, listed in layout `mode` (e.g. `https://w3id.org/atomgraph/client#GridMode`) when given. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). ### Example JSON ```json {"@op": "ldh-CreateContainer", "args": {"parent": {"@id": "https://localhost:4443/"}, "title": "Danish cities", "slug": "denmark"}} ``` -## ldh-CreateItem(container: URI, title: Literal, slug?: Literal) → Result +## ldh-CreateItem(container: URI, title: Literal, slug?: Literal, description?: Literal, primary_topic?: Literal) → Result -Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). A document that already exists at that URL is replaced. +Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). A document that already exists at that URL is replaced. `primary_topic` is what the item is about, a URI reference resolved against its URL (`#this` for a resource described in it). ### Example JSON ```json @@ -627,9 +627,9 @@ Writes a stored SPARQL `CONSTRUCT` query (`sp:Construct`) into the document, as } ``` -## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI) → Result +## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI, show_when_empty?: Literal) → Result -Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. `mode` is a layout: `https://w3id.org/atomgraph/client#ReadMode`, `#ListMode`, `#GridMode`, `#GraphMode` or `#MapMode`. To show the view, place it with `ldh-AddObjectBlock`. +Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. `mode` is a layout such as `https://w3id.org/atomgraph/client#TableMode`, `#ListMode`, `#GridMode`, `#ReadMode`, `#GraphMode` or `#MapMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. ### Example JSON ```json @@ -654,9 +654,9 @@ Writes a view of a stored `SELECT` query's results into the document. `query` is ] ``` -## ldh-AddResultSetChart(url: URI, query: URI, title: Literal, chart_type: URI, category_var_name: Literal, series_var_name: Literal, description?: Literal, fragment?: Literal) → Result +## ldh-AddResultSetChart(url: URI, query: URI, title: Literal, chart_type: URI, category_var_name: Literal, series_var_name: Literal + Sequence Literal, description?: Literal, fragment?: Literal) → Result -Writes a chart of a stored `SELECT` query's results into the document. `chart_type` is one of `https://w3id.org/atomgraph/client#Table`, `#BarChart`, `#LineChart`, `#ScatterChart`, `#Timeline`; the category and series are variable names of the query. To show the chart, place it with `ldh-AddObjectBlock`. +Writes a chart of a stored `SELECT` query's results into the document. `chart_type` is one of `https://w3id.org/atomgraph/client#Table`, `#BarChart`, `#LineChart`, `#ScatterChart`, `#Timeline`; the category and series are variable names of the query, and `series_var_name` may be an array of names for several series. To show the chart, place it with `ldh-AddObjectBlock`. ### Example JSON ```json @@ -707,9 +707,9 @@ Uploads a local file (an absolute path) and writes its description into the docu } ``` -## ldh-AddObjectBlock(url: URI, value: URI, title?: Literal, description?: Literal, fragment?: Literal, mode?: URI) → Result +## ldh-AddObjectBlock(url: URI, value: URI, title?: Literal, description?: Literal, fragment?: Literal, mode?: URI, graph?: URI) → Result -Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is `https://w3id.org/atomgraph/client#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. +Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is a layout such as `https://w3id.org/atomgraph/client#ReadMode` (the default), `#GraphMode`, `#MapMode`, `#ChartMode`, `#TableMode`, `#GridMode` or `#ListMode`. `graph` is the document that describes `value`, read instead of dereferencing `value`'s URI. ### Example JSON ```json diff --git a/tests/test_ldh_cli_parity.py b/tests/test_ldh_cli_parity.py new file mode 100644 index 0000000..ddad843 --- /dev/null +++ b/tests/test_ldh_cli_parity.py @@ -0,0 +1,232 @@ +"""The ldh-* operations write what LinkedDataHub's CLI writes (formal-semantics.md +Appendix A): each operation and the CLI command it corresponds to are run with +the same inputs, every option included, against a LinkedDataHub, and the two +documents must be the same graph. + +Live (marker `ldh`): `LDH_BASE` (default https://localhost:4443/) with the +certificate in CERT_PEM_PATH and CERT_PASSWORD, and `LDH_CLI`, the CLI's +launcher (`cli/bin/ldh` of a LinkedDataHub checkout built with `mvn package`). +Skipped when any is missing. Everything a run writes is deleted afterwards. + +Two things the server decides itself are set aside in the comparison: the +dct:created / dct:modified stamps, and the fragment IDs it mints for blank +nodes (`#id`), which are compared as blank nodes. +""" + +from __future__ import annotations + +import os +import re +import ssl +import subprocess +import urllib.error +import urllib.request +import uuid + +import pytest +from rdflib import BNode, Graph, URIRef +from rdflib.compare import graph_diff, to_isomorphic +from rdflib.namespace import DCTERMS + +from web_algebra.main import LinkedDataHubSettings +from web_algebra.operation import Operation + +pytestmark = pytest.mark.ldh + +BASE = os.getenv("LDH_BASE", "https://localhost:4443/") +CLI = os.getenv("LDH_CLI") +AC = "https://w3id.org/atomgraph/client#" +SKOLEM = re.compile(r"#id[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +XHTML = '

Hello

' + + +@pytest.fixture(scope="module") +def ldh(): + cert, password = os.getenv("CERT_PEM_PATH"), os.getenv("CERT_PASSWORD") + if not (cert and password): + pytest.skip("CERT_PEM_PATH and CERT_PASSWORD are required") + if not (CLI and os.access(CLI, os.X_OK)): + pytest.skip("LDH_CLI must name LinkedDataHub's CLI launcher") + settings = LinkedDataHubSettings(cert_pem_path=cert, cert_password=password) + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.load_cert_chain(cert, password=password) + opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context)) + env = {**os.environ, "LDH_CERT_FILE": cert, "LDH_CERT_PASSWORD": password} + run = BASE + "wa-cli-parity-" + uuid.uuid4().hex[:8] + "/" + state = {"settings": settings, "opener": opener, "env": env, "run": run} + yield state + _delete_under(opener, run) + + +def _delete_under(opener, prefix): + """Delete every document under `prefix`, deepest first, each with the + If-Match LinkedDataHub requires.""" + query = f'SELECT DISTINCT ?g WHERE {{ GRAPH ?g {{ ?s ?p ?o }} FILTER(STRSTARTS(STR(?g), "{prefix}")) }}' + request = urllib.request.Request( + BASE + "sparql?" + urllib.parse.urlencode({"query": query}), + headers={"Accept": "application/sparql-results+xml"}, + ) + with opener.open(request) as response: + graphs = re.findall(r"([^<]+)", response.read().decode()) + accept = {"Accept": "application/n-triples"} + for graph in sorted(set(graphs), key=lambda u: -u.count("/")): + try: + with opener.open(urllib.request.Request(graph, method="HEAD", headers=accept)) as head: + etag = head.headers["ETag"] + with opener.open(urllib.request.Request(graph, method="DELETE", headers={**accept, "If-Match": etag})): + pass + except urllib.error.HTTPError: + pass + + +def _wa(ldh, op, **args): + plan = {"@op": op, "args": {k: ({"@id": v[1:]} if isinstance(v, str) and v.startswith("@") else v) for k, v in args.items()}} + return Operation.process_json(ldh["settings"], plan) + + +def _cli(ldh, *argv) -> str: + done = subprocess.run([CLI, *argv], env=ldh["env"], capture_output=True, text=True, timeout=120) + assert done.returncode == 0, f"ldh {' '.join(argv[:2])}: {done.stderr.strip()[:500]}" + return done.stdout.strip() + + +def _url(result) -> str: + return str(list(result.bindings)[0]["url"]) + + +def _document(ldh, url) -> Graph: + request = urllib.request.Request(url, headers={"Accept": "application/n-triples"}) + with ldh["opener"].open(request) as response: + g = Graph().parse(data=response.read().decode(), format="nt") + normal = Graph() + + def term(t): + if isinstance(t, URIRef) and str(t).startswith(url): + if SKOLEM.search(str(t)): + return BNode(str(t)) + return URIRef("urn:doc/" + str(t)[len(url):]) + return t + + for s, p, o in g: + if p in (DCTERMS.created, DCTERMS.modified) and term(s) == URIRef("urn:doc/"): + continue + normal.add((term(s), p, term(o))) + return normal + + +def _same(ldh, ours, theirs): + a, b = _document(ldh, ours), _document(ldh, theirs) + _, only_ours, only_cli = graph_diff(to_isomorphic(a), to_isomorphic(b)) + lines = [f"ours: {t[0].n3()} {t[1].n3()} {t[2].n3()}" for t in only_ours] + lines += [f"CLI: {t[0].n3()} {t[1].n3()} {t[2].n3()}" for t in only_cli] + assert not lines, "\n".join(sorted(lines)) + + +@pytest.fixture(scope="module") +def parent(ldh): + """The run's container, holding every document the tests write.""" + return _url(_wa(ldh, "ldh-CreateContainer", parent="@" + BASE, title="CLI parity", + slug=ldh["run"][len(BASE):-1])) + + +@pytest.mark.parametrize( + "options, cli_options", + [ + ({}, []), + ({"description": "A container"}, ["--description", "A container"]), + ({"mode": "@" + AC + "GridMode"}, ["--mode", AC + "GridMode"]), + ({"block": "@https://example.org/blocks#b"}, ["--block", "https://example.org/blocks#b"]), + ({"primary_topic": "#this"}, ["--primary-topic", "#this"]), + ], + ids=["plain", "description", "mode", "block", "primary-topic"], +) +def test_create_container(ldh, parent, options, cli_options): + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateContainer", parent="@" + parent, title="Container", slug=slug, **options)) + theirs = _cli(ldh, "create", "container", "--parent", parent, "--title", "Container", "--slug", slug + "-cli", *cli_options).split()[-1] + _same(ldh, ours, theirs) + + +@pytest.mark.parametrize( + "options, cli_options", + [ + ({}, []), + ({"description": "An item"}, ["--description", "An item"]), + ({"primary_topic": "#this"}, ["--primary-topic", "#this"]), + ], + ids=["plain", "description", "primary-topic"], +) +def test_create_item(ldh, parent, options, cli_options): + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateItem", container="@" + parent, title="Item", slug=slug, **options)) + theirs = _cli(ldh, "create", "item", "--container", parent, "--title", "Item", "--slug", slug + "-cli", *cli_options).split()[-1] + _same(ldh, ours, theirs) + + +@pytest.fixture(scope="module") +def pair(ldh, parent): + """An item written by the operations and one written by the CLI, which + the content tests below add the same resources to, in the same order.""" + slug = uuid.uuid4().hex[:8] + ours = _url(_wa(ldh, "ldh-CreateItem", container="@" + parent, title="Content", slug=slug)) + theirs = _cli(ldh, "create", "item", "--container", parent, "--title", "Content", "--slug", slug + "-cli").split()[-1] + return ours, theirs + + +SELECT = "SELECT ?s ?o WHERE { ?s a ?o } LIMIT 10" +CONSTRUCT = "CONSTRUCT { ?s a ?o } WHERE { ?s a ?o } LIMIT 10" + +# (operation, its arguments, the CLI command and its options); `{doc}` is the item written to +CONTENT = [ + ("ldh-AddSelect", dict(query=SELECT, title="Q", description="Select", fragment="q", service="@" + BASE + "#service"), + ["add", "select", "--uri", "#q", "--title", "Q", "--description", "Select", "--query-file", "{select}", "--service", BASE + "#service"]), + ("ldh-AddConstruct", dict(query=CONSTRUCT, title="C", fragment="c"), + ["add", "construct", "--uri", "#c", "--title", "C", "--query-file", "{construct}"]), + ("ldh-AddView", dict(query="@{doc}#q", title="V", description="View", fragment="v", mode="@" + AC + "TableMode"), + ["add", "view", "--uri", "#v", "--title", "V", "--description", "View", "--query", "{doc}#q", "--mode", AC + "TableMode"]), + ("ldh-AddResultSetChart", dict(query="@{doc}#q", title="Ch", chart_type="@" + AC + "BarChart", category_var_name="s", + series_var_name=["o", "p"], description="Chart", fragment="ch"), + ["add", "result-set-chart", "--uri", "#ch", "--title", "Ch", "--query", "{doc}#q", "--chart-type", AC + "BarChart", + "--category-var-name", "s", "--series-var-name", "o", "--series-var-name", "p", "--description", "Chart"]), + ("ldh-AddObjectBlock", dict(value="@{doc}#v", title="OB", description="Object", fragment="ob", mode="@" + AC + "TableMode", + graph="@{doc}"), + ["add", "object-block", "--uri", "#ob", "--title", "OB", "--description", "Object", "--value", "{doc}#v", + "--mode", AC + "TableMode", "--graph", "{doc}"]), + ("ldh-AddXHTMLBlock", dict(value=XHTML, title="XB", description="XHTML", fragment="xb"), + ["add", "xhtml-block", "--uri", "#xb", "--title", "XB", "--description", "XHTML", "--value", XHTML]), + ("ldh-AddGenericService", dict(endpoint="@https://example.org/sparql", title="Svc", description="Service", fragment="svc", + graph_store="@https://example.org/service", auth_user="user", auth_pwd="secret"), + ["add", "generic-service", "--uri", "#svc", "--title", "Svc", "--description", "Service", + "--endpoint", "https://example.org/sparql", "--graph-store", "https://example.org/service", + "--auth-user", "user", "--auth-pwd", "secret"]), +] + + +def test_content(ldh, pair, tmp_path): + """Each resource added in turn; the documents stay the same at every step.""" + ours, theirs = pair + files = {"select": tmp_path / "select.rq", "construct": tmp_path / "construct.rq"} + files["select"].write_text(SELECT) + files["construct"].write_text(CONSTRUCT) + for op, args, argv in CONTENT: + _wa(ldh, op, url="@" + ours, **{k: (v.replace("{doc}", ours) if isinstance(v, str) else v) for k, v in args.items()}) + _cli(ldh, *[a.format(doc=theirs, **files) if "{" in a else a for a in argv], theirs) + _same(ldh, ours, theirs) + + _wa(ldh, "ldh-RemoveBlock", url="@" + ours, block="@" + ours + "#xb") + _cli(ldh, "remove", "block", "--block", theirs + "#xb", theirs) + _same(ldh, ours, theirs) + + +def test_add_file(ldh, pair, tmp_path): + ours, theirs = pair + upload = tmp_path / "upload.txt" + upload.write_text("Web-Algebra CLI parity " + uuid.uuid4().hex + "\n") + file_ours = _url(_wa(ldh, "ldh-AddFile", url="@" + ours, file=str(upload), title="Upload", description="A file", + content_type="text/plain")) + file_theirs = _cli(ldh, "add", "file", "--file", str(upload), "--title", "Upload", "--description", "A file", + "--content-type", "text/plain", theirs).split()[-1] + assert file_ours == file_theirs + _same(ldh, ours, theirs) diff --git a/tests/unit/test_ldh_cli_options.py b/tests/unit/test_ldh_cli_options.py new file mode 100644 index 0000000..bc5b163 --- /dev/null +++ b/tests/unit/test_ldh_cli_options.py @@ -0,0 +1,195 @@ +"""Spec: formal-semantics.md Appendix A - the options the ldh-* operations share +with LinkedDataHub's CLI, written as the CLI writes them: + +- ldh-CreateContainer's first content block, rdf:_1: `block` when given; + else an ldh:Object whose rdf:value is a new ldh:View of ldh:SelectChildren + in ac:mode `mode` when given; else an ldh:Object of ldh:ChildrenView. +- `primary_topic` (CreateContainer, CreateItem): a URI reference resolved + against the document's URI, the document's one foaf:primaryTopic. +- ldh-CreateItem's `description`: dct:description. +- ldh-AddObjectBlock's `graph`: the block's ldh:graph. +- ldh-AddResultSetChart's `series_var_name`: a name or a sequence of names, + one ldh:seriesVarName each. +- ldh-AddView's `show_when_empty` (xsd:boolean): ldh:showWhenEmpty. + +Checked offline: every write is recorded and its body read as RDF. +""" + +from __future__ import annotations + +import pytest +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import DCTERMS, FOAF, RDF, XSD + +from tests.http_stub import request_body_text, request_header +from web_algebra.operation import Operation + +LDH = Namespace("https://w3id.org/atomgraph/linkeddatahub#") +AC = Namespace("https://w3id.org/atomgraph/client#") +SPIN = Namespace("http://spinrdf.org/spin#") +EX = "https://example.org/" +DOC = EX + "doc/" +QUERY = DOC + "#q" +SEQ_1 = RDF["_1"] + + +def written(settings, http_stub, op, **args) -> Graph: + """The graph `op` writes, read from the recorded request bodies.""" + Operation.process_json(settings, {"@op": op, "args": args}) + g = Graph() + for r in http_stub.requests: + if r.get_method() not in ("POST", "PUT"): + continue + content_type = (request_header(r, "Content-Type") or "").split(";")[0].strip() + fmt = {"application/n-triples": "nt", "text/turtle": "turtle", "application/ld+json": "json-ld"}.get(content_type, "nt") + g.parse(data=request_body_text(r), format=fmt, publicID=r.full_url) + return g + + +def container(settings, http_stub, **args) -> tuple[Graph, URIRef]: + g = written(settings, http_stub, "ldh-CreateContainer", parent={"@id": EX}, title="C", slug="c", **args) + return g, URIRef(EX + "c/") + + +def item(settings, http_stub, **args) -> tuple[Graph, URIRef]: + g = written(settings, http_stub, "ldh-CreateItem", container={"@id": EX}, title="I", slug="i", **args) + return g, URIRef(EX + "i/") + + +# --- ldh-CreateContainer: the first content block + + +def test_a_container_shows_its_children_by_default(settings, http_stub): + g, doc = container(settings, http_stub) + (block,) = g.objects(doc, SEQ_1) + assert (block, RDF.type, LDH.Object) in g + assert (block, RDF.value, LDH.ChildrenView) in g + + +def test_a_given_block_is_the_first(settings, http_stub): + g, doc = container(settings, http_stub, block={"@id": EX + "blocks#b"}) + assert list(g.objects(doc, SEQ_1)) == [URIRef(EX + "blocks#b")] + assert (None, RDF.value, LDH.ChildrenView) not in g + + +def test_a_mode_makes_a_children_view_in_that_mode(settings, http_stub): + g, doc = container(settings, http_stub, mode={"@id": str(AC.GridMode)}) + (block,) = g.objects(doc, SEQ_1) + assert (block, RDF.type, LDH.Object) in g + (view,) = g.objects(block, RDF.value) + assert isinstance(view, BNode) + assert (view, RDF.type, LDH.View) in g + assert (view, SPIN.query, LDH.SelectChildren) in g + assert (view, AC.mode, AC.GridMode) in g + + +def test_a_block_takes_precedence_over_a_mode(settings, http_stub): + g, doc = container(settings, http_stub, block={"@id": EX + "blocks#b"}, mode={"@id": str(AC.GridMode)}) + assert list(g.objects(doc, SEQ_1)) == [URIRef(EX + "blocks#b")] + assert (None, AC.mode, AC.GridMode) not in g + + +# --- primary_topic + + +@pytest.mark.parametrize("make", [container, item]) +@pytest.mark.parametrize( + "reference, resolved", + [("#this", "{doc}#this"), ("https://example.org/topic", "https://example.org/topic"), ("../topic#it", EX + "topic#it")], +) +def test_the_primary_topic_is_resolved_against_the_document(settings, http_stub, make, reference, resolved): + g, doc = make(settings, http_stub, primary_topic=reference) + assert list(g.objects(doc, FOAF.primaryTopic)) == [URIRef(resolved.format(doc=doc))] + + +@pytest.mark.parametrize("make", [container, item]) +def test_no_primary_topic_unless_given(settings, http_stub, make): + g, doc = make(settings, http_stub) + assert list(g.objects(doc, FOAF.primaryTopic)) == [] + + +# --- ldh-CreateItem: description + + +def test_an_item_has_its_description(settings, http_stub): + g, doc = item(settings, http_stub, description="About the item") + assert list(g.objects(doc, DCTERMS.description)) == [Literal("About the item")] + + +def test_an_item_has_no_description_unless_given(settings, http_stub): + g, doc = item(settings, http_stub) + assert list(g.objects(doc, DCTERMS.description)) == [] + + +# --- ldh-AddObjectBlock: graph + + +def test_an_object_block_reads_its_value_from_the_graph(settings, http_stub): + g = written(settings, http_stub, "ldh-AddObjectBlock", url={"@id": DOC}, value={"@id": EX + "thing"}, + fragment="ob", graph={"@id": EX + "things/thing/"}) + assert list(g.objects(URIRef(DOC + "#ob"), LDH.graph)) == [URIRef(EX + "things/thing/")] + + +def test_an_object_block_has_no_graph_unless_given(settings, http_stub): + g = written(settings, http_stub, "ldh-AddObjectBlock", url={"@id": DOC}, value={"@id": EX + "thing"}, fragment="ob") + assert list(g.objects(URIRef(DOC + "#ob"), LDH.graph)) == [] + + +# --- ldh-AddResultSetChart: several series + + +def chart(settings, http_stub, series) -> Graph: + return written(settings, http_stub, "ldh-AddResultSetChart", url={"@id": DOC}, query={"@id": QUERY}, title="Ch", + chart_type={"@id": "https://w3id.org/atomgraph/client#BarChart"}, category_var_name="year", + series_var_name=series, fragment="ch") + + +def test_one_series(settings, http_stub): + g = chart(settings, http_stub, "sales") + assert set(g.objects(URIRef(DOC + "#ch"), LDH.seriesVarName)) == {Literal("sales")} + + +def test_several_series(settings, http_stub): + g = chart(settings, http_stub, ["sales", "returns", "profit"]) + assert set(g.objects(URIRef(DOC + "#ch"), LDH.seriesVarName)) == {Literal("sales"), Literal("returns"), Literal("profit")} + + +# --- ldh-AddView: show_when_empty + + +def view(settings, http_stub, **args) -> Graph: + return written(settings, http_stub, "ldh-AddView", url={"@id": DOC}, query={"@id": QUERY}, title="V", fragment="v", **args) + + +@pytest.mark.parametrize("value", [False, True]) +def test_show_when_empty(settings, http_stub, value): + g = view(settings, http_stub, show_when_empty=value) + assert list(g.objects(URIRef(DOC + "#v"), LDH.showWhenEmpty)) == [Literal(value, datatype=XSD.boolean)] + + +def test_no_show_when_empty_unless_given(settings, http_stub): + g = view(settings, http_stub) + assert list(g.objects(URIRef(DOC + "#v"), LDH.showWhenEmpty)) == [] + + +def test_show_when_empty_is_a_boolean(settings, http_stub): + with pytest.raises(TypeError): + view(settings, http_stub, show_when_empty="sometimes") + + +# --- the schema names the options + + +@pytest.mark.parametrize( + "op, options", + [ + ("ldh-CreateContainer", {"block", "mode", "primary_topic"}), + ("ldh-CreateItem", {"description", "primary_topic"}), + ("ldh-AddObjectBlock", {"graph"}), + ("ldh-AddView", {"show_when_empty"}), + ], +) +def test_the_schema_has_the_options(op, options): + schema = Operation.get(op).inputSchema() + assert options <= set(schema["properties"]) + assert not options & set(schema.get("required", [])) From 46d6c6df731e4fdd33d0af05631f9180b71cfeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 11:49:51 +0200 Subject: [PATCH 2/8] Guide: a view's query lists resources by its first variable LinkedDataHub v6 shows a view's resources, taken from its query's first variable; one that binds values (a title, a number) shows an error. Found by looking at a rendered view. Co-Authored-By: Claude Opus 5.5 --- src/web_algebra/prompts/system.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index 3682264..c4329dc 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -629,7 +629,7 @@ Writes a stored SPARQL `CONSTRUCT` query (`sp:Construct`) into the document, as ## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI, show_when_empty?: Literal) → Result -Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. `mode` is a layout such as `https://w3id.org/atomgraph/client#TableMode`, `#ListMode`, `#GridMode`, `#ReadMode`, `#GraphMode` or `#MapMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. +Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is a layout such as `https://w3id.org/atomgraph/client#TableMode`, `#ListMode`, `#GridMode`, `#ReadMode`, `#GraphMode` or `#MapMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. ### Example JSON ```json From 7c1067e1e6dcdadf4fc8361d4f9534465390e96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 11:55:46 +0200 Subject: [PATCH 3/8] ldh-CreateContainer: the children view made in a mode is titled "Children view" As LinkedDataHub's CLI now writes it (AtomGraph/LinkedDataHub#460): untitled, the client headed the block with the ID the server mints for the blank node. Co-Authored-By: Claude Opus 5.5 --- formal-semantics.md | 5 +++-- .../operations/linkeddatahub/create_container.py | 9 ++++++++- tests/unit/test_ldh_cli_options.py | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/formal-semantics.md b/formal-semantics.md index bfa7650..6b35958 100644 --- a/formal-semantics.md +++ b/formal-semantics.md @@ -1223,8 +1223,9 @@ inputs (its `rdf` module), so a document made either way is the same: - `ldh-CreateContainer` gives the container its first content block, `rdf:_1`: `block` itself when given; otherwise an `ldh:Object` whose - `rdf:value` is a new `ldh:View` of `ldh:SelectChildren` in `ac:mode` `mode` - when `mode` is given; otherwise an `ldh:Object` of `ldh:ChildrenView`. + `rdf:value` is a new `ldh:View` of `ldh:SelectChildren` in `ac:mode` `mode`, + titled `Children view` (the label of `ldh:ChildrenView`), when `mode` is + given; otherwise an `ldh:Object` of `ldh:ChildrenView`. - `primary_topic` (`ldh-CreateContainer`, `ldh-CreateItem`) is a URI reference resolved against the document's URI (RFC 3986), written as the document's one `foaf:primaryTopic` — `#this` names the conventional diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index 1cc217d..4232abc 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -137,7 +137,14 @@ def execute( elif mode is not None: data["rdf:_1"] = { "@type": "ldh:Object", - "rdf:value": {"@type": "ldh:View", "spin:query": {"@id": "ldh:SelectChildren"}, "ac:mode": {"@id": str(mode)}}, + # titled as ldh:ChildrenView is labelled; untitled, a client heads + # the block with the ID the server mints for the blank node + "rdf:value": { + "@type": "ldh:View", + "dct:title": "Children view", + "spin:query": {"@id": "ldh:SelectChildren"}, + "ac:mode": {"@id": str(mode)}, + }, } else: data["rdf:_1"] = {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}} diff --git a/tests/unit/test_ldh_cli_options.py b/tests/unit/test_ldh_cli_options.py index bc5b163..300f060 100644 --- a/tests/unit/test_ldh_cli_options.py +++ b/tests/unit/test_ldh_cli_options.py @@ -3,7 +3,8 @@ - ldh-CreateContainer's first content block, rdf:_1: `block` when given; else an ldh:Object whose rdf:value is a new ldh:View of ldh:SelectChildren - in ac:mode `mode` when given; else an ldh:Object of ldh:ChildrenView. + in ac:mode `mode`, titled "Children view", when given; else an ldh:Object + of ldh:ChildrenView. - `primary_topic` (CreateContainer, CreateItem): a URI reference resolved against the document's URI, the document's one foaf:primaryTopic. - ldh-CreateItem's `description`: dct:description. @@ -81,6 +82,7 @@ def test_a_mode_makes_a_children_view_in_that_mode(settings, http_stub): assert (view, RDF.type, LDH.View) in g assert (view, SPIN.query, LDH.SelectChildren) in g assert (view, AC.mode, AC.GridMode) in g + assert list(g.objects(view, DCTERMS.title)) == [Literal("Children view")] def test_a_block_takes_precedence_over_a_mode(settings, http_stub): From 0d14dc63371353d3e5205ddc1216409fdde2a955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 11:59:47 +0200 Subject: [PATCH 4/8] Object modes and view modes are not the same An object block's ac:mode is how it shows its resource - ReadMode, GraphMode, MapMode, ChartMode (LinkedDataHub's ldh:canvas-classes); a view's is the layout of its results - ListMode, TableMode, GridMode, ReadMode, GraphMode, MapMode, ChartMode (ldh:class-modes). The previous commit dropped ldh-AddObjectBlock's mode enum, which was right, and described view modes there. Its enum is back; ldh-AddView's gains the TableMode and ChartMode it lacked; the guide says which is which; the parity test gives its object block an object mode. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/web_algebra/operations/linkeddatahub/add_view.py | 11 ++++++++++- .../linkeddatahub/content/add_object_block.py | 9 ++++++++- .../operations/linkeddatahub/create_container.py | 2 +- src/web_algebra/prompts/system.md | 6 +++--- tests/test_ldh_cli_parity.py | 4 ++-- 6 files changed, 26 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9033c..415cd08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,7 +97,8 @@ many are breaking. them as it does: `ldh-CreateContainer` `block`, `mode` and `primary_topic`; `ldh-CreateItem` `description` and `primary_topic`; `ldh-AddObjectBlock` `graph`; several series for `ldh-AddResultSetChart`; and - `ldh-AddView` `show_when_empty`. A `mode` is any layout URI. + `ldh-AddView` `show_when_empty`. A view's `mode` may also be `ac:TableMode` + or `ac:ChartMode`. `tests/test_ldh_cli_parity.py` runs each operation against the CLI on a live LinkedDataHub and requires the same document. - **[formal-semantics.md](formal-semantics.md)**: abstract syntax, value diff --git a/src/web_algebra/operations/linkeddatahub/add_view.py b/src/web_algebra/operations/linkeddatahub/add_view.py index 4fb38a0..2316f21 100644 --- a/src/web_algebra/operations/linkeddatahub/add_view.py +++ b/src/web_algebra/operations/linkeddatahub/add_view.py @@ -66,7 +66,16 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the view's layout mode, e.g. https://w3id.org/atomgraph/client#TableMode, #ListMode, #GridMode, #ReadMode, #GraphMode, #MapMode.", + "description": "Optional URI of the layout the view shows its results in (a view mode, not an object block's mode). Defaults to ListMode.", + "enum": [ + "https://w3id.org/atomgraph/client#ListMode", + "https://w3id.org/atomgraph/client#TableMode", + "https://w3id.org/atomgraph/client#GridMode", + "https://w3id.org/atomgraph/client#ReadMode", + "https://w3id.org/atomgraph/client#GraphMode", + "https://w3id.org/atomgraph/client#MapMode", + "https://w3id.org/atomgraph/client#ChartMode", + ], }, "show_when_empty": { "type": "boolean", diff --git a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py index c032977..28d3b32 100644 --- a/src/web_algebra/operations/linkeddatahub/content/add_object_block.py +++ b/src/web_algebra/operations/linkeddatahub/content/add_object_block.py @@ -65,7 +65,14 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "Optional URI of the block's layout mode, e.g. https://w3id.org/atomgraph/client#ReadMode (the default), #GraphMode, #MapMode, #ChartMode, #TableMode, #GridMode, #ListMode.", + "description": "Optional URI of the mode the block shows its resource in (an object mode, not a view's layout). Defaults to ReadMode.", + "enum": [ + "https://w3id.org/atomgraph/client#ReadMode", + "https://w3id.org/atomgraph/client#GraphMode", + "https://w3id.org/atomgraph/client#MapMode", + "https://w3id.org/atomgraph/client#ChartMode", + ], + "default": "https://w3id.org/atomgraph/client#ReadMode", }, "graph": { "type": "string", diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index 4232abc..61e5967 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -55,7 +55,7 @@ def inputSchema(cls) -> dict: }, "mode": { "type": "string", - "description": "URI of the layout mode (e.g. ac:GridMode) the children are listed in (optional; ignored when block is given).", + "description": "URI of the view mode the children are listed in, e.g. https://w3id.org/atomgraph/client#GridMode (optional; ignored when block is given). Without it, the shared ldh:ChildrenView is used.", }, "primary_topic": { "type": "string", diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index c4329dc..fe9af5f 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -562,7 +562,7 @@ These write to a LinkedDataHub instance. Every write returns the one-row `status ## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, block?: URI, mode?: URI, primary_topic?: Literal) → Result -Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block is `block` when given; otherwise its children, listed in layout `mode` (e.g. `https://w3id.org/atomgraph/client#GridMode`) when given. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). +Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block is `block` when given; otherwise its children: through the shared `ldh:ChildrenView`, or, when `mode` (a view mode such as `https://w3id.org/atomgraph/client#GridMode`) is given, through a new view in that layout. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). ### Example JSON ```json @@ -629,7 +629,7 @@ Writes a stored SPARQL `CONSTRUCT` query (`sp:Construct`) into the document, as ## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI, show_when_empty?: Literal) → Result -Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is a layout such as `https://w3id.org/atomgraph/client#TableMode`, `#ListMode`, `#GridMode`, `#ReadMode`, `#GraphMode` or `#MapMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. +Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is the layout of the results, a view mode: `https://w3id.org/atomgraph/client#ListMode` (the default), `#TableMode`, `#GridMode`, `#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. ### Example JSON ```json @@ -709,7 +709,7 @@ Uploads a local file (an absolute path) and writes its description into the docu ## ldh-AddObjectBlock(url: URI, value: URI, title?: Literal, description?: Literal, fragment?: Literal, mode?: URI, graph?: URI) → Result -Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is a layout such as `https://w3id.org/atomgraph/client#ReadMode` (the default), `#GraphMode`, `#MapMode`, `#ChartMode`, `#TableMode`, `#GridMode` or `#ListMode`. `graph` is the document that describes `value`, read instead of dereferencing `value`'s URI. +Appends a block showing a resource to the document's content: a view, chart or query in the document, an uploaded file, or any RDF resource or web page. `mode` is how the block shows its resource, an object mode: `https://w3id.org/atomgraph/client#ReadMode` (the default), `#GraphMode`, `#MapMode` or `#ChartMode`. It is not a view's layout: a view placed by the block keeps its own `mode`. `graph` is the document that describes `value`, read instead of dereferencing `value`'s URI. ### Example JSON ```json diff --git a/tests/test_ldh_cli_parity.py b/tests/test_ldh_cli_parity.py index ddad843..f57aa02 100644 --- a/tests/test_ldh_cli_parity.py +++ b/tests/test_ldh_cli_parity.py @@ -190,10 +190,10 @@ def pair(ldh, parent): series_var_name=["o", "p"], description="Chart", fragment="ch"), ["add", "result-set-chart", "--uri", "#ch", "--title", "Ch", "--query", "{doc}#q", "--chart-type", AC + "BarChart", "--category-var-name", "s", "--series-var-name", "o", "--series-var-name", "p", "--description", "Chart"]), - ("ldh-AddObjectBlock", dict(value="@{doc}#v", title="OB", description="Object", fragment="ob", mode="@" + AC + "TableMode", + ("ldh-AddObjectBlock", dict(value="@{doc}#v", title="OB", description="Object", fragment="ob", mode="@" + AC + "GraphMode", graph="@{doc}"), ["add", "object-block", "--uri", "#ob", "--title", "OB", "--description", "Object", "--value", "{doc}#v", - "--mode", AC + "TableMode", "--graph", "{doc}"]), + "--mode", AC + "GraphMode", "--graph", "{doc}"]), ("ldh-AddXHTMLBlock", dict(value=XHTML, title="XB", description="XHTML", fragment="xb"), ["add", "xhtml-block", "--uri", "#xb", "--title", "XB", "--description", "XHTML", "--value", XHTML]), ("ldh-AddGenericService", dict(endpoint="@https://example.org/sparql", title="Svc", description="Service", fragment="svc", From 216b93e92395e40cb79c501e7701069b7f208063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 15:44:29 +0200 Subject: [PATCH 5/8] ldh-CreateContainer: children_view replaces block and mode As LinkedDataHub's CLI now writes it: the container's first content block is the children view unless children_view is false, and other content is appended with ldh-AddView and ldh-AddObjectBlock, as to any document. A view in another mode is made and placed by the caller, who titles it; block wrote a bare rdf:_1 the platform rejects. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- formal-semantics.md | 9 +- .../linkeddatahub/create_container.py | 89 ++++++------------- src/web_algebra/prompts/system.md | 4 +- tests/test_ldh_cli_parity.py | 5 +- tests/unit/test_ldh_cli_options.py | 38 +++----- 6 files changed, 48 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 415cd08..34f0ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,7 +94,7 @@ many are breaking. ### Added - **The `ldh-*` operations take the LinkedDataHub CLI's options**, and write - them as it does: `ldh-CreateContainer` `block`, `mode` and `primary_topic`; + them as it does: `ldh-CreateContainer` `children_view` and `primary_topic`; `ldh-CreateItem` `description` and `primary_topic`; `ldh-AddObjectBlock` `graph`; several series for `ldh-AddResultSetChart`; and `ldh-AddView` `show_when_empty`. A view's `mode` may also be `ac:TableMode` diff --git a/formal-semantics.md b/formal-semantics.md index 6b35958..fde19ff 100644 --- a/formal-semantics.md +++ b/formal-semantics.md @@ -1197,7 +1197,7 @@ operations' MCP tools, and an MCP tool name allows only ASCII letters, digits, | Operation | JSON args (`Maybe` = optional) | |-----------|--------------------------------| -| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · block: Maybe URI · mode: Maybe URI · primary_topic: Maybe Literal` | +| `ldh-CreateContainer` | `parent: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · children_view: Maybe Literal (xsd:boolean) · primary_topic: Maybe Literal` | | `ldh-CreateItem` | `container: URI · title: Literal · slug: Maybe Literal · description: Maybe Literal · primary_topic: Maybe Literal` | | `ldh-List` *(query)* | `url: URI · endpoint: URI` (or `base: Literal`, from which `endpoint` = `base` + `sparql`) | | `ldh-AddFile` | `url: URI · file: Literal (path) · title: Literal · description: Maybe Literal · content_type: Maybe Literal` | @@ -1222,10 +1222,9 @@ What the operations write is what LinkedDataHub's CLI writes for the same inputs (its `rdf` module), so a document made either way is the same: - `ldh-CreateContainer` gives the container its first content block, - `rdf:_1`: `block` itself when given; otherwise an `ldh:Object` whose - `rdf:value` is a new `ldh:View` of `ldh:SelectChildren` in `ac:mode` `mode`, - titled `Children view` (the label of `ldh:ChildrenView`), when `mode` is - given; otherwise an `ldh:Object` of `ldh:ChildrenView`. + `rdf:_1`, an `ldh:Object` of `ldh:ChildrenView`, unless `children_view` is + false: then it has no content, which `ldh-AddObjectBlock` and + `ldh-AddXHTMLBlock` append as to any document. - `primary_topic` (`ldh-CreateContainer`, `ldh-CreateItem`) is a URI reference resolved against the document's URI (RFC 3986), written as the document's one `foaf:primaryTopic` — `#this` names the conventional diff --git a/src/web_algebra/operations/linkeddatahub/create_container.py b/src/web_algebra/operations/linkeddatahub/create_container.py index 61e5967..f239769 100644 --- a/src/web_algebra/operations/linkeddatahub/create_container.py +++ b/src/web_algebra/operations/linkeddatahub/create_container.py @@ -49,13 +49,9 @@ def inputSchema(cls) -> dict: "type": "string", "description": "Description (optional)", }, - "block": { - "type": "string", - "description": "URI of the container's first content block (optional). Without it, the block lists the container's children.", - }, - "mode": { - "type": "string", - "description": "URI of the view mode the children are listed in, e.g. https://w3id.org/atomgraph/client#GridMode (optional; ignored when block is given). Without it, the shared ldh:ChildrenView is used.", + "children_view": { + "type": "boolean", + "description": "Optional: whether the container's first content block lists its children (the shared ldh:ChildrenView). It does unless this is false; the content is then added with ldh-AddObjectBlock and ldh-AddXHTMLBlock, as to any document.", }, "primary_topic": { "type": "string", @@ -71,8 +67,7 @@ def execute( title: Literal, slug: Optional[Literal] = None, description: Optional[Literal] = None, - block: Optional[URIRef] = None, - mode: Optional[URIRef] = None, + children_view: Optional[Literal] = None, primary_topic: Optional[Literal] = None, ) -> Result: """Pure function: create container with RDFLib terms""" @@ -92,11 +87,16 @@ def execute( raise TypeError( f"CreateContainer.execute expects description to be Literal or None, got {type(description)}" ) - for name, term, typ in (("block", block, URIRef), ("mode", mode, URIRef), ("primary_topic", primary_topic, Literal)): - if term is not None and not isinstance(term, typ): - raise TypeError( - f"CreateContainer.execute expects {name} to be {typ.__name__} or None, got {type(term)}" - ) + if children_view is not None and not ( + isinstance(children_view, Literal) and children_view.datatype == XSD.boolean + ): + raise TypeError( + f"CreateContainer.execute expects children_view to be an xsd:boolean Literal, got {children_view!r}" + ) + if primary_topic is not None and not isinstance(primary_topic, Literal): + raise TypeError( + f"CreateContainer.execute expects primary_topic to be Literal or None, got {type(primary_topic)}" + ) # URL-encode slug, or mint a fresh one - a lowercase UUID, as the LinkedDataHub CLI does - so that # leaving it out never lands on an existing document; deriving it from the title did, on a repeated title @@ -121,32 +121,15 @@ def execute( "dct": "http://purl.org/dc/terms/", "sioc": "http://rdfs.org/sioc/ns#", "xsd": "http://www.w3.org/2001/XMLSchema#", - "spin": "http://spinrdf.org/spin#", - "ac": "https://w3id.org/atomgraph/client#", "foaf": "http://xmlns.com/foaf/0.1/", }, "@id": url, "@type": "dh:Container", "dct:title": Operation.jsonld_text(title), } - # the first content block, as the LinkedDataHub CLI writes it: the given - # block; else a view of the children in the given mode; else the - # children view (formal-semantics.md Appendix A) - if block is not None: - data["rdf:_1"] = {"@id": str(block)} - elif mode is not None: - data["rdf:_1"] = { - "@type": "ldh:Object", - # titled as ldh:ChildrenView is labelled; untitled, a client heads - # the block with the ID the server mints for the blank node - "rdf:value": { - "@type": "ldh:View", - "dct:title": "Children view", - "spin:query": {"@id": "ldh:SelectChildren"}, - "ac:mode": {"@id": str(mode)}, - }, - } - else: + # the first content block, as the LinkedDataHub CLI writes it: the children + # view, unless children_view is false (formal-semantics.md Appendix A) + if children_view is None or children_view.toPython(): data["rdf:_1"] = {"@type": "ldh:Object", "rdf:value": {"@id": "ldh:ChildrenView"}} if description: @@ -208,29 +191,12 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: ) - # Process optional block - block = None - if "block" in arguments: - block_data = Operation.process_json( - self.settings, arguments["block"], self.context, variable_stack - ) - block = Operation.json_to_rdflib(block_data) - if not isinstance(block, URIRef): - raise TypeError( - f"ldh-CreateContainer operation expects 'block' to be URIRef, got {type(block)}" - ) - - # Process optional mode - mode = None - if "mode" in arguments: - mode_data = Operation.process_json( - self.settings, arguments["mode"], self.context, variable_stack - ) - mode = Operation.json_to_rdflib(mode_data) - if not isinstance(mode, URIRef): - raise TypeError( - f"ldh-CreateContainer operation expects 'mode' to be URIRef, got {type(mode)}" - ) + # Process optional children_view + children_view = None + if "children_view" in arguments: + children_view = Operation.json_to_rdflib(Operation.process_json( + self.settings, arguments["children_view"], self.context, variable_stack + )) # Process optional primary_topic primary_topic = None @@ -244,7 +210,7 @@ def execute_json(self, arguments: dict, variable_stack: list = None) -> Result: f"ldh-CreateContainer operation expects 'primary_topic' to be Literal, got {type(primary_topic)}" ) - return self.execute(parent_uri, title, slug, description, block, mode, primary_topic) + return self.execute(parent_uri, title, slug, description, children_view, primary_topic) def mcp_run(self, arguments: dict, context: Any = None) -> Any: """MCP execution: plain args → plain results""" @@ -261,14 +227,15 @@ def mcp_run(self, arguments: dict, context: Any = None) -> Any: else None ) - block = URIRef(arguments["block"]) if "block" in arguments else None - mode = URIRef(arguments["mode"]) if "mode" in arguments else None + children_view = ( + Literal(bool(arguments["children_view"])) if "children_view" in arguments else None + ) primary_topic = ( Literal(arguments["primary_topic"], datatype=XSD.string) if "primary_topic" in arguments else None ) - result = self.execute(parent_uri, title, slug, description, block, mode, primary_topic) + result = self.execute(parent_uri, title, slug, description, children_view, primary_topic) # Extract URL from PUT result url_binding = result.bindings[0]["url"] diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index fe9af5f..a40523a 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -560,9 +560,9 @@ The classes, datatype properties and object properties of the data at a SPARQL e 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`. -## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, block?: URI, mode?: URI, primary_topic?: Literal) → Result +## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, children_view?: Literal, primary_topic?: Literal) → Result -Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block is `block` when given; otherwise its children: through the shared `ldh:ChildrenView`, or, when `mode` (a view mode such as `https://w3id.org/atomgraph/client#GridMode`) is given, through a new view in that layout. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). +Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block lists its children through the shared `ldh:ChildrenView`, unless `children_view` is `false`. To list them in another layout, pass `false`, add a view of `https://w3id.org/atomgraph/linkeddatahub#SelectChildren` in that mode to the container with `ldh-AddView` (with a `fragment`), and place it with `ldh-AddObjectBlock`. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). ### Example JSON ```json diff --git a/tests/test_ldh_cli_parity.py b/tests/test_ldh_cli_parity.py index f57aa02..604e975 100644 --- a/tests/test_ldh_cli_parity.py +++ b/tests/test_ldh_cli_parity.py @@ -136,11 +136,10 @@ def parent(ldh): [ ({}, []), ({"description": "A container"}, ["--description", "A container"]), - ({"mode": "@" + AC + "GridMode"}, ["--mode", AC + "GridMode"]), - ({"block": "@https://example.org/blocks#b"}, ["--block", "https://example.org/blocks#b"]), + ({"children_view": False}, ["--no-children-view"]), ({"primary_topic": "#this"}, ["--primary-topic", "#this"]), ], - ids=["plain", "description", "mode", "block", "primary-topic"], + ids=["plain", "description", "no-children-view", "primary-topic"], ) def test_create_container(ldh, parent, options, cli_options): slug = uuid.uuid4().hex[:8] diff --git a/tests/unit/test_ldh_cli_options.py b/tests/unit/test_ldh_cli_options.py index 300f060..3c6586a 100644 --- a/tests/unit/test_ldh_cli_options.py +++ b/tests/unit/test_ldh_cli_options.py @@ -1,10 +1,8 @@ """Spec: formal-semantics.md Appendix A - the options the ldh-* operations share with LinkedDataHub's CLI, written as the CLI writes them: -- ldh-CreateContainer's first content block, rdf:_1: `block` when given; - else an ldh:Object whose rdf:value is a new ldh:View of ldh:SelectChildren - in ac:mode `mode`, titled "Children view", when given; else an ldh:Object - of ldh:ChildrenView. +- ldh-CreateContainer's first content block, rdf:_1: an ldh:Object of + ldh:ChildrenView, unless `children_view` is false, when there is none. - `primary_topic` (CreateContainer, CreateItem): a URI reference resolved against the document's URI, the document's one foaf:primaryTopic. - ldh-CreateItem's `description`: dct:description. @@ -19,15 +17,13 @@ from __future__ import annotations import pytest -from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib import Graph, Literal, Namespace, URIRef from rdflib.namespace import DCTERMS, FOAF, RDF, XSD from tests.http_stub import request_body_text, request_header from web_algebra.operation import Operation LDH = Namespace("https://w3id.org/atomgraph/linkeddatahub#") -AC = Namespace("https://w3id.org/atomgraph/client#") -SPIN = Namespace("http://spinrdf.org/spin#") EX = "https://example.org/" DOC = EX + "doc/" QUERY = DOC + "#q" @@ -67,28 +63,16 @@ def test_a_container_shows_its_children_by_default(settings, http_stub): assert (block, RDF.value, LDH.ChildrenView) in g -def test_a_given_block_is_the_first(settings, http_stub): - g, doc = container(settings, http_stub, block={"@id": EX + "blocks#b"}) - assert list(g.objects(doc, SEQ_1)) == [URIRef(EX + "blocks#b")] - assert (None, RDF.value, LDH.ChildrenView) not in g - - -def test_a_mode_makes_a_children_view_in_that_mode(settings, http_stub): - g, doc = container(settings, http_stub, mode={"@id": str(AC.GridMode)}) +def test_a_container_shows_its_children_when_asked(settings, http_stub): + g, doc = container(settings, http_stub, children_view=True) (block,) = g.objects(doc, SEQ_1) - assert (block, RDF.type, LDH.Object) in g - (view,) = g.objects(block, RDF.value) - assert isinstance(view, BNode) - assert (view, RDF.type, LDH.View) in g - assert (view, SPIN.query, LDH.SelectChildren) in g - assert (view, AC.mode, AC.GridMode) in g - assert list(g.objects(view, DCTERMS.title)) == [Literal("Children view")] + assert (block, RDF.value, LDH.ChildrenView) in g -def test_a_block_takes_precedence_over_a_mode(settings, http_stub): - g, doc = container(settings, http_stub, block={"@id": EX + "blocks#b"}, mode={"@id": str(AC.GridMode)}) - assert list(g.objects(doc, SEQ_1)) == [URIRef(EX + "blocks#b")] - assert (None, AC.mode, AC.GridMode) not in g +def test_a_container_without_the_children_view_has_no_content(settings, http_stub): + g, doc = container(settings, http_stub, children_view=False) + assert list(g.objects(doc, SEQ_1)) == [] + assert (None, RDF.value, LDH.ChildrenView) not in g # --- primary_topic @@ -185,7 +169,7 @@ def test_show_when_empty_is_a_boolean(settings, http_stub): @pytest.mark.parametrize( "op, options", [ - ("ldh-CreateContainer", {"block", "mode", "primary_topic"}), + ("ldh-CreateContainer", {"children_view", "primary_topic"}), ("ldh-CreateItem", {"description", "primary_topic"}), ("ldh-AddObjectBlock", {"graph"}), ("ldh-AddView", {"show_when_empty"}), From 21d9091fcb0bcefb37a2d061c90d02462e63af0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 15:52:32 +0200 Subject: [PATCH 6/8] Guide: a container's children in another layout, and booleans are JSON booleans ldh-CreateContainer gains an example of the composition it now relies on: children_view false, a view of ldh:SelectChildren in a mode, placed with ldh-AddObjectBlock. children_view and show_when_empty say they take a JSON boolean: a model wrote "false", a string literal the operations reject before they write anything. Co-Authored-By: Claude Opus 5.5 --- src/web_algebra/prompts/system.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/web_algebra/prompts/system.md b/src/web_algebra/prompts/system.md index a40523a..5190710 100644 --- a/src/web_algebra/prompts/system.md +++ b/src/web_algebra/prompts/system.md @@ -562,13 +562,22 @@ These write to a LinkedDataHub instance. Every write returns the one-row `status ## ldh-CreateContainer(parent: URI, title: Literal, slug?: Literal, description?: Literal, children_view?: Literal, primary_topic?: Literal) → Result -Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block lists its children through the shared `ldh:ChildrenView`, unless `children_view` is `false`. To list them in another layout, pass `false`, add a view of `https://w3id.org/atomgraph/linkeddatahub#SelectChildren` in that mode to the container with `ldh-AddView` (with a `fragment`), and place it with `ldh-AddObjectBlock`. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). +Creates a container document at `parent` + `slug` + `/` (without `slug`, a random UUID), showing its children. A document that already exists at that URL is replaced. Its first content block lists its children through the shared `ldh:ChildrenView`, unless `children_view` is `false` — a JSON boolean, never the string `"false"`. To list them in another layout, pass `false`, add a view of `https://w3id.org/atomgraph/linkeddatahub#SelectChildren` in that mode to the container with `ldh-AddView` (with a `fragment`), and place it with `ldh-AddObjectBlock`. `primary_topic` is what the container is about, a URI reference resolved against its URL (`#this` for a resource described in it). ### Example JSON ```json {"@op": "ldh-CreateContainer", "args": {"parent": {"@id": "https://localhost:4443/"}, "title": "Danish cities", "slug": "denmark"}} ``` +A container whose children are listed in another layout, here on a map: +```json +[ + {"@op": "ldh-CreateContainer", "args": {"parent": {"@id": "https://localhost:4443/"}, "title": "Danish cities", "slug": "denmark", "children_view": false}}, + {"@op": "ldh-AddView", "args": {"url": {"@id": "https://localhost:4443/denmark/"}, "query": {"@id": "https://w3id.org/atomgraph/linkeddatahub#SelectChildren"}, "title": "Danish cities", "fragment": "cities", "mode": {"@id": "https://w3id.org/atomgraph/client#MapMode"}}}, + {"@op": "ldh-AddObjectBlock", "args": {"url": {"@id": "https://localhost:4443/denmark/"}, "value": {"@id": "https://localhost:4443/denmark/#cities"}}} +] +``` + ## ldh-CreateItem(container: URI, title: Literal, slug?: Literal, description?: Literal, primary_topic?: Literal) → Result Creates an item document at `container` + `slug` + `/` (without `slug`, a random UUID). A document that already exists at that URL is replaced. `primary_topic` is what the item is about, a URI reference resolved against its URL (`#this` for a resource described in it). @@ -629,7 +638,7 @@ Writes a stored SPARQL `CONSTRUCT` query (`sp:Construct`) into the document, as ## ldh-AddView(url: URI, query: URI, title: Literal, description?: Literal, fragment?: Literal, mode?: URI, show_when_empty?: Literal) → Result -Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is the layout of the results, a view mode: `https://w3id.org/atomgraph/client#ListMode` (the default), `#TableMode`, `#GridMode`, `#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. `show_when_empty: false` hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. +Writes a view of a stored `SELECT` query's results into the document. `query` is the URI of a stored query (from `ldh-AddSelect`), not query text. A view lists resources: the query's first variable must bind URIs (`SELECT ?item …`), not values such as titles or numbers; to show values, chart them with `ldh-AddResultSetChart` instead. `mode` is the layout of the results, a view mode: `https://w3id.org/atomgraph/client#ListMode` (the default), `#TableMode`, `#GridMode`, `#ReadMode`, `#GraphMode`, `#MapMode` or `#ChartMode`. `show_when_empty: false` (a JSON boolean, never the string `"false"`) hides the view while its query has no results. To show the view, place it with `ldh-AddObjectBlock`. ### Example JSON ```json From 0f7920da666e80ff2bb27d97ea3d4db89fa56ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 16:11:01 +0200 Subject: [PATCH 7/8] The planner writes with gpt-5.5, and a plan may be an array again JSON mode admits only an object, so since the guide made an array of steps a plan (444df5f), a model that wrote one returned its first step alone: 7 of 12 container plans with gpt-5.5, 20 of 20 without the mode. gpt-4o-mini composed none of them either way, so the model defaults to gpt-5.5, which is what LinkedDataHub's stack runs, and the planner reads OPENAI_MODEL through the settings like SPARQLString instead of hardcoding its own. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ src/web_algebra/main.py | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f0ac4..14320ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -142,6 +142,11 @@ many are breaking. model is given, is rewritten and checked by `tests/test_system_prompt.py` against each operation's schema and the spec: every example is well-formed, runs, and shows its actual result. ([#36](https://github.com/AtomGraph/Web-Algebra/pull/36)) +- **The model is `gpt-5.5`** by default, from `gpt-4o-mini`, for both the + interactive planner and `SPARQLString`, and `OPENAI_MODEL` overrides it; the + planner used to ignore the setting. The planner no longer asks for JSON mode, + which admits only an object: a plan of several steps written as an array + came back as its first step alone. - **`ldh-*` titles and descriptions** may carry a language tag, and keep it. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) - **A `fragment`** may be given with or without its `#`. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) diff --git a/src/web_algebra/main.py b/src/web_algebra/main.py index bdd4cea..b0967b8 100644 --- a/src/web_algebra/main.py +++ b/src/web_algebra/main.py @@ -27,7 +27,7 @@ class LinkedDataHubSettings(BaseSettings): cert_pem_path: Optional[str] = None cert_password: Optional[str] = None openai_client: Optional[OpenAI] = None - openai_model: str = "gpt-4o-mini" + openai_model: str = "gpt-5.5" def list_operation_subclasses( @@ -83,7 +83,7 @@ def main(settings: BaseSettings, json_data: Optional[str]): sys.exit("Error: Environment variable OPENAI_API_KEY is not set.") client = OpenAI(api_key=api_key) - model = "gpt-4o-mini" + model = settings.openai_model # Load prompts # the prompts are package data, found wherever the package is installed @@ -105,14 +105,14 @@ def main(settings: BaseSettings, json_data: Optional[str]): {"role": "user", "content": user_prompt}, ) + # no JSON mode: it admits only an object, and a plan may be an array of steps chat_completion = client.chat.completions.create( model=model, messages=messages, - response_format={"type": "json_object"}, ) reply_json = json.loads(chat_completion.choices[0].message.content) - assert isinstance(reply_json, dict) + assert isinstance(reply_json, (dict, list)) logging.info("Generated response: %s", reply_json) # Execute the generated operation From 145c8c3ff1d857ead5c17d26b478877822ec0d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 21 Oct 2026 16:13:32 +0200 Subject: [PATCH 8/8] The planner reads a plan out of a Markdown code fence Without JSON mode a model may fence its reply as ```json ... ```, which json.loads refuses: one of eleven gpt-4o-mini replies was, and the planner stopped on it. parse_plan takes the plan out of a fence around the whole reply, and refuses anything but an object or an array of steps; prose around the fence stays an error. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- src/web_algebra/main.py | 18 ++++++++++++++-- tests/test_planner.py | 46 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/test_planner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 14320ab..e78208c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,8 @@ many are breaking. interactive planner and `SPARQLString`, and `OPENAI_MODEL` overrides it; the planner used to ignore the setting. The planner no longer asks for JSON mode, which admits only an object: a plan of several steps written as an array - came back as its first step alone. + came back as its first step alone. A reply fenced as a Markdown code block + is read as the plan inside the fence. - **`ldh-*` titles and descriptions** may carry a language tag, and keep it. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) - **A `fragment`** may be given with or without its `#`. ([#35](https://github.com/AtomGraph/Web-Algebra/pull/35)) diff --git a/src/web_algebra/main.py b/src/web_algebra/main.py index b0967b8..1ec68fc 100644 --- a/src/web_algebra/main.py +++ b/src/web_algebra/main.py @@ -3,6 +3,7 @@ import os import sys import json +import re from types import ModuleType from typing import List, Type, Optional import pkgutil @@ -57,6 +58,20 @@ def register(classes: List[Type[Operation]]): Operation.register(cls) +# a Markdown code fence around the whole reply, which a model may add without JSON mode +FENCE = re.compile(r"\A```[\w-]*\s*\n(.*)\n\s*```\Z", re.S) + + +def parse_plan(reply: str): + """The plan in a model's reply: its JSON, fenced or not, an object or an array of steps.""" + text = reply.strip() + fenced = FENCE.match(text) + plan = json.loads(fenced.group(1) if fenced else text) + if not isinstance(plan, (dict, list)): + raise ValueError(f"A plan is a JSON object or array, got {type(plan).__name__}") + return plan + + def main(settings: BaseSettings, json_data: Optional[str]): register(list_operation_subclasses(web_algebra.operations, Operation)) @@ -111,8 +126,7 @@ def main(settings: BaseSettings, json_data: Optional[str]): messages=messages, ) - reply_json = json.loads(chat_completion.choices[0].message.content) - assert isinstance(reply_json, (dict, list)) + reply_json = parse_plan(chat_completion.choices[0].message.content) logging.info("Generated response: %s", reply_json) # Execute the generated operation diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..8727f33 --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,46 @@ +"""The interactive planner's reading of a model reply (web_algebra.main.parse_plan): +the plan is the reply's JSON, an object or an array of steps, whether or not the +model fenced it as a Markdown code block - the planner asks for no JSON mode, +which admits only an object.""" + +from __future__ import annotations + +import pytest + +from web_algebra.main import parse_plan + +STEP = {"@op": "Str", "args": {"input": "a"}} + + +@pytest.mark.parametrize( + "reply", + [ + '{"@op": "Str", "args": {"input": "a"}}', + '```json\n{"@op": "Str", "args": {"input": "a"}}\n```', + '```\n{"@op": "Str", "args": {"input": "a"}}\n```', + ' \n```json\n{"@op": "Str", "args": {"input": "a"}}\n```\n', + ], + ids=["bare", "json-fence", "plain-fence", "padded"], +) +def test_an_object_plan(reply): + assert parse_plan(reply) == STEP + + +@pytest.mark.parametrize( + "reply", + ['[{"@op": "Str", "args": {"input": "a"}}]', '```json\n[\n {"@op": "Str", "args": {"input": "a"}}\n]\n```'], + ids=["bare", "fenced"], +) +def test_an_array_of_steps_is_a_plan(reply): + assert parse_plan(reply) == [STEP] + + +@pytest.mark.parametrize("reply", ['"a"', "1", "null"]) +def test_a_scalar_is_not_a_plan(reply): + with pytest.raises(ValueError): + parse_plan(reply) + + +def test_prose_around_the_fence_is_not_stripped(): + with pytest.raises(ValueError): + parse_plan('Here is the plan:\n```json\n{"@op": "Str", "args": {"input": "a"}}\n```')