Skip to content

Commit 30789bd

Browse files
committed
feat: add optional local transcript cleanup after speech recognition
Adds an opt-in, self-hosted text model that corrects grammar, punctuation, capitalization, and paragraph breaks on a final transcript. It runs on the gateway machine after recognition, sees only recognised text (never audio), and needs no Voca account and no internet access once its model is installed. Off by default, and off until an operator installs a model and enables it. The governing rule is that cleanup can improve a transcript and never lose one. The deterministic writing-style result is computed first and independently; every way cleanup can fail — no model, unsupported language, input over the ceiling, runtime busy, timed out, malformed answer, or an edit the checks refuse — returns that exact string, with a bounded reason. A recognition that succeeded stays a success: cleanup failure is metadata on a 200, never an error a client has to handle. Cancellation propagates rather than being swallowed as a tidy fallback. app/cleanup/ holds the whole feature: - service.py is the single finalization every entry point shares, so sessions, one-shot requests, and streaming cannot drift into different decisions. - validation.py refuses a candidate that changes a protected span, a digit run, a negation, a weekday or month, the writing system, or more of the text than an edit budget allows, and rejects wrappers, refusals, leaked reasoning, and truncated generations. - prompts.py serialises the transcript as JSON data inside a user message, so a dictated "ignore the previous instructions" is content to preserve. - transport.py owns a raw loopback socket: no URL to redirect, no proxy inherited, a bounded body, and a close that actually stops the backend. - worker.py/host.py launch and own a llama-server via argv on an unpublished ephemeral port with a credential of its own; manager.py resolves environment overrides against saved UI choices, admits one inference at a time, and unloads an idle worker without ever touching the speech engine. - catalog.py is a separate namespace with its own pin file. A cleanup artifact with no pinned revision and SHA-256 cannot be installed at all, and never appears as a selectable speech engine. Wiring: - Sessions take cleanup: off|conservative|inherit, snapshotted at creation, and return original_transcript plus a redacted cleanup block. finish/retry answer from storage without a second model call. - /v1/audio/transcriptions takes a multipart cleanup field defaulting to off, keeps the {"text": ...} body unchanged, and reports status in optional X-Voca-Cleanup-* headers. - /v1/stream runs exactly one cleanup pass, after the streaming lock and the engine lease are released. Partials are never rewritten. - New GET /v1/capabilities reports supported modes and languages, keeping offered languages separate from evaluated ones. - Raw style is never corrected, whatever a request asks for. Also closes the read-then-update race around concurrent finish: the state test now lives inside the statement, so exactly one caller starts a transcription and a session deleted or re-uploaded mid-flight is neither resurrected nor overwritten by the stale job. Storage migrates additively and idempotently; existing rows and transcripts are untouched and read back as cleanup off with no manufactured original. The shipped candidates are the upstream-published Qwen3 0.6B and 1.7B Q8_0 GGUFs. Qwen publishes no smaller quantization in its own repositories, and a community Q4_K_M is a separate artifact whose bytes the upstream card does not vouch for, so first-party provenance won over file size. evaluated_languages is empty for both: no benchmark has been run, so nothing claims a tested language.
1 parent f34c887 commit 30789bd

51 files changed

Lines changed: 6545 additions & 98 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,47 @@ VOCAGATEWAY_PUBLISH_PORT=8765
198198
# local development.
199199
#VOCAGATEWAY_DEBUG=true
200200

