Files
arah2/search/src/source.ts
T
araharah-executor 8579da9d71 feat(04-01): curated KL gazetteer + lazy three-environment loader + GPL/ODbL licensing
- 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.
2026-10-24 00:36:03 +08:00

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 };