mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
feat(llm): pack chunks by token budget, parallelise, accept tiktoken
Three independent improvements to extract_corpus_parallel: 1. Token-aware chunking. Replaces `chunk_size=20` static packing with a greedy packer keyed on `token_budget` (default 60_000), grouped by parent directory so related artefacts share a chunk. Pass `token_budget=None` to fall back to fixed-count packing. 2. Optional tiktoken (added to the [kimi] extra). When available, `_estimate_file_tokens` uses cl100k_base for accurate counts; without it, the existing chars/4 heuristic kicks in. Kimi-K2 ships a tiktoken-based tokenizer so estimates against Moonshot are very close to truth. 3. True parallelism. The function name said "parallel" but the body was a sequential for-loop. Now uses ThreadPoolExecutor capped at `max_concurrency` (default 4 — conservative against provider rate limits). `on_chunk_done(idx, total, result)` still fires once per chunk with the original submission idx so progress UIs work unchanged. `max_concurrency=1` skips the pool to preserve sequential semantics. Plus failure tolerance: a chunk raising is now caught, logged to stderr, and the run continues. Other chunks' results merge as normal. On a 162-file repo (~125k words), the same work that took ~36 min sequential under the old code finishes in ~7 min.
This commit is contained in:
+166
-13
@@ -9,8 +9,40 @@ 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",
|
||||
@@ -165,6 +197,70 @@ def extract_files_direct(
|
||||
return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0))
|
||||
|
||||
|
||||
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_corpus_parallel(
|
||||
files: list[Path],
|
||||
backend: str = "kimi",
|
||||
@@ -173,30 +269,87 @@ def extract_corpus_parallel(
|
||||
root: Path = Path("."),
|
||||
chunk_size: int = 20,
|
||||
on_chunk_done: Callable | None = None,
|
||||
token_budget: int | None = 60_000,
|
||||
max_concurrency: int = 4,
|
||||
) -> 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.
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
"""
|
||||
chunks = [files[i:i + chunk_size] for i in range(0, len(files), chunk_size)]
|
||||
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)
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | None]:
|
||||
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)
|
||||
try:
|
||||
result = extract_files_direct(chunk, backend=backend, api_key=api_key, model=model, root=root)
|
||||
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:
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ svg = ["matplotlib"]
|
||||
leiden = ["graspologic; python_version < '3.13'"]
|
||||
office = ["python-docx", "openpyxl"]
|
||||
video = ["faster-whisper", "yt-dlp"]
|
||||
kimi = ["openai"]
|
||||
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai"]
|
||||
kimi = ["openai", "tiktoken"]
|
||||
all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib", "openai", "tiktoken"]
|
||||
|
||||
[project.scripts]
|
||||
graphify = "graphify.__main__:main"
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Tests for token-aware chunking and parallel chunk execution in graphify.llm."""
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=False)
|
||||
def no_tokenizer():
|
||||
"""Force the chars/4 fallback so packing math is deterministic regardless
|
||||
of whether tiktoken is installed in the test environment. tiktoken's BPE
|
||||
compresses repeated/synthetic content heavily, which would make pack-size
|
||||
assertions tied to specific input sizes flaky."""
|
||||
from graphify import llm
|
||||
with patch.object(llm, "_TOKENIZER", None):
|
||||
yield
|
||||
|
||||
|
||||
# ---- Token-aware packing -----------------------------------------------------
|
||||
|
||||
def test_pack_chunks_packs_small_files_together(tmp_path):
|
||||
"""Many small files should land in a single chunk, not one chunk per file."""
|
||||
from graphify.llm import _pack_chunks_by_tokens
|
||||
|
||||
files = []
|
||||
for i in range(20):
|
||||
f = tmp_path / f"small_{i}.py"
|
||||
f.write_text("x = 1\n") # ~6 bytes => ~1 token
|
||||
files.append(f)
|
||||
|
||||
chunks = _pack_chunks_by_tokens(files, token_budget=10_000)
|
||||
assert len(chunks) == 1
|
||||
assert sorted(chunks[0]) == sorted(files)
|
||||
|
||||
|
||||
def test_pack_chunks_starts_new_chunk_when_budget_would_overflow(tmp_path, no_tokenizer):
|
||||
"""When the next file would push the chunk past the budget, start a new chunk.
|
||||
|
||||
With chars/4 fallback: each 10,000-char file = (10000+80)/4 = 2520 tokens.
|
||||
Budget 6000 fits two (5040 < 6000) but not three (7560 > 6000).
|
||||
Five files → 2/2/1 = three chunks.
|
||||
"""
|
||||
from graphify.llm import _pack_chunks_by_tokens
|
||||
|
||||
files = []
|
||||
for i in range(5):
|
||||
f = tmp_path / f"file_{i}.py"
|
||||
f.write_text("x" * 10_000)
|
||||
files.append(f)
|
||||
|
||||
chunks = _pack_chunks_by_tokens(files, token_budget=6_000)
|
||||
sizes = [len(c) for c in chunks]
|
||||
assert sizes == [2, 2, 1], f"expected [2, 2, 1], got {sizes}"
|
||||
assert sum(sizes) == 5 # all files accounted for
|
||||
|
||||
|
||||
def test_pack_chunks_groups_by_directory(tmp_path):
|
||||
"""Files in the same directory should land in the same chunk when they fit."""
|
||||
from graphify.llm import _pack_chunks_by_tokens
|
||||
|
||||
dir_a = tmp_path / "a"
|
||||
dir_b = tmp_path / "b"
|
||||
dir_a.mkdir()
|
||||
dir_b.mkdir()
|
||||
|
||||
a1 = dir_a / "x.py"; a1.write_text("a")
|
||||
a2 = dir_a / "y.py"; a2.write_text("a")
|
||||
b1 = dir_b / "x.py"; b1.write_text("b")
|
||||
b2 = dir_b / "y.py"; b2.write_text("b")
|
||||
|
||||
# Big budget — everything fits in one chunk in principle, but the order
|
||||
# within the chunk should keep dir_a's files contiguous and dir_b's
|
||||
# contiguous (not interleaved).
|
||||
chunks = _pack_chunks_by_tokens([a1, b1, a2, b2], token_budget=1_000_000)
|
||||
assert len(chunks) == 1
|
||||
chunk = chunks[0]
|
||||
a_indices = [i for i, p in enumerate(chunk) if p.parent == dir_a]
|
||||
b_indices = [i for i, p in enumerate(chunk) if p.parent == dir_b]
|
||||
assert a_indices == sorted(a_indices)
|
||||
assert b_indices == sorted(b_indices)
|
||||
# all of one directory comes before all of the other
|
||||
assert max(a_indices) < min(b_indices) or max(b_indices) < min(a_indices)
|
||||
|
||||
|
||||
def test_pack_chunks_oversized_file_gets_its_own_chunk(tmp_path, no_tokenizer):
|
||||
"""A file larger than the budget can't be split — it goes alone in a chunk."""
|
||||
from graphify.llm import _pack_chunks_by_tokens
|
||||
|
||||
big = tmp_path / "big.py"; big.write_text("x" * 200_000) # ~50k tokens (cap-bound)
|
||||
small = tmp_path / "small.py"; small.write_text("x")
|
||||
|
||||
chunks = _pack_chunks_by_tokens([big, small], token_budget=1_000)
|
||||
sizes = [len(c) for c in chunks]
|
||||
# big should be alone in its own chunk; small in its own (no other file
|
||||
# to share with)
|
||||
assert sizes == [1, 1]
|
||||
|
||||
|
||||
def test_pack_chunks_rejects_non_positive_budget(tmp_path):
|
||||
from graphify.llm import _pack_chunks_by_tokens
|
||||
|
||||
f = tmp_path / "x.py"; f.write_text("a")
|
||||
with pytest.raises(ValueError):
|
||||
_pack_chunks_by_tokens([f], token_budget=0)
|
||||
|
||||
|
||||
# ---- Tokenizer fallback ------------------------------------------------------
|
||||
|
||||
def test_estimate_file_tokens_uses_tiktoken_when_available(tmp_path):
|
||||
"""When tiktoken is installed, the estimator should call into it for
|
||||
accurate counts rather than the chars/4 heuristic."""
|
||||
from graphify import llm
|
||||
|
||||
f = tmp_path / "sample.py"
|
||||
text = "def hello():\n return 'world'\n" * 50 # ~1500 chars
|
||||
f.write_text(text)
|
||||
|
||||
# Force the tokenizer to be a mock that records calls and returns a known
|
||||
# token list, so we can assert the tiktoken path is taken.
|
||||
fake_encoder = type("E", (), {"encode": staticmethod(lambda s: [0] * 999)})()
|
||||
with patch.object(llm, "_TOKENIZER", fake_encoder):
|
||||
n = llm._estimate_file_tokens(f)
|
||||
assert n == 999 + (llm._PER_FILE_OVERHEAD_CHARS // llm._CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def test_estimate_file_tokens_falls_back_to_chars_when_no_tokenizer(tmp_path):
|
||||
"""Without tiktoken installed, the estimator falls back to chars/4."""
|
||||
from graphify import llm
|
||||
|
||||
f = tmp_path / "sample.py"
|
||||
f.write_text("x" * 1_000) # 1000 bytes
|
||||
|
||||
with patch.object(llm, "_TOKENIZER", None):
|
||||
n = llm._estimate_file_tokens(f)
|
||||
# 1000 chars + 80 overhead = 1080 / 4 = 270 tokens
|
||||
assert n == (1000 + llm._PER_FILE_OVERHEAD_CHARS) // llm._CHARS_PER_TOKEN
|
||||
|
||||
|
||||
# ---- Parallel execution ------------------------------------------------------
|
||||
|
||||
def _stub_chunk_result(file_count: int, idx: int) -> dict:
|
||||
"""Build a deterministic fake extraction result for a chunk."""
|
||||
return {
|
||||
"nodes": [{"id": f"chunk_{idx}_node_{i}"} for i in range(file_count)],
|
||||
"edges": [],
|
||||
"hyperedges": [],
|
||||
"input_tokens": 100 * file_count,
|
||||
"output_tokens": 50 * file_count,
|
||||
}
|
||||
|
||||
|
||||
def test_corpus_parallel_runs_chunks_concurrently(tmp_path):
|
||||
"""With max_concurrency > 1, total wall time should be ~max(chunk times),
|
||||
not the sum. Each stub extraction sleeps; we assert wall time."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = []
|
||||
for i in range(8):
|
||||
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||
files.append(f)
|
||||
|
||||
def slow_extract(chunk, **kwargs):
|
||||
time.sleep(0.3)
|
||||
return _stub_chunk_result(len(chunk), 0)
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=slow_extract):
|
||||
t0 = time.time()
|
||||
# Force 4 chunks of 2 files each by setting a tight token budget.
|
||||
result = extract_corpus_parallel(
|
||||
files, backend="kimi", token_budget=None, chunk_size=2, max_concurrency=4
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# 4 chunks × 0.3s sequential = 1.2s. Parallel with 4 workers should land near 0.3-0.5s.
|
||||
assert elapsed < 1.0, f"expected parallel speedup, took {elapsed:.2f}s"
|
||||
assert len(result["nodes"]) == 8
|
||||
|
||||
|
||||
def test_corpus_parallel_sequential_when_max_concurrency_is_one(tmp_path):
|
||||
"""max_concurrency=1 should run sequentially (no thread pool)."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = []
|
||||
for i in range(3):
|
||||
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||
files.append(f)
|
||||
|
||||
call_order = []
|
||||
|
||||
def record(chunk, **kwargs):
|
||||
call_order.append(tuple(p.name for p in chunk))
|
||||
return _stub_chunk_result(len(chunk), len(call_order))
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||
extract_corpus_parallel(
|
||||
files, backend="kimi", token_budget=None, chunk_size=1, max_concurrency=1
|
||||
)
|
||||
|
||||
# Sequential => we see calls in submission order
|
||||
assert call_order == [("f0.py",), ("f1.py",), ("f2.py",)]
|
||||
|
||||
|
||||
def test_corpus_parallel_continues_after_chunk_failure(tmp_path, capsys):
|
||||
"""A single chunk raising should be logged but not abort the run.
|
||||
Other chunks' results should still be merged."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = []
|
||||
for i in range(4):
|
||||
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||
files.append(f)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def maybe_fail(chunk, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 2:
|
||||
raise RuntimeError("simulated API error")
|
||||
return _stub_chunk_result(len(chunk), call_count["n"])
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=maybe_fail):
|
||||
result = extract_corpus_parallel(
|
||||
files, backend="kimi", token_budget=None, chunk_size=1, max_concurrency=1
|
||||
)
|
||||
|
||||
# 4 chunks dispatched, 1 failed → 3 chunks contributed nodes
|
||||
assert len(result["nodes"]) == 3
|
||||
err = capsys.readouterr().err
|
||||
assert "failed" in err and "simulated API error" in err
|
||||
|
||||
|
||||
def test_corpus_parallel_legacy_mode_when_token_budget_is_none(tmp_path):
|
||||
"""token_budget=None should fall back to legacy fixed-count chunking."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = []
|
||||
for i in range(45):
|
||||
f = tmp_path / f"f{i}.py"; f.write_text("x")
|
||||
files.append(f)
|
||||
|
||||
chunks_seen = []
|
||||
|
||||
def record(chunk, **kwargs):
|
||||
chunks_seen.append(len(chunk))
|
||||
return _stub_chunk_result(len(chunk), len(chunks_seen))
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||
extract_corpus_parallel(
|
||||
files, backend="kimi", token_budget=None, chunk_size=20, max_concurrency=1
|
||||
)
|
||||
|
||||
# 45 files / chunk_size=20 = 3 chunks of 20, 20, 5
|
||||
assert chunks_seen == [20, 20, 5]
|
||||
|
||||
|
||||
def test_corpus_parallel_token_budget_default_packs_files(tmp_path):
|
||||
"""With the default token_budget, many tiny files pack into one chunk."""
|
||||
from graphify.llm import extract_corpus_parallel
|
||||
|
||||
files = []
|
||||
for i in range(50):
|
||||
f = tmp_path / f"f{i}.py"; f.write_text("x = 1\n")
|
||||
files.append(f)
|
||||
|
||||
chunks_seen = []
|
||||
|
||||
def record(chunk, **kwargs):
|
||||
chunks_seen.append(len(chunk))
|
||||
return _stub_chunk_result(len(chunk), len(chunks_seen))
|
||||
|
||||
with patch("graphify.llm.extract_files_direct", side_effect=record):
|
||||
extract_corpus_parallel(files, backend="kimi", max_concurrency=1)
|
||||
|
||||
# 50 tiny files at default 60k token budget should pack into 1 chunk
|
||||
assert len(chunks_seen) == 1
|
||||
assert chunks_seen[0] == 50
|
||||
Reference in New Issue
Block a user