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
218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
/**
|
||
* 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"),
|
||
}],
|
||
};
|
||
}
|
||
);
|
||
}
|