Skip to content

fix: repair #207 after the #259 merge - #269

Merged
delasous merged 19 commits into
fix/d-621-keyless-llm-endpointfrom
fix/pr-207-after-d-695
Sep 8, 2026
Merged

delasous merged 19 commits into
fix/d-621-keyless-llm-endpointfrom
fix/pr-207-after-d-695

Conversation

@silviot

@silviot silviot commented Sep 4, 2026

Copy link
Copy Markdown
Member

Repairs the integration branch behind #207 after #259 duplicated the readable-cleanup removal with different vocabulary semantics.

What this resolves:

Important: please merge this PR with a merge commit, not squash/rebase. Keeping main in this branch's ancestry is what makes #207 mergeable again and gives the stacked #261 repair a clean parent.

Verified:

  • go test ./... and go test -race ./... in both Go modules
  • cassini-app: 107 tests; cassini-viewer: 411 tests
  • app, embedded app, and viewer production builds
  • shellcheck scripts/process-recordings.sh
  • ./scripts/test-info-schema.sh

silviot and others added 19 commits September 3, 2026 21:27
…or resolved

Since D-447 the operator generates and persists a Talk recording secret
when CASSINI_TALK_RECORDING_SECRET is not set, and registers it in Talk.
The recorder it starts read the secret from the environment only, so on
a zero-config install Talk saw a working backend and every recording
failed at authentication with "talk auth mode hpb-internal requires
CASSINI_TALK_RECORDING_SECRET to be set". Found on the demo sandbox,
which relies on the generated secret.

The recorder and its doctor now get the resolved secret in their
environment, overriding the operator's own copy of the variable; when
the secret came from the environment the two are the same value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQZeU8K8CRrdWLBWAEp6yP
…nvKey

Review follow-ups: `cassini doctor --target record` checks disk and
directories only, so it no longer receives the recording secret; the
recorder's environment is built with the package's setEnvKey; the tests
now unset the variable when they mean absent, and cover a stale copy in
the environment and a whitespace-only secret.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQZeU8K8CRrdWLBWAEp6yP
fix(operator): hand the recorder the Talk recording secret the operator resolved
- Upgrade github.com/k2-fsa/sherpa-onnx-go and platform bindings to v1.13.7
- Update CUDA base image and GPU build script to sherpa-onnx v1.13.7
- Add changelog fragment for D-725
- Match workspace directive to Go 1.24.0
- Key extract dir by tarball name to avoid cache collision
- Key shim clone by sherpa version
…x-v1-13-7