201+
# ----------------------------------------------------------------------------
202+
# Transcript cleanup (optional, off by default)
203+
# ----------------------------------------------------------------------------
204+
# A small text model that fixes grammar, punctuation, and casing *after*
205+
# recognition, on the recognised text only. Audio never reaches it. Whenever it
206+
# cannot finish safely you get exactly the transcript you would have got with
207+
# the feature off — it can improve a result, never lose one.
208+
#
209+
# In Compose it needs the opt-in `cleanup` sidecar, because the gateway image
210+
# ships no text model and no llama.cpp runtime:
211+
#
212+
# VOCAGATEWAY_CLEANUP_MODEL_DIR=$HOME/.local/share/vocagateway/models/cleanup \
213+
# VOCAGATEWAY_CLEANUP_MODEL_FILE=/models/llama.cpp/Qwen3-0.6B-Q8_0.gguf \
214+
# VOCAGATEWAY_CLEANUP_ENDPOINT=cleanup:8080 \
215+
# VOCAGATEWAY_CLEANUP_API_KEY=$(openssl rand -hex 24) \
216+
# docker compose --profile cleanup up -d
217+
#
218+
# Leaving these unset is not the same as setting them to off: unset means "the
219+
# WebUI's saved choice decides", while a set value locks the toggle in the UI.
220+
#VOCAGATEWAY_CLEANUP_ENABLED=true
221+
#VOCAGATEWAY_CLEANUP_MODE=conservative
222+
#VOCAGATEWAY_CLEANUP_MODEL=cleanup:qwen3-0.6b
223+
#VOCAGATEWAY_CLEANUP_TIMEOUT_SECONDS=5
224+
225+
# Languages cleanup is allowed to run for. Defaults to the selected model's own
226+
# list. These are languages the feature is *offered* for, not ones an
227+
# evaluation has signed off — the WebUI reports the two separately.
228+
#VOCAGATEWAY_CLEANUP_LANGUAGES=en,hi,hinglish_roman
229+
230+
# Address of the sidecar, and the credential the gateway presents to it. Only
231+
# loopback, private ranges, and bare Compose service names are accepted; a
232+
# routable address is refused at startup, because "runs on your gateway" has to
233+
# stay true. A client's bearer token is never forwarded here.
234+
#VOCAGATEWAY_CLEANUP_ENDPOINT=cleanup:8080
235+
#VOCAGATEWAY_CLEANUP_API_KEY=
236+
237+
# Compose-only inputs for the sidecar itself.
238+
#VOCAGATEWAY_CLEANUP_IMAGE=ghcr.io/ggml-org/llama.cpp:server
239+
#VOCAGATEWAY_CLEANUP_MODEL_DIR=./models
240+
#VOCAGATEWAY_CLEANUP_MODEL_FILE=/models/model.gguf
241+
201242

