All files / lib locality-resolver.ts

95.45% Statements 63/66
94.11% Branches 32/34
88.88% Functions 8/9
100% Lines 52/52

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 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176                                          1x     10x       46x             26x 26x 26x 24x       23x 173x   173x 261x   23x 150x 1559x 1559x         1559x           3x       23x           20x 20x 20x 20x     1x                         8x 8x   6x 6x 24x 24x 24x 2x 22x   2x   20x   24x 5x                   6x 6x                       6x 6x   4x 4x 16x 3x                   4x                     5x 5x   3x 3x 12x 2x                   3x    
// Locality resolution helpers — name fuzzy match, pincode fan-out, slug
// match. Used by `/api/marketplace/localities/lookup` and the chatgpt-app's
// `lookup_locality` MCP tool.
 
export type LocalityLookupRow = {
	id: string;
	name: string;
	slug: string | null;
	zoneId: string;
	pinCodes: string[] | null;
};
 
export type LocalityMatch = {
	id: string;
	name: string;
	slug: string | null;
	zoneId: string;
	matchedPincode: string | null;
	score: number; // 0..1, higher = better
};
 
const PINCODE_REGEX = /^\d{6}$/;
 
export function isValidPincode(value: string): boolean {
	return PINCODE_REGEX.test(value);
}
 
function normalize(s: string): string {
	return s.trim().toLowerCase();
}
 
// Damerau-Levenshtein distance — handles transpositions ("madhapore" → 1
// edit from "madhapur") on top of insert/delete/substitute. Cheap for the
// short locality names we're matching (max ~30 chars × 92 localities).
export function damerauLevenshtein(a: string, b: string): number {
	const m = a.length;
	const n = b.length;
	if (m === 0) return n;
	if (n === 0) return m;
 
	// 2D matrix; allocated once per call. With <30-char inputs the cost is
	// negligible compared to the cron path that exercises this function.
	const d: number[][] = Array.from({ length: m + 1 }, () =>
		new Array<number>(n + 1).fill(0),
	);
	for (let i = 0; i <= m; i++) d[i][0] = i;
	for (let j = 0; j <= n; j++) d[0][j] = j;
 
	for (let i = 1; i <= m; i++) {
		for (let j = 1; j <= n; j++) {
			const cost = a[i - 1] === b[j - 1] ? 0 : 1;
			d[i][j] = Math.min(
				d[i - 1][j] + 1, // deletion
				d[i][j - 1] + 1, // insertion
				d[i - 1][j - 1] + cost, // substitution
			);
			if (
				i > 1 &&
				j > 1 &&
				a[i - 1] === b[j - 2] &&
				a[i - 2] === b[j - 1]
			) {
				d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost); // transposition
			}
		}
	}
	return d[m][n];
}
 
// Compute a 0..1 similarity score: 1 = exact match, falls off with edit
// distance relative to the longer string's length.
function similarity(a: string, b: string): number {
	Iif (a === b) return 1;
	const longer = Math.max(a.length, b.length);
	Iif (longer === 0) return 1;
	return 1 - damerauLevenshtein(a, b) / longer;
}
 
const NAME_MIN_SCORE = 0.6; // tunable: <0.6 generally feels unrelated
 
/**
 * Resolve a free-text locality name (e.g., "Madhapur" or "madhapore") to
 * the matching localities, sorted best-first. Exact match returns score=1;
 * prefix match scores between 0.85 and 1.0; otherwise we fall back to
 * Damerau-Levenshtein similarity. Below `NAME_MIN_SCORE`, results are
 * dropped — better to return zero than to recommend something irrelevant.
 */
export function resolveByName(
	rows: LocalityLookupRow[],
	rawName: string,
): LocalityMatch[] {
	const q = normalize(rawName);
	if (!q) return [];
 
	const matches: LocalityMatch[] = [];
	for (const row of rows) {
		const candidate = normalize(row.name);
		let score = 0;
		if (candidate === q) {
			score = 1;
		} else if (candidate.startsWith(q)) {
			// Prefix match: longer prefix relative to candidate length is better.
			score = 0.85 + 0.15 * (q.length / candidate.length);
		} else {
			score = similarity(q, candidate);
		}
		if (score >= NAME_MIN_SCORE) {
			matches.push({
				id: row.id,
				name: row.name,
				slug: row.slug,
				zoneId: row.zoneId,
				matchedPincode: null,
				score,
			});
		}
	}
	matches.sort((a, b) => b.score - a.score);
	return matches;
}
 
/**
 * Fan out a 6-digit pincode (F4.2) — the same pincode can map to multiple
 * localities (e.g., 500081 covers Madhapur + Hitech City). All matches are
 * returned in seed order; caller can present them as alternatives.
 */
export function resolveByPincode(
	rows: LocalityLookupRow[],
	pincode: string,
): LocalityMatch[] {
	const trimmed = pincode.trim();
	if (!isValidPincode(trimmed)) return [];
 
	const matches: LocalityMatch[] = [];
	for (const row of rows) {
		if (row.pinCodes?.includes(trimmed)) {
			matches.push({
				id: row.id,
				name: row.name,
				slug: row.slug,
				zoneId: row.zoneId,
				matchedPincode: trimmed,
				score: 1,
			});
		}
	}
	return matches;
}
 
/**
 * Exact slug match (case-insensitive). Slugs are unique within a zone but
 * could in principle repeat across zones, so this returns an array.
 */
export function resolveBySlug(
	rows: LocalityLookupRow[],
	rawSlug: string,
): LocalityMatch[] {
	const q = normalize(rawSlug);
	if (!q) return [];
 
	const matches: LocalityMatch[] = [];
	for (const row of rows) {
		if (row.slug && normalize(row.slug) === q) {
			matches.push({
				id: row.id,
				name: row.name,
				slug: row.slug,
				zoneId: row.zoneId,
				matchedPincode: null,
				score: 1,
			});
		}
	}
	return matches;
}