diff --git a/api/app/agent.py b/api/app/agent.py index 7a16cf3..6269735 100644 --- a/api/app/agent.py +++ b/api/app/agent.py @@ -1,4 +1,5 @@ import asyncio +import copy import json import logging import os @@ -10,7 +11,7 @@ from openai import AsyncOpenAI, BadRequestError -from . import azure_config, doc_render +from . import azure_config, doc_check, doc_render from .company_names import normalise_company_name as _normalise_company_name from . import http_client from . import tracker @@ -554,6 +555,140 @@ def norm(s: str) -> str: return out +_MAX_ROW_SKILLS = 8 + + +def _top_up_skills(skills: list[dict], base: list[dict], cv: dict, job: dict) -> tuple[list[dict], list[str]]: + """ + Add the ad's technical terms that the candidate lists as skills but the tailored CV leaves out, + each to the row it belongs in (its profile group's, else the row holding most of its group). + Only the profile's own skills, in its spelling, so nothing is invented. Returns (rows, added). + """ + ad = doc_check.terms_in(f"{job.get('title', '')}\n{job.get('description', '')}") + wanted = ad - doc_check.terms_in(doc_check.document_text("cv", {**cv, "skills": skills})) + if not wanted: + return skills, [] + rows = [{"category": g.get("category") or "Other", "items": list(g.get("items") or [])} for g in skills] + have = {str(s).lower() for r in rows for s in r["items"]} + added: list[str] = [] + for g in base: + mates = {str(x).lower() for x in g.get("items") or []} + for s in g.get("items") or []: + hit = doc_check.terms_in(str(s)) & wanted + if not hit or str(s).lower() in have: + continue + open_rows = [r for r in rows if len(r["items"]) < _MAX_ROW_SKILLS] + row = next((r for r in open_rows if r["category"].lower() == str(g.get("category") or "").lower()), None) + if row is None and open_rows: + best = max(open_rows, key=lambda r: len(mates & {str(x).lower() for x in r["items"]})) + row = best if mates & {str(x).lower() for x in best["items"]} else None + if row is None: + if len(rows) < doc_render.MAX_SKILL_GROUPS: + row = {"category": g.get("category") or "Other", "items": []} + rows.append(row) + elif open_rows: + row = open_rows[-1] + else: + continue + row["items"].append(s) + have.add(str(s).lower()) + wanted -= hit + added.append(str(s)) + return rows, added + + +def _finding_keys(findings: list[dict]) -> set[tuple[str, str]]: + return {(f["kind"], str(f["value"]).lower()) for f in findings} + + +def _issue_lines(findings: list[dict]) -> str: + lines = [] + for f in findings: + fix = ("use plain wording instead" if f["kind"] == "word" + else "remove it, or replace it with a fact the candidate data states") + lines.append(f"- {f['label']}: {f['message']} — {fix}") + return "\n".join(lines) + + +async def _revise_cv_text(cv: dict, findings: list[dict], candidate: dict, job: dict) -> dict | None: + """ + One targeted pass over the CV's free text to fix what the document check flagged: claims the + candidate data doesn't back, and machine-sounding words. Returns the CV with the revised summary, + bullets and project text, or None when the reply doesn't keep the CV's shape. + """ + text = { + "summary": cv.get("summary", ""), + "experience": [{"title": r.get("title", ""), "employer": r.get("employer", ""), "bullets": r.get("bullets", [])} + for r in cv.get("experience") or []], + "projects": [{"name": p.get("name", ""), "description": p.get("description", ""), "tech": p.get("tech", [])} + for p in cv.get("projects") or []], + } + data = await _llm_json( + system=( + "You check a tailored CV against the candidate's own data. Fix ONLY the listed issues and leave " + "every other word as it is. A number, technology, employer or project the candidate data doesn't " + "state must be removed or replaced by one it does state — never invent, estimate or round one. " + "Keep each bullet's meaning and length. Return ONLY JSON in exactly the shape you were given: " + '{"summary": str, "experience": [{"title", "employer", "bullets": [str]}], ' + '"projects": [{"name", "description", "tech": [str]}]}, same roles and projects in the same order.' + ), + user=(f"ISSUES TO FIX:\n{_issue_lines(findings)}\n\nCV TEXT (JSON):\n{json.dumps(text, ensure_ascii=False)}\n\n" + f"TARGET JOB: {job.get('title', '')} at {job.get('company', '')}\n\n" + f"CANDIDATE DATA (JSON):\n{json.dumps(candidate, ensure_ascii=False)}"), + max_tokens=2048, + label="revise_cv", + ) + if not isinstance(data, dict): + return None + roles, projects = data.get("experience"), data.get("projects") + if (not isinstance(roles, list) or len(roles) != len(text["experience"]) + or not isinstance(projects, list) or len(projects) != len(text["projects"])): + return None + out = copy.deepcopy(cv) + if isinstance(data.get("summary"), str) and data["summary"].strip(): + out["summary"] = data["summary"].strip() + for role, new in zip(out.get("experience") or [], roles): + bullets = new.get("bullets") if isinstance(new, dict) else None + if isinstance(bullets, list) and all(isinstance(b, str) for b in bullets): + role["bullets"] = [b.strip() for b in bullets if b.strip()] + for proj, new in zip(out.get("projects") or [], projects): + if not isinstance(new, dict): + continue + if isinstance(new.get("description"), str) and new["description"].strip(): + proj["description"] = new["description"].strip() + if isinstance(new.get("tech"), list): + proj["tech"] = [str(t).strip() for t in new["tech"] if str(t).strip()] + return out + + +async def _revise_letter_text(letter: dict, findings: list[dict], persona: dict, job: dict) -> dict | None: + """The cover-letter counterpart of _revise_cv_text: fixes the flagged paragraphs only.""" + data = await _llm_json( + system=( + "You check a cover letter against the candidate's own data and the job ad. Fix ONLY the listed " + "issues and leave every other word as it is. A number or technology that neither the candidate " + "data nor the job ad states must be removed or replaced by one they do state — never invent one. " + 'Return ONLY JSON: {"paragraphs": [str]}, the same number of paragraphs in the same order.' + ), + user=(f"ISSUES TO FIX:\n{_issue_lines(findings)}\n\n" + f"LETTER (JSON):\n{json.dumps({'paragraphs': letter.get('paragraphs', [])}, ensure_ascii=False)}\n\n" + f"JOB: {job.get('title', '')} at {job.get('company', '')}\nDESCRIPTION: {job.get('description', '')[:2500]}\n\n" + f"CANDIDATE PERSONA (JSON): {json.dumps(_llm_persona(persona), ensure_ascii=False)}"), + max_tokens=1024, + label="revise_letter", + ) + paragraphs = data.get("paragraphs") if isinstance(data, dict) else None + if (not isinstance(paragraphs, list) or len(paragraphs) != len(letter.get("paragraphs") or []) + or not all(isinstance(p, str) and p.strip() for p in paragraphs)): + return None + return {**letter, "paragraphs": [p.strip() for p in paragraphs]} + + +def _better(before: list[dict], after: list[dict]) -> bool: + """A revision is kept only when it clears findings and introduces none of its own.""" + return len(after) < len(before) and _finding_keys(after) <= _finding_keys(before) + + def _seniority(persona: dict) -> str: """ Classify a persona as 'junior' (recent grad / early career) or 'experienced'. @@ -1020,9 +1155,28 @@ async def execute_tool(name: str, tool_input: dict) -> dict: ) letter["signoff"] = "Yours faithfully," if _generic_salutation else "Yours sincerely," + # Numbers and tools the persona, the ad and the company research don't state, and + # machine-sounding words: one targeted revision, kept only if it clears them. + revision = "fallback" + if data: + findings = (doc_check.check_facts("cover_letter", letter, persona, job, company_research) + + doc_check.check_style("cover_letter", letter)) + revision = "clean" + if findings: + revised = await _revise_letter_text(letter, findings, persona, job) + if revised is None: + revision = f"{len(findings)} found, bad reply" + else: + after = (doc_check.check_facts("cover_letter", revised, persona, job, company_research) + + doc_check.check_style("cover_letter", revised)) + kept = _better(findings, after) + revision = f"{len(findings)}->{len(after)} {'kept' if kept else 'rejected'}" + if kept: + letter = revised + (logger.warning if data is None else logger.info)( - "cover_letter: llm_used=%s, paragraphs=%d, archetype=%s, level=%s | job=%r", - data is not None, len(letter["paragraphs"]), archetype, level, + "cover_letter: llm_used=%s, paragraphs=%d, archetype=%s, level=%s, revision=%s | job=%r", + data is not None, len(letter["paragraphs"]), archetype, level, revision, (job.get("title") or "")[:60], ) @@ -1217,15 +1371,35 @@ async def execute_tool(name: str, tool_input: dict) -> dict: cv["experience"] = _restore_role_dates(cv["experience"], base_cv["experience"]) if not cv.get("name"): cv["name"] = base_cv["name"] + # The ad's terms the candidate lists as skills but the model left out go into the skills rows. + added_skills: list[str] = [] + if chosen: + cv["skills"], added_skills = _top_up_skills(cv["skills"], base_cv["skills"], cv, job) + # Check the text against the candidate's data; one targeted revision when it flags something. + revision = "fallback" + if not used_fallback: + findings = doc_check.check_facts("cv", cv, persona, job) + doc_check.check_style("cv", cv) + revision = "clean" + if findings: + revised = await _revise_cv_text(cv, findings, llm_cv, job) + if revised is None: + revision = f"{len(findings)} found, bad reply" + else: + after = doc_check.check_facts("cv", revised, persona, job) + doc_check.check_style("cv", revised) + kept = _better(findings, after) + revision = f"{len(findings)}->{len(after)} {'kept' if kept else 'rejected'}" + if kept: + cv = revised cv["seniority"] = level # drives section order in the renderer # Diagnostic: confirms how often the bad-JSON fallback fires AND whether the # 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 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], + "tailored_cv: fallback=%s, level=%s, skills chosen=%s, added=%s, revision=%s, projects base=%d model=%d " + "final=%d %s | job=%r", + used_fallback, level, chosen is not None, added_skills, revision, 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_check.py b/api/app/doc_check.py new file mode 100644 index 0000000..b3bc97b --- /dev/null +++ b/api/app/doc_check.py @@ -0,0 +1,353 @@ +""" +Checks a tailored CV or cover letter before it goes out, against the candidate's own data and +the job ad. Deterministic, no model calls, so it can run on every edit: + +- keywords: the ad's technical terms, split into those the document has, those the candidate has + but the document leaves out (worth adding), and real gaps (never add those); +- facts: numbers, technologies, employers and project names in the document that the + candidate's data doesn't contain, so they may have been invented; +- style: words recruiters read as machine-written; +- ats: the rendered PDF read back as plain text, the way an applicant tracking system reads it. +""" +from __future__ import annotations + +import logging +import re + +logger = logging.getLogger(__name__) + +# ── Vocabulary ──────────────────────────────────────────────────────────────────────────────── +# Canonical name → extra spellings. Matching is case-insensitive and on word boundaries, except +# for the names in _CASE_SENSITIVE, which are also ordinary English words ("go", "swift", "spark"). + +_TERMS: dict[str, tuple[str, ...]] = { + # Languages + "Python": (), "Java": (), "JavaScript": ("JS",), "TypeScript": ("TS",), "C++": ("cpp",), "C#": ("csharp",), + "Go": ("Golang",), "Rust": (), "Ruby": (), "PHP": (), "Kotlin": (), "Swift": (), "Scala": (), "R": (), + "MATLAB": (), "SQL": (), "Bash": ("shell scripting",), "Haskell": (), "Elixir": (), "Dart": (), "Julia": (), + # Web + "React": ("React.js", "ReactJS"), "Next.js": ("NextJS",), "Vue": ("Vue.js", "VueJS"), "Angular": (), + "Svelte": (), "Node.js": ("NodeJS", "Node"), "Express.js": ("ExpressJS",), "Django": (), "Flask": (), + "FastAPI": (), "Spring Boot": ("Spring Framework",), "Ruby on Rails": ("Rails",), ".NET": ("dotnet", "ASP.NET"), + "GraphQL": (), "REST": ("RESTful", "REST API", "REST APIs"), "HTML": ("HTML5",), "CSS": ("CSS3",), + "Tailwind CSS": ("Tailwind",), "Redux": (), "WebSockets": ("WebSocket",), + # Data + "PostgreSQL": ("Postgres",), "MySQL": (), "SQLite": (), "MongoDB": ("Mongo",), "Redis": (), + "Elasticsearch": (), "DynamoDB": (), "Snowflake": (), "BigQuery": (), "Redshift": (), "Kafka": (), + "Spark": ("PySpark", "Apache Spark"), "Hadoop": (), "Airflow": ("Apache Airflow",), "dbt": (), + "pandas": (), "NumPy": (), "Databricks": (), "Tableau": (), "Power BI": ("PowerBI",), "Excel": (), + "Looker": (), "ETL": ("ELT",), "data pipelines": ("data pipeline",), "data modelling": ("data modeling",), + "data warehousing": ("data warehouse",), "statistics": ("statistical",), + # ML / AI + "PyTorch": (), "TensorFlow": (), "Keras": (), "scikit-learn": ("sklearn", "scikit learn"), "XGBoost": (), + "LightGBM": (), "Hugging Face": ("HuggingFace",), "LangChain": (), "LlamaIndex": (), "OpenCV": (), + "spaCy": (), "NLTK": (), "JAX": (), "MLflow": (), "machine learning": ("ML",), "deep learning": (), + "NLP": ("natural language processing",), "computer vision": (), "LLMs": ("LLM", "large language models", + "large language model"), "RAG": ("retrieval-augmented generation", "retrieval augmented generation"), + "reinforcement learning": (), "embeddings": ("embedding",), + # Cloud / DevOps + "AWS": ("Amazon Web Services",), "Azure": ("Microsoft Azure",), "GCP": ("Google Cloud", "Google Cloud Platform"), + "Docker": (), "Kubernetes": ("k8s",), "Terraform": (), "Ansible": (), "Jenkins": (), + "GitHub Actions": (), "GitLab CI": (), "CI/CD": ("CI CD", "continuous integration", "continuous delivery"), + "Linux": (), "Nginx": (), "serverless": (), "Prometheus": (), "Grafana": (), "Datadog": (), + "microservices": ("microservice",), "distributed systems": (), + # Testing / tools / practice + "pytest": (), "Jest": (), "Playwright": (), "Cypress": (), "Selenium": (), "JUnit": (), + "unit testing": ("unit tests",), "TDD": ("test-driven development",), "Git": (), "Jira": (), + "Figma": (), "Agile": (), "Scrum": (), "system design": (), "data structures": (), "algorithms": (), + "OOP": ("object-oriented programming", "object oriented programming"), "API design": (), +} + +# Ordinary words as well as technology names: matched only with this exact capitalisation. +_CASE_SENSITIVE = {"Go", "Swift", "Spark", "R", "Rust", "Ruby", "Dart", "Julia", "Excel", "Node", "Rails", + "Agile", "Scrum", "React", "Angular", "Flask", "Jest", "Looker", "Snowflake", "JS", "TS", + "ML", "REST", "RESTful", "Redux", "Elixir", "Scala", "Kotlin"} + + +# Practices and fields rather than tools: they count as the ad's keywords, but naming one isn't a +# factual claim the persona has to back word for word ("data pipelines" for a project that loads data). +_CONCEPTS = {"data pipelines", "data modelling", "data warehousing", "statistics", "machine learning", + "deep learning", "computer vision", "reinforcement learning", "NLP", "embeddings", "microservices", + "distributed systems", "unit testing", "TDD", "system design", "data structures", "algorithms", + "OOP", "API design", "Agile", "Scrum", "serverless", "ETL", "REST"} + + +def _pattern(name: str) -> str: + # \b doesn't work next to symbols (C++, C#, .NET, CI/CD), so look for non-word-ish neighbours. + body = re.escape(name).replace(r"\ ", r"[\s-]+") + return rf"(? set[str]: + """Canonical technical terms that appear in the text.""" + found = set() + if not text: + return found + for canon, rx in _MATCHERS: + if canon not in found and rx.search(text): + found.add(canon) + return found + + +# ── Numbers ─────────────────────────────────────────────────────────────────────────────────── + +_NUMBER_RE = re.compile( + r"(? list[tuple[str, float]]: + """(as written, value) for each number in the text, leaving out years and calendar dates.""" + out = [] + text = text or "" + for m in _NUMBER_RE.finditer(text): + if re.match(r"/\d", text[m.end():m.end() + 2]): + continue # the month of a date like 06/2024 + whole = m.group(2).replace(",", "") + value = float(f"{whole}.{m.group(3)}" if m.group(3) else whole) + suffix = (m.group(4) or "").strip().lower() + value *= _MULTIPLIER.get(suffix, 1) + if not m.group(3) and not suffix and "," not in m.group(2) and 1950 <= value <= 2099 and len(whole) == 4: + continue # a year + out.append((m.group(0).strip(), value)) + return out + + +def _same_number(a: float, b: float) -> bool: + return abs(a - b) <= max(1e-9, abs(b) * 1e-6) + + +# ── The candidate's own data ────────────────────────────────────────────────────────────────── + +def _flatten(value) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [s for v in value.values() for s in _flatten(v)] + if isinstance(value, (list, tuple)): + return [s for v in value for s in _flatten(v)] + if isinstance(value, (int, float)) and not isinstance(value, bool): + return [str(value)] + return [] + + +def persona_text(persona: dict) -> str: + """Everything the candidate has told us about their work: the source a document may draw on. + Contact details, credentials and equality data are left out; they're never claims.""" + parts = [persona.get("summary", ""), persona.get("custom_directives", "")] + for key in ("history", "education", "projects", "certifications", "story_bank", "skills"): + parts += _flatten(persona.get(key) or []) + parts += _flatten(persona.get("screening") or {}) + return "\n".join(p for p in parts if p) + + +def _norm(name: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip() + + +# ── Document text, field by field ───────────────────────────────────────────────────────────── + +def _cv_fields(cv: dict) -> list[tuple[str, str, str]]: + """(field id, human label, text) for every free-text field of a CV.""" + fields = [("summary", "Summary", cv.get("summary") or ""), ("headline", "Headline", cv.get("headline") or "")] + for i, r in enumerate(cv.get("experience") or []): + where = " at ".join(x for x in (r.get("title"), r.get("employer")) if x) or f"role {i + 1}" + for j, b in enumerate(r.get("bullets") or []): + fields.append((f"experience.{i}.bullets.{j}", f"Experience: {where}", b or "")) + for i, p in enumerate(cv.get("projects") or []): + name = p.get("name") or f"project {i + 1}" + fields.append((f"projects.{i}.description", f"Project: {name}", p.get("description") or "")) + fields.append((f"projects.{i}.tech", f"Project: {name} (technologies)", ", ".join(p.get("tech") or []))) + return [f for f in fields if f[2].strip()] + + +def _letter_fields(letter: dict) -> list[tuple[str, str, str]]: + return [(f"paragraphs.{i}", f"Paragraph {i + 1}", p) for i, p in enumerate(letter.get("paragraphs") or []) + if (p or "").strip()] + + +def document_text(doc_type: str, doc: dict) -> str: + """All the words in the document, including the skills section and names.""" + if doc_type == "cover_letter": + return "\n".join(_flatten([doc.get("paragraphs"), doc.get("job_title"), doc.get("headline")])) + return "\n".join(_flatten([doc.get(k) for k in ("summary", "headline", "skills", "experience", + "projects", "education", "certifications")])) + + +# ── Style ───────────────────────────────────────────────────────────────────────────────────── + +BANNED_WORDS = ("delve", "testament", "tapestry", "landscape", "foster", "underscore", "beacon", "synergy", + "pivotal", "intricate", "unleash", "elevate", "seamless", "robust", "leverage", "spearhead", + "cutting-edge", "passionate", "results-driven", "detail-oriented", "highly motivated") +_BANNED_RE = re.compile(r"\b(" + "|".join(re.escape(w) for w in BANNED_WORDS) + r")\w*", re.IGNORECASE) + + +# ── The checks ──────────────────────────────────────────────────────────────────────────────── + +def check_keywords(doc_type: str, doc: dict, persona: dict, job: dict) -> dict: + ad = terms_in(f"{job.get('title', '')}\n{job.get('description', '')}") + have_doc = terms_in(document_text(doc_type, doc)) + have_persona = terms_in(persona_text(persona)) + matched = sorted(ad & have_doc, key=str.lower) + missing_have = sorted((ad - have_doc) & have_persona, key=str.lower) + gaps = sorted(ad - have_doc - have_persona, key=str.lower) + in_reach = len(matched) + len(missing_have) + return { + "matched": matched, + "missing_have": missing_have, + "gaps": gaps, + "in_ad": len(ad), + # The share of the ad's terms the candidate has that the document shows. + "score": round(100 * len(matched) / in_reach) if in_reach else None, + } + + +def check_facts(doc_type: str, doc: dict, persona: dict, job: dict, extra_source: str = "") -> list[dict]: + """Claims the candidate's data doesn't support. Each finding names the field and the text. + `extra_source` is other material the document may quote, such as research on the company.""" + source = persona_text(persona) + source_numbers = [v for _, v in numbers_in(source)] + # A letter may also quote the ad (its team size, its founding year…); a CV shouldn't need to. + if doc_type == "cover_letter": + source_numbers += [v for _, v in numbers_in(job.get("description", ""))] + source_numbers += [v for _, v in numbers_in(extra_source)] + source_terms = terms_in(source) + where = "your persona or the ad" if doc_type == "cover_letter" else "your persona" + findings: list[dict] = [] + fields = _letter_fields(doc) if doc_type == "cover_letter" else _cv_fields(doc) + ad_terms = terms_in(f"{job.get('title', '')}\n{job.get('description', '')}") + for field, label, text in fields: + for written, value in numbers_in(text): + if not any(_same_number(value, v) for v in source_numbers): + findings.append({"kind": "number", "field": field, "label": label, "value": written, + "message": f"“{written}” isn't in {where}"}) + for term in sorted(terms_in(text) - source_terms - _CONCEPTS): + # Naming the employer's own stack in a letter ("your Kafka platform") isn't a claim. + if doc_type == "cover_letter" and term in ad_terms: + continue + findings.append({"kind": "technology", "field": field, "label": label, "value": term, + "message": f"{term} isn't in your persona"}) + if doc_type != "cover_letter": + employers = {_norm(r.get("employer", "")) for r in persona.get("history") or []} + for i, r in enumerate(doc.get("experience") or []): + if r.get("employer") and _norm(r["employer"]) not in employers: + findings.append({"kind": "employer", "field": f"experience.{i}.employer", "label": "Experience", + "value": r["employer"], "message": f"{r['employer']} isn't an employer in your persona"}) + projects = {_norm(p.get("name", "")) for p in persona.get("projects") or []} + for i, p in enumerate(doc.get("projects") or []): + name = _norm(p.get("name", "")) + if name and name not in projects and not any(name in q or q in name for q in projects if q): + findings.append({"kind": "project", "field": f"projects.{i}.name", "label": "Projects", + "value": p.get("name"), "message": f"“{p.get('name')}” isn't a project in your persona"}) + return findings + + +def check_style(doc_type: str, doc: dict) -> list[dict]: + fields = _letter_fields(doc) if doc_type == "cover_letter" else _cv_fields(doc) + out = [] + for field, label, text in fields: + for m in _BANNED_RE.finditer(text): + out.append({"kind": "word", "field": field, "label": label, "value": m.group(0), + "message": f"“{m.group(0)}” reads as machine-written"}) + return out + + +# ── ATS read-back ───────────────────────────────────────────────────────────────────────────── + +def _squash(text: str) -> str: + # PDF text breaks lines and may hyphenate; compare letters and digits only. + return re.sub(r"[^a-z0-9]+", "", (text or "").lower()) + + +def check_ats(doc_type: str, doc: dict, template_id: str | None = None) -> dict | None: + """Render the document to a PDF and read it back as plain text, as an ATS would. + None where the PDF renderer isn't available.""" + from . import doc_render + from .cv_parser import extract_text + + weasy = doc_render._weasyprint_html() + if weasy is None: + return None + html = doc_render.render_html(doc_type, doc, template_id) + document = weasy(string=html, base_url=doc_render._TEMPLATES_DIR).render() + pages = len(document.pages) + text = extract_text(document.write_pdf()) + flat = _squash(text) + checks: list[dict] = [] + + def add(name: str, ok: bool, detail: str): + checks.append({"check": name, "ok": bool(ok), "detail": detail}) + + words = len(text.split()) + add("Text can be read", words >= 40, f"{words} words read from the PDF") + if doc_type == "cover_letter": + data = doc_render.build_letter_template_data(doc) + add("Your name is on it", _squash(data["candidate_name"]) in flat, data["candidate_name"] or "No name") + missing = [i + 1 for i, p in enumerate(data["paragraphs"]) if _squash(p)[:60] not in flat] + add("Every paragraph reads back", not missing, + "All paragraphs found" if not missing else f"Paragraph {', '.join(map(str, missing))} not found") + add("Fits on one page", pages <= data["page_target"], f"{pages} page{'s' if pages != 1 else ''}") + return {"pages": pages, "checks": checks} + + data = doc_render.build_cv_template_data(doc) + add("Your name is at the top", _squash(data["name"]) in _squash(text[:300]), data["name"] or "No name") + contact = doc.get("contact") or {} + for key, label in (("email", "Email"), ("phone", "Phone")): + if contact.get(key): + add(f"{label} can be read", _squash(contact[key]) in flat, contact[key]) + headings = re.findall(r"
Every number, technology, employer and project is in your persona, and no + words read as machine-written.
+ ) : ( +