Files
Cloud-Tour-to-Libo/scripts/export_yunyou_libo_full_graph_bundle.py

605 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""Export any live FalkorDB graph as a lossless project import bundle.
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.
This exporter snapshots every live node, label, property, relationship and
relationship property, then compares the JSON with independent FalkorDB count
queries before writing a passed manifest. Multi-label nodes are preserved so
shared hotel/food POIs and BusLine/BusRoute nodes do not change shape on import.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import sys
from collections import Counter, defaultdict
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
from typing import Any, Iterable, Mapping
from uuid import UUID
from falkordb import FalkorDB
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.config import settings # noqa: E402
from app.project_lifecycle import normalize_provision_payload # noqa: E402
PROJECT_ID = "yunyou_libo"
GRAPH_NAME = "yunyou_libo"
DISPLAY_NAME = "云游荔波"
SCHEMA_VERSION = "3.0.0"
LABEL_PRIORITY = (
"Hotel",
"FoodPlace",
"ScenicSpot",
"TransitFacility",
"BusStop",
"BusRoute",
"BusLine",
"GeoCell",
"ScenicArea",
"Area",
"Place",
)
NODE_LABELS = {
"Place": "地点",
"Hotel": "酒店",
"FoodPlace": "美食店铺",
"ScenicSpot": "景点",
"TransitFacility": "交通设施",
"BusStop": "公交站",
"BusLine": "公交线路",
"BusRoute": "公交运行方向",
"GeoCell": "H3 空间网格",
"ScenicArea": "景区片区",
"Area": "行政区域",
}
RELATION_LABELS = {
"LOCATED_IN": "位于行政区域",
"IN_H3_R9": "位于 H3 网格",
"STOPS_AT": "途经站点",
"NEXT_STOP": "下一站",
"PART_OF": "行政隶属",
"PART_OF_SCENIC_AREA": "属于景区片区",
}
MAP_POI_LABELS = {"Hotel", "FoodPlace", "ScenicSpot", "TransitFacility"}
def jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
return value if math.isfinite(value) else str(value)
if isinstance(value, Decimal):
return int(value) if value == value.to_integral_value() else float(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
if isinstance(value, Mapping):
return {str(key): jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [jsonable(item) for item in value]
return str(value)
def properties_of(value: Any) -> dict[str, Any]:
properties = getattr(value, "properties", None) or {}
return {str(key): jsonable(item) for key, item in dict(properties).items()}
def internal_id(value: Any) -> str:
identifier = getattr(value, "id", None)
if identifier is None:
raise RuntimeError("FalkorDB 返回了缺少内部 ID 的节点")
return str(identifier)
def labels_of(value: Any) -> list[str]:
labels = [str(item) for item in (getattr(value, "labels", None) or [])]
if not labels:
raise RuntimeError(f"FalkorDB 节点 {internal_id(value)} 没有标签")
order = {label: index for index, label in enumerate(LABEL_PRIORITY)}
return sorted(set(labels), key=lambda label: (order.get(label, len(order)), label))
def primary_label(labels: Iterable[str]) -> str:
label_set = set(labels)
for label in LABEL_PRIORITY:
if label in label_set and label not in {"Place", "BusLine"}:
return label
for label in LABEL_PRIORITY:
if label in label_set:
return label
return sorted(label_set)[0]
def relation_type_of(edge: Any) -> str:
relation_type = str(getattr(edge, "relation", "") or "").strip()
if not relation_type:
raise RuntimeError("FalkorDB 返回了缺少类型的关系")
return relation_type
def inferred_value_type(key: str, values: list[Any]) -> str:
populated = [value for value in values if value is not None]
if not populated:
return "any"
kinds: set[str] = set()
for value in populated:
if isinstance(value, bool):
kinds.add("boolean")
elif isinstance(value, int):
kinds.add("integer")
elif isinstance(value, float):
kinds.add("number")
elif isinstance(value, list):
kinds.add("array")
elif isinstance(value, dict):
kinds.add("object")
else:
kinds.add("string")
if kinds <= {"integer", "number"}:
return "number" if "number" in kinds else "integer"
if len(kinds) == 1:
only = next(iter(kinds))
if only == "string" and (key.endswith("_at") or key.endswith("_time")):
return "datetime"
return only
return "any"
def infer_fields(property_rows: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
values: dict[str, list[Any]] = defaultdict(list)
for properties in property_rows:
for key, value in properties.items():
values[key].append(value)
return {
key: {"type": inferred_value_type(key, items), "required": False}
for key, items in sorted(values.items())
}
def make_client() -> FalkorDB:
options: dict[str, Any] = {
"host": settings.falkordb_host,
"port": settings.falkordb_port,
"socket_timeout": 60,
"socket_connect_timeout": 5,
}
if settings.falkordb_password:
options["password"] = settings.falkordb_password
return FalkorDB(**options)
def export_live_graph(graph: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
nodes: list[dict[str, Any]] = []
exported_id_by_internal: dict[str, str] = {}
exported_ids: set[str] = set()
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]] = []
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] = {}
for item in nodes:
primary_type_by_id[item["id"]] = item["type"]
for label in item["labels"]:
rows_by_label[label].append(item["properties"])
relation_rows: dict[str, list[dict[str, Any]]] = defaultdict(list)
relation_endpoints: dict[str, tuple[set[str], set[str]]] = {}
for item in relations:
relation_rows[item["type"]].append(item["properties"])
sources, targets = relation_endpoints.setdefault(item["type"], (set(), set()))
sources.add(primary_type_by_id[item["source"]])
targets.add(primary_type_by_id[item["target"]])
entity_types = {}
for label, rows in sorted(rows_by_label.items()):
entity_types[label] = {
"label": NODE_LABELS.get(label, label),
"primary_key": "element_id" if any("element_id" in row for row in rows) else "id",
"fields": infer_fields(rows),
}
relation_types = {}
for relation_type, rows in sorted(relation_rows.items()):
sources, targets = relation_endpoints[relation_type]
relation_types[relation_type] = {
"label": RELATION_LABELS.get(relation_type, relation_type),
"from": "|".join(sorted(sources)),
"to": "|".join(sorted(targets)),
"properties": infer_fields(rows),
}
return {
"namespace": project_id,
"version": SCHEMA_VERSION,
"display_name": f"{display_name}当前图谱快照 Schema v3",
"description": f"从当前 {graph_name} FalkorDB 图谱无损导出,保留多标签节点。",
"entity_types": entity_types,
"relation_types": relation_types,
}
def label_set_key(labels: Iterable[str]) -> str:
return "+".join(sorted(labels))
def exported_counts(
nodes: list[dict[str, Any]],
relations: list[dict[str, Any]],
) -> dict[str, Any]:
label_counts: Counter[str] = Counter()
label_set_counts: Counter[str] = Counter()
primary_type_counts: Counter[str] = Counter()
relation_type_counts: Counter[str] = Counter()
coordinate_nodes = 0
map_poi_nodes = 0
bus_stop_nodes = 0
for item in nodes:
labels = set(item["labels"])
label_counts.update(labels)
label_set_counts[label_set_key(labels)] += 1
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 or properties.get("place_type") or properties.get("type_label"):
map_poi_nodes += 1
if "BusStop" in labels:
bus_stop_nodes += 1
for item in relations:
relation_type_counts[item["type"]] += 1
return {
"nodes": len(nodes),
"relations": len(relations),
"primary_type_counts": dict(sorted(primary_type_counts.items())),
"label_counts": dict(sorted(label_counts.items())),
"label_set_counts": dict(sorted(label_set_counts.items())),
"relation_type_counts": dict(sorted(relation_type_counts.items())),
"coordinate_nodes": coordinate_nodes,
"map_poi_nodes": map_poi_nodes,
"bus_stop_nodes": bus_stop_nodes,
}
def source_counts(graph: Any) -> dict[str, Any]:
node_count = int(graph.query("MATCH (n) RETURN count(n)").result_set[0][0])
relation_count = int(graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0])
coordinate_count = int(
graph.query(
"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()
label_set_counts: Counter[str] = Counter()
for labels, count in graph.query(
"MATCH (n) RETURN labels(n), count(n) ORDER BY count(n) DESC"
).result_set:
normalized = [str(label) for label in labels]
amount = int(count)
for label in normalized:
label_counts[label] += amount
label_set_counts[label_set_key(normalized)] += amount
relation_type_counts = {
str(relation_type): int(count)
for relation_type, count in graph.query(
"MATCH ()-[r]->() RETURN type(r), count(r) ORDER BY type(r)"
).result_set
}
return {
"nodes": node_count,
"relations": relation_count,
"label_counts": dict(sorted(label_counts.items())),
"label_set_counts": dict(sorted(label_set_counts.items())),
"relation_type_counts": dict(sorted(relation_type_counts.items())),
"coordinate_nodes": coordinate_count,
}
def validate_snapshot(
source: dict[str, Any],
exported: dict[str, Any],
nodes: list[dict[str, Any]],
relations: list[dict[str, Any]],
) -> dict[str, bool]:
node_ids = [item["id"] for item in nodes]
node_id_set = set(node_ids)
checks = {
"node_count_matches_live_graph": exported["nodes"] == source["nodes"],
"relation_count_matches_live_graph": exported["relations"] == source["relations"],
"label_counts_match_live_graph": exported["label_counts"] == source["label_counts"],
"label_sets_match_live_graph": exported["label_set_counts"] == source["label_set_counts"],
"relation_types_match_live_graph": (
exported["relation_type_counts"] == source["relation_type_counts"]
),
"coordinate_count_matches_live_graph": (
exported["coordinate_nodes"] == source["coordinate_nodes"]
),
"node_ids_are_unique": len(node_ids) == len(node_id_set),
"all_relation_endpoints_exist": all(
item["source"] in node_id_set and item["target"] in node_id_set
for item in relations
),
}
failed = [name for name, passed in checks.items() if not passed]
if failed:
raise RuntimeError("图谱快照校验失败:" + "、".join(failed))
return checks
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def dump_json(path: Path, value: Any, *, pretty: bool = False) -> None:
with path.open("w", encoding="utf-8") as handle:
if pretty:
json.dump(value, handle, ensure_ascii=False, indent=2)
else:
json.dump(value, handle, ensure_ascii=False, separators=(",", ":"))
handle.write("\n")
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)
live_counts = source_counts(graph)
nodes, relations = export_live_graph(graph)
finally:
client.close()
counts = exported_counts(nodes, relations)
checks = validate_snapshot(live_counts, counts, 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,
"generated_at": generated_at,
"spatial_map": spatial_map,
"source_snapshot_counts": live_counts,
},
"nodes": nodes,
"relations": relations,
}
provision = {
"format": "znkg-project-bundle-v3",
"project_id": project_id,
"display_name": display_name,
"spatial_map": spatial_map,
"schema": schema,
"graph_data": graph_data,
}
normalized = normalize_provision_payload(provision)
if normalized["counts"]["nodes"] != counts["nodes"]:
raise RuntimeError("后端校验后的节点数量不一致")
if normalized["counts"]["relations"] != counts["relations"]:
raise RuntimeError("后端校验后的关系数量不一致")
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)
files = {
path.name: {"bytes": path.stat().st_size, "sha256": sha256_file(path)}
for path in (schema_path, graph_path, bundle_path)
}
manifest = {
"format": "znkg-full-graph-manifest-v3",
"project_id": project_id,
"graph_name": graph_name,
"schema_version": SCHEMA_VERSION,
"generated_at": generated_at,
"source": "falkordb",
"spatial_map": spatial_map,
"source_snapshot_counts": live_counts,
"export_counts": counts,
"validation": "passed",
"validation_checks": checks,
"files": files,
}
dump_json(manifest_path, manifest, pretty=True)
manifest["manifest_file"] = str(manifest_path)
return manifest
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--output-dir",
type=Path,
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(),
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))
if __name__ == "__main__":
main()