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:
parent
a10de64686
commit
1425a82cdd
8 changed files with 842 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
1
maskinrommet/Cargo.lock
generated
1
maskinrommet/Cargo.lock
generated
|
|
@ -1083,6 +1083,7 @@ dependencies = [
|
|||
"chrono",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ sha2 = "0.10"
|
|||
hex = "0.4"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tera = "1"
|
||||
rand = "0.8"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<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).
|
||||
|
|
@ -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<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
|
||||
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::<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_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::<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;
|
||||
for a in &mut featured {
|
||||
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;
|
||||
for a in &mut stream {
|
||||
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 {
|
||||
edge_id: Uuid,
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
#[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<String> {
|
||||
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<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.
|
||||
|
|
@ -1146,9 +1242,9 @@ async fn fetch_presentation_elements(
|
|||
db: &PgPool,
|
||||
article_id: Uuid,
|
||||
) -> 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#"
|
||||
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<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#"
|
||||
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<Uuid, PresentationElements> = 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<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 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::<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
|
||||
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<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
|
||||
// =============================================================================
|
||||
|
|
@ -2556,7 +3260,9 @@ mod tests {
|
|||
short_id: "test-sho".to_string(),
|
||||
title: "Testittel".to_string(),
|
||||
content: "<p>Testinnhold</p>".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: "<p>Innhold</p>".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(),
|
||||
};
|
||||
|
|
|
|||
37
migrations/012_ab_testing.sql
Normal file
37
migrations/012_ab_testing.sql
Normal 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';
|
||||
3
tasks.md
3
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue