fix: bind knowledge maps to explicit regions
This commit is contained in:
1 parent
329a94d457
commit
90f75d41ae
15 files changed
+1126
-275
No files matched your search
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the current Yunyou Libo FalkorDB graph as a lossless import bundle.
|
||||
"""Export any live FalkorDB graph as a lossless project import bundle.
|
||||
|
||||
The live ``yunyou_libo`` graph is the authority for this export. Earlier
|
||||
versions rebuilt a different graph from PostgreSQL rows: duplicate AMap POIs
|
||||
The selected live graph is the authority for this export. Earlier project
|
||||
fixtures rebuilt a different graph from PostgreSQL rows: duplicate AMap POIs
|
||||
were split, media and links were modeled twice, and unrelated custom tables
|
||||
were imported automatically. That made the JSON totals differ from the graph
|
||||
shown by the project.
|
||||
@@ -40,8 +40,6 @@ PROJECT_ID = "yunyou_libo"
|
||||
GRAPH_NAME = "yunyou_libo"
|
||||
DISPLAY_NAME = "云游荔波"
|
||||
SCHEMA_VERSION = "3.0.0"
|
||||
SPATIAL_MAP = {"enabled": True, "scope": "libo"}
|
||||
|
||||
LABEL_PRIORITY = (
|
||||
"Hotel",
|
||||
"FoodPlace",
|
||||
@@ -196,46 +194,69 @@ def export_live_graph(graph: Any) -> tuple[list[dict[str, Any]], list[dict[str,
|
||||
exported_id_by_internal: dict[str, str] = {}
|
||||
exported_ids: set[str] = set()
|
||||
|
||||
for row in graph.query("MATCH (n) RETURN n").result_set:
|
||||
raw_node = row[0]
|
||||
source_internal_id = internal_id(raw_node)
|
||||
properties = properties_of(raw_node)
|
||||
node_id = str(properties.get("__kg_node_id") or f"falkor:{source_internal_id}")
|
||||
if node_id in exported_ids:
|
||||
raise RuntimeError(f"图谱存在重复导出节点 ID:{node_id}")
|
||||
labels = labels_of(raw_node)
|
||||
exported_ids.add(node_id)
|
||||
exported_id_by_internal[source_internal_id] = node_id
|
||||
nodes.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"type": primary_label(labels),
|
||||
"labels": labels,
|
||||
"properties": properties,
|
||||
}
|
||||
)
|
||||
page_size = 5000
|
||||
offset = 0
|
||||
while True:
|
||||
rows = graph.query(
|
||||
"MATCH (n) RETURN n ORDER BY id(n) "
|
||||
f"SKIP {offset} LIMIT {page_size}"
|
||||
).result_set
|
||||
for row in rows:
|
||||
raw_node = row[0]
|
||||
source_internal_id = internal_id(raw_node)
|
||||
properties = properties_of(raw_node)
|
||||
node_id = str(properties.get("__kg_node_id") or f"falkor:{source_internal_id}")
|
||||
if node_id in exported_ids:
|
||||
raise RuntimeError(f"图谱存在重复导出节点 ID:{node_id}")
|
||||
labels = labels_of(raw_node)
|
||||
exported_ids.add(node_id)
|
||||
exported_id_by_internal[source_internal_id] = node_id
|
||||
nodes.append(
|
||||
{
|
||||
"id": node_id,
|
||||
"type": primary_label(labels),
|
||||
"labels": labels,
|
||||
"properties": properties,
|
||||
}
|
||||
)
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
|
||||
relations: list[dict[str, Any]] = []
|
||||
rows = graph.query("MATCH (source)-[relation]->(target) RETURN source, relation, target")
|
||||
for source, edge, target in rows.result_set:
|
||||
source_id = exported_id_by_internal.get(internal_id(source))
|
||||
target_id = exported_id_by_internal.get(internal_id(target))
|
||||
if not source_id or not target_id:
|
||||
raise RuntimeError("关系引用了未导出的节点")
|
||||
relations.append(
|
||||
{
|
||||
"type": relation_type_of(edge),
|
||||
"source": source_id,
|
||||
"target": target_id,
|
||||
"properties": properties_of(edge),
|
||||
}
|
||||
)
|
||||
offset = 0
|
||||
while True:
|
||||
rows = graph.query(
|
||||
"MATCH (source)-[relation]->(target) "
|
||||
"RETURN source, relation, target ORDER BY id(relation) "
|
||||
f"SKIP {offset} LIMIT {page_size}"
|
||||
).result_set
|
||||
for source, edge, target in rows:
|
||||
source_id = exported_id_by_internal.get(internal_id(source))
|
||||
target_id = exported_id_by_internal.get(internal_id(target))
|
||||
if not source_id or not target_id:
|
||||
raise RuntimeError("关系引用了未导出的节点")
|
||||
relations.append(
|
||||
{
|
||||
"type": relation_type_of(edge),
|
||||
"source": source_id,
|
||||
"target": target_id,
|
||||
"properties": properties_of(edge),
|
||||
}
|
||||
)
|
||||
if len(rows) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return nodes, relations
|
||||
|
||||
|
||||
def build_schema(
|
||||
nodes: list[dict[str, Any]],
|
||||
relations: list[dict[str, Any]],
|
||||
*,
|
||||
project_id: str,
|
||||
graph_name: str,
|
||||
display_name: str,
|
||||
) -> dict[str, Any]:
|
||||
rows_by_label: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
primary_type_by_id: dict[str, str] = {}
|
||||
@@ -271,10 +292,10 @@ def build_schema(
|
||||
}
|
||||
|
||||
return {
|
||||
"namespace": PROJECT_ID,
|
||||
"namespace": project_id,
|
||||
"version": SCHEMA_VERSION,
|
||||
"display_name": "云游荔波当前图谱快照 Schema v3",
|
||||
"description": "从当前 yunyou_libo FalkorDB 图谱无损导出,保留多标签节点。",
|
||||
"display_name": f"{display_name}当前图谱快照 Schema v3",
|
||||
"description": f"从当前 {graph_name} FalkorDB 图谱无损导出,保留多标签节点。",
|
||||
"entity_types": entity_types,
|
||||
"relation_types": relation_types,
|
||||
}
|
||||
@@ -302,9 +323,14 @@ def exported_counts(
|
||||
primary_type_counts[item["type"]] += 1
|
||||
properties = item["properties"]
|
||||
has_coordinates = properties.get("lng") is not None and properties.get("lat") is not None
|
||||
if not has_coordinates:
|
||||
has_coordinates = (
|
||||
properties.get("longitude") is not None
|
||||
and properties.get("latitude") is not None
|
||||
)
|
||||
if has_coordinates:
|
||||
coordinate_nodes += 1
|
||||
if labels & MAP_POI_LABELS:
|
||||
if labels & MAP_POI_LABELS or properties.get("place_type") or properties.get("type_label"):
|
||||
map_poi_nodes += 1
|
||||
if "BusStop" in labels:
|
||||
bus_stop_nodes += 1
|
||||
@@ -328,7 +354,8 @@ def source_counts(graph: Any) -> dict[str, Any]:
|
||||
relation_count = int(graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0])
|
||||
coordinate_count = int(
|
||||
graph.query(
|
||||
"MATCH (n) WHERE n.lng IS NOT NULL AND n.lat IS NOT NULL RETURN count(n)"
|
||||
"MATCH (n) WHERE coalesce(n.lng,n.longitude) IS NOT NULL "
|
||||
"AND coalesce(n.lat,n.latitude) IS NOT NULL RETURN count(n)"
|
||||
).result_set[0][0]
|
||||
)
|
||||
label_counts: Counter[str] = Counter()
|
||||
@@ -405,18 +432,51 @@ def dump_json(path: Path, value: Any, *, pretty: bool = False) -> None:
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def export(output_dir: Path) -> dict[str, Any]:
|
||||
def safe_prefix(value: str) -> str:
|
||||
normalized = "".join(
|
||||
char.lower() if char.isalnum() else "_"
|
||||
for char in value.strip()
|
||||
)
|
||||
return "_".join(part for part in normalized.split("_") if part) or "graph"
|
||||
|
||||
|
||||
def export(
|
||||
output_dir: Path,
|
||||
*,
|
||||
project_id: str = PROJECT_ID,
|
||||
graph_name: str = GRAPH_NAME,
|
||||
display_name: str = DISPLAY_NAME,
|
||||
map_enabled: bool = True,
|
||||
spatial_scope: str = "libo",
|
||||
region_name: str | None = None,
|
||||
region_adcode: str | None = None,
|
||||
region_level: str | None = None,
|
||||
output_prefix: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
default_region = (
|
||||
("荔波县", "522722", "district")
|
||||
if spatial_scope == "libo"
|
||||
else ("贵州省", "520000", "province")
|
||||
)
|
||||
spatial_map = {
|
||||
"enabled": map_enabled,
|
||||
"scope": spatial_scope,
|
||||
"region_name": (region_name or default_region[0]) if map_enabled else "",
|
||||
"region_adcode": (region_adcode or default_region[1]) if map_enabled else "",
|
||||
"region_level": (region_level or default_region[2]) if map_enabled else "province",
|
||||
}
|
||||
prefix = safe_prefix(output_prefix or project_id)
|
||||
client = make_client()
|
||||
try:
|
||||
graph_names = {
|
||||
item.decode("utf-8") if isinstance(item, bytes) else str(item)
|
||||
for item in client.list_graphs()
|
||||
}
|
||||
if GRAPH_NAME not in graph_names:
|
||||
raise RuntimeError(f"FalkorDB 中不存在图谱 {GRAPH_NAME!r}")
|
||||
graph = client.select_graph(GRAPH_NAME)
|
||||
if graph_name not in graph_names:
|
||||
raise RuntimeError(f"FalkorDB 中不存在图谱 {graph_name!r}")
|
||||
graph = client.select_graph(graph_name)
|
||||
live_counts = source_counts(graph)
|
||||
nodes, relations = export_live_graph(graph)
|
||||
finally:
|
||||
@@ -424,15 +484,21 @@ def export(output_dir: Path) -> dict[str, Any]:
|
||||
|
||||
counts = exported_counts(nodes, relations)
|
||||
checks = validate_snapshot(live_counts, counts, nodes, relations)
|
||||
schema = build_schema(nodes, relations)
|
||||
schema = build_schema(
|
||||
nodes,
|
||||
relations,
|
||||
project_id=project_id,
|
||||
graph_name=graph_name,
|
||||
display_name=display_name,
|
||||
)
|
||||
graph_data = {
|
||||
"_bundle": {
|
||||
"format": "znkg-falkordb-snapshot-v3",
|
||||
"source": "falkordb",
|
||||
"project_id": PROJECT_ID,
|
||||
"graph_name": GRAPH_NAME,
|
||||
"project_id": project_id,
|
||||
"graph_name": graph_name,
|
||||
"generated_at": generated_at,
|
||||
"spatial_map": SPATIAL_MAP,
|
||||
"spatial_map": spatial_map,
|
||||
"source_snapshot_counts": live_counts,
|
||||
},
|
||||
"nodes": nodes,
|
||||
@@ -440,9 +506,9 @@ def export(output_dir: Path) -> dict[str, Any]:
|
||||
}
|
||||
provision = {
|
||||
"format": "znkg-project-bundle-v3",
|
||||
"project_id": PROJECT_ID,
|
||||
"display_name": DISPLAY_NAME,
|
||||
"spatial_map": SPATIAL_MAP,
|
||||
"project_id": project_id,
|
||||
"display_name": display_name,
|
||||
"spatial_map": spatial_map,
|
||||
"schema": schema,
|
||||
"graph_data": graph_data,
|
||||
}
|
||||
@@ -452,10 +518,10 @@ def export(output_dir: Path) -> dict[str, Any]:
|
||||
if normalized["counts"]["relations"] != counts["relations"]:
|
||||
raise RuntimeError("后端校验后的关系数量不一致")
|
||||
|
||||
schema_path = output_dir / "yunyou_libo_full_schema.v3.json"
|
||||
graph_path = output_dir / "yunyou_libo_full_graph_data.v3.json"
|
||||
bundle_path = output_dir / "yunyou_libo_full_bundle.v3.json"
|
||||
manifest_path = output_dir / "yunyou_libo_full_manifest.v3.json"
|
||||
schema_path = output_dir / f"{prefix}_full_schema.v3.json"
|
||||
graph_path = output_dir / f"{prefix}_full_graph_data.v3.json"
|
||||
bundle_path = output_dir / f"{prefix}_full_bundle.v3.json"
|
||||
manifest_path = output_dir / f"{prefix}_full_manifest.v3.json"
|
||||
dump_json(schema_path, schema, pretty=True)
|
||||
dump_json(graph_path, graph_data)
|
||||
dump_json(bundle_path, provision)
|
||||
@@ -466,12 +532,12 @@ def export(output_dir: Path) -> dict[str, Any]:
|
||||
}
|
||||
manifest = {
|
||||
"format": "znkg-full-graph-manifest-v3",
|
||||
"project_id": PROJECT_ID,
|
||||
"graph_name": GRAPH_NAME,
|
||||
"project_id": project_id,
|
||||
"graph_name": graph_name,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": generated_at,
|
||||
"source": "falkordb",
|
||||
"spatial_map": SPATIAL_MAP,
|
||||
"spatial_map": spatial_map,
|
||||
"source_snapshot_counts": live_counts,
|
||||
"export_counts": counts,
|
||||
"validation": "passed",
|
||||
@@ -491,8 +557,46 @@ def main() -> None:
|
||||
required=True,
|
||||
help="Directory for schema, graph-data, bundle and manifest JSON files.",
|
||||
)
|
||||
parser.add_argument("--project-id", default=PROJECT_ID)
|
||||
parser.add_argument("--graph-name", default=GRAPH_NAME)
|
||||
parser.add_argument("--display-name", default=DISPLAY_NAME)
|
||||
parser.add_argument(
|
||||
"--map-enabled",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=True,
|
||||
help="Whether the exported project should enable the knowledge-map template.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--map-scope",
|
||||
choices=("libo", "guizhou"),
|
||||
default="libo",
|
||||
help="Boundary scope used by the single knowledge-map template.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-prefix",
|
||||
default=None,
|
||||
help="Optional filename prefix; defaults to project-id.",
|
||||
)
|
||||
parser.add_argument("--region-name", default=None)
|
||||
parser.add_argument("--region-adcode", default=None)
|
||||
parser.add_argument(
|
||||
"--region-level",
|
||||
choices=("province", "city", "district"),
|
||||
default=None,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
manifest = export(args.output_dir.expanduser().resolve())
|
||||
manifest = export(
|
||||
args.output_dir.expanduser().resolve(),
|
||||
project_id=args.project_id,
|
||||
graph_name=args.graph_name,
|
||||
display_name=args.display_name,
|
||||
map_enabled=args.map_enabled,
|
||||
spatial_scope=args.map_scope,
|
||||
region_name=args.region_name,
|
||||
region_adcode=args.region_adcode,
|
||||
region_level=args.region_level,
|
||||
output_prefix=args.output_prefix,
|
||||
)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user