Convert OptimizationConfig from dataclass to Pydantic BaseModel with field validators for ranges, types, and enum values. Missing/invalid fields now produce actionable CLI errors instead of cryptic KeyErrors. - Range validators: max_iterations>=1, minibatch_size>=1, seed>=0, etc. - Enum validator: error_strategy must be skip|retry|abort - Config migration hook via config_version field - CLI catches ValidationError and prints per-field error messages - Remove unused AppSettings class (Bug #7) - 30 unit tests covering all validation edge cases Co-Authored-By: Paperclip <noreply@paperclip.ing>
239 lines
7.1 KiB
Python
239 lines
7.1 KiB
Python
"""
|
|
CLI — user entry point.
|
|
|
|
Typer interface with -i (input) and -o (output) options.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from dataclasses import asdict
|
|
|
|
import dspy
|
|
import typer
|
|
from pydantic import ValidationError
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
|
|
from prometheus.application.bootstrap import SyntheticBootstrap
|
|
from prometheus.application.dto import OptimizationConfig, OptimizationResult
|
|
from prometheus.application.evaluator import PromptEvaluator
|
|
from prometheus.application.use_cases import OptimizePromptUseCase
|
|
from prometheus.infrastructure.file_io import YamlPersistence
|
|
from prometheus.infrastructure.judge_adapter import DSPyJudgeAdapter
|
|
from prometheus.infrastructure.llm_adapter import DSPyLLMAdapter
|
|
from prometheus.infrastructure.proposer_adapter import DSPyProposerAdapter
|
|
from prometheus.infrastructure.synth_adapter import DSPySyntheticAdapter
|
|
|
|
app = typer.Typer(
|
|
name="prometheus",
|
|
help="PROMETHEUS — Prompt evolution without reference data.",
|
|
no_args_is_help=True,
|
|
)
|
|
|
|
console = Console()
|
|
|
|
|
|
@app.command()
|
|
def optimize(
|
|
input: str = typer.Option(
|
|
...,
|
|
"-i",
|
|
"--input",
|
|
help="Path to input YAML config file.",
|
|
exists=True,
|
|
readable=True,
|
|
),
|
|
output: str = typer.Option(
|
|
"output.yaml",
|
|
"-o",
|
|
"--output",
|
|
help="Path to output YAML result file.",
|
|
),
|
|
verbose: bool = typer.Option(
|
|
False,
|
|
"-v",
|
|
"--verbose",
|
|
help="Print detailed progress.",
|
|
),
|
|
max_retries: int = typer.Option(
|
|
3,
|
|
"--max-retries",
|
|
help="Max retry attempts for transient LLM errors (429, timeout, 5xx).",
|
|
),
|
|
error_strategy: str = typer.Option(
|
|
"retry",
|
|
"--error-strategy",
|
|
help="How to handle errors: skip | retry | abort.",
|
|
),
|
|
max_concurrency: int = typer.Option(
|
|
5,
|
|
"--max-concurrency",
|
|
help="Max parallel LLM calls for minibatch execution and judging.",
|
|
),
|
|
) -> None:
|
|
"""Optimize a prompt without any reference data.
|
|
|
|
Usage:
|
|
prometheus optimize -i config.yaml -o result.yaml
|
|
"""
|
|
asyncio.run(
|
|
_async_optimize(input, output, verbose, max_retries, error_strategy, max_concurrency)
|
|
)
|
|
|
|
|
|
async def _async_optimize(
|
|
input: str,
|
|
output: str,
|
|
verbose: bool,
|
|
max_retries: int,
|
|
error_strategy: str,
|
|
max_concurrency: int,
|
|
) -> None:
|
|
# Configure verbose logging
|
|
if verbose:
|
|
logging.basicConfig(level=logging.INFO, format="[PROMETHEUS] %(message)s")
|
|
|
|
console.print(
|
|
Panel.fit(
|
|
"PROMETHEUS — Prompt Evolution Engine",
|
|
subtitle="No reference data required",
|
|
)
|
|
)
|
|
|
|
# 1. Load & validate config
|
|
persistence = YamlPersistence()
|
|
raw_config = persistence.read_config(input)
|
|
|
|
# CLI flags override config file values
|
|
raw_config.setdefault("max_retries", max_retries)
|
|
raw_config.setdefault("error_strategy", error_strategy)
|
|
raw_config.setdefault("max_concurrency", max_concurrency)
|
|
raw_config["output_path"] = output
|
|
raw_config["verbose"] = verbose
|
|
|
|
try:
|
|
config = OptimizationConfig.model_validate(raw_config)
|
|
except ValidationError as exc:
|
|
console.print("[bold red]Configuration error:[/bold red]\n")
|
|
for err in exc.errors():
|
|
loc = " → ".join(str(l) for l in err["loc"])
|
|
console.print(f" [red]• {loc}: {err['msg']}[/red]")
|
|
raise typer.Exit(code=1) from exc
|
|
console.print(f"[dim]Task: {config.task_description[:80]}...[/dim]")
|
|
console.print(f"[dim]Seed prompt: {config.seed_prompt[:80]}...[/dim]")
|
|
|
|
# 2. Create per-model DSPy LM instances
|
|
def _model_lm_kwargs(
|
|
model_api_base: str | None,
|
|
model_api_key_env: str | None,
|
|
) -> dict:
|
|
"""Build kwargs for dspy.LM, using per-model overrides with global fallback."""
|
|
kwargs: dict = {}
|
|
api_base = model_api_base or config.api_base
|
|
api_key_env = model_api_key_env or config.api_key_env
|
|
if api_base:
|
|
kwargs["api_base"] = api_base
|
|
if api_key_env:
|
|
kwargs["api_key"] = os.environ.get(api_key_env, "")
|
|
return kwargs
|
|
|
|
task_lm = dspy.LM(
|
|
config.task_model,
|
|
**_model_lm_kwargs(config.task_api_base, config.task_api_key_env),
|
|
)
|
|
judge_lm = dspy.LM(
|
|
config.judge_model,
|
|
**_model_lm_kwargs(config.judge_api_base, config.judge_api_key_env),
|
|
)
|
|
proposer_lm = dspy.LM(
|
|
config.proposer_model,
|
|
**_model_lm_kwargs(config.proposer_api_base, config.proposer_api_key_env),
|
|
)
|
|
synth_lm = dspy.LM(
|
|
config.synth_model,
|
|
**_model_lm_kwargs(config.synth_api_base, config.synth_api_key_env),
|
|
)
|
|
|
|
# 3. Build adapters (Dependency Injection — each gets its own LM + retry config)
|
|
synth_adapter = DSPySyntheticAdapter(lm=synth_lm)
|
|
llm_adapter = DSPyLLMAdapter(
|
|
lm=task_lm,
|
|
max_retries=config.max_retries,
|
|
retry_delay_base=config.retry_delay_base,
|
|
)
|
|
judge_adapter = DSPyJudgeAdapter(
|
|
lm=judge_lm,
|
|
max_retries=config.max_retries,
|
|
retry_delay_base=config.retry_delay_base,
|
|
max_concurrency=config.max_concurrency,
|
|
)
|
|
proposer_adapter = DSPyProposerAdapter(
|
|
lm=proposer_lm,
|
|
max_retries=config.max_retries,
|
|
retry_delay_base=config.retry_delay_base,
|
|
)
|
|
bootstrap = SyntheticBootstrap(generator=synth_adapter, seed=config.seed)
|
|
evaluator = PromptEvaluator(
|
|
executor=llm_adapter,
|
|
judge=judge_adapter,
|
|
max_concurrency=config.max_concurrency,
|
|
)
|
|
use_case = OptimizePromptUseCase(
|
|
evaluator=evaluator,
|
|
proposer=proposer_adapter,
|
|
bootstrap=bootstrap,
|
|
)
|
|
|
|
# 4. Execute
|
|
with console.status("[bold green]Evolving prompt..."):
|
|
result = await use_case.execute(config)
|
|
|
|
# 5. Display results
|
|
_display_result(result)
|
|
|
|
# 6. Save
|
|
_save_result(persistence, output, result)
|
|
console.print(f"\n[green]Results saved to {output}[/green]")
|
|
|
|
|
|
def _display_result(result: OptimizationResult) -> None:
|
|
"""Display a Rich summary in the terminal."""
|
|
console.print()
|
|
console.print(
|
|
Panel(
|
|
f"[bold green]Optimized Prompt[/bold green]\n\n{result.optimized_prompt}",
|
|
title="Result",
|
|
)
|
|
)
|
|
table = Table(title="Metrics")
|
|
table.add_column("Metric", style="cyan")
|
|
table.add_column("Value", style="bold")
|
|
table.add_row("Initial Score", f"{result.initial_score:.2f}")
|
|
table.add_row("Final Score", f"{result.final_score:.2f}")
|
|
table.add_row("Improvement", f"{result.improvement:+.2f}")
|
|
table.add_row("Iterations", str(result.iterations_used))
|
|
table.add_row("LLM Calls", str(result.total_llm_calls))
|
|
console.print(table)
|
|
|
|
|
|
def _save_result(
|
|
persistence: YamlPersistence,
|
|
path: str,
|
|
result: OptimizationResult,
|
|
) -> None:
|
|
"""Save the result as YAML."""
|
|
persistence.write_result(path, asdict(result))
|
|
|
|
|
|
@app.command(hidden=True)
|
|
def _help() -> None:
|
|
"""Internal placeholder to force multi-command Typer behavior."""
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app()
|