From 273cf7290dfb1c0f8ffbce76c7f840e3635ca28b Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Thu, 15 Oct 2026 23:23:27 +0100 Subject: [PATCH] feat: tailored CVs follow the candidate's own CV design and structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout, measured from the candidate's CV (Timothy_Ojebiyi_CV.pdf): Cambria (Caladea, its metric twin, in the image and CI), a 25.8pt name, a 9pt grey contact line with blue links, 13.2pt sentence-case headings over a light rule, 10.2pt body, grey dates on a tab stop, Word-style bullets, and "Name · context" project titles with a grey technologies line. Sections: Professional Summary, Education (certifications listed there), Experience, Projects, Skills & Technical Proficiency. Roles show no location and projects no links, as in the design. Structure, from the same CV: 1-2 bullets per role, 4 projects as short paragraphs, and 20-30 skills chosen for the job in at most 5 rows of 8. The model's skill choice keeps only the profile's own skills (anything it invents, renames or repeats is dropped); with fewer than 10 left, or more than 40, the profile's full list is used. Contact details, education, certifications and role dates always come from the profile, and the header shows "City, Country". Checked on the candidate's profile with the real model: CVs for three roles each fit one page with the technologies lines kept. --- .github/workflows/ci.yml | 6 +- api/Dockerfile | 2 + api/app/agent.py | 126 ++++++++++++++------- api/app/doc_render.py | 65 ++++------- api/app/templates/base.css | 107 +++++++----------- api/app/templates/classic_cv.html | 84 +++++++------- api/tests/test_documents.py | 181 +++++++++++++++++++----------- 7 files changed, 308 insertions(+), 263 deletions(-) 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 - -
-

{{ cv.name }}

+ +
+

{{ cv.name }}

{% if cv.contact_items %} -

+

{% for c in cv.contact_items %}{% if c.href %}{{ c.text }}{% else %}{{ c.text }}{% endif %}{% if not loop.last %} | {% endif %}{% endfor %}

{% endif %} @@ -19,29 +19,46 @@

{{ cv.name }}

{% for sec in cv.sections %} {% if sec == "summary" and cv.summary %} -
+

Professional Summary

-

{{ cv.summary }}

+

{{ cv.summary }}

{% endif %} - {% if sec == "skills" and cv.skills %} -
-

Skills

- {% for grp in cv.skills %} -
{{ grp.category }}: {{ grp["items"] | join(", ") }}
+ {% if sec == "education" and (cv.education or cv.certifications) %} +
+

Education

+ {% for e in cv.education %} +
+
+ {{ e.degree }}{% if e.institution %} | {{ e.institution }}{% endif %} + {% if e.dates %}{{ e.dates }}{% endif %} +
+ {% if e.grade %}

Grade: {{ e.grade }}

{% endif %} +
+ {% endfor %} + {% for c in cv.certifications %} +
+
+ {{ c.name }}{% if c.issuer %} | {{ c.issuer }}{% endif %} + {% if c.year %}{{ c.year }}{% endif %} +
+
{% endfor %}
{% endif %} {% if sec == "experience" and cv.experience %} -
-

Work Experience

+
+

Experience

