hetchyy's picture
deploy: inspector prod @ 2ce61c0
86bc0aa verified
|
Raw
History Blame Contribute Delete
20.5 kB

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_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 (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.