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:
parent
67310c8fe7
commit
ea8be4aea3
@ -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")));
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
186
packages/api/src/routes/equivalences.ts
Normal file
186
packages/api/src/routes/equivalences.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
90
packages/api/src/routes/price-history.ts
Normal file
90
packages/api/src/routes/price-history.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@ -805,6 +805,7 @@
|
||||
<div class="tab" data-tab="review" id="tab-review-nav">✎ 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>
|
||||
|
||||
@ -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(
|
||||
|
||||
217
packages/mcp-server/src/tools/equivalences.ts
Normal file
217
packages/mcp-server/src/tools/equivalences.ts
Normal 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 (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<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"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user