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
2 changes: 1 addition & 1 deletion api/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""),
Expand Down
51 changes: 30 additions & 21 deletions api/app/doc_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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")


Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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:
Expand All @@ -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"]:
Expand Down
17 changes: 15 additions & 2 deletions api/app/templates/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
Expand All @@ -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; }
Expand Down
15 changes: 14 additions & 1 deletion api/app/templates/classic_letter.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<title>{{ letter.candidate_name }} — Cover Letter</title>
<style>{% include "base.css" %}</style>
</head>
<body>
<body{% if letter.body_class %} class="{{ letter.body_class }}"{% endif %}>
<header class="letter-head">
<h1 class="letter-name">{{ letter.candidate_name }}</h1>
{% if letter.headline %}<p class="letter-headline">{{ letter.headline }}</p>{% endif %}
Expand All @@ -17,6 +17,19 @@ <h1 class="letter-name">{{ letter.candidate_name }}</h1>
{% endif %}
</header>

{% if letter.date or letter.company or letter.job_title %}
<div class="letter-meta">
{% if letter.date %}<p class="letter-date">{{ letter.date }}</p>{% endif %}
{% if letter.company %}
<div class="letter-recipient">
<p>{{ letter.company }}</p>
{% if letter.company_location %}<p>{{ letter.company_location }}</p>{% endif %}
</div>
{% endif %}
{% if letter.job_title %}<p class="letter-subject">Re: {{ letter.job_title }}</p>{% endif %}
</div>
{% endif %}

<div class="letter-body">
<p>{{ letter.salutation }}</p>
{% for para in letter.paragraphs %}
Expand Down
35 changes: 33 additions & 2 deletions api/tests/test_documents.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -352,6 +353,13 @@ def test_an_experienced_cv_may_run_to_two_pages(pages):
assert "<body>" 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"]
Expand Down Expand Up @@ -404,6 +412,25 @@ def test_cover_letter_renders_four_paragraphs_and_the_contact_block(llm):
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)

# 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.</p>", f'<p class="letter-date">{today}</p>', "<p>Acme Robotics</p>", "<p>Cambridge, UK</p>",
'<p class="letter-subject">Re: Graduate Software Engineer</p>', "<p>Dear Hiring Manager,</p>"]
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("</style>")[1] and "Re:" not in html


def test_cover_letter_without_the_model_still_reads_as_a_letter(llm):
llm.reply = ""
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/DocEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -307,6 +308,14 @@ function LetterForm({ letter, onChange }: { letter: LetterDoc; onChange: (l: Let
const set = (patch: Partial<LetterDoc>) => onChange({ ...letter, ...patch });
return (
<div className="space-y-2">
{/* The dated recipient block above the salutation. */}
<div className="grid grid-cols-2 gap-2">
<Field label="Date" value={letter.date ?? ""} onChange={(v) => set({ date: v })} />
<Field label="Role (Re: line)" value={letter.job_title ?? ""} onChange={(v) => set({ job_title: v })} />
<Field label="Company" value={letter.company ?? ""} onChange={(v) => set({ company: v })} />
<Field label="Company location" value={letter.company_location ?? ""}
onChange={(v) => set({ company_location: v })} />
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Salutation" value={letter.salutation} onChange={(v) => set({ salutation: v })} />
<Field label="Sign-off" value={letter.signoff} onChange={(v) => set({ signoff: v })} />
Expand Down
Loading