feat: add plugin framework with UI contributions and suite selection

- Implemented PluginBlocks component to render various plugin UI elements.
- Created SuitePicker for selecting test suites and questions.
- Introduced usePluginUI hook for managing plugin UI state and manifest.
- Developed DocsPage for comprehensive plugin framework documentation.
- Added PluginPage to render pages declared by plugins based on the current path.
This commit is contained in:
Netherwarlord
2026-07-28 03:00:55 -04:00
parent adf61ae1a0
commit 8f246fabbc
73 changed files with 4972 additions and 640 deletions
+182 -6
View File
@@ -34,9 +34,18 @@ import traceback
import types
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple
from plugin_api import Grade, PluginInfo, RunRecord
from plugin_api import (
UI_SLOTS,
Grade,
NavItem,
Page,
PluginInfo,
RunRecord,
SlotPanel,
ui_payload,
)
ROOT_DIR = Path(__file__).resolve().parent
PLUGIN_DIR = ROOT_DIR / "plugins"
@@ -49,8 +58,12 @@ HOOK_NAMES = (
"on_run_complete",
"report_sections",
"register_routes",
"ui_contributions",
)
#: Plugin pages live under this prefix so they can never shadow a core route.
UI_PATH_PREFIX = "/x"
@dataclass
class LoadedPlugin:
@@ -121,6 +134,12 @@ class PluginManager:
def load(self) -> List[LoadedPlugin]:
"""(Re)discover everything in the plugin directory."""
# Routes registered at startup close over the module object that
# existed then. Reloading swaps in a new module for every other hook
# but leaves those handlers reading the old one's globals, so they can
# quietly serve stale state. Nothing here can rebind them — say so.
had_routes = {plugin.slug for plugin in self.loaded if "register_routes" in plugin.hooks}
self.loaded = []
if not self.plugin_dir.is_dir():
return self.loaded
@@ -130,6 +149,15 @@ class PluginManager:
for path in _candidate_paths(self.plugin_dir):
slug = path.parent.name if path.name == "__init__.py" else path.stem
self.loaded.append(self._load_one(slug, path))
stale = sorted(had_routes & {plugin.slug for plugin in self.active})
if stale:
print(
f"[plugins] {', '.join(stale)} reloaded, but its HTTP routes still point at the "
"previously loaded module and may serve stale state. Restart the server to rebind them.",
file=sys.stderr,
)
return self.loaded
def _load_one(self, slug: str, path: Path) -> LoadedPlugin:
@@ -218,6 +246,66 @@ class PluginManager:
results.append((plugin, value))
return results
# ------------------------------------------------------------------- ui
def ui_manifest(self) -> Dict[str, Any]:
"""Collect every plugin's declared interface into one manifest.
The frontend fetches this at startup and renders from it, so installing
a plugin never requires rebuilding the UI. A plugin returning malformed
contributions is skipped with a warning rather than breaking the app.
"""
nav: List[Dict[str, Any]] = []
pages: List[Dict[str, Any]] = []
slots: Dict[str, List[Dict[str, Any]]] = {slot: [] for slot in UI_SLOTS}
for plugin, value in self.collect("ui_contributions"):
items = value if isinstance(value, (list, tuple)) else [value]
for item in items:
try:
self._add_contribution(plugin.slug, plugin.name, item, nav, pages, slots)
except Exception:
print(
f"[plugin:{plugin.slug}] bad UI contribution {item!r}: "
f"{_last_line(traceback.format_exc())}",
file=sys.stderr,
)
nav.sort(key=lambda entry: (entry.get("order", 100), entry.get("label", "")))
for entries in slots.values():
entries.sort(key=lambda entry: entry.get("order", 100))
return {"nav": nav, "pages": pages, "slots": slots}
@staticmethod
def _add_contribution(
slug: str,
name: str,
item: Any,
nav: List[Dict[str, Any]],
pages: List[Dict[str, Any]],
slots: Dict[str, List[Dict[str, Any]]],
) -> None:
if isinstance(item, NavItem):
entry = ui_payload(item)
entry["path"] = plugin_ui_path(slug, item.path)
entry["slug"] = slug
nav.append(entry)
elif isinstance(item, Page):
entry = ui_payload(item)
entry["path"] = plugin_ui_path(slug, item.path)
entry["slug"] = slug
entry["plugin"] = name
pages.append(entry)
elif isinstance(item, SlotPanel):
if item.slot not in slots:
raise ValueError(f"unknown slot {item.slot!r}; expected one of {', '.join(UI_SLOTS)}")
slots[item.slot].append(
{"slug": slug, "plugin": name, "order": item.order, "panel": ui_payload(item.panel)}
)
else:
raise TypeError(f"expected NavItem, Page or SlotPanel, got {type(item).__name__}")
def grade(self, test: Any) -> List[Tuple[str, Grade]]:
"""Run every grader over one answer, normalising the return values."""
grades: List[Tuple[str, Grade]] = []
@@ -234,6 +322,20 @@ class PluginManager:
return grades
def plugin_ui_path(slug: str, path: str) -> str:
"""Namespace a plugin's declared path under ``/x/``.
A plugin may write ``"/lint"`` or the fully-qualified ``"/x/code_lint/lint"``
and get the same result, so short paths stay readable while collisions
between two plugins — or with a future core route — remain impossible.
"""
cleaned = "/" + (path or "").strip("/")
if cleaned.startswith(f"{UI_PATH_PREFIX}/"):
return cleaned
suffix = "" if cleaned == "/" else cleaned
return f"{UI_PATH_PREFIX}/{slug}{suffix}"
def _last_line(text: str) -> str:
lines = [line for line in text.strip().splitlines() if line.strip()]
return lines[-1].strip() if lines else "unknown error"
@@ -276,6 +378,70 @@ def letter_grade(score: float) -> str:
return "F"
def _suite_breakdown(record: RunRecord, graders: List[str]) -> List[str]:
"""A suite × grader table of mean scores.
Worth its own table because a run now spans several suites, and a single
overall number hides where a model actually struggled. It also fixes a
misleading reading of abstentions: a grader that only understands code
abstains on most of a mixed run, which looks like weak coverage against the
whole suite list but is exactly right per-suite — "100% of math-code, not
applicable elsewhere". Abstentions show as `—`, never as a zero.
"""
order: List[str] = []
per_suite: Dict[str, List[Any]] = {}
for test in record.tests:
slug = getattr(test, "suite", "") or "(unassigned)"
if slug not in per_suite:
per_suite[slug] = []
order.append(slug)
per_suite[slug].append(test)
# A single suite adds no information the overall line does not already give.
if len(order) < 2:
return []
header = ["| Suite | Questions | Score |", "| --- | --- | --- |"]
if graders:
header = [
"| Suite | Questions | Score | " + " | ".join(graders) + " |",
"| --- | --- | --- |" + " --- |" * len(graders),
]
rows: List[str] = []
for slug in order:
tests = per_suite[slug]
scores = [s for s in (record.score_for(t.index) for t in tests) if s is not None]
mean = sum(scores) / len(scores) if scores else None
cells = [
f"`{slug}`",
str(len(tests)),
f"{mean:.0%} ({letter_grade(mean)})" if mean is not None else "",
]
for grader in graders:
values = [
entry.grade.score
for t in tests
for entry in (record.grades.get(t.index) or [])
if entry.grader == grader
]
cells.append(
f"{sum(values) / len(values):.0%} ({len(values)}/{len(tests)})" if values else ""
)
rows.append("| " + " | ".join(cells) + " |")
return [
"",
"**By suite**",
"",
*header,
*rows,
"",
"A `—` means every grader abstained there, not a zero. Abstentions never "
"count against a score.",
]
def render_grade_section(record: RunRecord) -> Optional[str]:
"""Render grades as the report's Qualitative Analysis block."""
overall = record.overall_score
@@ -293,17 +459,27 @@ def render_grade_section(record: RunRecord) -> Optional[str]:
"",
"Scores come from plugins in `plugins/`, so they reflect whatever those "
"plugins check — not a judgement of correctness. Review before relying on them.",
"",
"| # | Question | Score | Grader | Notes |",
"| --- | --- | --- | --- | --- |",
]
lines.extend(_suite_breakdown(record, graders))
lines.extend(
[
"",
"**By question**",
"",
"| # | Suite | Question | Score | Grader | Notes |",
"| --- | --- | --- | --- | --- | --- |",
]
)
for test in record.tests:
suite = escape(getattr(test, "suite", "") or "")
for entry in record.grades.get(test.index) or []:
label = escape(entry.grade.label)
score = f"{entry.grade.score:.0%}" + (f" ({label})" if label else "")
lines.append(
f"| {test.index} | {escape(test.title)} | {score} "
f"| {test.index} | `{suite}` | {escape(test.title)} | {score} "
f"| {escape(entry.grader)} | {escape(entry.grade.notes) or ''} |"
)