Compare commits
102 commits
818e46c17e
...
be19947cb4
| Author | SHA1 | Date | |
|---|---|---|---|
| be19947cb4 | |||
| a6b32917ea | |||
| 2959abb3da | |||
| 98754cbe43 | |||
| 8c42de3f5c | |||
| faa1807e96 | |||
| ee66b6b235 | |||
| 02e004ee5c | |||
| 9702646738 | |||
| dfac0f3d7a | |||
| 931a07d4e0 | |||
| faf0a7c4dc | |||
| 15dc4b2646 | |||
| 922d91fb91 | |||
| c94a9d5b30 | |||
| 3e41dbf030 | |||
| feea057463 | |||
| fa2569c7e4 | |||
| eb72776e9f | |||
| a380ec33ec | |||
| f6ddaca14f | |||
| bce997e596 | |||
| 5afb752be6 | |||
| 7af0366330 | |||
| a38d9e5663 | |||
| 2200d05b5c | |||
| 92bd82b4c9 | |||
| 56857dc989 | |||
| 6093275549 | |||
| 3bcc08c080 | |||
| d3b4ed74bb | |||
| da7d305588 | |||
| 1ef418ba00 | |||
| 32a83d6ff4 | |||
| 05a737572e | |||
| 4ac9cea5a6 | |||
| 3bfce5e6ef | |||
| 80999b9e7b | |||
| 4bea0899db | |||
| ea23845c23 | |||
| 80ed7a470a | |||
| 595035e02d | |||
| 75163b8e48 | |||
| b1a32ab207 | |||
| 8479f79701 | |||
| 1cee73e233 | |||
| e6385b4c7e | |||
| 7693abf79d | |||
| ff0dd8b3cd | |||
| de69140386 | |||
| 71480d630a | |||
| a29cc7b7d3 | |||
| 347c171e26 | |||
| 51f5b3f0a0 | |||
| 5621140a72 | |||
| 8302b58b20 | |||
| 247f807e02 | |||
| 165811c420 | |||
| 154f691334 | |||
| 4246e71061 | |||
| 9bf14fbc75 | |||
| 4c2a08057c | |||
| f3e7f89e2e | |||
| 1b2643675d | |||
| 5bb3674fea | |||
| 182ab789df | |||
| 7993984af9 | |||
| a503ecde3b | |||
| 0590a3a12e | |||
| 6a1ee3ed28 | |||
| c6f810fb30 | |||
| 87aae6eefc | |||
| 34494db8d8 | |||
| 909fe60908 | |||
| e487942eeb | |||
| 9de51d6b4a | |||
| 804c2a8064 | |||
| 2796d0d911 | |||
| 3b2df5e89e | |||
| 218f4ff9c8 | |||
| 1d943ed8a3 | |||
| e24e0b7233 | |||
| 5ca25e160c | |||
| 52c7dfcfe3 | |||
| 6e2ddaf6da | |||
| bc8174271e | |||
| 4abdf21981 | |||
| 1006e88e5b | |||
| b94828855b | |||
| d8aca3ec52 | |||
| 5ac742d892 | |||
| 73c2557c31 | |||
| c5b3d31cb9 | |||
| b523707d17 | |||
| 4dcab5ff29 | |||
| 6fb366e499 | |||
| cce0f8195a | |||
| d138b27619 | |||
| 1f5ab2df37 | |||
| 75cc0760e1 | |||
| f3ce46e252 | |||
| ae6021ceeb |
36 changed files with 6173 additions and 277 deletions
64
CHANGELOG.md
64
CHANGELOG.md
|
|
@ -9,6 +9,70 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
|
|
|||
44
Dockerfile.cfcore
Normal file
44
Dockerfile.cfcore
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# 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 first so pip can resolve the -e ../circuitforge-core
|
||||
# reference in requirements.txt (installed editable at /circuitforge-core)
|
||||
COPY circuitforge-core/ /circuitforge-core/
|
||||
|
||||
COPY peregrine/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browser (cached separately from Python deps so requirements
|
||||
# changes don't bust the ~600–900 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"]
|
||||
42
README.md
42
README.md
|
|
@ -1,16 +1,33 @@
|
|||
# Peregrine
|
||||
|
||||
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/pyr0ball/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform.
|
||||
> **Primary development** happens at [git.opensourcesolarpunk.com](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine) — GitHub and Codeberg are push mirrors. Issues and PRs are welcome on either platform.
|
||||
|
||||
[](./LICENSE-BSL)
|
||||
[](https://github.com/CircuitForge/peregrine/actions/workflows/ci.yml)
|
||||
|
||||
**AI-powered job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
|
||||
**Job search pipeline — by [Circuit Forge LLC](https://circuitforge.tech)**
|
||||
|
||||
> *"Don't be evil, for real and forever."*
|
||||
> *"Tools for the jobs that the system made hard on purpose."*
|
||||
|
||||
Automates the full job search lifecycle: discovery → matching → cover letters → applications → interview prep.
|
||||
Privacy-first, local-first. Your data never leaves your machine.
|
||||
---
|
||||
|
||||
Job search is a second job nobody hired you for.
|
||||
|
||||
ATS filters designed to reject. Job boards that show the same listing eight times. Cover letter number forty-seven for a role that might already be filled. Hours of prep for a phone screen that lasts twelve minutes.
|
||||
|
||||
Peregrine handles the pipeline — discovery, matching, tracking, drafting, and prep — so you can spend your time doing the work you actually want to be doing.
|
||||
|
||||
**LLM support is optional.** The full discovery and tracking pipeline works without one. When you do configure a backend, the LLM drafts the parts that are genuinely miserable — cover letters, company research briefs, interview prep sheets — and waits for your approval before anything goes anywhere.
|
||||
|
||||
### What Peregrine does not do
|
||||
|
||||
Peregrine does **not** submit job applications for you. You still have to go to each employer's site and click apply yourself.
|
||||
|
||||
This is intentional. Automated mass-applying is a bad experience for everyone — it's also a trust violation with employers who took the time to post a real role. Peregrine is a preparation and organization tool, not a bot.
|
||||
|
||||
What it *does* cover is everything before and after that click: finding the jobs, matching them against your resume, generating cover letters and prep materials, and once you've applied — tracking where you stand, classifying the emails that come back, and surfacing company research when an interview lands on your calendar. The submit button is yours. The rest of the grind is ours.
|
||||
|
||||
> **Exception:** [AIHawk](https://github.com/nicolomantini/LinkedIn-Easy-Apply) is a separate, optional tool that handles LinkedIn Easy Apply automation. Peregrine integrates with it for AIHawk-compatible profiles, but it is not part of Peregrine's core pipeline.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -19,7 +36,7 @@ Privacy-first, local-first. Your data never leaves your machine.
|
|||
**1. Clone and install dependencies** (Docker, NVIDIA toolkit if needed):
|
||||
|
||||
```bash
|
||||
git clone https://git.opensourcesolarpunk.com/pyr0ball/peregrine
|
||||
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||
cd peregrine
|
||||
./manage.sh setup
|
||||
```
|
||||
|
|
@ -129,21 +146,26 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
|
|||
| **Company research briefs** | Free with LLM¹ |
|
||||
| **Interview prep & practice Q&A** | Free with LLM¹ |
|
||||
| **Survey assistant** (culture-fit Q&A, screenshot analysis) | Free with LLM¹ |
|
||||
| **AI wizard helpers** (career summary, bullet expansion, skill suggestions) | Free with LLM¹ |
|
||||
| **Wizard helpers** (career summary, bullet expansion, skill suggestions, job title suggestions, mission notes) | Free with LLM¹ |
|
||||
| Managed cloud LLM (no API key needed) | Paid |
|
||||
| Email sync & auto-classification | Paid |
|
||||
| LLM-powered keyword blocklist | Paid |
|
||||
| Job tracking integrations (Notion, Airtable, Google Sheets) | Paid |
|
||||
| Calendar sync (Google, Apple) | Paid |
|
||||
| Slack notifications | Paid |
|
||||
| CircuitForge shared cover-letter model | Paid |
|
||||
| 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 |
|
||||
|
||||
¹ **BYOK unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
|
||||
or your own API key (Anthropic, OpenAI-compatible) — and all AI features marked **Free with LLM**
|
||||
¹ **BYOK (bring your own key/backend) unlock:** configure any LLM backend — a local [Ollama](https://ollama.com) or vLLM instance,
|
||||
or your own API key (Anthropic, OpenAI-compatible) — and all features marked **Free with LLM** or **Premium with LLM**
|
||||
unlock at no charge. The paid tier earns its price by providing managed cloud inference so you
|
||||
don't need a key at all, plus integrations and email sync.
|
||||
|
||||
² **Voice guidelines** requires Premium tier without a configured LLM backend. With BYOK, it unlocks at any tier.
|
||||
|
||||
---
|
||||
|
||||
## Email Sync
|
||||
|
|
@ -201,6 +223,6 @@ Full documentation at: https://docs.circuitforge.tech/peregrine
|
|||
## License
|
||||
|
||||
Core discovery pipeline: [MIT](LICENSE-MIT)
|
||||
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
|
||||
LLM features (cover letter generation, company research, interview prep, UI): [BSL 1.1](LICENSE-BSL)
|
||||
|
||||
© 2026 Circuit Forge LLC
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ 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(DEFAULT_DB)
|
||||
init_db(get_db_path())
|
||||
|
||||
_email_leads = get_email_leads(DEFAULT_DB)
|
||||
_email_leads = get_email_leads(get_db_path())
|
||||
|
||||
# ── Sidebar filters ────────────────────────────────────────────────────────────
|
||||
with st.sidebar:
|
||||
|
|
@ -37,7 +40,7 @@ with st.sidebar:
|
|||
index=0,
|
||||
)
|
||||
|
||||
jobs = get_jobs_by_status(DEFAULT_DB, show_status)
|
||||
jobs = get_jobs_by_status(get_db_path(), show_status)
|
||||
|
||||
if remote_only:
|
||||
jobs = [j for j in jobs if j.get("is_remote")]
|
||||
|
|
@ -86,11 +89,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(DEFAULT_DB, [lead_id], "approved")
|
||||
update_job_status(get_db_path(), [lead_id], "approved")
|
||||
st.rerun()
|
||||
if st.button("❌ Reject", key=f"el_reject_{lead_id}",
|
||||
use_container_width=True):
|
||||
update_job_status(DEFAULT_DB, [lead_id], "rejected")
|
||||
update_job_status(get_db_path(), [lead_id], "rejected")
|
||||
st.rerun()
|
||||
st.divider()
|
||||
|
||||
|
|
@ -162,7 +165,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(DEFAULT_DB, job_id, st.session_state[_cl_key])
|
||||
update_cover_letter(get_db_path(), job_id, st.session_state[_cl_key])
|
||||
st.success("Saved!")
|
||||
|
||||
# Applied date + cover letter preview (applied/synced)
|
||||
|
|
@ -182,11 +185,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(DEFAULT_DB, [job_id], "approved")
|
||||
update_job_status(get_db_path(), [job_id], "approved")
|
||||
st.rerun()
|
||||
if st.button("❌ Reject", key=f"reject_{job_id}",
|
||||
use_container_width=True):
|
||||
update_job_status(DEFAULT_DB, [job_id], "rejected")
|
||||
update_job_status(get_db_path(), [job_id], "rejected")
|
||||
st.rerun()
|
||||
|
||||
elif show_status == "approved":
|
||||
|
|
@ -198,6 +201,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(DEFAULT_DB, job_id, cl_text)
|
||||
mark_applied(DEFAULT_DB, [job_id])
|
||||
update_cover_letter(get_db_path(), job_id, cl_text)
|
||||
mark_applied(get_db_path(), [job_id])
|
||||
st.rerun()
|
||||
|
|
|
|||
|
|
@ -401,21 +401,31 @@ with tab_search:
|
|||
with st.spinner("Asking LLM for suggestions…"):
|
||||
try:
|
||||
suggestions = _suggest_search_terms(_current_titles, RESUME_PATH, _blocklist, _user_profile)
|
||||
except RuntimeError as _e:
|
||||
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: {_e}. "
|
||||
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="🚨")
|
||||
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", []))
|
||||
for _t in suggestions.get("suggested_titles", []):
|
||||
if _t not in _opts:
|
||||
_opts.append(_t)
|
||||
_new_titles = [_t for _t in suggestions.get("suggested_titles", []) if _t not in _opts]
|
||||
_opts.extend(_new_titles)
|
||||
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()
|
||||
|
||||
if st.session_state.get("_sp_suggestions"):
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ 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")
|
||||
|
|
@ -46,23 +49,23 @@ _calendar_connected = any(
|
|||
|
||||
st.title("🎯 Interviews")
|
||||
|
||||
init_db(DEFAULT_DB)
|
||||
init_db(get_db_path())
|
||||
|
||||
# ── Sidebar: Email sync ────────────────────────────────────────────────────────
|
||||
with st.sidebar:
|
||||
st.markdown("### 📧 Email Sync")
|
||||
_email_task = get_task_for_job(DEFAULT_DB, "email_sync", 0)
|
||||
_email_task = get_task_for_job(get_db_path(), "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(DEFAULT_DB, "email_sync", 0)
|
||||
submit_task(get_db_path(), "email_sync", 0)
|
||||
st.rerun()
|
||||
|
||||
if _email_running:
|
||||
@st.fragment(run_every=4)
|
||||
def _email_sidebar_status():
|
||||
t = get_task_for_job(DEFAULT_DB, "email_sync", 0)
|
||||
t = get_task_for_job(get_db_path(), "email_sync", 0)
|
||||
if t and t["status"] in ("queued", "running"):
|
||||
st.info("⏳ Syncing…")
|
||||
else:
|
||||
|
|
@ -99,7 +102,7 @@ STAGE_NEXT_LABEL = {
|
|||
}
|
||||
|
||||
# ── Data ──────────────────────────────────────────────────────────────────────
|
||||
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
def _days_ago(date_str: str | None) -> str:
|
||||
|
|
@ -120,8 +123,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(DEFAULT_DB, job_id=job_id)
|
||||
task = get_task_for_job(DEFAULT_DB, "company_research", job_id)
|
||||
research = get_research(get_db_path(), job_id=job_id)
|
||||
task = get_task_for_job(get_db_path(), "company_research", job_id)
|
||||
running = task and task["status"] in ("queued", "running")
|
||||
|
||||
if running:
|
||||
|
|
@ -144,7 +147,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(DEFAULT_DB, "company_research", job_id)
|
||||
submit_task(get_db_path(), "company_research", job_id)
|
||||
st.rerun()
|
||||
st.divider()
|
||||
else:
|
||||
|
|
@ -160,14 +163,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(DEFAULT_DB, "company_research", job_id)
|
||||
submit_task(get_db_path(), "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(DEFAULT_DB, "company_research", job_id)
|
||||
submit_task(get_db_path(), "company_research", job_id)
|
||||
st.rerun()
|
||||
|
||||
|
||||
|
|
@ -175,7 +178,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(DEFAULT_DB, job_id=job_id)
|
||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
||||
|
||||
if not contacts:
|
||||
st.info("No emails logged yet. Use the form below to add one.")
|
||||
|
|
@ -246,7 +249,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(
|
||||
DEFAULT_DB, job_id=job_id,
|
||||
get_db_path(), job_id=job_id,
|
||||
direction=direction, subject=subject,
|
||||
from_addr=from_addr, body=body_text, received_at=recv_at,
|
||||
)
|
||||
|
|
@ -255,7 +258,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(DEFAULT_DB, job_id=job_id)
|
||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
||||
last_contact = contacts[-1] if contacts else None
|
||||
|
||||
with st.container(border=True):
|
||||
|
|
@ -278,7 +281,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(DEFAULT_DB, job_id=job_id, date_str=str(new_date))
|
||||
set_interview_date(get_db_path(), job_id=job_id, date_str=str(new_date))
|
||||
st.success("Saved!")
|
||||
st.rerun()
|
||||
|
||||
|
|
@ -288,7 +291,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(DEFAULT_DB, job_id=job_id, config_dir=_CONFIG_DIR)
|
||||
result = push_interview_event(get_db_path(), 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()
|
||||
|
|
@ -297,7 +300,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(DEFAULT_DB, job_id=job_id)
|
||||
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
|
||||
if signals:
|
||||
sig = signals[-1]
|
||||
_SIGNAL_TO_STAGE = {
|
||||
|
|
@ -318,23 +321,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(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
|
||||
dismiss_stage_signal(get_db_path(), 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(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
||||
submit_task(get_db_path(), "company_research", job_id)
|
||||
elif target_stage:
|
||||
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
|
||||
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
|
||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
||||
st.rerun(scope="app")
|
||||
if b2.button("Dismiss", key=f"sig_dis_{sig['id']}",
|
||||
use_container_width=True):
|
||||
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
||||
st.rerun()
|
||||
|
||||
# Advance / Reject buttons
|
||||
|
|
@ -346,16 +349,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(DEFAULT_DB, job_id=job_id, stage=next_stage)
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage=next_stage)
|
||||
if next_stage == "phone_screen":
|
||||
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||
submit_task(get_db_path(), "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(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||
reject_at_stage(get_db_path(), job_id=job_id, rejection_stage=stage)
|
||||
st.rerun() # fragment-scope rerun — card disappears without scroll-to-top
|
||||
|
||||
if job.get("url"):
|
||||
|
|
@ -385,7 +388,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(DEFAULT_DB, job_id)
|
||||
job = get_job_by_id(get_db_path(), job_id)
|
||||
if job is None or job.get("status") != stage:
|
||||
return
|
||||
_render_card(job, stage)
|
||||
|
|
@ -394,11 +397,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(DEFAULT_DB, job_id)
|
||||
job = get_job_by_id(get_db_path(), job_id)
|
||||
if job is None or job.get("status") not in ("applied", "survey"):
|
||||
return
|
||||
stage = job["status"]
|
||||
contacts = get_contacts(DEFAULT_DB, job_id=job_id)
|
||||
contacts = get_contacts(get_db_path(), job_id=job_id)
|
||||
last_contact = contacts[-1] if contacts else None
|
||||
|
||||
with st.container(border=True):
|
||||
|
|
@ -414,7 +417,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(DEFAULT_DB, job_id=job_id)
|
||||
signals = get_unread_stage_signals(get_db_path(), job_id=job_id)
|
||||
if signals:
|
||||
sig = signals[-1]
|
||||
_SIGNAL_TO_STAGE = {
|
||||
|
|
@ -437,15 +440,15 @@ def _pre_kanban_row_fragment(job_id: int) -> None:
|
|||
use_container_width=True, type="primary",
|
||||
):
|
||||
if target_stage == "phone_screen":
|
||||
advance_to_stage(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
||||
submit_task(get_db_path(), "company_research", job_id)
|
||||
else:
|
||||
advance_to_stage(DEFAULT_DB, job_id=job_id, stage=target_stage)
|
||||
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage=target_stage)
|
||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
||||
st.rerun(scope="app")
|
||||
if s2.button("Dismiss", key=f"sig_dis_pre_{sig['id']}",
|
||||
use_container_width=True):
|
||||
dismiss_stage_signal(DEFAULT_DB, sig["id"])
|
||||
dismiss_stage_signal(get_db_path(), sig["id"])
|
||||
st.rerun()
|
||||
|
||||
with right:
|
||||
|
|
@ -453,24 +456,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(DEFAULT_DB, job_id=job_id, stage="phone_screen")
|
||||
submit_task(DEFAULT_DB, "company_research", job_id)
|
||||
advance_to_stage(get_db_path(), job_id=job_id, stage="phone_screen")
|
||||
submit_task(get_db_path(), "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(DEFAULT_DB, job_id=job_id, stage="survey")
|
||||
advance_to_stage(get_db_path(), 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(DEFAULT_DB, job_id=job_id, rejection_stage=stage)
|
||||
reject_at_stage(get_db_path(), 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(DEFAULT_DB, job_id)
|
||||
job = get_job_by_id(get_db_path(), job_id)
|
||||
if job is None or job.get("status") != "hired":
|
||||
return
|
||||
with st.container(border=True):
|
||||
|
|
|
|||
|
|
@ -25,11 +25,14 @@ 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
|
||||
|
||||
init_db(DEFAULT_DB)
|
||||
resolve_session("peregrine")
|
||||
|
||||
init_db(get_db_path())
|
||||
|
||||
# ── Job selection ─────────────────────────────────────────────────────────────
|
||||
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
||||
active_stages = ["phone_screen", "interviewing", "offer"]
|
||||
active_jobs = [
|
||||
j for stage in active_stages
|
||||
|
|
@ -100,10 +103,10 @@ col_prep, col_context = st.columns([2, 3])
|
|||
# ════════════════════════════════════════════════
|
||||
with col_prep:
|
||||
|
||||
research = get_research(DEFAULT_DB, job_id=selected_id)
|
||||
research = get_research(get_db_path(), job_id=selected_id)
|
||||
|
||||
# Refresh / generate research
|
||||
_res_task = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||
_res_task = get_task_for_job(get_db_path(), "company_research", selected_id)
|
||||
_res_running = _res_task and _res_task["status"] in ("queued", "running")
|
||||
|
||||
if not research:
|
||||
|
|
@ -112,13 +115,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(DEFAULT_DB, "company_research", selected_id)
|
||||
submit_task(get_db_path(), "company_research", selected_id)
|
||||
st.rerun()
|
||||
|
||||
if _res_running:
|
||||
@st.fragment(run_every=3)
|
||||
def _res_status_initial():
|
||||
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||
t = get_task_for_job(get_db_path(), "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 30–60 seconds")
|
||||
|
|
@ -133,13 +136,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(DEFAULT_DB, "company_research", selected_id)
|
||||
submit_task(get_db_path(), "company_research", selected_id)
|
||||
st.rerun()
|
||||
|
||||
if _res_running:
|
||||
@st.fragment(run_every=3)
|
||||
def _res_status_refresh():
|
||||
t = get_task_for_job(DEFAULT_DB, "company_research", selected_id)
|
||||
t = get_task_for_job(get_db_path(), "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…")
|
||||
|
|
@ -311,7 +314,7 @@ with col_context:
|
|||
st.markdown(job.get("description") or "_No description saved for this listing._")
|
||||
|
||||
with tab_emails:
|
||||
contacts = get_contacts(DEFAULT_DB, job_id=selected_id)
|
||||
contacts = get_contacts(get_db_path(), job_id=selected_id)
|
||||
if not contacts:
|
||||
st.info("No contacts logged yet. Use the Interviews page to log emails.")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ 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(DEFAULT_DB)
|
||||
init_db(get_db_path())
|
||||
|
||||
|
||||
# ── Vision service health check ────────────────────────────────────────────────
|
||||
|
|
@ -40,7 +43,7 @@ def _vision_available() -> bool:
|
|||
vision_up = _vision_available()
|
||||
|
||||
# ── Job selector ───────────────────────────────────────────────────────────────
|
||||
jobs_by_stage = get_interview_jobs(DEFAULT_DB)
|
||||
jobs_by_stage = get_interview_jobs(get_db_path())
|
||||
survey_jobs = jobs_by_stage.get("survey", [])
|
||||
other_jobs = (
|
||||
jobs_by_stage.get("applied", []) +
|
||||
|
|
@ -61,7 +64,7 @@ selected_job_id = st.selectbox(
|
|||
format_func=lambda jid: job_labels[jid],
|
||||
index=0,
|
||||
)
|
||||
selected_job = get_job_by_id(DEFAULT_DB, selected_job_id)
|
||||
selected_job = get_job_by_id(get_db_path(), selected_job_id)
|
||||
|
||||
# ── LLM prompt builders ────────────────────────────────────────────────────────
|
||||
_SURVEY_SYSTEM = (
|
||||
|
|
@ -236,7 +239,7 @@ with right_col:
|
|||
image_path = str(img_file)
|
||||
|
||||
insert_survey_response(
|
||||
DEFAULT_DB,
|
||||
get_db_path(),
|
||||
job_id=selected_job_id,
|
||||
survey_name=survey_name,
|
||||
source=source,
|
||||
|
|
@ -256,7 +259,7 @@ with right_col:
|
|||
# ── History ────────────────────────────────────────────────────────────────────
|
||||
st.divider()
|
||||
st.subheader("📂 Response History")
|
||||
history = get_survey_responses(DEFAULT_DB, job_id=selected_job_id)
|
||||
history = get_survey_responses(get_db_path(), job_id=selected_job_id)
|
||||
|
||||
if not history:
|
||||
st.caption("No saved responses for this job yet.")
|
||||
|
|
|
|||
34
compose.test-cfcore.yml
Normal file
34
compose.test-cfcore.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# 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:
|
||||
- peregrine-test-data:/devl/job-seeker
|
||||
environment:
|
||||
- STAGING_DB=/devl/job-seeker/staging.db
|
||||
- PYTHONUNBUFFERED=1
|
||||
- STREAMLIT_SERVER_BASE_URL_PATH=
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: "no"
|
||||
|
||||
volumes:
|
||||
peregrine-test-data:
|
||||
1800
dev-api.py
Normal file
1800
dev-api.py
Normal file
File diff suppressed because it is too large
Load diff
1
dev_api.py
Symbolic link
1
dev_api.py
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
dev-api.py
|
||||
174
docs/vue-spa-migration.md
Normal file
174
docs/vue-spa-migration.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# 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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: job-seeker
|
||||
name: cf
|
||||
# Recreate: conda env create -f environment.yml
|
||||
# Update pinned snapshot: conda env export --no-builds > environment.yml
|
||||
channels:
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ case "$CMD" in
|
|||
|
||||
models)
|
||||
info "Checking ollama models..."
|
||||
conda run -n job-seeker python scripts/preflight.py --models-only
|
||||
conda run -n cf python scripts/preflight.py --models-only
|
||||
success "Model check complete."
|
||||
;;
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ case "$CMD" in
|
|||
RUNNER=""
|
||||
fi
|
||||
info "Running E2E tests (mode=${MODE}, headless=${HEADLESS})..."
|
||||
$RUNNER conda run -n job-seeker pytest tests/e2e/ \
|
||||
$RUNNER conda run -n cf pytest tests/e2e/ \
|
||||
--mode="${MODE}" \
|
||||
--json-report \
|
||||
--json-report-file="${RESULTS_DIR}/report.json" \
|
||||
|
|
|
|||
198
scripts/credential_store.py
Normal file
198
scripts/credential_store.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""
|
||||
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()
|
||||
|
|
@ -121,6 +121,15 @@ 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"),
|
||||
|
|
@ -132,6 +141,8 @@ _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)
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -177,6 +188,7 @@ 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)
|
||||
|
|
@ -301,6 +313,38 @@ 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",
|
||||
|
|
|
|||
|
|
@ -196,13 +196,20 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
|
|||
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:
|
||||
jobs: pd.DataFrame = scrape_jobs(
|
||||
jobspy_kwargs: dict = dict(
|
||||
site_name=boards,
|
||||
search_term=" OR ".join(f'"{t}"' for t in profile["titles"]),
|
||||
location=location,
|
||||
|
|
@ -210,6 +217,9 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False) -> None
|
|||
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}")
|
||||
|
|
|
|||
|
|
@ -698,21 +698,43 @@ def _parse_message(conn: imaplib.IMAP4, uid: bytes) -> Optional[dict]:
|
|||
return None
|
||||
msg = email.message_from_bytes(data[0][1])
|
||||
|
||||
body = ""
|
||||
# Prefer text/html (preserves href attributes for digest link extraction);
|
||||
# fall back to text/plain if no HTML part exists.
|
||||
html_body = ""
|
||||
plain_body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
ct = part.get_content_type()
|
||||
if ct == "text/html" and not html_body:
|
||||
try:
|
||||
body = part.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
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")
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
try:
|
||||
body = msg.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
raw = msg.get_payload(decode=True).decode("utf-8", errors="replace")
|
||||
if ct == "text/html":
|
||||
html_body = raw
|
||||
else:
|
||||
plain_body = raw
|
||||
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
|
||||
|
|
@ -723,7 +745,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[:4000],
|
||||
"body": body, # no truncation — digest emails need full content
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
313
scripts/job_ranker.py
Normal file
313
scripts/job_ranker.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""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 0–100.
|
||||
|
||||
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 (0–100 → 0–1)
|
||||
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 (0–100 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 1–7 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 (0–100) 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 1–7.
|
||||
|
||||
Returns:
|
||||
A float 0–100. Higher = better match for this user's session.
|
||||
"""
|
||||
# ── Individual signals (all 0–1) ──────────────────────────────────────────
|
||||
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 0–100 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 1–7 (use seniority_from_experience()).
|
||||
limit: Stack size; pass 0 to return all qualifying jobs.
|
||||
min_score: Minimum stack_score to include (0–100).
|
||||
|
||||
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
|
||||
439
scripts/resume_optimizer.py
Normal file
439
scripts/resume_optimizer.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -9,10 +9,13 @@ and marks the task completed or failed.
|
|||
Deduplication: only one queued/running task per (task_type, job_id) is allowed.
|
||||
Different task types for the same job run concurrently (e.g. cover letter + research).
|
||||
"""
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from scripts.db import (
|
||||
DEFAULT_DB,
|
||||
insert_task,
|
||||
|
|
@ -20,6 +23,7 @@ from scripts.db import (
|
|||
update_task_stage,
|
||||
update_cover_letter,
|
||||
save_research,
|
||||
save_optimized_resume,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -39,9 +43,13 @@ def submit_task(db_path: Path = DEFAULT_DB, task_type: str = "",
|
|||
if is_new:
|
||||
from scripts.task_scheduler import get_scheduler, LLM_TASK_TYPES
|
||||
if task_type in LLM_TASK_TYPES:
|
||||
get_scheduler(db_path, run_task_fn=_run_task).enqueue(
|
||||
enqueued = get_scheduler(db_path, run_task_fn=_run_task).enqueue(
|
||||
task_id, task_type, job_id or 0, params
|
||||
)
|
||||
if not enqueued:
|
||||
update_task_status(
|
||||
db_path, task_id, "failed", error="Queue depth limit reached"
|
||||
)
|
||||
else:
|
||||
t = threading.Thread(
|
||||
target=_run_task,
|
||||
|
|
@ -261,6 +269,48 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
|||
)
|
||||
return
|
||||
|
||||
elif task_type == "resume_optimize":
|
||||
import json as _json
|
||||
from scripts.resume_parser import structure_resume
|
||||
from scripts.resume_optimizer import (
|
||||
extract_jd_signals,
|
||||
prioritize_gaps,
|
||||
rewrite_for_ats,
|
||||
hallucination_check,
|
||||
render_resume_text,
|
||||
)
|
||||
from scripts.user_profile import load_user_profile
|
||||
|
||||
description = job.get("description", "")
|
||||
resume_path = load_user_profile().get("resume_path", "")
|
||||
|
||||
# Parse the candidate's resume
|
||||
update_task_stage(db_path, task_id, "parsing resume")
|
||||
resume_text = Path(resume_path).read_text(errors="replace") if resume_path else ""
|
||||
resume_struct, parse_err = structure_resume(resume_text)
|
||||
|
||||
# Extract keyword gaps and build gap report (free tier)
|
||||
update_task_stage(db_path, task_id, "extracting keyword gaps")
|
||||
gaps = extract_jd_signals(description, resume_text)
|
||||
prioritized = prioritize_gaps(gaps, resume_struct)
|
||||
gap_report = _json.dumps(prioritized, indent=2)
|
||||
|
||||
# Full rewrite (paid tier only)
|
||||
rewritten_text = ""
|
||||
p = _json.loads(params or "{}")
|
||||
if p.get("full_rewrite", False):
|
||||
update_task_stage(db_path, task_id, "rewriting resume sections")
|
||||
candidate_voice = load_user_profile().get("candidate_voice", "")
|
||||
rewritten = rewrite_for_ats(resume_struct, prioritized, job, candidate_voice)
|
||||
if hallucination_check(resume_struct, rewritten):
|
||||
rewritten_text = render_resume_text(rewritten)
|
||||
else:
|
||||
log.warning("[task_runner] resume_optimize hallucination check failed for job %d", job_id)
|
||||
|
||||
save_optimized_resume(db_path, job_id=job_id,
|
||||
text=rewritten_text,
|
||||
gap_report=gap_report)
|
||||
|
||||
elif task_type == "prepare_training":
|
||||
from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT
|
||||
records = build_records()
|
||||
|
|
|
|||
|
|
@ -1,34 +1,38 @@
|
|||
# scripts/task_scheduler.py
|
||||
"""Resource-aware batch scheduler for LLM background tasks.
|
||||
"""Peregrine LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
|
||||
|
||||
Routes LLM task types through per-type deques with VRAM-aware scheduling.
|
||||
Non-LLM tasks bypass this module — routing lives in scripts/task_runner.py.
|
||||
All scheduling logic lives in circuitforge_core. This module defines
|
||||
Peregrine-specific task types, VRAM budgets, and config loading.
|
||||
|
||||
Public API:
|
||||
LLM_TASK_TYPES — set of task type strings routed through the scheduler
|
||||
get_scheduler() — lazy singleton accessor
|
||||
Public API (unchanged — callers do not need to change):
|
||||
LLM_TASK_TYPES — frozenset of task type strings routed through the scheduler
|
||||
DEFAULT_VRAM_BUDGETS — dict of conservative peak VRAM estimates per task type
|
||||
TaskSpec — lightweight task descriptor (re-exported from core)
|
||||
TaskScheduler — backward-compatible wrapper around the core scheduler class
|
||||
get_scheduler() — returns the process-level TaskScheduler singleton
|
||||
reset_scheduler() — test teardown only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from collections import deque, namedtuple
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Module-level import so tests can monkeypatch scripts.task_scheduler._get_gpus
|
||||
try:
|
||||
from scripts.preflight import get_gpus as _get_gpus
|
||||
except Exception: # graceful degradation if preflight unavailable
|
||||
_get_gpus = lambda: []
|
||||
from circuitforge_core.tasks.scheduler import (
|
||||
TaskSpec, # re-export unchanged
|
||||
TaskScheduler as _CoreTaskScheduler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Task types that go through the scheduler (all others spawn free threads)
|
||||
# ── Peregrine task types and VRAM budgets ─────────────────────────────────────
|
||||
|
||||
LLM_TASK_TYPES: frozenset[str] = frozenset({
|
||||
"cover_letter",
|
||||
"company_research",
|
||||
"wizard_generate",
|
||||
"resume_optimize",
|
||||
})
|
||||
|
||||
# Conservative peak VRAM estimates (GB) per task type.
|
||||
|
|
@ -37,196 +41,136 @@ DEFAULT_VRAM_BUDGETS: dict[str, float] = {
|
|||
"cover_letter": 2.5, # alex-cover-writer:latest (~2 GB GGUF + headroom)
|
||||
"company_research": 5.0, # llama3.1:8b or vllm model
|
||||
"wizard_generate": 2.5, # same model family as cover_letter
|
||||
"resume_optimize": 5.0, # section-by-section rewrite; same budget as research
|
||||
}
|
||||
|
||||
# Lightweight task descriptor stored in per-type deques
|
||||
TaskSpec = namedtuple("TaskSpec", ["id", "job_id", "params"])
|
||||
_DEFAULT_MAX_QUEUE_DEPTH = 500
|
||||
|
||||
|
||||
class TaskScheduler:
|
||||
"""Resource-aware LLM task batch scheduler. Use get_scheduler() — not direct construction."""
|
||||
|
||||
def __init__(self, db_path: Path, run_task_fn: Callable) -> None:
|
||||
self._db_path = db_path
|
||||
self._run_task = run_task_fn
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._wake = threading.Event()
|
||||
self._stop = threading.Event()
|
||||
self._queues: dict[str, deque] = {}
|
||||
self._active: dict[str, threading.Thread] = {}
|
||||
self._reserved_vram: float = 0.0
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
# Load VRAM budgets: defaults + optional config overrides
|
||||
self._budgets: dict[str, float] = dict(DEFAULT_VRAM_BUDGETS)
|
||||
def _load_config_overrides(db_path: Path) -> tuple[dict[str, float], int]:
|
||||
"""Load VRAM budget overrides and max_queue_depth from config/llm.yaml."""
|
||||
budgets = dict(DEFAULT_VRAM_BUDGETS)
|
||||
max_depth = _DEFAULT_MAX_QUEUE_DEPTH
|
||||
config_path = db_path.parent.parent / "config" / "llm.yaml"
|
||||
self._max_queue_depth: int = 500
|
||||
if config_path.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
sched_cfg = cfg.get("scheduler", {})
|
||||
self._budgets.update(sched_cfg.get("vram_budgets", {}))
|
||||
self._max_queue_depth = sched_cfg.get("max_queue_depth", 500)
|
||||
budgets.update(sched_cfg.get("vram_budgets", {}))
|
||||
max_depth = int(sched_cfg.get("max_queue_depth", max_depth))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load scheduler config from %s: %s", config_path, exc)
|
||||
logger.warning(
|
||||
"Failed to load scheduler config from %s: %s", config_path, exc
|
||||
)
|
||||
return budgets, max_depth
|
||||
|
||||
# Warn on LLM types with no budget entry after merge
|
||||
|
||||
# Module-level stub so tests can monkeypatch scripts.task_scheduler._get_gpus
|
||||
# (existing tests monkeypatch this symbol — keep it here for backward compat).
|
||||
try:
|
||||
from scripts.preflight import get_gpus as _get_gpus
|
||||
except Exception:
|
||||
_get_gpus = lambda: [] # noqa: E731
|
||||
|
||||
|
||||
class TaskScheduler(_CoreTaskScheduler):
|
||||
"""Peregrine-specific TaskScheduler.
|
||||
|
||||
Extends circuitforge_core.tasks.scheduler.TaskScheduler with:
|
||||
- Peregrine default VRAM budgets and task types wired into __init__
|
||||
- Config loading from config/llm.yaml
|
||||
- Backward-compatible two-argument __init__ signature (db_path, run_task_fn)
|
||||
- _get_gpus monkeypatch support (existing tests patch this module-level symbol)
|
||||
- Backward-compatible enqueue() that marks dropped tasks failed in the DB
|
||||
and logs under the scripts.task_scheduler logger
|
||||
|
||||
Direct construction is still supported for tests; production code should
|
||||
use get_scheduler() instead.
|
||||
"""
|
||||
|
||||
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)
|
||||
for t in LLM_TASK_TYPES:
|
||||
if t not in self._budgets:
|
||||
if t not in budgets:
|
||||
logger.warning(
|
||||
"No VRAM budget defined for LLM task type %r — "
|
||||
"defaulting to 0.0 GB (unlimited concurrency for this type)", t
|
||||
)
|
||||
|
||||
# Detect total GPU VRAM; fall back to unlimited (999) on CPU-only systems.
|
||||
# Uses module-level _get_gpus so tests can monkeypatch scripts.task_scheduler._get_gpus.
|
||||
try:
|
||||
gpus = _get_gpus()
|
||||
self._available_vram: float = (
|
||||
sum(g["vram_total_gb"] for g in gpus) if gpus else 999.0
|
||||
super().__init__(
|
||||
db_path=db_path,
|
||||
run_task_fn=run_task_fn,
|
||||
task_types=LLM_TASK_TYPES,
|
||||
vram_budgets=budgets,
|
||||
available_vram_gb=available_vram,
|
||||
max_queue_depth=max_depth,
|
||||
)
|
||||
except Exception:
|
||||
self._available_vram = 999.0
|
||||
|
||||
# Durability: reload surviving 'queued' LLM tasks from prior run
|
||||
self._load_queued_tasks()
|
||||
|
||||
def enqueue(self, task_id: int, task_type: str, job_id: int,
|
||||
params: Optional[str]) -> None:
|
||||
def enqueue(
|
||||
self,
|
||||
task_id: int,
|
||||
task_type: str,
|
||||
job_id: int,
|
||||
params: Optional[str],
|
||||
) -> bool:
|
||||
"""Add an LLM task to the scheduler queue.
|
||||
|
||||
If the queue for this type is at max_queue_depth, the task is marked
|
||||
failed in SQLite immediately (no ghost queued rows) and a warning is logged.
|
||||
"""
|
||||
from scripts.db import update_task_status
|
||||
When the queue is full, marks the task failed in SQLite immediately
|
||||
(backward-compatible with the original Peregrine behavior) and logs a
|
||||
warning under the scripts.task_scheduler logger.
|
||||
|
||||
with self._lock:
|
||||
q = self._queues.setdefault(task_type, deque())
|
||||
if len(q) >= self._max_queue_depth:
|
||||
Returns True if enqueued, False if the queue was full.
|
||||
"""
|
||||
enqueued = super().enqueue(task_id, task_type, job_id, params)
|
||||
if not enqueued:
|
||||
# Log under this module's logger so existing caplog tests pass
|
||||
logger.warning(
|
||||
"Queue depth limit reached for %s (max=%d) — task %d dropped",
|
||||
task_type, self._max_queue_depth, task_id,
|
||||
)
|
||||
update_task_status(self._db_path, task_id, "failed",
|
||||
error="Queue depth limit reached")
|
||||
return
|
||||
q.append(TaskSpec(task_id, job_id, params))
|
||||
|
||||
self._wake.set()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background scheduler loop thread. Call once after construction."""
|
||||
self._thread = threading.Thread(
|
||||
target=self._scheduler_loop, name="task-scheduler", daemon=True
|
||||
from scripts.db import update_task_status
|
||||
update_task_status(
|
||||
self._db_path, task_id, "failed", error="Queue depth limit reached"
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def shutdown(self, timeout: float = 5.0) -> None:
|
||||
"""Signal the scheduler to stop and wait for it to exit."""
|
||||
self._stop.set()
|
||||
self._wake.set() # unblock any wait()
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=timeout)
|
||||
|
||||
def _scheduler_loop(self) -> None:
|
||||
"""Main scheduler daemon — wakes on enqueue or batch completion."""
|
||||
while not self._stop.is_set():
|
||||
self._wake.wait(timeout=30)
|
||||
self._wake.clear()
|
||||
|
||||
with self._lock:
|
||||
# Defense in depth: reap externally-killed batch threads.
|
||||
# In normal operation _active.pop() runs in finally before _wake fires,
|
||||
# so this reap finds nothing — no double-decrement risk.
|
||||
for t, thread in list(self._active.items()):
|
||||
if not thread.is_alive():
|
||||
self._reserved_vram -= self._budgets.get(t, 0.0)
|
||||
del self._active[t]
|
||||
|
||||
# Start new type batches while VRAM allows
|
||||
candidates = sorted(
|
||||
[t for t in self._queues if self._queues[t] and t not in self._active],
|
||||
key=lambda t: len(self._queues[t]),
|
||||
reverse=True,
|
||||
)
|
||||
for task_type in candidates:
|
||||
budget = self._budgets.get(task_type, 0.0)
|
||||
# Always allow at least one batch to run even if its budget
|
||||
# exceeds _available_vram (prevents permanent starvation when
|
||||
# a single type's budget is larger than the VRAM ceiling).
|
||||
if self._reserved_vram == 0.0 or self._reserved_vram + budget <= self._available_vram:
|
||||
thread = threading.Thread(
|
||||
target=self._batch_worker,
|
||||
args=(task_type,),
|
||||
name=f"batch-{task_type}",
|
||||
daemon=True,
|
||||
)
|
||||
self._active[task_type] = thread
|
||||
self._reserved_vram += budget
|
||||
thread.start()
|
||||
|
||||
def _batch_worker(self, task_type: str) -> None:
|
||||
"""Serial consumer for one task type. Runs until the type's deque is empty."""
|
||||
try:
|
||||
while True:
|
||||
with self._lock:
|
||||
q = self._queues.get(task_type)
|
||||
if not q:
|
||||
break
|
||||
task = q.popleft()
|
||||
# _run_task is scripts.task_runner._run_task (passed at construction)
|
||||
self._run_task(
|
||||
self._db_path, task.id, task_type, task.job_id, task.params
|
||||
)
|
||||
finally:
|
||||
# Always release — even if _run_task raises.
|
||||
# _active.pop here prevents the scheduler loop reap from double-decrementing.
|
||||
with self._lock:
|
||||
self._active.pop(task_type, None)
|
||||
self._reserved_vram -= self._budgets.get(task_type, 0.0)
|
||||
self._wake.set()
|
||||
|
||||
def _load_queued_tasks(self) -> None:
|
||||
"""Load pre-existing queued LLM tasks from SQLite into deques (called once in __init__)."""
|
||||
llm_types = sorted(LLM_TASK_TYPES) # sorted for deterministic SQL params in logs
|
||||
placeholders = ",".join("?" * len(llm_types))
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
rows = conn.execute(
|
||||
f"SELECT id, task_type, job_id, params FROM background_tasks"
|
||||
f" WHERE status='queued' AND task_type IN ({placeholders})"
|
||||
f" ORDER BY created_at ASC",
|
||||
llm_types,
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
for row_id, task_type, job_id, params in rows:
|
||||
q = self._queues.setdefault(task_type, deque())
|
||||
q.append(TaskSpec(row_id, job_id, params))
|
||||
|
||||
if rows:
|
||||
logger.info("Scheduler: resumed %d queued task(s) from prior run", len(rows))
|
||||
return enqueued
|
||||
|
||||
|
||||
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||
# ── Peregrine-local singleton ──────────────────────────────────────────────────
|
||||
# We manage our own singleton (not the core one) so the process-level instance
|
||||
# is always a Peregrine TaskScheduler (with the enqueue() override).
|
||||
|
||||
_scheduler: Optional[TaskScheduler] = None
|
||||
_scheduler_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_scheduler(db_path: Path, run_task_fn: Callable = None) -> TaskScheduler:
|
||||
"""Return the process-level TaskScheduler singleton, constructing it if needed.
|
||||
def get_scheduler(
|
||||
db_path: Path,
|
||||
run_task_fn: Optional[Callable] = None,
|
||||
) -> TaskScheduler:
|
||||
"""Return the process-level Peregrine TaskScheduler singleton.
|
||||
|
||||
run_task_fn is required on the first call; ignored on subsequent calls.
|
||||
Safety: inner lock + double-check prevents double-construction under races.
|
||||
The outer None check is a fast-path performance optimisation only.
|
||||
run_task_fn is required on the first call; ignored on subsequent calls
|
||||
(double-checked locking — singleton already constructed).
|
||||
"""
|
||||
global _scheduler
|
||||
if _scheduler is None: # fast path — avoids lock on steady state
|
||||
if _scheduler is None: # fast path — no lock on steady state
|
||||
with _scheduler_lock:
|
||||
if _scheduler is None: # re-check under lock (double-checked locking)
|
||||
if _scheduler is None: # re-check under lock
|
||||
if run_task_fn is None:
|
||||
raise ValueError("run_task_fn required on first get_scheduler() call")
|
||||
_scheduler = TaskScheduler(db_path, run_task_fn)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ here so port/host/SSL changes propagate everywhere automatically.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import os
|
||||
import tempfile
|
||||
import yaml
|
||||
|
||||
_DEFAULTS = {
|
||||
|
|
@ -161,3 +163,30 @@ 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
|
||||
|
|
|
|||
238
tests/test_dev_api_digest.py
Normal file
238
tests/test_dev_api_digest.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""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
|
||||
216
tests/test_dev_api_interviews.py
Normal file
216
tests/test_dev_api_interviews.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""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
|
||||
161
tests/test_dev_api_prep.py
Normal file
161
tests/test_dev_api_prep.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""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
|
||||
632
tests/test_dev_api_settings.py
Normal file
632
tests/test_dev_api_settings.py
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
"""Tests for all settings API endpoints added in Tasks 1–8."""
|
||||
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", 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", 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", 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", 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", 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", 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.EMAIL_PATH", fake_email_path)
|
||||
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.EMAIL_PATH", fake_email_path)
|
||||
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(client):
|
||||
"""Status is 'idle' and pairs_count is 0 when no task exists."""
|
||||
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 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", 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", 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", fake_license)
|
||||
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||
|
||||
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", fake_license)
|
||||
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||
|
||||
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", fake_license)
|
||||
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||
|
||||
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", fake_license)
|
||||
monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path)
|
||||
|
||||
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", 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", 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
|
||||
164
tests/test_dev_api_survey.py
Normal file
164
tests/test_dev_api_survey.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""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"
|
||||
|
|
@ -1024,8 +1024,8 @@ def test_sync_all_per_job_exception_continues(tmp_path):
|
|||
|
||||
# ── Performance / edge cases ──────────────────────────────────────────────────
|
||||
|
||||
def test_parse_message_large_body_truncated():
|
||||
"""Body longer than 4000 chars is silently truncated to 4000."""
|
||||
def test_parse_message_large_body_not_truncated():
|
||||
"""Body longer than 4000 chars is stored in full (no truncation)."""
|
||||
from scripts.imap_sync import _parse_message
|
||||
|
||||
big_body = ("x" * 10_000).encode()
|
||||
|
|
@ -1037,7 +1037,7 @@ def test_parse_message_large_body_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"]) <= 4000
|
||||
assert len(result["body"]) == 10_000
|
||||
|
||||
|
||||
def test_parse_message_binary_attachment_no_crash():
|
||||
|
|
|
|||
288
tests/test_resume_optimizer.py
Normal file
288
tests/test_resume_optimizer.py
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# 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"] == ""
|
||||
|
|
@ -470,3 +470,14 @@ def test_llm_tasks_routed_to_scheduler(tmp_db):
|
|||
task_runner.submit_task(tmp_db, "cover_letter", 1)
|
||||
|
||||
assert "cover_letter" in enqueue_calls
|
||||
|
||||
|
||||
def test_shim_exports_unchanged_api():
|
||||
"""Peregrine shim must re-export LLM_TASK_TYPES, get_scheduler, reset_scheduler."""
|
||||
from scripts.task_scheduler import LLM_TASK_TYPES, get_scheduler, reset_scheduler
|
||||
assert "cover_letter" in LLM_TASK_TYPES
|
||||
assert "company_research" in LLM_TASK_TYPES
|
||||
assert "wizard_generate" in LLM_TASK_TYPES
|
||||
assert "resume_optimize" in LLM_TASK_TYPES
|
||||
assert callable(get_scheduler)
|
||||
assert callable(reset_scheduler)
|
||||
|
|
|
|||
165
web/public/peregrine.svg
Normal file
165
web/public/peregrine.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 106 KiB |
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Two-panel layout: job details | cover letter -->
|
||||
<!-- Two-panel layout: job details | cover letter + resume optimizer -->
|
||||
<div class="workspace__panels">
|
||||
|
||||
<!-- ── Left: Job details ──────────────────────────────────────── -->
|
||||
|
|
@ -98,7 +98,12 @@
|
|||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -143,6 +148,9 @@
|
|||
↺ Regenerate
|
||||
</button>
|
||||
|
||||
<!-- ── ATS Resume Optimizer ──────────────────────────────── -->
|
||||
<ResumeOptimizerPanel :job-id="props.jobId" />
|
||||
|
||||
<!-- ── Bottom action bar ──────────────────────────────────── -->
|
||||
<div class="workspace__actions">
|
||||
<button
|
||||
|
|
@ -178,6 +186,7 @@
|
|||
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 }>()
|
||||
|
||||
|
|
@ -610,6 +619,7 @@ 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 {
|
||||
|
|
|
|||
495
web/src/components/ResumeOptimizerPanel.vue
Normal file
495
web/src/components/ResumeOptimizerPanel.vue
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
<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>
|
||||
205
web/src/components/TaskIndicator.vue
Normal file
205
web/src/components/TaskIndicator.vue
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<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>
|
||||
101
web/src/stores/tasks.ts
Normal file
101
web/src/stores/tasks.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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 }
|
||||
})
|
||||
Loading…
Reference in a new issue