Skip to content

feat: summaries in the publishing pipeline [D-621] - #207

Merged
delasous merged 17 commits into
mainfrom
fix/d-621-keyless-llm-endpoint
Sep 8, 2026
Merged

delasous merged 17 commits into
mainfrom
fix/d-621-keyless-llm-endpoint

Conversation

@delasous

@delasous delasous commented Aug 21, 2026 •

Copy link
Copy Markdown
Member

Summaries as an automatic part of the publishing pipeline (D-621). Integration branch for the whole track; same tip as feat/summary.

  • Readable transcript cleanup is removed entirely (Chris + Silvio): transcripts publish as transcribed, the summary is the only LLM step, and the preferred-spellings vocabulary went with the cleanup prompt it steered. Captions are now always produced, from the transcript itself. Old meetings with a cleaned transcript still render it.
  • Keyless OpenAI-compatible endpoints switch summaries on; request bounds for slow local models. Recorder env only — no new deploy options.
  • In-app LLM settings (D-696): llm-settings.json, GET/PUT /settings/llm, named endpoints with write-only keys, model discovery proxied via the operator. Deploy env seeds the first start; endpoint changes never need a redeploy again.
  • New admin Settings surface (beside Browse and Operator) hosting transcription quality and the LLM panel.
  • The viewer shows the summary sealed inside the portable .opus (D-697) — retroactive for already-published meetings.
  • cassini meetings summarize backfills summaries into sealed meetings from their own transcripts, in place, audio untouched.

Upgrade notes: LLM_BASE_URL set without a key used to do nothing and now enables summaries; the env trio is read once to seed the settings, then ignored.

Suites green: recorder (16 pkgs), operator, viewer (406), app (93). Changelog fragments included.

🤖 Generated with Claude Code

`IsConfigured()` required an API key AND a base URL, so pointing Cassini at a
self-hosted OpenAI-compatible server — llama.cpp, vLLM, Ollama, none of which
usually authenticate — silently disabled readable cleanup and summaries
entirely. There is no error and no log line that says why; the pipeline just
publishes without a summary. That makes the comparison D-621 exists to run
(a local model against the paid one, on real meetings) impossible to even set
up, which is why this comes first.

The base URL is now what enables the capability, and the key is optional:

    IsConfigured() == !Disabled && BaseURL != ""

That has a consequence worth naming, because it is not obvious from the diff.
`CASSINI_SUMMARY_DISABLED` worked by blanking the API key and relying on
IsConfigured() to reject the result. With the key no longer required, blanking
it disables nothing — so the kill-switch needs a real field. `LLMConfig.Disabled`
is that field, applied to a copy taken *before* either switch, so the two
capabilities stay independent in both directions.

Readable cleanup gets its own switch, `CASSINI_READABLE_DISABLED`, in the same
change. Without it this fix would quietly switch cleanup back on for every local
endpoint, because the two share the gate — a decision made by accident rather
than on purpose. We have dropped cleanup for our own use; others may want it;
either way it should not ride summarisation's switch.

Two more small blockers on the same path:

  - The request timeout was hardcoded at 240s, which is short for a CPU-bound
    local model on a long prompt. `CASSINI_LLM_TIMEOUT_SEC` now sets it,
    default raised to 900s. Note this is a per-request bound and readable
    cleanup makes many sequential requests, so a wedged endpoint can stall a
    build longer than before; build jobs have no operator-side timeout.
  - `max_tokens` was hardcoded at 4096, which truncates a long summary.
    `CASSINI_LLM_MAX_TOKENS` now sets it, same default.

`Authorization` is omitted entirely when there is no key rather than sent as
`Bearer ` with an empty value, which some self-hosted servers reject outright.

Finally, the manifest. `SUMMARY_MODEL` and `CASSINI_SUMMARY_DISABLED` were
implemented but never declared in appinfo/info.xml, and AppAPI silently drops
undeclared keys — so on a real install they were unreachable no matter what an
administrator set. They are declared now, along with the three new keys, and
carried through the deployment bundle and the harness ExApp registration.

Docs across six files described the API key as the switch. They now describe the
base URL as the switch, and the privacy note is about "whatever endpoint this
names" rather than "that third party", since it may now be your own host.

One upgrade behaviour change: anyone who had set LLM_BASE_URL without a key was
getting nothing, and after this gets both steps.

