Some checks failed
CI / Check / Test (push) Has been cancelled
Reviewed-on: #22
4.0 KiB
4.0 KiB
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 overreviews+movies+ genus tables, the same cost class as existingStatsRepositoryaggregates; 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%
NULLwatch_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
StatsRepositorypattern (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 portInsightsQuery::get_watch_rows() -> Vec<WatchRow>fetches one denormalized row per review (genres, medium, watched time), analogous toWrapUpStatsQuery::get_reviews_with_profilesbut without WrapUp's report-era fields. A pure analyzerinsights_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 likewrapup_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 carryingsample_count) let the SPA render data-backed cards, show "why nothing here yet", and stay stable across copy changes.