Spaces:
Running
Running
| # Timestamps reports | |
| Reader-facing issue reporting on the **Timestamps** tab: a visitor (anonymous or | |
| signed-in) flags a problem on the verse they're hearing, an owner resolves it, | |
| and everyone sees persisted flags in the analysis grid. Two surfaces: | |
| - **Drop-up** (footer Report button) β pick a category, in order `timing` β | |
| `tajweed` β `phonemes` β `silence` β `audio` β `other`. `audio` / `other` open an | |
| inline comment composer (verse-level); `timing` / `tajweed` / `phonemes` / | |
| `silence` enter report mode (`tajweed` + `silence` expand to subtype rows first). | |
| - **Report mode** β an in-grid mode that replaces the waveform with a control | |
| strip and turns the analysis grid into the click surface, so a contributor | |
| annotates specific cells without a modal blocking them. | |
| Backed by one SQLite table; the per-reciter shard content is never touched. | |
| ## Data model β table `ts_reports` | |
| Migrations: `0025_ts_reports.sql` (table), `0026_ts_reports_hidden.sql` | |
| (`hidden_at` soft delete), `0027_ts_reports_rule_tags.sql` (`selected_rule_tags`). | |
| The legacy `ts_verse_flags` table (`0024`) is superseded and left in place | |
| pending a manual row migration. | |
| One row per `(slug, verse_key, category, target_key, identity)` β identity is | |
| EITHER a signed-in `hf_user_id` OR an anonymous browser `anon_token` (exactly | |
| one). Re-filing the same category+target updates in place (upsert). Key columns: | |
| | Column | Meaning | | |
| |---|---| | |
| | `category` | `audio` Β· `timing` Β· `tajweed` Β· `phonemes` Β· `silence` Β· `other` | | |
| | `subtype` | tajweed: `wrong_rule\|missing_rule`; silence: `pause_boundary\|pause_wasl\|pause_missed`; else NULL | | |
| | `timing_onset` / `timing_offset` | timing (and silence `pause_boundary`): each `early\|late\|NULL` (NULL = that boundary is fine), β₯1 set. The human label (too short/long, shifted, starts/finishes early/late) is derived via `qua_shared...ts_reports.timing_label()` β never stored | | |
| | `target_kind` + `word_index` / `source_letter_index` / `cell_index` / `phoneme_flat_index` / `share_group` | the flexible target descriptor (`verse\|word\|cell\|phoneme\|cell_group\|gap`). `phonemes` reports only target `phoneme`. **`silence` reports target a `gap`** β the word-boundary between `word_index` and `word_index+1`, keyed on the preceding word (only `word_index` is set). A cross-word merger/bridge phoneme has `phoneme_flat_index = -1` (see Bridge phonemes) | | |
| | `target_key` | canonical descriptor string (`kind:wi:sli:ci:pi:sg`, **plus `:subtype` for tajweed only**) the per-identity unique index keys on β built in `repo_ts_reports.target_key()`. Timing/phonemes/**silence** are subtype-free in the key β one report per target+identity (so a gap holds **one** silence stance per user β last write wins) | | |
| | `snap_*` | denormalized snapshot of the targeted shard content at create time β the drift fingerprint (no per-cell hash). Includes `snap_onset_ms`/`snap_offset_ms`, the target's boundary ms for timing + silence-gap staleness | | |
| | `selected_rule_tags` | JSON: the internal tajweed tag id(s) the reporter marked wrong (`wrong_rule` only) | | |
| | `comment` | mandatory for `audio`/`other` + every tajweed; optional for timing; never for phonemes or silence | | |
| | `status` / `resolved_*` | single terminal `resolved` outcome + optional owner note | | |
| | `stale` / `stale_at` | set when a shard regen changed the targeted content (see Staleness below) | | |
| | `hidden_at` | soft-delete stamp β NULL = visible. Every read filters `hidden_at IS NULL`; re-filing un-hides | | |
| ### Grouping (a domain concept, no schema column) | |
| - **Timing** and **phonemes** reports group by **word**: every cell/phoneme | |
| flagged in one `(slug, verse_key, word_index, category, identity)` is ONE logical | |
| report β one owner notification, resolved as a unit via the | |
| `/word/<wi>/<category>/resolve` endpoint. Computed from existing columns via | |
| `repo_ts_reports.word_group_key()`; never a stored `group_key`. The route | |
| word-grouped set is `_WORD_GROUPED = {timing, phonemes}`. | |
| - **Tajweed** reports are **per cell PER subtype** β the same cell can carry both | |
| a `wrong_rule` and a `missing_rule` report (two rows, two notifications). The | |
| `subtype` rides in `target_key` for tajweed only, so same cell + same subtype | |
| still upserts. No SQL migration: the unique index is unchanged; the key string | |
| carries the distinction. | |
| - **Silence** reports are **per gap** (one stance per boundary per identity) and | |
| each fires its own owner notification (the batch `else` branch, one-per-gap). The | |
| three subtypes share the subtype-free gap `target_key`, so switching subtype on | |
| the same gap upserts. | |
| Rows stay per-cell either way (so per-cell subtype + comment survive); grouping | |
| lives only in the notify/resolve/display layers. | |
| ### Bridge phonemes (cross-word mergers) | |
| A cross-word merger (idgham / iltiqaa) phoneme sits in the gap between two words. | |
| The FE renderer stamps it with `phoneme_flat_index = -1` and `word_index` = the | |
| word it renders **before** (the following word for a cross-word idgham). Since at | |
| most one bridge renders before any word, `(word_index, -1)` is already a unique | |
| `kind='phoneme'` key β no schema change. Such a report groups under that following | |
| word. The snapshot resolver (`_bridge_phone_for_target`) re-finds the merger phone | |
| by walking each word's `phones[]` for a row carrying a `bridge` rule (slot 5), | |
| mapping it to `wi` when it is the word's first phone else `wi+1` (the iltiqaa-kasra | |
| connector is found via its cell tag on the preceding word). | |
| ### Staleness (per-category, on shard regen) | |
| `recheck_reports_staleness` re-resolves every open report against the new shard and | |
| `mark_stale`s only those whose **category-relevant** content changed (`audio` never | |
| stales). For **timing**, that is the targeted cell's identity (chars/role) changing | |
| OR a boundary the report flagged moving: if `onset` is set and the target's start ms | |
| shifted by more than `config.TS_REPORT_BOUNDARY_STALE_MS` (default 100), or likewise | |
| `offset` and the end ms β staled for owner re-check (NOT auto-resolved). A pure ms | |
| shift on a boundary the report did **not** flag does not stale it. The boundary ms | |
| are captured at create in `snap_onset_ms`/`snap_offset_ms`. For **phonemes**, the | |
| targeted phone's identity (`chars` / `role` / rule `tag`, incl. a bridge's merger | |
| rule) changing β or the phone vanishing β stales it. | |
| **Silence reports don't just stale, they AUTO-RESOLVE** when the regen confirms | |
| them (`_silence_action`, gap-present = `offset_ms > onset_ms` from the gap | |
| snapshot): a `pause_missed` resolves once a gap appears at its boundary, a | |
| `pause_wasl` (and a `pause_boundary` whose gap vanished entirely) once the gap is | |
| gone. A `pause_boundary` whose gap remains stales on a flagged-boundary shift past | |
| the threshold (like timing). A vanished boundary (word structure changed) stales for | |
| owner re-check. Auto-resolve goes through `repo_ts_reports.resolve_auto` (system | |
| resolver, `resolved_by_*` NULL, the reason in `resolver_comment`) and notifies | |
| **both** owners and the (signed-in) reporter via `notify_ts_report_auto_resolved` β | |
| all inside the regen transaction (`durable_transaction` is nesting-safe). | |
| ## Visibility | |
| `timing` grid flags + the verse-level `audio`/`other` reports are | |
| **public** β returned to every viewer. `silence` gap flags are **public** too. `tajweed` + `phonemes` flags are | |
| **non-public**: the repo (`_visibility_filter`, applied in `verse_counts` + | |
| `list_for_verse`) returns them only to the reporter (matched by `hf_user_id` or | |
| `anon_token`) or to a caller holding `timestamps.view_nonpublic_reports` | |
| (maintainer + owner by default). Non-holders never receive those rows or their | |
| counts (true hide, not just un-rendered) β so the FE simply renders what it gets. | |
| The read endpoints pass an **existing** anon token (never minting one β see | |
| `peekAnonToken`) so an anonymous reporter still sees their own non-public flags. | |
| ## Backend | |
| | File | What | | |
| |---|---| | |
| | `inspector/services/db/repo_ts_reports.py` | `create` (upsert), `create_many` (batch, one identity/verse/txn), `resolve` (per id), `resolve_group` (timing/phoneme word-group, all identities), `resolve_auto` (system-resolve a silence report on regen), `delete` (soft), `verse_counts` + `list_for_verse` (both take the viewer ctx + `_visibility_filter`), `list_open_for_recheck`, `word_group_key` | | |
| | `inspector/routes/timestamps/reports.py` | thin blueprint over the repo + snapshot + notify (see endpoints below) | | |
| | `inspector/services/ts_reports/ts_target_snapshot.py` | `build_snapshot` (resolve a target β snapshot dict, incl. the `gap` kind), `_silence_action` (silence resolve/stale decision), `recheck_reports_staleness` (post-regen: re-resolve open reports β `mark_stale` the changed, auto-resolve + notify the agreed silence ones; `audio` never stales) | | |
| | `inspector/services/notifications/emit.py` | `notify_owners_ts_report` (optional `source_key` for word-group coalescing), `notify_reporter_ts_report_resolved`, `notify_ts_report_auto_resolved` (reporter + owners, regen auto-resolve) | | |
| | `qua_shared/schemas/wire/ts_reports.py` | wire models + `_validate_report_item` (shared single + batch validation) | | |
| β οΈ The package is `services/ts_reports/` (NOT `services/timestamps/`, which would | |
| collide with the `services.timestamps` attribute bound in `services/__init__.py`). | |
| ### Endpoints (`/api/ts/<slug>/reports`) | |
| | Method + path | Body β response | Gate | | |
| |---|---|---| | |
| | `GET /reports` | β `TsReciterReports` (per-verse open/resolved counts; non-public counts filtered by viewer) | public | | |
| | `GET /reports/<verse_key>` | β `TsVerseReports` (`?stale=1` owner-only; author redacted; non-public rows filtered by viewer) | public | | |
| | `POST /reports` | `TsReportCreateRequest` β `TsReport` | `timestamps.report` (anon) | | |
| | `POST /reports/batch` | `TsReportBatchCreateRequest` β `TsReportBatchResult` | `timestamps.report` | | |
| | `POST /reports/<id>/resolve` | `TsReportResolveRequest` β `TsReport` | `timestamps.resolve_report` | | |
| | `POST /reports/<verse_key>/word/<wi>/<cat>/resolve` | `TsReportResolveRequest` β `TsVerseReports` (timing/phonemes) | `timestamps.resolve_report` | | |
| | `DELETE /reports/<id>` | soft-delete the caller's own | `timestamps.report` | | |
| Batch create builds each snapshot BEFORE the transaction (shard reads out of the | |
| write window), then `_fan_batch_notifications` fires **one owner notification per | |
| new timing/phoneme word-group** (`source_key=word_group_key(...)`, coalesced by | |
| `repo_notifications.create`'s `(hf_user_id, source_key)` idempotency) and **one | |
| per new tajweed cell / silence gap**; re-submitted (`created=False`) rows never notify. | |
| Capabilities: `timestamps.report` (anon-eligible), `timestamps.resolve_report`, | |
| `timestamps.see_reporter_identity`, `timestamps.view_stale_reports`, | |
| `timestamps.view_nonpublic_reports` (maintainer-default; gates seeing tajweed + | |
| phoneme flags) β registered in `qua_shared/schemas/config/capabilities.py`. | |
| ## Frontend (`inspector/frontend/src/tabs/timestamps/`) | |
| | File | What | | |
| |---|---| | |
| | `stores/report-mode.ts` | the mode state machine (`inactive` / `timing` / `tajweed` + subtype / `phonemes` / `silence` + subtype), `staged` Map keyed by cell, `focusedCellKey`, `reportContext`; `enterTiming` / `enterTajweed` / `enterSilence` pause + force letters-only (snapshot/restore `showLetters`/`showPhonemes`), tajweed additionally `forceAllTajweedEnabled`; `enterPhonemes` instead forces `showPhonemes` ON and leaves `showLetters` at the user's setting (the inverse). Seed own flags by category+subtype; a `StagedPhoneme` carries just the target and a `StagedSilence` carries the gap + (for `pause_boundary`) onset/offset β both complete on select (binary silence subtypes too); `focusCell`/`isStagedComplete` auto-discard an incomplete cell on focus-move; `exitReportMode` restores display + tajweed snapshots, `exitLoop`, clears | | |
| | `stores/tajweed-settings.ts` | `forceAllTajweedEnabled()` / `restoreTajweedSettings()` β transient (non-persisted) bulk enable used by tajweed report mode | | |
| | `stores/ts-reports.ts` | `reportedVerses` (reciter counts β button highlight) + `currentVerseReports` / `loadVerseReports` (the focus verse's reports β in-grid public flags + report-mode seeds) | | |
| | `utils/report-target.ts` | the ONE keying place β `cellKey`/`wordKey`/`gapKey`/`targetCellKey` (DOM- and wire-derived keys must agree), `cellTargetFromEl`, `elCellKey`, `elHasTajweed`. A gap tile (pause bridge / missed slot) carries `data-gap-word-index` β `gapKey` | | |
| | `utils/rendered-blocks.ts` | builds the analysis grid β `RenderedPauseBridge.fromWordIndex` (the gap's preceding word) + per-`RenderedUnit` `gapWordIndex`/`missedMark` for the **missed-pause slot** at each contiguous word boundary (`splitWaqf` lifts the preceding word's stop sign, else `\|\|`) | | |
| | `utils/cell-model.ts` | threads `cellIndex` (raw `word.cells[]` index = the target's `cell_index`) and `ruleTags` (internal tajweed tag ids = the picker's options) onto rendered cells | | |
| | `components/UnifiedDisplay.svelte` | stamps `data-cell-index`/`-source-letter-index`/`-share-group`/`-has-tj`/`-tj-tags`/`-phoneme-flat-index`/`-gap-word-index` (pause bridges + missed slots); a delegated capture-phase click that STAGES via `focusCell` (auto-discard) instead of seeking. Timing + **silence** loop/seek (silence loops the two words straddling the gap); tajweed/phonemes stage only. Phonemes mode is a multi-select **toggle**. **Silence** spotlights the gap tiles: `pause_boundary`/`pause_wasl` target the existing `.pause-bridge`, `pause_missed` targets the inserted `.missed-slot`; everything else dims (`report-dim` on words + off-type gaps) + inerts. Reactive `report-*` passes (mode-scoped `report-timing`/`-tajweed`/`-phonemes`/`-silence`/`-missed`/`-existing`; staged/focused/public flags). A publicly-flagged missed slot gets a native "Reported missing pause" title. `data-has-tj='1'` β the cell carries a `ruleTag` (pickable rule) OR a badge/silent name | | |
| | `components/TimestampsFooterReport.svelte` + `report/ReportMenu.svelte` + `ReportComposer.svelte` | the drop-up: category list, inline audio/other composer (fixed-height field so it never reflows the drop-up), and `onenterMode('timing'\|'tajweed'\|'phonemes'\|'silence', subtype?)` β report mode (timing/phonemes enter directly; tajweed + silence expand subtype rows) | | |
| | `components/report/ReportControlStrip.svelte` | the strip that replaces the waveform β header (title + static subtype label), Cancel + Submit, and ONE inline row per staged cell (`label Β· control Β· comment Β· β`). Phonemes collapse to ONE row per word of removable chips. **Silence** rows: `pause_boundary` reuses the Start/End onset/offset axes + derived label; `pause_wasl` / `pause_missed` are binary (a static stance label, no control, no comment) | | |
| | `services/report-submit.ts` + `reports-client.ts` | build `TsReportBatchCreateRequest` from staged + reconcile removed own reports (`deleteReport`); the fetch client | | |
| Mount: `TimestampsTab.svelte` swaps `<TimestampsWaveform>` β `<ReportControlStrip>` | |
| on `$reportModeActive` in `.waveform-words-row`, and `loadVerseReports` on every | |
| verse change (also exits an active session if the verse moves β report mode is | |
| verse-scoped). | |
| ### Flag rendering | |
| The `report-*` classes are toggled **imperatively on a cached node list from a | |
| reactive one-shot, never inside the 60fps `updateHighlights()`** (the disjoint | |
| class names keep the two off each other). The spotlight dims + inerts | |
| (`report-dim` opacity + `report-inert` `pointer-events:none`, killing click AND | |
| hover tooltip) the cells that can't carry the current report: in tajweed | |
| `wrong_rule`, every cell with `data-has-tj!='1'` (no rule); in **timing**, every | |
| cell with `data-cell-timed!='1'` (a silent letter has no duration to call | |
| too-long/short β words stay live); in **phonemes**, every `[data-cell-index]` | |
| letter/diacritic cell (only `.mega-phoneme` spans stay live). So only rule-bearing | |
| cells (tajweed), timed letters (timing), or phoneme spans (phonemes) stay | |
| interactive. Because flag rows render only what the backend returns, non-public | |
| tajweed/phoneme flags simply don't appear for non-reporters (no FE gate needed). The `wrong_rule` rule-picker offers | |
| only **labelable** tags β sentinels like `silent_unclassified` are dropped | |
| (`ruleHasLabel`), so it never shows a raw tag id; the cell's true rule name still | |
| shows on the grid hover tooltip via `data-tj-rules`. `report-flag-staged` / `report-flag-public` draw a red `outline` ring | |
| (outline, not box-shadow, so it never collides with the tajweed underline and | |
| never reflows); `report-focused` adds the accent ring. Styles live in | |
| `styles/timestamps.css`; the tooltip line is appended by `_tipTextFor`. | |
| ## Invariants / gotchas | |
| - **Verse lock for the whole session.** Entering report mode pauses playback and | |
| `TimestampsTab.armVerseLock()` arms a whole-verse loop (`[verse start, next | |
| verse start)` β covers the trailing silence). That pin stops free play from | |
| auto-advancing and suppresses shuffle (`maybeFireShuffle` also bails on | |
| `reportModeActive`). Selecting a **timing** cell narrows the loop to that cell, | |
| and selecting a **silence** gap loops the two words straddling it (audio context); | |
| selecting a **tajweed** or **phonemes** cell does NOT touch the loop or play/pause | |
| (you judge a rule/phoneme against the running verse, not an isolated cell). Only a | |
| **manual** ayah/reciter change moves the focus verse β `_syncVerseReports` exits | |
| the session + discards staged. `exitReportMode` clears the loop. | |
| - **Auto-discard incomplete.** Moving focus to another cell drops the previously | |
| focused annotation when it is still missing a required field (timing subtype / | |
| tajweed rule pick when >1 option / mandatory comment); `report-submit` re-filters | |
| defensively. No hard block β keeps flagging frictionless. | |
| - **Subtype is fixed at entry.** The drop-up's `wrong_rule` / `missing_rule` rows | |
| enter the session with that subtype; the strip shows it as a static label (no | |
| in-mode toggle). A different subtype on the same cell is a separate session + | |
| separate report. | |
| - **`cell_index` is the raw `word.cells[]` index** (matches backend | |
| `word_cells(word)`), captured before the hamza-waαΉ£l transform; synthesized | |
| cells fall back to `source_letter_index`. | |
| - **Display forcing differs by mode.** `timing` + `tajweed` + `silence` force | |
| letters-only (the click surface is cells / gap tiles, not phonemes) β both footer | |
| toggles are locked. `phonemes` mode forces the phoneme row ON and leaves the | |
| letters row at the user's setting, so the letters toggle stays operable while the | |
| phonemes toggle is locked on. | |
| - **Missed-pause slots are always in the DOM, invisible until needed.** A | |
| `.missed-slot` renders at every contiguous word boundary but is `display:none` at | |
| rest; it's revealed (spotlit, clickable) only in `pause_missed` report mode, or | |
| shown with a red ring + native title for ALL viewers when publicly flagged β so the | |
| "red border in the gap with no cell there" works without re-rendering the grid. | |
| - **One key function.** DOM keys (`elCellKey`) and wire keys (`targetCellKey`) | |
| must agree or public flags won't land on the right cell. | |
| - **dev-remote can't exercise the real endpoints** (the deployed dev Space | |
| predates this) β drive the FE with Playwright route stubs; the real flow is | |
| covered by the backend tests. | |
| ## Tests | |
| - `inspector/tests/db/test_repo_ts_reports.py` β create/upsert/counts, soft-delete | |
| + re-file un-hide, `create_many` word-grouping (timing + phonemes), | |
| `resolve_group`, bridge-phoneme target uniqueness, visibility filtering of | |
| non-public counts + rows, silence one-stance-per-gap upsert + public visibility + | |
| `resolve_auto`. | |
| - `inspector/tests/routes/test_route_ts_reports.py` β gating, redaction, batch | |
| notification counts (one per timing/phoneme word, one per tajweed cell), group | |
| resolve (timing + phonemes), non-public visibility (hidden from other viewers, | |
| shown to the reporter + a capability holder). | |
| - `inspector/tests/services/test_ts_target_snapshot.py` β phoneme + bridge-phoneme | |
| resolution, per-category staleness, and the silence gap resolution + auto-resolve | |
| decision matrix (`_silence_action`). | |
| - `qua_shared/tests/test_ts_reports_wire.py` β per-category validators (incl. | |
| phonemes + silence/gap), batch models, `selected_rule_tags` gate, single/batch | |
| validation parity. | |