diff --git a/api/app/agent.py b/api/app/agent.py index bd8b4d9..9313d75 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -10,7 +10,7 @@ from openai import AsyncOpenAI, BadRequestError -from . import azure_config +from . import azure_config, doc_render from .company_names import normalise_company_name as _normalise_company_name from . import http_client from . import tracker @@ -462,6 +462,33 @@ def _rank_projects_by_relevance(job: dict, projects: list[dict]) -> list[dict]: return [p for _, _, p in scored] +def _regrouped_skills(groups, base: list[dict]) -> list[dict] | None: + """ + The model's regrouping of the profile's skills, if it holds exactly those skills (none dropped, + added or repeated) in at most doc_render.MAX_SKILL_GROUPS named groups; else None. Each skill + keeps the profile's spelling. + """ + spelling = {str(s).strip().lower(): str(s).strip() + for g in base for s in (g.get("items") or []) if s and str(s).strip()} + if not isinstance(groups, list) or not 0 < len(groups) <= doc_render.MAX_SKILL_GROUPS: + return None + out, seen = [], set() + for g in groups: + items = g.get("items") if isinstance(g, dict) else None + category = str(g.get("category") or "").strip() if isinstance(g, dict) else "" + if not category or not isinstance(items, list) or not items: + return None + row = [] + for s in items: + key = str(s).strip().lower() + if key not in spelling or key in seen: + return None + seen.add(key) + row.append(spelling[key]) + out.append({"category": category, "items": row}) + return out if seen == set(spelling) else None + + def _restore_project_depth(cv_projects: list, base_projects: list) -> list: """ Keep the model's tailored project selection AND its now role-specific description @@ -1109,6 +1136,10 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "'Results-driven', 'Detail-oriented', 'Passionate') and do NOT close with vague filler ('strong " "problem-solving skills and attention to detail', 'hardworking team player'). Do NOT spend a " "sentence naming assumed or basic tooling (e.g. Git / version control). " + f"SKILLS: sort EVERY skill in the candidate data into at most {doc_render.MAX_SKILL_GROUPS} " + "categories with short, conventional names (e.g. 'Languages', 'Machine Learning', 'Web', " + "'Data & Cloud', 'Tools'), the categories most relevant to this job first. Copy each skill " + "exactly as written; do not add, drop, rename or combine any. " "LENGTH: at most 4 most-recent roles; at most 3 projects. " "BANNED words (recruiters flag these as AI-written — never use them in bullets, summary or " "project text): delve, testament, robust, seamless, synergy, leverage, pivotal, intricate, " @@ -1133,11 +1164,13 @@ async def execute_tool(name: str, tool_input: dict) -> dict: if used_fallback: cv = base_cv # Trust our own structured data — never let the model alter identity/contact, and keep - # the candidate's full skills list (the model tends to silently drop groups/items when - # tailoring; completeness matters more to the user than reordering). + # the candidate's full skills list: the model's regrouping is used only when it holds + # exactly the profile's skills (it tends to silently drop some when tailoring). cv["contact"] = base_cv["contact"] + regrouped = None if base_cv.get("skills"): - cv["skills"] = base_cv["skills"] + regrouped = None if used_fallback else _regrouped_skills(cv.get("skills"), base_cv["skills"]) + cv["skills"] = regrouped or base_cv["skills"] if cv.get("projects") and base_cv.get("projects"): cv["projects"] = _restore_project_depth(cv["projects"], base_cv["projects"]) if not cv.get("name"): @@ -1148,8 +1181,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict: # same projects recur across roles (the model is meant to tailor the selection). final_proj_names = [(p.get("name") or "")[:40] for p in (cv.get("projects") or [])][:5] (logger.warning if used_fallback else logger.info)( - "tailored_cv: fallback=%s, level=%s, projects base=%d model=%d final=%d %s | job=%r", - used_fallback, level, base_proj_n, model_proj_n, len(cv.get("projects") or []), + "tailored_cv: fallback=%s, level=%s, skills regrouped=%s, projects base=%d model=%d final=%d %s | job=%r", + used_fallback, level, regrouped is not None, base_proj_n, model_proj_n, len(cv.get("projects") or []), final_proj_names, (job.get("title") or "")[:60], ) return {"cv": cv, "template_id": template_id} diff --git a/api/app/doc_render.py b/api/app/doc_render.py index 45f0838..97dc211 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -2,9 +2,11 @@ Structured document rendering for Windrush CVs and cover letters. The LLM emits a typed CVDoc / LetterDoc (see agent.py). This module: - 1. turns that doc into trimmed, render-ready template data (the *authoritative* - 1–2 page guarantee — slicing here holds even if the model ignores the prompt), - 2. renders it to HTML via Jinja (reused by both the live preview and the PDF), + 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), 3. renders that HTML to a PDF via WeasyPrint, falling back to the legacy fpdf2 renderer if WeasyPrint is unavailable or errors. @@ -14,6 +16,7 @@ from __future__ import annotations import asyncio +import functools import logging import re import uuid @@ -36,18 +39,36 @@ lstrip_blocks=True, ) -# ── Length limits (the real 1–2 page guarantee) ───────────────────────────────── +# ── Length limits ──────────────────────────────────────────────────────────────── +# These bound what any doc can put on the page; fitting a CV to its page target is _fitted_cv_html's job. _MAX_SUMMARY_CHARS = 420 _MAX_ROLES = 4 _MAX_BULLETS = 4 -_MAX_SKILL_GROUPS = 6 -_MAX_SKILL_ITEMS = 14 +MAX_SKILL_GROUPS = 6 # rows; skills are never dropped, extra groups fold into "Other" _MAX_PROJECTS = 3 _MAX_PROJECT_DESC_CHARS = 520 # room for 2 full sentences (what it is/does + how it works) _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} + +# When a CV runs over its page target, these steps are tried in order until it fits. Each adds to +# the one before: tighter spacing, then 10pt type (the floor), then trimming the least important +# content — project technology lists, each role's 4th bullet, project descriptions after their +# first sentence. "tight" and "small" are CSS classes on (base.css); the rest trim the data. +_FIT_STEPS: tuple[tuple[str, ...], ...] = ( + (), + ("tight",), + ("tight", "small"), + ("tight", "small", "no_tech"), + ("tight", "small", "no_tech", "three_bullets"), + ("tight", "small", "no_tech", "three_bullets", "one_sentence"), +) +_LAYOUT_STEPS = ("tight", "small") +_OTHER_CATEGORIES = ("other", "others", "additional", "miscellaneous", "misc") + # ── helpers ───────────────────────────────────────────────────────────────────── @@ -186,19 +207,44 @@ def _role_dates(role: dict) -> str: return start or end +def _skill_rows(groups: list) -> list[dict]: + """ + The CV's skill rows, at most MAX_SKILL_GROUPS of them, without losing a skill: past the limit, + the extra groups fold into one "Other" row (an existing "Other"-like group absorbs them, last). + """ + rows = [] + for grp in groups or []: + items = [str(s).strip() for s in (grp.get("items") or []) if s and str(s).strip()] + if items: + rows.append({"category": (grp.get("category") or "Skills").strip() or "Skills", "items": items}) + if len(rows) <= MAX_SKILL_GROUPS: + return rows + other = next((r for r in rows if r["category"].lower() in _OTHER_CATEGORIES), None) + rest = [r for r in rows if r is not other] + kept, folded = rest[:MAX_SKILL_GROUPS - 1], rest[MAX_SKILL_GROUPS - 1:] + items, seen = [], set() + for s in (other["items"] if other else []) + [s for r in folded for s in r["items"]]: + if s.lower() not in seen: + seen.add(s.lower()) + items.append(s) + return kept + [{"category": "Other", "items": items}] + + +def _first_sentence(text: str) -> str: + return re.split(r"(?<=[.!?])\s+", text.strip(), maxsplit=1)[0] + + # ── template-data builders (apply trimming/defaults) ──────────────────────────── -def build_cv_template_data(cv: dict) -> dict: +def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict: + """Render-ready CV data. `fit` is a step from _FIT_STEPS: the layout classes and trims to apply.""" cv = cv or {} - skills = [] - for grp in (cv.get("skills") or [])[:_MAX_SKILL_GROUPS]: - items = [s for s in (grp.get("items") or []) if s][:_MAX_SKILL_ITEMS] - if items: - skills.append({"category": (grp.get("category") or "Skills").strip(), "items": items}) + skills = _skill_rows(cv.get("skills") or []) + max_bullets = 3 if "three_bullets" in fit else _MAX_BULLETS experience = [] for role in sorted(cv.get("experience") or [], key=_render_recency, reverse=True)[:_MAX_ROLES]: - bullets = [b.strip() for b in (role.get("bullets") or []) if b and b.strip()][:_MAX_BULLETS] + bullets = [b.strip() for b in (role.get("bullets") or []) if b and b.strip()][:max_bullets] experience.append({ "title": (role.get("title") or "").strip(), "employer": (role.get("employer") or "").strip(), @@ -209,10 +255,11 @@ def build_cv_template_data(cv: dict) -> dict: projects = [] for p in (cv.get("projects") or [])[:_MAX_PROJECTS]: + description = _truncate(_clean_text(p.get("description", "")), _MAX_PROJECT_DESC_CHARS) projects.append({ "name": (p.get("name") or "").strip(), - "description": _truncate(_clean_text(p.get("description", "")), _MAX_PROJECT_DESC_CHARS), - "tech": [t for t in (p.get("tech") or []) if t], + "description": _first_sentence(description) if "one_sentence" in fit else description, + "tech": [] if "no_tech" in fit else [t for t in (p.get("tech") or []) if t], "link": _short_url(p.get("link", "")), "link_href": _abs_url(p.get("link", "")), "link_label": _project_link_label(p.get("link", "")), @@ -258,6 +305,8 @@ def build_cv_template_data(cv: dict) -> dict: "certifications": certifications, "seniority": seniority, "sections": sections, + "page_target": _PAGE_TARGET[seniority], + "body_class": " ".join(s for s in fit if s in _LAYOUT_STEPS), } @@ -302,9 +351,40 @@ def build_letter_template_data(letter: dict) -> dict: # ── HTML rendering ─────────────────────────────────────────────────────────────── +@functools.lru_cache(maxsize=1) +def _weasyprint_html(): + """WeasyPrint's HTML class, or None where it can't load (ImportError, or OSError without Pango).""" + try: + from weasyprint import HTML + return HTML + except Exception: + return None + + +def _page_count(html: str) -> int | None: + """Pages the HTML lays out to in WeasyPrint, or None where WeasyPrint can't load.""" + weasy = _weasyprint_html() + 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: + """ + 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. + """ + tpl = _env.get_template(template) + for fit in _FIT_STEPS: + data = build_cv_template_data(cv, fit) + html = tpl.render(cv=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"]) + return html + + def render_cv_html(cv: dict, template_id: str | None = None) -> str: - tpl = registry.resolve(template_id) - return _env.get_template(tpl["cv"]).render(cv=build_cv_template_data(cv)) + return _fitted_cv_html(cv, registry.resolve(template_id)["cv"]) def render_letter_html(letter: dict, template_id: str | None = None) -> str: diff --git a/api/app/main.py b/api/app/main.py index d3e679d..3ab91de 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1024,7 +1024,8 @@ async def preview_document( doc = body.get("doc", {}) template_id = body.get("template_id", "classic") try: - html = doc_render.render_html(doc_type, doc, template_id) + # A worker thread: fitting a CV to its page count lays it out in WeasyPrint. + html = await asyncio.to_thread(doc_render.render_html, doc_type, doc, template_id) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) return {"html": html} diff --git a/api/app/templates/base.css b/api/app/templates/base.css index 0348140..c0ae12e 100644 --- a/api/app/templates/base.css +++ b/api/app/templates/base.css @@ -100,6 +100,21 @@ a { color: inherit; text-decoration: none; } .skill-row { margin-bottom: 3pt; font-size: 10pt; } .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. */ +body.tight { line-height: 1.24; } +body.tight .head { margin-bottom: 5pt; } +body.tight .section { margin-top: 7pt; } +body.tight .section h2 { margin-bottom: 3pt; padding-bottom: 1pt; } +body.tight .role { margin-bottom: 5pt; } +body.tight .entry { margin-bottom: 4pt; } +body.tight li { margin-bottom: 1pt; } +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; } + /* ── Cover letter ───────────────────────────────────────────────────── */ .letter-head { margin-bottom: 14pt; } .letter-name { font-size: 18pt; font-weight: 700; margin: 0; } diff --git a/api/app/templates/classic_cv.html b/api/app/templates/classic_cv.html index 5070fe7..0c2e8b7 100644 --- a/api/app/templates/classic_cv.html +++ b/api/app/templates/classic_cv.html @@ -5,7 +5,7 @@ {{ cv.name }} — CV - +

{{ cv.name }}

{% if cv.contact_items %} diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index 1cdf257..2fdf0c0 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -210,12 +210,62 @@ def test_the_model_tailors_the_cv_but_cannot_touch_contact_details_skills_or_lin assert "sam@example.com" not in llm.prompts[-1] and "7700 900456" not in llm.prompts[-1] -@pytest.mark.xfail(strict=True, reason="skill groups after the sixth are dropped (doc_render._MAX_SKILL_GROUPS)") def test_every_skill_in_the_profile_reaches_the_cv(llm): - html = doc_render.render_cv_html(_tailored_cv()) + html = doc_render.render_cv_html(_tailored_cv()) # the model failed: the profile's 8 groups as they are skills = html.split("

Skills

")[1].split("")[0] missing = [s for grp in GRADUATE["skills"] for s in grp["skills"] if s not in skills] - assert missing == [] + assert missing == [] and skills.count('class="skill-row"') == doc_render.MAX_SKILL_GROUPS + + +def _regrouping(groups: dict[str, list[str]]) -> str: + """The model's reply: the base CV with its skills regrouped as given.""" + cv = agent._persona_to_cvdoc(GRADUATE) + return json.dumps(cv | {"skills": [{"category": c, "items": items} for c, items in groups.items()]}) + + +ALL_SKILLS = { + "Languages": ["Python", "TypeScript", "Java", "SQL"], + "Web": ["React", "Next.js", "Tailwind CSS", "FastAPI", "Node.js", "Spring Boot"], + "Data & Machine Learning": ["PostgreSQL", "Redis", "pandas", "scikit-learn", "PyTorch"], + "Cloud & Tools": ["AWS", "Docker", "Git", "GitHub Actions", "CI/CD"], + "Testing": ["pytest", "Jest", "Playwright"], +} + + +def test_the_models_regrouping_is_used_when_it_keeps_every_skill(llm): + llm.reply = _regrouping({**ALL_SKILLS, "Languages": ["python", "TypeScript", "Java", "SQL"]}) + cv = _tailored_cv() + assert cv["skills"] == [{"category": c, "items": items} for c, items in ALL_SKILLS.items()] # profile spelling + assert "at most 6 categories" in llm.prompts[-1] + + +@pytest.mark.parametrize("change", [ + pytest.param(lambda g: g["Testing"].remove("Jest"), id="drops a skill"), + pytest.param(lambda g: g["Testing"].append("Selenium"), id="adds one"), + pytest.param(lambda g: g["Testing"].append("Python"), id="repeats one"), + pytest.param(lambda g: g.update({"Misc": [g["Web"].pop()], "More": [g["Web"].pop()]}), id="7 groups"), + pytest.param(lambda g: g.update({"": g.pop("Testing")}), id="unnamed group"), +]) +def test_a_regrouping_that_changes_the_skills_is_ignored(llm, change): + groups = copy.deepcopy(ALL_SKILLS) + change(groups) + llm.reply = _regrouping(groups) + assert _tailored_cv()["skills"] == agent._persona_to_cvdoc(GRADUATE)["skills"] + + +@pytest.mark.parametrize("categories, rows", [ + (["A", "B", "C", "D", "E", "F", "G", "H"], ["A", "B", "C", "D", "E", "Other"]), + (["A", "Other", "B", "C", "D", "E", "F"], ["A", "B", "C", "D", "E", "Other"]), # absorbs, moves last + (["A", "B", "C"], ["A", "B", "C"]), +]) +def test_extra_skill_groups_fold_into_other(categories, rows): + groups = [{"category": c, "items": [f"{c}1", f"{c}2", "Shared"]} for c in categories] + out = doc_render._skill_rows(groups) + assert [r["category"] for r in out] == rows + every = {s for g in groups for s in g["items"]} + assert {s for r in out for s in r["items"]} == every + if len(categories) > doc_render.MAX_SKILL_GROUPS: + assert out[-1]["items"].count("Shared") == 1 # folded once, not once per group def test_length_limits_hold_whatever_the_model_returns(): @@ -234,11 +284,80 @@ def test_length_limits_hold_whatever_the_model_returns(): assert [r["title"] for r in data["experience"]] == ["Engineer 3", "Engineer 2", "Engineer 1", "Engineer 0"] assert max(len(r["bullets"]) for r in data["experience"]) == 4 assert len(data["projects"]) == 3 and len(data["education"]) == 4 and len(data["certifications"]) == 6 - assert len(data["skills"][0]["items"]) == 14 + assert len(data["skills"][0]["items"]) == 15 # skills are never cut assert len(data["summary"]) <= 420 and data["summary"].endswith(".") # ends on a whole sentence assert len(data["projects"][0]["description"]) <= 520 and data["projects"][0]["description"].endswith(".") +# ── Fitting a CV to its page target ───────────────────────────────────────────── + +def _one_role_junior() -> dict: + cv = _full_cvdoc("junior") + cv["experience"] = cv["experience"][:1] + return cv + + +@pytest.fixture +def pages(monkeypatch): + """Stand-in for WeasyPrint's page count: `pages.rule(html) -> int`; every measured step is recorded.""" + class Pages: + rule = staticmethod(lambda html: 1) + steps: list[str] = [] + + def count(html): + Pages.steps.append(re.search(r"", html).group(1) or "") + return Pages.rule(html) + monkeypatch.setattr(doc_render, "_page_count", count) + return Pages + + +def test_a_cv_that_fits_is_left_as_written(pages): + html = doc_render.render_cv_html(_one_role_junior()) + assert pages.steps == [""] and "" in html and html.count("
  • ") == 4 and "Technologies:" in html + + +def test_spacing_then_type_tighten_before_anything_is_trimmed(pages): + pages.rule = staticmethod(lambda html: 1 if 'class="tight small"' in html else 2) + html = doc_render.render_cv_html(_one_role_junior()) + assert pages.steps == ["", "tight", "tight small"] + assert html.count("
  • ") == 4 and "Technologies:" in html + + +def test_trimming_goes_tech_lists_then_fourth_bullets_then_descriptions(pages): + def rule(html): # fits once each role has at most 3 bullets + return 1 if html.count("
  • ") <= 3 else 2 + pages.rule = staticmethod(rule) + cv = _one_role_junior() + for p in cv["projects"]: + p["description"] = "What it does. How it works." + html = doc_render.render_cv_html(cv) + assert pages.steps == ["", "tight", "tight small", "tight small", "tight small"] + assert "Technologies:" not in html and html.count("
  • ") == 3 + assert "What it does. How it works." in html # descriptions kept whole + + +def test_when_nothing_fits_every_trim_is_applied_and_logged(pages, caplog): + pages.rule = staticmethod(lambda html: 2) + cv = _one_role_junior() + for p in cv["projects"]: + p["description"] = "What it does. How it works." + html = doc_render.render_cv_html(cv) + assert len(pages.steps) == 6 and "Technologies:" not in html and html.count("
  • ") == 3 + assert "What it does." in html and "How it works." not in html + assert "still runs to 2 pages (target 1)" in caplog.text + + +def test_an_experienced_cv_may_run_to_two_pages(pages): + pages.rule = staticmethod(lambda html: 2) + assert "" in doc_render.render_cv_html(_full_cvdoc("experienced")) and pages.steps == [""] + + +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"] + assert '' in html + + def test_text_is_escaped_not_rendered_as_markup(): cv = {"name": "Sam ", "experience": [{"title": "Intern", "bullets": ["Cut p99 latency to <50ms & halved cost"]}]} @@ -325,7 +444,9 @@ def _weasy(doc_type: str, doc: dict) -> fitz.Document: @needs_weasyprint def test_the_graduate_cv_is_one_page_in_the_template_font_with_live_links(pdf_dir): - pdf = _weasy("cv", agent._persona_to_cvdoc(GRADUATE) | {"seniority": "junior"}) + cv = agent._persona_to_cvdoc(GRADUATE) | {"seniority": "junior"} + assert "" in doc_render.render_cv_html(cv) # fits without tightening + pdf = _weasy("cv", cv) assert pdf.page_count == 1 text = pdf[0].get_text().lower() # entry lines are set in capitals assert "sam okafor" in text and "pantry planner" in text and "northfield university" in text @@ -335,7 +456,6 @@ def test_the_graduate_cv_is_one_page_in_the_template_font_with_live_links(pdf_di @needs_weasyprint -@pytest.mark.xfail(strict=True, reason="a full graduate CV runs about a fifth of a page onto page 2") def test_a_full_graduate_cv_fits_one_page(pdf_dir): # Everything the tailoring prompt allows a junior: 3-sentence summary, one role with 4 bullets of ~24 # words, 3 projects of 2 sentences with 8 technologies, 6 skill groups, 3 certificates.