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 {