Merge pull request 'feat: Corrections tab — SFT candidate import, review, and JSONL export' (#15) from feat/sft-corrections into main

This commit is contained in:
pyr0ball 2026-04-08 22:19:01 -07:00
commit c5eaacc767
78 changed files with 15139 additions and 1080 deletions

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ config/label_tool.yaml
data/email_score.jsonl
data/email_label_queue.jsonl
data/email_compare_sample.jsonl
data/sft_candidates.jsonl
data/sft_approved.jsonl
# Conda/pip artifacts
.env

7
PRIVACY.md Normal file
View file

@ -0,0 +1,7 @@
# Privacy Policy
CircuitForge LLC's privacy policy applies to this product and is published at:
**<https://circuitforge.tech/privacy>**
Last reviewed: March 2026.

572
app/api.py Normal file
View file

@ -0,0 +1,572 @@
"""Avocet — FastAPI REST layer.
JSONL read/write helpers and FastAPI app instance.
Endpoints and static file serving are added in subsequent tasks.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess as _subprocess
import yaml
from pathlib import Path
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
_ROOT = Path(__file__).parent.parent
_DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir()
_MODELS_DIR: Path = _ROOT / "models" # overridable in tests via set_models_dir()
_CONFIG_DIR: Path | None = None # None = use real path
# Process registry for running jobs — used by cancel endpoints.
# Keys: "benchmark" | "finetune". Values: the live Popen object.
_running_procs: dict = {}
_cancelled_jobs: set = set()
def set_data_dir(path: Path) -> None:
"""Override data directory — used by tests."""
global _DATA_DIR
_DATA_DIR = path
def _best_cuda_device() -> str:
"""Return the index of the GPU with the most free VRAM as a string.
Uses nvidia-smi so it works in the job-seeker env (no torch). Returns ""
if nvidia-smi is unavailable or no GPUs are found. Restricting the
training subprocess to a single GPU via CUDA_VISIBLE_DEVICES prevents
PyTorch DataParallel from replicating the model across all GPUs, which
would OOM the GPU with less headroom.
"""
try:
out = _subprocess.check_output(
["nvidia-smi", "--query-gpu=index,memory.free",
"--format=csv,noheader,nounits"],
text=True,
timeout=5,
)
best_idx, best_free = "", 0
for line in out.strip().splitlines():
parts = line.strip().split(", ")
if len(parts) == 2:
idx, free = parts[0].strip(), int(parts[1].strip())
if free > best_free:
best_free, best_idx = free, idx
return best_idx
except Exception:
return ""
def set_models_dir(path: Path) -> None:
"""Override models directory — used by tests."""
global _MODELS_DIR
_MODELS_DIR = path
def set_config_dir(path: Path | None) -> None:
"""Override config directory — used by tests."""
global _CONFIG_DIR
_CONFIG_DIR = path
def _config_file() -> Path:
if _CONFIG_DIR is not None:
return _CONFIG_DIR / "label_tool.yaml"
return _ROOT / "config" / "label_tool.yaml"
def reset_last_action() -> None:
"""Reset undo state — used by tests."""
global _last_action
_last_action = None
def _queue_file() -> Path:
return _DATA_DIR / "email_label_queue.jsonl"
def _score_file() -> Path:
return _DATA_DIR / "email_score.jsonl"
def _discarded_file() -> Path:
return _DATA_DIR / "discarded.jsonl"
def _read_jsonl(path: Path) -> list[dict]:
if not path.exists():
return []
lines = path.read_text(encoding="utf-8").splitlines()
return [json.loads(l) for l in lines if l.strip()]
def _write_jsonl(path: Path, records: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
text = "\n".join(json.dumps(r, ensure_ascii=False) for r in records)
path.write_text(text + "\n" if records else "", encoding="utf-8")
def _append_jsonl(path: Path, record: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _item_id(item: dict) -> str:
"""Stable content-hash ID — matches label_tool.py _entry_key dedup logic."""
key = (item.get("subject", "") + (item.get("body", "") or "")[:100])
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
def _normalize(item: dict) -> dict:
"""Normalize JSONL item to the Vue frontend schema.
label_tool.py stores: subject, body, from_addr, date, account (no id).
The Vue app expects: id, subject, body, from, date, source.
Both old (from_addr/account) and new (from/source) field names are handled.
"""
return {
"id": item.get("id") or _item_id(item),
"subject": item.get("subject", ""),
"body": item.get("body", ""),
"from": item.get("from") or item.get("from_addr", ""),
"date": item.get("date", ""),
"source": item.get("source") or item.get("account", ""),
}
app = FastAPI(title="Avocet API")
from app.sft import router as sft_router
app.include_router(sft_router, prefix="/api/sft")
# In-memory last-action store (single user, local tool — in-memory is fine)
_last_action: dict | None = None
@app.get("/api/queue")
def get_queue(limit: int = Query(default=10, ge=1, le=50)):
items = _read_jsonl(_queue_file())
return {"items": [_normalize(x) for x in items[:limit]], "total": len(items)}
class LabelRequest(BaseModel):
id: str
label: str
@app.post("/api/label")
def post_label(req: LabelRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": req.label,
"labeled_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_score_file(), record)
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "label", "item": match, "label": req.label}
return {"ok": True}
class SkipRequest(BaseModel):
id: str
@app.post("/api/skip")
def post_skip(req: SkipRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
reordered = [x for x in items if _normalize(x)["id"] != req.id] + [match]
_write_jsonl(_queue_file(), reordered)
_last_action = {"type": "skip", "item": match}
return {"ok": True}
class DiscardRequest(BaseModel):
id: str
@app.post("/api/discard")
def post_discard(req: DiscardRequest):
global _last_action
items = _read_jsonl(_queue_file())
match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": "__discarded__",
"discarded_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_discarded_file(), record)
_write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "discard", "item": match}
return {"ok": True}
@app.delete("/api/label/undo")
def delete_undo():
global _last_action
if not _last_action:
raise HTTPException(404, "No action to undo")
action = _last_action
item = action["item"] # always the original clean queue item
# Perform file operations FIRST — only clear _last_action on success
if action["type"] == "label":
records = _read_jsonl(_score_file())
if not records:
raise HTTPException(409, "Score file is empty — cannot undo label")
_write_jsonl(_score_file(), records[:-1])
items = _read_jsonl(_queue_file())
_write_jsonl(_queue_file(), [item] + items)
elif action["type"] == "discard":
records = _read_jsonl(_discarded_file())
if not records:
raise HTTPException(409, "Discarded file is empty — cannot undo discard")
_write_jsonl(_discarded_file(), records[:-1])
items = _read_jsonl(_queue_file())
_write_jsonl(_queue_file(), [item] + items)
elif action["type"] == "skip":
items = _read_jsonl(_queue_file())
item_id = _normalize(item)["id"]
items = [item] + [x for x in items if _normalize(x)["id"] != item_id]
_write_jsonl(_queue_file(), items)
# Clear AFTER all file operations succeed
_last_action = None
return {"undone": {"type": action["type"], "item": _normalize(item)}}
# Label metadata — 10 labels matching label_tool.py
_LABEL_META = [
{"name": "interview_scheduled", "emoji": "\U0001f4c5", "color": "#4CAF50", "key": "1"},
{"name": "offer_received", "emoji": "\U0001f389", "color": "#2196F3", "key": "2"},
{"name": "rejected", "emoji": "\u274c", "color": "#F44336", "key": "3"},
{"name": "positive_response", "emoji": "\U0001f44d", "color": "#FF9800", "key": "4"},
{"name": "survey_received", "emoji": "\U0001f4cb", "color": "#9C27B0", "key": "5"},
{"name": "neutral", "emoji": "\u2b1c", "color": "#607D8B", "key": "6"},
{"name": "event_rescheduled", "emoji": "\U0001f504", "color": "#FF5722", "key": "7"},
{"name": "digest", "emoji": "\U0001f4f0", "color": "#00BCD4", "key": "8"},
{"name": "new_lead", "emoji": "\U0001f91d", "color": "#009688", "key": "9"},
{"name": "hired", "emoji": "\U0001f38a", "color": "#FFC107", "key": "h"},
]
@app.get("/api/config/labels")
def get_labels():
return _LABEL_META
@app.get("/api/config")
def get_config():
f = _config_file()
if not f.exists():
return {"accounts": [], "max_per_account": 500}
raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
return {"accounts": raw.get("accounts", []), "max_per_account": raw.get("max_per_account", 500)}
class ConfigPayload(BaseModel):
accounts: list[dict]
max_per_account: int = 500
@app.post("/api/config")
def post_config(payload: ConfigPayload):
f = _config_file()
f.parent.mkdir(parents=True, exist_ok=True)
tmp = f.with_suffix(".tmp")
tmp.write_text(yaml.dump(payload.model_dump(), allow_unicode=True, sort_keys=False),
encoding="utf-8")
tmp.rename(f)
return {"ok": True}
@app.get("/api/stats")
def get_stats():
records = _read_jsonl(_score_file())
counts: dict[str, int] = {}
for r in records:
lbl = r.get("label", "")
if lbl:
counts[lbl] = counts.get(lbl, 0) + 1
return {
"total": len(records),
"counts": counts,
"score_file_bytes": _score_file().stat().st_size if _score_file().exists() else 0,
}
@app.get("/api/stats/download")
def download_stats():
from fastapi.responses import FileResponse
if not _score_file().exists():
raise HTTPException(404, "No score file")
return FileResponse(
str(_score_file()),
filename="email_score.jsonl",
media_type="application/jsonlines",
headers={"Content-Disposition": 'attachment; filename="email_score.jsonl"'},
)
class AccountTestRequest(BaseModel):
account: dict
@app.post("/api/accounts/test")
def test_account(req: AccountTestRequest):
from app.imap_fetch import test_connection
ok, message, count = test_connection(req.account)
return {"ok": ok, "message": message, "count": count}
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."""
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),
)
_running_procs["benchmark"] = proc
_cancelled_jobs.discard("benchmark") # clear any stale flag from a prior run
try:
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"
elif "benchmark" in _cancelled_jobs:
_cancelled_jobs.discard("benchmark")
yield f"data: {json.dumps({'type': 'cancelled'})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n"
finally:
_running_procs.pop("benchmark", None)
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"},
)
# ---------------------------------------------------------------------------
# Finetune endpoints
# ---------------------------------------------------------------------------
@app.get("/api/finetune/status")
def get_finetune_status():
"""Scan models/ for training_info.json files. Returns [] if none exist."""
models_dir = _MODELS_DIR
if not models_dir.exists():
return []
results = []
for sub in models_dir.iterdir():
if not sub.is_dir():
continue
info_path = sub / "training_info.json"
if not info_path.exists():
continue
try:
info = json.loads(info_path.read_text(encoding="utf-8"))
results.append(info)
except Exception:
pass
return results
@app.get("/api/finetune/run")
def run_finetune_endpoint(
model: str = "deberta-small",
epochs: int = 5,
score: list[str] = Query(default=[]),
):
"""Spawn finetune_classifier.py and stream stdout as SSE progress events."""
python_bin = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python"
script = str(_ROOT / "scripts" / "finetune_classifier.py")
cmd = [python_bin, script, "--model", model, "--epochs", str(epochs)]
data_root = _DATA_DIR.resolve()
for score_file in score:
resolved = (_DATA_DIR / score_file).resolve()
if not str(resolved).startswith(str(data_root)):
raise HTTPException(400, f"Invalid score path: {score_file!r}")
cmd.extend(["--score", str(resolved)])
# Pick the GPU with the most free VRAM. Setting CUDA_VISIBLE_DEVICES to a
# single device prevents DataParallel from replicating the model across all
# GPUs, which would force a full copy onto the more memory-constrained device.
proc_env = {**os.environ, "PYTORCH_ALLOC_CONF": "expandable_segments:True"}
best_gpu = _best_cuda_device()
if best_gpu:
proc_env["CUDA_VISIBLE_DEVICES"] = best_gpu
gpu_note = f"GPU {best_gpu}" if best_gpu else "CPU (no GPU found)"
def generate():
yield f"data: {json.dumps({'type': 'progress', 'message': f'[api] Using {gpu_note} (most free VRAM)'})}\n\n"
try:
proc = _subprocess.Popen(
cmd,
stdout=_subprocess.PIPE,
stderr=_subprocess.STDOUT,
text=True,
bufsize=1,
cwd=str(_ROOT),
env=proc_env,
)
_running_procs["finetune"] = proc
_cancelled_jobs.discard("finetune") # clear any stale flag from a prior run
try:
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"
elif "finetune" in _cancelled_jobs:
_cancelled_jobs.discard("finetune")
yield f"data: {json.dumps({'type': 'cancelled'})}\n\n"
else:
yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n"
finally:
_running_procs.pop("finetune", None)
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.post("/api/benchmark/cancel")
def cancel_benchmark():
"""Kill the running benchmark subprocess. 404 if none is running."""
proc = _running_procs.get("benchmark")
if proc is None:
raise HTTPException(404, "No benchmark is running")
_cancelled_jobs.add("benchmark")
proc.terminate()
try:
proc.wait(timeout=3)
except _subprocess.TimeoutExpired:
proc.kill()
return {"status": "cancelled"}
@app.post("/api/finetune/cancel")
def cancel_finetune():
"""Kill the running fine-tune subprocess. 404 if none is running."""
proc = _running_procs.get("finetune")
if proc is None:
raise HTTPException(404, "No finetune is running")
_cancelled_jobs.add("finetune")
proc.terminate()
try:
proc.wait(timeout=3)
except _subprocess.TimeoutExpired:
proc.kill()
return {"status": "cancelled"}
@app.get("/api/fetch/stream")
def fetch_stream(
accounts: str = Query(default=""),
days_back: int = Query(default=90, ge=1, le=365),
limit: int = Query(default=150, ge=1, le=1000),
mode: str = Query(default="wide"),
):
from app.imap_fetch import fetch_account_stream
selected_names = {n.strip() for n in accounts.split(",") if n.strip()}
config = get_config() # reuse existing endpoint logic
selected = [a for a in config["accounts"] if a.get("name") in selected_names]
def generate():
known_keys = {_item_id(x) for x in _read_jsonl(_queue_file())}
total_added = 0
for acc in selected:
try:
batch_emails: list[dict] = []
for event in fetch_account_stream(acc, days_back, limit, known_keys):
if event["type"] == "done":
batch_emails = event.pop("emails", [])
total_added += event["added"]
yield f"data: {json.dumps(event)}\n\n"
# Write new emails to queue after each account
if batch_emails:
existing = _read_jsonl(_queue_file())
_write_jsonl(_queue_file(), existing + batch_emails)
except Exception as exc:
error_event = {"type": "error", "account": acc.get("name", "?"),
"message": str(exc)}
yield f"data: {json.dumps(error_event)}\n\n"
queue_size = len(_read_jsonl(_queue_file()))
complete = {"type": "complete", "total_added": total_added, "queue_size": queue_size}
yield f"data: {json.dumps(complete)}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# Static SPA — MUST be last (catches all unmatched paths)
_DIST = _ROOT / "web" / "dist"
if _DIST.exists():
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
# Serve index.html with no-cache so browsers always fetch fresh HTML after rebuilds.
# Hashed assets (/assets/index-abc123.js) can be cached forever — they change names
# when content changes (standard Vite cache-busting strategy).
_NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"}
@app.get("/")
def get_spa_root():
return FileResponse(str(_DIST / "index.html"), headers=_NO_CACHE)
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa")

158
app/imap_fetch.py Normal file
View file

@ -0,0 +1,158 @@
"""Avocet — IMAP fetch utilities.
Shared between app/api.py (FastAPI SSE endpoint) and the label UI.
No Streamlit imports here stdlib + imaplib only.
"""
from __future__ import annotations
import email as _email_lib
import hashlib
import imaplib
from datetime import datetime, timedelta
from email.header import decode_header as _raw_decode
from typing import Any, Iterator
from app.utils import extract_body, strip_html # noqa: F401 (strip_html re-exported for callers)
# ── IMAP decode helpers ───────────────────────────────────────────────────────
def _decode_str(value: str | None) -> str:
if not value:
return ""
parts = _raw_decode(value)
out = []
for part, enc in parts:
if isinstance(part, bytes):
out.append(part.decode(enc or "utf-8", errors="replace"))
else:
out.append(str(part))
return " ".join(out).strip()
def entry_key(e: dict) -> str:
"""Stable MD5 content-hash for dedup — matches label_tool.py _entry_key."""
key = (e.get("subject", "") + (e.get("body", "") or "")[:100])
return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest()
# ── Wide search terms ────────────────────────────────────────────────────────
_WIDE_TERMS = [
"interview", "phone screen", "video call", "zoom link", "schedule a call",
"offer letter", "job offer", "offer of employment", "pleased to offer",
"unfortunately", "not moving forward", "other candidates", "regret to inform",
"no longer", "decided not to", "decided to go with",
"opportunity", "interested in your background", "reached out", "great fit",
"exciting role", "love to connect",
"assessment", "questionnaire", "culture fit", "culture-fit", "online assessment",
"application received", "thank you for applying", "application confirmation",
"you applied", "your application for",
"reschedule", "rescheduled", "new time", "moved to", "postponed", "new date",
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
"new jobs", "job alert",
"came across your profile", "reaching out about", "great fit for a role",
"exciting opportunity",
"welcome to the team", "start date", "onboarding", "first day", "we're excited to have you",
"application", "recruiter", "recruiting", "hiring", "candidate",
]
# ── Public API ────────────────────────────────────────────────────────────────
def test_connection(acc: dict) -> tuple[bool, str, int | None]:
"""Connect, login, select folder. Returns (ok, human_message, message_count|None)."""
host = acc.get("host", "")
port = int(acc.get("port", 993))
use_ssl = acc.get("use_ssl", True)
username = acc.get("username", "")
password = acc.get("password", "")
folder = acc.get("folder", "INBOX")
if not host or not username or not password:
return False, "Host, username, and password are all required.", None
try:
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
_, data = conn.select(folder, readonly=True)
count_raw = data[0].decode() if data and data[0] else "0"
count = int(count_raw) if count_raw.isdigit() else 0
conn.logout()
return True, f"Connected — {count:,} message(s) in {folder}.", count
except Exception as exc:
return False, str(exc), None
def fetch_account_stream(
acc: dict,
days_back: int,
limit: int,
known_keys: set[str],
) -> Iterator[dict]:
"""Generator — yields progress dicts while fetching emails via IMAP.
Mutates `known_keys` in place for cross-account dedup within one fetch session.
Yields event dicts with "type" key:
{"type": "start", "account": str, "total_uids": int}
{"type": "progress", "account": str, "fetched": int, "total_uids": int}
{"type": "done", "account": str, "added": int, "skipped": int, "emails": list}
"""
name = acc.get("name", acc.get("username", "?"))
host = acc.get("host", "imap.gmail.com")
port = int(acc.get("port", 993))
use_ssl = acc.get("use_ssl", True)
username = acc["username"]
password = acc["password"]
folder = acc.get("folder", "INBOX")
since = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y")
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
conn.select(folder, readonly=True)
seen_uids: dict[bytes, None] = {}
for term in _WIDE_TERMS:
try:
_, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")')
for uid in (data[0] or b"").split():
seen_uids[uid] = None
except Exception:
pass
uids = list(seen_uids.keys())[: limit * 3]
yield {"type": "start", "account": name, "total_uids": len(uids)}
emails: list[dict] = []
skipped = 0
for i, uid in enumerate(uids):
if len(emails) >= limit:
break
if i % 5 == 0:
yield {"type": "progress", "account": name, "fetched": len(emails), "total_uids": len(uids)}
try:
_, raw_data = conn.fetch(uid, "(RFC822)")
if not raw_data or not raw_data[0]:
continue
msg = _email_lib.message_from_bytes(raw_data[0][1])
subj = _decode_str(msg.get("Subject", ""))
from_addr = _decode_str(msg.get("From", ""))
date = _decode_str(msg.get("Date", ""))
body = extract_body(msg)[:800]
entry = {"subject": subj, "body": body, "from_addr": from_addr,
"date": date, "account": name}
k = entry_key(entry)
if k not in known_keys:
known_keys.add(k)
emails.append(entry)
else:
skipped += 1
except Exception:
skipped += 1
try:
conn.logout()
except Exception:
pass
yield {"type": "done", "account": name, "added": len(emails), "skipped": skipped,
"emails": emails}

View file

@ -1,981 +0,0 @@
"""Email Label Tool — card-stack UI for building classifier benchmark data.
Philosophy: Scrape Store Process
Fetch (IMAP, wide search, multi-account) data/email_label_queue.jsonl
Label (card stack) data/email_score.jsonl
Run:
conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503
Config: config/label_tool.yaml (gitignored see config/label_tool.yaml.example)
"""
from __future__ import annotations
import email as _email_lib
import hashlib
import html as _html
import imaplib
import json
import re
import sys
from datetime import datetime, timedelta
from email.header import decode_header as _raw_decode
from pathlib import Path
from typing import Any
import streamlit as st
import yaml
# ── Path setup ─────────────────────────────────────────────────────────────
_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT))
_QUEUE_FILE = _ROOT / "data" / "email_label_queue.jsonl"
_SCORE_FILE = _ROOT / "data" / "email_score.jsonl"
_CFG_FILE = _ROOT / "config" / "label_tool.yaml"
# ── Labels ─────────────────────────────────────────────────────────────────
LABELS = [
"interview_scheduled",
"offer_received",
"rejected",
"positive_response",
"survey_received",
"neutral",
"event_rescheduled",
"unrelated",
"digest",
]
_LABEL_META: dict[str, dict] = {
"interview_scheduled": {"emoji": "🗓️", "color": "#4CAF50", "key": "1"},
"offer_received": {"emoji": "🎉", "color": "#2196F3", "key": "2"},
"rejected": {"emoji": "", "color": "#F44336", "key": "3"},
"positive_response": {"emoji": "👍", "color": "#FF9800", "key": "4"},
"survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"},
"neutral": {"emoji": "", "color": "#607D8B", "key": "6"},
"event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"},
"unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"},
"digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"},
}
# ── HTML sanitiser ───────────────────────────────────────────────────────────
# Valid chars per XML 1.0 §2.2 (same set HTML5 innerHTML enforces):
# #x9 | #xA | #xD | [#x20#xD7FF] | [#xE000#xFFFD] | [#x10000#x10FFFF]
# Anything outside this range causes InvalidCharacterError in the browser.
_INVALID_XML_CHARS = re.compile(
r"[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]"
)
def _to_html(text: str, newlines_to_br: bool = False) -> str:
"""Strip invalid XML chars, HTML-escape the result, optionally convert \\n → <br>."""
if not text:
return ""
cleaned = _INVALID_XML_CHARS.sub("", text)
escaped = _html.escape(cleaned)
if newlines_to_br:
escaped = escaped.replace("\n", "<br>")
return escaped
# ── Wide IMAP search terms (cast a net across all 9 categories) ─────────────
_WIDE_TERMS = [
# interview_scheduled
"interview", "phone screen", "video call", "zoom link", "schedule a call",
# offer_received
"offer letter", "job offer", "offer of employment", "pleased to offer",
# rejected
"unfortunately", "not moving forward", "other candidates", "regret to inform",
"no longer", "decided not to", "decided to go with",
# positive_response
"opportunity", "interested in your background", "reached out", "great fit",
"exciting role", "love to connect",
# survey_received
"assessment", "questionnaire", "culture fit", "culture-fit", "online assessment",
# neutral / ATS confirms
"application received", "thank you for applying", "application confirmation",
"you applied", "your application for",
# event_rescheduled
"reschedule", "rescheduled", "new time", "moved to", "postponed", "new date",
# digest
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
"new jobs", "job alert",
# general recruitment
"application", "recruiter", "recruiting", "hiring", "candidate",
]
# ── IMAP helpers ────────────────────────────────────────────────────────────
def _decode_str(value: str | None) -> str:
if not value:
return ""
parts = _raw_decode(value)
out = []
for part, enc in parts:
if isinstance(part, bytes):
out.append(part.decode(enc or "utf-8", errors="replace"))
else:
out.append(str(part))
return " ".join(out).strip()
def _extract_body(msg: Any) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
try:
charset = part.get_content_charset() or "utf-8"
return part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
else:
try:
charset = msg.get_content_charset() or "utf-8"
return msg.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
return ""
def _test_imap_connection(acc: dict) -> tuple[bool, str]:
"""Try connect → login → select folder. Returns (ok, human message)."""
host = acc.get("host", "")
port = int(acc.get("port", 993))
use_ssl = acc.get("use_ssl", True)
username = acc.get("username", "")
password = acc.get("password", "")
folder = acc.get("folder", "INBOX")
if not host or not username or not password:
return False, "Host, username, and password are all required."
try:
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
typ, data = conn.select(folder, readonly=True)
count = data[0].decode() if data and data[0] else "?"
conn.logout()
return True, f"Connected — {count} message(s) in {folder}."
except Exception as exc:
return False, str(exc)
def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str],
progress_cb=None) -> list[dict]:
"""Fetch emails from one IMAP account using wide recruitment search terms."""
since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
host = cfg.get("host", "imap.gmail.com")
port = int(cfg.get("port", 993))
use_ssl = cfg.get("use_ssl", True)
username = cfg["username"]
password = cfg["password"]
name = cfg.get("name", username)
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
seen_uids: dict[bytes, None] = {}
conn.select("INBOX", readonly=True)
for term in _WIDE_TERMS:
try:
_, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")')
for uid in (data[0] or b"").split():
seen_uids[uid] = None
except Exception:
pass
emails: list[dict] = []
uids = list(seen_uids.keys())[:limit * 3] # overfetch; filter after dedup
for i, uid in enumerate(uids):
if len(emails) >= limit:
break
if progress_cb:
progress_cb(i / len(uids), f"{name}: {len(emails)} fetched…")
try:
_, raw_data = conn.fetch(uid, "(RFC822)")
if not raw_data or not raw_data[0]:
continue
msg = _email_lib.message_from_bytes(raw_data[0][1])
subj = _decode_str(msg.get("Subject", ""))
from_addr = _decode_str(msg.get("From", ""))
date = _decode_str(msg.get("Date", ""))
body = _extract_body(msg)[:800]
entry = {
"subject": subj,
"body": body,
"from_addr": from_addr,
"date": date,
"account": name,
}
key = _entry_key(entry)
if key not in known_keys:
known_keys.add(key)
emails.append(entry)
except Exception:
pass
try:
conn.logout()
except Exception:
pass
return emails
def _fetch_targeted(
cfg: dict,
since_dt: datetime, before_dt: datetime,
term: str, field: str,
limit: int,
known_keys: set[str],
progress_cb=None,
) -> list[dict]:
"""Fetch emails within a date range, optionally filtered by sender/subject.
field: "from" | "subject" | "either" | "none"
"""
since = since_dt.strftime("%d-%b-%Y")
before = before_dt.strftime("%d-%b-%Y")
host = cfg.get("host", "imap.gmail.com")
port = int(cfg.get("port", 993))
use_ssl = cfg.get("use_ssl", True)
username = cfg["username"]
password = cfg["password"]
name = cfg.get("name", username)
conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port)
conn.login(username, password)
conn.select("INBOX", readonly=True)
date_part = f'SINCE "{since}" BEFORE "{before}"'
if term and field == "from":
search_str = f'(FROM "{term}") {date_part}'
elif term and field == "subject":
search_str = f'(SUBJECT "{term}") {date_part}'
elif term and field == "either":
search_str = f'(OR (FROM "{term}") (SUBJECT "{term}")) {date_part}'
else:
search_str = date_part
try:
_, data = conn.search(None, search_str)
uids = (data[0] or b"").split()
except Exception:
uids = []
emails: list[dict] = []
for i, uid in enumerate(uids):
if len(emails) >= limit:
break
if progress_cb:
progress_cb(i / max(len(uids), 1), f"{name}: {len(emails)} fetched…")
try:
_, raw_data = conn.fetch(uid, "(RFC822)")
if not raw_data or not raw_data[0]:
continue
msg = _email_lib.message_from_bytes(raw_data[0][1])
subj = _decode_str(msg.get("Subject", ""))
from_addr = _decode_str(msg.get("From", ""))
date = _decode_str(msg.get("Date", ""))
body = _extract_body(msg)[:800]
entry = {
"subject": subj, "body": body,
"from_addr": from_addr, "date": date,
"account": name,
}
key = _entry_key(entry)
if key not in known_keys:
known_keys.add(key)
emails.append(entry)
except Exception:
pass
try:
conn.logout()
except Exception:
pass
return emails
# ── Queue / score file helpers ───────────────────────────────────────────────
def _entry_key(e: dict) -> str:
return hashlib.md5(
(e.get("subject", "") + (e.get("body") or "")[:100]).encode()
).hexdigest()
def _load_jsonl(path: Path) -> list[dict]:
if not path.exists():
return []
rows = []
with path.open() as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except Exception:
pass
return rows
def _save_jsonl(path: Path, rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def _append_jsonl(path: Path, row: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
# ── Config ──────────────────────────────────────────────────────────────────
def _load_config() -> list[dict]:
if not _CFG_FILE.exists():
return []
cfg = yaml.safe_load(_CFG_FILE.read_text()) or {}
return cfg.get("accounts", [])
# ── Page setup ──────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Avocet — Email Labeler",
page_icon="📬",
layout="wide",
)
st.markdown("""
<style>
/* Card stack */
.email-card {
border: 1px solid rgba(128,128,128,0.25);
border-radius: 14px;
padding: 28px 32px;
box-shadow: 0 6px 24px rgba(0,0,0,0.18);
margin-bottom: 4px;
position: relative;
}
.card-stack-hint {
height: 10px;
border-radius: 0 0 12px 12px;
border: 1px solid rgba(128,128,128,0.15);
margin: 0 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.10);
}
.card-stack-hint2 {
height: 8px;
border-radius: 0 0 10px 10px;
border: 1px solid rgba(128,128,128,0.08);
margin: 0 32px;
}
/* Subject line */
.card-subject { font-size: 1.3rem; font-weight: 700; margin-bottom: 6px; }
.card-meta { font-size: 0.82rem; opacity: 0.6; margin-bottom: 16px; }
.card-body { font-size: 0.92rem; opacity: 0.85; white-space: pre-wrap; line-height: 1.5; }
/* Bucket buttons */
div[data-testid="stButton"] > button.bucket-btn {
height: 70px;
font-size: 1.05rem;
font-weight: 600;
border-radius: 12px;
}
</style>
""", unsafe_allow_html=True)
st.title("📬 Avocet — Email Label Tool")
st.caption("Scrape → Store → Process | card-stack edition")
# ── Session state init ───────────────────────────────────────────────────────
if "queue" not in st.session_state:
st.session_state.queue: list[dict] = _load_jsonl(_QUEUE_FILE)
if "labeled" not in st.session_state:
st.session_state.labeled: list[dict] = _load_jsonl(_SCORE_FILE)
st.session_state.labeled_keys: set[str] = {
_entry_key(r) for r in st.session_state.labeled
}
if "idx" not in st.session_state:
# Start past already-labeled entries in the queue
labeled_keys = st.session_state.labeled_keys
for i, entry in enumerate(st.session_state.queue):
if _entry_key(entry) not in labeled_keys:
st.session_state.idx = i
break
else:
st.session_state.idx = len(st.session_state.queue)
if "history" not in st.session_state:
st.session_state.history: list[tuple[int, str]] = [] # (queue_idx, label)
# ── Sidebar stats ────────────────────────────────────────────────────────────
with st.sidebar:
labeled = st.session_state.labeled
queue = st.session_state.queue
unlabeled = [e for e in queue if _entry_key(e) not in st.session_state.labeled_keys]
st.metric("✅ Labeled", len(labeled))
st.metric("📥 Queue", len(unlabeled))
if labeled:
st.caption("**Label distribution**")
counts = {lbl: 0 for lbl in LABELS}
for r in labeled:
counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1
for lbl in LABELS:
m = _LABEL_META[lbl]
st.caption(f"{m['emoji']} {lbl}: **{counts[lbl]}**")
# ── Tabs ─────────────────────────────────────────────────────────────────────
tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"])
# ══════════════════════════════════════════════════════════════════════════════
# FETCH TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_fetch:
accounts = _load_config()
if not accounts:
st.warning(
f"No accounts configured. Copy `config/label_tool.yaml.example` → "
f"`config/label_tool.yaml` and add your IMAP accounts.",
icon="⚠️",
)
else:
st.markdown(f"**{len(accounts)} account(s) configured:**")
for acc in accounts:
st.caption(f"{acc.get('name', acc.get('username'))} ({acc.get('host')})")
col_days, col_limit = st.columns(2)
days = col_days.number_input("Days back", min_value=7, max_value=730, value=180)
limit = col_limit.number_input("Max emails per account", min_value=10, max_value=1000, value=150)
all_accs = [a.get("name", a.get("username")) for a in accounts]
selected = st.multiselect("Accounts to fetch", all_accs, default=all_accs)
if st.button("📥 Fetch from IMAP", disabled=not accounts or not selected, type="primary"):
existing_keys = {_entry_key(e) for e in st.session_state.queue}
existing_keys.update(st.session_state.labeled_keys)
fetched_all: list[dict] = []
status = st.status("Fetching…", expanded=True)
# Single updatable slot for per-email progress — overwrites instead of appending
_live = status.empty()
for acc in accounts:
name = acc.get("name", acc.get("username"))
if name not in selected:
continue
status.write(f"Connecting to **{name}**…")
try:
emails = _fetch_account(
acc, days=int(days), limit=int(limit),
known_keys=existing_keys,
progress_cb=lambda p, msg: _live.markdown(f"{msg}"),
)
_live.empty() # clear progress line once account is done
fetched_all.extend(emails)
status.write(f"{name}: {len(emails)} new emails")
except Exception as e:
_live.empty()
status.write(f"{name}: {e}")
if fetched_all:
_save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all)
st.session_state.queue = _load_jsonl(_QUEUE_FILE)
# Reset idx to first unlabeled
labeled_keys = st.session_state.labeled_keys
for i, entry in enumerate(st.session_state.queue):
if _entry_key(entry) not in labeled_keys:
st.session_state.idx = i
break
status.update(label=f"Done — {len(fetched_all)} new emails added to queue", state="complete")
else:
status.update(label="No new emails found (all already in queue or score file)", state="complete")
# ── Targeted fetch ───────────────────────────────────────────────────────
st.divider()
with st.expander("🎯 Targeted Fetch — date range + keyword"):
st.caption(
"Pull emails within a specific date window, optionally filtered by "
"sender or subject. Use this to retrieve historical hiring threads."
)
_t1, _t2 = st.columns(2)
_one_year_ago = (datetime.now() - timedelta(days=365)).date()
t_since = _t1.date_input("From date", value=_one_year_ago, key="t_since")
t_before = _t2.date_input("To date", value=datetime.now().date(), key="t_before")
t_term = st.text_input(
"Filter by keyword (optional)",
placeholder="e.g. Stateside",
key="t_term",
)
_tf1, _tf2 = st.columns(2)
t_field_label = _tf1.selectbox(
"Search in",
["Either (from or subject)", "Sender/from", "Subject line"],
key="t_field",
)
t_limit = _tf2.number_input("Max emails", min_value=10, max_value=1000, value=300, key="t_limit")
t_accs = st.multiselect("Accounts", all_accs, default=all_accs, key="t_accs")
_field_map = {
"Either (from or subject)": "either",
"Sender/from": "from",
"Subject line": "subject",
}
_t_invalid = not accounts or not t_accs or t_since >= t_before
if st.button("🎯 Targeted Fetch", disabled=_t_invalid, type="primary", key="btn_targeted"):
_t_since_dt = datetime(t_since.year, t_since.month, t_since.day)
_t_before_dt = datetime(t_before.year, t_before.month, t_before.day)
_t_field = _field_map[t_field_label]
existing_keys = {_entry_key(e) for e in st.session_state.queue}
existing_keys.update(st.session_state.labeled_keys)
fetched_all: list[dict] = []
status = st.status("Fetching…", expanded=True)
_live = status.empty()
for acc in accounts:
name = acc.get("name", acc.get("username"))
if name not in t_accs:
continue
status.write(f"Connecting to **{name}**…")
try:
emails = _fetch_targeted(
acc,
since_dt=_t_since_dt, before_dt=_t_before_dt,
term=t_term.strip(), field=_t_field,
limit=int(t_limit),
known_keys=existing_keys,
progress_cb=lambda p, msg: _live.markdown(f"{msg}"),
)
_live.empty()
fetched_all.extend(emails)
status.write(f"{name}: {len(emails)} new emails")
except Exception as e:
_live.empty()
status.write(f"{name}: {e}")
if fetched_all:
_save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all)
st.session_state.queue = _load_jsonl(_QUEUE_FILE)
labeled_keys = st.session_state.labeled_keys
for i, entry in enumerate(st.session_state.queue):
if _entry_key(entry) not in labeled_keys:
st.session_state.idx = i
break
status.update(
label=f"Done — {len(fetched_all)} new emails added to queue",
state="complete",
)
else:
status.update(
label="No new emails found in that date range",
state="complete",
)
# ══════════════════════════════════════════════════════════════════════════════
# LABEL TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_label:
queue = st.session_state.queue
labeled_keys = st.session_state.labeled_keys
idx = st.session_state.idx
# Advance idx past already-labeled entries
while idx < len(queue) and _entry_key(queue[idx]) in labeled_keys:
idx += 1
st.session_state.idx = idx
unlabeled = [e for e in queue if _entry_key(e) not in labeled_keys]
total_in_queue = len(queue)
n_labeled = len(st.session_state.labeled)
if not queue:
st.info("Queue is empty — go to **Fetch** to pull emails from IMAP.", icon="📥")
elif not unlabeled:
st.success(
f"🎉 All {n_labeled} emails labeled! Go to **Stats** to review and export.",
icon="",
)
else:
# Progress
labeled_in_queue = total_in_queue - len(unlabeled)
progress_pct = labeled_in_queue / total_in_queue if total_in_queue else 0
st.progress(progress_pct, text=f"{labeled_in_queue} / {total_in_queue} labeled in queue")
# Current email
entry = queue[idx]
# Card HTML
subj = entry.get("subject", "(no subject)") or "(no subject)"
from_ = entry.get("from_addr", "") or ""
date_ = entry.get("date", "") or ""
acct = entry.get("account", "") or ""
body = (entry.get("body") or "").strip()
st.markdown(
f"""<div class="email-card">
<div class="card-meta">{_to_html(from_)} &nbsp;·&nbsp; {_to_html(date_[:16])} &nbsp;·&nbsp; <em>{_to_html(acct)}</em></div>
<div class="card-subject">{_to_html(subj)}</div>
<div class="card-body">{_to_html(body[:500], newlines_to_br=True)}</div>
</div>""",
unsafe_allow_html=True,
)
if len(body) > 500:
with st.expander("Show full body"):
st.text(body)
# Stack hint (visual depth)
st.markdown('<div class="card-stack-hint"></div>', unsafe_allow_html=True)
st.markdown('<div class="card-stack-hint2"></div>', unsafe_allow_html=True)
st.markdown("") # spacer
# ── Bucket buttons ────────────────────────────────────────────────
def _do_label(label: str) -> None:
row = {"subject": entry.get("subject", ""), "body": body[:600], "label": label}
st.session_state.labeled.append(row)
st.session_state.labeled_keys.add(_entry_key(entry))
_append_jsonl(_SCORE_FILE, row)
st.session_state.history.append((idx, label))
# Advance
next_idx = idx + 1
while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys:
next_idx += 1
st.session_state.idx = next_idx
# Pre-compute per-label counts once
_counts: dict[str, int] = {}
for _r in st.session_state.labeled:
_lbl_r = _r.get("label", "")
_counts[_lbl_r] = _counts.get(_lbl_r, 0) + 1
row1_cols = st.columns(3)
row2_cols = st.columns(3)
row3_cols = st.columns(3)
bucket_pairs = [
(row1_cols[0], "interview_scheduled"),
(row1_cols[1], "offer_received"),
(row1_cols[2], "rejected"),
(row2_cols[0], "positive_response"),
(row2_cols[1], "survey_received"),
(row2_cols[2], "neutral"),
(row3_cols[0], "event_rescheduled"),
(row3_cols[1], "unrelated"),
(row3_cols[2], "digest"),
]
for col, lbl in bucket_pairs:
m = _LABEL_META[lbl]
cnt = _counts.get(lbl, 0)
label_display = f"{m['emoji']} **{lbl}** [{cnt}]\n`{m['key']}`"
if col.button(label_display, key=f"lbl_{lbl}", use_container_width=True):
_do_label(lbl)
st.rerun()
# ── Wildcard label ─────────────────────────────────────────────────
if "show_custom" not in st.session_state:
st.session_state.show_custom = False
other_col, _ = st.columns([1, 2])
if other_col.button("🏷️ Other… `0`", key="lbl_other_toggle", use_container_width=True):
st.session_state.show_custom = not st.session_state.show_custom
st.rerun()
if st.session_state.get("show_custom"):
custom_cols = st.columns([3, 1])
custom_val = custom_cols[0].text_input(
"Custom label:", key="custom_label_text",
placeholder="e.g. linkedin_outreach",
label_visibility="collapsed",
)
if custom_cols[1].button(
"✓ Apply", key="apply_custom", type="primary",
disabled=not (custom_val or "").strip(),
):
_do_label(custom_val.strip().lower().replace(" ", "_"))
st.session_state.show_custom = False
st.rerun()
# ── Navigation ────────────────────────────────────────────────────
st.markdown("")
nav_cols = st.columns([2, 1, 1, 1])
remaining = len(unlabeled) - 1
nav_cols[0].caption(f"**{remaining}** remaining · Keys: 19 = label, 0 = other, S = skip, U = undo")
if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True):
prev_idx, prev_label = st.session_state.history.pop()
# Remove the last labeled entry
if st.session_state.labeled:
removed = st.session_state.labeled.pop()
st.session_state.labeled_keys.discard(_entry_key(removed))
_save_jsonl(_SCORE_FILE, st.session_state.labeled)
st.session_state.idx = prev_idx
st.rerun()
if nav_cols[2].button("→ Skip", use_container_width=True):
next_idx = idx + 1
while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys:
next_idx += 1
st.session_state.idx = next_idx
st.rerun()
if nav_cols[3].button("🗑️ Discard", use_container_width=True):
# Remove from queue entirely — not written to score file
st.session_state.queue = [e for e in queue if _entry_key(e) != _entry_key(entry)]
_save_jsonl(_QUEUE_FILE, st.session_state.queue)
next_idx = min(idx, len(st.session_state.queue) - 1)
while next_idx < len(st.session_state.queue) and _entry_key(st.session_state.queue[next_idx]) in labeled_keys:
next_idx += 1
st.session_state.idx = max(next_idx, 0)
st.rerun()
# Keyboard shortcut capture (JS → hidden button click)
st.components.v1.html(
"""<script>
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const keyToLabel = {
'1':'interview_scheduled','2':'offer_received','3':'rejected',
'4':'positive_response','5':'survey_received','6':'neutral',
'7':'event_rescheduled','8':'unrelated','9':'digest'
};
const label = keyToLabel[e.key];
if (label) {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.toLowerCase().includes(label.replace('_',' '))) {
btn.click(); break;
}
}
} else if (e.key === '0') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.includes('Other')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 's') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.includes('Skip')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 'u') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.includes('Undo')) { btn.click(); break; }
}
}
});
</script>""",
height=0,
)
# ══════════════════════════════════════════════════════════════════════════════
# STATS TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_stats:
labeled = st.session_state.labeled
if not labeled:
st.info("No labeled emails yet.")
else:
counts: dict[str, int] = {}
for r in labeled:
lbl = r.get("label", "")
if lbl:
counts[lbl] = counts.get(lbl, 0) + 1
st.markdown(f"**{len(labeled)} labeled emails total**")
# Show known labels first, then any custom labels
all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS]
max_count = max(counts.values()) if counts else 1
for lbl in all_display_labels:
if lbl not in counts:
continue
m = _LABEL_META.get(lbl)
emoji = m["emoji"] if m else "🏷️"
col_name, col_bar, col_n = st.columns([3, 5, 1])
col_name.markdown(f"{emoji} {lbl}")
col_bar.progress(counts[lbl] / max_count)
col_n.markdown(f"**{counts[lbl]}**")
st.divider()
st.caption(
f"Score file: `{_SCORE_FILE.relative_to(_ROOT)}` "
f"({_SCORE_FILE.stat().st_size if _SCORE_FILE.exists() else 0:,} bytes)"
)
if st.button("🔄 Re-sync from disk"):
st.session_state.labeled = _load_jsonl(_SCORE_FILE)
st.session_state.labeled_keys = {_entry_key(r) for r in st.session_state.labeled}
st.rerun()
if _SCORE_FILE.exists():
st.download_button(
"⬇️ Download email_score.jsonl",
data=_SCORE_FILE.read_bytes(),
file_name="email_score.jsonl",
mime="application/jsonlines",
)
# ══════════════════════════════════════════════════════════════════════════════
# SETTINGS TAB
# ══════════════════════════════════════════════════════════════════════════════
def _sync_settings_to_state() -> None:
"""Collect current widget values back into settings_accounts, then clear
widget keys so the next render picks up freshly from the updated list."""
accounts = st.session_state.get("settings_accounts", [])
synced = []
for i in range(len(accounts)):
synced.append({
"name": st.session_state.get(f"s_name_{i}", accounts[i].get("name", "")),
"host": st.session_state.get(f"s_host_{i}", accounts[i].get("host", "imap.gmail.com")),
"port": int(st.session_state.get(f"s_port_{i}", accounts[i].get("port", 993))),
"use_ssl": bool(st.session_state.get(f"s_ssl_{i}", accounts[i].get("use_ssl", True))),
"username": st.session_state.get(f"s_user_{i}", accounts[i].get("username", "")),
"password": st.session_state.get(f"s_pass_{i}", accounts[i].get("password", "")),
"folder": st.session_state.get(f"s_folder_{i}", accounts[i].get("folder", "INBOX")),
"days_back": int(st.session_state.get(f"s_days_{i}", accounts[i].get("days_back", 90))),
})
st.session_state.settings_accounts = synced
for key in list(st.session_state.keys()):
if key.startswith("s_"):
del st.session_state[key]
with tab_settings:
# ── Init from disk on first load ─────────────────────────────────────────
if "settings_accounts" not in st.session_state:
_cfg_raw = yaml.safe_load(_CFG_FILE.read_text()) or {} if _CFG_FILE.exists() else {}
st.session_state.settings_accounts = [dict(a) for a in _cfg_raw.get("accounts", [])]
st.session_state.settings_max = _cfg_raw.get("max_per_account", 500)
_accs = st.session_state.settings_accounts
st.subheader("📧 IMAP Accounts")
st.caption(
"Credentials are saved to `config/label_tool.yaml` (gitignored). "
"Use an **App Password** for Gmail/Outlook — not your login password."
)
if not _accs:
st.info("No accounts configured yet. Click ** Add account** to get started.", icon="📭")
_to_remove = None
for _i, _acc in enumerate(_accs):
_label = f"**{_acc.get('name', 'Unnamed')}** — {_acc.get('username', '(no username)')}"
with st.expander(_label, expanded=not _acc.get("username")):
_c1, _c2 = st.columns(2)
_c1.text_input("Display name", key=f"s_name_{_i}", value=_acc.get("name", ""))
_c2.text_input("IMAP host", key=f"s_host_{_i}", value=_acc.get("host", "imap.gmail.com"))
_c3, _c4, _c5 = st.columns([3, 2, 1])
_c3.text_input("Username / email", key=f"s_user_{_i}", value=_acc.get("username", ""))
_c4.number_input("Port", key=f"s_port_{_i}", value=int(_acc.get("port", 993)),
min_value=1, max_value=65535, step=1)
_c5.checkbox("SSL", key=f"s_ssl_{_i}", value=bool(_acc.get("use_ssl", True)))
st.text_input("Password / app password", key=f"s_pass_{_i}",
value=_acc.get("password", ""), type="password")
_c6, _c7 = st.columns(2)
_c6.text_input("Folder", key=f"s_folder_{_i}", value=_acc.get("folder", "INBOX"))
_c7.number_input("Default days back", key=f"s_days_{_i}",
value=int(_acc.get("days_back", 90)), min_value=1, max_value=730)
_btn_l, _btn_r = st.columns([1, 3])
if _btn_l.button("🗑️ Remove", key=f"s_remove_{_i}"):
_to_remove = _i
if _btn_r.button("🔌 Test connection", key=f"s_test_{_i}"):
_test_acc = {
"host": st.session_state.get(f"s_host_{_i}", _acc.get("host", "")),
"port": st.session_state.get(f"s_port_{_i}", _acc.get("port", 993)),
"use_ssl": st.session_state.get(f"s_ssl_{_i}", _acc.get("use_ssl", True)),
"username": st.session_state.get(f"s_user_{_i}", _acc.get("username", "")),
"password": st.session_state.get(f"s_pass_{_i}", _acc.get("password", "")),
"folder": st.session_state.get(f"s_folder_{_i}", _acc.get("folder", "INBOX")),
}
with st.spinner("Connecting…"):
_ok, _msg = _test_imap_connection(_test_acc)
if _ok:
st.success(_msg)
else:
st.error(f"Connection failed: {_msg}")
if _to_remove is not None:
_sync_settings_to_state()
st.session_state.settings_accounts.pop(_to_remove)
st.rerun()
if st.button(" Add account"):
_sync_settings_to_state()
st.session_state.settings_accounts.append({
"name": f"Account {len(_accs) + 1}",
"host": "imap.gmail.com", "port": 993, "use_ssl": True,
"username": "", "password": "", "folder": "INBOX", "days_back": 90,
})
st.rerun()
st.divider()
st.subheader("⚙️ Global Settings")
st.number_input(
"Max emails per account per fetch (0 = unlimited)",
key="s_max_per_account",
value=st.session_state.settings_max,
min_value=0, max_value=5000, step=50,
)
st.divider()
_save_col, _reload_col = st.columns([3, 1])
if _save_col.button("💾 Save settings", type="primary", use_container_width=True):
_saved_accounts = []
for _i in range(len(st.session_state.settings_accounts)):
_a = st.session_state.settings_accounts[_i]
_saved_accounts.append({
"name": st.session_state.get(f"s_name_{_i}", _a.get("name", "")),
"host": st.session_state.get(f"s_host_{_i}", _a.get("host", "imap.gmail.com")),
"port": int(st.session_state.get(f"s_port_{_i}", _a.get("port", 993))),
"use_ssl": bool(st.session_state.get(f"s_ssl_{_i}", _a.get("use_ssl", True))),
"username": st.session_state.get(f"s_user_{_i}", _a.get("username", "")),
"password": st.session_state.get(f"s_pass_{_i}", _a.get("password", "")),
"folder": st.session_state.get(f"s_folder_{_i}", _a.get("folder", "INBOX")),
"days_back": int(st.session_state.get(f"s_days_{_i}", _a.get("days_back", 90))),
})
_cfg_out = {
"accounts": _saved_accounts,
"max_per_account": int(st.session_state.get("s_max_per_account", 500)),
}
_CFG_FILE.parent.mkdir(parents=True, exist_ok=True)
_CFG_FILE.write_text(yaml.dump(_cfg_out, default_flow_style=False, allow_unicode=True))
st.session_state.settings_accounts = _saved_accounts
st.session_state.settings_max = _cfg_out["max_per_account"]
st.success(f"Saved {len(_saved_accounts)} account(s) to `config/label_tool.yaml`.")
if _reload_col.button("↩ Reload", use_container_width=True, help="Discard unsaved changes and reload from disk"):
for _k in list(st.session_state.keys()):
if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"):
del st.session_state[_k]
st.rerun()

