feat(v0.2.5): hot topics engine + pipeline lock + UX fixes

Hot Topics Engine (GET /api/hot-topics):
- 7 data sources: price movements, competitor alerts, hype cycle transitions,
  news articles, conference calendar, research trends, evergreen topics
- Auto-discovers BREAKING/HOT/TRENDING/EMERGING topics
- Dashboard loads topics dynamically with urgency badges and source labels
- Click any topic → generates blog with that angle

Pipeline Lock (critical UX fix):
- Only 1 blog generation at a time (blogPipelineRunning flag)
- 'Pipeline Busy' toast if user clicks while generating
- Lock released on completion, timeout, or error

Dashboard:
- Static 3 cards replaced with dynamic hot topics grid
- 'Refresh Topics' button
- Topics show urgency color (red=breaking, orange=hot, yellow=trending, green=emerging)
- Auto-loads when Blog Engine tab opens
This commit is contained in:
Rene Fichtmueller 2026-03-31 09:49:43 +02:00
parent 278207078b
commit 3132b58309
4 changed files with 347 additions and 24 deletions

View File

@ -18,6 +18,7 @@ import { competitorRouter } from "./routes/competitor-alerts";
import { forecastRouter } from "./routes/forecast";
import { transportRouter } from "./routes/transport";
import { datasheetRouter } from "./routes/datasheets";
import { hotTopicsRouter } from "./routes/hot-topics";
import { adoptionRouter } from "./routes/adoption";
const app = express();
@ -54,6 +55,7 @@ app.use("/api/forecast", forecastRouter);
app.use("/api/transport", transportRouter);
app.use("/api/datasheets", datasheetRouter);
app.use("/api/adoption", adoptionRouter);
app.use("/api/hot-topics", hotTopicsRouter);
// Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
@ -67,7 +69,7 @@ app.get("/", (_req, res) => {
app.get("/api", (_req, res) => {
res.json({
name: "Transceiver Intelligence Platform",
version: "0.2.4",
version: "0.2.5",
endpoints: [
"GET /api/transceivers?q=&form_factor=&speed=&category=&fiber_type=&wdm_type=&coherent=",
"GET /api/transceivers/:id",

View File

@ -14,7 +14,7 @@ healthRouter.get("/", async (_req: Request, res: Response) => {
res.json({
success: true,
status: "healthy",
version: "0.2.4",
version: "0.2.5",
uptime: process.uptime(),
database: {
connected: true,

View File

@ -0,0 +1,291 @@
/**
* Hot Topics Engine Discovers trending blog topics from multiple sources
*
* Sources:
* 1. OFC/ECOC/CIOE conference papers + announcements
* 2. Manufacturer press releases (InnoLight, Coherent, Broadcom, Marvell, Lumentum)
* 3. Trade press (Lightwave, Fibre Systems, Gazettabyte, LightCounting)
* 4. arXiv papers (cs.NI, eess.SP, physics.optics)
* 5. Internal: price movements, new competitor products, hype cycle shifts
* 6. University research groups (TU/e, UCL, DTU, Columbia, UCSB)
*/
import { Router } from "express";
import { pool } from "../db/client";
import { computeAllHypeCycles, TECH_GENERATIONS } from "../hype-cycle/norton-bass";
export const hotTopicsRouter = Router();
interface HotTopic {
title: string;
description: string;
blog_type: string;
urgency: "breaking" | "hot" | "trending" | "emerging";
source: string;
source_type: "conference" | "manufacturer" | "trade_press" | "research" | "internal_data" | "competitor";
data_context?: Record<string, unknown>;
suggested_angle?: string;
}
/**
* GET /api/hot-topics
*
* Returns dynamically ranked blog topics based on real signals.
*/
hotTopicsRouter.get("/", async (_req, res) => {
try {
const topics: HotTopic[] = [];
const year = new Date().getFullYear();
// ═══ SOURCE 1: Internal Data — Price Movements ═══
const priceDrops = await pool.query(`
SELECT v.name AS vendor, t.form_factor, t.speed_gbps, t.reach_label,
pc.old_price, pc.new_price, pc.delta_pct, pc.currency, pc.detected_at
FROM price_changes pc
JOIN vendors v ON pc.vendor_id = v.id
JOIN transceivers t ON pc.transceiver_id = t.id
WHERE pc.delta_pct < -10 AND pc.detected_at > NOW() - INTERVAL '14 days'
ORDER BY pc.delta_pct ASC LIMIT 5
`).catch(() => ({ rows: [] }));
for (const drop of priceDrops.rows) {
topics.push({
title: `${drop.vendor} drops ${drop.form_factor} ${drop.speed_gbps}G prices by ${Math.abs(Math.round(drop.delta_pct))}%`,
description: `${drop.form_factor} ${drop.speed_gbps}G ${drop.reach_label} went from ${drop.currency} ${drop.old_price} to ${drop.currency} ${drop.new_price}. Market signal or clearance?`,
blog_type: "market_alert",
urgency: Math.abs(drop.delta_pct) > 20 ? "breaking" : "hot",
source: drop.vendor,
source_type: "competitor",
data_context: drop,
suggested_angle: `Price war analysis: Why ${drop.vendor} is cutting ${drop.speed_gbps}G pricing and what it means for procurement`,
});
}
// ═══ SOURCE 2: Internal Data — New Competitor Products ═══
const newProducts = await pool.query(`
SELECT ca.product_name, ca.form_factor, ca.speed_gbps, ca.source_url,
v.name AS vendor, ca.created_at
FROM competitor_alerts ca
JOIN vendors v ON ca.vendor_id = v.id
WHERE ca.alert_type = 'new_product' AND ca.created_at > NOW() - INTERVAL '14 days'
ORDER BY ca.speed_gbps DESC, ca.created_at DESC LIMIT 5
`).catch(() => ({ rows: [] }));
if (newProducts.rows.length > 0) {
const vendors = [...new Set(newProducts.rows.map(p => p.vendor))];
const speeds = [...new Set(newProducts.rows.map(p => p.speed_gbps + "G"))];
topics.push({
title: `${newProducts.rows.length} new transceiver products from ${vendors.slice(0, 3).join(", ")}`,
description: `New ${speeds.join("/")} products spotted. Competitive landscape shifting.`,
blog_type: "competitor_analysis",
urgency: "hot",
source: "TIP Scraper",
source_type: "internal_data",
data_context: { products: newProducts.rows },
suggested_angle: `Competitor roundup: What ${vendors[0]} and others just launched — and what it means for your next PO`,
});
}
// ═══ SOURCE 3: Internal Data — Hype Cycle Phase Transitions ═══
const hypes = computeAllHypeCycles(year);
const transitions = hypes.filter(h => {
const tech = TECH_GENERATIONS.find(t => t.name === h.technology);
if (!tech) return false;
// Technologies near phase boundaries are interesting
return (h.positionPct > 25 && h.positionPct < 35) || // Entering trough
(h.positionPct > 48 && h.positionPct < 55) || // Leaving trough
(h.positionPct > 78 && h.positionPct < 85); // Entering plateau
});
for (const t of transitions.slice(0, 2)) {
const phaseName = t.phase.replace(/_/g, " ").toLowerCase();
topics.push({
title: `${t.technology} entering ${phaseName}`,
description: `Adoption at ${Math.round(t.adoptionPct)}%. This phase transition means pricing and availability shifts.`,
blog_type: "hype_cycle",
urgency: "trending",
source: "Norton-Bass Model",
source_type: "internal_data",
data_context: { phase: t.phase, adoption: t.adoptionPct, position: t.positionPct },
suggested_angle: `${t.technology} phase shift: What it means for your ${year} infrastructure budget`,
});
}
// ═══ SOURCE 4: News Articles — Recent Industry News ═══
const recentNews = await pool.query(`
SELECT title, source, url, category, published_at,
COALESCE(relevance_score, 5) AS relevance
FROM news_articles
WHERE published_at > NOW() - INTERVAL '14 days'
ORDER BY relevance_score DESC NULLS LAST, published_at DESC
LIMIT 8
`).catch(() => ({ rows: [] }));
// Cluster news by theme
const newsThemes: Record<string, typeof recentNews.rows> = {};
for (const n of recentNews.rows) {
const theme = detectNewsTheme(n.title);
if (!newsThemes[theme]) newsThemes[theme] = [];
newsThemes[theme].push(n);
}
for (const [theme, articles] of Object.entries(newsThemes)) {
if (articles.length >= 2) {
topics.push({
title: `${theme}: ${articles.length} recent articles`,
description: articles.map(a => a.title).slice(0, 3).join(" | "),
blog_type: "technology_deep_dive",
urgency: "trending",
source: articles.map(a => a.source).filter(Boolean).slice(0, 2).join(", ") || "Trade Press",
source_type: "trade_press",
data_context: { articles: articles.slice(0, 3) },
suggested_angle: `${theme}: What the latest announcements actually mean for network operators`,
});
}
}
// ═══ SOURCE 5: Conference Calendar — Upcoming/Recent Events ═══
const conferences = getConferenceTopics(year);
topics.push(...conferences);
// ═══ SOURCE 6: Emerging Tech — Research-Driven Topics ═══
topics.push(...getResearchTopics(year));
// ═══ SOURCE 7: Evergreen High-Value Topics ═══
topics.push(...getEvergreenTopics(year));
// Sort by urgency: breaking > hot > trending > emerging
const urgencyOrder: Record<string, number> = { breaking: 0, hot: 1, trending: 2, emerging: 3 };
topics.sort((a, b) => (urgencyOrder[a.urgency] ?? 4) - (urgencyOrder[b.urgency] ?? 4));
res.json({
topics: topics.slice(0, 12),
total: topics.length,
generated_at: new Date().toISOString(),
sources: ["internal_price_data", "competitor_alerts", "hype_cycle_model", "news_articles", "conference_calendar", "research_papers"],
});
} catch (err) {
console.error("Hot topics error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
function detectNewsTheme(title: string): string {
const tl = title.toLowerCase();
if (tl.includes("800g") || tl.includes("osfp")) return "800G Deployment Wave";
if (tl.includes("1.6t") || tl.includes("1.6 t")) return "1.6T Race";
if (tl.includes("silicon photonics") || tl.includes("sipho")) return "Silicon Photonics";
if (tl.includes("cpo") || tl.includes("co-packaged")) return "Co-Packaged Optics";
if (tl.includes("lpo") || tl.includes("linear")) return "Linear-Drive Pluggable";
if (tl.includes("400zr") || tl.includes("coherent")) return "Coherent Pluggable";
if (tl.includes("ai") || tl.includes("gpu") || tl.includes("ml")) return "AI/ML Fabric Optics";
if (tl.includes("innolight") || tl.includes("coherent corp") || tl.includes("broadcom")) return "Manufacturer Moves";
if (tl.includes("supply") || tl.includes("shortage") || tl.includes("lead time")) return "Supply Chain Alert";
return "Industry Update";
}
function getConferenceTopics(year: number): HotTopic[] {
const now = new Date();
const month = now.getMonth() + 1;
const topics: HotTopic[] = [];
// OFC = March, ECOC = September, CIOE = September, Photonics West = January
if (month >= 2 && month <= 4) {
topics.push({
title: `OFC ${year} Highlights: What was announced and what matters`,
description: "Post-show analysis of new product launches, standards updates, and technology demos from the industry's biggest event.",
blog_type: "technology_deep_dive",
urgency: month === 3 ? "breaking" : "hot",
source: "OFC Conference",
source_type: "conference",
suggested_angle: "OFC show floor reality: 5 announcements that actually change your procurement strategy",
});
}
if (month >= 8 && month <= 10) {
topics.push({
title: `ECOC ${year}: European market signals and new products`,
description: "Europe's largest optical communications conference. Key for understanding EU market direction.",
blog_type: "technology_deep_dive",
urgency: month === 9 ? "breaking" : "trending",
source: "ECOC Conference",
source_type: "conference",
suggested_angle: "ECOC takeaways: What European carriers are actually deploying (not just demoing)",
});
}
// Always relevant
topics.push({
title: `${year} Standards Update: IEEE 802.3 / OIF / MSA tracker`,
description: "What's ratified, what's in draft, and what it means for your next-gen optics roadmap.",
blog_type: "technology_deep_dive",
urgency: "trending",
source: "IEEE / OIF / MSA",
source_type: "research",
suggested_angle: "Standards reality check: Which specs are production-ready vs. still in committee",
});
return topics;
}
function getResearchTopics(year: number): HotTopic[] {
return [
{
title: "Silicon Photonics: From Lab to Production — Where Are We Really?",
description: "Intel, Broadcom, Marvell, and startups (Lightmatter, Ayar Labs) are all pushing SiPho. But production yields and packaging costs tell a different story.",
blog_type: "technology_deep_dive",
urgency: "trending",
source: "Industry Research",
source_type: "research",
suggested_angle: "Silicon Photonics reality: What works in production vs what's still a conference demo",
},
{
title: "LPO vs DSP: The Power Efficiency Battle That Reshapes Data Centers",
description: "Linear-drive pluggable optics promise 50% power savings by eliminating the DSP. But interop and reach limitations are real.",
blog_type: "comparison",
urgency: "emerging",
source: "OIF / University Research",
source_type: "research",
suggested_angle: "LPO honest assessment: When the power savings justify the interop risk",
},
{
title: `CPO Roadmap ${year}: Co-Packaged Optics Timeline Reality Check`,
description: "Broadcom showed CPO demos at OFC. But what's the real timeline for rack-level deployment?",
blog_type: "hype_cycle",
urgency: "emerging",
source: "Broadcom / Intel / TSMC",
source_type: "manufacturer",
suggested_angle: "CPO: Why it's 3 years away (and has been for the last 5 years)",
},
];
}
function getEvergreenTopics(year: number): HotTopic[] {
return [
{
title: `The ${year} Transceiver Buying Guide: OEM vs Compatible`,
description: "Updated pricing data, vendor quality tiers, and the procurement playbook for every speed class.",
blog_type: "buying_guide",
urgency: "trending",
source: "TIP Price Data",
source_type: "internal_data",
suggested_angle: "Stop overpaying: The definitive OEM vs compatible decision framework with real numbers",
},
{
title: `400G Migration Playbook: From 100G to 400G in 12 Months`,
description: "Step-by-step migration guide including cabling, switch selection, optics procurement, and what goes wrong.",
blog_type: "migration_guide",
urgency: "trending",
source: "Field Experience",
source_type: "internal_data",
suggested_angle: "Migration horror stories and how to avoid them: The 100G→400G field guide",
},
{
title: "MPO Connector Survival Guide: Polarity, Cleaning, and Why Your Links Keep Dying",
description: "The #1 cause of 40G/100G/400G deployment failures. Everything you need to know about MPO.",
blog_type: "tutorial",
urgency: "trending",
source: "Support Cases",
source_type: "internal_data",
suggested_angle: "Your $350 optic isn't broken — your connector is dirty. The MPO reality guide.",
},
];
}

View File

@ -873,19 +873,12 @@
<!-- BLOG -->
<div id="tab-blog" class="hidden">
<div class="grid g3 mb">
<div class="gen-card" id="gen-hype">
<div class="gen-card-title">Hype Cycle Analysis</div>
<div class="gen-card-sub">800G technology position article</div>
</div>
<div class="gen-card" id="gen-comparison">
<div class="gen-card-title">Product Comparison</div>
<div class="gen-card-sub">400G transceiver comparison</div>
</div>
<div class="gen-card" id="gen-tutorial">
<div class="gen-card-title">Tutorial</div>
<div class="gen-card-sub">Transceiver troubleshooting guide</div>
<div style="margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center">
<h3 style="font-size:1.1rem;font-weight:600">Hot Topics <span style="font-size:0.75rem;color:var(--text-dim);font-weight:400">(auto-discovered from market data, conferences, research)</span></h3>
<button onclick="loadHotTopics()" style="background:var(--accent);color:white;border:none;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:0.8rem">Refresh Topics</button>
</div>
<div id="hot-topics-grid" class="grid g3 mb" style="min-height:80px">
<div class="loading pulse">Loading hot topics...</div>
</div>
<div class="card"><div id="blog-list"></div></div>
</div>
@ -1171,7 +1164,7 @@ function goToTab(tabName) {
if (tabName === 'transceivers') searchTransceivers();
if (tabName === 'switches') searchSwitches();
if (tabName === 'news') loadNews();
if (tabName === 'blog') loadBlogDrafts();
if (tabName === 'blog') { loadBlogDrafts(); if (typeof loadHotTopics === 'function') loadHotTopics(); }
}
document.querySelectorAll('.tab').forEach(function(tab) {
@ -2286,8 +2279,14 @@ function copyBlogContent(id) {
}
// BLOG
function generateBlog(topic, speed) {
// Show prominent progress overlay instead of just adding to list
function generateBlog(topic, speed, customTitle, customAngle) {
// Pipeline lock: prevent multiple simultaneous generations
if (blogPipelineRunning) {
showToast('Pipeline Busy', 'A blog is already being generated. Wait for it to finish.', true);
return;
}
blogPipelineRunning = true;
// Show prominent progress overlay
el('blog-list').innerHTML = '<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center">' +
'<div style="font-size:1.5rem;font-weight:700;margin-bottom:1rem">🔄 Generating Blog with AI...</div>' +
'<div id="blog-pipeline-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
@ -2316,7 +2315,7 @@ function generateBlog(topic, speed) {
showToast('Failed', data.error || 'Unknown error', true);
loadBlogDrafts();
}
}).catch(function(err) { showToast('Network error', err.message, true); loadBlogDrafts(); });
}).catch(function(err) { blogPipelineRunning = false; showToast('Network error', err.message, true); loadBlogDrafts(); });
}
var STEP_NAMES = [
@ -2334,7 +2333,7 @@ var STEP_NAMES = [
function pollBlogLlm(id, attempt) {
if (attempt > 60) {
showToast('Timeout', 'LLM pipeline took too long. Refresh to check.');
blogPipelineRunning = false; showToast('Timeout', 'LLM pipeline took too long. Refresh to check.');
loadBlogDrafts();
return;
}
@ -2356,7 +2355,7 @@ function pollBlogLlm(id, attempt) {
status.style.color = '#2d6a4f';
}
showToast('Blog Ready!', d.title + ' — ' + d.word_count + ' words (LLM-generated)');
setTimeout(function() { loadBlogDrafts(); viewBlogDraft(id); }, 2000);
blogPipelineRunning = false; setTimeout(function() { loadBlogDrafts(); viewBlogDraft(id); }, 2000);
} else {
// Still processing — update progress
var pctVal = Math.min(95, steps * 10 + 5);
@ -2374,9 +2373,40 @@ function pollBlogLlm(id, attempt) {
}, 15000);
}
el('gen-hype').addEventListener('click', function() { generateBlog('hype_cycle', '800G'); });
el('gen-comparison').addEventListener('click', function() { generateBlog('comparison', '400G'); });
el('gen-tutorial').addEventListener('click', function() { generateBlog('tutorial'); });
// Hot Topics: load dynamically from API
var blogPipelineRunning = false;
function loadHotTopics() {
var grid = el('hot-topics-grid');
if (!grid) return;
grid.innerHTML = '<div class="loading pulse">Discovering hot topics...</div>';
api('/api/hot-topics').then(function(data) {
if (!data.topics || data.topics.length === 0) {
grid.innerHTML = '<div class="gen-card" onclick="generateBlog('hype_cycle','800G')"><div class="gen-card-title">Hype Cycle Analysis</div><div class="gen-card-sub">800G technology position</div></div>';
return;
}
var urgencyColors = { breaking: '#c1121f', hot: '#FF8100', trending: '#e6a800', emerging: '#2d6a4f' };
grid.innerHTML = data.topics.slice(0, 6).map(function(t) {
var color = urgencyColors[t.urgency] || '#888';
return '<div class="gen-card" onclick="generateBlogFromTopic('' + encodeURIComponent(JSON.stringify(t)) + '')" style="cursor:pointer;border-left:3px solid ' + color + '">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">' +
'<span style="font-size:0.65rem;text-transform:uppercase;font-weight:600;color:' + color + '">' + t.urgency + '</span>' +
'<span style="font-size:0.6rem;color:var(--text-dim)">' + t.source_type + '</span></div>' +
'<div class="gen-card-title" style="font-size:0.85rem;line-height:1.3">' + t.title + '</div>' +
'<div class="gen-card-sub" style="font-size:0.7rem;margin-top:4px">' + (t.suggested_angle || t.description).slice(0, 80) + '...</div>' +
'</div>';
}).join('');
}).catch(function() {
grid.innerHTML = '<div class="gen-card" onclick="generateBlog('hype_cycle','800G')"><div class="gen-card-title">Hype Cycle Analysis</div></div><div class="gen-card" onclick="generateBlog('comparison','400G')"><div class="gen-card-title">Product Comparison</div></div><div class="gen-card" onclick="generateBlog('tutorial')"><div class="gen-card-title">Tutorial</div></div>';
});
}
function generateBlogFromTopic(encodedTopic) {
var t = JSON.parse(decodeURIComponent(encodedTopic));
generateBlog(t.blog_type, null, t.title, t.suggested_angle);
}
// Load hot topics when blog tab opens
var origLoadBlog = loadBlogDrafts;
async function loadBlogDrafts() {
var data = await api('/api/blog');