Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/app/doc_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 16 additions & 3 deletions api/tests/test_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<p class="letter-headline">Computer Science Graduate</p>' in html
assert ">Bristol.</p>" in html and 'href="mailto:sam@example.com"' in html and 'href="tel:+447700900456"' in html
assert re.search(r'<p>Yours faithfully,</p>\s*<p class="sig-name">Sam Okafor</p>', html)
Expand Down Expand Up @@ -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"]),
Expand Down
Loading