contextpress 0.2.0__tar.gz → 0.4.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 → contextpress-0.4.0}/.gitignore +3 -0
- contextpress-0.4.0/CHANGELOG.md +32 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/PKG-INFO +37 -3
- {contextpress-0.2.0 → contextpress-0.4.0}/README.md +36 -2
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/__init__.py +1 -1
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/compression.py +19 -3
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/core.py +91 -6
- contextpress-0.4.0/contextpress/llm/__init__.py +15 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/adapters.py +41 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/pipeline.py +82 -22
- contextpress-0.4.0/contextpress/registry.py +85 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/stats.py +2 -0
- contextpress-0.4.0/contextpress/warnings_capture.py +16 -0
- contextpress-0.4.0/examples/dry_run_preview.py +23 -0
- contextpress-0.4.0/examples/estimate_and_stats.py +21 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/pyproject.toml +1 -1
- contextpress-0.4.0/tests/test_v03.py +128 -0
- contextpress-0.4.0/tests/test_v04.py +51 -0
- contextpress-0.2.0/CHANGELOG.md +0 -17
- contextpress-0.2.0/contextpress/llm/__init__.py +0 -4
- {contextpress-0.2.0 → contextpress-0.4.0}/CITATION.cff +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/CONTRIBUTING.md +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/LICENSE +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/NOTICE +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/_helpers.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/llm/base.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/models.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/normalizer.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/profiles.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/py.typed +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/base.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/budget.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/filler.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/recency.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/repetition.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/contextpress/strategies/resolution.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/examples/llm_tier_openai.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/__init__.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_budget.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_filler.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_llm_helpers.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_models.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_normalizer.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_pipeline.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_recency.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_repetition.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_resolution.py +0 -0
- {contextpress-0.2.0 → contextpress-0.4.0}/tests/test_stats.py +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
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.4.0] - 2026-07-20
|
|
8
|
+
|
|
9
|
+
- **`preview()` / `dry_run=True`** — simulate compression and get stats without changing messages (Tier 1 only; no LLM calls).
|
|
10
|
+
- **`fits_budget()`** — check whether messages would fit a token budget after compression.
|
|
11
|
+
- **`CompressionStats.warnings_emitted`** — pipeline warnings collected during a run.
|
|
12
|
+
- **`OpenAICompatibleBackend`** — vLLM, LM Studio, LocalAI, and other OpenAI-compatible servers.
|
|
13
|
+
- Example: `examples/dry_run_preview.py`.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-07-14
|
|
16
|
+
|
|
17
|
+
- **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
|
|
18
|
+
- **Stage registry** — `ContextManager.register_stage()` for custom `BaseStrategy` stages in `stages=[...]`.
|
|
19
|
+
- **Tier 2 `llm_mode`** — `replace_all` (default), `dedupe_only`, or `summarize_only`.
|
|
20
|
+
- Example: `examples/estimate_and_stats.py`.
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-05-24
|
|
23
|
+
|
|
24
|
+
- **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
|
|
25
|
+
- **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
|
|
26
|
+
- **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
|
|
27
|
+
- Added **`CONTRIBUTING.md`** with contribution and review expectations.
|
|
28
|
+
|
|
29
|
+
## [0.1.0] - 2026-04-19
|
|
30
|
+
|
|
31
|
+
- Initial release by Taha Azizi.
|
|
32
|
+
- Tier 1 pipeline (filler, repetition, resolution, recency, budget) and optional Tier 2 `LLMBackend` (OpenAI, Anthropic, Ollama adapters).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextpress
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -290,6 +290,38 @@ print(result.stats.tokens_saved, result.stats.stages_run)
|
|
|
290
290
|
compressed = result.messages
|
|
291
291
|
```
|
|
292
292
|
|
|
293
|
+
Check token count **before** compressing:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
before = cm.estimate_tokens(messages)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**Preview without changing messages** (0.4+):
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
preview = cm.preview(messages, token_budget=500)
|
|
303
|
+
print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
|
|
304
|
+
assert preview.messages == messages # unchanged
|
|
305
|
+
|
|
306
|
+
if cm.fits_budget(messages, 4000):
|
|
307
|
+
...
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Custom stages (0.3+)
|
|
311
|
+
|
|
312
|
+
Register a `BaseStrategy` and include it in `stages=`:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from contextpress.strategies.base import BaseStrategy
|
|
316
|
+
|
|
317
|
+
class MyStage(BaseStrategy):
|
|
318
|
+
def process(self, conversation):
|
|
319
|
+
...
|
|
320
|
+
|
|
321
|
+
cm.register_stage("my_stage", MyStage)
|
|
322
|
+
out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
|
|
323
|
+
```
|
|
324
|
+
|
|
293
325
|
### Minimal examples
|
|
294
326
|
|
|
295
327
|
```python
|
|
@@ -405,7 +437,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
|
|
|
405
437
|
2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
|
|
406
438
|
3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
|
|
407
439
|
|
|
408
|
-
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
|
|
440
|
+
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
|
|
441
|
+
|
|
442
|
+
**`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
|
|
409
443
|
|
|
410
444
|
**Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
|
|
411
445
|
|
|
@@ -468,7 +502,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
|
|
|
468
502
|
|
|
469
503
|
## Research and citing
|
|
470
504
|
|
|
471
|
-
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff)
|
|
505
|
+
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
|
|
472
506
|
|
|
473
507
|
## Extension and growth
|
|
474
508
|
|
|
@@ -50,6 +50,38 @@ print(result.stats.tokens_saved, result.stats.stages_run)
|
|
|
50
50
|
compressed = result.messages
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
Check token count **before** compressing:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
before = cm.estimate_tokens(messages)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Preview without changing messages** (0.4+):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
preview = cm.preview(messages, token_budget=500)
|
|
63
|
+
print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
|
|
64
|
+
assert preview.messages == messages # unchanged
|
|
65
|
+
|
|
66
|
+
if cm.fits_budget(messages, 4000):
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Custom stages (0.3+)
|
|
71
|
+
|
|
72
|
+
Register a `BaseStrategy` and include it in `stages=`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from contextpress.strategies.base import BaseStrategy
|
|
76
|
+
|
|
77
|
+
class MyStage(BaseStrategy):
|
|
78
|
+
def process(self, conversation):
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
cm.register_stage("my_stage", MyStage)
|
|
82
|
+
out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
|
|
83
|
+
```
|
|
84
|
+
|
|
53
85
|
### Minimal examples
|
|
54
86
|
|
|
55
87
|
```python
|
|
@@ -165,7 +197,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
|
|
|
165
197
|
2. If the combined transcript is long enough (default **1500** characters; set **`llm_min_input_chars=0`** to always run), calls **`summarize(transcript, max_tokens)`**.
|
|
166
198
|
3. **System turns are unchanged** in order and content. **All other turns are replaced** by a **single assistant** message whose content is the LLM summary (metadata includes `source: contextpress_llm_tier`). If the LLM call fails, the Tier 1 conversation is returned and a **warning** is emitted.
|
|
167
199
|
|
|
168
|
-
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
|
|
200
|
+
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
|
|
201
|
+
|
|
202
|
+
**`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
|
|
169
203
|
|
|
170
204
|
**Install SDKs** (not bundled): `pip install openai`, `anthropic`, and/or **`ollama`** (for local Ollama), or `pip install "contextpress[llm]"` from this repo’s `pyproject.toml` to pull all optional LLM clients.
|
|
171
205
|
|
|
@@ -228,7 +262,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
|
|
|
228
262
|
|
|
229
263
|
## Research and citing
|
|
230
264
|
|
|
231
|
-
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff)
|
|
265
|
+
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
|
|
232
266
|
|
|
233
267
|
## Extension and growth
|
|
234
268
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from contextpress.profiles import Profile
|
|
5
|
+
from contextpress.profiles import Profile, StageConfig
|
|
6
6
|
|
|
7
7
|
STAGE_ORDER: tuple[str, ...] = (
|
|
8
8
|
"filler",
|
|
@@ -14,6 +14,14 @@ STAGE_ORDER: tuple[str, ...] = (
|
|
|
14
14
|
|
|
15
15
|
VALID_STAGES = frozenset(STAGE_ORDER)
|
|
16
16
|
|
|
17
|
+
|
|
18
|
+
def known_stages() -> frozenset[str]:
|
|
19
|
+
"""Built-in plus registered custom stage names."""
|
|
20
|
+
from contextpress.registry import registered_stage_names
|
|
21
|
+
|
|
22
|
+
return VALID_STAGES | registered_stage_names()
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
_NON_BUDGET_ORDER: tuple[str, ...] = tuple(s for s in STAGE_ORDER if s != "budget")
|
|
18
26
|
|
|
19
27
|
# NLP stages only; budget is toggled from token_budget (see apply_stage_selection)
|
|
@@ -38,6 +46,7 @@ __all__ = [
|
|
|
38
46
|
"STAGE_ORDER",
|
|
39
47
|
"VALID_STAGES",
|
|
40
48
|
"apply_stage_selection",
|
|
49
|
+
"known_stages",
|
|
41
50
|
"normalize_compression_level",
|
|
42
51
|
]
|
|
43
52
|
|
|
@@ -60,6 +69,7 @@ def apply_stage_selection(
|
|
|
60
69
|
stages: list[str] | None,
|
|
61
70
|
disable: list[str] | None,
|
|
62
71
|
token_budget: int | None,
|
|
72
|
+
custom_stages: dict[str, StageConfig] | None = None,
|
|
63
73
|
) -> None:
|
|
64
74
|
"""
|
|
65
75
|
Mutates ``profile`` in place: sets each stage's ``enabled`` from explicit
|
|
@@ -70,12 +80,15 @@ def apply_stage_selection(
|
|
|
70
80
|
if stages is not None:
|
|
71
81
|
if not stages:
|
|
72
82
|
raise ValueError("stages= must list at least one stage name when provided")
|
|
73
|
-
unknown = [s for s in stages if s not in
|
|
83
|
+
unknown = [s for s in stages if s not in known_stages()]
|
|
74
84
|
if unknown:
|
|
75
|
-
raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(
|
|
85
|
+
raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(known_stages())}")
|
|
76
86
|
want = frozenset(stages)
|
|
77
87
|
for name in _NON_BUDGET_ORDER:
|
|
78
88
|
getattr(profile, name).enabled = name in want
|
|
89
|
+
if custom_stages:
|
|
90
|
+
for name, cfg in custom_stages.items():
|
|
91
|
+
cfg.enabled = name in want
|
|
79
92
|
else:
|
|
80
93
|
level = normalize_compression_level(compression)
|
|
81
94
|
preset = _COMPRESSION_PRESETS[level]
|
|
@@ -85,6 +98,9 @@ def apply_stage_selection(
|
|
|
85
98
|
|
|
86
99
|
if disable:
|
|
87
100
|
for name in disable:
|
|
101
|
+
if custom_stages and name in custom_stages:
|
|
102
|
+
custom_stages[name].enabled = False
|
|
103
|
+
continue
|
|
88
104
|
if not hasattr(profile, name):
|
|
89
105
|
continue
|
|
90
106
|
getattr(profile, name).enabled = False
|
|
@@ -6,12 +6,17 @@ from typing import TYPE_CHECKING, Any
|
|
|
6
6
|
|
|
7
7
|
from contextpress.compression import apply_stage_selection, normalize_compression_level
|
|
8
8
|
from contextpress.normalizer import denormalize_output, normalize_messages
|
|
9
|
-
from contextpress.pipeline import Pipeline
|
|
9
|
+
from contextpress.pipeline import VALID_LLM_MODES, Pipeline, clone_conversation
|
|
10
10
|
from contextpress.profiles import PROFILES, Profile, StageConfig
|
|
11
|
-
from contextpress.
|
|
11
|
+
from contextpress.registry import register_stage as _register_stage
|
|
12
|
+
from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
|
|
13
|
+
from contextpress.warnings_capture import capture_warnings
|
|
12
14
|
|
|
13
15
|
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
14
18
|
from contextpress.llm.base import LLMBackend
|
|
19
|
+
from contextpress.strategies.base import BaseStrategy
|
|
15
20
|
|
|
16
21
|
|
|
17
22
|
def _validate_token_budget(token_budget: int | None) -> None:
|
|
@@ -39,9 +44,14 @@ class ContextManager:
|
|
|
39
44
|
compression: str = "medium",
|
|
40
45
|
llm_min_input_chars: int = 1500,
|
|
41
46
|
llm_max_summary_tokens: int = 2048,
|
|
47
|
+
llm_mode: str = "replace_all",
|
|
42
48
|
):
|
|
43
49
|
if type not in PROFILES:
|
|
44
50
|
raise ValueError(f"unknown context type {type!r}")
|
|
51
|
+
if llm_mode not in VALID_LLM_MODES:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
|
|
54
|
+
)
|
|
45
55
|
self._type = type
|
|
46
56
|
self._profile: Profile = copy.deepcopy(PROFILES[type])
|
|
47
57
|
self._compression: str = normalize_compression_level(compression)
|
|
@@ -49,6 +59,64 @@ class ContextManager:
|
|
|
49
59
|
self.llm_backend = llm_backend
|
|
50
60
|
self.llm_min_input_chars = int(llm_min_input_chars)
|
|
51
61
|
self.llm_max_summary_tokens = int(llm_max_summary_tokens)
|
|
62
|
+
self.llm_mode = llm_mode
|
|
63
|
+
self._custom_stages: dict[str, StageConfig] = {}
|
|
64
|
+
|
|
65
|
+
def estimate_tokens(self, messages: Any, *, model: str | None = None) -> int:
|
|
66
|
+
"""Count tokens for ``messages`` using the same encoding as the budget stage."""
|
|
67
|
+
conv, _ = normalize_messages(messages, context_type=self._type)
|
|
68
|
+
return count_conversation_tokens(conv, model if model is not None else self.model)
|
|
69
|
+
|
|
70
|
+
def fits_budget(
|
|
71
|
+
self,
|
|
72
|
+
messages: Any,
|
|
73
|
+
token_budget: int,
|
|
74
|
+
*,
|
|
75
|
+
compression: str | None = None,
|
|
76
|
+
stages: list[str] | None = None,
|
|
77
|
+
disable: list[str] | None = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Return True if ``messages`` would fit ``token_budget`` after compression (dry run)."""
|
|
80
|
+
preview = self.preview(
|
|
81
|
+
messages,
|
|
82
|
+
token_budget=token_budget,
|
|
83
|
+
compression=compression,
|
|
84
|
+
stages=stages,
|
|
85
|
+
disable=disable,
|
|
86
|
+
)
|
|
87
|
+
return preview.stats.tokens_after <= token_budget
|
|
88
|
+
|
|
89
|
+
def preview(
|
|
90
|
+
self,
|
|
91
|
+
messages: Any,
|
|
92
|
+
token_budget: int | None = None,
|
|
93
|
+
*,
|
|
94
|
+
compression: str | None = None,
|
|
95
|
+
stages: list[str] | None = None,
|
|
96
|
+
disable: list[str] | None = None,
|
|
97
|
+
) -> CompressionResult:
|
|
98
|
+
"""Dry-run compression: stats only, original ``messages`` returned unchanged."""
|
|
99
|
+
return self.compress(
|
|
100
|
+
messages,
|
|
101
|
+
token_budget=token_budget,
|
|
102
|
+
compression=compression,
|
|
103
|
+
stages=stages,
|
|
104
|
+
disable=disable,
|
|
105
|
+
return_stats=True,
|
|
106
|
+
dry_run=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def register_stage(
|
|
110
|
+
self,
|
|
111
|
+
name: str,
|
|
112
|
+
factory: Callable[..., BaseStrategy],
|
|
113
|
+
*,
|
|
114
|
+
before: str = "budget",
|
|
115
|
+
aggressiveness: float = 0.5,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Register a custom pipeline stage (see ``contextpress.registry``)."""
|
|
118
|
+
_register_stage(name, factory, before=before)
|
|
119
|
+
self._custom_stages[name] = StageConfig(enabled=False, aggressiveness=aggressiveness)
|
|
52
120
|
|
|
53
121
|
def compress(
|
|
54
122
|
self,
|
|
@@ -59,15 +127,20 @@ class ContextManager:
|
|
|
59
127
|
stages: list[str] | None = None,
|
|
60
128
|
disable: list[str] | None = None,
|
|
61
129
|
return_stats: bool = False,
|
|
130
|
+
dry_run: bool = False,
|
|
62
131
|
) -> Any | CompressionResult:
|
|
63
132
|
"""Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
|
|
64
133
|
|
|
65
134
|
``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
|
|
66
135
|
With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
|
|
136
|
+
With ``dry_run=True``, runs Tier 1 only (no LLM calls) and returns the original messages.
|
|
67
137
|
"""
|
|
138
|
+
if dry_run:
|
|
139
|
+
return_stats = True
|
|
68
140
|
_validate_token_budget(token_budget)
|
|
69
141
|
level = compression if compression is not None else self._compression
|
|
70
142
|
profile = copy.deepcopy(self._profile)
|
|
143
|
+
custom_stages = copy.deepcopy(self._custom_stages)
|
|
71
144
|
apply_stage_selection(
|
|
72
145
|
profile,
|
|
73
146
|
base_profile=self._profile,
|
|
@@ -75,6 +148,7 @@ class ContextManager:
|
|
|
75
148
|
stages=stages,
|
|
76
149
|
disable=disable,
|
|
77
150
|
token_budget=token_budget,
|
|
151
|
+
custom_stages=custom_stages,
|
|
78
152
|
)
|
|
79
153
|
|
|
80
154
|
conv, ctx = normalize_messages(messages, context_type=self._type)
|
|
@@ -86,9 +160,17 @@ class ContextManager:
|
|
|
86
160
|
llm_backend=self.llm_backend,
|
|
87
161
|
llm_min_input_chars=self.llm_min_input_chars,
|
|
88
162
|
llm_max_summary_tokens=self.llm_max_summary_tokens,
|
|
163
|
+
llm_mode=self.llm_mode,
|
|
164
|
+
custom_stages=custom_stages,
|
|
89
165
|
)
|
|
90
|
-
|
|
91
|
-
|
|
166
|
+
with capture_warnings() as captured:
|
|
167
|
+
out = pipeline.run(conv, stats=stats, dry_run=dry_run)
|
|
168
|
+
if stats is not None:
|
|
169
|
+
stats.warnings_emitted = captured
|
|
170
|
+
if dry_run:
|
|
171
|
+
messages_out = denormalize_output(clone_conversation(conv), ctx)
|
|
172
|
+
else:
|
|
173
|
+
messages_out = denormalize_output(out, ctx)
|
|
92
174
|
if return_stats:
|
|
93
175
|
assert stats is not None
|
|
94
176
|
return CompressionResult(messages=messages_out, stats=stats)
|
|
@@ -100,9 +182,12 @@ class ContextManager:
|
|
|
100
182
|
|
|
101
183
|
def configure(self, stage: str, **kwargs: Any) -> None:
|
|
102
184
|
"""Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
|
|
103
|
-
if
|
|
185
|
+
if stage in self._custom_stages:
|
|
186
|
+
sc = self._custom_stages[stage]
|
|
187
|
+
elif hasattr(self._profile, stage):
|
|
188
|
+
sc = getattr(self._profile, stage)
|
|
189
|
+
else:
|
|
104
190
|
raise ValueError(f"unknown stage {stage!r}")
|
|
105
|
-
sc: StageConfig = getattr(self._profile, stage)
|
|
106
191
|
for k, v in kwargs.items():
|
|
107
192
|
if hasattr(sc, k):
|
|
108
193
|
setattr(sc, k, v)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from contextpress.llm.adapters import (
|
|
2
|
+
AnthropicBackend,
|
|
3
|
+
OllamaBackend,
|
|
4
|
+
OpenAIBackend,
|
|
5
|
+
OpenAICompatibleBackend,
|
|
6
|
+
)
|
|
7
|
+
from contextpress.llm.base import LLMBackend
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"LLMBackend",
|
|
11
|
+
"OpenAIBackend",
|
|
12
|
+
"OpenAICompatibleBackend",
|
|
13
|
+
"AnthropicBackend",
|
|
14
|
+
"OllamaBackend",
|
|
15
|
+
]
|
|
@@ -221,3 +221,44 @@ class OllamaBackend(LLMBackend):
|
|
|
221
221
|
except Exception as exc:
|
|
222
222
|
warnings.warn(f"contextpress OllamaBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
223
223
|
return list(range(len(turns)))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class OpenAICompatibleBackend(OpenAIBackend):
|
|
227
|
+
"""
|
|
228
|
+
Adapter for **OpenAI-compatible HTTP APIs** (vLLM, LM Studio, LocalAI, etc.).
|
|
229
|
+
|
|
230
|
+
Requires: ``pip install openai``
|
|
231
|
+
|
|
232
|
+
Usage::
|
|
233
|
+
|
|
234
|
+
from contextpress.llm.adapters import OpenAICompatibleBackend
|
|
235
|
+
|
|
236
|
+
backend = OpenAICompatibleBackend(
|
|
237
|
+
base_url="http://localhost:8000/v1",
|
|
238
|
+
model="mistral",
|
|
239
|
+
)
|
|
240
|
+
cm = ContextManager(type="chat", llm_backend=backend)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
base_url: str,
|
|
246
|
+
model: str,
|
|
247
|
+
*,
|
|
248
|
+
api_key: str = "not-needed",
|
|
249
|
+
client: Any | None = None,
|
|
250
|
+
):
|
|
251
|
+
if client is not None:
|
|
252
|
+
super().__init__(client=client, model=model)
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
from openai import OpenAI
|
|
256
|
+
except ImportError as exc:
|
|
257
|
+
raise ImportError(
|
|
258
|
+
"OpenAICompatibleBackend requires the 'openai' package. "
|
|
259
|
+
"Install with: pip install openai"
|
|
260
|
+
) from exc
|
|
261
|
+
super().__init__(
|
|
262
|
+
client=OpenAI(base_url=base_url, api_key=api_key),
|
|
263
|
+
model=model,
|
|
264
|
+
)
|
|
@@ -27,6 +27,11 @@ from contextpress.compression import STAGE_ORDER
|
|
|
27
27
|
from contextpress.models import Conversation, Turn
|
|
28
28
|
from contextpress.normalizer import extract_text_for_processing
|
|
29
29
|
from contextpress.profiles import Profile, StageConfig
|
|
30
|
+
from contextpress.registry import (
|
|
31
|
+
build_custom_strategy,
|
|
32
|
+
effective_stage_order,
|
|
33
|
+
registered_stage_names,
|
|
34
|
+
)
|
|
30
35
|
from contextpress.stats import CompressionStats, count_conversation_tokens
|
|
31
36
|
from contextpress.strategies.base import BaseStrategy
|
|
32
37
|
from contextpress.strategies.budget import BudgetStrategy
|
|
@@ -38,6 +43,8 @@ from contextpress.strategies.resolution import ResolutionStrategy
|
|
|
38
43
|
if TYPE_CHECKING:
|
|
39
44
|
from contextpress.llm.base import LLMBackend
|
|
40
45
|
|
|
46
|
+
VALID_LLM_MODES = frozenset({"replace_all", "dedupe_only", "summarize_only"})
|
|
47
|
+
|
|
41
48
|
|
|
42
49
|
def clone_turn(t: Turn) -> Turn:
|
|
43
50
|
if isinstance(t.content, str):
|
|
@@ -80,6 +87,8 @@ class Pipeline:
|
|
|
80
87
|
*,
|
|
81
88
|
llm_min_input_chars: int = 1500,
|
|
82
89
|
llm_max_summary_tokens: int = 2048,
|
|
90
|
+
llm_mode: str = "replace_all",
|
|
91
|
+
custom_stages: dict[str, StageConfig] | None = None,
|
|
83
92
|
):
|
|
84
93
|
self.profile = profile
|
|
85
94
|
self.token_budget = token_budget
|
|
@@ -87,24 +96,34 @@ class Pipeline:
|
|
|
87
96
|
self.llm_backend = llm_backend # None = Tier 1 only
|
|
88
97
|
self.llm_min_input_chars = max(0, int(llm_min_input_chars))
|
|
89
98
|
self.llm_max_summary_tokens = max(64, int(llm_max_summary_tokens))
|
|
99
|
+
if llm_mode not in VALID_LLM_MODES:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
|
|
102
|
+
)
|
|
103
|
+
self.llm_mode = llm_mode
|
|
104
|
+
self.custom_stages = custom_stages or {}
|
|
90
105
|
|
|
91
106
|
def run(
|
|
92
107
|
self,
|
|
93
108
|
conversation: Conversation,
|
|
94
109
|
stats: CompressionStats | None = None,
|
|
110
|
+
*,
|
|
111
|
+
dry_run: bool = False,
|
|
95
112
|
) -> Conversation:
|
|
96
113
|
if stats is not None:
|
|
97
114
|
stats.turns_before = len(conversation.turns)
|
|
98
115
|
stats.tokens_before = count_conversation_tokens(conversation, self.model)
|
|
99
116
|
stats.context_type = conversation.type
|
|
100
117
|
stats.token_budget = self.token_budget
|
|
118
|
+
stats.dry_run = dry_run
|
|
101
119
|
|
|
102
120
|
result = clone_conversation(conversation)
|
|
103
|
-
|
|
121
|
+
stage_order = effective_stage_order()
|
|
122
|
+
for stage_name in stage_order:
|
|
104
123
|
if stage_name == "budget" and self.token_budget is None:
|
|
105
124
|
continue
|
|
106
|
-
stage_config =
|
|
107
|
-
if not stage_config.enabled:
|
|
125
|
+
stage_config = self._stage_config(stage_name)
|
|
126
|
+
if stage_config is None or not stage_config.enabled:
|
|
108
127
|
continue
|
|
109
128
|
before_turns = len(result.turns)
|
|
110
129
|
strategy = self._build_strategy(stage_name, stage_config)
|
|
@@ -115,7 +134,7 @@ class Pipeline:
|
|
|
115
134
|
if delta != 0:
|
|
116
135
|
stats.turn_delta_by_stage[stage_name] = delta
|
|
117
136
|
|
|
118
|
-
if self.llm_backend is not None:
|
|
137
|
+
if self.llm_backend is not None and not dry_run:
|
|
119
138
|
result = self._run_llm_tier(result, stats=stats)
|
|
120
139
|
|
|
121
140
|
if stats is not None:
|
|
@@ -124,12 +143,26 @@ class Pipeline:
|
|
|
124
143
|
|
|
125
144
|
return result
|
|
126
145
|
|
|
146
|
+
def _stage_config(self, name: str) -> StageConfig | None:
|
|
147
|
+
if name in self.custom_stages:
|
|
148
|
+
return self.custom_stages[name]
|
|
149
|
+
if hasattr(self.profile, name):
|
|
150
|
+
return getattr(self.profile, name)
|
|
151
|
+
return None
|
|
152
|
+
|
|
127
153
|
def _build_strategy(self, name: str, config: StageConfig) -> BaseStrategy:
|
|
128
154
|
kwargs: dict[str, Any] = {
|
|
129
155
|
"aggressiveness": config.aggressiveness,
|
|
130
156
|
"conv_type": self.profile.name,
|
|
131
157
|
"role_aware": self.profile.role_aware,
|
|
132
158
|
}
|
|
159
|
+
if name in registered_stage_names():
|
|
160
|
+
return build_custom_strategy(
|
|
161
|
+
name,
|
|
162
|
+
config,
|
|
163
|
+
conv_type=self.profile.name,
|
|
164
|
+
role_aware=self.profile.role_aware,
|
|
165
|
+
)
|
|
133
166
|
if name == "filler":
|
|
134
167
|
return FillerStrategy(**kwargs)
|
|
135
168
|
if name == "repetition":
|
|
@@ -166,25 +199,36 @@ class Pipeline:
|
|
|
166
199
|
stats.llm_dedup_turns_before = len(ns_turns)
|
|
167
200
|
|
|
168
201
|
texts = [extract_text_for_processing(t) for t in ns_turns]
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
202
|
+
if self.llm_mode in ("replace_all", "dedupe_only"):
|
|
203
|
+
try:
|
|
204
|
+
keep_idx = self.llm_backend.deduplicate(texts)
|
|
205
|
+
except Exception:
|
|
206
|
+
keep_idx = list(range(len(ns_turns)))
|
|
207
|
+
valid = sorted(
|
|
208
|
+
{
|
|
209
|
+
i
|
|
210
|
+
for i in keep_idx
|
|
211
|
+
if type(i) is int and not isinstance(i, bool) and 0 <= i < len(ns_turns)
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
if not valid:
|
|
215
|
+
valid = list(range(len(ns_turns)))
|
|
216
|
+
if len(valid) < len(ns_turns):
|
|
217
|
+
ns_turns = [ns_turns[i] for i in valid]
|
|
218
|
+
texts = [texts[i] for i in valid]
|
|
185
219
|
|
|
186
|
-
|
|
187
|
-
|
|
220
|
+
if stats is not None:
|
|
221
|
+
stats.llm_dedup_turns_after = len(ns_turns)
|
|
222
|
+
|
|
223
|
+
if self.llm_mode == "dedupe_only":
|
|
224
|
+
new_turns = list(system_turns) + [clone_turn(t) for t in ns_turns]
|
|
225
|
+
if stats is not None:
|
|
226
|
+
stats.llm_tier_applied = True
|
|
227
|
+
return Conversation(
|
|
228
|
+
turns=new_turns,
|
|
229
|
+
type=conversation.type,
|
|
230
|
+
metadata=copy.deepcopy(conversation.metadata),
|
|
231
|
+
)
|
|
188
232
|
|
|
189
233
|
lines = [f"{t.role}: {txt}" for t, txt in zip(ns_turns, texts, strict=True)]
|
|
190
234
|
combined = "\n\n".join(lines)
|
|
@@ -207,6 +251,22 @@ class Pipeline:
|
|
|
207
251
|
if stats is not None:
|
|
208
252
|
stats.llm_tier_applied = True
|
|
209
253
|
|
|
254
|
+
if self.llm_mode == "summarize_only":
|
|
255
|
+
new_turns = list(system_turns) + [clone_turn(t) for t in ns_turns]
|
|
256
|
+
new_turns.append(
|
|
257
|
+
Turn(
|
|
258
|
+
role="assistant",
|
|
259
|
+
content=summary,
|
|
260
|
+
metadata={"source": "contextpress_llm_tier", "mode": "summarize_only"},
|
|
261
|
+
compressed=True,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return Conversation(
|
|
265
|
+
turns=new_turns,
|
|
266
|
+
type=conversation.type,
|
|
267
|
+
metadata=copy.deepcopy(conversation.metadata),
|
|
268
|
+
)
|
|
269
|
+
|
|
210
270
|
new_turns: list[Turn] = list(system_turns)
|
|
211
271
|
new_turns.append(
|
|
212
272
|
Turn(
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Register custom pipeline stages without subclassing ``Pipeline``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from contextpress.profiles import StageConfig
|
|
9
|
+
from contextpress.strategies.base import BaseStrategy
|
|
10
|
+
|
|
11
|
+
# (name, factory, insert_before)
|
|
12
|
+
_CustomEntry = tuple[str, Callable[..., BaseStrategy], str]
|
|
13
|
+
_REGISTRY: list[_CustomEntry] = []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _builtin_stages() -> tuple[str, ...]:
|
|
17
|
+
from contextpress.compression import STAGE_ORDER
|
|
18
|
+
|
|
19
|
+
return STAGE_ORDER
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_stage(
|
|
23
|
+
name: str,
|
|
24
|
+
factory: Callable[..., BaseStrategy],
|
|
25
|
+
*,
|
|
26
|
+
before: str = "budget",
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Register a custom stage factory.
|
|
29
|
+
|
|
30
|
+
``factory`` receives ``aggressiveness``, ``conv_type``, ``role_aware``, and
|
|
31
|
+
optional ``**kwargs`` — same as built-in strategies. Custom stages run when
|
|
32
|
+
named in ``stages=`` on ``compress()``.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
name:
|
|
37
|
+
Unique stage name (must not collide with a built-in stage).
|
|
38
|
+
before:
|
|
39
|
+
Built-in or custom stage name to insert before (default: ``budget``).
|
|
40
|
+
"""
|
|
41
|
+
if not name or name in _builtin_stages():
|
|
42
|
+
raise ValueError(f"cannot register stage {name!r}: name invalid or reserved")
|
|
43
|
+
if any(e[0] == name for e in _REGISTRY):
|
|
44
|
+
raise ValueError(f"stage {name!r} is already registered")
|
|
45
|
+
_REGISTRY.append((name, factory, before))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def registered_stage_names() -> frozenset[str]:
|
|
49
|
+
return frozenset(name for name, _, _ in _REGISTRY)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def clear_registry() -> None:
|
|
53
|
+
"""Remove all custom stages (for tests)."""
|
|
54
|
+
_REGISTRY.clear()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def effective_stage_order() -> list[str]:
|
|
58
|
+
"""Built-in ``STAGE_ORDER`` with registered custom stages inserted."""
|
|
59
|
+
order = list(_builtin_stages())
|
|
60
|
+
for name, _, before in _REGISTRY:
|
|
61
|
+
if before not in order:
|
|
62
|
+
raise RuntimeError(f"custom stage {name!r}: anchor {before!r} not in stage order")
|
|
63
|
+
order.insert(order.index(before), name)
|
|
64
|
+
return order
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_custom_strategy(
|
|
68
|
+
name: str,
|
|
69
|
+
config: StageConfig,
|
|
70
|
+
*,
|
|
71
|
+
conv_type: str,
|
|
72
|
+
role_aware: bool,
|
|
73
|
+
extra_kwargs: dict[str, Any] | None = None,
|
|
74
|
+
) -> BaseStrategy:
|
|
75
|
+
for reg_name, factory, _ in _REGISTRY:
|
|
76
|
+
if reg_name == name:
|
|
77
|
+
kwargs: dict[str, Any] = {
|
|
78
|
+
"aggressiveness": config.aggressiveness,
|
|
79
|
+
"conv_type": conv_type,
|
|
80
|
+
"role_aware": role_aware,
|
|
81
|
+
}
|
|
82
|
+
if extra_kwargs:
|
|
83
|
+
kwargs.update(extra_kwargs)
|
|
84
|
+
return factory(**kwargs)
|
|
85
|
+
raise ValueError(f"no registered factory for custom stage {name!r}")
|
|
@@ -49,6 +49,8 @@ class CompressionStats:
|
|
|
49
49
|
compression_level: str | None = None
|
|
50
50
|
context_type: str = "chat"
|
|
51
51
|
token_budget: int | None = None
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
warnings_emitted: list[str] = field(default_factory=list)
|
|
52
54
|
|
|
53
55
|
@property
|
|
54
56
|
def turns_removed(self) -> int:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Capture warnings emitted during compression into ``CompressionStats``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def capture_warnings() -> Generator[list[str], None, None]:
|
|
12
|
+
captured: list[str] = []
|
|
13
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
14
|
+
warnings.simplefilter("always")
|
|
15
|
+
yield captured
|
|
16
|
+
captured.extend(str(item.message) for item in caught)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Dry-run preview: see compression stats without changing messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextpress import ContextManager
|
|
6
|
+
|
|
7
|
+
messages = [
|
|
8
|
+
{"role": "system", "content": "You are helpful."},
|
|
9
|
+
{"role": "user", "content": "Old topic: " + "blah " * 60},
|
|
10
|
+
{"role": "assistant", "content": "Basically, honestly, " + "yak " * 60},
|
|
11
|
+
{"role": "user", "content": "What is 2+2?"},
|
|
12
|
+
{"role": "assistant", "content": "Four."},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
cm = ContextManager(type="chat", compression="medium")
|
|
16
|
+
preview = cm.preview(messages, token_budget=150)
|
|
17
|
+
|
|
18
|
+
print("dry_run:", preview.stats.dry_run)
|
|
19
|
+
print("tokens:", preview.stats.tokens_before, "->", preview.stats.tokens_after)
|
|
20
|
+
print("would fit budget 150:", cm.fits_budget(messages, 150))
|
|
21
|
+
print("messages unchanged:", preview.messages == messages)
|
|
22
|
+
if preview.stats.warnings_emitted:
|
|
23
|
+
print("warnings:", preview.stats.warnings_emitted)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Estimate tokens before compressing, then inspect stats after."""
|
|
2
|
+
|
|
3
|
+
from contextpress import ContextManager
|
|
4
|
+
|
|
5
|
+
messages = [
|
|
6
|
+
{"role": "system", "content": "You are helpful."},
|
|
7
|
+
{"role": "user", "content": "Old topic: " + "blah " * 50},
|
|
8
|
+
{"role": "assistant", "content": "Basically, honestly, " + "yak " * 50},
|
|
9
|
+
{"role": "user", "content": "What is 2+2?"},
|
|
10
|
+
{"role": "assistant", "content": "Four."},
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
cm = ContextManager(type="chat", compression="medium")
|
|
14
|
+
before = cm.estimate_tokens(messages)
|
|
15
|
+
print("tokens before:", before)
|
|
16
|
+
|
|
17
|
+
result = cm.compress(messages, token_budget=200, return_stats=True)
|
|
18
|
+
print("tokens after:", result.stats.tokens_after)
|
|
19
|
+
print("saved:", result.stats.tokens_saved)
|
|
20
|
+
print("stages:", result.stats.stages_run)
|
|
21
|
+
print("turns:", result.stats.turns_before, "->", result.stats.turns_after)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from contextpress import ContextManager
|
|
6
|
+
from contextpress.compression import known_stages
|
|
7
|
+
from contextpress.models import Conversation, Turn
|
|
8
|
+
from contextpress.pipeline import Pipeline
|
|
9
|
+
from contextpress.profiles import PROFILES
|
|
10
|
+
from contextpress.registry import clear_registry, register_stage
|
|
11
|
+
from contextpress.strategies.base import BaseStrategy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _DropMarkedStrategy(BaseStrategy):
|
|
15
|
+
def process(self, conversation: Conversation) -> Conversation:
|
|
16
|
+
kept = [
|
|
17
|
+
copy.deepcopy(t)
|
|
18
|
+
for t in conversation.turns
|
|
19
|
+
if t.role == "system"
|
|
20
|
+
or "DROP" not in (t.content if isinstance(t.content, str) else str(t.content))
|
|
21
|
+
]
|
|
22
|
+
return Conversation(
|
|
23
|
+
turns=kept,
|
|
24
|
+
type=conversation.type,
|
|
25
|
+
metadata=copy.deepcopy(conversation.metadata),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(autouse=True)
|
|
30
|
+
def _reset_registry():
|
|
31
|
+
clear_registry()
|
|
32
|
+
yield
|
|
33
|
+
clear_registry()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_estimate_tokens_dict_list():
|
|
37
|
+
cm = ContextManager(type="chat")
|
|
38
|
+
messages = [
|
|
39
|
+
{"role": "user", "content": "Hello world"},
|
|
40
|
+
{"role": "assistant", "content": "Hi there"},
|
|
41
|
+
]
|
|
42
|
+
n = cm.estimate_tokens(messages)
|
|
43
|
+
assert isinstance(n, int) and n > 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_register_stage_and_run():
|
|
47
|
+
cm = ContextManager(type="chat")
|
|
48
|
+
cm.register_stage("drop_marked", _DropMarkedStrategy)
|
|
49
|
+
assert "drop_marked" in known_stages()
|
|
50
|
+
messages = [
|
|
51
|
+
{"role": "user", "content": "keep me"},
|
|
52
|
+
{"role": "assistant", "content": "DROP this turn"},
|
|
53
|
+
]
|
|
54
|
+
out = cm.compress(messages, stages=["drop_marked"])
|
|
55
|
+
assert len(out) == 1
|
|
56
|
+
assert out[0]["content"] == "keep me"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_register_stage_reserved_name_raises():
|
|
60
|
+
with pytest.raises(ValueError):
|
|
61
|
+
register_stage("filler", _DropMarkedStrategy)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_configure_custom_stage():
|
|
65
|
+
cm = ContextManager(type="chat")
|
|
66
|
+
cm.register_stage("drop_marked", _DropMarkedStrategy, aggressiveness=0.2)
|
|
67
|
+
cm.configure("drop_marked", aggressiveness=0.9)
|
|
68
|
+
assert cm._custom_stages["drop_marked"].aggressiveness == 0.9
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _DedupOnlyBackend:
|
|
72
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
73
|
+
raise AssertionError("summarize should not run in dedupe_only mode")
|
|
74
|
+
|
|
75
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
76
|
+
return [0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _SummarizeOnlyBackend:
|
|
80
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
81
|
+
return "summary appended"
|
|
82
|
+
|
|
83
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
84
|
+
raise AssertionError("deduplicate should not run in summarize_only mode")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_llm_mode_dedupe_only():
|
|
88
|
+
conv = Conversation(
|
|
89
|
+
turns=[
|
|
90
|
+
Turn(role="user", content="hello"),
|
|
91
|
+
Turn(role="user", content="hello again"),
|
|
92
|
+
],
|
|
93
|
+
type="chat",
|
|
94
|
+
)
|
|
95
|
+
p = Pipeline(
|
|
96
|
+
PROFILES["chat"],
|
|
97
|
+
token_budget=None,
|
|
98
|
+
llm_backend=_DedupOnlyBackend(),
|
|
99
|
+
llm_mode="dedupe_only",
|
|
100
|
+
llm_min_input_chars=0,
|
|
101
|
+
)
|
|
102
|
+
out = p.run(conv)
|
|
103
|
+
assert len(out.turns) == 1
|
|
104
|
+
assert out.turns[0].content == "hello"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_llm_mode_summarize_only():
|
|
108
|
+
conv = Conversation(
|
|
109
|
+
turns=[Turn(role="user", content="hello " * 200)],
|
|
110
|
+
type="chat",
|
|
111
|
+
)
|
|
112
|
+
p = Pipeline(
|
|
113
|
+
PROFILES["chat"],
|
|
114
|
+
token_budget=None,
|
|
115
|
+
llm_backend=_SummarizeOnlyBackend(),
|
|
116
|
+
llm_mode="summarize_only",
|
|
117
|
+
llm_min_input_chars=0,
|
|
118
|
+
)
|
|
119
|
+
out = p.run(conv)
|
|
120
|
+
assert len(out.turns) == 2
|
|
121
|
+
assert out.turns[0].role == "user"
|
|
122
|
+
assert out.turns[1].content == "summary appended"
|
|
123
|
+
assert out.turns[1].metadata.get("mode") == "summarize_only"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_invalid_llm_mode_raises():
|
|
127
|
+
with pytest.raises(ValueError):
|
|
128
|
+
ContextManager(llm_mode="invalid")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
from contextpress import ContextManager
|
|
4
|
+
from contextpress.llm.adapters import OpenAICompatibleBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_preview_returns_original_messages():
|
|
8
|
+
cm = ContextManager(type="chat")
|
|
9
|
+
messages = [
|
|
10
|
+
{"role": "user", "content": "hello basically there"},
|
|
11
|
+
{"role": "assistant", "content": "hi"},
|
|
12
|
+
]
|
|
13
|
+
preview = cm.preview(messages, token_budget=500)
|
|
14
|
+
assert preview.messages == messages
|
|
15
|
+
assert preview.stats.dry_run is True
|
|
16
|
+
assert preview.stats.tokens_before >= preview.stats.tokens_after
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_dry_run_skips_llm_backend():
|
|
20
|
+
backend = MagicMock()
|
|
21
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=0)
|
|
22
|
+
messages = [{"role": "user", "content": "hello " * 500}]
|
|
23
|
+
preview = cm.preview(messages, token_budget=100)
|
|
24
|
+
backend.deduplicate.assert_not_called()
|
|
25
|
+
backend.summarize.assert_not_called()
|
|
26
|
+
assert preview.stats.llm_tier_applied is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_fits_budget():
|
|
30
|
+
cm = ContextManager(type="chat", compression="low")
|
|
31
|
+
messages = [{"role": "user", "content": "short"}]
|
|
32
|
+
assert cm.fits_budget(messages, 500) is True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_warnings_captured_on_budget_truncation():
|
|
36
|
+
cm = ContextManager(type="chat")
|
|
37
|
+
messages = [
|
|
38
|
+
{"role": "user", "content": "word " * 200},
|
|
39
|
+
{"role": "assistant", "content": "reply " * 200},
|
|
40
|
+
{"role": "user", "content": "recent"},
|
|
41
|
+
{"role": "assistant", "content": "ok"},
|
|
42
|
+
]
|
|
43
|
+
preview = cm.preview(messages, token_budget=20, compression="low")
|
|
44
|
+
assert any("token budget" in w.lower() for w in preview.stats.warnings_emitted)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_openai_compatible_backend_init():
|
|
48
|
+
client = MagicMock()
|
|
49
|
+
backend = OpenAICompatibleBackend(base_url="http://localhost:8000/v1", model="x", client=client)
|
|
50
|
+
assert backend.model == "x"
|
|
51
|
+
assert backend.client is client
|
contextpress-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,17 +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.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).
|
|
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
|
|
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
|