Fullfører oppgave 14.17: A/B-testing for presentasjonselementer

Implementerer automatisk A/B-testing for forside-varianter:

- PG-migrasjon 012: ab_events-tabell for impression/klikk-logging
  med hour_of_week (0-167) for tidspunkt-normalisering
- Variant-rotasjon: ab_select() velger tilfeldig blant testing-varianter
  ved forside-rendering, winner prioriteres, retired filtreres bort
- Impression-logging: asynkron fire-and-forget ved forside-serve
  (både cache-hit og -miss), lagres i ab_events
- Klikk-attribusjon: artikkelbesøk sjekker forside-cache for aktive
  AB-varianter og logger klikk. Eksplisitt tracking via
  GET /pub/{slug}/t/{article_id}?v={edge_id}
- Periodisk evaluator (300s intervall): z-test for proporsjoner
  (p < 0.05), minimum 100 impressions per variant, oppdaterer
  edge-metadata (ab_status, impressions, clicks, ctr)
- Redaktør-overstyring: POST /intentions/ab_override markerer
  valgt variant som winner, andre som retired (krever owner/admin)
- Auto-initialisering: maybe_start_ab_test() setter ab_status=testing
  automatisk når >1 variant av samme type opprettes

Alle 42 tester passerer inkludert 3 nye z-test-tester.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 03:13:39 +00:00
parent a10de64686
commit 1425a82cdd
8 changed files with 842 additions and 36 deletions

View file

@ -248,12 +248,15 @@ samtale, ikke en workflow-tilstand.
## Presentasjonselementer er noder ## 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 > (`/query/presentation_elements`), rendering bruker presentasjonselementer
> (title, subtitle, summary, og_image) med fallback til artikkelnoden. > (title, subtitle, summary, og_image) med fallback til artikkelnoden.
> Frontend: PresentationEditor-komponent integrert i PublishDialog. > Frontend: PresentationEditor-komponent integrert i PublishDialog.
> A/B-testing (automatisk rotasjon, impression-logging) er spesifisert > A/B-testing: maskinrommet roterer varianter ved forside-rendering,
> men ikke implementert (planlagt oppgave 14.17). > 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 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 et bilde. Alt som vises *om* en artikkel på forsiden er en *ting med
@ -301,18 +304,27 @@ Artikkel (innholdsnoden)
### Automatisk A/B-testing ### Automatisk A/B-testing
> **Status:** Implementert i oppgave 14.17.
Når det finnes flere noder med samme edge-type til samme artikkel, Når det finnes flere noder med samme edge-type til samme artikkel,
er default å A/B-teste automatisk. er default å A/B-teste automatisk.
**Mekanikk:** **Mekanikk (implementert):**
- Maskinrommet roterer varianter ved forside-rendering - Maskinrommet roterer varianter tilfeldig ved forside-rendering
- Logger hvilken variant som ble vist (impression) og om leseren (i `fetch_index_articles_optimized` via `ab_select()`)
klikket videre til artikkelen (konvertering) - Logger impressions ved forside-serve (cache hit og miss) og klikk
- Normaliserer CTR mot tidspunkt — klikk kl. 08 mandag morgen har ved artikkelbesøk — alt asynkront (fire-and-forget) i `ab_events`-tabell
annen baseline enn kl. 22 lørdag kveld - `ab_events.hour_of_week` (0-167) lagres for tidspunkt-normalisering
- Etter statistisk signifikans → vinneren markeres, taperen - Periodisk evaluator (hvert 5. minutt) beregner CTR per variant,
deaktiveres kjører z-test for proporsjoner (p < 0.05), oppdaterer edge-metadata
- Redaktøren kan alltid overstyre — pin en spesifikk variant - 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:** **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 - 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. 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? ### Hva med podcast-episoder?
Samme mønster. AI-analyse i podcastfabrikken genererer forslag til Samme mønster. AI-analyse i podcastfabrikken genererer forslag til

View file

