From 24153b200195065667c8322a84902e687d91acf4 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Thu, 15 Oct 2026 21:44:28 +0100 Subject: [PATCH] fix: cover letters show the date, the employer and the role The letter builder passed the date, company, location and job title, but the template never showed them. Under the candidate's contact block the letter now has the date, the employer and its location, and a bold "Re: " line before the salutation; the fpdf2 fallback carries the same lines (and no longer prints the name twice). The letter editor gains Date, Role, Company and Company location fields. The extra lines pushed a 380-word letter (the top of the prompt's range) onto a second page, so letters get the CV's measured fit, layout only: tighter spacing, then 10pt type; paragraphs are never trimmed. The employer's location no longer falls back to the candidate's country when the job has none. --- api/app/agent.py | 2 +- api/app/doc_render.py | 51 ++++++++++++++++----------- api/app/templates/base.css | 17 +++++++-- api/app/templates/classic_letter.html | 15 +++++++- api/tests/test_documents.py | 35 ++++++++++++++++-- frontend/src/components/DocEditor.tsx | 9 +++++ 6 files changed, 102 insertions(+), 27 deletions(-) diff --git a/api/app/agent.py b/api/app/agent.py index 9313d75..e705628 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -888,7 +888,7 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "headline": "", "company": job.get("company", ""), "job_title": job.get("title", ""), - "company_location": job.get("location", "") or core.get("country", ""), + "company_location": job.get("location", ""), # the employer's, never the candidate's "date": datetime.date.today().strftime("%d %B %Y"), "contact": { "address_line_1": core.get("address_line_1", ""), diff --git a/api/app/doc_render.py b/api/app/doc_render.py index 97dc211..989af7d 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -4,9 +4,9 @@ The LLM emits a typed CVDoc / LetterDoc (see agent.py). This module: 1. turns that doc into trimmed, render-ready template data (the length limits hold even if the model ignores the prompt), - 2. renders it to HTML via Jinja (reused by both the live preview and the PDF), fitting a - CV to its page target — one page for a graduate, two otherwise — by measuring it in - WeasyPrint and tightening, then trimming, until it fits (_FIT_STEPS), + 2. renders it to HTML via Jinja (reused by both the live preview and the PDF), fitting it + to its page target — a cover letter or a graduate's CV to one page, other CVs to two — by + measuring it in WeasyPrint and tightening (then, for a CV, trimming) until it fits, 3. renders that HTML to a PDF via WeasyPrint, falling back to the legacy fpdf2 renderer if WeasyPrint is unavailable or errors. @@ -67,6 +67,8 @@ ("tight", "small", "no_tech", "three_bullets", "one_sentence"), ) _LAYOUT_STEPS = ("tight", "small") +# A cover letter is one page and is never trimmed: only its layout tightens. +_LETTER_FIT_STEPS: tuple[tuple[str, ...], ...] = ((), ("tight",), ("tight", "small")) _OTHER_CATEGORIES = ("other", "others", "additional", "miscellaneous", "misc") @@ -332,10 +334,13 @@ def _letter_contact_lines(contact: dict) -> list[dict]: return lines -def build_letter_template_data(letter: dict) -> dict: +def build_letter_template_data(letter: dict, fit: tuple[str, ...] = ()) -> dict: + """Render-ready letter data. `fit` is a step from _LETTER_FIT_STEPS: layout classes only.""" letter = letter or {} paragraphs = [p.strip() for p in (letter.get("paragraphs") or []) if p and p.strip()][:_MAX_PARAGRAPHS] return { + "page_target": 1, + "body_class": " ".join(fit), "candidate_name": (letter.get("candidate_name") or "").strip(), "headline": (letter.get("headline") or "").strip(), "company": (letter.get("company") or "").strip(), @@ -367,29 +372,33 @@ def _page_count(html: str) -> int | None: return None if weasy is None else len(weasy(string=html, base_url=_TEMPLATES_DIR).render().pages) -def _fitted_cv_html(cv: dict, template: str) -> str: +def _fitted_html(doc_type: str, doc: dict, template: str) -> str: """ - The CV's HTML at the first step of _FIT_STEPS that keeps it within its page target, so the - preview and the PDF agree. Without WeasyPrint to measure with, it's rendered as written. + The document's HTML at the first fit step (_FIT_STEPS for a CV, _LETTER_FIT_STEPS for a + letter) that keeps it within its page target, so the preview and the PDF agree. Without + WeasyPrint to measure with, it's rendered as written. """ tpl = _env.get_template(template) - for fit in _FIT_STEPS: - data = build_cv_template_data(cv, fit) - html = tpl.render(cv=data) + if doc_type == "cover_letter": + steps, build, name = _LETTER_FIT_STEPS, build_letter_template_data, "letter" + else: + steps, build, name = _FIT_STEPS, build_cv_template_data, "cv" + for fit in steps: + data = build(doc, fit) + html = tpl.render(**{name: data}) pages = _page_count(html) if pages is None or pages <= data["page_target"]: return html - logger.warning("CV still runs to %d pages (target %d) after every fit step", pages, data["page_target"]) + logger.warning("%s still runs to %d pages (target %d) after every fit step", name, pages, data["page_target"]) return html def render_cv_html(cv: dict, template_id: str | None = None) -> str: - return _fitted_cv_html(cv, registry.resolve(template_id)["cv"]) + return _fitted_html("cv", cv, registry.resolve(template_id)["cv"]) def render_letter_html(letter: dict, template_id: str | None = None) -> str: - tpl = registry.resolve(template_id) - return _env.get_template(tpl["letter"]).render(letter=build_letter_template_data(letter)) + return _fitted_html("cover_letter", letter, registry.resolve(template_id)["letter"]) def render_html(doc_type: str, doc: dict, template_id: str | None = None) -> str: @@ -413,14 +422,14 @@ 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) - head = [data["candidate_name"]] - if data["headline"]: - head.append(data["headline"]) - head += [c["text"] for c in data["contact_lines"]] - body = [data["salutation"], *data["paragraphs"], data["signoff"], data["candidate_name"]] + # fpdf2 prints the name as the heading; the date, employer and role open the text. + 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"]] text = "\n".join(head) + "\n\n" + "\n\n".join(body) - meta = {"name": data["candidate_name"], "job_title": data["job_title"], "company": data["company"]} - return pdf_generator.generate_pdf("cover_letter", text, meta) + return pdf_generator.generate_pdf("cover_letter", text, {"name": data["candidate_name"]}) data = build_cv_template_data(doc) lines: list[str] = [] if data["name"]: diff --git a/api/app/templates/base.css b/api/app/templates/base.css index c0ae12e..4b47def 100644 --- a/api/app/templates/base.css +++ b/api/app/templates/base.css @@ -101,8 +101,8 @@ a { color: inherit; text-decoration: none; } .skill-cat { font-weight: 700; } /* ── Fit to page ────────────────────────────────────────────────────── */ -/* Classes doc_render puts on the body element when a CV runs past its page target: "tight" - closes up the spacing, "small" then brings the body type down to 10pt, the floor. */ +/* Classes doc_render puts on the body element when a CV or letter runs past its page target: + "tight" closes up the spacing, "small" then brings the body type down to 10pt, the floor. */ body.tight { line-height: 1.24; } body.tight .head { margin-bottom: 5pt; } body.tight .section { margin-top: 7pt; } @@ -114,6 +114,16 @@ body.tight .skill-row { margin-bottom: 1.5pt; } body.small, body.small .summary { font-size: 10pt; } body.small .name { font-size: 20pt; } body.small .section h2 { font-size: 10pt; } +/* 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; } @@ -126,6 +136,9 @@ body.small .section h2 { font-size: 10pt; } } .letter-contact-line { font-size: 10pt; margin: 0; line-height: 1.35; } .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; } diff --git a/api/app/templates/classic_letter.html b/api/app/templates/classic_letter.html index f0fb803..61a32aa 100644 --- a/api/app/templates/classic_letter.html +++ b/api/app/templates/classic_letter.html @@ -5,7 +5,7 @@ {{ letter.candidate_name }} — Cover Letter - +

