fix: read map POIs from authenticated FalkorDB
This commit is contained in:
1 parent
90f75d41ae
commit
712ca1418c
7 files changed
+646
-206
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 文件的浏览器上传链路。本次不改上传架构、不导入或删除生产数据。
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user