Add Cloud Tour Libo knowledge graph platform

This commit is contained in:
2026-07-30 10:08:04 +08:00
parent 9e05b09a38
commit bae5197d62
103 changed files with 14036 additions and 160 deletions

View File

@@ -9,17 +9,123 @@ import time
from typing import Any
import h3
from falkordb import FalkorDB
from fastapi import APIRouter, Depends, HTTPException
from app.auth import CurrentUser
from app.config import settings
from app.db import get_agent_settings, get_plaza_overview, get_plaza_alerts, get_conn
from app.graph_qa_engine import answer_graph_question
from app.llm_client import LlmClient
from app.project_context import ProjectContext, get_project_context
router = APIRouter()
SPATIAL_GRAPH_NAME = "guiyang_spatial_v1"
LIBO_BUS_DATASET = "libo_bus_xlsx_v1"
FOOD_ENRICHMENT_KEYS = (
"food_fusion_status",
"food_match_confidence",
"food_match_rule",
"food_match_score",
"food_name_similarity",
"food_address_similarity",
"food_coordinate_distance_m",
"dianping_shop_id",
"dianping_name",
"dianping_address",
"dianping_url",
"dianping_rating",
"dianping_review_count",
"dianping_avg_price",
"dianping_area",
"dianping_category",
"dianping_score_details",
"dianping_ranking",
"dianping_business_status",
"dianping_business_hours",
"dianping_tags",
"dianping_transportation",
"dianping_shop_image",
"dianping_review_tags",
"recommended_dish_count",
"recommended_dish_names",
"group_buy_count",
"group_buy_min_price",
"group_buy_max_price",
"group_buy_titles",
"group_buy_items_json",
"review_sample_count",
"review_sample_avg_stars",
"review_sample_summary",
"food_review_items_json",
"food_review_tags_json",
"menu_image_count",
"candidate_dianping_name",
"food_candidate_name_similarity",
"food_candidate_address_similarity",
"enrichment_updated_at",
)
HOTEL_ENRICHMENT_KEYS = (
"hotel_fusion_status",
"hotel_match_confidence",
"hotel_match_rule",
"hotel_name_similarity",
"hotel_address_similarity",
"hotel_phone_exact_match",
"ctrip_hotel_id",
"ctrip_name_cn",
"ctrip_name_en",
"ctrip_address",
"ctrip_phone",
"ctrip_star_level",
"ctrip_diamond_level",
"ctrip_opened_year",
"ctrip_room_count",
"ctrip_hotel_type",
"ctrip_ranking",
"ctrip_rating",
"ctrip_rating_description",
"ctrip_review_count",
"ctrip_transportation",
"ctrip_tags",
"ctrip_popular_facilities",
"ctrip_score_details",
"ctrip_cleanliness_score",
"ctrip_facilities_score",
"ctrip_environment_score",
"ctrip_service_score",
"ctrip_description",
"ctrip_url",
"room_type_count",
"room_type_names",
"room_min_price",
"room_max_price",
"offer_count",
"offer_min_price",
"offer_max_price",
"room_items_json",
"facility_service_count",
"facility_summary",
"facility_category_items_json",
"policy_count",
"policy_summary",
"guest_review_sample_count",
"guest_review_sample_avg_score",
"latest_review_at",
"review_travel_types",
"review_items_json",
"hotel_image_samples",
"nearby_place_count",
"nearby_place_summary",
"nearby_category_items_json",
"candidate_ctrip_name",
"hotel_candidate_name_similarity",
"hotel_candidate_address_similarity",
"enrichment_updated_at",
)
CATEGORY_ALIASES: dict[str, list[str]] = {
"美食": ["美食", "", "餐厅", "饭店", "火锅", "小吃", "烧烤", "咖啡", "奶茶", "酸汤鱼"],
@@ -313,13 +419,463 @@ async def amap_config(_user: CurrentUser = None):
}
def _is_enterprise_travel_graph(graph_name: str) -> bool:
lower = graph_name.lower()
return "baixinghui" in lower or ("travel" in lower and graph_name != SPATIAL_GRAPH_NAME)
def _resolve_spatial_graph_name(graph_name: str) -> str:
"""Map the city project graph name to the materialized spatial POI graph."""
lower = graph_name.lower()
if graph_name == SPATIAL_GRAPH_NAME or lower.endswith("_spatial_v1"):
return graph_name
if graph_name == "guiyang_new2":
return SPATIAL_GRAPH_NAME
return graph_name
def _iso_datetime(value: Any) -> str:
return value.isoformat() if value else ""
def _build_libo_bus_route_payload(
rows: list[list[Any]],
graph_name: str,
) -> dict[str, Any]:
routes: dict[str, dict[str, Any]] = {}
stop_ids: set[str] = set()
for row in rows:
(
route_id,
line_name,
direction,
start_stop,
end_stop,
stop_count,
fare_yuan,
first_bus,
last_bus,
distance_km,
path_source,
stop_id,
stop_name,
lng,
lat,
sequence,
) = row
route_key = str(route_id or "")
if not route_key:
continue
route = routes.setdefault(
route_key,
{
"route_id": route_key,
"line_name": str(line_name or ""),
"direction": str(direction or ""),
"start_stop": str(start_stop or ""),
"end_stop": str(end_stop or ""),
"stop_count": int(stop_count or 0),
"fare_yuan": float(fare_yuan or 0),
"first_bus": str(first_bus or ""),
"last_bus": str(last_bus or ""),
"distance_km": float(distance_km or 0),
"path_source": str(path_source or "station_sequence"),
"stops": [],
},
)
if stop_id and lng is not None and lat is not None:
stop_key = str(stop_id)
stop_ids.add(stop_key)
route["stops"].append(
{
"stop_id": stop_key,
"name": str(stop_name or "未命名公交站"),
"lng": float(lng),
"lat": float(lat),
"sequence": int(sequence or 0),
}
)
items = sorted(
routes.values(),
key=lambda route: (
route["line_name"],
route["route_id"],
),
)
return {
"graph_name": graph_name,
"dataset": LIBO_BUS_DATASET,
"line_count": len({route["line_name"] for route in items}),
"direction_count": len(items),
"stop_count": len(stop_ids),
"route_stop_count": sum(len(route["stops"]) for route in items),
"items": items,
}
def _read_libo_bus_routes(graph_name: str) -> dict[str, Any]:
graph = FalkorDB(
host=settings.falkordb_host,
port=settings.falkordb_port,
).select_graph(graph_name)
rows = graph.query(
"MATCH (b:BusLine) WHERE b.dataset=$dataset "
"OPTIONAL MATCH (b)-[r:STOPS_AT]->(s:Place) "
"RETURN b.route_id,b.line_name,b.direction,b.start_stop,b.end_stop,"
"b.stop_count,b.fare_yuan,b.first_bus,b.last_bus,b.distance_km,"
"b.path_source,s.element_id,s.name,s.lng,s.lat,r.sequence "
"ORDER BY b.line_name,b.route_id,r.sequence",
{"dataset": LIBO_BUS_DATASET},
).result_set
return _build_libo_bus_route_payload(rows, graph_name)
_INTERNAL_ENRICHMENT_KEYS = {
"food_fusion_status",
"food_match_confidence",
"food_match_rule",
"food_match_score",
"food_name_similarity",
"food_address_similarity",
"food_coordinate_distance_m",
"candidate_dianping_name",
"food_candidate_name_similarity",
"food_candidate_address_similarity",
"hotel_fusion_status",
"hotel_match_confidence",
"hotel_match_rule",
"hotel_name_similarity",
"hotel_address_similarity",
"hotel_phone_exact_match",
"candidate_ctrip_name",
"hotel_candidate_name_similarity",
"hotel_candidate_address_similarity",
"ctrip_description",
}
_ZERO_MEANS_MISSING_ENRICHMENT_KEYS = {
"dianping_rating",
"dianping_review_count",
"dianping_avg_price",
"group_buy_count",
"review_sample_count",
"review_sample_avg_stars",
"ctrip_rating",
"ctrip_review_count",
"ctrip_room_count",
"room_min_price",
"room_max_price",
"offer_count",
"offer_min_price",
"offer_max_price",
"guest_review_sample_count",
"guest_review_sample_avg_score",
}
def _clean_enrichment_display_value(key: str, value: Any) -> Any:
"""Convert scraped platform values into stable, user-facing display values."""
if key in _INTERNAL_ENRICHMENT_KEYS:
return None
if key in _ZERO_MEANS_MISSING_ENRICHMENT_KEYS:
try:
if float(value) == 0:
return None
except (TypeError, ValueError):
pass
if key == "ctrip_diamond_level":
text = str(value or "").strip()
if re.fullmatch(r"\d+(?:\.\d+)?钻", text):
return text
matched = re.fullmatch(
r"(\d+(?:\.\d+)?)\s+out\s+of\s+5\s+(?:rating|diamonds?)",
text,
flags=re.IGNORECASE,
)
return f"{matched.group(1)}" if matched else None
return value
def _graph_poi_enrichment(
graph_name: str,
element_id: str,
) -> tuple[list[str], dict[str, Any], dict[str, Any]]:
graph = FalkorDB(
host=settings.falkordb_host,
port=settings.falkordb_port,
).select_graph(graph_name)
result = graph.query(
"MATCH (n {element_id:$element_id}) "
"RETURN labels(n), properties(n) LIMIT 1",
{"element_id": element_id},
).result_set
if not result:
return [], {}, {}
labels = [str(label) for label in (result[0][0] or [])]
properties = result[0][1] if isinstance(result[0][1], dict) else {}
def pick(keys: tuple[str, ...]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key in keys:
if key not in properties:
continue
value = _clean_enrichment_display_value(key, properties[key])
if value in (None, "", "[]", "{}"):
continue
result[key] = value
return result
return labels, pick(FOOD_ENRICHMENT_KEYS), pick(HOTEL_ENRICHMENT_KEYS)
def _graph_poi_categories(graph_name: str) -> dict[str, list[str]]:
graph = FalkorDB(
host=settings.falkordb_host,
port=settings.falkordb_port,
).select_graph(graph_name)
rows = graph.query(
"MATCH (n) "
"WHERE n.element_id IS NOT NULL "
"AND any(label IN labels(n) WHERE label IN "
"['FoodPlace','Hotel','ScenicSpot','TransitFacility']) "
"RETURN n.element_id, labels(n)"
).result_set
label_names = {
"FoodPlace": "美食",
"Hotel": "酒店",
"ScenicSpot": "景点",
"TransitFacility": "交通设施",
}
result: dict[str, list[str]] = {}
for element_id, labels in rows:
categories = [
label_names[label]
for label in (labels or [])
if label in label_names
]
if element_id and categories:
result[str(element_id)] = categories
return result
@router.get("/plaza/map-pois")
async def map_pois(
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
"""Return lightweight, project-scoped POI points for the knowledge map."""
graph_name = _resolve_spatial_graph_name(context.graph_name)
s = settings.db_schema
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"""SELECT element_id, gaode_poi_id, name, type_label, place_type,
lng, lat, address, district, city
FROM {s}.amap_spatial_pois
WHERE graph_name=%s
ORDER BY type_label, name
LIMIT 15000""",
(graph_name,),
)
rows = await cur.fetchall()
graph_categories = await asyncio.to_thread(
_graph_poi_categories,
graph_name,
)
items = [
{
"id": row["element_id"],
"gaode_poi_id": row["gaode_poi_id"],
"name": row["name"] or "未命名POI",
"category": row["type_label"] or "其他地点",
"categories": graph_categories.get(
str(row["element_id"]),
[row["type_label"] or "其他地点"],
),
"place_type": row["place_type"] or "poi",
"lng": float(row["lng"]),
"lat": float(row["lat"]),
"address": row["address"] or "",
"district": row["district"] or "",
"city": row["city"] or "",
}
for row in rows
if row.get("lng") is not None and row.get("lat") is not None
]
category_counts: dict[str, int] = {}
for item in items:
for category in item["categories"]:
category_counts[category] = category_counts.get(category, 0) + 1
return {
"graph_name": graph_name,
"total": len(items),
"categories": [
{"category": category, "count": count}
for category, count in sorted(
category_counts.items(),
key=lambda pair: (-pair[1], pair[0]),
)
],
"items": items,
}
@router.get("/plaza/bus-routes")
async def bus_routes(
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
"""Return the active project's graph-backed bus routes and ordered stops."""
graph_name = _resolve_spatial_graph_name(context.graph_name)
return await asyncio.to_thread(_read_libo_bus_routes, graph_name)
@router.get("/plaza/map-pois/{place_id}")
async def map_poi_detail(
place_id: str,
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
"""Return useful detail fields for one POI in the active project."""
graph_name = _resolve_spatial_graph_name(context.graph_name)
s = settings.db_schema
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"""SELECT element_id, gaode_poi_id, name, type_label, place_type,
amap_type, typecode, lng, lat, province, city, district,
adcode, business_area, address, tel, open_time, rating,
cost, level, tags, photo_urls, source, source_cell_id,
source_resolution, source_scope_adcode,
raw_jsonb,
first_fetched_at, last_fetched_at
FROM {s}.amap_spatial_pois
WHERE graph_name=%s
AND (element_id=%s OR gaode_poi_id=%s)
LIMIT 1""",
(graph_name, place_id, place_id.removeprefix("amap:")),
)
row = await cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="当前项目中未找到该POI")
raw = row.get("raw_jsonb") or {}
if not isinstance(raw, dict):
raw = {}
graph_labels, food_enrichment, hotel_enrichment = await asyncio.to_thread(
_graph_poi_enrichment,
graph_name,
row["element_id"],
)
external_photos = _photo_urls(food_enrichment.get("dianping_shop_image"))
external_photos.extend(_photo_urls(hotel_enrichment.get("hotel_image_samples")))
photo_urls = list(dict.fromkeys([
*_photo_urls(row["photo_urls"]),
*external_photos,
]))
return {
"graph_name": graph_name,
"id": row["element_id"],
"gaode_poi_id": row["gaode_poi_id"],
"name": row["name"] or "未命名POI",
"category": row["type_label"] or "其他地点",
"place_type": row["place_type"] or "poi",
"business_subcategory": raw.get("business_subcategory") or "",
"scenic_type": raw.get("scenic_type") or "",
"scenic_level": raw.get("scenic_level") or "",
"parent_scenic": raw.get("parent_scenic") or "",
"scenic_grade": raw.get("scenic_grade") or "",
"visitor_value": raw.get("visitor_value") or "",
"audit_result": raw.get("audit_result") or "",
"audit_confidence": raw.get("audit_confidence") or "",
"audit_basis": raw.get("audit_basis") or "",
"audit_date": raw.get("audit_date") or "",
"amap_type": row["amap_type"] or "",
"typecode": row["typecode"] or "",
"scan_hit_count": raw.get("scan_hit_count") or 0,
"matched_scan_types": raw.get("matched_scan_types") or [],
"lng": float(row["lng"]),
"lat": float(row["lat"]),
"province": row["province"] or "",
"city": row["city"] or "",
"district": row["district"] or "",
"adcode": row["adcode"] or "",
"business_area": row["business_area"] or "",
"address": row["address"] or "",
"tel": row["tel"] or "",
"open_time": row["open_time"] or "",
"rating": row["rating"] or "",
"cost": row["cost"] or "",
"level": row["level"] or "",
"tags": row["tags"] or "",
"photo_urls": photo_urls,
"source": row["source"] or "",
"source_cell_id": row["source_cell_id"] or "",
"source_resolution": row["source_resolution"],
"source_scope_adcode": row["source_scope_adcode"] or "",
"first_fetched_at": _iso_datetime(row["first_fetched_at"]),
"last_fetched_at": _iso_datetime(row["last_fetched_at"]),
"graph_labels": graph_labels,
"food_enrichment": food_enrichment,
"hotel_enrichment": hotel_enrichment,
}
@router.post("/plaza/user-query")
async def user_query(body: dict, _user: CurrentUser = None):
async def user_query(
body: dict,
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
"""User-facing KG nearby query: NL intent -> H3 recall -> distance/rank -> LLM answer."""
started_at = time.perf_counter()
question = str(body.get("question") or "").strip()
if not question:
raise HTTPException(400, "question required")
graph_name = str(body.get("graph_name") or context.graph_name or SPATIAL_GRAPH_NAME)
if _is_enterprise_travel_graph(graph_name):
try:
graph_response = await answer_graph_question(
question,
graph_name,
customer_context=body.get("customer_context") if isinstance(body.get("customer_context"), dict) else {},
limit=int(body.get("limit") or 80),
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"LLM 图查询问答失败: {str(exc)[:220]}") from exc
trace = graph_response.get("trace") or {}
trace.update({
"latency_ms": max(1, round((time.perf_counter() - started_at) * 1000)),
"rule_query_used": False,
"plaza_user_mode": "enterprise_graph_qa",
})
return {
"mode": "travel_customer_service",
"question": question,
"graph_name": graph_name,
"user_location": {
"lng": float(body.get("lng", 106.7135) or 106.7135),
"lat": float(body.get("lat", 26.5744) or 26.5744),
},
"intent": {
"radius_m": int(body.get("radius_m") or 1000),
"category": "百姓惠客服",
"keywords": [],
"user_need": question,
},
"answer": graph_response.get("copy_text") or graph_response.get("answer") or "",
"results": [],
"plans": graph_response.get("plans") or [],
"evidence": graph_response.get("evidence") or [],
"follow_up_questions": graph_response.get("follow_up_questions") or [],
"risk_notes": graph_response.get("risk_notes") or [],
"trace": trace,
}
graph_name = _resolve_spatial_graph_name(graph_name)
try:
lng = float(body.get("lng", 106.7135))
lat = float(body.get("lat", 26.5744))
@@ -327,8 +883,6 @@ async def user_query(body: dict, _user: CurrentUser = None):
raise HTTPException(400, "lng/lat required") from exc
radius_input = body.get("radius_m")
radius_m = int(radius_input) if radius_input not in (None, "") else None
graph_name = str(body.get("graph_name") or SPATIAL_GRAPH_NAME)
use_llm = body.get("use_llm") is True
fallback = _rule_intent(question, radius_m)
if use_llm: