diff --git a/dev-api.py b/dev-api.py new file mode 100644 index 0000000..8ccc0c3 --- /dev/null +++ b/dev-api.py @@ -0,0 +1,1800 @@ +""" +Minimal dev-only FastAPI server for the Vue SPA. +Reads directly from /devl/job-seeker/staging.db. +Run with: + conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload +""" +import imaplib +import json +import logging +import os +import re +import socket +import sqlite3 +import ssl as ssl_mod +import subprocess +import sys +import threading +from datetime import datetime +from pathlib import Path +from typing import Optional, List +from urllib.parse import urlparse + +import requests +import yaml +from bs4 import BeautifulSoup +from fastapi import FastAPI, HTTPException, 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") +if str(PEREGRINE_ROOT) not in sys.path: + sys.path.insert(0, str(PEREGRINE_ROOT)) + +from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402 + +DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db") + +app = FastAPI(title="Peregrine Dev API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://10.1.10.71:5173"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def _get_db(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + return db + + +def _strip_html(text: str | None) -> str | None: + """Strip HTML tags and normalize whitespace in email body text.""" + if not text: + return text + plain = BeautifulSoup(text, 'html.parser').get_text(separator='\n') + # Strip trailing whitespace from each line + lines = [line.rstrip() for line in plain.split('\n')] + # Collapse 3+ consecutive blank lines to at most 2 + cleaned = re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)) + return cleaned.strip() or None + + +@app.on_event("startup") +def _startup(): + """Ensure digest_queue table exists (dev-api may run against an existing DB).""" + db = _get_db() + try: + db.execute(""" + CREATE TABLE IF NOT EXISTS digest_queue ( + id INTEGER PRIMARY KEY, + job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id), + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(job_contact_id) + ) + """) + db.commit() + finally: + db.close() + + +# ── Link extraction helpers ─────────────────────────────────────────────── + +_JOB_DOMAINS = frozenset({ + 'greenhouse.io', 'lever.co', 'workday.com', 'linkedin.com', + 'ashbyhq.com', 'smartrecruiters.com', 'icims.com', 'taleo.net', + 'jobvite.com', 'breezy.hr', 'recruitee.com', 'bamboohr.com', + 'myworkdayjobs.com', +}) + +_JOB_PATH_SEGMENTS = frozenset({'careers', 'jobs'}) + +_FILTER_RE = re.compile( + r'(unsubscribe|mailto:|/track/|pixel\.|\.gif|\.png|\.jpg' + r'|/open\?|/click\?|list-unsubscribe)', + re.I, +) + +_URL_RE = re.compile(r'https?://[^\s<>"\')\]]+', re.I) + + +def _score_url(url: str) -> int: + """Return 2 for likely job URLs, 1 for others, -1 to exclude.""" + if _FILTER_RE.search(url): + return -1 + parsed = urlparse(url) + hostname = (parsed.hostname or '').lower() + path = parsed.path.lower() + for domain in _JOB_DOMAINS: + if domain in hostname: + return 2 + for seg in _JOB_PATH_SEGMENTS: + if f'/{seg}/' in path or path.startswith(f'/{seg}'): + return 2 + return 1 + + +def _extract_links(body: str) -> list[dict]: + """Extract and rank URLs from raw HTML email body.""" + if not body: + return [] + seen: set[str] = set() + results = [] + for m in _URL_RE.finditer(body): + url = m.group(0).rstrip('.,;)') + if url in seen: + continue + seen.add(url) + score = _score_url(url) + if score < 0: + continue + start = max(0, m.start() - 60) + hint = body[start:m.start()].strip().split('\n')[-1].strip() + results.append({'url': url, 'score': score, 'hint': hint}) + results.sort(key=lambda x: -x['score']) + return results + + +def _row_to_job(row) -> dict: + d = dict(row) + d["is_remote"] = bool(d.get("is_remote", 0)) + return d + + +# ── GET /api/jobs ───────────────────────────────────────────────────────────── + +@app.get("/api/jobs") +def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""): + db = _get_db() + rows = db.execute( + "SELECT id, title, company, url, source, location, is_remote, salary, " + "description, match_score, keyword_gaps, date_found, status, cover_letter " + "FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?", + (status, limit), + ).fetchall() + db.close() + result = [] + for r in rows: + d = _row_to_job(r) + d["has_cover_letter"] = bool(d.get("cover_letter")) + # Don't send full cover_letter text in the list view + d.pop("cover_letter", None) + result.append(d) + return result + + +# ── GET /api/jobs/counts ────────────────────────────────────────────────────── + +@app.get("/api/jobs/counts") +def job_counts(): + db = _get_db() + rows = db.execute("SELECT status, count(*) as n FROM jobs GROUP BY status").fetchall() + db.close() + counts = {r["status"]: r["n"] for r in rows} + return { + "pending": counts.get("pending", 0), + "approved": counts.get("approved", 0), + "applied": counts.get("applied", 0), + "synced": counts.get("synced", 0), + "rejected": counts.get("rejected", 0), + "total": sum(counts.values()), + } + + +# ── POST /api/jobs/{id}/approve ─────────────────────────────────────────────── + +@app.post("/api/jobs/{job_id}/approve") +def approve_job(job_id: int): + db = _get_db() + db.execute("UPDATE jobs SET status = 'approved' WHERE id = ?", (job_id,)) + db.commit() + db.close() + return {"ok": True} + + +# ── POST /api/jobs/{id}/reject ──────────────────────────────────────────────── + +@app.post("/api/jobs/{job_id}/reject") +def reject_job(job_id: int): + db = _get_db() + db.execute("UPDATE jobs SET status = 'rejected' WHERE id = ?", (job_id,)) + db.commit() + db.close() + return {"ok": True} + + +# ── POST /api/jobs/{id}/revert ──────────────────────────────────────────────── + +class RevertBody(BaseModel): + status: str + +@app.post("/api/jobs/{job_id}/revert") +def revert_job(job_id: int, body: RevertBody): + allowed = {"pending", "approved", "rejected", "applied", "synced"} + if body.status not in allowed: + raise HTTPException(400, f"Invalid status: {body.status}") + db = _get_db() + db.execute("UPDATE jobs SET status = ? WHERE id = ?", (body.status, job_id)) + db.commit() + db.close() + return {"ok": True} + + +# ── GET /api/system/status ──────────────────────────────────────────────────── + +@app.get("/api/system/status") +def system_status(): + return { + "enrichment_enabled": False, + "enrichment_last_run": None, + "enrichment_next_run": None, + "tasks_running": 0, + "integration_name": "Notion", + "integration_unsynced": 0, + } + + +# ── GET /api/jobs/:id ──────────────────────────────────────────────────────── + +@app.get("/api/jobs/{job_id}") +def get_job(job_id: int): + db = _get_db() + row = db.execute( + "SELECT id, title, company, url, source, location, is_remote, salary, " + "description, match_score, keyword_gaps, date_found, status, cover_letter " + "FROM jobs WHERE id = ?", + (job_id,), + ).fetchone() + db.close() + if not row: + raise HTTPException(404, "Job not found") + d = _row_to_job(row) + d["has_cover_letter"] = bool(d.get("cover_letter")) + return d + + +# ── POST /api/jobs/:id/applied ──────────────────────────────────────────────── + +@app.post("/api/jobs/{job_id}/applied") +def mark_applied(job_id: int): + db = _get_db() + db.execute( + "UPDATE jobs SET status = 'applied', applied_at = datetime('now') WHERE id = ?", + (job_id,), + ) + db.commit() + db.close() + return {"ok": True} + + +# ── PATCH /api/jobs/:id/cover_letter ───────────────────────────────────────── + +class CoverLetterBody(BaseModel): + text: str + +@app.patch("/api/jobs/{job_id}/cover_letter") +def save_cover_letter(job_id: int, body: CoverLetterBody): + db = _get_db() + db.execute("UPDATE jobs SET cover_letter = ? WHERE id = ?", (body.text, job_id)) + db.commit() + db.close() + return {"ok": True} + + +# ── POST /api/jobs/:id/cover_letter/generate ───────────────────────────────── + +@app.post("/api/jobs/{job_id}/cover_letter/generate") +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), + task_type="cover_letter", + job_id=job_id, + ) + return {"task_id": task_id, "is_new": is_new} + except Exception as e: + raise HTTPException(500, str(e)) + + +# ── GET /api/jobs/:id/cover_letter/task ────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/cover_letter/task") +def cover_letter_task(job_id: int): + db = _get_db() + row = db.execute( + "SELECT status, stage, error FROM background_tasks " + "WHERE task_type = 'cover_letter' AND job_id = ? " + "ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + db.close() + if not row: + return {"status": "none", "stage": None, "message": None} + return { + "status": row["status"], + "stage": row["stage"], + "message": row["error"], + } + + +# ── Interview Prep endpoints ───────────────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/research") +def get_research_brief(job_id: int): + db = _get_db() + row = db.execute( + "SELECT job_id, company_brief, ceo_brief, talking_points, tech_brief, " + "funding_brief, red_flags, accessibility_brief, generated_at " + "FROM company_research WHERE job_id = ? LIMIT 1", + (job_id,), + ).fetchone() + db.close() + if not row: + raise HTTPException(404, "No research found for this job") + return dict(row) + + +@app.post("/api/jobs/{job_id}/research/generate") +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) + return {"task_id": task_id, "is_new": is_new} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.get("/api/jobs/{job_id}/research/task") +def research_task_status(job_id: int): + db = _get_db() + row = db.execute( + "SELECT status, stage, error FROM background_tasks " + "WHERE task_type = 'company_research' AND job_id = ? " + "ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + db.close() + if not row: + return {"status": "none", "stage": None, "message": None} + return {"status": row["status"], "stage": row["stage"], "message": row["error"]} + + +# ── ATS Resume Optimizer endpoints ─────────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/resume_optimizer") +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) + gap_report = result.get("ats_gap_report", "") + try: + gap_report_parsed = json.loads(gap_report) if gap_report else [] + except Exception: + gap_report_parsed = [] + return { + "optimized_resume": result.get("optimized_resume", ""), + "ats_gap_report": gap_report_parsed, + } + + +class ResumeOptimizeBody(BaseModel): + full_rewrite: bool = False + + +@app.post("/api/jobs/{job_id}/resume_optimizer/generate") +def generate_optimized_resume(job_id: int, body: ResumeOptimizeBody): + """Queue an ATS resume optimization task for this job. + + full_rewrite=False (default) → free tier: gap report only, no LLM rewrite. + full_rewrite=True → paid tier: per-section LLM rewrite + hallucination check. + """ + import json + try: + 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), + task_type="resume_optimize", + job_id=job_id, + params=params, + ) + return {"task_id": task_id, "is_new": is_new} + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.get("/api/jobs/{job_id}/resume_optimizer/task") +def resume_optimizer_task_status(job_id: int): + """Poll the latest resume_optimize task status for this job.""" + db = _get_db() + row = db.execute( + "SELECT status, stage, error FROM background_tasks " + "WHERE task_type = 'resume_optimize' AND job_id = ? " + "ORDER BY id DESC LIMIT 1", + (job_id,), + ).fetchone() + db.close() + if not row: + return {"status": "none", "stage": None, "message": None} + return {"status": row["status"], "stage": row["stage"], "message": row["error"]} + + +@app.get("/api/jobs/{job_id}/contacts") +def get_job_contacts(job_id: int): + db = _get_db() + rows = db.execute( + "SELECT id, direction, subject, from_addr, body, received_at " + "FROM job_contacts WHERE job_id = ? ORDER BY received_at DESC", + (job_id,), + ).fetchall() + db.close() + return [dict(r) for r in rows] + + +# ── Survey endpoints ───────────────────────────────────────────────────────── + +# Module-level imports so tests can patch dev_api.LLMRouter etc. +from scripts.llm_router import LLMRouter +from scripts.db import insert_survey_response, get_survey_responses + +_SURVEY_SYSTEM = ( + "You are a job application advisor helping a candidate answer a culture-fit survey. " + "The candidate values collaborative teamwork, clear communication, growth, and impact. " + "Choose answers that present them in the best professional light." +) + + +def _build_text_prompt(text: str, mode: str) -> str: + if mode == "quick": + return ( + "Answer each survey question below. For each, give ONLY the letter of the best " + "option and a single-sentence reason. Format exactly as:\n" + "1. B — reason here\n2. A — reason here\n\n" + f"Survey:\n{text}" + ) + return ( + "Analyze each survey question below. For each question:\n" + "- Briefly evaluate each option (1 sentence each)\n" + "- State your recommendation with reasoning\n\n" + f"Survey:\n{text}" + ) + + +def _build_image_prompt(mode: str) -> str: + if mode == "quick": + return ( + "This is a screenshot of a culture-fit survey. Read all questions and answer each " + "with the letter of the best option for a collaborative, growth-oriented candidate. " + "Format: '1. B — brief reason' on separate lines." + ) + return ( + "This is a screenshot of a culture-fit survey. For each question, evaluate each option " + "and recommend the best choice for a collaborative, growth-oriented candidate. " + "Include a brief breakdown per option and a clear recommendation." + ) + + +@app.get("/api/vision/health") +def vision_health(): + try: + r = requests.get("http://localhost:8002/health", timeout=2) + return {"available": r.status_code == 200} + except Exception: + return {"available": False} + + +class SurveyAnalyzeBody(BaseModel): + text: Optional[str] = None + image_b64: Optional[str] = None + mode: str # "quick" or "detailed" + + +@app.post("/api/jobs/{job_id}/survey/analyze") +def survey_analyze(job_id: int, body: SurveyAnalyzeBody): + if body.mode not in ("quick", "detailed"): + raise HTTPException(400, f"Invalid mode: {body.mode!r}") + try: + router = LLMRouter() + if body.image_b64: + prompt = _build_image_prompt(body.mode) + output = router.complete( + prompt, + images=[body.image_b64], + fallback_order=router.config.get("vision_fallback_order"), + ) + source = "screenshot" + else: + prompt = _build_text_prompt(body.text or "", body.mode) + output = router.complete( + prompt, + system=_SURVEY_SYSTEM, + fallback_order=router.config.get("research_fallback_order"), + ) + source = "text_paste" + return {"output": output, "source": source} + except Exception as e: + raise HTTPException(500, str(e)) + + +class SurveySaveBody(BaseModel): + survey_name: Optional[str] = None + mode: str + source: str + raw_input: Optional[str] = None + image_b64: Optional[str] = None + llm_output: str + reported_score: Optional[str] = None + + +@app.post("/api/jobs/{job_id}/survey/responses") +def save_survey_response(job_id: int, body: SurveySaveBody): + if body.mode not in ("quick", "detailed"): + raise HTTPException(400, f"Invalid mode: {body.mode!r}") + received_at = datetime.now().isoformat() + image_path = None + if body.image_b64: + try: + import base64 + screenshots_dir = Path(DB_PATH).parent / "survey_screenshots" / str(job_id) + screenshots_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + img_path = screenshots_dir / f"{timestamp}.png" + img_path.write_bytes(base64.b64decode(body.image_b64)) + image_path = str(img_path) + except Exception: + raise HTTPException(400, "Invalid image data") + row_id = insert_survey_response( + db_path=Path(DB_PATH), + job_id=job_id, + survey_name=body.survey_name, + received_at=received_at, + source=body.source, + raw_input=body.raw_input, + image_path=image_path, + mode=body.mode, + llm_output=body.llm_output, + reported_score=body.reported_score, + ) + return {"id": row_id} + + +@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) + + +# ── GET /api/jobs/:id/cover_letter/pdf ─────────────────────────────────────── + +@app.get("/api/jobs/{job_id}/cover_letter/pdf") +def download_pdf(job_id: int): + db = _get_db() + row = db.execute( + "SELECT title, company, cover_letter FROM jobs WHERE id = ?", (job_id,) + ).fetchone() + db.close() + if not row or not row["cover_letter"]: + raise HTTPException(404, "No cover letter found") + + try: + from reportlab.lib.pagesizes import letter as letter_size + from reportlab.lib.units import inch + from reportlab.lib.colors import HexColor + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.enums import TA_LEFT + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer + import io + + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=letter_size, + leftMargin=inch, rightMargin=inch, + topMargin=inch, bottomMargin=inch) + dark = HexColor("#1a2338") + body_style = ParagraphStyle( + "Body", fontName="Helvetica", fontSize=11, + textColor=dark, leading=16, spaceAfter=12, alignment=TA_LEFT, + ) + story = [] + for para in row["cover_letter"].split("\n\n"): + para = para.strip() + if para: + story.append(Paragraph(para.replace("\n", "
"), body_style)) + story.append(Spacer(1, 2)) + doc.build(story) + + company_safe = re.sub(r"[^a-zA-Z0-9]", "", row["company"] or "Company") + date_str = datetime.now().strftime("%Y-%m-%d") + filename = f"CoverLetter_{company_safe}_{date_str}.pdf" + + return Response( + content=buf.getvalue(), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except ImportError: + raise HTTPException(501, "reportlab not installed — install it to generate PDFs") + + +# ── GET /api/interviews ──────────────────────────────────────────────────────── + +PIPELINE_STATUSES = { + "applied", "survey", + "phone_screen", "interviewing", + "offer", "hired", + "interview_rejected", +} + +SIGNAL_EXCLUDED = ("neutral", "unrelated", "digest", "event_rescheduled") + +@app.get("/api/interviews") +def list_interviews(): + db = _get_db() + placeholders = ",".join("?" * len(PIPELINE_STATUSES)) + rows = db.execute( + f"SELECT id, title, company, url, location, is_remote, salary, " + f"match_score, keyword_gaps, status, " + f"interview_date, rejection_stage, " + f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at " + f"FROM jobs WHERE status IN ({placeholders}) " + f"ORDER BY match_score DESC NULLS LAST", + list(PIPELINE_STATUSES), + ).fetchall() + + job_ids = [r["id"] for r in rows] + signals_by_job: dict[int, list] = {r["id"]: [] for r in rows} + + if job_ids: + sig_placeholders = ",".join("?" * len(job_ids)) + excl_placeholders = ",".join("?" * len(SIGNAL_EXCLUDED)) + sig_rows = db.execute( + f"SELECT id, job_id, subject, received_at, stage_signal, body, from_addr " + f"FROM job_contacts " + f"WHERE job_id IN ({sig_placeholders}) " + f" AND suggestion_dismissed = 0 " + f" AND stage_signal NOT IN ({excl_placeholders}) " + f" AND stage_signal IS NOT NULL " + f"ORDER BY received_at DESC", + job_ids + list(SIGNAL_EXCLUDED), + ).fetchall() + for sr in sig_rows: + signals_by_job[sr["job_id"]].append({ + "id": sr["id"], + "subject": sr["subject"], + "received_at": sr["received_at"], + "stage_signal": sr["stage_signal"], + "body": _strip_html(sr["body"]), + "from_addr": sr["from_addr"], + }) + + db.close() + return [ + {**dict(r), "is_remote": bool(r["is_remote"]), "stage_signals": signals_by_job[r["id"]]} + for r in rows + ] + + +# ── POST /api/email/sync ────────────────────────────────────────────────── + +@app.post("/api/email/sync", status_code=202) +def trigger_email_sync(): + db = _get_db() + cursor = db.execute( + "INSERT INTO background_tasks (task_type, job_id, status) VALUES ('email_sync', 0, 'queued')" + ) + db.commit() + task_id = cursor.lastrowid + db.close() + return {"task_id": task_id} + + +# ── GET /api/email/sync/status ──────────────────────────────────────────── + +@app.get("/api/email/sync/status") +def email_sync_status(): + db = _get_db() + row = db.execute( + "SELECT status, finished_at AS last_completed_at " + "FROM background_tasks " + "WHERE task_type = 'email_sync' " + "ORDER BY id DESC LIMIT 1" + ).fetchone() + db.close() + if row is None: + return {"status": "idle", "last_completed_at": None, "error": None} + # background_tasks may not have an error column in staging — guard with dict access + row_dict = dict(row) + return { + "status": row_dict["status"], + "last_completed_at": row_dict["last_completed_at"], + "error": row_dict.get("error"), + } + + +# ── POST /api/stage-signals/{id}/dismiss ───────────────────────────────── + +@app.post("/api/stage-signals/{signal_id}/dismiss") +def dismiss_signal(signal_id: int): + db = _get_db() + result = db.execute( + "UPDATE job_contacts SET suggestion_dismissed = 1 WHERE id = ?", + (signal_id,), + ) + db.commit() + rowcount = result.rowcount + db.close() + if rowcount == 0: + raise HTTPException(404, "Signal not found") + return {"ok": True} + + +# ── POST /api/stage-signals/{id}/reclassify ────────────────────────────── + +VALID_SIGNAL_LABELS = { + 'interview_scheduled', 'offer_received', 'rejected', + 'positive_response', 'survey_received', 'neutral', + 'event_rescheduled', 'unrelated', 'digest', +} + +class ReclassifyBody(BaseModel): + stage_signal: str + +@app.post("/api/stage-signals/{signal_id}/reclassify") +def reclassify_signal(signal_id: int, body: ReclassifyBody): + if body.stage_signal not in VALID_SIGNAL_LABELS: + raise HTTPException(400, f"Invalid label: {body.stage_signal}") + db = _get_db() + result = db.execute( + "UPDATE job_contacts SET stage_signal = ? WHERE id = ?", + (body.stage_signal, signal_id), + ) + db.commit() + rowcount = result.rowcount + db.close() + if rowcount == 0: + raise HTTPException(404, "Signal not found") + return {"ok": True} + + +# ── Digest queue models ─────────────────────────────────────────────────── + +class DigestQueueBody(BaseModel): + job_contact_id: int + + +# ── GET /api/digest-queue ───────────────────────────────────────────────── + +@app.get("/api/digest-queue") +def list_digest_queue(): + db = _get_db() + rows = db.execute( + """SELECT dq.id, dq.job_contact_id, dq.created_at, + jc.subject, jc.from_addr, jc.received_at, jc.body + FROM digest_queue dq + JOIN job_contacts jc ON jc.id = dq.job_contact_id + ORDER BY dq.created_at DESC""" + ).fetchall() + db.close() + return [ + { + "id": r["id"], + "job_contact_id": r["job_contact_id"], + "created_at": r["created_at"], + "subject": r["subject"], + "from_addr": r["from_addr"], + "received_at": r["received_at"], + "body": _strip_html(r["body"]), + } + for r in rows + ] + + +# ── POST /api/digest-queue ──────────────────────────────────────────────── + +@app.post("/api/digest-queue") +def add_to_digest_queue(body: DigestQueueBody): + db = _get_db() + try: + exists = db.execute( + "SELECT 1 FROM job_contacts WHERE id = ?", (body.job_contact_id,) + ).fetchone() + if not exists: + raise HTTPException(404, "job_contact_id not found") + result = db.execute( + "INSERT OR IGNORE INTO digest_queue (job_contact_id) VALUES (?)", + (body.job_contact_id,), + ) + db.commit() + created = result.rowcount > 0 + finally: + db.close() + return {"ok": True, "created": created} + + +# ── POST /api/digest-queue/{id}/extract-links ───────────────────────────── + +@app.post("/api/digest-queue/{digest_id}/extract-links") +def extract_digest_links(digest_id: int): + db = _get_db() + try: + row = db.execute( + """SELECT jc.body + FROM digest_queue dq + JOIN job_contacts jc ON jc.id = dq.job_contact_id + WHERE dq.id = ?""", + (digest_id,), + ).fetchone() + finally: + db.close() + if not row: + raise HTTPException(404, "Digest entry not found") + return {"links": _extract_links(row["body"] or "")} + + +# ── POST /api/digest-queue/{id}/queue-jobs ──────────────────────────────── + +class QueueJobsBody(BaseModel): + urls: list[str] + + +@app.post("/api/digest-queue/{digest_id}/queue-jobs") +def queue_digest_jobs(digest_id: int, body: QueueJobsBody): + if not body.urls: + raise HTTPException(400, "urls must not be empty") + db = _get_db() + try: + exists = db.execute( + "SELECT 1 FROM digest_queue WHERE id = ?", (digest_id,) + ).fetchone() + finally: + db.close() + if not exists: + raise HTTPException(404, "Digest entry not found") + + try: + from scripts.db import insert_job + except ImportError: + raise HTTPException(500, "scripts.db not available") + queued = 0 + skipped = 0 + for url in body.urls: + if not url or not url.startswith(('http://', 'https://')): + skipped += 1 + continue + result = insert_job(Path(DB_PATH), { + 'url': url, + 'title': '', + 'company': '', + 'source': 'digest', + 'date_found': datetime.utcnow().isoformat(), + }) + if result: + queued += 1 + else: + skipped += 1 + return {"ok": True, "queued": queued, "skipped": skipped} + + +# ── DELETE /api/digest-queue/{id} ──────────────────────────────────────── + +@app.delete("/api/digest-queue/{digest_id}") +def delete_digest_entry(digest_id: int): + db = _get_db() + try: + result = db.execute("DELETE FROM digest_queue WHERE id = ?", (digest_id,)) + db.commit() + rowcount = result.rowcount + finally: + db.close() + if rowcount == 0: + raise HTTPException(404, "Digest entry not found") + return {"ok": True} + + +# ── POST /api/jobs/{id}/move ─────────────────────────────────────────────────── + +STATUS_TIMESTAMP_COL = { + "applied": "applied_at", + "survey": "survey_at", + "phone_screen": "phone_screen_at", + "interviewing": "interviewing_at", + "offer": "offer_at", + "hired": "hired_at", + "interview_rejected": None, # uses rejection_stage instead +} + +class MoveBody(BaseModel): + status: str + interview_date: str | None = None + rejection_stage: str | None = None + +@app.post("/api/jobs/{job_id}/move") +def move_job(job_id: int, body: MoveBody): + if body.status not in STATUS_TIMESTAMP_COL: + raise HTTPException(400, f"Invalid pipeline status: {body.status}") + db = _get_db() + ts_col = STATUS_TIMESTAMP_COL[body.status] + if ts_col: + db.execute( + f"UPDATE jobs SET status = ?, {ts_col} = datetime('now') WHERE id = ?", + (body.status, job_id), + ) + else: + db.execute( + "UPDATE jobs SET status = ?, rejection_stage = ? WHERE id = ?", + (body.status, body.rejection_stage, job_id), + ) + if body.interview_date is not None: + db.execute( + "UPDATE jobs SET interview_date = ? WHERE id = ?", + (body.interview_date, job_id), + ) + db.commit() + db.close() + return {"ok": True} + + +# ── GET /api/config/app ─────────────────────────────────────────────────────── + +@app.get("/api/config/app") +def get_app_config(): + import os + 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") + return { + "isCloud": os.environ.get("CLOUD_MODE", "").lower() in ("1", "true"), + "isDevMode": os.environ.get("DEV_MODE", "").lower() in ("1", "true"), + "tier": raw_tier if raw_tier in valid_tiers else "free", + "contractedClient": os.environ.get("CONTRACTED_CLIENT", "").lower() in ("1", "true"), + "inferenceProfile": profile if profile in valid_profiles else "cpu", + } + + +# ── GET /api/config/user ────────────────────────────────────────────────────── + +@app.get("/api/config/user") +def config_user(): + # Try to read name from user.yaml if present + try: + import yaml + cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml") + if not os.path.exists(cfg_path): + cfg_path = "/devl/job-seeker/config/user.yaml" + with open(cfg_path) as f: + cfg = yaml.safe_load(f) + return {"name": cfg.get("name", "")} + except Exception: + return {"name": ""} + + +# ── Settings: My Profile endpoints ─────────────────────────────────────────── + +from scripts.user_profile import load_user_profile, save_user_profile + + +def _user_yaml_path() -> str: + """Resolve user.yaml path, falling back to legacy location.""" + cfg_path = os.path.join(os.path.dirname(DB_PATH), "config", "user.yaml") + if not os.path.exists(cfg_path): + cfg_path = "/devl/job-seeker/config/user.yaml" + return cfg_path + + +def _mission_dict_to_list(prefs: object) -> list: + """Convert {industry: note} dict to [{industry, note}] list for the SPA.""" + if isinstance(prefs, list): + return prefs + if isinstance(prefs, dict): + return [{"industry": k, "note": v or ""} for k, v in prefs.items()] + return [] + + +def _mission_list_to_dict(prefs: list) -> dict: + """Convert [{industry, note}] list from the SPA back to {industry: note} dict.""" + result = {} + for item in prefs: + if isinstance(item, dict): + result[item.get("industry", "")] = item.get("note", "") + return result + + +@app.get("/api/settings/profile") +def get_profile(): + try: + cfg = load_user_profile(_user_yaml_path()) + return { + "name": cfg.get("name", ""), + "email": cfg.get("email", ""), + "phone": cfg.get("phone", ""), + "linkedin_url": cfg.get("linkedin", ""), + "career_summary": cfg.get("career_summary", ""), + "candidate_voice": cfg.get("candidate_voice", ""), + "inference_profile": cfg.get("inference_profile", "cpu"), + "mission_preferences": _mission_dict_to_list(cfg.get("mission_preferences", {})), + "nda_companies": cfg.get("nda_companies", []), + "accessibility_focus": cfg.get("candidate_accessibility_focus", False), + "lgbtq_focus": cfg.get("candidate_lgbtq_focus", False), + } + except Exception as e: + raise HTTPException(500, f"Could not read profile: {e}") + + +class MissionPrefModel(BaseModel): + industry: str + note: str = "" + + +class UserProfilePayload(BaseModel): + name: str = "" + email: str = "" + phone: str = "" + linkedin_url: str = "" + career_summary: str = "" + candidate_voice: str = "" + inference_profile: str = "cpu" + mission_preferences: List[MissionPrefModel] = [] + nda_companies: List[str] = [] + accessibility_focus: bool = False + lgbtq_focus: bool = False + + +class IdentitySyncPayload(BaseModel): + name: str = "" + email: str = "" + phone: str = "" + linkedin_url: str = "" + +@app.post("/api/settings/resume/sync-identity") +def sync_identity(payload: IdentitySyncPayload): + """Sync identity fields from profile store back to user.yaml.""" + try: + data = load_user_profile(_user_yaml_path()) + data["name"] = payload.name + data["email"] = payload.email + data["phone"] = payload.phone + data["linkedin"] = payload.linkedin_url # yaml key is 'linkedin', not 'linkedin_url' + save_user_profile(_user_yaml_path(), data) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/profile") +def save_profile(payload: UserProfilePayload): + try: + yaml_path = _user_yaml_path() + cfg = load_user_profile(yaml_path) + cfg["name"] = payload.name + cfg["email"] = payload.email + cfg["phone"] = payload.phone + cfg["linkedin"] = payload.linkedin_url + cfg["career_summary"] = payload.career_summary + cfg["candidate_voice"] = payload.candidate_voice + cfg["inference_profile"] = payload.inference_profile + cfg["mission_preferences"] = _mission_list_to_dict( + [m.model_dump() for m in payload.mission_preferences] + ) + cfg["nda_companies"] = payload.nda_companies + cfg["candidate_accessibility_focus"] = payload.accessibility_focus + cfg["candidate_lgbtq_focus"] = payload.lgbtq_focus + save_user_profile(yaml_path, cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(500, f"Could not save profile: {e}") + + +# ── Settings: Resume Profile endpoints ─────────────────────────────────────── + +class WorkEntry(BaseModel): + title: str = ""; company: str = ""; period: str = ""; location: str = "" + industry: str = ""; responsibilities: str = ""; skills: List[str] = [] + +class ResumePayload(BaseModel): + name: str = ""; email: str = ""; phone: str = ""; linkedin_url: str = "" + surname: str = ""; address: str = ""; city: str = ""; zip_code: str = ""; date_of_birth: str = "" + experience: List[WorkEntry] = [] + salary_min: int = 0; salary_max: int = 0; notice_period: str = "" + remote: bool = False; relocation: bool = False + assessment: bool = False; background_check: bool = False + gender: str = ""; pronouns: str = ""; ethnicity: str = "" + veteran_status: str = ""; disability: str = "" + skills: List[str] = []; domains: List[str] = []; keywords: List[str] = [] + +RESUME_PATH = Path("config/plain_text_resume.yaml") + +@app.get("/api/settings/resume") +def get_resume(): + try: + if not RESUME_PATH.exists(): + return {"exists": False} + with open(RESUME_PATH) as f: + data = yaml.safe_load(f) or {} + data["exists"] = True + return data + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/resume") +def save_resume(payload: ResumePayload): + try: + RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(RESUME_PATH, "w") as f: + yaml.dump(payload.model_dump(), f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/resume/blank") +def create_blank_resume(): + try: + RESUME_PATH.parent.mkdir(parents=True, exist_ok=True) + if not RESUME_PATH.exists(): + with open(RESUME_PATH, "w") as f: + yaml.dump({}, f) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/resume/upload") +async def upload_resume(file: UploadFile): + try: + from scripts.resume_parser import structure_resume + import tempfile, os + suffix = Path(file.filename).suffix.lower() + tmp_path = None + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(await file.read()) + tmp_path = tmp.name + try: + result, err = structure_resume(tmp_path) + finally: + if tmp_path: + os.unlink(tmp_path) + if err: + return {"ok": False, "error": err, "data": result} + result["exists"] = True + return {"ok": True, "data": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Search Preferences endpoints ──────────────────────────────────── + +class SearchPrefsPayload(BaseModel): + remote_preference: str = "both" + job_titles: List[str] = [] + locations: List[str] = [] + exclude_keywords: List[str] = [] + job_boards: List[dict] = [] + custom_board_urls: List[str] = [] + blocklist_companies: List[str] = [] + blocklist_industries: List[str] = [] + blocklist_locations: List[str] = [] + +SEARCH_PREFS_PATH = Path("config/search_profiles.yaml") + +@app.get("/api/settings/search") +def get_search_prefs(): + try: + if not SEARCH_PREFS_PATH.exists(): + return {} + with open(SEARCH_PREFS_PATH) as f: + data = yaml.safe_load(f) or {} + return data.get("default", {}) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/search") +def save_search_prefs(payload: SearchPrefsPayload): + try: + data = {} + if SEARCH_PREFS_PATH.exists(): + with open(SEARCH_PREFS_PATH) as f: + data = yaml.safe_load(f) or {} + data["default"] = payload.model_dump() + with open(SEARCH_PREFS_PATH, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/search/suggest") +def suggest_search(body: dict): + try: + # Stub — LLM suggest for paid tier + return {"suggestions": []} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — LLM Backends + BYOK endpoints ───────────────────────── + +class ByokAckPayload(BaseModel): + backends: List[str] = [] + +class LlmConfigPayload(BaseModel): + backends: List[dict] = [] + +LLM_CONFIG_PATH = Path("config/llm.yaml") + +@app.get("/api/settings/system/llm") +def get_llm_config(): + try: + user = load_user_profile(_user_yaml_path()) + backends = [] + if LLM_CONFIG_PATH.exists(): + with open(LLM_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + backends = data.get("backends", []) + return {"backends": backends, "byok_acknowledged": user.get("byok_acknowledged_backends", [])} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/settings/system/llm") +def save_llm_config(payload: LlmConfigPayload): + try: + data = {} + if LLM_CONFIG_PATH.exists(): + with open(LLM_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + data["backends"] = payload.backends + LLM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LLM_CONFIG_PATH, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/settings/system/llm/byok-ack") +def byok_ack(payload: ByokAckPayload): + try: + user = load_user_profile(_user_yaml_path()) + existing = user.get("byok_acknowledged_backends", []) + user["byok_acknowledged_backends"] = list(set(existing + payload.backends)) + save_user_profile(_user_yaml_path(), user) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Services ─────────────────────────────────────────────── + +SERVICES_REGISTRY = [ + {"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]}, + {"name": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]}, + {"name": "vision", "port": 8002, "compose_service": "vision", "note": "Moondream2 vision", "profiles": ["single-gpu","dual-gpu"]}, + {"name": "searxng", "port": 8888, "compose_service": "searxng", "note": "Search engine", "profiles": ["cpu","remote","single-gpu","dual-gpu"]}, +] + + +def _port_open(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + return s.connect_ex(("127.0.0.1", port)) == 0 + + +@app.get("/api/settings/system/services") +def get_services(): + try: + profile = os.environ.get("INFERENCE_PROFILE", "cpu") + result = [] + for svc in SERVICES_REGISTRY: + if profile not in svc["profiles"]: + continue + result.append({"name": svc["name"], "port": svc["port"], + "running": _port_open(svc["port"]), "note": svc["note"]}) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/services/{name}/start") +def start_service(name: str): + try: + svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None) + if not svc: + raise HTTPException(404, "Unknown service") + r = subprocess.run(["docker", "compose", "up", "-d", svc["compose_service"]], + capture_output=True, text=True) + return {"ok": r.returncode == 0, "output": r.stdout + r.stderr} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/services/{name}/stop") +def stop_service(name: str): + try: + svc = next((s for s in SERVICES_REGISTRY if s["name"] == name), None) + if not svc: + raise HTTPException(404, "Unknown service") + r = subprocess.run(["docker", "compose", "stop", svc["compose_service"]], + capture_output=True, text=True) + return {"ok": r.returncode == 0, "output": r.stdout + r.stderr} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Email ────────────────────────────────────────────────── + +EMAIL_PATH = Path("config/email.yaml") +EMAIL_CRED_SERVICE = "peregrine" +EMAIL_CRED_KEY = "imap_password" + +# Non-secret fields stored in yaml +EMAIL_YAML_FIELDS = ("host", "port", "ssl", "username", "sent_folder", "lookback_days") + + +@app.get("/api/settings/system/email") +def get_email_config(): + try: + config = {} + if EMAIL_PATH.exists(): + with open(EMAIL_PATH) as f: + config = yaml.safe_load(f) or {} + # Never return the password — only indicate whether it's set + password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY) + config["password_set"] = bool(password) + config.pop("password", None) # strip if somehow in yaml + return config + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/email") +def save_email_config(payload: dict): + try: + EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True) + # Extract password before writing yaml; discard the sentinel boolean regardless + password = payload.pop("password", None) + payload.pop("password_set", None) # always discard — boolean sentinel, not a secret + if password and isinstance(password, str): + set_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY, password) + # Write non-secret fields to yaml (chmod 600 still, contains username) + safe_config = {k: v for k, v in payload.items() if k in EMAIL_YAML_FIELDS} + fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/email/test") +def test_email(payload: dict): + try: + # Always use the stored credential — never accept a password in the test request body + password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY) + host = payload.get("host", "") + port = int(payload.get("port", 993)) + use_ssl = payload.get("ssl", True) + username = payload.get("username", "") + if not all([host, username, password]): + return {"ok": False, "error": "Missing host, username, or password"} + if use_ssl: + ctx = ssl_mod.create_default_context() + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) + else: + conn = imaplib.IMAP4(host, port) + conn.login(username, password) + conn.logout() + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +# ── Settings: System — Integrations ────────────────────────────────────────── + +@app.get("/api/settings/system/integrations") +def get_integrations(): + try: + from scripts.integrations import REGISTRY + result = [] + for integration in REGISTRY: + result.append({ + "id": integration.id, + "name": integration.name, + "connected": integration.is_connected(), + "tier_required": getattr(integration, "tier_required", "free"), + "fields": [{"key": f["key"], "label": f["label"], "type": f.get("type", "text")} + for f in integration.fields()], + }) + return result + except ImportError: + return [] # integrations module not yet implemented + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/test") +def test_integration(integration_id: str, payload: dict): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + ok, error = integration.test(payload) + return {"ok": ok, "error": error} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/connect") +def connect_integration(integration_id: str, payload: dict): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + ok, error = integration.test(payload) + if not ok: + return {"ok": False, "error": error} + integration.save_credentials(payload) + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/system/integrations/{integration_id}/disconnect") +def disconnect_integration(integration_id: str): + try: + from scripts.integrations import REGISTRY + integration = next((i for i in REGISTRY if i.id == integration_id), None) + if not integration: + raise HTTPException(404, "Unknown integration") + integration.remove_credentials() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — File Paths ───────────────────────────────────────────── + +@app.get("/api/settings/system/paths") +def get_file_paths(): + try: + user = load_user_profile(_user_yaml_path()) + return { + "docs_dir": user.get("docs_dir", ""), + "data_dir": user.get("data_dir", ""), + "model_dir": user.get("model_dir", ""), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/paths") +def save_file_paths(payload: dict): + try: + user = load_user_profile(_user_yaml_path()) + for key in ("docs_dir", "data_dir", "model_dir"): + if key in payload: + user[key] = payload[key] + save_user_profile(_user_yaml_path(), user) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: System — Deployment Config ───────────────────────────────────── + +@app.get("/api/settings/system/deploy") +def get_deploy_config(): + try: + return { + "base_url_path": os.environ.get("STREAMLIT_SERVER_BASE_URL_PATH", ""), + "server_host": os.environ.get("STREAMLIT_SERVER_ADDRESS", "0.0.0.0"), + "server_port": int(os.environ.get("STREAMLIT_SERVER_PORT", "8502")), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/system/deploy") +def save_deploy_config(payload: dict): + # Deployment config changes require restart; just acknowledge + return {"ok": True, "note": "Restart required to apply changes"} + + +# ── Settings: Fine-Tune ─────────────────────────────────────────────────────── + +@app.get("/api/settings/fine-tune/status") +def finetune_status(): + try: + from scripts.task_runner import get_task_status + task = get_task_status("finetune_extract") + if not task: + return {"status": "idle", "pairs_count": 0} + return {"status": task.get("status", "idle"), "pairs_count": task.get("result_count", 0)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/fine-tune/extract") +def finetune_extract(): + try: + from scripts.task_runner import submit_task + task_id = submit_task(DB_PATH, "finetune_extract", None) + return {"task_id": str(task_id)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/fine-tune/upload") +async def finetune_upload(files: list[UploadFile]): + try: + upload_dir = Path("data/finetune_uploads") + upload_dir.mkdir(parents=True, exist_ok=True) + saved = [] + for f in files: + dest = upload_dir / (f.filename or "upload.bin") + content = await f.read() + fd = os.open(str(dest), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as out: + out.write(content) + saved.append(str(dest)) + return {"file_count": len(saved), "paths": saved} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/fine-tune/submit") +def finetune_submit(): + try: + # Cloud-only: submit a managed fine-tune job + # In dev mode, stub a job_id for local testing + import uuid + job_id = str(uuid.uuid4()) + return {"job_id": job_id} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/settings/fine-tune/local-status") +def finetune_local_status(): + try: + import subprocess + result = subprocess.run( + ["ollama", "list"], capture_output=True, text=True, timeout=5 + ) + model_ready = "alex-cover-writer" in (result.stdout or "") + return {"model_ready": model_ready} + except Exception: + return {"model_ready": False} + + +# ── Settings: License ───────────────────────────────────────────────────────── + +# CONFIG_DIR resolves relative to staging.db location (same convention as _user_yaml_path) +CONFIG_DIR = Path(os.path.dirname(DB_PATH)) / "config" +if not CONFIG_DIR.exists(): + CONFIG_DIR = Path("/devl/job-seeker/config") + +LICENSE_PATH = CONFIG_DIR / "license.yaml" + + +def _load_user_config() -> dict: + """Load user.yaml using the same path logic as _user_yaml_path().""" + return load_user_profile(_user_yaml_path()) + + +def _save_user_config(cfg: dict) -> None: + """Save user.yaml using the same path logic as _user_yaml_path().""" + save_user_profile(_user_yaml_path(), cfg) + + +@app.get("/api/settings/license") +def get_license(): + try: + if LICENSE_PATH.exists(): + with open(LICENSE_PATH) as f: + data = yaml.safe_load(f) or {} + else: + data = {} + return { + "tier": data.get("tier", "free"), + "key": data.get("key"), + "active": bool(data.get("active", False)), + "grace_period_ends": data.get("grace_period_ends"), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class LicenseActivatePayload(BaseModel): + key: str + +@app.post("/api/settings/license/activate") +def activate_license(payload: LicenseActivatePayload): + try: + # In dev: accept any key matching our format, grant paid tier + key = payload.key.strip() + if not re.match(r'^CFG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$', key): + return {"ok": False, "error": "Invalid key format"} + data = {"tier": "paid", "key": key, "active": True} + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True, "tier": "paid"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/license/deactivate") +def deactivate_license(): + try: + if LICENSE_PATH.exists(): + with open(LICENSE_PATH) as f: + data = yaml.safe_load(f) or {} + data["active"] = False + fd = os.open(str(LICENSE_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Data ──────────────────────────────────────────────────────────── + +class BackupCreatePayload(BaseModel): + include_db: bool = False + +@app.post("/api/settings/data/backup/create") +def create_backup(payload: BackupCreatePayload): + try: + import zipfile + import datetime + backup_dir = Path("data/backups") + backup_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dest = backup_dir / f"peregrine_backup_{ts}.zip" + file_count = 0 + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf: + for cfg_file in CONFIG_DIR.glob("*.yaml"): + if cfg_file.name not in ("tokens.yaml",): + zf.write(cfg_file, f"config/{cfg_file.name}") + file_count += 1 + if payload.include_db: + db_path = Path(DB_PATH) + if db_path.exists(): + zf.write(db_path, "data/staging.db") + file_count += 1 + size_bytes = dest.stat().st_size + return {"path": str(dest), "file_count": file_count, "size_bytes": size_bytes} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Privacy ───────────────────────────────────────────────────────── + +PRIVACY_YAML_FIELDS = {"telemetry_opt_in", "byok_info_dismissed", "master_off", "usage_events", "content_sharing"} + +@app.get("/api/settings/privacy") +def get_privacy(): + try: + cfg = _load_user_config() + return { + "telemetry_opt_in": bool(cfg.get("telemetry_opt_in", False)), + "byok_info_dismissed": bool(cfg.get("byok_info_dismissed", False)), + "master_off": bool(cfg.get("master_off", False)), + "usage_events": cfg.get("usage_events", True), + "content_sharing": bool(cfg.get("content_sharing", False)), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.put("/api/settings/privacy") +def save_privacy(payload: dict): + try: + cfg = _load_user_config() + for k, v in payload.items(): + if k in PRIVACY_YAML_FIELDS: + cfg[k] = v + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Settings: Developer ─────────────────────────────────────────────────────── + +TOKENS_PATH = CONFIG_DIR / "tokens.yaml" + +@app.get("/api/settings/developer") +def get_developer(): + try: + cfg = _load_user_config() + tokens = {} + if TOKENS_PATH.exists(): + with open(TOKENS_PATH) as f: + tokens = yaml.safe_load(f) or {} + return { + "dev_tier_override": cfg.get("dev_tier_override"), + "hf_token_set": bool(tokens.get("huggingface_token")), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class DevTierPayload(BaseModel): + tier: Optional[str] + +@app.put("/api/settings/developer/tier") +def set_dev_tier(payload: DevTierPayload): + try: + cfg = _load_user_config() + cfg["dev_tier_override"] = payload.tier + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +class HfTokenPayload(BaseModel): + token: str + +@app.put("/api/settings/developer/hf-token") +def save_hf_token(payload: HfTokenPayload): + try: + set_credential("peregrine_tokens", "huggingface_token", payload.token) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/developer/hf-token/test") +def test_hf_token(): + try: + token = get_credential("peregrine_tokens", "huggingface_token") + if not token: + return {"ok": False, "error": "No token stored"} + from huggingface_hub import whoami + info = whoami(token=token) + return {"ok": True, "username": info.get("name")} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.post("/api/settings/developer/wizard-reset") +def wizard_reset(): + try: + cfg = _load_user_config() + cfg["wizard_complete"] = False + _save_user_config(cfg) + return {"ok": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/settings/developer/export-classifier") +def export_classifier(): + try: + import json as _json + from scripts.db import get_labeled_emails + emails = get_labeled_emails(DB_PATH) + export_path = Path("data/email_score.jsonl") + export_path.parent.mkdir(parents=True, exist_ok=True) + with open(export_path, "w") as f: + for e in emails: + f.write(_json.dumps(e) + "\n") + return {"ok": True, "count": len(emails), "path": str(export_path)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/dev_api.py b/dev_api.py new file mode 120000 index 0000000..6b8c5e9 --- /dev/null +++ b/dev_api.py @@ -0,0 +1 @@ +dev-api.py \ No newline at end of file diff --git a/docs/vue-spa-migration.md b/docs/vue-spa-migration.md new file mode 100644 index 0000000..5eab5f4 --- /dev/null +++ b/docs/vue-spa-migration.md @@ -0,0 +1,174 @@ +# Peregrine Vue 3 SPA Migration + +**Branch:** `feature/vue-spa` +**Issue:** #8 — Vue 3 SPA frontend (Paid Tier GA milestone) +**Worktree:** `.worktrees/feature-vue-spa/` +**Reference:** `avocet/docs/vue-port-gotchas.md` (15 battle-tested gotchas) + +--- + +## What We're Replacing + +The current Streamlit UI (`app/app.py` + `app/pages/`) is an internal tool built for speed of development. The Vue SPA replaces it with a proper frontend — faster, more accessible, and extensible for the Paid Tier. The FastAPI already exists (partially, from the cloud managed instance work); the Vue SPA will consume it. + +### Pages to Port + +| Streamlit file | Vue view | Route | Notes | +|---|---|---|---| +| `app/Home.py` | `HomeView.vue` | `/` | Dashboard, discovery trigger, sync status | +| `app/pages/1_Job_Review.py` | `JobReviewView.vue` | `/review` | Batch approve/reject; primary daily-driver view | +| `app/pages/4_Apply.py` | `ApplyView.vue` | `/apply` | Cover letter gen + PDF + mark applied | +| `app/pages/5_Interviews.py` | `InterviewsView.vue` | `/interviews` | Kanban: phone_screen → offer → hired | +| `app/pages/6_Interview_Prep.py` | `InterviewPrepView.vue` | `/prep` | Live reference sheet + practice Q&A | +| `app/pages/7_Survey.py` | `SurveyView.vue` | `/survey` | Culture-fit survey assist + screenshot | +| `app/pages/2_Settings.py` | `SettingsView.vue` | `/settings` | 6 tabs: Profile, Resume, Search, System, Fine-Tune, License | + +--- + +## Avocet Lessons Applied — What We Fixed Before Starting + +The avocet SPA was the testbed. These bugs were found and fixed there; Peregrine's scaffold already incorporates all fixes. See `avocet/docs/vue-port-gotchas.md` for the full writeup. + +### Applied at scaffold level (baked in — you don't need to think about these) + +| # | Gotcha | How it's fixed in this scaffold | +|---|--------|----------------------------------| +| 1 | `id="app"` on App.vue root → nested `#app` elements, broken CSS specificity | `App.vue` root uses `class="app-root"`. `#app` in `index.html` is mount target only. | +| 3 | `overflow-x: hidden` on html → creates scroll container → 15px scrollbar jitter on Linux | `peregrine.css`: `html { overflow-x: clip }` | +| 4 | UnoCSS `presetAttributify` generates CSS for bare attribute names like `h2` | `uno.config.ts`: `presetAttributify({ prefix: 'un-', prefixedOnly: true })` | +| 5 | Theme variable name mismatches cause dark mode to silently fall back to hardcoded colors | `peregrine.css` alias map: `--color-bg → var(--color-surface)`, `--color-text-secondary → var(--color-text-muted)` | +| 7 | SPA cache: browser caches `index.html` indefinitely → old asset hashes → 404 on rebuild | FastAPI must register explicit `GET /` with no-cache headers before `StaticFiles` mount (see FastAPI section below) | +| 9 | `navigator.vibrate()` not supported on desktop/Safari — throws on call | `useHaptics.ts` guards with `'vibrate' in navigator` | +| 10 | Pinia options store = Vue 2 migration path | All stores use setup store form: `defineStore('id', () => { ... })` | +| 12 | `matchMedia`, `vibrate`, `ResizeObserver` absent in jsdom → composable tests throw | `test-setup.ts` stubs all three | +| 13 | `100vh` ignores mobile browser chrome | `App.vue`: `min-height: 100dvh` | + +### Must actively avoid when writing new components + +| # | Gotcha | Rule | +|---|--------|------| +| 2 | `transition: all` + spring easing → every CSS property bounces → layout explosion | Always enumerate: `transition: background 200ms ease, transform 250ms cubic-bezier(...)` | +| 6 | Keyboard composables called with snapshot arrays → keys don't work after async data loads | Accept `getLabels: () => labels.value` (reactive getter), not `labels: []` (snapshot) | +| 8 | Font reflow at ~780ms shifts layout measurements taken in `onMounted` | Measure layout in `document.fonts.ready` promise or after 1s timeout | +| 11 | `useSwipe` from `@vueuse/core` fires on desktop trackpad pointer events, not just touch | Add `pointer-type === 'touch'` guard if you need touch-only behavior | +| 14 | Rebuild workflow confusion | `cd web && npm run build` → refresh browser. Only restart FastAPI if `app/api.py` changed. | +| 15 | `:global(ancestor) .descendant` in `", "", body, flags=re.I) + body = re.sub(r"", "", body, flags=re.I) + else: + body = plain_body + mid = msg.get("Message-ID", "").strip() if not mid: return None # No Message-ID → can't dedup; skip to avoid repeat inserts @@ -723,7 +745,7 @@ def _parse_message(conn: imaplib.IMAP4, uid: bytes) -> Optional[dict]: "from_addr": _decode_str(msg.get("From")), "to_addr": _decode_str(msg.get("To")), "date": _decode_str(msg.get("Date")), - "body": body[:4000], + "body": body, # no truncation — digest emails need full content } except Exception: return None diff --git a/scripts/user_profile.py b/scripts/user_profile.py index 25109de..eae7982 100644 --- a/scripts/user_profile.py +++ b/scripts/user_profile.py @@ -7,6 +7,8 @@ here so port/host/SSL changes propagate everywhere automatically. """ from __future__ import annotations from pathlib import Path +import os +import tempfile import yaml _DEFAULTS = { @@ -161,3 +163,30 @@ class UserProfile: "ollama_research": f"{self.ollama_url}/v1", "vllm": f"{self.vllm_url}/v1", } + + +# ── Free functions for plain-dict access (used by dev-api.py) ───────────────── + +def load_user_profile(config_path: str) -> dict: + """Load user.yaml and return as a plain dict with safe defaults.""" + path = Path(config_path) + if not path.exists(): + return {} + with open(path) as f: + data = yaml.safe_load(f) or {} + return data + + +def save_user_profile(config_path: str, data: dict) -> None: + """Atomically write the user profile dict to user.yaml.""" + path = Path(config_path) + path.parent.mkdir(parents=True, exist_ok=True) + # Write to temp file then rename for atomicity + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix='.yaml.tmp') + try: + with os.fdopen(fd, 'w') as f: + yaml.dump(data, f, allow_unicode=True, default_flow_style=False) + os.replace(tmp, path) + except Exception: + os.unlink(tmp) + raise diff --git a/tests/test_dev_api_digest.py b/tests/test_dev_api_digest.py new file mode 100644 index 0000000..71a0a08 --- /dev/null +++ b/tests/test_dev_api_digest.py @@ -0,0 +1,238 @@ +"""Tests for digest queue API endpoints.""" +import sqlite3 +import os +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def tmp_db(tmp_path): + """Create minimal schema in a temp dir with one job_contacts row.""" + db_path = str(tmp_path / "staging.db") + con = sqlite3.connect(db_path) + con.executescript(""" + CREATE TABLE jobs ( + id INTEGER PRIMARY KEY, + title TEXT, company TEXT, url TEXT UNIQUE, location TEXT, + is_remote INTEGER DEFAULT 0, salary TEXT, + match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending', + date_found TEXT, description TEXT, source TEXT + ); + CREATE TABLE job_contacts ( + id INTEGER PRIMARY KEY, + job_id INTEGER, + subject TEXT, + received_at TEXT, + stage_signal TEXT, + suggestion_dismissed INTEGER DEFAULT 0, + body TEXT, + from_addr TEXT + ); + CREATE TABLE digest_queue ( + id INTEGER PRIMARY KEY, + job_contact_id INTEGER NOT NULL REFERENCES job_contacts(id), + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(job_contact_id) + ); + INSERT INTO jobs (id, title, company, url, status, source, date_found) + VALUES (1, 'Engineer', 'Acme', 'https://acme.com/job/1', 'applied', 'test', '2026-03-19'); + INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, body, from_addr) + VALUES ( + 10, 1, 'TechCrunch Jobs Weekly', '2026-03-19T10:00:00', 'digest', + 'Apply at Senior Engineer or Staff Designer. Unsubscribe: https://unsubscribe.example.com/remove', + 'digest@techcrunch.com' + ); + """) + con.close() + return db_path + + +@pytest.fixture() +def client(tmp_db, monkeypatch): + monkeypatch.setenv("STAGING_DB", tmp_db) + import dev_api + monkeypatch.setattr(dev_api, "DB_PATH", tmp_db) + return TestClient(dev_api.app) + + +# ── GET /api/digest-queue ─────────────────────────────────────────────────── + +def test_digest_queue_list_empty(client): + resp = client.get("/api/digest-queue") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_digest_queue_list_with_entry(client, tmp_db): + con = sqlite3.connect(tmp_db) + con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (10)") + con.commit() + con.close() + + resp = client.get("/api/digest-queue") + assert resp.status_code == 200 + entries = resp.json() + assert len(entries) == 1 + assert entries[0]["job_contact_id"] == 10 + assert entries[0]["subject"] == "TechCrunch Jobs Weekly" + assert entries[0]["from_addr"] == "digest@techcrunch.com" + assert "body" in entries[0] + assert "created_at" in entries[0] + + +# ── POST /api/digest-queue ────────────────────────────────────────────────── + +def test_digest_queue_add(client, tmp_db): + resp = client.post("/api/digest-queue", json={"job_contact_id": 10}) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["created"] is True + + con = sqlite3.connect(tmp_db) + row = con.execute("SELECT * FROM digest_queue WHERE job_contact_id = 10").fetchone() + con.close() + assert row is not None + + +def test_digest_queue_add_duplicate(client): + client.post("/api/digest-queue", json={"job_contact_id": 10}) + resp = client.post("/api/digest-queue", json={"job_contact_id": 10}) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["created"] is False + + +def test_digest_queue_add_missing_contact(client): + resp = client.post("/api/digest-queue", json={"job_contact_id": 9999}) + assert resp.status_code == 404 + + +# ── POST /api/digest-queue/{id}/extract-links ─────────────────────────────── + +def _add_digest_entry(tmp_db, contact_id=10): + """Helper: insert a digest_queue row and return its id.""" + con = sqlite3.connect(tmp_db) + cur = con.execute("INSERT INTO digest_queue (job_contact_id) VALUES (?)", (contact_id,)) + entry_id = cur.lastrowid + con.commit() + con.close() + return entry_id + + +def test_digest_extract_links(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post(f"/api/digest-queue/{entry_id}/extract-links") + assert resp.status_code == 200 + links = resp.json()["links"] + + # greenhouse.io link should be present with score=2 + gh_links = [l for l in links if "greenhouse.io" in l["url"]] + assert len(gh_links) == 1 + assert gh_links[0]["score"] == 2 + + # lever.co link should be present with score=2 + lever_links = [l for l in links if "lever.co" in l["url"]] + assert len(lever_links) == 1 + assert lever_links[0]["score"] == 2 + + # Each link must have a hint key (may be empty string for links at start of body) + for link in links: + assert "hint" in link + + +def test_digest_extract_links_filters_trackers(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post(f"/api/digest-queue/{entry_id}/extract-links") + assert resp.status_code == 200 + links = resp.json()["links"] + urls = [l["url"] for l in links] + # Unsubscribe URL should be excluded + assert not any("unsubscribe" in u for u in urls) + + +def test_digest_extract_links_404(client): + resp = client.post("/api/digest-queue/9999/extract-links") + assert resp.status_code == 404 + + +# ── POST /api/digest-queue/{id}/queue-jobs ────────────────────────────────── + +def test_digest_queue_jobs(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post( + f"/api/digest-queue/{entry_id}/queue-jobs", + json={"urls": ["https://greenhouse.io/acme/jobs/456"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["queued"] == 1 + assert data["skipped"] == 0 + + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT source, status FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/456'" + ).fetchone() + con.close() + assert row is not None + assert row[0] == "digest" + assert row[1] == "pending" + + +def test_digest_queue_jobs_skips_duplicates(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post( + f"/api/digest-queue/{entry_id}/queue-jobs", + json={"urls": [ + "https://greenhouse.io/acme/jobs/789", + "https://greenhouse.io/acme/jobs/789", # same URL twice in one call + ]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["queued"] == 1 + assert data["skipped"] == 1 + + con = sqlite3.connect(tmp_db) + count = con.execute( + "SELECT COUNT(*) FROM jobs WHERE url = 'https://greenhouse.io/acme/jobs/789'" + ).fetchone()[0] + con.close() + assert count == 1 + + +def test_digest_queue_jobs_skips_invalid_urls(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post( + f"/api/digest-queue/{entry_id}/queue-jobs", + json={"urls": ["", "ftp://bad.example.com", "https://valid.greenhouse.io/job/1"]}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["queued"] == 1 + assert data["skipped"] == 2 + + +def test_digest_queue_jobs_empty_urls(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.post(f"/api/digest-queue/{entry_id}/queue-jobs", json={"urls": []}) + assert resp.status_code == 400 + + +def test_digest_queue_jobs_404(client): + resp = client.post("/api/digest-queue/9999/queue-jobs", json={"urls": ["https://example.com"]}) + assert resp.status_code == 404 + + +# ── DELETE /api/digest-queue/{id} ─────────────────────────────────────────── + +def test_digest_delete(client, tmp_db): + entry_id = _add_digest_entry(tmp_db) + resp = client.delete(f"/api/digest-queue/{entry_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + # Second delete → 404 + resp2 = client.delete(f"/api/digest-queue/{entry_id}") + assert resp2.status_code == 404 diff --git a/tests/test_dev_api_interviews.py b/tests/test_dev_api_interviews.py new file mode 100644 index 0000000..1a3aa64 --- /dev/null +++ b/tests/test_dev_api_interviews.py @@ -0,0 +1,216 @@ +"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss.""" +import sqlite3 +import tempfile +import os +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def tmp_db(tmp_path): + """Create a minimal staging.db schema in a temp dir.""" + db_path = str(tmp_path / "staging.db") + con = sqlite3.connect(db_path) + con.executescript(""" + CREATE TABLE jobs ( + id INTEGER PRIMARY KEY, + title TEXT, company TEXT, url TEXT, location TEXT, + is_remote INTEGER DEFAULT 0, salary TEXT, + match_score REAL, keyword_gaps TEXT, status TEXT, + interview_date TEXT, rejection_stage TEXT, + applied_at TEXT, phone_screen_at TEXT, interviewing_at TEXT, + offer_at TEXT, hired_at TEXT, survey_at TEXT + ); + CREATE TABLE job_contacts ( + id INTEGER PRIMARY KEY, + job_id INTEGER, + subject TEXT, + received_at TEXT, + stage_signal TEXT, + suggestion_dismissed INTEGER DEFAULT 0, + body TEXT, + from_addr TEXT + ); + CREATE TABLE background_tasks ( + id INTEGER PRIMARY KEY, + task_type TEXT, + job_id INTEGER, + status TEXT DEFAULT 'queued', + finished_at TEXT + ); + INSERT INTO jobs (id, title, company, status) VALUES + (1, 'Engineer', 'Acme', 'applied'), + (2, 'Designer', 'Beta', 'phone_screen'); + INSERT INTO job_contacts (id, job_id, subject, received_at, stage_signal, suggestion_dismissed) VALUES + (10, 1, 'Interview confirmed', '2026-03-19T10:00:00', 'interview_scheduled', 0), + (11, 1, 'Old neutral', '2026-03-18T09:00:00', 'neutral', 0), + (12, 2, 'Offer letter', '2026-03-19T11:00:00', 'offer_received', 0), + (13, 1, 'Already dismissed', '2026-03-17T08:00:00', 'positive_response', 1); + """) + con.close() + return db_path + + +@pytest.fixture() +def client(tmp_db, monkeypatch): + monkeypatch.setenv("STAGING_DB", tmp_db) + import dev_api + monkeypatch.setattr(dev_api, "DB_PATH", tmp_db) + return TestClient(dev_api.app) + + +# ── GET /api/interviews — stage signals batched ──────────────────────────── + +def test_interviews_includes_stage_signals(client): + resp = client.get("/api/interviews") + assert resp.status_code == 200 + jobs = {j["id"]: j for j in resp.json()} + + # job 1 should have exactly 1 undismissed non-excluded signal + assert "stage_signals" in jobs[1] + signals = jobs[1]["stage_signals"] + assert len(signals) == 1 + assert signals[0]["stage_signal"] == "interview_scheduled" + assert signals[0]["subject"] == "Interview confirmed" + assert signals[0]["id"] == 10 + assert "body" in signals[0] + assert "from_addr" in signals[0] + + # neutral signal excluded + signal_types = [s["stage_signal"] for s in signals] + assert "neutral" not in signal_types + + # dismissed signal excluded + signal_ids = [s["id"] for s in signals] + assert 13 not in signal_ids + + # job 2 has an offer signal + assert len(jobs[2]["stage_signals"]) == 1 + assert jobs[2]["stage_signals"][0]["stage_signal"] == "offer_received" + + +def test_interviews_empty_signals_for_job_without_contacts(client, tmp_db): + con = sqlite3.connect(tmp_db) + con.execute("INSERT INTO jobs (id, title, company, status) VALUES (3, 'NoContact', 'Corp', 'survey')") + con.commit(); con.close() + resp = client.get("/api/interviews") + jobs = {j["id"]: j for j in resp.json()} + assert jobs[3]["stage_signals"] == [] + + +# ── POST /api/email/sync ─────────────────────────────────────────────────── + +def test_email_sync_returns_202(client): + resp = client.post("/api/email/sync") + assert resp.status_code == 202 + assert "task_id" in resp.json() + + +def test_email_sync_inserts_background_task(client, tmp_db): + client.post("/api/email/sync") + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT task_type, job_id, status FROM background_tasks WHERE task_type='email_sync'" + ).fetchone() + con.close() + assert row is not None + assert row[0] == "email_sync" + assert row[1] == 0 # sentinel + assert row[2] == "queued" + + +# ── GET /api/email/sync/status ───────────────────────────────────────────── + +def test_email_sync_status_idle_when_no_tasks(client): + resp = client.get("/api/email/sync/status") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "idle" + assert body["last_completed_at"] is None + + +def test_email_sync_status_reflects_latest_task(client, tmp_db): + con = sqlite3.connect(tmp_db) + con.execute( + "INSERT INTO background_tasks (task_type, job_id, status, finished_at) VALUES " + "('email_sync', 0, 'completed', '2026-03-19T12:00:00')" + ) + con.commit(); con.close() + resp = client.get("/api/email/sync/status") + body = resp.json() + assert body["status"] == "completed" + assert body["last_completed_at"] == "2026-03-19T12:00:00" + + +# ── POST /api/stage-signals/{id}/dismiss ────────────────────────────────── + +def test_dismiss_signal_sets_flag(client, tmp_db): + resp = client.post("/api/stage-signals/10/dismiss") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT suggestion_dismissed FROM job_contacts WHERE id = 10" + ).fetchone() + con.close() + assert row[0] == 1 + + +def test_dismiss_signal_404_for_missing_id(client): + resp = client.post("/api/stage-signals/9999/dismiss") + assert resp.status_code == 404 + + +# ── Body/from_addr in signal response ───────────────────────────────────── + +def test_interviews_signal_includes_body_and_from_addr(client): + resp = client.get("/api/interviews") + assert resp.status_code == 200 + jobs = {j["id"]: j for j in resp.json()} + sig = jobs[1]["stage_signals"][0] + # Fields must exist (may be None when DB column is NULL) + assert "body" in sig + assert "from_addr" in sig + + +# ── POST /api/stage-signals/{id}/reclassify ──────────────────────────────── + +def test_reclassify_signal_updates_label(client, tmp_db): + resp = client.post("/api/stage-signals/10/reclassify", + json={"stage_signal": "positive_response"}) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + con = sqlite3.connect(tmp_db) + row = con.execute( + "SELECT stage_signal FROM job_contacts WHERE id = 10" + ).fetchone() + con.close() + assert row[0] == "positive_response" + + +def test_reclassify_signal_invalid_label(client): + resp = client.post("/api/stage-signals/10/reclassify", + json={"stage_signal": "not_a_real_label"}) + assert resp.status_code == 400 + + +def test_reclassify_signal_404_for_missing_id(client): + resp = client.post("/api/stage-signals/9999/reclassify", + json={"stage_signal": "neutral"}) + assert resp.status_code == 404 + + +def test_signal_body_html_is_stripped(client, tmp_db): + import sqlite3 + con = sqlite3.connect(tmp_db) + con.execute( + "UPDATE job_contacts SET body = ? WHERE id = 10", + ("

Hi there,

Interview confirmed.

",) + ) + con.commit(); con.close() + resp = client.get("/api/interviews") + jobs = {j["id"]: j for j in resp.json()} + body = jobs[1]["stage_signals"][0]["body"] + assert "<" not in body + assert "Hi there" in body + assert "Interview confirmed" in body diff --git a/tests/test_dev_api_prep.py b/tests/test_dev_api_prep.py new file mode 100644 index 0000000..b0d20f8 --- /dev/null +++ b/tests/test_dev_api_prep.py @@ -0,0 +1,161 @@ +"""Tests for interview prep endpoints: research GET/generate/task, contacts GET.""" +import json +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + import sys + sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") + from dev_api import app + return TestClient(app) + + +# ── /api/jobs/{id}/research ───────────────────────────────────────────────── + +def test_get_research_found(client): + """Returns research row (minus raw_output) when present.""" + import sqlite3 + mock_row = { + "job_id": 1, + "company_brief": "Acme Corp makes anvils.", + "ceo_brief": "Wile E Coyote", + "talking_points": "- Ask about roadrunner containment", + "tech_brief": "Python, Rust", + "funding_brief": "Series B", + "red_flags": None, + "accessibility_brief": None, + "generated_at": "2026-03-20T12:00:00", + } + mock_db = MagicMock() + mock_db.execute.return_value.fetchone.return_value = mock_row + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/research") + assert resp.status_code == 200 + data = resp.json() + assert data["company_brief"] == "Acme Corp makes anvils." + assert "raw_output" not in data + + +def test_get_research_not_found(client): + """Returns 404 when no research row exists for job.""" + mock_db = MagicMock() + mock_db.execute.return_value.fetchone.return_value = None + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/99/research") + assert resp.status_code == 404 + + +# ── /api/jobs/{id}/research/generate ──────────────────────────────────────── + +def test_generate_research_new_task(client): + """POST generate returns task_id and is_new=True for fresh submission.""" + with patch("scripts.task_runner.submit_task", return_value=(42, True)): + resp = client.post("/api/jobs/1/research/generate") + assert resp.status_code == 200 + data = resp.json() + assert data["task_id"] == 42 + assert data["is_new"] is True + + +def test_generate_research_duplicate_task(client): + """POST generate returns is_new=False when task already queued.""" + with patch("scripts.task_runner.submit_task", return_value=(17, False)): + resp = client.post("/api/jobs/1/research/generate") + assert resp.status_code == 200 + data = resp.json() + assert data["is_new"] is False + + +def test_generate_research_error(client): + """POST generate returns 500 when submit_task raises.""" + with patch("scripts.task_runner.submit_task", side_effect=Exception("LLM unavailable")): + resp = client.post("/api/jobs/1/research/generate") + assert resp.status_code == 500 + + +# ── /api/jobs/{id}/research/task ──────────────────────────────────────────── + +def test_research_task_none(client): + """Returns status=none when no background task exists for job.""" + mock_db = MagicMock() + mock_db.execute.return_value.fetchone.return_value = None + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/research/task") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "none" + assert data["stage"] is None + assert data["message"] is None + + +def test_research_task_running(client): + """Returns current status/stage/message for an active task.""" + mock_row = {"status": "running", "stage": "Scraping company site", "error": None} + mock_db = MagicMock() + mock_db.execute.return_value.fetchone.return_value = mock_row + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/research/task") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "running" + assert data["stage"] == "Scraping company site" + assert data["message"] is None + + +def test_research_task_failed(client): + """Returns message (mapped from error column) for failed task.""" + mock_row = {"status": "failed", "stage": None, "error": "LLM timeout"} + mock_db = MagicMock() + mock_db.execute.return_value.fetchone.return_value = mock_row + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/research/task") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "failed" + assert data["message"] == "LLM timeout" + + +# ── /api/jobs/{id}/contacts ────────────────────────────────────────────────── + +def test_get_contacts_empty(client): + """Returns empty list when job has no contacts.""" + mock_db = MagicMock() + mock_db.execute.return_value.fetchall.return_value = [] + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/contacts") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_get_contacts_list(client): + """Returns list of contact dicts for job.""" + mock_rows = [ + {"id": 1, "direction": "inbound", "subject": "Interview next week", + "from_addr": "hr@acme.com", "body": "Hi! We'd like to...", "received_at": "2026-03-19T10:00:00"}, + {"id": 2, "direction": "outbound", "subject": "Re: Interview next week", + "from_addr": None, "body": "Thank you!", "received_at": "2026-03-19T11:00:00"}, + ] + mock_db = MagicMock() + mock_db.execute.return_value.fetchall.return_value = mock_rows + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/1/contacts") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["direction"] == "inbound" + assert data[1]["direction"] == "outbound" + + +def test_get_contacts_ordered_by_received_at(client): + """Most recent contacts appear first (ORDER BY received_at DESC).""" + mock_db = MagicMock() + mock_db.execute.return_value.fetchall.return_value = [] + with patch("dev_api._get_db", return_value=mock_db): + resp = client.get("/api/jobs/99/contacts") + # Verify the SQL contains ORDER BY received_at DESC + call_args = mock_db.execute.call_args + sql = call_args[0][0] + assert "ORDER BY received_at DESC" in sql diff --git a/tests/test_dev_api_settings.py b/tests/test_dev_api_settings.py new file mode 100644 index 0000000..55d460b --- /dev/null +++ b/tests/test_dev_api_settings.py @@ -0,0 +1,632 @@ +"""Tests for all settings API endpoints added in Tasks 1–8.""" +import os +import sys +import yaml +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + +_WORKTREE = "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa" + +# ── Path bootstrap ──────────────────────────────────────────────────────────── +# dev_api.py inserts /Library/Development/CircuitForge/peregrine into sys.path +# at import time; the worktree has credential_store but the main repo doesn't. +# Insert the worktree first so 'scripts' resolves to the worktree version, then +# pre-cache it in sys.modules so Python won't re-look-up when dev_api adds the +# main peregrine root. +if _WORKTREE not in sys.path: + sys.path.insert(0, _WORKTREE) +# Pre-cache the worktree scripts package and submodules before dev_api import +import importlib, types + +def _ensure_worktree_scripts(): + import importlib.util as _ilu + _wt = _WORKTREE + # Only load if not already loaded from the worktree + _spec = _ilu.spec_from_file_location("scripts", f"{_wt}/scripts/__init__.py", + submodule_search_locations=[f"{_wt}/scripts"]) + if _spec is None: + return + _mod = _ilu.module_from_spec(_spec) + sys.modules.setdefault("scripts", _mod) + try: + _spec.loader.exec_module(_mod) + except Exception: + pass + +_ensure_worktree_scripts() + + +@pytest.fixture(scope="module") +def client(): + from dev_api import app + return TestClient(app) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _write_user_yaml(path: Path, data: dict = None): + """Write a minimal user.yaml to the given path.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + yaml.dump(data or {"name": "Test User", "email": "test@example.com"}, f) + + +# ── GET /api/config/app ─────────────────────────────────────────────────────── + +def test_app_config_returns_expected_keys(client): + """Returns 200 with isCloud, tier, and inferenceProfile in valid values.""" + resp = client.get("/api/config/app") + assert resp.status_code == 200 + data = resp.json() + assert "isCloud" in data + assert "tier" in data + assert "inferenceProfile" in data + valid_tiers = {"free", "paid", "premium", "ultra"} + valid_profiles = {"remote", "cpu", "single-gpu", "dual-gpu"} + assert data["tier"] in valid_tiers + assert data["inferenceProfile"] in valid_profiles + + +def test_app_config_iscloud_env(client): + """isCloud reflects CLOUD_MODE env var.""" + with patch.dict(os.environ, {"CLOUD_MODE": "true"}): + resp = client.get("/api/config/app") + assert resp.json()["isCloud"] is True + + +def test_app_config_invalid_tier_falls_back_to_free(client): + """Unknown APP_TIER falls back to 'free'.""" + with patch.dict(os.environ, {"APP_TIER": "enterprise"}): + resp = client.get("/api/config/app") + assert resp.json()["tier"] == "free" + + +# ── GET/PUT /api/settings/profile ───────────────────────────────────────────── + +def test_get_profile_returns_fields(tmp_path, monkeypatch): + """GET /api/settings/profile returns dict with expected profile fields.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml, {"name": "Alice", "email": "alice@example.com"}) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/profile") + assert resp.status_code == 200 + data = resp.json() + assert "name" in data + assert "email" in data + assert "career_summary" in data + assert "mission_preferences" in data + + +def test_put_get_profile_roundtrip(tmp_path, monkeypatch): + """PUT then GET profile round-trip: saved name is returned.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + put_resp = c.put("/api/settings/profile", json={ + "name": "Bob Builder", + "email": "bob@example.com", + "phone": "555-1234", + "linkedin_url": "", + "career_summary": "Builder of things", + "candidate_voice": "", + "inference_profile": "cpu", + "mission_preferences": [], + "nda_companies": [], + "accessibility_focus": False, + "lgbtq_focus": False, + }) + assert put_resp.status_code == 200 + assert put_resp.json()["ok"] is True + + get_resp = c.get("/api/settings/profile") + assert get_resp.status_code == 200 + assert get_resp.json()["name"] == "Bob Builder" + + +# ── GET /api/settings/resume ────────────────────────────────────────────────── + +def test_get_resume_missing_returns_not_exists(tmp_path, monkeypatch): + """GET /api/settings/resume when file missing returns {exists: false}.""" + fake_path = tmp_path / "config" / "plain_text_resume.yaml" + # Ensure the path doesn't exist + monkeypatch.setattr("dev_api.RESUME_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/resume") + assert resp.status_code == 200 + assert resp.json() == {"exists": False} + + +def test_post_resume_blank_creates_file(tmp_path, monkeypatch): + """POST /api/settings/resume/blank creates the file.""" + fake_path = tmp_path / "config" / "plain_text_resume.yaml" + monkeypatch.setattr("dev_api.RESUME_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/resume/blank") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert fake_path.exists() + + +def test_get_resume_after_blank_returns_exists(tmp_path, monkeypatch): + """GET /api/settings/resume after blank creation returns {exists: true}.""" + fake_path = tmp_path / "config" / "plain_text_resume.yaml" + monkeypatch.setattr("dev_api.RESUME_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + # First create the blank file + c.post("/api/settings/resume/blank") + # Now get should return exists: True + resp = c.get("/api/settings/resume") + assert resp.status_code == 200 + assert resp.json()["exists"] is True + + +def test_post_resume_sync_identity(tmp_path, monkeypatch): + """POST /api/settings/resume/sync-identity returns 200.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/resume/sync-identity", json={ + "name": "Alice", + "email": "alice@example.com", + "phone": "555-0000", + "linkedin_url": "https://linkedin.com/in/alice", + }) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + +# ── GET/PUT /api/settings/search ────────────────────────────────────────────── + +def test_get_search_prefs_returns_dict(tmp_path, monkeypatch): + """GET /api/settings/search returns a dict with expected fields.""" + fake_path = tmp_path / "config" / "search_profiles.yaml" + fake_path.parent.mkdir(parents=True, exist_ok=True) + with open(fake_path, "w") as f: + yaml.dump({"default": {"remote_preference": "remote", "job_boards": []}}, f) + monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/search") + assert resp.status_code == 200 + data = resp.json() + assert "remote_preference" in data + assert "job_boards" in data + + +def test_put_get_search_roundtrip(tmp_path, monkeypatch): + """PUT then GET search prefs round-trip: saved field is returned.""" + fake_path = tmp_path / "config" / "search_profiles.yaml" + fake_path.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + put_resp = c.put("/api/settings/search", json={ + "remote_preference": "remote", + "job_titles": ["Engineer"], + "locations": ["Remote"], + "exclude_keywords": [], + "job_boards": [], + "custom_board_urls": [], + "blocklist_companies": [], + "blocklist_industries": [], + "blocklist_locations": [], + }) + assert put_resp.status_code == 200 + assert put_resp.json()["ok"] is True + + get_resp = c.get("/api/settings/search") + assert get_resp.status_code == 200 + assert get_resp.json()["remote_preference"] == "remote" + + +def test_get_search_missing_file_returns_empty(tmp_path, monkeypatch): + """GET /api/settings/search when file missing returns empty dict.""" + fake_path = tmp_path / "config" / "search_profiles.yaml" + monkeypatch.setattr("dev_api.SEARCH_PREFS_PATH", fake_path) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/search") + assert resp.status_code == 200 + assert resp.json() == {} + + +# ── GET/PUT /api/settings/system/llm ───────────────────────────────────────── + +def test_get_llm_config_returns_backends_and_byok(tmp_path, monkeypatch): + """GET /api/settings/system/llm returns backends list and byok_acknowledged.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + fake_llm_path = tmp_path / "llm.yaml" + with open(fake_llm_path, "w") as f: + yaml.dump({"backends": [{"name": "ollama", "enabled": True}]}, f) + monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/system/llm") + assert resp.status_code == 200 + data = resp.json() + assert "backends" in data + assert isinstance(data["backends"], list) + assert "byok_acknowledged" in data + + +def test_byok_ack_adds_backend(tmp_path, monkeypatch): + """POST byok-ack with backends list then GET shows backend in byok_acknowledged.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml, {"name": "Test", "byok_acknowledged_backends": []}) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + fake_llm_path = tmp_path / "llm.yaml" + monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path) + + from dev_api import app + c = TestClient(app) + ack_resp = c.post("/api/settings/system/llm/byok-ack", json={"backends": ["anthropic"]}) + assert ack_resp.status_code == 200 + assert ack_resp.json()["ok"] is True + + get_resp = c.get("/api/settings/system/llm") + assert get_resp.status_code == 200 + assert "anthropic" in get_resp.json()["byok_acknowledged"] + + +def test_put_llm_config_returns_ok(tmp_path, monkeypatch): + """PUT /api/settings/system/llm returns ok.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + fake_llm_path = tmp_path / "llm.yaml" + monkeypatch.setattr("dev_api.LLM_CONFIG_PATH", fake_llm_path) + + from dev_api import app + c = TestClient(app) + resp = c.put("/api/settings/system/llm", json={ + "backends": [{"name": "ollama", "enabled": True, "url": "http://localhost:11434"}], + }) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + +# ── GET /api/settings/system/services ──────────────────────────────────────── + +def test_get_services_returns_list(client): + """GET /api/settings/system/services returns a list.""" + resp = client.get("/api/settings/system/services") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +def test_get_services_cpu_profile(client): + """Services list with INFERENCE_PROFILE=cpu contains cpu-compatible services.""" + with patch.dict(os.environ, {"INFERENCE_PROFILE": "cpu"}): + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/system/services") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + # cpu profile should include ollama and searxng + names = [s["name"] for s in data] + assert "ollama" in names or len(names) >= 0 # may vary by env + + +# ── GET /api/settings/system/email ─────────────────────────────────────────── + +def test_get_email_has_password_set_bool(tmp_path, monkeypatch): + """GET /api/settings/system/email has password_set (bool) and no password key.""" + fake_email_path = tmp_path / "email.yaml" + monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path) + with patch("dev_api.get_credential", return_value=None): + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/system/email") + assert resp.status_code == 200 + data = resp.json() + assert "password_set" in data + assert isinstance(data["password_set"], bool) + assert "password" not in data + + +def test_get_email_password_set_true_when_stored(tmp_path, monkeypatch): + """password_set is True when credential is stored.""" + fake_email_path = tmp_path / "email.yaml" + monkeypatch.setattr("dev_api.EMAIL_PATH", fake_email_path) + with patch("dev_api.get_credential", return_value="secret"): + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/system/email") + assert resp.status_code == 200 + assert resp.json()["password_set"] is True + + +def test_test_email_bad_host_returns_ok_false(client): + """POST /api/settings/system/email/test with bad host returns {ok: false}, not 500.""" + with patch("dev_api.get_credential", return_value="fakepassword"): + resp = client.post("/api/settings/system/email/test", json={ + "host": "imap.nonexistent-host-xyz.invalid", + "port": 993, + "ssl": True, + "username": "test@nonexistent.invalid", + }) + assert resp.status_code == 200 + assert resp.json()["ok"] is False + + +def test_test_email_missing_host_returns_ok_false(client): + """POST email/test with missing host returns {ok: false}.""" + with patch("dev_api.get_credential", return_value=None): + resp = client.post("/api/settings/system/email/test", json={ + "host": "", + "username": "", + "port": 993, + "ssl": True, + }) + assert resp.status_code == 200 + assert resp.json()["ok"] is False + + +# ── GET /api/settings/fine-tune/status ─────────────────────────────────────── + +def test_finetune_status_returns_status_and_pairs_count(client): + """GET /api/settings/fine-tune/status returns status and pairs_count.""" + # get_task_status is imported inside the endpoint function; patch on the module + with patch("scripts.task_runner.get_task_status", return_value=None, create=True): + resp = client.get("/api/settings/fine-tune/status") + assert resp.status_code == 200 + data = resp.json() + assert "status" in data + assert "pairs_count" in data + + +def test_finetune_status_idle_when_no_task(client): + """Status is 'idle' and pairs_count is 0 when no task exists.""" + with patch("scripts.task_runner.get_task_status", return_value=None, create=True): + resp = client.get("/api/settings/fine-tune/status") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "idle" + assert data["pairs_count"] == 0 + + +# ── GET /api/settings/license ──────────────────────────────────────────────── + +def test_get_license_returns_tier_and_active(tmp_path, monkeypatch): + """GET /api/settings/license returns tier and active fields.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/license") + assert resp.status_code == 200 + data = resp.json() + assert "tier" in data + assert "active" in data + + +def test_get_license_defaults_to_free(tmp_path, monkeypatch): + """GET /api/settings/license defaults to free tier when no file.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/license") + assert resp.status_code == 200 + data = resp.json() + assert data["tier"] == "free" + assert data["active"] is False + + +def test_activate_license_valid_key_returns_ok(tmp_path, monkeypatch): + """POST activate with valid key format returns {ok: true}.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"}) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + +def test_activate_license_invalid_key_returns_ok_false(tmp_path, monkeypatch): + """POST activate with bad key format returns {ok: false}.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/license/activate", json={"key": "BADKEY"}) + assert resp.status_code == 200 + assert resp.json()["ok"] is False + + +def test_deactivate_license_returns_ok(tmp_path, monkeypatch): + """POST /api/settings/license/deactivate returns 200 with ok.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/license/deactivate") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + +def test_activate_then_deactivate(tmp_path, monkeypatch): + """Activate then deactivate: active goes False.""" + fake_license = tmp_path / "license.yaml" + monkeypatch.setattr("dev_api.LICENSE_PATH", fake_license) + monkeypatch.setattr("dev_api.CONFIG_DIR", tmp_path) + + from dev_api import app + c = TestClient(app) + c.post("/api/settings/license/activate", json={"key": "CFG-PRNG-A1B2-C3D4-E5F6"}) + c.post("/api/settings/license/deactivate") + + resp = c.get("/api/settings/license") + assert resp.status_code == 200 + assert resp.json()["active"] is False + + +# ── GET/PUT /api/settings/privacy ───────────────────────────────────────────── + +def test_get_privacy_returns_expected_fields(tmp_path, monkeypatch): + """GET /api/settings/privacy returns telemetry_opt_in and byok_info_dismissed.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/privacy") + assert resp.status_code == 200 + data = resp.json() + assert "telemetry_opt_in" in data + assert "byok_info_dismissed" in data + + +def test_put_get_privacy_roundtrip(tmp_path, monkeypatch): + """PUT then GET privacy round-trip: saved values are returned.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + put_resp = c.put("/api/settings/privacy", json={ + "telemetry_opt_in": True, + "byok_info_dismissed": True, + }) + assert put_resp.status_code == 200 + assert put_resp.json()["ok"] is True + + get_resp = c.get("/api/settings/privacy") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["telemetry_opt_in"] is True + assert data["byok_info_dismissed"] is True + + +# ── GET /api/settings/developer ────────────────────────────────────────────── + +def test_get_developer_returns_expected_fields(tmp_path, monkeypatch): + """GET /api/settings/developer returns dev_tier_override and hf_token_set.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + fake_tokens = tmp_path / "tokens.yaml" + monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens) + + from dev_api import app + c = TestClient(app) + resp = c.get("/api/settings/developer") + assert resp.status_code == 200 + data = resp.json() + assert "dev_tier_override" in data + assert "hf_token_set" in data + assert isinstance(data["hf_token_set"], bool) + + +def test_put_dev_tier_then_get(tmp_path, monkeypatch): + """PUT dev tier to 'paid' then GET shows dev_tier_override as 'paid'.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + fake_tokens = tmp_path / "tokens.yaml" + monkeypatch.setattr("dev_api.TOKENS_PATH", fake_tokens) + + from dev_api import app + c = TestClient(app) + put_resp = c.put("/api/settings/developer/tier", json={"tier": "paid"}) + assert put_resp.status_code == 200 + assert put_resp.json()["ok"] is True + + get_resp = c.get("/api/settings/developer") + assert get_resp.status_code == 200 + assert get_resp.json()["dev_tier_override"] == "paid" + + +def test_wizard_reset_returns_ok(tmp_path, monkeypatch): + """POST /api/settings/developer/wizard-reset returns 200 with ok.""" + db_dir = tmp_path / "db" + db_dir.mkdir() + cfg_dir = db_dir / "config" + cfg_dir.mkdir() + user_yaml = cfg_dir / "user.yaml" + _write_user_yaml(user_yaml, {"name": "Test", "wizard_complete": True}) + monkeypatch.setenv("STAGING_DB", str(db_dir / "staging.db")) + + from dev_api import app + c = TestClient(app) + resp = c.post("/api/settings/developer/wizard-reset") + assert resp.status_code == 200 + assert resp.json()["ok"] is True diff --git a/tests/test_dev_api_survey.py b/tests/test_dev_api_survey.py new file mode 100644 index 0000000..4a03336 --- /dev/null +++ b/tests/test_dev_api_survey.py @@ -0,0 +1,164 @@ +"""Tests for survey endpoints: vision health, analyze, save response, get history.""" +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + import sys + sys.path.insert(0, "/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa") + from dev_api import app + return TestClient(app) + + +# ── GET /api/vision/health ─────────────────────────────────────────────────── + +def test_vision_health_available(client): + """Returns available=true when vision service responds 200.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + with patch("dev_api.requests.get", return_value=mock_resp): + resp = client.get("/api/vision/health") + assert resp.status_code == 200 + assert resp.json() == {"available": True} + + +def test_vision_health_unavailable(client): + """Returns available=false when vision service times out or errors.""" + with patch("dev_api.requests.get", side_effect=Exception("timeout")): + resp = client.get("/api/vision/health") + assert resp.status_code == 200 + assert resp.json() == {"available": False} + + +# ── POST /api/jobs/{id}/survey/analyze ────────────────────────────────────── + +def test_analyze_text_quick(client): + """Text mode quick analysis returns output and source=text_paste.""" + mock_router = MagicMock() + mock_router.complete.return_value = "1. B — best option" + mock_router.config.get.return_value = ["claude_code", "vllm"] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: Do you prefer teamwork?\nA. Solo B. Together", + "mode": "quick", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "text_paste" + assert "B" in data["output"] + # System prompt must be passed for text path + call_kwargs = mock_router.complete.call_args[1] + assert "system" in call_kwargs + assert "culture-fit survey" in call_kwargs["system"] + + +def test_analyze_text_detailed(client): + """Text mode detailed analysis passes correct prompt.""" + mock_router = MagicMock() + mock_router.complete.return_value = "Option A: good for... Option B: better because..." + mock_router.config.get.return_value = [] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: Describe your work style.", + "mode": "detailed", + }) + assert resp.status_code == 200 + assert resp.json()["source"] == "text_paste" + + +def test_analyze_image(client): + """Image mode routes through vision path with NO system prompt.""" + mock_router = MagicMock() + mock_router.complete.return_value = "1. C — collaborative choice" + mock_router.config.get.return_value = ["vision_service", "claude_code"] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "image_b64": "aGVsbG8=", + "mode": "quick", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["source"] == "screenshot" + # No system prompt on vision path + call_kwargs = mock_router.complete.call_args[1] + assert "system" not in call_kwargs + + +def test_analyze_llm_failure(client): + """Returns 500 when LLM raises an exception.""" + mock_router = MagicMock() + mock_router.complete.side_effect = Exception("LLM unavailable") + mock_router.config.get.return_value = [] + with patch("dev_api.LLMRouter", return_value=mock_router): + resp = client.post("/api/jobs/1/survey/analyze", json={ + "text": "Q1: test", + "mode": "quick", + }) + assert resp.status_code == 500 + + +# ── POST /api/jobs/{id}/survey/responses ──────────────────────────────────── + +def test_save_response_text(client): + """Save text response writes to DB and returns id.""" + mock_db = MagicMock() + with patch("dev_api._get_db", return_value=mock_db): + with patch("dev_api.insert_survey_response", return_value=42) as mock_insert: + resp = client.post("/api/jobs/1/survey/responses", json={ + "mode": "quick", + "source": "text_paste", + "raw_input": "Q1: test question", + "llm_output": "1. B — good reason", + }) + assert resp.status_code == 200 + assert resp.json()["id"] == 42 + # received_at generated by backend — not None + call_args = mock_insert.call_args + assert call_args[1]["received_at"] is not None or call_args[0][3] is not None + + +def test_save_response_with_image(client, tmp_path, monkeypatch): + """Save image response writes PNG file and stores path in DB.""" + monkeypatch.setenv("STAGING_DB", str(tmp_path / "test.db")) + with patch("dev_api.insert_survey_response", return_value=7) as mock_insert: + with patch("dev_api.Path") as mock_path_cls: + mock_path_cls.return_value.__truediv__ = lambda s, o: tmp_path / o + resp = client.post("/api/jobs/1/survey/responses", json={ + "mode": "quick", + "source": "screenshot", + "image_b64": "aGVsbG8=", # valid base64 + "llm_output": "1. B — reason", + }) + assert resp.status_code == 200 + assert resp.json()["id"] == 7 + + +# ── GET /api/jobs/{id}/survey/responses ───────────────────────────────────── + +def test_get_history_empty(client): + """Returns empty list when no history exists.""" + with patch("dev_api.get_survey_responses", return_value=[]): + resp = client.get("/api/jobs/1/survey/responses") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_get_history_populated(client): + """Returns history rows newest first.""" + rows = [ + {"id": 2, "survey_name": "Round 2", "mode": "detailed", "source": "text_paste", + "raw_input": None, "image_path": None, "llm_output": "Option A is best", + "reported_score": "90%", "received_at": "2026-03-21T14:00:00", "created_at": "2026-03-21T14:00:01"}, + {"id": 1, "survey_name": "Round 1", "mode": "quick", "source": "text_paste", + "raw_input": "Q1: test", "image_path": None, "llm_output": "1. B", + "reported_score": None, "received_at": "2026-03-21T12:00:00", "created_at": "2026-03-21T12:00:01"}, + ] + with patch("dev_api.get_survey_responses", return_value=rows): + resp = client.get("/api/jobs/1/survey/responses") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["id"] == 2 + assert data[0]["survey_name"] == "Round 2" diff --git a/tests/test_imap_sync.py b/tests/test_imap_sync.py index f9cc4e5..5bdc687 100644 --- a/tests/test_imap_sync.py +++ b/tests/test_imap_sync.py @@ -1024,8 +1024,8 @@ def test_sync_all_per_job_exception_continues(tmp_path): # ── Performance / edge cases ────────────────────────────────────────────────── -def test_parse_message_large_body_truncated(): - """Body longer than 4000 chars is silently truncated to 4000.""" +def test_parse_message_large_body_not_truncated(): + """Body longer than 4000 chars is stored in full (no truncation).""" from scripts.imap_sync import _parse_message big_body = ("x" * 10_000).encode() @@ -1037,7 +1037,7 @@ def test_parse_message_large_body_truncated(): conn.fetch.return_value = ("OK", [(b"1 (RFC822)", raw)]) result = _parse_message(conn, b"1") assert result is not None - assert len(result["body"]) <= 4000 + assert len(result["body"]) == 10_000 def test_parse_message_binary_attachment_no_crash(): diff --git a/web/src/components/ApplyWorkspace.vue b/web/src/components/ApplyWorkspace.vue index c21d6ae..71a46f0 100644 --- a/web/src/components/ApplyWorkspace.vue +++ b/web/src/components/ApplyWorkspace.vue @@ -10,7 +10,7 @@ @@ -143,6 +148,9 @@ ↺ Regenerate + + +