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 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
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ cli()
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