Spaces:
Build error
Build error
| """LangChain Integration Evals: Comprehensive evaluation of Headroom with LangChain agents. | |
| These evals test real-world scenarios to ensure: | |
| 1. 100% preservation of critical items (errors, anomalies) | |
| 2. Meaningful compression ratios | |
| 3. No loss of query-relevant data | |
| 4. Correct schema preservation | |
| Run with: pytest tests/test_integrations/test_langchain_evals.py -v | |
| """ | |
| import json | |
| import random | |
| from datetime import datetime, timedelta | |
| import pytest | |
| from headroom.config import SmartCrusherConfig | |
| from headroom.providers import OpenAIProvider | |
| from headroom.transforms import SmartCrusher | |
| # Test fixtures for realistic data | |
| def tokenizer(): | |
| """Get OpenAI tokenizer.""" | |
| provider = OpenAIProvider() | |
| return provider.get_token_counter("gpt-4o") | |
| def smart_crusher(): | |
| """Create SmartCrusher with default config.""" | |
| config = SmartCrusherConfig( | |
| enabled=True, | |
| min_tokens_to_crush=200, | |
| max_items_after_crush=20, | |
| ) | |
| return SmartCrusher(config=config) | |
| def generate_log_entries(count: int, error_rate: float = 0.15) -> list[dict]: | |
| """Generate realistic log entries with configurable error rate.""" | |
| entries = [] | |
| levels = ["DEBUG", "INFO", "INFO", "INFO", "WARN"] # Base levels (no ERROR) | |
| for _i in range(count): | |
| timestamp = datetime.now() - timedelta(minutes=random.randint(1, 1440)) | |
| # Force specific error rate | |
| if random.random() < error_rate: | |
| level = "ERROR" | |
| message = random.choice( | |
| [ | |
| "Connection refused to db: timeout after 30s", | |
| "Failed to process request: NullPointerException", | |
| "Authentication failed for user: invalid token", | |
| "Rate limit exceeded: 429 Too Many Requests", | |
| ] | |
| ) | |
| else: | |
| level = random.choice(levels) | |
| message = f"Processing request {random.randint(1000, 9999)}" | |
| entry = { | |
| "timestamp": timestamp.isoformat(), | |
| "level": level, | |
| "service": "test-service", | |
| "message": message, | |
| "trace_id": f"trace_{random.randint(100000, 999999)}", | |
| } | |
| entries.append(entry) | |
| return entries | |
| def generate_metrics_data(count: int, anomaly_rate: float = 0.1) -> list[dict]: | |
| """Generate time-series metrics with configurable anomaly rate.""" | |
| metrics = [] | |
| now = datetime.now() | |
| for i in range(count): | |
| timestamp = now - timedelta(minutes=i * 5) | |
| # Force specific anomaly rate | |
| is_anomaly = random.random() < anomaly_rate | |
| metric = { | |
| "timestamp": timestamp.isoformat(), | |
| "service": "test-service", | |
| "cpu_percent": random.uniform(80, 99) if is_anomaly else random.uniform(20, 40), | |
| "memory_percent": random.uniform(85, 99) if is_anomaly else random.uniform(40, 60), | |
| "error_rate": random.uniform(5, 15) if is_anomaly else random.uniform(0, 1), | |
| "latency_p99_ms": random.randint(1000, 5000) if is_anomaly else random.randint(50, 200), | |
| } | |
| metrics.append(metric) | |
| return metrics | |
| def generate_search_results(count: int, query: str) -> list[dict]: | |
| """Generate search results with varying relevance.""" | |
| results = [] | |
| for i in range(count): | |
| # Some results match query, most don't | |
| if i < 5: | |
| title = f"Document about {query}" | |
| snippet = f"This article discusses {query} in detail. {query} is important..." | |
| else: | |
| title = f"Unrelated Document {i}" | |
| snippet = "This document covers something else entirely. Not about your search." | |
| result = { | |
| "id": f"doc_{random.randint(10000, 99999)}", | |
| "title": title, | |
| "snippet": snippet, | |
| "relevance_score": round( | |
| random.uniform(0.9, 1.0) if i < 5 else random.uniform(0.1, 0.5), 3 | |
| ), | |
| "url": f"https://docs.example.com/{i}", | |
| } | |
| results.append(result) | |
| # Shuffle to test relevance detection | |
| random.shuffle(results) | |
| return results | |
| def generate_user_records(count: int, target_user: str = None) -> list[dict]: | |
| """Generate user records with optional target user to find.""" | |
| users = [] | |
| for i in range(count): | |
| name = f"User {i}" | |
| if target_user and i == count // 2: | |
| name = target_user # Place target user in middle | |
| user = { | |
| "id": f"usr_{random.randint(100000, 999999)}", | |
| "email": f"user{i}@example.com", | |
| "name": name, | |
| "department": random.choice(["Engineering", "Sales", "HR"]), | |
| "status": random.choice(["active", "inactive"]), | |
| } | |
| users.append(user) | |
| return users | |
| class TestErrorPreservation: | |
| """Test that 100% of ERROR items are preserved.""" | |
| def test_100_percent_errors_preserved_logs(self, smart_crusher, tokenizer): | |
| """All ERROR log entries must be preserved.""" | |
| # Generate logs with known error count | |
| entries = generate_log_entries(200, error_rate=0.2) | |
| original_errors = [e for e in entries if e["level"] == "ERROR"] | |
| # Create tool message | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Find ERROR entries in the logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| # Apply compression | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| # Extract JSON (handle potential markers) | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # Count preserved errors | |
| compressed_errors = [e for e in compressed_data["entries"] if e["level"] == "ERROR"] | |
| # CRITICAL: 100% of errors must be preserved | |
| assert len(compressed_errors) == len(original_errors), ( | |
| f"ERROR preservation failed: {len(compressed_errors)}/{len(original_errors)} preserved" | |
| ) | |
| def test_errors_preserved_with_many_errors(self, smart_crusher, tokenizer): | |
| """Even with many errors (exceeding max_items), all must be preserved.""" | |
| # Generate logs with 50% error rate (100 errors in 200 entries) | |
| entries = generate_log_entries(200, error_rate=0.5) | |
| original_errors = [e for e in entries if e["level"] == "ERROR"] | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Find errors"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| compressed_errors = [e for e in compressed_data["entries"] if e["level"] == "ERROR"] | |
| # Even with many errors, ALL must be preserved | |
| assert len(compressed_errors) == len(original_errors), ( | |
| f"High-error-rate preservation failed: {len(compressed_errors)}/{len(original_errors)}" | |
| ) | |
| class TestAnomalyPreservation: | |
| """Test that anomalous metrics are preserved.""" | |
| def test_cpu_spike_preserved(self, smart_crusher, tokenizer): | |
| """CPU spikes (anomalies) should be preserved.""" | |
| metrics = generate_metrics_data(100, anomaly_rate=0.1) | |
| # Count high CPU entries (> 70% is anomaly in our data) | |
| original_anomalies = [m for m in metrics if m["cpu_percent"] > 70] | |
| raw_output = json.dumps({"metrics": metrics}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Look for CPU spikes or high error rates"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "get_metrics", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| compressed_anomalies = [m for m in compressed_data["metrics"] if m["cpu_percent"] > 70] | |
| # Most anomalies should be preserved (statistical detection may miss some edge cases) | |
| preservation_rate = ( | |
| len(compressed_anomalies) / len(original_anomalies) if original_anomalies else 1.0 | |
| ) | |
| assert preservation_rate >= 0.8, f"Anomaly preservation too low: {preservation_rate:.1%}" | |
| class TestRelevancePreservation: | |
| """Test that query-relevant items are preserved. | |
| Note: These tests may vary in effectiveness based on whether | |
| sentence-transformers is installed (full semantic matching) or | |
| not (BM25 keyword matching only). | |
| """ | |
| def test_search_results_with_query_term(self, smart_crusher, tokenizer): | |
| """Results containing exact query terms should be preserved.""" | |
| # Use exact keyword that appears in the document | |
| query = "authentication" # Simple keyword query | |
| results = generate_search_results(50, query) | |
| raw_output = json.dumps({"results": results}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": f"Find documentation about {query}"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_docs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # At least some high-relevance results should be preserved | |
| # (BM25 may not catch all without exact keyword matches) | |
| compressed_high_relevance = [ | |
| r for r in compressed_data["results"] if r["relevance_score"] > 0.8 | |
| ] | |
| # With BM25, we should preserve at least 1 high-relevance result | |
| # Full embedding support would preserve more | |
| assert len(compressed_high_relevance) >= 1, "No high-relevance results preserved" | |
| def test_exact_keyword_needle(self, smart_crusher, tokenizer): | |
| """A user with exact keyword match should be found.""" | |
| # Use ERROR as the "needle" since we know error detection works | |
| # This tests that relevance scoring via keywords works | |
| users = generate_user_records(100) | |
| # Add one user with "ERROR" status (will be caught by keyword detection) | |
| users[50]["status"] = "ERROR_SUSPENDED" | |
| users[50]["name"] = "Error Case User" | |
| raw_output = json.dumps({"users": users}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Find users with ERROR status"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_users", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| # The ERROR user should be preserved (error keyword detection) | |
| assert "ERROR_SUSPENDED" in compressed_output, ( | |
| "User with ERROR keyword not found in compressed results" | |
| ) | |
| def test_first_last_items_always_preserved(self, smart_crusher, tokenizer): | |
| """First and last items should always be preserved for context.""" | |
| users = generate_user_records(100) | |
| # Mark first and last users distinctly | |
| users[0]["name"] = "FIRST_USER_MARKER" | |
| users[-1]["name"] = "LAST_USER_MARKER" | |
| raw_output = json.dumps({"users": users}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "List all users"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_users", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| # First and last items should always be preserved | |
| assert "FIRST_USER_MARKER" in compressed_output, "First item not preserved" | |
| assert "LAST_USER_MARKER" in compressed_output, "Last item not preserved" | |
| class TestCompressionEfficiency: | |
| """Test that compression achieves meaningful reduction.""" | |
| def test_minimum_compression_ratio(self, smart_crusher, tokenizer): | |
| """Large outputs should achieve significant compression.""" | |
| entries = generate_log_entries(200, error_rate=0.1) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| original_tokens = tokenizer.count_text(raw_output) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check the logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| compressed_tokens = tokenizer.count_text(compressed_output) | |
| compression_ratio = 1 - (compressed_tokens / original_tokens) | |
| # Should achieve at least 50% compression | |
| assert compression_ratio >= 0.5, f"Compression ratio too low: {compression_ratio:.1%}" | |
| def test_token_savings_reported(self, smart_crusher, tokenizer): | |
| """TransformResult should report accurate token savings.""" | |
| entries = generate_log_entries(100, error_rate=0.1) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check the logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| # Token counts should be accurate | |
| assert result.tokens_before > result.tokens_after, ( | |
| f"No compression: {result.tokens_before} -> {result.tokens_after}" | |
| ) | |
| tokens_saved = result.tokens_before - result.tokens_after | |
| assert tokens_saved > 0, "Should save tokens" | |
| class TestSchemaPreservation: | |
| """Test that original JSON schema is preserved.""" | |
| def test_no_wrapper_added(self, smart_crusher, tokenizer): | |
| """Compressed output should maintain original schema, no wrappers.""" | |
| entries = generate_log_entries(100, error_rate=0.1) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check the logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| # Should be valid JSON | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # Should have same top-level key | |
| assert "entries" in compressed_data, "Original schema key 'entries' missing" | |
| # Each entry should have original fields | |
| if compressed_data["entries"]: | |
| first_entry = compressed_data["entries"][0] | |
| expected_fields = {"timestamp", "level", "service", "message", "trace_id"} | |
| assert expected_fields.issubset(set(first_entry.keys())), ( | |
| f"Original fields missing: {expected_fields - set(first_entry.keys())}" | |
| ) | |
| def test_no_summary_metadata(self, smart_crusher, tokenizer): | |
| """No summary or metadata fields should be added to output.""" | |
| entries = generate_log_entries(100, error_rate=0.1) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check the logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # Should NOT have added metadata keys | |
| forbidden_keys = {"_summary", "_compressed", "_original_count", "_metadata"} | |
| actual_keys = set(compressed_data.keys()) | |
| added_keys = actual_keys & forbidden_keys | |
| assert not added_keys, f"Metadata keys were added: {added_keys}" | |
| class TestEdgeCases: | |
| """Test edge cases and boundary conditions.""" | |
| def test_all_errors_input(self, smart_crusher, tokenizer): | |
| """Input with 100% errors should keep all of them.""" | |
| # Create entries that are ALL errors | |
| entries = [] | |
| for i in range(50): | |
| entries.append( | |
| { | |
| "timestamp": datetime.now().isoformat(), | |
| "level": "ERROR", | |
| "message": f"Error message {i}", | |
| "service": "test", | |
| } | |
| ) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check errors"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # ALL entries should be kept (they're all errors) | |
| assert len(compressed_data["entries"]) == 50, ( | |
| f"Should keep all 50 error entries, got {len(compressed_data['entries'])}" | |
| ) | |
| def test_small_input_no_compression(self, smart_crusher, tokenizer): | |
| """Small inputs below threshold should not be compressed.""" | |
| entries = generate_log_entries(5, error_rate=0.2) | |
| raw_output = json.dumps({"entries": entries}, indent=2) | |
| messages = [ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": "Check logs"}, | |
| { | |
| "role": "assistant", | |
| "content": None, | |
| "tool_calls": [ | |
| {"id": "call_1", "function": {"name": "search_logs", "arguments": "{}"}} | |
| ], | |
| }, | |
| {"role": "tool", "content": raw_output, "tool_call_id": "call_1"}, | |
| ] | |
| result = smart_crusher.apply(messages, tokenizer=tokenizer) | |
| compressed_output = result.messages[-1]["content"] | |
| import re | |
| json_match = re.search(r"(\{.*\})", compressed_output, re.DOTALL) | |
| compressed_data = json.loads(json_match.group(1) if json_match else compressed_output) | |
| # Should keep all entries (below min_items_to_analyze) | |
| assert len(compressed_data["entries"]) == 5 | |
| if __name__ == "__main__": | |
| pytest.main([__file__, "-v"]) | |