Youmnaaaa commited on
Commit
c1685ed
ยท
verified ยท
1 Parent(s): d2408c6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +86 -42
app.py CHANGED
@@ -539,8 +539,6 @@ def clean_item_query(text: str) -> str:
539
  "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง",
540
  "ููŠู‡", "ููŠู‡ุง",
541
  "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…"
542
- "ู†ูุณูŠ", "ุนู†ุฏูˆ",
543
-
544
  ]
545
 
546
  for w in remove_words:
@@ -648,11 +646,11 @@ def name_search(query, top_k=10, category=None):
648
  matched = df[df["name_score"] > 0].copy()
649
 
650
  if category and not matched.empty:
651
- cat_c = clean_text(category)
652
- cat_match = matched[matched["category_clean"].astype(str).str.contains(
653
- re.escape(cat_c), na=False)]
654
- if not cat_match.empty:
655
- matched = cat_match
656
 
657
  return matched.sort_values("name_score", ascending=False).head(top_k)
658
 
@@ -1059,11 +1057,11 @@ def build_response(place, intent, category=None, total_results=1):
1059
 
1060
  def handle_detail(text, place):
1061
  if not place:
1062
- return "ู…ุด ูุงูƒุฑ ุฅุญู†ุง ุงุชูƒู„ู…ู†ุง ุนู† ู…ูƒุงู†. ู…ู…ูƒู† ุชุณุฃู„ู†ูŠ ู…ู† ุงู„ุฃูˆู„ุŸ"
1063
  t = norm(text)
1064
  name = place.get("name", "ุงู„ู…ูƒุงู†")
1065
 
1066
- if any(w in t for w in ["ุจูŠูุชุญ","ุจุชูุชุญ","ู…ูˆุงุนูŠุฏ","ุงู…ุชู‰","ุงู…ุชูŠ","ุจูŠู‚ูู„","ุจุชู‚ูู„"]):
1067
  h = place.get("opening_hours","")
1068
  if not h or str(h).strip() in ("","nan","none"):
1069
  return f"ู…ุนู†ุฏู†ุงุด ู…ุนู„ูˆู…ุงุช ุนู† ู…ูˆุงุนูŠุฏ *{name}*."
@@ -1071,6 +1069,15 @@ def handle_detail(text, place):
1071
  status = "๐ŸŸข ู…ูุชูˆุญ ุฏู„ูˆู‚ุชูŠ." if on == 1 else ("๐Ÿ”ด ู…ุบู„ู‚ ุฏู„ูˆู‚ุชูŠ." if on == 0 else "")
1072
  return f"{fmt_hours(h)}{status}"
1073
 
 
 
 
 
 
 
 
 
 
1074
  if any(w in t for w in ["ุนู†ูˆุงู†","ููŠู†","ููŠู†ู‡","ููŠู†ู‡ุง","ู…ูƒุงู†ู‡","ู…ูƒุงู†ู‡ุง","ุงู„ุนู†ูˆุงู†",
1075
  "ู„ูˆูƒูŠุดู†","ุงู„ู…ูˆู‚ุน","ุงุฒุงูŠ ุงุฑูˆุญู‡","ุงุฒุงูŠ ุงุฑูˆุญ","map","ุงู„ุฎุฑูŠุทุฉ"]):
1076
  addr = place.get("address",""); loc = place.get("location","")
@@ -1084,10 +1091,6 @@ def handle_detail(text, place):
1084
  parts.append(f"๐Ÿ—บ๏ธ https://maps.google.com/?q={lat},{lon}")
1085
  return "\n".join(parts) if parts else f"ุนู†ูˆุงู† {name}: ุบูŠุฑ ู…ุชุงุญ."
1086
 
1087
- if any(w in t for w in ["ุชู„ูŠููˆู†","ุฑู‚ู…","phone","ุงุชุตู„"]):
1088
- ph = place.get("phone","")
1089
- return f"๐Ÿ“ž {name}: {ph}" if ph and ph not in ("","nan") else f"ู…ุด ุนู†ุฏู†ุง ุฑู‚ู… *{name}*."
1090
-
1091
  if any(w in t for w in ["ุชู‚ูŠูŠู…","ูƒุงู… ู†ุฌู…ุฉ","rating","ู†ุฌูˆู…"]):
