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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 11:14:50 +07:00
parent 252163625e
commit 84aa009b33
3 changed files with 77 additions and 11 deletions

View file

@ -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") ?? ""}`,

View file

@ -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 ─────────────────────────────────

View file

@ -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()) {