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.
- notebook_ta/__init__.py +226 -0
- notebook_ta/bench/__init__.py +1 -0
- notebook_ta/bench/app.py +37 -0
- notebook_ta/bench/catalog.py +73 -0
- notebook_ta/bench/cli.py +26 -0
- notebook_ta/bench/executor.py +307 -0
- notebook_ta/bench/hashing.py +76 -0
- notebook_ta/bench/internal_model.py +63 -0
- notebook_ta/bench/models.py +214 -0
- notebook_ta/bench/state.py +345 -0
- notebook_ta/bench/storage.py +120 -0
- notebook_ta/bench/ui/__init__.py +1 -0
- notebook_ta/bench/ui/_helpers.py +20 -0
- notebook_ta/bench/ui/compare_tab.py +461 -0
- notebook_ta/bench/ui/exercises_tab.py +429 -0
- notebook_ta/bench/ui/layout.py +89 -0
- notebook_ta/bench/ui/native_dialogs.py +65 -0
- notebook_ta/bench/ui/runner_tab.py +264 -0
- notebook_ta/bench/ui/settings_tab.py +224 -0
- notebook_ta/bench/ui/tag_badges.py +21 -0
- notebook_ta/bench/ui/welcome_dialog.py +91 -0
- notebook_ta/config/__init__.py +1 -0
- notebook_ta/config/loader.py +109 -0
- notebook_ta/config/models.py +92 -0
- notebook_ta/exercise/__init__.py +1 -0
- notebook_ta/exercise/definition.py +134 -0
- notebook_ta/exercise/registry.py +49 -0
- notebook_ta/llm/__init__.py +1 -0
- notebook_ta/llm/base.py +84 -0
- notebook_ta/llm/ollama.py +135 -0
- notebook_ta/llm/openai_compat.py +115 -0
- notebook_ta/logging.py +114 -0
- notebook_ta/notebook/__init__.py +1 -0
- notebook_ta/notebook/_ansi.py +79 -0
- notebook_ta/notebook/display.py +189 -0
- notebook_ta/notebook/extractor.py +165 -0
- notebook_ta/notebook/magic.py +184 -0
- notebook_ta/notebook/session.py +55 -0
- notebook_ta/notebook/streaming.py +42 -0
- notebook_ta/setup_wizard/__init__.py +1 -0
- notebook_ta/setup_wizard/detector.py +112 -0
- notebook_ta/testing/__init__.py +1 -0
- notebook_ta/testing/runner.py +216 -0
- notebook_ta-0.1.0.dist-info/METADATA +350 -0
- notebook_ta-0.1.0.dist-info/RECORD +48 -0
- notebook_ta-0.1.0.dist-info/WHEEL +4 -0
- notebook_ta-0.1.0.dist-info/entry_points.txt +2 -0
- notebook_ta-0.1.0.dist-info/licenses/LICENSE +21 -0
notebook_ta/__init__.py
ADDED
|
@@ -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."""
|
notebook_ta/bench/app.py
ADDED
|
@@ -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
|
notebook_ta/bench/cli.py
ADDED
|
@@ -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
|
+
)
|