From 712ca1418cabfc3458e95a019164b8afa6b1e86d Mon Sep 17 00:00:00 2001 From: 3452078359-xuexue <3452078359@qq.com> Date: Mon, 31 Aug 2026 01:51:18 -0700 Subject: [PATCH] fix: read map POIs from authenticated FalkorDB --- admin-web/src/panels/plaza/PlazaOverview.tsx | 23 +- .../src/panels/plaza/SuperAgentPanel.tsx | 10 +- admin-web/tests/knowledgeMapTemplate.test.ts | 13 +- app/api/plaza.py | 376 +++++++++--------- docs/MAP_POI_READ_FIX.md | 32 ++ scripts/verify_map_poi_read.py | 155 ++++++++ tests/test_plaza_graph_fallback.py | 243 ++++++++++- 7 files changed, 646 insertions(+), 206 deletions(-) create mode 100644 docs/MAP_POI_READ_FIX.md create mode 100644 scripts/verify_map_poi_read.py diff --git a/admin-web/src/panels/plaza/PlazaOverview.tsx b/admin-web/src/panels/plaza/PlazaOverview.tsx index ce9ca22..12b2c57 100644 --- a/admin-web/src/panels/plaza/PlazaOverview.tsx +++ b/admin-web/src/panels/plaza/PlazaOverview.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Card, Statistic, Row, Col, Space, Table, Tag } from "antd"; import { ApartmentOutlined, HistoryOutlined, NodeIndexOutlined, @@ -11,12 +11,11 @@ import { graphQuery, listProjects, plazaOverview, - superAgentCoverage, type KgProjectContext, type ProjectSummary, type SpatialMapConfig, } from "../../api"; -import { KnowledgeMap, type CoverageSnapshot } from "./SuperAgentPanel"; +import { KnowledgeMap } from "./SuperAgentPanel"; import { buildGenericGraphCategories, resolveOverviewCategoryView, @@ -132,14 +131,15 @@ export default function PlazaOverview() { const [graphData, setGraphData] = useState>({}); const [graphCategories, setGraphCategories] = useState([]); const [graphCategoriesLoading, setGraphCategoriesLoading] = useState(true); - const [mapCoverage, setMapCoverage] = useState(null); const [projectContext, setProjectContextState] = useState(getProjectContext()); const [projectSummary, setProjectSummary] = useState(null); const [spatialMap, setSpatialMap] = useState(() => ( resolveSpatialMap(null, getProjectContext()) )); + const fetchSequence = useRef(0); const fetch = async () => { + const sequence = ++fetchSequence.current; setGraphCategoriesLoading(true); const currentContext = getProjectContext(); try { @@ -148,6 +148,7 @@ export default function PlazaOverview() { graphOverview(), listProjects(), ]); + if (sequence !== fetchSequence.current) return; const plazaData = plazaResult.status === "fulfilled" ? plazaResult.value.data as Record : {}; @@ -163,9 +164,7 @@ export default function PlazaOverview() { const placeTypeRows = isKnowledgeMapProject && currentGraphData.connected === true ? await graphQuery(GRAPH_CATEGORY_CYPHER).then((res) => res.data.rows || []).catch(() => []) : []; - const coverage = currentContext.graphName === DEFAULT_PROJECT_CONTEXT.graphName - ? await superAgentCoverage().then((res) => res.data).catch(() => null) - : null; + if (sequence !== fetchSequence.current) return; setProjectContextState(currentContext); setProjectSummary(currentProject); setSpatialMap(currentSpatialMap); @@ -176,9 +175,8 @@ export default function PlazaOverview() { ? buildGraphCategories(currentGraphData, placeTypeRows) : buildGenericGraphCategories(currentGraphData, LABEL_NAMES), ); - setMapCoverage(coverage); } finally { - setGraphCategoriesLoading(false); + if (sequence === fetchSequence.current) setGraphCategoriesLoading(false); } }; @@ -186,7 +184,10 @@ export default function PlazaOverview() { fetch(); const onChange = () => fetch(); window.addEventListener("znkg-project-context", onChange); - return () => window.removeEventListener("znkg-project-context", onChange); + return () => { + ++fetchSequence.current; + window.removeEventListener("znkg-project-context", onChange); + }; }, []); const releases = (data.graph_releases as Array>) || []; @@ -220,7 +221,7 @@ export default function PlazaOverview() { {isKnowledgeMapProject && ( { }; export function KnowledgeMap({ - cov = null, scope = "guizhou", projectId = "default", projectName = "当前项目", @@ -2010,7 +2009,6 @@ export function KnowledgeMap({ regionAdcode = "520000", regionLevel = "province", }: { - cov?: CoverageSnapshot | null; scope?: "guizhou" | "libo"; projectId?: string; projectName?: string; @@ -2061,7 +2059,7 @@ export function KnowledgeMap({ } }); const searchBoxRef = useRef(null); - const [poiLoading, setPoiLoading] = useState(false); + const [poiLoading, setPoiLoading] = useState(true); const [poiError, setPoiError] = useState(""); const [selectedPoi, setSelectedPoi] = useState(null); const [poiDetail, setPoiDetail] = useState(null); @@ -2300,6 +2298,8 @@ export function KnowledgeMap({ let disposed = false; setPoiLoading(true); setPoiError(""); + setMapPois([]); + setSelectedCategories([]); plazaMapPois() .then(({ data }) => { if (disposed) return; @@ -3544,10 +3544,10 @@ export function KnowledgeMap({ }} >
- {isLibo ? `${activeRegionName}馆藏汇总` : "省域馆藏汇总"} + {regionLevel === "province" ? "省域馆藏汇总" : `${activeRegionName}馆藏汇总`}
- {(isLibo ? regionPois.length : (cov?.total ?? regionPois.length)).toLocaleString()} + {poiLoading ? "加载中…" : poiError ? "—" : regionPois.length.toLocaleString()}
{isLibo && selectedTowncode diff --git a/admin-web/tests/knowledgeMapTemplate.test.ts b/admin-web/tests/knowledgeMapTemplate.test.ts index efe6767..0138b49 100644 --- a/admin-web/tests/knowledgeMapTemplate.test.ts +++ b/admin-web/tests/knowledgeMapTemplate.test.ts @@ -25,14 +25,23 @@ test("every map-enabled project uses the single KnowledgeMap template", () => { assert.doesNotMatch(overviewSource, / { +test("the shared map preserves approved logos and counts only current-region graph POIs", () => { assert.match(mapSource, /MAP_SHORTCUT_CATEGORIES = \["美食", "酒店", "景点", "交通设施"\]/); assert.match(mapSource, /MAP_SHORTCUT_CATEGORIES\.map/); assert.match(mapSource, /省域馆藏汇总/); - assert.match(mapSource, /cov\?\.total \?\? regionPois\.length/); + assert.match(mapSource, /regionPois\.length\.toLocaleString\(\)/); + assert.doesNotMatch(mapSource, /cov\?\.total/); + assert.match(mapSource, /regionLevel === "province"/); + assert.match(mapSource, /poiLoading \? "加载中…" : poiError \? "—"/); assert.match(mapSource, /当前已入藏知识 \/ POI 条目/); }); +test("switching a graph version remounts the map and clears stale POIs", () => { + assert.match(overviewSource, /key=\{.*projectContext\.projectId.*projectContext\.graphName.*spatialMap\.region_adcode/); + assert.match(mapSource, /setPoiError\(""\);\s+setMapPois\(\[\]\);\s+setSelectedCategories\(\[\]\)/); + assert.match(overviewSource, /if \(sequence !== fetchSequence\.current\) return/); +}); + test("map-enabled project creation requires an explicit administrative region", () => { assert.match(projectLandingSource, /name="map_region_adcode"/); assert.match(projectLandingSource, /请选择地图区域/); diff --git a/app/api/plaza.py b/app/api/plaza.py index 407cc18..1df83d0 100644 --- a/app/api/plaza.py +++ b/app/api/plaza.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json +import logging import math import re import time @@ -14,12 +15,13 @@ 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_conn +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), @@ -58,6 +60,35 @@ GRAPH_PLACE_TYPE_CATEGORIES = { "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", @@ -460,6 +491,20 @@ async def weather_hourly(_user: CurrentUser = None): } +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) @@ -558,10 +603,7 @@ def _build_libo_bus_route_payload( def _read_libo_bus_routes(graph_name: str) -> dict[str, Any]: - db = FalkorDB( - host=settings.falkordb_host, - port=settings.falkordb_port, - ) + db = _map_falkor_client() try: graph = db.select_graph(graph_name) rows = graph.query( @@ -666,15 +708,15 @@ 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 + 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 [], {}, {} @@ -699,6 +741,11 @@ 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 []) @@ -714,10 +761,7 @@ def _graph_poi_business_categories( def _graph_poi_categories(graph_name: str) -> dict[str, list[str]]: - db = FalkorDB( - host=settings.falkordb_host, - port=settings.falkordb_port, - ) + db = _map_falkor_client() try: graph = db.select_graph(graph_name) result: dict[str, list[str]] = {} @@ -760,21 +804,59 @@ def _graph_json_value(value: Any) -> Any: def _graph_map_poi_items(rows: list[list[Any]]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] - for node_id, labels, raw_properties in rows: - properties = raw_properties if isinstance(raw_properties, dict) else {} - lng = properties.get("lng", properties.get("longitude")) - lat = properties.get("lat", properties.get("latitude")) + 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 - categories = _graph_poi_business_categories(labels, properties) + 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 node_id + or properties.get("__kg_node_id") or properties.get("gaode_poi_id") - or "" + or (node_id if node_id is not None else "") ) items.append( { @@ -784,8 +866,8 @@ def _graph_map_poi_items(rows: list[list[Any]]) -> list[dict[str, Any]]: "category": category, "categories": categories or [category], "place_type": str(properties.get("place_type") or "poi"), - "lng": float(lng), - "lat": float(lat), + "lng": longitude, + "lat": latitude, "address": str(properties.get("address") or ""), "district": str(properties.get("district") or ""), "city": str(properties.get("city") or ""), @@ -801,25 +883,43 @@ def _graph_map_poi_items(rows: list[list[Any]]) -> list[dict[str, Any]]: def _read_graph_map_pois(graph_name: str) -> list[dict[str, Any]]: - db = FalkorDB(host=settings.falkordb_host, port=settings.falkordb_port) + db = _map_falkor_client() try: graph = db.select_graph(graph_name) items: list[dict[str, Any]] = [] - page_size = 5000 + page_size = 2000 offset = 0 while True: rows = graph.query( "MATCH (n) " - "WHERE coalesce(n.lng,n.longitude) IS NOT NULL " - "AND coalesce(n.lat,n.latitude) IS NOT NULL " - "RETURN n.__kg_node_id,labels(n),properties(n) " - f"ORDER BY n.name,n.element_id SKIP {offset} LIMIT {page_size}" + "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 - return items + # 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() @@ -890,7 +990,7 @@ def _graph_zone_payload( def _read_graph_map_zones(graph_name: str) -> dict[str, Any]: - db = FalkorDB(host=settings.falkordb_host, port=settings.falkordb_port) + db = _map_falkor_client() try: graph = db.select_graph(graph_name) town_rows = graph.query( @@ -944,6 +1044,8 @@ def _graph_poi_detail_payload( [ *_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, ] ) @@ -955,8 +1057,12 @@ def _graph_poi_detail_payload( or "" ) categories = _graph_poi_business_categories(labels, properties) - lng = properties.get("lng", properties.get("longitude")) - lat = properties.get("lat", properties.get("latitude")) + 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, @@ -1007,7 +1113,7 @@ def _graph_poi_detail_payload( def _read_graph_poi_detail(graph_name: str, place_id: str) -> dict[str, Any] | None: - db = FalkorDB(host=settings.falkordb_host, port=settings.falkordb_port) + db = _map_falkor_client() try: rows = db.select_graph(graph_name).query( "MATCH (n) WHERE n.element_id=$place_id " @@ -1033,73 +1139,51 @@ async def map_pois( context: ProjectContext = Depends(get_project_context), _user: CurrentUser = None, ): - """Return lightweight, project-scoped POI points for the knowledge map.""" + """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) - s = settings.db_schema - async with get_conn() as conn: - async with conn.cursor() as cur: - await cur.execute( - f"""SELECT p.element_id, p.gaode_poi_id, p.name, p.type_label, - p.place_type, p.lng, p.lat, p.address, p.province, - p.district, p.city, p.adcode, p.towncode, p.town_name, - z.zone_id, z.zone_name - FROM {s}.amap_spatial_pois p - LEFT JOIN {s}.poi_zone_assignments z - ON z.graph_name = p.graph_name - AND z.gaode_poi_id = p.gaode_poi_id - AND z.zone_set_id = %s - WHERE p.graph_name=%s - ORDER BY p.type_label, p.name - LIMIT 100000""", - (LIBO_ZONE_SET_ID, graph_name), - ) - rows = await cur.fetchall() - if rows: - graph_categories: dict[str, list[str]] = {} - if graph_name != SPATIAL_GRAPH_NAME: - try: - graph_categories = await asyncio.to_thread( - _graph_poi_categories, - graph_name, - ) - except Exception: # noqa: BLE001 - relational points remain usable - graph_categories = {} - 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 "", - "province": row["province"] or "", - "district": row["district"] or "", - "city": row["city"] or "", - "adcode": row["adcode"] or "", - "towncode": row["towncode"] or "", - "town_name": row["town_name"] or "", - "zone_id": row["zone_id"] or "", - "zone_name": row["zone_name"] or "", - } - for row in rows - if row.get("lng") is not None and row.get("lat") is not None - ] - else: - # A project created entirely from JSON has no project-specific rows in - # the administrative spatial tables. Its map data lives in FalkorDB. + 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} @@ -1262,95 +1346,17 @@ async def map_poi_detail( ): """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: - graph_detail = await asyncio.to_thread( - _read_graph_poi_detail, - graph_name, - place_id, + 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, ) - if graph_detail is None: - raise HTTPException(status_code=404, detail="当前项目中未找到该POI") - return graph_detail - - 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, - } + raise HTTPException(503, "当前项目的 POI 详情读取失败,请检查 FalkorDB 服务") from exc + if detail is None: + raise HTTPException(404, "当前项目中未找到该POI") + return detail @router.post("/plaza/user-query") diff --git a/docs/MAP_POI_READ_FIX.md b/docs/MAP_POI_READ_FIX.md new file mode 100644 index 0000000..3889414 --- /dev/null +++ b/docs/MAP_POI_READ_FIX.md @@ -0,0 +1,32 @@ +# 地图 POI 读取修复与本地验证 + +## 修复范围 + +- 地图 POI 列表和详情直接读取当前项目的 FalkorDB 图谱,不再依赖 PostgreSQL 的 POI 缓存表。 +- 地图相关 FalkorDB 连接统一使用已有的 `FALKORDB_HOST`、`FALKORDB_PORT`、`FALKORDB_PASSWORD` 配置。密码只在后端使用,地图页面不新增登录或配置项。 +- 按 2,000 条分页读取必要的地图字段,避免批量返回完整图谱属性。兼容 `lng/lat` 和 `longitude/latitude`。 +- 使用稳定点位 ID 合并旧图谱的重复标签记录;保留业务分类,排除明确标记 `map_poi=false` 的辅助节点。公交辅助节点保留在公交图层。 +- 连接失败返回 503 并记录项目、图谱上下文;不再表现为成功返回 0 个 POI。 +- 统计数使用当前地图区域的实际点位;项目/图谱切换后不沿用旧点位或旧请求结果。 +- 保留地图模板、熊 Logo、四个分类图标和 POI 详情图片。 + +旧 `guiyang_new2` 项目继续兼容读取其空间图谱 `guiyang_spatial_v1`;新创建的 JSON 图谱直接读取自身,不要求固定的项目英文标识。 + +## 本地验证(2026-08-31) + +在基于 `90f75d4`、仅应用本次地图修改的独立代码副本中验证: + +- 后端单元测试:93 项通过,其中地图相关测试 20 项。 +- 前端测试:44 项通过;TypeScript 检查、Vite 生产构建通过。 +- 本地 `yunyou_libo`:读取 2,799 个不同 POI。 +- 本地贵阳旧空间图谱:读取 80,612 个不同 POI;现有导出快照中的 80,609 个 POI 全部包含在内。旧图谱额外有 3 条餐饮点位,未为凑数删除。 +- 从实际地图 JSON 抽取 2,600 个 POI,经现有 JSON 校验和 FalkorDB 导入器写入独立临时图谱:读取字段与输入一致,跨分页查询和详情 HTTP 路由均返回 200。 +- 上述 POI 测试主动禁止读取 PostgreSQL POI 表;现有图谱只读,临时测试图谱完成后已删除。 + +复跑本地测试(需要本地 FalkorDB 中已有荔波和贵阳测试数据): + +```sh +python3 scripts/verify_map_poi_read.py --map-json /path/to/map_poi_data.json +``` + +本地 FalkorDB 未启用密码;有/无密码时的连接参数由单元测试覆盖,未声称验证了生产密码。浏览器因停在登录页,未完成登录后的地图点击验收。以上验证不等于生产环境部署验收,也不涵盖 280 MB 文件的浏览器上传链路。本次不改上传架构、不导入或删除生产数据。 diff --git a/scripts/verify_map_poi_read.py b/scripts/verify_map_poi_read.py new file mode 100644 index 0000000..1b336a5 --- /dev/null +++ b/scripts/verify_map_poi_read.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Local-only map regression: real graphs + a fresh JSON-imported scratch graph. + +Existing graphs are read-only. Only the UUID graph created by this invocation +is written/deleted; PostgreSQL POI access is deliberately forbidden. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +import sys +import time +import uuid +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from app.api import plaza +from app.auth import get_current_user +from app.config import settings +from app.project_context import ProjectContext +from app.project_lifecycle import _falkor_client, import_falkor_graph, normalize_provision_payload + + +def map_result(graph_name): + with patch("app.api.plaza.get_conn", side_effect=AssertionError("Map reads must not require PostgreSQL POIs")): + return asyncio.run(plaza.map_pois(context=ProjectContext("local_map_test", graph_name, graph_name))) + + +def verify(map_json: Path | None = None): + if settings.falkordb_host.lower() not in {"localhost", "127.0.0.1", "::1"}: + raise RuntimeError("This test is restricted to the local FalkorDB service") + report = {"test": "graph-backed-map", "read_only_existing_graphs": True} + source_nodes = None + if map_json is not None: + with map_json.open(encoding="utf-8") as stream: + source_nodes = json.load(stream)["nodes"] + assert len(source_nodes) > 2000, "Provide a map JSON with more than 2,000 POIs" + for graph_name in ("yunyou_libo", "guiyang_new2"): + start = time.monotonic() + payload = map_result(graph_name) + ids = {item["id"] for item in payload["items"]} + assert len(ids) == payload["total"], "Duplicate map POI ids" + assert payload["source"] == "falkordb" + city_count = sum(str(item["adcode"]).startswith("5201") for item in payload["items"]) + report[graph_name] = { + "graph": payload["graph_name"], "points": payload["total"], + "guiyang_city_points": city_count, "categories": payload["categories"], + "elapsed_seconds": round(time.monotonic() - start, 3), + } + if graph_name == "yunyou_libo": + assert payload["total"] == 2799, "Libo point count changed" + else: + assert city_count > 0, "Guiyang map has no points in its selected city" + if source_nodes is not None: + source_ids = {str(node["properties"].get("element_id") or node["id"]) for node in source_nodes} + assert not source_ids - ids, "Exported POIs are missing from the local source graph" + report["export_snapshot"] = { + "points": len(source_ids), "missing_from_local_graph": 0, + "extra_points_in_local_graph": len(ids - source_ids), + } + + client = _falkor_client() + scratch_name = "codex_map_verify_" + uuid.uuid4().hex + created = False + try: + existing = {name.decode() if isinstance(name, bytes) else str(name) for name in client.list_graphs()} + assert scratch_name not in existing + source_graph = client.select_graph("guiyang_spatial_v1") + if source_nodes is None: + rows = source_graph.ro_query( + "MATCH (n) WHERE n.element_id IS NOT NULL " + "AND (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.adcode STARTS WITH '5201' RETURN labels(n),properties(n) LIMIT 2600", + timeout=30_000, + ).result_set + else: + rows = [(node.get("labels") or [node["type"]], node["properties"]) for node in source_nodes[:2600]] + nodes, seen, entity_types = [], set(), {} + for labels, properties in rows: + external_id = str(properties["element_id"]) + if external_id in seen: + continue + seen.add(external_id) + for label in labels: + entity_types[label] = {"fields": {"name": {"type": "string", "required": True}}} + nodes.append({"id": external_id, "type": labels[0], "labels": labels, "properties": properties}) + assert len(nodes) > 2000, "Test must cross the map query page boundary" + body = { + "project_id": scratch_name, "display_name": "城市知识图谱-贵州*贵阳", + "spatial_map": {"enabled": True, "scope": "guizhou", "region_name": "贵阳市", + "region_adcode": "520100", "region_level": "city"}, + "schema": {"namespace": scratch_name, "version": "1.0.0", + "entity_types": entity_types, "relation_types": {}}, + "graph_data": {"nodes": nodes, "relations": []}, + } + normalized = normalize_provision_payload(body) + expected = {item["id"]: item for item in plaza._graph_map_poi_items( + [[node["id"], node["labels"], node["properties"]] for node in nodes])} + created = True + counts = import_falkor_graph(scratch_name, normalized["graph_data"]) + assert counts == {"nodes": len(nodes), "relations": 0} + actual = map_result(scratch_name) + indexed = {item["id"]: item for item in actual["items"]} + assert indexed == expected, "Imported map fields do not round-trip intact" + + # Exercise real HTTP routing/serialization as well as direct readers. + app = FastAPI() + app.include_router(plaza.router, prefix="/v1/admin") + app.dependency_overrides[get_current_user] = lambda: {"username": "local_map_test", "roles": ["admin"]} + headers = {"X-KG-Tenant-Id": scratch_name, "X-KG-Project-Id": scratch_name, + "X-KG-Graph-Name": scratch_name} + with TestClient(app) as http, patch( + "app.api.plaza.get_conn", side_effect=AssertionError("No PostgreSQL POI rows in a new import") + ): + response = http.get("/v1/admin/plaza/map-pois", headers=headers) + assert response.status_code == 200, response.text[:300] + assert response.json()["total"] == len(expected) + sample = next(iter(expected.values())) + detail = http.get("/v1/admin/plaza/map-pois/" + sample["id"], headers=headers) + assert detail.status_code == 200, detail.text[:300] + data = detail.json() + assert (data["id"], data["name"], data["lng"], data["lat"]) == ( + sample["id"], sample["name"], sample["lng"], sample["lat"]) + report["fresh_json_import"] = { + "display_name": body["display_name"], "region": body["spatial_map"], + "nodes": counts["nodes"], "map_pois": len(expected), + "http_map_status": 200, "http_detail_status": 200, + "map_payload_matches_input": True, "postgres_pois_required": False, + } + report["status"] = "passed" + finally: + try: + if created: + remaining = {name.decode() if isinstance(name, bytes) else str(name) for name in client.list_graphs()} + if scratch_name in remaining: + client.select_graph(scratch_name).delete() + report["scratch_graph_removed"] = True + finally: + client.close() + return report + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--map-json", type=Path, help="Optional exported map POI JSON used for the import sample") + args = parser.parse_args() + print(json.dumps(verify(args.map_json), ensure_ascii=False, indent=2)) diff --git a/tests/test_plaza_graph_fallback.py b/tests/test_plaza_graph_fallback.py index 92765ba..952f092 100644 --- a/tests/test_plaza_graph_fallback.py +++ b/tests/test_plaza_graph_fallback.py @@ -1,16 +1,81 @@ import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException from app.api.plaza import ( _build_libo_bus_route_payload, _graph_map_poi_items, _graph_poi_detail_payload, _graph_zone_payload, + _map_falkor_client, + _read_graph_poi_detail, + _read_graph_map_pois, _resolve_spatial_graph_name, + map_pois, + map_poi_detail, ) +from app.config import settings +from app.project_context import ProjectContext class PlazaGraphFallbackTests(unittest.TestCase): - def test_graph_pois_cover_business_places_and_bus_stops(self) -> None: + def test_map_connection_uses_configured_credentials_and_finite_timeouts(self) -> None: + for password in ("", "local-unit-test-password"): + with ( + self.subTest(password_configured=bool(password)), + patch.object(settings, "falkordb_password", password), + patch("app.api.plaza.FalkorDB") as factory, + ): + _map_falkor_client() + kwargs = factory.call_args.kwargs + self.assertEqual(kwargs["host"], settings.falkordb_host) + self.assertEqual(kwargs["port"], settings.falkordb_port) + self.assertEqual(kwargs["socket_connect_timeout"], 5) + self.assertGreater(kwargs["socket_timeout"], 30) + if password: + self.assertEqual(kwargs["password"], password) + else: + self.assertNotIn("password", kwargs) + + def test_imported_poi_keeps_json_id_when_no_amap_id_is_present(self) -> None: + items = _graph_map_poi_items([[ + 0, ["Hotel"], + {"__kg_node_id": "guiyang-hotel-001", "name": "贵阳测试酒店", + "longitude": 106.63, "latitude": 26.65}, + ]]) + self.assertEqual(items[0]["id"], "guiyang-hotel-001") + graph = MagicMock() + graph.query.return_value.result_set = [[["Hotel"], { + "__kg_node_id": "guiyang-hotel-001", "name": "贵阳测试酒店", + "lng": None, "lat": None, "longitude": 106.63, "latitude": 26.65, + }]] + with patch("app.api.plaza._map_falkor_client") as factory: + factory.return_value.select_graph.return_value = graph + detail = _read_graph_poi_detail("guiyang_import", items[0]["id"]) + self.assertEqual(detail["id"], items[0]["id"]) + self.assertEqual((detail["lng"], detail["lat"]), (106.63, 26.65)) + self.assertEqual(graph.query.call_args.args[1]["place_id"], "guiyang-hotel-001") + factory.return_value.close.assert_called_once() + + def test_map_read_pages_before_filtering_and_merges_duplicate_poi_facets(self) -> None: + graph = MagicMock() + helper_rows = [[index, ["GeoCell"], {"lng": 106.6, "lat": 26.6}] for index in range(2000)] + business_rows = [ + [2000, ["Hotel"], {"element_id": "amap:H1", "name": "同一地点", "lng": 106.6, "lat": 26.6}], + [2001, ["FoodPlace"], {"element_id": "amap:H1", "name": "同一地点", "lng": 106.6, "lat": 26.6}], + ] + graph.query.side_effect = [ + MagicMock(result_set=helper_rows), MagicMock(result_set=business_rows), + ] + with patch("app.api.plaza._map_falkor_client") as factory: + factory.return_value.select_graph.return_value = graph + items = _read_graph_map_pois("fresh_guiyang") + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["categories"], ["酒店", "美食"]) + self.assertIn("SKIP 2000 LIMIT 2000", graph.query.call_args.args[0]) + + def test_graph_pois_keep_business_places_out_of_the_bus_route_layer(self) -> None: rows = [ [ "poi:hotel-1", @@ -44,9 +109,8 @@ class PlazaGraphFallbackTests(unittest.TestCase): items = _graph_map_poi_items(rows) - self.assertEqual(len(items), 2) + self.assertEqual(len(items), 1) self.assertEqual(items[0]["categories"], ["酒店"]) - self.assertEqual(items[1]["categories"], ["公交站"]) self.assertEqual(items[0]["zone_id"], "A01") def test_city_place_nodes_use_place_type_as_dynamic_business_category(self) -> None: @@ -81,6 +145,83 @@ class PlazaGraphFallbackTests(unittest.TestCase): self.assertEqual(items, []) + def test_explicit_semantic_only_node_is_excluded_but_legacy_poi_is_kept(self) -> None: + properties = { + "element_id": "amap:semantic-place", "name": "语义地点", + "lng": 106.63, "lat": 26.65, "place_type": "sight", + } + items = _graph_map_poi_items([ + [1, ["ScenicSpot"], {**properties, "map_poi": False}], + [2, ["ScenicSpot"], {**properties, "element_id": "amap:legacy-place"}], + ]) + self.assertEqual([item["id"] for item in items], ["amap:legacy-place"]) + + def test_merged_semantic_labels_do_not_change_verified_map_categories(self) -> None: + items = _graph_map_poi_items([[ + 1, ["Place", "ScenicSpot", "FoodPlace"], + { + "element_id": "amap:merged", "name": "多业态地点", + "lng": 106.6, "lat": 26.6, "type_label": "景点", + "map_poi": True, "map_source_labels": ["Place", "ScenicSpot"], + }, + ]]) + self.assertEqual(items[0]["categories"], ["景点"]) + + def test_bulk_graph_map_query_only_returns_lightweight_projection(self) -> None: + projected_row = [ + 7, + ["Hotel"], + "amap:H7", + "H7", + "荔波轻量酒店", + "酒店", + "hotel", + None, + "107.88", + None, + "25.40", + "测试地址", + "荔波县", + "黔南布依族苗族自治州", + "贵州省", + "522722", + "522722100000", + "玉屏街道", + "A01", + "古城片区", + True, + ["Place", "Hotel"], + ] + graph = MagicMock() + graph.query.return_value = MagicMock(result_set=[projected_row]) + client = MagicMock() + client.select_graph.return_value = graph + + with patch("app.api.plaza.FalkorDB", return_value=client): + items = _read_graph_map_pois("server_import_graph") + + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["name"], "荔波轻量酒店") + self.assertEqual(items[0]["lng"], 107.88) + self.assertEqual(items[0]["lat"], 25.40) + query = graph.query.call_args.args[0] + self.assertNotIn("properties(n)", query) + self.assertIn("n.element_id", query) + self.assertIn("n.map_poi IS NULL OR n.map_poi = true", query) + self.assertEqual(graph.query.call_args.kwargs["timeout"], 30_000) + client.close.assert_called_once_with() + + def test_invalid_graph_coordinates_are_not_returned_to_the_map(self) -> None: + items = _graph_map_poi_items( + [[ + "poi:bad-coordinate", + ["Hotel"], + {"name": "错误坐标", "lng": "not-a-number", "lat": 25.4}, + ]] + ) + + self.assertEqual(items, []) + def test_legacy_city_project_uses_its_explicit_spatial_companion(self) -> None: self.assertEqual(_resolve_spatial_graph_name("guiyang_new2"), "guiyang_spatial_v1") self.assertEqual(_resolve_spatial_graph_name("city_import_v3"), "city_import_v3") @@ -130,6 +271,102 @@ class PlazaGraphFallbackTests(unittest.TestCase): self.assertEqual(payload["stop_count"], 2) self.assertEqual(payload["route_stop_count"], 2) + def test_graph_detail_preserves_enrichment_photos(self) -> None: + payload = _graph_poi_detail_payload("imported", ["Hotel"], { + "element_id": "hotel-1", "lng": 106.6, "lat": 26.6, + "photo_urls": ["https://example.com/original.jpg"], + "dianping_shop_image": "https://example.com/food.jpg", + "hotel_image_samples": '["https://example.com/hotel.jpg","https://example.com/original.jpg"]', + }) + self.assertEqual(payload["photo_urls"], [ + "https://example.com/original.jpg", "https://example.com/food.jpg", + "https://example.com/hotel.jpg", + ]) + + +class PlazaMapEndpointSourceTests(unittest.IsolatedAsyncioTestCase): + async def test_detail_connection_failure_is_not_reported_as_missing_poi(self) -> None: + context = ProjectContext("city", "city", "fresh_guiyang") + with patch("app.api.plaza._read_graph_poi_detail", side_effect=RuntimeError("unavailable")): + with self.assertRaises(HTTPException) as raised: + await map_poi_detail("hotel-1", context=context) + self.assertEqual(raised.exception.status_code, 503) + + async def test_poi_detail_does_not_require_a_postgresql_poi_table(self) -> None: + context = ProjectContext("city", "city_guizhou_guiyang", "fresh_guiyang") + with ( + patch("app.api.plaza.get_conn", side_effect=AssertionError("No PostgreSQL POI table")), + patch("app.api.plaza._read_graph_poi_detail", return_value={"id": "hotel-1", "name": "贵阳酒店"}) as read, + ): + detail = await map_poi_detail("hotel-1", context=context) + read.assert_called_once_with("fresh_guiyang", "hotel-1") + self.assertEqual(detail["name"], "贵阳酒店") + + async def test_missing_poi_detail_is_not_taken_from_another_project(self) -> None: + context = ProjectContext("city", "city", "fresh_guiyang") + with patch("app.api.plaza._read_graph_poi_detail", return_value=None): + with self.assertRaises(HTTPException) as raised: + await map_poi_detail("missing", context=context) + self.assertEqual(raised.exception.status_code, 404) + + async def test_map_endpoint_uses_current_falkordb_as_single_source( + self, + ) -> None: + graph_items = [ + { + "id": "amap:H1", + "gaode_poi_id": "H1", + "name": "荔波测试酒店", + "category": "酒店", + "categories": ["酒店"], + "place_type": "hotel", + "lng": 107.88, + "lat": 25.40, + "address": "", + "district": "荔波县", + "city": "黔南布依族苗族自治州", + "province": "贵州省", + "adcode": "522722", + "towncode": "", + "town_name": "", + "zone_id": "", + "zone_name": "", + } + ] + context = ProjectContext("tenant", "project", "server_import_graph") + graph_read = AsyncMock(return_value=graph_items) + + with ( + patch( + "app.api.plaza.get_conn", + side_effect=AssertionError("地图运行时不应读取 PostgreSQL POI"), + ) as relational_read, + patch("app.api.plaza.asyncio.to_thread", graph_read), + ): + payload = await map_pois(context=context) + + self.assertEqual(payload["source"], "falkordb") + self.assertEqual(payload["graph_name"], "server_import_graph") + self.assertEqual(payload["total"], 1) + self.assertEqual(payload["categories"], [{"category": "酒店", "count": 1}]) + relational_read.assert_not_called() + graph_read.assert_awaited_once_with(_read_graph_map_pois, "server_import_graph") + + async def test_map_endpoint_reports_graph_read_failure_instead_of_empty_data( + self, + ) -> None: + context = ProjectContext("tenant", "project", "server_import_graph") + + with patch( + "app.api.plaza.asyncio.to_thread", + AsyncMock(side_effect=RuntimeError("FalkorDB timeout")), + ): + with self.assertRaises(HTTPException) as raised: + await map_pois(context=context) + + self.assertEqual(raised.exception.status_code, 503) + self.assertIn("FalkorDB", raised.exception.detail) + if __name__ == "__main__": unittest.main()