mirror of
https://github.com/Graphify-Labs/graphify.git
synced 2026-09-14 19:34:09 +08:00
f755aca58f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
8.3 KiB
Python
219 lines
8.3 KiB
Python
# Direct LLM backend for semantic extraction — supports Claude and Kimi K2.6.
|
|
# Used by `graphify . --backend kimi` 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 pathlib import Path
|
|
|
|
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,
|
|
},
|
|
"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
|
|
},
|
|
}
|
|
|
|
_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 _call_openai_compat(
|
|
base_url: str,
|
|
api_key: str,
|
|
model: str,
|
|
user_message: str,
|
|
temperature: float | None = 0,
|
|
) -> dict:
|
|
"""Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON."""
|
|
try:
|
|
from openai import OpenAI
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"Kimi/OpenAI-compatible extraction requires the openai package. "
|
|
"Run: pip install openai"
|
|
) 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": 8192,
|
|
}
|
|
if temperature is not None:
|
|
kwargs["temperature"] = temperature
|
|
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
|
|
return result
|
|
|
|
|
|
def _call_claude(api_key: str, model: str, user_message: str) -> 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=8192,
|
|
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
|
|
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 os.environ.get(cfg["env_key"], "")
|
|
if not key:
|
|
raise ValueError(
|
|
f"No API key for backend '{backend}'. "
|
|
f"Set {cfg['env_key']} or pass api_key=."
|
|
)
|
|
mdl = model or cfg["default_model"]
|
|
user_msg = _read_files(files, root)
|
|
|
|
if backend == "claude":
|
|
return _call_claude(key, mdl, user_msg)
|
|
else:
|
|
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0))
|
|
|
|
|
|
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,
|
|
) -> dict:
|
|
"""Extract a corpus in chunks, merging results.
|
|
|
|
on_chunk_done(idx, total, chunk_result) is called after each chunk if provided.
|
|
Returns merged dict with nodes, edges, hyperedges, input_tokens, output_tokens.
|
|
"""
|
|
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}
|
|
|
|
for idx, chunk in enumerate(chunks):
|
|
t0 = time.time()
|
|
result = extract_files_direct(chunk, backend=backend, api_key=api_key, model=model, root=root)
|
|
result["elapsed_seconds"] = round(time.time() - t0, 2)
|
|
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)
|
|
if callable(on_chunk_done):
|
|
on_chunk_done(idx, len(chunks), result)
|
|
|
|
return merged
|
|
|
|
|
|
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.
|
|
|
|
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.
|
|
"""
|
|
if os.environ.get("MOONSHOT_API_KEY"):
|
|
return "kimi"
|
|
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
return "claude"
|
|
return None
|