From 329a94d45718ec9d296459a392021c57b4636690 Mon Sep 17 00:00:00 2001 From: 3452078359-xuexue <3452078359@qq.com> Date: Wed, 26 Aug 2026 01:44:22 -0700 Subject: [PATCH] fix: export graph snapshots without data drift --- admin-web/src/ProjectLanding.tsx | 32 +- .../tests/projectCreationMultiLabel.test.ts | 17 + app/project_lifecycle.py | 62 +- .../libo_complex_graph_data.v1.json | 1128 ----------------- .../libo_complex_schema.v1.json | 512 -------- qa/graph-creation-fixtures/使用说明.md | 83 +- .../export_yunyou_libo_full_graph_bundle.py | 500 ++++++++ tests/test_project_lifecycle.py | 46 + 8 files changed, 673 insertions(+), 1707 deletions(-) create mode 100644 admin-web/tests/projectCreationMultiLabel.test.ts delete mode 100644 qa/graph-creation-fixtures/libo_complex_graph_data.v1.json delete mode 100644 qa/graph-creation-fixtures/libo_complex_schema.v1.json create mode 100644 scripts/export_yunyou_libo_full_graph_bundle.py diff --git a/admin-web/src/ProjectLanding.tsx b/admin-web/src/ProjectLanding.tsx index 00b9fd6..2359c58 100644 --- a/admin-web/src/ProjectLanding.tsx +++ b/admin-web/src/ProjectLanding.tsx @@ -493,6 +493,8 @@ function validateGraphText( } const id = String(rawNode.id || "").trim(); const entityType = String(rawNode.type || rawNode.entity_type || "").trim(); + const rawLabels = rawNode.labels; + let normalizedLabels: string[] | undefined; const properties = rawNode.properties === undefined ? {} : rawNode.properties; const normalizedProperties = isRecord(properties) ? properties : {}; if (!id) errors.push(`${path}.id 必须是非空字符串`); @@ -503,6 +505,29 @@ function validateGraphText( if (entityType && !isRecord(entityDefinition)) { errors.push(`${path}.type ${entityType} 未在 Schema 中定义`); } + if (rawLabels !== undefined) { + if (!Array.isArray(rawLabels) || !rawLabels.length) { + errors.push(`${path}.labels 必须是非空数组`); + } else { + normalizedLabels = []; + rawLabels.forEach((rawLabel, labelIndex) => { + const label = String(rawLabel || "").trim(); + const labelPath = `${path}.labels[${labelIndex}]`; + if (!label || !SAFE_SCHEMA_IDENTIFIER_PATTERN.test(label)) { + errors.push(`${labelPath} 不是安全的 Schema/Cypher 标识符`); + return; + } + if (!isRecord(entityTypes[label])) { + errors.push(`${labelPath} ${label} 未在 Schema 中定义`); + return; + } + if (!normalizedLabels?.includes(label)) normalizedLabels?.push(label); + }); + if (entityType && !normalizedLabels.includes(entityType)) { + errors.push(`${path}.labels 必须包含主类型 ${entityType}`); + } + } + } if (!isRecord(properties)) errors.push(`${path}.properties 必须是对象`); if (isRecord(entityDefinition)) { const fields = normalizeFieldDefinitions( @@ -512,7 +537,12 @@ function validateGraphText( ); validateProperties(normalizedProperties, fields, `${path}.properties`, errors); } - normalizedNodes.push({ id, type: entityType, properties: normalizedProperties }); + normalizedNodes.push({ + id, + type: entityType, + ...(normalizedLabels ? { labels: normalizedLabels } : {}), + properties: normalizedProperties, + }); }); const normalizedRelations: Array> = []; diff --git a/admin-web/tests/projectCreationMultiLabel.test.ts b/admin-web/tests/projectCreationMultiLabel.test.ts new file mode 100644 index 0000000..e296cc0 --- /dev/null +++ b/admin-web/tests/projectCreationMultiLabel.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const projectLandingSource = readFileSync( + new URL("../src/ProjectLanding.tsx", import.meta.url), + "utf8", +); + +test("project creation keeps validated multi-label graph nodes", () => { + assert.match(projectLandingSource, /const rawLabels = rawNode\.labels/); + assert.match(projectLandingSource, /labels 必须包含主类型/); + assert.match( + projectLandingSource, + /\.\.\.\(normalizedLabels \? \{ labels: normalizedLabels \} : \{\}\)/, + ); +}); diff --git a/app/project_lifecycle.py b/app/project_lifecycle.py index b7b3cce..df7255d 100644 --- a/app/project_lifecycle.py +++ b/app/project_lifecycle.py @@ -351,12 +351,32 @@ def normalize_provision_payload(body: Any) -> dict[str, Any]: node_id = str(node.get("id") or "").strip() entity_type = str(node.get("type") or node.get("entity_type") or "").strip() properties = node.get("properties", {}) + raw_labels = node.get("labels") + normalized_labels: list[str] | None = None if not node_id: errors.append(f"{path}.id 不能为空") elif node_id in node_types: errors.append(f"节点 ID {node_id!r} 重复") if entity_type not in normalized_entities: errors.append(f"{path}.type {entity_type!r} 未在 Schema 中定义") + if raw_labels is not None: + if not isinstance(raw_labels, list) or not raw_labels: + errors.append(f"{path}.labels 必须是非空数组") + else: + normalized_labels = [] + for label_index, raw_label in enumerate(raw_labels): + label = str(raw_label or "").strip() + label_path = f"{path}.labels[{label_index}]" + if not label or not SAFE_SCHEMA_IDENTIFIER.fullmatch(label): + errors.append(f"{label_path} 不是安全的 Schema/Cypher 标识符") + continue + if label not in normalized_entities: + errors.append(f"{label_path} {label!r} 未在 Schema 中定义") + continue + if label not in normalized_labels: + normalized_labels.append(label) + if entity_type and entity_type not in normalized_labels: + errors.append(f"{path}.labels 必须包含主类型 {entity_type!r}") if not isinstance(properties, Mapping): errors.append(f"{path}.properties 必须是 JSON 对象") properties = {} @@ -370,7 +390,14 @@ def normalize_provision_payload(body: Any) -> dict[str, Any]: ) if node_id and node_id not in node_types: node_types[node_id] = entity_type - nodes.append({"id": node_id, "type": entity_type, "properties": normalized_properties}) + normalized_node = { + "id": node_id, + "type": entity_type, + "properties": normalized_properties, + } + if normalized_labels is not None: + normalized_node["labels"] = normalized_labels + nodes.append(normalized_node) relations: list[dict[str, Any]] = [] for index, raw_relation in enumerate(raw_relations): @@ -488,6 +515,22 @@ def _graph_safe_properties(properties: Mapping[str, Any]) -> dict[str, Any]: } +def _node_labels(node: Mapping[str, Any]) -> tuple[str, ...]: + """Return validated FalkorDB labels, preserving optional multi-label nodes.""" + + primary = str(node.get("type") or "").strip() + raw_labels = node.get("labels") + labels = [str(item).strip() for item in raw_labels] if isinstance(raw_labels, list) else [] + if primary and primary not in labels: + labels.insert(0, primary) + if not labels: + labels = [primary] + deduplicated = tuple(dict.fromkeys(labels)) + if not all(label and SAFE_SCHEMA_IDENTIFIER.fullmatch(label) for label in deduplicated): + raise ValueError("图谱节点包含不安全的 Cypher 标签") + return deduplicated + + def _batches(items: list[Any], size: int = GRAPH_IMPORT_BATCH_SIZE): for start in range(0, len(items), size): yield items[start:start + size] @@ -499,19 +542,21 @@ def _import_falkor_graph_batched(graph: Any, graph_data: Mapping[str, Any]) -> N relations = list(graph_data.get("relations", [])) node_types = {str(item["id"]): str(item["type"]) for item in nodes} - nodes_by_type: dict[str, list[dict[str, Any]]] = defaultdict(list) + nodes_by_labels: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) for item in nodes: properties = _graph_safe_properties(item.get("properties") or {}) properties.setdefault("id", str(item["id"])) properties["__kg_node_id"] = str(item["id"]) - nodes_by_type[str(item["type"])].append({"properties": properties}) + nodes_by_labels[_node_labels(item)].append({"properties": properties}) - for label, items in nodes_by_type.items(): + for labels, items in nodes_by_labels.items(): + label_expression = ":".join(labels) for batch in _batches(items): graph.query( - f"UNWIND $rows AS row CREATE (n:{label}) SET n += row.properties", + f"UNWIND $rows AS row CREATE (n:{label_expression}) SET n += row.properties", {"rows": batch}, ) + for label in sorted(set(node_types.values())): # Relation creation matches both endpoint labels and this indexed id, # avoiding repeated full-graph scans for large JSON imports. graph.query(f"CREATE INDEX FOR (n:{label}) ON (n.__kg_node_id)") @@ -567,14 +612,17 @@ def import_falkor_graph(graph_name: str, graph_data: Mapping[str, Any]) -> dict[ _import_falkor_graph_batched(graph, {"nodes": nodes, "relations": relations}) else: for node in nodes: - label = str(node["type"]) + label_expression = ":".join(_node_labels(node)) properties = _graph_safe_properties(node.get("properties") or {}) properties.setdefault("id", str(node["id"])) properties["__kg_node_id"] = str(node["id"]) # From this point onward the graph may exist even if the client # raises after the server accepted the command. created = True - graph.query(f"CREATE (n:{label}) SET n += $props", {"props": properties}) + graph.query( + f"CREATE (n:{label_expression}) SET n += $props", + {"props": properties}, + ) for relation in relations: relation_type = str(relation["type"]) properties = _graph_safe_properties(relation.get("properties") or {}) diff --git a/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json b/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json deleted file mode 100644 index f5a9ce4..0000000 --- a/qa/graph-creation-fixtures/libo_complex_graph_data.v1.json +++ /dev/null @@ -1,1128 +0,0 @@ -{ - "nodes": [ - { - "id": "area_xiaoqikong", - "type": "ScenicArea", - "properties": { - "name": "小七孔景区", - "level": "AAAAA", - "rating": 4.9, - "open": true, - "tags": [ - "喀斯特", - "亲子", - "自然风光" - ], - "geo": { - "longitude": 107.741, - "latitude": 25.254 - }, - "description": "本文件中的业务属性均为创建流程测试样例,不作为真实经营信息。" - } - }, - { - "id": "area_daqikong", - "type": "ScenicArea", - "properties": { - "name": "大七孔景区", - "level": "AAAA", - "rating": 4.7, - "open": true, - "tags": [ - "峡谷", - "天生桥", - "徒步" - ], - "geo": { - "longitude": 107.727, - "latitude": 25.287 - }, - "description": "复杂图谱手工测试使用的景区节点。" - } - }, - { - "id": "spot_wolongtan", - "type": "ScenicSpot", - "properties": { - "name": "卧龙潭", - "spot_type": "waterfall", - "visit_minutes": 70, - "indoor": false, - "best_seasons": [ - "spring", - "summer", - "autumn" - ], - "description": "用于测试瀑布类景点和附近资源关系。" - } - }, - { - "id": "spot_laya_waterfall", - "type": "ScenicSpot", - "properties": { - "name": "拉雅瀑布", - "spot_type": "waterfall", - "visit_minutes": 45, - "indoor": false, - "best_seasons": [ - "summer", - "autumn" - ], - "description": "用于测试摄影活动与路线游览顺序。" - } - }, - { - "id": "spot_yuanyang_lake", - "type": "ScenicSpot", - "properties": { - "name": "鸳鸯湖", - "spot_type": "lake", - "visit_minutes": 100, - "indoor": false, - "best_seasons": [ - "spring", - "summer" - ], - "description": "用于测试游船活动。" - } - }, - { - "id": "spot_tiansheng_bridge", - "type": "ScenicSpot", - "properties": { - "name": "天生桥", - "spot_type": "geological_landscape", - "visit_minutes": 90, - "indoor": false, - "best_seasons": [ - "spring", - "autumn", - "winter" - ], - "description": "用于测试地质景观节点。" - } - }, - { - "id": "spot_yaofeng_cave", - "type": "ScenicSpot", - "properties": { - "name": "妖风洞测试点", - "spot_type": "cave", - "visit_minutes": 55, - "indoor": true, - "best_seasons": [ - "all_year" - ], - "description": "虚构测试点,仅用于验证室内景点和布尔属性。" - } - }, - { - "id": "hotel_libo_mountain", - "type": "Hotel", - "properties": { - "name": "测试·荔波山水酒店", - "stars": 4, - "price_from": 468.0, - "family_friendly": true, - "amenities": [ - "早餐", - "停车场", - "接送服务" - ], - "address": "荔波县测试路 1 号", - "contact": { - "phone": "0854-0000001", - "service": "07:00-23:00" - } - } - }, - { - "id": "hotel_xiaoqikong_family", - "type": "Hotel", - "properties": { - "name": "测试·小七孔亲子酒店", - "stars": 5, - "price_from": 688.0, - "family_friendly": true, - "amenities": [ - "亲子房", - "儿童餐", - "洗衣房" - ], - "address": "小七孔东门测试区 8 号", - "contact": { - "phone": "0854-0000002", - "service": "24h" - } - } - }, - { - "id": "hotel_daqikong_riverside", - "type": "Hotel", - "properties": { - "name": "测试·大七孔河畔酒店", - "stars": 3, - "price_from": 328.0, - "family_friendly": false, - "amenities": [ - "早餐", - "行李寄存" - ], - "address": "大七孔测试区 3 号", - "contact": { - "phone": "0854-0000003", - "service": "06:30-22:00" - } - } - }, - { - "id": "restaurant_sour_fish", - "type": "Restaurant", - "properties": { - "name": "测试·荔波酸汤鱼馆", - "cuisine": "黔菜", - "avg_price": 92.0, - "specialties": [ - "酸汤鱼", - "丝娃娃" - ], - "vegetarian_options": true, - "opening_hours": "10:30-21:30" - } - }, - { - "id": "restaurant_xiaoqikong_ecology", - "type": "Restaurant", - "properties": { - "name": "测试·小七孔生态餐厅", - "cuisine": "本地家常菜", - "avg_price": 68.0, - "specialties": [ - "豆花烤鱼", - "时蔬拼盘" - ], - "vegetarian_options": true, - "opening_hours": "09:30-20:30" - } - }, - { - "id": "restaurant_yaoshan_flavor", - "type": "Restaurant", - "properties": { - "name": "测试·瑶山风味馆", - "cuisine": "瑶族风味", - "avg_price": 118.0, - "specialties": [ - "长桌宴", - "竹筒饭" - ], - "vegetarian_options": false, - "opening_hours": "11:00-22:00" - } - }, - { - "id": "hub_libo_railway", - "type": "TransportHub", - "properties": { - "name": "荔波高铁站", - "hub_type": "railway_station", - "service_hours": "06:00-23:00", - "accessible": true, - "facilities": [ - "无障碍通道", - "行李寄存", - "旅游咨询" - ], - "geo": { - "longitude": 107.887, - "latitude": 25.401 - } - } - }, - { - "id": "hub_libo_bus", - "type": "TransportHub", - "properties": { - "name": "荔波汽车站", - "hub_type": "bus_station", - "service_hours": "06:30-20:30", - "accessible": true, - "facilities": [ - "售票厅", - "候车室" - ], - "geo": { - "longitude": 107.882, - "latitude": 25.413 - } - } - }, - { - "id": "hub_xiaoqikong_east", - "type": "TransportHub", - "properties": { - "name": "小七孔东门游客中心", - "hub_type": "visitor_center", - "service_hours": "07:30-18:30", - "accessible": true, - "facilities": [ - "换乘车", - "卫生间", - "母婴室" - ], - "geo": { - "longitude": 107.748, - "latitude": 25.251 - } - } - }, - { - "id": "route_family_3d", - "type": "TourRoute", - "properties": { - "name": "荔波亲子自然三日测试线路", - "days": 3, - "theme": "family_nature", - "suitable_for": [ - "亲子家庭", - "自然爱好者" - ], - "base_price": 1980.0, - "active": true, - "extra_metadata": { - "test_batch": "manual-20260805", - "difficulty": 2 - } - } - }, - { - "id": "route_depth_2d", - "type": "TourRoute", - "properties": { - "name": "荔波地貌深度两日测试线路", - "days": 2, - "theme": "geology_hiking", - "suitable_for": [ - "摄影爱好者", - "徒步游客" - ], - "base_price": 1360.0, - "active": true, - "extra_metadata": "允许自由扩展的测试文本" - } - }, - { - "id": "day_family_1", - "type": "RouteDay", - "properties": { - "title": "高铁抵达与瀑布初体验", - "day_no": 1, - "summary": "抵达荔波后游览卧龙潭和拉雅瀑布。", - "estimated_cost": 520.0 - } - }, - { - "id": "day_family_2", - "type": "RouteDay", - "properties": { - "title": "鸳鸯湖亲子体验", - "day_no": 2, - "summary": "安排鸳鸯湖游船和自然课堂。", - "estimated_cost": 680.0 - } - }, - { - "id": "day_family_3", - "type": "RouteDay", - "properties": { - "title": "大七孔地貌探索", - "day_no": 3, - "summary": "游览天生桥并完成返程。", - "estimated_cost": 430.0 - } - }, - { - "id": "day_depth_1", - "type": "RouteDay", - "properties": { - "title": "小七孔摄影深度日", - "day_no": 1, - "summary": "按卧龙潭、拉雅瀑布、鸳鸯湖顺序深度游览。", - "estimated_cost": 620.0 - } - }, - { - "id": "day_depth_2", - "type": "RouteDay", - "properties": { - "title": "大七孔峡谷徒步日", - "day_no": 2, - "summary": "天生桥和洞穴测试点组合徒步。", - "estimated_cost": 510.0 - } - }, - { - "id": "activity_boat", - "type": "Activity", - "properties": { - "name": "鸳鸯湖测试游船", - "activity_type": "boat", - "duration_minutes": 45, - "min_age": 6, - "booking_required": true, - "equipment": [ - "救生衣" - ] - } - }, - { - "id": "activity_photography", - "type": "Activity", - "properties": { - "name": "瀑布慢门摄影体验", - "activity_type": "photography", - "duration_minutes": 60, - "min_age": 12, - "booking_required": false, - "equipment": [ - "三脚架", - "减光镜" - ] - } - }, - { - "id": "activity_stargazing", - "type": "Activity", - "properties": { - "name": "河畔星空讲解", - "activity_type": "education", - "duration_minutes": 50, - "min_age": 8, - "booking_required": true, - "equipment": [ - "望远镜", - "防潮垫" - ] - } - }, - { - "id": "activity_nature_class", - "type": "Activity", - "properties": { - "name": "喀斯特亲子自然课堂", - "activity_type": "family_education", - "duration_minutes": 80, - "min_age": 5, - "booking_required": true, - "equipment": [ - "观察手册", - "放大镜" - ] - } - }, - { - "id": "ticket_xiaoqikong", - "type": "TicketPolicy", - "properties": { - "name": "小七孔 2026 测试票务政策", - "adult_price": 170.0, - "child_price": 85.0, - "free_policy": "测试规则:身高 1.2 米以下儿童免票。", - "valid_from": "2026-01-01", - "refundable": true - } - }, - { - "id": "ticket_daqikong", - "type": "TicketPolicy", - "properties": { - "name": "大七孔 2026 测试票务政策", - "adult_price": 90.0, - "child_price": 45.0, - "free_policy": "测试规则:证件优惠以现场为准。", - "valid_from": "2026-01-01", - "refundable": false - } - }, - { - "id": "tip_rainy", - "type": "TravelTip", - "properties": { - "title": "雨季步道安全测试提示", - "content": "雨天路面湿滑,请穿防滑鞋并预留更长游览时间。", - "severity": "warning", - "audiences": [ - "老人", - "儿童", - "徒步游客" - ], - "updated_at": "2026-08-05T09:00:00+08:00" - } - }, - { - "id": "tip_peak_booking", - "type": "TravelTip", - "properties": { - "title": "高峰期预约测试提示", - "content": "节假日建议提前预约门票、酒店和体验活动。", - "severity": "info", - "audiences": [ - "全部游客" - ], - "updated_at": "2026-08-05T09:15:00+08:00" - } - }, - { - "id": "tip_family", - "type": "TravelTip", - "properties": { - "title": "亲子装备测试提示", - "content": "建议携带替换衣物、驱蚊用品和儿童饮水。", - "severity": "info", - "audiences": [ - "亲子家庭" - ], - "updated_at": "2026-08-05T09:30:00+08:00" - } - } - ], - "relations": [ - { - "type": "CONTAINS", - "source": "area_xiaoqikong", - "target": "spot_wolongtan", - "properties": { - "recommended_order": 1 - } - }, - { - "type": "CONTAINS", - "source": "area_xiaoqikong", - "target": "spot_laya_waterfall", - "properties": { - "recommended_order": 2 - } - }, - { - "type": "CONTAINS", - "source": "area_xiaoqikong", - "target": "spot_yuanyang_lake", - "properties": { - "recommended_order": 3 - } - }, - { - "type": "CONTAINS", - "source": "area_daqikong", - "target": "spot_tiansheng_bridge", - "properties": { - "recommended_order": 1 - } - }, - { - "type": "CONTAINS", - "source": "area_daqikong", - "target": "spot_yaofeng_cave", - "properties": { - "recommended_order": 2 - } - }, - { - "type": "NEARBY", - "source": "spot_wolongtan", - "target": "hotel_xiaoqikong_family", - "properties": { - "distance_km": 6.8, - "drive_minutes": 15, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_wolongtan", - "target": "restaurant_xiaoqikong_ecology", - "properties": { - "distance_km": 5.2, - "drive_minutes": 12, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_laya_waterfall", - "target": "hub_xiaoqikong_east", - "properties": { - "distance_km": 4.0, - "drive_minutes": 10, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_laya_waterfall", - "target": "restaurant_xiaoqikong_ecology", - "properties": { - "distance_km": 4.8, - "drive_minutes": 11, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_yuanyang_lake", - "target": "hotel_xiaoqikong_family", - "properties": { - "distance_km": 3.2, - "drive_minutes": 8, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_yuanyang_lake", - "target": "restaurant_xiaoqikong_ecology", - "properties": { - "distance_km": 3.6, - "drive_minutes": 9, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_tiansheng_bridge", - "target": "hotel_daqikong_riverside", - "properties": { - "distance_km": 4.5, - "drive_minutes": 10, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_tiansheng_bridge", - "target": "restaurant_yaoshan_flavor", - "properties": { - "distance_km": 12.0, - "drive_minutes": 22, - "walkable": false - } - }, - { - "type": "NEARBY", - "source": "spot_yaofeng_cave", - "target": "hub_libo_bus", - "properties": { - "distance_km": 26.0, - "drive_minutes": 40, - "walkable": false - } - }, - { - "type": "ROUTE_HAS_DAY", - "source": "route_family_3d", - "target": "day_family_1", - "properties": { - "order": 1 - } - }, - { - "type": "ROUTE_HAS_DAY", - "source": "route_family_3d", - "target": "day_family_2", - "properties": { - "order": 2 - } - }, - { - "type": "ROUTE_HAS_DAY", - "source": "route_family_3d", - "target": "day_family_3", - "properties": { - "order": 3 - } - }, - { - "type": "ROUTE_HAS_DAY", - "source": "route_depth_2d", - "target": "day_depth_1", - "properties": { - "order": 1 - } - }, - { - "type": "ROUTE_HAS_DAY", - "source": "route_depth_2d", - "target": "day_depth_2", - "properties": { - "order": 2 - } - }, - { - "type": "DAY_VISITS", - "source": "day_family_1", - "target": "spot_wolongtan", - "properties": { - "order": 1, - "stay_minutes": 70, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_family_1", - "target": "spot_laya_waterfall", - "properties": { - "order": 2, - "stay_minutes": 45, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_family_2", - "target": "spot_yuanyang_lake", - "properties": { - "order": 1, - "stay_minutes": 120, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_family_3", - "target": "spot_tiansheng_bridge", - "properties": { - "order": 1, - "stay_minutes": 90, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_family_3", - "target": "spot_yaofeng_cave", - "properties": { - "order": 2, - "stay_minutes": 55, - "must_visit": false - } - }, - { - "type": "DAY_VISITS", - "source": "day_depth_1", - "target": "spot_wolongtan", - "properties": { - "order": 1, - "stay_minutes": 100, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_depth_1", - "target": "spot_laya_waterfall", - "properties": { - "order": 2, - "stay_minutes": 80, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_depth_1", - "target": "spot_yuanyang_lake", - "properties": { - "order": 3, - "stay_minutes": 110, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_depth_2", - "target": "spot_tiansheng_bridge", - "properties": { - "order": 1, - "stay_minutes": 120, - "must_visit": true - } - }, - { - "type": "DAY_VISITS", - "source": "day_depth_2", - "target": "spot_yaofeng_cave", - "properties": { - "order": 2, - "stay_minutes": 70, - "must_visit": true - } - }, - { - "type": "DAY_STAYS_AT", - "source": "day_family_1", - "target": "hotel_xiaoqikong_family", - "properties": { - "check_in": "18:30", - "nights": 1 - } - }, - { - "type": "DAY_STAYS_AT", - "source": "day_family_2", - "target": "hotel_xiaoqikong_family", - "properties": { - "check_in": "19:00", - "nights": 1 - } - }, - { - "type": "DAY_STAYS_AT", - "source": "day_family_3", - "target": "hotel_libo_mountain", - "properties": { - "check_in": "17:30", - "nights": 1 - } - }, - { - "type": "DAY_STAYS_AT", - "source": "day_depth_1", - "target": "hotel_libo_mountain", - "properties": { - "check_in": "20:00", - "nights": 1 - } - }, - { - "type": "DAY_STAYS_AT", - "source": "day_depth_2", - "target": "hotel_daqikong_riverside", - "properties": { - "check_in": "18:00", - "nights": 1 - } - }, - { - "type": "DAY_DINES_AT", - "source": "day_family_1", - "target": "restaurant_sour_fish", - "properties": { - "meal": "dinner", - "reservation_required": true - } - }, - { - "type": "DAY_DINES_AT", - "source": "day_family_2", - "target": "restaurant_xiaoqikong_ecology", - "properties": { - "meal": "lunch", - "reservation_required": false - } - }, - { - "type": "DAY_DINES_AT", - "source": "day_family_3", - "target": "restaurant_yaoshan_flavor", - "properties": { - "meal": "lunch", - "reservation_required": true - } - }, - { - "type": "DAY_DINES_AT", - "source": "day_depth_1", - "target": "restaurant_xiaoqikong_ecology", - "properties": { - "meal": "dinner", - "reservation_required": false - } - }, - { - "type": "DAY_DINES_AT", - "source": "day_depth_2", - "target": "restaurant_sour_fish", - "properties": { - "meal": "dinner", - "reservation_required": true - } - }, - { - "type": "DAY_USES_HUB", - "source": "day_family_1", - "target": "hub_libo_railway", - "properties": { - "mode": "high_speed_rail", - "departure_time": "10:20" - } - }, - { - "type": "DAY_USES_HUB", - "source": "day_family_2", - "target": "hub_xiaoqikong_east", - "properties": { - "mode": "scenic_shuttle", - "departure_time": "08:00" - } - }, - { - "type": "DAY_USES_HUB", - "source": "day_family_3", - "target": "hub_libo_bus", - "properties": { - "mode": "coach", - "departure_time": "16:30" - } - }, - { - "type": "DAY_USES_HUB", - "source": "day_depth_1", - "target": "hub_xiaoqikong_east", - "properties": { - "mode": "scenic_shuttle", - "departure_time": "07:30" - } - }, - { - "type": "DAY_USES_HUB", - "source": "day_depth_2", - "target": "hub_libo_railway", - "properties": { - "mode": "chartered_bus", - "departure_time": "17:20" - } - }, - { - "type": "OFFERS_ACTIVITY", - "source": "spot_yuanyang_lake", - "target": "activity_boat", - "properties": { - "season": "summer", - "extra_fee": 60.0 - } - }, - { - "type": "OFFERS_ACTIVITY", - "source": "spot_laya_waterfall", - "target": "activity_photography", - "properties": { - "season": "all_year", - "extra_fee": 0.0 - } - }, - { - "type": "OFFERS_ACTIVITY", - "source": "spot_tiansheng_bridge", - "target": "activity_stargazing", - "properties": { - "season": "autumn", - "extra_fee": 88.0 - } - }, - { - "type": "OFFERS_ACTIVITY", - "source": "spot_wolongtan", - "target": "activity_nature_class", - "properties": { - "season": "spring", - "extra_fee": 120.0 - } - }, - { - "type": "HAS_TICKET_POLICY", - "source": "area_xiaoqikong", - "target": "ticket_xiaoqikong", - "properties": { - "channel": "official_test_channel" - } - }, - { - "type": "HAS_TICKET_POLICY", - "source": "area_daqikong", - "target": "ticket_daqikong", - "properties": { - "channel": "official_test_channel" - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_rainy", - "target": "area_xiaoqikong", - "properties": { - "context": "rainy_season", - "priority": 1 - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_rainy", - "target": "spot_tiansheng_bridge", - "properties": { - "context": "wet_trail", - "priority": 1 - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_peak_booking", - "target": "route_family_3d", - "properties": { - "context": "public_holiday", - "priority": 2 - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_peak_booking", - "target": "hotel_xiaoqikong_family", - "properties": { - "context": "room_inventory", - "priority": 2 - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_family", - "target": "route_family_3d", - "properties": { - "context": "family_trip", - "priority": 3 - } - }, - { - "type": "APPLIES_TIP", - "source": "tip_family", - "target": "hub_xiaoqikong_east", - "properties": { - "context": "visitor_center", - "priority": 3 - } - }, - { - "type": "ROUTE_STARTS_AT", - "source": "route_family_3d", - "target": "hub_libo_railway", - "properties": { - "departure_time": "10:30" - } - }, - { - "type": "ROUTE_STARTS_AT", - "source": "route_depth_2d", - "target": "hub_libo_bus", - "properties": { - "departure_time": "07:00" - } - }, - { - "type": "ROUTE_ENDS_AT", - "source": "route_family_3d", - "target": "hub_libo_railway", - "properties": { - "arrival_time": "18:10" - } - }, - { - "type": "ROUTE_ENDS_AT", - "source": "route_depth_2d", - "target": "hub_libo_railway", - "properties": { - "arrival_time": "17:40" - } - }, - { - "type": "ALTERNATIVE_TO", - "source": "hotel_xiaoqikong_family", - "target": "hotel_libo_mountain", - "properties": { - "reason": "亲子房满房时可替换为县城酒店。", - "score": 0.82 - } - }, - { - "type": "ALTERNATIVE_TO", - "source": "restaurant_xiaoqikong_ecology", - "target": "restaurant_sour_fish", - "properties": { - "reason": "景区餐厅满位时可切换到县城餐厅。", - "score": 0.76 - } - }, - { - "type": "CONNECTS", - "source": "hub_libo_railway", - "target": "hub_libo_bus", - "properties": { - "mode": "city_bus", - "duration_minutes": 25, - "distance_km": 14.5 - } - }, - { - "type": "CONNECTS", - "source": "hub_libo_bus", - "target": "hub_xiaoqikong_east", - "properties": { - "mode": "tourist_coach", - "duration_minutes": 45, - "distance_km": 29.0 - } - }, - { - "type": "CONNECTS", - "source": "hub_libo_railway", - "target": "hub_xiaoqikong_east", - "properties": { - "mode": "direct_shuttle", - "duration_minutes": 55, - "distance_km": 36.0 - } - }, - { - "type": "CONNECTS", - "source": "hub_xiaoqikong_east", - "target": "hub_libo_railway", - "properties": { - "mode": "return_shuttle", - "duration_minutes": 60, - "distance_km": 36.0 - } - }, - { - "type": "RECOMMENDS", - "source": "route_family_3d", - "target": "activity_boat", - "properties": { - "priority": 1, - "reason": "适合亲子共同参与。" - } - }, - { - "type": "RECOMMENDS", - "source": "route_family_3d", - "target": "activity_nature_class", - "properties": { - "priority": 2, - "reason": "补充自然科普体验。" - } - }, - { - "type": "RECOMMENDS", - "source": "route_depth_2d", - "target": "activity_photography", - "properties": { - "priority": 1, - "reason": "符合摄影主题。" - } - }, - { - "type": "RECOMMENDS", - "source": "route_depth_2d", - "target": "activity_stargazing", - "properties": { - "priority": 2, - "reason": "适合作为徒步后的夜间活动。" - } - } - ] -} diff --git a/qa/graph-creation-fixtures/libo_complex_schema.v1.json b/qa/graph-creation-fixtures/libo_complex_schema.v1.json deleted file mode 100644 index ac3f5d1..0000000 --- a/qa/graph-creation-fixtures/libo_complex_schema.v1.json +++ /dev/null @@ -1,512 +0,0 @@ -{ - "namespace": "libo_complex_manual_test_20260805_01", - "version": "1.0.0", - "display_name": "荔波旅游复杂测试模型", - "description": "用于手工验证图谱创建、Schema 校验、节点导入、复杂关系、图谱浏览和安全删除的综合测试模型。", - "enums": { - "Season": [ - "spring", - "summer", - "autumn", - "winter", - "all_year" - ], - "TipSeverity": [ - "info", - "warning", - "critical" - ], - "MealType": [ - "breakfast", - "lunch", - "dinner" - ] - }, - "design_principles": [ - "TourRoute 通过 RouteDay 表达有序的多日行程。", - "RouteDay 分别关联游览点、酒店、餐厅和交通枢纽。", - "ScenicArea 与 ScenicSpot 分层表达景区和景点。", - "票务政策和旅行提示作为独立实体,便于复用和版本管理。" - ], - "entity_types": { - "ScenicArea": { - "label": "景区", - "description": "可包含多个具体景点的景区级实体。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true, - "description": "景区名称" - }, - "level": { - "type": "string", - "description": "景区等级" - }, - "rating": { - "type": "number", - "description": "综合评分" - }, - "open": { - "type": "boolean", - "description": "是否开放" - }, - "tags": { - "type": "array", - "description": "景区标签" - }, - "geo": { - "type": "object", - "description": "经纬度对象" - }, - "description": { - "type": "string" - } - } - }, - "ScenicSpot": { - "label": "景点", - "description": "景区内可独立游览的景点。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "spot_type": { - "type": "string", - "required": true - }, - "visit_minutes": { - "type": "integer" - }, - "indoor": { - "type": "boolean" - }, - "best_seasons": { - "type": "array" - }, - "description": { - "type": "string" - } - } - }, - "Hotel": { - "label": "酒店", - "description": "行程住宿资源。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "stars": { - "type": "integer", - "required": true - }, - "price_from": { - "type": "number" - }, - "family_friendly": { - "type": "boolean" - }, - "amenities": { - "type": "array" - }, - "address": { - "type": "string" - }, - "contact": { - "type": "object" - } - } - }, - "Restaurant": { - "label": "餐厅", - "description": "行程餐饮资源。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "cuisine": { - "type": "string", - "required": true - }, - "avg_price": { - "type": "number" - }, - "specialties": { - "type": "array" - }, - "vegetarian_options": { - "type": "boolean" - }, - "opening_hours": { - "type": "string" - } - } - }, - "TransportHub": { - "label": "交通枢纽", - "description": "高铁站、汽车站或游客中心等交通节点。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "hub_type": { - "type": "string", - "required": true - }, - "service_hours": { - "type": "string" - }, - "accessible": { - "type": "boolean" - }, - "facilities": { - "type": "array" - }, - "geo": { - "type": "object" - } - } - }, - "TourRoute": { - "label": "旅游线路", - "description": "由多个有序行程日组成的旅游产品线路。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "days": { - "type": "integer", - "required": true - }, - "theme": { - "type": "string" - }, - "suitable_for": { - "type": "array" - }, - "base_price": { - "type": "number" - }, - "active": { - "type": "boolean" - }, - "extra_metadata": { - "type": "any", - "description": "用于验证 any 类型的扩展信息" - } - } - }, - "RouteDay": { - "label": "行程日", - "description": "线路中的某一天。", - "fields": { - "title": { - "type": "string", - "required": true - }, - "day_no": { - "type": "integer", - "required": true - }, - "summary": { - "type": "string" - }, - "estimated_cost": { - "type": "number" - } - } - }, - "Activity": { - "label": "体验活动", - "description": "景点可提供的附加体验活动。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "activity_type": { - "type": "string", - "required": true - }, - "duration_minutes": { - "type": "integer" - }, - "min_age": { - "type": "integer" - }, - "booking_required": { - "type": "boolean" - }, - "equipment": { - "type": "array" - } - } - }, - "TicketPolicy": { - "label": "票务政策", - "description": "景区或景点的票价和退改政策。", - "primary_key": "name", - "fields": { - "name": { - "type": "string", - "required": true - }, - "adult_price": { - "type": "number", - "required": true - }, - "child_price": { - "type": "number" - }, - "free_policy": { - "type": "string" - }, - "valid_from": { - "type": "date" - }, - "refundable": { - "type": "boolean" - } - } - }, - "TravelTip": { - "label": "旅行提示", - "description": "可应用到景区、资源或线路的提醒。", - "primary_key": "title", - "fields": { - "title": { - "type": "string", - "required": true - }, - "content": { - "type": "string", - "required": true - }, - "severity": { - "type": "string", - "required": true - }, - "audiences": { - "type": "array" - }, - "updated_at": { - "type": "datetime" - } - } - } - }, - "relation_types": { - "CONTAINS": { - "label": "包含景点", - "from": "ScenicArea", - "to": "ScenicSpot", - "properties": { - "recommended_order": { - "type": "integer" - } - } - }, - "NEARBY": { - "label": "附近资源", - "from": "ScenicSpot", - "to": "Hotel|Restaurant|TransportHub", - "properties": { - "distance_km": { - "type": "number", - "required": true - }, - "drive_minutes": { - "type": "integer" - }, - "walkable": { - "type": "boolean" - } - } - }, - "ROUTE_HAS_DAY": { - "label": "线路包含行程日", - "from": "TourRoute", - "to": "RouteDay", - "properties": { - "order": { - "type": "integer", - "required": true - } - } - }, - "DAY_VISITS": { - "label": "当日游览", - "from": "RouteDay", - "to": "ScenicSpot", - "properties": { - "order": { - "type": "integer", - "required": true - }, - "stay_minutes": { - "type": "integer" - }, - "must_visit": { - "type": "boolean" - } - } - }, - "DAY_STAYS_AT": { - "label": "当日住宿", - "from": "RouteDay", - "to": "Hotel", - "properties": { - "check_in": { - "type": "string", - "required": true - }, - "nights": { - "type": "integer" - } - } - }, - "DAY_DINES_AT": { - "label": "当日用餐", - "from": "RouteDay", - "to": "Restaurant", - "properties": { - "meal": { - "type": "string", - "required": true - }, - "reservation_required": { - "type": "boolean" - } - } - }, - "DAY_USES_HUB": { - "label": "当日使用交通枢纽", - "from": "RouteDay", - "to": "TransportHub", - "properties": { - "mode": { - "type": "string", - "required": true - }, - "departure_time": { - "type": "string" - } - } - }, - "OFFERS_ACTIVITY": { - "label": "提供活动", - "from": "ScenicSpot", - "to": "Activity", - "properties": { - "season": { - "type": "string" - }, - "extra_fee": { - "type": "number" - } - } - }, - "HAS_TICKET_POLICY": { - "label": "采用票务政策", - "from": "ScenicArea|ScenicSpot", - "to": "TicketPolicy", - "properties": { - "channel": { - "type": "string" - } - } - }, - "APPLIES_TIP": { - "label": "提示适用于", - "from": "TravelTip", - "to": "ScenicArea|ScenicSpot|Hotel|Restaurant|TransportHub|TourRoute", - "properties": { - "context": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "ROUTE_STARTS_AT": { - "label": "线路起点", - "from": "TourRoute", - "to": "TransportHub", - "properties": { - "departure_time": { - "type": "string", - "required": true - } - } - }, - "ROUTE_ENDS_AT": { - "label": "线路终点", - "from": "TourRoute", - "to": "TransportHub", - "properties": { - "arrival_time": { - "type": "string", - "required": true - } - } - }, - "ALTERNATIVE_TO": { - "label": "可替代资源", - "from": "Hotel|Restaurant", - "to": "Hotel|Restaurant", - "properties": { - "reason": { - "type": "string", - "required": true - }, - "score": { - "type": "number" - } - } - }, - "CONNECTS": { - "label": "交通连接", - "from": "TransportHub", - "to": "TransportHub", - "properties": { - "mode": { - "type": "string", - "required": true - }, - "duration_minutes": { - "type": "integer", - "required": true - }, - "distance_km": { - "type": "number" - } - } - }, - "RECOMMENDS": { - "label": "线路推荐活动", - "from": "TourRoute", - "to": "Activity", - "properties": { - "priority": { - "type": "integer", - "required": true - }, - "reason": { - "type": "string" - } - } - } - } -} diff --git a/qa/graph-creation-fixtures/使用说明.md b/qa/graph-creation-fixtures/使用说明.md index 8429a49..0dd86b8 100644 --- a/qa/graph-creation-fixtures/使用说明.md +++ b/qa/graph-creation-fixtures/使用说明.md @@ -1,70 +1,35 @@ -# 复杂图谱创建手工测试 +# 云游荔波当前图谱导出与创建测试 -这套数据只用于验证创建流程,价格、电话、地址和经营信息均为测试样例。 +旧的 `libo_complex_schema.v1.json` 和 `libo_complex_graph_data.v1.json` 只有 +32 个人工样例节点、70 条样例关系,并不是当前云游荔波项目的导出文件,现已移除。 -## 创建时填写 +## 生成当前图谱 JSON -- 项目名称(中文):`荔波旅游复杂图谱手工测试` -- 项目标识(英文):`libo_complex_manual_test_20260805_01` +确保本地 FalkorDB 中存在 `yunyou_libo` 图谱,然后运行: -## 操作顺序 - -1. 打开 `http://localhost:8102/admin/projects`,点击“创建项目”。 -2. 填写上面的中文名称和英文标识,点击“下一步”。 -3. 上传或粘贴 `libo_complex_schema.v1.json`。 -4. 上传或粘贴 `libo_complex_graph_data.v1.json`。 -5. 进入确认页后检查统计并提交创建。 - -## 预期统计 - -- 实体类型:10 -- 关系类型:15 -- 节点:32 -- 关系:70 -- Schema 版本:1.0.0 -- 字段类型:覆盖 `string`、`integer`、`number`、`boolean`、`object`、`array`、`date`、`datetime`、`any` - -## 创建后建议查询 - -全部节点: - -```cypher -MATCH (n) RETURN n LIMIT 100 +```bash +python3 scripts/export_yunyou_libo_full_graph_bundle.py \ + --output-dir /安全的导出目录/yunyou_libo_graph_export ``` -全部节点和关系: +导出器会生成: -```cypher -MATCH (n)-[r]->(m) RETURN n,r,m LIMIT 200 -``` +- `yunyou_libo_full_schema.v3.json`:创建向导“本体 Schema”步骤使用。 +- `yunyou_libo_full_graph_data.v3.json`:创建向导“图谱数据”步骤使用。 +- `yunyou_libo_full_bundle.v3.json`:包含 Schema、图谱数据和地图配置的归档文件。 +- `yunyou_libo_full_manifest.v3.json`:数量、校验结果和 SHA-256 校验和。 -线路、行程日与景点: +只有清单中的 `validation` 为 `passed`,且全部 `validation_checks` 为 `true` 时, +才可使用本次导出。 -```cypher -MATCH (route:TourRoute)-[r1:ROUTE_HAS_DAY]->(day:RouteDay)-[r2:DAY_VISITS]->(spot:ScenicSpot) -RETURN route,r1,day,r2,spot LIMIT 100 -``` +## 创建测试项目 -景点附近的酒店、餐厅和交通枢纽: +1. 打开 `/admin/projects`,点击“创建项目”。 +2. 填写新的项目英文标识;被删除过的标识不能复用。 +3. 地图选择“显示”。 +4. 上传 `yunyou_libo_full_schema.v3.json`。 +5. 上传 `yunyou_libo_full_graph_data.v3.json`。 +6. 确认页面统计与清单一致后创建。 -```cypher -MATCH (spot:ScenicSpot)-[r:NEARBY]->(resource) -RETURN spot,r,resource LIMIT 100 -``` - -适合亲子的酒店: - -```cypher -MATCH (hotel:Hotel) -WHERE hotel.family_friendly = true -RETURN hotel LIMIT 50 -``` - -## 重新测试时注意 - -项目删除后系统会保留安全墓碑,原英文标识不能再次使用。重新测试时请同时修改: - -- 项目标识,例如改为 `libo_complex_manual_test_20260805_02` -- Schema JSON 顶层的 `namespace`,改成相同的新标识 - -图谱数据文件无需修改。 +导出文件直接快照当前 FalkorDB 图谱,保留节点的全部标签、属性、关系端点和关系属性。 +地图业务 POI 与公交站坐标也包含在图谱数据中,不再从关系库重新拼装另一套图谱。 diff --git a/scripts/export_yunyou_libo_full_graph_bundle.py b/scripts/export_yunyou_libo_full_graph_bundle.py new file mode 100644 index 0000000..28ffb1f --- /dev/null +++ b/scripts/export_yunyou_libo_full_graph_bundle.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Export the current Yunyou Libo FalkorDB graph as a lossless 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 +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" +SPATIAL_MAP = {"enabled": True, "scope": "libo"} + +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() + + 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, + } + ) + + 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), + } + ) + return nodes, relations + + +def build_schema( + nodes: list[dict[str, Any]], + relations: list[dict[str, Any]], +) -> 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": "云游荔波当前图谱快照 Schema v3", + "description": "从当前 yunyou_libo 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 has_coordinates: + coordinate_nodes += 1 + if labels & MAP_POI_LABELS: + 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 n.lng IS NOT NULL AND n.lat 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 export(output_dir: Path) -> dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + generated_at = datetime.now(timezone.utc).isoformat() + 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) + 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 / "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" + 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.", + ) + args = parser.parse_args() + manifest = export(args.output_dir.expanduser().resolve()) + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_project_lifecycle.py b/tests/test_project_lifecycle.py index 52e2a75..df6f95d 100644 --- a/tests/test_project_lifecycle.py +++ b/tests/test_project_lifecycle.py @@ -75,6 +75,29 @@ class ProjectPayloadValidationTests(unittest.TestCase): "number", ) + def test_multi_label_nodes_are_validated_and_preserved(self) -> None: + body = valid_payload() + body["schema"]["entity_types"]["Hotel"] = { + "label": "酒店", + "fields": {"name": {"type": "string", "required": True}}, + } + body["schema"]["relation_types"]["RELATED_TO"]["from"] = "Hotel|Place" + body["graph_data"]["nodes"][0]["type"] = "Hotel" + body["graph_data"]["nodes"][0]["labels"] = ["Place", "Hotel", "Place"] + + payload = normalize_provision_payload(body) + + self.assertEqual(payload["graph_data"]["nodes"][0]["labels"], ["Place", "Hotel"]) + + def test_multi_label_nodes_reject_undefined_labels(self) -> None: + body = valid_payload() + body["graph_data"]["nodes"][0]["labels"] = ["Place", "UnknownLabel"] + + with self.assertRaises(ProjectValidationError) as raised: + normalize_provision_payload(body) + + self.assertIn("UnknownLabel", ";".join(raised.exception.errors)) + def test_hidden_resource_ids_default_to_project_id(self) -> None: body = valid_payload() body.pop("tenant_id") @@ -382,6 +405,29 @@ class FalkorLifecycleTests(unittest.TestCase): self.assertFalse(graph.deleted) self.assertTrue(database.closed) + def test_import_preserves_all_validated_node_labels(self) -> None: + graph_data = { + "nodes": [ + { + "id": "hotel-food-1", + "type": "Hotel", + "labels": ["Place", "FoodPlace", "Hotel"], + "properties": {"name": "住宿与餐饮复合 POI"}, + } + ], + "relations": [], + } + graph = _FakeGraph(node_count=1, relation_count=0) + database = _FakeDb(graph) + + with patch("app.project_lifecycle._falkor_client", return_value=database): + counts = import_falkor_graph("multi_label_graph", graph_data) + + self.assertEqual(counts, {"nodes": 1, "relations": 0}) + self.assertTrue( + any("CREATE (n:Place:FoodPlace:Hotel)" in query for query, _ in graph.queries) + ) + def test_import_failure_compensates_only_the_new_graph(self) -> None: payload = normalize_provision_payload(valid_payload()) graph = _FakeGraph(fail_on_query=2)