{% for role in cv.experience %} -
-
{{ role.title }}{% if role.employer %} | {{ role.employer }}{% endif %}{% if role.location %} | {{ role.location }}{% endif %}{% if role.dates %} | {{ role.dates }}{% endif %}
+
+
+ {{ role.title }}{% if role.employer %} | {{ role.employer }}{% endif %} + {% if role.dates %}{{ role.dates }}{% endif %} +
{% if role.bullets %} -
    +
      {% for b in role.bullets %}
    • {{ b }}
    • {% endfor %}
    {% endif %} @@ -51,38 +68,23 @@

    Work Experience

    {% endif %} {% if sec == "projects" and cv.projects %} -
    +

    Projects

    {% for p in cv.projects %} -
    -
    {{ p.name }}
    - {% if p.description %}
    {{ p.description }}
    {% endif %} - {% if p.tech %}
    Technologies: {{ p.tech | join(", ") }}
    {% endif %} - {% if p.link_href %}{% endif %} +
    +

    {{ p.title }}{% if p.subtitle %} · {{ p.subtitle }}{% endif %}

    + {% if p.description %}

    {{ p.description }}

    {% endif %} + {% if p.tech %}

    Technologies: {{ p.tech | join(", ") }}

    {% endif %}
    {% endfor %}
    {% endif %} - {% if sec == "education" and cv.education %} -
    -

    Education

    - {% for e in cv.education %} -
    -
    {{ e.degree }}{% if e.year %} | {{ e.year }}{% endif %}{% if e.institution %} | {{ e.institution }}{% endif %}
    - {% if e.grade %}
    Grade: {{ e.grade }}
    {% endif %} -
    - {% endfor %} -
    - {% endif %} - - {% if sec == "certifications" and cv.certifications %} -
    -

    Certifications

    - {% for c in cv.certifications %} -
    -
    {{ c.name }}{% if c.issuer %} | {{ c.issuer }}{% endif %}{% if c.year %} | {{ c.year }}{% endif %}
    -
    + {% if sec == "skills" and cv.skills %} +
    +

    Skills & Technical Proficiency

    + {% for grp in cv.skills %} +

    {{ grp.category }}: {{ grp["items"] | join(", ") }}

    {% endfor %}
    {% endif %} diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index ef2d3cd..d2e1aef 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -42,7 +42,7 @@ "problem_solved": "A web app where students report and claim lost items, with image matching to suggest " "likely owners. Built with Django and PostgreSQL in Docker.", "outcomes": "450 items returned in the first term."}, - {"name": "Chess Engine", + {"name": "Chess Engine · Personal project", "problem_solved": "I wrote a chess engine in Java using alpha-beta search with move ordering and a " "transposition table. It plays at about 1900 on Lichess."}, ], @@ -145,44 +145,59 @@ def test_certifications_keep_issuer_and_date_through_to_the_rendered_cv(): assert "AWS Solutions Architect" in html and "Amazon" in html and "05/2024" in html -def test_a_graduate_cv_leads_with_education_and_links_every_profile(llm): +def _entry(html: str, title: str) -> str: + """The dates shown beside an entry title ("" when none).""" + m = re.search(rf'{re.escape(title)}\s*(?:(.*?))?', + html) + assert m, f"no entry titled {title!r}" + return m.group(1) or "" + + +def test_a_graduate_cv_follows_the_candidates_design(llm): llm.reply = "not json" # the model failed: the CV is built straight from the profile html = doc_render.render_cv_html(_tailored_cv()) - assert _headings(html) == ["Professional Summary", "Education", "Work Experience", "Projects", "Skills", - "Certifications"] - assert '

    Sam Okafor

    ' in html - for href in ("mailto:sam@example.com", "tel:+447700900456", "https://linkedin.com/in/sam-okafor", - "https://github.com/samokafor", "https://samokafor.dev"): - assert f'href="{href}"' in html - assert "BSc (Hons) Computer Science | 06/2025 | Northfield University" in html and "Grade: 2:1" in html - assert "Software Engineering Intern | Brightwave Analytics | 06/2024 – 09/2024" in html + assert _headings(html) == ["Professional Summary", "Education", "Experience", "Projects", + "Skills & Technical Proficiency"] + assert '

    Sam Okafor

    ' in html + contact = re.search(r'

    (.*?)

    ', html, re.S).group(1) + assert re.sub(r"<[^>]+>|\s+", " ", contact).split() == "Bristol | +44 7700 900456 | sam@example.com | LinkedIn | GitHub | Website".split() + for href in ("mailto:sam@example.com", "https://linkedin.com/in/sam-okafor", "https://github.com/samokafor", + "https://samokafor.dev"): + assert f'href="{href}"' in contact + assert "tel:" not in contact # the phone is plain text + assert _entry(html, "BSc (Hons) Computer Science | Northfield University") == "09/2022 – 06/2025" + assert "

    Grade: 2:1

    " in html + assert _entry(html, "Software Engineering Intern | Brightwave Analytics") == "06/2024 – 09/2024" assert "Cut dashboard load time from 6 s to 1.5 s" in html - assert "AWS Cloud Practitioner | Amazon Web Services | 03/2025" in html + education = html.split("

    Education

    ")[1].split("
    ")[0] + assert "AWS Cloud Practitioner | Amazon Web Services" in education # certifications sit under Education + assert _entry(education, "AWS Cloud Practitioner | Amazon Web Services") == "03/2025" def test_the_summary_loses_its_pronouns(llm): llm.reply = "{}" - summary = re.search(r'

    (.*?)

    ', doc_render.render_cv_html(_tailored_cv())).group(1) + summary = re.search(r"

    Professional Summary

    \s*

    (.*?)

    ", doc_render.render_cv_html(_tailored_cv())).group(1) assert summary == "Computer Science graduate who builds full-stack web apps and data pipelines." -def test_portfolio_prose_becomes_two_clean_sentences_with_labelled_links(llm): +def test_projects_show_a_titled_paragraph_and_a_technologies_line(llm): llm.reply = "{}" html = doc_render.render_cv_html(_tailored_cv()) projects = html.split("

    Projects

    ")[1].split("
    ")[0] - assert re.findall(r'
    (.*?)
    ', projects) == [ - "Pantry Planner", "Rail Delay Forecaster", "Campus Lost & Found"] # three at most, escaped + assert re.findall(r'(.*?)', projects) == [ + "Pantry Planner", "Rail Delay Forecaster", "Campus Lost & Found", "Chess Engine"] # four, escaped + assert 'Chess Engine · Personal project' in projects assert ("Built a meal planner that turns the food already in a kitchen into a week of recipes, so less of it " "is thrown away. It matches pantry items against 12,000 recipes with TF-IDF similarity and ranks them " "by what expires first.") in projects assert "What problem" not in projects and "How it works" not in projects and "<br" not in projects - assert 'GitHub' in projects - assert 'Live Demo' in projects + assert '

    Technologies: Python, PyTorch

    ' in projects + assert "Skills")[1].split("
