Compare commits
No commits in common. "bcab2b97af68f6fb3bf7c0829b26302c1f00245c" and "ea8be4aea3739b667759dd4cdb4c5faad0bd400a" have entirely different histories.
bcab2b97af
...
ea8be4aea3
@ -1,18 +1,6 @@
|
||||
# TIP Changelog
|
||||
|
||||
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 (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."}
|
||||
{"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-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."}
|
||||
|
||||
@ -22,112 +22,25 @@ 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 loadSettingsRaw(): LlmSettings {
|
||||
function loadSettings(): LlmSettings {
|
||||
try {
|
||||
if (existsSync(SETTINGS_FILE)) {
|
||||
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>;
|
||||
return {
|
||||
provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama",
|
||||
ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
|
||||
};
|
||||
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings;
|
||||
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" };
|
||||
}
|
||||
} catch { /* ignore corrupt file */ }
|
||||
return {
|
||||
provider: process.env.BLOG_LLM_PROVIDER || "ollama",
|
||||
ollamaModel: process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
|
||||
ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
let _settings = loadSettings();
|
||||
|
||||
/** Switch the active LLM provider at runtime. Persists to settings file. */
|
||||
export function setLlmProvider(provider: string, ollamaModel?: string): void {
|
||||
@ -139,15 +52,6 @@ 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<LlmSettings> {
|
||||
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; }
|
||||
|
||||
@ -165,242 +165,6 @@ 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 (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 {
|
||||
|
||||
@ -9,8 +9,6 @@
|
||||
* GET /api/procurement/market-intel — Market intelligence events
|
||||
* GET /api/procurement/stock-trends/:id — Stock history for a transceiver
|
||||
* 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 { pool } from "../db/client";
|
||||
@ -293,582 +291,3 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => {
|
||||
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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -238,85 +238,3 @@ scraperRouter.get("/llm-insights", async (_req: Request, res: Response) => {
|
||||
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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -31,28 +31,13 @@
|
||||
}
|
||||
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 =
|
||||
'<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 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 ' + initialModelLabel + '</div>' +
|
||||
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to FO_BlogLLM (fo-blog-v7)</div>' +
|
||||
'<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-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
|
||||
@ -152,7 +137,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 · ' + (window._activeFoBlogModel || 'fo-blog-v10') + ' via adapter bridge';
|
||||
if (step) step.textContent = 'Step ' + prog.step + '/10 · fo-blog-v7 via adapter bridge';
|
||||
} else {
|
||||
_stallCount++;
|
||||
// After 5 consecutive non-running polls (~40s), show stall warning
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user