310
app/sft.py Normal file
View file

@ -0,0 +1,310 @@
"""Avocet — SFT candidate import and correction API.
All endpoints are registered on `router` (a FastAPI APIRouter).
api.py includes this router with prefix="/api/sft".
Module-level globals (_SFT_DATA_DIR, _SFT_CONFIG_DIR) follow the same
testability pattern as api.py override them via set_sft_data_dir() and
set_sft_config_dir() in test fixtures.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
import yaml
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.utils import append_jsonl, read_jsonl, write_jsonl
logger = logging.getLogger(__name__)
_ROOT = Path(__file__).parent.parent
_SFT_DATA_DIR: Path = _ROOT / "data"
_SFT_CONFIG_DIR: Path | None = None
router = APIRouter()
# ── Testability seams ──────────────────────────────────────────────────────
def set_sft_data_dir(path: Path) -> None:
global _SFT_DATA_DIR
_SFT_DATA_DIR = path
def set_sft_config_dir(path: Path | None) -> None:
global _SFT_CONFIG_DIR
_SFT_CONFIG_DIR = path
# ── Internal helpers ───────────────────────────────────────────────────────
def _config_file() -> Path:
if _SFT_CONFIG_DIR is not None:
return _SFT_CONFIG_DIR / "label_tool.yaml"
return _ROOT / "config" / "label_tool.yaml"
def _get_bench_results_dir() -> Path:
f = _config_file()
if not f.exists():
return Path("/nonexistent-bench-results")
try:
raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
logger.warning("Failed to parse SFT config %s: %s", f, exc)
return Path("/nonexistent-bench-results")
d = raw.get("sft", {}).get("bench_results_dir", "")
return Path(d) if d else Path("/nonexistent-bench-results")
def _candidates_file() -> Path:
return _SFT_DATA_DIR / "sft_candidates.jsonl"
def _approved_file() -> Path:
return _SFT_DATA_DIR / "sft_approved.jsonl"
def _read_candidates() -> list[dict]:
return read_jsonl(_candidates_file())
def _write_candidates(records: list[dict]) -> None:
write_jsonl(_candidates_file(), records)
def _is_exportable(r: dict) -> bool:
"""Return True if an approved record is ready to include in SFT export."""
return (
r.get("status") == "approved"
and bool(r.get("corrected_response"))
and str(r["corrected_response"]).strip() != ""
)
# ── GET /runs ──────────────────────────────────────────────────────────────
@router.get("/runs")
def get_runs():
"""List available benchmark runs in the configured bench_results_dir."""
from scripts.sft_import import discover_runs
bench_dir = _get_bench_results_dir()
existing = _read_candidates()
# benchmark_run_id in each record equals the run's directory name by cf-orch convention
imported_run_ids = {
r["benchmark_run_id"]
for r in existing
if r.get("benchmark_run_id") is not None
}
runs = discover_runs(bench_dir)
return [
{
"run_id": r["run_id"],
"timestamp": r["timestamp"],
"candidate_count": r["candidate_count"],
"already_imported": r["run_id"] in imported_run_ids,
}
for r in runs
]
# ── POST /import ───────────────────────────────────────────────────────────
class ImportRequest(BaseModel):
run_id: str
@router.post("/import")
def post_import(req: ImportRequest):
"""Import one benchmark run's sft_candidates.jsonl into the local data dir."""
from scripts.sft_import import discover_runs, import_run
bench_dir = _get_bench_results_dir()
runs = discover_runs(bench_dir)
run = next((r for r in runs if r["run_id"] == req.run_id), None)
if run is None:
raise HTTPException(404, f"Run {req.run_id!r} not found in bench_results_dir")
return import_run(run["sft_path"], _SFT_DATA_DIR)
# ── GET /queue ─────────────────────────────────────────────────────────────
@router.get("/queue")
def get_queue(page: int = 1, per_page: int = 20):
"""Return paginated needs_review candidates."""
records = _read_candidates()
pending = [r for r in records if r.get("status") == "needs_review"]
start = (page - 1) * per_page
return {
"items": pending[start:start + per_page],
"total": len(pending),
"page": page,
"per_page": per_page,
}
# ── POST /submit ───────────────────────────────────────────────────────────
class SubmitRequest(BaseModel):
id: str
action: Literal["correct", "discard", "flag"]
corrected_response: str | None = None
@router.post("/submit")
def post_submit(req: SubmitRequest):
"""Record a reviewer decision for one SFT candidate."""
if req.action == "correct":
if not req.corrected_response or not req.corrected_response.strip():
raise HTTPException(422, "corrected_response must be non-empty when action is 'correct'")
records = _read_candidates()
idx = next((i for i, r in enumerate(records) if r.get("id") == req.id), None)
if idx is None:
raise HTTPException(404, f"Record {req.id!r} not found")
record = records[idx]
if record.get("status") != "needs_review":
raise HTTPException(409, f"Record is not in needs_review state (current: {record.get('status')})")
if req.action == "correct":
records[idx] = {**record, "status": "approved", "corrected_response": req.corrected_response}
_write_candidates(records)
append_jsonl(_approved_file(), records[idx])
elif req.action == "discard":
records[idx] = {**record, "status": "discarded"}
_write_candidates(records)
else: # flag
records[idx] = {**record, "status": "model_rejected"}
_write_candidates(records)
return {"ok": True}
# ── POST /undo ─────────────────────────────────────────────────────────────
class UndoRequest(BaseModel):
id: str
@router.post("/undo")
def post_undo(req: UndoRequest):
"""Restore a previously actioned candidate back to needs_review."""
records = _read_candidates()
idx = next((i for i, r in enumerate(records) if r.get("id") == req.id), None)
if idx is None:
raise HTTPException(404, f"Record {req.id!r} not found")
record = records[idx]
old_status = record.get("status")
if old_status == "needs_review":
raise HTTPException(409, "Record is already in needs_review state")
records[idx] = {**record, "status": "needs_review", "corrected_response": None}
_write_candidates(records)
# If it was approved, remove from the approved file too
if old_status == "approved":
approved = read_jsonl(_approved_file())
write_jsonl(_approved_file(), [r for r in approved if r.get("id") != req.id])
return {"ok": True}
# ── GET /export ─────────────────────────────────────────────────────────────
@router.get("/export")
def get_export() -> StreamingResponse:
"""Stream approved records as SFT-ready JSONL for download."""
exportable = [r for r in read_jsonl(_approved_file()) if _is_exportable(r)]
def generate():
for r in exportable:
record = {
"messages": r.get("prompt_messages", []) + [
{"role": "assistant", "content": r["corrected_response"]}
]
}
yield json.dumps(record) + "\n"
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return StreamingResponse(
generate(),
media_type="application/x-ndjson",
headers={
"Content-Disposition": f'attachment; filename="sft_export_{timestamp}.jsonl"'
},
)
# ── GET /stats ──────────────────────────────────────────────────────────────
@router.get("/stats")
def get_stats() -> dict[str, object]:
"""Return counts by status, model, and task type."""
records = _read_candidates()
by_status: dict[str, int] = {}
by_model: dict[str, int] = {}
by_task_type: dict[str, int] = {}
for r in records:
status = r.get("status", "unknown")
by_status[status] = by_status.get(status, 0) + 1
model = r.get("model_name", "unknown")
by_model[model] = by_model.get(model, 0) + 1
task_type = r.get("task_type", "unknown")
by_task_type[task_type] = by_task_type.get(task_type, 0) + 1
approved = read_jsonl(_approved_file())
export_ready = sum(1 for r in approved if _is_exportable(r))
return {
"total": len(records),
"by_status": by_status,
"by_model": by_model,
"by_task_type": by_task_type,
"export_ready": export_ready,
}
# ── GET /config ─────────────────────────────────────────────────────────────
@router.get("/config")
def get_sft_config() -> dict:
"""Return the current SFT configuration (bench_results_dir)."""
f = _config_file()
if not f.exists():
return {"bench_results_dir": ""}
try:
raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
return {"bench_results_dir": ""}
sft_section = raw.get("sft") or {}
return {"bench_results_dir": sft_section.get("bench_results_dir", "")}
class SftConfigPayload(BaseModel):
bench_results_dir: str
@router.post("/config")
def post_sft_config(payload: SftConfigPayload) -> dict:
"""Write the bench_results_dir setting to the config file."""
f = _config_file()
f.parent.mkdir(parents=True, exist_ok=True)
try:
raw = yaml.safe_load(f.read_text(encoding="utf-8")) if f.exists() else {}
raw = raw or {}
except yaml.YAMLError:
raw = {}
raw["sft"] = {"bench_results_dir": payload.bench_results_dir}
tmp = f.with_suffix(".tmp")
tmp.write_text(yaml.dump(raw, allow_unicode=True, sort_keys=False), encoding="utf-8")
tmp.rename(f)
return {"ok": True}

117
app/utils.py Normal file
View file

