feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s
Some checks failed
CI / Check / Test (push) Failing after 9m16s
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.
This commit is contained in:
62
docs/adr/0010-conservative-live-insights.md
Normal file
62
docs/adr/0010-conservative-live-insights.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user