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:
parent
252163625e
commit
84aa009b33
3 changed files with 77 additions and 11 deletions
|
|
@ -131,11 +131,15 @@ mcpServer.registerTool(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ city, country, difficulty, features, search, limit }) => {
|
async ({ city, country, difficulty, features, search, limit }) => {
|
||||||
const filters: Filter[] = [{ kinds: [30818], limit: 500 }];
|
// Query all kind 30818 events; also try with #t filter for relays that support it
|
||||||
|
const raw = await queryNostr([{ kinds: [30818], limit: 500 }]);
|
||||||
const raw = await queryNostr(filters);
|
|
||||||
let spots = deduplicateAddressable(raw);
|
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") {
|
if (difficulty && difficulty !== "all") {
|
||||||
spots = spots.filter(
|
spots = spots.filter(
|
||||||
(e) => getTag(e, "difficulty")?.toLowerCase() === difficulty
|
(e) => getTag(e, "difficulty")?.toLowerCase() === difficulty
|
||||||
|
|
@ -146,8 +150,13 @@ mcpServer.registerTool(
|
||||||
const q = city.toLowerCase();
|
const q = city.toLowerCase();
|
||||||
spots = spots.filter(
|
spots = spots.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
// 'city' dedicated tag (if present)
|
||||||
(getTag(e, "city") ?? "").toLowerCase().includes(q) ||
|
(getTag(e, "city") ?? "").toLowerCase().includes(q) ||
|
||||||
|
// 'location' free-text field (most spots have this)
|
||||||
(getTag(e, "location") ?? "").toLowerCase().includes(q) ||
|
(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)
|
e.content.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -157,13 +166,18 @@ mcpServer.registerTool(
|
||||||
spots = spots.filter(
|
spots = spots.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
(getTag(e, "country") ?? "").toLowerCase().includes(q) ||
|
(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) {
|
if (features && features.length > 0) {
|
||||||
spots = spots.filter((e) => {
|
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();
|
const spotContent = e.content.toLowerCase();
|
||||||
return features.some(
|
return features.some(
|
||||||
(f) =>
|
(f) =>
|
||||||
|
|
@ -177,6 +191,8 @@ mcpServer.registerTool(
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
spots = spots.filter(
|
spots = spots.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
|
// App publishes 'title' tag (not 'name') — check both for compatibility
|
||||||
|
(getTag(e, "title") ?? "").toLowerCase().includes(q) ||
|
||||||
(getTag(e, "name") ?? "").toLowerCase().includes(q) ||
|
(getTag(e, "name") ?? "").toLowerCase().includes(q) ||
|
||||||
e.content.toLowerCase().includes(q)
|
e.content.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
|
|
@ -186,14 +202,24 @@ mcpServer.registerTool(
|
||||||
spots = spots.slice(0, limit!);
|
spots = spots.slice(0, limit!);
|
||||||
|
|
||||||
const result = spots.map((e) => ({
|
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),
|
description: e.content.slice(0, 300),
|
||||||
city: getTag(e, "city") ?? getTag(e, "c"),
|
city: getTag(e, "city") ?? getTag(e, "c"),
|
||||||
country: getTag(e, "country"),
|
country: getTag(e, "country"),
|
||||||
|
location: getTag(e, "location"),
|
||||||
difficulty: getTag(e, "difficulty"),
|
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"),
|
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,
|
author_pubkey: e.pubkey,
|
||||||
naddr: buildNaddr(e),
|
naddr: buildNaddr(e),
|
||||||
url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e, "d") ?? ""}`,
|
url: `https://skate-spots.org/spot/${e.pubkey}/${getTag(e, "d") ?? ""}`,
|
||||||
|
|
|
||||||
|
|
@ -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":{"difficulty":"beginner","limit":5}}
|
||||||
Example: {"tool":"find_spots","args":{"city":"Bangkok","search":"street"}}
|
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_events
|
||||||
Find upcoming skateboarding 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)
|
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)
|
- goskatespots.nostr1.com = primary SkateSpots relay (allowlisted)
|
||||||
- The Lab = marketplace for coaches, filmers, video editors
|
- The Lab = marketplace for coaches, filmers, video editors
|
||||||
- Everything is free, no ads, no tracking 🛹
|
- Everything is free, no ads, no tracking 🛹
|
||||||
|
- The community is growing — encourage users to add spots they know!
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
1. For ANY question about spots, events, coaches, gear, or stats → use a CVM tool
|
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
|
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
|
3. For app/protocol questions → answer directly from your knowledge
|
||||||
4. Be concise, skater-friendly, use emoji sparingly
|
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 ─────────────────────────────────
|
// ─── Detect if a question needs live CVM data ─────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,30 @@ export default function CreateSpotPage() {
|
||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
.slice(0, 50) + '-' + Date.now().toString(36);
|
.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
|
// Build event tags
|
||||||
const eventTags: string[][] = [
|
const eventTags: string[][] = [
|
||||||
['d', identifier], // Required for addressable events
|
['d', identifier], // Required for addressable events
|
||||||
|
|
@ -226,8 +250,16 @@ export default function CreateSpotPage() {
|
||||||
['lon', lng.toString()],
|
['lon', lng.toString()],
|
||||||
];
|
];
|
||||||
|
|
||||||
if (location.trim()) {
|
if (locationStr) {
|
||||||
eventTags.push(['location', location.trim()]);
|
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()) {
|
if (contactInfo.trim()) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue