Rene Fichtmueller ea8be4aea3 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
2026-05-14 15:54:01 +02:00

218 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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"),
}],
};
}
);
}