diff --git a/packages/api/src/db/queries.ts b/packages/api/src/db/queries.ts index c507ce9..b0cf7e8 100644 --- a/packages/api/src/db/queries.ts +++ b/packages/api/src/db/queries.ts @@ -341,9 +341,9 @@ export async function getFlexoptixSuggestions(switchId: string) { ) SELECT t.id, t.slug, t.part_number, t.standard_name, t.form_factor, t.speed, t.speed_gbps, t.reach_meters, t.reach_label, - t.fiber_type, t.wavelength_nm, t.market_status, + t.fiber_type, t.wavelength_tx_nm AS wavelength_nm, t.market_status, t.product_page_url, t.image_url, - t.price_verified_eur, t.price_verified_at, t.price_verified_usd, + t.price_verified_eur, t.price_verified_at, t.street_price_usd AS price_verified_usd, v.name AS vendor_name, v.website AS vendor_website, COALESCE(t.price_verified_eur, (SELECT po.price FROM price_observations po diff --git a/packages/api/src/routes/hot-topics.ts b/packages/api/src/routes/hot-topics.ts index 7c12e0f..0d6e229 100644 --- a/packages/api/src/routes/hot-topics.ts +++ b/packages/api/src/routes/hot-topics.ts @@ -172,7 +172,8 @@ hotTopicsRouter.get("/", async (req, res) => { // ═══ SOURCE 3c: NOG Conference Talks — scraped from NOG agendas ═══ const nogTalks = await pool.query(` - SELECT title, source, source_url, published_at, relevance_score + SELECT title, source, source_url, published_at, relevance_score, + summary, mentioned_vendors, mentioned_products, mentioned_standards FROM news_articles WHERE source LIKE 'NOG Talks:%' AND relevance_score > 0.4 @@ -191,11 +192,17 @@ hotTopicsRouter.get("/", async (req, res) => { } for (const [event, talks] of Object.entries(nogByEvent)) { const topTalk = (talks as NogRow[])[0]; + const talkBullets = (talks as NogRow[]).slice(0, 5).map(t => { + const vendors = Array.isArray(t.mentioned_vendors) ? (t.mentioned_vendors as string[]).slice(0, 3).join(", ") : ""; + const products = Array.isArray(t.mentioned_products) ? (t.mentioned_products as string[]).slice(0, 3).join(", ") : ""; + const extra = [vendors, products].filter(Boolean).join(" / "); + return `• ${t.title}${extra ? ` (${extra})` : ""}${t.summary ? ` — ${String(t.summary).slice(0, 120)}` : ""}`; + }).join("\n"); topics.push({ title: talks.length === 1 ? `[${event}] ${topTalk.title}` : `${event}: ${talks.length} optics-relevant talks`, - description: (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "), + description: talkBullets || (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "), blog_type: "technology_deep_dive", urgency: "hot", source: event, @@ -209,7 +216,8 @@ hotTopicsRouter.get("/", async (req, res) => { // ═══ SOURCE 4: News Articles — Recent Industry News ═══ const recentNews = await pool.query(` SELECT title, source, source_url, category, published_at, - COALESCE(relevance_score, 5) AS relevance + COALESCE(relevance_score, 5) AS relevance, + summary, mentioned_vendors, mentioned_products, mentioned_standards, tags FROM news_articles WHERE source NOT LIKE 'NOG Talks:%' AND published_at > NOW() - INTERVAL '14 days' @@ -228,14 +236,22 @@ hotTopicsRouter.get("/", async (req, res) => { for (const [theme, articles] of Object.entries(newsThemes)) { if (articles.length >= 1) { + // Build rich description with article summaries, vendors, standards mentioned + const articleBullets = (articles as NewsRow[]).slice(0, 5).map(a => { + const vendors = Array.isArray(a.mentioned_vendors) ? (a.mentioned_vendors as string[]).slice(0, 3).join(", ") : ""; + const stds = Array.isArray(a.mentioned_standards) ? (a.mentioned_standards as string[]).slice(0, 2).join(", ") : ""; + const meta = [vendors, stds].filter(Boolean).join(" / "); + return `• ${a.title}${meta ? ` [${meta}]` : ""}${a.summary ? ` — ${String(a.summary).slice(0, 150)}` : ""}`; + }).join("\n"); + topics.push({ title: `${theme}: ${articles.length} recent articles`, - description: articles.map(a => a.title).slice(0, 3).join(" | "), + description: articleBullets || 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) }, + data_context: { articles: articles.slice(0, 5) }, suggested_angle: `${theme}: What the latest announcements actually mean for network operators`, date: articles[0]?.published_at ? new Date(articles[0].published_at).toISOString() : undefined, }); @@ -407,22 +423,55 @@ function compactDataContext(data: Record | undefined): string { function buildTopicBriefing(topic: HotTopic): string { const lines = [ - `Topic: ${topic.title}`, - `Urgency: ${topic.urgency}`, - `Source: ${topic.source_type} / ${topic.source}`, + `=== BLOG BRIEFING: ${topic.title} ===`, + ``, + `Urgency: ${topic.urgency.toUpperCase()}`, + `Source category: ${topic.source_type} | Source: ${topic.source}`, ]; - if (topic.date) lines.push(`Signal date: ${topic.date}`); - if (topic.description) lines.push(`Signal summary: ${topic.description}`); - if (topic.suggested_angle) lines.push(`Recommended angle: ${topic.suggested_angle}`); - if (topic.blog_title_created && topic.last_blog_created_at) { - lines.push(`Editorial note: A blog with a very similar title already exists from ${topic.last_blog_created_at}. If used anyway, choose a materially different angle.`); + if (topic.date) lines.push(`Signal date: ${new Date(topic.date).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}`); + + // Core signal content — article bullets or summary + if (topic.description) { + lines.push(``, `--- Market Signals ---`); + // Already formatted as bullets for news/nog, or plain summary for market intel + lines.push(topic.description.includes("•") ? topic.description : `Signal summary: ${topic.description}`); } - const dataContext = compactDataContext(topic.data_context); - if (dataContext) lines.push(`Structured supporting data:\n${dataContext}`); + // Recommended editorial angle + if (topic.suggested_angle) { + lines.push(``, `--- Recommended Blog Angle ---`); + lines.push(topic.suggested_angle); + } + + // Structured data from data_context (vendors, tech, buy signal, etc.) + const ctx = topic.data_context; + if (ctx) { + const extraLines: string[] = []; + if (ctx.buy_signal && typeof ctx.buy_signal === "string") { + const signalMap: Record = { bullish: "BUY signal — demand growing, order soon", bearish: "WAIT signal — pricing softening or supply improving", opportunity: "SHORT-TERM OPPORTUNITY — act now", neutral: "Monitor — no immediate action needed" }; + extraLines.push(`Buy signal: ${signalMap[ctx.buy_signal] ?? ctx.buy_signal}`); + } + if (ctx.technologies && String(ctx.technologies).length > 2) extraLines.push(`Key technologies: ${ctx.technologies}`); + if (ctx.impact_months) extraLines.push(`Expected market impact: within ${ctx.impact_months} months`); + if (extraLines.length > 0) { + lines.push(``, `--- Market Context ---`); + lines.push(...extraLines); + } + } + + if (topic.blog_title_created && topic.last_blog_created_at) { + lines.push(``, `⚠ Editorial note: A similar blog already exists (created ${new Date(topic.last_blog_created_at).toLocaleDateString("de-DE")}). Choose a materially different angle — different structure, timeframe, or use-case focus.`); + } + + lines.push(``, `--- Writing Instructions ---`); + lines.push(`Write a practical optical networking article (600–900 words) that a network engineer or procurement manager at an ISP, cloud provider, or enterprise can immediately use. Include:`); + lines.push(`1. What is actually happening in the market (fact-based, no generic intro)`); + lines.push(`2. Specific technical implications (form factors, speeds, reach, protocol implications)`); + lines.push(`3. Procurement/planning consequences — what to order, what to delay, what to watch`); + lines.push(`4. One concrete recommendation or action item`); + lines.push(`Do NOT write generic summaries or restate the title. Be opinionated and specific.`); - lines.push("Editorial instruction: turn this into a practical optical networking article with procurement/engineering consequences, not a generic news summary."); return lines.join("\n"); } diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 235177a..1a89e0d 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1846,7 +1846,7 @@

🏭 Warehouse Stock Intelligence

-

Preise: real (scraped from fs.com · QSFPTEK · NADDOD & more) · Abverkauf: ✅ echte Flexoptix-Verkaufszahlen (intern) · ⚠ Scraper-Lagermengen: DEMO DATA

+

Preise & Lagermengen: real (scraped from fs.com · QSFPTEK · NADDOD & more) · Abverkauf: ✅ echte Flexoptix-Verkaufszahlen (intern) · ℹ Scraper-Lagermengen: Wettbewerber-Marktdaten

@@ -1865,17 +1865,17 @@
🇩🇪
-
DE-Lager Total [DEMO]
+
DE-Lager Total FS.com
🌍
-
Global-Lager Total [DEMO]
+
Global-Lager Total FS.com
-
In Nachlieferung [DEMO]
+
In Nachlieferung FS.com
@@ -1986,9 +1986,9 @@ Part Number Form Factor - Verkauft [DEMO] - DE-Lager [DEMO] - Global-Lager [DEMO] + Verkauft FS.com + DE-Lager FS.com + Global-Lager FS.com Preis (net) @@ -2026,7 +2026,7 @@
-
🆕 Recently Restocked (last 24h) DEMO DATA
+
🆕 Recently Restocked (last 24h) SCRAPER DATA
No recent restock events
@@ -4197,6 +4197,7 @@ async function openSwitchDetail(id) { h += ''; h += '
'; + var links = []; if (s.product_page_url) links.push('Product Page'); if (s.datasheet_url) links.push('Datasheet'); @@ -5740,6 +5741,8 @@ async function loadBlogLLMStatus() { if (foCard) foCard.dataset.foModel = currentFoModel; if (foName) foName.textContent = '🎯 ' + currentFoModel; if (foEnv) foEnv.innerHTML = 'BLOG_LLM_PROVIDER=ollama
OLLAMA_LLM_MODEL=' + currentFoModel; + // Expose for hot-topics.js (blog pipeline progress label) + window._activeFoBlogModel = currentFoModel; var badge = document.getElementById('blog-llm-status-badge'); var activeModel = document.getElementById('blog-llm-active-model'); var activeProvider = document.getElementById('blog-llm-active-provider'); @@ -7548,9 +7551,9 @@ async function loadStock() { return '' + '' + pn + '' + '' + esc(r.form_factor || '—') + '' - + '' + Number(r.units_sold || 0).toLocaleString() + ' demo' - + '' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() + ' demo' : '—') + '' - + '' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() + ' demo' : '—') + '' + + '' + Number(r.units_sold || 0).toLocaleString() + '' + + '' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : '—') + '' + + '' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : '—') + '' + '' + fmtPrice(r.price_net, r.price_currency) + '' + ''; }).join(''); @@ -7791,11 +7794,11 @@ async function lookupStock() { var latest = obs[0]; resultEl.innerHTML = '' + esc(tx.part_number) + ' — ' + esc(tx.form_factor || '') + ' ' + esc(tx.speed || '') + '
' - + 'DE-Lager: ' + (latest.warehouse_de_qty != null ? latest.warehouse_de_qty : '—') + '[demo] · ' - + 'Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + '[demo] · ' - + 'Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + '[demo] · ' + + 'DE-Lager: ' + (latest.warehouse_de_qty != null ? latest.warehouse_de_qty : '—') + ' · ' + + 'Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + ' · ' + + 'Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + ' · ' + (latest.price_net != null ? '€' + Number(latest.price_net).toFixed(2) + ' (net)' : '') - + (latest.units_sold != null ? ' · ' + latest.units_sold + '× verkauft [demo]' : '') + + (latest.units_sold != null ? ' · ' + latest.units_sold + '× verkauft' : '') + '
via ' + esc(latest.vendor_name) + ' · ' + new Date(latest.time).toLocaleString('de-DE') + '' + (obs.length > 1 ? ' (' + obs.length + ' observations this week)' : ''); } catch(e) {