Youmnaaaa commited on
Commit
9d217dd
ยท
verified ยท
1 Parent(s): 53562d8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +139 -30
app.py CHANGED
@@ -116,7 +116,7 @@ KEYWORD_OVERRIDE = {
116
  "goodbye": ["ู…ุน ุงู„ุณู„ุงู…ุฉ","ู…ุน ุงู„ุณู„ุงู…ู‡","ุจุงูŠ","ูˆุฏุงุนุง","bye","goodbye","ุชุตุจุญ ุนู„ู‰ ุฎูŠุฑ",
117
  "ููŠ ุงู…ุงู† ุงู„ู„ู‡","ุงู„ู„ู‡ ูŠุณู„ู…ูƒ","ุณู„ุงู…ุชูƒ"],
118
  "greeting":["ุงู„ุณู„ุงู… ุนู„ูŠูƒู…","ูˆุนู„ูŠูƒู… ุงู„ุณู„ุงู…","ุงู‡ู„ุง","ุฃู‡ู„ุง","ู‡ู„ุง","ู‡ู„ูˆ","ู…ุฑุญุจุง","ู…ุฑุญุจุงู‹",
119
- "ุตุจุงุญ ุงู„ุฎูŠุฑ","ู…ุณุงุก ุงู„ุฎูŠุฑ","ู‡ุงูŠ","hi","hello","ุงุฒูŠูƒ","ุงุฎุจุงุฑูƒ","ุนุงู…ู„ ุงูŠู‡","ุตุจุงุญ","ู…ุณุงุก"],
120
  "thanks": ["ุดูƒุฑุง","ุดูƒุฑุงู‹","ุชุณู„ู…","ูŠุณู„ู…ูˆ","ู…ู…ู†ูˆู†","ู…ุดูƒูˆุฑ","thanks","thank","ุงู„ู ุดูƒุฑ"],
121
  }
