MCP Server (packages/mcp-server/src/index.ts): - Register registerSwitchDocTools (switch-docs.ts) — switch documentation lookup - Register finderTools dynamically (finder.ts) — find_flexoptix_for_switch, get_competitor_alerts - Add analyze_market_with_llm tool: qwen2.5:14b via Ollama, enriched with live hype cycle + pricing + news - Add generate_blog_post tool: fo-blog-v5 (fine-tuned) with qwen2.5:14b fallback, enriched with live pricing data - OLLAMA_BASE_URL env var (default: https://ollama.fichtmueller.org) Also includes scraper improvements (ascentoptics, atgbics, gbics, skylane, ebay-enricher), API route updates (blog, blog-sll, health, hot-topics, transceivers, queries), and dashboard hot-topics refresh.
578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
/**
|
||
* 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<string | null> {
|
||
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<UmamiSlot[]> {
|
||
// 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<string, number> = {};
|
||
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<string, { avgEng: number; bestEng: number; count: number }> = {};
|
||
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<number, typeof top[0]> = {};
|
||
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<string, unknown> | 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) });
|
||
}
|
||
});
|