feat(sasquatch): answer 'closest spot to me' locally + stop crew hijack

1) Proximity intent: 'closest / near me / nearby' + spots is handled ON DEVICE —
   the fix is read locally (fresh, else cached) purely to sort distances, and
   nothing is transmitted (not to relays, not to the AI). Answers the nearest 3
   with distances and says location never left the device.
2) System-prompt guardrail: the AI CANNOT see the user's location — for
   closest/near-me it now says so and points at the map's Near Me (or asks for a
   city), instead of guessing.
3) Intent hijack fix: a question mentioning both a crew and a spot was routed to
   find_crews ('crews' was checked before 'spots'). Spots now win when both
   appear.
This commit is contained in:
SkateSpots 2026-09-28 21:14:00 +07:00
parent 7f17bea341
commit f233fcc8ed

View file

@ -14,7 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCVMClient } from '@/hooks/useCVMClient';
import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient';
import { usePersonalContext } from '@/hooks/usePersonalContext';
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
@ -29,6 +29,9 @@ import { TripItineraryCard } from '@/components/TripItineraryCard';
import { buildItinerary, type Itinerary, type PlannerSpot, type PlannerEvent, type PlannerCoach, type PlannerCrew, type WeatherLite } from '@/lib/tripPlanner';
import { cn } from '@/lib/utils';
import { fetchWeather, calculateSkateability } from '@/lib/weather';
import { getCurrentPosition } from '@/lib/geolocation';
import { getLastLocation, saveLastLocation } from '@/lib/lastLocation';
import { calculateDistance } from '@/lib/sortOptions';
import ngeohash from 'ngeohash';
// ─── Weather helper — fetch current conditions for a city/spot via Open-Meteo ──
@ -271,6 +274,7 @@ Use when the user is LOOKING FOR gear ("I need…", "wanted: …", "anyone selli
Example: {"tool":"draft_action","args":{"action":"wtb_gear","title":"8.25 deck","price":6000,"category":"deck"}}
Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data.
Rule: you CANNOT see the user's location or GPS. For "closest / near me / nearby", NEVER guess a spot — say you can't see their location and point them to the map's 🎯 **Near Me** button, or ask for a city ("spots in Bangkok").
Rule: for "post/sell/list/want/set my status" requests use draft_action — never claim you posted something.`;
// ─── Detect if a question needs live CVM data ─────────────────────────────────
@ -344,7 +348,7 @@ function parseLocalIntent(question: string): LocalIntent | null {
if (/\bshops?\b|skate ?shops?|stores?/.test(q)) tool = 'find_shops';
else if (/\btours?\b|guides?\b/.test(q)) tool = 'find_tours';
else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events';
else if (/\bcrews?\b/.test(q)) tool = 'find_crews';
else if (/\bcrews?\b/.test(q) && !/\bspots?\b|skate ?parks?/.test(q)) tool = 'find_crews';
else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear';
else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?|builders?|artists?|graffiti|tattoo|trainers?|physios?|musicians?|bands?|judges?/.test(q)) tool = 'find_coaches';
else if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots';
@ -838,6 +842,74 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
[cvm],
);
// ── Proximity — "closest spot to me". The fix is read ON DEVICE and used only
// to sort by distance; nothing is transmitted (not to relays, not to the AI).
const answerNearestSpots = useCallback(async (): Promise<boolean> => {
let loc = getLastLocation();
try {
setStreamingText('📍 Getting your location…');
const fix = await getCurrentPosition();
loc = { lat: fix.lat, lng: fix.lng, at: Date.now() };
saveLastLocation(fix.lat, fix.lng);
} catch {
/* denied/unavailable — fall back to the cached fix if we have one */
}
setStreamingText('');
if (!loc) {
appendMessage({
role: 'sasquatch',
text: "I can't see your location — nothing ever leaves your device; I read it locally only when you ask, and the browser just declined. Tap 🎯 **Near Me** on the map, or tell me a city: \"spots in Bangkok\". 🛹",
timestamp: Date.now(),
});
return true;
}
try {
setStreamingText('🔍 Finding the closest spots…');
const res = await cvm.findSpots({ limit: 500 });
const nearest = (res.spots ?? [])
.map((s) => {
if (!s.geohash) return null;
try {
const { latitude, longitude } = ngeohash.decode(s.geohash);
return { s, km: calculateDistance(loc!.lat, loc!.lng, latitude, longitude) };
} catch {
return null;
}
})
.filter((x): x is { s: SpotResult; km: number } => !!x && Number.isFinite(x.km))
.sort((a, b) => a.km - b.km)
.slice(0, 3);
setStreamingText('');
setCvmConnected(true);
if (!nearest.length) {
appendMessage({
role: 'sasquatch',
text: 'No mapped spots near you yet 🦧 — [be the first to drop one](https://skate-spots.org/create-spot). 🛹',
timestamp: Date.now(),
isLive: true,
});
return true;
}
const fmt = (km: number) => (km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`);
const list = nearest
.map(({ s, km }) => `• [${s.name}](${s.url}) — **${fmt(km)}**${s.city ? ` · ${s.city}` : ''}`)
.join('\n');
appendMessage({
role: 'sasquatch',
text: `Closest to you right now:\n\n${list}\n\n_Computed on your device — your location never left it._`,
timestamp: Date.now(),
isLive: true,
});
return true;
} catch {
setStreamingText('');
return false;
}
}, [cvm, appendMessage]);
// ── Core: handle a user question ───────────────────────────────────────────
const handleSendMessage = useCallback(async (questionOverride?: string) => {
@ -888,7 +960,20 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
return;
}
// 1d. LOCAL FAST-PATH — the big speed win.
// 1d. PROXIMITY — "closest spot to me / near me". Handled HERE, on device:
// the fix is read locally and used ONLY to sort distances, so no location
// is sent anywhere (not to relays, not to the AI). Guarded against other
// subjects so "spots near my crew" isn't mistaken for "near me".
{
const wantsNearby = /\b(near ?me|nearby|close(?:st)?(?: to me| by me)?|around me|nearest|by me)\b/i.test(question);
const wantsSpots = /\b(spots?|skate ?parks?|parks?|places? to skate)\b/i.test(question);
const otherSubject = /\b(crews?|shops?|stores?|events?|comps?|jams?|gear|decks?|trucks?|wheels?|bearings?|shoes?|coach(?:es)?|filmers?|photographers?|editors?|builders?|tours?|guides?)\b/i.test(question);
if (wantsNearby && wantsSpots && !otherSubject) {
if (await answerNearestSpots()) return;
}
}
// 1e. LOCAL FAST-PATH — the big speed win.
// For clear finder questions ("spots in bangkok", "events in lisbon",
// "coaches near berlin", "community stats", "weather in X") we can parse
// the intent + location ourselves and go STRAIGHT to the relays — skipping
@ -1396,7 +1481,7 @@ Instructions:
setIsThinking(false);
setStreamingText('');
}
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner]);
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots]);
// Listen for external events from FAQ/About pages
useEffect(() => {