/* * source.ts — the D-03 in-Worker SearchSource implementation. * ----------------------------------------------------------- * Wraps the lazy gazetteer index in the pluggable `SearchSource` seam * (D-03): an async search() the bot handler awaits, and a synchronous * byId() the alternates callback handler and the webview deep-link * reader use without awaiting a network. * * THE REFUSAL CONTRACT IS LOAD-BEARING (D-13/D-14): an EMPTY array is a * legitimate, load-bearing return value meaning "not in the v1 area". * Admission is TIERED, never a single floor — the EXACT / ALIAS_EXACT / * PREFIX / TOKEN_SET bands are always admitted; a FUZZY hit is admitted * only when its similarity reaches FUZZY_ADMIT_SIMILARITY (0.72): * "KCLC" vs "KLCC" sim 0.75 -> admitted (score 0.45) * "Penang" vs "Padang" sim 0.667 -> REFUSED (empty array, never a * fabricated pin) * scoreCandidate already applies that floor, so a null here means * "not in the v1 area" and the bot renders the honest refusal copy * (plan 04-03). * * Ranking: score band DESC (the bands are disjoint, so score order IS * tier order), then entry id ASC — a deterministic tie-break so corpus * results are reproducible. * * No logging, no storage (D-15): the query text never leaves the caller. */ import { getEntries, getEntryById } from "./gazetteer.ts"; import { normalize } from "./normalize.ts"; import { FUZZY_ADMIT_SIMILARITY, TIER, scoreCandidate } from "./score.ts"; import type { GazetteerEntry, SearchHit, SearchQuery, SearchSource } from "./types.ts"; /** Entry ids are slugs — byId rejects anything else before it touches the index. */ const ENTRY_ID_PATTERN = /^[a-z0-9-]{1,64}$/; /** 1 best + 2-4 alternates (D-02). */ const DEFAULT_LIMIT = 5; /** Synchronous convenience search over the whole gazetteer — the corpus * runner (plan 04-02) calls this without awaiting a network. */ export function search(text: string, limit?: number): SearchHit[] { const queryNorm = normalize(text); if (queryNorm === "") return []; const cap = limit ?? DEFAULT_LIMIT; const hits: SearchHit[] = []; for (const entry of getEntries()) { const scored = scoreCandidate(queryNorm, [entry.name, ...entry.aliases]); if (scored === null) continue; // not admitted — "not in the v1 area" hits.push({ entry, score: scored.score, matchedAlias: scored.matchedAlias }); } hits.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; // tier band DESC (disjoint bands) if (a.entry.id < b.entry.id) return -1; // deterministic id ASC tie-break if (a.entry.id > b.entry.id) return 1; return 0; }); return hits.slice(0, cap); } /** The D-03 gazetteer backend: async search() + synchronous byId(). */ export function createGazetteerSource(): SearchSource { return { name: "gazetteer", async search(q: SearchQuery): Promise { return search(q.text, q.limit); }, byId(id: string): GazetteerEntry | null { if (!ENTRY_ID_PATTERN.test(id)) return null; return getEntryById(id); }, }; } /** Re-exported so the tiered admission rule reads as one story: the * floor lives in score.ts; this module applies it per D-13. */ export { FUZZY_ADMIT_SIMILARITY };