{{ letter.candidate_name }}

{% if letter.headline %}

{{ letter.headline }}

{% endif %} @@ -17,6 +17,19 @@

{{ letter.candidate_name }}

{% 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 %} +

{{ letter.salutation }}

{% for para in letter.paragraphs %} diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index 2fdf0c0..ef2d3cd 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -1,6 +1,7 @@ """CV and cover-letter documents: profile → CVDoc / LetterDoc → HTML → PDF.""" import asyncio import copy +import datetime import json import os import re @@ -352,6 +353,13 @@ def test_an_experienced_cv_may_run_to_two_pages(pages): assert "" in doc_render.render_cv_html(_full_cvdoc("experienced")) and pages.steps == [""] +def test_a_long_letter_tightens_its_layout_but_keeps_every_paragraph(pages): + pages.rule = staticmethod(lambda html: 1 if 'class="tight small"' in html else 2) + paragraphs = _letter_paragraphs((70, 95, 160, 55)) + html = doc_render.render_letter_html({"candidate_name": "Sam Okafor", "paragraphs": paragraphs}) + assert pages.steps == ["", "tight", "tight small"] and _letter_body(html)[1:] == paragraphs + + def test_the_preview_is_fitted_like_the_pdf(client, pages): pages.rule = staticmethod(lambda html: 1 if 'class="tight"' in html else 2) html = client.post("/documents/preview", json={"type": "cv", "doc": _one_role_junior()}).json()["html"] @@ -404,6 +412,25 @@ def test_cover_letter_renders_four_paragraphs_and_the_contact_block(llm): 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) + # 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,

