Files
Cloud-Tour-to-Libo/app/api/plaza.py

980 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""STEP 02 — Knowledge Plaza overview, usage, alerts."""
from __future__ import annotations
import asyncio
import json
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_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]] = {
"美食": ["美食", "", "餐厅", "饭店", "火锅", "小吃", "烧烤", "咖啡", "奶茶", "酸汤鱼"],
"景点": ["景点", "景区", "公园", "博物馆", "古镇", "夜游", "历史", "文化", "好玩"],
"酒店": ["酒店", "住宿", "", "宾馆", "民宿"],
"商场": ["商场", "购物", "商圈", "超市", "商城"],
"医疗保健": ["医院", "诊所", "药店", "医疗", "看病", "急诊"],
"交通设施": ["地铁", "公交", "车站", "交通", "停车", "机场", "高铁"],
"生活服务": ["生活服务", "维修", "营业厅", "服务"],
"科教文化": ["学校", "大学", "图书馆", "教育", "培训"],
}
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 和 reasonsreasons 是 {候选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/usage")
async def usage(
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
"""Return usage statistics — top hot and cold entities."""
s = settings.db_schema
async with get_conn() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"SELECT entity_type, COUNT(*) AS cnt FROM {s}.candidate_entities "
"WHERE tenant_id=%s AND project_id=%s AND status='published' "
"GROUP BY entity_type ORDER BY cnt DESC",
(context.tenant_id, context.project_id),
)
by_type = await cur.fetchall()
await cur.execute(
f"SELECT COUNT(*) AS cnt FROM {s}.candidate_entities "
"WHERE tenant_id=%s AND project_id=%s AND status='pending_review'",
(context.tenant_id, context.project_id),
)
pending = (await cur.fetchone())["cnt"]
return {
"entities_by_type": by_type,
"pending_review": pending,
}
@router.get("/plaza/alerts")
async def alerts(
context: ProjectContext = Depends(get_project_context),
_user: CurrentUser = None,
):
return await get_plaza_alerts(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),
}
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,
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 空间知识图谱;采集未完成的区域会影响召回。",
},
}