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:
parent
6df526bf27
commit
7e7cf0a8bf
2 changed files with 67 additions and 12 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue