diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index f24c8e5a6..65adf8c00 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -95,13 +95,29 @@ export function namesOverlap(a: string, b: string): boolean { .split(/[^a-z0-9]+/) .filter((word) => word.length > 2), ); - const left = words(a); - if (left.size === 0) return false; + const left = [...words(a)]; + if (left.length === 0) return false; + + /** + * Same word, allowing for an inflected ending. + * + * Providers localise names, and the two sides of this comparison rarely come + * from the same language: searching "Hamburg Airport" returns "Hamburger + * Flughafen Helmut Schmidt", and a plain equality test rejects its own + * correct answer over the "-er". A short prefix would over-match, so the + * shared stem has to carry weight and the endings have to be close. + */ + const sameStem = (x: string, y: string): boolean => { + if (x === y) return true; + const [short, long] = x.length <= y.length ? [x, y] : [y, x]; + return short.length >= 4 && long.length - short.length <= 3 && long.startsWith(short); + }; let shared = 0; for (const word of words(b)) { - if (!left.has(word)) continue; - if (word.length >= 4) return true; + const match = left.find((candidate) => sameStem(candidate, word)); + if (!match) continue; + if (Math.min(match.length, word.length) >= 4) return true; if (++shared >= 2) return true; } return false; diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index c1e29bb81..a1990e312 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -34,6 +34,21 @@ const GOOGLE_CAP = 3; */ const CACHE_KIND = 2; const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** + * A week is right for an answer, and far too long for the absence of one. + * + * Nothing found is usually the provider having a bad minute — a Wikimedia + * timeout, a rate limit, a Nominatim hiccup — not a place that genuinely has no + * pictures and no article. Storing that under the same TTL means one unlucky + * request leaves the column blank for the rest of the week, which is exactly + * how a fix can look like no fix at all when it lands. + */ +const EMPTY_CACHE_TTL_MS = 10 * 60 * 1000; + +/** Whether an answer is worth keeping for a week. */ +function hasAnything(value: CachedEnrichment): boolean { + return !!(value.photos.length || value.description || value.facts.length || value.hours || value.rating); +} /** * Bumped whenever the shape or the sources change. Without it a release that * adds a field or swaps a provider keeps serving the previous week's answers @@ -593,7 +608,7 @@ export class PlaceEnrichmentService { lang ?? '', CACHE_KIND, ); - if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null; + if (!row) return null; const parsed = JSON.parse(row.payload_json) as CachePayload; if (parsed.v !== CACHE_VERSION) return null; @@ -604,13 +619,18 @@ export class PlaceEnrichmentService { // of the week for a place that has pictures perfectly available. const photos = parsed.photos.filter((photo) => this.photoCache.get(photo.key)); if (photos.length !== parsed.photos.length) return null; - return { + + const value: CachedEnrichment = { photos, description: parsed.description ?? null, facts: parsed.facts ?? [], hours: parsed.hours ?? null, rating: parsed.rating ?? null, }; + // An answer keeps for a week; the absence of one gets ten minutes. + const ttl = hasAnything(value) ? CACHE_TTL_MS : EMPTY_CACHE_TTL_MS; + if (Date.now() - row.fetched_at >= ttl) return null; + return value; } catch { return null; } diff --git a/server/tests/unit/nest/maps.identity.test.ts b/server/tests/unit/nest/maps.identity.test.ts index d7d3a68e3..ec5da4eab 100644 --- a/server/tests/unit/nest/maps.identity.test.ts +++ b/server/tests/unit/nest/maps.identity.test.ts @@ -81,6 +81,18 @@ describe('namesOverlap', () => { expect(namesOverlap('Café Zürich', 'Cafe Zurich')).toBe(true); }); + it('MAPS-162c: matches a name across an inflected ending', () => { + // Live case: searching "Hamburg Airport" returns OSM's "Hamburger + // Flughafen Helmut Schmidt". Insisting on exact words rejected the correct + // answer over an "-er", and the airport came back with nothing at all. + expect(namesOverlap('Hamburg Airport', 'Hamburger Flughafen Helmut Schmidt')).toBe(true); + expect(namesOverlap('Berlin Hauptbahnhof', 'Berliner Hauptbahnhof')).toBe(true); + // Still not a licence to match on a shared opening: the stem has to carry + // weight and the endings have to be close. + expect(namesOverlap('Bahn Museum', 'Bahnhofsvorplatz Kiosk')).toBe(false); + expect(namesOverlap('Alt Museum', 'Altenpflegeheim Nord')).toBe(false); + }); + it('MAPS-162b: will not match on a single short word', () => { // An article, and TREK speaks 23 languages — a stopword list for all of // them is its own problem, so weight decides instead.