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
4
.gitignore
vendored
|
|
@ -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
173
CLAUDE.md
Normal 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: 1–9 = 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
7
PRIVACY.md
Normal 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
569
app/api.py
Normal 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
214
app/imap_fetch.py
Normal 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}
|
||||
|
|
@ -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: 1–9 = label, 0 = other, S = skip, U = undo")
|
||||
nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9, 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")
|
||||
|
|
|
|||
95
docs/plans/2026-03-08-anime-animation-design.md
Normal file
95
docs/plans/2026-03-08-anime-animation-design.md
Normal 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 |
|
||||
573
docs/plans/2026-03-08-anime-animation-plan.md
Normal file
573
docs/plans/2026-03-08-anime-animation-plan.md
Normal 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 25–46) 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 (1–9) 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"
|
||||
```
|
||||
1861
docs/superpowers/plans/2026-03-15-finetune-classifier.md
Normal file
1861
docs/superpowers/plans/2026-03-15-finetune-classifier.md
Normal file
File diff suppressed because it is too large
Load diff
254
docs/superpowers/specs/2026-03-15-finetune-classifier-design.md
Normal file
254
docs/superpowers/specs/2026-03-15-finetune-classifier-design.md
Normal 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: ~10–20ms/email vs 111–338ms 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 1–20)
|
||||
- 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
|
||||
│ └── ~10–20ms/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`
|
||||
|
|
@ -14,6 +14,7 @@ dependencies:
|
|||
- transformers>=4.40
|
||||
- torch>=2.2
|
||||
- accelerate>=0.27
|
||||
- scikit-learn>=1.4
|
||||
|
||||
# Optional: GLiClass adapter
|
||||
# - gliclass
|
||||
|
|
|
|||
72
manage.sh
72
manage.sh
|
|
@ -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
5
requirements.txt
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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: ~10–20ms/email vs 111–338ms 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"]
|
||||
|
|
|
|||
416
scripts/finetune_classifier.py
Normal file
416
scripts/finetune_classifier.py
Normal 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
561
tests/test_api.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
371
tests/test_finetune.py
Normal 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
86
tests/test_imap_fetch.py
Normal 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
87
tests/test_label_tool.py
Normal 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 & etc.
|
||||
result = _strip_html("<p>Hello & welcome</p>")
|
||||
assert "&" 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
24
web/.gitignore
vendored
Normal 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
3
web/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
web/README.md
Normal file
5
web/README.md
Normal 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
18
web/index.html
Normal 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
4939
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
web/package.json
Normal file
38
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
54
web/src/App.vue
Normal 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
71
web/src/assets/avocet.css
Normal 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
268
web/src/assets/theme.css
Normal 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
1
web/src/assets/vue.svg
Normal 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 |
275
web/src/components/AppSidebar.vue
Normal file
275
web/src/components/AppSidebar.vue
Normal 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>
|
||||
39
web/src/components/EmailCard.test.ts
Normal file
39
web/src/components/EmailCard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
114
web/src/components/EmailCard.vue
Normal file
114
web/src/components/EmailCard.vue
Normal 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>
|
||||
183
web/src/components/EmailCardStack.test.ts
Normal file
183
web/src/components/EmailCardStack.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
272
web/src/components/EmailCardStack.vue
Normal file
272
web/src/components/EmailCardStack.vue
Normal 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>
|
||||
41
web/src/components/HelloWorld.vue
Normal file
41
web/src/components/HelloWorld.vue
Normal 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>
|
||||
52
web/src/components/LabelBucketGrid.test.ts
Normal file
52
web/src/components/LabelBucketGrid.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
132
web/src/components/LabelBucketGrid.vue
Normal file
132
web/src/components/LabelBucketGrid.vue
Normal 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>
|
||||
89
web/src/components/UndoToast.test.ts
Normal file
89
web/src/components/UndoToast.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
106
web/src/components/UndoToast.vue
Normal file
106
web/src/components/UndoToast.vue
Normal 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>
|
||||
50
web/src/composables/useApi.ts
Normal file
50
web/src/composables/useApi.ts
Normal 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()
|
||||
}
|
||||
142
web/src/composables/useCardAnimation.test.ts
Normal file
142
web/src/composables/useCardAnimation.test.ts
Normal 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' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
99
web/src/composables/useCardAnimation.ts
Normal file
99
web/src/composables/useCardAnimation.ts
Normal 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 }
|
||||
}
|
||||
160
web/src/composables/useEasterEgg.ts
Normal file
160
web/src/composables/useEasterEgg.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
18
web/src/composables/useHaptics.ts
Normal file
18
web/src/composables/useHaptics.ts
Normal 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]),
|
||||
}
|
||||
}
|
||||
106
web/src/composables/useLabelKeyboard.test.ts
Normal file
106
web/src/composables/useLabelKeyboard.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
41
web/src/composables/useLabelKeyboard.ts
Normal file
41
web/src/composables/useLabelKeyboard.ts
Normal 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 }
|
||||
}
|
||||
28
web/src/composables/useMotion.ts
Normal file
28
web/src/composables/useMotion.ts
Normal 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
20
web/src/main.ts
Normal 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
19
web/src/router/index.ts
Normal 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
30
web/src/smoke.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
62
web/src/stores/label.test.ts
Normal file
62
web/src/stores/label.test.ts
Normal 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
53
web/src/stores/label.ts
Normal 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
79
web/src/style.css
Normal 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
17
web/src/test-setup.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
846
web/src/views/BenchmarkView.vue
Normal file
846
web/src/views/BenchmarkView.vue
Normal 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.4–0.7 · 🔴 < 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 red→yellow→green 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
459
web/src/views/FetchView.vue
Normal 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>
|
||||
92
web/src/views/LabelView.test.ts
Normal file
92
web/src/views/LabelView.test.ts
Normal 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
506
web/src/views/LabelView.vue
Normal 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>
|
||||
431
web/src/views/SettingsView.vue
Normal file
431
web/src/views/SettingsView.vue
Normal 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 & 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
245
web/src/views/StatsView.vue
Normal 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
16
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
5
web/uno.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { defineConfig, presetWind, presetAttributify } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [presetWind(), presetAttributify()],
|
||||
})
|
||||
12
web/vite.config.ts
Normal file
12
web/vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue