@@ -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+
36093693def _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"\n Batch complete: { result . succeeded } /{ result . total } succeeded "
3837+ summary = (
3838+ f"\n Batch 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