diff --git a/.planning/phases/04-destination-search-kl-gazetteer/04-02-PLAN.md b/.planning/phases/04-destination-search-kl-gazetteer/04-02-PLAN.md index a62dc1d..ffb0653 100644 --- a/.planning/phases/04-destination-search-kl-gazetteer/04-02-PLAN.md +++ b/.planning/phases/04-destination-search-kl-gazetteer/04-02-PLAN.md @@ -110,7 +110,7 @@ From `search/src/index.ts` (plan 04-01's output — the surface every script in ```typescript export type { GazetteerEntry, SearchQuery, SearchHit, SearchSource } from "./types.ts"; export { normalize } from "./normalize.ts"; -export { TIER } from "./score.ts"; +export { TIER, FUZZY_ADMIT_SIMILARITY } from "./score.ts"; export { damerau } from "./distance.ts"; export { getEntries, getEntryById, getIndex } from "./gazetteer.ts"; export { createGazetteerSource, search } from "./source.ts"; @@ -316,7 +316,7 @@ Review and hand-merge into the curated dataset file — this script NEVER writes corpus before it ships. ``` -**Acceptance:** `npm run search:corpus` exits 0 with top-3 >= 90% and zero negative leaks; `npm run search:test` green; `docs/SEARCH-QUALITY.md` records the measured numbers and the lever. +**Acceptance:** `npm run search:corpus` exits 0 with top-3 >= 90% and zero negative leaks; `npm run search:test` green; `docs/SEARCH-QUALITY.md` records the measured numbers and the lever. If lever 1 cannot reach threshold, the runner exits 1 and the acceptance is met by the recorded lever-2/3 outcome in `docs/SEARCH-QUALITY.md` per the escape-hatch clause (D-14's honest close) — never by fabricating a pass. @@ -363,14 +363,14 @@ and to the root `package.json`: The suite imports the shared module and the corpus directly (in-process), so `npm run search:test` fails on a corpus regression. -**Acceptance:** `npm run search:corpus` exits 0; `npm run search:test` green with `PASS CO1`…`PASS CO8`. +**Acceptance:** `npm run search:corpus` exits 0 (threshold met) OR exits 1 with the lever-2/3 outcome recorded in `docs/SEARCH-QUALITY.md` (D-14's honest close); `npm run search:test` green with `PASS CO1`…`PASS CO8`. cd /home/C-Fu/araharah && npm run search:corpus 2>&1 | tail -30 ; npm run search:test 2>&1 | grep -E 'PASS CO|runner' ; node -e "const c=require('./search/test/fixtures/golden-corpus.json'); if(c.cases.length<60) throw new Error('corpus too small: '+c.cases.length); console.log('corpus OK '+c.cases.length+' cases')" - - `npm run search:corpus` exits 0 - - `npm run search:corpus` output contains a `top-3` line with a percentage >= 90.0% and a `negatives refused` line showing all negatives refused + - `npm run search:corpus` exits 0 (threshold met) OR exits 1 with `docs/SEARCH-QUALITY.md` recording the lever-2/3 outcome per the escape-hatch clause + - `npm run search:corpus` output contains a `top-3` line with a percentage >= 90.0% and a `negatives refused` line showing all negatives refused (threshold-met branch; the threshold-unmet branch prints `D-14 threshold not met` + the escalation ladder instead) - `npm run search:corpus` output contains one `PASS`/`FAIL` threshold line naming the 90.0% gate - `npm run search:test` exits 0 and the output contains `PASS CO1` through `PASS CO8` - `node -e "const c=require('./search/test/fixtures/golden-corpus.json'); const g=require('./search/src/data/kl-gazetteer.json'); const ids=new Set(g.entries.map(e=>e.id)); const kinds={}; let bad=0; for(const x of c.cases){ kinds[x.kind]=(kinds[x.kind]||0)+1; if(x.expectId!==null && !ids.has(x.expectId)) bad++; if(x.expectId!==null && !/^[A-Za-z0-9_-]{1,64}$/.test(x.expectId)) bad++; } if(c.cases.length<60) throw new Error('size'); if(bad) throw new Error('dangling ids'); console.log(JSON.stringify(kinds))"` exits 0 and prints all six `kind` keys with landmark>=12, abbreviation>=15, glued>=6, typo>=12, alias>=12, negative>=6 diff --git a/.planning/phases/04-destination-search-kl-gazetteer/04-03-PLAN.md b/.planning/phases/04-destination-search-kl-gazetteer/04-03-PLAN.md index 735e607..ca37ea0 100644 --- a/.planning/phases/04-destination-search-kl-gazetteer/04-03-PLAN.md +++ b/.planning/phases/04-destination-search-kl-gazetteer/04-03-PLAN.md @@ -250,7 +250,7 @@ export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: if (!/^[a-z0-9-]{1,64}$/.test(id)) return; // hostile id → answer, then drop const entry = source.byId(id); if (entry === null) return; // honest null — never a fabricated card - await sendVenueCard(ctx, entry, alternatesFor(entry, source), origin, locale); + await sendVenueCard(ctx, entry, await alternatesFor(entry, source), origin, locale); }); // Always answer everything else so Telegram doesn't show a 60-second spinner. @@ -266,7 +266,7 @@ export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: **`answerCallbackQuery` on EVERY path** — the 30-second rule (RESEARCH §Security: "`answerCallbackQuery` not called — Denial of Service (UX spinner)"). The catch-all `bot.on("callback_query:data", ...)` is grammY's documented recommendation ("Always answer all other callback queries … Otherwise, some clients may display a loading animation for up to a minute"). -**The alternates for a swapped card.** `alternatesFor(entry, source)` asks the source for the entry's own neighbourhood — `source.search({ text: entry.name, limit: 5 })` — and filters out the entry itself, capping at 4. If that yields nothing, the card carries the OPEN button alone. +**The alternates for a swapped card.** `alternatesFor(entry, source)` is ASYNC — `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise` — because the `SearchSource` interface's `search()` returns `Promise` (only `byId()` is synchronous). It awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, filters out the entry itself, capping at 4. The callback caller is already async, so it awaits the result. If that yields nothing, the card carries the OPEN button alone. @@ -429,6 +429,8 @@ The handler's flow: 5. `if (hits.length === 0)` → reply `searchNoMatchText(locale)` with link previews disabled; log `[search] rejected: no-match`; RETURN (no venue card, no keyboard — D-13's honest refusal). 6. Otherwise: build the alternates from `hits.slice(1)` (each `hits[i].entry`), then `await sendVenueCard(ctx, hits[0].entry, alternates, origin, locale)` — exactly ONE message (D-02). NO log line on the success path (there is nothing category-worthy to say, and every log line is a privacy surface). +**Task sequencing note:** at THIS task the card is a single inline `replyWithVenue` in `cari.ts` (this task's acceptance gate counts exactly one `replyWithVenue` here, and `npm run bot:typecheck` must pass without `search-alternate.ts` existing); task 2 extracts it into `sendVenueCard` (search-alternate.ts) and `cari.ts` imports it. + The OPEN button is `new InlineKeyboard().webApp(strings[locale].searchOpenButton, miniAppUrlFor(origin, entry.id))`. When `origin` is empty or blank: skip the OPEN button, send the card with the alternates only, and log `[search] rejected: mini-app-origin-missing`. The card still confirms the destination (SRCH-03 holds). @@ -472,7 +474,7 @@ When `origin` is empty or blank: skip the OPEN button, send the card with the al Exports: - `export function registerSearchAlternate(bot: Bot, source: SearchSource, origin: string): void` - `export async function sendVenueCard(ctx: Context, entry: GazetteerEntry, alternates: readonly GazetteerEntry[], origin: string, locale: Locale): Promise` — the ONE venue-card builder, shared by the `/cari` handler and the callback handler so the first card and the swapped card are structurally identical. -- `export function alternatesFor(entry: GazetteerEntry, source: SearchSource): GazetteerEntry[]` — the entry's own neighbourhood, filtered to exclude the entry itself, capped at 4. +- `export async function alternatesFor(entry: GazetteerEntry, source: SearchSource): Promise` — ASYNC because the `SearchSource` interface's `search()` returns `Promise`: it awaits `source.search({ text: entry.name, limit: 5 })`, maps the hits to their entries, excludes the entry itself, capped at 4. The card builder: @@ -502,7 +504,7 @@ export async function sendVenueCard( - `await ctx.answerCallbackQuery();` FIRST (the 30-second rule). - The id is validated against `/^[a-z0-9-]{1,64}$/` BEFORE any lookup — a hostile id is dropped silently (after the answer). - `const entry = source.byId(id);` → `null` means RETURN (honest null — never a fabricated card). -- The new card's alternates: `alternatesFor(entry, source)`. +- The new card's alternates: `await alternatesFor(entry, source)` (async — the awaited result feeds `sendVenueCard`). - The locale comes from `pickLocale(ctx.from?.language_code)` — the same rule the other handlers use. **The catch-all:** `bot.on("callback_query:data", async (ctx) => { const data = ctx.callbackQuery?.data ?? ""; if (/^alt:/.test(data)) return; await ctx.answerCallbackQuery(); })` — the specific matcher owns the `alt:` path exclusively, so the catch-all answers everything else (grammY's documented recommendation) and never double-answers.