diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..668cce8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + push: + branches: [master] + +jobs: + test: + # mlx-whisper only installs on Apple Silicon + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check + + - name: Format check + run: uv run ruff format --check + + - name: Test + run: uv run pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a671ce --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + branches: [master] + paths: + - pyproject.toml + +concurrency: + group: release + cancel-in-progress: false + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check + + - name: Test + run: uv run pytest -q + + publish: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Detect version change + id: version + run: | + LOCAL=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + PUBLISHED=$(curl -fsSL https://pypi.org/pypi/vox-transcribe/json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null || echo "0.0.0") + echo "local=$LOCAL" >> "$GITHUB_OUTPUT" + if [ "$LOCAL" != "$PUBLISHED" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Version changed: $PUBLISHED -> $LOCAL" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Version unchanged ($LOCAL), skipping publish" + fi + + - name: Build distributions + if: steps.version.outputs.changed == 'true' + run: uv build + + - name: Publish to PyPI + if: steps.version.outputs.changed == 'true' + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Tag and GitHub Release + if: steps.version.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.local }} + run: | + git tag "v$VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" --title "v$VERSION" --generate-notes diff --git a/README.md b/README.md index 2b9dddb..a3d8307 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ vox doctor # Download a model ahead of time vox init -m small +# List available models per backend +vox models +vox models -b openai --format json + # Get API schema (for AI agents) vox schema transcribe ``` @@ -80,6 +84,7 @@ vox schema transcribe | `vox channel ` | Batch transcribe a YouTube channel | | `vox init [-m MODEL]` | Download Whisper model | | `vox doctor` | Check dependency health | +| `vox models [-b BACKEND]` | List transcription models per backend | | `vox schema [COMMAND]` | JSON schema for agent introspection | ## Models @@ -139,7 +144,7 @@ Input (URL or file) ```bash uv sync -uv run pytest # Run tests (107 tests) +uv run pytest # Run tests (159 tests) uv run ruff check --fix # Lint uv run ruff format # Format uv run ty check # Type check diff --git a/skills/vox/SKILL.md b/skills/vox/SKILL.md index 0091dbf..9e0a4b4 100644 --- a/skills/vox/SKILL.md +++ b/skills/vox/SKILL.md @@ -44,17 +44,18 @@ Apply in this order, highest volume first: isolated `Merci.`) emitted over silent stretches. Found in 73 of 227 files. 2. **Collapse decoder repetition loops** — detect by run length (≥5 identical consecutive tokens, pinning at 221–223), never by vocabulary. -3. **Fix domain vocabulary** — brand and instrument names are where Whisper fails, - not general anglicisms. +3. **Fix proper nouns** — brand, product and person names are where Whisper fails, + not general anglicisms. Ask the user for a glossary rather than guessing. 4. **Dedupe segment boundaries** — 1.29% of boundaries repeat a word, and this leaks into the concatenated `text` field. 5. **Re-punctuate and recapitalize only where measured as degraded** — most files are fine; a minority have zero periods and no uppercase at all. -6. **Normalize hyphenation and number formats** for consistency. +6. **Restore diacritics and normalize hyphenation and number formats**. -Do NOT strip filler words (Whisper already removes them: 296 `euh` per 1.8M words) -and do NOT apply broad homophone rules (>90% false positives). Insert -`[Speaker Name]:` markers only when speakers are clearly identifiable. +Fix form, never content. Do NOT strip filler words (Whisper already removes them: +296 `euh` per 1.8M words) and do NOT apply broad homophone rules (>90% false +positives). Mark unintelligible passages `[inaudible]` rather than inventing, and +insert `[Speaker Name]:` markers only when speakers are clearly identifiable. When editing `.srt`, preserve timestamps, sequence numbers and block count. @@ -76,6 +77,11 @@ vox channel "https://www.youtube.com/@ChannelName" --years 2025 --summarizer non ```bash vox schema transcribe vox schema init +vox schema models + +# Which models can I pass to --model? +vox models --format json +vox models -b openai --format json ``` ## Commands @@ -87,6 +93,7 @@ vox schema init | `vox channel ` | Batch transcribe a YouTube channel | | `vox init [-m MODEL] [-l LANG]` | Download Whisper model + check deps | | `vox doctor` | Check dependencies health | +| `vox models [-b BACKEND]` | List models available per backend | | `vox schema [COMMAND]` | JSON schema for agent introspection | | `vox --version` | Print the installed version | diff --git a/skills/vox/references/whisper-fixes.md b/skills/vox/references/whisper-fixes.md index 8e2fdbc..94d303b 100644 --- a/skills/vox/references/whisper-fixes.md +++ b/skills/vox/references/whisper-fixes.md @@ -1,13 +1,27 @@ # Whisper Post-Processing Reference Corrections ranked by measured frequency, derived from a 1.8M-word French corpus -(227 transcripts, `large-v3`, trading/finance domain). Counts are the evidence for -the ranking — apply the top sections first, they carry the volume. +(227 transcripts, `large-v3`). Counts are the evidence for the ranking — apply the +top sections first, they carry the volume. -Read [Section 9](#9-do-not-correct-these) before writing any rule. Several +The corpus is single-domain and French, so treat the absolute numbers as +indicative. The phenomena themselves — hallucinations, decoder loops, boundary +duplication, missing punctuation — are properties of the decoder, not of the +subject matter, and show up in any language. + +Read [Section 10](#10-do-not-correct-these) before writing any rule. Several categories that look obvious are **empty** in practice, and acting on them only introduces regressions. +## Ground rules + +- Fix **form**, never **content**. Do not reword, summarize, or repair the + speaker's grammar — a transcript is a record of what was said. +- Never invent. Mark an unintelligible passage `[inaudible]` rather than guessing. +- Ask the user for a glossary (names, acronyms, jargon) instead of inferring + domain spellings. Proper nouns are the one category the model reliably fails, + and the one you cannot derive from context. + ## 1. Silence hallucinations — highest priority On silent stretches Whisper emits subtitling boilerplate. Always delete outright; @@ -21,13 +35,17 @@ never treat as content. | `Abonnez-vous`, `Merci d'avoir regardé` | 15 | | A bare URL repeated across segments | 148 | +English audio produces the same family: `Thanks for watching!`, `Please +subscribe`, `Subtitles by ...`, `Amara.org`, `♪`, `[Music]`, `[Applause]`, and a +closing credit line duplicated at the very end of the file. + Present in **73 of 227 files**. Some files are ~100% artifact (e.g. 84 hits for 252 words) — if the hallucination-to-word ratio approaches 1.0, the recording has no usable content; report that instead of "cleaning" it. ## 2. Decoder repetition loops -Distinct from boundary duplication (§7). A single token repeats hundreds of times. +Distinct from boundary duplication (§3). A single token repeats hundreds of times. Measured: 300 runs of ≥5 identical consecutive tokens, 24,414 junk tokens, 106 files. **Detection signature: run lengths pin to 221–223** — that's the decoder's internal @@ -43,89 +61,26 @@ the same collapse. Beware the promoted-proper-noun trap: a looped token gets capitalized and reads as a name. One corpus token appeared 2462 times, of which only 10 were genuine. -## 3. Domain vocabulary — certain corrections - -### propfirm — the single highest-volume error - -**1042 wrong vs 12 correct.** The term is essentially never transcribed right. - -| Wrong | Occurrences | -|---|---| -| `propre firme` / `propres firmes` | 447 | -| `profs firme` / `profs firm` | 255 | -| `propes firme` / `propes firmes` | 209 | -| `profirme` / `profirm` | 59 | -| `profilme` / `profilmes` | 28 | -| `prop firm` / `prop firme` (spaced) | 22 | -| `profilère(s)` / `profilière(s)` | 9 | -| `multiprop firme`, `multipro firme` | 15 | -| Long tail: `profilien`, `profilaires`, `profilums`, `profilité`, `propure firme`, `croque firme`, `propre ferme` | ~30 | - -All → `propfirm` / `propfirms` / `multipropfirm`. Note `propes` standing alone also -means propfirms. - -### Instruments - -| Wrong | Correct | Occurrences | -|---|---|---| -| `Eurostox` | `Eurostoxx` | 221 | -| `Euro Stock` / `euro stocks` | `Eurostoxx` | 142 | -| `Eurostock` | `Eurostoxx` | 18 | -| `Bound` | `Bund` | 205 | -| `tiques` | `ticks` | 8 | -| `renge` | `range` | 2 | - -`Bound` → `Bund` only in an instrument context. Plain `bond` (62 occ.) is the -legitimate French noun for a bounce — leave it. - -### `carnet d'ordre` drift - -62 phonetic variants: `carnet d'or` (30), `carnet d'accord` (16), `carnet d'ordes` -(5), `carnet d'eau` (4), `carnet d'orne` (3), `carnet d'arbre(s)` (3), -`carnet d'orgue` (1). All → `carnet d'ordre(s)`. - -### Brands and platforms - -| Wrong | Correct | Occurrences | -|---|---|---| -| `Lucide` | `Lucid` | 167 | -| `Xellos` / `Zelos` / `Zellos` / `Xelo` | `Xelos` | 163 | -| `Bullnox` / `Boodlox` | `Bulenox` | 75 | -| `discorde` | `Discord` | 43 | -| `Tradify` / `Tradesy` | `Tradeify` | 25 | -| `click size` / `clic size` / `clipsize` | `clip size` | 23 | -| `rythmique` / `Rhythmic` | `Rithmic` | 23 | -| `Funden Next` / `Fundenext` | `FundedNext` | 6 | -| `edging` | `hedging` | 5 | -| `Tradervate` | `Tradovate` | 4 | -| `Cantover` / `Quantover` | `Quantower` | 3 | -| `Motivwave` | `MotiveWave` | 1 | -| `bidet ask` | `bid et ask` | 1 | -| `URSAF` | `URSSAF` | 7 | - -`rythmique` is only wrong when naming the data feed — the French adjective is -legitimate elsewhere. Same caution for `discorde`. - -**Already correct, never touch:** Nasdaq, Russell, DAX, Apex, TPT, Tradovate, -TradingView, MetaTrader, FTMO, MyFundedFuture, ATAS, CME, Eurex, Bollinger, -Fibonacci, VWAP, FOMC, NFP, CPI, PMI, AMF — zero faulty variants observed. - -## 4. Segment-boundary duplication +## 3. Segment-boundary duplication The last word of segment N repeats as the first word of segment N+1: **4267 occurrences over 330,352 boundaries (1.29%)**, plus 1402 three-word overlaps. -Top offenders: `merci` 391, `ça` 230, `ok` 229, `là` 165, `c'est` 130. +Top offenders are short, high-frequency words (`merci` 391, `ça` 230, `ok` 229, +`là` 165, `c'est` 130). ``` seg N : "...ça va être le premier cours sur la fiscalité" -seg N+1 : "fiscalité du trader propfirm" +seg N+1 : "fiscalité du contribuable" ``` **This leaks into the `text` field**, which is the concatenation. Any reconstruction from `segments` must dedupe boundaries. -## 5. Punctuation — bimodal, not uniform +The mirror case: a word split across two blocks appears truncated in one and +duplicated in the other. Merge it into the block where the word begins. + +## 4. Punctuation — bimodal, not uniform Corpus median is a healthy 22 words per period, but the distribution is bimodal: @@ -150,12 +105,28 @@ Question marks are structurally under-generated: 9313 interrogative markers Only 21.4% of segments end in `.`, `!` or `?`. Mean segment length is 5.5 words, so segment boundaries are **not** sentence boundaries — never punctuate by segment. -## 6. Capitalization +Languages with paired marks need the opening `¿` and `¡`, which Whisper almost +never emits. + +## 5. Capitalization 16 files are entirely lowercase (<0.1% uppercase, corpus median 1.55%); 43 sit under 0.5%. **12 of those 16 also have zero periods** — the two defects are correlated and signal the same degraded decode. Check both together, and restore both together. +## 6. Diacritics + +Restore from grammatical context, never by blind search-and-replace. In French the +measured gain is small (§10) — other languages lose accents far more often. + +**Spanish**: `como`/`cómo` (question), `esta`/`está` (verb), `mas`/`más` (quantity), +`si`/`sí` (yes), `el`/`él` (pronoun), `que`/`qué` (question), `tambien`/`también`, +`informacion`/`información`. + +**Portuguese**: `nao`/`não`, `voce`/`você`, `esta`/`está`, `sao`/`são`, `ja`/`já`. + +**German**: umlauts and `ß` may come back as `ae/oe/ue/ss`. + ## 7. Hyphenation and compounds Both spellings coexist at scale — this is normalization, not transcription error. @@ -169,56 +140,62 @@ Both spellings coexist at scale — this is normalization, not transcription err | `celle-là` | `celle là` | 204 / 169 | | `au-dessus` | `au dessus` | 172 / 193 | | `là-haut` | `là haut` | 230 / 80 | -| `Topstep` | `Top Step` | 29 / 363 | -| `TradingView` | `Trading View` | 53 / 40 | | `micro-entreprise` | `micro entreprise` | 27 / 10 | | `auto-entrepreneur` | `auto entrepreneur` | 4 / 2 | `peut-être` (1825) vs `peut être` (484): hyphenate only the adverb. `peut être` is correct when it's the verb (`ça peut être utile`). +Multi-word brand names follow the same pattern — the model splits or joins them +inconsistently. That is a glossary matter, not a rule. + ## 8. Number and time formatting — inconsistent, not wrong Pick one style per document; the corpus mixes all of them. - Thousands: spaced `15 000` (1926) vs glued `26400` (3339) - Shorthand: `25k`, `50k` (909) -- Decimals use the French comma: `80,25` (1684) +- Decimals use the French comma: `80,25` (1684) — English uses a point, and the + model follows the detected language, not the speaker - Times: `15h30` (934), `15h` (605), `15 heures` (370) — `15:30` never appears - Percent: `15%` (1564) vs `15 pour cent` (3) - Currency: `dollars` (814) vs `$` (346); `euros` (372) vs `€` (23) -- `balles` (846) is slang for euros — keep it, it's register, not error +- Locale spacing: French puts a space before `%`, `€`, `:`, `?`, `!` +- Slang currency terms (`balles`, `bucks`, `quid`) are register, not error — keep + +## 9. Language detection + +Whisper detects the language from the first ~30 seconds. Consequences: + +- Audio that opens with music, an intro jingle, or a foreign greeting can be + detected wrong, and the whole file is then transcribed — or translated — into + that language. Re-run with `-l `; no post-processing fixes it. +- Code-switching mid-file is transcribed in the detected language, sometimes + phonetically. -## 9. Do NOT correct these +## 10. Do NOT correct these Each was tested against the corpus and found empty or already correct. Writing rules for them costs tokens and causes regressions. **Phonetized anglicisms — essentially nonexistent.** `large-v3` spells recognized -English terms correctly: `scalping` 844, `drawdown` 291, `stop loss` 305, -`payout` 629, `challenge` 1550, `clip size` 560, `money management` 131. Tested and -absent: `skalping`, `nasdak`, `bolinger`, `drawdawn`, `poolback`, `brekout`, -`stoploss`, `taïm frame`. Only *brand names* (§3) fail. +English terms correctly, including technical vocabulary borrowed into French. +Tested and absent: phonetic manglings of common loanwords. Only *proper nouns* +fail — brands, products, people, platforms. **Disfluencies — already stripped.** `euh` appears 296 times in 1.8M words (1 per -6145). Nothing to remove. +6145). Nothing to remove. Strip fillers only if the user explicitly asks; they are +part of the record. **Spelled-out numbers — absent.** `mille` 142, `vingt` 7, `trente` 2; "quinze heures" and "dix pour cent" appear **zero** times. -**Terms with zero occurrences** — do not add rules: `backtest`, `breakout`, -`win rate`, `PFU`, `prélèvement forfaitaire`, `MT4`/`MT5`, `MACD`, `Jigsaw`. - -**ICT/SMC lexicon — absent.** `OTE` 0, `killzone` 0, `BOS` 0, `CHOCH` 0, -`smart money` 0, `London session` 0. This domain uses proprietary vocabulary -instead (§10). - **Given names — already stable.** No phonetic variants found across 40+ names. Only accent/spelling normalization applies: `Erwan`/`Erwann`, `Loïc`/`Loic`, `Etienne`/`Étienne`. -**Tax vocabulary — correct.** `BNC`, `micro-entreprise`, `flat tax`, `TVA`, `SASU` -all transcribe cleanly. +**Terms that never occur in your corpus** — verify before writing any rule. Half of +a plausible-looking rule list typically has zero matches. ### French homophones — deliberately minimal @@ -237,18 +214,16 @@ Context-dependent, verify each before changing: `ce`→`se` before a pronominal Tested with **zero** detectable errors — skip entirely: `sont`→`son`, `ces`→`ses`, `peut`→`peu`, `sur`→`sûr`. (`du`→`dû`: 2 occurrences, not worth a rule.) -## 10. Proprietary terms — protect from "correction" - -House vocabulary a naive corrector would rewrite. These are correct as-is: +English and Spanish were not measured here; the same discipline applies — verify +frequency on your own output before writing a rule. -`matelas` (4593), `amplitude` (3233), `dynamique` (3114), `carnet` (3441), -`retracement` (1168), `bougie` (1106), `vol` = volatilité (1097), `mèche` (511), -`liquidité` (304), `craquage`, `carnet lourd` / `carnet léger`, `zone de pierre`, -`branche de l'arbre`, `clip size`, `payout`, `PA` = compte financé, `TPT Pro`. +## 11. Speaker attribution -`Michigan` (58) is legitimate — the consumer confidence index, not a loop artifact. +Whisper does not do diarization: it produces one undifferentiated stream. Insert +`[Name]:` markers only when speakers are clearly identifiable from context, and +never guess who is talking. Real speaker separation requires a diarization tool. -## 11. SRT editing rules +## 12. SRT editing rules When editing `.srt` output: diff --git a/src/vox/adapters/cli/app.py b/src/vox/adapters/cli/app.py index aac9917..2cb2d57 100644 --- a/src/vox/adapters/cli/app.py +++ b/src/vox/adapters/cli/app.py @@ -5,6 +5,7 @@ from vox.adapters.cli.channel_cmd import channel from vox.adapters.cli.doctor_cmd import doctor from vox.adapters.cli.init_cmd import init +from vox.adapters.cli.models_cmd import models from vox.adapters.cli.schema_cmd import schema from vox.adapters.cli.transcribe_cmd import transcribe @@ -27,6 +28,7 @@ def main() -> None: main.add_command(init) main.add_command(doctor) main.add_command(schema) +main.add_command(models) def _is_agent_mode() -> bool: diff --git a/src/vox/adapters/cli/models_cmd.py b/src/vox/adapters/cli/models_cmd.py new file mode 100644 index 0000000..99804e7 --- /dev/null +++ b/src/vox/adapters/cli/models_cmd.py @@ -0,0 +1,36 @@ +import json +import sys + +import click + +from vox.adapters.cli.output_formatter import resolve_format +from vox.models.exceptions import VoxError +from vox.use_cases.list_models import ListModelsUseCase + + +@click.command() +@click.option("-b", "--backend", default=None, help="local | openai") +@click.option("--format", "fmt", default=None, help="json|table") +def models(backend, fmt): + try: + listed = ListModelsUseCase().execute(backend) + except VoxError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + click.echo(_render(listed, resolve_format(fmt))) + + +def _render(listed: dict[str, tuple[str, ...]], output_format: str) -> str: + if output_format == "json": + return json.dumps({k: list(v) for k, v in listed.items()}, indent=2) + return _format_table(listed) + + +def _format_table(listed: dict[str, tuple[str, ...]]) -> str: + max_backend = max(len(name) for name in listed) + rows = [ + f" {name.ljust(max_backend)} {model}" + for name, models_for_backend in listed.items() + for model in models_for_backend + ] + return "\n".join(rows) diff --git a/src/vox/adapters/cli/output_formatter.py b/src/vox/adapters/cli/output_formatter.py index f09d99f..7144784 100644 --- a/src/vox/adapters/cli/output_formatter.py +++ b/src/vox/adapters/cli/output_formatter.py @@ -5,7 +5,7 @@ def format_output(data: Any, fields: str | None, fmt: str | None) -> str: - output_format = _resolve_format(fmt) + output_format = resolve_format(fmt) as_dict = asdict(data) if hasattr(data, "__dataclass_fields__") else data filtered = _filter_fields(as_dict, fields) if output_format == "json": @@ -13,7 +13,7 @@ def format_output(data: Any, fields: str | None, fmt: str | None) -> str: return _format_table(filtered) -def _resolve_format(fmt: str | None) -> str: +def resolve_format(fmt: str | None) -> str: if fmt: return fmt return "json" if not sys.stdout.isatty() else "table" diff --git a/src/vox/models/whisper_model.py b/src/vox/models/whisper_model.py index 81f9958..4a98d75 100644 --- a/src/vox/models/whisper_model.py +++ b/src/vox/models/whisper_model.py @@ -20,6 +20,10 @@ class WhisperModel(Enum): def hf_repo(self) -> str: return self.value + @property + def cli_name(self) -> str: + return self.name.lower().replace("_", "-") + @classmethod def from_string(cls, name: str) -> "WhisperModel": normalized = _normalize(name) diff --git a/src/vox/schemas/models.json b/src/vox/schemas/models.json new file mode 100644 index 0000000..7716b8f --- /dev/null +++ b/src/vox/schemas/models.json @@ -0,0 +1,26 @@ +{ + "name": "models", + "description": "List the transcription models available for each backend", + "arguments": {}, + "options": { + "backend": { + "type": "string", + "enum": ["local", "openai"], + "description": "Restrict the listing to a single backend (default: all backends)" + }, + "format": { + "type": "string", + "enum": ["json", "table"], + "description": "Output format (auto-detected: json when piped, table when TTY)" + } + }, + "output": { + "local": "Model names accepted by --model on the local MLX backend", + "openai": "Model names accepted by --model on the OpenAI cloud backend" + }, + "examples": [ + "vox models", + "vox models -b openai --format json" + ], + "dependencies": [] +} diff --git a/src/vox/use_cases/list_models.py b/src/vox/use_cases/list_models.py new file mode 100644 index 0000000..82984a9 --- /dev/null +++ b/src/vox/use_cases/list_models.py @@ -0,0 +1,21 @@ +from vox.models.openai_model import OpenAIModel +from vox.models.transcription_backend import TranscriptionBackend +from vox.models.whisper_model import WhisperModel + + +class ListModelsUseCase: + def execute(self, backend: str | None) -> dict[str, tuple[str, ...]]: + if backend is None: + return {name: _models_for(name) for name in _backend_names()} + resolved = TranscriptionBackend.from_string(backend) + return {resolved.value: _models_for(resolved.value)} + + +def _backend_names() -> tuple[str, ...]: + return tuple(backend.value for backend in TranscriptionBackend) + + +def _models_for(backend_name: str) -> tuple[str, ...]: + if backend_name == TranscriptionBackend.OPENAI.value: + return tuple(model.api_name for model in OpenAIModel) + return tuple(model.cli_name for model in WhisperModel) diff --git a/tests/unit/adapters/test_models_cmd.py b/tests/unit/adapters/test_models_cmd.py new file mode 100644 index 0000000..fe355e9 --- /dev/null +++ b/tests/unit/adapters/test_models_cmd.py @@ -0,0 +1,33 @@ +import json + +from click.testing import CliRunner + +from vox.adapters.cli.models_cmd import models + + +class TestModelsCommand: + def test_models_when_json_format_then_lists_every_backend(self): + result = CliRunner().invoke(models, ["--format", "json"]) + + payload = json.loads(result.output) + assert set(payload) == {"local", "openai"} + assert "large-v3-turbo" in payload["local"] + assert "gpt-4o-transcribe" in payload["openai"] + + def test_models_when_backend_filter_then_lists_only_that_backend(self): + result = CliRunner().invoke(models, ["-b", "openai", "--format", "json"]) + + assert set(json.loads(result.output)) == {"openai"} + + def test_models_when_table_format_then_pairs_backend_and_model(self): + result = CliRunner().invoke(models, ["-b", "local", "--format", "table"]) + + assert "local" in result.output + assert "small" in result.output + assert result.output.count("local") == len(result.output.strip().splitlines()) + + def test_models_when_unknown_backend_then_exits_with_error(self): + result = CliRunner().invoke(models, ["-b", "vercel"]) + + assert result.exit_code == 1 + assert "Unknown backend" in result.output diff --git a/tests/unit/models/test_whisper_model.py b/tests/unit/models/test_whisper_model.py index d03b222..d3a4918 100644 --- a/tests/unit/models/test_whisper_model.py +++ b/tests/unit/models/test_whisper_model.py @@ -40,6 +40,21 @@ def test_from_string_when_large_v3_turbo_underscore_then_turbo_model(self): assert result == WhisperModel.LARGE_V3_TURBO +class TestWhisperModelCliName: + def test_cli_name_when_small_then_small(self): + assert WhisperModel.SMALL.cli_name == "small" + + def test_cli_name_when_large_v3_then_dashed(self): + assert WhisperModel.LARGE_V3.cli_name == "large-v3" + + def test_cli_name_when_large_v3_turbo_then_dashed(self): + assert WhisperModel.LARGE_V3_TURBO.cli_name == "large-v3-turbo" + + def test_cli_name_when_round_tripped_then_resolves_to_same_model(self): + for model in WhisperModel: + assert WhisperModel.from_string(model.cli_name) == model + + class TestWhisperModelHfRepo: def test_hf_repo_when_small_then_correct_path(self): assert WhisperModel.SMALL.hf_repo == "mlx-community/whisper-small-mlx" diff --git a/tests/unit/use_cases/test_list_models.py b/tests/unit/use_cases/test_list_models.py new file mode 100644 index 0000000..b1c8b94 --- /dev/null +++ b/tests/unit/use_cases/test_list_models.py @@ -0,0 +1,46 @@ +import pytest + +from vox.models.exceptions import ValidationError +from vox.use_cases.list_models import ListModelsUseCase + + +class TestListModelsUseCase: + def test_execute_when_no_backend_then_lists_every_backend(self): + result = ListModelsUseCase().execute(None) + + assert set(result) == {"local", "openai"} + + def test_execute_when_local_backend_then_lists_only_local(self): + result = ListModelsUseCase().execute("local") + + assert set(result) == {"local"} + + def test_execute_when_openai_backend_then_lists_only_openai(self): + result = ListModelsUseCase().execute("openai") + + assert set(result) == {"openai"} + + def test_execute_when_local_backend_then_returns_cli_model_names(self): + result = ListModelsUseCase().execute("local") + + assert result["local"] == ( + "tiny", + "base", + "small", + "medium", + "large-v3", + "large-v3-turbo", + ) + + def test_execute_when_openai_backend_then_returns_api_model_names(self): + result = ListModelsUseCase().execute("openai") + + assert result["openai"] == ( + "gpt-4o-transcribe", + "gpt-4o-mini-transcribe", + "whisper-1", + ) + + def test_execute_when_unknown_backend_then_raises(self): + with pytest.raises(ValidationError, match="Unknown backend"): + ListModelsUseCase().execute("vercel")