Files
movies-diary/docs/adr/0010-conservative-live-insights.md
Gabriel Kaszewski 020fbb891e
Some checks failed
CI / Check / Test (push) Failing after 9m16s
feat: always-on Insights — correlation analysis over viewing habits
Conservative, live insights (ADR-0010) derived from each user's diary:
rating lift by genre (first-time watches only), genre seasonality vs a
uniform 1/12 month baseline, medium weekend-lift, and watch-time rhythm.

- domain: WatchRow model, InsightsQuery port, pure insights_analyzer with
  all honesty thresholds (sample minimums, strength cutoffs, suppressions
  for import noise), unit-tested
- adapters: thin Sqlite/Postgres fetch twins, reusing WrapUp's genre fetch
- application: get_insights use case, InsightsGroup in Deps
- composition/server: wiring through Repositories/DatabaseOutput
- presentation: GET /api/v1/insights handler, typed serde-tagged DTOs,
  openapi doc
- spa: /insights route (shadcn cards), insights feature hook, profile
  entry link, en locale strings
- server test: end-to-end seed sqlite → HTTP → rating lift + rhythm

Also records ADR-0010, defines "Insight" in the glossary, and includes
pre-existing worktree comment-stripping cleanups.
2026-10-12 19:29:54 +02:00

4.0 KiB
Raw Blame History

Insights are live, conservative, and computed by a pure analyzer over denormalized rows

Insights (a new always-on surface, distinct from WrapUp and from plain UserTrends) analyse a user's diary for behavioural patterns — rating lift by genre, genre seasonality, medium timing, watch-time rhythm. Every insight compares the user against their own baseline rather than reporting a ranking.

Decision

  • Live, not precomputed. Unlike WrapUp (async worker, stored report_json), Insights are computed on every request. The underlying query is one join over reviews + movies + genus tables, the same cost class as existing StatsRepository aggregates; a materialization layer is only earned if diaries grow provably too large for that.
  • Honesty by suppression, not disclaimers. Insights are conservative: any candidate below sample-size minimums (≥3–5 ratings per genre for rating lift, ≥15–20 rows for seasonality, ≥5 rows per medium bucket) or strength thresholds (+0.3 stars, 1.5×) is silently not shown, and whole categories collapse when their inputs are unreliable (>~30% NULL watch_medium;

    50% of rows at exactly 00:00, which betrays imported CSVs that carry no real time). An empty-insights page is the correct rendering of a small or noisy diary — the UI shows an empty state, not filler. This is deliberate; do not "fix" it by lowering thresholds or adding hedges.

  • Fetch rows, then analyze in the domain. Rejected the StatsRepository pattern (aggregation in adapter SQL) as the primary carrier of insight logic: thresholds and baseline comparisons would hide inside SQL, untestable and duplicated per database. Instead the port InsightsQuery::get_watch_rows() -> Vec<WatchRow> fetches one denormalized row per review (genres, medium, watched time), analogous to WrapUpStatsQuery::get_reviews_with_profiles but without WrapUp's report-era fields. A pure analyzer insights_analyzer::build_insights(rows) -> Vec<Insight> owns every threshold and comparison, unit-tested without a database. Rewatch detection also lives in the analyzer — grouping rows by movie and keeping the earliest, exactly like wrapup_analyzer::compute_rewatch_stats — so there is one rewatch definition per analyzer and the adapters stay shapeless "rows in, rows out". Rating lift uses first-time entries only (rewatches inflate genre ratings by choice, not by property), while seasonality and timing count every entry, rewatch or not.
  • Multi-genre rows are counted per genre, unnormalized. A review of a movie with three genres contributes to all three genre distributions. Genre insights are frequency comparisons, not probabilities over reviews.
  • Private surface for now. Insights render only on the user's own page (/insights, own data). They expose habits (late-night watching, mediums), which are more sensitive than the taste statistics already public on profiles; sharing is an opt-in feature to design later, not a default.

Considered Options

  • SQL aggregation in adapters (stats pattern) — rejected for the primary logic path: threshold rules would fragment into per-database SQL and resist unit testing; SQL still wins where it is just "rows in, rows out".
  • Insights as WrapUp sections — rejected: WrapUp is a dated snapshot, insights decay/shift with every logged review and belong to a live surface; whether to feed selected insights into future WrapUps is a later product decision.
  • Rewatch detection in adapter SQL — rejected: it would fork the rewatch definition already applied by wrapup_analyzer::compute_rewatch_stats; the analyzer groups by movie and keeps the earliest review, same as WrapUp.
  • Generic Insight { title, description } text payload — rejected: typed serde-tagged variants (RatingLift, Seasonality, MediumTiming, Rhythm, each carrying sample_count) let the SPA render data-backed cards, show "why nothing here yet", and stay stable across copy changes.