lightcone-cli 0.2.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.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
lightcone/eval/build.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Auto-build wheels and collect version metadata for eval runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from lightcone.eval.models import VersionInfo
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_repo_root() -> Path:
|
|
17
|
+
"""Find the lightcone-cli repo root via git."""
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
20
|
+
capture_output=True, text=True, check=True,
|
|
21
|
+
)
|
|
22
|
+
return Path(result.stdout.strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_git_info(repo_root: Path) -> VersionInfo:
|
|
26
|
+
"""Collect git metadata from the repo."""
|
|
27
|
+
info = VersionInfo()
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["git", "rev-parse", "HEAD"],
|
|
32
|
+
capture_output=True, text=True, check=True, cwd=repo_root,
|
|
33
|
+
)
|
|
34
|
+
info.lightcone_commit = result.stdout.strip()
|
|
35
|
+
except subprocess.CalledProcessError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
41
|
+
capture_output=True, text=True, check=True, cwd=repo_root,
|
|
42
|
+
)
|
|
43
|
+
info.lightcone_branch = result.stdout.strip()
|
|
44
|
+
except subprocess.CalledProcessError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "diff", "--quiet", "HEAD"],
|
|
50
|
+
capture_output=True, cwd=repo_root,
|
|
51
|
+
)
|
|
52
|
+
info.lightcone_dirty = result.returncode != 0
|
|
53
|
+
except subprocess.CalledProcessError:
|
|
54
|
+
info.lightcone_dirty = True
|
|
55
|
+
|
|
56
|
+
return info
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_wheel(repo_root: Path, outdir: Path) -> Path:
|
|
60
|
+
"""Build a wheel from the repo and return the wheel path."""
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["python", "-m", "build", "--wheel", "--outdir", str(outdir)],
|
|
63
|
+
capture_output=True, text=True, cwd=repo_root,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise RuntimeError(f"Wheel build failed:\n{result.stderr}")
|
|
67
|
+
|
|
68
|
+
wheels = list(outdir.glob("lightcone_cli-*.whl"))
|
|
69
|
+
if not wheels:
|
|
70
|
+
raise RuntimeError(f"No lightcone_cli wheel found in {outdir} after build")
|
|
71
|
+
return wheels[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_version(wheel_path: Path) -> str:
|
|
75
|
+
"""Extract version string from a wheel filename."""
|
|
76
|
+
# Wheel filenames: {name}-{version}-{tags}.whl
|
|
77
|
+
match = re.match(r"[^-]+-([^-]+)-", wheel_path.name)
|
|
78
|
+
return match.group(1) if match else wheel_path.name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_astra_wheel(outdir: Path) -> Path:
|
|
82
|
+
"""Build an ASTRA wheel from the version installed in the current environment.
|
|
83
|
+
|
|
84
|
+
Uses importlib.metadata to find the git URL and commit of the installed astra
|
|
85
|
+
package, then builds a wheel pinned to that exact commit via pip wheel.
|
|
86
|
+
"""
|
|
87
|
+
import importlib.metadata
|
|
88
|
+
import json
|
|
89
|
+
|
|
90
|
+
dist = importlib.metadata.distribution("astra")
|
|
91
|
+
url_text = dist.read_text("direct_url.json")
|
|
92
|
+
if not url_text:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"Cannot determine ASTRA source — installed package has no direct_url.json. "
|
|
95
|
+
"Ensure astra is installed from its git repo (not a plain wheel)."
|
|
96
|
+
)
|
|
97
|
+
url_info = json.loads(url_text)
|
|
98
|
+
git_url = url_info["url"]
|
|
99
|
+
commit = url_info.get("vcs_info", {}).get("commit_id", "")
|
|
100
|
+
spec = f"astra @ git+{git_url}@{commit}" if commit else f"astra @ git+{git_url}"
|
|
101
|
+
|
|
102
|
+
logger.info("Building ASTRA wheel from %s (commit %s) ...", git_url, commit[:8] or "HEAD")
|
|
103
|
+
result = subprocess.run(
|
|
104
|
+
["pip", "wheel", "--no-deps", "--wheel-dir", str(outdir), spec],
|
|
105
|
+
capture_output=True, text=True,
|
|
106
|
+
)
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
raise RuntimeError(f"ASTRA wheel build failed:\n{result.stderr}")
|
|
109
|
+
|
|
110
|
+
wheels = list(outdir.glob("astra-*.whl"))
|
|
111
|
+
if not wheels:
|
|
112
|
+
raise RuntimeError(f"No astra wheel found in {outdir} after build")
|
|
113
|
+
return wheels[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_eval_wheels(evals_dir: Path) -> tuple[VersionInfo, list[Path]]:
|
|
117
|
+
"""Build the lightcone-cli and ASTRA wheels for sandbox injection.
|
|
118
|
+
|
|
119
|
+
Returns (version_info, [wheel_paths]).
|
|
120
|
+
Both wheels are built fresh from the current environment:
|
|
121
|
+
- lightcone-cli: built from the local git working tree
|
|
122
|
+
- ASTRA: built from the git URL/commit recorded in the installed package metadata
|
|
123
|
+
"""
|
|
124
|
+
repo_root = _get_repo_root()
|
|
125
|
+
version_info = _get_git_info(repo_root)
|
|
126
|
+
|
|
127
|
+
# Build lightcone-cli wheel into a temp dir
|
|
128
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="lightcone-eval-wheels-"))
|
|
129
|
+
logger.info("Building lightcone-cli wheel from %s ...", repo_root)
|
|
130
|
+
lightcone_wheel = _build_wheel(repo_root, tmpdir)
|
|
131
|
+
version_info.lightcone_version = _extract_version(lightcone_wheel)
|
|
132
|
+
logger.info("Built %s (commit %s%s)",
|
|
133
|
+
lightcone_wheel.name,
|
|
134
|
+
version_info.lightcone_commit[:8],
|
|
135
|
+
" dirty" if version_info.lightcone_dirty else "")
|
|
136
|
+
|
|
137
|
+
wheels: list[Path] = [lightcone_wheel]
|
|
138
|
+
|
|
139
|
+
# Build ASTRA wheel from the installed package's git source
|
|
140
|
+
try:
|
|
141
|
+
astra_wheel = _build_astra_wheel(tmpdir)
|
|
142
|
+
version_info.astra_version = _extract_version(astra_wheel)
|
|
143
|
+
wheels.append(astra_wheel)
|
|
144
|
+
logger.info("Built %s", astra_wheel.name)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("Failed to build ASTRA wheel: %s — sandbox may not have astra CLI", exc)
|
|
147
|
+
|
|
148
|
+
return version_info, wheels
|
lightcone/eval/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""CLI commands for the eval harness: lc eval {run, report, compare}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def eval_group() -> None:
|
|
15
|
+
"""Evaluate the lightcone-cli build loop against seed tasks."""
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
# Configure logging so sandbox build logs and harness progress are visible
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format="%(asctime)s %(name)s %(message)s",
|
|
26
|
+
datefmt="%H:%M:%S",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@eval_group.command("run")
|
|
31
|
+
@click.argument("config_path", type=click.Path(exists=True, path_type=Path))
|
|
32
|
+
@click.option("--concurrency", "-c", type=int, default=None, help="Max parallel sandboxes")
|
|
33
|
+
@click.option("--num-trials", "-n", type=int, default=None, help="Override number of trials")
|
|
34
|
+
@click.option("--dry-run", is_flag=True, help="Print trial schedule without running")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--evals-dir",
|
|
37
|
+
type=click.Path(exists=True, path_type=Path),
|
|
38
|
+
default=None,
|
|
39
|
+
help="Path to evals/ directory (default: evals/ in current dir)",
|
|
40
|
+
)
|
|
41
|
+
def run_cmd(
|
|
42
|
+
config_path: Path,
|
|
43
|
+
concurrency: int | None,
|
|
44
|
+
num_trials: int | None,
|
|
45
|
+
dry_run: bool,
|
|
46
|
+
evals_dir: Path | None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Run an eval suite from a config file.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
lc eval run evals/example-run.yaml
|
|
52
|
+
lc eval run evals/example-run.yaml --dry-run
|
|
53
|
+
lc eval run evals/example-run.yaml --num-trials 1 --concurrency 2
|
|
54
|
+
"""
|
|
55
|
+
from lightcone.eval.harness import load_run_config, run_eval
|
|
56
|
+
from lightcone.eval.report import compute_summary, print_comparison_table, save_results
|
|
57
|
+
|
|
58
|
+
config = load_run_config(config_path)
|
|
59
|
+
|
|
60
|
+
# Apply CLI overrides
|
|
61
|
+
if concurrency is not None:
|
|
62
|
+
config.max_concurrency = concurrency
|
|
63
|
+
if num_trials is not None:
|
|
64
|
+
config.num_trials = num_trials
|
|
65
|
+
|
|
66
|
+
if evals_dir is None:
|
|
67
|
+
evals_dir = Path.cwd() / "evals"
|
|
68
|
+
|
|
69
|
+
if not evals_dir.exists():
|
|
70
|
+
console.print(f"[red]Error:[/red] Evals directory not found: {evals_dir}")
|
|
71
|
+
raise SystemExit(1)
|
|
72
|
+
|
|
73
|
+
if dry_run:
|
|
74
|
+
console.print("[bold]Dry run — trial schedule:[/bold]\n")
|
|
75
|
+
|
|
76
|
+
def _on_trial_complete(trial: object) -> None:
|
|
77
|
+
from lightcone.eval.models import TrialResult
|
|
78
|
+
|
|
79
|
+
assert isinstance(trial, TrialResult)
|
|
80
|
+
if trial.build_complete:
|
|
81
|
+
status = "[green]complete[/green]"
|
|
82
|
+
else:
|
|
83
|
+
status = "[yellow]incomplete[/yellow]"
|
|
84
|
+
if trial.error:
|
|
85
|
+
status = f"[red]error: {trial.error[:60]}[/red]"
|
|
86
|
+
console.print(
|
|
87
|
+
f" {trial.task_id} "
|
|
88
|
+
f"trial {trial.trial_number}: "
|
|
89
|
+
f"score={trial.composite_score:.2f} {status}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
eval_run = run_eval(
|
|
93
|
+
config,
|
|
94
|
+
evals_dir,
|
|
95
|
+
progress_callback=_on_trial_complete,
|
|
96
|
+
dry_run=dry_run,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if dry_run:
|
|
100
|
+
schedule = eval_run.summary.get("schedule", [])
|
|
101
|
+
for s in schedule:
|
|
102
|
+
console.print(f" {s['task']} trial {s['trial']}")
|
|
103
|
+
console.print(f"\n[bold]Total: {eval_run.summary.get('total_trials', 0)} trials[/bold]")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# Display version info
|
|
107
|
+
v = eval_run.version
|
|
108
|
+
dirty = " [yellow](dirty)[/yellow]" if v.lightcone_dirty else ""
|
|
109
|
+
console.print(
|
|
110
|
+
f"\n[bold]lightcone-cli:[/bold] {v.lightcone_version} "
|
|
111
|
+
f"({v.lightcone_branch} {v.lightcone_commit[:8]}){dirty}"
|
|
112
|
+
)
|
|
113
|
+
if v.astra_version:
|
|
114
|
+
console.print(f"[bold]ASTRA:[/bold] {v.astra_version}")
|
|
115
|
+
|
|
116
|
+
# Compute summary and display
|
|
117
|
+
eval_run.summary = compute_summary(eval_run)
|
|
118
|
+
console.print()
|
|
119
|
+
print_comparison_table(eval_run, console=console)
|
|
120
|
+
|
|
121
|
+
# Save results
|
|
122
|
+
output_path = save_results(eval_run, config.output_dir)
|
|
123
|
+
console.print(f"\n[bold]Results saved to:[/bold] {output_path}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@eval_group.command("report")
|
|
127
|
+
@click.argument("results_path", type=click.Path(exists=True, path_type=Path))
|
|
128
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON summary")
|
|
129
|
+
def report_cmd(results_path: Path, as_json: bool) -> None:
|
|
130
|
+
"""Display results from a previous eval run.
|
|
131
|
+
|
|
132
|
+
Examples:
|
|
133
|
+
lc eval report eval-results/my-run-20260315.json
|
|
134
|
+
lc eval report eval-results/my-run-20260315.json --json
|
|
135
|
+
"""
|
|
136
|
+
import json
|
|
137
|
+
|
|
138
|
+
from lightcone.eval.report import compute_summary, load_results, print_comparison_table
|
|
139
|
+
|
|
140
|
+
eval_run = load_results(results_path)
|
|
141
|
+
|
|
142
|
+
if not eval_run.summary:
|
|
143
|
+
eval_run.summary = compute_summary(eval_run)
|
|
144
|
+
|
|
145
|
+
if as_json:
|
|
146
|
+
console.print(json.dumps(eval_run.summary, indent=2, default=str))
|
|
147
|
+
else:
|
|
148
|
+
print_comparison_table(eval_run, console=console)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@eval_group.command("compare")
|
|
152
|
+
@click.argument("results1", type=click.Path(exists=True, path_type=Path))
|
|
153
|
+
@click.argument("results2", type=click.Path(exists=True, path_type=Path))
|
|
154
|
+
def compare_cmd(results1: Path, results2: Path) -> None:
|
|
155
|
+
"""Compare two eval runs side by side.
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
lc eval compare eval-results/run1.json eval-results/run2.json
|
|
159
|
+
"""
|
|
160
|
+
from lightcone.eval.report import (
|
|
161
|
+
compute_summary,
|
|
162
|
+
load_results,
|
|
163
|
+
print_comparison_between,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
run1 = load_results(results1)
|
|
167
|
+
run2 = load_results(results2)
|
|
168
|
+
|
|
169
|
+
if not run1.summary:
|
|
170
|
+
run1.summary = compute_summary(run1)
|
|
171
|
+
if not run2.summary:
|
|
172
|
+
run2.summary = compute_summary(run2)
|
|
173
|
+
|
|
174
|
+
print_comparison_between(run1, run2, console=console)
|
|
175
|
+
|
|
176
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Grader dispatch for eval trials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from lightcone.eval.models import GraderResult, GraderSpec, GraderType
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from lightcone.eval.sandbox import EvalSandbox
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _error_result(grader: GraderSpec, error: str, **kwargs: object) -> GraderResult:
|
|
18
|
+
"""Create a failed GraderResult with score 0."""
|
|
19
|
+
return GraderResult(
|
|
20
|
+
name=grader.name,
|
|
21
|
+
type=grader.type,
|
|
22
|
+
passed=False,
|
|
23
|
+
score=0.0,
|
|
24
|
+
weight=grader.weight,
|
|
25
|
+
error=error,
|
|
26
|
+
**kwargs, # type: ignore[arg-type]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_graders(
|
|
31
|
+
sandbox: EvalSandbox,
|
|
32
|
+
graders: list[GraderSpec],
|
|
33
|
+
evals_dir: Path,
|
|
34
|
+
task_id: str,
|
|
35
|
+
) -> list[GraderResult]:
|
|
36
|
+
"""Run all graders for a trial and return results."""
|
|
37
|
+
results: list[GraderResult] = []
|
|
38
|
+
for grader in graders:
|
|
39
|
+
try:
|
|
40
|
+
result = _run_single_grader(sandbox, grader, evals_dir, task_id)
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
result = _error_result(grader, str(exc))
|
|
43
|
+
results.append(result)
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_single_grader(
|
|
48
|
+
sandbox: EvalSandbox,
|
|
49
|
+
grader: GraderSpec,
|
|
50
|
+
evals_dir: Path,
|
|
51
|
+
task_id: str,
|
|
52
|
+
) -> GraderResult:
|
|
53
|
+
"""Run a single grader and return its result."""
|
|
54
|
+
if grader.type == GraderType.command:
|
|
55
|
+
return _grade_command(sandbox, grader)
|
|
56
|
+
elif grader.type == GraderType.status:
|
|
57
|
+
return _grade_status(sandbox, grader)
|
|
58
|
+
elif grader.type == GraderType.script:
|
|
59
|
+
return _grade_script(sandbox, grader, evals_dir, task_id)
|
|
60
|
+
else:
|
|
61
|
+
return _error_result(grader, f"Unknown grader type: {grader.type}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _grade_command(sandbox: EvalSandbox, grader: GraderSpec) -> GraderResult:
|
|
65
|
+
"""Run a shell command; exit 0 = pass."""
|
|
66
|
+
if grader.command is None:
|
|
67
|
+
raise ValueError("command grader requires 'command' field")
|
|
68
|
+
|
|
69
|
+
cmd = f"cd {sandbox.WORK_DIR} && {grader.command}"
|
|
70
|
+
result = sandbox.exec(cmd, timeout=grader.timeout)
|
|
71
|
+
passed = result.exit_code == 0
|
|
72
|
+
return GraderResult(
|
|
73
|
+
name=grader.name,
|
|
74
|
+
type=grader.type,
|
|
75
|
+
passed=passed,
|
|
76
|
+
score=1.0 if passed else 0.0,
|
|
77
|
+
weight=grader.weight,
|
|
78
|
+
details=result.output[:2000] if result.output else "",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _grade_status(sandbox: EvalSandbox, grader: GraderSpec) -> GraderResult:
|
|
83
|
+
"""Check output materialization status via lc status.
|
|
84
|
+
|
|
85
|
+
Parses the text table output, counting 'ok' vs 'pending'/'no_recipe' entries.
|
|
86
|
+
"""
|
|
87
|
+
cmd = f"cd {sandbox.WORK_DIR} && lc status"
|
|
88
|
+
result = sandbox.exec(cmd, timeout=grader.timeout)
|
|
89
|
+
|
|
90
|
+
if result.exit_code != 0:
|
|
91
|
+
return _error_result(
|
|
92
|
+
grader,
|
|
93
|
+
f"lc status exited with code {result.exit_code}",
|
|
94
|
+
details=result.output[:2000],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Parse the Rich table output for status values
|
|
98
|
+
materialized = 0
|
|
99
|
+
total = 0
|
|
100
|
+
for line in result.output.splitlines():
|
|
101
|
+
# Table rows start with │ and contain status words
|
|
102
|
+
if "│" not in line:
|
|
103
|
+
continue
|
|
104
|
+
cells = [c.strip() for c in line.split("│") if c.strip()]
|
|
105
|
+
if len(cells) < 2:
|
|
106
|
+
continue
|
|
107
|
+
# Skip header row
|
|
108
|
+
if cells[0].lower() in ("output", ""):
|
|
109
|
+
continue
|
|
110
|
+
# Each non-header cell after the first is a status value
|
|
111
|
+
for cell in cells[1:]:
|
|
112
|
+
if cell in ("ok", "pending", "no_recipe"):
|
|
113
|
+
total += 1
|
|
114
|
+
if cell == "ok":
|
|
115
|
+
materialized += 1
|
|
116
|
+
|
|
117
|
+
if total == 0:
|
|
118
|
+
return GraderResult(
|
|
119
|
+
name=grader.name,
|
|
120
|
+
type=grader.type,
|
|
121
|
+
passed=False,
|
|
122
|
+
score=0.0,
|
|
123
|
+
weight=grader.weight,
|
|
124
|
+
details="No outputs found in lc status output",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
score = materialized / total
|
|
128
|
+
return GraderResult(
|
|
129
|
+
name=grader.name,
|
|
130
|
+
type=grader.type,
|
|
131
|
+
passed=score == 1.0,
|
|
132
|
+
score=score,
|
|
133
|
+
weight=grader.weight,
|
|
134
|
+
details=f"{materialized}/{total} outputs materialized",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _grade_script(
|
|
140
|
+
sandbox: EvalSandbox,
|
|
141
|
+
grader: GraderSpec,
|
|
142
|
+
evals_dir: Path,
|
|
143
|
+
task_id: str,
|
|
144
|
+
) -> GraderResult:
|
|
145
|
+
"""Upload and run a custom grading script; exit 0 = pass, last stdout line as score."""
|
|
146
|
+
if grader.script is None:
|
|
147
|
+
raise ValueError("script grader requires 'script' field")
|
|
148
|
+
|
|
149
|
+
# Look for script in task graders dir
|
|
150
|
+
script_path = evals_dir / "tasks" / task_id / "graders" / grader.script
|
|
151
|
+
if not script_path.exists():
|
|
152
|
+
return _error_result(grader, f"Grader script not found: {script_path}")
|
|
153
|
+
|
|
154
|
+
remote_script = f"/tmp/grader_{grader.name}.py"
|
|
155
|
+
sandbox.upload_file(remote_script, script_path.read_bytes())
|
|
156
|
+
|
|
157
|
+
result = sandbox.exec(
|
|
158
|
+
f"cd {sandbox.WORK_DIR} && python {remote_script}",
|
|
159
|
+
timeout=grader.timeout,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
passed = result.exit_code == 0
|
|
163
|
+
score = 1.0 if passed else 0.0
|
|
164
|
+
|
|
165
|
+
# Try to parse last stdout line as a float score
|
|
166
|
+
if result.output:
|
|
167
|
+
lines = result.output.strip().splitlines()
|
|
168
|
+
if lines:
|
|
169
|
+
try:
|
|
170
|
+
parsed = float(lines[-1].strip())
|
|
171
|
+
if 0.0 <= parsed <= 1.0:
|
|
172
|
+
score = parsed
|
|
173
|
+
passed = score > 0.0
|
|
174
|
+
except ValueError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return GraderResult(
|
|
178
|
+
name=grader.name,
|
|
179
|
+
type=grader.type,
|
|
180
|
+
passed=passed,
|
|
181
|
+
score=score,
|
|
182
|
+
weight=grader.weight,
|
|
183
|
+
details=result.output[:2000] if result.output else "",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def compute_composite_score(results: list[GraderResult]) -> float:
|
|
188
|
+
"""Compute weighted mean of grader scores."""
|
|
189
|
+
total_weight = sum(r.weight for r in results)
|
|
190
|
+
if total_weight == 0:
|
|
191
|
+
return 0.0
|
|
192
|
+
return sum(r.score * r.weight for r in results) / total_weight
|