202243
# ============================================================================
203244
# 6. Listener inside the container

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ contract is in [configuration.md](docs/configuration.md).
7676
- [WebUI](#webui)
7777
- [Fast model guide](#fast-model-guide)
7878
- [Model download integrity](#model-download-integrity)
79+
- [Transcript cleanup](#transcript-cleanup) — optional local grammar and punctuation repair
7980
- [Engine selection](#engine-selection)
8081
- [Configuration](#configuration) — every `VOCAGATEWAY_*` variable and its default
8182
- [Listener and network access](#listener-and-network-access)
@@ -490,14 +491,71 @@ change; `--only` explicitly refreshes the matching model or family.
490491
uv run scripts/harvest-model-pins.py # newly added models
491492
uv run scripts/harvest-model-pins.py --only whisperkit: # refresh one family
492493
uv run scripts/harvest-model-pins.py --refresh # refresh everything
494+
uv run scripts/harvest-model-pins.py --cleanup # transcript-cleanup models
493495
```
494496

497+
Transcript-cleanup artifacts have their own catalog and their own pin file,
498+
[`app/cleanup_model_pins.json`](app/cleanup_model_pins.json). They are held to a
499+
stricter rule than speech models: an entry with no pinned revision and digest
500+
cannot be installed **at all**, because unverifiable weights must never reach a
501+
runtime launch.
502+
495503
Each revision and its digests are written as one snapshot. If the complete
496504
snapshot cannot be collected, the command fails and preserves the previous
497505
record rather than combining a new revision with stale hashes. Review the
498506
resulting diff as carefully as code. A changed digest means the upstream bytes
499507
changed, and the commit message should say why.
500508

509+
## Transcript cleanup
510+
511+
Optional. Off by default, and off until you install a model and turn it on.
512+
513+
A small text model runs **after** speech recognition and fixes grammar,
514+
punctuation, capitalization, and paragraph breaks while keeping what you said.
515+
It runs on your gateway, needs no Voca account, and needs no internet access
516+
once the model is installed. Audio never reaches it — only the recognised text
517+
does.
518+
519+
The design rule is that it can improve a transcript and never lose one. Every
520+
way it can fail — no model, wrong language, text too long, busy, timed out, or
521+
an edit the safety checks refuse — returns exactly the transcript you would have
522+
got with the feature switched off, plus a bounded reason saying why.
523+
524+
**Setup.** Settings → Transcript cleanup → install a model → **Load model now**
525+
→ tick *Correct transcripts by default*. Natively the gateway launches and owns
526+
a `llama-server` on loopback (install llama.cpp, or set
527+
`VOCAGATEWAY_CLEANUP_BINARY`). Under Compose it is an opt-in sidecar that
528+
publishes no port:
529+
530+
```sh
531+
docker compose --profile cleanup up -d
532+
```
533+
534+
**What it will not do.** It does not translate, summarise, answer questions,
535+
add content, or invent facts. **Raw is never corrected**, whatever a request
536+
asks for. And it cannot fix a word the speech model misheard: it only sees
537+
text, so a wrong word that reads as a sentence stays. Filler and stutter removal
538+
are deliberately out — repetition often carries meaning.
539+
540+
**Languages.** A transcript left on `auto` is only corrected when its writing
541+
system names one supported language on its own. Latin script does not, so ask
542+
for `en` explicitly rather than relying on detection. The WebUI reports *offered*
543+
and *tested* languages separately: a language is offered because a model claims
544+
it, and tested only once an evaluation has signed it off.
545+
546+
**Clients.** Sessions and `/v1/stream` take
547+
`cleanup: "off" | "conservative" | "inherit"`;
548+
`POST /v1/audio/transcriptions` takes a multipart `cleanup=off|conservative`
549+
that defaults to `off` and answers in the unchanged `{"text": ...}` shape, with
550+
status in optional `X-Voca-Cleanup-*` headers. `GET /v1/capabilities` says what
551+
this gateway supports; a gateway that predates the feature answers `404`, and a
552+
client must then omit the new fields. Session responses carry
553+
`original_transcript` beside `transcript` whenever cleanup was asked for, under
554+
the same retention rules, so the recognised text is always recoverable.
555+
556+
See [configuration.md](docs/configuration.md#transcript-cleanup) for the
557+
`VOCAGATEWAY_CLEANUP_*` variables and both deployment shapes.
558+
501559
## Engine selection
502560

503561
The `auto` engine preference uses the first runnable option in this order:

app/admin_queries.py

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
from importlib import util as importlib_util
44
from types import MappingProxyType
5-
from typing import Any
5+
from typing import Any, cast
66

77
from app import schemas
88
from app.build_info import current_commit
99
from app.catalog import catalog_source_url, language_names, recommended_ids
10+
from app.cleanup import catalog as cleanup_catalog
1011
from app.config import Settings
1112
from app.context import BOOTSTRAP_TOKEN_ID, TOKEN_FILE_HINT, VERSION, GatewayContext
1213
from app.engine_state import active_model_path, available_engines, engine_id
@@ -15,6 +16,9 @@
1516
from app.system import SystemInfo, detect_system
1617

1718
PYTHON_PACKAGE_PATH = "Python package"
19+
INSTALLED_STATE = "installed"
20+
DOWNLOADING_STATE = "downloading"
21+
NOT_INSTALLED_STATE = "not_installed"
1822
PYTHON_ENGINE_INSTALL_HINT = "Install vocagateway[engines] or use the Docker image"
1923
# One engine paired with the single runtime it needs.
2024
_EngineRuntime = tuple[str, schemas.DependencyStatus]
@@ -318,7 +322,7 @@ def build_custom_entry(self, custom: Any) -> schemas.AdminModelEntry:
318322
family="Custom Whisper",
319323
description="User-provided local model.",
320324
source="Local file",
321-
state="installed",
325+
state=INSTALLED_STATE,
322326
active=custom.path == self.active_path,
323327
offloaded=bool(
324328
custom.path == self.active_path
@@ -355,16 +359,16 @@ def filter_by_criteria(
355359
return matching
356360

357361
def _resolve_state(self, download: Any, inst: Any) -> _ModelState:
358-
if download and download.status == "downloading":
362+
if download and download.status == DOWNLOADING_STATE:
359363
progress = None
360364
if download.total_bytes:
361365
progress = round(download.downloaded_bytes / download.total_bytes, 4)
362-
return "downloading", progress, None
366+
return DOWNLOADING_STATE, progress, None
363367
if inst:
364-
return "installed", None, None
368+
return INSTALLED_STATE, None, None
365369
if download and download.status == "failed":
366-
return "not_installed", None, download.error
367-
return "not_installed", None, None
370+
return NOT_INSTALLED_STATE, None, download.error
371+
return NOT_INSTALLED_STATE, None, None
368372

369373

370374
async def status_payload(ctx: GatewayContext) -> schemas.AdminStatusResponse:
@@ -422,7 +426,7 @@ def filtered_model_entries(
422426
helper = _ModelEntryHelper(ctx)
423427
entries = model_entries(ctx)
424428
if installed_only:
425-
entries = [entry for entry in entries if entry.state == "installed"]
429+
entries = [entry for entry in entries if entry.state == INSTALLED_STATE]
426430
entries = helper.filter_by_criteria(
427431
entries,
428432
language=language,
@@ -453,6 +457,96 @@ def token_entries(ctx: GatewayContext) -> list[schemas.DeviceTokenEntry]:
453457
return entries
454458

455459

460+
def cleanup_config(ctx: GatewayContext) -> schemas.CleanupConfigResponse:
461+
"""The cleanup block, as the settings page and a diagnostics bundle see it.
462+
463+
Carries no runtime address and no executable path: those are operator-only
464+
settings, and a redacted bundle attached to a bug report has no reason to
465+
describe the deployment's internal topology.
466+
"""
467+
manager = ctx.cleanup
468+
if manager is None:
469+
return schemas.CleanupConfigResponse()
470+
report = manager.status()
471+
return schemas.CleanupConfigResponse(
472+
enabled=report.enabled,
473+
mode=cast(Any, report.mode),
474+
model_id=report.model_id,
475+
model_label=report.model_label,
476+
model_installed=report.model_installed,
477+
runtime_available=report.runtime_available,
478+
managed=report.managed,
479+
state=cast(Any, report.state),
480+
timeout_seconds=report.timeout_seconds,
481+
languages=list(report.languages),
482+
evaluated_languages=list(report.evaluated_languages),
483+
idle_unload_enabled=report.idle_unload_enabled,
484+
idle_unload_minutes=report.idle_unload_minutes,
485+
locked_settings=list(report.locked_settings),
486+
detail=report.detail,
487+
)
488+
489+
490+
def cleanup_model_entries(ctx: GatewayContext) -> list[schemas.CleanupModelEntry]:
491+
"""The cleanup catalog with each artifact's install state and provenance.
492+
493+
Deliberately its own list. A cleanup model must never appear among the
494+
speech models: it cannot transcribe anything, and offering it as an engine
495+
would be an invitation to select it as one.
496+
"""
497+
manager = ctx.cleanup
498+
active = manager.model_id if manager else None
499+
return [_cleanup_entry(ctx, model, active) for model in cleanup_catalog.CLEANUP_CATALOG]
500+
501+
502+
def _cleanup_entry(
503+
ctx: GatewayContext, model: cleanup_catalog.CleanupModel, active: str | None
504+
) -> schemas.CleanupModelEntry:
505+
manager = ctx.cleanup
506+
download = manager.models.download_state(model.id) if manager else None
507+
installed = manager is not None and manager.models.installed_path(model.id) is not None
508+
state, progress, error = _cleanup_state(download, installed=installed)
509+
return schemas.CleanupModelEntry(
510+
id=model.id,
511+
label=model.label,
512+
description=model.description,
513+
runtime=model.runtime,
514+
size_bytes=model.size_bytes,
515+
minimum_ram_gb=model.minimum_ram_gb,
516+
upstream_model=model.upstream_model,
517+
quantization=model.quantization,
518+
conversion_source=model.conversion_source,
519+
chat_template_source=model.chat_template_source,
520+
license_name=model.license_name,
521+
license_notice=model.license_notice,
522+
source_url=model.source_url,
523+
revision=model.revision,
524+
sha256=model.sha256,
525+
installable=model.installable,
526+
languages=list(model.candidate_languages),
527+
evaluated_languages=list(model.evaluated_languages),
528+
state=state,
529+
active=model.id == active,
530+
progress=progress,
531+
downloaded_bytes=download.downloaded_bytes if download else None,
532+
total_bytes=download.total_bytes if download else None,
533+
error=error,
534+
)
535+
536+
537+
def _cleanup_state(download: Any, *, installed: bool) -> _ModelState:
538+
if download and download.status == DOWNLOADING_STATE:
539+
progress = None
540+
if download.total_bytes:
541+
progress = round(download.downloaded_bytes / download.total_bytes, 4)
542+
return DOWNLOADING_STATE, progress, None
543+
if installed:
544+
return INSTALLED_STATE, None, None
545+
if download and download.status == "failed":
546+
return NOT_INSTALLED_STATE, None, download.error
547+
return NOT_INSTALLED_STATE, None, None
548+
549+
456550
def config_response(ctx: GatewayContext) -> schemas.ConfigResponse:
457551
rc = ctx.engine_manager.runtime_config if ctx.engine_manager else None
458552
if rc:
@@ -471,6 +565,7 @@ def config_response(ctx: GatewayContext) -> schemas.ConfigResponse:
471565
cpu_threads=rc.cpu_threads,
472566
idle_offload_enabled=rc.idle_offload_enabled,
473567
idle_offload_minutes=rc.idle_offload_minutes,
568+
cleanup=cleanup_config(ctx),
474569
)
475570
return schemas.ConfigResponse(
476571
engine="custom",
@@ -487,4 +582,5 @@ def config_response(ctx: GatewayContext) -> schemas.ConfigResponse:
487582
cpu_threads=0,
488583
idle_offload_enabled=False,
489584
idle_offload_minutes=DEFAULT_IDLE_OFFLOAD_MINUTES,
585+
cleanup=cleanup_config(ctx),
490586
)

app/cleanup/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Optional, local, conservative transcript cleanup.
2+
3+
Off by default, and off unless an operator installs a model and turns it on.
4+
Audio never reaches this package — only recognised text does — and nothing here
5+
can turn a successful transcription into a failed one.
6+
7+
Import from the modules directly rather than from this package: `base` for the
8+
vocabulary, `service` for the single decision every entry point shares,
9+
`manager` for the runtime that owns the process, `validation` for the checks
10+
that decide whether a candidate is safe to insert.
11+
"""

0 commit comments

Comments
 (0)