contextpress 0.1.0__tar.gz → 0.2.0__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.
- contextpress-0.2.0/CHANGELOG.md +17 -0
- contextpress-0.2.0/CONTRIBUTING.md +61 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/PKG-INFO +9 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/README.md +8 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/__init__.py +10 -3
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/compression.py +1 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/core.py +14 -5
- contextpress-0.2.0/contextpress/llm/_helpers.py +57 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/adapters.py +53 -4
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/models.py +3 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/normalizer.py +25 -8
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/pipeline.py +49 -9
- contextpress-0.2.0/contextpress/stats.py +67 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/filler.py +1 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/recency.py +4 -2
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/repetition.py +3 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/resolution.py +3 -2
- {contextpress-0.1.0 → contextpress-0.2.0}/examples/llm_tier_openai.py +1 -1
- {contextpress-0.1.0 → contextpress-0.2.0}/pyproject.toml +13 -2
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_filler.py +1 -1
- contextpress-0.2.0/tests/test_llm_helpers.py +65 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_models.py +2 -2
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_pipeline.py +8 -4
- contextpress-0.2.0/tests/test_stats.py +40 -0
- contextpress-0.1.0/CHANGELOG.md +0 -10
- {contextpress-0.1.0 → contextpress-0.2.0}/.gitignore +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/CITATION.cff +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/LICENSE +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/NOTICE +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/llm/base.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/profiles.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/py.typed +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/base.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/contextpress/strategies/budget.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_budget.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_normalizer.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_recency.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_repetition.py +0 -0
- {contextpress-0.1.0 → contextpress-0.2.0}/tests/test_resolution.py +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `contextpress` are recorded here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-05-24
|
|
8
|
+
|
|
9
|
+
- **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
|
|
10
|
+
- **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
|
|
11
|
+
- **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
|
|
12
|
+
- Added **`CONTRIBUTING.md`** with contribution and review expectations.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-04-19
|
|
15
|
+
|
|
16
|
+
- Initial release by Taha Azizi.
|
|
17
|
+
- Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Contributing to contextpress
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing. This project is maintained by [Taha Azizi](https://github.com/Taha-azizi) at a **low cadence**. Bug fixes are welcome; large feature work may be better suited to a fork if you need it quickly.
|
|
4
|
+
|
|
5
|
+
## Before you start
|
|
6
|
+
|
|
7
|
+
- Read the [Project Status](README.md#project-status) section in the README.
|
|
8
|
+
- Search [existing issues](https://github.com/Taha-azizi/contextpress/issues) before opening a duplicate.
|
|
9
|
+
- Expect a **2–4 week** review cycle for pull requests.
|
|
10
|
+
|
|
11
|
+
## Branches
|
|
12
|
+
|
|
13
|
+
| Branch | Purpose |
|
|
14
|
+
|--------|---------|
|
|
15
|
+
| **`dev`** | Active development — **branch from here, open PRs here** |
|
|
16
|
+
| **`main`** | Stable releases (merged from `dev` at release time) |
|
|
17
|
+
|
|
18
|
+
After cloning:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git checkout dev
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## How to contribute
|
|
25
|
+
|
|
26
|
+
1. **Fork** the repository and create a feature branch from **`dev`** (not `main`).
|
|
27
|
+
2. **Set up** a local environment:
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e ".[dev]"
|
|
30
|
+
pytest tests -q
|
|
31
|
+
```
|
|
32
|
+
3. **Make focused changes** — one logical change per PR when possible.
|
|
33
|
+
4. **Add or update tests** for behavior you change. Tier 1 tests must stay deterministic (no live LLM calls in `tests/`).
|
|
34
|
+
5. **Open a pull request** targeting **`dev`** with:
|
|
35
|
+
- A short summary of *why* the change is needed
|
|
36
|
+
- How you tested it (`pytest tests -q`, etc.)
|
|
37
|
+
- A note if you changed public API (update `CHANGELOG.md`)
|
|
38
|
+
|
|
39
|
+
## Code guidelines
|
|
40
|
+
|
|
41
|
+
- Match existing style in the file you edit.
|
|
42
|
+
- Do not break the [behavior contract](contextpress/pipeline.py) at the top of `pipeline.py`.
|
|
43
|
+
- System turns stay protected; input is never mutated in place.
|
|
44
|
+
- Keep Tier 1 deterministic — mock LLM backends in tests.
|
|
45
|
+
|
|
46
|
+
## Reporting bugs
|
|
47
|
+
|
|
48
|
+
Open a [GitHub issue](https://github.com/Taha-azizi/contextpress/issues) with:
|
|
49
|
+
|
|
50
|
+
- Python version
|
|
51
|
+
- `contextpress` version (`pip show contextpress`)
|
|
52
|
+
- Minimal input that reproduces the problem
|
|
53
|
+
- Expected vs actual behavior
|
|
54
|
+
|
|
55
|
+
## Feature requests
|
|
56
|
+
|
|
57
|
+
Feature requests may be accepted, deferred, or closed with a suggestion to fork. That is not a rejection of the idea — it reflects limited maintenance bandwidth.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextpress
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
|
|
5
5
|
Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
|
|
6
6
|
Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
|
|
@@ -282,6 +282,14 @@ compressed = cm.compress(messages, token_budget=2000)
|
|
|
282
282
|
|
|
283
283
|
No API keys are required for Tier 1. Passing **`token_budget`** turns on the **budget** stage; other stages follow the chosen **compression** preset (`low` / `medium` / `high`).
|
|
284
284
|
|
|
285
|
+
Pass **`return_stats=True`** to get a `CompressionResult` with `messages` and compression stats (token counts, stages run, turn deltas):
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
result = cm.compress(messages, token_budget=2000, return_stats=True)
|
|
289
|
+
print(result.stats.tokens_saved, result.stats.stages_run)
|
|
290
|
+
compressed = result.messages
|
|
291
|
+
```
|
|
292
|
+
|
|
285
293
|
### Minimal examples
|
|
286
294
|
|
|
287
295
|
```python
|
|
@@ -42,6 +42,14 @@ compressed = cm.compress(messages, token_budget=2000)
|
|
|
42
42
|
|
|
43
43
|
No API keys are required for Tier 1. Passing **`token_budget`** turns on the **budget** stage; other stages follow the chosen **compression** preset (`low` / `medium` / `high`).
|
|
44
44
|
|
|
45
|
+
Pass **`return_stats=True`** to get a `CompressionResult` with `messages` and compression stats (token counts, stages run, turn deltas):
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
result = cm.compress(messages, token_budget=2000, return_stats=True)
|
|
49
|
+
print(result.stats.tokens_saved, result.stats.stages_run)
|
|
50
|
+
compressed = result.messages
|
|
51
|
+
```
|
|
52
|
+
|
|
45
53
|
### Minimal examples
|
|
46
54
|
|
|
47
55
|
```python
|
|
@@ -4,15 +4,22 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
from contextpress._bootstrap import bootstrap_nltk
|
|
6
6
|
from contextpress.models import ContentBlock, Conversation, Turn
|
|
7
|
+
from contextpress.stats import CompressionResult, CompressionStats
|
|
7
8
|
|
|
8
9
|
bootstrap_nltk()
|
|
9
10
|
|
|
10
|
-
__all__ = [
|
|
11
|
-
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ContextManager",
|
|
13
|
+
"Turn",
|
|
14
|
+
"Conversation",
|
|
15
|
+
"ContentBlock",
|
|
16
|
+
"CompressionResult",
|
|
17
|
+
"CompressionStats",
|
|
18
|
+
]
|
|
19
|
+
__version__ = "0.2.0"
|
|
12
20
|
|
|
13
21
|
|
|
14
22
|
def __getattr__(name: str) -> Any:
|
|
15
|
-
# Lazy import: ContextManager -> core -> pipeline -> strategies (sumy, etc.)
|
|
16
23
|
if name == "ContextManager":
|
|
17
24
|
from contextpress.core import ContextManager
|
|
18
25
|
|
|
@@ -8,6 +8,7 @@ from contextpress.compression import apply_stage_selection, normalize_compressio
|
|
|
8
8
|
from contextpress.normalizer import denormalize_output, normalize_messages
|
|
9
9
|
from contextpress.pipeline import Pipeline
|
|
10
10
|
from contextpress.profiles import PROFILES, Profile, StageConfig
|
|
11
|
+
from contextpress.stats import CompressionResult, CompressionStats
|
|
11
12
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
from contextpress.llm.base import LLMBackend
|
|
@@ -57,23 +58,27 @@ class ContextManager:
|
|
|
57
58
|
compression: str | None = None,
|
|
58
59
|
stages: list[str] | None = None,
|
|
59
60
|
disable: list[str] | None = None,
|
|
60
|
-
|
|
61
|
+
return_stats: bool = False,
|
|
62
|
+
) -> Any | CompressionResult:
|
|
61
63
|
"""Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
|
|
62
64
|
|
|
63
65
|
``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
|
|
66
|
+
With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
|
|
64
67
|
"""
|
|
65
68
|
_validate_token_budget(token_budget)
|
|
69
|
+
level = compression if compression is not None else self._compression
|
|
66
70
|
profile = copy.deepcopy(self._profile)
|
|
67
71
|
apply_stage_selection(
|
|
68
72
|
profile,
|
|
69
73
|
base_profile=self._profile,
|
|
70
|
-
compression=
|
|
74
|
+
compression=level,
|
|
71
75
|
stages=stages,
|
|
72
76
|
disable=disable,
|
|
73
77
|
token_budget=token_budget,
|
|
74
78
|
)
|
|
75
79
|
|
|
76
80
|
conv, ctx = normalize_messages(messages, context_type=self._type)
|
|
81
|
+
stats = CompressionStats(compression_level=level) if return_stats else None
|
|
77
82
|
pipeline = Pipeline(
|
|
78
83
|
profile,
|
|
79
84
|
token_budget=token_budget,
|
|
@@ -82,15 +87,19 @@ class ContextManager:
|
|
|
82
87
|
llm_min_input_chars=self.llm_min_input_chars,
|
|
83
88
|
llm_max_summary_tokens=self.llm_max_summary_tokens,
|
|
84
89
|
)
|
|
85
|
-
out = pipeline.run(conv)
|
|
86
|
-
|
|
90
|
+
out = pipeline.run(conv, stats=stats)
|
|
91
|
+
messages_out = denormalize_output(out, ctx)
|
|
92
|
+
if return_stats:
|
|
93
|
+
assert stats is not None
|
|
94
|
+
return CompressionResult(messages=messages_out, stats=stats)
|
|
95
|
+
return messages_out
|
|
87
96
|
|
|
88
97
|
def set_compression(self, compression: str) -> None:
|
|
89
98
|
"""Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
|
|
90
99
|
self._compression = normalize_compression_level(compression)
|
|
91
100
|
|
|
92
101
|
def configure(self, stage: str, **kwargs: Any) -> None:
|
|
93
|
-
"""Patch ``StageConfig`` fields on the live profile (e.g.
|
|
102
|
+
"""Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
|
|
94
103
|
if not hasattr(self._profile, stage):
|
|
95
104
|
raise ValueError(f"unknown stage {stage!r}")
|
|
96
105
|
sc: StageConfig = getattr(self._profile, stage)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared helpers for Tier 2 LLM adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_keep_indices(raw: str, n: int) -> list[int]:
|
|
10
|
+
"""Parse an LLM response into sorted unique turn indices to keep."""
|
|
11
|
+
if n <= 0:
|
|
12
|
+
return []
|
|
13
|
+
if n == 1:
|
|
14
|
+
return [0]
|
|
15
|
+
|
|
16
|
+
text = (raw or "").strip()
|
|
17
|
+
if not text:
|
|
18
|
+
return list(range(n))
|
|
19
|
+
|
|
20
|
+
# JSON array: [0, 2, 4]
|
|
21
|
+
if text.startswith("["):
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(text)
|
|
24
|
+
if isinstance(data, list):
|
|
25
|
+
return _validate_indices(data, n)
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Comma / space separated: 0, 2, 4
|
|
30
|
+
nums = re.findall(r"\d+", text)
|
|
31
|
+
if nums:
|
|
32
|
+
return _validate_indices([int(x) for x in nums], n)
|
|
33
|
+
|
|
34
|
+
return list(range(n))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_indices(values: list[int], n: int) -> list[int]:
|
|
38
|
+
valid = sorted({i for i in values if isinstance(i, int) and 0 <= i < n})
|
|
39
|
+
return valid if valid else list(range(n))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_numbered_turns(turns: list[str], *, max_chars: int = 800) -> str:
|
|
43
|
+
lines: list[str] = []
|
|
44
|
+
for i, text in enumerate(turns):
|
|
45
|
+
snippet = text.strip().replace("\n", " ")
|
|
46
|
+
if len(snippet) > max_chars:
|
|
47
|
+
snippet = snippet[: max_chars - 3] + "..."
|
|
48
|
+
lines.append(f"{i}: {snippet}")
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
DEDUP_SYSTEM_PROMPT = (
|
|
53
|
+
"You deduplicate conversation turns. Given numbered turns, return ONLY the 0-based "
|
|
54
|
+
"indices of turns to KEEP as a comma-separated list (example: 0,2,5). "
|
|
55
|
+
"When turns repeat the same idea, keep the most recent index. "
|
|
56
|
+
"Always keep at least one turn. Output nothing else."
|
|
57
|
+
)
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import warnings
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
from contextpress.llm._helpers import DEDUP_SYSTEM_PROMPT, format_numbered_turns, parse_keep_indices
|
|
7
7
|
from contextpress.llm.base import LLMBackend
|
|
8
8
|
|
|
9
9
|
|
|
@@ -60,7 +60,24 @@ class OpenAIBackend(LLMBackend):
|
|
|
60
60
|
raise
|
|
61
61
|
|
|
62
62
|
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
63
|
-
|
|
63
|
+
if len(turns) <= 1:
|
|
64
|
+
return list(range(len(turns)))
|
|
65
|
+
prompt = format_numbered_turns(turns)
|
|
66
|
+
try:
|
|
67
|
+
resp = self.client.chat.completions.create(
|
|
68
|
+
model=self.model,
|
|
69
|
+
messages=[
|
|
70
|
+
{"role": "system", "content": DEDUP_SYSTEM_PROMPT},
|
|
71
|
+
{"role": "user", "content": prompt},
|
|
72
|
+
],
|
|
73
|
+
max_tokens=128,
|
|
74
|
+
temperature=0,
|
|
75
|
+
)
|
|
76
|
+
content = resp.choices[0].message.content or ""
|
|
77
|
+
return parse_keep_indices(content, len(turns))
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
warnings.warn(f"contextpress OpenAIBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
80
|
+
return list(range(len(turns)))
|
|
64
81
|
|
|
65
82
|
|
|
66
83
|
class AnthropicBackend(LLMBackend):
|
|
@@ -98,7 +115,24 @@ class AnthropicBackend(LLMBackend):
|
|
|
98
115
|
raise
|
|
99
116
|
|
|
100
117
|
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
101
|
-
|
|
118
|
+
if len(turns) <= 1:
|
|
119
|
+
return list(range(len(turns)))
|
|
120
|
+
prompt = format_numbered_turns(turns)
|
|
121
|
+
try:
|
|
122
|
+
msg = self.client.messages.create(
|
|
123
|
+
model=self.model,
|
|
124
|
+
max_tokens=128,
|
|
125
|
+
system=DEDUP_SYSTEM_PROMPT,
|
|
126
|
+
messages=[{"role": "user", "content": prompt}],
|
|
127
|
+
)
|
|
128
|
+
parts = []
|
|
129
|
+
for b in msg.content:
|
|
130
|
+
if hasattr(b, "text"):
|
|
131
|
+
parts.append(b.text)
|
|
132
|
+
return parse_keep_indices("".join(parts), len(turns))
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
warnings.warn(f"contextpress AnthropicBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
135
|
+
return list(range(len(turns)))
|
|
102
136
|
|
|
103
137
|
|
|
104
138
|
class OllamaBackend(LLMBackend):
|
|
@@ -171,4 +205,19 @@ class OllamaBackend(LLMBackend):
|
|
|
171
205
|
raise
|
|
172
206
|
|
|
173
207
|
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
174
|
-
|
|
208
|
+
if len(turns) <= 1:
|
|
209
|
+
return list(range(len(turns)))
|
|
210
|
+
prompt = format_numbered_turns(turns)
|
|
211
|
+
try:
|
|
212
|
+
resp = self._client.chat(
|
|
213
|
+
model=self.model,
|
|
214
|
+
messages=[
|
|
215
|
+
{"role": "system", "content": DEDUP_SYSTEM_PROMPT},
|
|
216
|
+
{"role": "user", "content": prompt},
|
|
217
|
+
],
|
|
218
|
+
options={"num_predict": 128, "temperature": 0},
|
|
219
|
+
)
|
|
220
|
+
return parse_keep_indices(_ollama_response_text(resp), len(turns))
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
warnings.warn(f"contextpress OllamaBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
223
|
+
return list(range(len(turns)))
|
|
@@ -32,7 +32,9 @@ class Turn:
|
|
|
32
32
|
importance: float = 1.0 # set by pipeline stages, 0.0–1.0
|
|
33
33
|
resolved: bool = False # True when flagged by resolution detector
|
|
34
34
|
compressed: bool = False # True if content was modified by pipeline
|
|
35
|
-
original_content: str | list[ContentBlock] | None =
|
|
35
|
+
original_content: str | list[ContentBlock] | None = (
|
|
36
|
+
None # preserves original before compression
|
|
37
|
+
)
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
@dataclass
|
|
@@ -52,8 +52,14 @@ def _blocks_from_openai_style(items: list[dict[str, Any]]) -> list[ContentBlock]
|
|
|
52
52
|
url = ""
|
|
53
53
|
if "image_url" in item:
|
|
54
54
|
iu = item["image_url"]
|
|
55
|
-
url =
|
|
56
|
-
|
|
55
|
+
url = (
|
|
56
|
+
iu
|
|
57
|
+
if isinstance(iu, str)
|
|
58
|
+
else (iu.get("url", "") if isinstance(iu, dict) else "")
|
|
59
|
+
)
|
|
60
|
+
blocks.append(
|
|
61
|
+
ContentBlock(type="image", content=url or str(item), metadata=copy.deepcopy(item))
|
|
62
|
+
)
|
|
57
63
|
else:
|
|
58
64
|
blocks.append(
|
|
59
65
|
ContentBlock(
|
|
@@ -83,12 +89,13 @@ def _blocks_to_openai_style(blocks: list[ContentBlock]) -> list[dict[str, Any]]:
|
|
|
83
89
|
else:
|
|
84
90
|
out.append({"type": "image_url", "image_url": {"url": b.content}})
|
|
85
91
|
else:
|
|
86
|
-
out.append(
|
|
92
|
+
out.append(
|
|
93
|
+
copy.deepcopy(b.metadata) if b.metadata else {"type": b.type, "content": b.content}
|
|
94
|
+
)
|
|
87
95
|
return out
|
|
88
96
|
|
|
89
97
|
|
|
90
98
|
def _get_lc_role_and_content(obj: Any) -> tuple[str, str | list[ContentBlock], dict[str, Any]]:
|
|
91
|
-
meta: dict[str, Any] = {}
|
|
92
99
|
role = None
|
|
93
100
|
if hasattr(obj, "type") and obj.type is not None:
|
|
94
101
|
role = _LC_TYPE_MAP.get(str(obj.type).lower(), str(obj.type).lower())
|
|
@@ -101,7 +108,11 @@ def _get_lc_role_and_content(obj: Any) -> tuple[str, str | list[ContentBlock], d
|
|
|
101
108
|
blocks = []
|
|
102
109
|
for part in content:
|
|
103
110
|
if isinstance(part, dict) and part.get("type") == "text":
|
|
104
|
-
blocks.append(
|
|
111
|
+
blocks.append(
|
|
112
|
+
ContentBlock(
|
|
113
|
+
type="text", content=part.get("text", ""), metadata=copy.deepcopy(part)
|
|
114
|
+
)
|
|
115
|
+
)
|
|
105
116
|
elif isinstance(part, str):
|
|
106
117
|
blocks.append(ContentBlock(type="text", content=part))
|
|
107
118
|
else:
|
|
@@ -176,7 +187,9 @@ def normalize_messages(
|
|
|
176
187
|
turns = []
|
|
177
188
|
for i, raw in enumerate(messages):
|
|
178
189
|
if not isinstance(raw, dict):
|
|
179
|
-
warnings.warn(
|
|
190
|
+
warnings.warn(
|
|
191
|
+
f"contextpress: unknown message shape at index {i}, skipping", stacklevel=2
|
|
192
|
+
)
|
|
180
193
|
continue
|
|
181
194
|
d = copy.deepcopy(raw)
|
|
182
195
|
role = str(d.get("role", "user")).lower()
|
|
@@ -292,7 +305,9 @@ def apply_text_to_turn(turn: Turn, new_text: str) -> Turn:
|
|
|
292
305
|
importance=turn.importance,
|
|
293
306
|
resolved=turn.resolved,
|
|
294
307
|
compressed=True,
|
|
295
|
-
original_content=
|
|
308
|
+
original_content=(
|
|
309
|
+
turn.original_content if turn.original_content is not None else turn.content
|
|
310
|
+
),
|
|
296
311
|
)
|
|
297
312
|
new_blocks: list[ContentBlock] = []
|
|
298
313
|
text_assigned = False
|
|
@@ -304,7 +319,9 @@ def apply_text_to_turn(turn: Turn, new_text: str) -> Turn:
|
|
|
304
319
|
new_blocks.append(copy.deepcopy(b))
|
|
305
320
|
if not text_assigned:
|
|
306
321
|
new_blocks.insert(0, ContentBlock(type="text", content=new_text))
|
|
307
|
-
orig =
|
|
322
|
+
orig = (
|
|
323
|
+
turn.original_content if turn.original_content is not None else copy.deepcopy(turn.content)
|
|
324
|
+
)
|
|
308
325
|
return Turn(
|
|
309
326
|
role=turn.role,
|
|
310
327
|
content=new_blocks,
|
|
@@ -11,8 +11,10 @@ CONTEXTPRESS BEHAVIOR CONTRACT
|
|
|
11
11
|
8. Tier 1 (no LLM) behavior is ALWAYS deterministic. Tests must pass consistently.
|
|
12
12
|
9. LLM backend failures fall back to Tier 1 and emit a warning.
|
|
13
13
|
10. token_budget=None means run all stages but skip budget enforcement.
|
|
14
|
-
11. Compression presets (low/medium/high) select non-budget stages; budget runs when
|
|
15
|
-
|
|
14
|
+
11. Compression presets (low/medium/high) select non-budget stages; budget runs when
|
|
15
|
+
token_budget is set unless opted out.
|
|
16
|
+
12. Tier 2 (when enabled) may dedupe non-system turns, then replace them with one assistant
|
|
17
|
+
summary; system turns stay unchanged.
|
|
16
18
|
"""
|
|
17
19
|
|
|
18
20
|
from __future__ import annotations
|
|
@@ -25,6 +27,7 @@ from contextpress.compression import STAGE_ORDER
|
|
|
25
27
|
from contextpress.models import Conversation, Turn
|
|
26
28
|
from contextpress.normalizer import extract_text_for_processing
|
|
27
29
|
from contextpress.profiles import Profile, StageConfig
|
|
30
|
+
from contextpress.stats import CompressionStats, count_conversation_tokens
|
|
28
31
|
from contextpress.strategies.base import BaseStrategy
|
|
29
32
|
from contextpress.strategies.budget import BudgetStrategy
|
|
30
33
|
from contextpress.strategies.filler import FillerStrategy
|
|
@@ -49,9 +52,9 @@ def clone_turn(t: Turn) -> Turn:
|
|
|
49
52
|
importance=t.importance,
|
|
50
53
|
resolved=t.resolved,
|
|
51
54
|
compressed=t.compressed,
|
|
52
|
-
original_content=
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
original_content=(
|
|
56
|
+
copy.deepcopy(t.original_content) if t.original_content is not None else None
|
|
57
|
+
),
|
|
55
58
|
)
|
|
56
59
|
|
|
57
60
|
|
|
@@ -85,7 +88,17 @@ class Pipeline:
|
|
|
85
88
|
self.llm_min_input_chars = max(0, int(llm_min_input_chars))
|
|
86
89
|
self.llm_max_summary_tokens = max(64, int(llm_max_summary_tokens))
|
|
87
90
|
|
|
88
|
-
def run(
|
|
91
|
+
def run(
|
|
92
|
+
self,
|
|
93
|
+
conversation: Conversation,
|
|
94
|
+
stats: CompressionStats | None = None,
|
|
95
|
+
) -> Conversation:
|
|
96
|
+
if stats is not None:
|
|
97
|
+
stats.turns_before = len(conversation.turns)
|
|
98
|
+
stats.tokens_before = count_conversation_tokens(conversation, self.model)
|
|
99
|
+
stats.context_type = conversation.type
|
|
100
|
+
stats.token_budget = self.token_budget
|
|
101
|
+
|
|
89
102
|
result = clone_conversation(conversation)
|
|
90
103
|
for stage_name in self.STAGE_ORDER:
|
|
91
104
|
if stage_name == "budget" and self.token_budget is None:
|
|
@@ -93,11 +106,21 @@ class Pipeline:
|
|
|
93
106
|
stage_config = getattr(self.profile, stage_name)
|
|
94
107
|
if not stage_config.enabled:
|
|
95
108
|
continue
|
|
109
|
+
before_turns = len(result.turns)
|
|
96
110
|
strategy = self._build_strategy(stage_name, stage_config)
|
|
97
111
|
result = strategy.process(result)
|
|
112
|
+
if stats is not None:
|
|
113
|
+
stats.stages_run.append(stage_name)
|
|
114
|
+
delta = len(result.turns) - before_turns
|
|
115
|
+
if delta != 0:
|
|
116
|
+
stats.turn_delta_by_stage[stage_name] = delta
|
|
98
117
|
|
|
99
118
|
if self.llm_backend is not None:
|
|
100
|
-
result = self._run_llm_tier(result)
|
|
119
|
+
result = self._run_llm_tier(result, stats=stats)
|
|
120
|
+
|
|
121
|
+
if stats is not None:
|
|
122
|
+
stats.turns_after = len(result.turns)
|
|
123
|
+
stats.tokens_after = count_conversation_tokens(result, self.model)
|
|
101
124
|
|
|
102
125
|
return result
|
|
103
126
|
|
|
@@ -125,7 +148,11 @@ class Pipeline:
|
|
|
125
148
|
)
|
|
126
149
|
raise ValueError(f"unknown stage {name!r}")
|
|
127
150
|
|
|
128
|
-
def _run_llm_tier(
|
|
151
|
+
def _run_llm_tier(
|
|
152
|
+
self,
|
|
153
|
+
conversation: Conversation,
|
|
154
|
+
stats: CompressionStats | None = None,
|
|
155
|
+
) -> Conversation:
|
|
129
156
|
if self.llm_backend is None:
|
|
130
157
|
return conversation
|
|
131
158
|
|
|
@@ -135,13 +162,20 @@ class Pipeline:
|
|
|
135
162
|
if not ns_turns:
|
|
136
163
|
return conversation
|
|
137
164
|
|
|
165
|
+
if stats is not None:
|
|
166
|
+
stats.llm_dedup_turns_before = len(ns_turns)
|
|
167
|
+
|
|
138
168
|
texts = [extract_text_for_processing(t) for t in ns_turns]
|
|
139
169
|
try:
|
|
140
170
|
keep_idx = self.llm_backend.deduplicate(texts)
|
|
141
171
|
except Exception:
|
|
142
172
|
keep_idx = list(range(len(ns_turns)))
|
|
143
173
|
valid = sorted(
|
|
144
|
-
{
|
|
174
|
+
{
|
|
175
|
+
i
|
|
176
|
+
for i in keep_idx
|
|
177
|
+
if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)
|
|
178
|
+
}
|
|
145
179
|
)
|
|
146
180
|
if not valid:
|
|
147
181
|
valid = list(range(len(ns_turns)))
|
|
@@ -149,6 +183,9 @@ class Pipeline:
|
|
|
149
183
|
ns_turns = [ns_turns[i] for i in valid]
|
|
150
184
|
texts = [texts[i] for i in valid]
|
|
151
185
|
|
|
186
|
+
if stats is not None:
|
|
187
|
+
stats.llm_dedup_turns_after = len(ns_turns)
|
|
188
|
+
|
|
152
189
|
lines = [f"{t.role}: {txt}" for t, txt in zip(ns_turns, texts, strict=True)]
|
|
153
190
|
combined = "\n\n".join(lines)
|
|
154
191
|
if self.llm_min_input_chars > 0 and len(combined) < self.llm_min_input_chars:
|
|
@@ -167,6 +204,9 @@ class Pipeline:
|
|
|
167
204
|
if not summary:
|
|
168
205
|
return conversation
|
|
169
206
|
|
|
207
|
+
if stats is not None:
|
|
208
|
+
stats.llm_tier_applied = True
|
|
209
|
+
|
|
170
210
|
new_turns: list[Turn] = list(system_turns)
|
|
171
211
|
new_turns.append(
|
|
172
212
|
Turn(
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Compression statistics returned when ``return_stats=True``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import tiktoken
|
|
9
|
+
|
|
10
|
+
from contextpress.models import Conversation, Turn
|
|
11
|
+
from contextpress.normalizer import extract_text_for_processing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_encoding(model: str | None) -> tiktoken.Encoding:
|
|
15
|
+
if model:
|
|
16
|
+
try:
|
|
17
|
+
return tiktoken.encoding_for_model(model)
|
|
18
|
+
except KeyError:
|
|
19
|
+
pass
|
|
20
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def count_turn_tokens(turn: Turn, encoding: tiktoken.Encoding) -> int:
|
|
24
|
+
if isinstance(turn.content, str):
|
|
25
|
+
body = turn.content
|
|
26
|
+
else:
|
|
27
|
+
body = extract_text_for_processing(turn)
|
|
28
|
+
return len(encoding.encode(f"{turn.role}\n{body}"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def count_conversation_tokens(conversation: Conversation, model: str | None) -> int:
|
|
32
|
+
enc = get_encoding(model)
|
|
33
|
+
return sum(count_turn_tokens(t, enc) for t in conversation.turns)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CompressionStats:
|
|
38
|
+
"""Observed effects of a single ``compress()`` call."""
|
|
39
|
+
|
|
40
|
+
turns_before: int = 0
|
|
41
|
+
turns_after: int = 0
|
|
42
|
+
tokens_before: int = 0
|
|
43
|
+
tokens_after: int = 0
|
|
44
|
+
stages_run: list[str] = field(default_factory=list)
|
|
45
|
+
turn_delta_by_stage: dict[str, int] = field(default_factory=dict)
|
|
46
|
+
llm_tier_applied: bool = False
|
|
47
|
+
llm_dedup_turns_before: int = 0
|
|
48
|
+
llm_dedup_turns_after: int = 0
|
|
49
|
+
compression_level: str | None = None
|
|
50
|
+
context_type: str = "chat"
|
|
51
|
+
token_budget: int | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def turns_removed(self) -> int:
|
|
55
|
+
return max(0, self.turns_before - self.turns_after)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def tokens_saved(self) -> int:
|
|
59
|
+
return max(0, self.tokens_before - self.tokens_after)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CompressionResult:
|
|
64
|
+
"""Messages plus stats when ``return_stats=True``."""
|
|
65
|
+
|
|
66
|
+
messages: Any
|
|
67
|
+
stats: CompressionStats
|
|
@@ -94,7 +94,7 @@ def _build_filler_pattern() -> re.Pattern[str]:
|
|
|
94
94
|
|
|
95
95
|
_FILLER_RE = _build_filler_pattern()
|
|
96
96
|
|
|
97
|
-
#
|
|
97
|
+
# Keep "actually" when it starts a phrase like "actually, no" (per spec).
|
|
98
98
|
_ACTUALLY_NO = re.compile(r"^\s*actually\s*,\s*no\b", re.IGNORECASE)
|
|
99
99
|
|
|
100
100
|
|
|
@@ -114,14 +114,16 @@ class RecencyStrategy(BaseStrategy):
|
|
|
114
114
|
else:
|
|
115
115
|
processed_by_ns.append(copy.deepcopy(t))
|
|
116
116
|
|
|
117
|
-
by_idx = dict(zip(ns_indices, processed_by_ns))
|
|
117
|
+
by_idx = dict(zip(ns_indices, processed_by_ns, strict=True))
|
|
118
118
|
out: list[Turn] = []
|
|
119
119
|
for i, t in enumerate(conversation.turns):
|
|
120
120
|
if i in by_idx:
|
|
121
121
|
out.append(by_idx[i])
|
|
122
122
|
else:
|
|
123
123
|
out.append(copy.deepcopy(t))
|
|
124
|
-
return Conversation(
|
|
124
|
+
return Conversation(
|
|
125
|
+
turns=out, type=conversation.type, metadata=copy.deepcopy(conversation.metadata)
|
|
126
|
+
)
|
|
125
127
|
|
|
126
128
|
def _relevance_score(self, query: str, chunk: str) -> float:
|
|
127
129
|
if not query.strip() or not chunk.strip():
|
|
@@ -38,7 +38,9 @@ class RepetitionStrategy(BaseStrategy):
|
|
|
38
38
|
indices_to_drop: set[int] = set()
|
|
39
39
|
|
|
40
40
|
# Positions of non-system turns with their global index
|
|
41
|
-
nst: list[tuple[int, Turn]] = [
|
|
41
|
+
nst: list[tuple[int, Turn]] = [
|
|
42
|
+
(i, t) for i, t in enumerate(turns) if not self._is_protected(t)
|
|
43
|
+
]
|
|
42
44
|
|
|
43
45
|
def run_group(group: list[tuple[int, Turn]]) -> None:
|
|
44
46
|
if len(group) < 2:
|
|
@@ -166,14 +166,15 @@ class ResolutionStrategy(BaseStrategy):
|
|
|
166
166
|
res_text = extract_text_for_processing(turns[res_idx])
|
|
167
167
|
|
|
168
168
|
if self.conv_type == "chat":
|
|
169
|
-
# Include one trailing turn so an assistant
|
|
169
|
+
# Include one trailing turn so an assistant ack after a user's resolution still counts.
|
|
170
170
|
scan_end = min(len(turns) - 1, res_idx + 1)
|
|
171
171
|
has_user = any(
|
|
172
172
|
turns[i].role == "user" and _has_signal_any(extract_text_for_processing(turns[i]))
|
|
173
173
|
for i in range(thread_start, scan_end + 1)
|
|
174
174
|
)
|
|
175
175
|
has_asst = any(
|
|
176
|
-
turns[i].role == "assistant"
|
|
176
|
+
turns[i].role == "assistant"
|
|
177
|
+
and _has_signal_any(extract_text_for_processing(turns[i]))
|
|
177
178
|
for i in range(thread_start, scan_end + 1)
|
|
178
179
|
)
|
|
179
180
|
if not (has_user and has_asst):
|
|
@@ -31,7 +31,7 @@ def main() -> None:
|
|
|
31
31
|
messages = [
|
|
32
32
|
{"role": "system", "content": "You are a concise assistant."},
|
|
33
33
|
{"role": "user", "content": "What is Python? " + ("Explain briefly. " * 200)},
|
|
34
|
-
{"role": "assistant", "content": "Python is a programming language. " *150},
|
|
34
|
+
{"role": "assistant", "content": "Python is a programming language. " * 150},
|
|
35
35
|
{"role": "user", "content": "And numpy? " * 80},
|
|
36
36
|
]
|
|
37
37
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "contextpress"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -59,4 +59,15 @@ packages = ["contextpress"]
|
|
|
59
59
|
"contextpress/py.typed" = "contextpress/py.typed"
|
|
60
60
|
|
|
61
61
|
[tool.hatch.build.targets.sdist]
|
|
62
|
-
include = ["/contextpress", "/tests", "/examples", "/README.md", "/LICENSE", "/NOTICE", "/CHANGELOG.md", "/CITATION.cff"]
|
|
62
|
+
include = ["/contextpress", "/tests", "/examples", "/README.md", "/LICENSE", "/NOTICE", "/CHANGELOG.md", "/CITATION.cff", "/CONTRIBUTING.md"]
|
|
63
|
+
|
|
64
|
+
[tool.black]
|
|
65
|
+
line-length = 100
|
|
66
|
+
target-version = ["py310", "py311", "py312", "py313"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
target-version = "py310"
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = ["E", "F", "I", "W", "UP", "B"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from contextpress.llm._helpers import parse_keep_indices
|
|
6
|
+
from contextpress.llm.adapters import AnthropicBackend, OllamaBackend, OpenAIBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_parse_keep_indices_comma_separated():
|
|
10
|
+
assert parse_keep_indices("0, 2, 4", 6) == [0, 2, 4]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_parse_keep_indices_json_array():
|
|
14
|
+
assert parse_keep_indices("[1, 3]", 4) == [1, 3]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_keep_indices_empty_falls_back_to_all():
|
|
18
|
+
assert parse_keep_indices("", 3) == [0, 1, 2]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_parse_keep_indices_invalid_falls_back_to_all():
|
|
22
|
+
assert parse_keep_indices("keep everything please", 2) == [0, 1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_openai_deduplicate_parses_response():
|
|
26
|
+
client = MagicMock()
|
|
27
|
+
client.chat.completions.create.return_value = MagicMock(
|
|
28
|
+
choices=[MagicMock(message=MagicMock(content="0, 2"))]
|
|
29
|
+
)
|
|
30
|
+
b = OpenAIBackend(client=client)
|
|
31
|
+
assert b.deduplicate(["a", "b", "c", "d"]) == [0, 2]
|
|
32
|
+
client.chat.completions.create.assert_called_once()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_openai_deduplicate_single_turn_skips_llm():
|
|
36
|
+
client = MagicMock()
|
|
37
|
+
b = OpenAIBackend(client=client)
|
|
38
|
+
assert b.deduplicate(["only one"]) == [0]
|
|
39
|
+
client.chat.completions.create.assert_not_called()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_openai_deduplicate_error_keeps_all():
|
|
43
|
+
client = MagicMock()
|
|
44
|
+
client.chat.completions.create.side_effect = RuntimeError("network")
|
|
45
|
+
b = OpenAIBackend(client=client)
|
|
46
|
+
with pytest.warns(UserWarning):
|
|
47
|
+
assert b.deduplicate(["a", "b", "c"]) == [0, 1, 2]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_anthropic_deduplicate_parses_response():
|
|
51
|
+
client = MagicMock()
|
|
52
|
+
block = MagicMock()
|
|
53
|
+
block.text = "0,1"
|
|
54
|
+
msg = MagicMock()
|
|
55
|
+
msg.content = [block]
|
|
56
|
+
client.messages.create.return_value = msg
|
|
57
|
+
b = AnthropicBackend(client=client)
|
|
58
|
+
assert b.deduplicate(["x", "y", "z"]) == [0, 1]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_ollama_deduplicate_parses_response():
|
|
62
|
+
client = MagicMock()
|
|
63
|
+
client.chat.return_value = {"message": {"content": "0, 2"}}
|
|
64
|
+
b = OllamaBackend(client=client, model="llama3.2")
|
|
65
|
+
assert b.deduplicate(["a", "b", "c"]) == [0, 2]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from datetime import
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
2
|
|
|
3
3
|
from contextpress.models import ContentBlock, Conversation, Turn
|
|
4
4
|
|
|
@@ -22,5 +22,5 @@ def test_non_system_turns():
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def test_timestamp_optional():
|
|
25
|
-
t = Turn(role="user", content="x", timestamp=datetime.now(
|
|
25
|
+
t = Turn(role="user", content="x", timestamp=datetime.now(timezone.utc))
|
|
26
26
|
assert t.timestamp is not None
|
|
@@ -40,7 +40,10 @@ def test_disable_resolution():
|
|
|
40
40
|
|
|
41
41
|
def test_system_survives():
|
|
42
42
|
cm = ContextManager(type="chat")
|
|
43
|
-
messages = [
|
|
43
|
+
messages = [
|
|
44
|
+
{"role": "system", "content": "You are helpful."},
|
|
45
|
+
{"role": "user", "content": "hi"},
|
|
46
|
+
]
|
|
44
47
|
r = cm.compress(messages, token_budget=500)
|
|
45
48
|
assert r[0]["role"] == "system"
|
|
46
49
|
assert r[0]["content"] == "You are helpful."
|
|
@@ -216,11 +219,12 @@ def test_anthropic_backend_summarize_warns_on_error():
|
|
|
216
219
|
b.summarize("x", 10)
|
|
217
220
|
|
|
218
221
|
|
|
219
|
-
def
|
|
222
|
+
def test_adapter_deduplicate_single_turn():
|
|
220
223
|
client = MagicMock()
|
|
221
|
-
assert OpenAIBackend(client=client).deduplicate(["a"
|
|
224
|
+
assert OpenAIBackend(client=client).deduplicate(["a"]) == [0]
|
|
222
225
|
assert AnthropicBackend(client=client).deduplicate(["a"]) == [0]
|
|
223
|
-
assert OllamaBackend(client=client, model="x").deduplicate(["a"
|
|
226
|
+
assert OllamaBackend(client=client, model="x").deduplicate(["a"]) == [0]
|
|
227
|
+
client.chat.completions.create.assert_not_called()
|
|
224
228
|
|
|
225
229
|
|
|
226
230
|
def test_ollama_backend_summarize_dict_response():
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from contextpress import CompressionResult, ContextManager
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_compress_without_stats_returns_list():
|
|
5
|
+
cm = ContextManager(type="chat")
|
|
6
|
+
messages = [{"role": "user", "content": "hello basically there"}]
|
|
7
|
+
out = cm.compress(messages, token_budget=500)
|
|
8
|
+
assert isinstance(out, list)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_compress_with_stats_returns_result():
|
|
12
|
+
cm = ContextManager(type="chat")
|
|
13
|
+
messages = [
|
|
14
|
+
{"role": "system", "content": "You are helpful."},
|
|
15
|
+
{"role": "user", "content": "Old question: " + "blah " * 80},
|
|
16
|
+
{"role": "assistant", "content": "Basically, honestly, " + "yak " * 80},
|
|
17
|
+
{"role": "user", "content": "What is 2+2?"},
|
|
18
|
+
{"role": "assistant", "content": "Four."},
|
|
19
|
+
]
|
|
20
|
+
result = cm.compress(messages, token_budget=120, return_stats=True)
|
|
21
|
+
assert isinstance(result, CompressionResult)
|
|
22
|
+
assert isinstance(result.messages, list)
|
|
23
|
+
assert result.stats.turns_before == 5
|
|
24
|
+
assert result.stats.turns_after <= result.stats.turns_before
|
|
25
|
+
assert result.stats.tokens_before >= result.stats.tokens_after
|
|
26
|
+
assert result.stats.tokens_saved >= 0
|
|
27
|
+
assert result.stats.compression_level == "medium"
|
|
28
|
+
assert result.stats.context_type == "chat"
|
|
29
|
+
assert result.stats.token_budget == 120
|
|
30
|
+
assert "filler" in result.stats.stages_run
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_stats_turn_delta_by_stage():
|
|
34
|
+
cm = ContextManager(type="chat", compression="low")
|
|
35
|
+
messages = [
|
|
36
|
+
{"role": "user", "content": "hello basically"},
|
|
37
|
+
{"role": "assistant", "content": "sounds good"},
|
|
38
|
+
]
|
|
39
|
+
result = cm.compress(messages, token_budget=None, return_stats=True)
|
|
40
|
+
assert isinstance(result.stats.turn_delta_by_stage, dict)
|
contextpress-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `contextpress` are recorded here.
|
|
4
|
-
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
-
|
|
7
|
-
## [0.1.0] - 2026-04-19
|
|
8
|
-
|
|
9
|
-
- Initial release by Taha Azizi.
|
|
10
|
-
- Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|