diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index bce8626..6d63d41 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -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); diff --git a/src/hooks/useCVMClient.ts b/src/hooks/useCVMClient.ts index 44031be..5025d40 100644 --- a/src/hooks/useCVMClient.ts +++ b/src/hooks/useCVMClient.ts @@ -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()) ); }