Skip to content

An assistant: a request in words becomes a Web-Algebra plan, in a chat block of its own - #407

Merged
namedgraph merged 23 commits into
developfrom
feat-assistant-drawer
Oct 5, 2026
Merged

namedgraph merged 23 commits into
developfrom
feat-assistant-drawer

Conversation

@namedgraph

Copy link
Copy Markdown
Member

A signed-in reader can ask the assistant for something in words. The request becomes a Web-Algebra plan, written by REST-VKG's web-algebra service. The plan is shown with its operations and XML, and only runs on Execute. Read-only plans can run on arrival.

A conversation is a block

  • Placement. An ldh:Chat is placed in the document by an ldh:Object block, like a view or a chart. A page holds as many chats as it has chat blocks.
  • Starting one. The create bar's Assistant button starts a new chat block. Its first question writes the chat, the object block and the document's next rdf:_N, in one conditional POST.
  • Composer. Each chat block ends in its own composer. A reader who may not append sees the transcript without it.
  • Storage. Every turn is stored as an ldh:ChatTurn, the chat's next rdf:_N, written to the chat's own document once its answer lands. A turn holds the question, the answer, the plan and what the execution reported, with the result capped at 100 rows. Plan and execution are escaped rdf:XMLLiterals. The block draws its turns from the store, so a chat survives leaving the page and can be embedded.

A plan card

  • Answer first. A second pass reads what the plan did, the rows labelled, as one or two sentences.
  • Trace. The steps fold under the answer, each row folding out its operation's XML as it ran. A SPARQLString's row also shows the query it resolved to.
  • Writes and catch-up. The documents a plan wrote are listed, and the page catches up once the turn is stored.
  • Result well. The result is drawn as a block inside the card, by the plan's presentation hint, the client's ac:mode: a table, a list, a grid of image cards, or a chart (bar, line, scatter or timeline).

Platform

  • Stack. A web-algebra service (ghcr.io/atomgraph/webalgebra-server-ldh) is reached through nginx at the reserved /webalgebra path. It acts for the reader through the secretary's delegation. The model key is the optional openai_api_key secret.
  • ASK. The SPARQL endpoint answers an ASK with a boolean, which needs Core 5.0.6. Core 5.0.6 now arrives through Web-Client 6.0.4, so the snapshot pin and overlay exclude are gone.
  • Agent guide. AGENTS.md is served at /AGENTS.md on every dataspace origin, and says the default graph is empty.
  • Vocabulary. ldh:Chat, ldh:ChatTurn and the turn properties are in ldh.ttl and the rdf/ library.
  • Views. A view lists what an aliased first variable binds, and says when a computed one binds no resources. This fix is already on develop as a cherry-pick.
  • Smaller fixes. A bar chart's value axis starts at zero, and a maximised 3D graph fills the viewport.

Depends on

Tests

  • tests/ui/specs/shell/assistant.spec.mjs has 10 specs:
    • The six that need no model seed a chat with ldh, or stub the plan service. They cover the button and draft, a stored chat with history and storing a turn, two independent chats, the anonymous transcript, resolved values in the trace, and grid and timeline wells.
    • Three go through the real model: Execute and Cancel, a write stored and redrawn, and an answer with a result well.
    • The tenth is the read-only transcript, run as the anonymous reader.
  • tests/ui/specs/document/blocks/view/projection.spec.mjs covers the view fix.
  • All passed on localhost:4443. One model-backed test was rerun after a Fuseki restart.

🤖 Generated with Claude Code

namedgraph and others added 23 commits October 3, 2026 20:14
…ore it runs

The drawer on the right edge of every page for a signed-in reader sends a question
in natural language to the web-algebra sidecar's plan service, with the document the
reader is on, its dataspace's SPARQL endpoint and the earlier turns. The plan comes
back as its summary, the operations it invokes and the XML itself, and runs only on
Execute (or at once with "Execute by default"); the steps report as the executor
does, the result set or the documents written are shown, and the page catches up.

Each card keeps its plan and, once run, what it returned, so a follow-up that refers
to "them" is planned on that result as a VALUES block rather than on a new query. A
Clear button empties the log and both stores. LDH's operation family is addressed as
`waldh:` elements. The stack gains the web-algebra service and nginx's reserved
/webalgebra path with the caller's certificate; OPENAI_MODEL picks the planner's
model. The chat section of ldh.css parks the core Drawer under the sticky header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…raph is empty

