contextpress 0.1.0__py3-none-any.whl
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/__init__.py +20 -0
- contextpress/_bootstrap.py +34 -0
- contextpress/compression.py +101 -0
- contextpress/core.py +105 -0
- contextpress/llm/__init__.py +4 -0
- contextpress/llm/adapters.py +174 -0
- contextpress/llm/base.py +27 -0
- contextpress/models.py +61 -0
- contextpress/normalizer.py +317 -0
- contextpress/pipeline.py +183 -0
- contextpress/profiles.py +51 -0
- contextpress/py.typed +0 -0
- contextpress/strategies/__init__.py +5 -0
- contextpress/strategies/base.py +28 -0
- contextpress/strategies/budget.py +123 -0
- contextpress/strategies/filler.py +176 -0
- contextpress/strategies/recency.py +134 -0
- contextpress/strategies/repetition.py +88 -0
- contextpress/strategies/resolution.py +222 -0
- contextpress-0.1.0.dist-info/METADATA +476 -0
- contextpress-0.1.0.dist-info/RECORD +24 -0
- contextpress-0.1.0.dist-info/WHEEL +4 -0
- contextpress-0.1.0.dist-info/licenses/LICENSE +201 -0
- contextpress-0.1.0.dist-info/licenses/NOTICE +8 -0
contextpress/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from contextpress._bootstrap import bootstrap_nltk
|
|
6
|
+
from contextpress.models import ContentBlock, Conversation, Turn
|
|
7
|
+
|
|
8
|
+
bootstrap_nltk()
|
|
9
|
+
|
|
10
|
+
__all__ = ["ContextManager", "Turn", "Conversation", "ContentBlock"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str) -> Any:
|
|
15
|
+
# Lazy import: ContextManager -> core -> pipeline -> strategies (sumy, etc.)
|
|
16
|
+
if name == "ContextManager":
|
|
17
|
+
from contextpress.core import ContextManager
|
|
18
|
+
|
|
19
|
+
return ContextManager
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""One-time NLTK data download.
|
|
2
|
+
|
|
3
|
+
Runs on first import; writes a flag file so later imports stay silent.
|
|
4
|
+
Offline installs will hit the network here — set the flag manually
|
|
5
|
+
(``touch ~/.contextpress/nltk_ready``) or pre-populate NLTK_DATA to skip.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import warnings
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import nltk
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def bootstrap_nltk() -> None:
|
|
17
|
+
flag = Path.home() / ".contextpress" / "nltk_ready"
|
|
18
|
+
if flag.exists():
|
|
19
|
+
return
|
|
20
|
+
packages = ["punkt", "stopwords", "averaged_perceptron_tagger"]
|
|
21
|
+
failed: list[str] = []
|
|
22
|
+
for pkg in packages:
|
|
23
|
+
try:
|
|
24
|
+
nltk.download(pkg, quiet=True)
|
|
25
|
+
except Exception:
|
|
26
|
+
failed.append(pkg)
|
|
27
|
+
if failed:
|
|
28
|
+
warnings.warn(
|
|
29
|
+
f"contextpress: could not download NLTK data {failed}; "
|
|
30
|
+
"resolution/recency may degrade. Check network or set NLTK_DATA.",
|
|
31
|
+
stacklevel=2,
|
|
32
|
+
)
|
|
33
|
+
flag.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
flag.touch()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Presets (low/medium/high), explicit ``stages=``, and budget toggling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextpress.profiles import Profile
|
|
6
|
+
|
|
7
|
+
STAGE_ORDER: tuple[str, ...] = (
|
|
8
|
+
"filler",
|
|
9
|
+
"repetition",
|
|
10
|
+
"resolution",
|
|
11
|
+
"recency",
|
|
12
|
+
"budget",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
VALID_STAGES = frozenset(STAGE_ORDER)
|
|
16
|
+
|
|
17
|
+
_NON_BUDGET_ORDER: tuple[str, ...] = tuple(s for s in STAGE_ORDER if s != "budget")
|
|
18
|
+
|
|
19
|
+
# NLP stages only; budget is toggled from token_budget (see apply_stage_selection)
|
|
20
|
+
_COMPRESSION_PRESETS: dict[str, frozenset[str]] = {
|
|
21
|
+
"low": frozenset({"filler", "repetition"}),
|
|
22
|
+
"medium": frozenset({"filler", "repetition", "recency"}),
|
|
23
|
+
"high": frozenset({"filler", "repetition", "resolution", "recency"}),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_COMPRESSION_ALIASES: dict[str, str] = {
|
|
27
|
+
"low": "low",
|
|
28
|
+
"light": "low",
|
|
29
|
+
"medium": "medium",
|
|
30
|
+
"med": "medium",
|
|
31
|
+
"mid": "medium",
|
|
32
|
+
"high": "high",
|
|
33
|
+
"max": "high",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"STAGE_ORDER",
|
|
39
|
+
"VALID_STAGES",
|
|
40
|
+
"apply_stage_selection",
|
|
41
|
+
"normalize_compression_level",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_compression_level(level: str) -> str:
|
|
46
|
+
key = level.strip().lower()
|
|
47
|
+
if key not in _COMPRESSION_ALIASES:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"unknown compression level {level!r}; "
|
|
50
|
+
f"use one of: low, medium, high (aliases: light, med, max)"
|
|
51
|
+
)
|
|
52
|
+
return _COMPRESSION_ALIASES[key]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_stage_selection(
|
|
56
|
+
profile: Profile,
|
|
57
|
+
*,
|
|
58
|
+
base_profile: Profile,
|
|
59
|
+
compression: str,
|
|
60
|
+
stages: list[str] | None,
|
|
61
|
+
disable: list[str] | None,
|
|
62
|
+
token_budget: int | None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Mutates ``profile`` in place: sets each stage's ``enabled`` from explicit
|
|
66
|
+
``stages``, or from a compression preset merged with ``base_profile`` for
|
|
67
|
+
non-budget stages, then applies ``disable``. Budget is set last from
|
|
68
|
+
``token_budget`` / ``stages`` / ``disable``.
|
|
69
|
+
"""
|
|
70
|
+
if stages is not None:
|
|
71
|
+
if not stages:
|
|
72
|
+
raise ValueError("stages= must list at least one stage name when provided")
|
|
73
|
+
unknown = [s for s in stages if s not in VALID_STAGES]
|
|
74
|
+
if unknown:
|
|
75
|
+
raise ValueError(f"unknown stage name(s): {unknown}; valid: {sorted(VALID_STAGES)}")
|
|
76
|
+
want = frozenset(stages)
|
|
77
|
+
for name in _NON_BUDGET_ORDER:
|
|
78
|
+
getattr(profile, name).enabled = name in want
|
|
79
|
+
else:
|
|
80
|
+
level = normalize_compression_level(compression)
|
|
81
|
+
preset = _COMPRESSION_PRESETS[level]
|
|
82
|
+
for name in _NON_BUDGET_ORDER:
|
|
83
|
+
base_on = getattr(base_profile, name).enabled
|
|
84
|
+
getattr(profile, name).enabled = (name in preset) and base_on
|
|
85
|
+
|
|
86
|
+
if disable:
|
|
87
|
+
for name in disable:
|
|
88
|
+
if not hasattr(profile, name):
|
|
89
|
+
continue
|
|
90
|
+
getattr(profile, name).enabled = False
|
|
91
|
+
|
|
92
|
+
# Budget: token cap enforcement (pipeline still skips if token_budget is None)
|
|
93
|
+
if token_budget is None:
|
|
94
|
+
profile.budget.enabled = False
|
|
95
|
+
elif stages is not None:
|
|
96
|
+
profile.budget.enabled = "budget" in want
|
|
97
|
+
else:
|
|
98
|
+
if disable and "budget" in disable:
|
|
99
|
+
profile.budget.enabled = False
|
|
100
|
+
else:
|
|
101
|
+
profile.budget.enabled = getattr(base_profile, "budget").enabled
|
contextpress/core.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from contextpress.compression import apply_stage_selection, normalize_compression_level
|
|
8
|
+
from contextpress.normalizer import denormalize_output, normalize_messages
|
|
9
|
+
from contextpress.pipeline import Pipeline
|
|
10
|
+
from contextpress.profiles import PROFILES, Profile, StageConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from contextpress.llm.base import LLMBackend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate_token_budget(token_budget: int | None) -> None:
|
|
17
|
+
if token_budget is None:
|
|
18
|
+
return
|
|
19
|
+
if isinstance(token_budget, bool) or not isinstance(token_budget, int):
|
|
20
|
+
raise TypeError("token_budget must be a positive int or None (bools are not allowed)")
|
|
21
|
+
if token_budget < 1:
|
|
22
|
+
raise ValueError("token_budget must be >= 1 when set")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContextManager:
|
|
26
|
+
"""Main API: ``compress()`` runs Tier 1 (and Tier 2 if ``llm_backend`` is set).
|
|
27
|
+
|
|
28
|
+
``model`` is only for tiktoken when enforcing ``token_budget``. It does not call that model
|
|
29
|
+
unless you pass an ``llm_backend`` that uses it.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
type: str = "chat",
|
|
35
|
+
model: str | None = None,
|
|
36
|
+
llm_backend: LLMBackend | None = None,
|
|
37
|
+
*,
|
|
38
|
+
compression: str = "medium",
|
|
39
|
+
llm_min_input_chars: int = 1500,
|
|
40
|
+
llm_max_summary_tokens: int = 2048,
|
|
41
|
+
):
|
|
42
|
+
if type not in PROFILES:
|
|
43
|
+
raise ValueError(f"unknown context type {type!r}")
|
|
44
|
+
self._type = type
|
|
45
|
+
self._profile: Profile = copy.deepcopy(PROFILES[type])
|
|
46
|
+
self._compression: str = normalize_compression_level(compression)
|
|
47
|
+
self.model = model
|
|
48
|
+
self.llm_backend = llm_backend
|
|
49
|
+
self.llm_min_input_chars = int(llm_min_input_chars)
|
|
50
|
+
self.llm_max_summary_tokens = int(llm_max_summary_tokens)
|
|
51
|
+
|
|
52
|
+
def compress(
|
|
53
|
+
self,
|
|
54
|
+
messages: Any,
|
|
55
|
+
token_budget: int | None = None,
|
|
56
|
+
*,
|
|
57
|
+
compression: str | None = None,
|
|
58
|
+
stages: list[str] | None = None,
|
|
59
|
+
disable: list[str] | None = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
"""Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
|
|
62
|
+
|
|
63
|
+
``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
|
|
64
|
+
"""
|
|
65
|
+
_validate_token_budget(token_budget)
|
|
66
|
+
profile = copy.deepcopy(self._profile)
|
|
67
|
+
apply_stage_selection(
|
|
68
|
+
profile,
|
|
69
|
+
base_profile=self._profile,
|
|
70
|
+
compression=compression if compression is not None else self._compression,
|
|
71
|
+
stages=stages,
|
|
72
|
+
disable=disable,
|
|
73
|
+
token_budget=token_budget,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
conv, ctx = normalize_messages(messages, context_type=self._type)
|
|
77
|
+
pipeline = Pipeline(
|
|
78
|
+
profile,
|
|
79
|
+
token_budget=token_budget,
|
|
80
|
+
model=self.model,
|
|
81
|
+
llm_backend=self.llm_backend,
|
|
82
|
+
llm_min_input_chars=self.llm_min_input_chars,
|
|
83
|
+
llm_max_summary_tokens=self.llm_max_summary_tokens,
|
|
84
|
+
)
|
|
85
|
+
out = pipeline.run(conv)
|
|
86
|
+
return denormalize_output(out, ctx)
|
|
87
|
+
|
|
88
|
+
def set_compression(self, compression: str) -> None:
|
|
89
|
+
"""Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
|
|
90
|
+
self._compression = normalize_compression_level(compression)
|
|
91
|
+
|
|
92
|
+
def configure(self, stage: str, **kwargs: Any) -> None:
|
|
93
|
+
"""Patch ``StageConfig`` fields on the live profile (e.g. ``aggressiveness``, ``enabled``)."""
|
|
94
|
+
if not hasattr(self._profile, stage):
|
|
95
|
+
raise ValueError(f"unknown stage {stage!r}")
|
|
96
|
+
sc: StageConfig = getattr(self._profile, stage)
|
|
97
|
+
for k, v in kwargs.items():
|
|
98
|
+
if hasattr(sc, k):
|
|
99
|
+
setattr(sc, k, v)
|
|
100
|
+
unknown = [k for k in kwargs if not hasattr(sc, k)]
|
|
101
|
+
if unknown:
|
|
102
|
+
warnings.warn(
|
|
103
|
+
f"contextpress: configure({stage!r}) ignored unknown key(s): {unknown}",
|
|
104
|
+
stacklevel=2,
|
|
105
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from contextpress.llm.base import LLMBackend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ollama_response_text(resp: Any) -> str:
|
|
11
|
+
"""Normalize ollama chat response (object or dict) to a string."""
|
|
12
|
+
if resp is None:
|
|
13
|
+
return ""
|
|
14
|
+
if isinstance(resp, dict):
|
|
15
|
+
msg = resp.get("message") or {}
|
|
16
|
+
if isinstance(msg, dict):
|
|
17
|
+
return str(msg.get("content") or "").strip()
|
|
18
|
+
return str(getattr(msg, "content", "") or "").strip()
|
|
19
|
+
msg = getattr(resp, "message", None)
|
|
20
|
+
if msg is not None:
|
|
21
|
+
c = getattr(msg, "content", None)
|
|
22
|
+
if c is not None:
|
|
23
|
+
return str(c).strip()
|
|
24
|
+
return ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenAIBackend(LLMBackend):
|
|
28
|
+
"""
|
|
29
|
+
Adapter for OpenAI-compatible APIs.
|
|
30
|
+
Requires: pip install openai
|
|
31
|
+
User must pass their own client instance.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
from openai import OpenAI
|
|
35
|
+
from contextpress.llm.adapters import OpenAIBackend
|
|
36
|
+
|
|
37
|
+
backend = OpenAIBackend(client=OpenAI(), model="gpt-4o-mini")
|
|
38
|
+
cm = ContextManager(type="chat", llm_backend=backend)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, client: Any, model: str = "gpt-4o-mini"):
|
|
42
|
+
self.client = client
|
|
43
|
+
self.model = model
|
|
44
|
+
|
|
45
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
46
|
+
try:
|
|
47
|
+
resp = self.client.chat.completions.create(
|
|
48
|
+
model=self.model,
|
|
49
|
+
messages=[
|
|
50
|
+
{"role": "system", "content": "Summarize the following text concisely."},
|
|
51
|
+
{"role": "user", "content": text},
|
|
52
|
+
],
|
|
53
|
+
max_tokens=max_tokens,
|
|
54
|
+
)
|
|
55
|
+
choice = resp.choices[0]
|
|
56
|
+
content = choice.message.content
|
|
57
|
+
return content if content is not None else text
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
warnings.warn(f"contextpress OpenAIBackend.summarize failed: {exc}", stacklevel=2)
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
63
|
+
return list(range(len(turns)))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AnthropicBackend(LLMBackend):
|
|
67
|
+
"""
|
|
68
|
+
Adapter for Anthropic Claude APIs.
|
|
69
|
+
Requires: pip install anthropic
|
|
70
|
+
User must pass their own client instance.
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
import anthropic
|
|
74
|
+
from contextpress.llm.adapters import AnthropicBackend
|
|
75
|
+
|
|
76
|
+
backend = AnthropicBackend(client=anthropic.Anthropic(), model="claude-haiku-4-5")
|
|
77
|
+
cm = ContextManager(type="chat", llm_backend=backend)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, client: Any, model: str = "claude-haiku-4-5"):
|
|
81
|
+
self.client = client
|
|
82
|
+
self.model = model
|
|
83
|
+
|
|
84
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
85
|
+
try:
|
|
86
|
+
msg = self.client.messages.create(
|
|
87
|
+
model=self.model,
|
|
88
|
+
max_tokens=max_tokens,
|
|
89
|
+
messages=[{"role": "user", "content": f"Summarize concisely:\n\n{text}"}],
|
|
90
|
+
)
|
|
91
|
+
parts = []
|
|
92
|
+
for b in msg.content:
|
|
93
|
+
if hasattr(b, "text"):
|
|
94
|
+
parts.append(b.text)
|
|
95
|
+
return "".join(parts) if parts else text
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
warnings.warn(f"contextpress AnthropicBackend.summarize failed: {exc}", stacklevel=2)
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
101
|
+
return list(range(len(turns)))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OllamaBackend(LLMBackend):
|
|
105
|
+
"""
|
|
106
|
+
Adapter for **Ollama** (local or remote) using the official ``ollama`` Python library.
|
|
107
|
+
|
|
108
|
+
Requires: ``pip install ollama``
|
|
109
|
+
|
|
110
|
+
Ollama must be installed and running (see https://ollama.com). Pull a model first, e.g.::
|
|
111
|
+
|
|
112
|
+
ollama pull llama3.2
|
|
113
|
+
|
|
114
|
+
Usage::
|
|
115
|
+
|
|
116
|
+
from contextpress import ContextManager
|
|
117
|
+
from contextpress.llm.adapters import OllamaBackend
|
|
118
|
+
|
|
119
|
+
backend = OllamaBackend(model="llama3.2")
|
|
120
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
|
|
121
|
+
|
|
122
|
+
Remote server::
|
|
123
|
+
|
|
124
|
+
backend = OllamaBackend(model="mistral", host="http://192.168.1.10:11434")
|
|
125
|
+
|
|
126
|
+
Custom client (advanced)::
|
|
127
|
+
|
|
128
|
+
from ollama import Client
|
|
129
|
+
backend = OllamaBackend(client=Client(host="http://localhost:11434"), model="llama3.2")
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
model: str = "llama3.2",
|
|
135
|
+
*,
|
|
136
|
+
host: str | None = None,
|
|
137
|
+
client: Any | None = None,
|
|
138
|
+
):
|
|
139
|
+
self.model = model
|
|
140
|
+
if client is not None:
|
|
141
|
+
self._client = client
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
from ollama import Client as OllamaClient
|
|
145
|
+
except ImportError as exc:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"OllamaBackend requires the 'ollama' package. Install with: pip install ollama"
|
|
148
|
+
) from exc
|
|
149
|
+
self._client = OllamaClient(host=host) if host else OllamaClient()
|
|
150
|
+
|
|
151
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
152
|
+
try:
|
|
153
|
+
resp = self._client.chat(
|
|
154
|
+
model=self.model,
|
|
155
|
+
messages=[
|
|
156
|
+
{
|
|
157
|
+
"role": "system",
|
|
158
|
+
"content": (
|
|
159
|
+
"Summarize the following conversation transcript concisely. "
|
|
160
|
+
"Preserve important facts, names, and decisions."
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
{"role": "user", "content": text},
|
|
164
|
+
],
|
|
165
|
+
options={"num_predict": max(64, int(max_tokens))},
|
|
166
|
+
)
|
|
167
|
+
content = _ollama_response_text(resp)
|
|
168
|
+
return content if content.strip() else text
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
warnings.warn(f"contextpress OllamaBackend.summarize failed: {exc}", stacklevel=2)
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
174
|
+
return list(range(len(turns)))
|
contextpress/llm/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LLMBackend(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Interface for optional Tier 2 LLM processing.
|
|
9
|
+
Users implement this to connect their preferred LLM provider.
|
|
10
|
+
contextpress never imports any LLM SDK directly.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Abstractively summarize text to fit within max_tokens.
|
|
17
|
+
Must return a plain string.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
23
|
+
"""
|
|
24
|
+
Given a list of turn texts, return indices of turns to KEEP.
|
|
25
|
+
The backend decides which are semantically redundant.
|
|
26
|
+
"""
|
|
27
|
+
...
|
contextpress/models.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ContentBlock:
|
|
10
|
+
"""
|
|
11
|
+
A single block of content within a turn.
|
|
12
|
+
Used when content is multimodal (text + image, tool call, etc.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
type: str # "text" | "image" | "tool_use" | "tool_result"
|
|
16
|
+
content: str # text content or reference string
|
|
17
|
+
mime_type: str | None = None
|
|
18
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Turn:
|
|
23
|
+
"""
|
|
24
|
+
A single message in a conversation.
|
|
25
|
+
This is the canonical unit the entire pipeline operates on.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
role: str # "user" | "assistant" | "system"
|
|
29
|
+
content: str | list[ContentBlock] # string for simple, list for multimodal
|
|
30
|
+
timestamp: datetime | None = None
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
importance: float = 1.0 # set by pipeline stages, 0.0–1.0
|
|
33
|
+
resolved: bool = False # True when flagged by resolution detector
|
|
34
|
+
compressed: bool = False # True if content was modified by pipeline
|
|
35
|
+
original_content: str | list[ContentBlock] | None = None # preserves original before compression
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Conversation:
|
|
40
|
+
"""
|
|
41
|
+
An ordered list of turns with a declared context type.
|
|
42
|
+
This is the primary object passed into the pipeline.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
turns: list[Turn]
|
|
46
|
+
type: str = "chat" # "chat" | "rag_doc" | "agent"
|
|
47
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
def text_turns(self) -> list[Turn]:
|
|
50
|
+
"""Returns only turns that contain extractable text content."""
|
|
51
|
+
return [t for t in self.turns if self._has_text(t)]
|
|
52
|
+
|
|
53
|
+
def non_system_turns(self) -> list[Turn]:
|
|
54
|
+
"""Returns all turns except system turns."""
|
|
55
|
+
return [t for t in self.turns if t.role != "system"]
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _has_text(turn: Turn) -> bool:
|
|
59
|
+
if isinstance(turn.content, str):
|
|
60
|
+
return bool(turn.content.strip())
|
|
61
|
+
return any(b.type == "text" for b in turn.content)
|