All files / services/import taxonomy-match.service.ts

94.91% Statements 56/59
90% Branches 36/40
90.9% Functions 10/11
98.03% Lines 50/51

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145                                                                      2x                       2x 2x     103x       172x 172x 5x 5x   167x 1098x 1098x   167x       99x 86x 86x 86x 86x 86x 86x 86x 534x 569x 86x 534x 534x   86x                 18x 18x 18x 18x   18x 18x   18x 81x 81x 81x 81x 81x 81x 25x       18x   18x           18x 81x       18x                             5x 6x 8x 8x 6x    
/**
 * Fuzzy match scraped free-text labels (e.g. "Bombay", "MDF", "Mid-Range")
 * against the existing taxonomy tables.
 *
 * Stateless: takes an array of `{id, name}` candidates + the scraped label,
 * returns the best match plus a 0–1 confidence score. Callers fetch the
 * taxonomy rows once per scrape and reuse for every label.
 *
 * Scoring: Sørensen–Dice coefficient over bigrams, max(directScore, aliasScore).
 *
 * Thresholds (returned via `recommendation`):
 *   - score >= 0.85 → "auto"   (apply without admin review)
 *   - score >= 0.50 → "review" (surface in admin UI for confirmation)
 *   - score <  0.50 → "unknown" (no taxonomy id assigned; admin picks manually)
 */
 
export interface TaxonomyCandidate {
	id: string;
	name: string;
}
 
export interface MatchResult {
	id: string | null;
	score: number;
	candidate: string;
	recommendation: "auto" | "review" | "unknown";
	alternatives: Array<{ id: string; name: string; score: number }>;
}
 
export type AliasMap = Record<string, string>;
 
/**
 * Common Indian city + business-type aliases. Caller can pass an empty map to
 * disable aliasing or extend with category-specific entries.
 */
export const DEFAULT_ALIASES: AliasMap = {
	// City synonyms
	bombay: "mumbai",
	calcutta: "kolkata",
	madras: "chennai",
	bangaluru: "bengaluru",
	bangalore: "bengaluru",
	gurgaon: "gurugram",
	// Spelling variants
	mdf: "medium density fibreboard",
};
 
const AUTO_THRESHOLD = 0.85;
const REVIEW_THRESHOLD = 0.5;
 
function normalize(s: string): string {
	return s.toLowerCase().trim().replace(/\s+/g, " ").replace(/[^a-z0-9 ]/g, "");
}
 
function bigrams(s: string): Map<string, number> {
	const out = new Map<string, number>();
	if (s.length < 2) {
		Eif (s.length === 1) out.set(s, 1);
		return out;
	}
	for (let i = 0; i < s.length - 1; i++) {
		const g = s.slice(i, i + 2);
		out.set(g, (out.get(g) ?? 0) + 1);
	}
	return out;
}
 
function diceCoefficient(a: string, b: string): number {
	if (a === b) return 1;
	Iif (!a || !b) return 0;
	const ag = bigrams(a);
	const bg = bigrams(b);
	Iif (ag.size === 0 || bg.size === 0) return 0;
	let overlap = 0;
	let aTotal = 0;
	let bTotal = 0;
	for (const [, n] of ag) aTotal += n;
	for (const [, n] of bg) bTotal += n;
	for (const [g, n] of ag) {
		const m = bg.get(g) ?? 0;
		overlap += Math.min(n, m);
	}
	return (2 * overlap) / (aTotal + bTotal);
}
 
/** Pick the best taxonomy match for one scraped label. */
export function matchOne(
	label: string,
	candidates: TaxonomyCandidate[],
	aliases: AliasMap = DEFAULT_ALIASES,
): MatchResult {
	const trimmed = label.trim();
	const direct = normalize(trimmed);
	const aliasTarget = aliases[direct];
	const alias = aliasTarget ? normalize(aliasTarget) : null;
 
	let best: { id: string; name: string; score: number } | null = null;
	const scored: Array<{ id: string; name: string; score: number }> = [];
 
	for (const c of candidates) {
		const cn = normalize(c.name);
		const s1 = diceCoefficient(direct, cn);
		const s2 = alias ? diceCoefficient(alias, cn) : 0;
		const score = Math.max(s1, s2);
		scored.push({ id: c.id, name: c.name, score });
		if (!best || score > best.score) {
			best = { id: c.id, name: c.name, score };
		}
	}
 
	const score = best?.score ?? 0;
	const recommendation: MatchResult["recommendation"] =
		score >= AUTO_THRESHOLD
			? "auto"
			: score >= REVIEW_THRESHOLD
				? "review"
				: "unknown";
 
	const alternatives = scored
		.filter((c) => c.id !== best?.id && c.score > 0.2)
		.sort((a, b) => b.score - a.score)
		.slice(0, 3);
 
	return {
		id: recommendation === "unknown" ? null : (best?.id ?? null),
		score,
		candidate: trimmed,
		recommendation,
		alternatives,
	};
}
 
/** Match each comma- or array-separated label; returns one MatchResult per. */
export function matchMany(
	labels: string[],
	candidates: TaxonomyCandidate[],
	aliases: AliasMap = DEFAULT_ALIASES,
): MatchResult[] {
	const cleaned = labels
		.flatMap((l) => l.split(","))
		.map((l) => l.trim())
		.filter((l) => l.length > 0);
	return cleaned.map((l) => matchOne(l, candidates, aliases));
}