feat(transcribe): upgrade sherpa-onnx to v1.13.7 [D-725]
…scriber [D-695, D-726] (#259)

* feat!: retire the LLM cleanup step and make the vocabulary bias the decoder [D-695]

The LLM readable-transcript cleanup was shelved, but nothing in the tree said
so: the pipeline still called it behind an `IsConfigured()` gate, we still
documented a knob for it, and an entire component existed to run it. Configure
an LLM and it would start rewriting transcripts again.

Remove the step and everything that only existed to serve it: `ReadableCleanup`,
`ApplyReadableText`, `redistributeWords`, `writeReadableArtifacts`, the
`transcript.readable.v1` producer, `CASSINI_READABLE_STRICT_BATCHES`, the
`--strict-readable-cleanup` flag, and the `cassini-readable` component.
Summarisation is untouched and is now the only LLM step in the pipeline.

Reading stays. The published portable format v1 lists `readableTranscripts` as
optional and shipped files carry one, so `internal/portable` and the viewer keep
their read paths. Only the producer is gone.

Captions are now written on every build. They used to be a by-product of
cleanup, so a deployment with no LLM configured got none, which was never
intentional: nothing about a caption file needs a model.

The vocabulary setting survives with a new job. It used to steer the cleanup
prompt and explicitly not affect the transcript; it now biases the speech
recogniser through sherpa-onnx hotwords, which is the thing an operator wanted
when they typed a name into that box. sherpa reads hotwords only under
modified beam search and encodes them with the model's BPE vocabulary, so both
move together or neither does, and a build that cannot bias records that in
`provenance.speechToText.hints` rather than decoding unbiased in silence.
Upstream ships some Parakeet bundles without `bpe.vocab`; one is derived from
`tokens.txt` once per model directory so those bundles can take hints too.

`WriteManifest` takes a struct now. It had grown to sixteen positional
parameters including two device strings and three booleans, and this change had
to touch it anyway. The recognizer construction path grew a `passConfig` for the
same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

* fix(transcribe): require a real bpe.vocab for decoder hints, and measure the cost

Deriving a flat-score bpe.vocab from tokens.txt was measurably not safe. On
parakeet-tdt-0.6b-v2-int8 with a derived vocabulary, biasing towards "Librocco"
turned the baseline's "Libroco" into "Libro" and dropped a clause; the same clip
on v3 with the bundle's own bpe.vocab was clean. A flat-score substitute does
not tokenise the way the model's real vocabulary does, and a partial hotword
match then boosts a prefix the decoder emits on its own.

So require the real file. A bundle without one records the vocabulary as not
applied, names bpe.vocab, and points at upstream's generate_bpe_vocab.py. That
is the "clear disabled state" this feature needs, and it is strictly better than
a silent no-op or a confident wrong answer.

Add the manual probe harness the measurements came from. It is skipped unless
CASSINI_PROBE_PCM is set, so it never runs in CI, and it is how the next person
re-measures instead of guessing.

Measured cost of biasing on CPU, Parakeet TDT 0.6B v3, real meeting audio:
30 s of audio decoded in 1.79 s greedy and 2.60 s biased; 45 s in 3.19 s and
4.34 s. About 40 percent more, still several times faster than realtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

* feat(transcribe): decode with beam search on every tier that supports hotwords

Silvio's call: switch it all the time rather than adding a setting. "For now we
can switch it all the time: it's simple. We'll have numbers after we use it
switched this way. And we can revisit this decision."

So the decoder no longer depends on whether the vocabulary box happens to be
empty. Transducer models always run modified beam search; a vocabulary only adds
the context graph on top. One code path, no new switch, and the numbers will
come from real use rather than a lab comparison.

That makes two things more important, not less, and both are handled:

The fast quality tier runs a CTC model, and sherpa-onnx has no hotword support
for CTC. "Always beam search" therefore cannot mean "on every tier": fast keeps
greedy search, and a vocabulary configured against it is recorded as not applied
with a reason naming the tiers that do work. The settings panel says so too.

The silent no-op is the failure mode to engineer against, because an always-on
decoder would ship that bug to everyone. Getting modeling_unit or bpe_vocab
wrong yields a recognizer that starts happily and biases nothing. Both are
refused loudly: no bpe.vocab means hints are recorded as not applied, and
modeling_unit is set in the same block as the hotwords file so neither can be
set without the other. Tests cover both paths.

Measured cost, Parakeet TDT 0.6B v3 fp32 on CPU, real meeting audio:
32.9 s clip 1.63 s greedy vs 2.31 s beam; 45 s clip 2.78 s vs 4.10 s. So 1.3x to
1.5x, worst realtime factor 0.091, memory unchanged. Output changes in both
directions: beam lost a sentence on the short sparse clip and gained words on
the dense one. It is a different decoder, not a better one. Full table in the
PR and in the changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

* feat!: withdraw the readable-cleanup role from the portable format [D-695]

Silvio asked for all references to LLM cleanup of transcriptions gone, not just
the producer. The earlier commit kept the format-level read path on the theory
that shipped files might carry a cleaned transcript. They do not: all 128
published meetings in the archive were decoded to check, and every transcript
role present is raw-asr. The code was preserving a capability no file exercises.

So the role goes: the writer in portable_meeting_tags.go, the loader in
portable_meeting.go, the readable_transcript bundle file, Provenance.
ReadableCleanup, the CASSINI_READABLE_* tag mirrors, and the inspect output.

Three judgement calls, all stated in the PR body:

The display role STAYS. It looked like part of the same family, but it has an
independent producer: cassini-viewer's export builds a display transcript from
the word transcript alone (buildDisplayTranscriptFromArtifacts takes readable as
an optional second argument and works with null). Removing it would break the
static-site export.

The schema is DEPRECATED, not deleted. provenance uses additionalProperties:
false, so removing readableCleanup from a published, versioned, publicly served
v1 schema would make previously valid files fail validation. That is the same
failure this change exists to avoid, one layer up. Both the field and the enum
value stay, marked deprecated with a description saying why, and the format
documentation says the role is withdrawn and that readers skip it.

A stray entry is IGNORED, not rejected. The format contract says a reader fails
closed on a layout it cannot understand, but this is one it understands
perfectly: a body this build no longer writes. Rejecting would turn an older
meeting into an error and take its perfectly good raw transcript with it. Two
tests hold the line: one proves such a file still validates, the other proves
nothing can write a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

* chore(inspect): rename the derived-transcript label and a stale comment

Fallout from withdrawing the readable-cleanup role: the inspect output called
every derived entry readable_transcript, which now only ever holds display
transcripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

* fix(transcribe,portable): address the codex review of the hints work

Nine findings from an adversarial review, in rough order of how much they
mattered.

Hint provenance was written by the build and then silently dropped at pack time,
because portable.ProcessingStep had no field for it. A record that never reaches
the published file is worse than no record: the build log claims hints were
applied and the meeting cannot corroborate it. ProcessingStep now carries it,
the schema declares it, and two tests hold the round-trip.

Glossary text could steer sherpa's own grammar. sherpa reads a trailing
whitespace-separated ":n" token as a per-phrase boost, so a participant named
"Alice :100000" would have overridden the score this build recorded, and a bare
":2" would have contributed no phrase while still being counted as applied.
Terms ending in such a token are dropped.

The kill switch could not switch anything back. CASSINI_STT_HINTS_DISABLED
dropped the hotwords but left beam search on, giving an operator a third
behaviour and no route to the output they had before. It now restores greedy
search too, which is the one thing a kill switch has to be able to do.

The first build after a model download decoded unbiased. EnsureModel resolved
ModelPaths against an empty directory and returned that stale value, and
BpeVocabFile is only set when the file is on disk, so the bundle looked like it
had no BPE vocabulary until the second build. Re-resolve after extraction.

A legacy readable-cleanup body was reported as unreadable by inspect. The
validator skips such an entry, but the body decoder still validated it as a
words transcript, which it is not. Skip it there too, so the guarantee holds at
both layers.

The settings panel promised more than the code delivers. Biasing needs a bundle
that ships bpe.vocab, and the balanced CPU tier downloads an upstream archive
that does not, so naming quality tiers was misleading. It now names the real
condition and says the build records what happened.

CASSINI_STT_HINTS_DISABLED and CASSINI_STT_HINTS_SCORE were documented but
unreachable: neither was passed through the deployment compose file.

Troubleshooting had no entry for the most likely question this feature creates,
and still described cleanup as a thing that could be missing.

Not fixed here, filed instead: additional-model passes discard their hints
provenance; a custom recognizer backend may ignore DecoderConfig while
provenance claims success; a malformed vocabulary is dropped without a record;
100 configured terms evict the automatic participant names; hotwords.txt is
retained in promoted .meeting bundles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkaR6civoG8pTu9nsgZ3eP

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-729]

