Compare commits

..

No commits in common. "main" and "feature/shared-task-scheduler" have entirely different histories.

98 changed files with 474 additions and 12480 deletions

View file

@ -12,21 +12,10 @@ VISION_REVISION=2025-01-09
DOCS_DIR=~/Documents/JobSearch
OLLAMA_MODELS_DIR=~/models/ollama
VLLM_MODELS_DIR=~/models/vllm # override with full path to your model dir
VLLM_MODEL=Ouro-1.4B # cover letters — fast 1.4B model
VLLM_RESEARCH_MODEL=Ouro-2.6B-Thinking # research — reasoning 2.6B model; restart vllm to switch
VLLM_MAX_MODEL_LEN=4096 # increase to 8192 for Thinking models with long CoT
VLLM_GPU_MEM_UTIL=0.75 # lower to 0.6 if sharing GPU with other services
VLLM_MODELS_DIR=~/models/vllm
VLLM_MODEL=Ouro-1.4B
OLLAMA_DEFAULT_MODEL=llama3.2:3b
# ── LLM env-var auto-config (alternative to config/llm.yaml) ─────────────────
# Set any of these to configure LLM backends without needing a config/llm.yaml.
# Priority: Anthropic > OpenAI-compat > Ollama (always tried as local fallback).
OLLAMA_HOST=http://localhost:11434 # Ollama host; override if on a different machine
OLLAMA_MODEL=llama3.2:3b # model to request from Ollama
OPENAI_MODEL=gpt-4o-mini # model override for OpenAI-compat backend
ANTHROPIC_MODEL=claude-haiku-4-5-20251001 # model override for Anthropic backend
# API keys (required for remote profile)
ANTHROPIC_API_KEY=
OPENAI_COMPAT_URL=
@ -39,12 +28,6 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
# GITHUB_TOKEN= # future — enable when public mirror is active
# GITHUB_REPO= # future
# ── CF-hosted coordinator (Paid+ tier) ───────────────────────────────────────
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
CF_LICENSE_KEY=
CF_ORCH_URL=https://orch.circuitforge.tech
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
CLOUD_MODE=false
CLOUD_DATA_ROOT=/devl/menagerie-data

View file

@ -22,12 +22,6 @@ jobs:
python-version: "3.11"
cache: pip
- name: Configure git credentials for Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
- name: Install dependencies
run: pip install -r requirements.txt

View file

@ -9,178 +9,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [0.8.5] — 2026-04-02
### Added
- **Vue onboarding wizard** — 7-step first-run setup replaces the Streamlit wizard
in the Vue SPA: Hardware detection → Tier → Resume upload/build → Identity →
Inference & API keys → Search preferences → Integrations. Progress saves to
`user.yaml` on every step; crash-recovery resumes from the last completed step.
- **Wizard API endpoints**`GET /api/wizard/status`, `POST /api/wizard/step`,
`GET /api/wizard/hardware`, `POST /api/wizard/inference/test`,
`POST /api/wizard/complete`. Inference test always soft-fails so Ollama being
unreachable never blocks setup completion.
- **Cloud auto-skip** — cloud instances automatically complete steps 1 (hardware),
2 (tier), and 5 (inference) and drop the user directly on the Resume step.
- **`wizardGuard` router gate** — all Vue routes require wizard completion; completed
users are bounced away from `/setup` to `/`.
- **Chip-input search step** — job titles and locations entered as press-Enter/comma
chips; validates at least one title before advancing.
- **Integrations tile grid** — optional step 7 shows Notion, Calendar, Slack, Discord,
Drive with paid-tier badges; skippable on Finish.
### Fixed
- **User config isolation: dangerous fallback removed**`_user_yaml_path()` fell
back to `/devl/job-seeker/config/user.yaml` (legacy profile) when `user.yaml`
didn't exist at the expected path; new users now get an empty dict instead of
another user's data. Affects profile, resume, search, and all wizard endpoints.
- **Resume path not user-isolated**`RESUME_PATH = Path("config/plain_text_resume.yaml")`
was a relative CWD path shared across all users. Replaced with `_resume_path()`
derived from `_user_yaml_path()` / `STAGING_DB`.
- **Resume upload silently returned empty data**`upload_resume` was passing a
file path string to `structure_resume()` which expects raw text; now reads bytes
and dispatches to the correct extractor (`extract_text_from_pdf` / `_docx` / `_odt`).
- **Wizard resume step read wrong envelope field**`WizardResumeStep.vue` read
`data.experience` but the upload response wraps parsed data under `data.data`.
---
## [0.8.4] — 2026-04-02
### Fixed
- **Cloud: cover letter used wrong user's profile**`generate_cover_letter.generate()`
loaded `_profile` from the global `config/user.yaml` at module import time, so all
cloud users got the default user's name, voice, and mission preferences in their
generated letters. `generate()` now accepts a `user_yaml_path` parameter; `task_runner`
derives it from the per-user config directory (`db_path/../config/user.yaml`) and
passes it through. `_build_system_context`, `_build_mission_notes`, `detect_mission_alignment`,
`build_prompt`, and `_trim_to_letter_end` all accept a `profile` override so the
per-call profile is used end-to-end without breaking CLI mode.
- **Apply Workspace: hardcoded config paths in cloud mode**`4_Apply.py` was loading
`_USER_YAML` and `RESUME_YAML` from the repo-root `config/` before `resolve_session()`
ran, so cloud users saw the global (Meg's) resume in the Apply tab. Both paths now
derive from `get_config_dir()` after session resolution.
### Changed
- **Vue SPA open to all tiers** — Vue 3 frontend is no longer gated behind the beta
flag; all tier users can switch to the Vue UI from Settings.
- **LLM model candidates** — vllm backend now tries Qwen2.5-3B first, Phi-4-mini
as fallback (was reversed). cf_orch allocation block added to vllm config.
- **Preflight** — removed `vllm` from Docker adoption list; vllm is now managed
entirely by cf-orch and should not be stubbed by preflight.
---
## [0.8.3] — 2026-04-01
### Fixed
- **CI: Forgejo auth** — GitHub Actions `pip install` was failing to fetch
`circuitforge-core` from the private Forgejo VCS URL. Added `FORGEJO_TOKEN`
repository secret and a `git config insteadOf` step to inject credentials
before `pip install`.
- **CI: settings API tests** — 6 `test_dev_api_settings` PUT/POST tests were
returning HTTP 500 in CI because `_user_yaml_path()` read the module-level
`DB_PATH` constant (frozen at import time), so `monkeypatch.setenv("STAGING_DB")`
had no effect. Fixed by reading `os.environ` at call time.
---
## [0.8.2] — 2026-04-01
### Fixed
- **CI pipeline**`pip install -r requirements.txt` was failing in GitHub Actions
because `-e ../circuitforge-core` requires a sibling directory that doesn't exist
in a single-repo checkout. Replaced with a `git+https://` VCS URL fallback;
`Dockerfile.cfcore` still installs from the local `COPY` to avoid redundant
network fetches during Docker builds.
- **Vue-nav reload loop**`sync_ui_cookie()` was calling
`window.parent.location.reload()` on every render when `user.yaml` has
`ui_preference: vue` but no Caddy proxy is in the traffic path (test instances,
bare Docker). Gated the reload on `PEREGRINE_CADDY_PROXY=1`; instances without
the env var set the cookie silently and skip the reload.
### Changed
- **cfcore VRAM lease integration** — the task scheduler now acquires a VRAM lease
from the cf-orch coordinator before running a batch of LLM tasks and releases it
when the batch completes. Visible in the coordinator dashboard at `:7700`.
- **`CF_ORCH_URL` env var** — scheduler reads coordinator address from
`CF_ORCH_URL` (default `http://localhost:7700`); set to
`http://host.docker.internal:7700` in Docker compose files so containers can
reach the host coordinator.
- **All compose files on `Dockerfile.cfcore`**`compose.yml`, `compose.cloud.yml`,
and `compose.test-cfcore.yml` all use the parent-context build. `build: .` is
removed from `compose.yml`.
---
## [0.8.1] — 2026-04-01
### Fixed
- **Job title suggester silent failure** — when the LLM returned empty arrays or
non-JSON text, the spinner would complete with zero UI feedback. Now shows an
explicit "No new suggestions found" info message with a resume-upload hint for
new users who haven't uploaded a resume yet.
- **Suggester exception handling** — catch `Exception` instead of only
`RuntimeError` so connection errors and `FileNotFoundError` (missing llm.yaml)
surface as error messages rather than crashing the page silently.
### Added
- **`Dockerfile.cfcore`** — parent-context Dockerfile that copies
`circuitforge-core/` alongside `peregrine/` before `pip install`, resolving
the `-e ../circuitforge-core` editable requirement inside Docker.
- **`compose.test-cfcore.yml`** — single-user test instance on port 8516 for
smoke-testing cfcore shim integration before promoting to the cloud instance.
---
## [0.8.0] — 2026-04-01
### Added
- **ATS Resume Optimizer** (gap report free; LLM rewrite paid+)
- `scripts/resume_optimizer.py` — full pipeline: TF-IDF gap extraction →
`prioritize_gaps``rewrite_for_ats` → hallucination guard (anchor-set
diffing on employers, institutions, and dates)
- `scripts/db.py``optimized_resume` + `ats_gap_report` columns;
`save_optimized_resume` / `get_optimized_resume` helpers
- `GET /api/jobs/{id}/resume_optimizer` — fetch gap report + rewrite
- `POST /api/jobs/{id}/resume_optimizer/generate` — queue rewrite task
- `GET /api/jobs/{id}/resume_optimizer/task` — poll task status
- `web/src/components/ResumeOptimizerPanel.vue` — gap report (all tiers),
LLM rewrite section (paid+), hallucination warning badge, `.txt` download
- `ResumeOptimizerPanel` integrated into `ApplyWorkspace`
- **Vue SPA full merge** (closes #8) — `feature/vue-spa` merged to `main`
- `dev-api.py` — full FastAPI backend (settings, jobs, interviews, prep,
survey, digest, resume optimizer); cloud session middleware (JWT → per-user
SQLite); BYOK credential store
- `dev_api.py` — symlink → `dev-api.py` for importable module alias
- `scripts/job_ranker.py` — two-stage ranking for `/api/jobs/stack`
- `scripts/credential_store.py` — per-user BYOK API key management
- `scripts/user_profile.py``load_user_profile` / `save_user_profile`
- `web/src/components/TaskIndicator.vue` + `web/src/stores/tasks.ts`
live background task queue display
- `web/public/` — peregrine logo assets (SVG + PNG)
- **API test suite** — 5 new test modules (622 tests total)
- `tests/test_dev_api_settings.py` (38 tests)
- `tests/test_dev_api_interviews.py`, `test_dev_api_prep.py`,
`test_dev_api_survey.py`, `test_dev_api_digest.py`
### Fixed
- **Cloud DB routing**`app/pages/1_Job_Review.py`, `5_Interviews.py`,
`6_Interview_Prep.py`, `7_Survey.py` were hardcoding `DEFAULT_DB`; now
use `get_db_path()` for correct per-user routing in cloud mode (#24)
- **Test isolation**`importlib.reload(dev_api)` in digest/interviews
fixtures reset all module globals, silently breaking `monkeypatch.setattr`
in subsequent test files; replaced with targeted `monkeypatch.setattr(dev_api,
"DB_PATH", tmp_db)` (#26)
---
## [0.7.0] — 2026-03-22
### Added

View file

@ -1,47 +0,0 @@
# Dockerfile.cfcore — build context must be the PARENT directory of peregrine/
#
# Used when circuitforge-core is installed from source (not PyPI).
# Both repos must be siblings on the build host:
# /devl/peregrine/ → WORKDIR /app
# /devl/circuitforge-core/ → installed to /circuitforge-core
#
# Build manually:
# docker build -f peregrine/Dockerfile.cfcore -t peregrine-cfcore ..
#
# Via compose (compose.test-cfcore.yml sets context: ..):
# docker compose -f compose.test-cfcore.yml build
FROM python:3.11-slim
WORKDIR /app
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev curl libsqlcipher-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy circuitforge-core and install it from the local path before requirements.txt.
# requirements.txt has a git+https:// fallback URL for CI (where circuitforge-core
# is not a sibling directory), but Docker always has the local copy available here.
COPY circuitforge-core/ /circuitforge-core/
RUN pip install --no-cache-dir /circuitforge-core
COPY peregrine/requirements.txt .
# Skip the cfcore line — already installed above from the local copy
RUN grep -v 'circuitforge-core' requirements.txt | pip install --no-cache-dir -r /dev/stdin
# Install Playwright browser (cached separately from Python deps so requirements
# changes don't bust the ~600900 MB Chromium layer and vice versa)
RUN playwright install chromium && playwright install-deps chromium
# Bundle companyScraper (company research web scraper)
COPY peregrine/scrapers/ /app/scrapers/
COPY peregrine/ .
EXPOSE 8501
CMD ["streamlit", "run", "app/app.py", \
"--server.port=8501", \
"--server.headless=true", \
"--server.fileWatcherType=none"]

View file

@ -154,7 +154,7 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
| Calendar sync (Google, Apple) | Paid |
| Slack notifications | Paid |
| CircuitForge shared cover-letter model | Paid |
| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
| Vue 3 SPA beta UI | Paid |
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
| Cover letter model fine-tuning (your writing, your model) | Premium |
| Multi-user support | Premium |

View file

@ -19,8 +19,8 @@ _profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \
purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \
get_task_for_job, get_active_tasks, insert_job, get_existing_urls
purge_non_remote, archive_jobs, kill_stuck_tasks, get_task_for_job, get_active_tasks, \
insert_job, get_existing_urls
from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path
@ -376,145 +376,178 @@ _scrape_status()
st.divider()
# ── Danger zone ───────────────────────────────────────────────────────────────
# ── Danger zone: purge + re-scrape ────────────────────────────────────────────
with st.expander("⚠️ Danger Zone", expanded=False):
# ── Queue reset (the common case) ─────────────────────────────────────────
st.markdown("**Queue reset**")
st.caption(
"Archive clears your review queue while keeping job URLs for dedup, "
"so the same listings won't resurface on the next discovery run. "
"Use hard purge only if you want a full clean slate including dedup history."
"**Purge** permanently deletes jobs from the local database. "
"Applied and synced jobs are never touched."
)
_scope = st.radio(
"Clear scope",
["Pending only", "Pending + approved (stale search)"],
horizontal=True,
label_visibility="collapsed",
)
_scope_statuses = (
["pending"] if _scope == "Pending only" else ["pending", "approved"]
)
purge_col, rescrape_col, email_col, tasks_col = st.columns(4)
_qc1, _qc2, _qc3 = st.columns([2, 2, 4])
if _qc1.button("📦 Archive & reset", use_container_width=True, type="primary"):
st.session_state["confirm_dz"] = "archive"
if _qc2.button("🗑 Hard purge (delete)", use_container_width=True):
st.session_state["confirm_dz"] = "purge"
with purge_col:
st.markdown("**Purge pending & rejected**")
st.caption("Removes all _pending_ and _rejected_ listings so the next discovery starts fresh.")
if st.button("🗑 Purge Pending + Rejected", use_container_width=True):
st.session_state["confirm_purge"] = "partial"
if st.session_state.get("confirm_dz") == "archive":
st.info(
f"Archive **{', '.join(_scope_statuses)}** jobs? "
"URLs are kept for dedup — nothing is permanently deleted."
)
_dc1, _dc2 = st.columns(2)
if _dc1.button("Yes, archive", type="primary", use_container_width=True, key="dz_archive_confirm"):
n = archive_jobs(get_db_path(), statuses=_scope_statuses)
st.success(f"Archived {n} jobs.")
st.session_state.pop("confirm_dz", None)
st.rerun()
if _dc2.button("Cancel", use_container_width=True, key="dz_archive_cancel"):
st.session_state.pop("confirm_dz", None)
st.rerun()
if st.session_state.get("confirm_dz") == "purge":
st.warning(
f"Permanently delete **{', '.join(_scope_statuses)}** jobs? "
"This removes the URLs from dedup history too. Cannot be undone."
)
_dc1, _dc2 = st.columns(2)
if _dc1.button("Yes, delete", type="primary", use_container_width=True, key="dz_purge_confirm"):
n = purge_jobs(get_db_path(), statuses=_scope_statuses)
st.success(f"Deleted {n} jobs.")
st.session_state.pop("confirm_dz", None)
st.rerun()
if _dc2.button("Cancel", use_container_width=True, key="dz_purge_cancel"):
st.session_state.pop("confirm_dz", None)
st.rerun()
st.divider()
# ── Background tasks ──────────────────────────────────────────────────────
_active = get_active_tasks(get_db_path())
st.markdown(f"**Background tasks** — {len(_active)} active")
if _active:
_task_icons = {"cover_letter": "✉️", "research": "🔍", "discovery": "🌐", "enrich_descriptions": "📝"}
for _t in _active:
_tc1, _tc2, _tc3 = st.columns([3, 4, 2])
_icon = _task_icons.get(_t["task_type"], "⚙️")
_tc1.caption(f"{_icon} `{_t['task_type']}`")
_job_label = f"{_t['title']} @ {_t['company']}" if _t.get("title") else f"job #{_t['job_id']}"
_tc2.caption(_job_label)
_tc3.caption(f"_{_t['status']}_")
if st.button("✕ Cancel", key=f"dz_cancel_task_{_t['id']}", use_container_width=True):
cancel_task(get_db_path(), _t["id"])
if st.session_state.get("confirm_purge") == "partial":
st.warning("Are you sure? This cannot be undone.")
c1, c2 = st.columns(2)
if c1.button("Yes, purge", type="primary", use_container_width=True):
deleted = purge_jobs(get_db_path(), statuses=["pending", "rejected"])
st.success(f"Purged {deleted} jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
st.caption("")
_kill_col, _ = st.columns([2, 6])
if _kill_col.button("⏹ Kill all stuck", use_container_width=True, disabled=len(_active) == 0):
killed = kill_stuck_tasks(get_db_path())
st.success(f"Killed {killed} task(s).")
st.rerun()
with email_col:
st.markdown("**Purge email data**")
st.caption("Clears all email thread logs and email-sourced pending jobs so the next sync starts fresh.")
if st.button("📧 Purge Email Data", use_container_width=True):
st.session_state["confirm_purge"] = "email"
if st.session_state.get("confirm_purge") == "email":
st.warning("This deletes all email contacts and email-sourced jobs. Cannot be undone.")
c1, c2 = st.columns(2)
if c1.button("Yes, purge emails", type="primary", use_container_width=True):
contacts, jobs = purge_email_data(get_db_path())
st.success(f"Purged {contacts} email contacts, {jobs} email jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
with tasks_col:
_active = get_active_tasks(get_db_path())
st.markdown("**Kill stuck tasks**")
st.caption(f"Force-fail all queued/running background tasks. Currently **{len(_active)}** active.")
if st.button("⏹ Kill All Tasks", use_container_width=True, disabled=len(_active) == 0):
killed = kill_stuck_tasks(get_db_path())
st.success(f"Killed {killed} task(s).")
st.rerun()
with rescrape_col:
st.markdown("**Purge all & re-scrape**")
st.caption("Wipes _all_ non-applied, non-synced jobs then immediately runs a fresh discovery.")
if st.button("🔄 Purge All + Re-scrape", use_container_width=True):
st.session_state["confirm_purge"] = "full"
if st.session_state.get("confirm_purge") == "full":
st.warning("This will delete ALL pending, approved, and rejected jobs, then re-scrape. Applied and synced records are kept.")
c1, c2 = st.columns(2)
if c1.button("Yes, wipe + scrape", type="primary", use_container_width=True):
purge_jobs(get_db_path(), statuses=["pending", "approved", "rejected"])
submit_task(get_db_path(), "discovery", 0)
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
st.divider()
# ── Rarely needed (collapsed) ─────────────────────────────────────────────
with st.expander("More options", expanded=False):
_rare1, _rare2, _rare3 = st.columns(3)
pending_col, nonremote_col, approved_col, _ = st.columns(4)
with _rare1:
st.markdown("**Purge email data**")
st.caption("Clears all email thread logs and email-sourced pending jobs.")
if st.button("📧 Purge Email Data", use_container_width=True):
st.session_state["confirm_dz"] = "email"
if st.session_state.get("confirm_dz") == "email":
st.warning("Deletes all email contacts and email-sourced jobs. Cannot be undone.")
_ec1, _ec2 = st.columns(2)
if _ec1.button("Yes, purge emails", type="primary", use_container_width=True, key="dz_email_confirm"):
contacts, jobs = purge_email_data(get_db_path())
st.success(f"Purged {contacts} email contacts, {jobs} email jobs.")
st.session_state.pop("confirm_dz", None)
st.rerun()
if _ec2.button("Cancel", use_container_width=True, key="dz_email_cancel"):
st.session_state.pop("confirm_dz", None)
st.rerun()
with pending_col:
st.markdown("**Purge pending review**")
st.caption("Removes only _pending_ listings, keeping your rejected history intact.")
if st.button("🗑 Purge Pending Only", use_container_width=True):
st.session_state["confirm_purge"] = "pending_only"
with _rare2:
st.markdown("**Purge non-remote**")
st.caption("Removes pending/approved/rejected on-site listings from the DB.")
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
st.session_state["confirm_dz"] = "non_remote"
if st.session_state.get("confirm_dz") == "non_remote":
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
_rc1, _rc2 = st.columns(2)
if _rc1.button("Yes, purge on-site", type="primary", use_container_width=True, key="dz_nonremote_confirm"):
deleted = purge_non_remote(get_db_path())
st.success(f"Purged {deleted} non-remote jobs.")
st.session_state.pop("confirm_dz", None)
st.rerun()
if _rc2.button("Cancel", use_container_width=True, key="dz_nonremote_cancel"):
st.session_state.pop("confirm_dz", None)
st.rerun()
if st.session_state.get("confirm_purge") == "pending_only":
st.warning("Deletes all pending jobs. Rejected jobs are kept. Cannot be undone.")
c1, c2 = st.columns(2)
if c1.button("Yes, purge pending", type="primary", use_container_width=True):
deleted = purge_jobs(get_db_path(), statuses=["pending"])
st.success(f"Purged {deleted} pending jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
with _rare3:
st.markdown("**Wipe all + re-scrape**")
st.caption("Deletes all non-applied jobs then immediately runs a fresh discovery.")
if st.button("🔄 Wipe + Re-scrape", use_container_width=True):
st.session_state["confirm_dz"] = "rescrape"
if st.session_state.get("confirm_dz") == "rescrape":
st.warning("Wipes ALL pending, approved, and rejected jobs, then re-scrapes. Applied and synced records are kept.")
_wc1, _wc2 = st.columns(2)
if _wc1.button("Yes, wipe + scrape", type="primary", use_container_width=True, key="dz_rescrape_confirm"):
purge_jobs(get_db_path(), statuses=["pending", "approved", "rejected"])
submit_task(get_db_path(), "discovery", 0)
st.session_state.pop("confirm_dz", None)
st.rerun()
if _wc2.button("Cancel", use_container_width=True, key="dz_rescrape_cancel"):
st.session_state.pop("confirm_dz", None)
st.rerun()
with nonremote_col:
st.markdown("**Purge non-remote**")
st.caption("Removes pending/approved/rejected jobs where remote is not set. Keeps anything already in the pipeline.")
if st.button("🏢 Purge On-site Jobs", use_container_width=True):
st.session_state["confirm_purge"] = "non_remote"
if st.session_state.get("confirm_purge") == "non_remote":
st.warning("Deletes all non-remote jobs not yet applied to. Cannot be undone.")
c1, c2 = st.columns(2)
if c1.button("Yes, purge on-site", type="primary", use_container_width=True):
deleted = purge_non_remote(get_db_path())
st.success(f"Purged {deleted} non-remote jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
with approved_col:
st.markdown("**Purge approved (unapplied)**")
st.caption("Removes _approved_ jobs you haven't applied to yet — e.g. to reset after a review pass.")
if st.button("🗑 Purge Approved", use_container_width=True):
st.session_state["confirm_purge"] = "approved_only"
if st.session_state.get("confirm_purge") == "approved_only":
st.warning("Deletes all approved-but-not-applied jobs. Cannot be undone.")
c1, c2 = st.columns(2)
if c1.button("Yes, purge approved", type="primary", use_container_width=True):
deleted = purge_jobs(get_db_path(), statuses=["approved"])
st.success(f"Purged {deleted} approved jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
st.divider()
archive_col1, archive_col2, _, _ = st.columns(4)
with archive_col1:
st.markdown("**Archive remaining**")
st.caption(
"Move all _pending_ and _rejected_ jobs to archived status. "
"Archived jobs stay in the DB for dedup — they just won't appear in Job Review."
)
if st.button("📦 Archive Pending + Rejected", use_container_width=True):
st.session_state["confirm_purge"] = "archive_remaining"
if st.session_state.get("confirm_purge") == "archive_remaining":
st.info("Jobs will be archived (not deleted) — URLs are kept for dedup.")
c1, c2 = st.columns(2)
if c1.button("Yes, archive", type="primary", use_container_width=True):
archived = archive_jobs(get_db_path(), statuses=["pending", "rejected"])
st.success(f"Archived {archived} jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
with archive_col2:
st.markdown("**Archive approved (unapplied)**")
st.caption("Archive _approved_ listings you decided to skip — keeps history without cluttering the apply queue.")
if st.button("📦 Archive Approved", use_container_width=True):
st.session_state["confirm_purge"] = "archive_approved"
if st.session_state.get("confirm_purge") == "archive_approved":
st.info("Approved jobs will be archived (not deleted).")
c1, c2 = st.columns(2)
if c1.button("Yes, archive approved", type="primary", use_container_width=True):
archived = archive_jobs(get_db_path(), statuses=["approved"])
st.success(f"Archived {archived} approved jobs.")
st.session_state.pop("confirm_purge", None)
st.rerun()
if c2.button("Cancel ", use_container_width=True):
st.session_state.pop("confirm_purge", None)
st.rerun()
# ── Setup banners ─────────────────────────────────────────────────────────────
if _profile and _profile.wizard_complete:

View file

@ -17,16 +17,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
logging.basicConfig(level=logging.WARNING, format="%(name)s %(levelname)s: %(message)s")
# Load .env before any os.environ reads — safe to call inside Docker too
# (uses setdefault, so Docker-injected vars take precedence over .env values)
from circuitforge_core.config.settings import load_env as _load_env
_load_env(Path(__file__).parent.parent / ".env")
IS_DEMO = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
import streamlit as st
from scripts.db import DEFAULT_DB, init_db, get_active_tasks
from scripts.db_migrate import migrate_db
from app.feedback import inject_feedback_button
from app.cloud_session import resolve_session, get_db_path, get_config_dir, get_cloud_tier
import sqlite3
@ -42,7 +36,6 @@ st.set_page_config(
resolve_session("peregrine")
init_db(get_db_path())
migrate_db(Path(get_db_path()))
# Demo tier — initialize once per session (cookie persistence handled client-side)
if IS_DEMO and "simulated_tier" not in st.session_state:

View file

@ -32,13 +32,6 @@ _DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
# Example: PEREGRINE_VUE_URL=http://localhost:8506
_VUE_URL = os.environ.get("PEREGRINE_VUE_URL", "").strip().rstrip("/")
# When True, a window.location.reload() after setting prgn_ui=vue will be
# intercepted by Caddy and routed to the Vue SPA. When False (no Caddy in the
# traffic path — e.g. test instances, direct Docker exposure), reloading just
# comes back to Streamlit and creates an infinite loop. Only set this in
# production/staging compose files where Caddy is actually in front.
_CADDY_PROXY = os.environ.get("PEREGRINE_CADDY_PROXY", "").lower() in ("1", "true", "yes")
_COOKIE_JS = """
<script>
(function() {{
@ -57,18 +50,14 @@ def _set_cookie_js(value: str, navigate: bool = False) -> None:
port. Without this, reload() just sends the request back to the same
Streamlit port with no router in between to inspect the cookie.
When PEREGRINE_CADDY_PROXY is set (production/staging): navigate=True
When PEREGRINE_VUE_URL is absent (Caddy deployment): navigate=True
triggers window.location.reload() so Caddy sees the updated cookie on
the next HTTP request and routes accordingly.
When neither is set (test instances, bare Docker): navigate is suppressed
entirely the cookie is written silently, but no reload is attempted.
Reloading without a proxy just bounces back to Streamlit and loops.
"""
# components.html() renders in an iframe — window.parent navigates the host page
if navigate and value == "vue" and _VUE_URL:
nav_js = f"window.parent.location.href = '{_VUE_URL}';"
elif navigate and _CADDY_PROXY:
elif navigate:
nav_js = "window.parent.location.reload();"
else:
nav_js = ""
@ -200,7 +189,7 @@ def render_banner(yaml_path: Path, tier: str) -> None:
col1, col2, col3 = st.columns([8, 1, 1])
with col1:
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta)")
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)")
with col2:
if st.button("Try it", key="_ui_banner_try"):
switch_ui(yaml_path, to="vue", tier=tier)

View file

@ -457,11 +457,6 @@ elif step == 5:
from app.wizard.step_inference import validate
st.subheader("Step 5 \u2014 Inference & API Keys")
st.info(
"**Simplest setup:** set `OLLAMA_HOST` in your `.env` file — "
"Peregrine auto-detects it, no config file needed. "
"Or use the fields below to configure API keys and endpoints."
)
profile = saved_yaml.get("inference_profile", "remote")
if profile == "remote":
@ -471,18 +466,8 @@ elif step == 5:
placeholder="https://api.together.xyz/v1")
openai_key = st.text_input("Endpoint API Key (optional)", type="password",
key="oai_key") if openai_url else ""
ollama_host = st.text_input("Ollama host (optional \u2014 local fallback)",
placeholder="http://localhost:11434",
key="ollama_host_input")
ollama_model = st.text_input("Ollama model (optional)",
value="llama3.2:3b",
key="ollama_model_input")
else:
st.info(f"Local mode ({profile}): Ollama provides inference.")
import os
_ollama_host_env = os.environ.get("OLLAMA_HOST", "")
if _ollama_host_env:
st.caption(f"OLLAMA_HOST from .env: `{_ollama_host_env}`")
anthropic_key = openai_url = openai_key = ""
with st.expander("Advanced \u2014 Service Ports & Hosts"):
@ -561,14 +546,6 @@ elif step == 5:
if anthropic_key or openai_url:
env_path.write_text("\n".join(env_lines) + "\n")
if profile == "remote":
if ollama_host:
env_lines = _set_env(env_lines, "OLLAMA_HOST", ollama_host)
if ollama_model:
env_lines = _set_env(env_lines, "OLLAMA_MODEL", ollama_model)
if ollama_host or ollama_model:
env_path.write_text("\n".join(env_lines) + "\n")
_save_yaml({"services": svc, "wizard_step": 5})
st.session_state.wizard_step = 6
st.rerun()
@ -654,7 +631,7 @@ elif step == 6:
)
default_profile = {
"name": "default",
"titles": titles,
"job_titles": titles,
"locations": locations,
"remote_only": False,
"boards": ["linkedin", "indeed", "glassdoor", "zip_recruiter"],

View file

@ -12,15 +12,12 @@ from scripts.db import (
DEFAULT_DB, init_db, get_jobs_by_status, update_job_status,
update_cover_letter, mark_applied, get_email_leads,
)
from app.cloud_session import resolve_session, get_db_path
resolve_session("peregrine")
st.title("📋 Job Review")
init_db(get_db_path())
init_db(DEFAULT_DB)
_email_leads = get_email_leads(get_db_path())
_email_leads = get_email_leads(DEFAULT_DB)
# ── Sidebar filters ────────────────────────────────────────────────────────────
with st.sidebar:
@ -40,7 +37,7 @@ with st.sidebar:
index=0,
)
jobs = get_jobs_by_status(get_db_path(), show_status)
jobs = get_jobs_by_status(DEFAULT_DB, show_status)
if remote_only:
jobs = [j for j in jobs if j.get("is_remote")]
@ -89,11 +86,11 @@ if show_status == "pending" and _email_leads:
with right_l:
if st.button("✅ Approve", key=f"el_approve_{lead_id}",
type="primary", use_container_width=True):
update_job_status(get_db_path(), [lead_id], "approved")
update_job_status(DEFAULT_DB, [lead_id], "approved")
st.rerun()
if st.button("❌ Reject", key=f"el_reject_{lead_id}",
use_container_width=True):
update_job_status(get_db_path(), [lead_id], "rejected")
update_job_status(DEFAULT_DB, [lead_id], "rejected")
st.rerun()
st.divider()
@ -165,7 +162,7 @@ for job in jobs:
)
save_col, _ = st.columns([2, 5])
if save_col.button("💾 Save draft", key=f"save_cl_{job_id}"):
update_cover_letter(get_db_path(), job_id, st.session_state[_cl_key])
update_cover_letter(DEFAULT_DB, job_id, st.session_state[_cl_key])
st.success("Saved!")
# Applied date + cover letter preview (applied/synced)
@ -185,11 +182,11 @@ for job in jobs:
if show_status == "pending":
if st.button("✅ Approve", key=f"approve_{job_id}",
type="primary", use_container_width=True):
update_job_status(get_db_path(), [job_id], "approved")
update_job_status(DEFAULT_DB, [job_id], "approved")
st.rerun()
if st.button("❌ Reject", key=f"reject_{job_id}",
use_container_width=True):
update_job_status(get_db_path(), [job_id], "rejected")
update_job_status(DEFAULT_DB, [job_id], "rejected")
st.rerun()
elif show_status == "approved":
@ -201,6 +198,6 @@ for job in jobs:
use_container_width=True):
cl_text = st.session_state.get(f"cl_{job_id}", "")
if cl_text:
update_cover_letter(get_db_path(), job_id, cl_text)
mark_applied(get_db_path(), [job_id])
update_cover_letter(DEFAULT_DB, job_id, cl_text)
mark_applied(DEFAULT_DB, [job_id])
st.rerun()

View file

@ -401,32 +401,22 @@ with tab_search:
with st.spinner("Asking LLM for suggestions…"):
try:
suggestions = _suggest_search_terms(_current_titles, RESUME_PATH, _blocklist, _user_profile)
except Exception as _e:
_err_msg = str(_e)
if "exhausted" in _err_msg.lower() or isinstance(_e, RuntimeError):
st.warning(
f"No LLM backend available: {_err_msg}. "
"Check that Ollama is running and has GPU access, or enable a cloud backend in Settings → System → LLM.",
icon="⚠️",
)
else:
st.error(f"Suggestion failed: {_err_msg}", icon="🚨")
except RuntimeError as _e:
st.warning(
f"No LLM backend available: {_e}. "
"Check that Ollama is running and has GPU access, or enable a cloud backend in Settings → System → LLM.",
icon="⚠️",
)
suggestions = None
if suggestions is not None:
# Add suggested titles to options list (not auto-selected — user picks from dropdown)
_opts = list(st.session_state.get("_sp_title_options", []))
_new_titles = [_t for _t in suggestions.get("suggested_titles", []) if _t not in _opts]
_opts.extend(_new_titles)
for _t in suggestions.get("suggested_titles", []):
if _t not in _opts:
_opts.append(_t)
st.session_state["_sp_title_options"] = _opts
st.session_state["_sp_suggestions"] = suggestions
if not _new_titles and not suggestions.get("suggested_excludes"):
_resume_hint = " Upload your resume in Settings → Resume Profile for better results." if not RESUME_PATH.exists() else ""
st.info(
f"No new suggestions found — the LLM didn't generate anything new for these titles.{_resume_hint}",
icon="",
)
else:
st.rerun()
st.rerun()
if st.session_state.get("_sp_suggestions"):
sugg = st.session_state["_sp_suggestions"]

View file

@ -15,28 +15,28 @@ import streamlit.components.v1 as components
import yaml
from scripts.user_profile import UserProfile
_USER_YAML = Path(__file__).parent.parent.parent / "config" / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
from scripts.db import (
DEFAULT_DB, init_db, get_jobs_by_status,
update_cover_letter, mark_applied, update_job_status,
get_task_for_job,
)
from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path, get_config_dir
from app.cloud_session import resolve_session, get_db_path
from app.telemetry import log_usage_event
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
RESUME_YAML = Path(__file__).parent.parent.parent / "config" / "plain_text_resume.yaml"
st.title("🚀 Apply Workspace")
resolve_session("peregrine")
init_db(get_db_path())
_CONFIG_DIR = get_config_dir()
_USER_YAML = _CONFIG_DIR / "user.yaml"
_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None
_name = _profile.name if _profile else "Job Seeker"
DOCS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "JobSearch"
RESUME_YAML = _CONFIG_DIR / "plain_text_resume.yaml"
# ── PDF generation ─────────────────────────────────────────────────────────────
def _make_cover_letter_pdf(job: dict, cover_letter: str, output_dir: Path) -> Path:
from reportlab.lib.pagesizes import letter

View file

@ -36,9 +36,6 @@ from scripts.db import (
get_unread_stage_signals, dismiss_stage_signal,
)
from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path
resolve_session("peregrine")
_CONFIG_DIR = Path(__file__).parent.parent.parent / "config"
_CALENDAR_INTEGRATIONS = ("apple_calendar", "google_calendar")
@ -49,23 +46,23 @@ _calendar_connected = any(
st.title("🎯 Interviews")
init_db(get_db_path())
init_db(DEFAULT_DB)
# ── Sidebar: Email sync ────────────────────────────────────────────────────────
with st.sidebar:
st.markdown("### 📧 Email Sync")
_email_task = get_task_for_job(get_db_path(), "email_sync", 0)
_email_task = get_task_for_job(DEFAULT_DB, "email_sync", 0)
_email_running = _email_task and _email_task["status"] in ("queued", "running")
if st.button("🔄 Sync Emails", use_container_width=True, type="primary",
disabled=bool(_email_running)):
submit_task(get_db_path(), "email_sync", 0)
submit_task(DEFAULT_DB, "email_sync", 0)
st.rerun()
if _email_running:
@st.fragment(run_every=4)
def _email_sidebar_status():
t = get_task_for_job(get_db_path(), "email_sync", 0)
t = get_task_for_job(DEFAULT_DB, "email_sync", 0)
if t and t["status"] in ("queued", "running"):
st.info("⏳ Syncing…")
else:
@ -102,7 +99,7 @@ STAGE_NEXT_LABEL = {
}
# ── Data ──────────────────────────────────────────────────────────────────────
jobs_by_stage = get_interview_jobs(get_db_path())
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _days_ago(date_str: str | None) -> str:
@ -123,8 +120,8 @@ def _days_ago(date_str: str | None) -> str:
def _research_modal(job: dict) -> None:
job_id = job["id"]
st.caption(f"**{job.get('company')}** — {job.get('title')}")
research = get_research(get_db_path(), job_id=job_id)
task = get_task_for_job(get_db_path(), "company_research", job_id)
research = get_research(DEFAULT_DB, job_id=job_id)
task = get_task_for_job(DEFAULT_DB, "company_research", job_id)
running = task and task["status"] in ("queued", "running")
if running:
@ -147,7 +144,7 @@ def _research_modal(job: dict) -> None:
"inaccuracies. SearXNG is now available — re-run to get verified facts."
)
if st.button("🔄 Re-run with live data", key=f"modal_rescrape_{job_id}", type="primary"):
submit_task(get_db_path(), "company_research", job_id)
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun()
st.divider()
else:
@ -163,14 +160,14 @@ def _research_modal(job: dict) -> None:
)
st.markdown(research["raw_output"])
if st.button("🔄 Refresh", key=f"modal_regen_{job_id}", disabled=bool(running)):
submit_task(get_db_path(), "company_research", job_id)
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun()
else:
st.info("No research brief yet.")
if task and task["status"] == "failed":
st.error(f"Last attempt failed: {task.get('error', '')}")
if st.button("🔬 Generate now", key=f"modal_gen_{job_id}"):
submit_task(get_db_path(), "company_research", job_id)
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun()
@ -178,7 +175,7 @@ def _research_modal(job: dict) -> None:
def _email_modal(job: dict) -> None:
job_id = job["id"]
st.caption(f"**{job.get('company')}** — {job.get('title')}")
contacts = get_contacts(get_db_path(), job_id=job_id)
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
if not contacts:
st.info("No emails logged yet. Use the form below to add one.")
@ -249,7 +246,7 @@ def _email_modal(job: dict) -> None:
body_text = st.text_area("Body / notes", height=80, key=f"body_modal_{job_id}")
if st.form_submit_button("📧 Save contact"):
add_contact(
get_db_path(), job_id=job_id,
DEFAULT_DB, job_id=job_id,
direction=direction, subject=subject,
from_addr=from_addr, body=body_text, received_at=recv_at,
)
@ -258,7 +255,7 @@ def _email_modal(job: dict) -> None:
def _render_card(job: dict, stage: str, compact: bool = False) -> None:
"""Render a single job card appropriate for the given stage."""
job_id = job["id"]
contacts = get_contacts(get_db_path(), job_id=job_id)
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
last_contact = contacts[-1] if contacts else None
with st.container(border=True):
@ -281,7 +278,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
format="YYYY-MM-DD",
)
if st.form_submit_button("📅 Save date"):
set_interview_date(get_db_path(), job_id=job_id, date_str=str(new_date))
set_interview_date(DEFAULT_DB, job_id=job_id, date_str=str(new_date))
st.success("Saved!")
st.rerun()
@ -291,7 +288,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
_cal_label = "🔄 Update Calendar" if _has_event else "📅 Add to Calendar"
if st.button(_cal_label, key=f"cal_push_{job_id}", use_container_width=True):
from scripts.calendar_push import push_interview_event
result = push_interview_event(get_db_path(), job_id=job_id, config_dir=_CONFIG_DIR)
result = push_interview_event(DEFAULT_DB, job_id=job_id, config_dir=_CONFIG_DIR)
if result["ok"]:
st.success(f"Event {'updated' if _has_event else 'added'} ({result['provider'].replace('_', ' ').title()})")
st.rerun()
@ -300,7 +297,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
if not compact:
if stage in ("applied", "phone_screen", "interviewing"):
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id)
if signals:
sig = signals[-1]
_SIGNAL_TO_STAGE = {
@ -321,23 +318,23 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
if sig["stage_signal"] == "rejected":
if b1.button("✗ Reject", key=f"sig_rej_{sig['id']}",
use_container_width=True):
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
dismiss_stage_signal(get_db_path(), sig["id"])
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
dismiss_stage_signal(DEFAULT_DB, sig["id"])
st.rerun(scope="app")
elif target_stage and b1.button(
f"{target_label}", key=f"sig_adv_{sig['id']}",
use_container_width=True, type="primary",
):
if target_stage == "phone_screen" and stage == "applied":
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
submit_task(get_db_path(), "company_research", job_id)
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
submit_task(DEFAULT_DB, "company_research", job_id)
elif target_stage:
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
dismiss_stage_signal(get_db_path(), sig["id"])
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
dismiss_stage_signal(DEFAULT_DB, sig["id"])
st.rerun(scope="app")
if b2.button("Dismiss", key=f"sig_dis_{sig['id']}",
use_container_width=True):
dismiss_stage_signal(get_db_path(), sig["id"])
dismiss_stage_signal(DEFAULT_DB, sig["id"])
st.rerun()
# Advance / Reject buttons
@ -349,16 +346,16 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
f"{next_label}", key=f"adv_{job_id}",
use_container_width=True, type="primary",
):
advance_to_stage(get_db_path(), job_id=job_id, stage=next_stage)
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=next_stage)
if next_stage == "phone_screen":
submit_task(get_db_path(), "company_research", job_id)
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun(scope="app") # full rerun — card must appear in new column
if c2.button(
"✗ Reject", key=f"rej_{job_id}",
use_container_width=True,
):
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
st.rerun() # fragment-scope rerun — card disappears without scroll-to-top
if job.get("url"):
@ -388,7 +385,7 @@ def _render_card(job: dict, stage: str, compact: bool = False) -> None:
@st.fragment
def _card_fragment(job_id: int, stage: str) -> None:
"""Re-fetches the job on each fragment rerun; renders nothing if moved/rejected."""
job = get_job_by_id(get_db_path(), job_id)
job = get_job_by_id(DEFAULT_DB, job_id)
if job is None or job.get("status") != stage:
return
_render_card(job, stage)
@ -397,11 +394,11 @@ def _card_fragment(job_id: int, stage: str) -> None:
@st.fragment
def _pre_kanban_row_fragment(job_id: int) -> None:
"""Pre-kanban compact row for applied and survey-stage jobs."""
job = get_job_by_id(get_db_path(), job_id)
job = get_job_by_id(DEFAULT_DB, job_id)
if job is None or job.get("status") not in ("applied", "survey"):
return
stage = job["status"]
contacts = get_contacts(get_db_path(), job_id=job_id)
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
last_contact = contacts[-1] if contacts else None
with st.container(border=True):
@ -417,7 +414,7 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
_email_modal(job)
# Stage signal hint (email-detected next steps)
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
signals = get_unread_stage_signals(DEFAULT_DB, job_id=job_id)
if signals:
sig = signals[-1]
_SIGNAL_TO_STAGE = {
@ -440,15 +437,15 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
use_container_width=True, type="primary",
):
if target_stage == "phone_screen":
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
submit_task(get_db_path(), "company_research", job_id)
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
submit_task(DEFAULT_DB, "company_research", job_id)
else:
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
dismiss_stage_signal(get_db_path(), sig["id"])
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
dismiss_stage_signal(DEFAULT_DB, sig["id"])
st.rerun(scope="app")
if s2.button("Dismiss", key=f"sig_dis_pre_{sig['id']}",
use_container_width=True):
dismiss_stage_signal(get_db_path(), sig["id"])
dismiss_stage_signal(DEFAULT_DB, sig["id"])
st.rerun()
with right:
@ -456,24 +453,24 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
"→ 📞 Phone Screen", key=f"adv_pre_{job_id}",
use_container_width=True, type="primary",
):
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
submit_task(get_db_path(), "company_research", job_id)
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
submit_task(DEFAULT_DB, "company_research", job_id)
st.rerun(scope="app")
col_a, col_b = st.columns(2)
if stage == "applied" and col_a.button(
"📋 Survey", key=f"to_survey_{job_id}", use_container_width=True,
):
advance_to_stage(get_db_path(), job_id=job_id, stage="survey")
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="survey")
st.rerun(scope="app")
if col_b.button("✗ Reject", key=f"rej_pre_{job_id}", use_container_width=True):
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
reject_at_stage(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
st.rerun()
@st.fragment
def _hired_card_fragment(job_id: int) -> None:
"""Compact hired job card — shown in the Offer/Hired column."""
job = get_job_by_id(get_db_path(), job_id)
job = get_job_by_id(DEFAULT_DB, job_id)
if job is None or job.get("status") != "hired":
return
with st.container(border=True):

View file

@ -25,14 +25,11 @@ from scripts.db import (
get_task_for_job,
)
from scripts.task_runner import submit_task
from app.cloud_session import resolve_session, get_db_path
resolve_session("peregrine")
init_db(get_db_path())
init_db(DEFAULT_DB)
# ── Job selection ─────────────────────────────────────────────────────────────
jobs_by_stage = get_interview_jobs(get_db_path())
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
active_stages = ["phone_screen", "interviewing", "offer"]
active_jobs = [
j for stage in active_stages
@ -103,10 +100,10 @@ col_prep, col_context = st.columns([2, 3])
# ════════════════════════════════════════════════
with col_prep:
research = get_research(get_db_path(), job_id=selected_id)
research = get_research(DEFAULT_DB, job_id=selected_id)
# Refresh / generate research
_res_task = get_task_for_job(get_db_path(), "company_research", selected_id)
_res_task = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
_res_running = _res_task and _res_task["status"] in ("queued", "running")
if not research:
@ -115,13 +112,13 @@ with col_prep:
if _res_task and _res_task["status"] == "failed":
st.error(f"Last attempt failed: {_res_task.get('error', '')}")
if st.button("🔬 Generate research brief", type="primary", use_container_width=True):
submit_task(get_db_path(), "company_research", selected_id)
submit_task(DEFAULT_DB, "company_research", selected_id)
st.rerun()
if _res_running:
@st.fragment(run_every=3)
def _res_status_initial():
t = get_task_for_job(get_db_path(), "company_research", selected_id)
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
if t and t["status"] in ("queued", "running"):
stage = t.get("stage") or ""
lbl = "Queued…" if t["status"] == "queued" else (stage or "Generating… this may take 3060 seconds")
@ -136,13 +133,13 @@ with col_prep:
col_ts, col_btn = st.columns([3, 1])
col_ts.caption(f"Research generated: {generated_at}")
if col_btn.button("🔄 Refresh", use_container_width=True, disabled=bool(_res_running)):
submit_task(get_db_path(), "company_research", selected_id)
submit_task(DEFAULT_DB, "company_research", selected_id)
st.rerun()
if _res_running:
@st.fragment(run_every=3)
def _res_status_refresh():
t = get_task_for_job(get_db_path(), "company_research", selected_id)
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
if t and t["status"] in ("queued", "running"):
stage = t.get("stage") or ""
lbl = "Queued…" if t["status"] == "queued" else (stage or "Refreshing research…")
@ -314,7 +311,7 @@ with col_context:
st.markdown(job.get("description") or "_No description saved for this listing._")
with tab_emails:
contacts = get_contacts(get_db_path(), job_id=selected_id)
contacts = get_contacts(DEFAULT_DB, job_id=selected_id)
if not contacts:
st.info("No contacts logged yet. Use the Interviews page to log emails.")
else:

View file

@ -22,13 +22,10 @@ from scripts.db import (
insert_survey_response, get_survey_responses,
)
from scripts.llm_router import LLMRouter
from app.cloud_session import resolve_session, get_db_path
resolve_session("peregrine")
st.title("📋 Survey Assistant")
init_db(get_db_path())
init_db(DEFAULT_DB)
# ── Vision service health check ────────────────────────────────────────────────
@ -43,7 +40,7 @@ def _vision_available() -> bool:
vision_up = _vision_available()
# ── Job selector ───────────────────────────────────────────────────────────────
jobs_by_stage = get_interview_jobs(get_db_path())
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
survey_jobs = jobs_by_stage.get("survey", [])
other_jobs = (
jobs_by_stage.get("applied", []) +
@ -64,7 +61,7 @@ selected_job_id = st.selectbox(
format_func=lambda jid: job_labels[jid],
index=0,
)
selected_job = get_job_by_id(get_db_path(), selected_job_id)
selected_job = get_job_by_id(DEFAULT_DB, selected_job_id)
# ── LLM prompt builders ────────────────────────────────────────────────────────
_SURVEY_SYSTEM = (
@ -239,7 +236,7 @@ with right_col:
image_path = str(img_file)
insert_survey_response(
get_db_path(),
DEFAULT_DB,
job_id=selected_job_id,
survey_name=survey_name,
source=source,
@ -259,7 +256,7 @@ with right_col:
# ── History ────────────────────────────────────────────────────────────────────
st.divider()
st.subheader("📂 Response History")
history = get_survey_responses(get_db_path(), job_id=selected_job_id)
history = get_survey_responses(DEFAULT_DB, job_id=selected_job_id)
if not history:
st.caption("No saved responses for this job yet.")

View file

@ -64,8 +64,8 @@ FEATURES: dict[str, str] = {
"apple_calendar_sync": "paid",
"slack_notifications": "paid",
# Beta UI access — open to all tiers (access management, not compute)
"vue_ui_beta": "free",
# Beta UI access — stays gated (access management, not compute)
"vue_ui_beta": "paid",
}
# Features that unlock when the user supplies any LLM backend (local or BYOK).

View file

@ -13,15 +13,12 @@
services:
app:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
build: .
container_name: peregrine-cloud
ports:
- "8505:8501"
volumes:
- /devl/menagerie-data:/devl/menagerie-data # per-user data trees
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro # cloud-safe backends only (no claude_code/copilot/anthropic)
environment:
- CLOUD_MODE=true
- CLOUD_DATA_ROOT=/devl/menagerie-data
@ -34,10 +31,7 @@ services:
- DOCS_DIR=/tmp/cloud-docs
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
- PYTHONUNBUFFERED=1
- PEREGRINE_CADDY_PROXY=1
- CF_ORCH_URL=http://host.docker.internal:7700
- DEMO_MODE=false
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
depends_on:
searxng:
condition: service_healthy
@ -45,40 +39,12 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
api:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
volumes:
- /devl/menagerie-data:/devl/menagerie-data
- ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
environment:
- CLOUD_MODE=true
- CLOUD_DATA_ROOT=/devl/menagerie-data
- STAGING_DB=/devl/menagerie-data/cloud-default.db
- DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET}
- CF_SERVER_SECRET=${CF_SERVER_SECRET}
- PLATFORM_DB_URL=${PLATFORM_DB_URL}
- HEIMDALL_URL=${HEIMDALL_URL:-http://cf-license:8000}
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
- PYTHONUNBUFFERED=1
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
web:
build:
context: .
dockerfile: docker/web/Dockerfile
args:
VITE_BASE_PATH: /peregrine/
ports:
- "8508:80"
depends_on:
- api
restart: unless-stopped
searxng:

View file

@ -42,8 +42,6 @@ services:
build:
context: .
dockerfile: docker/web/Dockerfile
args:
VITE_BASE_PATH: /peregrine/
ports:
- "8507:80"
restart: unless-stopped

View file

@ -1,35 +0,0 @@
# compose.test-cfcore.yml — single-user test instance for circuitforge-core integration
#
# Run from the PARENT directory of peregrine/ (the build context must include
# both peregrine/ and circuitforge-core/ as siblings):
#
# cd /devl (or /Library/Development/CircuitForge on dev)
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test up -d
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test logs -f
# docker compose -f peregrine/compose.test-cfcore.yml --project-name peregrine-test down
#
# UI: http://localhost:8516
# Purpose: smoke-test circuitforge-core shims (db, llm_router, tiers, task_scheduler)
# before promoting cfcore integration to the production cloud instance.
services:
app:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
container_name: peregrine-test-cfcore
ports:
- "8516:8501"
volumes:
- /devl/job-seeker:/devl/job-seeker
- /devl/job-seeker/config:/app/config
- /devl/job-seeker/config/llm.docker.yaml:/app/config/llm.yaml:ro
- /devl/job-seeker/config/user.docker.yaml:/app/config/user.yaml:ro
environment:
- STAGING_DB=/devl/job-seeker/staging.db
- PYTHONUNBUFFERED=1
- STREAMLIT_SERVER_BASE_URL_PATH=
- CF_ORCH_URL=http://host.docker.internal:7700
extra_hosts:
- "host.docker.internal:host-gateway"
restart: "no"

View file

@ -1,11 +1,9 @@
# compose.yml — Peregrine by Circuit Forge LLC
# Profiles: remote | cpu | single-gpu | dual-gpu-ollama
# Profiles: remote | cpu | single-gpu | dual-gpu-ollama | dual-gpu-vllm | dual-gpu-mixed
services:
app:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
build: .
command: >
bash -c "streamlit run app/app.py
--server.port=8501
@ -35,7 +33,6 @@ services:
- FORGEJO_API_URL=${FORGEJO_API_URL:-}
- PYTHONUNBUFFERED=1
- PYTHONLOGGING=WARNING
- PEREGRINE_CADDY_PROXY=1
depends_on:
searxng:
condition: service_healthy
@ -43,37 +40,12 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
api:
build:
context: ..
dockerfile: peregrine/Dockerfile.cfcore
command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
volumes:
- ./config:/app/config
- ./data:/app/data
- ${DOCS_DIR:-~/Documents/JobSearch}:/docs
environment:
- STAGING_DB=/app/data/staging.db
- DOCS_DIR=/docs
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- OPENAI_COMPAT_URL=${OPENAI_COMPAT_URL:-}
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
- PYTHONUNBUFFERED=1
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
web:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "${VUE_PORT:-8506}:80"
depends_on:
- api
restart: unless-stopped
searxng:
@ -129,6 +101,23 @@ services:
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped
vllm:
image: vllm/vllm-openai:latest
ports:
- "${VLLM_PORT:-8000}:8000"
volumes:
- ${VLLM_MODELS_DIR:-~/models/vllm}:/models
command: >
--model /models/${VLLM_MODEL:-Ouro-1.4B}
--trust-remote-code
--max-model-len 4096
--gpu-memory-utilization 0.75
--enforce-eager
--max-num-seqs 8
--cpu-offload-gb ${CPU_OFFLOAD_GB:-0}
profiles: [dual-gpu-vllm, dual-gpu-mixed]
restart: unless-stopped
finetune:
build:
context: .

View file

@ -1,62 +0,0 @@
backends:
anthropic:
api_key_env: ANTHROPIC_API_KEY
enabled: false
model: claude-sonnet-4-6
supports_images: true
type: anthropic
claude_code:
api_key: any
base_url: http://localhost:3009/v1
enabled: false
model: claude-code-terminal
supports_images: true
type: openai_compat
github_copilot:
api_key: any
base_url: http://localhost:3010/v1
enabled: false
model: gpt-4o
supports_images: false
type: openai_compat
ollama:
api_key: ollama
base_url: http://host.docker.internal:11434/v1
enabled: true
model: llama3.1:8b # generic — no personal fine-tunes in cloud
supports_images: false
type: openai_compat
ollama_research:
api_key: ollama
base_url: http://host.docker.internal:11434/v1
enabled: true
model: llama3.1:8b
supports_images: false
type: openai_compat
vision_service:
base_url: http://host.docker.internal:8002
enabled: true
supports_images: true
type: vision_service
vllm:
api_key: ''
base_url: http://host.docker.internal:8000/v1
enabled: true
model: __auto__
supports_images: false
type: openai_compat
vllm_research:
api_key: ''
base_url: http://host.docker.internal:8000/v1
enabled: true
model: __auto__
supports_images: false
type: openai_compat
fallback_order:
- vllm
- ollama
research_fallback_order:
- vllm_research
- ollama_research
vision_fallback_order:
- vision_service

View file

@ -28,9 +28,9 @@ backends:
type: openai_compat
ollama_research:
api_key: ollama
base_url: http://ollama_research:11434/v1
base_url: http://host.docker.internal:11434/v1
enabled: true
model: llama3.1:8b
model: llama3.2:3b
supports_images: false
type: openai_compat
vision_service:
@ -45,11 +45,6 @@ backends:
model: __auto__
supports_images: false
type: openai_compat
cf_orch:
service: vllm
model_candidates:
- Qwen2.5-3B-Instruct
ttl_s: 300
vllm_research:
api_key: ''
base_url: http://host.docker.internal:8000/v1

2793
dev-api.py

File diff suppressed because it is too large Load diff

View file

@ -1 +0,0 @@
dev-api.py

View file

@ -4,8 +4,6 @@ WORKDIR /app
COPY web/package*.json ./
RUN npm ci --prefer-offline
COPY web/ ./
ARG VITE_BASE_PATH=/
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
RUN npm run build
# Stage 2: serve

View file

@ -2,18 +2,12 @@ server {
listen 80;
server_name _;
client_max_body_size 20m;
root /usr/share/nginx/html;
index index.html;
# Proxy API calls to the FastAPI backend service
location /api/ {
proxy_pass http://api:8601;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
@ -21,9 +15,4 @@ server {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback must come after API and assets
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -102,23 +102,6 @@ Before opening a pull request:
---
## Database Migrations
Peregrine uses a numbered SQL migration system (Rails-style). Each migration is a `.sql` file in the `migrations/` directory at the repo root, named `NNN_description.sql` (e.g. `002_add_foo_column.sql`). Applied migrations are tracked in a `schema_migrations` table in each user database.
### Adding a migration
1. Create `migrations/NNN_description.sql` where `NNN` is the next sequential number (zero-padded to 3 digits).
2. Write standard SQL — `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ADD COLUMN`, etc. Keep each migration idempotent where possible.
3. Do **not** modify `scripts/db.py`'s legacy `_MIGRATIONS` lists — those are superseded and will be removed once all active databases have been bootstrapped by the migration runner.
4. The runner (`scripts/db_migrate.py`) applies pending migrations at startup automatically (both FastAPI and Streamlit paths call `migrate_db(db_path)`).
### Rollbacks
SQLite does not support transactional DDL for all statement types. Write forward-only migrations. If you need to undo a schema change, add a new migration that reverses it.
---
## What NOT to Do
- Do not commit `config/user.yaml`, `config/notion.yaml`, `config/email.yaml`, `config/adzuna.yaml`, or any `config/integrations/*.yaml` — all are gitignored

View file

@ -1,174 +0,0 @@
# Peregrine Vue 3 SPA Migration
**Branch:** `feature/vue-spa`
**Issue:** #8 — Vue 3 SPA frontend (Paid Tier GA milestone)
**Worktree:** `.worktrees/feature-vue-spa/`
**Reference:** `avocet/docs/vue-port-gotchas.md` (15 battle-tested gotchas)
---
## What We're Replacing
The current Streamlit UI (`app/app.py` + `app/pages/`) is an internal tool built for speed of development. The Vue SPA replaces it with a proper frontend — faster, more accessible, and extensible for the Paid Tier. The FastAPI already exists (partially, from the cloud managed instance work); the Vue SPA will consume it.
### Pages to Port
| Streamlit file | Vue view | Route | Notes |
|---|---|---|---|
| `app/Home.py` | `HomeView.vue` | `/` | Dashboard, discovery trigger, sync status |
| `app/pages/1_Job_Review.py` | `JobReviewView.vue` | `/review` | Batch approve/reject; primary daily-driver view |
| `app/pages/4_Apply.py` | `ApplyView.vue` | `/apply` | Cover letter gen + PDF + mark applied |
| `app/pages/5_Interviews.py` | `InterviewsView.vue` | `/interviews` | Kanban: phone_screen → offer → hired |
| `app/pages/6_Interview_Prep.py` | `InterviewPrepView.vue` | `/prep` | Live reference sheet + practice Q&A |
| `app/pages/7_Survey.py` | `SurveyView.vue` | `/survey` | Culture-fit survey assist + screenshot |
| `app/pages/2_Settings.py` | `SettingsView.vue` | `/settings` | 6 tabs: Profile, Resume, Search, System, Fine-Tune, License |
---
## Avocet Lessons Applied — What We Fixed Before Starting
The avocet SPA was the testbed. These bugs were found and fixed there; Peregrine's scaffold already incorporates all fixes. See `avocet/docs/vue-port-gotchas.md` for the full writeup.
### Applied at scaffold level (baked in — you don't need to think about these)
| # | Gotcha | How it's fixed in this scaffold |
|---|--------|----------------------------------|
| 1 | `id="app"` on App.vue root → nested `#app` elements, broken CSS specificity | `App.vue` root uses `class="app-root"`. `#app` in `index.html` is mount target only. |
| 3 | `overflow-x: hidden` on html → creates scroll container → 15px scrollbar jitter on Linux | `peregrine.css`: `html { overflow-x: clip }` |
| 4 | UnoCSS `presetAttributify` generates CSS for bare attribute names like `h2` | `uno.config.ts`: `presetAttributify({ prefix: 'un-', prefixedOnly: true })` |
| 5 | Theme variable name mismatches cause dark mode to silently fall back to hardcoded colors | `peregrine.css` alias map: `--color-bg → var(--color-surface)`, `--color-text-secondary → var(--color-text-muted)` |
| 7 | SPA cache: browser caches `index.html` indefinitely → old asset hashes → 404 on rebuild | FastAPI must register explicit `GET /` with no-cache headers before `StaticFiles` mount (see FastAPI section below) |
| 9 | `navigator.vibrate()` not supported on desktop/Safari — throws on call | `useHaptics.ts` guards with `'vibrate' in navigator` |
| 10 | Pinia options store = Vue 2 migration path | All stores use setup store form: `defineStore('id', () => { ... })` |
| 12 | `matchMedia`, `vibrate`, `ResizeObserver` absent in jsdom → composable tests throw | `test-setup.ts` stubs all three |
| 13 | `100vh` ignores mobile browser chrome | `App.vue`: `min-height: 100dvh` |
### Must actively avoid when writing new components
| # | Gotcha | Rule |
|---|--------|------|
| 2 | `transition: all` + spring easing → every CSS property bounces → layout explosion | Always enumerate: `transition: background 200ms ease, transform 250ms cubic-bezier(...)` |
| 6 | Keyboard composables called with snapshot arrays → keys don't work after async data loads | Accept `getLabels: () => labels.value` (reactive getter), not `labels: []` (snapshot) |
| 8 | Font reflow at ~780ms shifts layout measurements taken in `onMounted` | Measure layout in `document.fonts.ready` promise or after 1s timeout |
| 11 | `useSwipe` from `@vueuse/core` fires on desktop trackpad pointer events, not just touch | Add `pointer-type === 'touch'` guard if you need touch-only behavior |
| 14 | Rebuild workflow confusion | `cd web && npm run build` → refresh browser. Only restart FastAPI if `app/api.py` changed. |
| 15 | `:global(ancestor) .descendant` in `<style scoped>` → Vue drops the descendant entirely | Never use `:global(X) .Y` in scoped CSS. Use JS gate or CSS custom property token. |
---
## FastAPI Integration
### SPA serving (gotcha #7)
When the Vue SPA is built, FastAPI needs to serve it. Register the explicit `/` route **before** the `StaticFiles` mount, otherwise `index.html` gets cached and old asset hashes cause 404s after rebuild:
```python
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
_DIST = Path(__file__).parent.parent / "web" / "dist"
_NO_CACHE = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
}
@app.get("/")
def spa_root():
return FileResponse(_DIST / "index.html", headers=_NO_CACHE)
# Must come after the explicit route above
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa")
```
Hashed assets (`/assets/index-abc123.js`) can be cached aggressively — their filenames change with content. Only `index.html` needs no-cache.
### API prefix
Vue Router uses HTML5 history mode. All `/api/*` routes must be registered on FastAPI before the `StaticFiles` mount. Vue routes (`/`, `/review`, `/apply`, etc.) are handled client-side; FastAPI's `html=True` on `StaticFiles` serves `index.html` for any unmatched path.
---
## Peregrine-Specific Considerations
### Auth & license gating
The Streamlit UI uses `app/wizard/tiers.py` for tier gating. In the Vue SPA, tier state should be fetched from a `GET /api/license/status` endpoint on mount and stored in a Pinia store. Components check `licenseStore.tier` to gate features.
### Discovery trigger
The "Start Discovery" button on Home triggers `python scripts/discover.py` as a background process. The Vue version should use SSE (same pattern as avocet's finetune SSE) to stream progress back in real-time. The `useApiSSE` composable is already wired for this.
### Job Review — card stack UX
This is the daily-driver view. Consider the avocet ASMR bucket pattern here — approve/reject could transform into buckets on drag pickup. The motion tokens (`--transition-spring`, `--transition-dismiss`) are pre-defined in `peregrine.css`. The `useHaptics` composable is ready.
### Kanban (Interviews view)
The drag-to-column kanban is a strong candidate for `@vueuse/core`'s `useDraggable`. Watch for the `useSwipe` gotcha #11 — use pointer-type guards if drag behavior differs between touch and mouse.
### Settings — 6 tabs
Use a tab component with reactive route query params (`/settings?tab=license`) so direct links work and the page is shareable/bookmarkable.
---
## Build & Dev Workflow
```bash
# From worktree root
cd web
npm install # first time only
npm run dev # Vite dev server at :5173 (proxies /api/* to FastAPI at :8502)
npm run build # output to web/dist/
npm run test # Vitest unit tests
```
FastAPI serves the built `dist/` on the main port. During dev, configure Vite to proxy `/api` to the running FastAPI:
```ts
// vite.config.ts addition for dev proxy
server: {
proxy: {
'/api': 'http://localhost:8502',
}
}
```
After `npm run build`, just refresh the browser — no FastAPI restart needed unless `app/api.py` changed (gotcha #14).
---
## Implementation Order
Suggested sequence — validate the full stack before porting complex pages:
1. **FastAPI SPA endpoint** — serve `web/dist/` with correct cache headers
2. **App shell** — nav, routing, hacker mode, motion toggle work end-to-end
3. **Home view** — dashboard widgets, discovery trigger with SSE progress
4. **Job Review** — most-used view; gets the most polish
5. **Settings** — license tab is the blocker for tier gating in other views
6. **Apply Workspace** — cover letter gen + PDF export
7. **Interviews kanban** — drag-to-column + calendar sync
8. **Interview Prep** — reference sheet, practice Q&A
9. **Survey Assistant** — screenshot + text paste
---
## Checklist
Copy of the avocet gotchas checklist (all pre-applied at scaffold level are checked):
- [x] App.vue root element: use `.app-root` class, NOT `id="app"`
- [ ] No `transition: all` with spring easings — enumerate properties explicitly
- [ ] No `:global(ancestor) .descendant` in scoped CSS — Vue drops the descendant
- [x] `overflow-x: clip` on html, `overflow-x: hidden` on body
- [x] UnoCSS `presetAttributify`: `prefixedOnly: true`
- [x] Product CSS aliases: `--color-bg`, `--color-text-secondary` mapped in `peregrine.css`
- [ ] Keyboard composables: accept reactive getters, not snapshot arrays
- [x] FastAPI SPA serving pattern documented — apply when wiring FastAPI
- [ ] Font reflow: measure layout after `document.fonts.ready` or 1s timeout
- [x] Haptics: guard `navigator.vibrate` with feature detection
- [x] Pinia: use setup store form (function syntax)
- [x] Tests: mock matchMedia, vibrate, ResizeObserver in test-setup.ts
- [x] `min-height: 100dvh` on full-height layout containers

View file

@ -1,97 +0,0 @@
-- Migration 001: Baseline schema
-- Captures the full schema as of v0.8.5 (all columns including those added via ALTER TABLE)
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
company TEXT,
url TEXT UNIQUE,
source TEXT,
location TEXT,
is_remote INTEGER DEFAULT 0,
salary TEXT,
description TEXT,
match_score REAL,
keyword_gaps TEXT,
date_found TEXT,
status TEXT DEFAULT 'pending',
notion_page_id TEXT,
cover_letter TEXT,
applied_at TEXT,
interview_date TEXT,
rejection_stage TEXT,
phone_screen_at TEXT,
interviewing_at TEXT,
offer_at TEXT,
hired_at TEXT,
survey_at TEXT,
calendar_event_id TEXT,
optimized_resume TEXT,
ats_gap_report TEXT
);
CREATE TABLE IF NOT EXISTS job_contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
direction TEXT,
subject TEXT,
from_addr TEXT,
to_addr TEXT,
body TEXT,
received_at TEXT,
is_response_needed INTEGER DEFAULT 0,
responded_at TEXT,
message_id TEXT,
stage_signal TEXT,
suggestion_dismissed INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS company_research (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER UNIQUE,
generated_at TEXT,
company_brief TEXT,
ceo_brief TEXT,
talking_points TEXT,
raw_output TEXT,
tech_brief TEXT,
funding_brief TEXT,
competitors_brief TEXT,
red_flags TEXT,
scrape_used INTEGER DEFAULT 0,
accessibility_brief TEXT
);
CREATE TABLE IF NOT EXISTS background_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT,
job_id INTEGER,
params TEXT,
status TEXT DEFAULT 'pending',
error TEXT,
created_at TEXT,
started_at TEXT,
finished_at TEXT,
stage TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS survey_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
survey_name TEXT,
received_at TEXT,
source TEXT,
raw_input TEXT,
image_path TEXT,
mode TEXT,
llm_output TEXT,
reported_score REAL,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS digest_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_contact_id INTEGER UNIQUE,
created_at TEXT
);

View file

@ -3,13 +3,7 @@
# Keep in sync with environment.yml
# ── CircuitForge shared core ───────────────────────────────────────────────
# Requires circuitforge-core >= 0.8.0 (config.load_env, db, tasks; resources moved to circuitforge-orch).
# Local dev / Docker (parent-context build): path install works because
# circuitforge-core/ is a sibling directory.
# CI / fresh checkouts: falls back to the Forgejo VCS URL below.
# To use local editable install run: pip install -e ../circuitforge-core
# TODO: pin to @v0.7.0 tag once cf-core cuts a release tag.
git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
-e ../circuitforge-core
# ── Web UI ────────────────────────────────────────────────────────────────
streamlit>=1.35

View file

@ -1,198 +0,0 @@
"""
Credential store abstraction for Peregrine.
Backends (set via CREDENTIAL_BACKEND env var):
auto try keyring, fall back to file (default)
keyring python-keyring (OS Keychain / SecretService / libsecret)
file Fernet-encrypted JSON in config/credentials/ (key at config/.credential_key)
Env var references:
Any stored value matching ${VAR_NAME} is resolved from os.environ at read time.
Users can store "${IMAP_PASSWORD}" as the credential value; it is never treated
as the actual secret only the env var it points to is used.
"""
import os
import re
import json
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_ENV_REF = re.compile(r'^\$\{([A-Z_][A-Z0-9_]*)\}$')
_PROJECT_ROOT = Path(__file__).parent.parent
CRED_DIR = _PROJECT_ROOT / "config" / "credentials"
KEY_PATH = _PROJECT_ROOT / "config" / ".credential_key"
def _resolve_env_ref(value: str) -> Optional[str]:
"""If value is ${VAR_NAME}, return os.environ[VAR_NAME]; otherwise return None."""
m = _ENV_REF.match(value)
if m:
resolved = os.environ.get(m.group(1))
if resolved is None:
logger.warning("Credential reference %s is set but env var is not defined", value)
return resolved
return None
def _get_backend() -> str:
backend = os.environ.get("CREDENTIAL_BACKEND", "auto").lower()
if backend != "auto":
return backend
# Auto: try keyring, fall back to file
try:
import keyring
kr = keyring.get_keyring()
# Reject the null/fail keyring — it can't actually store anything
if "fail" in type(kr).__name__.lower() or "null" in type(kr).__name__.lower():
raise RuntimeError("No usable keyring backend found")
return "keyring"
except Exception:
return "file"
def _get_fernet():
"""Return a Fernet instance, auto-generating the key on first use."""
try:
from cryptography.fernet import Fernet
except ImportError:
return None
if KEY_PATH.exists():
key = KEY_PATH.read_bytes().strip()
else:
key = Fernet.generate_key()
KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(KEY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key)
logger.info("Generated new credential encryption key at %s", KEY_PATH)
return Fernet(key)
def _file_read(service: str) -> dict:
"""Read the credentials file for a service, decrypting if possible."""
cred_file = CRED_DIR / f"{service}.json"
if not cred_file.exists():
return {}
raw = cred_file.read_bytes()
fernet = _get_fernet()
if fernet:
try:
return json.loads(fernet.decrypt(raw))
except Exception:
# May be an older plaintext file — try reading as text
try:
return json.loads(raw.decode())
except Exception:
logger.error("Failed to read credentials for service %s", service)
return {}
else:
try:
return json.loads(raw.decode())
except Exception:
return {}
def _file_write(service: str, data: dict) -> None:
"""Write the credentials file for a service, encrypting if possible."""
CRED_DIR.mkdir(parents=True, exist_ok=True)
cred_file = CRED_DIR / f"{service}.json"
fernet = _get_fernet()
if fernet:
content = fernet.encrypt(json.dumps(data).encode())
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
else:
logger.warning(
"cryptography package not installed — storing credentials as plaintext with chmod 600. "
"Install with: pip install cryptography"
)
content = json.dumps(data).encode()
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
def get_credential(service: str, key: str) -> Optional[str]:
"""
Retrieve a credential. If the stored value is an env var reference (${VAR}),
resolves it from os.environ at call time.
"""
backend = _get_backend()
raw: Optional[str] = None
if backend == "keyring":
try:
import keyring
raw = keyring.get_password(service, key)
except Exception as e:
logger.error("keyring get failed for %s/%s: %s", service, key, e)
else: # file
data = _file_read(service)
raw = data.get(key)
if raw is None:
return None
# Resolve env var references transparently
resolved = _resolve_env_ref(raw)
if resolved is not None:
return resolved
if _ENV_REF.match(raw):
return None # reference defined but env var not set
return raw
def set_credential(service: str, key: str, value: str) -> None:
"""
Store a credential. Value may be a literal secret or a ${VAR_NAME} reference.
Env var references are stored as-is and resolved at get time.
"""
if not value:
return
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.set_password(service, key, value)
return
except Exception as e:
logger.error("keyring set failed for %s/%s: %s — falling back to file", service, key, e)
backend = "file"
# file backend
data = _file_read(service)
data[key] = value
_file_write(service, data)
def delete_credential(service: str, key: str) -> None:
"""Remove a stored credential."""
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.delete_password(service, key)
return
except Exception:
backend = "file"
data = _file_read(service)
data.pop(key, None)
if data:
_file_write(service, data)
else:
cred_file = CRED_DIR / f"{service}.json"
if cred_file.exists():
cred_file.unlink()

View file

@ -70,7 +70,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
print(f" [adzuna] Skipped — {exc}")
return []
titles = profile.get("titles") or profile.get("job_titles", [])
titles = profile.get("titles", [])
hours_old = profile.get("hours_old", 240)
max_days_old = max(1, hours_old // 24)
is_remote_search = location.lower() == "remote"

View file

@ -121,7 +121,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
return []
metros = [metro]
titles: list[str] = profile.get("titles") or profile.get("job_titles", [])
titles: list[str] = profile.get("titles", [])
hours_old: int = profile.get("hours_old", 240)
cutoff = datetime.now(tz=timezone.utc).timestamp() - (hours_old * 3600)

View file

@ -107,7 +107,7 @@ def scrape(profile: dict, location: str, results_wanted: int = 50) -> list[dict]
)
page = ctx.new_page()
for title in (profile.get("titles") or profile.get("job_titles", [])):
for title in profile.get("titles", []):
if len(results) >= results_wanted:
break

View file

@ -121,15 +121,6 @@ CREATE TABLE IF NOT EXISTS survey_responses (
);
"""
CREATE_DIGEST_QUEUE = """
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)
)
"""
_MIGRATIONS = [
("cover_letter", "TEXT"),
("applied_at", "TEXT"),
@ -141,8 +132,6 @@ _MIGRATIONS = [
("hired_at", "TEXT"),
("survey_at", "TEXT"),
("calendar_event_id", "TEXT"),
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
("ats_gap_report", "TEXT"), # JSON gap report (free tier)
]
@ -188,7 +177,6 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
conn.execute(CREATE_COMPANY_RESEARCH)
conn.execute(CREATE_BACKGROUND_TASKS)
conn.execute(CREATE_SURVEY_RESPONSES)
conn.execute(CREATE_DIGEST_QUEUE)
conn.commit()
conn.close()
_migrate_db(db_path)
@ -313,38 +301,6 @@ def update_cover_letter(db_path: Path = DEFAULT_DB, job_id: int = None, text: st
conn.close()
def save_optimized_resume(db_path: Path = DEFAULT_DB, job_id: int = None,
text: str = "", gap_report: str = "") -> None:
"""Persist ATS-optimized resume text and/or gap report for a job."""
if job_id is None:
return
conn = sqlite3.connect(db_path)
conn.execute(
"UPDATE jobs SET optimized_resume = ?, ats_gap_report = ? WHERE id = ?",
(text or None, gap_report or None, job_id),
)
conn.commit()
conn.close()
def get_optimized_resume(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
"""Return optimized_resume and ats_gap_report for a job, or empty strings if absent."""
if job_id is None:
return {"optimized_resume": "", "ats_gap_report": ""}
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT optimized_resume, ats_gap_report FROM jobs WHERE id = ?", (job_id,)
).fetchone()
conn.close()
if not row:
return {"optimized_resume": "", "ats_gap_report": ""}
return {
"optimized_resume": row["optimized_resume"] or "",
"ats_gap_report": row["ats_gap_report"] or "",
}
_UPDATABLE_JOB_COLS = {
"title", "company", "url", "source", "location", "is_remote",
"salary", "description", "match_score", "keyword_gaps",
@ -383,19 +339,6 @@ def mark_applied(db_path: Path = DEFAULT_DB, ids: list[int] = None) -> None:
conn.close()
def cancel_task(db_path: Path = DEFAULT_DB, task_id: int = 0) -> bool:
"""Cancel a single queued/running task by id. Returns True if a row was updated."""
conn = sqlite3.connect(db_path)
count = conn.execute(
"UPDATE background_tasks SET status='failed', error='Cancelled by user',"
" finished_at=datetime('now') WHERE id=? AND status IN ('queued','running')",
(task_id,),
).rowcount
conn.commit()
conn.close()
return count > 0
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
"""Mark all queued/running background tasks as failed. Returns count killed."""
conn = sqlite3.connect(db_path)

View file

@ -1,73 +0,0 @@
"""
db_migrate.py Rails-style numbered SQL migration runner for Peregrine user DBs.
Migration files live in migrations/ (sibling to this script's parent directory),
named NNN_description.sql (e.g. 001_baseline.sql). They are applied in sorted
order and tracked in the schema_migrations table so each runs exactly once.
Usage:
from scripts.db_migrate import migrate_db
migrate_db(Path("/path/to/user.db"))
"""
import logging
import sqlite3
from pathlib import Path
log = logging.getLogger(__name__)
# Resolved at import time: peregrine repo root / migrations/
_MIGRATIONS_DIR = Path(__file__).parent.parent / "migrations"
_CREATE_MIGRATIONS_TABLE = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""
def migrate_db(db_path: Path) -> list[str]:
"""Apply any pending migrations to db_path. Returns list of applied versions."""
applied: list[str] = []
con = sqlite3.connect(db_path)
try:
con.execute(_CREATE_MIGRATIONS_TABLE)
con.commit()
if not _MIGRATIONS_DIR.is_dir():
log.warning("migrations/ directory not found at %s — skipping", _MIGRATIONS_DIR)
return applied
migration_files = sorted(_MIGRATIONS_DIR.glob("*.sql"))
if not migration_files:
return applied
already_applied = {
row[0] for row in con.execute("SELECT version FROM schema_migrations")
}
for path in migration_files:
version = path.stem # e.g. "001_baseline"
if version in already_applied:
continue
sql = path.read_text(encoding="utf-8")
log.info("Applying migration %s to %s", version, db_path.name)
try:
con.executescript(sql)
con.execute(
"INSERT INTO schema_migrations (version) VALUES (?)", (version,)
)
con.commit()
applied.append(version)
log.info("Migration %s applied successfully", version)
except Exception as exc:
con.rollback()
log.error("Migration %s failed: %s", version, exc)
raise RuntimeError(f"Migration {version} failed: {exc}") from exc
finally:
con.close()
return applied

View file

@ -34,21 +34,17 @@ CUSTOM_SCRAPERS: dict[str, object] = {
}
def load_config(config_dir: Path | None = None) -> tuple[dict, dict]:
cfg = config_dir or CONFIG_DIR
profiles_path = cfg / "search_profiles.yaml"
notion_path = cfg / "notion.yaml"
profiles = yaml.safe_load(profiles_path.read_text())
notion_cfg = yaml.safe_load(notion_path.read_text()) if notion_path.exists() else {"field_map": {}, "token": None, "database_id": None}
def load_config() -> tuple[dict, dict]:
profiles = yaml.safe_load(PROFILES_CFG.read_text())
notion_cfg = yaml.safe_load(NOTION_CFG.read_text())
return profiles, notion_cfg
def load_blocklist(config_dir: Path | None = None) -> dict:
def load_blocklist() -> dict:
"""Load global blocklist config. Returns dict with companies, industries, locations lists."""
blocklist_path = (config_dir or CONFIG_DIR) / "blocklist.yaml"
if not blocklist_path.exists():
if not BLOCKLIST_CFG.exists():
return {"companies": [], "industries": [], "locations": []}
raw = yaml.safe_load(blocklist_path.read_text()) or {}
raw = yaml.safe_load(BLOCKLIST_CFG.read_text()) or {}
return {
"companies": [c.lower() for c in raw.get("companies", []) if c],
"industries": [i.lower() for i in raw.get("industries", []) if i],
@ -121,15 +117,10 @@ def push_to_notion(notion: Client, db_id: str, job: dict, fm: dict) -> None:
)
def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_dir: Path | None = None) -> None:
# In cloud mode, config_dir is the per-user config directory derived from db_path.
# Falls back to the app-level /app/config for single-tenant deployments.
resolved_cfg = config_dir or Path(db_path).parent / "config"
if not resolved_cfg.exists():
resolved_cfg = CONFIG_DIR
profiles_cfg, notion_cfg = load_config(resolved_cfg)
fm = notion_cfg.get("field_map") or {}
blocklist = load_blocklist(resolved_cfg)
def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None:
profiles_cfg, notion_cfg = load_config()
fm = notion_cfg["field_map"]
blocklist = load_blocklist()
_bl_summary = {k: len(v) for k, v in blocklist.items() if v}
if _bl_summary:
@ -205,30 +196,20 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
exclude_kw = [kw.lower() for kw in profile.get("exclude_keywords", [])]
results_per_board = profile.get("results_per_board", 25)
# Map remote_preference → JobSpy is_remote param:
# 'remote' → True (remote-only listings)
# 'onsite' → False (on-site-only listings)
# 'both' → None (no filter — JobSpy default)
_rp = profile.get("remote_preference", "both")
_is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None)
for location in profile["locations"]:
# ── JobSpy boards ──────────────────────────────────────────────────
if boards:
print(f" [jobspy] {location} — boards: {', '.join(boards)}")
try:
jobspy_kwargs: dict = dict(
jobs: pd.DataFrame = scrape_jobs(
site_name=boards,
search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))),
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
location=location,
results_wanted=results_per_board,
hours_old=profile.get("hours_old", 72),
linkedin_fetch_description=True,
)
if _is_remote is not None:
jobspy_kwargs["is_remote"] = _is_remote
jobs: pd.DataFrame = scrape_jobs(**jobspy_kwargs)
print(f" [jobspy] {len(jobs)} raw results")
except Exception as exc:
print(f" [jobspy] ERROR: {exc}")

View file

@ -26,14 +26,13 @@ LETTERS_DIR = _profile.docs_dir if _profile else Path.home() / "Documents" / "Jo
LETTER_GLOB = "*Cover Letter*.md"
# Background injected into every prompt so the model has the candidate's facts
def _build_system_context(profile=None) -> str:
p = profile or _profile
if not p:
def _build_system_context() -> str:
if not _profile:
return "You are a professional cover letter writer. Write in first person."
parts = [f"You are writing cover letters for {p.name}. {p.career_summary}"]
if p.candidate_voice:
parts = [f"You are writing cover letters for {_profile.name}. {_profile.career_summary}"]
if _profile.candidate_voice:
parts.append(
f"Voice and personality: {p.candidate_voice} "
f"Voice and personality: {_profile.candidate_voice} "
"Write in a way that reflects these authentic traits — not as a checklist, "
"but as a natural expression of who this person is."
)
@ -126,17 +125,15 @@ _MISSION_DEFAULTS: dict[str, str] = {
}
def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dict[str, str]:
def _build_mission_notes() -> dict[str, str]:
"""Merge user's custom mission notes with generic defaults."""
p = profile or _profile
name = candidate_name or _candidate
prefs = p.mission_preferences if p else {}
prefs = _profile.mission_preferences if _profile else {}
notes = {}
for industry, default_note in _MISSION_DEFAULTS.items():
custom = (prefs.get(industry) or "").strip()
if custom:
notes[industry] = (
f"Mission alignment — {name} shared: \"{custom}\". "
f"Mission alignment — {_candidate} shared: \"{custom}\". "
"Para 3 should warmly and specifically reflect this authentic connection."
)
else:
@ -147,15 +144,12 @@ def _build_mission_notes(profile=None, candidate_name: str | None = None) -> dic
_MISSION_NOTES = _build_mission_notes()
def detect_mission_alignment(
company: str, description: str, mission_notes: dict | None = None
) -> str | None:
def detect_mission_alignment(company: str, description: str) -> str | None:
"""Return a mission hint string if company/JD matches a preferred industry, else None."""
notes = mission_notes if mission_notes is not None else _MISSION_NOTES
text = f"{company} {description}".lower()
for industry, signals in _MISSION_SIGNALS.items():
if any(sig in text for sig in signals):
return notes[industry]
return _MISSION_NOTES[industry]
return None
@ -196,14 +190,10 @@ def build_prompt(
examples: list[dict],
mission_hint: str | None = None,
is_jobgether: bool = False,
system_context: str | None = None,
candidate_name: str | None = None,
) -> str:
ctx = system_context if system_context is not None else SYSTEM_CONTEXT
name = candidate_name or _candidate
parts = [ctx.strip(), ""]
parts = [SYSTEM_CONTEXT.strip(), ""]
if examples:
parts.append(f"=== STYLE EXAMPLES ({name}'s past letters) ===\n")
parts.append(f"=== STYLE EXAMPLES ({_candidate}'s past letters) ===\n")
for i, ex in enumerate(examples, 1):
parts.append(f"--- Example {i} ({ex['company']}) ---")
parts.append(ex["text"])
@ -241,14 +231,13 @@ def build_prompt(
return "\n".join(parts)
def _trim_to_letter_end(text: str, profile=None) -> str:
def _trim_to_letter_end(text: str) -> str:
"""Remove repetitive hallucinated content after the first complete sign-off.
Fine-tuned models sometimes loop after completing the letter. This cuts at
the first closing + candidate name so only the intended letter is saved.
"""
p = profile or _profile
candidate_first = (p.name.split()[0] if p else "").strip()
candidate_first = (_profile.name.split()[0] if _profile else "").strip()
pattern = (
r'(?:Warm regards|Sincerely|Best regards|Kind regards|Thank you)[,.]?\s*\n+\s*'
+ (re.escape(candidate_first) if candidate_first else r'\w+(?:\s+\w+)?')
@ -268,8 +257,6 @@ def generate(
feedback: str = "",
is_jobgether: bool = False,
_router=None,
config_path: "Path | None" = None,
user_yaml_path: "Path | None" = None,
) -> str:
"""Generate a cover letter and return it as a string.
@ -277,29 +264,15 @@ def generate(
and requested changes are appended to the prompt so the LLM revises rather
than starting from scratch.
user_yaml_path overrides the module-level profile required in cloud mode
so each user's name/voice/mission prefs are used instead of the global default.
_router is an optional pre-built LLMRouter (used in tests to avoid real LLM calls).
"""
# Per-call profile override (cloud mode: each user has their own user.yaml)
if user_yaml_path and Path(user_yaml_path).exists():
_prof = UserProfile(Path(user_yaml_path))
else:
_prof = _profile
sys_ctx = _build_system_context(_prof)
mission_notes = _build_mission_notes(_prof, candidate_name=(_prof.name if _prof else None))
candidate_name = _prof.name if _prof else _candidate
corpus = load_corpus()
examples = find_similar_letters(description or f"{title} {company}", corpus)
mission_hint = detect_mission_alignment(company, description, mission_notes=mission_notes)
mission_hint = detect_mission_alignment(company, description)
if mission_hint:
print(f"[cover-letter] Mission alignment detected for {company}", file=sys.stderr)
prompt = build_prompt(title, company, description, examples,
mission_hint=mission_hint, is_jobgether=is_jobgether,
system_context=sys_ctx, candidate_name=candidate_name)
mission_hint=mission_hint, is_jobgether=is_jobgether)
if previous_result:
prompt += f"\n\n---\nPrevious draft:\n{previous_result}"
@ -308,9 +281,8 @@ def generate(
if _router is None:
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.llm_router import LLMRouter, CONFIG_PATH
resolved = config_path if (config_path and Path(config_path).exists()) else CONFIG_PATH
_router = LLMRouter(resolved)
from scripts.llm_router import LLMRouter
_router = LLMRouter()
print(f"[cover-letter] Generating for: {title} @ {company}", file=sys.stderr)
print(f"[cover-letter] Style examples: {[e['company'] for e in examples]}", file=sys.stderr)
@ -320,7 +292,7 @@ def generate(
# max_tokens=1200 caps generation at ~900 words — enough for any cover letter
# and prevents fine-tuned models from looping into repetitive garbage output.
result = _router.complete(prompt, max_tokens=1200)
return _trim_to_letter_end(result, _prof)
return _trim_to_letter_end(result)
def main() -> None:

View file

@ -698,43 +698,21 @@ def _parse_message(conn: imaplib.IMAP4, uid: bytes) -> Optional[dict]:
return None
msg = email.message_from_bytes(data[0][1])
# Prefer text/html (preserves href attributes for digest link extraction);
# fall back to text/plain if no HTML part exists.
html_body = ""
plain_body = ""
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/html" and not html_body:
if part.get_content_type() == "text/plain":
try:
html_body = part.get_payload(decode=True).decode("utf-8", errors="replace")
except Exception:
pass
elif ct == "text/plain" and not plain_body:
try:
plain_body = part.get_payload(decode=True).decode("utf-8", errors="replace")
body = part.get_payload(decode=True).decode("utf-8", errors="replace")
except Exception:
pass
break
else:
ct = msg.get_content_type()
try:
raw = msg.get_payload(decode=True).decode("utf-8", errors="replace")
if ct == "text/html":
html_body = raw
else:
plain_body = raw
body = msg.get_payload(decode=True).decode("utf-8", errors="replace")
except Exception:
pass
if html_body:
# Strip <head>…</head> (CSS, meta, title) and any stray <style> blocks.
# Keeps <body> HTML intact so href attributes survive for digest extraction.
body = re.sub(r"<head[\s\S]*?</head>", "", html_body, flags=re.I)
body = re.sub(r"<style[\s\S]*?</style>", "", body, flags=re.I)
body = re.sub(r"<script[\s\S]*?</script>", "", body, flags=re.I)
else:
body = plain_body
mid = msg.get("Message-ID", "").strip()
if not mid:
return None # No Message-ID → can't dedup; skip to avoid repeat inserts
@ -745,7 +723,7 @@ def _parse_message(conn: imaplib.IMAP4, uid: bytes) -> Optional[dict]:
"from_addr": _decode_str(msg.get("From")),
"to_addr": _decode_str(msg.get("To")),
"date": _decode_str(msg.get("Date")),
"body": body, # no truncation — digest emails need full content
"body": body[:4000],
}
except Exception:
return None

View file

@ -1,313 +0,0 @@
"""Job ranking engine — two-stage discovery → review pipeline.
Stage 1 (discover.py) scrapes a wide corpus and stores everything as 'pending'.
Stage 2 (this module) scores the corpus; GET /api/jobs/stack returns top-N best
matches for the user's current review session.
All signal functions return a float in [0, 1]. The final stack_score is 0100.
Usage:
from scripts.job_ranker import rank_jobs
ranked = rank_jobs(jobs, search_titles, salary_min, salary_max, user_level)
"""
from __future__ import annotations
import math
import re
from datetime import datetime, timezone
# ── TUNING ─────────────────────────────────────────────────────────────────────
# Adjust these constants to change how jobs are ranked.
# All individual signal scores are normalised to [0, 1] before weighting.
# Weights should sum to ≤ 1.0; the remainder is unallocated slack.
W_RESUME_MATCH = 0.40 # TF-IDF cosine similarity stored as match_score (0100 → 01)
W_TITLE_MATCH = 0.30 # seniority-aware title + domain keyword overlap
W_RECENCY = 0.15 # freshness — exponential decay from date_found
W_SALARY_FIT = 0.10 # salary range overlap vs user target (neutral when unknown)
W_DESC_QUALITY = 0.05 # posting completeness — penalises stub / ghost posts
# Keyword gap penalty: each missing keyword from the resume match costs points.
# Gaps are already partially captured by W_RESUME_MATCH (same TF-IDF source),
# so this is a soft nudge, not a hard filter.
GAP_PENALTY_PER_KEYWORD: float = 0.5 # points off per gap keyword (0100 scale)
GAP_MAX_PENALTY: float = 5.0 # hard cap so a gap-heavy job can still rank
# Recency half-life: score halves every N days past date_found
RECENCY_HALF_LIFE: int = 7 # days
# Description word-count thresholds
DESC_MIN_WORDS: int = 50 # below this → scaled penalty
DESC_TARGET_WORDS: int = 200 # at or above → full quality score
# ── END TUNING ─────────────────────────────────────────────────────────────────
# ── Seniority level map ────────────────────────────────────────────────────────
# (level, [keyword substrings that identify that level])
# Matched on " <lower_title> " with a space-padded check to avoid false hits.
# Level 3 is the default (mid-level, no seniority modifier in title).
_SENIORITY_MAP: list[tuple[int, list[str]]] = [
(1, ["intern", "internship", "trainee", "apprentice", "co-op", "coop"]),
(2, ["entry level", "entry-level", "junior", "jr ", "jr.", "associate "]),
(3, ["mid level", "mid-level", "intermediate"]),
(4, ["senior ", "senior,", "sr ", "sr.", " lead ", "lead,", " ii ", " iii ",
"specialist", "experienced"]),
(5, ["staff ", "principal ", "architect ", "expert ", "distinguished"]),
(6, ["director", "head of ", "manager ", "vice president", " vp "]),
(7, ["chief", "cto", "cio", "cpo", "president", "founder"]),
]
# job_level user_level → scoring multiplier
# Positive delta = job is more senior (stretch up = encouraged)
# Negative delta = job is below the user's level
_LEVEL_MULTIPLIER: dict[int, float] = {
-4: 0.05, -3: 0.10, -2: 0.25, -1: 0.65,
0: 1.00,
1: 0.90, 2: 0.65, 3: 0.25, 4: 0.05,
}
_DEFAULT_LEVEL_MULTIPLIER = 0.05
# ── Seniority helpers ─────────────────────────────────────────────────────────
def infer_seniority(title: str) -> int:
"""Return seniority level 17 for a job or resume title. Defaults to 3."""
padded = f" {title.lower()} "
# Iterate highest → lowest so "Senior Lead" resolves to 4, not 6
for level, keywords in reversed(_SENIORITY_MAP):
for kw in keywords:
if kw in padded:
return level
return 3
def seniority_from_experience(titles: list[str]) -> int:
"""Estimate user's current level from their most recent experience titles.
Averages the levels of the top-3 most recent titles (first in the list).
Falls back to 3 (mid-level) if no titles are provided.
"""
if not titles:
return 3
sample = [t for t in titles if t.strip()][:3]
if not sample:
return 3
levels = [infer_seniority(t) for t in sample]
return round(sum(levels) / len(levels))
def _strip_level_words(text: str) -> str:
"""Remove seniority/modifier words so domain keywords stand out."""
strip = {
"senior", "sr", "junior", "jr", "lead", "staff", "principal",
"associate", "entry", "mid", "intermediate", "experienced",
"director", "head", "manager", "architect", "chief", "intern",
"ii", "iii", "iv", "i",
}
return " ".join(w for w in text.lower().split() if w not in strip)
# ── Signal functions ──────────────────────────────────────────────────────────
def title_match_score(job_title: str, search_titles: list[str], user_level: int) -> float:
"""Seniority-aware title similarity in [0, 1].
Combines:
- Domain overlap: keyword intersection between job title and search titles
after stripping level modifiers (so "Senior Software Engineer" vs
"Software Engineer" compares only on "software engineer").
- Seniority multiplier: rewards same-level and +1 stretch; penalises
large downgrade or unreachable stretch.
"""
if not search_titles:
return 0.5 # neutral — user hasn't set title prefs yet
job_level = infer_seniority(job_title)
level_delta = job_level - user_level
seniority_factor = _LEVEL_MULTIPLIER.get(level_delta, _DEFAULT_LEVEL_MULTIPLIER)
job_core_words = {w for w in _strip_level_words(job_title).split() if len(w) > 2}
best_domain = 0.0
for st in search_titles:
st_core_words = {w for w in _strip_level_words(st).split() if len(w) > 2}
if not st_core_words:
continue
# Recall-biased overlap: what fraction of the search title keywords
# appear in the job title? (A job posting may use synonyms but we
# at least want the core nouns to match.)
overlap = len(st_core_words & job_core_words) / len(st_core_words)
best_domain = max(best_domain, overlap)
# Base score from domain match scaled by seniority appropriateness.
# A small seniority_factor bonus (×0.2) ensures that even a near-miss
# domain match still benefits from seniority alignment.
return min(1.0, best_domain * seniority_factor + seniority_factor * 0.15)
def recency_decay(date_found: str) -> float:
"""Exponential decay starting from date_found.
Returns 1.0 for today, 0.5 after RECENCY_HALF_LIFE days, ~0.0 after ~4×.
Returns 0.5 (neutral) if the date is unparseable.
"""
try:
# Support both "YYYY-MM-DD" and "YYYY-MM-DD HH:MM:SS"
found = datetime.fromisoformat(date_found.split("T")[0].split(" ")[0])
found = found.replace(tzinfo=timezone.utc)
now = datetime.now(tz=timezone.utc)
days_old = max(0.0, (now - found).total_seconds() / 86400)
return math.exp(-math.log(2) * days_old / RECENCY_HALF_LIFE)
except Exception:
return 0.5
def _parse_salary_range(text: str | None) -> tuple[int | None, int | None]:
"""Extract (low, high) salary integers from free-text. Returns (None, None) on failure.
Handles: "$80k - $120k", "USD 80,000 - 120,000 per year", "£45,000",
"80000", "80K/yr", "80-120k", etc.
"""
if not text:
return None, None
normalized = re.sub(r"[$,£€₹¥\s]", "", text.lower())
# Match numbers optionally followed by 'k'
raw_nums = re.findall(r"(\d+(?:\.\d+)?)k?", normalized)
values = []
for n, full in zip(raw_nums, re.finditer(r"(\d+(?:\.\d+)?)(k?)", normalized)):
val = float(full.group(1))
if full.group(2): # ends with 'k'
val *= 1000
elif val < 1000: # bare numbers < 1000 are likely thousands (e.g., "80" in "80-120k")
val *= 1000
if val >= 10_000: # sanity: ignore clearly wrong values
values.append(int(val))
values = sorted(set(values))
if not values:
return None, None
return values[0], values[-1]
def salary_fit(
salary_text: str | None,
target_min: int | None,
target_max: int | None,
) -> float:
"""Salary range overlap score in [0, 1].
Returns 0.5 (neutral) when either range is unknown a missing salary
line is not inherently negative.
"""
if not salary_text or (target_min is None and target_max is None):
return 0.5
job_low, job_high = _parse_salary_range(salary_text)
if job_low is None:
return 0.5
t_min = target_min or 0
t_max = target_max or (int(target_min * 1.5) if target_min else job_high or job_low)
job_high = job_high or job_low
overlap_low = max(job_low, t_min)
overlap_high = min(job_high, t_max)
overlap = max(0, overlap_high - overlap_low)
target_span = max(1, t_max - t_min)
return min(1.0, overlap / target_span)
def description_quality(description: str | None) -> float:
"""Posting completeness score in [0, 1].
Stubs and ghost posts score near 0; well-written descriptions score 1.0.
"""
if not description:
return 0.0
words = len(description.split())
if words < DESC_MIN_WORDS:
return (words / DESC_MIN_WORDS) * 0.4 # steep penalty for stubs
if words >= DESC_TARGET_WORDS:
return 1.0
return 0.4 + 0.6 * (words - DESC_MIN_WORDS) / (DESC_TARGET_WORDS - DESC_MIN_WORDS)
# ── Composite scorer ──────────────────────────────────────────────────────────
def score_job(
job: dict,
search_titles: list[str],
target_salary_min: int | None,
target_salary_max: int | None,
user_level: int,
) -> float:
"""Compute composite stack_score (0100) for a single job dict.
Args:
job: Row dict from the jobs table (must have title, match_score,
date_found, salary, description, keyword_gaps).
search_titles: User's desired job titles (from search prefs).
target_salary_*: User's salary target from resume profile (or None).
user_level: Inferred seniority level 17.
Returns:
A float 0100. Higher = better match for this user's session.
"""
# ── Individual signals (all 01) ──────────────────────────────────────────
match_raw = job.get("match_score")
s_resume = (match_raw / 100.0) if match_raw is not None else 0.5
s_title = title_match_score(job.get("title", ""), search_titles, user_level)
s_recency = recency_decay(job.get("date_found", ""))
s_salary = salary_fit(job.get("salary"), target_salary_min, target_salary_max)
s_desc = description_quality(job.get("description"))
# ── Weighted sum ──────────────────────────────────────────────────────────
base = (
W_RESUME_MATCH * s_resume
+ W_TITLE_MATCH * s_title
+ W_RECENCY * s_recency
+ W_SALARY_FIT * s_salary
+ W_DESC_QUALITY * s_desc
)
# ── Keyword gap penalty (applied on the 0100 scale) ─────────────────────
gaps_raw = job.get("keyword_gaps") or ""
gap_count = len([g for g in gaps_raw.split(",") if g.strip()]) if gaps_raw else 0
gap_penalty = min(GAP_MAX_PENALTY, gap_count * GAP_PENALTY_PER_KEYWORD) / 100.0
return round(max(0.0, base - gap_penalty) * 100, 1)
# ── Public API ────────────────────────────────────────────────────────────────
def rank_jobs(
jobs: list[dict],
search_titles: list[str],
target_salary_min: int | None = None,
target_salary_max: int | None = None,
user_level: int = 3,
limit: int = 10,
min_score: float = 20.0,
) -> list[dict]:
"""Score and rank pending jobs; return top-N above min_score.
Args:
jobs: List of job dicts (from DB or any source).
search_titles: User's desired job titles from search prefs.
target_salary_*: User's salary target (from resume profile).
user_level: Seniority level 17 (use seniority_from_experience()).
limit: Stack size; pass 0 to return all qualifying jobs.
min_score: Minimum stack_score to include (0100).
Returns:
Sorted list (best first) with 'stack_score' key added to each dict.
"""
scored = []
for job in jobs:
s = score_job(job, search_titles, target_salary_min, target_salary_max, user_level)
if s >= min_score:
scored.append({**job, "stack_score": s})
scored.sort(key=lambda j: j["stack_score"], reverse=True)
return scored[:limit] if limit > 0 else scored

View file

@ -1,46 +1,19 @@
"""
LLM abstraction layer with priority fallback chain.
Config lookup order:
1. <repo>/config/llm.yaml per-install local config
2. ~/.config/circuitforge/llm.yaml user-level config (circuitforge-core default)
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST, )
Reads config/llm.yaml. Tries backends in order; falls back on any error.
"""
from pathlib import Path
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
# Kept for backwards-compatibility — external callers that import CONFIG_PATH
# from this module continue to work.
CONFIG_PATH = Path(__file__).parent.parent / "config" / "llm.yaml"
class LLMRouter(_CoreLLMRouter):
"""Peregrine-specific LLMRouter — tri-level config path priority.
"""Peregrine-specific LLMRouter — defaults to Peregrine's config/llm.yaml."""
When ``config_path`` is supplied (e.g. in tests) it is passed straight
through to the core. When omitted, the lookup order is:
1. <repo>/config/llm.yaml (per-install local config)
2. ~/.config/circuitforge/llm.yaml (user-level, circuitforge-core default)
3. env-var auto-config (ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_HOST )
"""
def __init__(self, config_path: Path | None = None) -> None:
if config_path is not None:
# Explicit path supplied — use it directly (e.g. tests, CLI override).
super().__init__(config_path)
return
local = Path(__file__).parent.parent / "config" / "llm.yaml"
user_level = Path.home() / ".config" / "circuitforge" / "llm.yaml"
if local.exists():
super().__init__(local)
elif user_level.exists():
super().__init__(user_level)
else:
# No yaml found — let circuitforge-core's env-var auto-config run.
# The core default CONFIG_PATH (~/.config/circuitforge/llm.yaml)
# won't exist either, so _auto_config_from_env() will be triggered.
super().__init__()
def __init__(self, config_path: Path = CONFIG_PATH):
super().__init__(config_path)
# Module-level singleton for convenience

View file

@ -47,7 +47,7 @@ OVERRIDE_YML = ROOT / "compose.override.yml"
_SERVICES: dict[str, tuple[str, int, str, bool, bool]] = {
"streamlit": ("streamlit_port", 8501, "STREAMLIT_PORT", True, False),
"searxng": ("searxng_port", 8888, "SEARXNG_PORT", True, True),
# vllm removed — now managed by cf-orch (host process), not a Docker service
"vllm": ("vllm_port", 8000, "VLLM_PORT", True, True),
"vision": ("vision_port", 8002, "VISION_PORT", True, True),
"ollama": ("ollama_port", 11434, "OLLAMA_PORT", True, True),
"ollama_research": ("ollama_research_port", 11435, "OLLAMA_RESEARCH_PORT", True, True),
@ -65,6 +65,7 @@ _LLM_BACKENDS: dict[str, list[tuple[str, str]]] = {
_DOCKER_INTERNAL: dict[str, tuple[str, int]] = {
"ollama": ("ollama", 11434),
"ollama_research": ("ollama_research", 11434), # container-internal port is always 11434
"vllm": ("vllm", 8000),
"vision": ("vision", 8002),
"searxng": ("searxng", 8080), # searxng internal port differs from host port
}
@ -492,12 +493,6 @@ def main() -> None:
# binds a harmless free port instead of conflicting with the external service.
env_updates: dict[str, str] = {i["env_var"]: str(i["stub_port"]) for i in ports.values()}
env_updates["RECOMMENDED_PROFILE"] = profile
# When Ollama is adopted from the host process, write OLLAMA_HOST so
# LLMRouter's env-var auto-config finds it without needing config/llm.yaml.
ollama_info = ports.get("ollama")
if ollama_info and ollama_info.get("external"):
env_updates["OLLAMA_HOST"] = f"http://host.docker.internal:{ollama_info['resolved']}"
if offload_gb > 0:
env_updates["CPU_OFFLOAD_GB"] = str(offload_gb)
# GPU info for the app container (which lacks nvidia-smi access)

View file

@ -1,439 +0,0 @@
"""
ATS Resume Optimizer rewrite a candidate's resume to maximize keyword match
for a specific job description without fabricating experience.
Tier behaviour:
Free gap report only (extract_jd_signals + prioritize_gaps, no LLM rewrite)
Paid full LLM rewrite targeting the JD (rewrite_for_ats)
Premium same as paid for now; fine-tuned voice model is a future enhancement
Pipeline:
job.description
extract_jd_signals() # TF-IDF gaps + LLM-extracted ATS signals
prioritize_gaps() # rank by impact, map to resume sections
rewrite_for_ats() # per-section LLM rewrite (paid+)
hallucination_check() # reject rewrites that invent new experience
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
# ── Signal extraction ─────────────────────────────────────────────────────────
def extract_jd_signals(description: str, resume_text: str = "") -> list[str]:
"""Return ATS keyword signals from a job description.
Combines two sources:
1. TF-IDF keyword gaps from match.py (fast, deterministic, no LLM cost)
2. LLM extraction for phrasing nuance TF-IDF misses (e.g. "cross-functional"
vs "cross-team", "led" vs "managed")
Falls back to TF-IDF-only if LLM is unavailable.
Args:
description: Raw job description text.
resume_text: Candidate's resume text (used to compute gap vs. already present).
Returns:
Deduplicated list of ATS keyword signals, most impactful first.
"""
# Phase 1: deterministic TF-IDF gaps (always available)
tfidf_gaps: list[str] = []
if resume_text:
try:
from scripts.match import match_score
_, tfidf_gaps = match_score(resume_text, description)
except Exception:
log.warning("[resume_optimizer] TF-IDF gap extraction failed", exc_info=True)
# Phase 2: LLM extraction for phrasing/qualifier nuance
llm_signals: list[str] = []
try:
from scripts.llm_router import LLMRouter
prompt = (
"Extract the most important ATS (applicant tracking system) keywords and "
"phrases from this job description. Focus on:\n"
"- Required skills and technologies (exact phrasing matters)\n"
"- Action verbs used to describe responsibilities\n"
"- Qualification signals ('required', 'must have', 'preferred')\n"
"- Industry-specific terminology\n\n"
"Return a JSON array of strings only. No explanation.\n\n"
f"Job description:\n{description[:3000]}"
)
raw = LLMRouter().complete(prompt)
# Extract JSON array from response (LLM may wrap it in markdown)
match = re.search(r"\[.*\]", raw, re.DOTALL)
if match:
llm_signals = json.loads(match.group(0))
llm_signals = [s.strip() for s in llm_signals if isinstance(s, str) and s.strip()]
except Exception:
log.warning("[resume_optimizer] LLM signal extraction failed", exc_info=True)
# Merge: LLM signals first (richer phrasing), TF-IDF fills gaps
seen: set[str] = set()
merged: list[str] = []
for term in llm_signals + tfidf_gaps:
key = term.lower()
if key not in seen:
seen.add(key)
merged.append(term)
return merged
# ── Gap prioritization ────────────────────────────────────────────────────────
# Map each gap term to the resume section where it would have the most ATS impact.
# ATS systems weight keywords higher in certain sections:
# skills — direct keyword match, highest density, indexed first
# summary — executive summary keywords often boost overall relevance score
# experience — verbs + outcomes in bullet points; adds context weight
_SECTION_KEYWORDS: dict[str, list[str]] = {
"skills": [
"python", "sql", "java", "typescript", "react", "vue", "docker",
"kubernetes", "aws", "gcp", "azure", "terraform", "ci/cd", "git",
"postgresql", "redis", "kafka", "spark", "tableau", "salesforce",
"jira", "figma", "excel", "powerpoint", "machine learning", "llm",
"deep learning", "pytorch", "tensorflow", "scikit-learn",
],
"summary": [
"leadership", "strategy", "vision", "executive", "director", "vp",
"growth", "transformation", "stakeholder", "cross-functional",
"p&l", "revenue", "budget", "board", "c-suite",
],
}
def prioritize_gaps(gaps: list[str], resume_sections: dict[str, Any]) -> list[dict]:
"""Rank keyword gaps by ATS impact and map each to a target resume section.
Args:
gaps: List of missing keyword signals from extract_jd_signals().
resume_sections: Structured resume dict from resume_parser.parse_resume().
Returns:
List of dicts, sorted by priority score descending:
{
"term": str, # the keyword/phrase to inject
"section": str, # target resume section ("skills", "summary", "experience")
"priority": int, # 1=high, 2=medium, 3=low
"rationale": str, # why this section was chosen
}
TODO: implement the ranking logic below.
The current stub assigns every gap to "experience" at medium priority.
A good implementation should:
- Score "skills" section terms highest (direct keyword density)
- Score "summary" terms next (executive/leadership signals)
- Route remaining gaps to "experience" bullets
- Deprioritize terms already present in any section (case-insensitive)
- Consider gap term length: multi-word phrases > single words (more specific = higher ATS weight)
"""
existing_text = _flatten_resume_text(resume_sections).lower()
prioritized: list[dict] = []
for term in gaps:
# Skip terms already present anywhere in the resume
if term.lower() in existing_text:
continue
# REVIEW: _SECTION_KEYWORDS lists are tech-centric; domain-specific roles
# (creative, healthcare, operations) may over-route to experience.
# Consider expanding the lists or making them config-driven.
term_lower = term.lower()
# Partial-match: term contains a skills keyword (handles "PostgreSQL" vs "postgresql",
# "AWS Lambda" vs "aws", etc.)
skills_match = any(kw in term_lower or term_lower in kw
for kw in _SECTION_KEYWORDS["skills"])
summary_match = any(kw in term_lower or term_lower in kw
for kw in _SECTION_KEYWORDS["summary"])
if skills_match:
section = "skills"
priority = 1
rationale = "matched technical skills list — highest ATS keyword density"
elif summary_match:
section = "summary"
priority = 1
rationale = "matched leadership/executive signals — boosts overall relevance score"
elif len(term.split()) > 1:
section = "experience"
priority = 2
rationale = "multi-word phrase — more specific than single keywords, context weight in bullets"
else:
section = "experience"
priority = 3
rationale = "single generic term — lowest ATS impact, added to experience for coverage"
prioritized.append({
"term": term,
"section": section,
"priority": priority,
"rationale": rationale,
})
prioritized.sort(key=lambda x: x["priority"])
return prioritized
def _flatten_resume_text(resume: dict[str, Any]) -> str:
"""Concatenate all text from a structured resume dict into one searchable string."""
parts: list[str] = []
parts.append(resume.get("career_summary", "") or "")
parts.extend(resume.get("skills", []))
for exp in resume.get("experience", []):
parts.append(exp.get("title", ""))
parts.append(exp.get("company", ""))
parts.extend(exp.get("bullets", []))
for edu in resume.get("education", []):
parts.append(edu.get("degree", ""))
parts.append(edu.get("field", ""))
parts.append(edu.get("institution", ""))
parts.extend(resume.get("achievements", []))
return " ".join(parts)
# ── LLM rewrite ───────────────────────────────────────────────────────────────
def rewrite_for_ats(
resume: dict[str, Any],
prioritized_gaps: list[dict],
job: dict[str, Any],
candidate_voice: str = "",
) -> dict[str, Any]:
"""Rewrite resume sections to naturally incorporate ATS keyword gaps.
Operates section-by-section. For each target section in prioritized_gaps,
builds a focused prompt that injects only the gaps destined for that section.
The hallucination constraint is enforced in the prompt itself and verified
post-hoc by hallucination_check().
Args:
resume: Structured resume dict (from resume_parser.parse_resume).
prioritized_gaps: Output of prioritize_gaps().
job: Job dict with at minimum {"title": str, "company": str, "description": str}.
candidate_voice: Free-text personality/style note from user.yaml (may be empty).
Returns:
New resume dict (same structure as input) with rewritten sections.
Sections with no relevant gaps are copied through unchanged.
"""
from scripts.llm_router import LLMRouter
router = LLMRouter()
# Group gaps by target section
by_section: dict[str, list[str]] = {}
for gap in prioritized_gaps:
by_section.setdefault(gap["section"], []).append(gap["term"])
rewritten = dict(resume) # shallow copy — sections replaced below
for section, terms in by_section.items():
terms_str = ", ".join(f'"{t}"' for t in terms)
original_content = _section_text_for_prompt(resume, section)
voice_note = (
f'\n\nCandidate voice/style: "{candidate_voice}". '
"Preserve this authentic tone — do not write generically."
) if candidate_voice else ""
prompt = (
f"You are rewriting the **{section}** section of a resume to help it pass "
f"ATS (applicant tracking system) screening for this role:\n"
f" Job title: {job.get('title', 'Unknown')}\n"
f" Company: {job.get('company', 'Unknown')}\n\n"
f"Inject these missing ATS keywords naturally into the section:\n"
f" {terms_str}\n\n"
f"CRITICAL RULES — violating any of these invalidates the rewrite:\n"
f"1. Do NOT invent new employers, job titles, dates, or education.\n"
f"2. Do NOT add skills the candidate did not already demonstrate.\n"
f"3. Only rephrase existing content — replace vague verbs/nouns with the "
f" ATS-preferred equivalents listed above.\n"
f"4. Keep the same number of bullet points in experience entries.\n"
f"5. Return ONLY the rewritten section content, no labels or explanation."
f"{voice_note}\n\n"
f"Original {section} section:\n{original_content}"
)
try:
result = router.complete(prompt)
rewritten = _apply_section_rewrite(rewritten, section, result.strip())
except Exception:
log.warning("[resume_optimizer] rewrite failed for section %r", section, exc_info=True)
# Leave section unchanged on failure
return rewritten
def _section_text_for_prompt(resume: dict[str, Any], section: str) -> str:
"""Render a resume section as plain text suitable for an LLM prompt."""
if section == "summary":
return resume.get("career_summary", "") or "(empty)"
if section == "skills":
skills = resume.get("skills", [])
return ", ".join(skills) if skills else "(empty)"
if section == "experience":
lines: list[str] = []
for exp in resume.get("experience", []):
lines.append(f"{exp['title']} at {exp['company']} ({exp['start_date']}{exp['end_date']})")
for b in exp.get("bullets", []):
lines.append(f"{b}")
return "\n".join(lines) if lines else "(empty)"
return "(unsupported section)"
def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str) -> dict[str, Any]:
"""Return a new resume dict with the given section replaced by rewritten text."""
updated = dict(resume)
if section == "summary":
updated["career_summary"] = rewritten
elif section == "skills":
# LLM returns comma-separated or newline-separated skills
skills = [s.strip() for s in re.split(r"[,\n•·]+", rewritten) if s.strip()]
updated["skills"] = skills
elif section == "experience":
# For experience, we keep the structured entries but replace the bullets.
# The LLM rewrites the whole section as plain text; we re-parse the bullets.
updated["experience"] = _reparse_experience_bullets(resume["experience"], rewritten)
return updated
def _reparse_experience_bullets(
original_entries: list[dict],
rewritten_text: str,
) -> list[dict]:
"""Re-associate rewritten bullet text with the original experience entries.
The LLM rewrites the section as a block of text. We split on the original
entry headers (title + company) to re-bind bullets to entries. Falls back
to the original entries if splitting fails.
"""
if not original_entries:
return original_entries
result: list[dict] = []
remaining = rewritten_text
for i, entry in enumerate(original_entries):
# Find where the next entry starts so we can slice out this entry's bullets
if i + 1 < len(original_entries):
next_title = original_entries[i + 1]["title"]
# Look for the next entry header in the remaining text
split_pat = re.escape(next_title)
m = re.search(split_pat, remaining, re.IGNORECASE)
chunk = remaining[:m.start()] if m else remaining
remaining = remaining[m.start():] if m else ""
else:
chunk = remaining
bullets = [
re.sub(r"^[•\-–—*◦▪▸►]\s*", "", line).strip()
for line in chunk.splitlines()
if re.match(r"^[•\-–—*◦▪▸►]\s*", line.strip())
]
new_entry = dict(entry)
new_entry["bullets"] = bullets if bullets else entry["bullets"]
result.append(new_entry)
return result
# ── Hallucination guard ───────────────────────────────────────────────────────
def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool:
"""Return True if the rewrite is safe (no fabricated facts detected).
Checks that the set of employers, job titles, and date ranges in the
rewritten resume is a subset of those in the original. Any new entry
signals hallucination.
Args:
original: Structured resume dict before rewrite.
rewritten: Structured resume dict after rewrite.
Returns:
True rewrite is safe to use
False hallucination detected; caller should fall back to original
"""
orig_anchors = _extract_anchors(original)
rewrite_anchors = _extract_anchors(rewritten)
new_anchors = rewrite_anchors - orig_anchors
if new_anchors:
log.warning(
"[resume_optimizer] hallucination_check FAILED — new anchors in rewrite: %s",
new_anchors,
)
return False
return True
def _extract_anchors(resume: dict[str, Any]) -> frozenset[str]:
"""Extract stable factual anchors (company, title, dates) from experience entries."""
anchors: set[str] = set()
for exp in resume.get("experience", []):
for field in ("company", "title", "start_date", "end_date"):
val = (exp.get(field) or "").strip().lower()
if val:
anchors.add(val)
for edu in resume.get("education", []):
val = (edu.get("institution") or "").strip().lower()
if val:
anchors.add(val)
return frozenset(anchors)
# ── Resume → plain text renderer ─────────────────────────────────────────────
def render_resume_text(resume: dict[str, Any]) -> str:
"""Render a structured resume dict back to formatted plain text for PDF export."""
lines: list[str] = []
contact_parts = [resume.get("name", ""), resume.get("email", ""), resume.get("phone", "")]
lines.append(" ".join(p for p in contact_parts if p))
lines.append("")
if resume.get("career_summary"):
lines.append("SUMMARY")
lines.append(resume["career_summary"])
lines.append("")
if resume.get("experience"):
lines.append("EXPERIENCE")
for exp in resume["experience"]:
lines.append(
f"{exp.get('title', '')} | {exp.get('company', '')} "
f"({exp.get('start_date', '')}{exp.get('end_date', '')})"
)
for b in exp.get("bullets", []):
lines.append(f"{b}")
lines.append("")
if resume.get("education"):
lines.append("EDUCATION")
for edu in resume["education"]:
lines.append(
f"{edu.get('degree', '')} {edu.get('field', '')} | "
f"{edu.get('institution', '')} {edu.get('graduation_year', '')}"
)
lines.append("")
if resume.get("skills"):
lines.append("SKILLS")
lines.append(", ".join(resume["skills"]))
lines.append("")
if resume.get("achievements"):
lines.append("ACHIEVEMENTS")
for a in resume["achievements"]:
lines.append(f"{a}")
lines.append("")
return "\n".join(lines)

View file

@ -166,8 +166,7 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
)
return
from scripts.discover import run_discovery
from pathlib import Path as _Path
new_count = run_discovery(db_path, config_dir=_Path(db_path).parent / "config")
new_count = run_discovery(db_path)
n = new_count or 0
update_task_status(
db_path, task_id, "completed",
@ -179,9 +178,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
import json as _json
p = _json.loads(params or "{}")
from scripts.generate_cover_letter import generate
_cfg_dir = Path(db_path).parent / "config"
_user_llm_cfg = _cfg_dir / "llm.yaml"
_user_yaml = _cfg_dir / "user.yaml"
result = generate(
job.get("title", ""),
job.get("company", ""),
@ -189,8 +185,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
previous_result=p.get("previous_result", ""),
feedback=p.get("feedback", ""),
is_jobgether=job.get("source") == "jobgether",
config_path=_user_llm_cfg,
user_yaml_path=_user_yaml,
)
update_cover_letter(db_path, job_id, result)

View file

@ -15,14 +15,13 @@ Public API (unchanged — callers do not need to change):
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
from typing import Callable, Optional
from circuitforge_core.tasks.scheduler import (
TaskSpec, # re-export unchanged
LocalScheduler as _CoreTaskScheduler,
TaskScheduler as _CoreTaskScheduler,
)
logger = logging.getLogger(__name__)
@ -94,6 +93,15 @@ class TaskScheduler(_CoreTaskScheduler):
def __init__(self, db_path: Path, run_task_fn: Callable) -> None:
budgets, max_depth = _load_config_overrides(db_path)
# Resolve VRAM using module-level _get_gpus so tests can monkeypatch it
try:
gpus = _get_gpus()
available_vram: float = (
sum(g["vram_total_gb"] for g in gpus) if gpus else 999.0
)
except Exception:
available_vram = 999.0
# Warn under this module's logger for any task types with no VRAM budget
# (mirrors the core warning but captures under scripts.task_scheduler
# so existing tests using caplog.at_level(logger="scripts.task_scheduler") pass)
@ -109,6 +117,7 @@ class TaskScheduler(_CoreTaskScheduler):
run_task_fn=run_task_fn,
task_types=LLM_TASK_TYPES,
vram_budgets=budgets,
available_vram_gb=available_vram,
max_queue_depth=max_depth,
)

View file

@ -7,8 +7,6 @@ here so port/host/SSL changes propagate everywhere automatically.
"""
from __future__ import annotations
from pathlib import Path
import os
import tempfile
import yaml
_DEFAULTS = {
@ -163,30 +161,3 @@ class UserProfile:
"ollama_research": f"{self.ollama_url}/v1",
"vllm": f"{self.vllm_url}/v1",
}
# ── Free functions for plain-dict access (used by dev-api.py) ─────────────────
def load_user_profile(config_path: str) -> dict:
"""Load user.yaml and return as a plain dict with safe defaults."""
path = Path(config_path)
if not path.exists():
return {}
with open(path) as f:
data = yaml.safe_load(f) or {}
return data
def save_user_profile(config_path: str, data: dict) -> None:
"""Atomically write the user profile dict to user.yaml."""
path = Path(config_path)
path.parent.mkdir(parents=True, exist_ok=True)
# Write to temp file then rename for atomicity
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.yaml.tmp')
try:
with os.fdopen(fd, 'w') as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
os.replace(tmp, path)
except Exception:
os.unlink(tmp)
raise

View file

@ -80,8 +80,7 @@ class TestTaskRunnerCoverLetterParams:
captured = {}
def mock_generate(title, company, description="", previous_result="", feedback="",
is_jobgether=False, _router=None, config_path=None,
user_yaml_path=None):
is_jobgether=False, _router=None):
captured.update({
"title": title, "company": company,
"previous_result": previous_result, "feedback": feedback,

View file

@ -1,148 +0,0 @@
"""Tests for scripts/db_migrate.py — numbered SQL migration runner."""
import sqlite3
import textwrap
from pathlib import Path
import pytest
from scripts.db_migrate import migrate_db
# ── helpers ───────────────────────────────────────────────────────────────────
def _applied(db_path: Path) -> list[str]:
con = sqlite3.connect(db_path)
try:
rows = con.execute("SELECT version FROM schema_migrations ORDER BY version").fetchall()
return [r[0] for r in rows]
finally:
con.close()
def _tables(db_path: Path) -> set[str]:
con = sqlite3.connect(db_path)
try:
rows = con.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
return {r[0] for r in rows}
finally:
con.close()
# ── tests ──────────────────────────────────────────────────────────────────────
def test_creates_schema_migrations_table(tmp_path):
"""Running against an empty DB creates the tracking table."""
db = tmp_path / "test.db"
(tmp_path / "migrations").mkdir() # empty migrations dir
# Patch the module-level _MIGRATIONS_DIR
import scripts.db_migrate as m
orig = m._MIGRATIONS_DIR
m._MIGRATIONS_DIR = tmp_path / "migrations"
try:
migrate_db(db)
assert "schema_migrations" in _tables(db)
finally:
m._MIGRATIONS_DIR = orig
def test_applies_migration_file(tmp_path):
"""A .sql file in migrations/ is applied and recorded."""
db = tmp_path / "test.db"
mdir = tmp_path / "migrations"
mdir.mkdir()
(mdir / "001_test.sql").write_text(
"CREATE TABLE IF NOT EXISTS widgets (id INTEGER PRIMARY KEY, name TEXT);"
)
import scripts.db_migrate as m
orig = m._MIGRATIONS_DIR
m._MIGRATIONS_DIR = mdir
try:
applied = migrate_db(db)
assert applied == ["001_test"]
assert "widgets" in _tables(db)
assert _applied(db) == ["001_test"]
finally:
m._MIGRATIONS_DIR = orig
def test_idempotent_second_run(tmp_path):
"""Running migrate_db twice does not re-apply migrations."""
db = tmp_path / "test.db"
mdir = tmp_path / "migrations"
mdir.mkdir()
(mdir / "001_test.sql").write_text(
"CREATE TABLE IF NOT EXISTS widgets (id INTEGER PRIMARY KEY, name TEXT);"
)
import scripts.db_migrate as m
orig = m._MIGRATIONS_DIR
m._MIGRATIONS_DIR = mdir
try:
migrate_db(db)
applied = migrate_db(db) # second run
assert applied == []
assert _applied(db) == ["001_test"]
finally:
m._MIGRATIONS_DIR = orig
def test_applies_only_new_migrations(tmp_path):
"""Migrations already in schema_migrations are skipped; only new ones run."""
db = tmp_path / "test.db"
mdir = tmp_path / "migrations"
mdir.mkdir()
(mdir / "001_first.sql").write_text(
"CREATE TABLE IF NOT EXISTS first_table (id INTEGER PRIMARY KEY);"
)
import scripts.db_migrate as m
orig = m._MIGRATIONS_DIR
m._MIGRATIONS_DIR = mdir
try:
migrate_db(db)
# Add a second migration
(mdir / "002_second.sql").write_text(
"CREATE TABLE IF NOT EXISTS second_table (id INTEGER PRIMARY KEY);"
)
applied = migrate_db(db)
assert applied == ["002_second"]
assert set(_applied(db)) == {"001_first", "002_second"}
assert "second_table" in _tables(db)
finally:
m._MIGRATIONS_DIR = orig
def test_migration_failure_raises(tmp_path):
"""A bad migration raises RuntimeError and does not record the version."""
db = tmp_path / "test.db"
mdir = tmp_path / "migrations"
mdir.mkdir()
(mdir / "001_bad.sql").write_text("THIS IS NOT VALID SQL !!!")
import scripts.db_migrate as m
orig = m._MIGRATIONS_DIR
m._MIGRATIONS_DIR = mdir
try:
with pytest.raises(RuntimeError, match="001_bad"):
migrate_db(db)
assert _applied(db) == []
finally:
m._MIGRATIONS_DIR = orig
def test_baseline_migration_runs(tmp_path):
"""The real 001_baseline.sql applies cleanly to a fresh database."""
db = tmp_path / "test.db"
applied = migrate_db(db)
assert "001_baseline" in applied
expected_tables = {
"jobs", "job_contacts", "company_research",
"background_tasks", "survey_responses", "digest_queue",
"schema_migrations",
}
assert expected_tables <= _tables(db)

View file

@ -1,238 +0,0 @@
"""Tests for digest queue API endpoints."""
import sqlite3
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def tmp_db(tmp_path):
"""Create minimal schema in a temp dir with one job_contacts row."""
db_path = str(tmp_path / "staging.db")
con = sqlite3.connect(db_path)
con.executescript("""
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
title TEXT, company TEXT, url TEXT UNIQUE, location TEXT,
is_remote INTEGER DEFAULT 0, salary TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending',
date_found TEXT, description TEXT, source TEXT
);
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
);
CREATE TABLE 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)
);
INSERT INTO jobs (id, title, company, url, status, source, date_found)
VALUES (1, 'Engineer', 'Acme', 'https://acme.com/job/1', 'applied', 'test', '2026-03-19');
INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, body, from_addr)
VALUES (
10, 1, 'TechCrunch Jobs Weekly', '2026-03-19T10:00:00', 'digest',
'<html><body>Apply at <a href="https://greenhouse.io/acme/jobs/456">Senior Engineer</a> or <a href="https://lever.co/globex/staff">Staff Designer</a>. Unsubscribe: https://unsubscribe.example.com/remove</body></html>',
'digest@techcrunch.com'
);
""")
con.close()
return db_path
@pytest.fixture()
def client(tmp_db, monkeypatch):
monkeypatch.setenv("STAGING_DB", tmp_db)
import dev_api
monkeypatch.setattr(dev_api, "DB_PATH", tmp_db)
return TestClient(dev_api.app)
# ── GET /api/digest-queue ───────────────────────────────────────────────────
def test_digest_queue_list_empty(client):
resp = client.get("/api/digest-queue")
assert resp.status_code == 200
assert resp.json() == []
def test_digest_queue_list_with_entry(client, tmp_db):
con = sqlite3.connect(tmp_db)
con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (10)")
con.commit()
con.close()
resp = client.get("/api/digest-queue")
assert resp.status_code == 200
entries = resp.json()
assert len(entries) == 1
assert entries[0]["job_contact_id"] == 10
assert entries[0]["subject"] == "TechCrunch Jobs Weekly"
assert entries[0]["from_addr"] == "digest@techcrunch.com"
assert "body" in entries[0]
assert "created_at" in entries[0]
# ── POST /api/digest-queue ──────────────────────────────────────────────────
def test_digest_queue_add(client, tmp_db):
resp = client.post("/api/digest-queue", json={"job_contact_id": 10})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["created"] is True
con = sqlite3.connect(tmp_db)
row = con.execute("SELECT * FROM digest_queue WHERE job_contact_id = 10").fetchone()
con.close()
assert row is not None
def test_digest_queue_add_duplicate(client):
client.post("/api/digest-queue", json={"job_contact_id": 10})
resp = client.post("/api/digest-queue", json={"job_contact_id": 10})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["created"] is False
def test_digest_queue_add_missing_contact(client):
resp = client.post("/api/digest-queue", json={"job_contact_id": 9999})
assert resp.status_code == 404
# ── POST /api/digest-queue/{id}/extract-links ───────────────────────────────
def _add_digest_entry(tmp_db, contact_id=10):
"""Helper: insert a digest_queue row and return its id."""
con = sqlite3.connect(tmp_db)
cur = con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (?)", (contact_id,))
entry_id = cur.lastrowid
con.commit()
con.close()
return entry_id
def test_digest_extract_links(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/extract-links")
assert resp.status_code == 200
links = resp.json()["links"]
# greenhouse.io link should be present with score=2
gh_links = [l for l in links if "greenhouse.io" in l["url"]]
assert len(gh_links) == 1
assert gh_links[0]["score"] == 2
# lever.co link should be present with score=2
lever_links = [l for l in links if "lever.co" in l["url"]]
assert len(lever_links) == 1
assert lever_links[0]["score"] == 2
# Each link must have a hint key (may be empty string for links at start of body)
for link in links:
assert "hint" in link
def test_digest_extract_links_filters_trackers(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/extract-links")
assert resp.status_code == 200
links = resp.json()["links"]
urls = [l["url"] for l in links]
# Unsubscribe URL should be excluded
assert not any("unsubscribe" in u for u in urls)
def test_digest_extract_links_404(client):
resp = client.post("/api/digest-queue/9999/extract-links")
assert resp.status_code == 404
# ── POST /api/digest-queue/{id}/queue-jobs ──────────────────────────────────
def test_digest_queue_jobs(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": ["https://greenhouse.io/acme/jobs/456"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 0
con = sqlite3.connect(tmp_db)
row = con.execute(
"SELECT source, status FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/456'"
).fetchone()
con.close()
assert row is not None
assert row[0] == "digest"
assert row[1] == "pending"
def test_digest_queue_jobs_skips_duplicates(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": [
"https://greenhouse.io/acme/jobs/789",
"https://greenhouse.io/acme/jobs/789", # same URL twice in one call
]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 1
con = sqlite3.connect(tmp_db)
count = con.execute(
"SELECT COUNT(*) FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/789'"
).fetchone()[0]
con.close()
assert count == 1
def test_digest_queue_jobs_skips_invalid_urls(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(
f"/api/digest-queue/{entry_id}/queue-jobs",
json={"urls": ["", "ftp://bad.example.com", "https://valid.greenhouse.io/job/1"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["queued"] == 1
assert data["skipped"] == 2
def test_digest_queue_jobs_empty_urls(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.post(f"/api/digest-queue/{entry_id}/queue-jobs", json={"urls": []})
assert resp.status_code == 400
def test_digest_queue_jobs_404(client):
resp = client.post("/api/digest-queue/9999/queue-jobs", json={"urls": ["https://example.com"]})
assert resp.status_code == 404
# ── DELETE /api/digest-queue/{id} ───────────────────────────────────────────
def test_digest_delete(client, tmp_db):
entry_id = _add_digest_entry(tmp_db)
resp = client.delete(f"/api/digest-queue/{entry_id}")
assert resp.status_code == 200
assert resp.json()["ok"] is True
# Second delete → 404
resp2 = client.delete(f"/api/digest-queue/{entry_id}")
assert resp2.status_code == 404

View file

@ -1,216 +0,0 @@
"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
import sqlite3
import tempfile
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def tmp_db(tmp_path):
"""Create a minimal staging.db schema in a temp dir."""
db_path = str(tmp_path / "staging.db")
con = sqlite3.connect(db_path)
con.executescript("""
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
title TEXT, company TEXT, url TEXT, location TEXT,
is_remote INTEGER DEFAULT 0, salary TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT,
interview_date TEXT, rejection_stage TEXT,
applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT,
offer_at TEXT, hired_at TEXT, survey_at TEXT
);
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
);
CREATE TABLE background_tasks (
id INTEGER PRIMARY KEY,
task_type TEXT,
job_id INTEGER,
status TEXT DEFAULT 'queued',
finished_at TEXT
);
INSERT INTO jobs (id, title, company, status) VALUES
(1, 'Engineer', 'Acme', 'applied'),
(2, 'Designer', 'Beta', 'phone_screen');
INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, suggestion_dismissed) VALUES
(10, 1, 'Interview confirmed', '2026-03-19T10:00:00', 'interview_scheduled', 0),
(11, 1, 'Old neutral', '2026-03-18T09:00:00', 'neutral', 0),
(12, 2, 'Offer letter', '2026-03-19T11:00:00', 'offer_received', 0),
(13, 1, 'Already dismissed', '2026-03-17T08:00:00', 'positive_response', 1);
""")
con.close()
return db_path
@pytest.fixture()
def client(tmp_db, monkeypatch):
monkeypatch.setenv("STAGING_DB", tmp_db)
import dev_api
monkeypatch.setattr(dev_api, "DB_PATH", tmp_db)
return TestClient(dev_api.app)
# ── GET /api/interviews — stage signals batched ────────────────────────────
def test_interviews_includes_stage_signals(client):
resp = client.get("/api/interviews")
assert resp.status_code == 200
jobs = {j["id"]: j for j in resp.json()}
# job 1 should have exactly 1 undismissed non-excluded signal
assert "stage_signals" in jobs[1]
signals = jobs[1]["stage_signals"]
assert len(signals) == 1
assert signals[0]["stage_signal"] == "interview_scheduled"
assert signals[0]["subject"] == "Interview confirmed"
assert signals[0]["id"] == 10
assert "body" in signals[0]
assert "from_addr" in signals[0]
# neutral signal excluded
signal_types = [s["stage_signal"] for s in signals]
assert "neutral" not in signal_types
# dismissed signal excluded
signal_ids = [s["id"] for s in signals]
assert 13 not in signal_ids
# job 2 has an offer signal
assert len(jobs[2]["stage_signals"]) == 1
assert jobs[2]["stage_signals"][0]["stage_signal"] == "offer_received"
def test_interviews_empty_signals_for_job_without_contacts(client, tmp_db):
con = sqlite3.connect(tmp_db)
con.execute("INSERT INTO jobs (id, title, company, status) VALUES (3, 'NoContact', 'Corp', 'survey')")
con.commit(); con.close()
resp = client.get("/api/interviews")
jobs = {j["id"]: j for j in resp.json()}
assert jobs[3]["stage_signals"] == []
# ── POST /api/email/sync ───────────────────────────────────────────────────
def test_email_sync_returns_202(client):
resp = client.post("/api/email/sync")
assert resp.status_code == 202
assert "task_id" in resp.json()
def test_email_sync_inserts_background_task(client, tmp_db):
client.post("/api/email/sync")
con = sqlite3.connect(tmp_db)
row = con.execute(
"SELECT task_type, job_id, status FROM background_tasks WHERE task_type='email_sync'"
).fetchone()
con.close()
assert row is not None
assert row[0] == "email_sync"
assert row[1] == 0 # sentinel
assert row[2] == "queued"
# ── GET /api/email/sync/status ─────────────────────────────────────────────
def test_email_sync_status_idle_when_no_tasks(client):
resp = client.get("/api/email/sync/status")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "idle"
assert body["last_completed_at"] is None
def test_email_sync_status_reflects_latest_task(client, tmp_db):
con = sqlite3.connect(tmp_db)
con.execute(
"INSERT INTO background_tasks (task_type, job_id, status, finished_at) VALUES "
"('email_sync', 0, 'completed', '2026-03-19T12:00:00')"
)
con.commit(); con.close()
resp = client.get("/api/email/sync/status")
body = resp.json()
assert body["status"] == "completed"
assert body["last_completed_at"] == "2026-03-19T12:00:00"
# ── POST /api/stage-signals/{id}/dismiss ──────────────────────────────────
def test_dismiss_signal_sets_flag(client, tmp_db):
resp = client.post("/api/stage-signals/10/dismiss")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
con = sqlite3.connect(tmp_db)
row = con.execute(
"SELECT suggestion_dismissed FROM job_contacts WHERE id = 10"
).fetchone()
con.close()
assert row[0] == 1
def test_dismiss_signal_404_for_missing_id(client):
resp = client.post("/api/stage-signals/9999/dismiss")
assert resp.status_code == 404
# ── 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
def test_signal_body_html_is_stripped(client, tmp_db):
import sqlite3
con = sqlite3.connect(tmp_db)
con.execute(
"UPDATE job_contacts SET body = ? WHERE id = 10",
("<html><body><p>Hi there,</p><p>Interview confirmed.</p></body></html>",)
)
con.commit(); con.close()
resp = client.get("/api/interviews")
jobs = {j["id"]: j for j in resp.json()}
body = jobs[1]["stage_signals"][0]["body"]
assert "<" not in body
assert "Hi there" in body
assert "Interview confirmed" in body

View file

@ -1,161 +0,0 @@
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
import json
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
import sys
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa")
from dev_api import app
return TestClient(app)
# ── /api/jobs/{id}/research ─────────────────────────────────────────────────
def test_get_research_found(client):
"""Returns research row (minus raw_output) when present."""
import sqlite3
mock_row = {
"job_id": 1,
"company_brief": "Acme Corp makes anvils.",
"ceo_brief": "Wile E Coyote",
"talking_points": "- Ask about roadrunner containment",
"tech_brief": "Python, Rust",
"funding_brief": "Series B",
"red_flags": None,
"accessibility_brief": None,
"generated_at": "2026-03-20T12:00:00",
}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research")
assert resp.status_code == 200
data = resp.json()
assert data["company_brief"] == "Acme Corp makes anvils."
assert "raw_output" not in data
def test_get_research_not_found(client):
"""Returns 404 when no research row exists for job."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/99/research")
assert resp.status_code == 404
# ── /api/jobs/{id}/research/generate ────────────────────────────────────────
def test_generate_research_new_task(client):
"""POST generate returns task_id and is_new=True for fresh submission."""
with patch("scripts.task_runner.submit_task", return_value=(42, True)):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == 42
assert data["is_new"] is True
def test_generate_research_duplicate_task(client):
"""POST generate returns is_new=False when task already queued."""
with patch("scripts.task_runner.submit_task", return_value=(17, False)):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 200
data = resp.json()
assert data["is_new"] is False
def test_generate_research_error(client):
"""POST generate returns 500 when submit_task raises."""
with patch("scripts.task_runner.submit_task", side_effect=Exception("LLM unavailable")):
resp = client.post("/api/jobs/1/research/generate")
assert resp.status_code == 500
# ── /api/jobs/{id}/research/task ────────────────────────────────────────────
def test_research_task_none(client):
"""Returns status=none when no background task exists for job."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = None
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "none"
assert data["stage"] is None
assert data["message"] is None
def test_research_task_running(client):
"""Returns current status/stage/message for an active task."""
mock_row = {"status": "running", "stage": "Scraping company site", "error": None}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "running"
assert data["stage"] == "Scraping company site"
assert data["message"] is None
def test_research_task_failed(client):
"""Returns message (mapped from error column) for failed task."""
mock_row = {"status": "failed", "stage": None, "error": "LLM timeout"}
mock_db = MagicMock()
mock_db.execute.return_value.fetchone.return_value = mock_row
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/research/task")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "failed"
assert data["message"] == "LLM timeout"
# ── /api/jobs/{id}/contacts ──────────────────────────────────────────────────
def test_get_contacts_empty(client):
"""Returns empty list when job has no contacts."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = []
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
assert resp.json() == []
def test_get_contacts_list(client):
"""Returns list of contact dicts for job."""
mock_rows = [
{"id": 1, "direction": "inbound", "subject": "Interview next week",
"from_addr": "hr@acme.com", "body": "Hi! We'd like to...", "received_at": "2026-03-19T10:00:00"},
{"id": 2, "direction": "outbound", "subject": "Re: Interview next week",
"from_addr": None, "body": "Thank you!", "received_at": "2026-03-19T11:00:00"},
]
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = mock_rows
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/1/contacts")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["direction"] == "inbound"
assert data[1]["direction"] == "outbound"
def test_get_contacts_ordered_by_received_at(client):
"""Most recent contacts appear first (ORDER BY received_at DESC)."""
mock_db = MagicMock()
mock_db.execute.return_value.fetchall.return_value = []
with patch("dev_api._get_db", return_value=mock_db):
resp = client.get("/api/jobs/99/contacts")
# Verify the SQL contains ORDER BY received_at DESC
call_args = mock_db.execute.call_args
sql = call_args[0][0]
assert "ORDER BY received_at DESC" in sql

View file

@ -1,632 +0,0 @@
"""Tests for all settings API endpoints added in Tasks 18."""
import os
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa"
# ── Path bootstrap ────────────────────────────────────────────────────────────
# dev_api.py inserts /Library/Development/CircuitForge/peregrine into sys.path
# at import time; the worktree has credential_store but the main repo doesn't.
# Insert the worktree first so 'scripts' resolves to the worktree version, then
# pre-cache it in sys.modules so Python won't re-look-up when dev_api adds the
# main peregrine root.
if _WORKTREE not in sys.path:
sys.path.insert(0, _WORKTREE)
# Pre-cache the worktree scripts package and submodules before dev_api import
import importlib, types
def _ensure_worktree_scripts():
import importlib.util as _ilu
_wt = _WORKTREE
# Only load if not already loaded from the worktree
_spec = _ilu.spec_from_file_location("scripts", f"{_wt}/scripts/__init__.py",
submodule_search_locations=[f"{_wt}/scripts"])
if _spec is None:
return
_mod = _ilu.module_from_spec(_spec)
sys.modules.setdefault("scripts", _mod)
try:
_spec.loader.exec_module(_mod)
except Exception:
pass
_ensure_worktree_scripts()
@pytest.fixture(scope="module")
def client():
from dev_api import app
return TestClient(app)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_user_yaml(path: Path, data: dict = None):
"""Write a minimal user.yaml to the given path."""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as f:
yaml.dump(data or {"name": "Test User", "email": "test@example.com"}, f)
# ── GET /api/config/app ───────────────────────────────────────────────────────
def test_app_config_returns_expected_keys(client):
"""Returns 200 with isCloud, tier, and inferenceProfile in valid values."""
resp = client.get("/api/config/app")
assert resp.status_code == 200
data = resp.json()
assert "isCloud" in data
assert "tier" in data
assert "inferenceProfile" in data
valid_tiers = {"free", "paid", "premium", "ultra"}
valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"}
assert data["tier"] in valid_tiers
assert data["inferenceProfile"] in valid_profiles
def test_app_config_iscloud_env(client):
"""isCloud reflects CLOUD_MODE env var."""
with patch.dict(os.environ, {"CLOUD_MODE": "true"}):
resp = client.get("/api/config/app")
assert resp.json()["isCloud"] is True
def test_app_config_invalid_tier_falls_back_to_free(client):
"""Unknown APP_TIER falls back to 'free'."""
with patch.dict(os.environ, {"APP_TIER": "enterprise"}):
resp = client.get("/api/config/app")
assert resp.json()["tier"] == "free"
# ── GET/PUT /api/settings/profile ─────────────────────────────────────────────
def test_get_profile_returns_fields(tmp_path, monkeypatch):
"""GET /api/settings/profile returns dict with expected profile fields."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Alice", "email": "alice@example.com"})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/profile")
assert resp.status_code == 200
data = resp.json()
assert "name" in data
assert "email" in data
assert "career_summary" in data
assert "mission_preferences" in data
def test_put_get_profile_roundtrip(tmp_path, monkeypatch):
"""PUT then GET profile round-trip: saved name is returned."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/profile", json={
"name": "Bob Builder",
"email": "bob@example.com",
"phone": "555-1234",
"linkedin_url": "",
"career_summary": "Builder of things",
"candidate_voice": "",
"inference_profile": "cpu",
"mission_preferences": [],
"nda_companies": [],
"accessibility_focus": False,
"lgbtq_focus": False,
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/profile")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "Bob Builder"
# ── GET /api/settings/resume ──────────────────────────────────────────────────
def test_get_resume_missing_returns_not_exists(tmp_path, monkeypatch):
"""GET /api/settings/resume when file missing returns {exists: false}."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
# Ensure the path doesn't exist
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/resume")
assert resp.status_code == 200
assert resp.json() == {"exists": False}
def test_post_resume_blank_creates_file(tmp_path, monkeypatch):
"""POST /api/settings/resume/blank creates the file."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/resume/blank")
assert resp.status_code == 200
assert resp.json()["ok"] is True
assert fake_path.exists()
def test_get_resume_after_blank_returns_exists(tmp_path, monkeypatch):
"""GET /api/settings/resume after blank creation returns {exists: true}."""
fake_path = tmp_path / "config" / "plain_text_resume.yaml"
monkeypatch.setattr("dev_api._resume_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
# First create the blank file
c.post("/api/settings/resume/blank")
# Now get should return exists: True
resp = c.get("/api/settings/resume")
assert resp.status_code == 200
assert resp.json()["exists"] is True
def test_post_resume_sync_identity(tmp_path, monkeypatch):
"""POST /api/settings/resume/sync-identity returns 200."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/resume/sync-identity", json={
"name": "Alice",
"email": "alice@example.com",
"phone": "555-0000",
"linkedin_url": "https://linkedin.com/in/alice",
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
# ── GET/PUT /api/settings/search ──────────────────────────────────────────────
def test_get_search_prefs_returns_dict(tmp_path, monkeypatch):
"""GET /api/settings/search returns a dict with expected fields."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True)
with open(fake_path, "w") as f:
yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f)
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/search")
assert resp.status_code == 200
data = resp.json()
assert "remote_preference" in data
assert "job_boards" in data
def test_put_get_search_roundtrip(tmp_path, monkeypatch):
"""PUT then GET search prefs round-trip: saved field is returned."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
fake_path.parent.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/search", json={
"remote_preference": "remote",
"job_titles": ["Engineer"],
"locations": ["Remote"],
"exclude_keywords": [],
"job_boards": [],
"custom_board_urls": [],
"blocklist_companies": [],
"blocklist_industries": [],
"blocklist_locations": [],
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/search")
assert get_resp.status_code == 200
assert get_resp.json()["remote_preference"] == "remote"
def test_get_search_missing_file_returns_empty(tmp_path, monkeypatch):
"""GET /api/settings/search when file missing returns empty dict."""
fake_path = tmp_path / "config" / "search_profiles.yaml"
monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/search")
assert resp.status_code == 200
assert resp.json() == {}
# ── GET/PUT /api/settings/system/llm ─────────────────────────────────────────
def test_get_llm_config_returns_backends_and_byok(tmp_path, monkeypatch):
"""GET /api/settings/system/llm returns backends list and byok_acknowledged."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
with open(fake_llm_path, "w") as f:
yaml.dump({"backends": [{"name": "ollama", "enabled": True}]}, f)
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/llm")
assert resp.status_code == 200
data = resp.json()
assert "backends" in data
assert isinstance(data["backends"], list)
assert "byok_acknowledged" in data
def test_byok_ack_adds_backend(tmp_path, monkeypatch):
"""POST byok-ack with backends list then GET shows backend in byok_acknowledged."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Test", "byok_acknowledged_backends": []})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
ack_resp = c.post("/api/settings/system/llm/byok-ack", json={"backends": ["anthropic"]})
assert ack_resp.status_code == 200
assert ack_resp.json()["ok"] is True
get_resp = c.get("/api/settings/system/llm")
assert get_resp.status_code == 200
assert "anthropic" in get_resp.json()["byok_acknowledged"]
def test_put_llm_config_returns_ok(tmp_path, monkeypatch):
"""PUT /api/settings/system/llm returns ok."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_llm_path = tmp_path / "llm.yaml"
monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path)
from dev_api import app
c = TestClient(app)
resp = c.put("/api/settings/system/llm", json={
"backends": [{"name": "ollama", "enabled": True, "url": "http://localhost:11434"}],
})
assert resp.status_code == 200
assert resp.json()["ok"] is True
# ── GET /api/settings/system/services ────────────────────────────────────────
def test_get_services_returns_list(client):
"""GET /api/settings/system/services returns a list."""
resp = client.get("/api/settings/system/services")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_get_services_cpu_profile(client):
"""Services list with INFERENCE_PROFILE=cpu contains cpu-compatible services."""
with patch.dict(os.environ, {"INFERENCE_PROFILE": "cpu"}):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/services")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# cpu profile should include ollama and searxng
names = [s["name"] for s in data]
assert "ollama" in names or len(names) >= 0 # may vary by env
# ── GET /api/settings/system/email ───────────────────────────────────────────
def test_get_email_has_password_set_bool(tmp_path, monkeypatch):
"""GET /api/settings/system/email has password_set (bool) and no password key."""
fake_email_path = tmp_path / "email.yaml"
monkeypatch.setattr("dev_api._config_dir", lambda: fake_email_path.parent)
with patch("dev_api.get_credential", return_value=None):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/email")
assert resp.status_code == 200
data = resp.json()
assert "password_set" in data
assert isinstance(data["password_set"], bool)
assert "password" not in data
def test_get_email_password_set_true_when_stored(tmp_path, monkeypatch):
"""password_set is True when credential is stored."""
fake_email_path = tmp_path / "email.yaml"
monkeypatch.setattr("dev_api._config_dir", lambda: fake_email_path.parent)
with patch("dev_api.get_credential", return_value="secret"):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/system/email")
assert resp.status_code == 200
assert resp.json()["password_set"] is True
def test_test_email_bad_host_returns_ok_false(client):
"""POST /api/settings/system/email/test with bad host returns {ok: false}, not 500."""
with patch("dev_api.get_credential", return_value="fakepassword"):
resp = client.post("/api/settings/system/email/test", json={
"host": "imap.nonexistent-host-xyz.invalid",
"port": 993,
"ssl": True,
"username": "test@nonexistent.invalid",
})
assert resp.status_code == 200
assert resp.json()["ok"] is False
def test_test_email_missing_host_returns_ok_false(client):
"""POST email/test with missing host returns {ok: false}."""
with patch("dev_api.get_credential", return_value=None):
resp = client.post("/api/settings/system/email/test", json={
"host": "",
"username": "",
"port": 993,
"ssl": True,
})
assert resp.status_code == 200
assert resp.json()["ok"] is False
# ── GET /api/settings/fine-tune/status ───────────────────────────────────────
def test_finetune_status_returns_status_and_pairs_count(client):
"""GET /api/settings/fine-tune/status returns status and pairs_count."""
# get_task_status is imported inside the endpoint function; patch on the module
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
resp = client.get("/api/settings/fine-tune/status")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert "pairs_count" in data
def test_finetune_status_idle_when_no_task(tmp_path, monkeypatch):
"""Status is 'idle' and pairs_count is 0 when no task exists."""
fake_jsonl = tmp_path / "cover_letters.jsonl" # does not exist -> 0 pairs
monkeypatch.setattr("dev_api._TRAINING_JSONL", fake_jsonl)
with patch("scripts.task_runner.get_task_status", return_value=None, create=True):
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/fine-tune/status")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "idle"
assert data["pairs_count"] == 0
# ── GET /api/settings/license ────────────────────────────────────────────────
def test_get_license_returns_tier_and_active(tmp_path, monkeypatch):
"""GET /api/settings/license returns tier and active fields."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/license")
assert resp.status_code == 200
data = resp.json()
assert "tier" in data
assert "active" in data
def test_get_license_defaults_to_free(tmp_path, monkeypatch):
"""GET /api/settings/license defaults to free tier when no file."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/license")
assert resp.status_code == 200
data = resp.json()
assert data["tier"] == "free"
assert data["active"] is False
def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch):
"""POST activate with valid key format returns {ok: true}."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"})
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch):
"""POST activate with bad key format returns {ok: false}."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/activate", json={"key": "BADKEY"})
assert resp.status_code == 200
assert resp.json()["ok"] is False
def test_deactivate_license_returns_ok(tmp_path, monkeypatch):
"""POST /api/settings/license/deactivate returns 200 with ok."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/license/deactivate")
assert resp.status_code == 200
assert resp.json()["ok"] is True
def test_activate_then_deactivate(tmp_path, monkeypatch):
"""Activate then deactivate: active goes False."""
fake_license = tmp_path / "license.yaml"
monkeypatch.setattr("dev_api._license_path", lambda: fake_license)
from dev_api import app
c = TestClient(app)
c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"})
c.post("/api/settings/license/deactivate")
resp = c.get("/api/settings/license")
assert resp.status_code == 200
assert resp.json()["active"] is False
# ── GET/PUT /api/settings/privacy ─────────────────────────────────────────────
def test_get_privacy_returns_expected_fields(tmp_path, monkeypatch):
"""GET /api/settings/privacy returns telemetry_opt_in and byok_info_dismissed."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/privacy")
assert resp.status_code == 200
data = resp.json()
assert "telemetry_opt_in" in data
assert "byok_info_dismissed" in data
def test_put_get_privacy_roundtrip(tmp_path, monkeypatch):
"""PUT then GET privacy round-trip: saved values are returned."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/privacy", json={
"telemetry_opt_in": True,
"byok_info_dismissed": True,
})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/privacy")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["telemetry_opt_in"] is True
assert data["byok_info_dismissed"] is True
# ── GET /api/settings/developer ──────────────────────────────────────────────
def test_get_developer_returns_expected_fields(tmp_path, monkeypatch):
"""GET /api/settings/developer returns dev_tier_override and hf_token_set."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_tokens = tmp_path / "tokens.yaml"
monkeypatch.setattr("dev_api._tokens_path", lambda: fake_tokens)
from dev_api import app
c = TestClient(app)
resp = c.get("/api/settings/developer")
assert resp.status_code == 200
data = resp.json()
assert "dev_tier_override" in data
assert "hf_token_set" in data
assert isinstance(data["hf_token_set"], bool)
def test_put_dev_tier_then_get(tmp_path, monkeypatch):
"""PUT dev tier to 'paid' then GET shows dev_tier_override as 'paid'."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml)
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
fake_tokens = tmp_path / "tokens.yaml"
monkeypatch.setattr("dev_api._tokens_path", lambda: fake_tokens)
from dev_api import app
c = TestClient(app)
put_resp = c.put("/api/settings/developer/tier", json={"tier": "paid"})
assert put_resp.status_code == 200
assert put_resp.json()["ok"] is True
get_resp = c.get("/api/settings/developer")
assert get_resp.status_code == 200
assert get_resp.json()["dev_tier_override"] == "paid"
def test_wizard_reset_returns_ok(tmp_path, monkeypatch):
"""POST /api/settings/developer/wizard-reset returns 200 with ok."""
db_dir = tmp_path / "db"
db_dir.mkdir()
cfg_dir = db_dir / "config"
cfg_dir.mkdir()
user_yaml = cfg_dir / "user.yaml"
_write_user_yaml(user_yaml, {"name": "Test", "wizard_complete": True})
monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db"))
from dev_api import app
c = TestClient(app)
resp = c.post("/api/settings/developer/wizard-reset")
assert resp.status_code == 200
assert resp.json()["ok"] is True

View file

@ -1,164 +0,0 @@
"""Tests for survey endpoints: vision health, analyze, save response, get history."""
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def client():
import sys
sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa")
from dev_api import app
return TestClient(app)
# ── GET /api/vision/health ───────────────────────────────────────────────────
def test_vision_health_available(client):
"""Returns available=true when vision service responds 200."""
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("dev_api.requests.get", return_value=mock_resp):
resp = client.get("/api/vision/health")
assert resp.status_code == 200
assert resp.json() == {"available": True}
def test_vision_health_unavailable(client):
"""Returns available=false when vision service times out or errors."""
with patch("dev_api.requests.get", side_effect=Exception("timeout")):
resp = client.get("/api/vision/health")
assert resp.status_code == 200
assert resp.json() == {"available": False}
# ── POST /api/jobs/{id}/survey/analyze ──────────────────────────────────────
def test_analyze_text_quick(client):
"""Text mode quick analysis returns output and source=text_paste."""
mock_router = MagicMock()
mock_router.complete.return_value = "1. B — best option"
mock_router.config.get.return_value = ["claude_code", "vllm"]
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Do you prefer teamwork?\nA. Solo B. Together",
"mode": "quick",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "text_paste"
assert "B" in data["output"]
# System prompt must be passed for text path
call_kwargs = mock_router.complete.call_args[1]
assert "system" in call_kwargs
assert "culture-fit survey" in call_kwargs["system"]
def test_analyze_text_detailed(client):
"""Text mode detailed analysis passes correct prompt."""
mock_router = MagicMock()
mock_router.complete.return_value = "Option A: good for... Option B: better because..."
mock_router.config.get.return_value = []
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: Describe your work style.",
"mode": "detailed",
})
assert resp.status_code == 200
assert resp.json()["source"] == "text_paste"
def test_analyze_image(client):
"""Image mode routes through vision path with NO system prompt."""
mock_router = MagicMock()
mock_router.complete.return_value = "1. C — collaborative choice"
mock_router.config.get.return_value = ["vision_service", "claude_code"]
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"image_b64": "aGVsbG8=",
"mode": "quick",
})
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "screenshot"
# No system prompt on vision path
call_kwargs = mock_router.complete.call_args[1]
assert "system" not in call_kwargs
def test_analyze_llm_failure(client):
"""Returns 500 when LLM raises an exception."""
mock_router = MagicMock()
mock_router.complete.side_effect = Exception("LLM unavailable")
mock_router.config.get.return_value = []
with patch("dev_api.LLMRouter", return_value=mock_router):
resp = client.post("/api/jobs/1/survey/analyze", json={
"text": "Q1: test",
"mode": "quick",
})
assert resp.status_code == 500
# ── POST /api/jobs/{id}/survey/responses ────────────────────────────────────
def test_save_response_text(client):
"""Save text response writes to DB and returns id."""
mock_db = MagicMock()
with patch("dev_api._get_db", return_value=mock_db):
with patch("dev_api.insert_survey_response", return_value=42) as mock_insert:
resp = client.post("/api/jobs/1/survey/responses", json={
"mode": "quick",
"source": "text_paste",
"raw_input": "Q1: test question",
"llm_output": "1. B — good reason",
})
assert resp.status_code == 200
assert resp.json()["id"] == 42
# received_at generated by backend — not None
call_args = mock_insert.call_args
assert call_args[1]["received_at"] is not None or call_args[0][3] is not None
def test_save_response_with_image(client, tmp_path, monkeypatch):
"""Save image response writes PNG file and stores path in DB."""
monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db"))
with patch("dev_api.insert_survey_response", return_value=7) as mock_insert:
with patch("dev_api.Path") as mock_path_cls:
mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o
resp = client.post("/api/jobs/1/survey/responses", json={
"mode": "quick",
"source": "screenshot",
"image_b64": "aGVsbG8=", # valid base64
"llm_output": "1. B — reason",
})
assert resp.status_code == 200
assert resp.json()["id"] == 7
# ── GET /api/jobs/{id}/survey/responses ─────────────────────────────────────
def test_get_history_empty(client):
"""Returns empty list when no history exists."""
with patch("dev_api.get_survey_responses", return_value=[]):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200
assert resp.json() == []
def test_get_history_populated(client):
"""Returns history rows newest first."""
rows = [
{"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste",
"raw_input": None, "image_path": None, "llm_output": "Option A is best",
"reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"},
{"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste",
"raw_input": "Q1: test", "image_path": None, "llm_output": "1. B",
"reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"},
]
with patch("dev_api.get_survey_responses", return_value=rows):
resp = client.get("/api/jobs/1/survey/responses")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == 2
assert data[0]["survey_name"] == "Round 2"

View file

@ -1024,8 +1024,8 @@ def test_sync_all_per_job_exception_continues(tmp_path):
# ── Performance / edge cases ──────────────────────────────────────────────────
def test_parse_message_large_body_not_truncated():
"""Body longer than 4000 chars is stored in full (no truncation)."""
def test_parse_message_large_body_truncated():
"""Body longer than 4000 chars is silently truncated to 4000."""
from scripts.imap_sync import _parse_message
big_body = ("x" * 10_000).encode()
@ -1037,7 +1037,7 @@ def test_parse_message_large_body_not_truncated():
conn.fetch.return_value = ("OK", [(b"1 (RFC822)", raw)])
result = _parse_message(conn, b"1")
assert result is not None
assert len(result["body"]) == 10_000
assert len(result["body"]) <= 4000
def test_parse_message_binary_attachment_no_crash():

View file

@ -1,132 +0,0 @@
"""Tests for Peregrine's LLMRouter shim — priority fallback logic."""
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock, call
sys.path.insert(0, str(Path(__file__).parent.parent))
def _import_fresh():
"""Import scripts.llm_router fresh (bypass module cache)."""
import importlib
import scripts.llm_router as mod
importlib.reload(mod)
return mod
# ---------------------------------------------------------------------------
# Test 1: local config/llm.yaml takes priority when it exists
# ---------------------------------------------------------------------------
def test_uses_local_yaml_when_present():
"""When config/llm.yaml exists locally, super().__init__ is called with that path."""
import scripts.llm_router as shim_mod
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
local_path = Path(shim_mod.__file__).parent.parent / "config" / "llm.yaml"
user_path = Path.home() / ".config" / "circuitforge" / "llm.yaml"
def fake_exists(self):
return self == local_path # only the local path "exists"
captured = {}
def fake_core_init(self, config_path=None):
captured["config_path"] = config_path
self.config = {}
with patch.object(Path, "exists", fake_exists), \
patch.object(_CoreLLMRouter, "__init__", fake_core_init):
import importlib
import scripts.llm_router as mod
importlib.reload(mod)
mod.LLMRouter()
assert captured.get("config_path") == local_path, (
f"Expected super().__init__ to be called with local path {local_path}, "
f"got {captured.get('config_path')}"
)
# ---------------------------------------------------------------------------
# Test 2: falls through to env-var auto-config when neither yaml exists
# ---------------------------------------------------------------------------
def test_falls_through_to_env_when_no_yamls():
"""When no yaml files exist, super().__init__ is called with no args (env-var path)."""
import scripts.llm_router as shim_mod
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
captured = {}
def fake_exists(self):
return False # no yaml files exist anywhere
def fake_core_init(self, config_path=None):
# Record whether a path was passed
captured["config_path"] = config_path
captured["called"] = True
self.config = {}
with patch.object(Path, "exists", fake_exists), \
patch.object(_CoreLLMRouter, "__init__", fake_core_init):
import importlib
import scripts.llm_router as mod
importlib.reload(mod)
mod.LLMRouter()
assert captured.get("called"), "super().__init__ was never called"
# When called with no args, config_path defaults to None in our mock,
# meaning the shim correctly fell through to env-var auto-config
assert captured.get("config_path") is None, (
f"Expected super().__init__ to be called with no explicit path (None), "
f"got {captured.get('config_path')}"
)
# ---------------------------------------------------------------------------
# Test 3: module-level complete() singleton is only instantiated once
# ---------------------------------------------------------------------------
def test_complete_singleton_is_reused():
"""complete() reuses the same LLMRouter instance across multiple calls."""
import importlib
import scripts.llm_router as mod
importlib.reload(mod)
# Reset singleton
mod._router = None
instantiation_count = [0]
original_init = mod.LLMRouter.__init__
mock_router = MagicMock()
mock_router.complete.return_value = "OK"
original_class = mod.LLMRouter
class CountingRouter(original_class):
def __init__(self):
instantiation_count[0] += 1
# Bypass real __init__ to avoid needing config files
self.config = {}
def complete(self, prompt, system=None):
return "OK"
# Patch the class in the module
mod.LLMRouter = CountingRouter
mod._router = None
result1 = mod.complete("first call")
result2 = mod.complete("second call")
assert result1 == "OK"
assert result2 == "OK"
assert instantiation_count[0] == 1, (
f"Expected LLMRouter to be instantiated exactly once, "
f"got {instantiation_count[0]} instantiation(s)"
)
# Restore
mod.LLMRouter = original_class

View file

@ -1,80 +0,0 @@
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
import sys
from pathlib import Path
from unittest.mock import patch, call
sys.path.insert(0, str(Path(__file__).parent.parent))
import scripts.preflight as pf
def _make_ports(ollama_external: bool = True, ollama_port: int = 11434) -> dict:
"""Build a minimal ports dict as returned by preflight's port-scanning logic."""
return {
"ollama": {
"resolved": ollama_port,
"external": ollama_external,
"stub_port": 54321,
"env_var": "OLLAMA_PORT",
"adoptable": True,
},
"streamlit": {
"resolved": 8502,
"external": False,
"stub_port": 8502,
"env_var": "STREAMLIT_PORT",
"adoptable": False,
},
}
def _capture_env_updates(ports: dict) -> dict:
"""Run the env_updates construction block from preflight.main() and return the result.
We extract this logic from main() so tests can call it directly without
needing to simulate the full CLI argument parsing and system probe flow.
The block under test is the `if not args.check_only:` section.
"""
captured = {}
def fake_write_env(updates: dict) -> None:
captured.update(updates)
with patch.object(pf, "write_env", side_effect=fake_write_env), \
patch.object(pf, "update_llm_yaml"), \
patch.object(pf, "write_compose_override"):
# Replicate the env_updates block from preflight.main() as faithfully as possible
env_updates: dict[str, str] = {i["env_var"]: str(i["stub_port"]) for i in ports.values()}
env_updates["RECOMMENDED_PROFILE"] = "single-gpu"
# ---- Code under test: the OLLAMA_HOST adoption block ----
ollama_info = ports.get("ollama")
if ollama_info and ollama_info.get("external"):
env_updates["OLLAMA_HOST"] = f"http://host.docker.internal:{ollama_info['resolved']}"
# ---------------------------------------------------------
pf.write_env(env_updates)
return captured
def test_ollama_host_written_when_adopted():
"""OLLAMA_HOST is added when Ollama is adopted from the host (external=True)."""
ports = _make_ports(ollama_external=True, ollama_port=11434)
result = _capture_env_updates(ports)
assert "OLLAMA_HOST" in result
assert result["OLLAMA_HOST"] == "http://host.docker.internal:11434"
def test_ollama_host_not_written_when_docker_managed():
"""OLLAMA_HOST is NOT added when Ollama runs in Docker (external=False)."""
ports = _make_ports(ollama_external=False)
result = _capture_env_updates(ports)
assert "OLLAMA_HOST" not in result
def test_ollama_host_reflects_adopted_port():
"""OLLAMA_HOST uses the actual adopted port, not the default."""
ports = _make_ports(ollama_external=True, ollama_port=11500)
result = _capture_env_updates(ports)
assert result["OLLAMA_HOST"] == "http://host.docker.internal:11500"

View file

@ -1,288 +0,0 @@
# tests/test_resume_optimizer.py
"""Tests for scripts/resume_optimizer.py"""
import json
import pytest
from unittest.mock import MagicMock, patch
# ── Fixtures ─────────────────────────────────────────────────────────────────
SAMPLE_RESUME = {
"name": "Alex Rivera",
"email": "alex@example.com",
"phone": "555-1234",
"career_summary": "Experienced Customer Success Manager with a track record of growth.",
"skills": ["Salesforce", "Python", "customer success"],
"experience": [
{
"title": "Customer Success Manager",
"company": "Acme Corp",
"start_date": "2021",
"end_date": "present",
"bullets": [
"Managed a portfolio of 120 enterprise accounts.",
"Reduced churn by 18% through proactive outreach.",
],
},
{
"title": "Support Engineer",
"company": "Beta Inc",
"start_date": "2018",
"end_date": "2021",
"bullets": ["Resolved escalations for top-tier clients."],
},
],
"education": [
{
"degree": "B.S.",
"field": "Computer Science",
"institution": "State University",
"graduation_year": "2018",
}
],
"achievements": [],
}
SAMPLE_JD = (
"We are looking for a Customer Success Manager with Gainsight, cross-functional "
"leadership experience, and strong stakeholder management skills. AWS knowledge a plus."
)
# ── extract_jd_signals ────────────────────────────────────────────────────────
def test_extract_jd_signals_returns_list():
"""extract_jd_signals returns a list even when LLM and TF-IDF both fail."""
from scripts.resume_optimizer import extract_jd_signals
with patch("scripts.llm_router.LLMRouter") as MockRouter:
MockRouter.return_value.complete.side_effect = Exception("no LLM")
result = extract_jd_signals(SAMPLE_JD, resume_text="Python developer")
assert isinstance(result, list)
def test_extract_jd_signals_llm_path_parses_json_array():
"""extract_jd_signals merges LLM-extracted signals with TF-IDF gaps."""
from scripts.resume_optimizer import extract_jd_signals
llm_response = '["Gainsight", "cross-functional leadership", "stakeholder management"]'
with patch("scripts.llm_router.LLMRouter") as MockRouter:
MockRouter.return_value.complete.return_value = llm_response
result = extract_jd_signals(SAMPLE_JD)
assert "Gainsight" in result
assert "cross-functional leadership" in result
def test_extract_jd_signals_deduplicates():
"""extract_jd_signals deduplicates terms across LLM and TF-IDF sources."""
from scripts.resume_optimizer import extract_jd_signals
llm_response = '["Python", "AWS", "Python"]'
with patch("scripts.llm_router.LLMRouter") as MockRouter:
MockRouter.return_value.complete.return_value = llm_response
result = extract_jd_signals(SAMPLE_JD)
assert result.count("Python") == 1
def test_extract_jd_signals_handles_malformed_llm_json():
"""extract_jd_signals falls back gracefully when LLM returns non-JSON."""
from scripts.resume_optimizer import extract_jd_signals
with patch("scripts.llm_router.LLMRouter") as MockRouter:
MockRouter.return_value.complete.return_value = "Here are some keywords: Gainsight, AWS"
result = extract_jd_signals(SAMPLE_JD)
# Should still return a list (may be empty if TF-IDF also silent)
assert isinstance(result, list)
# ── prioritize_gaps ───────────────────────────────────────────────────────────
def test_prioritize_gaps_skips_existing_terms():
"""prioritize_gaps excludes terms already present in the resume."""
from scripts.resume_optimizer import prioritize_gaps
# "Salesforce" is already in SAMPLE_RESUME skills
result = prioritize_gaps(["Salesforce", "Gainsight"], SAMPLE_RESUME)
terms = [r["term"] for r in result]
assert "Salesforce" not in terms
assert "Gainsight" in terms
def test_prioritize_gaps_routes_tech_terms_to_skills():
"""prioritize_gaps maps known tech keywords to the skills section at priority 1."""
from scripts.resume_optimizer import prioritize_gaps
result = prioritize_gaps(["AWS", "Docker"], SAMPLE_RESUME)
by_term = {r["term"]: r for r in result}
assert by_term["AWS"]["section"] == "skills"
assert by_term["AWS"]["priority"] == 1
assert by_term["Docker"]["section"] == "skills"
def test_prioritize_gaps_routes_leadership_terms_to_summary():
"""prioritize_gaps maps leadership/executive signals to the summary section."""
from scripts.resume_optimizer import prioritize_gaps
result = prioritize_gaps(["cross-functional", "stakeholder"], SAMPLE_RESUME)
by_term = {r["term"]: r for r in result}
assert by_term["cross-functional"]["section"] == "summary"
assert by_term["stakeholder"]["section"] == "summary"
def test_prioritize_gaps_multi_word_routes_to_experience():
"""Multi-word phrases not in skills/summary lists go to experience at priority 2."""
from scripts.resume_optimizer import prioritize_gaps
result = prioritize_gaps(["proactive client engagement"], SAMPLE_RESUME)
assert result[0]["section"] == "experience"
assert result[0]["priority"] == 2
def test_prioritize_gaps_single_word_is_lowest_priority():
"""Single generic words not in any list go to experience at priority 3."""
from scripts.resume_optimizer import prioritize_gaps
result = prioritize_gaps(["innovation"], SAMPLE_RESUME)
assert result[0]["priority"] == 3
def test_prioritize_gaps_sorted_by_priority():
"""prioritize_gaps output is sorted ascending by priority (1 first)."""
from scripts.resume_optimizer import prioritize_gaps
gaps = ["innovation", "AWS", "cross-functional", "managed service contracts"]
result = prioritize_gaps(gaps, SAMPLE_RESUME)
priorities = [r["priority"] for r in result]
assert priorities == sorted(priorities)
# ── hallucination_check ───────────────────────────────────────────────────────
def test_hallucination_check_passes_unchanged_resume():
"""hallucination_check returns True when rewrite has no new employers or institutions."""
from scripts.resume_optimizer import hallucination_check
# Shallow rewrite: same structure
rewritten = {
**SAMPLE_RESUME,
"career_summary": "Dynamic CSM with cross-functional stakeholder management experience.",
}
assert hallucination_check(SAMPLE_RESUME, rewritten) is True
def test_hallucination_check_fails_on_new_employer():
"""hallucination_check returns False when a new company is introduced."""
from scripts.resume_optimizer import hallucination_check
fabricated_entry = {
"title": "VP of Customer Success",
"company": "Fabricated Corp",
"start_date": "2019",
"end_date": "2021",
"bullets": ["Led a team of 30."],
}
rewritten = dict(SAMPLE_RESUME)
rewritten["experience"] = SAMPLE_RESUME["experience"] + [fabricated_entry]
assert hallucination_check(SAMPLE_RESUME, rewritten) is False
def test_hallucination_check_fails_on_new_institution():
"""hallucination_check returns False when a new educational institution appears."""
from scripts.resume_optimizer import hallucination_check
rewritten = dict(SAMPLE_RESUME)
rewritten["education"] = [
*SAMPLE_RESUME["education"],
{"degree": "M.S.", "field": "Data Science", "institution": "MIT", "graduation_year": "2020"},
]
assert hallucination_check(SAMPLE_RESUME, rewritten) is False
# ── render_resume_text ────────────────────────────────────────────────────────
def test_render_resume_text_contains_all_sections():
"""render_resume_text produces plain text containing all resume sections."""
from scripts.resume_optimizer import render_resume_text
text = render_resume_text(SAMPLE_RESUME)
assert "Alex Rivera" in text
assert "SUMMARY" in text
assert "EXPERIENCE" in text
assert "Customer Success Manager" in text
assert "Acme Corp" in text
assert "EDUCATION" in text
assert "State University" in text
assert "SKILLS" in text
assert "Salesforce" in text
def test_render_resume_text_omits_empty_sections():
"""render_resume_text skips sections that have no content."""
from scripts.resume_optimizer import render_resume_text
sparse = {
"name": "Jordan Lee",
"email": "",
"phone": "",
"career_summary": "",
"skills": [],
"experience": [],
"education": [],
"achievements": [],
}
text = render_resume_text(sparse)
assert "EXPERIENCE" not in text
assert "SKILLS" not in text
# ── db integration ────────────────────────────────────────────────────────────
def test_save_and_get_optimized_resume(tmp_path):
"""save_optimized_resume persists and get_optimized_resume retrieves the data."""
from scripts.db import init_db, save_optimized_resume, get_optimized_resume
db_path = tmp_path / "test.db"
init_db(db_path)
# Insert a minimal job to satisfy FK
import sqlite3
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO jobs (id, title, company, url, source, status) VALUES (1, 'CSM', 'Acme', 'http://x.com', 'test', 'approved')"
)
conn.commit()
conn.close()
gap_report = json.dumps([{"term": "Gainsight", "section": "skills", "priority": 1, "rationale": "test"}])
save_optimized_resume(db_path, job_id=1, text="Rewritten resume text.", gap_report=gap_report)
result = get_optimized_resume(db_path, job_id=1)
assert result["optimized_resume"] == "Rewritten resume text."
parsed = json.loads(result["ats_gap_report"])
assert parsed[0]["term"] == "Gainsight"
def test_get_optimized_resume_returns_empty_for_missing(tmp_path):
"""get_optimized_resume returns empty strings when no record exists."""
from scripts.db import init_db, get_optimized_resume
db_path = tmp_path / "test.db"
init_db(db_path)
result = get_optimized_resume(db_path, job_id=999)
assert result["optimized_resume"] == ""
assert result["ats_gap_report"] == ""

View file

@ -109,33 +109,24 @@ def test_missing_budget_logs_warning(tmp_db, caplog):
ts.LLM_TASK_TYPES = frozenset(original)
def test_cpu_only_system_creates_scheduler(tmp_db, monkeypatch):
"""Scheduler constructs without error when _get_gpus() returns empty list.
LocalScheduler has no VRAM gating it runs tasks regardless of GPU count.
VRAM-aware scheduling is handled by circuitforge_orch's coordinator.
"""
def test_cpu_only_system_gets_unlimited_vram(tmp_db, monkeypatch):
"""_available_vram is 999.0 when _get_gpus() returns empty list."""
# Patch the module-level _get_gpus in task_scheduler (not preflight)
# so __init__'s _ts_mod._get_gpus() call picks up the mock.
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: [])
s = TaskScheduler(tmp_db, _noop_run_task)
# Scheduler still has correct budgets configured; no VRAM attribute expected
# Scheduler constructed successfully; budgets contain all LLM task types.
# Does not assert exact values -- a sibling test may write a config override
# to the shared pytest tmp dir, causing _load_config_overrides to pick it up.
assert set(s._budgets.keys()) >= LLM_TASK_TYPES
assert s._available_vram == 999.0
def test_gpu_detection_does_not_affect_local_scheduler(tmp_db, monkeypatch):
"""LocalScheduler ignores GPU VRAM — it has no _available_vram attribute.
VRAM-gated concurrency requires circuitforge_orch (Paid tier).
"""
def test_gpu_vram_summed_across_all_gpus(tmp_db, monkeypatch):
"""_available_vram sums vram_total_gb across all detected GPUs."""
fake_gpus = [
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 20.0},
{"name": "RTX 3090", "vram_total_gb": 24.0, "vram_free_gb": 18.0},
]
monkeypatch.setattr("scripts.task_scheduler._get_gpus", lambda: fake_gpus)
s = TaskScheduler(tmp_db, _noop_run_task)
assert not hasattr(s, "_available_vram")
assert s._available_vram == 48.0
def test_enqueue_adds_taskspec_to_deque(tmp_db):
@ -215,37 +206,40 @@ def _make_recording_run_task(log: list, done_event: threading.Event, expected: i
return _run
def _start_scheduler(tmp_db, run_task_fn):
def _start_scheduler(tmp_db, run_task_fn, available_vram=999.0):
s = TaskScheduler(tmp_db, run_task_fn)
s._available_vram = available_vram
s.start()
return s
# ── Tests ─────────────────────────────────────────────────────────────────────
def test_all_task_types_complete(tmp_db):
"""Scheduler runs tasks from multiple types; all complete.
LocalScheduler runs type batches concurrently (no VRAM gating).
VRAM-gated sequential scheduling requires circuitforge_orch.
"""
def test_deepest_queue_wins_first_slot(tmp_db):
"""Type with more queued tasks starts first when VRAM only fits one type."""
log, done = [], threading.Event()
# Build scheduler but DO NOT start it yet — enqueue all tasks first
# so the scheduler sees the full picture on its very first wake.
run_task_fn = _make_recording_run_task(log, done, 4)
s = TaskScheduler(tmp_db, run_task_fn)
s._available_vram = 3.0 # fits cover_letter (2.5) but not +company_research (5.0)
# Enqueue cover_letter (3 tasks) and company_research (1 task) before start.
# cover_letter has the deeper queue and must win the first batch slot.
for i in range(3):
s.enqueue(i + 1, "cover_letter", i + 1, None)
s.enqueue(4, "company_research", 4, None)
s.start()
s.start() # scheduler now sees all tasks atomically on its first iteration
assert done.wait(timeout=5.0), "timed out — not all 4 tasks completed"
s.shutdown()
assert len(log) == 4
cl = [t for _, t in log if t == "cover_letter"]
cr = [t for _, t in log if t == "company_research"]
cl = [i for i, (_, t) in enumerate(log) if t == "cover_letter"]
cr = [i for i, (_, t) in enumerate(log) if t == "company_research"]
assert len(cl) == 3 and len(cr) == 1
assert max(cl) < min(cr), "All cover_letter tasks must finish before company_research starts"
def test_fifo_within_type(tmp_db):
@ -262,8 +256,8 @@ def test_fifo_within_type(tmp_db):
assert [task_id for task_id, _ in log] == [10, 20, 30]
def test_concurrent_batches_different_types(tmp_db):
"""Two type batches run concurrently (LocalScheduler has no VRAM gating)."""
def test_concurrent_batches_when_vram_allows(tmp_db):
"""Two type batches start simultaneously when VRAM fits both."""
started = {"cover_letter": threading.Event(), "company_research": threading.Event()}
all_done = threading.Event()
log = []
@ -274,7 +268,8 @@ def test_concurrent_batches_different_types(tmp_db):
if len(log) >= 2:
all_done.set()
s = _start_scheduler(tmp_db, run_task)
# VRAM=10.0 fits both cover_letter (2.5) and company_research (5.0) simultaneously
s = _start_scheduler(tmp_db, run_task, available_vram=10.0)
s.enqueue(1, "cover_letter", 1, None)
s.enqueue(2, "company_research", 2, None)
@ -312,15 +307,8 @@ def test_new_tasks_picked_up_mid_batch(tmp_db):
assert log == [1, 2]
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_worker_crash_does_not_stall_scheduler(tmp_db):
"""If _run_task raises, the scheduler continues processing the next task.
The batch_worker intentionally lets the RuntimeError propagate to the thread
boundary (so LocalScheduler can detect crash vs. normal exit). This produces
a PytestUnhandledThreadExceptionWarning -- suppressed here because it is the
expected behavior under test.
"""
def test_worker_crash_releases_vram(tmp_db):
"""If _run_task raises, _reserved_vram returns to 0 and scheduler continues."""
log, done = [], threading.Event()
def run_task(db_path, task_id, task_type, job_id, params):
@ -329,15 +317,16 @@ def test_worker_crash_does_not_stall_scheduler(tmp_db):
log.append(task_id)
done.set()
s = _start_scheduler(tmp_db, run_task)
s = _start_scheduler(tmp_db, run_task, available_vram=3.0)
s.enqueue(1, "cover_letter", 1, None)
s.enqueue(2, "cover_letter", 2, None)
assert done.wait(timeout=5.0), "timed out — task 2 never completed after task 1 crash"
s.shutdown()
# Second task still ran despite first crashing
# Second task still ran, VRAM was released
assert 2 in log
assert s._reserved_vram == 0.0
def test_get_scheduler_returns_singleton(tmp_db):

View file

@ -66,12 +66,8 @@ def test_sync_cookie_prgn_switch_param_overrides_yaml(profile_yaml, monkeypatch)
assert any("prgn_ui=streamlit" in s for s in injected)
def test_sync_cookie_free_tier_keeps_vue(profile_yaml, monkeypatch):
"""Free-tier user with vue preference keeps vue (vue_ui_beta is free tier).
Previously this test verified a downgrade to streamlit. Vue SPA was opened
to free tier in issue #20 — the downgrade path no longer triggers.
"""
def test_sync_cookie_downgrades_tier_resets_to_streamlit(profile_yaml, monkeypatch):
"""Free-tier user with vue preference gets reset to streamlit."""
import yaml as _yaml
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
@ -84,8 +80,8 @@ def test_sync_cookie_free_tier_keeps_vue(profile_yaml, monkeypatch):
sync_ui_cookie(profile_yaml, tier="free")
saved = _yaml.safe_load(profile_yaml.read_text())
assert saved["ui_preference"] == "vue"
assert any("prgn_ui=vue" in s for s in injected)
assert saved["ui_preference"] == "streamlit"
assert any("prgn_ui=streamlit" in s for s in injected)
def test_switch_ui_writes_yaml_and_calls_sync(profile_yaml, monkeypatch):

View file

@ -1,368 +0,0 @@
"""Tests for wizard API endpoints (GET/POST /api/wizard/*)."""
import os
import sys
import yaml
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
# ── Path bootstrap ────────────────────────────────────────────────────────────
_REPO = Path(__file__).parent.parent
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
@pytest.fixture(scope="module")
def client():
from dev_api import app
return TestClient(app)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _write_user_yaml(path: Path, data: dict | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = data if data is not None else {}
path.write_text(yaml.dump(payload, allow_unicode=True, default_flow_style=False))
def _read_user_yaml(path: Path) -> dict:
if not path.exists():
return {}
return yaml.safe_load(path.read_text()) or {}
# ── GET /api/config/app — wizardComplete + isDemo ─────────────────────────────
class TestAppConfigWizardFields:
def test_wizard_complete_false_when_missing(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
# user.yaml does not exist yet
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["wizardComplete"] is False
def test_wizard_complete_true_when_set(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/config/app")
assert r.json()["wizardComplete"] is True
def test_is_demo_false_by_default(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch.dict(os.environ, {"DEMO_MODE": ""}, clear=False):
r = client.get("/api/config/app")
assert r.json()["isDemo"] is False
def test_is_demo_true_when_env_set(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._user_yaml_path", return_value=str(yaml_path)):
with patch.dict(os.environ, {"DEMO_MODE": "true"}, clear=False):
r = client.get("/api/config/app")
assert r.json()["isDemo"] is True
# ── GET /api/wizard/status ────────────────────────────────────────────────────
class TestWizardStatus:
def test_returns_not_complete_when_no_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
assert r.status_code == 200
body = r.json()
assert body["wizard_complete"] is False
assert body["wizard_step"] == 0
def test_returns_saved_step(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 3, "name": "Alex"})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
body = r.json()
assert body["wizard_step"] == 3
assert body["saved_data"]["name"] == "Alex"
def test_returns_complete_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_complete": True})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.get("/api/wizard/status")
assert r.json()["wizard_complete"] is True
# ── GET /api/wizard/hardware ──────────────────────────────────────────────────
class TestWizardHardware:
def test_returns_profiles_list(self, client):
r = client.get("/api/wizard/hardware")
assert r.status_code == 200
body = r.json()
assert set(body["profiles"]) == {"remote", "cpu", "single-gpu", "dual-gpu"}
assert "gpus" in body
assert "suggested_profile" in body
def test_gpu_from_env_var(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090,RTX 3080"}, clear=False):
r = client.get("/api/wizard/hardware")
body = r.json()
assert body["gpus"] == ["RTX 4090", "RTX 3080"]
assert body["suggested_profile"] == "dual-gpu"
def test_single_gpu_suggests_single(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": "RTX 4090"}, clear=False):
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "single-gpu"
def test_no_gpus_suggests_remote(self, client):
with patch.dict(os.environ, {"PEREGRINE_GPU_NAMES": ""}, clear=False):
with patch.dict(os.environ, {"RECOMMENDED_PROFILE": ""}, clear=False):
with patch("subprocess.check_output", side_effect=FileNotFoundError):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "remote"
assert r.json()["gpus"] == []
def test_recommended_profile_env_takes_priority(self, client):
with patch.dict(os.environ,
{"PEREGRINE_GPU_NAMES": "RTX 4090", "RECOMMENDED_PROFILE": "cpu"},
clear=False):
r = client.get("/api/wizard/hardware")
assert r.json()["suggested_profile"] == "cpu"
# ── POST /api/wizard/step ─────────────────────────────────────────────────────
class TestWizardStep:
def test_step1_saves_inference_profile(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 1, "data": {"inference_profile": "single-gpu"}})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["inference_profile"] == "single-gpu"
assert saved["wizard_step"] == 1
def test_step1_rejects_unknown_profile(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 1, "data": {"inference_profile": "turbo-gpu"}})
assert r.status_code == 400
def test_step2_saves_tier(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 2, "data": {"tier": "paid"}})
assert r.status_code == 200
assert _read_user_yaml(yaml_path)["tier"] == "paid"
def test_step2_rejects_unknown_tier(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 2, "data": {"tier": "enterprise"}})
assert r.status_code == 400
def test_step3_writes_resume_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
resume = {"experience": [{"title": "Engineer", "company": "Acme"}]}
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step",
json={"step": 3, "data": {"resume": resume}})
assert r.status_code == 200
resume_path = yaml_path.parent / "plain_text_resume.yaml"
assert resume_path.exists()
saved_resume = yaml.safe_load(resume_path.read_text())
assert saved_resume["experience"][0]["title"] == "Engineer"
def test_step4_saves_identity_fields(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
identity = {
"name": "Alex Rivera",
"email": "alex@example.com",
"phone": "555-1234",
"linkedin": "https://linkedin.com/in/alex",
"career_summary": "Experienced engineer.",
}
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 4, "data": identity})
assert r.status_code == 200
saved = _read_user_yaml(yaml_path)
assert saved["name"] == "Alex Rivera"
assert saved["career_summary"] == "Experienced engineer."
assert saved["wizard_step"] == 4
def test_step5_writes_env_keys(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
env_path = tmp_path / ".env"
env_path.write_text("SOME_KEY=existing\n")
_write_user_yaml(yaml_path, {})
# Patch both _wizard_yaml_path and the Path resolution inside wizard_save_step
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("dev_api.Path") as mock_path_cls:
# Only intercept the .env path construction; let other Path() calls pass through
real_path = Path
def path_side_effect(*args):
result = real_path(*args)
return result
mock_path_cls.side_effect = path_side_effect
# Direct approach: monkeypatch the env path
import dev_api as _dev_api
original_fn = _dev_api.wizard_save_step
# Simpler: just test via the real endpoint, verify env not written if no key given
r = client.post("/api/wizard/step",
json={"step": 5, "data": {"services": {"ollama_host": "localhost"}}})
assert r.status_code == 200
def test_step6_writes_search_profiles(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
search_path = tmp_path / "config" / "search_profiles.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("dev_api._search_prefs_path", return_value=search_path):
r = client.post("/api/wizard/step",
json={"step": 6, "data": {
"titles": ["Software Engineer", "Backend Developer"],
"locations": ["Remote", "Austin, TX"],
}})
assert r.status_code == 200
assert search_path.exists()
prefs = yaml.safe_load(search_path.read_text())
assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"]
assert "Remote" in prefs["default"]["location"]
def test_step7_only_advances_counter(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 7, "data": {}})
assert r.status_code == 200
assert _read_user_yaml(yaml_path)["wizard_step"] == 7
def test_invalid_step_number(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
r = client.post("/api/wizard/step", json={"step": 99, "data": {}})
assert r.status_code == 400
def test_crash_recovery_round_trip(self, client, tmp_path):
"""Save steps 1-4 sequentially, then verify status reflects step 4."""
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
steps = [
(1, {"inference_profile": "cpu"}),
(2, {"tier": "free"}),
(4, {"name": "Alex", "email": "a@b.com", "career_summary": "Eng."}),
]
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
for step, data in steps:
r = client.post("/api/wizard/step", json={"step": step, "data": data})
assert r.status_code == 200
r = client.get("/api/wizard/status")
body = r.json()
assert body["wizard_step"] == 4
assert body["saved_data"]["name"] == "Alex"
assert body["saved_data"]["inference_profile"] == "cpu"
# ── POST /api/wizard/inference/test ──────────────────────────────────────────
class TestWizardInferenceTest:
def test_local_profile_ollama_running(self, client):
mock_resp = MagicMock()
mock_resp.status_code = 200
with patch("dev_api.requests.get", return_value=mock_resp):
r = client.post("/api/wizard/inference/test",
json={"profile": "cpu", "ollama_host": "localhost",
"ollama_port": 11434})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "Ollama" in body["message"]
def test_local_profile_ollama_down_soft_fail(self, client):
import requests as _req
with patch("dev_api.requests.get", side_effect=_req.exceptions.ConnectionError):
r = client.post("/api/wizard/inference/test",
json={"profile": "single-gpu"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "configure" in body["message"].lower()
def test_remote_profile_llm_responding(self, client):
# LLMRouter is imported inside wizard_test_inference — patch the source module
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.return_value = "OK"
r = client.post("/api/wizard/inference/test",
json={"profile": "remote", "anthropic_key": "sk-ant-test"})
assert r.status_code == 200
assert r.json()["ok"] is True
def test_remote_profile_llm_error(self, client):
with patch("scripts.llm_router.LLMRouter") as mock_cls:
mock_cls.return_value.complete.side_effect = RuntimeError("no key")
r = client.post("/api/wizard/inference/test",
json={"profile": "remote"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is False
assert "failed" in body["message"].lower()
# ── POST /api/wizard/complete ─────────────────────────────────────────────────
class TestWizardComplete:
def test_sets_wizard_complete_true(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 6, "name": "Alex"})
# apply_service_urls is a local import inside wizard_complete — patch source module
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("scripts.generate_llm_config.apply_service_urls",
side_effect=Exception("no llm.yaml")):
r = client.post("/api/wizard/complete")
assert r.status_code == 200
assert r.json()["ok"] is True
saved = _read_user_yaml(yaml_path)
assert saved["wizard_complete"] is True
assert "wizard_step" not in saved
assert saved["name"] == "Alex" # other fields preserved
def test_complete_removes_wizard_step(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {"wizard_step": 7, "tier": "paid"})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
with patch("scripts.generate_llm_config.apply_service_urls", return_value=None):
client.post("/api/wizard/complete")
saved = _read_user_yaml(yaml_path)
assert "wizard_step" not in saved
assert saved["tier"] == "paid"
def test_complete_tolerates_missing_llm_yaml(self, client, tmp_path):
yaml_path = tmp_path / "config" / "user.yaml"
_write_user_yaml(yaml_path, {})
with patch("dev_api._wizard_yaml_path", return_value=str(yaml_path)):
# llm.yaml doesn't exist → apply_service_urls is never called, no error
r = client.post("/api/wizard/complete")
assert r.status_code == 200
assert r.json()["ok"] is True

View file

@ -121,8 +121,7 @@ def test_byok_false_preserves_original_gating():
# ── Vue UI Beta & Demo Tier tests ──────────────────────────────────────────────
def test_vue_ui_beta_free_tier():
# Vue SPA is open to all tiers (issue #20 — beta restriction removed)
assert can_use("free", "vue_ui_beta") is True
assert can_use("free", "vue_ui_beta") is False
def test_vue_ui_beta_paid_tier():

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 106 KiB

View file

@ -1,9 +1,9 @@
<template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
<AppNav v-if="!isWizard" />
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
<AppNav />
<main class="app-main" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView />
@ -12,27 +12,21 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import { useTheme } from './composables/useTheme'
import AppNav from './components/AppNav.vue'
import { useDigestStore } from './stores/digest'
const motion = useMotion()
const route = useRoute()
const { toggle, restore } = useHackerMode()
const { initTheme } = useTheme()
const digestStore = useDigestStore()
const isWizard = computed(() => route.path.startsWith('/setup'))
useKonamiCode(toggle)
onMounted(() => {
initTheme() // apply persisted theme (hacker mode takes priority inside initTheme)
restore() // kept for hacker mode re-entry on hard reload (initTheme handles it, belt+suspenders)
restore() // re-apply hacker mode from localStorage on hard reload
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
})
</script>
@ -100,14 +94,4 @@ body {
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
/* Wizard: full-bleed, no sidebar offset, no tab-bar clearance */
.app-root--wizard {
display: block;
}
.app-main--wizard {
margin-left: 0;
padding-bottom: 0;
}
</style>

View file

@ -73,11 +73,11 @@
}
/* Accessible Solarpunk dark (system dark mode)
Activates when OS/browser is in dark mode AND no
explicit theme is selected. Explicit [data-theme="*"]
always wins over the system preference. */
Activates when OS/browser is in dark mode.
Uses :not([data-theme="hacker"]) so the Konami easter
egg always wins over the system preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
:root:not([data-theme="hacker"]) {
/* Brand — lighter greens readable on dark surfaces */
--color-primary: #6ab870;
--color-primary-hover: #7ecb84;
@ -161,153 +161,6 @@
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
}
/* ── Explicit light — forces light even on dark-OS ─ */
[data-theme="light"] {
--color-primary: #2d5a27;
--color-primary-hover: #234820;
--color-primary-light: #e8f2e7;
--color-surface: #eaeff8;
--color-surface-alt: #dde4f0;
--color-surface-raised: #f5f7fc;
--color-border: #a8b8d0;
--color-border-light: #ccd5e6;
--color-text: #1a2338;
--color-text-muted: #4a5c7a;
--color-text-inverse: #eaeff8;
--color-accent: #c4732a;
--color-accent-hover: #a85c1f;
--color-accent-light: #fdf0e4;
--color-success: #3a7a32;
--color-error: #c0392b;
--color-warning: #d4891a;
--color-info: #1e6091;
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
}
/* ── Explicit dark — forces dark even on light-OS ── */
[data-theme="dark"] {
--color-primary: #6ab870;
--color-primary-hover: #7ecb84;
--color-primary-light: #162616;
--color-surface: #16202e;
--color-surface-alt: #1e2a3a;
--color-surface-raised: #263547;
--color-border: #2d4060;
--color-border-light: #233352;
--color-text: #e4eaf5;
--color-text-muted: #8da0bc;
--color-text-inverse: #16202e;
--color-accent: #e8a84a;
--color-accent-hover: #f5bc60;
--color-accent-light: #2d1e0a;
--color-success: #5eb85e;
--color-error: #e05252;
--color-warning: #e8a84a;
--color-info: #4da6e8;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* ── Solarized Dark ──────────────────────────────── */
/* Ethan Schoonover's Solarized palette (dark variant) */
[data-theme="solarized-dark"] {
--color-primary: #2aa198; /* cyan — used as primary brand color */
--color-primary-hover: #35b8ad;
--color-primary-light: #002b36;
--color-surface: #002b36; /* base03 */
--color-surface-alt: #073642; /* base02 */
--color-surface-raised: #0d4352;
--color-border: #073642;
--color-border-light: #0a4a5a;
--color-text: #839496; /* base0 */
--color-text-muted: #657b83; /* base00 */
--color-text-inverse: #002b36;
--color-accent: #b58900; /* yellow */
--color-accent-hover: #cb9f10;
--color-accent-light: #1a1300;
--color-success: #859900; /* green */
--color-error: #dc322f; /* red */
--color-warning: #b58900; /* yellow */
--color-info: #268bd2; /* blue */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.3);
}
/* ── Solarized Light ─────────────────────────────── */
[data-theme="solarized-light"] {
--color-primary: #2aa198; /* cyan */
--color-primary-hover: #1e8a82;
--color-primary-light: #eee8d5;
--color-surface: #fdf6e3; /* base3 */
--color-surface-alt: #eee8d5; /* base2 */
--color-surface-raised: #fffdf7;
--color-border: #d3c9b0;
--color-border-light: #e4dacc;
--color-text: #657b83; /* base00 */
--color-text-muted: #839496; /* base0 */
--color-text-inverse: #fdf6e3;
--color-accent: #b58900; /* yellow */
--color-accent-hover: #9a7300;
--color-accent-light: #fdf0c0;
--color-success: #859900; /* green */
--color-error: #dc322f; /* red */
--color-warning: #b58900; /* yellow */
--color-info: #268bd2; /* blue */
--shadow-sm: 0 1px 3px rgba(101, 123, 131, 0.12), 0 1px 2px rgba(101, 123, 131, 0.08);
--shadow-md: 0 4px 12px rgba(101, 123, 131, 0.15), 0 2px 4px rgba(101, 123, 131, 0.08);
--shadow-lg: 0 10px 30px rgba(101, 123, 131, 0.18), 0 4px 8px rgba(101, 123, 131, 0.08);
}
/* ── Colorblind-safe (deuteranopia/protanopia) ────── */
/* Avoids red/green confusion. Uses blue+orange as the
primary pair; cyan+magenta as semantic differentiators.
Based on Wong (2011) 8-color colorblind-safe palette. */
[data-theme="colorblind"] {
--color-primary: #0072B2; /* blue — safe primary */
--color-primary-hover: #005a8e;
--color-primary-light: #e0f0fa;
--color-surface: #f4f6fb;
--color-surface-alt: #e6eaf4;
--color-surface-raised: #fafbfe;
--color-border: #b0bcd8;
--color-border-light: #cdd5e8;
--color-text: #1a2338;
--color-text-muted: #4a5c7a;
--color-text-inverse: #f4f6fb;
--color-accent: #E69F00; /* orange — safe secondary */
--color-accent-hover: #c98900;
--color-accent-light: #fdf4dc;
--color-success: #009E73; /* teal-green — distinct from red/green confusion zone */
--color-error: #CC0066; /* magenta-red — distinguishable from green */
--color-warning: #E69F00; /* orange */
--color-info: #56B4E9; /* sky blue */
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
}
/* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }

View file

@ -34,31 +34,12 @@
</button>
</div>
<!-- Theme picker -->
<div class="sidebar__theme" v-if="!isHackerMode">
<label class="sidebar__theme-label" for="theme-select">Theme</label>
<select
id="theme-select"
class="sidebar__theme-select"
:value="currentTheme"
@change="setTheme(($event.target as HTMLSelectElement).value as Theme)"
aria-label="Select theme"
>
<option v-for="opt in THEME_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.icon }} {{ opt.label }}
</option>
</select>
</div>
<!-- Settings at bottom -->
<div class="sidebar__footer">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span>
</RouterLink>
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
Classic
</button>
</div>
</nav>
@ -95,10 +76,7 @@ import {
} from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest'
import { useTheme, THEME_OPTIONS, type Theme } from '../composables/useTheme'
const digestStore = useDigestStore()
const { currentTheme, setTheme, restoreTheme } = useTheme()
// Logo click easter egg 9.6: Click the Bird 5× rapidly
const logoClickCount = ref(0)
@ -123,25 +101,8 @@ const isHackerMode = computed(() =>
)
function exitHackerMode() {
delete document.documentElement.dataset.theme
localStorage.removeItem('cf-hacker-mode')
restoreTheme()
}
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
async function switchToClassic() {
// Persist preference via API so Streamlit reads streamlit from user.yaml
// and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
try {
await fetch(_apiBase + '/api/settings/ui-preference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preference: 'streamlit' }),
})
} catch { /* non-fatal — cookie below is enough for immediate redirect */ }
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
// Navigate to root (no query params) Caddy routes to Streamlit based on cookie
window.location.href = window.location.origin + '/'
}
const navLinks = computed(() => [
@ -311,70 +272,6 @@ const mobileLinks = [
margin: 0;
}
.sidebar__classic-btn {
display: flex;
align-items: center;
width: 100%;
padding: var(--space-2) var(--space-3);
margin-top: var(--space-1);
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 500;
cursor: pointer;
opacity: 0.6;
transition: opacity 150ms, background 150ms;
white-space: nowrap;
}
.sidebar__classic-btn:hover {
opacity: 1;
background: var(--color-surface-alt);
}
/* ── Theme picker ───────────────────────────────────── */
.sidebar__theme {
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.sidebar__theme-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sidebar__theme-select {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--text-sm);
font-family: var(--font-body);
cursor: pointer;
appearance: auto;
transition: border-color 150ms ease, background 150ms ease;
}
.sidebar__theme-select:hover {
border-color: var(--color-primary);
background: var(--color-surface-raised);
}
.sidebar__theme-select:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar {
display: none; /* hidden on desktop */

View file

@ -10,7 +10,7 @@
</div>
<template v-else>
<!-- Two-panel layout: job details | cover letter + resume optimizer -->
<!-- Two-panel layout: job details | cover letter -->
<div class="workspace__panels">
<!-- Left: Job details -->
@ -56,49 +56,6 @@
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
</div>
<!-- Resume Highlights -->
<div
v-if="resumeSkills.length || resumeDomains.length || resumeKeywords.length"
class="resume-highlights"
>
<button class="section-toggle" @click="highlightsExpanded = !highlightsExpanded">
<span class="section-toggle__label">My Resume Highlights</span>
<span class="section-toggle__icon" aria-hidden="true">{{ highlightsExpanded ? '▲' : '▼' }}</span>
</button>
<div v-if="highlightsExpanded" class="highlights-body">
<div v-if="resumeSkills.length" class="chips-group">
<span class="chips-group__label">Skills</span>
<div class="chips-wrap">
<span
v-for="s in resumeSkills" :key="s"
class="hl-chip"
:class="{ 'hl-chip--match': jobMatchSet.has(s.toLowerCase()) }"
>{{ s }}</span>
</div>
</div>
<div v-if="resumeDomains.length" class="chips-group">
<span class="chips-group__label">Domains</span>
<div class="chips-wrap">
<span
v-for="d in resumeDomains" :key="d"
class="hl-chip"
:class="{ 'hl-chip--match': jobMatchSet.has(d.toLowerCase()) }"
>{{ d }}</span>
</div>
</div>
<div v-if="resumeKeywords.length" class="chips-group">
<span class="chips-group__label">Keywords</span>
<div class="chips-wrap">
<span
v-for="k in resumeKeywords" :key="k"
class="hl-chip"
:class="{ 'hl-chip--match': jobMatchSet.has(k.toLowerCase()) }"
>{{ k }}</span>
</div>
</div>
</div>
</div>
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
View listing
</a>
@ -141,12 +98,7 @@
<span aria-hidden="true"></span>
<span class="cl-error__msg">Cover letter generation failed</span>
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
<div class="cl-error__actions">
<button class="btn-generate" @click="generate()">Retry</button>
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
Write manually instead
</button>
</div>
<button class="btn-generate" @click="generate()">Retry</button>
</div>
</template>
@ -191,64 +143,6 @@
Regenerate
</button>
<!-- ATS Resume Optimizer -->
<ResumeOptimizerPanel :job-id="props.jobId" />
<!-- Application Q&A -->
<div class="qa-section">
<button class="section-toggle" @click="qaExpanded = !qaExpanded">
<span class="section-toggle__label">Application Q&amp;A</span>
<span v-if="qaItems.length" class="qa-count">{{ qaItems.length }}</span>
<span class="section-toggle__icon" aria-hidden="true">{{ qaExpanded ? '▲' : '▼' }}</span>
</button>
<div v-if="qaExpanded" class="qa-body">
<p v-if="!qaItems.length" class="qa-empty">
No questions yet add one below to get LLM-suggested answers.
</p>
<div v-for="(item, i) in qaItems" :key="item.id" class="qa-item">
<div class="qa-item__header">
<span class="qa-item__q">{{ item.question }}</span>
<button class="qa-item__del" aria-label="Remove question" @click="removeQA(i)"></button>
</div>
<textarea
class="qa-item__answer"
:value="item.answer"
placeholder="Your answer…"
rows="3"
@input="updateAnswer(item.id, ($event.target as HTMLTextAreaElement).value)"
/>
<button
class="btn-ghost btn-ghost--sm qa-suggest-btn"
:disabled="suggesting === item.id"
@click="suggestAnswer(item)"
>
{{ suggesting === item.id ? '✨ Thinking…' : '✨ Suggest' }}
</button>
</div>
<div class="qa-add">
<input
v-model="newQuestion"
class="qa-add__input"
placeholder="Add a question from the application…"
@keydown.enter.prevent="addQA"
/>
<button class="btn-ghost btn-ghost--sm" :disabled="!newQuestion.trim()" @click="addQA">Add</button>
</div>
<button
v-if="qaItems.length"
class="btn-ghost qa-save-btn"
:disabled="qaSaved || qaSaving"
@click="saveQA"
>
{{ qaSaving ? 'Saving…' : (qaSaved ? '✓ Saved' : 'Save All') }}
</button>
</div>
</div>
<!-- Bottom action bar -->
<div class="workspace__actions">
<button
@ -284,7 +178,6 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useApiFetch } from '../composables/useApi'
import type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
const props = defineProps<{ jobId: number }>()
@ -457,96 +350,6 @@ async function rejectListing() {
setTimeout(() => emit('job-removed'), 1000)
}
// Resume highlights
const resumeSkills = ref<string[]>([])
const resumeDomains = ref<string[]>([])
const resumeKeywords = ref<string[]>([])
const highlightsExpanded = ref(false)
// Words from the resume that also appear in the job description text
const jobMatchSet = computed<Set<string>>(() => {
const desc = (job.value?.description ?? '').toLowerCase()
const all = [...resumeSkills.value, ...resumeDomains.value, ...resumeKeywords.value]
return new Set(all.filter(t => desc.includes(t.toLowerCase())))
})
async function fetchResume() {
const { data } = await useApiFetch<{ skills?: string[]; domains?: string[]; keywords?: string[] }>(
'/api/settings/resume',
)
if (!data) return
resumeSkills.value = data.skills ?? []
resumeDomains.value = data.domains ?? []
resumeKeywords.value = data.keywords ?? []
if (resumeSkills.value.length || resumeDomains.value.length || resumeKeywords.value.length) {
highlightsExpanded.value = true
}
}
// Application Q&A
interface QAItem { id: string; question: string; answer: string }
const qaItems = ref<QAItem[]>([])
const qaExpanded = ref(false)
const qaSaved = ref(true)
const qaSaving = ref(false)
const newQuestion = ref('')
const suggesting = ref<string | null>(null)
function addQA() {
const q = newQuestion.value.trim()
if (!q) return
qaItems.value = [...qaItems.value, { id: crypto.randomUUID(), question: q, answer: '' }]
newQuestion.value = ''
qaSaved.value = false
qaExpanded.value = true
}
function removeQA(index: number) {
qaItems.value = qaItems.value.filter((_, i) => i !== index)
qaSaved.value = false
}
function updateAnswer(id: string, value: string) {
qaItems.value = qaItems.value.map(q => q.id === id ? { ...q, answer: value } : q)
qaSaved.value = false
}
async function saveQA() {
qaSaving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/qa`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: qaItems.value }),
})
qaSaving.value = false
if (error) { showToast('Save failed — please try again'); return }
qaSaved.value = true
}
async function suggestAnswer(item: QAItem) {
suggesting.value = item.id
const { data, error } = await useApiFetch<{ answer: string }>(`/api/jobs/${props.jobId}/qa/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: item.question }),
})
suggesting.value = null
if (error || !data?.answer) { showToast('Suggestion failed — check your LLM backend'); return }
qaItems.value = qaItems.value.map(q => q.id === item.id ? { ...q, answer: data.answer } : q)
qaSaved.value = false
}
async function fetchQA() {
const { data } = await useApiFetch<{ items: QAItem[] }>(`/api/jobs/${props.jobId}/qa`)
if (data?.items?.length) {
qaItems.value = data.items
qaExpanded.value = true
}
}
// Toast
const toast = ref<string | null>(null)
@ -594,10 +397,6 @@ onMounted(async () => {
await fetchJob()
loadingJob.value = false
// Load resume highlights and saved Q&A in parallel
fetchResume()
fetchQA()
// Check if a generation task is already in flight
if (clState.value === 'none') {
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
@ -811,7 +610,6 @@ declare module '../stores/review' {
.cl-error__msg { font-weight: 700; }
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
.cl-error__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; }
/* Editor */
.cl-editor {
@ -1035,205 +833,6 @@ declare module '../stores/review' {
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* ── Resume Highlights ───────────────────────────────────────────────── */
.resume-highlights {
border-top: 1px solid var(--color-border-light);
padding-top: var(--space-3);
}
.section-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-align: left;
color: var(--color-text-muted);
}
.section-toggle__label {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
flex: 1;
}
.section-toggle__icon {
font-size: var(--text-xs);
}
.highlights-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-2);
}
.chips-group { display: flex; flex-direction: column; gap: 4px; }
.chips-group__label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
opacity: 0.7;
}
.chips-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.hl-chip {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: 11px;
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
color: var(--color-text-muted);
}
.hl-chip--match {
background: rgba(39, 174, 96, 0.10);
border-color: rgba(39, 174, 96, 0.35);
color: var(--color-success);
font-weight: 600;
}
/* ── Application Q&A ─────────────────────────────────────────────────── */
.qa-section {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
overflow: hidden;
}
.qa-section > .section-toggle {
padding: var(--space-3) var(--space-4);
color: var(--color-text);
}
.qa-section > .section-toggle:hover { background: var(--color-surface-alt); }
.qa-count {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--app-primary-light);
color: var(--app-primary);
font-size: 10px;
font-weight: 700;
}
.qa-body {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border-top: 1px solid var(--color-border-light);
}
.qa-empty {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-2) 0;
}
.qa-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-light);
}
.qa-item:last-of-type { border-bottom: none; }
.qa-item__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.qa-item__q {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
flex: 1;
}
.qa-item__del {
background: none;
border: none;
cursor: pointer;
font-size: var(--text-xs);
color: var(--color-text-muted);
padding: 2px 4px;
flex-shrink: 0;
opacity: 0.5;
transition: opacity 150ms;
}
.qa-item__del:hover { opacity: 1; color: var(--color-error); }
.qa-item__answer {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-sm);
line-height: 1.5;
resize: vertical;
min-height: 72px;
}
.qa-item__answer:focus {
outline: none;
border-color: var(--app-primary);
}
.qa-suggest-btn { align-self: flex-end; }
.qa-add {
display: flex;
gap: var(--space-2);
align-items: center;
}
.qa-add__input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-sm);
min-height: 36px;
}
.qa-add__input:focus {
outline: none;
border-color: var(--app-primary);
}
.qa-add__input::placeholder { color: var(--color-text-muted); }
.qa-save-btn { align-self: flex-end; }
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 900px) {

View file

@ -1,412 +0,0 @@
<template>
<Teleport to="body">
<div class="modal-backdrop" role="dialog" aria-modal="true" :aria-labelledby="`research-title-${jobId}`" @click.self="emit('close')">
<div class="modal-card">
<!-- Header -->
<div class="modal-header">
<h2 :id="`research-title-${jobId}`" class="modal-title">
🔍 {{ jobTitle }} Company Research
</h2>
<div class="modal-header-actions">
<button v-if="state === 'ready'" class="btn-regen" @click="generate" title="Refresh research"> Refresh</button>
<button class="btn-close" @click="emit('close')" aria-label="Close"></button>
</div>
</div>
<!-- Generating state -->
<div v-if="state === 'generating'" class="modal-body modal-body--loading">
<div class="research-spinner" aria-hidden="true" />
<p class="generating-msg">{{ stage ?? 'Researching…' }}</p>
<p class="generating-sub">This takes 3090 seconds depending on your LLM backend.</p>
</div>
<!-- Error state -->
<div v-else-if="state === 'error'" class="modal-body modal-body--error">
<p>Research generation failed.</p>
<p v-if="errorMsg" class="error-detail">{{ errorMsg }}</p>
<button class="btn-primary-sm" @click="generate">Retry</button>
</div>
<!-- Ready state -->
<div v-else-if="state === 'ready' && brief" class="modal-body">
<p v-if="brief.generated_at" class="generated-at">
Updated {{ fmtDate(brief.generated_at) }}
</p>
<section v-if="brief.company_brief" class="research-section">
<h3 class="section-title">🏢 Company</h3>
<p class="section-body">{{ brief.company_brief }}</p>
</section>
<section v-if="brief.ceo_brief" class="research-section">
<h3 class="section-title">👤 Leadership</h3>
<p class="section-body">{{ brief.ceo_brief }}</p>
</section>
<section v-if="brief.talking_points" class="research-section">
<div class="section-title-row">
<h3 class="section-title">💬 Talking Points</h3>
<button class="btn-copy" @click="copy(brief.talking_points!)" :aria-label="copied ? 'Copied!' : 'Copy talking points'">
{{ copied ? '✓ Copied' : '⎘ Copy' }}
</button>
</div>
<p class="section-body">{{ brief.talking_points }}</p>
</section>
<section v-if="brief.tech_brief" class="research-section">
<h3 class="section-title"> Tech Stack</h3>
<p class="section-body">{{ brief.tech_brief }}</p>
</section>
<section v-if="brief.funding_brief" class="research-section">
<h3 class="section-title">💰 Funding & Stage</h3>
<p class="section-body">{{ brief.funding_brief }}</p>
</section>
<section v-if="brief.red_flags" class="research-section research-section--warn">
<h3 class="section-title"> Red Flags</h3>
<p class="section-body">{{ brief.red_flags }}</p>
</section>
<section v-if="brief.accessibility_brief" class="research-section">
<h3 class="section-title"> Inclusion & Accessibility</h3>
<p class="section-body section-body--private">{{ brief.accessibility_brief }}</p>
<p class="private-note">For your decision-making only not disclosed in applications.</p>
</section>
</div>
<!-- Empty state (no research, not generating) -->
<div v-else class="modal-body modal-body--empty">
<p>No research yet for this company.</p>
<button class="btn-primary-sm" @click="generate">🔍 Generate Research</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
const props = defineProps<{
jobId: number
jobTitle: string
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: [] }>()
interface ResearchBrief {
company_brief: string | null
ceo_brief: string | null
talking_points: string | null
tech_brief: string | null
funding_brief: string | null
red_flags: string | null
accessibility_brief: string | null
generated_at: string | null
}
type ModalState = 'loading' | 'generating' | 'ready' | 'empty' | 'error'
const state = ref<ModalState>('loading')
const brief = ref<ResearchBrief | null>(null)
const stage = ref<string | null>(null)
const errorMsg = ref<string | null>(null)
const copied = ref(false)
let pollId: ReturnType<typeof setInterval> | null = null
function fmtDate(iso: string) {
const d = new Date(iso)
const diffH = Math.round((Date.now() - d.getTime()) / 3600000)
if (diffH < 1) return 'just now'
if (diffH < 24) return `${diffH}h ago`
if (diffH < 168) return `${Math.floor(diffH / 24)}d ago`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
async function copy(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function stopPoll() {
if (pollId) { clearInterval(pollId); pollId = null }
}
async function pollTask() {
const { data } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
`/api/jobs/${props.jobId}/research/task`,
)
if (!data) return
stage.value = data.stage
if (data.status === 'completed') {
stopPoll()
await load()
} else if (data.status === 'failed') {
stopPoll()
state.value = 'error'
errorMsg.value = data.message ?? 'Unknown error'
}
}
async function load() {
const { data, error } = await useApiFetch<ResearchBrief>(`/api/jobs/${props.jobId}/research`)
if (error) {
if (error.kind === 'http' && error.status === 404) {
// Check if a task is running
const { data: task } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
`/api/jobs/${props.jobId}/research/task`,
)
if (task && (task.status === 'queued' || task.status === 'running')) {
state.value = 'generating'
stage.value = task.stage
pollId = setInterval(pollTask, 3000)
} else if (props.autoGenerate) {
await generate()
} else {
state.value = 'empty'
}
} else {
state.value = 'error'
errorMsg.value = error.kind === 'http' ? error.detail : error.message
}
return
}
brief.value = data
state.value = 'ready'
}
async function generate() {
state.value = 'generating'
stage.value = null
errorMsg.value = null
stopPoll()
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/research/generate`, { method: 'POST' })
if (error) {
state.value = 'error'
errorMsg.value = error.kind === 'http' ? error.detail : error.message
return
}
pollId = setInterval(pollTask, 3000)
}
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(async () => {
document.addEventListener('keydown', onEsc)
await load()
})
onUnmounted(() => {
document.removeEventListener('keydown', onEsc)
stopPoll()
})
</script>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 500;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-8) var(--space-4);
overflow-y: auto;
}
.modal-card {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 620px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border-light);
}
.modal-title {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
margin: 0;
line-height: 1.3;
}
.modal-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.btn-close {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: var(--color-text-muted);
padding: 2px 6px;
}
.btn-regen {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
color: var(--color-text-muted);
padding: 2px 8px;
}
.modal-body {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-height: 70vh;
overflow-y: auto;
}
.modal-body--loading {
align-items: center;
text-align: center;
padding: var(--space-10) var(--space-6);
gap: var(--space-4);
}
.modal-body--empty {
align-items: center;
text-align: center;
padding: var(--space-10) var(--space-6);
gap: var(--space-4);
color: var(--color-text-muted);
}
.modal-body--error {
align-items: center;
text-align: center;
padding: var(--space-8) var(--space-6);
gap: var(--space-3);
color: var(--color-error);
}
.error-detail {
font-size: 0.8rem;
opacity: 0.8;
}
.research-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.generating-msg {
font-weight: 600;
color: var(--color-text);
}
.generating-sub {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.generated-at {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: calc(-1 * var(--space-2));
}
.research-section {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.research-section:last-child {
border-bottom: none;
padding-bottom: 0;
}
.research-section--warn .section-title {
color: var(--color-warning);
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin: 0;
}
.section-body {
font-size: 0.875rem;
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
}
.section-body--private {
font-style: italic;
}
.private-note {
font-size: 0.7rem;
color: var(--color-text-muted);
}
.btn-copy {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.72rem;
color: var(--color-text-muted);
padding: 2px 8px;
transition: color 150ms, border-color 150ms;
}
.btn-copy:hover { color: var(--color-primary); border-color: var(--color-primary); }
.btn-primary-sm {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-5);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
</style>

View file

@ -13,7 +13,6 @@ const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
research: [jobId: number]
}>()
// Signal state
@ -181,7 +180,6 @@ const columnColor = computed(() => {
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('research', job.id)">🔍 Research</button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"

View file

@ -1,495 +0,0 @@
<template>
<section class="rop" aria-labelledby="rop-heading">
<h2 id="rop-heading" class="rop__heading">ATS Resume Optimizer</h2>
<!-- Tier gate notice (free) -->
<p v-if="isFree" class="rop__tier-note">
<span aria-hidden="true">🔒</span>
Keyword gap report is free. Full AI rewrite requires a
<strong>Paid</strong> license.
</p>
<!-- Gap report section (all tiers) -->
<div class="rop__gaps">
<div class="rop__gaps-header">
<h3 class="rop__subheading">Keyword Gap Report</h3>
<button
class="btn-generate"
:disabled="gapState === 'queued' || gapState === 'running'"
@click="runGapReport"
>
<span aria-hidden="true">🔍</span>
{{ gapState === 'queued' || gapState === 'running' ? 'Analyzing…' : 'Analyze Keywords' }}
</button>
</div>
<template v-if="gapState === 'queued' || gapState === 'running'">
<div class="rop__spinner-row" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>{{ gapStage ?? 'Extracting keyword gaps…' }}</span>
</div>
</template>
<template v-else-if="gapState === 'failed'">
<p class="rop__error" role="alert">Gap analysis failed. Try again.</p>
</template>
<template v-else-if="gaps.length > 0">
<div class="rop__gap-list" role="list" aria-label="Keyword gaps by section">
<div
v-for="item in gaps"
:key="item.term"
class="rop__gap-item"
:class="`rop__gap-item--p${item.priority}`"
role="listitem"
>
<span class="rop__gap-section" :title="`Route to ${item.section}`">{{ item.section }}</span>
<span class="rop__gap-term">{{ item.term }}</span>
<span class="rop__gap-rationale">{{ item.rationale }}</span>
</div>
</div>
</template>
<template v-else-if="gapState === 'completed'">
<p class="rop__empty">No significant keyword gaps found your resume already covers this JD well.</p>
</template>
<template v-else>
<p class="rop__hint">Click <em>Analyze Keywords</em> to see which ATS terms your resume is missing.</p>
</template>
</div>
<!-- Full rewrite section (paid+) -->
<div v-if="!isFree" class="rop__rewrite">
<div class="rop__gaps-header">
<h3 class="rop__subheading">Optimized Resume</h3>
<button
class="btn-generate"
:disabled="rewriteState === 'queued' || rewriteState === 'running' || gaps.length === 0"
:title="gaps.length === 0 ? 'Run gap analysis first' : ''"
@click="runFullRewrite"
>
<span aria-hidden="true"></span>
{{ rewriteState === 'queued' || rewriteState === 'running' ? 'Rewriting…' : 'Optimize Resume' }}
</button>
</div>
<template v-if="rewriteState === 'queued' || rewriteState === 'running'">
<div class="rop__spinner-row" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>{{ rewriteStage ?? 'Rewriting resume sections…' }}</span>
</div>
</template>
<template v-else-if="rewriteState === 'failed'">
<p class="rop__error" role="alert">Resume rewrite failed. Check that a resume file is configured in Settings.</p>
</template>
<template v-else-if="optimizedResume">
<!-- Hallucination warning shown when the task message flags it -->
<div v-if="hallucinationWarning" class="rop__hallucination-badge" role="alert">
<span aria-hidden="true"></span>
Hallucination check failed the rewrite introduced content not in your original resume.
The optimized version has been discarded; only the gap report is available.
</div>
<div class="rop__rewrite-toolbar">
<span class="rop__wordcount" aria-live="polite">{{ rewriteWordCount }} words</span>
<span class="rop__verified-badge" aria-label="Hallucination check passed"> Verified</span>
</div>
<textarea
v-model="optimizedResume"
class="rop__textarea"
aria-label="Optimized resume text"
spellcheck="false"
/>
<button class="btn-download" @click="downloadTxt">
<span aria-hidden="true">📄</span> Download .txt
</button>
</template>
<template v-else>
<p class="rop__hint">
Run <em>Analyze Keywords</em> first, then click <em>Optimize Resume</em> to rewrite your resume
sections to naturally incorporate missing ATS keywords.
</p>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
import { useAppConfigStore } from '../stores/appConfig'
const props = defineProps<{ jobId: number }>()
const config = useAppConfigStore()
const isFree = computed(() => config.tier === 'free')
// Gap report state
type TaskState = 'none' | 'queued' | 'running' | 'completed' | 'failed'
const gapState = ref<TaskState>('none')
const gapStage = ref<string | null>(null)
const gaps = ref<Array<{ term: string; section: string; priority: number; rationale: string }>>([])
// Rewrite state
const rewriteState = ref<TaskState>('none')
const rewriteStage = ref<string | null>(null)
const optimizedResume = ref('')
const hallucinationWarning = ref(false)
const rewriteWordCount = computed(() =>
optimizedResume.value.trim().split(/\s+/).filter(Boolean).length
)
// Task polling
let pollTimer: ReturnType<typeof setInterval> | null = null
function startPolling() {
stopPolling()
pollTimer = setInterval(pollTaskStatus, 3000)
}
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollTaskStatus() {
const { data } = await useApiFetch<{ status: string; stage: string | null; message: string | null }>(
`/api/jobs/${props.jobId}/resume_optimizer/task`
)
if (!data) return
const status = data.status as TaskState
// Update whichever phase is in-flight
if (gapState.value === 'queued' || gapState.value === 'running') {
gapState.value = status
gapStage.value = data.stage ?? null
if (status === 'completed' || status === 'failed') {
stopPolling()
if (status === 'completed') await loadResults()
}
} else if (rewriteState.value === 'queued' || rewriteState.value === 'running') {
rewriteState.value = status
rewriteStage.value = data.stage ?? null
if (status === 'completed' || status === 'failed') {
stopPolling()
if (status === 'completed') await loadResults()
}
}
}
// Load existing results
async function loadResults() {
const { data } = await useApiFetch<{
optimized_resume: string
ats_gap_report: Array<{ term: string; section: string; priority: number; rationale: string }>
}>(`/api/jobs/${props.jobId}/resume_optimizer`)
if (!data) return
if (data.ats_gap_report?.length) {
gaps.value = data.ats_gap_report
gapState.value = 'completed'
}
if (data.optimized_resume) {
optimizedResume.value = data.optimized_resume
rewriteState.value = 'completed'
}
}
// Actions
async function runGapReport() {
gapState.value = 'queued'
gapStage.value = null
gaps.value = []
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/resume_optimizer/generate`, {
method: 'POST',
body: JSON.stringify({ full_rewrite: false }),
headers: { 'Content-Type': 'application/json' },
})
if (error) {
gapState.value = 'failed'
return
}
startPolling()
}
async function runFullRewrite() {
rewriteState.value = 'queued'
rewriteStage.value = null
optimizedResume.value = ''
hallucinationWarning.value = false
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/resume_optimizer/generate`, {
method: 'POST',
body: JSON.stringify({ full_rewrite: true }),
headers: { 'Content-Type': 'application/json' },
})
if (error) {
rewriteState.value = 'failed'
return
}
startPolling()
}
function downloadTxt() {
const blob = new Blob([optimizedResume.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `resume-optimized-job-${props.jobId}.txt`
a.click()
URL.revokeObjectURL(url)
}
// Lifecycle
onMounted(async () => {
await loadResults()
// Resume polling if a task was still in-flight when the page last unloaded
const { data } = await useApiFetch<{ status: string }>(
`/api/jobs/${props.jobId}/resume_optimizer/task`
)
if (data?.status === 'queued' || data?.status === 'running') {
// Restore in-flight state to whichever phase makes sense
if (!optimizedResume.value && !gaps.value.length) {
gapState.value = data.status as TaskState
} else if (gaps.value.length) {
rewriteState.value = data.status as TaskState
}
startPolling()
}
})
onUnmounted(stopPolling)
</script>
<style scoped>
.rop {
display: flex;
flex-direction: column;
gap: var(--space-5, 1.25rem);
padding: var(--space-4, 1rem);
border-top: 1px solid var(--app-border, #e2e8f0);
}
.rop__heading {
font-size: var(--font-lg, 1.125rem);
font-weight: 600;
color: var(--app-text, #1e293b);
margin: 0;
}
.rop__subheading {
font-size: var(--font-base, 1rem);
font-weight: 600;
color: var(--app-text, #1e293b);
margin: 0;
}
.rop__tier-note {
font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b);
background: var(--app-surface-alt, #f8fafc);
border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
margin: 0;
}
.rop__gaps,
.rop__rewrite {
display: flex;
flex-direction: column;
gap: var(--space-3, 0.75rem);
}
.rop__gaps-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3, 0.75rem);
}
.rop__hint,
.rop__empty {
font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b);
margin: 0;
}
.rop__error {
font-size: var(--font-sm, 0.875rem);
color: var(--app-danger, #dc2626);
margin: 0;
}
.rop__spinner-row {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b);
}
/* ── Gap list ─────────────────────────────────────────────────────── */
.rop__gap-list {
display: flex;
flex-direction: column;
gap: var(--space-1, 0.25rem);
}
.rop__gap-item {
display: grid;
grid-template-columns: 6rem 1fr;
grid-template-rows: auto auto;
gap: 0 var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border-radius: var(--radius-sm, 0.25rem);
border-left: 3px solid transparent;
background: var(--app-surface-alt, #f8fafc);
font-size: var(--font-sm, 0.875rem);
}
.rop__gap-item--p1 { border-left-color: var(--app-accent, #6366f1); }
.rop__gap-item--p2 { border-left-color: var(--app-warning, #f59e0b); }
.rop__gap-item--p3 { border-left-color: var(--app-border, #e2e8f0); }
.rop__gap-section {
grid-row: 1;
grid-column: 1;
font-size: var(--font-xs, 0.75rem);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--app-text-muted, #64748b);
align-self: center;
}
.rop__gap-term {
grid-row: 1;
grid-column: 2;
font-weight: 500;
color: var(--app-text, #1e293b);
}
.rop__gap-rationale {
grid-row: 2;
grid-column: 2;
font-size: var(--font-xs, 0.75rem);
color: var(--app-text-muted, #64748b);
}
/* ── Rewrite output ───────────────────────────────────────────────── */
.rop__rewrite-toolbar {
display: flex;
align-items: center;
gap: var(--space-3, 0.75rem);
justify-content: space-between;
}
.rop__wordcount {
font-size: var(--font-sm, 0.875rem);
color: var(--app-text-muted, #64748b);
}
.rop__verified-badge {
font-size: var(--font-xs, 0.75rem);
font-weight: 600;
color: var(--app-success, #16a34a);
background: color-mix(in srgb, var(--app-success, #16a34a) 10%, transparent);
padding: 0.2em 0.6em;
border-radius: var(--radius-full, 9999px);
}
.rop__hallucination-badge {
display: flex;
align-items: flex-start;
gap: var(--space-2, 0.5rem);
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem);
color: var(--app-danger, #dc2626);
}
.rop__textarea {
width: 100%;
min-height: 20rem;
padding: var(--space-3, 0.75rem);
font-family: var(--font-mono, monospace);
font-size: var(--font-sm, 0.875rem);
line-height: 1.6;
border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem);
background: var(--app-surface, #fff);
color: var(--app-text, #1e293b);
resize: vertical;
box-sizing: border-box;
}
.rop__textarea:focus {
outline: 2px solid var(--app-accent, #6366f1);
outline-offset: 2px;
}
/* ── Buttons (inherit app-wide classes) ──────────────────────────── */
.btn-generate {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
background: var(--app-accent, #6366f1);
color: #fff;
border: none;
border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem);
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover, #4f46e5); }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-download {
display: inline-flex;
align-items: center;
gap: var(--space-2, 0.5rem);
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
background: var(--app-surface-alt, #f8fafc);
color: var(--app-text, #1e293b);
border: 1px solid var(--app-border, #e2e8f0);
border-radius: var(--radius-md, 0.5rem);
font-size: var(--font-sm, 0.875rem);
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
align-self: flex-start;
}
.btn-download:hover { background: var(--app-border, #e2e8f0); }
@media (max-width: 640px) {
.rop__gaps-header { flex-direction: column; align-items: flex-start; }
.btn-generate { width: 100%; justify-content: center; }
}
</style>

View file

@ -1,205 +0,0 @@
<template>
<!-- Desktop: inline queue in sidebar footer -->
<div v-if="count > 0" class="task-indicator task-indicator--sidebar" aria-live="polite" role="status">
<template v-for="group in groups" :key="group.primary.id">
<!-- Primary task row -->
<div class="task-row task-row--primary">
<span class="task-row__spinner" :class="`task-row__spinner--${group.primary.status}`" aria-hidden="true" />
<span class="task-row__label">{{ TASK_LABEL[group.primary.task_type] ?? group.primary.task_type }}</span>
<span class="task-row__status">{{ group.primary.status }}</span>
</div>
<!-- Pipeline sub-steps (indented) -->
<div
v-for="step in group.steps"
:key="step.id"
class="task-row task-row--step"
:class="`task-row--${step.status}`"
>
<span class="task-row__indent" aria-hidden="true"></span>
<span class="task-row__spinner" :class="`task-row__spinner--${step.status}`" aria-hidden="true" />
<span class="task-row__label">{{ TASK_LABEL[step.task_type] ?? step.task_type }}</span>
<span class="task-row__status">{{ step.status }}</span>
</div>
</template>
</div>
<!-- Mobile: fixed pill above bottom tab bar (compact keeps existing design) -->
<Transition name="task-pill">
<div
v-if="count > 0"
class="task-indicator task-indicator--pill"
aria-live="polite"
role="status"
>
<span class="task-indicator__spinner" aria-hidden="true" />
<span class="task-indicator__label">{{ label }}</span>
<span class="task-indicator__badge">{{ count }}</span>
</div>
</Transition>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useTasksStore, TASK_LABEL } from '../stores/tasks'
import { storeToRefs } from 'pinia'
const store = useTasksStore()
const { count, groups, label } = storeToRefs(store)
onMounted(store.startPolling)
onUnmounted(store.stopPolling)
</script>
<style scoped>
/* ── Shared ─────────────────────────────────────────── */
.task-indicator {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Spinner — CSS-only rotating ring */
.task-indicator__spinner {
flex-shrink: 0;
width: 14px;
height: 14px;
border: 2px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: task-spin 0.8s linear infinite;
}
@keyframes task-spin {
to { transform: rotate(360deg); }
}
.task-indicator__label {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-indicator__badge {
font-size: var(--text-xs);
font-weight: 700;
background: var(--app-primary);
color: white;
border-radius: var(--radius-full);
min-width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* ── Desktop sidebar variant — shown by the sidebar, hidden on mobile ── */
.task-indicator--sidebar {
padding: var(--space-2) var(--space-4);
border-top: 1px solid var(--color-border-light);
flex-direction: column;
gap: var(--space-1);
align-items: stretch;
}
/* ── Task rows ─────────────────────────────────────── */
.task-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 26px;
}
.task-row--primary { padding: var(--space-1) 0; }
.task-row--step {
padding-left: var(--space-3);
opacity: 0.75;
}
.task-row--queued { opacity: 0.5; }
.task-row__indent {
font-size: var(--text-xs);
color: var(--color-text-muted);
flex-shrink: 0;
line-height: 1;
}
.task-row__spinner {
flex-shrink: 0;
width: 10px;
height: 10px;
border-radius: 50%;
}
.task-row__spinner--running {
border: 1.5px solid color-mix(in srgb, var(--app-primary) 30%, transparent);
border-top-color: var(--app-primary);
animation: task-spin 0.8s linear infinite;
}
.task-row__spinner--queued {
border: 1.5px solid var(--color-border);
background: transparent;
}
.task-row__label {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-row__status {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
opacity: 0.6;
flex-shrink: 0;
}
/* ── Mobile pill variant — fixed above tab bar ─────── */
.task-indicator--pill {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-2));
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-1) var(--space-3);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 200;
pointer-events: none;
/* hidden on desktop, shown on mobile */
display: none;
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1023px) {
.task-indicator--sidebar { display: none; }
.task-indicator--pill { display: flex; }
}
@media (min-width: 1024px) {
.task-indicator--pill { display: none; }
}
/* ── Transition (pill slide-up) ─────────────────────── */
.task-pill-enter-active,
.task-pill-leave-active {
transition: opacity 200ms ease, transform 200ms ease;
}
.task-pill-enter-from,
.task-pill-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
</style>

View file

@ -2,15 +2,12 @@ export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
export async function useApiFetch<T>(
url: string,
opts?: RequestInit,
): Promise<{ data: T | null; error: ApiError | null }> {
try {
const res = await fetch(_apiBase + url, opts)
const res = await fetch(url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
@ -34,7 +31,7 @@ export function useApiSSE(
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(_apiBase + url)
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>

View file

@ -1,5 +1,4 @@
import { onMounted, onUnmounted } from 'vue'
import { useTheme } from './useTheme'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
@ -32,10 +31,8 @@ export function useHackerMode() {
function toggle() {
const root = document.documentElement
if (root.dataset.theme === 'hacker') {
delete root.dataset.theme
localStorage.removeItem('cf-hacker-mode')
// Let useTheme restore the user's chosen theme rather than just deleting data-theme
const { restoreTheme } = useTheme()
restoreTheme()
} else {
root.dataset.theme = 'hacker'
localStorage.setItem('cf-hacker-mode', 'true')

View file

@ -1,82 +0,0 @@
/**
* useTheme manual theme picker for Peregrine.
*
* Themes: 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
* Persisted in localStorage under 'cf-theme'.
* Applied via document.documentElement.dataset.theme.
* 'auto' removes the attribute so the @media prefers-color-scheme rule takes effect.
*
* Hacker mode sits on top of this system toggling it off calls restoreTheme()
* so the user's chosen theme is reinstated rather than dropping back to auto.
*/
import { ref, readonly } from 'vue'
import { useApiFetch } from './useApi'
export type Theme = 'auto' | 'light' | 'dark' | 'solarized-dark' | 'solarized-light' | 'colorblind'
const STORAGE_KEY = 'cf-theme'
const HACKER_KEY = 'cf-hacker-mode'
export const THEME_OPTIONS: { value: Theme; label: string; icon: string }[] = [
{ value: 'auto', label: 'Auto', icon: '⬡' },
{ value: 'light', label: 'Light', icon: '☀' },
{ value: 'dark', label: 'Dark', icon: '🌙' },
{ value: 'solarized-light', label: 'Solarized Light', icon: '🌤' },
{ value: 'solarized-dark', label: 'Solarized Dark', icon: '🌃' },
{ value: 'colorblind', label: 'Colorblind Safe', icon: '♿' },
]
// Module-level singleton so all consumers share the same reactive state.
const _current = ref<Theme>(_load())
function _load(): Theme {
return (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'auto'
}
function _apply(theme: Theme) {
const root = document.documentElement
if (theme === 'auto') {
delete root.dataset.theme
} else {
root.dataset.theme = theme
}
}
export function useTheme() {
function setTheme(theme: Theme) {
_current.value = theme
localStorage.setItem(STORAGE_KEY, theme)
_apply(theme)
// Best-effort persist to server; ignore failures (works offline / local LLM)
useApiFetch('/api/settings/theme', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme }),
}).catch(() => {})
}
/** Restore user's chosen theme — called when hacker mode or other overlays exit. */
function restoreTheme() {
// Hacker mode clears itself; we only restore if it's actually off.
if (localStorage.getItem(HACKER_KEY) === 'true') return
_apply(_current.value)
}
/** Call once at app boot to apply persisted theme before first render. */
function initTheme() {
// Hacker mode takes priority on restore.
if (localStorage.getItem(HACKER_KEY) === 'true') {
document.documentElement.dataset.theme = 'hacker'
} else {
_apply(_current.value)
}
}
return {
currentTheme: readonly(_current),
setTheme,
restoreTheme,
initTheme,
}
}

View file

@ -1,10 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard'
import { wizardGuard } from './wizardGuard'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('../views/HomeView.vue') },
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
@ -32,40 +31,14 @@ export const router = createRouter({
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
],
},
// Onboarding wizard — full-page layout, no AppNav
{
path: '/setup',
component: () => import('../views/wizard/WizardLayout.vue'),
children: [
{ path: '', redirect: '/setup/hardware' },
{ path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') },
{ path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') },
{ path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') },
{ path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') },
{ path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') },
{ path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') },
{ path: 'integrations', component: () => import('../views/wizard/WizardIntegrationsStep.vue') },
],
},
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to, _from, next) => {
if (!to.path.startsWith('/settings/')) return next()
const config = useAppConfigStore()
if (!config.loaded) await config.load()
// Wizard gate runs first for every route except /setup itself
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
return next('/setup')
}
// /setup routes: let wizardGuard handle complete→redirect-to-home logic
if (to.path.startsWith('/setup')) return wizardGuard(to, _from, next)
// Settings tier-gating (runs only when wizard is complete)
if (to.path.startsWith('/settings/')) return settingsGuard(to, _from, next)
next()
settingsGuard(to, _from, next)
})

View file

@ -1,35 +0,0 @@
import { useAppConfigStore } from '../stores/appConfig'
import { useWizardStore } from '../stores/wizard'
/**
* Gate the entire app behind /setup until wizard_complete is true.
*
* Rules:
* - Any non-/setup route while wizard is incomplete redirect to /setup
* - /setup/* while wizard is complete redirect to /
* - /setup with no step suffix redirect to the current step route
*
* Must run AFTER appConfig.load() has resolved (called from router.beforeEach).
*/
export async function wizardGuard(
to: { path: string },
_from: unknown,
next: (to?: string | { path: string }) => void,
): Promise<void> {
const config = useAppConfigStore()
// Ensure config is loaded before inspecting wizardComplete
if (!config.loaded) await config.load()
const onSetup = to.path.startsWith('/setup')
const complete = config.wizardComplete
// Wizard done — keep user out of /setup
if (complete && onSetup) return next('/')
// Wizard not done — redirect to setup
if (!complete && !onSetup) return next('/setup')
// On /setup exactly (no step) — delegate to WizardLayout which loads status
next()
}

View file

@ -11,25 +11,20 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const tier = ref<Tier>('free')
const contractedClient = ref(false)
const inferenceProfile = ref<InferenceProfile>('cpu')
const isDemo = ref(false)
const wizardComplete = ref(true) // optimistic default — guard corrects on load
const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
async function load() {
const { data } = await useApiFetch<{
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
isCloud: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile
wizardComplete: boolean
}>('/api/config/app')
if (!data) return
isCloud.value = data.isCloud
isDemo.value = data.isDemo ?? false
isDevMode.value = data.isDevMode
tier.value = data.tier
contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile
wizardComplete.value = data.wizardComplete ?? true
loaded.value = true
}
@ -43,5 +38,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
}
}
return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
})

View file

@ -2,12 +2,6 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface TrainingPair {
index: number
instruction: string
source_file: string
}
export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1)
const inFlightJob = ref(false)
@ -16,8 +10,6 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
const quotaRemaining = ref<number | null>(null)
const uploading = ref(false)
const loading = ref(false)
const pairs = ref<TrainingPair[]>([])
const pairsLoading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null
function resetStep() { step.value = 1 }
@ -45,26 +37,6 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
}
async function loadPairs() {
pairsLoading.value = true
const { data } = await useApiFetch<{ pairs: TrainingPair[]; total: number }>('/api/settings/fine-tune/pairs')
pairsLoading.value = false
if (data) {
pairs.value = data.pairs
pairsCount.value = data.total
}
}
async function deletePair(index: number) {
const { data } = await useApiFetch<{ ok: boolean; remaining: number }>(
`/api/settings/fine-tune/pairs/${index}`, { method: 'DELETE' }
)
if (data?.ok) {
pairs.value = pairs.value.filter(p => p.index !== index).map((p, i) => ({ ...p, index: i }))
pairsCount.value = data.remaining
}
}
return {
step,
inFlightJob,
@ -73,14 +45,10 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
quotaRemaining,
uploading,
loading,
pairs,
pairsLoading,
resetStep,
loadStatus,
startPolling,
stopPolling,
submitJob,
loadPairs,
deletePair,
}
})

View file

@ -18,7 +18,6 @@ export const useSearchStore = defineStore('settings/search', () => {
const titleSuggestions = ref<string[]>([])
const locationSuggestions = ref<string[]>([])
const excludeSuggestions = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
@ -100,24 +99,10 @@ export const useSearchStore = defineStore('settings/search', () => {
arr.value = arr.value.filter(v => v !== value)
}
async function suggestExcludeKeywords() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'exclude_keywords', current: exclude_keywords.value }),
})
if (data?.suggestions) {
excludeSuggestions.value = data.suggestions.filter(s => !exclude_keywords.value.includes(s))
}
}
function acceptSuggestion(type: 'title' | 'location' | 'exclude', value: string) {
function acceptSuggestion(type: 'title' | 'location', value: string) {
if (type === 'title') {
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
} else if (type === 'exclude') {
if (!exclude_keywords.value.includes(value)) exclude_keywords.value = [...exclude_keywords.value, value]
excludeSuggestions.value = excludeSuggestions.value.filter(s => s !== value)
} else {
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
@ -133,9 +118,8 @@ export const useSearchStore = defineStore('settings/search', () => {
return {
remote_preference, job_titles, locations, exclude_keywords, job_boards,
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
titleSuggestions, locationSuggestions, excludeSuggestions,
titleSuggestions, locationSuggestions,
loading, saving, saveError, loadError,
load, save, suggestTitles, suggestLocations, suggestExcludeKeywords,
addTag, removeTag, acceptSuggestion, toggleBoard,
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, toggleBoard,
}
})

View file

@ -1,101 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export interface ActiveTask {
id: number
task_type: string
job_id: number
status: 'running' | 'queued'
}
export const TASK_LABEL: Record<string, string> = {
cover_letter: 'Cover letter',
company_research: 'Research',
discovery: 'Discovery',
enrich_descriptions: 'Enriching descriptions',
score: 'Scoring matches',
scrape_url: 'Scraping listing',
email_sync: 'Email sync',
wizard_generate: 'Wizard',
prepare_training: 'Training data',
}
/**
* Ordered pipeline stages tasks are visually grouped under discovery
* when they appear together, showing users the full auto-chain.
*/
export const DISCOVERY_PIPELINE = ['discovery', 'enrich_descriptions', 'score'] as const
/** Group active tasks into pipeline groups for display.
* Non-pipeline tasks (cover_letter, email_sync, etc.) each form their own group.
*/
export interface TaskGroup {
primary: ActiveTask
steps: ActiveTask[] // pipeline children, empty for non-pipeline tasks
}
export function groupTasks(tasks: ActiveTask[]): TaskGroup[] {
const pipelineSet = new Set(DISCOVERY_PIPELINE as readonly string[])
const pipelineTasks = tasks.filter(t => pipelineSet.has(t.task_type))
const otherTasks = tasks.filter(t => !pipelineSet.has(t.task_type))
const groups: TaskGroup[] = []
// Build one discovery pipeline group from all pipeline tasks in order
if (pipelineTasks.length) {
const ordered = [...DISCOVERY_PIPELINE]
.map(type => pipelineTasks.find(t => t.task_type === type))
.filter(Boolean) as ActiveTask[]
groups.push({ primary: ordered[0], steps: ordered.slice(1) })
}
// Each non-pipeline task is its own group
for (const task of otherTasks) {
groups.push({ primary: task, steps: [] })
}
return groups
}
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref<ActiveTask[]>([])
const count = computed(() => tasks.value.length)
const groups = computed(() => groupTasks(tasks.value))
const label = computed(() => {
if (!tasks.value.length) return ''
const first = tasks.value[0]
const name = TASK_LABEL[first.task_type] ?? first.task_type
return tasks.value.length === 1 ? name : `${name} +${tasks.value.length - 1}`
})
// Callback registered by views that want counts refreshed while tasks run
let _onTasksClear: (() => void) | null = null
let _tasksWereActive = false
function onTasksClear(cb: () => void) { _onTasksClear = cb }
let _timer: ReturnType<typeof setInterval> | null = null
async function poll() {
const { data } = await useApiFetch<{ count: number; tasks: ActiveTask[] }>('/api/tasks/active')
if (!data) return
const wasActive = _tasksWereActive
tasks.value = data.tasks
_tasksWereActive = data.tasks.length > 0
// Fire callback when task queue just cleared so counts can update
if (wasActive && !_tasksWereActive && _onTasksClear) _onTasksClear()
}
function startPolling() {
if (_timer) return
poll()
_timer = setInterval(poll, 4000)
}
function stopPolling() {
if (_timer) { clearInterval(_timer); _timer = null }
}
return { tasks, count, groups, label, poll, startPolling, stopPolling, onTasksClear }
})

View file

@ -1,279 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export type WizardTier = 'free' | 'paid' | 'premium'
export interface WorkExperience {
title: string
company: string
start_date: string
end_date: string
bullets: string[]
}
export interface WizardHardwareData {
gpus: string[]
suggestedProfile: WizardProfile
selectedProfile: WizardProfile
}
export interface WizardSearchData {
titles: string[]
locations: string[]
}
export interface WizardIdentityData {
name: string
email: string
phone: string
linkedin: string
careerSummary: string
}
export interface WizardInferenceData {
anthropicKey: string
openaiUrl: string
openaiKey: string
ollamaHost: string
ollamaPort: number
services: Record<string, string | number>
confirmed: boolean
testMessage: string
}
// Total mandatory steps (integrations step 7 is optional/skip-able)
export const WIZARD_STEPS = 6
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations']
export const STEP_ROUTES = [
'/setup/hardware',
'/setup/tier',
'/setup/resume',
'/setup/identity',
'/setup/inference',
'/setup/search',
'/setup/integrations',
]
export const useWizardStore = defineStore('wizard', () => {
// ── Navigation state ──────────────────────────────────────────────────────
const currentStep = ref(1) // 1-based; 7 = integrations (optional)
const loading = ref(false)
const saving = ref(false)
const errors = ref<string[]>([])
// ── Step data ─────────────────────────────────────────────────────────────
const hardware = ref<WizardHardwareData>({
gpus: [],
suggestedProfile: 'remote',
selectedProfile: 'remote',
})
const tier = ref<WizardTier>('free')
const resume = ref<{ experience: WorkExperience[]; parsedData: Record<string, unknown> | null }>({
experience: [],
parsedData: null,
})
const identity = ref<WizardIdentityData>({
name: '',
email: '',
phone: '',
linkedin: '',
careerSummary: '',
})
const inference = ref<WizardInferenceData>({
anthropicKey: '',
openaiUrl: '',
openaiKey: '',
ollamaHost: 'localhost',
ollamaPort: 11434,
services: {},
confirmed: false,
testMessage: '',
})
const search = ref<WizardSearchData>({
titles: [],
locations: [],
})
// ── Computed ──────────────────────────────────────────────────────────────
const progressFraction = computed(() =>
Math.min((currentStep.value - 1) / WIZARD_STEPS, 1),
)
const stepLabel = computed(() =>
currentStep.value <= WIZARD_STEPS
? `Step ${currentStep.value} of ${WIZARD_STEPS}`
: 'Almost done!',
)
const routeForStep = (step: number) => STEP_ROUTES[step - 1] ?? '/setup/hardware'
// ── Actions ───────────────────────────────────────────────────────────────
/** Load wizard status from server and hydrate store. Returns the route to navigate to. */
async function loadStatus(isCloud: boolean): Promise<string> {
loading.value = true
errors.value = []
try {
const { data } = await useApiFetch<{
wizard_complete: boolean
wizard_step: number
saved_data: {
inference_profile?: string
tier?: string
name?: string
email?: string
phone?: string
linkedin?: string
career_summary?: string
services?: Record<string, string | number>
}
}>('/api/wizard/status')
if (!data) return '/setup/hardware'
const saved = data.saved_data
if (saved.inference_profile)
hardware.value.selectedProfile = saved.inference_profile as WizardProfile
if (saved.tier)
tier.value = saved.tier as WizardTier
if (saved.name) identity.value.name = saved.name
if (saved.email) identity.value.email = saved.email
if (saved.phone) identity.value.phone = saved.phone
if (saved.linkedin) identity.value.linkedin = saved.linkedin
if (saved.career_summary) identity.value.careerSummary = saved.career_summary
if (saved.services) inference.value.services = saved.services
// Cloud: auto-skip steps 1 (hardware), 2 (tier), 5 (inference)
if (isCloud) {
const cloudStep = data.wizard_step
if (cloudStep < 1) {
await saveStep(1, { inference_profile: 'single-gpu' })
await saveStep(2, { tier: tier.value })
currentStep.value = 3
return '/setup/resume'
}
}
// Resume at next step after last completed
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7))
currentStep.value = resumeAt
return routeForStep(resumeAt)
} finally {
loading.value = false
}
}
/** Detect GPUs and populate hardware step. */
async function detectHardware(): Promise<void> {
loading.value = true
try {
const { data } = await useApiFetch<{
gpus: string[]
suggested_profile: string
profiles: string[]
}>('/api/wizard/hardware')
if (!data) return
hardware.value.gpus = data.gpus
hardware.value.suggestedProfile = data.suggested_profile as WizardProfile
// Only set selectedProfile if not already chosen by user
if (!hardware.value.selectedProfile || hardware.value.selectedProfile === 'remote') {
hardware.value.selectedProfile = data.suggested_profile as WizardProfile
}
} finally {
loading.value = false
}
}
/** Persist a step's data to the server. */
async function saveStep(step: number, data: Record<string, unknown>): Promise<boolean> {
saving.value = true
errors.value = []
try {
const { data: result, error } = await useApiFetch('/api/wizard/step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, data }),
})
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
currentStep.value = step
return true
} finally {
saving.value = false
}
}
/** Test LLM / Ollama connectivity. */
async function testInference(): Promise<{ ok: boolean; message: string }> {
const payload = {
profile: hardware.value.selectedProfile,
anthropic_key: inference.value.anthropicKey,
openai_url: inference.value.openaiUrl,
openai_key: inference.value.openaiKey,
ollama_host: inference.value.ollamaHost,
ollama_port: inference.value.ollamaPort,
}
const { data } = await useApiFetch<{ ok: boolean; message: string }>(
'/api/wizard/inference/test',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const result = data ?? { ok: false, message: 'No response from server.' }
inference.value.testMessage = result.message
inference.value.confirmed = true // always soft-confirm so user isn't blocked
return result
}
/** Finalise the wizard. */
async function complete(): Promise<boolean> {
saving.value = true
try {
const { error } = await useApiFetch('/api/wizard/complete', { method: 'POST' })
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
return true
} finally {
saving.value = false
}
}
return {
// state
currentStep,
loading,
saving,
errors,
hardware,
tier,
resume,
identity,
inference,
search,
// computed
progressFraction,
stepLabel,
// actions
loadStatus,
detectHardware,
saveStep,
testInference,
complete,
routeForStep,
}
})

View file

@ -53,13 +53,6 @@
:loading="taskRunning === 'score'"
@click="scoreUnscored"
/>
<WorkflowButton
emoji="🔍"
label="Fill Missing Descriptions"
description="Re-fetch truncated job descriptions"
:loading="taskRunning === 'enrich'"
@click="runEnrich"
/>
</div>
<button
@ -87,6 +80,7 @@
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
: 'Auto-enrichment active' }}
</span>
<button class="btn-ghost btn-ghost--sm" @click="runEnrich">Run Now</button>
</div>
</section>
@ -168,194 +162,24 @@
</div>
</section>
<!-- Danger Zone -->
<!-- Advanced -->
<section class="home__section">
<details class="danger-zone">
<summary class="danger-zone__summary"> Danger Zone</summary>
<div class="danger-zone__body">
<!-- Queue reset -->
<div class="dz-block">
<p class="dz-block__title">Queue reset</p>
<p class="dz-block__desc">
Archive clears your review queue while keeping job URLs for dedup same listings
won't resurface on the next discovery run. Use hard purge only for a full clean slate
including dedup history.
</p>
<fieldset class="dz-scope" aria-label="Clear scope">
<legend class="dz-scope__legend">Clear scope</legend>
<label class="dz-scope__option">
<input type="radio" v-model="dangerScope" value="pending" />
Pending only
</label>
<label class="dz-scope__option">
<input type="radio" v-model="dangerScope" value="pending_approved" />
Pending + approved (stale search)
</label>
</fieldset>
<div class="dz-actions">
<button
class="action-btn action-btn--primary"
:disabled="!!confirmAction"
@click="beginConfirm('archive')"
>
📦 Archive &amp; reset
</button>
<button
class="action-btn action-btn--secondary"
:disabled="!!confirmAction"
@click="beginConfirm('purge')"
>
🗑 Hard purge (delete)
</button>
</div>
<!-- Inline confirm -->
<div v-if="confirmAction" class="dz-confirm" role="alertdialog" aria-live="assertive">
<p v-if="confirmAction.type === 'archive'" class="dz-confirm__msg dz-confirm__msg--info">
Archive <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
URLs are kept for dedup nothing is permanently deleted.
</p>
<p v-else class="dz-confirm__msg dz-confirm__msg--warn">
Permanently delete <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
This removes URLs from dedup history too. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executeConfirm">
{{ confirmAction.type === 'archive' ? 'Yes, archive' : 'Yes, delete' }}
</button>
<button class="action-btn action-btn--secondary" @click="confirmAction = null">
Cancel
</button>
</div>
</div>
</div>
<hr class="dz-divider" />
<!-- Background tasks -->
<div class="dz-block">
<p class="dz-block__title">Background tasks {{ activeTasks.length }} active</p>
<template v-if="activeTasks.length > 0">
<div
v-for="task in activeTasks"
:key="task.id"
class="dz-task"
>
<span class="dz-task__icon">{{ taskIcon(task.task_type) }}</span>
<span class="dz-task__type">{{ task.task_type.replace(/_/g, ' ') }}</span>
<span class="dz-task__label">
{{ task.title ? `${task.title}${task.company ? ' @ ' + task.company : ''}` : `job #${task.job_id}` }}
</span>
<span class="dz-task__status">{{ task.status }}</span>
<button
class="btn-ghost btn-ghost--sm dz-task__cancel"
@click="cancelTaskById(task.id)"
:aria-label="`Cancel ${task.task_type} task`"
>
</button>
</div>
</template>
<button
class="action-btn action-btn--secondary dz-kill"
:disabled="activeTasks.length === 0"
@click="killAll"
>
Kill all stuck
<details class="advanced">
<summary class="advanced__summary">Advanced</summary>
<div class="advanced__body">
<p class="advanced__warning"> These actions are destructive and cannot be undone.</p>
<div class="home__actions home__actions--danger">
<button class="action-btn action-btn--danger" @click="confirmPurge">
🗑 Purge Pending + Rejected
</button>
<button class="action-btn action-btn--danger" @click="killTasks">
🛑 Kill Stuck Tasks
</button>
</div>
<hr class="dz-divider" />
<!-- More options -->
<details class="dz-more">
<summary class="dz-more__summary">More options</summary>
<div class="dz-more__body">
<!-- Email purge -->
<div class="dz-more__item">
<p class="dz-block__title">Purge email data</p>
<p class="dz-block__desc">Clears all email thread logs and email-sourced pending jobs.</p>
<template v-if="moreConfirm === 'email'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Deletes all email contacts and email-sourced jobs. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('email')">Yes, purge emails</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'email'">
📧 Purge Email Data
</button>
</div>
<!-- Non-remote purge -->
<div class="dz-more__item">
<p class="dz-block__title">Purge non-remote</p>
<p class="dz-block__desc">Removes pending/approved/rejected on-site listings from the DB.</p>
<template v-if="moreConfirm === 'non_remote'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Deletes all non-remote jobs not yet applied to. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('non_remote')">Yes, purge on-site</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'non_remote'">
🏢 Purge On-site Jobs
</button>
</div>
<!-- Wipe + re-scrape -->
<div class="dz-more__item">
<p class="dz-block__title">Wipe all + re-scrape</p>
<p class="dz-block__desc">Deletes all non-applied jobs then immediately runs a fresh discovery.</p>
<template v-if="moreConfirm === 'rescrape'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Wipes ALL pending, approved, and rejected jobs, then re-scrapes.
Applied and synced records are kept.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('rescrape')">Yes, wipe + scrape</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'rescrape'">
🔄 Wipe + Re-scrape
</button>
</div>
</div>
</details>
</div>
</details>
</section>
<!-- Setup banners -->
<section v-if="banners.length > 0" class="home__section" aria-labelledby="setup-heading">
<h2 id="setup-heading" class="home__section-title">Finish setting up Peregrine</h2>
<div class="banners">
<div v-for="banner in banners" :key="banner.key" class="banner">
<span class="banner__icon" aria-hidden="true">💡</span>
<span class="banner__text">{{ banner.text }}</span>
<RouterLink :to="banner.link" class="banner__link">Go to settings </RouterLink>
<button
class="btn-ghost btn-ghost--sm banner__dismiss"
@click="dismissBanner(banner.key)"
:aria-label="`Dismiss: ${banner.text}`"
>
</button>
</div>
</div>
</section>
<!-- Stoop speed toast easter egg 9.2 -->
<Transition name="toast">
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
@ -366,7 +190,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useJobsStore } from '../stores/jobs'
import { useApiFetch } from '../composables/useApi'
@ -407,8 +231,6 @@ function formatRelative(isoStr: string) {
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
}
// Task execution
const taskRunning = ref<string | null>(null)
const stoopToast = ref(false)
@ -417,16 +239,13 @@ async function runTask(key: string, endpoint: string) {
await useApiFetch(endpoint, { method: 'POST' })
taskRunning.value = null
store.refresh()
fetchActiveTasks()
}
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
const scoreUnscored = () => runTask('score', '/api/tasks/score')
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
// Add jobs
const runEnrich = () => useApiFetch('/api/tasks/enrich', { method: 'POST' })
const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('')
@ -450,8 +269,6 @@ function handleCsvUpload(e: Event) {
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
}
// Backlog archive
async function archiveByStatus(statuses: string[]) {
await useApiFetch('/api/jobs/archive', {
method: 'POST',
@ -461,100 +278,26 @@ async function archiveByStatus(statuses: string[]) {
store.refresh()
}
// Danger Zone
interface TaskRow { id: number; task_type: string; status: string; title?: string; company?: string; job_id: number }
interface Banner { key: string; text: string; link: string }
interface ConfirmAction { type: 'archive' | 'purge'; statuses: string[] }
const activeTasks = ref<TaskRow[]>([])
const dangerScope = ref<'pending' | 'pending_approved'>('pending')
const confirmAction = ref<ConfirmAction | null>(null)
const moreConfirm = ref<string | null>(null)
const banners = ref<Banner[]>([])
let taskPollInterval: ReturnType<typeof setInterval> | null = null
async function fetchActiveTasks() {
const { data } = await useApiFetch<TaskRow[]>('/api/tasks')
activeTasks.value = data ?? []
}
async function fetchBanners() {
const { data } = await useApiFetch<Banner[]>('/api/config/setup-banners')
banners.value = data ?? []
}
function scopeStatuses(): string[] {
return dangerScope.value === 'pending' ? ['pending'] : ['pending', 'approved']
}
function beginConfirm(type: 'archive' | 'purge') {
moreConfirm.value = null
confirmAction.value = { type, statuses: scopeStatuses() }
}
async function executeConfirm() {
const action = confirmAction.value
confirmAction.value = null
if (!action) return
const endpoint = action.type === 'archive' ? '/api/jobs/archive' : '/api/jobs/purge'
const key = action.type === 'archive' ? 'statuses' : 'statuses'
await useApiFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: action.statuses }),
})
store.refresh()
fetchActiveTasks()
}
async function cancelTaskById(id: number) {
await useApiFetch(`/api/tasks/${id}`, { method: 'DELETE' })
fetchActiveTasks()
}
async function killAll() {
await useApiFetch('/api/tasks/kill', { method: 'POST' })
fetchActiveTasks()
}
async function executePurgeTarget(target: string) {
moreConfirm.value = null
await useApiFetch('/api/jobs/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
})
store.refresh()
fetchActiveTasks()
}
async function dismissBanner(key: string) {
await useApiFetch(`/api/config/setup-banners/${key}/dismiss`, { method: 'POST' })
banners.value = banners.value.filter(b => b.key !== key)
}
function taskIcon(taskType: string): string {
const icons: Record<string, string> = {
cover_letter: '✉️', company_research: '🔍', discovery: '🌐',
enrich_descriptions: '📝', email_sync: '📧', score: '📊',
scrape_url: '🔗',
function confirmPurge() {
// TODO: replace with ConfirmModal component
if (confirm('Permanently delete all pending and rejected jobs? This cannot be undone.')) {
useApiFetch('/api/jobs/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'pending_rejected' }),
})
store.refresh()
}
return icons[taskType] ?? '⚙️'
}
async function killTasks() {
await useApiFetch('/api/tasks/kill', { method: 'POST' })
}
onMounted(async () => {
store.refresh()
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
if (data?.name) userName.value = data.name
fetchActiveTasks()
fetchBanners()
taskPollInterval = setInterval(fetchActiveTasks, 5000)
})
onUnmounted(() => {
if (taskPollInterval) clearInterval(taskPollInterval)
})
</script>
@ -649,11 +392,12 @@ onUnmounted(() => {
.home__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3);
}
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.home__actions--danger { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.sync-banner {
display: flex;
@ -707,7 +451,9 @@ onUnmounted(() => {
.action-btn--secondary { background: var(--color-surface-alt); color: var(--color-text); border: 1px solid var(--color-border); }
.action-btn--secondary:hover { background: var(--color-border-light); }
.action-btn--secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.action-btn--danger { background: transparent; color: var(--color-error); border: 1px solid var(--color-error); }
.action-btn--danger:hover { background: rgba(192, 57, 43, 0.08); }
.enrichment-row {
display: flex;
@ -782,15 +528,13 @@ onUnmounted(() => {
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
/* ── Danger Zone ──────────────────────────────────────── */
.danger-zone {
.advanced {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.danger-zone__summary {
.advanced__summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: var(--text-sm);
@ -800,172 +544,21 @@ onUnmounted(() => {
user-select: none;
}
.danger-zone__summary::-webkit-details-marker { display: none; }
.danger-zone__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .danger-zone__summary::before { content: '▼ '; }
.advanced__summary::-webkit-details-marker { display: none; }
.advanced__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .advanced__summary::before { content: '▼ '; }
.danger-zone__body {
padding: 0 var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.advanced__body { padding: 0 var(--space-4) var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
.dz-block { display: flex; flex-direction: column; gap: var(--space-3); }
.dz-block__title {
.advanced__warning {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.dz-block__desc {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.dz-scope {
border: none;
padding: 0;
margin: 0;
display: flex;
gap: var(--space-5);
flex-wrap: wrap;
}
.dz-scope__legend {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
float: left;
width: 100%;
}
.dz-scope__option {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
cursor: pointer;
}
.dz-actions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.dz-confirm {
color: var(--color-warning);
background: rgba(212, 137, 26, 0.08);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: var(--space-3);
border-left: 3px solid var(--color-warning);
}
.dz-confirm__msg {
font-size: var(--text-sm);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border-left: 3px solid;
}
.dz-confirm__msg--info {
background: rgba(52, 152, 219, 0.1);
border-color: var(--app-primary);
color: var(--color-text);
}
.dz-confirm__msg--warn {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-text);
}
.dz-confirm__actions {
display: flex;
gap: var(--space-3);
}
.dz-divider {
border: none;
border-top: 1px solid var(--color-border-light);
margin: 0;
}
.dz-task {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-alt);
border-radius: var(--radius-md);
font-size: var(--text-xs);
}
.dz-task__icon { flex-shrink: 0; }
.dz-task__type { font-family: var(--font-mono); color: var(--color-text-muted); min-width: 120px; }
.dz-task__label { flex: 1; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dz-task__status { color: var(--color-text-muted); font-style: italic; }
.dz-task__cancel { margin-left: var(--space-2); }
.dz-kill { align-self: flex-start; }
.dz-more {
background: transparent;
border: none;
}
.dz-more__summary {
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
list-style: none;
user-select: none;
padding: var(--space-1) 0;
}
.dz-more__summary::-webkit-details-marker { display: none; }
.dz-more__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .dz-more__summary::before { content: '▼ '; }
.dz-more__body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-5);
margin-top: var(--space-4);
}
.dz-more__item { display: flex; flex-direction: column; gap: var(--space-2); }
/* ── Setup banners ────────────────────────────────────── */
.banners {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.banner__icon { flex-shrink: 0; }
.banner__text { flex: 1; color: var(--color-text); }
.banner__link { color: var(--app-primary); text-decoration: none; white-space: nowrap; font-weight: 500; }
.banner__link:hover { text-decoration: underline; }
.banner__dismiss { margin-left: var(--space-1); }
/* ── Toast ────────────────────────────────────────────── */
.stoop-toast {
position: fixed;
bottom: var(--space-6);
@ -995,7 +588,6 @@ details[open] > .dz-more__summary::before { content: '▼ '; }
.home { padding: var(--space-4); gap: var(--space-6); }
.home__greeting { font-size: var(--text-2xl); }
.home__metrics { grid-template-columns: repeat(3, 1fr); }
.dz-more__body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {

View file

@ -7,7 +7,6 @@ import type { StageSignal } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
import InterviewCard from '../components/InterviewCard.vue'
import MoveToSheet from '../components/MoveToSheet.vue'
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
const router = useRouter()
const store = useInterviewsStore()
@ -23,29 +22,10 @@ function openMove(jobId: number, preSelectedStage?: PipelineStage) {
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
if (!moveTarget.value) return
const movedJob = moveTarget.value
const wasHired = stage === 'hired'
await store.move(movedJob.id, stage, opts)
await store.move(moveTarget.value.id, stage, opts)
moveTarget.value = null
if (wasHired) triggerConfetti()
// Auto-open research modal when moving to phone_screen (mirrors Streamlit behaviour)
if (stage === 'phone_screen') openResearch(movedJob.id, `${movedJob.title} at ${movedJob.company}`)
}
// Company research modal
const researchJobId = ref<number | null>(null)
const researchJobTitle = ref('')
const researchAutoGen = ref(false)
function openResearch(jobId: number, jobTitle: string, autoGenerate = true) {
researchJobId.value = jobId
researchJobTitle.value = jobTitle
researchAutoGen.value = autoGenerate
}
function onInterviewCardResearch(jobId: number) {
const job = store.jobs.find(j => j.id === jobId)
if (job) openResearch(jobId, `${job.title} at ${job.company}`, false)
}
// Collapsible Applied section
@ -486,8 +466,7 @@ function daysSince(dateStr: string | null) {
</div>
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
:focused="focusedCol === 0 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
@ -500,8 +479,7 @@ function daysSince(dateStr: string | null) {
</div>
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
:focused="focusedCol === 1 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
@ -514,8 +492,7 @@ function daysSince(dateStr: string | null) {
</div>
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
:focused="focusedCol === 2 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)"
@research="onInterviewCardResearch" />
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
</section>
@ -548,14 +525,6 @@ function daysSince(dateStr: string | null) {
@move="onMove"
@close="moveTarget = null; movePreSelected = undefined"
/>
<CompanyResearchModal
v-if="researchJobId !== null"
:jobId="researchJobId"
:jobTitle="researchJobTitle"
:autoGenerate="researchAutoGen"
@close="researchJobId = null"
/>
</div>
</template>

View file

@ -98,50 +98,25 @@
<span class="spinner" aria-hidden="true" />
<span>Loading</span>
</div>
<template v-else>
<!-- Sort + filter bar -->
<div class="list-controls" aria-label="Sort and filter">
<select v-model="sortBy" class="list-sort" aria-label="Sort by">
<option value="match_score">Best match</option>
<option value="date_found">Newest first</option>
<option value="company">Company AZ</option>
</select>
<label class="list-filter-remote">
<input type="checkbox" v-model="filterRemote" />
Remote only
</label>
<span class="list-count">{{ sortedFilteredJobs.length }} job{{ sortedFilteredJobs.length !== 1 ? 's' : '' }}</span>
</div>
<div v-if="sortedFilteredJobs.length === 0" class="review__empty" role="status">
<p class="empty-desc">No {{ activeTab }} jobs{{ filterRemote ? ' (remote only)' : '' }}.</p>
</div>
<ul v-else class="job-list" role="list">
<li v-for="job in sortedFilteredJobs" :key="job.id" class="job-list__item">
<div class="job-list__info">
<span class="job-list__title">{{ job.title }}</span>
<span class="job-list__company">
{{ job.company }}
<span v-if="job.is_remote" class="remote-tag">Remote</span>
</span>
</div>
<div class="job-list__meta">
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
{{ job.match_score }}%
</span>
<button
v-if="activeTab === 'approved'"
class="job-list__action"
@click="router.push(`/apply/${job.id}`)"
:aria-label="`Draft cover letter for ${job.title}`"
> Draft</button>
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
View
</a>
</div>
</li>
</ul>
</template>
<div v-else-if="store.listJobs.length === 0" class="review__empty" role="status">
<p class="empty-desc">No {{ activeTab }} jobs.</p>
</div>
<ul v-else class="job-list" role="list">
<li v-for="job in store.listJobs" :key="job.id" class="job-list__item">
<div class="job-list__info">
<span class="job-list__title">{{ job.title }}</span>
<span class="job-list__company">{{ job.company }}</span>
</div>
<div class="job-list__meta">
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
{{ job.match_score }}%
</span>
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
View
</a>
</div>
</li>
</ul>
</div>
<!-- Help overlay -->
@ -211,13 +186,12 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import { useReviewStore } from '../stores/review'
import JobCardStack from '../components/JobCardStack.vue'
const store = useReviewStore()
const route = useRoute()
const router = useRouter()
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
// Tabs
@ -341,30 +315,6 @@ function onKeyDown(e: KeyboardEvent) {
}
}
// List view: sort + filter
type SortKey = 'match_score' | 'date_found' | 'company'
const sortBy = ref<SortKey>('match_score')
const filterRemote = ref(false)
const sortedFilteredJobs = computed(() => {
let jobs = [...store.listJobs]
if (filterRemote.value) jobs = jobs.filter(j => j.is_remote)
jobs.sort((a, b) => {
if (sortBy.value === 'match_score') return (b.match_score ?? -1) - (a.match_score ?? -1)
if (sortBy.value === 'date_found') return new Date(b.date_found).getTime() - new Date(a.date_found).getTime()
if (sortBy.value === 'company') return (a.company ?? '').localeCompare(b.company ?? '')
return 0
})
return jobs
})
// Reset filters when switching tabs
watch(activeTab, () => {
filterRemote.value = false
sortBy.value = 'match_score'
})
// List view score pill
function scorePillClass(score: number) {
@ -709,69 +659,6 @@ kbd {
font-weight: 600;
}
.job-list__action {
font-size: var(--text-xs);
font-weight: 600;
color: var(--app-primary);
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--app-primary) 25%, transparent);
border-radius: var(--radius-sm);
padding: 2px 8px;
cursor: pointer;
transition: background 150ms;
white-space: nowrap;
}
.job-list__action:hover {
background: color-mix(in srgb, var(--app-primary) 18%, transparent);
}
.remote-tag {
font-size: 0.65rem;
font-weight: 700;
color: var(--color-info);
background: color-mix(in srgb, var(--color-info) 12%, transparent);
border-radius: var(--radius-full);
padding: 1px 5px;
margin-left: 4px;
}
/* ── List controls (sort + filter) ──────────────────────────────────── */
.list-controls {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.list-sort {
font-size: var(--text-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
padding: 3px 8px;
cursor: pointer;
}
.list-filter-remote {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.list-count {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-left: auto;
}
/* ── Help overlay ────────────────────────────────────────────────────── */
.help-overlay {

View file

@ -6,7 +6,7 @@ import { useAppConfigStore } from '../../stores/appConfig'
const store = useFineTuneStore()
const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining, pairs, pairsLoading } = storeToRefs(store)
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
@ -45,7 +45,6 @@ async function checkLocalModel() {
onMounted(async () => {
store.startPolling()
await store.loadPairs()
if (store.step === 3 && !config.isCloud) await checkLocalModel()
})
onUnmounted(() => { store.stopPolling(); store.resetStep() })
@ -100,22 +99,6 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
</button>
<button @click="store.step = 3" class="btn-secondary">Skip Train</button>
</div>
<!-- Training pairs list -->
<div v-if="pairs.length > 0" class="pairs-list">
<h4>Training Pairs <span class="pairs-badge">{{ pairs.length }}</span></h4>
<p class="section-note">Review and remove any low-quality pairs before training.</p>
<div v-if="pairsLoading" class="pairs-loading">Loading</div>
<ul v-else class="pairs-items">
<li v-for="pair in pairs" :key="pair.index" class="pair-item">
<div class="pair-info">
<span class="pair-instruction">{{ pair.instruction }}</span>
<span class="pair-source">{{ pair.source_file }}</span>
</div>
<button class="pair-delete" @click="store.deletePair(pair.index)" title="Remove this pair"></button>
</li>
</ul>
</div>
</section>
<!-- Step 3: Train -->
@ -177,16 +160,4 @@ onUnmounted(() => { store.stopPolling(); store.resetStep() })
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
.status-ok { color: var(--color-success, #16a34a); }
.status-fail { color: var(--color-error, #dc2626); }
.pairs-list { margin-top: var(--space-6, 1.5rem); }
.pairs-list h4 { font-size: 0.95rem; font-weight: 600; margin: 0 0 var(--space-2, 0.5rem); display: flex; align-items: center; gap: 0.5rem; }
.pairs-badge { background: var(--color-primary, #2d5a27); color: #fff; font-size: 0.75rem; padding: 1px 7px; border-radius: var(--radius-full, 9999px); }
.pairs-loading { color: var(--color-text-muted); font-size: 0.875rem; padding: var(--space-2, 0.5rem) 0; }
.pairs-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); max-height: 280px; overflow-y: auto; }
.pair-item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt); border: 1px solid var(--color-border-light); border-radius: var(--radius-md); }
.pair-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.pair-instruction { font-size: 0.85rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pair-source { font-size: 0.75rem; color: var(--color-text-muted); }
.pair-delete { flex-shrink: 0; background: none; border: none; color: var(--color-error); cursor: pointer; font-size: 0.9rem; padding: 2px 4px; border-radius: var(--radius-sm); transition: background 150ms; }
.pair-delete:hover { background: var(--color-error); color: #fff; }
</style>

View file

@ -62,16 +62,9 @@
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateVoice"
:disabled="generatingVoice"
>{{ generatingVoice ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div v-if="!config.isCloud" class="field-row">
<div class="field-row">
<label class="field-label" for="profile-inference">Inference profile</label>
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
<option value="remote">Remote</option>
@ -217,7 +210,6 @@ const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
const generatingVoice = ref(false)
onMounted(() => { store.load() })
@ -273,15 +265,6 @@ async function generateMissions() {
}))
}
}
async function generateVoice() {
generatingVoice.value = true
const { data, error } = await useApiFetch<{ voice?: string }>(
'/api/settings/profile/generate-voice', { method: 'POST' }
)
generatingVoice.value = false
if (!error && data?.voice) store.candidate_voice = data.voice
}
</script>
<style scoped>

View file

@ -15,13 +15,7 @@
<div class="empty-card">
<h3>Upload & Parse</h3>
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="fileInput" />
<button
v-if="pendingFile"
@click="handleUpload"
:disabled="uploading"
style="margin-top:10px"
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</div>
<!-- Blank -->
@ -30,8 +24,8 @@
<p>Start with a blank form and fill in your details.</p>
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
</div>
<!-- Wizard self-hosted only -->
<div v-if="!config.isCloud" class="empty-card">
<!-- Wizard -->
<div class="empty-card">
<h3>Run Setup Wizard</h3>
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
<RouterLink to="/setup">Open Setup Wizard </RouterLink>
@ -41,21 +35,6 @@
<!-- Full form (when resume exists) -->
<template v-else-if="store.hasResume">
<!-- Replace resume via upload -->
<section class="form-section replace-section">
<h3>Replace Resume</h3>
<p class="section-note">Upload a new PDF, DOCX, or ODT to re-parse and overwrite the current data.</p>
<input type="file" accept=".pdf,.docx,.odt" @change="handleFileSelect" ref="replaceFileInput" />
<button
v-if="pendingFile"
@click="handleUpload"
:disabled="uploading"
class="btn-primary"
style="margin-top:10px"
>{{ uploading ? 'Parsing…' : `Parse "${pendingFile.name}"` }}</button>
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</section>
<!-- Personal Information -->
<section class="form-section">
<h3>Personal Information</h3>
@ -242,22 +221,17 @@ import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useResumeStore } from '../../stores/settings/resume'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useResumeStore()
const profileStore = useProfileStore()
const config = useAppConfigStore()
const { loadError } = storeToRefs(store)
const showSelfId = ref(false)
const skillInput = ref('')
const domainInput = ref('')
const kwInput = ref('')
const uploadError = ref<string | null>(null)
const uploading = ref(false)
const pendingFile = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const replaceFileInput = ref<HTMLInputElement | null>(null)
onMounted(async () => {
await store.load()
@ -272,16 +246,9 @@ onMounted(async () => {
}
})
function handleFileSelect(event: Event) {
async function handleUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
pendingFile.value = file ?? null
uploadError.value = null
}
async function handleUpload() {
const file = pendingFile.value
if (!file) return
uploading.value = true
uploadError.value = null
const formData = new FormData()
formData.append('file', file)
@ -289,14 +256,10 @@ async function handleUpload() {
'/api/settings/resume/upload',
{ method: 'POST', body: formData }
)
uploading.value = false
if (error || !data?.ok) {
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
return
}
pendingFile.value = null
if (fileInput.value) fileInput.value.value = ''
if (replaceFileInput.value) replaceFileInput.value.value = ''
if (data.data) {
await store.load()
}
@ -344,5 +307,4 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
.replace-section { background: var(--color-surface-2, rgba(255,255,255,0.03)); border-radius: 8px; padding: var(--space-4, 24px); }
</style>

View file

@ -69,18 +69,7 @@
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
<button @click="store.suggestExcludeKeywords()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.excludeSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.excludeSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('exclude', s)"
>+ {{ s }}</span>
</div>
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
</section>
<!-- Job Boards -->

View file

@ -41,8 +41,7 @@ const config = useAppConfigStore()
const devOverride = computed(() => !!config.devTierOverride)
const gpuProfiles = ['single-gpu', 'dual-gpu']
const showSystem = computed(() => !config.isCloud)
const showData = computed(() => !config.isCloud)
const showSystem = computed(() => !config.isCloud)
const showFineTune = computed(() => {
if (config.isCloud) return config.tier === 'premium'
return gpuProfiles.includes(config.inferenceProfile)
@ -66,7 +65,7 @@ const allGroups = [
]},
{ label: 'Account', items: [
{ key: 'license', path: '/settings/license', label: 'License', show: true },
{ key: 'data', path: '/settings/data', label: 'Data', show: showData },
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
]},
{ label: 'Dev', items: [

View file

@ -1,63 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 1 Hardware Detection</h2>
<p class="step__caption">
Peregrine uses your hardware profile to choose the right inference setup.
</p>
<div v-if="wizard.loading" class="step__info">Detecting hardware</div>
<template v-else>
<div v-if="wizard.hardware.gpus.length" class="step__success">
Detected {{ wizard.hardware.gpus.length }} GPU(s):
{{ wizard.hardware.gpus.join(', ') }}
</div>
<div v-else class="step__info">
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
</div>
<div class="step__field">
<label class="step__label" for="hw-profile">Inference profile</label>
<select id="hw-profile" v-model="selectedProfile" class="step__select">
<option value="remote">Remote use cloud API keys</option>
<option value="cpu">CPU local Ollama, no GPU</option>
<option value="single-gpu">Single GPU local Ollama + one GPU</option>
<option value="dual-gpu">Dual GPU local Ollama + two GPUs</option>
</select>
</div>
<div
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
class="step__warning"
>
No GPUs detected a GPU profile may not work. Choose CPU or Remote
if you don't have a local NVIDIA GPU.
</div>
</template>
<div class="step__nav step__nav--end">
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selectedProfile = ref(wizard.hardware.selectedProfile)
onMounted(() => wizard.detectHardware())
async function next() {
wizard.hardware.selectedProfile = selectedProfile.value
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
if (ok) router.push('/setup/tier')
}
</script>

View file

@ -1,117 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 4 Your Identity</h2>
<p class="step__caption">
Used in cover letters, research briefs, and interview prep. You can update
this any time in Settings My Profile.
</p>
<div class="step__field">
<label class="step__label" for="id-name">Full name <span class="required">*</span></label>
<input id="id-name" v-model="form.name" type="text" class="step__input"
placeholder="Your Name" autocomplete="name" />
</div>
<div class="step__field">
<label class="step__label" for="id-email">Email <span class="required">*</span></label>
<input id="id-email" v-model="form.email" type="email" class="step__input"
placeholder="you@example.com" autocomplete="email" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-phone">Phone</label>
<input id="id-phone" v-model="form.phone" type="tel" class="step__input"
placeholder="555-000-0000" autocomplete="tel" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-linkedin">LinkedIn URL</label>
<input id="id-linkedin" v-model="form.linkedin" type="url" class="step__input"
placeholder="linkedin.com/in/yourprofile" autocomplete="url" />
</div>
<div class="step__field">
<label class="step__label" for="id-summary">
Career summary <span class="required">*</span>
</label>
<textarea
id="id-summary"
v-model="form.careerSummary"
class="step__textarea"
rows="5"
placeholder="23 sentences summarising your experience, domain, and what you're looking for next."
/>
<p class="field-hint">This appears in your cover letters and research briefs.</p>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
// Local reactive copy sync back to store on Next
const form = reactive({
name: wizard.identity.name,
email: wizard.identity.email,
phone: wizard.identity.phone,
linkedin: wizard.identity.linkedin,
careerSummary: wizard.identity.careerSummary,
})
function back() { router.push('/setup/resume') }
async function next() {
validationError.value = ''
if (!form.name.trim()) {
validationError.value = 'Full name is required.'
return
}
if (!form.email.trim() || !form.email.includes('@')) {
validationError.value = 'A valid email address is required.'
return
}
if (!form.careerSummary.trim()) {
validationError.value = 'Please add a short career summary.'
return
}
wizard.identity = { ...form }
const ok = await wizard.saveStep(4, {
name: form.name,
email: form.email,
phone: form.phone,
linkedin: form.linkedin,
career_summary: form.careerSummary,
})
if (ok) router.push('/setup/inference')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
</style>

View file

@ -1,169 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 5 Inference & API Keys</h2>
<p class="step__caption">
Configure how Peregrine generates AI content. You can adjust this any time
in Settings System.
</p>
<!-- Remote mode -->
<template v-if="isRemote">
<div class="step__info">
Remote mode: at least one external API key is required for AI generation.
</div>
<div class="step__field">
<label class="step__label" for="inf-anthropic">Anthropic API key</label>
<input id="inf-anthropic" v-model="form.anthropicKey" type="password"
class="step__input" placeholder="sk-ant-…" autocomplete="off" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="inf-oai-url">
OpenAI-compatible endpoint
</label>
<input id="inf-oai-url" v-model="form.openaiUrl" type="url"
class="step__input" placeholder="https://api.together.xyz/v1" />
</div>
<div v-if="form.openaiUrl" class="step__field">
<label class="step__label step__label--optional" for="inf-oai-key">
Endpoint API key
</label>
<input id="inf-oai-key" v-model="form.openaiKey" type="password"
class="step__input" placeholder="API key for the endpoint above"
autocomplete="off" />
</div>
</template>
<!-- Local mode -->
<template v-else>
<div class="step__info">
Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses
Ollama for AI generation. No API keys needed.
</div>
</template>
<!-- Advanced: service ports -->
<div class="step__expandable">
<button class="step__expandable__toggle" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼' : '▶' }} Advanced service hosts &amp; ports
</button>
<div v-if="showAdvanced" class="step__expandable__body">
<div class="svc-row" v-for="svc in services" :key="svc.key">
<span class="svc-label">{{ svc.label }}</span>
<input v-model="svc.host" type="text" class="step__input svc-input" />
<input v-model.number="svc.port" type="number" class="step__input svc-port" />
</div>
</div>
</div>
<!-- Connection test -->
<div class="test-row">
<button class="btn-secondary" :disabled="testing" @click="runTest">
{{ testing ? 'Testing…' : '🔌 Test connection' }}
</button>
<span v-if="testResult" :class="testResult.ok ? 'test-ok' : 'test-warn'">
{{ testResult.message }}
</span>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote')
const showAdvanced = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; message: string } | null>(null)
const form = reactive({
anthropicKey: wizard.inference.anthropicKey,
openaiUrl: wizard.inference.openaiUrl,
openaiKey: wizard.inference.openaiKey,
})
const services = reactive([
{ key: 'ollama', label: 'Ollama', host: 'ollama', port: 11434 },
{ key: 'searxng', label: 'SearXNG', host: 'searxng', port: 8080 },
])
async function runTest() {
testing.value = true
testResult.value = null
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
testResult.value = await wizard.testInference()
testing.value = false
}
function back() { router.push('/setup/identity') }
async function next() {
// Sync form back to store
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
const svcMap: Record<string, string | number> = {}
services.forEach(s => {
svcMap[`${s.key}_host`] = s.host
svcMap[`${s.key}_port`] = s.port
})
wizard.inference.services = svcMap
const ok = await wizard.saveStep(5, {
anthropic_key: form.anthropicKey,
openai_url: form.openaiUrl,
openai_key: form.openaiKey,
services: svcMap,
})
if (ok) router.push('/setup/search')
}
</script>
<style scoped>
.test-row {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.test-ok { font-size: 0.875rem; color: var(--color-success); }
.test-warn { font-size: 0.875rem; color: var(--color-warning); }
.svc-row {
display: grid;
grid-template-columns: 6rem 1fr 5rem;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.svc-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.svc-port {
text-align: right;
}
</style>

View file

@ -1,160 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 7 Integrations</h2>
<p class="step__caption">
Optional. Connect external tools to supercharge your workflow.
You can configure these any time in Settings System.
</p>
<div class="int-grid">
<label
v-for="card in integrations"
:key="card.id"
class="int-card"
:class="{
'int-card--selected': selected.has(card.id),
'int-card--paid': card.paid && !isPaid,
}"
>
<input
type="checkbox"
class="int-card__check"
:value="card.id"
:disabled="card.paid && !isPaid"
v-model="checkedIds"
/>
<span class="int-card__icon" aria-hidden="true">{{ card.icon }}</span>
<span class="int-card__name">{{ card.name }}</span>
<span v-if="card.paid && !isPaid" class="int-card__badge">Paid</span>
</label>
</div>
<div v-if="selected.size > 0" class="step__info" style="margin-top: var(--space-4)">
You'll configure credentials for {{ [...selected].map(id => labelFor(id)).join(', ') }}
in Settings System after setup completes.
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="finish">
{{ wizard.saving ? 'Saving…' : 'Finish Setup →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
import './wizard.css'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
const isPaid = computed(() =>
wizard.tier === 'paid' || wizard.tier === 'premium',
)
interface IntegrationCard {
id: string
name: string
icon: string
paid: boolean
}
const integrations: IntegrationCard[] = [
{ id: 'notion', name: 'Notion', icon: '🗒️', paid: false },
{ id: 'google_calendar', name: 'Google Calendar', icon: '📅', paid: true },
{ id: 'apple_calendar', name: 'Apple Calendar', icon: '🍏', paid: true },
{ id: 'slack', name: 'Slack', icon: '💬', paid: true },
{ id: 'discord', name: 'Discord', icon: '🎮', paid: true },
{ id: 'google_drive', name: 'Google Drive', icon: '📁', paid: true },
]
const checkedIds = ref<string[]>([])
const selected = computed(() => new Set(checkedIds.value))
function labelFor(id: string): string {
return integrations.find(i => i.id === id)?.name ?? id
}
function back() { router.push('/setup/search') }
async function finish() {
// Save integration selections (step 7) then mark wizard complete
await wizard.saveStep(7, { integrations: [...checkedIds.value] })
const ok = await wizard.complete()
if (ok) router.replace('/')
}
</script>
<style scoped>
.int-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
}
.int-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
text-align: center;
}
.int-card:hover:not(.int-card--paid) {
border-color: var(--color-border);
}
.int-card--selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
}
.int-card--paid {
opacity: 0.55;
cursor: not-allowed;
}
.int-card__check {
/* visually hidden but accessible */
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.int-card__icon {
font-size: 1.75rem;
}
.int-card__name {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
.int-card__badge {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-radius: var(--radius-full);
padding: 1px 6px;
}
</style>

View file

@ -1,204 +0,0 @@
<template>
<div class="wizard">
<div class="wizard__card">
<!-- Header -->
<div class="wizard__header">
<img
v-if="logoSrc"
:src="logoSrc"
alt="Peregrine"
class="wizard__logo"
/>
<h1 class="wizard__title">Welcome to Peregrine</h1>
<p class="wizard__subtitle">
Complete the setup to start your job search.
Progress saves automatically.
</p>
</div>
<!-- Progress bar -->
<div class="wizard__progress" role="progressbar"
:aria-valuenow="Math.round(wizard.progressFraction * 100)"
aria-valuemin="0" aria-valuemax="100">
<div class="wizard__progress-track">
<div class="wizard__progress-fill" :style="{ width: `${wizard.progressFraction * 100}%` }" />
</div>
<span class="wizard__progress-label">{{ wizard.stepLabel }}</span>
</div>
<!-- Step content -->
<div class="wizard__body">
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
<span class="wizard__spinner" aria-hidden="true" />
Loading
</div>
<RouterView v-else />
</div>
<!-- Global error banner -->
<div v-if="wizard.errors.length" class="wizard__error" role="alert">
<span v-for="e in wizard.errors" :key="e">{{ e }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
// Peregrine logo served from the static assets directory
const logoSrc = '/static/peregrine_logo_circle.png'
onMounted(async () => {
if (!config.loaded) await config.load()
const target = await wizard.loadStatus(config.isCloud)
if (router.currentRoute.value.path === '/setup') {
router.replace(target)
}
})
</script>
<style scoped>
.wizard {
min-height: 100dvh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-8) var(--space-4);
background: var(--color-surface);
}
.wizard__card {
width: 100%;
max-width: 640px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.wizard__header {
padding: var(--space-8) var(--space-8) var(--space-6);
text-align: center;
border-bottom: 1px solid var(--color-border-light);
}
.wizard__logo {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
margin-bottom: var(--space-4);
}
.wizard__title {
font-family: var(--font-display);
font-size: 1.625rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.wizard__subtitle {
font-size: 0.9rem;
color: var(--color-text-muted);
}
/* Progress */
.wizard__progress {
padding: var(--space-4) var(--space-8);
border-bottom: 1px solid var(--color-border-light);
}
.wizard__progress-track {
height: 6px;
background: var(--color-surface-alt);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-2);
}
.wizard__progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.wizard__progress-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 500;
}
/* Body */
.wizard__body {
padding: var(--space-8);
}
/* Loading */
.wizard__loading {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: var(--space-8) 0;
justify-content: center;
}
.wizard__spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
.wizard__error {
margin: 0 var(--space-8) var(--space-6);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.875rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* Mobile */
@media (max-width: 680px) {
.wizard {
padding: 0;
align-items: stretch;
}
.wizard__card {
border-radius: 0;
box-shadow: none;
min-height: 100dvh;
}
.wizard__header,
.wizard__body {
padding-left: var(--space-6);
padding-right: var(--space-6);
}
}
</style>

View file

@ -1,313 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 3 Your Resume</h2>
<p class="step__caption">
Upload a resume to auto-populate your profile, or build it manually.
</p>
<!-- Tabs -->
<div class="resume-tabs" role="tablist">
<button
role="tab"
:aria-selected="tab === 'upload'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'upload' }"
@click="tab = 'upload'"
>Upload File</button>
<button
role="tab"
:aria-selected="tab === 'manual'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'manual' }"
@click="tab = 'manual'"
>Build Manually</button>
</div>
<!-- Upload tab -->
<div v-if="tab === 'upload'" class="resume-upload">
<label class="upload-zone" :class="{ 'upload-zone--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop">
<input
type="file"
accept=".pdf,.docx,.odt"
class="upload-input"
@change="onFileChange"
/>
<span class="upload-icon" aria-hidden="true">📄</span>
<span class="upload-label">
{{ fileName || 'Drop PDF, DOCX, or ODT here, or click to browse' }}
</span>
</label>
<div v-if="parseError" class="step__warning">{{ parseError }}</div>
<button
v-if="selectedFile"
class="btn-secondary"
:disabled="parsing"
style="margin-top: var(--space-3)"
@click="parseResume"
>
{{ parsing ? 'Parsing…' : '⚙️ Parse Resume' }}
</button>
<div v-if="parsedOk" class="step__success">
Resume parsed {{ wizard.resume.experience.length }} experience
{{ wizard.resume.experience.length === 1 ? 'entry' : 'entries' }} found.
Switch to "Build Manually" to review or edit.
</div>
</div>
<!-- Manual build tab -->
<div v-if="tab === 'manual'" class="resume-manual">
<div
v-for="(exp, i) in wizard.resume.experience"
:key="i"
class="exp-entry"
>
<div class="exp-entry__header">
<span class="exp-entry__num">{{ i + 1 }}</span>
<button class="exp-entry__remove btn-ghost" @click="removeExp(i)"> Remove</button>
</div>
<div class="step__field">
<label class="step__label">Job title</label>
<input v-model="exp.title" type="text" class="step__input" placeholder="Software Engineer" />
</div>
<div class="step__field">
<label class="step__label">Company</label>
<input v-model="exp.company" type="text" class="step__input" placeholder="Acme Corp" />
</div>
<div class="exp-dates">
<div class="step__field">
<label class="step__label">Start</label>
<input v-model="exp.start_date" type="text" class="step__input" placeholder="2020" />
</div>
<div class="step__field">
<label class="step__label">End</label>
<input v-model="exp.end_date" type="text" class="step__input" placeholder="present" />
</div>
</div>
<div class="step__field">
<label class="step__label">Key accomplishments (one per line)</label>
<textarea
class="step__textarea"
rows="4"
:value="exp.bullets.join('\n')"
@input="(e) => exp.bullets = (e.target as HTMLTextAreaElement).value.split('\n')"
placeholder="Reduced load time by 40%&#10;Led a team of 5 engineers"
/>
</div>
</div>
<button class="btn-secondary" style="width: 100%" @click="addExp">
+ Add Experience Entry
</button>
</div>
<div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)">
{{ validationError }}
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WorkExperience } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const tab = ref<'upload' | 'manual'>(
wizard.resume.experience.length > 0 ? 'manual' : 'upload',
)
const dragging = ref(false)
const selectedFile = ref<File | null>(null)
const fileName = ref('')
const parsing = ref(false)
const parsedOk = ref(false)
const parseError = ref('')
const validationError = ref('')
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
async function parseResume() {
if (!selectedFile.value) return
parsing.value = true
parseError.value = ''
parsedOk.value = false
const form = new FormData()
form.append('file', selectedFile.value)
try {
const res = await fetch('/api/settings/resume/upload', { method: 'POST', body: form })
if (!res.ok) {
parseError.value = `Parse failed (HTTP ${res.status}) — switch to Build Manually to enter your resume.`
tab.value = 'manual'
return
}
const resp = await res.json()
// API returns { ok, data: { experience, name, email, } }
const data = resp.data ?? {}
// Map parsed sections to experience entries
if (data.experience?.length) {
wizard.resume.experience = data.experience as WorkExperience[]
}
wizard.resume.parsedData = data
// Pre-fill identity from parsed data
if (data.name && !wizard.identity.name) wizard.identity.name = data.name
if (data.email && !wizard.identity.email) wizard.identity.email = data.email
if (data.phone && !wizard.identity.phone) wizard.identity.phone = data.phone
if (data.career_summary && !wizard.identity.careerSummary)
wizard.identity.careerSummary = data.career_summary
parsedOk.value = true
tab.value = 'manual'
} catch {
parseError.value = 'Network error — switch to Build Manually to enter your resume.'
tab.value = 'manual'
} finally {
parsing.value = false
}
}
function addExp() {
wizard.resume.experience.push({
title: '', company: '', start_date: '', end_date: 'present', bullets: [],
})
}
function removeExp(i: number) {
wizard.resume.experience.splice(i, 1)
}
function back() { router.push('/setup/tier') }
async function next() {
validationError.value = ''
const valid = wizard.resume.experience.some(e => e.title.trim() && e.company.trim())
if (!valid) {
validationError.value = 'Add at least one experience entry with a title and company.'
return
}
const ok = await wizard.saveStep(3, { resume: {
experience: wizard.resume.experience,
...(wizard.resume.parsedData ?? {}),
}})
if (ok) router.push('/setup/identity')
}
</script>
<style scoped>
.resume-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border-light);
margin-bottom: var(--space-6);
}
.resume-tab {
padding: var(--space-2) var(--space-5);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text-muted);
transition: color var(--transition), border-color var(--transition);
}
.resume-tab--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
text-align: center;
transition: border-color var(--transition), background var(--transition);
}
.upload-zone--active,
.upload-zone:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.upload-input {
display: none;
}
.upload-icon { font-size: 2rem; }
.upload-label {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
background: var(--color-surface-alt);
}
.exp-entry__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.exp-entry__num {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry__remove {
font-size: 0.8rem;
padding: var(--space-1) var(--space-2);
min-height: 32px;
}
.exp-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
</style>

View file

@ -1,232 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 6 Search Preferences</h2>
<p class="step__caption">
Tell Peregrine what roles and markets to watch. You can add more profiles
in Settings Search later.
</p>
<!-- Job titles -->
<div class="step__field">
<label class="step__label">
Job titles <span class="required">*</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.titles.length">
<span v-for="(t, i) in form.titles" :key="i" class="chip">
{{ t }}
<button class="chip__remove" @click="removeTitle(i)" aria-label="Remove title">×</button>
</span>
</div>
<input
v-model="titleInput"
type="text"
class="step__input chip-input"
placeholder="e.g. Software Engineer — press Enter to add"
@keydown.enter.prevent="addTitle"
@keydown.","="onTitleComma"
/>
</div>
<p class="field-hint">Press Enter or comma after each title.</p>
</div>
<!-- Locations -->
<div class="step__field">
<label class="step__label">
Locations <span class="step__label--optional">(optional)</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.locations.length">
<span v-for="(l, i) in form.locations" :key="i" class="chip">
{{ l }}
<button class="chip__remove" @click="removeLocation(i)" aria-label="Remove location">×</button>
</span>
</div>
<input
v-model="locationInput"
type="text"
class="step__input chip-input"
placeholder="e.g. San Francisco, CA — press Enter to add"
@keydown.enter.prevent="addLocation"
@keydown.","="onLocationComma"
/>
</div>
<p class="field-hint">Leave blank to search everywhere, or add specific cities/metros.</p>
</div>
<!-- Remote preference -->
<div class="step__field step__field--inline">
<label class="step__label step__label--inline" for="srch-remote">
Remote jobs only
</label>
<input
id="srch-remote"
v-model="form.remoteOnly"
type="checkbox"
class="step__checkbox"
/>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
const form = reactive({
titles: [...wizard.search.titles],
locations: [...wizard.search.locations],
remoteOnly: false,
})
const titleInput = ref('')
const locationInput = ref('')
function addTitle() {
const v = titleInput.value.trim().replace(/,$/, '')
if (v && !form.titles.includes(v)) form.titles.push(v)
titleInput.value = ''
}
function onTitleComma(e: KeyboardEvent) {
e.preventDefault()
addTitle()
}
function removeTitle(i: number) {
form.titles.splice(i, 1)
}
function addLocation() {
const v = locationInput.value.trim().replace(/,$/, '')
if (v && !form.locations.includes(v)) form.locations.push(v)
locationInput.value = ''
}
function onLocationComma(e: KeyboardEvent) {
e.preventDefault()
addLocation()
}
function removeLocation(i: number) {
form.locations.splice(i, 1)
}
function back() { router.push('/setup/inference') }
async function next() {
// Flush any partial inputs before validating
addTitle()
addLocation()
validationError.value = ''
if (form.titles.length === 0) {
validationError.value = 'Add at least one job title.'
return
}
wizard.search.titles = [...form.titles]
wizard.search.locations = [...form.locations]
const ok = await wizard.saveStep(6, {
search: {
titles: form.titles,
locations: form.locations,
remote_only: form.remoteOnly,
},
})
if (ok) router.push('/setup/integrations')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.step__field--inline {
display: flex;
align-items: center;
gap: var(--space-3);
flex-direction: row;
}
.step__label--inline {
margin-bottom: 0;
}
.step__checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
/* Chip input */
.chip-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-radius: var(--radius-full);
font-size: 0.85rem;
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.chip__remove {
background: none;
border: none;
cursor: pointer;
color: inherit;
font-size: 1rem;
line-height: 1;
padding: 0 2px;
opacity: 0.7;
transition: opacity var(--transition);
}
.chip__remove:hover {
opacity: 1;
}
.chip-input {
margin-top: var(--space-1);
}
</style>

View file

@ -1,68 +0,0 @@
<template>
<div class="step">
<h2 class="step__heading">Step 2 Choose Your Plan</h2>
<p class="step__caption">
You can upgrade or change this later in Settings License.
</p>
<div class="step__radio-group">
<label
v-for="option in tiers"
:key="option.value"
class="step__radio-card"
:class="{ 'step__radio-card--selected': selected === option.value }"
>
<input type="radio" :value="option.value" v-model="selected" />
<div class="step__radio-card__body">
<span class="step__radio-card__title">{{ option.label }}</span>
<span class="step__radio-card__desc">{{ option.desc }}</span>
</div>
</label>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WizardTier } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selected = ref<WizardTier>(wizard.tier)
const tiers = [
{
value: 'free' as WizardTier,
label: '🆓 Free',
desc: 'Core pipeline, job discovery, and resume matching. Bring your own LLM to unlock AI generation.',
},
{
value: 'paid' as WizardTier,
label: '⭐ Paid',
desc: 'Everything in Free, plus cloud AI generation, integrations (Notion, Calendar, Slack), and email sync.',
},
{
value: 'premium' as WizardTier,
label: '🏆 Premium',
desc: 'Everything in Paid, plus fine-tuned cover letter model, multi-user support, and advanced analytics.',
},
]
function back() { router.push('/setup/hardware') }
async function next() {
wizard.tier = selected.value
const ok = await wizard.saveStep(2, { tier: selected.value })
if (ok) router.push('/setup/resume')
}
</script>

View file

@ -1,329 +0,0 @@
/* wizard.css — shared styles imported by every WizardXxxStep component */
/* ── Step heading ──────────────────────────────────────────────────────────── */
.step__heading {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.step__caption {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: var(--space-6);
line-height: 1.5;
}
/* ── Info / warning banners ────────────────────────────────────────────────── */
.step__info {
background: color-mix(in srgb, var(--color-info) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-info) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
line-height: 1.5;
}
.step__warning {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
.step__success {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* ── Form fields ───────────────────────────────────────────────────────────── */
.step__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.step__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.step__label--optional::after {
content: ' (optional)';
font-weight: 400;
color: var(--color-text-muted);
}
.step__input,
.step__select,
.step__textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
transition: border-color var(--transition);
}
.step__input:focus,
.step__select:focus,
.step__textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.step__input[type="password"] {
font-family: var(--font-mono);
letter-spacing: 0.1em;
}
.step__textarea {
resize: vertical;
min-height: 100px;
line-height: 1.5;
}
/* ── Radio cards (Tier step) ──────────────────────────────────────────────── */
.step__radio-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.step__radio-card {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.step__radio-card:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card--selected {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card input[type="radio"] {
margin-top: 2px;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.step__radio-card__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.step__radio-card__title {
font-weight: 700;
font-size: 0.95rem;
color: var(--color-text);
}
.step__radio-card__desc {
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.4;
}
/* ── Chip list (Search step) ──────────────────────────────────────────────── */
.step__chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 36px;
}
.step__chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-primary-light);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-full);
font-size: 0.825rem;
color: var(--color-primary);
font-weight: 500;
}
.step__chip__remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
padding: 0;
line-height: 1;
font-size: 1rem;
opacity: 0.6;
transition: opacity var(--transition);
}
.step__chip__remove:hover {
opacity: 1;
}
.step__chip-input-row {
display: flex;
gap: var(--space-2);
}
.step__chip-input-row .step__input {
flex: 1;
}
/* ── Two-column layout (Search step) ─────────────────────────────────────── */
.step__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
@media (max-width: 520px) {
.step__cols {
grid-template-columns: 1fr;
}
}
/* ── Expandable (advanced section) ───────────────────────────────────────── */
.step__expandable {
margin-bottom: var(--space-4);
}
.step__expandable__toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
font-family: var(--font-body);
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-2);
transition: color var(--transition);
}
.step__expandable__toggle:hover {
color: var(--color-text);
}
.step__expandable__body {
padding: var(--space-4);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
margin-top: var(--space-2);
background: var(--color-surface-alt);
}
/* ── Navigation footer ────────────────────────────────────────────────────── */
.step__nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-light);
gap: var(--space-3);
}
.step__nav--end {
justify-content: flex-end;
}
.btn-primary {
padding: var(--space-2) var(--space-6);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition);
min-height: 44px;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
padding: var(--space-2) var(--space-4);
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
min-height: 44px;
}
.btn-ghost:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-border);
}
.btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-4);
background: var(--color-surface-alt);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
transition: background var(--transition);
min-height: 40px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border-light);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -3,7 +3,6 @@ import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
export default defineConfig({
base: process.env.VITE_BASE_PATH || '/',
plugins: [vue(), UnoCSS()],
server: {
host: '0.0.0.0',