notebook-ta 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.
Files changed (48) hide show
  1. notebook_ta/__init__.py +226 -0
  2. notebook_ta/bench/__init__.py +1 -0
  3. notebook_ta/bench/app.py +37 -0
  4. notebook_ta/bench/catalog.py +73 -0
  5. notebook_ta/bench/cli.py +26 -0
  6. notebook_ta/bench/executor.py +307 -0
  7. notebook_ta/bench/hashing.py +76 -0
  8. notebook_ta/bench/internal_model.py +63 -0
  9. notebook_ta/bench/models.py +214 -0
  10. notebook_ta/bench/state.py +345 -0
  11. notebook_ta/bench/storage.py +120 -0
  12. notebook_ta/bench/ui/__init__.py +1 -0
  13. notebook_ta/bench/ui/_helpers.py +20 -0
  14. notebook_ta/bench/ui/compare_tab.py +461 -0
  15. notebook_ta/bench/ui/exercises_tab.py +429 -0
  16. notebook_ta/bench/ui/layout.py +89 -0
  17. notebook_ta/bench/ui/native_dialogs.py +65 -0
  18. notebook_ta/bench/ui/runner_tab.py +264 -0
  19. notebook_ta/bench/ui/settings_tab.py +224 -0
  20. notebook_ta/bench/ui/tag_badges.py +21 -0
  21. notebook_ta/bench/ui/welcome_dialog.py +91 -0
  22. notebook_ta/config/__init__.py +1 -0
  23. notebook_ta/config/loader.py +109 -0
  24. notebook_ta/config/models.py +92 -0
  25. notebook_ta/exercise/__init__.py +1 -0
  26. notebook_ta/exercise/definition.py +134 -0
  27. notebook_ta/exercise/registry.py +49 -0
  28. notebook_ta/llm/__init__.py +1 -0
  29. notebook_ta/llm/base.py +84 -0
  30. notebook_ta/llm/ollama.py +135 -0
  31. notebook_ta/llm/openai_compat.py +115 -0
  32. notebook_ta/logging.py +114 -0
  33. notebook_ta/notebook/__init__.py +1 -0
  34. notebook_ta/notebook/_ansi.py +79 -0
  35. notebook_ta/notebook/display.py +189 -0
  36. notebook_ta/notebook/extractor.py +165 -0
  37. notebook_ta/notebook/magic.py +184 -0
  38. notebook_ta/notebook/session.py +55 -0
  39. notebook_ta/notebook/streaming.py +42 -0
  40. notebook_ta/setup_wizard/__init__.py +1 -0
  41. notebook_ta/setup_wizard/detector.py +112 -0
  42. notebook_ta/testing/__init__.py +1 -0
  43. notebook_ta/testing/runner.py +216 -0
  44. notebook_ta-0.1.0.dist-info/METADATA +350 -0
  45. notebook_ta-0.1.0.dist-info/RECORD +48 -0
  46. notebook_ta-0.1.0.dist-info/WHEEL +4 -0
  47. notebook_ta-0.1.0.dist-info/entry_points.txt +2 -0
  48. notebook_ta-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,226 @@
