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 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; +}