Skip to content

Feature: Auto extraction - #121

Merged
jhweir merged 11 commits into
devfrom
feat/auto-extraction
Aug 18, 2026
Merged

Feature: Auto extraction#121
jhweir merged 11 commits into
devfrom
feat/auto-extraction

Conversation

@jhweir

@jhweir jhweir commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Auto-extraction — interpreting a call as it happens

Summary

Extraction has been one-shot: somebody presses Extract and a pass runs over the transcript so far.
This adds the standing form — a watch registered against a call's collection, which interprets new
turns as they arrive, on whichever member's node wins the election, with nobody pressing anything.

Four layers: the watch implemented over AD4M's auto-processor; registration driven from the call it
belongs to; two paths that attach what a pass produces; and a space setting that decides whether any
of it runs.

Pinned to @coasys/ad4m@0.13.0-test-interpretation-1.

Status: verified working end to end against a locally built executor, and inert against any
node that has not been rebuilt
from the upstream gather fix this work uncovered
(fix/interpretation-gather-expression-body, open on feature/generic-extraction-ws-ts). The fix is
Rust-only, so no client release is involved — but until a node carries it, the watch registers, the
config lands, and no pass ever runs. One-shot extraction is unaffected and works today. See
Sequencing at the end.

Changes

The watch

interpretationAdapter.tswatch/unwatch. Declared on the port since the interpretation
branch and unimplemented until now.

The turns become a query rather than a list. The one-shot path hands the engine turns the host
read out of a collection; a watch cannot work that way, because the point is that it runs with
nobody there to read anything. So the scope is SPARQL, and transcriptScopeQuery is where WE's
layout gets written in it: children of the call, flagged as text blocks, with speaker and timestamp
read off the reifier of the body link rather than from fields on the block. That is AD4M's own
convention, and it is why a TextBlock needs no author property for this — every agent transcribes
their own microphone, so the link's author is the speaker.

unwatch deletes the config, because there is no other removal. addAutoProcessor has no
counterpart — not on PerspectiveProxy, not in the WS handler map, not in the engine. What it does
is write an AutoProcessorConfig instance into the perspective's graph, which the watch loop reads
back on every tick. So the registration is data, and deleting the record is what stops it. That is
also the right shape rather than a workaround: the config is shared, so removing it stops the watch
for the neighbourhood rather than for whoever pressed stop.

Names in, URIs out. runInterpretation matches shape names; addAutoProcessor takes
target-class URIs. Two entry points to one engine that disagree about how a class is named — the
same trap that cost a day on the one-shot path, where the wrong one is logged as "skipping class"
and surfaces as "perspective has no subject classes to extract into". targetClasses converts
explicitly and throws on an unknown name rather than dropping it, since dropping would silently
narrow what a watch extracts. Raised with the runtime team as a consistency ask.

Registration

The module contract gains watchCollection/unwatchCollection, which does not contradict its
own note that watch is deliberately absent. A module names a collection and holds nothing: the
watch id, the dataset, the containment predicate and the lifetime are the host's. That is what keeps
a panel closing from leaving a registration behind — the failure that note was written against.

The watch id is derived, not stored (we-call-<collectionId>, reduced to letters, digits and
dashes). Any peer computing it must land on the same string, because the engine keys its processors
inside the shared perspective: two members starting one call and disagreeing would register two
watches over one transcript and interpret every utterance twice. The reduction matters because the
id becomes part of a URI — the engine mints its processor node at ad4m://autoprocessor/<id> — and a
WE collection id is itself a literal:string:… URL, so passing it through raw produced a URI with
literal: inside it on a layer that decides literal-from-URI by exactly that prefix.

The transcribe module pairs the watch with the collection assignment, not with the record button
and not with an effect over the signal. The collection appears late — whoever wins the creation
election makes it and the rest adopt the announced id — so there is nothing to name at the press.
Four separate paths assign it (adopt, create, resume, call-ends); an effect that missed one would
leave a watch pointed at a call that is over, which fails silently while still spending an LLM call
per pass. useCollection makes the pairing structural rather than remembered.

Attaching the results

