From e6adaad8b323426a9f78d1a229a406b3c80e70ef Mon Sep 17 00:00:00 2001 From: iomgaa Date: Wed, 15 Jul 2026 22:05:50 -0400 Subject: [PATCH] feat: add tree.json flattener for diagnosis ground_truth --- app/harness/tree_nodes.py | 87 +++++++++++++++++++++++++++++++++++ tests/unit/test_tree_nodes.py | 56 ++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 app/harness/tree_nodes.py create mode 100644 tests/unit/test_tree_nodes.py diff --git a/app/harness/tree_nodes.py b/app/harness/tree_nodes.py new file mode 100644 index 0000000..5746bea --- /dev/null +++ b/app/harness/tree_nodes.py @@ -0,0 +1,87 @@ +"""诊断侧树读取适配:把嵌套 tree.json 展平成诊断消费的扁平 nodes dict。 + +诊断编排(core/evolution/diagnose.py)期望 tree_data 形如 +{"nodes": {node_id: {card, level, time_range}}},但 TRM5 建树产物 +store/videos//tree.json 是嵌套 {"metadata","roots":[...]}。本模块递归展平, +接通 TRM4→TRM5 迁移时断掉的 ground_truth 加载环。 + +不走 TreeIndex 对象层:仅 L1Node 有 to_dict(app/tree/index.py:260),L2/L3 为其内部闭包, +且 to_dict 输出无 level、L3 用 timestamp 无 time_range。直接遍历 json 更省且零改建树模块。 +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + + +def load_tree_nodes(store_dir: Path, video_id: str) -> dict[str, Any]: + """加载单视频 tree.json 并展平成扁平 nodes dict。 + + 参数: + store_dir: store 根目录(含 videos//tree.json)。 + video_id: 视频标识。 + + 返回: + {"nodes": {node_id: {"card": dict, "level": int, "time_range": list}}}。 + + 异常: + FileNotFoundError: tree.json 不存在(沿用 factory.py fail-loud 先例)。 + ValueError: 树无有效 roots、节点缺 id、或展平后 nodes 为空。 + + 关键实现: + level 由遍历深度赋值(root=1/child=2/孙=3),不解析 node_id——node_id 累积式 + (..._L1_..._L2_..._L3_)用正则首匹配会把 L2/L3 误判成 1。 + L3 无 time_range,用 timestamp 合成 [t, t]。 + """ + tree_path = store_dir / "videos" / video_id / "tree.json" + if not tree_path.exists(): + raise FileNotFoundError(f"树索引文件不存在: {tree_path}(诊断需真实树,P5 fail loud)") + tree = json.loads(tree_path.read_text(encoding="utf-8")) + roots = tree.get("roots") + if not isinstance(roots, list) or not roots: + raise ValueError(f"树无有效 roots: {tree_path}") + + nodes: dict[str, Any] = {} + + def _walk(node: dict[str, Any], level: int) -> None: + node_id = node.get("id") + if not isinstance(node_id, str) or not node_id: + raise ValueError(f"节点缺 id: {tree_path}") + time_range = node.get("time_range") + if time_range is None: + ts = node.get("timestamp") + time_range = [ts, ts] if ts is not None else [0, 0] + nodes[node_id] = { + "card": node.get("card", {}), + "level": level, + "time_range": time_range, + } + for child in node.get("children", []) or []: + _walk(child, level + 1) + + for root in roots: + _walk(root, 1) + + if not nodes: + raise ValueError(f"展平后 nodes 为空: {tree_path}") + return {"nodes": nodes} + + +def load_tree_data_for_videos(store_dir: Path, video_ids: list[str]) -> dict[str, Any]: + """按一组 video_id 去重加载展平树,供诊断按 video 注入。 + + 参数: + store_dir: store 根目录。 + video_ids: 视频标识列表(可含重复,内部按首次出现顺序去重)。 + + 返回: + {video_id: {"nodes": {...}}}。 + + 异常: + 同 load_tree_nodes(任一视频树缺失/无效即 fail-loud)。 + """ + return {vid: load_tree_nodes(store_dir, vid) for vid in dict.fromkeys(video_ids)} diff --git a/tests/unit/test_tree_nodes.py b/tests/unit/test_tree_nodes.py new file mode 100644 index 0000000..d25ccf3 --- /dev/null +++ b/tests/unit/test_tree_nodes.py @@ -0,0 +1,56 @@ +"""树展平器单测:用真实 store/videos/0RxMZBLeqRI/tree.json 验证展平正确性与 fail-loud。""" + +import json +from pathlib import Path + +import pytest + +from app.harness.tree_nodes import load_tree_data_for_videos, load_tree_nodes + +_STORE = Path("store") +_VID = "0RxMZBLeqRI" # 真实样本,111 节点 + + +def _recursive_count(tree_json: dict) -> int: + def walk(n: dict) -> int: + return 1 + sum(walk(c) for c in (n.get("children") or [])) + + return sum(walk(r) for r in tree_json["roots"]) + + +def test_load_tree_nodes_flattens_all_nodes(): + result = load_tree_nodes(_STORE, _VID) + assert set(result.keys()) == {"nodes"} + nodes = result["nodes"] + raw = json.loads((_STORE / "videos" / _VID / "tree.json").read_text(encoding="utf-8")) + assert len(nodes) == _recursive_count(raw) + sample = next(iter(nodes.values())) + assert set(sample.keys()) == {"card", "level", "time_range"} + assert isinstance(sample["card"], dict) + + +def test_level_assigned_by_depth_not_node_id(): + nodes = load_tree_nodes(_STORE, _VID)["nodes"] + l1_id = f"{_VID}_L1_000" + l3_id = f"{_VID}_L1_000_L2_000_L3_000" + assert nodes[l1_id]["level"] == 1 + assert nodes[l3_id]["level"] == 3 # 若按 node_id 首个 _L\d_ 会误判成 1 + + +def test_missing_tree_raises_file_not_found(): + with pytest.raises(FileNotFoundError): + load_tree_nodes(_STORE, "__no_such_video__") + + +def test_empty_roots_raises_value_error(tmp_path): + vdir = tmp_path / "videos" / "vX" + vdir.mkdir(parents=True) + (vdir / "tree.json").write_text(json.dumps({"metadata": {}, "roots": []}), encoding="utf-8") + with pytest.raises(ValueError): + load_tree_nodes(tmp_path, "vX") + + +def test_load_for_videos_dedups(): + data = load_tree_data_for_videos(_STORE, [_VID, _VID]) + assert set(data.keys()) == {_VID} + assert data[_VID]["nodes"]