Tests in llm_config_test.go cover keyless vs keyed vs endpointless config, each
kill-switch independently, the request-bound overrides and their defaults, and
the wire behaviour — that the header is absent when keyless, present when keyed,
and that the configured max_tokens reaches the request body.
…[D-621]

Drop the five new deploy-time declarations — SUMMARY_MODEL,
CASSINI_SUMMARY_DISABLED, CASSINI_READABLE_DISABLED, CASSINI_LLM_TIMEOUT_SEC,
CASSINI_LLM_MAX_TOKENS — from info.xml, compose.yml, the harness passthrough
and the ExApp docs. They stay as recorder env: that is how the operator will
drive `cassini build` from persisted settings (D-696) and how scripts configure
it. But an AppAPI deploy option can only be changed by re-creating the
container, and D-696 replaces that path with in-app settings, so declaring
them now adds install-form fields we are about to retire. LLM_BASE_URL,
OPENROUTER_API_KEY and LLM_MODEL remain as the install-time seed, with the
reworded descriptions.

Adds the changelog fragment D-621 asks for, including the upgrade note.
Recorder code and tests unchanged.
@delasous
delasous marked this pull request as ready for review September 1, 2026 14:07
…-endpoint

# Conflicts:
#	cassini-go-recorder/internal/transcribe/llm.go
#	docs/exapp-install.md
The operator now owns the LLM policy instead of the deploy environment:
llm-settings.json beside the job database holds named providers (base URL +
write-only key) and, for readable cleanup and the meeting summary, whether the
step runs, on which provider, with which model. GET/PUT /settings/llm reads and
changes it; GET /settings/llm/providers/{id}/models proxies the endpoint's
model list so the key stays server-side.

Env is now the operator→recorder wire only. ChildEnv strips every inherited
LLM variable and emits a resolved per-step set — READABLE_* / SUMMARY_* — so
"off" is simply no endpoint; the recorder learns those per-step overrides
(an endpoint override brings its own key). The deploy trio LLM_BASE_URL /
OPENROUTER_API_KEY / LLM_MODEL seeds the file once on first start and is
ignored afterwards; an install configured only by env behaves as before.
A third admin-gated surface, Settings, beside Browse and Operator — same
fragment routing and the same admin probe as the operator surface. It hosts
the transcription-quality panel (moved off the operator page) and a new LLM
panel: endpoint CRUD with write-only keys, per-step enable/endpoint/model for
readable cleanup and the meeting summary (model field is free text with a
fetched model list behind it), and the request bounds. The client normalizes
the /settings/llm contract defensively and never carries a raw key.
@delasous delasous changed the title fix(recorder): let a keyless LLM endpoint configure summaries [D-621] feat: summaries in the publishing pipeline [D-621] Sep 2, 2026
@delasous
delasous marked this pull request as draft September 2, 2026 09:35
Decision (Chris + Silvio): transcripts publish as transcribed — speaker
attribution and paragraph breaks, no LLM rewriting. The meeting summary is
now the only LLM step.

Gone with the step: its pipeline stage and batch client; the config surface
(CASSINI_READABLE_DISABLED, CASSINI_READABLE_STRICT_BATCHES, the per-step
READABLE_* variables, BuildConfig.LLM); the readable step in the operator's
LLM settings and the app's Settings panel; and the preferred-spellings
vocabulary end to end (STT settings field, CASSINI_TRANSCRIPTION_TERMS, the
panel section) — it existed only to steer the cleanup prompt.

Captions are now always written, from the canonical transcript; they were
previously a by-product of cleanup and vanished without an LLM endpoint.
ChildEnv still strips the retired variable names so an old deploy env cannot
leak them to the recorder, and the seed still honours a legacy
CASSINI_SUMMARY_DISABLED. The portable format keeps reading files that carry
readable transcripts, and the viewer keeps rendering them: old meetings are
untouched.
# Conflicts:
#	cassini-app/src/SettingsPanel.svelte
#	cassini-app/src/operator/client.test.ts
#	cassini-app/src/operator/client.ts
#	cassini-app/src/operator/types.ts
#	cassini-operator/internal/operator/build_runtime.go
#	cassini-operator/internal/operator/settings.go
#	cassini-operator/internal/operator/settings_test.go
The exapp image's web-builder predated the root package-lock.json and ran
npm install over the stale per-package lockfiles, which crashes npm 10+
(edgesOut of null). npm ci from the root lockfile is what CI and local
builds already do.
@delasous
delasous marked this pull request as ready for review September 3, 2026 16:05
A timeout describes the endpoint's host — a CPU-bound local model needs a
longer leash than a hosted API — so timeout_sec and max_tokens move from a
global section onto each provider, in the settings file, the API, and the
panel. The wire is unchanged: ChildEnv emits the resolved endpoint's bounds.

