ellements 0.2.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 (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,128 @@
1
+ """JSON-file disk cache backend.
2
+
3
+ Each cache entry lives at ``<root>/<key>.json``. The file body is
4
+ a single JSON object::
5
+
6
+ {"value": <whatever>, "created_at": 1700000000.0, "expires_at": null}
7
+
8
+ This backend is fully durable across process restarts. It is **not**
9
+ designed for high write throughput — it does one open/write per
10
+ :meth:`set`. For high-volume workloads, prefer
11
+ :class:`InMemoryCache` or build a Redis/SQLite backend on top of the
12
+ :class:`Cache` Protocol.
13
+
14
+ Values must be JSON-serializable (Pydantic ``BaseModel`` instances
15
+ are accepted because they expose ``.model_dump()``). Round-tripping
16
+ preserves only JSON-native types: dicts, lists, strings, numbers,
17
+ ``True``/``False``/``None``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .cache import CacheEntry
29
+
30
+
31
+ class JsonDiskCache:
32
+ """Durable JSON-on-disk cache satisfying :class:`Cache`.
33
+
34
+ Args:
35
+ root: Directory where entries are stored. Created on
36
+ construction if missing.
37
+ default_ttl: Optional default time-to-live (seconds). When a
38
+ caller omits ``ttl=`` on :meth:`set`, this value is used.
39
+ ``None`` (the default) means entries don't expire on
40
+ their own.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ root: Path | str,
46
+ *,
47
+ default_ttl: float | None = None,
48
+ ) -> None:
49
+ self._root = Path(root)
50
+ self._root.mkdir(parents=True, exist_ok=True)
51
+ self._default_ttl = default_ttl
52
+ self._lock = asyncio.Lock()
53
+
54
+ async def get(self, key: str) -> CacheEntry | None:
55
+ """Return the cached entry for *key*, or ``None`` if missing,
56
+ unreadable, or expired (in which case the file is removed)."""
57
+ path = self._path_for(key)
58
+ if not path.exists():
59
+ return None
60
+ async with self._lock:
61
+ try:
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ except (OSError, json.JSONDecodeError):
64
+ return None
65
+ expires_at = data.get("expires_at")
66
+ if expires_at is not None and expires_at <= time.time():
67
+ path.unlink(missing_ok=True)
68
+ return None
69
+ return CacheEntry(
70
+ value=data.get("value"),
71
+ created_at=float(data.get("created_at", time.time())),
72
+ )
73
+
74
+ async def set(
75
+ self,
76
+ key: str,
77
+ value: Any,
78
+ *,
79
+ ttl: float | None = None,
80
+ ) -> None:
81
+ """Store *value* as a JSON file under ``<root>/<key>.json``.
82
+
83
+ Uses ``ttl`` when provided; falls back to ``default_ttl``.
84
+ Pydantic ``BaseModel`` values are converted via
85
+ ``.model_dump()`` before serialisation.
86
+ """
87
+ effective_ttl = ttl if ttl is not None else self._default_ttl
88
+ now = time.time()
89
+ expires_at = now + effective_ttl if effective_ttl is not None else None
90
+ payload = {
91
+ "value": _to_json_native(value),
92
+ "created_at": now,
93
+ "expires_at": expires_at,
94
+ }
95
+ async with self._lock:
96
+ path = self._path_for(key)
97
+ path.write_text(json.dumps(payload), encoding="utf-8")
98
+
99
+ async def delete(self, key: str) -> None:
100
+ """Remove the file for *key* if present; no-op otherwise."""
101
+ async with self._lock:
102
+ self._path_for(key).unlink(missing_ok=True)
103
+
104
+ async def clear(self) -> None:
105
+ """Remove every ``*.json`` file under ``root``."""
106
+ async with self._lock:
107
+ for path in self._root.glob("*.json"):
108
+ path.unlink(missing_ok=True)
109
+
110
+ def _path_for(self, key: str) -> Path:
111
+ return self._root / f"{key}.json"
112
+
113
+
114
+ def _to_json_native(value: Any) -> Any:
115
+ """Convert *value* into a JSON-native shape.
116
+
117
+ Pydantic models are converted via ``.model_dump()``; dataclasses
118
+ that expose ``.__dict__`` are converted to dicts. Everything else
119
+ is passed through and will raise ``TypeError`` at write time if
120
+ it is not JSON-serializable — a deliberate fail-loud choice so
121
+ misuses surface immediately rather than corrupting the cache.
122
+ """
123
+ if hasattr(value, "model_dump"):
124
+ return value.model_dump()
125
+ return value
126
+
127
+
128
+ __all__ = ["JsonDiskCache"]
@@ -0,0 +1,97 @@
1
+ """Stable cache-key generation for LLM requests.
2
+
3
+ The single public helper :func:`build_cache_key` produces a short
4
+ hex digest that uniquely identifies a request payload modulo
5
+ non-semantic noise:
6
+
7
+ * Mappings are JSON-encoded with ``sort_keys=True``, so dict ordering
8
+ cannot perturb the digest.
9
+ * Falsy "no override" fields (``model=None``, ``response_model=None``,
10
+ empty extras) are normalized to a fixed sentinel so different
11
+ call-sites that ultimately produce identical requests collide on
12
+ the same key.
13
+ * Pydantic ``BaseModel`` types are represented by their fully
14
+ qualified name, since two distinct classes with identical fields
15
+ should not share cached entries.
16
+
17
+ Callers should treat the returned key as **opaque**.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import json
24
+ from collections.abc import Mapping
25
+ from typing import Any
26
+
27
+
28
+ def build_cache_key(
29
+ *,
30
+ method: str,
31
+ model: str,
32
+ messages: Any,
33
+ temperature: float,
34
+ max_tokens: int | None,
35
+ extra: Mapping[str, Any] | None = None,
36
+ response_model: type | None = None,
37
+ ) -> str:
38
+ """Return a stable hex digest for an LLM request.
39
+
40
+ Args:
41
+ method: The :class:`ellements.core.LLMClient` method name
42
+ being cached (e.g. ``"complete"``,
43
+ ``"complete_structured"``).
44
+ model: Final resolved model identifier (after any
45
+ per-call override).
46
+ messages: The normalized messages list / Conversation /
47
+ string. Serialized with :func:`_normalize` so unhashable
48
+ container types still hash consistently.
49
+ temperature: Sampling temperature.
50
+ max_tokens: Optional max tokens cap; ``None`` is preserved.
51
+ extra: Extra kwargs (e.g. ``top_p``, ``stop``) the caller
52
+ passed through. Order-independent.
53
+ response_model: Optional Pydantic model class (for
54
+ ``complete_structured``); represented by its fully
55
+ qualified name.
56
+
57
+ Returns:
58
+ Hex digest (SHA-256, 64 chars) of the canonicalized payload.
59
+ """
60
+ payload = {
61
+ "method": method,
62
+ "model": model,
63
+ "messages": _normalize(messages),
64
+ "temperature": temperature,
65
+ "max_tokens": max_tokens,
66
+ "extra": _normalize(dict(extra or {})),
67
+ "response_model": (
68
+ f"{response_model.__module__}.{response_model.__qualname__}"
69
+ if response_model is not None
70
+ else None
71
+ ),
72
+ }
73
+ encoded = json.dumps(payload, sort_keys=True, default=_json_default)
74
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
75
+
76
+
77
+ def _normalize(value: Any) -> Any:
78
+ """Render *value* into a JSON-friendly, order-insensitive shape."""
79
+ if value is None or isinstance(value, (bool, int, float, str)):
80
+ return value
81
+ if isinstance(value, Mapping):
82
+ return {str(k): _normalize(v) for k, v in sorted(value.items())}
83
+ if isinstance(value, (list, tuple)):
84
+ return [_normalize(item) for item in value]
85
+ if hasattr(value, "model_dump"):
86
+ return _normalize(value.model_dump())
87
+ return repr(value)
88
+
89
+
90
+ def _json_default(value: Any) -> Any:
91
+ """Fallback for ``json.dumps`` when ``_normalize`` returned something exotic."""
92
+ if hasattr(value, "model_dump"):
93
+ return value.model_dump()
94
+ return repr(value)
95
+
96
+
97
+ __all__ = ["build_cache_key"]
@@ -0,0 +1,104 @@
1
+ """Process-local in-memory cache backend.
2
+
3
+ Backed by a plain ``dict`` plus a single :class:`asyncio.Lock` for
4
+ mutation safety. Supports optional TTL with lazy expiry on read.
5
+
6
+ Suitable for tests, demos, and short-lived processes; if you need
7
+ durability across runs, use :class:`ellements.core.caching.JsonDiskCache`
8
+ or build your own backend on top of the :class:`Cache` Protocol.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from .cache import CacheEntry
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class _Record:
23
+ entry: CacheEntry
24
+ expires_at: float | None
25
+
26
+
27
+ class InMemoryCache:
28
+ """Thread/task-safe in-memory cache satisfying :class:`Cache`.
29
+
30
+ Args:
31
+ default_ttl: Default time-to-live (seconds) applied when
32
+ callers omit ``ttl=`` on :meth:`set`. ``None`` (the
33
+ default) means entries never expire on their own.
34
+ max_entries: Optional cap on the number of entries. When the
35
+ cap is exceeded, the **oldest** entry (by ``created_at``)
36
+ is evicted on each new write. ``None`` disables eviction.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ default_ttl: float | None = None,
43
+ max_entries: int | None = None,
44
+ ) -> None:
45
+ self._default_ttl = default_ttl
46
+ self._max_entries = max_entries
47
+ self._store: dict[str, _Record] = {}
48
+ self._lock = asyncio.Lock()
49
+
50
+ async def get(self, key: str) -> CacheEntry | None:
51
+ """Return the cached entry for *key*, or ``None`` if it's missing
52
+ or has expired (in which case the entry is also evicted)."""
53
+ async with self._lock:
54
+ record = self._store.get(key)
55
+ if record is None:
56
+ return None
57
+ if record.expires_at is not None and record.expires_at <= time.time():
58
+ del self._store[key]
59
+ return None
60
+ return record.entry
61
+
62
+ async def set(
63
+ self,
64
+ key: str,
65
+ value: Any,
66
+ *,
67
+ ttl: float | None = None,
68
+ ) -> None:
69
+ """Store *value* under *key*.
70
+
71
+ Uses ``ttl`` when provided; falls back to ``default_ttl`` from
72
+ construction. Triggers eviction of the oldest entry when
73
+ ``max_entries`` is set and the cap is exceeded.
74
+ """
75
+ effective_ttl = ttl if ttl is not None else self._default_ttl
76
+ now = time.time()
77
+ expires_at = now + effective_ttl if effective_ttl is not None else None
78
+ entry = CacheEntry(value=value, created_at=now)
79
+ async with self._lock:
80
+ self._store[key] = _Record(entry=entry, expires_at=expires_at)
81
+ self._enforce_capacity()
82
+
83
+ async def delete(self, key: str) -> None:
84
+ """Remove *key* if present; no-op otherwise."""
85
+ async with self._lock:
86
+ self._store.pop(key, None)
87
+
88
+ async def clear(self) -> None:
89
+ """Drop every entry from the cache."""
90
+ async with self._lock:
91
+ self._store.clear()
92
+
93
+ def _enforce_capacity(self) -> None:
94
+ if self._max_entries is None:
95
+ return
96
+ overflow = len(self._store) - self._max_entries
97
+ if overflow <= 0:
98
+ return
99
+ ordered = sorted(self._store.items(), key=lambda kv: kv[1].entry.created_at)
100
+ for key, _ in ordered[:overflow]:
101
+ del self._store[key]
102
+
103
+
104
+ __all__ = ["InMemoryCache"]
@@ -0,0 +1,262 @@
1
+ """Token-aware text **chunking** primitives.
2
+
3
+ Everything here is LLM-dependency-free and operates on plain strings:
4
+
5
+ - :func:`count_tokens` — token accounting via tiktoken (cached
6
+ encoder lookup so callers can call it freely on hot paths).
7
+ - :func:`simple_truncate` — direct character/token truncation with an
8
+ optional suffix.
9
+ - :func:`map_reduce` — chunked fan-out (one mapper call per chunk)
10
+ followed by a single reducer.
11
+ - :func:`sequential_refine` — rolling synthesis where each chunk
12
+ refines an accumulating draft.
13
+ - :class:`TextProcessor` — strategy-based orchestration of the above
14
+ with a single ``process()`` entry point.
15
+
16
+ These primitives accept arbitrary mapper/reducer/refiner callables so
17
+ callers plug in their own LLM-backed (or non-LLM-backed) processing
18
+ without this module having an opinion.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import functools
24
+ from collections.abc import Callable, Sequence
25
+ from typing import Literal
26
+
27
+ import tiktoken
28
+
29
+ TruncationStrategy = Literal["simple", "map_reduce", "sequential"]
30
+
31
+ _DEFAULT_MODEL = "gpt-5-mini"
32
+ _Mapper = Callable[[str], str]
33
+ _Reducer = Callable[[list[str]], str]
34
+ _Refiner = Callable[[str, str], str]
35
+ _InitialProcessor = Callable[[str], str]
36
+
37
+ __all__ = [
38
+ "TextProcessor",
39
+ "TruncationStrategy",
40
+ "count_tokens",
41
+ "map_reduce",
42
+ "sequential_refine",
43
+ "simple_truncate",
44
+ ]
45
+
46
+
47
+ @functools.lru_cache(maxsize=32)
48
+ def _get_encoding(model: str = _DEFAULT_MODEL) -> tiktoken.Encoding:
49
+ """Return the tiktoken encoding for *model*, falling back to ``cl100k_base``."""
50
+ try:
51
+ return tiktoken.encoding_for_model(model)
52
+ except KeyError:
53
+ return tiktoken.get_encoding("cl100k_base")
54
+
55
+
56
+ def _validate_positive(name: str, value: int) -> None:
57
+ if value <= 0:
58
+ raise ValueError(f"{name} must be greater than 0")
59
+
60
+
61
+ def _resolve_chunk_size(max_tokens: int, chunk_size: int | None) -> int:
62
+ _validate_positive("max_tokens", max_tokens)
63
+ if chunk_size is None:
64
+ return max_tokens
65
+ _validate_positive("chunk_size", chunk_size)
66
+ return chunk_size
67
+
68
+
69
+ def _chunk_tokens(
70
+ tokens: Sequence[int],
71
+ chunk_size: int,
72
+ chunk_overlap: int = 0,
73
+ ) -> list[list[int]]:
74
+ """Split a token sequence into chunks with optional overlap."""
75
+ _validate_positive("chunk_size", chunk_size)
76
+ if chunk_overlap < 0:
77
+ raise ValueError("chunk_overlap must be greater than or equal to 0")
78
+ if chunk_overlap >= chunk_size:
79
+ raise ValueError("chunk_overlap must be less than chunk_size")
80
+
81
+ if not tokens:
82
+ return []
83
+
84
+ stride = chunk_size - chunk_overlap
85
+ chunks: list[list[int]] = []
86
+ for start in range(0, len(tokens), stride):
87
+ chunk = list(tokens[start : start + chunk_size])
88
+ chunks.append(chunk)
89
+ if start + chunk_size >= len(tokens):
90
+ break
91
+ return chunks
92
+
93
+
94
+ def count_tokens(text: str, model: str = _DEFAULT_MODEL) -> int:
95
+ """Return the number of tokens in *text* under the given model's tokenizer."""
96
+ encoding = _get_encoding(model)
97
+ return len(encoding.encode(text))
98
+
99
+
100
+ def simple_truncate(
101
+ text: str,
102
+ max_tokens: int,
103
+ model: str = _DEFAULT_MODEL,
104
+ suffix: str = "\n\n[... truncated ...]",
105
+ ) -> str:
106
+ """Truncate *text* to at most *max_tokens* tokens, appending *suffix*."""
107
+ _validate_positive("max_tokens", max_tokens)
108
+ encoding = _get_encoding(model)
109
+ tokens = encoding.encode(text)
110
+
111
+ if len(tokens) <= max_tokens:
112
+ return text
113
+
114
+ suffix_tokens = len(encoding.encode(suffix))
115
+ keep_tokens = max_tokens - suffix_tokens
116
+ truncated_tokens = tokens[:max_tokens] if keep_tokens <= 0 else tokens[:keep_tokens]
117
+ truncated_text: str = encoding.decode(truncated_tokens)
118
+ return truncated_text + suffix if keep_tokens > 0 else truncated_text
119
+
120
+
121
+ def map_reduce(
122
+ text: str,
123
+ max_tokens: int,
124
+ mapper: _Mapper,
125
+ reducer: _Reducer | None = None,
126
+ chunk_size: int | None = None,
127
+ chunk_overlap: int = 0,
128
+ model: str = _DEFAULT_MODEL,
129
+ ) -> str:
130
+ """Process *text* via a map-reduce pipeline until it fits the token budget."""
131
+ resolved_chunk_size = _resolve_chunk_size(max_tokens, chunk_size)
132
+ reduce_results = reducer or (lambda results: "\n\n".join(results))
133
+ encoding = _get_encoding(model)
134
+ current = text
135
+
136
+ while True:
137
+ current_tokens = encoding.encode(current)
138
+ if len(current_tokens) <= max_tokens:
139
+ return current
140
+
141
+ token_chunks = _chunk_tokens(current_tokens, resolved_chunk_size, chunk_overlap)
142
+ mapped_results = [mapper(encoding.decode(chunk)) for chunk in token_chunks]
143
+ current = reduce_results(mapped_results)
144
+ current_token_count = len(encoding.encode(current))
145
+
146
+ if current_token_count <= max_tokens:
147
+ return current
148
+ if current_token_count >= len(current_tokens):
149
+ raise ValueError(
150
+ "map_reduce requires the mapper/reducer combination to reduce "
151
+ "the text when the input exceeds max_tokens"
152
+ )
153
+
154
+
155
+ def sequential_refine(
156
+ text: str,
157
+ max_tokens: int,
158
+ refiner: _Refiner,
159
+ chunk_size: int | None = None,
160
+ chunk_overlap: int = 0,
161
+ initial_processor: _InitialProcessor | None = None,
162
+ model: str = _DEFAULT_MODEL,
163
+ ) -> str:
164
+ """Process *text* chunk-by-chunk while refining an accumulated result."""
165
+ resolved_chunk_size = _resolve_chunk_size(max_tokens, chunk_size)
166
+ encoding = _get_encoding(model)
167
+ tokens = encoding.encode(text)
168
+
169
+ if len(tokens) <= max_tokens:
170
+ return initial_processor(text) if initial_processor is not None else text
171
+
172
+ token_chunks = _chunk_tokens(tokens, resolved_chunk_size, chunk_overlap)
173
+ chunks = [encoding.decode(chunk) for chunk in token_chunks]
174
+
175
+ accumulated = (
176
+ initial_processor(chunks[0]) if initial_processor is not None else chunks[0]
177
+ )
178
+ for chunk in chunks[1:]:
179
+ accumulated = refiner(accumulated, chunk)
180
+
181
+ if len(encoding.encode(accumulated)) > max_tokens:
182
+ return simple_truncate(accumulated, max_tokens, model)
183
+ return accumulated
184
+
185
+
186
+ class TextProcessor:
187
+ """Configurable orchestrator over the chunking primitives."""
188
+
189
+ def __init__(
190
+ self,
191
+ max_tokens: int = 4000,
192
+ strategy: TruncationStrategy = "simple",
193
+ model: str = _DEFAULT_MODEL,
194
+ chunk_size: int | None = None,
195
+ chunk_overlap: int = 0,
196
+ ) -> None:
197
+ self.max_tokens = max_tokens
198
+ self.strategy = strategy
199
+ self.model = model
200
+ self.chunk_size = chunk_size
201
+ self.chunk_overlap = chunk_overlap
202
+ self._mapper: _Mapper | None = None
203
+ self._reducer: _Reducer | None = None
204
+ self._refiner: _Refiner | None = None
205
+ self._initial_processor: _InitialProcessor | None = None
206
+
207
+ def set_mapper(self, mapper: _Mapper) -> None:
208
+ """Bind the mapper used by the ``map_reduce`` strategy."""
209
+ self._mapper = mapper
210
+
211
+ def set_reducer(self, reducer: _Reducer) -> None:
212
+ """Bind the reducer used by the ``map_reduce`` strategy."""
213
+ self._reducer = reducer
214
+
215
+ def set_refiner(self, refiner: _Refiner) -> None:
216
+ """Bind the refiner used by the ``sequential`` strategy."""
217
+ self._refiner = refiner
218
+
219
+ def set_initial_processor(self, processor: _InitialProcessor) -> None:
220
+ """Bind the initial processor used for the first chunk in sequential mode."""
221
+ self._initial_processor = processor
222
+
223
+ def process(self, text: str) -> str:
224
+ """Process *text* according to the configured strategy."""
225
+ if self.strategy == "simple":
226
+ return simple_truncate(text, self.max_tokens, self.model)
227
+
228
+ if self.strategy == "map_reduce":
229
+ if self._mapper is None:
230
+ raise ValueError(
231
+ "map_reduce strategy requires a mapper. Call set_mapper() first."
232
+ )
233
+ return map_reduce(
234
+ text=text,
235
+ max_tokens=self.max_tokens,
236
+ mapper=self._mapper,
237
+ reducer=self._reducer,
238
+ chunk_size=self.chunk_size,
239
+ chunk_overlap=self.chunk_overlap,
240
+ model=self.model,
241
+ )
242
+
243
+ if self.strategy == "sequential":
244
+ if self._refiner is None:
245
+ raise ValueError(
246
+ "sequential strategy requires a refiner. Call set_refiner() first."
247
+ )
248
+ return sequential_refine(
249
+ text=text,
250
+ max_tokens=self.max_tokens,
251
+ refiner=self._refiner,
252
+ chunk_size=self.chunk_size,
253
+ chunk_overlap=self.chunk_overlap,
254
+ initial_processor=self._initial_processor,
255
+ model=self.model,
256
+ )
257
+
258
+ raise ValueError(f"Unknown strategy: {self.strategy}")
259
+
260
+ def count_tokens(self, text: str) -> int:
261
+ """Count tokens in *text* using the processor's configured model."""
262
+ return count_tokens(text, self.model)