Skip to content
Merged
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ many are breaking.

### Added

- **The `ldh-*` operations take the LinkedDataHub CLI's options**, and write
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`
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
domains, big-step evaluation rules, effects and ordering, the error table,
the full catalog with an `Abstract:` signature per operation, and the
Expand Down Expand Up @@ -134,6 +142,12 @@ 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. 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))
Expand Down
28 changes: 23 additions & 5 deletions formal-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 · 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` |
| `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` |
Expand All @@ -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`, 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
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.
Expand Down
24 changes: 19 additions & 5 deletions src/web_algebra/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import sys
import json
import re
from types import ModuleType
from typing import List, Type, Optional
import pkgutil
Expand All @@ -27,7 +28,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(
Expand Down Expand Up @@ -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))

Expand All @@ -83,7 +98,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
Expand All @@ -105,14 +120,13 @@ 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)
reply_json = parse_plan(chat_completion.choices[0].message.content)
logging.info("Generated response: %s", reply_json)

# Execute the generated operation
Expand Down
33 changes: 21 additions & 12 deletions src/web_algebra/operations/linkeddatahub/add_result_set_chart.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
34 changes: 31 additions & 3 deletions src/web_algebra/operations/linkeddatahub/add_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,21 @@ def inputSchema(cls) -> dict:
},
"mode": {
"type": "string",
"description": "Optional URI of the display mode for the view.",
"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#ReadMode",
"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",
"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"],
}
Expand Down Expand Up @@ -129,13 +135,20 @@ 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,
title_literal,
description_literal,
fragment_literal,
mode_uri,
show_when_empty,
)

def execute(
Expand All @@ -146,6 +159,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):
Expand Down Expand Up @@ -177,6 +191,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)
Expand Down Expand Up @@ -218,6 +239,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
Expand Down Expand Up @@ -245,8 +269,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"]
Expand Down
Loading
Loading