@ -1083,6 +1083,7 @@ dependencies = [
"chrono", "chrono",
"hex", "hex",
"jsonwebtoken", "jsonwebtoken",
"rand 0.8.5",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -20,3 +20,4 @@ sha2 = "0.10"
hex = "0.4" hex = "0.4"
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tera = "1" tera = "1"
rand = "0.8"

View file

@ -2428,6 +2428,12 @@ fn spawn_pg_insert_edge(
if edge_type == "belongs_to" { if edge_type == "belongs_to" {
trigger_render_if_publishing(&db, &index_cache, source_id, target_id).await; 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) => { Err(e) => {
tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL"); tracing::error!(edge_id = %edge_id, error = %e, "Kunne ikke persistere edge til PostgreSQL");

View file

@ -144,6 +144,9 @@ async fn main() {
// Start planlagt publisering-scheduler i bakgrunnen // Start planlagt publisering-scheduler i bakgrunnen
publishing::start_publish_scheduler(db.clone()); 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 index_cache = publishing::new_index_cache();
let dynamic_page_cache = publishing::new_dynamic_page_cache(); let dynamic_page_cache = publishing::new_dynamic_page_cache();
let state = AppState { db, jwks, stdb, cas, index_cache, 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("/query/segments_version", get(queries::query_segments_version))
.route("/intentions/audio_analyze", post(intentions::audio_analyze)) .route("/intentions/audio_analyze", post(intentions::audio_analyze))
.route("/intentions/audio_process", post(intentions::audio_process)) .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("/query/audio_info", get(intentions::audio_info))
.route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed))
.route("/pub/{slug}", get(publishing::serve_index)) .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) // Dynamiske sider: kategori, arkiv, søk, om (oppgave 14.15)
.route("/pub/{slug}/kategori/{tag}", get(publishing::serve_category)) .route("/pub/{slug}/kategori/{tag}", get(publishing::serve_category))
.route("/pub/{slug}/arkiv", get(publishing::serve_archive)) .route("/pub/{slug}/arkiv", get(publishing::serve_archive))

View file

@ -19,6 +19,7 @@ use axum::{
response::Response, response::Response,
}; };
use chrono::{DateTime, Datelike, Utc}; use chrono::{DateTime, Datelike, Utc};
use rand::seq::SliceRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use tera::{Context, Tera}; use tera::{Context, Tera};
@ -325,6 +326,12 @@ pub struct IndexData {
pub struct CachedIndex { pub struct CachedIndex {
html: String, html: String,
expires_at: DateTime<Utc>, expires_at: DateTime<Utc>,
/// 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<Uuid, Vec<Uuid>>,
} }
/// Thread-safe cache for forside-rendering (dynamisk modus). /// Thread-safe cache for forside-rendering (dynamisk modus).
@ -656,8 +663,8 @@ pub async fn render_index_to_cas(
.map(|d| format!("https://{d}")) .map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("/pub/{slug}")); .unwrap_or_else(|| format!("/pub/{slug}"));
// Hent artikler med tre indekserte spørringer // Hent artikler med tre indekserte spørringer (A/B-rotasjon inkludert)
let (hero, featured, stream) = let (hero, featured, stream, _ab_variants) =
fetch_index_articles_optimized(db, collection_id, featured_max, stream_page_size).await fetch_index_articles_optimized(db, collection_id, featured_max, stream_page_size).await
.map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?; .map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?;
@ -923,7 +930,7 @@ async fn fetch_index_articles_optimized(
collection_id: Uuid, collection_id: Uuid,
featured_max: i64, featured_max: i64,
stream_page_size: i64, stream_page_size: i64,
) -> Result<(Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>), sqlx::Error> { ) -> Result<(Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>, Vec<(Uuid, Uuid)>), sqlx::Error> {
// Hjelpefunksjon for å konvertere rader til ArticleData // Hjelpefunksjon for å konvertere rader til ArticleData
fn row_to_article( fn row_to_article(
id: Uuid, id: Uuid,
@ -1040,33 +1047,61 @@ async fn fetch_index_articles_optimized(
let pres_map = fetch_presentation_elements_batch(db, &all_ids).await let pres_map = fetch_presentation_elements_batch(db, &all_ids).await
.unwrap_or_default(); .unwrap_or_default();
fn enrich(article: &mut ArticleData, pres: &PresentationElements) { // Samle aktive A/B-variant edge-IDs for impression-logging
if let Some(t) = pres.best_title() { article.title = t; } 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::<Uuid>().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_subtitle() { article.subtitle = Some(s); }
if let Some(s) = pres.best_summary() { article.summary = Some(s); } // Summary: AB-rotasjon
if let Some(img) = pres.best_og_image() { article.og_image = Some(img); } 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; let mut hero = hero;
if let Some(ref mut h) = hero { if let Some(ref mut h) = hero {
if let Ok(uid) = h.id.parse::<Uuid>() { if let Ok(uid) = h.id.parse::<Uuid>() {
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; let mut featured = featured;
for a in &mut featured { for a in &mut featured {
if let Ok(uid) = a.id.parse::<Uuid>() { if let Ok(uid) = a.id.parse::<Uuid>() {
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; let mut stream = stream;
for a in &mut stream { for a in &mut stream {
if let Ok(uid) = a.id.parse::<Uuid>() { if let Ok(uid) = a.id.parse::<Uuid>() {
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 { struct PresEl {
edge_id: Uuid,
title: Option<String>, title: Option<String>,
content: Option<String>, content: Option<String>,
#[allow(dead_code)] #[allow(dead_code)]
@ -1102,7 +1138,8 @@ impl PresEl {
} }
impl PresentationElements { 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> { fn best_of(elements: &[PresEl]) -> Option<&PresEl> {
// Prioriter "winner" // Prioriter "winner"
if let Some(el) = elements.iter().find(|e| e.ab_status() == "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") 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<String> { fn best_title(&self) -> Option<String> {
Self::best_of(&self.titles) Self::best_of(&self.titles)
.and_then(|el| el.title.clone().or(el.content.clone())) .and_then(|el| el.title.clone().or(el.content.clone()))
@ -1137,6 +1203,36 @@ impl PresentationElements {
Self::best_of(&self.og_descriptions) Self::best_of(&self.og_descriptions)
.and_then(|el| el.content.clone().or(el.title.clone())) .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<Uuid>)> {
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<Uuid>)> {
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<Uuid>)> {
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. /// Hent alle presentasjonselementer for en artikkel.
@ -1146,9 +1242,9 @@ async fn fetch_presentation_elements(
db: &PgPool, db: &PgPool,
article_id: Uuid, article_id: Uuid,
) -> Result<PresentationElements, sqlx::Error> { ) -> Result<PresentationElements, sqlx::Error> {
let rows: Vec<(String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( let rows: Vec<(Uuid, String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as(
r#" 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 FROM edges e
JOIN nodes n ON n.id = e.source_id JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1 WHERE e.target_id = $1
@ -1166,8 +1262,8 @@ async fn fetch_presentation_elements(
let mut og_images = vec![]; let mut og_images = vec![];
let mut og_descriptions = vec![]; let mut og_descriptions = vec![];
for (edge_type, title, content, node_kind, metadata, edge_metadata) in rows { for (edge_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows {
let el = PresEl { title, content, node_kind, metadata, edge_metadata }; let el = PresEl { edge_id, title, content, node_kind, metadata, edge_metadata };
match edge_type.as_str() { match edge_type.as_str() {
"title" => titles.push(el), "title" => titles.push(el),
"subtitle" => subtitles.push(el), "subtitle" => subtitles.push(el),
@ -1191,9 +1287,9 @@ async fn fetch_presentation_elements_batch(
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
let rows: Vec<(Uuid, String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as( let rows: Vec<(Uuid, Uuid, String, Option<String>, Option<String>, String, serde_json::Value, serde_json::Value)> = sqlx::query_as(
r#" 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 FROM edges e
JOIN nodes n ON n.id = e.source_id JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = ANY($1) WHERE e.target_id = ANY($1)
@ -1207,8 +1303,8 @@ async fn fetch_presentation_elements_batch(
let mut map: HashMap<Uuid, PresentationElements> = HashMap::new(); let mut map: HashMap<Uuid, PresentationElements> = HashMap::new();
for (article_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows { for (article_id, edge_id, edge_type, title, content, node_kind, metadata, edge_metadata) in rows {
let el = PresEl { title, content, node_kind, metadata, edge_metadata }; let el = PresEl { edge_id, title, content, node_kind, metadata, edge_metadata };
let pres = map.entry(article_id).or_insert_with(|| PresentationElements { let pres = map.entry(article_id).or_insert_with(|| PresentationElements {
titles: vec![], subtitles: vec![], summaries: vec![], titles: vec![], subtitles: vec![], summaries: vec![],
og_images: vec![], og_descriptions: vec![], og_images: vec![], og_descriptions: vec![],
@ -1307,6 +1403,16 @@ pub async fn serve_index(
let cache = state.index_cache.read().await; let cache = state.index_cache.read().await;
if let Some(cached) = cache.get(&collection.id) { if let Some(cached) = cache.get(&collection.id) {
if cached.expires_at > Utc::now() { 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); let max_age = (cached.expires_at - Utc::now()).num_seconds().max(0);
return Ok(Response::builder() return Ok(Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8") .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 featured_max = collection.publishing_config.featured_max.unwrap_or(4);
let stream_page_size = collection.publishing_config.stream_page_size.unwrap_or(20); 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, &state.db,
collection.id, collection.id,
featured_max, featured_max,
@ -1360,13 +1466,31 @@ pub async fn serve_index(
StatusCode::INTERNAL_SERVER_ERROR 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<Uuid, Vec<Uuid>> = 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 expires_at = Utc::now() + chrono::Duration::seconds(cache_ttl as i64);
{ {
let mut cache = state.index_cache.write().await; let mut cache = state.index_cache.write().await;
cache.insert(collection.id, CachedIndex { cache.insert(collection.id, CachedIndex {
html: html.clone(), html: html.clone(),
expires_at, expires_at,
active_ab_variants: ab_variants,
ab_article_variants,
}); });
} }
@ -1407,6 +1531,26 @@ pub async fn serve_article(
})? })?
.ok_or(StatusCode::NOT_FOUND)?; .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::<Uuid>() {
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 // Sjekk om pre-rendret HTML finnes i CAS
if let Some(ref hash) = fetched.html_hash { if let Some(ref hash) = fetched.html_hash {
let cas_path = state.cas.path_for(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<AppState>,
Path((slug, article_short_id)): Path<(String, String)>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, StatusCode> {
// 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::<Uuid>() {
// 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<edge_id>).
async fn find_active_ab_tests(db: &PgPool) -> Result<Vec<AbTestGroup>, 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<Uuid>> = 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<Uuid>,
}
/// Hent impression/klikk-tall per edge_id.
async fn fetch_ab_stats(
db: &PgPool,
edge_ids: &[Uuid],
) -> Result<HashMap<Uuid, (i64, i64)>, 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<Uuid, (i64, i64)> = 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<usize, String> {
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<AppState>,
user: crate::auth::AuthUser,
axum::Json(req): axum::Json<AbOverrideRequest>,
) -> Result<axum::Json<serde_json::Value>, 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 // Tester
// ============================================================================= // =============================================================================
@ -2556,7 +3260,9 @@ mod tests {
short_id: "test-sho".to_string(), short_id: "test-sho".to_string(),
title: "Testittel".to_string(), title: "Testittel".to_string(),
content: "<p>Testinnhold</p>".to_string(), content: "<p>Testinnhold</p>".to_string(),
subtitle: None,
summary: Some("Kort oppsummering".to_string()), summary: Some("Kort oppsummering".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(), published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(), published_at_short: "18. mars 2026".to_string(),
}; };
@ -2581,7 +3287,9 @@ mod tests {
short_id: "seo-test".to_string(), short_id: "seo-test".to_string(),
title: "SEO-tittel".to_string(), title: "SEO-tittel".to_string(),
content: "<p>Innhold</p>".to_string(), content: "<p>Innhold</p>".to_string(),
subtitle: None,
summary: Some("SEO-beskrivelse her".to_string()), summary: Some("SEO-beskrivelse her".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(), published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(), published_at_short: "18. mars 2026".to_string(),
}; };
@ -2617,8 +3325,10 @@ mod tests {
id: "s1".to_string(), id: "s1".to_string(),
short_id: "s1000000".to_string(), short_id: "s1000000".to_string(),
title: "Strøm-artikkel".to_string(), title: "Strøm-artikkel".to_string(),
subtitle: None,
content: "Innhold".to_string(), content: "Innhold".to_string(),
summary: Some("Sammendrag".to_string()), summary: Some("Sammendrag".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(), published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(), published_at_short: "18. mars 2026".to_string(),
}], }],
@ -2640,14 +3350,38 @@ mod tests {
assert_eq!(css, css_blogg); 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] #[test]
fn json_ld_contains_required_fields() { fn json_ld_contains_required_fields() {
let article = ArticleData { let article = ArticleData {
id: "test".to_string(), id: "test".to_string(),
short_id: "test1234".to_string(), short_id: "test1234".to_string(),
title: "Test-artikkel".to_string(), title: "Test-artikkel".to_string(),
subtitle: None,
content: "Innhold".to_string(), content: "Innhold".to_string(),
summary: Some("Oppsummering".to_string()), summary: Some("Oppsummering".to_string()),
og_image: None,
published_at: "2026-03-18T12:00:00Z".to_string(), published_at: "2026-03-18T12:00:00Z".to_string(),
published_at_short: "18. mars 2026".to_string(), published_at_short: "18. mars 2026".to_string(),
}; };

View file

@ -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';

View file

@ -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.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.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". - [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`. - [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`.
> Påbegynt: 2026-03-18T02:56
## Fase 15: Adminpanel ## Fase 15: Adminpanel