From e9de78f598cfabdceec44c8d5f31769d650e2b95 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 20 Oct 2026 12:40:17 +0100 Subject: [PATCH] feat: cover letters in the candidate's own style and structure The letter now follows the candidate's Jump Trading letter, measured from the PDF: a Gelasio name with the surname in sage, a sage rule, a right-aligned left column (contact without the street address, then "Recruitment Team, / Company, / Town.") beside the letter, a sage line between them and a sage band on the page's foot. No date or "Re:" line; "Dear Hiring Manager" as a heading; "Kind Regards,". The generator writes the same four parts: a hook from the hardest challenge in one of the candidate's projects tied to a concrete fact about the employer, two projects as evidence ("Separately, ..."), an honest gap taken from a real requirement in the ad, and the candidate's closing line, which is now enforced along with the sign-off. The critique rubric judges against that structure. "C++" no longer breaks across lines, and the editor drops the Date and Re: fields the letter no longer shows. --- api/app/agent.py | 119 +++++++++++-------- api/app/doc_render.py | 45 ++++--- api/app/templates/base.css | 32 +---- api/app/templates/classic_letter.html | 64 +++++----- api/app/templates/fonts/Gelasio-Bold.woff | Bin 0 -> 24216 bytes api/app/templates/fonts/Gelasio-Regular.woff | Bin 0 -> 24008 bytes api/app/templates/fonts/OFL.txt | 93 +++++++++++++++ api/app/templates/letter.css | 69 +++++++++++ api/tests/test_doc_check.py | 3 +- api/tests/test_documents.py | 115 +++++++++++++----- frontend/src/components/DocEditor.tsx | 6 +- 11 files changed, 384 insertions(+), 162 deletions(-) create mode 100644 api/app/templates/fonts/Gelasio-Bold.woff create mode 100644 api/app/templates/fonts/Gelasio-Regular.woff create mode 100644 api/app/templates/fonts/OFL.txt create mode 100644 api/app/templates/letter.css diff --git a/api/app/agent.py b/api/app/agent.py index a064716..2b105f2 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -663,13 +663,28 @@ async def _revise_cv_text(cv: dict, findings: list[dict], candidate: dict, job: ) _LETTER_RUBRIC = ( - "1. SPECIFIC TO THEM: the 'why them' paragraph passes the swap test — if another employer's name could " - "replace theirs and it still reads fine, it fails. 2. EVIDENCE: two or three requirements named in the ad " - "are each proved by a named project or role from the candidate data, with a real result. 3. OPENING: a " - "specific hook tied to this employer, never 'I am writing to apply'. 4. ENDING: short and concrete; no perks " - "or salary. 5. LANGUAGE: active, confident, no hedging ('I believe', 'I feel'), no clichés or filler." + "The letter follows the candidate's own structure; judge each part against it, never reshape it into another. " + "1. HOOK: opens on a specific challenge from one of the candidate's own projects and what it shows about how " + "they work, tied to the exact role and one concrete fact about this employer; 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 " + "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." ) +# How the candidate ends every letter. +_CLOSING_LINE = "Thank you for your time and consideration and I hope to hear from you soon." + + +def _with_closing_line(paragraphs: list[str]) -> list[str]: + """The letter's last paragraph ending on the candidate's closing line, in place of the thanks the model wrote.""" + if not paragraphs or _CLOSING_LINE in paragraphs[-1]: + return paragraphs + last = re.sub(r"(\s*(?:Thank you|I hope|I look forward)\b[^.!]*[.!])+\s*$", "", paragraphs[-1].strip()) + return paragraphs[:-1] + [f"{last} {_CLOSING_LINE}".strip()] + async def _critique_cv_text(cv: dict, candidate: dict, job: dict) -> tuple[dict | None, list[str]]: """ @@ -1190,47 +1205,52 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "email": core.get("email", ""), "phone": core.get("phone", ""), }, - "salutation": "Dear Hiring Manager,", + "salutation": "Dear Hiring Manager", "paragraphs": [], - "signoff": "Yours faithfully,", + "signoff": "Kind Regards,", "archetype": archetype, } + # The structure and voice of the candidate's own letters (their Jump Trading letter): a hook story from one + # of their projects, two projects as evidence, an honest gap, a short close. Described, not quoted: a + # sample letter's facts would leak into other letters. system = ( - f"Write a {tone}, authentically human, UK-English cover letter for a {level} candidate, framed " - "around the EMPLOYER'S needs (not a list of your own achievements). " + f"Write a UK-English cover letter in the candidate's own voice for a {level} candidate. " f"Archetype: {archetype}. {archetype_guidance} " 'Return ONLY a JSON object (no prose, no code fences) with this shape: ' '{"headline":str,"salutation":str,"paragraphs":[str,...],"signoff":str}. ' "'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 short paragraphs, one page total (~300-380 words), in this order: " - "(1) INTRODUCTION ~60 words — name the EXACT role and open with a specific hook about YOU that ties to " - "this employer (e.g. why you chose your specialism / what draws you to their problem), then a one-line " - "summary of what you bring. NEVER begin with 'I am writing to apply for'. " - "(2) WHY THEM ~80 words — give specific, researched reasons for wanting THIS company/sector/role, drawn " - "from the COMPANY RESEARCH and job description, and explain WHY those facts connect to YOU (your " - "experience, goals or values). Do NOT restate their website or tell them what they already know. It " - "MUST fail the 'swap-the-company-name' test: if another employer's name could replace theirs and the " - "paragraph still reads fine, it is too generic — rewrite it. " - "(3) WHY ME ~140 words — choose 2-3 skills/requirements taken DIRECTLY from the job's requirements / " - "person specification and prove EACH with a concrete, quantified example, leading with the most " - "domain-relevant NAMED project or internship. Mirror the employer's EXACT terminology from the posting " - "(their tool, framework and skill names). Link each example forward to how it lets you deliver in THIS " - "role. " - "(4) ENDING ~40 words — short and specific; reiterate interest by referencing a concrete point you " - "made, and state availability if requested. Do NOT mention or praise perks, benefits, salary, " - "holiday, working hours or well-being; avoid generic lines like 'it would be a privilege'. " - "LANGUAGE (employers judge your writing here): active voice; sound confident — NO hedging qualifiers " - "('I feel that', 'I believe I could', 'I think'); NO vague quantity words (various, some, many, " - "several, lots); quantify ONLY with real numbers present in the candidate data — never invent, " - "estimate or round; frame any skill gap positively (how you would build it) — never 'lacking' or " - "'unfortunately'; no slang or abbreviations (write 'undergraduate', not 'undergrad'); no clichés or " - "buzzwords. Do NOT use any of these words: delve, testament, tapestry, landscape, foster, underscore, " - "beacon, synergy, pivotal, intricate, unleash, elevate, seamless, robust, leverage. Do NOT cluster " - "'Furthermore/Moreover/Additionally'. No placeholders, no invented facts. " - "Salutation: 'Dear Hiring Manager,' unless a named contact is provided. " - "Sign-off: 'Yours faithfully,' when no name is known, 'Yours sincerely,' when it is (UK convention)." + "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 " + "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 " + "about this employer from the COMPANY RESEARCH or the ad (what they build, or how they work), ending 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. " + "(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 " + "second named project that shows a judgement call (a trade-off chosen, and why). End with one sentence " + "naming the discipline both show. " + "(3) HONEST GAP ~90 words — pick ONE requirement the ad names that the candidate data shows least of (a " + "technology, tool or domain). State it plainly and briefly ('My experience is limited so far'), say " + "exactly what related experience the data DOES show, say candidly that closing that gap is the stretch " + "they want ('I'll be upfront: ...'), then the strengths from the data that let them close it quickly. " + "Never invent a weakness the ad doesn't ask about; never apologise or run the candidate down. If the " + "data covers every requirement, use this paragraph for a third piece of evidence instead. " + "(4) CLOSE ~35 words — 'I'd welcome the opportunity to discuss how " + f"could contribute to your .' then end with this sentence, word for word: '{_CLOSING_LINE}' No " + "perks, benefits, salary or working hours. " + "VOICE: first person, direct and specific, like an engineer explaining their own work; semicolons and " + "colons are fine; contractions are fine (I'd, I'll); British spelling; active voice; no hedging filler " + "('I feel that', 'I believe I could'). Quantify ONLY with numbers present in the candidate data, the ad " + "or the company research — never invent, estimate or round. No clichés or buzzwords. Do NOT use any of " + "these words: delve, testament, tapestry, landscape, foster, underscore, beacon, synergy, pivotal, " + "intricate, unleash, elevate, seamless, robust, leverage. No placeholders, no invented facts. " + "Salutation: 'Dear Hiring Manager' (no comma) unless a named contact is provided. Sign-off: " + "'Kind Regards,'." + (f"\n\nDirectives from user: {directives}" if directives else "") ) data = await _llm_json( @@ -1239,10 +1259,10 @@ 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 (use the best fit as your main 'Why me' " - f"evidence, in priority order): {', '.join(top_project_names)}" if top_project_names else "") - + (f"\n\nCOMPANY RESEARCH (ground your 'Why them' in this; explain why it connects to you — do " - f"NOT restate it):\n{company_research}" if company_research else "") + + (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\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 "") ), max_tokens=1024, label="cover_letter", @@ -1266,18 +1286,6 @@ async def execute_tool(name: str, tool_input: dict) -> dict: or f"I am writing to apply for the {job.get('title', '')} role at {job.get('company', '')}." ] - # Deterministic UK sign-off guard: 'Yours faithfully' for a generic salutation, - # 'Yours sincerely' only when a specific person is named. The model gets this wrong - # intermittently (e.g. 'Dear Hiring Manager,' + 'Yours sincerely,'). - _salu = letter.get("salutation", "").lower() - _generic_salutation = (not _salu) or any( - g in _salu for g in ( - "hiring manager", "madam", "sir", "to whom it may concern", - "team", "recruiter", "recruitment", - ) - ) - letter["signoff"] = "Yours faithfully," if _generic_salutation else "Yours sincerely," - # Critique and revise against the rubric; kept only if it adds no unsupported claim and # stays about the same length. review: dict = {"issues": [], "kept": False} @@ -1307,6 +1315,13 @@ async def execute_tool(name: str, tool_input: dict) -> dict: if kept: letter = revised + # The candidate's own sign-off and closing line, whatever the model or a revision wrote, and the salutation + # as a heading (no comma). + letter["signoff"] = "Kind Regards," + letter["salutation"] = (letter.get("salutation") or "Dear Hiring Manager").strip().rstrip(",") + if data: + letter["paragraphs"] = _with_closing_line(letter["paragraphs"]) + (logger.warning if data is None else logger.info)( "cover_letter: llm_used=%s, paragraphs=%d, archetype=%s, level=%s, critique=%d issues %s, revision=%s " "| job=%r", diff --git a/api/app/doc_render.py b/api/app/doc_render.py index 156e934..ebbc252 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -38,6 +38,9 @@ trim_blocks=True, lstrip_blocks=True, ) +# A word joiner between plus signs keeps "C++" on one line; WeasyPrint otherwise breaks it as "C+ / +". A filter, +# not the data: the fallback PDF writes latin-1, which has no word joiner. +_env.filters["unbroken"] = lambda text: str(text).replace("++", "+⁠+") # ── Length limits ──────────────────────────────────────────────────────────────── # These bound what any doc can put on the page; fitting a CV to its page target is _fitted_cv_html's job. @@ -285,17 +288,15 @@ def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict: def _letter_contact_lines(contact: dict) -> list[dict]: - """Vertical contact block for a cover letter: address, city+postcode, phone, email.""" + """Contact block for a cover letter, as the candidate's own letter has it: town and postcode, phone, email. + No street address: the persona asks for it only where a form requires one.""" if not isinstance(contact, dict): return [] lines: list[dict] = [] - addr = (contact.get("address_line_1") or "").strip() city = (contact.get("city") or contact.get("location") or "").strip() postcode = (contact.get("postcode") or "").strip() phone = (contact.get("phone") or "").strip() email = (contact.get("email") or "").strip() - if addr: - lines.append({"text": addr + ","}) city_line = " ".join(x for x in [city, postcode] if x).strip() if city_line: lines.append({"text": city_line + "."}) @@ -312,22 +313,40 @@ def build_letter_template_data(letter: dict, fit: tuple[str, ...] = ()) -> dict: # Every paragraph is kept (the model may write five, the editor can add more); fitting the # letter to one page is _fitted_html's job, which only ever tightens the layout. paragraphs = [p.strip() for p in (letter.get("paragraphs") or []) if p and p.strip()] + name = (letter.get("candidate_name") or "").strip().rstrip(".") + first_names, _, last_name = name.rpartition(" ") if " " in name else (name, "", "") + company = (letter.get("company") or "").strip() + company_location = (letter.get("company_location") or "").strip() return { "page_target": 1, "body_class": " ".join(fit), - "candidate_name": (letter.get("candidate_name") or "").strip(), + "candidate_name": name, + # The name heading sets the surname apart (in the accent colour), as the candidate's letter does. + "first_names": first_names, + "last_name": last_name, "headline": (letter.get("headline") or "").strip(), - "company": (letter.get("company") or "").strip(), + "company": company, "job_title": (letter.get("job_title") or "").strip(), - "company_location": (letter.get("company_location") or "").strip(), + "company_location": company_location, "date": (letter.get("date") or "").strip(), "contact_lines": _letter_contact_lines(letter.get("contact", {})), - "salutation": (letter.get("salutation") or "Dear Hiring Manager,").strip(), + "recipient_lines": _letter_recipient_lines(company, company_location), + # A heading in the letter, so no trailing comma ("Dear Hiring Manager"). + "salutation": (letter.get("salutation") or "Dear Hiring Manager").strip().rstrip(","), "paragraphs": paragraphs, - "signoff": (letter.get("signoff") or "Yours faithfully,").strip(), + "signoff": (letter.get("signoff") or "Kind Regards,").strip(), } +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() + return ["Recruitment Team,", f"{company}{',' if town else '.'}"] + ([f"{town}."] if town else []) + + # ── HTML rendering ─────────────────────────────────────────────────────────────── @functools.lru_cache(maxsize=1) @@ -396,12 +415,10 @@ def _fallback_pdf(doc_type: str, doc: dict) -> str: """Legacy fpdf2 path used only if WeasyPrint is unavailable/errors.""" if doc_type == "cover_letter": data = build_letter_template_data(doc) - # fpdf2 prints the name as the heading; the date, employer and role open the text. + # fpdf2 prints the name as the heading; who it's to opens the text, as in the letter's left column. head = ([data["headline"]] if data["headline"] else []) + [c["text"] for c in data["contact_lines"]] - recipient = "\n".join(x for x in (data["company"], data["company_location"]) if x) - subject = f"Re: {data['job_title']}" if data["job_title"] else "" - body = [x for x in (data["date"], recipient, subject) if x] - body += [data["salutation"], *data["paragraphs"], data["signoff"], data["candidate_name"]] + body = ["\n".join(data["recipient_lines"])] if data["recipient_lines"] else [] + body += [data["salutation"], *data["paragraphs"], data["signoff"], data["candidate_name"] + "."] text = "\n".join(head) + "\n\n" + "\n\n".join(body) return pdf_generator.generate_pdf("cover_letter", text, {"name": data["candidate_name"]}) data = build_cv_template_data(doc) diff --git a/api/app/templates/base.css b/api/app/templates/base.css index c9de056..e2808b4 100644 --- a/api/app/templates/base.css +++ b/api/app/templates/base.css @@ -78,7 +78,7 @@ body.cv { .cv-muted { font-size: 9pt; color: #595959; } /* ── Fit to page ────────────────────────────────────────────────────── */ -/* Classes doc_render puts on the body element when a CV or letter runs past its page target: +/* Classes doc_render puts on the body element when a CV runs past its page target (letter.css has the letter's): "tight" closes up the spacing, "small" then brings the body type down to 10pt, the floor. */ body.cv.tight { line-height: 1.12; } body.cv.tight .cv-section h2 { margin-top: 4pt; } @@ -86,33 +86,3 @@ body.cv.tight .cv-entry { margin-top: 2pt; } body.cv.small { font-size: 10pt; } body.cv.small .cv-section h2 { font-size: 12pt; } body.cv.small .cv-name { font-size: 22pt; } -/* The same two steps for a cover letter. */ -body.tight .letter-head { margin-bottom: 8pt; } -body.tight .letter-contact-block { margin-top: 6pt; } -body.tight .letter-meta { margin: 8pt 0; } -body.tight .letter-meta .letter-date { margin-bottom: 5pt; } -body.tight .letter-meta .letter-subject { margin-top: 6pt; } -body.tight .letter-body p { margin-bottom: 6pt; line-height: 1.32; } -body.tight .letter-signoff { margin-top: 8pt; } -body.small .letter-body p { font-size: 10pt; } -body.small .letter-name { font-size: 16pt; } - -/* ── Cover letter ───────────────────────────────────────────────────── */ -.letter-head { margin-bottom: 14pt; } -.letter-name { font-size: 18pt; font-weight: 700; margin: 0; } -.letter-headline { font-size: 11pt; color: #333; font-weight: 600; margin: 1pt 0 0; } -.letter-contact-block { margin-top: 10pt; } -.letter-contact-label { - font-weight: 700; font-size: 10pt; text-transform: uppercase; - letter-spacing: 0.6pt; margin: 0 0 2pt; -} -.letter-contact-line { font-size: 10pt; margin: 0; line-height: 1.35; } -.letter-contact-line a { color: inherit; text-decoration: underline; } -.letter-meta { margin: 12pt 0; font-size: 10pt; color: #444; } -.letter-meta p { margin: 0; line-height: 1.35; } -.letter-meta .letter-date { margin-bottom: 8pt; } -.letter-meta .letter-subject { margin-top: 10pt; font-weight: 700; color: #1a1a1a; } -.letter-body p { margin: 0 0 9pt; font-size: 10.5pt; line-height: 1.4; } -.letter-signoff { margin-top: 14pt; } -.letter-signoff p { margin: 0; } -.letter-signoff .sig-name { font-weight: 700; } diff --git a/api/app/templates/classic_letter.html b/api/app/templates/classic_letter.html index 61a32aa..6cd3063 100644 --- a/api/app/templates/classic_letter.html +++ b/api/app/templates/classic_letter.html @@ -3,43 +3,41 @@ {{ letter.candidate_name }} — Cover Letter - + -
-

