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

@@ -12,9 +12,8 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{
EntityType, ExternalPersonId, GeneratedToken, IndexableDocument, MetadataSearchCriteria,
Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults,
User,
EntityType, ExternalPersonId, IndexableDocument, MetadataSearchCriteria, Movie, Person,
PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User,
},
ports::{
AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand,
@@ -27,11 +26,12 @@ use presentation::context::AppContext;
use presentation::{routes, state::AppState};
use rss::RssAdapter;
use sqlite::{
SqliteDiaryRepository, SqliteMovieRepository, SqliteReviewRepository, SqliteStatsRepository,
migrate as sqlite_migrate,
SqliteDiaryRepository, SqliteInsightsQuery, SqliteMovieRepository, SqliteReviewRepository,
SqliteStatsRepository, migrate as sqlite_migrate,
};
use sqlx::SqlitePool;
use tower::ServiceExt;
use uuid::Uuid;
struct NoopEventPublisher;
#[async_trait]
@@ -106,17 +106,6 @@ impl PasswordHasher for PanicHasher {
}
}
struct PanicAuth;
#[async_trait]
impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
}
struct NobodyUserRepo;
#[async_trait]
impl UserRepository for NobodyUserRepo {
@@ -401,6 +390,10 @@ impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist {
}
}
async fn test_app() -> Router {
test_app_with(domain::testing::FakeAuthService).await.0
}
async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("in-memory SQLite failed");
@@ -432,6 +425,7 @@ async fn test_app() -> Router {
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _,
wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _,
insights: Arc::new(SqliteInsightsQuery::new(pool.clone())) as _,
goal_command: Arc::new(domain::testing::NoopGoalCommand),
goal_query: Arc::new(domain::testing::NoopGoalQuery),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
@@ -441,7 +435,7 @@ async fn test_app() -> Router {
};
let services = application::Services {
auth: Arc::new(PanicAuth),
auth: Arc::new(auth),
password_hasher: Arc::new(PanicHasher),
metadata: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher),
@@ -485,7 +479,8 @@ async fn test_app() -> Router {
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
};
routes::build_router(state, axum::Router::new())
let router = routes::build_router(state, axum::Router::new());
(router, pool)
}
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
@@ -625,3 +620,127 @@ async fn get_movie_detail_html_returns_404_for_unknown_id() {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
// ── Insights ─────────────────────────────────────────────────────────────────
async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) {
// 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a
// rating lift once genres come from movie_genres.
for i in 0..12u32 {
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Thriller {i}"))
.bind(2020)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(44400 + i as i64)
.bind("Thriller")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(5)
.bind("2024-01-05 22:30:00")
.bind("2024-01-05 22:30:00")
.execute(pool)
.await
.unwrap();
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(format!("Comedy {i}"))
.bind(2015)
.execute(pool)
.await
.unwrap();
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
.bind(&movie_id)
.bind(55300 + i as i64)
.bind("Comedy")
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(&movie_id)
.bind(user_id.to_string())
.bind(2)
.bind("2024-03-10 13:00:00")
.bind("2024-03-10 13:00:00")
.execute(pool)
.await
.unwrap();
}
}
#[tokio::test]
async fn get_api_insights_returns_rating_lift_for_own_user() {
let (app, pool) = test_app_with(domain::testing::FakeAuthService).await;
let user_id = Uuid::new_v4();
seed_insights_diary(&pool, user_id).await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/insights")
.header("authorization", format!("Bearer {user_id}"))
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
let (status, bytes) = (
response.status(),
response.into_body().collect().await.unwrap().to_bytes(),
);
assert_eq!(
status,
StatusCode::OK,
"body: {}",
String::from_utf8(bytes.to_vec()).unwrap()
);
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let rating_lifts: Vec<&serde_json::Value> = json["insights"]
.as_array()
.unwrap()
.iter()
.filter(|i| i["kind"] == "rating_lift")
.collect();
assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}");
let thriller = rating_lifts
.iter()
.find(|i| i["genre"] == "Thriller")
.unwrap();
assert_eq!(thriller["delta"], 1.5);
assert_eq!(thriller["sample_count"], 12);
// Timestamps are real → rhythm fires. Hours split evenly between 22
// (thrillers) and 13 (comedies); every window covering either bucket ties,
// so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted
// ascending.
let rhythm = json["insights"]
.as_array()
.unwrap()
.iter()
.find(|i| i["kind"] == "rhythm")
.expect("rhythm expected for real timestamps");
assert_eq!(rhythm["peak_hour_start"], 11);
assert_eq!(rhythm["peak_hour_end"], 14);
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
}