Compare commits

...

No commits in common. "main" and "feat/vue-label-tab" have entirely different histories.

69 changed files with 16159 additions and 38 deletions

4
.gitignore vendored
View file

@ -14,7 +14,3 @@ data/email_compare_sample.jsonl
# Conda/pip artifacts
.env
# Claude context — BSL 1.1, keep out of version control
CLAUDE.md
docs/superpowers/

173
CLAUDE.md Normal file
View file

@ -0,0 +1,173 @@
# Avocet — Email Classifier Training Tool
## What it is
Shared infrastructure for building and benchmarking email classifiers across the CircuitForge menagerie.
Named for the avocet's sweeping-bill technique — it sweeps through email streams and filters out categories.
**Pipeline:**
```
Scrape (IMAP, wide search, multi-account) → data/email_label_queue.jsonl
Label (card-stack UI) → data/email_score.jsonl
Benchmark (HuggingFace NLI/reranker) → per-model macro-F1 + latency
```
## Environment
- Python env: `conda run -n job-seeker <cmd>` for basic use (streamlit, yaml, stdlib only)
- Classifier env: `conda run -n job-seeker-classifiers <cmd>` for benchmark (transformers, FlagEmbedding, gliclass)
- Run tests: `/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v`
(direct binary — `conda run pytest` can spawn runaway processes)
- Create classifier env: `conda env create -f environment.yml`
## Label Tool (app/label_tool.py)
Card-stack Streamlit UI for manually labeling recruitment emails.
```
conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503
```
- Config: `config/label_tool.yaml` (gitignored — copy from `.example`, or use ⚙️ Settings tab)
- Queue: `data/email_label_queue.jsonl` (gitignored)
- Output: `data/email_score.jsonl` (gitignored)
- Four tabs: 🃏 Label, 📥 Fetch, 📊 Stats, ⚙️ Settings
- Keyboard shortcuts: 19 = label, 0 = Other (wildcard, prompts free-text input), S = skip, U = undo
- Dedup: MD5 of `(subject + body[:100])` — cross-account safe
### Settings Tab (⚙️)
- Add / edit / remove IMAP accounts via form UI — no manual YAML editing required
- Per-account fields: display name, host, port, SSL toggle, username, password (masked), folder, days back
- **🔌 Test connection** button per account — connects, logs in, selects folder, reports message count
- Global: max emails per account per fetch
- **💾 Save** writes `config/label_tool.yaml`; **↩ Reload** discards unsaved changes
- `_sync_settings_to_state()` collects widget values before any add/remove to avoid index-key drift
## Benchmark (scripts/benchmark_classifier.py)
```
# List available models
conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --list-models
# Score against labeled JSONL
conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score
# Visual comparison on live IMAP emails
conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --compare --limit 20
# Include slow/large models
conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score --include-slow
# Export DB-labeled emails (⚠️ LLM-generated labels — review first)
conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --export-db --db /path/to/staging.db
```
## Labels (peregrine defaults — configurable per product)
| Label | Key | Meaning |
|-------|-----|---------|
| `interview_scheduled` | 1 | Phone screen, video call, or on-site invitation |
| `offer_received` | 2 | Formal job offer or offer letter |
| `rejected` | 3 | Application declined or not moving forward |
| `positive_response` | 4 | Recruiter interest or request to connect |
| `survey_received` | 5 | Culture-fit survey or assessment invitation |
| `neutral` | 6 | ATS confirmation (application received, etc.) |
| `event_rescheduled` | 7 | Interview or event moved to a new time |
| `digest` | 8 | Job digest or multi-listing email (scrapeable) |
| `new_lead` | 9 | Unsolicited recruiter outreach or cold contact |
| `hired` | h | Offer accepted, onboarding, welcome email, start date |
## Model Registry (13 models, 7 defaults)
See `scripts/benchmark_classifier.py:MODEL_REGISTRY`.
Default models run without `--include-slow`.
Add `--models deberta-small deberta-small-2pass` to test a specific subset.
## Config Files
- `config/label_tool.yaml` — gitignored; multi-account IMAP config
- `config/label_tool.yaml.example` — committed template
## Data Files
- `data/email_score.jsonl` — gitignored; manually-labeled ground truth
- `data/email_score.jsonl.example` — committed sample for CI
- `data/email_label_queue.jsonl` — gitignored; IMAP fetch queue
## Key Design Notes
- `ZeroShotAdapter.load()` instantiates the pipeline object; `classify()` calls the object.
Tests patch `scripts.classifier_adapters.pipeline` (the module-level factory) with a
two-level mock: `mock_factory.return_value = MagicMock(return_value={...})`.
- `two_pass=True` on ZeroShotAdapter: first pass ranks all 6 labels; second pass re-runs
with only top-2, forcing a binary choice. 2× cost, better confidence.
- `--compare` uses the first account in `label_tool.yaml` for live IMAP emails.
- DB export labels are llama3.1:8b-generated — treat as noisy, not gold truth.
## Vue Label UI (app/api.py + web/)
FastAPI on port 8503 serves both the REST API and the built Vue SPA (`web/dist/`).
```
./manage.sh start-api # build Vue SPA + start FastAPI (binds 0.0.0.0:8503 — LAN accessible)
./manage.sh stop-api
./manage.sh open-api # xdg-open http://localhost:8503
```
Logs: `log/api.log`
## Email Field Schema — IMPORTANT
Two schemas exist. The normalization layer in `app/api.py` bridges them automatically.
### JSONL on-disk schema (written by `label_tool.py` and `label_tool.py`'s IMAP fetch)
| Field | Type | Notes |
|-------|------|-------|
| `subject` | str | Email subject line |
| `body` | str | Plain-text body, truncated at 800 chars; HTML stripped by `_strip_html()` |
| `from_addr` | str | Sender address string (`"Name <addr>"`) |
| `date` | str | Raw RFC 2822 date string |
| `account` | str | Display name of the IMAP account that fetched it |
| *(no `id`)* | — | Dedup key is MD5 of `(subject + body[:100])` — never stored on disk |
### Vue API schema (returned by `GET /api/queue`, required by POST endpoints)
| Field | Type | Notes |
|-------|------|-------|
| `id` | str | MD5 content hash, or stored `id` if item has one |
| `subject` | str | Unchanged |
| `body` | str | Unchanged |
| `from` | str | Mapped from `from_addr` (or `from` if already present) |
| `date` | str | Unchanged |
| `source` | str | Mapped from `account` (or `source` if already present) |
### Normalization layer (`_normalize()` in `app/api.py`)
`_normalize(item)` handles the mapping and ID generation. All `GET /api/queue` responses
pass through it. Mutating endpoints (`/api/label`, `/api/skip`, `/api/discard`) look up
items via `_normalize(x)["id"]`, so both real data (no `id`, uses content hash) and test
fixtures (explicit `id` field) work transparently.
### Peregrine integration
Peregrine's `staging.db` uses different field names again:
| staging.db column | Maps to avocet JSONL field |
|-------------------|---------------------------|
| `subject` | `subject` |
| `body` | `body` (may contain HTML — run through `_strip_html()` before queuing) |
| `from_address` | `from_addr` |
| `received_date` | `date` |
| `account` or source context | `account` |
When exporting from Peregrine's DB for avocet labeling, transform to the JSONL schema above
(not the Vue API schema). The `--export-db` flag in `benchmark_classifier.py` does this.
Any new export path should also call `_strip_html()` on the body before writing.
## Relationship to Peregrine
Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`.
Peregrine retains copies during stabilization; once avocet is proven, peregrine will import from here.

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.

569
app/api.py Normal file
View file

@ -0,0 +1,569 @@
"""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")
# 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")

214
app/imap_fetch.py Normal file
View file

@ -0,0 +1,214 @@
"""Avocet — IMAP fetch utilities.
Shared between app/api.py (FastAPI SSE endpoint) and app/label_tool.py (Streamlit).
No Streamlit imports here stdlib + imaplib only.
"""
from __future__ import annotations
import email as _email_lib
import hashlib
import imaplib
import re
from datetime import datetime, timedelta
from email.header import decode_header as _raw_decode
from html.parser import HTMLParser
from typing import Any, Iterator
# ── HTML → plain text ────────────────────────────────────────────────────────
class _TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self._parts: list[str] = []
def handle_data(self, data: str) -> None:
stripped = data.strip()
if stripped:
self._parts.append(stripped)
def get_text(self) -> str:
return " ".join(self._parts)
def strip_html(html_str: str) -> str:
try:
ex = _TextExtractor()
ex.feed(html_str)
return ex.get_text()
except Exception:
return re.sub(r"<[^>]+>", " ", html_str).strip()
# ── 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 _extract_body(msg: Any) -> str:
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 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

@ -14,6 +14,7 @@ from __future__ import annotations
import email as _email_lib
import hashlib
import html as _html
from html.parser import HTMLParser
import imaplib
import json
import re
@ -23,6 +24,9 @@ from email.header import decode_header as _raw_decode
from pathlib import Path
from typing import Any
import os
import subprocess
import streamlit as st
import yaml
@ -43,8 +47,9 @@ LABELS = [
"survey_received",
"neutral",
"event_rescheduled",
"unrelated",
"digest",
"new_lead",
"hired",
]
_LABEL_META: dict[str, dict] = {
@ -55,8 +60,9 @@ _LABEL_META: dict[str, dict] = {
"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"},
"digest": {"emoji": "📰", "color": "#00BCD4", "key": "8"},
"new_lead": {"emoji": "🤝", "color": "#009688", "key": "9"},
"hired": {"emoji": "🎊", "color": "#FFC107", "key": "h"},
}
# ── HTML sanitiser ───────────────────────────────────────────────────────────
@ -78,7 +84,50 @@ def _to_html(text: str, newlines_to_br: bool = False) -> str:
return escaped
# ── Wide IMAP search terms (cast a net across all 9 categories) ─────────────
# ── 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()
# ── Wide IMAP search terms (cast a net across all 10 categories) ────────────
_WIDE_TERMS = [
# interview_scheduled
"interview", "phone screen", "video call", "zoom link", "schedule a call",
@ -100,6 +149,11 @@ _WIDE_TERMS = [
# digest
"job digest", "jobs you may like", "recommended jobs", "jobs for you",
"new jobs", "job alert",
# new_lead
"came across your profile", "reaching out about", "great fit for a role",
"exciting opportunity", "love to connect",
# hired / onboarding
"welcome to the team", "start date", "onboarding", "first day", "we're excited to have you",
# general recruitment
"application", "recruiter", "recruiting", "hiring", "candidate",
]
@ -121,18 +175,32 @@ def _decode_str(value: str | None) -> str:
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():
if part.get_content_type() == "text/plain":
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"
return msg.get_payload(decode=True).decode(charset, errors="replace")
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 ""
@ -436,7 +504,9 @@ with st.sidebar:
# ── Tabs ─────────────────────────────────────────────────────────────────────
tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"])
tab_label, tab_fetch, tab_stats, tab_settings, tab_benchmark = st.tabs(
["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings", "🔬 Benchmark"]
)
# ══════════════════════════════════════════════════════════════════════════════
@ -669,19 +739,19 @@ with tab_label:
_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)
row1_cols = st.columns(5)
row2_cols = st.columns(5)
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"),
(row1_cols[3], "positive_response"),
(row1_cols[4], "survey_received"),
(row2_cols[0], "neutral"),
(row2_cols[1], "event_rescheduled"),
(row2_cols[2], "digest"),
(row2_cols[3], "new_lead"),
(row2_cols[4], "hired"),
]
for col, lbl in bucket_pairs:
m = _LABEL_META[lbl]
@ -720,7 +790,7 @@ with tab_label:
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")
nav_cols[0].caption(f"**{remaining}** remaining · Keys: 19, H = 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()
@ -757,7 +827,7 @@ document.addEventListener('keydown', function(e) {
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'
'7':'event_rescheduled','8':'digest','9':'new_lead'
};
const label = keyToLabel[e.key];
if (label) {
@ -772,6 +842,11 @@ document.addEventListener('keydown', function(e) {
for (const btn of btns) {
if (btn.innerText.includes('Other')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 'h') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
if (btn.innerText.toLowerCase().includes('hired')) { btn.click(); break; }
}
} else if (e.key.toLowerCase() === 's') {
const btns = window.parent.document.querySelectorAll('button');
for (const btn of btns) {
@ -979,3 +1054,133 @@ with tab_settings:
if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"):
del st.session_state[_k]
st.rerun()
# ══════════════════════════════════════════════════════════════════════════════
# BENCHMARK TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_benchmark:
# ── Model selection ───────────────────────────────────────────────────────
_DEFAULT_MODELS = [
"deberta-zeroshot", "deberta-small", "gliclass-large",
"bart-mnli", "bge-m3-zeroshot", "deberta-small-2pass", "deberta-base-anli",
]
_SLOW_MODELS = [
"deberta-large-ling", "mdeberta-xnli-2m", "bge-reranker",
"deberta-xlarge", "mdeberta-mnli", "xlm-roberta-anli",
]
st.subheader("🔬 Benchmark Classifier Models")
_b_include_slow = st.checkbox("Include slow / large models", value=False, key="b_include_slow")
_b_all_models = _DEFAULT_MODELS + (_SLOW_MODELS if _b_include_slow else [])
_b_selected = st.multiselect(
"Models to run",
options=_b_all_models,
default=_b_all_models,
help="Uncheck models to skip them. Slow models require --include-slow.",
)
_n_examples = len(st.session_state.labeled)
st.caption(
f"Scoring against `{_SCORE_FILE.name}` · **{_n_examples} labeled examples**"
f" · Est. time: ~{max(1, len(_b_selected))} {max(2, len(_b_selected) * 2)} min"
)
# Direct binary avoids conda's output interception; -u = unbuffered stdout
_CLASSIFIER_PYTHON = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python"
if st.button("▶ Run Benchmark", type="primary", disabled=not _b_selected, key="b_run"):
_b_cmd = [
_CLASSIFIER_PYTHON, "-u",
str(_ROOT / "scripts" / "benchmark_classifier.py"),
"--score", "--score-file", str(_SCORE_FILE),
"--models", *_b_selected,
]
with st.status("Running benchmark…", expanded=True) as _b_status:
_b_proc = subprocess.Popen(
_b_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=str(_ROOT),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
_b_lines: list[str] = []
_b_area = st.empty()
for _b_line in _b_proc.stdout:
_b_lines.append(_b_line)
_b_area.code("".join(_b_lines[-30:]), language="text")
_b_proc.wait()
_b_full = "".join(_b_lines)
st.session_state["bench_output"] = _b_full
if _b_proc.returncode == 0:
_b_status.update(label="Benchmark complete ✓", state="complete", expanded=False)
else:
_b_status.update(label="Benchmark failed", state="error")
# ── Results display ───────────────────────────────────────────────────────
if "bench_output" in st.session_state:
_b_out = st.session_state["bench_output"]
# Parse summary table rows: name f1 accuracy ms
_b_rows = []
for _b_l in _b_out.splitlines():
_b_m = re.match(r"^([\w-]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*$", _b_l.strip())
if _b_m:
_b_rows.append({
"Model": _b_m.group(1),
"macro-F1": float(_b_m.group(2)),
"Accuracy": float(_b_m.group(3)),
"ms/email": float(_b_m.group(4)),
})
if _b_rows:
import pandas as _pd
_b_df = _pd.DataFrame(_b_rows).sort_values("macro-F1", ascending=False).reset_index(drop=True)
st.dataframe(
_b_df,
column_config={
"macro-F1": st.column_config.ProgressColumn(
"macro-F1", min_value=0, max_value=1, format="%.3f",
),
"Accuracy": st.column_config.ProgressColumn(
"Accuracy", min_value=0, max_value=1, format="%.3f",
),
"ms/email": st.column_config.NumberColumn("ms/email", format="%.1f"),
},
use_container_width=True, hide_index=True,
)
with st.expander("Full benchmark output"):
st.code(_b_out, language="text")
st.divider()
# ── Tests ─────────────────────────────────────────────────────────────────
st.subheader("🧪 Run Tests")
st.caption("Runs `pytest tests/ -v` in the job-seeker env (no model downloads required).")
if st.button("▶ Run Tests", key="b_run_tests"):
_t_cmd = [
"/devl/miniconda3/envs/job-seeker/bin/pytest", "tests/", "-v", "--tb=short",
]
with st.status("Running tests…", expanded=True) as _t_status:
_t_proc = subprocess.Popen(
_t_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=str(_ROOT),
)
_t_lines: list[str] = []
_t_area = st.empty()
for _t_line in _t_proc.stdout:
_t_lines.append(_t_line)
_t_area.code("".join(_t_lines[-30:]), language="text")
_t_proc.wait()
_t_full = "".join(_t_lines)
st.session_state["test_output"] = _t_full
_t_summary = [l for l in _t_lines if "passed" in l or "failed" in l or "error" in l.lower()]
_t_label = _t_summary[-1].strip() if _t_summary else "Done"
_t_state = "error" if _t_proc.returncode != 0 else "complete"
_t_status.update(label=_t_label, state=_t_state, expanded=False)
if "test_output" in st.session_state:
with st.expander("Full test output", expanded=True):
st.code(st.session_state["test_output"], language="text")

View file

@ -0,0 +1,95 @@
# Anime.js Animation Integration — Design
**Date:** 2026-03-08
**Status:** Approved
**Branch:** feat/vue-label-tab
## Problem
The current animation system mixes CSS keyframes, CSS transitions, and imperative inline-style bindings across three files. The seams between systems produce:
- Abrupt ball pickup (instant scale/borderRadius jump)
- No spring snap-back on release to no target
- Rigid CSS dismissals with no timing control
- Bucket grid and badge pop on basic `@keyframes`
## Decision
Integrate **Anime.js v4** as a single animation layer. Vue reactive state is unchanged; Anime.js owns all DOM motion imperatively.
## Architecture
One new composable, minimal changes to two existing files, CSS cleanup in two files.
```
web/src/composables/useCardAnimation.ts ← NEW
web/src/components/EmailCardStack.vue ← modify
web/src/views/LabelView.vue ← modify
```
**Data flow:**
```
pointer events → Vue refs (isHeld, deltaX, deltaY, dismissType)
↓ watched by
useCardAnimation(cardEl, stackEl, isHeld, ...)
↓ imperatively drives
Anime.js → DOM transforms
```
`useCardAnimation` is a pure side-effect composable — returns nothing to the template. The `cardStyle` computed in `EmailCardStack.vue` is removed; Anime.js owns the element's transform directly.
## Animation Surfaces
### Pickup morph
```
animate(cardEl, { scale: 0.55, borderRadius: '50%', y: -80 }, { duration: 200, ease: spring(1, 80, 10) })
```
Replaces the instant CSS transform jump on `onPointerDown`.
### Drag tracking
Raw `cardEl.style.translate` update on `onPointerMove` — no animation, just position. Easing only at boundaries (pickup / release), not during active drag.
### Snap-back
```
animate(cardEl, { x: 0, y: 0, scale: 1, borderRadius: '1rem' }, { ease: spring(1, 80, 10) })
```
Fires on `onPointerUp` when no zone/bucket target was hit.
### Dismissals (replace CSS `@keyframes`)
- **fileAway**`animate(cardEl, { y: '-120%', scale: 0.85, opacity: 0 }, { duration: 280, ease: 'out(3)' })`
- **crumple** — 2-step timeline: shrink + redden → `scale(0)` + rotate
- **slideUnder**`animate(cardEl, { x: '110%', rotate: 5, opacity: 0 }, { duration: 260 })`
### Bucket grid rise
`animate(gridEl, { y: -8, opacity: 0.45 })` on `isHeld` → true; reversed on false. Spring easing.
### Badge pop
`animate(badgeEl, { scale: [0.6, 1], opacity: [0, 1] }, { ease: spring(1.5, 80, 8), duration: 300 })` triggered on badge mount via Vue's `onMounted` lifecycle hook in a `BadgePop` wrapper component or `v-enter-active` transition hook.
## Constraints
### Reduced motion
`useCardAnimation` checks `motion.rich.value` before firing any Anime.js call. If false, all animations are skipped — instant state changes only. Consistent with existing `useMotion` pattern.
### Bundle size
Anime.js v4 core ~17KB gzipped. Only `animate`, `spring`, and `createTimeline` are imported — Vite ESM tree-shaking keeps footprint minimal. The `draggable` module is not used.
### Tests
Existing `EmailCardStack.test.ts` tests emit behavior, not animation — they remain passing. Anime.js mocked at module level in Vitest via `vi.mock('animejs')` where needed.
### CSS cleanup
Remove from `EmailCardStack.vue` and `LabelView.vue`:
- `@keyframes fileAway`, `crumple`, `slideUnder`
- `@keyframes badge-pop`
- `.dismiss-label`, `.dismiss-skip`, `.dismiss-discard` classes (Anime.js fires on element refs directly)
- The `dismissClass` computed in `EmailCardStack.vue`
## Files Changed
| File | Change |
|------|--------|
| `web/package.json` | Add `animejs` dependency |
| `web/src/composables/useCardAnimation.ts` | New — all Anime.js animation logic |
| `web/src/components/EmailCardStack.vue` | Remove `cardStyle` computed + dismiss classes; call `useCardAnimation` |
| `web/src/views/LabelView.vue` | Badge pop + bucket grid rise via Anime.js |
| `web/src/assets/avocet.css` | Remove any global animation keyframes if present |

View file

@ -0,0 +1,573 @@
# Anime.js Animation Integration — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the current mixed CSS keyframes / inline-style animation system with Anime.js v4 for all card motion — pickup morph, drag tracking, spring snap-back, dismissals, bucket grid rise, and badge pop.
**Architecture:** A new `useCardAnimation` composable owns all Anime.js calls imperatively against DOM refs. Vue reactive state (`isHeld`, `deltaX`, `deltaY`, `dismissType`) is unchanged. `cardStyle` computed and `dismissClass` computed are deleted; Anime.js writes to the element directly.
**Tech Stack:** Anime.js v4 (`animejs`), Vue 3 Composition API, `@vue/test-utils` + Vitest for tests.
---
## Task 1: Install Anime.js
**Files:**
- Modify: `web/package.json`
**Step 1: Install the package**
```bash
cd /Library/Development/CircuitForge/avocet/web
npm install animejs
```
**Step 2: Verify the import resolves**
Create a throwaway check — open `web/src/main.ts` briefly and confirm:
```ts
import { animate, spring } from 'animejs'
```
resolves without error in the editor (TypeScript types ship with animejs v4).
Remove the import immediately after verifying — do not commit it.
**Step 3: Commit**
```bash
cd /Library/Development/CircuitForge/avocet/web
git add package.json package-lock.json
git commit -m "feat(avocet): add animejs v4 dependency"
```
---
## Task 2: Create `useCardAnimation` composable
**Files:**
- Create: `web/src/composables/useCardAnimation.ts`
- Create: `web/src/composables/useCardAnimation.test.ts`
**Background — Anime.js v4 transform model:**
Anime.js v4 tracks `x`, `y`, `scale`, `rotate`, etc. as separate transform components internally.
Use `utils.set(el, props)` for instant (no-animation) property updates — this keeps the internal cache consistent.
Never mix direct `el.style.transform = "..."` with Anime.js on the same element, or the cache desyncs.
**Step 1: Write the failing tests**
`web/src/composables/useCardAnimation.test.ts`:
```ts
import { ref, nextTick } 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%' }),
expect.anything(),
)
})
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 }),
expect.anything(),
)
})
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()
})
})
```
**Step 2: Run tests to confirm they fail**
```bash
cd /Library/Development/CircuitForge/avocet/web
npm test -- useCardAnimation
```
Expected: FAIL — "Cannot find module './useCardAnimation'"
**Step 3: Implement the composable**
`web/src/composables/useCardAnimation.ts`:
```ts
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
// NOTE: animejs v4 — spring() takes an object, not positional args
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
// NOTE: animejs v4 — animate() is 2-arg; timing options merge into the params 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(cardEl.value, { x: dx, y: dy - PICKUP_Y_OFFSET })
}
function snapBack() {
if (!motion.rich.value || !cardEl.value) return
// No duration — spring physics determines settling time
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') {
// Two-step: crumple then shrink (keyframes array in params object)
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)' })
}
}
return { pickup, setDragPosition, snapBack, animateDismiss }
}
```
**Step 4: Run tests — expect pass**
```bash
npm test -- useCardAnimation
```
Expected: All 8 tests PASS.
**Step 5: Commit**
```bash
git add web/src/composables/useCardAnimation.ts web/src/composables/useCardAnimation.test.ts
git commit -m "feat(avocet): add useCardAnimation composable with Anime.js"
```
---
## Task 3: Wire `useCardAnimation` into `EmailCardStack.vue`
**Files:**
- Modify: `web/src/components/EmailCardStack.vue`
- Modify: `web/src/components/EmailCardStack.test.ts`
**What changes:**
- Remove `cardStyle` computed and `:style="cardStyle"` binding
- Remove `dismissClass` computed and `:class="[dismissClass, ...]"` binding (keep `is-held`)
- Remove `deltaX`, `deltaY` reactive refs (position now owned by Anime.js)
- Call `pickup()` in `onPointerDown`, `setDragPosition()` in `onPointerMove`, `snapBack()` in `onPointerUp` (no-target path)
- Watch `props.dismissType` and call `animateDismiss()`
- Remove CSS `@keyframes fileAway`, `crumple`, `slideUnder` and their `.dismiss-*` rule blocks from `<style>`
**Step 1: Update the tests that check dismiss classes**
In `EmailCardStack.test.ts`, the 5 tests checking `.dismiss-label`, `.dismiss-discard`, `.dismiss-skip` classes are testing implementation (CSS class name), not behavior. Replace them with a single test that verifies `animateDismiss` is called:
```ts
// Add at the top of the file (after existing imports):
vi.mock('../composables/useCardAnimation', () => ({
useCardAnimation: vi.fn(() => ({
pickup: vi.fn(),
setDragPosition: vi.fn(),
snapBack: vi.fn(),
animateDismiss: vi.fn(),
})),
}))
import { useCardAnimation } from '../composables/useCardAnimation'
```
Replace the five `dismissType` class tests (lines 2546) with:
```ts
it('calls animateDismiss with type when dismissType prop changes', async () => {
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')
})
```
Add `nextTick` import to the test file header if not already present:
```ts
import { nextTick } from 'vue'
```
**Step 2: Run tests to confirm the replaced tests fail**
```bash
npm test -- EmailCardStack
```
Expected: FAIL — `animateDismiss` not called (not yet wired in component)
**Step 3: Modify `EmailCardStack.vue`**
Script section changes:
```ts
// Remove:
// import { ref, computed } from 'vue' → change to:
import { ref, watch } from 'vue'
// Add import:
import { useCardAnimation } from '../composables/useCardAnimation'
// Remove these refs:
// const deltaX = ref(0)
// const deltaY = ref(0)
// Add after const motion = useMotion():
const { pickup, setDragPosition, snapBack, animateDismiss } = useCardAnimation(cardEl, motion)
// Add watcher:
watch(() => props.dismissType, (type) => {
if (type) animateDismiss(type)
})
// Remove dismissClass computed entirely.
// In onPointerDown — add after isHeld.value = true:
pickup()
// In onPointerMove — replace deltaX/deltaY assignments with:
const dx = e.clientX - pickupX.value
const dy = e.clientY - pickupY.value
setDragPosition(dx, dy)
// (keep the zone/bucket detection that uses e.clientX/e.clientY — those stay the same)
// In onPointerUp — in the snap-back else branch, replace:
// deltaX.value = 0
// deltaY.value = 0
// with:
snapBack()
```
Template changes — on the `.card-wrapper` div:
```html
<!-- Remove: :class="[dismissClass, { 'is-held': isHeld }]" -->
<!-- Replace with: -->
:class="{ 'is-held': isHeld }"
<!-- Remove: :style="cardStyle" -->
```
CSS changes in `<style scoped>` — delete these entire blocks:
```
@keyframes fileAway { ... }
@keyframes crumple { ... }
@keyframes slideUnder { ... }
.card-wrapper.dismiss-label { ... }
.card-wrapper.dismiss-discard { ... }
.card-wrapper.dismiss-skip { ... }
```
Also delete `--card-dismiss` and `--card-skip` CSS var usages if present.
**Step 4: Run all tests**
```bash
npm test
```
Expected: All pass (both `useCardAnimation.test.ts` and `EmailCardStack.test.ts`).
**Step 5: Commit**
```bash
git add web/src/components/EmailCardStack.vue web/src/components/EmailCardStack.test.ts
git commit -m "feat(avocet): wire Anime.js card animation into EmailCardStack"
```
---
## Task 4: Bucket grid rise animation
**Files:**
- Modify: `web/src/views/LabelView.vue`
**What changes:**
Replace the CSS class-toggle animation on `.bucket-grid-footer.grid-active` with an Anime.js watch in `LabelView.vue`. The `position: sticky → fixed` switch stays as a CSS class (can't animate position), but `translateY` and `opacity` move to Anime.js.
**Step 1: Add gridEl ref and import animate**
In `LabelView.vue` `<script setup>`:
```ts
// Add to imports:
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { animate, spring } from 'animejs'
// Add ref:
const gridEl = ref<HTMLElement | null>(null)
```
**Step 2: Add watcher for isHeld**
```ts
watch(isHeld, (held) => {
if (!motion.rich.value || !gridEl.value) return
// animejs v4: 2-arg animate, spring() takes object
animate(gridEl.value,
held
? { y: -8, opacity: 0.45, ease: spring({ mass: 1, stiffness: 80, damping: 10 }), duration: 250 }
: { y: 0, opacity: 1, ease: spring({ mass: 1, stiffness: 80, damping: 10 }), duration: 250 }
)
})
```
**Step 3: Wire ref in template**
On the `.bucket-grid-footer` div:
```html
<div ref="gridEl" class="bucket-grid-footer" :class="{ 'grid-active': isHeld }">
```
**Step 4: Remove CSS transition from `.bucket-grid-footer`**
In `LabelView.vue <style scoped>`, delete the `transition:` line from `.bucket-grid-footer`:
```css
/* DELETE this line: */
transition: transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 200ms ease,
background 200ms ease;
```
Keep the `transform: translateY(-8px)` and `opacity: 0.45` on `.bucket-grid-footer.grid-active` as fallback for reduced-motion users (no-JS fallback too).
Actually — keep `.grid-active` rules as-is for the no-motion path. The Anime.js `watch` guard (`if (!motion.rich.value)`) means reduced-motion users never hit Anime.js; the CSS class handles them.
**Step 5: Run tests**
```bash
npm test
```
Expected: All pass (LabelView has no dedicated tests, but full suite should be green).
**Step 6: Commit**
```bash
git add web/src/views/LabelView.vue
git commit -m "feat(avocet): animate bucket grid rise with Anime.js spring"
```
---
## Task 5: Badge pop animation
**Files:**
- Modify: `web/src/views/LabelView.vue`
**What changes:**
Replace `@keyframes badge-pop` (scale + opacity keyframe) with a Vue `<Transition>` `@enter` hook that calls `animate()`. Badges already appear/disappear via `v-if`, so they have natural mount/unmount lifecycle.
**Step 1: Wrap each badge in a `<Transition>`**
In `LabelView.vue` template, each badge `<span v-if="...">` gets wrapped:
```html
<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>
<!-- repeat for all 6 badges -->
```
`:css="false"` tells Vue not to apply any CSS transition classes — Anime.js owns the enter animation entirely.
**Step 2: Add `onBadgeEnter` hook**
```ts
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(1.5, 80, 8), duration: 300, onComplete: done }
)
}
```
**Step 3: Remove `@keyframes badge-pop` from CSS**
In `LabelView.vue <style scoped>`:
```css
/* DELETE: */
@keyframes badge-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* DELETE animation line from .badge: */
animation: badge-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
```
**Step 4: Run tests**
```bash
npm test
```
Expected: All pass.
**Step 5: Commit**
```bash
git add web/src/views/LabelView.vue
git commit -m "feat(avocet): badge pop via Anime.js spring transition hook"
```
---
## Task 6: Build and smoke test
**Step 1: Build the SPA**
```bash
cd /Library/Development/CircuitForge/avocet
./manage.sh start-api
```
(This builds Vue + starts FastAPI on port 8503.)
**Step 2: Open the app**
```bash
./manage.sh open-api
```
**Step 3: Manual smoke test checklist**
- [ ] Pick up a card — ball morph is smooth (not instant jump)
- [ ] Drag ball around — follows finger with no lag
- [ ] Release in center — springs back to card with bounce
- [ ] Release in left zone — discard fires (card crumples)
- [ ] Release in right zone — skip fires (card slides right)
- [ ] Release on a bucket — label fires (card files up)
- [ ] Fling left fast — discard fires
- [ ] Bucket grid rises smoothly on pickup, falls on release
- [ ] Badge (label 10 in a row for 🔥) pops in with spring
- [ ] Reduced motion: toggle in system settings → no animations, instant behavior
- [ ] Keyboard labels (19) still work (pointer events unchanged)
**Step 4: Final commit if all green**
```bash
git add -A
git commit -m "feat(avocet): complete Anime.js animation integration"
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,254 @@
# Fine-tune Email Classifier — Design Spec
**Date:** 2026-03-15
**Status:** Approved
**Scope:** Avocet — `scripts/`, `app/api.py`, `web/src/views/BenchmarkView.vue`, `environment.yml`
---
## Problem
The benchmark baseline shows zero-shot macro-F1 of 0.366 for the best models (`deberta-zeroshot`, `deberta-base-anli`). Zero-shot inference cannot improve with more labeled data. Fine-tuning the fastest models (`deberta-small` at 111ms, `bge-m3` at 123ms) on the growing labeled dataset is the path to meaningful accuracy gains.
---
## Constraints
- 501 labeled samples after dropping 2 non-canonical `profile_alert` rows
- Heavy class imbalance: `digest` 29%, `neutral` 26%, `new_lead` 2.6%, `survey_received` 3%
- 8.2 GB VRAM (shared with Peregrine vLLM during dev)
- Target models: `cross-encoder/nli-deberta-v3-small` (100M params), `MoritzLaurer/bge-m3-zeroshot-v2.0` (600M params)
- Output: local `models/avocet-{name}/` directory
- UI-triggerable via web interface (SSE streaming log)
- Stack: transformers 4.57.3, torch 2.10.0, accelerate 1.12.0, sklearn, CUDA 8.2GB
---
## Environment changes
`environment.yml` must add:
- `scikit-learn` — required for `train_test_split(stratify=...)` and `f1_score`
- `peft` is NOT used by this spec; it is available in the env but not required here
---
## Architecture
### New file: `scripts/finetune_classifier.py`
CLI entry point for fine-tuning. All prints use `flush=True` so stdout is SSE-streamable.
```
python scripts/finetune_classifier.py --model deberta-small [--epochs 5]
```
Supported `--model` values: `deberta-small`, `bge-m3`
**Model registry** (internal to this script):
| Key | Base model ID | Max tokens | fp16 | Batch size | Grad accum steps | Gradient checkpointing |
|-----|--------------|------------|------|------------|-----------------|----------------------|
| `deberta-small` | `cross-encoder/nli-deberta-v3-small` | 512 | No | 16 | 1 | No |
| `bge-m3` | `MoritzLaurer/bge-m3-zeroshot-v2.0` | 512 | Yes | 4 | 4 | Yes |
`bge-m3` uses `fp16=True` (halves optimizer state from ~4.8GB to ~2.4GB) with batch size 4 + gradient accumulation 4 = effective batch 16, matching `deberta-small`. These settings are required to fit within 8.2GB VRAM. Still stop Peregrine vLLM before running bge-m3 fine-tuning.
### Modified: `scripts/classifier_adapters.py`
Add `FineTunedAdapter(ClassifierAdapter)`:
- Takes `model_dir: str` (path to a `models/avocet-*/` checkpoint)
- Loads via `pipeline("text-classification", model=model_dir)`
- `classify()` input format: **`f"{subject} [SEP] {body[:400]}"`** — must match the training format exactly. Do NOT use the zero-shot adapters' `f"Subject: {subject}\n\n{body[:600]}"` format; distribution shift will degrade accuracy.
- Returns the top predicted label directly (single forward pass — no per-label NLI scoring loop)
- Expected inference speed: ~1020ms/email vs 111338ms for zero-shot
### Modified: `scripts/benchmark_classifier.py`
At startup, scan `models/` for subdirectories containing `training_info.json`. Register each as a dynamic entry in the model registry using `FineTunedAdapter`. Silently skips if `models/` does not exist. Existing CLI behaviour unchanged.
### Modified: `app/api.py`
Two new GET endpoints (GET required for `EventSource` compatibility):
**`GET /api/finetune/status`**
Scans `models/` for `training_info.json` files. Returns:
```json
[
{
"name": "avocet-deberta-small",
"base_model": "cross-encoder/nli-deberta-v3-small",
"val_macro_f1": 0.712,
"timestamp": "2026-03-15T12:00:00Z",
"sample_count": 401
}
]
```
Returns `[]` if no fine-tuned models exist.
**`GET /api/finetune/run?model=deberta-small&epochs=5`**
Spawns `finetune_classifier.py` via the `job-seeker-classifiers` Python binary. Streams stdout as SSE `{"type":"progress","message":"..."}` events. Emits `{"type":"complete"}` on clean exit, `{"type":"error","message":"..."}` on non-zero exit. Same implementation pattern as `/api/benchmark/run`.
### Modified: `web/src/views/BenchmarkView.vue`
**Trained models badge row** (top of view, conditional on fine-tuned models existing):
Shows each fine-tuned model name + val macro-F1 chip. Fetches from `/api/finetune/status` on mount.
**Fine-tune section** (collapsible, below benchmark charts):
- Dropdown: `deberta-small` | `bge-m3`
- Number input: epochs (default 5, range 120)
- Run button → streams into existing log component
- On `complete`: auto-triggers `/api/benchmark/run` (with `--save`) so charts update immediately
---
## Training Pipeline
### Data preparation
1. Load `data/email_score.jsonl`
2. Drop rows where `label` not in canonical `LABELS` (removes `profile_alert` etc.)
3. Check for classes with < 2 **total** samples (before any split). Drop those classes and warn. Additionally warn but do not skip classes with < 5 training samples, noting eval F1 for those classes will be unreliable.
4. Input text: `f"{subject} [SEP] {body[:400]}"` — fits within 512 tokens for both target models
5. Stratified 80/20 train/val split via `sklearn.model_selection.train_test_split(stratify=labels)`
### Class weighting
Compute per-class weights: `total_samples / (n_classes × class_count)`. Pass to a `WeightedTrainer` subclass:
```python
class WeightedTrainer(Trainer):
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
```
### Model setup
```python
AutoModelForSequenceClassification.from_pretrained(
base_model_id,
num_labels=10,
ignore_mismatched_sizes=True, # see note below
id2label=id2label,
label2id=label2id,
)
```
**Note on `ignore_mismatched_sizes=True`:** The pretrained NLI head is a 3-class linear projection. It mismatches the 10-class head constructed by `num_labels=10`, so its weights are skipped during loading. PyTorch initializes the new head from scratch using the model's default init scheme. The backbone weights load normally. Do not set this to `False` — it will raise a shape error.
### Training config and `compute_metrics`
The Trainer requires a `compute_metrics` callback that takes an `EvalPrediction` (logits + label_ids) and returns a dict with a `macro_f1` key. This is distinct from the existing `compute_metrics` in `classifier_adapters.py` (which operates on string predictions):
```python
def compute_metrics_for_trainer(eval_pred: EvalPrediction) -> dict:
logits, labels = eval_pred
preds = logits.argmax(axis=-1)
return {
"macro_f1": f1_score(labels, preds, average="macro", zero_division=0),
"accuracy": accuracy_score(labels, preds),
}
```
`TrainingArguments` must include:
- `load_best_model_at_end=True`
- `metric_for_best_model="macro_f1"`
- `greater_is_better=True`
These are required for `EarlyStoppingCallback` to work correctly. Without `load_best_model_at_end=True`, `EarlyStoppingCallback` raises `AssertionError` on init.
| Hyperparameter | deberta-small | bge-m3 |
|---------------|--------------|--------|
| Epochs | 5 (default, CLI-overridable) | 5 |
| Batch size | 16 | 4 |
| Gradient accumulation | 1 | 4 (effective batch = 16) |
| Learning rate | 2e-5 | 2e-5 |
| LR schedule | Linear with 10% warmup | same |
| Optimizer | AdamW | AdamW |
| fp16 | No | Yes |
| Gradient checkpointing | No | Yes |
| Eval strategy | Every epoch | Every epoch |
| Best checkpoint | By `macro_f1` | same |
| Early stopping patience | 3 epochs | 3 epochs |
### Output
Saved to `models/avocet-{name}/`:
- Model weights + tokenizer (standard HuggingFace format)
- `training_info.json`:
```json
{
"name": "avocet-deberta-small",
"base_model_id": "cross-encoder/nli-deberta-v3-small",
"timestamp": "2026-03-15T12:00:00Z",
"epochs_run": 5,
"val_macro_f1": 0.712,
"val_accuracy": 0.798,
"sample_count": 401,
"label_counts": { "digest": 116, "neutral": 104, ... }
}
```
---
## Data Flow
```
email_score.jsonl
finetune_classifier.py
├── drop non-canonical labels
├── check for < 2 total samples per class (drop + warn)
├── stratified 80/20 split
├── tokenize (subject [SEP] body[:400])
├── compute class weights
├── WeightedTrainer + EarlyStoppingCallback
└── save → models/avocet-{name}/
├── FineTunedAdapter (classifier_adapters.py)
│ ├── pipeline("text-classification")
│ ├── input: subject [SEP] body[:400] ← must match training format
│ └── ~1020ms/email inference
└── training_info.json
└── /api/finetune/status
└── BenchmarkView badge row
```
---
## Error Handling
- **Insufficient data (< 2 total samples in a class):** Drop class before split, print warning with class name and count.
- **Low data warning (< 5 training samples in a class):** Warn but continue; note eval F1 for that class will be unreliable.
- **VRAM OOM on bge-m3:** Surface as clear SSE error message. Suggest stopping Peregrine vLLM first (it holds ~5.7GB).
- **Missing score file:** Raise `FileNotFoundError` with actionable message (same pattern as `load_scoring_jsonl`).
- **Model dir already exists:** Overwrite with a warning log line. Re-running always produces a fresh checkpoint.
---
## Testing
- Unit test `WeightedTrainer.compute_loss` with a mock model and known label distribution — verify weighted loss differs from unweighted; verify `**kwargs` does not raise `TypeError`
- Unit test `compute_metrics_for_trainer` — verify `macro_f1` key in output, correct value on known inputs
- Unit test `FineTunedAdapter.classify` with a mock pipeline — verify it returns a string from `LABELS` using `subject [SEP] body[:400]` format
- Unit test auto-discovery in `benchmark_classifier.py` — mock `models/` dir with two `training_info.json` files, verify both appear in the active registry
- Integration test: fine-tune on `data/email_score.jsonl.example` (8 samples, 5 of 10 labels represented, 1 epoch, `--model deberta-small`). The 5 missing labels trigger the `< 2 total samples` drop path — the test must verify the drop warning is emitted for each missing label rather than treating it as a failure. Verify `models/avocet-deberta-small/training_info.json` is written with correct keys.
---
## Out of Scope
- Pushing fine-tuned weights to HuggingFace Hub (future)
- Cross-validation or k-fold evaluation (future — dataset too small to be meaningful now)
- Hyperparameter search (future)
- LoRA/PEFT adapter fine-tuning (future — relevant if model sizes grow beyond available VRAM)
- Fine-tuning models other than `deberta-small` and `bge-m3`