{{ letter.candidate_name }}

- {% if letter.headline %}

{{ letter.headline }}

{% endif %} - {% if letter.contact_lines %} -
-

Contact

- {% for line in letter.contact_lines %}

{% if line.href %}{{ line.text }}{% else %}{{ line.text }}{% endif %}

{% endfor %} -
- {% endif %} +
+

{{ letter.first_names }}{% if letter.last_name %} {{ letter.last_name }}{% endif %}

+ {% if letter.headline %}

{{ letter.headline }}

{% endif %}
+
- {% if letter.date or letter.company or letter.job_title %} -
- {% if letter.date %}

{{ letter.date }}

{% endif %} - {% if letter.company %} -
-

{{ letter.company }}

- {% if letter.company_location %}

{{ letter.company_location }}

{% endif %} -
- {% endif %} - {% if letter.job_title %}

Re: {{ letter.job_title }}

{% endif %} -
- {% endif %} + + + + + +
+ {% if letter.contact_lines %} +

Contact

+ {% for line in letter.contact_lines %}

{% if line.href %}{{ line.text }}{% else %}{{ line.text }}{% endif %}

{% endfor %} + {% endif %} + {% if letter.recipient_lines %} +
+ {% for line in letter.recipient_lines %}

{{ line }}

{% endfor %} +
+ {% endif %} +
+

