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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- 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)
|