From 65d9335be50c584fe348606e640b24d53c2e5c8b Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Sat, 17 Oct 2026 02:43:14 +0100 Subject: [PATCH 1/2] feat(api): autopilot applies to clean matches from the feed on its own Works down the feed best match first: Greenhouse/Lever/Ashby/Workable forms only, each job once, fit at or above the threshold and not a seniority stretch. Writes and fact-checks the tailored CV and cover letter, fills the form in an unattended browser, and sends it only when every answer came from the answer bank or the profile, no required field is empty, the documents raise no fact flags and the 24-hour limit isn't reached. Practice mode (the default) never sends. Held forms go to a queue with their reasons; sent ones keep form and confirmation screenshots. Scheduled runs every 6 hours when switched on; /autopilot endpoints for settings, runs, retry, dismiss and screenshots. --- api/app/agent.py | 2 + api/app/autopilot.py | 477 ++++++++++++++++++++++++ api/app/browser_agent.py | 40 ++ api/app/jobs_db.py | 37 ++ api/app/main.py | 127 +++++-- api/app/tracker.py | 190 ++++++++++ api/tests/browser/test_browser_agent.py | 31 ++ api/tests/test_autopilot.py | 320 ++++++++++++++++ api/tests/test_ssrf.py | 4 +- 9 files changed, 1185 insertions(+), 43 deletions(-) create mode 100644 api/app/autopilot.py create mode 100644 api/tests/test_autopilot.py diff --git a/api/app/agent.py b/api/app/agent.py index 6b16275..8b5aed3 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -1319,6 +1319,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "letter": letter, "archetype": archetype, "review": review, + # What the letter may say about the company beyond the ad: its fact check needs it. + "research": company_research, } elif name == "generate_skill_roadmap": diff --git a/api/app/autopilot.py b/api/app/autopilot.py new file mode 100644 index 0000000..6be3c73 --- /dev/null +++ b/api/app/autopilot.py @@ -0,0 +1,477 @@ +""" +Autopilot: applies to jobs from the user's feed on its own, inside the guardrails the user set. + +Each run works down the feed, best match first: + 1. only forms on Greenhouse, Lever, Ashby and Workable, and each job once. LinkedIn and Indeed forbid + automated applying, and sites that want an account per employer (Workday) are left to the user; + 2. the fit score must reach the user's threshold, and a stretch in seniority ('reach') is skipped; + 3. the tailored CV and cover letter are written and fact-checked, as on the job page; + 4. a browser nobody watches fills the form, and it is sent only if nothing needs the user: every answer + came from the answer bank or the profile (none drafted, none left blank), no required field is empty, + neither document claims something the profile doesn't back, and fewer than the daily limit went out + in the last 24 hours. In practice mode nothing is sent at all. +Whatever is held back goes to the queue with its reasons; the user's answers there are saved, so the next +form that asks is clean. A sent form keeps two screenshots: the filled form, and the page after sending. +""" +import asyncio +import base64 +import json +import logging +import os +import re +from contextlib import aclosing +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from urllib.parse import urlparse + +from . import agent, answers, browser_agent, doc_check, doc_render, jobs_db, pdf_generator, semantic, tracker + +logger = logging.getLogger("windrush.autopilot") + +DEFAULTS = {"enabled": False, "live": False, "min_fit": 75, "daily_cap": 3} +MIN_FIT = (50, 100) +DAILY_CAP = (1, 10) + +# Applicant tracking systems whose forms take an application without an account: host (and its subdomains). +ATS_HOSTS = {"greenhouse.io": "Greenhouse", "lever.co": "Lever", "ashbyhq.com": "Ashby", "workable.com": "Workable"} + +_FEED_DEPTH = 60 # feed jobs looked at per run, best match first +_SCORE_PER_RUN = 15 # fit-scored per run (one model call per 5) +_SCORE_BATCH = 5 # what score_job_fit takes at once +_EXTRA_FILLS = 2 # forms filled per run: the daily limit plus this many, for ones that get held back +_MAX_FILLS = 8 +_EVERY = timedelta(hours=6) # scheduled runs, while autopilot is on +_TICK_S = 15 * 60 +_DESCRIPTION_CHARS = 1500 # of each ad, for scoring + + +# ── Settings ────────────────────────────────────────────────────────────────────────────────── + +def settings(user_id: str) -> dict: + return {**DEFAULTS, **clean_settings(tracker.get_autopilot_settings(user_id), strict=False)} + + +def clean_settings(raw: dict, strict: bool = True) -> dict: + """The known settings in `raw`, checked. strict: a bad value raises ValueError, else it is dropped.""" + out: dict = {} + for name in ("enabled", "live"): + if name in raw: + if isinstance(raw[name], bool): + out[name] = raw[name] + elif strict: + raise ValueError(f"{name} must be true or false") + for name, (low, high) in (("min_fit", MIN_FIT), ("daily_cap", DAILY_CAP)): + if name in raw: + value = raw[name] + if isinstance(value, int) and not isinstance(value, bool) and low <= value <= high: + out[name] = value + elif strict: + raise ValueError(f"{name} must be a whole number from {low} to {high}") + return out + + +def save_settings(user_id: str, changes: dict) -> dict: + """Apply `changes` (validated) to the user's settings and return them all.""" + merged = {**settings(user_id), **clean_settings(changes)} + tracker.save_autopilot_settings(user_id, merged) + return merged + + +# ── Which jobs, and whether to send ─────────────────────────────────────────────────────────── + +def ats_of(url: str) -> str: + """The applicant tracking system a job's form is on ('Greenhouse'…), or '' if autopilot doesn't apply there.""" + parsed = urlparse(url or "") + host = (parsed.hostname or "").lower() + if parsed.scheme != "https": + return "" + return next((name for domain, name in ATS_HOSTS.items() if host == domain or host.endswith("." + domain)), "") + + +def _listed(items: list[str], limit: int = 4) -> str: + shown = "; ".join(f"“{item}”" for item in items[:limit]) + return shown + (f" and {len(items) - limit} more" if len(items) > limit else "") + + +def _plural(n: int, word: str) -> str: + return f"{n} {word}{'' if n == 1 else 's'}" + + +def hold_reasons(filled: dict, document_problems: list[str]) -> list[str]: + """ + Why a filled form must not go out without the user, as sentences for the queue; [] when it can. + `filled` is what the fill left behind (browser_agent._finish_unattended): {ready, error, questions, empty}. + """ + reasons = [] + if filled.get("error"): + reasons.append(f"The agent hit a problem: {filled['error']}"[:300]) + elif not filled.get("ready"): + reasons.append("The agent couldn't finish the form.") + questions = filled.get("questions") or [] + blank = [q["question"] for q in questions if q.get("source") == "needs_you" or not q.get("answer")] + drafted = [q["question"] for q in questions if q["question"] not in blank and q.get("source") == "drafted"] + # Anything else that isn't the answer bank or the profile isn't the user's own data either. + other = [q["question"] for q in questions + if q["question"] not in blank + drafted and (q.get("source") not in ("saved", "profile") or q.get("flags"))] + if blank: + reasons.append(f"{_plural(len(blank), 'question')} only you can answer: {_listed(blank)}") + if drafted: + reasons.append(f"{_plural(len(drafted), 'written answer')} drafted for this job that you haven't read: " + f"{_listed(drafted)}") + if other: + reasons.append(f"{_plural(len(other), 'answer')} that didn't come from your saved answers or profile: " + f"{_listed(other)}") + empty = filled.get("empty") or [] + if empty: + reasons.append(f"{_plural(len(empty), 'required field')} still empty: {_listed(empty)}") + return reasons + list(document_problems) + + +def document_problems(cv: dict, letter: dict, persona: dict, job: dict, research: str = "") -> list[str]: + """Claims in the tailored CV or cover letter that the profile doesn't back (doc_check), as sentences.""" + out = [] + for doc_type, doc, extra, name in (("cv", cv, "", "The tailored CV"), + ("cover_letter", letter, research, "The cover letter")): + findings = doc_check.check_facts(doc_type, doc, persona, job, extra) + if findings: + messages = list(dict.fromkeys(f["message"] for f in findings)) + out.append(f"{name} says something your profile doesn't back: " + "; ".join(messages[:3]) + + (f" and {len(messages) - 3} more" if len(messages) > 3 else "")) + return out + + +def letter_text(letter: dict) -> str: + """The letter as the form's text box takes it (as DocEditor.tsx letterToText does).""" + parts = [letter.get("salutation"), *(letter.get("paragraphs") or []), letter.get("signoff"), + letter.get("candidate_name")] + return "\n\n".join(str(p) for p in parts if p) + + +def _since_last_day() -> str: + return (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + + +def sent_last_day(user_id: str) -> int: + return tracker.autopilot_sent_since(user_id, _since_last_day()) + + +# ── Screenshots ─────────────────────────────────────────────────────────────────────────────── + +SCREENSHOTS = ("form", "confirm") + + +def screenshot_path(run: dict, which: str) -> str: + """Where a run's screenshot is kept: one per job and kind, so a retry replaces the last one.""" + folder = os.path.join(os.environ.get("APP_DATA_PATH", "/tmp"), "autopilot") + safe = lambda text: re.sub(r"[^A-Za-z0-9_-]", "_", str(text))[:80] + return os.path.join(folder, f"{safe(run['user_id'])}-{safe(run['job_id'])}-{which}.jpg") + + +def _keep_screenshot(run: dict, which: str, b64: str) -> None: + path = screenshot_path(run, which) + try: + if b64: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(base64.b64decode(b64)) + elif os.path.exists(path): + os.remove(path) # a retry didn't get this far: don't show the last attempt's + except Exception as exc: + logger.warning("couldn't keep the %s screenshot: %s", which, exc) + + +# ── A run ───────────────────────────────────────────────────────────────────────────────────── + +@dataclass +class _Run: + started_at: str + live: bool + step: str = "Starting" + job: str = "" + tried: int = 0 + counts: dict = field(default_factory=dict) + task: asyncio.Task | None = None + + +_runs: dict[str, _Run] = {} # user id → the run in progress +_last: dict[str, dict] = {} # user id → how the last run ended + + +class _Stop(Exception): + """Ends one job's attempt with a reason for the user.""" + + +def _distinct(items) -> list[str]: + seen, out = set(), [] + for item in items or []: + norm = " ".join(str(item or "").lower().split()) + if norm and norm not in seen: + seen.add(norm) + out.append(str(item).strip()) + return out + + +async def _feed(user_id: str, persona: dict, live: bool) -> list[dict]: + """The feed as the user sees it by default (their locations, sponsorship needs, nothing they've already + tracked), best match first, cut to jobs autopilot can apply to and hasn't dealt with.""" + prefs = persona.get("preferences") or {} + core = persona.get("core_info") or {} + vector = await semantic.get_embedding(semantic.vectorize_persona(persona), timeout=20.0) + tracked = set(tracker.get_user_application_statuses(user_id)) + jobs, _ = await asyncio.to_thread( + jobs_db.get_jobs, location=_distinct(prefs.get("preferred_locations")), persona_vector=vector, + limit=_FEED_DEPTH, exclude=tracked, sponsorship="possible" if core.get("require_sponsorship") is True else "any", + marks=tracker.get_job_marks(user_id)) + tried = tracker.autopilot_tried(user_id, count_practice=not live) + return [j for j in jobs if ats_of(j.get("url")) and str(j.get("id")) not in tried] + + +async def _fit_scores(jobs: list[dict], persona: dict) -> dict[str, dict]: + """job id → score_job_fit's verdict {fit_score, level_match, reasoning…}; jobs it couldn't score are missing.""" + out: dict[str, dict] = {} + for start in range(0, len(jobs), _SCORE_BATCH): + batch = [{"job_id": str(j["id"]), "title": j.get("title", ""), "company": j.get("company", ""), + "location": j.get("location", ""), "level": j.get("level", ""), + "description": (j.get("description") or "")[:_DESCRIPTION_CHARS]} + for j in jobs[start:start + _SCORE_BATCH]] + try: + result = await agent.execute_tool("score_job_fit", {"jobs": batch, "persona": persona}) + except Exception as exc: + logger.warning("autopilot: fit scoring failed: %s", exc) + continue + for verdict in result.get("ranked_jobs") or []: + if isinstance(verdict, dict) and isinstance(verdict.get("fit_score"), (int, float)): + out[str(verdict.get("job_id"))] = verdict + return out + + +def _skip_reason(verdict: dict, min_fit: int) -> str: + if verdict.get("level_match") == "reach": + return f"A stretch in seniority for you. {verdict.get('reasoning') or ''}".strip()[:300] + if verdict["fit_score"] < min_fit: + return f"Fit {round(verdict['fit_score'])} is under your threshold of {min_fit}. {verdict.get('reasoning') or ''}".strip()[:300] + return "" + + +def _pdf(doc_id: str) -> str: + path = pdf_generator.get_pdf_path(doc_id) if re.fullmatch(r"[a-f0-9]{32}", doc_id or "") else "" + return path if path and os.path.exists(path) else "" + + +async def _apply(state: _Run, user_id: str, job: dict, verdict: dict, cfg: dict, persona: dict) -> str: + """Try one job: documents, then the form, then send or hold. Returns the run's outcome.""" + live = cfg["live"] + run_id = tracker.start_autopilot_run(user_id, job, practice=not live, fit_score=verdict.get("fit_score")) + run = tracker.get_autopilot_run(user_id, run_id) + state.job = f"{job.get('title', '')} at {job.get('company', '')}" + held: list[str] = [] + final: dict = {} + questions: list[dict] = [] + try: + job = await jobs_db.ensure_full_description(job) + state.step = "Writing the tailored CV" + cv_result = await agent.execute_tool("generate_tailored_cv", {"job": job, "persona": persona}) + cv, template_id = cv_result.get("cv"), cv_result.get("template_id") + if not cv: + raise _Stop("Couldn't write the tailored CV.") + state.step = "Writing the cover letter" + letter_result = await agent.execute_tool("generate_cover_letter", {"job": job, "persona": persona}) + letter = letter_result.get("letter") or {} + if not letter.get("paragraphs"): + raise _Stop("Couldn't write the cover letter.") + problems = document_problems(cv, letter, persona, job, letter_result.get("research") or "") + cv_doc_id = await doc_render.render_pdf("cv", cv, template_id) + letter_doc_id = await doc_render.render_pdf("cover_letter", letter, template_id) + tracker.update_autopilot_run(run_id, user_id=user_id, cv_doc_id=cv_doc_id, letter_doc_id=letter_doc_id) + + text = letter_text(letter) + answer_job = {"title": job.get("title", ""), "company": job.get("company", ""), + "location": job.get("location", ""), "description": job.get("description", "")} + + async def answerer(asked: list[dict]) -> list[dict]: + return await answers.answer_questions(asked, persona, tracker.list_answers(user_id), answer_job, text) + + async def decide(filled: dict) -> bool: + held[:] = hold_reasons(filled, problems) + if live and not held and sent_last_day(user_id) >= cfg["daily_cap"]: + held.append(f"Your limit of {cfg['daily_cap']} a day was reached before this one could go.") + return live and not held + + state.step = "Filling in the form" + core = persona.get("core_info") or {} + async with aclosing(browser_agent.apply_with_browser( + job["url"], persona, text, asyncio.Queue(), None, + job_email=core.get("job_email", ""), job_password=tracker.get_job_password(user_id), + cv_path=_pdf(cv_doc_id), cover_letter_path=_pdf(letter_doc_id), answerer=answerer, decide=decide, + )) as steps: + async for step in steps: + if step.get("done"): + final = step + break + state.step = str(step.get("action") or state.step)[:160] + if "form_screenshot" not in final: + raise _Stop(str(final.get("action") or "The browser stopped before the form was filled.")) + except _Stop as stop: + tracker.update_autopilot_run(run_id, user_id=user_id, outcome="failed", reasons=[str(stop)[:300]]) + return "failed" + except Exception as exc: + logger.exception("autopilot: %s failed", state.job) + tracker.update_autopilot_run(run_id, user_id=user_id, outcome="failed", + reasons=[f"Something went wrong: {type(exc).__name__}: {exc}"[:300]]) + return "failed" + + questions = final.get("questions") or [] + _keep_screenshot(run, "form", final.get("form_screenshot") or "") + _keep_screenshot(run, "confirm", final.get("confirm_screenshot") or "") + if final.get("submitted"): + outcome, reasons = "submitted", [] + elif final.get("submit_attempted"): + detail = final.get("submit_detail") or "no confirmation shown" + outcome, reasons = "unconfirmed", [f"The submit button was clicked, but the page didn't confirm it ({detail}). " + f"Check whether it went through before applying again."[:400]] + elif held: + outcome, reasons = "needs_you", held + else: + outcome, reasons = "ready", [] # practice mode: it would have gone + + app_id = None + if live: + status = "Applied" if outcome == "submitted" else "Pending Review" + tracked = {"job_id": job.get("id"), "title": job.get("title", ""), "company": job.get("company", ""), + "location": job.get("location", ""), "url": job.get("url", "")} + app_id = (tracker.add_application(user_id, tracked, {}, text, {"fit_score": verdict.get("fit_score"), + "level_match": verdict.get("level_match", "ok"), + "skill_gaps": verdict.get("skill_gaps") or []}, + tailored_cv=json.dumps(cv), status=status) + or tracker.find_application_id(user_id, job.get("company", ""), job.get("title", ""))) + if app_id: + note = {"submitted": "Sent by autopilot.", "unconfirmed": "Autopilot clicked submit but saw no confirmation.", + "needs_you": "Autopilot filled this in and held it for you."}[outcome] + tracker.update_status(app_id, status, note, user_id=user_id) + tracker.set_application_questions(app_id, questions, user_id=user_id) + tracker.update_autopilot_run(run_id, user_id=user_id, outcome=outcome, reasons=reasons, questions=questions, + notes=str(final.get("notes") or "")[:2000], application_id=app_id) + logger.info("autopilot: %s -> %s (%d reasons)", state.job, outcome, len(reasons)) + return outcome + + +async def run(user_id: str, job_id: str = "") -> dict: + """One autopilot run for the user (or for one job, when `job_id` is given). Returns what it did.""" + cfg = settings(user_id) + state = _runs.get(user_id) or _Run(started_at=datetime.now(timezone.utc).isoformat(), live=cfg["live"]) + tracker.mark_autopilot_run(user_id) + persona = tracker.get_user_persona(user_id) + counts: dict[str, int] = {} + note = "" + try: + if cfg["live"] and sent_last_day(user_id) >= cfg["daily_cap"]: + note = f"Your limit of {cfg['daily_cap']} a day is reached; nothing was tried." + return {"note": note, "counts": counts} + state.step = "Looking through your feed" + if job_id: + job = jobs_db.get_job(job_id) + if not job or not ats_of(job.get("url")): + note = "Autopilot can't apply to that job: its form isn't on Greenhouse, Lever, Ashby or Workable." + return {"note": note, "counts": counts} + if tracker.get_user_application_statuses(user_id).get( + ((job.get("company") or "").lower(), (job.get("title") or "").lower())) == "Applied": + note = "You've already applied to that job." + return {"note": note, "counts": counts} + candidates = [job] + else: + candidates = (await _feed(user_id, persona, cfg["live"]))[:_SCORE_PER_RUN] + if not candidates: + note = "No new jobs in your feed that autopilot can apply to." + return {"note": note, "counts": counts} + + state.step = f"Scoring the fit of {_plural(len(candidates), 'job')}" + verdicts = await _fit_scores(candidates, persona) + to_try = [] + for job in candidates: + verdict = verdicts.get(str(job["id"])) + if verdict is None: + continue # couldn't score it: try again next run + # A job the user picked (a retry from the queue) is theirs to judge: only the feed's are screened. + reason = "" if job_id else _skip_reason(verdict, cfg["min_fit"]) + if reason: + tracker.start_autopilot_run(user_id, job, "skipped", fit_score=verdict["fit_score"], reasons=[reason]) + counts["skipped"] = counts.get("skipped", 0) + 1 + else: + to_try.append((job, verdict)) + to_try.sort(key=lambda pair: pair[1]["fit_score"], reverse=True) + + for job, verdict in to_try[:min(cfg["daily_cap"] + _EXTRA_FILLS, _MAX_FILLS)]: + if cfg["live"] and sent_last_day(user_id) >= cfg["daily_cap"]: + note = f"Stopped at your limit of {cfg['daily_cap']} a day." + break + outcome = await _apply(state, user_id, job, verdict, cfg, persona) + counts[outcome] = counts.get(outcome, 0) + 1 + state.tried += 1 + return {"note": note, "counts": counts} + finally: + _last[user_id] = {"finished_at": datetime.now(timezone.utc).isoformat(), "live": cfg["live"], + "counts": counts, "note": note} + + +def start(user_id: str, job_id: str = "") -> bool: + """Start a run in the background; False if one is already going for this user.""" + if user_id in _runs: + return False + state = _Run(started_at=datetime.now(timezone.utc).isoformat(), live=settings(user_id)["live"]) + _runs[user_id] = state + + async def go(): + try: + await run(user_id, job_id) + except Exception: + logger.exception("autopilot run failed") + finally: + _runs.pop(user_id, None) + + state.task = asyncio.create_task(go()) + return True + + +def status(user_id: str) -> dict: + cfg = settings(user_id) + state = _runs.get(user_id) + last_run_at = next((last for uid, _, last in tracker.autopilot_schedules() if uid == user_id), None) + next_run_at = None + if cfg["enabled"] and not state: + last = _parse(last_run_at) + next_run_at = (last + _EVERY).isoformat() if last else datetime.now(timezone.utc).isoformat() + return { + "settings": cfg, + "running": bool(state), + "step": state.step if state else "", + "job": state.job if state else "", + "started_at": state.started_at if state else None, + "sent_last_day": sent_last_day(user_id), + "last_run_at": last_run_at, + "next_run_at": next_run_at, + "last": _last.get(user_id), + } + + +def _parse(stamp: str | None) -> datetime | None: + try: + return datetime.fromisoformat(stamp) if stamp else None + except ValueError: + return None + + +async def keep_running() -> None: + """Scheduled runs: every user with autopilot on gets a run every few hours.""" + tracker.finish_stale_autopilot_runs() + while True: + await asyncio.sleep(_TICK_S) + try: + now = datetime.now(timezone.utc) + for user_id, raw, last_run_at in tracker.autopilot_schedules(): + if not {**DEFAULTS, **clean_settings(raw, strict=False)}["enabled"]: + continue + last = _parse(last_run_at) + if last is None or now - last >= _EVERY: + start(user_id) + except Exception: + logger.exception("autopilot scheduler tick failed") diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index 52332d6..c07a242 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -1062,6 +1062,37 @@ def _with_empty_fields(notes: str, empty: list[str], left_blank: list[str] = ()) return "\n\n".join(blocks + [notes]).strip() +# Unattended runs (autopilot.py): once the form is filled, `decide` is shown what the fill left behind — +# {ready, error, notes, questions, empty} — and says whether to send it. Nobody reviews it in between. +Decider = Callable[[dict], Awaitable[bool]] + + +async def _finish_unattended(decide: Decider, filled: dict, browser_session, llm, sensitive_data: dict | None, + secret: str) -> AsyncGenerator[dict, None]: + """ + Send the filled form if `decide` says to, else leave it. The last event is done=True and carries the + fill's results plus `form_screenshot` (the filled form, before anything is sent); when the submit agent + ran, also `submit_attempted`, `submitted` (only if the page confirmed it), `submit_detail` and + `confirm_screenshot` (the page after the click). + """ + final = {"action": "Filled in; not sent", "screenshot": None, "blocked": False, "reason": None, "done": True, + "submitted": False, "submit_attempted": False, **filled, + "form_screenshot": await _screenshot_b64(browser_session)} + try: + send = await decide(filled) + except Exception as exc: + logger.warning("autopilot decision failed: %s", exc) + send = False + if send: + yield {"action": "Submitting the application…", "screenshot": None, "blocked": False, "reason": None, + "done": False} + ok, detail = await _submit_with_agent(llm, browser_session, sensitive_data, secret) + final.update(submit_attempted=True, submitted=ok, submit_detail=detail, + confirm_screenshot=await _screenshot_b64(browser_session), + action="Application submitted" if ok else "Couldn't confirm the submission") + yield final + + async def apply_with_browser( job_url: str, cv_profile: dict, @@ -1073,12 +1104,15 @@ async def apply_with_browser( cv_path: str = "", cover_letter_path: str = "", answerer: Answerer | None = None, + decide: Decider | None = None, ) -> AsyncGenerator[dict, None]: """ Async generator: the browser-use agent fills the application, then the user reviews it and decides whether to submit (see module docstring). The CV and cover-letter PDFs are uploaded under the candidate's name ("Ada_Lovelace_CV.pdf"), not their stored ids. `answerer` answers the form's questions (answers.answer_questions); without one, every question is left for the user. + With `decide` the run is unattended: there is no review, and `decide` says whether the filled form is + sent (see _finish_unattended for what the last event then carries). Yields dicts: { action, screenshot (base64|None), blocked (bool), reason (str|None), done (bool), interactive (bool, optional), submitted (bool, only on the final done event), @@ -1186,6 +1220,12 @@ async def on_step(browser_state, agent_output, step_number): left_blank = [q["question"] for q in questions if q["source"] == "needs_you"] notes = _with_empty_fields(summary, empty, left_blank)[:_MAX_NOTES_CHARS] + if decide is not None: + filled = {"ready": ready, "error": agent_error, "notes": notes, "questions": questions, "empty": empty} + async for step in _finish_unattended(decide, filled, browser_session, llm, sensitive_data, job_password): + yield step + return + async def put_answers_on_form(answers: list[dict]) -> dict: ok, detail = await _fill_with_agent(llm, browser_session, sensitive_data, answers, job_password) for a in answers: diff --git a/api/app/jobs_db.py b/api/app/jobs_db.py index a3ea2c1..6b37776 100644 --- a/api/app/jobs_db.py +++ b/api/app/jobs_db.py @@ -642,3 +642,40 @@ def store_attributes(job_db_id: str, attributes: dict | None) -> None: attributes["clearance"], attributes["years_min"], attributes["degree"], ATTRIBUTES_VERSION, job_db_id)) con.close() + + +async def ensure_full_description(job: dict) -> dict: + """ + Make sure a job has its real, full description before we analyse it. + + Stored descriptions are capped at 500 chars at sync time (and Brave results + are only a web snippet), so anything that short is re-fetched from the live + listing page. The result is persisted back to the DB so re-runs and revisits + are instant. + + The jobs table is shared by all users, so the client-supplied job is never + trusted for the write: we only persist when the id matches a stored row, and + then we fetch that row's *stored* URL — never the URL from the request body. + """ + desc = (job.get("description") or "").strip() + if len(desc) > 600: # already a full description (e.g. previously fetched) + return job + + from .job_searcher import fetch_full_description + db_id = str(job.get("id") or "") + stored = get_job(db_id) if db_id else None + if stored: + stored_desc = (stored.get("description") or "").strip() + if len(stored_desc) > 600: # another request already fetched it + return {**job, "description": stored_desc} + full = await fetch_full_description(stored.get("url") or "") + if full and len(full) > len(stored_desc): + update_description(db_id, full) + return {**job, "description": full} + return job + + # Not a stored job (e.g. pasted via /jobs/from-url): enrich this request only. + full = await fetch_full_description(job.get("url", "")) + if full and len(full) > len(desc): + job = {**job, "description": full} + return job diff --git a/api/app/main.py b/api/app/main.py index ecb98b8..476b32e 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -20,6 +20,7 @@ from .guardrails import check_cv_for_injection, GuardrailViolation from . import tracker from . import answers +from . import autopilot from . import auth from . import pdf_generator from . import doc_render @@ -50,7 +51,8 @@ async def lifespan(app: FastAPI): jobs_db.init_db() pdf_generator.init_pdf_dir(data_dir) background = [asyncio.create_task(sponsor_register.keep_fresh()), - asyncio.create_task(job_attributes.keep_extracting())] + asyncio.create_task(job_attributes.keep_extracting()), + asyncio.create_task(autopilot.keep_running())] yield for task in background: task.cancel() @@ -813,43 +815,6 @@ def _sse_json(event_type: str, data: dict) -> str: return f"data: {json.dumps(payload)}\n\n" -async def _ensure_full_description(job: dict) -> dict: - """ - Make sure a job has its real, full description before we analyse it. - - Stored descriptions are capped at 500 chars at sync time (and Brave results - are only a web snippet), so anything that short is re-fetched from the live - listing page. The result is persisted back to the DB so re-runs and revisits - are instant. - - The jobs table is shared by all users, so the client-supplied job is never - trusted for the write: we only persist when the id matches a stored row, and - then we fetch that row's *stored* URL — never the URL from the request body. - """ - desc = (job.get("description") or "").strip() - if len(desc) > 600: # already a full description (e.g. previously fetched) - return job - - from .job_searcher import fetch_full_description - db_id = str(job.get("id") or "") - stored = jobs_db.get_job(db_id) if db_id else None - if stored: - stored_desc = (stored.get("description") or "").strip() - if len(stored_desc) > 600: # another request already fetched it - return {**job, "description": stored_desc} - full = await fetch_full_description(stored.get("url") or "") - if full and len(full) > len(stored_desc): - jobs_db.update_description(db_id, full) - return {**job, "description": full} - return job - - # Not a stored job (e.g. pasted via /jobs/from-url): enrich this request only. - full = await fetch_full_description(job.get("url", "")) - if full and len(full) > len(desc): - job = {**job, "description": full} - return job - - @app.post("/jobs/from-url") async def job_from_url( body: dict, @@ -884,7 +849,7 @@ async def analyze_job( current_user: Annotated[auth.User, Depends(auth.get_current_user)], ): """SSE: score fit + AI risk for a single job against the user's persona.""" - job = await _ensure_full_description(body.get("job", {})) + job = await jobs_db.ensure_full_description(body.get("job", {})) persona = tracker.get_user_persona(current_user.id) async def gen(): @@ -917,7 +882,7 @@ async def generate_cover_letter_endpoint( current_user: Annotated[auth.User, Depends(auth.get_current_user)], ): """SSE: generate a tailored cover letter for a job.""" - job = await _ensure_full_description(body.get("job", {})) + job = await jobs_db.ensure_full_description(body.get("job", {})) tone = body.get("tone", "professional") persona = tracker.get_user_persona(current_user.id) @@ -937,7 +902,7 @@ async def generate_tailored_cv_endpoint( current_user: Annotated[auth.User, Depends(auth.get_current_user)], ): """SSE: generate a tailored CV (Markdown) for a specific job.""" - job = await _ensure_full_description(body.get("job", {})) + job = await jobs_db.ensure_full_description(body.get("job", {})) persona = tracker.get_user_persona(current_user.id) async def gen(): @@ -1124,6 +1089,86 @@ async def draft_answers(body: dict, current_user: Annotated[auth.User, Depends(a from fastapi.responses import FileResponse # noqa: E402 +# ── Autopilot ───────────────────────────────────────────────────────────────── + +# What the user can still do with a run: retry it (autopilot tries the job again) or clear it from the queue. +_RETRYABLE = {"needs_you", "failed", "skipped", "ready", "dismissed"} +_DISMISSABLE = {"needs_you", "failed", "skipped", "ready", "unconfirmed"} + + +@app.get("/autopilot") +async def autopilot_status(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Autopilot's settings, whether a run is going (and what it's doing), and how many went out today.""" + return autopilot.status(current_user.id) + + +@app.put("/autopilot/settings") +async def autopilot_settings(body: dict, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Change any of {enabled, live, min_fit, daily_cap}; returns them all.""" + try: + return autopilot.save_settings(current_user.id, body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@app.post("/autopilot/run", status_code=202) +async def autopilot_run(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Start a run now, in the background (GET /autopilot follows it).""" + if not autopilot.start(current_user.id): + raise HTTPException(status_code=409, detail="Autopilot is already running") + return {"started": True} + + +@app.get("/autopilot/runs") +async def autopilot_runs(current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Every job autopilot has dealt with, latest first, with which screenshots it kept.""" + runs = tracker.list_autopilot_runs(current_user.id) + for run in runs: + run["screenshots"] = [w for w in autopilot.SCREENSHOTS if os.path.exists(autopilot.screenshot_path(run, w))] + run.pop("user_id", None) + return runs + + +def _own_run(user_id: str, run_id: str) -> dict: + run = tracker.get_autopilot_run(user_id, run_id) + if run is None: + raise HTTPException(status_code=404, detail="No such autopilot run") + return run + + +@app.post("/autopilot/runs/{run_id}/retry", status_code=202) +async def autopilot_retry(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Have autopilot try this job again now: after you've answered its questions, say.""" + run = _own_run(current_user.id, run_id) + if run["outcome"] not in _RETRYABLE: + raise HTTPException(status_code=400, detail=f"A run that is {run['outcome']} can't be retried") + if not autopilot.start(current_user.id, run["job_id"]): + raise HTTPException(status_code=409, detail="Autopilot is already running") + return {"started": True} + + +@app.post("/autopilot/runs/{run_id}/dismiss") +async def autopilot_dismiss(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """Clear a run from the queue. Autopilot won't try the job again unless you retry it.""" + run = _own_run(current_user.id, run_id) + if run["outcome"] not in _DISMISSABLE: + raise HTTPException(status_code=400, detail=f"A run that is {run['outcome']} can't be dismissed") + tracker.update_autopilot_run(run_id, user_id=current_user.id, outcome="dismissed") + return {"dismissed": run_id} + + +@app.get("/autopilot/runs/{run_id}/screenshots/{which}") +async def autopilot_screenshot(run_id: str, which: str, + current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """A run's screenshot: 'form' (the filled form) or 'confirm' (the page after it was sent).""" + if which not in autopilot.SCREENSHOTS: + raise HTTPException(status_code=404, detail="No such screenshot") + path = autopilot.screenshot_path(_own_run(current_user.id, run_id), which) + if not os.path.exists(path): + raise HTTPException(status_code=404, detail="No such screenshot") + return FileResponse(path, media_type="image/jpeg") + + @app.get("/documents/{doc_id}/download") async def download_pdf( doc_id: str, diff --git a/api/app/tracker.py b/api/app/tracker.py index fc35428..558a286 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -91,6 +91,48 @@ """ ANSWER_KINDS = ("fact", "written") +# Autopilot (see autopilot.py): each user's settings, and one row per job it has tried. job_id is the +# jobs.db row id; a job is tried once, so a retry replaces its row. practice is 1 when the run filled the +# form without being allowed to send it. reasons (a JSON list of sentences) says why it wasn't sent. +_CREATE_AUTOPILOT_SETTINGS_TABLE = """ +CREATE TABLE IF NOT EXISTS autopilot_settings ( + user_id TEXT PRIMARY KEY, + settings TEXT NOT NULL DEFAULT '{}', + last_run_at TEXT +); +""" +_CREATE_AUTOPILOT_RUNS_TABLE = """ +CREATE TABLE IF NOT EXISTS autopilot_runs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + job_id TEXT NOT NULL, + job_title TEXT NOT NULL, + company TEXT NOT NULL, + location TEXT, + job_url TEXT, + outcome TEXT NOT NULL, + practice INTEGER NOT NULL DEFAULT 0, + fit_score REAL, + reasons TEXT NOT NULL DEFAULT '[]', + questions TEXT NOT NULL DEFAULT '[]', + notes TEXT, + cv_doc_id TEXT, + letter_doc_id TEXT, + application_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +""" +# running - being worked on now +# submitted - sent, and the page confirmed it +# unconfirmed - the submit button was clicked but the page didn't confirm it: the user checks +# ready - practice mode: everything checked out, and it would have been sent +# needs_you - filled in but held back (see reasons) +# skipped - not tried: the fit was too low, say +# failed - something broke before the form was filled +# dismissed - the user cleared it from the queue +AUTOPILOT_OUTCOMES = ("running", "submitted", "unconfirmed", "ready", "needs_you", "skipped", "failed", "dismissed") + def init_db(db_path: str = "") -> None: global _DB_PATH @@ -106,6 +148,9 @@ def init_db(db_path: str = "") -> None: con.execute(_CREATE_JOB_MARKS_TABLE) con.execute(_CREATE_ANSWER_BANK_TABLE) con.execute("CREATE INDEX IF NOT EXISTS idx_answer_bank_user ON answer_bank(user_id)") + con.execute(_CREATE_AUTOPILOT_SETTINGS_TABLE) + con.execute(_CREATE_AUTOPILOT_RUNS_TABLE) + con.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_autopilot_job ON autopilot_runs(user_id, job_id)") # Migrations — safe to run on existing DBs for stmt in [ "ALTER TABLE users ADD COLUMN persona TEXT DEFAULT '{}'", @@ -534,3 +579,148 @@ def delete_answer(user_id: str, answer_id: str) -> bool: cur = con.execute("DELETE FROM answer_bank WHERE id=? AND user_id=?", (answer_id, user_id)) con.close() return cur.rowcount == 1 + + +# ── Autopilot ───────────────────────────────────────────────────────────────── + +def get_autopilot_settings(user_id: str) -> dict: + """The user's autopilot settings as saved ({} if never saved); autopilot.py fills in the defaults.""" + con = sqlite3.connect(_DB_PATH) + row = con.execute("SELECT settings FROM autopilot_settings WHERE user_id=?", (user_id,)).fetchone() + con.close() + try: + settings = json.loads(row[0]) if row else {} + except json.JSONDecodeError: + settings = {} + return settings if isinstance(settings, dict) else {} + + +def save_autopilot_settings(user_id: str, settings: dict) -> None: + con = sqlite3.connect(_DB_PATH) + with con: + con.execute("INSERT INTO autopilot_settings (user_id, settings) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET settings=excluded.settings", (user_id, json.dumps(settings))) + con.close() + + +def mark_autopilot_run(user_id: str) -> None: + """Record that an autopilot run started now (the scheduler spaces runs by it).""" + con = sqlite3.connect(_DB_PATH) + with con: + con.execute("INSERT INTO autopilot_settings (user_id, last_run_at) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET last_run_at=excluded.last_run_at", (user_id, _now())) + con.close() + + +def autopilot_schedules() -> list[tuple[str, dict, str | None]]: + """(user id, saved settings, last run started at) for every user who has saved autopilot settings.""" + con = sqlite3.connect(_DB_PATH) + rows = con.execute("SELECT user_id, settings, last_run_at FROM autopilot_settings").fetchall() + con.close() + out = [] + for user_id, raw, last_run_at in rows: + try: + settings = json.loads(raw or "{}") + except json.JSONDecodeError: + settings = {} + out.append((user_id, settings if isinstance(settings, dict) else {}, last_run_at)) + return out + + +def _autopilot_run(row: sqlite3.Row) -> dict: + run = dict(row) + run["practice"] = bool(run["practice"]) + run["reasons"] = json.loads(run["reasons"] or "[]") + run["questions"] = json.loads(run["questions"] or "[]") + return run + + +def start_autopilot_run(user_id: str, job: dict, outcome: str = "running", *, practice: bool = False, + fit_score: float | None = None, reasons: list[str] = ()) -> str: + """Record that autopilot is trying (or skipped) a job; replaces an earlier row for the same job.""" + if outcome not in AUTOPILOT_OUTCOMES: + raise ValueError(f"outcome must be one of {AUTOPILOT_OUTCOMES}") + run_id, now = uuid.uuid4().hex, _now() + con = sqlite3.connect(_DB_PATH) + with con: + con.execute("DELETE FROM autopilot_runs WHERE user_id=? AND job_id=?", (user_id, str(job.get("id", "")))) + con.execute( + "INSERT INTO autopilot_runs (id, user_id, job_id, job_title, company, location, job_url, outcome, " + "practice, fit_score, reasons, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (run_id, user_id, str(job.get("id", "")), job.get("title") or "", job.get("company") or "", + job.get("location") or "", job.get("url") or "", outcome, int(practice), fit_score, + json.dumps(list(reasons), ensure_ascii=False), now, now)) + con.close() + return run_id + + +_RUN_FIELDS = {"outcome", "practice", "fit_score", "reasons", "questions", "notes", "cv_doc_id", "letter_doc_id", + "application_id"} + + +def update_autopilot_run(run_id: str, *, user_id: str, **fields) -> bool: + unknown = set(fields) - _RUN_FIELDS + if unknown: + raise ValueError(f"unknown autopilot run fields: {sorted(unknown)}") + if "outcome" in fields and fields["outcome"] not in AUTOPILOT_OUTCOMES: + raise ValueError(f"outcome must be one of {AUTOPILOT_OUTCOMES}") + for name in ("reasons", "questions"): + if name in fields: + fields[name] = json.dumps(list(fields[name]), ensure_ascii=False) + if "practice" in fields: + fields["practice"] = int(bool(fields["practice"])) + fields["updated_at"] = _now() + con = sqlite3.connect(_DB_PATH) + with con: + cur = con.execute(f"UPDATE autopilot_runs SET {', '.join(f'{k}=?' for k in fields)} WHERE id=? AND user_id=?", + [*fields.values(), run_id, user_id]) + con.close() + return cur.rowcount == 1 + + +def get_autopilot_run(user_id: str, run_id: str) -> dict | None: + con = sqlite3.connect(_DB_PATH) + con.row_factory = sqlite3.Row + row = con.execute("SELECT * FROM autopilot_runs WHERE id=? AND user_id=?", (run_id, user_id)).fetchone() + con.close() + return _autopilot_run(row) if row else None + + +def list_autopilot_runs(user_id: str, limit: int = 200) -> list[dict]: + """The user's autopilot runs, latest first.""" + con = sqlite3.connect(_DB_PATH) + con.row_factory = sqlite3.Row + rows = con.execute("SELECT * FROM autopilot_runs WHERE user_id=? ORDER BY updated_at DESC LIMIT ?", + (user_id, limit)).fetchall() + con.close() + return [_autopilot_run(r) for r in rows] + + +def autopilot_tried(user_id: str, *, count_practice: bool = True) -> set[str]: + """Job ids autopilot has already dealt with. With count_practice=False, jobs it only filled in practice + mode don't count, so a live run tries them for real.""" + sql = "SELECT job_id FROM autopilot_runs WHERE user_id=?" + if not count_practice: + sql += " AND NOT (practice=1 AND outcome IN ('ready', 'needs_you'))" + con = sqlite3.connect(_DB_PATH) + rows = con.execute(sql, (user_id,)).fetchall() + con.close() + return {r[0] for r in rows} + + +def autopilot_sent_since(user_id: str, since: str) -> int: + """Applications autopilot sent (or clicked submit on) since `since`, an ISO timestamp.""" + con = sqlite3.connect(_DB_PATH) + row = con.execute("SELECT count(*) FROM autopilot_runs WHERE user_id=? AND outcome IN ('submitted', 'unconfirmed') " + "AND updated_at >= ?", (user_id, since)).fetchone() + con.close() + return row[0] + + +def finish_stale_autopilot_runs() -> None: + """Runs left 'running' by a restart are over: nothing is working on them now.""" + con = sqlite3.connect(_DB_PATH) + with con: + con.execute("UPDATE autopilot_runs SET outcome='failed', reasons=?, updated_at=? WHERE outcome='running'", + (json.dumps(["Windrush restarted while this was in progress."]), _now())) + con.close() diff --git a/api/tests/browser/test_browser_agent.py b/api/tests/browser/test_browser_agent.py index 9af2a70..45226f3 100644 --- a/api/tests/browser/test_browser_agent.py +++ b/api/tests/browser/test_browser_agent.py @@ -415,3 +415,34 @@ async def flow(): # The dropdown is filled now, so the page no longer lists it as empty. assert "Do you have any unspent criminal convictions?" not in reviews[1]["notes"] assert_no_chromium_left() + + +def test_unattended_run_sends_only_when_decide_says_so(ba, site, monkeypatch): + def run(send: bool): + llm = ScriptedLLM() + monkeypatch.setattr(ba, "_make_browser_llm", lambda: llm) + shown = [] + + async def decide(filled): + shown.append(filled) + return send + + async def flow(): + return [event async for event in ba.apply_with_browser( + f"{site}/form.html", {"core_info": {"first_name": "Ada"}}, "", asyncio.Queue(), decide=decide)] + return llm, shown, asyncio.run(flow()) + + llm, shown, events = run(send=True) + final = events[-1] + assert not any(e.get("interactive") for e in events) # nobody is asked anything + assert shown == [{"ready": True, "error": "", "notes": "Filled every field", "questions": [], "empty": []}] + assert llm.tasks == ["fill", "submit"] + assert final["done"] and final["submitted"] and final["submit_attempted"] + assert _is_jpeg(final["form_screenshot"]) and _is_jpeg(final["confirm_screenshot"]) + + llm, shown, events = run(send=False) + final = events[-1] + assert llm.tasks == ["fill"] # the submit agent never ran + assert final["done"] and not final["submitted"] and not final["submit_attempted"] + assert _is_jpeg(final["form_screenshot"]) and "confirm_screenshot" not in final + assert_no_chromium_left() diff --git a/api/tests/test_autopilot.py b/api/tests/test_autopilot.py new file mode 100644 index 0000000..3b74b74 --- /dev/null +++ b/api/tests/test_autopilot.py @@ -0,0 +1,320 @@ +"""Autopilot: which jobs it tries, when it sends a form and when it holds it for the user.""" +import asyncio +import base64 +import os +import uuid + +import pytest +from conftest import make_job + +from app import agent, autopilot, browser_agent, doc_check, doc_render, jobs_db, pdf_generator, tracker + +JPEG = base64.b64encode(b"\xff\xd8\xff\xe0fake-jpeg").decode() +PERSONA = {"core_info": {"first_name": "Ada", "last_name": "Lovelace"}, + "preferences": {"preferred_locations": ["London"]}} +CLEAN = {"question": "Do you have the right to work in the UK?", "options": ["Yes", "No"], "kind": "fact", + "source": "profile", "answer": "Yes", "because": "", "bank_id": "", "flags": []} + + +def _answer(question, source, answer="", kind="fact"): + return {"question": question, "options": [], "kind": kind, "source": source, "answer": answer, "because": "", + "bank_id": "", "flags": []} + + +# ── The rule ────────────────────────────────────────────────────────────────── + +def test_a_clean_fill_can_go(): + filled = {"ready": True, "error": "", "questions": [CLEAN, _answer("Notice period", "saved", "Immediately")], + "empty": []} + assert autopilot.hold_reasons(filled, []) == [] + + +@pytest.mark.parametrize("filled, expected", [ + ({"ready": True, "questions": [_answer("Do you need a visa?", "needs_you")]}, "1 question only you can answer"), + ({"ready": True, "questions": [_answer("Why us?", "drafted", "Because…", "written")]}, + "1 written answer drafted for this job that you haven't read"), + ({"ready": True, "questions": [CLEAN], "empty": ["Pronouns", "Salary"]}, "2 required fields still empty"), + ({"ready": False, "questions": []}, "The agent couldn't finish the form"), + ({"ready": True, "error": "page crashed", "questions": []}, "The agent hit a problem: page crashed"), + ({"ready": True, "questions": [{**CLEAN, "flags": ["odd"]}]}, "didn't come from your saved answers or profile"), +]) +def test_anything_new_or_missing_holds_the_form(filled, expected): + reasons = autopilot.hold_reasons({"error": "", "empty": [], **filled}, []) + assert len(reasons) == 1 and expected in reasons[0] + + +def test_a_document_claim_the_profile_doesnt_back_holds_the_form(): + persona = {"core_info": {"first_name": "Ada"}, "skills": [{"category": "Languages", "skills": ["Python"]}]} + cv = {"summary": "Python and Kubernetes engineer", "experience": [], "projects": []} + letter = {"paragraphs": ["I have used Python for 2 years."]} + problems = autopilot.document_problems(cv, letter, persona, {"title": "Engineer", "description": ""}) + assert len(problems) == 2 + assert problems[0].startswith("The tailored CV") and "Kubernetes" in problems[0] + assert problems[1].startswith("The cover letter") and "2" in problems[1] + assert autopilot.hold_reasons({"ready": True, "error": "", "questions": [], "empty": []}, problems) == problems + + +@pytest.mark.parametrize("url, ats", [ + ("https://boards.greenhouse.io/acme/jobs/1", "Greenhouse"), + ("https://job-boards.greenhouse.io/acme/jobs/1", "Greenhouse"), + ("https://jobs.lever.co/acme/abc", "Lever"), + ("https://jobs.ashbyhq.com/acme/abc", "Ashby"), + ("https://apply.workable.com/acme/j/ABC/", "Workable"), + ("http://boards.greenhouse.io/acme/jobs/1", ""), # not https + ("https://www.linkedin.com/jobs/view/1", ""), + ("https://uk.indeed.com/viewjob?jk=1", ""), + ("https://acme.wd3.myworkdayjobs.com/en-US/careers/job/1", ""), + ("https://greenhouse.io.evil.example/jobs/1", ""), + ("", ""), +]) +def test_only_forms_on_the_supported_trackers(url, ats): + assert autopilot.ats_of(url) == ats + + +def test_settings_are_checked(): + assert autopilot.clean_settings({"live": True, "daily_cap": 5, "min_fit": 60, "other": 1}) == \ + {"live": True, "daily_cap": 5, "min_fit": 60} + for bad in ({"daily_cap": 0}, {"daily_cap": 11}, {"min_fit": 40}, {"min_fit": 80.5}, {"live": "yes"}, + {"enabled": 1}): + with pytest.raises(ValueError): + autopilot.clean_settings(bad) + assert autopilot.clean_settings({"daily_cap": 99, "live": True}, strict=False) == {"live": True} + + +# ── A run, with the model and the browser faked ─────────────────────────────── + +URLS = { + "alpha": "https://boards.greenhouse.io/alpha/jobs/1", + "beta": "https://jobs.lever.co/beta/2", + "gamma": "https://jobs.ashbyhq.com/gamma/3", + "linked": "https://www.linkedin.com/jobs/view/4", + "workday": "https://delta.wd3.myworkdayjobs.com/careers/job/5", +} + + +class World: + """The fakes' script: each company's fit, and what its form leaves behind.""" + + def __init__(self): + self.fit = {name: (90, "strong") for name in URLS} + self.forms = {name: {"questions": [CLEAN], "empty": [], "confirm": True} for name in URLS} + self.filled: list[str] = [] # companies whose form was filled + self.decided: dict[str, bool] = {} + self.cv_paths: list[str] = [] + + +@pytest.fixture +def world(db, ollama, monkeypatch): + w = World() + + async def execute_tool(name, tool_input): + if name == "score_job_fit": + return {"ranked_jobs": [{"job_id": j["job_id"], "fit_score": w.fit[j["company"]][0], + "level_match": w.fit[j["company"]][1], "reasoning": "why"} + for j in tool_input["jobs"]]} + if name == "generate_tailored_cv": + return {"cv": {"summary": "Engineer"}, "template_id": "classic"} + if name == "generate_cover_letter": + return {"letter": {"salutation": "Dear Hiring Manager,", "paragraphs": ["I'd like to join."], + "signoff": "Yours faithfully,", "candidate_name": "Ada Lovelace"}, "research": ""} + raise AssertionError(name) + + async def render_pdf(doc_type, doc, template_id=None): + doc_id = uuid.uuid4().hex + with open(pdf_generator.get_pdf_path(doc_id), "wb") as f: + f.write(b"%PDF-1.4 fake") + return doc_id + + async def ensure_full_description(job): + return job + + async def apply_with_browser(job_url, persona, cover_letter, queue, frames, *, job_email, job_password, cv_path, + cover_letter_path, answerer, decide): + company = next(name for name, url in URLS.items() if url == job_url) + w.filled.append(company) + w.cv_paths.append(cv_path) + assert cover_letter.startswith("Dear Hiring Manager,") and os.path.exists(cover_letter_path) + form = w.forms[company] + yield {"action": "Step 1: filling", "done": False} + filled = {"ready": True, "error": "", "notes": "Filled it", "questions": form["questions"], + "empty": form["empty"]} + final = {"action": "Filled in; not sent", "done": True, "submitted": False, "submit_attempted": False, + **filled, "form_screenshot": JPEG} + send = await decide(filled) + w.decided[company] = send + if send: + final.update(submit_attempted=True, submitted=form["confirm"], submit_detail="Thanks for applying", + confirm_screenshot=JPEG) + yield final + + monkeypatch.setattr(agent, "execute_tool", execute_tool) + monkeypatch.setattr(doc_render, "render_pdf", render_pdf) + monkeypatch.setattr(jobs_db, "ensure_full_description", ensure_full_description) + monkeypatch.setattr(browser_agent, "apply_with_browser", apply_with_browser) + monkeypatch.setattr(doc_check, "check_facts", lambda *a, **k: []) + jobs_db.add_jobs([make_job(i, f"Graduate Engineer {name}", name, url=url, description="Python") + for i, (name, url) in enumerate(URLS.items())]) + return w + + +@pytest.fixture +def ada(world): + uid = tracker.create_user("ada@example.com", "h") + tracker.update_user_persona(uid, PERSONA) + return uid + + +def _run(uid, **settings): + if settings: + autopilot.save_settings(uid, settings) + return asyncio.run(autopilot.run(uid)) + + +def _runs(uid) -> dict[str, dict]: + return {r["company"]: r for r in tracker.list_autopilot_runs(uid)} + + +def test_practice_mode_fills_but_never_sends(ada, world): + result = _run(ada) + assert sorted(world.filled) == ["alpha", "beta", "gamma"] # LinkedIn and Workday never opened + assert world.decided == {"alpha": False, "beta": False, "gamma": False} + runs = _runs(ada) + assert {r["outcome"] for r in runs.values()} == {"ready"} and all(r["practice"] for r in runs.values()) + assert result["counts"] == {"ready": 3} + assert tracker.list_applications(ada) == [] # practice leaves the tracker alone + assert all(path and os.path.exists(path) for path in world.cv_paths) # the tailored CV went up + + +def test_live_mode_sends_clean_forms_and_keeps_screenshots(ada, world): + _run(ada, live=True) + runs = _runs(ada) + assert {name: r["outcome"] for name, r in runs.items()} == \ + {"alpha": "submitted", "beta": "submitted", "gamma": "submitted"} + for run in runs.values(): + for which in ("form", "confirm"): + with open(autopilot.screenshot_path({**run, "user_id": ada}, which), "rb") as f: + assert f.read() == base64.b64decode(JPEG) + apps = {a["company"]: a for a in tracker.list_applications(ada)} + assert {a["status"] for a in apps.values()} == {"Applied"} and apps["alpha"]["notes"] == "Sent by autopilot." + assert apps["alpha"]["questions"] == [CLEAN] + assert autopilot.sent_last_day(ada) == 3 + + +def test_the_daily_limit_stops_the_run(ada, world): + _run(ada, live=True, daily_cap=2) + assert sorted(r["outcome"] for r in _runs(ada).values()) == ["submitted", "submitted"] + assert len(world.filled) == 2 + result = _run(ada) # same day: nothing more is tried + assert "limit of 2 a day" in result["note"] and len(world.filled) == 2 + + +def test_a_form_that_needs_the_user_is_held_with_reasons(ada, world): + world.forms["alpha"]["questions"] = [CLEAN, _answer("Do you hold SC clearance?", "needs_you")] + world.forms["beta"]["empty"] = ["Preferred pronouns"] + _run(ada, live=True) + runs = _runs(ada) + assert runs["alpha"]["outcome"] == "needs_you" and "Do you hold SC clearance?" in runs["alpha"]["reasons"][0] + assert runs["beta"]["outcome"] == "needs_you" and "Preferred pronouns" in runs["beta"]["reasons"][0] + assert runs["gamma"]["outcome"] == "submitted" + assert world.decided == {"alpha": False, "beta": False, "gamma": True} + apps = {a["company"]: a for a in tracker.list_applications(ada)} + assert apps["alpha"]["status"] == "Pending Review" and apps["gamma"]["status"] == "Applied" + + +def test_a_click_the_page_doesnt_confirm_is_not_counted_as_applied(ada, world): + world.forms["alpha"]["confirm"] = False + _run(ada, live=True) + run = _runs(ada)["alpha"] + assert run["outcome"] == "unconfirmed" and "Check whether it went through" in run["reasons"][0] + assert {a["company"]: a["status"] for a in tracker.list_applications(ada)}["alpha"] == "Pending Review" + assert autopilot.sent_last_day(ada) == 3 # still counts toward the limit + + +def test_poor_fits_and_stretches_are_skipped_without_filling(ada, world): + world.fit["alpha"] = (60, "strong") + world.fit["beta"] = (95, "reach") + _run(ada, live=True) + runs = _runs(ada) + assert runs["alpha"]["outcome"] == "skipped" and "under your threshold of 75" in runs["alpha"]["reasons"][0] + assert runs["beta"]["outcome"] == "skipped" and "stretch" in runs["beta"]["reasons"][0] + assert world.filled == ["gamma"] + + +def test_each_job_is_tried_once_but_practice_runs_dont_count_once_live(ada, world): + world.forms["alpha"]["questions"] = [_answer("Visa?", "needs_you")] + _run(ada) # practice: all three filled + _run(ada) # nothing new + assert len(world.filled) == 3 + _run(ada, live=True) # live: the practice ones are tried for real + assert len(world.filled) == 6 + _run(ada) # and then not again + assert len(world.filled) == 6 + + +def test_jobs_already_in_the_tracker_are_left_alone(ada, world): + tracker.add_application(ada, {"job_id": "x", "title": "Graduate Engineer alpha", "company": "alpha"}, {}, "", {}, + status="Saved") + _run(ada) + assert sorted(world.filled) == ["beta", "gamma"] + + +def test_a_retry_tries_a_held_job_again_even_below_the_threshold(ada, world): + world.fit["alpha"] = (60, "strong") + _run(ada, live=True) + skipped = _runs(ada)["alpha"] + asyncio.run(autopilot.run(ada, skipped["job_id"])) + assert _runs(ada)["alpha"]["outcome"] == "submitted" + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +def test_status_settings_and_runs_endpoints(ada, world, client_for): + client = client_for(ada) + status = client.get("/autopilot").json() + assert status["settings"] == autopilot.DEFAULTS and status["running"] is False and status["sent_last_day"] == 0 + assert client.put("/autopilot/settings", json={"daily_cap": 50}).status_code == 400 + assert client.put("/autopilot/settings", json={"live": True, "daily_cap": 2}).json()["daily_cap"] == 2 + assert client.get("/autopilot").json()["settings"]["live"] is True + + world.forms["alpha"]["questions"] = [_answer("Visa?", "needs_you")] + asyncio.run(autopilot.run(ada)) + runs = {r["company"]: r for r in client.get("/autopilot/runs").json()} + assert runs["alpha"]["outcome"] == "needs_you" and runs["alpha"]["screenshots"] == ["form"] + assert runs["beta"]["screenshots"] == ["form", "confirm"] and "user_id" not in runs["beta"] + + shot = client.get(f"/autopilot/runs/{runs['beta']['id']}/screenshots/confirm") + assert shot.status_code == 200 and shot.headers["content-type"] == "image/jpeg" + assert client.get(f"/autopilot/runs/{runs['alpha']['id']}/screenshots/confirm").status_code == 404 + assert client.get(f"/autopilot/runs/{runs['alpha']['id']}/screenshots/passwd").status_code == 404 + + assert client.post(f"/autopilot/runs/{runs['beta']['id']}/dismiss").status_code == 400 # sent: stays + assert client.post(f"/autopilot/runs/{runs['beta']['id']}/retry").status_code == 400 + assert client.post(f"/autopilot/runs/{runs['alpha']['id']}/dismiss").json() == {"dismissed": runs["alpha"]["id"]} + assert tracker.get_autopilot_run(ada, runs["alpha"]["id"])["outcome"] == "dismissed" + + +def test_another_users_runs_are_out_of_reach(ada, world, client_for, make_user): + asyncio.run(autopilot.run(ada)) + run_id = tracker.list_autopilot_runs(ada)[0]["id"] + other = client_for(make_user("eve@example.com")) + assert other.get("/autopilot/runs").json() == [] + for path in (f"/autopilot/runs/{run_id}/screenshots/form",): + assert other.get(path).status_code == 404 + assert other.post(f"/autopilot/runs/{run_id}/dismiss").status_code == 404 + assert other.post(f"/autopilot/runs/{run_id}/retry").status_code == 404 + + +def test_one_run_at_a_time(ada, world, client_for, monkeypatch): + autopilot._runs[ada] = autopilot._Run(started_at="now", live=False) + try: + assert client_for(ada).post("/autopilot/run").status_code == 409 + assert autopilot.start(ada) is False + finally: + autopilot._runs.pop(ada, None) + + +def test_a_restart_finishes_runs_left_running(ada, world): + run_id = tracker.start_autopilot_run(ada, {"id": "j1", "title": "T", "company": "C"}) + tracker.finish_stale_autopilot_runs() + run = tracker.get_autopilot_run(ada, run_id) + assert run["outcome"] == "failed" and "restarted" in run["reasons"][0] diff --git a/api/tests/test_ssrf.py b/api/tests/test_ssrf.py index 17c0aee..238d35c 100644 --- a/api/tests/test_ssrf.py +++ b/api/tests/test_ssrf.py @@ -84,10 +84,10 @@ async def fake_fetch(url, max_chars=8000): return ("ATTACKER " if "evil" in url else "REAL ") * 100 monkeypatch.setattr(job_searcher, "fetch_full_description", fake_fetch) - out = asyncio.run(main._ensure_full_description({"id": "victim", "description": "", "url": "https://evil.example/x"})) + out = asyncio.run(jobs_db.ensure_full_description({"id": "victim", "description": "", "url": "https://evil.example/x"})) assert fetched == ["https://jobs.example.com/real"] assert out["description"].startswith("REAL") and jobs_db.get_job("victim")["description"].startswith("REAL") - out = asyncio.run(main._ensure_full_description({"id": "unknown", "description": "", "url": "https://evil.example/x"})) + out = asyncio.run(jobs_db.ensure_full_description({"id": "unknown", "description": "", "url": "https://evil.example/x"})) assert out["description"].startswith("ATTACKER") # used for this request only… assert jobs_db.get_job("unknown") is None # …never persisted From 6d241ec45d588ada4e16f89b1337fa0bd3e702e5 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Sat, 17 Oct 2026 02:43:14 +0100 Subject: [PATCH 2/2] feat(frontend): autopilot page with settings, run now, the held queue and history Practice or sending mode (sending asks first), lowest fit, daily limit and the 6-hourly schedule; live run progress; Needs you / Sent / Practice / Skipped tabs with reasons, screenshots, documents, the questions panel to answer held questions, Try again, Apply myself and Dismiss. Sidebar entry with a badge for held forms. --- frontend/e2e/smoke.spec.ts | 79 ++++- frontend/src/app/autopilot/page.tsx | 458 ++++++++++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 5 +- frontend/src/lib/autopilot.ts | 51 ++++ frontend/src/lib/types.ts | 50 +++ 5 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 frontend/src/app/autopilot/page.tsx create mode 100644 frontend/src/lib/autopilot.ts diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index e0b3a41..9982c31 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -28,8 +28,8 @@ const app = (id: string, status: string, extra: object = {}) => ({ fit_score: 80, skill_gaps: [], level_match: "strong", notes: "", created_at: new Date().toISOString(), ...extra, }); -/** What every signed-in page asks for: the sidebar reads the persona and the applications. */ -const SHELL = { "GET /api/persona": PERSONA, "GET /api/applications": [] }; +/** What every signed-in page asks for: the sidebar reads the persona, the applications and autopilot's runs. */ +const SHELL = { "GET /api/persona": PERSONA, "GET /api/applications": [], "GET /api/autopilot/runs": [] }; test("a signed-out visitor is sent to the login page", async ({ page }) => { const api = await mockApi(page, {}); @@ -116,6 +116,7 @@ test("an expired session sends you back to the login page", async ({ page }) => const expired = { status: 401, json: { detail: "Could not validate credentials" } }; const api = await mockApi(page, { "GET /api/jobs": expired, "GET /api/persona": expired, "GET /api/applications": expired, + "GET /api/autopilot/runs": expired, }); await page.goto("/login"); await page.evaluate(() => localStorage.setItem("windrush_token", "expired-token")); @@ -155,7 +156,7 @@ test("changing a status in the application panel saves it, and Esc closes the pa await signIn(page); const patches: unknown[] = []; const api = await mockApi(page, { - "GET /api/persona": PERSONA, + ...SHELL, "GET /api/applications": [app("a1", "Applied"), app("a2", "Saved")], "PATCH /api/applications/a1/status": (request) => { patches.push(request.postDataJSON()); @@ -180,7 +181,7 @@ test("the review queue lists paused applications and records one you sent yourse await signIn(page); const patches: unknown[] = []; const api = await mockApi(page, { - "GET /api/persona": PERSONA, + ...SHELL, "GET /api/applications": [app("a1", "Pending Review"), app("a2", "Pending Review"), app("a3", "Applied")], "PATCH /api/applications/a1/status": (request) => { patches.push(request.postDataJSON()); @@ -277,7 +278,7 @@ test("the review shows where each answer came from and saves the one only you ca answered("Have you ever been convicted of a criminal offence?", "needs_you", "", { options: ["Yes", "No"] }), ]; const api = await mockApi(page, { - "GET /api/persona": PERSONA, + ...SHELL, "GET /api/applications": [app("a1", "Pending Review", { questions })], "POST /api/answers": (request) => { posted.push(request.postDataJSON()); @@ -420,3 +421,71 @@ test("an answer given in the live review is put on the form by the agent", async await expect(panel.getByRole("button", { name: /Fill it in on the form/ })).toHaveCount(0); api.expectAllMocked(); }); + +test("autopilot: practice runs, the queue, answering a held question and turning on sending", async ({ page }) => { + await signIn(page); + const blank = { question: "Do you hold SC clearance?", options: ["Yes", "No"], kind: "fact", source: "needs_you", + answer: "", because: "", bank_id: "", flags: [] }; + const run = (id: string, outcome: string, extra: object = {}) => ({ + id, job_id: `job-${id}`, job_title: `Graduate Engineer ${id}`, company: `Company ${id}`, location: "London", + job_url: `https://boards.greenhouse.io/co/jobs/${id}`, outcome, practice: false, fit_score: 82, reasons: [], + questions: [], notes: "", cv_doc_id: null, letter_doc_id: null, application_id: null, screenshots: [], + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), ...extra, + }); + const runs = [ + run("1", "needs_you", { reasons: ["1 question only you can answer: “Do you hold SC clearance?”"], questions: [blank] }), + run("2", "ready", { practice: true }), + run("3", "skipped", { reasons: ["Fit 60 is under your threshold of 75."] }), + ]; + let settings = { enabled: false, live: false, min_fit: 75, daily_cap: 3 }; + const puts: object[] = []; + const posts: string[] = []; + const api = await mockApi(page, { + ...SHELL, + "GET /api/autopilot/runs": runs, + "GET /api/autopilot": () => ({ settings, running: false, step: "", job: "", started_at: null, sent_last_day: 0, + last_run_at: null, next_run_at: null, last: null }), + "PUT /api/autopilot/settings": (request) => { + puts.push(request.postDataJSON()); + settings = { ...settings, ...request.postDataJSON() }; + return settings; + }, + "POST /api/autopilot/run": () => { posts.push("run"); return { status: 202, json: { started: true } }; }, + "POST /api/autopilot/runs/1/retry": () => { posts.push("retry 1"); return { status: 202, json: { started: true } }; }, + "POST /api/answers": { id: "b1", question: blank.question, answer: "No", kind: "fact", company: "", created_at: "", + updated_at: "" }, + }); + await page.goto("/autopilot"); + + await expect(page.getByText("Practice mode", { exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: /Autopilot/ })).toContainText("1"); // the sidebar badge + const queue = page.getByRole("tabpanel"); + await expect(queue).toContainText("Graduate Engineer 1"); + await expect(queue).toContainText("only you can answer"); + + await queue.getByLabel(blank.question).selectOption("No"); + await queue.getByRole("button", { name: "Save answer" }).click(); + await expect(queue).toContainText("Press Try again"); + await queue.getByRole("button", { name: "Try again with my answers" }).click(); + await expect.poll(() => posts).toEqual(["retry 1"]); + + await page.getByRole("tab", { name: /Practice/ }).click(); + await expect(page.getByRole("tabpanel")).toContainText("Would have sent"); + await page.getByRole("tab", { name: /Skipped/ }).click(); + await expect(page.getByRole("tabpanel")).toContainText("under your threshold"); + + // Sending asks first. + await page.getByRole("button", { name: "Send applications" }).click(); + await expect(page.getByText("Send applications without asking you?")).toBeVisible(); + expect(puts).toEqual([]); + await page.getByRole("button", { name: "Turn on sending" }).click(); + await expect.poll(() => puts).toEqual([{ live: true }]); + await expect(page.getByText("Sending applications", { exact: true })).toBeVisible(); + + await page.getByLabel("Most applications sent a day").selectOption("5"); + await expect.poll(() => puts.at(-1)).toEqual({ daily_cap: 5 }); + + await page.getByRole("button", { name: "Run now" }).click(); + await expect.poll(() => posts).toEqual(["retry 1", "run"]); + api.expectAllMocked(); +}); diff --git a/frontend/src/app/autopilot/page.tsx b/frontend/src/app/autopilot/page.tsx new file mode 100644 index 0000000..e1fb3f0 --- /dev/null +++ b/frontend/src/app/autopilot/page.tsx @@ -0,0 +1,458 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import FormQuestions from "@/components/FormQuestions"; +import Icon from "@/components/ui/Icon"; +import TopBar, { Page } from "@/components/ui/TopBar"; +import { Segmented, TabPanel, UnderlineTabs } from "@/components/ui/Tabs"; +import { + buttonClass, Callout, Card, Chip, CompanyMark, cx, EmptyState, inputClass, labelClass, Skeleton, Spinner, Switch, + type ChipTone, +} from "@/components/ui/kit"; +import { load, useResource } from "@/lib/store"; +import { jobKey, longAgo } from "@/lib/format"; +import { jobHref, rememberJob } from "@/lib/jobs"; +import { + dismissRun, downloadDocument, retryRun, RUNS_URL, runNow, saveSettings, screenshotUrl, STATUS_URL, +} from "@/lib/autopilot"; +import type { AutopilotOutcome, AutopilotRun, AutopilotSettings, AutopilotStatus, Job } from "@/lib/types"; + +type Tab = "queue" | "sent" | "practice" | "other"; + +const OUTCOME: Record = { + running: { label: "Working on it", tone: "info" }, + submitted: { label: "Sent", tone: "positive" }, + unconfirmed: { label: "Check it went through", tone: "warn" }, + ready: { label: "Would have sent", tone: "info" }, + needs_you: { label: "Needs you", tone: "warn" }, + skipped: { label: "Skipped", tone: "neutral" }, + failed: { label: "Didn't work", tone: "danger" }, + dismissed: { label: "Dismissed", tone: "neutral" }, +}; + +const TAB_OF: Record = { + needs_you: "queue", unconfirmed: "queue", running: "queue", + submitted: "sent", ready: "practice", + skipped: "other", failed: "other", dismissed: "other", +}; +const RETRYABLE = new Set(["needs_you", "failed", "skipped", "ready", "dismissed"]); +const DISMISSABLE = new Set(["needs_you", "failed", "skipped", "ready", "unconfirmed"]); + +const FIT_CHOICES = [50, 55, 60, 65, 70, 75, 80, 85, 90, 95]; +const CAP_CHOICES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + +function whenNext(iso: string | null): string | null { + if (!iso) return null; + const mins = Math.round((new Date(iso).getTime() - Date.now()) / 60_000); + if (mins <= 1) return "in the next few minutes"; + if (mins < 60) return `in ${mins} minutes`; + return `in about ${Math.round(mins / 60)} hours`; +} + +function lastRunLine(last: AutopilotStatus["last"]): string | null { + if (!last) return null; + const c = last.counts; + const parts = [ + c.submitted && `${c.submitted} sent`, + c.ready && `${c.ready} would have been sent`, + c.needs_you && `${c.needs_you} need you`, + c.unconfirmed && `${c.unconfirmed} to check`, + c.skipped && `${c.skipped} skipped`, + c.failed && `${c.failed} didn't work`, + ].filter(Boolean); + const what = parts.length ? parts.join(", ") : "nothing new to apply to"; + return `Last run ${longAgo(last.finished_at) ?? ""}: ${what}.${last.note ? ` ${last.note}` : ""}`; +} + +/* ── Settings ──────────────────────────────────────────────────────────── */ + +function Settings({ settings }: { settings: AutopilotSettings }) { + const [confirmLive, setConfirmLive] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const save = async (changes: Partial) => { + setSaving(true); + setError(null); + try { + await saveSettings(changes); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't save the setting."); + } finally { + setSaving(false); + } + }; + + const mode = confirmLive ? "live" : settings.live ? "live" : "practice"; + + return ( + +
+

