diff --git a/app/api.py b/app/api.py index 5355628..29f3af2 100644 --- a/app/api.py +++ b/app/api.py @@ -287,6 +287,59 @@ def test_account(req: AccountTestRequest): from fastapi.responses import StreamingResponse +# --------------------------------------------------------------------------- +# Benchmark endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/benchmark/results") +def get_benchmark_results(): + """Return the most recently saved benchmark results, or an empty envelope.""" + path = _DATA_DIR / "benchmark_results.json" + if not path.exists(): + return {"models": {}, "sample_count": 0, "timestamp": None} + return json.loads(path.read_text()) + + +@app.get("/api/benchmark/run") +def run_benchmark(include_slow: bool = False): + """Spawn the benchmark script and stream stdout as SSE progress events.""" + import subprocess + + python_bin = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python" + script = str(_ROOT / "scripts" / "benchmark_classifier.py") + cmd = [python_bin, script, "--score", "--save"] + if include_slow: + cmd.append("--include-slow") + + def generate(): + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=str(_ROOT), + ) + for line in proc.stdout: + line = line.rstrip() + if line: + yield f"data: {json.dumps({'type': 'progress', 'message': line})}\n\n" + proc.wait() + if proc.returncode == 0: + yield f"data: {json.dumps({'type': 'complete'})}\n\n" + else: + yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @app.get("/api/fetch/stream") def fetch_stream( accounts: str = Query(default=""), diff --git a/scripts/benchmark_classifier.py b/scripts/benchmark_classifier.py index 3f661a6..947f909 100644 --- a/scripts/benchmark_classifier.py +++ b/scripts/benchmark_classifier.py @@ -163,7 +163,8 @@ def run_scoring( gold = [r["label"] for r in rows] results: dict[str, Any] = {} - for adapter in adapters: + for i, adapter in enumerate(adapters, 1): + print(f"[{i}/{len(adapters)}] Running {adapter.name} ({len(rows)} samples) …", flush=True) preds: list[str] = [] t0 = time.monotonic() for row in rows: @@ -177,6 +178,7 @@ def run_scoring( metrics = compute_metrics(preds, gold, LABELS) metrics["latency_ms"] = round(elapsed_ms / len(rows), 1) results[adapter.name] = metrics + print(f" → macro-F1 {metrics['__macro_f1__']:.3f} accuracy {metrics['__accuracy__']:.3f} {metrics['latency_ms']:.1f} ms/email", flush=True) adapter.unload() return results @@ -375,6 +377,31 @@ def cmd_score(args: argparse.Namespace) -> None: print(row_str) print() + if args.save: + import datetime + rows = load_scoring_jsonl(args.score_file) + save_data = { + "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "sample_count": len(rows), + "models": { + name: { + "macro_f1": round(m["__macro_f1__"], 4), + "accuracy": round(m["__accuracy__"], 4), + "latency_ms": m["latency_ms"], + "per_label": { + label: {k: round(v, 4) for k, v in m[label].items()} + for label in LABELS + if label in m + }, + } + for name, m in results.items() + }, + } + save_path = Path(args.score_file).parent / "benchmark_results.json" + with open(save_path, "w") as f: + json.dump(save_data, f, indent=2) + print(f"Results saved → {save_path}", flush=True) + def cmd_compare(args: argparse.Namespace) -> None: active = _active_models(args.include_slow) @@ -431,6 +458,8 @@ def main() -> None: parser.add_argument("--days", type=int, default=90, help="Days back for IMAP search") parser.add_argument("--include-slow", action="store_true", help="Include non-default heavy models") parser.add_argument("--models", nargs="+", help="Override: run only these model names") + parser.add_argument("--save", action="store_true", + help="Save results to data/benchmark_results.json (for the web UI)") args = parser.parse_args() diff --git a/web/index.html b/web/index.html index 508eabd..a97bf18 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,12 @@ - web + Avocet — Label Tool + +
diff --git a/web/src/App.vue b/web/src/App.vue index a5422ff..f15fb5c 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -11,11 +11,13 @@ import { onMounted } from 'vue' import { RouterView } from 'vue-router' import { useMotion } from './composables/useMotion' -import { useHackerMode } from './composables/useEasterEgg' +import { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import AppSidebar from './components/AppSidebar.vue' const motion = useMotion() -const { restore } = useHackerMode() +const { toggle, restore } = useHackerMode() + +useKonamiCode(toggle) onMounted(() => { restore() // re-apply hacker mode from localStorage on page load diff --git a/web/src/assets/avocet.css b/web/src/assets/avocet.css index 3ad8a01..7a91dac 100644 --- a/web/src/assets/avocet.css +++ b/web/src/assets/avocet.css @@ -8,8 +8,29 @@ Accent — Russet (#B8622A) — inspired by avocet's vivid orange-russet head */ +/* ── Page-level overrides — must be in avocet.css (applied after theme.css base) ── */ +html { + /* Prevent Mac Chrome's horizontal swipe-to-navigate page animation + from triggering when the user scrolls near the viewport edge */ + overscroll-behavior-x: none; + /* clip (not hidden) — prevents overflowing content from expanding the html layout + width beyond the viewport. Without this, body's overflow-x:hidden propagates to + the viewport and body has no BFC, so long email URLs inflate the layout and + margin:0 auto centering drifts rightward as fonts load. */ + overflow-x: clip; +} + +body { + /* Prevent horizontal scroll from card swipe animations */ + overflow-x: hidden; +} + + /* ── Light mode (default) ──────────────────────────── */ :root { + /* Aliases bridging avocet component vars to CircuitForge base theme vars */ + --color-bg: var(--color-surface); /* App.vue body bg → #eaeff8 in light */ + --color-text-secondary: var(--color-text-muted); /* muted label text */ /* Primary — Slate Teal */ --app-primary: #2A6080; /* 4.8:1 on light surface #eaeff8 — ✅ AA */ --app-primary-hover: #1E4D66; /* darker for hover */ diff --git a/web/src/components/AppSidebar.vue b/web/src/components/AppSidebar.vue index 2df47e8..f691c9b 100644 --- a/web/src/components/AppSidebar.vue +++ b/web/src/components/AppSidebar.vue @@ -62,10 +62,11 @@ import { RouterLink } from 'vue-router' const LS_KEY = 'cf-avocet-nav-stowed' const navItems = [ - { path: '/', icon: '🃏', label: 'Label' }, - { path: '/fetch', icon: '📥', label: 'Fetch' }, - { path: '/stats', icon: '📊', label: 'Stats' }, - { path: '/settings', icon: '⚙️', label: 'Settings' }, + { path: '/', icon: '🃏', label: 'Label' }, + { path: '/fetch', icon: '📥', label: 'Fetch' }, + { path: '/stats', icon: '📊', label: 'Stats' }, + { path: '/benchmark', icon: '🏁', label: 'Benchmark' }, + { path: '/settings', icon: '⚙️', label: 'Settings' }, ] const stowed = ref(localStorage.getItem(LS_KEY) === 'true') diff --git a/web/src/components/EmailCard.vue b/web/src/components/EmailCard.vue index 2180df2..beb994d 100644 --- a/web/src/components/EmailCard.vue +++ b/web/src/components/EmailCard.vue @@ -86,6 +86,7 @@ const displayBody = computed(() => { font-size: 0.9375rem; line-height: 1.6; white-space: pre-wrap; + overflow-wrap: break-word; margin: 0; } diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue index acacbea..2abc6c3 100644 --- a/web/src/components/EmailCardStack.vue +++ b/web/src/components/EmailCardStack.vue @@ -84,6 +84,8 @@ const FLING_WINDOW_MS = 50 // rolling sample window in ms let velocityBuf: { x: number; y: number; t: number }[] = [] function onPointerDown(e: PointerEvent) { + // Let clicks on interactive children (expand/collapse, links, etc.) pass through + if ((e.target as Element).closest('button, a, input, select, textarea')) return if (!motion.rich.value) return ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) pickupX.value = e.clientX diff --git a/web/src/components/LabelBucketGrid.vue b/web/src/components/LabelBucketGrid.vue index f1102c2..ebd1500 100644 --- a/web/src/components/LabelBucketGrid.vue +++ b/web/src/components/LabelBucketGrid.vue @@ -1,7 +1,7 @@