From ea8be4aea3739b667759dd4cdb4c5faad0bd400a Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Thu, 14 May 2026 15:54:01 +0200 Subject: [PATCH] feat(tip): equivalences explorer + price history charts + linkedin status + MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equivalences Explorer: - GET /api/equivalences — search 63k cross-brand mappings by part number/vendor - GET /api/equivalences/transceiver/:id — all equivalences for a specific product - GET /api/equivalences/stats — active count, unique products, avg confidence (93.9%) - GET /api/equivalences/top-vendors — top 20 competitor vendors by coverage - New "Equivalences" tab in dashboard with part-number search, vendor filter, quick-click vendor chips, and results table with confidence coloring - Transceiver detail modal: equivalences panel (Flexoptix alternatives or competitor products), clickable rows, confidence percentage, orange highlight for FX products Price History Charts: - GET /api/price-history/:id?days=90 — daily min/max/avg per source vendor (392k obs) - Transceiver detail modal: SVG sparkline chart per vendor, legend with latest prices, range summary — loads async without blocking the modal LinkedIn Distribution Status: - GET /api/blog/linkedin/history — from blog_linkedin_distribution table - Blog tab: LinkedIn status panel showing DRY_RUN badge, posted/dry_run/skipped/failed stats, distribution history table with URN link to live posts MCP Server — 2 new tools: - find_equivalences: search 63k+ verified cross-brand mappings with confidence filter - get_price_history: 392k+ observations, daily series, per-vendor analysis, cheapest source --- packages/api/src/index.ts | 6 + packages/api/src/routes/blog.ts | 38 +++ packages/api/src/routes/equivalences.ts | 186 +++++++++++ packages/api/src/routes/price-history.ts | 90 +++++ packages/dashboard/index.html | 313 ++++++++++++++++++ packages/mcp-server/src/index.ts | 4 +- packages/mcp-server/src/tools/equivalences.ts | 217 ++++++++++++ 7 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/routes/equivalences.ts create mode 100644 packages/api/src/routes/price-history.ts create mode 100644 packages/mcp-server/src/tools/equivalences.ts diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 191cf6a..104d996 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -35,6 +35,8 @@ import { selflearningRouter } from "./routes/selflearning"; import { internalDemandRouter } from "./routes/internal-demand"; import { formFactorsRouter } from "./routes/form-factors"; import { tipLlmRouter } from "./routes/tip-llm"; +import { equivalencesRouter } from "./routes/equivalences"; +import { priceHistoryRouter } from "./routes/price-history"; const app = express(); @@ -102,6 +104,10 @@ app.use("/api/form-factors", formFactorsRouter); app.use("/api/internal/demand", internalDemandRouter); // tip-llm-v1 guided inference app.use("/api/tip-llm", tipLlmRouter); +// Equivalences (cross-brand alternatives) +app.use("/api/equivalences", equivalencesRouter); +// Price history charts +app.use("/api/price-history", priceHistoryRouter); // Dashboard (static HTML) app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard"))); diff --git a/packages/api/src/routes/blog.ts b/packages/api/src/routes/blog.ts index 8f50625..b73be9e 100644 --- a/packages/api/src/routes/blog.ts +++ b/packages/api/src/routes/blog.ts @@ -2039,3 +2039,41 @@ blogRouter.delete("/:id", async (req: Request, res: Response) => { res.status(500).json({ success: false, error: (err as Error).message }); } }); + +// GET /api/blog/linkedin/history — LinkedIn distribution log +blogRouter.get("/linkedin/history", async (_req: Request, res: Response) => { + try { + const result = await pool.query( + `SELECT id, ghost_post_id, ghost_slug, ghost_url, title, state, teaser, + linkedin_urn, error_message, attempt_count, created_at, posted_at + FROM blog_linkedin_distribution + ORDER BY created_at DESC + LIMIT 50` + ); + // Also get current DRY_RUN status from env (read from distributor ecosystem config) + let dryRun = true; + try { + const { execSync } = await import("child_process"); + const out = execSync( + "cat /opt/linkedin-distributor/ecosystem.config.cjs 2>/dev/null | grep DRY_RUN | head -1", + { timeout: 3000 } + ).toString(); + dryRun = !out.includes("'false'") && !out.includes('"false"'); + } catch { /* keep default true */ } + + res.json({ + success: true, + dry_run: dryRun, + history: result.rows, + total: result.rows.length, + stats: { + posted: result.rows.filter(r => r.state === "posted").length, + dry_run: result.rows.filter(r => r.state === "dry_run").length, + skipped: result.rows.filter(r => r.state === "skipped").length, + failed: result.rows.filter(r => r.state === "failed").length, + }, + }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); diff --git a/packages/api/src/routes/equivalences.ts b/packages/api/src/routes/equivalences.ts new file mode 100644 index 0000000..68ab054 --- /dev/null +++ b/packages/api/src/routes/equivalences.ts @@ -0,0 +1,186 @@ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const equivalencesRouter = Router(); + +// GET /api/equivalences?q=&vendor=&limit=50&offset=0 +// Search equivalences by competitor or Flexoptix part number +equivalencesRouter.get("/", async (req: Request, res: Response) => { + const { q, vendor, limit: lim, offset: off } = req.query as Record; + const limit = Math.min(parseInt(lim || "50"), 200); + const offset = parseInt(off || "0"); + + const conditions: string[] = ["e.status IN ('approved', 'auto_approved')"]; + const values: unknown[] = []; + let idx = 1; + + if (q) { + conditions.push( + `(fx.part_number ILIKE $${idx} OR fx.standard_name ILIKE $${idx} OR cx.part_number ILIKE $${idx} OR cx.standard_name ILIKE $${idx})` + ); + values.push(`%${q}%`); + idx++; + } + if (vendor) { + conditions.push(`(cv.name ILIKE $${idx} OR fv.name ILIKE $${idx})`); + values.push(`%${vendor}%`); + idx++; + } + + const where = `WHERE ${conditions.join(" AND ")}`; + + const query = ` + SELECT + e.id, + e.confidence, + e.match_basis, + e.status, + e.created_at, + -- Flexoptix side + fx.id AS flexoptix_id, + fx.part_number AS flexoptix_pn, + fx.standard_name AS flexoptix_std, + fx.form_factor AS flexoptix_form_factor, + fx.speed AS flexoptix_speed, + fx.speed_gbps AS flexoptix_speed_gbps, + fx.reach_label AS flexoptix_reach, + fx.product_page_url AS flexoptix_url, + fx.price_verified_eur AS flexoptix_price_eur, + fx.market_status AS flexoptix_market_status, + -- Competitor side + cx.id AS competitor_id, + cx.part_number AS competitor_pn, + cx.standard_name AS competitor_std, + cx.form_factor AS competitor_form_factor, + cx.speed AS competitor_speed, + cx.reach_label AS competitor_reach, + cx.product_page_url AS competitor_url, + cx.price_verified_eur AS competitor_price_eur, + cx.market_status AS competitor_market_status, + cv.name AS competitor_vendor, + cv.website AS competitor_vendor_website + FROM transceiver_equivalences e + JOIN transceivers fx ON fx.id = e.flexoptix_id + JOIN vendors fv ON fv.id = fx.vendor_id + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + ${where} + ORDER BY e.confidence DESC, e.status DESC + LIMIT ${limit} OFFSET ${offset} + `; + + const countQuery = ` + SELECT COUNT(*) FROM transceiver_equivalences e + JOIN transceivers fx ON fx.id = e.flexoptix_id + JOIN vendors fv ON fv.id = fx.vendor_id + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + ${where} + `; + + try { + const [data, count] = await Promise.all([ + pool.query(query, values), + pool.query(countQuery, values), + ]); + res.json({ + success: true, + data: data.rows, + total: parseInt(count.rows[0].count), + limit, + offset, + }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); + +// GET /api/equivalences/transceiver/:id — all equivalences for a specific transceiver (both sides) +equivalencesRouter.get("/transceiver/:id", async (req: Request, res: Response) => { + const { id } = req.params; + + try { + const result = await pool.query( + `SELECT + e.id, + e.confidence, + e.match_basis, + e.status, + -- Flexoptix side + fx.id AS flexoptix_id, + fx.part_number AS flexoptix_pn, + fx.standard_name AS flexoptix_std, + fx.form_factor AS flexoptix_form_factor, + fx.speed AS flexoptix_speed, + fx.reach_label AS flexoptix_reach, + fx.product_page_url AS flexoptix_url, + fx.price_verified_eur AS flexoptix_price_eur, + -- Competitor side + cx.id AS competitor_id, + cx.part_number AS competitor_pn, + cx.standard_name AS competitor_std, + cx.form_factor AS competitor_form_factor, + cx.speed AS competitor_speed, + cx.reach_label AS competitor_reach, + cx.product_page_url AS competitor_url, + cx.price_verified_eur AS competitor_price_eur, + cv.name AS competitor_vendor, + cv.website AS competitor_vendor_website + FROM transceiver_equivalences e + JOIN transceivers fx ON fx.id = e.flexoptix_id + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + WHERE (e.flexoptix_id = $1::uuid OR e.competitor_id = $1::uuid) + AND e.status IN ('approved', 'auto_approved') + ORDER BY e.confidence DESC`, + [id] + ); + res.json({ success: true, data: result.rows, total: result.rows.length }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); + +// GET /api/equivalences/stats — overview numbers +equivalencesRouter.get("/stats", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE status IN ('approved','auto_approved')) AS active, + COUNT(DISTINCT competitor_id) FILTER (WHERE status IN ('approved','auto_approved')) AS unique_competitor_products, + COUNT(DISTINCT flexoptix_id) FILTER (WHERE status IN ('approved','auto_approved')) AS unique_flexoptix_products, + COUNT(DISTINCT cv.name) AS unique_competitor_vendors, + AVG(confidence) FILTER (WHERE status IN ('approved','auto_approved'))::numeric(4,3) AS avg_confidence + FROM transceiver_equivalences e + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + `); + res.json({ success: true, stats: result.rows[0] }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); + +// GET /api/equivalences/top-vendors — which competitor vendors have most equivalences +equivalencesRouter.get("/top-vendors", async (_req: Request, res: Response) => { + try { + const result = await pool.query(` + SELECT + cv.name AS vendor, + cv.website, + COUNT(*) AS equiv_count, + COUNT(DISTINCT e.competitor_id) AS products_covered, + AVG(e.confidence)::numeric(4,3) AS avg_confidence + FROM transceiver_equivalences e + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + WHERE e.status IN ('approved','auto_approved') + GROUP BY cv.name, cv.website + ORDER BY equiv_count DESC + LIMIT 20 + `); + res.json({ success: true, data: result.rows }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); diff --git a/packages/api/src/routes/price-history.ts b/packages/api/src/routes/price-history.ts new file mode 100644 index 0000000..1afdd06 --- /dev/null +++ b/packages/api/src/routes/price-history.ts @@ -0,0 +1,90 @@ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const priceHistoryRouter = Router(); + +// GET /api/price-history/:transceiverIdOrSlug?days=90&vendor=all +// Returns time-bucketed price history for a transceiver (for charts) +priceHistoryRouter.get("/:id", async (req: Request, res: Response) => { + const { id } = req.params; + const days = Math.min(parseInt((req.query.days as string) || "90"), 365); + const vendorFilter = req.query.vendor as string | undefined; + + try { + // Resolve slug or UUID to transceiver + const tx = await pool.query( + `SELECT t.id, t.part_number, t.standard_name, v.name as vendor_name + FROM transceivers t LEFT JOIN vendors v ON v.id = t.vendor_id + WHERE t.id::text = $1 OR t.slug = $1 LIMIT 1`, + [id] + ); + if (!tx.rows[0]) { + res.status(404).json({ success: false, error: "Transceiver not found" }); + return; + } + const txId = tx.rows[0].id; + + // Daily min/max/avg price per source vendor — bucket by day + const conditions = [`po.transceiver_id = $1`, `po.time >= NOW() - INTERVAL '${days} days'`, `po.price > 0`, `po.is_anomalous IS NOT TRUE`]; + const values: unknown[] = [txId]; + let idx = 2; + + if (vendorFilter && vendorFilter !== "all") { + conditions.push(`sv.name ILIKE $${idx}`); + values.push(`%${vendorFilter}%`); + idx++; + } + + const where = `WHERE ${conditions.join(" AND ")}`; + + const seriesQuery = ` + SELECT + DATE_TRUNC('day', po.time) AS day, + sv.name AS source_vendor, + sv.id AS source_vendor_id, + MIN(po.price) AS price_min, + MAX(po.price) AS price_max, + AVG(po.price)::numeric(12,2) AS price_avg, + po.currency, + COUNT(*) AS observations + FROM price_observations po + LEFT JOIN vendors sv ON sv.id = po.source_vendor_id + ${where} + GROUP BY DATE_TRUNC('day', po.time), sv.name, sv.id, po.currency + ORDER BY day ASC, source_vendor + `; + + // Current best price (lowest verified non-anomalous) + const currentQuery = ` + SELECT + sv.name AS source_vendor, + MIN(po.price) AS best_price, + po.currency, + MAX(po.time) AS last_seen + FROM price_observations po + LEFT JOIN vendors sv ON sv.id = po.source_vendor_id + WHERE po.transceiver_id = $1 + AND po.time >= NOW() - INTERVAL '7 days' + AND po.price > 0 + AND po.is_anomalous IS NOT TRUE + GROUP BY sv.name, po.currency + ORDER BY best_price ASC + LIMIT 10 + `; + + const [series, current] = await Promise.all([ + pool.query(seriesQuery, values), + pool.query(currentQuery, [txId]), + ]); + + res.json({ + success: true, + transceiver: tx.rows[0], + days, + series: series.rows, + current_prices: current.rows, + }); + } catch (err) { + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index d532fb8..fd8d225 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -805,6 +805,7 @@
✎ Review
🏭 Stock
💲 Price Comparison
+
🔀 Equivalences
@@ -1543,6 +1544,19 @@
+ + +
+
+ 🔵 LinkedIn Distribution +
+ DRY RUN + +
+
+
+
Loading…
+
@@ -2194,6 +2208,38 @@ + + + @@ -3946,6 +3992,121 @@ async function openTxDetail(id) { el('panel-content').insertAdjacentHTML('beforeend', ch); }).catch(function() {}); + // Async: load price history chart (last 30 days) + (function loadPriceHistoryPanel(txId) { + var ph = '
📈 Price History (30 days)
'; + ph += '
Loading…
'; + el('panel-content').insertAdjacentHTML('beforeend', ph); + api('/api/price-history/' + txId + '?days=30').then(function(phd) { + var inner = document.getElementById('price-history-inner'); + if (!inner) return; + var series = phd.series || []; + if (!series.length) { inner.textContent = 'No price data in the last 30 days.'; return; } + + // Group series by vendor + var byVendor = {}; + series.forEach(function(row) { + var v = row.source_vendor || 'Unknown'; + if (!byVendor[v]) byVendor[v] = []; + byVendor[v].push(row); + }); + + // Build mini sparklines (SVG-based, per vendor) + var vendorColors = ['#f97316','#3b82f6','#10b981','#a855f7','#f59e0b','#ef4444','#06b6d4','#84cc16']; + var vendors = Object.keys(byVendor).slice(0, 8); + + // Collect all values for y-scale + var allVals = series.map(function(r) { return parseFloat(r.price_avg); }).filter(function(v) { return !isNaN(v) && v > 0; }); + var yMin = Math.min.apply(null, allVals); + var yMax = Math.max.apply(null, allVals); + var yRange = yMax - yMin || 1; + + // Collect all days for x-scale + var allDays = []; + series.forEach(function(r) { if (allDays.indexOf(r.day) === -1) allDays.push(r.day); }); + allDays.sort(); + var xCount = allDays.length || 1; + var W = 260, H = 60; + + var svgLines = ''; + vendors.forEach(function(vendor, vi) { + var points = byVendor[vendor]; + var coords = points.map(function(p) { + var xi = allDays.indexOf(p.day); + var x = Math.round((xi / (xCount - 1 || 1)) * (W - 8) + 4); + var y = Math.round(H - 4 - ((parseFloat(p.price_avg) - yMin) / yRange) * (H - 8)); + return x + ',' + y; + }).join(' '); + svgLines += ''; + }); + + var currency = series[0].currency || 'USD'; + var legend = vendors.map(function(v, i) { + var last = byVendor[v][byVendor[v].length - 1]; + var lastVal = last ? parseFloat(last.price_min).toFixed(2) : '—'; + return '' + + '' + + esc(v) + ' ' + currency + ' ' + lastVal + ''; + }).join(''); + + var html = '' + + '' + + svgLines + '' + + '
' + legend + '
' + + '
' + currency + ' range: ' + + yMin.toFixed(2) + ' – ' + yMax.toFixed(2) + ' · ' + series.length + ' observations
'; + inner.innerHTML = html; + }).catch(function() { + var inner = document.getElementById('price-history-inner'); + if (inner) inner.textContent = 'Price history not available.'; + }); + })(t.id); + + // Async: load cross-brand equivalences + (function loadEquivalencesPanel(txId) { + var ph = '
🔀 Cross-Brand Equivalences
'; + ph += '
Loading…
'; + el('panel-content').insertAdjacentHTML('beforeend', ph); + api('/api/equivalences/transceiver/' + txId).then(function(eqd) { + var inner = document.getElementById('equiv-panel-inner'); + if (!inner) return; + var rows = eqd.data || []; + if (!rows.length) { inner.textContent = 'No equivalences found for this product.'; return; } + + var isFx = rows[0] && rows[0].flexoptix_id === txId; + var html = '
'; + rows.slice(0, 8).forEach(function(r) { + var conf = Math.round(parseFloat(r.confidence) * 100); + var confColor = conf >= 90 ? '#10b981' : conf >= 75 ? '#f59e0b' : '#f97316'; + var basis = Array.isArray(r.match_basis) ? r.match_basis.slice(0, 3).join(', ') : ''; + if (isFx) { + // Show competitor side + var url = r.competitor_url ? ' onclick="window.open(\'' + esc(r.competitor_url) + '\',\'_blank\')" style="cursor:pointer"' : ''; + html += '
' + + '' + esc(r.competitor_pn || r.competitor_std || '—') + '' + + '' + esc(r.competitor_vendor) + '' + + '' + conf + '%' + + '
'; + } else { + // Show Flexoptix alternative + var url2 = r.flexoptix_url ? ' onclick="window.open(\'' + esc(r.flexoptix_url) + '\',\'_blank\')" style="cursor:pointer"' : ''; + var priceTag = r.flexoptix_price_eur ? ' €' + parseFloat(r.flexoptix_price_eur).toFixed(2) + '' : ''; + html += '
' + + 'FX: ' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '' + + priceTag + + '' + conf + '%' + + '
'; + } + }); + if (rows.length > 8) html += '
+' + (rows.length - 8) + ' more equivalences
'; + html += '
'; + inner.innerHTML = html; + }).catch(function() { + var inner = document.getElementById('equiv-panel-inner'); + if (inner) inner.textContent = 'Equivalences data not available.'; + }); + })(t.id); + } catch(e) { el('panel-content').textContent = 'Error: ' + e.message; } } @@ -8054,6 +8215,158 @@ async function lookupPriceComparison() { resultEl.textContent = 'Error: ' + e.message; } } + +// ─── EQUIVALENCES TAB ──────────────────────────────────────────────────────── + +var _equivDebounceTimer = null; +function debounceEquiv() { + clearTimeout(_equivDebounceTimer); + _equivDebounceTimer = setTimeout(searchEquivalences, 420); +} + +async function loadEquivStats() { + try { + var s = await api('/api/equivalences/stats'); + var st = s.stats || {}; + el('equiv-stats').textContent = + (st.active || 0).toLocaleString() + ' equivalences · ' + + (st.unique_competitor_products || 0).toLocaleString() + ' competitor products · ' + + (st.unique_flexoptix_products || 0).toLocaleString() + ' Flexoptix alternatives · ' + + 'Ø ' + (parseFloat(st.avg_confidence || 0) * 100).toFixed(1) + '% confidence'; + + var tv = await api('/api/equivalences/top-vendors'); + var vendors = (tv.data || []).slice(0, 10); + var chips = vendors.map(function(v) { + return '' + + esc(v.vendor) + ' ' + v.equiv_count.toLocaleString() + ''; + }).join(''); + el('equiv-top-vendors').innerHTML = chips; + } catch(e) {} +} + +async function searchEquivalences() { + var q = (el('equiv-q').value || '').trim(); + var vendor = (el('equiv-vendor').value || '').trim(); + var resultEl = el('equiv-results'); + if (!q && !vendor) { + resultEl.innerHTML = 'Enter a part number to find Flexoptix equivalents or competitor matches.'; + return; + } + + resultEl.innerHTML = 'Searching…'; + try { + var params = new URLSearchParams({ limit: '50' }); + if (q) params.set('q', q); + if (vendor) params.set('vendor', vendor); + var data = await api('/api/equivalences?' + params.toString()); + var rows = data.data || []; + + if (!rows.length) { + resultEl.innerHTML = 'No equivalences found for "' + esc(q) + '"' + (vendor ? ' (vendor: ' + esc(vendor) + ')' : '') + '.'; + return; + } + + var vendorColors = {'approved':'#10b981','auto_approved':'#3b82f6'}; + var html = '
' + + rows.length + ' result(s) — showing up to 50
'; + html += ''; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + rows.forEach(function(r) { + var conf = Math.round(parseFloat(r.confidence) * 100); + var confColor = conf >= 90 ? '#10b981' : conf >= 75 ? '#f59e0b' : '#f97316'; + var fxPrice = r.flexoptix_price_eur ? '€' + parseFloat(r.flexoptix_price_eur).toFixed(0) : '—'; + var fxSpec = [r.flexoptix_form_factor, r.flexoptix_speed, r.flexoptix_reach].filter(Boolean).join(' · '); + var fxUrl = r.flexoptix_url ? ' onclick="window.open(\'' + esc(r.flexoptix_url) + '\',\'_blank\')" style="cursor:pointer;color:var(--accent)"' : ''; + var cpUrl = r.competitor_url ? ' onclick="window.open(\'' + esc(r.competitor_url) + '\',\'_blank\')" style="cursor:pointer"' : ''; + var statusColor = vendorColors[r.status] || '#666'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
Competitor PNVendorFlexoptix AlternativeSpecsConf.Price
' + esc(r.competitor_pn || r.competitor_std || '—') + '' + esc(r.competitor_vendor) + '' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '' + esc(fxSpec) + '' + conf + '%' + fxPrice + '
'; + resultEl.innerHTML = html; + } catch(e) { + resultEl.textContent = 'Error: ' + e.message; + } +} + +// ─── LINKEDIN DISTRIBUTION STATUS ──────────────────────────────────────────── + +async function loadLinkedinHistory() { + var histEl = el('linkedin-history'); + var statsEl = el('linkedin-stats'); + var badgeEl = el('linkedin-dry-run-badge'); + if (!histEl) return; + try { + var data = await api('/api/blog/linkedin/history'); + if (badgeEl) { + badgeEl.textContent = data.dry_run ? 'DRY RUN' : '🟢 LIVE'; + badgeEl.style.background = data.dry_run ? '#f97316' : '#10b981'; + } + var st = data.stats || {}; + if (statsEl) { + statsEl.innerHTML = [ + '✅ Posted: ' + (st.posted || 0) + '', + '🧪 Dry-run: ' + (st.dry_run || 0) + '', + '⏭ Skipped: ' + (st.skipped || 0) + '', + '❌ Failed: ' + (st.failed || 0) + '', + ].join(''); + } + var rows = data.history || []; + if (!rows.length) { histEl.textContent = 'No distribution history yet.'; return; } + var html = ''; + rows.forEach(function(r) { + var stateColor = r.state === 'posted' ? '#10b981' : r.state === 'dry_run' ? '#f59e0b' : r.state === 'failed' ? '#ef4444' : '#6b7280'; + var date = r.posted_at ? new Date(r.posted_at).toLocaleDateString('de-DE') : (r.created_at ? new Date(r.created_at).toLocaleDateString('de-DE') : '—'); + var postLink = r.linkedin_urn ? ' View' : ''; + html += '' + + '' + + '' + + '' + + ''; + }); + html += '
' + esc(r.state) + '' + esc(r.title || r.ghost_slug || '—') + postLink + '' + date + '
'; + if (data.dry_run) { + html += '
' + + '⚠ DRY RUN mode — no real LinkedIn posts are being made. To go live, set DRY_RUN=false in /opt/linkedin-distributor/ecosystem.config.cjs and restart the PM2 process.
'; + } + histEl.innerHTML = html; + } catch(e) { + if (histEl) histEl.textContent = 'Error loading LinkedIn history: ' + e.message; + } +} + +// ─── Tab init for new tabs ──────────────────────────────────────────────────── +var _equivTabLoaded = false; +var _linkedinTabLoaded = false; +document.querySelectorAll('.tab').forEach(function(tab) { + tab.addEventListener('click', function() { + var t = this.getAttribute('data-tab'); + if (t === 'equivalences' && !_equivTabLoaded) { + _equivTabLoaded = true; + loadEquivStats(); + } + if (t === 'blog' && !_linkedinTabLoaded) { + _linkedinTabLoaded = true; + loadLinkedinHistory(); + } + }); +}); diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index c09a728..da3f7f2 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -25,6 +25,7 @@ import { registerContentTools } from "./tools/content.js"; import { registerMarketTools } from "./tools/market.js"; import { registerSwitchDocTools } from "./tools/switch-docs.js"; import { finderTools, handleFinderTool } from "./tools/finder.js"; +import { registerEquivalencesTools } from "./tools/equivalences.js"; async function main() { const server = new McpServer({ @@ -350,6 +351,7 @@ async function main() { await registerContentTools(server); await registerMarketTools(server); await registerSwitchDocTools(server); + await registerEquivalencesTools(server); // --- Register finder.ts tools (find_flexoptix_for_switch, get_competitor_alerts) --- for (const [toolName, toolDef] of Object.entries(finderTools)) { @@ -374,7 +376,7 @@ async function main() { // --- Ollama-compatible LLM tools: market analysis (TIP_LLM) + blog generation (FO_BlogLLM) --- const OLLAMA_BASE = process.env["OLLAMA_BASE_URL"] ?? "https://ollama.fichtmueller.org"; const TIP_LLM_MODEL = process.env["TIP_LLM_MODEL"] ?? "tip-llm-v1"; - const BLOG_LLM_MODEL = process.env["BLOG_LLM_MODEL"] ?? "fo-blog-v7"; + const BLOG_LLM_MODEL = process.env["BLOG_LLM_MODEL"] ?? "fo-blog-v10"; const BLOG_LLM_FALLBACK = process.env["BLOG_LLM_FALLBACK_MODEL"] ?? "qwen2.5:14b"; server.tool( diff --git a/packages/mcp-server/src/tools/equivalences.ts b/packages/mcp-server/src/tools/equivalences.ts new file mode 100644 index 0000000..b4abff2 --- /dev/null +++ b/packages/mcp-server/src/tools/equivalences.ts @@ -0,0 +1,217 @@ +/** + * Equivalences & price-history tools: find_equivalences, get_price_history + */ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { pool } from "../db.js"; + +export async function registerEquivalencesTools(server: McpServer): Promise { + // --- Tool: find_equivalences --- + server.tool( + "find_equivalences", + `Find Flexoptix equivalent transceivers for a competitor product (or vice-versa). + Uses the TIP equivalences database (63k+ verified cross-brand mappings, 93.9% avg confidence). + Example: "What Flexoptix alternative exists for Cisco GLC-LH-SMD?" → Returns Flexoptix part numbers, pricing, specs, and confidence.`, + { + part_number: z.string().describe("Competitor or Flexoptix part number, e.g. 'GLC-LH-SMD', 'SFP-10G-LR', 'QSFP-100G-PSM4'"), + vendor: z.string().optional().describe("Vendor filter, e.g. 'Cisco', 'Juniper', 'FS.COM'. Leave empty to search all vendors."), + min_confidence: z.number().min(0).max(1).default(0.8).describe("Minimum confidence threshold (0–1). Default 0.8."), + max_results: z.number().default(10).describe("Maximum results to return"), + }, + async ({ part_number, vendor, min_confidence, max_results }) => { + const conditions = [ + "e.status IN ('approved', 'auto_approved')", + `e.confidence >= $1`, + `(cx.part_number ILIKE $2 OR cx.standard_name ILIKE $2 OR fx.part_number ILIKE $2 OR fx.standard_name ILIKE $2)`, + ]; + const values: unknown[] = [min_confidence, `%${part_number}%`]; + let idx = 3; + + if (vendor) { + conditions.push(`cv.name ILIKE $${idx}`); + values.push(`%${vendor}%`); + idx++; + } + + const result = await pool.query( + `SELECT + e.confidence, + e.match_basis, + e.status, + -- Flexoptix product + fx.part_number AS flexoptix_pn, + fx.standard_name AS flexoptix_std, + fx.form_factor AS flexoptix_form_factor, + fx.speed AS flexoptix_speed, + fx.reach_label AS flexoptix_reach, + fx.fiber_type AS flexoptix_fiber, + fx.market_status AS flexoptix_market_status, + fx.price_verified_eur AS flexoptix_price_eur, + fx.product_page_url AS flexoptix_url, + -- Competitor product + cx.part_number AS competitor_pn, + cx.standard_name AS competitor_std, + cx.form_factor AS competitor_form_factor, + cx.speed AS competitor_speed, + cx.reach_label AS competitor_reach, + cx.price_verified_eur AS competitor_price_eur, + cv.name AS competitor_vendor + FROM transceiver_equivalences e + JOIN transceivers fx ON fx.id = e.flexoptix_id + JOIN transceivers cx ON cx.id = e.competitor_id + JOIN vendors cv ON cv.id = cx.vendor_id + WHERE ${conditions.join(" AND ")} + ORDER BY e.confidence DESC + LIMIT $${idx}`, + [...values, max_results] + ); + + if (result.rows.length === 0) { + return { + content: [{ + type: "text", + text: `No equivalences found for "${part_number}"${vendor ? ` from ${vendor}` : ""} with confidence ≥ ${min_confidence}.\n\nTry a broader search term or lower confidence threshold.`, + }], + }; + } + + const lines = result.rows.map((r, i) => { + const conf = `${(parseFloat(r.confidence) * 100).toFixed(0)}%`; + const basis = Array.isArray(r.match_basis) ? r.match_basis.join(", ") : r.match_basis; + const fxPrice = r.flexoptix_price_eur ? `€${r.flexoptix_price_eur}` : "—"; + const compPrice = r.competitor_price_eur ? `€${r.competitor_price_eur}` : "—"; + return [ + `${i + 1}. Competitor: ${r.competitor_vendor} **${r.competitor_pn}** (${r.competitor_std || r.competitor_form_factor}, ${r.competitor_speed}, ${r.competitor_reach || "—"}) @ ${compPrice}`, + ` → Flexoptix: **${r.flexoptix_pn}** (${r.flexoptix_std || r.flexoptix_form_factor}, ${r.flexoptix_speed}, ${r.flexoptix_reach || "—"}) @ ${fxPrice} | Status: ${r.flexoptix_market_status || "—"}`, + ` Confidence: ${conf} | Match basis: ${basis}`, + r.flexoptix_url ? ` Product page: ${r.flexoptix_url}` : "", + ].filter(Boolean).join("\n"); + }); + + return { + content: [{ + type: "text", + text: `## Equivalences for "${part_number}"\n\nFound ${result.rows.length} match(es):\n\n${lines.join("\n\n")}`, + }], + }; + } + ); + + // --- Tool: get_price_history --- + server.tool( + "get_price_history", + `Get price history for a transceiver over time (from 392k+ price observations across 60+ competitors). + Returns daily min/max/avg prices per source vendor for charting and trend analysis. + Useful for: price trend analysis, sourcing decisions, identifying cheapest vendor window.`, + { + part_number: z.string().describe("Part number, slug, or standard name, e.g. 'QSFP-40G-SR4', '100GBASE-LR4'"), + days: z.number().default(30).describe("Number of days of history to return (max 365)"), + vendor: z.string().optional().describe("Filter to specific source vendor, e.g. 'FS.COM', 'Mouser'. Leave empty for all."), + }, + async ({ part_number, days, vendor }) => { + const daysLimited = Math.min(days, 365); + + // Resolve transceiver + const tx = await pool.query( + `SELECT t.id, t.part_number, t.standard_name, t.form_factor, t.speed, v.name as vendor_name + FROM transceivers t LEFT JOIN vendors v ON v.id = t.vendor_id + WHERE t.slug ILIKE $1 OR t.part_number ILIKE $1 OR t.standard_name ILIKE $1 + LIMIT 1`, + [`%${part_number}%`] + ); + + if (tx.rows.length === 0) { + return { + content: [{ type: "text", text: `Transceiver not found: "${part_number}"` }], + }; + } + + const txRow = tx.rows[0]; + + const conditions = [ + `po.transceiver_id = $1`, + `po.time >= NOW() - INTERVAL '${daysLimited} days'`, + `po.price > 0`, + `po.is_anomalous IS NOT TRUE`, + ]; + const values: unknown[] = [txRow.id]; + let idx = 2; + + if (vendor) { + conditions.push(`sv.name ILIKE $${idx}`); + values.push(`%${vendor}%`); + idx++; + } + + const series = await pool.query( + `SELECT + DATE_TRUNC('day', po.time) AS day, + sv.name AS source_vendor, + MIN(po.price)::numeric(12,2) AS price_min, + MAX(po.price)::numeric(12,2) AS price_max, + AVG(po.price)::numeric(12,2) AS price_avg, + po.currency, + COUNT(*) AS observations + FROM price_observations po + LEFT JOIN vendors sv ON sv.id = po.source_vendor_id + WHERE ${conditions.join(" AND ")} + GROUP BY DATE_TRUNC('day', po.time), sv.name, po.currency + ORDER BY day ASC, source_vendor`, + values + ); + + if (series.rows.length === 0) { + return { + content: [{ + type: "text", + text: `No price history found for "${txRow.part_number}" in the last ${daysLimited} days.`, + }], + }; + } + + // Summarize by vendor + const byVendor = new Map(); + for (const row of series.rows) { + const key = row.source_vendor || "Unknown"; + const cur = byVendor.get(key); + if (!cur) { + byVendor.set(key, { min: parseFloat(row.price_min), max: parseFloat(row.price_max), latest: parseFloat(row.price_avg), currency: row.currency, points: parseInt(row.observations) }); + } else { + cur.min = Math.min(cur.min, parseFloat(row.price_min)); + cur.max = Math.max(cur.max, parseFloat(row.price_max)); + cur.latest = parseFloat(row.price_avg); // last in order = latest + cur.points += parseInt(row.observations); + } + } + + const vendorLines = [...byVendor.entries()] + .sort((a, b) => a[1].min - b[1].min) + .map(([v, d]) => + `- **${v}**: ${d.currency} ${d.min}–${d.max} (latest avg: ${d.latest}, ${d.points} observations)` + ); + + const allMins = [...byVendor.values()].map(d => d.min); + const overallMin = Math.min(...allMins); + const overallMax = Math.max(...[...byVendor.values()].map(d => d.max)); + const cheapestVendor = [...byVendor.entries()].sort((a, b) => a[1].min - b[1].min)[0]; + + return { + content: [{ + type: "text", + text: [ + `## Price History: ${txRow.part_number} (${txRow.vendor_name || "—"})`, + `**Standard:** ${txRow.standard_name || "—"} | **Form factor:** ${txRow.form_factor} | **Speed:** ${txRow.speed}`, + `**Period:** Last ${daysLimited} days | **Total observations:** ${series.rows.reduce((s, r) => s + parseInt(r.observations), 0)}`, + ``, + `### Price Range (all vendors)`, + `- Overall min: **${overallMin}** | max: **${overallMax}**`, + `- Cheapest source: **${cheapestVendor[0]}** @ ${cheapestVendor[1].min}`, + ``, + `### By Vendor`, + ...vendorLines, + ].join("\n"), + }], + }; + } + ); +}