1092
  r = fmt_rating(place.get("rating_norm", place.get("rating", 0)))
1093
  return r or "ู…ุด ุนู†ุฏู†ุง ุชู‚ูŠูŠู… ู„ู‡ุฐุง ุงู„ู…ูƒุงู†."
@@ -1623,6 +1626,13 @@ def chat(text: str, session, user_lat=None, user_lon=None):
1623
  session.add(text, reply, result["intent"], {}, session.last_place, [])
1624
  return result
1625
 
 
 
 
 
 
 
 
1626
  # โ”€โ”€ next result โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1627
  if ref == "next" and session.last_results:
1628
  ptr = session.result_pointer + 1
@@ -1659,6 +1669,21 @@ def chat(text: str, session, user_lat=None, user_lon=None):
1659
  ir = predict_intent(text)
1660
  intent = ir["intent"]
1661
  conf = ir["confidence"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1662
  result["intent"] = intent
1663
  result["confidence"] = conf
1664
 
@@ -1740,17 +1765,24 @@ def chat(text: str, session, user_lat=None, user_lon=None):
1740
  session.context_slots = {}
1741
  log.info("Context cleared for new category")
1742
 
 
 
 
 
 
 
 
 
1743
  merged = session.merge(ents)
1744
 
1745
- category = normalize_category(
1746
- merged.get("category") or INTENT_TO_CATEGORY.get(intent) or infer_category(text)
1747
- )
1748
- sub_cat = merged.get("sub_category")
1749
  if sub_cat and normalize_category(sub_cat):
1750
  sub_cat = None
1751
- location = merged.get("location")
1752
- price_range = merged.get("price")
1753
- place_name = merged.get("place_name")
 
1754
  open_only = (intent == "open_now")
1755
 
1756
  # โ”€โ”€ extract place name โ€” ู…ุน ู…ู†ุน proximity words โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -1954,18 +1986,21 @@ _ITEM_SEARCH_PATTERNS = [
1954
  "ู…ูƒุงู† ุนู†ุฏู‡", "ู…ูƒุงู† ุนู†ุฏู‡ุง", "ุงู…ุงูƒู† ุนู†ุฏู‡ุง", "ุฃู…ุงูƒู† ุนู†ุฏู‡ุง",
1955
  "ู…ุทุนู… ุนู†ุฏู‡", "ูƒุงููŠู‡ ุนู†ุฏู‡", "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง",
1956
  "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…", "ููŠู‡", "ููŠู‡ุง",
1957
- "ุนุงูŠุฒ", "ุนุงูŠุฒุฉ", "ุนุงูˆุฒู‡", "ุนุงูˆุฒ", "ู†ูุณูŠ ููŠ", "ุจุฏูˆุฑ ุนู„ู‰", "ููŠู†",
1958
  "place has", "places have", "has", "serves", "serve", "where can i find"
1959
  ]
1960
 
1961
  def _looks_like_item_search(text: str) -> bool:
1962
  """
1963
- Detect item/menu-item search without stealing normal category requests.
1964
-
1965
- Important fixes:
1966
- - "ุนุงูŠุฒุฉ ุณูˆุจุฑู…ุงุฑูƒุช" is nearest_supermarket, not search_by_item.
1967
- - "ุนุงูŠุฒุฉ ุงุญุณู† ู…ุทุนู…" is recommendation/search, not item_query=ุงุญุณู†.
1968
- - "ุนุงูŠุฒุฉ ุจุฑุฌุฑ" / "ู…ูƒุงู† ุนู†ุฏู‡ ุงุณู…ูˆุฒูŠ ุฎูˆุฎ" stays search_by_item.
 
 
 
1969
  """
1970
  t = norm(text)
1971
 
@@ -1973,7 +2008,7 @@ def _looks_like_item_search(text: str) -> bool:
1973
  item_context_words = [
1974
  "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง", "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…",
1975
  "ููŠู‡", "ููŠู‡ุง", "ู…ูƒุงู† ุนู†ุฏู‡", "ู…ูƒุงู† ุนู†ุฏู‡ุง", "ู…ุทุนู… ุนู†ุฏู‡", "ูƒุงููŠู‡ ุนู†ุฏู‡",
1976
- "has", "serves", "serve"
1977
  ]