1
+ """notebook-ta public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ import nest_asyncio
10
+
11
+ from notebook_ta.config.loader import load_exercises, load_global
12
+ from notebook_ta.config.models import ConfigurationError, GlobalConfig, LLMConfig
13
+ from notebook_ta.exercise.definition import Exercise
14
+ from notebook_ta.exercise.registry import ExerciseRegistry
15
+ from notebook_ta.llm.base import LLMProvider, create_provider
16
+ from notebook_ta.logging import get_logger, setup_logging
17
+ from notebook_ta.notebook.extractor import detect_notebook_path, extract_statements
18
+ from notebook_ta.notebook.magic import load_ipython_extension
19
+ from notebook_ta.notebook.session import SessionState
20
+
21
+ # Module-level singletons updated on each call to load()
22
+ _registry: ExerciseRegistry = ExerciseRegistry()
23
+ _llm_provider: LLMProvider | None = None
24
+ _global_config: GlobalConfig | None = None
25
+
26
+ _log = get_logger("init")
27
+
28
+
29
+ def load(
30
+ global_config: str | Path,
31
+ exercises_config: str | Path,
32
+ *,
33
+ notebook_path: str | Path | None = None,
34
+ llm_overrides: dict[str, Any] | None = None,
35
+ debug: bool = False,
36
+ ) -> None:
37
+ """Load configuration files, register exercises, run auto-setup if needed,
38
+ and register the %%notebook_ta IPython magic.
39
+
40
+ Must be called from within a Jupyter notebook cell.
41
+
42
+ Args:
43
+ global_config: Path or URL to the global configuration TOML.
44
+ exercises_config: Path or URL to the exercises TOML.
45
+ notebook_path: Optional path to the ``.ipynb`` file. Required only
46
+ when one or more exercises omit ``statement`` from the TOML and
47
+ the statement should instead be extracted from the notebook's
48
+ markdown cells (``<div id="<exercise_id>">…</div>`` pattern). If
49
+ not provided the system will try to detect the notebook path
50
+ automatically; pass this argument explicitly when auto-detection
51
+ fails.
52
+ llm_overrides: Optional dict of LLM settings that override the values
53
+ from *global_config*. Valid keys mirror :class:`LLMConfig` fields
54
+ (e.g. ``model``, ``base_url``, ``provider``, ``timeout``).
55
+ debug: When ``True``, enable DEBUG-level logging to the terminal and
56
+ display the final LLM prompt in the notebook output as a
57
+ collapsible widget before each LLM call. Defaults to ``False``.
58
+ """
59
+ global _registry, _llm_provider, _global_config
60
+
61
+ # Allow asyncio event loop nesting required in Jupyter environments
62
+ nest_asyncio.apply()
63
+
64
+ # Configure logging before anything else so all subsequent calls are captured.
65
+ setup_logging(debug=debug)
66
+
67
+ # 1. Load and validate configuration
68
+ _log.info("Loading notebook-ta configuration")
69
+ cfg = load_global(global_config)
70
+ exercise_configs = load_exercises(exercises_config)
71
+
72
+ # 1b. Apply programmatic LLM overrides (validated through Pydantic)
73
+ if llm_overrides:
74
+ try:
75
+ updated_llm = LLMConfig.model_validate({**cfg.llm.model_dump(), **llm_overrides})
76
+ except Exception as exc:
77
+ raise ConfigurationError(f"Invalid LLM override: {exc}") from exc
78
+ cfg = cfg.model_copy(update={"llm": updated_llm})
79
+
80
+ # 2. Auto-setup wizard
81
+ if cfg.llm.model == "auto":
82
+ _run_setup_wizard(cfg)
83
+
84
+ # 3. Create LLM provider
85
+ provider = create_provider(cfg.llm)
86
+ _log.info("LLM provider created: %r (model=%r)", cfg.llm.provider, cfg.llm.model)
87
+
88
+ # 4. Resolve missing statements from the notebook file
89
+ missing = [ex for ex in exercise_configs if ex.statement is None]
90
+ if missing:
91
+ nb_path = Path(notebook_path) if notebook_path is not None else _resolve_notebook_path()
92
+ _log.info("Extracting %d statement(s) from notebook: %s", len(missing), nb_path)
93
+ extracted = extract_statements(nb_path)
94
+ for ex_cfg in missing:
95
+ text = extracted.get(ex_cfg.id)
96
+ if text is None:
97
+ raise ConfigurationError(
98
+ f"Exercise {ex_cfg.id!r} has no statement in the TOML and no "
99
+ f"<div id={ex_cfg.id!r}> was found in {nb_path}."
100
+ )
101
+ ex_cfg.statement = text
102
+
103
+ # 5. Build and populate the registry
104
+ registry = ExerciseRegistry()
105
+ for ex_cfg in exercise_configs:
106
+ registry.register(Exercise(config=ex_cfg, global_config=cfg))
107
+
108
+ session = SessionState(hint_history_length=cfg.prompts.hint_history_length)
109
+ _log.info("Registry populated: %d exercise(s)", len(exercise_configs))
110
+
111
+ # 6. Register the IPython magic
112
+ try:
113
+ from IPython import get_ipython # type: ignore[attr-defined]
114
+
115
+ ip = get_ipython() # type: ignore[no-untyped-call]
116
+ if ip is not None:
117
+ load_ipython_extension(
118
+ ip, registry=registry, llm_provider=provider, session=session, debug=debug
119
+ )
120
+ else:
121
+ import warnings
122
+
123
+ warnings.warn(
124
+ "notebook_ta.load() was called outside of an IPython/Jupyter environment. "
125
+ "The %%notebook_ta magic will not be registered.",
126
+ stacklevel=2,
127
+ )
128
+ except ImportError:
129
+ import warnings
130
+
131
+ warnings.warn(
132
+ "IPython is not installed. The %%notebook_ta magic will not be registered.",
133
+ stacklevel=2,
134
+ )
135
+
136
+ # Update module singletons
137
+ _registry = registry
138
+ _llm_provider = provider
139
+ _global_config = cfg
140
+ _log.info("notebook-ta loaded successfully")
141
+
142
+ from IPython import display as ipydisplay
143
+
144
+ with contextlib.suppress(Exception):
145
+ cast(Any, ipydisplay.display)(
146
+ cast(Any, ipydisplay.Markdown)(
147
+ "✅ **notebook-ta loaded.** "
148
+ f"Provider: `{cfg.llm.provider}` — Model: `{cfg.llm.model}` \n"
149
+ f"{len(exercise_configs)} exercise(s) registered."
150
+ )
151
+ )
152
+
153
+
154
+ def get_registry() -> ExerciseRegistry:
155
+ """Return the active ExerciseRegistry for introspection."""
156
+ return _registry
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Internal helpers
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def _resolve_notebook_path() -> Path:
165
+ """Auto-detect the current notebook path or raise ConfigurationError."""
166
+ try:
167
+ from IPython import get_ipython as _gip # type: ignore[attr-defined]
168
+
169
+ ip = _gip() # type: ignore[no-untyped-call]
170
+ except ImportError:
171
+ ip = None
172
+
173
+ path = detect_notebook_path(ip)
174
+ if path is None:
175
+ raise ConfigurationError(
176
+ "Could not automatically determine the notebook path. "
177
+ "Pass notebook_path= to notebook_ta.load() explicitly, e.g.:\n"
178
+ " notebook_ta.load(global_cfg, exercises_cfg, "
179
+ "notebook_path='my_notebook.ipynb')"
180
+ )
181
+ return path
182
+
183
+
184
+ def _run_setup_wizard(cfg: GlobalConfig) -> None:
185
+ """Run hardware detection and update cfg.llm.model in place."""
186
+ from notebook_ta.setup_wizard.detector import detect_hardware, select_model
187
+
188
+ profile = detect_hardware()
189
+ model_spec = select_model(cfg.llm.available_models, profile)
190
+
191
+ try:
192
+ from IPython import display as ipydisplay
193
+
194
+ if model_spec is not None:
195
+ cfg.llm.model = model_spec.name
196
+ gpu_text = (
197
+ f", GPU: {profile.gpu_name} ({profile.vram_gb:.1f} GB VRAM)"
198
+ if profile.gpu_name
199
+ else ""
200
+ )
201
+ cast(Any, ipydisplay.display)(
202
+ cast(Any, ipydisplay.Markdown)(
203
+ "🔍 **Hardware detected** — auto-selecting LLM model:\n\n"
204
+ f"- RAM: {profile.ram_gb:.1f} GB"
205
+ + gpu_text
206
+ + f"\n- **Selected model:** `{model_spec.name}` — {model_spec.description}"
207
+ )
208
+ )
209
+ else:
210
+ gpu_text = (
211
+ f", GPU: {profile.gpu_name} ({profile.vram_gb:.1f} GB VRAM)"
212
+ if profile.gpu_name
213
+ else ""
214
+ )
215
+ cast(Any, ipydisplay.display)(
216
+ cast(Any, ipydisplay.Markdown)(
217
+ "⚠️ **Hardware auto-detection:** No suitable model found for your hardware.\n\n"
218
+ f"- RAM: {profile.ram_gb:.1f} GB"
219
+ + gpu_text
220
+ + "\n\nThe LLM provider will be marked as unavailable. "
221
+ "Please configure a model manually in your `global_config.toml`."
222
+ )
223
+ )
224
+ except Exception:
225
+ if model_spec is not None:
226
+ cfg.llm.model = model_spec.name
@@ -0,0 +1 @@
1
+ """Prompt/model benchmarking tool for notebook-ta instructors."""
@@ -0,0 +1,37 @@
1
+ """NiceGUI application bootstrap for the prompt benchmarking tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from nicegui import ui
8
+
9
+ from notebook_ta.bench.state import BenchAppState
10
+ from notebook_ta.bench.storage import ProjectStore, get_last_project_path
11
+ from notebook_ta.bench.ui import layout
12
+ from notebook_ta.logging import get_logger
13
+
14
+ _log = get_logger("bench.app")
15
+
16
+
17
+ def main(project_path: str | None = None) -> None:
18
+ """Launch the benchmarking GUI and show its project welcome dialog.
19
+
20
+ ``project_path`` is offered as the recent project. Otherwise the last project
21
+ opened across previous runs is offered.
22
+ """
23
+ candidate = Path(project_path) if project_path else get_last_project_path()
24
+ recent_path = candidate if candidate is not None and candidate.exists() else None
25
+ state = BenchAppState(
26
+ ProjectStore(None), project_open=False, recent_project_path=recent_path
27
+ )
28
+
29
+ @ui.page("/")
30
+ def index() -> None:
31
+ layout.build(state)
32
+
33
+ ui.run(title="Notebook-TA Benchmarking", reload=False, show=True, port=0)
34
+
35
+
36
+ if __name__ in {"__main__", "__mp_main__"}:
37
+ main()
@@ -0,0 +1,73 @@
1
+ """Style-preserving mutations for a local exercise TOML catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import tomlkit
11
+ from tomlkit.items import Table
12
+
13
+ from notebook_ta.config.models import ConfigurationError, ExerciseConfig
14
+
15
+
16
+ def set_exercise_name(path: str | Path, exercise_id: str, name: str) -> None:
17
+ """Update an exercise display name while preserving unrelated TOML formatting."""
18
+ catalog_path, document, exercises = _load_editable_catalog(path)
19
+ exercise = exercises.get(exercise_id)
20
+ if not isinstance(exercise, Table):
21
+ raise ConfigurationError(f"Exercise {exercise_id!r} does not exist in {catalog_path}.")
22
+ exercise["name"] = name
23
+ _write_document(catalog_path, document.as_string())
24
+
25
+
26
+ def add_exercise(path: str | Path, exercise: ExerciseConfig) -> None:
27
+ """Append a new exercise to a local TOML catalog without disturbing existing entries."""
28
+ catalog_path, document, exercises = _load_editable_catalog(path)
29
+ if exercise.id in exercises:
30
+ raise ConfigurationError(f"Exercise {exercise.id!r} already exists in {catalog_path}.")
31
+
32
+ item = tomlkit.table()
33
+ if exercise.name:
34
+ item.add("name", exercise.name)
35
+ if exercise.statement:
36
+ item.add("statement", exercise.statement)
37
+ if exercise.additional_info:
38
+ item.add("additional_info", exercise.additional_info)
39
+ exercises.add(exercise.id, item)
40
+ _write_document(catalog_path, document.as_string())
41
+
42
+
43
+ def _load_editable_catalog(path: str | Path) -> tuple[Path, tomlkit.TOMLDocument, Table]:
44
+ """Load a local catalog and return its mutable exercises table."""
45
+ source = str(path)
46
+ if source.startswith(("http://", "https://")):
47
+ raise ConfigurationError("Remote exercise catalogs are read-only.")
48
+ catalog_path = Path(path)
49
+ if not catalog_path.exists():
50
+ raise ConfigurationError(f"Exercise catalog not found: {catalog_path}")
51
+ try:
52
+ document = tomlkit.parse(catalog_path.read_text(encoding="utf-8"))
53
+ except (OSError, tomlkit.exceptions.ParseError) as exc:
54
+ raise ConfigurationError(f"Could not edit exercise catalog {catalog_path}: {exc}") from exc
55
+
56
+ exercises = document.get("exercises")
57
+ if not isinstance(exercises, Table):
58
+ exercises = tomlkit.table()
59
+ document["exercises"] = exercises
60
+ return catalog_path, document, exercises
61
+
62
+
63
+ def _write_document(path: Path, content: str) -> None:
64
+ """Atomically replace a TOML catalog with updated content."""
65
+ fd, temporary_name = tempfile.mkstemp(dir=str(path.parent), prefix=".exercises-", suffix=".tmp")
66
+ try:
67
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as temporary_file:
68
+ temporary_file.write(content)
69
+ os.replace(temporary_name, path)
70
+ except BaseException:
71
+ with contextlib.suppress(OSError):
72
+ os.remove(temporary_name)
73
+ raise
@@ -0,0 +1,26 @@
1
+ """CLI command for launching the prompt benchmarking GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+
8
+ @click.group()
9
+ def cli() -> None:
10
+ """notebook-ta command line interface."""
11
+
12
+
13
+ @click.command("bench")
14
+ @click.argument("project_file", required=False, type=click.Path(dir_okay=False))
15
+ def bench(project_file: str | None) -> None:
16
+ """Launch the benchmarking GUI, optionally offering PROJECT_FILE on its welcome screen."""
17
+ try:
18
+ from notebook_ta.bench.app import main
19
+ except ImportError as exc:
20
+ raise click.ClickException(
21
+ "The benchmarking UI requires the 'bench' extra: pip install 'notebook-ta[bench]'"
22
+ ) from exc
23
+ main(project_file)
24
+
25
+
26
+ cli.add_command(bench)
@@ -0,0 +1,307 @@
1
+ """Sequential benchmark execution engine with per-job metrics capture.
2
+
3
+ Per the architecture decision, LLM calls are processed strictly one at a time
4
+ across the whole exercise x solution x model matrix -- this keeps hardware load
5
+ predictable and keeps TTFT/throughput measurements free of interference from
6
+ concurrent requests.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import io
14
+ import sys
15
+ import time
16
+ from collections.abc import Callable, Iterator
17
+ from contextlib import contextmanager
18
+ from dataclasses import dataclass
19
+
20
+ from notebook_ta.bench.hashing import build_input_snapshot
21
+ from notebook_ta.bench.models import (
22
+ BenchmarkRun,
23
+ ExecutionMetrics,
24
+ ExecutionRecord,
25
+ ModelUnderTest,
26
+ PromptVersion,
27
+ StudentSolution,
28
+ TestResultModel,
29
+ TokenUsage,
30
+ )
31
+ from notebook_ta.config.models import ExerciseConfig, GlobalConfig, PromptConfig
32
+ from notebook_ta.exercise.definition import Exercise
33
+ from notebook_ta.llm.base import LLMProvider, create_provider
34
+ from notebook_ta.logging import get_logger
35
+ from notebook_ta.testing.runner import TestResult, TestRunner
36
+
37
+ _log = get_logger("bench.executor")
38
+
39
+
40
+ @dataclass
41
+ class BenchJob:
42
+ """One (exercise, solution, model, prompt version) unit of work for a benchmark run."""
43
+
44
+ exercise_config: ExerciseConfig
45
+ solution: StudentSolution
46
+ model: ModelUnderTest
47
+ prompt_version: PromptVersion
48
+ setup_code: str = ""
49
+
50
+
51
+ # Called as on_progress(job, status, message, record). `record` is populated only
52
+ # when the job has just finished (status in {"completed", "failed"}).
53
+ ProgressCallback = Callable[[BenchJob, str, "str | None", "ExecutionRecord | None"], None]
54
+
55
+
56
+ @contextmanager
57
+ def extended_sys_path(dirs: list[str]) -> Iterator[None]:
58
+ """Temporarily prepend `dirs` to `sys.path` for external test module imports."""
59
+ added = [d for d in dirs if d not in sys.path]
60
+ for d in added:
61
+ sys.path.insert(0, d)
62
+ try:
63
+ yield
64
+ finally:
65
+ for d in added:
66
+ if d in sys.path:
67
+ sys.path.remove(d)
68
+
69
+
70
+ def _build_global_config(prompt_version: PromptVersion, model: ModelUnderTest) -> GlobalConfig:
71
+ """Build a synthetic GlobalConfig so `Exercise.build_prompt()` can be reused unchanged."""
72
+ return GlobalConfig(
73
+ llm=model.llm_config,
74
+ prompts=PromptConfig(
75
+ on_success=prompt_version.on_success,
76
+ on_failure=prompt_version.on_failure,
77
+ on_no_llm="",
78
+ ),
79
+ )
80
+
81
+
82
+ def build_jobs(
83
+ exercises: list[ExerciseConfig],
84
+ solutions_by_exercise: dict[str, list[StudentSolution]],
85
+ models: list[ModelUnderTest],
86
+ prompt_version: PromptVersion,
87
+ setup_code_by_exercise: dict[str, str] | None = None,
88
+ ) -> list[BenchJob]:
89
+ """Build the full model x exercise x solution job matrix for a benchmark run.
90
+
91
+ Jobs are grouped by model first (outer loop) so that `BenchExecutor.run()` -- which
92
+ processes the list strictly in order -- finishes every job for one model before moving
93
+ to the next. This avoids repeatedly loading/unloading models on the LLM backend (e.g.
94
+ Ollama swapping models in and out of GPU/RAM) that would happen with an exercise-major
95
+ ordering.
96
+ """
97
+ setup_code_by_exercise = setup_code_by_exercise or {}
98
+ jobs: list[BenchJob] = []
99
+ for model in models:
100
+ for exercise_config in exercises:
101
+ for solution in solutions_by_exercise.get(exercise_config.id, []):
102
+ jobs.append(
103
+ BenchJob(
104
+ exercise_config,
105
+ solution,
106
+ model,
107
+ prompt_version,
108
+ setup_code_by_exercise.get(exercise_config.id, ""),
109
+ )
110
+ )
111
+ return jobs
112
+
113
+
114
+ def run_setup_code(
115
+ setup_code: str, namespace: dict[str, object], test_names: list[str]
116
+ ) -> list[TestResult] | None:
117
+ """Run benchmark setup code and return failed test results if setup fails."""
118
+ if not setup_code:
119
+ return None
120
+ stdout_buf = io.StringIO()
121
+ try:
122
+ with contextlib.redirect_stdout(stdout_buf):
123
+ exec(setup_code, namespace) # noqa: S102 -- isolated authoring-time sandbox
124
+ except Exception as exc:
125
+ captured = stdout_buf.getvalue()
126
+ message = str(exc)
127
+ if captured:
128
+ message = f"{message}\nOutput: {captured}"
129
+ return [
130
+ TestResult(name=name, passed=False, message=f"Setup code failed: {message}")
131
+ for name in test_names
132
+ ]
133
+ return None
134
+
135
+
136
+ class BenchExecutor:
137
+ """Runs a benchmark job matrix sequentially, one LLM call at a time."""
138
+
139
+ def __init__(
140
+ self,
141
+ python_path_dirs: Callable[[], list[str]] | None = None,
142
+ unit_test_timeout: Callable[[], float] | None = None,
143
+ ) -> None:
144
+ """`python_path_dirs`, if given, is called fresh before every job so that changes
145
+ made in the Settings tab while a run is in progress take effect immediately."""
146
+ self._get_python_path_dirs = python_path_dirs or (lambda: [])
147
+ self._get_unit_test_timeout = unit_test_timeout or (lambda: 5.0)
148
+ self._provider_cache: dict[str, LLMProvider] = {}
149
+ self._cancel_requested = False
150
+ self._retry_event: asyncio.Event | None = None
151
+
152
+ def cancel(self) -> None:
153
+ """Request cancellation; takes effect before the next job starts (or on retry-wait)."""
154
+ self._cancel_requested = True
155
+ if self._retry_event is not None:
156
+ self._retry_event.set()
157
+
158
+ def retry(self) -> None:
159
+ """Resume a paused run (e.g. after the user restarts a disconnected LLM server)."""
160
+ if self._retry_event is not None:
161
+ self._retry_event.set()
162
+
163
+ def _get_provider(self, model: ModelUnderTest) -> LLMProvider:
164
+ """Return a cached LLMProvider instance for `model`, creating one if needed."""
165
+ if model.label not in self._provider_cache:
166
+ self._provider_cache[model.label] = create_provider(model.llm_config)
167
+ return self._provider_cache[model.label]
168
+
169
+ async def run(
170
+ self, jobs: list[BenchJob], run: BenchmarkRun, on_progress: ProgressCallback
171
+ ) -> None:
172
+ """Execute all jobs sequentially, invoking `on_progress` as progress is made."""
173
+ self._cancel_requested = False
174
+ for job in jobs:
175
+ if self._cancel_requested:
176
+ run.status = "cancelled"
177
+ return
178
+
179
+ provider = self._get_provider(job.model)
180
+ while not provider.is_available():
181
+ run.status = "paused"
182
+ on_progress(job, "paused", f"{job.model.label} is unreachable", None)
183
+ self._retry_event = asyncio.Event()
184
+ await self._retry_event.wait()
185
+ if self._cancel_requested:
186
+ run.status = "cancelled"
187
+ return
188
+ run.status = "running"
189
+
190
+ await self._execute_job(job, run, on_progress)
191
+
192
+ if run.status == "running":
193
+ run.status = "completed"
194
+
195
+ async def run_single(self, job: BenchJob, run: BenchmarkRun) -> ExecutionRecord:
196
+ """Execute a single job immediately, bypassing the queue (used for a Compare tab Re-run)."""
197
+ record_box: list[ExecutionRecord] = []
198
+
199
+ def _capture(
200
+ _job: BenchJob,
201
+ _status: str,
202
+ _message: str | None,
203
+ record: ExecutionRecord | None,
204
+ ) -> None:
205
+ if record is not None:
206
+ record_box.append(record)
207
+
208
+ await self._execute_job(job, run, _capture)
209
+ return record_box[0]
210
+
211
+ async def _execute_job(
212
+ self, job: BenchJob, run: BenchmarkRun, on_progress: ProgressCallback
213
+ ) -> None:
214
+ """Run unit tests + the LLM call for one job and report the resulting record."""
215
+ on_progress(job, "generating", None, None)
216
+ snapshot = build_input_snapshot(job.exercise_config, job.solution, job.setup_code)
217
+ global_config = _build_global_config(job.prompt_version, job.model)
218
+ global_config.unit_test_timeout = self._get_unit_test_timeout()
219
+ exercise = Exercise(job.exercise_config, global_config)
220
+
221
+ try:
222
+ namespace: dict[str, object] = {}
223
+ with extended_sys_path(self._get_python_path_dirs()):
224
+ exec(job.solution.code, namespace) # noqa: S102 -- isolated authoring-time sandbox
225
+ test_names = [test_def.name for test_def in exercise.tests]
226
+ test_results = run_setup_code(job.setup_code, namespace, test_names)
227
+ if test_results is None:
228
+ test_results = TestRunner().run(exercise, namespace)
229
+ prompt = exercise.build_prompt(job.solution.code, test_results, hint_history=None)
230
+
231
+ provider = self._get_provider(job.model)
232
+ output, metrics = await self._run_llm(provider, prompt)
233
+
234
+ record = ExecutionRecord(
235
+ run_id=run.id,
236
+ exercise_id=job.exercise_config.id,
237
+ solution_id=job.solution.id,
238
+ model_label=job.model.label,
239
+ prompt_version_id=job.prompt_version.id,
240
+ input_snapshot=snapshot,
241
+ full_prompt=prompt,
242
+ test_results=[
243
+ TestResultModel(name=r.name, passed=r.passed, message=r.message)
244
+ for r in test_results
245
+ ],
246
+ llm_output=output,
247
+ metrics=metrics,
248
+ status="completed",
249
+ )
250
+ on_progress(job, "completed", None, record)
251
+ except Exception as exc:
252
+ _log.warning(
253
+ "Job failed for exercise=%r solution=%r model=%r: %s",
254
+ job.exercise_config.id,
255
+ job.solution.id,
256
+ job.model.label,
257
+ exc,
258
+ )
259
+ record = ExecutionRecord(
260
+ run_id=run.id,
261
+ exercise_id=job.exercise_config.id,
262
+ solution_id=job.solution.id,
263
+ model_label=job.model.label,
264
+ prompt_version_id=job.prompt_version.id,
265
+ input_snapshot=snapshot,
266
+ status="failed",
267
+ error=str(exc),
268
+ )
269
+ on_progress(job, "failed", str(exc), record)
270
+
271
+ @staticmethod
272
+ async def _run_llm(provider: LLMProvider, prompt: str) -> tuple[str, ExecutionMetrics]:
273
+ """Stream `prompt` through `provider`, timing TTFT/total time and capturing token usage."""
274
+ start = time.monotonic()
275
+ first_token_time: float | None = None
276
+ chunks: list[str] = []
277
+ async for chunk in provider.stream(prompt):
278
+ if first_token_time is None:
279
+ first_token_time = time.monotonic()
280
+ chunks.append(chunk)
281
+ end = time.monotonic()
282
+ output = "".join(chunks)
283
+
284
+ usage = provider.get_last_usage()
285
+ if usage and usage.completion_tokens:
286
+ completion_tokens = usage.completion_tokens
287
+ prompt_tokens = usage.prompt_tokens
288
+ approximate = False
289
+ else:
290
+ completion_tokens = len(output.split())
291
+ prompt_tokens = usage.prompt_tokens if usage else None
292
+ approximate = True
293
+
294
+ total_time = end - start
295
+ ttft = (first_token_time - start) if first_token_time is not None else None
296
+ throughput = (completion_tokens / total_time) if total_time > 0 else None
297
+
298
+ return output, ExecutionMetrics(
299
+ time_to_first_token_s=ttft,
300
+ total_generation_time_s=total_time,
301
+ throughput_tokens_per_s=throughput,
302
+ token_usage=TokenUsage(
303
+ prompt_tokens=prompt_tokens,
304
+ completion_tokens=completion_tokens,
305
+ approximate=approximate,
306
+ ),
307
+ )