Skip to content

Commit 44689e5

Browse files
authored
feat(settings): ask before downloading a newly selected model (#152)
* feat(settings): ask before downloading a newly selected model Saving a transcription model change queued a download as a side effect. That download runs on the GPU lane, the same lane that serves live transcription, so on a single-card host it could sit in front of a meeting; nothing in the UI said it had started, and the setup wizard's promise to "check progress later in Settings" was never honoured. Preparation is now explicit. Selecting a model that is not on the server prompts for a choice: fetch it now so it is ready for the next recording, or leave it to the lazy fetch on first use, which delays live transcription and Meeting Edge until the download completes. Selecting a model already on disk saves silently, so the prompt only appears when there is something to download. A new admin-only POST /system/models/prepare carries the request, with targets for the active selection, the core batch, and each ONNX ASR model, so a single missing row in Model dependencies can be repaired on its own. It refuses a second request with 409 while one is in flight, and resolves the model from the admin's own settings rather than the install config, since the transcription keys are user-scoped and reading config alone would prepare the install default instead. Model dependencies gains a Download action on every missing row plus a live progress strip, so declining the prompt is recoverable and a running preparation is visible however it was started. Saving settings through the API no longer prepares models for any caller. Lazy fetch on first use still covers that path. Refs: docs/DEPLOYMENT.md, docs/ADMIN.md, docs/USAGE.md * fix(models): detect cached ONNX ASR models by their repo name Canary was reported as missing however many times it was downloaded. The status check matched the Nojoin model id against Hugging Face cache directory names, but onnx-asr caches nemo-canary-1b-v2 under the repo istupakov/canary-1b-v2-onnx, so the id never appeared in the directory name. Parakeet escaped the bug only because its id happens to be a substring of its repo name. Match a fragment of the repo name instead, held in one named map with the divergence written down so it is not "corrected" back to the model id later. Resolving the id through onnx-asr would be exact, but that would pull the ASR stack into the API process, which the torch boundary test exists to prevent. This also unblocks deletion and the admin health card, both of which read the same status: a model reported as missing has no resolvable path to delete, and the transcription component showed Canary as uncached. Refs: backend/preload_models.py * fix(models): delete cached models from a worker, not the API Deleting a model failed on every Docker install with EROFS. The API mounts the shared model volume read-only, by design, so the delete could never have worked from there: the failure was structural rather than a bad path. Dispatch the delete to the io lane, which mounts that volume read-write along with the other worker lanes. The task resolves the path itself rather than accepting one from the API, because the same volume is mounted at a different path in each container, and a delete sink that trusts a caller-supplied path is worth not building. It returns a status dict instead of raising, since the JSON serialiser would not carry the difference between "not found" and "refused" across the boundary. The request still waits for the result, so the UI can keep refreshing model status the moment it returns, with a 60 second ceiling that fails the request rather than holding a thread when no worker answers. Deleting a model now needs a running worker; that is documented. Refs: docs/DEPLOYMENT.md, docs/ADMIN.md
1 parent 5ca9b22 commit 44689e5

21 files changed

Lines changed: 1171 additions & 97 deletions

backend/api/v1/endpoints/settings.py

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from backend.models.notes_template import NotesTemplate, NotesTemplateScope
1212
from backend.models.recording import Recording, RecordingStatus
1313
from backend.models.user import User
14-
from backend.services.model_preparation import enqueue_model_preparation
1514
from backend.utils.config_manager import (
1615
APP_THEMES,
1716
INSTALL_WIDE_AI_SETTING_KEYS,
@@ -517,31 +516,10 @@ async def _save_user_settings(
517516
if meeting_edge_keys.intersection(update_data):
518517
await _dispatch_meeting_edge_refresh_for_active_recordings(db, current_user)
519518

520-
model_keys = {
521-
"whisper_model_size",
522-
"transcription_backend",
523-
"parakeet_model",
524-
"canary_model",
525-
}
526-
if is_admin and model_keys.intersection(update_data):
527-
prepared_settings = dict(current_settings)
528-
try:
529-
enqueue_model_preparation(
530-
whisper_model_size=prepared_settings.get("whisper_model_size"),
531-
transcription_backend=prepared_settings.get("transcription_backend"),
532-
parakeet_model=prepared_settings.get("parakeet_model"),
533-
canary_model=prepared_settings.get("canary_model"),
534-
include_core=(
535-
"whisper_model_size" in update_data
536-
or update_data.get("transcription_backend") == "whisper"
537-
),
538-
)
539-
except Exception as e: # noqa: BLE001
540-
logger.error(
541-
"Failed to queue model preparation after settings update: %s",
542-
e,
543-
exc_info=True,
544-
)
519+
# Changing the transcription model deliberately does not download anything.
520+
# Preparation runs on the GPU lane, so it is requested explicitly through
521+
# POST /system/models/prepare after the admin has been asked; a model that is
522+
# never prepared is still fetched lazily on first use.
545523

546524
return await _merge_settings(current_user.settings, db)
547525

backend/api/v1/endpoints/system.py

Lines changed: 120 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from typing import Any, Optional
44

5+
from celery.exceptions import TimeoutError as CeleryTimeoutError
56
from celery.result import AsyncResult
67
from docker.client import DockerClient
78
from docker.errors import DockerException, NotFound
@@ -35,6 +36,7 @@
3536
is_system_initialized,
3637
require_first_run_password,
3738
)
39+
from backend.celery_app import celery_app
3840
from backend.core.security import (
3941
MIN_PASSWORD_LENGTH,
4042
hash_user_password,
@@ -54,6 +56,11 @@
5456
router = APIRouter()
5557
logger = logging.getLogger(__name__)
5658

59+
MODEL_DELETION_TASK = "backend.worker.tasks.delete_model_task"
60+
# Deleting a cached model is an rmtree on a local volume, so seconds; the ceiling
61+
# is here so an absent worker fails the request rather than holding the thread.
62+
MODEL_DELETION_TIMEOUT_S = 60
63+
5764
RETIRED_COMPANION_RESPONSE = {
5865
"error": "companion_retired",
5966
"message": "The Nojoin Companion app has been retired. Please update your installation and use the web app for recording.",
@@ -581,6 +588,82 @@ async def get_models_status(
581588
return status
582589

583590

591+
class ModelPreparationRequest(BaseModel):
592+
"""Which assets to prepare.
593+
594+
``active`` covers whatever the saved transcription settings actually need,
595+
which is what the Settings prompt asks about. The remaining targets exist so
596+
a single missing row in Model dependencies can be repaired on its own.
597+
"""
598+
599+
target: str = Field(default="active")
600+
601+
@field_validator("target")
602+
@classmethod
603+
def validate_target(cls, value: str) -> str:
604+
allowed = {"active", "core", "parakeet", "canary"}
605+
if value not in allowed:
606+
raise ValueError(f"target must be one of {sorted(allowed)}")
607+
return value
608+
609+
610+
@router.post("/models/prepare")
611+
async def prepare_models_endpoint(
612+
payload: ModelPreparationRequest | None = None,
613+
current_user: User = Depends(get_current_admin_user),
614+
) -> Any:
615+
"""
616+
Queue preparation of local model assets.
617+
618+
Preparation is explicit: saving a transcription model change no longer
619+
downloads anything by itself, because the preparation task runs on the GPU
620+
lane and would otherwise queue in front of live work unannounced.
621+
"""
622+
target = (payload or ModelPreparationRequest()).target
623+
624+
if is_download_in_progress():
625+
raise HTTPException(
626+
status_code=409,
627+
detail="Model preparation is already running. Wait for it to finish.",
628+
)
629+
630+
# The transcription keys are user-scoped rather than install-wide, so the
631+
# admin's own row wins over config.json. Reading config alone would prepare
632+
# whatever the install default happens to be, not the model just chosen.
633+
user_settings = current_user.settings or {}
634+
635+
def effective(key: str, default: str) -> str:
636+
value = user_settings.get(key)
637+
return str(value) if value else str(config_manager.get(key, default))
638+
639+
if target == "active":
640+
transcription_backend = effective("transcription_backend", "whisper")
641+
include_core = transcription_backend == "whisper"
642+
elif target == "core":
643+
transcription_backend = "whisper"
644+
include_core = True
645+
else:
646+
transcription_backend = target
647+
include_core = False
648+
649+
try:
650+
task_id = enqueue_model_preparation(
651+
whisper_model_size=effective("whisper_model_size", "turbo"),
652+
transcription_backend=transcription_backend,
653+
parakeet_model=effective("parakeet_model", "parakeet-tdt-0.6b-v3"),
654+
canary_model=effective("canary_model", "nemo-canary-1b-v2"),
655+
include_core=include_core,
656+
)
657+
except Exception as e: # noqa: BLE001
658+
logger.error("Failed to queue model preparation: %s", e, exc_info=True)
659+
raise HTTPException(
660+
status_code=503,
661+
detail="Could not queue model preparation. Is the worker running?",
662+
)
663+
664+
return {"task_id": task_id, "target": target, "status": "queued"}
665+
666+
584667
@router.delete("/models/{model_name}")
585668
async def delete_model_endpoint(
586669
model_name: str,
@@ -589,25 +672,50 @@ async def delete_model_endpoint(
589672
) -> Any:
590673
"""
591674
Delete a specific model from the cache.
675+
676+
Dispatched to a worker: the API mounts the shared model volume read-only, so
677+
deleting from this process fails with EROFS. The call still waits for the
678+
result, because the UI refreshes model status as soon as it returns.
592679
"""
593-
from backend.preload_models import delete_model
680+
del current_user
594681

595682
if model_name not in ["whisper", "pyannote", "embedding", "parakeet", "canary"]:
596683
raise HTTPException(status_code=400, detail="Invalid model name")
597684

598685
try:
599-
success = delete_model(model_name, whisper_model_size=variant)
600-
if success:
601-
return {"message": f"Model {model_name} deleted successfully"}
602-
else:
603-
raise HTTPException(
604-
status_code=404,
605-
detail=f"Model {model_name} not found or could not be deleted",
606-
)
607-
except ValueError as e:
608-
raise HTTPException(status_code=400, detail=str(e))
686+
task = celery_app.send_task(
687+
MODEL_DELETION_TASK,
688+
kwargs={"model_name": model_name, "variant": variant},
689+
)
690+
result = await asyncio.to_thread(task.get, timeout=MODEL_DELETION_TIMEOUT_S)
691+
except CeleryTimeoutError:
692+
raise HTTPException(
693+
status_code=504,
694+
detail=(
695+
"Model deletion did not finish in time. It may still be running "
696+
"on the worker; refresh model status in a moment."
697+
),
698+
)
609699
except Exception as e: # noqa: BLE001
610-
raise HTTPException(status_code=500, detail=str(e))
700+
logger.error("Model deletion task failed for %s: %s", model_name, e)
701+
raise HTTPException(
702+
status_code=503,
703+
detail="Could not reach the worker to delete the model.",
704+
)
705+
706+
status = (result or {}).get("status")
707+
message = (result or {}).get("message") or f"Model {model_name} deleted."
708+
709+
if status == "deleted":
710+
return {"message": message}
711+
if status == "not_found":
712+
raise HTTPException(
713+
status_code=404,
714+
detail=f"Model {model_name} not found or could not be deleted",
715+
)
716+
if status == "forbidden":
717+
raise HTTPException(status_code=400, detail=message)
718+
raise HTTPException(status_code=500, detail=message)
611719

612720

613721
@router.post("/seed-demo")

backend/celery_app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ def release_model_caches_after_task(**kwargs):
200200
"backend.worker.tasks.ensure_calendar_push_channels_task": {"queue": IO_QUEUE},
201201
"backend.worker.tasks.renew_calendar_push_channels_task": {"queue": IO_QUEUE},
202202
"backend.worker.tasks.cleanup_temp_recordings": {"queue": IO_QUEUE},
203+
# Local disk work on the model volume. Belongs on a worker at all because
204+
# the API mounts that volume read-only.
205+
"backend.worker.tasks.delete_model_task": {"queue": IO_QUEUE},
203206
"backend.worker.tasks.send_telemetry_ping_task": {"queue": IO_QUEUE},
204207
}
205208

backend/preload_models.py

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@
4040
"turbo": "large-v3-turbo.pt",
4141
}
4242

43+
# Hugging Face cache directory fragments for the ONNX ASR models, used to detect
44+
# them without importing onnx-asr into the API process.
45+
#
46+
# These are fragments of the *repo* name, not of the Nojoin model id, because the
47+
# two diverge: `nemo-canary-1b-v2` is cached as `models--istupakov--canary-1b-v2-onnx`,
48+
# with no `nemo-` prefix. Matching the Nojoin id reported Canary as permanently
49+
# missing however many times it was downloaded, which also made it undeletable,
50+
# since deletion resolves its path through the same status check.
51+
ONNX_ASR_CACHE_FRAGMENTS = {
52+
"parakeet": "parakeet-tdt-0.6b-v3",
53+
"canary": "canary-1b-v2",
54+
}
55+
4356

4457
def _is_onnx_asr_model_cached(model_substring: str) -> bool:
4558
"""Check if an onnx-asr model is present in the Hugging Face hub cache."""
@@ -510,7 +523,7 @@ def check_model_status(whisper_model_size=None):
510523
status["whisper"]["downloaded"] = True
511524
status["whisper"]["path"] = default_filepath
512525

513-
# Check Parakeet
526+
# Check the ONNX ASR models.
514527
# Best-effort detection: onnx-asr caches the model under the Hugging Face hub
515528
# cache. Detection is a directory-name match; the exact repo dir name may vary
516529
# by onnx-asr version, so this is treated as a heuristic, not authoritative.
@@ -525,35 +538,20 @@ def check_model_status(whisper_model_size=None):
525538
if default_hf_cache not in parakeet_hf_caches:
526539
parakeet_hf_caches.append(default_hf_cache)
527540

528-
for cache_dir in parakeet_hf_caches:
529-
status["parakeet"]["checked_paths"].append(cache_dir)
530-
if os.path.isdir(cache_dir):
531-
try:
532-
for entry in os.listdir(cache_dir):
533-
if "parakeet-tdt-0.6b-v3" in entry:
534-
status["parakeet"]["downloaded"] = True
535-
status["parakeet"]["path"] = os.path.join(cache_dir, entry)
536-
break
537-
except OSError:
538-
pass
539-
if status["parakeet"]["downloaded"]:
540-
break
541-
542-
# Check Canary
543-
# Same best-effort HF-cache directory-name match as Parakeet above.
544-
for cache_dir in parakeet_hf_caches:
545-
status["canary"]["checked_paths"].append(cache_dir)
546-
if os.path.isdir(cache_dir):
547-
try:
548-
for entry in os.listdir(cache_dir):
549-
if "nemo-canary-1b-v2" in entry:
550-
status["canary"]["downloaded"] = True
551-
status["canary"]["path"] = os.path.join(cache_dir, entry)
552-
break
553-
except OSError:
554-
pass
555-
if status["canary"]["downloaded"]:
556-
break
541+
for status_key, fragment in ONNX_ASR_CACHE_FRAGMENTS.items():
542+
for cache_dir in parakeet_hf_caches:
543+
status[status_key]["checked_paths"].append(cache_dir)
544+
if os.path.isdir(cache_dir):
545+
try:
546+
for entry in os.listdir(cache_dir):
547+
if fragment in entry:
548+
status[status_key]["downloaded"] = True
549+
status[status_key]["path"] = os.path.join(cache_dir, entry)
550+
break
551+
except OSError:
552+
pass
553+
if status[status_key]["downloaded"]:
554+
break
557555

558556
for status_key, model_id in (
559557
("pyannote", "pyannote/speaker-diarization-community-1"),
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Detecting cached ONNX ASR models on disk.
2+
3+
The status heuristic matches Hugging Face cache directory names. It has to match
4+
the *repo* name rather than the Nojoin model id, because the two diverge:
5+
onnx-asr caches `nemo-canary-1b-v2` as `models--istupakov--canary-1b-v2-onnx`.
6+
Matching the Nojoin id reported Canary as missing however many times it was
7+
prepared, and made it undeletable with it, since deletion resolves its path
8+
through this same check.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
15+
from backend.preload_models import check_model_status
16+
17+
# The directory names a real install ends up with, taken from a live cache.
18+
CACHED_REPOS = (
19+
"models--istupakov--canary-1b-v2-onnx",
20+
"models--istupakov--parakeet-tdt-0.6b-v3-onnx",
21+
)
22+
23+
24+
@pytest.mark.parametrize("model", ["parakeet", "canary"])
25+
def test_a_downloaded_onnx_model_is_reported_as_present(model, monkeypatch, tmp_path):
26+
hub = tmp_path / "hub"
27+
hub.mkdir()
28+
for repo in CACHED_REPOS:
29+
(hub / repo).mkdir()
30+
monkeypatch.setenv("HF_HOME", str(tmp_path))
31+
32+
status = check_model_status(whisper_model_size="turbo")
33+
34+
assert status[model]["downloaded"] is True
35+
assert status[model]["path"].startswith(str(hub))
36+
37+
38+
@pytest.mark.parametrize("model", ["parakeet", "canary"])
39+
def test_an_empty_cache_reports_the_model_as_missing(model, monkeypatch, tmp_path):
40+
(tmp_path / "hub").mkdir()
41+
monkeypatch.setenv("HF_HOME", str(tmp_path))
42+
43+
status = check_model_status(whisper_model_size="turbo")
44+
45+
assert status[model]["downloaded"] is False
46+
assert status[model]["path"] is None

0 commit comments

Comments
 (0)