fix: read map POIs from authenticated FalkorDB

This commit is contained in:
xuelong committed 2026-08-31 01:51:18 -07:00
1 parent 90f75d41ae
commit 712ca1418c
7 files changed
+646 -206

No files matched your search

+12 -11
View File
@@ -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<Record<string, unknown>>({});
const [graphCategories, setGraphCategories] = useState<EntityCategory[]>([]);
const [graphCategoriesLoading, setGraphCategoriesLoading] = useState(true);
const [mapCoverage, setMapCoverage] = useState<CoverageSnapshot | null>(null);
const [projectContext, setProjectContextState] = useState<KgProjectContext>(getProjectContext());
const [projectSummary, setProjectSummary] = useState<ProjectSummary | null>(null);
const [spatialMap, setSpatialMap] = useState<SpatialMapConfig>(() => (
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<string, unknown>
: {};
@@ -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<Record<string, unknown>>) || [];
@@ -220,7 +221,7 @@ export default function PlazaOverview() {
{isKnowledgeMapProject && (
<KnowledgeMap
cov={mapCoverage}
key={`${projectContext.projectId}:${projectContext.graphName}:${spatialMap.region_adcode}`}
projectId={projectContext.projectId}
projectName={projectSummary?.display_name || projectSummary?.name || projectContext.projectId}
scope={spatialMap.scope}
@@ -2002,7 +2002,6 @@ const loadAmapSdk = (jsKey: string, securityJsCode?: string) => {
};
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<HTMLDivElement | null>(null);
const [poiLoading, setPoiLoading] = useState(false);
const [poiLoading, setPoiLoading] = useState(true);
const [poiError, setPoiError] = useState("");
const [selectedPoi, setSelectedPoi] = useState<MapPoi | null>(null);
const [poiDetail, setPoiDetail] = useState<MapPoiDetail | null>(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({
}}
>
<div style={{ color: "#888", fontSize: 12, marginBottom: 4 }}>
{isLibo ? `${activeRegionName}馆藏汇总` : "省域馆藏汇总"}
{regionLevel === "province" ? "省域馆藏汇总" : `${activeRegionName}馆藏汇总`}
</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1.15 }}>
{(isLibo ? regionPois.length : (cov?.total ?? regionPois.length)).toLocaleString()}
{poiLoading ? "加载中…" : poiError ? "—" : regionPois.length.toLocaleString()}
</div>
<div style={{ color: "#888", fontSize: 12, marginTop: 4 }}>
{isLibo && selectedTowncode
+11 -2
View File
@@ -25,14 +25,23 @@ test("every map-enabled project uses the single KnowledgeMap template", () => {
assert.doesNotMatch(overviewSource, /<SuperAgentPanel/);
});
test("the shared map preserves the four approved shortcut logos and province summary", () => {
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, /请选择地图区域/);
+191 -185
View File
@@ -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")
+32
View File
@@ -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 文件的浏览器上传链路。本次不改上传架构、不导入或删除生产数据。
+155
View File
@@ -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))
+240 -3
View File
@@ -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()