{{ letter.salutation }}

+ {% for para in letter.paragraphs %} +

{{ para | unbroken }}

+ {% endfor %} +
+

{{ letter.signoff }}

+

{{ letter.candidate_name }}.

+
+
-
-

{{ letter.salutation }}

- {% for para in letter.paragraphs %} -

{{ para }}

- {% endfor %} -
- -
-

{{ letter.signoff }}

-

{{ letter.candidate_name }}

-
+
diff --git a/api/app/templates/fonts/Gelasio-Bold.woff b/api/app/templates/fonts/Gelasio-Bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..33cdbedc36ed26108f44a341a2b9ffaa9d54a659 GIT binary patch literal 24216 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 Wg%9IjK!Cug0002E0sn=t1dRYD)K=dB literal 0 HcmV?d00001 diff --git a/api/app/templates/fonts/OFL.txt b/api/app/templates/fonts/OFL.txt new file mode 100644 index 0000000..5c33707 --- /dev/null +++ b/api/app/templates/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/api/app/templates/letter.css b/api/app/templates/letter.css new file mode 100644 index 0000000..e8adb4d --- /dev/null +++ b/api/app/templates/letter.css @@ -0,0 +1,69 @@ +/* Cover letter stylesheet, measured from the candidate's own letter: a serif name with the surname in sage, a sage + rule, a right-aligned left column (contact, then who it's to) beside the letter, a sage line between them and a + sage band along the foot. Inlined into classic_letter.html like base.css, so the preview and the PDF agree. + Gelasio (OFL, metric-compatible with Georgia) is bundled for the PDF; the browser preview falls back to Georgia. */ + +@font-face { font-family: "Gelasio"; src: url("fonts/Gelasio-Regular.woff") format("woff"); font-weight: 400; } +@font-face { font-family: "Gelasio"; src: url("fonts/Gelasio-Bold.woff") format("woff"); font-weight: 700; } + +@page { size: A4; margin: 0; } + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: #ffffff; + color: #404040; + font-family: "Liberation Sans", Arial, Helvetica, sans-serif; + font-size: 10pt; + line-height: 1.38; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +body { width: 210mm; min-height: 297mm; position: relative; padding: 16mm 12.7mm 22mm; } +@media screen { body { margin: 0 auto; } } + +a { color: inherit; text-decoration: none; } +p { margin: 0; } + +/* The name and who the candidate is. */ +.head { padding: 0 0 8.9mm 18mm; } +.head .name { font-family: "Gelasio", Georgia, serif; font-weight: 400; font-size: 45pt; line-height: 1.05; margin: 0; + color: #000000; } +.head .name .last { color: #648276; } +.head .headline { font-family: "Gelasio", Georgia, serif; font-weight: 700; font-size: 22pt; line-height: 1.2; + margin: 2.4mm 0 0; color: #000000; } + +.rule { border-top: 0.8mm solid #648276; margin: 0; } + +/* Two columns: the left one is right-aligned, a sage line runs down between them. */ +table.columns { width: 100%; border-collapse: collapse; table-layout: fixed; } +table.columns td { vertical-align: top; } +table.columns td.side { width: 47.2mm; text-align: right; padding: 6.8mm 4.1mm 0 0; } +table.columns td.main { border-left: 0.7mm solid #648276; padding: 6.8mm 0 11mm 5.2mm; } + +.side h2, .main h2 { font-family: "Gelasio", Georgia, serif; font-weight: 700; font-size: 14pt; color: #648276; + margin: 0 0 1.6mm; line-height: 1.2; } +.side p { font-size: 11pt; line-height: 1.39; } +.side .to { margin-top: 10.3mm; } + +.main .para { margin: 0 0 4.7mm; } +.main .signoff { color: #648276; } + +/* The sage band along the foot of the page: against the body in the browser preview, against the page in the PDF + (WeasyPrint places an absolute box under a min-height body straight after the text, not at its foot). */ +.band { position: absolute; left: 12.7mm; right: 12.7mm; bottom: 0; height: 12.7mm; background: #648276; } +@media print { .band { position: fixed; } } + +/* doc_render tightens a letter that runs past one page: first the spacing, then the heading and side type + (the body is already at 10pt, the floor). */ +body.tight { padding-top: 12mm; } +body.tight .head { padding-bottom: 6mm; } +body.tight table.columns td.side, body.tight table.columns td.main { padding-top: 5mm; } +body.tight table.columns td.main { padding-bottom: 6mm; } +body.tight .main .para { margin-bottom: 3mm; line-height: 1.3; } +body.small .head .name { font-size: 38pt; } +body.small .head .headline { font-size: 18pt; } +body.small .side p { font-size: 10pt; } diff --git a/api/tests/test_doc_check.py b/api/tests/test_doc_check.py index fa216b1..98afbfa 100644 --- a/api/tests/test_doc_check.py +++ b/api/tests/test_doc_check.py @@ -222,7 +222,8 @@ def test_a_letter_with_a_flagged_word_is_revised(replies): replies.queue = [json.dumps(first), json.dumps({"paragraphs": ["I use FastAPI daily.", "Your team of 40 engineers ships daily."]})] letter = asyncio.run(agent.execute_tool("generate_cover_letter", {"job": AD, "persona": GRADUATE}))["letter"] - assert letter["paragraphs"] == ["I use FastAPI daily.", "Your team of 40 engineers ships daily."] + assert letter["paragraphs"] == ["I use FastAPI daily.", + f"Your team of 40 engineers ships daily. {agent._CLOSING_LINE}"] # ── Critique and revise (writing quality) ───────────────────────────────────────────────────── diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index e4e4560..0bed541 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -74,6 +74,18 @@ def _letter_paragraphs(words_each=(60, 80, 140, 40)) -> list[str]: return [_sentence(n, i) + "." for i, n in enumerate(words_each)] +# Prose at the density of the candidate's own letter (about 5.2 characters a word; _WORDS run to 8), for +# measuring what a page holds. +_PROSE = ("I designed a scheduling service that cut deployment latency from forty minutes to nine by caching every " + "build step, checking which inputs changed and rerunning only the affected tests, which taught me to " + "measure before optimising and to explain each compromise clearly to reviewers on the team").split() + + +def _prose_paragraphs(words_each) -> list[str]: + return [" ".join(_PROSE[(7 * p + i) % len(_PROSE)] for i in range(n)).capitalize() + "." + for p, n in enumerate(words_each)] + + def _full_cvdoc(seniority: str) -> dict: """A CVDoc at every length limit: what a verbose model can hand the renderer.""" return { @@ -125,8 +137,9 @@ def _headings(html: str) -> list[str]: def _letter_body(html: str) -> list[str]: - body = re.search(r'
(.*?)
', html, re.S).group(1) - return re.findall(r"

