Skip to content

Commit 41d0e2d

Browse files
committed
feat(batch): schema output, lossless rows, per-job cost, resilient input
1 parent 2a68247 commit 41d0e2d

5 files changed

Lines changed: 703 additions & 43 deletions

File tree

docs/guides/scaling.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,46 @@ effgen batch --input queries.jsonl --output results.jsonl \
126126
--concurrency 10 --batch-size 100 --timeout 60 --retries 2
127127
```
128128

129+
### Structured output per row
130+
131+
Pass a JSON Schema file with `--schema`, or a Pydantic model with
132+
`--output-model module:ClassName`, to validate every row. Each output row then
133+
carries a `parsed` object; a row that cannot be coerced to the schema is written
134+
as a failed row with a reason rather than an off-schema string.
135+
136+
```bash
137+
effgen batch --input tickets.jsonl --output results.jsonl \
138+
--preset minimal --schema ticket_schema.json --max-tokens 4096
139+
```
140+
141+
For pure extraction or generation, prefer `--preset minimal`: it runs the model
142+
directly without the default tool loop, which small models can otherwise spend
143+
iterations on.
144+
145+
### Output columns, per-job cost, and reruns
146+
147+
Each output row carries token counts (`prompt_tokens`/`completion_tokens`/
148+
`total_tokens`), `cost_usd` for priced models, the validated `parsed` object
149+
(when a schema is set), and an `error` reason for failed rows. A local or
150+
unpriced model reports tokens without a `cost_usd`. The final line reports a
151+
per-job token total and, for priced models, a cost total:
152+
153+
```
154+
Batch complete: 1000/1000 succeeded in 42.11s · 1,204,882 tokens · $0.48
155+
```
156+
157+
A `.jsonl` `--output` is written row-by-row as each finishes, so an interrupted
158+
job keeps the rows already done. Rerun the same command with `--resume` to skip
159+
the indices already present and run only the remaining rows:
160+
161+
```bash
162+
effgen batch --input queries.jsonl --output results.jsonl --resume
163+
```
164+
165+
A malformed input line is skipped with a message naming the file and line
166+
number; pass `--strict` to hard-fail on the first bad line instead. Use
167+
`--temperature 0` for deterministic reruns where the provider supports it.
168+
129169
## Domain Keyword Expansion
130170

131171
Scale keyword coverage per domain:

effgen/cli/_main.py

Lines changed: 200 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2832,6 +2832,18 @@ def create_parser():
28322832
batch_parser.add_argument('--query-field', default='query', help='Field name for queries in JSONL/CSV (default: query)')
28332833
batch_parser.add_argument('--max-tokens', type=int, default=None,
28342834
help='Max output tokens per query (raise for token-heavy or reasoning models)')
2835+
batch_parser.add_argument('--temperature', type=float, default=None,
2836+
help='Sampling temperature per query (0 for deterministic reruns where the provider supports it)')
2837+
batch_parser.add_argument('--schema', dest='schema_path', default=None,
2838+
help='JSON Schema file; each row is validated against it and its parsed object is written')
2839+
batch_parser.add_argument('--output-model', dest='output_model', default=None,
2840+
help='Pydantic model as module:ClassName to validate each row against')
2841+
batch_parser.add_argument('--strict', action='store_true',
2842+
help='Abort on the first malformed input line instead of skipping it')
2843+
batch_parser.add_argument('--resume', action='store_true',
2844+
help='Skip input rows already present in the JSONL --output file and append the rest')
2845+
batch_parser.add_argument('-q', '--quiet', action='store_true', default=argparse.SUPPRESS,
2846+
help='Quiet output (suppress the progress bar)')
28352847
batch_parser.add_argument('--no-animation', action='store_true', default=argparse.SUPPRESS,
28362848
help='Disable the live progress bar (plain output)')
28372849

@@ -3606,6 +3618,78 @@ def _agent_factory(nd):
36063618
return 0
36073619

36083620

3621+
def _batch_structured_kwargs(args) -> dict:
3622+
"""Build ``output_schema`` / ``output_model`` run-kwargs from the CLI flags.
3623+
3624+
``--schema PATH`` loads a JSON Schema file; ``--output-model module:Class``
3625+
imports a Pydantic model. Each validates every row and writes the parsed
3626+
object; a row that cannot be coerced to the schema is reported as a failed
3627+
row with a reason rather than a silently off-schema string. Raises
3628+
``ValueError`` with an actionable message on a bad path or spec.
3629+
"""
3630+
schema_path = getattr(args, 'schema_path', None)
3631+
model_spec = getattr(args, 'output_model', None)
3632+
if schema_path and model_spec:
3633+
raise ValueError("Use only one of --schema / --output-model, not both.")
3634+
if schema_path:
3635+
p = Path(schema_path)
3636+
if not p.exists():
3637+
raise ValueError(f"Schema file not found: {schema_path}")
3638+
try:
3639+
schema = json.loads(p.read_text(encoding="utf-8"))
3640+
except json.JSONDecodeError as e:
3641+
raise ValueError(f"{schema_path}: not valid JSON: {e}") from e
3642+
if not isinstance(schema, dict):
3643+
raise ValueError(f"{schema_path}: a JSON Schema must be a JSON object.")
3644+
return {"output_schema": schema}
3645+
if model_spec:
3646+
if ":" not in model_spec:
3647+
raise ValueError(
3648+
"--output-model must be 'module:ClassName' "
3649+
"(e.g. myproject.schemas:Ticket)."
3650+
)
3651+
mod_name, _, cls_name = model_spec.partition(":")
3652+
import importlib
3653+
# Make a project-local module importable from a headless run.
3654+
if os.getcwd() not in sys.path:
3655+
sys.path.insert(0, os.getcwd())
3656+
try:
3657+
mod = importlib.import_module(mod_name)
3658+
except ImportError as e:
3659+
raise ValueError(f"Could not import module '{mod_name}': {e}") from e
3660+
cls = getattr(mod, cls_name, None)
3661+
if cls is None:
3662+
raise ValueError(f"Module '{mod_name}' has no attribute '{cls_name}'.")
3663+
from effgen.core.structured_output import is_pydantic_model_class
3664+
if not is_pydantic_model_class(cls):
3665+
raise ValueError(
3666+
f"{model_spec} is not a Pydantic model class "
3667+
"(it must subclass pydantic.BaseModel)."
3668+
)
3669+
return {"output_model": cls}
3670+
return {}
3671+
3672+
3673+
def _read_done_indices(output_path: Path) -> dict:
3674+
"""Read an existing JSONL output file into ``{index: row}`` for --resume."""
3675+
done: dict[int, dict] = {}
3676+
if not output_path.exists():
3677+
return done
3678+
with open(output_path, encoding="utf-8") as f:
3679+
for line in f:
3680+
line = line.strip()
3681+
if not line:
3682+
continue
3683+
try:
3684+
row = json.loads(line)
3685+
except json.JSONDecodeError:
3686+
continue
3687+
idx = row.get("index") if isinstance(row, dict) else None
3688+
if isinstance(idx, int):
3689+
done[idx] = row
3690+
return done
3691+
3692+
36093693
def _handle_batch_command(args, cli) -> int:
36103694
"""Handle the 'batch' CLI subcommand."""
36113695
from effgen.core.batch import BatchConfig, BatchRunner
@@ -3616,12 +3700,32 @@ def _handle_batch_command(args, cli) -> int:
36163700
preset_name = getattr(args, 'preset', None)
36173701
query_field = getattr(args, 'query_field', 'query')
36183702
max_tokens = getattr(args, 'max_tokens', None)
3703+
temperature = getattr(args, 'temperature', None)
3704+
strict = getattr(args, 'strict', False)
3705+
resume = getattr(args, 'resume', False)
3706+
36193707
# Extra kwargs forwarded to each agent.run() call.
36203708
run_kwargs: dict = {}
36213709
if max_tokens is not None:
36223710
run_kwargs['max_tokens'] = max_tokens
3711+
if temperature is not None:
3712+
run_kwargs['temperature'] = temperature
3713+
try:
3714+
run_kwargs.update(_batch_structured_kwargs(args))
3715+
except ValueError as e:
3716+
cli.print_error(str(e))
3717+
return 1
3718+
3719+
out_suffix = Path(output_path).suffix.lower() if output_path else None
3720+
# A .jsonl output streams each finished row as it completes, so a crash
3721+
# mid-job keeps the rows already done; --resume then skips those on rerun.
3722+
stream_jsonl = bool(output_path) and out_suffix == ".jsonl"
3723+
if resume and not stream_jsonl:
3724+
cli.print_error("--resume requires a .jsonl --output file.")
3725+
return 1
36233726

36243727
agent = None
3728+
out_fh = None
36253729
try:
36263730
# Create agent
36273731
if preset_name:
@@ -3639,50 +3743,127 @@ def _handle_batch_command(args, cli) -> int:
36393743
runner = BatchRunner(agent)
36403744
cli.print(f"Loading queries from {input_path}...")
36413745

3642-
# Count queries up front so the progress bar shows a real total/ETA.
3746+
# Read queries once. A malformed input line is skipped with a message
3747+
# naming the file and line number (not the parser's byte offset);
3748+
# --strict turns the first bad line into a hard failure instead.
3749+
skipped: list[int] = []
3750+
3751+
def _on_skip(lineno: int, msg: str) -> None:
3752+
skipped.append(lineno)
3753+
cli.print(f"Skipping malformed input at {input_path}:{lineno}: {msg}")
3754+
36433755
try:
3644-
_queries = runner._read_queries(Path(input_path), query_field)
3645-
_total = len(_queries)
3646-
except Exception: # noqa: BLE001 - fall back to an indeterminate bar
3647-
_total = None
3756+
queries = runner._read_queries(
3757+
Path(input_path), query_field, strict=strict, on_skip=_on_skip,
3758+
)
3759+
except Exception as e: # noqa: BLE001 - one clear message, no traceback
3760+
cli.print_error(f"Could not read {input_path}: {e}")
3761+
return 1
3762+
if skipped:
3763+
cli.print(
3764+
f"Skipped {len(skipped)} malformed line(s); "
3765+
f"{len(queries)} queries loaded."
3766+
)
3767+
3768+
# --resume: skip input rows already present in the JSONL output.
3769+
done_rows: dict[int, dict] = {}
3770+
if resume:
3771+
done_rows = {
3772+
i: row for i, row in _read_done_indices(Path(output_path)).items()
3773+
if 0 <= i < len(queries)
3774+
}
3775+
run_positions = [i for i in range(len(queries)) if i not in done_rows]
3776+
run_queries = [queries[i] for i in run_positions]
3777+
if done_rows:
3778+
cli.print(
3779+
f"Resuming: {len(done_rows)} row(s) already present, "
3780+
f"running the remaining {len(run_queries)}."
3781+
)
3782+
3783+
# Open the streaming output before the run so completed rows persist
3784+
# immediately. Resume appends to the existing file; a fresh run truncates.
3785+
if stream_jsonl:
3786+
mode = "a" if (resume and Path(output_path).exists()) else "w"
3787+
out_fh = open(output_path, mode, encoding="utf-8")
3788+
3789+
def _on_result(pos: int, query: str, resp) -> None:
3790+
if out_fh is None:
3791+
return
3792+
orig_idx = run_positions[pos]
3793+
row = BatchRunner._result_row(orig_idx, resp, query)
3794+
out_fh.write(json.dumps(row, ensure_ascii=False) + "\n")
3795+
out_fh.flush()
36483796

36493797
animate = _progress.animation_enabled(
36503798
quiet=getattr(args, 'quiet', False),
36513799
no_animation=getattr(args, 'no_animation', False),
36523800
)
36533801
with _progress.StepProgress(
3654-
cli.console, total=_total, description="Batch", animate=animate,
3802+
cli.console, total=len(run_queries), description="Batch", animate=animate,
36553803
) as _bar:
36563804
batch_config = BatchConfig(
36573805
max_concurrency=args.concurrency,
36583806
batch_size=args.batch_size,
36593807
retry_failed=args.retries,
36603808
timeout_per_item=args.timeout,
36613809
progress_callback=lambda done, total: _bar.update(done, total),
3810+
on_result=_on_result if out_fh is not None else None,
36623811
)
3663-
result = runner.run_from_file(
3664-
input_path, config=batch_config, query_field=query_field,
3665-
**run_kwargs,
3666-
)
3812+
result = runner.run(run_queries, config=batch_config, **run_kwargs)
3813+
3814+
if out_fh is not None:
3815+
out_fh.close()
3816+
out_fh = None
3817+
3818+
# Combine this run with any rows carried over by --resume so the
3819+
# headline counts and totals reflect the whole job, not just the rerun.
3820+
done_success = sum(1 for r in done_rows.values() if r.get("success"))
3821+
total = len(queries)
3822+
succeeded = done_success + result.succeeded
3823+
failed = total - succeeded
3824+
3825+
done_cost = sum(
3826+
r["cost_usd"] for r in done_rows.values()
3827+
if isinstance(r.get("cost_usd"), int | float)
3828+
)
3829+
done_tokens = sum(
3830+
r.get("total_tokens", 0) for r in done_rows.values()
3831+
if isinstance(r.get("total_tokens"), int)
3832+
)
3833+
cost_present = result.total_cost_usd is not None or done_cost > 0
3834+
total_cost = (result.total_cost_usd or 0.0) + done_cost if cost_present else None
3835+
total_tokens = result.total_tokens + done_tokens
36673836

3668-
cli.print(
3669-
f"\nBatch complete: {result.succeeded}/{result.total} succeeded "
3837+
summary = (
3838+
f"\nBatch complete: {succeeded}/{total} succeeded "
36703839
f"in {result.total_time:.2f}s"
36713840
)
3672-
3673-
if output_path:
3674-
queries = runner._read_queries(
3675-
__import__('pathlib').Path(input_path), query_field,
3676-
)
3677-
runner.write_results(result, output_path, query_list=queries)
3841+
if total_tokens:
3842+
summary += f" · {total_tokens:,} tokens"
3843+
from effgen.ui.render import format_cost
3844+
cost_str = format_cost(total_cost)
3845+
if cost_str is not None:
3846+
summary += f" · {cost_str}"
3847+
cli.print(summary)
3848+
3849+
# Non-streaming formats (.csv/.json) get one batched write at the end.
3850+
if output_path and not stream_jsonl:
3851+
runner.write_results(result, output_path, query_list=run_queries)
3852+
cli.print(f"Results written to {output_path}")
3853+
elif output_path:
36783854
cli.print(f"Results written to {output_path}")
36793855

3680-
return 0 if result.failed == 0 else 1
3856+
return 0 if failed == 0 else 1
36813857

36823858
except Exception as e:
36833859
cli.print(f"Batch execution failed: {e}")
36843860
return 1
36853861
finally:
3862+
if out_fh is not None:
3863+
try:
3864+
out_fh.close()
3865+
except Exception: # noqa: BLE001
3866+
pass
36863867
# Release the agent's resources so the CLI never trips its own
36873868
# "garbage-collected without close()" warning.
36883869
if agent is not None:

0 commit comments

Comments
 (0)