Fix Sasquatch spot results: honest count + collapse duplicates

Two bugs from "spots in bangkok" (said "Found 20" but showed 6, with repeats):

1. Misleading count: formatLocalResults hard-capped the visible list to 6 while
   the headline reported the full count. Now shows up to 10 and appends a
   truthful "…and N more — see them all on the map" line, so the number and the
   list always agree.

2. Duplicate spots: coordinate dedupe (kind:pubkey:d) can't catch the same
   real-world spot re-posted under a different identifier or by another account
   (e.g. "Yellow Park – Sukhumvit 50" appearing twice). Added
   deduplicateByIdentity() — a second-pass dedupe on a normalized
   name + geohash/location signature, keeping the freshest event — applied in
   findSpots so the count and list are clean.

Both fixes flow through the AI tool-call path and the offline fallback path.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 23:17:15 +07:00
parent ada4bfee5e
commit 8b7d9ca4ea
2 changed files with 44 additions and 2 deletions

View file

@ -414,7 +414,11 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
return `No ${label} listed there yet 🦧 — be the first to add some at [skate-spots.org](https://skate-spots.org).`;
}
const lines = items.slice(0, 6).map((it) => {
// Show a generous slice, then honestly summarise the rest so the headline
// count and the visible list never disagree.
const MAX_SHOWN = 10;
const shown = items.slice(0, MAX_SHOWN);
const lines = shown.map((it) => {
const name = (it.name ?? it.title ?? it.role ?? 'Unnamed') as string;
const url = it.url as string | undefined;
const sub = (it.location ?? it.city ?? it.start ?? '') as string;
@ -429,7 +433,13 @@ function formatLocalResults(intent: LocalIntent, result: unknown): string {
intent.tool === 'find_crews' ? '👥' :
intent.tool === 'find_shops' ? '🏪' :
intent.tool === 'find_tours' ? '🗺️' : '🛍️';
return `${emoji} Found **${items.length}** ${label}:\n${lines}`;
const remaining = items.length - shown.length;
const more = remaining > 0
? `\n\n…and **${remaining}** more — [see them all on the map](https://skate-spots.org/map) 🗺️`
: '';
return `${emoji} Found **${items.length}** ${label}:\n${lines}${more}`;
}
/**

View file

@ -182,6 +182,34 @@ function deduplicateAddressable(events: { pubkey: string; kind: number; tags: st
return Array.from(seen.values());
}
/** Normalise a string for fuzzy matching: lowercase, trim, collapse whitespace. */
function normalizeForDedupe(s: string | undefined): string {
return (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
}
/**
* Collapse spots that are the SAME real-world spot published as different
* addressable events — e.g. the same place re-posted under a new `d` identifier
* or by two different accounts. Coordinate dedupe (kind:pubkey:d) can't catch
* these because the coordinates differ, so we also dedupe on a
* name + location/geohash signature and keep the freshest event.
*/
function deduplicateByIdentity<T extends { tags: string[][]; created_at: number }>(spots: T[]): T[] {
const seen = new Map<string, T>();
for (const e of spots) {
const name = normalizeForDedupe(getTag(e, 'title') ?? getTag(e, 'name'));
// Prefer geohash (precise) then location text as the place signature.
const place = normalizeForDedupe(getTag(e, 'g') ?? getTag(e, 'location') ?? getTag(e, 'city'));
// No name → can't confidently match; keep it (unique key on coordinate).
const key = name ? `${name}|${place}` : `__unique__${buildNaddr(e as unknown as { kind: number; pubkey: string; tags: string[][] })}`;
const existing = seen.get(key);
if (!existing || e.created_at > existing.created_at) {
seen.set(key, e);
}
}
return Array.from(seen.values());
}
function buildNaddr(event: { kind: number; pubkey: string; tags: string[][] }): string {
return `${event.kind}:${event.pubkey}:${getTag(event, 'd') ?? ''}`;
}
@ -215,6 +243,10 @@ export function useCVMClient() {
e.tags.some((t) => t[0] === 't' && t[1] === 'skatespot')
);
// Collapse the same real-world spot re-posted under a different id/author
// (coordinate dedupe above can't catch those). Keeps the freshest copy.
spots = deduplicateByIdentity(spots as typeof spots & { tags: string[][]; created_at: number }[]);
if (params.difficulty && params.difficulty !== 'all') {
spots = spots.filter(
(e) => getTag(e, 'difficulty')?.toLowerCase() === params.difficulty