@ -0,0 +1,117 @@
"""Shared email utility functions for Avocet.
Pure-stdlib helpers extracted from the retired label_tool.py Streamlit app.
These are reused by the FastAPI backend and the test suite.
"""
from __future__ import annotations
import json
import re
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
# ── HTML → plain-text extractor ──────────────────────────────────────────────
class _TextExtractor(HTMLParser):
"""Extract visible text from an HTML email body, preserving line breaks."""
_BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote"}
_SKIP = {"script", "style", "head", "noscript"}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._depth_skip = 0
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag in self._SKIP:
self._depth_skip += 1
elif tag in self._BLOCK:
self._parts.append("\n")
def handle_endtag(self, tag):
if tag.lower() in self._SKIP:
self._depth_skip = max(0, self._depth_skip - 1)
def handle_data(self, data):
if not self._depth_skip:
self._parts.append(data)
def get_text(self) -> str:
text = "".join(self._parts)
lines = [ln.strip() for ln in text.splitlines()]
return "\n".join(ln for ln in lines if ln)
def strip_html(html_str: str) -> str:
"""Convert HTML email body to plain text. Pure stdlib, no dependencies."""
try:
extractor = _TextExtractor()
extractor.feed(html_str)
return extractor.get_text()
except Exception:
return re.sub(r"<[^>]+>", " ", html_str).strip()
def extract_body(msg: Any) -> str:
"""Return plain-text body. Strips HTML when no text/plain part exists."""
if msg.is_multipart():
html_fallback: str | None = None
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
try:
charset = part.get_content_charset() or "utf-8"
return part.get_payload(decode=True).decode(charset, errors="replace")
except Exception:
pass
elif ct == "text/html" and html_fallback is None:
try:
charset = part.get_content_charset() or "utf-8"
raw = part.get_payload(decode=True).decode(charset, errors="replace")
html_fallback = strip_html(raw)
except Exception:
pass
return html_fallback or ""
else:
try:
charset = msg.get_content_charset() or "utf-8"
raw = msg.get_payload(decode=True).decode(charset, errors="replace")
if msg.get_content_type() == "text/html":
return strip_html(raw)
return raw
except Exception:
pass
return ""
def read_jsonl(path: Path) -> list[dict]:
"""Read a JSONL file, returning valid records. Skips blank lines and malformed JSON."""
if not path.exists():
return []
records: list[dict] = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
pass
return records
def write_jsonl(path: Path, records: list[dict]) -> None:
"""Write records to a JSONL file, overwriting any existing content."""
path.parent.mkdir(parents=True, exist_ok=True)
content = "\n".join(json.dumps(r) for r in records)
path.write_text(content + ("\n" if records else ""), encoding="utf-8")
def append_jsonl(path: Path, record: dict) -> None:
"""Append a single record to a JSONL file."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record) + "\n")

View file

@ -21,3 +21,8 @@ accounts:
# Optional: limit emails fetched per account per run (0 = unlimited)
max_per_account: 500
# cf-orch SFT candidate import — path to the bench_results/ directory
# produced by circuitforge-orch's benchmark harness.
sft:
bench_results_dir: /path/to/circuitforge-orch/scripts/bench_results

View file

@ -14,6 +14,7 @@ dependencies:
- transformers>=4.40
- torch>=2.2
- accelerate>=0.27
- scikit-learn>=1.4
# Optional: GLiClass adapter
# - gliclass

164
manage.sh
View file

@ -19,9 +19,8 @@ LOG_FILE="${LOG_DIR}/label_tool.log"
DEFAULT_PORT=8503
CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}"
ENV_UI="job-seeker"
ENV_UI="${AVOCET_ENV:-cf}"
ENV_BM="job-seeker-classifiers"
STREAMLIT="${CONDA_BASE}/envs/${ENV_UI}/bin/streamlit"
PYTHON_BM="${CONDA_BASE}/envs/${ENV_BM}/bin/python"
PYTHON_UI="${CONDA_BASE}/envs/${ENV_UI}/bin/python"
@ -79,13 +78,11 @@ usage() {
echo ""
echo " Usage: ./manage.sh <command> [args]"
echo ""
echo " Label tool:"
echo -e " ${GREEN}start${NC} Start label tool UI (port collision-safe)"
echo -e " ${GREEN}stop${NC} Stop label tool UI"
echo -e " ${GREEN}restart${NC} Restart label tool UI"
echo -e " ${GREEN}status${NC} Show running state and port"
echo -e " ${GREEN}logs${NC} Tail label tool log output"
echo -e " ${GREEN}open${NC} Open label tool in browser"
echo " Vue UI + FastAPI:"
echo -e " ${GREEN}start${NC} Build Vue SPA + start FastAPI on port 8503"
echo -e " ${GREEN}stop${NC} Stop FastAPI server"
echo -e " ${GREEN}restart${NC} Stop + rebuild + restart FastAPI server"
echo -e " ${GREEN}open${NC} Open Vue UI in browser (http://localhost:8503)"
echo ""
echo " Benchmark:"
echo -e " ${GREEN}benchmark [args]${NC} Run benchmark_classifier.py (args passed through)"
@ -94,6 +91,7 @@ usage() {
echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]"
echo ""
echo " Dev:"
echo -e " ${GREEN}dev${NC} Hot-reload: uvicorn --reload (:8503) + Vite HMR (:5173)"
echo -e " ${GREEN}test${NC} Run pytest suite"
echo ""
echo " Port defaults to ${DEFAULT_PORT}; auto-increments if occupied."
@ -115,102 +113,102 @@ shift || true
case "$CMD" in
start)
pid=$(_running_pid)
if [[ -n "$pid" ]]; then
port=$(_running_port)
warn "Already running (PID ${pid}) on port ${port} → http://localhost:${port}"
API_PID_FILE=".avocet-api.pid"
API_PORT=8503
if [[ -f "$API_PID_FILE" ]] && kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then
warn "API already running (PID $(<"$API_PID_FILE")) → http://localhost:${API_PORT}"
exit 0
fi
if [[ ! -x "$STREAMLIT" ]]; then
error "Streamlit not found at ${STREAMLIT}\nActivate env: conda run -n ${ENV_UI} ..."
fi
port=$(_find_free_port "$DEFAULT_PORT")
mkdir -p "$LOG_DIR"
info "Starting label tool on port ${port}"
nohup "$STREAMLIT" run app/label_tool.py \
--server.port "$port" \
--server.headless true \
--server.fileWatcherType none \
>"$LOG_FILE" 2>&1 &
pid=$!
echo "$pid" > "$PID_FILE"
echo "$port" > "$PORT_FILE"
# Wait briefly and confirm the process survived
sleep 1
if kill -0 "$pid" 2>/dev/null; then
success "Avocet label tool started → http://localhost:${port} (PID ${pid})"
success "Logs: ${LOG_FILE}"
else
rm -f "$PID_FILE" "$PORT_FILE"
error "Process died immediately. Check ${LOG_FILE} for details."
API_LOG="${LOG_DIR}/api.log"
info "Building Vue SPA…"
(cd web && npm run build) >> "$API_LOG" 2>&1
info "Starting FastAPI on port ${API_PORT}"
nohup "$PYTHON_UI" -m uvicorn app.api:app \
--host 0.0.0.0 --port "$API_PORT" \
>> "$API_LOG" 2>&1 &
echo $! > "$API_PID_FILE"
# Poll until port is actually bound (up to 10 s), not just process alive
for _i in $(seq 1 20); do
sleep 0.5
if (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then
success "Avocet started → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))"
break
fi
if ! kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then
rm -f "$API_PID_FILE"
error "Server died during startup. Check ${API_LOG}"
fi
done
if ! (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then
error "Server did not bind to port ${API_PORT} within 10 s. Check ${API_LOG}"
fi
;;
stop)
pid=$(_running_pid)
if [[ -z "$pid" ]]; then
API_PID_FILE=".avocet-api.pid"
if [[ ! -f "$API_PID_FILE" ]]; then
warn "Not running."
exit 0
fi
info "Stopping label tool (PID ${pid})…"
kill "$pid"
# Wait up to 5 s for clean exit
for _ in $(seq 1 10); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
if kill -0 "$pid" 2>/dev/null; then
warn "Process did not exit cleanly; sending SIGKILL…"
kill -9 "$pid" 2>/dev/null || true
PID="$(<"$API_PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" && rm -f "$API_PID_FILE"
success "Stopped (PID ${PID})."
else
warn "Stale PID file (process ${PID} not running). Cleaning up."
rm -f "$API_PID_FILE"
fi
rm -f "$PID_FILE" "$PORT_FILE"
success "Stopped."
;;
restart)
pid=$(_running_pid)
if [[ -n "$pid" ]]; then
info "Stopping existing process (PID ${pid})…"
kill "$pid"
for _ in $(seq 1 10); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
rm -f "$PID_FILE" "$PORT_FILE"
fi
bash "$0" stop
exec bash "$0" start
;;
status)
pid=$(_running_pid)
if [[ -n "$pid" ]]; then
port=$(_running_port)
success "Running — PID ${pid} port ${port} → http://localhost:${port}"
else
warn "Not running."
fi
;;
dev)
API_PORT=8503
VITE_PORT=5173
DEV_API_PID_FILE=".avocet-dev-api.pid"
mkdir -p "$LOG_DIR"
DEV_API_LOG="${LOG_DIR}/dev-api.log"
logs)
if [[ ! -f "$LOG_FILE" ]]; then
warn "No log file found at ${LOG_FILE}. Has the tool been started?"
exit 0
if [[ -f "$DEV_API_PID_FILE" ]] && kill -0 "$(<"$DEV_API_PID_FILE")" 2>/dev/null; then
warn "Dev API already running (PID $(<"$DEV_API_PID_FILE"))"
else
info "Starting uvicorn with --reload on port ${API_PORT}"
nohup "$PYTHON_UI" -m uvicorn app.api:app \
--host 0.0.0.0 --port "$API_PORT" --reload \
>> "$DEV_API_LOG" 2>&1 &
echo $! > "$DEV_API_PID_FILE"
# Wait for API to bind
for _i in $(seq 1 20); do
sleep 0.5
(echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null && break
if ! kill -0 "$(<"$DEV_API_PID_FILE")" 2>/dev/null; then
rm -f "$DEV_API_PID_FILE"
error "Dev API died during startup. Check ${DEV_API_LOG}"
fi
info "Tailing ${LOG_FILE} (Ctrl-C to stop)"
tail -f "$LOG_FILE"
done
success "API (hot-reload) → http://localhost:${API_PORT}"
fi
# Kill API on exit (Ctrl+C or Vite exits)
_cleanup_dev() {
local pid
pid=$(<"$DEV_API_PID_FILE" 2>/dev/null || true)
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null && rm -f "$DEV_API_PID_FILE"
info "Dev servers stopped."
}
trap _cleanup_dev EXIT INT TERM
info "Starting Vite HMR on port ${VITE_PORT} (proxy /api → :${API_PORT})…"
success "Frontend (HMR) → http://localhost:${VITE_PORT}"
(cd web && npm run dev -- --host 0.0.0.0 --port "$VITE_PORT")
;;
open)
port=$(_running_port)
pid=$(_running_pid)
[[ -z "$pid" ]] && warn "Label tool does not appear to be running. Start with: ./manage.sh start"
URL="http://localhost:${port}"
URL="http://localhost:8503"
info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then
xdg-open "$URL"

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
fastapi>=0.100.0
pydantic>=2.0.0
uvicorn[standard]>=0.20.0
httpx>=0.24.0
pytest>=7.0.0

View file

@ -32,10 +32,14 @@ from typing import Any
sys.path.insert(0, str(Path(__file__).parent.parent))
_ROOT = Path(__file__).parent.parent
_MODELS_DIR = _ROOT / "models"
from scripts.classifier_adapters import (
LABELS,
LABEL_DESCRIPTIONS,
ClassifierAdapter,
FineTunedAdapter,
GLiClassAdapter,
RerankerAdapter,
ZeroShotAdapter,
@ -150,8 +154,55 @@ def load_scoring_jsonl(path: str) -> list[dict[str, str]]:
return rows
def _active_models(include_slow: bool) -> dict[str, dict[str, Any]]:
return {k: v for k, v in MODEL_REGISTRY.items() if v["default"] or include_slow}
def discover_finetuned_models(models_dir: Path | None = None) -> list[dict]:
"""Scan models/ for subdirs containing training_info.json.
Returns a list of training_info dicts, each with an added 'model_dir' key.
Returns [] silently if models_dir does not exist.
"""
if models_dir is None:
models_dir = _MODELS_DIR
if not models_dir.exists():
return []
found = []
for sub in models_dir.iterdir():
if not sub.is_dir():
continue
info_path = sub / "training_info.json"
if not info_path.exists():
continue
try:
info = json.loads(info_path.read_text(encoding="utf-8"))
except Exception as exc:
print(f"[discover] WARN: skipping {info_path}: {exc}", flush=True)
continue
if "name" not in info:
print(f"[discover] WARN: skipping {info_path}: missing 'name' key", flush=True)
continue
info["model_dir"] = str(sub)
found.append(info)
return found
def _active_models(include_slow: bool = False) -> dict[str, dict[str, Any]]:
"""Return the active model registry, merged with any discovered fine-tuned models."""
active: dict[str, dict[str, Any]] = {
key: {**entry, "adapter_instance": entry["adapter"](
key,
entry["model_id"],
**entry.get("kwargs", {}),
)}
for key, entry in MODEL_REGISTRY.items()
if include_slow or entry.get("default", False)
}
for info in discover_finetuned_models():
name = info["name"]
active[name] = {
"adapter_instance": FineTunedAdapter(name, info["model_dir"]),
"params": "fine-tuned",
"default": True,
}
return active
def run_scoring(
@ -163,7 +214,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 +229,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
@ -345,10 +398,7 @@ def cmd_score(args: argparse.Namespace) -> None:
if args.models:
active = {k: v for k, v in active.items() if k in args.models}
adapters = [
entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {}))
for name, entry in active.items()
]
adapters = [entry["adapter_instance"] for entry in active.values()]
print(f"\nScoring {len(adapters)} model(s) against {args.score_file}\n")
results = run_scoring(adapters, args.score_file)
@ -375,6 +425,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)
@ -385,10 +460,7 @@ def cmd_compare(args: argparse.Namespace) -> None:
emails = _fetch_imap_sample(args.limit, args.days)
print(f"Fetched {len(emails)} emails. Loading {len(active)} model(s) …\n")
adapters = [
entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {}))
for name, entry in active.items()
]
adapters = [entry["adapter_instance"] for entry in active.values()]
model_names = [a.name for a in adapters]
col = 22
@ -431,6 +503,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()

View file

@ -17,6 +17,7 @@ __all__ = [
"ZeroShotAdapter",
"GLiClassAdapter",
"RerankerAdapter",
"FineTunedAdapter",
]
LABELS: list[str] = [
@ -27,8 +28,9 @@ LABELS: list[str] = [
"survey_received",
"neutral",
"event_rescheduled",
"unrelated",
"digest",
"new_lead",
"hired",
]
# Natural-language descriptions used by the RerankerAdapter.
@ -40,8 +42,9 @@ LABEL_DESCRIPTIONS: dict[str, str] = {
"survey_received": "invitation to complete a culture-fit survey or assessment",
"neutral": "automated ATS confirmation such as application received",
"event_rescheduled": "an interview or scheduled event moved to a new time",
"unrelated": "non-job-search email unrelated to any application or recruiter",
"digest": "job digest or multi-listing email with multiple job postings",
"new_lead": "unsolicited recruiter outreach or cold contact about a new opportunity",
"hired": "job offer accepted, onboarding logistics, welcome email, or start date confirmation",
}
# Lazy import shims — allow tests to patch without requiring the libs installed.
@ -261,3 +264,43 @@ class RerankerAdapter(ClassifierAdapter):
pairs = [[text, LABEL_DESCRIPTIONS.get(label, label.replace("_", " "))] for label in LABELS]
scores: list[float] = self._reranker.compute_score(pairs, normalize=True)
return LABELS[scores.index(max(scores))]
class FineTunedAdapter(ClassifierAdapter):
"""Loads a fine-tuned checkpoint from a local models/ directory.
Uses pipeline("text-classification") for a single forward pass.
Input format: 'subject [SEP] body[:400]' must match training format exactly.
Expected inference speed: ~1020ms/email vs 111338ms for zero-shot.
"""
def __init__(self, name: str, model_dir: str) -> None:
self._name = name
self._model_dir = model_dir
self._pipeline: Any = None
@property
def name(self) -> str:
return self._name
@property
def model_id(self) -> str:
return self._model_dir
def load(self) -> None:
import scripts.classifier_adapters as _mod # noqa: PLC0415
_pipe_fn = _mod.pipeline
if _pipe_fn is None:
raise ImportError("transformers not installed — run: pip install transformers")
device = 0 if _cuda_available() else -1
self._pipeline = _pipe_fn("text-classification", model=self._model_dir, device=device)
def unload(self) -> None:
self._pipeline = None
def classify(self, subject: str, body: str) -> str:
if self._pipeline is None:
self.load()
text = f"{subject} [SEP] {body[:400]}"
result = self._pipeline(text)
return result[0]["label"]

View file

@ -0,0 +1,416 @@
"""Fine-tune email classifiers on the labeled dataset.
CLI entry point. All prints use flush=True so stdout is SSE-streamable.
Usage:
python scripts/finetune_classifier.py --model deberta-small [--epochs 5]
Supported --model values: deberta-small, bge-m3
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset as TorchDataset
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score, accuracy_score
from transformers import (
AutoTokenizer,
AutoModelForSequenceClassification,
EvalPrediction,
Trainer,
TrainingArguments,
EarlyStoppingCallback,
)
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.classifier_adapters import LABELS
_ROOT = Path(__file__).parent.parent
_MODEL_CONFIG: dict[str, dict[str, Any]] = {
"deberta-small": {
"base_model_id": "cross-encoder/nli-deberta-v3-small",
"max_tokens": 512,
# fp16 must stay OFF — DeBERTa-v3 disentangled attention overflows fp16.
"fp16": False,
# batch_size=8 + grad_accum=2 keeps effective batch of 16 while halving
# per-step activation memory. gradient_checkpointing recomputes activations
# on backward instead of storing them — ~60% less activation VRAM.
"batch_size": 8,
"grad_accum": 2,
"gradient_checkpointing": True,
},
"bge-m3": {
"base_model_id": "MoritzLaurer/bge-m3-zeroshot-v2.0",
"max_tokens": 512,
"fp16": True,
"batch_size": 4,
"grad_accum": 4,
"gradient_checkpointing": True,
},
}
def load_and_prepare_data(score_files: Path | list[Path]) -> tuple[list[str], list[str]]:
"""Load labeled JSONL and return (texts, labels) filtered to canonical LABELS.
score_files: a single Path or a list of Paths. When multiple files are given,
rows are merged with last-write-wins deduplication keyed by content hash
(MD5 of subject + body[:100]).
Drops rows with non-canonical labels (with warning), and drops entire classes
that have fewer than 2 total samples (required for stratified split).
Warns (but continues) for classes with fewer than 5 samples.
"""
# Normalise to list — backwards compatible with single-Path callers.
if isinstance(score_files, Path):
score_files = [score_files]
for score_file in score_files:
if not score_file.exists():
raise FileNotFoundError(
f"Labeled data not found: {score_file}\n"
"Run the label tool first to generate email_score.jsonl."
)
label_set = set(LABELS)
# Use a plain dict keyed by content hash; later entries overwrite earlier ones
# (last-write wins), which lets later labeling runs correct earlier labels.
seen: dict[str, dict] = {}
total = 0
for score_file in score_files:
with score_file.open() as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except json.JSONDecodeError:
continue
lbl = r.get("label", "")
if lbl not in label_set:
print(
f"[data] WARNING: Dropping row with non-canonical label {lbl!r}",
flush=True,
)
continue
content_hash = hashlib.md5(
(r.get("subject", "") + (r.get("body", "") or "")[:100]).encode(
"utf-8", errors="replace"
)
).hexdigest()
seen[content_hash] = r
total += 1
kept = len(seen)
dropped = total - kept
if dropped > 0:
print(
f"[data] Deduped: kept {kept} of {total} rows (dropped {dropped} duplicates)",
flush=True,
)
rows = list(seen.values())
# Count samples per class
counts: Counter = Counter(r["label"] for r in rows)
# Drop classes with < 2 total samples (cannot stratify-split)
drop_classes: set[str] = set()
for lbl, cnt in counts.items():
if cnt < 2:
print(
f"[data] WARNING: Dropping class {lbl!r} — only {counts[lbl]} total "
f"sample(s). Need at least 2 for stratified split.",
flush=True,
)
drop_classes.add(lbl)
# Warn for classes with < 5 samples (unreliable eval F1)
for lbl, cnt in counts.items():
if lbl not in drop_classes and cnt < 5:
print(
f"[data] WARNING: Class {lbl!r} has only {cnt} sample(s). "
f"Eval F1 for this class will be unreliable.",
flush=True,
)
# Filter rows
rows = [r for r in rows if r["label"] not in drop_classes]
texts = [f"{r['subject']} [SEP] {r['body'][:400]}" for r in rows]
labels = [r["label"] for r in rows]
return texts, labels
def compute_class_weights(label_ids: list[int], n_classes: int) -> torch.Tensor:
"""Compute inverse-frequency class weights.
Formula: total / (n_classes * class_count) per class.
Unseen classes (count=0) use count=1 to avoid division by zero.
Returns a CPU float32 tensor of shape (n_classes,).
"""
counts = Counter(label_ids)
total = len(label_ids)
weights = []
for cls in range(n_classes):
cnt = counts.get(cls, 1) # use 1 for unseen to avoid div-by-zero
weights.append(total / (n_classes * cnt))
return torch.tensor(weights, dtype=torch.float32)
def compute_metrics_for_trainer(eval_pred: EvalPrediction) -> dict:
"""Compute macro F1 and accuracy from EvalPrediction.
Called by Hugging Face Trainer at each evaluation step.
"""
logits, label_ids = eval_pred.predictions, eval_pred.label_ids
preds = logits.argmax(axis=-1)
macro_f1 = f1_score(label_ids, preds, average="macro", zero_division=0)
acc = accuracy_score(label_ids, preds)
return {"macro_f1": float(macro_f1), "accuracy": float(acc)}
class WeightedTrainer(Trainer):
"""Trainer subclass that applies per-class weights to the cross-entropy loss."""
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
# **kwargs is required — absorbs num_items_in_batch added in Transformers 4.38.
# Do not remove it; removing it causes TypeError on the first training step.
labels = inputs.pop("labels")
outputs = model(**inputs)
# Move class_weights to the same device as logits — required for GPU training.
# class_weights is created on CPU; logits are on cuda:0 during training.
weight = self.class_weights.to(outputs.logits.device)
loss = F.cross_entropy(outputs.logits, labels, weight=weight)
return (loss, outputs) if return_outputs else loss
# ---------------------------------------------------------------------------
# Training dataset wrapper
# ---------------------------------------------------------------------------
class _EmailDataset(TorchDataset):
def __init__(self, encodings: dict, label_ids: list[int]) -> None:
self.encodings = encodings
self.label_ids = label_ids
def __len__(self) -> int:
return len(self.label_ids)
def __getitem__(self, idx: int) -> dict:
item = {k: torch.tensor(v[idx]) for k, v in self.encodings.items()}
item["labels"] = torch.tensor(self.label_ids[idx], dtype=torch.long)
return item
# ---------------------------------------------------------------------------
# Main training function
# ---------------------------------------------------------------------------
def run_finetune(model_key: str, epochs: int = 5, score_files: list[Path] | None = None) -> None:
"""Fine-tune the specified model on labeled data.
score_files: list of score JSONL paths to merge. Defaults to [_ROOT / "data" / "email_score.jsonl"].
Saves model + tokenizer + training_info.json to models/avocet-{model_key}/.
All prints use flush=True for SSE streaming.
"""
if model_key not in _MODEL_CONFIG:
raise ValueError(f"Unknown model key: {model_key!r}. Choose from: {list(_MODEL_CONFIG)}")
if score_files is None:
score_files = [_ROOT / "data" / "email_score.jsonl"]
config = _MODEL_CONFIG[model_key]
base_model_id = config["base_model_id"]
output_dir = _ROOT / "models" / f"avocet-{model_key}"
print(f"[finetune] Model: {model_key} ({base_model_id})", flush=True)
print(f"[finetune] Score files: {[str(f) for f in score_files]}", flush=True)
print(f"[finetune] Output: {output_dir}", flush=True)
if output_dir.exists():
print(f"[finetune] WARNING: {output_dir} already exists — will overwrite.", flush=True)
# --- Data ---
print(f"[finetune] Loading data ...", flush=True)
texts, str_labels = load_and_prepare_data(score_files)
present_labels = sorted(set(str_labels))
label2id = {l: i for i, l in enumerate(present_labels)}
id2label = {i: l for l, i in label2id.items()}
n_classes = len(present_labels)
label_ids = [label2id[l] for l in str_labels]
print(f"[finetune] {len(texts)} samples, {n_classes} classes", flush=True)
# Stratified 80/20 split — ensure val set has at least n_classes samples.
# For very small datasets (e.g. example data) we may need to give the val set
# more than 20% so every class appears at least once in eval.
desired_test = max(int(len(texts) * 0.2), n_classes)
# test_size must leave at least n_classes samples for train too
desired_test = min(desired_test, len(texts) - n_classes)
(train_texts, val_texts,
train_label_ids, val_label_ids) = train_test_split(
texts, label_ids,
test_size=desired_test,
stratify=label_ids,
random_state=42,
)
print(f"[finetune] Train: {len(train_texts)}, Val: {len(val_texts)}", flush=True)
# Warn for classes with < 5 training samples
train_counts = Counter(train_label_ids)
for cls_id, cnt in train_counts.items():
if cnt < 5:
print(
f"[finetune] WARNING: Class {id2label[cls_id]!r} has {cnt} training sample(s). "
"Eval F1 for this class will be unreliable.",
flush=True,
)
# --- Tokenize ---
print(f"[finetune] Loading tokenizer ...", flush=True)
tokenizer = AutoTokenizer.from_pretrained(base_model_id)
train_enc = tokenizer(train_texts, truncation=True,
max_length=config["max_tokens"], padding=True)
val_enc = tokenizer(val_texts, truncation=True,
max_length=config["max_tokens"], padding=True)
train_dataset = _EmailDataset(train_enc, train_label_ids)
val_dataset = _EmailDataset(val_enc, val_label_ids)
# --- Class weights ---
class_weights = compute_class_weights(train_label_ids, n_classes)
print(f"[finetune] Class weights computed", flush=True)
# --- Model ---
print(f"[finetune] Loading model ...", flush=True)
model = AutoModelForSequenceClassification.from_pretrained(
base_model_id,
num_labels=n_classes,
ignore_mismatched_sizes=True, # NLI head (3-class) → new head (n_classes)
id2label=id2label,
label2id=label2id,
)
if config["gradient_checkpointing"]:
# use_reentrant=False avoids "backward through graph a second time" errors
# when Accelerate's gradient accumulation context is layered on top.
# Reentrant checkpointing (the default) conflicts with Accelerate ≥ 0.27.
model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False}
)
# --- TrainingArguments ---
training_args = TrainingArguments(
output_dir=str(output_dir),
num_train_epochs=epochs,
per_device_train_batch_size=config["batch_size"],
per_device_eval_batch_size=config["batch_size"],
gradient_accumulation_steps=config["grad_accum"],
learning_rate=2e-5,
lr_scheduler_type="linear",
warmup_ratio=0.1,
fp16=config["fp16"],
eval_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="macro_f1",
greater_is_better=True,
logging_steps=10,
report_to="none",
save_total_limit=2,
)
trainer = WeightedTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
compute_metrics=compute_metrics_for_trainer,
callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],
)
trainer.class_weights = class_weights
# --- Train ---
print(f"[finetune] Starting training ({epochs} epochs) ...", flush=True)
train_result = trainer.train()
print(f"[finetune] Training complete. Steps: {train_result.global_step}", flush=True)
# --- Evaluate ---
print(f"[finetune] Evaluating best checkpoint ...", flush=True)
metrics = trainer.evaluate()
val_macro_f1 = metrics.get("eval_macro_f1", 0.0)
val_accuracy = metrics.get("eval_accuracy", 0.0)
print(f"[finetune] Val macro-F1: {val_macro_f1:.4f}, Accuracy: {val_accuracy:.4f}", flush=True)
# --- Save model + tokenizer ---
print(f"[finetune] Saving model to {output_dir} ...", flush=True)
trainer.save_model(str(output_dir))
tokenizer.save_pretrained(str(output_dir))
# --- Write training_info.json ---
label_counts = dict(Counter(str_labels))
info = {
"name": f"avocet-{model_key}",
"base_model_id": base_model_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"epochs_run": epochs,
"val_macro_f1": round(val_macro_f1, 4),
"val_accuracy": round(val_accuracy, 4),
"sample_count": len(texts),
"train_sample_count": len(train_texts),
"label_counts": label_counts,
"score_files": [str(f) for f in score_files],
}
info_path = output_dir / "training_info.json"
info_path.write_text(json.dumps(info, indent=2), encoding="utf-8")
print(f"[finetune] Saved training_info.json: val_macro_f1={val_macro_f1:.4f}", flush=True)
print(f"[finetune] Done.", flush=True)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Fine-tune an email classifier")
parser.add_argument(
"--model",
choices=list(_MODEL_CONFIG),
required=True,
help="Model key to fine-tune",
)
parser.add_argument(
"--epochs",
type=int,
default=5,
help="Number of training epochs (default: 5)",
)
parser.add_argument(
"--score",
dest="score_files",
type=Path,
action="append",
metavar="FILE",
help="Score JSONL file to include (repeatable; defaults to data/email_score.jsonl)",
)
args = parser.parse_args()
score_files = args.score_files or None # None → run_finetune uses default
run_finetune(args.model, args.epochs, score_files=score_files)

110
scripts/sft_import.py Normal file
View file

@ -0,0 +1,110 @@
"""Avocet — SFT candidate run discovery and JSONL import.
No FastAPI dependency pure Python file operations.
Used by app/sft.py endpoints and can be run standalone.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
_CANDIDATES_FILENAME = "sft_candidates.jsonl"
def discover_runs(bench_results_dir: Path) -> list[dict]:
"""Return one entry per run subdirectory that contains sft_candidates.jsonl.
Sorted newest-first by directory name (directories are named YYYY-MM-DD-HHMMSS
by the cf-orch benchmark harness, so lexicographic order is chronological).
Each entry: {run_id, timestamp, candidate_count, sft_path}
"""
if not bench_results_dir.exists() or not bench_results_dir.is_dir():
return []
runs = []
for subdir in bench_results_dir.iterdir():
if not subdir.is_dir():
continue
sft_path = subdir / _CANDIDATES_FILENAME
if not sft_path.exists():
continue
records = _read_jsonl(sft_path)
runs.append({
"run_id": subdir.name,
"timestamp": subdir.name,
"candidate_count": len(records),
"sft_path": sft_path,
})
runs.sort(key=lambda r: r["run_id"], reverse=True)
return runs
def import_run(sft_path: Path, data_dir: Path) -> dict[str, int]:
"""Append records from sft_path into data_dir/sft_candidates.jsonl.
Deduplicates on the `id` field records whose id already exists in the
destination file are skipped silently. Records missing an `id` field are
also skipped (malformed input from a partial benchmark write).
Returns {imported: N, skipped: M}.
"""
dest = data_dir / _CANDIDATES_FILENAME
existing_ids = _read_existing_ids(dest)
new_records: list[dict] = []
skipped = 0
for record in _read_jsonl(sft_path):
if "id" not in record:
logger.warning("Skipping record missing 'id' field in %s", sft_path)
continue # malformed — skip without crashing
if record["id"] in existing_ids:
skipped += 1
continue
new_records.append(record)
existing_ids.add(record["id"])
if new_records:
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "a", encoding="utf-8") as fh:
for r in new_records:
fh.write(json.dumps(r) + "\n")
return {"imported": len(new_records), "skipped": skipped}
def _read_jsonl(path: Path) -> list[dict]:
"""Read a JSONL file, returning valid records. Skips blank lines and malformed JSON."""
if not path.exists():
return []
records: list[dict] = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError as exc:
logger.warning("Skipping malformed JSON line in %s: %s", path, exc)
return records
def _read_existing_ids(path: Path) -> set[str]:
"""Read only the id field from each line of a JSONL file."""
if not path.exists():
return set()
ids: set[str] = set()
with path.open() as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
if "id" in record:
ids.add(record["id"])
except json.JSONDecodeError:
pass # corrupt line, skip silently (ids file is our own output)
return ids

561
tests/test_api.py Normal file
View file

@ -0,0 +1,561 @@
import json
import pytest
from app import api as api_module # noqa: F401
@pytest.fixture(autouse=True)
def reset_globals(tmp_path):
from app import api
api.set_data_dir(tmp_path)
api.reset_last_action()
yield
api.reset_last_action()
def test_import():
from app import api # noqa: F401
from fastapi.testclient import TestClient
@pytest.fixture
def client():
from app.api import app
return TestClient(app)
@pytest.fixture
def queue_with_items():
"""Write 3 test emails to the queue file."""
from app import api as api_module
items = [
{"id": f"id{i}", "subject": f"Subject {i}", "body": f"Body {i}",
"from": "test@example.com", "date": "2026-03-01", "source": "imap:test"}
for i in range(3)
]
queue_path = api_module._DATA_DIR / "email_label_queue.jsonl"
queue_path.write_text("\n".join(json.dumps(x) for x in items) + "\n")
return items
def test_queue_returns_items(client, queue_with_items):
r = client.get("/api/queue?limit=2")
assert r.status_code == 200
data = r.json()
assert len(data["items"]) == 2
assert data["total"] == 3
def test_queue_empty_when_no_file(client):
r = client.get("/api/queue")
assert r.status_code == 200
assert r.json() == {"items": [], "total": 0}
def test_label_appends_to_score(client, queue_with_items):
from app import api as api_module
r = client.post("/api/label", json={"id": "id0", "label": "interview_scheduled"})
assert r.status_code == 200
records = api_module._read_jsonl(api_module._score_file())
assert len(records) == 1
assert records[0]["id"] == "id0"
assert records[0]["label"] == "interview_scheduled"
assert "labeled_at" in records[0]
def test_label_removes_from_queue(client, queue_with_items):
from app import api as api_module
client.post("/api/label", json={"id": "id0", "label": "rejected"})
queue = api_module._read_jsonl(api_module._queue_file())
assert not any(x["id"] == "id0" for x in queue)
def test_label_unknown_id_returns_404(client, queue_with_items):
r = client.post("/api/label", json={"id": "unknown", "label": "neutral"})
assert r.status_code == 404
def test_skip_moves_to_back(client, queue_with_items):
from app import api as api_module
r = client.post("/api/skip", json={"id": "id0"})
assert r.status_code == 200
queue = api_module._read_jsonl(api_module._queue_file())
assert queue[-1]["id"] == "id0"
assert queue[0]["id"] == "id1"
def test_skip_unknown_id_returns_404(client, queue_with_items):
r = client.post("/api/skip", json={"id": "nope"})
assert r.status_code == 404
# --- Part A: POST /api/discard ---
def test_discard_writes_to_discarded_file(client, queue_with_items):
from app import api as api_module
r = client.post("/api/discard", json={"id": "id1"})
assert r.status_code == 200
discarded = api_module._read_jsonl(api_module._discarded_file())
assert len(discarded) == 1
assert discarded[0]["id"] == "id1"
assert discarded[0]["label"] == "__discarded__"
def test_discard_removes_from_queue(client, queue_with_items):
from app import api as api_module
client.post("/api/discard", json={"id": "id1"})
queue = api_module._read_jsonl(api_module._queue_file())
assert not any(x["id"] == "id1" for x in queue)
# --- Part B: DELETE /api/label/undo ---
def test_undo_label_removes_from_score(client, queue_with_items):
from app import api as api_module
client.post("/api/label", json={"id": "id0", "label": "neutral"})
r = client.delete("/api/label/undo")
assert r.status_code == 200
data = r.json()
assert data["undone"]["type"] == "label"
score = api_module._read_jsonl(api_module._score_file())
assert score == []
# Item should be restored to front of queue
queue = api_module._read_jsonl(api_module._queue_file())
assert queue[0]["id"] == "id0"
def test_undo_discard_removes_from_discarded(client, queue_with_items):
from app import api as api_module
client.post("/api/discard", json={"id": "id0"})
r = client.delete("/api/label/undo")
assert r.status_code == 200
discarded = api_module._read_jsonl(api_module._discarded_file())
assert discarded == []
def test_undo_skip_restores_to_front(client, queue_with_items):
from app import api as api_module
client.post("/api/skip", json={"id": "id0"})
r = client.delete("/api/label/undo")
assert r.status_code == 200
queue = api_module._read_jsonl(api_module._queue_file())
assert queue[0]["id"] == "id0"
def test_undo_with_no_action_returns_404(client):
r = client.delete("/api/label/undo")
assert r.status_code == 404
# --- Part C: GET /api/config/labels ---
def test_config_labels_returns_metadata(client):
r = client.get("/api/config/labels")
assert r.status_code == 200
labels = r.json()
assert len(labels) == 10
assert labels[0]["key"] == "1"
assert "emoji" in labels[0]
assert "color" in labels[0]
assert "name" in labels[0]
# ── /api/config ──────────────────────────────────────────────────────────────
@pytest.fixture
def config_dir(tmp_path):
"""Give the API a writable config directory."""
from app import api as api_module
api_module.set_config_dir(tmp_path)
yield tmp_path
api_module.set_config_dir(None) # reset to default
@pytest.fixture
def data_dir():
"""Expose the current _DATA_DIR set by the autouse reset_globals fixture."""
from app import api as api_module
return api_module._DATA_DIR
def test_get_config_returns_empty_when_no_file(client, config_dir):
r = client.get("/api/config")
assert r.status_code == 200
data = r.json()
assert data["accounts"] == []
assert data["max_per_account"] == 500
def test_post_config_writes_yaml(client, config_dir):
import yaml
payload = {
"accounts": [{"name": "Test", "host": "imap.test.com", "port": 993,
"use_ssl": True, "username": "u@t.com", "password": "pw",
"folder": "INBOX", "days_back": 30}],
"max_per_account": 200,
}
r = client.post("/api/config", json=payload)
assert r.status_code == 200
assert r.json()["ok"] is True
cfg_file = config_dir / "label_tool.yaml"
assert cfg_file.exists()
saved = yaml.safe_load(cfg_file.read_text())
assert saved["max_per_account"] == 200
assert saved["accounts"][0]["name"] == "Test"
def test_get_config_round_trips(client, config_dir):
payload = {"accounts": [{"name": "R", "host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX",
"days_back": 90}], "max_per_account": 300}
client.post("/api/config", json=payload)
r = client.get("/api/config")
data = r.json()
assert data["max_per_account"] == 300
assert data["accounts"][0]["name"] == "R"
# ── /api/stats ───────────────────────────────────────────────────────────────
@pytest.fixture
def score_with_labels(tmp_path, data_dir):
"""Write a score file with 3 labels for stats tests."""
score_path = data_dir / "email_score.jsonl"
records = [
{"id": "a", "label": "interview_scheduled"},
{"id": "b", "label": "interview_scheduled"},
{"id": "c", "label": "rejected"},
]
score_path.write_text("\n".join(json.dumps(r) for r in records) + "\n")
return records
def test_stats_returns_counts(client, score_with_labels):
r = client.get("/api/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 3
assert data["counts"]["interview_scheduled"] == 2
assert data["counts"]["rejected"] == 1
def test_stats_empty_when_no_file(client, data_dir):
r = client.get("/api/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 0
assert data["counts"] == {}
assert data["score_file_bytes"] == 0
def test_stats_download_returns_file(client, score_with_labels):
r = client.get("/api/stats/download")
assert r.status_code == 200
assert "jsonlines" in r.headers.get("content-type", "")
def test_stats_download_404_when_no_file(client, data_dir):
r = client.get("/api/stats/download")
assert r.status_code == 404
# ── /api/accounts/test ───────────────────────────────────────────────────────
def test_account_test_missing_fields(client):
r = client.post("/api/accounts/test", json={"account": {"host": "", "username": "", "password": ""}})
assert r.status_code == 200
data = r.json()
assert data["ok"] is False
assert "required" in data["message"].lower()
def test_account_test_success(client):
from unittest.mock import MagicMock, patch
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"99"])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
r = client.post("/api/accounts/test", json={"account": {
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "pw", "folder": "INBOX",
}})
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["count"] == 99
# ── /api/fetch/stream (SSE) ──────────────────────────────────────────────────
def _parse_sse(content: bytes) -> list[dict]:
"""Parse SSE response body into list of event dicts."""
events = []
for line in content.decode().splitlines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
return events
def test_fetch_stream_no_accounts_configured(client, config_dir):
"""With no config, stream should immediately complete with 0 added."""
r = client.get("/api/fetch/stream?accounts=NoSuchAccount&days_back=30&limit=10")
assert r.status_code == 200
events = _parse_sse(r.content)
complete = next((e for e in events if e["type"] == "complete"), None)
assert complete is not None
assert complete["total_added"] == 0
def test_fetch_stream_with_mock_imap(client, config_dir, data_dir):
"""With one configured account, stream should yield start/done/complete events."""
import yaml
from unittest.mock import MagicMock, patch
# Write a config with one account
cfg = {"accounts": [{"name": "Mock", "host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX",
"days_back": 30}], "max_per_account": 50}
(config_dir / "label_tool.yaml").write_text(yaml.dump(cfg))
raw_msg = (b"Subject: Interview\r\nFrom: a@b.com\r\n"
b"Date: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody")
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1"])
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
r = client.get("/api/fetch/stream?accounts=Mock&days_back=30&limit=50")
assert r.status_code == 200
events = _parse_sse(r.content)
types = [e["type"] for e in events]
assert "start" in types
assert "done" in types
assert "complete" in types
# ---- /api/finetune/status tests ----
def test_finetune_status_returns_empty_when_no_models_dir(client):
"""GET /api/finetune/status must return [] if models/ does not exist."""
r = client.get("/api/finetune/status")
assert r.status_code == 200
assert r.json() == []
def test_finetune_status_returns_training_info(client, tmp_path):
"""GET /api/finetune/status must return one entry per training_info.json found."""
import json as _json
from app import api as api_module
models_dir = tmp_path / "models" / "avocet-deberta-small"
models_dir.mkdir(parents=True)
info = {
"name": "avocet-deberta-small",
"base_model_id": "cross-encoder/nli-deberta-v3-small",
"val_macro_f1": 0.712,
"timestamp": "2026-03-15T12:00:00Z",
"sample_count": 401,
}
(models_dir / "training_info.json").write_text(_json.dumps(info))
api_module.set_models_dir(tmp_path / "models")
try:
r = client.get("/api/finetune/status")
assert r.status_code == 200
data = r.json()
assert any(d["name"] == "avocet-deberta-small" for d in data)
finally:
api_module.set_models_dir(api_module._ROOT / "models")
def test_finetune_run_streams_sse_events(client):
"""GET /api/finetune/run must return text/event-stream content type."""
from unittest.mock import patch, MagicMock
mock_proc = MagicMock()
mock_proc.stdout = iter(["Training epoch 1\n", "Done\n"])
mock_proc.returncode = 0
mock_proc.wait = MagicMock()
with patch("app.api._subprocess.Popen",return_value=mock_proc):
r = client.get("/api/finetune/run?model=deberta-small&epochs=1")
assert r.status_code == 200
assert "text/event-stream" in r.headers.get("content-type", "")
def test_finetune_run_emits_complete_on_success(client):
"""GET /api/finetune/run must emit a complete event on clean exit."""
from unittest.mock import patch, MagicMock
mock_proc = MagicMock()
mock_proc.stdout = iter(["progress line\n"])
mock_proc.returncode = 0
mock_proc.wait = MagicMock()
with patch("app.api._subprocess.Popen",return_value=mock_proc):
r = client.get("/api/finetune/run?model=deberta-small&epochs=1")
assert '{"type": "complete"}' in r.text
def test_finetune_run_emits_error_on_nonzero_exit(client):
"""GET /api/finetune/run must emit an error event on non-zero exit."""
from unittest.mock import patch, MagicMock
mock_proc = MagicMock()
mock_proc.stdout = iter([])
mock_proc.returncode = 1
mock_proc.wait = MagicMock()
with patch("app.api._subprocess.Popen",return_value=mock_proc):
r = client.get("/api/finetune/run?model=deberta-small&epochs=1")
assert '"type": "error"' in r.text
def test_finetune_run_passes_score_files_to_subprocess(client):
"""GET /api/finetune/run?score=file1&score=file2 must pass --score args to subprocess."""
from unittest.mock import patch, MagicMock
captured_cmd = []
def mock_popen(cmd, **kwargs):
captured_cmd.extend(cmd)
m = MagicMock()
m.stdout = iter([])
m.returncode = 0
m.wait = MagicMock()
return m
with patch("app.api._subprocess.Popen",side_effect=mock_popen):
client.get("/api/finetune/run?model=deberta-small&epochs=1&score=run1.jsonl&score=run2.jsonl")
assert "--score" in captured_cmd
assert captured_cmd.count("--score") == 2
# Paths are resolved to absolute — check filenames are present as substrings
assert any("run1.jsonl" in arg for arg in captured_cmd)
assert any("run2.jsonl" in arg for arg in captured_cmd)
# ---- Cancel endpoint tests ----
def test_benchmark_cancel_returns_404_when_not_running(client):
"""POST /api/benchmark/cancel must return 404 if no benchmark is running."""
from app import api as api_module
api_module._running_procs.pop("benchmark", None)
r = client.post("/api/benchmark/cancel")
assert r.status_code == 404
def test_finetune_cancel_returns_404_when_not_running(client):
"""POST /api/finetune/cancel must return 404 if no finetune is running."""
from app import api as api_module
api_module._running_procs.pop("finetune", None)
r = client.post("/api/finetune/cancel")
assert r.status_code == 404
def test_benchmark_cancel_terminates_running_process(client):
"""POST /api/benchmark/cancel must call terminate() on the running process."""
from unittest.mock import MagicMock
from app import api as api_module
mock_proc = MagicMock()
mock_proc.wait = MagicMock()
api_module._running_procs["benchmark"] = mock_proc
try:
r = client.post("/api/benchmark/cancel")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
mock_proc.terminate.assert_called_once()
finally:
api_module._running_procs.pop("benchmark", None)
api_module._cancelled_jobs.discard("benchmark")
def test_finetune_cancel_terminates_running_process(client):
"""POST /api/finetune/cancel must call terminate() on the running process."""
from unittest.mock import MagicMock
from app import api as api_module
mock_proc = MagicMock()
mock_proc.wait = MagicMock()
api_module._running_procs["finetune"] = mock_proc
try:
r = client.post("/api/finetune/cancel")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
mock_proc.terminate.assert_called_once()
finally:
api_module._running_procs.pop("finetune", None)
api_module._cancelled_jobs.discard("finetune")
def test_benchmark_cancel_kills_process_on_timeout(client):
"""POST /api/benchmark/cancel must call kill() if the process does not exit within 3 s."""
import subprocess
from unittest.mock import MagicMock
from app import api as api_module
mock_proc = MagicMock()
mock_proc.wait.side_effect = subprocess.TimeoutExpired(cmd="benchmark", timeout=3)
api_module._running_procs["benchmark"] = mock_proc
try:
r = client.post("/api/benchmark/cancel")
assert r.status_code == 200
mock_proc.kill.assert_called_once()
finally:
api_module._running_procs.pop("benchmark", None)
api_module._cancelled_jobs.discard("benchmark")
def test_finetune_run_emits_cancelled_event(client):
"""GET /api/finetune/run must emit cancelled (not error) when job was cancelled."""
from unittest.mock import patch, MagicMock
from app import api as api_module
mock_proc = MagicMock()
mock_proc.stdout = iter([])
mock_proc.returncode = -15 # SIGTERM
def mock_wait():
# Simulate cancel being called while the process is running (after discard clears stale flag)
api_module._cancelled_jobs.add("finetune")
mock_proc.wait = mock_wait
def mock_popen(cmd, **kwargs):
return mock_proc
try:
with patch("app.api._subprocess.Popen",side_effect=mock_popen):
r = client.get("/api/finetune/run?model=deberta-small&epochs=1")
assert '{"type": "cancelled"}' in r.text
assert '"type": "error"' not in r.text
finally:
api_module._cancelled_jobs.discard("finetune")
def test_benchmark_run_emits_cancelled_event(client):
"""GET /api/benchmark/run must emit cancelled (not error) when job was cancelled."""
from unittest.mock import patch, MagicMock
from app import api as api_module
mock_proc = MagicMock()
mock_proc.stdout = iter([])
mock_proc.returncode = -15
def mock_wait():
# Simulate cancel being called while the process is running (after discard clears stale flag)
api_module._cancelled_jobs.add("benchmark")
mock_proc.wait = mock_wait
def mock_popen(cmd, **kwargs):
return mock_proc
try:
with patch("app.api._subprocess.Popen",side_effect=mock_popen):
r = client.get("/api/benchmark/run")
assert '{"type": "cancelled"}' in r.text
assert '"type": "error"' not in r.text
finally:
api_module._cancelled_jobs.discard("benchmark")

View file

@ -92,3 +92,77 @@ def test_run_scoring_handles_classify_error(tmp_path):
results = run_scoring([broken], str(score_file))
assert "broken" in results
# ---- Auto-discovery tests ----
def test_discover_finetuned_models_finds_training_info_files(tmp_path):
"""discover_finetuned_models() must return one entry per training_info.json found."""
import json
from scripts.benchmark_classifier import discover_finetuned_models
# Create two fake model directories
for name in ("avocet-deberta-small", "avocet-bge-m3"):
model_dir = tmp_path / name
model_dir.mkdir()
info = {
"name": name,
"base_model_id": "cross-encoder/nli-deberta-v3-small",
"timestamp": "2026-03-15T12:00:00Z",
"val_macro_f1": 0.72,
"val_accuracy": 0.80,
"sample_count": 401,
}
(model_dir / "training_info.json").write_text(json.dumps(info))
results = discover_finetuned_models(tmp_path)
assert len(results) == 2
names = {r["name"] for r in results}
assert "avocet-deberta-small" in names
assert "avocet-bge-m3" in names
for r in results:
assert "model_dir" in r, "discover_finetuned_models must inject model_dir key"
assert r["model_dir"].endswith(r["name"])
def test_discover_finetuned_models_returns_empty_when_no_models_dir():
"""discover_finetuned_models() must return [] silently if models/ doesn't exist."""
from pathlib import Path
from scripts.benchmark_classifier import discover_finetuned_models
results = discover_finetuned_models(Path("/nonexistent/path/models"))
assert results == []
def test_discover_finetuned_models_skips_dirs_without_training_info(tmp_path):
"""Subdirs without training_info.json are silently skipped."""
from scripts.benchmark_classifier import discover_finetuned_models
# A dir WITHOUT training_info.json
(tmp_path / "some-other-dir").mkdir()
results = discover_finetuned_models(tmp_path)
assert results == []
def test_active_models_includes_discovered_finetuned(tmp_path):
"""The active models dict must include FineTunedAdapter entries for discovered models."""
import json
from unittest.mock import patch
from scripts.benchmark_classifier import _active_models
from scripts.classifier_adapters import FineTunedAdapter
model_dir = tmp_path / "avocet-deberta-small"
model_dir.mkdir()
(model_dir / "training_info.json").write_text(json.dumps({
"name": "avocet-deberta-small",
"base_model_id": "cross-encoder/nli-deberta-v3-small",
"val_macro_f1": 0.72,
"sample_count": 401,
}))
with patch("scripts.benchmark_classifier._MODELS_DIR", tmp_path):
models = _active_models(include_slow=False)
assert "avocet-deberta-small" in models
assert isinstance(models["avocet-deberta-small"]["adapter_instance"], FineTunedAdapter)

