diff --git a/docs/concepts/publisering.md b/docs/concepts/publisering.md index a34742d..210d940 100644 --- a/docs/concepts/publisering.md +++ b/docs/concepts/publisering.md @@ -248,12 +248,15 @@ samtale, ikke en workflow-tilstand. ## Presentasjonselementer er noder -> **Status:** Implementert i oppgave 14.16. Backend: query-endpoint +> **Status:** Implementert i oppgave 14.16 (presentasjonselementer) og +> 14.17 (A/B-testing). Backend: query-endpoint > (`/query/presentation_elements`), rendering bruker presentasjonselementer > (title, subtitle, summary, og_image) med fallback til artikkelnoden. > Frontend: PresentationEditor-komponent integrert i PublishDialog. -> A/B-testing (automatisk rotasjon, impression-logging) er spesifisert -> men ikke implementert (planlagt oppgave 14.17). +> A/B-testing: maskinrommet roterer varianter ved forside-rendering, +> logger impressions/klikk i `ab_events`-tabell, evaluerer signifikans +> med z-test for proporsjoner, og markerer vinner/taper i edge-metadata. +> Redaktør kan overstyre via `POST /intentions/ab_override`. En ingress er en tekst. En overskrift er en tekst. Et forsidebilde er et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med @@ -301,18 +304,27 @@ Artikkel (innholdsnoden) ### Automatisk A/B-testing +> **Status:** Implementert i oppgave 14.17. + Når det finnes flere noder med samme edge-type til samme artikkel, er default å A/B-teste automatisk. -**Mekanikk:** -- Maskinrommet roterer varianter ved forside-rendering -- Logger hvilken variant som ble vist (impression) og om leseren - klikket videre til artikkelen (konvertering) -- Normaliserer CTR mot tidspunkt — klikk kl. 08 mandag morgen har - annen baseline enn kl. 22 lørdag kveld -- Etter statistisk signifikans → vinneren markeres, taperen - deaktiveres -- Redaktøren kan alltid overstyre — pin en spesifikk variant +**Mekanikk (implementert):** +- Maskinrommet roterer varianter tilfeldig ved forside-rendering + (i `fetch_index_articles_optimized` via `ab_select()`) +- Logger impressions ved forside-serve (cache hit og miss) og klikk + ved artikkelbesøk — alt asynkront (fire-and-forget) i `ab_events`-tabell +- `ab_events.hour_of_week` (0-167) lagres for tidspunkt-normalisering +- Periodisk evaluator (hvert 5. minutt) beregner CTR per variant, + kjører z-test for proporsjoner (p < 0.05), oppdaterer edge-metadata +- Etter statistisk signifikans → vinneren markeres, taperne retires +- Redaktøren kan alltid overstyre via `POST /intentions/ab_override` + +**Database:** +- `ab_events`-tabell (migrasjon 012) med `edge_id`, `article_id`, + `collection_id`, `event_type` (impression/click), `hour_of_week` +- Indekser på `(edge_id, event_type)`, `(collection_id, created_at)`, + `(article_id, edge_id)` **Edge-metadata under testing:** @@ -333,6 +345,16 @@ er default å A/B-teste automatisk. - Redaktøren trenger ikke vite at dette skjer. Skriver du én tittel får du én tittel. Skriver du to får du automatisk testing. +**Endepunkter:** +- `GET /pub/{slug}/t/{article_id}?v={edge_id}` — klikk-sporing redirect +- `POST /intentions/ab_override` — redaktør overstyrer (krever owner/admin) + +**Evaluering:** +- Minimum 100 impressions per variant før evaluering vurderes +- Z-test for proporsjoner med p < 0.05 for signifikans +- Multi-variant (>2): beste variant testes mot nest-beste +- Evaluator kjører som bakgrunns-loop (`start_ab_evaluator`) + ### Hva med podcast-episoder? Samme mønster. AI-analyse i podcastfabrikken genererer forslag til diff --git a/maskinrommet/Cargo.lock b/maskinrommet/Cargo.lock index b0b62eb..84048b7 100644 --- a/maskinrommet/Cargo.lock +++ b/maskinrommet/Cargo.lock @@ -1083,6 +1083,7 @@ dependencies = [ "chrono", "hex", "jsonwebtoken", + "rand 0.8.5", "reqwest", "serde", "serde_json", diff --git a/maskinrommet/Cargo.toml b/maskinrommet/Cargo.toml index 1392825..7898ed0 100644 --- a/maskinrommet/Cargo.toml +++ b/maskinrommet/Cargo.toml @@ -20,3 +20,4 @@ sha2 = "0.10" hex = "0.4" tokio-util = { version = "0.7", features = ["io"] } tera = "1" +rand = "0.8" diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index cfe2680..09a8f22 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -2428,6 +2428,12 @@ fn spawn_pg_insert_edge( if edge_type == "belongs_to" { trigger_render_if_publishing(&db, &index_cache, source_id, target_id).await; } + + // Sjekk om dette er en presentasjonselement-edge og start A/B-test + // hvis det finnes >1 variant av samme type (oppgave 14.17) + if matches!(edge_type.as_str(), "title" | "subtitle" | "summary" | "og_image") { + crate::publishing::maybe_start_ab_test(&db, target_id, &edge_type).await; + } } Err(e) => { tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL"); diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 69ebc6c..47c5fac 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -144,6 +144,9 @@ async fn main() { // Start planlagt publisering-scheduler i bakgrunnen publishing::start_publish_scheduler(db.clone()); + // Start A/B-evaluator i bakgrunnen (oppgave 14.17) + publishing::start_ab_evaluator(db.clone()); + let index_cache = publishing::new_index_cache(); let dynamic_page_cache = publishing::new_dynamic_page_cache(); let state = AppState { db, jwks, stdb, cas, index_cache, dynamic_page_cache }; @@ -183,9 +186,12 @@ async fn main() { .route("/query/segments_version", get(queries::query_segments_version)) .route("/intentions/audio_analyze", post(intentions::audio_analyze)) .route("/intentions/audio_process", post(intentions::audio_process)) + .route("/intentions/ab_override", post(publishing::ab_override)) .route("/query/audio_info", get(intentions::audio_info)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}", get(publishing::serve_index)) + // A/B-testing: klikk-sporing (oppgave 14.17) + .route("/pub/{slug}/t/{article_id}", get(publishing::track_click)) // Dynamiske sider: kategori, arkiv, søk, om (oppgave 14.15) .route("/pub/{slug}/kategori/{tag}", get(publishing::serve_category)) .route("/pub/{slug}/arkiv", get(publishing::serve_archive)) diff --git a/maskinrommet/src/publishing.rs b/maskinrommet/src/publishing.rs index a6e4b0e..62efc75 100644 --- a/maskinrommet/src/publishing.rs +++ b/maskinrommet/src/publishing.rs @@ -19,6 +19,7 @@ use axum::{ response::Response, }; use chrono::{DateTime, Datelike, Utc}; +use rand::seq::SliceRandom; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tera::{Context, Tera}; @@ -325,6 +326,12 @@ pub struct IndexData { pub struct CachedIndex { html: String, expires_at: DateTime, + /// Aktive A/B-variant edge-IDs som vises i denne cachede forsiden. + /// Brukes for impression-logging ved serve. + /// Tuple: (edge_id, article_id) + active_ab_variants: Vec<(Uuid, Uuid)>, + /// Map fra article_id til aktive variant edge_ids (for klikk-attribusjon). + ab_article_variants: HashMap>, } /// Thread-safe cache for forside-rendering (dynamisk modus). @@ -656,8 +663,8 @@ pub async fn render_index_to_cas( .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("/pub/{slug}")); - // Hent artikler med tre indekserte spørringer - let (hero, featured, stream) = + // Hent artikler med tre indekserte spørringer (A/B-rotasjon inkludert) + let (hero, featured, stream, _ab_variants) = fetch_index_articles_optimized(db, collection_id, featured_max, stream_page_size).await .map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?; @@ -923,7 +930,7 @@ async fn fetch_index_articles_optimized( collection_id: Uuid, featured_max: i64, stream_page_size: i64, -) -> Result<(Option, Vec, Vec), sqlx::Error> { +) -> Result<(Option, Vec, Vec, Vec<(Uuid, Uuid)>), sqlx::Error> { // Hjelpefunksjon for å konvertere rader til ArticleData fn row_to_article( id: Uuid, @@ -1040,33 +1047,61 @@ async fn fetch_index_articles_optimized( let pres_map = fetch_presentation_elements_batch(db, &all_ids).await .unwrap_or_default(); - fn enrich(article: &mut ArticleData, pres: &PresentationElements) { - if let Some(t) = pres.best_title() { article.title = t; } + // Samle aktive A/B-variant edge-IDs for impression-logging + let mut active_ab_variants: Vec<(Uuid, Uuid)> = vec![]; + + /// Berik artikkel med presentasjonselementer via A/B-rotasjon. + /// Returnerer edge-IDs for varianter som er del av aktive A/B-tester. + fn enrich_with_ab(article: &mut ArticleData, pres: &PresentationElements) -> Vec<(Uuid, Uuid)> { + let mut variants = vec![]; + let article_id = article.id.parse::().unwrap_or_default(); + + // Tittel: AB-rotasjon + if let Some((text, ab_edge)) = pres.ab_title() { + article.title = text; + if let Some(eid) = ab_edge { variants.push((eid, article_id)); } + } + // Subtitle: bruk best (sjelden A/B-testet) if let Some(s) = pres.best_subtitle() { article.subtitle = Some(s); } - if let Some(s) = pres.best_summary() { article.summary = Some(s); } - if let Some(img) = pres.best_og_image() { article.og_image = Some(img); } + // Summary: AB-rotasjon + if let Some((text, ab_edge)) = pres.ab_summary() { + article.summary = Some(text); + if let Some(eid) = ab_edge { variants.push((eid, article_id)); } + } + // OG-image: AB-rotasjon + if let Some((hash, ab_edge)) = pres.ab_og_image() { + article.og_image = Some(hash); + if let Some(eid) = ab_edge { variants.push((eid, article_id)); } + } + variants } let mut hero = hero; if let Some(ref mut h) = hero { if let Ok(uid) = h.id.parse::() { - if let Some(pres) = pres_map.get(&uid) { enrich(h, pres); } + if let Some(pres) = pres_map.get(&uid) { + active_ab_variants.extend(enrich_with_ab(h, pres)); + } } } let mut featured = featured; for a in &mut featured { if let Ok(uid) = a.id.parse::() { - if let Some(pres) = pres_map.get(&uid) { enrich(a, pres); } + if let Some(pres) = pres_map.get(&uid) { + active_ab_variants.extend(enrich_with_ab(a, pres)); + } } } let mut stream = stream; for a in &mut stream { if let Ok(uid) = a.id.parse::() { - if let Some(pres) = pres_map.get(&uid) { enrich(a, pres); } + if let Some(pres) = pres_map.get(&uid) { + active_ab_variants.extend(enrich_with_ab(a, pres)); + } } } - Ok((hero, featured, stream)) + Ok((hero, featured, stream, active_ab_variants)) } // ============================================================================= @@ -1084,6 +1119,7 @@ struct PresentationElements { } struct PresEl { + edge_id: Uuid, title: Option, content: Option, #[allow(dead_code)] @@ -1102,7 +1138,8 @@ impl PresEl { } impl PresentationElements { - /// Velg beste variant: "winner" > "testing" > første tilgjengelige + /// Velg beste variant: "winner" > "testing" > første tilgjengelige. + /// Brukes for enkeltartikkel-rendering der vi alltid viser "best". fn best_of(elements: &[PresEl]) -> Option<&PresEl> { // Prioriter "winner" if let Some(el) = elements.iter().find(|e| e.ab_status() == "winner") { @@ -1112,6 +1149,35 @@ impl PresentationElements { elements.iter().find(|e| e.ab_status() != "retired") } + /// Velg variant for forside-visning med A/B-rotasjon. + /// Returnerer (valgt element, edge_id) — edge_id brukes for impression-logging. + /// Hvis det finnes en "winner", returneres alltid den. + /// Hvis det finnes flere "testing"-varianter, velges tilfeldig. + fn ab_select(elements: &[PresEl]) -> Option<&PresEl> { + // Prioriter "winner" + if let Some(el) = elements.iter().find(|e| e.ab_status() == "winner") { + return Some(el); + } + // Filtrer til testing-kandidater (alt som ikke er retired) + let candidates: Vec<&PresEl> = elements.iter() + .filter(|e| e.ab_status() != "retired") + .collect(); + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return Some(candidates[0]); + } + // Tilfeldig rotasjon mellom testing-varianter + let mut rng = rand::thread_rng(); + candidates.choose(&mut rng).copied() + } + + /// Sjekk om det finnes aktive A/B-tester (>1 ikke-retired variant). + fn has_active_ab_test(elements: &[PresEl]) -> bool { + elements.iter().filter(|e| e.ab_status() != "retired").count() > 1 + } + fn best_title(&self) -> Option { Self::best_of(&self.titles) .and_then(|el| el.title.clone().or(el.content.clone())) @@ -1137,6 +1203,36 @@ impl PresentationElements { Self::best_of(&self.og_descriptions) .and_then(|el| el.content.clone().or(el.title.clone())) } + + /// AB-seleksjon for forside: velg tittel med rotasjon. + fn ab_title(&self) -> Option<(String, Option)> { + Self::ab_select(&self.titles) + .and_then(|el| { + let text = el.title.clone().or(el.content.clone())?; + let eid = if Self::has_active_ab_test(&self.titles) { Some(el.edge_id) } else { None }; + Some((text, eid)) + }) + } + + /// AB-seleksjon for forside: velg summary med rotasjon. + fn ab_summary(&self) -> Option<(String, Option)> { + Self::ab_select(&self.summaries) + .and_then(|el| { + let text = el.content.clone().or(el.title.clone())?; + let eid = if Self::has_active_ab_test(&self.summaries) { Some(el.edge_id) } else { None }; + Some((text, eid)) + }) + } + + /// AB-seleksjon for forside: velg OG-image med rotasjon. + fn ab_og_image(&self) -> Option<(String, Option)> { + Self::ab_select(&self.og_images) + .and_then(|el| { + let hash = el.metadata.get("cas_hash").and_then(|h| h.as_str()).map(|s| s.to_string())?; + let eid = if Self::has_active_ab_test(&self.og_images) { Some(el.edge_id) } else { None }; + Some((hash, eid)) + }) + } } /// Hent alle presentasjonselementer for en artikkel. @@ -1146,9 +1242,9 @@ async fn fetch_presentation_elements( db: &PgPool, article_id: Uuid, ) -> Result { - let rows: Vec<(String, Option, Option, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( + let rows: Vec<(Uuid, String, Option, Option, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( r#" - SELECT e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata + SELECT e.id AS edge_id, e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 @@ -1166,8 +1262,8 @@ async fn fetch_presentation_elements( let mut og_images = vec![]; let mut og_descriptions = vec![]; - for (edge_type, title, content, node_kind, metadata, edge_metadata) in rows { - let el = PresEl { title, content, node_kind, metadata, edge_metadata }; + for (edge_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows { + let el = PresEl { edge_id, title, content, node_kind, metadata, edge_metadata }; match edge_type.as_str() { "title" => titles.push(el), "subtitle" => subtitles.push(el), @@ -1191,9 +1287,9 @@ async fn fetch_presentation_elements_batch( return Ok(HashMap::new()); } - let rows: Vec<(Uuid, String, Option, Option, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( + let rows: Vec<(Uuid, Uuid, String, Option, Option, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( r#" - SELECT e.target_id AS article_id, e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata + SELECT e.target_id AS article_id, e.id AS edge_id, e.edge_type, n.title, n.content, n.node_kind, n.metadata, e.metadata AS edge_metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = ANY($1) @@ -1207,8 +1303,8 @@ async fn fetch_presentation_elements_batch( let mut map: HashMap = HashMap::new(); - for (article_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows { - let el = PresEl { title, content, node_kind, metadata, edge_metadata }; + for (article_id, edge_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows { + let el = PresEl { edge_id, title, content, node_kind, metadata, edge_metadata }; let pres = map.entry(article_id).or_insert_with(|| PresentationElements { titles: vec![], subtitles: vec![], summaries: vec![], og_images: vec![], og_descriptions: vec![], @@ -1307,6 +1403,16 @@ pub async fn serve_index( let cache = state.index_cache.read().await; if let Some(cached) = cache.get(&collection.id) { if cached.expires_at > Utc::now() { + // Log A/B-impressions asynkront ved cache-hit + if !cached.active_ab_variants.is_empty() { + let db_clone = state.db.clone(); + let cid = collection.id; + let variants = cached.active_ab_variants.clone(); + tokio::spawn(async move { + log_ab_impressions(&db_clone, cid, &variants).await; + }); + } + let max_age = (cached.expires_at - Utc::now()).num_seconds().max(0); return Ok(Response::builder() .header(header::CONTENT_TYPE, "text/html; charset=utf-8") @@ -1326,7 +1432,7 @@ pub async fn serve_index( let featured_max = collection.publishing_config.featured_max.unwrap_or(4); let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20); - let (hero, featured, stream) = fetch_index_articles_optimized( + let (hero, featured, stream, ab_variants) = fetch_index_articles_optimized( &state.db, collection.id, featured_max, @@ -1360,13 +1466,31 @@ pub async fn serve_index( StatusCode::INTERNAL_SERVER_ERROR })?; - // Legg i cache + // Log A/B-impressions asynkront (fire-and-forget) + if !ab_variants.is_empty() { + let db_clone = state.db.clone(); + let cid = collection.id; + let variants = ab_variants.clone(); + tokio::spawn(async move { + log_ab_impressions(&db_clone, cid, &variants).await; + }); + } + + // Bygg article_id → variant edge_ids map for klikk-attribusjon + let mut ab_article_variants: HashMap> = HashMap::new(); + for &(edge_id, article_id) in &ab_variants { + ab_article_variants.entry(article_id).or_default().push(edge_id); + } + + // Legg i cache med aktive A/B-varianter let expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64); { let mut cache = state.index_cache.write().await; cache.insert(collection.id, CachedIndex { html: html.clone(), expires_at, + active_ab_variants: ab_variants, + ab_article_variants, }); } @@ -1407,6 +1531,26 @@ pub async fn serve_article( })? .ok_or(StatusCode::NOT_FOUND)?; + // A/B klikk-attribusjon: sjekk om denne artikkelen har aktive varianter + // i den cachede forsiden, og logg klikk for dem. + if let Ok(uid) = fetched.article.id.parse::() { + let cache = state.index_cache.read().await; + if let Some(cached) = cache.get(&collection.id) { + if let Some(variant_edges) = cached.ab_article_variants.get(&uid) { + let db = state.db.clone(); + let cid = collection.id; + let edges = variant_edges.clone(); + let aid = uid; + drop(cache); // Release read lock before spawning + tokio::spawn(async move { + for edge_id in edges { + log_ab_click(&db, edge_id, aid, cid).await; + } + }); + } + } + } + // Sjekk om pre-rendret HTML finnes i CAS if let Some(ref hash) = fetched.html_hash { let cas_path = state.cas.path_for(hash); @@ -2489,6 +2633,566 @@ pub async fn render_about_to_cas( })) } +// ============================================================================= +// ============================================================================= +// A/B-testing: impression-logging, klikk-sporing, evaluering (oppgave 14.17) +// ============================================================================= + +/// Log impressions for A/B-varianter som vises på forsiden. +/// Kalles asynkront (fire-and-forget) ved forside-serving. +async fn log_ab_impressions(db: &PgPool, collection_id: Uuid, variants: &[(Uuid, Uuid)]) { + for &(edge_id, article_id) in variants { + if let Err(e) = sqlx::query( + r#" + INSERT INTO ab_events (edge_id, article_id, collection_id, event_type) + VALUES ($1, $2, $3, 'impression') + "#, + ) + .bind(edge_id) + .bind(article_id) + .bind(collection_id) + .execute(db) + .await + { + tracing::warn!( + edge_id = %edge_id, + error = %e, + "Kunne ikke logge A/B-impression" + ); + } + } +} + +/// Log et klikk for en A/B-variant. +async fn log_ab_click(db: &PgPool, edge_id: Uuid, article_id: Uuid, collection_id: Uuid) { + if let Err(e) = sqlx::query( + r#" + INSERT INTO ab_events (edge_id, article_id, collection_id, event_type) + VALUES ($1, $2, $3, 'click') + "#, + ) + .bind(edge_id) + .bind(article_id) + .bind(collection_id) + .execute(db) + .await + { + tracing::warn!( + edge_id = %edge_id, + error = %e, + "Kunne ikke logge A/B-klikk" + ); + } +} + +/// GET /pub/{slug}/t/{article_short_id} — klikk-sporing for A/B-test. +/// +/// Mottar edge_id som query-parameter `v`. Logger klikk og redirecter +/// til den egentlige artikkelsiden. +pub async fn track_click( + State(state): State, + Path((slug, article_short_id)): Path<(String, String)>, + Query(params): Query>, +) -> Result { + // Finn samling + let collection = find_publishing_collection(&state.db, &slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Hent edge_id fra query-parameter + if let Some(edge_id_str) = params.get("v") { + if let Ok(edge_id) = edge_id_str.parse::() { + // Finn article_id fra short_id + let pattern = format!("{article_short_id}%"); + let article_id: Option<(Uuid,)> = sqlx::query_as( + r#" + SELECT n.id + FROM edges e + JOIN nodes n ON n.id = e.source_id + WHERE e.target_id = $1 + AND e.edge_type = 'belongs_to' + AND n.id::text LIKE $2 + LIMIT 1 + "#, + ) + .bind(collection.id) + .bind(&pattern) + .fetch_optional(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Some((aid,)) = article_id { + // Log klikk asynkront + let db = state.db.clone(); + let cid = collection.id; + tokio::spawn(async move { + log_ab_click(&db, edge_id, aid, cid).await; + }); + } + } + } + + // Redirect til artikkelsiden + let redirect_url = format!("/pub/{slug}/{article_short_id}"); + Ok(Response::builder() + .status(StatusCode::FOUND) + .header(header::LOCATION, redirect_url) + .header(header::CACHE_CONTROL, "no-cache, no-store") + .body(Default::default()) + .unwrap()) +} + +// ============================================================================= +// A/B-evaluering: periodisk CTR-beregning og signifikans-testing +// ============================================================================= + +/// Finn alle aktive A/B-tester (artikler med >1 ikke-retired variant av samme type). +/// Returnerer grupper: (article_id, edge_type, vec). +async fn find_active_ab_tests(db: &PgPool) -> Result, sqlx::Error> { + // Finn alle presentasjonselement-edges med ab_status = 'testing' eller uten ab_status + // der artikkelen har >1 variant av samme type + let rows: Vec<(Uuid, Uuid, String, serde_json::Value)> = sqlx::query_as( + r#" + SELECT e.target_id AS article_id, e.id AS edge_id, e.edge_type, e.metadata + FROM edges e + WHERE e.edge_type IN ('title', 'subtitle', 'summary', 'og_image') + AND (e.metadata->>'ab_status' IS NULL + OR e.metadata->>'ab_status' = 'testing') + ORDER BY e.target_id, e.edge_type + "#, + ) + .fetch_all(db) + .await?; + + // Grupper per (article_id, edge_type) og filtrer til de med >1 variant + let mut groups: HashMap<(Uuid, String), Vec> = HashMap::new(); + for (article_id, edge_id, edge_type, _metadata) in &rows { + groups + .entry((*article_id, edge_type.clone())) + .or_default() + .push(*edge_id); + } + + Ok(groups + .into_iter() + .filter(|(_, edges)| edges.len() > 1) + .map(|((article_id, edge_type), edge_ids)| AbTestGroup { + article_id, + edge_type, + edge_ids, + }) + .collect()) +} + +struct AbTestGroup { + article_id: Uuid, + edge_type: String, + edge_ids: Vec, +} + +/// Hent impression/klikk-tall per edge_id. +async fn fetch_ab_stats( + db: &PgPool, + edge_ids: &[Uuid], +) -> Result, sqlx::Error> { + // Returnerer (impressions, clicks) per edge_id + let rows: Vec<(Uuid, String, i64)> = sqlx::query_as( + r#" + SELECT edge_id, event_type, COUNT(*) as cnt + FROM ab_events + WHERE edge_id = ANY($1) + GROUP BY edge_id, event_type + "#, + ) + .bind(edge_ids) + .fetch_all(db) + .await?; + + let mut stats: HashMap = HashMap::new(); + for (edge_id, event_type, cnt) in rows { + let entry = stats.entry(edge_id).or_insert((0, 0)); + match event_type.as_str() { + "impression" => entry.0 = cnt, + "click" => entry.1 = cnt, + _ => {} + } + } + + Ok(stats) +} + +/// Z-test for to proporsjoner. Returnerer p-verdi (tosidig). +/// p1 = clicks1/impressions1, p2 = clicks2/impressions2. +fn z_test_proportions(imp1: i64, click1: i64, imp2: i64, click2: i64) -> f64 { + if imp1 < 30 || imp2 < 30 { + // For lite data for meningsfull test + return 1.0; + } + + let n1 = imp1 as f64; + let n2 = imp2 as f64; + let p1 = click1 as f64 / n1; + let p2 = click2 as f64 / n2; + + // Pooled proportion + let p = (click1 + click2) as f64 / (n1 + n2); + let q = 1.0 - p; + + let se = (p * q * (1.0 / n1 + 1.0 / n2)).sqrt(); + if se < 1e-12 { + return 1.0; + } + + let z = (p1 - p2).abs() / se; + + // Approksimert p-verdi (tosidig) via kumulativ normalfordeling + // Bruk enkel tilnærming: P(Z > z) ≈ erfc(z / sqrt(2)) / 2 + 2.0 * normal_cdf_complement(z) +} + +/// Komplementær kumulativ normalfordeling P(Z > z). +/// Abramowitz & Stegun-tilnærming. +fn normal_cdf_complement(z: f64) -> f64 { + let t = 1.0 / (1.0 + 0.2316419 * z); + let d = 0.3989423 * (-z * z / 2.0).exp(); + let p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274)))); + p.max(0.0).min(1.0) +} + +/// Minimum impressions per variant før evaluering vurderes. +const MIN_IMPRESSIONS_FOR_EVAL: i64 = 100; + +/// Signifikansnivå for å erklære vinner. +const SIGNIFICANCE_LEVEL: f64 = 0.05; + +/// Evaluer alle aktive A/B-tester og marker vinnere/tapere. +pub async fn evaluate_ab_tests(db: &PgPool) -> Result { + let groups = find_active_ab_tests(db) + .await + .map_err(|e| format!("Feil ved henting av A/B-tester: {e}"))?; + + if groups.is_empty() { + return Ok(0); + } + + let mut resolved = 0; + + for group in &groups { + let stats = fetch_ab_stats(db, &group.edge_ids) + .await + .map_err(|e| format!("Feil ved henting av A/B-stats: {e}"))?; + + // Sjekk at alle varianter har nok data + let all_ready = group.edge_ids.iter().all(|eid| { + stats.get(eid).map(|(imp, _)| *imp >= MIN_IMPRESSIONS_FOR_EVAL).unwrap_or(false) + }); + + if !all_ready { + continue; + } + + // Oppdater edge-metadata med impressions, clicks, ctr + for &edge_id in &group.edge_ids { + let (imp, clicks) = stats.get(&edge_id).copied().unwrap_or((0, 0)); + let ctr = if imp > 0 { clicks as f64 / imp as f64 } else { 0.0 }; + + if let Err(e) = sqlx::query( + r#" + UPDATE edges + SET metadata = metadata + || jsonb_build_object( + 'impressions', $2::bigint, + 'clicks', $3::bigint, + 'ctr', $4::float8 + ) + WHERE id = $1 + "#, + ) + .bind(edge_id) + .bind(imp) + .bind(clicks) + .bind(ctr) + .execute(db) + .await + { + tracing::warn!(edge_id = %edge_id, error = %e, "Kunne ikke oppdatere edge AB-metadata"); + } + } + + // For par-sammenligning: test alle par og finn om det finnes en klar vinner + if group.edge_ids.len() == 2 { + let (imp_a, click_a) = stats.get(&group.edge_ids[0]).copied().unwrap_or((0, 0)); + let (imp_b, click_b) = stats.get(&group.edge_ids[1]).copied().unwrap_or((0, 0)); + + let p_value = z_test_proportions(imp_a, click_a, imp_b, click_b); + + if p_value < SIGNIFICANCE_LEVEL { + let ctr_a = if imp_a > 0 { click_a as f64 / imp_a as f64 } else { 0.0 }; + let ctr_b = if imp_b > 0 { click_b as f64 / imp_b as f64 } else { 0.0 }; + + let (winner_id, loser_id) = if ctr_a >= ctr_b { + (group.edge_ids[0], group.edge_ids[1]) + } else { + (group.edge_ids[1], group.edge_ids[0]) + }; + + // Marker vinner + let _ = sqlx::query( + "UPDATE edges SET metadata = metadata || '{\"ab_status\": \"winner\"}' WHERE id = $1", + ) + .bind(winner_id) + .execute(db) + .await; + + // Marker taper som retired + let _ = sqlx::query( + "UPDATE edges SET metadata = metadata || '{\"ab_status\": \"retired\"}' WHERE id = $1", + ) + .bind(loser_id) + .execute(db) + .await; + + tracing::info!( + article_id = %group.article_id, + edge_type = %group.edge_type, + winner = %winner_id, + loser = %loser_id, + p_value = p_value, + "A/B-test avgjort: vinner markert" + ); + + resolved += 1; + } + } else { + // >2 varianter: finn den med høyest CTR og test mot nest-best + let mut ranked: Vec<(Uuid, f64, i64, i64)> = group.edge_ids.iter().map(|&eid| { + let (imp, clicks) = stats.get(&eid).copied().unwrap_or((0, 0)); + let ctr = if imp > 0 { clicks as f64 / imp as f64 } else { 0.0 }; + (eid, ctr, imp, clicks) + }).collect(); + ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + if ranked.len() >= 2 { + let (best_id, _, best_imp, best_clicks) = ranked[0]; + let (_second_id, _, second_imp, second_clicks) = ranked[1]; + + let p_value = z_test_proportions(best_imp, best_clicks, second_imp, second_clicks); + + if p_value < SIGNIFICANCE_LEVEL { + // Vinneren er bedre enn alle andre med signifikans + let _ = sqlx::query( + "UPDATE edges SET metadata = metadata || '{\"ab_status\": \"winner\"}' WHERE id = $1", + ) + .bind(best_id) + .execute(db) + .await; + + // Retire alle andre + for &(eid, _, _, _) in &ranked[1..] { + let _ = sqlx::query( + "UPDATE edges SET metadata = metadata || '{\"ab_status\": \"retired\"}' WHERE id = $1", + ) + .bind(eid) + .execute(db) + .await; + } + + tracing::info!( + article_id = %group.article_id, + edge_type = %group.edge_type, + winner = %best_id, + variants = ranked.len(), + p_value = p_value, + "A/B-test avgjort (multi-variant): vinner markert" + ); + + resolved += 1; + } + } + } + } + + Ok(resolved) +} + +/// Start periodisk A/B-evaluering. +/// Kjører hvert 5. minutt, evaluerer aktive tester og markerer vinnere. +pub fn start_ab_evaluator(db: PgPool) { + tokio::spawn(async move { + // Vent 60 sekunder etter oppstart + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + tracing::info!("A/B-evaluator startet (intervall: 300s)"); + + loop { + match evaluate_ab_tests(&db).await { + Ok(count) => { + if count > 0 { + tracing::info!(resolved = count, "A/B-evaluator: {} tester avgjort", count); + } + } + Err(e) => { + tracing::error!(error = %e, "A/B-evaluator feilet"); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + } + }); +} + +/// POST /intentions/ab_override — redaktør overstyrer A/B-test. +/// +/// Markerer valgt variant som "winner" og alle andre som "retired". +/// Krever owner/admin-tilgang til samlingen. +#[derive(Deserialize)] +pub struct AbOverrideRequest { + pub edge_id: Uuid, + pub article_id: Uuid, +} + +pub async fn ab_override( + State(state): State, + user: crate::auth::AuthUser, + axum::Json(req): axum::Json, +) -> Result, StatusCode> { + // Finn artikkelen og dens samling + let edge_row: Option<(String, Uuid)> = sqlx::query_as( + r#" + SELECT e.edge_type, e.target_id + FROM edges e + WHERE e.id = $1 + "#, + ) + .bind(req.edge_id) + .fetch_optional(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let Some((edge_type, article_id)) = edge_row else { + return Err(StatusCode::NOT_FOUND); + }; + + if article_id != req.article_id { + return Err(StatusCode::BAD_REQUEST); + } + + // Sjekk at brukeren har tilgang (owner/admin av en samling artikkelen tilhører) + let has_access: bool = sqlx::query_scalar( + r#" + SELECT EXISTS( + SELECT 1 + FROM edges e_belongs + JOIN edges e_role ON e_role.source_id = $2 AND e_role.target_id = e_belongs.target_id + AND e_role.edge_type IN ('owner', 'admin') + WHERE e_belongs.source_id = $1 AND e_belongs.edge_type = 'belongs_to' + ) + "#, + ) + .bind(article_id) + .bind(user.node_id) + .fetch_one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !has_access { + return Err(StatusCode::FORBIDDEN); + } + + // Marker valgt variant som winner + sqlx::query( + "UPDATE edges SET metadata = metadata || '{\"ab_status\": \"winner\"}' WHERE id = $1", + ) + .bind(req.edge_id) + .execute(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Retire alle andre varianter av samme type til samme artikkel + sqlx::query( + r#" + UPDATE edges + SET metadata = metadata || '{"ab_status": "retired"}' + WHERE target_id = $1 + AND edge_type = $2 + AND id != $3 + AND (metadata->>'ab_status' IS NULL OR metadata->>'ab_status' = 'testing') + "#, + ) + .bind(article_id) + .bind(&edge_type) + .bind(req.edge_id) + .execute(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + tracing::info!( + edge_id = %req.edge_id, + article_id = %article_id, + edge_type = %edge_type, + user = %user.node_id, + "A/B-test overstyrt av redaktør" + ); + + Ok(axum::Json(serde_json::json!({ + "status": "ok", + "winner_edge_id": req.edge_id, + "edge_type": edge_type + }))) +} + +// ============================================================================= +// A/B initialisering: sett testing-status på nye varianter automatisk +// ============================================================================= + +/// Sett ab_status = "testing" og started_at på en presentasjonselement-edge +/// når det finnes >1 variant av samme type til samme artikkel. +pub async fn maybe_start_ab_test(db: &PgPool, article_id: Uuid, edge_type: &str) { + // Tell antall varianter (ikke-retired) + let count: Option<(i64,)> = sqlx::query_as( + r#" + SELECT COUNT(*) FROM edges + WHERE target_id = $1 + AND edge_type = $2 + AND (metadata->>'ab_status' IS NULL OR metadata->>'ab_status' != 'retired') + "#, + ) + .bind(article_id) + .bind(edge_type) + .fetch_optional(db) + .await + .unwrap_or(None); + + let count = count.map(|(c,)| c).unwrap_or(0); + + if count > 1 { + // Marker alle varianter uten ab_status som "testing" + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query( + r#" + UPDATE edges + SET metadata = metadata + || jsonb_build_object('ab_status', 'testing', 'started_at', $3::text) + WHERE target_id = $1 + AND edge_type = $2 + AND metadata->>'ab_status' IS NULL + "#, + ) + .bind(article_id) + .bind(edge_type) + .bind(&now) + .execute(db) + .await; + + tracing::info!( + article_id = %article_id, + edge_type = %edge_type, + variants = count, + "A/B-test startet automatisk" + ); + } +} + // ============================================================================= // Tester // ============================================================================= @@ -2556,7 +3260,9 @@ mod tests { short_id: "test-sho".to_string(), title: "Testittel".to_string(), content: "