1978
  if any(kw in t for kw in _MENU_TRIGGERS) and not any(norm(p) in t for p in item_context_words):
1979
  return False
@@ -1982,44 +2017,48 @@ def _looks_like_item_search(text: str) -> bool:
1982
  candidate = extract_item_query(text)
1983
  cand_clean = clean_text(candidate or "")
1984
 
1985
- # If the remaining candidate is only a category or a ranking adjective, do NOT treat it as food item.
1986
  category_words = set()
1987
  for words in CATEGORY_KEYWORDS.values():
1988
  category_words.update(clean_text(w) for w in words)
1989
  category_words.update(clean_text(k) for k in _CAT_MAP.keys())
 
1990
 
1991
  ranking_words = {
1992
  "ุงุญุณู†", "ุงูุถู„", "ุงู„ุงุญุณู†", "ุงู„ุงูุถู„", "ุฃุญุณู†", "ุฃูุถู„",
1993
  "ูƒูˆูŠุณ", "ูƒูˆูŠุณุฉ", "ุญู„ูˆ", "ุญู„ูˆุฉ", "ุชุฑุดูŠุญ", "ุฑุดุญู„ูŠ",
1994
- "best", "good", "recommended", "recommend"
1995
  }
1996
  ranking_words = {clean_text(w) for w in ranking_words}
1997
 
 
1998
  cand_tokens = [w for w in cand_clean.split() if w]
1999
- if cand_tokens and all((w in category_words or w in ranking_words) for w in cand_tokens):
 
 
2000
  return False
2001
 
2002
- # Pure category requests must go to nearest_* intents, not item search.
2003
  if inferred_cat in ("supermarket", "pharmacy", "housing"):
2004
  return False
2005
- if inferred_cat in ("restaurant", "cafe") and cand_tokens and all(w in category_words or w in ranking_words for w in cand_tokens):
 
 
2006
  return False
2007
 
2008
  has_item_keyword = any(norm(kw) in t for kw in _ITEM_TRIGGERS)
2009
- has_item_pattern = any(norm(p) in t for p in _ITEM_SEARCH_PATTERNS)
2010
  has_strong_item_context = any(norm(p) in t for p in item_context_words)
2011
 
2012
- # Known item names are enough: "ุนุงูŠุฒุฉ ุจุฑุฌุฑ".
2013
  if has_item_keyword:
2014
  return True
2015
 
2016
- # Unknown item names need strong item context: "ู…ูƒุงู† ุนู†ุฏู‡ ูุชุฉ" / "ูƒุงููŠู‡ ุจูŠุนู…ู„ ู…ูˆู‡ูŠุชูˆ".
2017
- if has_strong_item_context and candidate and len(cand_clean) >= 3:
2018
  return True
2019
 
2020
- # Avoid broad patterns like "ุนุงูŠุฒุฉ" stealing normal searches.
2021
- if has_item_pattern and inferred_cat in ("restaurant", "cafe") and candidate and len(cand_clean) >= 3:
2022
- return False
 
2023
 
2024
  return False
2025
 
@@ -2426,7 +2465,7 @@ def _infer_item_kind(item_query: str) -> str:
2426
  return "unknown"
2427
 
2428
 
