Spaces:
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/otheropen an inline comment composer (verse-level);timing/tajweed/phonemes/silenceenter report mode (tajweed+silenceexpand 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>/resolveendpoint. Computed from existing columns viarepo_ts_reports.word_group_key(); never a storedgroup_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_ruleand amissing_rulereport (two rows, two notifications). Thesubtyperides intarget_keyfor 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
elsebranch, one-per-gap). The three subtypes share the subtype-free gaptarget_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_stales 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 (maybeFireShufflealso bails onreportModeActive). 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 β_syncVerseReportsexits the session + discards staged.exitReportModeclears 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-submitre-filters defensively. No hard block β keeps flagging frictionless. - Subtype is fixed at entry. The drop-up's
wrong_rule/missing_rulerows 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_indexis the rawword.cells[]index (matches backendword_cells(word)), captured before the hamza-waαΉ£l transform; synthesized cells fall back tosource_letter_index.- Display forcing differs by mode.
timing+tajweed+silenceforce letters-only (the click surface is cells / gap tiles, not phonemes) β both footer toggles are locked.phonemesmode 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-slotrenders at every contiguous word boundary but isdisplay:noneat rest; it's revealed (spotlit, clickable) only inpause_missedreport 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_manyword-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.
- re-file un-hide,
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_tagsgate, single/batch validation parity.