Files
2026-05-07 10:53:53 +01:00

638 lines
25 KiB
Python

# Direct LLM backend for semantic extraction — supports Claude, Kimi K2.6,
# Gemini, and OpenAI.
# Used by `graphify extract . --backend gemini` and the benchmark scripts.
# The default graphify pipeline uses Claude Code subagents via skill.md;
# this module provides a direct API path for non-Claude-Code environments.
from __future__ import annotations
import json
import os
import sys
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
# `_read_files` truncates each file at this many characters before joining into
# the user message. Token estimates use the same cap so packing matches reality.
_FILE_CHAR_CAP = 20_000
# `_read_files` also wraps each file in a `=== {rel} ===\n...\n\n` separator;
# this is roughly the per-file overhead in characters that the prompt adds.
_PER_FILE_OVERHEAD_CHARS = 80
# Coarse fallback used only when `tiktoken` is not installed. 1 token ≈ 4 chars
# is the standard heuristic for English/code on BPE tokenizers.
_CHARS_PER_TOKEN = 4
def _get_tokenizer():
"""Return a tiktoken encoder for accurate token counts, or None if tiktoken
is not installed. We use `cl100k_base` (GPT-4 / GPT-3.5-turbo) as a proxy:
Kimi-K2 ships a tiktoken-based tokenizer with very similar BPE behaviour,
and Claude's tokenizer has a comparable token-to-char ratio for prose/code.
Estimates only need to be within ~5%, not exact.
"""
try:
import tiktoken
except ImportError:
return None
try:
return tiktoken.get_encoding("cl100k_base")
except Exception: # network failure on first-use download, etc.
return None
# Cached at import time. None if tiktoken is unavailable; consumers must handle.
_TOKENIZER = _get_tokenizer()
BACKENDS: dict[str, dict] = {
"claude": {
"base_url": "https://api.anthropic.com",
"default_model": "claude-sonnet-4-6",
"env_key": "ANTHROPIC_API_KEY",
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
"max_tokens": 16384,
},
"kimi": {
"base_url": "https://api.moonshot.ai/v1",
"default_model": "kimi-k2.6",
"env_key": "MOONSHOT_API_KEY",
"pricing": {"input": 0.74, "output": 4.66}, # USD per 1M tokens
"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,
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
"default_model": "gemini-3-flash-preview",
"env_keys": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
"model_env_key": "GRAPHIFY_GEMINI_MODEL",
"pricing": {"input": 0.50, "output": 3.00}, # USD per 1M tokens
"temperature": 0,
"reasoning_effort": "low",
"max_completion_tokens": 16384,
},
"openai": {
"base_url": "https://api.openai.com/v1",
"default_model": "gpt-4.1-mini",
"env_key": "OPENAI_API_KEY",
"model_env_key": "GRAPHIFY_OPENAI_MODEL",
"pricing": {"input": 0.40, "output": 1.60}, # USD per 1M tokens
"temperature": 0,
},
"bedrock": {
"default_model": "anthropic.claude-3-5-sonnet-20241022-v2:0",
"model_env_key": "GRAPHIFY_BEDROCK_MODEL",
"pricing": {"input": 3.0, "output": 15.0}, # USD per 1M tokens
"temperature": 0,
"max_tokens": 16384,
},
}
def _resolve_max_tokens(default: int) -> int:
"""Honour GRAPHIFY_MAX_OUTPUT_TOKENS env var override, else use backend default."""
raw = os.environ.get("GRAPHIFY_MAX_OUTPUT_TOKENS", "").strip()
if raw:
try:
v = int(raw)
if v > 0:
return v
except ValueError:
pass
return default
_EXTRACTION_SYSTEM = """\
You are a graphify semantic extraction agent. Extract a knowledge graph fragment from the files provided.
Output ONLY valid JSON — no explanation, no markdown fences, no preamble.
Rules:
- EXTRACTED: relationship explicit in source (import, call, citation, reference)
- INFERRED: reasonable inference (shared data structure, implied dependency)
- AMBIGUOUS: uncertain — flag for review, do not omit
Node ID format: lowercase, only [a-z0-9_], no dots or slashes.
Format: {stem}_{entity} where stem = filename without extension, entity = symbol name (both normalised).
Output exactly this schema:
{"nodes":[{"id":"stem_entity","label":"Human Readable Name","file_type":"code|document|paper|image|concept","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[],"input_tokens":0,"output_tokens":0}
"""
def _read_files(paths: list[Path], root: Path) -> str:
"""Return file contents formatted for the extraction prompt."""
parts: list[str] = []
for p in paths:
try:
rel = p.relative_to(root)
except ValueError:
rel = p
try:
content = p.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
parts.append(f"=== {rel} ===\n{content[:20000]}")
return "\n\n".join(parts)
def _parse_llm_json(raw: str) -> dict:
"""Strip optional markdown fences and parse JSON. Returns empty fragment on failure."""
if raw.startswith("```"):
raw = raw.split("```", 2)[1]
if raw.startswith("json"):
raw = raw[4:]
raw = raw.rsplit("```", 1)[0]
try:
return json.loads(raw.strip())
except json.JSONDecodeError as exc:
print(f"[graphify] LLM returned invalid JSON, skipping chunk: {exc}", file=sys.stderr)
return {"nodes": [], "edges": [], "hyperedges": []}
def _backend_env_keys(backend: str) -> list[str]:
"""Return accepted API-key environment variables for a backend."""
cfg = BACKENDS[backend]
keys = cfg.get("env_keys")
if keys:
return list(keys)
env_key = cfg.get("env_key")
if env_key:
return [env_key]
return []
def _get_backend_api_key(backend: str) -> str:
"""Return the first configured API key for backend, or an empty string."""
for env_key in _backend_env_keys(backend):
value = os.environ.get(env_key)
if value:
return value
return ""
def _format_backend_env_keys(backend: str) -> str:
"""Return user-facing accepted API-key variable names."""
keys = _backend_env_keys(backend)
return " or ".join(keys) if keys else "AWS_PROFILE or AWS_REGION"
def _default_model_for_backend(backend: str) -> str:
"""Return configured model override or backend default model."""
cfg = BACKENDS[backend]
model_env_key = cfg.get("model_env_key")
if model_env_key:
model = os.environ.get(model_env_key)
if model:
return model
return cfg["default_model"]
def _call_openai_compat(
base_url: str,
api_key: str,
model: str,
user_message: str,
temperature: float | None = 0,
reasoning_effort: str | None = None,
max_completion_tokens: int = 8192,
*,
backend: str = "",
) -> dict:
"""Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON."""
try:
from openai import OpenAI
except ImportError as exc:
pkg_hint = "graphifyy[kimi]" if backend == "kimi" else "openai"
raise ImportError(
"Gemini/Kimi/Ollama/OpenAI-compatible extraction requires the openai package. "
f"Run: pip install {pkg_hint}"
) from exc
client = OpenAI(api_key=api_key, base_url=base_url)
kwargs: dict = {
"model": model,
"messages": [
{"role": "system", "content": _EXTRACTION_SYSTEM},
{"role": "user", "content": user_message},
],
"max_completion_tokens": max_completion_tokens,
}
if temperature is not None:
kwargs["temperature"] = temperature
if reasoning_effort is not None:
kwargs["reasoning_effort"] = reasoning_effort
# Kimi-k2.6 is a reasoning model — disable thinking so content isn't empty
if "moonshot" in base_url:
kwargs["extra_body"] = {"thinking": {"type": "disabled"}}
resp = client.chat.completions.create(**kwargs)
result = _parse_llm_json(resp.choices[0].message.content or "{}")
result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0
result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0
result["model"] = model
# `finish_reason == "length"` means the model hit max_completion_tokens
# 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
def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = 8192) -> dict:
"""Call Anthropic Claude directly (not via OpenAI compat layer)."""
try:
import anthropic
except ImportError as exc:
raise ImportError(
"Claude direct extraction requires the anthropic package. "
"Run: pip install anthropic"
) from exc
client = anthropic.Anthropic(api_key=api_key)
resp = client.messages.create(
model=model,
max_tokens=max_tokens,
system=_EXTRACTION_SYSTEM,
messages=[{"role": "user", "content": user_message}],
)
result = _parse_llm_json(resp.content[0].text if resp.content else "{}")
result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0
result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0
result["model"] = model
# Normalise Anthropic's `stop_reason` to the OpenAI-compat `finish_reason`
# vocabulary so the adaptive-retry layer doesn't have to know which
# backend produced the result.
result["finish_reason"] = "length" if resp.stop_reason == "max_tokens" else "stop"
return result
def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192) -> dict:
"""Call AWS Bedrock via boto3 Converse API using the standard AWS credential chain."""
try:
import boto3
import botocore.exceptions
except ImportError as exc:
raise ImportError(
"AWS Bedrock extraction requires boto3. Run: pip install graphifyy[bedrock]"
) from exc
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1"
profile = os.environ.get("AWS_PROFILE")
session = boto3.Session(profile_name=profile, region_name=region)
client = session.client("bedrock-runtime")
try:
resp = client.converse(
modelId=model,
system=[{"text": _EXTRACTION_SYSTEM}],
messages=[{"role": "user", "content": [{"text": user_message}]}],
inferenceConfig={"maxTokens": max_tokens, "temperature": 0},
)
except botocore.exceptions.ClientError as exc:
code = exc.response["Error"]["Code"]
msg = exc.response["Error"]["Message"]
raise RuntimeError(f"Bedrock API error ({code}): {msg}") from exc
text = resp.get("output", {}).get("message", {}).get("content", [{}])[0].get("text", "{}")
result = _parse_llm_json(text)
usage = resp.get("usage", {})
result["input_tokens"] = usage.get("inputTokens", 0)
result["output_tokens"] = usage.get("outputTokens", 0)
result["model"] = model
result["finish_reason"] = "length" if resp.get("stopReason") == "max_tokens" else "stop"
return result
def extract_files_direct(
files: list[Path],
backend: str = "kimi",
api_key: str | None = None,
model: str | None = None,
root: Path = Path("."),
) -> dict:
"""Extract semantic nodes/edges from a list of files using the given backend.
Returns dict with nodes, edges, hyperedges, input_tokens, output_tokens.
Raises ValueError for unknown backends. Raises ImportError if SDK missing.
"""
if backend not in BACKENDS:
raise ValueError(f"Unknown backend {backend!r}. Available: {sorted(BACKENDS)}")
cfg = BACKENDS[backend]
key = api_key or _get_backend_api_key(backend)
if not key and backend == "ollama":
key = "ollama" # Ollama ignores auth but openai client requires non-empty
if not key and backend != "bedrock":
raise ValueError(
f"No API key for backend '{backend}'. "
f"Set {_format_backend_env_keys(backend)} or pass api_key=."
)
mdl = model or _default_model_for_backend(backend)
user_msg = _read_files(files, root)
max_out = _resolve_max_tokens(cfg.get("max_tokens", 8192))
if backend == "claude":
return _call_claude(key, mdl, user_msg, max_tokens=max_out)
if backend == "bedrock":
return _call_bedrock(mdl, user_msg, max_tokens=max_out)
return _call_openai_compat(
cfg["base_url"],
key,
mdl,
user_msg,
temperature=cfg.get("temperature", 0),
reasoning_effort=cfg.get("reasoning_effort"),
max_completion_tokens=cfg.get("max_completion_tokens", max_out),
backend=backend,
)
def _estimate_file_tokens(path: Path) -> int:
"""Estimate the prompt-token cost of a single file under `_read_files` rules.
Uses tiktoken (`cl100k_base`) when available for accurate counts. Falls back
to the chars/4 heuristic if tiktoken is not installed. Both paths cap at
`_FILE_CHAR_CAP` to match `_read_files`'s truncation, plus a constant for
the `=== rel ===` separator. Returns 0 for unreadable paths so they don't
blow up packing.
"""
if _TOKENIZER is None:
try:
size = path.stat().st_size
except OSError:
return 0
chars = min(size, _FILE_CHAR_CAP) + _PER_FILE_OVERHEAD_CHARS
return chars // _CHARS_PER_TOKEN
try:
content = path.read_text(encoding="utf-8", errors="replace")[:_FILE_CHAR_CAP]
except OSError:
return 0
return len(_TOKENIZER.encode(content)) + (_PER_FILE_OVERHEAD_CHARS // _CHARS_PER_TOKEN)
def _pack_chunks_by_tokens(
files: list[Path],
token_budget: int,
) -> list[list[Path]]:
"""Greedily pack files into chunks that fit a token budget.
Files are first grouped by parent directory so related artifacts share a
chunk (cross-file edges are more likely to be extracted within a chunk
than across chunks). Within each directory, files are added one at a
time; a chunk is closed when adding the next file would exceed the
budget. A single file larger than the budget gets its own chunk and the
caller is expected to handle the API error if it actually overflows the
model's context window — packing can't shrink one big file.
"""
if token_budget <= 0:
raise ValueError(f"token_budget must be positive, got {token_budget}")
by_dir: dict[Path, list[Path]] = {}
for f in files:
by_dir.setdefault(f.parent, []).append(f)
chunks: list[list[Path]] = []
current: list[Path] = []
current_tokens = 0
for directory in sorted(by_dir):
for path in by_dir[directory]:
cost = _estimate_file_tokens(path)
if current and current_tokens + cost > token_budget:
chunks.append(current)
current = []
current_tokens = 0
current.append(path)
current_tokens += cost
if current:
chunks.append(current)
return chunks
def _extract_with_adaptive_retry(
chunk: list[Path],
backend: str,
api_key: str | None,
model: str | None,
root: Path,
max_depth: int,
_depth: int = 0,
) -> dict:
"""Extract a chunk; if the response is truncated (`finish_reason="length"`),
split the chunk in half and recurse.
The signal driving the retry is the API's own `finish_reason` — `"length"`
means the model hit `max_completion_tokens` mid-output. The truncated JSON
has nothing useful in it (parse fails partway through a string or array),
so we discard it and re-extract on smaller inputs that produce shorter
outputs.
Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N
files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If
still truncated at the cap, we surface the (likely empty) result with a
warning rather than infinite-loop.
A single-file chunk that truncates is unrecoverable here — we can't make
one file smaller than itself, so we return what we got and warn.
"""
result = extract_files_direct(
chunk, backend=backend, api_key=api_key, model=model, root=root
)
if result.get("finish_reason") != "length":
return result
if len(chunk) <= 1:
print(
f"[graphify] single-file chunk {chunk[0]} truncated at "
f"max_completion_tokens — partial result kept",
file=sys.stderr,
)
return result
if _depth >= max_depth:
print(
f"[graphify] chunk of {len(chunk)} still truncated at recursion "
f"depth {_depth} (max {max_depth}) — partial result kept",
file=sys.stderr,
)
return result
print(
f"[graphify] chunk of {len(chunk)} truncated at depth {_depth}, "
f"splitting into halves of {len(chunk) // 2} and "
f"{len(chunk) - len(chunk) // 2}",
file=sys.stderr,
)
mid = len(chunk) // 2
left = _extract_with_adaptive_retry(
chunk[:mid], backend, api_key, model, root, max_depth, _depth + 1
)
right = _extract_with_adaptive_retry(
chunk[mid:], backend, api_key, model, root, max_depth, _depth + 1
)
return {
"nodes": left.get("nodes", []) + right.get("nodes", []),
"edges": left.get("edges", []) + right.get("edges", []),
"hyperedges": left.get("hyperedges", []) + right.get("hyperedges", []),
"input_tokens": left.get("input_tokens", 0) + right.get("input_tokens", 0),
"output_tokens": left.get("output_tokens", 0) + right.get("output_tokens", 0),
"model": result.get("model"),
# Both halves either succeeded or have already surfaced their own
# truncation warning; the merged result is no longer truncated as a
# logical unit.
"finish_reason": "stop",
}
def extract_corpus_parallel(
files: list[Path],
backend: str = "kimi",
api_key: str | None = None,
model: str | None = None,
root: Path = Path("."),
chunk_size: int = 20,
on_chunk_done: Callable | None = None,
token_budget: int | None = 60_000,
max_concurrency: int = 4,
max_retry_depth: int = 3,
) -> dict:
"""Extract a corpus in chunks, merging results.
Chunking strategy:
- If `token_budget` is set (default 60_000), files are packed to fit
the budget and grouped by parent directory. This avoids the worst
case where 20 randomly-grouped files exceed a model's context
window in a single request.
- If `token_budget=None`, falls back to the legacy fixed-count
`chunk_size` packing for backwards compatibility.
Concurrency:
- Chunks run in parallel via a thread pool capped at `max_concurrency`
(default 4 — conservative to stay under provider rate limits).
- Set `max_concurrency=1` to force sequential execution.
Adaptive retry on truncation:
- When the LLM returns `finish_reason="length"` (output truncated at
`max_completion_tokens`), the chunk is split in half and each half
re-extracted recursively, up to `max_retry_depth` levels deep
(default 3 → max 8x expansion of one chunk).
- This is signal-driven: chunks too dense to fit in one response
self-heal by splitting until they do, while well-sized chunks pay
no extra cost. Set `max_retry_depth=0` to disable retries.
`on_chunk_done(idx, total, chunk_result)` fires once per chunk as it
completes (in completion order, not submission order). `idx` is the
chunk's submission index so callers can correlate progress. The
callback fires once per top-level chunk; recursive splits are merged
transparently before the callback is invoked.
Returns merged dict with nodes, edges, hyperedges, input_tokens,
output_tokens. Failed chunks are logged to stderr and skipped — one bad
chunk does not abort the run.
"""
if token_budget is not None:
chunks = _pack_chunks_by_tokens(files, token_budget=token_budget)
else:
chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
merged: dict = {"nodes": [], "edges": [], "hyperedges": [], "input_tokens": 0, "output_tokens": 0}
total = len(chunks)
def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | None]:
t0 = time.time()
try:
result = _extract_with_adaptive_retry(
chunk,
backend=backend,
api_key=api_key,
model=model,
root=root,
max_depth=max_retry_depth,
)
result["elapsed_seconds"] = round(time.time() - t0, 2)
return idx, result, None
except Exception as exc: # noqa: BLE001 — caller-facing surface, log + continue
return idx, None, exc
workers = max(1, min(max_concurrency, total))
if workers == 1:
# Avoid thread pool overhead for single-worker runs (and keep
# callback ordering identical to the pre-refactor sequential path).
for idx, chunk in enumerate(chunks):
_, result, exc = _run_one(idx, chunk)
if exc is not None:
print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr)
continue
assert result is not None
_merge_into(merged, result)
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
return merged
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [pool.submit(_run_one, idx, chunk) for idx, chunk in enumerate(chunks)]
for future in as_completed(futures):
idx, result, exc = future.result()
if exc is not None:
print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr)
continue
assert result is not None
_merge_into(merged, result)
if callable(on_chunk_done):
on_chunk_done(idx, total, result)
return merged
def _merge_into(merged: dict, result: dict) -> None:
"""Append a chunk result into the running merged accumulator."""
merged["nodes"].extend(result.get("nodes", []))
merged["edges"].extend(result.get("edges", []))
merged["hyperedges"].extend(result.get("hyperedges", []))
merged["input_tokens"] += result.get("input_tokens", 0)
merged["output_tokens"] += result.get("output_tokens", 0)
def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float:
"""Estimate USD cost for a given token count using published pricing."""
if backend not in BACKENDS:
return 0.0
p = BACKENDS[backend]["pricing"]
return (input_tokens * p["input"] + output_tokens * p["output"]) / 1_000_000
def detect_backend() -> str | None:
"""Return the name of whichever backend has an API key set, or None.
Priority: gemini → kimi → ollama (opt-in via OLLAMA_BASE_URL) → claude → openai.
Ollama is opt-in via env var — never auto-probed without OLLAMA_BASE_URL set.
Claude is the default for the skill.md subagent pipeline and is never forced here.
"""
for backend in ("gemini", "kimi"):
if _get_backend_api_key(backend):
return backend
if os.environ.get("OLLAMA_BASE_URL"):
return "ollama"
if os.environ.get("AWS_PROFILE") or os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION"):
return "bedrock"
for backend in ("claude", "openai"):
if _get_backend_api_key(backend):
return backend
return None