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:
parent
7f17bea341
commit
f233fcc8ed
1 changed files with 89 additions and 4 deletions
|
|
@ -14,7 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCVMClient } from '@/hooks/useCVMClient';
|
import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient';
|
||||||
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
||||||
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
||||||
import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus';
|
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 { buildItinerary, type Itinerary, type PlannerSpot, type PlannerEvent, type PlannerCoach, type PlannerCrew, type WeatherLite } from '@/lib/tripPlanner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { fetchWeather, calculateSkateability } from '@/lib/weather';
|
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';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
||||||
// ─── Weather helper — fetch current conditions for a city/spot via Open-Meteo ──
|
// ─── 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"}}
|
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: 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.`;
|
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 ─────────────────────────────────
|
// ─── 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';
|
if (/\bshops?\b|skate ?shops?|stores?/.test(q)) tool = 'find_shops';
|
||||||
else if (/\btours?\b|guides?\b/.test(q)) tool = 'find_tours';
|
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 (/\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 (/\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 (/\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';
|
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],
|
[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 ───────────────────────────────────────────
|
// ── Core: handle a user question ───────────────────────────────────────────
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async (questionOverride?: string) => {
|
const handleSendMessage = useCallback(async (questionOverride?: string) => {
|
||||||
|
|
@ -888,7 +960,20 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
return;
|
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",
|
// For clear finder questions ("spots in bangkok", "events in lisbon",
|
||||||
// "coaches near berlin", "community stats", "weather in X") we can parse
|
// "coaches near berlin", "community stats", "weather in X") we can parse
|
||||||
// the intent + location ourselves and go STRAIGHT to the relays — skipping
|
// the intent + location ourselves and go STRAIGHT to the relays — skipping
|
||||||
|
|
@ -1396,7 +1481,7 @@ Instructions:
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
setStreamingText('');
|
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
|
// Listen for external events from FAQ/About pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue