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;
|
break;
|
||||||
}
|
}
|
||||||
setCvmConnected(true);
|
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('');
|
setStreamingText('');
|
||||||
appendMessage({
|
|
||||||
role: 'sasquatch',
|
|
||||||
text: formatLocalResults(fastIntent, fastResult),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isLive: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (fastErr) {
|
} catch (fastErr) {
|
||||||
// Relays hiccuped — fall through to the AI path as a backup.
|
// Relays hiccuped — fall through to the AI path as a backup.
|
||||||
console.warn('[Sasquatch] fast-path failed, falling back to AI:', fastErr);
|
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();
|
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
|
* 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
|
* addressable events — e.g. the same place re-posted under a new `d` identifier
|
||||||
|
|
@ -254,13 +291,15 @@ export function useCVMClient() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.city) {
|
if (params.city) {
|
||||||
const q = params.city.toLowerCase();
|
const q = params.city;
|
||||||
spots = spots.filter(
|
spots = spots.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
(getTag(e, 'city') ?? '').toLowerCase().includes(q) ||
|
placeMatches(getTag(e, 'city') ?? '', q) ||
|
||||||
(getTag(e, 'location') ?? '').toLowerCase().includes(q) ||
|
placeMatches(getTag(e, 'location') ?? '', q) ||
|
||||||
(getTag(e, 'title') ?? '').toLowerCase().includes(q) ||
|
placeMatches(getTag(e, 'title') ?? '', q) ||
|
||||||
(e as unknown as { content: string }).content.toLowerCase().includes(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