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 (