Match a place name across its inflected forms, and forget an empty answer quickly

This commit is contained in:
Maurice
2026-08-10 10:40:17 +02:00
committed by Maurice
parent e1a52ba43c
commit e321bfcf1b
3 changed files with 54 additions and 6 deletions
+20 -4
View File
@@ -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;
@@ -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;
}
@@ -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.