longloom 1.0.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.
longloom/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ """longloom — long-context map/merge/repair for any LLM and any storage.
2
+
3
+ Process documents far larger than a model's context window using a LangGraph
4
+ map/merge/repair workflow, with dependency injection for both the LLM and the
5
+ storage backend::
6
+
7
+ import os
8
+ from longloom import MapReduceProcessor, SQLiteContextStore, init_llm
9
+
10
+ processor = MapReduceProcessor(
11
+ llm=init_llm("nvidia", "nvidia/nemotron-3-ultra-550b-a55b",
12
+ api_key=os.environ["NVIDIA_API_KEY"]),
13
+ store=SQLiteContextStore("ctx.db"),
14
+ )
15
+ result = processor.process(long_document, task="Summarize")
16
+ """
17
+
18
+ from .chunks import ContextChunk, content_hash, split_text
19
+ from .factory import SUPPORTED_SCHEMES, create_store
20
+ from .stores.naming import FieldMap
21
+ from .graph import MapReduceState, build_graph
22
+ from .llm import LLMAdapter, LLMLike, init_llm
23
+ from .processor import MapReduceProcessor
24
+ from .prompts import DEFAULT_PROMPTS, PromptTemplates, render_prompt
25
+ from .stores import (
26
+ MYSQL_DIALECT,
27
+ POSTGRES_DIALECT,
28
+ SQLITE_DIALECT,
29
+ BaseContextStore,
30
+ InMemoryContextStore,
31
+ JSONFileContextStore,
32
+ MongoContextStore,
33
+ SQLiteContextStore,
34
+ SqlContextStore,
35
+ SupabaseContextStore,
36
+ )
37
+ from .tokens import EstimateCounter, TiktokenCounter, TokenCounter, default_counter
38
+
39
+ __version__ = "1.0.0"
40
+
41
+ __all__ = [
42
+ # processor / graph
43
+ "MapReduceProcessor",
44
+ "MapReduceState",
45
+ "build_graph",
46
+ # llm
47
+ "LLMAdapter",
48
+ "LLMLike",
49
+ "init_llm",
50
+ "NVIDIAChatModel",
51
+ # stores
52
+ "BaseContextStore",
53
+ "InMemoryContextStore",
54
+ "SQLiteContextStore",
55
+ "JSONFileContextStore",
56
+ "SqlContextStore",
57
+ "POSTGRES_DIALECT",
58
+ "MYSQL_DIALECT",
59
+ "SQLITE_DIALECT",
60
+ "MongoContextStore",
61
+ "SupabaseContextStore",
62
+ "create_store",
63
+ "SUPPORTED_SCHEMES",
64
+ "FieldMap",
65
+ # tokens
66
+ "TokenCounter",
67
+ "TiktokenCounter",
68
+ "EstimateCounter",
69
+ "default_counter",
70
+ # chunks
71
+ "ContextChunk",
72
+ "split_text",
73
+ "content_hash",
74
+ # prompts
75
+ "PromptTemplates",
76
+ "DEFAULT_PROMPTS",
77
+ "render_prompt",
78
+ ]
79
+
80
+
81
+ def __getattr__(name: str):
82
+ # Optional import: NVIDIAChatModel only pulls `requests` when used.
83
+ if name == "NVIDIAChatModel":
84
+ from .nvidia import NVIDIAChatModel
85
+
86
+ return NVIDIAChatModel
87
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
longloom/chunks.py ADDED
@@ -0,0 +1,109 @@
1
+ """Chunk data model and token-aware text splitting.
2
+
3
+ Backends and counters are pluggable: :func:`split_text` works with any
4
+ callable token counter (tiktoken-based or estimator) and never depends on
5
+ a specific tokenizer's encode/decode round-trip.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Callable, Dict, List
13
+
14
+ __all__ = ["ContextChunk", "content_hash", "utc_now_iso", "split_text"]
15
+
16
+
17
+ def content_hash(text: str, length: int = 16) -> str:
18
+ """Short SHA-256 hash of text, used for content addressing."""
19
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:length]
20
+
21
+
22
+ def utc_now_iso() -> str:
23
+ """UTC timestamp in ISO-8601 format."""
24
+ return datetime.now(timezone.utc).isoformat()
25
+
26
+
27
+ @dataclass
28
+ class ContextChunk:
29
+ """A single chunk of a larger document."""
30
+
31
+ id: str
32
+ content: str
33
+ metadata: Dict[str, Any] = field(default_factory=dict)
34
+ token_count: int = 0
35
+ created_at: str = ""
36
+ chunk_index: int = 0
37
+ total_chunks: int = 1
38
+
39
+
40
+ def split_text(
41
+ text: str,
42
+ max_tokens: int,
43
+ counter: Callable[[str], int],
44
+ ) -> List[str]:
45
+ """Split *text* into pieces of at most *max_tokens* (counter-defined tokens).
46
+
47
+ Strategy — LINE-AWARE, so facts on a single line are never cut in half:
48
+
49
+ 1. Greedily pack whole lines into chunks while the chunk fits
50
+ (``counter()`` measures each line; works with any counter).
51
+ 2. A single line longer than *max_tokens* is hard-split by token count
52
+ (binary search with the counter) — unavoidable last resort.
53
+
54
+ Returns a list of non-empty strings whose concatenation reconstructs the
55
+ original text.
56
+ """
57
+ if max_tokens < 1:
58
+ raise ValueError("max_tokens must be >= 1")
59
+ if not text:
60
+ return []
61
+
62
+ def _hard_split(segment: str) -> List[str]:
63
+ """Token-count split for a single line that exceeds max_tokens."""
64
+ if chr(10) in segment: # defensive; caller passes single lines
65
+ return [segment]
66
+ out: List[str] = []
67
+ remaining = segment
68
+ while remaining:
69
+ if counter(remaining) <= max_tokens:
70
+ out.append(remaining)
71
+ break
72
+ guess = max(1, int(len(remaining) * max_tokens / max(counter(remaining), 1)))
73
+ steps = 0
74
+ while guess > 1 and counter(remaining[:guess]) > max_tokens and steps < 64:
75
+ guess = max(1, int(guess * 0.9))
76
+ steps += 1
77
+ while guess < len(remaining) and counter(remaining[: guess + 1]) <= max_tokens:
78
+ guess += 1
79
+ if guess <= 0:
80
+ guess = 1
81
+ # Snap to a whitespace boundary so identifier tokens (IDs, numbers,
82
+ # handles — anything without spaces) are never cut in half.
83
+ ws = remaining.rfind(" ", 0, guess)
84
+ if ws > guess * 0.5:
85
+ guess = ws + 1
86
+ out.append(remaining[:guess])
87
+ remaining = remaining[guess:]
88
+ return out
89
+
90
+ chunks: List[str] = []
91
+ current: List[str] = []
92
+ current_tokens = 0
93
+ for line in text.splitlines(keepends=True):
94
+ line_tokens = counter(line)
95
+ if line_tokens > max_tokens:
96
+ if current:
97
+ chunks.append("".join(current))
98
+ current, current_tokens = [], 0
99
+ chunks.extend(_hard_split(line.rstrip("\n")) if line.strip() else [line])
100
+ continue
101
+ if current_tokens + line_tokens > max_tokens and current:
102
+ chunks.append("".join(current))
103
+ current, current_tokens = [], 0
104
+ current.append(line)
105
+ current_tokens += line_tokens
106
+ if current:
107
+ chunks.append("".join(current))
108
+
109
+ return [c for c in chunks if c.strip()]
longloom/factory.py ADDED
@@ -0,0 +1,245 @@
1
+ """``create_store(url)`` — build any supported backend from a connection string.
2
+
3
+ Supported URL schemes:
4
+
5
+ ========================== ==============================================
6
+ ``memory://`` :class:`InMemoryContextStore`
7
+ ``json://<dir>`` :class:`JSONFileContextStore`
8
+ ``sqlite:///<path>`` :class:`SQLiteContextStore` (``sqlite://`` = in-memory)
9
+ ``postgresql://<dsn>`` :class:`SqlContextStore` (PostgreSQL dialect)
10
+ ``mysql://<dsn>`` :class:`SqlContextStore` (MySQL/MariaDB dialect)
11
+ ``mongodb://<uri>`` :class:`MongoContextStore` (path = database name)
12
+ ``https://<proj>.supabase.co`` :class:`SupabaseContextStore` (key via SUPABASE_KEY)
13
+ ========================== ==============================================
14
+
15
+ SQL URLs prefer SQLAlchemy when installed (pooled, works with every driver);
16
+ otherwise known drivers (psycopg2/psycopg, pymysql/MySQLdb) are tried directly.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ from typing import Any, Callable, Optional
23
+ from urllib.parse import unquote, urlparse
24
+
25
+ from .stores.base import BaseContextStore
26
+ from .stores.memory import InMemoryContextStore
27
+
28
+ __all__ = ["create_store", "SUPPORTED_SCHEMES"]
29
+
30
+ SUPPORTED_SCHEMES = (
31
+ "memory", "json", "sqlite", "postgres", "postgresql", "mysql", "mariadb",
32
+ "mongodb", "mongodb+srv", "supabase", "https",
33
+ )
34
+
35
+
36
+ def create_store(
37
+ url: str,
38
+ table: str = "context_chunks",
39
+ columns: Optional[Dict[str, str]] = None,
40
+ namespace: Optional[str] = None,
41
+ **kwargs,
42
+ ) -> BaseContextStore:
43
+ """Create a storage backend from a connection string (see module docstring).
44
+
45
+ ``table``, ``columns`` (logical → physical names) and ``namespace`` (a
46
+ discriminator letting several apps share one table/collection) embed
47
+ LongLoom into existing databases — supported by the SQL, MongoDB and
48
+ Supabase backends; ``memory`` and ``json`` ignore them.
49
+ """
50
+ if not url:
51
+ raise ValueError("url must be a non-empty connection string")
52
+ import re as _re
53
+
54
+ if table and not _re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", table):
55
+ raise ValueError(f"invalid table/collection name: {table!r}")
56
+ parsed = urlparse(url)
57
+ scheme = (parsed.scheme or "").lower()
58
+
59
+ if scheme == "memory":
60
+ return InMemoryContextStore(**kwargs)
61
+
62
+ if scheme == "json":
63
+ root = parsed.netloc + parsed.path if parsed.netloc else parsed.path
64
+ from .stores.jsonfile import JSONFileContextStore
65
+
66
+ return JSONFileContextStore(root_dir=unquote(root) or "longloom_data", **kwargs)
67
+
68
+ if scheme == "sqlite":
69
+ path = parsed.path or ""
70
+ if parsed.netloc and parsed.netloc != ":memory:":
71
+ path = parsed.netloc + path
72
+ db_path = unquote(path) if path and path != ":memory:" else ":memory:"
73
+ from .stores.sqlite import SQLiteContextStore
74
+
75
+ return SQLiteContextStore(db_path=db_path, table=table, columns=columns,
76
+ namespace=namespace, **kwargs)
77
+
78
+ if scheme in ("postgres", "postgresql"):
79
+ from .stores.sql import POSTGRES_DIALECT, SqlContextStore
80
+
81
+ return SqlContextStore(
82
+ connect=_sql_connect_factory(url, "postgres"),
83
+ dialect=POSTGRES_DIALECT,
84
+ table=table, columns=columns, namespace=namespace,
85
+ **kwargs,
86
+ )
87
+
88
+ if scheme in ("mysql", "mariadb"):
89
+ from .stores.sql import MYSQL_DIALECT, SqlContextStore
90
+
91
+ return SqlContextStore(
92
+ connect=_sql_connect_factory(url, "mysql"),
93
+ dialect=MYSQL_DIALECT,
94
+ table=table, columns=columns, namespace=namespace,
95
+ **kwargs,
96
+ )
97
+
98
+ if scheme in ("mongodb", "mongodb+srv"):
99
+ from .stores.mongo import MongoContextStore
100
+
101
+ db_name = unquote(parsed.path.lstrip("/")) or kwargs.pop("db_name", "longloom")
102
+ return MongoContextStore(client_or_uri=url, db_name=db_name,
103
+ collection=kwargs.pop("collection", table),
104
+ columns=columns, namespace=namespace, **kwargs)
105
+
106
+ if scheme == "supabase":
107
+ from .stores.supabase import SupabaseContextStore
108
+
109
+ host = parsed.netloc
110
+ # supabase://x.supabase.co → https://x.supabase.co (cloud);
111
+ # supabase://127.0.0.1:8905 → http://… (local PostgREST stub).
112
+ project_url = f"https://{host}" if host.endswith(".supabase.co") else f"http://{host}"
113
+ return SupabaseContextStore(url=project_url, table=table, columns=columns,
114
+ namespace=namespace, **kwargs)
115
+
116
+ if scheme == "https" and re.search(r"\.supabase\.", parsed.netloc or ""):
117
+ from .stores.supabase import SupabaseContextStore
118
+
119
+ return SupabaseContextStore(url=url, table=table, **kwargs)
120
+
121
+ raise ValueError(
122
+ f"Unsupported store URL scheme: {scheme!r}. Supported: memory://, json://, "
123
+ "sqlite://, postgresql://, mysql://, mongodb://, supabase://, https://<proj>.supabase.co"
124
+ )
125
+
126
+
127
+ # ----------------------------------------------------------------------
128
+ # SQL connection helpers
129
+ # ----------------------------------------------------------------------
130
+
131
+ def _sql_connect_factory(url: str, kind: str) -> Callable[[], Any]:
132
+ """Return a zero-arg callable yielding a DB-API-style connection."""
133
+
134
+ # Preferred: SQLAlchemy — uniform support for every driver and pooling.
135
+ try:
136
+ import sqlalchemy # noqa: F401
137
+ from sqlalchemy import create_engine
138
+
139
+ norm = url
140
+ if norm.startswith("postgres://"):
141
+ norm = "postgresql://" + norm[len("postgres://"):]
142
+ if kind == "mysql" and not norm.startswith("mysql+"):
143
+ try:
144
+ import MySQLdb # type: ignore # noqa: F401
145
+ except ImportError:
146
+ try:
147
+ import pymysql # type: ignore # noqa: F401
148
+
149
+ norm = "mysql+pymysql://" + norm.split("://", 1)[1]
150
+ except ImportError:
151
+ pass # let SQLAlchemy report the missing driver
152
+
153
+ engine = create_engine(norm, pool_pre_ping=True)
154
+
155
+ class _SaConn:
156
+ """DB-API-style adapter over a SQLAlchemy Connection.
157
+
158
+ SQLAlchemy 2.0 ``Connection.execute`` is statement-oriented, not
159
+ cursor-oriented (returns the statement result only via
160
+ ``exec_driver_sql``), so expose exactly the interface
161
+ SqlContextStore needs.
162
+ """
163
+
164
+ def __init__(self, sa_conn: Any):
165
+ self._c = sa_conn
166
+
167
+ def execute(self, sql: str, params: Optional[list] = None) -> Any:
168
+ # exec_driver_sql requires a tuple for positional params
169
+ # (a bare list fails SQLAlchemy's param validation).
170
+ return self._c.exec_driver_sql(sql, tuple(params or []))
171
+
172
+ def commit(self) -> None:
173
+ self._c.commit()
174
+
175
+ def rollback(self) -> None:
176
+ self._c.rollback()
177
+
178
+ def close(self) -> None:
179
+ self._c.close()
180
+
181
+ def connect_sa() -> Any:
182
+ return _SaConn(engine.connect())
183
+
184
+ # Validate eagerly so misconfigurations surface at construction time.
185
+ connect_sa().execute("SELECT 1")
186
+ return connect_sa
187
+ except ImportError:
188
+ pass
189
+
190
+ # Fallback: known drivers directly.
191
+ if kind == "postgres":
192
+ def connect_pg() -> Any:
193
+ try:
194
+ import psycopg2
195
+
196
+ return _as_executable(psycopg2.connect(url))
197
+ except ImportError:
198
+ pass
199
+ try:
200
+ import psycopg
201
+
202
+ return _as_executable(psycopg.connect(url))
203
+ except ImportError as exc:
204
+ raise ImportError(
205
+ "No PostgreSQL driver found. Install one of: "
206
+ "psycopg2-binary, psycopg, or sqlalchemy. "
207
+ "pip install 'longloom[postgres]'"
208
+ ) from exc
209
+
210
+ connect_pg() # validate eagerly
211
+ return connect_pg
212
+
213
+ def connect_mysql() -> Any:
214
+ parsed = urlparse(url)
215
+ conn_kwargs = {
216
+ "host": parsed.hostname or "127.0.0.1",
217
+ "port": parsed.port or 3306,
218
+ "user": unquote(parsed.username or "root"),
219
+ "password": unquote(parsed.password or ""),
220
+ "database": unquote(parsed.path.lstrip("/")) or None,
221
+ }
222
+ conn_kwargs = {k: v for k, v in conn_kwargs.items() if v is not None}
223
+ try:
224
+ import pymysql
225
+
226
+ return _as_executable(pymysql.connect(**conn_kwargs))
227
+ except ImportError:
228
+ pass
229
+ try:
230
+ import MySQLdb
231
+
232
+ return _as_executable(MySQLdb.connect(**conn_kwargs))
233
+ except ImportError as exc:
234
+ raise ImportError(
235
+ "No MySQL driver found. Install pymysql or mysqlclient, "
236
+ "or sqlalchemy. pip install 'longloom[mysql]'"
237
+ ) from exc
238
+
239
+ connect_mysql() # validate eagerly
240
+ return connect_mysql
241
+
242
+
243
+ def _as_executable(conn: Any) -> Any:
244
+ from .stores.sql import _as_executable as _adapt
245
+ return _adapt(conn)
longloom/graph.py ADDED
@@ -0,0 +1,220 @@
1
+ """The LangGraph map → merge → repair workflow, with all dependencies injected.
2
+
3
+ Every node is a closure over an :class:`LLMAdapter` and a
4
+ :class:`BaseContextStore` supplied by the caller — the graph itself has zero
5
+ knowledge of any vendor or storage engine.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Optional, TypedDict
10
+
11
+ from langgraph.graph import END, StateGraph
12
+
13
+ from .chunks import ContextChunk
14
+ from .llm import LLMAdapter
15
+ from .prompts import DEFAULT_PROMPTS, PromptTemplates, render_prompt
16
+ from .stores.base import BaseContextStore
17
+ from .tokens import TokenCounter, default_counter
18
+
19
+ __all__ = ["MapReduceState", "build_graph"]
20
+
21
+
22
+ class MapReduceState(TypedDict, total=False):
23
+ # Input
24
+ input_content: str
25
+ session_id: str
26
+ task: str
27
+ task_focus: str
28
+ # Config
29
+ max_chunk_tokens: int
30
+ max_context_tokens: int
31
+ max_repair_attempts: int
32
+ # Map phase
33
+ chunks: List[ContextChunk]
34
+ map_results: List[str]
35
+ # Merge phase
36
+ merged_result: str
37
+ merge_token_count: int
38
+ # Repair phase
39
+ needs_repair: bool
40
+ repair_attempts: int
41
+ final_result: str
42
+ # Metadata
43
+ total_input_tokens: int
44
+ processing_log: List[str]
45
+
46
+
47
+ def build_graph(
48
+ llm: LLMAdapter,
49
+ store: BaseContextStore,
50
+ token_counter: Optional[TokenCounter] = None,
51
+ prompts: Optional[PromptTemplates] = None,
52
+ ) -> Any:
53
+ """Compile the map/merge/repair graph with injected dependencies."""
54
+ prompts = prompts or DEFAULT_PROMPTS
55
+ counter: TokenCounter = token_counter or store.counter
56
+
57
+ def _combine(parts: List[str], state: MapReduceState, label: str) -> str:
58
+ """One combine call over a group of results (fits the merge budget)."""
59
+ chunk_results_text = "\n\n---\n\n".join(
60
+ f"Part {i + 1}:\n{part}" for i, part in enumerate(parts)
61
+ )
62
+ prompt = render_prompt(
63
+ prompts.merge,
64
+ {
65
+ "task": state["task"],
66
+ "task_focus": state.get("task_focus", "key information and insights"),
67
+ "chunk_results": chunk_results_text,
68
+ },
69
+ )
70
+ return llm.complete(prompt)
71
+
72
+ # ------------------------------------------------------------------
73
+ # Nodes (closures over llm/store/prompts/counter)
74
+ # ------------------------------------------------------------------
75
+
76
+ def chunk_input(state: MapReduceState) -> Dict[str, Any]:
77
+ chunks = store.store_context(
78
+ content=state["input_content"],
79
+ session_id=state["session_id"],
80
+ metadata={"task": state["task"]},
81
+ max_chunk_tokens=state.get("max_chunk_tokens", 3000),
82
+ )
83
+ total_tokens = sum(c.token_count for c in chunks)
84
+ return {
85
+ "chunks": chunks,
86
+ "total_input_tokens": total_tokens,
87
+ "repair_attempts": 0,
88
+ "processing_log": [
89
+ f"Chunked input into {len(chunks)} chunks ({total_tokens} total tokens)"
90
+ ],
91
+ }
92
+
93
+ def map_chunks(state: MapReduceState) -> Dict[str, Any]:
94
+ chunks = state["chunks"]
95
+ total = len(chunks)
96
+ log = list(state.get("processing_log", []))
97
+ results: List[str] = []
98
+ for chunk in chunks:
99
+ prompt = render_prompt(
100
+ prompts.map,
101
+ {
102
+ "task": state["task"],
103
+ "task_focus": state.get("task_focus", "key information and insights"),
104
+ "chunk_index": chunk.chunk_index + 1,
105
+ "total_chunks": total,
106
+ "chunk_content": chunk.content,
107
+ },
108
+ )
109
+ results.append(llm.complete(prompt))
110
+ log.append(f"Mapped chunk {chunk.chunk_index + 1}/{total}: {len(results[-1])} chars")
111
+ return {"map_results": results, "processing_log": log}
112
+
113
+ def merge_results(state: MapReduceState) -> Dict[str, Any]:
114
+ results = state["map_results"]
115
+ log = list(state.get("processing_log", []))
116
+ max_context = state.get("max_context_tokens", 8192)
117
+
118
+ # Merge-input budget: keep every combine call comfortably inside the
119
+ # model window (60% head-room for instructions + output).
120
+ budget = max(1000, int(max_context * 0.6))
121
+
122
+ # HIERARCHICAL MERGE: with verbatim/exhaustive map phases the combined
123
+ # map results can exceed any model's input window. Merge in groups
124
+ # that fit the budget, recursively, until one result remains.
125
+ current = list(results)
126
+ passes = 0
127
+ while True:
128
+ total = sum(counter(r) for r in current) + 30 * len(current)
129
+ if total <= budget or len(current) <= 1:
130
+ break
131
+ avg = max(1, total // len(current))
132
+ # group size from CURRENT average result size: k * avg <= budget
133
+ k = max(2, budget // avg)
134
+ if k >= len(current):
135
+ break # cannot split further; final merge below takes the risk
136
+ nxt: List[str] = []
137
+ for i in range(0, len(current), k):
138
+ group = current[i:i + k]
139
+ nxt.append(_combine(group, state, f"pass{passes + 1}-g{i // k + 1}"))
140
+ current = nxt
141
+ passes += 1
142
+ log.append(f"Hierarchical merge pass {passes}: "
143
+ f"{len(results)} map results -> {len(current)} partials")
144
+
145
+ if len(current) == 1:
146
+ merged = current[0]
147
+ else:
148
+ merged = _combine(current, state, "final")
149
+
150
+ token_count = counter(merged)
151
+ log_msg = (f"Merged {len(results)} results into {token_count} tokens"
152
+ + (f" ({passes} hierarchical pass(es))" if passes else ""))
153
+ log.append(log_msg)
154
+ return {
155
+ "merged_result": merged,
156
+ "merge_token_count": token_count,
157
+ "needs_repair": token_count > max_context,
158
+ "processing_log": log,
159
+ }
160
+
161
+ def repair_result(state: MapReduceState) -> Dict[str, Any]:
162
+ current_tokens = state["merge_token_count"]
163
+ max_context = state.get("max_context_tokens", 8192)
164
+ attempts = state.get("repair_attempts", 0)
165
+ prompt = render_prompt(
166
+ prompts.repair,
167
+ {
168
+ "task": state["task"],
169
+ "task_focus": state.get("task_focus", "key information and insights"),
170
+ "current_tokens": current_tokens,
171
+ "max_tokens": max_context,
172
+ "merged_result": state["merged_result"],
173
+ },
174
+ )
175
+ repaired = llm.complete(prompt)
176
+ repaired_tokens = counter(repaired)
177
+ attempts += 1
178
+ log = list(state.get("processing_log", []))
179
+ log.append(f"Repair attempt {attempts}: {current_tokens} -> {repaired_tokens} tokens")
180
+
181
+ still_too_long = repaired_tokens > max_context
182
+ can_retry = attempts < state.get("max_repair_attempts", 3)
183
+ update: Dict[str, Any] = {
184
+ "merged_result": repaired,
185
+ "merge_token_count": repaired_tokens,
186
+ "needs_repair": still_too_long and can_retry,
187
+ "repair_attempts": attempts,
188
+ "processing_log": log,
189
+ }
190
+ if not (still_too_long and can_retry):
191
+ update["final_result"] = repaired
192
+ return update
193
+
194
+ def finalize(state: MapReduceState) -> Dict[str, Any]:
195
+ final = state.get("final_result") or state.get("merged_result", "")
196
+ log = list(state.get("processing_log", [])) + ["Processing complete"]
197
+ return {"final_result": final, "processing_log": log}
198
+
199
+ # ------------------------------------------------------------------
200
+ # Wiring
201
+ # ------------------------------------------------------------------
202
+
203
+ def _route(state: MapReduceState) -> str:
204
+ return "repair_result" if state.get("needs_repair", False) else "finalize"
205
+
206
+ workflow = StateGraph(MapReduceState)
207
+ workflow.add_node("chunk_input", chunk_input)
208
+ workflow.add_node("map_chunks", map_chunks)
209
+ workflow.add_node("merge_results", merge_results)
210
+ workflow.add_node("repair_result", repair_result)
211
+ workflow.add_node("finalize", finalize)
212
+
213
+ workflow.set_entry_point("chunk_input")
214
+ workflow.add_edge("chunk_input", "map_chunks")
215
+ workflow.add_edge("map_chunks", "merge_results")
216
+ workflow.add_conditional_edges("merge_results", _route)
217
+ workflow.add_conditional_edges("repair_result", _route)
218
+ workflow.add_edge("finalize", END)
219
+
220
+ return workflow.compile()