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():