feat: always-on Insights — correlation analysis over viewing habits
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:
2026-10-12 19:29:54 +02:00
parent 4d221e60de
commit 020fbb891e
126 changed files with 1922 additions and 639 deletions

View File

@@ -52,7 +52,6 @@ pub async fn csrf_middleware(mut req: Request, next: Next) -> Response {
response
}
/// Returns true if the form token does not match the cookie token.
pub fn mismatch(token: &CsrfToken, form_value: &str) -> bool {
token.0 != form_value || form_value.is_empty()
}

View File

@@ -0,0 +1,81 @@
use axum::{Json, extract::State};
use application::insights::get as get_insights_uc;
use domain::models::Insight;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use api_types::insights::{InsightDto, InsightsResponse};
fn to_dto(insight: Insight) -> InsightDto {
match insight {
Insight::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
} => InsightDto::RatingLift {
genre,
genre_avg,
user_avg,
delta,
sample_count,
},
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
} => InsightDto::Seasonality {
genre,
month,
multiplier,
sample_count,
},
Insight::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
} => InsightDto::MediumTiming {
medium,
multiplier,
weekend_share,
baseline_share,
sample_count,
},
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => InsightDto::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
},
}
}
/// Live insights for the authenticated user's own diary. Private surface:
/// habits (late-night watching, mediums) are more sensitive than the taste
/// statistics already public on profiles (ADR-0010).
#[utoipa::path(
get, path = "/api/v1/insights",
responses(
(status = 200, body = InsightsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_insights(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<InsightsResponse>, ApiError> {
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0).await?;
Ok(Json(InsightsResponse {
insights: insights.into_iter().map(to_dto).collect(),
}))
}

View File

@@ -4,6 +4,7 @@ pub mod goals;
mod helpers;
pub mod images;
pub mod import;
pub mod insights;
pub mod integrations;
pub mod movies;
pub mod rss;

View File

@@ -1104,9 +1104,6 @@ mod tests {
}
}
/// The bug: a local actor used to serialize as `local:{uuid}`, which
/// `identify` cannot parse, so every POST echoing this url back resolved
/// to Remote and was routed to ActivityPub.
#[test]
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");

View File

@@ -0,0 +1,8 @@
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(crate::handlers::insights::get_insights,),
components(schemas(api_types::insights::InsightsResponse, api_types::insights::InsightDto,))
)]
pub struct InsightsDoc;

View File

@@ -2,6 +2,7 @@ mod auth;
mod diary;
mod goals;
mod import;
mod insights;
mod movies;
mod search;
mod social;
@@ -46,6 +47,7 @@ fn build() -> utoipa::openapi::OpenApi {
api.merge(goals::GoalsDoc::openapi());
api.merge(webhook::WebhookDoc::openapi());
api.merge(wrapup::WrapUpDoc::openapi());
api.merge(insights::InsightsDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);

View File

@@ -460,6 +460,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report),
)
.route("/insights", routing::get(handlers::insights::get_insights))
.route(
"/admin/reindex-search",
routing::post(handlers::search::post_reindex_search),

View File

@@ -163,13 +163,6 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// Proves stub injection reaches the prebuilt deps, not just the `Repositories`
/// passed into `build_test_state_from`. Before the fix that added construct-time
/// stub injection, this failed on the assertion below — exactly the false-green
/// that would have silently broken the person-handler tests in Task 6. `AppContext`
/// no longer carries a `repos` field (Plan C2), so the original companion
/// assertion on `state.app_ctx.repos.person_query` was dropped along with the
/// field; the assertion that matters — that `deps` itself holds the stub — stays.
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
@@ -209,7 +202,6 @@ async fn get_watchlist_requires_auth() {
// --- Pending-follower-count endpoint tests ---
/// Stub `FollowGraphQuery` returning a fixed pending-follower count.
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
@@ -264,10 +256,6 @@ impl domain::ports::FollowGraphQuery for FixedPendingCount {
}
}
/// The point of Task 4: one use case (`social::count_pending_followers`) now
/// serves both the classic UI (via `get_page_viewer`) and this new HTTP
/// endpoint. Seed the query port with a fixed count and assert the endpoint
/// reflects it verbatim.
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
@@ -351,9 +339,6 @@ impl domain::ports::ApDocumentPort for FailingApDocument {
}
}
/// Swapping `ap_document` on the returned state is safe — unlike the `repos`
/// fields, handlers read `app_ctx.ap_document` directly and no prebuilt deps
/// struct carries a copy of it.
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> crate::state::AppState {
let mut state = test_app_state();

View File

@@ -1,12 +1,3 @@
/// The prebuilt deps must point at the SAME port instances as the `Repositories`
/// they were built from. If build_deps is wired to a different Arc — or is called
/// before a repo is swapped — handlers in Plan B silently talk to the wrong
/// dependency, and no other test in Plan A would catch it.
///
/// `AppContext` no longer carries a `repos` field (Plan C2 deleted it), so this
/// test builds its own `Repositories` locally, keeps a clone of the Arcs it
/// cares about, and hands the original to `build_test_state_from` — the same
/// construction `test_app_state()` uses internally.
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;

View File

@@ -16,12 +16,13 @@ use domain::{
Person, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
SearchResults, UserStats, UserTrends,
collections::{PageParams, Paginated},
insights::WatchRow,
},
ports::{
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, MetadataClient, MovieCommand,
MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
WatchlistRepository,
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, InsightsQuery, MetadataClient,
MovieCommand, MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
UserRepository, WatchlistRepository,
},
testing::InMemoryUserRepository,
value_objects::{
@@ -169,6 +170,12 @@ impl StatsRepository for Panic {
}
}
#[async_trait::async_trait]
impl InsightsQuery for Panic {
async fn get_watch_rows(&self, _: &UserId) -> Result<Vec<WatchRow>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl MetadataClient for Panic {
async fn fetch_movie_metadata(
&self,
@@ -794,6 +801,7 @@ pub(crate) fn test_repositories() -> Repositories {
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,
insights: Arc::clone(&repo) as _,
goal_command: Arc::clone(&repo) as _,
goal_query: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,