")[0] + skills = html.split("

Skills & Technical Proficiency

")[1].split("
")[0] missing = [s for grp in GRADUATE["skills"] for s in grp["skills"] if s not in skills] - assert missing == [] and skills.count('class="skill-row"') == doc_render.MAX_SKILL_GROUPS + assert missing == [] and skills.count("

") == 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.""" +def _choosing(groups: dict[str, list[str]]) -> str: + """The model's reply: the base CV with the skills it chose for the job.""" 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"], +CHOSEN = { # 13 of the profile's 23 skills, the way the candidate's own CV groups them + "Languages": ["Python", "SQL"], + "Backend & Data": ["FastAPI", "PostgreSQL", "Redis"], + "ML & AI": ["scikit-learn", "PyTorch", "pandas"], + "Tools": ["Docker", "Git", "GitHub Actions", "AWS", "pytest"], } -def test_the_models_regrouping_is_used_when_it_keeps_every_skill(llm): - llm.reply = _regrouping({**ALL_SKILLS, "Languages": ["python", "TypeScript", "Java", "SQL"]}) +def test_the_model_chooses_the_skills_for_the_job(llm): + llm.reply = _choosing({**CHOSEN, "Languages": ["python", "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] + assert cv["skills"] == [{"category": c, "items": items} for c, items in CHOSEN.items()] # profile spelling + assert "choose the 20-30 skills" in llm.prompts[-1] and "Leave out spoken languages" 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"), + pytest.param(lambda g: g["Tools"].append("Kubernetes"), id="invents one"), + pytest.param(lambda g: g["Tools"].append("Python"), id="repeats one"), + pytest.param(lambda g: g["Tools"].append("GitHub Actions CI/CD pipelines"), id="renames one"), ]) -def test_a_regrouping_that_changes_the_skills_is_ignored(llm, change): - groups = copy.deepcopy(ALL_SKILLS) +def test_skills_the_model_invents_renames_or_repeats_are_dropped(llm, change): + groups = copy.deepcopy(CHOSEN) change(groups) - llm.reply = _regrouping(groups) - assert _tailored_cv()["skills"] == agent._persona_to_cvdoc(GRADUATE)["skills"] + llm.reply = _choosing(groups) + assert _tailored_cv()["skills"] == [{"category": c, "items": items} for c, items in CHOSEN.items()] + + +def test_an_unnamed_row_is_called_other(): + base = [{"category": "All", "items": [f"Skill {i}" for i in range(12)]}] + chosen = agent._chosen_skills([{"category": "", "items": [f"Skill {i}" for i in range(12)]}], base) + assert chosen == [{"category": "Other", "items": [f"Skill {i}" for i in range(12)]}] + + +@pytest.mark.parametrize("n, used", [(9, False), (10, True), (40, True), (41, False)]) +def test_too_few_real_skills_or_too_many_falls_back_to_the_full_list(n, used): + base = [{"category": "All", "items": [f"Skill {i}" for i in range(50)]}] + chosen = agent._chosen_skills([{"category": "All", "items": [f"Skill {i}" for i in range(n)]}], base) + assert (chosen is not None) is used + + +@pytest.mark.parametrize("core, location", [ + ({"city": "Bristol", "country": "United Kingdom"}, "Bristol, United Kingdom"), + ({"city": "London, United Kingdom", "country": "United Kingdom"}, "London, United Kingdom"), + ({"city": "Bristol"}, "Bristol"), + ({"country": "United Kingdom"}, "United Kingdom"), +]) +def test_the_cv_shows_city_and_country(core, location): + assert agent._cv_location(core) == location @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", "D", "E", "F", "G", "H"], ["A", "B", "C", "D", "Other"]), + (["A", "Other", "B", "C", "D", "E", "F"], ["A", "B", "C", "D", "Other"]), # absorbs, moves last (["A", "B", "C"], ["A", "B", "C"]), ]) def test_extra_skill_groups_fold_into_other(categories, rows): @@ -284,7 +328,7 @@ def test_length_limits_hold_whatever_the_model_returns(): data = doc_render.build_cv_template_data(cv) 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["projects"]) == 4 and len(data["education"]) == 4 and len(data["certifications"]) == 6 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(".") @@ -306,7 +350,8 @@ class Pages: steps: list[str] = [] def count(html): - Pages.steps.append(re.search(r"", html).group(1) or "") + classes = re.search(r"", html).group(1) or "" + Pages.steps.append(classes.removeprefix("cv").strip()) return Pages.rule(html) monkeypatch.setattr(doc_render, "_page_count", count) return Pages @@ -314,11 +359,11 @@ def count(html): 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 + 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) + pages.rule = staticmethod(lambda html: 1 if '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 @@ -350,7 +395,7 @@ def test_when_nothing_fits_every_trim_is_applied_and_logged(pages, caplog): 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 == [""] + assert '' 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): @@ -361,9 +406,9 @@ def test_a_long_letter_tightens_its_layout_but_keeps_every_paragraph(pages): def test_the_preview_is_fitted_like_the_pdf(client, pages): - pages.rule = staticmethod(lambda html: 1 if 'class="tight"' in html else 2) + pages.rule = staticmethod(lambda html: 1 if 'class="cv tight"' in html else 2) html = client.post("/documents/preview", json={"type": "cv", "doc": _one_role_junior()}).json()["html"] - assert '' in html + assert '' in html def test_text_is_escaped_not_rendered_as_markup(): @@ -470,27 +515,35 @@ 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): +def test_the_graduate_cv_is_one_page_in_the_designs_type_with_live_links(pdf_dir): cv = agent._persona_to_cvdoc(GRADUATE) | {"seniority": "junior"} - assert "" in doc_render.render_cv_html(cv) # fits without tightening + 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 - assert any(f[3].endswith("+Liberation-Serif") for f in pdf[0].get_fonts()) # not a fallback font + text = pdf[0].get_text() + assert "Sam Okafor" in text and "Pantry Planner" in text and "Northfield University" in text + assert any(f[3].endswith("+Caladea") for f in pdf[0].get_fonts()) # Cambria's metric twin, not a fallback + spans = {s["text"].strip(): (round(s["size"], 1), "Bold" in s["font"], f"#{s['color']:06x}") + for b in pdf[0].get_text("dict")["blocks"] for line in b.get("lines", []) for s in line["spans"]} + assert spans["Sam Okafor"] == (25.8, False, "#1a1a1a") # the candidate's CV, measured + assert spans["Professional Summary"] == (13.2, True, "#1a1a1a") + assert spans["Software Engineering Intern | Brightwave Analytics"] == (10.2, True, "#1a1a1a") + assert spans["06/2024 – 09/2024"] == (9.0, False, "#595959") uris = {link.get("uri") for link in pdf[0].get_links()} - assert {"mailto:sam@example.com", "https://github.com/samokafor/pantry-planner"} <= uris + assert {"mailto:sam@example.com", "https://github.com/samokafor"} <= uris @needs_weasyprint 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. + # Everything the tailoring prompt allows a junior: a 3-sentence summary, three roles with 2 bullets of + # ~35 words, 4 projects of ~60 words with 8 technologies, 35 skills in 5 rows, 3 certificates. cv = _full_cvdoc("junior") - cv["experience"] = cv["experience"][:1] - cv["skills"] = [{"category": f"Group {g}", "items": [f"Skill{g}{i}" for i in range(6)]} for g in range(6)] - for p in cv["projects"]: - p["description"] = _sentence(25, 1) + ". " + _sentence(25, 2) + "." + cv["experience"] = cv["experience"][:3] + for r in cv["experience"]: + r["bullets"] = [_sentence(35, 1) + ".", _sentence(35, 2) + "."] + cv["skills"] = [{"category": f"Group {g}", "items": [f"Skill{g}{i}" for i in range(7)]} for g in range(5)] + cv["projects"] = [dict(cv["projects"][0], name=f"Project {i}", description=_sentence(30, 1) + ". " + _sentence(30, 2) + ".") + for i in range(4)] assert _weasy("cv", cv).page_count == 1