Spaces:
Running
Running
Deploy RAG Tourism API
Browse files- .idea/workspace.xml +1 -1
- app/main.py +82 -5
- 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="
|
| 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
|
| 817 |
-
# (
|
| 818 |
-
#
|
| 819 |
-
|
|
|
|
| 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.
|
| 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 có 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')."""
|