Youmnaaaa commited on
Commit
2aea363
·
verified ·
1 Parent(s): 12db342

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +116 -78
app.py CHANGED
@@ -51,6 +51,7 @@ INTENT_THRESHOLD = float(os.getenv("INTENT_THRESHOLD", "0.70"))
51
  SEMANTIC_MIN_SCORE = float(os.getenv("SEMANTIC_MIN_SCORE", "0.35"))
52
  SEMANTIC_TOP_K = int(os.getenv("SEMANTIC_TOP_K", "25"))
53
  SESSION_TTL_SEC = int(os.getenv("SESSION_TTL_SEC", "3600"))
 
54
 
55
  intent_pipe = None
56
  ner_pipe = None
@@ -98,7 +99,7 @@ CATEGORY_KEYWORDS = {
98
  "طعمية", "سمك", "restaurant", "food", "burger", "pizza", "shawarma", "crepe",
99
  ],
100
  "pharmacy": [
101
- "صيدليه", "صيدلية", "صديليه", "صيدليات", "صيدله", "صديليه", "صديليه", "دوا", "دواء", "ادويه", "أدوية",
102
  "ادوية", "علاج", "pharmacy", "drugstore", "medicine",
103
  ],
104
  "cafe": [
@@ -173,18 +174,18 @@ ITEM_CATEGORY_HINTS = {
173
 
174
  DYNAMIC_TEMPLATES = {
175
  "category": [
176
- "من عينيا! جمعتلك اختيارات {cat_ar} متاحة في بني سويف، شوف الكروت تحت واختار اللي يناسبك 👇",
177
  "جاهز لمساعدتك! دي لستة {cat_ar} مناسبة ومترتبة عشان تختار بسرعة 🗺️",
178
  "تأمر يا هندسة! لقيتلك شوية اختيارات كويسة من {cat_ar} في بني سويف ✨",
179
  "تمام، دورتلك وطلعتلك أنسب {cat_ar} حسب طلبك 👇",
180
- "دي أفضل نتايج لقيتها ليك من {cat_ar}، بص على الكروت وشوف الأنسب ",
181
  ],
182
  "nearest": [
183
  "حسبتلك المسافات بالملي! دي الأماكن مترتبة من الأقرب للأبعد من موقعك الحالي 📍",
184
  "علطول كده! أول كارت هو الأقرب ليك، وبعده الباقي حسب المسافة 🚗",
185
  "جبتلك الأقرب فالأقرب عشان المشوار يبقى سهل وسريع ⏱️",
186
  "دي أقرب اختيارات حوالين موقعك الحالي، مرتبة من الأقرب للأبعد 👇",
187
- "رتبتهم حسب المسافة من مكانك، شوف أول كارت هيبقى الأقرب 📌",
188
  ],
189
  "best": [
190
  "بناءً على التقييمات، {best_name} ظاهر كأقوى اختيار، وباقي الكروت مرتبة حسب الأعلى تقييمًا 🏆",
@@ -193,17 +194,24 @@ DYNAMIC_TEMPLATES = {
193
  "الناس بتقول إن دول الأفضل، رتبتهم حسب التقييم عشان تختار وانت مطمن ✨",
194
  "جمعتلك الاختيارات الأعلى تقييمًا في بني سويف، أول كارت هو الأقوى تقييمًا 👇",
195
  ],
 
 
 
 
 
 
 
196
  "item": [
197
- "طلبك عندي! دورت في الأماكن المتاحة ولقيتلك اللي بيقدموا {item} 👇",
198
- "جبتلك المفيد! دي الأماكن اللي ظاهر عندها {item} 🎯",
199
  "بال هنا مقدمًا! دي اختيارات مناسبة لطلب {item} 👇",
200
  "دورتلك على {item} وطلعتلك الأماكن الأقرب للطلب ده ✨",
201
- "دي الأماكن اللي ينفع تبدأ بيها لو نفسك في {item} ",
202
  ],
203
  "open": [
204
  "فلترتلك الأماكن المفتوحة دلوقتي قدر الإمكان، شوف الكروت تحت 🟢",
205
  "دورت على المتاح دلوقتي، والنتايج تحت حسب حالة المواعيد المسجلة ⏰",
206
- "دي الأماكن اللي باين إنها شغالة أو متاحة حاليًا في الداتا 👇",
207
  "تمام، ركزت على الأماكن المفتوحة دلوقتي حسب المواعيد المتاحة 🟢",
208
  "شوف دول، حاولت أجيبلك المتاح دلوقتي بأفضل ترتيب 👇",
209
  ],
@@ -224,7 +232,7 @@ DYNAMIC_TEMPLATES = {
224
  "area_fallback": [
225
  "مفيش {cat_ar} متاحة حاليًا في {location}، بس لقيت بدائل قريبة/متاحة في بني سويف 👇",
226
  "المنطقة دي مش ظاهر فيها {cat_ar} دلوقتي، فبعرضلك اختيارات تانية ممكن تنفعك 👇",
227
- "مش لاقي {cat_ar} في {location} حاليًا، لكن دي نتائج بديلة 👇",
228
  "في {location} مفيش نتيجة مطابقة، فدي أقرب بدائل مناسبة عندي 👇",
229
  "مفيش {cat_ar} مسجلة في {location} حاليًا، شوف البدائل دي 👇",
230
  ],
@@ -263,42 +271,47 @@ def normalize_category(cat: Any) -> str:
263
  c = clean_text(cat)
264
  if not c:
265
  return ""
266
- if "pharmacy" in c or "صيدلي" in c or "صيدله" in c or "دواء" in c or "دوا" in c:
 
 
 
 
267
  return "pharmacy"
268
  if "supermarket" in c or "market" in c or "grocery" in c or "ماركت" in c or "بقال" in c or "هايبر" in c:
269
  return "supermarket"
270
- if "cafe" in c or "coffee" in c or "كافيه" in c or "كوفي" in c or "قهوه" in c or "قهوة" in c:
271
  return "cafe"
272
  if "housing" in c or "hotel" in c or "hostel" in c or "سكن" in c or "شقه" in c or "فندق" in c:
273
  return "housing"
274
- if "restaurant" in c or "مطعم" in c or "مطاعم" in c or "اكل" in c:
275
  return "restaurant"
276
  return c
277
 
278
 
279
  def category_matches(raw_category: Any, target: str) -> bool:
 
 
280
  """
281
- Strict but multi-label aware category matching.
282
- Important: DB categories can be mixed like "restaurant & café".
283
- Normalizing that to one label only makes cafe/restaurant queries miss valid cards.
284
- """
285
- target = normalize_category(target)
286
  if not target:
287
  return True
 
288
  raw = clean_text(raw_category)
289
- normed = normalize_category(raw_category)
290
-
 
 
 
291
  if target == "restaurant":
292
- return ("restaurant" in raw or "مطعم" in raw or "مطاعم" in raw or "food" in raw or normed == "restaurant")
293
  if target == "cafe":
294
- return ("cafe" in raw or "coffee" in raw or "كافيه" in raw or "كوفي" in raw or "قهوه" in raw or "قهوة" in raw or normed == "cafe")
295
  if target == "pharmacy":
296
- return ("pharmacy" in raw or "صيدلي" in raw or "صيدله" in raw or "صديلي" in raw or normed == "pharmacy")
297
  if target == "supermarket":
298
- return ("supermarket" in raw or "market" in raw or "grocery" in raw or "ماركت" in raw or "بقال" in raw or "هايبر" in raw or normed == "supermarket")
299
  if target == "housing":
300
- return ("housing" in raw or "hotel" in raw or "hostel" in raw or "سكن" in raw or "شقه" in raw or "فندق" in raw or normed == "housing")
301
- return normed == target or target in raw
302
 
303
 
304
  def infer_category(text: str, intent: str = "", entities: Optional[dict] = None) -> str:
@@ -591,7 +604,7 @@ def clean_item_query(text: str) -> str:
591
  remove_words = [
592
  "عايز", "عايزه", "عايزة", "عاوز", "عاوزه", "عاوزة", "محتاج", "محتاجة", "نفسي",
593
  "في", "فين", "مطعم", "مطاعم", "كافيه", "مكان", "بيعمل", "بتعمل", "عنده", "عندها",
594
- "فيه", "فيها", "بيقدم", "بتقدم", "لو سمحت", "ممكن", "من فضلك", "قريب", "اقرب", "طب", "احسن", "افضل", "أفضل", "اعلى", "أعلى", "تقييم", "التوب", "توب", "كويس",
595
  ]
596
  for w in remove_words:
597
  q = re.sub(rf"\b{re.escape(clean_text(w))}\b", " ", q)
@@ -618,6 +631,18 @@ def extract_item_query(text: str) -> str:
618
  return cleaned
619
 
620
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  def extract_menu_place_name(text: str) -> str:
622
  t = clean_text(text)
623
  for w in ["هات", "ابعت", "وريني", "اعرض", "منيو", "مينيو", "menu", "بتاع", "قائمة"]:
@@ -673,9 +698,10 @@ def extract_place_name_from_text(text: str) -> str:
673
  stop_phrases = [
674
  "فين", "مكان", "مكانه", "مكانها", "عنوان", "عنوانه", "عنوانها", "لوكيشن", "رقم", "تليفون",
675
  "مطعم", "صيدليه", "صيدلية", "صديليه", "كافيه", "ماركت", "سوبر", "سوبرماركت", "هات", "منيو", "عايز", "عايزة",
676
- "عاوز", "عايزه", "عاوزه", "مواعيد", "بيفتح", "بتفتح", "امتى", "امتي", "عايزة", "اروح", "ازاي", "قريب", "اقرب", "مفتوح", "فاتح", "دلوقتي", "في", "من", "عند",
677
  "رقمه", "رقمها", "تليفونه", "تليفونها", "ايه", "إيه", "احسن", "أحسن", "افضل", "أفضل", "اعلى", "أعلى", "توب", "التوب",
678
  "طب", "طيب", "تمام", "ماشي",
 
679
  ]
680
  for w in stop_phrases:
681
  t = re.sub(rf"\b{re.escape(clean_text(w))}\b", " ", t)
@@ -686,9 +712,10 @@ def extract_place_name_from_text(text: str) -> str:
686
  return ""
687
  if infer_category(t) and len(t.split()) <= 2:
688
  return ""
689
- # Named-place shortcut: "on public عايزه", "lamera عايزه", "pizza bike مواعيد".
690
- # If removing request/category words leaves a short phrase, treat it as a candidate place name.
691
- if len(t.split()) <= 4:
 
692
  return t
693
  return ""
694
 
@@ -747,6 +774,10 @@ def is_best_query(text: str, intent: str = "") -> bool:
747
  return has_any(text, BEST_WORDS) or intent in {"best_of", "recommend_place"}
748
 
749
 
 
 
 
 
750
  def compare_place_names(text: str) -> list:
751
  t = clean_text(text)
752
  for w in ["قارن", "قارني", "مقارنة", "بين", "ما بين", "مين احسن", "احسن"]:
@@ -781,17 +812,14 @@ def semantic_candidates(query: str, top_k: int = SEMANTIC_TOP_K) -> pd.DataFrame
781
  return result.reset_index(drop=True)
782
 
783
 
784
- def name_search(place_name: str, category: str = "", top_k: int = 5) -> pd.DataFrame:
785
  if places_df is None or places_df.empty or not place_name:
786
  return pd.DataFrame()
787
  q = clean_text(place_name)
788
  q_words = [w for w in q.split() if len(w) > 1]
789
  df = places_df.copy()
790
  if category:
791
- if "category" in df.columns:
792
- df = df[df["category"].apply(lambda c: category_matches(c, category))]
793
- elif "category_norm" in df.columns:
794
- df = df[df["category_norm"].apply(lambda c: category_matches(c, category))]
795
  if df.empty:
796
  return df
797
 
@@ -817,11 +845,8 @@ def apply_filters(df: pd.DataFrame, category: str = "", location: str = "", pric
817
  if df is None or df.empty:
818
  return pd.DataFrame()
819
  f = df.copy()
820
- if category:
821
- if "category" in f.columns:
822
- f = f[f["category"].apply(lambda c: category_matches(c, category))]
823
- elif "category_norm" in f.columns:
824
- f = f[f["category_norm"].apply(lambda c: category_matches(c, category))]
825
  if location and not f.empty:
826
  loc = clean_text(location)
827
  parts = [p for p in loc.split() if len(p) > 1]
@@ -878,6 +903,9 @@ def rank_places(df: pd.DataFrame, query: str, user_lat=None, user_lon=None, inte
878
  if col not in f.columns:
879
  f[col] = default
880
  f[col] = pd.to_numeric(f[col], errors="coerce").fillna(default)
 
 
 
881
  if near and user_lat is not None and user_lon is not None:
882
  return f.sort_values(["distance_km", "rating_norm"], ascending=[True, False], na_position="last").reset_index(drop=True)
883
  if best:
@@ -890,7 +918,7 @@ def rank_places(df: pd.DataFrame, query: str, user_lat=None, user_lon=None, inte
890
  f["final_score"] = 0.45 * f["semantic_score"] + 0.25 * f["rating_score"] + 0.15 * f["distance_score"] + 0.10 * f["open_score"] + 0.05 * f["name_score"]
891
  return f.sort_values("final_score", ascending=False).reset_index(drop=True)
892
 
893
- def search_places(query: str, category: str = "", item_query: str = "", location: str = "", price_range: str = "", open_now_only: bool = False, place_name: str = "", user_lat=None, user_lon=None, intent: str = "", top_k: int = 5) -> pd.DataFrame:
894
  category = normalize_category(category)
895
  if item_query and not category:
896
  category = intended_item_category(item_query) or "restaurant"
@@ -919,15 +947,22 @@ def search_places(query: str, category: str = "", item_query: str = "", location
919
  out["semantic_score"] = 0.80
920
  out = apply_filters(out, category=category or "restaurant", location="", price_range=price_range, open_now_only=open_now_only)
921
  return rank_places(out, query, user_lat, user_lon, intent).drop_duplicates("place_id").head(top_k).reset_index(drop=True)
922
- # Generic category/ranking requests should not depend on semantic score.
923
- # Examples: "عايزه كافيه", "احسن مطعم", "توب كافيه".
924
- # Use all DB places in that category, then rank/filter them.
925
- if category:
 
 
 
 
 
 
 
926
  base = places_df.copy() if places_df is not None else pd.DataFrame()
927
  filtered = apply_filters(base, category=category, location=location, price_range=price_range, open_now_only=open_now_only)
928
  if not filtered.empty:
929
  if "semantic_score" not in filtered.columns:
930
- filtered["semantic_score"] = 0.60
931
  return rank_places(filtered, query, user_lat, user_lon, intent).drop_duplicates("place_id").head(top_k).reset_index(drop=True)
932
 
933
  candidates = semantic_candidates(query, top_k=SEMANTIC_TOP_K)
@@ -974,7 +1009,7 @@ def _dynamic_reply(kind: str, **kwargs) -> str:
974
  return random.choice(templates)
975
 
976
 
977
- def build_response(results: List[dict], intent: str, category: str = "", item_query: str = "", place_name: str = "", location: str = "", open_now_only: bool = False, soft_reason: str = "") -> str:
978
  category = normalize_category(category)
979
  if not results:
980
  if place_name:
@@ -994,9 +1029,11 @@ def build_response(results: List[dict], intent: str, category: str = "", item_qu
994
  header = _dynamic_reply("area_fallback", category=category, location=location)
995
  elif item_query:
996
  header = _dynamic_reply("item", category=category or "restaurant", item_query=item_query)
997
- elif is_near_query(intent) or intent in {"nearby_search", "nearest_restaurant", "nearest_cafe", "nearest_pharmacy", "nearest_supermarket"}:
 
 
998
  header = _dynamic_reply("nearest", category=category, best_place=results[0])
999
- elif intent in {"best_of", "recommend_place"}:
1000
  header = _dynamic_reply("best", category=category, best_place=results[0])
1001
  elif open_now_only:
1002
  header = _dynamic_reply("open", category=category, best_place=results[0])
@@ -1004,8 +1041,8 @@ def build_response(results: List[dict], intent: str, category: str = "", item_qu
1004
  header = _dynamic_reply("one", category=category, best_place=results[0])
1005
  else:
1006
  header = _dynamic_reply("category", category=category, best_place=results[0])
1007
- # Flutter already renders the place cards, so keep the chat bubble clean.
1008
- # Do not duplicate results as numbered text inside the reply.
1009
  return header
1010
 
1011
  def handle_detail(text: str, place: Optional[dict]) -> str:
@@ -1095,7 +1132,8 @@ class Session:
1095
  self.last_place = place
1096
  if results:
1097
  self.last_results = results
1098
- self.result_pointer = 0
 
1099
  for key in ["category", "location", "price_range", "item_query", "place_name"]:
1100
  if entities and entities.get(key):
1101
  self.context_slots[key] = entities[key]
@@ -1133,15 +1171,24 @@ def chat(text: str, session: Session, user_lat=None, user_lon=None) -> dict:
1133
 
1134
  ref_type = detect_ref_type(text)
1135
  if ref_type == "next" and session.last_results:
1136
- if session.result_pointer + 1 >= len(session.last_results):
 
1137
  reply = "دي كانت كل النتائج المتاحة حاليًا 😊\nلو حابب، جرّب منطقة تانية أو نوع تاني."
1138
  session.add(text, reply, "show_more", session.last_entities, session.last_place, session.last_results)
 
1139
  return {"reply": reply, "intent": "show_more", "confidence": 1.0, "entities": session.last_entities, "best_place": session.last_place, "all_results": []}
1140
- session.result_pointer += 1
1141
- place = session.last_results[session.result_pointer]
1142
- reply = "تمام، شوف الاختيار اللي بعده 👇\n" + format_place_line(place)
 
 
 
 
 
 
1143
  session.add(text, reply, session.last_intent or "show_more", session.last_entities, place, session.last_results)
1144
- return {"reply": reply, "intent": "show_more", "confidence": 1.0, "entities": session.last_entities, "best_place": place, "all_results": [place]}
 
1145
 
1146
  if ref_type == "detail" and session.last_place and not extract_place_name_from_text(text):
1147
  reply = handle_detail(text, session.last_place)
@@ -1157,7 +1204,6 @@ def chat(text: str, session: Session, user_lat=None, user_lon=None) -> dict:
1157
  session.add(text, reply, "missing_info", entities, None, [])
1158
  return {"reply": reply, "intent": "missing_info", "confidence": confidence, "entities": entities, "best_place": None, "all_results": []}
1159
  category = infer_category(text, intent, entities)
1160
- explicit_category = category # category mentioned in the current user message only
1161
  if category:
1162
  entities["category"] = category
1163
  location_from_text = entities.get("location", "")
@@ -1224,9 +1270,7 @@ def chat(text: str, session: Session, user_lat=None, user_lon=None) -> dict:
1224
  return {"reply": reply, "intent": "show_menu", "confidence": confidence, "entities": entities, "best_place": place, "all_results": [place], "menu_items": items}
1225
 
1226
  if intent == "place_details" and entities.get("place_name"):
1227
- # Explicit place-name details must not be polluted by previous session category.
1228
- explicit_detail_category = infer_category(text, intent="", entities={})
1229
- hits = name_search(entities["place_name"], category=explicit_detail_category, top_k=1)
1230
  if not hits.empty:
1231
  place = row_to_dict(hits.iloc[0])
1232
  reply = handle_detail(text, place)
@@ -1234,26 +1278,20 @@ def chat(text: str, session: Session, user_lat=None, user_lon=None) -> dict:
1234
  return {"reply": reply, "intent": intent, "confidence": confidence, "entities": entities, "best_place": place, "all_results": [place]}
1235
 
1236
  merged = session.merge(entities)
1237
- place_name = entities.get("place_name", "")
1238
- # Do not let previous category pollute explicit place-name searches.
1239
- # Example: after asking for a pharmacy, "on public عايزه" must search all places, not only pharmacies.
1240
- if not category and merged.get("category") and not place_name:
1241
  category = merged.get("category")
1242
  entities["category"] = category
1243
  item_query = entities.get("item_query", "")
 
 
 
 
1244
  location = entities.get("location") or merged.get("location", "")
1245
  price_range = entities.get("price_range", "")
1246
  if intent == "search_by_item" and not item_query:
1247
  item_query = extract_item_query(text)
1248
  if item_query:
1249
  entities["item_query"] = item_query
1250
-
1251
- # Guard: words like "احسن مطعم" / "توب كافيه" are ranking requests, not menu items.
1252
- invalid_item_words = {"احسن", "افضل", "أفضل", "اعلى", "أعلى", "تقييم", "التوب", "توب", "كويس", "مطعم", "كافيه", "صيدليه", "صيدلية", "صديليه", "ماركت", "مكان", "طب"}
1253
- if item_query and (clean_text(item_query) in invalid_item_words or has_any(item_query, list(invalid_item_words & {"احسن", "افضل", "أفضل", "اعلى", "أعلى", "تقييم", "التوب", "توب", "كويس"}))):
1254
- item_query = ""
1255
- entities.pop("item_query", None)
1256
-
1257
  if item_query and not category:
1258
  category = intended_item_category(item_query) or "restaurant"
1259
  entities["category"] = category
@@ -1267,21 +1305,21 @@ def chat(text: str, session: Session, user_lat=None, user_lon=None) -> dict:
1267
  category = infer_category(text) or "cafe"
1268
  entities["category"] = category
1269
 
1270
- results_df = search_places(text, category=category, item_query=item_query, location=location, price_range=price_range, open_now_only=open_now_only, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=5)
1271
  soft_reason = ""
1272
  if results_df.empty and open_now_only and category:
1273
- results_df = search_places(text, category=category, item_query=item_query, location=location, price_range=price_range, open_now_only=False, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=5)
1274
  soft_reason = "open" if not results_df.empty else ""
1275
  if results_df.empty and location and category:
1276
- results_df = search_places(text, category=category, item_query=item_query, location="", price_range=price_range, open_now_only=False, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=5)
1277
  soft_reason = "area" if not results_df.empty else soft_reason
1278
  if results_df.empty and item_query and is_near_query(text):
1279
- results_df = search_places(text, category=category or "restaurant", item_query=item_query, location="", price_range=price_range, open_now_only=False, place_name="", user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=5)
1280
  soft_reason = "near_item" if not results_df.empty else soft_reason
1281
  results = [row_to_dict(r) for _, r in results_df.iterrows()] if not results_df.empty else []
1282
  best = results[0] if results else None
1283
  result_intent = intent if results else "no_result"
1284
- reply = build_response(results, intent, category=category, item_query=item_query, place_name=place_name, location=location, open_now_only=open_now_only, soft_reason=soft_reason)
1285
  session.add(text, reply, result_intent, entities, best, results)
1286
  return {"reply": reply, "intent": result_intent, "confidence": confidence, "entities": entities, "best_place": best, "all_results": results}
1287
  @asynccontextmanager
@@ -1501,7 +1539,7 @@ def chat_endpoint(req: ChatRequest):
1501
  session_id=req.session_id,
1502
  ui_type=ui_type,
1503
  best_place=unique_cards[0] if unique_cards else None,
1504
- place_cards=unique_cards,
1505
  menu_items=menu_items,
1506
  )
1507
 
 
51
  SEMANTIC_MIN_SCORE = float(os.getenv("SEMANTIC_MIN_SCORE", "0.35"))
52
  SEMANTIC_TOP_K = int(os.getenv("SEMANTIC_TOP_K", "25"))
53
  SESSION_TTL_SEC = int(os.getenv("SESSION_TTL_SEC", "3600"))
54
+ MAX_CARDS = int(os.getenv("MAX_CARDS", "4"))
55
 
56
  intent_pipe = None
57
  ner_pipe = None
 
99
  "طعمية", "سمك", "restaurant", "food", "burger", "pizza", "shawarma", "crepe",
100
  ],
101
  "pharmacy": [
102
+ "صيدليه", "صيدلية", "صيدليات", "صيدله", "صديليه", "صديليه", "دوا", "دواء", "ادويه", "أدوية",
103
  "ادوية", "علاج", "pharmacy", "drugstore", "medicine",
104
  ],
105
  "cafe": [
 
174
 
175
  DYNAMIC_TEMPLATES = {
176
  "category": [
177
+ "من عينيا! جمعتلك أحسن اختيارات {cat_ar} متاحة في بني سويف، شوف الكروت تحت واختار اللي يناسبك 👇",
178
  "جاهز لمساعدتك! دي لستة {cat_ar} مناسبة ومترتبة عشان تختار بسرعة 🗺️",
179
  "تأمر يا هندسة! لقيتلك شوية اختيارات كويسة من {cat_ar} في بني سويف ✨",
180
  "تمام، دورتلك وطلعتلك أنسب {cat_ar} حسب طلبك 👇",
181
+ "دي أفضل نتايج لقيتها ليك من {cat_ar}، بص على الكروت وشوف الأنسب 😊",
182
  ],
183
  "nearest": [
184
  "حسبتلك المسافات بالملي! دي الأماكن مترتبة من الأقرب للأبعد من موقعك الحالي 📍",
185
  "علطول كده! أول كارت هو الأقرب ليك، وبعده الباقي حسب المسافة 🚗",
186
  "جبتلك الأقرب فالأقرب عشان المشوار يبقى سهل وسريع ⏱️",
187
  "دي أقرب اختيارات حوالين موقعك الحالي، مرتبة من الأقرب للأبعد 👇",
188
+ "رتبتهم حسب المسافة من مكانك، شوف أول كارت هيبقى الأقرب 📌",
189
  ],
190
  "best": [
191
  "بناءً على التقييمات، {best_name} ظاهر كأقوى اختيار، وباقي الكروت مرتبة حسب الأعلى تقييمًا 🏆",
 
194
  "الناس بتقول إن دول الأفضل، رتبتهم حسب التقييم عشان تختار وانت مطمن ✨",
195
  "جمعتلك الاختيارات الأعلى تقييمًا في بني سويف، أول كارت هو الأقوى تقييمًا 👇",
196
  ],
197
+ "best_near": [
198
+ "جمعتلك المعادلة الصح: أماكن تقييمها عالي وقريبة منك في نفس الوقت، أول كارت هو الأفضل توازنًا بين الجودة والمسافة ⭐📍",
199
+ "رتبتلك الاختيارات حسب التقييم مع مراعاة القرب، عشان تختار مكان كويس ومش بعيد 👇",
200
+ "دي أماكن قوية في الريتنج وقريبة نسبيًا من موقعك، يعني جودة ومشوار أسهل في نفس الوقت 🚗⭐",
201
+ "عملتلك ترتيب ذكي بين الأعلى تقييمًا والأقرب، شوف أول كارت هيبقى أنسب اختيار متوازن ✨",
202
+ "لو عايز حاجة كويسة وقريبة، دي أفضل النتائج حسب الريتنج والمسافة مع بعض 📌",
203
+ ],
204
  "item": [
205
+ "طلبك عندي! دورت في الأماكن المتاحة ولقيتلك اللي بيقدموا {item} 😋👇",
206
+ "جبتلك المفيد! دي الأماكن اللي ظاهر عندها {item} 🎯",
207
  "بال هنا مقدمًا! دي اختيارات مناسبة لطلب {item} 👇",
208
  "دورتلك على {item} وطلعتلك الأماكن الأقرب للطلب ده ✨",
209
+ "دي الأماكن اللي ينفع تبدأ بيها لو نفسك في {item} 😋",
210
  ],
211
  "open": [
212
  "فلترتلك الأماكن المفتوحة دلوقتي قدر الإمكان، شوف الكروت تحت 🟢",
213
  "دورت على المتاح دلوقتي، والنتايج تحت حسب حالة المواعيد المسجلة ⏰",
214
+ "دي الأماكن اللي باين إنها شغالة أو متاحة حاليًا 👇",
215
  "تمام، ركزت على الأماكن المفتوحة دلوقتي حسب المواعيد المتاحة 🟢",
216
  "شوف دول، حاولت أجيبلك المتاح دلوقتي بأفضل ترتيب 👇",
217
  ],
 
232
  "area_fallback": [
233
  "مفيش {cat_ar} متاحة حاليًا في {location}، بس لقيت بدائل قريبة/متاحة في بني سويف 👇",
234
  "المنطقة دي مش ظاهر فيها {cat_ar} دلوقتي، فبعرضلك اختيارات تانية ممكن تنفعك 👇",
235
+ "مش لاقي {cat_ar} في {location} حاليًا، لكن دي نتائج بديلة 👇",
236
  "في {location} مفيش نتيجة مطابقة، فدي أقرب بدائل مناسبة عندي 👇",
237
  "مفيش {cat_ar} مسجلة في {location} حاليًا، شوف البدائل دي 👇",
238
  ],
 
271
  c = clean_text(cat)
272
  if not c:
273
  return ""
274
+ has_rest = "restaurant" in c or "مطعم" in c or "مطاعم" in c or "اكل" in c
275
+ has_cafe = "cafe" in c or "coffee" in c or "كافيه" in c or "كوفي" in c or "قهوه" in c or "قهوة" in c
276
+ if has_rest and has_cafe:
277
+ return "restaurant_cafe"
278
+ if "pharmacy" in c or "صيدلي" in c or "صيدله" in c or "صيدليه" in c or "صيدلية" in c or "دواء" in c or "دوا" in c:
279
  return "pharmacy"
280
  if "supermarket" in c or "market" in c or "grocery" in c or "ماركت" in c or "بقال" in c or "هايبر" in c:
281
  return "supermarket"
282
+ if has_cafe:
283
  return "cafe"
284
  if "housing" in c or "hotel" in c or "hostel" in c or "سكن" in c or "شقه" in c or "فندق" in c:
285
  return "housing"
286
+ if has_rest:
287
  return "restaurant"
288
  return c
289
 
290
 
291
  def category_matches(raw_category: Any, target: str) -> bool:
292
+ """Strict but fair category matching.
293
+ Mixed categories like 'restaurant & café' should match both restaurant and cafe.
294
  """
 
 
 
 
 
295
  if not target:
296
  return True
297
+ target = normalize_category(target)
298
  raw = clean_text(raw_category)
299
+ norm_cat = normalize_category(raw_category)
300
+ if norm_cat == target:
301
+ return True
302
+ if norm_cat == "restaurant_cafe" and target in {"restaurant", "cafe"}:
303
+ return True
304
  if target == "restaurant":
305
+ return "restaurant" in raw or "مطعم" in raw or "مطاعم" in raw
306
  if target == "cafe":
307
+ return "cafe" in raw or "coffee" in raw or "كافيه" in raw or "كوفي" in raw or "قهوه" in raw or "قهوة" in raw
308
  if target == "pharmacy":
309
+ return "pharmacy" in raw or "صيدلي" in raw or "صيدله" in raw or "صيدليه" in raw or "صيدلية" in raw
310
  if target == "supermarket":
311
+ return "supermarket" in raw or "market" in raw or "ماركت" in raw or "بقال" in raw or "هايبر" in raw
312
  if target == "housing":
313
+ return "housing" in raw or "hotel" in raw or "hostel" in raw or "سكن" in raw or "شقه" in raw or "فندق" in raw
314
+ return norm_cat == target or target in raw
315
 
316
 
317
  def infer_category(text: str, intent: str = "", entities: Optional[dict] = None) -> str:
 
604
  remove_words = [
605
  "عايز", "عايزه", "عايزة", "عاوز", "عاوزه", "عاوزة", "محتاج", "محتاجة", "نفسي",
606
  "في", "فين", "مطعم", "مطاعم", "كافيه", "مكان", "بيعمل", "بتعمل", "عنده", "عندها",
607
+ "فيه", "فيها", "بيقدم", "بتقدم", "لو سمحت", "ممكن", "من فضلك", "قريب", "اقرب",
608
  ]
609
  for w in remove_words:
610
  q = re.sub(rf"\b{re.escape(clean_text(w))}\b", " ", q)
 
631
  return cleaned
632
 
633
 
634
+
635
+
636
+ def is_generic_category_item(value: str) -> bool:
637
+ q = clean_text(value)
638
+ generic = {
639
+ "مطعم", "مطاعم", "اكل", "أكل", "مكان",
640
+ "كافيه", "كافيهات", "قهوه", "قهوة", "كوفي",
641
+ "صيدليه", "صيدلية", "صيدليات", "صيدله",
642
+ "ماركت", "سوبر", "سوبرماركت", "سوبر ماركت",
643
+ }
644
+ return q in {clean_text(x) for x in generic}
645
+
646
  def extract_menu_place_name(text: str) -> str:
647
  t = clean_text(text)
648
  for w in ["هات", "ابعت", "وريني", "اعرض", "منيو", "مينيو", "menu", "بتاع", "قائمة"]:
 
698
  stop_phrases = [
699
  "فين", "مكان", "مكانه", "مكانها", "عنوان", "عنوانه", "عنوانها", "لوكيشن", "رقم", "تليفون",
700
  "مطعم", "صيدليه", "صيدلية", "صديليه", "كافيه", "ماركت", "سوبر", "سوبرماركت", "هات", "منيو", "عايز", "عايزة",
701
+ "عايزه", "عاوزه", "عاوز", "اروح", "ازاي", "قريب", "اقرب", "مفتوح", "فاتح", "دلوقتي", "في", "من", "عند",
702
  "رقمه", "رقمها", "تليفونه", "تليفونها", "ايه", "إيه", "احسن", "أحسن", "افضل", "أفضل", "اعلى", "أعلى", "توب", "التوب",
703
  "طب", "طيب", "تمام", "ماشي",
704
+ "مواعيد", "معاد", "ميعاد", "بيفتح", "بتفتح", "بيقفل", "بتقفل", "امتى", "امتي", "شغال", "شغاله", "شغالة",
705
  ]
706
  for w in stop_phrases:
707
  t = re.sub(rf"\b{re.escape(clean_text(w))}\b", " ", t)
 
712
  return ""
713
  if infer_category(t) and len(t.split()) <= 2:
714
  return ""
715
+ # English place names like "on public عايزه" or "lamera مواعيد" should beat old context.
716
+ if re.search(r"[a-zA-Z]", t) and len(t.split()) <= 5:
717
+ return t
718
+ if any(w in original for w in ["فين", "مكان", "عنوان", "لوكيشن", "منيو", "رقم", "مواعيد", "بيفتح", "بتفتح"]) and len(t.split()) <= 5:
719
  return t
720
  return ""
721
 
 
774
  return has_any(text, BEST_WORDS) or intent in {"best_of", "recommend_place"}
775
 
776
 
777
+ def is_best_near_query(text: str, intent: str = "") -> bool:
778
+ return is_best_query(text, intent) and is_near_query(text)
779
+
780
+
781
  def compare_place_names(text: str) -> list:
782
  t = clean_text(text)
783
  for w in ["قارن", "قارني", "مقارنة", "بين", "ما بين", "مين احسن", "احسن"]:
 
812
  return result.reset_index(drop=True)
813
 
814
 
815
+ def name_search(place_name: str, category: str = "", top_k: int = 12) -> pd.DataFrame:
816
  if places_df is None or places_df.empty or not place_name:
817
  return pd.DataFrame()
818
  q = clean_text(place_name)
819
  q_words = [w for w in q.split() if len(w) > 1]
820
  df = places_df.copy()
821
  if category:
822
+ df = df[df["category_norm"].apply(lambda c: category_matches(c, category))]
 
 
 
823
  if df.empty:
824
  return df
825
 
 
845
  if df is None or df.empty:
846
  return pd.DataFrame()
847
  f = df.copy()
848
+ if category and "category_norm" in f.columns:
849
+ f = f[f["category_norm"].apply(lambda c: category_matches(c, category))]
 
 
 
850
  if location and not f.empty:
851
  loc = clean_text(location)
852
  parts = [p for p in loc.split() if len(p) > 1]
 
903
  if col not in f.columns:
904
  f[col] = default
905
  f[col] = pd.to_numeric(f[col], errors="coerce").fillna(default)
906
+ if best and near and user_lat is not None and user_lon is not None:
907
+ f["final_score"] = 0.70 * f["rating_score"] + 0.30 * f["distance_score"]
908
+ return f.sort_values(["final_score", "rating_count", "semantic_score"], ascending=[False, False, False]).reset_index(drop=True)
909
  if near and user_lat is not None and user_lon is not None:
910
  return f.sort_values(["distance_km", "rating_norm"], ascending=[True, False], na_position="last").reset_index(drop=True)
911
  if best:
 
918
  f["final_score"] = 0.45 * f["semantic_score"] + 0.25 * f["rating_score"] + 0.15 * f["distance_score"] + 0.10 * f["open_score"] + 0.05 * f["name_score"]
919
  return f.sort_values("final_score", ascending=False).reset_index(drop=True)
920
 
921
+ def search_places(query: str, category: str = "", item_query: str = "", location: str = "", price_range: str = "", open_now_only: bool = False, place_name: str = "", user_lat=None, user_lon=None, intent: str = "", top_k: int = 12) -> pd.DataFrame:
922
  category = normalize_category(category)
923
  if item_query and not category:
924
  category = intended_item_category(item_query) or "restaurant"
 
947
  out["semantic_score"] = 0.80
948
  out = apply_filters(out, category=category or "restaurant", location="", price_range=price_range, open_now_only=open_now_only)
949
  return rank_places(out, query, user_lat, user_lon, intent).drop_duplicates("place_id").head(top_k).reset_index(drop=True)
950
+ # For broad category requests ("عايز كافيه", "احسن مطعم"), use the live DB directly.
951
+ # Semantic threshold can wrongly hide valid category results, especially mixed categories like restaurant & café.
952
+ if category and places_df is not None and not places_df.empty:
953
+ base = apply_filters(places_df.copy(), category=category, location=location, price_range=price_range, open_now_only=open_now_only)
954
+ if not base.empty:
955
+ if "semantic_score" not in base.columns:
956
+ base["semantic_score"] = 0.80
957
+ return rank_places(base, query, user_lat, user_lon, intent).drop_duplicates("place_id").head(top_k).reset_index(drop=True)
958
+
959
+ # Pure category queries like "عايزه كافيه" or "احسن مطعم" should not be killed by semantic threshold.
960
+ if category and not item_query and not place_name:
961
  base = places_df.copy() if places_df is not None else pd.DataFrame()
962
  filtered = apply_filters(base, category=category, location=location, price_range=price_range, open_now_only=open_now_only)
963
  if not filtered.empty:
964
  if "semantic_score" not in filtered.columns:
965
+ filtered["semantic_score"] = 0.75
966
  return rank_places(filtered, query, user_lat, user_lon, intent).drop_duplicates("place_id").head(top_k).reset_index(drop=True)
967
 
968
  candidates = semantic_candidates(query, top_k=SEMANTIC_TOP_K)
 
1009
  return random.choice(templates)
1010
 
1011
 
1012
+ def build_response(results: List[dict], intent: str, category: str = "", item_query: str = "", place_name: str = "", location: str = "", open_now_only: bool = False, soft_reason: str = "", query: str = "") -> str:
1013
  category = normalize_category(category)
1014
  if not results:
1015
  if place_name:
 
1029
  header = _dynamic_reply("area_fallback", category=category, location=location)
1030
  elif item_query:
1031
  header = _dynamic_reply("item", category=category or "restaurant", item_query=item_query)
1032
+ elif is_best_near_query(query, intent):
1033
+ header = _dynamic_reply("best_near", category=category, best_place=results[0])
1034
+ elif is_near_query(query) or intent in {"nearby_search", "nearest_restaurant", "nearest_cafe", "nearest_pharmacy", "nearest_supermarket"}:
1035
  header = _dynamic_reply("nearest", category=category, best_place=results[0])
1036
+ elif is_best_query(query, intent):
1037
  header = _dynamic_reply("best", category=category, best_place=results[0])
1038
  elif open_now_only:
1039
  header = _dynamic_reply("open", category=category, best_place=results[0])
 
1041
  header = _dynamic_reply("one", category=category, best_place=results[0])
1042
  else:
1043
  header = _dynamic_reply("category", category=category, best_place=results[0])
1044
+ if len(results) > MAX_CARDS:
1045
+ header += "\n\nفي أماكن تانية كمان، اكتبي «كمان» لو عايزاهم."
1046
  return header
1047
 
1048
  def handle_detail(text: str, place: Optional[dict]) -> str:
 
1132
  self.last_place = place
1133
  if results:
1134
  self.last_results = results
1135
+ # First response shows MAX_CARDS cards; "كمان" starts after them.
1136
+ self.result_pointer = min(MAX_CARDS, len(results))
1137
  for key in ["category", "location", "price_range", "item_query", "place_name"]:
1138
  if entities and entities.get(key):
1139
  self.context_slots[key] = entities[key]
 
1171
 
1172
  ref_type = detect_ref_type(text)
1173
  if ref_type == "next" and session.last_results:
1174
+ start = int(session.result_pointer or 0)
1175
+ if start >= len(session.last_results):
1176
  reply = "دي كانت كل النتائج المتاحة حاليًا 😊\nلو حابب، جرّب منطقة تانية أو نوع تاني."
1177
  session.add(text, reply, "show_more", session.last_entities, session.last_place, session.last_results)
1178
+ session.result_pointer = len(session.last_results)
1179
  return {"reply": reply, "intent": "show_more", "confidence": 1.0, "entities": session.last_entities, "best_place": session.last_place, "all_results": []}
1180
+ end = min(start + MAX_CARDS, len(session.last_results))
1181
+ batch = session.last_results[start:end]
1182
+ session.result_pointer = end
1183
+ place = batch[0] if batch else session.last_place
1184
+ reply = "دي أماكن تانية كمان 👇"
1185
+ if session.result_pointer < len(session.last_results):
1186
+ reply += "\nوفيه كمان لو حبيتي."
1187
+ else:
1188
+ reply += "\nدي آخر الأماكن المتاحة عندي حاليًا."
1189
  session.add(text, reply, session.last_intent or "show_more", session.last_entities, place, session.last_results)
1190
+ session.result_pointer = end
1191
+ return {"reply": reply, "intent": "show_more", "confidence": 1.0, "entities": session.last_entities, "best_place": place, "all_results": batch}
1192
 
1193
  if ref_type == "detail" and session.last_place and not extract_place_name_from_text(text):
1194
  reply = handle_detail(text, session.last_place)
 
1204
  session.add(text, reply, "missing_info", entities, None, [])
1205
  return {"reply": reply, "intent": "missing_info", "confidence": confidence, "entities": entities, "best_place": None, "all_results": []}
1206
  category = infer_category(text, intent, entities)
 
1207
  if category:
1208
  entities["category"] = category
1209
  location_from_text = entities.get("location", "")
 
1270
  return {"reply": reply, "intent": "show_menu", "confidence": confidence, "entities": entities, "best_place": place, "all_results": [place], "menu_items": items}
1271
 
1272
  if intent == "place_details" and entities.get("place_name"):
1273
+ hits = name_search(entities["place_name"], category=category, top_k=1)
 
 
1274
  if not hits.empty:
1275
  place = row_to_dict(hits.iloc[0])
1276
  reply = handle_detail(text, place)
 
1278
  return {"reply": reply, "intent": intent, "confidence": confidence, "entities": entities, "best_place": place, "all_results": [place]}
1279
 
1280
  merged = session.merge(entities)
1281
+ if not category and merged.get("category"):
 
 
 
1282
  category = merged.get("category")
1283
  entities["category"] = category
1284
  item_query = entities.get("item_query", "")
1285
+ if item_query and is_generic_category_item(item_query):
1286
+ item_query = ""
1287
+ entities.pop("item_query", None)
1288
+ place_name = entities.get("place_name", "")
1289
  location = entities.get("location") or merged.get("location", "")
1290
  price_range = entities.get("price_range", "")
1291
  if intent == "search_by_item" and not item_query:
1292
  item_query = extract_item_query(text)
1293
  if item_query:
1294
  entities["item_query"] = item_query
 
 
 
 
 
 
 
1295
  if item_query and not category:
1296
  category = intended_item_category(item_query) or "restaurant"
1297
  entities["category"] = category
 
1305
  category = infer_category(text) or "cafe"
1306
  entities["category"] = category
1307
 
1308
+ results_df = search_places(text, category=category, item_query=item_query, location=location, price_range=price_range, open_now_only=open_now_only, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=12)
1309
  soft_reason = ""
1310
  if results_df.empty and open_now_only and category:
1311
+ results_df = search_places(text, category=category, item_query=item_query, location=location, price_range=price_range, open_now_only=False, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=12)
1312
  soft_reason = "open" if not results_df.empty else ""
1313
  if results_df.empty and location and category:
1314
+ results_df = search_places(text, category=category, item_query=item_query, location="", price_range=price_range, open_now_only=False, place_name=place_name, user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=12)
1315
  soft_reason = "area" if not results_df.empty else soft_reason
1316
  if results_df.empty and item_query and is_near_query(text):
1317
+ results_df = search_places(text, category=category or "restaurant", item_query=item_query, location="", price_range=price_range, open_now_only=False, place_name="", user_lat=user_lat, user_lon=user_lon, intent=intent, top_k=12)
1318
  soft_reason = "near_item" if not results_df.empty else soft_reason
1319
  results = [row_to_dict(r) for _, r in results_df.iterrows()] if not results_df.empty else []
1320
  best = results[0] if results else None
1321
  result_intent = intent if results else "no_result"
1322
+ reply = build_response(results, intent, category=category, item_query=item_query, place_name=place_name, location=location, open_now_only=open_now_only, soft_reason=soft_reason, query=text)
1323
  session.add(text, reply, result_intent, entities, best, results)
1324
  return {"reply": reply, "intent": result_intent, "confidence": confidence, "entities": entities, "best_place": best, "all_results": results}
1325
  @asynccontextmanager
 
1539
  session_id=req.session_id,
1540
  ui_type=ui_type,
1541
  best_place=unique_cards[0] if unique_cards else None,
1542
+ place_cards=unique_cards[:MAX_CARDS],
1543
  menu_items=menu_items,
1544
  )
1545