(.*?)

", body, re.S) + """The letter's right-hand column: the salutation heading, then each paragraph.""" + main = re.search(r'(.*?)
', html, re.S).group(1) + return re.findall(r"

(.*?)

", main, re.S) + re.findall(r'

(.*?)

', main, re.S) # ── CV: profile → document ─────────────────────────────────────────────────────── @@ -433,35 +446,71 @@ def test_summaries_are_depersonalised(raw, clean): # ── Cover letter ───────────────────────────────────────────────────────────────── -@pytest.mark.parametrize("salutation, signoff", [ - ("Dear Hiring Manager,", "Yours faithfully,"), - ("Dear Acme Robotics team,", "Yours faithfully,"), - ("Dear Ms Patel,", "Yours sincerely,"), +@pytest.mark.parametrize("salutation, heading", [ + ("Dear Hiring Manager,", "Dear Hiring Manager"), + ("Dear Ms Patel,", "Dear Ms Patel"), # a named contact is kept + ("", "Dear Hiring Manager"), ]) -def test_cover_letter_sign_off_follows_the_salutation(llm, salutation, signoff): - wrong = "Yours sincerely," if signoff == "Yours faithfully," else "Yours faithfully," +def test_cover_letter_signs_off_as_the_candidate_does(llm, salutation, heading): llm.reply = json.dumps({"headline": "Computer Science Graduate", "salutation": salutation, - "paragraphs": _letter_paragraphs(), "signoff": wrong}) + "paragraphs": _letter_paragraphs(), "signoff": "Yours faithfully,"}) letter = _cover_letter() - assert (letter["salutation"], letter["signoff"]) == (salutation, signoff) + assert (letter["salutation"], letter["signoff"]) == (heading, "Kind Regards,") def test_cover_letter_renders_every_paragraph_and_the_contact_block(llm): paragraphs = _letter_paragraphs() + ["A fifth, closing paragraph the model added."] llm.reply = json.dumps({"headline": "Computer Science Graduate", "salutation": "Dear Hiring Manager,", - "paragraphs": paragraphs, "signoff": "Yours faithfully,"}) + "paragraphs": paragraphs, "signoff": "Kind Regards,"}) html = doc_render.render_letter_html(_cover_letter()) - assert _letter_body(html) == ["Dear Hiring Manager,", *paragraphs] # the closing paragraph isn't dropped - assert '