View file

@ -2,14 +2,16 @@
import pytest
def test_labels_constant_has_nine_items():
def test_labels_constant_has_ten_items():
from scripts.classifier_adapters import LABELS
assert len(LABELS) == 9
assert len(LABELS) == 10
assert "interview_scheduled" in LABELS
assert "neutral" in LABELS
assert "event_rescheduled" in LABELS
assert "unrelated" in LABELS
assert "digest" in LABELS
assert "new_lead" in LABELS
assert "hired" in LABELS
assert "unrelated" not in LABELS
def test_compute_metrics_perfect_predictions():
@ -178,3 +180,91 @@ def test_reranker_adapter_picks_highest_score():
def test_reranker_adapter_descriptions_cover_all_labels():
from scripts.classifier_adapters import LABEL_DESCRIPTIONS, LABELS
assert set(LABEL_DESCRIPTIONS.keys()) == set(LABELS)
# ---- FineTunedAdapter tests ----
def test_finetuned_adapter_classify_calls_pipeline_with_sep_format(tmp_path):
"""classify() must format input as 'subject [SEP] body[:400]' — not the zero-shot format."""
from unittest.mock import MagicMock, patch
from scripts.classifier_adapters import FineTunedAdapter
mock_result = [{"label": "digest", "score": 0.95}]
mock_pipe_instance = MagicMock(return_value=mock_result)
mock_pipe_factory = MagicMock(return_value=mock_pipe_instance)
adapter = FineTunedAdapter("avocet-deberta-small", str(tmp_path))
with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory):
result = adapter.classify("Test subject", "Test body")
assert result == "digest"
call_args = mock_pipe_instance.call_args[0][0]
assert "[SEP]" in call_args
assert "Test subject" in call_args
assert "Test body" in call_args
def test_finetuned_adapter_truncates_body_to_400():
"""Body must be truncated to 400 chars in the [SEP] format."""
from unittest.mock import MagicMock, patch
from scripts.classifier_adapters import FineTunedAdapter, LABELS
long_body = "x" * 800
mock_result = [{"label": "neutral", "score": 0.9}]
mock_pipe_instance = MagicMock(return_value=mock_result)
mock_pipe_factory = MagicMock(return_value=mock_pipe_instance)
adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path")
with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory):
adapter.classify("Subject", long_body)
call_text = mock_pipe_instance.call_args[0][0]
parts = call_text.split(" [SEP] ", 1)
assert len(parts) == 2, "Input must contain ' [SEP] ' separator"
assert len(parts[1]) == 400, f"Body must be exactly 400 chars, got {len(parts[1])}"
def test_finetuned_adapter_returns_label_string():
"""classify() must return a plain string, not a dict."""
from unittest.mock import MagicMock, patch
from scripts.classifier_adapters import FineTunedAdapter
mock_result = [{"label": "interview_scheduled", "score": 0.87}]
mock_pipe_instance = MagicMock(return_value=mock_result)
mock_pipe_factory = MagicMock(return_value=mock_pipe_instance)
adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path")
with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory):
result = adapter.classify("S", "B")
assert isinstance(result, str)
assert result == "interview_scheduled"
def test_finetuned_adapter_lazy_loads_pipeline():
"""Pipeline factory must not be called until classify() is first called."""
from unittest.mock import MagicMock, patch
from scripts.classifier_adapters import FineTunedAdapter
mock_pipe_factory = MagicMock(return_value=MagicMock(return_value=[{"label": "neutral", "score": 0.9}]))
with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory):
adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path")
assert not mock_pipe_factory.called
adapter.classify("s", "b")
assert mock_pipe_factory.called
def test_finetuned_adapter_unload_clears_pipeline():
"""unload() must set _pipeline to None so memory is released."""
from unittest.mock import MagicMock, patch
from scripts.classifier_adapters import FineTunedAdapter
mock_pipe_factory = MagicMock(return_value=MagicMock(return_value=[{"label": "neutral", "score": 0.9}]))
with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory):
adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path")
adapter.classify("s", "b")
assert adapter._pipeline is not None
adapter.unload()
assert adapter._pipeline is None

371
tests/test_finetune.py Normal file
View file

@ -0,0 +1,371 @@
"""Tests for finetune_classifier — no model downloads required."""
from __future__ import annotations
import json
import pytest
# ---- Data loading tests ----
def test_load_and_prepare_data_drops_non_canonical_labels(tmp_path):
"""Rows with labels not in LABELS must be silently dropped."""
from scripts.finetune_classifier import load_and_prepare_data
from scripts.classifier_adapters import LABELS
# Two samples per canonical label so they survive the < 2 class-drop rule.
rows = [
{"subject": "s1", "body": "b1", "label": "digest"},
{"subject": "s2", "body": "b2", "label": "digest"},
{"subject": "s3", "body": "b3", "label": "profile_alert"}, # non-canonical
{"subject": "s4", "body": "b4", "label": "neutral"},
{"subject": "s5", "body": "b5", "label": "neutral"},
]
score_file = tmp_path / "email_score.jsonl"
score_file.write_text("\n".join(json.dumps(r) for r in rows))
texts, labels = load_and_prepare_data(score_file)
assert len(texts) == 4
assert all(l in LABELS for l in labels)
def test_load_and_prepare_data_formats_input_as_sep(tmp_path):
"""Input text must be 'subject [SEP] body[:400]'."""
# Two samples with the same label so the class survives the < 2 drop rule.
rows = [
{"subject": "Hello", "body": "World" * 100, "label": "neutral"},
{"subject": "Hello2", "body": "World" * 100, "label": "neutral"},
]
score_file = tmp_path / "email_score.jsonl"
score_file.write_text("\n".join(json.dumps(r) for r in rows))
from scripts.finetune_classifier import load_and_prepare_data
texts, labels = load_and_prepare_data(score_file)
assert texts[0].startswith("Hello [SEP] ")
parts = texts[0].split(" [SEP] ", 1)
assert len(parts[1]) == 400, f"Body must be exactly 400 chars, got {len(parts[1])}"
def test_load_and_prepare_data_raises_on_missing_file():
"""FileNotFoundError must be raised with actionable message."""
from pathlib import Path
from scripts.finetune_classifier import load_and_prepare_data
with pytest.raises(FileNotFoundError, match="email_score.jsonl"):
load_and_prepare_data(Path("/nonexistent/email_score.jsonl"))
def test_load_and_prepare_data_drops_class_with_fewer_than_2_samples(tmp_path, capsys):
"""Classes with < 2 total samples must be dropped with a warning."""
from scripts.finetune_classifier import load_and_prepare_data
rows = [
{"subject": "s1", "body": "b", "label": "digest"},
{"subject": "s2", "body": "b", "label": "digest"},
{"subject": "s3", "body": "b", "label": "new_lead"}, # only 1 sample — drop
]
score_file = tmp_path / "email_score.jsonl"
score_file.write_text("\n".join(json.dumps(r) for r in rows))
texts, labels = load_and_prepare_data(score_file)
captured = capsys.readouterr()
assert "new_lead" not in labels
assert "new_lead" in captured.out # warning printed
# ---- Class weights tests ----
def test_compute_class_weights_returns_tensor_for_each_class():
"""compute_class_weights must return a float tensor of length n_classes."""
import torch
from scripts.finetune_classifier import compute_class_weights
label_ids = [0, 0, 0, 1, 1, 2] # 3 classes, imbalanced
weights = compute_class_weights(label_ids, n_classes=3)
assert isinstance(weights, torch.Tensor)
assert weights.shape == (3,)
assert all(w > 0 for w in weights)
def test_compute_class_weights_upweights_minority():
"""Minority classes must receive higher weight than majority classes."""
from scripts.finetune_classifier import compute_class_weights
# Class 0: 10 samples, Class 1: 2 samples
label_ids = [0] * 10 + [1] * 2
weights = compute_class_weights(label_ids, n_classes=2)
assert weights[1] > weights[0]
# ---- compute_metrics_for_trainer tests ----
def test_compute_metrics_for_trainer_returns_macro_f1_key():
"""Must return a dict with 'macro_f1' key."""
import numpy as np
from scripts.finetune_classifier import compute_metrics_for_trainer
from transformers import EvalPrediction
logits = np.array([[2.0, 0.1], [0.1, 2.0], [2.0, 0.1]])
labels = np.array([0, 1, 0])
pred = EvalPrediction(predictions=logits, label_ids=labels)
result = compute_metrics_for_trainer(pred)
assert "macro_f1" in result
assert result["macro_f1"] == pytest.approx(1.0)
def test_compute_metrics_for_trainer_returns_accuracy_key():
"""Must also return 'accuracy' key."""
import numpy as np
from scripts.finetune_classifier import compute_metrics_for_trainer
from transformers import EvalPrediction
logits = np.array([[2.0, 0.1], [0.1, 2.0]])
labels = np.array([0, 1])
pred = EvalPrediction(predictions=logits, label_ids=labels)
result = compute_metrics_for_trainer(pred)
assert "accuracy" in result
assert result["accuracy"] == pytest.approx(1.0)
# ---- WeightedTrainer tests ----
def test_weighted_trainer_compute_loss_returns_scalar():
"""compute_loss must return a scalar tensor when return_outputs=False."""
import torch
from unittest.mock import MagicMock
from scripts.finetune_classifier import WeightedTrainer
n_classes = 3
batch = 4
logits = torch.randn(batch, n_classes)
mock_outputs = MagicMock()
mock_outputs.logits = logits
mock_model = MagicMock(return_value=mock_outputs)
trainer = WeightedTrainer.__new__(WeightedTrainer)
trainer.class_weights = torch.ones(n_classes)
inputs = {
"input_ids": torch.zeros(batch, 10, dtype=torch.long),
"labels": torch.randint(0, n_classes, (batch,)),
}
loss = trainer.compute_loss(mock_model, inputs, return_outputs=False)
assert isinstance(loss, torch.Tensor)
assert loss.ndim == 0 # scalar
def test_weighted_trainer_compute_loss_accepts_kwargs():
"""compute_loss must not raise TypeError when called with num_items_in_batch kwarg."""
import torch
from unittest.mock import MagicMock
from scripts.finetune_classifier import WeightedTrainer
n_classes = 3
batch = 2
logits = torch.randn(batch, n_classes)
mock_outputs = MagicMock()
mock_outputs.logits = logits
mock_model = MagicMock(return_value=mock_outputs)
trainer = WeightedTrainer.__new__(WeightedTrainer)
trainer.class_weights = torch.ones(n_classes)
inputs = {
"input_ids": torch.zeros(batch, 5, dtype=torch.long),
"labels": torch.randint(0, n_classes, (batch,)),
}
loss = trainer.compute_loss(mock_model, inputs, return_outputs=False,
num_items_in_batch=batch)
assert isinstance(loss, torch.Tensor)
def test_weighted_trainer_weighted_loss_differs_from_unweighted():
"""Weighted loss must differ from uniform-weight loss for imbalanced inputs."""
import torch
from unittest.mock import MagicMock
from scripts.finetune_classifier import WeightedTrainer
n_classes = 2
batch = 4
# Mixed labels: 3× class-0, 1× class-1.
# Asymmetric logits (class-0 samples predicted well, class-1 predicted poorly)
# ensure per-class CE values differ, so re-weighting changes the weighted mean.
labels = torch.tensor([0, 0, 0, 1], dtype=torch.long)
logits = torch.tensor([[3.0, -1.0], [3.0, -1.0], [3.0, -1.0], [0.5, 0.5]])
mock_outputs = MagicMock()
mock_outputs.logits = logits
trainer_uniform = WeightedTrainer.__new__(WeightedTrainer)
trainer_uniform.class_weights = torch.ones(n_classes)
inputs_uniform = {"input_ids": torch.zeros(batch, 5, dtype=torch.long), "labels": labels.clone()}
loss_uniform = trainer_uniform.compute_loss(MagicMock(return_value=mock_outputs),
inputs_uniform)
trainer_weighted = WeightedTrainer.__new__(WeightedTrainer)
trainer_weighted.class_weights = torch.tensor([0.1, 10.0])
inputs_weighted = {"input_ids": torch.zeros(batch, 5, dtype=torch.long), "labels": labels.clone()}
mock_outputs2 = MagicMock()
mock_outputs2.logits = logits.clone()
loss_weighted = trainer_weighted.compute_loss(MagicMock(return_value=mock_outputs2),
inputs_weighted)
assert not torch.isclose(loss_uniform, loss_weighted)
def test_weighted_trainer_compute_loss_returns_outputs_when_requested():
"""compute_loss with return_outputs=True must return (loss, outputs) tuple."""
import torch
from unittest.mock import MagicMock
from scripts.finetune_classifier import WeightedTrainer
n_classes = 3
batch = 2
logits = torch.randn(batch, n_classes)
mock_outputs = MagicMock()
mock_outputs.logits = logits
mock_model = MagicMock(return_value=mock_outputs)
trainer = WeightedTrainer.__new__(WeightedTrainer)
trainer.class_weights = torch.ones(n_classes)
inputs = {
"input_ids": torch.zeros(batch, 5, dtype=torch.long),
"labels": torch.randint(0, n_classes, (batch,)),
}
result = trainer.compute_loss(mock_model, inputs, return_outputs=True)
assert isinstance(result, tuple)
loss, outputs = result
assert isinstance(loss, torch.Tensor)
# ---- Multi-file merge / dedup tests ----
def test_load_and_prepare_data_merges_multiple_files(tmp_path):
"""Multiple score files must be merged into a single dataset."""
from scripts.finetune_classifier import load_and_prepare_data
file1 = tmp_path / "run1.jsonl"
file2 = tmp_path / "run2.jsonl"
file1.write_text(
json.dumps({"subject": "s1", "body": "b1", "label": "digest"}) + "\n" +
json.dumps({"subject": "s2", "body": "b2", "label": "digest"}) + "\n"
)
file2.write_text(
json.dumps({"subject": "s3", "body": "b3", "label": "neutral"}) + "\n" +
json.dumps({"subject": "s4", "body": "b4", "label": "neutral"}) + "\n"
)
texts, labels = load_and_prepare_data([file1, file2])
assert len(texts) == 4
assert labels.count("digest") == 2
assert labels.count("neutral") == 2
def test_load_and_prepare_data_deduplicates_last_write_wins(tmp_path, capsys):
"""Duplicate rows (same content hash) keep the last occurrence."""
from scripts.finetune_classifier import load_and_prepare_data
# Same subject+body[:100] = same hash
row_early = {"subject": "Hello", "body": "World", "label": "neutral"}
row_late = {"subject": "Hello", "body": "World", "label": "digest"} # relabeled
file1 = tmp_path / "run1.jsonl"
file2 = tmp_path / "run2.jsonl"
# Add a second row with different content so class count >= 2 for both classes
file1.write_text(
json.dumps(row_early) + "\n" +
json.dumps({"subject": "Other1", "body": "Other", "label": "neutral"}) + "\n"
)
file2.write_text(
json.dumps(row_late) + "\n" +
json.dumps({"subject": "Other2", "body": "Stuff", "label": "digest"}) + "\n"
)
texts, labels = load_and_prepare_data([file1, file2])
captured = capsys.readouterr()
# The duplicate row should be counted as dropped
assert "Deduped" in captured.out
# The relabeled row should have "digest" (last-write wins), not "neutral"
hello_idx = next(i for i, t in enumerate(texts) if t.startswith("Hello [SEP]"))
assert labels[hello_idx] == "digest"
def test_load_and_prepare_data_single_path_still_works(tmp_path):
"""Passing a single Path (not a list) must still work — backwards compatibility."""
from scripts.finetune_classifier import load_and_prepare_data
rows = [
{"subject": "s1", "body": "b1", "label": "digest"},
{"subject": "s2", "body": "b2", "label": "digest"},
]
score_file = tmp_path / "email_score.jsonl"
score_file.write_text("\n".join(json.dumps(r) for r in rows))
texts, labels = load_and_prepare_data(score_file) # single Path, not list
assert len(texts) == 2
# ---- Integration test ----
def test_integration_finetune_on_example_data(tmp_path):
"""Fine-tune deberta-small on example data for 1 epoch.
Uses data/email_score.jsonl.example (8 samples, 5 labels represented).
The 5 missing labels must trigger the < 2 samples drop warning.
Verifies training_info.json is written with correct keys.
Requires job-seeker-classifiers env and downloads deberta-small (~100MB on first run).
"""
import shutil
from scripts import finetune_classifier as ft_mod
from scripts.finetune_classifier import run_finetune
example_file = ft_mod._ROOT / "data" / "email_score.jsonl.example"
if not example_file.exists():
pytest.skip("email_score.jsonl.example not found")
orig_root = ft_mod._ROOT
ft_mod._ROOT = tmp_path
(tmp_path / "data").mkdir()
shutil.copy(example_file, tmp_path / "data" / "email_score.jsonl")
try:
import io
from contextlib import redirect_stdout
captured = io.StringIO()
with redirect_stdout(captured):
run_finetune("deberta-small", epochs=1)
output = captured.getvalue()
finally:
ft_mod._ROOT = orig_root
# Missing labels should trigger the < 2 samples drop warning
assert "WARNING: Dropping class" in output
# training_info.json must exist with correct keys
info_path = tmp_path / "models" / "avocet-deberta-small" / "training_info.json"
assert info_path.exists(), "training_info.json not written"
info = json.loads(info_path.read_text())
for key in ("name", "base_model_id", "timestamp", "epochs_run",
"val_macro_f1", "val_accuracy", "sample_count", "train_sample_count",
"label_counts", "score_files"):
assert key in info, f"Missing key: {key}"
assert info["name"] == "avocet-deberta-small"
assert info["epochs_run"] == 1
assert isinstance(info["score_files"], list)

86
tests/test_imap_fetch.py Normal file
View file

@ -0,0 +1,86 @@
"""Tests for imap_fetch — IMAP calls mocked."""
from unittest.mock import MagicMock, patch
def test_test_connection_missing_fields():
from app.imap_fetch import test_connection
ok, msg, count = test_connection({"host": "", "username": "", "password": ""})
assert ok is False
assert "required" in msg.lower()
def test_test_connection_success():
from app.imap_fetch import test_connection
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"42"])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
ok, msg, count = test_connection({
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "secret", "folder": "INBOX",
})
assert ok is True
assert count == 42
assert "42" in msg
def test_test_connection_auth_failure():
from app.imap_fetch import test_connection
import imaplib
with patch("app.imap_fetch.imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("auth failed")):
ok, msg, count = test_connection({
"host": "imap.example.com", "port": 993, "use_ssl": True,
"username": "u@example.com", "password": "wrong", "folder": "INBOX",
})
assert ok is False
assert count is None
def test_fetch_account_stream_yields_start_done(tmp_path):
from app.imap_fetch import fetch_account_stream
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1 2"])
raw_msg = b"Subject: Test\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nHello"
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events = list(fetch_account_stream(
acc={"host": "h", "port": 993, "use_ssl": True,
"username": "u", "password": "p", "folder": "INBOX", "name": "Test"},
days_back=30, limit=10, known_keys=set(),
))
types = [e["type"] for e in events]
assert "start" in types
assert "done" in types
def test_fetch_account_stream_deduplicates(tmp_path):
from app.imap_fetch import fetch_account_stream
raw_msg = b"Subject: Dupe\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody"
mock_conn = MagicMock()
mock_conn.search.return_value = ("OK", [b"1"])
mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)])
known = set()
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events1 = list(fetch_account_stream(
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
"password": "p", "folder": "INBOX", "name": "T"},
30, 10, known,
))
done1 = next(e for e in events1 if e["type"] == "done")
with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn):
events2 = list(fetch_account_stream(
{"host": "h", "port": 993, "use_ssl": True, "username": "u",
"password": "p", "folder": "INBOX", "name": "T"},
30, 10, known,
))
done2 = next(e for e in events2 if e["type"] == "done")
assert done1["added"] == 1
assert done2["added"] == 0

87
tests/test_label_tool.py Normal file
View file

@ -0,0 +1,87 @@
"""Tests for label_tool HTML extraction utilities.
These functions are stdlib-only and safe to test without an IMAP connection.
"""
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.utils import extract_body, strip_html
# ── strip_html ──────────────────────────────────────────────────────────────
def test_strip_html_removes_tags():
assert strip_html("<p>Hello <b>world</b></p>") == "Hello world"
def test_strip_html_skips_script_content():
result = strip_html("<script>doEvil()</script><p>real</p>")
assert "doEvil" not in result
assert "real" in result
def test_strip_html_skips_style_content():
result = strip_html("<style>.foo{color:red}</style><p>visible</p>")
assert ".foo" not in result
assert "visible" in result
def test_strip_html_handles_br_as_newline():
result = strip_html("line1<br>line2")
assert "line1" in result
assert "line2" in result
def test_strip_html_decodes_entities():
# convert_charrefs=True on HTMLParser handles &amp; etc.
result = strip_html("<p>Hello &amp; welcome</p>")
assert "&amp;" not in result
assert "Hello" in result
assert "welcome" in result
def test_strip_html_empty_string():
assert strip_html("") == ""
def test_strip_html_plain_text_passthrough():
assert strip_html("no tags here") == "no tags here"
# ── extract_body ────────────────────────────────────────────────────────────
def test_extract_body_prefers_plain_over_html():
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("plain body", "plain"))
msg.attach(MIMEText("<html><body>html body</body></html>", "html"))
assert extract_body(msg) == "plain body"
def test_extract_body_falls_back_to_html_when_no_plain():
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("<html><body><p>HTML only email</p></body></html>", "html"))
result = extract_body(msg)
assert "HTML only email" in result
assert "<" not in result # no raw HTML tags leaked through
def test_extract_body_non_multipart_html_stripped():
msg = MIMEText("<html><body><p>Solo HTML</p></body></html>", "html")
result = extract_body(msg)
assert "Solo HTML" in result
assert "<html>" not in result
def test_extract_body_non_multipart_plain_unchanged():
msg = MIMEText("just plain text", "plain")
assert extract_body(msg) == "just plain text"
def test_extract_body_empty_message():
msg = MIMEText("", "plain")
assert extract_body(msg) == ""
def test_extract_body_multipart_empty_returns_empty():
msg = MIMEMultipart("alternative")
assert extract_body(msg) == ""

342
tests/test_sft.py Normal file
View file

@ -0,0 +1,342 @@
"""API integration tests for app/sft.py — /api/sft/* endpoints."""
import json
import pytest
from fastapi.testclient import TestClient
from pathlib import Path
@pytest.fixture(autouse=True)
def reset_sft_globals(tmp_path):
from app import sft as sft_module
_prev_data = sft_module._SFT_DATA_DIR
_prev_cfg = sft_module._SFT_CONFIG_DIR
sft_module.set_sft_data_dir(tmp_path)
sft_module.set_sft_config_dir(tmp_path)
yield
sft_module.set_sft_data_dir(_prev_data)
sft_module.set_sft_config_dir(_prev_cfg)
@pytest.fixture
def client():
from app.api import app
return TestClient(app)
def _make_record(id: str, run_id: str = "2026-04-07-143022") -> dict:
return {
"id": id, "source": "cf-orch-benchmark",
"benchmark_run_id": run_id, "timestamp": "2026-04-07T10:00:00Z",
"status": "needs_review",
"prompt_messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Write a Python function that adds two numbers."},
],
"model_response": "def add(a, b): return a - b",
"corrected_response": None,
"quality_score": 0.2, "failure_reason": "pattern_match: 0/2 matched",
"task_id": "code-fn", "task_type": "code",
"task_name": "Code: Write a Python function",
"model_id": "Qwen/Qwen2.5-3B", "model_name": "Qwen2.5-3B",
"node_id": "heimdall", "gpu_id": 0, "tokens_per_sec": 38.4,
}
def _write_run(tmp_path, run_id: str, records: list[dict]) -> Path:
run_dir = tmp_path / "bench_results" / run_id
run_dir.mkdir(parents=True)
sft_path = run_dir / "sft_candidates.jsonl"
sft_path.write_text(
"\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8"
)
return sft_path
def _write_config(tmp_path, bench_results_dir: Path) -> None:
import yaml
cfg = {"sft": {"bench_results_dir": str(bench_results_dir)}}
(tmp_path / "label_tool.yaml").write_text(
yaml.dump(cfg, allow_unicode=True), encoding="utf-8"
)
# ── /api/sft/runs ──────────────────────────────────────────────────────────
def test_runs_returns_empty_when_no_config(client):
r = client.get("/api/sft/runs")
assert r.status_code == 200
assert r.json() == []
def test_runs_returns_available_runs(client, tmp_path):
_write_run(tmp_path, "2026-04-07-143022", [_make_record("a"), _make_record("b")])
_write_config(tmp_path, tmp_path / "bench_results")
r = client.get("/api/sft/runs")
assert r.status_code == 200
data = r.json()
assert len(data) == 1
assert data[0]["run_id"] == "2026-04-07-143022"
assert data[0]["candidate_count"] == 2
assert data[0]["already_imported"] is False
def test_runs_marks_already_imported(client, tmp_path):
_write_run(tmp_path, "2026-04-07-143022", [_make_record("a")])
_write_config(tmp_path, tmp_path / "bench_results")
from app import sft as sft_module
candidates = sft_module._candidates_file()
candidates.parent.mkdir(parents=True, exist_ok=True)
candidates.write_text(
json.dumps(_make_record("a", run_id="2026-04-07-143022")) + "\n",
encoding="utf-8"
)
r = client.get("/api/sft/runs")
assert r.json()[0]["already_imported"] is True
# ── /api/sft/import ─────────────────────────────────────────────────────────
def test_import_adds_records(client, tmp_path):
_write_run(tmp_path, "2026-04-07-143022", [_make_record("a"), _make_record("b")])
_write_config(tmp_path, tmp_path / "bench_results")
r = client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"})
assert r.status_code == 200
assert r.json() == {"imported": 2, "skipped": 0}
def test_import_is_idempotent(client, tmp_path):
_write_run(tmp_path, "2026-04-07-143022", [_make_record("a")])
_write_config(tmp_path, tmp_path / "bench_results")
client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"})
r = client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"})
assert r.json() == {"imported": 0, "skipped": 1}
def test_import_unknown_run_returns_404(client, tmp_path):
_write_config(tmp_path, tmp_path / "bench_results")
r = client.post("/api/sft/import", json={"run_id": "nonexistent"})
assert r.status_code == 404
# ── /api/sft/queue ──────────────────────────────────────────────────────────
def _populate_candidates(tmp_path, records: list[dict]) -> None:
from app import sft as sft_module
path = sft_module._candidates_file()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8"
)
def test_queue_returns_needs_review_only(client, tmp_path):
records = [
_make_record("a"), # needs_review
{**_make_record("b"), "status": "approved"}, # should not appear
{**_make_record("c"), "status": "discarded"}, # should not appear
]
_populate_candidates(tmp_path, records)
r = client.get("/api/sft/queue")
assert r.status_code == 200
data = r.json()
assert data["total"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["id"] == "a"
def test_queue_pagination(client, tmp_path):
records = [_make_record(str(i)) for i in range(25)]
_populate_candidates(tmp_path, records)
r = client.get("/api/sft/queue?page=1&per_page=10")
data = r.json()
assert data["total"] == 25
assert len(data["items"]) == 10
r2 = client.get("/api/sft/queue?page=3&per_page=10")
assert len(r2.json()["items"]) == 5
def test_queue_empty_when_no_file(client):
r = client.get("/api/sft/queue")
assert r.status_code == 200
assert r.json() == {"items": [], "total": 0, "page": 1, "per_page": 20}
# ── /api/sft/submit ─────────────────────────────────────────────────────────
def test_submit_correct_sets_approved(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/submit", json={
"id": "a", "action": "correct",
"corrected_response": "def add(a, b): return a + b",
})
assert r.status_code == 200
from app import sft as sft_module
records = sft_module._read_candidates()
assert records[0]["status"] == "approved"
assert records[0]["corrected_response"] == "def add(a, b): return a + b"
def test_submit_correct_also_appends_to_approved_file(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
client.post("/api/sft/submit", json={
"id": "a", "action": "correct",
"corrected_response": "def add(a, b): return a + b",
})
from app import sft as sft_module
from app.utils import read_jsonl
approved = read_jsonl(sft_module._approved_file())
assert len(approved) == 1
assert approved[0]["id"] == "a"
def test_submit_discard_sets_discarded(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/submit", json={"id": "a", "action": "discard"})
assert r.status_code == 200
from app import sft as sft_module
assert sft_module._read_candidates()[0]["status"] == "discarded"
def test_submit_flag_sets_model_rejected(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/submit", json={"id": "a", "action": "flag"})
assert r.status_code == 200
from app import sft as sft_module
assert sft_module._read_candidates()[0]["status"] == "model_rejected"
def test_submit_correct_empty_response_returns_422(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/submit", json={
"id": "a", "action": "correct", "corrected_response": " ",
})
assert r.status_code == 422
def test_submit_correct_null_response_returns_422(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/submit", json={
"id": "a", "action": "correct", "corrected_response": None,
})
assert r.status_code == 422
def test_submit_unknown_id_returns_404(client, tmp_path):
r = client.post("/api/sft/submit", json={"id": "nope", "action": "discard"})
assert r.status_code == 404
def test_submit_already_approved_returns_409(client, tmp_path):
_populate_candidates(tmp_path, [{**_make_record("a"), "status": "approved"}])
r = client.post("/api/sft/submit", json={"id": "a", "action": "discard"})
assert r.status_code == 409
# ── /api/sft/undo ────────────────────────────────────────────────────────────
def test_undo_restores_discarded_to_needs_review(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
client.post("/api/sft/submit", json={"id": "a", "action": "discard"})
r = client.post("/api/sft/undo", json={"id": "a"})
assert r.status_code == 200
from app import sft as sft_module
assert sft_module._read_candidates()[0]["status"] == "needs_review"
def test_undo_removes_approved_from_approved_file(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
client.post("/api/sft/submit", json={
"id": "a", "action": "correct",
"corrected_response": "def add(a, b): return a + b",
})
client.post("/api/sft/undo", json={"id": "a"})
from app import sft as sft_module
from app.utils import read_jsonl
approved = read_jsonl(sft_module._approved_file())
assert not any(r["id"] == "a" for r in approved)
def test_undo_already_needs_review_returns_409(client, tmp_path):
_populate_candidates(tmp_path, [_make_record("a")])
r = client.post("/api/sft/undo", json={"id": "a"})
assert r.status_code == 409
# ── /api/sft/export ──────────────────────────────────────────────────────────
def test_export_returns_approved_as_sft_jsonl(client, tmp_path):
from app import sft as sft_module
from app.utils import write_jsonl
approved = {
**_make_record("a"),
"status": "approved",
"corrected_response": "def add(a, b): return a + b",
"prompt_messages": [
{"role": "system", "content": "You are a coding assistant."},
{"role": "user", "content": "Write a Python add function."},
],
}
write_jsonl(sft_module._approved_file(), [approved])
_populate_candidates(tmp_path, [approved])
r = client.get("/api/sft/export")
assert r.status_code == 200
assert "application/x-ndjson" in r.headers["content-type"]
lines = [l for l in r.text.splitlines() if l.strip()]
assert len(lines) == 1
record = json.loads(lines[0])
assert record["messages"][-1] == {
"role": "assistant", "content": "def add(a, b): return a + b"
}
assert record["messages"][0]["role"] == "system"
assert record["messages"][1]["role"] == "user"
def test_export_excludes_non_approved(client, tmp_path):
from app import sft as sft_module
from app.utils import write_jsonl
records = [
{**_make_record("a"), "status": "discarded", "corrected_response": None},
{**_make_record("b"), "status": "needs_review", "corrected_response": None},
]
write_jsonl(sft_module._approved_file(), records)
r = client.get("/api/sft/export")
assert r.text.strip() == ""
def test_export_empty_when_no_approved_file(client):
r = client.get("/api/sft/export")
assert r.status_code == 200
assert r.text.strip() == ""
# ── /api/sft/stats ───────────────────────────────────────────────────────────
def test_stats_counts_by_status(client, tmp_path):
from app import sft as sft_module
from app.utils import write_jsonl
records = [
_make_record("a"),
{**_make_record("b"), "status": "approved", "corrected_response": "ok"},
{**_make_record("c"), "status": "discarded"},
{**_make_record("d"), "status": "model_rejected"},
]
_populate_candidates(tmp_path, records)
write_jsonl(sft_module._approved_file(), [records[1]])
r = client.get("/api/sft/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 4
assert data["by_status"]["needs_review"] == 1
assert data["by_status"]["approved"] == 1
assert data["by_status"]["discarded"] == 1
assert data["by_status"]["model_rejected"] == 1
assert data["export_ready"] == 1
def test_stats_empty_when_no_data(client):
r = client.get("/api/sft/stats")
assert r.status_code == 200
data = r.json()
assert data["total"] == 0
assert data["export_ready"] == 0

95
tests/test_sft_import.py Normal file
View file

@ -0,0 +1,95 @@
"""Unit tests for scripts/sft_import.py — run discovery and JSONL deduplication."""
import json
import pytest
from pathlib import Path
def _write_candidates(path: Path, records: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8")
def _make_record(id: str, run_id: str = "run1") -> dict:
return {
"id": id, "source": "cf-orch-benchmark",
"benchmark_run_id": run_id, "timestamp": "2026-04-07T10:00:00Z",
"status": "needs_review", "prompt_messages": [],
"model_response": "bad", "corrected_response": None,
"quality_score": 0.3, "failure_reason": "missing patterns",
"task_id": "code-fn", "task_type": "code", "task_name": "Code: fn",
"model_id": "Qwen/Qwen2.5-3B", "model_name": "Qwen2.5-3B",
"node_id": "heimdall", "gpu_id": 0, "tokens_per_sec": 38.4,
}
def test_discover_runs_empty_when_dir_missing(tmp_path):
from scripts.sft_import import discover_runs
result = discover_runs(tmp_path / "nonexistent")
assert result == []
def test_discover_runs_returns_runs(tmp_path):
from scripts.sft_import import discover_runs
run_dir = tmp_path / "2026-04-07-143022"
_write_candidates(run_dir / "sft_candidates.jsonl", [_make_record("a"), _make_record("b")])
result = discover_runs(tmp_path)
assert len(result) == 1
assert result[0]["run_id"] == "2026-04-07-143022"
assert result[0]["candidate_count"] == 2
assert "sft_path" in result[0]
def test_discover_runs_skips_dirs_without_sft_file(tmp_path):
from scripts.sft_import import discover_runs
(tmp_path / "2026-04-07-no-sft").mkdir()
result = discover_runs(tmp_path)
assert result == []
def test_discover_runs_sorted_newest_first(tmp_path):
from scripts.sft_import import discover_runs
for name in ["2026-04-05-120000", "2026-04-07-143022", "2026-04-06-090000"]:
run_dir = tmp_path / name
_write_candidates(run_dir / "sft_candidates.jsonl", [_make_record("x")])
result = discover_runs(tmp_path)
assert [r["run_id"] for r in result] == [
"2026-04-07-143022", "2026-04-06-090000", "2026-04-05-120000"
]
def test_import_run_imports_new_records(tmp_path):
from scripts.sft_import import import_run
sft_path = tmp_path / "run1" / "sft_candidates.jsonl"
_write_candidates(sft_path, [_make_record("a"), _make_record("b")])
result = import_run(sft_path, tmp_path)
assert result == {"imported": 2, "skipped": 0}
dest = tmp_path / "sft_candidates.jsonl"
lines = [json.loads(l) for l in dest.read_text().splitlines() if l.strip()]
assert len(lines) == 2
def test_import_run_deduplicates_on_id(tmp_path):
from scripts.sft_import import import_run
sft_path = tmp_path / "run1" / "sft_candidates.jsonl"
_write_candidates(sft_path, [_make_record("a"), _make_record("b")])
import_run(sft_path, tmp_path)
result = import_run(sft_path, tmp_path) # second import
assert result == {"imported": 0, "skipped": 2}
dest = tmp_path / "sft_candidates.jsonl"
lines = [l for l in dest.read_text().splitlines() if l.strip()]
assert len(lines) == 2 # no duplicates
def test_import_run_skips_records_missing_id(tmp_path, caplog):
import logging
from scripts.sft_import import import_run
sft_path = tmp_path / "run1" / "sft_candidates.jsonl"
sft_path.parent.mkdir()
sft_path.write_text(
json.dumps({"model_response": "bad", "status": "needs_review"}) + "\n"
+ json.dumps({"id": "abc123", "model_response": "good", "status": "needs_review"}) + "\n"
)
with caplog.at_level(logging.WARNING, logger="scripts.sft_import"):
result = import_run(sft_path, tmp_path)
assert result == {"imported": 1, "skipped": 0}
assert "missing 'id'" in caplog.text

24
web/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web/README.md Normal file
View file

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

18
web/index.html Normal file
View file

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Avocet — Label Tool</title>
<!-- Inline background prevents blank-white flash before the CSS bundle loads -->
<style>
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4939
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
web/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@unocss/preset-attributify": "^66.6.4",
"@unocss/preset-wind": "^66.6.4",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"unocss": "^66.6.4",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.1.5"
}
}

1
web/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

54
web/src/App.vue Normal file
View file

@ -0,0 +1,54 @@
<template>
<div id="app" :class="{ 'rich-motion': motion.rich.value }">
<AppSidebar />
<main class="app-main">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppSidebar from './components/AppSidebar.vue'
const motion = useMotion()
const { toggle, restore } = useHackerMode()
useKonamiCode(toggle)
onMounted(() => {
restore() // re-apply hacker mode from localStorage on page load
})
</script>
<style>
/* Global reset + base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body, sans-serif);
background: var(--color-bg, #f0f4fc);
color: var(--color-text, #1a2338);
min-height: 100dvh;
}
#app {
display: flex;
min-height: 100dvh;
overflow-x: hidden;
}
.app-main {
flex: 1;
min-width: 0; /* prevent flex blowout */
margin-left: var(--sidebar-width, 200px);
transition: margin-left 250ms ease;
}
</style>

71
web/src/assets/avocet.css Normal file
View file

@ -0,0 +1,71 @@
/* web/src/assets/avocet.css
Avocet token overrides imports AFTER theme.css.
Only overrides what is genuinely different from the CircuitForge base theme.
Pattern mirrors peregrine.css see peregrine/docs/plans/2026-03-03-nuxt-design-system.md.
App colors:
Primary Slate Teal (#2A6080) inspired by avocet's slate-blue back plumage + deep water
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 */
--app-primary-light: #E4F0F7; /* subtle bg tint — background use only */
/* Accent — Russet */
--app-accent: #B8622A; /* 4.6:1 on light surface — ✅ AA */
--app-accent-hover: #9A4E1F; /* darker for hover */
--app-accent-light: #FAF0E8; /* subtle bg tint — background use only */
/* Text on accent buttons — dark navy, NOT white (russet bg only ~2.8:1 with white) */
--app-accent-text: #1a2338;
/* Avocet motion tokens */
--swipe-exit: 300ms;
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); /* card gestures */
--bucket-expand: 250ms cubic-bezier(0.34, 1.56, 0.64, 1); /* label→bucket transform */
--card-dismiss: 350ms ease-in; /* fileAway / crumple */
--card-skip: 300ms ease-out; /* slideUnder */
}
/* ── Dark mode ─────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
/* Primary — lighter for legibility on dark surfaces */
--app-primary: #5A9DBF; /* 6.2:1 on dark surface #16202e — ✅ AA */
--app-primary-hover: #74B5D8; /* lighter for hover */
--app-primary-light: #0D1F2D; /* subtle bg tint */
/* Accent — lighter russet */
--app-accent: #D4854A; /* 5.4:1 on dark surface — ✅ AA */
--app-accent-hover: #E8A060; /* lighter for hover */
--app-accent-light: #2D1A08; /* subtle bg tint */
/* Dark text still needed on accent bg (dark russet bg + dark text ≈ 1.5:1 — use light) */
--app-accent-text: #1a2338; /* in dark mode, russet is darker so dark text still works */
}
}

