feat(tip): equivalences explorer + price history charts + linkedin status + MCP tools

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
This commit is contained in:
Rene Fichtmueller 2026-05-14 15:54:01 +02:00
parent 67310c8fe7
commit ea8be4aea3
7 changed files with 853 additions and 1 deletions

View File

@ -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")));

View File

@ -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 });
}
});

View File

@ -0,0 +1,186 @@
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const equivalencesRouter = Router();
// GET /api/equivalences?q=<part_number>&vendor=<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<string, string>;
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 });
}
});

View File

@ -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 });
}
});

View File

@ -805,6 +805,7 @@
<div class="tab" data-tab="review" id="tab-review-nav">&#9998; Review <span id="review-pending-badge" style="display:none;background:#f97316;color:#fff;border-radius:10px;padding:1px 7px;font-size:0.68rem;margin-left:4px;font-weight:700"></span></div>
<div class="tab" data-tab="stock">🏭 Stock</div>
<div class="tab" data-tab="prices">💲 Price Comparison</div>
<div class="tab" data-tab="equivalences">🔀 Equivalences</div>
</div>
<div class="main">
@ -1543,6 +1544,19 @@
</div>
<div style="margin-bottom:0.5rem;text-align:right"><button onclick="deleteAllTemplateDrafts()" style="background:#c1121f;color:white;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:0.7rem">Delete All Templates</button></div><div class="card"><div id="blog-list"></div></div>
<!-- LinkedIn Distribution Status -->
<div class="card mt" style="border-left:3px solid #0a66c2">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;flex-wrap:wrap;gap:0.5rem">
<span style="font-weight:600;font-size:0.9rem">🔵 LinkedIn Distribution</span>
<div style="display:flex;gap:0.5rem;align-items:center">
<span id="linkedin-dry-run-badge" style="font-size:0.72rem;padding:2px 8px;border-radius:10px;background:#f97316;color:#fff">DRY RUN</span>
<button onclick="loadLinkedinHistory()" style="background:var(--surface);border:1px solid var(--border);color:var(--text);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:0.72rem">↻ Refresh</button>
</div>
</div>
<div id="linkedin-stats" style="display:flex;gap:1.2rem;font-size:0.78rem;color:var(--text-dim);margin-bottom:0.75rem;flex-wrap:wrap"></div>
<div id="linkedin-history" style="font-size:0.78rem;color:var(--text-dim)">Loading…</div>
</div>
</div>
<!-- PROCUREMENT INTEL TAB -->
@ -2194,6 +2208,38 @@
</div>
</div><!-- end tab-prices -->
<!-- EQUIVALENCES TAB -->
<div id="tab-equivalences" class="hidden fade-in">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">
<h2 style="margin:0;font-size:1.1rem">🔀 Cross-Brand Equivalences</h2>
<div id="equiv-stats" style="font-size:0.78rem;color:var(--text-dim)">Loading…</div>
</div>
<!-- Search bar -->
<div class="card mb" style="padding:0.85rem">
<div style="display:flex;gap:0.6rem;flex-wrap:wrap;align-items:center">
<input type="text" id="equiv-q" placeholder="Part number, e.g. GLC-LH-SMD, SFP-10G-LR, QSFP-100G-PSM4…"
style="flex:1;min-width:200px;background:var(--surface);border:1px solid var(--border);color:var(--text);
padding:7px 12px;border-radius:6px;font-size:0.85rem"
oninput="debounceEquiv()" onkeydown="if(event.key==='Enter')searchEquivalences()">
<input type="text" id="equiv-vendor" placeholder="Vendor filter (optional)"
style="width:160px;background:var(--surface);border:1px solid var(--border);color:var(--text);
padding:7px 12px;border-radius:6px;font-size:0.85rem"
oninput="debounceEquiv()">
<button onclick="searchEquivalences()" style="background:var(--accent);color:#fff;border:none;
padding:7px 16px;border-radius:6px;cursor:pointer;font-size:0.82rem;white-space:nowrap">Search</button>
</div>
<div id="equiv-top-vendors" style="margin-top:0.7rem;display:flex;flex-wrap:wrap;gap:0.4rem"></div>
</div>
<!-- Results -->
<div class="card" style="padding:0">
<div id="equiv-results" style="padding:1rem;color:var(--text-dim);font-size:0.85rem">
Enter a part number to find Flexoptix equivalents or competitor matches.
</div>
</div>
</div><!-- end tab-equivalences -->
</div>
</div><!-- .app -->
@ -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 = '<div class="panel-section">📈 Price History <span style="font-size:0.7rem;font-weight:400;color:var(--text-dim)">(30 days)</span></div>';
ph += '<div id="price-history-inner" style="min-height:60px;font-size:0.78rem;color:var(--text-dim)">Loading…</div>';
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 += '<polyline points="' + coords + '" fill="none" stroke="' + vendorColors[vi % vendorColors.length] + '" stroke-width="1.5" stroke-linejoin="round"/>';
});
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 '<span style="display:inline-flex;align-items:center;gap:3px;white-space:nowrap;font-size:0.68rem">'
+ '<span style="width:10px;height:2px;background:' + vendorColors[i % vendorColors.length] + ';display:inline-block;border-radius:1px"></span>'
+ esc(v) + ' ' + currency + ' ' + lastVal + '</span>';
}).join('');
var html = '<svg width="' + W + '" height="' + H + '" style="display:block;margin-bottom:6px;overflow:visible">'
+ '<rect width="' + W + '" height="' + H + '" rx="4" fill="var(--surface2)"/>'
+ svgLines + '</svg>'
+ '<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:0.3rem">' + legend + '</div>'
+ '<div style="font-size:0.66rem;color:var(--text-dim)">' + currency + ' range: '
+ yMin.toFixed(2) + ' ' + yMax.toFixed(2) + ' · ' + series.length + ' observations</div>';
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 = '<div class="panel-section">🔀 Cross-Brand Equivalences</div>';
ph += '<div id="equiv-panel-inner" style="min-height:40px;font-size:0.78rem;color:var(--text-dim)">Loading…</div>';
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 = '<div style="display:flex;flex-direction:column;gap:0.4rem">';
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 += '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0.5rem;background:var(--surface2);border-radius:5px"' + url + '>'
+ '<span style="font-weight:600;font-size:0.8rem">' + esc(r.competitor_pn || r.competitor_std || '—') + '</span>'
+ '<span class="b b-blue" style="font-size:0.68rem">' + esc(r.competitor_vendor) + '</span>'
+ '<span style="margin-left:auto;font-size:0.68rem;color:' + confColor + ';font-weight:600">' + conf + '%</span>'
+ '</div>';
} 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 ? ' <span style="color:var(--text-dim);font-size:0.7rem">€' + parseFloat(r.flexoptix_price_eur).toFixed(2) + '</span>' : '';
html += '<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0.5rem;background:rgba(255,102,0,0.06);border:1px solid rgba(255,102,0,0.15);border-radius:5px"' + url2 + '>'
+ '<span style="font-weight:600;font-size:0.8rem;color:var(--accent)">FX: ' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '</span>'
+ priceTag
+ '<span style="margin-left:auto;font-size:0.68rem;color:' + confColor + ';font-weight:600">' + conf + '%</span>'
+ '</div>';
}
});
if (rows.length > 8) html += '<div style="font-size:0.7rem;color:var(--text-dim);padding:0.2rem 0.4rem">+' + (rows.length - 8) + ' more equivalences</div>';
html += '</div>';
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 '<span onclick="el(\'equiv-vendor\').value=\'' + esc(v.vendor) + '\';searchEquivalences()" '
+ 'style="cursor:pointer;padding:3px 9px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);'
+ 'font-size:0.72rem;white-space:nowrap" title="' + v.equiv_count.toLocaleString() + ' equivalences, ' + v.products_covered + ' products">'
+ esc(v.vendor) + ' <span style="color:var(--text-dim)">' + v.equiv_count.toLocaleString() + '</span></span>';
}).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 = '<span style="color:var(--text-dim)">Enter a part number to find Flexoptix equivalents or competitor matches.</span>';
return;
}
resultEl.innerHTML = '<span class="loading pulse">Searching…</span>';
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 = '<span style="color:var(--text-dim)">No equivalences found for "' + esc(q) + '"' + (vendor ? ' (vendor: ' + esc(vendor) + ')' : '') + '.</span>';
return;
}
var vendorColors = {'approved':'#10b981','auto_approved':'#3b82f6'};
var html = '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.6rem">'
+ rows.length + ' result(s) — showing up to 50</div>';
html += '<table style="width:100%;border-collapse:collapse;font-size:0.78rem">';
html += '<thead><tr style="border-bottom:1px solid var(--border)">'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Competitor PN</th>'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Vendor</th>'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Flexoptix Alternative</th>'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Specs</th>'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Conf.</th>'
+ '<th style="text-align:left;padding:0.3rem 0.4rem;color:var(--text-dim);font-weight:600">Price</th>'
+ '</tr></thead><tbody>';
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 += '<tr style="border-bottom:1px solid var(--border);transition:background 0.1s" onmouseenter="this.style.background=\'var(--surface2)\'" onmouseleave="this.style.background=\'\'">'
+ '<td style="padding:0.35rem 0.4rem;font-weight:600"' + cpUrl + '>' + esc(r.competitor_pn || r.competitor_std || '—') + '</td>'
+ '<td style="padding:0.35rem 0.4rem"><span class="b b-blue" style="font-size:0.68rem">' + esc(r.competitor_vendor) + '</span></td>'
+ '<td style="padding:0.35rem 0.4rem;color:var(--accent);font-weight:600"' + fxUrl + '>' + esc(r.flexoptix_pn || r.flexoptix_std || '—') + '</td>'
+ '<td style="padding:0.35rem 0.4rem;color:var(--text-dim);font-size:0.72rem">' + esc(fxSpec) + '</td>'
+ '<td style="padding:0.35rem 0.4rem;font-weight:700;color:' + confColor + '">' + conf + '%</td>'
+ '<td style="padding:0.35rem 0.4rem">' + fxPrice + '</td>'
+ '</tr>';
});
html += '</tbody></table>';
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 = [
'<span>✅ Posted: <strong>' + (st.posted || 0) + '</strong></span>',
'<span>🧪 Dry-run: <strong>' + (st.dry_run || 0) + '</strong></span>',
'<span>⏭ Skipped: <strong>' + (st.skipped || 0) + '</strong></span>',
'<span>❌ Failed: <strong>' + (st.failed || 0) + '</strong></span>',
].join('');
}
var rows = data.history || [];
if (!rows.length) { histEl.textContent = 'No distribution history yet.'; return; }
var html = '<table style="width:100%;border-collapse:collapse;font-size:0.75rem">';
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 ? ' <a href="https://www.linkedin.com/feed/update/' + esc(r.linkedin_urn) + '" target="_blank" style="color:var(--accent);font-size:0.68rem">View</a>' : '';
html += '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:0.3rem 0.4rem"><span style="font-size:0.7rem;padding:1px 7px;border-radius:8px;background:' + stateColor + '22;color:' + stateColor + ';font-weight:600">' + esc(r.state) + '</span></td>'
+ '<td style="padding:0.3rem 0.4rem;font-weight:500">' + esc(r.title || r.ghost_slug || '—') + postLink + '</td>'
+ '<td style="padding:0.3rem 0.4rem;color:var(--text-dim)">' + date + '</td>'
+ '</tr>';
});
html += '</table>';
if (data.dry_run) {
html += '<div style="margin-top:0.6rem;font-size:0.72rem;color:#f59e0b;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:5px;padding:0.4rem 0.7rem">'
+ '⚠ 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.</div>';
}
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();
}
});
});
</script>
<script src="/dashboard/hot-topics.js"></script>
</body>

View File

@ -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(

View File

@ -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<void> {
// --- 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 (01). 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<string, { min: number; max: number; latest: number; currency: string; points: number }>();
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"),
}],
};
}
);
}