AddAutoProcessorConfig has no parent option. basePrefix decides the URI an instance is minted
under — which confines a pass to a subtree and creates no edge — so nothing links a result to the
call it came from. An unparented TaskBlock is a real, queryable record that no traversal-shaped
route lists, so the call card and the graph would show a successful pass as nothing having happened.
The tasks board and the calendar find them regardless, reading status and startDate rather than
containment.

Two paths, because one is not enough:

  • The listener parents on the processed event, guarded on agentDid. Not defensive: the event
    stream carries other peers' passes, so without it every online member links the same records and
    the call collects one duplicate edge per participant.
  • The sweep covers what the listener cannot see. It fires only on the client whose node ran the
    pass, and only while that client is open; on desktop the executor is a separate process, so it can
    win an election and finish a pass with the app closed. reconcile runs when a call is adopted and
    links every instance under the call's basePrefix that the call does not already contain. Both
    halves are forward traversal, so no reverse scan is involved.

Both delete the day AddAutoProcessorConfig grows a parent. Raised with the runtime team; the
reply confirmed basePrefix is used for output, which it is — as a URI namespace. A namespace is
not an edge, and WE's routes reach content by traversal.

The gate

Space.autoInterpret, administer-only, false by default. A standing watch spends an LLM call on
whichever member's node wins the election and writes what it finds into everyone's copy — left to
each agent, one member could sign the rest up to both. Joining a space should not be the same act as
volunteering to run its extraction.

Injected rather than read: the setting lives on a Space, SpaceStore layers on DatasetStore, and the
dependency only points one way — so DatasetStore takes provideAutoInterpretGate the way
TemplateStore takes provideSpaceLookup. An unset gate reads off.

Its own settings section rather than a row under Modules, because it is not a module: it is what one
of them is allowed to do while nobody is watching.

What the first working run changed

Running it end to end surfaced three things worth fixing before anyone judges the feature.

The task hint had no notion of scope. A live call produced "Add more" and "Trigger the
extraction" as tasks — from "maybe I need to add some more" and "I guess one more to trigger the
extraction"
. Both imperative, neither work: they are about the act of testing. EventBlock's hint
already excluded "the conversation currently happening"; TaskBlock now requires work that outlives
the conversation and names that failure mode directly, because the model plainly needs telling.

Auto-extraction says when it is on. It was otherwise invisible — a pass runs on somebody's node,
writes records and announces nothing, so a call with nothing found yet looked identical to a space
where the setting was never turned on. That ambiguity cost a day of debugging here and would cost a
user the same unanswerable question. The call card now reads the space setting and says so.

That indicator also corrected a classification error: autoInterpret was space-admin state, copied
from enabledModules beside it, which made it unreadable by the template that wanted to show it. It
is space-settings now — the audience is every member, not only whoever may change it, and whether a
space extracts is not an administrator's secret when the results land in everyone's copy. The write
stays admin-gated. tierFit caught it, which is that check earning its place: an unreadable member
failed a test rather than rendering nothing forever.

The diagnostics came down to debug. The config read-back is gone — a query per call start to
answer a question that is now answered. The per-step logging stays, because it is what turned a day
of silence into a five-minute diagnosis, and the generated scope query stays with it: a gather that
binds nothing fails silently, so keeping the query one console-filter away is worth more than the
noise saved by deleting it.

