Initial commit: Converge portfolio rationalization app
Next.js app for mapping a customer's competitive tool landscape onto our portfolio and building the consolidation business case: assessment workbench, customer report and Word export, product catalog, competitive knowledge base, dossier import, shareable landscape questionnaires with a review inbox, tool-footprint and business-unit value analysis, and an implementation timeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
94
scripts/seed-kb.mjs
Normal file
94
scripts/seed-kb.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Builds data/knowledge-base.json from the catalog's `replaces` lists.
|
||||
*
|
||||
* Each competitor string named by a product becomes one entry mapped to every
|
||||
* product that claims to displace it. Scores start at neutral defaults and are
|
||||
* refined automatically as real assessments are saved.
|
||||
*
|
||||
* Re-running is safe: existing entries keep their edits, observations and pins,
|
||||
* and only gain newly-discovered product mappings.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
const CATALOG = "data/catalog.json";
|
||||
const KB = "data/knowledge-base.json";
|
||||
|
||||
const MULTIWORD_VENDORS = [
|
||||
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
|
||||
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
|
||||
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
|
||||
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
|
||||
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
|
||||
"Amazon Web Services", "Google Cloud", "Data Theorem",
|
||||
];
|
||||
|
||||
const splitVendor = (label) => {
|
||||
const clean = label.replace(/\s+/g, " ").trim();
|
||||
for (const v of MULTIWORD_VENDORS) {
|
||||
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) return { vendor: v, product: clean.slice(v.length).trim() };
|
||||
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
|
||||
}
|
||||
const parts = clean.split(" ");
|
||||
return parts.length === 1 ? { vendor: clean, product: "" } : { vendor: parts[0], product: parts.slice(1).join(" ") };
|
||||
};
|
||||
|
||||
const NON_COMPETITIVE = [
|
||||
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
|
||||
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
|
||||
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
|
||||
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
|
||||
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
|
||||
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
|
||||
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
|
||||
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
|
||||
];
|
||||
const isCompetitive = (label) => !NON_COMPETITIVE.some((re) => re.test(label));
|
||||
|
||||
const kbId = (label) =>
|
||||
label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72) || "entry";
|
||||
|
||||
const catalog = JSON.parse(readFileSync(CATALOG, "utf8"));
|
||||
const existing = existsSync(KB) ? JSON.parse(readFileSync(KB, "utf8")) : { version: 1, entries: [] };
|
||||
const byId = new Map(existing.entries.map((e) => [e.id, e]));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let created = 0, enriched = 0;
|
||||
|
||||
for (const p of catalog.products) {
|
||||
for (const raw of p.replaces ?? []) {
|
||||
const label = raw.replace(/\s+/g, " ").trim();
|
||||
if (!label) continue;
|
||||
const id = kbId(label);
|
||||
|
||||
let e = byId.get(id);
|
||||
if (!e) {
|
||||
const { vendor, product } = splitVendor(label);
|
||||
e = {
|
||||
id, label, vendor, product, aliases: [], domain: p.domain,
|
||||
productIds: [], capability: 4, switchEase: 3, timeToRoiMonths: 12,
|
||||
savingsPct: 50, migrationCost: 0, unitLabel: "", risks: "", notes: "",
|
||||
competitive: isCompetitive(label),
|
||||
pinned: false, origin: "catalog", observations: [],
|
||||
createdAt: now, updatedAt: now,
|
||||
};
|
||||
byId.set(id, e);
|
||||
created++;
|
||||
}
|
||||
// Only add mappings; never strip a mapping a human curated.
|
||||
if (!e.productIds.includes(p.id)) {
|
||||
e.productIds.push(p.id);
|
||||
if (e.origin === "catalog") enriched++;
|
||||
}
|
||||
if (!e.domain) e.domain = p.domain;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
writeFileSync(KB, JSON.stringify({ version: 1, updatedAt: now, entries }, null, 2) + "\n");
|
||||
|
||||
// Backfill the flag for entries written before it existed.
|
||||
for (const e of entries) if (e.competitive === undefined) e.competitive = isCompetitive(e.label);
|
||||
|
||||
const mapped = entries.filter((e) => e.productIds.length > 0).length;
|
||||
const rivals = entries.filter((e) => e.competitive).length;
|
||||
console.log(`knowledge base: ${entries.length} entries (${created} new), ${mapped} mapped, ${rivals} true competitors, ${enriched} mappings added`);
|
||||
Reference in New Issue
Block a user