Spaces:
Running
Running
| # 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 | | |