From c82899cd0edf9e07c2f5ecf8fce9b4518853b98e Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Thu, 15 Oct 2026 19:24:18 +0100 Subject: [PATCH] fix: every skill reaches the CV, and a graduate CV fits on one page Skills: groups past the sixth were dropped (and rows past 14 items cut), so a profile with ten overlapping groups lost Git, AWS and CI/CD. The tailored-CV prompt now asks the model to sort every skill into at most six categories; its grouping is used only if it holds exactly the profile's skills, none dropped, added or repeated. Otherwise, and for any CV not written by the model, extra groups fold into one "Other" row. No skill is cut any more. Page fit: the renderer measures a CV in WeasyPrint and, when it runs past its target (one page for a graduate, two otherwise), tries in order: tighter spacing, 10pt type, then dropping project technology lists, each role's 4th bullet, and project descriptions after their first sentence. The first step that fits is used for both the preview and the PDF; the preview endpoint now renders in a worker thread. Without WeasyPrint it renders as written. The two strict xfails from the rendering tests now pass. --- api/app/agent.py | 45 ++++++++-- api/app/doc_render.py | 114 ++++++++++++++++++++++---- api/app/main.py | 3 +- api/app/templates/base.css | 15 ++++ api/app/templates/classic_cv.html | 2 +- api/tests/test_documents.py | 132 ++++++++++++++++++++++++++++-- 6 files changed, 280 insertions(+), 31 deletions(-) diff --git a/api/app/agent.py b/api/app/agent.py index bd8b4d9..9313d75 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -10,7 +10,7 @@ from openai import AsyncOpenAI, BadRequestError -from . import azure_config +from . import azure_config, doc_render from .company_names import normalise_company_name as _normalise_company_name from . import http_client from . import tracker @@ -462,6 +462,33 @@ def _rank_projects_by_relevance(job: dict, projects: list[dict]) -> list[dict]: return [p for _, _, p in scored] +def _regrouped_skills(groups, base: list[dict]) -> list[dict] | None: + """ + The model's regrouping of the profile's skills, if it holds exactly those skills (none dropped, + added or repeated) in at most doc_render.MAX_SKILL_GROUPS named groups; else None. Each skill + keeps the profile's spelling. + """ + spelling = {str(s).strip().lower(): str(s).strip() + for g in base for s in (g.get("items") or []) if s and str(s).strip()} + if not isinstance(groups, list) or not 0 < len(groups) <= doc_render.MAX_SKILL_GROUPS: + return None + out, seen = [], set() + for g in groups: + items = g.get("items") if isinstance(g, dict) else None + category = str(g.get("category") or "").strip() if isinstance(g, dict) else "" + if not category or not isinstance(items, list) or not items: + return None + row = [] + for s in items: + key = str(s).strip().lower() + if key not in spelling or key in seen: + return None + seen.add(key) + row.append(spelling[key]) + out.append({"category": category, "items": row}) + return out if seen == set(spelling) else None + + def _restore_project_depth(cv_projects: list, base_projects: list) -> list: """ Keep the model's tailored project selection AND its now role-specific description @@ -1109,6 +1136,10 @@ async def execute_tool(name: str, tool_input: dict) -> dict: "'Results-driven', 'Detail-oriented', 'Passionate') and do NOT close with vague filler ('strong " "problem-solving skills and attention to detail', 'hardworking team player'). Do NOT spend a " "sentence naming assumed or basic tooling (e.g. Git / version control). " + f"SKILLS: sort EVERY skill in the candidate data into at most {doc_render.MAX_SKILL_GROUPS} " + "categories with short, conventional names (e.g. 'Languages', 'Machine Learning', 'Web', " + "'Data & Cloud', 'Tools'), the categories most relevant to this job first. Copy each skill " + "exactly as written; do not add, drop, rename or combine any. " "LENGTH: at most 4 most-recent roles; at most 3 projects. " "BANNED words (recruiters flag these as AI-written — never use them in bullets, summary or " "project text): delve, testament, robust, seamless, synergy, leverage, pivotal, intricate, " @@ -1133,11 +1164,13 @@ async def execute_tool(name: str, tool_input: dict) -> dict: if used_fallback: cv = base_cv # Trust our own structured data — never let the model alter identity/contact, and keep - # the candidate's full skills list (the model tends to silently drop groups/items when - # tailoring; completeness matters more to the user than reordering). + # the candidate's full skills list: the model's regrouping is used only when it holds + # exactly the profile's skills (it tends to silently drop some when tailoring). cv["contact"] = base_cv["contact"] + regrouped = None if base_cv.get("skills"): - cv["skills"] = base_cv["skills"] + regrouped = None if used_fallback else _regrouped_skills(cv.get("skills"), base_cv["skills"]) + cv["skills"] = regrouped or base_cv["skills"] if cv.get("projects") and base_cv.get("projects"): cv["projects"] = _restore_project_depth(cv["projects"], base_cv["projects"]) if not cv.get("name"): @@ -1148,8 +1181,8 @@ async def execute_tool(name: str, tool_input: dict) -> dict: # same projects recur across roles (the model is meant to tailor the selection). final_proj_names = [(p.get("name") or "")[:40] for p in (cv.get("projects") or [])][:5] (logger.warning if used_fallback else logger.info)( - "tailored_cv: fallback=%s, level=%s, projects base=%d model=%d final=%d %s | job=%r", - used_fallback, level, base_proj_n, model_proj_n, len(cv.get("projects") or []), + "tailored_cv: fallback=%s, level=%s, skills regrouped=%s, projects base=%d model=%d final=%d %s | job=%r", + used_fallback, level, regrouped is not None, base_proj_n, model_proj_n, len(cv.get("projects") or []), final_proj_names, (job.get("title") or "")[:60], ) return {"cv": cv, "template_id": template_id} diff --git a/api/app/doc_render.py b/api/app/doc_render.py index 45f0838..97dc211 100644 --- a/api/app/doc_render.py +++ b/api/app/doc_render.py @@ -2,9 +2,11 @@ Structured document rendering for Windrush CVs and cover letters. The LLM emits a typed CVDoc / LetterDoc (see agent.py). This module: - 1. turns that doc into trimmed, render-ready template data (the *authoritative* - 1–2 page guarantee — slicing here holds even if the model ignores the prompt), - 2. renders it to HTML via Jinja (reused by both the live preview and the PDF), + 1. turns that doc into trimmed, render-ready template data (the length limits hold + even if the model ignores the prompt), + 2. renders it to HTML via Jinja (reused by both the live preview and the PDF), fitting a + CV to its page target — one page for a graduate, two otherwise — by measuring it in + WeasyPrint and tightening, then trimming, until it fits (_FIT_STEPS), 3. renders that HTML to a PDF via WeasyPrint, falling back to the legacy fpdf2 renderer if WeasyPrint is unavailable or errors. @@ -14,6 +16,7 @@ from __future__ import annotations import asyncio +import functools import logging import re import uuid @@ -36,18 +39,36 @@ lstrip_blocks=True, ) -# ── Length limits (the real 1–2 page guarantee) ───────────────────────────────── +# ── Length limits ──────────────────────────────────────────────────────────────── +# These bound what any doc can put on the page; fitting a CV to its page target is _fitted_cv_html's job. _MAX_SUMMARY_CHARS = 420 _MAX_ROLES = 4 _MAX_BULLETS = 4 -_MAX_SKILL_GROUPS = 6 -_MAX_SKILL_ITEMS = 14 +MAX_SKILL_GROUPS = 6 # rows; skills are never dropped, extra groups fold into "Other" _MAX_PROJECTS = 3 _MAX_PROJECT_DESC_CHARS = 520 # room for 2 full sentences (what it is/does + how it works) _MAX_EDUCATION = 4 _MAX_CERTS = 6 _MAX_PARAGRAPHS = 4 +# Pages a CV must fit: one for a graduate / early-career candidate, two otherwise. +_PAGE_TARGET = {"junior": 1, "experienced": 2} + +# When a CV runs over its page target, these steps are tried in order until it fits. Each adds to +# the one before: tighter spacing, then 10pt type (the floor), then trimming the least important +# content — project technology lists, each role's 4th bullet, project descriptions after their +# first sentence. "tight" and "small" are CSS classes on (base.css); the rest trim the data. +_FIT_STEPS: tuple[tuple[str, ...], ...] = ( + (), + ("tight",), + ("tight", "small"), + ("tight", "small", "no_tech"), + ("tight", "small", "no_tech", "three_bullets"), + ("tight", "small", "no_tech", "three_bullets", "one_sentence"), +) +_LAYOUT_STEPS = ("tight", "small") +_OTHER_CATEGORIES = ("other", "others", "additional", "miscellaneous", "misc") + # ── helpers ───────────────────────────────────────────────────────────────────── @@ -186,19 +207,44 @@ def _role_dates(role: dict) -> str: return start or end +def _skill_rows(groups: list) -> list[dict]: + """ + The CV's skill rows, at most MAX_SKILL_GROUPS of them, without losing a skill: past the limit, + the extra groups fold into one "Other" row (an existing "Other"-like group absorbs them, last). + """ + rows = [] + for grp in groups or []: + items = [str(s).strip() for s in (grp.get("items") or []) if s and str(s).strip()] + if items: + rows.append({"category": (grp.get("category") or "Skills").strip() or "Skills", "items": items}) + if len(rows) <= MAX_SKILL_GROUPS: + return rows + other = next((r for r in rows if r["category"].lower() in _OTHER_CATEGORIES), None) + rest = [r for r in rows if r is not other] + kept, folded = rest[:MAX_SKILL_GROUPS - 1], rest[MAX_SKILL_GROUPS - 1:] + items, seen = [], set() + for s in (other["items"] if other else []) + [s for r in folded for s in r["items"]]: + if s.lower() not in seen: + seen.add(s.lower()) + items.append(s) + return kept + [{"category": "Other", "items": items}] + + +def _first_sentence(text: str) -> str: + return re.split(r"(?<=[.!?])\s+", text.strip(), maxsplit=1)[0] + + # ── template-data builders (apply trimming/defaults) ──────────────────────────── -def build_cv_template_data(cv: dict) -> dict: +def build_cv_template_data(cv: dict, fit: tuple[str, ...] = ()) -> dict: + """Render-ready CV data. `fit` is a step from _FIT_STEPS: the layout classes and trims to apply.""" cv = cv or {} - skills = [] - for grp in (cv.get("skills") or [])[:_MAX_SKILL_GROUPS]: - items = [s for s in (grp.get("items") or []) if s][:_MAX_SKILL_ITEMS] - if items: - skills.append({"category": (grp.get("category") or "Skills").strip(), "items": items}) + skills = _skill_rows(cv.get("skills") or []) + max_bullets = 3 if "three_bullets" in fit else _MAX_BULLETS experience = [] for role in sorted(cv.get("experience") or [], key=_render_recency, reverse=True)[:_MAX_ROLES]: - bullets = [b.strip() for b in (role.get("bullets") or []) if b and b.strip()][:_MAX_BULLETS] + bullets = [b.strip() for b in (role.get("bullets") or []) if b and b.strip()][:max_bullets] experience.append({ "title": (role.get("title") or "").strip(), "employer": (role.get("employer") or "").strip(), @@ -209,10 +255,11 @@ def build_cv_template_data(cv: dict) -> dict: projects = [] for p in (cv.get("projects") or [])[:_MAX_PROJECTS]: + description = _truncate(_clean_text(p.get("description", "")), _MAX_PROJECT_DESC_CHARS) projects.append({ "name": (p.get("name") or "").strip(), - "description": _truncate(_clean_text(p.get("description", "")), _MAX_PROJECT_DESC_CHARS), - "tech": [t for t in (p.get("tech") or []) if t], + "description": _first_sentence(description) if "one_sentence" in fit else description, + "tech": [] if "no_tech" in fit else [t for t in (p.get("tech") or []) if t], "link": _short_url(p.get("link", "")), "link_href": _abs_url(p.get("link", "")), "link_label": _project_link_label(p.get("link", "")), @@ -258,6 +305,8 @@ def build_cv_template_data(cv: dict) -> dict: "certifications": certifications, "seniority": seniority, "sections": sections, + "page_target": _PAGE_TARGET[seniority], + "body_class": " ".join(s for s in fit if s in _LAYOUT_STEPS), } @@ -302,9 +351,40 @@ def build_letter_template_data(letter: dict) -> dict: # ── HTML rendering ─────────────────────────────────────────────────────────────── +@functools.lru_cache(maxsize=1) +def _weasyprint_html(): + """WeasyPrint's HTML class, or None where it can't load (ImportError, or OSError without Pango).""" + try: + from weasyprint import HTML + return HTML + except Exception: + return None + + +def _page_count(html: str) -> int | None: + """Pages the HTML lays out to in WeasyPrint, or None where WeasyPrint can't load.""" + weasy = _weasyprint_html() + return None if weasy is None else len(weasy(string=html, base_url=_TEMPLATES_DIR).render().pages) + + +def _fitted_cv_html(cv: dict, template: str) -> str: + """ + The CV's HTML at the first step of _FIT_STEPS that keeps it within its page target, so the + preview and the PDF agree. Without WeasyPrint to measure with, it's rendered as written. + """ + tpl = _env.get_template(template) + for fit in _FIT_STEPS: + data = build_cv_template_data(cv, fit) + html = tpl.render(cv=data) + pages = _page_count(html) + if pages is None or pages <= data["page_target"]: + return html + logger.warning("CV still runs to %d pages (target %d) after every fit step", pages, data["page_target"]) + return html + + def render_cv_html(cv: dict, template_id: str | None = None) -> str: - tpl = registry.resolve(template_id) - return _env.get_template(tpl["cv"]).render(cv=build_cv_template_data(cv)) + return _fitted_cv_html(cv, registry.resolve(template_id)["cv"]) def render_letter_html(letter: dict, template_id: str | None = None) -> str: diff --git a/api/app/main.py b/api/app/main.py index d3e679d..3ab91de 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -1024,7 +1024,8 @@ async def preview_document( doc = body.get("doc", {}) template_id = body.get("template_id", "classic") try: - html = doc_render.render_html(doc_type, doc, template_id) + # A worker thread: fitting a CV to its page count lays it out in WeasyPrint. + html = await asyncio.to_thread(doc_render.render_html, doc_type, doc, template_id) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) return {"html": html} diff --git a/api/app/templates/base.css b/api/app/templates/base.css index 0348140..c0ae12e 100644 --- a/api/app/templates/base.css +++ b/api/app/templates/base.css @@ -100,6 +100,21 @@ a { color: inherit; text-decoration: none; } .skill-row { margin-bottom: 3pt; font-size: 10pt; } .skill-cat { font-weight: 700; } +/* ── Fit to page ────────────────────────────────────────────────────── */ +/* Classes doc_render puts on the body element when a CV runs past its page target: "tight" + closes up the spacing, "small" then brings the body type down to 10pt, the floor. */ +body.tight { line-height: 1.24; } +body.tight .head { margin-bottom: 5pt; } +body.tight .section { margin-top: 7pt; } +body.tight .section h2 { margin-bottom: 3pt; padding-bottom: 1pt; } +body.tight .role { margin-bottom: 5pt; } +body.tight .entry { margin-bottom: 4pt; } +body.tight li { margin-bottom: 1pt; } +body.tight .skill-row { margin-bottom: 1.5pt; } +body.small, body.small .summary { font-size: 10pt; } +body.small .name { font-size: 20pt; } +body.small .section h2 { font-size: 10pt; } + /* ── Cover letter ───────────────────────────────────────────────────── */ .letter-head { margin-bottom: 14pt; } .letter-name { font-size: 18pt; font-weight: 700; margin: 0; } diff --git a/api/app/templates/classic_cv.html b/api/app/templates/classic_cv.html index 5070fe7..0c2e8b7 100644 --- a/api/app/templates/classic_cv.html +++ b/api/app/templates/classic_cv.html @@ -5,7 +5,7 @@ {{ cv.name }} — CV - +

{{ cv.name }}

{% if cv.contact_items %} diff --git a/api/tests/test_documents.py b/api/tests/test_documents.py index 1cdf257..2fdf0c0 100644 --- a/api/tests/test_documents.py +++ b/api/tests/test_documents.py @@ -210,12 +210,62 @@ def test_the_model_tailors_the_cv_but_cannot_touch_contact_details_skills_or_lin assert "sam@example.com" not in llm.prompts[-1] and "7700 900456" not in llm.prompts[-1] -@pytest.mark.xfail(strict=True, reason="skill groups after the sixth are dropped (doc_render._MAX_SKILL_GROUPS)") def test_every_skill_in_the_profile_reaches_the_cv(llm): - html = doc_render.render_cv_html(_tailored_cv()) + html = doc_render.render_cv_html(_tailored_cv()) # the model failed: the profile's 8 groups as they are skills = html.split("

Skills

")[1].split("")[0] missing = [s for grp in GRADUATE["skills"] for s in grp["skills"] if s not in skills] - assert missing == [] + assert missing == [] and skills.count('class="skill-row"') == 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.""" + 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"], +} + + +def test_the_models_regrouping_is_used_when_it_keeps_every_skill(llm): + llm.reply = _regrouping({**ALL_SKILLS, "Languages": ["python", "TypeScript", "Java", "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] + + +@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"), +]) +def test_a_regrouping_that_changes_the_skills_is_ignored(llm, change): + groups = copy.deepcopy(ALL_SKILLS) + change(groups) + llm.reply = _regrouping(groups) + assert _tailored_cv()["skills"] == agent._persona_to_cvdoc(GRADUATE)["skills"] + + +@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"], ["A", "B", "C"]), +]) +def test_extra_skill_groups_fold_into_other(categories, rows): + groups = [{"category": c, "items": [f"{c}1", f"{c}2", "Shared"]} for c in categories] + out = doc_render._skill_rows(groups) + assert [r["category"] for r in out] == rows + every = {s for g in groups for s in g["items"]} + assert {s for r in out for s in r["items"]} == every + if len(categories) > doc_render.MAX_SKILL_GROUPS: + assert out[-1]["items"].count("Shared") == 1 # folded once, not once per group def test_length_limits_hold_whatever_the_model_returns(): @@ -234,11 +284,80 @@ def test_length_limits_hold_whatever_the_model_returns(): 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["skills"][0]["items"]) == 14 + 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(".") +# ── Fitting a CV to its page target ───────────────────────────────────────────── + +def _one_role_junior() -> dict: + cv = _full_cvdoc("junior") + cv["experience"] = cv["experience"][:1] + return cv + + +@pytest.fixture +def pages(monkeypatch): + """Stand-in for WeasyPrint's page count: `pages.rule(html) -> int`; every measured step is recorded.""" + class Pages: + rule = staticmethod(lambda html: 1) + steps: list[str] = [] + + def count(html): + Pages.steps.append(re.search(r"", html).group(1) or "") + return Pages.rule(html) + monkeypatch.setattr(doc_render, "_page_count", count) + return Pages + + +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 + + +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) + html = doc_render.render_cv_html(_one_role_junior()) + assert pages.steps == ["", "tight", "tight small"] + assert html.count("
  • ") == 4 and "Technologies:" in html + + +def test_trimming_goes_tech_lists_then_fourth_bullets_then_descriptions(pages): + def rule(html): # fits once each role has at most 3 bullets + return 1 if html.count("
  • ") <= 3 else 2 + pages.rule = staticmethod(rule) + cv = _one_role_junior() + for p in cv["projects"]: + p["description"] = "What it does. How it works." + html = doc_render.render_cv_html(cv) + assert pages.steps == ["", "tight", "tight small", "tight small", "tight small"] + assert "Technologies:" not in html and html.count("
  • ") == 3 + assert "What it does. How it works." in html # descriptions kept whole + + +def test_when_nothing_fits_every_trim_is_applied_and_logged(pages, caplog): + pages.rule = staticmethod(lambda html: 2) + cv = _one_role_junior() + for p in cv["projects"]: + p["description"] = "What it does. How it works." + html = doc_render.render_cv_html(cv) + assert len(pages.steps) == 6 and "Technologies:" not in html and html.count("
  • ") == 3 + assert "What it does." in html and "How it works." not in html + assert "still runs to 2 pages (target 1)" in caplog.text + + +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 == [""] + + +def test_the_preview_is_fitted_like_the_pdf(client, pages): + pages.rule = staticmethod(lambda html: 1 if 'class="tight"' in html else 2) + html = client.post("/documents/preview", json={"type": "cv", "doc": _one_role_junior()}).json()["html"] + assert '' in html + + def test_text_is_escaped_not_rendered_as_markup(): cv = {"name": "Sam ", "experience": [{"title": "Intern", "bullets": ["Cut p99 latency to <50ms & halved cost"]}]} @@ -325,7 +444,9 @@ 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): - pdf = _weasy("cv", agent._persona_to_cvdoc(GRADUATE) | {"seniority": "junior"}) + cv = agent._persona_to_cvdoc(GRADUATE) | {"seniority": "junior"} + 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 @@ -335,7 +456,6 @@ def test_the_graduate_cv_is_one_page_in_the_template_font_with_live_links(pdf_di @needs_weasyprint -@pytest.mark.xfail(strict=True, reason="a full graduate CV runs about a fifth of a page onto page 2") 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.