Compare commits

...

4 Commits

Author SHA1 Message Date
Rene Fichtmueller
bcab2b97af feat: procurement — 5 intelligence sections (A-E)
E  Buy-Now Intel    211k precomputed reorder signals surfaced,
                    filterable by form factor, signal strength bars
A  Arbitrage        59k equivalence pairs + price data, FX vs comp
                    normalized to USD, sorted by savings %
B  Switch Compat    search 429 switches → compatible transceivers
                    with prices; 58k compatibility rows
C  Supply Squeeze   4-signal detector: price momentum (30d vs 60d),
                    hype phase, AI cluster demand, stock pressure
D  Dead Stock       7,297 dead-stock SKUs matched against ascending
                    hype phases (revival candidates)

5 new API endpoints: /api/procurement/reorder-top, /arbitrage,
/switch-compat, /supply-squeeze, /dead-stock-revival
2026-05-14 18:31:07 +02:00
Rene Fichtmueller
4bd16af9a5 feat: data quality panel in Crawler Intelligence tab
GET /api/scrapers/data-quality — 4 parallel queries across 200k+
transceiver_verification_evidence rows. Returns: coverage percentages
(price 62%, image 68%, details 94%, competitor 2%), all 10 evidence
types with counts + avg confidence, 17 robot/scraper contributions,
14-day daily activity time series.

Dashboard: coverage progress bars (color-coded thresholds), evidence
type table, SVG activity sparkline, robot contributions table.
2026-05-14 16:22:25 +02:00
Rene Fichtmueller
10d13633fb feat: dynamic hype cycle + market signal engine + eBay/CapEx panels
6-source composite Market Signal Score (0-100) per transceiver technology.
New GET /api/hype-cycle/market-signals blends: Norton-Bass hype_score,
hyperscaler CapEx YoY (MSFT +68.8%, GOOG +107%, META +46.8%), price
observation activity ratio 30d vs prior 30d, AI cluster transceiver demand,
eBay secondary market sell-through velocity, internal fast-mover trend.
All 6 queries run in parallel via Promise.all().

Recommendation engine maps hype phase × capex boom × speed class →
Buy/Hold/Watch labels with tooltips. Dashboard Hype Cycle table now shows
Market Signal ● LIVE column + Recommendation column. Hyperscaler CapEx
panel + eBay panel added to hype tab. Procurement: new eBay Market section.
Sourcing Hype Cycle replaced hardcoded seed with live price observation data.
2026-05-14 16:17:52 +02:00
Rene Fichtmueller
13fe33eceb feat: procurement — Internal Demand + AI Clusters sections with real data
Two new procurement sub-tabs backed by live database tables:

📦 Internal Demand (flexoptix_internal_demand, 8,585 SKUs):
- Velocity cards: fast_mover (70 SKUs, 53k units/12M), regular, slow, dead stock
- Filterable table with demand_12m, demand_3m, trend %, form factor
- GET /api/procurement/internal-demand — summary + paginated rows

🤖 AI Clusters (ai_cluster_announcements, 396 rows last 30d):
- Live datacenter build announcements with estimated transceiver demand
- Stats: total announcements, MW sum, distinct companies, total ~transceivers
- Filter for entries with transceiver estimates; time range selector
- GET /api/procurement/ai-clusters — data + period stats

Also: replaced misleading DEMO DATA banners on Reorder Signals and ABC
Classification sections with informational notes pointing to real data.
2026-05-14 16:04:11 +02:00
7 changed files with 1999 additions and 37 deletions

View File

