Compare commits

...

10 commits

Author SHA1 Message Date
82eeb4defc fix: prevent blank page on rebuild and queue drain on skip/discard
Two bugs fixed:

1. Blank white page after vue SPA rebuild: browsers cached old index.html
   referencing old asset hashes. Assets are deleted on rebuild, causing
   404s for JS/CSS -> blank page. Fix: serve index.html with
   Cache-Control: no-cache so browsers always fetch fresh HTML.
   Hashed assets (/assets/chunk-abc123.js) remain cacheable forever.

2. Queue draining to empty on skip/discard: handleSkip and handleDiscard
   never refilled the local queue buffer. After enough skips, store.current
   went null and the empty state showed (blank-looking). Fix: both handlers
   now call fetchBatch() when queue drops below 3, matching handleLabel.

Also: sync classifier_adapters LABELS to match current 10-label schema
(new_lead + hired, remove unrelated).

48 Python tests pass, 48 frontend tests pass.
2026-03-03 19:26:34 -08:00
a06b133a6e docs(avocet): document email field schemas and normalization layer 2026-03-03 18:43:41 -08:00
b54b2a711e fix(avocet): normalize queue schema + bind to 0.0.0.0 for LAN access
- Add _item_id() (content hash) + _normalize() to map legacy JSONL fields
  (from_addr/account/no-id) to Vue schema (from/source/id)
- All mutating endpoints now look up by _normalize(x)[id] — handles both
  stored-id (test fixtures) and content-hash (real data) transparently
- Change uvicorn bind from 127.0.0.1 to 0.0.0.0 so LAN clients can connect
2026-03-03 18:43:00 -08:00
cd7bbd1dbf fix(avocet): start-api polls port instead of sleeping 1s — avoids false-success on slow start 2026-03-03 18:11:53 -08:00
682a958c28 fix(avocet): strip HTML from email bodies — stdlib HTMLParser, no deps 2026-03-03 16:28:18 -08:00
47973aeba6 feat(avocet): easter eggs — hired confetti, century mark, clean sweep, midnight labeler, cursor trail 2026-03-03 16:24:47 -08:00
deddd763ea feat(avocet): manage.sh start-api / stop-api / open-api commands 2026-03-03 16:23:56 -08:00
382bca28a1 feat(avocet): LabelView — wires store, API, card stack, keyboard, easter eggs
Implements Task 13: LabelView.vue wires together the label store, API
fetch, card stack, bucket grid, keyboard shortcuts, haptics, motion
preference, and three easter egg badges (on-a-roll, speed round, fifty
deep). App.vue updated to mount LabelView and restore hacker-mode theme
on load. 3 new LabelView tests; all 48 tests pass, build clean.
2026-03-03 16:21:07 -08:00
b623d252d0 feat(avocet): LabelBucketGrid bucket-mode CSS — spring expansion, glow on drop 2026-03-03 16:19:29 -08:00
9b5a1ae752 feat(avocet): EmailCardStack — swipe gestures, depth shadows, dismissal classes 2026-03-03 16:16:09 -08:00
14 changed files with 1307 additions and 97 deletions

View file

@ -75,8 +75,9 @@ conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --exp
| `survey_received` | 5 | Culture-fit survey or assessment invitation | | `survey_received` | 5 | Culture-fit survey or assessment invitation |
| `neutral` | 6 | ATS confirmation (application received, etc.) | | `neutral` | 6 | ATS confirmation (application received, etc.) |
| `event_rescheduled` | 7 | Interview or event moved to a new time | | `event_rescheduled` | 7 | Interview or event moved to a new time |
| `unrelated` | 8 | Non-job-search email, not classifiable | | `digest` | 8 | Job digest or multi-listing email (scrapeable) |
| `digest` | 9 | 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) ## Model Registry (13 models, 7 defaults)
@ -105,6 +106,67 @@ Add `--models deberta-small deberta-small-2pass` to test a specific subset.
- `--compare` uses the first account in `label_tool.yaml` for live IMAP emails. - `--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. - 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 ## Relationship to Peregrine
Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`. Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`.

View file

@ -5,6 +5,7 @@ Endpoints and static file serving are added in subsequent tasks.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
from pathlib import Path from pathlib import Path
@ -60,6 +61,29 @@ def _append_jsonl(path: Path, record: dict) -> None:
f.write(json.dumps(record, ensure_ascii=False) + "\n") 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") app = FastAPI(title="Avocet API")
# In-memory last-action store (single user, local tool — in-memory is fine) # In-memory last-action store (single user, local tool — in-memory is fine)
@ -69,7 +93,7 @@ _last_action: dict | None = None
@app.get("/api/queue") @app.get("/api/queue")
def get_queue(limit: int = Query(default=10, ge=1, le=50)): def get_queue(limit: int = Query(default=10, ge=1, le=50)):
items = _read_jsonl(_queue_file()) items = _read_jsonl(_queue_file())
return {"items": items[:limit], "total": len(items)} return {"items": [_normalize(x) for x in items[:limit]], "total": len(items)}
class LabelRequest(BaseModel): class LabelRequest(BaseModel):
@ -81,13 +105,13 @@ class LabelRequest(BaseModel):
def post_label(req: LabelRequest): def post_label(req: LabelRequest):
global _last_action global _last_action
items = _read_jsonl(_queue_file()) items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None) match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match: if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue") raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": req.label, record = {**match, "label": req.label,
"labeled_at": datetime.now(timezone.utc).isoformat()} "labeled_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_score_file(), record) _append_jsonl(_score_file(), record)
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id]) _write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "label", "item": match, "label": req.label} _last_action = {"type": "label", "item": match, "label": req.label}
return {"ok": True} return {"ok": True}
@ -100,10 +124,10 @@ class SkipRequest(BaseModel):
def post_skip(req: SkipRequest): def post_skip(req: SkipRequest):
global _last_action global _last_action
items = _read_jsonl(_queue_file()) items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None) match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match: if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue") raise HTTPException(404, f"Item {req.id!r} not found in queue")
reordered = [x for x in items if x["id"] != req.id] + [match] reordered = [x for x in items if _normalize(x)["id"] != req.id] + [match]
_write_jsonl(_queue_file(), reordered) _write_jsonl(_queue_file(), reordered)
_last_action = {"type": "skip", "item": match} _last_action = {"type": "skip", "item": match}
return {"ok": True} return {"ok": True}
@ -117,14 +141,14 @@ class DiscardRequest(BaseModel):
def post_discard(req: DiscardRequest): def post_discard(req: DiscardRequest):
global _last_action global _last_action
items = _read_jsonl(_queue_file()) items = _read_jsonl(_queue_file())
match = next((x for x in items if x["id"] == req.id), None) match = next((x for x in items if _normalize(x)["id"] == req.id), None)
if not match: if not match:
raise HTTPException(404, f"Item {req.id!r} not found in queue") raise HTTPException(404, f"Item {req.id!r} not found in queue")
record = {**match, "label": "__discarded__", record = {**match, "label": "__discarded__",
"discarded_at": datetime.now(timezone.utc).isoformat()} "discarded_at": datetime.now(timezone.utc).isoformat()}
_append_jsonl(_discarded_file(), record) _append_jsonl(_discarded_file(), record)
_write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id]) _write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id])
_last_action = {"type": "discard", "item": match} # store ORIGINAL match, not enriched record _last_action = {"type": "discard", "item": match}
return {"ok": True} return {"ok": True}
@ -153,14 +177,13 @@ def delete_undo():
_write_jsonl(_queue_file(), [item] + items) _write_jsonl(_queue_file(), [item] + items)
elif action["type"] == "skip": elif action["type"] == "skip":
items = _read_jsonl(_queue_file()) items = _read_jsonl(_queue_file())
# Remove the item wherever it sits (guards against duplicate insertion), item_id = _normalize(item)["id"]
# then prepend it to the front — restoring it to position 0. items = [item] + [x for x in items if _normalize(x)["id"] != item_id]
items = [item] + [x for x in items if x["id"] != item["id"]]
_write_jsonl(_queue_file(), items) _write_jsonl(_queue_file(), items)
# Clear AFTER all file operations succeed # Clear AFTER all file operations succeed
_last_action = None _last_action = None
return {"undone": {"type": action["type"], "item": item}} return {"undone": {"type": action["type"], "item": _normalize(item)}}
# Label metadata — 10 labels matching label_tool.py # Label metadata — 10 labels matching label_tool.py
@ -186,5 +209,16 @@ def get_labels():
# Static SPA — MUST be last (catches all unmatched paths) # Static SPA — MUST be last (catches all unmatched paths)
_DIST = _ROOT / "web" / "dist" _DIST = _ROOT / "web" / "dist"
if _DIST.exists(): if _DIST.exists():
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles 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") app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa")

View file

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

View file

@ -93,6 +93,11 @@ usage() {
echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]" echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]"
echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]"
echo "" 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}open-api${NC} Open Vue UI in browser (http://localhost:8503)"
echo ""
echo " Dev:" echo " Dev:"
echo -e " ${GREEN}test${NC} Run pytest suite" echo -e " ${GREEN}test${NC} Run pytest suite"
echo "" echo ""
@ -251,6 +256,67 @@ case "$CMD" in
exec "$0" benchmark --compare "$@" 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
;;
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) help|--help|-h)
usage usage
;; ;;

View file

@ -27,8 +27,9 @@ LABELS: list[str] = [
"survey_received", "survey_received",
"neutral", "neutral",
"event_rescheduled", "event_rescheduled",
"unrelated",
"digest", "digest",
"new_lead",
"hired",
] ]
# Natural-language descriptions used by the RerankerAdapter. # Natural-language descriptions used by the RerankerAdapter.
@ -40,8 +41,9 @@ LABEL_DESCRIPTIONS: dict[str, str] = {
"survey_received": "invitation to complete a culture-fit survey or assessment", "survey_received": "invitation to complete a culture-fit survey or assessment",
"neutral": "automated ATS confirmation such as application received", "neutral": "automated ATS confirmation such as application received",
"event_rescheduled": "an interview or scheduled event moved to a new time", "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", "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. # Lazy import shims — allow tests to patch without requiring the libs installed.

View file

@ -2,14 +2,16 @@
import pytest import pytest
def test_labels_constant_has_nine_items(): def test_labels_constant_has_ten_items():
from scripts.classifier_adapters import LABELS from scripts.classifier_adapters import LABELS
assert len(LABELS) == 9 assert len(LABELS) == 10
assert "interview_scheduled" in LABELS assert "interview_scheduled" in LABELS
assert "neutral" in LABELS assert "neutral" in LABELS
assert "event_rescheduled" in LABELS assert "event_rescheduled" in LABELS
assert "unrelated" in LABELS
assert "digest" 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(): def test_compute_metrics_perfect_predictions():

87
tests/test_label_tool.py Normal file
View file

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

View file

@ -1,30 +1,39 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
</script>
<template> <template>
<div> <div id="app" :class="{ 'rich-motion': motion.rich.value }">
<a href="https://vite.dev" target="_blank"> <LabelView />
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div> </div>
<HelloWorld msg="Vite + Vue" />
</template> </template>
<style scoped> <script setup lang="ts">
.logo { import { onMounted } from 'vue'
height: 6em; import { useMotion } from './composables/useMotion'
padding: 1.5em; import { useHackerMode } from './composables/useEasterEgg'
will-change: filter; import LabelView from './views/LabelView.vue'
transition: filter 300ms;
const motion = useMotion()
const { restore } = useHackerMode()
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;
} }
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa); body {
font-family: var(--font-body, sans-serif);
background: var(--color-bg, #f0f4fc);
color: var(--color-text, #1a2338);
min-height: 100dvh;
} }
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa); #app {
min-height: 100dvh;
} }
</style> </style>

View file

@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import EmailCardStack from './EmailCardStack.vue'
import { describe, it, expect } from 'vitest'
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('applies dismiss-label class when dismissType is label', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'label' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-label')
})
it('applies dismiss-discard class when dismissType is discard', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'discard' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-discard')
})
it('applies dismiss-skip class when dismissType is skip', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'skip' } })
expect(w.find('.card-wrapper').classes()).toContain('dismiss-skip')
})
it('no dismiss class when dismissType is null', () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } })
const wrapperClasses = w.find('.card-wrapper').classes()
expect(wrapperClasses).not.toContain('dismiss-label')
expect(wrapperClasses).not.toContain('dismiss-discard')
expect(wrapperClasses).not.toContain('dismiss-skip')
})
it('emits drag-start on dragstart event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragstart')
expect(w.emitted('drag-start')).toBeTruthy()
})
it('emits drag-end on dragend event', async () => {
const w = mount(EmailCardStack, { props: { item, isBucketMode: false } })
await w.find('.card-stack').trigger('dragend')
expect(w.emitted('drag-end')).toBeTruthy()
})
})

View file

@ -0,0 +1,138 @@
<template>
<div
class="card-stack"
ref="stackEl"
:draggable="motion.rich.value"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<!-- 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="dismissClass"
:style="cardStyle"
>
<EmailCard
:item="item"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSwipe } from '@vueuse/core'
import { useMotion } from '../composables/useMotion'
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': []
}>()
const motion = useMotion()
const cardEl = ref<HTMLElement | null>(null)
const stackEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const dragX = ref(0)
const { isSwiping, lengthX } = useSwipe(cardEl, {
threshold: 60,
onSwipeEnd(_, dir) {
if (dir === 'left') emit('discard')
if (dir === 'right') emit('skip')
dragX.value = 0
},
onSwipe() {
if (motion.rich.value) dragX.value = lengthX.value * -1
},
})
const dismissClass = computed(() => {
if (!props.dismissType) return null
return `dismiss-${props.dismissType}`
})
const cardStyle = computed(() => {
if (!motion.rich.value || !isSwiping.value) return {}
const tilt = dragX.value * 0.05
const opacity = Math.abs(dragX.value) > 20 ? 0.9 : 1
const color = dragX.value < -20 ? 'rgba(244,67,54,0.15)'
: dragX.value > 20 ? 'rgba(255,152,0,0.15)'
: 'transparent'
return {
transform: `translateX(${dragX.value}px) rotate(${tilt}deg)`,
opacity,
background: color,
transition: isSwiping.value ? 'none' : 'all 0.3s ease',
}
})
function onDragStart() { emit('drag-start') }
function onDragEnd() { emit('drag-end') }
</script>
<style scoped>
.card-stack {
position: relative;
min-height: 200px;
}
.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);
}
.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: transform, opacity;
}
/* Dismissal animations — only active under .rich-motion on root */
:global(.rich-motion) .card-wrapper.dismiss-label {
animation: fileAway var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .card-wrapper.dismiss-discard {
animation: crumple var(--card-dismiss, 350ms ease-in) forwards;
}
:global(.rich-motion) .card-wrapper.dismiss-skip {
animation: slideUnder var(--card-skip, 300ms ease-out) forwards;
}
@keyframes fileAway {
to { transform: translateY(-120%) scale(0.85); opacity: 0; }
}
@keyframes crumple {
50% { transform: scale(0.95) rotate(2deg); filter: brightness(0.6) sepia(1) hue-rotate(-20deg); }
to { transform: scale(0) rotate(8deg); opacity: 0; }
}
@keyframes slideUnder {
to { transform: translateX(110%) rotate(5deg); opacity: 0; }
}
</style>

View file

@ -40,11 +40,11 @@ function onDrop(name: string) {
.label-grid { .label-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
gap: var(--space-2); gap: 0.5rem;
transition: all var(--bucket-expand); transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
} }
/* Mobile: 3×3 numpad layout + hired at bottom */ /* Mobile: 3-column numpad layout */
@media (max-width: 480px) { @media (max-width: 480px) {
.label-grid { .label-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@ -52,67 +52,70 @@ function onDrop(name: string) {
} }
.label-grid.bucket-mode { .label-grid.bucket-mode {
gap: var(--space-4); gap: 1rem;
padding: var(--space-3); padding: 1rem;
} }
.label-btn { .label-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--space-1); justify-content: center;
padding: var(--space-2) var(--space-1); gap: 0.25rem;
border: 2px solid var(--label-color, var(--color-border)); min-height: 44px; /* Touch target */
border-radius: var(--radius-md); padding: 0.5rem 0.25rem;
border-radius: 0.5rem;
border: 2px solid var(--label-color, #607D8B);
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text, #1a2338);
cursor: pointer; cursor: pointer;
font-family: var(--font-body); transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1));
transition: all var(--bucket-expand); font-family: var(--font-body, sans-serif);
min-height: 44px; /* touch target */
} }
.label-grid.bucket-mode .label-btn { .label-grid.bucket-mode .label-btn {
padding: var(--space-6) var(--space-2); min-height: 80px;
font-size: 1.15rem; padding: 1rem 0.5rem;
} border-width: 3px;
font-size: 1.1rem;
.label-btn:hover,
.label-btn:focus-visible {
background: color-mix(in srgb, var(--label-color) 12%, transparent);
}
.label-btn:focus-visible {
outline: 2px solid var(--label-color);
outline-offset: 2px;
} }
.label-btn.is-drop-target { .label-btn.is-drop-target {
background: var(--label-color); background: var(--label-color, #607D8B);
color: var(--color-text-inverse, #fff); color: #fff;
transform: scale(1.08); transform: scale(1.08);
box-shadow: 0 0 16px color-mix(in srgb, var(--label-color) 60%, transparent); 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 { .key-hint {
font-size: 0.7rem; font-size: 0.65rem;
font-weight: 700; font-family: var(--font-mono, monospace);
opacity: 0.6; opacity: 0.55;
font-family: var(--font-mono); line-height: 1;
} }
.emoji { font-size: 1.2rem; line-height: 1; } .emoji {
font-size: 1.25rem;
line-height: 1;
}
.label-name { .label-name {
font-size: 0.65rem; font-size: 0.7rem;
text-align: center; text-align: center;
line-height: 1.2; line-height: 1.2;
word-break: break-word; word-break: break-word;
color: var(--color-text-muted); hyphens: auto;
} }
.label-grid.bucket-mode .label-name { /* Reduced-motion fallback */
font-size: 0.75rem; @media (prefers-reduced-motion: reduce) {
color: var(--color-text); .label-grid,
.label-btn {
transition: none;
}
} }
</style> </style>

View file

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

View file

@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import LabelView from './LabelView.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')
})
})

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

@ -0,0 +1,386 @@
<template>
<div class="label-view">
<!-- Header bar -->
<header class="lv-header">
<span class="queue-count">
<template v-if="store.totalRemaining > 0">
{{ store.totalRemaining }} remaining
</template>
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
<span v-if="speedRound" class="badge badge-speed"> Speed round!</span>
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
<span v-if="centuryMark" class="badge badge-century">💯 Century!</span>
<span v-if="cleanSweep" class="badge badge-sweep">🧹 Clean sweep!</span>
<span v-if="midnightLabeler" class="badge badge-midnight">🦉 Midnight labeler!</span>
</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>
<div class="card-stack-wrapper">
<EmailCardStack
:item="store.current"
:is-bucket-mode="isDragging"
:dismiss-type="dismissType"
@label="handleLabel"
@skip="handleSkip"
@discard="handleDiscard"
@drag-start="isDragging = true"
@drag-end="isDragging = false"
/>
</div>
<LabelBucketGrid
:labels="labels"
:is-bucket-mode="isDragging"
@label="handleLabel"
/>
</template>
<!-- Undo toast -->
<UndoToast
v-if="store.lastAction"
:action="store.lastAction"
@undo="handleUndo"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
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 loading = ref(true)
const apiError = ref(false)
const isDragging = ref(false)
const labels = ref<any[]>([])
const dismissType = ref<'label' | 'skip' | 'discard' | null>(null)
// 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: [], // will be updated after labels load keyboard not active until queue loads
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;
}
.lv-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.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);
animation: badge-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes badge-pop {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.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;
}
</style>