605 lines
21 KiB
Python
605 lines
21 KiB
Python
#!/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()
|