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
45 changes: 39 additions & 6 deletions api/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, "
Expand All @@ -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"):
Expand All @@ -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}
Expand Down
114 changes: 97 additions & 17 deletions api/app/doc_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -14,6 +16,7 @@
from __future__ import annotations

import asyncio
import functools
import logging
import re
import uuid
Expand All @@ -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 <body> (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 ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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(),
Expand All @@ -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", "")),
Expand Down Expand Up @@ -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),
}


Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
15 changes: 15 additions & 0 deletions api/app/templates/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 1 addition & 1 deletion api/app/templates/classic_cv.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<title>{{ cv.name }} — CV</title>
<style>{% include "base.css" %}</style>
</head>
<body>
<body{% if cv.body_class %} class="{{ cv.body_class }}"{% endif %}>
<header class="head">
<h1 class="name">{{ cv.name }}</h1>
{% if cv.contact_items %}
Expand Down
Loading
Loading