View file

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

View file

@ -93,6 +93,12 @@ usage() {
echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]"
echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]"
echo ""
echo " Vue API:"
echo -e " ${GREEN}start-api${NC} Build Vue SPA + start FastAPI on port 8503"
echo -e " ${GREEN}stop-api${NC} Stop FastAPI server"
echo -e " ${GREEN}restart-api${NC} Stop + rebuild + restart FastAPI server"
echo -e " ${GREEN}open-api${NC} Open Vue UI in browser (http://localhost:8503)"
echo ""
echo " Dev:"
echo -e " ${GREEN}test${NC} Run pytest suite"
echo ""
@ -251,6 +257,72 @@ case "$CMD" in
exec "$0" benchmark --compare "$@"
;;
start-api)
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
mkdir -p "$LOG_DIR"
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 API 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 "API died during startup. Check ${API_LOG}"
fi
done
if ! (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then
error "API did not bind to port ${API_PORT} within 10 s. Check ${API_LOG}"
fi
;;
stop-api)
API_PID_FILE=".avocet-api.pid"
if [[ ! -f "$API_PID_FILE" ]]; then
warn "API not running."
exit 0
fi
PID="$(<"$API_PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" && rm -f "$API_PID_FILE"
success "API stopped (PID ${PID})."
else
warn "Stale PID file (process ${PID} not running). Cleaning up."
rm -f "$API_PID_FILE"
fi
;;
restart-api)
bash "$0" stop-api
exec bash "$0" start-api
;;
open-api)
URL="http://localhost:8503"
info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then
xdg-open "$URL"
elif command -v open &>/dev/null; then
open "$URL"
else
echo "$URL"
fi
;;
help|--help|-h)
usage
;;

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)

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.label_tool 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) == ""

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,275 @@
<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: '/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,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 }
}

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')

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

@ -0,0 +1,19 @@
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')
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: '/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,
}
})

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>

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,431 @@
<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>
<!-- 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')
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)
</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;
}
</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()],
})

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

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