diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 3cb77a3..4f8f3f0 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -1,6 +1,7 @@ # TIP Changelog Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` +{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."} {"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."} {"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."} diff --git a/packages/api/src/llm/client.ts b/packages/api/src/llm/client.ts index 4901698..4faf13a 100644 --- a/packages/api/src/llm/client.ts +++ b/packages/api/src/llm/client.ts @@ -22,25 +22,112 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325 // ── Runtime-switchable provider state ────────────────────────────────────── // Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch). // Falls back to process.env, then to defaults. No restart required for switches. +// +// AUTO-DISCOVERY: At startup and on a periodic refresh, the active fo-blog-v* model +// is validated against Ollama's actual model list. If the configured model no longer +// exists (e.g. Magatama trained a new version and Ollama removed older tags), the +// highest available fo-blog-v* version is picked automatically — no manual env or +// settings-file update needed after each training cycle. const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json"); +const STATIC_FALLBACK_MODEL = "fo-blog-v10"; +const DISCOVERY_REFRESH_MS = Number.parseInt(process.env.BLOG_LLM_DISCOVERY_REFRESH_MS || "", 10) || 10 * 60_000; interface LlmSettings { provider: string; ollamaModel: string } -function loadSettings(): LlmSettings { +function loadSettingsRaw(): LlmSettings { try { if (existsSync(SETTINGS_FILE)) { - const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings; - return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" }; + const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial; + return { + provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama", + ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL, + }; } } catch { /* ignore corrupt file */ } return { provider: process.env.BLOG_LLM_PROVIDER || "ollama", - ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7", + ollamaModel: process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL, }; } -let _settings = loadSettings(); +/** + * Sort fo-blog-v{N}[-r{M}] tags newest-first. + * + * Magatama convention (confirmed by /api/llm/status?lane=fo_blogllm): + * - `fo-blog-vN` ← base tag, the active production model after adoption + * - `fo-blog-vN-rM` ← revision metadata, intermediate adapter save + * + * So within the same major N, the BASE tag wins over -rM revisions. + * + * Order: higher N > lower N; within same N, base ("no -r") > any -rM revision. + */ +function compareFoBlogVersionsDesc(a: string, b: string): number { + const re = /^fo-blog-v(\d+)(?:-r(\d+))?$/; + const ma = re.exec(a); + const mb = re.exec(b); + if (!ma || !mb) return a.localeCompare(b); + const va = Number.parseInt(ma[1], 10); + const vb = Number.parseInt(mb[1], 10); + if (va !== vb) return vb - va; + // Same major version: base tag (no -r suffix) wins over any -rM revision + const aIsBase = ma[2] === undefined; + const bIsBase = mb[2] === undefined; + if (aIsBase !== bIsBase) return aIsBase ? -1 : 1; + // Both have -rM: higher M is newer + const ra = ma[2] ? Number.parseInt(ma[2], 10) : 0; + const rb = mb[2] ? Number.parseInt(mb[2], 10) : 0; + return rb - ra; +} + +interface OllamaTag { name: string } +interface OllamaTagsResponse { models: OllamaTag[] } + +/** Probe Ollama for available fo-blog-v* models. Returns [] on any error (non-fatal). */ +async function fetchOllamaFoBlogTags(): Promise { + try { + const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) return []; + const data = await resp.json() as OllamaTagsResponse; + return (data.models || []) + .map(m => m.name.replace(/:latest$/, "")) + .filter(n => /^fo-blog-v\d+(?:-r\d+)?$/.test(n)); + } catch { + return []; + } +} + +/** + * Reconcile configured model against Ollama reality. + * + * Priority: + * 1. Configured model (env or settings file) — if Ollama actually serves it + * 2. Highest fo-blog-v* version Ollama actually serves — auto-discovered + * 3. Static fallback STATIC_FALLBACK_MODEL — last resort + * + * Non-blocking: any Ollama failure leaves _settings untouched. + */ +async function reconcileWithOllama(): Promise { + const configured = _settings.ollamaModel; + if (!configured.startsWith("fo-blog-v")) return; // only manage fo-blog-* lane + const available = await fetchOllamaFoBlogTags(); + if (available.length === 0) return; + if (available.includes(configured)) return; // configured model still exists + + const sorted = [...available].sort(compareFoBlogVersionsDesc); + const winner = sorted[0]; + if (!winner || winner === configured) return; + + console.log(`[LLM] auto-discovery: configured "${configured}" not in Ollama; switching to latest available "${winner}" (candidates: ${sorted.join(", ")})`); + _settings = { ..._settings, ollamaModel: winner }; + try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ } +} + +let _settings = loadSettingsRaw(); + +// Fire-and-forget initial reconciliation. Subsequent refresh runs every DISCOVERY_REFRESH_MS. +void reconcileWithOllama(); +setInterval(() => { void reconcileWithOllama(); }, DISCOVERY_REFRESH_MS).unref(); /** Switch the active LLM provider at runtime. Persists to settings file. */ export function setLlmProvider(provider: string, ollamaModel?: string): void { @@ -52,6 +139,15 @@ export function setLlmProvider(provider: string, ollamaModel?: string): void { /** Returns the currently active provider config. */ export function getLlmProvider(): LlmSettings { return { ..._settings }; } +/** + * Force an immediate auto-discovery reconciliation against Ollama. + * Returns the active settings after reconcile. + */ +export async function refreshLlmAutoDiscovery(): Promise { + await reconcileWithOllama(); + return { ..._settings }; +} + // Convenience getters used below (re-read on every call for zero-latency switch) function provider(): string { return _settings.provider; } function llmModel(): string { return _settings.ollamaModel; } diff --git a/packages/api/src/routes/hype-cycle.ts b/packages/api/src/routes/hype-cycle.ts index 567ea84..8a9fcf9 100644 --- a/packages/api/src/routes/hype-cycle.ts +++ b/packages/api/src/routes/hype-cycle.ts @@ -165,6 +165,242 @@ hypeCycleRouter.get("/regional/:tech", (req: Request, res: Response) => { }); }); +// ── MARKET SIGNAL COMPUTATION ─────────────────────────────────────────────── +/** Technology → form-factor/speed mapping for cross-table signal aggregation */ +const TECH_SIGNAL_MAP = [ + { label: "10G-SFP+", speedGbps: 10, formFactors: ["SFP+"], speedLabel: "10G" }, + { label: "100G-QSFP28", speedGbps: 100, formFactors: ["QSFP28"], speedLabel: "100G" }, + { label: "400G-QSFP-DD", speedGbps: 400, formFactors: ["QSFP-DD"], speedLabel: "400G" }, + { label: "800G-OSFP", speedGbps: 800, formFactors: ["OSFP"], speedLabel: "800G" }, + { label: "1.6T-OSFP", speedGbps: 1600, formFactors: ["OSFP"], speedLabel: "1.6T" }, + { label: "400G-ZR", speedGbps: 400, formFactors: ["SFP-DD", "CFP2"], speedLabel: "400G" }, +] as const; + +type PhaseKey = + | "plateau_productivity" | "slope_enlightenment" | "trough_disillusionment" + | "peak_inflated_expectations" | "innovation_trigger"; + +function buildRecommendation( + phase: PhaseKey, + signalScore: number, + capexYoyAvg: number, + speedGbps: number, +): { label: string; color: string; detail: string } { + const fast = speedGbps >= 400; + const capexBoom = capexYoyAvg > 50; + + switch (phase) { + case "plateau_productivity": + if (fast && capexBoom) + return { label: "🚀 Buy — AI Wave", color: "#16a34a", detail: "Commodity pricing + AI infrastructure demand surge. Stock up now." }; + if (signalScore >= 85) + return { label: "✅ Hold — Stable", color: "#2563eb", detail: "Mature commodity market, stable long-term demand." }; + return { label: "📦 Hold", color: "#64748b", detail: "Commodity market. Price-driven. Order on demand." }; + case "slope_enlightenment": + if (capexBoom) + return { label: "🟢 Buy Now", color: "#16a34a", detail: "Growing mainstream adoption + hyperscaler capex boom. Window closing." }; + return { label: "🟡 Buy", color: "#ca8a04", detail: "Adoption curve steepening. Monitor pricing before large orders." }; + case "trough_disillusionment": + if (signalScore > 50) + return { label: "🔍 Buy Opportunity", color: "#7c3aed", detail: "Hype trough but demand signals emerging. Strategic buying window." }; + return { label: "⏳ Watch", color: "#94a3b8", detail: "Wait for demand confirmation before stocking." }; + case "peak_inflated_expectations": + if (fast && capexBoom) + return { label: "⚡ Caution / Buy", color: "#f97316", detail: "Hype peak but real hyperscaler demand. Buy selectively, not speculatively." }; + return { label: "⚠ Caution", color: "#ef4444", detail: "Peak hype. Verify real end-customer demand before building inventory." }; + case "innovation_trigger": + return { label: "👁 Watch", color: "#94a3b8", detail: "Early stage — too early for volume commitments. Monitor for traction." }; + default: + return { label: "📊 Monitor", color: "#64748b", detail: "Monitor signals." }; + } +} + +// GET /api/hype-cycle/market-signals — Multi-source demand intelligence +hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => { + try { + const { pool } = await import("../db/client"); + + // ── Parallel data fetch ────────────────────────────────────────────────── + const [hypeRows, priceRows, capexRows, aiRows, ebayRows, internalRows] = await Promise.all([ + // Latest hype cycle per technology + pool.query(` + SELECT DISTINCT ON (technology) + technology, hype_phase, hype_score, asp_current_usd, + r_squared, computed_at, current_share, years_to_next_phase + FROM hype_cycle_analysis + ORDER BY technology, computed_at DESC + `), + + // Price observation activity: last 30d vs prior 30d (scraping frequency = demand proxy) + pool.query(` + SELECT + t.speed_gbps, + t.form_factor, + COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d, + COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' + AND po.time < NOW() - INTERVAL '30 days') AS obs_prior_30d + FROM price_observations po + JOIN transceivers t ON t.id = po.transceiver_id + WHERE po.time >= NOW() - INTERVAL '60 days' + GROUP BY t.speed_gbps, t.form_factor + `), + + // Hyperscaler capex — most recent per company + pool.query(` + SELECT DISTINCT ON (company) + company, period_label, capex_usd_millions, + dc_capex_est_millions, yoy_growth_pct + FROM hyperscaler_capex + WHERE yoy_growth_pct IS NOT NULL + ORDER BY company, period_end DESC + `), + + // AI cluster demand last 90 days + pool.query(` + SELECT + COALESCE(SUM(estimated_transceivers), 0) AS total_tx, + COUNT(*) AS cluster_count, + COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates + FROM ai_cluster_announcements + WHERE announced_date >= NOW() - INTERVAL '90 days' + `), + + // eBay marketplace velocity + pool.query(` + SELECT DISTINCT ON (form_factor, speed_label) + marketplace, keyword, form_factor, speed_label, + sold_count_30d, active_listings, avg_sold_price + FROM marketplace_velocity + WHERE sold_count_30d IS NOT NULL + ORDER BY form_factor, speed_label, scraped_at DESC + `), + + // Internal demand: fast-mover trend + pool.query(` + SELECT + COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct > 0) AS fast_mover_pos, + COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct < 0) AS fast_mover_neg, + AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'fast_mover') AS avg_fast_trend, + AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'regular') AS avg_regular_trend + FROM flexoptix_internal_demand + WHERE is_internal = true + `), + ]); + + // ── Pre-compute global signals ─────────────────────────────────────────── + const capexYoyValues = capexRows.rows + .map((r) => parseFloat(r.yoy_growth_pct)) + .filter((v) => !isNaN(v)); + const capexYoyAvg = capexYoyValues.length + ? capexYoyValues.reduce((a, b) => a + b, 0) / capexYoyValues.length + : 0; + + const aiData = aiRows.rows[0]; + const totalAiTx = parseInt(aiData?.total_tx ?? "0") || 0; + + const internalData = internalRows.rows[0]; + const avgFastTrend = parseFloat(internalData?.avg_fast_trend ?? "0") || 0; + + // Build price activity map: speedGbps+formFactor → ratio (30d/prior30d) + const priceMap = new Map(); + for (const row of priceRows.rows) { + const obs30 = parseInt(row.obs_30d) || 0; + const obsPrior = parseInt(row.obs_prior_30d) || 1; + const key = `${row.speed_gbps}__${row.form_factor ?? ""}`; + priceMap.set(key, obs30 / obsPrior); + } + + // Build eBay map: speedLabel → sold_count_30d + const ebayMap = new Map(); + for (const row of ebayRows.rows) { + const speed = (row.speed_label ?? "").replace(/\s+/g, ""); + ebayMap.set(speed, parseInt(row.sold_count_30d) || 0); + } + + // ── Per-technology signals ─────────────────────────────────────────────── + const technologies = hypeRows.rows.map((r) => { + const tech = TECH_SIGNAL_MAP.find((t) => t.label === r.technology); + const phase = (r.hype_phase ?? "innovation_trigger") as PhaseKey; + const hypeScore = parseInt(r.hype_score) || 0; + + // Price activity ratio: average across matching speed+formFactor combos + let priceRatios: number[] = []; + if (tech) { + for (const ff of tech.formFactors) { + const key = `${tech.speedGbps}__${ff}`; + const ratio = priceMap.get(key); + if (ratio !== undefined) priceRatios.push(ratio); + } + } + const priceActivityRatio = priceRatios.length + ? priceRatios.reduce((a, b) => a + b, 0) / priceRatios.length + : 1; + + // eBay velocity for this speed + const ebayVelocity = tech ? (ebayMap.get(tech.speedLabel) ?? 0) : 0; + + // AI cluster: allocate demand proportionally for high-speed techs + const aiBoostTx = tech && tech.speedGbps >= 400 ? totalAiTx : 0; + + // Compute composite score (0–100) + let score = hypeScore * 0.3; + const capexBoostPts = capexYoyAvg > 100 ? 18 : capexYoyAvg > 50 ? 12 : capexYoyAvg > 20 ? 5 : 0; + const priceBoostPts = priceActivityRatio > 1.3 ? 10 : priceActivityRatio > 1.0 ? 5 : -3; + const aiBoostPts = aiBoostTx > 100000 ? 14 : aiBoostTx > 50000 ? 9 : aiBoostTx > 10000 ? 4 : 0; + const ebayBoostPts = ebayVelocity > 200 ? 8 : ebayVelocity > 100 ? 5 : ebayVelocity > 50 ? 2 : 0; + const intlBoostPts = avgFastTrend > 10 ? 6 : avgFastTrend > 0 ? 3 : avgFastTrend < -20 ? -5 : 0; + + score += capexBoostPts + priceBoostPts + aiBoostPts + ebayBoostPts + intlBoostPts; + const marketSignalScore = Math.max(0, Math.min(100, Math.round(score))); + + const rec = buildRecommendation(phase, marketSignalScore, capexYoyAvg, tech?.speedGbps ?? 0); + + // Signal drivers list for tooltip + const drivers: string[] = []; + if (capexBoostPts > 0) drivers.push(`Hyperscaler CapEx +${capexYoyAvg.toFixed(0)}% YoY avg`); + if (priceActivityRatio > 1.1) drivers.push(`Price obs +${((priceActivityRatio - 1) * 100).toFixed(0)}% MoM activity`); + if (aiBoostTx > 0) drivers.push(`~${(aiBoostTx / 1000).toFixed(0)}k transceivers in AI cluster builds`); + if (ebayVelocity > 0) drivers.push(`${ebayVelocity} units sold on secondary market (30d)`); + if (intlBoostPts > 0) drivers.push(`Internal fast-movers trending ${avgFastTrend > 0 ? "+" : ""}${avgFastTrend.toFixed(1)}%`); + + return { + technology: r.technology, + phase, + hypeScore, + aspCurrentUsd: r.asp_current_usd, + marketSignalScore, + recommendation: rec, + drivers, + speedGbps: tech?.speedGbps, + priceActivityRatio: Math.round(priceActivityRatio * 100) / 100, + ebayVelocity, + computedAt: r.computed_at, + }; + }); + + // ── Global context ─────────────────────────────────────────────────────── + const globalContext = { + hyperscalerCapex: capexRows.rows.map((r) => ({ + company: r.company, + periodLabel: r.period_label, + capexMillions: parseFloat(r.capex_usd_millions), + dcCapexMillions: parseFloat(r.dc_capex_est_millions), + yoyGrowthPct: parseFloat(r.yoy_growth_pct), + })), + capexYoyAvg: Math.round(capexYoyAvg), + capexBoom: capexYoyAvg > 50, + totalAiClusterTx90d: totalAiTx, + aiClusterCount90d: parseInt(aiData?.cluster_count ?? "0") || 0, + internalFastMoverTrend: Math.round(avgFastTrend * 10) / 10, + }; + + res.json({ success: true, technologies, globalContext, computed_at: new Date().toISOString() }); + } catch (err) { + console.error("Market signals error:", err); + res.status(500).json({ success: false, error: "Failed to compute market signals" }); + } +}); + // GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table) hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => { try { diff --git a/packages/api/src/routes/procurement.ts b/packages/api/src/routes/procurement.ts index bb028ad..fa2c716 100644 --- a/packages/api/src/routes/procurement.ts +++ b/packages/api/src/routes/procurement.ts @@ -424,3 +424,79 @@ procurementRouter.get("/internal-demand", async (req: Request, res: Response) => res.status(500).json({ error: "Internal server error" }); } }); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/hyperscaler-capex +// Hyperscaler quarterly CapEx from SEC filings — demand context for transceivers +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/hyperscaler-capex", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + company, period_label, period_end, + capex_usd_millions, dc_capex_est_millions, + yoy_growth_pct, filing_type, source_url + FROM hyperscaler_capex + ORDER BY period_end DESC, capex_usd_millions DESC + `); + + const summaryResult = await pool.query(` + SELECT + company, + MAX(period_end) AS latest_period_end, + MAX(period_label) AS latest_period, + (ARRAY_AGG(capex_usd_millions ORDER BY period_end DESC))[1] AS latest_capex, + (ARRAY_AGG(dc_capex_est_millions ORDER BY period_end DESC))[1] AS latest_dc_capex, + (ARRAY_AGG(yoy_growth_pct ORDER BY period_end DESC))[1] AS latest_yoy_growth, + AVG(yoy_growth_pct) FILTER (WHERE period_end >= NOW() - INTERVAL '365 days') AS avg_yoy_12m + FROM hyperscaler_capex + GROUP BY company + ORDER BY latest_capex DESC NULLS LAST + `); + + res.json({ + data: result.rows, + summary: summaryResult.rows, + }); + } catch (err) { + console.error("Hyperscaler capex error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/procurement/marketplace-velocity +// Secondary market (eBay) sell-through as demand signal +// ───────────────────────────────────────────────────────────────────────────── +procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT DISTINCT ON (form_factor, speed_label) + marketplace, keyword, form_factor, speed_label, + sold_count_30d, active_listings, avg_sold_price, + min_price, max_price, currency, scraped_at + FROM marketplace_velocity + ORDER BY form_factor, speed_label, scraped_at DESC + `); + + const hotResult = await pool.query(` + SELECT DISTINCT ON (form_factor, speed_label) + marketplace, form_factor, speed_label, + sold_count_30d, active_listings, avg_sold_price + FROM marketplace_velocity + WHERE sold_count_30d > 0 + ORDER BY form_factor, speed_label, scraped_at DESC + `); + + res.json({ + data: result.rows, + hot: hotResult.rows.sort( + (a: { sold_count_30d: string }, b: { sold_count_30d: string }) => + parseInt(b.sold_count_30d) - parseInt(a.sold_count_30d) + ), + }); + } catch (err) { + console.error("Marketplace velocity error:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); diff --git a/packages/dashboard/hot-topics.js b/packages/dashboard/hot-topics.js index 119fed7..d98fd32 100644 --- a/packages/dashboard/hot-topics.js +++ b/packages/dashboard/hot-topics.js @@ -31,13 +31,28 @@ } blogPipelineRunning = true; + // Fetch the current active model name so we never show a stale hardcoded version. + var initialModelLabel = (window._activeFoBlogModel) || 'FO_BlogLLM'; + fetch(API + '/api/blog/llm/status', { headers: authHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var m = data && data.llm && data.llm.model; + if (m) { + window._activeFoBlogModel = m; + var s = document.getElementById('bp-step'); + if (s && s.textContent.indexOf('Connecting to FO_BlogLLM') === 0) { + s.textContent = 'Connecting to FO_BlogLLM (' + m + ')'; + } + } + }).catch(function() {}); + var pipelineEl = document.getElementById('blog-pipeline-status'); if (pipelineEl) { pipelineEl.innerHTML = '
' + '
Generating Blog with AI...
' + '
Starting 10-step Flexoptix Style pipeline...
' + - '
Connecting to FO_BlogLLM (fo-blog-v7)
' + + '
Connecting to ' + initialModelLabel + '
' + '
' + '
' + '
0%
' + @@ -137,7 +152,7 @@ if (bar) bar.style.width = prog.pct + '%'; if (pct) pct.textContent = prog.pct + '%'; if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); } - if (step) step.textContent = 'Step ' + prog.step + '/10 · fo-blog-v7 via adapter bridge'; + if (step) step.textContent = 'Step ' + prog.step + '/10 · ' + (window._activeFoBlogModel || 'fo-blog-v10') + ' via adapter bridge'; } else { _stallCount++; // After 5 consecutive non-running polls (~40s), show stall warning diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 8fba3f8..74899cd 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -916,6 +916,11 @@
+ +
+
Loading market signals…
+
+
@@ -923,9 +928,8 @@ - - - + + @@ -934,7 +938,7 @@
- [M] = MODELL — Norton-Bass mathematische Schätzung, keine echten Marktdaten + ● LIVE = Real market data: hyperscaler capex + price activity + AI cluster demand + eBay velocity + internal demand OEM ASP = real (Mouser/Marktdaten)
@@ -974,6 +978,31 @@
Loading sourcing data…
+ + +
+
+
+
Hyperscaler CapEx SEC Filings
+
Quarterly infrastructure spend — primary demand driver for high-speed transceivers
+
+
+
+
+
Loading capex data…
+
+
+ + +
+
+
eBay Secondary Market Demand Signal
+
Secondary market sell-through rates — real end-buyer demand by form factor
+
+
+
Loading marketplace data…
+
+
@@ -1568,6 +1597,7 @@ +
@@ -1658,6 +1688,18 @@ + + + ' + '' + '' + '' - + '' - + '' - + '' + + '' + + '' + '' + '' + ''; @@ -3369,6 +3436,9 @@ async function loadHypeCycle() { // Render Sourcing Hype Cycle loadSourcingHypeCycle(); + + // Render Hyperscaler CapEx + eBay panels + loadHypeCapexAndEbay(); } // ── SOURCING HYPE CYCLE ────────────────────────────────────────────────────── @@ -3395,26 +3465,41 @@ async function loadSourcingHypeCycle() { var meta = document.getElementById('sourcing-hype-meta'); if (!container) return; - var seedData = [ - { label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 }, - { label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 }, - { label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 }, - { label: 'QSFP+ 40G', speed_gbps: 40, form_factor: 'QSFP+', obs: 217, avg_price: 180.17 }, - { label: 'SFP28 25G', speed_gbps: 25, form_factor: 'SFP28', obs: 198, avg_price: 142.50 }, - { label: 'QSFP-DD 400G', speed_gbps: 400, form_factor: 'QSFP-DD', obs: 193, avg_price: 510.99 }, - { label: 'OSFP 800G', speed_gbps: 800, form_factor: 'OSFP', obs: 80, avg_price: 810.06 }, - { label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 } + // Static fallback (only used if API fails) + var seedFallback = [ + { label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 }, + { label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 }, + { label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 }, + { label: 'QSFP+ 40G', speed_gbps: 40, form_factor: 'QSFP+', obs: 217, avg_price: 180.17 }, + { label: 'SFP28 25G', speed_gbps: 25, form_factor: 'SFP28', obs: 198, avg_price: 142.50 }, + { label: 'QSFP-DD 400G', speed_gbps: 400, form_factor: 'QSFP-DD', obs: 193, avg_price: 510.99 }, + { label: 'OSFP 800G', speed_gbps: 800, form_factor: 'OSFP', obs: 80, avg_price: 810.06 }, + { label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 } ]; - var items = seedData; + var items = seedFallback; + // Try real price observation counts per form_factor+speed_gbps (live DB data) try { - var d = await api('/api/procurement/signals?limit=30'); - if (d && d.signals && d.signals.length > 0) { - d.signals.forEach(function(sig) { - var key = (sig.form_factor || '') + String(sig.speed_gbps || ''); - var ex = items.find(function(it) { return (it.form_factor || '') + String(it.speed_gbps || '') === key; }); - if (ex && sig.observation_count > 0) ex.obs = sig.observation_count; + var priceCountRes = await api('/api/procurement/signals?limit=200'); + var sigItems = priceCountRes.data || []; + if (sigItems.length > 0) { + // Build a map from form_factor+speed_gbps → {obs, avg_price} + var realMap = {}; + sigItems.forEach(function(row) { + var key = (row.form_factor || '') + '|' + String(row.speed_gbps || ''); + if (!realMap[key] || row.observation_count > realMap[key].obs) { + realMap[key] = { obs: row.observation_count || 0, avg_price: parseFloat(row.avg_price) || 0 }; + } }); + // Merge into items + items = items.map(function(it) { + var key = (it.form_factor || '') + '|' + String(it.speed_gbps || ''); + if (realMap[key] && realMap[key].obs > 0) { + return Object.assign({}, it, { obs: realMap[key].obs, avg_price: realMap[key].avg_price || it.avg_price }); + } + return it; + }); + if (meta) meta.setAttribute('data-source', 'live'); } } catch(e) {} @@ -6769,7 +6854,7 @@ var procAiClustersData = []; var procAiClustersMinTx = 0; function showProcSection(name) { - ['signals','abc','demand','ai-clusters','market','lifecycle'].forEach(function(s) { + ['signals','abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) { var sec = el('proc-section-' + s); var btn = el('proc-btn-' + s); if (sec) sec.style.display = s === name ? '' : 'none'; @@ -6778,6 +6863,7 @@ function showProcSection(name) { // Lazy-load on first visit if (name === 'demand' && procDemandData.length === 0) loadInternalDemand(); if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters(); + if (name === 'marketplace' && !el('proc-marketplace-grid').querySelector('.card')) loadProcMarketplace(); } async function loadProcurement() { @@ -7144,6 +7230,167 @@ function filterAiClusters(minTx) { }).join(''); } +// ─── MARKET SIGNALS — HYPE CYCLE ENRICHMENT ────────────────────────────────── + +function renderHypeMarketContext(ctx, technologies) { + var el2 = el('hype-market-context'); + if (!el2) return; + var capexYoy = ctx.capexYoyAvg || 0; + var capexColor = capexYoy > 80 ? '#16a34a' : capexYoy > 40 ? '#ca8a04' : '#64748b'; + var totalAiTx = ctx.totalAiClusterTx90d || 0; + var fastTrend = ctx.internalFastMoverTrend || 0; + + // Summary stat cards + var cards = [ + statCard('💰', capexYoy.toFixed(0) + '% YoY avg', 'Hyperscaler CapEx', + (ctx.capexBoom ? '🚀 Boom — above +50% threshold' : 'Moderate growth') + ' (' + (ctx.hyperscalerCapex || []).map(function(c) { return c.company; }).join(', ') + ')'), + statCard('🤖', totalAiTx > 0 ? '~' + (totalAiTx / 1000).toFixed(0) + 'k Tx' : '—', 'AI Cluster Demand (90d)', + ctx.aiClusterCount90d + ' cluster builds with transceiver demand estimates'), + statCard('📈', fastTrend > 0 ? '+' + fastTrend.toFixed(1) + '%' : fastTrend.toFixed(1) + '%', 'Internal Fast-Mover Trend', + 'Average demand trend across ' + (ctx.hyperscalerCapex || []).length + ' fast-moving SKUs'), + ]; + + // Per-technology recommendation summary + var recCards = (technologies || []).map(function(ms) { + if (!ms.recommendation) return ''; + return '
' + + '
' + esc(ms.technology) + '
' + + '
' + esc(ms.recommendation.label) + '
' + + '
' + esc(ms.recommendation.detail.substring(0, 80)) + (ms.recommendation.detail.length > 80 ? '…' : '') + '
' + + '
' + + '
' + + '' + ms.marketSignalScore + '' + + '
'; + }).join(''); + + el2.innerHTML = '
' + cards.join('') + '
' + + '
Recommendations per Technology
' + + '
' + recCards + '
'; +} + +// ─── CAPEX TABLE — in Hype Cycle tab ───────────────────────────────────────── + +async function loadHypeCapexAndEbay() { + try { + var d = await api('/api/procurement/hyperscaler-capex'); + renderCapexTable(d.data || [], d.summary || []); + } catch(e) { + var el2 = el('capex-table-container'); + if (el2) el2.innerHTML = '
Could not load capex data.
'; + } + try { + var e = await api('/api/procurement/marketplace-velocity'); + renderEbayVelocity(e.hot || e.data || []); + renderProcMarketplace(e.hot || e.data || []); + } catch(e2) {} +} + +function renderCapexTable(rows, summary) { + var badge = el('capex-avg-badge'); + if (badge && summary.length) { + var avgYoy = summary.reduce(function(s, r) { return s + (parseFloat(r.latest_yoy_growth) || 0); }, 0) / summary.length; + badge.textContent = 'Avg YoY: +' + avgYoy.toFixed(0) + '% · ' + summary.length + ' hyperscalers'; + badge.style.color = avgYoy > 80 ? '#16a34a' : avgYoy > 40 ? '#ca8a04' : '#2563eb'; + badge.style.background = avgYoy > 80 ? '#16a34a11' : '#2563eb11'; + badge.style.borderColor = avgYoy > 80 ? '#16a34a33' : '#2563eb33'; + } + + var container = el('capex-table-container'); + if (!container || !rows.length) return; + + var html = '
Technology Phase PositionAdoption [M]Peak [M]To Plateau [M]Market Signal ● LIVERecommendation OEM ASP
' + esc(t.technology) + '' + esc(t.phase) + '
' + adoptionDisplay + '' + esc(t.peakYear || '—') + '' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '' + signalHtml + '' + recHtml + '' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + rows.forEach(function(r) { + var yoy = parseFloat(r.yoy_growth_pct); + var yoyColor = yoy > 80 ? '#16a34a' : yoy > 40 ? '#ca8a04' : yoy > 0 ? '#2563eb' : '#ef4444'; + var yoyStr = isNaN(yoy) ? '—' : (yoy > 0 ? '+' : '') + yoy.toFixed(1) + '%'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + container.innerHTML = html + '
CompanyPeriodCapEx ($M)DC Est. ($M)YoY %Filing
' + esc(r.company) + '' + esc(r.period_label) + '$' + parseFloat(r.capex_usd_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '$' + parseFloat(r.dc_capex_est_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '' + yoyStr + '' + esc(r.filing_type || '—') + '
'; +} + +function renderEbayVelocity(rows) { + var container = el('ebay-velocity-container'); + if (!container || !rows.length) { + if (container) container.innerHTML = '
No marketplace data.
'; + return; + } + + var html = '
'; + rows.forEach(function(r) { + var sold = parseInt(r.sold_count_30d) || 0; + var listings = parseInt(r.active_listings) || 0; + var sellThrough = listings > 0 ? ((sold / listings) * 100).toFixed(1) + '%' : '—'; + var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)'; + var avgPrice = parseFloat(r.avg_sold_price); + html += '
' + + '
' + esc(r.form_factor || r.keyword || '?') + ' ' + esc(r.speed_label || '') + '
' + + '
' + + '' + sold.toLocaleString() + '' + + 'sold / 30d' + + '
' + + '
' + + listings.toLocaleString() + ' active listings · sell-through ' + sellThrough + + '
' + + (avgPrice > 0 ? '
Avg sold: $' + avgPrice.toLocaleString(undefined,{maximumFractionDigits:0}) + '
' : '') + + '
'; + }); + container.innerHTML = html + '
'; +} + +// ─── PROC MARKETPLACE SECTION ───────────────────────────────────────────────── + +async function loadProcMarketplace() { + var grid = el('proc-marketplace-grid'); + try { + var d = await api('/api/procurement/marketplace-velocity'); + renderProcMarketplace(d.hot || d.data || []); + } catch(e) { + if (grid) grid.innerHTML = '
Could not load marketplace data.
'; + } +} + +function renderProcMarketplace(rows) { + var grid = el('proc-marketplace-grid'); + if (!grid) return; + if (!rows.length) { grid.innerHTML = '
No data yet.
'; return; } + grid.innerHTML = rows.map(function(r) { + var sold = parseInt(r.sold_count_30d) || 0; + var listings = parseInt(r.active_listings) || 0; + var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)'; + var borderColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : 'var(--border)'; + var avgP = parseFloat(r.avg_sold_price); + return '
' + + '
' + esc(r.form_factor || '?') + ' ' + esc(r.speed_label || '') + '
' + + '
' + esc(r.keyword || '') + ' · ' + esc(r.marketplace || 'eBay') + '
' + + '
' + + '' + sold.toLocaleString() + '' + + 'sold / 30d' + + '
' + + '
' + + listings.toLocaleString() + ' active listings' + + (avgP > 0 ? ' · avg $' + avgP.toLocaleString(undefined,{maximumFractionDigits:0}) : '') + + '
' + + '
'; + }).join(''); +} + // INIT loadOverview(); loadChangelog();