268
web/src/assets/theme.css Normal file
View file

@ -0,0 +1,268 @@
/* assets/styles/theme.css CENTRAL THEME FILE
Accessible Solarpunk: warm, earthy, humanist, trustworthy.
Hacker mode: terminal green circuit-trace dark (Konami code).
ALL color/font/spacing tokens live here nowhere else.
*/
/* ── Accessible Solarpunk — light (default) ──────── */
:root {
/* Brand */
--color-primary: #2d5a27;
--color-primary-hover: #234820;
--color-primary-light: #e8f2e7;
/* Surfaces — cool blue-slate, crisp and legible */
--color-surface: #eaeff8;
--color-surface-alt: #dde4f0;
--color-surface-raised: #f5f7fc;
/* Borders — cool blue-gray */
--color-border: #a8b8d0;
--color-border-light: #ccd5e6;
/* Text — dark navy, cool undertone */
--color-text: #1a2338;
--color-text-muted: #4a5c7a;
--color-text-inverse: #eaeff8;
/* Accent — amber/terracotta (action, links, CTAs) */
--color-accent: #c4732a;
--color-accent-hover: #a85c1f;
--color-accent-light: #fdf0e4;
/* Semantic */
--color-success: #3a7a32;
--color-error: #c0392b;
--color-warning: #d4891a;
--color-info: #1e6091;
/* Typography */
--font-display: 'Fraunces', Georgia, serif; /* Headings — optical humanist serif */
--font-body: 'Atkinson Hyperlegible', system-ui, sans-serif; /* Body — designed for accessibility */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, hacker mode */
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-24: 6rem;
/* Radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* Shadows — cool blue-navy base */
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
/* Transitions */
--transition: 200ms ease;
--transition-slow: 400ms ease;
/* Header */
--header-height: 4rem;
--header-border: 2px solid var(--color-border);
}
/* Accessible Solarpunk dark (system dark mode)
Activates when OS/browser is in dark mode.
Uses :not([data-theme="hacker"]) so the Konami easter
egg always wins over the system preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
/* Brand — lighter greens readable on dark surfaces */
--color-primary: #6ab870;
--color-primary-hover: #7ecb84;
--color-primary-light: #162616;
/* Surfaces — deep blue-slate, not pure black */
--color-surface: #16202e;
--color-surface-alt: #1e2a3a;
--color-surface-raised: #263547;
/* Borders */
--color-border: #2d4060;
--color-border-light: #233352;
/* Text */
--color-text: #e4eaf5;
--color-text-muted: #8da0bc;
--color-text-inverse: #16202e;
/* Accent — lighter amber for dark bg contrast (WCAG AA) */
--color-accent: #e8a84a;
--color-accent-hover: #f5bc60;
--color-accent-light: #2d1e0a;
/* Semantic */
--color-success: #5eb85e;
--color-error: #e05252;
--color-warning: #e8a84a;
--color-info: #4da6e8;
/* Shadows — darker base for dark bg */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
}
}
/* ── Hacker/maker easter egg theme ──────────────── */
/* Activated by Konami code: ↑↑↓↓←→←→BA */
/* Stored in localStorage: 'cf-hacker-mode' */
/* Applied: document.documentElement.dataset.theme */
[data-theme="hacker"] {
--color-primary: #00ff41;
--color-primary-hover: #00cc33;
--color-primary-light: #001a00;
--color-surface: #0a0c0a;
--color-surface-alt: #0d120d;
--color-surface-raised: #111811;
--color-border: #1a3d1a;
--color-border-light: #123012;
--color-text: #b8f5b8;
--color-text-muted: #5a9a5a;
--color-text-inverse: #0a0c0a;
--color-accent: #00ff41;
--color-accent-hover: #00cc33;
--color-accent-light: #001a0a;
--color-success: #00ff41;
--color-error: #ff3333;
--color-warning: #ffaa00;
--color-info: #00aaff;
/* Hacker mode: mono font everywhere */
--font-display: 'JetBrains Mono', monospace;
--font-body: 'JetBrains Mono', monospace;
--shadow-sm: 0 1px 3px rgba(0, 255, 65, 0.08);
--shadow-md: 0 4px 12px rgba(0, 255, 65, 0.12);
--shadow-lg: 0 10px 30px rgba(0, 255, 65, 0.15);
--header-border: 2px solid var(--color-border);
/* Hacker glow variants — for box-shadow, text-shadow, bg overlays */
--color-accent-glow-xs: rgba(0, 255, 65, 0.08);
--color-accent-glow-sm: rgba(0, 255, 65, 0.15);
--color-accent-glow-md: rgba(0, 255, 65, 0.4);
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
}
/* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-surface);
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { margin: 0; min-height: 100vh; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: var(--color-primary);
line-height: 1.2;
margin: 0;
}
/* Focus visible — keyboard nav — accessibility requirement */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ── Prose — CMS rich text ───────────────────────── */
.prose {
font-family: var(--font-body);
line-height: 1.75;
color: var(--color-text);
max-width: 65ch;
}
.prose h2 {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
margin: 2rem 0 0.75rem;
color: var(--color-primary);
}
.prose h3 {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
color: var(--color-primary);
}
.prose p { margin: 0 0 1rem; }
.prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.4rem; }
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
.prose strong { font-weight: 700; }
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.prose blockquote {
border-left: 3px solid var(--color-accent);
margin: 1.5rem 0;
padding: 0.5rem 0 0.5rem 1.25rem;
color: var(--color-text-muted);
font-style: italic;
}
/* ── Utility: screen reader only ────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only:focus-visible {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
padding: 0.5rem 1rem;
clip: auto;
white-space: normal;
background: var(--color-accent);
color: var(--color-text-inverse);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
}

1
web/src/assets/vue.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,276 @@
<template>
<!-- Mobile backdrop scrim -->
<div
v-if="isMobile && !stowed"
class="sidebar-scrim"
aria-hidden="true"
@click="stow()"
/>
<nav
class="sidebar"
:class="{ stowed, mobile: isMobile }"
:style="{ '--sidebar-w': stowed ? '56px' : '200px' }"
aria-label="App navigation"
>
<!-- Logo + stow toggle -->
<div class="sidebar-header">
<span v-if="!stowed" class="sidebar-logo">
<span class="logo-icon">🐦</span>
<span class="logo-name">Avocet</span>
</span>
<button
class="stow-btn"
:aria-label="stowed ? 'Expand navigation' : 'Collapse navigation'"
@click="toggle()"
>
{{ stowed ? '' : '' }}
</button>
</div>
<!-- Nav items -->
<ul class="nav-list" role="list">
<li v-for="item in navItems" :key="item.path">
<RouterLink
:to="item.path"
class="nav-item"
:title="stowed ? item.label : ''"
@click="isMobile && stow()"
>
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
<!-- Mobile hamburger button rendered outside the sidebar so it's visible when stowed -->
<button
v-if="isMobile && stowed"
class="mobile-hamburger"
aria-label="Open navigation"
@click="toggle()"
>
</button>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
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: '/benchmark', icon: '🏁', label: 'Benchmark' },
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
{ path: '/settings', icon: '⚙️', label: 'Settings' },
]
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
const winWidth = ref(window.innerWidth)
const isMobile = computed(() => winWidth.value < 640)
function toggle() {
stowed.value = !stowed.value
localStorage.setItem(LS_KEY, String(stowed.value))
// Update CSS variable on :root so .app-main margin-left syncs
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
}
function stow() {
stowed.value = true
localStorage.setItem(LS_KEY, 'true')
document.documentElement.style.setProperty('--sidebar-width', '56px')
}
function onResize() { winWidth.value = window.innerWidth }
onMounted(() => {
window.addEventListener('resize', onResize)
// Apply persisted sidebar width to :root on mount
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
// On mobile, default to stowed
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
stowed.value = true
document.documentElement.style.setProperty('--sidebar-width', '56px')
}
})
onUnmounted(() => window.removeEventListener('resize', onResize))
</script>
<style scoped>
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-w, 200px);
background: var(--color-surface-raised, #e4ebf5);
border-right: 1px solid var(--color-border, #d0d7e8);
display: flex;
flex-direction: column;
z-index: 200;
transition: width 250ms ease;
overflow: hidden;
}
.sidebar.stowed {
width: 56px;
}
/* Mobile: slide in/out from left */
.sidebar.mobile {
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
}
.sidebar.mobile.stowed {
transform: translateX(-100%);
width: 200px; /* keep width so slide-in looks right */
transition: transform 250ms ease, width 250ms ease;
}
.sidebar.mobile:not(.stowed) {
transform: translateX(0);
transition: transform 250ms ease;
}
.sidebar-scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 199;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.5rem 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
min-height: 52px;
}
.sidebar-logo {
display: flex;
align-items: center;
gap: 0.4rem;
overflow: hidden;
white-space: nowrap;
}
.logo-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.logo-name {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.stow-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--color-text-secondary, #6b7a99);
cursor: pointer;
font-size: 1.1rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.stow-btn:hover {
background: var(--color-border, #d0d7e8);
}
.nav-list {
list-style: none;
padding: 0.5rem 0;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
color: var(--color-text, #1a2338);
text-decoration: none;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
position: relative;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: color-mix(in srgb, var(--app-primary, #2A6080) 10%, transparent);
}
.nav-item.router-link-active {
background: color-mix(in srgb, var(--app-primary, #2A6080) 15%, transparent);
color: var(--app-primary, #2A6080);
font-weight: 600;
}
.nav-item.router-link-active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--app-primary, #2A6080);
border-radius: 0 2px 2px 0;
}
.nav-icon {
font-size: 1.1rem;
flex-shrink: 0;
width: 24px;
text-align: center;
}
.nav-label {
overflow: hidden;
text-overflow: ellipsis;
}
/* Mobile hamburger — visible when sidebar is stowed on mobile */
.mobile-hamburger {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 201;
width: 36px;
height: 36px;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface-raised, #e4ebf5);
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
@media (prefers-reduced-motion: reduce) {
.sidebar,
.sidebar.mobile,
.sidebar.mobile.stowed {
transition: none;
}
}
</style>

View file

@ -0,0 +1,39 @@
import { mount } from '@vue/test-utils'
import EmailCard from './EmailCard.vue'
import { describe, it, expect } from 'vitest'
const item = {
id: 'abc', subject: 'Interview Invitation',
body: 'Hi there, we would like to schedule a phone screen with you. This will be a 30-minute call.',
from: 'recruiter@acme.com', date: '2026-03-01', source: 'imap:test',
}
describe('EmailCard', () => {
it('renders subject', () => {
const w = mount(EmailCard, { props: { item } })
expect(w.text()).toContain('Interview Invitation')
})
it('renders from and date', () => {
const w = mount(EmailCard, { props: { item } })
expect(w.text()).toContain('recruiter@acme.com')
expect(w.text()).toContain('2026-03-01')
})
it('renders truncated body by default', () => {
const w = mount(EmailCard, { props: { item } })
expect(w.text()).toContain('Hi there')
})
it('emits expand on button click', async () => {
const w = mount(EmailCard, { props: { item } })
await w.find('[data-testid="expand-btn"]').trigger('click')
expect(w.emitted('expand')).toBeTruthy()
})
it('shows collapse button when expanded', () => {
const w = mount(EmailCard, { props: { item, expanded: true } })
expect(w.find('[data-testid="collapse-btn"]').exists()).toBe(true)
expect(w.find('[data-testid="expand-btn"]').exists()).toBe(false)
})
})

View file

@ -0,0 +1,114 @@
<template>
<article class="email-card" :class="{ expanded }">
<header class="card-header">
<h2 class="subject">{{ item.subject }}</h2>
<div class="meta">
<span class="from" :title="item.from">{{ item.from }}</span>
<time class="date" :datetime="item.date">{{ item.date }}</time>
</div>
</header>
<div class="card-body">
<p class="body-text">{{ displayBody }}</p>
</div>
<footer class="card-footer">
<button
v-if="!expanded"
data-testid="expand-btn"
class="expand-btn"
aria-label="Expand full email"
@click="$emit('expand')"
>
Show more
</button>
<button
v-else
data-testid="collapse-btn"
class="expand-btn"
aria-label="Collapse email"
@click="$emit('collapse')"
>
Show less
</button>
</footer>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { QueueItem } from '../stores/label'
const props = defineProps<{ item: QueueItem; expanded?: boolean }>()
defineEmits<{ expand: []; collapse: [] }>()
const PREVIEW_LINES = 6
const displayBody = computed(() => {
if (props.expanded) return props.item.body
const lines = props.item.body.split('\n').slice(0, PREVIEW_LINES).join('\n')
return lines.length < props.item.body.length ? lines + '…' : lines
})
</script>
<style scoped>
.email-card {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
font-family: var(--font-body);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.subject {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
line-height: 1.4;
}
.meta {
display: flex;
gap: var(--space-4);
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: var(--space-2);
flex-wrap: wrap;
}
.body-text {
color: var(--color-text);
font-size: 0.9375rem;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
margin: 0;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.expand-btn {
background: none;
border: none;
color: var(--app-primary);
cursor: pointer;
font-size: 0.875rem;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: opacity var(--transition);
}
.expand-btn:hover { opacity: 0.75; }
.expand-btn:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 3px;
}
</style>

View file

@ -0,0 +1,183 @@
import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect, vi } from 'vitest'
vi.mock('../composables/useCardAnimation', () => ({
useCardAnimation: vi.fn(() => ({
pickup: vi.fn(),
setDragPosition: vi.fn(),
snapBack: vi.fn(),
animateDismiss: vi.fn(),
updateAura: vi.fn(),
reset: vi.fn(),
})),
}))
import { useCardAnimation } from '../composables/useCardAnimation'
import { nextTick } from 'vue'
const item = {
id: 'abc',
subject: 'Interview at Acme',
body: 'We would like to schedule...',
from: 'hr@acme.com',
date: '2026-03-01',
source: 'imap:test',
}
describe('EmailCardStack', () => {
it('renders the email subject', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.text()).toContain('Interview at Acme')
})
it('renders shadow cards for depth effect', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
expect(w.findAll('.card-shadow')).toHaveLength(2)
})
it('calls animateDismiss with type when dismissType prop changes', async () => {
;(useCardAnimation as ReturnType<typeof vi.fn>).mockClear()
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
const { animateDismiss } = (useCardAnimation as ReturnType<typeof vi.fn>).mock.results[0].value
await w.setProps({ dismissType: 'label' })
await nextTick()
expect(animateDismiss).toHaveBeenCalledWith('label')
})
// JSDOM doesn't implement setPointerCapture — mock it on the element.
// Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils
// .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM).
function mockPointerCapture(element: Element) {
;(element as any).setPointerCapture = vi.fn()
;(element as any).releasePointerCapture = vi.fn()
}
function fire(element: Element, type: string, init: PointerEventInit) {
element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init }))
}
it('emits drag-start on pointerdown', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-start')).toBeTruthy()
})
it('emits drag-end on pointerup', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('drag-end')).toBeTruthy()
})
it('emits discard when released in left zone (x < 7% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 7% = 71.7px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
})
it('emits skip when released in right zone (x > 93% viewport)', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
// JSDOM window.innerWidth defaults to 1024; 93% = 952px
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
})
it('does not emit action on pointerup without movement past zone', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
expect(w.emitted('label')).toBeFalsy()
})
// Fling tests — mock performance.now() to control timestamps between events
it('emits discard on fast leftward fling (option B: speed + alignment)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 310 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 320 })
// vx = (288-400)/(50-30)*1000 = -5600 px/s, vy ≈ 500 px/s
// speed ≈ 5622 px/s > 600, alignment = 5600/5622 ≈ 0.996 > 0.707 ✓
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 320 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeTruthy()
vi.restoreAllMocks()
})
it('emits skip on fast rightward fling', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 624, clientY: 310 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 736, clientY: 320 })
// vx = (736-624)/(50-30)*1000 = 5600 px/s — mirror of discard case
fire(el, 'pointerup', { pointerId: 1, clientX: 736, clientY: 320 })
await w.vm.$nextTick()
expect(w.emitted('skip')).toBeTruthy()
vi.restoreAllMocks()
})
it('does not fling on diagonal swipe (alignment < 0.707)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 150 })
mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 0 })
// vx = -5600 px/s, vy = -7500 px/s, speed ≈ 9356 px/s
// alignment = 5600/9356 ≈ 0.598 < 0.707 — too diagonal ✓
fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 0 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
vi.restoreAllMocks()
})
it('does not fling on slow movement (speed < threshold)', async () => {
let mockTime = 0
vi.spyOn(performance, 'now').mockImplementation(() => mockTime)
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
const el = w.find('.card-wrapper').element
mockPointerCapture(el)
mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 })
mockTime = 100; fire(el, 'pointermove', { pointerId: 1, clientX: 480, clientY: 300 })
mockTime = 200; fire(el, 'pointermove', { pointerId: 1, clientX: 450, clientY: 300 })
// vx = (450-480)/(200-100)*1000 = -300 px/s < 600 threshold
fire(el, 'pointerup', { pointerId: 1, clientX: 450, clientY: 300 })
await w.vm.$nextTick()
expect(w.emitted('discard')).toBeFalsy()
expect(w.emitted('skip')).toBeFalsy()
vi.restoreAllMocks()
})
})

View file

@ -0,0 +1,272 @@
<template>
<div
class="card-stack"
:class="{ 'bucket-mode': isBucketMode && motion.rich.value }"
>
<!-- Depth shadow cards (visual stack effect) -->
<div class="card-shadow card-shadow-2" aria-hidden="true" />
<div class="card-shadow card-shadow-1" aria-hidden="true" />
<!-- Active card -->
<div
class="card-wrapper"
ref="cardEl"
:class="{ 'is-held': isHeld }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<EmailCard
:item="item"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useMotion } from '../composables/useMotion'
import { useCardAnimation } from '../composables/useCardAnimation'
import EmailCard from './EmailCard.vue'
import type { QueueItem } from '../stores/label'
const props = defineProps<{
item: QueueItem
isBucketMode: boolean
dismissType?: 'label' | 'skip' | 'discard' | null
}>()
const emit = defineEmits<{
label: [name: string]
skip: []
discard: []
'drag-start': []
'drag-end': []
'zone-hover': ['discard' | 'skip' | null]
'bucket-hover': [string | null]
}>()
const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const { pickup, setDragPosition, snapBack, animateDismiss, updateAura, reset } = useCardAnimation(cardEl, motion)
watch(() => props.dismissType, (type) => {
if (type) animateDismiss(type)
})
// When a new card loads into the same element, clear any inline styles left by the previous animation
watch(() => props.item.id, () => {
reset()
isExpanded.value = false
})
// Toss gesture state
const isHeld = ref(false)
const pickupX = ref(0)
const pickupY = ref(0)
const hoveredZone = ref<'discard' | 'skip' | null>(null)
const hoveredBucketName = ref<string | null>(null)
// Zone threshold: 7% of viewport width on each side
const ZONE_PCT = 0.07
// Fling detection Option B: speed + direction alignment
// Plain array (not ref) drives no template state, updates on every pointermove
const FLING_SPEED_PX_S = 600 // px/s minimum to qualify as a fling
const FLING_ALIGN = 0.707 // cos(45°) velocity must point within 45° of horizontal
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
pickupY.value = e.clientY
isHeld.value = true
pickup()
hoveredZone.value = null
hoveredBucketName.value = null
velocityBuf = []
emit('drag-start')
}
function onPointerMove(e: PointerEvent) {
if (!isHeld.value) return
const dx = e.clientX - pickupX.value
const dy = e.clientY - pickupY.value
setDragPosition(dx, dy)
// Rolling velocity buffer keep only the last FLING_WINDOW_MS of samples
const now = performance.now()
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
velocityBuf.shift()
}
const vw = window.innerWidth
const zone: 'discard' | 'skip' | null =
e.clientX < vw * ZONE_PCT ? 'discard' :
e.clientX > vw * (1 - ZONE_PCT) ? 'skip' :
null
if (zone !== hoveredZone.value) {
hoveredZone.value = zone
emit('zone-hover', zone)
}
// Bucket detection via hit-test (works through overlapping elements).
// Optional chain guards against JSDOM in tests (doesn't implement elementsFromPoint).
const els = document.elementsFromPoint?.(e.clientX, e.clientY) ?? []
const bucket = els.find(el => el.hasAttribute('data-label-key'))
const bucketName = bucket?.getAttribute('data-label-key') ?? null
if (bucketName !== hoveredBucketName.value) {
hoveredBucketName.value = bucketName
emit('bucket-hover', bucketName)
}
updateAura(hoveredZone.value, hoveredBucketName.value)
}
function onPointerUp(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
// Fling detection (Option B): fires before zone check so a fast fling
// resolves even if the pointer didn't reach the 7% edge zone
if (hoveredZone.value === null && hoveredBucketName.value === null
&& velocityBuf.length >= 2) {
const oldest = velocityBuf[0]
const newest = velocityBuf[velocityBuf.length - 1]
const dt = (newest.t - oldest.t) / 1000 // seconds
if (dt > 0) {
const vx = (newest.x - oldest.x) / dt
const vy = (newest.y - oldest.y) / dt
const speed = Math.sqrt(vx * vx + vy * vy)
// Require: fast enough AND velocity points within 45° of horizontal
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
velocityBuf = []
emit(vx < 0 ? 'discard' : 'skip')
return
}
}
}
velocityBuf = []
if (hoveredZone.value === 'discard') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('discard')
} else if (hoveredZone.value === 'skip') {
hoveredZone.value = null
hoveredBucketName.value = null
emit('skip')
} else if (hoveredBucketName.value) {
const name = hoveredBucketName.value
hoveredZone.value = null
hoveredBucketName.value = null
emit('label', name)
} else {
// Snap back
snapBack()
updateAura(null, null)
hoveredZone.value = null
hoveredBucketName.value = null
}
}
function onPointerCancel(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
snapBack()
updateAura(null, null)
hoveredZone.value = null
hoveredBucketName.value = null
velocityBuf = []
emit('drag-end')
emit('zone-hover', null)
emit('bucket-hover', null)
}
</script>
<style scoped>
.card-stack {
position: relative;
min-height: 200px;
max-height: 2000px; /* effectively unlimited — needed for max-height transition */
overflow: hidden;
transition:
max-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
min-height 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Bucket mode: collapse card stack to a small pill so buckets get more room */
.card-stack.bucket-mode {
min-height: 0;
max-height: 90px;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible; /* ball must escape the collapsed stack bounds */
}
.card-stack.bucket-mode .card-shadow {
opacity: 0;
transition: opacity 180ms ease;
}
.card-stack.bucket-mode .card-wrapper {
clip-path: inset(35% 15% round 0.75rem);
opacity: 0.35;
pointer-events: none;
}
.card-shadow {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
border: 1px solid var(--color-border, #e0e4ed);
transition: opacity 180ms ease;
}
.card-shadow-1 { transform: translateY(8px) scale(0.97); opacity: 0.6; }
.card-shadow-2 { transform: translateY(16px) scale(0.94); opacity: 0.35; }
.card-wrapper {
position: relative;
z-index: 1;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised, #fff);
will-change: clip-path, opacity;
clip-path: inset(0% 0% round 1rem);
touch-action: none; /* prevent scroll from stealing the gesture */
cursor: grab;
transition:
clip-path 260ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
}
.card-wrapper.is-held {
cursor: grabbing;
/* Override bucket-mode clip and opacity so the held ball renders cleanly */
clip-path: none !important;
opacity: 1 !important;
pointer-events: auto !important;
}
@media (prefers-reduced-motion: reduce) {
.card-stack,
.card-wrapper {
transition: none;
}
}
</style>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View file

@ -0,0 +1,52 @@
import { mount } from '@vue/test-utils'
import LabelBucketGrid from './LabelBucketGrid.vue'
import { describe, it, expect } from 'vitest'
const labels = [
{ name: 'interview_scheduled', emoji: '🗓️', color: '#4CAF50', key: '1' },
{ name: 'offer_received', emoji: '🎉', color: '#2196F3', key: '2' },
{ name: 'rejected', emoji: '❌', color: '#F44336', key: '3' },
]
describe('LabelBucketGrid', () => {
it('renders all labels', () => {
const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } })
expect(w.findAll('[data-testid="label-btn"]')).toHaveLength(3)
})
it('emits label event on click', async () => {
const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } })
await w.find('[data-testid="label-btn"]').trigger('click')
expect(w.emitted('label')?.[0]).toEqual(['interview_scheduled'])
})
it('applies bucket-mode class when isBucketMode is true', () => {
const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: true } })
expect(w.find('.label-grid').classes()).toContain('bucket-mode')
})
it('shows key hint and emoji', () => {
const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } })
const btn = w.find('[data-testid="label-btn"]')
expect(btn.text()).toContain('1')
expect(btn.text()).toContain('🗓️')
})
it('marks button as drop-target when hoveredBucket matches label name', () => {
const w = mount(LabelBucketGrid, {
props: { labels, isBucketMode: true, hoveredBucket: 'interview_scheduled' },
})
const btns = w.findAll('[data-testid="label-btn"]')
expect(btns[0].classes()).toContain('is-drop-target')
expect(btns[1].classes()).not.toContain('is-drop-target')
})
it('no button marked as drop-target when hoveredBucket is null', () => {
const w = mount(LabelBucketGrid, {
props: { labels, isBucketMode: false, hoveredBucket: null },
})
w.findAll('[data-testid="label-btn"]').forEach(btn => {
expect(btn.classes()).not.toContain('is-drop-target')
})
})
})

View file

