fold community labeling into llm.py instead of a standalone module

No behavior change. label_communities / generate_community_labels live next
to the backend dispatch (_call_llm, detect_backend) they use, so the lazy
import and separate file are no longer needed. Tests unchanged except the
import path; they still monkeypatch graphify.llm._call_llm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-31 23:37:28 +01:00
co-authored by Claude Opus 4.8
parent 3922235eba
commit 1dc5048b71
4 changed files with 142 additions and 159 deletions
+1 -1
View File
@@ -2426,7 +2426,7 @@ def main() -> None:
# standalone there is no orchestrating agent to do skill.md Step 5, so
# auto-name communities with the configured backend rather than leave
# "Community N" (#1097). Degrades to placeholders if no backend/on error.
from graphify.labeling import generate_community_labels
from graphify.llm import generate_community_labels
print("Labeling communities...")
labels, _label_src = generate_community_labels(
G, communities, backend=label_backend, gods=gods
-156
View File
@@ -1,156 +0,0 @@
"""LLM-backed community naming for standalone CLI runs (issue #1097).
When graphify runs inside an orchestrating agent (Claude Code / Gemini CLI), the
agent names communities itself per skill.md Step 5 - it reads the analysis file
and writes 2-5 word names with its own reasoning, no API call. When graphify is
run as a bare CLI (``python -m graphify extract . --backend X``), there is no
agent to do that step, so community labels stay ``Community 0/1/2...``.
This module fills that gap: given the graph and its communities, it asks the
configured backend to name them in ONE batched call, then returns a complete
``{cid: name}`` map (placeholders for anything the backend didn't name).
"""
from __future__ import annotations
import json
import re
import sys
import networkx as nx
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
_MAX_COMMUNITIES = 200 # cap LLM-named communities; tail stays placeholder
_TOP_K = 12 # node labels sampled per community for the prompt
_LABEL_MAXLEN = 60 # truncate individual labels to keep the prompt small
def _placeholder_labels(communities) -> dict[int, str]:
return {int(cid): f"Community {cid}" for cid in communities}
def _community_label_lines(
G: nx.Graph, communities: dict, gods, max_communities: int, top_k: int
) -> tuple[list[str], list[int]]:
"""Build one prompt line per community (largest first), sampling up to
``top_k`` representative node labels (god nodes first). Returns the lines and
the list of cids that actually got a line (skips empty communities)."""
# gods may be node-id strings or god_nodes() dicts ({"id": ..., "label": ...}).
god_set = {g["id"] if isinstance(g, dict) else g for g in (gods or [])}
ordered = sorted(communities.items(), key=lambda kv: -len(kv[1]))
lines: list[str] = []
labeled_cids: list[int] = []
for cid, members in ordered[:max_communities]:
ranked = [m for m in members if m in god_set] + [m for m in members if m not in god_set]
names: list[str] = []
seen: set[str] = set()
for nid in ranked:
label = str(G.nodes[nid].get("label", nid)) if nid in G.nodes else str(nid)
label = label.strip().strip("()")[:_LABEL_MAXLEN]
if label and label.lower() not in seen:
seen.add(label.lower())
names.append(label)
if len(names) >= top_k:
break
if names:
lines.append(f"Community {cid}: {', '.join(names)}")
labeled_cids.append(int(cid))
return lines, labeled_cids
def _parse_label_response(text: str, labeled_cids: list[int]) -> dict[int, str]:
"""Parse the backend's JSON ``{cid: name}`` reply. Raises on non-JSON or a
non-object payload; silently ignores cids it didn't name (caller fills those
with placeholders)."""
cleaned = _FENCE_RE.sub("", text.strip())
# If the model wrapped the object in prose, grab the outermost {...}.
if not cleaned.startswith("{"):
start, end = cleaned.find("{"), cleaned.rfind("}")
if start != -1 and end > start:
cleaned = cleaned[start:end + 1]
data = json.loads(cleaned)
if not isinstance(data, dict):
raise ValueError("label response is not a JSON object")
out: dict[int, str] = {}
for cid in labeled_cids:
name = data.get(str(cid))
if name is None:
name = data.get(cid)
if isinstance(name, str) and name.strip():
out[cid] = name.strip()
return out
def label_communities(
G: nx.Graph,
communities: dict,
*,
backend: str,
gods=None,
max_communities: int = _MAX_COMMUNITIES,
top_k: int = _TOP_K,
) -> dict[int, str]:
"""Return a complete ``{cid: name}`` map using ``backend`` for naming.
Placeholders (``Community N``) are used for any community the backend did not
name (empty community, beyond ``max_communities``, or absent from the reply).
Raises on backend/parse failure - callers that want graceful degradation
should use :func:`generate_community_labels`.
"""
labels = _placeholder_labels(communities)
lines, labeled_cids = _community_label_lines(G, communities, gods, max_communities, top_k)
if not lines:
return labels
prompt = (
"You are naming clusters in a knowledge graph. For each community below, "
"return a concise 2-5 word plain-language name describing what it is about "
"(e.g. \"Order Management\", \"Payment Flow\", \"Auth Middleware\"). "
"Respond ONLY with a JSON object mapping the community id (as a string) to "
"its name - no prose, no markdown fences.\n\n" + "\n".join(lines)
)
from graphify.llm import _call_llm
max_tokens = min(40 + 16 * len(labeled_cids), 4096)
text = _call_llm(prompt, backend=backend, max_tokens=max_tokens)
labels.update(_parse_label_response(text, labeled_cids))
return labels
def generate_community_labels(
G: nx.Graph,
communities: dict,
*,
backend: str | None = None,
gods=None,
quiet: bool = False,
) -> tuple[dict[int, str], str]:
"""CLI entry point: resolve a backend, name communities, and degrade to
``Community N`` placeholders on any failure (no backend, API error, malformed
reply). Returns ``(labels, source)`` where source is ``"llm"`` or
``"placeholder"``. Never raises."""
if backend is None:
try:
from graphify.llm import detect_backend
backend = detect_backend()
except Exception:
backend = None
if not backend:
if not quiet:
print(
"[graphify label] no LLM backend configured; keeping Community N "
"placeholders. Set an API key (e.g. GOOGLE_API_KEY) or pass --backend.",
file=sys.stderr,
)
return _placeholder_labels(communities), "placeholder"
try:
labels = label_communities(G, communities, backend=backend, gods=gods)
return labels, "llm"
except Exception as exc:
if not quiet:
print(
f"[graphify label] warning: community labeling failed ({exc}); "
"using Community N placeholders.",
file=sys.stderr,
)
return _placeholder_labels(communities), "placeholder"
+140
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import json
import os
import re
import sys
import time
from collections.abc import Callable
@@ -1252,3 +1253,142 @@ def detect_backend() -> str | None:
if _get_backend_api_key(name):
return name
return None
# ── Community labeling ────────────────────────────────────────────────────────
# When graphify runs inside an orchestrating agent (Claude Code / Gemini CLI),
# the agent names communities itself per skill.md Step 5 - it reads the analysis
# file and writes 2-5 word names with its own reasoning, no API call. When
# graphify is run as a bare CLI (``graphify extract . --backend X``), there is no
# agent to do that step, so community labels stay ``Community 0/1/2...``. These
# helpers fill that gap: ask the configured backend to name communities in ONE
# batched call and return a complete ``{cid: name}`` map (#1097).
_LABEL_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
_LABEL_MAX_COMMUNITIES = 200 # cap LLM-named communities; tail stays placeholder
_LABEL_TOP_K = 12 # node labels sampled per community for the prompt
_LABEL_MAXLEN = 60 # truncate individual labels to keep the prompt small
def _placeholder_community_labels(communities) -> dict[int, str]:
return {int(cid): f"Community {cid}" for cid in communities}
def _community_label_lines(G, communities, gods, max_communities, top_k):
"""One prompt line per community (largest first), sampling up to ``top_k``
representative node labels (god nodes first). Returns (lines, labeled_cids);
skips communities with no resolvable nodes."""
# gods may be node-id strings or god_nodes() dicts ({"id": ..., "label": ...}).
god_set = {g["id"] if isinstance(g, dict) else g for g in (gods or [])}
ordered = sorted(communities.items(), key=lambda kv: -len(kv[1]))
lines: list[str] = []
labeled_cids: list[int] = []
for cid, members in ordered[:max_communities]:
ranked = [m for m in members if m in god_set] + [m for m in members if m not in god_set]
names: list[str] = []
seen: set[str] = set()
for nid in ranked:
label = str(G.nodes[nid].get("label", nid)) if nid in G.nodes else str(nid)
label = label.strip().strip("()")[:_LABEL_MAXLEN]
if label and label.lower() not in seen:
seen.add(label.lower())
names.append(label)
if len(names) >= top_k:
break
if names:
lines.append(f"Community {cid}: {', '.join(names)}")
labeled_cids.append(int(cid))
return lines, labeled_cids
def _parse_label_response(text: str, labeled_cids: list[int]) -> dict[int, str]:
"""Parse the backend's JSON ``{cid: name}`` reply. Raises on non-JSON or a
non-object payload; silently ignores cids it didn't name."""
cleaned = _LABEL_FENCE_RE.sub("", text.strip())
if not cleaned.startswith("{"):
start, end = cleaned.find("{"), cleaned.rfind("}")
if start != -1 and end > start:
cleaned = cleaned[start:end + 1]
data = json.loads(cleaned)
if not isinstance(data, dict):
raise ValueError("label response is not a JSON object")
out: dict[int, str] = {}
for cid in labeled_cids:
name = data.get(str(cid))
if name is None:
name = data.get(cid)
if isinstance(name, str) and name.strip():
out[cid] = name.strip()
return out
def label_communities(
G,
communities,
*,
backend: str,
gods=None,
max_communities: int = _LABEL_MAX_COMMUNITIES,
top_k: int = _LABEL_TOP_K,
) -> dict[int, str]:
"""Return a complete ``{cid: name}`` map using ``backend`` for naming.
Placeholders (``Community N``) are used for any community the backend did not
name. Raises on backend/parse failure - callers that want graceful
degradation should use :func:`generate_community_labels`.
"""
labels = _placeholder_community_labels(communities)
lines, labeled_cids = _community_label_lines(G, communities, gods, max_communities, top_k)
if not lines:
return labels
prompt = (
"You are naming clusters in a knowledge graph. For each community below, "
"return a concise 2-5 word plain-language name describing what it is about "
"(e.g. \"Order Management\", \"Payment Flow\", \"Auth Middleware\"). "
"Respond ONLY with a JSON object mapping the community id (as a string) to "
"its name - no prose, no markdown fences.\n\n" + "\n".join(lines)
)
max_tokens = min(40 + 16 * len(labeled_cids), 4096)
text = _call_llm(prompt, backend=backend, max_tokens=max_tokens)
labels.update(_parse_label_response(text, labeled_cids))
return labels
def generate_community_labels(
G,
communities,
*,
backend: str | None = None,
gods=None,
quiet: bool = False,
) -> tuple[dict[int, str], str]:
"""CLI entry point: resolve a backend, name communities, and degrade to
``Community N`` placeholders on any failure (no backend, API error, malformed
reply). Returns ``(labels, source)`` where source is ``"llm"`` or
``"placeholder"``. Never raises."""
if backend is None:
try:
backend = detect_backend()
except Exception:
backend = None
if not backend:
if not quiet:
print(
"[graphify label] no LLM backend configured; keeping Community N "
"placeholders. Set an API key (e.g. GOOGLE_API_KEY) or pass --backend.",
file=sys.stderr,
)
return _placeholder_community_labels(communities), "placeholder"
try:
labels = label_communities(G, communities, backend=backend, gods=gods)
return labels, "llm"
except Exception as exc:
if not quiet:
print(
f"[graphify label] warning: community labeling failed ({exc}); "
"using Community N placeholders.",
file=sys.stderr,
)
return _placeholder_community_labels(communities), "placeholder"
+1 -2
View File
@@ -6,8 +6,7 @@ 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
from graphify.llm import label_communities, generate_community_labels
def _graph():