bintuulugbek Claude Opus 4.8 commited on
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 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"