From 513259ea4d785162ce0ac11ac85e5238fabe0fed Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 20 Oct 2026 14:11:03 +0100 Subject: [PATCH] feat: a live send uses the CV and letter the practice run showed Every run wrote a fresh CV and cover letter, so "send" never uploaded the pair the user had just reviewed on the practice run. Now each run keeps the documents it wrote (autopilot_runs.documents: the CV, template, letter and company research), and a live run uses the last run's pair for that job when both PDFs are still there. It reads them before start_autopilot_run replaces that run, keeps them on the new run at once (so a retry after a failure still has them), re-runs the fact checks on them, and notes "Used the CV and cover letter from the practice run of ". Practice runs always write a new pair. Run listings leave the documents out, so the API's responses stay small. The letter's hook now uses a different project from the two in the evidence paragraph when the candidate has three or more, so no project or detail is told twice (Catapult's hook and first evidence were both the World Cup model). --- api/app/agent.py | 9 ++++-- api/app/autopilot.py | 63 ++++++++++++++++++++++++++++--------- api/app/tracker.py | 26 ++++++++++++++- api/tests/test_autopilot.py | 42 ++++++++++++++++++++++++- api/tests/test_documents.py | 1 + 5 files changed, 121 insertions(+), 20 deletions(-) diff --git a/api/app/agent.py b/api/app/agent.py index cd91940..02dc118 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -670,7 +670,8 @@ async def _revise_cv_text(cv: dict, findings: list[dict], candidate: dict, job: "the candidate through their own data (not a bare 'this is relevant'); it passes the swap test (another " "employer's name couldn't replace theirs); never 'I am writing to apply'. 2. EVIDENCE: two named projects, the " "first in precise detail with a real number matched to a requirement in the ad, the second a judgement call, " - "joined by the discipline both show. 3. HONEST GAP: one real requirement from the ad the candidate is lighter " + "joined by the discipline both show; neither is the hook's project when the candidate has three or more, and " + "no detail is told twice. 3. HONEST GAP: one real requirement from the ad the candidate is lighter " "on, stated plainly with what they do have and how they'd close it; no invented weakness, no self-deprecation " "(or, if nothing is missing, a third piece of evidence). 4. CLOSE: short; no perks or salary. 5. VOICE: first " "person, direct and specific, no hedging filler or clichés." @@ -1233,6 +1234,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "worked on, a stated goal), never just that it is 'relevant'; end on why this is the place to start " "their career. NEVER begin with 'I am writing'. It MUST fail the " "'swap-the-company-name' test: if another employer's name could replace theirs, it is too generic. " + "Whenever the data has three or more projects, the hook's project is a different one from the two in " + "EVIDENCE, so no project or detail is told twice. " "(2) EVIDENCE ~110 words — start 'Two projects demonstrate the I'd bring to " "your .' First, one named project in precise technical detail with a real number from the " "candidate data, matched to a requirement in the ad (use the ad's own terms). Then 'Separately, ...' a " @@ -1263,8 +1266,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict: f"CANDIDATE PERSONA (JSON): {json.dumps(_llm_persona(persona))}\n\n" f"JOB: {job.get('title')} at {job.get('company')}\n" f"DESCRIPTION: {job.get('description', '')[:2500]}" - + (f"\n\nMOST DOMAIN-RELEVANT PROJECTS for this role (draw the hook and the evidence from these, " - f"in priority order): {', '.join(top_project_names)}" if top_project_names else "") + + (f"\n\nMOST DOMAIN-RELEVANT PROJECTS for this role, in priority order (one for the hook, a " + f"different two for the evidence): {', '.join(top_project_names)}" if top_project_names else "") + (f"\n\nCOMPANY RESEARCH (take the hook's fact about the employer from this and tie it to the " f"candidate — do NOT restate it):\n{company_research}" if company_research else "") ), diff --git a/api/app/autopilot.py b/api/app/autopilot.py index 6b96d0c..65f1847 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -289,10 +289,46 @@ def _pdf(doc_id: str) -> str: return path if path and os.path.exists(path) else "" +def _earlier_documents(user_id: str, job: dict) -> dict | None: + """The CV and cover letter the last run for this job wrote, when both PDFs are still there.""" + docs = tracker.autopilot_documents(user_id, str(job.get("id", ""))) + return docs if docs and _pdf(docs["cv_doc_id"]) and _pdf(docs["letter_doc_id"]) else None + + +async def _write_documents(state: _Run, job: dict, persona: dict) -> dict: + """A tailored CV and cover letter for the job, rendered: {cv, template_id, letter, research, cv_doc_id, + letter_doc_id}.""" + 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.") + return {"cv": cv, "template_id": template_id, "letter": letter, "research": 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)} + + +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"], + documents={k: docs.get(k) for k in ("cv", "template_id", "letter", "research")}) + + 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"] + # A live send uses the CV and cover letter the last run for this job wrote (the practice run the user looked + # at), so what goes out is what they saw. Read before start_autopilot_run replaces that run; practice always + # writes them afresh. + earlier = _earlier_documents(user_id, job) if live else None run_id = tracker.start_autopilot_run(user_id, job, practice=not live, fit_score=verdict.get("fit_score")) + if earlier: + _keep_documents(run_id, user_id, earlier) # at once, so a retry after a failure still has them run = tracker.get_autopilot_run(user_id, run_id) state.job = f"{job.get('title', '')} at {job.get('company', '')}" held: list[str] = [] @@ -300,20 +336,15 @@ async def _apply(state: _Run, user_id: str, job: dict, verdict: dict, cfg: 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) + if earlier: + state.step = "Using the CV and cover letter from the last run" + docs = earlier + else: + docs = await _write_documents(state, job, persona) + _keep_documents(run_id, user_id, docs) + cv, letter = docs["cv"], docs["letter"] + cv_doc_id, letter_doc_id = docs["cv_doc_id"], docs["letter_doc_id"] + problems = document_problems(cv, letter, persona, job, docs.get("research") or "") text = letter_text(letter) answer_job = {"title": job.get("title", ""), "company": job.get("company", ""), @@ -380,8 +411,10 @@ async def decide(filled: dict) -> bool: "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) + reused = (f"Used the CV and cover letter from the {'practice ' if earlier['practice'] else ''}run of " + f"{earlier['updated_at'][:10]}. " if earlier else "") 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) + notes=(reused + str(final.get("notes") or ""))[:2000], application_id=app_id) logger.info("autopilot: %s -> %s (%d reasons)", state.job, outcome, len(reasons)) return outcome diff --git a/api/app/tracker.py b/api/app/tracker.py index 6d2ee39..c5e0865 100644 --- a/api/app/tracker.py +++ b/api/app/tracker.py @@ -119,6 +119,7 @@ cv_doc_id TEXT, letter_doc_id TEXT, application_id TEXT, + documents TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -162,6 +163,8 @@ def init_db(db_path: str = "") -> None: "ALTER TABLE users ADD COLUMN job_password_enc TEXT", # The questions the agent met on the application's form, with each answer and its source. "ALTER TABLE applications ADD COLUMN questions TEXT", + # The CV and cover letter a run wrote, so a live send uses the ones the practice run showed. + "ALTER TABLE autopilot_runs ADD COLUMN documents TEXT", ]: try: con.execute(stmt) @@ -676,6 +679,7 @@ def _autopilot_run(row: sqlite3.Row) -> dict: run["practice"] = bool(run["practice"]) run["reasons"] = json.loads(run["reasons"] or "[]") run["questions"] = json.loads(run["questions"] or "[]") + run.pop("documents", None) # the documents' content: autopilot_documents reads it return run @@ -699,7 +703,7 @@ def start_autopilot_run(user_id: str, job: dict, outcome: str = "running", *, pr _RUN_FIELDS = {"outcome", "practice", "fit_score", "reasons", "questions", "notes", "cv_doc_id", "letter_doc_id", - "application_id"} + "application_id", "documents"} def update_autopilot_run(run_id: str, *, user_id: str, **fields) -> bool: @@ -711,6 +715,8 @@ def update_autopilot_run(run_id: str, *, user_id: str, **fields) -> bool: for name in ("reasons", "questions"): if name in fields: fields[name] = json.dumps(list(fields[name]), ensure_ascii=False) + if "documents" in fields: + fields["documents"] = json.dumps(dict(fields["documents"]), ensure_ascii=False) if "practice" in fields: fields["practice"] = int(bool(fields["practice"])) fields["updated_at"] = _now() @@ -730,6 +736,24 @@ def get_autopilot_run(user_id: str, run_id: str) -> dict | None: return _autopilot_run(row) if row else None +def autopilot_documents(user_id: str, job_id: str) -> dict | None: + """The CV and cover letter the last run for this job wrote: {cv, template_id, letter, research, cv_doc_id, + letter_doc_id, practice, updated_at}, or None when it kept none.""" + con = sqlite3.connect(_DB_PATH) + con.row_factory = sqlite3.Row + row = con.execute("SELECT documents, cv_doc_id, letter_doc_id, practice, updated_at FROM autopilot_runs " + "WHERE user_id=? AND job_id=?", (user_id, str(job_id))).fetchone() + con.close() + try: + docs = json.loads(row["documents"]) if row and row["documents"] else None + except ValueError: + docs = None + if not isinstance(docs, dict) or not docs.get("cv") or not (docs.get("letter") or {}).get("paragraphs"): + return None + return {**docs, "cv_doc_id": row["cv_doc_id"] or "", "letter_doc_id": row["letter_doc_id"] or "", + "practice": bool(row["practice"]), "updated_at": row["updated_at"]} + + def list_autopilot_runs(user_id: str, limit: int = 200) -> list[dict]: """The user's autopilot runs, latest first.""" con = sqlite3.connect(_DB_PATH) diff --git a/api/tests/test_autopilot.py b/api/tests/test_autopilot.py index 15d8954..042fef1 100644 --- a/api/tests/test_autopilot.py +++ b/api/tests/test_autopilot.py @@ -1,6 +1,7 @@ """Autopilot: which jobs it tries, when it sends a form and when it holds it for the user.""" import asyncio import base64 +import json import os import uuid @@ -138,6 +139,7 @@ def __init__(self): self.filled: list[str] = [] # companies whose form was filled self.decided: dict[str, bool] = {} self.cv_paths: list[str] = [] + self.written: list[str] = [] # the documents written, as "cv" / "letter" @pytest.fixture @@ -150,8 +152,10 @@ async def execute_tool(name, tool_input): "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"} + w.written.append("cv") + return {"cv": {"summary": f"Engineer, draft {len(w.written)}"}, "template_id": "classic"} if name == "generate_cover_letter": + w.written.append("letter") return {"letter": {"salutation": "Dear Hiring Manager,", "paragraphs": ["I'd like to join."], "signoff": "Yours faithfully,", "candidate_name": "Ada Lovelace"}, "research": ""} raise AssertionError(name) @@ -288,6 +292,42 @@ def test_each_job_is_tried_once_but_practice_runs_dont_count_once_live(ada, worl assert len(world.filled) == 6 +def test_a_live_send_uses_the_documents_the_practice_run_showed(ada, world): + _run(ada) # practice: writes and keeps a CV and letter per job + practice = _runs(ada) + assert len(world.written) == 6 and "documents" not in practice["alpha"] # listings stay small + _run(ada, live=True) + live = _runs(ada) + assert len(world.written) == 6 # nothing rewritten: what the user saw is what went up + assert sorted(world.cv_paths[3:]) == sorted(world.cv_paths[:3]) + for name in ("alpha", "beta", "gamma"): + assert (live[name]["cv_doc_id"], live[name]["letter_doc_id"]) == \ + (practice[name]["cv_doc_id"], practice[name]["letter_doc_id"]) + assert live[name]["notes"].startswith("Used the CV and cover letter from the practice run of ") + sent_cv = {a["company"]: a["tailored_cv"] for a in tracker.list_applications(ada)}["alpha"] + assert json.loads(sent_cv) == tracker.autopilot_documents(ada, practice["alpha"]["job_id"])["cv"] + + +def test_a_practice_run_writes_new_documents_each_time(ada, world): + _run(ada) + first = _runs(ada)["alpha"] + asyncio.run(autopilot.run(ada, first["job_id"])) # a practice retry of one job + again = _runs(ada)["alpha"] + assert len(world.written) == 8 and again["cv_doc_id"] != first["cv_doc_id"] + assert tracker.autopilot_documents(ada, first["job_id"])["cv"]["summary"] == "Engineer, draft 7" + + +def test_a_live_send_writes_documents_when_the_earlier_ones_are_gone(ada, world): + _run(ada) + alpha = _runs(ada)["alpha"] + os.remove(pdf_generator.get_pdf_path(alpha["letter_doc_id"])) + _run(ada, live=True) + live = _runs(ada) + assert len(world.written) == 8 # alpha's pair again; beta's and gamma's kept + assert live["alpha"]["cv_doc_id"] != alpha["cv_doc_id"] and not live["alpha"]["notes"].startswith("Used") + assert live["beta"]["notes"].startswith("Used the CV and cover letter") + + 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") diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index 6a66abf..6b75c36 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -496,6 +496,7 @@ def test_the_hook_says_why_the_employer_draws_the_candidate(llm): "paragraphs": _letter_paragraphs(), "signoff": "Kind Regards,"}) _cover_letter() assert "why that fact draws THIS candidate" in llm.prompts[0] + assert "the hook's project is a different one from the two in EVIDENCE" in llm.prompts[0] def test_c_plus_plus_stays_on_one_line():