@ -1,6 +1,18 @@
# TIP Changelog # TIP Changelog
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}` Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
{"d":"2026-05-14","t":"FEAT","m":"Procurement: 5 neue Intelligence-Sektionen. (E) 🟢 Buy-Now Intel — Top buy_now Reorder Signals aus 211k preberechneten Signalen, filterbar nach Form Factor, Signalstärke-Balken, Preis/Stock-Trend, Gründe als Tooltip. API: GET /api/procurement/reorder-top. (A) 💰 Arbitrage — FX-Preis vs. Competitor-Preis für 59k Equivalenz-Paare mit Preisdaten auf beiden Seiten, normalisiert auf USD (EUR×1.08, GBP×1.27), sortiert nach Ersparnis-%. API: GET /api/procurement/arbitrage. (B) 🖥 Switch Compat — Suche nach Switch-Modell (Cisco, Juniper, Arista etc.), zeigt alle kompatiblen Transceiver mit Preis + Verifikationsmethode. 58k Compatibility-Rows, 429 Switches. API: GET /api/procurement/switch-compat?search=. (C) ⚠️ Supply Squeeze — Multi-Signal-Detektor: 4 parallele Quellen (Preis-Momentum 30d vs 60d, Hype-Phase, AI-Cluster-Transceiver-Nachfrage, Stock-Level-Verteilung). Severity: critical/warning/watch. API: GET /api/procurement/supply-squeeze. (D) 🪦 Dead Stock Revival — 7.297 Dead-Stock-SKUs gegen Hype-Cycle-Phasen: zeigt welche Lagerhüter in Technologieklassen liegen die gerade aufsteigen (ascending hype phases, score >30). API: GET /api/procurement/dead-stock-revival."}
{"d":"2026-05-14","t":"FEAT","m":"Crawler Intelligence: Data Quality panel. New GET /api/scrapers/data-quality endpoint — 4 parallel queries over 200,617 transceiver_verification_evidence rows: (1) coverage breakdown (price 11,366/18,146 = 62%, image 12,333/68%, details 17,085/94%, competitor_match 399/2%, quarantined 1,193); (2) all 10 evidence types with count + avg confidence + product count + last seen; (3) robot/scraper contributions table (17 robots ranked by output); (4) daily activity last 14 days. Dashboard Crawler Intelligence tab: new 🔬 Data Quality section with coverage progress bars (color-coded ≥80% green / ≥50% amber / red), evidence type table, SVG sparkline bar chart for 14-day activity, robot contributions table with live/stale dot indicators."}
{"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 (0100) 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."}
{"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."}
{"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools — find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."}
{"d":"2026-05-14","t":"FIX","m":"Blog from URL: SPA-aware content extraction. fetchUrlContent() now extracts OG/meta tags (og:title, og:description, name=description, og:site_name) as fallback for JavaScript SPAs. Returns spaDetected=true when body text < 300 chars. from-url endpoint skips gatherBlogData() product injection when SPA detected prevents fo-blog model from defaulting to optical networking domain on non-networking URLs. additionalContext now includes explicit SPA warning + meta content. generated_by in pipeline UPDATE uses active model name (no more hardcoded 'fo-blog-engine-v7'). Dashboard shows SPA warning toast + spa_detected field in response."}
{"d":"2026-05-14","t":"FEAT","m":"Blog Engine: URL → Blog feature. POST /api/blog/from-url fetches any URL server-side (20s timeout, redirect-follow), strips scripts/nav/footer/SVG, extracts readable text (~5000 chars) + page title, passes as structured additional_context to the 16-step FO blog pipeline. Dashboard: new '🔗 Blog aus URL generieren' panel with URL input (Enter key supported), Blog-Typ selector, loading state, and char count confirmation. Same pollBlogLlm() polling reused for step progress."}
{"d":"2026-05-14","t":"UI","m":"Switch modal Flexoptix section: (1) Speed formatting fixed — 1600.00G → 1.6T, 400G clean integer (fmtSpeed() helper, ≥1000 Gbps → T). (2) Lagerbestand badges added per transceiver row: DE-Lager (green), Global-Lager (blue), Zulauf with ETA date (yellow). Data sourced from stock_observations via LEFT JOIN LATERAL in getFlexoptixSuggestions(). Badges hidden when quantities are null/0 (scraper not yet populating Flexoptix warehouse columns — shows automatically once scraper is extended)."}
{"d":"2026-05-14","t":"FEAT","m":"Stock velocity API: GET /api/stock/velocity (paginated, filterable by vendor_id/confidence/stockout_days/min_sell_rate/part_number) + GET /api/stock/velocity/:id (per-product velocity summary + sell/zulauf event history). Both routes live in packages/api/src/routes/stock.ts, compiled + deployed to tip-api PM2 id 24, port 3201."}
{"d":"2026-05-14","t":"DATA","m":"Demo data cleanup: deleted 2133 demo rows from reorder_signals (is_demo_data=true). Stock observation coverage expanded: atgbics.ts + optcore.ts now call upsertStockObservation after each price observation (binary in/out stock, confidence=1). FS.com scraper already runs 3x daily from Mac (02:00/10:00/18:00) with full DE-Lager/Global-Lager/Nachlieferung breakdown. Competitor stock audit: QSFPTEK (confidence=2, real quantities), FS.COM (confidence=3, per-warehouse breakdown) are highest fidelity; ATGBICS/Optcore added at confidence=1 (binary); sfpcables/prolabs/wiitek hardcode or lack stock — not added."} {"d":"2026-05-14","t":"DATA","m":"Demo data cleanup: deleted 2133 demo rows from reorder_signals (is_demo_data=true). Stock observation coverage expanded: atgbics.ts + optcore.ts now call upsertStockObservation after each price observation (binary in/out stock, confidence=1). FS.com scraper already runs 3x daily from Mac (02:00/10:00/18:00) with full DE-Lager/Global-Lager/Nachlieferung breakdown. Competitor stock audit: QSFPTEK (confidence=2, real quantities), FS.COM (confidence=3, per-warehouse breakdown) are highest fidelity; ATGBICS/Optcore added at confidence=1 (binary); sfpcables/prolabs/wiitek hardcode or lack stock — not added."}
{"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 → fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). Persisted /opt/tip/blog-llm-settings.json overrode env — also updated. tip-api restarted, PM2 state saved."} {"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 → fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). Persisted /opt/tip/blog-llm-settings.json overrode env — also updated. tip-api restarted, PM2 state saved."}
{"d":"2026-05-13","t":"FEAT","m":"BlogLLM auto-discovery: client.ts now probes Ollama at startup + every 10 min, reconciles configured fo-blog-vN against actual available tags, auto-falls to highest available version when configured model no longer exists. Magatama-aware sort: base 'fo-blog-vN' tag wins over '-rM' revisions within same N (matches Magatama adoption convention where -rM is intermediate adapter save, base is production alias). New POST /api/blog/llm/refresh-discovery endpoint for manual trigger. Eliminates 3-step manual sync after every Magatama training."} {"d":"2026-05-13","t":"FEAT","m":"BlogLLM auto-discovery: client.ts now probes Ollama at startup + every 10 min, reconciles configured fo-blog-vN against actual available tags, auto-falls to highest available version when configured model no longer exists. Magatama-aware sort: base 'fo-blog-vN' tag wins over '-rM' revisions within same N (matches Magatama adoption convention where -rM is intermediate adapter save, base is production alias). New POST /api/blog/llm/refresh-discovery endpoint for manual trigger. Eliminates 3-step manual sync after every Magatama training."}

View File

@ -22,25 +22,112 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325
// ── Runtime-switchable provider state ────────────────────────────────────── // ── Runtime-switchable provider state ──────────────────────────────────────
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch). // 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. // 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 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 } interface LlmSettings { provider: string; ollamaModel: string }
function loadSettings(): LlmSettings { function loadSettingsRaw(): LlmSettings {
try { try {
if (existsSync(SETTINGS_FILE)) { if (existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings; const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>;
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" }; 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 */ } } catch { /* ignore corrupt file */ }
return { return {
provider: process.env.BLOG_LLM_PROVIDER || "ollama", 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<string[]> {
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<void> {
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. */ /** Switch the active LLM provider at runtime. Persists to settings file. */
export function setLlmProvider(provider: string, ollamaModel?: string): void { 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. */ /** Returns the currently active provider config. */
export function getLlmProvider(): LlmSettings { return { ..._settings }; } 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<LlmSettings> {
await reconcileWithOllama();
return { ..._settings };
}
// Convenience getters used below (re-read on every call for zero-latency switch) // Convenience getters used below (re-read on every call for zero-latency switch)
function provider(): string { return _settings.provider; } function provider(): string { return _settings.provider; }
function llmModel(): string { return _settings.ollamaModel; } function llmModel(): string { return _settings.ollamaModel; }

View File

@ -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<string, number>();
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<string, number>();
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 (0100)
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) // GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => { hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
try { try {

View File

@ -9,6 +9,8 @@
* GET /api/procurement/market-intel Market intelligence events * GET /api/procurement/market-intel Market intelligence events
* GET /api/procurement/stock-trends/:id Stock history for a transceiver * GET /api/procurement/stock-trends/:id Stock history for a transceiver
* GET /api/procurement/lifecycle Lifecycle events (EOL, standards) * GET /api/procurement/lifecycle Lifecycle events (EOL, standards)
* GET /api/procurement/ai-clusters AI datacenter announcements with transceiver demand
* GET /api/procurement/internal-demand Flexoptix internal demand velocity (fast/slow/dead)
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { pool } from "../db/client"; import { pool } from "../db/client";
@ -291,3 +293,582 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => {
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
}); });
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/ai-clusters?days=90&limit=50&min_transceivers=0
// Returns AI datacenter announcements with transceiver demand estimates
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/ai-clusters", async (req: Request, res: Response) => {
try {
const {
days = "90",
limit = "50",
min_transceivers = "0",
} = req.query;
const daysN = Math.min(Math.max(parseInt(days as string) || 90, 1), 730);
const limitN = Math.min(parseInt(limit as string) || 50, 200);
const minTx = parseInt(min_transceivers as string) || 0;
const result = await pool.query(
`SELECT
id, company, title, summary,
announced_date, scale_mw, scale_servers,
network_speed, estimated_transceivers,
deployment_date, location, source_url, source_name,
created_at
FROM ai_cluster_announcements
WHERE
(announced_date IS NULL OR announced_date >= NOW() - INTERVAL '1 day' * $1)
AND ($2 = 0 OR estimated_transceivers >= $2)
ORDER BY announced_date DESC NULLS LAST, created_at DESC
LIMIT $3`,
[daysN, minTx, limitN]
);
// Aggregate stats
const statsResult = await pool.query(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates,
SUM(estimated_transceivers) FILTER (WHERE estimated_transceivers > 0) AS total_estimated_transceivers,
SUM(scale_mw) FILTER (WHERE scale_mw IS NOT NULL) AS total_mw,
COUNT(DISTINCT company) FILTER (WHERE company != 'Unknown') AS distinct_companies
FROM ai_cluster_announcements
WHERE announced_date >= NOW() - INTERVAL '1 day' * $1`,
[daysN]
);
res.json({
data: result.rows,
stats: statsResult.rows[0],
period_days: daysN,
});
} catch (err) {
console.error("AI clusters error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─────────────────────────────────────────────────────────────────────────────
// GET /api/procurement/internal-demand?velocity_class=fast_mover&limit=100
// Returns Flexoptix internal demand data — real SKU velocity from internal data
// ─────────────────────────────────────────────────────────────────────────────
procurementRouter.get("/internal-demand", async (req: Request, res: Response) => {
try {
const {
velocity_class,
limit = "100",
offset = "0",
sort = "demand_12m",
} = req.query;
const allowedSorts: Record<string, string> = {
demand_12m: "fid.demand_12m DESC",
demand_3m: "fid.demand_3m DESC",
trend: "fid.demand_trend_pct DESC NULLS LAST",
sku: "fid.sku ASC",
};
const orderBy = allowedSorts[sort as string] ?? allowedSorts["demand_12m"];
const params: unknown[] = [];
const conditions: string[] = ["fid.is_internal = true"];
let idx = 1;
if (velocity_class) {
conditions.push(`fid.velocity_class = $${idx}`);
params.push(velocity_class);
idx++;
}
params.push(Math.min(parseInt(limit as string) || 100, 500));
params.push(Math.max(parseInt(offset as string) || 0, 0));
const result = await pool.query(
`SELECT
fid.id, fid.sku, fid.description,
fid.demand_12m, fid.demand_3m, fid.demand_trend_pct,
fid.velocity_class, fid.imported_at,
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
t.reach_label, t.image_url,
v.name AS vendor_name
FROM flexoptix_internal_demand fid
LEFT JOIN transceivers t ON t.id = fid.transceiver_id
LEFT JOIN vendors v ON v.id = t.vendor_id
WHERE ${conditions.join(" AND ")}
ORDER BY ${orderBy}
LIMIT $${idx} OFFSET $${idx + 1}`,
params
);
// Velocity summary
const summaryResult = await pool.query(
`SELECT
velocity_class,
COUNT(*) AS cnt,
SUM(demand_12m)::numeric(12,0) AS total_demand_12m,
AVG(demand_trend_pct)::numeric(8,1) AS avg_trend_pct
FROM flexoptix_internal_demand
WHERE is_internal = true
GROUP BY velocity_class
ORDER BY total_demand_12m DESC NULLS LAST`
);
res.json({
data: result.rows,
summary: summaryResult.rows,
total: result.rowCount,
});
} catch (err) {
console.error("Internal demand error:", err);
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" });
}
});
// ─── E: GET /api/procurement/reorder-top ─────────────────────────────────────
// Top buy_now reorder signals with full reasons — 211k precomputed signals
procurementRouter.get("/reorder-top", async (req: Request, res: Response) => {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const formFactor = (req.query.form_factor as string) || "";
const minStrength = parseFloat(req.query.min_strength as string) || 0;
try {
const result = await pool.query(`
SELECT DISTINCT ON (t.id)
t.id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name,
rs.signal, rs.signal_strength,
rs.price_trend, rs.stock_trend, rs.hype_phase,
rs.reasons,
rs.computed_at
FROM reorder_signals rs
JOIN transceivers t ON t.id = rs.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE rs.signal = 'buy_now'
AND rs.is_demo_data = false
AND rs.signal_strength >= $1
AND ($2 = '' OR t.form_factor ILIKE $2)
ORDER BY t.id, rs.signal_strength DESC, rs.computed_at DESC
`, [minStrength, formFactor]);
// After DISTINCT ON, re-sort by signal_strength
const rows = result.rows.sort(
(a: { signal_strength: string }, b: { signal_strength: string }) =>
parseFloat(b.signal_strength) - parseFloat(a.signal_strength)
);
const summary = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::int AS buy_now,
COUNT(*) FILTER (WHERE signal = 'wait' AND is_demo_data = false)::int AS wait,
COUNT(*) FILTER (WHERE signal = 'hold' AND is_demo_data = false)::int AS hold,
COUNT(*) FILTER (WHERE signal = 'monitor' AND is_demo_data = false)::int AS monitor,
ROUND(AVG(signal_strength) FILTER (WHERE signal = 'buy_now' AND is_demo_data = false)::numeric,3) AS avg_buy_strength
FROM reorder_signals
`);
res.json({ success: true, data: rows.slice(0, limit), summary: summary.rows[0] });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── B: GET /api/procurement/switch-compat ───────────────────────────────────
// Switch ↔ transceiver compatibility matrix
procurementRouter.get("/switch-compat", async (req: Request, res: Response) => {
const search = (req.query.search as string) || "";
const limitNum = Math.min(parseInt(req.query.limit as string) || 30, 100);
try {
if (search.length >= 2) {
// Search for switches matching query, return their compatible transceivers
const switches = await pool.query(`
SELECT DISTINCT ON (sw.id)
sw.id, sw.vendor AS sw_vendor, sw.model AS sw_model, sw.series AS sw_series,
COUNT(c.transceiver_id) OVER (PARTITION BY sw.id)::int AS compat_count
FROM switches sw
JOIN compatibility c ON c.switch_id = sw.id
WHERE sw.model ILIKE $1 OR sw.vendor ILIKE $1 OR sw.series ILIKE $1
ORDER BY sw.id, compat_count DESC
LIMIT $2
`, [`%${search}%`, limitNum]);
// For each matched switch, get top compatible transceivers with prices
const switchIds = switches.rows.map((s: { id: string }) => s.id);
if (switchIds.length === 0) {
return res.json({ success: true, switches: [], transceivers: [] });
}
const transceivers = await pool.query(`
SELECT
c.switch_id,
t.id AS tx_id, t.part_number, t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name,
c.verification_method, c.status,
(SELECT ROUND(MIN(po.price)::numeric,2) FROM price_observations po
WHERE po.transceiver_id = t.id AND po.price > 0
ORDER BY po.time DESC LIMIT 1) AS min_price,
(SELECT po.currency FROM price_observations po
WHERE po.transceiver_id = t.id AND po.price > 0
ORDER BY po.time DESC LIMIT 1) AS currency
FROM compatibility c
JOIN transceivers t ON t.id = c.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE c.switch_id = ANY($1)
AND c.status = 'compatible'
ORDER BY t.speed_gbps DESC, t.form_factor
LIMIT 200
`, [switchIds]);
return res.json({
success: true,
switches: switches.rows,
transceivers: transceivers.rows,
});
}
// No search — return top switches by compat count
const top = await pool.query(`
SELECT sw.vendor, sw.model, sw.series,
COUNT(c.transceiver_id)::int AS compat_count
FROM switches sw
JOIN compatibility c ON c.switch_id = sw.id
WHERE c.status = 'compatible'
GROUP BY sw.id, sw.vendor, sw.model, sw.series
ORDER BY compat_count DESC
LIMIT $1
`, [limitNum]);
const stats = await pool.query(`
SELECT
COUNT(DISTINCT sw.id)::int AS total_switches,
COUNT(DISTINCT c.transceiver_id)::int AS total_transceivers,
COUNT(*)::int AS total_compat_rows
FROM switches sw JOIN compatibility c ON c.switch_id = sw.id
WHERE c.status = 'compatible'
`);
return res.json({ success: true, topSwitches: top.rows, stats: stats.rows[0] });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── A: GET /api/procurement/arbitrage ───────────────────────────────────────
// OEM vs Flexoptix price gaps via transceiver_equivalences
procurementRouter.get("/arbitrage", async (_req: Request, res: Response) => {
// FX rates for normalization — approximate
const FX: Record<string, number> = { USD: 1.0, EUR: 1.08, GBP: 1.27 };
try {
const result = await pool.query(`
SELECT
te.confidence,
fx.part_number AS fx_part,
vfx.name AS fx_vendor,
fx.speed_gbps, fx.form_factor, fx.reach_label,
comp.part_number AS comp_part,
vcomp.name AS comp_vendor,
(SELECT price FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_price,
(SELECT currency FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2 ORDER BY time DESC LIMIT 1) AS fx_curr,
(SELECT price FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_price,
(SELECT currency FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2 ORDER BY time DESC LIMIT 1) AS comp_curr
FROM transceiver_equivalences te
JOIN transceivers fx ON fx.id = te.flexoptix_id
JOIN transceivers comp ON comp.id = te.competitor_id
JOIN vendors vfx ON vfx.id = fx.vendor_id
JOIN vendors vcomp ON vcomp.id = comp.vendor_id
WHERE te.status IN ('approved','auto_approved')
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.flexoptix_id AND price > 2)
AND EXISTS(SELECT 1 FROM price_observations WHERE transceiver_id = te.competitor_id AND price > 2)
ORDER BY te.confidence DESC
LIMIT 2000
`);
const pairs = result.rows
.map((r: {
fx_price: string; fx_curr: string;
comp_price: string; comp_curr: string;
confidence: string;
fx_part: string; fx_vendor: string;
comp_part: string; comp_vendor: string;
speed_gbps: string; form_factor: string; reach_label: string;
}) => {
const fxUSD = parseFloat(r.fx_price) * (FX[r.fx_curr] || 1.0);
const compUSD = parseFloat(r.comp_price) * (FX[r.comp_curr] || 1.0);
if (!fxUSD || !compUSD) return null;
const savings = compUSD - fxUSD;
const savingsPct = Math.round((savings / compUSD) * 100);
return { ...r, fxUSD: Math.round(fxUSD), compUSD: Math.round(compUSD), savings: Math.round(savings), savingsPct };
})
.filter((r): r is NonNullable<typeof r> => r !== null && r.savings > 0)
.sort((a, b) => b.savingsPct - a.savingsPct)
.slice(0, 100);
// Stats
const totalPairs = result.rows.length;
const fxCheaper = pairs.length;
const avgSavings = pairs.length ? Math.round(pairs.reduce((s, r) => s + r.savingsPct, 0) / pairs.length) : 0;
res.json({ success: true, pairs, stats: { totalPairs, fxCheaper, avgSavingsPct: avgSavings } });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── D: GET /api/procurement/dead-stock-revival ──────────────────────────────
// Dead-stock SKUs whose equivalents are in rising hype phases
procurementRouter.get("/dead-stock-revival", async (_req: Request, res: Response) => {
try {
const [deadStock, hypeMap] = await Promise.all([
pool.query(`
SELECT
fid.transceiver_id,
fid.part_number_raw AS part_number,
fid.velocity_class,
fid.demand_12m,
fid.demand_trend_pct,
t.speed_gbps, t.form_factor, t.reach_label,
v.name AS vendor_name
FROM flexoptix_internal_demand fid
JOIN transceivers t ON t.id = fid.transceiver_id
JOIN vendors v ON v.id = t.vendor_id
WHERE fid.velocity_class = 'dead_stock'
AND fid.is_internal = true
LIMIT 7500
`),
pool.query(`
SELECT DISTINCT ON (technology)
technology, hype_phase, hype_score, computed_at
FROM hype_cycle_analysis
ORDER BY technology, computed_at DESC
`),
]);
// Build speed → hype phase map
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
const ASCENDING = new Set(["innovation_trigger","peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
const speedToHype = new Map<number, HypeRow>();
for (const h of hypeMap.rows as HypeRow[]) {
const speedMatch = h.technology.match(/^(\d+(?:\.\d+)?)G/);
if (speedMatch) speedToHype.set(parseFloat(speedMatch[1]), h);
}
type DeadRow = {
transceiver_id: string; part_number: string;
speed_gbps: string; form_factor: string; reach_label: string;
vendor_name: string; demand_12m: string; demand_trend_pct: string;
velocity_class: string;
};
const revivals = (deadStock.rows as DeadRow[])
.map((r) => {
const speed = parseFloat(r.speed_gbps);
const hype = speedToHype.get(speed);
if (!hype) return null;
const ascending = ASCENDING.has(hype.hype_phase);
const score = parseFloat(hype.hype_score);
return { ...r, hype_phase: hype.hype_phase, hype_score: score, ascending };
})
.filter((r): r is NonNullable<typeof r> => r !== null && r.ascending && r.hype_score > 30)
.sort((a, b) => b.hype_score - a.hype_score)
.slice(0, 100);
const totalDead = deadStock.rows.length;
res.json({ success: true, revivals, totalDeadStock: totalDead, revivalCount: revivals.length });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
// ─── C: GET /api/procurement/supply-squeeze ──────────────────────────────────
// Multi-signal supply constraint detector
procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) => {
try {
const [priceSignals, aiDemand, hypeData, stockData] = await Promise.all([
// Price momentum: 30d vs 60d avg by speed/form_factor
pool.query(`
SELECT
t.speed_gbps, t.form_factor,
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days')::numeric,2) AS avg_30d,
ROUND(AVG(po.price) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days' AND po.time < NOW() - INTERVAL '30 days')::numeric,2) AS avg_prior_30d,
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d
FROM price_observations po
JOIN transceivers t ON t.id = po.transceiver_id
WHERE po.price > 5 AND po.currency = 'USD'
GROUP BY t.speed_gbps, t.form_factor
HAVING COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') >= 3
`),
// AI cluster demand by speed tier
pool.query(`
SELECT
CASE
WHEN description ILIKE '%800G%' THEN 800
WHEN description ILIKE '%400G%' THEN 400
WHEN description ILIKE '%100G%' THEN 100
ELSE 0
END AS speed_tier,
COALESCE(SUM(estimated_transceivers),0)::int AS total_tx,
COUNT(*)::int AS cluster_count
FROM ai_cluster_announcements
WHERE announced_date >= NOW() - INTERVAL '90 days'
GROUP BY speed_tier
HAVING COALESCE(SUM(estimated_transceivers),0) > 0
`),
// Hype phase per technology
pool.query(`
SELECT DISTINCT ON (technology)
technology, hype_phase, hype_score
FROM hype_cycle_analysis ORDER BY technology, computed_at DESC
`),
// Stock level distribution (in_stock vs out_of_stock)
pool.query(`
SELECT
t.speed_gbps, t.form_factor,
COUNT(*) FILTER (WHERE so.stock_level = 'out_of_stock')::int AS out_of_stock,
COUNT(*) FILTER (WHERE so.stock_level = 'in_stock')::int AS in_stock,
COUNT(*)::int AS total_obs
FROM stock_observations so
JOIN transceivers t ON t.id = so.transceiver_id
WHERE so.observed_at >= NOW() - INTERVAL '14 days'
GROUP BY t.speed_gbps, t.form_factor
HAVING COUNT(*) >= 3
`).catch(() => ({ rows: [] })),
]);
type PriceRow = { speed_gbps: string; form_factor: string; avg_30d: string; avg_prior_30d: string; obs_30d: string };
type HypeRow = { technology: string; hype_phase: string; hype_score: string };
type AiRow = { speed_tier: string; total_tx: string; cluster_count: string };
type StockRow = { speed_gbps: string; form_factor: string; out_of_stock: string; in_stock: string; total_obs: string };
const speedToHype = new Map<number, HypeRow>();
for (const h of hypeData.rows as HypeRow[]) {
const m = h.technology.match(/^(\d+(?:\.\d+)?)G/);
if (m) speedToHype.set(parseFloat(m[1]), h);
}
const aiBySpeed = new Map<number, AiRow>();
for (const a of aiDemand.rows as AiRow[]) {
aiBySpeed.set(parseFloat(a.speed_tier), a);
}
const stockByKey = new Map<string, StockRow>();
for (const s of stockData.rows as StockRow[]) {
stockByKey.set(`${s.speed_gbps}:${s.form_factor}`, s);
}
const RISKY_PHASES = new Set(["peak_inflated_expectations","slope_enlightenment","plateau_productivity"]);
const signals = (priceSignals.rows as PriceRow[])
.map((r) => {
const speed = parseFloat(r.speed_gbps);
const priceUp = r.avg_30d && r.avg_prior_30d
? ((parseFloat(r.avg_30d) - parseFloat(r.avg_prior_30d)) / parseFloat(r.avg_prior_30d)) * 100
: 0;
const hype = speedToHype.get(speed);
const ai = aiBySpeed.get(speed);
const stock = stockByKey.get(`${r.speed_gbps}:${r.form_factor}`);
let activeSignals = 0;
const reasons: string[] = [];
if (priceUp > 5) { activeSignals++; reasons.push(`Price +${Math.round(priceUp)}% (30d)`); }
if (hype && RISKY_PHASES.has(hype.hype_phase)) { activeSignals++; reasons.push(`Hype: ${hype.hype_phase.replace(/_/g,' ')}`); }
if (ai && parseInt(ai.total_tx) > 50000) { activeSignals++; reasons.push(`AI demand: ${parseInt(ai.total_tx).toLocaleString()} tx in 90d`); }
if (stock && parseInt(stock.out_of_stock) > parseInt(stock.in_stock)) { activeSignals++; reasons.push(`Stock pressure: ${stock.out_of_stock}/${stock.total_obs} vendors OOS`); }
const severity = activeSignals >= 3 ? "critical" : activeSignals === 2 ? "warning" : activeSignals === 1 ? "watch" : "ok";
return {
speed_gbps: r.speed_gbps, form_factor: r.form_factor,
avg_30d: r.avg_30d, avg_prior_30d: r.avg_prior_30d,
price_momentum_pct: Math.round(priceUp),
hype_phase: hype?.hype_phase || null,
hype_score: hype ? parseFloat(hype.hype_score) : null,
ai_demand_tx: ai ? parseInt(ai.total_tx) : 0,
activeSignals, severity, reasons,
};
})
.filter((r) => r.activeSignals >= 1)
.sort((a, b) => b.activeSignals - a.activeSignals || b.price_momentum_pct - a.price_momentum_pct);
res.json({ success: true, signals, criticalCount: signals.filter(s => s.severity === "critical").length });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});

View File

@ -238,3 +238,85 @@ scraperRouter.get("/llm-insights", async (_req: Request, res: Response) => {
res.status(503).json({ success: false, error: String(err) }); res.status(503).json({ success: false, error: String(err) });
} }
}); });
// GET /api/scrapers/data-quality — Verification evidence coverage + quality metrics
scraperRouter.get("/data-quality", async (_req: Request, res: Response) => {
try {
const [coverageRows, evidenceTypes, robotActivity, dailyActivity] = await Promise.all([
// Coverage: how many transceivers have each evidence type
pool.query(`
SELECT
COUNT(DISTINCT t.id)::int AS total_transceivers,
COUNT(DISTINCT CASE WHEN e.verification_type = 'price' THEN t.id END)::int AS have_price,
COUNT(DISTINCT CASE WHEN e.verification_type = 'image' THEN t.id END)::int AS have_image,
COUNT(DISTINCT CASE WHEN e.verification_type = 'details' THEN t.id END)::int AS have_details,
COUNT(DISTINCT CASE WHEN e.verification_type = 'competitor_match' THEN t.id END)::int AS have_competitor,
COUNT(DISTINCT CASE WHEN e.verification_type = 'artifact_quarantine' THEN t.id END)::int AS quarantined
FROM transceivers t
LEFT JOIN transceiver_verification_evidence e ON e.transceiver_id = t.id
`),
// Evidence type breakdown
pool.query(`
SELECT
verification_type,
COUNT(*)::int AS cnt,
ROUND(AVG(confidence)::numeric, 3) AS avg_confidence,
COUNT(DISTINCT transceiver_id)::int AS distinct_tx,
COUNT(DISTINCT robot_name) AS robot_count,
MAX(created_at) AS last_seen
FROM transceiver_verification_evidence
GROUP BY verification_type
ORDER BY cnt DESC
`),
// Robot / scraper activity
pool.query(`
SELECT
robot_name,
COUNT(*)::int AS total_evidence,
COUNT(DISTINCT transceiver_id)::int AS transceivers_covered,
COUNT(DISTINCT verification_type) AS types_covered,
MIN(created_at)::date AS first_run,
MAX(created_at)::date AS last_run
FROM transceiver_verification_evidence
GROUP BY robot_name
ORDER BY total_evidence DESC
LIMIT 20
`),
// Daily activity last 14 days
pool.query(`
SELECT
created_at::date AS day,
COUNT(*)::int AS evidence_added,
COUNT(DISTINCT transceiver_id)::int AS transceivers_processed
FROM transceiver_verification_evidence
WHERE created_at >= NOW() - INTERVAL '14 days'
GROUP BY day
ORDER BY day DESC
`),
]);
const cov = coverageRows.rows[0];
const total = cov.total_transceivers || 1;
res.json({
success: true,
coverage: {
total: cov.total_transceivers,
price: cov.have_price,
image: cov.have_image,
details: cov.have_details,
competitor: cov.have_competitor,
quarantined: cov.quarantined,
pricePct: Math.round((cov.have_price / total) * 100),
imagePct: Math.round((cov.have_image / total) * 100),
detailsPct: Math.round((cov.have_details / total) * 100),
competitorPct: Math.round((cov.have_competitor / total) * 100),
},
evidenceTypes: evidenceTypes.rows,
robotActivity: robotActivity.rows,
dailyActivity: dailyActivity.rows,
});
} catch (err) {
res.status(503).json({ success: false, error: String(err) });
}
});

View File

@ -31,13 +31,28 @@
} }
blogPipelineRunning = true; 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'); var pipelineEl = document.getElementById('blog-pipeline-status');
if (pipelineEl) { if (pipelineEl) {
pipelineEl.innerHTML = pipelineEl.innerHTML =
'<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' + '<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' +
'<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' + '<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' +
'<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' + '<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to FO_BlogLLM (fo-blog-v7)</div>' + '<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to ' + initialModelLabel + '</div>' +
'<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' + '<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' +
'<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' + '<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' +
'<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' + '<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
@ -137,7 +152,7 @@
if (bar) bar.style.width = prog.pct + '%'; if (bar) bar.style.width = prog.pct + '%';
if (pct) pct.textContent = prog.pct + '%'; if (pct) pct.textContent = prog.pct + '%';
if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); } 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 { } else {
_stallCount++; _stallCount++;
// After 5 consecutive non-running polls (~40s), show stall warning // After 5 consecutive non-running polls (~40s), show stall warning

File diff suppressed because it is too large Load Diff