Known follow-ups

  • Blocked on an AD4M fix to run at all. gather_transcript_sparql skips every row whose ?text
    is an expression-wrapped literal — which is what @Property produces by default, since it sets
    resolveLanguage: "literal" and the stored target becomes a signed
    literal:json:{author,timestamp,data,proof} envelope. decode_literal_string accepts only
    LiteralValue::String, so the gather returns an empty transcript with no error and no event. Found
    by instrumenting a locally built executor; patched, verified locally, and open upstream as
    fix/interpretation-gather-expression-body on feature/generic-extraction-ws-ts
    (decode_transcript_body, applied to both gathers). The fix is Rust-only, so no client release is
    needed and this branch's pin stays put — but the deployment will not extract automatically until
    the node it connects to is rebuilt from that branch. Until then the watch registers correctly and
    produces nothing.
  • parent on AddAutoProcessorConfig — removes the parenting listener and the reconcile sweep
    entirely.
  • Names vs URIs between runInterpretation (shape names) and addAutoProcessor (target-class
    URIs). Converted in the adapter; the inconsistency is upstream.
  • The card says "on", not "when". The engine emits per-pass steps (batchReady, claimed,
    processed, …) and a "last pass 2 minutes ago" line would be far better, but those arrive on the
    backend port and carrying them to a template needs new port surface.
  • Stale configs accumulate. unwatch failed until the shape-registration fix, so configs from
    earlier calls remain in those perspectives. They settle harmlessly — a finished call's turns get
    marked processed and no batch forms — but they will each run once when the gather starts working.
    Worth clearing a test space before judging output.
  • The [timestamp] text hack. The one-shot WS turn is {speaker, text}, so the host folds time
    into the text and the class hints name that format. The auto path binds ?timestamp properly, so a
    class's hints cannot describe both shapes accurately at once. Harmless; simplifies if the WS turn
    gains the field.
  • Watch tuning is fixed (15s quiet window, min 3 turns, 2min safety flush). Right for a call;
    worth exposing if it is ever pointed at something else.

Test plan

  • pnpm build (48 packages), pnpm typecheck, pnpm lint, pnpm test,
    pnpm --filter @we/schema-shared validate — all clean.
  • New tests: the generated SPARQL (6), and the watch lifecycle (6 — nothing before a collection
    exists, the right classes when it does, removal on call end, the repair sweep on adoption, a
    backend that cannot hold a watch, and a failed teardown still registering the next call).
  • Verified end to end against a live call, on a locally built executor carrying the upstream
    patch: utterances transcribed, a pass firing on the debounce, and typed instances landing —
    "An upcoming task is testing the auto extraction" → a TaskBlock, "an upcoming event is
    going to Bristol this weekend"
    → an EventBlock.
  • AD4M's own auto_processor_* e2e suite passes on the build this is pinned against (5/5), and
    the full perspectives::interpretation suite is 87/90 — the three failures are model-quality
    tests expecting gemma3:12b and fail identically without the patch.
  • Not run: against a hosted node. Marvin needs a release carrying the gather fix. Registration
    and config-write are confirmed working there; the pass is not.
  • Not run: two peers. The election, the agentDid guard on parenting, and peers converging on
    one shared config are reasoned from the engine's source, not observed.

Sequencing

The branch is complete and merge-ready as WE-side work. Auto-extraction will not produce anything
until: the gather patch merges upstream → a release carries it → Marvin is rebuilt on that release →
package.json moves off 0.13.0-test-interpretation-1.

One-shot extraction is unaffected and works today, hosted or local.

jhweir added 11 commits August 18, 2026 14:27
…tions

`@coasys/ad4m` and `@coasys/ad4m-connect` both move to
`0.13.0-test-interpretation`, published today. It carries the whole surface this
branch needs: `interpretationHint` on `ModelConfig` and `PropertyOptions`,
`identity` on `PropertyOptions`, and the four `PerspectiveProxy` calls.

So the stand-in declarations go — `ad4mDecoratorOptions.d.ts` and
`ad4mInterpretationApi.d.ts` both delete. They existed only to let the repo build
against a runtime that predated the stack, and a duplicate declaration that ever
disagreed with the real one would fail the build rather than merge, which is why
they were always going to be deleted rather than left as belt and braces.

`runtimeSupportsInterpretation` and its guards **stay**. The web build reaches
whatever executor the connect UI is pointed at, so one without the stack is now an
ordinary thing to meet rather than the only thing available — the guard is the
difference between a sentence naming what is missing and a `runInterpretation is
not a function` stack trace.

Verified on the published package with no worktree link: build, typecheck, lint,
28 schemas and the full suite green, and `@we/backend-ad4m` reports 162 passed
with **0 skipped** — the two tests that skip themselves when a runtime drops
`interpretationHint` now run, which is what says the hints reach the SHACL.
`watch`/`unwatch` were declared on the port and unimplemented. This fills them in
against `addAutoProcessor`, which is the half of the interpretation engine that
runs without anybody pressing anything.

