From 10ffae9b67725ce6dfbfd288d92996b8eeec9cbd Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Mon, 2 Nov 2026 02:58:35 +0000 Subject: [PATCH] fix: cover letter openings spread across projects, a project can be kept out of them, and the addressee's town is a town Windrush opened most letters because job-ad words (application, career, global, teams) matched a project about job hunting. Those words no longer count towards relevance. The code now names the project for the hook, and a project marked "Don't open cover letters with this" is never that project: a letter that still opens on it gets one revision and is held by autopilot if it stays. It can still be evidence. The recipient block took the location's first part as it came ("United Kingdom - London", "Belfast 10 Mays Meadow"). It now finds the town, or leaves the line out when there is none. --- api/app/agent.py | 61 +++++++++++++++++++++++++------ api/app/autopilot.py | 3 ++ api/app/doc_render.py | 25 ++++++++++++- api/app/models.py | 1 + api/tests/test_autopilot.py | 9 +++++ api/tests/test_cv_upload.py | 2 +- api/tests/test_doc_check.py | 35 ++++++++++++++++++ api/tests/test_documents.py | 31 ++++++++++++++++ frontend/src/app/profile/page.tsx | 4 ++ frontend/src/lib/types.ts | 1 + 10 files changed, 158 insertions(+), 14 deletions(-) diff --git a/api/app/agent.py b/api/app/agent.py index 02dc118..8de0efc 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -428,6 +428,16 @@ def _persona_projects_for_llm(persona: dict) -> list[dict]: "using", "into", "across", "within", "including", "etc", "via", "per", "new", "use", "used", "build", "building", "built", "develop", "developed", "developing", "design", "designing", "designed", "candidate", "company", "join", "looking", "passionate", + # Everyday words in nearly every ad, and the job-ad vocabulary itself: these say nothing about fit, and they ranked + # a project about job hunting (Windrush) first for ads it had nothing to do with. + "about", "against", "all", "also", "any", "best", "but", "can", "day", "each", "every", "first", "how", "like", + "may", "more", "next", "not", "one", "out", "over", "own", "part", "set", "since", "such", "than", "their", "them", + "they", "through", "time", "was", "were", "what", "when", "where", "which", "who", "why", "world", "would", + "application", "applications", "apply", "based", "benefits", "business", "candidates", "career", "careers", + "challenges", "committed", "communication", "core", "degree", "development", "directly", "driven", "employer", + "employers", "end", "engineering", "exposure", "full", "global", "hiring", "hours", "human", "impact", "job", "jobs", + "learn", "level", "live", "london", "office", "open", "people", "problem", "process", "quality", "real", + "recruitment", "requirements", "roles", "salary", "search", "sponsorship", "teams", "technical", "value", "years", }) @@ -464,6 +474,28 @@ def _rank_projects_by_relevance(job: dict, projects: list[dict]) -> list[dict]: return [p for _, _, p in scored] +def _project_short_name(name: str) -> str: + """'Windrush: Autonomous AI Career Advisor · AI London 2026' -> 'Windrush'.""" + return re.split(r"[:·]", name or "")[0].strip() + + +def opening_names_barred_project(letter: dict, persona: dict) -> list[str]: + """Projects the candidate keeps out of a letter's opening (Project.not_in_opening) that its first paragraph names.""" + first = next(iter(letter.get("paragraphs") or []), "") + names = [_project_short_name(p.get("name", "")) for p in persona.get("projects") or [] if p.get("not_in_opening")] + return [n for n in names if n and re.search(rf"\b{re.escape(n)}\b", first, re.I)] + + +def _letter_findings(letter: dict, persona: dict, job: dict, research: str, hook: str) -> list[dict]: + """What the letter's one targeted revision has to fix: unbacked claims, machine-sounding words, a barred opening.""" + return (doc_check.check_facts("cover_letter", letter, persona, job, research) + + doc_check.check_style("cover_letter", letter) + + [{"kind": "opening", "field": "paragraphs.0", "label": "First paragraph", "value": name, + "message": f"it names {name}, which the candidate keeps out of the opening" + + (f": open on {_project_short_name(hook)} instead" if hook else "")} + for name in opening_names_barred_project(letter, persona)]) + + # How many skills a tailored CV lists: the candidate's own CV design shows about 30, in 5 rows. _MIN_CV_SKILLS, _MAX_CV_SKILLS = 20, 30 _CV_SKILLS_CEILING = 40 # a choice a little over the asked-for range is still better than the full list @@ -604,8 +636,9 @@ def _finding_keys(findings: list[dict]) -> set[tuple[str, str]]: def _issue_lines(findings: list[dict]) -> str: lines = [] for f in findings: - fix = ("use plain wording instead" if f["kind"] == "word" - else "remove it, or replace it with a fact the candidate data states") + fix = {"word": "use plain wording instead", + "opening": "tell the paragraph's story from that project, using only what the candidate data states", + }.get(f["kind"], "remove it, or replace it with a fact the candidate data states") lines.append(f"- {f['label']}: {f['message']} — {fix}") return "\n".join(lines) @@ -1185,10 +1218,13 @@ async def execute_tool(name: str, tool_input: dict) -> dict: core = persona.get("core_info", {}) full_name = f"{core.get('first_name', '')} {core.get('last_name', '')}".strip() level = _seniority(persona) - # Surface the most domain-relevant projects so the EVIDENCE paragraph can anchor on - # the best-fitting project (not always the internship) when one fits the role better. + # The most domain-relevant projects, named by role: the model opens on whichever project it's told to (with the + # top three in reverse, every letter opened on the third), so the code picks the hook, never one the candidate + # keeps out of openings (Project.not_in_opening); those can still be evidence. ranked_projects = _rank_projects_by_relevance(job, _persona_projects_for_llm(persona)) - top_project_names = [p["name"] for p in ranked_projects[:3]] + barred = {p.get("name") for p in persona.get("projects") or [] if p.get("not_in_opening")} + hook_project = next((p["name"] for p in ranked_projects if p["name"] not in barred), "") + evidence_projects = [p["name"] for p in ranked_projects if p["name"] != hook_project][:2] # Live company research grounds the "Why them?" paragraph (best-effort; "" on failure). company_research = await _research_company(job.get("company", ""), job.get("title", "")) @@ -1225,7 +1261,7 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "'headline' = the candidate's concise professional identity in 2-4 words " "(e.g. 'Computer Science Graduate'); do NOT put the role being applied for. " "Write EXACTLY 4 paragraphs, one page total (~320-380 words), in this order: " - "(1) HOOK ~90 words — open on a specific moment from ONE of the candidate's own projects: the hardest " + "(1) HOOK ~90 words — open on a specific moment from the PROJECT FOR THE HOOK named below: the hardest " "technical challenge in it and what actually made it hard (the shape 'the hardest challenge wasn't X, " "but Y'), told only from what that project's data says. Say what it shows about how the candidate works " "(an instinct or habit), then tie that same instinct to the EXACT role and ONE concrete, specific fact " @@ -1266,8 +1302,11 @@ 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, 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\nPROJECT FOR THE HOOK: {hook_project}" if hook_project else "") + + (f"\nPROJECTS FOR THE EVIDENCE, in priority order: {', '.join(evidence_projects)}" + if evidence_projects else "") + + (f"\nDo NOT name {', '.join(_project_short_name(b) for b in sorted(barred))} in the first paragraph." + if barred 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 "") ), @@ -1307,16 +1346,14 @@ async def execute_tool(name: str, tool_input: dict) -> dict: # machine-sounding words: one targeted revision, kept only if it clears them. revision = "fallback" if data: - findings = (doc_check.check_facts("cover_letter", letter, persona, job, company_research) - + doc_check.check_style("cover_letter", letter)) + findings = _letter_findings(letter, persona, job, company_research, hook_project) revision = "clean" if findings: revised = await _revise_letter_text(letter, findings, persona, job) if revised is None: revision = f"{len(findings)} found, bad reply" else: - after = (doc_check.check_facts("cover_letter", revised, persona, job, company_research) - + doc_check.check_style("cover_letter", revised)) + after = _letter_findings(revised, persona, job, company_research, hook_project) kept = _better(findings, after) revision = f"{len(findings)}->{len(after)} {'kept' if kept else 'rejected'}" if kept: diff --git a/api/app/autopilot.py b/api/app/autopilot.py index e1b685d..2b8ac67 100644 --- a/api/app/autopilot.py +++ b/api/app/autopilot.py @@ -168,6 +168,9 @@ def document_problems(cv: dict, letter: dict, persona: dict, job: dict, research messages = list(dict.fromkeys(f["message"] for f in findings)) out.append(f"{name} says something your profile doesn't back: " + "; ".join(messages[:3]) + (f" and {len(messages) - 3} more" if len(messages) > 3 else "")) + barred = agent.opening_names_barred_project(letter or {}, persona or {}) + if barred: + out.append(f"The cover letter opens on {', '.join(barred)}, which you keep out of openings.") return out diff --git a/api/app/doc_render.py b/api/app/doc_render.py index ebbc252..4d77eb8 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -338,12 +338,35 @@ def build_letter_template_data(letter: dict, fit: tuple[str, ...] = ()) -> dict: } +_COUNTRY = r"(?:united kingdom|great britain|england|scotland|wales|northern ireland|gbr?|uk|united states|usa|us)" +# Before the town: "GB - London", "GBR-Nottingham-1 Chapel Qtr", "Hybrid - London", "Full-Time – London", "August 2026 - London". +_NOT_THE_TOWN_YET = re.compile( + rf"^\s*(?:{_COUNTRY}|hybrid|remote|on-?site|home|office(?: based)?|(?:full|part)-?time|[a-z]+ \d{{4}})\s*[-–—]\s*", re.I) +# After it: "Glasgow Campus", "Belfast United Kingdom", "Berlin Metropolitan Area", "Massachusetts Remote Work". +_NOT_THE_TOWN_ANY_MORE = re.compile(rf"\s+(?:campus|metropol[a-z]+ area|remote(?: work)?|{_COUNTRY})$", re.I) +_NOT_A_TOWN = re.compile(rf"^(?:{_COUNTRY}\b|(?:hybrid|remote|on-?site|office|home|multiple locations|various|see listing|telecommute)$)", re.I) + + +def _letter_town(location: str) -> str: + """The first town in a job's location, whichever way the employer's system writes it: no country, work setting or + street address ("United Kingdom - London", "Belfast 10 Mays Meadow", "LONDON, United Kingdom" -> a town each).""" + for part in re.split(r"[,;/|]", re.sub(r"\([^)]*\)", " ", location or "")): + while _NOT_THE_TOWN_YET.match(part): + part = _NOT_THE_TOWN_YET.sub("", part, count=1) + # What follows it: a street number ("-1 Chapel Qtr", " 10 Mays Meadow"), a site ("London - Shell Centre"). + part = re.split(r"[\s-]+\d|^\d|\s+[-–—]\s+", part.strip())[0] + part = _NOT_THE_TOWN_ANY_MORE.sub("", part).strip(" -–—") + if part and not _NOT_A_TOWN.match(part): + return part.title() if part.isupper() and len(part) > 3 else part + return "" + + def _letter_recipient_lines(company: str, location: str) -> list[str]: """Who the letter is to, under the contact block: "Recruitment Team," / "Acme," / "London." The town is the first part of the job's location, without a work setting ("London, UK (Hybrid)" -> "London").""" if not company: return [] - town = re.sub(r"\s*\([^)]*\)", "", re.split(r"[,;/]", location or "")[0]).strip() + town = _letter_town(location) return ["Recruitment Team,", f"{company}{',' if town else '.'}"] + ([f"{town}."] if town else []) diff --git a/api/app/models.py b/api/app/models.py index a67c81f..9025ec7 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -44,6 +44,7 @@ class Project(BaseModel): outcomes: str = "" summary: str = "" is_ongoing: bool = False + not_in_opening: bool = False # never the project a cover letter opens on (it can still be evidence) class BehavioralStory(BaseModel): title: str diff --git a/api/tests/test_autopilot.py b/api/tests/test_autopilot.py index 92210f5..8d7a7cf 100644 --- a/api/tests/test_autopilot.py +++ b/api/tests/test_autopilot.py @@ -94,6 +94,15 @@ def test_a_document_claim_the_profile_doesnt_back_holds_the_form(): assert autopilot.hold_reasons({"ready": True, "error": "", "questions": [], "empty": []}, problems) == problems +def test_a_letter_opening_on_a_barred_project_holds_the_form(): + persona = {"core_info": {"first_name": "Ada"}, "projects": [{"name": "Windrush: Career Advisor", "not_in_opening": True}]} + job = {"title": "Engineer", "description": ""} + empty = {"summary": "", "experience": [], "projects": []} + assert autopilot.document_problems(empty, {"paragraphs": ["Building Windrush taught me.", "Next."]}, persona, job) == [ + "The cover letter opens on Windrush, which you keep out of openings."] + assert autopilot.document_problems(empty, {"paragraphs": ["Other.", "Windrush taught me."]}, persona, job) == [] + + @pytest.mark.parametrize("url, ats", [ ("https://boards.greenhouse.io/acme/jobs/1", "Greenhouse"), ("https://job-boards.greenhouse.io/acme/jobs/1", "Greenhouse"), diff --git a/api/tests/test_cv_upload.py b/api/tests/test_cv_upload.py index 9bbb3e1..f0fc35b 100644 --- a/api/tests/test_cv_upload.py +++ b/api/tests/test_cv_upload.py @@ -123,7 +123,7 @@ def test_upload_keeps_bullets_stack_projects_and_certifications(client, user, mo assert {"grade": "First Class Honours", "end_date": "2025"}.items() <= p["education"][0].items() # a lone year ends it assert p["projects"] == [{"name": "Engine Sim", "url": "https://github.com/ada/engine", "problem_solved": "Simulates the engine.", "technologies": ["Python", "Numba"], - "outcomes": "10x faster than the 1843 notes", "summary": "", "is_ongoing": False}] + "outcomes": "10x faster than the 1843 notes", "summary": "", "is_ongoing": False, "not_in_opening": False}] assert p["certifications"][0]["issuing_organization"] == "Amazon" assert p["summary"] == "Engineer who ships ML products." diff --git a/api/tests/test_doc_check.py b/api/tests/test_doc_check.py index ab7e886..dfab25a 100644 --- a/api/tests/test_doc_check.py +++ b/api/tests/test_doc_check.py @@ -237,6 +237,41 @@ def test_a_letter_with_a_flagged_word_is_revised(replies): f"Your team of 40 engineers ships daily. {agent._CLOSING_LINE}"] +def test_job_ad_vocabulary_doesnt_rank_a_project_first(): + job = {"title": "Graduate Developer", "description": "Our global teams value communication and quality. Apply for " + "this career in London: the application process, job requirements and hiring. You will price options."} + careers = {"name": "Careers", "problem_solved": "A job search and application process for global teams, on quality " + "and communication.", "outcomes": "Career and hiring requirements in London.", "technologies": []} + options = {"name": "Pricer", "problem_solved": "Options pricing.", "outcomes": "", "technologies": []} + assert agent._rank_projects_by_relevance(job, [careers, options])[0]["name"] == "Pricer" + + +def _with_barred_project() -> dict: + persona = copy.deepcopy(GRADUATE) + persona["projects"] = [{"name": "Windrush: Career Advisor", "problem_solved": "FastAPI Python services.", + "outcomes": "", "technologies": ["FastAPI", "Python"], "not_in_opening": True}, + {"name": "Pantry Planner", "problem_solved": "A meal planner.", "outcomes": "", "technologies": []}] + return persona + + +def test_the_letter_opens_on_the_best_project_the_candidate_allows_there(replies): + replies.queue = [json.dumps({"paragraphs": ["Pantry Planner was hard.", "Windrush used FastAPI."]})] + letter = asyncio.run(agent.execute_tool("generate_cover_letter", {"job": AD, "persona": _with_barred_project()}))["letter"] + assert letter["paragraphs"][0] == "Pantry Planner was hard." + assert "PROJECT FOR THE HOOK: Pantry Planner" in replies.prompts[0] + assert "PROJECTS FOR THE EVIDENCE, in priority order: Windrush: Career Advisor" in replies.prompts[0] + assert "Do NOT name Windrush in the first paragraph." in replies.prompts[0] + assert len(replies.prompts) == 1 # naming it as evidence isn't an issue, so no revision + + +def test_a_letter_opening_on_a_barred_project_is_revised(replies): + replies.queue = [json.dumps({"paragraphs": ["Building Windrush was hard.", "Pantry Planner too."]}), + json.dumps({"paragraphs": ["Pantry Planner was hard.", "Windrush too."]})] + letter = asyncio.run(agent.execute_tool("generate_cover_letter", {"job": AD, "persona": _with_barred_project()}))["letter"] + assert letter["paragraphs"][0] == "Pantry Planner was hard." + assert "open on Pantry Planner instead" in replies.prompts[1] + + # ── Critique and revise (writing quality) ───────────────────────────────────────────────────── @pytest.fixture diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index 6b75c36..d0b09a0 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -516,11 +516,42 @@ def test_the_letter_leaves_out_the_street_address(): ("London, UK (Hybrid)", ["Recruitment Team,", "Acme Robotics,", "London."]), ("London / Remote", ["Recruitment Team,", "Acme Robotics,", "London."]), ("", ["Recruitment Team,", "Acme Robotics."]), + ("United Kingdom", ["Recruitment Team,", "Acme Robotics."]), ]) def test_the_letter_is_addressed_to_the_employers_recruitment_team(location, lines): assert doc_render._letter_recipient_lines("Acme Robotics", location) == lines +@pytest.mark.parametrize("location, town", [ + ("United Kingdom - London", "London"), + ("GB - London, United Kingdom (Hybrid)", "London"), + ("GBR-Nottingham-1 Chapel Qtr", "Nottingham"), + ("Belfast 10 Mays Meadow; Der-Derry/Londonderry (Hybrid)", "Belfast"), + ("Canary Wharf, 1 Churchill Place", "Canary Wharf"), + ("Glasgow Campus", "Glasgow"), + ("Hybrid - London", "London"), + ("Office Based - London, UK", "London"), + ("August 2026 - London", "London"), + ("London - Shell Centre", "London"), + ("London, United Kingdom - Deliveroo", "London"), + ("LONDON, United Kingdom", "London"), + ("BURGESS HILL, WEST SUSSEX, United Kingdom", "Burgess Hill"), + ("Edinburgh, UK (ZUK129)", "Edinburgh"), + ("Home - UK- England", ""), + ("United Kingdom Offsite (ZUK99)", ""), + ("Multiple Locations", ""), + ("See listing", ""), + ("USA", ""), + ("US-Remote", ""), + ("Full-Time – London", "London"), + ("Belfast United Kingdom", "Belfast"), + ("London Metropolitain Area", "London"), + ("Remote", ""), +]) +def test_the_letter_takes_the_town_from_however_the_employer_writes_it(location, town): + assert doc_render._letter_town(location) == town + + def test_a_job_without_a_location_leaves_the_employers_location_blank(llm): persona = copy.deepcopy(GRADUATE) persona["core_info"]["country"] = "Nigeria" diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index e2fcee8..574857f 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -472,6 +472,10 @@ export default function ProfilePage() { projects.edit(i, { is_ongoing: e.target.checked })} className="w-4 h-4" style={{ accentColor: "var(--teal)" }} />Still working on it + }> {(p.summary || p.problem_solved) &&

{p.summary || p.problem_solved}

} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4a1a0d2..2b824cf 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -128,6 +128,7 @@ export interface Project { outcomes: string; summary: string; is_ongoing: boolean; + not_in_opening?: boolean; } export interface Certification {