@ -0,0 +1,132 @@
<template>
<div class="label-grid" :class="{ 'bucket-mode': isBucketMode }" role="group" aria-label="Label buttons">
<button
v-for="label in displayLabels"
:key="label.key"
data-testid="label-btn"
:data-label-key="label.name"
class="label-btn"
:class="{ 'is-drop-target': props.hoveredBucket === label.name }"
:style="{ '--label-color': label.color }"
:aria-label="`Label as ${label.name.replace(/_/g, ' ')} (key: ${label.key})`"
@click="$emit('label', label.name)"
>
<span class="key-hint" aria-hidden="true">{{ label.key }}</span>
<span class="emoji" aria-hidden="true">{{ label.emoji }}</span>
<span class="label-name">{{ label.name.replace(/_/g, '\u00a0') }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Label { name: string; emoji: string; color: string; key: string }
const props = defineProps<{
labels: Label[]
isBucketMode: boolean
hoveredBucket?: string | null
}>()
const emit = defineEmits<{ label: [name: string] }>()
// Numpad layout: reverse the row order of numeric keys (7-8-9 on top, 1-2-3 on bottom)
// Non-numeric keys (e.g. 'h' for hired) stay pinned after the grid.
const displayLabels = computed(() => {
const numeric = props.labels.filter(l => !isNaN(Number(l.key)))
const other = props.labels.filter(l => isNaN(Number(l.key)))
const rows: Label[][] = []
for (let i = 0; i < numeric.length; i += 3) rows.push(numeric.slice(i, i + 3))
return [...rows.reverse().flat(), ...other]
})
</script>
<style scoped>
.label-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
transition: gap var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
}
/* 10th button (hired / key h) — full-width bar below the 3×3 */
.label-btn:last-child {
grid-column: 1 / -1;
}
.label-grid.bucket-mode {
gap: 1rem;
padding: 1rem;
}
.label-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-height: 44px; /* Touch target */
padding: 0.5rem 0.25rem;
border-radius: 0.5rem;
border: 2px solid var(--label-color, #607D8B);
background: transparent;
color: var(--color-text, #1a2338);
cursor: pointer;
transition: min-height var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
padding var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
border-width var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
font-size var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)),
background var(--transition, 200ms ease),
transform var(--transition, 200ms ease),
box-shadow var(--transition, 200ms ease),
opacity var(--transition, 200ms ease);
font-family: var(--font-body, sans-serif);
}
.label-grid.bucket-mode .label-btn {
min-height: 80px;
padding: 1rem 0.5rem;
border-width: 3px;
font-size: 1.1rem;
}
.label-btn.is-drop-target {
background: var(--label-color, #607D8B);
color: #fff;
transform: scale(1.08);
box-shadow: 0 0 16px color-mix(in srgb, var(--label-color, #607D8B) 60%, transparent);
}
.label-btn:hover:not(.is-drop-target) {
background: color-mix(in srgb, var(--label-color, #607D8B) 12%, transparent);
}
.key-hint {
font-size: 0.65rem;
font-family: var(--font-mono, monospace);
opacity: 0.55;
line-height: 1;
}
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.label-name {
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
word-break: break-word;
hyphens: auto;
}
/* Reduced-motion fallback */
@media (prefers-reduced-motion: reduce) {
.label-grid,
.label-btn {
transition: none;
}
}
</style>

View file

@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import SftCard from './SftCard.vue'
import type { SftQueueItem } from '../stores/sft'
import { describe, it, expect } from 'vitest'
const LOW_QUALITY_ITEM: SftQueueItem = {
id: 'abc', source: 'cf-orch-benchmark', benchmark_run_id: 'run1',
timestamp: '2026-04-07T10:00:00Z', status: 'needs_review',
prompt_messages: [
{ role: 'system', content: 'You are a coding assistant.' },
{ role: 'user', content: 'Write a Python add function.' },
],
model_response: 'def add(a, b): return a - b',
corrected_response: null, quality_score: 0.2,
failure_reason: 'pattern_match: 0/2 matched',
task_id: 'code-fn', task_type: 'code', task_name: 'Code: Write a function',
model_id: 'Qwen/Qwen2.5-3B', model_name: 'Qwen2.5-3B',
node_id: 'heimdall', gpu_id: 0, tokens_per_sec: 38.4,
}
const MID_QUALITY_ITEM: SftQueueItem = { ...LOW_QUALITY_ITEM, id: 'mid', quality_score: 0.55 }
const HIGH_QUALITY_ITEM: SftQueueItem = { ...LOW_QUALITY_ITEM, id: 'hi', quality_score: 0.72 }
describe('SftCard', () => {
it('renders model name chip', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.text()).toContain('Qwen2.5-3B')
})
it('renders task type chip', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.text()).toContain('code')
})
it('renders failure reason', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.text()).toContain('pattern_match: 0/2 matched')
})
it('renders model response', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.text()).toContain('def add(a, b): return a - b')
})
it('quality chip shows numeric value for low quality', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.text()).toContain('0.20')
})
it('quality chip has low-quality class when score < 0.4', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-low')
})
it('quality chip has mid-quality class when score is 0.4 to <0.7', () => {
const w = mount(SftCard, { props: { item: MID_QUALITY_ITEM } })
expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-mid')
})
it('quality chip has acceptable class when score >= 0.7', () => {
const w = mount(SftCard, { props: { item: HIGH_QUALITY_ITEM } })
expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-ok')
})
it('clicking Correct button emits correct', async () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
await w.find('[data-testid="correct-btn"]').trigger('click')
expect(w.emitted('correct')).toBeTruthy()
})
it('clicking Discard button emits discard', async () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
await w.find('[data-testid="discard-btn"]').trigger('click')
expect(w.emitted('discard')).toBeTruthy()
})
it('clicking Flag Model button emits flag', async () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
await w.find('[data-testid="flag-btn"]').trigger('click')
expect(w.emitted('flag')).toBeTruthy()
})
it('correction area hidden initially', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } })
expect(w.find('[data-testid="correction-area"]').exists()).toBe(false)
})
it('correction area shown when correcting prop is true', () => {
const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM, correcting: true } })
expect(w.find('[data-testid="correction-area"]').exists()).toBe(true)
})
it('renders nothing for failure reason when null', () => {
const item = { ...LOW_QUALITY_ITEM, failure_reason: null }
const w = mount(SftCard, { props: { item } })
expect(w.find('.failure-reason').exists()).toBe(false)
})
})

View file

@ -0,0 +1,246 @@
<template>
<article class="sft-card">
<!-- Chips row -->
<div class="chips-row">
<span class="chip chip-model">{{ item.model_name }}</span>
<span class="chip chip-task">{{ item.task_type }}</span>
<span class="chip chip-node">{{ item.node_id }} · GPU {{ item.gpu_id }}</span>
<span class="chip chip-speed">{{ item.tokens_per_sec.toFixed(1) }} tok/s</span>
<span
class="chip quality-chip"
:class="qualityClass"
data-testid="quality-chip"
:title="qualityLabel"
>
{{ item.quality_score.toFixed(2) }} · {{ qualityLabel }}
</span>
</div>
<!-- Failure reason -->
<p v-if="item.failure_reason" class="failure-reason">{{ item.failure_reason }}</p>
<!-- Prompt (collapsible) -->
<div class="prompt-section">
<button
class="prompt-toggle"
:aria-expanded="promptExpanded"
@click="promptExpanded = !promptExpanded"
>
{{ promptExpanded ? 'Hide prompt ↑' : 'Show full prompt ↓' }}
</button>
<div v-if="promptExpanded" class="prompt-messages">
<div
v-for="(msg, i) in item.prompt_messages"
:key="i"
class="prompt-message"
:class="`role-${msg.role}`"
>
<span class="role-label">{{ msg.role }}</span>
<pre class="message-content">{{ msg.content }}</pre>
</div>
</div>
</div>
<!-- Model response -->
<div class="model-response-section">
<p class="section-label">Model output (incorrect)</p>
<pre class="model-response">{{ item.model_response }}</pre>
</div>
<!-- Action bar -->
<div class="action-bar">
<button
data-testid="correct-btn"
class="btn-correct"
@click="$emit('correct')"
> Correct</button>
<button
data-testid="discard-btn"
class="btn-discard"
@click="$emit('discard')"
> Discard</button>
<button
data-testid="flag-btn"
class="btn-flag"
@click="$emit('flag')"
> Flag Model</button>
</div>
<!-- Correction area (shown when correcting = true) -->
<div v-if="correcting" data-testid="correction-area">
<SftCorrectionArea
ref="correctionAreaEl"
:described-by="'sft-failure-' + item.id"
@submit="$emit('submit-correction', $event)"
@cancel="$emit('cancel-correction')"
/>
</div>
</article>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { SftQueueItem } from '../stores/sft'
import SftCorrectionArea from './SftCorrectionArea.vue'
const props = defineProps<{ item: SftQueueItem; correcting?: boolean }>()
const emit = defineEmits<{
correct: []
discard: []
flag: []
'submit-correction': [text: string]
'cancel-correction': []
}>()
const promptExpanded = ref(false)
const correctionAreaEl = ref<InstanceType<typeof SftCorrectionArea> | null>(null)
const qualityClass = computed(() => {
const s = props.item.quality_score
if (s < 0.4) return 'quality-low'
if (s < 0.7) return 'quality-mid'
return 'quality-ok'
})
const qualityLabel = computed(() => {
const s = props.item.quality_score
if (s < 0.4) return 'low quality'
if (s < 0.7) return 'fair'
return 'acceptable'
})
function resetCorrection() {
correctionAreaEl.value?.reset()
}
defineExpose({ resetCorrection })
</script>
<style scoped>
.sft-card {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.chips-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.chip-model { background: var(--color-primary-light, #e8f2e7); color: var(--color-primary); }
.chip-task { background: var(--color-surface-alt); color: var(--color-text-muted); }
.chip-node { background: var(--color-surface-alt); color: var(--color-text-muted); }
.chip-speed { background: var(--color-surface-alt); color: var(--color-text-muted); }
.quality-chip { color: #fff; }
.quality-low { background: var(--color-error, #c0392b); }
.quality-mid { background: var(--color-warning, #d4891a); }
.quality-ok { background: var(--color-success, #3a7a32); }
.failure-reason {
font-size: 0.82rem;
color: var(--color-text-muted);
font-style: italic;
}
.prompt-toggle {
background: none;
border: none;
color: var(--color-accent);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.prompt-messages {
margin-top: var(--space-2);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.prompt-message {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.role-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.message-content {
font-family: var(--font-mono);
font-size: 0.82rem;
white-space: pre-wrap;
background: var(--color-surface-alt);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
max-height: 200px;
overflow-y: auto;
}
.section-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.model-response {
font-family: var(--font-mono);
font-size: 0.88rem;
white-space: pre-wrap;
background: color-mix(in srgb, var(--color-error, #c0392b) 8%, var(--color-surface-alt));
border-left: 3px solid var(--color-error, #c0392b);
padding: var(--space-3);
border-radius: var(--radius-md);
max-height: 300px;
overflow-y: auto;
}
.action-bar {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.action-bar button {
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
font-size: 0.9rem;
cursor: pointer;
background: var(--color-surface-raised);
color: var(--color-text);
}
.btn-correct { border-color: var(--color-success); color: var(--color-success); }
.btn-correct:hover { background: color-mix(in srgb, var(--color-success) 10%, transparent); }
.btn-discard { border-color: var(--color-error); color: var(--color-error); }
.btn-discard:hover { background: color-mix(in srgb, var(--color-error) 10%, transparent); }
.btn-flag { border-color: var(--color-warning); color: var(--color-warning); }
.btn-flag:hover { background: color-mix(in srgb, var(--color-warning) 10%, transparent); }
</style>

View file

@ -0,0 +1,68 @@
import { mount } from '@vue/test-utils'
import SftCorrectionArea from './SftCorrectionArea.vue'
import { describe, it, expect } from 'vitest'
describe('SftCorrectionArea', () => {
it('renders a textarea', () => {
const w = mount(SftCorrectionArea)
expect(w.find('textarea').exists()).toBe(true)
})
it('submit button is disabled when textarea is empty', () => {
const w = mount(SftCorrectionArea)
const btn = w.find('[data-testid="submit-btn"]')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
it('submit button is disabled when textarea is whitespace only', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').setValue(' ')
const btn = w.find('[data-testid="submit-btn"]')
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
})
it('submit button is enabled when textarea has content', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').setValue('def add(a, b): return a + b')
const btn = w.find('[data-testid="submit-btn"]')
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
})
it('clicking submit emits submit with trimmed text', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').setValue(' def add(a, b): return a + b ')
await w.find('[data-testid="submit-btn"]').trigger('click')
expect(w.emitted('submit')?.[0]).toEqual(['def add(a, b): return a + b'])
})
it('clicking cancel emits cancel', async () => {
const w = mount(SftCorrectionArea)
await w.find('[data-testid="cancel-btn"]').trigger('click')
expect(w.emitted('cancel')).toBeTruthy()
})
it('Escape key emits cancel', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').trigger('keydown', { key: 'Escape' })
expect(w.emitted('cancel')).toBeTruthy()
})
it('Ctrl+Enter emits submit when text is non-empty', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').setValue('correct answer')
await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
expect(w.emitted('submit')?.[0]).toEqual(['correct answer'])
})
it('Ctrl+Enter does not emit submit when text is empty', async () => {
const w = mount(SftCorrectionArea)
await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
expect(w.emitted('submit')).toBeFalsy()
})
it('omits aria-describedby when describedBy prop is not provided', () => {
const w = mount(SftCorrectionArea)
const textarea = w.find('textarea')
expect(textarea.attributes('aria-describedby')).toBeUndefined()
})
})

View file

@ -0,0 +1,130 @@
<template>
<div class="correction-area">
<label class="correction-label" for="correction-textarea">
Write the corrected response:
</label>
<textarea
id="correction-textarea"
ref="textareaEl"
v-model="text"
class="correction-textarea"
aria-label="Write corrected response"
aria-required="true"
:aria-describedby="describedBy || undefined"
placeholder="Write the response this model should have given..."
rows="4"
@keydown.escape="$emit('cancel')"
@keydown.enter.ctrl.prevent="submitIfValid"
@keydown.enter.meta.prevent="submitIfValid"
/>
<div class="correction-actions">
<button
data-testid="submit-btn"
class="btn-submit"
:disabled="!isValid"
@click="submitIfValid"
>
Submit correction
</button>
<button data-testid="cancel-btn" class="btn-cancel" @click="$emit('cancel')">
Cancel
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const props = withDefaults(defineProps<{ describedBy?: string }>(), { describedBy: undefined })
const emit = defineEmits<{ submit: [text: string]; cancel: [] }>()
const text = ref('')
const textareaEl = ref<HTMLTextAreaElement | null>(null)
const isValid = computed(() => text.value.trim().length > 0)
onMounted(() => textareaEl.value?.focus())
function submitIfValid() {
if (isValid.value) emit('submit', text.value.trim())
}
function reset() {
text.value = ''
}
defineExpose({ reset })
</script>
<style scoped>
.correction-area {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border-top: 1px solid var(--color-border);
background: var(--color-surface-alt, var(--color-surface));
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.correction-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-muted);
}
.correction-textarea {
width: 100%;
min-height: 7rem;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.88rem;
line-height: 1.5;
resize: vertical;
}
.correction-textarea:focus {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.correction-actions {
display: flex;
gap: var(--space-3);
align-items: center;
}
.btn-submit {
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
color: var(--color-text-inverse, #fff);
border: none;
border-radius: var(--radius-md);
font-size: 0.9rem;
cursor: pointer;
}
.btn-submit:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-submit:not(:disabled):hover {
background: var(--color-primary-hover, var(--color-primary));
}
.btn-cancel {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.9rem;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
</style>

View file

@ -0,0 +1,89 @@
import { mount } from '@vue/test-utils'
import UndoToast from './UndoToast.vue'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock requestAnimationFrame for jsdom
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
// Call with a fake timestamp to simulate one frame
setTimeout(() => fn(16), 0)
return 1
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
const labelAction = {
type: 'label' as const,
item: { id: 'abc', subject: 'Interview at Acme', body: '...', from: 'hr@acme.com', date: '2026-03-01', source: 'imap:test' },
label: 'interview_scheduled',
}
const skipAction = {
type: 'skip' as const,
item: { id: 'xyz', subject: 'Cold Outreach', body: '...', from: 'recruiter@x.com', date: '2026-03-01', source: 'imap:test' },
}
const discardAction = {
type: 'discard' as const,
item: { id: 'def', subject: 'Spam Email', body: '...', from: 'spam@spam.com', date: '2026-03-01', source: 'imap:test' },
}
describe('UndoToast', () => {
it('renders subject for a label action', () => {
const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.text()).toContain('Interview at Acme')
expect(w.text()).toContain('interview_scheduled')
})
it('renders subject for a skip action', () => {
const w = mount(UndoToast, { props: { action: skipAction } })
expect(w.text()).toContain('Cold Outreach')
expect(w.text()).toContain('Skipped')
})
it('renders subject for a discard action', () => {
const w = mount(UndoToast, { props: { action: discardAction } })
expect(w.text()).toContain('Spam Email')
expect(w.text()).toContain('Discarded')
})
it('has undo button', () => {
const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.find('.undo-btn').exists()).toBe(true)
})
it('emits undo when button clicked', async () => {
const w = mount(UndoToast, { props: { action: labelAction } })
await w.find('.undo-btn').trigger('click')
expect(w.emitted('undo')).toBeTruthy()
})
it('has timer bar element', () => {
const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.find('.timer-bar').exists()).toBe(true)
})
it('has accessible role=status', () => {
const w = mount(UndoToast, { props: { action: labelAction } })
expect(w.find('[role="status"]').exists()).toBe(true)
})
it('emits expire when tick fires with timestamp beyond DURATION', async () => {
let capturedTick: FrameRequestCallback | null = null
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
capturedTick = fn
return 1
})
vi.spyOn(performance, 'now').mockReturnValue(0)
const w = mount(UndoToast, { props: { action: labelAction } })
await import('vue').then(v => v.nextTick())
// Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION
if (capturedTick) capturedTick(6000)
await import('vue').then(v => v.nextTick())
expect(w.emitted('expire')).toBeTruthy()
})
})

View file

@ -0,0 +1,106 @@
<template>
<div class="undo-toast" role="status" aria-live="polite">
<span class="toast-label">{{ label }}</span>
<button class="undo-btn" @click="$emit('undo')"> Undo</button>
<div class="timer-track" aria-hidden="true">
<div class="timer-bar" :style="{ width: `${progress}%` }" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import type { LastAction } from '../stores/label'
const props = defineProps<{ action: LastAction }>()
const emit = defineEmits<{ undo: []; expire: [] }>()
const DURATION = 5000
const elapsed = ref(0)
let start = 0
let raf = 0
const progress = computed(() => Math.max(0, 100 - (elapsed.value / DURATION) * 100))
const label = computed(() => {
const name = props.action.item.subject
if (props.action.type === 'label') return `Labeled "${name}" as ${props.action.label}`
if (props.action.type === 'discard') return `Discarded "${name}"`
return `Skipped "${name}"`
})
function tick(ts: number) {
elapsed.value = ts - start
if (elapsed.value < DURATION) {
raf = requestAnimationFrame(tick)
} else {
emit('expire')
}
}
onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) })
onUnmounted(() => cancelAnimationFrame(raf))
</script>
<style scoped>
.undo-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
min-width: 280px;
max-width: 480px;
background: var(--color-surface-overlay, #1a2338);
color: var(--color-text-inverse, #f0f4fc);
border-radius: var(--radius-toast, 0.75rem);
padding: 0.75rem 1rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
z-index: 1000;
}
.toast-label {
flex: 1;
font-size: 0.9rem;
font-family: var(--font-body, sans-serif);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.undo-btn {
flex-shrink: 0;
padding: 0.3rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--app-primary, #5A9DBF);
background: transparent;
color: var(--app-primary, #5A9DBF);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
margin-bottom: 0.75rem;
}
.undo-btn:hover {
background: var(--app-primary, #5A9DBF);
color: #fff;
}
.timer-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255,255,255,0.15);
border-radius: 0 0 0.75rem 0.75rem;
overflow: hidden;
}
.timer-bar {
height: 100%;
background: var(--app-primary, #5A9DBF);
transition: width 0.1s linear;
}
</style>

View file

@ -0,0 +1,50 @@
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
export async function useApiFetch<T>(
url: string,
opts?: RequestInit,
): Promise<{ data: T | null; error: ApiError | null }> {
try {
const res = await fetch(url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
}
const data = await res.json() as T
return { data, error: null }
} catch (e) {
return { data: null, error: { kind: 'network', message: String(e) } }
}
}
/**
* Open an SSE connection. Returns a cleanup function.
* onEvent receives each parsed JSON payload.
* onComplete is called when the server sends a {"type":"complete"} event.
* onError is called on connection error.
*/
export function useApiSSE(
url: string,
onEvent: (data: Record<string, unknown>) => void,
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>
onEvent(data)
if (data.type === 'complete') {
es.close()
onComplete?.()
}
} catch { /* ignore malformed events */ }
}
es.onerror = (e) => {
onError?.(e)
es.close()
}
return () => es.close()
}

View file

@ -0,0 +1,142 @@
import { ref } from 'vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock animejs before importing the composable
vi.mock('animejs', () => ({
animate: vi.fn(),
spring: vi.fn(() => 'mock-spring'),
utils: { set: vi.fn() },
}))
import { useCardAnimation } from './useCardAnimation'
import { animate, utils } from 'animejs'
const mockAnimate = animate as ReturnType<typeof vi.fn>
const mockSet = utils.set as ReturnType<typeof vi.fn>
function makeEl() {
return document.createElement('div')
}
describe('useCardAnimation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('pickup() calls animate with ball shape', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { pickup } = useCardAnimation(cardEl, motion)
pickup()
expect(mockAnimate).toHaveBeenCalledWith(
el,
expect.objectContaining({ scale: 0.55, borderRadius: '50%' }),
)
})
it('pickup() is a no-op when motion.rich is false', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(false) }
const { pickup } = useCardAnimation(cardEl, motion)
pickup()
expect(mockAnimate).not.toHaveBeenCalled()
})
it('setDragPosition() calls utils.set with translated coords', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { setDragPosition } = useCardAnimation(cardEl, motion)
setDragPosition(50, 30)
expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ x: 50, y: -50 }))
// y = deltaY - 80 = 30 - 80 = -50
})
it('snapBack() calls animate returning to card shape', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { snapBack } = useCardAnimation(cardEl, motion)
snapBack()
expect(mockAnimate).toHaveBeenCalledWith(
el,
expect.objectContaining({ x: 0, y: 0, scale: 1 }),
)
})
it('animateDismiss("label") calls animate', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { animateDismiss } = useCardAnimation(cardEl, motion)
animateDismiss('label')
expect(mockAnimate).toHaveBeenCalled()
})
it('animateDismiss("discard") calls animate', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { animateDismiss } = useCardAnimation(cardEl, motion)
animateDismiss('discard')
expect(mockAnimate).toHaveBeenCalled()
})
it('animateDismiss("skip") calls animate', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { animateDismiss } = useCardAnimation(cardEl, motion)
animateDismiss('skip')
expect(mockAnimate).toHaveBeenCalled()
})
it('animateDismiss is a no-op when motion.rich is false', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(false) }
const { animateDismiss } = useCardAnimation(cardEl, motion)
animateDismiss('label')
expect(mockAnimate).not.toHaveBeenCalled()
})
describe('updateAura', () => {
it('sets red background for discard zone', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { updateAura } = useCardAnimation(cardEl, motion)
updateAura('discard', null)
expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ background: 'rgba(244, 67, 54, 0.25)' }))
})
it('sets orange background for skip zone', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { updateAura } = useCardAnimation(cardEl, motion)
updateAura('skip', null)
expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ background: 'rgba(255, 152, 0, 0.25)' }))
})
it('sets blue background for bucket hover', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { updateAura } = useCardAnimation(cardEl, motion)
updateAura(null, 'interview_scheduled')
expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ background: 'rgba(42, 96, 128, 0.20)' }))
})
it('sets transparent background when no zone/bucket', () => {
const el = makeEl()
const cardEl = ref<HTMLElement | null>(el)
const motion = { rich: ref(true) }
const { updateAura } = useCardAnimation(cardEl, motion)
updateAura(null, null)
expect(mockSet).toHaveBeenCalledWith(el, expect.objectContaining({ background: 'transparent' }))
})
})
})

View file

@ -0,0 +1,99 @@
import { type Ref } from 'vue'
import { animate, spring, utils } from 'animejs'
const BALL_SCALE = 0.55
const BALL_RADIUS = '50%'
const CARD_RADIUS = '1rem'
const PICKUP_Y_OFFSET = 80 // px above finger
const PICKUP_DURATION = 200
// Anime.js v4: spring() takes an object { mass, stiffness, damping, velocity }
const SNAP_SPRING = spring({ mass: 1, stiffness: 80, damping: 10 })
interface Motion { rich: Ref<boolean> }
export function useCardAnimation(
cardEl: Ref<HTMLElement | null>,
motion: Motion,
) {
function pickup() {
if (!motion.rich.value || !cardEl.value) return
// Anime.js v4: animate(target, params) — all props + timing in one object
animate(cardEl.value, {
scale: BALL_SCALE,
borderRadius: BALL_RADIUS,
y: -PICKUP_Y_OFFSET,
duration: PICKUP_DURATION,
ease: SNAP_SPRING,
})
}
function setDragPosition(dx: number, dy: number) {
if (!cardEl.value) return
// utils.set() for instant (no-animation) position update — keeps Anime cache consistent
utils.set(cardEl.value, { x: dx, y: dy - PICKUP_Y_OFFSET })
}
function snapBack() {
if (!motion.rich.value || !cardEl.value) return
animate(cardEl.value, {
x: 0,
y: 0,
scale: 1,
borderRadius: CARD_RADIUS,
ease: SNAP_SPRING,
})
}
function animateDismiss(type: 'label' | 'skip' | 'discard') {
if (!motion.rich.value || !cardEl.value) return
const el = cardEl.value
if (type === 'label') {
animate(el, { y: '-120%', scale: 0.85, opacity: 0, duration: 280, ease: 'out(3)' })
} else if (type === 'discard') {
// Anime.js v4 keyframe array: array of param objects, each can have its own duration
animate(el, {
keyframes: [
{ scale: 0.95, rotate: 2, filter: 'brightness(0.6) sepia(1) hue-rotate(-20deg)', duration: 140 },
{ scale: 0, rotate: 8, opacity: 0, duration: 210 },
],
})
} else if (type === 'skip') {
animate(el, { x: '110%', rotate: 5, opacity: 0, duration: 260, ease: 'out(2)' })
}
}
const AURA_COLORS = {
discard: 'rgba(244, 67, 54, 0.25)',
skip: 'rgba(255, 152, 0, 0.25)',
bucket: 'rgba(42, 96, 128, 0.20)',
none: 'transparent',
} as const
function updateAura(zone: 'discard' | 'skip' | null, bucket: string | null) {
if (!cardEl.value) return
const color =
zone === 'discard' ? AURA_COLORS.discard :
zone === 'skip' ? AURA_COLORS.skip :
bucket ? AURA_COLORS.bucket :
AURA_COLORS.none
utils.set(cardEl.value, { background: color })
}
function reset() {
if (!cardEl.value) return
// Instantly restore initial card state — called when a new item loads into the same element
utils.set(cardEl.value, {
x: 0,
y: 0,
scale: 1,
opacity: 1,
rotate: 0,
borderRadius: CARD_RADIUS,
background: 'transparent',
filter: 'none',
})
}
return { pickup, setDragPosition, snapBack, animateDismiss, updateAura, reset }
}

View file

@ -0,0 +1,160 @@
import { onMounted, onUnmounted } from 'vue'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
export function useKeySequence(sequence: string[], onActivate: () => void) {
let pos = 0
function handler(e: KeyboardEvent) {
if (e.key === sequence[pos]) {
pos++
if (pos === sequence.length) {
pos = 0
onActivate()
}
} else {
pos = 0
}
}
onMounted(() => window.addEventListener('keydown', handler))
onUnmounted(() => window.removeEventListener('keydown', handler))
}
export function useKonamiCode(onActivate: () => void) {
useKeySequence(KONAMI, onActivate)
useKeySequence(KONAMI_AB, onActivate)
}
export function useHackerMode() {
function toggle() {
const root = document.documentElement
if (root.dataset.theme === 'hacker') {
delete root.dataset.theme
localStorage.removeItem('cf-hacker-mode')
} else {
root.dataset.theme = 'hacker'
localStorage.setItem('cf-hacker-mode', 'true')
}
}
function restore() {
if (localStorage.getItem('cf-hacker-mode') === 'true') {
document.documentElement.dataset.theme = 'hacker'
}
}
return { toggle, restore }
}
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
if (typeof requestAnimationFrame === 'undefined') return
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')!
const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50']
const particles = Array.from({ length: 80 }, () => ({
x: originX,
y: originY,
vx: (Math.random() - 0.5) * 14,
vy: (Math.random() - 0.6) * 12,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: 5 + Math.random() * 6,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
life: 1.0,
}))
let raf = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35 // gravity
p.vx *= 0.98 // air friction
p.angle += p.spin
p.life -= 0.016
if (p.life <= 0) continue
alive = true
ctx.save()
ctx.globalAlpha = p.life
ctx.fillStyle = p.color
ctx.translate(p.x, p.y)
ctx.rotate(p.angle)
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
ctx.restore()
}
if (alive) {
raf = requestAnimationFrame(draw)
} else {
cancelAnimationFrame(raf)
canvas.remove()
}
}
raf = requestAnimationFrame(draw)
}
/** Enable cursor trail in hacker mode — returns a cleanup function. */
export function useCursorTrail() {
const DOT_COUNT = 10
const dots: HTMLElement[] = []
let positions: { x: number; y: number }[] = []
let mouseX = 0
let mouseY = 0
let raf = 0
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div')
dot.style.cssText = [
'position:fixed',
'pointer-events:none',
'z-index:9998',
'border-radius:50%',
'background:#5A9DBF',
'transition:opacity 0.1s',
].join(';')
document.body.appendChild(dot)
dots.push(dot)
}
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function animate() {
positions.unshift({ x: mouseX, y: mouseY })
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
dots.forEach((dot, i) => {
const pos = positions[i]
if (!pos) { dot.style.opacity = '0'; return }
const scale = 1 - i / DOT_COUNT
const size = Math.round(8 * scale)
dot.style.left = `${pos.x - size / 2}px`
dot.style.top = `${pos.y - size / 2}px`
dot.style.width = `${size}px`
dot.style.height = `${size}px`
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
})
raf = requestAnimationFrame(animate)
}
window.addEventListener('mousemove', onMouseMove)
raf = requestAnimationFrame(animate)
return function cleanup() {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(raf)
dots.forEach(d => d.remove())
}
}

View file

@ -0,0 +1,18 @@
import { useMotion } from './useMotion'
export function useHaptics() {
const { rich } = useMotion()
function vibrate(pattern: number | number[]) {
if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(pattern)
}
}
return {
label: () => vibrate(40),
discard: () => vibrate([40, 30, 40]),
skip: () => vibrate(15),
undo: () => vibrate([20, 20, 60]),
}
}

View file

@ -0,0 +1,106 @@
import { useLabelKeyboard } from './useLabelKeyboard'
import { describe, it, expect, vi, afterEach } from 'vitest'
const LABELS = [
{ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' },
{ name: 'offer_received', key: '2', emoji: '🎉', color: '#2196F3' },
{ name: 'rejected', key: '3', emoji: '❌', color: '#F44336' },
]
describe('useLabelKeyboard', () => {
const cleanups: (() => void)[] = []
afterEach(() => {
cleanups.forEach(fn => fn())
cleanups.length = 0
})
it('calls onLabel when digit key pressed', () => {
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
expect(onLabel).toHaveBeenCalledWith('interview_scheduled')
})
it('calls onLabel for key 2', () => {
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: '2' }))
expect(onLabel).toHaveBeenCalledWith('offer_received')
})
it('calls onLabel("hired") when h pressed', () => {
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
expect(onLabel).toHaveBeenCalledWith('hired')
})
it('calls onSkip when s pressed', () => {
const onSkip = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip, onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
expect(onSkip).toHaveBeenCalled()
})
it('calls onDiscard when d pressed', () => {
const onDiscard = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard, onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
expect(onDiscard).toHaveBeenCalled()
})
it('calls onUndo when u pressed', () => {
const onUndo = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo, onHelp: vi.fn() })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'u' }))
expect(onUndo).toHaveBeenCalled()
})
it('calls onHelp when ? pressed', () => {
const onHelp = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp })
cleanups.push(cleanup)
window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }))
expect(onHelp).toHaveBeenCalled()
})
it('ignores keydown when target is an input', () => {
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
const input = document.createElement('input')
document.body.appendChild(input)
input.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
expect(onLabel).not.toHaveBeenCalled()
document.body.removeChild(input)
})
it('cleanup removes the listener', () => {
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanup()
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' }))
expect(onLabel).not.toHaveBeenCalled()
})
it('evaluates labels getter on each keypress', () => {
const labelList: { name: string; key: string; emoji: string; color: string }[] = []
const onLabel = vi.fn()
const { cleanup } = useLabelKeyboard({ labels: () => labelList, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() })
cleanups.push(cleanup)
// Before labels loaded — pressing '1' does nothing
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
expect(onLabel).not.toHaveBeenCalled()
// Add a label (simulating async load)
labelList.push({ name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' })
window.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }))
expect(onLabel).toHaveBeenCalledWith('interview_scheduled')
})
})

View file

@ -0,0 +1,41 @@
import { onUnmounted, getCurrentInstance } from 'vue'
interface Label { name: string; key: string; emoji: string; color: string }
interface Options {
labels: Label[] | (() => Label[])
onLabel: (name: string) => void
onSkip: () => void
onDiscard: () => void
onUndo: () => void
onHelp: () => void
}
export function useLabelKeyboard(opts: Options) {
function handler(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement) return
if (e.target instanceof HTMLTextAreaElement) return
const k = e.key.toLowerCase()
// Evaluate labels lazily so reactive updates work
const labelList = typeof opts.labels === 'function' ? opts.labels() : opts.labels
const keyMap = new Map(labelList.map(l => [l.key.toLowerCase(), l.name]))
if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return }
if (k === 'h') { opts.onLabel('hired'); return }
if (k === 's') { opts.onSkip(); return }
if (k === 'd') { opts.onDiscard(); return }
if (k === 'u') { opts.onUndo(); return }
if (k === '?') { opts.onHelp(); return }
}
// Add listener immediately (composable is called in setup, not in onMounted)
window.addEventListener('keydown', handler)
const cleanup = () => window.removeEventListener('keydown', handler)
// In component context: auto-cleanup on unmount
if (getCurrentInstance()) {
onUnmounted(cleanup)
}
return { cleanup }
}

View file

@ -0,0 +1,28 @@
import { computed, ref } from 'vue'
const STORAGE_KEY = 'cf-avocet-rich-motion'
// OS-level prefers-reduced-motion — checked once at module load
const OS_REDUCED = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Reactive ref so toggling localStorage triggers re-reads in the same session
const _richOverride = ref(
typeof window !== 'undefined'
? localStorage.getItem(STORAGE_KEY)
: null
)
export function useMotion() {
const rich = computed(() =>
!OS_REDUCED && _richOverride.value !== 'false'
)
function setRich(enabled: boolean) {
localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false')
_richOverride.value = enabled ? 'true' : 'false'
}
return { rich, setRich }
}

View file

@ -0,0 +1,42 @@
// src/composables/useSftKeyboard.ts
import { onUnmounted, getCurrentInstance } from 'vue'
interface Options {
onCorrect: () => void
onDiscard: () => void
onFlag: () => void
onEscape: () => void
onSubmit: () => void
isEditing: () => boolean // returns true when correction area is open
}
export function useSftKeyboard(opts: Options) {
function handler(e: KeyboardEvent) {
// Never intercept keys when focus is in an input (correction textarea handles its own keys)
if (e.target instanceof HTMLInputElement) return
// When correction area is open, only handle Escape (textarea handles Ctrl+Enter itself)
if (e.target instanceof HTMLTextAreaElement) return
const k = e.key.toLowerCase()
if (opts.isEditing()) {
if (k === 'escape') opts.onEscape()
return
}
if (k === 'c') { opts.onCorrect(); return }
if (k === 'd') { opts.onDiscard(); return }
if (k === 'f') { opts.onFlag(); return }
if (k === 'escape') { opts.onEscape(); return }
}
window.addEventListener('keydown', handler)
const cleanup = () => window.removeEventListener('keydown', handler)
if (getCurrentInstance()) {
onUnmounted(cleanup)
}
return { cleanup }
}

20
web/src/main.ts Normal file
View file

@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/atkinson-hyperlegible/400.css'
import '@fontsource/atkinson-hyperlegible/700.css'
import '@fontsource/jetbrains-mono/400.css'
import 'virtual:uno.css'
import './assets/theme.css'
import './assets/avocet.css'
import App from './App.vue'
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

21
web/src/router/index.ts Normal file
View file

@ -0,0 +1,21 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import LabelView from '../views/LabelView.vue'
// Views are lazy-loaded to keep initial bundle small
const FetchView = () => import('../views/FetchView.vue')
const StatsView = () => import('../views/StatsView.vue')
const BenchmarkView = () => import('../views/BenchmarkView.vue')
const SettingsView = () => import('../views/SettingsView.vue')
const CorrectionsView = () => import('../views/CorrectionsView.vue')
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: LabelView, meta: { title: 'Label' } },
{ path: '/fetch', component: FetchView, meta: { title: 'Fetch' } },
{ path: '/stats', component: StatsView, meta: { title: 'Stats' } },
{ path: '/benchmark', component: BenchmarkView, meta: { title: 'Benchmark' } },
{ path: '/corrections', component: CorrectionsView, meta: { title: 'Corrections' } },
{ path: '/settings', component: SettingsView, meta: { title: 'Settings' } },
],
})

30
web/src/smoke.test.ts Normal file
View file

@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
describe('scaffold', () => {
it('vitest works', () => {
expect(1 + 1).toBe(2)
})
})
describe('composable imports', () => {
it('useApi imports', async () => {
const { useApiFetch } = await import('./composables/useApi')
expect(typeof useApiFetch).toBe('function')
})
it('useMotion imports', async () => {
const { useMotion } = await import('./composables/useMotion')
expect(typeof useMotion).toBe('function')
})
it('useHaptics imports', async () => {
const { useHaptics } = await import('./composables/useHaptics')
expect(typeof useHaptics).toBe('function')
})
it('useEasterEgg imports', async () => {
const { useKonamiCode, useHackerMode } = await import('./composables/useEasterEgg')
expect(typeof useKonamiCode).toBe('function')
expect(typeof useHackerMode).toBe('function')
})
})