**The turns become a query rather than a list.** The one-shot path hands the
engine turns the *host* read out of a collection; a watch cannot work that way,
because the point is that it runs with nobody there to read anything. So the
scope is SPARQL, and `transcriptScopeQuery` is where WE's layout gets written in
it: children of the call, flagged as text blocks, with speaker and timestamp read
off the **reifier of the body link** rather than from fields on the block. That
is AD4M's own convention, and it is why a `TextBlock` needs no author property
for this to work — every agent transcribes their own microphone, so the link's
author is the speaker.

Dialect in a string is what the port exists to avoid, and this is the adapter's
own file, which is the one place it is allowed. The alternative is a query IR
that can express reification, which is far more than one call site is worth.

**`unwatch` deletes the config, because there is no other removal.**
`addAutoProcessor` has no counterpart — not on `PerspectiveProxy`, not in the WS
handler map, not in the engine. What it does is write an `AutoProcessorConfig`
instance into the perspective's graph, which the watch loop reads back on every
tick. So the registration is data and deleting the record is what stops it. That
is also the right shape rather than a workaround: the config is shared, so
removing it stops the watch for the neighbourhood rather than for whoever pressed
stop.

**Names in, URIs out.** `runInterpretation` matches shape *names*;
`addAutoProcessor` takes target-class URIs. Two entry points to one engine that
disagree about how a class is named — the same trap that cost a day on the
one-shot path, where the wrong one is logged as "skipping class" and surfaces as
"perspective has no subject classes to extract into". `targetClasses` converts
explicitly and throws on an unknown name rather than dropping it, since dropping
would silently narrow what a watch extracts.

Feature-probed separately from `interpret`, because the port declares them
separately and that is not a formality: one-shot is a function call, a watch is
coordination. A backend can have the first without the second, and a caller
assuming otherwise registers a watch that never fires and reports nothing wrong.

The generated query is pinned by tests against the shape AD4M's own
neighbourhood test uses — all three bindings present, the reified triple naming
the same predicate as the body pattern, ordered by time. It is the one part that
cannot be checked by reading: a query binding only speaker and text fails the
gather, and a failed gather looks exactly like a quiet call.
Three layers of plumbing so a module can say "keep interpreting this collection"
without holding a watch.

**The module contract gains `watchCollection`/`unwatchCollection`,** which does
not contradict its own note that `watch` is deliberately absent. A module names a
collection and holds nothing: the watch id, the dataset, the containment
predicate and the lifetime are the host's. That is what keeps a panel closing
from leaving a registration behind — the failure the original note was written
against.

**The host derives the watch id from the collection** (`we-call:<id>`) rather
than storing one. Any peer computing it must land on the same string, because the
engine keys its processors inside the *shared* perspective: two members starting
one call and disagreeing about the id would register two watches over one
transcript and interpret every utterance twice.

**The transcribe module pairs the watch with the collection assignment**, not
with the record button and not with an effect over the signal. The collection
appears late — whoever wins the creation election makes it and the rest adopt the
announced id — so there is nothing to name at the press. And four separate paths
assign it (adopt, create, resume, call-ends); an effect that missed one would
leave a watch pointed at a call that is over, which fails silently and keeps
spending an LLM call on a conversation nobody is having. `useCollection` makes
the pairing structural instead of remembered.

Best-effort throughout, logged at debug rather than warn: on any runtime without
the auto-processor this throws on every call, and a warning there would teach
people to ignore the console. The Extract button is the whole feature without it.

Four tests pin the lifecycle — nothing before there is a collection, the right
classes when there is one, removal when the call ends, and a backend that cannot
hold a watch not taking the call down with it.
`addAutoProcessor` has no `parent` option. `basePrefix` decides the URI an
instance is minted under — which confines a pass to a subtree and creates no edge
— so nothing links a result to the call it came from. An unparented `TaskBlock`
is a real, queryable record that no traversal-shaped route lists, so the call
card and the graph would show a successful pass as nothing having happened. The
board and the calendar find them regardless, reading `status` and `startDate`
rather than containment.

Two paths, because one is not enough.

