Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion logpilot/chunker.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,55 @@
from typing import List

import tiktoken
from tqdm import tqdm

from logpilot.log_parser import LogEntry

# Global encoding instance for token estimation
_encoding = None


def _get_encoding():
"""Get or create the encoding instance for token estimation."""
global _encoding
if _encoding is None:
try:
# Use cl100k_base encoding which is used by GPT-4 and GPT-3.5-turbo
_encoding = tiktoken.get_encoding("cl100k_base")
except Exception: # noqa: S110
# Catch all exceptions (network errors, file system errors, etc.)
# and fall back to simple estimation. Tiktoken may need to download
# encoding files, which can fail in various ways.
pass
return _encoding


# Dummy token estimation (replace with real logic if needed)
def estimate_tokens(text: str) -> int:
"""
Estimate the number of tokens in the given text.

Uses tiktoken with cl100k_base encoding (GPT-4 compatible) for accurate
token counting. Falls back to simple character-based estimation if tiktoken
is unavailable or fails.

Args:
text: The text to estimate tokens for

Returns:
The estimated number of tokens (minimum 1)
"""
encoding = _get_encoding()

if encoding is not None:
try:
# Use tiktoken for accurate token counting
return max(1, len(encoding.encode(text)))
except Exception: # noqa: S110
# Catch all encoding errors (invalid UTF-8, memory errors, etc.)
# and fall back to simple estimation for robustness
pass

# Simple fallback: approximately 4 characters per token
return max(1, len(text) // 4)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ exclude = [".git", "__pycache__", ".venv", "venv", "env", "build", "dist"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "S"]
ignore = []
ignore = ["W191"] # Allow tabs (used by formatter)

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "S603"] # Allow assert and subprocess in tests
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ git+https://github.com/github/copilot-sdk.git#subdirectory=python
pytest
tqdm
orjson
tiktoken
59 changes: 47 additions & 12 deletions tests/test_chunker.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,24 @@ def test_estimate_tokens_short_string(self):

def test_estimate_tokens_medium_string(self):
"""Test token estimation for medium-length strings."""
# 100 chars = 100 // 4 = 25 tokens
# 100 repeated 'x' chars - tiktoken encodes runs of chars efficiently
text = "x" * 100
tokens = estimate_tokens(text)
assert tokens == 25
assert tokens == 13

def test_estimate_tokens_large_string(self):
"""Test token estimation for large strings."""
# 10000 chars = 10000 // 4 = 2500 tokens
# 10000 repeated 'x' chars - tiktoken encodes runs of chars efficiently
text = "x" * 10000
tokens = estimate_tokens(text)
assert tokens == 2500
assert tokens == 1250

def test_estimate_tokens_unicode(self):
"""Test token estimation with unicode characters."""
# Each unicode char still counts as 1 character in Python
# Multi-byte unicode chars each use multiple tokens in cl100k_base
text = "你好世界🚀" # 5 characters
tokens = estimate_tokens(text)
assert tokens == 1 # 5 // 4 = 1
assert tokens == 8

def test_estimate_tokens_single_char(self):
"""Test token estimation for single character."""
Expand All @@ -65,9 +65,9 @@ def test_estimate_tokens_realistic_log_line(self):
'"source":"app.main","message":"Application started"}'
)
tokens = estimate_tokens(log_line)
# Length is ~107 chars, so ~26-27 tokens
# tiktoken (cl100k_base) gives ~31 tokens for this JSON log line
assert tokens > 20
assert tokens < 30
assert tokens < 40


# ============================================================================
Expand Down Expand Up @@ -217,22 +217,24 @@ def test_chunk_logs_all_entries_exceed_limit(self, mock_tqdm):
for chunk in chunks:
assert len(chunk) == 1

@patch("logpilot.chunker.estimate_tokens", return_value=10)
@patch("logpilot.chunker.tqdm", side_effect=lambda x, **kwargs: x)
def test_chunk_logs_exact_boundary(self, mock_tqdm):
def test_chunk_logs_exact_boundary(self, mock_tqdm, mock_estimate):
"""Test chunking when entries exactly hit the token boundary."""
# Create entries that exactly fit the limit
# estimate_tokens is mocked to return 10 tokens per entry
entries = [
LogEntry(
timestamp=f"2024-01-0{i}",
level="INFO",
source="test",
message=f"Entry {i}",
raw="x" * 40, # 10 tokens each
raw="x" * 40,
)
for i in range(10)
]

# With 50 token limit, should get 5 entries per chunk (50 tokens)
# With 50 token limit and 10 tokens each, 5 entries fill one chunk
# exactly; the 6th entry (0+10 → 50+10=60 > 50) triggers a new chunk
chunks = chunk_logs(entries, max_tokens=50)

# Should create 2 chunks
Expand Down Expand Up @@ -388,3 +390,36 @@ def test_estimate_tokens_consistency(self, mock_tqdm):
tokens3 = estimate_tokens(text)

assert tokens1 == tokens2 == tokens3

def test_estimate_tokens_uses_tiktoken_when_available(self):
"""Test that estimate_tokens uses tiktoken for accurate counting."""
# Test with a known string that has different counts with tiktoken vs simple
text = "Hello, world!"
tokens = estimate_tokens(text)

# With tiktoken, "Hello, world!" should be around 3-4 tokens
# With simple estimation (len // 4), it would be 13 // 4 = 3
# Both give similar results, so we just verify it returns a reasonable count
assert tokens >= 1
assert tokens <= 10 # Should be much less than 10 for this short text

@patch("logpilot.chunker.tiktoken.get_encoding")
def test_estimate_tokens_fallback_on_tiktoken_failure(self, mock_get_encoding):
"""Test fallback to simple estimation if tiktoken fails."""
# Force tiktoken to fail
mock_get_encoding.side_effect = Exception("Simulated failure")

# Reset the global encoding cache
import logpilot.chunker

logpilot.chunker._encoding = None

# Should fall back to simple estimation
text = "x" * 100
tokens = estimate_tokens(text)

# Simple estimation: 100 // 4 = 25
assert tokens == 25

# Clean up - reset encoding to None so other tests work
logpilot.chunker._encoding = None