View file

@ -0,0 +1,62 @@
// src/stores/label.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useLabelStore } from './label'
import { beforeEach, describe, it, expect } from 'vitest'
const MOCK_ITEM = {
id: 'abc', subject: 'Test', body: 'Body', from: 'a@b.com',
date: '2026-03-01', source: 'imap:test',
}
describe('label store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('starts with empty queue', () => {
const store = useLabelStore()
expect(store.queue).toEqual([])
expect(store.current).toBeNull()
})
it('current returns first item', () => {
const store = useLabelStore()
store.queue = [MOCK_ITEM]
expect(store.current).toEqual(MOCK_ITEM)
})
it('removeCurrentFromQueue removes first item', () => {
const store = useLabelStore()
store.queue = [MOCK_ITEM, { ...MOCK_ITEM, id: 'def' }]
store.removeCurrentFromQueue()
expect(store.queue[0].id).toBe('def')
})
it('tracks lastAction', () => {
const store = useLabelStore()
store.queue = [MOCK_ITEM]
store.setLastAction('label', MOCK_ITEM, 'interview_scheduled')
expect(store.lastAction?.type).toBe('label')
expect(store.lastAction?.label).toBe('interview_scheduled')
})
it('incrementLabeled increases sessionLabeled', () => {
const store = useLabelStore()
store.incrementLabeled()
store.incrementLabeled()
expect(store.sessionLabeled).toBe(2)
})
it('restoreItem adds to front of queue', () => {
const store = useLabelStore()
store.queue = [{ ...MOCK_ITEM, id: 'def' }]
store.restoreItem(MOCK_ITEM)
expect(store.queue[0].id).toBe('abc')
expect(store.queue[1].id).toBe('def')
})
it('clearLastAction nulls lastAction', () => {
const store = useLabelStore()
store.setLastAction('skip', MOCK_ITEM)
store.clearLastAction()
expect(store.lastAction).toBeNull()
})
})

53
web/src/stores/label.ts Normal file
View file

@ -0,0 +1,53 @@
// src/stores/label.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface QueueItem {
id: string
subject: string
body: string
from: string
date: string
source: string
}
export interface LastAction {
type: 'label' | 'skip' | 'discard'
item: QueueItem
label?: string
}
export const useLabelStore = defineStore('label', () => {
const queue = ref<QueueItem[]>([])
const totalRemaining = ref(0)
const lastAction = ref<LastAction | null>(null)
const sessionLabeled = ref(0) // for easter eggs
const current = computed(() => queue.value[0] ?? null)
function removeCurrentFromQueue() {
queue.value.shift()
}
function setLastAction(type: LastAction['type'], item: QueueItem, label?: string) {
lastAction.value = { type, item, label }
}
function clearLastAction() {
lastAction.value = null
}
function restoreItem(item: QueueItem) {
queue.value.unshift(item)
}
function incrementLabeled() {
sessionLabeled.value++
}
return {
queue, totalRemaining, lastAction, sessionLabeled, current,
removeCurrentFromQueue, setLastAction, clearLastAction,
restoreItem, incrementLabeled,
}
})

View file

@ -0,0 +1,78 @@
import { setActivePinia, createPinia } from 'pinia'
import { useSftStore } from './sft'
import type { SftQueueItem } from './sft'
import { beforeEach, describe, it, expect } from 'vitest'
function makeMockItem(overrides: Partial<SftQueueItem> = {}): SftQueueItem {
return {
id: 'abc',
source: 'cf-orch-benchmark',
benchmark_run_id: 'run1',
timestamp: '2026-04-07T10:00:00Z',
status: 'needs_review',
prompt_messages: [
{ role: 'system', content: 'You are a coding assistant.' },
{ role: 'user', content: 'Write a Python add function.' },
],
model_response: 'def add(a, b): return a - b',
corrected_response: null,
quality_score: 0.2,
failure_reason: 'pattern_match: 0/2 matched',
task_id: 'code-fn',
task_type: 'code',
task_name: 'Code: Write a Python function',
model_id: 'Qwen/Qwen2.5-3B',
model_name: 'Qwen2.5-3B',
node_id: 'heimdall',
gpu_id: 0,
tokens_per_sec: 38.4,
...overrides,
}
}
describe('useSftStore', () => {
beforeEach(() => setActivePinia(createPinia()))
it('starts with empty queue', () => {
const store = useSftStore()
expect(store.queue).toEqual([])
expect(store.current).toBeNull()
})
it('current returns first item', () => {
const store = useSftStore()
store.queue = [makeMockItem()]
expect(store.current?.id).toBe('abc')
})
it('removeCurrentFromQueue removes first item', () => {
const store = useSftStore()
const second = makeMockItem({ id: 'def' })
store.queue = [makeMockItem(), second]
store.removeCurrentFromQueue()
expect(store.queue[0].id).toBe('def')
})
it('restoreItem adds to front of queue', () => {
const store = useSftStore()
const second = makeMockItem({ id: 'def' })
store.queue = [second]
store.restoreItem(makeMockItem())
expect(store.queue[0].id).toBe('abc')
expect(store.queue[1].id).toBe('def')
})
it('setLastAction records the action', () => {
const store = useSftStore()
store.setLastAction('discard', makeMockItem())
expect(store.lastAction?.type).toBe('discard')
expect(store.lastAction?.item.id).toBe('abc')
})
it('clearLastAction nulls lastAction', () => {
const store = useSftStore()
store.setLastAction('flag', makeMockItem())
store.clearLastAction()
expect(store.lastAction).toBeNull()
})
})

58
web/src/stores/sft.ts Normal file
View file

@ -0,0 +1,58 @@
// src/stores/sft.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface SftQueueItem {
id: string
source: 'cf-orch-benchmark'
benchmark_run_id: string
timestamp: string
status: 'needs_review' | 'approved' | 'discarded' | 'model_rejected'
prompt_messages: { role: string; content: string }[]
model_response: string
corrected_response: string | null
quality_score: number // 0.0 to 1.0
failure_reason: string | null
task_id: string
task_type: string
task_name: string
model_id: string
model_name: string
node_id: string
gpu_id: number
tokens_per_sec: number
}
export interface SftLastAction {
type: 'correct' | 'discard' | 'flag'
item: SftQueueItem
}
export const useSftStore = defineStore('sft', () => {
const queue = ref<SftQueueItem[]>([])
const totalRemaining = ref(0)
const lastAction = ref<SftLastAction | null>(null)
const current = computed(() => queue.value[0] ?? null)
function removeCurrentFromQueue() {
queue.value.shift()
}
function setLastAction(type: SftLastAction['type'], item: SftQueueItem) {
lastAction.value = { type, item }
}
function clearLastAction() {
lastAction.value = null
}
function restoreItem(item: SftQueueItem) {
queue.value.unshift(item)
}
return {
queue, totalRemaining, lastAction, current,
removeCurrentFromQueue, setLastAction, clearLastAction, restoreItem,
}
})

79
web/src/style.css Normal file
View file

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

17
web/src/test-setup.ts Normal file
View file

@ -0,0 +1,17 @@
// jsdom does not implement window.matchMedia — stub it so composables that
// check prefers-reduced-motion can import without throwing.
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
}

View file

