Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3428,7 +3428,8 @@ def _invalidate_file_manifest_for_db_graph() -> None:
# passing --allow-partial (the good graph is preserved and the manifest
# is not stamped, so the retry re-extracts).
_force_write = cli_allow_partial or not _extraction_incomplete
_wrote = _to_json(G, communities, str(graph_json_path), force=_force_write)
_wrote = _to_json(G, communities, str(graph_json_path), force=_force_write,
backend=backend, model=model, mode=extract_mode)
if not _wrote:
# The shrink guard refused: this partial build is smaller than the
# existing graph. Exit before writing the manifest/marker below, which
Expand Down
59 changes: 58 additions & 1 deletion graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,58 @@ def existing_graph_node_count(path: "str | Path"):
return len(nodes) if isinstance(nodes, list) else MALFORMED_GRAPH


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:
_MAX_PROVENANCE_STR = 256


def _graphify_version() -> str | None:
try:
from importlib.metadata import version
return version("graphifyy")
except Exception:
return None


def _extractor_block(backend: str | None, model: str | None,
mode: str | None) -> dict | None:
"""Assemble the extraction-provenance block for graph.json (#2077). Returns
None when there is no extractor identity to record, so plain AST runs keep the
existing schema. Records what produced the graph (backend/model/mode) plus the
graphify version, so model-vs-model comparisons stay attributable."""
def _clean(v: str | None) -> str | None:
return str(v)[:_MAX_PROVENANCE_STR] if v else None
backend, model, mode = _clean(backend), _clean(model), _clean(mode)
if not (backend or model or mode):
return None
block: dict[str, str] = {}
if backend:
block["backend"] = backend
if model:
block["model"] = model
ver = _graphify_version()
if ver:
block["graphify_version"] = ver
if mode:
block["mode"] = mode
return block


def _read_existing_extractor(output_path: str) -> dict | None:
"""Return the `extractor` block already recorded in an existing graph.json so a
re-write that supplies no new provenance (community labeling, watch) doesn't
drop it (#2077). Size-capped and best-effort."""
p = Path(output_path)
if not p.exists():
return None
try:
from graphify.security import check_graph_file_size_cap
check_graph_file_size_cap(p)
block = json.loads(p.read_text(encoding="utf-8")).get("extractor")
except Exception:
return None
return block if isinstance(block, dict) and block else None


def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None, backend: str | None = None, model: str | None = None, mode: str | None = None) -> bool:
# Safety check: refuse to silently shrink an existing graph (#479)
existing_path = Path(output_path)
if not force and existing_path.exists():
Expand Down Expand Up @@ -315,6 +366,12 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *,
commit = built_at_commit if built_at_commit is not None else _git_head()
if commit:
data["built_at_commit"] = commit
# Extraction provenance (#2077): stamp what produced this graph. Carry forward
# any block an earlier extract wrote when this re-write supplies none, so a
# later labeling/watch pass doesn't strip attribution.
extractor = _extractor_block(backend, model, mode) or _read_existing_extractor(output_path)
if extractor:
data["extractor"] = extractor
from graphify.paths import write_json_atomic
# Atomic write: a crash/ENOSPC mid-write must not truncate a good graph.json.
write_json_atomic(output_path, data, indent=2)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,43 @@ def test_to_json_nodes_have_community():
for node in data["nodes"]:
assert "community" in node

def test_to_json_records_extractor_provenance(tmp_path):
"""`graphify extract --backend X --model Y` must stamp graph.json so a graph
can be attributed to what produced it (#2077)."""
G = make_graph()
communities = cluster(G)
out = tmp_path / "graph.json"
to_json(G, communities, str(out),
backend="ollama", model="gemma4:12b", mode="semantic")
data = json.loads(out.read_text())
prov = data["extractor"]
assert prov["backend"] == "ollama"
assert prov["model"] == "gemma4:12b"
assert prov["mode"] == "semantic"
assert isinstance(prov.get("graphify_version"), str) and prov["graphify_version"]

def test_to_json_omits_extractor_block_when_no_identity(tmp_path):
"""A plain AST run with no extractor identity leaves the schema unchanged."""
G = make_graph()
communities = cluster(G)
out = tmp_path / "graph.json"
to_json(G, communities, str(out))
assert "extractor" not in json.loads(out.read_text())

def test_to_json_carries_forward_extractor_on_relabel(tmp_path):
"""A later community-labeling rewrite (which passes no backend/model) must not
drop the provenance recorded at extraction time (#2077)."""
G = make_graph()
communities = cluster(G)
out = tmp_path / "graph.json"
to_json(G, communities, str(out),
backend="ollama", model="gemma4:12b", mode="semantic")
# relabel pass: same graph, no provenance args (mirrors `graphify cluster-only`)
to_json(G, communities, str(out), community_labels={0: "Core"})
prov = json.loads(out.read_text())["extractor"]
assert prov["backend"] == "ollama"
assert prov["model"] == "gemma4:12b"

def test_to_cypher_creates_file():
G = make_graph()
with tempfile.TemporaryDirectory() as tmp:
Expand Down
Loading