Spaces:
Sleeping
Sleeping
Commit Β·
0978d28
0
Parent(s):
feat(m1): deployable FastAPI skeleton for Uzbek STT
Browse files- create_app() factory + lifespan; GET /api/health; root -> /docs
- RFC 9457 problem+json error model (413/422/400/429/503 inventory)
- single-source Settings (caps, rate, model ids)
- Dockerfile for HF Spaces (non-root UID 1000, port 7860, minimal M1 deps)
- spec features/uzbek-stt.md (team-lead SHAPE+JUDGE approved)
- tests green: ruff, mypy --strict, pytest (3 passed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- .dockerignore +13 -0
- .gitignore +11 -0
- Dockerfile +22 -0
- README.md +109 -0
- app/__init__.py +1 -0
- app/core/__init__.py +0 -0
- app/core/errors.py +168 -0
- app/core/logging.py +12 -0
- app/core/settings.py +48 -0
- app/main.py +55 -0
- app/routers/__init__.py +0 -0
- app/routers/health.py +18 -0
- app/schemas/__init__.py +0 -0
- app/schemas/health.py +11 -0
- features/uzbek-stt.md +340 -0
- pyproject.toml +42 -0
- tests/__init__.py +0 -0
- tests/test_health.py +30 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
features/
|
| 11 |
+
tests/
|
| 12 |
+
benchmark/
|
| 13 |
+
.env
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
*.egg-info/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
.env
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# M1 image β intentionally minimal (no torch / ffmpeg yet) so the first HF Spaces
|
| 2 |
+
# build is fast and reliable. M2 adds the ML stack + ffmpeg + baked fast weights.
|
| 3 |
+
FROM python:3.12-slim
|
| 4 |
+
|
| 5 |
+
# HF Spaces runs containers as a non-root user with UID 1000.
|
| 6 |
+
# Create /app owned by `user` BEFORE dropping privilege β otherwise Docker creates
|
| 7 |
+
# the WORKDIR root-owned and later writes (M2 weight baking) fail.
|
| 8 |
+
RUN useradd -m -u 1000 user && mkdir -p /app && chown user:user /app
|
| 9 |
+
USER user
|
| 10 |
+
ENV HOME=/home/user \
|
| 11 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 12 |
+
PYTHONUNBUFFERED=1
|
| 13 |
+
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
COPY --chown=user pyproject.toml README.md ./
|
| 17 |
+
COPY --chown=user app ./app
|
| 18 |
+
RUN pip install --no-cache-dir --user .
|
| 19 |
+
|
| 20 |
+
# Port 7860 must stay in lockstep with README `app_port` and Settings.port.
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Uzbek STT
|
| 3 |
+
emoji: ποΈ
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Uzbek Speech-to-Text β a deployable service
|
| 13 |
+
|
| 14 |
+
An end-to-end Uzbek speech-to-text **service**: a documented REST API, a demo UI, and an
|
| 15 |
+
honest multi-engine benchmark β deployed to a live public URL on a **$0 budget** (no paid
|
| 16 |
+
server, no paid API, no GPU). Built to demonstrate shipping and operating software, not just
|
| 17 |
+
writing it.
|
| 18 |
+
|
| 19 |
+
> **Live demo:** _coming with the M1 deploy_ β `https://huggingface.co/spaces/bintuulugbek/Uzbek-STT`
|
| 20 |
+
> **API docs:** `/docs` on the same URL.
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Why this project
|
| 25 |
+
|
| 26 |
+
Uzbek is an underserved language for speech tech. This project takes verified open Uzbek ASR
|
| 27 |
+
models and wraps them in production-grade engineering: a clean FastAPI service, a swappable
|
| 28 |
+
engine seam, problem+json errors, and a reproducible benchmark with real WER/CER numbers
|
| 29 |
+
measured by us (not quoted from model cards).
|
| 30 |
+
|
| 31 |
+
## Architecture
|
| 32 |
+
|
| 33 |
+
```mermaid
|
| 34 |
+
flowchart TD
|
| 35 |
+
U[User: mic / upload / API call] -->|audio| FE[Gradio UI '/']
|
| 36 |
+
U -->|POST audio| API[FastAPI '/api/*']
|
| 37 |
+
FE --> SVC[TranscriptionService]
|
| 38 |
+
API --> SVC
|
| 39 |
+
SVC --> SEAM[Engine seam - Protocol]
|
| 40 |
+
SEAM --> E1[whisper-small-uz FAST]
|
| 41 |
+
SEAM --> E2[rubaistt_v2_medium ACCURATE]
|
| 42 |
+
API --> DOCS[OpenAPI '/docs']
|
| 43 |
+
subgraph Offline [Benchmark - offline]
|
| 44 |
+
BM[harness] --> SEAM
|
| 45 |
+
BM --> E3[xls-r-uzbek benchmark-only]
|
| 46 |
+
BM --> M[(metrics.csv + plots)]
|
| 47 |
+
end
|
| 48 |
+
subgraph Deploy [HF Spaces - Docker SDK - FREE]
|
| 49 |
+
API
|
| 50 |
+
FE
|
| 51 |
+
DOCS
|
| 52 |
+
end
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## The $0 stack
|
| 56 |
+
|
| 57 |
+
| Need | Free tool |
|
| 58 |
+
|---|---|
|
| 59 |
+
| API + demo hosting | Hugging Face Spaces (Docker SDK, free CPU, 16 GB) |
|
| 60 |
+
| Models | Hugging Face Hub (Apache-2.0 Uzbek ASR) |
|
| 61 |
+
| Benchmark data | FLEURS `uz_uz` test split |
|
| 62 |
+
| Code | GitHub |
|
| 63 |
+
|
| 64 |
+
## Engines
|
| 65 |
+
|
| 66 |
+
| Role | Model | Deployed |
|
| 67 |
+
|---|---|---|
|
| 68 |
+
| Fast (default) | `BlueRaccoon/whisper-small-uz` | β
|
|
| 69 |
+
| Accurate | `islomov/rubaistt_v2_medium` | β
|
|
| 70 |
+
| Benchmark baseline | `lucio/xls-r-uzbek-cv8` | offline only |
|
| 71 |
+
|
| 72 |
+
## API
|
| 73 |
+
|
| 74 |
+
| Endpoint | Purpose |
|
| 75 |
+
|---|---|
|
| 76 |
+
| `GET /api/health` | readiness (engines loaded) |
|
| 77 |
+
| `POST /api/transcribe` | transcribe audio β text + word timings |
|
| 78 |
+
| `GET /api/examples` | bundled FLEURS clips |
|
| 79 |
+
| `POST /api/transcribe_example` | transcribe a bundled clip + live WER |
|
| 80 |
+
| `GET /api/benchmark` | latest WER/CER per engine |
|
| 81 |
+
|
| 82 |
+
Errors follow **RFC 9457** (`application/problem+json`). All times are integer milliseconds.
|
| 83 |
+
|
| 84 |
+
### Why there's no login
|
| 85 |
+
|
| 86 |
+
Deliberate: this is a public evaluation demo with no user data to protect. Auth would only
|
| 87 |
+
add friction. Abuse is bounded by per-IP rate limiting + upload-size and duration caps instead.
|
| 88 |
+
|
| 89 |
+
## Roadmap
|
| 90 |
+
|
| 91 |
+
- [x] **M1** β deployable skeleton: live `/health` + `/docs` on HF Spaces
|
| 92 |
+
- [ ] **M2** β fast engine wired: real Uzbek transcription via `/api/transcribe`
|
| 93 |
+
- [ ] **M3** β Gradio UI (4 tabs) + one-click FLEURS examples with live WER
|
| 94 |
+
- [ ] **M4** β 3-engine benchmark on FLEURS + error dashboard
|
| 95 |
+
- [ ] **M5** β docs polish
|
| 96 |
+
- [ ] **v2** β long-audio chunking
|
| 97 |
+
|
| 98 |
+
## Local development
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
pip install -e ".[dev]"
|
| 102 |
+
uvicorn app.main:app --reload --port 7860
|
| 103 |
+
# open http://localhost:7860/docs
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## License
|
| 107 |
+
|
| 108 |
+
Apache-2.0. All deployed models are Apache-2.0. This is original work; it follows common
|
| 109 |
+
FastAPI architectural conventions but contains no third-party application code.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Uzbek STT service."""
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/errors.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured errors as RFC 9457 ``application/problem+json``.
|
| 2 |
+
|
| 3 |
+
Domain code raises an ``AppError`` (or a subclass) with a stable ``type`` URI;
|
| 4 |
+
the registered handlers turn it into a problem+json body. Routers stay free of
|
| 5 |
+
ad-hoc ``HTTPException`` shapes, so every error the API emits looks the same.
|
| 6 |
+
|
| 7 |
+
Two correctness rules (see features/uzbek-stt.md Β§6):
|
| 8 |
+
1. Every body is serialized through ``jsonable_encoder`` β validation errors carry
|
| 9 |
+
non-JSON-native objects that a raw ``JSONResponse`` cannot encode.
|
| 10 |
+
2. Per-error extras live under a nested ``params`` envelope, never merged at the
|
| 11 |
+
top level where they could shadow a reserved member.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from fastapi import FastAPI, Request, status
|
| 19 |
+
from fastapi.encoders import jsonable_encoder
|
| 20 |
+
from fastapi.exceptions import RequestValidationError
|
| 21 |
+
from fastapi.responses import JSONResponse
|
| 22 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 23 |
+
|
| 24 |
+
PROBLEM_CONTENT_TYPE = "application/problem+json"
|
| 25 |
+
_TYPE_PREFIX = "urn:uzbekstt:error"
|
| 26 |
+
_RESERVED = {"type", "title", "status", "detail", "instance"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AppError(Exception):
|
| 30 |
+
"""A domain error carrying its HTTP status and a stable type URI."""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
*,
|
| 35 |
+
status_code: int,
|
| 36 |
+
type_: str,
|
| 37 |
+
title: str,
|
| 38 |
+
detail: str | None = None,
|
| 39 |
+
params: dict[str, Any] | None = None,
|
| 40 |
+
headers: dict[str, str] | None = None,
|
| 41 |
+
) -> None:
|
| 42 |
+
super().__init__(detail or title)
|
| 43 |
+
self.status_code = status_code
|
| 44 |
+
self.type_ = type_
|
| 45 |
+
self.title = title
|
| 46 |
+
self.detail = detail
|
| 47 |
+
self.params = params or {}
|
| 48 |
+
self.headers = headers or {}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class UnsupportedAudioError(AppError):
|
| 52 |
+
def __init__(self, *, detail: str) -> None:
|
| 53 |
+
super().__init__(
|
| 54 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 55 |
+
type_=f"{_TYPE_PREFIX}:unsupported-audio",
|
| 56 |
+
title="Unsupported or unreadable audio",
|
| 57 |
+
detail=detail,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class PayloadTooLargeError(AppError):
|
| 62 |
+
def __init__(self, *, size_bytes: int, limit_bytes: int) -> None:
|
| 63 |
+
super().__init__(
|
| 64 |
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
| 65 |
+
type_=f"{_TYPE_PREFIX}:payload-too-large",
|
| 66 |
+
title="Upload exceeds the size cap",
|
| 67 |
+
detail=f"Upload is {size_bytes} bytes; the limit is {limit_bytes} bytes.",
|
| 68 |
+
params={"size_bytes": size_bytes, "limit_bytes": limit_bytes},
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class AudioTooLongError(AppError):
|
| 73 |
+
def __init__(self, *, seconds: float, limit: float) -> None:
|
| 74 |
+
super().__init__(
|
| 75 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 76 |
+
type_=f"{_TYPE_PREFIX}:audio-too-long",
|
| 77 |
+
title="Audio exceeds the duration cap",
|
| 78 |
+
detail=f"Clip is {seconds:.1f}s; the limit is {limit:.0f}s.",
|
| 79 |
+
params={"seconds": seconds, "limit": limit},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class RateLimitedError(AppError):
|
| 84 |
+
def __init__(self, *, retry_after: int) -> None:
|
| 85 |
+
super().__init__(
|
| 86 |
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
| 87 |
+
type_=f"{_TYPE_PREFIX}:rate-limited",
|
| 88 |
+
title="Too many requests",
|
| 89 |
+
detail="Rate limit exceeded; retry after a short wait.",
|
| 90 |
+
params={"retry_after": retry_after},
|
| 91 |
+
headers={"Retry-After": str(retry_after)},
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class EngineNotReadyError(AppError):
|
| 96 |
+
def __init__(self, *, engine: str) -> None:
|
| 97 |
+
super().__init__(
|
| 98 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 99 |
+
type_=f"{_TYPE_PREFIX}:engine-not-ready",
|
| 100 |
+
title="Engine is still loading",
|
| 101 |
+
detail=f"Engine '{engine}' is not ready yet; retry shortly.",
|
| 102 |
+
params={"engine": engine},
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _problem(
|
| 107 |
+
*,
|
| 108 |
+
request: Request,
|
| 109 |
+
status_code: int,
|
| 110 |
+
type_: str,
|
| 111 |
+
title: str,
|
| 112 |
+
detail: str | None,
|
| 113 |
+
params: dict[str, Any] | None = None,
|
| 114 |
+
headers: dict[str, str] | None = None,
|
| 115 |
+
) -> JSONResponse:
|
| 116 |
+
body: dict[str, Any] = {
|
| 117 |
+
"type": type_,
|
| 118 |
+
"title": title,
|
| 119 |
+
"status": status_code,
|
| 120 |
+
"instance": str(request.url.path),
|
| 121 |
+
}
|
| 122 |
+
if detail:
|
| 123 |
+
body["detail"] = detail
|
| 124 |
+
if params:
|
| 125 |
+
# Nested envelope β never shadow a reserved member.
|
| 126 |
+
body["params"] = {k: v for k, v in params.items() if k not in _RESERVED}
|
| 127 |
+
return JSONResponse(
|
| 128 |
+
status_code=status_code,
|
| 129 |
+
content=jsonable_encoder(body),
|
| 130 |
+
media_type=PROBLEM_CONTENT_TYPE,
|
| 131 |
+
headers=headers or None,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def register_exception_handlers(app: FastAPI) -> None:
|
| 136 |
+
@app.exception_handler(AppError)
|
| 137 |
+
async def _app_error(request: Request, exc: AppError) -> JSONResponse:
|
| 138 |
+
return _problem(
|
| 139 |
+
request=request,
|
| 140 |
+
status_code=exc.status_code,
|
| 141 |
+
type_=exc.type_,
|
| 142 |
+
title=exc.title,
|
| 143 |
+
detail=exc.detail,
|
| 144 |
+
params=exc.params,
|
| 145 |
+
headers=exc.headers,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
@app.exception_handler(StarletteHTTPException)
|
| 149 |
+
async def _http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
| 150 |
+
title = exc.detail if isinstance(exc.detail, str) else "HTTP error"
|
| 151 |
+
return _problem(
|
| 152 |
+
request=request,
|
| 153 |
+
status_code=exc.status_code,
|
| 154 |
+
type_=f"{_TYPE_PREFIX}:http-{exc.status_code}",
|
| 155 |
+
title=title,
|
| 156 |
+
detail=None,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
@app.exception_handler(RequestValidationError)
|
| 160 |
+
async def _validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
| 161 |
+
return _problem(
|
| 162 |
+
request=request,
|
| 163 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 164 |
+
type_=f"{_TYPE_PREFIX}:validation",
|
| 165 |
+
title="Validation error",
|
| 166 |
+
detail="One or more fields did not validate.",
|
| 167 |
+
params={"errors": exc.errors()},
|
| 168 |
+
)
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging configuration. Called once from the app lifespan."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def configure_logging() -> None:
|
| 9 |
+
logging.basicConfig(
|
| 10 |
+
level=logging.INFO,
|
| 11 |
+
format="%(asctime)s %(levelname)-8s %(name)s β %(message)s",
|
| 12 |
+
)
|
app/core/settings.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-source application configuration.
|
| 2 |
+
|
| 3 |
+
Every module reads config through ``get_settings()`` β never via ``os.environ``
|
| 4 |
+
directly. Values come from environment variables (or a local ``.env``), so the
|
| 5 |
+
same image behaves differently across dev / Spaces without code changes.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
|
| 12 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Settings(BaseSettings):
|
| 16 |
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="", extra="ignore")
|
| 17 |
+
|
| 18 |
+
app_name: str = "Uzbek STT"
|
| 19 |
+
version: str = "0.1.0"
|
| 20 |
+
environment: str = "production"
|
| 21 |
+
|
| 22 |
+
# Public demo, no auth (see features/uzbek-stt.md Β§7.4); a permissive CORS
|
| 23 |
+
# policy is intentional here and revisited if an API-key mode is ever added.
|
| 24 |
+
cors_origins: list[str] = ["*"]
|
| 25 |
+
|
| 26 |
+
# HF Spaces (Docker SDK) routes traffic to this port by default.
|
| 27 |
+
# Keep in lockstep with Dockerfile (--port/EXPOSE) and README `app_port`.
|
| 28 |
+
port: int = 7860
|
| 29 |
+
|
| 30 |
+
# Audio guards (Β§7.1) β single-source; read by app/audio.py from M2 on.
|
| 31 |
+
max_upload_bytes: int = 10 * 1024 * 1024 # 10 MB, checked before decode -> 413
|
| 32 |
+
max_audio_seconds: float = 30.0 # checked after decode -> 400
|
| 33 |
+
|
| 34 |
+
# Rate limit (Β§7.3) β slowapi limit string; read by the limiter from M3 on.
|
| 35 |
+
rate_limit: str = "30/minute"
|
| 36 |
+
|
| 37 |
+
# Engine model IDs (Β§5) β single-source; only app/engines/ imports the ML libs.
|
| 38 |
+
fast_model_id: str = "BlueRaccoon/whisper-small-uz"
|
| 39 |
+
accurate_model_id: str = "islomov/rubaistt_v2_medium"
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def is_production(self) -> bool:
|
| 43 |
+
return self.environment.lower() == "production"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@lru_cache
|
| 47 |
+
def get_settings() -> Settings:
|
| 48 |
+
return Settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entrypoint β ``create_app()`` factory + lifespan."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import AsyncIterator
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
from fastapi.responses import RedirectResponse
|
| 11 |
+
|
| 12 |
+
from app.core.errors import register_exception_handlers
|
| 13 |
+
from app.core.logging import configure_logging
|
| 14 |
+
from app.core.settings import get_settings
|
| 15 |
+
from app.routers import health
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
| 20 |
+
configure_logging()
|
| 21 |
+
# M2: warm the fast engine here, then flip /health readiness once weights load.
|
| 22 |
+
yield
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_app() -> FastAPI:
|
| 26 |
+
settings = get_settings()
|
| 27 |
+
app = FastAPI(
|
| 28 |
+
title=f"{settings.app_name} API",
|
| 29 |
+
version=settings.version,
|
| 30 |
+
description=(
|
| 31 |
+
"Uzbek speech-to-text service. Errors follow RFC 9457 "
|
| 32 |
+
"(application/problem+json). No authentication by design β see the README."
|
| 33 |
+
),
|
| 34 |
+
lifespan=_lifespan,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
app.add_middleware(
|
| 38 |
+
CORSMiddleware,
|
| 39 |
+
allow_origins=settings.cors_origins,
|
| 40 |
+
allow_methods=["GET", "POST", "OPTIONS"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
register_exception_handlers(app)
|
| 45 |
+
app.include_router(health.router)
|
| 46 |
+
|
| 47 |
+
@app.get("/", include_in_schema=False)
|
| 48 |
+
async def _root() -> RedirectResponse:
|
| 49 |
+
# M3 mounts the Gradio UI here; until then, send visitors to the API docs.
|
| 50 |
+
return RedirectResponse(url="/docs")
|
| 51 |
+
|
| 52 |
+
return app
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
app = create_app()
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/health.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health / readiness endpoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from app.core.settings import get_settings
|
| 8 |
+
from app.schemas.health import HealthResponse
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api", tags=["health"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/health", response_model=HealthResponse)
|
| 14 |
+
async def health() -> HealthResponse:
|
| 15 |
+
settings = get_settings()
|
| 16 |
+
# M1 skeleton: no engines loaded yet, so the service is trivially ready.
|
| 17 |
+
# M2 redefines `ready` as "fast-engine weights present and warmed" (spec Β§7.2).
|
| 18 |
+
return HealthResponse(ready=True, engines_loaded=[], version=settings.version)
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/health.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health endpoint schema (the HTTP-boundary contract)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class HealthResponse(BaseModel):
|
| 9 |
+
ready: bool
|
| 10 |
+
engines_loaded: list[str]
|
| 11 |
+
version: str
|
features/uzbek-stt.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spec: Uzbek Speech-to-Text Service
|
| 2 |
+
|
| 3 |
+
**Status:** Draft for review Β· **Author:** Bahor Β· **Date:** 2026-06-23
|
| 4 |
+
**Type:** Greenfield portfolio project β deployable Uzbek STT service with public API + demo UI + benchmark.
|
| 5 |
+
|
| 6 |
+
> **Originality note.** This project's *code is written from scratch.* It follows senior structural
|
| 7 |
+
> patterns that are standard FastAPI craft (app factory, Protocol-based provider seam, problem+json
|
| 8 |
+
> errors, single-source settings, layered `core/routers/schemas/services`). Those patterns are
|
| 9 |
+
> industry conventions β not anyone's property. No code is copied from any other repository.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1. Why this exists (the one-paragraph pitch)
|
| 14 |
+
|
| 15 |
+
A deployed, evaluable Uzbek speech-to-text **service** β not a notebook, not a model dump. What a
|
| 16 |
+
reviewer opens is a **live URL** with documented API endpoints (`/docs`), a working demo, and an
|
| 17 |
+
honest benchmark. The model is a *component*; the **production engineering is the point.** Built
|
| 18 |
+
under a hard constraint β **$0: no paid server, no paid API** β on an underserved language.
|
| 19 |
+
|
| 20 |
+
**The 60-second proof:** open the URL β click a preloaded Uzbek example β see a transcript **and a
|
| 21 |
+
real WER** against known ground truth.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 2. Goals / Non-goals
|
| 26 |
+
|
| 27 |
+
### Goals
|
| 28 |
+
- A **real REST API** (FastAPI) with OpenAPI docs, deployed to a public URL, fully free.
|
| 29 |
+
- A **demo UI** (Gradio, mounted on the same app) anyone can use without setup.
|
| 30 |
+
- An **honest benchmark**: 3 engines on FLEURS uz_uz, WER/CER + error analysis, **re-measured by us**.
|
| 31 |
+
- **Clean, defensible architecture**: a Protocol-based engine seam reused by both API and benchmark;
|
| 32 |
+
dependency rule respected (services/engines never import FastAPI).
|
| 33 |
+
- Docs that let a reviewer evaluate by reading.
|
| 34 |
+
|
| 35 |
+
### Non-goals (v1)
|
| 36 |
+
- **No auth / login** β deliberate (Β§7.4).
|
| 37 |
+
- **No long-audio chunking** β v1 caps at ~30s; long-audio is the **v2 milestone**.
|
| 38 |
+
- **No training** β deploy verified off-the-shelf Uzbek models.
|
| 39 |
+
- **No GPU** β must run on free HF Spaces CPU (16 GB).
|
| 40 |
+
- **No DB / Celery / WebSocket** β the service is **stateless**; we don't need persistence.
|
| 41 |
+
- **No CC-BY-NC models** (MMS excluded on purpose).
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## 3. Architecture
|
| 46 |
+
|
| 47 |
+
```mermaid
|
| 48 |
+
flowchart TD
|
| 49 |
+
U[User: mic / upload / API call] -->|audio| FE[Gradio UI '/']
|
| 50 |
+
U -->|POST audio| API[FastAPI '/api/*']
|
| 51 |
+
FE --> SVC
|
| 52 |
+
API --> SVC[TranscriptionService]
|
| 53 |
+
SVC --> SEAM[Engine seam - Protocol]
|
| 54 |
+
SEAM --> E1[whisper-small-uz FAST]
|
| 55 |
+
SEAM --> E2[rubaistt_v2_medium ACCURATE]
|
| 56 |
+
API --> DOCS[OpenAPI '/docs']
|
| 57 |
+
subgraph Offline [Benchmark - offline / CI]
|
| 58 |
+
BM[benchmark harness] --> SEAM
|
| 59 |
+
BM --> E3[xls-r-uzbek benchmark-only]
|
| 60 |
+
BM --> M[(metrics.csv + plots)]
|
| 61 |
+
M --> RPT[report + error dashboard]
|
| 62 |
+
end
|
| 63 |
+
RPT -.published.-> README[README + UI Benchmark tab]
|
| 64 |
+
subgraph Deploy [HF Spaces - Docker SDK - FREE]
|
| 65 |
+
API
|
| 66 |
+
FE
|
| 67 |
+
DOCS
|
| 68 |
+
end
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
**Layering (dependency rule β imports point inward only):**
|
| 72 |
+
`routers β services β engines β models`. The Gradio UI calls the **service layer directly** (not HTTP)
|
| 73 |
+
to avoid a self-call. The benchmark calls the **same engine seam** the API uses β one source of
|
| 74 |
+
inference truth. **No engine/service module imports `fastapi`; no router contains inference logic.**
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
## 4. Repository layout (senior structure, our own code)
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
uzbek-stt/
|
| 82 |
+
βββ app/
|
| 83 |
+
β βββ main.py # create_app() factory + lifespan (model warmup)
|
| 84 |
+
β βββ core/ # cross-cutting, framework-adjacent
|
| 85 |
+
β β βββ settings.py # get_settings() single-source config
|
| 86 |
+
β β βββ errors.py # AppError hierarchy + problem+json handlers
|
| 87 |
+
β β βββ logging.py # configure_logging()
|
| 88 |
+
β β βββ middleware.py # request-id + rate limit
|
| 89 |
+
β βββ routers/ # HTTP endpoints only β no business logic
|
| 90 |
+
β β βββ health.py
|
| 91 |
+
β β βββ transcription.py
|
| 92 |
+
β β βββ benchmark.py
|
| 93 |
+
β βββ schemas/ # Pydantic request/response models (the API contract)
|
| 94 |
+
β β βββ transcription.py
|
| 95 |
+
β β βββ benchmark.py
|
| 96 |
+
β βββ services/ # business logic β NEVER imports fastapi
|
| 97 |
+
β β βββ transcription.py # TranscriptionService
|
| 98 |
+
β β βββ examples.py # bundled FLEURS example loader + WER
|
| 99 |
+
β βββ engines/ # the provider seam β NEVER imports fastapi
|
| 100 |
+
β β βββ base.py # Engine Protocol + frozen dataclass results
|
| 101 |
+
β β βββ whisper.py # WhisperEngine (covers fast + accurate via model id)
|
| 102 |
+
β β βββ factory.py # select engine by name; single place models load
|
| 103 |
+
β βββ audio.py # decode/resample to 16k mono, duration/size guards
|
| 104 |
+
β βββ ui/
|
| 105 |
+
β βββ gradio_app.py # 4-tab Gradio UI, mounted at '/'
|
| 106 |
+
βββ benchmark/
|
| 107 |
+
β βββ run_benchmark.py # WER/CER on FLEURS uz_uz across 3 engines
|
| 108 |
+
β βββ error_analysis.py # confused chars, WER-vs-length, latency plots
|
| 109 |
+
βββ data/examples/ # ~5 bundled FLEURS clips + references.json
|
| 110 |
+
βββ tests/ # pytest β engine seam, audio guards, contracts
|
| 111 |
+
βββ Dockerfile # the deployment artifact (HF Spaces Docker SDK)
|
| 112 |
+
βββ pyproject.toml # deps, ruff, mypy config
|
| 113 |
+
βββ README.md # architecture diagram + metrics + "why"
|
| 114 |
+
βββ INVENTORY.md # spec-coverage map
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
*Differences from `studio-qiroat` (by design):* no `models/` (no DB), no `workers/` (no Celery),
|
| 118 |
+
no `ws/` (no realtime), no `alembic/` (no migrations). We're stateless β simpler on purpose.
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 5. The engine seam (the architectural keystone)
|
| 123 |
+
|
| 124 |
+
A `Protocol` so any model is one swappable class β API and benchmark both depend on the abstraction,
|
| 125 |
+
never on a concrete model. **ML libraries (`transformers`, `torch`) are imported only inside
|
| 126 |
+
`app/engines/`** β the dependency-discipline rule, our analogue of studio-qiroat's "never import the
|
| 127 |
+
SDK outside its service module."
|
| 128 |
+
|
| 129 |
+
```python
|
| 130 |
+
# app/engines/base.py (shape, written by us)
|
| 131 |
+
# UNIT CONVENTION: all times are integer MILLISECONDS, everywhere β at this seam
|
| 132 |
+
# AND in the public API JSON (Β§6). No seconds anywhere, no per-field ambiguity.
|
| 133 |
+
@dataclass(frozen=True, slots=True)
|
| 134 |
+
class RecognizedWord:
|
| 135 |
+
word: str
|
| 136 |
+
start_ms: int
|
| 137 |
+
end_ms: int
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True, slots=True)
|
| 140 |
+
class Transcript:
|
| 141 |
+
text: str
|
| 142 |
+
words: list[RecognizedWord] # MAY be empty β see "Word timings" note below
|
| 143 |
+
engine: str # e.g. "fast:whisper-small-uz"
|
| 144 |
+
audio_ms: int # decoded audio duration; the engine owns this value
|
| 145 |
+
inference_ms: int # wall-clock spent inside transcribe()
|
| 146 |
+
|
| 147 |
+
class Engine(Protocol):
|
| 148 |
+
name: str
|
| 149 |
+
def transcribe(self, audio: AudioInput) -> Transcript: ...
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
**Word timings.** `Transcript.words` **may be empty** and that is a valid result. The two
|
| 153 |
+
deployed Whisper engines (fast + accurate) populate it via the HF pipeline's
|
| 154 |
+
`return_timestamps="word"`. The benchmark-only `xls-r` (CTC) does **not** emit word timings β
|
| 155 |
+
it returns `words=[]`, which is fine because the benchmark scores text (WER/CER), not timings.
|
| 156 |
+
Consumers must treat `words` as optional, never assume it is non-empty.
|
| 157 |
+
|
| 158 |
+
| Role | Model ID | Params | Deployed? | Notes |
|
| 159 |
+
|---|---|---|---|---|
|
| 160 |
+
| **Fast** (UI default) | `BlueRaccoon/whisper-small-uz` | 244M | β
live | publishes WER on named FLEURS+CV sets; snappy on CPU |
|
| 161 |
+
| **Accurate** | `islomov/rubaistt_v2_medium` | 769M | β
live | most-adopted Uzbek model (10.2k dl/mo); slow on CPU (10β30s) |
|
| 162 |
+
| **Baseline** (other arch) | `lucio/xls-r-uzbek-cv8` | 300M | β benchmark-only | wav2vec2/CTC; KenLM dep too heavy for live image |
|
| 163 |
+
|
| 164 |
+
Card WER numbers are measured on *different* splits and are **not comparable** β the benchmark
|
| 165 |
+
re-measures all three on FLEURS uz_uz. That re-measurement *is* the analysis.
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
## 6. API contracts
|
| 170 |
+
|
| 171 |
+
> Errors follow **RFC 9457 `application/problem+json`** via an `AppError` hierarchy (our own classes).
|
| 172 |
+
> Every error has a stable `type`, `title`, `status`, `detail`. Two correctness rules the
|
| 173 |
+
> implementation MUST follow (both flagged by review of the started `errors.py`):
|
| 174 |
+
> 1. The body is always serialized through `jsonable_encoder(...)` before `JSONResponse` β
|
| 175 |
+
> validation errors carry non-JSON-native objects and a raw `JSONResponse` crashes the handler.
|
| 176 |
+
> 2. Per-error extra data goes under a nested `"params": {...}` envelope β it must **never**
|
| 177 |
+
> be merged at top level where it could shadow a reserved member (`type`/`title`/`status`/`detail`/`instance`).
|
| 178 |
+
|
| 179 |
+
### `POST /api/transcribe`
|
| 180 |
+
- **Request:** multipart `audio` (wav/mp3/m4a/ogg) + optional `engine` β {`fast`,`accurate`} (default `fast`).
|
| 181 |
+
- **200** (all times integer ms; `words` may be `[]`):
|
| 182 |
+
```json
|
| 183 |
+
{ "text": "...", "engine": "fast", "audio_ms": 7200, "inference_ms": 1840,
|
| 184 |
+
"words": [{ "word": "salom", "start_ms": 120, "end_ms": 540 }] }
|
| 185 |
+
```
|
| 186 |
+
- **Error inventory** (each is an `AppError` subclass β problem+json):
|
| 187 |
+
|
| 188 |
+
| Status | Type slug | When |
|
| 189 |
+
|---|---|---|
|
| 190 |
+
| `413` | `payload-too-large` | upload exceeds the **byte-size** cap (Β§7.1) |
|
| 191 |
+
| `422` | `unsupported-audio` | unreadable / empty / unsupported format |
|
| 192 |
+
| `400` | `audio-too-long` | decoded **duration** exceeds the cap (Β§7.1) |
|
| 193 |
+
| `429` | `rate-limited` | per-IP rate limit hit (Β§7.3); includes `Retry-After` |
|
| 194 |
+
| `503` | `engine-not-ready` | requested engine's weights not yet loaded (Β§7.2) |
|
| 195 |
+
|
| 196 |
+
Sizeβ`413` and durationβ`400` are **distinct** and must not be conflated (Β§7.1 matches this table).
|
| 197 |
+
|
| 198 |
+
### `GET /api/examples`
|
| 199 |
+
- Bundled FLEURS clips: `[{ id, audio_url, reference }]`.
|
| 200 |
+
|
| 201 |
+
### `POST /api/transcribe_example` (`id`, `engine`)
|
| 202 |
+
- Transcribes a bundled example β transcript **+ per-clip WER/CER vs its reference** (powers one-click demo).
|
| 203 |
+
|
| 204 |
+
### `GET /api/benchmark`
|
| 205 |
+
- Latest benchmark as JSON: per-engine WER/CER on FLEURS + metadata (date, n, set).
|
| 206 |
+
|
| 207 |
+
### `GET /api/health`
|
| 208 |
+
- `{ "ready": true, "engines_loaded": ["fast"], "version": "..." }` β `ready` is `true` **only after**
|
| 209 |
+
the default engine warms up (honest HF Spaces cold-start handling).
|
| 210 |
+
|
| 211 |
+
---
|
| 212 |
+
|
| 213 |
+
## 7. Cross-cutting decisions
|
| 214 |
+
|
| 215 |
+
### 7.1 Audio handling (`app/audio.py`)
|
| 216 |
+
- **Decode path is the `ffmpeg` binary** (installed as a system package in the Dockerfile β see Β§10),
|
| 217 |
+
wrapped from Python, resampling to **16 kHz mono** (model requirement). `librosa`/`soundfile` alone
|
| 218 |
+
cannot reliably decode `m4a`/`ogg`; the ffmpeg binary is a hard, non-pip deploy dependency.
|
| 219 |
+
- **Two distinct caps, two distinct errors:**
|
| 220 |
+
- byte-size cap (`max_upload_bytes`, default ~10 MB) β **`413`** `payload-too-large`, checked *before* decode.
|
| 221 |
+
- duration cap (`max_audio_seconds`, default **30s**) β **`400`** `audio-too-long`, checked *after* decode.
|
| 222 |
+
- Word timings come from the Whisper pipeline (`return_timestamps="word"`) for the deployed engines;
|
| 223 |
+
the contract allows `words=[]` (Β§5 "Word timings").
|
| 224 |
+
|
| 225 |
+
### 7.2 Model loading / warmup (`lifespan`)
|
| 226 |
+
- **Fast engine weights are baked into the Docker image at build time** (Β§10) β deterministic,
|
| 227 |
+
offline-safe cold-start with no first-request download penalty.
|
| 228 |
+
- **Accurate engine is lazy**: downloaded from HF Hub on first use into the HF cache dir, then reused
|
| 229 |
+
for the life of the running Space (re-downloaded only after a rebuild). The **first** accurate call
|
| 230 |
+
after a cold-start is therefore slow β documented, and surfaced in the UI.
|
| 231 |
+
- `/health.ready` means "**fast engine weights present and warmed**" β not merely "process up." It must
|
| 232 |
+
account for "weights still downloading," so a cold Space never 500s mid-load.
|
| 233 |
+
|
| 234 |
+
### 7.3 Rate limiting & abuse (load-bearing β it is the whole no-auth safety argument)
|
| 235 |
+
- **Library:** `slowapi` (Starlette-native limiter).
|
| 236 |
+
- **Client identity:** real caller IP from the **first hop of `X-Forwarded-For`** (HF Spaces sits behind
|
| 237 |
+
a proxy; using the socket peer would lump every caller into one bucket). Fall back to the socket peer
|
| 238 |
+
if the header is absent.
|
| 239 |
+
- **Limit:** `rate_limit` (default **30 requests/minute/IP**) on `POST /api/transcribe` and `/api/transcribe_example`.
|
| 240 |
+
- **Store:** in-process / in-memory. It resets on cold-start and is per-worker β **acceptable for a
|
| 241 |
+
single-worker demo Space; stated explicitly, not hidden.**
|
| 242 |
+
- **On limit:** `429` `rate-limited` problem+json with a `Retry-After` header.
|
| 243 |
+
|
| 244 |
+
### 7.4 Why no auth (documented, not forgotten)
|
| 245 |
+
- Auth is friction that suppresses evaluation; no user data to protect; Space is public anyway.
|
| 246 |
+
Abuse handled by Β§7.3. README states this. A future API-key mode is a noted non-v1 extension.
|
| 247 |
+
|
| 248 |
+
### 7.5 Loud failures
|
| 249 |
+
- App **refuses to start** if a *deployed* engine fails to load (fail fast). Benchmark-only engine
|
| 250 |
+
failures degrade the report, not the service.
|
| 251 |
+
|
| 252 |
+
### 7.6 Type & quality discipline (the senior bar)
|
| 253 |
+
- Full type hints; `Protocol` for seams; frozen `@dataclass(slots=True)` for value objects;
|
| 254 |
+
Pydantic v2 at the HTTP boundary. No `# type: ignore` without a reason comment.
|
| 255 |
+
- `ruff format && ruff check` + `mypy app` clean before commit. Tests alongside non-trivial logic.
|
| 256 |
+
- **All tunables live on `Settings` (single-source), never hardcoded in modules:**
|
| 257 |
+
`max_upload_bytes`, `max_audio_seconds`, `rate_limit`, plus the engine model IDs. `app/audio.py`
|
| 258 |
+
and the limiter read them from `get_settings()`.
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
## 8. Benchmark & error dashboard
|
| 263 |
+
|
| 264 |
+
- **Set:** `google/fleurs`, config `uz_uz`, **test** split (~350 clips). Secondary: Common Voice 17 test.
|
| 265 |
+
- **Metrics:** WER + CER per engine (jiwer); normalization (lowercase, punctuation strip) documented.
|
| 266 |
+
- **One normalizer, shared:** the Examples tab (Β§9) and the benchmark MUST call the *same* normalization
|
| 267 |
+
function before scoring β bundled example references are stored raw and normalized at eval time, so a
|
| 268 |
+
single source of truth prevents the demo WER and the benchmark WER from diverging.
|
| 269 |
+
- **Error dashboard (the "analysis" axis):** most-confused characters Β· WER vs audio length Β·
|
| 270 |
+
per-engine CPU latency (the story behind the Fast/Accurate selector).
|
| 271 |
+
- **Outputs:** `metrics.csv`, plots, `report.md` β README **and** UI Benchmark tab.
|
| 272 |
+
- **Honesty rule:** publish n, set, normalization, date. No card-number quoting, no cherry-picking.
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## 9. Frontend (Gradio, 4 tabs, mounted at `/`)
|
| 277 |
+
|
| 278 |
+
1. **Transcribe** β mic/upload + Fast/Accurate selector β transcript, word timestamps, latency.
|
| 279 |
+
2. **Examples** β preloaded FLEURS clips β prediction vs reference + per-clip WER (the 60-second wow).
|
| 280 |
+
3. **Benchmark** β metrics table + plots + error dashboard.
|
| 281 |
+
4. **About** β architecture diagram, GitHub link, `/docs` link, "$0 stack", "why no auth".
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## 10. Deployment
|
| 286 |
+
|
| 287 |
+
- **Target:** Hugging Face Spaces, **Docker SDK** (arbitrary Dockerfile, free CPU, 16 GB, public URL, no card).
|
| 288 |
+
- **One deployment** serves `/` (UI), `/api/*` (REST), `/docs` (OpenAPI).
|
| 289 |
+
- **Dockerfile must `apt-get install ffmpeg`** (the Β§7.1 decode dependency) and run as the non-root
|
| 290 |
+
`user` (UID 1000) HF Spaces expects, with `HOME`/HF cache pointed at a writable dir.
|
| 291 |
+
- **Model weights β explicit policy:** the **fast** engine is downloaded **at image-build time** and
|
| 292 |
+
baked in (deterministic cold-start). The **accurate** engine is **not** baked (keeps the image lean);
|
| 293 |
+
it lazy-downloads on first use into the HF cache (Β§7.2). README carries the HF Spaces YAML header.
|
| 294 |
+
|
| 295 |
+
---
|
| 296 |
+
|
| 297 |
+
## 11. Acceptance criteria (v1 done)
|
| 298 |
+
|
| 299 |
+
- [ ] `GET /api/health` β `ready:true` on the live Space.
|
| 300 |
+
- [ ] `POST /api/transcribe` returns correct-shape JSON for a real Uzbek clip, both engines.
|
| 301 |
+
- [ ] Examples tab: one click β transcript + WER against bundled reference.
|
| 302 |
+
- [ ] `/docs` renders all endpoints with schemas on the live URL.
|
| 303 |
+
- [ ] `python -m benchmark.run_benchmark` reproduces `metrics.csv` (WER/CER, 3 engines, FLEURS uz_uz).
|
| 304 |
+
- [ ] Error dashboard renders the 3 plots.
|
| 305 |
+
- [ ] README: diagram + metrics table + "why this design" + "$0 stack" + "why no auth".
|
| 306 |
+
- [ ] Caps enforced: oversized/overlong/empty audio β documented problem+json codes.
|
| 307 |
+
- [ ] `ruff`, `mypy`, `pytest` all green.
|
| 308 |
+
- [ ] Repo public on GitHub; live URL linked from README.
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## 12. Milestones (deploy-first)
|
| 313 |
+
|
| 314 |
+
- **M1 β Skeleton, deployed empty:** `create_app()` + `/health` + Dockerfile live on HF Spaces. *URL before the model.*
|
| 315 |
+
- **M2 β One engine live:** engine seam + `/api/transcribe` returns real Uzbek text (fast).
|
| 316 |
+
- **M3 β UI + examples:** Gradio 4-tab, Fast/Accurate selector, one-click FLEURS examples + WER.
|
| 317 |
+
- **M4 β Benchmark + dashboard:** 3-engine WER/CER on FLEURS, error plots, wired to README + UI.
|
| 318 |
+
- **M5 β Docs polish:** diagram, metrics table, INVENTORY, "why" sections.
|
| 319 |
+
- **v2 (post-ship) β long-audio chunking** as a visible roadmap item.
|
| 320 |
+
|
| 321 |
+
---
|
| 322 |
+
|
| 323 |
+
## 13. Risks & mitigations
|
| 324 |
+
|
| 325 |
+
| Risk | Mitigation |
|
| 326 |
+
|---|---|
|
| 327 |
+
| HF Space cold-start / sleeps | `/health` gates readiness; warm fast engine at startup; document first-call latency |
|
| 328 |
+
| `rubaistt` medium slow on CPU | it's the opt-in "Accurate" engine; latency shown as a feature, not hidden |
|
| 329 |
+
| FLEURS WER worse than card numbers | expected (domain shift); honesty is the point β publish our own measurement |
|
| 330 |
+
| Docker image too big / build timeout | pin deps, cache models, keep xls-r/KenLM out of the live image |
|
| 331 |
+
| Scope creep (chunking, training) | explicitly v2/out-of-scope (Β§2) |
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## 14. Open questions (need a human decision)
|
| 336 |
+
|
| 337 |
+
1. **Repo / project name** (working name `uzbek-stt`) β memorable brand vs descriptive?
|
| 338 |
+
2. **Hugging Face account** for the Space + model cache β do you have one? (free, needed at M1.)
|
| 339 |
+
3. **GitHub repo** under `bintuulugbek` β confirm name and public-from-day-one.
|
| 340 |
+
4. Bundle the ~5 FLEURS example clips **in the repo** (a few MB) or fetch at runtime? (Lean: bundle.)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "uzbek-stt"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Deployable Uzbek speech-to-text service: FastAPI API + Gradio demo + honest 3-engine benchmark."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.12"
|
| 11 |
+
dependencies = [
|
| 12 |
+
"fastapi>=0.115",
|
| 13 |
+
"uvicorn[standard]>=0.34",
|
| 14 |
+
"pydantic-settings>=2.5",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
[project.optional-dependencies]
|
| 18 |
+
dev = [
|
| 19 |
+
"ruff>=0.6",
|
| 20 |
+
"mypy>=1.11",
|
| 21 |
+
"pytest>=8.3",
|
| 22 |
+
"httpx>=0.27",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[tool.hatch.build.targets.wheel]
|
| 26 |
+
packages = ["app"]
|
| 27 |
+
|
| 28 |
+
[tool.ruff]
|
| 29 |
+
line-length = 100
|
| 30 |
+
target-version = "py312"
|
| 31 |
+
|
| 32 |
+
[tool.ruff.lint]
|
| 33 |
+
select = ["E", "F", "I", "UP", "B"]
|
| 34 |
+
|
| 35 |
+
[tool.mypy]
|
| 36 |
+
python_version = "3.12"
|
| 37 |
+
strict = true
|
| 38 |
+
ignore_missing_imports = true
|
| 39 |
+
|
| 40 |
+
[tool.pytest.ini_options]
|
| 41 |
+
testpaths = ["tests"]
|
| 42 |
+
addopts = "-q"
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_health.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""M1 contract tests: the service boots and the health/docs surface is correct."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi.testclient import TestClient
|
| 6 |
+
|
| 7 |
+
from app.main import app
|
| 8 |
+
|
| 9 |
+
client = TestClient(app)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_health_is_ready() -> None:
|
| 13 |
+
resp = client.get("/api/health")
|
| 14 |
+
assert resp.status_code == 200
|
| 15 |
+
body = resp.json()
|
| 16 |
+
assert body["ready"] is True
|
| 17 |
+
assert body["engines_loaded"] == []
|
| 18 |
+
assert isinstance(body["version"], str)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_root_redirects_to_docs() -> None:
|
| 22 |
+
resp = client.get("/", follow_redirects=False)
|
| 23 |
+
assert resp.status_code in (307, 308)
|
| 24 |
+
assert resp.headers["location"] == "/docs"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_openapi_schema_is_served() -> None:
|
| 28 |
+
resp = client.get("/openapi.json")
|
| 29 |
+
assert resp.status_code == 200
|
| 30 |
+
assert resp.json()["info"]["title"] == "Uzbek STT API"
|