@ -0,0 +1,846 @@
<template>
<div class="bench-view">
<header class="bench-header">
<h1 class="page-title">🏁 Benchmark</h1>
<div class="header-actions">
<label class="slow-toggle" :class="{ disabled: running }">
<input type="checkbox" v-model="includeSlow" :disabled="running" />
Include slow models
</label>
<button
class="btn-run"
:disabled="running"
@click="startBenchmark"
>
{{ running ? '⏳ Running…' : results ? '🔄 Re-run' : '▶ Run Benchmark' }}
</button>
<button
v-if="running"
class="btn-cancel"
@click="cancelBenchmark"
>
Cancel
</button>
</div>
</header>
<!-- Trained models badge row -->
<div v-if="fineTunedModels.length > 0" class="trained-models-row">
<span class="trained-label">Trained:</span>
<span
v-for="m in fineTunedModels"
:key="m.name"
class="trained-badge"
:title="m.base_model_id ? `Base: ${m.base_model_id} · ${m.sample_count ?? '?'} samples` : m.name"
>
{{ m.name }}
<span v-if="m.val_macro_f1 != null" class="trained-f1">
F1 {{ (m.val_macro_f1 * 100).toFixed(1) }}%
</span>
</span>
</div>
<!-- Progress log -->
<div v-if="running || runLog.length" class="run-log">
<div class="run-log-title">
<span>{{ running ? '⏳ Running benchmark…' : runCancelled ? '⏹ Cancelled' : runError ? '❌ Failed' : '✅ Done' }}</span>
<button class="btn-ghost" @click="runLog = []; runError = ''; runCancelled = false">Clear</button>
</div>
<div class="log-lines" ref="logEl">
<div
v-for="(line, i) in runLog"
:key="i"
class="log-line"
:class="{ 'log-error': line.startsWith('ERROR') || line.startsWith('[error]') }"
>{{ line }}</div>
</div>
<p v-if="runError" class="run-error">{{ runError }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="status-notice">Loading</div>
<!-- No results yet -->
<div v-else-if="!results" class="status-notice empty">
<p>No benchmark results yet.</p>
<p class="hint">Click <strong>Run Benchmark</strong> to score all default models against your labeled data.</p>
</div>
<!-- Results -->
<template v-else>
<p class="meta-line">
<span>{{ results.sample_count.toLocaleString() }} labeled emails</span>
<span class="sep">·</span>
<span>{{ modelCount }} model{{ modelCount === 1 ? '' : 's' }}</span>
<span class="sep">·</span>
<span>{{ formatDate(results.timestamp) }}</span>
</p>
<!-- Macro-F1 chart -->
<section class="chart-section">
<h2 class="chart-title">Macro-F1 (higher = better)</h2>
<div class="bar-chart">
<div v-for="row in f1Rows" :key="row.name" class="bar-row">
<span class="bar-label" :title="row.name">{{ row.name }}</span>
<div class="bar-track">
<div
class="bar-fill"
:style="{ width: `${row.pct}%`, background: scoreColor(row.value) }"
/>
</div>
<span class="bar-value" :style="{ color: scoreColor(row.value) }">
{{ row.value.toFixed(3) }}
</span>
</div>
</div>
</section>
<!-- Latency chart -->
<section class="chart-section">
<h2 class="chart-title">Latency (ms / email, lower = better)</h2>
<div class="bar-chart">
<div v-for="row in latencyRows" :key="row.name" class="bar-row">
<span class="bar-label" :title="row.name">{{ row.name }}</span>
<div class="bar-track">
<div
class="bar-fill latency-fill"
:style="{ width: `${row.pct}%` }"
/>
</div>
<span class="bar-value">{{ row.value.toFixed(1) }} ms</span>
</div>
</div>
</section>
<!-- Per-label F1 heatmap -->
<section class="chart-section">
<h2 class="chart-title">Per-label F1</h2>
<div class="heatmap-scroll">
<table class="heatmap">
<thead>
<tr>
<th class="hm-label-col">Label</th>
<th v-for="name in modelNames" :key="name" class="hm-model-col" :title="name">
{{ name }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="label in labelNames" :key="label">
<td class="hm-label-cell">
<span class="hm-emoji">{{ LABEL_META[label]?.emoji ?? '🏷️' }}</span>
{{ label.replace(/_/g, '\u00a0') }}
</td>
<td
v-for="name in modelNames"
:key="name"
class="hm-value-cell"
:style="{ background: heatmapBg(f1For(name, label)), color: heatmapFg(f1For(name, label)) }"
:title="`${name} / ${label}: F1 ${f1For(name, label).toFixed(3)}, support ${supportFor(name, label)}`"
>
{{ f1For(name, label).toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
<p class="heatmap-hint">Hover a cell for precision / recall / support. Color: 🟢 0.7 · 🟡 0.40.7 · 🔴 &lt; 0.4</p>
</section>
</template>
<!-- Fine-tune section -->
<details class="ft-section">
<summary class="ft-summary">Fine-tune a model</summary>
<div class="ft-body">
<div class="ft-controls">
<label class="ft-field">
<span class="ft-field-label">Model</span>
<select v-model="ftModel" class="ft-select" :disabled="ftRunning">
<option value="deberta-small">deberta-small (100M, fast)</option>
<option value="bge-m3">bge-m3 (600M stop Peregrine vLLM first)</option>
</select>
</label>
<label class="ft-field">
<span class="ft-field-label">Epochs</span>
<input
v-model.number="ftEpochs"
type="number" min="1" max="20"
class="ft-epochs"
:disabled="ftRunning"
/>
</label>
<button
class="btn-run ft-run-btn"
:disabled="ftRunning"
@click="startFinetune"
>
{{ ftRunning ? '⏳ Training…' : '▶ Run fine-tune' }}
</button>
<button
v-if="ftRunning"
class="btn-cancel"
@click="cancelFinetune"
>
Cancel
</button>
</div>
<div v-if="ftRunning || ftLog.length || ftError" class="run-log ft-log">
<div class="run-log-title">
<span>{{ ftRunning ? '⏳ Training…' : ftCancelled ? '⏹ Cancelled' : ftError ? '❌ Failed' : '✅ Done' }}</span>
<button class="btn-ghost" @click="ftLog = []; ftError = ''; ftCancelled = false">Clear</button>
</div>
<div class="log-lines" ref="ftLogEl">
<div
v-for="(line, i) in ftLog"
:key="i"
class="log-line"
:class="{ 'log-error': line.startsWith('ERROR') || line.startsWith('[error]') }"
>{{ line }}</div>
</div>
<p v-if="ftError" class="run-error">{{ ftError }}</p>
</div>
</div>
</details>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useApiFetch, useApiSSE } from '../composables/useApi'
// Label metadata (same as StatsView)
const LABEL_META: Record<string, { emoji: string }> = {
interview_scheduled: { emoji: '🗓️' },
offer_received: { emoji: '🎉' },
rejected: { emoji: '❌' },
positive_response: { emoji: '👍' },
survey_received: { emoji: '📋' },
neutral: { emoji: '⬜' },
event_rescheduled: { emoji: '🔄' },
digest: { emoji: '📰' },
new_lead: { emoji: '🤝' },
hired: { emoji: '🎊' },
}
// Types
interface FineTunedModel {
name: string
base_model_id?: string
val_macro_f1?: number
timestamp?: string
sample_count?: number
}
interface PerLabel { f1: number; precision: number; recall: number; support: number }
interface ModelResult {
macro_f1: number
accuracy: number
latency_ms: number
per_label: Record<string, PerLabel>
}
interface BenchResults {
timestamp: string | null
sample_count: number
models: Record<string, ModelResult>
}
// State
const results = ref<BenchResults | null>(null)
const loading = ref(true)
const running = ref(false)
const runLog = ref<string[]>([])
const runError = ref('')
const includeSlow = ref(false)
const logEl = ref<HTMLElement | null>(null)
// Fine-tune state
const fineTunedModels = ref<FineTunedModel[]>([])
const ftModel = ref('deberta-small')
const ftEpochs = ref(5)
const ftRunning = ref(false)
const ftLog = ref<string[]>([])
const ftError = ref('')
const ftLogEl = ref<HTMLElement | null>(null)
const runCancelled = ref(false)
const ftCancelled = ref(false)
async function cancelBenchmark() {
await fetch('/api/benchmark/cancel', { method: 'POST' }).catch(() => {})
}
async function cancelFinetune() {
await fetch('/api/finetune/cancel', { method: 'POST' }).catch(() => {})
}
// Derived
const modelNames = computed(() => Object.keys(results.value?.models ?? {}))
const modelCount = computed(() => modelNames.value.length)
const labelNames = computed(() => {
const canonical = Object.keys(LABEL_META)
const inResults = new Set(
modelNames.value.flatMap(n => Object.keys(results.value!.models[n].per_label))
)
return [...canonical.filter(l => inResults.has(l)), ...[...inResults].filter(l => !canonical.includes(l))]
})
const f1Rows = computed(() => {
if (!results.value) return []
const rows = modelNames.value.map(name => ({
name,
value: results.value!.models[name].macro_f1,
}))
rows.sort((a, b) => b.value - a.value)
const max = rows[0]?.value || 1
return rows.map(r => ({ ...r, pct: Math.round((r.value / max) * 100) }))
})
const latencyRows = computed(() => {
if (!results.value) return []
const rows = modelNames.value.map(name => ({
name,
value: results.value!.models[name].latency_ms,
}))
rows.sort((a, b) => a.value - b.value) // fastest first
const max = rows[rows.length - 1]?.value || 1
return rows.map(r => ({ ...r, pct: Math.round((r.value / max) * 100) }))
})
// Helpers
function f1For(model: string, label: string): number {
return results.value?.models[model]?.per_label[label]?.f1 ?? 0
}
function supportFor(model: string, label: string): number {
return results.value?.models[model]?.per_label[label]?.support ?? 0
}
function scoreColor(v: number): string {
if (v >= 0.7) return 'var(--color-success, #4CAF50)'
if (v >= 0.4) return 'var(--app-accent, #B8622A)'
return 'var(--color-error, #ef4444)'
}
function heatmapBg(v: number): string {
// Blend redyellowgreen using the F1 value
if (v >= 0.7) return `color-mix(in srgb, #4CAF50 ${Math.round(v * 100)}%, #1a2338 ${Math.round((1 - v) * 80)}%)`
if (v >= 0.4) return `color-mix(in srgb, #FF9800 ${Math.round(v * 120)}%, #1a2338 40%)`
return `color-mix(in srgb, #ef4444 ${Math.round(v * 200 + 30)}%, #1a2338 60%)`
}
function heatmapFg(v: number): string {
return v >= 0.5 ? '#fff' : 'rgba(255,255,255,0.75)'
}
function formatDate(iso: string | null): string {
if (!iso) return 'unknown date'
const d = new Date(iso)
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
}
// Data loading
async function loadResults() {
loading.value = true
const { data } = await useApiFetch<BenchResults>('/api/benchmark/results')
loading.value = false
if (data && Object.keys(data.models).length > 0) {
results.value = data
}
}
// Benchmark run
function startBenchmark() {
running.value = true
runLog.value = []
runError.value = ''
runCancelled.value = false
const url = `/api/benchmark/run${includeSlow.value ? '?include_slow=true' : ''}`
useApiSSE(
url,
async (event) => {
if (event.type === 'progress' && typeof event.message === 'string') {
runLog.value.push(event.message)
await nextTick()
logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' })
}
if (event.type === 'error' && typeof event.message === 'string') {
runError.value = event.message
}
if (event.type === 'cancelled') {
running.value = false
runCancelled.value = true
}
},
async () => {
running.value = false
await loadResults()
},
() => {
running.value = false
if (!runError.value) runError.value = 'Connection lost'
},
)
}
async function loadFineTunedModels() {
const { data } = await useApiFetch<FineTunedModel[]>('/api/finetune/status')
if (Array.isArray(data)) fineTunedModels.value = data
}
function startFinetune() {
if (ftRunning.value) return
ftRunning.value = true
ftLog.value = []
ftError.value = ''
ftCancelled.value = false
const params = new URLSearchParams({ model: ftModel.value, epochs: String(ftEpochs.value) })
useApiSSE(
`/api/finetune/run?${params}`,
async (event) => {
if (event.type === 'progress' && typeof event.message === 'string') {
ftLog.value.push(event.message)
await nextTick()
ftLogEl.value?.scrollTo({ top: ftLogEl.value.scrollHeight, behavior: 'smooth' })
}
if (event.type === 'error' && typeof event.message === 'string') {
ftError.value = event.message
}
if (event.type === 'cancelled') {
ftRunning.value = false
ftCancelled.value = true
}
},
async () => {
ftRunning.value = false
await loadFineTunedModels()
startBenchmark() // auto-trigger benchmark to refresh charts
},
() => {
ftRunning.value = false
if (!ftError.value) ftError.value = 'Connection lost'
},
)
}
onMounted(() => {
loadResults()
loadFineTunedModels()
})
</script>
<style scoped>
.bench-view {
max-width: 860px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.bench-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.slow-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--color-text-secondary, #6b7a99);
cursor: pointer;
user-select: none;
}
.slow-toggle.disabled { opacity: 0.5; pointer-events: none; }
.btn-run {
padding: 0.45rem 1.1rem;
border-radius: 0.375rem;
border: none;
background: var(--app-primary, #2A6080);
color: #fff;
font-size: 0.88rem;
font-family: var(--font-body, sans-serif);
cursor: pointer;
transition: opacity 0.15s;
}
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-run:not(:disabled):hover { opacity: 0.85; }
.btn-cancel {
padding: 0.45rem 0.9rem;
background: transparent;
border: 1px solid var(--color-text-secondary, #6b7a99);
color: var(--color-text-secondary, #6b7a99);
border-radius: 0.4rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-cancel:hover {
background: color-mix(in srgb, var(--color-text-secondary, #6b7a99) 12%, transparent);
}
/* ── Run log ────────────────────────────────────────────── */
.run-log {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
}
.run-log-title {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
border-bottom: 1px solid var(--color-border, #d0d7e8);
font-size: 0.8rem;
color: var(--color-text-secondary, #6b7a99);
}
.btn-ghost {
background: none;
border: none;
color: var(--color-text-secondary, #6b7a99);
cursor: pointer;
font-size: 0.78rem;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.btn-ghost:hover { background: var(--color-border, #d0d7e8); }
.log-lines {
max-height: 200px;
overflow-y: auto;
padding: 0.5rem 0.75rem;
background: var(--color-surface, #fff);
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.log-line { color: var(--color-text, #1a2338); line-height: 1.5; }
.log-line.log-error { color: var(--color-error, #ef4444); }
.run-error {
margin: 0;
padding: 0.4rem 0.75rem;
background: color-mix(in srgb, var(--color-error, #ef4444) 10%, transparent);
color: var(--color-error, #ef4444);
font-size: 0.82rem;
font-family: var(--font-mono, monospace);
}
/* ── Status notices ─────────────────────────────────────── */
.status-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 1rem;
}
.status-notice.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
}
.hint { font-size: 0.85rem; opacity: 0.75; }
/* ── Meta line ──────────────────────────────────────────── */
.meta-line {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-secondary, #6b7a99);
font-family: var(--font-mono, monospace);
flex-wrap: wrap;
}
.sep { opacity: 0.4; }
/* ── Chart sections ─────────────────────────────────────── */
.chart-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chart-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text, #1a2338);
margin: 0;
}
/* ── Bar charts ─────────────────────────────────────────── */
.bar-chart {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.bar-row {
display: grid;
grid-template-columns: 14rem 1fr 5rem;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
}
.bar-label {
font-family: var(--font-mono, monospace);
font-size: 0.76rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text, #1a2338);
}
.bar-track {
height: 16px;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.latency-fill { background: var(--app-primary, #2A6080); opacity: 0.65; }
.bar-value {
text-align: right;
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* ── Heatmap ────────────────────────────────────────────── */
.heatmap-scroll {
overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
}
.heatmap {
border-collapse: collapse;
min-width: 100%;
font-size: 0.78rem;
}
.hm-label-col {
text-align: left;
min-width: 11rem;
padding: 0.4rem 0.6rem;
background: var(--color-surface-raised, #e4ebf5);
font-weight: 600;
border-bottom: 1px solid var(--color-border, #d0d7e8);
position: sticky;
left: 0;
}
.hm-model-col {
min-width: 5rem;
max-width: 8rem;
padding: 0.4rem 0.5rem;
background: var(--color-surface-raised, #e4ebf5);
border-bottom: 1px solid var(--color-border, #d0d7e8);
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
.hm-label-cell {
padding: 0.35rem 0.6rem;
background: var(--color-surface, #fff);
border-top: 1px solid var(--color-border, #d0d7e8);
white-space: nowrap;
font-family: var(--font-mono, monospace);
font-size: 0.74rem;
position: sticky;
left: 0;
}
.hm-emoji { margin-right: 0.3rem; }
.hm-value-cell {
padding: 0.35rem 0.5rem;
text-align: center;
font-family: var(--font-mono, monospace);
font-variant-numeric: tabular-nums;
border-top: 1px solid rgba(255,255,255,0.08);
cursor: default;
transition: filter 0.15s;
}
.hm-value-cell:hover { filter: brightness(1.15); }
.heatmap-hint {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7a99);
margin: 0;
}
/* ── Mobile tweaks ──────────────────────────────────────── */
@media (max-width: 600px) {
.bar-row { grid-template-columns: 9rem 1fr 4rem; }
.bar-label { font-size: 0.7rem; }
.bench-header { flex-direction: column; align-items: flex-start; }
}
/* ── Trained models badge row ──────────────────────────── */
.trained-models-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
}
.trained-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-text-secondary, #6b7a99);
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.trained-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
background: var(--app-primary, #2A6080);
color: #fff;
border-radius: 1rem;
font-family: var(--font-mono, monospace);
font-size: 0.76rem;
cursor: default;
}
.trained-f1 {
background: rgba(255,255,255,0.2);
border-radius: 0.75rem;
padding: 0.05rem 0.35rem;
font-size: 0.7rem;
font-weight: 700;
}
/* ── Fine-tune section ──────────────────────────────────── */
.ft-section {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
}
.ft-summary {
padding: 0.65rem 0.9rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #1a2338);
user-select: none;
list-style: none;
background: var(--color-surface-raised, #e4ebf5);
}
.ft-summary::-webkit-details-marker { display: none; }
.ft-summary::before { content: '▶ '; font-size: 0.65rem; color: var(--color-text-secondary, #6b7a99); }
details[open] .ft-summary::before { content: '▼ '; }
.ft-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
border-top: 1px solid var(--color-border, #d0d7e8);
}
.ft-controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.ft-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ft-field-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7a99);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ft-select {
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
font-size: 0.85rem;
color: var(--color-text, #1a2338);
min-width: 220px;
}
.ft-select:disabled { opacity: 0.55; }
.ft-epochs {
width: 64px;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
font-size: 0.85rem;
color: var(--color-text, #1a2338);
text-align: center;
}
.ft-epochs:disabled { opacity: 0.55; }
.ft-run-btn { align-self: flex-end; }
.ft-log { margin-top: 0; }
@media (max-width: 600px) {
.ft-controls { flex-direction: column; align-items: stretch; }
.ft-select { min-width: 0; width: 100%; }
}
</style>

View file

@ -0,0 +1,319 @@
<template>
<div class="corrections-view">
<header class="cv-header">
<span class="queue-count">
<template v-if="loading">Loading</template>
<template v-else-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<span v-else class="queue-empty-label">All caught up</span>
</span>
<div class="header-actions">
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button>
</div>
</header>
<!-- States -->
<div v-if="loading" class="skeleton-card" aria-label="Loading candidates" />
<div v-else-if="apiError" class="error-display" role="alert">
<p>Couldn't reach Avocet API.</p>
<button @click="fetchBatch" class="btn-action">Retry</button>
</div>
<div v-else-if="!store.current" class="empty-state">
<p>No candidates need review.</p>
<p class="empty-hint">Import a benchmark run from the Settings tab to get started.</p>
</div>
<template v-else>
<div class="card-wrapper">
<SftCard
:item="store.current"
:correcting="correcting"
@correct="startCorrection"
@discard="handleDiscard"
@flag="handleFlag"
@submit-correction="handleCorrect"
@cancel-correction="correcting = false"
/>
</div>
</template>
<!-- Stats footer -->
<footer v-if="stats" class="stats-footer">
<span class="stat"> {{ stats.by_status?.approved ?? 0 }} approved</span>
<span class="stat"> {{ stats.by_status?.discarded ?? 0 }} discarded</span>
<span class="stat"> {{ stats.by_status?.model_rejected ?? 0 }} flagged</span>
<a
v-if="(stats.export_ready ?? 0) > 0"
:href="exportUrl"
download
class="btn-export"
>
Export {{ stats.export_ready }} corrections
</a>
</footer>
<!-- Undo toast (inline UndoToast.vue uses label store's LastAction shape, not SFT's) -->
<div v-if="store.lastAction" class="undo-toast">
<span>Last: {{ store.lastAction.type }}</span>
<button @click="handleUndo" class="btn-undo"> Undo</button>
<button @click="store.clearLastAction()" class="btn-dismiss"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSftStore } from '../stores/sft'
import { useSftKeyboard } from '../composables/useSftKeyboard'
import SftCard from '../components/SftCard.vue'
const store = useSftStore()
const loading = ref(false)
const apiError = ref(false)
const correcting = ref(false)
const stats = ref<Record<string, any> | null>(null)
const exportUrl = '/api/sft/export'
useSftKeyboard({
onCorrect: () => { if (store.current && !correcting.value) correcting.value = true },
onDiscard: () => { if (store.current && !correcting.value) handleDiscard() },
onFlag: () => { if (store.current && !correcting.value) handleFlag() },
onEscape: () => { correcting.value = false },
onSubmit: () => {},
isEditing: () => correcting.value,
})
async function fetchBatch() {
loading.value = true
apiError.value = false
try {
const res = await fetch('/api/sft/queue?per_page=20')
if (!res.ok) throw new Error('API error')
const data = await res.json()
store.queue = data.items
store.totalRemaining = data.total
} catch {
apiError.value = true
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await fetch('/api/sft/stats')
if (res.ok) stats.value = await res.json()
} catch { /* ignore */ }
}
function startCorrection() {
correcting.value = true
}
async function handleCorrect(text: string) {
if (!store.current) return
const item = store.current
correcting.value = false
try {
const res = await fetch('/api/sft/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, action: 'correct', corrected_response: text }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
store.removeCurrentFromQueue()
store.setLastAction('correct', item)
store.totalRemaining = Math.max(0, store.totalRemaining - 1)
fetchStats()
if (store.queue.length < 5) fetchBatch()
} catch (err) {
console.error('handleCorrect failed:', err)
}
}
async function handleDiscard() {
if (!store.current) return
const item = store.current
try {
const res = await fetch('/api/sft/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, action: 'discard' }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
store.removeCurrentFromQueue()
store.setLastAction('discard', item)
store.totalRemaining = Math.max(0, store.totalRemaining - 1)
fetchStats()
if (store.queue.length < 5) fetchBatch()
} catch (err) {
console.error('handleDiscard failed:', err)
}
}
async function handleFlag() {
if (!store.current) return
const item = store.current
try {
const res = await fetch('/api/sft/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, action: 'flag' }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
store.removeCurrentFromQueue()
store.setLastAction('flag', item)
store.totalRemaining = Math.max(0, store.totalRemaining - 1)
fetchStats()
if (store.queue.length < 5) fetchBatch()
} catch (err) {
console.error('handleFlag failed:', err)
}
}
async function handleUndo() {
if (!store.lastAction) return
const action = store.lastAction
const { item } = action
try {
const res = await fetch('/api/sft/undo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
store.restoreItem(item)
store.totalRemaining++
store.clearLastAction()
fetchStats()
} catch (err) {
// Backend did not restore clear the undo UI without restoring queue state
console.error('handleUndo failed:', err)
store.clearLastAction()
}
}
onMounted(() => {
fetchBatch()
fetchStats()
})
</script>
<style scoped>
.corrections-view {
display: flex;
flex-direction: column;
min-height: 100dvh;
padding: var(--space-4);
gap: var(--space-4);
max-width: 760px;
margin: 0 auto;
}
.cv-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-count {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.queue-empty-label { color: var(--color-text-muted); }
.btn-action {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font-size: 0.88rem;
}
.btn-action:disabled { opacity: 0.4; cursor: not-allowed; }
.skeleton-card {
height: 320px;
background: var(--color-surface-alt);
border-radius: var(--radius-lg);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton-card { animation: none; }
}
.error-display, .empty-state {
text-align: center;
padding: var(--space-12);
color: var(--color-text-muted);
}
.empty-hint { font-size: 0.88rem; margin-top: var(--space-2); }
.stats-footer {
display: flex;
gap: var(--space-4);
align-items: center;
flex-wrap: wrap;
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border-light);
font-size: 0.85rem;
color: var(--color-text-muted);
}
.btn-export {
margin-left: auto;
padding: var(--space-2) var(--space-3);
background: var(--color-primary);
color: var(--color-text-inverse, #fff);
border-radius: var(--radius-md);
text-decoration: none;
font-size: 0.88rem;
}
.undo-toast {
position: fixed;
bottom: var(--space-6);
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-size: 0.9rem;
}
.btn-undo {
background: var(--color-primary);
color: var(--color-text-inverse, #fff);
border: none;
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-3);
cursor: pointer;
font-size: 0.88rem;
}
.btn-dismiss {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
}
</style>

459
web/src/views/FetchView.vue Normal file
View file

@ -0,0 +1,459 @@
<template>
<div class="fetch-view">
<h1 class="page-title">📥 Fetch Emails</h1>
<!-- No accounts -->
<div v-if="!loading && accounts.length === 0" class="empty-notice">
No accounts configured.
<RouterLink to="/settings" class="link">Go to Settings </RouterLink>
</div>
<template v-else>
<!-- Account selection -->
<section class="section">
<h2 class="section-title">Accounts</h2>
<label
v-for="acc in accounts"
:key="acc.name"
class="account-check"
>
<input v-model="selectedAccounts" type="checkbox" :value="acc.name" />
{{ acc.name || acc.username }}
</label>
</section>
<!-- Standard fetch options -->
<section class="section">
<h2 class="section-title">Options</h2>
<label class="field field-inline">
<span class="field-label">Days back</span>
<input v-model.number="daysBack" type="range" min="7" max="365" class="slider" />
<span class="field-value">{{ daysBack }}</span>
</label>
<label class="field field-inline">
<span class="field-label">Max per account</span>
<input v-model.number="limitPerAccount" type="number" min="10" max="2000" class="field-num" />
</label>
</section>
<!-- Fetch button -->
<button
class="btn-primary btn-fetch"
:disabled="fetching || selectedAccounts.length === 0"
@click="startFetch"
>
{{ fetching ? 'Fetching…' : '📥 Fetch from IMAP' }}
</button>
<!-- Progress bars -->
<div v-if="progress.length > 0" class="progress-section">
<div v-for="p in progress" :key="p.account" class="progress-row">
<span class="progress-name">{{ p.account }}</span>
<div class="progress-track">
<div
class="progress-fill"
:class="{ done: p.done, error: p.error }"
:style="{ width: `${p.pct}%` }"
/>
</div>
<span class="progress-label">{{ p.label }}</span>
</div>
<div v-if="completeMsg" class="complete-msg">{{ completeMsg }}</div>
</div>
<!-- Targeted fetch (collapsible) -->
<details class="targeted-section">
<summary class="targeted-summary">🎯 Targeted fetch (by date range + keyword)</summary>
<div class="targeted-fields">
<div class="field-row">
<label class="field field-grow">
<span>From</span>
<input v-model="targetSince" type="date" />
</label>
<label class="field field-grow">
<span>To</span>
<input v-model="targetBefore" type="date" />
</label>
</div>
<label class="field">
<span>Search term (optional)</span>
<input v-model="targetTerm" type="text" placeholder="e.g. interview" />
</label>
<label class="field">
<span>Match field</span>
<select v-model="targetField" class="field-select">
<option value="either">Subject or From</option>
<option value="subject">Subject only</option>
<option value="from">From only</option>
<option value="none">No filter (date range only)</option>
</select>
</label>
<button class="btn-secondary" :disabled="fetching" @click="startTargetedFetch">
🎯 Targeted fetch
</button>
<p class="targeted-note">
Targeted fetch uses the same SSE stream progress appears above.
</p>
</div>
</details>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch, useApiSSE } from '../composables/useApi'
interface Account { name: string; username: string; days_back: number }
interface ProgressRow {
account: string
pct: number
label: string
done: boolean
error: boolean
}
const accounts = ref<Account[]>([])
const selectedAccounts = ref<string[]>([])
const daysBack = ref(90)
const limitPerAccount = ref(150)
const loading = ref(true)
const fetching = ref(false)
const progress = ref<ProgressRow[]>([])
const completeMsg = ref('')
// Targeted fetch
const targetSince = ref('')
const targetBefore = ref('')
const targetTerm = ref('')
const targetField = ref('either')
async function loadConfig() {
loading.value = true
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
loading.value = false
if (data) {
accounts.value = data.accounts
selectedAccounts.value = data.accounts.map(a => a.name)
limitPerAccount.value = data.max_per_account
}
}
function initProgress() {
progress.value = selectedAccounts.value.map(name => ({
account: name, pct: 0, label: 'waiting…', done: false, error: false,
}))
completeMsg.value = ''
}
function startFetch() {
if (fetching.value || selectedAccounts.value.length === 0) return
fetching.value = true
initProgress()
const params = new URLSearchParams({
accounts: selectedAccounts.value.join(','),
days_back: String(daysBack.value),
limit: String(limitPerAccount.value),
mode: 'wide',
})
useApiSSE(
`/api/fetch/stream?${params}`,
(data) => handleEvent(data as Record<string, unknown>),
() => { fetching.value = false },
() => { fetching.value = false },
)
}
function startTargetedFetch() {
if (fetching.value || selectedAccounts.value.length === 0) return
fetching.value = true
initProgress()
const params = new URLSearchParams({
accounts: selectedAccounts.value.join(','),
days_back: String(daysBack.value),
limit: String(limitPerAccount.value),
mode: 'targeted',
since: targetSince.value,
before: targetBefore.value,
term: targetTerm.value,
field: targetField.value,
})
useApiSSE(
`/api/fetch/stream?${params}`,
(data) => handleEvent(data as Record<string, unknown>),
() => { fetching.value = false },
() => { fetching.value = false },
)
}
function handleEvent(data: Record<string, unknown>) {
const type = data.type as string
const account = data.account as string | undefined
const row = account ? progress.value.find(p => p.account === account) : null
if (type === 'start' && row) {
row.label = `0 / ${data.total_uids} found`
row.pct = 2 // show a sliver immediately
} else if (type === 'progress' && row) {
const total = (data.total_uids as number) || 1
const fetched = (data.fetched as number) || 0
row.pct = Math.round((fetched / total) * 95)
row.label = `${fetched} fetched…`
} else if (type === 'done' && row) {
row.pct = 100
row.done = true
row.label = `${data.added} added, ${data.skipped} skipped`
} else if (type === 'error' && row) {
row.error = true
row.label = String(data.message || 'Error')
} else if (type === 'complete') {
completeMsg.value =
`Done — ${data.total_added} new email(s) added · Queue: ${data.queue_size}`
}
}
onMounted(loadConfig)
</script>
<style scoped>
.fetch-view {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7a99);
text-transform: uppercase;
letter-spacing: 0.04em;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
}
.account-check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.88rem;
}
.field-inline {
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.field-label {
min-width: 120px;
color: var(--color-text-secondary, #6b7a99);
font-size: 0.85rem;
}
.field-value {
min-width: 32px;
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
}
.slider {
flex: 1;
accent-color: var(--app-primary, #2A6080);
}
.field-num {
width: 90px;
padding: 0.35rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
font-size: 0.9rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
}
.btn-primary {
padding: 0.6rem 1.5rem;
border-radius: 0.5rem;
border: none;
background: var(--app-primary, #2A6080);
color: #fff;
font-size: 1rem;
font-family: var(--font-body, sans-serif);
cursor: pointer;
align-self: flex-start;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--app-primary-dark, #1d4d65); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-fetch { min-width: 200px; }
.progress-section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.progress-row {
display: grid;
grid-template-columns: 10rem 1fr 8rem;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.progress-name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-track {
height: 12px;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--app-primary, #2A6080);
border-radius: 99px;
transition: width 0.3s ease;
}
.progress-fill.done { background: #4CAF50; }
.progress-fill.error { background: var(--color-error, #ef4444); }
.progress-label {
font-size: 0.78rem;
color: var(--color-text-secondary, #6b7a99);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.complete-msg {
font-size: 0.9rem;
font-weight: 600;
color: #155724;
background: #d4edda;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin-top: 0.5rem;
}
.targeted-section {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
}
.targeted-summary {
padding: 0.6rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
cursor: pointer;
font-size: 0.88rem;
font-weight: 600;
user-select: none;
}
.targeted-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--color-surface, #fff);
}
.field-row {
display: flex;
gap: 0.5rem;
}
.field-grow { flex: 1; }
.field select,
.field input[type="date"],
.field input[type="text"] {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.88rem;
}
.field-select { width: 100%; }
.btn-secondary {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
font-family: var(--font-body, sans-serif);
align-self: flex-start;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-surface-raised, #e4ebf5); }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.targeted-note {
font-size: 0.78rem;
color: var(--color-text-secondary, #6b7a99);
}
.empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 1rem;
border: 1px dashed var(--color-border, #d0d7e8);
border-radius: 0.5rem;
}
.link {
color: var(--app-primary, #2A6080);
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,92 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.vue'
import EmailCardStack from '../components/EmailCardStack.vue'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock fetch globally
beforeEach(() => {
setActivePinia(createPinia())
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [], total: 0 }),
text: async () => '',
}))
})
describe('LabelView', () => {
it('shows loading state initially', () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Should show skeleton while loading
expect(w.find('.skeleton-card').exists()).toBe(true)
})
it('shows empty state when queue is empty after load', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
// Let all promises resolve
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.empty-state').exists()).toBe(true)
})
it('renders header with action buttons', async () => {
const w = mount(LabelView, {
global: { plugins: [createPinia()] },
})
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.lv-header').exists()).toBe(true)
expect(w.text()).toContain('Undo')
expect(w.text()).toContain('Skip')
expect(w.text()).toContain('Discard')
})
const queueItem = {
id: 'test-1', subject: 'Test Email', body: 'Test body',
from: 'test@test.com', date: '2026-03-05', source: 'test',
}
// Return queue items for /api/queue, empty array for /api/config/labels
function mockFetchWithQueue() {
vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string) =>
Promise.resolve({
ok: true,
json: async () => (url as string).includes('/api/queue')
? { items: [queueItem], total: 1 }
: [],
text: async () => '',
})
))
}
it('renders toss zone overlays when isHeld is true (after drag-start)', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
// Zone overlays should not exist before drag
expect(w.find('.toss-zone-left').exists()).toBe(false)
// Emit drag-start from EmailCardStack child
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.toss-zone-left').exists()).toBe(true)
expect(w.find('.toss-zone-right').exists()).toBe(true)
})
it('bucket-grid-footer has grid-active class while card is held', async () => {
mockFetchWithQueue()
const w = mount(LabelView, { global: { plugins: [createPinia()] } })
await new Promise(r => setTimeout(r, 0))
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).not.toContain('grid-active')
const cardStack = w.findComponent(EmailCardStack)
cardStack.vm.$emit('drag-start')
await w.vm.$nextTick()
expect(w.find('.bucket-grid-footer').classes()).toContain('grid-active')
})
})

506
web/src/views/LabelView.vue Normal file
View file

@ -0,0 +1,506 @@
<template>
<div class="label-view">
<!-- Header bar -->
<header class="lv-header" :class="{ 'is-held': isHeld }">
<span class="queue-count">
<span v-if="loading" class="queue-status">Loading</span>
<template v-else-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<span v-else class="queue-status">Queue empty</span>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
</Transition>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
</Transition>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
</Transition>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="centuryMark" class="badge badge-century">💯 Century!</span>
</Transition>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="cleanSweep" class="badge badge-sweep">🧹 Clean sweep!</span>
</Transition>
<Transition @enter="onBadgeEnter" :css="false">
<span v-if="midnightLabeler" class="badge badge-midnight">🦉 Midnight labeler!</span>
</Transition>
</span>
<div class="header-actions">
<button @click="handleUndo" :disabled="!store.lastAction" class="btn-action"> Undo</button>
<button @click="handleSkip" :disabled="!store.current" class="btn-action"> Skip</button>
<button @click="handleDiscard" :disabled="!store.current" class="btn-action btn-danger"> Discard</button>
</div>
</header>
<!-- States -->
<div v-if="loading" class="skeleton-card" aria-label="Loading email" />
<div v-else-if="apiError" class="error-display" role="alert">
<p>Couldn't reach Avocet API.</p>
<button @click="fetchBatch" class="btn-action">Retry</button>
</div>
<div v-else-if="!store.current" class="empty-state">
<p>Queue is empty fetch more emails to continue.</p>
</div>
<!-- Card stack + label grid -->
<template v-else>
<!-- Toss edge zones thin trip-wires at screen edges, visible only while card is held -->
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-left"
:class="{ active: hoveredZone === 'discard' }"
aria-hidden="true"
></div>
</Transition>
<Transition name="zone-fade">
<div
v-if="isHeld && motion.rich.value"
class="toss-zone toss-zone-right"
:class="{ active: hoveredZone === 'skip' }"
aria-hidden="true"
></div>
</Transition>
<div class="card-stack-wrapper" :class="{ 'is-held': isHeld }">
<EmailCardStack
:item="store.current"
:is-bucket-mode="isHeld"
:dismiss-type="dismissType"
@label="handleLabel"
@skip="handleSkip"
@discard="handleDiscard"
@drag-start="isHeld = true"
@drag-end="isHeld = false"
@zone-hover="hoveredZone = $event"
@bucket-hover="hoveredBucket = $event"
/>
</div>
<div ref="gridEl" class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
<LabelBucketGrid
:labels="labels"
:is-bucket-mode="isHeld"
:hovered-bucket="hoveredBucket"
@label="handleLabel"
/>
</div>
</template>
<!-- Undo toast -->
<UndoToast
v-if="store.lastAction"
:action="store.lastAction"
@undo="handleUndo"
@expire="store.clearLastAction()"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { animate } from 'animejs'
import { useLabelStore } from '../stores/label'
import { useApiFetch } from '../composables/useApi'
import { useHaptics } from '../composables/useHaptics'
import { useMotion } from '../composables/useMotion'
import { useLabelKeyboard } from '../composables/useLabelKeyboard'
import { fireConfetti, useCursorTrail } from '../composables/useEasterEgg'
import EmailCardStack from '../components/EmailCardStack.vue'
import LabelBucketGrid from '../components/LabelBucketGrid.vue'
import UndoToast from '../components/UndoToast.vue'
const store = useLabelStore()
const haptics = useHaptics()
const motion = useMotion() // only needed to pass to child actual value used in App.vue
const gridEl = ref<HTMLElement | null>(null)
const loading = ref(true)
const apiError = ref(false)
const isHeld = ref(false)
const hoveredZone = ref<'discard' | 'skip' | null>(null)
const hoveredBucket = ref<string | null>(null)
const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
watch(isHeld, (held) => {
if (!motion.rich.value || !gridEl.value) return
animate(gridEl.value,
held
? { y: -8, opacity: 0.45, ease: 'out(4)', duration: 380 }
: { y: 0, opacity: 1, ease: 'out(4)', duration: 320 }
)
})
function onBadgeEnter(el: Element, done: () => void) {
if (!motion.rich.value) { done(); return }
animate(el as HTMLElement,
{ scale: [0.6, 1], opacity: [0, 1], ease: spring({ mass: 1.5, stiffness: 80, damping: 8 }), duration: 300, onComplete: done }
)
}
// Easter egg state
const consecutiveLabeled = ref(0)
const recentLabels = ref<number[]>([])
const onRoll = ref(false)
const speedRound = ref(false)
const fiftyDeep = ref(false)
// New easter egg state
const centuryMark = ref(false)
const cleanSweep = ref(false)
const midnightLabeler = ref(false)
let midnightShownThisSession = false
let trailCleanup: (() => void) | null = null
let themeObserver: MutationObserver | null = null
function syncCursorTrail() {
const isHacker = document.documentElement.dataset.theme === 'hacker'
if (isHacker && !trailCleanup) {
trailCleanup = useCursorTrail()
} else if (!isHacker && trailCleanup) {
trailCleanup()
trailCleanup = null
}
}
async function fetchBatch() {
loading.value = true
apiError.value = false
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
loading.value = false
if (error || !data) {
apiError.value = true
return
}
store.queue = data.items
store.totalRemaining = data.total
// Clean sweep queue exhausted in this batch
if (data.total === 0 && data.items.length === 0 && store.sessionLabeled > 0) {
cleanSweep.value = true
setTimeout(() => { cleanSweep.value = false }, 4000)
}
}
function checkSpeedRound(): boolean {
const now = Date.now()
recentLabels.value = recentLabels.value.filter(t => now - t < 20000)
recentLabels.value.push(now)
return recentLabels.value.length >= 5
}
async function handleLabel(name: string) {
const item = store.current
if (!item) return
// Optimistic update
store.setLastAction('label', item, name)
dismissType.value = 'label'
if (motion.rich.value) {
await new Promise(r => setTimeout(r, 350))
}
store.removeCurrentFromQueue()
store.incrementLabeled()
dismissType.value = null
consecutiveLabeled.value++
haptics.label()
// Easter eggs
if (consecutiveLabeled.value >= 10) {
onRoll.value = true
setTimeout(() => { onRoll.value = false }, 3000)
}
if (store.sessionLabeled === 50) {
fiftyDeep.value = true
setTimeout(() => { fiftyDeep.value = false }, 5000)
}
if (checkSpeedRound()) {
onRoll.value = false
speedRound.value = true
setTimeout(() => { speedRound.value = false }, 2500)
}
// Hired confetti
if (name === 'hired') {
fireConfetti()
}
// Century mark
if (store.sessionLabeled === 100) {
centuryMark.value = true
setTimeout(() => { centuryMark.value = false }, 4000)
}
// Midnight labeler once per session
if (!midnightShownThisSession) {
const h = new Date().getHours()
if (h >= 0 && h < 3) {
midnightShownThisSession = true
midnightLabeler.value = true
setTimeout(() => { midnightLabeler.value = false }, 5000)
}
}
await useApiFetch('/api/label', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id, label: name }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleSkip() {
const item = store.current
if (!item) return
store.setLastAction('skip', item)
dismissType.value = 'skip'
if (motion.rich.value) await new Promise(r => setTimeout(r, 300))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.skip()
await useApiFetch('/api/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleDiscard() {
const item = store.current
if (!item) return
store.setLastAction('discard', item)
dismissType.value = 'discard'
if (motion.rich.value) await new Promise(r => setTimeout(r, 400))
store.removeCurrentFromQueue()
dismissType.value = null
consecutiveLabeled.value = 0
haptics.discard()
await useApiFetch('/api/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: item.id }),
})
if (store.queue.length < 3) await fetchBatch()
}
async function handleUndo() {
const { data } = await useApiFetch<{ undone: { type: string; item: any } }>('/api/label/undo', { method: 'DELETE' })
if (data?.undone?.item) {
store.restoreItem(data.undone.item)
store.clearLastAction()
haptics.undo()
if (data.undone.type === 'label') {
// decrement session counter sessionLabeled is direct state in a setup store
if (store.sessionLabeled > 0) store.sessionLabeled--
}
}
}
useLabelKeyboard({
labels: () => labels.value, // getter evaluated on each keypress
onLabel: handleLabel,
onSkip: handleSkip,
onDiscard: handleDiscard,
onUndo: handleUndo,
onHelp: () => { /* TODO: help overlay */ },
})
onMounted(async () => {
const { data } = await useApiFetch<any[]>('/api/config/labels')
if (data) labels.value = data
await fetchBatch()
// Cursor trail activate immediately if already in hacker mode, then watch for changes
syncCursorTrail()
themeObserver = new MutationObserver(syncCursorTrail)
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
themeObserver = null
if (trailCleanup) {
trailCleanup()
trailCleanup = null
}
})
</script>
<style scoped>
.label-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
max-width: 640px;
margin: 0 auto;
min-height: 100dvh;
overflow-x: hidden; /* prevent card animations from causing horizontal scroll */
}
.queue-status {
opacity: 0.6;
font-style: italic;
}
.lv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
transition: opacity 200ms ease;
}
.lv-header.is-held {
opacity: 0.2;
pointer-events: none;
}
.queue-count {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
color: var(--color-text-secondary, #6b7a99);
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
font-family: var(--font-body, sans-serif);
}
.badge-roll { background: #ff6b35; color: #fff; }
.badge-speed { background: #7c3aed; color: #fff; }
.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); }
.badge-century { background: #ffd700; color: #1a2338; }
.badge-sweep { background: var(--app-primary, #2A6080); color: #fff; }
.badge-midnight { background: #1a1a2e; color: #7c9dcf; border: 1px solid #7c9dcf; }
.header-actions {
display: flex;
gap: 0.5rem;
}
.btn-action {
padding: 0.4rem 0.8rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-action:hover:not(:disabled) {
background: var(--app-primary-light, #E4F0F7);
}
.btn-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-danger {
border-color: var(--color-error, #ef4444);
color: var(--color-error, #ef4444);
}
.skeleton-card {
min-height: 200px;
border-radius: var(--radius-card, 1rem);
background: linear-gradient(
90deg,
var(--color-surface-raised, #f0f4fc) 25%,
var(--color-surface, #e4ebf5) 50%,
var(--color-surface-raised, #f0f4fc) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.error-display, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 1rem;
color: var(--color-text-secondary, #6b7a99);
text-align: center;
}
.card-stack-wrapper {
flex: 1;
min-height: 0;
padding-bottom: 0.5rem;
}
/* When held: escape overflow clip so ball floats freely above the footer. */
.card-stack-wrapper.is-held {
overflow: visible;
position: relative;
z-index: 20;
}
/* Bucket grid stays pinned to the bottom of the viewport while the email card
can be scrolled freely. "hired" (10th button) may clip on very small screens
that is intentional per design. */
.bucket-grid-footer {
position: sticky;
bottom: 0;
background: var(--color-bg, var(--color-surface, #f0f4fc));
padding: 0.5rem 0 0.75rem;
z-index: 10;
}
/* During toss: stay sticky so the grid holds its natural column position
(fixed caused a horizontal jump on desktop due to sidebar offset).
Opacity and translateY(-8px) are owned by Anime.js. */
.bucket-grid-footer.grid-active {
opacity: 0.45;
}
/* ── Toss edge zones ── */
.toss-zone {
position: fixed;
top: 0;
bottom: 0;
width: 7%;
z-index: 50;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
opacity: 0.25;
transition: opacity 200ms ease, background 200ms ease;
}
.toss-zone-left { left: 0; background: rgba(244, 67, 54, 0.12); color: #ef4444; }
.toss-zone-right { right: 0; background: rgba(255, 152, 0, 0.12); color: #f97316; }
.toss-zone.active {
opacity: 0.85;
background: color-mix(in srgb, currentColor 25%, transparent);
}
/* Zone transition */
.zone-fade-enter-active,
.zone-fade-leave-active { transition: opacity 180ms ease; }
.zone-fade-enter-from,
.zone-fade-leave-to { opacity: 0; }
</style>

View file

@ -0,0 +1,606 @@
<template>
<div class="settings-view">
<h1 class="page-title"> Settings</h1>
<!-- IMAP Accounts -->
<section class="section">
<h2 class="section-title">IMAP Accounts</h2>
<div v-if="accounts.length === 0" class="empty-notice">
No accounts configured yet. Click <strong> Add account</strong> to get started.
</div>
<details
v-for="(acc, i) in accounts"
:key="i"
class="account-panel"
open
>
<summary class="account-summary">
{{ acc.name || acc.username || `Account ${i + 1}` }}
</summary>
<div class="account-fields">
<label class="field">
<span>Display name</span>
<input v-model="acc.name" type="text" placeholder="e.g. Gmail Personal" />
</label>
<div class="field-row">
<label class="field field-grow">
<span>IMAP host</span>
<input v-model="acc.host" type="text" placeholder="imap.gmail.com" />
</label>
<label class="field field-short">
<span>Port</span>
<input v-model.number="acc.port" type="number" min="1" max="65535" />
</label>
<label class="field field-check">
<span>SSL</span>
<input v-model="acc.use_ssl" type="checkbox" />
</label>
</div>
<label class="field">
<span>Username</span>
<input v-model="acc.username" type="text" autocomplete="off" />
</label>
<label class="field">
<span>Password</span>
<div class="password-wrap">
<input
v-model="acc.password"
:type="showPassword[i] ? 'text' : 'password'"
autocomplete="new-password"
/>
<button type="button" class="btn-icon" @click="togglePassword(i)">
{{ showPassword[i] ? '🙈' : '👁' }}
</button>
</div>
</label>
<div class="field-row">
<label class="field field-grow">
<span>Folder</span>
<input v-model="acc.folder" type="text" placeholder="INBOX" />
</label>
<label class="field field-short">
<span>Days back</span>
<input v-model.number="acc.days_back" type="number" min="1" max="3650" />
</label>
</div>
<div class="account-actions">
<button class="btn-secondary" @click="testAccount(i)">🔌 Test connection</button>
<button class="btn-danger" @click="removeAccount(i)">🗑 Remove</button>
<span
v-if="testResults[i]"
class="test-result"
:class="testResults[i]?.ok ? 'result-ok' : 'result-err'"
>
{{ testResults[i]?.message }}
</span>
</div>
</div>
</details>
<button class="btn-secondary btn-add" @click="addAccount"> Add account</button>
</section>
<!-- Global settings -->
<section class="section">
<h2 class="section-title">Global</h2>
<label class="field field-inline">
<span>Max emails per account per fetch</span>
<input v-model.number="maxPerAccount" type="number" min="10" max="2000" class="field-num" />
</label>
</section>
<!-- Display settings -->
<section class="section">
<h2 class="section-title">Display</h2>
<label class="field field-inline">
<input v-model="richMotion" type="checkbox" @change="onMotionChange" />
<span>Rich animations &amp; haptic feedback</span>
</label>
<label class="field field-inline">
<input v-model="keyHints" type="checkbox" @change="onKeyHintsChange" />
<span>Show keyboard shortcut hints on label buttons</span>
</label>
</section>
<!-- cf-orch SFT Integration section -->
<section class="section">
<h2 class="section-title">cf-orch Integration</h2>
<p class="section-desc">
Import SFT (supervised fine-tuning) candidates from cf-orch benchmark runs.
</p>
<div class="field-row">
<label class="field field-grow">
<span>bench_results_dir</span>
<input
id="bench-results-dir"
v-model="benchResultsDir"
type="text"
placeholder="/path/to/circuitforge-orch/scripts/bench_results"
/>
</label>
</div>
<div class="account-actions">
<button class="btn-primary" @click="saveSftConfig">Save</button>
<button class="btn-secondary" @click="scanRuns">Scan for runs</button>
<span v-if="saveStatus" class="save-status">{{ saveStatus }}</span>
</div>
<table v-if="runs.length > 0" class="runs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Candidates</th>
<th>Imported</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.run_id">
<td>{{ run.timestamp }}</td>
<td>{{ run.candidate_count }}</td>
<td>{{ run.already_imported ? '✓' : '—' }}</td>
<td>
<button
class="btn-import"
:disabled="run.already_imported || importingRunId === run.run_id"
@click="importRun(run.run_id)"
>
{{ importingRunId === run.run_id ? 'Importing…' : 'Import' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="importResult" class="import-result">
Imported {{ importResult.imported }}, skipped {{ importResult.skipped }}.
</div>
</section>
<!-- Save / Reload -->
<div class="save-bar">
<button class="btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : '💾 Save' }}
</button>
<button class="btn-secondary" @click="reload"> Reload from disk</button>
<span v-if="saveMsg" class="save-msg" :class="saveOk ? 'msg-ok' : 'msg-err'">
{{ saveMsg }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Account {
name: string; host: string; port: number; use_ssl: boolean
username: string; password: string; folder: string; days_back: number
}
const accounts = ref<Account[]>([])
const maxPerAccount = ref(500)
const showPassword = ref<boolean[]>([])
const testResults = ref<Array<{ ok: boolean; message: string } | null>>([])
const saving = ref(false)
const saveMsg = ref('')
const saveOk = ref(true)
const richMotion = ref(localStorage.getItem('cf-avocet-rich-motion') !== 'false')
const keyHints = ref(localStorage.getItem('cf-avocet-key-hints') !== 'false')
// SFT integration state
const benchResultsDir = ref('')
const runs = ref<Array<{ run_id: string; timestamp: string; candidate_count: number; already_imported: boolean }>>([])
const importingRunId = ref<string | null>(null)
const importResult = ref<{ imported: number; skipped: number } | null>(null)
const saveStatus = ref('')
async function loadSftConfig() {
try {
const res = await fetch('/api/sft/config')
if (res.ok) {
const data = await res.json()
benchResultsDir.value = data.bench_results_dir ?? ''
}
} catch {
// non-fatal leave field empty
}
}
async function saveSftConfig() {
saveStatus.value = 'Saving…'
try {
const res = await fetch('/api/sft/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bench_results_dir: benchResultsDir.value }),
})
saveStatus.value = res.ok ? 'Saved.' : 'Error saving.'
} catch {
saveStatus.value = 'Error saving.'
}
setTimeout(() => { saveStatus.value = '' }, 2000)
}
async function scanRuns() {
try {
const res = await fetch('/api/sft/runs')
if (res.ok) runs.value = await res.json()
} catch { /* ignore */ }
}
async function importRun(runId: string) {
importingRunId.value = runId
importResult.value = null
try {
const res = await fetch('/api/sft/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ run_id: runId }),
})
if (res.ok) {
importResult.value = await res.json()
scanRuns() // refresh already_imported flags
}
} catch { /* ignore */ }
importingRunId.value = null
}
async function reload() {
const { data } = await useApiFetch<{ accounts: Account[]; max_per_account: number }>('/api/config')
if (data) {
accounts.value = data.accounts
maxPerAccount.value = data.max_per_account
showPassword.value = new Array(data.accounts.length).fill(false)
testResults.value = new Array(data.accounts.length).fill(null)
}
}
async function save() {
saving.value = true
saveMsg.value = ''
const { error } = await useApiFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounts: accounts.value, max_per_account: maxPerAccount.value }),
})
saving.value = false
if (error) {
saveOk.value = false
saveMsg.value = '✗ Save failed'
} else {
saveOk.value = true
saveMsg.value = '✓ Saved'
setTimeout(() => { saveMsg.value = '' }, 3000)
}
}
async function testAccount(i: number) {
testResults.value[i] = null
const { data } = await useApiFetch<{ ok: boolean; message: string; count: number | null }>(
'/api/accounts/test',
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account: accounts.value[i] }) },
)
if (data) {
testResults.value[i] = { ok: data.ok, message: data.message }
// Easter egg: > 5000 messages
if (data.ok && data.count !== null && data.count > 5000) {
setTimeout(() => {
if (testResults.value[i]?.ok) {
testResults.value[i] = { ok: true, message: `${data.message} That's a lot of email 📬` }
}
}, 800)
}
}
}
function addAccount() {
accounts.value.push({
name: '', host: 'imap.gmail.com', port: 993, use_ssl: true,
username: '', password: '', folder: 'INBOX', days_back: 90,
})
showPassword.value.push(false)
testResults.value.push(null)
}
function removeAccount(i: number) {
accounts.value.splice(i, 1)
showPassword.value.splice(i, 1)
testResults.value.splice(i, 1)
}
function togglePassword(i: number) {
showPassword.value[i] = !showPassword.value[i]
}
function onMotionChange() {
localStorage.setItem('cf-avocet-rich-motion', String(richMotion.value))
}
function onKeyHintsChange() {
localStorage.setItem('cf-avocet-key-hints', String(keyHints.value))
document.documentElement.classList.toggle('hide-key-hints', !keyHints.value)
}
onMounted(() => {
reload()
loadSftConfig()
})
</script>
<style scoped>
.settings-view {
max-width: 680px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #1a2338);
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--color-border, #d0d7e8);
}
.account-panel {
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.5rem;
overflow: hidden;
}
.account-summary {
padding: 0.6rem 0.75rem;
background: var(--color-surface-raised, #e4ebf5);
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
user-select: none;
}
.account-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
background: var(--color-surface, #fff);
}
.field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
}
.field span:first-child {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.field input[type="text"],
.field input[type="password"],
.field input[type="number"] {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.9rem;
font-family: var(--font-body, sans-serif);
}
.field-row {
display: flex;
gap: 0.5rem;
align-items: flex-end;
}
.field-grow { flex: 1; }
.field-short { width: 80px; }
.field-check { width: 48px; align-items: center; }
.field-inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.field-num {
width: 100px;
}
.password-wrap {
display: flex;
gap: 0.4rem;
}
.password-wrap input {
flex: 1;
}
.btn-icon {
border: 1px solid var(--color-border, #d0d7e8);
background: transparent;
border-radius: 0.375rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.account-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.test-result {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
}
.result-ok { background: #d4edda; color: #155724; }
.result-err { background: #f8d7da; color: #721c24; }
.btn-add { margin-top: 0.25rem; }
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
font-size: 0.85rem;
cursor: pointer;
border: 1px solid;
font-family: var(--font-body, sans-serif);
transition: background 0.15s, color 0.15s;
}
.btn-primary {
border-color: var(--app-primary, #2A6080);
background: var(--app-primary, #2A6080);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--app-primary-dark, #1d4d65);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
border-color: var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
}
.btn-secondary:hover {
background: var(--color-surface-raised, #e4ebf5);
}
.btn-danger {
border-color: var(--color-error, #ef4444);
background: transparent;
color: var(--color-error, #ef4444);
}
.btn-danger:hover {
background: #fef2f2;
}
.save-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.save-msg {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 0.25rem;
}
.msg-ok { background: #d4edda; color: #155724; }
.msg-err { background: #f8d7da; color: #721c24; }
.empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 0.75rem;
border: 1px dashed var(--color-border, #d0d7e8);
border-radius: 0.5rem;
}
.section-desc {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.88rem;
line-height: 1.5;
}
.field-input {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border, #d0d7e8);
border-radius: 0.375rem;
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.9rem;
font-family: var(--font-body, sans-serif);
width: 100%;
}
.runs-table {
width: 100%;
border-collapse: collapse;
margin-top: var(--space-3, 0.75rem);
font-size: 0.88rem;
}
.runs-table th,
.runs-table td {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
text-align: left;
border-bottom: 1px solid var(--color-border, #d0d7e8);
}
.btn-import {
padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
border: 1px solid var(--app-primary, #2A6080);
border-radius: var(--radius-sm, 0.25rem);
background: none;
color: var(--app-primary, #2A6080);
cursor: pointer;
font-size: 0.85rem;
}
.btn-import:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.import-result {
margin-top: var(--space-2, 0.5rem);
font-size: 0.88rem;
color: var(--color-text-secondary, #6b7a99);
}
.save-status {
font-size: 0.85rem;
color: var(--color-text-secondary, #6b7a99);
}
</style>

245
web/src/views/StatsView.vue Normal file
View file

@ -0,0 +1,245 @@
<template>
<div class="stats-view">
<h1 class="page-title">📊 Statistics</h1>
<div v-if="loading" class="loading">Loading</div>
<div v-else-if="error" class="error-notice" role="alert">
{{ error }} <button class="btn-secondary" @click="load">Retry</button>
</div>
<template v-else>
<p class="total-count">
<strong>{{ stats.total.toLocaleString() }}</strong> emails labeled total
</p>
<div v-if="stats.total === 0" class="empty-notice">
No labeled emails yet go to <strong>Label</strong> to start labeling.
</div>
<div v-else class="label-bars">
<div
v-for="row in rows"
:key="row.name"
class="bar-row"
>
<span class="bar-emoji" aria-hidden="true">{{ row.emoji }}</span>
<span class="bar-label">{{ row.name.replace(/_/g, '\u00a0') }}</span>
<div class="bar-track" :title="`${row.count} (${row.pct}%)`">
<div
class="bar-fill"
:style="{ width: `${row.pct}%`, background: row.color }"
/>
</div>
<span class="bar-count">{{ row.count.toLocaleString() }}</span>
</div>
</div>
<div class="file-info">
<span class="file-path">Score file: <code>data/email_score.jsonl</code></span>
<span class="file-size">{{ fileSizeLabel }}</span>
</div>
<div class="action-bar">
<button class="btn-secondary" @click="load">🔄 Refresh</button>
<a class="btn-secondary" href="/api/stats/download" download="email_score.jsonl">
Download
</a>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface StatsResponse {
total: number
counts: Record<string, number>
score_file_bytes: number
}
// Canonical label order + metadata
const LABEL_META: Record<string, { emoji: string; color: string }> = {
interview_scheduled: { emoji: '🗓️', color: '#4CAF50' },
offer_received: { emoji: '🎉', color: '#2196F3' },
rejected: { emoji: '❌', color: '#F44336' },
positive_response: { emoji: '👍', color: '#FF9800' },
survey_received: { emoji: '📋', color: '#9C27B0' },
neutral: { emoji: '⬜', color: '#607D8B' },
event_rescheduled: { emoji: '🔄', color: '#FF5722' },
digest: { emoji: '📰', color: '#00BCD4' },
new_lead: { emoji: '🤝', color: '#009688' },
hired: { emoji: '🎊', color: '#FFC107' },
}
const CANONICAL_ORDER = Object.keys(LABEL_META)
const stats = ref<StatsResponse>({ total: 0, counts: {}, score_file_bytes: 0 })
const loading = ref(true)
const error = ref('')
const rows = computed(() => {
const max = Math.max(...Object.values(stats.value.counts), 1)
const allLabels = [
...CANONICAL_ORDER,
...Object.keys(stats.value.counts).filter(k => !CANONICAL_ORDER.includes(k)),
].filter(k => stats.value.counts[k] > 0)
return allLabels.map(name => {
const count = stats.value.counts[name] ?? 0
const meta = LABEL_META[name] ?? { emoji: '🏷️', color: '#607D8B' }
return {
name,
count,
emoji: meta.emoji,
color: meta.color,
pct: Math.round((count / max) * 100),
}
})
})
const fileSizeLabel = computed(() => {
const b = stats.value.score_file_bytes
if (b === 0) return '(file not found)'
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
return `${(b / 1024 / 1024).toFixed(2)} MB`
})
async function load() {
loading.value = true
error.value = ''
const { data, error: err } = await useApiFetch<StatsResponse>('/api/stats')
loading.value = false
if (err || !data) {
error.value = 'Could not reach Avocet API.'
} else {
stats.value = data
}
}
onMounted(load)
</script>
<style scoped>
.stats-view {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem 4rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-title {
font-family: var(--font-display, var(--font-body, sans-serif));
font-size: 1.4rem;
font-weight: 700;
color: var(--app-primary, #2A6080);
}
.total-count {
font-size: 1rem;
color: var(--color-text-secondary, #6b7a99);
}
.label-bars {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bar-row {
display: grid;
grid-template-columns: 1.5rem 11rem 1fr 3.5rem;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
}
.bar-emoji { text-align: center; }
.bar-label {
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
color: var(--color-text, #1a2338);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
height: 14px;
background: var(--color-surface-raised, #e4ebf5);
border-radius: 99px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.4s ease;
}
.bar-count {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary, #6b7a99);
font-size: 0.82rem;
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary, #6b7a99);
}
.file-path code {
font-family: var(--font-mono, monospace);
background: var(--color-surface-raised, #e4ebf5);
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.action-bar {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-secondary {
padding: 0.4rem 0.9rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #d0d7e8);
background: var(--color-surface, #fff);
color: var(--color-text, #1a2338);
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
font-family: var(--font-body, sans-serif);
transition: background 0.15s;
}
.btn-secondary:hover {
background: var(--color-surface-raised, #e4ebf5);
}
.loading, .error-notice, .empty-notice {
color: var(--color-text-secondary, #6b7a99);
font-size: 0.9rem;
padding: 1rem;
}
@media (max-width: 480px) {
.bar-row {
grid-template-columns: 1.5rem 1fr 1fr 3rem;
}
.bar-label {
display: none;
}
}
</style>

16
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

5
web/uno.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig, presetWind, presetAttributify } from 'unocss'
export default defineConfig({
presets: [presetWind(), presetAttributify()],
})

17
web/vite.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
export default defineConfig({
plugins: [vue(), UnoCSS()],
server: {
proxy: {
'/api': 'http://localhost:8503',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})