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 @@