- Remove the top-level ops/ directory from gocassini:
  - Relocate AppAPI register logic and tests to scripts/lib-exapp-register.sh and scripts/test-exapp-register.sh
  - Relocate batch processing runner to scripts/process-recordings.sh
  - Remove deploy-exapp.sh and staging tests (moved to private codemyriad-systems repository)
  - Update stack.sh, deploy-preview.yml, and lint.yml workflow references
- Purge all stray Jellyfin scripts, systemd units, and documentation:
  - Remove bin/proxmox-sync-jellyfin-nvidia.sh, ops/proxmox-sync-jellyfin-nvidia.service, and docs/proxmox-jellyfin-nvidia.md
  - Remove references in docs/README.md, docs/architecture.md, and docs/exapp-install.md
…tory-and-remove-jellyfin-references

refactor(ops): remove ops/ directory and purge Jellyfin references [D-729]
docs(changelog): purge out-of-scope mention from d-729 fragment
…D-729]

- Require released SemVer tag format or opted-in sha-* pin
- Propagate unregister failures rather than ignoring them prior to re-registration
- Verify disable state before enabling during enable cycling
- Require jq and fail on parsing errors in exapp_assert_pullable
fix(exapp): harden AppAPI registration lifecycle and tag validation [D-729]
fix(exapp): propagate occ exit status in exapp_register_app
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
delasous merged commit 9c7cce5 into fix/d-621-keyless-llm-endpoint Sep 8, 2026
31 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.

2 participants