add Ollama backend and cross-project global graph (#729)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
69d2cc84e2
commit
48888a7c26
@@ -2,6 +2,14 @@
|
||||
|
||||
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
|
||||
|
||||
## 0.7.7 (2026-05-05)
|
||||
|
||||
- Feat: Ollama backend for headless extraction -- `graphify extract ./docs --backend ollama`; auto-detected when `OLLAMA_BASE_URL` is set; defaults to `qwen2.5-coder:7b`; zero cost ($0.00); sentinel API key handles OpenAI client auth requirement (#729)
|
||||
- Feat: Cross-project global graph at `~/.graphify/global.json` -- `graphify global add/remove/list/path` to register multiple project graphs with `<repo>::<id>` prefixed node IDs, preventing silent collisions; hash-based skip avoids re-ingesting unchanged graphs (#729)
|
||||
- Feat: `graphify extract --global --as <tag>` flag -- after building a project graph, auto-registers it into the global graph in one step (#729)
|
||||
- Feat: `merge-graphs` now prefix-relabels each input graph before composing, preventing silent node ID collisions when two projects share entity names (#729)
|
||||
- Fix: `deduplicate_entities` raises `ValueError` if called with nodes spanning multiple repos (cross-project dedup disabled by design -- per-project graphs are deduplicated in isolation) (#729)
|
||||
|
||||
## 0.7.6 (2026-05-05)
|
||||
|
||||
- Fix: `cluster-only` now accepts `--graph <path>` to specify a non-default graph.json location; positional path and flags can appear in any order (#724)
|
||||
|
||||
@@ -221,7 +221,7 @@ The MCP server gives your assistant structured access: `query_graph`, `get_node`
|
||||
|
||||
- **Code files** — processed locally via tree-sitter. Nothing leaves your machine.
|
||||
- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
|
||||
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` (Claude) or `MOONSHOT_API_KEY` (Kimi). The `--dedup-llm` flag uses the same key.
|
||||
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` (Claude), `MOONSHOT_API_KEY` (Kimi), or a running Ollama instance (`OLLAMA_BASE_URL`). The `--dedup-llm` flag uses the same key.
|
||||
- No telemetry, no usage tracking, no analytics.
|
||||
|
||||
---
|
||||
@@ -275,10 +275,17 @@ graphify antigravity install / uninstall
|
||||
|
||||
graphify extract ./docs # headless LLM extraction for CI (no IDE needed)
|
||||
graphify extract ./docs --backend claude # explicit backend: claude (ANTHROPIC_API_KEY) or kimi (MOONSHOT_API_KEY)
|
||||
graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL)
|
||||
graphify extract ./docs --no-cluster # raw extraction only, skip clustering
|
||||
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key)
|
||||
graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora
|
||||
|
||||
graphify global add graphify-out/graph.json myrepo # register a project graph into ~/.graphify/global.json
|
||||
graphify global remove myrepo # remove a project from the global graph
|
||||
graphify global list # show all registered repos + node/edge counts
|
||||
graphify global path # print path to the global graph file
|
||||
|
||||
graphify clone https://github.com/karpathy/nanoGPT
|
||||
graphify merge-graphs a.json b.json --out merged.json
|
||||
graphify watch ./src
|
||||
|
||||
+106
-5
@@ -1097,8 +1097,16 @@ def main() -> None:
|
||||
print(" --label NAME project label in header")
|
||||
print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
|
||||
print(" --backend B kimi|claude (default: whichever API key is set)")
|
||||
print(" --model <name> override the backend's default model")
|
||||
print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
|
||||
print(" --no-cluster skip clustering, write raw extraction only")
|
||||
print(" --global also merge the resulting graph into the global graph")
|
||||
print(" --as <tag> repo tag for --global (default: target directory name)")
|
||||
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
|
||||
print(" --as <tag> repo tag (default: parent directory name)")
|
||||
print(" global remove <tag> remove a repo's nodes from the global graph")
|
||||
print(" global list list repos in the global graph")
|
||||
print(" global path print path to the global graph file")
|
||||
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
|
||||
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
|
||||
print(" hook uninstall remove git hooks")
|
||||
@@ -1714,6 +1722,7 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
import networkx as _nx
|
||||
from networkx.readwrite import json_graph as _jg
|
||||
from graphify.build import prefix_graph_for_global as _prefix
|
||||
graphs = []
|
||||
for gp in graph_paths:
|
||||
if not gp.exists():
|
||||
@@ -1728,12 +1737,12 @@ def main() -> None:
|
||||
G = _jg.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
G = _jg.node_link_graph(data)
|
||||
# Tag every node with which repo it came from
|
||||
repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
|
||||
for node in G.nodes:
|
||||
G.nodes[node].setdefault("repo", repo_tag)
|
||||
graphs.append(G)
|
||||
merged = _nx.compose_all(graphs)
|
||||
merged = _nx.Graph()
|
||||
for G, gp in zip(graphs, graph_paths):
|
||||
repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name
|
||||
prefixed = _prefix(G, repo_tag)
|
||||
merged = _nx.compose(merged, prefixed)
|
||||
try:
|
||||
out_data = _jg.node_link_data(merged, edges="links")
|
||||
except TypeError:
|
||||
@@ -1911,6 +1920,62 @@ def main() -> None:
|
||||
result = run_benchmark(graph_path, corpus_words=corpus_words)
|
||||
print_benchmark(result)
|
||||
|
||||
elif cmd == "global":
|
||||
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
from graphify.global_graph import (
|
||||
global_add as _global_add,
|
||||
global_remove as _global_remove,
|
||||
global_list as _global_list,
|
||||
global_path as _global_path,
|
||||
)
|
||||
if subcmd == "add":
|
||||
# graphify global add <graph.json> [--as <tag>]
|
||||
args = sys.argv[3:]
|
||||
source = None
|
||||
tag = None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--as" and i + 1 < len(args):
|
||||
tag = args[i + 1]; i += 2
|
||||
elif not source:
|
||||
source = Path(args[i]); i += 1
|
||||
else:
|
||||
i += 1
|
||||
if not source:
|
||||
print("Usage: graphify global add <graph.json> [--as <repo-tag>]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
tag = tag or source.parent.parent.name
|
||||
try:
|
||||
result = _global_add(source, tag)
|
||||
if result["skipped"]:
|
||||
print(f"'{tag}' unchanged since last add — global graph not modified.")
|
||||
else:
|
||||
print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, "
|
||||
f"-{result['nodes_removed']} pruned. Global: {_global_path()}")
|
||||
except Exception as exc:
|
||||
print(f"error: {exc}", file=sys.stderr); sys.exit(1)
|
||||
elif subcmd == "remove":
|
||||
tag = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
if not tag:
|
||||
print("Usage: graphify global remove <repo-tag>", file=sys.stderr); sys.exit(1)
|
||||
try:
|
||||
removed = _global_remove(tag)
|
||||
print(f"Removed '{tag}' from global graph ({removed} nodes pruned).")
|
||||
except KeyError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr); sys.exit(1)
|
||||
elif subcmd == "list":
|
||||
repos = _global_list()
|
||||
if not repos:
|
||||
print("Global graph is empty. Use 'graphify global add' to add a project.")
|
||||
else:
|
||||
print(f"Global graph: {_global_path()}")
|
||||
for tag, info in repos.items():
|
||||
print(f" {tag}: {info.get('node_count', '?')} nodes, added {info.get('added_at', '?')[:10]}")
|
||||
elif subcmd == "path":
|
||||
print(_global_path())
|
||||
else:
|
||||
print("Usage: graphify global [add|remove|list|path]", file=sys.stderr); sys.exit(1)
|
||||
|
||||
elif cmd == "extract":
|
||||
# Headless full-pipeline extraction for CI / scripts (#698).
|
||||
# Runs detect -> AST extraction on code -> semantic LLM extraction on
|
||||
@@ -1935,6 +2000,9 @@ def main() -> None:
|
||||
out_dir: Path | None = None
|
||||
no_cluster = False
|
||||
dedup_llm = False
|
||||
model_override: str | None = None
|
||||
global_merge = False
|
||||
global_repo_tag: str | None = None
|
||||
args = sys.argv[3:]
|
||||
i = 0
|
||||
while i < len(args):
|
||||
@@ -1943,6 +2011,10 @@ def main() -> None:
|
||||
backend = args[i + 1]; i += 2
|
||||
elif a.startswith("--backend="):
|
||||
backend = a.split("=", 1)[1]; i += 1
|
||||
elif a == "--model" and i + 1 < len(args):
|
||||
model_override = args[i + 1]; i += 2
|
||||
elif a.startswith("--model="):
|
||||
model_override = a.split("=", 1)[1]; i += 1
|
||||
elif a == "--out" and i + 1 < len(args):
|
||||
out_dir = Path(args[i + 1]); i += 2
|
||||
elif a.startswith("--out="):
|
||||
@@ -1951,6 +2023,10 @@ def main() -> None:
|
||||
no_cluster = True; i += 1
|
||||
elif a == "--dedup-llm":
|
||||
dedup_llm = True; i += 1
|
||||
elif a == "--global":
|
||||
global_merge = True; i += 1
|
||||
elif a == "--as" and i + 1 < len(args):
|
||||
global_repo_tag = args[i + 1]; i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
@@ -2084,6 +2160,7 @@ def main() -> None:
|
||||
fresh = _extract_corpus_parallel(
|
||||
[Path(p) for p in uncached_paths],
|
||||
backend=backend,
|
||||
model=model_override,
|
||||
root=target,
|
||||
)
|
||||
except ImportError as exc:
|
||||
@@ -2150,6 +2227,18 @@ def main() -> None:
|
||||
_save_manifest(files_by_type, manifest_path=str(manifest_path))
|
||||
except Exception as exc:
|
||||
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
|
||||
if global_merge:
|
||||
from graphify.global_graph import global_add as _global_add
|
||||
_tag = global_repo_tag or target.name
|
||||
try:
|
||||
result = _global_add(graphify_out / "graph.json", _tag)
|
||||
if result["skipped"]:
|
||||
print(f"[graphify global] '{_tag}' unchanged since last add — skipped.")
|
||||
else:
|
||||
print(f"[graphify global] '{_tag}' merged into global graph "
|
||||
f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
|
||||
except Exception as exc:
|
||||
print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
# Build graph + cluster + score + write.
|
||||
@@ -2193,6 +2282,18 @@ def main() -> None:
|
||||
surprises = []
|
||||
|
||||
_to_json(G, communities, str(graph_json_path), force=True)
|
||||
if global_merge:
|
||||
from graphify.global_graph import global_add as _global_add
|
||||
_tag = global_repo_tag or target.name
|
||||
try:
|
||||
result = _global_add(graphify_out / "graph.json", _tag)
|
||||
if result["skipped"]:
|
||||
print(f"[graphify global] '{_tag}' unchanged since last add — skipped.")
|
||||
else:
|
||||
print(f"[graphify global] '{_tag}' merged into global graph "
|
||||
f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).")
|
||||
except Exception as exc:
|
||||
print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr)
|
||||
analysis = {
|
||||
"communities": {str(k): v for k, v in communities.items()},
|
||||
"cohesion": {str(k): v for k, v in cohesion.items()},
|
||||
|
||||
@@ -271,3 +271,26 @@ def build_merge(
|
||||
)
|
||||
|
||||
return G
|
||||
|
||||
|
||||
def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph:
|
||||
"""Return a copy of G with all node IDs prefixed with repo_tag::.
|
||||
|
||||
Labels are preserved unchanged (for display). A 'local_id' attribute
|
||||
is added to each node so the original ID can be recovered. Edges are
|
||||
rewritten to match the new prefixed IDs. The 'repo' attribute is set
|
||||
on every node.
|
||||
"""
|
||||
relabel = {n: f"{repo_tag}::{n}" for n in G.nodes}
|
||||
H = nx.relabel_nodes(G, relabel, copy=True)
|
||||
for node, data in H.nodes(data=True):
|
||||
data["repo"] = repo_tag
|
||||
data.setdefault("local_id", node.split("::", 1)[1])
|
||||
return H
|
||||
|
||||
|
||||
def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int:
|
||||
"""Remove all nodes tagged with repo_tag from G in-place. Returns count removed."""
|
||||
to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag]
|
||||
G.remove_nodes_from(to_remove)
|
||||
return len(to_remove)
|
||||
|
||||
@@ -103,6 +103,16 @@ def deduplicate_entities(
|
||||
Returns:
|
||||
(deduped_nodes, deduped_edges) with edges rewired to survivors
|
||||
"""
|
||||
# Guard: cross-project dedup is not supported — nodes from different repos
|
||||
# share label names by coincidence and must never be merged by string similarity.
|
||||
# If you need to dedup a global graph, run deduplicate_entities per-repo first.
|
||||
repos_seen = {n.get("repo") for n in nodes if n.get("repo")}
|
||||
if len(repos_seen) > 1:
|
||||
raise ValueError(
|
||||
f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. "
|
||||
f"Cross-project dedup is disabled — run dedup per-repo before merging."
|
||||
)
|
||||
|
||||
if len(nodes) <= 1:
|
||||
return nodes, edges
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph as _jg
|
||||
|
||||
_GLOBAL_DIR = Path.home() / ".graphify"
|
||||
_GLOBAL_GRAPH = _GLOBAL_DIR / "global-graph.json"
|
||||
_GLOBAL_MANIFEST = _GLOBAL_DIR / "global-manifest.json"
|
||||
|
||||
|
||||
def _load_manifest() -> dict:
|
||||
if _GLOBAL_MANIFEST.exists():
|
||||
try:
|
||||
return json.loads(_GLOBAL_MANIFEST.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {"version": 1, "repos": {}}
|
||||
|
||||
|
||||
def _save_manifest(manifest: dict) -> None:
|
||||
_GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_GLOBAL_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_global_graph() -> nx.Graph:
|
||||
if _GLOBAL_GRAPH.exists():
|
||||
data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8"))
|
||||
if "links" not in data and "edges" in data:
|
||||
data = dict(data, links=data["edges"])
|
||||
try:
|
||||
return _jg.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
return _jg.node_link_graph(data)
|
||||
return nx.Graph()
|
||||
|
||||
|
||||
def _save_global_graph(G: nx.Graph) -> None:
|
||||
_GLOBAL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
data = _jg.node_link_data(G, edges="links")
|
||||
except TypeError:
|
||||
data = _jg.node_link_data(G)
|
||||
_GLOBAL_GRAPH.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _file_hash(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
h.update(path.read_bytes())
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
|
||||
def global_add(source_path: Path, repo_tag: str) -> dict:
|
||||
"""Add or update a project graph in the global graph.
|
||||
|
||||
Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped.
|
||||
Skipped=True means the source graph hasn't changed since last add.
|
||||
"""
|
||||
from graphify.build import prefix_graph_for_global, prune_repo_from_graph
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"graph not found: {source_path}")
|
||||
|
||||
manifest = _load_manifest()
|
||||
src_hash = _file_hash(source_path)
|
||||
|
||||
existing = manifest["repos"].get(repo_tag, {})
|
||||
existing_path = existing.get("source_path", "")
|
||||
if existing_path and existing_path != str(source_path.resolve()):
|
||||
print(
|
||||
f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to "
|
||||
f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. "
|
||||
f"Use --as <tag> to give it a different name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if existing.get("source_hash") == src_hash:
|
||||
return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True}
|
||||
|
||||
# Load source graph
|
||||
data = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
if "links" not in data and "edges" in data:
|
||||
data = dict(data, links=data["edges"])
|
||||
try:
|
||||
src_G = _jg.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
src_G = _jg.node_link_graph(data)
|
||||
|
||||
# Prefix IDs for cross-project isolation
|
||||
prefixed = prefix_graph_for_global(src_G, repo_tag)
|
||||
|
||||
# Load global graph and prune stale nodes for this repo
|
||||
G = _load_global_graph()
|
||||
removed = prune_repo_from_graph(G, repo_tag)
|
||||
|
||||
# Merge external-library nodes (no source_file) by label to avoid duplication
|
||||
external_labels = {
|
||||
d.get("label", ""): n
|
||||
for n, d in G.nodes(data=True)
|
||||
if not d.get("source_file") and d.get("label")
|
||||
}
|
||||
nodes_to_skip = set()
|
||||
for node, data in prefixed.nodes(data=True):
|
||||
if not data.get("source_file") and data.get("label") in external_labels:
|
||||
nodes_to_skip.add(node)
|
||||
|
||||
# Compose: add prefixed nodes (except deduplicated externals) into global graph
|
||||
for node, data in prefixed.nodes(data=True):
|
||||
if node not in nodes_to_skip:
|
||||
G.add_node(node, **data)
|
||||
for u, v, data in prefixed.edges(data=True):
|
||||
if u not in nodes_to_skip and v not in nodes_to_skip:
|
||||
G.add_edge(u, v, **data)
|
||||
|
||||
added = prefixed.number_of_nodes() - len(nodes_to_skip)
|
||||
_save_global_graph(G)
|
||||
|
||||
manifest["repos"][repo_tag] = {
|
||||
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_path": str(source_path.resolve()),
|
||||
"node_count": added,
|
||||
"edge_count": prefixed.number_of_edges(),
|
||||
"source_hash": src_hash,
|
||||
}
|
||||
_save_manifest(manifest)
|
||||
|
||||
return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False}
|
||||
|
||||
|
||||
def global_remove(repo_tag: str) -> int:
|
||||
"""Remove all nodes for repo_tag from the global graph. Returns count removed."""
|
||||
from graphify.build import prune_repo_from_graph
|
||||
|
||||
manifest = _load_manifest()
|
||||
if repo_tag not in manifest["repos"]:
|
||||
raise KeyError(f"repo '{repo_tag}' not in global graph")
|
||||
|
||||
G = _load_global_graph()
|
||||
removed = prune_repo_from_graph(G, repo_tag)
|
||||
_save_global_graph(G)
|
||||
|
||||
del manifest["repos"][repo_tag]
|
||||
_save_manifest(manifest)
|
||||
return removed
|
||||
|
||||
|
||||
def global_list() -> dict:
|
||||
"""Return the manifest repos dict."""
|
||||
return _load_manifest().get("repos", {})
|
||||
|
||||
|
||||
def global_path() -> Path:
|
||||
return _GLOBAL_GRAPH
|
||||
+25
-3
@@ -60,6 +60,14 @@ BACKENDS: dict[str, dict] = {
|
||||
"temperature": None, # kimi-k2.6 enforces its own fixed temperature; sending any value raises 400
|
||||
"max_tokens": 16384,
|
||||
},
|
||||
"ollama": {
|
||||
"base_url": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1"),
|
||||
"default_model": os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"),
|
||||
"env_key": "OLLAMA_API_KEY",
|
||||
"pricing": {"input": 0.0, "output": 0.0},
|
||||
"temperature": 0,
|
||||
"max_tokens": 16384,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +137,8 @@ def _call_openai_compat(
|
||||
user_message: str,
|
||||
temperature: float | None = 0,
|
||||
max_tokens: int = 8192,
|
||||
*,
|
||||
backend: str = "",
|
||||
) -> dict:
|
||||
"""Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON."""
|
||||
try:
|
||||
@@ -162,6 +172,14 @@ def _call_openai_compat(
|
||||
# mid-generation. The JSON we got back is truncated; callers should
|
||||
# treat this as a signal to retry with smaller input.
|
||||
result["finish_reason"] = resp.choices[0].finish_reason
|
||||
output_tokens = result["output_tokens"]
|
||||
if output_tokens < 50 and backend == "ollama":
|
||||
print(
|
||||
"[graphify] warning: ollama returned very few tokens — the model may be "
|
||||
"too small or not following the JSON instruction format. "
|
||||
"Try a larger model with --model (e.g. --model qwen2.5-coder:14b).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -210,6 +228,8 @@ def extract_files_direct(
|
||||
|
||||
cfg = BACKENDS[backend]
|
||||
key = api_key or os.environ.get(cfg["env_key"], "")
|
||||
if not key and backend == "ollama":
|
||||
key = "ollama" # Ollama ignores auth but openai client requires non-empty
|
||||
if not key:
|
||||
raise ValueError(
|
||||
f"No API key for backend '{backend}'. "
|
||||
@@ -222,7 +242,7 @@ def extract_files_direct(
|
||||
if backend == "claude":
|
||||
return _call_claude(key, mdl, user_msg, max_tokens=max_out)
|
||||
else:
|
||||
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0), max_tokens=max_out)
|
||||
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0), max_tokens=max_out, backend=backend)
|
||||
|
||||
|
||||
def _estimate_file_tokens(path: Path) -> int:
|
||||
@@ -485,11 +505,13 @@ def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
|
||||
def detect_backend() -> str | None:
|
||||
"""Return the name of whichever backend has an API key set, or None.
|
||||
|
||||
Kimi is checked first (opt-in). Falls back to Claude if ANTHROPIC_API_KEY is set.
|
||||
Claude is the default for the skill.md subagent pipeline and is never forced here.
|
||||
Priority: kimi → ollama (if OLLAMA_BASE_URL set) → claude.
|
||||
Ollama is opt-in via env var — never auto-probed.
|
||||
"""
|
||||
if os.environ.get("MOONSHOT_API_KEY"):
|
||||
return "kimi"
|
||||
if os.environ.get("OLLAMA_BASE_URL"):
|
||||
return "ollama"
|
||||
if os.environ.get("ANTHROPIC_API_KEY"):
|
||||
return "claude"
|
||||
return None
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "graphifyy"
|
||||
version = "0.7.6"
|
||||
version = "0.7.7"
|
||||
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for the global graph infrastructure (graphify/global_graph.py),
|
||||
prefix/prune helpers in graphify/build.py, and the cross-repo guard in
|
||||
graphify/dedup.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import networkx as nx
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_graph(nodes, edges=None):
|
||||
"""Build a simple nx.Graph from node dicts."""
|
||||
G = nx.Graph()
|
||||
for n in nodes:
|
||||
nid = n["id"]
|
||||
G.add_node(nid, **{k: v for k, v in n.items() if k != "id"})
|
||||
for e in (edges or []):
|
||||
G.add_edge(
|
||||
e["source"],
|
||||
e["target"],
|
||||
**{k: v for k, v in e.items() if k not in ("source", "target")},
|
||||
)
|
||||
return G
|
||||
|
||||
|
||||
def _graph_to_json(G, path):
|
||||
from networkx.readwrite import json_graph as jg
|
||||
try:
|
||||
data = jg.node_link_data(G, edges="links")
|
||||
except TypeError:
|
||||
data = jg.node_link_data(G)
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
# ── build.py helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_prefix_graph_preserves_label():
|
||||
from graphify.build import prefix_graph_for_global
|
||||
G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
H = prefix_graph_for_global(G, "repoA")
|
||||
assert "repoA::userservice" in H.nodes
|
||||
assert "userservice" not in H.nodes
|
||||
assert H.nodes["repoA::userservice"]["label"] == "UserService"
|
||||
|
||||
|
||||
def test_prefix_graph_sets_repo_and_local_id():
|
||||
from graphify.build import prefix_graph_for_global
|
||||
G = _make_graph([{"id": "userservice", "label": "UserService"}])
|
||||
H = prefix_graph_for_global(G, "repoA")
|
||||
data = H.nodes["repoA::userservice"]
|
||||
assert data["repo"] == "repoA"
|
||||
assert data["local_id"] == "userservice"
|
||||
|
||||
|
||||
def test_prefix_graph_rewrites_edges():
|
||||
from graphify.build import prefix_graph_for_global
|
||||
G = _make_graph(
|
||||
[{"id": "a", "label": "A"}, {"id": "b", "label": "B"}],
|
||||
[{"source": "a", "target": "b"}],
|
||||
)
|
||||
H = prefix_graph_for_global(G, "repo1")
|
||||
assert H.has_edge("repo1::a", "repo1::b")
|
||||
assert not H.has_edge("a", "b")
|
||||
|
||||
|
||||
def test_prune_repo_removes_correct_nodes():
|
||||
from graphify.build import prune_repo_from_graph
|
||||
G = nx.Graph()
|
||||
G.add_node("repoA::userservice", repo="repoA", label="UserService")
|
||||
G.add_node("repoB::userservice", repo="repoB", label="UserService")
|
||||
G.add_node("repoA::auth", repo="repoA", label="Auth")
|
||||
removed = prune_repo_from_graph(G, "repoA")
|
||||
assert removed == 2
|
||||
assert "repoB::userservice" in G.nodes
|
||||
assert "repoA::userservice" not in G.nodes
|
||||
assert "repoA::auth" not in G.nodes
|
||||
|
||||
|
||||
def test_prune_repo_returns_zero_if_not_present():
|
||||
from graphify.build import prune_repo_from_graph
|
||||
G = nx.Graph()
|
||||
G.add_node("repoA::x", repo="repoA")
|
||||
removed = prune_repo_from_graph(G, "repoB")
|
||||
assert removed == 0
|
||||
assert G.number_of_nodes() == 1
|
||||
|
||||
|
||||
# ── global_graph.py ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_global_add_creates_global_graph(tmp_path):
|
||||
src_graph = tmp_path / "graph.json"
|
||||
G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
_graph_to_json(G, src_graph)
|
||||
|
||||
global_dir = tmp_path / ".graphify"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"):
|
||||
from graphify.global_graph import global_add
|
||||
result = global_add(src_graph, "repoA")
|
||||
|
||||
assert result["skipped"] is False
|
||||
assert result["nodes_added"] > 0
|
||||
manifest_path = global_dir / "global-manifest.json"
|
||||
assert manifest_path.exists()
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
assert "repoA" in manifest["repos"]
|
||||
|
||||
|
||||
def test_global_add_skip_on_unchanged_hash(tmp_path):
|
||||
src_graph = tmp_path / "graph.json"
|
||||
G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
_graph_to_json(G, src_graph)
|
||||
|
||||
global_dir = tmp_path / ".graphify"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"):
|
||||
from graphify.global_graph import global_add
|
||||
global_add(src_graph, "repoA")
|
||||
result2 = global_add(src_graph, "repoA")
|
||||
|
||||
assert result2["skipped"] is True
|
||||
|
||||
|
||||
def test_global_add_two_repos_no_collision(tmp_path):
|
||||
g1 = tmp_path / "graph1.json"
|
||||
g2 = tmp_path / "graph2.json"
|
||||
G1 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
G2 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
_graph_to_json(G1, g1)
|
||||
_graph_to_json(G2, g2)
|
||||
|
||||
global_dir = tmp_path / ".graphify"
|
||||
global_graph_path = global_dir / "global-graph.json"
|
||||
global_manifest_path = global_dir / "global-manifest.json"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_graph_path), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_manifest_path):
|
||||
from graphify.global_graph import global_add, _load_global_graph
|
||||
global_add(g1, "repoA")
|
||||
global_add(g2, "repoB")
|
||||
G = _load_global_graph()
|
||||
|
||||
assert "repoA::userservice" in G.nodes
|
||||
assert "repoB::userservice" in G.nodes
|
||||
assert G.number_of_nodes() == 2 # no silent merge
|
||||
|
||||
|
||||
def test_global_remove(tmp_path):
|
||||
src_graph = tmp_path / "graph.json"
|
||||
G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
_graph_to_json(G, src_graph)
|
||||
|
||||
global_dir = tmp_path / ".graphify"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"):
|
||||
from graphify.global_graph import global_add, global_remove
|
||||
global_add(src_graph, "repoA")
|
||||
removed = global_remove("repoA")
|
||||
|
||||
assert removed > 0
|
||||
# manifest should no longer list repoA - need to re-patch for list call
|
||||
global_dir2 = global_dir # same dir
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir2), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir2 / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir2 / "global-manifest.json"):
|
||||
from graphify.global_graph import global_list
|
||||
repos = global_list()
|
||||
assert "repoA" not in repos
|
||||
|
||||
|
||||
def test_global_remove_unknown_tag_raises(tmp_path):
|
||||
global_dir = tmp_path / ".graphify"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"):
|
||||
from graphify.global_graph import global_remove
|
||||
with pytest.raises(KeyError):
|
||||
global_remove("nonexistent")
|
||||
|
||||
|
||||
def test_global_add_collision_warning(tmp_path, capsys):
|
||||
g1 = tmp_path / "graph1.json"
|
||||
g2 = tmp_path / "graph2.json"
|
||||
G = _make_graph([{"id": "x", "label": "X", "source_file": "x.py"}])
|
||||
_graph_to_json(G, g1)
|
||||
_graph_to_json(G, g2)
|
||||
|
||||
global_dir = tmp_path / ".graphify"
|
||||
with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \
|
||||
patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \
|
||||
patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"):
|
||||
from graphify.global_graph import global_add
|
||||
global_add(g1, "myrepo")
|
||||
global_add(g2, "myrepo") # different source path, same tag
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "warning" in captured.err.lower() or "warning" in captured.out.lower()
|
||||
|
||||
|
||||
# ── dedup guard ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_dedup_raises_on_cross_repo_nodes():
|
||||
from graphify.dedup import deduplicate_entities
|
||||
nodes = [
|
||||
{"id": "repoA::userservice", "label": "UserService", "repo": "repoA"},
|
||||
{"id": "repoB::userservice", "label": "UserService", "repo": "repoB"},
|
||||
]
|
||||
with pytest.raises(ValueError, match="multiple repos"):
|
||||
deduplicate_entities(nodes, [], communities={})
|
||||
|
||||
|
||||
def test_dedup_ok_with_single_repo():
|
||||
from graphify.dedup import deduplicate_entities
|
||||
nodes = [
|
||||
{"id": "repoA::userservice", "label": "UserService", "repo": "repoA"},
|
||||
{"id": "repoA::auth", "label": "Auth", "repo": "repoA"},
|
||||
]
|
||||
result_nodes, result_edges = deduplicate_entities(nodes, [], communities={})
|
||||
assert len(result_nodes) == 2 # no false merge
|
||||
|
||||
|
||||
def test_dedup_ok_with_no_repo_attr():
|
||||
from graphify.dedup import deduplicate_entities
|
||||
nodes = [
|
||||
{"id": "userservice", "label": "UserService"},
|
||||
{"id": "auth", "label": "Auth"},
|
||||
]
|
||||
result_nodes, result_edges = deduplicate_entities(nodes, [], communities={})
|
||||
assert len(result_nodes) == 2
|
||||
|
||||
|
||||
# ── merge-graphs prefix ───────────────────────────────────────────────────────
|
||||
|
||||
def test_merge_graphs_prefixes_ids(tmp_path):
|
||||
"""merge-graphs should prefix node IDs with repo name to avoid silent collision."""
|
||||
from graphify.build import prefix_graph_for_global
|
||||
from networkx.readwrite import json_graph as jg
|
||||
|
||||
# Two graphs with same node ID
|
||||
G1 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
G2 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}])
|
||||
|
||||
repo1 = tmp_path / "repo1" / "graphify-out"
|
||||
repo2 = tmp_path / "repo2" / "graphify-out"
|
||||
repo1.mkdir(parents=True)
|
||||
repo2.mkdir(parents=True)
|
||||
|
||||
g1_path = repo1 / "graph.json"
|
||||
g2_path = repo2 / "graph.json"
|
||||
_graph_to_json(G1, g1_path)
|
||||
_graph_to_json(G2, g2_path)
|
||||
|
||||
# Simulate what merge-graphs now does (prefix before compose)
|
||||
graphs = []
|
||||
graph_paths = [g1_path, g2_path]
|
||||
for gp in graph_paths:
|
||||
data = json.loads(gp.read_text())
|
||||
if "links" not in data and "edges" in data:
|
||||
data = dict(data, links=data["edges"])
|
||||
try:
|
||||
G = jg.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
G = jg.node_link_graph(data)
|
||||
repo_tag = gp.parent.parent.name
|
||||
graphs.append(prefix_graph_for_global(G, repo_tag))
|
||||
|
||||
merged = nx.Graph()
|
||||
for G in graphs:
|
||||
merged = nx.compose(merged, G)
|
||||
|
||||
assert "repo1::userservice" in merged.nodes
|
||||
assert "repo2::userservice" in merged.nodes
|
||||
assert merged.number_of_nodes() == 2 # no silent collapse
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the Ollama backend additions in graphify/llm.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from graphify.llm import detect_backend, BACKENDS
|
||||
|
||||
|
||||
def test_ollama_in_backends():
|
||||
assert "ollama" in BACKENDS
|
||||
assert BACKENDS["ollama"]["pricing"]["input"] == 0.0
|
||||
assert BACKENDS["ollama"]["pricing"]["output"] == 0.0
|
||||
assert "max_tokens" in BACKENDS["ollama"]
|
||||
|
||||
|
||||
def test_detect_backend_ollama(monkeypatch):
|
||||
monkeypatch.delenv("MOONSHOT_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
|
||||
assert detect_backend() == "ollama"
|
||||
|
||||
|
||||
def test_detect_backend_kimi_beats_ollama(monkeypatch):
|
||||
monkeypatch.setenv("MOONSHOT_API_KEY", "test-key")
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
assert detect_backend() == "kimi"
|
||||
|
||||
|
||||
def test_detect_backend_ollama_beats_claude(monkeypatch):
|
||||
monkeypatch.delenv("MOONSHOT_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
||||
assert detect_backend() == "ollama"
|
||||
|
||||
|
||||
def test_detect_backend_none_without_envvars(monkeypatch):
|
||||
monkeypatch.delenv("MOONSHOT_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
assert detect_backend() is None
|
||||
|
||||
|
||||
def test_ollama_api_key_sentinel(monkeypatch):
|
||||
"""extract_files_direct with backend=ollama and no OLLAMA_API_KEY should use sentinel 'ollama' not raise."""
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
fake_result = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"hyperedges": [],
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 10,
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
with patch("graphify.llm._call_openai_compat", return_value=fake_result) as mock_call:
|
||||
from graphify.llm import extract_files_direct
|
||||
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
|
||||
f.write("x = 1\n")
|
||||
tmp = Path(f.name)
|
||||
try:
|
||||
extract_files_direct([tmp], backend="ollama", root=tmp.parent)
|
||||
# Should have called _call_openai_compat with api_key="ollama"
|
||||
assert mock_call.called
|
||||
call_kwargs = mock_call.call_args
|
||||
api_key_used = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("api_key", "")
|
||||
assert api_key_used == "ollama"
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user