Skip to content

Commit 2d73027

Browse files
committed
fix(search): stop hallucinated NL attributes from zeroing category results
A small local model returned a full attribute set for "Schulen in meiner Nähe" (schools), which applyFacets applied globally — filtering every school away and showing a wall of "could not filter by" tags as a near-invisible map overlay. - applyFacets now respects each facet's categoryIds (schools support no facets, so bogus attrs are ignored) and skips the model-default "no" on multi facets. - The unmapped-attribute notice drops recognized OSM tag keys (keeps genuine free-text qualities), dedupes, and renders as a readable pill, not faint text. - Prompt: emit only attributes the user explicitly states; empty otherwise.
1 parent 21342c6 commit 2d73027

5 files changed

Lines changed: 82 additions & 9 deletions

File tree

apps/web/src/components/search/CategoryFilterBar.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,27 @@ import { CategoryFiltersPanel } from "./CategoryFiltersPanel";
3333
import { floatingChipSx, floatingToolbarSx } from "./floatingChipSx";
3434
import { NlpUnmappedNotice } from "./NlpFilterChips";
3535

36+
// Recognized OSM attribute keys (base form). A small model sometimes echoes
37+
// these into `unmapped_attributes` instead of leaving them out — they aren't
38+
// genuinely "unmapped", so they're stripped from the notice.
39+
const KNOWN_OSM_ATTRIBUTE_KEYS = new Set([
40+
"outdoor_seating",
41+
"wheelchair",
42+
"internet_access",
43+
"cuisine",
44+
"diet",
45+
"takeaway",
46+
"delivery",
47+
"drive_through",
48+
"smoking",
49+
"dog",
50+
"fee",
51+
"payment",
52+
"live_music",
53+
"organic",
54+
"distance",
55+
]);
56+
3657
// Display order: Mon–Sun; JS day indices
3758
const DAYS: { key: string; idx: number }[] = [
3859
{ key: "monday", idx: 1 },
@@ -123,11 +144,22 @@ export function CategoryFilterBar() {
123144
// filter (e.g. "best", "instagrammable") so the user knows they aren't
124145
// narrowing the results.
125146
const isNlpActive = useNlpSearchStore((s) => s.isNlpActive);
126-
const nlpUnmapped = useNlpSearchStore((s) => s.intent?.unmapped_attributes);
147+
const nlpRawUnmapped = useNlpSearchStore((s) => s.intent?.unmapped_attributes);
148+
const nlpAttributes = useNlpSearchStore((s) => s.intent?.attributes);
149+
// Keep only genuine free-text qualities ("cozy", "best") — drop anything that
150+
// is a recognized OSM tag key or already present in `attributes` (small models
151+
// duplicate/echo the tag vocabulary here), and dedupe.
152+
const nlpUnmapped = useMemo(() => {
153+
if (!nlpRawUnmapped) return [];
154+
const attrKeys = new Set(Object.keys(nlpAttributes ?? {}));
155+
return [
156+
...new Set(
157+
nlpRawUnmapped.filter((a) => !attrKeys.has(a) && !KNOWN_OSM_ATTRIBUTE_KEYS.has(a)),
158+
),
159+
];
160+
}, [nlpRawUnmapped, nlpAttributes]);
127161
const unmappedNotice =
128-
isNlpActive && nlpUnmapped && nlpUnmapped.length > 0 ? (
129-
<NlpUnmappedNotice attributes={nlpUnmapped} />
130-
) : null;
162+
isNlpActive && nlpUnmapped.length > 0 ? <NlpUnmappedNotice attributes={nlpUnmapped} /> : null;
131163
const { rawResults, dominantCategory } = useExploreResults();
132164
// In text mode there is no active category chip — reuse the facets of the
133165
// category that the results predominantly belong to (e.g. "McDonald's" →

apps/web/src/components/search/NlpFilterChips.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@ export function NlpUnmappedNotice({ attributes }: { attributes: string[] }) {
1717
return (
1818
<Box
1919
sx={{
20-
display: "flex",
20+
display: "inline-flex",
2121
alignItems: "center",
2222
gap: 0.75,
2323
color: "text.secondary",
2424
pointerEvents: "auto",
25+
// Give the notice an opaque pill background — it floats over the map, so
26+
// without this the muted caption is nearly unreadable against the tiles.
27+
bgcolor: "background.paper",
28+
px: 1.25,
29+
py: 0.5,
30+
borderRadius: 2,
31+
boxShadow: 2,
32+
maxWidth: "100%",
2533
}}
2634
>
2735
<InfoOutlinedIcon sx={{ fontSize: 16, flexShrink: 0 }} />

integrations/search-nlp/prompt.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ OSM ATTRIBUTE TAGS (use these exact keys in "attributes", string values only):
99
outdoor_seating ("yes"/"no"), wheelchair ("yes"/"limited"/"no"), internet_access ("wlan"/"yes"/"no"), cuisine (lowercase, e.g. "italian"), diet:vegan ("yes"/"only"), diet:vegetarian ("yes"/"only"), diet:halal ("yes"), diet:kosher ("yes"), diet:gluten_free ("yes"), takeaway ("yes"/"no"), delivery ("yes"/"no"), drive_through ("yes"/"no"), smoking ("yes"/"no"/"outside"), dog ("yes"/"no"), fee ("yes"/"no"), payment:credit_cards ("yes"/"no"), live_music ("yes"), organic ("yes")
1010
1111
RULES:
12-
1. Map intent to one or more categories from the list.
13-
2. Extract OSM attribute filters using only the keys above; values are strings.
12+
1. Map intent to one or more categories from the list, using the exact IDs above (e.g. "kindergartens", not "kindergarten").
13+
2. Extract OSM attribute filters using only the keys above; values are strings. Include ONLY attributes the user EXPLICITLY states. If the user mentions no attributes, "attributes" MUST be an empty object {}. Never invent attributes, never fill in defaults, and never emit the value "no" unless the user explicitly excludes that feature (e.g. "no outdoor seating").
1414
3. "near <place>" => spatial_constraint {type:"near_place", place_name}. "near me" => {type:"current_view"} (the client resolves the user's location).
1515
4. Time phrases => time_constraint (open_now / open_24h / open_at with day "Monday".."Sunday" and "HH:MM").
16-
5. Qualities with no OSM tag (quiet, cozy, cheap, family-friendly) go in unmapped_attributes.
16+
5. Free-text qualities the user typed that have no OSM tag (quiet, cozy, cheap, family-friendly) go in unmapped_attributes. Put ONLY such words here — never OSM tag keys — and leave it empty if there are none.
1717
6. confidence 0-1: 0.9+ clear intent, 0.5-0.8 ambiguous, below 0.4 if it looks like a place name/address (then categories=[]).
1818
7. explanation: short human-readable summary.
1919
8. sort_by defaults to "relevance"; "nearest/closest" => "distance"; "best/top-rated" => "rating".

packages/core/src/stores/__tests__/nlpSearchStore.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,30 @@ describe("useNlpSearchStore", () => {
132132
useNlpSearchStore.getState().activate(intent, bbox, "openai");
133133
expect(useCategoryFacetStore.getState().selections.wheelchairAccessible).toBeUndefined();
134134
});
135+
136+
it("ignores facets not scoped to the active category (e.g. food attrs on a schools search)", () => {
137+
// Reproduces the Aachen bug: a small model hallucinated a full attribute
138+
// set for "Schulen in meiner Nähe"; none of these apply to schools, so
139+
// no facet should be set and the schools results must not be filtered.
140+
const intent = makeIntent({
141+
categories: ["schools"],
142+
attributes: {
143+
outdoor_seating: "no",
144+
wheelchair: "limited",
145+
internet_access: "wlan",
146+
cuisine: "no",
147+
"diet:vegan": "yes",
148+
},
149+
});
150+
useNlpSearchStore.getState().activate(intent, bbox, "openai");
151+
expect(Object.keys(useCategoryFacetStore.getState().selections)).toHaveLength(0);
152+
});
153+
154+
it("skips a multi facet whose value is the model default 'no'", () => {
155+
const intent = makeIntent({ categories: ["restaurants"], attributes: { cuisine: "no" } });
156+
useNlpSearchStore.getState().activate(intent, bbox, "openai");
157+
expect(useCategoryFacetStore.getState().selections.cuisine).toBeUndefined();
158+
});
135159
});
136160
});
137161

packages/core/src/stores/nlpSearchStore.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,23 @@ function applyTimeConstraint(intent: SearchIntent): void {
4444
function applyFacets(intent: SearchIntent): void {
4545
const facets = useCategoryFacetStore.getState();
4646
const attrs = intent.attributes;
47+
// Only apply facets that actually belong to the category being searched.
48+
// Models (especially small local ones) can emit attributes the user never
49+
// asked for; without this scope a hallucinated food/dietary attribute would
50+
// get applied to e.g. a "schools" search and filter every result away.
51+
const activeCategory = intent.categories[0];
52+
if (!activeCategory) return;
4753
for (const facet of CATEGORY_FACETS) {
54+
if (!facet.categoryIds.has(activeCategory)) continue;
4855
const value = attrs[facet.tag];
4956
if (value === undefined) continue;
5057
if (facet.type === "toggle") {
5158
if ((facet.matchValues ?? []).includes(value)) {
5259
facets.setMultiFacet(facet.id, ["on"]);
5360
}
54-
} else {
61+
} else if (value !== "no") {
62+
// Multi facets (e.g. cuisine) take the value verbatim — skip the model's
63+
// default "no", which would filter to a nonexistent value (zero results).
5564
facets.setMultiFacet(facet.id, [value]);
5665
}
5766
}

0 commit comments

Comments
 (0)