Sasquatch fast-path: don't dead-end on empty/typos

The local fast-path was stricter than the AI it replaced: a typo like "bangok"
won't substring-match "Bangkok", so the fast-path returned a false "No spots
listed there yet" even though Bangkok has spots.

Two-layer fix:

1. Safety net (most important): if a finder fast-path comes back EMPTY, fall
   through to the AI path instead of rendering "nothing found". The AI normalizes
   spelling and can still find results. Stats/weather always render immediately.
   Net effect: fast when the local match works, correct-always via AI when it
   doesn't — never a false empty.

2. Typo tolerance: findSpots city matching now uses placeMatches() — substring
   OR edit-distance-1 against candidate word tokens (only for queries ≥4 chars,
   so short words can't over-match). So "bangok" matches "Bangkok" on the fast
   path too. Freeform content stays a strict substring check to avoid false hits.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 23:52:08 +07:00
parent 6df526bf27
commit 7e7cf0a8bf
2 changed files with 67 additions and 12 deletions

View file

@ -911,14 +911,30 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
break;
}
setCvmConnected(true);
// If a finder came back EMPTY, don't dead-end the user with "nothing
// found" — the local match is stricter than the AI (e.g. a typo like
// "bangok" won't substring-match "bangkok"). Fall through to the AI
// path, which normalizes spelling and can still find results. Stats &
// weather are always meaningful, so they render immediately.
const fastCount =
fastIntent.tool === 'get_community_stats' || fastIntent.tool === 'get_weather'
? 1
: ((fastResult as { count?: number } | undefined)?.count ?? 0);
if (fastCount > 0 || fastIntent.tool === 'get_community_stats' || fastIntent.tool === 'get_weather') {
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: formatLocalResults(fastIntent, fastResult),
timestamp: Date.now(),
isLive: true,
});
return;
}
// Empty → fall through to the AI path for a spelling-tolerant retry.
console.info('[Sasquatch] fast-path empty, retrying via AI for typo tolerance');
setStreamingText('');
appendMessage({
role: 'sasquatch',
text: formatLocalResults(fastIntent, fastResult),
timestamp: Date.now(),
isLive: true,
});
return;
} catch (fastErr) {
// Relays hiccuped — fall through to the AI path as a backup.
console.warn('[Sasquatch] fast-path failed, falling back to AI:', fastErr);

View file

@ -187,6 +187,43 @@ function normalizeForDedupe(s: string | undefined): string {
return (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
}
/** Levenshtein edit distance, capped early once it exceeds `max` (cheap). */
function editDistance(a: string, b: string, max = 2): number {
if (Math.abs(a.length - b.length) > max) return max + 1;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const curr = [i];
let rowMin = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const val = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
curr[j] = val;
if (val < rowMin) rowMin = val;
}
if (rowMin > max) return max + 1; // whole row exceeded budget → bail
prev = curr;
}
return prev[b.length];
}
/**
* Tolerant place match: true if the query appears as a substring of the
* candidate, OR (for queries ≥4 chars) is within edit-distance 1 of any word
* token in the candidate — so a small typo like "bangok" still matches
* "Bangkok, Thailand". Short queries require exact substring to avoid
* over-matching (e.g. "la" shouldn't fuzzy-hit half the world).
*/
function placeMatches(candidate: string, query: string): boolean {
const c = candidate.toLowerCase();
const q = query.toLowerCase().trim();
if (!q) return false;
if (c.includes(q)) return true;
if (q.length < 4) return false;
return c
.split(/[^a-z0-9]+/i)
.some((word) => word.length >= 3 && editDistance(word, q, 1) <= 1);
}
/**
* Collapse spots that are the SAME real-world spot published as different
* addressable events — e.g. the same place re-posted under a new `d` identifier
@ -254,13 +291,15 @@ export function useCVMClient() {
}
if (params.city) {
const q = params.city.toLowerCase();
const q = params.city;
spots = spots.filter(
(e) =>
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
(e as unknown as { content: string }).content.toLowerCase().includes(q)
placeMatches(getTag(e, 'city') ?? '', q) ||
placeMatches(getTag(e, 'location') ?? '', q) ||
placeMatches(getTag(e, 'title') ?? '', q) ||
// Content is freeform prose — keep it a strict substring check so a
// fuzzy word deep in a description doesn't cause a false match.
(e as unknown as { content: string }).content.toLowerCase().includes(q.toLowerCase())
);
}