jev-col 0.5.1__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.
- jcol/__init__.py +9 -0
- jcol/__main__.py +3 -0
- jcol/app.py +231 -0
- jcol/batch.py +122 -0
- jcol/cli.py +413 -0
- jcol/codebook.py +97 -0
- jcol/core.py +5 -0
- jcol/engine.py +293 -0
- jcol/evaluation.py +89 -0
- jcol/pool.py +177 -0
- jcol/project.py +122 -0
- jcol/spec.py +72 -0
- jcol/static/index.html +329 -0
- jcol/tables.py +129 -0
- jev_col-0.5.1.dist-info/METADATA +381 -0
- jev_col-0.5.1.dist-info/RECORD +19 -0
- jev_col-0.5.1.dist-info/WHEEL +4 -0
- jev_col-0.5.1.dist-info/entry_points.txt +2 -0
- jev_col-0.5.1.dist-info/licenses/LICENSE +21 -0
jcol/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Apply natural-language codebooks to tables, with resumable annotation and evaluation."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.5.1"
|
|
4
|
+
|
|
5
|
+
from .batch import AnnotationResult, annotate, annotate_async
|
|
6
|
+
from .codebook import Codebook
|
|
7
|
+
from .evaluation import evaluate
|
|
8
|
+
|
|
9
|
+
__all__ = ["AnnotationResult", "Codebook", "annotate", "annotate_async", "evaluate"]
|
jcol/__main__.py
ADDED
jcol/app.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""jcol: open a table in the browser and add columns to it by describing them.
|
|
2
|
+
|
|
3
|
+
jcol data/complaints-5k.parquet --text narrative
|
|
4
|
+
|
|
5
|
+
The server holds the API key, the data and the scheduler. The page is one static file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
import webbrowser
|
|
17
|
+
from dataclasses import replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
import uvicorn
|
|
23
|
+
from starlette.applications import Starlette
|
|
24
|
+
from starlette.datastructures import Headers
|
|
25
|
+
from starlette.middleware import Middleware
|
|
26
|
+
from starlette.responses import FileResponse, JSONResponse, Response
|
|
27
|
+
from starlette.routing import Route, WebSocketRoute
|
|
28
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from jevkit_runtime import AnswerStore, Client, JevFatal, resolve
|
|
32
|
+
from .core import PROVIDERS
|
|
33
|
+
from .engine import Engine
|
|
34
|
+
from .pool import LocalPool, ProcessPool
|
|
35
|
+
from .project import Project
|
|
36
|
+
from .tables import append_columns, identity, read_table, row_texts, select_inputs
|
|
37
|
+
|
|
38
|
+
STATIC = Path(__file__).parent / "static"
|
|
39
|
+
PREVIEW_CHARS = 320
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LocalRequestsOnly:
|
|
43
|
+
"""Reject DNS rebinding and cross-origin browser access to the local server."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, app):
|
|
46
|
+
self.app = app
|
|
47
|
+
|
|
48
|
+
async def __call__(self, scope, receive, send):
|
|
49
|
+
if scope["type"] not in ("http", "websocket"):
|
|
50
|
+
return await self.app(scope, receive, send)
|
|
51
|
+
headers = Headers(scope=scope)
|
|
52
|
+
host, origin = headers.get("host", ""), headers.get("origin")
|
|
53
|
+
try:
|
|
54
|
+
parsed = urlsplit("http://" + host)
|
|
55
|
+
local = parsed.hostname in {"localhost", "127.0.0.1", "::1"} and not parsed.username and not parsed.password
|
|
56
|
+
local = local and parsed.netloc == host and not parsed.path and not parsed.query and not parsed.fragment
|
|
57
|
+
except ValueError:
|
|
58
|
+
local = False
|
|
59
|
+
scheme = "https" if scope.get("scheme") in ("https", "wss") else "http"
|
|
60
|
+
same_origin = origin is None or origin == f"{scheme}://{host}"
|
|
61
|
+
if not local or not same_origin:
|
|
62
|
+
if scope["type"] == "websocket":
|
|
63
|
+
await send({"type": "websocket.close", "code": 1008})
|
|
64
|
+
else:
|
|
65
|
+
await Response("Local, same-origin requests only", status_code=403 if local else 400)(scope, receive, send)
|
|
66
|
+
return
|
|
67
|
+
await self.app(scope, receive, send)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_table(path: Path, text: str | list[str] | None, limit: int | None):
|
|
71
|
+
if not path.exists():
|
|
72
|
+
raise SystemExit(f"jcol: no such file: {path}")
|
|
73
|
+
try:
|
|
74
|
+
df = read_table(path, limit)
|
|
75
|
+
inputs = select_inputs(df, text)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise SystemExit(f"jcol: {exc}") from exc
|
|
78
|
+
return df, inputs[0] if len(inputs) == 1 else inputs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build(df: pl.DataFrame, text, *, source: str, budget: float, api: str | None, model: str | None,
|
|
82
|
+
cache: bool, workers: int, per_worker: int, project: Path | None = None,
|
|
83
|
+
max_chars: int | None = 4000) -> Starlette:
|
|
84
|
+
if not math.isfinite(budget) or budget < 0 or workers < 0 or per_worker <= 0:
|
|
85
|
+
raise ValueError("budget and workers must be nonnegative; per-worker must be positive")
|
|
86
|
+
if max_chars is not None and max_chars <= 0:
|
|
87
|
+
raise ValueError("max-chars must be positive, or 0 to send full rows")
|
|
88
|
+
inputs = select_inputs(df, text)
|
|
89
|
+
texts = row_texts(df, inputs)
|
|
90
|
+
backend = resolve(PROVIDERS, api, model=model)
|
|
91
|
+
model, endpoint = backend.model, backend.url
|
|
92
|
+
store = Project(project, identity(df, inputs, model=model, endpoint=endpoint, max_chars=max_chars)) if project else None
|
|
93
|
+
pool = (ProcessPool(backend, workers, per_worker) if workers > 0
|
|
94
|
+
else LocalPool(Client(backend, timeout=12), per_worker))
|
|
95
|
+
engine = Engine(texts, pool, backend=backend, cache=AnswerStore() if cache else None, budget=budget,
|
|
96
|
+
max_chars=max_chars, project=store)
|
|
97
|
+
fields = [c for c in df.columns if c not in inputs]
|
|
98
|
+
table = {
|
|
99
|
+
"version": __version__, "source": source, "n": df.height, "text": ", ".join(inputs), "inputs": list(inputs),
|
|
100
|
+
"fields": fields, "order": engine.order,
|
|
101
|
+
"previews": [t[:PREVIEW_CHARS] for t in engine.texts],
|
|
102
|
+
"values": {c: df[c].cast(pl.String).fill_null("").to_list() for c in fields},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async def index(request):
|
|
106
|
+
return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-store"}) # the page changes with the package
|
|
107
|
+
|
|
108
|
+
async def init(request):
|
|
109
|
+
return JSONResponse(table | {"stats": engine.stats()})
|
|
110
|
+
|
|
111
|
+
async def row(request):
|
|
112
|
+
i = int(request.path_params["i"])
|
|
113
|
+
if not 0 <= i < df.height:
|
|
114
|
+
return JSONResponse({"error": "no such row"}, status_code=404)
|
|
115
|
+
return JSONResponse({k: (v if isinstance(v, (str, int, float, bool)) or v is None else str(v))
|
|
116
|
+
for k, v in df.row(i, named=True).items()})
|
|
117
|
+
|
|
118
|
+
async def export(request):
|
|
119
|
+
try:
|
|
120
|
+
payload = await request.json()
|
|
121
|
+
rows = payload.get("rows", list(range(df.height)))
|
|
122
|
+
if not isinstance(rows, list) or any(type(r) is not int or not 0 <= r < df.height for r in rows):
|
|
123
|
+
raise ValueError("rows must be valid zero-based row indices")
|
|
124
|
+
columns, used = [], set(df.columns)
|
|
125
|
+
for col in engine.columns.values():
|
|
126
|
+
if not col.committed:
|
|
127
|
+
continue
|
|
128
|
+
name = col.spec.name
|
|
129
|
+
while name in used or name + "__confidence" in used:
|
|
130
|
+
name = "jcol_" + col.id + "__" + name
|
|
131
|
+
used.update((name, name + "__confidence"))
|
|
132
|
+
columns.append(replace(col, spec=replace(col.spec, name=name)))
|
|
133
|
+
output = append_columns(df, columns)[rows]
|
|
134
|
+
return Response(output.write_csv(), media_type="text/csv",
|
|
135
|
+
headers={"Content-Disposition": 'attachment; filename="jcol-export.csv"'})
|
|
136
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
137
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
138
|
+
|
|
139
|
+
async def ws(socket: WebSocket):
|
|
140
|
+
await socket.accept()
|
|
141
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
142
|
+
engine.listeners.add(queue)
|
|
143
|
+
await socket.send_text(json.dumps(engine.snapshot()))
|
|
144
|
+
|
|
145
|
+
async def pump():
|
|
146
|
+
while True:
|
|
147
|
+
await socket.send_text(json.dumps(await queue.get()))
|
|
148
|
+
|
|
149
|
+
sender = asyncio.ensure_future(pump())
|
|
150
|
+
try:
|
|
151
|
+
while True:
|
|
152
|
+
m = json.loads(await socket.receive_text())
|
|
153
|
+
kind = m.get("type")
|
|
154
|
+
if kind == "preview":
|
|
155
|
+
engine.preview(m.get("header", ""), [int(r) for r in m.get("rows", [])][:80])
|
|
156
|
+
elif kind == "commit":
|
|
157
|
+
engine.visible = [int(r) for r in m.get("rows", [])][:80]
|
|
158
|
+
engine.commit(m.get("header", ""))
|
|
159
|
+
elif kind == "view":
|
|
160
|
+
engine.view([int(r) for r in m.get("rows", [])][:80])
|
|
161
|
+
elif kind == "remove":
|
|
162
|
+
engine.remove(str(m.get("id")))
|
|
163
|
+
except WebSocketDisconnect:
|
|
164
|
+
pass
|
|
165
|
+
finally:
|
|
166
|
+
sender.cancel()
|
|
167
|
+
engine.listeners.discard(queue)
|
|
168
|
+
|
|
169
|
+
@contextlib.asynccontextmanager
|
|
170
|
+
async def lifespan(app):
|
|
171
|
+
try:
|
|
172
|
+
await engine.start()
|
|
173
|
+
print(f"jcol: ready ({workers or 1} worker{'s' if workers > 1 else ''})", file=sys.stderr)
|
|
174
|
+
yield
|
|
175
|
+
finally:
|
|
176
|
+
await engine.close()
|
|
177
|
+
if engine.cache:
|
|
178
|
+
engine.cache.db.close()
|
|
179
|
+
if store:
|
|
180
|
+
store.close()
|
|
181
|
+
|
|
182
|
+
return Starlette(routes=[Route("/", index), Route("/api/init", init), Route("/api/row/{i}", row),
|
|
183
|
+
Route("/api/export", export, methods=["POST"]),
|
|
184
|
+
WebSocketRoute("/ws", ws)], lifespan=lifespan,
|
|
185
|
+
middleware=[Middleware(LocalRequestsOnly)])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cli(argv=None) -> None:
|
|
189
|
+
ap = argparse.ArgumentParser(prog="jcol browse", description="Open a table in the browser and add columns by describing them.",
|
|
190
|
+
epilog="Batch: jcol run --help. Offline validation: jcol evaluate --help.")
|
|
191
|
+
ap.add_argument("file", help="a .parquet, .csv or .tsv table")
|
|
192
|
+
ap.add_argument("--text", nargs="+", metavar="COLUMN", help="input columns Jev reads (default: the one with the most text)")
|
|
193
|
+
ap.add_argument("--limit", type=int, metavar="N", help="use only the first N rows")
|
|
194
|
+
ap.add_argument("--budget", type=float, default=2.0, metavar="DOLLARS", help="stop spending at this much (default 2.00)")
|
|
195
|
+
ap.add_argument("--port", type=int, default=8765)
|
|
196
|
+
ap.add_argument("--api", choices=("typesafe", "openrouter"), help="which API to call (default: whichever has a key)")
|
|
197
|
+
ap.add_argument("--model", metavar="ID")
|
|
198
|
+
ap.add_argument("--no-cache", action="store_true", help="do not read or write the answer cache")
|
|
199
|
+
ap.add_argument("--workers", type=int, default=16, metavar="N",
|
|
200
|
+
help="worker processes; one process tops out near 200 calls a second (default 16; 0 runs in-process)")
|
|
201
|
+
ap.add_argument("--per-worker", type=int, default=64, metavar="N", help="background calls in flight per worker (default 64)")
|
|
202
|
+
ap.add_argument("--no-open", action="store_true", help="do not open a browser window")
|
|
203
|
+
save = ap.add_mutually_exclusive_group()
|
|
204
|
+
save.add_argument("--project", type=Path, help="saved columns (default: FILE.jcol.sqlite)")
|
|
205
|
+
save.add_argument("--no-project", action="store_true", help="keep columns only in memory")
|
|
206
|
+
ap.add_argument("--max-chars", type=int, default=4000, help="characters sent per row (default 4000; 0 sends full rows)")
|
|
207
|
+
ap.add_argument("--version", action="version", version=f"jcol {__version__}")
|
|
208
|
+
a = ap.parse_args(argv)
|
|
209
|
+
df, text = load_table(Path(a.file), a.text, a.limit)
|
|
210
|
+
try:
|
|
211
|
+
project = None if a.no_project else (a.project or Path(str(a.file) + ".jcol.sqlite"))
|
|
212
|
+
if project and project.resolve() == Path(a.file).resolve():
|
|
213
|
+
raise ValueError("project and source table paths must be distinct")
|
|
214
|
+
app = build(df, text, source=Path(a.file).name, budget=a.budget, api=a.api, model=a.model,
|
|
215
|
+
cache=not a.no_cache, workers=a.workers, per_worker=a.per_worker, project=project,
|
|
216
|
+
max_chars=None if a.max_chars == 0 else a.max_chars)
|
|
217
|
+
except (JevFatal, ValueError, OSError) as e:
|
|
218
|
+
raise SystemExit(f"jcol: {e}")
|
|
219
|
+
truncated = sum(len(t) > a.max_chars for t in row_texts(df, select_inputs(df, text))) if a.max_chars else 0
|
|
220
|
+
if truncated:
|
|
221
|
+
print(f"jcol: warning: {truncated} rows truncated to {a.max_chars} characters; use --max-chars 0 for full rows", file=sys.stderr)
|
|
222
|
+
url = f"http://127.0.0.1:{a.port}"
|
|
223
|
+
print(f"jcol: {df.height:,} rows of {Path(a.file).name}; reading column {text!r}; budget ${a.budget:.2f}\njcol: {url}",
|
|
224
|
+
file=sys.stderr)
|
|
225
|
+
if not a.no_open:
|
|
226
|
+
webbrowser.open(url)
|
|
227
|
+
uvicorn.run(app, host="127.0.0.1", port=a.port, log_level="warning")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
cli()
|
jcol/batch.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Headless annotation using the same scheduler and durable columns as the browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import warnings
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
|
|
14
|
+
from .codebook import Codebook
|
|
15
|
+
from jevkit_runtime import AnswerStore, Client, resolve
|
|
16
|
+
from .core import PROVIDERS
|
|
17
|
+
from .engine import Engine
|
|
18
|
+
from .evaluation import evaluate
|
|
19
|
+
from .pool import LocalPool
|
|
20
|
+
from .project import Project
|
|
21
|
+
from .tables import append_columns, identity, read_table, row_texts, select_inputs, write_table
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AnnotationResult:
|
|
26
|
+
table: pl.DataFrame
|
|
27
|
+
metadata: dict
|
|
28
|
+
evaluation: dict
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def complete(self) -> bool:
|
|
32
|
+
return self.metadata["complete"]
|
|
33
|
+
|
|
34
|
+
def write(self, path: str | Path) -> None:
|
|
35
|
+
write_table(self.table, path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def annotate(table: pl.DataFrame | str | Path, codebook: Codebook | dict | str | Path, **kwargs) -> AnnotationResult:
|
|
39
|
+
"""Apply a codebook synchronously. In notebooks with a running loop, await annotate_async instead."""
|
|
40
|
+
try:
|
|
41
|
+
asyncio.get_running_loop()
|
|
42
|
+
except RuntimeError:
|
|
43
|
+
return asyncio.run(annotate_async(table, codebook, **kwargs))
|
|
44
|
+
raise RuntimeError("an event loop is already running; use await jcol.annotate_async(...)")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def annotate_async(table: pl.DataFrame | str | Path, codebook: Codebook | dict | str | Path, *,
|
|
48
|
+
project: str | Path | None = None, api: str | None = None, model: str | None = None,
|
|
49
|
+
budget: float = 2.0, concurrency: int = 32, max_chars: int | None = None,
|
|
50
|
+
cache: bool = True, progress: Callable[[dict], None] | None = None) -> AnnotationResult:
|
|
51
|
+
"""Return all source columns plus values/confidences; preserve partial results on budget or API failure.
|
|
52
|
+
|
|
53
|
+
`project` checkpoints each successful cell and refuses changed data, inputs, codebook or model settings.
|
|
54
|
+
`budget` is checked before scheduling calls; calls already in flight may exceed it. It resets per invocation.
|
|
55
|
+
"""
|
|
56
|
+
validate_settings(budget, concurrency, max_chars)
|
|
57
|
+
df, book, inputs, texts, truncated = prepare(table, codebook, max_chars=max_chars)
|
|
58
|
+
if truncated:
|
|
59
|
+
warnings.warn(f"{truncated} rows will be truncated to {max_chars} characters", stacklevel=2)
|
|
60
|
+
backend = resolve(PROVIDERS, api, model=model)
|
|
61
|
+
model, endpoint = backend.model, backend.url
|
|
62
|
+
context = identity(df, inputs, model=model, endpoint=endpoint, max_chars=max_chars, codebook=book.to_dict())
|
|
63
|
+
store = Project(project, context) if project is not None else None
|
|
64
|
+
answer_cache = None
|
|
65
|
+
engine = None
|
|
66
|
+
try:
|
|
67
|
+
answer_cache = AnswerStore() if cache else None
|
|
68
|
+
pool = LocalPool(Client(backend, timeout=12), concurrency, warmup=False)
|
|
69
|
+
engine = Engine(texts, pool, backend=backend, cache=answer_cache, budget=budget, max_chars=max_chars,
|
|
70
|
+
project=store)
|
|
71
|
+
existing = {c.spec.name for c in engine.columns.values()}
|
|
72
|
+
for variable in book.variables:
|
|
73
|
+
if variable.spec.name not in existing:
|
|
74
|
+
engine.commit_spec(variable.spec)
|
|
75
|
+
if any(len(c.values) < df.height for c in engine.columns.values()):
|
|
76
|
+
await engine.start()
|
|
77
|
+
while not engine.idle.is_set():
|
|
78
|
+
try:
|
|
79
|
+
await asyncio.wait_for(engine.idle.wait(), timeout=1)
|
|
80
|
+
except asyncio.TimeoutError:
|
|
81
|
+
if progress:
|
|
82
|
+
progress({"filled": sum(len(c.values) for c in engine.columns.values()),
|
|
83
|
+
"total": df.height * len(book.variables), "stats": engine.stats()})
|
|
84
|
+
output = append_columns(df, engine.columns.values())
|
|
85
|
+
counts = {c.spec.name: len(c.values) for c in engine.columns.values()}
|
|
86
|
+
metadata = {"identity": context, "stats": engine.stats(), "filled": counts,
|
|
87
|
+
"complete": all(n == df.height for n in counts.values()),
|
|
88
|
+
"fatal": engine.fatal, "truncated_rows": truncated, "project": str(project) if project else None}
|
|
89
|
+
return AnnotationResult(output, metadata, evaluate(output, book))
|
|
90
|
+
finally:
|
|
91
|
+
if engine:
|
|
92
|
+
await engine.close()
|
|
93
|
+
if answer_cache:
|
|
94
|
+
answer_cache.db.close()
|
|
95
|
+
if store:
|
|
96
|
+
store.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_settings(budget: float, concurrency: int, max_chars: int | None) -> None:
|
|
100
|
+
if not math.isfinite(budget) or budget < 0:
|
|
101
|
+
raise ValueError("budget must be finite and nonnegative")
|
|
102
|
+
if not isinstance(concurrency, int) or isinstance(concurrency, bool) or concurrency <= 0:
|
|
103
|
+
raise ValueError("concurrency must be a positive integer")
|
|
104
|
+
if max_chars is not None and (not isinstance(max_chars, int) or isinstance(max_chars, bool) or max_chars <= 0):
|
|
105
|
+
raise ValueError("max_chars must be a positive integer or None")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def prepare(table, codebook, *, max_chars=None):
|
|
109
|
+
"""Validate the same input contract for preflight and paid runs, without auth or writes."""
|
|
110
|
+
validate_settings(0, 1, max_chars)
|
|
111
|
+
book = Codebook.load(codebook)
|
|
112
|
+
df = table if isinstance(table, pl.DataFrame) else read_table(table)
|
|
113
|
+
inputs = select_inputs(df, book.inputs)
|
|
114
|
+
outputs = {n for v in book.variables for n in (v.spec.name, v.spec.name + "__confidence")}
|
|
115
|
+
if conflicts := outputs.intersection(df.columns):
|
|
116
|
+
raise ValueError(f"output columns already exist: {', '.join(sorted(conflicts))}")
|
|
117
|
+
# Validate declared labels before starting a paid run, even if no predictions exist yet.
|
|
118
|
+
blank = df.with_columns([pl.lit(None).alias(v.spec.name) for v in book.variables])
|
|
119
|
+
evaluate(blank, book)
|
|
120
|
+
texts = row_texts(df, inputs)
|
|
121
|
+
truncated = sum(len(t) > max_chars for t in texts) if max_chars is not None else 0
|
|
122
|
+
return df, book, inputs, texts, truncated
|