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 +87 -0
- longloom/chunks.py +109 -0
- longloom/factory.py +245 -0
- longloom/graph.py +220 -0
- longloom/llm.py +185 -0
- longloom/nvidia.py +166 -0
- longloom/processor.py +114 -0
- longloom/prompts.py +103 -0
- longloom/py.typed +0 -0
- longloom/stores/__init__.py +29 -0
- longloom/stores/base.py +139 -0
- longloom/stores/jsonfile.py +115 -0
- longloom/stores/memory.py +63 -0
- longloom/stores/mongo.py +170 -0
- longloom/stores/naming.py +69 -0
- longloom/stores/sql.py +372 -0
- longloom/stores/sqlite.py +47 -0
- longloom/stores/supabase.py +170 -0
- longloom/tokens.py +67 -0
- longloom-1.0.0.dist-info/METADATA +358 -0
- longloom-1.0.0.dist-info/RECORD +24 -0
- longloom-1.0.0.dist-info/WHEEL +5 -0
- longloom-1.0.0.dist-info/licenses/LICENSE +21 -0
- longloom-1.0.0.dist-info/top_level.txt +1 -0
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()
|