/** * Blog Self-Learning Loop (SLL v1.0) * * Routes: * POST /api/blog/:id/performance — log engagement metrics for a post * GET /api/blog/sll/insights — current learning state * POST /api/blog/sll/analyze — trigger LLM pattern extraction * GET /api/blog/sll/patterns — all learned patterns * GET /api/blog/sll/posting-time — best posting time (Umami + SLL combined) * POST /api/blog/sll/sync-umami — refresh Umami analytics cache */ import { Router, Request, Response } from "express"; import { pool } from "../db/client"; export const blogSllRouter = Router(); // ───────────────────────────────────────────────────────────────── // POST /api/blog/:id/performance — log LinkedIn engagement // ───────────────────────────────────────────────────────────────── blogSllRouter.post("/:id/performance", async (req: Request, res: Response) => { const { id } = req.params; const { platform = "linkedin", impressions, comments = 0, shares = 0, saves = 0, likes = 0, hook_text, posted_at, notes, } = req.body as { platform?: string; impressions?: number; comments?: number; shares?: number; saves?: number; likes?: number; hook_text?: string; posted_at?: string; notes?: string; }; try { // Pull blog metadata for pattern context const blogRow = await pool.query( `SELECT topic, pipeline_version, word_count, linkedin_post FROM blog_drafts WHERE id = $1::uuid`, [id] ); if (blogRow.rows.length === 0) { res.status(404).json({ success: false, error: "Blog draft not found" }); return; } const blog = blogRow.rows[0]; // Auto-extract hook from linkedin_post if not provided const resolvedHook = hook_text || (blog.linkedin_post ? blog.linkedin_post.split("\n")[0].slice(0, 120) : null); const result = await pool.query( `INSERT INTO blog_performance (blog_id, platform, impressions, comments, shares, saves, likes, hook_text, blog_type, topic, word_count, pipeline_version, posted_at, notes) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, engagement_score`, [ id, platform, impressions ?? null, comments, shares, saves, likes, resolvedHook, blog.topic, blog.topic, blog.word_count, blog.pipeline_version, posted_at ? new Date(posted_at) : null, notes ?? null, ] ); const row = result.rows[0]; const score = row.engagement_score; const tier = score >= 20 ? "gold" : score >= 10 ? "silver" : score >= 4 ? "bronze" : "miss"; console.log(`SLL: Performance logged for ${id} — score ${score} (${tier})`); res.json({ success: true, performance_id: row.id, engagement_score: score, tier, formula: `(comments×3) + (shares×2) + (saves×2) = ${comments * 3} + ${shares * 2} + ${saves * 2}`, }); } catch (err) { console.error("SLL performance log error:", err); res.status(500).json({ success: false, error: String(err) }); } }); // ───────────────────────────────────────────────────────────────── // GET /api/blog/sll/insights — current learning state // ───────────────────────────────────────────────────────────────── blogSllRouter.get("/sll/insights", async (_req: Request, res: Response) => { try { const [stateRes, statsRes, topRes, bottomRes, patternsRes] = await Promise.all([ // Latest SLL state snapshot pool.query( `SELECT * FROM blog_sll_state ORDER BY week_start DESC LIMIT 1` ), // Overall performance stats pool.query(` SELECT COUNT(*) as total_posts, AVG(engagement_score) as avg_score, MAX(engagement_score) as best_score, COUNT(*) FILTER (WHERE engagement_score >= 20) as gold_count, COUNT(*) FILTER (WHERE engagement_score >= 10) as silver_count, COUNT(*) FILTER (WHERE engagement_score >= 4) as bronze_count, COUNT(*) FILTER (WHERE engagement_score < 4) as miss_count FROM blog_performance `), // Top 5 posts pool.query(` SELECT d.title, d.topic, p.engagement_score, p.hook_text, p.word_count, p.blog_type, p.posted_at, CASE WHEN p.engagement_score >= 20 THEN 'gold' WHEN p.engagement_score >= 10 THEN 'silver' WHEN p.engagement_score >= 4 THEN 'bronze' ELSE 'miss' END as tier FROM blog_performance p JOIN blog_drafts d ON d.id = p.blog_id ORDER BY p.engagement_score DESC LIMIT 5 `), // Bottom 5 posts pool.query(` SELECT d.title, d.topic, p.engagement_score, p.hook_text, p.word_count FROM blog_performance p JOIN blog_drafts d ON d.id = p.blog_id WHERE p.engagement_score IS NOT NULL ORDER BY p.engagement_score ASC LIMIT 5 `), // Active learned patterns pool.query(` SELECT pattern_type, pattern_value, performance_class, avg_engagement, sample_count FROM blog_learned_patterns WHERE active = TRUE ORDER BY performance_class, avg_engagement DESC NULLS LAST `), ]); const state = stateRes.rows[0] || null; const stats = statsRes.rows[0]; res.json({ success: true, stats: { total_posts: Number(stats.total_posts), avg_score: stats.avg_score ? Math.round(Number(stats.avg_score) * 10) / 10 : null, best_score: Number(stats.best_score) || 0, tiers: { gold: Number(stats.gold_count), silver: Number(stats.silver_count), bronze: Number(stats.bronze_count), miss: Number(stats.miss_count), }, }, current_state: state, top_posts: topRes.rows, bottom_posts: bottomRes.rows, learned_patterns: { winners: patternsRes.rows.filter((p: any) => p.performance_class === "winner"), losers: patternsRes.rows.filter((p: any) => p.performance_class === "loser"), }, sll_ready: Number(stats.total_posts) >= 5, note: Number(stats.total_posts) < 5 ? `SLL needs ${5 - Number(stats.total_posts)} more posts with performance data before pattern extraction` : "SLL active — enough data for pattern extraction", }); } catch (err) { res.status(500).json({ success: false, error: String(err) }); } }); // ───────────────────────────────────────────────────────────────── // In-memory Umami cache (TTL 1h — single PM2 process) // ───────────────────────────────────────────────────────────────── interface UmamiSlot { weekday: number; hour: number; sessions: number } let umamiCache: { slots: UmamiSlot[]; fetchedAt: number } | null = null; const UMAMI_TTL_MS = 60 * 60 * 1000; // 1h const UMAMI_URL = process.env["UMAMI_URL"] ?? "https://analytics.fichtmueller.org"; const UMAMI_USER = process.env["UMAMI_USER"] ?? "admin"; const UMAMI_PASS = process.env["UMAMI_PASS"] ?? ""; const UMAMI_WEBSITE = process.env["UMAMI_WEBSITE_ID"] ?? "c737bf75-ccc4-463b-992a-13bed31d7f43"; const DAY_NAMES = ["Mo","Di","Mi","Do","Fr","Sa","So"]; async function fetchUmamiToken(): Promise { try { const r = await fetch(`${UMAMI_URL}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: UMAMI_USER, password: UMAMI_PASS }), signal: AbortSignal.timeout(8000), }); const d = await r.json() as { token?: string }; return d.token ?? null; } catch { return null; } } async function fetchUmamiSlots(): Promise { // Return cache if fresh if (umamiCache && Date.now() - umamiCache.fetchedAt < UMAMI_TTL_MS) { return umamiCache.slots; } const token = await fetchUmamiToken(); if (!token) return []; const startAt = Date.now() - 90 * 24 * 60 * 60 * 1000; const endAt = Date.now(); const url = `${UMAMI_URL}/api/websites/${UMAMI_WEBSITE}/sessions?startAt=${startAt}&endAt=${endAt}&pageSize=500&page=1`; try { const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15000), }); const d = await r.json() as { data?: Array<{ firstAt?: string }> }; const sessions = d.data ?? []; // Build (weekday, hour) counts — Berlin = UTC+2 in April/summer const counts: Record = {}; for (const s of sessions) { if (!s.firstAt) continue; const dt = new Date(s.firstAt); const berlinH = (dt.getUTCHours() + 2) % 24; const berlinWd = dt.getUTCDay() === 0 ? 6 : dt.getUTCDay() - 1; // 0=Mon const key = `${berlinWd}:${berlinH}`; counts[key] = (counts[key] ?? 0) + 1; } const slots: UmamiSlot[] = Object.entries(counts).map(([key, n]) => { const [wd, h] = key.split(":").map(Number); return { weekday: wd, hour: h, sessions: n }; }); umamiCache = { slots, fetchedAt: Date.now() }; return slots; } catch { return []; } } // ───────────────────────────────────────────────────────────────── // GET /api/blog/sll/posting-time — best posting time // Combines Umami traffic data + SLL historical engagement by slot // ───────────────────────────────────────────────────────────────── blogSllRouter.get("/sll/posting-time", async (_req: Request, res: Response) => { try { // 1. Umami: traffic per (weekday, hour) const umamiSlots = await fetchUmamiSlots(); const umamiMax = Math.max(1, ...umamiSlots.map((s) => s.sessions)); // 2. SLL: avg engagement per (weekday, hour) from historical posts const sllRes = await pool.query(` SELECT EXTRACT(DOW FROM posted_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin')::int AS wd_raw, EXTRACT(HOUR FROM posted_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Berlin')::int AS hour, AVG(engagement_score) AS avg_eng, MAX(engagement_score) AS best_eng, COUNT(*) AS post_count FROM blog_performance WHERE posted_at IS NOT NULL AND engagement_score IS NOT NULL GROUP BY wd_raw, hour `); // Convert Sunday=0 (PostgreSQL DOW) to Monday=0 index const sllMap: Record = {}; for (const row of sllRes.rows) { const wd = row.wd_raw === 0 ? 6 : Number(row.wd_raw) - 1; // Mon=0 const key = `${wd}:${row.hour}`; sllMap[key] = { avgEng: Math.round(Number(row.avg_eng) * 10) / 10, bestEng: Number(row.best_eng), count: Number(row.post_count), }; } const sllMax = Math.max(1, ...Object.values(sllMap).map((v) => v.avgEng)); // 3. Build candidate slots (union of Umami + SLL slots) const allKeys = new Set([ ...umamiSlots.map((s) => `${s.weekday}:${s.hour}`), ...Object.keys(sllMap), ]); const scored = Array.from(allKeys).map((key) => { const [wd, h] = key.split(":").map(Number); const umami = umamiSlots.find((s) => s.weekday === wd && s.hour === h); const sll = sllMap[key]; const umamiScore = umami ? umami.sessions / umamiMax : 0; const sllScore = sll ? sll.avgEng / sllMax : 0; // Weight: 50% Umami traffic + 50% SLL engagement // If no SLL data → 100% Umami; if no Umami → 100% SLL const hasUmami = !!umami; const hasSll = !!sll; let combined: number; if (hasUmami && hasSll) { combined = umamiScore * 0.5 + sllScore * 0.5; } else if (hasUmami) { combined = umamiScore * 0.7; // penalise slots with no SLL validation } else { combined = sllScore * 0.6; // SLL-only slots get a slight boost } return { weekday: wd, hour: h, label: `${DAY_NAMES[wd]} ${String(h).padStart(2, "0")}:00h`, score: Math.round(combined * 100), umami_sessions: umami?.sessions ?? 0, sll_avg_engagement: sll?.avgEng ?? null, sll_best_engagement: sll?.bestEng ?? null, sll_post_count: sll?.count ?? 0, data_sources: [hasUmami ? "umami" : null, hasSll ? "sll" : null].filter(Boolean), }; }); // Sort by score descending scored.sort((a, b) => b.score - a.score); const top = scored.slice(0, 10); // Build weekday summary (best hour per weekday) const byWeekday: Record = {}; for (const slot of scored) { if (!byWeekday[slot.weekday] || slot.score > byWeekday[slot.weekday].score) { byWeekday[slot.weekday] = slot; } } const weekdaySummary = DAY_NAMES.map((name, wd) => ({ weekday: wd, name, best_slot: byWeekday[wd] ?? null, })); res.json({ success: true, top_slots: top, weekday_summary: weekdaySummary, recommended: top[0] ?? null, data_sources: { umami_sessions_analyzed: umamiSlots.reduce((s, x) => s + x.sessions, 0), umami_cache_age_min: umamiCache ? Math.round((Date.now() - umamiCache.fetchedAt) / 60000) : null, sll_posts_with_time: sllRes.rows.length, }, note: sllRes.rows.length === 0 ? "SLL has no timed posts yet — using Umami traffic data only" : `Combined Umami + ${sllRes.rows.length} SLL engagement data point(s)`, }); } catch (err) { console.error("posting-time error:", err); res.status(500).json({ success: false, error: String(err) }); } }); // ───────────────────────────────────────────────────────────────── // POST /api/blog/sll/sync-umami — force-refresh Umami cache // ───────────────────────────────────────────────────────────────── blogSllRouter.post("/sll/sync-umami", async (_req: Request, res: Response) => { umamiCache = null; // invalidate const slots = await fetchUmamiSlots(); res.json({ success: slots.length > 0, slots_loaded: slots.length, total_sessions: slots.reduce((s, x) => s + x.sessions, 0), message: slots.length > 0 ? "Umami cache refreshed" : "Umami unreachable — check credentials", }); }); // ───────────────────────────────────────────────────────────────── // GET /api/blog/sll/patterns — all learned patterns // ───────────────────────────────────────────────────────────────── blogSllRouter.get("/sll/patterns", async (_req: Request, res: Response) => { try { const result = await pool.query(` SELECT lp.*, d.title as example_title FROM blog_learned_patterns lp LEFT JOIN blog_drafts d ON d.id = lp.example_post_id WHERE lp.active = TRUE ORDER BY lp.performance_class, lp.avg_engagement DESC NULLS LAST `); res.json({ success: true, patterns: result.rows }); } catch (err) { res.status(500).json({ success: false, error: String(err) }); } }); // ───────────────────────────────────────────────────────────────── // POST /api/blog/sll/analyze — trigger LLM pattern extraction // ───────────────────────────────────────────────────────────────── blogSllRouter.post("/sll/analyze", async (_req: Request, res: Response) => { try { // Need at least 3 posts with performance data const countRes = await pool.query( `SELECT COUNT(*) as cnt FROM blog_performance WHERE engagement_score IS NOT NULL` ); const count = Number(countRes.rows[0].cnt); if (count < 3) { res.status(400).json({ success: false, error: `Not enough data — need at least 3 posts with performance data, have ${count}`, }); return; } // Pull all performance data with article context const perfData = await pool.query(` SELECT d.title, d.topic, d.word_count as article_words, d.pipeline_version, p.engagement_score, p.hook_text, p.blog_type, p.comments, p.shares, p.saves, p.likes, p.impressions, CASE WHEN p.engagement_score >= 20 THEN 'gold' WHEN p.engagement_score >= 10 THEN 'silver' WHEN p.engagement_score >= 4 THEN 'bronze' ELSE 'miss' END as tier FROM blog_performance p JOIN blog_drafts d ON d.id = p.blog_id ORDER BY p.engagement_score DESC `); // Sort into winners / losers const winners = perfData.rows.filter((r: any) => r.engagement_score >= 10); const losers = perfData.rows.filter((r: any) => r.engagement_score < 4); // Build LLM prompt for pattern extraction const analysisPrompt = `You are analyzing LinkedIn post performance data for the Flexoptix technical blog. PERFORMANCE DATA (${count} posts): TOP PERFORMERS: ${winners.map((r: any, i: number) => `${i+1}. Score: ${r.engagement_score} (${r.tier}) Title: ${r.title} Topic: ${r.topic} | Words: ${r.article_words} Hook: "${r.hook_text || "n/a"}" Metrics: ${r.comments}c / ${r.shares}sh / ${r.saves}sa / ${r.likes}li`).join("\n\n") || "No winners yet"} UNDERPERFORMERS: ${losers.map((r: any, i: number) => `${i+1}. Score: ${r.engagement_score} Title: ${r.title} Topic: ${r.topic} | Words: ${r.article_words} Hook: "${r.hook_text || "n/a"}"`).join("\n\n") || "No losers yet"} SCORING FORMULA: (comments×3) + (shares×2) + (saves×2) Likes = 0 weight. Saves and shares = real interest. Extract patterns in this EXACT JSON format: { "winner_patterns": [ {"type": "hook|structure|topic|length|opening", "value": "description of what works", "evidence": "why you think this"} ], "loser_patterns": [ {"type": "hook|structure|topic|length|opening", "value": "description of what fails", "evidence": "why"} ], "optimal_length": {"min": 900, "max": 1400}, "top_topics": ["topic1", "topic2"], "avoid_topics": ["topic3"], "best_hook_patterns": ["pattern1", "pattern2"], "key_insight": "One sentence: the single most important finding from this data" } Be specific. "short hook + contrast" is better than "good hooks". Use the actual data.`; // Call LLM let extractedPatterns: Record | null = null; try { const { generate } = await import("../llm/client"); const llmResult = await generate( "You are a content analytics expert extracting patterns from performance data. Return only valid JSON.", analysisPrompt, { temperature: 0.2, maxTokens: 2048, timeoutMs: 120000 } ); const jsonMatch = llmResult.text.match(/\{[\s\S]*"winner_patterns"[\s\S]*\}/); if (jsonMatch) { extractedPatterns = JSON.parse(jsonMatch[0]); } } catch (llmErr) { console.warn("SLL LLM extraction failed, using heuristic fallback:", llmErr); } // Heuristic fallback if LLM fails if (!extractedPatterns) { const avgWinner = winners.length > 0 ? winners.reduce((s: number, r: any) => s + Number(r.article_words), 0) / winners.length : 1100; const avgLoser = losers.length > 0 ? losers.reduce((s: number, r: any) => s + Number(r.article_words), 0) / losers.length : 1400; extractedPatterns = { winner_patterns: [ { type: "length", value: `~${Math.round(avgWinner)} words performs best`, evidence: "heuristic from top posts" }, ], loser_patterns: [ { type: "length", value: `~${Math.round(avgLoser)} words underperforms`, evidence: "heuristic from low posts" }, ], optimal_length: { min: Math.round(avgWinner * 0.8), max: Math.round(avgWinner * 1.2) }, top_topics: [...new Set(winners.map((r: any) => r.topic))].slice(0, 3), avoid_topics: [...new Set(losers.map((r: any) => r.topic))].slice(0, 2), best_hook_patterns: winners.filter((r: any) => r.hook_text).map((r: any) => r.hook_text).slice(0, 2), key_insight: "Based on available data — more posts needed for reliable patterns", }; } const ep = extractedPatterns as any; // Save patterns to DB (upsert by value to avoid duplicates) let savedCount = 0; const allPatterns = [ ...(ep.winner_patterns || []).map((p: any) => ({ ...p, cls: "winner" })), ...(ep.loser_patterns || []).map((p: any) => ({ ...p, cls: "loser" })), ]; for (const p of allPatterns) { await pool.query( `INSERT INTO blog_learned_patterns (pattern_type, pattern_value, performance_class, sample_count) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, [p.type, p.value, p.cls, count] ); savedCount++; } // Save weekly SLL state const weekStart = new Date(); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Monday await pool.query( `INSERT INTO blog_sll_state (week_start, winner_patterns, loser_patterns, top_topics, avoid_topics, optimal_length_min, optimal_length_max, best_hook_patterns, posts_analyzed, generated_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'sll-analyze') ON CONFLICT (week_start) DO UPDATE SET winner_patterns = EXCLUDED.winner_patterns, loser_patterns = EXCLUDED.loser_patterns, top_topics = EXCLUDED.top_topics, avoid_topics = EXCLUDED.avoid_topics, optimal_length_min = EXCLUDED.optimal_length_min, optimal_length_max = EXCLUDED.optimal_length_max, best_hook_patterns = EXCLUDED.best_hook_patterns, posts_analyzed = EXCLUDED.posts_analyzed, generated_at = NOW()`, [ weekStart.toISOString().split("T")[0], JSON.stringify(ep.winner_patterns || []), JSON.stringify(ep.loser_patterns || []), JSON.stringify(ep.top_topics || []), JSON.stringify(ep.avoid_topics || []), ep.optimal_length?.min ?? null, ep.optimal_length?.max ?? null, JSON.stringify(ep.best_hook_patterns || []), count, ] ); console.log(`SLL: Pattern analysis complete — ${savedCount} patterns saved, ${count} posts analyzed`); res.json({ success: true, posts_analyzed: count, patterns_saved: savedCount, key_insight: ep.key_insight, winner_patterns: ep.winner_patterns, loser_patterns: ep.loser_patterns, optimal_length: ep.optimal_length, top_topics: ep.top_topics, best_hook_patterns: ep.best_hook_patterns, }); } catch (err) { console.error("SLL analyze error:", err); res.status(500).json({ success: false, error: String(err) }); } });