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