Settings

+ Autopilot works down your job feed, best match first. +
+ +
+ Mode + { + if (k === "practice") { setConfirmLive(false); if (settings.live) save({ live: false }); } + else if (!settings.live) setConfirmLive(true); + }} /> + + {settings.live + ? "Forms that pass every check below are sent to the employer without asking you." + : "Forms are filled in but never sent. You see what would have gone and what would have waited for you."} + + {confirmLive && ( + + + + + }> + An application can't be taken back once it's sent. Autopilot only sends a form when every answer on it + came from your persona or your saved answers, and stops at your daily limit. + + )} +
+ +
+ + +
+ +
+ save({ enabled: v })} /> + Off: it only runs when you press Run now. +
+ + {error && {error}} + +
+ A form is sent only when +
    +
  • it's on Greenhouse, Lever, Ashby or Workable (never LinkedIn or Indeed);
  • +
  • the fit is at least {settings.min_fit} and the level isn't a stretch;
  • +
  • every answer came from your persona or your saved answers: nothing drafted, nothing left blank;
  • +
  • no required field is empty, and the CV and cover letter say nothing your persona doesn't back;
  • +
  • fewer than {settings.daily_cap} went out in the last 24 hours.
  • +
+ + Anything else waits for you under Needs you. Each sent form keeps a screenshot of the filled form and of the page after it went. + +
+
+ ); +} + +/* ── Runs ──────────────────────────────────────────────────────────────── */ + +function Shot({ runId, which }: { runId: string; which: "form" | "confirm" }) { + const [url, setUrl] = useState(null); + useEffect(() => { + let alive = true; + let made: string | null = null; + screenshotUrl(runId, which).then((u) => { made = u; if (alive) setUrl(u); else if (u) URL.revokeObjectURL(u); }); + return () => { alive = false; if (made) URL.revokeObjectURL(made); }; + }, [runId, which]); + const label = which === "form" ? "The filled form, before sending" : "The page after sending"; + return ( +
+ {url ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {label} + + ) : ( + + )} +
{label}
+
+ ); +} + +function asJob(run: AutopilotRun): Job { + return { id: run.job_id, title: run.job_title, company: run.company, location: run.location, url: run.job_url }; +} + +function RunRow({ run, running }: { run: AutopilotRun; running: boolean }) { + const [open, setOpen] = useState(run.outcome === "needs_you" || run.outcome === "unconfirmed"); + const [busy, setBusy] = useState<"retry" | "dismiss" | null>(null); + const [error, setError] = useState(null); + const meta = OUTCOME[run.outcome]; + const job = asJob(run); + const blank = run.questions.filter((q) => q.source === "needs_you").length; + + const act = async (kind: "retry" | "dismiss") => { + setBusy(kind); + setError(null); + try { + await (kind === "retry" ? retryRun(run.id) : dismissRun(run.id)); + } catch (e) { + setError(e instanceof Error ? e.message : "That didn't work."); + } finally { + setBusy(null); + } + }; + + const fileName = (kind: string) => + `${run.company}_${kind}.pdf`.replace(/[^A-Za-z0-9_.-]+/g, "_"); + const download = (docId: string, kind: string) => + downloadDocument(docId, fileName(kind)).catch((e) => setError(e instanceof Error ? e.message : "Couldn't download it.")); + + return ( +
  • +
    + +
    + {run.job_title} + + {[run.company, run.location, longAgo(run.updated_at)].filter(Boolean).join(" · ")} + +
    + + {run.fit_score != null && ( + + {Math.round(run.fit_score)} fit + + )} + {run.practice && run.outcome !== "skipped" && Practice} + {meta.label} + +
    + + {run.reasons.length > 0 && ( +
      + {run.reasons.map((r) => ( +
    • + {r} +
    • + ))} +
    + )} + +
    + {(run.questions.length > 0 || run.screenshots.length > 0 || run.notes) && ( + + )} + {RETRYABLE.has(run.outcome) && ( + + )} + {run.outcome !== "submitted" && run.job_url && ( + rememberJob(job)} className={buttonClass("secondary", "xs")}> + Apply myself + + )} + {run.cv_doc_id && ( + + )} + {run.letter_doc_id && ( + + )} + {DISMISSABLE.has(run.outcome) && ( + + )} +
    + {error &&

    {error}

    } + + {open && ( +
    + {run.screenshots.length > 0 && ( +
    1 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1 max-w-[640px]")}> + {run.screenshots.map((w) => )} +
    + )} + {run.questions.length > 0 && ( + + )} + {run.notes && ( +
    + The agent's notes +

    {run.notes}

    +
    + )} +
    + )} +
  • + ); +} + +/* ── Page ──────────────────────────────────────────────────────────────── */ + +export default function AutopilotPage() { + const status = useResource(STATUS_URL, { staleMs: 0 }); + const runs = useResource(RUNS_URL, { staleMs: 0 }); + const [tab, setTab] = useState("queue"); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const running = status.data?.running ?? false; + + // Follow a run while it goes, and pick up what it did once it ends. + useEffect(() => { + if (!running) return; + const timer = setInterval(() => { + load(STATUS_URL, { force: true }).catch(() => {}); + load(RUNS_URL, { force: true }).catch(() => {}); + }, 4000); + return () => clearInterval(timer); + }, [running]); + const wasRunning = useRef(false); + useEffect(() => { + if (wasRunning.current && !running) load(RUNS_URL, { force: true }).catch(() => {}); + wasRunning.current = running; + }, [running]); + + const all = useMemo(() => runs.data ?? [], [runs.data]); + const byTab = useMemo(() => { + const out: Record = { queue: [], sent: [], practice: [], other: [] }; + for (const r of all) out[TAB_OF[r.outcome]].push(r); + return out; + }, [all]); + + const start = async () => { + setStarting(true); + setError(null); + try { + await runNow(); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't start autopilot."); + } finally { + setStarting(false); + } + }; + + const s = status.data; + const settings = s?.settings; + const empty: Record = { + queue: "Nothing is waiting for you. Forms autopilot holds back show up here with the reasons.", + sent: settings?.live ? "Nothing sent yet." : "Nothing is sent in practice mode. Switch to Send applications when you're ready.", + practice: "Practice runs show here: forms that passed every check and would have been sent.", + other: "Jobs autopilot skipped (fit too low, a stretch) or couldn't fill show here.", + }; + + return ( + <> + + + + +
    +

    Autopilot

    +

    + Applies to jobs from your feed on its own: it writes the tailored CV and cover letter, fills in the form, and + sends it only when nothing on it needs you. +

    +
    + + {status.error && {String(status.error)}} + {error && {error}} + +
    +
    + + {!s ? ( + + ) : ( + <> +
    + + {s.settings.live ? "Sending applications" : "Practice mode"} + + + {s.settings.live + ? `${s.sent_last_day} of ${s.settings.daily_cap} sent in the last 24 hours` + : "Nothing is sent"} + +
    + {running ? ( +
    + + + {s.job || "Getting started"} + {s.step} + +
    + ) : ( + + {lastRunLine(s.last) ?? (s.last_run_at ? `Last run ${longAgo(s.last_run_at)}.` : "Autopilot hasn't run yet.")} + {s.settings.enabled && s.next_run_at ? ` Next run ${whenNext(s.next_run_at)}.` : ""} + + )} + + )} +
    + + + + {(["queue", "sent", "practice", "other"] as Tab[]).map((k) => ( + + {runs.loading ? ( + + ) : byTab[k].length === 0 ? ( + {empty[k]} + ) : ( + <> + {k === "other" && ( +

    + {plural(byTab.other.length, "job")}. Try again on one to have autopilot apply to it anyway. +

    + )} +
      + {byTab[k].map((r) => )} +
    + + )} +
    + ))} +
    +
    + + {settings ? : } +
    +
    + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6fc687f..61f80b2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -8,7 +8,7 @@ import { cx } from "@/components/ui/kit"; import { clearStore, useResource } from "@/lib/store"; import { displayName, personaStrength } from "@/lib/persona"; import { applyTheme, readTheme, saveTheme, type ThemeChoice } from "@/lib/theme"; -import type { Application, Persona } from "@/lib/types"; +import type { Application, AutopilotRun, Persona } from "@/lib/types"; interface NavItem { href: string; @@ -112,9 +112,12 @@ export default function Sidebar() { useEffect(() => setOpen(false), [pathname]); const waiting = (apps.data ?? []).filter((a) => a.status === "Pending Review").length; + const autopilot = useResource("/api/autopilot/runs"); + const held = (autopilot.data ?? []).filter((r) => r.outcome === "needs_you" || r.outcome === "unconfirmed").length; const workspace: NavItem[] = [ { href: "/dashboard", label: "Home", icon: "home" }, { href: "/jobs", label: "Job feed", icon: "feed" }, + { href: "/autopilot", label: "Autopilot", icon: "play", badge: held, badgeTone: "amber" }, { href: "/review", label: "Agent review", icon: "eye", badge: waiting, badgeTone: "amber" }, { href: "/applications", label: "Applications", icon: "board" }, ]; diff --git a/frontend/src/lib/autopilot.ts b/frontend/src/lib/autopilot.ts new file mode 100644 index 0000000..7661dca --- /dev/null +++ b/frontend/src/lib/autopilot.ts @@ -0,0 +1,51 @@ +import { authFetch } from "@/app/api"; +import { load, mutate, requestJson } from "./store"; +import type { AutopilotRun, AutopilotSettings, AutopilotStatus } from "./types"; + +/* Autopilot: applies to jobs from your feed on its own, within your limits (api/app/autopilot.py). */ + +export const STATUS_URL = "/api/autopilot"; +export const RUNS_URL = "/api/autopilot/runs"; + +const json = (method: string, body?: object): RequestInit => + ({ method, headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined }); + +export async function saveSettings(changes: Partial) { + const settings = await requestJson(`${STATUS_URL}/settings`, json("PUT", changes)); + mutate(STATUS_URL, (prev) => ({ ...(prev as AutopilotStatus), settings })); + load(STATUS_URL, { force: true }).catch(() => {}); + return settings; +} + +export async function runNow() { + await requestJson(`${STATUS_URL}/run`, json("POST")); + await load(STATUS_URL, { force: true }); +} + +export async function retryRun(id: string) { + await requestJson(`${RUNS_URL}/${encodeURIComponent(id)}/retry`, json("POST")); + await load(STATUS_URL, { force: true }); +} + +export async function dismissRun(id: string) { + await requestJson(`${RUNS_URL}/${encodeURIComponent(id)}/dismiss`, json("POST")); + mutate(RUNS_URL, (prev) => (prev ?? []).map((r) => (r.id === id ? { ...r, outcome: "dismissed" } : r))); +} + +/** A run's screenshot as an object URL (revoke it when done), or null. */ +export async function screenshotUrl(id: string, which: "form" | "confirm"): Promise { + const res = await authFetch(`${RUNS_URL}/${encodeURIComponent(id)}/screenshots/${which}`); + if (!res.ok) return null; + return URL.createObjectURL(await res.blob()); +} + +export async function downloadDocument(docId: string, filename: string) { + const res = await authFetch(`/api/documents/${docId}/download`); + if (!res.ok) throw new Error("That document isn't available any more."); + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0efc840..42148d9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -234,3 +234,53 @@ export interface Persona { skills: SkillCategory[]; summary: string; } + +/* ── Autopilot (mirrors api/app/autopilot.py) ───────────────────────── */ + +export interface AutopilotSettings { + /** Runs on its own every few hours. */ + enabled: boolean; + /** Sends forms that pass every check; off = practice (fills, never sends). */ + live: boolean; + min_fit: number; + daily_cap: number; +} + +export interface AutopilotStatus { + settings: AutopilotSettings; + running: boolean; + step: string; + job: string; + started_at: string | null; + sent_last_day: number; + last_run_at: string | null; + next_run_at: string | null; + last: { finished_at: string; live: boolean; counts: Partial>; note: string } | null; +} + +export type AutopilotOutcome = + | "running" | "submitted" | "unconfirmed" | "ready" | "needs_you" | "skipped" | "failed" | "dismissed"; + +export interface AutopilotRun { + id: string; + job_id: string; + job_title: string; + company: string; + location: string; + job_url: string; + outcome: AutopilotOutcome; + /** Filled in practice mode, where nothing is sent. */ + practice: boolean; + fit_score: number | null; + /** Why it wasn't sent. */ + reasons: string[]; + questions: FormAnswer[]; + notes: string | null; + cv_doc_id: string | null; + letter_doc_id: string | null; + application_id: string | null; + /** Kept screenshots: "form" (filled, before sending) and "confirm" (after). */ + screenshots: ("form" | "confirm")[]; + created_at: string; + updated_at: string; +}