Testinnhold

".to_string(), + subtitle: None, summary: Some("Kort oppsummering".to_string()), + og_image: None, published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }; @@ -2581,7 +3287,9 @@ mod tests { short_id: "seo-test".to_string(), title: "SEO-tittel".to_string(), content: "

Innhold

".to_string(), + subtitle: None, summary: Some("SEO-beskrivelse her".to_string()), + og_image: None, published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }; @@ -2617,8 +3325,10 @@ mod tests { id: "s1".to_string(), short_id: "s1000000".to_string(), title: "Strøm-artikkel".to_string(), + subtitle: None, content: "Innhold".to_string(), summary: Some("Sammendrag".to_string()), + og_image: None, published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }], @@ -2640,14 +3350,38 @@ mod tests { assert_eq!(css, css_blogg); } + #[test] + fn z_test_insufficient_data_returns_1() { + // For lite data: returnerer p=1.0 (ingen signifikans) + assert_eq!(z_test_proportions(10, 5, 10, 3), 1.0); + } + + #[test] + fn z_test_significant_difference() { + // Variant A: 1000 imp, 100 klikk (10% CTR) + // Variant B: 1000 imp, 50 klikk (5% CTR) + let p = z_test_proportions(1000, 100, 1000, 50); + assert!(p < 0.05, "Forventet signifikant forskjell, fikk p={p}"); + } + + #[test] + fn z_test_no_significant_difference() { + // Variant A: 100 imp, 10 klikk (10% CTR) + // Variant B: 100 imp, 9 klikk (9% CTR) + let p = z_test_proportions(100, 10, 100, 9); + assert!(p > 0.05, "Forventet ingen signifikant forskjell, fikk p={p}"); + } + #[test] fn json_ld_contains_required_fields() { let article = ArticleData { id: "test".to_string(), short_id: "test1234".to_string(), title: "Test-artikkel".to_string(), + subtitle: None, content: "Innhold".to_string(), summary: Some("Oppsummering".to_string()), + og_image: None, published_at: "2026-03-18T12:00:00Z".to_string(), published_at_short: "18. mars 2026".to_string(), }; diff --git a/migrations/012_ab_testing.sql b/migrations/012_ab_testing.sql new file mode 100644 index 0000000..eb8eea6 --- /dev/null +++ b/migrations/012_ab_testing.sql @@ -0,0 +1,37 @@ +-- A/B-testing: impression/click-logging for presentasjonselement-varianter. +-- +-- Når en artikkel har flere varianter av tittel/summary/og_image-noder, +-- roterer maskinrommet mellom dem på forsiden og logger visninger/klikk. +-- Etter statistisk signifikans markeres vinneren i edge-metadata. +-- +-- Ref: docs/concepts/publisering.md § "Automatisk A/B-testing" +-- Oppgave: 14.17 + +-- Event-logg: hvert impression/klikk logges individuelt for tid-normalisering. +CREATE TABLE ab_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + edge_id UUID NOT NULL, + article_id UUID NOT NULL, + collection_id UUID NOT NULL, + event_type VARCHAR(16) NOT NULL CHECK (event_type IN ('impression', 'click')), + -- Timens posisjon i uken (0-167) for tidspunkt-normalisering. + -- Mandag 00:00 = 0, søndag 23:00 = 167. + hour_of_week SMALLINT NOT NULL DEFAULT ( + EXTRACT(ISODOW FROM now())::int * 24 + - 24 + + EXTRACT(HOUR FROM now())::int + ), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Primær-oppslag: alle events for en bestemt edge +CREATE INDEX idx_ab_events_edge ON ab_events(edge_id, event_type); + +-- Opprydning/aggregering per samling +CREATE INDEX idx_ab_events_collection ON ab_events(collection_id, created_at); + +-- For periodisk evaluering: finn alle aktive tester per samling +CREATE INDEX idx_ab_events_article ON ab_events(article_id, edge_id); + +COMMENT ON TABLE ab_events IS 'A/B-test impression/klikk-logg for presentasjonselement-varianter'; +COMMENT ON COLUMN ab_events.hour_of_week IS 'Timens posisjon i uken (0-167) for CTR-normalisering mot tidspunkt-baseline'; diff --git a/tasks.md b/tasks.md index b7ae8c0..f34074b 100644 --- a/tasks.md +++ b/tasks.md @@ -159,8 +159,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 14.14 Bulk re-rendering: batch-jobb via jobbkø ved temaendring. Paginert (100 artikler om gangen), oppdaterer `renderer_version`. Artikler serveres med gammelt tema til re-rendret. - [x] 14.15 Dynamiske sider: kategori-sider (filtrert på tag-edges), arkiv (kronologisk med månedsgruppering), søk (PG fulltekst). Alle paginerte, cachet i maskinrommet. Om-side som statisk CAS-node. - [x] 14.16 Presentasjonselementer som noder: publisert tittel, ingress, OG-bilde, undertittel er egne noder med `title`/`summary`/`og_image`-edges til artikkelen. Frontend for å opprette/redigere varianter. Ref: `docs/concepts/publisering.md` § "Presentasjonselementer". -- [~] 14.17 A/B-testing: maskinrommet roterer varianter ved forside-rendering, logger impressions/klikk per variant, normaliserer CTR mot tidspunkt-baseline. Etter statistisk signifikans markeres vinner. Redaktør kan overstyre. Edge-metadata: `ab_status`, `impressions`, `clicks`, `ctr`. - > Påbegynt: 2026-03-18T02:56 +- [x] 14.17 A/B-testing: maskinrommet roterer varianter ved forside-rendering, logger impressions/klikk per variant, normaliserer CTR mot tidspunkt-baseline. Etter statistisk signifikans markeres vinner. Redaktør kan overstyre. Edge-metadata: `ab_status`, `impressions`, `clicks`, `ctr`. ## Fase 15: Adminpanel