add backup_if_protected to snapshot graph before overwrite when semantic/curated (#834)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-18 21:57:09 +01:00
co-authored by Claude Sonnet 4.6
parent 4c95d02cbb
commit 6939494b3e
4 changed files with 156 additions and 0 deletions
+10
View File
@@ -1825,6 +1825,8 @@ def main() -> None:
tokens, str(watch_path), suggested_questions=questions,
min_community_size=min_community_size, built_at_commit=_commit)
(out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8")
from graphify.export import backup_if_protected as _backup
_backup(out)
to_json(G, communities, str(out / "graph.json"))
labels_path.write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding="utf-8")
@@ -2769,6 +2771,8 @@ def main() -> None:
if no_cluster:
# --no-cluster: dump the raw merged extraction as graph.json.
# No NetworkX, no community detection, no analysis sidecar.
from graphify.export import backup_if_protected as _backup
_backup(graphify_out)
graph_json_path.write_text(
json.dumps(merged, indent=2), encoding="utf-8"
)
@@ -2846,7 +2850,13 @@ def main() -> None:
except Exception:
surprises = []
from graphify.export import backup_if_protected as _backup
_backup(graphify_out)
_to_json(G, communities, str(graph_json_path), force=True)
if merged.get("output_tokens", 0) > 0:
(graphify_out / ".graphify_semantic_marker").write_text(
json.dumps({"output_tokens": merged["output_tokens"]}), encoding="utf-8"
)
if global_merge:
from graphify.global_graph import global_add as _global_add
_tag = global_repo_tag or target.name
+74
View File
@@ -3,8 +3,11 @@ from __future__ import annotations
import html as _html
import json
import math
import os
import re
import shutil
from collections import Counter
from datetime import date
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
@@ -12,6 +15,77 @@ from graphify.security import sanitize_label
from graphify.analyze import _node_community_map
from graphify.build import edge_data
# Artifacts worth preserving across rebuilds (non-regenerable without LLM or curation).
_BACKUP_ARTIFACTS = [
"graph.json",
"GRAPH_REPORT.md",
".graphify_labels.json",
".graphify_analysis.json",
"manifest.json",
".graphify_semantic_marker",
"cost.json",
]
def backup_if_protected(out_dir: Path) -> "Path | None":
"""Snapshot graph artifacts to a dated subfolder before an overwrite.
Triggers when graph.json exists AND either:
- .graphify_semantic_marker is present (graph cost real LLM tokens), or
- .graphify_labels.json contains at least one non-default community label
(graph has been curated by a human or skill).
Returns the backup folder path, or None if no backup was taken.
Never raises — backup failure prints a warning but never blocks the write.
Set GRAPHIFY_NO_BACKUP=1 to disable.
"""
if os.environ.get("GRAPHIFY_NO_BACKUP"):
return None
out = Path(out_dir)
if not (out / "graph.json").exists():
return None
is_semantic = (out / ".graphify_semantic_marker").exists()
is_curated = False
labels_file = out / ".graphify_labels.json"
if labels_file.exists():
try:
labels = json.loads(labels_file.read_text(encoding="utf-8"))
is_curated = any(v != f"Community {k}" for k, v in labels.items())
except Exception:
pass
if not is_semantic and not is_curated:
return None
reason = "+".join(filter(None, ["semantic" if is_semantic else "", "curated" if is_curated else ""]))
today = date.today().isoformat()
backup_dir = out / today
suffix = 2
while backup_dir.exists():
backup_dir = out / f"{today}_{suffix}"
suffix += 1
try:
backup_dir.mkdir(parents=True, exist_ok=True)
copied = 0
for name in _BACKUP_ARTIFACTS:
src = out / name
if src.exists():
try:
shutil.copy2(src, backup_dir / name)
copied += 1
except Exception:
pass
if copied:
print(f"[graphify] backed up {reason} graph ({copied} files) → {backup_dir.name}/")
return backup_dir
except Exception as exc:
import sys
print(f"[graphify] warning: backup failed ({exc}) — continuing with overwrite", file=sys.stderr)
return None
def _obsidian_tag(name: str) -> str:
"""Sanitize a community name for use as an Obsidian tag.
+2
View File
@@ -543,6 +543,8 @@ def _rebuild_code(
else:
if not _check_shrink(force, existing_graph_data, candidate_graph_data, tmp=graph_tmp):
return False
from graphify.export import backup_if_protected as _backup
_backup(out)
graph_tmp.replace(existing_graph)
report_path.write_text(report, encoding="utf-8")
labels_file.write_text(labels_json, encoding="utf-8")
+70
View File
@@ -151,3 +151,73 @@ def test_to_canvas_file_paths_relative_to_vault():
for node in file_nodes:
assert "/" not in node["file"], f"file path should not contain '/': {node['file']}"
assert node["file"].endswith(".md")
# ── Issue #834: backup_if_protected ──────────────────────────────────────────
def test_backup_no_graph_json(tmp_path):
"""No graph.json → no backup."""
from graphify.export import backup_if_protected
assert backup_if_protected(tmp_path) is None
def test_backup_no_markers(tmp_path):
"""graph.json present but no sentinel and no curated labels → no backup."""
from graphify.export import backup_if_protected
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
assert backup_if_protected(tmp_path) is None
def test_backup_semantic_marker(tmp_path):
"""graph.json + .graphify_semantic_marker → backup taken."""
from graphify.export import backup_if_protected
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
(tmp_path / "GRAPH_REPORT.md").write_text("# Report")
(tmp_path / ".graphify_semantic_marker").write_text('{"output_tokens": 1234}')
result = backup_if_protected(tmp_path)
assert result is not None
assert result.is_dir()
assert (result / "graph.json").exists()
assert (result / "GRAPH_REPORT.md").exists()
assert (result / ".graphify_semantic_marker").exists()
def test_backup_curated_labels(tmp_path):
"""graph.json + non-default label in .graphify_labels.json → backup taken."""
import json
from graphify.export import backup_if_protected
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
(tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Auth Pipeline", "1": "Community 1"}))
result = backup_if_protected(tmp_path)
assert result is not None
def test_backup_default_labels_only(tmp_path):
"""All-default labels → no backup (not curated)."""
import json
from graphify.export import backup_if_protected
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
(tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Community 0", "1": "Community 1"}))
assert backup_if_protected(tmp_path) is None
def test_backup_same_day_collision(tmp_path):
"""Second backup on same day gets _2 suffix."""
from graphify.export import backup_if_protected
from datetime import date
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
(tmp_path / ".graphify_semantic_marker").write_text("{}")
b1 = backup_if_protected(tmp_path)
b2 = backup_if_protected(tmp_path)
assert b1 is not None and b2 is not None
assert b1 != b2
assert b2.name == f"{date.today().isoformat()}_2"
def test_backup_env_disable(tmp_path, monkeypatch):
"""GRAPHIFY_NO_BACKUP=1 disables backup entirely."""
from graphify.export import backup_if_protected
monkeypatch.setenv("GRAPHIFY_NO_BACKUP", "1")
(tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
(tmp_path / ".graphify_semantic_marker").write_text("{}")
assert backup_if_protected(tmp_path) is None