contextpress 0.1.0__tar.gz → 0.3.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.1.0 → contextpress-0.3.0}/.gitignore +3 -0
- contextpress-0.3.0/CHANGELOG.md +24 -0
- contextpress-0.3.0/CONTRIBUTING.md +61 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/PKG-INFO +33 -2
- {contextpress-0.1.0 → contextpress-0.3.0}/README.md +32 -1
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/__init__.py +10 -3
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/compression.py +20 -4
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/core.py +52 -8
- contextpress-0.3.0/contextpress/llm/_helpers.py +57 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/adapters.py +53 -4
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/models.py +3 -1
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/normalizer.py +25 -8
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/pipeline.py +120 -23
- contextpress-0.3.0/contextpress/registry.py +85 -0
- contextpress-0.3.0/contextpress/stats.py +67 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/filler.py +1 -1
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/recency.py +4 -2
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/repetition.py +3 -1
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/resolution.py +3 -2
- contextpress-0.3.0/examples/estimate_and_stats.py +21 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/examples/llm_tier_openai.py +1 -1
- {contextpress-0.1.0 → contextpress-0.3.0}/pyproject.toml +13 -2
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_filler.py +1 -1
- contextpress-0.3.0/tests/test_llm_helpers.py +65 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_models.py +2 -2
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_pipeline.py +8 -4
- contextpress-0.3.0/tests/test_stats.py +40 -0
- contextpress-0.3.0/tests/test_v03.py +128 -0
- contextpress-0.1.0/CHANGELOG.md +0 -10
- {contextpress-0.1.0 → contextpress-0.3.0}/CITATION.cff +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/LICENSE +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/NOTICE +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/llm/base.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/profiles.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/py.typed +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/base.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/contextpress/strategies/budget.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/__init__.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_budget.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_normalizer.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_recency.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_repetition.py +0 -0
- {contextpress-0.1.0 → contextpress-0.3.0}/tests/test_resolution.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
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.3.0] - 2026-07-14
|
|
8
|
+
|
|
9
|
+
- **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
|
|
10
|
+
- **Stage registry** — `ContextManager.register_stage()` for custom `BaseStrategy` stages in `stages=[...]`.
|
|
11
|
+
- **Tier 2 `llm_mode`** — `replace_all` (default), `dedupe_only`, or `summarize_only`.
|
|
12
|
+
- Example: `examples/estimate_and_stats.py`.
|
|
13
|
+
|
|
14
|
+
## [0.2.0] - 2026-05-24
|
|
15
|
+
|
|
16
|
+
- **`return_stats=True`** on `ContextManager.compress()` returns a `CompressionResult` with token/turn counts, stages run, and per-stage turn deltas.
|
|
17
|
+
- **Tier 2 deduplication** implemented in OpenAI, Anthropic, and Ollama adapters (LLM selects indices to keep).
|
|
18
|
+
- **GitHub Actions CI** runs `pytest` on Python 3.10–3.13.
|
|
19
|
+
- Added **`CONTRIBUTING.md`** with contribution and review expectations.
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2026-04-19
|
|
22
|
+
|
|
23
|
+
- Initial release by Taha Azizi.
|
|
24
|
+
- 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.3.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,35 @@ 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
|
+
|
|
293
|
+
Check token count **before** compressing:
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
before = cm.estimate_tokens(messages)
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Custom stages (0.3+)
|
|
300
|
+
|
|
301
|
+
Register a `BaseStrategy` and include it in `stages=`:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from contextpress.strategies.base import BaseStrategy
|
|
305
|
+
|
|
306
|
+
class MyStage(BaseStrategy):
|
|
307
|
+
def process(self, conversation):
|
|
308
|
+
...
|
|
309
|
+
|
|
310
|
+
cm.register_stage("my_stage", MyStage)
|
|
311
|
+
out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
|
|
312
|
+
```
|
|
313
|
+
|
|
285
314
|
### Minimal examples
|
|
286
315
|
|
|
287
316
|
```python
|
|
@@ -397,7 +426,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
|
|
|
397
426
|
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)`**.
|
|
398
427
|
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.
|
|
399
428
|
|
|
400
|
-
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
|
|
429
|
+
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
|
|
430
|
+
|
|
431
|
+
**`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
|
|
401
432
|
|
|
402
433
|
**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.
|
|
403
434
|
|
|
@@ -42,6 +42,35 @@ 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
|
+
|
|
53
|
+
Check token count **before** compressing:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
before = cm.estimate_tokens(messages)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Custom stages (0.3+)
|
|
60
|
+
|
|
61
|
+
Register a `BaseStrategy` and include it in `stages=`:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from contextpress.strategies.base import BaseStrategy
|
|
65
|
+
|
|
66
|
+
class MyStage(BaseStrategy):
|
|
67
|
+
def process(self, conversation):
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
cm.register_stage("my_stage", MyStage)
|
|
71
|
+
out = cm.compress(messages, stages=["filler", "my_stage", "budget"], token_budget=500)
|
|
72
|
+
```
|
|
73
|
+
|
|
45
74
|
### Minimal examples
|
|
46
75
|
|
|
47
76
|
```python
|
|
@@ -157,7 +186,9 @@ After **Tier 1** finishes, you can attach an **`LLMBackend`** for semantic compr
|
|
|
157
186
|
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)`**.
|
|
158
187
|
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.
|
|
159
188
|
|
|
160
|
-
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**.
|
|
189
|
+
Optional constructor knobs: **`llm_min_input_chars`**, **`llm_max_summary_tokens`**, **`llm_mode`**.
|
|
190
|
+
|
|
191
|
+
**`llm_mode`** (0.3+): `replace_all` (default — dedupe then one summary turn), `dedupe_only` (dedupe, keep turns), `summarize_only` (append summary, keep turns).
|
|
161
192
|
|
|
162
193
|
**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.
|
|
163
194
|
|
|
@@ -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.3.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
|
|
|
@@ -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
|
|
@@ -98,4 +114,4 @@ def apply_stage_selection(
|
|
|
98
114
|
if disable and "budget" in disable:
|
|
99
115
|
profile.budget.enabled = False
|
|
100
116
|
else:
|
|
101
|
-
profile.budget.enabled =
|
|
117
|
+
profile.budget.enabled = base_profile.budget.enabled
|
|
@@ -6,11 +6,16 @@ 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
|
|
10
10
|
from contextpress.profiles import PROFILES, Profile, StageConfig
|
|
11
|
+
from contextpress.registry import register_stage as _register_stage
|
|
12
|
+
from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
13
17
|
from contextpress.llm.base import LLMBackend
|
|
18
|
+
from contextpress.strategies.base import BaseStrategy
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
def _validate_token_budget(token_budget: int | None) -> None:
|
|
@@ -38,9 +43,14 @@ class ContextManager:
|
|
|
38
43
|
compression: str = "medium",
|
|
39
44
|
llm_min_input_chars: int = 1500,
|
|
40
45
|
llm_max_summary_tokens: int = 2048,
|
|
46
|
+
llm_mode: str = "replace_all",
|
|
41
47
|
):
|
|
42
48
|
if type not in PROFILES:
|
|
43
49
|
raise ValueError(f"unknown context type {type!r}")
|
|
50
|
+
if llm_mode not in VALID_LLM_MODES:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"unknown llm_mode {llm_mode!r}; use one of: {sorted(VALID_LLM_MODES)}"
|
|
53
|
+
)
|
|
44
54
|
self._type = type
|
|
45
55
|
self._profile: Profile = copy.deepcopy(PROFILES[type])
|
|
46
56
|
self._compression: str = normalize_compression_level(compression)
|
|
@@ -48,6 +58,25 @@ class ContextManager:
|
|
|
48
58
|
self.llm_backend = llm_backend
|
|
49
59
|
self.llm_min_input_chars = int(llm_min_input_chars)
|
|
50
60
|
self.llm_max_summary_tokens = int(llm_max_summary_tokens)
|
|
61
|
+
self.llm_mode = llm_mode
|
|
62
|
+
self._custom_stages: dict[str, StageConfig] = {}
|
|
63
|
+
|
|
64
|
+
def estimate_tokens(self, messages: Any, *, model: str | None = None) -> int:
|
|
65
|
+
"""Count tokens for ``messages`` using the same encoding as the budget stage."""
|
|
66
|
+
conv, _ = normalize_messages(messages, context_type=self._type)
|
|
67
|
+
return count_conversation_tokens(conv, model if model is not None else self.model)
|
|
68
|
+
|
|
69
|
+
def register_stage(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
factory: Callable[..., BaseStrategy],
|
|
73
|
+
*,
|
|
74
|
+
before: str = "budget",
|
|
75
|
+
aggressiveness: float = 0.5,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Register a custom pipeline stage (see ``contextpress.registry``)."""
|
|
78
|
+
_register_stage(name, factory, before=before)
|
|
79
|
+
self._custom_stages[name] = StageConfig(enabled=False, aggressiveness=aggressiveness)
|
|
51
80
|
|
|
52
81
|
def compress(
|
|
53
82
|
self,
|
|
@@ -57,23 +86,29 @@ class ContextManager:
|
|
|
57
86
|
compression: str | None = None,
|
|
58
87
|
stages: list[str] | None = None,
|
|
59
88
|
disable: list[str] | None = None,
|
|
60
|
-
|
|
89
|
+
return_stats: bool = False,
|
|
90
|
+
) -> Any | CompressionResult:
|
|
61
91
|
"""Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
|
|
62
92
|
|
|
63
93
|
``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
|
|
94
|
+
With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
|
|
64
95
|
"""
|
|
65
96
|
_validate_token_budget(token_budget)
|
|
97
|
+
level = compression if compression is not None else self._compression
|
|
66
98
|
profile = copy.deepcopy(self._profile)
|
|
99
|
+
custom_stages = copy.deepcopy(self._custom_stages)
|
|
67
100
|
apply_stage_selection(
|
|
68
101
|
profile,
|
|
69
102
|
base_profile=self._profile,
|
|
70
|
-
compression=
|
|
103
|
+
compression=level,
|
|
71
104
|
stages=stages,
|
|
72
105
|
disable=disable,
|
|
73
106
|
token_budget=token_budget,
|
|
107
|
+
custom_stages=custom_stages,
|
|
74
108
|
)
|
|
75
109
|
|
|
76
110
|
conv, ctx = normalize_messages(messages, context_type=self._type)
|
|
111
|
+
stats = CompressionStats(compression_level=level) if return_stats else None
|
|
77
112
|
pipeline = Pipeline(
|
|
78
113
|
profile,
|
|
79
114
|
token_budget=token_budget,
|
|
@@ -81,19 +116,28 @@ class ContextManager:
|
|
|
81
116
|
llm_backend=self.llm_backend,
|
|
82
117
|
llm_min_input_chars=self.llm_min_input_chars,
|
|
83
118
|
llm_max_summary_tokens=self.llm_max_summary_tokens,
|
|
119
|
+
llm_mode=self.llm_mode,
|
|
120
|
+
custom_stages=custom_stages,
|
|
84
121
|
)
|
|
85
|
-
out = pipeline.run(conv)
|
|
86
|
-
|
|
122
|
+
out = pipeline.run(conv, stats=stats)
|
|
123
|
+
messages_out = denormalize_output(out, ctx)
|
|
124
|
+
if return_stats:
|
|
125
|
+
assert stats is not None
|
|
126
|
+
return CompressionResult(messages=messages_out, stats=stats)
|
|
127
|
+
return messages_out
|
|
87
128
|
|
|
88
129
|
def set_compression(self, compression: str) -> None:
|
|
89
130
|
"""Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
|
|
90
131
|
self._compression = normalize_compression_level(compression)
|
|
91
132
|
|
|
92
133
|
def configure(self, stage: str, **kwargs: Any) -> None:
|
|
93
|
-
"""Patch ``StageConfig`` fields on the live profile (e.g.
|
|
94
|
-
if
|
|
134
|
+
"""Patch ``StageConfig`` fields on the live profile (e.g. aggressiveness, enabled)."""
|
|
135
|
+
if stage in self._custom_stages:
|
|
136
|
+
sc = self._custom_stages[stage]
|
|
137
|
+
elif hasattr(self._profile, stage):
|
|
138
|
+
sc = getattr(self._profile, stage)
|
|
139
|
+
else:
|
|
95
140
|
raise ValueError(f"unknown stage {stage!r}")
|
|
96
|
-
sc: StageConfig = getattr(self._profile, stage)
|
|
97
141
|
for k, v in kwargs.items():
|
|
98
142
|
if hasattr(sc, k):
|
|
99
143
|
setattr(sc, k, v)
|
|
@@ -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,
|