paperlab-space 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.
@@ -0,0 +1,51 @@
1
+ """paperlab-space: evaluate LLMs on spreadsheet data and produce paper-ready results.
2
+
3
+ >>> from paperlab_space import RunConfig, load_dataset, run, export
4
+ >>> data = load_dataset("data.xlsx") # doctest: +SKIP
5
+ >>> config = RunConfig.from_yaml("run.yaml") # doctest: +SKIP
6
+ >>> results = run(config, data) # doctest: +SKIP
7
+ >>> export.write_all(results, "results/") # doctest: +SKIP
8
+ """
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from paperlab_space import export
13
+ from paperlab_space.config import DatasetSpec, MetricSpec, ModelSpec, RetrySettings, RunConfig
14
+ from paperlab_space.data import ColumnMapping, Dataset, MappedDataset, list_sheets, load_dataset
15
+ from paperlab_space.errors import ConfigError, DataError, PaperLabError, TemplateError
16
+ from paperlab_space.metrics import available_metrics, register, score
17
+ from paperlab_space.prompt import PromptTemplate
18
+ from paperlab_space.results import MetricResult, ModelRun, RowOutput, RunResults
19
+ from paperlab_space.runner import LiteLLMBackend, Progress, arun, estimate, run
20
+
21
+ __all__ = [
22
+ "ColumnMapping",
23
+ "ConfigError",
24
+ "DataError",
25
+ "Dataset",
26
+ "DatasetSpec",
27
+ "LiteLLMBackend",
28
+ "MappedDataset",
29
+ "MetricResult",
30
+ "MetricSpec",
31
+ "ModelRun",
32
+ "ModelSpec",
33
+ "PaperLabError",
34
+ "Progress",
35
+ "PromptTemplate",
36
+ "RetrySettings",
37
+ "RowOutput",
38
+ "RunConfig",
39
+ "RunResults",
40
+ "TemplateError",
41
+ "__version__",
42
+ "arun",
43
+ "available_metrics",
44
+ "estimate",
45
+ "export",
46
+ "list_sheets",
47
+ "load_dataset",
48
+ "register",
49
+ "run",
50
+ "score",
51
+ ]
paperlab_space/_cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """The ``paperlab-space`` command (needs the extra: ``pip install 'paperlab-space[cli]'``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeRemainingColumn
12
+ from rich.table import Table
13
+
14
+ from paperlab_space import __version__
15
+ from paperlab_space.config import RunConfig
16
+ from paperlab_space.data import ColumnMapping, list_sheets, load_dataset
17
+ from paperlab_space.errors import PaperLabError
18
+ from paperlab_space.export import write_all
19
+ from paperlab_space.metrics import available_metrics
20
+ from paperlab_space.results import RunResults
21
+ from paperlab_space.runner import Progress as RunProgress
22
+ from paperlab_space.runner import estimate, prepare_data, run
23
+
24
+ app = typer.Typer(
25
+ help="Evaluate LLMs on spreadsheet data and get paper-ready results.",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ )
29
+ # emoji=False: model IDs like `x:free:` must not turn into emoji.
30
+ console = Console(emoji=False)
31
+ err = Console(stderr=True, emoji=False)
32
+
33
+ _EXPECTED_HINTS = ("expected", "gold", "answer", "label", "reference", "target", "output", "ref")
34
+ _ID_HINTS = ("id", "row_id", "index", "no", "no.", "#")
35
+
36
+
37
+ def _fail(exc: Exception) -> typer.Exit:
38
+ err.print(f"[bold red]Error:[/] {exc}")
39
+ return typer.Exit(1)
40
+
41
+
42
+ @app.callback(invoke_without_command=True)
43
+ def _main(
44
+ version: Annotated[bool, typer.Option("--version", help="Show the version and exit.")] = False,
45
+ ) -> None:
46
+ if version:
47
+ console.print(f"paperlab-space {__version__}")
48
+ raise typer.Exit()
49
+
50
+
51
+ @app.command()
52
+ def init(
53
+ data: Annotated[Path, typer.Argument(help="Excel or CSV file to evaluate on.")],
54
+ output: Annotated[Path, typer.Option("--output", "-o", help="Config file to write.")] = Path(
55
+ "run.yaml"
56
+ ),
57
+ sheet: Annotated[str | None, typer.Option(help="Sheet name for Excel files.")] = None,
58
+ force: Annotated[bool, typer.Option(help="Overwrite an existing config.")] = False,
59
+ ) -> None:
60
+ """Write a starter run config for DATA, guessing the column roles."""
61
+ try:
62
+ dataset = load_dataset(data, sheet)
63
+ except PaperLabError as exc:
64
+ raise _fail(exc) from exc
65
+ if output.exists() and not force:
66
+ raise _fail(FileExistsError(f"{output} exists; use --force to overwrite."))
67
+
68
+ columns = dataset.columns
69
+ lower = {c.lower(): c for c in columns}
70
+ id_col = next((lower[h] for h in _ID_HINTS if h in lower), None)
71
+ expected = next(
72
+ (c for c in columns if any(h in c.lower() for h in _EXPECTED_HINTS) and c != id_col), None
73
+ )
74
+ inputs = [c for c in columns if c not in (id_col, expected)][:1] or columns[:1]
75
+
76
+ try:
77
+ relative = data.resolve().relative_to(output.resolve().parent)
78
+ except ValueError:
79
+ relative = data.resolve()
80
+ config: dict[str, object] = {
81
+ "dataset": {"path": str(relative), "sheet": dataset.sheet},
82
+ "mapping": {"input": inputs, "context": [], "expected": expected, "id": id_col},
83
+ "prompt": f"{{{inputs[0]}}}",
84
+ "models": ["openrouter/nvidia/nemotron-3-super-120b-a12b:free", "gemini/gemini-2.5-flash"],
85
+ "metrics": ["exact_match"] if expected else [],
86
+ "seed": 42,
87
+ }
88
+ header = (
89
+ f"# PaperLab run config for {data.name}\n"
90
+ f"# Columns: {', '.join(columns)}\n"
91
+ "# Edit the mapping, prompt, models, and metrics, then run:\n"
92
+ f"# paperlab-space estimate {output.name}\n"
93
+ f"# paperlab-space run {output.name}\n"
94
+ f"# Metrics: {', '.join(sorted(available_metrics()))}\n"
95
+ "# API keys are read from OPENROUTER_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, ...\n"
96
+ )
97
+ output.write_text(
98
+ header + yaml.safe_dump(config, allow_unicode=True, sort_keys=False), encoding="utf-8"
99
+ )
100
+ console.print(f"Wrote [bold]{output}[/] (input: {inputs}, expected: {expected}, id: {id_col})")
101
+
102
+
103
+ @app.command()
104
+ def validate(
105
+ data: Annotated[Path, typer.Argument(help="Excel or CSV file.")],
106
+ sheet: Annotated[str | None, typer.Option(help="Sheet name for Excel files.")] = None,
107
+ input: Annotated[
108
+ list[str] | None, typer.Option("--input", "-i", help="Input column(s).")
109
+ ] = None,
110
+ expected: Annotated[str | None, typer.Option("--expected", "-e")] = None,
111
+ id: Annotated[str | None, typer.Option("--id")] = None,
112
+ delimiter: Annotated[str | None, typer.Option(help="Separator for multiple answers.")] = None,
113
+ preview: Annotated[int, typer.Option(help="Rows to preview as JSONL.")] = 3,
114
+ ) -> None:
115
+ """Check a data file for problems and preview the converted JSONL."""
116
+ try:
117
+ dataset = load_dataset(data, sheet)
118
+ console.print(
119
+ f"[bold]{data.name}[/]: {len(dataset)} rows, sheet {dataset.sheet!r}"
120
+ if dataset.sheet
121
+ else f"[bold]{data.name}[/]: {len(dataset)} rows"
122
+ )
123
+ if data.suffix.lower() in (".xlsx", ".xlsm"):
124
+ console.print(f"Sheets: {', '.join(list_sheets(data))}")
125
+ console.print(f"Columns: {', '.join(dataset.columns)}")
126
+ if not input:
127
+ console.print("Pass --input (and --expected) to check a column mapping.")
128
+ return
129
+ mapping = ColumnMapping(input=input, expected=expected, id=id, answer_delimiter=delimiter)
130
+ mapped = dataset.map(mapping)
131
+ except (PaperLabError, ValueError) as exc:
132
+ raise _fail(exc) from exc
133
+ report = mapped.validate()
134
+ console.print(str(report), markup=False, style="red" if not report.ok else None)
135
+ if preview:
136
+ console.print(f"\nFirst {min(preview, len(mapped))} records:")
137
+ for line in mapped.to_jsonl().splitlines()[:preview]:
138
+ console.print(line, markup=False)
139
+ if not report.ok:
140
+ raise typer.Exit(1)
141
+
142
+
143
+ @app.command("estimate")
144
+ def estimate_cmd(
145
+ config: Annotated[Path, typer.Argument(help="Run config (YAML).")],
146
+ ) -> None:
147
+ """Estimate tokens, cost, and time for a run without calling any model."""
148
+ try:
149
+ cfg = RunConfig.from_yaml(config)
150
+ console.print(str(estimate(cfg, prepare_data(cfg, None))), markup=False)
151
+ except PaperLabError as exc:
152
+ raise _fail(exc) from exc
153
+
154
+
155
+ @app.command("run")
156
+ def run_cmd(
157
+ config: Annotated[Path, typer.Argument(help="Run config (YAML).")],
158
+ out: Annotated[Path | None, typer.Option("--out", "-o", help="Output folder.")] = None,
159
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation.")] = False,
160
+ resume: Annotated[
161
+ Path | None, typer.Option(help="results.json of an interrupted run to continue.")
162
+ ] = None,
163
+ ) -> None:
164
+ """Run an evaluation and write Excel, LaTeX, YAML, and JSON results."""
165
+ out = out or config.parent / f"results-{config.stem}"
166
+ try:
167
+ cfg = RunConfig.from_yaml(config)
168
+ data = prepare_data(cfg, None)
169
+ previous = RunResults.load_json(resume) if resume else None
170
+ except PaperLabError as exc:
171
+ raise _fail(exc) from exc
172
+
173
+ console.print(str(estimate(cfg, data)), markup=False)
174
+ if not yes and not typer.confirm("Start the run?", default=True):
175
+ raise typer.Exit()
176
+
177
+ with Progress(
178
+ TextColumn("{task.description}"),
179
+ BarColumn(),
180
+ MofNCompleteColumn(),
181
+ TextColumn("[red]{task.fields[failed]} failed"),
182
+ TimeRemainingColumn(),
183
+ console=console,
184
+ ) as bar:
185
+ tasks = {m.name: bar.add_task(m.name, total=len(data), failed=0) for m in cfg.models}
186
+
187
+ def on_progress(p: RunProgress) -> None:
188
+ bar.update(tasks[p.model], completed=p.done, failed=p.failed)
189
+
190
+ try:
191
+ results = run(cfg, data, on_progress=on_progress, previous=previous)
192
+ except PaperLabError as exc:
193
+ raise _fail(exc) from exc
194
+
195
+ paths = write_all(results, out)
196
+ _print_scores(results)
197
+ failed = sum(r.n_failed for r in results.runs.values())
198
+ if failed:
199
+ console.print(
200
+ f"[yellow]{failed} request(s) failed.[/] Re-run with "
201
+ f"--resume {paths['json']} to retry only those rows."
202
+ )
203
+ console.print(
204
+ f"Results written to [bold]{out}[/]: " + ", ".join(p.name for p in paths.values())
205
+ )
206
+
207
+
208
+ @app.command()
209
+ def metrics() -> None:
210
+ """List the available metrics."""
211
+ table = Table("Metric", "Needs answer key", "Description")
212
+ for name, cls in sorted(available_metrics().items()):
213
+ table.add_row(name, "yes" if cls.requires_reference else "no", cls.description)
214
+ console.print(table)
215
+
216
+
217
+ def _print_scores(results: RunResults) -> None:
218
+ keys = [s.key for s in results.config.metrics]
219
+ table = Table("Model", *keys, "Failed", "Cost (USD)")
220
+ for name, model_run in results.runs.items():
221
+ scores = results.scores.get(name, {})
222
+ cells = [
223
+ f"{scores[k].value:.4f}" if k in scores and scores[k].value is not None else "--"
224
+ for k in keys
225
+ ]
226
+ cost = model_run.cost_usd
227
+ table.add_row(
228
+ name, *cells, str(model_run.n_failed), "--" if cost is None else f"{cost:.4f}"
229
+ )
230
+ console.print(table)
paperlab_space/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ """Entry point for the ``paperlab-space`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> None:
9
+ try:
10
+ from paperlab_space._cli import app
11
+ except ImportError as exc:
12
+ if getattr(exc, "name", None) not in ("typer", "rich"):
13
+ raise
14
+ sys.stderr.write(
15
+ "The paperlab-space command needs the CLI extra:\n pip install 'paperlab-space[cli]'\n"
16
+ )
17
+ raise SystemExit(1) from exc
18
+ app()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,196 @@
1
+ """The run configuration: one object that fully describes an evaluation.
2
+
3
+ The same :class:`RunConfig` drives a run and is written out as the reproducibility
4
+ config, so a run can be repeated from its exported config alone.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
14
+
15
+ from paperlab_space.data.mapping import ColumnMapping
16
+ from paperlab_space.errors import ConfigError
17
+ from paperlab_space.prompt import PromptTemplate
18
+
19
+
20
+ class _Strict(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+
24
+ class ModelSpec(_Strict):
25
+ """A model to evaluate, addressed by its LiteLLM ID (``provider/model``)."""
26
+
27
+ id: str
28
+ label: str | None = None
29
+ """Display name in tables. Defaults to the model ID without the provider prefix."""
30
+ temperature: float = Field(0.0, ge=0)
31
+ max_tokens: int | None = Field(None, gt=0)
32
+ top_p: float | None = Field(None, gt=0, le=1)
33
+ seed: int | None = None
34
+ requests_per_minute: float | None = Field(None, gt=0)
35
+ """Override the default rate limit for this model."""
36
+ concurrency: int | None = Field(None, gt=0)
37
+ """Override the run-level number of parallel requests for this model."""
38
+ params: dict[str, Any] = {}
39
+ """Extra provider parameters passed through to LiteLLM."""
40
+
41
+ @model_validator(mode="before")
42
+ @classmethod
43
+ def _from_string(cls, value: Any) -> Any:
44
+ return {"id": value} if isinstance(value, str) else value
45
+
46
+ @field_validator("id")
47
+ @classmethod
48
+ def _check_id(cls, value: str) -> str:
49
+ value = value.strip()
50
+ if "/" not in value:
51
+ raise ValueError(
52
+ f"Model ID {value!r} needs a provider prefix, e.g. 'openrouter/{value}' or "
53
+ f"'gemini/{value}'."
54
+ )
55
+ return value
56
+
57
+ @property
58
+ def provider(self) -> str:
59
+ return self.id.split("/", 1)[0]
60
+
61
+ @property
62
+ def name(self) -> str:
63
+ """The label used in outputs."""
64
+ return self.label or self.id.split("/", 1)[1]
65
+
66
+
67
+ class MetricSpec(_Strict):
68
+ """A metric name plus its options, e.g. ``{"name": "exact_match", "ignore_case": false}``."""
69
+
70
+ name: str
71
+ label: str | None = None
72
+ """Column name in outputs; needed to use the same metric twice with different options."""
73
+ params: dict[str, Any] = {}
74
+
75
+ @model_validator(mode="before")
76
+ @classmethod
77
+ def _from_string(cls, value: Any) -> Any:
78
+ if isinstance(value, str):
79
+ return {"name": value}
80
+ if isinstance(value, dict) and "name" in value and "params" not in value:
81
+ # Allow the flat form ``{name: f1, average: macro}``.
82
+ keys = {"name", "label"}
83
+ return {
84
+ **{k: v for k, v in value.items() if k in keys},
85
+ "params": {k: v for k, v in value.items() if k not in keys},
86
+ }
87
+ return value
88
+
89
+ @property
90
+ def key(self) -> str:
91
+ return self.label or self.name
92
+
93
+
94
+ class DatasetSpec(_Strict):
95
+ """Where the data comes from. Optional when the dataset is passed in directly."""
96
+
97
+ path: str
98
+ sheet: str | int | None = None
99
+
100
+
101
+ class RetrySettings(_Strict):
102
+ max_attempts: int = Field(5, ge=1)
103
+ base_delay: float = Field(2.0, ge=0)
104
+ """Seconds before the first retry; doubles on each attempt."""
105
+ max_delay: float = Field(60.0, ge=0)
106
+ timeout: float = Field(120.0, gt=0)
107
+ """Seconds to wait for one model response."""
108
+
109
+
110
+ class RunConfig(_Strict):
111
+ """Everything needed to run (and re-run) an evaluation."""
112
+
113
+ dataset: DatasetSpec | None = None
114
+ mapping: ColumnMapping
115
+ prompt: PromptTemplate
116
+ models: list[ModelSpec]
117
+ metrics: list[MetricSpec] = []
118
+ seed: int | None = None
119
+ """Default seed for models that do not set their own."""
120
+ concurrency: int = Field(4, gt=0)
121
+ """Parallel requests per model (rate limits still apply)."""
122
+ retry: RetrySettings = RetrySettings()
123
+
124
+ @model_validator(mode="after")
125
+ def _check(self) -> RunConfig:
126
+ if not self.models:
127
+ raise ValueError("Pick at least one model.")
128
+ names = [m.name for m in self.models]
129
+ dupes = sorted({n for n in names if names.count(n) > 1})
130
+ if dupes:
131
+ raise ValueError(
132
+ f"Models must have distinct labels; duplicated: {', '.join(dupes)}. "
133
+ "Set `label` to tell them apart (e.g. the same model at two temperatures)."
134
+ )
135
+ keys = [m.key for m in self.metrics]
136
+ dupes = sorted({k for k in keys if keys.count(k) > 1})
137
+ if dupes:
138
+ raise ValueError(
139
+ f"Metrics must have distinct labels; duplicated: {', '.join(dupes)}. "
140
+ "Set `label` to use the same metric with different options."
141
+ )
142
+ try:
143
+ self.prompt.check(self.mapping.fields)
144
+ except Exception as exc:
145
+ raise ValueError(str(exc)) from exc
146
+ return self
147
+
148
+ def model_seed(self, model: ModelSpec) -> int | None:
149
+ return model.seed if model.seed is not None else self.seed
150
+
151
+ # --- YAML -------------------------------------------------------------------------
152
+
153
+ @classmethod
154
+ def from_dict(cls, data: dict[str, Any]) -> RunConfig:
155
+ """Build from a dict; also accepts a reproducibility file (config under ``config``)."""
156
+ if "config" in data and "paperlab_space" in data:
157
+ data = data["config"]
158
+ try:
159
+ return cls.model_validate(data)
160
+ except ValidationError as exc:
161
+ raise ConfigError(_format_errors(exc)) from exc
162
+
163
+ @classmethod
164
+ def from_yaml(cls, path: str | Path) -> RunConfig:
165
+ """Load a YAML config. A relative dataset path is resolved against the file's folder."""
166
+ path = Path(path)
167
+ try:
168
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
169
+ except (OSError, yaml.YAMLError) as exc:
170
+ raise ConfigError(f"Could not read {path}: {exc}") from exc
171
+ if not isinstance(data, dict):
172
+ raise ConfigError(f"{path} does not contain a YAML mapping.")
173
+ config = cls.from_dict(data)
174
+ if config.dataset is not None and not Path(config.dataset.path).is_absolute():
175
+ resolved = (path.parent / config.dataset.path).resolve()
176
+ config = config.model_copy(
177
+ update={"dataset": config.dataset.model_copy(update={"path": str(resolved)})}
178
+ )
179
+ return config
180
+
181
+ def to_dict(self) -> dict[str, Any]:
182
+ return self.model_dump(mode="json", exclude_defaults=False)
183
+
184
+ def to_yaml(self, path: str | Path | None = None) -> str:
185
+ text = yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False)
186
+ if path is not None:
187
+ Path(path).write_text(text, encoding="utf-8")
188
+ return text
189
+
190
+
191
+ def _format_errors(exc: ValidationError) -> str:
192
+ lines = ["Invalid run configuration:"]
193
+ for error in exc.errors():
194
+ where = ".".join(str(part) for part in error["loc"]) or "config"
195
+ lines.append(f" {where}: {error['msg']}")
196
+ return "\n".join(lines)
@@ -0,0 +1,17 @@
1
+ """Loading, mapping, and validating evaluation data."""
2
+
3
+ from paperlab_space.data.load import Dataset, list_sheets, load_dataset
4
+ from paperlab_space.data.mapping import ColumnMapping, MappedDataset, Record
5
+ from paperlab_space.data.validate import Issue, ValidationReport, validate
6
+
7
+ __all__ = [
8
+ "ColumnMapping",
9
+ "Dataset",
10
+ "Issue",
11
+ "MappedDataset",
12
+ "Record",
13
+ "ValidationReport",
14
+ "list_sheets",
15
+ "load_dataset",
16
+ "validate",
17
+ ]