122
  CATEGORY_KEYWORDS = {
@@ -1942,22 +1942,43 @@ _ITEM_TRIGGERS = {
1942
  "ูุฑุงุฎ","ูุฑุฎู‡","ูุฑุฎุฉ","ูุฑุฎ","ุฏุฌุงุฌ","ู„ุญู…ู‡","ู„ุญู…ุฉ","ู„ุญู…","ูƒุจุงุจ","ูƒูุชู‡",
1943
  "ุณู…ูƒ","ุฌู…ุจุฑูŠ","ุจู„ุทูŠ","ู…ุดูˆูŠุงุช","ู…ุดูˆูŠ","ู…ุดูˆูŠู‡",
1944
  # ู…ุดุฑูˆุจุงุช ูˆุญู„ูˆูŠุงุช
1945
- "ุนุตูŠุฑ","ู‚ู‡ูˆู‡","ู‚ู‡ูˆุฉ","ูƒูˆููŠ","ุชุดูŠุฒ ูƒูŠูƒ","ูˆุงูƒูˆ","ูƒูŠูƒ","ุชูˆุฑุชู‡",
1946
  # ุทู„ุจ ุจุงู„ุงุณู…
1947
  "ุนุงูŠุฒ ุงูƒู„ุฉ","ุนุงูŠุฒุฉ ุงูƒู„ุฉ","ููŠู† ุงูƒู„",
1948
  }
1949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1950
  def _detect_new_intent(text: str) -> str | None:
1951
  """
1952
  Detects search_by_item or show_menu from text.
1953
  Returns intent string or None.
1954
  """
1955
  t = norm(text)
 
 
 
1956
  if any(kw in t for kw in _MENU_TRIGGERS):
1957
  return "show_menu"
1958
- # item search: any food keyword present
1959
- if any(kw in t for kw in _ITEM_TRIGGERS):
1960
- return "search_by_item"
1961
  return None
1962
 
1963
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
@@ -1976,6 +1997,7 @@ _FOOD_STOP_WORDS = {
1976
  "ุนุงูŠุฒ ุงูƒู„","ุนุงูŠุฒุฉ ุงูƒู„","ุนุงูŠุฒ ุงูƒู„ุฉ","ุงุจูŠ ุงูƒู„",
1977
  # categories
1978
  "ู…ู†ูŠูˆ","menu","ู‚ุงุฆู…ู‡","ู‚ุงูŠู…ุฉ","ู…ุทุนู…","ู…ุทุงุนู…","ูƒุงููŠู‡","restaurant","cafe",
 
1979
  # ุญุฑูˆู
1980
  "ููŠ","ู…ู†","ุนู„ู‰","ุนู†","ู‡ู†ุง","ู‚ุฑูŠุจ","ุงู‚ุฑุจ","ุญู„ูˆ","ูƒูˆูŠุณ","ุฑุฎูŠุต",
1981
  }
@@ -1984,8 +2006,40 @@ _FOOD_STOP_WORDS = {
1984
  _ITEM_PHRASE_STRIP = [
1985
  "ุนุงูŠุฒ ุงูƒู„ุฉ","ุนุงูŠุฒุฉ ุงูƒู„ุฉ","ุนุงูŠุฒ ุงูƒู„","ุนุงูŠุฒุฉ ุงูƒู„",
1986
  "ููŠู† ุงูƒู„","ุงุจุญุซ ุนู†","ุจุฏูˆุฑ ุนู„ู‰","ุจุฏูˆุฑ ุนู†",
 
 
 
 
1987
  ]
1988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1989
  _MENU_PHRASE_STRIP = [
1990
  "ุนุงูŠุฒ ุงุดูˆู ู…ู†ูŠูˆ","ุนุงูŠุฒุฉ ุงุดูˆู ู…ู†ูŠูˆ","ุนุงูŠุฒ ุงุดูˆู ุงู„ู…ู†ูŠูˆ","ุนุงูŠุฒุฉ ุงุดูˆู ุงู„ู…ู†ูŠูˆ",
1991
  "ู‡ุงุช ู…ู†ูŠูˆ","ู‡ุงุชู„ูŠ ู…ู†ูŠูˆ","ู‡ุงุชู„ู†ุง ู…ู†ูŠูˆ",
@@ -2141,12 +2195,40 @@ def search_places_by_item(item_query: str) -> list[dict]:
2141
  return places
2142
 
2143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2144
  def filter_item_results_strict(places: list, item_query: str) -> list:
2145
  """
2146
- ูู„ุชุฑ ู‚ูˆูŠ ู„ู†ุชุงุฆุฌ search_by_item:
2147
- - ู„ูˆ ุงู„ุณุคุงู„ ุนู† ุฃูƒู„: restaurants ูู‚ุท.
2148
- - ู„ูˆ ุงู„ุณุคุงู„ ุนู† ู…ุดุฑูˆุจ: cafe ุฃูˆ restaurant ูู‚ุท.
2149
- - ู„ุงุฒู… ุงู„ุตู†ู ู†ูุณู‡ ูŠูƒูˆู† ุธุงู‡ุฑ ููŠ matched_items / sub_category / description / name.
2150
  """
2151
  if not places:
2152
  return []
@@ -2154,34 +2236,36 @@ def filter_item_results_strict(places: list, item_query: str) -> list:
2154
  q = clean_text(item_query)
2155
 
2156
  food_keywords = {
2157
- "ุดุงูˆุฑู…ุง", "ุจุฑุฌุฑ", "ุจูŠุชุฒุง", "ูƒุฑูŠุจ", "ู…ุดูˆูŠุงุช", "ูุฑุงุฎ",
2158
- "ุฏุฌุงุฌ", "ูƒุจุงุจ", "ูƒูุชู‡", "ูƒูุชุฉ", "ุณู…ูƒ", "ุณุงู†ุฏูˆุชุด",
2159
- "ุณู†ุฏูˆุชุด", "ุญูˆุงูˆุดูŠ", "ูƒุดุฑูŠ", "ู…ูƒุฑูˆู†ู‡", "ู…ูƒุฑูˆู†ุฉ",
2160
- "ูˆุฌุจู‡", "ูˆุฌุจุฉ", "ุงูƒู„", "ุฃูƒู„", "burger", "pizza",
2161
- "shawarma", "crepe", "chicken", "grill", "grills"
2162
  }
2163
 
2164
  drink_keywords = {
2165
- "ู‚ู‡ูˆุฉ", "ู‚ู‡ูˆู‡", "ูƒูˆููŠ", "ุนุตูŠุฑ", "ู…ุดุฑูˆุจ", "ู…ุดุฑูˆุจุงุช",
2166
- "ู„ุงุชูŠู‡", "ุงุณุจุฑูŠุณูˆ", "ู†ุณูƒุงููŠู‡", "ุณู…ูˆุฐูŠ",
2167
  "coffee", "juice", "latte", "espresso", "smoothie"
2168
  }
2169
 
2170
  is_food = any(clean_text(w) in q for w in food_keywords)
2171
  is_drink = any(clean_text(w) in q for w in drink_keywords)
2172
 
2173
- query_words = [w for w in q.split() if len(w) > 2]
2174
  filtered = []
2175
-
2176
  for p in places:
 
 
 
2177
  raw_category = str(p.get("category", ""))
2178
  category = normalize_category(raw_category)
2179
 
2180
  text_blob = clean_text(
 
2181
  f"{p.get('name','')} "
2182
  f"{p.get('name_ar','')} "
2183
  f"{p.get('name_en','')} "
2184
  f"{p.get('category','')} "
 
2185
  f"{p.get('sub_category','')} "
2186
  f"{p.get('description','')} "
2187
  f"{p.get('matched_item','')} "
@@ -2190,26 +2274,34 @@ def filter_item_results_strict(places: list, item_query: str) -> list:
2190
  f"{p.get('tags','')}"
2191
  )
2192
 
2193
- if is_food and category != "restaurant" and not _category_match_value(raw_category, "restaurant"):
2194
  continue
2195
 
2196
- if is_drink and not (
 
 
 
 
 
 
 
 
 
2197
  category in ("cafe", "restaurant") or
2198
  _category_match_value(raw_category, "cafe") or
2199
  _category_match_value(raw_category, "restaurant")
2200
  ):
2201
  continue
2202
 
2203
- if query_words and not any(w in text_blob for w in query_words):
2204
- continue
2205
-
2206
  filtered.append(p)
2207
 
2208
- # dedup
2209
  seen, unique = set(), []
2210
  for p in filtered:
2211
- pid = str(p.get("place_id") or p.get("id") or p.get("name") or "")
2212
- if pid and pid not in seen:
 
 
2213
  seen.add(pid)
2214
  unique.append(p)
2215
  return unique
@@ -2307,7 +2399,8 @@ def _sort_places_by_distance(places: list[dict], user_lat=None, user_lon=None) -
2307
  def format_item_search_response(places: list[dict], item_query: str, user_lat=None, user_lon=None) -> dict:
2308
  """
2309
  Builds the structured response for search_by_item intent.
2310
- Flutter reads: ui_type='places_cards', place_cards=[...]
 
2311
  """
2312
  if not places:
2313
  return {
@@ -2315,23 +2408,39 @@ def format_item_search_response(places: list[dict], item_query: str, user_lat=No
2315
  "intent": "search_by_item",
2316
  "ui_type": CFG.UI_TEXT_ONLY,
2317
  "place_cards": [],
 
2318
  "best_place": None,
2319
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2320
  places = _sort_places_by_distance(places, user_lat, user_lon)
2321
  cards = [clean_place(p) for p in places[:CFG.MAX_CARDS]]
2322
  # dedup
2323
  seen, unique = set(), []
2324
  for c in cards:
2325
- pid = c.get("place_id") or c.get("id")
2326
  if pid not in seen:
2327
  seen.add(pid)
2328
  unique.append(c)
2329
- reply = f"๐Ÿฝ๏ธ ู„ุงู‚ูŠุชู„ูƒ {len(unique)} ู…ุทุนู… ููŠู‡ *{item_query}* โ€” ุงุฎุชุงุฑ:"
2330
  return {
2331
  "reply": reply,
2332
  "intent": "search_by_item",
2333
  "ui_type": CFG.UI_PLACES_CARDS,
2334
  "place_cards": unique,
 
2335
  "best_place": unique[0] if unique else None,
2336
  }
2337
 
@@ -2647,7 +2756,7 @@ def chat_endpoint(req: ChatRequest):
2647
  ui_type=res.get("ui_type", CFG.UI_TEXT_ONLY),
2648
  best_place=res.get("best_place"),
2649
  place_cards=res.get("place_cards", []),
2650
- menu_items=[],
2651
  )
2652
 
2653
  if new_intent == "show_menu":
 
116
  "goodbye": ["ู…ุน ุงู„ุณู„ุงู…ุฉ","ู…ุน ุงู„ุณู„ุงู…ู‡","ุจุงูŠ","ูˆุฏุงุนุง","bye","goodbye","ุชุตุจุญ ุนู„ู‰ ุฎูŠุฑ",
117
  "ููŠ ุงู…ุงู† ุงู„ู„ู‡","ุงู„ู„ู‡ ูŠุณู„ู…ูƒ","ุณู„ุงู…ุชูƒ"],
118
  "greeting":["ุงู„ุณู„ุงู… ุนู„ูŠูƒู…","ูˆุนู„ูŠูƒู… ุงู„ุณู„ุงู…","ุงู‡ู„ุง","ุฃู‡ู„ุง","ู‡ู„ุง","ู‡ู„ูˆ","ู…ุฑุญุจุง","ู…ุฑุญุจุงู‹",
119
+ "ุตุจุงุญ ุงู„ุฎูŠุฑ","ู…ุณุงุก ุงู„ุฎูŠุฑ","ู‡ุงูŠ","hi","hello","ุตุจุงุญ","ู…ุณุงุก"],
120
  "thanks": ["ุดูƒุฑุง","ุดูƒุฑุงู‹","ุชุณู„ู…","ูŠุณู„ู…ูˆ","ู…ู…ู†ูˆู†","ู…ุดูƒูˆุฑ","thanks","thank","ุงู„ู ุดูƒุฑ"],
121
  }
122
  CATEGORY_KEYWORDS = {
 
1942
  "ูุฑุงุฎ","ูุฑุฎู‡","ูุฑุฎุฉ","ูุฑุฎ","ุฏุฌุงุฌ","ู„ุญู…ู‡","ู„ุญู…ุฉ","ู„ุญู…","ูƒุจุงุจ","ูƒูุชู‡",
1943
  "ุณู…ูƒ","ุฌู…ุจุฑูŠ","ุจู„ุทูŠ","ู…ุดูˆูŠุงุช","ู…ุดูˆูŠ","ู…ุดูˆูŠู‡",
1944
  # ู…ุดุฑูˆุจุงุช ูˆุญู„ูˆูŠุงุช
1945
+ "ุนุตูŠุฑ","ู‚ู‡ูˆู‡","ู‚ู‡ูˆุฉ","ูƒูˆููŠ","ุงุณู…ูˆุฒูŠ","ุฅุณู…ูˆุฒูŠ","ุณู…ูˆุฒูŠ","ุณู…ูˆุฐูŠ","smoothie","ุชุดูŠุฒ ูƒูŠูƒ","ูˆุงูƒูˆ","ูƒูŠูƒ","ุชูˆุฑุชู‡",
1946
  # ุทู„ุจ ุจุงู„ุงุณู…
1947
  "ุนุงูŠุฒ ุงูƒู„ุฉ","ุนุงูŠุฒุฉ ุงูƒู„ุฉ","ููŠู† ุงูƒู„",
1948
  }
1949
 
1950
+ _ITEM_SEARCH_PATTERNS = [
1951
+ "ู…ูƒุงู† ุนู†ุฏู‡", "ู…ูƒุงู† ุนู†ุฏู‡ุง", "ุงู…ุงูƒู† ุนู†ุฏู‡ุง", "ุฃู…ุงูƒู† ุนู†ุฏู‡ุง",
1952
+ "ู…ุทุนู… ุนู†ุฏู‡", "ูƒุงููŠู‡ ุนู†ุฏู‡", "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง",
1953
+ "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…", "ููŠู‡", "ููŠู‡ุง",
1954
+ "ุนุงูŠุฒ", "ุนุงูŠุฒุฉ", "ุนุงูˆุฒู‡", "ุนุงูˆุฒ", "ู†ูุณูŠ ููŠ", "ุจุฏูˆุฑ ุนู„ู‰", "ููŠู†",
1955
+ "place has", "places have", "has", "serves", "serve", "where can i find"
1956
+ ]
1957
+
1958
+ def _looks_like_item_search(text: str) -> bool:
1959
+ """Detect item/menu-item search even if the item word is not in our keywords."""
1960
+ t = norm(text)
1961
+ # menu display is different from item search
1962
+ if any(kw in t for kw in _MENU_TRIGGERS) and not any(p in t for p in ["ุนู†ุฏู‡", "ุนู†ุฏู‡ุง", "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…", "ููŠู‡", "ููŠู‡ุง"]):
1963
+ return False
1964
+ has_item_pattern = any(norm(p) in t for p in _ITEM_SEARCH_PATTERNS)
1965
+ category_context = infer_category(text) in ("restaurant", "cafe")
1966
+ has_item_keyword = any(norm(kw) in t for kw in _ITEM_TRIGGERS)
1967
+ candidate = extract_item_query(text)
1968
+ # ู„ูˆ ููŠู‡ ุตูŠุบุฉ ุทู„ุจ + ุจุงู‚ูŠ ู†ุต ุจุนุฏ ุงู„ุดูŠู„ ูŠุจู‚ู‰ ุบุงู„ุจู‹ุง ุตู†ูุŒ ุญุชู‰ ู„ูˆ ู…ุด ููŠ keywords
1969
+ return bool(has_item_keyword or (has_item_pattern and candidate) or (category_context and has_item_pattern and candidate))
1970
+
1971
  def _detect_new_intent(text: str) -> str | None:
1972
  """
1973
  Detects search_by_item or show_menu from text.
1974
  Returns intent string or None.
1975
  """
1976
  t = norm(text)
1977
+ # item-search patterns take priority over menu, e.g. "ู…ูƒุงู† ุนู†ุฏู‡ ุงุณู…ูˆุฒูŠ ุฎูˆุฎ"
1978
+ if _looks_like_item_search(text):
1979
+ return "search_by_item"
1980
  if any(kw in t for kw in _MENU_TRIGGERS):
1981
  return "show_menu"
 
 
 
1982
  return None
1983
 
1984
  # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
 
1997
  "ุนุงูŠุฒ ุงูƒู„","ุนุงูŠุฒุฉ ุงูƒู„","ุนุงูŠุฒ ุงูƒู„ุฉ","ุงุจูŠ ุงูƒู„",
1998
  # categories
1999
  "ู…ู†ูŠูˆ","menu","ู‚ุงุฆู…ู‡","ู‚ุงูŠู…ุฉ","ู…ุทุนู…","ู…ุทุงุนู…","ูƒุงููŠู‡","restaurant","cafe",
2000
+ "ู…ูƒุงู†","ุงู…ุงูƒู†","ุฃู…ุงูƒู†","ู…ุญู„","ุนู†ุฏู‡","ุนู†ุฏู‡ุง","ุจูŠุนู…ู„","ุจุชุนู…ู„","ุจูŠู‚ุฏู…","ุจุชู‚ุฏู…","ููŠู‡","ููŠู‡ุง",
2001
  # ุญุฑูˆู
2002
  "ููŠ","ู…ู†","ุนู„ู‰","ุนู†","ู‡ู†ุง","ู‚ุฑูŠุจ","ุงู‚ุฑุจ","ุญู„ูˆ","ูƒูˆูŠุณ","ุฑุฎูŠุต",
2003
  }
 
2006
  _ITEM_PHRASE_STRIP = [
2007
  "ุนุงูŠุฒ ุงูƒู„ุฉ","ุนุงูŠุฒุฉ ุงูƒู„ุฉ","ุนุงูŠุฒ ุงูƒู„","ุนุงูŠุฒุฉ ุงูƒู„",
2008
  "ููŠู† ุงูƒู„","ุงุจุญุซ ุนู†","ุจุฏูˆุฑ ุนู„ู‰","ุจุฏูˆุฑ ุนู†",
2009
+ "ุนุงูŠุฒู‡ ู…ูƒุงู† ุนู†ุฏู‡","ุนุงูŠุฒุฉ ู…ูƒุงู† ุนู†ุฏู‡","ุนุงูŠุฒ ู…ูƒุงู† ุนู†ุฏู‡","ุนุงูˆุฒ ู…ูƒุงู† ุนู†ุฏู‡",
2010
+ "ุนุงูŠุฒู‡ ู…ูƒุงู† ุนู†ุฏู‡ุง","ุนุงูŠุฒุฉ ู…ูƒุงู† ุนู†ุฏู‡ุง","ุนุงูŠุฒ ู…ูƒุงู† ุนู†ุฏู‡ุง",
2011
+ "ู…ูƒุงู† ุนู†ุฏู‡","ู…ูƒุงู† ุนู†ุฏู‡ุง","ู…ุทุนู… ุนู†ุฏู‡","ูƒุงููŠู‡ ุนู†ุฏู‡",
2012
+ "ุนุงูŠุฒู‡","ุนุงูŠุฒุฉ","ุนุงูŠุฒ","ุนุงูˆุฒ","ู†ูุณูŠ ููŠ",
2013
  ]
2014
 
2015
+ _ITEM_SYNONYMS = {
2016
+ "ุงุณู…ูˆุฒูŠ": ["ุงุณู…ูˆุฒูŠ", "ุฅุณู…ูˆุฒูŠ", "ุณู…ูˆุฒูŠ", "ุณู…ูˆุฐูŠ", "smoothie"],
2017
+ "ุณู…ูˆุฒูŠ": ["ุงุณู…ูˆุฒูŠ", "ุฅุณู…ูˆุฒูŠ", "ุณู…ูˆุฒูŠ", "ุณู…ูˆุฐูŠ", "smoothie"],
2018
+ "ุณู…ูˆุฐูŠ": ["ุงุณู…ูˆุฒูŠ", "ุฅุณู…ูˆุฒูŠ", "ุณู…ูˆุฒูŠ", "ุณู…ูˆุฐูŠ", "smoothie"],
2019
+ "ูุฑุงุฎ": ["ูุฑุงุฎ", "ูุฑุฎุฉ", "ูุฑุฎู‡", "ุฏุฌุงุฌ", "chicken"],
2020
+ "ูุฑุฎู‡": ["ูุฑุงุฎ", "ูุฑุฎุฉ", "ูุฑุฎู‡", "ุฏุฌุงุฌ", "chicken"],
2021
+ "ุฏุฌุงุฌ": ["ูุฑุงุฎ", "ูุฑุฎุฉ", "ูุฑุฎู‡", "ุฏุฌุงุฌ", "chicken"],
2022
+ "ุณุงู†ุฏูˆุชุด": ["ุณุงู†ุฏูˆุชุด", "ุณู†ุฏูˆุชุด", "sandwich"],
2023
+ "ุณู†ุฏูˆุชุด": ["ุณุงู†ุฏูˆุชุด", "ุณู†ุฏูˆุชุด", "sandwich"],
2024
+ }
2025
+
2026
+ def _stem_ar_word(w: str) -> str:
2027
+ w = clean_text(w)
2028
+ for sfx in ("ุงุช", "ูŠู†", "ูˆู†", "ูŠุฉ", "ูŠู‡", "ู‡", "ุฉ", "ูŠ", "ุง"):
2029
+ if w.endswith(sfx) and len(w) - len(sfx) >= 3:
2030
+ return w[:-len(sfx)]
2031
+ return w
2032
+
2033
+ def _expand_item_query_words(item_query: str) -> list[str]:
2034
+ words = [clean_text(w) for w in str(item_query).split() if len(clean_text(w)) > 1]
2035
+ expanded = set(words)
2036
+ for w in words:
2037
+ expanded.add(_stem_ar_word(w))
2038
+ for alt in _ITEM_SYNONYMS.get(w, []):
2039
+ expanded.add(clean_text(alt))
2040
+ expanded.add(_stem_ar_word(alt))
2041
+ return [w for w in expanded if w]
2042
+
2043
  _MENU_PHRASE_STRIP = [
2044
  "ุนุงูŠุฒ ุงุดูˆู ู…ู†ูŠูˆ","ุนุงูŠุฒุฉ ุงุดูˆู ู…ู†ูŠูˆ","ุนุงูŠุฒ ุงุดูˆู ุงู„ู…ู†ูŠูˆ","ุนุงูŠุฒุฉ ุงุดูˆู ุงู„ู…ู†ูŠูˆ",
2045
  "ู‡ุงุช ู…ู†ูŠูˆ","ู‡ุงุชู„ูŠ ู…ู†ูŠูˆ","ู‡ุงุชู„ู†ุง ู…ู†ูŠูˆ",
 
2195
  return places
2196
 
2197
 
2198
+ def _is_menu_item_object(p: dict) -> bool:
2199
+ """True when backend returned menu-item rows, not place rows."""
2200
+ if not isinstance(p, dict):
2201
+ return False
2202
+ has_item_keys = any(k in p for k in ("item_name", "subcategory_name", "sub_category", "price"))
2203
+ has_place_keys = any(k in p for k in ("place_id", "lat", "lon", "location", "address", "rating", "opening_hours"))
2204
+ return bool(has_item_keys and not has_place_keys)
2205
+
2206
+ def _text_matches_item_query(text_blob: str, item_query: str) -> bool:
2207
+ """Flexible token/stem/synonym matching for menu items."""
2208
+ blob = clean_text(text_blob)
2209
+ q_words = _expand_item_query_words(item_query)
2210
+ # Ignore weak generic terms when deciding if the item itself matched.
2211
+ weak = {"ุงูƒู„", "ุงูƒู„ู‡", "ุงูƒู„ุฉ", "ูˆุฌุจู‡", "ูˆุฌุจุฉ", "ู…ุดุฑูˆุจ", "ู…ุดุฑูˆุจุงุช", "ู…ูƒุงู†"}
2212
+ q_words = [w for w in q_words if w not in weak and len(w) > 1]
2213
+ if not q_words:
2214
+ return True
2215
+ blob_tokens = blob.split()
2216
+ blob_stems = {_stem_ar_word(t) for t in blob_tokens}
2217
+ hits = 0
2218
+ for w in q_words:
2219
+ sw = _stem_ar_word(w)
2220
+ if w in blob or sw in blob_stems or any(sw and (sw in bt or bt in sw) for bt in blob_stems):
2221
+ hits += 1
2222
+ # For multi-word items like "ุงุณู…ูˆุฒูŠ ุฎูˆุฎ", one strong word is enough for broad recall,
2223
+ # but two-word match is preferred and kept by backend ordering.
2224
+ return hits >= 1
2225
+
2226
  def filter_item_results_strict(places: list, item_query: str) -> list:
2227
  """
2228
+ Flexible but safe filter for search_by_item:
2229
+ - understands unseen item names by matching returned menu text, not only fixed keywords.
2230
+ - if backend returns menu-item rows with no parent place, keep matching items instead of false no_result.
2231
+ - if backend/local returns place rows, apply category guard only when category exists.
2232
  """
2233
  if not places:
2234
  return []
 
2236
  q = clean_text(item_query)
2237
 
2238
  food_keywords = {
2239
+ "ุดุงูˆุฑู…ุง", "ุจุฑุฌุฑ", "ุจูŠุชุฒุง", "ูƒุฑูŠุจ", "ู…ุดูˆูŠุงุช", "ูุฑุงุฎ", "ุฏุฌุงุฌ",
2240
+ "ูƒุจุงุจ", "ูƒูุชู‡", "ูƒูุชุฉ", "ุณู…ูƒ", "ุณุงู†ุฏูˆุชุด", "ุณู†ุฏูˆุชุด", "ุญูˆุงูˆุดูŠ",
2241
+ "ูƒุดุฑูŠ", "ู…ูƒุฑูˆู†ู‡", "ู…ูƒุฑูˆู†ุฉ", "ูˆุฌุจู‡", "ูˆุฌุจุฉ", "ุงูƒู„", "ุฃูƒู„",
2242
+ "burger", "pizza", "shawarma", "crepe", "chicken", "grill", "grills"
 
2243
  }
2244
 
2245
  drink_keywords = {
2246
+ "ู‚ู‡ูˆุฉ", "ู‚ู‡ูˆู‡", "ูƒูˆููŠ", "ุนุตูŠุฑ", "ู…ุดุฑูˆุจ", "ู…ุดุฑูˆุจุงุช", "ู„ุงุชูŠู‡",
2247
+ "ุงุณุจุฑูŠุณูˆ", "ู†ุณูƒุงููŠู‡", "ุณู…ูˆุฐูŠ", "ุณู…ูˆุฒูŠ", "ุงุณู…ูˆุฒูŠ", "ุฅุณู…ูˆุฒูŠ",
2248
  "coffee", "juice", "latte", "espresso", "smoothie"
2249
  }
2250
 
2251
  is_food = any(clean_text(w) in q for w in food_keywords)
2252
  is_drink = any(clean_text(w) in q for w in drink_keywords)
2253
 
 
2254
  filtered = []
 
2255
  for p in places:
2256
+ if not isinstance(p, dict):
2257
+ continue
2258
+
2259
  raw_category = str(p.get("category", ""))
2260
  category = normalize_category(raw_category)
2261
 
2262
  text_blob = clean_text(
2263
+ f"{p.get('item_name','')} "
2264
  f"{p.get('name','')} "
2265
  f"{p.get('name_ar','')} "
2266
  f"{p.get('name_en','')} "
2267
  f"{p.get('category','')} "
2268
+ f"{p.get('subcategory_name','')} "
2269
  f"{p.get('sub_category','')} "
2270
  f"{p.get('description','')} "
2271
  f"{p.get('matched_item','')} "
 
2274
  f"{p.get('tags','')}"
2275
  )
2276
 
2277
+ if not _text_matches_item_query(text_blob, item_query):
2278
  continue
2279
 
2280
+ # If this is a pure menu item row, don't reject it because it has no place category.
2281
+ if _is_menu_item_object(p):
2282
+ filtered.append(p)
2283
+ continue
2284
+
2285
+ # Place rows: keep category safe when the item type is clear.
2286
+ if is_food and raw_category and category != "restaurant" and not _category_match_value(raw_category, "restaurant"):
2287
+ continue
2288
+
2289
+ if is_drink and raw_category and not (
2290
  category in ("cafe", "restaurant") or
2291
  _category_match_value(raw_category, "cafe") or
2292
  _category_match_value(raw_category, "restaurant")
2293
  ):
2294
  continue
2295
 
 
 
 
2296
  filtered.append(p)
2297
 
2298
+ # dedup; for menu items use id+name+price, for places use place_id.
2299
  seen, unique = set(), []
2300
  for p in filtered:
2301
+ pid = str(p.get("place_id") or p.get("placeId") or p.get("place_id_id") or "")
2302
+ if not pid:
2303
+ pid = f"item:{p.get('id','')}:{p.get('item_name') or p.get('name','')}:{p.get('price','')}"
2304
+ if pid not in seen:
2305
  seen.add(pid)
2306
  unique.append(p)
2307
  return unique
 
2399
  def format_item_search_response(places: list[dict], item_query: str, user_lat=None, user_lon=None) -> dict:
2400
  """
2401
  Builds the structured response for search_by_item intent.
2402
+ Preferred output is places_cards when parent places are available.
2403
+ If backend returns menu-item rows only, return menu_list so the matched item is not lost.
2404
  """
2405
  if not places:
2406
  return {
 
2408
  "intent": "search_by_item",
2409
  "ui_type": CFG.UI_TEXT_ONLY,
2410
  "place_cards": [],
2411
+ "menu_items": [],
2412
  "best_place": None,
2413
  }
2414
+
2415
+ # Backend sometimes returns menu-item rows without parent place data.
2416
+ # In this case, showing the matched menu items is better than saying no_result.
2417
+ if all(_is_menu_item_object(p) for p in places):
2418
+ items = [_fmt_menu_item(i) for i in places[:20]]
2419
+ return {
2420
+ "reply": f"ู„ู‚ูŠุช ุฃุตู†ุงู ู…ุทุงุจู‚ุฉ ู„ู€ *{item_query}* ๐Ÿ‘‡",
2421
+ "intent": "search_by_item",
2422
+ "ui_type": CFG.UI_MENU_LIST,
2423
+ "place_cards": [],
2424
+ "menu_items": items,
2425
+ "best_place": None,
2426
+ }
2427
+
2428
  places = _sort_places_by_distance(places, user_lat, user_lon)
2429
  cards = [clean_place(p) for p in places[:CFG.MAX_CARDS]]
2430
  # dedup
2431
  seen, unique = set(), []
2432
  for c in cards:
2433
+ pid = c.get("place_id") or c.get("id") or c.get("name")
2434
  if pid not in seen:
2435
  seen.add(pid)
2436
  unique.append(c)
2437
+ reply = f"๐Ÿฝ๏ธ ู„ุงู‚ูŠุชู„ูƒ {len(unique)} ู…ูƒุงู† ููŠู‡ *{item_query}* โ€” ุงุฎุชุงุฑ:"
2438
  return {
2439
  "reply": reply,
2440
  "intent": "search_by_item",
2441
  "ui_type": CFG.UI_PLACES_CARDS,
2442
  "place_cards": unique,
2443
+ "menu_items": [],
2444
  "best_place": unique[0] if unique else None,
2445
  }
2446
 
 
2756
  ui_type=res.get("ui_type", CFG.UI_TEXT_ONLY),
2757
  best_place=res.get("best_place"),
2758
  place_cards=res.get("place_cards", []),
2759
+ menu_items=res.get("menu_items", []),
2760
  )
2761
 
2762
  if new_intent == "show_menu":