A client writing a query against a dataspace's endpoint has to know that every
document is a named graph and a pattern outside GRAPH matches nothing; the
assistant's SPARQLString reads an endpoint's AGENTS.md as service documentation and
was answering "no results" for want of it. The repository's guide is shipped in the
WAR from where it lives, mapped to the default servlet beside robots.txt and
sitemap.xml so it is public and never enters the dispatcher, served as
text/markdown, and gains the named-graph paragraph in its Querying section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t they return

The drawer moves inside #tab-body, beside the panes and under the dataspace tab bar:
the header and the tab bar keep the full width, the drawer starts where the panel
does, and its head takes the action bar's height so the two seams are one line. An
open drawer insets the panes and the footer rather than the frame. The create dock's
full-bleed margin and padding, computed on the viewport, slid its buttons under the
drawer; they now share --ldh-pane-w, the panes' width, which the open drawer narrows.

A result set's rows get their labels: the distinct URIs are looked up with the two
metadata queries a view block runs, against the endpoint the plan queried, and the
rows are drawn again with the metadata tunnelled to the link leaf when the labels
land. The UI spec measures the panes and the seam, and expects the written container
by its title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e OAuth ones

The base compose file no longer binds secrets/openai_api_key.txt, so a stack without a
key - the test workflows, a deployment that only runs plans - comes up; a deployment
that writes plans declares the openai_api_key secret and OPENAI_API_KEY_FILE in its
override, which is where the dev stack now has them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nt guide says a join crosses documents