**The listener** parents on the `processed` event, guarded on `agentDid`. That
guard is not defensive: the event stream carries *other peers'* passes —
`agentDid` is documented as "which peer claimed/processed/backed off" — so
without it every online member links the same records and the call collects one
duplicate edge per participant.

**The sweep** covers what the listener cannot see. It only fires on the client
whose node ran the pass, and only while that client is open; on desktop the
executor is a separate process, so it can win an election and finish a pass with
the app closed. Those records would never get their place. `reconcile` runs when
a call is adopted — the moment somebody is about to look — and links every
instance under the call's `basePrefix` that the call does not already contain.
Both halves are forward traversal, the call's children and instances by URI
prefix, so no reverse scan is involved and it is cheap enough to run on open
rather than on a schedule.

The in-memory map of watch → parent is deliberately not persisted. A pass can
complete on a peer's node while this client has never run, so *some* path has to
repair after the fact regardless; once that exists the map is only an
optimisation on the common case.

Both delete the day `AddAutoProcessorConfig` grows a `parent` — with the edge
written server-side there is no window in which a record exists without one.

Also feature-tests the host wrapper **per method** rather than per object. The
host always publishes a forwarding wrapper, so `interpretation?.` only answers
"is there a wrapper"; an older host whose wrapper predates the watch sails past
that and throws on the call. Caught by an existing test whose stub predates these
methods — which is exactly the shape of host a released module will meet.
`SHACLShape.toJSON()` now emits `identity`, and `fromJSON()` reads it back.
Verified in the published tarball rather than from the release note, since the
previous release carried the `identity` decorator and the `toLinks` half while
this exact serialiser still dropped it — and `ensureSubjectClasses` registers
every class *through* `toJSON()`.

So the dedup key reaches the perspective, the interpreter can see it when it
assembles its "already exists" block, and a repeat pass stops re-minting
everything it finds. That matters more for the standing watch than it did for the
button: a watch runs repeatedly by design, so the same task was being re-minted
on every pass rather than once per press.
A standing watch spends an LLM call on whichever member's node wins the election
and writes what it finds into everyone's copy of the space. Left to each agent,
one member could sign the rest up to both — so `Space.autoInterpret` is a
property of the space, administer-only, and false unless somebody says otherwise.
Joining a space should not be the same act as volunteering to run its extraction.

The gate is *injected* rather than read: the setting lives on a `Space`,
SpaceStore layers on DatasetStore, and the dependency only points one way. So
DatasetStore takes `provideAutoInterpretGate` the way TemplateStore takes
`provideSpaceLookup`, and an unset gate reads off — a decision nobody has made is
not a decision to spend somebody's budget.

Its own settings section rather than a row under Modules, because it is not a
module: it is what one of them is allowed to do while nobody is watching. The
copy says who pays, since that is the part easy to miss.

Three generated artefacts move with it, and each was caught by its own check
rather than by inspection: the store manifest the schema validator reads (the
switch failed validation as an unknown member), the capability classification the
surface test demands (an unclassified member is absent from a template's bag —
the `$sources` failure again), and the core SHACL manifest the compiled-vs-written
test compares.
A standing watch that produces nothing looks exactly like a call nobody said
anything extractable in. Testing the first one against a real executor, the whole
observable surface was an absence — no way to tell "never registered" from
"registered and the gather returned nothing" from "another peer holds the claim".

**The engine was already saying which.** `AutoProcessorEvent.step` names it
exactly — `emptyTranscript`, `shapesMissing`, `backedOff`, `notCandidate`,
`gatheringTranscript`, `processed` — and the listener filtered everything except
`processed` because parenting was all it was written for. Throwing away the only
evidence there is was the wrong trade for one `if`.

Also logs the registration itself, from both sides. Module-side, because no log
is equally consistent with "this never executed" and "it executed and threw
nothing" — the ambiguity that made the first round of testing inconclusive.
Adapter-side with the class URIs, and the scope query at debug, because a gather
that binds nothing fails silently and the query is the one part of this that
cannot be verified by reading — so it is emitted where it can be copied and run
by hand.
Two bugs, found by running it. The second is the one that mattered.

