Files
Converge/src/lib/kb.ts
Chris Olson d5c77543e8 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>
2026-09-24 11:48:42 -04:00

244 lines
8.9 KiB
TypeScript

import type { Assessment, Candidate, KbEntry, KbObservation, KnowledgeBase } from "./types";
/**
* Catalog `replaces` entries that name a status quo or an internal option
* rather than a competitor we could displace.
*/
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,
];
export function isCompetitive(label: string) {
return !NON_COMPETITIVE.some((re) => re.test(label));
}
export const KB_DEFAULTS = {
capability: 4,
switchEase: 3,
timeToRoiMonths: 12,
savingsPct: 50,
migrationCost: 0,
};
/** Vendors whose names run to more than one word, so the split does not cut them short. */
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", "Scale Computing", "Data Theorem", "Wiz Runtime",
];
export function splitVendor(label: string): { vendor: string; product: string } {
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(" ");
if (parts.length === 1) return { vendor: clean, product: "" };
return { vendor: parts[0], product: parts.slice(1).join(" ") };
}
export function kbId(label: string) {
return (
label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 72) || "entry"
);
}
/** The key an assessment candidate matches against. */
export function candidateLabel(c: Pick<Candidate, "incumbentVendor" | "incumbentProduct">) {
return [c.incumbentVendor, c.incumbentProduct].map((s) => s.trim()).filter(Boolean).join(" ");
}
const median = (xs: number[]) => {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
/**
* Recomputes an entry's defaults from its observations.
*
* Median rather than latest, so one unusual deal does not distort the entry;
* product mapping takes the ids that show up in at least half of the uses.
*/
export function recomputeDefaults(entry: KbEntry): KbEntry {
if (entry.pinned) return entry;
// With no observations left, the entry is seeded again — so it must show the
// seed baseline rather than keep numbers learned from a deal that is gone.
// The product mapping is kept: that came from the catalog, not from a deal.
if (entry.observations.length === 0) return { ...entry, ...KB_DEFAULTS };
const obs = entry.observations;
const counts = new Map<string, number>();
for (const o of obs) for (const id of new Set(o.productIds)) counts.set(id, (counts.get(id) ?? 0) + 1);
const majority = [...counts.entries()]
.filter(([, n]) => n * 2 >= obs.length)
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
return {
...entry,
productIds: majority.length ? majority : entry.productIds,
capability: Math.round(median(obs.map((o) => o.capability))),
switchEase: Math.round(median(obs.map((o) => o.switchEase))),
timeToRoiMonths: Math.round(median(obs.map((o) => o.timeToRoiMonths))),
savingsPct: Math.round(median(obs.map((o) => o.savingsPct))),
migrationCost: Math.round(median(obs.map((o) => o.migrationCost))),
};
}
export function newEntry(label: string, origin: KbEntry["origin"]): KbEntry {
const { vendor, product } = splitVendor(label);
const now = new Date().toISOString();
return {
id: kbId(label),
label,
vendor,
product,
aliases: [],
domain: "",
productIds: [],
...KB_DEFAULTS,
unitLabel: "",
risks: "",
notes: "",
competitive: isCompetitive(label),
pinned: false,
origin,
observations: [],
createdAt: now,
updatedAt: now,
};
}
/**
* Folds an assessment's candidates into the knowledge base.
*
* Observations are keyed by assessment + candidate, so re-saving an assessment
* updates that candidate's observation in place instead of stacking duplicates.
* Returns null when nothing changed, so an unchanged save writes no file.
*/
export function learnFromAssessment(kb: KnowledgeBase, a: Assessment): KnowledgeBase | null {
const before = JSON.stringify(kb.entries);
const byId = new Map(kb.entries.map((e) => [e.id, e]));
const now = new Date().toISOString();
for (const c of a.candidates) {
const label = candidateLabel(c);
// Only capture entries a seller has actually filled in and mapped.
if (!label || c.productIds.length === 0) continue;
const id = kbId(label);
let entry = byId.get(id);
if (!entry) {
entry = newEntry(label, "learned");
entry.domain = c.domain;
byId.set(id, entry);
}
const observation: KbObservation = {
sourceKey: `${a.id}::${c.id}`,
assessmentId: a.id,
customerName: a.customerName,
annualSpend: c.annualSpend,
units: c.units,
capability: c.capability,
switchEase: c.switchEase,
timeToRoiMonths: c.timeToRoiMonths,
savingsPct: c.savingsPct,
migrationCost: c.migrationCost,
productIds: [...c.productIds],
recordedAt: now,
};
entry.observations = [
...entry.observations.filter((o) => o.sourceKey !== observation.sourceKey),
observation,
];
if (!entry.domain) entry.domain = c.domain;
if (!entry.unitLabel && c.unitLabel) entry.unitLabel = c.unitLabel;
if (!entry.risks && c.risks.trim()) entry.risks = c.risks.trim();
const next = recomputeDefaults(entry);
next.updatedAt = now;
byId.set(id, next);
}
// Candidates that were removed or renamed must not leave stale observations
// behind, or the medians keep reflecting rows the seller has since deleted.
const liveKeys = new Set(
a.candidates
.filter((c) => candidateLabel(c) && c.productIds.length > 0)
.map((c) => `${a.id}::${c.id}`),
);
for (const [id, entry] of byId) {
const kept = entry.observations.filter((o) => o.assessmentId !== a.id || liveKeys.has(o.sourceKey));
if (kept.length === entry.observations.length) continue;
const next = recomputeDefaults({ ...entry, observations: kept });
next.updatedAt = now;
byId.set(id, next);
}
const entries = [...byId.values()].sort((x, y) => x.label.localeCompare(y.label));
if (JSON.stringify(entries) === before) return null;
return { ...kb, entries, updatedAt: now };
}
/** Drops every observation contributed by an assessment that no longer exists. */
export function forgetAssessment(kb: KnowledgeBase, assessmentId: string): KnowledgeBase | null {
const now = new Date().toISOString();
let changed = false;
const entries = kb.entries.map((entry) => {
const kept = entry.observations.filter((o) => o.assessmentId !== assessmentId);
if (kept.length === entry.observations.length) return entry;
changed = true;
return { ...recomputeDefaults({ ...entry, observations: kept }), updatedAt: now };
});
return changed ? { ...kb, entries, updatedAt: now } : null;
}
/** Ranks knowledge-base entries against what the seller has typed so far. */
export function searchKb(kb: KnowledgeBase, query: string, limit = 8): KbEntry[] {
const q = query.trim().toLowerCase();
if (!q) {
return [...kb.entries].sort((a, b) => b.observations.length - a.observations.length).slice(0, limit);
}
return kb.entries
.map((e) => {
const hay = [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase();
let score = 0;
if (hay.startsWith(q)) score += 100;
else if (e.label.toLowerCase().includes(q)) score += 60;
else if (hay.includes(q)) score += 30;
else return { e, score: 0 };
// Entries proven in real deals outrank ones only seeded from the catalog.
score += Math.min(20, e.observations.length * 4);
return { e, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || a.e.label.localeCompare(b.e.label))
.slice(0, limit)
.map((x) => x.e);
}