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 +7 -0
- jev_table/cli.py +339 -0
- jev_table/corrections.py +221 -0
- jev_table/engine.py +242 -0
- jev_table/output.py +207 -0
- jev_table/report.py +226 -0
- jev_table/spec.py +119 -0
- jev_table/transport.py +83 -0
- jev_table-0.1.0.dist-info/METADATA +220 -0
- jev_table-0.1.0.dist-info/RECORD +13 -0
- jev_table-0.1.0.dist-info/WHEEL +4 -0
- jev_table-0.1.0.dist-info/entry_points.txt +2 -0
- jev_table-0.1.0.dist-info/licenses/LICENSE +201 -0
jev_table/__init__.py
ADDED
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())
|
jev_table/corrections.py
ADDED
|
@@ -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)
|