**`unwatch` queried through a shape that is not there.** The engine registers the
config class as **`AutoProcessor`**; the ORM class is `AutoProcessorConfig`, and
`findAll` resolves a shape *by name* — so the delete failed with "No SHACL shape
stored for class 'AutoProcessorConfig'". Two names over one set of instances,
which the model's own docs anticipate by telling callers to register it first. It
now does. This is the same disagreement about how a class is named that
`targetClasses` already exists for, one layer along; both are worth reading as
one symptom.

**And that failure took the next registration with it.** `unwatch` and `watch`
sat in one `try`, so the throw skipped the call that starts watching the *new*
collection — one failed teardown and nothing was watched again for the rest of
the session. Which is exactly what happened: the first call registered fine, the
error fired on ending it, and every call after that was silent for a reason that
had nothing to do with them.

They are now separate attempts with separate handling, because they are unrelated
operations on different collections: stopping a watch on a call that ended and
starting one on the call that just began are each worth doing when the other
cannot be. A failed stop is a warning rather than debug — a watch left running
keeps interpreting a call that is over, at an LLM call per pass.

Pinned by a test that fails the teardown and asserts the next call still gets
registered.
The id becomes part of a URI — the engine mints its processor node at
`ad4m://autoprocessor/<id>`. A WE collection id is itself a `literal:string:…`
URL, so passing it through raw produced
`ad4m://autoprocessor/we-call:literal:string:xhzng…`: a URI with `literal:`
inside it, on a layer that decides literal-from-URI by exactly that prefix.

Not proven to be why no pass has ever run — the config is written, the RPC
handler exists on the node being tested against, and the watch loop is wired into
`start_background_tasks` at the version it is built from. But it is the one
oddity visible from this side, and not worth leaving in the picture while
diagnosing something else.

Letters, digits and dashes, still derived rather than stored, so peers still
agree on it.
`addAutoProcessor` resolving says the RPC was accepted. It does not say the
config is in the perspective, and that is the claim that matters: the watch loop
polls `load_processors`, which reads `AutoProcessorConfig` instances out of the
graph. A write that did not land, or landed in a shape the loop cannot parse,
leaves the loop with nothing to do and nothing to say about it — which from
outside is indistinguishable from a loop that is not running at all.

Those two want different people looking at them, so the client now says which.
One query per call start, and it prints what the perspective actually holds.
…n is running

Three things the first working run surfaced.

**The task hint had no notion of scope.** A live call produced "Add more" and
"Trigger the extraction" as tasks — from "maybe I need to add some more" and "I
guess one more to trigger the extraction". Both are imperative and neither is
work: they are about the act of testing. `EventBlock` already excludes "the
conversation currently happening"; `TaskBlock` now requires work that outlives
the conversation and names that failure mode directly, since the model plainly
needs telling.

**The diagnostics come down to debug.** The config read-back is gone — it cost a
query per call start to answer a question that is now answered. The per-step
logging stays, because it is what turned a day of silence into a five-minute
diagnosis, and the scope query stays with it: a gather that binds nothing fails
silently, so keeping the query one console-filter away is worth more than the
noise it saves to delete it.

**Auto-extraction says it is on.** It was otherwise invisible — a pass runs on
somebody's node, writes records, announces nothing, so a call with nothing found
yet looked identical to a space where the setting was never turned on. That
ambiguity cost a day here and would cost a user the same question with no way to
answer it. The card reads the space setting; surfacing the engine's own per-pass
steps would be better but needs new port surface, so it stays a follow-up.

That indicator also corrected a classification error: `autoInterpret` was
`space-admin` state, copied from `enabledModules` beside it, which made it
unreadable by the template that wanted to show it. It is `space-settings` now —
the audience is every member, not only whoever may change it, and whether a space
extracts is not an administrator's secret when the results land in everyone's
copy. `tierFit` caught it, which is the check earning its place: an unreadable
member failed a test rather than rendering nothing forever.
@netlify

netlify Bot commented Aug 18, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit e6f5bf1
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a849ae64cd02e0008a9ccd6
😎 Deploy Preview https://deploy-preview-121--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit a5fb64c into dev Aug 18, 2026
3 of 5 checks passed
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