contextsage 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.
- contextsage/__init__.py +22 -0
- contextsage/budget/__init__.py +3 -0
- contextsage/budget/analyzer.py +100 -0
- contextsage/budget/tokens.py +216 -0
- contextsage/classification/__init__.py +3 -0
- contextsage/classification/signals.py +131 -0
- contextsage/context/__init__.py +3 -0
- contextsage/context/decomposition.py +208 -0
- contextsage/context/observer.py +90 -0
- contextsage/core/__init__.py +3 -0
- contextsage/core/models.py +254 -0
- contextsage/core/reconstruction.py +61 -0
- contextsage/exceptions.py +67 -0
- contextsage/importance/__init__.py +3 -0
- contextsage/importance/engine.py +127 -0
- contextsage/integrations/__init__.py +3 -0
- contextsage/integrations/langgraph/__init__.py +3 -0
- contextsage/integrations/langgraph/adapter.py +92 -0
- contextsage/integrations/langgraph_xai/__init__.py +3 -0
- contextsage/integrations/langgraph_xai/provenance.py +148 -0
- contextsage/lineage/__init__.py +3 -0
- contextsage/lineage/manager.py +83 -0
- contextsage/middleware/__init__.py +9 -0
- contextsage/middleware/summarization.py +467 -0
- contextsage/observability/__init__.py +3 -0
- contextsage/observability/events.py +59 -0
- contextsage/parsers/__init__.py +49 -0
- contextsage/parsers/base.py +97 -0
- contextsage/parsers/code_parser.py +157 -0
- contextsage/parsers/error_parser.py +74 -0
- contextsage/parsers/grok_patterns.py +90 -0
- contextsage/parsers/json_parser.py +56 -0
- contextsage/parsers/log_parser.py +78 -0
- contextsage/parsers/table_parser.py +37 -0
- contextsage/parsers/tree_sitter_support.py +134 -0
- contextsage/planning/__init__.py +3 -0
- contextsage/planning/planner.py +119 -0
- contextsage/planning/trigger.py +78 -0
- contextsage/preservation/__init__.py +3 -0
- contextsage/preservation/engine.py +130 -0
- contextsage/py.typed +0 -0
- contextsage/recovery/__init__.py +3 -0
- contextsage/recovery/manager.py +98 -0
- contextsage/relationships/__init__.py +3 -0
- contextsage/relationships/engine.py +123 -0
- contextsage/transformation/__init__.py +3 -0
- contextsage/transformation/engine.py +106 -0
- contextsage/transformation/logs.py +71 -0
- contextsage/transformation/structured.py +81 -0
- contextsage/validation/__init__.py +3 -0
- contextsage/validation/validator.py +116 -0
- contextsage-0.1.0.dist-info/METADATA +204 -0
- contextsage-0.1.0.dist-info/RECORD +55 -0
- contextsage-0.1.0.dist-info/WHEEL +4 -0
- contextsage-0.1.0.dist-info/licenses/LICENSE +190 -0
contextsage/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""ContextSage: intelligent, production-grade context summarization for LangGraph agents.
|
|
2
|
+
|
|
3
|
+
ContextSage is a narrowly-focused enhancement layer over LangGraph/LangChain's
|
|
4
|
+
built-in ``SummarizationMiddleware``. It analyzes heterogeneous agent context
|
|
5
|
+
(mixed natural language, JSON, logs, errors, tables, code) to decide *what*
|
|
6
|
+
should be preserved, compressed, or left untouched, then delegates the
|
|
7
|
+
actual LLM-based semantic summarization to LangGraph itself.
|
|
8
|
+
|
|
9
|
+
The public surface of this package is intentionally small — a single class:
|
|
10
|
+
|
|
11
|
+
from contextsage import IntelligentSummarizationMiddleware
|
|
12
|
+
|
|
13
|
+
See ``docs/`` for the full architecture, or the module docstrings under
|
|
14
|
+
``contextsage.middleware``, ``contextsage.planning``, ``contextsage.validation``,
|
|
15
|
+
etc. for internal implementation details.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from contextsage.middleware.summarization import IntelligentSummarizationMiddleware
|
|
19
|
+
|
|
20
|
+
__all__ = ["IntelligentSummarizationMiddleware", "__version__"]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Context budget analysis.
|
|
2
|
+
|
|
3
|
+
The :class:`BudgetAnalyzer` computes an explicit, auditable accounting of
|
|
4
|
+
the model's context window: how many tokens are already spoken for by the
|
|
5
|
+
system prompt, tool schemas, reserved output, and safety margin, and
|
|
6
|
+
therefore how many tokens are actually available for conversation input.
|
|
7
|
+
|
|
8
|
+
ContextSage never assumes "characters / 4" is an authoritative token count; it
|
|
9
|
+
delegates to a :class:`~contextsage.budget.tokens.TokenCounter` and clearly
|
|
10
|
+
tracks whether the resulting numbers are estimates.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from contextsage.budget.tokens import TokenCounter
|
|
19
|
+
from contextsage.core.models import BudgetReport
|
|
20
|
+
from contextsage.exceptions import BudgetError
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterable, Sequence
|
|
24
|
+
|
|
25
|
+
from langchain_core.messages import BaseMessage
|
|
26
|
+
|
|
27
|
+
_DEFAULT_MAX_CONTEXT_TOKENS = 128_000
|
|
28
|
+
_DEFAULT_SAFETY_MARGIN_FRACTION = 0.10
|
|
29
|
+
_DEFAULT_SUMMARIZATION_OVERHEAD_TOKENS = 2_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class BudgetConfig:
|
|
34
|
+
"""User/derived configuration for budget analysis."""
|
|
35
|
+
|
|
36
|
+
maximum_context_tokens: int = _DEFAULT_MAX_CONTEXT_TOKENS
|
|
37
|
+
reserved_output_tokens: int = 4_000
|
|
38
|
+
safety_margin_fraction: float = _DEFAULT_SAFETY_MARGIN_FRACTION
|
|
39
|
+
summarization_overhead_tokens: int = _DEFAULT_SUMMARIZATION_OVERHEAD_TOKENS
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
if self.maximum_context_tokens <= 0:
|
|
43
|
+
raise BudgetError("maximum_context_tokens must be positive")
|
|
44
|
+
if not 0.0 <= self.safety_margin_fraction < 1.0:
|
|
45
|
+
raise BudgetError("safety_margin_fraction must be in [0, 1)")
|
|
46
|
+
if self.reserved_output_tokens < 0:
|
|
47
|
+
raise BudgetError("reserved_output_tokens must not be negative")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BudgetAnalyzer:
|
|
51
|
+
"""Computes a :class:`BudgetReport` for a given message list."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, token_counter: TokenCounter, config: BudgetConfig | None = None) -> None:
|
|
54
|
+
self._token_counter = token_counter
|
|
55
|
+
self._config = config or BudgetConfig()
|
|
56
|
+
|
|
57
|
+
def analyze(
|
|
58
|
+
self,
|
|
59
|
+
messages: Sequence[BaseMessage],
|
|
60
|
+
*,
|
|
61
|
+
system_prompt: str | None = None,
|
|
62
|
+
tool_schemas: Iterable[str] = (),
|
|
63
|
+
) -> BudgetReport:
|
|
64
|
+
try:
|
|
65
|
+
current_input_tokens = self._token_counter.count_messages(messages)
|
|
66
|
+
system_tokens = self._token_counter.count_text(system_prompt) if system_prompt else 0
|
|
67
|
+
tool_schema_tokens = sum(
|
|
68
|
+
self._token_counter.count_text(schema) for schema in tool_schemas
|
|
69
|
+
)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise BudgetError(f"failed to compute budget: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
safety_margin_tokens = round(
|
|
74
|
+
self._config.maximum_context_tokens * self._config.safety_margin_fraction
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return BudgetReport(
|
|
78
|
+
maximum_context_tokens=self._config.maximum_context_tokens,
|
|
79
|
+
reserved_output_tokens=self._config.reserved_output_tokens,
|
|
80
|
+
system_tokens=system_tokens,
|
|
81
|
+
tool_schema_tokens=tool_schema_tokens,
|
|
82
|
+
current_input_tokens=current_input_tokens,
|
|
83
|
+
safety_margin_tokens=safety_margin_tokens,
|
|
84
|
+
summarization_overhead_tokens=self._config.summarization_overhead_tokens,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def infer_max_context_tokens(model: object, default: int = _DEFAULT_MAX_CONTEXT_TOKENS) -> int:
|
|
89
|
+
"""Best-effort extraction of a model's max input tokens from its profile.
|
|
90
|
+
|
|
91
|
+
Falls back to ``default`` when the model exposes no profile information;
|
|
92
|
+
this mirrors LangGraph's own graceful-degradation approach for models
|
|
93
|
+
without published context-window metadata.
|
|
94
|
+
"""
|
|
95
|
+
profile = getattr(model, "profile", None)
|
|
96
|
+
if isinstance(profile, dict):
|
|
97
|
+
value = profile.get("max_input_tokens")
|
|
98
|
+
if isinstance(value, int) and value > 0:
|
|
99
|
+
return value
|
|
100
|
+
return default
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Provider/model-aware token counting.
|
|
2
|
+
|
|
3
|
+
ContextSage needs token counts in two places with different cost profiles:
|
|
4
|
+
|
|
5
|
+
* **Budget analysis** (whole-conversation accounting) — should match, as
|
|
6
|
+
closely as possible, whatever the wrapped LangGraph
|
|
7
|
+
``SummarizationMiddleware`` itself uses, so ContextSage's "is summarization
|
|
8
|
+
required" decision is consistent with the mechanics that ultimately run.
|
|
9
|
+
* **Per-unit accounting during decomposition** — invoked many times over a
|
|
10
|
+
single large tool output, so it must be cheap and cache-friendly (this is
|
|
11
|
+
why every counter is wrapped in :class:`CachingTokenCounter`).
|
|
12
|
+
|
|
13
|
+
We never hardcode "characters / 4" as if it were authoritative.
|
|
14
|
+
:class:`TiktokenTokenCounter` self-reports that it is an authoritative,
|
|
15
|
+
provider-verified count via :attr:`TokenCounter.is_authoritative`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import socket
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from functools import lru_cache
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from contextsage.exceptions import TokenCountingError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import tiktoken
|
|
30
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
31
|
+
from langchain_core.messages import BaseMessage
|
|
32
|
+
|
|
33
|
+
_TEXT_CACHE_SIZE = 4096
|
|
34
|
+
|
|
35
|
+
#: Used when no model is supplied (and no ``encoding_name`` override) —
|
|
36
|
+
#: ``cl100k_base`` is the most broadly compatible encoding across
|
|
37
|
+
#: OpenAI-compatible and many open-weight tokenizer families, and is used
|
|
38
|
+
#: directly with no model-name lookup, so the zero-configuration default
|
|
39
|
+
#: never depends on network access to resolve an uncached encoding.
|
|
40
|
+
_DEFAULT_ENCODING_NAME = "cl100k_base"
|
|
41
|
+
|
|
42
|
+
#: Tiktoken downloads uncached encoding files over plain HTTP with no
|
|
43
|
+
#: explicit timeout (see ``tiktoken.load.read_file``). To keep ContextSage
|
|
44
|
+
#: from hanging indefinitely in network-restricted/offline environments —
|
|
45
|
+
#: the same production-reliability concern that ruled out on-demand grammar
|
|
46
|
+
#: downloads elsewhere in this package — resolving an *explicit* model name
|
|
47
|
+
#: to its encoding is bounded by a short socket timeout, with a safe local
|
|
48
|
+
#: fallback to ``cl100k_base`` on failure.
|
|
49
|
+
_ENCODING_DOWNLOAD_TIMEOUT_SECONDS = 5.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenCounter(ABC):
|
|
53
|
+
"""Abstract base class for every token counting strategy in ContextSage.
|
|
54
|
+
|
|
55
|
+
``tiktoken`` is the default, always-installed implementation
|
|
56
|
+
(:class:`TiktokenTokenCounter`), but token counting remains pluggable:
|
|
57
|
+
anyone integrating a provider-specific tokenizer
|
|
58
|
+
(Anthropic, Gemini, a local model's own tokenizer, ...) subclasses
|
|
59
|
+
``TokenCounter`` and passes an instance to
|
|
60
|
+
``IntelligentSummarizationMiddleware(token_counter=...)`` — no other
|
|
61
|
+
ContextSage code needs to change.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
#: Whether this counter reports an authoritative, provider-verified count
|
|
65
|
+
#: (``True``) or an estimate (``False``).
|
|
66
|
+
is_authoritative: bool
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def count_text(self, text: str) -> int:
|
|
70
|
+
"""Return the (approximate or authoritative) token count for raw text."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def count_messages(self, messages: Iterable[BaseMessage]) -> int:
|
|
74
|
+
"""Return the total token count for a sequence of messages."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_encoding(
|
|
78
|
+
model: str,
|
|
79
|
+
*,
|
|
80
|
+
fallback_encoding_name: str = _DEFAULT_ENCODING_NAME,
|
|
81
|
+
download_timeout_seconds: float = _ENCODING_DOWNLOAD_TIMEOUT_SECONDS,
|
|
82
|
+
) -> tiktoken.Encoding:
|
|
83
|
+
"""Resolve an explicit ``model`` name to a tiktoken encoding, bounded and fail-safe.
|
|
84
|
+
|
|
85
|
+
Tries the model-specific encoding first (bounded by a short socket
|
|
86
|
+
timeout so an uncached encoding never hangs indefinitely on a
|
|
87
|
+
network-restricted host); falls back to ``fallback_encoding_name`` if
|
|
88
|
+
that fails for any reason.
|
|
89
|
+
"""
|
|
90
|
+
import tiktoken # noqa: PLC0415
|
|
91
|
+
|
|
92
|
+
previous_timeout = socket.getdefaulttimeout()
|
|
93
|
+
socket.setdefaulttimeout(download_timeout_seconds)
|
|
94
|
+
try:
|
|
95
|
+
try:
|
|
96
|
+
return tiktoken.encoding_for_model(model)
|
|
97
|
+
except Exception:
|
|
98
|
+
return tiktoken.get_encoding(fallback_encoding_name)
|
|
99
|
+
finally:
|
|
100
|
+
socket.setdefaulttimeout(previous_timeout)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TiktokenTokenCounter(TokenCounter):
|
|
104
|
+
"""Authoritative-ish counting using ``tiktoken``.
|
|
105
|
+
|
|
106
|
+
``tiktoken`` is a direct, always-installed dependency, and this is the
|
|
107
|
+
only built-in :class:`TokenCounter` implementation: it is the closest
|
|
108
|
+
thing ContextSage has to a provider-verified count without requiring a
|
|
109
|
+
live API round-trip, for both OpenAI-compatible and most open-weight
|
|
110
|
+
tokenizer families (via ``cl100k_base``/``o200k_base``-family BPE).
|
|
111
|
+
|
|
112
|
+
With no arguments, uses ``cl100k_base`` directly — no model-name lookup,
|
|
113
|
+
so the zero-configuration default has no network dependency. Pass
|
|
114
|
+
``model`` to resolve the encoding for a specific model actually in use
|
|
115
|
+
(e.g. ``"gpt-4o"``, ``"gpt-4.1"``); pass ``encoding_name`` to select an
|
|
116
|
+
encoding directly. ``fallback_encoding_name``/``download_timeout_seconds``
|
|
117
|
+
tune the ``model``-based resolution path itself (see :func:`_resolve_encoding`).
|
|
118
|
+
Remains fully pluggable for non-GPT-family providers: pass any other
|
|
119
|
+
:class:`TokenCounter` subclass via
|
|
120
|
+
``IntelligentSummarizationMiddleware(token_counter=...)``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
is_authoritative = True
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
model: str | None = None,
|
|
128
|
+
encoding_name: str | None = None,
|
|
129
|
+
*,
|
|
130
|
+
fallback_encoding_name: str = _DEFAULT_ENCODING_NAME,
|
|
131
|
+
download_timeout_seconds: float = _ENCODING_DOWNLOAD_TIMEOUT_SECONDS,
|
|
132
|
+
) -> None:
|
|
133
|
+
import tiktoken # noqa: PLC0415
|
|
134
|
+
|
|
135
|
+
self.model = model
|
|
136
|
+
try:
|
|
137
|
+
if encoding_name is not None:
|
|
138
|
+
self._encoding = tiktoken.get_encoding(encoding_name)
|
|
139
|
+
elif model is not None:
|
|
140
|
+
self._encoding = _resolve_encoding(
|
|
141
|
+
model,
|
|
142
|
+
fallback_encoding_name=fallback_encoding_name,
|
|
143
|
+
download_timeout_seconds=download_timeout_seconds,
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
self._encoding = tiktoken.get_encoding(fallback_encoding_name)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
raise TokenCountingError(f"tiktoken failed to load an encoding: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
def count_text(self, text: str) -> int:
|
|
151
|
+
if not text:
|
|
152
|
+
return 0
|
|
153
|
+
return len(self._encoding.encode(text, disallowed_special=()))
|
|
154
|
+
|
|
155
|
+
def count_messages(self, messages: Iterable[BaseMessage]) -> int:
|
|
156
|
+
return sum(self.count_text(_message_text(m)) for m in messages)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CachingTokenCounter(TokenCounter):
|
|
160
|
+
"""Wraps another counter with a bounded LRU cache over raw text."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, inner: TokenCounter, maxsize: int = _TEXT_CACHE_SIZE) -> None:
|
|
163
|
+
self._inner = inner
|
|
164
|
+
self.is_authoritative = inner.is_authoritative
|
|
165
|
+
self._cached_count_text = lru_cache(maxsize=maxsize)(self._inner.count_text)
|
|
166
|
+
|
|
167
|
+
def count_text(self, text: str) -> int:
|
|
168
|
+
return self._cached_count_text(text)
|
|
169
|
+
|
|
170
|
+
def count_messages(self, messages: Iterable[BaseMessage]) -> int:
|
|
171
|
+
return self._inner.count_messages(messages)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _message_text(message: BaseMessage) -> str:
|
|
175
|
+
content = getattr(message, "content", "")
|
|
176
|
+
if isinstance(content, str):
|
|
177
|
+
return content
|
|
178
|
+
if isinstance(content, list):
|
|
179
|
+
parts = []
|
|
180
|
+
for block in content:
|
|
181
|
+
if isinstance(block, str):
|
|
182
|
+
parts.append(block)
|
|
183
|
+
elif isinstance(block, dict):
|
|
184
|
+
parts.append(str(block.get("text", block)))
|
|
185
|
+
return "\n".join(parts)
|
|
186
|
+
return str(content)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _extract_model_name(model: BaseChatModel | None) -> str | None:
|
|
190
|
+
"""Best-effort extraction of a model name string from a chat model instance."""
|
|
191
|
+
if model is None:
|
|
192
|
+
return None
|
|
193
|
+
for attr in ("model_name", "model"):
|
|
194
|
+
value = getattr(model, attr, None)
|
|
195
|
+
if isinstance(value, str) and value:
|
|
196
|
+
return value
|
|
197
|
+
profile = getattr(model, "profile", None)
|
|
198
|
+
if isinstance(profile, dict):
|
|
199
|
+
value = profile.get("name")
|
|
200
|
+
if isinstance(value, str) and value:
|
|
201
|
+
return value
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def create_default_token_counter(model: BaseChatModel | None = None) -> TokenCounter:
|
|
206
|
+
"""Build the default token counter used when the caller does not supply one.
|
|
207
|
+
|
|
208
|
+
Resolves the tokenizer to whatever model is actually configured on the
|
|
209
|
+
middleware (e.g. ``"gpt-4.1"``, ``"gpt-4o-mini"``); falls back to the
|
|
210
|
+
universal ``cl100k_base`` encoding directly (no network-dependent
|
|
211
|
+
model-name lookup) when no model name can be determined. The counter
|
|
212
|
+
remains fully pluggable: pass any other :class:`TokenCounter` subclass
|
|
213
|
+
via ``IntelligentSummarizationMiddleware(token_counter=...)``.
|
|
214
|
+
"""
|
|
215
|
+
model_name = _extract_model_name(model)
|
|
216
|
+
return CachingTokenCounter(TiktokenTokenCounter(model=model_name))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Structural signal detection.
|
|
2
|
+
|
|
3
|
+
Detection here is intentionally lightweight and conservative: this module
|
|
4
|
+
answers "what does this chunk of text look like?" with a confidence score,
|
|
5
|
+
not "parse this into a full AST". Low-confidence detections are treated as
|
|
6
|
+
plain text by callers — false structural classification is worse than no
|
|
7
|
+
classification.
|
|
8
|
+
|
|
9
|
+
Kind detection itself is delegated to a pluggable
|
|
10
|
+
:class:`~contextsage.parsers.base.ParserRegistry` (see :mod:`contextsage.parsers`):
|
|
11
|
+
:class:`StructuralSignalDetector` owns the *always-on* enrichment (identifier
|
|
12
|
+
extraction, error-keyword/severity tagging) that applies regardless of which
|
|
13
|
+
kind was detected, and never needs a mature parsing library itself — only
|
|
14
|
+
the Python standard library is used here, to keep the dependency footprint
|
|
15
|
+
minimal.
|
|
16
|
+
|
|
17
|
+
Classification is logically distinct from parsing (a parser decides *what
|
|
18
|
+
kind* a region is; the detector's own enrichment applies uniformly no
|
|
19
|
+
matter which kind was decided) and is therefore overridable independently:
|
|
20
|
+
all three patterns below are overridable at construction time (no
|
|
21
|
+
subclassing required), the same way parsers expose their own default
|
|
22
|
+
patterns — pass ``severity_pattern``/``error_keywords_pattern``/
|
|
23
|
+
``identifier_patterns`` to recognize an application's own severity
|
|
24
|
+
vocabulary or identifier conventions via the public API.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
from dataclasses import replace
|
|
32
|
+
|
|
33
|
+
from contextsage.core.models import ContentSignals, RegionKind
|
|
34
|
+
from contextsage.parsers.base import ParserRegistry, default_registry
|
|
35
|
+
|
|
36
|
+
#: The built-in defaults, exposed so callers can override just one of them
|
|
37
|
+
#: (or compose with them) without having to reconstruct the others.
|
|
38
|
+
DEFAULT_SEVERITY_PATTERN = re.compile(r"\b(DEBUG|INFO|WARN(?:ING)?|ERROR|CRITICAL|FATAL|TRACE)\b")
|
|
39
|
+
DEFAULT_ERROR_KEYWORDS_PATTERN = re.compile(
|
|
40
|
+
r"\b(traceback|exception|error|failed|failure|stack trace|panic)\b", re.IGNORECASE
|
|
41
|
+
)
|
|
42
|
+
DEFAULT_IDENTIFIER_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
43
|
+
re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
|
|
44
|
+
re.compile(r"\b[A-Z]{2,6}-\d{3,}\b"),
|
|
45
|
+
re.compile(r"\b(?:req|request|trace|correlation|txn|transaction)[_-]?id\s*[:=]\s*[\w-]+", re.I),
|
|
46
|
+
re.compile(r"\bcustomer\s+\d+\b", re.I),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StructuralSignalDetector:
|
|
51
|
+
"""Detects the structural nature of a chunk of text.
|
|
52
|
+
|
|
53
|
+
``registry`` is an optional, advanced extension point: pass a custom
|
|
54
|
+
:class:`~contextsage.parsers.base.ParserRegistry` (built from your own
|
|
55
|
+
:class:`~contextsage.parsers.base.ContentParser` subclasses, alongside or
|
|
56
|
+
instead of the built-in ones) to plug in domain-specific detection
|
|
57
|
+
without forking ContextSage. The default registry (built-in JSON/table/
|
|
58
|
+
code/log/error parsers) is used when ``registry`` is omitted, so ordinary
|
|
59
|
+
usage of :class:`contextsage.IntelligentSummarizationMiddleware` never
|
|
60
|
+
needs to know this exists.
|
|
61
|
+
|
|
62
|
+
``severity_pattern``, ``error_keywords_pattern``, and
|
|
63
|
+
``identifier_patterns`` override the always-on enrichment applied on top
|
|
64
|
+
of whatever the registry detected; they default to
|
|
65
|
+
:data:`DEFAULT_SEVERITY_PATTERN`, :data:`DEFAULT_ERROR_KEYWORDS_PATTERN`,
|
|
66
|
+
and :data:`DEFAULT_IDENTIFIER_PATTERNS` respectively.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
registry: ParserRegistry | None = None,
|
|
72
|
+
*,
|
|
73
|
+
severity_pattern: re.Pattern[str] = DEFAULT_SEVERITY_PATTERN,
|
|
74
|
+
error_keywords_pattern: re.Pattern[str] = DEFAULT_ERROR_KEYWORDS_PATTERN,
|
|
75
|
+
identifier_patterns: Sequence[re.Pattern[str]] = DEFAULT_IDENTIFIER_PATTERNS,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._registry = registry or default_registry()
|
|
78
|
+
self._severity_pattern = severity_pattern
|
|
79
|
+
self._error_keywords_pattern = error_keywords_pattern
|
|
80
|
+
self._identifier_patterns = tuple(identifier_patterns)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def registry(self) -> ParserRegistry:
|
|
84
|
+
"""The configured parser registry.
|
|
85
|
+
|
|
86
|
+
Exposed so :class:`~contextsage.context.decomposition.ContextDecomposer`
|
|
87
|
+
can derive its own segmentation-time patterns from whichever
|
|
88
|
+
parsers are actually registered (default or overridden via
|
|
89
|
+
``IntelligentSummarizationMiddleware(parsers=[...])``), rather than
|
|
90
|
+
maintaining independent, possibly-stale copies.
|
|
91
|
+
"""
|
|
92
|
+
return self._registry
|
|
93
|
+
|
|
94
|
+
def detect(self, text: str) -> ContentSignals:
|
|
95
|
+
stripped = text.strip()
|
|
96
|
+
if not stripped:
|
|
97
|
+
return ContentSignals(kind=RegionKind.TEXT, confidence=1.0)
|
|
98
|
+
|
|
99
|
+
signals = self._registry.detect(stripped) or ContentSignals(
|
|
100
|
+
kind=RegionKind.TEXT, confidence=0.5
|
|
101
|
+
)
|
|
102
|
+
identifiers = self._extract_identifiers(text)
|
|
103
|
+
has_error = bool(self._error_keywords_pattern.search(text))
|
|
104
|
+
severity_match = self._severity_pattern.search(text)
|
|
105
|
+
return replace(
|
|
106
|
+
signals,
|
|
107
|
+
identifiers=identifiers,
|
|
108
|
+
has_error=signals.has_error or has_error,
|
|
109
|
+
severity=signals.severity
|
|
110
|
+
or (severity_match.group(0).upper() if severity_match else None),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _extract_identifiers(self, text: str) -> tuple[str, ...]:
|
|
114
|
+
found: list[str] = []
|
|
115
|
+
for pattern in self._identifier_patterns:
|
|
116
|
+
found.extend(m.group(0) for m in pattern.finditer(text))
|
|
117
|
+
# De-duplicate while preserving order.
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
unique = []
|
|
120
|
+
for item in found:
|
|
121
|
+
if item not in seen:
|
|
122
|
+
seen.add(item)
|
|
123
|
+
unique.append(item)
|
|
124
|
+
return tuple(unique)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_for_repetition(line: str) -> str:
|
|
128
|
+
"""Collapse volatile substrings (numbers, hex ids, timestamps) for dedup grouping."""
|
|
129
|
+
normalized = re.sub(r"\b[0-9a-fA-F]{8,}\b", "<ID>", line)
|
|
130
|
+
normalized = re.sub(r"\d+", "<N>", normalized)
|
|
131
|
+
return normalized.strip()
|