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                                          2x     11x       61x             30x 30x 30x 28x       27x 225x   225x 312x   27x 198x 2103x 2103x         2103x           3x       27x           24x 24x 24x 24x     2x                         11x 11x   9x 9x 30x 30x 30x 4x 26x   2x   24x   30x 7x                   9x 9x                       7x 7x   5x 5x 18x 4x                   5x                     7x 7x   5x 5x 16x 4x                   5x    
// 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;
}