- kl-gazetteer.json: 30 hand-curated KL-metro landmarks (10 required + 20
more) with locals' aliases as native data (D-08) — canonical OSM names
as name, shorthand ('KLCC', 'DUKE', 'Sg Wang') as aliases — plus the
ODbL-1.0 attribution header traveling inside the JSON (D-06/D-07)
- gazetteer.ts: the three laws — lazy memoized index never at module
scope (Workers startup, T-04-04), static JSON import surviving
wrangler/Vite/bare-Node (D-07), zero logging/storage (D-15); aliases
normalized once at index-build (D-09 symmetry)
- source.ts: the D-03 SearchSource — async search() + synchronous byId()
(slug-validated, honest null); tiered admission (fuzzy only at sim
>= 0.72) so 'Kuching'/'Penang' refuse as [] — never a fabricated pin
- index.ts: the named-export public API (no default, subpath+extension
imports per the @araharah/theme precedent)
- LICENSE: the verbatim GPL-3.0 text (D-04); LICENSES.md: the dual-licence
note (code GPL-3.0, data ODbL-1.0 + OSM attribution); README: corrected
licence section + search/ in the layout, four-package table, commands
G1-G11 + L1-L3 green (39/39); typecheck clean; esbuild bundle carries the
suria-klcc marker; wrangler dry-run exits 0; bot + web suites unaffected.
77 lines
3.2 KiB
TypeScript
77 lines
3.2 KiB
TypeScript
/*
|
|
* 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<SearchHit[]> {
|
|
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 };
|