2429
- def scan_menus_for_item(item_query: str, max_places_to_scan: int = 120) -> list[dict]:
2430
  """
2431
  Last-resort but important fallback:
2432
  Search inside actual menus when /search-by-item returns empty or too strict.
@@ -2793,6 +2832,11 @@ def clean_place(place: dict) -> dict:
2793
 
2794
  image = _first_value(place, "image", "image_url", "main_image_url", "photo", "photo_url", default="")
2795
  favorite_raw = _first_value(place, "is_favorited", "is_favorite", default=False)
 
 
 
 
 
2796
 
2797
  return {
2798
  "id": str(_first_value(place, "id", "place_id", default="")),
@@ -2808,7 +2852,7 @@ def clean_place(place: dict) -> dict:
2808
  "opening_hours": _first_value(place, "opening_hours", "openning_hours", default=""),
2809
  "distance_km": distance,
2810
  "image": image or "",
2811
- "phone": _first_value(place, "phone", "mobile", "telephone", default=""),
2812
  "lat": lat,
2813
  "lon": lon,
2814
  "is_favorited": bool(favorite_raw),
 
539
  "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง",
540
  "ููŠู‡", "ููŠู‡ุง",
541
  "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…"
 
 
542
  ]
543
 
544
  for w in remove_words:
 
646
  matched = df[df["name_score"] > 0].copy()
647
 
648
  if category and not matched.empty:
649
+ target_cat = normalize_category(category)
650
+ cat_match = matched[matched["category"].astype(str).apply(lambda x: _category_match_value(x, target_cat))]
651
+ if cat_match.empty and "category_clean" in matched.columns:
652
+ cat_match = matched[matched["category_clean"].astype(str).apply(lambda x: _category_match_value(x, target_cat))]
653
+ matched = cat_match
654
 
655
  return matched.sort_values("name_score", ascending=False).head(top_k)
656
 
 
1057
 
1058
  def handle_detail(text, place):
1059
  if not place:
1060
+ return "ุชู‚ุตุฏ ุฃู†ู‡ูŠ ู…ูƒุงู†ุŸ ุงูƒุชุจ ุงุณู… ุงู„ู…ูƒุงู† ุฃูˆ ุงุณุฃู„ู†ูŠ ุงู„ุฃูˆู„ ุนู† ู…ุทุนู…/ุตูŠุฏู„ูŠุฉ/ูƒุงููŠู‡. ๐Ÿ˜Š"
1061
  t = norm(text)
1062
  name = place.get("name", "ุงู„ู…ูƒุงู†")
1063
 
1064
+ if any(w in t for w in ["ุจูŠูุชุญ","ุจุชูุชุญ","ู…ูˆุงุนูŠุฏ","ู…ูˆุงุนูŠุฏู‡","ู…ูˆุงุนูŠุฏู‡ุง","ุงู…ุชู‰","ุงู…ุชูŠ","ุจูŠู‚ูู„","ุจุชู‚ูู„"]):
1065
  h = place.get("opening_hours","")
1066
  if not h or str(h).strip() in ("","nan","none"):
1067
  return f"ู…ุนู†ุฏู†ุงุด ู…ุนู„ูˆู…ุงุช ุนู† ู…ูˆุงุนูŠุฏ *{name}*."
 
1069
  status = "๐ŸŸข ู…ูุชูˆุญ ุฏู„ูˆู‚ุชูŠ." if on == 1 else ("๐Ÿ”ด ู…ุบู„ู‚ ุฏู„ูˆู‚ุชูŠ." if on == 0 else "")
1070
  return f"{fmt_hours(h)}{status}"
1071
 
1072
+ # Phone must be checked before address because queries like "ููŠู† ุฑู‚ู… ุชู„ูŠููˆู†ู‡ุŸ" contain "ููŠู†".
1073
+ if any(w in t for w in ["ุชู„ูŠููˆู†","ุชู„ููˆู†","ุฑู‚ู…","ุฑู‚ู…ู‡","ุฑู‚ู…ู‡ุง","phone","ุงุชุตู„"]):
1074
+ ph = place.get("phone","")
1075
+ if isinstance(ph, (list, tuple)):
1076
+ ph = ", ".join(str(x) for x in ph if x)
1077
+ elif isinstance(ph, str) and ph.startswith("[") and ph.endswith("]"):
1078
+ ph = ph.strip("[]").replace("'", "").replace('"', "").strip()
1079
+ return f"๐Ÿ“ž {name}: {ph}" if ph and ph not in ("","nan") else f"ู…ุด ุนู†ุฏู†ุง ุฑู‚ู… *{name}*."
1080
+
1081
  if any(w in t for w in ["ุนู†ูˆุงู†","ููŠู†","ููŠู†ู‡","ููŠู†ู‡ุง","ู…ูƒุงู†ู‡","ู…ูƒุงู†ู‡ุง","ุงู„ุนู†ูˆุงู†",
1082
  "ู„ูˆูƒูŠุดู†","ุงู„ู…ูˆู‚ุน","ุงุฒุงูŠ ุงุฑูˆุญู‡","ุงุฒุงูŠ ุงุฑูˆุญ","map","ุงู„ุฎุฑูŠุทุฉ"]):
1083
  addr = place.get("address",""); loc = place.get("location","")
 
1091
  parts.append(f"๐Ÿ—บ๏ธ https://maps.google.com/?q={lat},{lon}")
1092
  return "\n".join(parts) if parts else f"ุนู†ูˆุงู† {name}: ุบูŠุฑ ู…ุชุงุญ."
1093
 
 
 
 
 
1094
  if any(w in t for w in ["ุชู‚ูŠูŠู…","ูƒุงู… ู†ุฌู…ุฉ","rating","ู†ุฌูˆู…"]):
1095
  r = fmt_rating(place.get("rating_norm", place.get("rating", 0)))
1096
  return r or "ู…ุด ุนู†ุฏู†ุง ุชู‚ูŠูŠู… ู„ู‡ุฐุง ุงู„ู…ูƒุงู†."
 
1626
  session.add(text, reply, result["intent"], {}, session.last_place, [])
1627
  return result
1628
 
1629
+ if ref == "detail" and not session.last_place:
1630
+ reply = "ุชู‚ุตุฏ ุฃู†ู‡ูŠ ู…ูƒุงู†ุŸ ุงูƒุชุจ ุงุณู… ุงู„ู…ูƒุงู† ุฃูˆ ุงุณุฃู„ู†ูŠ ุงู„ุฃูˆู„ ุนู† ู…ุทุนู…/ุตูŠุฏู„ูŠุฉ/ูƒุงููŠู‡. ๐Ÿ˜Š"
1631
+ result.update(reply=reply, intent="missing_info", confidence=1.0,
1632
+ best_place=None, all_results=[])
1633
+ session.add(text, reply, "missing_info", {}, None, [])
1634
+ return result
1635
+
1636
  # โ”€โ”€ next result โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1637
  if ref == "next" and session.last_results:
1638
  ptr = session.result_pointer + 1
 
1669
  ir = predict_intent(text)
1670
  intent = ir["intent"]
1671
  conf = ir["confidence"]
1672
+ # Deterministic category intent override: the model/context must not turn
1673
+ # "ุนุงูŠุฒุฉ ุตูŠุฏู„ูŠุฉ" into restaurant or keep old category from previous turns.
1674
+ current_text_cat = infer_category(text)
1675
+ if current_text_cat:
1676
+ forced_intent_map = {
1677
+ "restaurant": "nearest_restaurant",
1678
+ "pharmacy": "nearest_pharmacy",
1679
+ "cafe": "nearest_cafe",
1680
+ "supermarket": "nearest_supermarket",
1681
+ "housing": "housing_search",
1682
+ }
1683
+ if intent in ("fallback", "recommend_place", "open_now", "place_details") or intent in SEARCH_INTENTS:
1684
+ intent = forced_intent_map.get(current_text_cat, intent)
1685
+ conf = max(float(conf or 0), 0.96)
1686
+
1687
  result["intent"] = intent
1688
  result["confidence"] = conf
1689
 
 
1765
  session.context_slots = {}
1766
  log.info("Context cleared for new category")
1767
 
1768
+ # Merge context carefully: explicit category in the current user message wins.
1769
+ # If the user changes from restaurant โ†’ pharmacy/supermarket/etc, old context is cleared.
1770
+ current_text_cat = infer_category(text) or INTENT_TO_CATEGORY.get(intent)
1771
+ old_ctx_cat = normalize_category(session.context_slots.get("category")) if session.context_slots else None
1772
+ if current_text_cat and old_ctx_cat and normalize_category(current_text_cat) != old_ctx_cat:
1773
+ session.context_slots = {}
1774
+ log.info(f"Context cleared: category changed {old_ctx_cat} โ†’ {current_text_cat}")
1775
+
1776
  merged = session.merge(ents)
1777
 
1778
+ category = normalize_category(current_text_cat or merged.get("category"))
1779
+ sub_cat = ents.get("sub_category") or (merged.get("sub_category") if not current_text_cat else None)
 
 
1780
  if sub_cat and normalize_category(sub_cat):
1781
  sub_cat = None
1782
+ # inherit location only for true continuations, not for a new category question
1783
+ location = ents.get("location") or (merged.get("location") if not current_text_cat else None)
1784
+ price_range = ents.get("price") or merged.get("price")
1785
+ place_name = ents.get("place_name") or (merged.get("place_name") if not current_text_cat else None)
1786
  open_only = (intent == "open_now")
1787
 
1788
  # โ”€โ”€ extract place name โ€” ู…ุน ู…ู†ุน proximity words โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
1986
  "ู…ูƒุงู† ุนู†ุฏู‡", "ู…ูƒุงู† ุนู†ุฏู‡ุง", "ุงู…ุงูƒู† ุนู†ุฏู‡ุง", "ุฃู…ุงูƒู† ุนู†ุฏู‡ุง",
1987
  "ู…ุทุนู… ุนู†ุฏู‡", "ูƒุงููŠู‡ ุนู†ุฏู‡", "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง",
1988
  "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…", "ููŠู‡", "ููŠู‡ุง",
1989
+ "ุนุงูŠุฒ", "ุนุงูŠุฒุฉ", "ุนุงูŠุฒู‡", "ุนุงูˆุฒู‡", "ุนุงูˆุฒ", "ู…ุญุชุงุฌ", "ู…ุญุชุงุฌุฉ", "ู†ูุณูŠ ููŠ", "ุจุฏูˆุฑ ุนู„ู‰", "ููŠู†",
1990
  "place has", "places have", "has", "serves", "serve", "where can i find"
1991
  ]
1992
 
1993
  def _looks_like_item_search(text: str) -> bool:
1994
  """
