diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 722e470..646b4a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,10 +23,10 @@ jobs:
python-version: "3.13"
cache: pip
cache-dependency-path: api/requirements*.txt
- # WeasyPrint's libraries and the CV template's font, as in api/Dockerfile: the PDF tests
- # measure page counts, so they must lay out with the same font as production.
+ # WeasyPrint's libraries and the templates' fonts, as in api/Dockerfile: the PDF tests
+ # measure page counts, so they must lay out with the same fonts as production.
- run: sudo apt-get update && sudo apt-get install -y --no-install-recommends
- libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 fonts-liberation
+ libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 fonts-liberation fonts-crosextra-caladea
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest
diff --git a/api/Dockerfile b/api/Dockerfile
index b5247df..61a244f 100644
--- a/api/Dockerfile
+++ b/api/Dockerfile
@@ -8,6 +8,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# WeasyPrint runtime deps (HTML->PDF document rendering)
libpangoft2-1.0-0 libgdk-pixbuf-2.0-0 libffi8 libfontconfig1 fontconfig \
fonts-dejavu-core \
+ # Caladea: metric-compatible with Cambria, the CV template's font
+ fonts-crosextra-caladea \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
diff --git a/api/app/agent.py b/api/app/agent.py
index e705628..7a16cf3 100644
--- a/api/app/agent.py
+++ b/api/app/agent.py
@@ -325,7 +325,7 @@ def _persona_to_cvdoc(persona: dict) -> dict:
contact = {
"email": core.get("email", ""),
"phone": core.get("phone", ""),
- "location": core.get("city", "") or core.get("location", ""),
+ "location": _cv_location(core),
"linkedin": core.get("linkedin", ""),
"github": core.get("github", ""),
# core_info has both a 'website' and a 'portfolio' field; either may hold the
@@ -362,7 +362,8 @@ def _persona_to_cvdoc(persona: dict) -> dict:
education = [
{"degree": e.get("degree", ""), "institution": e.get("institution", ""),
- "year": e.get("end_date", "") or e.get("year", ""), "grade": e.get("grade", "")}
+ "start_date": e.get("start_date", ""), "year": e.get("end_date", "") or e.get("year", ""),
+ "grade": e.get("grade", "")}
for e in sorted(persona.get("education", []), key=_recency_key, reverse=True)
]
projects = []
@@ -462,31 +463,64 @@ 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:
+# How many skills a tailored CV lists: the candidate's own CV design shows about 30, in 5 rows.
+_MIN_CV_SKILLS, _MAX_CV_SKILLS = 20, 30
+_CV_SKILLS_CEILING = 40 # a choice a little over the asked-for range is still better than the full list
+_MIN_KEPT_SKILLS = 10 # fewer real skills than this left after dropping the rest: use the full list
+
+
+def _chosen_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.
+ The model's choice of job-relevant skills, keeping only the profile's own skills in the
+ profile's spelling: anything it invented, renamed or repeated is dropped, and an unnamed row
+ becomes "Other" (rows past doc_render.MAX_SKILL_GROUPS fold when rendered). None when fewer than
+ _MIN_KEPT_SKILLS remain, or more than _CV_SKILLS_CEILING: then the profile's full list is used.
"""
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:
+ if not isinstance(groups, list):
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
+ if not isinstance(g, dict) or not isinstance(g.get("items"), list):
+ continue
row = []
- for s in items:
+ for s in g["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
+ if key in spelling and key not in seen:
+ seen.add(key)
+ row.append(spelling[key])
+ if row:
+ out.append({"category": str(g.get("category") or "").strip() or "Other", "items": row})
+ return out if _MIN_KEPT_SKILLS <= len(seen) <= _CV_SKILLS_CEILING else None
+
+
+def _cv_location(core: dict) -> str:
+ """'City, Country' for the CV header, as in the candidate's CV ("Bristol, United Kingdom")."""
+ city = (core.get("city") or core.get("location") or "").strip()
+ country = (core.get("country") or "").strip()
+ if not country or country.lower() in city.lower():
+ return city
+ return f"{city}, {country}" if city else country
+
+
+def _restore_role_dates(roles: list, base_roles: list) -> list:
+ """
+ Each role keeps the profile's own dates (the model tends to reformat them): matched by employer
+ and title, else by employer alone when the profile has one role there.
+ """
+ def key(text) -> str:
+ return re.sub(r"[^a-z0-9]", "", (text or "").lower())
+
+ out = []
+ for role in roles or []:
+ same = [b for b in base_roles if key(b.get("employer")) == key(role.get("employer"))]
+ match = next((b for b in same if key(b.get("title")) == key(role.get("title"))),
+ same[0] if len(same) == 1 else None)
+ if match:
+ role = {**role, "start_date": match.get("start_date", ""), "end_date": match.get("end_date", "")}
+ out.append(role)
+ return out
def _restore_project_depth(cv_projects: list, base_projects: list) -> list:
@@ -1089,9 +1123,9 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
"This is an early-career / graduate candidate. Treat substantial university projects, "
"capstones, internships and part-time (incl. retail/hospitality) roles as first-class "
"experience, reframing duties as transferable competencies (stakeholder management, data "
- "analysis, operational delivery, working under pressure). The CV MUST fit a SINGLE page."
+ "analysis, operational delivery, working under pressure)."
)
- summary_rule = "summary = 2-3 sentences"
+ summary_rule = "summary = 3 sentences (~50-60 words)"
else:
level_guidance = (
"This is an experienced candidate. Lead with scope of ownership, leadership and measurable "
@@ -1105,11 +1139,12 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
"object (no prose, no code fences) using EXACTLY this shape: " + _CVDOC_SCHEMA + ". "
"Tailor to the target job: reorder and reword to mirror the job's exact, conventional keywords. "
"ATS RULES: use standard industry skill names and SPELL OUT acronyms next to the abbreviation, "
- "e.g. 'Search Engine Optimization (SEO)'. Format EVERY date as 'Mon YYYY' or MM/YYYY "
- "(never 'Summer 2025' or \"Jan '25\"). "
- "BULLETS: up to 4 per role, each a single line of ~16-26 words, beginning with a strong, varied "
- "action verb (never 'Responsible for' or 'Helped with'); follow Action + Task/Tool + quantified "
- "Result. Quantify ONLY with metrics actually present in the candidate data; if a bullet has no "
+ "e.g. 'Search Engine Optimization (SEO)'. Keep every date exactly as the candidate data gives it "
+ "(MM/YYYY); never reformat one. "
+ "BULLETS: 1-2 per role (one is right when the role is one story), each one or two lines "
+ "(~20-35 words) and no more than ~60 words per role in all, beginning with a strong, varied action verb (never 'Responsible for' or 'Helped "
+ "with'); follow Action + Task/Tool + quantified Result, and combine closely related achievements "
+ "into one bullet rather than listing them. Quantify ONLY with metrics actually present in the candidate data; if a bullet has no "
"source number, convey scope/scale with concrete specifics instead — NEVER invent, estimate or "
"round up a percentage, count or efficiency gain. NEVER pad to a count: write only bullets backed "
"by a real achievement in the "
@@ -1121,11 +1156,11 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
f"{level_guidance} "
"PROJECTS: the candidate's full project list is provided (ALREADY ORDERED by relevance to this "
"job), each with its problem, quantified 'outcomes' and 'technologies'/'tech_source'. SELECT the "
- "3 most relevant to THIS job's domain and responsibilities and keep each 'name' exactly as given. "
- "Write a tailored 'description' of AT LEAST TWO full sentences: sentence 1 states what the "
+ "4 most relevant to THIS job's domain and responsibilities and keep each 'name' exactly as given. "
+ "Write a tailored 'description' as ONE paragraph of 2-3 full sentences (~40-55 words): what the "
"project is and what it achieves (include ONE real quantified outcome from its 'outcomes' when "
- "available — never invent, estimate or round a number); sentence 2 explains HOW IT WORKS — its "
- "architecture, core approach or key technique. Set 'tech' to 6-10 clean, canonical technology "
+ "available — never invent, estimate or round a number), then HOW IT WORKS — its architecture, "
+ "core approach or key technique. Set 'tech' to 5-10 clean, canonical technology "
"names (languages, frameworks, datastores, key libraries) drawn from that project's "
"'technologies'/'tech_source', omitting evaluation metrics and verbose phrases and normalising to "
"the common name (e.g. 'PostgreSQL', 'FastAPI', 'Next.js'). "
@@ -1136,11 +1171,14 @@ 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. "
+ f"SKILLS: choose the {_MIN_CV_SKILLS}-{_MAX_CV_SKILLS} skills from the candidate data most "
+ f"relevant to THIS job and group them into at most {doc_render.MAX_SKILL_GROUPS} rows of no more "
+ "than 8 skills, with short, conventional names such as 'Languages', 'Frontend', 'Backend & Data', "
+ "'ML & AI', 'Tools'; where two fit equally, choose the one with the shorter name. Copy each chosen "
+ "skill EXACTLY as written, character for character (never shorten, expand or rename one), and "
+ "never add one the candidate data doesn't list. Leave "
+ "out spoken languages: they aren't technical skills. "
+ "LENGTH: at most 4 most-recent roles and 4 projects. A graduate's CV must fit ONE page. "
"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, "
"elevate, foster, underscore, tapestry, landscape. "
@@ -1163,16 +1201,20 @@ async def execute_tool(name: str, tool_input: dict) -> dict:
used_fallback = not cv or not (cv.get("experience") or cv.get("summary"))
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's regrouping is used only when it holds
- # exactly the profile's skills (it tends to silently drop some when tailoring).
+ # Trust our own structured data — never let the model alter identity, contact details,
+ # education or certifications; its choice of skills is used only when every one is the
+ # candidate's own (otherwise the profile's full list).
cv["contact"] = base_cv["contact"]
- regrouped = None
+ cv["education"] = base_cv["education"]
+ cv["certifications"] = base_cv["certifications"]
+ chosen = None
if base_cv.get("skills"):
- regrouped = None if used_fallback else _regrouped_skills(cv.get("skills"), base_cv["skills"])
- cv["skills"] = regrouped or base_cv["skills"]
+ chosen = None if used_fallback else _chosen_skills(cv.get("skills"), base_cv["skills"])
+ cv["skills"] = chosen or base_cv["skills"]
if cv.get("projects") and base_cv.get("projects"):
cv["projects"] = _restore_project_depth(cv["projects"], base_cv["projects"])
+ if cv.get("experience") and base_cv.get("experience"):
+ cv["experience"] = _restore_role_dates(cv["experience"], base_cv["experience"])
if not cv.get("name"):
cv["name"] = base_cv["name"]
cv["seniority"] = level # drives section order in the renderer
@@ -1181,8 +1223,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, 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 []),
+ "tailored_cv: fallback=%s, level=%s, skills chosen=%s, projects base=%d model=%d final=%d %s | job=%r",
+ used_fallback, level, chosen 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 989af7d..98544fe 100644
--- a/api/app/doc_render.py
+++ b/api/app/doc_render.py
@@ -44,9 +44,9 @@
_MAX_SUMMARY_CHARS = 420
_MAX_ROLES = 4
_MAX_BULLETS = 4
-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_SKILL_GROUPS = 5 # rows, as in the candidate's CV design; extra groups fold into "Other"
+_MAX_PROJECTS = 4
+_MAX_PROJECT_DESC_CHARS = 520 # room for 2-3 sentences (what it is, how it works, a result)
_MAX_EDUCATION = 4
_MAX_CERTS = 6
_MAX_PARAGRAPHS = 4
@@ -140,15 +140,6 @@ def _clean_text(text: str) -> str:
return re.sub(r"\s+", " ", t).strip()
-def _short_url(url: str) -> str:
- """Display form for a profile link: drop scheme + leading www."""
- u = (url or "").strip()
- if not u:
- return ""
- u = u.split("://", 1)[-1]
- return u[4:] if u.startswith("www.") else u
-
-
def _abs_url(url: str) -> str:
"""Ensure a profile link is an absolute URL so it is clickable in the PDF."""
u = (url or "").strip()
@@ -159,28 +150,11 @@ def _abs_url(url: str) -> str:
return "https://" + u.lstrip("/")
-def _project_link_label(url: str) -> str:
- """Human label for a project link, by host, so it renders as a clickable word
- ("GitHub" / "Live Demo") instead of a long raw URL — consistent with the
- labelled contact links."""
- host = _short_url(url).lower()
- if not host:
- return ""
- if "github.com" in host:
- return "GitHub"
- if "gitlab.com" in host:
- return "GitLab"
- if "bitbucket" in host:
- return "Bitbucket"
- return "Live Demo"
-
-
def _contact_items(contact: dict) -> list[dict]:
"""
- Header contact entries as {text, href?} objects. Profile links render as
- labelled, clickable hyperlinks ("LinkedIn Profile" / "GitHub" / "Website")
- rather than long raw URLs; email/phone become mailto:/tel: links; location is
- plain text. Order: location, phone, email, then profile links.
+ Header contact entries as {text, href?} objects, in the order of the candidate's CV design:
+ location and phone as plain text, then the email (a mailto: link) and labelled profile links
+ ("LinkedIn" / "GitHub" / "Website") rather than long raw URLs.
"""
if not isinstance(contact, dict):
return []
@@ -191,10 +165,10 @@ def _contact_items(contact: dict) -> list[dict]:
if loc:
items.append({"text": loc})
if phone:
- items.append({"text": phone, "href": "tel:" + re.sub(r"[^\d+]", "", phone)})
+ items.append({"text": phone})
if email:
items.append({"text": email, "href": "mailto:" + email})
- for key, label in (("linkedin", "LinkedIn Profile"), ("github", "GitHub"), ("website", "Website")):
+ for key, label in (("linkedin", "LinkedIn"), ("github", "GitHub"), ("website", "Website")):
url = _abs_url(contact.get(key, ""))
if url:
items.append({"text": label, "href": url})
@@ -250,7 +224,6 @@ def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict:
experience.append({
"title": (role.get("title") or "").strip(),
"employer": (role.get("employer") or "").strip(),
- "location": (role.get("location") or "").strip(),
"dates": _role_dates(role),
"bullets": bullets,
})
@@ -258,13 +231,13 @@ def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict:
projects = []
for p in (cv.get("projects") or [])[:_MAX_PROJECTS]:
description = _truncate(_clean_text(p.get("description", "")), _MAX_PROJECT_DESC_CHARS)
+ # "Name · context" renders the context as a grey subtitle, as in the candidate's CV.
+ title, _, subtitle = (p.get("name") or "").partition(" · ")
projects.append({
- "name": (p.get("name") or "").strip(),
+ "title": title.strip(),
+ "subtitle": subtitle.strip(),
"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", "")),
})
education = []
@@ -272,7 +245,7 @@ def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict:
education.append({
"degree": (e.get("degree") or "").strip(),
"institution": (e.get("institution") or "").strip(),
- "year": (e.get("year") or "").strip(),
+ "dates": _role_dates({"start_date": e.get("start_date"), "end_date": e.get("year")}),
"grade": (e.get("grade") or "").strip(),
})
@@ -284,16 +257,16 @@ def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict:
"year": (c.get("year") or "").strip(),
})
- # Section order is seniority-aware (2026 graduate strategy): juniors lead with
- # Education and place Skills lower; experienced candidates lead with Skills and
- # keep Education near the end. Falls back to a sensible inference if unset.
+ # Section order is seniority-aware: juniors follow the candidate's CV design (education first,
+ # skills last); experienced candidates lead with experience and keep education near the end.
+ # Certifications are listed under Education. Falls back to a sensible inference if unset.
seniority = (cv.get("seniority") or "").lower()
if seniority not in ("junior", "experienced"):
seniority = "junior" if (education and len(experience) <= 1) else "experienced"
if seniority == "junior":
- sections = ["summary", "education", "experience", "projects", "skills", "certifications"]
+ sections = ["summary", "education", "experience", "projects", "skills"]
else:
- sections = ["summary", "skills", "experience", "projects", "education", "certifications"]
+ sections = ["summary", "experience", "projects", "skills", "education"]
return {
"name": (cv.get("name") or "").strip(),
@@ -454,7 +427,7 @@ def _fallback_pdf(doc_type: str, doc: dict) -> str:
if data["projects"]:
lines += ["", "## Projects"]
for p in data["projects"]:
- lines.append(f"### {p['name']}")
+ lines.append(f"### {p['title']}" + (f" · {p['subtitle']}" if p["subtitle"] else ""))
if p["description"]:
lines.append(p["description"])
if data["education"]:
diff --git a/api/app/templates/base.css b/api/app/templates/base.css
index 4b47def..c9de056 100644
--- a/api/app/templates/base.css
+++ b/api/app/templates/base.css
@@ -1,5 +1,5 @@
/* Windrush document base stylesheet — shared by the live preview (iframe) and the
- WeasyPrint PDF. Tuned to keep a mid-career CV to 2 pages and a junior CV to 1.
+ WeasyPrint PDF. The CV follows the candidate's own CV design; doc_render fits it to its page target.
Inlined into each template via a
-
-