From 84aa009b33b6bb22ba1f78866d75e5d622ebec28 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 17 Jun 2026 11:14:50 +0700 Subject: [PATCH] fix: CVM server tag mapping + add city/country geocoding to spots CVM server fixes (cvm-server/server.ts): - Use 'title' tag instead of 'name' to read spot names (app publishes 'title') - Search now checks 'title' tag (not just 'name') for keyword matches - City filter now also checks 'title' tag and event content - Country filter also checks event content - Features filter now checks 't' tags (app uses 't' not 'feature') - Validate spots with 't: skatespot' tag (same rule as the app) - Return 'location' field and proper features list in results - Image parsing now supports both 'image' (legacy) and 'imeta' (NIP-92) CreateSpotPage fix (src/pages/CreateSpotPage.tsx): - Add reverse geocoding via Nominatim on spot submit - Emit structured 'city' and 'country' tags so CVM can filter by them - Auto-fill location field from geocoding if user left it blank SasquatchGuide improvement (src/components/SasquatchGuide.tsx): - Add data model notes to system prompt explaining tag structure - Instruct AI to handle 0-result responses gracefully (encourage adding spots) - Use limit:20+ for city/country searches Co-authored-by: shakespeare.diy --- cvm-server/server.ts | 42 +++++++++++++++++++++++++------ src/components/SasquatchGuide.tsx | 10 +++++++- src/pages/CreateSpotPage.tsx | 36 ++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/cvm-server/server.ts b/cvm-server/server.ts index cdb9300..fc9651a 100644 --- a/cvm-server/server.ts +++ b/cvm-server/server.ts @@ -131,11 +131,15 @@ mcpServer.registerTool( }, }, async ({ city, country, difficulty, features, search, limit }) => { - const filters: Filter[] = [{ kinds: [30818], limit: 500 }]; - - const raw = await queryNostr(filters); + // Query all kind 30818 events; also try with #t filter for relays that support it + const raw = await queryNostr([{ kinds: [30818], limit: 500 }]); let spots = deduplicateAddressable(raw); + // Only keep valid skate spots (must have 't: skatespot' tag — same rule as the app) + spots = spots.filter((e) => + e.tags.some((t: string[]) => t[0] === "t" && t[1] === "skatespot") + ); + if (difficulty && difficulty !== "all") { spots = spots.filter( (e) => getTag(e, "difficulty")?.toLowerCase() === difficulty @@ -146,8 +150,13 @@ mcpServer.registerTool( const q = city.toLowerCase(); spots = spots.filter( (e) => + // 'city' dedicated tag (if present) (getTag(e, "city") ?? "").toLowerCase().includes(q) || + // 'location' free-text field (most spots have this) (getTag(e, "location") ?? "").toLowerCase().includes(q) || + // 'title' tag (spot name may include city) + (getTag(e, "title") ?? "").toLowerCase().includes(q) || + // Event content / description e.content.toLowerCase().includes(q) ); } @@ -157,13 +166,18 @@ mcpServer.registerTool( spots = spots.filter( (e) => (getTag(e, "country") ?? "").toLowerCase().includes(q) || - (getTag(e, "location") ?? "").toLowerCase().includes(q) + (getTag(e, "location") ?? "").toLowerCase().includes(q) || + e.content.toLowerCase().includes(q) ); } if (features && features.length > 0) { spots = spots.filter((e) => { - const spotFeatures = getTags(e, "feature").map((f) => f.toLowerCase()); + // Check 'feature' tags AND generic 't' tags (app uses 't' for features) + const spotFeatures = [ + ...getTags(e, "feature"), + ...getTags(e, "t"), + ].map((f) => f.toLowerCase()); const spotContent = e.content.toLowerCase(); return features.some( (f) => @@ -177,6 +191,8 @@ mcpServer.registerTool( const q = search.toLowerCase(); spots = spots.filter( (e) => + // App publishes 'title' tag (not 'name') — check both for compatibility + (getTag(e, "title") ?? "").toLowerCase().includes(q) || (getTag(e, "name") ?? "").toLowerCase().includes(q) || e.content.toLowerCase().includes(q) ); @@ -186,14 +202,24 @@ mcpServer.registerTool( spots = spots.slice(0, limit!); const result = spots.map((e) => ({ - name: getTag(e, "name") ?? "Unnamed Spot", + // App uses 'title' tag; fall back to 'name' for legacy events + name: getTag(e, "title") ?? getTag(e, "name") ?? "Unnamed Spot", description: e.content.slice(0, 300), city: getTag(e, "city") ?? getTag(e, "c"), country: getTag(e, "country"), + location: getTag(e, "location"), difficulty: getTag(e, "difficulty"), - features: getTags(e, "feature"), + category: getTag(e, "category"), + // App uses 't' tags for features (street, park, rails, etc.) + features: getTags(e, "t").filter((t) => t !== "skatespot"), geohash: getTag(e, "g"), - image: getTag(e, "image") ?? getTag(e, "thumb"), + // Support both 'image' (legacy) and 'imeta' (NIP-92, new) + image: getTag(e, "image") ?? + (() => { + const imeta = e.tags.find((t: string[]) => t[0] === "imeta"); + const urlPart = imeta?.find((p: string) => p.startsWith("url ")); + return urlPart ? urlPart.replace("url ", "") : undefined; + })(), author_pubkey: e.pubkey, naddr: buildNaddr(e), url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e, "d") ?? ""}`, diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 4845716..6147774 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -95,6 +95,12 @@ Args: city?(string), country?(string), difficulty?("beginner"|"intermediate"|"ad Example: {"tool":"find_spots","args":{"difficulty":"beginner","limit":5}} Example: {"tool":"find_spots","args":{"city":"Bangkok","search":"street"}} +IMPORTANT — Data model notes for find_spots: +- Spots are filtered by: 'city' tag OR 'location' free-text (which may contain city name) OR spot description OR spot title +- If city filter returns 0 results, it means no spots have been added in that city yet — tell the user that and encourage them to add spots via the map +- The community is growing — 0 results means "none yet", not a server error +- Always use limit:20 or higher to get broader results when searching by city/country + ### find_events Find upcoming skateboarding events. Args: city?(string), country?(string), event_type?(string), from_date?(string YYYY-MM-DD), to_date?(string YYYY-MM-DD), limit?(number) @@ -123,13 +129,15 @@ Example: {"tool":"get_community_stats","args":{}} - goskatespots.nostr1.com = primary SkateSpots relay (allowlisted) - The Lab = marketplace for coaches, filmers, video editors - Everything is free, no ads, no tracking 🛹 +- The community is growing — encourage users to add spots they know! ## Rules 1. For ANY question about spots, events, coaches, gear, or stats → use a CVM tool 2. Output the JSON tool call on its own line, nothing else — do not explain what you're doing 3. For app/protocol questions → answer directly from your knowledge 4. Be concise, skater-friendly, use emoji sparingly -5. Never make up spot names, event dates, or user data`; +5. Never make up spot names, event dates, or user data +6. If results come back empty (count: 0), explain honestly that no spots have been added there yet and encourage the user to add them via the map at skate-spots.org/map`; // ─── Detect if a question needs live CVM data ───────────────────────────────── diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 7dff25a..83d8326 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -215,6 +215,30 @@ export default function CreateSpotPage() { .replace(/^-|-$/g, '') .slice(0, 50) + '-' + Date.now().toString(36); + // Reverse geocode to get structured city/country tags + // This powers the CVM server's city/country filtering + let cityTag = ''; + let countryTag = ''; + let locationStr = location.trim(); + try { + const geoResp = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`, + { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } } + ); + if (geoResp.ok) { + const geoData = await geoResp.json(); + const addr = geoData.address ?? {}; + cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? ''; + countryTag = addr.country ?? ''; + // If user didn't fill in location, auto-populate from geocoding + if (!locationStr && cityTag) { + locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', '); + } + } + } catch { + // Geocoding is best-effort — don't block spot creation + } + // Build event tags const eventTags: string[][] = [ ['d', identifier], // Required for addressable events @@ -226,8 +250,16 @@ export default function CreateSpotPage() { ['lon', lng.toString()], ]; - if (location.trim()) { - eventTags.push(['location', location.trim()]); + if (locationStr) { + eventTags.push(['location', locationStr]); + } + + // Structured city/country tags — critical for CVM server search + if (cityTag) { + eventTags.push(['city', cityTag]); + } + if (countryTag) { + eventTags.push(['country', countryTag]); } if (contactInfo.trim()) {