Rene Fichtmueller e9fcda2811 feat: wire finder.ts + switch-docs + Ollama LLM tools to MCP server
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.
2026-04-18 00:21:58 +02:00

578 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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) });
}
});