tokenshrink 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenshrink-0.2.1/Dockerfile +21 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/PKG-INFO +1 -1
- tokenshrink-0.2.1/docker-compose.test.yml +47 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/pyproject.toml +1 -1
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/__init__.py +1 -1
- tokenshrink-0.2.1/src/tokenshrink/__main__.py +4 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/cli.py +19 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/src/tokenshrink/pipeline.py +1 -1
- tokenshrink-0.2.1/tests/conftest.py +211 -0
- tokenshrink-0.2.1/tests/test_cli.py +248 -0
- tokenshrink-0.2.1/tests/test_integration.py +263 -0
- tokenshrink-0.2.1/tests/test_pipeline.py +411 -0
- tokenshrink-0.2.1/tests/test_stress.py +264 -0
- tokenshrink-0.2.1/tests/test_utils.py +255 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/.github/ISSUE_TEMPLATE/feedback.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/.gitignore +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/LICENSE +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/README.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/ASSETS.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/index.html +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/origin-story-post.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-log.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-posts.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/marketing/reddit-routine.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/docs/monitoring-log.md +0 -0
- {tokenshrink-0.2.0 → tokenshrink-0.2.1}/site/index.html +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# Install system deps for FAISS
|
|
6
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
7
|
+
build-essential \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
9
|
+
|
|
10
|
+
# Copy project
|
|
11
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
12
|
+
COPY src/ ./src/
|
|
13
|
+
|
|
14
|
+
# Install package with dev deps (no compression — too heavy for test image)
|
|
15
|
+
RUN pip install --no-cache-dir -e ".[dev]"
|
|
16
|
+
|
|
17
|
+
# Copy tests
|
|
18
|
+
COPY tests/ ./tests/
|
|
19
|
+
|
|
20
|
+
# Default: run all tests
|
|
21
|
+
CMD ["pytest", "tests/", "-v", "--tb=short", "-x"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokenshrink
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Cut your AI costs 50-80%. FAISS retrieval + LLMLingua compression + REFRAG-inspired adaptive optimization.
|
|
5
5
|
Project-URL: Homepage, https://tokenshrink.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/MusashiMiyamoto1-cloud/tokenshrink
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
version: "3.8"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
# Full test suite
|
|
5
|
+
test-all:
|
|
6
|
+
build:
|
|
7
|
+
context: .
|
|
8
|
+
dockerfile: Dockerfile
|
|
9
|
+
command: pytest tests/ -v --tb=short -x
|
|
10
|
+
environment:
|
|
11
|
+
- TOKENIZERS_PARALLELISM=false
|
|
12
|
+
|
|
13
|
+
# Unit tests only (fast)
|
|
14
|
+
test-unit:
|
|
15
|
+
build:
|
|
16
|
+
context: .
|
|
17
|
+
dockerfile: Dockerfile
|
|
18
|
+
command: pytest tests/test_utils.py tests/test_pipeline.py -v --tb=short
|
|
19
|
+
environment:
|
|
20
|
+
- TOKENIZERS_PARALLELISM=false
|
|
21
|
+
|
|
22
|
+
# CLI tests
|
|
23
|
+
test-cli:
|
|
24
|
+
build:
|
|
25
|
+
context: .
|
|
26
|
+
dockerfile: Dockerfile
|
|
27
|
+
command: pytest tests/test_cli.py -v --tb=short
|
|
28
|
+
environment:
|
|
29
|
+
- TOKENIZERS_PARALLELISM=false
|
|
30
|
+
|
|
31
|
+
# Integration tests
|
|
32
|
+
test-integration:
|
|
33
|
+
build:
|
|
34
|
+
context: .
|
|
35
|
+
dockerfile: Dockerfile
|
|
36
|
+
command: pytest tests/test_integration.py -v --tb=short
|
|
37
|
+
environment:
|
|
38
|
+
- TOKENIZERS_PARALLELISM=false
|
|
39
|
+
|
|
40
|
+
# Stress tests
|
|
41
|
+
test-stress:
|
|
42
|
+
build:
|
|
43
|
+
context: .
|
|
44
|
+
dockerfile: Dockerfile
|
|
45
|
+
command: pytest tests/test_stress.py -v --tb=short -s
|
|
46
|
+
environment:
|
|
47
|
+
- TOKENIZERS_PARALLELISM=false
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tokenshrink"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Cut your AI costs 50-80%. FAISS retrieval + LLMLingua compression + REFRAG-inspired adaptive optimization."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -32,6 +32,11 @@ def main():
|
|
|
32
32
|
action="store_true",
|
|
33
33
|
help="Output as JSON",
|
|
34
34
|
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--quiet",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Suppress model loading messages",
|
|
39
|
+
)
|
|
35
40
|
|
|
36
41
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
37
42
|
|
|
@@ -118,6 +123,17 @@ def main():
|
|
|
118
123
|
parser.print_help()
|
|
119
124
|
sys.exit(0)
|
|
120
125
|
|
|
126
|
+
# Suppress noisy output when --quiet or --json
|
|
127
|
+
if args.quiet or args.json:
|
|
128
|
+
import os, logging, warnings
|
|
129
|
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
|
130
|
+
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
|
131
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
132
|
+
logging.getLogger("sentence_transformers").setLevel(logging.ERROR)
|
|
133
|
+
logging.getLogger("transformers").setLevel(logging.ERROR)
|
|
134
|
+
logging.getLogger("huggingface_hub").setLevel(logging.ERROR)
|
|
135
|
+
warnings.filterwarnings("ignore", message=".*unauthenticated.*")
|
|
136
|
+
|
|
121
137
|
# Determine compression setting
|
|
122
138
|
compression = True
|
|
123
139
|
if hasattr(args, 'no_compress') and args.no_compress:
|
|
@@ -195,6 +211,9 @@ def main():
|
|
|
195
211
|
print(f"Sources: {', '.join(Path(s).name for s in result.sources)}")
|
|
196
212
|
print(f"Stats: {result.savings}")
|
|
197
213
|
|
|
214
|
+
if result.savings_pct == 0.0:
|
|
215
|
+
print(" Tip: Install llmlingua for compression: pip install llmlingua")
|
|
216
|
+
|
|
198
217
|
if getattr(args, 'scores', False) and result.chunk_scores:
|
|
199
218
|
print("\nChunk Importance Scores:")
|
|
200
219
|
for cs in result.chunk_scores:
|
|
@@ -613,7 +613,7 @@ class TokenShrink:
|
|
|
613
613
|
"ratio": total_compressed / total_original if total_original else 1.0,
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
def search(self, question: str, k: int = 5, min_score: float = 0.
|
|
616
|
+
def search(self, question: str, k: int = 5, min_score: float = 0.15) -> list[dict]:
|
|
617
617
|
"""Search without compression. Returns raw chunks with scores."""
|
|
618
618
|
if self._index.ntotal == 0:
|
|
619
619
|
return []
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Shared fixtures for TokenShrink test suite."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from sentence_transformers import SentenceTransformer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Session-scoped: load the embedding model ONCE for all tests
|
|
14
|
+
@pytest.fixture(scope="session")
|
|
15
|
+
def shared_model():
|
|
16
|
+
"""Load embedding model once per test session."""
|
|
17
|
+
return SentenceTransformer("all-MiniLM-L6-v2")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def tmp_dir():
|
|
22
|
+
"""Create a temporary directory, clean up after."""
|
|
23
|
+
d = tempfile.mkdtemp(prefix="tokenshrink_test_")
|
|
24
|
+
yield Path(d)
|
|
25
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def sample_docs(tmp_dir):
|
|
30
|
+
"""Create sample documents for indexing."""
|
|
31
|
+
docs_dir = tmp_dir / "docs"
|
|
32
|
+
docs_dir.mkdir()
|
|
33
|
+
|
|
34
|
+
# Auth documentation
|
|
35
|
+
(docs_dir / "auth.md").write_text(
|
|
36
|
+
"# Authentication\n\n"
|
|
37
|
+
"All API requests require a Bearer token in the Authorization header. "
|
|
38
|
+
"Tokens expire after 24 hours and must be refreshed using the /auth/refresh endpoint. "
|
|
39
|
+
"Rate limiting is enforced at 100 requests per minute per token. "
|
|
40
|
+
"If you exceed the rate limit, you'll receive a 429 status code. "
|
|
41
|
+
"OAuth2 flows are supported for third-party integrations. "
|
|
42
|
+
"The client_id and client_secret must be stored securely. "
|
|
43
|
+
"Never expose credentials in client-side code or version control. "
|
|
44
|
+
"Use environment variables or a secrets manager for production deployments. "
|
|
45
|
+
"Multi-factor authentication is required for admin endpoints. "
|
|
46
|
+
"Session tokens are tied to IP address for security. "
|
|
47
|
+
* 3
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Rate limiting documentation
|
|
51
|
+
(docs_dir / "rate-limits.md").write_text(
|
|
52
|
+
"# Rate Limits\n\n"
|
|
53
|
+
"The API enforces the following rate limits:\n"
|
|
54
|
+
"- Free tier: 10 requests per minute\n"
|
|
55
|
+
"- Pro tier: 100 requests per minute\n"
|
|
56
|
+
"- Enterprise: 1000 requests per minute\n\n"
|
|
57
|
+
"Rate limit headers are included in every response:\n"
|
|
58
|
+
"- X-RateLimit-Limit: Maximum requests allowed\n"
|
|
59
|
+
"- X-RateLimit-Remaining: Requests remaining\n"
|
|
60
|
+
"- X-RateLimit-Reset: Unix timestamp when limit resets\n\n"
|
|
61
|
+
"When rate limited, the response includes a Retry-After header. "
|
|
62
|
+
"Implement exponential backoff in your client. "
|
|
63
|
+
"Batch endpoints have separate, higher limits. "
|
|
64
|
+
"WebSocket connections have a message rate limit of 60 messages per minute. "
|
|
65
|
+
"Exceeding limits temporarily blocks the API key for 5 minutes. "
|
|
66
|
+
* 3
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Deployment guide
|
|
70
|
+
(docs_dir / "deployment.md").write_text(
|
|
71
|
+
"# Deployment Guide\n\n"
|
|
72
|
+
"## Docker\n"
|
|
73
|
+
"Build the image: `docker build -t myapp .`\n"
|
|
74
|
+
"Run with: `docker run -p 8080:8080 myapp`\n\n"
|
|
75
|
+
"## Kubernetes\n"
|
|
76
|
+
"Apply manifests: `kubectl apply -f k8s/`\n"
|
|
77
|
+
"The service uses a HorizontalPodAutoscaler with CPU target of 70%. "
|
|
78
|
+
"Minimum 2 replicas, maximum 10 replicas. "
|
|
79
|
+
"Persistent volumes are required for the database. "
|
|
80
|
+
"Use ConfigMaps for environment-specific settings. "
|
|
81
|
+
"Secrets should be managed via external-secrets-operator. "
|
|
82
|
+
"Health checks are configured on /health and /ready endpoints. "
|
|
83
|
+
"The readiness probe has an initial delay of 10 seconds. "
|
|
84
|
+
"Rolling updates with maxUnavailable=1 and maxSurge=1. "
|
|
85
|
+
* 3
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Near-duplicate of auth (for dedup testing)
|
|
89
|
+
(docs_dir / "auth2.md").write_text(
|
|
90
|
+
"# Authentication Guide\n\n"
|
|
91
|
+
"All API requests require a Bearer token in the Authorization header. "
|
|
92
|
+
"Tokens expire after 24 hours and must be refreshed using the /auth/refresh endpoint. "
|
|
93
|
+
"Rate limiting is enforced at 100 requests per minute per token. "
|
|
94
|
+
"If you exceed the rate limit, you'll receive a 429 status code. "
|
|
95
|
+
"OAuth2 flows are supported for third-party integrations. "
|
|
96
|
+
"The client_id and client_secret must be stored securely. "
|
|
97
|
+
"Never expose credentials in client-side code or version control. "
|
|
98
|
+
"Use environment variables or a secrets manager for production deployments. "
|
|
99
|
+
"Multi-factor authentication is required for admin endpoints. "
|
|
100
|
+
"Session tokens are tied to IP address for extra security measures. "
|
|
101
|
+
* 3
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Python code file
|
|
105
|
+
(docs_dir / "client.py").write_text(
|
|
106
|
+
'"""\nAPI Client for the service.\n"""\n\n'
|
|
107
|
+
"import requests\n"
|
|
108
|
+
"import time\n\n"
|
|
109
|
+
"class APIClient:\n"
|
|
110
|
+
' """HTTP client with retry and rate limit handling."""\n\n'
|
|
111
|
+
" def __init__(self, base_url: str, token: str):\n"
|
|
112
|
+
" self.base_url = base_url\n"
|
|
113
|
+
" self.token = token\n"
|
|
114
|
+
" self.session = requests.Session()\n"
|
|
115
|
+
' self.session.headers["Authorization"] = f"Bearer {token}"\n\n'
|
|
116
|
+
" def get(self, path: str, **kwargs):\n"
|
|
117
|
+
' """GET request with retry."""\n'
|
|
118
|
+
" for attempt in range(3):\n"
|
|
119
|
+
" resp = self.session.get(f'{self.base_url}{path}', **kwargs)\n"
|
|
120
|
+
" if resp.status_code == 429:\n"
|
|
121
|
+
" wait = int(resp.headers.get('Retry-After', 5))\n"
|
|
122
|
+
" time.sleep(wait)\n"
|
|
123
|
+
" continue\n"
|
|
124
|
+
" return resp\n"
|
|
125
|
+
" raise Exception('Rate limited after 3 retries')\n\n"
|
|
126
|
+
" def post(self, path: str, data=None, **kwargs):\n"
|
|
127
|
+
' """POST request with retry."""\n'
|
|
128
|
+
" for attempt in range(3):\n"
|
|
129
|
+
" resp = self.session.post(f'{self.base_url}{path}', json=data, **kwargs)\n"
|
|
130
|
+
" if resp.status_code == 429:\n"
|
|
131
|
+
" wait = int(resp.headers.get('Retry-After', 5))\n"
|
|
132
|
+
" time.sleep(wait)\n"
|
|
133
|
+
" continue\n"
|
|
134
|
+
" return resp\n"
|
|
135
|
+
" raise Exception('Rate limited after 3 retries')\n"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return docs_dir
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture
|
|
142
|
+
def large_docs(tmp_dir):
|
|
143
|
+
"""Create a large document set for stress testing."""
|
|
144
|
+
docs_dir = tmp_dir / "large_docs"
|
|
145
|
+
docs_dir.mkdir()
|
|
146
|
+
|
|
147
|
+
topics = [
|
|
148
|
+
("machine-learning", "Machine learning models use gradient descent to optimize loss functions. "
|
|
149
|
+
"Neural networks consist of layers of interconnected nodes. "),
|
|
150
|
+
("databases", "PostgreSQL supports JSONB for semi-structured data storage. "
|
|
151
|
+
"Indexes improve query performance on frequently accessed columns. "),
|
|
152
|
+
("networking", "TCP provides reliable ordered delivery of data between applications. "
|
|
153
|
+
"DNS resolves domain names to IP addresses using a hierarchical system. "),
|
|
154
|
+
("security", "TLS encrypts data in transit between client and server. "
|
|
155
|
+
"CORS headers control which origins can access API resources. "),
|
|
156
|
+
("devops", "CI/CD pipelines automate building, testing, and deploying code. "
|
|
157
|
+
"Infrastructure as code tools like Terraform manage cloud resources. "),
|
|
158
|
+
("frontend", "React components re-render when state or props change. "
|
|
159
|
+
"CSS Grid and Flexbox provide powerful layout capabilities. "),
|
|
160
|
+
("api-design", "REST APIs use HTTP methods to perform CRUD operations. "
|
|
161
|
+
"GraphQL allows clients to request exactly the data they need. "),
|
|
162
|
+
("testing", "Unit tests verify individual functions in isolation. "
|
|
163
|
+
"Integration tests check that components work together correctly. "),
|
|
164
|
+
("monitoring", "Prometheus collects metrics from instrumented applications. "
|
|
165
|
+
"Grafana dashboards visualize time-series data for observability. "),
|
|
166
|
+
("caching", "Redis provides in-memory key-value storage with persistence options. "
|
|
167
|
+
"CDN edge caching reduces latency for static assets. "),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
for i in range(50):
|
|
171
|
+
topic_name, content = topics[i % len(topics)]
|
|
172
|
+
filename = f"{topic_name}-{i:03d}.md"
|
|
173
|
+
# Each file ~2000 words
|
|
174
|
+
(docs_dir / filename).write_text(
|
|
175
|
+
f"# {topic_name.replace('-', ' ').title()} - Part {i}\n\n"
|
|
176
|
+
+ (content * 80)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return docs_dir
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.fixture
|
|
183
|
+
def indexed_ts(tmp_dir, sample_docs, shared_model):
|
|
184
|
+
"""Return a TokenShrink instance with sample docs already indexed."""
|
|
185
|
+
from tokenshrink import TokenShrink
|
|
186
|
+
|
|
187
|
+
ts = TokenShrink(
|
|
188
|
+
index_dir=str(tmp_dir / ".tokenshrink"),
|
|
189
|
+
compression=False,
|
|
190
|
+
adaptive=True,
|
|
191
|
+
dedup=True,
|
|
192
|
+
)
|
|
193
|
+
# Inject the shared model to avoid reloading
|
|
194
|
+
ts._model = shared_model
|
|
195
|
+
ts.index(str(sample_docs))
|
|
196
|
+
return ts
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.fixture
|
|
200
|
+
def make_ts(tmp_dir, shared_model):
|
|
201
|
+
"""Factory fixture: creates a TokenShrink with the shared model."""
|
|
202
|
+
from tokenshrink import TokenShrink
|
|
203
|
+
|
|
204
|
+
def _make(**kwargs):
|
|
205
|
+
kwargs.setdefault("index_dir", str(tmp_dir / ".ts"))
|
|
206
|
+
kwargs.setdefault("compression", False)
|
|
207
|
+
ts = TokenShrink(**kwargs)
|
|
208
|
+
ts._model = shared_model
|
|
209
|
+
return ts
|
|
210
|
+
|
|
211
|
+
return _make
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Tests for the CLI interface."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def cli_env(tmp_dir, sample_docs):
|
|
13
|
+
"""Set up CLI test environment."""
|
|
14
|
+
return {
|
|
15
|
+
"docs_dir": str(sample_docs),
|
|
16
|
+
"index_dir": str(tmp_dir / ".ts"),
|
|
17
|
+
"tmp_dir": tmp_dir,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_cli(*args, cwd=None):
|
|
22
|
+
"""Run tokenshrink CLI and return result."""
|
|
23
|
+
cmd = [sys.executable, "-m", "tokenshrink.cli"] + list(args)
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
cmd,
|
|
26
|
+
capture_output=True,
|
|
27
|
+
text=True,
|
|
28
|
+
cwd=cwd,
|
|
29
|
+
timeout=120,
|
|
30
|
+
)
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestCLIIndex:
|
|
35
|
+
"""Test CLI index command."""
|
|
36
|
+
|
|
37
|
+
def test_index_basic(self, cli_env):
|
|
38
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
39
|
+
assert r.returncode == 0
|
|
40
|
+
assert "Indexed" in r.stdout
|
|
41
|
+
assert "Chunks" in r.stdout
|
|
42
|
+
|
|
43
|
+
def test_index_json_output(self, cli_env):
|
|
44
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "--json", "index", cli_env["docs_dir"])
|
|
45
|
+
assert r.returncode == 0
|
|
46
|
+
data = json.loads(r.stdout)
|
|
47
|
+
assert "files_indexed" in data
|
|
48
|
+
assert "chunks_added" in data
|
|
49
|
+
assert data["files_indexed"] > 0
|
|
50
|
+
|
|
51
|
+
def test_index_with_extensions(self, cli_env):
|
|
52
|
+
r = run_cli(
|
|
53
|
+
"--index-dir", cli_env["index_dir"],
|
|
54
|
+
"--json",
|
|
55
|
+
"index", cli_env["docs_dir"],
|
|
56
|
+
"-e", ".md",
|
|
57
|
+
)
|
|
58
|
+
assert r.returncode == 0
|
|
59
|
+
data = json.loads(r.stdout)
|
|
60
|
+
assert data["files_indexed"] == 4 # Only .md files
|
|
61
|
+
|
|
62
|
+
def test_index_force(self, cli_env):
|
|
63
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
64
|
+
r = run_cli(
|
|
65
|
+
"--index-dir", cli_env["index_dir"],
|
|
66
|
+
"--json",
|
|
67
|
+
"index", cli_env["docs_dir"],
|
|
68
|
+
"-f",
|
|
69
|
+
)
|
|
70
|
+
assert r.returncode == 0
|
|
71
|
+
data = json.loads(r.stdout)
|
|
72
|
+
assert data["files_indexed"] > 0 # Re-indexed with force
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestCLIQuery:
|
|
76
|
+
"""Test CLI query command."""
|
|
77
|
+
|
|
78
|
+
def test_query_basic(self, cli_env):
|
|
79
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
80
|
+
r = run_cli(
|
|
81
|
+
"--index-dir", cli_env["index_dir"],
|
|
82
|
+
"query", "authentication tokens",
|
|
83
|
+
"--no-compress",
|
|
84
|
+
)
|
|
85
|
+
assert r.returncode == 0
|
|
86
|
+
assert "Sources:" in r.stdout or "No relevant" in r.stdout
|
|
87
|
+
|
|
88
|
+
def test_query_json(self, cli_env):
|
|
89
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
90
|
+
r = run_cli(
|
|
91
|
+
"--index-dir", cli_env["index_dir"],
|
|
92
|
+
"--json",
|
|
93
|
+
"query", "authentication",
|
|
94
|
+
"--no-compress",
|
|
95
|
+
)
|
|
96
|
+
assert r.returncode == 0
|
|
97
|
+
data = json.loads(r.stdout)
|
|
98
|
+
assert "context" in data
|
|
99
|
+
assert "sources" in data
|
|
100
|
+
assert "original_tokens" in data
|
|
101
|
+
|
|
102
|
+
def test_query_with_scores(self, cli_env):
|
|
103
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
104
|
+
r = run_cli(
|
|
105
|
+
"--index-dir", cli_env["index_dir"],
|
|
106
|
+
"query", "rate limits",
|
|
107
|
+
"--no-compress",
|
|
108
|
+
"--scores",
|
|
109
|
+
)
|
|
110
|
+
assert r.returncode == 0
|
|
111
|
+
assert "Chunk Importance Scores" in r.stdout
|
|
112
|
+
assert "sim=" in r.stdout
|
|
113
|
+
assert "density=" in r.stdout
|
|
114
|
+
|
|
115
|
+
def test_query_json_with_scores(self, cli_env):
|
|
116
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
117
|
+
r = run_cli(
|
|
118
|
+
"--index-dir", cli_env["index_dir"],
|
|
119
|
+
"--json",
|
|
120
|
+
"query", "deployment kubernetes",
|
|
121
|
+
"--no-compress",
|
|
122
|
+
"--scores",
|
|
123
|
+
)
|
|
124
|
+
assert r.returncode == 0
|
|
125
|
+
data = json.loads(r.stdout)
|
|
126
|
+
assert "chunk_scores" in data
|
|
127
|
+
for cs in data["chunk_scores"]:
|
|
128
|
+
assert "similarity" in cs
|
|
129
|
+
assert "density" in cs
|
|
130
|
+
assert "importance" in cs
|
|
131
|
+
|
|
132
|
+
def test_query_no_dedup(self, cli_env):
|
|
133
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
134
|
+
r = run_cli(
|
|
135
|
+
"--index-dir", cli_env["index_dir"],
|
|
136
|
+
"--json",
|
|
137
|
+
"query", "authentication",
|
|
138
|
+
"--no-compress",
|
|
139
|
+
"--no-dedup",
|
|
140
|
+
)
|
|
141
|
+
assert r.returncode == 0
|
|
142
|
+
data = json.loads(r.stdout)
|
|
143
|
+
assert data["dedup_removed"] == 0
|
|
144
|
+
|
|
145
|
+
def test_query_k_param(self, cli_env):
|
|
146
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
147
|
+
r = run_cli(
|
|
148
|
+
"--index-dir", cli_env["index_dir"],
|
|
149
|
+
"--json",
|
|
150
|
+
"query", "authentication",
|
|
151
|
+
"--no-compress",
|
|
152
|
+
"-k", "2",
|
|
153
|
+
)
|
|
154
|
+
assert r.returncode == 0
|
|
155
|
+
data = json.loads(r.stdout)
|
|
156
|
+
assert len(data.get("sources", [])) <= 2
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestCLISearch:
|
|
160
|
+
"""Test CLI search command."""
|
|
161
|
+
|
|
162
|
+
def test_search_basic(self, cli_env):
|
|
163
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
164
|
+
r = run_cli(
|
|
165
|
+
"--index-dir", cli_env["index_dir"],
|
|
166
|
+
"search", "rate limits",
|
|
167
|
+
)
|
|
168
|
+
assert r.returncode == 0
|
|
169
|
+
assert "score:" in r.stdout
|
|
170
|
+
|
|
171
|
+
def test_search_json(self, cli_env):
|
|
172
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
173
|
+
r = run_cli(
|
|
174
|
+
"--index-dir", cli_env["index_dir"],
|
|
175
|
+
"--json",
|
|
176
|
+
"search", "rate limits",
|
|
177
|
+
)
|
|
178
|
+
assert r.returncode == 0
|
|
179
|
+
data = json.loads(r.stdout)
|
|
180
|
+
assert isinstance(data, list)
|
|
181
|
+
assert len(data) > 0
|
|
182
|
+
|
|
183
|
+
def test_search_empty_index(self, cli_env):
|
|
184
|
+
r = run_cli(
|
|
185
|
+
"--index-dir", cli_env["index_dir"],
|
|
186
|
+
"search", "anything",
|
|
187
|
+
)
|
|
188
|
+
assert r.returncode == 0
|
|
189
|
+
assert "No results" in r.stdout
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class TestCLIStats:
|
|
193
|
+
"""Test CLI stats command."""
|
|
194
|
+
|
|
195
|
+
def test_stats_empty(self, cli_env):
|
|
196
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "stats")
|
|
197
|
+
assert r.returncode == 0
|
|
198
|
+
assert "Chunks: 0" in r.stdout
|
|
199
|
+
|
|
200
|
+
def test_stats_after_index(self, cli_env):
|
|
201
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
202
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "stats")
|
|
203
|
+
assert r.returncode == 0
|
|
204
|
+
assert "Chunks:" in r.stdout
|
|
205
|
+
assert "Files:" in r.stdout
|
|
206
|
+
|
|
207
|
+
def test_stats_json(self, cli_env):
|
|
208
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
209
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "--json", "stats")
|
|
210
|
+
assert r.returncode == 0
|
|
211
|
+
data = json.loads(r.stdout)
|
|
212
|
+
assert "total_chunks" in data
|
|
213
|
+
assert data["total_chunks"] > 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestCLIClear:
|
|
217
|
+
"""Test CLI clear command."""
|
|
218
|
+
|
|
219
|
+
def test_clear(self, cli_env):
|
|
220
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
221
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "clear")
|
|
222
|
+
assert r.returncode == 0
|
|
223
|
+
assert "cleared" in r.stdout.lower()
|
|
224
|
+
|
|
225
|
+
def test_clear_json(self, cli_env):
|
|
226
|
+
run_cli("--index-dir", cli_env["index_dir"], "index", cli_env["docs_dir"])
|
|
227
|
+
r = run_cli("--index-dir", cli_env["index_dir"], "--json", "clear")
|
|
228
|
+
assert r.returncode == 0
|
|
229
|
+
data = json.loads(r.stdout)
|
|
230
|
+
assert data["status"] == "cleared"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class TestCLIMisc:
|
|
234
|
+
"""Test miscellaneous CLI behavior."""
|
|
235
|
+
|
|
236
|
+
def test_version(self):
|
|
237
|
+
r = run_cli("--version")
|
|
238
|
+
assert r.returncode == 0
|
|
239
|
+
assert "0.2.0" in r.stdout
|
|
240
|
+
|
|
241
|
+
def test_no_command(self):
|
|
242
|
+
r = run_cli()
|
|
243
|
+
assert r.returncode == 0 # Just prints help
|
|
244
|
+
|
|
245
|
+
def test_help(self):
|
|
246
|
+
r = run_cli("--help")
|
|
247
|
+
assert r.returncode == 0
|
|
248
|
+
assert "tokenshrink" in r.stdout.lower()
|