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.
Files changed (55) hide show
  1. contextsage/__init__.py +22 -0
  2. contextsage/budget/__init__.py +3 -0
  3. contextsage/budget/analyzer.py +100 -0
  4. contextsage/budget/tokens.py +216 -0
  5. contextsage/classification/__init__.py +3 -0
  6. contextsage/classification/signals.py +131 -0
  7. contextsage/context/__init__.py +3 -0
  8. contextsage/context/decomposition.py +208 -0
  9. contextsage/context/observer.py +90 -0
  10. contextsage/core/__init__.py +3 -0
  11. contextsage/core/models.py +254 -0
  12. contextsage/core/reconstruction.py +61 -0
  13. contextsage/exceptions.py +67 -0
  14. contextsage/importance/__init__.py +3 -0
  15. contextsage/importance/engine.py +127 -0
  16. contextsage/integrations/__init__.py +3 -0
  17. contextsage/integrations/langgraph/__init__.py +3 -0
  18. contextsage/integrations/langgraph/adapter.py +92 -0
  19. contextsage/integrations/langgraph_xai/__init__.py +3 -0
  20. contextsage/integrations/langgraph_xai/provenance.py +148 -0
  21. contextsage/lineage/__init__.py +3 -0
  22. contextsage/lineage/manager.py +83 -0
  23. contextsage/middleware/__init__.py +9 -0
  24. contextsage/middleware/summarization.py +467 -0
  25. contextsage/observability/__init__.py +3 -0
  26. contextsage/observability/events.py +59 -0
  27. contextsage/parsers/__init__.py +49 -0
  28. contextsage/parsers/base.py +97 -0
  29. contextsage/parsers/code_parser.py +157 -0
  30. contextsage/parsers/error_parser.py +74 -0
  31. contextsage/parsers/grok_patterns.py +90 -0
  32. contextsage/parsers/json_parser.py +56 -0
  33. contextsage/parsers/log_parser.py +78 -0
  34. contextsage/parsers/table_parser.py +37 -0
  35. contextsage/parsers/tree_sitter_support.py +134 -0
  36. contextsage/planning/__init__.py +3 -0
  37. contextsage/planning/planner.py +119 -0
  38. contextsage/planning/trigger.py +78 -0
  39. contextsage/preservation/__init__.py +3 -0
  40. contextsage/preservation/engine.py +130 -0
  41. contextsage/py.typed +0 -0
  42. contextsage/recovery/__init__.py +3 -0
  43. contextsage/recovery/manager.py +98 -0
  44. contextsage/relationships/__init__.py +3 -0
  45. contextsage/relationships/engine.py +123 -0
  46. contextsage/transformation/__init__.py +3 -0
  47. contextsage/transformation/engine.py +106 -0
  48. contextsage/transformation/logs.py +71 -0
  49. contextsage/transformation/structured.py +81 -0
  50. contextsage/validation/__init__.py +3 -0
  51. contextsage/validation/validator.py +116 -0
  52. contextsage-0.1.0.dist-info/METADATA +204 -0
  53. contextsage-0.1.0.dist-info/RECORD +55 -0
  54. contextsage-0.1.0.dist-info/WHEEL +4 -0
  55. contextsage-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -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,3 @@
1
+ """Context budget analysis: token counting and window accounting."""
2
+
3
+ from __future__ import annotations
@@ -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,3 @@
1
+ """Structural signal detection for heterogeneous context regions."""
2
+
3
+ from __future__ import annotations
@@ -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()
@@ -0,0 +1,3 @@
1
+ """Context observation and heterogeneous decomposition into ContextUnits."""
2
+
3
+ from __future__ import annotations