phamluan commited on
Commit
d0182f2
·
verified ·
1 Parent(s): 0a6e343

Deploy RAG Tourism API

Browse files
Files changed (3) hide show
  1. .idea/workspace.xml +1 -1
  2. app/main.py +82 -5
  3. app/services/kg_service.py +53 -0
.idea/workspace.xml CHANGED
@@ -152,7 +152,7 @@
152
  <workItem from="1780588755460" duration="248000" />
153
  <workItem from="1780738673328" duration="6034000" />
154
  <workItem from="1780756419437" duration="9915000" />
155
- <workItem from="1780806816923" duration="2590000" />
156
  </task>
157
  <servers />
158
  </component>
 
152
  <workItem from="1780588755460" duration="248000" />
153
  <workItem from="1780738673328" duration="6034000" />
154
  <workItem from="1780756419437" duration="9915000" />
155
+ <workItem from="1780806816923" duration="5651000" />
156
  </task>
157
  <servers />
158
  </component>
app/main.py CHANGED
@@ -516,6 +516,52 @@ async def search_locations(query: TourismQuery, background_tasks: BackgroundTask
516
  logger.info(f"Ward search term: '{ward_search_term}' -> normalized: '{ward_name_clean}'")
517
 
518
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  if intent_info['predicted_intent'] == 'ask_old_name':
520
  # Query: "Phường X cũ tên là gì?"
521
  old_names_result = kg_service.get_old_ward_names(ward_name_clean)
@@ -783,6 +829,36 @@ async def search_locations(query: TourismQuery, background_tasks: BackgroundTask
783
  except Exception as e:
784
  logger.warning(f"KG ward lookup failed: {e}")
785
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
786
  # Step 3: Knowledge Graph search (for tourism locations)
787
  if kg_service:
788
  kg_results = kg_service.search_locations_by_name(main_search_term, limit=10)
@@ -813,10 +889,11 @@ async def search_locations(query: TourismQuery, background_tasks: BackgroundTask
813
  # Boost PhoBERT confidence for primary usage
814
  # Convert numpy.float32 to Python float to fix Pydantic serialization
815
  similarity_score = float(loc.get('similarity_score', 0.5))
816
- # Ngưỡng liên quan: loại kết quả vector quá xa nghĩa
817
- # (tránh trưng card 35% lạc tỉnh). Chỉnh qua env
818
- # MIN_VECTOR_SIMILARITY (mặc định 0.35 ~ confidence 45%).
819
- if similarity_score < float(_os.getenv("MIN_VECTOR_SIMILARITY", "0.35")):
 
820
  continue
821
  boosted_confidence = float(min(0.95, similarity_score + 0.1))
822
  results.append(LocationResponse(
@@ -842,7 +919,7 @@ async def search_locations(query: TourismQuery, background_tasks: BackgroundTask
842
  for loc in vector_results: # ChromaDB already limits results
843
  if loc['name'] and loc['name'].lower() not in existing_names:
844
  # Ngưỡng liên quan (đồng bộ với nhánh PhoBERT)
845
- if float(loc.get('similarity_score', 0.5)) < float(_os.getenv("MIN_VECTOR_SIMILARITY", "0.35")):
846
  continue
847
  # Convert numpy types to Python types for Pydantic serialization
848
  results.append(LocationResponse(
 
516
  logger.info(f"Ward search term: '{ward_search_term}' -> normalized: '{ward_name_clean}'")
517
 
518
  try:
519
+ # Cấp TỈNH trước (vd "Bình Phước thuộc tỉnh nào sau sáp nhập?").
520
+ # CHỈ chạy khi câu thực sự hỏi hành chính và KHÔNG chứa từ khóa
521
+ # du lịch — tránh "thác nước đẹp ở Đồng Nai" (lỡ bị định tuyến
522
+ # admin) trả nhầm ánh xạ tỉnh thay vì gợi ý thác.
523
+ _q_low = processed_text.lower()
524
+ _has_admin_cue = re.search(
525
+ r"(?i)(sáp nhập|sap nhap|thuộc tỉnh|thuoc tinh|đổi tên|tên mới|"
526
+ r"tên cũ|hợp nhất|sắp xếp|trực thuộc|tỉnh nào|còn tỉnh)", _q_low)
527
+ _has_tourism_kw = any(k in _q_low for k in [
528
+ "thác", "suối", "hồ", "chùa", "đền", "miếu", "núi", "biển",
529
+ "đảo", "chợ", "công viên", "bảo tàng", "nhà thờ", "du lịch",
530
+ "tham quan", "chơi", "ăn", "quán", "địa điểm"])
531
+ province_term = next(
532
+ (e.get('word') for e in (ner_entities or [])
533
+ if e.get('entity_group') == 'PROVINCE'), None
534
+ )
535
+ if province_term and _has_admin_cue and not _has_tourism_kw:
536
+ pm = kg_service.get_province_mapping(province_term)
537
+ if pm:
538
+ if pm['unchanged']:
539
+ summary = (f"Tỉnh {pm['old_name']} giữ nguyên tên sau sáp nhập "
540
+ f"(không đổi).")
541
+ else:
542
+ summary = (f"Tỉnh {pm['old_name']} nay thuộc tỉnh/thành "
543
+ f"{pm['new_name']} sau sáp nhập 2025.")
544
+ results.append(LocationResponse(
545
+ name=summary, lat=None, lng=None,
546
+ ward='', province=pm['new_name'],
547
+ confidence=0.97, source="administrative_kg",
548
+ ))
549
+ others = [o for o in pm['merged_from']
550
+ if o.strip().lower() != pm['old_name'].strip().lower()]
551
+ if others:
552
+ results.append(LocationResponse(
553
+ name=f"Tỉnh {pm['new_name']} mới gồm: "
554
+ + ", ".join(pm['merged_from']),
555
+ lat=None, lng=None, ward='', province=pm['new_name'],
556
+ confidence=0.9, source="administrative_kg",
557
+ ))
558
+ return SearchResponse(
559
+ query=query.text, results=results, total=len(results),
560
+ processing_time=time.time() - start_time,
561
+ search_method=search_method, intent_info=intent_info,
562
+ ner_entities=ner_entities,
563
+ )
564
+
565
  if intent_info['predicted_intent'] == 'ask_old_name':
566
  # Query: "Phường X cũ tên là gì?"
567
  old_names_result = kg_service.get_old_ward_names(ward_name_clean)
 
829
  except Exception as e:
830
  logger.warning(f"KG ward lookup failed: {e}")
831
 
832
+ # Step 3b: Tìm theo TỪ KHÓA LOẠI HÌNH + tỉnh ("suối/thác/chùa... ở Đồng Nai").
833
+ # Bắt từ khóa từ NER TOURISM hoặc từ danh sách head-noun trong câu.
834
+ if kg_service and len(results) < 5:
835
+ TOURISM_KEYWORDS = [
836
+ "thác", "suối", "hồ", "chùa", "đền", "miếu", "đình", "lăng",
837
+ "núi", "biển", "đảo", "chợ", "công viên", "bảo tàng", "nhà thờ",
838
+ "khu du lịch", "vườn quốc gia", "thiền viện", "căn cứ", "di tích",
839
+ ]
840
+ low = processed_text.lower()
841
+ kw = next((k for k in sorted(TOURISM_KEYWORDS, key=len, reverse=True) if k in low), None)
842
+ if kw:
843
+ prov = location_filter
844
+ try:
845
+ existing = {r.name.lower() for r in results}
846
+ for loc in kg_service.search_by_keyword(kw, province=prov, limit=10):
847
+ if loc.get('name') and loc['name'].lower() not in existing:
848
+ results.append(LocationResponse(
849
+ name=loc['name'],
850
+ lat=float(loc['lat']) if loc.get('lat') is not None else None,
851
+ lng=float(loc['lng']) if loc.get('lng') is not None else None,
852
+ ward=loc.get('ward') or '',
853
+ province=loc.get('province') or '',
854
+ confidence=0.9,
855
+ source="kg_keyword_match",
856
+ ))
857
+ if results:
858
+ logger.info(f"KG keyword match: '{kw}' (tỉnh={prov}) -> {len(results)} kết quả")
859
+ except Exception as e:
860
+ logger.warning(f"KG keyword search failed: {e}")
861
+
862
  # Step 3: Knowledge Graph search (for tourism locations)
863
  if kg_service:
864
  kg_results = kg_service.search_locations_by_name(main_search_term, limit=10)
 
889
  # Boost PhoBERT confidence for primary usage
890
  # Convert numpy.float32 to Python float to fix Pydantic serialization
891
  similarity_score = float(loc.get('similarity_score', 0.5))
892
+ # Ngưỡng liên quan: chỉ loại kết quả vector RẤT xa nghĩa
893
+ # (rác thật sự). Embedding này thang điểm nén ~0.25-0.45
894
+ # nên để thấp (0.2); việc phân loại "chính xác/gợi ý" do
895
+ # frontend đảm nhiệm. Chỉnh qua env MIN_VECTOR_SIMILARITY.
896
+ if similarity_score < float(_os.getenv("MIN_VECTOR_SIMILARITY", "0.2")):
897
  continue
898
  boosted_confidence = float(min(0.95, similarity_score + 0.1))
899
  results.append(LocationResponse(
 
919
  for loc in vector_results: # ChromaDB already limits results
920
  if loc['name'] and loc['name'].lower() not in existing_names:
921
  # Ngưỡng liên quan (đồng bộ với nhánh PhoBERT)
922
+ if float(loc.get('similarity_score', 0.5)) < float(_os.getenv("MIN_VECTOR_SIMILARITY", "0.2")):
923
  continue
924
  # Convert numpy types to Python types for Pydantic serialization
925
  results.append(LocationResponse(
app/services/kg_service.py CHANGED
@@ -342,6 +342,59 @@ class AdminKGService:
342
  """, name=ward_name.strip(), limit=limit)
343
  return [dict(r) for r in result]
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  def get_all_district_names(self) -> List[str]:
346
  """Distinct old district/city names (for fuzzy, diacritic-insensitive
347
  matching of user queries like 'quan binh thanh')."""
 
342
  """, name=ward_name.strip(), limit=limit)
343
  return [dict(r) for r in result]
344
 
345
+ def get_province_mapping(self, name: str) -> Optional[Dict]:
346
+ """Ánh xạ tỉnh cũ -> tỉnh mới sau sáp nhập (vd Bình Phước -> Đồng Nai).
347
+
348
+ Trả về tên cũ, tên mới và danh sách các tỉnh cũ khác cùng gộp vào tỉnh
349
+ mới đó. Khớp tên không phân biệt loại từ ('tỉnh ...').
350
+ """
351
+ bare = name.strip()
352
+ for pre in ("tỉnh ", "thành phố ", "tp ", "tp. "):
353
+ if bare.lower().startswith(pre):
354
+ bare = bare[len(pre):].strip()
355
+ with self.driver.session() as session:
356
+ rec = session.run("""
357
+ MATCH (p:Province)
358
+ WHERE toLower(p.old_name) = toLower($name)
359
+ RETURN p.old_name AS old_name, p.new_name AS new_name
360
+ LIMIT 1
361
+ """, name=bare).single()
362
+ if not rec:
363
+ return None
364
+ siblings = session.run("""
365
+ MATCH (p:Province)
366
+ WHERE toLower(p.new_name) = toLower($new)
367
+ RETURN collect(DISTINCT p.old_name) AS olds
368
+ """, new=rec["new_name"]).single()
369
+ return {
370
+ "old_name": rec["old_name"],
371
+ "new_name": rec["new_name"],
372
+ "merged_from": [o for o in (siblings["olds"] if siblings else []) if o],
373
+ "unchanged": rec["old_name"].strip().lower() == rec["new_name"].strip().lower(),
374
+ }
375
+
376
+ def search_by_keyword(self, keyword: str, province: str = None, limit: int = 10) -> List[Dict]:
377
+ """Tìm POI theo TỪ KHÓA loại hình (suối, thác, chùa, hồ...) trong tên,
378
+ tùy chọn lọc theo tỉnh (khớp cả tên tỉnh mới lẫn cũ qua district_city).
379
+
380
+ Khác search_locations_by_name (CONTAINS cả câu) — ở đây chỉ khớp 1 từ
381
+ khóa nên bắt được 'các suối đẹp ở Đồng Nai' -> POI tên chứa 'suối'.
382
+ """
383
+ with self.driver.session() as session:
384
+ result = session.run("""
385
+ MATCH (l:Location)-[:LOCATED_IN]->(w:Ward)-[:BELONGS_TO]->(p:Province)
386
+ WHERE toLower(l.name) CONTAINS toLower($kw)
387
+ AND ($prov IS NULL
388
+ OR toLower(coalesce(p.new_name,p.name)) CONTAINS toLower($prov)
389
+ OR toLower(w.district_city) CONTAINS toLower($prov))
390
+ RETURN l.name as name, l.lat as lat, l.lng as lng,
391
+ l.address as address, w.name as ward,
392
+ w.district_city as district_city,
393
+ coalesce(p.new_name,p.name) as province
394
+ LIMIT $limit
395
+ """, kw=keyword.strip(), prov=(province.strip() if province else None), limit=limit)
396
+ return [dict(r) for r in result]
397
+
398
  def get_all_district_names(self) -> List[str]:
399
  """Distinct old district/city names (for fuzzy, diacritic-insensitive
400
  matching of user queries like 'quan binh thanh')."""