"""STEP 02 — Knowledge Plaza overview, usage, alerts.""" from __future__ import annotations import asyncio import json import logging import math import re 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_conn, get_plaza_overview 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() logger = logging.getLogger(__name__) SPATIAL_GRAPH_NAME = "guiyang_spatial_v1" # Libo business zone set: 玉屏街道 12 surveyed city zones (A01–A12), # supersedes the earlier 18-anchor ZS-LB-YUPING-V1. LIBO_ZONE_SET_ID = "ZS-LB-YUPING-V2" LIBO_BUS_DATASET = "libo_bus_xlsx_v1" GRAPH_POI_LABELS = { "FoodPlace": "美食", "Hotel": "酒店", "ScenicSpot": "景点", "TransitFacility": "交通设施", "BusStop": "公交站", } # Projects imported from JSON do not have to use the historical Libo labels. # The city graph, for example, models every spatial business as ``Place`` and # stores its business type in ``place_type``. Keep this mapping in the map # adapter so every map-enabled project can use the same UI template. GRAPH_PLACE_TYPE_CATEGORIES = { "eat": "美食", "drink": "饮品", "hotel": "酒店", "mall": "商场", "sight": "景点", "transit": "交通设施", "transit_stop": "交通设施", "recreation": "体育休闲", "education": "科教文化", "medical": "医疗保健", "government": "政府机构", "enterprise": "公司企业", "life": "生活服务", "residential": "商务住宅", "facility": "公共设施", "poi": "其他地点", } # Keep bulk FalkorDB map reads deliberately small. Full imported graph nodes # can contain raw source payloads, image lists and enrichment results; returning # ``properties(n)`` for thousands of nodes can exhaust the query response or # time out even though a lightweight graph count succeeds. GRAPH_MAP_POI_PROJECTION_FIELDS = ( "element_id", "gaode_poi_id", "name", "type_label", "place_type", "lng", "longitude", "lat", "latitude", "address", "district", "city", "province", "adcode", "towncode", "town_name", "zone_id", "zone_name", "map_poi", "map_source_labels", "__kg_node_id", ) GRAPH_MAP_QUERY_TIMEOUT_MS = 30_000 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]] = { "美食": ["美食", "吃", "餐厅", "饭店", "火锅", "小吃", "烧烤", "咖啡", "奶茶", "酸汤鱼"], "景点": ["景点", "景区", "公园", "博物馆", "古镇", "夜游", "历史", "文化", "好玩"], "酒店": ["酒店", "住宿", "住", "宾馆", "民宿"], "商场": ["商场", "购物", "商圈", "超市", "商城"], "医疗保健": ["医院", "诊所", "药店", "医疗", "看病", "急诊"], "交通设施": ["地铁", "公交", "车站", "交通", "停车", "机场", "高铁"], "生活服务": ["生活服务", "维修", "营业厅", "服务"], "科教文化": ["学校", "大学", "图书馆", "教育", "培训"], } PLACE_TYPE_ALIASES = { "美食": "eat", "景点": "sight", "酒店": "hotel", "商场": "mall", "医疗保健": "medical", "交通设施": "transit", "生活服务": "life", "科教文化": "education", } def _haversine_m(lng1: float, lat1: float, lng2: float, lat2: float) -> float: radius = 6_371_008.8 d_lng = math.radians(lng2 - lng1) d_lat = math.radians(lat2 - lat1) part = ( math.sin(d_lat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(d_lng / 2) ** 2 ) return 2 * radius * math.asin(math.sqrt(part)) def _h3_plan(radius_m: int) -> tuple[int, str, int]: if radius_m <= 500: return 9, "h3_r9", 2 if radius_m <= 1000: return 9, "h3_r9", 4 if radius_m <= 3000: return 8, "h3_r8", 4 res = 7 edge_m = h3.average_hexagon_edge_length(res, unit="m") return res, "h3_r7", max(2, math.ceil(radius_m / (math.sqrt(3) * edge_m)) + 1) def _rule_intent(question: str, radius_m: int | None) -> dict[str, Any]: q = question.strip() radius = radius_m or 1000 km = re.search(r"(\d+(?:\.\d+)?)\s*(?:公里|千米|km)", q, flags=re.I) meter = re.search(r"(\d+(?:\.\d+)?)\s*(?:米|m)", q, flags=re.I) minutes = re.search(r"(\d+(?:\.\d+)?)\s*分钟", q) if km: radius = int(float(km.group(1)) * 1000) elif meter: radius = int(float(meter.group(1))) elif minutes: # 步行 15 分钟约 1.1~1.3km,先用保守半径召回,后续可接路线时长。 radius = min(3000, max(500, int(float(minutes.group(1)) * 80))) category = "" for cat, aliases in CATEGORY_ALIASES.items(): if any(a in q for a in aliases): category = cat break keywords = [ w for w in re.split(r"[,,。??!!\s]+", q) if w and not any(w in aliases for aliases in CATEGORY_ALIASES.values()) ][:6] return { "radius_m": max(100, min(radius, 10000)), "category": category, "keywords": keywords, "sort_preference": "综合距离、评分和语义匹配", "user_need": q, } async def _deepseek_client(max_tokens: int = 900) -> LlmClient | None: cfg = await get_agent_settings() extract = cfg.get("extract") or {} models = extract.get("models") or {} deepseek_cfg = models.get("deepseek") or {} if deepseek_cfg.get("base_url") and deepseek_cfg.get("api_key"): return LlmClient( deepseek_cfg["base_url"], deepseek_cfg["api_key"], deepseek_cfg.get("model") or "deepseek-chat", timeout=int(extract.get("timeout") or 60), max_tokens=max_tokens, ) global_cfg = cfg.get("global") or {} if global_cfg.get("base_url") and global_cfg.get("api_key"): return LlmClient( global_cfg["base_url"], global_cfg["api_key"], global_cfg.get("model") or "deepseek-chat", timeout=int(global_cfg.get("timeout") or 45), max_tokens=max_tokens, ) return None async def _llm_intent(question: str, fallback: dict[str, Any]) -> tuple[dict[str, Any], str]: client = await _deepseek_client(max_tokens=700) if not client: return fallback, "DeepSeek 未配置,使用规则解析" system = ( "你是城市知识图谱的游客问答意图解析器。只输出 JSON。" "把用户问题解析为 nearby POI 查询意图,类别只能从:" "美食、景点、酒店、商场、医疗保健、交通设施、生活服务、科教文化、空字符串 中选择。" "radius_m 为整数米,没说半径默认 1000。keywords 提取用户真正关心的语义词。" ) user = json.dumps({"question": question, "rule_fallback": fallback}, ensure_ascii=False) try: data = await asyncio.to_thread(client.chat_json, system, user) merged = {**fallback, **{k: v for k, v in data.items() if v not in (None, "")}} merged["radius_m"] = max(100, min(int(merged.get("radius_m") or fallback["radius_m"]), 10000)) if merged.get("category") not in CATEGORY_ALIASES: merged["category"] = fallback.get("category", "") if not isinstance(merged.get("keywords"), list): merged["keywords"] = fallback.get("keywords", []) return merged, "DeepSeek 意图解析" except Exception as exc: # noqa: BLE001 return fallback, f"DeepSeek 意图解析失败,使用规则解析:{str(exc)[:120]}" def _photo_urls(value: Any) -> list[str]: if isinstance(value, list): return [str(v) for v in value if v] if isinstance(value, str): try: parsed = json.loads(value) if isinstance(parsed, list): return [str(v) for v in parsed if v] except Exception: pass return [v.strip() for v in re.split(r"[|,]", value) if v.strip().startswith("http")] return [] def _rating_num(value: Any) -> float: try: return float(value or 0) except Exception: return 0.0 def _score_place(row: dict[str, Any], distance_m: float, radius_m: int, intent: dict[str, Any]) -> tuple[float, list[str]]: score = max(0.0, 55.0 * (1 - distance_m / max(radius_m, 1))) reasons = [f"距离约 {round(distance_m)} 米"] rating = _rating_num(row.get("rating")) if rating: score += min(rating, 5) * 7 reasons.append(f"评分 {rating:g}") category = intent.get("category") or "" if category and row.get("type_label") == category: score += 20 reasons.append(f"匹配类别「{category}」") haystack = " ".join( str(row.get(k) or "") for k in ("name", "address", "tags", "amap_type", "type_label") ) for kw in intent.get("keywords") or []: if kw and kw in haystack: score += 12 reasons.append(f"命中关键词「{kw}」") return round(score, 3), reasons[:4] async def _llm_answer(question: str, intent: dict[str, Any], results: list[dict[str, Any]]) -> tuple[str, dict[str, str], str]: if not results: return "当前已采集的知识图谱中,没有在这个半径内找到匹配结果。可以扩大半径,或等高德网格续采完成后再试。", {}, "no_candidates" client = await _deepseek_client(max_tokens=1200) if not client: first = results[0] return ( f"根据当前知识图谱,优先推荐 {first['name']},距离约 {round(first['distance_m'])} 米。" f"下面结果已按距离、类别匹配和评分综合排序。", {r["place_id"]: "距离近、类别匹配、来自当前空间知识图谱" for r in results[:8]}, "fallback_answer", ) compact = [ { "id": r["place_id"], "name": r["name"], "type": r["type_label"], "distance_m": r["distance_m"], "rating": r.get("rating"), "address": r.get("address"), "tags": r.get("tags"), "score": r.get("score"), } for r in results[:20] ] system = ( "你是面向游客的城市知识图谱问答助手。只输出 JSON。" "基于候选 POI 回答用户问题,不能编造候选中没有的地点。" "输出 answer 和 reasons,reasons 是 {候选id: 推荐理由}。" "回答要像真实产品结果页,简洁、可解释。" ) user = json.dumps({"question": question, "intent": intent, "candidates": compact}, ensure_ascii=False) try: data = await asyncio.to_thread(client.chat_json, system, user) answer = str(data.get("answer") or "").strip() reasons = data.get("reasons") if isinstance(data.get("reasons"), dict) else {} return answer or "已根据当前知识图谱完成附近结果排序。", {str(k): str(v) for k, v in reasons.items()}, "DeepSeek 回答排序" except Exception as exc: # noqa: BLE001 first = results[0] return ( f"根据当前知识图谱,优先推荐 {first['name']},距离约 {round(first['distance_m'])} 米。" f"DeepSeek 回答组织暂时失败,页面仍展示规则排序结果。", {}, f"DeepSeek 回答失败:{str(exc)[:120]}", ) def _rule_answer(question: str, intent: dict[str, Any], results: list[dict[str, Any]]) -> tuple[str, dict[str, str], str]: if not results: return "当前已采集的知识图谱中,没有在这个半径内找到匹配结果。可以扩大半径,或等采集完成后再试。", {}, "rule_fast_answer" category = intent.get("category") or "相关地点" first = results[0] answer = ( f"根据当前知识图谱,{category}共召回 {len(results)} 个候选;" f"优先推荐 {first['name']},距离约 {round(first['distance_m'])} 米。" "下方已按距离、类别匹配和评分综合排序。" ) reasons = { r["place_id"]: ";".join(r.get("rank_reasons") or ["距离、类别和评分综合靠前"]) for r in results[:12] } return answer, reasons, "rule_fast_answer" @router.get("/plaza/overview") async def overview( context: ProjectContext = Depends(get_project_context), _user: CurrentUser = None, ): return await get_plaza_overview(context.tenant_id, context.project_id) @router.get("/plaza/amap-config") async def amap_config(_user: CurrentUser = None): """Return browser-side AMap JS API configuration for admin map canvases.""" return { "configured": bool(settings.amap_js_key), "js_key": settings.amap_js_key, "security_jscode": settings.amap_security_jscode, "security_configured": bool(settings.amap_security_jscode), } @router.get("/plaza/weather/hourly") async def weather_hourly(_user: CurrentUser = None): """荔波县逐小时气温(未来 24h)。 高德天气 API 无逐小时能力,改用和风天气 (QWeather) ``/v7/weather/24h``, KEY 存后端 (``.env``/settings),不暴露给前端。高德的实时+逐天保持不变。 """ if not settings.qweather_api_key: return {"configured": False, "hourly": []} import httpx host = settings.qweather_api_host.rstrip("/") url = f"{host}/v7/weather/24h" params = { "location": settings.qweather_libo_location, "key": settings.qweather_api_key, } try: async with httpx.AsyncClient(timeout=10) as client: # 和风响应默认 gzip,httpx 会自动解压。 resp = await client.get(url, params=params) payload = resp.json() except Exception as exc: # 天气非核心功能,失败降级为空列表 return {"configured": True, "hourly": [], "error": str(exc)} if str(payload.get("code")) != "200": return {"configured": True, "hourly": [], "error": payload.get("code")} hourly = [ { "time": item.get("fxTime", ""), "temp": item.get("temp", ""), "text": item.get("text", ""), "icon": item.get("icon", ""), } for item in (payload.get("hourly") or []) ] return { "configured": True, "update_time": payload.get("updateTime", ""), "hourly": hourly, } def _map_falkor_client() -> FalkorDB: """Use the same configured database credentials for every map read.""" kwargs: dict[str, Any] = { "host": settings.falkordb_host, "port": settings.falkordb_port, "socket_connect_timeout": 5, # Allow the 30s server-side query timeout to produce a useful error. "socket_timeout": 40, } if settings.falkordb_password: kwargs["password"] = settings.falkordb_password return FalkorDB(**kwargs) 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: """Resolve the explicitly paired city map store used by the legacy project. ``guiyang_new2`` is the semantic city graph while ``guiyang_spatial_v1`` is its map/POI store. Other map-enabled projects keep using their own graph, so imported bundles do not depend on a project-name convention. """ 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]: db = _map_falkor_client() try: graph = db.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 payload = _build_libo_bus_route_payload(rows, graph_name) if payload["direction_count"]: return payload # Full JSON v2 stores one BusLine with one or more directional # BusRoute nodes. Keep the legacy query above for existing graphs and # fall back to the normalized structure for newly imported projects. rows = graph.query( "MATCH (b:BusLine)-[:HAS_ROUTE]->(route:BusRoute) " "WHERE b.dataset=$dataset " "OPTIONAL MATCH (route)-[r:STOPS_AT]->(s:BusStop) " "RETURN route.route_id,coalesce(route.line_name,b.line_name)," "route.direction,route.start_stop,route.end_stop,route.stop_count," "route.fare_yuan,route.first_bus,route.last_bus,route.distance_km," "route.path_source,s.element_id,s.name,s.lng,s.lat,r.sequence " "ORDER BY route.line_name,route.route_id,r.sequence", {"dataset": LIBO_BUS_DATASET}, ).result_set return _build_libo_bus_route_payload(rows, graph_name) finally: db.close() _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]]: db = _map_falkor_client() try: result = db.select_graph(graph_name).query( "MATCH (n {element_id:$element_id}) " "RETURN labels(n), properties(n) LIMIT 1", {"element_id": element_id}, ).result_set finally: db.close() 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_business_categories( labels: list[str] | tuple[str, ...] | None, properties: dict[str, Any], ) -> list[str]: source_labels = properties.get("map_source_labels") if properties.get("map_poi") is True and isinstance(source_labels, list): # Merging semantic/spatial facets must not alter the verified map's # business-category contract. All labels remain on the graph node. labels = source_labels categories = [ GRAPH_POI_LABELS[label] for label in (labels or []) if label in GRAPH_POI_LABELS ] type_label = str(properties.get("type_label") or "").strip() place_type = str(properties.get("place_type") or "").strip().lower() if type_label: categories.insert(0, type_label) elif place_type: categories.insert(0, GRAPH_PLACE_TYPE_CATEGORIES.get(place_type, place_type)) return list(dict.fromkeys(category for category in categories if category)) def _graph_poi_categories(graph_name: str) -> dict[str, list[str]]: db = _map_falkor_client() try: graph = db.select_graph(graph_name) result: dict[str, list[str]] = {} page_size = 5000 offset = 0 while True: rows = graph.query( "MATCH (n) " "WHERE n.element_id IS NOT NULL " "AND coalesce(n.lng,n.longitude) IS NOT NULL " "AND coalesce(n.lat,n.latitude) IS NOT NULL " "RETURN n.element_id, labels(n), properties(n) " f"ORDER BY n.element_id SKIP {offset} LIMIT {page_size}" ).result_set for element_id, labels, raw_properties in rows: properties = raw_properties if isinstance(raw_properties, dict) else {} categories = _graph_poi_business_categories(labels, properties) if element_id and categories: result[str(element_id)] = categories if len(rows) < page_size: break offset += page_size return result finally: db.close() def _graph_json_value(value: Any) -> Any: """Decode object/array properties serialized for FalkorDB storage.""" if not isinstance(value, str): return value text = value.strip() if not text or text[0] not in "[{": return value try: return json.loads(text) except json.JSONDecodeError: return value def _graph_map_poi_items(rows: list[list[Any]]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for row in rows: if len(row) == 3: node_id, labels, raw_properties = row properties = raw_properties if isinstance(raw_properties, dict) else {} elif 20 <= len(row) <= len(GRAPH_MAP_POI_PROJECTION_FIELDS) + 2: node_id, labels, *values = row fields = GRAPH_MAP_POI_PROJECTION_FIELDS[:len(values)] properties = dict(zip(fields, values, strict=True)) else: continue if properties.get("map_poi") is False: continue normalized_labels = [str(label) for label in (labels or [])] # Normalized BusStop nodes belong to the dedicated bus-route layer. # The historical PostgreSQL map source never counted those 90 helper # nodes as business POIs, so excluding them keeps JSON-created projects # consistent with the existing 2,799-point map contract. if "BusStop" in normalized_labels and not any( label in {"FoodPlace", "Hotel", "ScenicSpot", "TransitFacility"} for label in normalized_labels ): continue lng = properties.get("lng") if lng is None: lng = properties.get("longitude") lat = properties.get("lat") if lat is None: lat = properties.get("latitude") if lng is None or lat is None: continue try: longitude = float(lng) latitude = float(lat) except (TypeError, ValueError): continue if ( not math.isfinite(longitude) or not math.isfinite(latitude) or not -180 <= longitude <= 180 or not -90 <= latitude <= 90 ): continue categories = _graph_poi_business_categories(normalized_labels, properties) if not categories: continue category = categories[0] element_id = str( properties.get("element_id") or properties.get("__kg_node_id") or properties.get("gaode_poi_id") or (node_id if node_id is not None else "") ) items.append( { "id": element_id, "gaode_poi_id": str(properties.get("gaode_poi_id") or ""), "name": str(properties.get("name") or "未命名POI"), "category": category, "categories": categories or [category], "place_type": str(properties.get("place_type") or "poi"), "lng": longitude, "lat": latitude, "address": str(properties.get("address") or ""), "district": str(properties.get("district") or ""), "city": str(properties.get("city") or ""), "province": str(properties.get("province") or ""), "adcode": str(properties.get("adcode") or ""), "towncode": str(properties.get("towncode") or ""), "town_name": str(properties.get("town_name") or ""), "zone_id": str(properties.get("zone_id") or ""), "zone_name": str(properties.get("zone_name") or ""), } ) return items def _read_graph_map_pois(graph_name: str) -> list[dict[str, Any]]: db = _map_falkor_client() try: graph = db.select_graph(graph_name) items: list[dict[str, Any]] = [] page_size = 2000 offset = 0 while True: rows = graph.query( "MATCH (n) " "WHERE (n.lng IS NOT NULL OR n.longitude IS NOT NULL) " "AND (n.lat IS NOT NULL OR n.latitude IS NOT NULL) " "AND (n.map_poi IS NULL OR n.map_poi = true) " "RETURN id(n) AS node_id,labels(n)," "n.element_id,n.gaode_poi_id,n.name,n.type_label,n.place_type," "n.lng,n.longitude,n.lat,n.latitude,n.address,n.district,n.city," "n.province,n.adcode,n.towncode,n.town_name,n.zone_id,n.zone_name," "n.map_poi,n.map_source_labels,n.__kg_node_id " f"ORDER BY node_id SKIP {offset} LIMIT {page_size}", timeout=GRAPH_MAP_QUERY_TIMEOUT_MS, ).result_set items.extend(_graph_map_poi_items(rows)) if len(rows) < page_size: break offset += page_size # Legacy graphs can contain several label facets for the same POI. # A map point is identified by its stable POI id, not its graph row. # Preserve all business categories without drawing duplicate points. unique: dict[str, dict[str, Any]] = {} for item in items: previous = unique.get(item["id"]) if previous is None: unique[item["id"]] = item else: previous["categories"] = list(dict.fromkeys( [*previous["categories"], *item["categories"]] )) return list(unique.values()) finally: db.close() def _graph_zone_payload( town_rows: list[list[Any]], zone_rows: list[list[Any]], graph_name: str, ) -> dict[str, Any]: towns = [] for raw_properties, poi_count, min_lng, min_lat, max_lng, max_lat in town_rows: properties = raw_properties if isinstance(raw_properties, dict) else {} bbox = None if min_lng is not None: bbox = [float(min_lng), float(min_lat), float(max_lng), float(max_lat)] towns.append( { "towncode": str(properties.get("towncode") or ""), "name": str(properties.get("name") or ""), "unit_type": str(properties.get("unit_type") or ""), "civil_adcode": str(properties.get("civil_adcode") or ""), "community_count": int(properties.get("community_count") or 0), "village_count": int(properties.get("village_count") or 0), "poi_count": int(poi_count or 0), "bbox": bbox, "has_boundary": False, } ) zones = [] for raw_properties, poi_count in zone_rows: properties = raw_properties if isinstance(raw_properties, dict) else {} anchor_lng = properties.get("anchor_lng") anchor_lat = properties.get("anchor_lat") zones.append( { "zone_id": str(properties.get("zone_id") or ""), "zone_name": str(properties.get("zone_name") or ""), "anchor_name": str(properties.get("anchor_name") or ""), "anchor": ( [float(anchor_lng), float(anchor_lat)] if anchor_lng is not None and anchor_lat is not None else None ), "cap_m": float(properties["cap_m"]) if properties.get("cap_m") is not None else None, "parent_admin_name": str(properties.get("parent_admin_name") or ""), "anchor_source": str(properties.get("anchor_source") or ""), "poi_count": int(poi_count or 0), "area_km2": float(properties["area_km2"]) if properties.get("area_km2") is not None else None, "dominant_category": str(properties.get("dominant_category") or ""), "bank": _graph_json_value(properties.get("bank")), "food_count": int(properties.get("food_count") or 0), "hotel_count": int(properties.get("hotel_count") or 0), "transit_count": int(properties.get("transit_count") or 0), "scenic_count": int(properties.get("scenic_count") or 0), "geometry": _graph_json_value(properties.get("geometry")), "geometry_variant": str(properties.get("geometry_variant") or ""), } ) towns.sort(key=lambda item: (-item["poi_count"], item["towncode"])) zones.sort(key=lambda item: item["zone_id"]) return { "graph_name": graph_name, "zone_set_id": LIBO_ZONE_SET_ID, "towns": towns, "zones": zones, } def _read_graph_map_zones(graph_name: str) -> dict[str, Any]: db = _map_falkor_client() try: graph = db.select_graph(graph_name) town_rows = graph.query( "MATCH (town:Area) WHERE town.area_level='town' " "OPTIONAL MATCH (poi)-[:LOCATED_IN]->(town) " "RETURN properties(town),count(poi),min(poi.lng),min(poi.lat)," "max(poi.lng),max(poi.lat)" ).result_set zone_rows = graph.query( "MATCH (zone:BusinessZone) " "OPTIONAL MATCH (poi)-[:IN_BUSINESS_ZONE]->(zone) " "RETURN properties(zone),count(poi)" ).result_set return _graph_zone_payload(town_rows, zone_rows, graph_name) finally: db.close() def _graph_enrichment_properties( properties: dict[str, Any], 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] = _graph_json_value(value) return result def _graph_poi_detail_payload( graph_name: str, labels: list[str], raw_properties: dict[str, Any], ) -> dict[str, Any]: properties = {key: _graph_json_value(value) for key, value in raw_properties.items()} raw = properties.get("spatial_raw_data") or properties.get("raw_jsonb") or {} if not isinstance(raw, dict): raw = {} image_rows = properties.get("images") or [] image_urls = [ str(item.get("image_url")) for item in image_rows if isinstance(item, dict) and item.get("image_url") ] photo_urls = list( dict.fromkeys( [ *_photo_urls(properties.get("photo_urls")), *_photo_urls(properties.get("image_urls")), *_photo_urls(properties.get("dianping_shop_image")), *_photo_urls(properties.get("hotel_image_samples")), *image_urls, ] ) ) element_id = str( properties.get("element_id") or properties.get("__kg_node_id") or properties.get("gaode_poi_id") or "" ) categories = _graph_poi_business_categories(labels, properties) lng = properties.get("lng") if lng is None: lng = properties.get("longitude") lat = properties.get("lat") if lat is None: lat = properties.get("latitude") return { "graph_name": graph_name, "id": element_id, "gaode_poi_id": str(properties.get("gaode_poi_id") or ""), "name": str(properties.get("name") or "未命名POI"), "category": categories[0] if categories else "其他地点", "categories": categories or ["其他地点"], "place_type": str(properties.get("place_type") or "poi"), "business_subcategory": raw.get("business_subcategory") or "", "scenic_type": raw.get("scenic_type") or properties.get("scenic_type") or "", "scenic_level": raw.get("scenic_level") or properties.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 properties.get("visitor_value_type") 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": str(properties.get("amap_type") or ""), "typecode": str(properties.get("typecode") or ""), "scan_hit_count": raw.get("scan_hit_count") or 0, "matched_scan_types": raw.get("matched_scan_types") or [], "lng": float(lng or 0), "lat": float(lat or 0), "province": str(properties.get("province") or ""), "city": str(properties.get("city") or ""), "district": str(properties.get("district") or ""), "adcode": str(properties.get("adcode") or ""), "business_area": str(properties.get("business_area") or ""), "address": str(properties.get("address") or ""), "tel": str(properties.get("tel") or properties.get("phone") or ""), "open_time": str(properties.get("open_time") or ""), "rating": properties.get("rating") or "", "cost": properties.get("cost") or "", "level": properties.get("level") or "", "tags": properties.get("tags") or "", "photo_urls": photo_urls, "source": str(properties.get("source") or ""), "source_cell_id": str(properties.get("source_cell_id") or ""), "source_resolution": properties.get("source_resolution"), "source_scope_adcode": str(properties.get("source_scope_adcode") or ""), "first_fetched_at": str(properties.get("first_fetched_at") or ""), "last_fetched_at": str(properties.get("last_fetched_at") or ""), "graph_labels": [str(label) for label in labels], "food_enrichment": _graph_enrichment_properties(properties, FOOD_ENRICHMENT_KEYS), "hotel_enrichment": _graph_enrichment_properties(properties, HOTEL_ENRICHMENT_KEYS), } def _read_graph_poi_detail(graph_name: str, place_id: str) -> dict[str, Any] | None: db = _map_falkor_client() try: rows = db.select_graph(graph_name).query( "MATCH (n) WHERE n.element_id=$place_id " "OR n.gaode_poi_id=$gaode_poi_id OR n.__kg_node_id=$place_id " "RETURN labels(n),properties(n) LIMIT 1", { "place_id": place_id, "gaode_poi_id": place_id.removeprefix("amap:"), }, ).result_set if not rows: return None labels, properties = rows[0] if not isinstance(properties, dict): return None return _graph_poi_detail_payload(graph_name, labels or [], properties) finally: db.close() @router.get("/plaza/map-pois") async def map_pois( context: ProjectContext = Depends(get_project_context), _user: CurrentUser = None, ): """Return map POIs from the current project's FalkorDB graph. PostgreSQL spatial tables are collection/staging stores. They are not a runtime fallback because doing so can display stale POIs from a different import or project version while the graph counters show current data. """ graph_name = _resolve_spatial_graph_name(context.graph_name) source = "falkordb" try: items = await asyncio.to_thread(_read_graph_map_pois, graph_name) except Exception as exc: logger.exception( "map POI read failed: source=falkordb tenant=%s project=%s graph=%s", context.tenant_id, context.project_id, graph_name, ) raise HTTPException( status_code=503, detail="当前项目的地图 POI 读取失败,请检查 FalkorDB 服务和图谱数据", ) from exc if items: logger.info( "map POI read completed: source=%s tenant=%s project=%s graph=%s total=%d", source, context.tenant_id, context.project_id, graph_name, len(items), ) else: logger.warning( "map POI read returned no data: source=%s tenant=%s project=%s graph=%s", source, context.tenant_id, context.project_id, graph_name, ) 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, "source": source, "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/map-zones") async def map_zones( context: ProjectContext = Depends(get_project_context), _user: CurrentUser = None, ): """Return the two-track region tree for the map's 区域图层 selector. Track 1 (法定): the 8 towns/subdistricts, keyed by ``towncode``. AMap publishes no town-level boundary vector, so these carry POI counts and a bounding box only — never a boundary line. Track 2 (业务): the ``ZS-LB-YUPING-V1`` zone set, which only covers 玉屏街道. Zones carry a ``geometry`` face (B-seamless variant) where one exists. """ 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( "SELECT to_regclass(%s) AS reg", (f"{s}.libo_business_zones",), ) if not (await cur.fetchone())["reg"]: return await asyncio.to_thread(_read_graph_map_zones, graph_name) await cur.execute( f"SELECT EXISTS(SELECT 1 FROM {s}.amap_spatial_pois WHERE graph_name=%s) AS found", (graph_name,), ) if not bool((await cur.fetchone())["found"]): return await asyncio.to_thread(_read_graph_map_zones, graph_name) await cur.execute( f"""SELECT t.towncode, t.name, t.unit_type, t.civil_adcode, t.community_count, t.village_count, coalesce(p.poi_count, 0) AS poi_count, p.min_lng, p.min_lat, p.max_lng, p.max_lat FROM {s}.libo_admin_towns t LEFT JOIN ( SELECT towncode, count(*) AS poi_count, min(lng) AS min_lng, min(lat) AS min_lat, max(lng) AS max_lng, max(lat) AS max_lat FROM {s}.amap_spatial_pois WHERE graph_name=%s AND towncode IS NOT NULL GROUP BY towncode ) p ON p.towncode = t.towncode ORDER BY poi_count DESC, t.towncode""", (graph_name,), ) town_rows = await cur.fetchall() await cur.execute( f"""SELECT z.zone_id, z.zone_name, z.anchor_name, z.anchor_lng, z.anchor_lat, z.cap_m, z.parent_admin_name, z.geometry, z.geometry_variant, z.anchor_source, z.area_km2, z.dominant_category, z.bank, z.food_count, z.hotel_count, z.transit_count, z.scenic_count, coalesce(a.poi_count, 0) AS poi_count FROM {s}.libo_business_zones z LEFT JOIN ( SELECT zone_id, count(*) AS poi_count FROM {s}.poi_zone_assignments WHERE graph_name=%s AND zone_set_id=%s GROUP BY zone_id ) a ON a.zone_id = z.zone_id WHERE z.zone_set_id=%s ORDER BY z.zone_id""", (graph_name, LIBO_ZONE_SET_ID, LIBO_ZONE_SET_ID), ) zone_rows = await cur.fetchall() def bbox(row: dict[str, Any]) -> list[float] | None: if row["min_lng"] is None: return None return [ float(row["min_lng"]), float(row["min_lat"]), float(row["max_lng"]), float(row["max_lat"]), ] towns = [ { "towncode": row["towncode"], "name": row["name"], "unit_type": row["unit_type"], "civil_adcode": row["civil_adcode"], "community_count": row["community_count"], "village_count": row["village_count"], "poi_count": row["poi_count"], "bbox": bbox(row), # AMap has no town-level boundary vector; see doc §1.4. "has_boundary": False, } for row in town_rows ] zones = [ { "zone_id": row["zone_id"], "zone_name": row["zone_name"], "anchor_name": row["anchor_name"], "anchor": ( [float(row["anchor_lng"]), float(row["anchor_lat"])] if row["anchor_lng"] is not None else None ), "cap_m": float(row["cap_m"]) if row["cap_m"] is not None else None, "parent_admin_name": row["parent_admin_name"], "anchor_source": row["anchor_source"], "poi_count": row["poi_count"], "area_km2": float(row["area_km2"]) if row["area_km2"] is not None else None, "dominant_category": row["dominant_category"], "bank": row["bank"], "food_count": row["food_count"], "hotel_count": row["hotel_count"], "transit_count": row["transit_count"], "scenic_count": row["scenic_count"], "geometry": row["geometry"], "geometry_variant": row["geometry_variant"], } for row in zone_rows ] return { "graph_name": graph_name, "zone_set_id": LIBO_ZONE_SET_ID, "towns": towns, "zones": zones, } @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) try: detail = await asyncio.to_thread(_read_graph_poi_detail, graph_name, place_id) except Exception as exc: logger.exception( "map POI detail read failed: project=%s graph=%s", context.project_id, graph_name, ) raise HTTPException(503, "当前项目的 POI 详情读取失败,请检查 FalkorDB 服务") from exc if detail is None: raise HTTPException(404, "当前项目中未找到该POI") return detail @router.post("/plaza/user-query") 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)) except Exception as exc: 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 use_llm = body.get("use_llm") is True fallback = _rule_intent(question, radius_m) if use_llm: intent, intent_source = await _llm_intent(question, fallback) else: intent, intent_source = fallback, "规则意图快路径" radius = int(intent["radius_m"]) res, h3_col, k = _h3_plan(radius) cells = list(h3.grid_disk(h3.latlng_to_cell(lat, lng, res), k)) category = intent.get("category") or "" place_type = PLACE_TYPE_ALIASES.get(category, "") s = settings.db_schema async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( f"""SELECT gaode_poi_id, element_id, name, type_label, place_type, amap_type, typecode, lng, lat, address, district, city, adcode, business_area, tel, rating, cost, open_time, tags, photo_urls, {h3_col} AS h3_cell, source, first_fetched_at, last_fetched_at FROM {s}.amap_spatial_pois WHERE graph_name=%s AND {h3_col}=ANY(%s) AND (%s='' OR type_label=%s OR place_type=%s) LIMIT 8000""", (graph_name, cells, category, category, place_type), ) rows = await cur.fetchall() results: list[dict[str, Any]] = [] for row in rows: d = _haversine_m(lng, lat, float(row["lng"]), float(row["lat"])) if d > radius: continue score, reasons = _score_place(dict(row), d, radius, intent) results.append({ "place_id": row["element_id"], "gaode_poi_id": row["gaode_poi_id"], "name": row["name"], "type_label": row["type_label"], "place_type": row["place_type"], "amap_type": row["amap_type"], "typecode": row["typecode"], "lng": float(row["lng"]), "lat": float(row["lat"]), "address": row["address"] or "", "district": row["district"] or "", "city": row["city"] or "", "adcode": row["adcode"] or "", "business_area": row["business_area"] or "", "tel": row["tel"] or "", "rating": row["rating"] or "", "cost": row["cost"] or "", "open_time": row["open_time"] or "", "tags": row["tags"] or "", "photo_urls": _photo_urls(row["photo_urls"]), "h3_cell": row["h3_cell"], "source": row["source"], "last_fetched_at": row["last_fetched_at"].isoformat() if row.get("last_fetched_at") else "", "distance_m": round(d, 1), "score": score, "rank_reasons": reasons, }) results.sort(key=lambda r: (-r["score"], r["distance_m"])) results = results[:60] if use_llm: answer, llm_reasons, answer_source = await _llm_answer(question, intent, results) else: answer, llm_reasons, answer_source = _rule_answer(question, intent, results) for r in results: if llm_reasons.get(r["place_id"]): r["llm_reason"] = llm_reasons[r["place_id"]] return { "question": question, "graph_name": graph_name, "user_location": {"lng": lng, "lat": lat}, "intent": intent, "answer": answer, "results": results, "trace": { "intent_source": intent_source, "answer_source": answer_source, "latency_ms": max(1, round((time.perf_counter() - started_at) * 1000)), "performance_target_ms": 1200, "use_llm": use_llm, "h3_resolution": res, "h3_column": h3_col, "h3_k": k, "h3_cells": len(cells), "h3_candidates": len(rows), "radius_filtered": len(results), "note": "当前结果来自已采集 guiyang_spatial_v1 空间知识图谱;采集未完成的区域会影响召回。", }, }