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 0000000..33cdbed Binary files /dev/null and b/api/app/templates/fonts/Gelasio-Bold.woff differ diff --git a/api/app/templates/fonts/Gelasio-Regular.woff b/api/app/templates/fonts/Gelasio-Regular.woff new file mode 100644 index 0000000..7023476 Binary files /dev/null and b/api/app/templates/fonts/Gelasio-Regular.woff differ 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 })} />