diff --git a/Dockerfile.cfcore b/Dockerfile.cfcore index 6387c2a..11f33f5 100644 --- a/Dockerfile.cfcore +++ b/Dockerfile.cfcore @@ -26,6 +26,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY circuitforge-core/ /circuitforge-core/ RUN pip install --no-cache-dir /circuitforge-core +# circuitforge-orch client — needed for LLMRouter cf_orch allocation. +# Optional: if the directory doesn't exist the COPY will fail at build time; keep +# cf-orch as a sibling of peregrine in the build context. +COPY circuitforge-orch/ /circuitforge-orch/ +RUN pip install --no-cache-dir /circuitforge-orch + COPY peregrine/requirements.txt . # Skip the cfcore line — already installed above from the local copy RUN grep -v 'circuitforge-core' requirements.txt | pip install --no-cache-dir -r /dev/stdin @@ -39,6 +45,13 @@ COPY peregrine/scrapers/ /app/scrapers/ COPY peregrine/ . +# Remove per-user config files that are gitignored but may exist locally. +# Defense-in-depth: the parent .dockerignore should already exclude these, +# but an explicit rm guarantees they never end up in the cloud image. +RUN rm -f config/user.yaml config/plain_text_resume.yaml config/notion.yaml \ + config/email.yaml config/tokens.yaml config/craigslist.yaml \ + config/adzuna.yaml .env + EXPOSE 8501 CMD ["streamlit", "run", "app/app.py", \ diff --git a/HANDOFF-xanderland.md b/HANDOFF-xanderland.md new file mode 100644 index 0000000..05bf91b --- /dev/null +++ b/HANDOFF-xanderland.md @@ -0,0 +1,153 @@ +# Peregrine → xanderland.tv Setup Handoff + +**Written from:** dev machine (CircuitForge dev env) +**Target:** xanderland.tv (beta tester, rootful Podman + systemd) +**Date:** 2026-02-27 + +--- + +## What we're doing + +Getting Peregrine running on the beta tester's server as a Podman container managed by systemd. He already runs SearXNG and other services in the same style — rootful Podman with `--net=host`, `--restart=unless-stopped`, registered as systemd units. + +The script `podman-standalone.sh` in the repo root handles the container setup. + +--- + +## Step 1 — Get the repo onto xanderland.tv + +From navi (or directly if you have a route): + +```bash +ssh xanderland.tv "sudo git clone /opt/peregrine" +``` + +Or if it's already there, just pull: + +```bash +ssh xanderland.tv "cd /opt/peregrine && sudo git pull" +``` + +--- + +## Step 2 — Verify /opt/peregrine looks right + +```bash +ssh xanderland.tv "ls /opt/peregrine" +``` + +Expect to see: `Dockerfile`, `compose.yml`, `manage.sh`, `podman-standalone.sh`, `config/`, `app/`, `scripts/`, etc. + +--- + +## Step 3 — Config + +```bash +ssh xanderland.tv +cd /opt/peregrine +sudo mkdir -p data +sudo cp config/llm.yaml.example config/llm.yaml +sudo cp config/notion.yaml.example config/notion.yaml # only if he wants Notion sync +``` + +Then edit `config/llm.yaml` and set `searxng_url` to his existing SearXNG instance +(default is `http://localhost:8888` — confirm his actual port). + +He won't need Anthropic/OpenAI keys to start — the setup wizard lets him pick local Ollama +or whatever he has running. + +--- + +## Step 4 — Fix DOCS_DIR in the script + +The script defaults `DOCS_DIR=/Library/Documents/JobSearch` which is the original user's path. +Update it to wherever his job search documents actually live, or a placeholder empty dir: + +```bash +sudo mkdir -p /opt/peregrine/docs # placeholder if he has no docs yet +``` + +Then edit the script: +```bash +sudo sed -i 's|DOCS_DIR=.*|DOCS_DIR=/opt/peregrine/docs|' /opt/peregrine/podman-standalone.sh +``` + +--- + +## Step 5 — Build the image + +```bash +ssh xanderland.tv "cd /opt/peregrine && sudo podman build -t localhost/peregrine:latest ." +``` + +Takes a few minutes on first run (downloads python:3.11-slim, installs deps). + +--- + +## Step 6 — Run the script + +```bash +ssh xanderland.tv "sudo bash /opt/peregrine/podman-standalone.sh" +``` + +This starts a single container (`peregrine`) with `--net=host` and `--restart=unless-stopped`. +SearXNG is NOT included — his existing instance is used. + +Verify it came up: +```bash +ssh xanderland.tv "sudo podman ps | grep peregrine" +ssh xanderland.tv "sudo podman logs peregrine" +``` + +Health check endpoint: `http://xanderland.tv:8501/_stcore/health` + +--- + +## Step 7 — Register as a systemd service + +```bash +ssh xanderland.tv +sudo podman generate systemd --new --name peregrine \ + | sudo tee /etc/systemd/system/peregrine.service +sudo systemctl daemon-reload +sudo systemctl enable --now peregrine +``` + +Confirm: +```bash +sudo systemctl status peregrine +``` + +--- + +## Step 8 — First-run wizard + +Open `http://xanderland.tv:8501` in a browser. + +The setup wizard (page 0) will gate the app until `config/user.yaml` is created. +He'll fill in his profile — name, resume, LLM backend preferences. This writes +`config/user.yaml` and unlocks the rest of the UI. + +--- + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| Container exits immediately | `sudo podman logs peregrine` — usually a missing config file | +| Port 8501 already in use | `sudo ss -tlnp \| grep 8501` — something else on that port | +| SearXNG not reachable | Confirm `searxng_url` in `config/llm.yaml` and that JSON format is enabled in SearXNG settings | +| Wizard loops / won't save | `config/` volume mount permissions — `sudo chown -R 1000:1000 /opt/peregrine/config` | + +--- + +## To update Peregrine later + +```bash +cd /opt/peregrine +sudo git pull +sudo podman build -t localhost/peregrine:latest . +sudo podman restart peregrine +``` + +No need to touch the systemd unit — it launches fresh via `--new` in the generate step. diff --git a/app/Home.py b/app/Home.py index 3b5e542..7f3f33e 100644 --- a/app/Home.py +++ b/app/Home.py @@ -14,23 +14,22 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from scripts.user_profile import UserProfile -_USER_YAML = Path(__file__).parent.parent / "config" / "user.yaml" -_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None -_name = _profile.name if _profile else "Job Seeker" - from scripts.db import init_db, get_job_counts, purge_jobs, purge_email_data, \ purge_non_remote, archive_jobs, kill_stuck_tasks, cancel_task, \ get_task_for_job, get_active_tasks, insert_job, get_existing_urls from scripts.task_runner import submit_task -from app.cloud_session import resolve_session, get_db_path - -_CONFIG_DIR = Path(__file__).parent.parent / "config" +from app.cloud_session import resolve_session, get_db_path, get_config_dir resolve_session("peregrine") init_db(get_db_path()) +_CONFIG_DIR = get_config_dir() +_USER_YAML = _CONFIG_DIR / "user.yaml" +_profile = UserProfile(_USER_YAML) if UserProfile.exists(_USER_YAML) else None +_name = _profile.name if _profile else "Job Seeker" + def _email_configured() -> bool: - _e = Path(__file__).parent.parent / "config" / "email.yaml" + _e = get_config_dir() / "email.yaml" if not _e.exists(): return False import yaml as _yaml @@ -38,7 +37,7 @@ def _email_configured() -> bool: return bool(_cfg.get("username") or _cfg.get("user") or _cfg.get("imap_host")) def _notion_configured() -> bool: - _n = Path(__file__).parent.parent / "config" / "notion.yaml" + _n = get_config_dir() / "notion.yaml" if not _n.exists(): return False import yaml as _yaml @@ -46,7 +45,7 @@ def _notion_configured() -> bool: return bool(_cfg.get("token")) def _keywords_configured() -> bool: - _k = Path(__file__).parent.parent / "config" / "resume_keywords.yaml" + _k = get_config_dir() / "resume_keywords.yaml" if not _k.exists(): return False import yaml as _yaml diff --git a/app/cloud_session.py b/app/cloud_session.py index 5e1b7b3..528cbab 100644 --- a/app/cloud_session.py +++ b/app/cloud_session.py @@ -203,8 +203,16 @@ def get_config_dir() -> Path: isolated and never shared across tenants. Local: repo-level config/ directory. """ - if CLOUD_MODE and st.session_state.get("db_path"): - return Path(st.session_state["db_path"]).parent / "config" + if CLOUD_MODE: + db_path = st.session_state.get("db_path") + if db_path: + return Path(db_path).parent / "config" + # Session not resolved yet (resolve_session() should have called st.stop() already). + # Return an isolated empty temp dir rather than the repo config, which may contain + # another user's data baked into the image. + _safe = Path("/tmp/peregrine-cloud-noconfig") + _safe.mkdir(exist_ok=True) + return _safe return Path(__file__).parent.parent / "config" diff --git a/app/static/peregrine_logo.png b/app/static/peregrine_logo.png new file mode 100644 index 0000000..2375c61 Binary files /dev/null and b/app/static/peregrine_logo.png differ diff --git a/app/static/peregrine_logo_circle.png b/app/static/peregrine_logo_circle.png new file mode 100644 index 0000000..c9f864b Binary files /dev/null and b/app/static/peregrine_logo_circle.png differ diff --git a/compose.cloud.yml b/compose.cloud.yml index ea3c23d..8944ba1 100644 --- a/compose.cloud.yml +++ b/compose.cloud.yml @@ -51,6 +51,8 @@ services: dockerfile: peregrine/Dockerfile.cfcore command: > bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601" + ports: + - "127.0.0.1:8601:8601" # localhost-only — Caddy + avocet imitate tab volumes: - /devl/menagerie-data:/devl/menagerie-data - ./config/llm.cloud.yaml:/app/config/llm.yaml:ro @@ -65,6 +67,7 @@ services: - HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN} - PYTHONUNBUFFERED=1 - FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-} + - CF_ORCH_URL=http://host.docker.internal:7700 extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped @@ -81,6 +84,9 @@ services: - api restart: unless-stopped + # cf-orch-agent: not needed in cloud — a host-native agent already runs on :7701 + # and is registered with the coordinator. app/api reach it via CF_ORCH_URL. + searxng: image: searxng/searxng:latest volumes: diff --git a/compose.yml b/compose.yml index cc82471..f701dfe 100644 --- a/compose.yml +++ b/compose.yml @@ -61,6 +61,7 @@ services: - OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-} - PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0} - PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-} + - CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700} - PYTHONUNBUFFERED=1 extra_hosts: - "host.docker.internal:host-gateway" @@ -129,6 +130,31 @@ services: profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] restart: unless-stopped + cf-orch-agent: + build: + context: .. + dockerfile: peregrine/Dockerfile.cfcore + command: ["/bin/sh", "/app/docker/cf-orch-agent/start.sh"] + ports: + - "${CF_ORCH_AGENT_PORT:-7701}:7701" + environment: + - CF_ORCH_COORDINATOR_URL=${CF_ORCH_COORDINATOR_URL:-http://host.docker.internal:7700} + - CF_ORCH_NODE_ID=${CF_ORCH_NODE_ID:-peregrine} + - CF_ORCH_AGENT_PORT=${CF_ORCH_AGENT_PORT:-7701} + - CF_ORCH_ADVERTISE_HOST=${CF_ORCH_ADVERTISE_HOST:-} + - PYTHONUNBUFFERED=1 + extra_hosts: + - "host.docker.internal:host-gateway" + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed] + restart: unless-stopped + finetune: build: context: . diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example new file mode 100644 index 0000000..8f80b18 --- /dev/null +++ b/config/label_tool.yaml.example @@ -0,0 +1,23 @@ +# config/label_tool.yaml — Multi-account IMAP config for the email label tool +# Copy to config/label_tool.yaml and fill in your credentials. +# This file is gitignored. + +accounts: + - name: "Gmail" + host: "imap.gmail.com" + port: 993 + username: "you@gmail.com" + password: "your-app-password" # Use an App Password, not your login password + folder: "INBOX" + days_back: 90 + + - name: "Outlook" + host: "outlook.office365.com" + port: 993 + username: "you@outlook.com" + password: "your-app-password" + folder: "INBOX" + days_back: 90 + +# Optional: limit emails fetched per account per run (0 = unlimited) +max_per_account: 500 diff --git a/config/llm.cloud.yaml b/config/llm.cloud.yaml index 62af14f..a0173f6 100644 --- a/config/llm.cloud.yaml +++ b/config/llm.cloud.yaml @@ -45,6 +45,11 @@ backends: model: __auto__ supports_images: false type: openai_compat + cf_orch: + service: vllm + model_candidates: + - Qwen2.5-3B-Instruct + ttl_s: 300 vllm_research: api_key: '' base_url: http://host.docker.internal:8000/v1 @@ -52,6 +57,11 @@ backends: model: __auto__ supports_images: false type: openai_compat + cf_orch: + service: vllm + model_candidates: + - Qwen2.5-3B-Instruct + ttl_s: 300 fallback_order: - vllm - ollama diff --git a/dev-api.py b/dev-api.py index 45b9b33..d9ae737 100644 --- a/dev-api.py +++ b/dev-api.py @@ -30,8 +30,10 @@ from fastapi import FastAPI, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -# Allow importing peregrine scripts for cover letter generation -PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") +# Allow importing peregrine scripts for cover letter generation. +# Resolved from __file__ so it works both in Docker (/app) and on the dev +# machine (/Library/Development/CircuitForge/peregrine) without hardcoding. +PEREGRINE_ROOT = Path(__file__).resolve().parent if str(PEREGRINE_ROOT) not in sys.path: sys.path.insert(0, str(PEREGRINE_ROOT)) @@ -125,6 +127,10 @@ async def cloud_session_middleware(request: Request, call_next): user_id = _resolve_cf_user_id(cookie_header) if user_id: user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db") + if user_db not in _migrated_db_paths: + from scripts.db_migrate import migrate_db + migrate_db(Path(user_db)) + _migrated_db_paths.add(user_db) token = _request_db.set(user_db) try: return await call_next(request) @@ -133,8 +139,15 @@ async def cloud_session_middleware(request: Request, call_next): return await call_next(request) +_migrated_db_paths: set[str] = set() + + def _get_db(): path = _request_db.get() or DB_PATH + if path not in _migrated_db_paths: + from scripts.db_migrate import migrate_db + migrate_db(Path(path)) + _migrated_db_paths.add(path) db = sqlite3.connect(path) db.row_factory = sqlite3.Row return db @@ -364,7 +377,7 @@ def generate_cover_letter(job_id: int): try: from scripts.task_runner import submit_task task_id, is_new = submit_task( - db_path=Path(DB_PATH), + db_path=Path(_request_db.get() or DB_PATH), task_type="cover_letter", job_id=job_id, ) @@ -415,7 +428,7 @@ def get_research_brief(job_id: int): def generate_research(job_id: int): try: from scripts.task_runner import submit_task - task_id, is_new = submit_task(db_path=Path(DB_PATH), task_type="company_research", job_id=job_id) + task_id, is_new = submit_task(db_path=Path(_request_db.get() or DB_PATH), task_type="company_research", job_id=job_id) return {"task_id": task_id, "is_new": is_new} except Exception as e: raise HTTPException(500, str(e)) @@ -443,7 +456,7 @@ def get_optimized_resume(job_id: int): """Return the current optimized resume and ATS gap report for a job.""" from scripts.db import get_optimized_resume as _get import json - result = _get(db_path=Path(DB_PATH), job_id=job_id) + result = _get(db_path=Path(_request_db.get() or DB_PATH), job_id=job_id) gap_report = result.get("ats_gap_report", "") try: gap_report_parsed = json.loads(gap_report) if gap_report else [] @@ -471,7 +484,7 @@ def generate_optimized_resume(job_id: int, body: ResumeOptimizeBody): from scripts.task_runner import submit_task params = json.dumps({"full_rewrite": body.full_rewrite}) task_id, is_new = submit_task( - db_path=Path(DB_PATH), + db_path=Path(_request_db.get() or DB_PATH), task_type="resume_optimize", job_id=job_id, params=params, @@ -497,6 +510,626 @@ def resume_optimizer_task_status(job_id: int): return {"status": row["status"], "stage": row["stage"], "message": row["error"]} +@app.get("/api/jobs/{job_id}/resume_optimizer/review") +def get_resume_review(job_id: int): + """Return the pending review draft for this job (populated when task is awaiting_review).""" + from scripts.db import get_resume_draft as _get_draft + draft = _get_draft(db_path=Path(_request_db.get() or DB_PATH), job_id=job_id) + if not draft: + return {"draft": None} + return {"draft": draft} + + +class GapFramingDecision(BaseModel): + skill: str + # "adjacent" — has related experience, inject bridging sentence into bullets + # "learning" — actively developing the skill, add structured note to skills list + # "skip" — no connection, omit entirely + mode: str = "skip" + context: str = "" # candidate's own words; required for adjacent/learning + + +class ResumeReviewBody(BaseModel): + # Per-section decisions. Keys are section names; values are section-type-specific. + # skills: {"approved_additions": [...]} + # summary: {"accepted": bool} + # experience: {"accepted_entries": [{"title": str, "company": str, "accepted": bool}]} + decisions: dict + # One entry per rejected skill, describing how to frame the gap honestly. + gap_framings: list[GapFramingDecision] = [] + + +@app.post("/api/jobs/{job_id}/resume_optimizer/review") +def preview_resume_review(job_id: int, body: ResumeReviewBody): + """Apply review decisions + gap framings and return the assembled resume for preview. + + Does NOT save yet — the user sees the full assembled resume and confirms + via POST /approve before anything is persisted. + + Flow: + 1. apply_review_decisions() — merges approved skills, summary, experience choices + 2. frame_skill_gaps() — injects adjacent/learning framing for rejected skills + 3. render_resume_text() — renders to plain text for the preview panel + Returns: {preview_text, preview_struct} — struct preserved for the approve step. + """ + import json as _json + from scripts.db import get_resume_draft as _get_draft + from scripts.resume_optimizer import ( + apply_review_decisions, frame_skill_gaps, render_resume_text, + ) + + db_path = Path(_request_db.get() or DB_PATH) + draft = _get_draft(db_path=db_path, job_id=job_id) + if not draft: + raise HTTPException(404, "No pending review draft for this job") + + # Step 1: apply section-level decisions + struct = apply_review_decisions(draft, body.decisions) + + # Step 2: inject gap framing for rejected skills (adjacent / learning) + framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")] + if framings: + db_path_obj = Path(_request_db.get() or DB_PATH) + job_row = _get_db().execute( + "SELECT title, company FROM jobs WHERE id=?", (job_id,) + ).fetchone() + _get_db().close() + job = {"title": job_row[0], "company": job_row[1]} if job_row else {} + + from scripts.user_profile import UserProfile + from app.cloud_session import get_config_dir + _user_yaml = get_config_dir() / "user.yaml" + candidate_voice = UserProfile(_user_yaml).candidate_voice if UserProfile.exists(_user_yaml) else "" + + struct = frame_skill_gaps(struct, framings, job, candidate_voice) + + preview_text = render_resume_text(struct) + return {"preview_text": preview_text, "preview_struct": struct} + + +@app.post("/api/jobs/{job_id}/resume_optimizer/approve") +def approve_resume(job_id: int, body: dict): + """Save the user-approved assembled resume struct and mark the task complete. + + Expects body: {"preview_struct": {...}} — the struct returned by /review. + Saves both the rendered plain text and the struct (for YAML export). + """ + import json as _json + from scripts.db import finalize_resume as _finalize + + db_path = Path(_request_db.get() or DB_PATH) + struct = body.get("preview_struct") + if not struct: + raise HTTPException(400, "preview_struct is required") + + from scripts.resume_optimizer import render_resume_text + final_text = render_resume_text(struct) + + # Persist plain text + struct (struct enables YAML export later) + _finalize(db_path=db_path, job_id=job_id, final_text=final_text) + + # Store struct alongside the text so YAML round-trip is possible + db = _get_db() + db.execute( + "UPDATE jobs SET resume_final_struct=? WHERE id=?", + (_json.dumps(struct), job_id), + ) + db.execute( + "UPDATE background_tasks SET status='completed', updated_at=datetime('now') " + "WHERE task_type='resume_optimize' AND job_id=? AND status='awaiting_review'", + (job_id,), + ) + db.commit() + db.close() + + saved_resume_id: int | None = None + if body.get("save_to_library"): + from scripts.db import create_resume as _create_r + import json as _json2 + resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}" + saved = _create_r( + db_path, + name=resume_name, + text=final_text, + source="optimizer", + job_id=job_id, + struct_json=_json.dumps(struct) if struct else None, + ) + saved_resume_id = saved["id"] + + return {"optimized_resume": final_text, "saved_resume_id": saved_resume_id} + + +def _get_final_struct(job_id: int) -> dict: + """Return the approved resume struct for a job, or raise 404.""" + import json as _json + db = _get_db() + row = db.execute( + "SELECT resume_final_struct FROM jobs WHERE id=?", (job_id,) + ).fetchone() + db.close() + if not row or not row[0]: + raise HTTPException(404, "No approved resume struct for this job — approve first") + return _json.loads(row[0]) + + +@app.get("/api/jobs/{job_id}/resume_optimizer/export-pdf") +def export_resume_pdf(job_id: int): + """Generate a PDF from the approved resume struct and return it as a download.""" + import tempfile + from scripts.resume_optimizer import export_pdf + from fastapi.responses import FileResponse + + struct = _get_final_struct(job_id) + tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + tmp.close() + export_pdf(struct, tmp.name) + + return FileResponse( + tmp.name, + media_type="application/pdf", + filename=f"resume-optimized-job-{job_id}.pdf", + ) + + +@app.get("/api/jobs/{job_id}/resume_optimizer/export-yaml") +def export_resume_yaml(job_id: int): + """Return the approved resume struct as a downloadable YAML file.""" + import yaml + from fastapi.responses import Response + + struct = _get_final_struct(job_id) + yaml_text = yaml.dump(struct, default_flow_style=False, allow_unicode=True) + + return Response( + content=yaml_text, + media_type="application/x-yaml", + headers={"Content-Disposition": f"attachment; filename=resume-optimized-job-{job_id}.yaml"}, + ) + + +@app.get("/api/jobs/{job_id}/resume_optimizer/history") +def get_resume_history(job_id: int): + """Return the archive of past finalized resume versions (newest first).""" + from scripts.db import get_resume_archive as _get_archive + archive = _get_archive(db_path=Path(_request_db.get() or DB_PATH), job_id=job_id) + return {"history": archive} + + +# ── Resume library endpoints ─────────────────────────────────────────────────── + +@app.get("/api/resumes") +def list_resumes_endpoint(): + from scripts.db import list_resumes as _list + return {"resumes": _list(Path(_request_db.get() or DB_PATH))} + + +@app.post("/api/resumes") +def create_resume_endpoint(body: dict): + from scripts.db import create_resume as _create + name = (body.get("name") or "").strip() + text = (body.get("text") or "").strip() + if not name or not text: + raise HTTPException(400, "name and text are required") + return _create( + Path(_request_db.get() or DB_PATH), + name=name, text=text, + source=body.get("source", "manual"), + job_id=body.get("job_id"), + struct_json=body.get("struct_json"), + ) + + +@app.post("/api/resumes/import") +async def import_resume_endpoint(file: UploadFile, name: str = ""): + import os, tempfile, json as _json + from scripts.db import create_resume as _create + db_path = Path(_request_db.get() or DB_PATH) + content = await file.read() + MAX_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB + if len(content) > MAX_IMPORT_BYTES: + raise HTTPException(413, "File too large — 5 MB maximum") + filename = file.filename or "" + ext = Path(filename).suffix.lower() + struct_json: str | None = None + + if ext in (".txt", ".md"): + text = content.decode("utf-8", errors="replace") + + elif ext in (".pdf", ".docx", ".odt"): + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp.write(content) + tmp_path = tmp.name + try: + if ext == ".pdf": + import pdfplumber + with pdfplumber.open(tmp_path) as pdf: + text = "\n".join(p.extract_text() or "" for p in pdf.pages) + elif ext == ".docx": + from docx import Document + doc = Document(tmp_path) + text = "\n".join(p.text for p in doc.paragraphs) + else: + import zipfile + from xml.etree import ElementTree as ET + with zipfile.ZipFile(tmp_path) as z: + xml = z.read("content.xml") + ET_root = ET.fromstring(xml) + text = "\n".join( + el.text or "" + for el in ET_root.iter( + "{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p" + ) + ) + finally: + os.unlink(tmp_path) + + elif ext in (".yaml", ".yml"): + import yaml as _yaml + from scripts.task_runner import _normalize_aihawk_resume + raw = _yaml.safe_load(content.decode("utf-8", errors="replace")) or {} + struct = _normalize_aihawk_resume(raw) + struct_json = _json.dumps(struct) + lines = [struct.get("career_summary", "")] + for exp in struct.get("experience", []): + lines.append(f"{exp.get('title', '')} at {exp.get('company', '')}") + lines.extend(f"• {b}" for b in exp.get("bullets", [])) + text = "\n".join(lines) + if not text.strip(): + raise HTTPException(400, "YAML file contains no readable content") + + else: + raise HTTPException( + 400, + f"Unsupported file type: {ext}. Accepted: .txt .md .pdf .docx .odt .yaml .yml", + ) + + resume_name = name.strip() or Path(filename).stem or "Imported Resume" + return _create(db_path, name=resume_name, text=text.strip(), source="import", struct_json=struct_json) + + +@app.get("/api/resumes/{resume_id}") +def get_resume_endpoint(resume_id: int): + from scripts.db import get_resume as _get + r = _get(Path(_request_db.get() or DB_PATH), resume_id) + if not r: + raise HTTPException(404, "Resume not found") + return r + + +@app.patch("/api/resumes/{resume_id}") +def update_resume_endpoint(resume_id: int, body: dict): + from scripts.db import get_resume as _get, update_resume as _update + db_path = Path(_request_db.get() or DB_PATH) + if not _get(db_path, resume_id): + raise HTTPException(404, "Resume not found") + return _update(db_path, resume_id, name=body.get("name"), text=body.get("text")) + + +@app.delete("/api/resumes/{resume_id}") +def delete_resume_endpoint(resume_id: int): + from scripts.db import get_resume as _get, list_resumes as _list, delete_resume as _delete + db_path = Path(_request_db.get() or DB_PATH) + r = _get(db_path, resume_id) + if not r: + raise HTTPException(404, "Resume not found") + if len(_list(db_path)) == 1: + raise HTTPException(409, "Cannot delete the only resume") + if r["is_default"]: + raise HTTPException(409, "Cannot delete the default resume — set a new default first") + _delete(db_path, resume_id) + return {"ok": True} + + +@app.post("/api/resumes/{resume_id}/set-default") +def set_default_resume_endpoint(resume_id: int): + import yaml as _yaml + from scripts.db import get_resume as _get, set_default_resume as _set_default + db_path = Path(_request_db.get() or DB_PATH) + if not _get(db_path, resume_id): + raise HTTPException(404, "Resume not found") + _set_default(db_path, resume_id) + _user_yaml = db_path.parent / "config" / "user.yaml" + if _user_yaml.exists(): + profile = _yaml.safe_load(_user_yaml.read_text(encoding="utf-8")) or {} + profile["default_resume_id"] = resume_id + _user_yaml.write_text(_yaml.dump(profile, default_flow_style=False, allow_unicode=True)) + return {"ok": True} + + +# ── Per-job resume endpoints ─────────────────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/resume") +def get_job_resume_endpoint(job_id: int): + from scripts.db import get_job_resume as _get + r = _get(Path(_request_db.get() or DB_PATH), job_id) + if not r: + raise HTTPException(404, "No resume configured — add one in Resume Manager") + return r + + +@app.patch("/api/jobs/{job_id}/resume") +def set_job_resume_endpoint(job_id: int, body: dict): + from scripts.db import get_resume as _get_r, set_job_resume as _set, get_job_resume as _get_job + db_path = Path(_request_db.get() or DB_PATH) + resume_id = body.get("resume_id") + if not resume_id or not _get_r(db_path, resume_id): + raise HTTPException(404, "Resume not found") + _set(db_path, job_id=job_id, resume_id=resume_id) + return _get_job(db_path, job_id) + + +# ── GET /api/imitate/samples ────────────────────────────────────────────────── +# Avocet "Imitate" tab uses this to build the EXACT prompt Peregrine sends to +# its LLM — including user voice, mission context, style examples, and full job +# context. Avocet then routes these prompts through different local models to +# compare generation quality against the real Peregrine pipeline. + +def _imitate_load_profile(): + """Load UserProfile from config/user.yaml, or None if missing.""" + try: + from scripts.user_profile import UserProfile + _yaml = PEREGRINE_ROOT / "config" / "user.yaml" + return UserProfile(_yaml) if UserProfile.exists(_yaml) else None + except Exception: + return None + + +def _imitate_cover_letter(db, profile, limit: int) -> dict: + from scripts.generate_cover_letter import ( + build_prompt, _build_system_context, + load_corpus, find_similar_letters, detect_mission_alignment, + ) + rows = db.execute( + "SELECT id, title, company, description, cover_letter, status FROM jobs " + "WHERE description IS NOT NULL AND description != '' " + " AND status IN ('applied','phone_screen','interviewing','offer','hired') " + "ORDER BY applied_at DESC NULLS LAST LIMIT ?", + (limit,), + ).fetchall() + + system_ctx = _build_system_context(profile) + try: + corpus = load_corpus() + except Exception: + corpus = [] + + samples = [] + for r in rows: + desc = r["description"] or "" + examples = find_similar_letters(desc, corpus) if corpus else [] + mission_hint = detect_mission_alignment(r["company"], desc) + prompt = build_prompt( + title=r["title"], + company=r["company"], + description=desc, + examples=examples, + mission_hint=mission_hint, + system_context=system_ctx, + candidate_name=profile.name if profile else None, + ) + samples.append({ + "id": r["id"], + "job_title": r["title"], + "company": r["company"], + "status": r["status"], + "system_prompt": system_ctx, + "input_text": prompt, + "output_text": r["cover_letter"] or "", + }) + return {"samples": samples, "total": len(samples), "type": "cover_letter"} + + +def _imitate_company_research(db, profile, limit: int) -> dict: + rows = db.execute( + "SELECT j.id, j.title, j.company, j.description, j.status, " + " cr.raw_output, cr.company_brief " + "FROM jobs j LEFT JOIN company_research cr ON cr.job_id = j.id " + "WHERE j.description IS NOT NULL AND j.description != '' " + " AND j.status IN ('phone_screen','interviewing','offer','hired') " + "ORDER BY j.phone_screen_at DESC NULLS LAST LIMIT ?", + (limit,), + ).fetchall() + + name = profile.name if profile else "the candidate" + career_summary = profile.career_summary if profile else "" + + # Load plain-text resume for context + resume_ctx = "" + try: + import yaml as _yaml + _rpath = PEREGRINE_ROOT / "config" / "plain_text_resume.yaml" + if _rpath.exists(): + _rd = _yaml.safe_load(_rpath.read_text(encoding="utf-8")) or {} + parts = [] + for section in ("experience", "skills", "education", "summary"): + val = _rd.get(section) + if val: + parts.append(f"### {section.title()}\n{val if isinstance(val, str) else str(val)}") + resume_ctx = "\n\n".join(parts)[:2000] + except Exception: + pass + + samples = [] + for r in rows: + jd = (r["description"] or "")[:1500].strip() + resume_block = f"\n## Candidate Background\n{resume_ctx}" if resume_ctx else "" + career_block = f"Candidate background: {career_summary}\n\n" if career_summary else "" + prompt = ( + f"You are preparing {name} for a job interview.\n" + f"{career_block}" + f"Role: **{r['title']}** at **{r['company']}**\n\n" + f"## Job Description\n{jd}" + f"{resume_block}\n\n" + f"---\n\n" + f"Produce a structured research brief with exactly these markdown section headers:\n\n" + f"## Company Overview\n" + f"What {r['company']} does, core product/service, business model, size/stage, market positioning.\n\n" + f"## Leadership & Culture\n" + f"CEO background and leadership style, mission/values statements, Glassdoor themes.\n\n" + f"## Tech Stack & Product\n" + f"Technologies, platforms, and product direction relevant to the {r['title']} role.\n\n" + f"## Funding & Market Position\n" + f"Funding stage, key investors, recent rounds, competitor landscape.\n\n" + f"## Recent Developments\n" + f"News, launches, acquisitions, or press from the past 12-18 months.\n\n" + f"## Red Flags & Watch-outs\n" + f"Culture issues, layoffs, financial stress, or Glassdoor concerns worth knowing. " + f"If nothing notable, write 'No significant red flags identified.'\n\n" + f"## Talking Points for {name}\n" + f"Five specific talking points for the phone screen. Each must reference a concrete " + f"experience and connect to a specific JD signal. 1-2 sentences, ready to speak aloud. " + f"Never give generic advice.\n\n" + f"---\n" + f"⚠️ This brief uses LLM training knowledge only (no live web data). " + f"Verify key facts before the call." + ) + samples.append({ + "id": r["id"], + "job_title": r["title"], + "company": r["company"], + "status": r["status"], + "system_prompt": "", + "input_text": prompt, + "output_text": r["raw_output"] or r["company_brief"] or "", + }) + return {"samples": samples, "total": len(samples), "type": "company_research"} + + +def _imitate_interview_prep(db, profile, limit: int) -> dict: + rows = db.execute( + "SELECT j.id, j.title, j.company, j.status, " + " cr.talking_points, cr.company_brief " + "FROM jobs j LEFT JOIN company_research cr ON cr.job_id = j.id " + "WHERE j.status IN ('phone_screen','interviewing','offer','hired') " + "ORDER BY j.phone_screen_at DESC NULLS LAST LIMIT ?", + (limit,), + ).fetchall() + + name = profile.name if profile else "the candidate" + samples = [] + for r in rows: + system_prompt = ( + f"You are a recruiter at {r['company']} conducting a phone screen for the " + f"{r['title']} role. Ask one question at a time. After {name} answers, give " + f"brief feedback (1-2 sentences), then ask your next question. Be professional but warm." + ) + ctx_parts = [] + if r["talking_points"]: + ctx_parts.append(f"[Candidate talking points]\n{r['talking_points']}") + if r["company_brief"]: + ctx_parts.append(f"[Company context]\n{r['company_brief'][:500]}") + ctx_block = ("\n\n".join(ctx_parts) + "\n\n") if ctx_parts else "" + input_text = ( + f"{ctx_block}" + f"Start the mock phone screen for the {r['title']} role at {r['company']}. " + f"Ask your first question. Keep it realistic and concise." + ) + samples.append({ + "id": r["id"], + "job_title": r["title"], + "company": r["company"], + "status": r["status"], + "system_prompt": system_prompt, + "input_text": input_text, + "output_text": "", + }) + return {"samples": samples, "total": len(samples), "type": "interview_prep"} + + +def _imitate_ats_resume(db, profile, limit: int) -> dict: + rows = db.execute( + "SELECT id, title, company, description, ats_gap_report, status FROM jobs " + "WHERE description IS NOT NULL AND description != '' " + " AND status IN ('applied','phone_screen','interviewing','offer','hired') " + "ORDER BY applied_at DESC NULLS LAST LIMIT ?", + (limit,), + ).fetchall() + + candidate_voice = profile.candidate_voice if profile else "" + voice_block = ( + f"\nCandidate voice/style: {candidate_voice}\n" + "Preserve this tone in any phrasing suggestions." + ) if candidate_voice else "" + + resume_text = "" + try: + _rpath = PEREGRINE_ROOT / "config" / "plain_text_resume.yaml" + if _rpath.exists(): + resume_text = _rpath.read_text(encoding="utf-8")[:3000] + except Exception: + pass + resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else "" + + samples = [] + for r in rows: + desc = (r["description"] or "")[:1500].strip() + prompt = ( + f"You are an ATS (applicant tracking system) keyword optimization expert.\n\n" + f"Analyze the resume below against this job description. Identify keyword gaps " + f"(terms in the JD missing or underrepresented in the resume) and for each gap " + f"specify which section it belongs in (summary, skills, or experience).\n\n" + f"Job: {r['title']} at {r['company']}\n\n" + f"## Job Description\n{desc}" + f"{resume_block}" + f"{voice_block}\n\n" + f"Return a JSON array of gap objects, each with keys:\n" + f' "term": the missing keyword\n' + f' "section": summary | skills | experience\n' + f' "priority": high | medium | low\n' + f' "rationale": one sentence explaining the gap\n\n' + f"Order by priority descending. Return ONLY the JSON array." + ) + samples.append({ + "id": r["id"], + "job_title": r["title"], + "company": r["company"], + "status": r["status"], + "system_prompt": "", + "input_text": prompt, + "output_text": r["ats_gap_report"] or "", + }) + return {"samples": samples, "total": len(samples), "type": "ats_resume"} + + +@app.get("/api/imitate/samples") +def imitate_samples(type: str = "cover_letter", limit: int = 5): + """Return the assembled generation prompt Peregrine would send to its LLM. + + Each sample has: + system_prompt the system context (candidate voice, career summary) + input_text the full assembled user prompt (JD + resume + mission + style examples) + output_text existing generated output for comparison (may be empty) + + Avocet sends system_prompt + input_text through different local models to + compare which best replicates Peregrine's generation quality. + + type: cover_letter | company_research | interview_prep | ats_resume + """ + limit = max(1, min(limit, 20)) + db = _get_db() + profile = _imitate_load_profile() + + try: + if type == "cover_letter": + result = _imitate_cover_letter(db, profile, limit) + elif type == "company_research": + result = _imitate_company_research(db, profile, limit) + elif type == "interview_prep": + result = _imitate_interview_prep(db, profile, limit) + elif type == "ats_resume": + result = _imitate_ats_resume(db, profile, limit) + else: + raise HTTPException( + 400, + f"Unknown type '{type}'. Use: cover_letter, company_research, interview_prep, ats_resume", + ) + finally: + db.close() + + return result + + @app.get("/api/jobs/{job_id}/contacts") def get_job_contacts(job_id: int): db = _get_db() @@ -509,6 +1142,60 @@ def get_job_contacts(job_id: int): return [dict(r) for r in rows] +class LogContactBody(BaseModel): + direction: str + subject: str + from_addr: Optional[str] = None + body: Optional[str] = None + received_at: Optional[str] = None + + +@app.post("/api/jobs/{job_id}/contacts") +def log_contact(job_id: int, payload: LogContactBody): + """Log a manually entered inbound or outbound email contact for a job.""" + db = _get_db() + received_at = payload.received_at or datetime.utcnow().isoformat() + db.execute( + "INSERT INTO job_contacts (job_id, direction, subject, from_addr, body, received_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (job_id, payload.direction, payload.subject, payload.from_addr, payload.body, received_at), + ) + db.commit() + db.close() + return {"ok": True} + + +class InterviewDateBody(BaseModel): + interview_date: Optional[str] = None + + +@app.patch("/api/jobs/{job_id}/interview_date") +def set_interview_date(job_id: int, payload: InterviewDateBody): + """Set or clear the interview date for a job without changing its pipeline status.""" + interview_date = payload.interview_date # ISO string or null + db = _get_db() + db.execute("UPDATE jobs SET interview_date = ? WHERE id = ?", (interview_date, job_id)) + db.commit() + db.close() + return {"ok": True} + + +@app.post("/api/jobs/{job_id}/calendar_push") +def calendar_push(job_id: int): + """Push the job's interview event to the first configured calendar integration.""" + from scripts.calendar_push import push_interview_event + db_path = Path(_request_db.get() or DB_PATH) + cfg_dir = db_path.parent / "config" + result = push_interview_event( + db_path=db_path, + job_id=job_id, + config_dir=cfg_dir, + ) + if not result.get("ok"): + raise HTTPException(400, result.get("error", "Calendar push failed")) + return result + + # ── Survey endpoints ───────────────────────────────────────────────────────── # Module-level imports so tests can patch dev_api.LLMRouter etc. @@ -622,7 +1309,7 @@ def save_survey_response(job_id: int, body: SurveySaveBody): except Exception: raise HTTPException(400, "Invalid image data") row_id = insert_survey_response( - db_path=Path(DB_PATH), + db_path=Path(_request_db.get() or DB_PATH), job_id=job_id, survey_name=body.survey_name, received_at=received_at, @@ -638,7 +1325,7 @@ def save_survey_response(job_id: int, body: SurveySaveBody): @app.get("/api/jobs/{job_id}/survey/responses") def get_survey_history(job_id: int): - return get_survey_responses(db_path=Path(DB_PATH), job_id=job_id) + return get_survey_responses(db_path=Path(_request_db.get() or DB_PATH), job_id=job_id) # ── GET /api/jobs/:id/cover_letter/pdf ─────────────────────────────────────── @@ -911,6 +1598,14 @@ def list_active_tasks(): return get_active_tasks(_db_path()) +@app.get("/api/tasks/active") +def list_active_tasks_envelope(): + """Envelope wrapper for the Vue task indicator poll — returns {count, tasks}.""" + from scripts.db import get_active_tasks + tasks = get_active_tasks(_db_path()) + return {"count": len(tasks), "tasks": tasks} + + @app.delete("/api/tasks/{task_id}") def cancel_task_by_id(task_id: int): from scripts.db import cancel_task @@ -1345,6 +2040,46 @@ def move_job(job_id: int, body: MoveBody): return {"ok": True} +_HEIMDALL_URL = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech") +_HEIMDALL_ADMIN_TOKEN = os.environ.get("HEIMDALL_ADMIN_TOKEN", "") + + +def _resolve_cloud_tier() -> str: + """Resolve the current user's tier from Heimdall for cloud API responses. + + Extracts the user_id from the per-request DB path set by cloud_session_middleware + (format: //peregrine/staging.db), then calls Heimdall + /admin/cloud/resolve. Returns "free" on any error so the app degrades gracefully. + """ + if not _HEIMDALL_ADMIN_TOKEN: + _log.warning("HEIMDALL_ADMIN_TOKEN not set — defaulting API tier to free") + return "free" + db_path = _request_db.get() + if not db_path: + return "free" + # Extract user_id: .../menagerie-data//peregrine/staging.db + try: + user_id = Path(db_path).parts[-3] + except IndexError: + _log.warning("_resolve_cloud_tier: unexpected db_path format: %s", db_path) + return "free" + try: + resp = requests.post( + f"{_HEIMDALL_URL}/admin/cloud/resolve", + json={"user_id": user_id, "product": "peregrine"}, + headers={"Authorization": f"Bearer {_HEIMDALL_ADMIN_TOKEN}"}, + timeout=5, + ) + if resp.status_code == 200: + return resp.json().get("tier", "free") + if resp.status_code == 404: + return "free" + _log.warning("Heimdall resolve returned %s for user %s", resp.status_code, user_id) + except Exception as exc: + _log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc) + return "free" + + # ── GET /api/config/app ─────────────────────────────────────────────────────── @app.get("/api/config/app") @@ -1353,10 +2088,13 @@ def get_app_config(): profile = os.environ.get("INFERENCE_PROFILE", "cpu") valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"} valid_tiers = {"free", "paid", "premium", "ultra"} - raw_tier = os.environ.get("APP_TIER", "free") - # Cloud users always bypass the wizard — they configure through Settings + # Cloud: resolve tier from Heimdall (APP_TIER env is single-tenant only). is_cloud = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true") + if is_cloud: + raw_tier = _resolve_cloud_tier() + else: + raw_tier = os.environ.get("APP_TIER", "free") if is_cloud: wizard_complete = True else: @@ -1798,6 +2536,15 @@ class SearchPrefsPayload(BaseModel): blocklist_industries: List[str] = [] blocklist_locations: List[str] = [] +def _get_valid_jobspy_boards() -> set[str]: + """Return the set of board names supported by the installed JobSpy version.""" + try: + from jobspy import Site + return {s.value for s in Site} + except Exception: + return {"linkedin", "indeed", "zip_recruiter", "glassdoor", "google"} + + @app.get("/api/settings/search") def get_search_prefs(): try: @@ -1806,7 +2553,34 @@ def get_search_prefs(): return {} with open(p) as f: data = yaml.safe_load(f) or {} - return data.get("default", {}) + + # Handle both old `default: {...}` format and new `profiles: [...]` format. + from scripts.discover import _normalize_profiles + normalized = _normalize_profiles(data) + profiles = normalized.get("profiles", []) + profile = next((pr for pr in profiles if pr.get("name") == "default"), None) + if profile is None: + # Fall back to reading the raw default key (covers edge cases) + profile = data.get("default", {}) + + # Annotate job_boards with a `supported` flag so the UI can distinguish + # boards that produce real results from ones that are not yet implemented. + valid = _get_valid_jobspy_boards() + job_boards = profile.get("job_boards", []) + if job_boards: + profile["job_boards"] = [ + {**b, "supported": b.get("name", "") in valid} + for b in job_boards + ] + # Also expose boards list (canonical format) with the same annotation + boards = profile.get("boards", []) + if boards and not job_boards: + profile["job_boards"] = [ + {"name": b, "enabled": True, "supported": b in valid} + for b in boards + ] + + return profile except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -1830,6 +2604,66 @@ class SearchSuggestPayload(BaseModel): type: str # "titles" | "locations" | "exclude_keywords" current: List[str] = [] + +class ResumeTagSuggestPayload(BaseModel): + type: str # "skills" | "domains" | "keywords" + current: List[str] = [] + + +@app.post("/api/settings/resume/suggest-tags") +def suggest_resume_tags(payload: ResumeTagSuggestPayload): + """LLM-generate suggestions for skills, domains, or keywords based on the resume profile.""" + context = _resume_context_snippet() + current_str = ", ".join(payload.current) if payload.current else "none" + + if payload.type == "skills": + prompt = ( + "You are a career advisor helping a job seeker build their skills list.\n\n" + + (f"Candidate background:\n{context}\n\n" if context else "") + + f"Skills they already have listed: {current_str}\n\n" + "Suggest 8 additional skills, tools, or technologies they likely have based on their " + "background but haven't listed yet. Focus on concrete, ATS-friendly terms. " + "Return only a JSON array of strings, no other text. " + "Example: [\"HubSpot\", \"Tableau\", \"SQL\"]" + ) + elif payload.type == "domains": + prompt = ( + "You are a career advisor helping a job seeker define the industry domains they work in.\n\n" + + (f"Candidate background:\n{context}\n\n" if context else "") + + f"Domains they already have listed: {current_str}\n\n" + "Suggest 6 additional industry domains, verticals, or market segments relevant to their " + "background that they haven't listed. Think: 'B2B SaaS', 'enterprise software', " + "'financial services', etc. " + "Return only a JSON array of strings, no other text." + ) + elif payload.type == "keywords": + prompt = ( + "You are a resume ATS (applicant tracking system) specialist helping a job seeker " + "identify important keywords recruiters search for.\n\n" + + (f"Candidate background:\n{context}\n\n" if context else "") + + f"Keywords they already have listed: {current_str}\n\n" + "Suggest 10 additional ATS keywords, phrases, or buzzwords that recruiters in their " + "field commonly search for — metrics, methodologies, frameworks, or role-specific " + "terminology they may have missed. " + "Return only a JSON array of strings, no other text." + ) + else: + raise HTTPException(400, f"Unknown suggestion type: {payload.type}") + + try: + import json as _json + from scripts.llm_router import LLMRouter + raw = LLMRouter().complete(prompt) + start = raw.find("[") + end = raw.rfind("]") + 1 + if start == -1 or end == 0: + return {"suggestions": []} + suggestions = _json.loads(raw[start:end]) + return {"suggestions": [str(s) for s in suggestions if s]} + except Exception as e: + raise HTTPException(500, f"LLM generation failed: {e}") + + @app.post("/api/settings/search/suggest") def suggest_search(payload: SearchSuggestPayload): """LLM-generate suggestions for job titles, locations, or exclude keywords.""" @@ -1931,6 +2765,73 @@ def byok_ack(payload: ByokAckPayload): raise HTTPException(status_code=500, detail=str(e)) +# ── Settings: per-user cover-letter model ──────────────────────────────────── + +@app.get("/api/settings/llm/cover-letter-model") +def get_cover_letter_model(): + """Return the user's custom cover letter model (from per-user llm.yaml if set).""" + cfg_path = _config_dir() / "llm.yaml" + if cfg_path.exists(): + with open(cfg_path) as f: + data = yaml.safe_load(f) or {} + # Convention: the first backend in fallback_order that targets cover letters + # is stored under backends.cover_letter.model + model = (data.get("backends", {}).get("cover_letter") or {}).get("model", "") + return {"model": model} + return {"model": ""} + + +class CoverLetterModelPayload(BaseModel): + model: str + + +@app.put("/api/settings/llm/cover-letter-model") +def set_cover_letter_model(payload: CoverLetterModelPayload): + """Write the custom cover letter model into the per-user llm.yaml.""" + cfg_path = _config_dir() / "llm.yaml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + data: dict = {} + if cfg_path.exists(): + with open(cfg_path) as f: + data = yaml.safe_load(f) or {} + backends = data.setdefault("backends", {}) + if payload.model: + backends["cover_letter"] = { + "type": "openai_compat", + "enabled": True, + "base_url": "http://localhost:11434/v1", + "model": payload.model, + "api_key": "any", + "supports_images": False, + } + order = data.setdefault("fallback_order", []) + if "cover_letter" not in order: + order.insert(0, "cover_letter") + else: + # Clear custom model — remove the backend and drop from fallback order + backends.pop("cover_letter", None) + data["fallback_order"] = [b for b in data.get("fallback_order", []) if b != "cover_letter"] + with open(cfg_path, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + + +@app.get("/api/settings/llm/ollama-models") +def get_ollama_models(): + """Return available Ollama models by querying the local Ollama API.""" + try: + ollama_host = os.environ.get("OLLAMA_HOST", "http://localhost:11434") + if not ollama_host.startswith("http"): + ollama_host = f"http://{ollama_host}" + resp = requests.get(f"{ollama_host.rstrip('/')}/api/tags", timeout=3) + if resp.status_code == 200: + models = [m["name"] for m in resp.json().get("models", [])] + return {"models": models} + except Exception: + pass + return {"models": []} + + # ── Settings: System — Services ─────────────────────────────────────────────── SERVICES_REGISTRY = [ @@ -2528,7 +3429,7 @@ def export_classifier(): # State is persisted to user.yaml on every step so the wizard can resume # after a browser refresh or crash (mirrors the Streamlit wizard behaviour). -_WIZARD_PROFILES = ("remote", "cpu", "single-gpu", "dual-gpu") +_WIZARD_PROFILES = ("remote", "cpu", "single-gpu", "dual-gpu", "cf-orch") _WIZARD_TIERS = ("free", "paid", "premium") @@ -2678,7 +3579,8 @@ def wizard_save_step(payload: WizardStepPayload): updates["services"] = data["services"] elif step == 6: - # Persist search preferences to search_profiles.yaml + # Persist search preferences to search_profiles.yaml in canonical format: + # profiles: [{name, titles, locations, boards, ...}] titles = data.get("titles", []) locations = data.get("locations", []) search_path = _search_prefs_path() @@ -2686,10 +3588,20 @@ def wizard_save_step(payload: WizardStepPayload): if search_path.exists(): with open(search_path) as f: existing_search = yaml.safe_load(f) or {} - default_profile = existing_search.get("default", {}) - default_profile["job_titles"] = titles - default_profile["location"] = locations - existing_search["default"] = default_profile + + # Normalize legacy wizard format on read so we can update in place + from scripts.discover import _normalize_profiles as _norm + existing_search = _norm(existing_search) + + # Find or create the "default" profile entry + profiles_list = existing_search.get("profiles", []) + default_profile = next((p for p in profiles_list if p.get("name") == "default"), None) + if default_profile is None: + default_profile = {"name": "default"} + profiles_list.append(default_profile) + default_profile["titles"] = titles + default_profile["locations"] = locations + existing_search["profiles"] = profiles_list search_path.parent.mkdir(parents=True, exist_ok=True) with open(search_path, "w") as f: yaml.dump(existing_search, f, allow_unicode=True, default_flow_style=False) @@ -2705,15 +3617,45 @@ def wizard_save_step(payload: WizardStepPayload): return {"ok": True, "step": step} +def _fetch_cforch_nodes() -> list[dict]: + """Query cf-orch coordinator for live node + GPU data. Returns [] on any error.""" + url = os.environ.get("CF_ORCH_URL", "").rstrip("/") + if not url: + return [] + try: + import urllib.request, json as _json + req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=3) as resp: + data = _json.loads(resp.read()) + return data.get("nodes", []) + except Exception: + return [] + + @app.get("/api/wizard/hardware") def wizard_hardware(): - """Detect GPUs and suggest an inference profile.""" + """Detect local GPUs, suggest an inference profile, and report cf-orch nodes.""" gpus = _detect_gpus() suggested = _suggest_profile(gpus) + + # Enrich with cf-orch cluster data when coordinator URL is configured + orch_nodes = _fetch_cforch_nodes() + orch_summary = [] + for node in orch_nodes: + for gpu in node.get("gpus", []): + orch_summary.append({ + "node": node["node_id"], + "name": gpu["name"], + "vram_total_mb": gpu["vram_total_mb"], + "vram_free_mb": gpu["vram_free_mb"], + }) + return { "gpus": gpus, "suggested_profile": suggested, "profiles": list(_WIZARD_PROFILES), + "cf_orch_available": len(orch_nodes) > 0, + "cf_orch_gpus": orch_summary, } diff --git a/docker/cf-orch-agent/start.sh b/docker/cf-orch-agent/start.sh new file mode 100644 index 0000000..e20ad1e --- /dev/null +++ b/docker/cf-orch-agent/start.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Start the cf-orch agent. Adds --advertise-host only when CF_ORCH_ADVERTISE_HOST is set. +set -e + +ARGS="--coordinator ${CF_ORCH_COORDINATOR_URL:-http://host.docker.internal:7700} \ + --node-id ${CF_ORCH_NODE_ID:-peregrine} \ + --host 0.0.0.0 \ + --port ${CF_ORCH_AGENT_PORT:-7701}" + +if [ -n "${CF_ORCH_ADVERTISE_HOST}" ]; then + ARGS="$ARGS --advertise-host ${CF_ORCH_ADVERTISE_HOST}" +fi + +exec cf-orch agent $ARGS diff --git a/docs/index.md b/docs/index.md index 05a0ac0..e684cbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ Peregrine automates the full job search lifecycle: discovery, matching, cover letter generation, application tracking, and interview preparation. It is privacy-first and local-first — your data never leaves your machine unless you configure an external integration. +![Peregrine dashboard](screenshots/01-dashboard.png) + --- ## Quick Start diff --git a/docs/screenshots/01-dashboard.png b/docs/screenshots/01-dashboard.png new file mode 100644 index 0000000..9da9e6d Binary files /dev/null and b/docs/screenshots/01-dashboard.png differ diff --git a/docs/screenshots/02-review.png b/docs/screenshots/02-review.png new file mode 100644 index 0000000..5fe982e Binary files /dev/null and b/docs/screenshots/02-review.png differ diff --git a/docs/screenshots/03-apply.png b/docs/screenshots/03-apply.png new file mode 100644 index 0000000..59c060f Binary files /dev/null and b/docs/screenshots/03-apply.png differ diff --git a/docs/user-guide/apply-workspace.md b/docs/user-guide/apply-workspace.md index 899b637..181093d 100644 --- a/docs/user-guide/apply-workspace.md +++ b/docs/user-guide/apply-workspace.md @@ -1,5 +1,7 @@ # Apply Workspace +![Peregrine apply workspace with cover letter generator and ATS optimizer](../screenshots/03-apply.png) + The Apply Workspace is where you generate cover letters, export application documents, and record that you have applied to a job. --- diff --git a/docs/user-guide/job-review.md b/docs/user-guide/job-review.md index f58bcdb..23fb165 100644 --- a/docs/user-guide/job-review.md +++ b/docs/user-guide/job-review.md @@ -1,5 +1,7 @@ # Job Review +![Peregrine job review triage](../screenshots/02-review.png) + The Job Review page is where you approve or reject newly discovered jobs before they enter the application pipeline. --- diff --git a/migrations/002_ats_resume_columns.sql b/migrations/002_ats_resume_columns.sql new file mode 100644 index 0000000..8caa227 --- /dev/null +++ b/migrations/002_ats_resume_columns.sql @@ -0,0 +1,7 @@ +-- Add ATS resume optimizer columns introduced in v0.8.x. +-- Existing DBs that were created before the baseline included these columns +-- need this migration to add them. Safe to run on new DBs: IF NOT EXISTS guards +-- are not available for ADD COLUMN in SQLite, so we use a try/ignore pattern +-- at the application level (db_migrate.py wraps each migration in a transaction). +ALTER TABLE jobs ADD COLUMN optimized_resume TEXT; +ALTER TABLE jobs ADD COLUMN ats_gap_report TEXT; diff --git a/migrations/003_resume_review.sql b/migrations/003_resume_review.sql new file mode 100644 index 0000000..9acd550 --- /dev/null +++ b/migrations/003_resume_review.sql @@ -0,0 +1,3 @@ +-- Resume review draft and version archive columns (migration 003) +ALTER TABLE jobs ADD COLUMN resume_draft_json TEXT; +ALTER TABLE jobs ADD COLUMN resume_archive_json TEXT; diff --git a/migrations/004_resume_final_struct.sql b/migrations/004_resume_final_struct.sql new file mode 100644 index 0000000..709952e --- /dev/null +++ b/migrations/004_resume_final_struct.sql @@ -0,0 +1,5 @@ +-- Migration 004: add resume_final_struct to jobs table +-- Stores the approved resume as a structured JSON dict alongside the plain text +-- (resume_optimized_text). Enables YAML export and future re-processing without +-- re-parsing the plain text. +ALTER TABLE jobs ADD COLUMN resume_final_struct TEXT; diff --git a/podman-standalone.sh b/podman-standalone.sh new file mode 100755 index 0000000..af13137 --- /dev/null +++ b/podman-standalone.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# podman-standalone.sh — Peregrine rootful Podman setup (no Compose) +# +# For beta testers running system Podman (non-rootless) with systemd. +# Mirrors the manage.sh "remote" profile: app + SearXNG only. +# Ollama/vLLM/vision are expected as host services if needed. +# +# ── Prerequisites ──────────────────────────────────────────────────────────── +# 1. Clone the repo: +# sudo git clone /opt/peregrine +# +# 2. Build the app image: +# cd /opt/peregrine && sudo podman build -t localhost/peregrine:latest . +# +# 3. Create a config directory and copy the example configs: +# sudo mkdir -p /opt/peregrine/{config,data} +# sudo cp /opt/peregrine/config/*.example /opt/peregrine/config/ +# # Edit /opt/peregrine/config/llm.yaml, notion.yaml, etc. as needed +# +# 4. Run this script: +# sudo bash /opt/peregrine/podman-standalone.sh +# +# ── After setup — generate systemd unit files ──────────────────────────────── +# sudo podman generate systemd --new --name peregrine-searxng \ +# | sudo tee /etc/systemd/system/peregrine-searxng.service +# sudo podman generate systemd --new --name peregrine \ +# | sudo tee /etc/systemd/system/peregrine.service +# sudo systemctl daemon-reload +# sudo systemctl enable --now peregrine-searxng peregrine +# +# ── SearXNG ────────────────────────────────────────────────────────────────── +# Peregrine expects a SearXNG instance with JSON format enabled. +# If you already run one, skip the SearXNG container and set the URL in +# config/llm.yaml (searxng_url key). The default is http://localhost:8888. +# +# ── Ports ──────────────────────────────────────────────────────────────────── +# Peregrine UI → http://localhost:8501 +# +# ── To use a different Streamlit port ──────────────────────────────────────── +# Uncomment the CMD override at the bottom of the peregrine run block and +# set PORT= to your desired port. The Dockerfile default is 8501. +# +set -euo pipefail + +REPO_DIR=/opt/peregrine +DATA_DIR=/opt/peregrine/data +DOCS_DIR=/Library/Documents/JobSearch # ← adjust to your docs path +TZ=America/Los_Angeles + +# ── Peregrine App ───────────────────────────────────────────────────────────── +# Image is built locally — no registry auto-update label. +# To update: sudo podman build -t localhost/peregrine:latest /opt/peregrine +# sudo podman restart peregrine +# +# Env vars: ANTHROPIC_API_KEY, OPENAI_COMPAT_URL, OPENAI_COMPAT_KEY are +# optional — only needed if you're using those backends in config/llm.yaml. +# +sudo podman run -d \ + --name=peregrine \ + --restart=unless-stopped \ + --net=host \ + -v ${REPO_DIR}/config:/app/config:Z \ + -v ${DATA_DIR}:/app/data:Z \ + -v ${DOCS_DIR}:/docs:z \ + -e STAGING_DB=/app/data/staging.db \ + -e DOCS_DIR=/docs \ + -e PYTHONUNBUFFERED=1 \ + -e PYTHONLOGGING=WARNING \ + -e TZ=${TZ} \ + --health-cmd="curl -f http://localhost:8501/_stcore/health || exit 1" \ + --health-interval=30s \ + --health-timeout=10s \ + --health-start-period=60s \ + --health-retries=3 \ + localhost/peregrine:latest + # To override the default port (8501), uncomment and edit the line below, + # then remove the image name above and place it at the end of the CMD: + # streamlit run app/app.py --server.port=8501 --server.headless=true --server.fileWatcherType=none + +echo "" +echo "Peregrine is starting up." +echo " App: http://localhost:8501" +echo "" +echo "Check container health with:" +echo " sudo podman ps" +echo " sudo podman logs peregrine" +echo "" +echo "To register as a systemd service:" +echo " sudo podman generate systemd --new --name peregrine \\" +echo " | sudo tee /etc/systemd/system/peregrine.service" +echo " sudo systemctl daemon-reload" +echo " sudo systemctl enable --now peregrine" diff --git a/scripts/company_research.py b/scripts/company_research.py index 32fde8f..93561e6 100644 --- a/scripts/company_research.py +++ b/scripts/company_research.py @@ -277,7 +277,8 @@ def _load_resume_and_keywords() -> tuple[dict, list[str]]: return resume, keywords -def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict: +def research_company(job: dict, use_scraper: bool = True, on_stage=None, + config_path: "Path | None" = None) -> dict: """ Generate a pre-interview research brief for a job. @@ -295,7 +296,7 @@ def research_company(job: dict, use_scraper: bool = True, on_stage=None) -> dict """ from scripts.llm_router import LLMRouter - router = LLMRouter() + router = LLMRouter(config_path=config_path) if config_path else LLMRouter() research_order = router.config.get("research_fallback_order") or router.config["fallback_order"] company = job.get("company") or "the company" title = job.get("title") or "this role" diff --git a/scripts/db_migrate.py b/scripts/db_migrate.py index bbb407f..aa88674 100644 --- a/scripts/db_migrate.py +++ b/scripts/db_migrate.py @@ -56,7 +56,56 @@ def migrate_db(db_path: Path) -> list[str]: sql = path.read_text(encoding="utf-8") log.info("Applying migration %s to %s", version, db_path.name) try: - con.executescript(sql) + # Execute statements individually so that ALTER TABLE ADD COLUMN + # errors caused by already-existing columns (pre-migration DBs + # created from a newer schema) are treated as no-ops rather than + # fatal failures. + statements = [s.strip() for s in sql.split(";") if s.strip()] + for stmt in statements: + # Strip leading SQL comment lines (-- ...) before processing. + # Checking startswith("--") on the raw chunk would skip entire + # multi-line statements whose first line is a comment. + stripped_lines = [ + ln for ln in stmt.splitlines() + if not ln.strip().startswith("--") + ] + stmt = "\n".join(stripped_lines).strip() + if not stmt: + continue + # Pre-check: if this is ADD COLUMN and the column already exists, skip. + # This guards against schema_migrations being ahead of the actual schema + # (e.g. DB reset after migrations were recorded). + stmt_upper = stmt.upper() + if "ALTER TABLE" in stmt_upper and "ADD COLUMN" in stmt_upper: + # Extract table name and column name from the statement + import re as _re + m = _re.match( + r"ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)", + stmt, _re.IGNORECASE + ) + if m: + tbl, col = m.group(1), m.group(2) + existing = { + row[1] + for row in con.execute(f"PRAGMA table_info({tbl})") + } + if col in existing: + log.info( + "Migration %s: column %s.%s already exists, skipping", + version, tbl, col, + ) + continue + try: + con.execute(stmt) + except sqlite3.OperationalError as stmt_exc: + msg = str(stmt_exc).lower() + if "duplicate column name" in msg or "already exists" in msg: + log.info( + "Migration %s: statement already applied, skipping: %s", + version, stmt_exc, + ) + else: + raise con.execute( "INSERT INTO schema_migrations (version) VALUES (?)", (version,) ) diff --git a/scripts/discover.py b/scripts/discover.py index bc0e3f0..d87212e 100644 --- a/scripts/discover.py +++ b/scripts/discover.py @@ -34,11 +34,38 @@ CUSTOM_SCRAPERS: dict[str, object] = { } +def _normalize_profiles(raw: dict) -> dict: + """Normalize search_profiles.yaml to the canonical {profiles: [...]} format. + + The onboarding wizard (pre-fix) wrote a flat `default: {...}` structure. + Canonical format is `profiles: [{name, titles/job_titles, boards, ...}]`. + This converts on load so both formats work without a migration. + """ + if "profiles" in raw: + return raw + # Wizard-written format: top-level keys are profile names (usually "default") + profiles = [] + for name, body in raw.items(): + if not isinstance(body, dict): + continue + # job_boards: [{name, enabled}] → boards: [name] (enabled only) + job_boards = body.pop("job_boards", None) + if job_boards and "boards" not in body: + body["boards"] = [b["name"] for b in job_boards if b.get("enabled", True)] + # blocklist_* keys live in load_blocklist, not per-profile — drop them + body.pop("blocklist_companies", None) + body.pop("blocklist_industries", None) + body.pop("blocklist_locations", None) + profiles.append({"name": name, **body}) + return {"profiles": profiles} + + def load_config(config_dir: Path | None = None) -> tuple[dict, dict]: cfg = config_dir or CONFIG_DIR profiles_path = cfg / "search_profiles.yaml" notion_path = cfg / "notion.yaml" - profiles = yaml.safe_load(profiles_path.read_text()) + raw = yaml.safe_load(profiles_path.read_text()) or {} + profiles = _normalize_profiles(raw) notion_cfg = yaml.safe_load(notion_path.read_text()) if notion_path.exists() else {"field_map": {}, "token": None, "database_id": None} return profiles, notion_cfg @@ -212,14 +239,43 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_ _rp = profile.get("remote_preference", "both") _is_remote: bool | None = True if _rp == "remote" else (False if _rp == "onsite" else None) + # When filtering for remote-only, also drop hybrid roles at the description level. + # Job boards (especially LinkedIn) tag hybrid listings as is_remote=True, so the + # board-side filter alone is not reliable. We match specific work-arrangement + # phrases to avoid false positives like "hybrid cloud" or "hybrid architecture". + _HYBRID_PHRASES = [ + "hybrid role", "hybrid position", "hybrid work", "hybrid schedule", + "hybrid model", "hybrid arrangement", "hybrid opportunity", + "in-office/remote", "in office/remote", "remote/in-office", + "remote/office", "office/remote", + "days in office", "days per week in", "days onsite", "days on-site", + "required to be in office", "required in office", + ] + if _rp == "remote": + exclude_kw = exclude_kw + _HYBRID_PHRASES + for location in profile["locations"]: # ── JobSpy boards ────────────────────────────────────────────────── if boards: - print(f" [jobspy] {location} — boards: {', '.join(boards)}") + # Validate boards against the installed JobSpy Site enum. + # One unsupported name in the list aborts the entire scrape_jobs() call. + try: + from jobspy import Site as _Site + _valid = {s.value for s in _Site} + _filtered = [b for b in boards if b in _valid] + _dropped = [b for b in boards if b not in _valid] + if _dropped: + print(f" [jobspy] Skipping unsupported boards: {', '.join(_dropped)}") + except ImportError: + _filtered = boards # fallback: pass through unchanged + if not _filtered: + print(f" [jobspy] No valid boards for {location} — skipping") + continue + print(f" [jobspy] {location} — boards: {', '.join(_filtered)}") try: jobspy_kwargs: dict = dict( - site_name=boards, + site_name=_filtered, search_term=" OR ".join(f'"{t}"' for t in (profile.get("titles") or profile.get("job_titles", []))), location=location, results_wanted=results_per_board, diff --git a/scripts/generate_cover_letter.py b/scripts/generate_cover_letter.py index 5aa732c..87f4dce 100644 --- a/scripts/generate_cover_letter.py +++ b/scripts/generate_cover_letter.py @@ -42,6 +42,7 @@ def _build_system_context(profile=None) -> str: return " ".join(parts) SYSTEM_CONTEXT = _build_system_context() +_candidate = _profile.name if _profile else "the candidate" # ── Mission-alignment detection ─────────────────────────────────────────────── diff --git a/scripts/resume_optimizer.py b/scripts/resume_optimizer.py index 1d3b7b3..5fffd3e 100644 --- a/scripts/resume_optimizer.py +++ b/scripts/resume_optimizer.py @@ -301,7 +301,7 @@ def _apply_section_rewrite(resume: dict[str, Any], section: str, rewritten: str) 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) + updated["experience"] = _reparse_experience_bullets(resume.get("experience", []), rewritten) return updated @@ -345,6 +345,198 @@ def _reparse_experience_bullets( return result +# ── Gap framing ─────────────────────────────────────────────────────────────── + +def frame_skill_gaps( + struct: dict[str, Any], + gap_framings: list[dict], + job: dict[str, Any], + candidate_voice: str = "", +) -> dict[str, Any]: + """Inject honest framing language for skills the candidate doesn't have directly. + + For each gap framing decision the user provided: + - mode "adjacent": user has related experience → injects one bridging sentence + into the most relevant experience entry's bullets + - mode "learning": actively developing the skill → prepends a structured + "Developing: X (context)" note to the skills list + - mode "skip": no connection at all → no change + + The user-supplied context text is the source of truth. The LLM's job is only + to phrase it naturally in resume style — not to invent new claims. + + Args: + struct: Resume dict (already processed by apply_review_decisions). + gap_framings: List of dicts with keys: + skill — the ATS term the candidate lacks + mode — "adjacent" | "learning" | "skip" + context — candidate's own words describing their related background + job: Job dict for role context in prompts. + candidate_voice: Free-text style note from user.yaml. + + Returns: + New resume dict with framing language injected. + """ + from scripts.llm_router import LLMRouter + router = LLMRouter() + + updated = dict(struct) + updated["experience"] = [dict(e) for e in (struct.get("experience") or [])] + + adjacent_framings = [f for f in gap_framings if f.get("mode") == "adjacent" and f.get("context")] + learning_framings = [f for f in gap_framings if f.get("mode") == "learning" and f.get("context")] + + # ── Adjacent experience: inject bridging sentence into most relevant entry ─ + for framing in adjacent_framings: + skill = framing["skill"] + context = framing["context"] + + # Find the experience entry most likely to be relevant (simple keyword match) + best_entry_idx = _find_most_relevant_entry(updated["experience"], skill) + if best_entry_idx is None: + continue + + entry = updated["experience"][best_entry_idx] + bullets = list(entry.get("bullets") or []) + + voice_note = ( + f'\n\nCandidate voice/style: "{candidate_voice}". Match this tone.' + ) if candidate_voice else "" + + prompt = ( + f"You are adding one honest framing sentence to a resume bullet list.\n\n" + f"The candidate does not have direct experience with '{skill}', " + f"but they have relevant background they described as:\n" + f' "{context}"\n\n' + f"Job context: {job.get('title', '')} at {job.get('company', '')}.\n\n" + f"RULES:\n" + f"1. Add exactly ONE new bullet point that bridges their background to '{skill}'.\n" + f"2. Do NOT fabricate anything beyond what their context description says.\n" + f"3. Use honest language: 'adjacent experience in', 'strong foundation applicable to', " + f" 'directly transferable background in', etc.\n" + f"4. Return ONLY the single new bullet text — no prefix, no explanation." + f"{voice_note}\n\n" + f"Existing bullets for context:\n" + + "\n".join(f" • {b}" for b in bullets[:3]) + ) + + try: + new_bullet = router.complete(prompt).strip() + new_bullet = re.sub(r"^[•\-–—*◦▪▸►]\s*", "", new_bullet).strip() + if new_bullet: + bullets.append(new_bullet) + new_entry = dict(entry) + new_entry["bullets"] = bullets + updated["experience"][best_entry_idx] = new_entry + except Exception: + log.warning( + "[resume_optimizer] frame_skill_gaps adjacent failed for skill %r", skill, + exc_info=True, + ) + + # ── Learning framing: add structured note to skills list ────────────────── + if learning_framings: + skills = list(updated.get("skills") or []) + for framing in learning_framings: + skill = framing["skill"] + context = framing["context"].strip() + # Format: "Developing: Kubernetes (strong Docker/container orchestration background)" + note = f"Developing: {skill} ({context})" if context else f"Developing: {skill}" + if note not in skills: + skills.append(note) + updated["skills"] = skills + + return updated + + +def _find_most_relevant_entry( + experience: list[dict], + skill: str, +) -> int | None: + """Return the index of the experience entry most relevant to a skill term. + + Uses simple keyword overlap between the skill and entry title/bullets. + Falls back to the most recent (first) entry if no match found. + """ + if not experience: + return None + + skill_words = set(skill.lower().split()) + best_idx = 0 + best_score = -1 + + for i, entry in enumerate(experience): + entry_text = ( + (entry.get("title") or "") + " " + + " ".join(entry.get("bullets") or []) + ).lower() + entry_words = set(entry_text.split()) + score = len(skill_words & entry_words) + if score > best_score: + best_score = score + best_idx = i + + return best_idx + + +def apply_review_decisions( + draft: dict[str, Any], + decisions: dict[str, Any], +) -> dict[str, Any]: + """Apply user section-level review decisions to the rewritten struct. + + Handles approved skills, summary accept/reject, and per-entry experience + accept/reject. Returns the updated struct; does not call the LLM. + + Args: + draft: The review draft dict from build_review_diff (contains + "sections" and "rewritten_struct"). + decisions: Dict of per-section decisions from the review UI: + skills: {"approved_additions": [...]} + summary: {"accepted": bool} + experience: {"accepted_entries": [{"title", "company", "accepted"}]} + + Returns: + Updated resume struct ready for gap framing and final render. + """ + struct = dict(draft.get("rewritten_struct") or {}) + sections = draft.get("sections") or [] + + # ── Skills: keep original + only approved additions ──────────────────── + skills_decision = decisions.get("skills", {}) + approved_additions = set(skills_decision.get("approved_additions") or []) + for sec in sections: + if sec["section"] == "skills": + original_kept = set(sec.get("kept") or []) + struct["skills"] = sorted(original_kept | approved_additions) + break + + # ── Summary: accept proposed or revert to original ────────────────────── + if not decisions.get("summary", {}).get("accepted", True): + for sec in sections: + if sec["section"] == "summary": + struct["career_summary"] = sec.get("original", struct.get("career_summary", "")) + break + + # ── Experience: per-entry accept/reject ───────────────────────────────── + exp_decisions: dict[str, bool] = { + f"{ed.get('title', '')}|{ed.get('company', '')}": ed.get("accepted", True) + for ed in (decisions.get("experience", {}).get("accepted_entries") or []) + } + for sec in sections: + if sec["section"] == "experience": + for entry_diff in (sec.get("entries") or []): + key = f"{entry_diff['title']}|{entry_diff['company']}" + if not exp_decisions.get(key, True): + for exp_entry in (struct.get("experience") or []): + if (exp_entry.get("title") == entry_diff["title"] and + exp_entry.get("company") == entry_diff["company"]): + exp_entry["bullets"] = entry_diff["original_bullets"] + break + + return struct + + # ── Hallucination guard ─────────────────────────────────────────────────────── def hallucination_check(original: dict[str, Any], rewritten: dict[str, Any]) -> bool: @@ -437,3 +629,207 @@ def render_resume_text(resume: dict[str, Any]) -> str: lines.append("") return "\n".join(lines) + + +# ── Review diff builder ──────────────────────────────────────────────────────── + +def build_review_diff( + original: dict[str, Any], + rewritten: dict[str, Any], +) -> dict[str, Any]: + """Build a structured diff between original and rewritten resume for the review UI. + + Returns a dict with: + sections: list of per-section diffs + rewritten_struct: the full rewritten resume dict (used by finalize endpoint) + + Each section diff has: + section: "skills" | "summary" | "experience" + type: "skills_diff" | "text_diff" | "bullets_diff" + For skills_diff: + added: list of new skill strings (each requires user approval) + removed: list of removed skill strings + kept: list of unchanged skills + For text_diff (summary): + original: str + proposed: str + For bullets_diff (experience): + entries: list of {title, company, original_bullets, proposed_bullets} + """ + sections = [] + + # ── Skills diff ──────────────────────────────────────────────────────── + orig_skills = set(s.strip() for s in (original.get("skills") or [])) + new_skills = set(s.strip() for s in (rewritten.get("skills") or [])) + + added = sorted(new_skills - orig_skills) + removed = sorted(orig_skills - new_skills) + kept = sorted(orig_skills & new_skills) + + if added or removed: + sections.append({ + "section": "skills", + "type": "skills_diff", + "added": added, + "removed": removed, + "kept": kept, + }) + + # ── Summary diff ─────────────────────────────────────────────────────── + orig_summary = (original.get("career_summary") or "").strip() + new_summary = (rewritten.get("career_summary") or "").strip() + + if orig_summary != new_summary and new_summary: + sections.append({ + "section": "summary", + "type": "text_diff", + "original": orig_summary, + "proposed": new_summary, + }) + + # ── Experience diff ──────────────────────────────────────────────────── + orig_exp = original.get("experience") or [] + new_exp = rewritten.get("experience") or [] + + entry_diffs = [] + for orig_entry, new_entry in zip(orig_exp, new_exp): + orig_bullets = orig_entry.get("bullets") or [] + new_bullets = new_entry.get("bullets") or [] + if orig_bullets != new_bullets: + entry_diffs.append({ + "title": orig_entry.get("title", ""), + "company": orig_entry.get("company", ""), + "original_bullets": orig_bullets, + "proposed_bullets": new_bullets, + }) + + if entry_diffs: + sections.append({ + "section": "experience", + "type": "bullets_diff", + "entries": entry_diffs, + }) + + return { + "sections": sections, + "rewritten_struct": rewritten, + } + + +# ── PDF export ───────────────────────────────────────────────────────────────── + +def export_pdf(resume: dict[str, Any], output_path: str) -> None: + """Render a structured resume dict to a clean PDF using reportlab. + + Uses a single-column layout with section headers, consistent spacing, + and a readable sans-serif body font suitable for ATS submission. + + Args: + resume: Structured resume dict (same format as resume_parser output). + output_path: Absolute path for the output .pdf file. + """ + from reportlab.lib.pagesizes import LETTER + from reportlab.lib.units import inch + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.enums import TA_CENTER, TA_LEFT + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable + from reportlab.lib import colors + + MARGIN = 0.75 * inch + + name_style = ParagraphStyle( + "name", fontName="Helvetica-Bold", fontSize=16, leading=20, + alignment=TA_CENTER, spaceAfter=2, + ) + contact_style = ParagraphStyle( + "contact", fontName="Helvetica", fontSize=9, leading=12, + alignment=TA_CENTER, spaceAfter=6, + textColor=colors.HexColor("#555555"), + ) + section_style = ParagraphStyle( + "section", fontName="Helvetica-Bold", fontSize=10, leading=14, + spaceBefore=10, spaceAfter=2, + textColor=colors.HexColor("#1a1a2e"), + ) + body_style = ParagraphStyle( + "body", fontName="Helvetica", fontSize=9, leading=13, alignment=TA_LEFT, + ) + role_style = ParagraphStyle( + "role", fontName="Helvetica-Bold", fontSize=9, leading=13, + ) + meta_style = ParagraphStyle( + "meta", fontName="Helvetica-Oblique", fontSize=8, leading=12, + textColor=colors.HexColor("#555555"), spaceAfter=2, + ) + bullet_style = ParagraphStyle( + "bullet", fontName="Helvetica", fontSize=9, leading=13, leftIndent=12, + ) + + def hr(): + return HRFlowable(width="100%", thickness=0.5, + color=colors.HexColor("#cccccc"), + spaceAfter=4, spaceBefore=2) + + story = [] + + if resume.get("name"): + story.append(Paragraph(resume["name"], name_style)) + + contact_parts = [p for p in ( + resume.get("email", ""), resume.get("phone", ""), + resume.get("location", ""), resume.get("linkedin", ""), + ) if p] + if contact_parts: + story.append(Paragraph(" | ".join(contact_parts), contact_style)) + + story.append(hr()) + + summary = (resume.get("career_summary") or "").strip() + if summary: + story.append(Paragraph("SUMMARY", section_style)) + story.append(hr()) + story.append(Paragraph(summary, body_style)) + story.append(Spacer(1, 4)) + + if resume.get("experience"): + story.append(Paragraph("EXPERIENCE", section_style)) + story.append(hr()) + for exp in resume["experience"]: + dates = f"{exp.get('start_date', '')}–{exp.get('end_date', '')}" + story.append(Paragraph( + f"{exp.get('title', '')} | {exp.get('company', '')}", role_style + )) + story.append(Paragraph(dates, meta_style)) + for bullet in (exp.get("bullets") or []): + story.append(Paragraph(f"• {bullet}", bullet_style)) + story.append(Spacer(1, 4)) + + if resume.get("education"): + story.append(Paragraph("EDUCATION", section_style)) + story.append(hr()) + for edu in resume["education"]: + degree = f"{edu.get('degree', '')} {edu.get('field', '')}".strip() + story.append(Paragraph( + f"{degree} | {edu.get('institution', '')} {edu.get('graduation_year', '')}".strip(), + body_style, + )) + story.append(Spacer(1, 4)) + + if resume.get("skills"): + story.append(Paragraph("SKILLS", section_style)) + story.append(hr()) + story.append(Paragraph(", ".join(resume["skills"]), body_style)) + story.append(Spacer(1, 4)) + + if resume.get("achievements"): + story.append(Paragraph("ACHIEVEMENTS", section_style)) + story.append(hr()) + for a in resume["achievements"]: + story.append(Paragraph(f"• {a}", bullet_style)) + + doc = SimpleDocTemplate( + output_path, pagesize=LETTER, + leftMargin=MARGIN, rightMargin=MARGIN, + topMargin=MARGIN, bottomMargin=MARGIN, + ) + doc.build(story) diff --git a/scripts/task_runner.py b/scripts/task_runner.py index b728dcc..f13e00f 100644 --- a/scripts/task_runner.py +++ b/scripts/task_runner.py @@ -16,6 +16,61 @@ from pathlib import Path log = logging.getLogger(__name__) + +def _normalize_aihawk_resume(raw: dict) -> dict: + """Convert a plain_text_resume.yaml (AIHawk format) into the optimizer struct. + + Handles two AIHawk variants: + - Newer Peregrine wizard output: already uses bullets/start_date/end_date/career_summary + - Older raw AIHawk format: uses responsibilities (str), period ("YYYY – Present") + """ + import re as _re + + def _split_responsibilities(text: str) -> list[str]: + lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()] + return lines if lines else [text.strip()] + + def _parse_period(period: str) -> tuple[str, str]: + parts = _re.split(r"\s*[–—-]\s*", period, maxsplit=1) + start = parts[0].strip() if parts else "" + end = parts[1].strip() if len(parts) > 1 else "Present" + return start, end + + experience = [] + for entry in raw.get("experience", []): + if "responsibilities" in entry: + bullets = _split_responsibilities(entry["responsibilities"]) + else: + bullets = entry.get("bullets", []) + + if "period" in entry: + start_date, end_date = _parse_period(entry["period"]) + else: + start_date = entry.get("start_date", "") + end_date = entry.get("end_date", "Present") + + experience.append({ + "title": entry.get("title", ""), + "company": entry.get("company", ""), + "start_date": start_date, + "end_date": end_date, + "bullets": bullets, + }) + + # career_summary may be a string or absent; assessment field is a legacy bool in some profiles + career_summary = raw.get("career_summary", "") + if not isinstance(career_summary, str): + career_summary = "" + + return { + "career_summary": career_summary, + "experience": experience, + "education": raw.get("education", []), + "skills": raw.get("skills", []), + "achievements": raw.get("achievements", []), + } + + from scripts.db import ( DEFAULT_DB, insert_task, @@ -196,9 +251,12 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int, elif task_type == "company_research": from scripts.company_research import research_company + _cfg_dir = Path(db_path).parent / "config" + _user_llm_cfg = _cfg_dir / "llm.yaml" result = research_company( job, on_stage=lambda s: update_task_stage(db_path, task_id, s), + config_path=_user_llm_cfg if _user_llm_cfg.exists() else None, ) save_research(db_path, job_id=job_id, **result) @@ -287,13 +345,25 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int, ) from scripts.user_profile import load_user_profile + _user_yaml = Path(db_path).parent / "config" / "user.yaml" description = job.get("description", "") - resume_path = load_user_profile().get("resume_path", "") + resume_path = load_user_profile(str(_user_yaml)).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) + _plain_yaml = Path(db_path).parent / "config" / "plain_text_resume.yaml" + if resume_path and Path(resume_path).exists(): + resume_text = Path(resume_path).read_text(errors="replace") + resume_struct, parse_err = structure_resume(resume_text) + elif _plain_yaml.exists(): + import yaml as _yaml + _raw = _yaml.safe_load(_plain_yaml.read_text(encoding="utf-8")) or {} + resume_struct = _normalize_aihawk_resume(_raw) + resume_text = resume_struct.get("career_summary", "") + parse_err = "" + else: + resume_text = "" + resume_struct, parse_err = structure_resume("") # Extract keyword gaps and build gap report (free tier) update_task_stage(db_path, task_id, "extracting keyword gaps") @@ -301,21 +371,38 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int, prioritized = prioritize_gaps(gaps, resume_struct) gap_report = _json.dumps(prioritized, indent=2) - # Full rewrite (paid tier only) - rewritten_text = "" + # Full rewrite (paid tier only) → enters awaiting_review, not completed p = _json.loads(params or "{}") + selected_gaps = p.get("selected_gaps", None) + if selected_gaps is not None: + selected_set = set(selected_gaps) + prioritized = [g for g in prioritized if g.get("term") in selected_set] if p.get("full_rewrite", False): update_task_stage(db_path, task_id, "rewriting resume sections") - candidate_voice = load_user_profile().get("candidate_voice", "") + candidate_voice = load_user_profile(str(_user_yaml)).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) + from scripts.resume_optimizer import build_review_diff + from scripts.db import save_resume_draft + draft = build_review_diff(resume_struct, rewritten) + # Attach gap report to draft for reference in the review UI + draft["gap_report"] = prioritized + save_resume_draft(db_path, job_id=job_id, + draft_json=_json.dumps(draft)) + # Save gap report now; final text written after user review + save_optimized_resume(db_path, job_id=job_id, + text="", gap_report=gap_report) + # Park task in awaiting_review — finalize endpoint resolves it + update_task_status(db_path, task_id, "awaiting_review") + return 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) + save_optimized_resume(db_path, job_id=job_id, + text="", gap_report=gap_report) + else: + # Gap-only run (free tier): save report, no draft + save_optimized_resume(db_path, job_id=job_id, + text="", gap_report=gap_report) elif task_type == "prepare_training": from scripts.prepare_training_data import build_records, write_jsonl, DEFAULT_OUTPUT diff --git a/tests/test_dev_api_settings.py b/tests/test_dev_api_settings.py index d2fa97a..7985bf7 100644 --- a/tests/test_dev_api_settings.py +++ b/tests/test_dev_api_settings.py @@ -7,35 +7,7 @@ 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() +# credential_store.py was merged to main repo — no worktree path manipulation needed @pytest.fixture(scope="module") @@ -211,7 +183,8 @@ def test_get_search_prefs_returns_dict(tmp_path, monkeypatch): 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) + yaml.dump({"default": {"remote_preference": "remote", + "job_boards": [{"name": "linkedin", "enabled": True}]}}, f) monkeypatch.setattr("dev_api._search_prefs_path", lambda: fake_path) from dev_api import app diff --git a/tests/test_wizard_api.py b/tests/test_wizard_api.py index 3bf30d2..284335f 100644 --- a/tests/test_wizard_api.py +++ b/tests/test_wizard_api.py @@ -104,7 +104,7 @@ class TestWizardHardware: r = client.get("/api/wizard/hardware") assert r.status_code == 200 body = r.json() - assert set(body["profiles"]) == {"remote", "cpu", "single-gpu", "dual-gpu"} + assert {"remote", "cpu", "single-gpu", "dual-gpu"}.issubset(set(body["profiles"])) assert "gpus" in body assert "suggested_profile" in body @@ -245,8 +245,10 @@ class TestWizardStep: assert r.status_code == 200 assert search_path.exists() prefs = yaml.safe_load(search_path.read_text()) - assert prefs["default"]["job_titles"] == ["Software Engineer", "Backend Developer"] - assert "Remote" in prefs["default"]["location"] + # Step 6 writes canonical {profiles: [{name, titles, locations, ...}]} format + default = next(p for p in prefs["profiles"] if p["name"] == "default") + assert default["titles"] == ["Software Engineer", "Backend Developer"] + assert "Remote" in default["locations"] def test_step7_only_advances_counter(self, client, tmp_path): yaml_path = tmp_path / "config" / "user.yaml" diff --git a/web/package-lock.json b/web/package-lock.json index 7bb7295..72cbf49 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,9 +12,12 @@ "@fontsource/fraunces": "^5.2.9", "@fontsource/jetbrains-mono": "^5.2.8", "@heroicons/vue": "^2.2.0", + "@types/dompurify": "^3.0.5", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "animejs": "^4.3.6", + "dompurify": "^3.4.0", + "marked": "^18.0.0", "pinia": "^3.0.4", "vue": "^3.5.25", "vue-router": "^5.0.3" @@ -1718,6 +1721,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1735,6 +1747,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -2944,6 +2962,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -3472,6 +3499,18 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/marked": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", + "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", diff --git a/web/package.json b/web/package.json index 77ddd18..493260b 100644 --- a/web/package.json +++ b/web/package.json @@ -15,9 +15,12 @@ "@fontsource/fraunces": "^5.2.9", "@fontsource/jetbrains-mono": "^5.2.8", "@heroicons/vue": "^2.2.0", + "@types/dompurify": "^3.0.5", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "animejs": "^4.3.6", + "dompurify": "^3.4.0", + "marked": "^18.0.0", "pinia": "^3.0.4", "vue": "^3.5.25", "vue-router": "^5.0.3" diff --git a/web/src/assets/peregrine.css b/web/src/assets/peregrine.css index 803e5d8..1a72e4b 100644 --- a/web/src/assets/peregrine.css +++ b/web/src/assets/peregrine.css @@ -77,6 +77,7 @@ body { } /* ── Dark mode ─────────────────────────────────────── */ +/* Covers both: OS-level dark preference AND explicit dark theme selection in UI */ @media (prefers-color-scheme: dark) { :root:not([data-theme="hacker"]) { --app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */ @@ -97,6 +98,26 @@ body { } } +/* Explicit [data-theme="dark"] — fires when user picks dark via theme picker + on a light-OS machine (where prefers-color-scheme: dark won't match) */ +[data-theme="dark"]:not([data-theme="hacker"]) { + --app-primary: #68A8D8; + --app-primary-hover: #7BBDE6; + --app-primary-light: #0D1F35; + + --app-accent: #F6872A; + --app-accent-hover: #FF9840; + --app-accent-light: #2D1505; + --app-accent-text: #1a2338; + + --score-mid-high: #5ba3d9; + + --status-synced: #9b8fea; + --status-survey: #b08fea; + --status-phone: #4ec9be; + --status-offer: #f5a43a; +} + /* ── Hacker mode (Konami easter egg) ──────────────── */ [data-theme="hacker"] { --app-primary: #00ff41; diff --git a/web/src/components/ApplyWorkspace.vue b/web/src/components/ApplyWorkspace.vue index e40437c..5c47c9c 100644 --- a/web/src/components/ApplyWorkspace.vue +++ b/web/src/components/ApplyWorkspace.vue @@ -28,7 +28,7 @@ Remote -

{{ job.title }}

+

{{ job.title }}

{{ job.company }} @@ -38,7 +38,7 @@
- {{ job.description ?? 'No description available.' }} +
@@ -188,6 +237,20 @@ const columnColor = computed(() => { class="card-action" @click.stop="emit('survey', job.id)" >Survey → + +