feat: add Settings and Suite pages with comprehensive settings management and question suite builder

- Implemented SettingsPage for configuring default provider, temperature, and model paths.
- Added SuitePage for managing a suite of questions with features to add, edit, duplicate, and delete questions.
- Introduced TypeScript configuration files for app and node environments.
- Set up Vite configuration for development server with API proxying to backend.
This commit is contained in:
Christopher Clendening
2026-07-27 17:41:08 -04:00
parent ce0e022f11
commit adf61ae1a0
50 changed files with 10952 additions and 164 deletions
+9
View File
@@ -0,0 +1,9 @@
"""LM-Gambit web backend.
Wraps the existing ``.core`` diagnostic engine in a FastAPI application so the
React frontend can drive it. The engine itself is untouched: providers, the
runner and the markdown reporting pipeline behave exactly as they do for the
``auto-test.py`` CLI.
"""
__version__ = "2.0.0"
+464
View File
@@ -0,0 +1,464 @@
"""HTTP surface for the LM-Gambit frontend."""
from __future__ import annotations
import asyncio
import platform
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import StreamingResponse
from . import __version__
from .core_bridge import (
DEFAULT_PROVIDER_NAME,
DEFAULT_TEMPERATURE,
MODELS_DIR,
RESULTS_DIR,
TEMPLATE_PATH,
TESTS_DIR,
EngineLoadError,
ProviderError,
build_provider,
detect_architecture,
list_provider_names,
load_engine_class,
load_settings,
save_settings,
)
from .plugins import get_plugin_manager
from .run_manager import run_manager
from .schemas import (
PluginSummary,
ModelListResponse,
ModelPathEntry,
ModelSummary,
PlaygroundRequest,
PlaygroundResponse,
ProviderSummary,
ReportDetail,
ReportSummary,
RunRequest,
RunResponse,
SaveSuiteRequest,
SettingsResponse,
SettingsUpdate,
SystemInfo,
TestPrompt,
TestSuiteResponse,
)
from .suite import SuiteError, list_tests, find_tests, save_suite
router = APIRouter(prefix="/api")
_REPORT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+\.md$")
_REPORT_TITLE_RE = re.compile(r"^#\s*Automated Diagnostic Report:\s*(.+)$", re.MULTILINE)
# --------------------------------------------------------------------- system
@router.get("/system", response_model=SystemInfo)
async def get_system() -> SystemInfo:
descriptor = detect_architecture()
runtime_name = "unavailable"
try:
runtime_cls = load_engine_class(descriptor)
runtime_name = getattr(runtime_cls, "name", runtime_cls.__name__)
except EngineLoadError as exc:
runtime_name = f"unavailable ({exc})"
return SystemInfo(
version=__version__,
engine_architecture=descriptor.architecture,
engine_runtime=runtime_name,
template_ok=TEMPLATE_PATH.exists(),
python_version=platform.python_version(),
metrics={
"platform": f"{platform.system()} {platform.machine()}",
"models_dir": str(MODELS_DIR),
},
)
# -------------------------------------------------------------------- plugins
@router.get("/plugins", response_model=List[PluginSummary])
async def get_plugins() -> List[PluginSummary]:
"""Everything discovered in plugins/, including files that failed to load."""
return [PluginSummary(**vars(info)) for info in get_plugin_manager().info()]
@router.post("/plugins/reload", response_model=List[PluginSummary])
async def reload_plugins_endpoint() -> List[PluginSummary]:
"""Re-scan plugins/. Newly added HTTP routes still need a server restart."""
if run_manager.active() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="A run is in progress. Reload plugins once it finishes.",
)
manager = get_plugin_manager()
manager.load()
return [PluginSummary(**vars(info)) for info in manager.info()]
# ------------------------------------------------------------------ providers
@router.get("/providers", response_model=List[ProviderSummary])
async def get_providers() -> List[ProviderSummary]:
settings = load_settings()
preferred = str(settings.get("default_provider") or DEFAULT_PROVIDER_NAME)
return [
ProviderSummary(name=name, is_default=name == preferred)
for name in list_provider_names()
]
@router.get("/providers/{provider_name}/models", response_model=ModelListResponse)
async def get_models(provider_name: str) -> ModelListResponse:
def _load() -> List[ModelSummary]:
provider = build_provider(provider_name)
return [
ModelSummary(id=model.id, display_name=model.display_name)
for model in provider.list_models()
]
try:
models = await asyncio.to_thread(_load)
except ProviderError as exc:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 - engine/hardware faults reach the UI
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"{type(exc).__name__}: {exc}",
) from exc
return ModelListResponse(provider=provider_name, models=models)
# ---------------------------------------------------------------- test suite
@router.get("/tests", response_model=TestSuiteResponse)
async def get_tests() -> TestSuiteResponse:
tests = [TestPrompt(**prompt) for prompt in list_tests()]
return TestSuiteResponse(tests=tests, directory=str(TESTS_DIR))
@router.put("/tests", response_model=TestSuiteResponse)
async def put_tests(payload: SaveSuiteRequest) -> TestSuiteResponse:
if run_manager.active() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="A run is in progress. Wait for it to finish before editing the suite.",
)
try:
tests = save_suite([draft.prompt for draft in payload.tests])
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return TestSuiteResponse(
tests=[TestPrompt(**prompt) for prompt in tests],
directory=str(TESTS_DIR),
)
# ----------------------------------------------------------------------- runs
@router.post("/runs", response_model=RunResponse, status_code=status.HTTP_201_CREATED)
async def create_run(payload: RunRequest) -> RunResponse:
if not TEMPLATE_PATH.exists():
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Report template missing at .core/templates/test-block.md.",
)
try:
prompts = find_tests(payload.filenames) if payload.filenames else list_tests()
except SuiteError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if not prompts:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="No questions in the suite. Add at least one in the Suite Builder.",
)
def _resolve_label() -> str:
provider = build_provider(payload.provider)
for model in provider.list_models():
if model.id == payload.model_id:
return model.display_name
raise ProviderError(f"Model '{payload.model_id}' not found for {payload.provider}.")
try:
model_label = await asyncio.to_thread(_resolve_label)
except ProviderError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
try:
run = run_manager.start(
provider_name=payload.provider,
model_id=payload.model_id,
model_label=model_label,
temperature=payload.temperature,
prompts=prompts,
loop=asyncio.get_running_loop(),
)
except RuntimeError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return RunResponse(**run.as_dict())
@router.get("/runs", response_model=List[RunResponse])
async def get_runs() -> List[RunResponse]:
return [RunResponse(**run.as_dict()) for run in run_manager.history()]
@router.get("/runs/active", response_model=Optional[RunResponse])
async def get_active_run() -> Optional[RunResponse]:
run = run_manager.active()
return RunResponse(**run.as_dict()) if run else None
@router.get("/runs/{run_id}", response_model=RunResponse)
async def get_run(run_id: str) -> RunResponse:
run = run_manager.get(run_id)
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.")
return RunResponse(**run.as_dict())
@router.get("/runs/{run_id}/events")
async def stream_run(run_id: str) -> StreamingResponse:
if run_manager.get(run_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.")
return StreamingResponse(
run_manager.stream(run_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/runs/{run_id}/cancel", response_model=RunResponse)
async def cancel_run(run_id: str) -> RunResponse:
run = run_manager.get(run_id)
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Unknown run id.")
if not run_manager.cancel(run_id):
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=f"Run is already {run.status}.",
)
return RunResponse(**run.as_dict())
# -------------------------------------------------------------------- reports
def _report_path(name: str) -> Path:
if not _REPORT_NAME_RE.match(name):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid report name.")
path = (RESULTS_DIR / name).resolve()
if path.parent != RESULTS_DIR.resolve() or not path.is_file():
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Report not found.")
return path
def _model_label_from(content: str, fallback: str) -> str:
match = _REPORT_TITLE_RE.search(content)
return match.group(1).strip() if match else fallback
@router.get("/reports", response_model=List[ReportSummary])
async def get_reports() -> List[ReportSummary]:
if not RESULTS_DIR.exists():
return []
summaries: List[ReportSummary] = []
for path in RESULTS_DIR.glob("*.md"):
try:
stat = path.stat()
head = path.read_text(encoding="utf-8", errors="replace")[:400]
except OSError:
continue
summaries.append(
ReportSummary(
name=path.name,
model_label=_model_label_from(head, path.stem),
size_bytes=stat.st_size,
modified_at=stat.st_mtime,
)
)
summaries.sort(key=lambda item: item.modified_at, reverse=True)
return summaries
@router.get("/reports/{name}", response_model=ReportDetail)
async def get_report(name: str) -> ReportDetail:
path = _report_path(name)
try:
content = path.read_text(encoding="utf-8", errors="replace")
stat = path.stat()
except OSError as exc:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
return ReportDetail(
name=path.name,
model_label=_model_label_from(content, path.stem),
size_bytes=stat.st_size,
modified_at=stat.st_mtime,
content=content,
)
@router.delete("/reports/{name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(name: str) -> None:
path = _report_path(name)
try:
path.unlink()
except OSError as exc:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
# ----------------------------------------------------------------- playground
@router.post("/playground", response_model=PlaygroundResponse)
async def run_playground(payload: PlaygroundRequest) -> PlaygroundResponse:
if run_manager.active() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="A diagnostic run is in progress. The engine can only serve one at a time.",
)
def _execute() -> Dict[str, Any]:
provider = build_provider(payload.provider)
return provider.run_prompt(
payload.model_id,
payload.prompt,
temperature=payload.temperature,
)
started = time.time()
try:
result = await asyncio.to_thread(_execute)
except ProviderError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 - engine faults are user-facing here
return PlaygroundResponse(
error=f"{type(exc).__name__}: {exc}",
elapsed=round(time.time() - started, 2),
)
elapsed = round(time.time() - started, 2)
if "error" in result:
return PlaygroundResponse(error=str(result["error"]), elapsed=elapsed)
return PlaygroundResponse(
response=str(result.get("response", "")),
metrics=result.get("metrics") or None,
elapsed=elapsed,
)
# ------------------------------------------------------------------- settings
_NICKNAME_KEY = "local_model_path_nicknames"
def _normalized_paths(settings: Dict[str, Any]) -> List[str]:
"""Read model paths tolerating the legacy list-of-dicts format."""
raw = settings.get("local_model_paths", [])
if not isinstance(raw, list):
return []
paths: List[str] = []
for entry in raw:
if isinstance(entry, str) and entry.strip():
candidate = str(Path(entry).expanduser())
elif isinstance(entry, dict) and str(entry.get("path", "")).strip():
candidate = str(Path(str(entry["path"])).expanduser())
else:
continue
if candidate not in paths:
paths.append(candidate)
return paths
def _nicknames(settings: Dict[str, Any]) -> Dict[str, str]:
"""Nicknames live in their own key so the engine's path list stays strings."""
nicknames: Dict[str, str] = {}
stored = settings.get(_NICKNAME_KEY)
if isinstance(stored, dict):
for key, value in stored.items():
if isinstance(key, str) and isinstance(value, str):
nicknames[str(Path(key).expanduser())] = value
# Recover nicknames from the legacy list-of-dicts shape.
raw = settings.get("local_model_paths", [])
if isinstance(raw, list):
for entry in raw:
if isinstance(entry, dict) and entry.get("path"):
key = str(Path(str(entry["path"])).expanduser())
nicknames.setdefault(key, str(entry.get("nickname", "")))
return nicknames
@router.get("/settings", response_model=SettingsResponse)
async def get_settings() -> SettingsResponse:
settings = load_settings()
nicknames = _nicknames(settings)
return SettingsResponse(
default_provider=str(settings.get("default_provider") or DEFAULT_PROVIDER_NAME),
default_temperature=float(settings.get("default_temperature") or DEFAULT_TEMPERATURE),
local_model_paths=[
ModelPathEntry(nickname=nicknames.get(path, ""), path=path)
for path in _normalized_paths(settings)
],
tests_dir=str(TESTS_DIR),
results_dir=str(RESULTS_DIR),
models_dir=str(MODELS_DIR),
)
@router.put("/settings", response_model=SettingsResponse)
async def put_settings(payload: SettingsUpdate) -> SettingsResponse:
settings = load_settings()
if payload.default_provider is not None:
if payload.default_provider not in list_provider_names():
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Unknown provider '{payload.default_provider}'.",
)
settings["default_provider"] = payload.default_provider
if payload.default_temperature is not None:
settings["default_temperature"] = payload.default_temperature
if payload.local_model_paths is not None:
ordered: List[str] = []
nicknames: Dict[str, str] = {}
for entry in payload.local_model_paths:
candidate = str(Path(entry.path).expanduser())
if not candidate.strip() or candidate in ordered:
continue
ordered.append(candidate)
if entry.nickname.strip():
nicknames[candidate] = entry.nickname.strip()
settings["local_model_paths"] = ordered
settings[_NICKNAME_KEY] = nicknames
save_settings(settings)
return await get_settings()
+103
View File
@@ -0,0 +1,103 @@
"""Import shim for the ``.core`` engine package.
``.core`` is a hidden directory, so it cannot be imported as a normal package.
The CLI (``auto-test.py``) works around this by putting the directory on
``sys.path`` and importing its modules flat. We do the same thing here, in one
place, so the rest of the server can just ``from .core_bridge import run_suite``.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent.parent
CORE_DIR = ROOT_DIR / ".core"
if str(CORE_DIR) not in sys.path:
sys.path.insert(0, str(CORE_DIR))
# ruff: noqa: E402 (imports must follow the sys.path mutation above)
from config import ( # type: ignore[import-not-found]
DEFAULT_PROVIDER_NAME,
DEFAULT_TEMPERATURE,
MODELS_DIR,
RESULTS_DIR,
TEMPLATE_PATH,
TESTS_DIR,
ensure_directories,
)
from engine_loader import ( # type: ignore[import-not-found]
EngineLoadError,
detect_architecture,
load_engine_class,
)
from prompts import load_test_prompts # type: ignore[import-not-found]
from providers import ( # type: ignore[import-not-found]
LocalEngineProvider,
ModelInfo,
Provider,
ProviderError,
get_provider,
list_provider_names,
)
from reporting import ( # type: ignore[import-not-found]
TemplateNotFoundError,
append_sections,
finalize_report_summary,
replace_analysis_section,
sanitize_model_name,
)
from runner import TestRunError, run_suite # type: ignore[import-not-found]
from settings import ( # type: ignore[import-not-found]
get_local_model_paths,
load_settings,
save_settings,
set_local_model_paths,
)
__all__ = [
"CORE_DIR",
"DEFAULT_PROVIDER_NAME",
"EngineLoadError",
"detect_architecture",
"load_engine_class",
"DEFAULT_TEMPERATURE",
"MODELS_DIR",
"ModelInfo",
"Provider",
"ProviderError",
"RESULTS_DIR",
"ROOT_DIR",
"TEMPLATE_PATH",
"TESTS_DIR",
"TemplateNotFoundError",
"TestRunError",
"LocalEngineProvider",
"append_sections",
"ensure_directories",
"finalize_report_summary",
"replace_analysis_section",
"get_local_model_paths",
"get_provider",
"list_provider_names",
"load_settings",
"load_test_prompts",
"run_suite",
"sanitize_model_name",
"save_settings",
"set_local_model_paths",
]
def provider_kwargs(provider_name: str) -> dict:
"""Build constructor kwargs for a provider, mirroring the runner's logic."""
if provider_name == LocalEngineProvider.name:
custom_paths = [Path(p).expanduser() for p in get_local_model_paths()]
return {"search_paths": [path for path in custom_paths if path]}
return {}
def build_provider(provider_name: str) -> Provider:
"""Instantiate a provider with the correct per-provider configuration."""
return get_provider(provider_name, **provider_kwargs(provider_name))
+118
View File
@@ -0,0 +1,118 @@
"""FastAPI application factory.
In production the same process serves the JSON API and the compiled React
bundle from ``web/dist``. During frontend development Vite serves the UI on its
own port and proxies ``/api`` back here, so CORS is opened for localhost only.
"""
from __future__ import annotations
from pathlib import Path
import sys
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from . import __version__
from .api import router
from .core_bridge import ROOT_DIR, ensure_directories
from .plugins import get_plugin_manager
WEB_DIST = ROOT_DIR / "web" / "dist"
DEV_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
def create_app() -> FastAPI:
ensure_directories()
app = FastAPI(
title="LM-Gambit",
description="Automated LLM diagnostic suite.",
version=__version__,
docs_url="/api/docs",
openapi_url="/api/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=DEV_ORIGINS,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(router)
_mount_plugin_routes(app)
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok", "version": __version__, "ui_built": WEB_DIST.is_dir()}
_mount_frontend(app)
return app
def _mount_plugin_routes(app: FastAPI) -> None:
"""Give each plugin defining register_routes its own /api/plugins/<slug>.
Routes are bound at startup, so a plugin that adds or changes endpoints
needs a server restart — unlike its other hooks, which reload live.
"""
manager = get_plugin_manager()
for plugin in manager.with_hook("register_routes"):
plugin_router = APIRouter(
prefix=f"/api/plugins/{plugin.slug}",
tags=[f"plugin:{plugin.slug}"],
)
try:
plugin.module.register_routes(plugin_router) # type: ignore[union-attr]
except Exception as exc: # noqa: BLE001 - never let a plugin block startup
print(
f"[plugin:{plugin.slug}] register_routes() failed: {exc}",
file=sys.stderr,
)
continue
if plugin_router.routes:
app.include_router(plugin_router)
def _mount_frontend(app: FastAPI) -> None:
assets_dir = WEB_DIST / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(request: Request, full_path: str) -> object:
if full_path.startswith("api/"):
return JSONResponse({"detail": "Not found."}, status_code=404)
index_file = WEB_DIST / "index.html"
if not index_file.is_file():
return JSONResponse(
{
"detail": "Frontend not built.",
"fix": "Run 'npm install && npm run build' inside web/, "
"or start the Vite dev server with 'npm run dev'.",
},
status_code=503,
)
# Serve real files (favicon, manifest, …) directly; everything else
# falls through to index.html so client-side routing works on reload.
if full_path:
candidate = (WEB_DIST / full_path).resolve()
if candidate.is_file() and WEB_DIST.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(index_file)
app = create_app()
+93
View File
@@ -0,0 +1,93 @@
"""Bridge between the server's internals and the plugin API.
Plugins only ever see the frozen dataclasses in ``plugin_api``. This module does
the translation and owns the markdown rendering of grades, so nothing in the
plugin contract is coupled to how runs happen to be stored.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
ROOT_DIR = Path(__file__).resolve().parent.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from plugin_api import Grade, GradeEntry, RunRecord, TestRecord # noqa: E402
from plugin_system import ( # noqa: E402
PluginManager,
collect_report_sections,
get_plugin_manager,
letter_grade,
render_grade_section,
)
if TYPE_CHECKING: # pragma: no cover
from .run_manager import RunState, TestOutcome
__all__ = [
"Grade",
"GradeEntry",
"PluginManager",
"RunRecord",
"TestRecord",
"build_run_record",
"build_test_record",
"collect_report_sections",
"get_plugin_manager",
"letter_grade",
"render_grade_section",
]
def build_test_record(
outcome: "TestOutcome",
*,
total: int,
prompt_text: str,
) -> TestRecord:
return TestRecord(
index=outcome.index,
total=total,
title=outcome.title,
filename=outcome.filename,
prompt=prompt_text,
ok=outcome.status == "ok",
response=outcome.response,
error=outcome.error,
metrics=dict(outcome.metrics or {}),
elapsed=outcome.elapsed,
)
def build_run_record(
run: "RunState",
*,
prompts_by_filename: Dict[str, str],
report_path: Optional[Path] = None,
) -> RunRecord:
tests = [
build_test_record(
outcome,
total=run.total,
prompt_text=prompts_by_filename.get(outcome.filename, ""),
)
for outcome in run.outcomes
]
return RunRecord(
id=run.id,
provider=run.provider,
model_id=run.model_id,
model_label=run.model_label,
temperature=run.temperature,
total=run.total,
status=run.status,
started_at=run.started_at,
finished_at=run.finished_at,
report_path=report_path,
summary=run.summary(),
tests=tests,
grades={index: list(entries) for index, entries in run.grades.items()},
)
+523
View File
@@ -0,0 +1,523 @@
"""Background execution of diagnostic runs with server-sent-event streaming.
The engine's ``run_suite`` is synchronous and blocking, so each run executes on
a worker thread. Progress is pushed back onto the asyncio loop through
``loop.call_soon_threadsafe`` and fanned out to any connected SSE subscribers.
Every event is also retained on the run, so a client that connects late (or
reconnects after a dropped connection) replays the full history before it
starts following the live feed.
"""
from __future__ import annotations
import asyncio
import json
import threading
import time
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncIterator, Dict, List, Optional
from .core_bridge import (
RESULTS_DIR,
TemplateNotFoundError,
TestRunError,
append_sections,
finalize_report_summary,
replace_analysis_section,
run_suite,
sanitize_model_name,
)
from .plugins import (
GradeEntry,
build_run_record,
build_test_record,
collect_report_sections,
get_plugin_manager,
render_grade_section,
)
TERMINAL_EVENTS = {"run.completed", "run.failed", "run.cancelled"}
MAX_RUN_HISTORY = 20
KEEPALIVE_SECONDS = 15.0
class RunCancelled(Exception):
"""Raised inside the engine's progress callback to unwind a running suite.
The engine has no cancellation hook of its own. Raising from the callback
stops the loop cleanly between tests -- results already written to the
report are kept and the summary is finalized with whatever completed.
"""
@dataclass
class TestOutcome:
index: int
title: str
filename: str
status: str
elapsed: float
response: Optional[str] = None
error: Optional[str] = None
metrics: Optional[Dict[str, Any]] = None
grades: List[Dict[str, Any]] = field(default_factory=list)
@property
def score(self) -> Optional[float]:
if not self.grades:
return None
return sum(g["score"] for g in self.grades) / len(self.grades)
def as_event(self, total: int) -> Dict[str, Any]:
return {
"type": "test.completed",
"index": self.index,
"total": total,
"title": self.title,
"filename": self.filename,
"status": self.status,
"elapsed": round(self.elapsed, 2),
"response": self.response,
"error": self.error,
"metrics": self.metrics,
"grades": self.grades,
"score": self.score,
}
@dataclass
class RunState:
id: str
provider: str
model_id: str
model_label: str
temperature: float
total: int
status: str = "running"
started_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
report_name: Optional[str] = None
error: Optional[str] = None
outcomes: List[TestOutcome] = field(default_factory=list)
events: List[Dict[str, Any]] = field(default_factory=list)
subscribers: List["asyncio.Queue[Dict[str, Any]]"] = field(default_factory=list)
cancel_event: threading.Event = field(default_factory=threading.Event)
grades: Dict[int, List[GradeEntry]] = field(default_factory=dict)
prompts_by_filename: Dict[str, str] = field(default_factory=dict)
@property
def completed(self) -> int:
return len(self.outcomes)
@property
def overall_score(self) -> Optional[float]:
scores = [o.score for o in self.outcomes if o.score is not None]
return sum(scores) / len(scores) if scores else None
def summary(self) -> Dict[str, Any]:
ok = [o for o in self.outcomes if o.status == "ok" and o.metrics]
if ok:
avg_tps = sum(float(o.metrics.get("tokens_per_second", 0)) for o in ok) / len(ok)
avg_ttft = sum(float(o.metrics.get("time_to_first_token", 0)) for o in ok) / len(ok)
total_tokens = sum(int(o.metrics.get("total_tokens", 0)) for o in ok)
else:
avg_tps = avg_ttft = 0.0
total_tokens = 0
overall = self.overall_score
return {
"average_tokens_per_second": round(avg_tps, 2),
"average_time_to_first_token": round(avg_ttft, 2),
"total_tokens": total_tokens,
"passed": len([o for o in self.outcomes if o.status == "ok"]),
"failed": len([o for o in self.outcomes if o.status == "error"]),
"overall_score": round(overall, 4) if overall is not None else None,
"graded": len([o for o in self.outcomes if o.score is not None]),
}
def as_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"status": self.status,
"provider": self.provider,
"model_id": self.model_id,
"model_label": self.model_label,
"temperature": self.temperature,
"total": self.total,
"completed": self.completed,
"started_at": self.started_at,
"finished_at": self.finished_at,
"report_name": self.report_name,
"error": self.error,
"summary": self.summary(),
}
def _format_sse(event: Dict[str, Any]) -> str:
return f"event: {event['type']}\ndata: {json.dumps(event)}\n\n"
class RunManager:
"""Owns the lifecycle of diagnostic runs.
Only one run may be active at a time: the local engine loads model weights
into memory, so overlapping runs would compete for RAM and produce
meaningless throughput numbers.
"""
def __init__(self) -> None:
self._runs: "OrderedDict[str, RunState]" = OrderedDict()
self._active_id: Optional[str] = None
self._lock = threading.Lock()
# ------------------------------------------------------------------ query
def get(self, run_id: str) -> Optional[RunState]:
with self._lock:
return self._runs.get(run_id)
def active(self) -> Optional[RunState]:
with self._lock:
if self._active_id is None:
return None
return self._runs.get(self._active_id)
def history(self) -> List[RunState]:
with self._lock:
return list(reversed(self._runs.values()))
# ------------------------------------------------------------------ start
def start(
self,
*,
provider_name: str,
model_id: str,
model_label: str,
temperature: float,
prompts: List[Dict[str, str]],
loop: asyncio.AbstractEventLoop,
) -> RunState:
with self._lock:
if self._active_id is not None:
active = self._runs.get(self._active_id)
if active is not None and active.status == "running":
raise RuntimeError(
f"A run is already in progress ({active.model_label}). "
"Cancel it before starting another."
)
run = RunState(
id=uuid.uuid4().hex[:12],
provider=provider_name,
model_id=model_id,
model_label=model_label,
temperature=temperature,
total=len(prompts),
prompts_by_filename={
p.get("filename", ""): p.get("prompt", "") for p in prompts
},
)
self._runs[run.id] = run
self._active_id = run.id
while len(self._runs) > MAX_RUN_HISTORY:
oldest, _ = next(iter(self._runs.items()))
if oldest == self._active_id:
break
self._runs.pop(oldest)
run.events.append(
{
"type": "run.started",
"run": run.as_dict(),
"tests": [
{"index": i, "title": p["title"], "filename": p["filename"]}
for i, p in enumerate(prompts, start=1)
],
}
)
thread = threading.Thread(
target=self._worker,
args=(run, prompts, loop),
name=f"lm-gambit-run-{run.id}",
daemon=True,
)
thread.start()
return run
def cancel(self, run_id: str) -> bool:
run = self.get(run_id)
if run is None or run.status != "running":
return False
run.cancel_event.set()
return True
# ----------------------------------------------------------------- worker
def _worker(
self,
run: RunState,
prompts: List[Dict[str, str]],
loop: asyncio.AbstractEventLoop,
) -> None:
last_tick = time.time()
def progress_callback(
index: int,
total: int,
prompt: Dict[str, str],
result: Dict[str, Any],
) -> None:
nonlocal last_tick
now = time.time()
elapsed = now - last_tick
last_tick = now
if "error" in result:
outcome = TestOutcome(
index=index,
title=prompt.get("title", f"Test {index}"),
filename=prompt.get("filename", f"test{index}"),
status="error",
elapsed=elapsed,
error=str(result["error"]),
)
else:
metrics = result.get("metrics") or {}
outcome = TestOutcome(
index=index,
title=prompt.get("title", f"Test {index}"),
filename=prompt.get("filename", f"test{index}"),
status="ok",
elapsed=elapsed,
response=str(result.get("response", "")),
metrics=dict(metrics),
)
run.outcomes.append(outcome)
self._grade(run, outcome)
# Plugins observe the question after grading, so on_test_complete
# sees the same record the report will.
record = build_test_record(
outcome,
total=run.total,
prompt_text=run.prompts_by_filename.get(outcome.filename, ""),
)
get_plugin_manager().emit("on_test_complete", record)
self._publish(loop, run, outcome.as_event(total))
if run.cancel_event.is_set():
raise RunCancelled
get_plugin_manager().emit(
"on_run_start",
build_run_record(run, prompts_by_filename=run.prompts_by_filename),
)
try:
report_path = run_suite(
provider_name=run.provider,
model_id=run.model_id,
temperature=run.temperature,
progress_callback=progress_callback,
prompts=prompts,
)
except RunCancelled:
self._finalize_partial_report(run)
run.status = "cancelled"
run.finished_at = time.time()
self._apply_plugin_report(run)
self._publish(
loop,
run,
{"type": "run.cancelled", "run": run.as_dict()},
)
except (TestRunError, TemplateNotFoundError) as exc:
run.status = "failed"
run.error = str(exc)
run.finished_at = time.time()
self._publish(
loop,
run,
{"type": "run.failed", "message": str(exc), "run": run.as_dict()},
)
except Exception as exc: # noqa: BLE001 - surface engine faults to the UI
run.status = "failed"
run.error = f"{type(exc).__name__}: {exc}"
run.finished_at = time.time()
self._publish(
loop,
run,
{"type": "run.failed", "message": run.error, "run": run.as_dict()},
)
else:
run.status = "completed"
run.report_name = report_path.name
run.finished_at = time.time()
self._apply_plugin_report(run)
self._publish(
loop,
run,
{"type": "run.completed", "run": run.as_dict()},
)
finally:
with self._lock:
if self._active_id == run.id:
self._active_id = None
get_plugin_manager().emit("on_run_complete", self._record(run))
# ---------------------------------------------------------------- plugins
def _report_path(self, run: RunState) -> Path:
return RESULTS_DIR / f"automated_report_{sanitize_model_name(run.model_label)}.md"
def _record(self, run: RunState):
path = self._report_path(run)
return build_run_record(
run,
prompts_by_filename=run.prompts_by_filename,
report_path=path if path.exists() else None,
)
def _grade(self, run: RunState, outcome: TestOutcome) -> None:
"""Run grader plugins over one answer and attach the results.
Graders run on this worker thread, so a slow grader delays the next
question. That is deliberate: grading is part of the run, and the
report should not be finalized before it lands.
Questions the provider failed are never graded — there is no answer to
judge, and scoring them would let one careless plugin drag down the
overall score. Plugins that want to react to failures use
``on_test_complete``, which fires for every question.
"""
if outcome.status != "ok":
return
record = build_test_record(
outcome,
total=run.total,
prompt_text=run.prompts_by_filename.get(outcome.filename, ""),
)
graded = get_plugin_manager().grade(record)
if not graded:
return
run.grades[outcome.index] = [
GradeEntry(grader=name, grade=grade) for name, grade in graded
]
outcome.grades = [
{
"grader": name,
"score": grade.score,
"label": grade.label,
"notes": grade.notes,
}
for name, grade in graded
]
def _apply_plugin_report(self, run: RunState) -> None:
"""Write grades and plugin sections into the finished report."""
report_path = self._report_path(run)
if not report_path.exists():
return
record = self._record(run)
manager = get_plugin_manager()
grade_markdown = render_grade_section(record)
if grade_markdown:
try:
replace_analysis_section(report_path, grade_markdown)
except OSError:
pass
try:
sections = collect_report_sections(record, manager)
except Exception: # noqa: BLE001 - a plugin fault must not fail the run
sections = []
if sections:
try:
append_sections(report_path, sections)
except OSError:
pass
def _finalize_partial_report(self, run: RunState) -> None:
"""Write the summary block for a run that stopped early."""
report_path = RESULTS_DIR / f"automated_report_{sanitize_model_name(run.model_label)}.md"
if not report_path.exists():
return
results: List[Dict[str, Any]] = []
for outcome in run.outcomes:
if outcome.status == "ok" and outcome.metrics:
results.append({"response": outcome.response, "metrics": outcome.metrics})
else:
results.append({"error": outcome.error or "cancelled"})
try:
finalize_report_summary(report_path, results)
except (OSError, KeyError):
return
run.report_name = report_path.name
# -------------------------------------------------------------- streaming
def _publish(
self,
loop: asyncio.AbstractEventLoop,
run: RunState,
event: Dict[str, Any],
) -> None:
"""Hand an event from the worker thread to the event loop."""
try:
loop.call_soon_threadsafe(self._emit, run, event)
except RuntimeError:
# Loop already closed (server shutting down); keep the history only.
run.events.append(event)
@staticmethod
def _emit(run: RunState, event: Dict[str, Any]) -> None:
run.events.append(event)
for queue in list(run.subscribers):
queue.put_nowait(event)
async def stream(self, run_id: str) -> AsyncIterator[str]:
run = self.get(run_id)
if run is None:
yield _format_sse({"type": "run.failed", "message": "Unknown run id."})
return
queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
# Subscribing and snapshotting in the same synchronous block guarantees
# no event is delivered twice or dropped between the two.
run.subscribers.append(queue)
history = list(run.events)
try:
for event in history:
yield _format_sse(event)
if event["type"] in TERMINAL_EVENTS:
return
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield _format_sse(event)
if event["type"] in TERMINAL_EVENTS:
return
finally:
if queue in run.subscribers:
run.subscribers.remove(queue)
run_manager = RunManager()
+157
View File
@@ -0,0 +1,157 @@
"""Pydantic request/response models for the LM-Gambit API."""
from __future__ import annotations
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class ProviderSummary(BaseModel):
name: str
is_default: bool = False
class ModelSummary(BaseModel):
id: str
display_name: str
class ModelListResponse(BaseModel):
provider: str
models: List[ModelSummary]
class TestPrompt(BaseModel):
"""A single question in the suite.
``title`` is derived from the first non-empty line of ``prompt`` by the
engine, so it is read-only here and returned for display purposes only.
"""
filename: str
title: str
prompt: str
class TestSuiteResponse(BaseModel):
tests: List[TestPrompt]
directory: str
class TestDraft(BaseModel):
"""One question as submitted by the suite builder form."""
prompt: str = Field(min_length=1)
class SaveSuiteRequest(BaseModel):
tests: List[TestDraft]
class RunMetrics(BaseModel):
tokens_per_second: float = 0.0
total_tokens: int = 0
time_to_first_token: float = 0.0
stop_reason: str = "N/A"
class RunRequest(BaseModel):
provider: str
model_id: str
temperature: float = Field(default=0.1, ge=0.0, le=2.0)
filenames: Optional[List[str]] = Field(
default=None,
description="Subset of test filenames to run. Omit to run the whole suite.",
)
class RunSummary(BaseModel):
average_tokens_per_second: float = 0.0
average_time_to_first_token: float = 0.0
total_tokens: int = 0
passed: int = 0
failed: int = 0
overall_score: Optional[float] = None
graded: int = 0
class PluginSummary(BaseModel):
name: str
slug: str
version: str
description: str
path: str
enabled: bool
hooks: List[str] = []
error: Optional[str] = None
class RunResponse(BaseModel):
id: str
status: str
provider: str
model_id: str
model_label: str
temperature: float
total: int
completed: int
started_at: float
finished_at: Optional[float] = None
report_name: Optional[str] = None
error: Optional[str] = None
summary: RunSummary = RunSummary()
class PlaygroundRequest(BaseModel):
provider: str
model_id: str
prompt: str = Field(min_length=1)
temperature: float = Field(default=0.1, ge=0.0, le=2.0)
class PlaygroundResponse(BaseModel):
response: Optional[str] = None
error: Optional[str] = None
metrics: Optional[RunMetrics] = None
elapsed: float = 0.0
class ReportSummary(BaseModel):
name: str
model_label: str
size_bytes: int
modified_at: float
class ReportDetail(ReportSummary):
content: str
class ModelPathEntry(BaseModel):
nickname: str = ""
path: str
class SettingsResponse(BaseModel):
default_provider: str
default_temperature: float
local_model_paths: List[ModelPathEntry]
tests_dir: str
results_dir: str
models_dir: str
class SettingsUpdate(BaseModel):
default_provider: Optional[str] = None
default_temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
local_model_paths: Optional[List[ModelPathEntry]] = None
class SystemInfo(BaseModel):
version: str
engine_architecture: str
engine_runtime: str
template_ok: bool
python_version: str
metrics: Dict[str, str] = {}
+73
View File
@@ -0,0 +1,73 @@
"""Reading and writing the prompt suite in ``tests/``.
The engine treats every ``tests/*.txt`` file as one prompt and derives its title
from the first non-empty line, ordering files by a natural sort of their names.
To keep the web editor and the ``auto-test.py`` CLI in perfect agreement, saving
rewrites the directory as ``test1.txt`` … ``testN.txt`` in the order shown in
the builder. That makes add / remove / reorder unambiguous for both entrypoints.
"""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List
from .core_bridge import TESTS_DIR, load_test_prompts
class SuiteError(Exception):
"""Raised when the suite on disk cannot be read or written."""
def derive_title(prompt: str, fallback: str) -> str:
for line in prompt.splitlines():
stripped = line.strip()
if stripped:
return stripped
return fallback
def list_tests() -> List[Dict[str, str]]:
"""Return the suite exactly as the engine sees it."""
TESTS_DIR.mkdir(parents=True, exist_ok=True)
return load_test_prompts()
def find_tests(filenames: List[str]) -> List[Dict[str, str]]:
"""Return the prompts matching ``filenames``, preserving suite order."""
wanted = set(filenames)
selected = [prompt for prompt in list_tests() if prompt["filename"] in wanted]
missing = wanted - {prompt["filename"] for prompt in selected}
if missing:
raise SuiteError(f"Unknown test file(s): {', '.join(sorted(missing))}")
return selected
def save_suite(prompts: List[str]) -> List[Dict[str, str]]:
"""Replace the suite with ``prompts`` and return the resulting tests."""
cleaned = [text.strip() for text in prompts]
for position, text in enumerate(cleaned, start=1):
if not text:
raise SuiteError(f"Question {position} is empty. Remove it or add a prompt.")
TESTS_DIR.mkdir(parents=True, exist_ok=True)
target_names = {f"test{index}.txt" for index in range(1, len(cleaned) + 1)}
written: List[Path] = []
for index, text in enumerate(cleaned, start=1):
path = TESTS_DIR / f"test{index}.txt"
try:
path.write_text(text + "\n", encoding="utf-8")
except OSError as exc:
raise SuiteError(f"Could not write {path.name}: {exc}") from exc
written.append(path)
for stale in TESTS_DIR.glob("*.txt"):
if stale.name not in target_names:
try:
stale.unlink()
except OSError:
continue
return list_tests()