jev-table 0.1.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.
jev_table/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """jev-table — AI columns for CSV files with TypeSafe's Jev."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ class UsageError(Exception):
7
+ """Bad input, bad options, or a refused run. CLI exit code 2."""
jev_table/cli.py ADDED
@@ -0,0 +1,339 @@
1
+ """jev-table command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import statistics
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from . import UsageError, __version__
14
+ from .corrections import apply_corrections, emit_cases, read_corrections
15
+ from .engine import (
16
+ MAX_ROWS_WITHOUT_YES,
17
+ USD_PER_MTOK,
18
+ JobResult,
19
+ Prepared,
20
+ estimate_tokens_per_row,
21
+ prepare,
22
+ resolve_concurrency,
23
+ run,
24
+ )
25
+ from .output import build_rows, output_columns, read_rows, write_corrections, write_csv
26
+ from .report import build_stats, write_stats_json, write_stats_md
27
+ from .spec import ColumnSpec, load_column_spec
28
+ from .transport import DEFAULT_BASE_URL, Transport, TypeSafeTransport
29
+
30
+ _OVERSIZE_TOKENS = 30_000 # 32k budget for state + longest question, with headroom
31
+ _ALIASES = ("jev-latest", "jev-preview")
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ args = _build_parser().parse_args(argv)
36
+ try:
37
+ return _execute(args)
38
+ except UsageError as exc:
39
+ print(f"error: {exc}", file=sys.stderr)
40
+ return 2
41
+
42
+
43
+ def _build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="jev-table",
46
+ description=(
47
+ "Add AI columns to a CSV or JSONL: classify every row with TypeSafe's Jev, "
48
+ "with confidence and a review queue."
49
+ ),
50
+ )
51
+ parser.add_argument("input", type=Path, help="CSV or JSONL file to classify")
52
+ parser.add_argument(
53
+ "--spec",
54
+ required=True,
55
+ type=Path,
56
+ help="column spec: a pack.yaml file or a directory containing one",
57
+ )
58
+ parser.add_argument(
59
+ "--out", type=Path, default=None, help="output CSV (default: <input stem>.jev.csv)"
60
+ )
61
+ parser.add_argument("--model", default="jev-latest", help="Jev model or alias")
62
+ parser.add_argument(
63
+ "--base-url",
64
+ default=None,
65
+ help=f"Jev-compatible endpoint (default: {DEFAULT_BASE_URL})",
66
+ )
67
+ parser.add_argument(
68
+ "--limit", type=int, default=None, metavar="N", help="process only the first N rows"
69
+ )
70
+ parser.add_argument(
71
+ "--concurrency",
72
+ type=int,
73
+ default=None,
74
+ metavar="N",
75
+ help="max in-flight requests (default: 8, auto-capped for large states)",
76
+ )
77
+ parser.add_argument(
78
+ "--dry-run",
79
+ action="store_true",
80
+ help="estimate tokens and cost; sends nothing, no API key needed",
81
+ )
82
+ parser.add_argument(
83
+ "--yes", action="store_true", help=f"allow more than {MAX_ROWS_WITHOUT_YES} rows"
84
+ )
85
+ parser.add_argument("--no-cache", action="store_true", help="disable the resume cache")
86
+ parser.add_argument(
87
+ "--corrections",
88
+ type=Path,
89
+ default=None,
90
+ metavar="PATH",
91
+ help="replay human corrections from a *.corrections.csv file (needs the _row column)",
92
+ )
93
+ parser.add_argument(
94
+ "--emit-cases",
95
+ type=Path,
96
+ default=None,
97
+ metavar="PATH",
98
+ help="write cases.jsonl (jev-packs format v0) for rows corrected by hand",
99
+ )
100
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
101
+ return parser
102
+
103
+
104
+ def _execute(args: argparse.Namespace) -> int:
105
+ spec = load_column_spec(args.spec)
106
+ if args.emit_cases and not args.corrections:
107
+ raise UsageError("--emit-cases needs --corrections (there is nothing to emit yet)")
108
+ fieldnames, rows = read_rows(args.input)
109
+ if args.limit is not None:
110
+ if args.limit < 1:
111
+ raise UsageError("--limit must be >= 1")
112
+ rows = rows[: args.limit]
113
+ prepared = prepare(rows, spec, args.model)
114
+ columns = output_columns(spec, fieldnames)
115
+ endpoint = (
116
+ args.base_url or os.environ.get("TYPESAFE_BASE_URL") or DEFAULT_BASE_URL
117
+ ).rstrip("/")
118
+ estimates = estimate_tokens_per_row(prepared, spec)
119
+
120
+ if args.dry_run:
121
+ _print_dry_run(args, spec, prepared, estimates, endpoint)
122
+ return 0
123
+
124
+ if len(rows) > MAX_ROWS_WITHOUT_YES and not args.yes:
125
+ raise UsageError(
126
+ f"{len(rows)} rows exceeds the {MAX_ROWS_WITHOUT_YES}-row safety cap; "
127
+ "preview with --dry-run, then pass --yes to run"
128
+ )
129
+
130
+ if not os.environ.get("TYPESAFE_API_KEY", "").strip():
131
+ raise UsageError(
132
+ "TYPESAFE_API_KEY is not set (any non-empty value works for local endpoints)"
133
+ )
134
+
135
+ out_path = args.out or args.input.with_suffix(".jev.csv")
136
+ stats_path = out_path.with_suffix(".stats.json")
137
+ stats_md_path = out_path.with_suffix(".stats.md")
138
+ corrections_path = out_path.with_suffix(".corrections.csv")
139
+ cases_path = args.emit_cases
140
+ cache_path = None if args.no_cache else out_path.with_suffix(".cache.jsonl")
141
+
142
+ _print_banner(args, spec, prepared, estimates, endpoint, out_path, cache_path)
143
+ started_at = time.monotonic()
144
+ transport = _make_transport(args)
145
+ job = asyncio.run(_run(prepared, spec, transport, args, cache_path))
146
+ finished_at = time.monotonic()
147
+
148
+ if args.corrections is not None:
149
+ corrected_cells = apply_corrections(
150
+ read_corrections(args.corrections),
151
+ prepared=prepared,
152
+ job=job,
153
+ spec=spec,
154
+ cache_path=cache_path,
155
+ )
156
+ print(f" applied {corrected_cells} correction(s) from {args.corrections}")
157
+
158
+ output_rows = build_rows(fieldnames, rows, prepared, job, spec)
159
+ write_csv(out_path, columns, output_rows)
160
+ review_count = write_corrections(corrections_path, fieldnames, prepared, output_rows, spec)
161
+ stats = build_stats(
162
+ input_path=args.input,
163
+ out_path=out_path,
164
+ spec=spec,
165
+ model=args.model,
166
+ endpoint=endpoint,
167
+ prepared=prepared,
168
+ job=job,
169
+ started_at=started_at,
170
+ finished_at=finished_at,
171
+ estimated_input_tokens=sum(estimates),
172
+ )
173
+ write_stats_json(stats_path, stats)
174
+ write_stats_md(stats_md_path, stats)
175
+ emitted = 0
176
+ if cases_path is not None:
177
+ emitted = emit_cases(cases_path, prepared=prepared, job=job, spec=spec)
178
+ _print_summary(
179
+ stats,
180
+ out_path,
181
+ corrections_path,
182
+ review_count,
183
+ stats_path,
184
+ stats_md_path,
185
+ cases_path,
186
+ emitted,
187
+ )
188
+ return 1 if stats["rows"]["errors"] else 0
189
+
190
+
191
+ async def _run(
192
+ prepared: Prepared,
193
+ spec: ColumnSpec,
194
+ transport: Transport,
195
+ args: argparse.Namespace,
196
+ cache_path: Path | None,
197
+ ) -> JobResult:
198
+ try:
199
+ return await run(
200
+ prepared,
201
+ spec=spec,
202
+ transport=transport,
203
+ model=args.model,
204
+ concurrency=args.concurrency,
205
+ cache_path=cache_path,
206
+ progress=_progress_printer(),
207
+ )
208
+ finally:
209
+ await transport.aclose()
210
+
211
+
212
+ def _make_transport(args: argparse.Namespace) -> Transport:
213
+ return TypeSafeTransport(base_url=args.base_url)
214
+
215
+
216
+ def _print_dry_run(
217
+ args: argparse.Namespace,
218
+ spec: ColumnSpec,
219
+ prepared: Prepared,
220
+ estimates: list[int],
221
+ endpoint: str,
222
+ ) -> None:
223
+ total = sum(estimates)
224
+ per_row = int(statistics.median(estimates)) if estimates else 0
225
+ estimated_usd = total * USD_PER_MTOK / 1_000_000
226
+ print("dry run — nothing was sent")
227
+ print(
228
+ f" spec: {spec.id} v{spec.version} "
229
+ f"({len(spec.questions)} questions: {', '.join(spec.questions)})"
230
+ )
231
+ print(f" model: {args.model} · endpoint: {endpoint}")
232
+ print(f" rows: {len(prepared.rows)} · unique: {len(prepared.unique_keys)}")
233
+ print(f" estimated {per_row:,} input tokens per unique row (state + questions)")
234
+ print(
235
+ f" estimated total: {total:,} input tokens ≈ ${estimated_usd:.6f} "
236
+ "(input $0.042/Mtok; output free)"
237
+ )
238
+ oversize = sum(1 for tokens in estimates if tokens > _OVERSIZE_TOKENS)
239
+ if oversize:
240
+ print(
241
+ f" warning: {oversize} row(s) near Jev's 32k-token budget for "
242
+ "state + longest question; split or shorten them"
243
+ )
244
+
245
+
246
+ def _print_banner(
247
+ args: argparse.Namespace,
248
+ spec: ColumnSpec,
249
+ prepared: Prepared,
250
+ estimates: list[int],
251
+ endpoint: str,
252
+ out_path: Path,
253
+ cache_path: Path | None,
254
+ ) -> None:
255
+ concurrency = resolve_concurrency(prepared, spec, args.concurrency)
256
+ print(f"jev-table {__version__} — spec {spec.id} v{spec.version} · model {args.model}")
257
+ print(f" endpoint: {endpoint}")
258
+ print(
259
+ " row data (the state fields) is sent to that endpoint; "
260
+ "nothing else leaves this machine."
261
+ )
262
+ print(
263
+ f" rows: {len(prepared.rows)} · unique: {len(prepared.unique_keys)} · "
264
+ f"concurrency: {concurrency}"
265
+ )
266
+ ungated = [qid for qid in spec.questions if qid not in spec.thresholds]
267
+ if ungated:
268
+ print(
269
+ f" note: no thresholds for {', '.join(ungated)} — "
270
+ "those answers always route to review"
271
+ )
272
+ oversize = sum(1 for tokens in estimates if tokens > _OVERSIZE_TOKENS)
273
+ if oversize:
274
+ print(f" warning: {oversize} row(s) may exceed Jev's 32k-token budget")
275
+ print(f" out: {out_path} · cache: {'off' if cache_path is None else cache_path}")
276
+
277
+
278
+ def _progress_printer():
279
+ tty = sys.stdout.isatty()
280
+
281
+ def on_progress(done: int, total: int, result) -> None: # noqa: ANN001
282
+ if tty:
283
+ print(f"\r {done}/{total} rows evaluated", end="", flush=True)
284
+ if done == total:
285
+ print()
286
+ elif done % 25 == 0 or done == total:
287
+ print(f" {done}/{total} rows evaluated", flush=True)
288
+
289
+ return on_progress
290
+
291
+
292
+ def _print_summary(
293
+ stats: dict,
294
+ out_path: Path,
295
+ corrections_path: Path,
296
+ review_count: int,
297
+ stats_path: Path,
298
+ stats_md_path: Path,
299
+ cases_path: Path | None,
300
+ emitted: int,
301
+ ) -> None:
302
+ rows = stats["rows"]
303
+ cost = stats["cost"]
304
+ latency = stats["latency_ms"]
305
+ print(
306
+ f"done: {rows['calls']} calls · {rows['cache_hits']} from cache · "
307
+ f"{rows['errors']} errors"
308
+ )
309
+ print(
310
+ f" automation: {rows['automation_rate'] * 100:.1f}% "
311
+ f"({rows['automated']}/{rows['total']} rows) · review: {rows['review']} · "
312
+ f"verified: {rows['verified']}"
313
+ )
314
+ estimated = cost["estimated_input_tokens"]
315
+ delta = ""
316
+ if estimated:
317
+ delta = f" (estimated {estimated:,}, {cost['input_tokens'] / estimated:+.2f}x)"
318
+ print(
319
+ f" tokens: {cost['input_tokens']:,} in + {cost['output_tokens']:,} out "
320
+ f"≈ ${cost['usd']:.6f}{delta}"
321
+ )
322
+ if latency["p50"] is not None:
323
+ print(f" latency: p50 {latency['p50']:.0f} ms · p95 {latency['p95']:.0f} ms")
324
+ reported = stats["model"]["reported"]
325
+ requested = stats["model"]["requested"]
326
+ if reported and requested not in _ALIASES:
327
+ unexpected = [model for model in reported if model != requested]
328
+ if unexpected:
329
+ print(f" warning: pinned model {requested} was answered by {', '.join(unexpected)}")
330
+ print(f" wrote {out_path}")
331
+ if review_count:
332
+ print(f" wrote {corrections_path} ({review_count} rows need review)")
333
+ if cases_path is not None:
334
+ print(f" wrote {cases_path} ({emitted} golden cases)")
335
+ print(f" wrote {stats_path} and {stats_md_path}")
336
+
337
+
338
+ if __name__ == "__main__":
339
+ sys.exit(main())
@@ -0,0 +1,221 @@
1
+ """Replay human corrections and emit golden cases — the jev-table flywheel.
2
+
3
+ Workflow: run once, open ``*.corrections.csv``, fix the flagged answers, rerun
4
+ with ``--corrections`` (no API calls for corrected rows), then ``--emit-cases``
5
+ to produce a ``cases.jsonl`` the jev-packs/jevassert suite can consume.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import UsageError
16
+ from .engine import JobResult, Prepared, RowResult, append_cache
17
+ from .output import ROW_COLUMN
18
+ from .spec import ColumnQuestion, ColumnSpec
19
+
20
+ _TRUE_WORDS = {"true", "yes", "1"}
21
+ _FALSE_WORDS = {"false", "no", "0"}
22
+
23
+
24
+ def read_corrections(path: Path) -> dict[int, dict[str, str]]:
25
+ """Map input row index -> raw corrected cells; requires the `_row` column."""
26
+ if not path.is_file():
27
+ raise UsageError(f"corrections file not found: {path}")
28
+ with path.open(newline="", encoding="utf-8-sig") as handle:
29
+ reader = csv.DictReader(handle)
30
+ if not reader.fieldnames or ROW_COLUMN not in reader.fieldnames:
31
+ raise UsageError(f"{path}: corrections file needs a `{ROW_COLUMN}` column")
32
+ corrections: dict[int, dict[str, str]] = {}
33
+ for raw in reader:
34
+ try:
35
+ index = int(raw[ROW_COLUMN])
36
+ except (TypeError, ValueError):
37
+ raise UsageError(
38
+ f"{path}: invalid {ROW_COLUMN} value {raw[ROW_COLUMN]!r}"
39
+ ) from None
40
+ if index in corrections:
41
+ raise UsageError(f"{path}: duplicate {ROW_COLUMN} {index}")
42
+ corrections[index] = {key: value for key, value in raw.items() if value}
43
+ return corrections
44
+
45
+
46
+ def apply_corrections(
47
+ corrections: dict[int, dict[str, str]],
48
+ *,
49
+ prepared: Prepared,
50
+ job: JobResult,
51
+ spec: ColumnSpec,
52
+ cache_path: Path | None = None,
53
+ ) -> int:
54
+ """Override answers with human-corrected cells; mark questions as verified.
55
+
56
+ A question is verified when its cell was edited, or when the user removed
57
+ its id from the row's `review` column (confirm-as-is). Returns the number
58
+ of applied corrections.
59
+ """
60
+ applied = 0
61
+ processed: set[str] = set()
62
+ for index, cells in corrections.items():
63
+ if not 0 <= index < len(prepared.rows):
64
+ raise UsageError(
65
+ f"corrections reference row {index}, but the input has "
66
+ f"{len(prepared.rows)} row(s) (0..{len(prepared.rows) - 1})"
67
+ )
68
+ key = prepared.rows[index].key
69
+ if key in processed:
70
+ continue # duplicate states share one result; first occurrence wins
71
+ processed.add(key)
72
+ result = job.results[key]
73
+ cells = dict(cells)
74
+ still_in_review = {
75
+ qid.strip() for qid in cells.pop("review", "").split(";") if qid.strip()
76
+ }
77
+ for question_id, raw in cells.items():
78
+ question = spec.questions.get(question_id)
79
+ if question is None:
80
+ continue # unknown column: ignore (the file may carry extra columns)
81
+ original = result.answers.get(question_id) or {}
82
+ corrected = _corrected_answer(question, raw.strip(), original)
83
+ if corrected is original:
84
+ continue # cell left as-is
85
+ result.answers[question_id] = corrected
86
+ _verify(result, question_id)
87
+ applied += 1
88
+ for question_id in spec.questions:
89
+ if question_id in still_in_review:
90
+ continue
91
+ answer = result.answers.get(question_id)
92
+ if answer and question_id not in result.verified:
93
+ if spec.needs_review(question_id, answer):
94
+ _verify(result, question_id) # removed from review: confirmed as-is
95
+ applied += 1
96
+ if result.verified and cache_path is not None:
97
+ append_cache(cache_path, result)
98
+ return applied
99
+
100
+
101
+ def _verify(result: RowResult, question_id: str) -> None:
102
+ if question_id not in result.verified:
103
+ result.verified.append(question_id)
104
+
105
+
106
+ def emit_cases(
107
+ path: Path,
108
+ *,
109
+ prepared: Prepared,
110
+ job: JobResult,
111
+ spec: ColumnSpec,
112
+ ) -> int:
113
+ """Write cases.jsonl (SPEC v0) for rows with at least one verified answer."""
114
+ seen: set[str] = set()
115
+ lines: list[str] = []
116
+ for prepared_row in prepared.rows:
117
+ result = job.results[prepared_row.key]
118
+ if prepared_row.key in seen or not result.verified:
119
+ continue
120
+ expect: dict[str, Any] = {}
121
+ for question_id, question in spec.questions.items():
122
+ label = _expected_label(question, result)
123
+ if label is None:
124
+ expect = {}
125
+ break
126
+ expect[question_id] = label
127
+ if not expect:
128
+ continue
129
+ seen.add(prepared_row.key)
130
+ lines.append(
131
+ json.dumps(
132
+ {
133
+ "id": f"{spec.id}-{prepared_row.index + 1:05d}",
134
+ "state": prepared_row.state,
135
+ "expect": expect,
136
+ },
137
+ ensure_ascii=False,
138
+ sort_keys=True,
139
+ )
140
+ )
141
+ if not lines:
142
+ raise UsageError(
143
+ "nothing to emit: no corrected rows with complete answers "
144
+ "(run with --corrections first)"
145
+ )
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
148
+ return len(lines)
149
+
150
+
151
+ def _corrected_answer(
152
+ question: ColumnQuestion, raw: str, original: dict[str, Any]
153
+ ) -> dict[str, Any]:
154
+ if not raw:
155
+ return original
156
+ where = f"corrections: question '{question.id}'"
157
+ if question.type == "choice":
158
+ if raw not in question.labels:
159
+ raise UsageError(f"{where}: '{raw}' is not one of {', '.join(question.labels)}")
160
+ if original.get("choice") == raw:
161
+ return original
162
+ return {
163
+ "type": "choice",
164
+ "choice": raw,
165
+ "confidence": 1.0,
166
+ "probabilities": {raw: 1.0},
167
+ }
168
+ if question.type == "score":
169
+ if raw in question.labels:
170
+ value = float(question.labels.index(raw))
171
+ else:
172
+ try:
173
+ value = float(raw)
174
+ except ValueError:
175
+ raise UsageError(
176
+ f"{where}: '{raw}' is neither a number nor one of "
177
+ f"{', '.join(question.labels)}"
178
+ ) from None
179
+ if original.get("score") == value:
180
+ return original
181
+ index = max(0, min(len(question.labels) - 1, int(round(value))))
182
+ return {
183
+ "type": "score",
184
+ "score": value,
185
+ "confidence": 1.0,
186
+ "probabilities": {str(index): 1.0},
187
+ }
188
+ value = _noul_value(raw, where)
189
+ if original.get("noul") == value:
190
+ return original
191
+ return {"type": "noul", "noul": value}
192
+
193
+
194
+ def _noul_value(raw: str, where: str) -> float:
195
+ lowered = raw.lower()
196
+ if lowered in _TRUE_WORDS:
197
+ return 1.0
198
+ if lowered in _FALSE_WORDS:
199
+ return 0.0
200
+ try:
201
+ value = float(raw)
202
+ except ValueError:
203
+ raise UsageError(f"{where}: '{raw}' is not a probability in [0, 1] or true/false") from None
204
+ if not 0.0 <= value <= 1.0:
205
+ raise UsageError(f"{where}: '{raw}' is not a probability in [0, 1]")
206
+ return value
207
+
208
+
209
+ def _expected_label(question: ColumnQuestion, result: RowResult) -> Any:
210
+ answer = result.answers.get(question.id) or {}
211
+ if question.type == "choice":
212
+ choice = answer.get("choice")
213
+ return choice if choice in question.labels else None
214
+ if question.type == "score":
215
+ score = answer.get("score")
216
+ if score is None:
217
+ return None
218
+ index = max(0, min(len(question.labels) - 1, int(round(float(score)))))
219
+ return question.labels[index]
220
+ probability = answer.get("noul")
221
+ return None if probability is None else bool(float(probability) >= 0.5)