"] + assert [html.index(s) for s in order] == sorted(html.index(s) for s in order) + + +def test_a_job_without_a_location_leaves_the_employers_location_blank(llm): + persona = copy.deepcopy(GRADUATE) + persona["core_info"]["country"] = "Nigeria" + letter = asyncio.run(agent.execute_tool("generate_cover_letter", + {"job": {**JOB, "location": ""}, "persona": persona}))["letter"] + assert letter["company"] == "Acme Robotics" and letter["company_location"] == "" + + +def test_a_letter_without_date_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 + def test_cover_letter_without_the_model_still_reads_as_a_letter(llm): llm.reply = "" @@ -477,6 +504,8 @@ def test_a_full_length_cover_letter_fits_one_page(pdf_dir): letter = {"candidate_name": "Sam Okafor", "headline": "Computer Science Graduate", "contact": {"address_line_1": "1 High Street", "city": "Bristol", "postcode": "BS1 1AA", "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 @@ -485,8 +514,10 @@ def test_a_full_length_cover_letter_fits_one_page(pdf_dir): ("cv", agent._persona_to_cvdoc(GRADUATE), ["Sam Okafor", "Software Engineering Intern", "Pantry Planner", "BSc (Hons) Computer Science"]), ("cover_letter", {"candidate_name": "Sam Okafor", "job_title": "Graduate Software Engineer", - "company": "Acme Robotics", "paragraphs": ["I would like to join Acme Robotics."]}, - ["Sam Okafor", "Dear Hiring Manager", "I would like to join Acme Robotics."]), + "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."]), ]) 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 626d013..b11701e 100644 --- a/frontend/src/components/DocEditor.tsx +++ b/frontend/src/components/DocEditor.tsx @@ -32,6 +32,7 @@ export interface CVDoc { export interface LetterDoc { candidate_name: string; company?: string; + company_location?: string; job_title?: string; date?: string; contact?: { email?: string; phone?: string; location?: string }; @@ -307,6 +308,14 @@ function LetterForm({ letter, onChange }: { letter: LetterDoc; onChange: (l: Let const set = (patch: Partial) => onChange({ ...letter, ...patch }); return (
+ {/* The dated recipient block above the salutation. */} +
+ set({ date: v })} /> + set({ job_title: v })} /> + set({ company: v })} /> + set({ company_location: v })} /> +
set({ salutation: v })} /> set({ signoff: v })} />