hetchyy's picture
deploy: inspector prod @ 349d570
4cc98c7 verified
|
Raw
History Blame Contribute Delete
17.4 kB
# Inspector backend architecture β€” START HERE
The Inspector is a single-worker Flask app serving a Svelte 5 SPA. It manages the lifecycle of Quran-recitation forced-alignment deliveries (catalogue β†’ align β†’ review β†’ release β†’ publish) and lets editors trim/split/merge/re-reference word-level segments. The deployed app is a Docker HF Space; its source of truth is a container-local SQLite DB (`inspector.db`) full-file synced to an HF bucket after every committed write. Per-reciter content (`detailed.json`, `segments.json`, `timestamps/<ch>.json`, peaks, audio) stays JSON/binary on the bucket, read on demand.
## Layering
```
HTTP ─▢ routes/<domain>/* thin Flask blueprints: parse β†’ service β†’ jsonify
β”‚ (primary Flask layer; only services/ exception is auth/auth.py)
β–Ό
services/<domain>/* business logic, Flask-free, returns plain dicts
β”‚ db/ storage/ audio/ auth/ state/ segments/
β”‚ validation/ activity/ reference/ quran_foundation/
β–Ό
domain/ adapters/ utils/ pure model Β· JSON↔domain conversion Β· helpers
services/storage/cache.py single owner of every mutable cache var
config.py Β· constants.py env-overridable tunables Β· domain literals
```
Invariants:
- **Routes are the Flask layer.** Services accept params, return dicts, raise typed errors; `app.py` maps those errors to the `{error: str}` JSON envelope. The sole `services/` exception is `services/auth/auth.py` β€” authlib's `flask_client` OAuth integration (`_CacheStateOAuth`/`_CacheStateIntegration` subclass Flask integration) cannot be abstracted away. The `services-flask-free` CI guard enforces this with `auth.py` as the only allow-listed file.
- **Single worker.** `app.py::_assert_single_worker` refuses to boot under any multi-worker signal. State store, per-slug locks, role cache, OAuth-state cookie, and all in-memory caches assume one process.
- **SQLite is the source of truth** for the 7 former bucket JSON stores + audit. Reads go through `services/db/repo_*`; writes commit in `services/db/connection.py::transaction()` then sync via `services/db/sync.py`.
- **Bucket-first reads** for reciter content go through `services/storage/data_dir.py` + `storage_paths.py`, never raw `Path.read_text()`.
- **`services/__init__.py` re-exports** every submodule at the legacy flat name (`from services.cache import …` still resolves to `services/storage/cache.py`) via `sys.modules` aliasing. New modules drop into a subpackage + one re-export line.
## Services subpackage map
| Subpackage | Role | Key files |
|---|---|---|
| `db/` | SQLite substrate β€” source of truth for state/catalog/access/claims/requests/activity + transitions (audit). WAL single-writer, full-file bucket sync. | `connection.py` (txn primitives, `transaction()`, `get_writer`, `current_db_seq`), `migrate.py` (`PRAGMA user_version` runner over `migrations/NNNN_*.sql`), `sync.py` (bucket pull/push + CAS guard), `repo_state.py`, `repo_catalog.py`, `repo_access.py`, `repo_claims.py`, `repo_requests.py`, `repo_activity.py`, `repo_transitions.py`, `_serde.py`, `errors.py` |
| `storage/` | Bucket/filesystem backend, per-reciter content IO, the cache registry, local auto-mount, external-file validation. | `cache.py` (all cache vars + invalidation), `data_dir.py` (slug→bytes/dict under `reciters/<slug>/`), `storage_paths.py` (pure on-bucket path strings), `data_loader.py` (cached loaders for segs/verses/probe/reference), `hf_bucket.py` (`BucketBackend`/`FilesystemBackend`), `auto_mount.py` (local hf-mount FUSE), `bucket_audit.py` (Flask-free per-`reciters/<slug>/` artefact validator against `qua_shared/schemas`; backs the per-slug CLI `scripts/bucket/audit_bucket_reciter.py`, the whole-bucket gate `scripts/diagnostics/validate_bucket.py`, and `/healthz?deep=1` via `sample_validation`) |
| `audio/` | Everything bytes-on-disk β†’ `<audio>`: source resolution, fetch, peaks, manifest. | `audio_source.py` (resolve bucket/CDN + VBR), `audio_fetch.py` (bucket audio + peaks reads), `audio_meta.py` (per-chapter sidecar), `peaks.py` (ffmpeg compute), `peaks_slim.py` (canonical schema-v3 packed format), `peaks_history.py` (per-op JSONL) |
| `auth/` | HF OAuth + signed identity cookie, roles, pure authorization predicates. | `auth.py` (OAuth + `inspector_session` cookie + dev-mode), `access.py` (roles facade over DB), `permissions.py` (pure predicates + `normalize_reason`), `predicates.py` (`/api/reciter-task` can-edit predicates), `hf_users.py` (login→hf_user_id proxy), `secrets_guard.py` |
| `state/` | Reciter lifecycle state machine, catalog, requests, audit facade. | `state.py` (`transition(slug, event, actor)`, `_HANDLERS`, typed errors), `catalog.py` (`snapshot()` β†’ `ReciterCatalog`, mutations), `pending_requests.py`, `request_archive.py`, `audit.py` (legacy `append()` facade β†’ `repo_transitions`) |
| `segments/` | Save / undo / read queries for the Segments editor, plus auto-detect + auto-split. | `save.py` (write-through, history append, rebuild segments.json), `undo.py` (batch reversal + snapshot verify), `segments_query.py` (read-only data), `auto_detect.py` (reconciler: `reciters/` dir β†’ `alignment_completed`), `auto_split.py` (precomputed cursor lookup), `qalqala.py` (persisted `qalqala_letter`) |
| `validation/` | Registry-backed segment validation + chapter counts (Flask-free). | `registry.py` (`IssueRegistry`, category sets β€” keep in lockstep with FE `registry.ts`), `classifier.py`, `snapshot_classifier.py`, `detail.py`, `_missing.py`, `_structural.py`; package `__init__.py` exposes `validate_reciter_segments` |
| `activity/` | Audit-event classification + public activity feed + history queries + stats. The admin notifications rail was retired β€” admin awareness lives in the Admin dashboard tabs now. | `activity_classification.py` (event β†’ public/hidden), `activity_state.py` (global-tombstone facade), `public_activity.py`, `history_query.py` (edit-history read + split-group/resolved-by-edit indexes), `stats.py`, `search_normalize.py` (mirrors FE `normalizeArabic`) |
| `reference/` | Static Quran reference payloads + timestamps server + public dashboard mapper. | `quran_refs.py` (DK words + verse word counts, one immutable asset), `timestamps.py` (manifest + per-chapter shard server, gzip LRU), `public_state.py` (ReciterRow β†’ six-bucket public taxonomy, strips identity) |
| `quran_foundation/` | Quran.Foundation API integration (OAuth user APIs + content APIs + bookmarks). | `oauth.py`, `content.py`, `bookmarks.py`, `reciter_map.py`, `session.py` (signed `qf_session` cookie), `config.py` β€” see [Quran Foundation integration](#quran-foundation-integration) |
## Routes registry
Blueprints register in `routes/__init__.py::register_blueprints`. Subpackage `__init__.py` files are empty; each module defines its own `Blueprint`.
| URL prefix | Blueprint | Module | Purpose |
|---|---|---|---|
| `/healthz`, `/readyz` | `health` | `routes/auth/health.py` | Liveness/readiness; reports SQLite + bucket-mount status (503 when degraded) |
| `/api` (`/api/auth/*`) | `auth` | `routes/auth/auth.py` | HF OAuth login/callback/logout; sets `inspector_session` identity cookie |
| `/api/dev` | `dev` | `routes/auth/dev.py` | Dev-only: flip `inspector_dev_role` cookie (404 when dev mode off) |
| `/api` | `claims` | `routes/claims/claims.py` | Claim/release/mark/unmark + `/api/reciter-task/<slug>` (via `state.transition`) |
| `/api` | `requests` | `routes/claims/requests.py` | Request flow β€” user submit + admin review/reject/undiscard |
| `/api/public` | `public` | `routes/public/public.py` | Public read-only dashboard data (identity-redacted) |
| `/api/static` | `static_data` | `routes/public/static.py` | Static projections (`catalog.json`, `quran-refs.json`, …) for the FE |
| `/api/admin/access` | `access_admin` | `routes/admin/access.py` | Role grant/revoke/update (mirrors `services/access.py`) |
| `/api/admin` | `admin_actions` | `routes/admin/actions.py` | Admin override transitions (force-release, reassign, force-set-state, send-back) |
| `/api/public/activity` | `public_activity_admin` | `routes/admin/activity.py` | Owner-only DELETE β€” global tombstone for a public-feed card |
| `/api/admin` | `admin_reconcile` | `routes/admin/reconcile.py` | Manual trigger for the auto-detect reconciler |
| `/api/ts` | `ts` | `routes/timestamps/timestamps.py` | Timestamps tab: `/manifest`, `/shard/<reciter>/<chapter>`, `/config` |
| `/api/seg` | `seg_data` | `routes/segments/data.py` | Segments tab read-only data (`/all`, refs, …) |
| `/api/seg` | `seg_edit` | `routes/segments/edit.py` | Segments save/undo (`@require_same_origin` β†’ `@require_edit_lock`) |
| `/api/seg` | `seg_val` | `routes/segments/validation.py` | Validate, stats, edit-history |
| `/api/seg` | `peaks` | `routes/segments/peaks.py` | `/peaks`, `/segment-peaks`, `/history-peaks` |
| `/api/seg` | `audio_proxy` | `routes/audio/proxy.py` | `/audio-proxy/<reciter>` β€” bucket-resident audio; CDN **stream-through** fallback (same-origin 200/206 + ACAO, not a 302) |
| `/api/audio` | `audio_meta` | `routes/audio/metadata.py` | Audio tab metadata |
| `/api/seg` | `segment_clip` | `routes/audio/clip.py` | `/segment-clip` β€” ffmpeg MP3 window clip (VBR-safe seek) |
| `/api/qf` | `qf_auth` | `routes/qf_auth.py` | Quran.Foundation OAuth2 (pre-prod user APIs) |
| `/api/qf/content` | `qf_content` | `routes/qf_content.py` | QF Content-API read proxy (word-by-word translations) |
| `/api/bookmarks` | `bookmarks` | `routes/bookmarks.py` | QF bookmarks proxy (`[]` for anonymous) |
| `/` + static | (Flask static) | `app.py` | SPA shell `frontend/dist/index.html`; `/api/surah-info` cross-tab route |
`_admin_helpers.py` (package root, not a blueprint) is shared by `admin/` + `claims/` routes.
## domain / adapters / utils
`inspector/domain/` β€” pure model, no Flask, no IO:
| File | Role |
|---|---|
| `segment.py` | Frozen `Segment` dataclass β€” one aligned segment as stored in `detailed.json` |
| `command.py` | `SegmentPatch` β€” change set produced by a command; undo applies its inverse |
| `identity.py` | Deterministic `segment_uid` (uuid5 over `NAMESPACE_INSPECTOR`); must match the TS impl |
`inspector/adapters/` β€” JSON ↔ domain conversion:
| File | Role |
|---|---|
| `detailed_json.py` | `detailed.json` entries list ↔ segment dicts with backfilled UIDs (`load_entries`) |
| `segments_json.py` | Rebuild verse-aggregated `segments.json` from detailed entries |
| `save_payload.py` | Incoming save payload β†’ canonical segment dicts (`make_seg`) |
`inspector/utils/` β€” pure utilities + cross-cutting decorators:
| File | Role |
|---|---|
| `decorators.py` | `require_same_origin` (CSRF), `require_edit_lock` (signed-in + active claim + editable, `admin_bypass`) |
| `json_response.py` | `orjson_response` β€” fast JSON for big-payload routes |
| `arabic_text.py` | Strip Quranic decoration, last-letter extraction |
| `references.py` | Reference-string parsing + segment classification helpers |
| `repetitions.py` | Repetition-segment helpers over `wrap_word_ranges` |
| `formatting.py` | Display formatting |
| `io.py` | Atomic writes + safe filenames |
| `uuid7.py` | UUIDv7 (time-ordered) generator |
## app.py boot sequence
Runs at module import (so `python3 inspector/app.py` and `gunicorn inspector.app:app` follow the same path):
1. Insert repo root on `sys.path` (resolves `qua_shared.*`).
2. `_load_dotenv_for_local_dev()` β€” hydrate env from `<repo>/.env` then `inspector/.env` (process env wins).
3. Auto-enable `INSPECTOR_DEV_MODE=1` when not behind proxy and not under pytest.
4. `auto_mount()` β€” local hf-mount FUSE of the bucket (silent degrade to API path).
5. `_configure_logging()` β€” plain single-line formatter; silence httpx/urllib3/hf noise.
6. `_assert_single_worker()` β€” abort on any `-w >1` / `WEB_CONCURRENCY` / `GUNICORN_*` signal.
7. Build `Flask(static_folder=frontend/dist)`; wrap with `Compress` (gzip/brotli).
8. `ProxyFix` if `INSPECTOR_BEHIND_PROXY=1`; set session cookie SameSite/Secure accordingly.
9. `app.secret_key = get_session_secret()` (warns, doesn't crash, if missing).
10. `auth_service.init_oauth(app)` β€” register HF OAuth provider with Authlib.
11. `register_blueprints(app)` β€” all route blueprints.
12. `_boot_substrate()` (skipped under pytest): `sync.pull()` bucket DB β†’ local β†’ `init_db()` (open writer + run migrations) β†’ `auto_detect.hydrate_initial_seen()` boot-scan inside `deferred_sync()` (one coalesced upload). Then the opt-in auto-detect loop (`INSPECTOR_AUTO_DETECT=1`). Deployed-mode init failure leaves app importable with `db_open:false`; local failure raises.
13. Register error handlers (`HTTPException`, `UnknownReciter`, `InvalidTransition`, `NotAuthorizedForTransition`, catch-all) β†’ `{error: …}` envelope.
14. Register `/` (SPA shell) + `/api/surah-info`.
15. `if __name__ == "__main__"`: parse `--port`, run dev server (debug/reloader only under `FLASK_ENV=development`).
No eager warms of reciter data β€” timestamps + per-reciter caches are lazy on first request.
## Caching
All mutable cache vars live in `services/storage/cache.py` (`services.cache`); no `global` for caches elsewhere. Classes: `_SingletonCache` (one nullable value), `_KeyedCache` (LRU dict, ceiling `_KEYED_CACHE_LRU_MAX=20`).
- **Eager (singletons, immutable post-boot):** word counts, single-word verses, QPC/DK data, surah-info-lite β€” filled on first use, never invalidated.
- **Lazy per-reciter (LRU-bounded):** segments, seg meta/verses, probe-v2, auto-split, pipeline-meta, history batches, split-group index, validate/stats results, audio manifest.
- **db_seq-keyed:** `public_reciters` and `catalog_snapshot` cache on the monotonic `current_db_seq()` β€” any committed DB write bumps the seq and transparently invalidates them (no per-mutation hook).
- **Peaks:** `_PEAKS_RESPONSE_CACHE` (global LRU, 50 entries, serialized JSON bytes), thread-safe with explicit locks.
Invalidation hook: `invalidate_seg_caches(reciter)` runs on every save/undo. The surgical variant `pop_seg_caches_affected_by_segment_edit` preserves append-in-place caches (history batches, split-group index) and immutable ones (pipeline-meta). The chapter-peaks LRU (`_PEAKS_RESPONSE_CACHE`) is **deliberately NOT** evicted on save β€” peaks track immutable audio bytes, not segment edits; it sheds only via its own 50-entry LRU and an explicit `pop_reciter_peaks_response_cache` wherever a future path rewrites bucket peaks. Add a new cache here with its invalidation tied to the mutation that dirties it.
## Quran Foundation integration
`services/quran_foundation/` integrates the Quran.Foundation (QF) APIs. No standalone doc β€” this is the reference.
Backend-proxy pattern: per-user OAuth2 tokens stay server-side in a signed `qf_session` cookie; the browser never sees them. Outbound QF calls inject `x-auth-token` + `x-client-id`. Two-environment split (never mix tokens): **User APIs** (bookmarks) use the pre-prod client + `apis-prelive` base; **Content APIs** use the prod client (`client_credentials`, HTTP Basic). All modules Flask-free; only `routes/qf_*` + `routes/bookmarks.py` import Flask.
| File | Role |
|---|---|
| `oauth.py` | authorization_code + PKCE helpers (pre-prod); token endpoint requires `client_secret_basic` |
| `content.py` | Content API (`client_credentials`) β€” full-surah audio URLs, word-by-word translations |
| `bookmarks.py` | Bookmarks proxy β†’ QF `/auth/v1/bookmarks` + dev in-memory store; normalizes to `{surah, ayah, key}` |
| `reciter_map.py` | `(reciter_id, style)` β†’ QF chapter-reciter id for verified reciters |
| `session.py` | Signed `qf_session` cookie holding the per-user token server-side (itsdangerous) |
| `config.py` | Env-driven endpoints + credentials (pre-prod OAuth issuer, user-API base, auth method) |
Routes: `routes/qf_auth.py` (`/api/qf` β€” OAuth2 login/callback + dev-stub login), `routes/qf_content.py` (`/api/qf/content` β€” read-only word-by-word for the Timestamps Analysis view), `routes/bookmarks.py` (`/api/bookmarks` β€” returns `[]` for anonymous so the FE silently stays app-local). QF caches (content token, chapter URLs, token cooldown, WBW translations) live in `services/storage/cache.py`.
## Reference index β€” which sibling doc per task
| Doc | When to read |
|---|---|
| `database.md` | Working on the SQLite substrate β€” schema, migrations, repos, WAL/sync, CAS |
| `state-machine.md` | Lifecycle states, events, `transition()` handlers, admin overrides |
| `auth-permissions.md` | OAuth, identity cookie, roles, predicates, edit-lock/CSRF gating |
| `catalog.md` | Reciter taxonomy, `ReciterCatalog`, audio URL templates, accept flow |
| `segments-editor.md` | Save/undo flow, segment domain model, adapters, auto-split |
| `validation.md` | Validation registry, classifier categories, accordion lockstep with FE |
| `frontend.md` | Svelte 5 SPA β€” stores, tabs (dashboard/timestamps/segments), editGate |
| `accordion-guides.md` | Per-category guide copy for the validation accordion |
| `config-deploy.md` | Env vars, runtime profiles (dev/dev-Space/prod), HF Space deploy |
| `data-migrations.md` | Schema/data migration procedures, backfill scripts, drift checks |