Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions api/app/autopilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
6 changes: 3 additions & 3 deletions api/app/browser_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]):
Expand Down
2 changes: 1 addition & 1 deletion api/tests/test_application_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
30 changes: 29 additions & 1 deletion api/tests/test_autopilot.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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("/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
Expand Down
12 changes: 11 additions & 1 deletion frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."] }),
];
Expand Down Expand Up @@ -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");
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/app/autopilot/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<li className="flex flex-col gap-3 py-4 border-t border-track first:border-t-0 first:pt-0">
Expand Down Expand Up @@ -271,6 +273,12 @@ function RunRow({ run, running }: { run: AutopilotRun; running: boolean }) {
<Icon name="arrowRight" size={13} />Apply myself
</Link>
)}
{run.cv_doc_id && run.letter_doc_id && (
<button type="button" onClick={downloadHandover} className={buttonClass("secondary", "xs")}
title="The CV and cover letter as autopilot uploads them, and the form's questions with your answers">
<Icon name="download" size={13} />Handover pack
</button>
)}
{run.cv_doc_id && (
<button type="button" onClick={() => download(run.cv_doc_id!, "CV")} className={buttonClass("quiet", "xs")}>
<Icon name="download" size={13} />CV
Expand Down
20 changes: 16 additions & 4 deletions frontend/src/lib/autopilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,25 @@ export async function screenshotUrl(id: string, which: "form" | "confirm"): Prom
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());
function save(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

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.");
save(await res.blob(), filename);
}

/** A run's handover pack: its CV and cover letter as autopilot uploads them, and the form's answers, as a ZIP. */
export async function downloadPack(id: string) {
const res = await authFetch(`${RUNS_URL}/${encodeURIComponent(id)}/pack`);
if (!res.ok) throw new Error("This run's CV and cover letter aren't there any more.");
const named = /filename="([^"]+)"/.exec(res.headers.get("Content-Disposition") ?? "");
save(await res.blob(), named?.[1] ?? "Windrush handover pack.zip");
}
Loading