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.headline }}
{% endif %} @@ -17,6 +17,19 @@{{ 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