Computer Science Graduate

' in html + closing = f"{paragraphs[-1]} {agent._CLOSING_LINE}" # the closing paragraph isn't dropped + assert _letter_body(html) == ["Dear Hiring Manager", *paragraphs[:-1], closing] + assert 'Sam Okafor' in html # the surname in the accent colour + assert '

Computer Science Graduate

' in html assert ">Bristol.

" in html and 'href="mailto:sam@example.com"' in html and 'href="tel:+447700900456"' in html - assert re.search(r'

Yours faithfully,

\s*

Sam Okafor

', html) + assert re.search(r'

Kind Regards,

\s*

Sam Okafor.

', html) - # Under the candidate's contact details: the date, the employer and where, then the role. - today = datetime.date.today().strftime("%d %B %Y") - order = [">Bristol.

", f'

{today}

', "

Acme Robotics

", "

Cambridge, UK

", - '

Re: Graduate Software Engineer

', "

Dear Hiring Manager,

"] + # The left column: contact, then who it's to; the letter has no date and no "Re:" line. + order = ["

Contact

", ">Bristol.

", "

Recruitment Team,

", "

Acme Robotics,

", + "

Cambridge.

", '', "

Dear Hiring Manager

"] assert [html.index(s) for s in order] == sorted(html.index(s) for s in order) + assert datetime.date.today().strftime("%d %B %Y") not in html and "Re:" not in html + + +@pytest.mark.parametrize("close", [ + "I'd welcome the chance to talk. Thank you for your consideration.", + "I'd welcome the chance to talk. Thank you for your time. I look forward to hearing from you.", + "I'd welcome the chance to talk.", + "I'd welcome the chance to talk. Thank you for your time and consideration and I hope to hear from you soon.", +]) +def test_the_letter_ends_on_the_candidates_closing_line(llm, close): + llm.reply = json.dumps({"headline": "Computer Science Graduate", "salutation": "Dear Hiring Manager", + "paragraphs": _letter_paragraphs()[:3] + [close], "signoff": "Kind Regards,"}) + assert _cover_letter()["paragraphs"][-1] == ( + "I'd welcome the chance to talk. Thank you for your time and consideration and I hope to hear from you soon.") + + +def test_c_plus_plus_stays_on_one_line(): + html = doc_render.render_letter_html({"candidate_name": "Sam Okafor", "paragraphs": ["I write C++ daily."]}) + assert "C+⁠+ daily" in html # a word joiner between the plus signs + + +def test_the_letter_leaves_out_the_street_address(): + contact = {"address_line_1": "1 High Street", "city": "Bristol", "postcode": "BS1 1AA", + "phone": "+44 7700 900456", "email": "sam@example.com"} + html = doc_render.render_letter_html({"candidate_name": "Sam Okafor", "contact": contact, "paragraphs": ["Hi."]}) + assert "

Bristol BS1 1AA.

" in html and "High Street" not in html + + +@pytest.mark.parametrize("location, lines", [ + ("Cambridge, UK", ["Recruitment Team,", "Acme Robotics,", "Cambridge."]), + ("London, UK (Hybrid)", ["Recruitment Team,", "Acme Robotics,", "London."]), + ("London / Remote", ["Recruitment Team,", "Acme Robotics,", "London."]), + ("", ["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 def test_a_job_without_a_location_leaves_the_employers_location_blank(llm): @@ -472,9 +521,10 @@ def test_a_job_without_a_location_leaves_the_employers_location_blank(llm): assert letter["company"] == "Acme Robotics" and letter["company_location"] == "" -def test_a_letter_without_date_or_employer_has_no_empty_block(): +def test_a_letter_without_contact_or_employer_has_no_empty_block(): html = doc_render.render_letter_html({"candidate_name": "Sam Okafor", "paragraphs": ["Hello."]}) - assert 'class="letter-meta"' not in html.split("")[1] and "Re:" not in html + page = html.split("")[1] + assert 'class="to"' not in page and "

Contact

" not in page def test_cover_letter_without_the_model_still_reads_as_a_letter(llm): @@ -482,7 +532,7 @@ def test_cover_letter_without_the_model_still_reads_as_a_letter(llm): letter = _cover_letter() assert letter["headline"] == "Computer Science Graduate" # from the degree assert letter["paragraphs"] == [GRADUATE["summary"]] - assert (letter["salutation"], letter["signoff"]) == ("Dear Hiring Manager,", "Yours faithfully,") + assert (letter["salutation"], letter["signoff"]) == ("Dear Hiring Manager", "Kind Regards,") # ── PDFs ───────────────────────────────────────────────────────────────────────── @@ -559,8 +609,19 @@ def test_a_full_length_cover_letter_fits_one_page(pdf_dir): "phone": "+44 7700 900456", "email": "sam@example.com"}, "date": "15 October 2026", "company": "Acme Robotics", "company_location": "Cambridge, UK", "job_title": "Graduate Software Engineer (2027 start)", - "paragraphs": _letter_paragraphs((70, 95, 160, 55))} # longer than the ~380 words asked for - assert _weasy("cover_letter", letter).page_count == 1 + "paragraphs": _prose_paragraphs((110, 130, 110, 70))} # longer than the ~380 words asked for + assert '' in doc_render.render_letter_html(letter) # fits without tightening + pdf = _weasy("cover_letter", letter) + assert pdf.page_count == 1 + assert any(f[3].endswith("+Gelasio-Bold") for f in pdf[0].get_fonts()) # the bundled serif, not a fallback + spans = {s["text"].strip(): (round(s["size"], 1), f"#{s['color']:06x}") + for b in pdf[0].get_text("dict")["blocks"] for line in b.get("lines", []) for s in line["spans"]} + assert spans["Okafor"] == (45.0, "#648276") and spans["Contact"] == (14.0, "#648276") # the candidate's letter, + assert spans["Kind Regards,"] == (10.0, "#648276") # measured + assert "High Street" not in pdf[0].get_text() + mm = 25.4 / 72 + band = [d["rect"] for d in pdf[0].get_drawings() if d["rect"].y0 * mm > 280] + assert band and round(band[0].y1 * mm) == 297 # the sage band sits on the page's foot @needs_weasyprint @@ -570,10 +631,10 @@ def test_a_five_paragraph_letter_at_full_length_fits_one_page(pdf_dir): "phone": "+44 7700 900456", "email": "sam@example.com"}, "date": "15 October 2026", "company": "Acme Robotics", "company_location": "Cambridge, UK", "job_title": "Graduate Software Engineer (2027 start)", - "paragraphs": _letter_paragraphs((60, 75, 120, 70, 55))} # 380 words, the prompt's top end + "paragraphs": _prose_paragraphs((60, 75, 120, 70, 55))} # 380 words, the prompt's top end pdf = _weasy("cover_letter", letter) assert pdf.page_count == 1 - assert _sentence(55, 4) in " ".join(pdf[0].get_text().split()) # the last paragraph is on the page + assert letter["paragraphs"][-1] in " ".join(pdf[0].get_text().split()) # the last paragraph is on the page @pytest.mark.parametrize("doc_type, doc, expected", [ @@ -582,8 +643,8 @@ def test_a_five_paragraph_letter_at_full_length_fits_one_page(pdf_dir): ("cover_letter", {"candidate_name": "Sam Okafor", "job_title": "Graduate Software Engineer", "company": "Acme Robotics", "company_location": "Cambridge, UK", "date": "15 October 2026", "paragraphs": ["I would like to join Acme Robotics."]}, - ["Sam Okafor", "15 October 2026", "Acme Robotics Cambridge, UK", "Re: Graduate Software Engineer", - "Dear Hiring Manager", "I would like to join Acme Robotics."]), + ["Sam Okafor", "Recruitment Team, Acme Robotics, Cambridge.", "Dear Hiring Manager", + "I would like to join Acme Robotics.", "Kind Regards, Sam Okafor."]), ]) def test_a_failing_weasyprint_falls_back_to_a_plain_pdf(pdf_dir, monkeypatch, doc_type, doc, expected): def broken(html): diff --git a/frontend/src/components/DocEditor.tsx b/frontend/src/components/DocEditor.tsx index 3380e13..6bc298f 100644 --- a/frontend/src/components/DocEditor.tsx +++ b/frontend/src/components/DocEditor.tsx @@ -349,12 +349,10 @@ function LetterForm({ letter, onChange }: { letter: LetterDoc; onChange: (l: Let set({ paragraphs: [...letter.paragraphs, ""] })}>Add paragraph set({ signoff: v })} /> - {/* The dated recipient block above the greeting. */} - s.toggle("head")}>
- set({ date: v })} /> - set({ job_title: v })} /> set({ company: v })} /> set({ company_location: v })} />