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:
243
src/lib/kb.ts
Normal file
243
src/lib/kb.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user