* fix: speaker detection — gated pyannote license surfaces a docs deeplink (closes #78)
Issue #78 ("Speaker detection fails — speakers blend together or aren't
detected correctly") was the user-visible symptom of the dub pipeline
silently falling back to the silence-gap heuristic in
`backend/api/routers/dub_core.py::_diarize`. The heuristic alternates
Speaker 1 ↔ Speaker 2 on >1.2s gaps only, so two real speakers with
similar pacing get merged or swapped — and once the auto-clone step
extracts a reference voice for the wrong label, downstream dubs make
"person A speak like person B" (the reporter's exact phrasing).
The structural cause is that pyannote-3.1 is gated on HuggingFace: a
valid HF_TOKEN by itself isn't enough — the user must also click
"Agree and access repository" on both pyannote/speaker-diarization-3.1
AND pyannote/segmentation-3.0. We can't fix that for the user, but we
CAN make the failure actionable instead of silent.
Changes:
- `backend/services/model_manager.py`: `get_diarization_pipeline()`
gains an opt-in `return_error=True` shape that returns
`(pipeline | None, error_sentinel)`. Sentinels distinguish
NO_TOKEN / PYANNOTE_LICENSE_REQUIRED / LOAD_FAILED. A new
`_classify_diarization_error()` sniffs the exception's class name +
message for 401/403/gated/"accept license" signals — kept as a
string heuristic so it survives huggingface_hub major-version
churn. Bare-`None` default return preserved for the legacy
`_transcribe` call site at dub_core.py:781.
- `backend/api/routers/dub_core.py::_diarize`: now emits a structured
SSE warning `{detail, source, error_class, docs_url}` instead of
plain `{detail, source}`. The new fields let the front-end render a
"See docs" button that deeplinks directly to the
`License acceptance flow` section of `docs/features/diarization.md`
(landed in PR #94) — the page with the click-by-click instructions
for fixing this exact failure mode.
- `backend/core/error_docs_map.py` + `frontend/src/utils/errorDocsMap.ts`:
add a 5th taxonomy class `PYANNOTE_LICENSE_REQUIRED` pointing at the
diarization docs section. Distinct from `HF_AUTH_FAILED` (which is
the more general "token missing or invalid" case). The TS
`classifyError` heuristic also picks up pyannote / gated /
"speaker diarization" keywords so a thrown error in the boundary
routes to the right deeplink too.
- `tests/backend/core/test_error_docs_map.py`: bump locked-keys set to
5 classes; add an explicit assertion that the new class points at
the `license-acceptance-flow` anchor.
- `frontend/src/utils/errorDocsMap.test.ts`: bump locked-keys set to
5 classes; add classifier tests for pyannote / gated / accept-license
keyword routing.
- `tests/test_diarization_error_class.py`: regression test (20 cases)
covering `_classify_diarization_error`, the new
`get_diarization_pipeline(return_error=True)` shape, backward-
compatible bare-`None` return for the legacy call site, and the
error_docs_map deeplink target. Uses sys.modules patching so
pyannote / torch are never actually imported.
HF token plumbing: unchanged. The new code continues to route through
`token_resolver.resolve()` per the AUTH-01 contract — no new bare
`os.environ.get("HF_TOKEN")` reads.
Cross-platform: identical behaviour on macOS / Windows / Linux —
the only platform-touching change is a docs URL string, which is
opened via the existing `openExternal()` helper that already abstracts
Tauri's `shell.open` on all three platforms.
Verification:
.venv/bin/python -m pytest tests/test_diarization_error_class.py \
tests/backend/core/test_error_docs_map.py -v
# 20 passed in 0.03s
bun run test src/utils/errorDocsMap.test.ts
# 13 passed (1 test file)
.venv/bin/python -m pytest tests/test_segmentation.py \
tests/test_dub_transcribe.py \
tests/backend/services/test_token_resolver.py \
tests/test_model_manager_preload.py
# 40 passed, 10 xfailed (pre-existing), 1 xpassed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add regression test for diarization error classification (issue #78)
Companion to the fix in d6e6586. 20 test cases covering:
- `_classify_diarization_error` — the string heuristic that buckets
pyannote/HF exceptions into NO_TOKEN / LICENSE / LOAD sentinels.
Pinned for 401/403/gated/accept-license/accept-user-conditions
signals so it survives huggingface_hub major-version churn.
- `get_diarization_pipeline(return_error=True)` — the new 2-tuple
return shape that lets the dub pipeline's SSE warning carry an
error_class.
- Backward compatibility — the bare-`None` return on the default
signature is preserved so dub_core.py:781's legacy `_transcribe`
call site doesn't break.
- The error_docs_map deeplink — the new PYANNOTE_LICENSE_REQUIRED
class points at `docs/features/diarization.md#license-acceptance-flow`.
Uses sys.modules patching for pyannote.audio.Pipeline + token_resolver
so the real torch + pyannote + HF API are never imported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(diarization): dotted-path monkeypatch to survive Wave 1 sys.modules purge
The new `test_diarization_error_class.py` tests pass in isolation but fail
in the full suite — Wave 1's `fresh_resolver` fixture aggressively purges
all `services.*` and `core.*` modules from `sys.modules` mid-suite. When
this file's tests later did `from services import token_resolver` then
`monkeypatch.setattr(token_resolver, "resolve", ...)`, the local
`token_resolver` reference bound to a stale module identity. The function
under test does `from services import token_resolver` at call time, which
re-resolves through the (post-purge) `sys.modules['services.token_resolver']`
— a different object — so the monkeypatch was applied to one ID and the
function read from another.
Two fixes in this commit:
1. Don't pop `services.token_resolver` in this file's `model_manager`
fixture — the test body's import and the function's import must agree
on identity. Popping forces re-import that can create two distinct
modules.
2. Use the dotted-path form `monkeypatch.setattr("services.token_resolver.resolve", ...)`
instead of the object-attribute form. Pytest's dotted form re-resolves
the path through `sys.modules` at setattr time, so the binding is
always on the live module object regardless of which identity the test
imported earlier.
Verified: `pytest tests/ -q` → 442 passed, 0 failures (was 1 failed
before this commit on PR #110).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
44 lines
2.1 KiB
Python
44 lines
2.1 KiB
Python
"""Error class → docs URL mapping for the in-app deeplink button.
|
|
|
|
Used by the React ErrorBoundary's "Open docs for this error" button (via the
|
|
TypeScript mirror at `frontend/src/utils/errorDocsMap.ts`) and by the Phase 5
|
|
bug-reporter for "this error has a docs page" links.
|
|
|
|
The 5-class taxonomy below is the contract — Phase 5 reporter consumes it,
|
|
the TS map mirrors it, and `test_error_docs_map.test_keys_match_taxonomy`
|
|
locks the key set. To add a new class:
|
|
|
|
1. Pick a stable ALL_CAPS key (the API will live forever).
|
|
2. Add the entry here.
|
|
3. Mirror it in `frontend/src/utils/errorDocsMap.ts`.
|
|
4. Update the `test_keys_match_taxonomy` set and the TS-side keys-sync test.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from core import links
|
|
|
|
_BASE = links.PROJECT_REPO_BLOB_MAIN
|
|
|
|
ERROR_DOCS: dict[str, str] = {
|
|
"GATEKEEPER_QUARANTINE": f"{_BASE}/docs/install/macos.md#gatekeeper-quarantine",
|
|
"APPIMAGE_WEBKIT_WHITESCREEN": f"{_BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404",
|
|
"PKG_RESOURCES_MISSING": f"{_BASE}/docs/install/troubleshooting.md#pkg_resources-missing",
|
|
"HF_AUTH_FAILED": f"{_BASE}/docs/setup/huggingface-token.md",
|
|
# Issue #78 — pyannote/speaker-diarization-3.1 + pyannote/segmentation-3.0
|
|
# are both gated on HuggingFace. A valid HF_TOKEN by itself isn't enough:
|
|
# the user must also click "Agree and access repository" on both model
|
|
# pages. `docs/features/diarization.md` walks through that flow in its
|
|
# "License acceptance flow" section, so the deeplink targets that anchor
|
|
# directly. Distinct from HF_AUTH_FAILED (which is the more general
|
|
# token-missing-or-invalid case pointing at the token-setup doc).
|
|
"PYANNOTE_LICENSE_REQUIRED": f"{_BASE}/docs/features/diarization.md#license-acceptance-flow",
|
|
}
|
|
|
|
DEFAULT_DOCS: str = f"{_BASE}/docs/install/troubleshooting.md"
|
|
|
|
|
|
def lookup(error_class: str | None) -> str:
|
|
"""Return the docs URL for `error_class`, or DEFAULT_DOCS when the
|
|
class is None / unknown."""
|
|
return ERROR_DOCS.get(error_class or "", DEFAULT_DOCS)
|