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:
co-authored by
Claude Sonnet 4.6
parent
4c95d02cbb
commit
6939494b3e
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user