1995
+ Detect item/menu-item search without breaking normal category searches.
1996
+
1997
+ Rules:
1998
+ - Category-only requests stay category search:
1999
+ "ุนุงูŠุฒุฉ ุตูŠุฏู„ูŠุฉ" โ†’ nearest_pharmacy, "ุนุงูŠุฒุฉ ุณูˆุจุฑู…ุงุฑูƒุช" โ†’ nearest_supermarket.
2000
+ - Ranking/category requests stay place search:
2001
+ "ุนุงูŠุฒุฉ ุฃุญุณู† ู…ุทุนู…" โ†’ recommend/restaurant, not item=ุฃุญุณู†.
2002
+ - Known or unknown item requests become search_by_item:
2003
+ "ุนุงูŠุฒุฉ ุจุฑุฌุฑ", "ุนุงูŠุฒุฉ ูุชุฉ", "ู…ูƒุงู† ุนู†ุฏู‡ ุงุณู…ูˆุฒูŠ ุฎูˆุฎ".
2004
  """
2005
  t = norm(text)
2006
 
 
2008
  item_context_words = [
2009
  "ุนู†ุฏู‡", "ุนู†ุฏู‡ุง", "ุจูŠุนู…ู„", "ุจุชุนู…ู„", "ุจูŠู‚ุฏู…", "ุจุชู‚ุฏู…",
2010
  "ููŠู‡", "ููŠู‡ุง", "ู…ูƒุงู† ุนู†ุฏู‡", "ู…ูƒุงู† ุนู†ุฏู‡ุง", "ู…ุทุนู… ุนู†ุฏู‡", "ูƒุงููŠู‡ ุนู†ุฏู‡",
2011
+ "ู…ุญู„ ุนู†ุฏู‡", "ู…ุทุนู… ููŠู‡", "ูƒุงููŠู‡ ููŠู‡", "has", "serves", "serve"
2012
  ]
2013
  if any(kw in t for kw in _MENU_TRIGGERS) and not any(norm(p) in t for p in item_context_words):
2014
  return False
 
2017
  candidate = extract_item_query(text)
2018
  cand_clean = clean_text(candidate or "")
2019
 
 
2020
  category_words = set()
2021
  for words in CATEGORY_KEYWORDS.values():
2022
  category_words.update(clean_text(w) for w in words)
2023
  category_words.update(clean_text(k) for k in _CAT_MAP.keys())
2024
+ category_words.update({"restaurant", "restaurants", "cafe", "coffee", "pharmacy", "supermarket", "market", "housing", "hotel"})
2025
 
2026
  ranking_words = {
2027
  "ุงุญุณู†", "ุงูุถู„", "ุงู„ุงุญุณู†", "ุงู„ุงูุถู„", "ุฃุญุณู†", "ุฃูุถู„",
2028
  "ูƒูˆูŠุณ", "ูƒูˆูŠุณุฉ", "ุญู„ูˆ", "ุญู„ูˆุฉ", "ุชุฑุดูŠุญ", "ุฑุดุญู„ูŠ",
2029
+ "best", "good", "recommended", "recommend", "ุงู‚ุฑุจ", "ู‚ุฑูŠุจ", "nearest"
2030
  }
2031
  ranking_words = {clean_text(w) for w in ranking_words}
2032
 
2033
+ generic_words = {"ู…ูƒุงู†", "ุงู…ุงูƒู†", "ุญุงุฌู‡", "ุญุงุฌุฉ", "ุดูŠ", "ุงูŠ", "ุงู‰", "ููŠู†", "ุนู†ุฏูŠ", "ุนู†ุฏูƒ"}
2034
  cand_tokens = [w for w in cand_clean.split() if w]
2035
+
2036
+ # Candidate is only category/ranking/generic โ†’ not item.
2037
+ if not cand_tokens or all((w in category_words or w in ranking_words or w in generic_words) for w in cand_tokens):
2038
  return False
2039
 
2040
+ # Pure pharmacy/supermarket/housing requests are never item searches.
2041
  if inferred_cat in ("supermarket", "pharmacy", "housing"):
2042
  return False
2043
+
2044
+ # Restaurant/cafe requests with only category/ranking words are place search.
2045
+ if inferred_cat in ("restaurant", "cafe") and all(w in category_words or w in ranking_words or w in generic_words for w in cand_tokens):
2046
  return False
2047
 
2048
  has_item_keyword = any(norm(kw) in t for kw in _ITEM_TRIGGERS)
 
2049
  has_strong_item_context = any(norm(p) in t for p in item_context_words)
2050
 
 
2051
  if has_item_keyword:
2052
  return True
2053
 
2054
+ # Unknown item with strong context: "ู…ูƒุงู† ุนู†ุฏู‡ ูุชุฉ".
2055
+ if has_strong_item_context:
2056
  return True
2057
 
2058
+ # Unknown item without category: "ุนุงูŠุฒุฉ ูุชุฉ" / "ุนุงูŠุฒ ู…ูˆู‡ูŠุชูˆ".
2059
+ demand_words = ["ุนุงูŠุฒ", "ุนุงูŠุฒุฉ", "ุนุงูŠุฒู‡", "ุนุงูˆุฒ", "ู…ุญุชุงุฌ", "ู…ุญุชุงุฌุฉ", "ู†ูุณูŠ", "ุจุฏูˆุฑ ุนู„ู‰", "ููŠู†"]
2060
+ if inferred_cat is None and any(norm(w) in t for w in demand_words) and len(cand_clean) >= 3:
2061
+ return True
2062
 
2063
  return False
2064
 
 
2465
  return "unknown"
2466
 
2467
 
2468
+ def scan_menus_for_item(item_query: str, max_places_to_scan: int = 300) -> list[dict]:
2469
  """
2470
  Last-resort but important fallback:
2471
  Search inside actual menus when /search-by-item returns empty or too strict.
 
2832
 
2833
  image = _first_value(place, "image", "image_url", "main_image_url", "photo", "photo_url", default="")
2834
  favorite_raw = _first_value(place, "is_favorited", "is_favorite", default=False)
2835
+ phone_val = _first_value(place, "phone", "mobile", "telephone", default="")
2836
+ if isinstance(phone_val, (list, tuple)):
2837
+ phone_val = ", ".join(str(x) for x in phone_val if x)
2838
+ elif isinstance(phone_val, str) and phone_val.startswith("[") and phone_val.endswith("]"):
2839
+ phone_val = phone_val.strip("[]").replace("'", "").replace('"', "").strip()
2840
 
2841
  return {
2842
  "id": str(_first_value(place, "id", "place_id", default="")),
 
2852
  "opening_hours": _first_value(place, "opening_hours", "openning_hours", default=""),
2853
  "distance_km": distance,
2854
  "image": image or "",
2855
+ "phone": phone_val,
2856
  "lat": lat,
2857
  "lon": lon,
2858
  "is_favorited": bool(favorite_raw),