DataFlowing / processor /scripts /consolidate_project_meta.py
stouchinglhh
collector: 同步采集工程 v1.2.1(全部 LFS 模式文件指针化,与 Space 现有文件合并)
d646dd7
Raw History Blame Contribute Delete
6.5 kB
#!/usr/bin/env python3
"""Consolidate active project metadata to the internal four-entry contract.
The active project metadata is intentionally small and predictable::
meta/info.json
meta/stats.json
meta/tasks.json
meta/episodes/chunk-XXX/episode_XXXXXX.parquet
Calibration and collector JSON is preserved inside the matching episode row
under ``calibration`` and ``collector``. Unexpected legacy metadata is moved
to a timestamped sibling history directory after verification; data and video
files are not copied or removed by this command.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _archive_path(history: Path, relative: Path) -> Path:
target = history / relative
target.parent.mkdir(parents=True, exist_ok=True)
return target
def consolidate(root: Path, history_root: Path) -> dict:
from app.project_dataset import (
_merge_info,
_read_episode_sidecars,
_read_json,
_read_jsonl,
_repair_episode_stats,
_task_rows,
_write_json,
_write_tasks_json,
episode_chunk_for_index,
project_episode_rows,
clone_tree,
verify_project_dataset,
write_project_episode_index,
write_project_stats,
)
root = Path(root).resolve()
meta = root / "meta"
if not (root / "data").is_dir() or not (root / "videos").is_dir():
raise RuntimeError(f"Not a project dataset: {root}")
rows = project_episode_rows(root)
if not rows:
raise RuntimeError(f"No episode metadata found: {root}")
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
history = Path(history_root).resolve() / stamp / root.name
backup_meta = history / "meta-before"
clone_tree(meta, backup_meta)
try:
old_stats = _read_jsonl(meta / "episodes_stats.jsonl")
repaired_stats = _repair_episode_stats(old_stats, rows)
stats_by_index = {
int(item["episode_index"]): item.get("stats") or {}
for item in repaired_stats
if isinstance(item, dict) and item.get("episode_index") is not None
}
normalized_rows = []
rows = sorted(rows, key=lambda item: int(item.get("episode_index", 0)))
dataset_cursor = 0
for row in rows:
row = dict(row)
index = int(row.get("episode_index", len(normalized_rows)))
episode_id = str(
row.get("episode_id") or row.get("source_batch")
or f"{root.name}_{index:06d}"
)
row["episode_index"] = index
row["episode_id"] = episode_id
row.setdefault("source_batch", episode_id)
if not isinstance(row.get("stats"), dict) or not row.get("stats"):
stats = stats_by_index.get(index, {})
if stats:
row["stats"] = stats
else:
row.pop("stats", None)
row.update(_read_episode_sidecars(root, episode_id))
chunk = episode_chunk_for_index(index)
row["data/chunk_index"] = chunk
row["data/file_index"] = index
for key in list(row):
if key.endswith("/chunk_index"):
row[key] = chunk
elif key.endswith("/file_index"):
row[key] = index
length = int(row.get("length") or 0)
row["dataset_from_index"] = dataset_cursor
dataset_cursor += length
row["dataset_to_index"] = dataset_cursor
normalized_rows.append(row)
tasks = _task_rows(root, root.name)
write_project_episode_index(root, normalized_rows)
write_project_stats(root, normalized_rows)
_write_tasks_json(root, tasks)
info = _read_json(meta / "info.json", {})
info = _merge_info(root, info, normalized_rows, tasks)
_write_json(meta / "info.json", info)
verification = verify_project_dataset(root)
allowed = {"info.json", "stats.json", "tasks.json", "episodes"}
legacy_meta = history / "legacy-meta"
for child in list(meta.iterdir()):
if child.name in allowed:
continue
shutil.move(str(child), str(_archive_path(legacy_meta, Path(child.name))))
actual = {child.name for child in meta.iterdir()}
if not verification["passed"]:
raise RuntimeError(f"dataset verification failed: {verification['errors']}")
if actual != allowed:
raise RuntimeError(f"metadata contract not ready: {sorted(actual)}")
# The old files/directories are already absent from the active tree
# only after the successful write above. Keep them out of meta while
# preserving a local rollback/history copy beside the sessions root.
# (The backup contains the complete pre-consolidation meta tree.)
return {
"project": root.name,
"episodes": len(normalized_rows),
"chunks": len({episode_chunk_for_index(int(row["episode_index"]))
for row in normalized_rows}),
"history": str(history),
"verification": verification,
}
except Exception:
# Restore the metadata tree only; source data and videos were never
# changed. This makes a failed project independently retryable.
if meta.exists():
shutil.rmtree(meta)
clone_tree(backup_meta, meta)
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("roots", nargs="+", type=Path)
parser.add_argument(
"--history-root", type=Path,
help="sibling directory for pre-consolidation metadata backups",
)
args = parser.parse_args()
for root in args.roots:
root = root.resolve()
history_root = args.history_root or (root.parent.parent / ".meta-history")
try:
result = consolidate(root, history_root)
except Exception as exc:
print(json.dumps({"project": root.name, "error": str(exc)},
ensure_ascii=False, indent=2))
return 1
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())