From 0d2b0db23f0935d333c38710bbe73218f60673e7 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Thu, 15 Oct 2026 23:44:00 +0100 Subject: [PATCH] fix: cover letters keep every paragraph The renderer kept only the first four paragraphs, so when the model wrote five (it sometimes does, despite the prompt) the closing paragraph was silently dropped, as was any paragraph added in the letter editor. Every paragraph is now rendered; the letter's measured page fit (tighter spacing, then 10pt type) keeps it to one page, as before. --- api/app/doc_render.py | 5 +++-- api/tests/test_documents.py | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/api/app/doc_render.py b/api/app/doc_render.py index 98544fe..156e934 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -49,7 +49,6 @@ _MAX_PROJECT_DESC_CHARS = 520 # room for 2-3 sentences (what it is, how it works, a result) _MAX_EDUCATION = 4 _MAX_CERTS = 6 -_MAX_PARAGRAPHS = 4 # Pages a CV must fit: one for a graduate / early-career candidate, two otherwise. _PAGE_TARGET = {"junior": 1, "experienced": 2} @@ -310,7 +309,9 @@ def _letter_contact_lines(contact: dict) -> list[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] + # 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()] return { "page_target": 1, "body_class": " ".join(fit), diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index d2e1aef..e4e4560 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -446,13 +446,13 @@ def test_cover_letter_sign_off_follows_the_salutation(llm, salutation, signoff): assert (letter["salutation"], letter["signoff"]) == (salutation, signoff) -def test_cover_letter_renders_four_paragraphs_and_the_contact_block(llm): - paragraphs = _letter_paragraphs() + ["A fifth paragraph the model added."] +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,"}) html = doc_render.render_letter_html(_cover_letter()) - assert _letter_body(html) == ["Dear Hiring Manager,", *paragraphs[:4]] + assert _letter_body(html) == ["Dear Hiring Manager,", *paragraphs] # the closing paragraph isn't dropped 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) @@ -563,6 +563,19 @@ def test_a_full_length_cover_letter_fits_one_page(pdf_dir): assert _weasy("cover_letter", letter).page_count == 1 +@needs_weasyprint +def test_a_five_paragraph_letter_at_full_length_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((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 + + @pytest.mark.parametrize("doc_type, doc, expected", [ ("cv", agent._persona_to_cvdoc(GRADUATE), ["Sam Okafor", "Software Engineering Intern", "Pantry Planner", "BSc (Hons) Computer Science"]),