From 723f93c5d23b52336968935e06b50db137f5b4f2 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 20 Oct 2026 15:01:31 +0100 Subject: [PATCH] feat: a handover pack for applying to a prepared job yourself Sending can stop at a site's emailed-code check (Catapult, on Greenhouse), so the user finishes in their own browser. Putting the files together by hand each time is slow; now each autopilot run with documents has a "Handover pack" button. GET /autopilot/runs/{id}/pack (your own runs only) returns a ZIP: - the CV and cover letter under the names autopilot uploads them as (Timothy_Ojebiyi_CV.pdf), not the run-id or company names the CV/letter buttons save; - " - answers.txt": the job and its link, why it was held, what to upload, each question the form asked with its answer ("left for you to answer" when blank), and the letter as text for a form that wants it pasted. It's 404 when the run's PDFs are gone. browser_agent.upload_name (was _upload_name) now serves both. Tests: the pack's files, names and sheet; 404 without its PDFs; out of another user's reach; the e2e smoke test downloads it under the API's name. Catapult's real run gives both PDFs and a sheet with its 8 questions and the letter text. --- api/app/autopilot.py | 36 +++++++++++++++++++++++++++ api/app/browser_agent.py | 6 ++--- api/app/main.py | 15 ++++++++++- api/tests/test_application_answers.py | 2 +- api/tests/test_autopilot.py | 30 +++++++++++++++++++++- frontend/e2e/smoke.spec.ts | 12 ++++++++- frontend/src/app/autopilot/page.tsx | 10 +++++++- frontend/src/lib/autopilot.ts | 20 ++++++++++++--- 8 files changed, 119 insertions(+), 12 deletions(-) diff --git a/api/app/autopilot.py b/api/app/autopilot.py index 9868dd7..f9cac8a 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -15,10 +15,12 @@ """ import asyncio import base64 +import io import json import logging import os import re +import zipfile from contextlib import aclosing from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone @@ -313,6 +315,40 @@ async def _write_documents(state: _Run, job: dict, persona: dict) -> dict: "letter_doc_id": await doc_render.render_pdf("cover_letter", letter, template_id)} +def handover_pack(user_id: str, run: dict, persona: dict) -> tuple[str, bytes]: + """ + (file name, ZIP) for applying to the run's job yourself: its CV and cover letter under the names autopilot + uploads them as, and a sheet with the job's link, why it was held, each question the form asked with its + answer, and the letter as text for a form that wants it pasted. Raises LookupError when the PDFs are gone. + """ + cv, letter = _pdf(run.get("cv_doc_id") or ""), _pdf(run.get("letter_doc_id") or "") + if not cv or not letter: + raise LookupError("This run's CV and cover letter aren't there any more.") + cv_name = browser_agent.upload_name(persona, "CV") + letter_name = browser_agent.upload_name(persona, "Cover Letter") + plain = lambda text: " ".join(re.sub(r"[^A-Za-z0-9 .&-]+", " ", str(text or "")).split())[:60] # noqa: E731 + company = plain(run.get("company")) or "Job" + + lines = [f"{run.get('job_title') or ''} at {run.get('company') or ''}", run.get("job_url") or "", ""] + if run.get("reasons"): + lines += ["Held because:", *[f"- {reason}" for reason in run["reasons"]], ""] + lines += ["Upload:", f"- CV: {cv_name}", f"- Cover letter: {letter_name}", ""] + if run.get("questions"): + lines += ["The form's questions and your answers:", ""] + for q in run["questions"]: + lines += [f"Q: {q.get('question') or ''}", f"A: {q.get('answer') or '(left for you to answer)'}", ""] + written = (tracker.autopilot_documents(user_id, run.get("job_id") or "") or {}).get("letter") + if written: + lines += ["The cover letter as text, for a form that wants it pasted:", "", letter_text(written), ""] + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as pack: + pack.write(cv, cv_name) + pack.write(letter, letter_name) + pack.writestr(f"{company} - answers.txt", "\n".join(lines)) + return f"Windrush - {company} - {plain(run.get('job_title')) or 'application'}.zip", buf.getvalue() + + def _keep_documents(run_id: str, user_id: str, docs: dict) -> None: tracker.update_autopilot_run(run_id, user_id=user_id, cv_doc_id=docs["cv_doc_id"], letter_doc_id=docs["letter_doc_id"], diff --git a/api/app/browser_agent.py b/api/app/browser_agent.py index e4551b1..df4ed9c 100644 --- a/api/app/browser_agent.py +++ b/api/app/browser_agent.py @@ -822,7 +822,7 @@ async def _submit_with_agent(llm, browser_session, sensitive_data: dict | None, # ── Documents, the cover-letter box and the empty-field check ─────────────── -def _upload_name(persona: dict, kind: str) -> str: +def upload_name(persona: dict, kind: str) -> str: """'Ada_Lovelace_CV.pdf': the name an employer sees on an uploaded document (stored files are named by id). ASCII only, since some applicant-tracking systems mangle anything else.""" core = persona.get("core_info") or {} @@ -1613,8 +1613,8 @@ async def on_step(browser_state, agent_output, step_number): try: # Named copies for the upload, removed in the finally below. upload_dir = tempfile.mkdtemp(prefix="windrush-apply-") - cv_upload = _named_copy(cv_path, upload_dir, _upload_name(cv_profile, "CV")) - letter_upload = _named_copy(cover_letter_path, upload_dir, _upload_name(cv_profile, "Cover Letter")) + cv_upload = _named_copy(cv_path, upload_dir, upload_name(cv_profile, "CV")) + letter_upload = _named_copy(cover_letter_path, upload_dir, upload_name(cv_profile, "Cover Letter")) task = _build_task(job_url, cv_profile, cover_letter, job_email, job_password, cv_upload, letter_upload) agent = Agent( task=task, diff --git a/api/app/main.py b/api/app/main.py index cc9fcbd..40d7080 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -10,7 +10,7 @@ from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends, status from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse, StreamingResponse +from fastapi.responses import RedirectResponse, Response, StreamingResponse from fastapi.security import OAuth2PasswordRequestForm from .cv_parser import extract_text, extract_links, profile_links @@ -1052,6 +1052,19 @@ async def autopilot_dismiss(run_id: str, current_user: Annotated[auth.User, Depe return {"dismissed": run_id} +@app.get("/autopilot/runs/{run_id}/pack") +async def autopilot_pack(run_id: str, current_user: Annotated[auth.User, Depends(auth.get_current_user)]): + """A run's handover pack, for applying yourself: a ZIP of its CV and cover letter under the names autopilot + uploads them as, and a sheet of the form's questions with your answers.""" + run = _own_run(current_user.id, run_id) + try: + name, data = autopilot.handover_pack(current_user.id, run, tracker.get_user_persona(current_user.id)) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + return Response(content=data, media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{name}"'}) + + @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)]): diff --git a/api/tests/test_application_answers.py b/api/tests/test_application_answers.py index 2547e89..8f6d764 100644 --- a/api/tests/test_application_answers.py +++ b/api/tests/test_application_answers.py @@ -172,7 +172,7 @@ def test_nationality_answers_are_stated_with_their_details(): ({}, "CV", "CV.pdf"), ]) def test_uploads_are_named_after_the_candidate(core, kind, name): - assert ba._upload_name({"core_info": core}, kind) == name + assert ba.upload_name({"core_info": core}, kind) == name def test_the_agent_pastes_the_letter_and_is_offered_its_pdf(): diff --git a/api/tests/test_autopilot.py b/api/tests/test_autopilot.py index df8c604..0eed38f 100644 --- a/api/tests/test_autopilot.py +++ b/api/tests/test_autopilot.py @@ -1,9 +1,11 @@ """Autopilot: which jobs it tries, when it sends a form and when it holds it for the user.""" import asyncio import base64 +import io import json import os import uuid +import zipfile import pytest from conftest import make_job @@ -382,12 +384,38 @@ def test_status_settings_and_runs_endpoints(ada, world, client_for): assert tracker.get_autopilot_run(ada, runs["alpha"]["id"])["outcome"] == "dismissed" +def test_a_handover_pack_holds_what_applying_by_hand_needs(ada, world, client_for): + world.forms["alpha"]["questions"] = [CLEAN, _answer("Do you hold SC clearance?", "needs_you")] + asyncio.run(autopilot.run(ada)) + run = _runs(ada)["alpha"] + reply = client_for(ada).get(f"/autopilot/runs/{run['id']}/pack") + assert reply.status_code == 200 and reply.headers["content-type"] == "application/zip" + assert reply.headers["content-disposition"] == 'attachment; filename="Windrush - alpha - Graduate Engineer alpha.zip"' + with zipfile.ZipFile(io.BytesIO(reply.content)) as pack: + assert sorted(pack.namelist()) == ["Ada_Lovelace_CV.pdf", "Ada_Lovelace_Cover_Letter.pdf", "alpha - answers.txt"] + assert pack.read("Ada_Lovelace_CV.pdf") == open(pdf_generator.get_pdf_path(run["cv_doc_id"]), "rb").read() + sheet = pack.read("alpha - answers.txt").decode() + assert sheet.startswith(f"Graduate Engineer alpha at alpha\n{URLS['alpha']}\n") + assert "Held because:\n- 1 question only you can answer" in sheet + assert "Q: Do you have the right to work in the UK?\nA: Yes" in sheet + assert "Q: Do you hold SC clearance?\nA: (left for you to answer)" in sheet + assert "for a form that wants it pasted:\n\nDear Hiring Manager,\n\nI'd like to join." in sheet + + +def test_a_handover_pack_needs_the_runs_documents(ada, world, client_for): + asyncio.run(autopilot.run(ada)) + run = _runs(ada)["alpha"] + os.remove(pdf_generator.get_pdf_path(run["letter_doc_id"])) + reply = client_for(ada).get(f"/autopilot/runs/{run['id']}/pack") + assert reply.status_code == 404 and "aren't there any more" in reply.json()["detail"] + + 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("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/autopilot/runs").json() == [] - for path in (f"/autopilot/runs/{run_id}/screenshots/form",): + for path in (f"/autopilot/runs/{run_id}/screenshots/form", f"/autopilot/runs/{run_id}/pack"): 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 diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index e52bb63..971597f 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -468,7 +468,8 @@ test("autopilot: practice runs, the queue, answering a held question and turning 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("1", "needs_you", { reasons: ["1 question only you can answer: “Do you hold SC clearance?”"], questions: [blank], + cv_doc_id: "c".repeat(32), letter_doc_id: "d".repeat(32) }), run("2", "ready", { practice: true }), run("3", "skipped", { reasons: ["Fit 60 is under your threshold of 75."] }), ]; @@ -498,6 +499,15 @@ test("autopilot: practice runs, the queue, answering a held question and turning await expect(queue).toContainText("Graduate Engineer 1"); await expect(queue).toContainText("only you can answer"); + // Applying yourself: the handover pack downloads under the name the API gives it. A ZIP isn't JSON, so this + // route sits on top of mockApi's (Playwright tries the last-registered route first). + await page.route("**/api/autopilot/runs/1/pack", (route) => route.fulfill({ + status: 200, body: "PK", headers: { "content-type": "application/zip", + "content-disposition": 'attachment; filename="Windrush - Company 1 - Graduate Engineer 1.zip"' } })); + const download = page.waitForEvent("download"); + await queue.getByRole("button", { name: "Handover pack" }).click(); + expect((await download).suggestedFilename()).toBe("Windrush - Company 1 - Graduate Engineer 1.zip"); + await queue.getByLabel(blank.question).selectOption("No"); await queue.getByRole("button", { name: "Save answer" }).click(); await expect(queue).toContainText("Press Try again"); diff --git a/frontend/src/app/autopilot/page.tsx b/frontend/src/app/autopilot/page.tsx index e1fb3f0..afc373d 100644 --- a/frontend/src/app/autopilot/page.tsx +++ b/frontend/src/app/autopilot/page.tsx @@ -14,7 +14,7 @@ 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, + dismissRun, downloadDocument, downloadPack, retryRun, RUNS_URL, runNow, saveSettings, screenshotUrl, STATUS_URL, } from "@/lib/autopilot"; import type { AutopilotOutcome, AutopilotRun, AutopilotSettings, AutopilotStatus, Job } from "@/lib/types"; @@ -221,6 +221,8 @@ function RunRow({ run, running }: { run: AutopilotRun; running: boolean }) { `${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.")); + const downloadHandover = () => + downloadPack(run.id).catch((e) => setError(e instanceof Error ? e.message : "Couldn't download it.")); return (
  • @@ -271,6 +273,12 @@ function RunRow({ run, running }: { run: AutopilotRun; running: boolean }) { Apply myself )} + {run.cv_doc_id && run.letter_doc_id && ( + + )} {run.cv_doc_id && (