Community labeling was an agent-only step (skill.md Step 5): inside Claude Code
or Gemini CLI the agent names communities itself. Run as a bare CLI
(python -m graphify extract . --backend X), no agent does Step 5, so labels
stayed Community 0/1/2 for every backend.
Add graphify/labeling.py: one batched _call_llm asking the backend for a
{cid: name} map from each community's top node labels (god nodes first),
with per-community placeholder fallback and graceful degradation (no backend,
API error, or malformed reply -> Community N, never crashes).
Wire it in:
- cluster-only auto-labels when no .graphify_labels.json exists (the reported
repro path); --no-label opts out, --backend=<name> overrides auto-detect
- new `graphify label <path>` subcommand force-regenerates names
- extract prints a hint pointing standalone users at cluster-only
Tests mock the backend (no network): happy path, partial/fenced/malformed
replies, no-backend and error fallback, god_nodes dict shape.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
121 lines
4.5 KiB
Python
121 lines
4.5 KiB
Python
"""Tests for LLM-backed community labeling (issue #1097).
|
|
|
|
Backend calls are mocked - no network. Covers the happy path, partial replies,
|
|
malformed replies, and the no-backend fallback.
|
|
"""
|
|
import networkx as nx
|
|
import pytest
|
|
|
|
from graphify import labeling
|
|
from graphify.labeling import label_communities, generate_community_labels
|
|
|
|
|
|
def _graph():
|
|
G = nx.Graph()
|
|
# community 0 = ordering, community 1 = payments
|
|
G.add_node("order_place", label="place_order")
|
|
G.add_node("order_repo", label="OrderRepository")
|
|
G.add_node("pay_charge", label="charge_card")
|
|
G.add_node("pay_stripe", label="StripeClient")
|
|
communities = {0: ["order_place", "order_repo"], 1: ["pay_charge", "pay_stripe"]}
|
|
return G, communities
|
|
|
|
|
|
def test_label_communities_happy_path(monkeypatch):
|
|
G, communities = _graph()
|
|
|
|
captured = {}
|
|
|
|
def fake_call(prompt, *, backend, max_tokens=200):
|
|
captured["prompt"] = prompt
|
|
captured["backend"] = backend
|
|
return '{"0": "Order Management", "1": "Payment Flow"}'
|
|
|
|
monkeypatch.setattr("graphify.llm._call_llm", fake_call)
|
|
labels = label_communities(G, communities, backend="gemini")
|
|
|
|
assert labels == {0: "Order Management", 1: "Payment Flow"}
|
|
# the prompt must carry the real node labels so the model can name them
|
|
assert "place_order" in captured["prompt"]
|
|
assert "StripeClient" in captured["prompt"]
|
|
assert captured["backend"] == "gemini"
|
|
|
|
|
|
def test_label_communities_partial_reply_fills_placeholder(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: '{"0": "Order Management"}')
|
|
labels = label_communities(G, communities, backend="gemini")
|
|
assert labels[0] == "Order Management"
|
|
assert labels[1] == "Community 1" # missing cid falls back
|
|
|
|
|
|
def test_label_communities_strips_code_fences(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr(
|
|
"graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: '```json\n{"0":"Orders","1":"Pay"}\n```',
|
|
)
|
|
labels = label_communities(G, communities, backend="gemini")
|
|
assert labels == {0: "Orders", 1: "Pay"}
|
|
|
|
|
|
def test_label_communities_malformed_raises(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: "sorry, I cannot help")
|
|
with pytest.raises(Exception):
|
|
label_communities(G, communities, backend="gemini")
|
|
|
|
|
|
def test_generate_community_labels_degrades_on_error(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: "not json")
|
|
labels, source = generate_community_labels(G, communities, backend="gemini", quiet=True)
|
|
assert source == "placeholder"
|
|
assert labels == {0: "Community 0", 1: "Community 1"}
|
|
|
|
|
|
def test_generate_community_labels_no_backend(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm.detect_backend", lambda: None)
|
|
labels, source = generate_community_labels(G, communities, backend=None, quiet=True)
|
|
assert source == "placeholder"
|
|
assert labels == {0: "Community 0", 1: "Community 1"}
|
|
|
|
|
|
def test_generate_community_labels_success(monkeypatch):
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: '{"0":"Orders","1":"Payments"}')
|
|
labels, source = generate_community_labels(G, communities, backend="gemini", quiet=True)
|
|
assert source == "llm"
|
|
assert labels == {0: "Orders", 1: "Payments"}
|
|
|
|
|
|
def test_gods_as_dicts_do_not_crash(monkeypatch):
|
|
"""god_nodes() returns list[dict] with an 'id' key, not bare ids."""
|
|
G, communities = _graph()
|
|
monkeypatch.setattr("graphify.llm._call_llm",
|
|
lambda p, *, backend, max_tokens=200: '{"0":"Orders","1":"Pay"}')
|
|
gods = [{"id": "order_repo", "label": "OrderRepository"}]
|
|
labels = label_communities(G, communities, backend="gemini", gods=gods)
|
|
assert labels == {0: "Orders", 1: "Pay"}
|
|
|
|
|
|
def test_empty_communities_returns_placeholders(monkeypatch):
|
|
G = nx.Graph()
|
|
called = False
|
|
|
|
def fake_call(p, *, backend, max_tokens=200):
|
|
nonlocal called
|
|
called = True
|
|
return "{}"
|
|
|
|
monkeypatch.setattr("graphify.llm._call_llm", fake_call)
|
|
# community with no resolvable nodes -> no prompt line -> no backend call
|
|
labels = label_communities(G, {0: []}, backend="gemini")
|
|
assert labels == {0: "Community 0"}
|
|
assert called is False
|