The panel shows an empty field with the default as placeholder (900s / 4096)
instead of a literal 0. A pre-existing llm-settings.json with root-level
bounds loses them (only the harness ever wrote one); the env seed now attaches
CASSINI_LLM_TIMEOUT_SEC / MAX_TOKENS to the seeded provider.
# Conflicts:
#	cassini-go-recorder/internal/cassini/retag.go
#	changelog.d/viewer-published-v1-transcript.md
#	deployment/Dockerfile.exapp
Main's published-v1-only rework changed buildPortableOpusFixture to an
options object with payload-ref transcripts; the D-697 summary test still
passed the manifest directly and crashed on wire.readableTranscripts.
Keep #259's decoder-vocabulary behavior while preserving #207's keyless summaries, in-app LLM settings, portable summary support, and backfill command. Compose the newer Talk-secret handoff with both persisted STT and LLM child environments.
Assert that decoder vocabulary, keyless summary settings, STT quality, and the Talk secret all survive the composed child environment. Remove stale documentation that still described portable summaries as future work.
delasous added a commit that referenced this pull request Sep 8, 2026
Resolves two viewer conflicts left by main's 1379554, which retired
`pickReadableForTranscript` in favour of `pickDisplayForTranscript`
while #207 was adding the sealed-summary reader alongside it.

- loadArtifact.ts: drop the import of the deleted function (its call
  sites are already on pickDisplayForTranscript), keep
  readPortableSummaryMarkdown, which still feeds `summary:`.
- portable.test.ts: keep #207's readPortableSummaryMarkdown suite and
  main's pickDisplayForTranscript suite, which supersedes the readable
  one and covers the same cases plus the flagged-default variant.
@delasous
delasous merged commit 6478b43 into main Sep 8, 2026
33 checks passed
ikusteu added a commit that referenced this pull request Sep 9, 2026
Main brought the insight surfaces (D-700, D-715..D-724), the meetings-list
endpoint (D-701) and the harness seed pack; this branch made Nextcloud access
control an opt-in storage model with two archive roots (D-616, D-708). Fifteen
files conflicted. Most were additive unions; three were real design meetings.

The catalog resolution. D-701 extracted resolveCatalogForCaller so
`published/meetings-list` and `catalog.json` share ONE access-control path, and
built it on the per-caller PROPFIND intersect. This branch had already split
that read in two — the intersect for the Team folder, a plain owner read for the
private root — because a per-caller scan of a tree only the service account has
can answer nothing but 404. Resolved by moving the model decision INTO
resolveCatalogForCaller: it dispatches to resolveOwnerCatalog or
resolveACLCatalogForCaller, serveOwnerCatalog is re-expressed over the first,
and D-701's invariant survives intact — every reader still goes through one
resolution. Without that join `published/meetings-list` would have answered "you
may read no meetings" on every default-model install, the exact false negative
the endpoint exists to prevent. TestMeetingsListInTheDefaultModelServesTheWhole
Archive pins it and fails without the dispatch.

The read identity. Main's context-bundle staging reads as the caller from a
hard-coded ncRecordingsRoot, which this branch had already split into two
constants. Identity and root are one question, so they are now answered in one
place: ncArchiveReadIdentity, used by the read proxy, readableMeetingsForCaller
and stageMeetingForContext alike. Pairing the wrong identity with the wrong root
is the disclosure that guard exists to prevent, and it can no longer be done
half-way.

The surfaces. D-723 folded #207's `surface=settings` into a panel nav inside
Operator; this branch had added `setup` as a third surface. Both kept: settings
is gone as a surface, `setup` stays one, because it does not configure the
pipeline — it decides where the whole archive lives, and moves it. /setup now
carries awaiting_choice AND features, and readSetupHealth reads both.

Verified: both Go modules build, vet and test clean; the app's 367 tests and the
viewer's suite pass; gofmt, shellcheck and the offline script suites are green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B2TDAnxLKfdKKdVDPFRvYL
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.

2 participants