The dataspace's ontology, which every document advertises with Link rel=lds:ontology, is the
vocabulary its domain data is described in: labels, domains and ranges the planner's induced
schema lacks, and none of the documents' plumbing the induced one picks up. The client now keeps
the Link target the way it keeps the endpoint, lds:ontology() reads it, and the question goes to
the plan service with it, so a SPARQLString against this dataspace is written from GET of the
ontology rather than from ExtractOntology (REST-VKG #26).

The guide the query writer reads from /AGENTS.md gains the two rules the Northwind evaluation
showed the model breaking: a resource is described in the document about it, so a pattern that
follows a link to another resource needs a GRAPH of its own for each; and a property is used on
instances of its domain, so what the ontology declares on Person is reached through the
corporation's employee, not written on the corporation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…depends on

The SPARQL endpoint answered an ASK with an empty result set, so the Web-Algebra SPARQLString
check - an ASK with the query's own pattern, to send a query that matches nothing back to the
model - never bit against this platform. The boolean result is Core 5.0.6: SPARQLResult, its
provider, and the ASK path of SPARQLEndpointImpl. Core is pinned at 5.0.6-SNAPSHOT ahead of
Web-Client's transitive 5.0.5, and SPARQLResultProvider is registered beside ResultSetProvider
on the server and on both clients. The pin resolves from a local install only until Core is
released, so an image build needs that release, or the snapshot deployed, first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he service's reason

An envelope holding a message instead of a plan was shown as a reply whatever the status, which
read a validator's refusal - a parser's line and column - as the assistant declining in words. A
message on a 200 is still a reply; on any other status it is the service's reason for failing, and
the card now reports it as the failure it is, through the chain's own failure path, with that
reason as the detail rather than the status line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A step row borrowed its operation's XML from the plan only when the executor's steps and the
plan's operations matched one to one, so a plan that failed at its tenth operation of nineteen
rendered every row without a fold-out, and so did one still running - only a plan that ran to the
end lined up. The steps are now paired with the plan's leading operations, as many as were
entered, in the order the executor enters them; a ForEach's iterations are more steps than
operations and still get none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d onto the create bar

The assistant produces blocks, and the drawer kept it where blocks are not: a plan wrote into the
document, then the whole page reloaded to catch up, in a panel beside the content it changed. The
placement rule already said where it belongs - a form for what is added to the current document
renders inline - so it moves there. The conversation is an ephemeral block at the end of the
content body, before the create bar, so what a step writes lands above it in order; the composer
is a panel docked onto the bar's upper edge, opened by the bar's own button beside Create and
closed by it or by Escape. A page with no bar - a proxied resource, a search result - gets a bar
holding the composer alone, so reads work everywhere.

None of it is server-rendered but the button: a conversation exists only once the client runs,
and the body it sits in is replaced on every navigation and every write's catch-up. So the
conversation is not the DOM: each document's log is an element kept under the document's URI, the
one composer form is kept for the session, and ldh:ChatMount moves them into whatever body was
just rendered. A card is painted into its log whether the log is in the page or not, so a plan
still running when the body goes finishes into its log and is there when the document is next
shown; a card that reported a write comes through the catch-up with its steps, its documents and
its fold-outs. Conversations are per document, and the history the planner hears is that
document's.

The drawer, its edge sensor and its CSS go; the mount is guarded on the WebID itself rather than
the agent's document, which document() only has once something loaded it. The assistant spec
covers the new surface: opened from the bar and docked above it, a card in the body, Clear from
the block's head, and the card surviving the catch-up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…arts

Docked onto the create bar, the composer took the bar's full width: the bar is full-bleed on the
left and padded on the right to the column's edge, so the composer ran from the viewport's edge to
the Create button while the content it serves sits in a centred column. Its left margin now makes
up the column's inset over the bar's own padding, and its width is the column's inside its padding;
margin plus width fill the bar's line exactly, which is what wraps the buttons below it. The spec
measures it against the column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it's block header, its skin in app.css

The assistant had no place in the design system - no component, no card, no rule - so its markup
could only be read off the XSL. It is one now (ui_kits/app/Assistant.jsx, preview/component-assistant.html),
and the product uses the kit's vocabulary: the chat-* classes become ldh-chat-*, the block's head is
THE block header at compact density with Clear in its actions cluster, and the rules that are the
component's look - the create bar's, the block's, the turns', the plan card's, the step rows', the
composer's - move from ldh.css into the vendored app.css, pushed upstream in the same change.
What stays in ldh.css is the page's geometry: where the bar parks and how wide the docked
composer is, which only this page's column and pane widths can say.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A plan's card showed its rows and its step tree, which is the machinery; the answer the reader
asked for was theirs to read off it. Once a plan ends, and once its rows have their labels, the
card asks the answer service beside the plan service for a reading - the question, the plan's
summary, the steps, the documents written and the labelled rows go, a sentence or two comes back
- and that sentence becomes the card's first line. The steps now live under a trace disclosure
whose summary is their count and the plan's time: open while the plan is being read and run,
since the rows are what there is to see, folded once there is an answer to read instead. The rows
stay on the card and stay the authority; a service that cannot answer leaves the card as it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…akes it the document's

A plan's result rendered as a bare table whatever it was. It now sits in a well of its own under
the card, headed like a block with the mode's label, and drawn through the document's own block
rendering by the plan's presentation hint: a result set as the results table or, when the plan
said chart, as a chart drawn into the card with the hint's axes; a graph as the view block's list,
grid or table. What the reader sees in the card is what a block in the document would show.

Keep, in the well's actions, writes it there: a plan of the card's own making - the query stored
on the document, the endpoint registered as a service first when it is not the dataspace's own, a
view in the mode drawn or a chart with the axes drawn, and an object block placing it after the
document's last block - run at once as its own card. A document the assistant wrote to comes back
in ContentMode, which is the mode that shows its blocks, so the kept block appears above the
conversation. The fragments carry their '#', since the waldh: operations resolve <fragment>
against <url> as a relative URI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and how it went

Every write answers a one-row ?status ?url result set, and the card drew each one as a result
well with Keep, sent them to the answer pass as the rows - which then said no value was returned -
and kept them as the next question's "them". They are the documents written, which the card
already lists, so they are left out of all three, and the list counts each document once.

The trace folded under a summary that read as a caption: no chevron, no hover, and the green rows
seemed to vanish. The summary now has a chevron that turns when open and the outcome's glyph, a
green check or a red error, so the folded trace still says how the steps went.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep pinned a result into the document as a view or chart built from the card's query. It
chose the block by the card's rendering, and turned "what's the current year" into a view over
SELECT (YEAR(NOW()) AS ?currentYear), which a view - a list of resources - cannot show, and whose
block then failed to render at all. With chats about to be stored in the document, every
result persists anyway, and putting one into the document's flow is a request in words that
the planner writes as blocks. The button, its write plan and its translations are removed; the
result wells stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…inds no resources

SPARQL.js gives an aliased projection, (?s AS ?resource), as a map rather than the string "?s",
and reading only the string left the view's focus variable empty and its whole render failing.
ldh:first-var-name reads both shapes.

An alias is bound only in the projection, so the rewrites that project something else or append
patterns to the WHERE (the result count, the container lookup, the parallax step) first wrap the
query as a subquery (ldh:wrap-subquery). Without it the container lookup joined on an unbound
variable, matched every triple in the store and exhausted Fuseki's memory. The count's projection
and the parallax step's WHERE now match the outer query only, not a subquery's.

A first variable that binds values, (YEAR(NOW()) AS ?year), gives rows the DESCRIBE finds nothing
for. The count tells the two apart, and the block says the query lists no resources rather than
that nothing matched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…turn by turn, and shows its plans resolved

The assistant moves from one ephemeral, client-kept log docked on the create bar to an ldh:Chat
resource placed by an ldh:Object block, like a view or a chart. A page holds as many chats as it has
chat blocks, each ending in its own composer; the bar's Assistant button starts a new one before the
bar, which is written - chat, object block and the document's next rdf:_N, in one conditional POST -
on its first question, so a click that asks nothing leaves nothing behind.

Every turn is an ldh:ChatTurn, the chat's next rdf:_N, written to the chat's own document once its
answer lands: the question, the answer, the plan and what the execution reported (result capped at
100), as XMLLiterals, and the outcome. The write catch-up waits for it, and the chat block's RowHook
draws every turn from the store, so a chat survives leaving the page, can be embedded, and continues
with its stored history. A reader without acl:Append sees the transcript and no composer.

The literals travel escaped, typed rdf:XMLLiteral: Jena's RDF/XML parser refuses an rdf:RDF inside
rdf:parseType="Literal", and a turn whose plan read a graph answered 400.

The trace shows the plan as it ran: a step that reported a value at run time (the query a
SPARQLString wrote, as wa:value) folds it out, and the operation around it shows the call replaced by
that value.

ldh.ttl and the rdf/ vocabulary gain ldh:Chat, ldh:ChatTurn and the turn's properties; the global
mount, the session composer and Clear are gone. tests/ui/specs/shell/assistant.spec.mjs is rewritten
around chat blocks (8 specs, 5 without a model).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nd draws a grid and a timeline

A plan's <wa:present> now names the layout mode as ac:mode - ac:TableMode, ac:ListMode, ac:GridMode,
ac:ChartMode, the values a view block's ac:mode takes - and a chart's details as the properties a
chart block has: ldh:chartType, ldh:categoryVarName, ldh:seriesVarName. ldh:present-mode and
ldh:present-chart-type read them, and still read the table/list/grid/chart tokens and the
type/category/series attributes of turns stored before, so those draw as they did. A result well
carries its mode's URI in data-mode.

Specs: a stubbed grid plan whose graph depicts its resources draws one card per resource with its
image, live and again from the store; a stubbed timeline plan draws one labelled span per row, the
one whose end came from COALESCE included. Both run without a model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The trace folds out each operation's XML in the kit's code field, which is a flex row built for the
kit's own text area, the one child that grows. The trace puts a plain pre there, which nothing told
to grow, so a plan of short lines showed its XML in a box as wide as its longest line inside a
field as wide as the card. The pre now takes flex: 1 and min-width: 0, as the text area does; long
lines still scroll inside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t since #404

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the overlay exclude go

The boolean result of an ASK, which the assistant checks its queries with, shipped in Core 5.0.6 and
Web-Client 6.0.4 depends on it. The direct core:5.0.6-SNAPSHOT dependency, which only resolved from a
local repository, and the overlay's exclude of its own Core jar are no longer needed; the
SPARQLResultProvider registrations compile against the released Core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s controls, and a maximised graph fills the viewport

- chart.xsl: a bar's length is its value, so the bar chart's value axis takes minValue 0 (ignored when
  the data goes below it)
- app.css: the chart's Save stores what its controls picked, so its footer is hidden while the
  controls are collapsed
- ldh.css: a maximised 3D graph canvas is pinned with inset: 0 and auto size, rather than 100% of a
  containing block that is not the viewport

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@namedgraph
namedgraph merged commit 2ad169d into develop Oct 5, 2026
0 of 3 checks passed
@namedgraph
namedgraph deleted the feat-assistant-drawer branch October 5, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant