chore: ignore .superpowers/, docs/superpowers/, pytest-output.txt; untrack plan/spec files
Some checks failed
CI / test (pull_request) Failing after 24s

This commit is contained in:
pyr0ball 2026-03-21 00:55:17 -07:00
parent a7303c1dff
commit 5e22067ab5
18 changed files with 3 additions and 12863 deletions

3
.gitignore vendored
View file

@ -35,6 +35,9 @@ config/user.yaml.working
# Claude context files — kept out of version control # Claude context files — kept out of version control
CLAUDE.md CLAUDE.md
.superpowers/
pytest-output.txt
docs/superpowers/
data/email_score.jsonl data/email_score.jsonl
data/email_label_queue.jsonl data/email_label_queue.jsonl

File diff suppressed because it is too large Load diff

View file

@ -1,700 +0,0 @@
# Jobgether Integration Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Filter Jobgether listings out of all other scrapers, add a dedicated Jobgether scraper and URL scraper (Playwright-based), and add recruiter-aware cover letter framing for Jobgether jobs.
**Architecture:** Blocklist config handles filtering with zero code changes. A new `_scrape_jobgether()` in `scrape_url.py` handles manual URL imports via Playwright with URL slug fallback. A new `scripts/custom_boards/jobgether.py` handles discovery. Cover letter framing is an `is_jobgether` flag threaded from `task_runner.py``generate()``build_prompt()`.
**Tech Stack:** Python, Playwright (already installed), SQLite, PyTest, YAML config
**Spec:** `/Library/Development/CircuitForge/peregrine/docs/superpowers/specs/2026-03-15-jobgether-integration-design.md`
---
## Worktree Setup
- [ ] **Create worktree for this feature**
```bash
cd /Library/Development/CircuitForge/peregrine
git worktree add .worktrees/jobgether-integration -b feature/jobgether-integration
```
All implementation work happens in `/Library/Development/CircuitForge/peregrine/.worktrees/jobgether-integration/`.
---
## Chunk 1: Blocklist filter + scrape_url.py
### Task 1: Add Jobgether to blocklist
**Files:**
- Modify: `/Library/Development/CircuitForge/peregrine/config/blocklist.yaml`
- [ ] **Step 1: Edit blocklist.yaml**
```yaml
companies:
- jobgether
```
- [ ] **Step 2: Verify the existing `_is_blocklisted` test passes (or write one)**
Check `/Library/Development/CircuitForge/peregrine/tests/test_discover.py` for existing blocklist tests. If none cover company matching, add:
```python
def test_is_blocklisted_jobgether():
from scripts.discover import _is_blocklisted
blocklist = {"companies": ["jobgether"], "industries": [], "locations": []}
assert _is_blocklisted({"company": "Jobgether", "location": "", "description": ""}, blocklist)
assert _is_blocklisted({"company": "jobgether inc", "location": "", "description": ""}, blocklist)
assert not _is_blocklisted({"company": "Acme Corp", "location": "", "description": ""}, blocklist)
```
Run: `conda run -n job-seeker python -m pytest tests/test_discover.py -v -k "blocklist"`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add config/blocklist.yaml tests/test_discover.py
git commit -m "feat: filter Jobgether listings via blocklist"
```
---
### Task 2: Add Jobgether detection to scrape_url.py
**Files:**
- Modify: `/Library/Development/CircuitForge/peregrine/scripts/scrape_url.py`
- Modify: `/Library/Development/CircuitForge/peregrine/tests/test_scrape_url.py`
- [ ] **Step 1: Write failing tests**
In `/Library/Development/CircuitForge/peregrine/tests/test_scrape_url.py`, add:
```python
def test_detect_board_jobgether():
from scripts.scrape_url import _detect_board
assert _detect_board("https://jobgether.com/offer/69b42d9d24d79271ee0618e8-csm---resware") == "jobgether"
assert _detect_board("https://www.jobgether.com/offer/abc-role---company") == "jobgether"
def test_jobgether_slug_company_extraction():
from scripts.scrape_url import _company_from_jobgether_url
assert _company_from_jobgether_url(
"https://jobgether.com/offer/69b42d9d24d79271ee0618e8-customer-success-manager---resware"
) == "Resware"
assert _company_from_jobgether_url(
"https://jobgether.com/offer/abc123-director-of-cs---acme-corp"
) == "Acme Corp"
assert _company_from_jobgether_url(
"https://jobgether.com/offer/abc123-no-separator-here"
) == ""
def test_scrape_jobgether_no_playwright(tmp_path):
"""When Playwright is unavailable, _scrape_jobgether falls back to URL slug for company."""
# Patch playwright.sync_api to None in sys.modules so the local import inside
# _scrape_jobgether raises ImportError at call time (local imports run at call time,
# not at module load time — so no reload needed).
import sys
import unittest.mock as mock
url = "https://jobgether.com/offer/69b42d9d24d79271ee0618e8-customer-success-manager---resware"
with mock.patch.dict(sys.modules, {"playwright": None, "playwright.sync_api": None}):
from scripts.scrape_url import _scrape_jobgether
result = _scrape_jobgether(url)
assert result.get("company") == "Resware"
assert result.get("source") == "jobgether"
```
Run: `conda run -n job-seeker python -m pytest tests/test_scrape_url.py::test_detect_board_jobgether tests/test_scrape_url.py::test_jobgether_slug_company_extraction tests/test_scrape_url.py::test_scrape_jobgether_no_playwright -v`
Expected: FAIL (functions not yet defined)
- [ ] **Step 2: Add `_company_from_jobgether_url()` to scrape_url.py**
Add after the `_STRIP_PARAMS` block (around line 34):
```python
def _company_from_jobgether_url(url: str) -> str:
"""Extract company name from Jobgether offer URL slug.
Slug format: /offer/{24-hex-hash}-{title-slug}---{company-slug}
Triple-dash separator delimits title from company.
Returns title-cased company name, or "" if pattern not found.
"""
m = re.search(r"---([^/?]+)$", urlparse(url).path)
if not m:
print(f"[scrape_url] Jobgether URL slug: no company separator found in {url}")
return ""
return m.group(1).replace("-", " ").title()
```
- [ ] **Step 3: Add `"jobgether"` branch to `_detect_board()`**
In `/Library/Development/CircuitForge/peregrine/scripts/scrape_url.py`, modify `_detect_board()` (add before `return "generic"`):
```python
if "jobgether.com" in url_lower:
return "jobgether"
```
- [ ] **Step 4: Add `_scrape_jobgether()` function**
Add after `_scrape_glassdoor()` (around line 137):
```python
def _scrape_jobgether(url: str) -> dict:
"""Scrape a Jobgether offer page using Playwright to bypass 403.
Falls back to URL slug for company name when Playwright is unavailable.
Does not use requests — no raise_for_status().
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
company = _company_from_jobgether_url(url)
if company:
print(f"[scrape_url] Jobgether: Playwright not installed, using slug fallback → {company}")
return {"company": company, "source": "jobgether"} if company else {}
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
ctx = browser.new_context(user_agent=_HEADERS["User-Agent"])
page = ctx.new_page()
page.goto(url, timeout=30_000)
page.wait_for_load_state("networkidle", timeout=20_000)
result = page.evaluate("""() => {
const title = document.querySelector('h1')?.textContent?.trim() || '';
const company = document.querySelector('[class*="company"], [class*="employer"], [data-testid*="company"]')
?.textContent?.trim() || '';
const location = document.querySelector('[class*="location"], [data-testid*="location"]')
?.textContent?.trim() || '';
const desc = document.querySelector('[class*="description"], [class*="job-desc"], article')
?.innerText?.trim() || '';
return { title, company, location, description: desc };
}""")
finally:
browser.close()
# Fall back to slug for company if DOM extraction missed it
if not result.get("company"):
result["company"] = _company_from_jobgether_url(url)
result["source"] = "jobgether"
return {k: v for k, v in result.items() if v}
except Exception as exc:
print(f"[scrape_url] Jobgether Playwright error for {url}: {exc}")
# Last resort: slug fallback
company = _company_from_jobgether_url(url)
return {"company": company, "source": "jobgether"} if company else {}
```
> ⚠️ **The CSS selectors in the `page.evaluate()` call are placeholders.** Before committing, inspect `https://jobgether.com/offer/` in a browser to find the actual class names for title, company, location, and description. Update the selectors accordingly.
- [ ] **Step 5: Add dispatch branch in `scrape_job_url()`**
In the `if board == "linkedin":` dispatch chain (around line 208), add before the `else`:
```python
elif board == "jobgether":
fields = _scrape_jobgether(url)
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `conda run -n job-seeker python -m pytest tests/test_scrape_url.py -v`
Expected: All PASS (including pre-existing tests)
- [ ] **Step 7: Commit**
```bash
git add scripts/scrape_url.py tests/test_scrape_url.py
git commit -m "feat: add Jobgether URL detection and scraper to scrape_url.py"
```
---
## Chunk 2: Jobgether custom board scraper
> ⚠️ **Pre-condition:** Before writing the scraper, inspect `https://jobgether.com/remote-jobs` live to determine the actual URL/filter param format and DOM card selectors. Use the Playwright MCP browser tool or Chrome devtools. Record: (1) the query param for job title search, (2) the job card CSS selectors for title, company, URL, location, salary.
### Task 3: Inspect Jobgether search live
**Files:** None (research step)
- [ ] **Step 1: Navigate to Jobgether remote jobs and inspect search params**
Using browser devtools or Playwright network capture, navigate to `https://jobgether.com/remote-jobs`, search for "Customer Success Manager", and capture:
- The resulting URL (query params)
- Network requests (XHR/fetch) if the page uses API calls
- CSS selectors for job card elements
Record findings here before proceeding.
- [ ] **Step 2: Test a Playwright page.evaluate() extraction manually**
```python
# Run interactively to validate selectors
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False) # headless=False to see the page
page = browser.new_page()
page.goto("https://jobgether.com/remote-jobs")
page.wait_for_load_state("networkidle")
# Test your selectors here
cards = page.query_selector_all("[YOUR_CARD_SELECTOR]")
print(len(cards))
browser.close()
```
---
### Task 4: Write jobgether.py scraper
**Files:**
- Create: `/Library/Development/CircuitForge/peregrine/scripts/custom_boards/jobgether.py`
- Modify: `/Library/Development/CircuitForge/peregrine/tests/test_discover.py` (or create `tests/test_jobgether.py`)
- [ ] **Step 1: Write failing test**
In `/Library/Development/CircuitForge/peregrine/tests/test_discover.py` (or a new `tests/test_jobgether.py`):
```python
def test_jobgether_scraper_returns_empty_on_missing_playwright(monkeypatch):
"""Graceful fallback when Playwright is unavailable."""
import scripts.custom_boards.jobgether as jg
monkeypatch.setattr("scripts.custom_boards.jobgether.sync_playwright", None)
result = jg.scrape({"titles": ["Customer Success Manager"]}, "Remote", results_wanted=5)
assert result == []
def test_jobgether_scraper_respects_results_wanted(monkeypatch):
"""Scraper caps results at results_wanted."""
import scripts.custom_boards.jobgether as jg
fake_jobs = [
{"title": f"CSM {i}", "href": f"/offer/abc{i}-csm---acme", "company": f"Acme {i}",
"location": "Remote", "is_remote": True, "salary": ""}
for i in range(20)
]
class FakePage:
def goto(self, *a, **kw): pass
def wait_for_load_state(self, *a, **kw): pass
def evaluate(self, _): return fake_jobs
class FakeCtx:
def new_page(self): return FakePage()
class FakeBrowser:
def new_context(self, **kw): return FakeCtx()
def close(self): pass
class FakeChromium:
def launch(self, **kw): return FakeBrowser()
class FakeP:
chromium = FakeChromium()
def __enter__(self): return self
def __exit__(self, *a): pass
monkeypatch.setattr("scripts.custom_boards.jobgether.sync_playwright", lambda: FakeP())
result = jg.scrape({"titles": ["CSM"]}, "Remote", results_wanted=5)
assert len(result) <= 5
```
Run: `conda run -n job-seeker python -m pytest tests/ -v -k "jobgether"`
Expected: FAIL (module not found)
- [ ] **Step 2: Create `scripts/custom_boards/jobgether.py`**
```python
"""Jobgether scraper — Playwright-based (requires chromium installed).
Jobgether (jobgether.com) is a remote-work job aggregator. It blocks plain
requests with 403, so we use Playwright to render the page and extract cards.
Install Playwright: conda run -n job-seeker pip install playwright &&
conda run -n job-seeker python -m playwright install chromium
Returns a list of dicts compatible with scripts.db.insert_job().
"""
from __future__ import annotations
import re
import time
from typing import Any
_BASE = "https://jobgether.com"
_SEARCH_PATH = "/remote-jobs"
# TODO: Replace with confirmed query param key after live inspection (Task 3)
_QUERY_PARAM = "search"
# Module-level import so tests can monkeypatch scripts.custom_boards.jobgether.sync_playwright
try:
from playwright.sync_api import sync_playwright
except ImportError:
sync_playwright = None
def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]:
"""
Scrape job listings from Jobgether using Playwright.
Args:
profile: Search profile dict (uses 'titles').
location: Location string — Jobgether is remote-focused; location used
only if the site exposes a location filter.
results_wanted: Maximum results to return across all titles.
Returns:
List of job dicts with keys: title, company, url, source, location,
is_remote, salary, description.
"""
if sync_playwright is None:
print(
" [jobgether] playwright not installed.\n"
" Install: conda run -n job-seeker pip install playwright && "
"conda run -n job-seeker python -m playwright install chromium"
)
return []
results: list[dict] = []
seen_urls: set[str] = set()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
)
)
page = ctx.new_page()
for title in profile.get("titles", []):
if len(results) >= results_wanted:
break
# TODO: Confirm URL param format from live inspection (Task 3)
url = f"{_BASE}{_SEARCH_PATH}?{_QUERY_PARAM}={title.replace(' ', '+')}"
try:
page.goto(url, timeout=30_000)
page.wait_for_load_state("networkidle", timeout=20_000)
except Exception as exc:
print(f" [jobgether] Page load error for '{title}': {exc}")
continue
# TODO: Replace JS selector with confirmed card selector from Task 3
try:
raw_jobs: list[dict[str, Any]] = page.evaluate(_extract_jobs_js())
except Exception as exc:
print(f" [jobgether] JS extract error for '{title}': {exc}")
continue
if not raw_jobs:
print(f" [jobgether] No cards found for '{title}' — selector may need updating")
continue
for job in raw_jobs:
href = job.get("href", "")
if not href:
continue
full_url = _BASE + href if href.startswith("/") else href
if full_url in seen_urls:
continue
seen_urls.add(full_url)
results.append({
"title": job.get("title", ""),
"company": job.get("company", ""),
"url": full_url,
"source": "jobgether",
"location": job.get("location") or "Remote",
"is_remote": True, # Jobgether is remote-focused
"salary": job.get("salary") or "",
"description": "", # not in card view; scrape_url fills in
})
if len(results) >= results_wanted:
break
time.sleep(1) # polite pacing between titles
browser.close()
return results[:results_wanted]
def _extract_jobs_js() -> str:
"""JS to run in page context — extracts job data from rendered card elements.
TODO: Replace selectors with confirmed values from Task 3 live inspection.
"""
return """() => {
// TODO: replace '[class*=job-card]' with confirmed card selector
const cards = document.querySelectorAll('[class*="job-card"], [data-testid*="job"]');
return Array.from(cards).map(card => {
// TODO: replace these selectors with confirmed values
const titleEl = card.querySelector('h2, h3, [class*="title"]');
const companyEl = card.querySelector('[class*="company"], [class*="employer"]');
const linkEl = card.querySelector('a');
const salaryEl = card.querySelector('[class*="salary"]');
const locationEl = card.querySelector('[class*="location"]');
return {
title: titleEl ? titleEl.textContent.trim() : null,
company: companyEl ? companyEl.textContent.trim() : null,
href: linkEl ? linkEl.getAttribute('href') : null,
salary: salaryEl ? salaryEl.textContent.trim() : null,
location: locationEl ? locationEl.textContent.trim() : null,
is_remote: true,
};
}).filter(j => j.title && j.href);
}"""
```
- [ ] **Step 3: Run tests**
Run: `conda run -n job-seeker python -m pytest tests/ -v -k "jobgether"`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add scripts/custom_boards/jobgether.py tests/test_discover.py
git commit -m "feat: add Jobgether custom board scraper (selectors pending live inspection)"
```
---
## Chunk 3: Registration, config, cover letter framing
### Task 5: Register scraper in discover.py + update search_profiles.yaml
**Files:**
- Modify: `/Library/Development/CircuitForge/peregrine/scripts/discover.py`
- Modify: `/Library/Development/CircuitForge/peregrine/config/search_profiles.yaml`
- Modify: `/Library/Development/CircuitForge/peregrine/config/search_profiles.yaml.example` (if it exists)
- [ ] **Step 1: Add import to discover.py import block (lines 2022)**
`jobgether.py` absorbs the Playwright `ImportError` internally (module-level `try/except`), so it always imports successfully. Match the existing pattern exactly:
```python
from scripts.custom_boards import jobgether as _jobgether
```
- [ ] **Step 2: Add to CUSTOM_SCRAPERS dict literal (lines 3034)**
```python
CUSTOM_SCRAPERS: dict[str, object] = {
"adzuna": _adzuna.scrape,
"theladders": _theladders.scrape,
"craigslist": _craigslist.scrape,
"jobgether": _jobgether.scrape,
}
```
When Playwright is absent, `_jobgether.scrape()` returns `[]` gracefully — no special guard needed in `discover.py`.
- [ ] **Step 3: Add `jobgether` to remote-eligible profiles in search_profiles.yaml**
Add `- jobgether` to the `custom_boards` list for every profile that has `Remote` in its `locations`. Based on the current file, that means: `cs_leadership`, `music_industry`, `animal_welfare`, `education`. Do NOT add it to `default` (locations: San Francisco CA only).
- [ ] **Step 4: Run discover tests**
Run: `conda run -n job-seeker python -m pytest tests/test_discover.py -v`
Expected: All PASS
- [ ] **Step 5: Commit**
```bash
git add scripts/discover.py config/search_profiles.yaml
git commit -m "feat: register Jobgether scraper and add to remote search profiles"
```
---
### Task 6: Cover letter recruiter framing
**Files:**
- Modify: `/Library/Development/CircuitForge/peregrine/scripts/generate_cover_letter.py`
- Modify: `/Library/Development/CircuitForge/peregrine/scripts/task_runner.py`
- Modify: `/Library/Development/CircuitForge/peregrine/tests/test_match.py` or add `tests/test_cover_letter.py`
- [ ] **Step 1: Write failing test**
Create or add to `/Library/Development/CircuitForge/peregrine/tests/test_cover_letter.py`:
```python
def test_build_prompt_jobgether_framing_unknown_company():
from scripts.generate_cover_letter import build_prompt
prompt = build_prompt(
title="Customer Success Manager",
company="Jobgether",
description="CSM role at an undisclosed company.",
examples=[],
is_jobgether=True,
)
assert "Your client" in prompt
assert "recruiter" in prompt.lower() or "jobgether" in prompt.lower()
def test_build_prompt_jobgether_framing_known_company():
from scripts.generate_cover_letter import build_prompt
prompt = build_prompt(
title="Customer Success Manager",
company="Resware",
description="CSM role at Resware.",
examples=[],
is_jobgether=True,
)
assert "Your client at Resware" in prompt
def test_build_prompt_no_jobgether_framing_by_default():
from scripts.generate_cover_letter import build_prompt
prompt = build_prompt(
title="Customer Success Manager",
company="Acme Corp",
description="CSM role.",
examples=[],
)
assert "Your client" not in prompt
```
Run: `conda run -n job-seeker python -m pytest tests/test_cover_letter.py -v`
Expected: FAIL
- [ ] **Step 2: Add `is_jobgether` to `build_prompt()` in generate_cover_letter.py**
Modify the `build_prompt()` signature (line 186):
```python
def build_prompt(
title: str,
company: str,
description: str,
examples: list[dict],
mission_hint: str | None = None,
is_jobgether: bool = False,
) -> str:
```
Add the recruiter hint block after the `mission_hint` block (after line 203):
```python
if is_jobgether:
if company and company.lower() != "jobgether":
recruiter_note = (
f"🤝 Recruiter context: This listing is posted by Jobgether on behalf of "
f"{company}. Address the cover letter to the Jobgether recruiter, not directly "
f"to the hiring company. Use framing like 'Your client at {company} will "
f"appreciate...' rather than addressing {company} directly. The role "
f"requirements are those of the actual employer."
)
else:
recruiter_note = (
"🤝 Recruiter context: This listing is posted by Jobgether on behalf of an "
"undisclosed employer. Address the cover letter to the Jobgether recruiter. "
"Use framing like 'Your client will appreciate...' rather than addressing "
"the company directly."
)
parts.append(f"{recruiter_note}\n")
```
- [ ] **Step 3: Add `is_jobgether` to `generate()` signature**
Modify `generate()` (line 233):
```python
def generate(
title: str,
company: str,
description: str = "",
previous_result: str = "",
feedback: str = "",
is_jobgether: bool = False,
_router=None,
) -> str:
```
Pass it through to `build_prompt()` (line 254):
```python
prompt = build_prompt(title, company, description, examples,
mission_hint=mission_hint, is_jobgether=is_jobgether)
```
- [ ] **Step 4: Pass `is_jobgether` from task_runner.py**
In `/Library/Development/CircuitForge/peregrine/scripts/task_runner.py`, modify the `generate()` call inside the `cover_letter` task block (`elif task_type == "cover_letter":` starts at line 152; the `generate()` call is at ~line 156):
```python
elif task_type == "cover_letter":
import json as _json
p = _json.loads(params or "{}")
from scripts.generate_cover_letter import generate
result = generate(
job.get("title", ""),
job.get("company", ""),
job.get("description", ""),
previous_result=p.get("previous_result", ""),
feedback=p.get("feedback", ""),
is_jobgether=job.get("source") == "jobgether",
)
update_cover_letter(db_path, job_id, result)
```
- [ ] **Step 5: Run tests**
Run: `conda run -n job-seeker python -m pytest tests/test_cover_letter.py -v`
Expected: All PASS
- [ ] **Step 6: Run full test suite**
Run: `conda run -n job-seeker python -m pytest tests/ -v`
Expected: All PASS
- [ ] **Step 7: Commit**
```bash
git add scripts/generate_cover_letter.py scripts/task_runner.py tests/test_cover_letter.py
git commit -m "feat: add Jobgether recruiter framing to cover letter generation"
```
---
## Final: Merge
- [ ] **Merge worktree branch to main**
```bash
cd /Library/Development/CircuitForge/peregrine
git merge feature/jobgether-integration
git worktree remove .worktrees/jobgether-integration
```
- [ ] **Push to remote**
```bash
git push origin main
```
---
## Manual verification after merge
1. Add the stuck Jobgether manual import (job 2286) — delete the old stuck row and re-add the URL via "Add Jobs by URL" in the Home page. Verify the scraper resolves company = "Resware".
2. Run a short discovery (`discover.py` with `results_per_board: 5`) and confirm no `company="Jobgether"` rows appear in `staging.db`.
3. Generate a cover letter for a Jobgether-sourced job and confirm recruiter framing appears.

File diff suppressed because it is too large Load diff

View file

@ -1,934 +0,0 @@
# Apply View — Desktop Split-Pane Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor the Apply view for desktop into a master-detail split pane (28% job list / 72% workspace) with an expand-from-divider animation, while leaving mobile completely unchanged.
**Architecture:** `ApplyWorkspace.vue` is extracted from `ApplyWorkspaceView.vue` as a prop-driven component, allowing it to render both inline (split pane) and as a standalone route (mobile). `ApplyView.vue` owns the split layout, selection state, and three easter eggs. The fourth easter egg (Perfect Match shimmer) lives inside `ApplyWorkspace.vue` since it needs access to the loaded job's score.
**Tech Stack:** Vue 3 + TypeScript + Pinia-free (local `ref` state) + CSS Grid column transitions + `useEasterEgg.ts` composables (existing)
---
## File Map
| File | Action | Responsibility |
|------|--------|----------------|
| `web/src/assets/peregrine.css` | Modify | Add `--score-mid-high` CSS variable; add `.score-badge--mid-high` class |
| `web/src/components/ApplyWorkspace.vue` | **Create** | Extracted workspace: `jobId: number` prop, emits `job-removed` + `cover-letter-generated`, Perfect Match shimmer |
| `web/src/views/ApplyWorkspaceView.vue` | Modify | Slim to thin wrapper: `<ApplyWorkspace :job-id="...">` |
| `web/src/views/ApplyView.vue` | **Replace** | Split-pane layout, narrow list rows, Speed Demon + Marathon + (Konami already global) |
No router changes — `/apply/:id` stays as-is.
---
## Task 1: Score Badge 4-Tier CSS
**Files:**
- Modify: `web/src/assets/peregrine.css`
The current badge CSS has 3 tiers with outdated thresholds (`≥80`, `≥60`). The new spec uses 4 tiers aligned with the existing CSS variable comments: green ≥70%, **blue 5069%** (new), amber 3049%, red <30%.
**Why `peregrine.css`?** Score tokens (`--score-high`, `--score-mid`, `--score-low`) are defined there. The new `--score-mid-high` token and the `.score-badge--mid-high` class belong alongside them.
**Note:** `ApplyWorkspaceView.vue` defines `.score-badge--*` classes in its `<style scoped>`. After Task 2 extracts the workspace into `ApplyWorkspace.vue`, those scoped styles move with it. The canonical badge classes already also exist in `ApplyView.vue`'s scoped styles and will be updated there in Task 3.
- [ ] **Step 1: Add `--score-mid-high` token and `.score-badge--mid-high` class**
Open `web/src/assets/peregrine.css`. Find the score token block (around line 55). Add the `--score-mid-high` variable and its dark-mode equivalent. Then find where `.score-badge--*` classes are defined (if they exist globally) or note the pattern for Task 2.
In `:root`:
```css
--score-high: var(--color-success); /* ≥ 70% */
--score-mid-high: #2b7cb8; /* 5069% — Falcon Blue variant */
--score-mid: var(--color-warning); /* 3049% */
--score-low: var(--color-error); /* < 30% */
--score-none: var(--color-text-muted);
```
Also add dark-mode override. The existing dark-mode block in `peregrine.css` uses:
`@media (prefers-color-scheme: dark) { :root:not([data-theme="hacker"]) { ... } }`
Add inside that exact block:
```css
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
```
Also update the existing `--score-mid` comment from `/* 4069% */` to `/* 3049% */` to keep the inline documentation accurate.
- [ ] **Step 2: Verify the token exists by checking the file**
```bash
grep -n "score-mid-high\|score-high\|score-mid\|score-low" \
/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web/src/assets/peregrine.css
```
Expected: 4 lines with the new token appearing between `score-high` and `score-mid`.
- [ ] **Step 3: Commit**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/assets/peregrine.css
git commit -m "style(apply): add score-badge--mid-high token for 4-tier scoring"
```
---
## Task 2: Extract `ApplyWorkspace.vue` + Perfect Match Easter Egg
**Files:**
- Create: `web/src/components/ApplyWorkspace.vue`
- Modify: `web/src/views/ApplyWorkspaceView.vue`
This is the largest refactor. The goal is to move all workspace logic out of the route view into a reusable component that accepts a `jobId` prop.
**Dependencies:** Task 2 must complete before Task 3 — `ApplyView.vue` imports `ApplyWorkspace.vue`.
**Key changes vs. `ApplyWorkspaceView.vue`:**
1. `jobId` comes from prop, not `useRoute()` — remove the `useRoute()`, `useRouter()`, and `RouterLink` imports
2. All API calls use `props.jobId` instead of the old module-level `const jobId`. The exact locations: `fetchJob()`, `pollTaskStatus()`, `generate()`, `saveCoverLetter()`, `downloadPdf()`, `markApplied()`, `rejectListing()`, and the in-flight task check inside `onMounted`
3. `markApplied` / `rejectListing`: emit `job-removed` instead of calling `router.push('/apply')`
4. `generate()` polling: emit `cover-letter-generated` when status transitions to `completed`
5. Remove the `← Back to Apply` `RouterLink` (only needed in the standalone route context)
6. **Preserve `onUnmounted`**`stopPolling()` + `clearTimeout(toastTimer)` cleanup is critical: the component can now unmount mid-session when the user selects a different job
7. `declare module '../stores/review'` augmentation moves here (path `'../stores/review'` is correct from `components/` — resolves to `src/stores/review`)
8. Updated 4-tier `scoreBadgeClass` + `.score-badge--mid-high` class
9. `PERFECT_MATCH_THRESHOLD = 70` const + shimmer on open
- [ ] **Step 1: Create `web/src/components/ApplyWorkspace.vue`**
Create the file with the following structure. Start from `ApplyWorkspaceView.vue` as the source — copy it wholesale, then apply the changes listed below.
**`<script setup lang="ts">` changes:**
```typescript
// Props (replaces route.params.id)
const props = defineProps<{ jobId: number }>()
// Emits
const emit = defineEmits<{
'job-removed': []
'cover-letter-generated': []
}>()
// Remove: const route = useRoute()
// Remove: const router = useRouter()
// Remove: RouterLink import (it is used in template only for the back-link — remove that element too)
// Remove: const jobId = Number(route.params.id)
// KEEP: onUnmounted(() => { stopPolling(); clearTimeout(toastTimer) }) ← do NOT remove this
// jobId is now: props.jobId — update all references from `jobId` to `props.jobId`
// Perfect Match
const PERFECT_MATCH_THRESHOLD = 70 // intentionally = score-badge--high boundary; update together
const shimmeringBadge = ref(false)
// Updated scoreBadgeClass — 4-tier, replaces old 3-tier
const scoreBadgeClass = computed(() => {
const s = job.value?.match_score ?? 0
if (s >= 70) return 'score-badge--high'
if (s >= 50) return 'score-badge--mid-high'
if (s >= 30) return 'score-badge--mid'
return 'score-badge--low'
})
// In markApplied() — replace router.push:
// showToast('Marked as applied ✓')
// setTimeout(() => emit('job-removed'), 1200)
// In rejectListing() — replace router.push:
// showToast('Listing rejected')
// setTimeout(() => emit('job-removed'), 1000)
// In pollTaskStatus(), when status === 'completed', after clState = 'ready':
// emit('cover-letter-generated')
// Perfect Match trigger — add inside fetchJob(), after clState and isSaved are set:
// if ((data.match_score ?? 0) >= PERFECT_MATCH_THRESHOLD) {
// shimmeringBadge.value = false
// nextTick(() => { shimmeringBadge.value = true })
// setTimeout(() => { shimmeringBadge.value = false }, 850)
// }
```
**`<template>` changes:**
- Remove the `<RouterLink to="/apply" class="workspace__back">← Back to Apply</RouterLink>` element
- Add `:class="{ 'score-badge--shimmer': shimmeringBadge }"` to the score badge `<span>` in `.job-details__badges`
**`<style scoped>` changes:**
- Update `.score-badge--mid` and add `.score-badge--mid-high`:
```css
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
/* Perfect Match shimmer — fires once when a ≥70% job opens */
@keyframes shimmer-badge {
0% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
30% { box-shadow: 0 0 8px 3px rgba(212, 175, 55, 0.6); background: rgba(212, 175, 55, 0.2); }
100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
}
.score-badge--shimmer { animation: shimmer-badge 850ms ease-out forwards; }
```
- Move the `declare module` augmentation from `ApplyWorkspaceView.vue` to here:
```typescript
declare module '../stores/review' {
interface Job { cover_letter?: string | null }
}
```
- [ ] **Step 2: Slim down `ApplyWorkspaceView.vue`**
Replace the entire file content with:
```vue
<template>
<ApplyWorkspace
:job-id="jobId"
@job-removed="router.push('/apply')"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
const route = useRoute()
const router = useRouter()
const jobId = computed(() => Number(route.params.id))
</script>
```
- [ ] **Step 3: Run type-check**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
```
Expected: 0 errors. Fix any type errors before continuing. Common errors to expect: forgotten `props.jobId` rename (search for bare `jobId` in `ApplyWorkspace.vue` and confirm every instance is `props.jobId`); leftover `useRoute`/`useRouter` imports.
- [ ] **Step 4: Smoke-test the standalone route**
Start the dev stack (if not already running):
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker uvicorn dev-api:app --port 8601 --reload &
cd web && npm run dev
```
Navigate directly to `http://localhost:5173/apply/1` (or any valid job ID from the staging DB). Verify:
- The workspace loads the job correctly
- "Mark as Applied" and "Reject Listing" navigate back to `/apply` as before
- No console errors
- [ ] **Step 5: Run tests**
```bash
./node_modules/.bin/vitest run
```
Expected: all existing tests still pass (3/3 in `interviews.test.ts`). The refactor should not touch any store logic.
- [ ] **Step 6: Commit**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/components/ApplyWorkspace.vue web/src/views/ApplyWorkspaceView.vue
git commit -m "feat(apply): extract ApplyWorkspace component with job-removed emit and perfect match easter egg"
```
---
## Task 3: Rebuild `ApplyView.vue` — Split Pane + Easter Eggs
**Files:**
- Replace: `web/src/views/ApplyView.vue`
This replaces the entire file. The new `ApplyView.vue` is the split-pane orchestrator on desktop and the unchanged job list on mobile.
**Key behaviors:**
- Desktop (≥1024px): CSS Grid split, `selectedJobId` local state, `<ApplyWorkspace>` panel
- Mobile (<1024px): full-width list, `RouterLink` to `/apply/:id` (unchanged)
- Speed Demon: track last 5 click timestamps; if 5 clicks in < 3s, fire bird animation + toast
- Marathon: `coverLetterCount` ref incremented on `cover-letter-generated` emit from child; badge appears after 5
- Konami: verify it is already registered globally in `App.vue` (see Step 1 below) — if so, no code needed here
- [ ] **Step 1: Verify Konami is already global**
```bash
grep -n "useKonamiCode" \
/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web/src/App.vue
```
Expected: a line like `useKonamiCode(toggle)` — confirming hacker mode is already wired globally. If that line is absent, add `useKonamiCode` + `useHackerMode` to `ApplyView.vue` per the `useEasterEgg.ts` composable API. (In practice it is there — this step just confirms it.)
- [ ] **Step 2: Write the new `ApplyView.vue`**
```vue
<template>
<!-- ── Mobile: full-width list ──────────────────────────────────── -->
<div v-if="isMobile" class="apply-list">
<header class="apply-list__header">
<h1 class="apply-list__title">Apply</h1>
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
</header>
<div v-if="loading" class="apply-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading approved jobs…</span>
</div>
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
<span aria-hidden="true" class="empty-icon">📋</span>
<h2 class="empty-title">No approved jobs yet</h2>
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
<RouterLink to="/review" class="empty-cta">Go to Job Review →</RouterLink>
</div>
<ul v-else class="apply-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
<div class="job-row__main">
<div class="job-row__badges">
<span v-if="job.match_score !== null" class="score-badge" :class="scoreBadgeClass(job.match_score)">
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done">✓ Draft</span>
<span v-else class="cl-badge cl-badge--pending">○ No draft</span>
</div>
<span class="job-row__title">{{ job.title }}</span>
<span class="job-row__company">
{{ job.company }}
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
<span v-if="job.location">{{ job.location }}</span>
</span>
</div>
<div class="job-row__meta">
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
<span class="job-row__arrow" aria-hidden="true"></span>
</div>
</RouterLink>
</li>
</ul>
</div>
<!-- ── Desktop: split pane ─────────────────────────────────────── -->
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
<!-- Left: narrow job list -->
<div class="apply-split__list">
<div class="split-list__header">
<h1 class="split-list__title">Apply</h1>
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
📬 {{ coverLetterCount }} today
</span>
</div>
<div v-if="loading" class="split-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
</div>
<div v-else-if="jobs.length === 0" class="split-list__empty" role="status">
<span>No approved jobs yet.</span>
<RouterLink to="/review" class="split-list__cta">Go to Job Review →</RouterLink>
</div>
<ul v-else class="split-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<button
class="narrow-row"
:class="{ 'narrow-row--selected': job.id === selectedJobId }"
:aria-label="`Open ${job.title} at ${job.company}`"
:aria-pressed="job.id === selectedJobId"
@click="selectJob(job.id)"
>
<div class="narrow-row__top">
<span class="narrow-row__title">{{ job.title }}</span>
<span
v-if="job.match_score !== null"
class="score-badge"
:class="scoreBadgeClass(job.match_score)"
>{{ job.match_score }}%</span>
</div>
<div class="narrow-row__company">
{{ job.company }}<span v-if="job.has_cover_letter" class="narrow-row__cl-tick"></span>
</div>
</button>
</li>
</ul>
</div>
<!-- Right: workspace panel -->
<div class="apply-split__panel" aria-live="polite">
<!-- Empty state -->
<div v-if="selectedJobId === null" class="split-panel__empty">
<span aria-hidden="true" style="font-size: 2rem;">🦅</span>
<p>Select a job to open the workspace</p>
</div>
<!-- Workspace -->
<ApplyWorkspace
v-else
:key="selectedJobId"
:job-id="selectedJobId"
@job-removed="onJobRemoved"
@cover-letter-generated="onCoverLetterGenerated"
/>
</div>
<!-- Speed Demon canvas (hidden until triggered) -->
<canvas ref="birdCanvas" class="bird-canvas" aria-hidden="true" />
<!-- Toast -->
<Transition name="toast">
<div v-if="toast" class="split-toast" role="status" aria-live="polite">{{ toast }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
// ── Responsive ───────────────────────────────────────────────────────────────
const isMobile = ref(window.innerWidth < 1024)
let _mq: MediaQueryList | null = null
let _mqHandler: ((e: MediaQueryListEvent) => void) | null = null
onMounted(() => {
_mq = window.matchMedia('(max-width: 1023px)')
_mqHandler = (e: MediaQueryListEvent) => { isMobile.value = e.matches }
_mq.addEventListener('change', _mqHandler)
})
onUnmounted(() => {
if (_mq && _mqHandler) _mq.removeEventListener('change', _mqHandler)
})
// ── Job list data ─────────────────────────────────────────────────────────────
interface ApprovedJob {
id: number
title: string
company: string
location: string | null
is_remote: boolean
salary: string | null
match_score: number | null
has_cover_letter: boolean
}
const jobs = ref<ApprovedJob[]>([])
const loading = ref(true)
async function fetchJobs() {
loading.value = true
const { data } = await useApiFetch<ApprovedJob[]>(
'/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter'
)
loading.value = false
if (data) jobs.value = data
}
onMounted(fetchJobs)
// ── Score badge — 4-tier ──────────────────────────────────────────────────────
function scoreBadgeClass(score: number | null): string {
if (score === null) return ''
if (score >= 70) return 'score-badge--high'
if (score >= 50) return 'score-badge--mid-high'
if (score >= 30) return 'score-badge--mid'
return 'score-badge--low'
}
// ── Selection ─────────────────────────────────────────────────────────────────
const selectedJobId = ref<number | null>(null)
// Speed Demon: track up to 5 most-recent click timestamps
// Plain let (not ref) — never bound to template, no reactivity needed
let recentClicks: number[] = []
function selectJob(id: number) {
selectedJobId.value = id
// Speed Demon tracking
const now = Date.now()
recentClicks = [...recentClicks, now].slice(-5)
if (
recentClicks.length === 5 &&
recentClicks[4] - recentClicks[0] < 3000
) {
fireSpeedDemon()
recentClicks = []
}
}
// ── Job removed ───────────────────────────────────────────────────────────────
async function onJobRemoved() {
selectedJobId.value = null
await fetchJobs()
}
// ── Marathon counter ──────────────────────────────────────────────────────────
const coverLetterCount = ref(0)
function onCoverLetterGenerated() {
coverLetterCount.value++
}
// ── Toast ─────────────────────────────────────────────────────────────────────
const toast = ref<string | null>(null)
let toastTimer = 0
function showToast(msg: string) {
clearTimeout(toastTimer)
toast.value = msg
toastTimer = window.setTimeout(() => { toast.value = null }, 2500)
}
// ── Easter egg: Speed Demon 🦅 ────────────────────────────────────────────────
const birdCanvas = ref<HTMLCanvasElement | null>(null)
const splitEl = ref<HTMLElement | null>(null)
function fireSpeedDemon() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showToast('🦅 You\'re on the hunt!')
return
}
const canvas = birdCanvas.value
const parent = splitEl.value
if (!canvas || !parent) return
const rect = parent.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')!
const FRAMES = 36 // 600ms at 60fps
const startY = rect.height * 0.35
let frame = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const progress = frame / FRAMES
const x = progress * (canvas.width + 60) - 30
const y = startY + Math.sin(progress * Math.PI) * -30 // slight arc up then down
ctx.font = '2rem serif'
ctx.globalAlpha = frame < 4 ? frame / 4 : frame > FRAMES - 4 ? (FRAMES - frame) / 4 : 1
ctx.fillText('🦅', x, y)
frame++
if (frame <= FRAMES) {
requestAnimationFrame(draw)
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height)
canvas.style.display = 'none'
showToast('🦅 You\'re on the hunt!')
}
}
requestAnimationFrame(draw)
}
</script>
<style scoped>
/* ── Shared: spinner ─────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Shared: score badges ────────────────────────────────────────── */
.score-badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
flex-shrink: 0;
}
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
.remote-badge {
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
/* ── Mobile list (unchanged from original) ───────────────────────── */
.apply-list {
max-width: 760px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
.apply-list__title { font-family: var(--font-display); font-size: var(--text-2xl); color: var(--app-primary); }
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
.apply-list__loading { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-12); color: var(--color-text-muted); font-size: var(--text-sm); justify-content: center; }
.apply-list__empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding: var(--space-16) var(--space-8); text-align: center; }
.empty-icon { font-size: 3rem; }
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
.empty-cta { margin-top: var(--space-2); color: var(--app-primary); font-size: var(--text-sm); font-weight: 600; text-decoration: none; }
.empty-cta:hover { opacity: 0.7; }
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
.job-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-5); background: var(--color-surface-raised); border: 1px solid var(--color-border-light); border-radius: var(--radius-lg); text-decoration: none; min-height: 72px; transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease; }
.job-row:hover { border-color: var(--app-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
.job-row__badges { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: 2px; }
.job-row__title { font-size: var(--text-sm); font-weight: 700; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__company { font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__meta { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
.job-row__salary { font-size: var(--text-xs); color: var(--color-success); font-weight: 600; white-space: nowrap; }
.job-row__arrow { font-size: 1.25rem; color: var(--color-text-muted); line-height: 1; }
.job-row__sep { color: var(--color-border); }
.cl-badge { padding: 1px var(--space-2); border-radius: 999px; font-size: var(--text-xs); font-weight: 600; }
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
/* ── Desktop split pane ──────────────────────────────────────────── */
.apply-split {
position: relative;
display: grid;
grid-template-columns: 28% 0fr;
height: calc(100vh - var(--nav-height, 4rem));
overflow: hidden;
transition: grid-template-columns 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.apply-split { transition: none; }
}
.apply-split.has-selection {
grid-template-columns: 28% 1fr;
}
/* ── Left: narrow list column ────────────────────────────────────── */
.apply-split__list {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-light);
overflow: hidden;
}
.split-list__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--color-border-light);
flex-shrink: 0;
}
.split-list__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--app-primary);
}
/* Marathon badge */
.marathon-badge {
font-size: var(--text-xs);
font-weight: 700;
padding: 2px var(--space-2);
border-radius: 999px;
background: rgba(224, 104, 32, 0.12);
color: var(--app-accent);
border: 1px solid rgba(224, 104, 32, 0.3);
cursor: default;
}
.split-list__loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.split-list__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.split-list__cta {
color: var(--app-primary);
font-size: var(--text-xs);
font-weight: 600;
text-decoration: none;
}
.split-list__jobs {
list-style: none;
overflow-y: auto;
flex: 1;
}
/* ── Narrow row ──────────────────────────────────────────────────── */
.narrow-row {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-light);
cursor: pointer;
text-align: left;
transition: background 100ms ease, border-left-color 100ms ease;
}
.narrow-row:hover {
background: var(--app-primary-light);
border-left-color: rgba(43, 108, 176, 0.3);
}
.narrow-row--selected {
background: var(--app-primary-light);
/* color-mix enhancement for supported browsers */
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised));
border-left-color: var(--app-primary);
}
.narrow-row__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-width: 0;
}
.narrow-row__title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.narrow-row__company {
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.narrow-row__cl-tick {
color: var(--color-success);
font-weight: 700;
}
/* ── Right: workspace panel ──────────────────────────────────────── */
.apply-split__panel {
min-width: 0;
overflow: clip; /* clip prevents BFC side-effect of hidden; also lets position:sticky work inside */
overflow-y: auto;
height: 100%;
opacity: 0;
transition: opacity 150ms ease 100ms; /* 100ms delay so content fades in after column expands */
}
.apply-split.has-selection .apply-split__panel {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.apply-split__panel { transition: none; opacity: 1; }
}
.split-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Easter egg: Speed Demon canvas ─────────────────────────────── */
.bird-canvas {
display: none;
position: absolute;
inset: 0;
pointer-events: none;
z-index: 50;
}
/* ── Toast ───────────────────────────────────────────────────────── */
.split-toast {
position: absolute;
bottom: var(--space-6);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 100;
white-space: nowrap;
}
.toast-enter-active, .toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
/* ── Mobile overrides ────────────────────────────────────────────── */
@media (max-width: 767px) {
.apply-list { padding: var(--space-4); gap: var(--space-4); }
.apply-list__title { font-size: var(--text-xl); }
.job-row { padding: var(--space-3) var(--space-4); }
}
</style>
```
- [ ] **Step 2: Run type-check**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
```
Expected: 0 errors. Fix any type errors before continuing.
- [ ] **Step 3: Smoke-test in the browser**
Start the dev stack:
```bash
# Terminal 1
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker uvicorn dev-api:app --port 8601 --reload &
# Terminal 2
cd web && npm run dev
```
Open http://localhost:5173/apply and verify:
- Desktop (≥1024px): split pane renders, list is narrow on left, right shows empty state with 🦅
- Click a job → panel expands from the divider with animation; workspace loads
- Click another job → panel content switches, selected row highlight updates
- Mark a job as Applied → panel closes, job disappears from list
- Mobile emulation (DevTools → 375px) → single-column list with RouterLink navigation (no split)
- [ ] **Step 4: Test Speed Demon easter egg**
Quickly click 5 different jobs within 3 seconds. Expected: 🦅 streaks across the panel, "You're on the hunt!" toast appears.
With DevTools → Rendering → `prefers-reduced-motion: reduce`: toast only, no canvas animation.
- [ ] **Step 5: Test Marathon easter egg**
Generate cover letters for 5 jobs (or temporarily lower the threshold to 2 for testing, then revert). Expected: `📬 5 today` badge appears in list header. Tooltip on hover: "You're on a roll!".
- [ ] **Step 6: Commit**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/views/ApplyView.vue
git commit -m "feat(apply): desktop split-pane layout with narrow list, expand animation, speed demon + marathon easter eggs"
```
---
## Task 4: Type-Check and Test Suite
**Files:**
- No changes — verification only
- [ ] **Step 1: Run full type-check**
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
```
Expected: 0 errors.
- [ ] **Step 2: Run full test suite**
```bash
./node_modules/.bin/vitest run
```
Expected: all tests pass (minimum 3 from `interviews.test.ts`; any other tests that exist).
- [ ] **Step 3: Commit fixes if needed**
If any fixes were required:
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add -p
git commit -m "fix(apply): type-check and test fixes"
```
---
## Done Criteria
- [ ] `--score-mid-high` CSS token added; `.score-badge--mid-high` class works
- [ ] `scoreBadgeClass()` uses 4-tier thresholds (≥70 / ≥50 / ≥30 / else) in all apply-flow files
- [ ] `ApplyWorkspace.vue` renders the full workspace from a `jobId: number` prop
- [ ] `ApplyWorkspace.vue` emits `job-removed` on mark-applied / reject-listing
- [ ] `ApplyWorkspace.vue` emits `cover-letter-generated` when polling completes
- [ ] Perfect Match shimmer fires once when a ≥70% job opens (`.score-badge--shimmer` keyframe)
- [ ] `ApplyWorkspaceView.vue` is a thin wrapper with `<ApplyWorkspace :job-id="..." @job-removed="...">`
- [ ] Desktop (≥1024px): 28/72 CSS Grid split with `grid-template-columns` transition
- [ ] Panel expand animation uses `overflow: clip` + `min-width: 0` (not `overflow: hidden`)
- [ ] Panel content fades in with 100ms delay after column expands
- [ ] `prefers-reduced-motion`: no grid transition, no canvas animation (toast only for Speed Demon)
- [ ] Narrow list rows: title + score badge (top row), company + ✓ tick (bottom row)
- [ ] Selected row: border-left accent + tinted background (`color-mix` with `--app-primary-light` fallback)
- [ ] Empty panel state shows 🦅 + "Select a job to open the workspace"
- [ ] `@job-removed` clears `selectedJobId` + re-fetches job list
- [ ] Speed Demon: 5 clicks in <3s canvas bird + toast (reduced-motion: toast only)
- [ ] Marathon: 5+ cover letters in session → `📬 N today` badge in list header
- [ ] Konami: already global in `App.vue` — no additional code needed
- [ ] Mobile (<1024px): unchanged full-width list with `RouterLink` navigation
- [ ] Type-check: 0 errors; all tests pass

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,707 +0,0 @@
# Signal Banner Redesign — Expandable Email + Re-classification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Expand signal banners to show full email body, add inline re-classification chips, and remove single-click stage advancement (always route through MoveToSheet).
**Architecture:** Backend adds `body`/`from_addr` to the signal query and a new `POST /api/stage-signals/{id}/reclassify` endpoint. The `StageSignal` TypeScript interface gains two nullable fields. Both `InterviewCard.vue` (kanban) and `InterviewsView.vue` (pre-list) get an expand toggle, body display, and six re-classification chips. Optimistic local mutation drives reactive re-labeling; neutral triggers a two-call dismiss path to preserve Avocet training signal.
**Tech Stack:** FastAPI (Python), SQLite, Vue 3, TypeScript, Pinia, `useApiFetch` composable
**Spec:** `docs/superpowers/specs/2026-03-19-signal-banner-reclassify-design.md`
---
## File Map
| File | Action |
|---|---|
| `dev-api.py` | Add `body, from_addr` to signal SELECT; add `reclassify_signal` endpoint |
| `tests/test_dev_api_interviews.py` | Add `body`/`from_addr` columns to fixture; extend existing signal test; add 3 reclassify tests |
| `web/src/stores/interviews.ts` | Add `body: string \| null`, `from_addr: string \| null` to `StageSignal` |
| `web/src/components/InterviewCard.vue` | `bodyExpanded` ref; expand toggle button; body+from_addr display; 6 reclassify chips; `reclassifySignal()` |
| `web/src/views/InterviewsView.vue` | `bodyExpandedMap` ref; `toggleBodyExpand()`; same body display + chips for pre-list rows |
---
## Task 1: Backend — body/from_addr fields + reclassify endpoint
**Files:**
- Modify: `dev-api.py` (lines ~309325 signal SELECT + append block; after line 388 for new endpoint)
- Modify: `tests/test_dev_api_interviews.py` (fixture schema + 4 test changes)
### Step 1.1: Write the four failing tests
Add to `tests/test_dev_api_interviews.py`:
```python
# ── Body/from_addr in signal response ─────────────────────────────────────
def test_interviews_signal_includes_body_and_from_addr(client):
resp = client.get("/api/interviews")
assert resp.status_code == 200
jobs = {j["id"]: j for j in resp.json()}
sig = jobs[1]["stage_signals"][0]
# Fields must exist (may be None when DB column is NULL)
assert "body" in sig
assert "from_addr" in sig
# ── POST /api/stage-signals/{id}/reclassify ────────────────────────────────
def test_reclassify_signal_updates_label(client, tmp_db):
resp = client.post("/api/stage-signals/10/reclassify",
json={"stage_signal": "positive_response"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
con = sqlite3.connect(tmp_db)
row = con.execute(
"SELECT stage_signal FROM job_contacts WHERE id = 10"
).fetchone()
con.close()
assert row[0] == "positive_response"
def test_reclassify_signal_invalid_label(client):
resp = client.post("/api/stage-signals/10/reclassify",
json={"stage_signal": "not_a_real_label"})
assert resp.status_code == 400
def test_reclassify_signal_404_for_missing_id(client):
resp = client.post("/api/stage-signals/9999/reclassify",
json={"stage_signal": "neutral"})
assert resp.status_code == 404
```
- [ ] Add the four test functions above to `tests/test_dev_api_interviews.py`
### Step 1.2: Also extend `test_interviews_includes_stage_signals` to assert body/from_addr
The existing test (line 64) asserts `id`, `stage_signal`, and `subject`. Add assertions for the two new fields after the existing `assert signals[0]["id"] == 10` line:
```python
assert "body" in signals[0]
assert "from_addr" in signals[0]
```
- [ ] Add those two lines inside `test_interviews_includes_stage_signals`, after `assert signals[0]["id"] == 10`
### Step 1.3: Update the fixture to include body and from_addr columns
The `job_contacts` CREATE TABLE in the `tmp_db` fixture is missing `body TEXT` and `from_addr TEXT`. The fixture test-side schema must match the real DB.
Replace the `job_contacts` CREATE TABLE block (currently `id, job_id, subject, received_at, stage_signal, suggestion_dismissed`) with:
```sql
CREATE TABLE job_contacts (
id INTEGER PRIMARY KEY,
job_id INTEGER,
subject TEXT,
received_at TEXT,
stage_signal TEXT,
suggestion_dismissed INTEGER DEFAULT 0,
body TEXT,
from_addr TEXT
);
```
- [ ] Update the `tmp_db` fixture's `job_contacts` schema to add `body TEXT` and `from_addr TEXT`
### Step 1.4: Run tests to confirm they all fail as expected
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v
```
Expected: 5 new/modified tests FAIL (body/from_addr not in response; reclassify endpoint 404s); existing 8 tests still PASS.
- [ ] Run and confirm
### Step 1.5: Update `dev-api.py` — add body/from_addr to signal SELECT and append
Find the signal SELECT query (line ~309). Replace:
```python
sig_rows = db.execute(
f"SELECT id, job_id, subject, received_at, stage_signal "
```
With:
```python
sig_rows = db.execute(
f"SELECT id, job_id, subject, received_at, stage_signal, body, from_addr "
```
Then extend the `signals_by_job` append dict (line ~319). Replace:
```python
signals_by_job[sr["job_id"]].append({
"id": sr["id"],
"subject": sr["subject"],
"received_at": sr["received_at"],
"stage_signal": sr["stage_signal"],
})
```
With:
```python
signals_by_job[sr["job_id"]].append({
"id": sr["id"],
"subject": sr["subject"],
"received_at": sr["received_at"],
"stage_signal": sr["stage_signal"],
"body": sr["body"],
"from_addr": sr["from_addr"],
})
```
- [ ] Apply both edits to `dev-api.py`
### Step 1.6: Add the reclassify endpoint to `dev-api.py`
After the `dismiss_signal` endpoint (around line 388), add:
```python
# ── POST /api/stage-signals/{id}/reclassify ──────────────────────────────
VALID_SIGNAL_LABELS = {
'interview_scheduled', 'offer_received', 'rejected',
'positive_response', 'survey_received', 'neutral',
'event_rescheduled', 'unrelated', 'digest',
}
class ReclassifyBody(BaseModel):
stage_signal: str
@app.post("/api/stage-signals/{signal_id}/reclassify")
def reclassify_signal(signal_id: int, body: ReclassifyBody):
if body.stage_signal not in VALID_SIGNAL_LABELS:
raise HTTPException(400, f"Invalid label: {body.stage_signal}")
db = _get_db()
result = db.execute(
"UPDATE job_contacts SET stage_signal = ? WHERE id = ?",
(body.stage_signal, signal_id),
)
rowcount = result.rowcount
db.commit()
db.close()
if rowcount == 0:
raise HTTPException(404, "Signal not found")
return {"ok": True}
```
Note: `BaseModel` is already imported via `from pydantic import BaseModel` at the top of the file — check before adding a duplicate import.
- [ ] Add the endpoint and `VALID_SIGNAL_LABELS` / `ReclassifyBody` to `dev-api.py` after the dismiss endpoint
### Step 1.7: Run the full test suite to verify all tests pass
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_dev_api_interviews.py -v
```
Expected: all tests PASS (13 total: 8 existing + 5 new/extended).
- [ ] Run and confirm
### Step 1.8: Commit
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add dev-api.py tests/test_dev_api_interviews.py
git commit -m "feat(signals): add body/from_addr to signal query; add reclassify endpoint"
```
- [ ] Commit
---
## Task 2: Store — add body/from_addr to StageSignal interface
**Files:**
- Modify: `web/src/stores/interviews.ts` (lines 510, `StageSignal` interface)
**Why this is its own task:** Both `InterviewCard.vue` and `InterviewsView.vue` import `StageSignal`. TypeScript will error on `sig.body` / `sig.from_addr` until the interface is updated. Committing the type change first keeps Tasks 3 and 4 independently compilable.
### Step 2.1: Update `StageSignal` in `web/src/stores/interviews.ts`
Replace:
```typescript
export interface StageSignal {
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
subject: string
received_at: string // ISO timestamp
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
}
```
With:
```typescript
export interface StageSignal {
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
subject: string
received_at: string // ISO timestamp
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
body: string | null // email body text; null if not available
from_addr: string | null // sender address; null if not available
}
```
- [ ] Edit `web/src/stores/interviews.ts`
### Step 2.2: Verify TypeScript compiles cleanly
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit
```
Expected: 0 errors (the new fields are nullable so no existing code should break).
- [ ] Run and confirm
### Step 2.3: Commit
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/stores/interviews.ts
git commit -m "feat(signals): add body and from_addr to StageSignal interface"
```
- [ ] Commit
---
## Task 3: InterviewCard.vue — expand toggle, body display, reclassify chips
**Files:**
- Modify: `web/src/components/InterviewCard.vue`
**Context:** This component is the kanban card. It shows one signal by default, and a `+N more` button for additional signals. The `bodyExpanded` ref is per-card (not per-signal) because at most one signal is visible in collapsed state.
### Step 3.1: Add `bodyExpanded` ref and `reclassifySignal` function
In the `<script setup>` section, after the `dismissSignal` function, add:
```typescript
const bodyExpanded = ref(false)
// Re-classify chips — neutral triggers two-call dismiss path
const RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '— Neutral', value: 'neutral' },
] as const
async function reclassifySignal(sig: StageSignal, newLabel: string) {
if (newLabel === 'neutral') {
// Optimistic removal — neutral signals are dismissed
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
// Two-call path: persist corrected label then dismiss (Avocet training hook)
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: 'neutral' }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
} else {
// Optimistic local re-label — Vue 3 proxy tracks the mutation
sig.stage_signal = newLabel as StageSignal['stage_signal']
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
}
}
```
- [ ] Add `bodyExpanded`, `RECLASSIFY_CHIPS`, and `reclassifySignal` to the script section
### Step 3.2: Update the signal banner template
Find the signal banner `<template v-if="job.stage_signals?.length">` block (lines ~131163). Replace the entire block with:
```html
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
<div
v-for="sig in visibleSignals()"
:key="sig.id"
class="signal-banner"
:style="{
background: COLOR_BG[SIGNAL_META[sig.stage_signal].color],
borderTopColor: COLOR_BORDER[SIGNAL_META[sig.stage_signal].color],
}"
>
<div class="signal-header">
<span class="signal-label">
📧 <strong>{{ SIGNAL_META[sig.stage_signal].label.replace('Move to ', '') }}</strong>
</span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click.stop="bodyExpanded = !bodyExpanded">
{{ bodyExpanded ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click.stop="emit('move', props.job.id, SIGNAL_META[sig.stage_signal].stage)"
:aria-label="`${SIGNAL_META[sig.stage_signal].label} for ${props.job.title}`"
>→ Move</button>
<button
class="btn-signal-dismiss"
@click.stop="dismissSignal(sig)"
aria-label="Dismiss signal"
>✕</button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="bodyExpanded" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifySignal(sig, chip.value)"
>{{ chip.label }}</button>
</div>
</div>
</div>
<button
v-if="(job.stage_signals?.length ?? 0) > 1"
class="btn-sig-expand"
@click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
```
- [ ] Replace the signal banner template block
### Step 3.3: Add CSS for new elements
After the existing `.btn-signal-dismiss` rule, add:
```css
.btn-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
```
- [ ] Add the CSS rules to the `<style>` section of `InterviewCard.vue`
### Step 3.4: Verify TypeScript compiles cleanly
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit
```
Expected: 0 errors.
- [ ] Run and confirm
### Step 3.5: Commit
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/components/InterviewCard.vue
git commit -m "feat(signals): expandable body + reclassify chips in InterviewCard"
```
- [ ] Commit
---
## Task 4: InterviewsView.vue — expand toggle, body display, reclassify chips (pre-list)
**Files:**
- Modify: `web/src/views/InterviewsView.vue`
**Context:** This view shows signal banners in the "Applied" pre-list rows. Unlike InterviewCard (one `bodyExpanded` per card), here each signal row can be independently expanded. Use `ref<Record<number, boolean>>` keyed by `sig.id` with spread-copy for guaranteed Vue 3 reactivity (same pattern as `sigExpandedIds` but for body expansion).
### Step 4.1: Add `bodyExpandedMap`, `toggleBodyExpand`, and `reclassifyPreSignal`
In the script section, after the `dismissPreSignal` function (line ~5963), add:
```typescript
const bodyExpandedMap = ref<Record<number, boolean>>({})
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
}
const PRE_RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '— Neutral', value: 'neutral' },
] as const
async function reclassifyPreSignal(job: PipelineJob, sig: StageSignal, newLabel: string) {
if (newLabel === 'neutral') {
// Optimistic removal
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
if (idx !== -1) job.stage_signals.splice(idx, 1)
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: 'neutral' }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
} else {
sig.stage_signal = newLabel as StageSignal['stage_signal']
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
}
}
```
- [ ] Add `bodyExpandedMap`, `toggleBodyExpand`, `PRE_RECLASSIFY_CHIPS`, and `reclassifyPreSignal` to the script section
### Step 4.2: Update the pre-list signal banner template
Find the `<!-- Signal banners for pre-list rows -->` block (lines ~319342). Replace it with:
```html
<!-- Signal banners for pre-list rows -->
<template v-if="job.stage_signals?.length">
<div
v-for="sig in (job.stage_signals ?? []).slice(0, sigExpandedIds.has(job.id) ? undefined : 1)"
:key="sig.id"
class="pre-signal-banner"
:data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
>
<div class="signal-header">
<span class="signal-label">📧 <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label?.replace('Move to ', '') ?? sig.stage_signal }}</strong></span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click="toggleBodyExpand(sig.id)">
{{ bodyExpandedMap[sig.id] ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
>→ Move</button>
<button class="btn-signal-dismiss" @click="dismissPreSignal(job, sig)"></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="bodyExpandedMap[sig.id]" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in PRE_RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click="reclassifyPreSignal(job, sig, chip.value)"
>{{ chip.label }}</button>
</div>
</div>
</div>
<button
v-if="(job.stage_signals?.length ?? 0) > 1"
class="btn-sig-expand"
@click="togglePreSigExpand(job.id)"
>{{ sigExpandedIds.has(job.id) ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
```
- [ ] Replace the pre-list signal banner template block
### Step 4.3: Add CSS for new elements
The `InterviewsView.vue` `<style>` section already has `.btn-signal-dismiss`, `.btn-signal-move`, and `.signal-actions`. Add new rules that do NOT conflict with existing ones — use `.signal-header-actions` (not `.signal-actions`) for the right-aligned button group:
```css
.btn-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
```
- [ ] Add CSS to the `<style>` section of `InterviewsView.vue`
### Step 4.4: Verify TypeScript compiles cleanly
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit
```
Expected: 0 errors.
- [ ] Run and confirm
### Step 4.5: Commit
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/views/InterviewsView.vue
git commit -m "feat(signals): expandable body + reclassify chips in InterviewsView pre-list"
```
- [ ] Commit
---
## Task 5: Final verification
### Step 5.1: Run full Python test suite
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v
```
Expected: all tests pass (509 existing + 5 new = 514 total, or whatever the total becomes).
- [ ] Run and confirm
### Step 5.2: Run TypeScript type check
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npx vue-tsc --noEmit
```
Expected: 0 errors.
- [ ] Run and confirm
### Step 5.3: Run production build
```bash
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
npm run build
```
Expected: build completes with 0 errors and 0 type errors.
- [ ] Run and confirm
---
## Implementation Notes
### `[→ Move]` button label
The spec says keep `[→ Move]` (no rename). The template above uses `→ Move` for both InterviewCard and InterviewsView pre-list rows. The old text was `→ {{ SIGNAL_META[sig.stage_signal].label }}` which was verbose and confusing. The spec says "pre-selection hint is still useful; MoveToSheet confirm is the safeguard" — so `→ Move` is correct: it still opens MoveToSheet with the pre-selected stage.
### `signal-header` and `signal-header-actions` layout note
The existing `.signal-banner` in both files has `display: flex; align-items: center`. The `.signal-header` inner div creates a horizontal flex row for label + subject + action buttons. The action button group uses `.signal-header-actions` (NOT `.signal-actions`) to avoid conflicting with the existing `.signal-actions` rule already present in both Vue files. The existing `.signal-actions` rule does not include `margin-left: auto`, which is what pushes the buttons to the right — `.signal-header-actions` adds that. Do not merge or rename `.signal-actions`; leave it in place for any existing usages.
### Neutral chip type safety
`RECLASSIFY_CHIPS` includes `'neutral'` which is not in `StageSignal['stage_signal']` union. The `reclassifySignal` function handles neutral as a special case before attempting the `as StageSignal['stage_signal']` cast, so TypeScript is satisfied. The `chip.value` in the template is typed as the union of all chip values; the `btn-chip-active` binding uses `sig.stage_signal === chip.value` which TypeScript allows since it's a `===` comparison.
### Reactive re-labeling (spec §"Reactive re-labeling")
`sig.stage_signal = newLabel as StageSignal['stage_signal']` mutates the Pinia reactive proxy directly. This works because `sig` is accessed through the Pinia store's reactive object chain — Vue 3 wraps nested objects on access. This would silently fail only if `job` or `stage_signals` were marked `toRaw()` or `markRaw()`, which they are not. The `SIGNAL_META[sig.stage_signal]` lookups in the template will reactively re-evaluate when `sig.stage_signal` changes.
### `bodyExpandedMap` in InterviewsView
Uses `ref<Record<number, boolean>>({})` (not `Map`) because Vue 3 can track property mutations on plain objects held in a `ref` deeply. The spread-copy pattern `{ ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }` is the guaranteed-safe approach (same principle as the `sigExpandedIds` Set copy-on-write pattern already in the file).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,477 +0,0 @@
# LLM Queue Optimizer — Design Spec
**Date:** 2026-03-14
**Branch:** `feature/llm-queue-optimizer`
**Closes:** [#2](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/issues/2)
**Author:** pyr0ball
---
## Problem
On single-GPU and CPU-only systems, the background task runner spawns a daemon thread for every task immediately on submission. When a user approves N jobs at once, N threads race to load their respective LLM models simultaneously, causing repeated model swaps and significant latency overhead.
The root issue is that `submit_task()` is a spawn-per-task model with no scheduling layer. SQLite's `background_tasks` table is a status log, not a consumed work queue.
Additionally, on restart all `queued` and `running` tasks are cleared to `failed` (inline SQL in `app.py`'s `_startup()`), discarding pending work that had not yet started executing.
---
## Goals
- Eliminate unnecessary model switching by batching LLM tasks by type
- Allow concurrent model execution when VRAM permits multiple models simultaneously
- Preserve FIFO ordering within each task type
- Survive process restarts — `queued` tasks resume after restart; only `running` tasks (whose results are unknown) are reset to `failed`
- Apply to all tiers (no tier gating)
- Keep non-LLM tasks (discovery, email sync, scrape, enrich) unaffected — they continue to spawn free threads
---
## Non-Goals
- Changing the LLM router fallback chain
- Adding new task types
- Tier gating on the scheduler
- Persistent task history in memory
- Durability for non-LLM task types (discovery, email_sync, etc. — these do not survive restarts, same as current behavior)
- Dynamic VRAM tracking — `_available_vram` is read once at startup and not refreshed (see Known Limitations)
---
## Architecture
### Task Classification
```python
LLM_TASK_TYPES = {"cover_letter", "company_research", "wizard_generate"}
```
The routing rule is: if `task_type in LLM_TASK_TYPES`, route through the scheduler. Everything else spawns a free thread unchanged from the current implementation. **Future task types default to bypass mode** unless explicitly added to `LLM_TASK_TYPES` — which is the safe default (bypass = current behavior).
`LLM_TASK_TYPES` is defined in `scripts/task_scheduler.py` and imported by `scripts/task_runner.py` for routing. This import direction (task_runner imports from task_scheduler) avoids circular imports because `task_scheduler.py` does **not** import from `task_runner.py`.
Current non-LLM types (all bypass scheduler): `discovery`, `email_sync`, `scrape_url`, `enrich_descriptions`, `enrich_craigslist`, `prepare_training`.
### Routing in `submit_task()` — No Circular Import
The routing split lives entirely in `submit_task()` in `task_runner.py`:
```python
def submit_task(db_path, task_type, job_id=None, params=None):
task_id, is_new = insert_task(db_path, task_type, job_id or 0, params=params)
if is_new:
from scripts.task_scheduler import get_scheduler, LLM_TASK_TYPES
if task_type in LLM_TASK_TYPES:
get_scheduler(db_path).enqueue(task_id, task_type, job_id or 0, params)
else:
t = threading.Thread(
target=_run_task,
args=(db_path, task_id, task_type, job_id or 0, params),
daemon=True,
)
t.start()
return task_id, is_new
```
`TaskScheduler.enqueue()` only handles LLM task types and never imports or calls `_run_task`. This eliminates any circular import between `task_runner` and `task_scheduler`.
### Component Overview
```
submit_task()
├── task_type in LLM_TASK_TYPES?
│ │ yes │ no
│ ▼ ▼
│ get_scheduler().enqueue() spawn free thread (unchanged)
│ │
│ ▼
│ per-type deque
│ │
│ ▼
│ Scheduler loop (daemon thread)
│ (wakes on enqueue or batch completion)
│ │
│ Sort eligible types by queue depth (desc)
│ │
│ For each type:
│ reserved_vram + budget[type] ≤ available_vram?
│ │ yes │ no
│ ▼ ▼
│ Start batch worker skip (wait for slot)
│ (serial: one task at a time)
│ │
│ Batch worker signals done → scheduler re-evaluates
```
### New File: `scripts/task_scheduler.py`
**State:**
| Attribute | Type | Purpose |
|---|---|---|
| `_queues` | `dict[str, deque[TaskSpec]]` | Per-type pending task deques |
| `_active` | `dict[str, Thread]` | Currently running batch worker per type |
| `_budgets` | `dict[str, float]` | VRAM budget per task type (GB). Loaded at construction by merging `DEFAULT_VRAM_BUDGETS` with `scheduler.vram_budgets` from `config/llm.yaml`. Config path derived from `db_path` (e.g. `db_path.parent.parent / "config/llm.yaml"`). Missing file or key → defaults used as-is. At construction, a warning is logged for any type in `LLM_TASK_TYPES` with no budget entry after the merge. |
| `_reserved_vram` | `float` | Sum of `_budgets` values for currently active type batches |
| `_available_vram` | `float` | Total VRAM from `get_gpus()` summed across all GPUs at construction; 999.0 on CPU-only systems. Static — not refreshed after startup (see Known Limitations). |
| `_max_queue_depth` | `int` | Max tasks per type queue before drops. From `scheduler.max_queue_depth` in config; default 500. |
| `_lock` | `threading.Lock` | Protects all mutable scheduler state |
| `_wake` | `threading.Event` | Pulsed on enqueue or batch completion |
| `_stop` | `threading.Event` | Set by `shutdown()` to terminate the loop |
**Default VRAM budgets (module-level constant):**
```python
DEFAULT_VRAM_BUDGETS: dict[str, float] = {
"cover_letter": 2.5, # alex-cover-writer:latest (~2GB GGUF + headroom)
"company_research": 5.0, # llama3.1:8b or vllm model
"wizard_generate": 2.5, # same model family as cover_letter
}
```
At construction, the scheduler validates that every type in `LLM_TASK_TYPES` has an entry
in the merged `_budgets`. If any type is missing, a warning is logged:
```
WARNING task_scheduler: No VRAM budget defined for LLM task type 'foo' — defaulting to 0.0 GB (unlimited concurrency for this type)
```
**Scheduler loop:**
```python
while not _stop.is_set():
_wake.wait(timeout=30)
_wake.clear()
with _lock:
# Defense in depth: reap dead threads not yet cleaned by their finally block.
# In the normal path, a batch worker's finally block calls _active.pop() and
# decrements _reserved_vram BEFORE firing _wake — so by the time we scan here,
# the entry is already gone and there is no double-decrement risk.
# This reap only catches threads killed externally (daemon exit on shutdown).
for t, thread in list(_active.items()):
if not thread.is_alive():
_reserved_vram -= _budgets.get(t, 0)
del _active[t]
# Start new batches where VRAM allows
candidates = sorted(
[t for t in _queues if _queues[t] and t not in _active],
key=lambda t: len(_queues[t]),
reverse=True,
)
for task_type in candidates:
budget = _budgets.get(task_type, 0)
if _reserved_vram + budget <= _available_vram:
thread = Thread(target=_batch_worker, args=(task_type,), daemon=True)
_active[task_type] = thread
_reserved_vram += budget
thread.start()
```
**Batch worker:**
The `finally` block is the single authoritative path for releasing `_reserved_vram` and
removing the entry from `_active`. Because `_active.pop` runs in `finally` before
`_wake.set()`, the scheduler loop's dead-thread scan will never find this entry —
no double-decrement is possible in the normal execution path.
```python
def _batch_worker(task_type: str) -> None:
try:
while True:
with _lock:
if not _queues[task_type]:
break
task = _queues[task_type].popleft()
_run_task(db_path, task.id, task_type, task.job_id, task.params)
finally:
with _lock:
_active.pop(task_type, None)
_reserved_vram -= _budgets.get(task_type, 0)
_wake.set()
```
`_run_task` here refers to `task_runner._run_task`, passed in as a callable at
construction (e.g. `self._run_task = run_task_fn`). The caller (`task_runner.py`)
passes `_run_task` when constructing the scheduler, avoiding any import of `task_runner`
from within `task_scheduler`.
**`enqueue()` method:**
`enqueue()` only accepts LLM task types. Non-LLM routing is handled in `submit_task()`
before `enqueue()` is called (see Routing section above).
```python
def enqueue(self, task_id: int, task_type: str, job_id: int, params: str | None) -> None:
with self._lock:
q = self._queues.setdefault(task_type, deque())
if len(q) >= self._max_queue_depth:
logger.warning(
"Queue depth limit reached for %s (max=%d) — task %d dropped",
task_type, self._max_queue_depth, task_id,
)
update_task_status(self._db_path, task_id, "failed",
error="Queue depth limit reached")
return
q.append(TaskSpec(task_id, job_id, params))
self._wake.set()
```
When a task is dropped at the depth limit, `update_task_status()` marks it `failed` in
SQLite immediately — the row inserted by `insert_task()` is never left as a permanent
ghost in `queued` state.
**Singleton access — thread-safe initialization:**
```python
_scheduler: TaskScheduler | None = None
_scheduler_lock = threading.Lock()
def get_scheduler(db_path: Path) -> TaskScheduler:
global _scheduler
if _scheduler is None: # fast path — avoids lock on steady state
with _scheduler_lock:
if _scheduler is None: # re-check under lock (double-checked locking)
_scheduler = TaskScheduler(db_path)
_scheduler.start()
return _scheduler
def reset_scheduler() -> None:
"""Tear down and clear singleton. Test teardown only."""
global _scheduler
with _scheduler_lock:
if _scheduler:
_scheduler.shutdown()
_scheduler = None
```
The safety guarantee comes from the **inner `with _scheduler_lock:` block and re-check**,
not from GIL atomicity. The outer `if _scheduler is None` is a performance optimization
(avoid acquiring the lock on every `submit_task()` call once the scheduler is running).
Two threads racing at startup will both pass the outer check, but only one will win the
inner lock and construct the scheduler; the other will see a non-None value on its
inner re-check and return the already-constructed instance.
---
## Required Call Ordering in `app.py`
`reset_running_tasks()` **must complete before** `get_scheduler()` is ever called.
The scheduler's durability query reads `status='queued'` rows; if `reset_running_tasks()`
has not yet run, a row stuck in `status='running'` from a prior crash would be loaded
into the deque and re-executed, producing a duplicate result.
In practice, the first call to `get_scheduler()` is triggered by the `submit_task()` call
inside `_startup()`'s SearXNG auto-recovery block — not by a user action. The ordering
holds because `reset_running_tasks()` is called on an earlier line within the same
`_startup()` function body. **Do not reorder these calls.**
```python
@st.cache_resource
def _startup() -> None:
# Step 1: Reset interrupted tasks — MUST come first
from scripts.db import reset_running_tasks
reset_running_tasks(get_db_path())
# Step 2 (later in same function): SearXNG re-queue calls submit_task(),
# which triggers get_scheduler() for the first time. Ordering is guaranteed
# because _startup() runs synchronously and step 1 is already complete.
conn = sqlite3.connect(get_db_path())
# ... existing SearXNG re-queue logic using conn ...
conn.close()
```
---
## Changes to Existing Files
### `scripts/task_runner.py`
`submit_task()` gains routing logic; `_run_task` is passed to the scheduler at first call:
```python
def submit_task(db_path, task_type, job_id=None, params=None):
task_id, is_new = insert_task(db_path, task_type, job_id or 0, params=params)
if is_new:
from scripts.task_scheduler import get_scheduler, LLM_TASK_TYPES
if task_type in LLM_TASK_TYPES:
get_scheduler(db_path, run_task_fn=_run_task).enqueue(
task_id, task_type, job_id or 0, params
)
else:
t = threading.Thread(
target=_run_task,
args=(db_path, task_id, task_type, job_id or 0, params),
daemon=True,
)
t.start()
return task_id, is_new
```
`get_scheduler()` accepts `run_task_fn` only on first call (when constructing); subsequent
calls ignore it (singleton already initialized). `_run_task()` and all handler branches
remain unchanged.
### `scripts/db.py`
Add `reset_running_tasks()` alongside the existing `kill_stuck_tasks()`. Like
`kill_stuck_tasks()`, it uses a plain `sqlite3.connect()` — consistent with the
existing pattern in this file, and appropriate because this call happens before the
app's connection pooling is established:
```python
def reset_running_tasks(db_path: Path = DEFAULT_DB) -> int:
"""On restart: mark in-flight tasks failed. Queued tasks survive for the scheduler."""
conn = sqlite3.connect(db_path)
count = conn.execute(
"UPDATE background_tasks SET status='failed', error='Interrupted by restart',"
" finished_at=datetime('now') WHERE status='running'"
).rowcount
conn.commit()
conn.close()
return count
```
### `app/app.py`
Inside `_startup()`, replace the inline SQL block that wipes both `queued` and `running`
rows with a call to `reset_running_tasks()`. The replacement must be the **first operation
in `_startup()`** — before the SearXNG re-queue logic that calls `submit_task()`:
```python
# REMOVE this block:
conn.execute(
"UPDATE background_tasks SET status='failed', error='Interrupted by server restart',"
" finished_at=datetime('now') WHERE status IN ('queued','running')"
)
# ADD at the top of _startup(), before any submit_task() calls:
from scripts.db import reset_running_tasks
reset_running_tasks(get_db_path())
```
The existing `conn` used for subsequent SearXNG logic is unaffected — `reset_running_tasks()`
opens and closes its own connection.
### `config/llm.yaml.example`
Add `scheduler:` section:
```yaml
scheduler:
vram_budgets:
cover_letter: 2.5 # alex-cover-writer:latest (~2GB GGUF + headroom)
company_research: 5.0 # llama3.1:8b or vllm model
wizard_generate: 2.5 # same model family as cover_letter
max_queue_depth: 500
```
---
## Data Model
No schema changes. The existing `background_tasks` table supports all scheduler needs:
| Column | Scheduler use |
|---|---|
| `task_type` | Queue routing — determines which deque receives the task |
| `status` | `queued` → in deque; `running` → batch worker executing; `completed`/`failed` → done |
| `created_at` | FIFO ordering within type (durability startup query sorts by this) |
| `params` | Passed through to `_run_task()` unchanged |
---
## Durability
Scope: **LLM task types only** (`cover_letter`, `company_research`, `wizard_generate`).
Non-LLM tasks do not survive restarts, same as current behavior.
On construction, `TaskScheduler.__init__()` queries:
```sql
SELECT id, task_type, job_id, params
FROM background_tasks
WHERE status = 'queued'
AND task_type IN ('cover_letter', 'company_research', 'wizard_generate')
ORDER BY created_at ASC
```
Results are pushed onto their respective deques. This query runs inside `__init__` before
`start()` is called (before the scheduler loop thread exists), so there is no concurrency
concern with deque population.
`running` rows are reset to `failed` by `reset_running_tasks()` before `get_scheduler()`
is called — see Required Call Ordering above.
---
## Known Limitations
**Static `_available_vram`:** Total GPU VRAM is read from `get_gpus()` once at scheduler
construction and never refreshed. Changes after startup — another process releasing VRAM,
a GPU going offline, Ollama unloading a model — are not reflected. The scheduler's
correctness depends on per-task VRAM budgets being conservative estimates of **peak model
footprint** (not free VRAM at a given moment). On a system where Ollama and vLLM share
the GPU, budgets should account for both models potentially resident simultaneously.
Dynamic VRAM polling is a future enhancement.
---
## Memory Safety
- **`finally` block owns VRAM release** — batch worker always decrements `_reserved_vram`
and removes its `_active` entry before firing `_wake`, even on exception. The scheduler
loop's dead-thread scan is defense in depth for externally-killed daemons only; it cannot
double-decrement because `_active.pop` in `finally` runs first.
- **Max queue depth with DB cleanup**`enqueue()` rejects tasks past `max_queue_depth`,
logs a warning, and immediately marks the dropped task `failed` in SQLite to prevent
permanent ghost rows in `queued` state.
- **No in-memory history** — deques hold only pending `TaskSpec` namedtuples. Completed
and failed state lives exclusively in SQLite. Memory footprint is `O(pending tasks)`.
- **Thread-safe singleton** — double-checked locking with `_scheduler_lock` prevents
double-construction. Safety comes from the inner lock + re-check; the outer `None`
check is a performance optimization only.
- **Missing budget warning** — any `LLM_TASK_TYPES` entry with no budget entry after
config merge logs a warning at construction; defaults to 0.0 GB (unlimited concurrency
for that type). This prevents silent incorrect scheduling for future task types.
- **`reset_scheduler()`** — explicit teardown for test isolation: sets `_stop`, joins
scheduler thread with timeout, clears module-level reference under `_scheduler_lock`.
---
## Testing (`tests/test_task_scheduler.py`)
All tests mock `_run_task` to avoid real LLM calls. `reset_scheduler()` is called in
an `autouse` fixture for isolation between test cases.
| Test | What it verifies |
|---|---|
| `test_deepest_queue_wins_first_slot` | N cover_letter + M research enqueued (N > M); cover_letter batch starts first when `_available_vram` only fits one model budget, because it has the deeper queue |
| `test_fifo_within_type` | Arrival order preserved within a type batch |
| `test_concurrent_batches_when_vram_allows` | Two type batches start simultaneously when `_available_vram` fits both budgets combined |
| `test_new_tasks_picked_up_mid_batch` | Task enqueued via `enqueue()` while a batch is active is consumed by the running worker in the same batch |
| `test_worker_crash_releases_vram` | `_run_task` raises; `_reserved_vram` returns to 0; scheduler continues; no double-decrement |
| `test_non_llm_tasks_bypass_scheduler` | `discovery`, `email_sync` etc. spawn free threads via `submit_task()`; scheduler deques untouched |
| `test_durability_llm_tasks_on_startup` | DB has existing `queued` LLM-type rows; scheduler loads them into deques on construction |
| `test_durability_excludes_non_llm` | `queued` non-LLM rows in DB are not loaded into deques on startup |
| `test_running_rows_reset_before_scheduler` | `reset_running_tasks()` sets `running``failed`; `queued` rows untouched |
| `test_max_queue_depth_marks_failed` | Enqueue past limit logs warning, does not add to deque, and marks task `failed` in DB |
| `test_missing_budget_logs_warning` | Type in `LLM_TASK_TYPES` with no budget entry at construction logs a warning |
| `test_singleton_thread_safe` | Concurrent calls to `get_scheduler()` produce exactly one scheduler instance |
| `test_reset_scheduler_cleans_up` | `reset_scheduler()` stops loop thread; no lingering threads after call |
---
## Files Touched
| File | Change |
|---|---|
| `scripts/task_scheduler.py` | **New** — ~180 lines |
| `scripts/task_runner.py` | `submit_task()` routing shim — ~12 lines changed |
| `scripts/db.py` | `reset_running_tasks()` added — ~10 lines |
| `app/app.py` | `_startup()`: inline SQL block → `reset_running_tasks()` call, placed first |
| `config/llm.yaml.example` | Add `scheduler:` section |
| `tests/test_task_scheduler.py` | **New** — ~240 lines |

View file

@ -1,173 +0,0 @@
# Jobgether Integration Design
**Date:** 2026-03-15
**Status:** Approved
**Scope:** Peregrine — discovery pipeline + manual URL import
---
## Problem
Jobgether is a job aggregator that posts listings on LinkedIn and other boards with `company = "Jobgether"` rather than the actual employer. This causes two problems:
1. **Misleading listings** — Jobs appear to be at "Jobgether" rather than the real hiring company. Meg sees "Jobgether" as employer throughout the pipeline (Job Review, cover letters, company research).
2. **Broken manual import** — Direct `jobgether.com` URLs return HTTP 403 when scraped with plain `requests`, leaving jobs stuck as `title = "Importing…"`.
**Evidence from DB:** 29+ Jobgether-sourced LinkedIn listings with `company = "Jobgether"`. Actual employer is intentionally withheld by Jobgether's business model ("on behalf of a partner company").
---
## Decision: Option A — Filter + Dedicated Scraper
Drop Jobgether listings from other scrapers entirely and replace with a direct Jobgether scraper that retrieves accurate company names. Existing Jobgether-via-LinkedIn listings in the DB are left as-is for manual review/rejection.
**Why not Option B (follow-through):** LinkedIn→Jobgether→employer is a two-hop chain where the employer is deliberately hidden. Jobgether blocks `requests`. Not worth the complexity for unreliable data.
---
## Components
### 1. Jobgether company filter — `config/blocklist.yaml`
Add `"jobgether"` to the `companies` list in `config/blocklist.yaml`. The existing `_is_blocklisted()` function in `discover.py` already performs a partial case-insensitive match on the company field and applies to all scrapers (JobSpy boards + all custom boards). No code change required.
```yaml
companies:
- jobgether
```
This is the correct mechanism — it is user-visible, config-driven, and applies uniformly. Log output already reports blocklisted jobs per run.
### 2. URL handling in `scrape_url.py`
Three changes required:
**a) `_detect_board()`** — add `"jobgether"` branch returning `"jobgether"` when `"jobgether.com"` is in the URL. Must be added before the `return "generic"` fallback.
**b) dispatch block in `scrape_job_url()`** — add `elif board == "jobgether": fields = _scrape_jobgether(url)` to the `if/elif` chain (lines 208215). Without this, the new `_detect_board()` branch silently falls through to `_scrape_generic()`.
**c) `_scrape_jobgether(url)`** — Playwright-based scraper to bypass 403. Extracts:
- `title` — job title from page heading
- `company` — actual employer name (visible on Jobgether offer pages)
- `location` — remote/location info
- `description` — full job description
- `source = "jobgether"`
Playwright errors (`playwright.sync_api.Error`, `TimeoutError`) are not subclasses of `requests.RequestException` but are caught by the existing broad `except Exception` handler in `scrape_job_url()` — no changes needed to the error handling block.
**URL slug fallback for company name (manual import path only):** Jobgether offer URLs follow the pattern:
```
https://jobgether.com/offer/{24-hex-hash}-{title-slug}---{company-slug}
```
When Playwright is unavailable, parse `company-slug` using:
```python
m = re.search(r'---([^/?]+)$', parsed_path)
company = m.group(1).replace("-", " ").title() if m else ""
```
Example: `/offer/69b42d9d24d79271ee0618e8-customer-success-manager---resware``"Resware"`.
This fallback is scoped to `_scrape_jobgether()` in `scrape_url.py` only; the discovery scraper always gets company name from the rendered DOM. `_scrape_jobgether()` does not make any `requests` calls — there is no `raise_for_status()` — so the `requests.RequestException` handler in `scrape_job_url()` is irrelevant to this path; only the broad `except Exception` applies.
**Pre-implementation checkpoint:** Confirm that Jobgether offer URLs have no tracking query params beyond UTM (already covered by `_STRIP_PARAMS`). No `canonicalize_url()` changes are expected but verify before implementation.
### 3. `scripts/custom_boards/jobgether.py`
Playwright-based search scraper following the same interface as `theladders.py`:
```python
def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
```
- Base URL: `https://jobgether.com/remote-jobs`
- Search strategy: iterate over `profile["titles"]`, apply search/filter params
- **Pre-condition — do not begin implementation of this file until live URL inspection is complete.** Use browser dev tools or a Playwright `page.on("request")` capture to determine the actual query parameter format for title/location filtering. Jobgether may use URL query params, path segments, or JS-driven state — this cannot be assumed from the URL alone.
- Extraction: job cards from rendered DOM (Playwright `page.evaluate()`)
- Returns standard job dicts: `title, company, url, source, location, is_remote, salary, description`
- `source = "jobgether"`
- Graceful `ImportError` handling if Playwright not installed (same pattern as `theladders.py`)
- Polite pacing: 1s sleep between title iterations
- Company name comes from DOM; URL slug parse is not needed in this path
### 4. Registration + config
**`discover.py` — import block (lines 2022):**
```python
from scripts.custom_boards import jobgether as _jobgether
```
**`discover.py` — `CUSTOM_SCRAPERS` dict literal (lines 3034):**
```python
CUSTOM_SCRAPERS: dict[str, object] = {
"adzuna": _adzuna.scrape,
"theladders": _theladders.scrape,
"craigslist": _craigslist.scrape,
"jobgether": _jobgether.scrape, # ← add this line
}
```
**`config/search_profiles.yaml` (and `.example`):**
Add `jobgether` to `custom_boards` for any profile that includes `Remote` in its `locations` list. Jobgether is a remote-work-focused aggregator; adding it to location-specific non-remote profiles is not useful. Do not add a `custom_boards` key to profiles that don't already have one unless they are remote-eligible.
```yaml
custom_boards:
- jobgether
```
---
## Data Flow
```
discover.py
├── JobSpy boards → _is_blocklisted(company="jobgether") → drop → DB insert
├── custom: adzuna → _is_blocklisted(company="jobgether") → drop → DB insert
├── custom: theladders → _is_blocklisted(company="jobgether") → drop → DB insert
├── custom: craigslist → _is_blocklisted(company="jobgether") → drop → DB insert
└── custom: jobgether → (company = real employer, never "jobgether") → DB insert
scrape_url.py
└── jobgether.com URL → _detect_board() = "jobgether"
→ _scrape_jobgether()
├── Playwright available → full job fields from page
└── Playwright unavailable → company from URL slug only
```
---
## Implementation Notes
- **Slug fallback None-guard:** The regex `r'---([^/?]+)$'` returns a wrong value (not `None`) if the URL slug doesn't follow the expected format. Add a logged warning and return `""` rather than title-casing garbage.
- **Import guard in `discover.py`:** Wrap the `jobgether` import with `try/except ImportError`, setting `_jobgether = None`, and gate the `CUSTOM_SCRAPERS` registration with `if _jobgether is not None`. This ensures the graceful ImportError in `jobgether.py` (for missing Playwright) propagates cleanly to the caller rather than crashing discovery.
### 5. Cover letter recruiter framing — `scripts/generate_cover_letter.py`
When `source = "jobgether"`, inject a system hint that shifts the cover letter addressee from the employer to the Jobgether recruiter. Use Policy A: recruiter framing applies for all Jobgether-sourced jobs regardless of whether the real company name was resolved.
- If company is known (e.g. "Resware"): *"Your client at Resware will appreciate..."*
- If company is unknown: *"Your client will appreciate..."*
The real company name is always stored in the DB as resolved by the scraper — this is internal knowledge only. The framing shift is purely in the generated letter text, not in how the job is stored or displayed.
Implementation: add an `is_jobgether` flag to the cover letter prompt context (same pattern as `mission_hint` injection). Add a conditional block in the system prompt / Para 1 instructions when the flag is true.
---
## Out of Scope
- Retroactively fixing existing `company = "Jobgether"` rows in the DB (left for manual review/rejection)
- Jobgether discovery scraper — **decided against during implementation (2026-03-15)**: Cloudflare Turnstile blocks all headless browsers on all Jobgether pages; `filter-api.jobgether.com` requires auth; `robots.txt` blocks all bots. The email digest → manual URL paste → slug company extraction flow covers the actual use case.
- Jobgether authentication / logged-in scraping
- Pagination
- Dedup between Jobgether and other boards (existing URL dedup handles this)
---
## Files Changed
| File | Change |
|------|--------|
| `config/blocklist.yaml` | Add `"jobgether"` to `companies` list |
| `scripts/discover.py` | Add import + entry in `CUSTOM_SCRAPERS` dict literal |
| `scripts/scrape_url.py` | Add `_detect_board` branch, dispatch branch, `_scrape_jobgether()` |
| `scripts/custom_boards/jobgether.py` | New file — Playwright search scraper |
| `config/search_profiles.yaml` | Add `jobgether` to `custom_boards` |
| `config/search_profiles.yaml.example` | Same |

View file

@ -1,214 +0,0 @@
# Apply View — Desktop Split-Pane Design
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
Refactor the Apply view for desktop: replace the centered 760px list → full-page-navigation pattern with a persistent master-detail split pane. The left column holds a compact job list; clicking a job expands the cover letter workspace inline to the right. Mobile layout is unchanged.
---
## Decisions Made
| Decision | Choice | Future option |
|---|---|---|
| Split ratio | 28% list / 72% workspace (fixed) | Resizable drag handle |
| Panel open animation | Expand from list divider edge (~200ms) | — |
| URL routing | Local state only — URL stays at `/apply` | URL-synced selection for deep linking |
| List row density | Option A: title + company + score badge, truncated | C (company+score only), D (wrapped/taller) via future layout selector |
---
## Layout
### Desktop (≥ 1024px)
The split pane activates at 1024px — the same breakpoint where the app nav sidebar collapses (`App.vue` `max-width: 1023px`). This ensures the two-column layout never renders without its sidebar, avoiding an uncomfortably narrow list column.
```
┌──────────────────────────────────────────────────────────────────┐
│ [NAV 220px] │ List (28%) │ Workspace (72%) │
│ │ ─────────────────── │ ──────────────────────── │
│ │ 25 jobs │ Sr. Software Engineer │
│ │ ▶ Sr. SWE Acme 87% │ Acme Corp │
│ │ FS Dev Globex 72% │ │
│ │ Backend Init 58% │ [Cover letter editor] │
│ │ ... │ [Actions: Generate / PDF] │
└──────────────────────────────────────────────────────────────────┘
```
- No job selected → right panel shows a warm empty state: `← Select a job to open the workspace` (desktop-only — the empty state element is conditionally rendered only inside the split layout, not the mobile list)
- The `max-width: 760px` constraint on `.apply-list` is removed for desktop
### Mobile (< 1024px)
No changes. Existing full-width list + `RouterLink` navigation to `/apply/:id`. All existing mobile breakpoint styles are preserved.
---
## Component Architecture
### Current
```
ApplyView.vue → list only, RouterLink to /apply/:id
ApplyWorkspaceView.vue → full page, reads :id from route params
```
### New
```
ApplyView.vue → split pane (desktop) OR list (mobile)
├─ [left] Narrow job list (inline in ApplyView — not a separate component)
└─ [right] ApplyWorkspace.vue (new component, :job-id prop)
ApplyWorkspaceView.vue → thin wrapper: <ApplyWorkspace :job-id="Number(route.params.id)" />
ApplyWorkspace.vue → extracted workspace content; accepts jobId: number prop
```
**Why extract `ApplyWorkspace.vue`?** The workspace now renders in two contexts: the split pane (inline, `jobId` from local state) and the existing `/apply/:id` route (for mobile + future deep links). Extracting it as a prop-driven component avoids duplication.
**`jobId` prop type:** `number`. The wrapper in `ApplyWorkspaceView.vue` does `Number(route.params.id)` before passing it. `ApplyWorkspace.vue` receives a `number` and never touches `route.params` directly.
**`declare module` augmentation:** The `declare module '@/stores/review'` block in the current `ApplyWorkspaceView.vue` (if present) moves into `ApplyWorkspace.vue`, not the thin wrapper.
---
## Narrow Job List (left panel)
**Row layout — Option A:**
```
┌─────────────────────────────────────┐
│ Sr. Software Engineer [87%]│ ← title truncated, score right-aligned
│ Acme Corp ✓ │ ← company truncated; ✓ if has_cover_letter
└─────────────────────────────────────┘
```
- The existing `cl-badge` (`✓ Draft` / `○ No draft`) badge row is **removed** from the narrow list. Cover letter status is indicated by a subtle `✓` suffix on the company line only when `has_cover_letter === true`. No badge for "no draft" — the absence of `✓` is sufficient signal at this density.
- **Score badge color thresholds (unified — replaces old 3-tier system in the apply flow):**
- Green `score-badge--high`: ≥ 70%
- Blue `score-badge--mid-high`: 5069%
- Amber `score-badge--mid`: 3049%
- Red `score-badge--low`: < 30%
- This 4-tier scheme applies in both the narrow list and the workspace header, replacing the previous `≥80 / ≥60 / else` thresholds. The `.score-badge--mid-high` class is new and needs adding to the shared badge CSS.
- Selected row: `border-left: 3px solid var(--app-primary)` accent + tinted background. Use `var(--app-primary-light)` as the primary fallback; `color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised))` as the enhancement for browsers that support it (Chrome 111+, Firefox 113+, Safari 16.2+).
- Hover: same border-left treatment at 40% opacity
- `salary`, `location`, `is_remote` badge: shown in the workspace header only — not in the narrow list
- List scrolls independently within its column
---
## Panel Open Animation
CSS Grid column transition on the `.apply-split` root element:
```css
.apply-split {
display: grid;
grid-template-columns: 28% 0fr;
transition: grid-template-columns 200ms ease-out;
}
.apply-split.has-selection {
grid-template-columns: 28% 1fr;
}
/* Required: prevent intrinsic min-content from blocking collapse */
.apply-split__panel {
min-width: 0;
overflow: clip; /* clip (not hidden) — hidden creates a new stacking context
and blocks position:sticky children inside the workspace */
}
```
`min-width: 0` on `.apply-split__panel` is required — without it, the panel's intrinsic content width prevents the `0fr` column from collapsing to zero.
Panel content fades in on top of the expand: `opacity: 0 → 1` with a 100ms delay and 150ms duration, so content doesn't flash half-rendered mid-expand.
**Panel height:** The right panel uses `height: calc(100vh - var(--app-header-height, 4rem))` with `overflow-y: auto` so the workspace scrolls independently within the column. Use a CSS variable rather than a bare literal so height stays correct if the nav height changes.
**`prefers-reduced-motion`:** Skip the grid transition and opacity fade; panel appears and content shows instantly.
---
## Post-Action Behavior (Mark Applied / Reject)
In the current `ApplyWorkspaceView.vue`, both `markApplied()` and `rejectListing()` call `router.push('/apply')` after success — fine for a full-page route.
In the embedded split-pane context, `router.push('/apply')` is a no-op (already there), but `selectedJobId` must also be cleared and the job list refreshed. `ApplyWorkspace.vue` emits a `job-removed` event when either action completes. `ApplyView.vue` handles it:
```
@job-removed="onJobRemoved()" → selectedJobId = null + re-fetch job list
```
The thin `ApplyWorkspaceView.vue` wrapper can handle `@job-removed` by calling `router.push('/apply')` as before (same behavior, different mechanism).
---
## Empty State (no job selected)
Shown in the right panel when `selectedJobId === null` on desktop only:
```
🦅
Select a job to open
the workspace
```
Centered vertically, subdued text color. Disappears when a job is selected.
---
## Easter Eggs
### 1. Speed Demon 🦅
- **Trigger:** User clicks 5+ different jobs in under 3 seconds
- **Effect:** A `<canvas>` element, absolutely positioned inside the split-pane container (`.apply-split` has `position: relative`), renders a 🦅 streaking left → right across the panel area (600ms). Followed by a "you're on the hunt" toast (2s, bottom-right).
- **`prefers-reduced-motion`:** Toast only, no canvas
### 2. Perfect Match ✨
- **Trigger:** A job with `match_score ≥ 70` is opened in the workspace
- **Effect:** The score badge in the workspace header plays a golden shimmer (`box-shadow` + `background` keyframe, 800ms, once per open)
- **Threshold constant:** `const PERFECT_MATCH_THRESHOLD = 70` at top of `ApplyWorkspace.vue` — intentionally matches the `score-badge--high` boundary (≥ 70%). If badge thresholds are tuned later, update this constant in sync.
- **Note:** Current scoring rarely exceeds 40% — this easter egg may be dormant until the scoring algorithm is tuned. The constant makes it easy to adjust.
### 3. Cover Letter Marathon 📬
- **Trigger:** 5th cover letter generated in a single session
- **Counter:** Component-level `ref<number>` in `ApplyView.vue` (not Pinia) — resets on page refresh, persists across job selections within the session
- **Effect:** A `📬 N today` streak badge appears in the list panel header with a warm amber glow. Increments with each subsequent generation.
- **Tooltip:** "You're on a roll!" on hover
### 4. Konami Code 🎮
- **Trigger:** ↑↑↓↓←→←→BA anywhere on the Apply view
- **Implementation:** Use the **existing** `useKonamiCode(callback)` + `useHackerMode()` from `web/src/composables/useEasterEgg.ts`. Do **not** create a new `useKonami.ts` composable — one already exists. Do **not** add a new global `keydown` listener (one is already registered in `App.vue`); wire up via the composable's callback pattern instead.
---
## What Stays the Same
- `/apply/:id` route — still exists, still works (used by mobile nav)
- All existing mobile breakpoint styles in `ApplyView.vue`
- The `useApiFetch` data fetching pattern
- The `remote-badge` and `salary` display — moved to workspace header, same markup
---
## Future Options (do not implement now)
- **Resizable split:** drag handle between panels, persisted in `localStorage` as `apply.splitRatio`
- **URL-synced selection:** update route to `/apply/:id` on selection; back button closes panel
- **Layout selector:** density toggle in list header offering Option C (company+score only) and Option D (wrapped/taller cards), persisted in `localStorage` as `apply.listDensity`
---
## Files
| File | Action |
|---|---|
| `web/src/views/ApplyView.vue` | Replace: split-pane layout (desktop), narrow list, easter eggs 1 + 3 + 4 |
| `web/src/components/ApplyWorkspace.vue` | Create: workspace content extracted from `ApplyWorkspaceView.vue`; `jobId: number` prop; emits `job-removed` |
| `web/src/views/ApplyWorkspaceView.vue` | Modify: thin wrapper → `<ApplyWorkspace :job-id="Number(route.params.id)" @job-removed="router.push('/apply')" />` |
| `web/src/assets/theme.css` or `peregrine.css` | Add `.score-badge--mid-high` (blue, 5069%) to badge CSS |

View file

@ -1,419 +0,0 @@
# Digest Scrape Queue — Design Spec
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
When a user clicks the 📰 Digest chip on a signal banner, the email is added to a persistent digest queue accessible via a dedicated nav tab. The user browses queued digest emails, selects extracted job links to process, and queues them through the existing discovery pipeline as `status='pending'` jobs in `staging.db`.
---
## Decisions Made
| Decision | Choice |
|---|---|
| Digest tab placement | Separate top-level nav tab "📰 Digest", between Interviews and Apply |
| Storage | New `digest_queue` table in `staging.db`; unique on `job_contact_id` |
| Table creation | In `scripts/db.py` `init_db()` — canonical schema location, not `dev-api.py` |
| Link extraction | On-demand, backend regex against HTML-stripped plain-text body — no background task needed |
| Extraction UX | Show ranked link list; job-likely pre-checked, others unchecked; user ticks and submits |
| After queueing | Entry stays in digest list for reference; `[✕]` removes explicitly |
| Failure handling | Digest chip dismisses signal optimistically regardless of `POST /api/digest-queue` success |
| Duplicate protection | `UNIQUE(job_contact_id)` in table; `POST /api/digest-queue` returns `{ created: false }` on duplicate (no 409) |
| Mobile nav | Digest tab does NOT appear in mobile bottom tab bar (all 5 slots occupied; deferred) |
| URL validation | Non-http/https schemes and blank URLs skipped silently in `queue-jobs`; validation deferred to pipeline |
---
## Data Model
### New table: `digest_queue`
Added to `scripts/db.py` `init_db()`:
```sql
CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY,
job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id),
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(job_contact_id)
)
```
`init_db()` is called at app startup and by `dev-api.py` startup — adding the `CREATE TABLE IF NOT EXISTS` there is safe and idempotent.
---
## Backend
### New endpoints in `dev-api.py`
#### `GET /api/digest-queue`
Returns all queued entries joined with `job_contacts`. `body` is HTML-stripped via `_strip_html()` before returning (display only — extraction uses a separate raw read, see `extract-links`):
```python
SELECT dq.id, dq.job_contact_id, dq.created_at,
jc.subject, jc.from_addr, jc.received_at, jc.body
FROM digest_queue dq
JOIN job_contacts jc ON jc.id = dq.job_contact_id
ORDER BY dq.created_at DESC
```
Response: array of `{ id, job_contact_id, created_at, subject, from_addr, received_at, body }`.
---
#### `POST /api/digest-queue`
Body: `{ job_contact_id: int }`
- Verify `job_contact_id` exists in `job_contacts` → 404 if not found
- `INSERT OR IGNORE INTO digest_queue (job_contact_id) VALUES (?)`
- Returns `{ ok: true, created: true }` on insert, `{ ok: true, created: false }` if already present
- Never returns 409 — the `created` field is the duplicate signal
---
#### `POST /api/digest-queue/{id}/extract-links`
Extracts and ranks URLs from the entry's email body. No request body.
**Important:** this endpoint reads the **raw** `body` from `job_contacts` directly and runs `URL_RE` against it **before** any HTML stripping. `_strip_html()` calls `BeautifulSoup.get_text()`, which extracts visible text only — it does not preserve `href` attribute values. A URL that appears only as an `href` target (e.g., `<a href="https://greenhouse.io/acme/1">Click here</a>`) would be lost after stripping. Running the regex on raw HTML captures those URLs correctly because `URL_RE`'s character exclusion class (`[^\s<>"')\]]`) stops at `"`, so it cleanly extracts href values without matching surrounding markup.
```python
# Fetch raw body from DB — do NOT strip before extraction
row = db.execute(
"SELECT jc.body FROM digest_queue dq JOIN job_contacts jc ON jc.id = dq.job_contact_id WHERE dq.id = ?",
(digest_id,)
).fetchone()
if not row:
raise HTTPException(404, "Digest entry not found")
return {"links": extract_links(row["body"] or "")}
```
**Extraction algorithm:**
```python
import re
from urllib.parse import urlparse
JOB_DOMAINS = {
'greenhouse.io', 'lever.co', 'workday.com', 'linkedin.com',
'ashbyhq.com', 'smartrecruiters.com', 'icims.com', 'taleo.net',
'jobvite.com', 'breezy.hr', 'recruitee.com', 'bamboohr.com',
'myworkdayjobs.com', 'careers.', 'jobs.',
}
FILTER_PATTERNS = re.compile(
r'(unsubscribe|mailto:|/track/|pixel\.|\.gif|\.png|\.jpg'
r'|/open\?|/click\?|list-unsubscribe)',
re.I
)
URL_RE = re.compile(r'https?://[^\s<>"\')\]]+', re.I)
def _score_url(url: str) -> int:
parsed = urlparse(url)
hostname = parsed.hostname or ''
path = parsed.path.lower()
if FILTER_PATTERNS.search(url):
return -1 # exclude
for domain in JOB_DOMAINS:
if domain in hostname or domain in path:
return 2 # job-likely
return 1 # other
def extract_links(body: str) -> list[dict]:
if not body:
return []
seen = set()
results = []
for m in URL_RE.finditer(body):
url = m.group(0).rstrip('.,;)')
if url in seen:
continue
seen.add(url)
score = _score_url(url)
if score < 0:
continue
# Title hint: last line of text immediately before the URL (up to 60 chars)
start = max(0, m.start() - 60)
hint = body[start:m.start()].strip().split('\n')[-1].strip()
results.append({'url': url, 'score': score, 'hint': hint})
results.sort(key=lambda x: -x['score'])
return results
```
Response: `{ links: [{ url, score, hint }] }``score=2` means job-likely (pre-check in UI), `score=1` means other (unchecked).
---
#### `POST /api/digest-queue/{id}/queue-jobs`
Body: `{ urls: [string] }`
- 404 if digest entry not found
- 400 if `urls` is empty
- Non-http/https URLs and blank strings are skipped silently (counted as `skipped`)
Calls `insert_job` from `scripts/db.py`. The actual signature is `insert_job(db_path, job)` where `job` is a dict. The `status` field is **not** passed — the schema default of `'pending'` handles it:
```python
from scripts.db import insert_job
from datetime import datetime
queued = 0
skipped = 0
for url in body.urls:
if not url or not url.startswith(('http://', 'https://')):
skipped += 1
continue
result = insert_job(DB_PATH, {
'url': url,
'title': '',
'company': '',
'source': 'digest',
'date_found': datetime.utcnow().isoformat(),
})
if result:
queued += 1
else:
skipped += 1 # duplicate URL — insert_job returns None on UNIQUE conflict
return {'ok': True, 'queued': queued, 'skipped': skipped}
```
---
#### `DELETE /api/digest-queue/{id}`
Removes entry from `digest_queue`. Does not affect `job_contacts`.
Returns `{ ok: true }`. 404 if not found.
---
## Frontend Changes
### Chip handler update (`InterviewCard.vue` + `InterviewsView.vue`)
When `newLabel === 'digest'`, the handler fires a **third call** after the existing reclassify + dismiss calls. Note: `sig.id` is `job_contacts.id` — this is the correct value for `job_contact_id` (the `StageSignal.id` field maps directly to the `job_contacts` primary key):
```typescript
// After existing reclassify + dismiss calls:
if (newLabel === 'digest') {
fetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }), // sig.id === job_contacts.id
}).catch(() => {}) // best-effort; signal already dismissed optimistically
}
```
Signal is removed from local array optimistically before this call (same as current dismiss behavior).
---
### New store: `web/src/stores/digest.ts`
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface DigestEntry {
id: number
job_contact_id: number
created_at: string
subject: string
from_addr: string | null
received_at: string
body: string | null
}
export interface DigestLink {
url: string
score: number // 2 = job-likely, 1 = other
hint: string
}
export const useDigestStore = defineStore('digest', () => {
const entries = ref<DigestEntry[]>([])
async function fetchAll() {
const res = await fetch('/api/digest-queue')
entries.value = await res.json()
}
async function remove(id: number) {
entries.value = entries.value.filter(e => e.id !== id)
await fetch(`/api/digest-queue/${id}`, { method: 'DELETE' })
}
return { entries, fetchAll, remove }
})
```
---
### New page: `web/src/views/DigestView.vue`
**Layout — collapsed entry (default):**
```
┌─────────────────────────────────────────────┐
│ ▸ TechCrunch Jobs Weekly │
│ From: digest@techcrunch.com · Mar 19 │
│ [Extract] [✕] │
└─────────────────────────────────────────────┘
```
**Layout — expanded entry (after Extract):**
```
┌─────────────────────────────────────────────┐
│ ▾ LinkedIn Job Digest │
│ From: jobs@linkedin.com · Mar 18 │
│ [Re-extract] [✕] │
│ ┌──────────────────────────────────────┐ │
│ │ ☑ Senior Engineer — Acme Corp │ │ ← score=2, pre-checked
│ │ greenhouse.io/acme/jobs/456 │ │
│ │ ☑ Staff Designer — Globex │ │
│ │ lever.co/globex/staff-designer │ │
│ │ ─── Other links ────────────────── │ │
│ │ ☐ acme.com/blog/engineering │ │ ← score=1, unchecked
│ │ ☐ linkedin.com/company/acme │ │
│ └──────────────────────────────────────┘ │
│ [Queue 2 selected →] │
└─────────────────────────────────────────────┘
```
**After queueing:**
Inline confirmation replaces the link list:
```
✅ 2 jobs queued for review, 1 skipped (already in pipeline)
```
Entry remains in the list. `[✕]` removes it.
**Empty state:**
```
🦅 No digest emails queued.
When you mark an email as 📰 Digest, it appears here.
```
**Component state (per entry, keyed by `DigestEntry.id`):**
```typescript
const expandedIds = ref<Record<number, boolean>>({})
const linkResults = ref<Record<number, DigestLink[]>>({})
const selectedUrls = ref<Record<number, Set<string>>>({})
const queueResult = ref<Record<number, { queued: number; skipped: number } | null>>({})
const extracting = ref<Record<number, boolean>>({})
const queuing = ref<Record<number, boolean>>({})
```
`selectedUrls` uses `Set<string>`. Toggling a URL uses the spread-copy pattern to trigger Vue 3 reactivity — same pattern as `expandedSignalIds` in `InterviewCard.vue`:
```typescript
function toggleUrl(entryId: number, url: string) {
const prev = selectedUrls.value[entryId] ?? new Set()
const next = new Set(prev)
next.has(url) ? next.delete(url) : next.add(url)
selectedUrls.value = { ...selectedUrls.value, [entryId]: next }
}
```
---
### Router + Nav
Add to `web/src/router/index.ts`:
```typescript
{ path: '/digest', component: () => import('../views/DigestView.vue') }
```
**AppNav.vue changes:**
Add `NewspaperIcon` to the Heroicons import (already imported from `@heroicons/vue/24/outline`), then append to `navLinks` after `Interviews`:
```typescript
import { NewspaperIcon } from '@heroicons/vue/24/outline'
const navLinks = [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest' }, // NEW
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
]
```
`navLinks` remains a static array. The badge count is rendered as a separate reactive expression in the template alongside the Digest link — keep `navLinks` as-is and add the digest store separately:
```typescript
// In AppNav.vue <script setup>
import { useDigestStore } from '@/stores/digest'
const digestStore = useDigestStore()
```
In the template, inside the `v-for="link in navLinks"` loop, add a badge overlay for the Digest entry:
```html
<span v-if="link.to === '/digest' && digestStore.entries.length > 0" class="nav-badge">
{{ digestStore.entries.length }}
</span>
```
The Digest nav item does **not** appear in the mobile bottom tab bar (`mobileLinks` array) — all 5 slots are occupied. Deferred to a future pass.
`DigestView.vue` calls `digestStore.fetchAll()` on `onMounted`.
---
## Required Tests (`tests/test_dev_api_digest.py`)
All tests follow the same isolated DB pattern as `test_dev_api_interviews.py`: use `importlib.reload` + FastAPI `TestClient`, seed fixtures directly into the test DB.
| Test | Setup + assertion |
|---|---|
| `test_digest_queue_add` | Seed a `job_contacts` row; POST `{ job_contact_id }` → 200, `created: true`, row in DB |
| `test_digest_queue_add_duplicate` | Seed + POST twice → second returns `created: false`, no error, only one row in DB |
| `test_digest_queue_add_missing_contact` | POST nonexistent `job_contact_id` → 404 |
| `test_digest_queue_list` | Seed `job_contacts` + `digest_queue`; GET → entries include `subject`, `from_addr`, `body` |
| `test_digest_extract_links` | Seed `job_contacts` with body containing a `greenhouse.io` URL and a tracker URL; seed `digest_queue`; POST to `/extract-links` → greenhouse URL present with `score=2`, tracker URL absent |
| `test_digest_extract_links_filters_trackers` | Same setup; assert unsubscribe and pixel URLs excluded from results |
| `test_digest_queue_jobs` | Seed `digest_queue`; POST `{ urls: ["https://greenhouse.io/acme/1"] }``queued: 1, skipped: 0`; row exists in `jobs` with `source='digest'` and `status='pending'` |
| `test_digest_queue_jobs_skips_duplicates` | POST `{ urls: ["https://greenhouse.io/acme/1", "https://greenhouse.io/acme/1"] }` — same URL twice in a single call → `queued: 1, skipped: 1`; one row in DB |
| `test_digest_queue_jobs_skips_invalid_urls` | POST `{ urls: ["", "ftp://bad", "https://good.com/job"] }``queued: 1, skipped: 2` |
| `test_digest_queue_jobs_empty_urls` | POST `{ urls: [] }` → 400 |
| `test_digest_delete` | Seed + DELETE → 200; second DELETE → 404 |
---
## Files
| File | Action |
|---|---|
| `scripts/db.py` | Add `digest_queue` table to `init_db()` |
| `dev-api.py` | Add 4 new endpoints; add `extract_links()` + `_score_url()` helpers |
| `web/src/stores/digest.ts` | New Pinia store |
| `web/src/views/DigestView.vue` | New page |
| `web/src/router/index.ts` | Add `/digest` route |
| `web/src/components/AppNav.vue` | Import digest store; add Digest nav item + reactive badge; desktop nav only |
| `web/src/components/InterviewCard.vue` | Third call in digest chip handler |
| `web/src/views/InterviewsView.vue` | Third call in digest chip handler |
| `tests/test_dev_api_digest.py` | New test file — 11 tests |
---
## What Stays the Same
- Existing reclassify + dismiss two-call path for digest chip — unchanged
- `insert_job` in `scripts/db.py` — called as-is, no modification needed
- Job Review UI — queued jobs appear there as `status='pending'` automatically
- Signal banner dismiss behavior — optimistic, unchanged
- `_strip_html()` helper in `dev-api.py` — reused for `GET /api/digest-queue` response body

View file

@ -1,251 +0,0 @@
# Interviews Page — Improvements Design
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
Add three improvements to the Vue SPA Interviews page:
1. Collapse the Applied/Survey pre-kanban strip so the kanban board is immediately visible
2. Email sync status pill in the page header
3. Stage signal banners on job cards — in both the Applied/Survey pre-list rows and the kanban `InterviewCard` components
---
## Decisions Made
| Decision | Choice |
|---|---|
| Applied section default state | Collapsed |
| Collapse persistence | `localStorage` key `peregrine.interviews.appliedExpanded` |
| Signal visibility when collapsed | `⚡ N signals` count shown in collapsed header |
| Email sync placement | Page header status pill (right side, beside ↻ Refresh) |
| Signal banner placement | Applied/Survey pre-list rows AND InterviewCard kanban cards |
| Signal data loading | Batched with `GET /api/interviews` response (no N+1 requests) |
| Multiple signals | Show most recent; `+N more` expands rest inline; click again to collapse |
| Signal dismiss | Optimistic removal; `POST /api/stage-signals/{id}/dismiss` |
| MoveToSheet pre-selection | New optional `preSelectedStage?: PipelineStage` prop on MoveToSheet |
| Email not configured | `POST /api/email/sync` returns 503; pill shows muted `📧 Email not configured` (non-interactive) |
| Polling teardown | Stop polling on `onUnmounted`; hydrate status from `GET /api/email/sync/status` on mount |
---
## Feature 1: Applied Section Collapsible
### Behavior
The pre-kanban "Applied + Survey" strip (currently rendered above the three kanban columns) becomes a toggle section. Survey jobs remain in the same section.
**Default state:** collapsed on page load, unless `localStorage` indicates the user previously expanded it.
**Header row (always visible):**
```
▶ Applied [12] · ⚡ 2 signals (collapsed)
▼ Applied [12] · ⚡ 2 signals (expanded)
```
- Arrow chevron toggles on click (anywhere on the header row)
- Count badge: total applied + survey jobs
- Signal indicator: `⚡ N signals` in amber — shown only when there are undismissed signals across applied/survey jobs. Hidden when N = 0.
- CSS `max-height` transition: transition from `0` to `800px` (safe cap — enough for any real list). `prefers-reduced-motion`: instant toggle (no transition).
**Expanded state:** renders the existing applied/survey job rows with signal banners (see Feature 3).
### localStorage
```typescript
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
// default: false (collapsed). localStorage returns null on first load → defaults to false.
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))
```
`localStorage.getItem(...)` returns `null` on first load; `null === 'true'` is `false`, so the section starts collapsed correctly.
---
## Feature 2: Email Sync Status Pill
### Placement
Right side of the Interviews page header, alongside the existing ↻ Refresh button.
### States
| API `status` + `last_completed_at` | Pill appearance | Interaction |
|---|---|---|
| No API call yet / `idle` + `null` | `📧 Sync Emails` (outlined button) | Click → trigger sync |
| `idle` + timestamp exists | `📧 Synced 4m ago` (green pill) | Click → re-trigger sync |
| `queued` or `running` | `⏳ Syncing…` (disabled, pulse animation) | Non-interactive |
| `completed` | `📧 Synced 4m ago` (green pill) | Click → re-trigger sync |
| `failed` | `⚠ Sync failed` (amber pill) | Click → retry |
| 503 from `POST /api/email/sync` | `📧 Email not configured` (muted, non-interactive) | None |
The elapsed-time label ("4m ago") is computed from `lastSyncedAt` using a reactive tick. A `setInterval` updates a `now` ref every 60 seconds in `onMounted`, cleared in `onUnmounted`.
### Lifecycle
**On mount:** call `GET /api/email/sync/status` once to hydrate pill state. If status is `queued` or `running` (sync was in progress before navigation), start polling immediately.
**On sync trigger:** `POST /api/email/sync` → if 503, set pill to "Email not configured" permanently for the session. Otherwise poll `GET /api/email/sync/status` every 3 seconds.
**Polling stop conditions:** status becomes `completed` or `failed`, OR component unmounts (`onUnmounted` clears the interval). On `completed`, re-fetch the interview job list to pick up new signals.
### API
**Trigger sync:**
```
POST /api/email/sync
→ 202 { task_id: number } (sync queued)
→ 503 { detail: "Email not configured" } (no email integration)
```
Inserts a `background_tasks` row with `task_type = "email_sync"`, `job_id = 0` (sentinel for global/non-job tasks).
**Poll status:**
```
GET /api/email/sync/status
→ {
status: "idle" | "queued" | "running" | "completed" | "failed",
last_completed_at: string | null, // ISO timestamp or null
error: string | null
}
```
Implementation: `SELECT status, finished_at AS last_completed_at FROM background_tasks WHERE task_type = 'email_sync' ORDER BY id DESC LIMIT 1`. If no rows: return `{ status: "idle", last_completed_at: null, error: null }`. Note: the column is `finished_at` (not `completed_at`) per the `background_tasks` schema.
### Store shape
```typescript
interface SyncStatus {
state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
lastCompletedAt: string | null
error: string | null
}
// ref in stores/interviews.ts, or local ref in InterviewsView.vue
```
The sync state can live as a local ref in `InterviewsView.vue` (not in the Pinia store) since it's view-only state with no cross-component consumers.
---
## Feature 3: Stage Signal Banners
### Data Model
`GET /api/interviews` response includes `stage_signals` per job. Implementation: after the main jobs query, run a second query:
```sql
SELECT id, job_id, subject, received_at, stage_signal
FROM job_contacts
WHERE job_id IN (:job_ids)
AND suggestion_dismissed = 0
AND stage_signal NOT IN ('neutral', 'unrelated', 'digest', 'event_rescheduled')
AND stage_signal IS NOT NULL
ORDER BY received_at DESC
```
Group results by `job_id` in Python and attach to each job dict. Empty list `[]` if no signals.
The `StageSignal.id` is `job_contacts.id` — the contact row id, used for the dismiss endpoint.
```typescript
// Export from stores/interviews.ts so InterviewCard.vue can import it
export interface StageSignal {
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
subject: string
received_at: string // ISO timestamp
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
// 'event_rescheduled' is excluded server-side; other classifier labels filtered at query level
}
export interface PipelineJob {
// ... existing fields
stage_signals: StageSignal[] // undismissed signals, newest first
}
```
### Signal Label + Color Map
| Signal type | Suggested action label | Banner accent | `preSelectedStage` value |
|---|---|---|---|
| `interview_scheduled` | Move to Phone Screen | Amber | `'phone_screen'` |
| `positive_response` | Move to Phone Screen | Amber | `'phone_screen'` |
| `offer_received` | Move to Offer | Green | `'offer'` |
| `survey_received` | Move to Survey | Amber | `'survey'` |
| `rejected` | Mark Rejected | Red | `'interview_rejected'` |
Note: `'rejected'` maps to the stage value `'interview_rejected'` (not `'rejected'`) — this non-obvious mapping must be hardcoded in the signal banner logic.
### Where Banners Appear
Signal banners appear in **both** locations:
1. **Applied/Survey pre-list rows** (in `InterviewsView.vue`) — inline below the existing row content
2. **Kanban `InterviewCard` components** (phone_screen / interviewing / offer columns) — at the bottom of the card, inside the card border
This ensures the `⚡ N signals` count in the Applied section header points to visible, actionable banners in that section.
### Banner Layout
```
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│ ← colored top border (40% opacity)
│ 📧 Email suggests: Move to Phone Screen │
│ "Interview confirmed for Tuesday…" [→ Move] [✕] │
└──────────────────────────────────────────────────┘
```
- Background tint: `rgba(245,158,11,0.08)` amber / `rgba(39,174,96,0.08)` green / `rgba(192,57,43,0.08)` red
- Top border: 1px solid matching accent at 40% opacity
- Subject line: truncated to ~60 chars with ellipsis
- **[→ Move]** button: emits `move: [jobId: number, preSelectedStage: PipelineStage]` up to `InterviewsView.vue`, which passes `preSelectedStage` to `MoveToSheet` when opening it. The `InterviewCard` `move` emit signature is extended from `move: [jobId: number]` to `move: [jobId: number, preSelectedStage?: PipelineStage]` — the second argument is optional so existing non-signal `move` calls remain unchanged.
- **[✕]** dismiss button: optimistic removal from local `stage_signals` array, then `POST /api/stage-signals/{id}/dismiss`
**Multiple signals:** when `stage_signals.length > 1`, only the most recent banner shows. A `+N more` link below it expands to show all signals stacked; clicking ` less` (same element, toggled) collapses back to one. Each expanded signal has its own `[✕]` button.
**Empty signals:** `v-if="job.stage_signals?.length"` gates the entire banner — nothing renders when the array is empty or undefined.
### Dismiss API
```
POST /api/stage-signals/{id}/dismiss (id = job_contacts.id)
→ 200 { ok: true }
```
Sets `suggestion_dismissed = 1` in `job_contacts` for that row. Optimistic update: remove from local `stage_signals` array immediately on click, before API response.
### Applied section signal count
```typescript
// Computed in InterviewsView.vue
const appliedSignalCount = computed(() =>
[...store.applied, ...store.survey]
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
)
```
---
## Files
| File | Action |
|---|---|
| `web/src/views/InterviewsView.vue` | Collapsible Applied section (toggle, localStorage, `max-height` CSS, signal count in header); email sync pill + polling in header |
| `web/src/components/InterviewCard.vue` | Stage signal banner at card bottom; import `StageSignal` from store |
| `web/src/components/MoveToSheet.vue` | Add optional `preSelectedStage?: PipelineStage` prop; pre-select stage button on open |
| `web/src/components/InterviewCard.vue` (emit) | Extend `move` emit: `move: [jobId: number, preSelectedStage?: PipelineStage]` — second arg passed from signal banner `[→ Move]` button; existing card move button continues passing `undefined` |
| `web/src/stores/interviews.ts` | Export `StageSignal` interface; add `stage_signals: StageSignal[]` to `PipelineJob`; update `_row_to_job()` equivalent |
| `dev-api.py` | `stage_signals` nested in `/api/interviews` (second query + Python grouping); `POST /api/email/sync`; `GET /api/email/sync/status`; `POST /api/stage-signals/{id}/dismiss` |
---
## What Stays the Same
- Kanban columns (Phone Screen → Interviewing → Offer/Hired) — layout unchanged
- MoveToSheet modal — existing behavior unchanged; only a new optional prop added
- Rejected section — unchanged
- InterviewCard content above the signal banner — unchanged
- Keyboard navigation — unchanged

View file

@ -1,259 +0,0 @@
# Signal Banner Redesign — Expandable Email + Re-classification
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
Improve the stage signal banners added in the interviews improvements feature by:
1. Allowing users to expand a banner to read the full email body before acting
2. Providing inline re-classification chips to correct inaccurate classifier labels
3. Removing the implicit one-click stage advancement risk — `[→ Move]` remains but always routes through MoveToSheet confirmation
---
## Decisions Made
| Decision | Choice |
|---|---|
| Email body loading | Eager — add `body` and `from_addr` to `GET /api/interviews` signal query |
| Neutral re-classification | Two calls: `POST /api/stage-signals/{id}/reclassify` (body: `{stage_signal:"neutral"}`) then `POST /api/stage-signals/{id}/dismiss`. This persists the corrected label before dismissing, preserving training signal for Avocet. |
| Re-classification persistence | Update `job_contacts.stage_signal` in place; no separate correction record |
| Avocet training integration | Deferred — reclassify endpoint is the hook; export logic added later |
| Expand state persistence | None — local component state only, resets on page reload |
| `[→ Move]` button rename | No rename — pre-selection hint is still useful; MoveToSheet confirm is the safeguard |
---
## Data Model Changes
### `StageSignal` interface (add two fields)
```typescript
export interface StageSignal {
id: number
subject: string
received_at: string
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
body: string | null // NEW — email body text
from_addr: string | null // NEW — sender address
}
```
**Important — do NOT widen the `stage_signal` union.** The five values above are exactly what the SQL query returns (all others are excluded by the `NOT IN` filter). `SIGNAL_META` in `InterviewCard.vue` and `SIGNAL_META_PRE` in `InterviewsView.vue` are both typed as `Record<StageSignal['stage_signal'], ...>`, which requires every union member to be a key. Widening the union to include `neutral`, `unrelated`, etc. would require adding entries to both maps or TypeScript will error. The reclassify endpoint accepts all nine classifier labels server-side, but client-side we only need the five actionable ones since neutral triggers dismiss (not a local label change).
### `GET /api/interviews` — signal query additions
Add `body, from_addr` to the SELECT clause of the second query in `list_interviews()`:
```sql
SELECT id, job_id, subject, received_at, stage_signal, body, from_addr
FROM job_contacts
WHERE job_id IN (:job_ids)
AND suggestion_dismissed = 0
AND stage_signal NOT IN ('neutral', 'unrelated', 'digest', 'event_rescheduled')
AND stage_signal IS NOT NULL
ORDER BY received_at DESC
```
The Python grouping dict append also includes the new fields:
```python
signals_by_job[sr["job_id"]].append({
"id": sr["id"],
"subject": sr["subject"],
"received_at": sr["received_at"],
"stage_signal": sr["stage_signal"],
"body": sr["body"],
"from_addr": sr["from_addr"],
})
```
---
## New Endpoint
### `POST /api/stage-signals/{id}/reclassify`
```
POST /api/stage-signals/{id}/reclassify
Body: { stage_signal: string }
→ 200 { ok: true }
→ 400 if stage_signal is not a valid label
→ 404 if signal not found
```
Updates `job_contacts.stage_signal` for the given row. Valid labels are the nine classifier labels: `interview_scheduled`, `offer_received`, `rejected`, `positive_response`, `survey_received`, `neutral`, `event_rescheduled`, `unrelated`, `digest`.
Implementation:
```python
VALID_SIGNAL_LABELS = {
'interview_scheduled', 'offer_received', 'rejected',
'positive_response', 'survey_received', 'neutral',
'event_rescheduled', 'unrelated', 'digest',
}
@app.post("/api/stage-signals/{signal_id}/reclassify")
def reclassify_signal(signal_id: int, body: ReclassifyBody):
if body.stage_signal not in VALID_SIGNAL_LABELS:
raise HTTPException(400, f"Invalid label: {body.stage_signal}")
db = _get_db()
result = db.execute(
"UPDATE job_contacts SET stage_signal = ? WHERE id = ?",
(body.stage_signal, signal_id),
)
rowcount = result.rowcount
db.commit()
db.close()
if rowcount == 0:
raise HTTPException(404, "Signal not found")
return {"ok": True}
```
With Pydantic model:
```python
class ReclassifyBody(BaseModel):
stage_signal: str
```
---
## Banner UI Redesign
### Layout — collapsed (default)
```
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│ ← colored top border
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▸ Read] [→ Move] [✕] │
└──────────────────────────────────────────────────┘
```
- Subject line: signal type label + subject snippet (truncated to ~60 chars)
- `[▸ Read]` — expand toggle (text button, no border)
- `[→ Move]` — opens MoveToSheet with `preSelectedStage` based on current signal type (unchanged from previous implementation)
- `[✕]` — dismiss
### Layout — expanded
```
┌──────────────────────────────────────────────────┐
│ [existing card / row content] │
│──────────────────────────────────────────────────│
│ 📧 Interview scheduled "Interview confirmed…" │
│ [▾ Hide] [→ Move] [✕] │
│ From: recruiter@acme.com │
│ "Hi, we'd like to schedule an interview │
│ for Tuesday at 2pm…" │
│ Re-classify: [🟡 Interview ✓] [🟢 Offer] │
│ [✅ Positive] [📋 Survey] │
│ [✖ Rejected] [— Neutral] │
└──────────────────────────────────────────────────┘
```
- `[▾ Hide]` — collapses back to default
- Email body: full text, not truncated, in a `pre-wrap` style block
- `From:` line shown if `from_addr` is non-null
- Re-classify chips: one per actionable type + neutral (see chip spec below)
### Re-classify chips
| Label | Display | Action on click |
|---|---|---|
| `interview_scheduled` | 🟡 Interview | Set as active label |
| `positive_response` | ✅ Positive | Set as active label |
| `offer_received` | 🟢 Offer | Set as active label |
| `survey_received` | 📋 Survey | Set as active label |
| `rejected` | ✖ Rejected | Set as active label |
| `neutral` | — Neutral | Two-call optimistic dismiss: fire `POST reclassify` (neutral) + `POST dismiss` in sequence, then remove from local array |
The chip matching the current `stage_signal` is highlighted (active state). Clicking a non-neutral chip:
1. Optimistically updates `sig.stage_signal` on the local signal object
2. Banner accent color, action label, and `[→ Move]` preSelectedStage update reactively
3. `POST /api/stage-signals/{id}/reclassify` fires in background
Clicking **Neutral**:
1. Optimistically removes the signal from the local `stage_signals` array
2. `POST /api/stage-signals/{id}/reclassify` fires with `{ stage_signal: "neutral" }` to persist the corrected label (Avocet training hook)
3. `POST /api/stage-signals/{id}/dismiss` fires immediately after to suppress the banner going forward
### Reactive re-labeling
When `sig.stage_signal` changes locally (after a chip click), the banner updates immediately:
- Accent color (`amber` / `green` / `red`) from `SIGNAL_META[sig.stage_signal].color`
- Action label in `[→ Move]` pre-selection from `SIGNAL_META[sig.stage_signal].stage`
- Active chip highlight moves to the new label
This works because `sig` is accessed through Pinia's reactive proxy chain — Vue 3 wraps nested objects on access, so `sig.stage_signal = 'offer_received'` triggers the proxy setter and causes the template to re-evaluate.
**Note:** This relies on `sig` being a live reactive proxy, not a raw copy. It would silently fail if `job` or `stage_signals` were passed through `toRaw()` or `markRaw()`. Additionally, if `store.fetchAll()` fires while a reclassify API call is in flight (e.g. triggered by email sync completing), the old `sig` reference becomes stale — the optimistic mutation has already updated the UI correctly, and `fetchAll()` will overwrite with server data. Since the reclassify endpoint persists immediately, the server value after `fetchAll()` will match the user's intent. No special handling needed.
### Expand state
`bodyExpanded` — local `ref` per banner instance. Not persisted. Use `bodyExpanded` consistently (not `sigBodyExpanded`).
In `InterviewCard.vue`: one `ref<boolean>` per card instance (`const bodyExpanded = ref(false)`), since each card shows at most one visible signal at a time (the others hidden behind `+N more`).
In `InterviewsView.vue` pre-list: keyed by signal id using a **`ref<Record<number, boolean>>`** (NOT a `Map`). Vue 3 can track property access on plain objects held in a `ref` deeply, so `bodyExpandedMap.value[sig.id] = true` triggers re-render correctly. Using a `Map` would have the same copy-on-write trap as `Set` (documented in the previous spec). Implementation:
```typescript
const bodyExpandedMap = ref<Record<number, boolean>>({})
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
}
```
Or alternatively, mutate in place (Vue 3 tracks object property mutations on reactive refs):
```typescript
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value[sigId] = !bodyExpandedMap.value[sigId]
}
```
The spread-copy pattern is safer and consistent with the `sigExpandedIds` Set pattern used in `InterviewsView.vue`. Use whichever the implementer verifies triggers re-render — the spread-copy is the guaranteed-safe choice.
---
## SIGNAL_META Sync Contract
`InterviewCard.vue` has `SIGNAL_META` and `InterviewsView.vue` has `SIGNAL_META_PRE`. Both are `Record<StageSignal['stage_signal'], ...>` and must have exactly the same five keys. The reclassify feature does not add new chip targets that don't already exist in both maps — the five actionable labels are the same set. No changes to either map are needed. **Implementation note:** if a chip label is ever added or removed, it must be updated in both maps simultaneously.
---
## Required Test Cases (`tests/test_dev_api_interviews.py`)
### Existing test additions
- `test_interviews_includes_stage_signals`: extend to assert `body` and `from_addr` are present in the returned signal objects (can be `None` if no body in fixture)
### New reclassify endpoint tests
- `test_reclassify_signal_updates_label`: POST valid label → 200 `{"ok": true}`, DB row has new `stage_signal` value
- `test_reclassify_signal_invalid_label`: POST unknown label → 400
- `test_reclassify_signal_404_for_missing_id`: POST to non-existent id → 404
---
## Files
| File | Action |
|---|---|
| `dev-api.py` | Add `body, from_addr` to signal SELECT; add `POST /api/stage-signals/{id}/reclassify` |
| `tests/test_dev_api_interviews.py` | Add tests for reclassify endpoint; verify body/from_addr in interviews response |
| `web/src/stores/interviews.ts` | Add `body: string \| null`, `from_addr: string \| null` to `StageSignal` |
| `web/src/components/InterviewCard.vue` | Expand toggle; body/from display; re-classify chips; reactive local re-label |
| `web/src/views/InterviewsView.vue` | Same for pre-list banner rows (keyed expand map) |
---
## What Stays the Same
- Dismiss endpoint and optimistic removal — unchanged
- `[→ Move]` button routing through MoveToSheet with preSelectedStage — unchanged
- `+N more` / ` less` multi-signal expand — unchanged
- Signal banner placement (InterviewCard kanban + InterviewsView pre-list) — unchanged
- Signal → stage → color mapping (`SIGNAL_META` / `SIGNAL_META_PRE`) — unchanged (but now reactive to re-classification)
- `⚡ N signals` count in Applied section header — unchanged

View file

@ -1,184 +0,0 @@
# Interview Prep Page — Vue SPA Design
## Goal
Port the Streamlit Interview Prep page (`app/pages/6_Interview_Prep.py`) to a Vue 3 SPA view at `/prep/:id`, with a two-column layout, research brief generation, reference tabs, and localStorage call notes.
## Scope
**In scope:**
- Job header with stage badge + interview date countdown
- Research brief display with generate / refresh / polling
- All research sections: Talking Points, Company Overview, Leadership & Culture, Tech Stack (conditional), Funding (conditional), Red Flags (conditional), Inclusion & Accessibility (conditional)
- Reference panel: Job Description tab, Email History tab, Cover Letter tab
- Call Notes (localStorage, per job)
- Navigation: `/prep` redirects to `/interviews`; Interviews kanban adds "Prep →" link on active-stage cards
**Explicitly deferred:**
- Practice Q&A (LLM mock interviewer chat — needs streaming chat endpoint, deferred to a future sprint)
- "Draft reply to last email" LLM button in Email tab (present in Streamlit, requires additional LLM endpoint, deferred to a future sprint)
- Layout B / C options (full-width tabbed, accordion) — architecture supports future layout preference stored in localStorage
---
## Architecture
### Routing
- `/prep/:id` — renders `InterviewPrepView.vue` with the specified job
- `/prep` (no id) — redirects to `/interviews`
- On mount: if job id is missing, or job is not in `phone_screen` / `interviewing` / `offer`, redirect to `/interviews`
- Router already has both routes defined (`/prep` and `/prep/:id`)
### Backend — `dev-api.py` (4 new endpoints)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/jobs/{id}/research` | Returns `company_research` row for job, or 404 if none |
| `POST` | `/api/jobs/{id}/research/generate` | Submits `company_research` background task via `submit_task()`; returns `{task_id, is_new}` |
| `GET` | `/api/jobs/{id}/research/task` | Latest task status from `background_tasks`: `{status, stage, message}` — matches `cover_letter_task` response shape (`message` maps the `error` column) |
| `GET` | `/api/jobs/{id}/contacts` | Returns all `job_contacts` rows for this job, ordered by `received_at` desc |
Reuses existing patterns: `submit_task()` (same as cover letter), `background_tasks` query (same as `cover_letter_task`), `get_contacts()` (same as Streamlit). No schema changes.
### Store — `web/src/stores/prep.ts`
```ts
interface ResearchBrief {
company_brief: string | null
ceo_brief: string | null
talking_points: string | null
tech_brief: string | null // confirmed present in company_research (used in Streamlit 6_Interview_Prep.py:178)
funding_brief: string | null // confirmed present in company_research (used in Streamlit 6_Interview_Prep.py:185)
red_flags: string | null
accessibility_brief: string | null
generated_at: string | null
// raw_output is returned by the API but not used in the UI — intentionally omitted from interface
}
interface Contact {
id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
body: string | null
received_at: string | null
}
interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null
message: string | null // maps the background_tasks.error column; matches cover_letter_task shape
}
```
State: `research: ResearchBrief | null`, `contacts: Contact[]`, `taskStatus: TaskStatus`, `loading: boolean`, `error: string | null`, `currentJobId: number | null`.
Methods:
- `fetchFor(jobId)` — clears state if `jobId !== currentJobId`, fires three parallel requests: `GET /research`, `GET /contacts`, `GET /research/task`. Stores results. If task status from the task fetch is `queued` or `running`, calls `pollTask(jobId)` to start the polling interval.
- `generateResearch(jobId)``POST /research/generate`, then calls `pollTask(jobId)`
- `pollTask(jobId)``setInterval` at 3s; stops when status is `completed` or `failed`; on `completed` re-calls `fetchFor(jobId)` to pull in fresh research
- `clear()` — cancels any active poll interval, resets all state
### Component — `web/src/views/InterviewPrepView.vue`
**Mount / unmount:**
- Reads `route.params.id`; redirects to `/interviews` if missing
- Looks up job in `interviewsStore.jobs`; redirects to `/interviews` if job status not in active stages
- Calls `prepStore.fetchFor(jobId)` on mount
- Calls `prepStore.clear()` on unmount (`onUnmounted`)
**Layout (desktop ≥1024px): two-column**
Left column (40%):
1. Job header
- Company + title (`h1`)
- Stage badge (pill)
- Interview date + countdown (🔴 TODAY / 🟡 TOMORROW / 🟢 in N days / grey "was N days ago")
- "Open job listing ↗" link button (if `job.url`)
2. Research controls
- State: `no research + no task` → "Generate research brief" primary button
- State: `task queued/running` → spinner + stage label (e.g. "Scraping company site…"), polling active
- State: `research loaded` → "Generated: {timestamp}" caption + "Refresh" button (disabled while task running)
- State: `task failed` → inline error + "Retry" button
3. Research sections (render only if non-empty string):
- 🎯 Talking Points
- 🏢 Company Overview
- 👤 Leadership & Culture
- ⚙️ Tech Stack & Product *(conditional)*
- 💰 Funding & Market Position *(conditional)*
- ⚠️ Red Flags & Watch-outs *(conditional; styled as warning block; skip if text contains "no significant red flags")*
- ♿ Inclusion & Accessibility *(conditional; privacy caption: "For your personal evaluation — not disclosed in any application.")*
Right column (60%):
1. Tabs: Job Description | Email History | Cover Letter
- **JD tab**: match score badge (🟢 ≥70% / 🟡 ≥40% / 🔴 <40%), keyword gaps, description rendered as markdown
- **Email tab**: list of contacts — icon (📥/📤) + subject + date + from_addr + first 500 chars of body; empty state if none
- **Letter tab**: cover letter markdown; empty state if none
2. Call Notes
- Textarea below tabs
- `v-model` bound to computed getter/setter reading `localStorage.getItem('cf-prep-notes-{jobId}')`
- Auto-saved via debounced `watch` (300ms)
- Caption: "Notes are saved locally — they won't sync between devices."
- **Intentional upgrade from Streamlit**: Streamlit stored notes in `session_state` only (lost on navigation). localStorage persists across page refreshes and sessions.
**Mobile (≤1023px):** single column — left panel content first (scrollable), then tabs panel below.
### Navigation addition — `InterviewsView.vue`
Add a "Prep →" `RouterLink` to `/prep/:id` on each job card in `phone_screen`, `interviewing`, and `offer` columns. Not shown in `applied`, `survey`, `hired`, or `interview_rejected`.
---
## Data Flow
```
User navigates to /prep/:id
→ InterviewPrepView mounts
→ redirect check (job in active stage?)
→ prepStore.fetchFor(id)
├─ GET /api/jobs/{id}/research (parallel)
├─ GET /api/jobs/{id}/contacts (parallel)
└─ GET /api/jobs/{id}/research/task (parallel — to check if a task is already running)
→ if task running: pollTask(id) starts interval
→ user clicks "Generate" / "Refresh"
→ POST /api/jobs/{id}/research/generate
→ pollTask(id) starts
→ GET /api/jobs/{id}/research/task every 3s
→ on completed: fetchFor(id) re-fetches research
User navigates away
→ prepStore.clear() cancels interval
```
---
## Error Handling
- Research fetch 404 → `research` stays null, show generate button
- Research fetch network/5xx → show inline error in left column
- Contacts fetch error → show "Could not load email history" in Email tab
- Generate task failure → `taskStatus.message` shown with "Retry" button
- Job not found / wrong stage → redirect to `/interviews` (no error flash)
---
## Testing
New test files:
- `tests/test_dev_api_prep.py` — covers all 4 endpoints: research GET (found/not-found), generate (new/duplicate), task status, contacts GET
- `web/src/stores/prep.test.ts` — unit tests for `fetchFor`, `generateResearch`, `pollTask` (mock `useApiFetch`), `clear` cancels interval
No new DB migrations. All DB access uses existing `scripts/db.py` helpers.
---
## Files Changed
| Action | Path |
|--------|------|
| Modify | `dev-api.py` — 4 new endpoints |
| Create | `tests/test_dev_api_prep.py` |
| Create | `web/src/stores/prep.ts` |
| Modify | `web/src/views/InterviewPrepView.vue` — full implementation |
| Modify | `web/src/views/InterviewsView.vue` — add "Prep →" links |
| Create | `web/src/stores/prep.test.ts` |

View file

@ -1,224 +0,0 @@
# Survey Assistant Page — Vue SPA Design
## Goal
Port the Streamlit Survey Assistant page (`app/pages/7_Survey.py`) to a Vue 3 SPA view at `/survey/:id`, with a calm single-column layout, text paste and screenshot input, Quick/Detailed mode selection, LLM analysis, save-to-job, and response history.
## Scope
**In scope:**
- Routing at `/survey/:id` with redirect guard (job must be in `survey`, `phone_screen`, `interviewing`, or `offer`)
- `/survey` (no id) redirects to `/interviews`
- Calm single-column layout (max-width 760px, centered) with sticky job context bar
- Input: tabbed text paste / screenshot (paste Ctrl+V + drag-and-drop + file upload)
- Screenshot tab disabled (but visible) when vision service is unavailable
- Mode selection: two full-width labeled cards (Quick / Detailed)
- Synchronous LLM analysis via new backend endpoint
- Results rendered below input after analysis
- Save to job: optional survey name + reported score
- Response history: collapsible accordion, closed by default
- "Survey →" navigation link on kanban cards in `survey`, `phone_screen`, `interviewing`, `offer` stages
**Explicitly deferred:**
- Streaming LLM responses (requires SSE endpoint — deferred to future sprint)
- Mock Q&A / interview practice chat (separate feature, requires streaming chat endpoint)
---
## Architecture
### Routing
- `/survey/:id` — renders `SurveyView.vue` with the specified job
- `/survey` (no id) — redirects to `/interviews`
- On mount: if job id is missing, or job status not in `['survey', 'phone_screen', 'interviewing', 'offer']`, redirect to `/interviews`
### Backend — `dev-api.py` (4 new endpoints)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/vision/health` | Proxy to vision service health check; returns `{available: bool}` |
| `POST` | `/api/jobs/{id}/survey/analyze` | Accepts `{text?, image_b64?, mode}`; runs LLM synchronously; returns `{output, source}` |
| `POST` | `/api/jobs/{id}/survey/responses` | Saves survey response to `survey_responses` table; saves image file if `image_b64` provided |
| `GET` | `/api/jobs/{id}/survey/responses` | Returns all `survey_responses` rows for job, newest first |
**Analyze endpoint details:**
- `mode`: `"quick"` or `"detailed"` (lowercase) — frontend sends lowercase; backend uses as-is to select prompt template (same as Streamlit `_build_text_prompt` / `_build_image_prompt`, which expect lowercase `mode`)
- If `image_b64` provided: routes through `vision_fallback_order`; **no system prompt** (matches Streamlit vision path); `source = "screenshot"`
- If `text` provided: routes through `research_fallback_order`; passes `system=_SURVEY_SYSTEM` (matches Streamlit text path); `source = "text_paste"`
- `_SURVEY_SYSTEM` constant: `"You are a job application advisor helping a candidate answer a culture-fit survey. The candidate values collaborative teamwork, clear communication, growth, and impact. Choose answers that present them in the best professional light."`
- Returns `{output: str, source: str}` on success; raises HTTP 500 on LLM failure
**Save endpoint details:**
- Body: `{survey_name?, mode, source, raw_input?, image_b64?, llm_output, reported_score?}`
- Backend generates `received_at = datetime.now().isoformat()` — not passed by client
- If `image_b64` present: saves PNG to `data/survey_screenshots/{job_id}/{timestamp}.png`; stores path in `image_path` column; `image_b64` is NOT stored in DB
- Calls `scripts.db.insert_survey_response(db_path, job_id, survey_name, received_at, source, raw_input, image_path, mode, llm_output, reported_score)` — note `received_at` is the second positional arg after `job_id`
- `SurveyResponse.created_at` in the store interface maps the DB `created_at` column (SQLite auto-set on insert); `received_at` is a separate column storing the analysis timestamp — both are returned by `GET /survey/responses`; store interface exposes `received_at` for display
- Returns `{id: int}` of new row
**Vision health endpoint:**
- Attempts `GET http://localhost:8002/health` with 2s timeout
- Returns `{available: true}` on 200, `{available: false}` on any error/timeout
### Store — `web/src/stores/survey.ts`
```ts
interface SurveyAnalysis {
output: string
source: 'text_paste' | 'screenshot'
mode: 'quick' | 'detailed' // retained so saveResponse can include it
rawInput: string | null // retained so saveResponse can include raw_input
}
interface SurveyResponse {
id: number
survey_name: string | null
mode: 'quick' | 'detailed'
source: string
raw_input: string | null
image_path: string | null
llm_output: string
reported_score: string | null
received_at: string | null // analysis timestamp (from DB received_at column)
created_at: string | null // row insert timestamp (SQLite auto)
}
```
State: `analysis: SurveyAnalysis | null`, `history: SurveyResponse[]`, `loading: boolean`, `saving: boolean`, `error: string | null`, `visionAvailable: boolean`, `currentJobId: number | null`
Methods:
- `fetchFor(jobId)` — clears state if `jobId !== currentJobId`; fires two parallel requests: `GET /api/jobs/{id}/survey/responses` and `GET /api/vision/health`; stores results
- `analyze(jobId, payload: {text?: string, image_b64?: string, mode: 'quick' | 'detailed'})` — sets `loading = true`; POST to analyze endpoint; stores result in `analysis` (including `mode` and `rawInput = payload.text ?? null` for later use by `saveResponse`); sets `error` on failure
- `saveResponse(jobId, {surveyName: string, reportedScore: string, image_b64?: string})` — sets `saving = true`; constructs full save body from current `analysis` (`mode`, `source`, `rawInput`, `llm_output`) + method args; POST to save endpoint; prepends new response to `history`; clears `analysis`; sets `error` on failure
- `clear()` — resets all state to initial values
### Component — `web/src/views/SurveyView.vue`
**Mount / unmount:**
- Reads `route.params.id`; redirects to `/interviews` if missing or non-numeric
- Looks up job in `interviewsStore.jobs` (fetches if empty); redirects if job status not in valid stages
- Calls `surveyStore.fetchFor(jobId)` on mount
- Calls `surveyStore.clear()` on unmount
**Layout:** Single column, `max-width: 760px`, centered (`margin: 0 auto`), padding `var(--space-6)`.
**1. Sticky context bar**
- Sticky top, low height (~40px), soft background color
- Shows: company name + job title + stage badge
- Always visible while scrolling
**2. Input card**
- Tabs: "📝 Paste Text" (always active) / "📷 Screenshot"
- Screenshot tab: rendered but non-interactive (`aria-disabled`) when `!surveyStore.visionAvailable`; tooltip on hover: "Vision service not running — start it with: bash scripts/manage-vision.sh start"
- **Text tab:** `<textarea>` with placeholder showing example Q&A format, min-height 200px
- **Screenshot tab:** Combined drop zone with three affordances:
- Paste: listens for `paste` event on the zone (Ctrl+V); accepts `image/*` items from `ClipboardEvent.clipboardData`
- Drag-and-drop: `dragover` / `drop` events; accepts image files
- File upload: `<input type="file" accept="image/*">` button within the zone
- Preview: shows thumbnail of loaded image with "✕ Remove" button
- Stores image as base64 string in component state
**3. Mode selection**
- Two full-width stacked cards, one per mode:
- ⚡ **Quick** — "Best answer + one-liner per question"
- 📋 **Detailed** — "Option-by-option breakdown with reasoning"
- Selected card: border highlight + subtle background fill
- Reactive `selectedMode` ref, default `'quick'`
**4. Analyze button**
- Full-width primary button
- Disabled when: no text input AND no image loaded
- While `surveyStore.loading`: shows spinner + "Analyzing…" label, disabled
- On click: calls `surveyStore.analyze(jobId, {text?, image_b64?, mode: selectedMode})`
**5. Results card** (rendered when `surveyStore.analysis` is set)
- Appears below the Analyze button (pushes history further down)
- LLM output rendered with `whitespace-pre-wrap`
- Inline save form below output:
- Optional "Survey name" text input (placeholder: "e.g. Culture Fit Round 1")
- Optional "Reported score" text input (placeholder: "e.g. 82% or 4.2/5")
- "💾 Save to job" button — calls `surveyStore.saveResponse()`; shows spinner while `surveyStore.saving`
- Inline success message on save; clears results card
**6. History accordion**
- Header: "Survey history (N responses)" — closed by default
- Low visual weight (muted header style)
- Each entry: survey name (fallback "Survey response") + date + score if present
- Expandable per entry: shows full LLM output + mode + source + `received_at` timestamp
- `raw_input` and `image_path` are intentionally not shown in history — raw input can be long and images are not served by the API
- Empty state if no history
**Error display:**
- Analyze error: inline below Analyze button
- Save error: inline below save form (analysis output preserved)
- Store-level load error (history/vision fetch): subtle banner below context bar
**Mobile:** identical — already single column.
### Navigation addition — `InterviewsView.vue` / `InterviewCard.vue`
Follow the existing `InterviewCard.vue` emit pattern (same as "Prep →"):
- Add `emit('survey', job.id)` button to `InterviewCard.vue` with `v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"`
- Add `@survey="router.push('/survey/' + $event)"` handler in `InterviewsView.vue` on the relevant column card instances
Do NOT use a `RouterLink` directly on the card — the established pattern is event emission to the parent view for navigation.
---
## Data Flow
```
User navigates to /survey/:id (from kanban "Survey →" link)
→ SurveyView mounts
→ redirect check (job in valid stage?)
→ surveyStore.fetchFor(id)
├─ GET /api/jobs/{id}/survey/responses (parallel)
└─ GET /api/vision/health (parallel)
→ user pastes text OR uploads/pastes/drags screenshot
→ user selects mode (Quick / Detailed)
→ user clicks Analyze
→ POST /api/jobs/{id}/survey/analyze
→ surveyStore.analysis set with output
→ user reviews output
→ user optionally fills survey name + reported score
→ user clicks Save
→ POST /api/jobs/{id}/survey/responses
→ new entry prepended to surveyStore.history
→ results card cleared
User navigates away
→ surveyStore.clear() resets state
```
---
## Error Handling
- Vision health check fails → `visionAvailable = false`; screenshot tab disabled; text input unaffected
- Analyze POST fails → `error` set; inline error below button; input preserved for retry
- Save POST fails → `saving` error set; inline error on save form; analysis output preserved
- Job not found / wrong stage → redirect to `/interviews`
- History fetch fails → empty history, inline error banner; does not block analyze flow
---
## Testing
New test files:
- `tests/test_dev_api_survey.py` — covers all 4 endpoints: vision health (up/down), analyze text (quick/detailed), analyze image, analyze LLM failure, save response (with/without image), get history (empty/populated)
- `web/src/stores/survey.test.ts` — unit tests: `fetchFor` parallel loads, job change clears state, `analyze` stores result, `analyze` sets error on failure, `saveResponse` prepends to history and clears analysis, `clear` resets all state
No new DB migrations. All DB access uses existing `scripts/db.py` helpers (`insert_survey_response`, `get_survey_responses`).
---
## Files Changed
| Action | Path |
|--------|------|
| Modify | `dev-api.py` — 4 new endpoints |
| Create | `tests/test_dev_api_survey.py` |
| Create | `web/src/stores/survey.ts` |
| Create | `web/src/stores/survey.test.ts` |
| Create | `web/src/views/SurveyView.vue` — full implementation (replaces placeholder stub) |
| Modify | `web/src/components/InterviewCard.vue` — add "Survey →" link |