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"

(.*?)

", html) + headings = [re.sub(r"&", "&", h) for h in headings] + # Each heading must come after the one before it (the word "experience" in a summary is no heading). + at, in_order = 0, True + for h in headings: + found = flat.find(_squash(h), at) + if found < 0: + in_order = False + break + at = found + len(_squash(h)) + add("Standard section headings, in order", in_order, ", ".join(headings) or "No headings") + skills = [s for g in data["skills"] for s in g["items"]] + lost = [s for s in skills if _squash(s) and _squash(s) not in flat] + add("Every skill reads back", not lost, f"{len(skills)} skills" if not lost else f"Not found: {', '.join(lost[:6])}") + roles = data["experience"] + undated = [r["title"] for r in roles if r.get("dates") and _squash(r["dates"]) not in flat] + add("Role dates read back", not undated, f"{len(roles)} roles" if not undated else f"Missing dates: {', '.join(undated)}") + target = data["page_target"] + add(f"Fits on {target} page{'s' if target != 1 else ''}", pages <= target, f"{pages} page{'s' if pages != 1 else ''}") + return {"pages": pages, "checks": checks} + + +def check_document(doc_type: str, doc: dict, persona: dict, job: dict | None = None, + template_id: str | None = None, ats: bool = True) -> dict: + job = job or {} + report = { + "keywords": check_keywords(doc_type, doc, persona, job), + "facts": check_facts(doc_type, doc, persona, job), + "style": check_style(doc_type, doc), + "ats": None, + } + if ats: + try: + report["ats"] = check_ats(doc_type, doc, template_id) + except Exception: + logger.exception("ATS read-back failed (%s)", doc_type) + return report diff --git a/api/app/main.py b/api/app/main.py index 3ab91de..3b179b9 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -22,6 +22,7 @@ from . import auth from . import pdf_generator from . import doc_render +from . import doc_check from . import jobs_db from . import net_guard from . import seniority @@ -1031,6 +1032,27 @@ async def preview_document( return {"html": html} +@app.post("/documents/check") +async def check_document( + body: dict, + current_user: Annotated[auth.User, Depends(auth.get_current_user)], +): + """ + Check a tailored CV or cover letter before it goes out: the ad's keywords it shows or misses, + claims your persona doesn't back up, machine-sounding words, and the rendered PDF read back as + an applicant tracking system reads it. No model calls. + """ + doc_type = body.get("type", "cv") + if doc_type not in ("cv", "cover_letter"): + raise HTTPException(status_code=400, detail="type must be 'cv' or 'cover_letter'") + doc = body.get("doc") or {} + job = body.get("job") or {} + persona = tracker.get_user_persona(current_user.id) + return await asyncio.to_thread( + doc_check.check_document, doc_type, doc, persona, job, body.get("template_id"), bool(body.get("ats", True)), + ) + + from fastapi.responses import FileResponse # noqa: E402 diff --git a/api/tests/test_doc_check.py b/api/tests/test_doc_check.py new file mode 100644 index 0000000..5fedb1a --- /dev/null +++ b/api/tests/test_doc_check.py @@ -0,0 +1,225 @@ +"""Document checks: keywords against the ad, claims the persona doesn't back, style, ATS read-back.""" +import asyncio +import copy +import json + +import pytest + +from app import agent, doc_check, tracker +from tests.test_documents import GRADUATE, needs_weasyprint + +AD = { + "title": "Graduate Backend Engineer", + "company": "Northwind Labs", + "description": "You'll build Python and FastAPI services on PostgreSQL, deployed with Docker and Kubernetes. " + "Experience with Redis or Kafka is a plus. Our team of 40 engineers ships daily.", +} + + +def _cv(**changes) -> dict: + cv = agent._persona_to_cvdoc(copy.deepcopy(GRADUATE)) + cv.update(changes) + return cv + + +# ── Matching terms and numbers ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("text, expected", [ + ("REST APIs in Python", {"REST", "Python"}), + ("the rest of the team", set()), # an ordinary word, not REST + ("JavaScript, not Java", {"JavaScript", "Java"}), + ("PostgreSQL and MySQL", {"PostgreSQL", "MySQL"}), # no bare SQL inside either name + ("Postgres with k8s", {"PostgreSQL", "Kubernetes"}), # aliases map to one name + ("Go and gRPC; the go-live date", {"Go"}), + ("GitHub Actions", {"GitHub Actions"}), # not Git + ("C++ and C# on .NET", {"C++", "C#", ".NET"}), + ("Next, we ship", set()), +]) +def test_terms_are_matched_as_whole_names(text, expected): + assert doc_check.terms_in(text) == expected + + +def test_numbers_skip_years_and_dates_and_read_units(): + found = dict(doc_check.numbers_in("From 06/2024 in 2024: 40k readings, 52% to 81%, £2m, 2,500 users, 10x, " + "300+ people, GPT-4 on S3")) + assert found == {"40k": 40_000, "52%": 52, "81%": 81, "£2m": 2_000_000, "2,500": 2500, "10x": 10, "300+": 300} + + +# ── Keywords ────────────────────────────────────────────────────────────────────────────────── + +def test_keywords_split_into_shown_worth_adding_and_gaps(): + cv = _cv(summary="Computer Science graduate who builds Python and FastAPI services.", experience=[], projects=[]) + cv["skills"] = [{"category": "Backend", "items": ["Python", "FastAPI", "PostgreSQL"]}] + kw = doc_check.check_keywords("cv", cv, GRADUATE, AD) + assert kw["matched"] == ["FastAPI", "PostgreSQL", "Python"] + # Docker and Redis are in the persona, so the CV can show them; Kubernetes and Kafka are real gaps. + assert kw["missing_have"] == ["Docker", "Redis"] + assert kw["gaps"] == ["Kafka", "Kubernetes"] + assert kw["score"] == 60 # 3 of the 5 the candidate has + + +# ── Facts ───────────────────────────────────────────────────────────────────────────────────── + +def test_claims_the_persona_backs_are_not_flagged(): + cv = _cv() + cv["experience"][0]["bullets"] = ["Built a FastAPI service ingesting 40k sensor readings a day into PostgreSQL, " + "and cut dashboard load time from 6 s to 1.5 s with Redis caching."] + assert doc_check.check_facts("cv", cv, GRADUATE, AD) == [] + + +def test_invented_numbers_technologies_employers_and_projects_are_flagged(): + cv = _cv() + cv["experience"][0]["bullets"] = ["Cut latency by 35% by moving the service to Kubernetes."] + cv["experience"].append({"title": "Engineer", "employer": "Initech", "bullets": []}) + cv["projects"].append({"name": "Quantum Ledger", "description": "A ledger.", "tech": ["Rust"]}) + found = {(f["kind"], f["value"]) for f in doc_check.check_facts("cv", cv, GRADUATE, AD)} + assert found == {("number", "35%"), ("technology", "Kubernetes"), ("employer", "Initech"), + ("project", "Quantum Ledger"), ("technology", "Rust")} + + +def test_a_letter_may_quote_the_ad(): + letter = {"paragraphs": ["Your team of 40 engineers runs Kubernetes, and I have shipped FastAPI services.", + "I raised test coverage to 95%."]} + found = [(f["kind"], f["value"]) for f in doc_check.check_facts("cover_letter", letter, GRADUATE, AD)] + # The ad's own number and stack are fine to mention; a coverage figure the persona lacks isn't. + assert found == [("number", "95%")] + + +def test_findings_name_the_field_they_are_in(): + cv = _cv() + cv["summary"] = "Graduate with 7 years of Kotlin." + f = doc_check.check_facts("cv", cv, GRADUATE, AD) + assert {(x["field"], x["label"], x["value"]) for x in f} == {("summary", "Summary", "7"), ("summary", "Summary", "Kotlin")} + + +def test_machine_sounding_words_are_flagged(): + letter = {"paragraphs": ["I leveraged robust pipelines.", "A seamless handover."]} + words = [(f["field"], f["value"].lower()) for f in doc_check.check_style("cover_letter", letter)] + assert words == [("paragraphs.0", "leveraged"), ("paragraphs.0", "robust"), ("paragraphs.1", "seamless")] + + +# ── Endpoint and ATS read-back ──────────────────────────────────────────────────────────────── + +def test_check_endpoint_uses_the_signed_in_persona(client, user): + tracker.update_user_persona(user, copy.deepcopy(GRADUATE)) + cv = _cv() + cv["experience"][0]["bullets"] = ["Shipped 12 releases."] + r = client.post("/documents/check", json={"type": "cv", "doc": cv, "job": AD, "ats": False}) + assert r.status_code == 200 + body = r.json() + assert [(f["kind"], f["value"]) for f in body["facts"]] == [("number", "12")] + assert "Kubernetes" in body["keywords"]["gaps"] + assert body["ats"] is None + assert client.post("/documents/check", json={"type": "memo", "doc": {}}).status_code == 400 + + +@needs_weasyprint +def test_ats_reads_the_cv_back_from_its_pdf(): + report = doc_check.check_ats("cv", {**_cv(), "seniority": "junior"}) + by_name = {c["check"]: c for c in report["checks"]} + assert all(c["ok"] for c in report["checks"]), report + assert {"Text can be read", "Your name is at the top", "Email can be read", "Phone can be read", + "Standard section headings, in order", "Every skill reads back", "Role dates read back", + "Fits on 1 page"} <= set(by_name) + assert report["pages"] == 1 + + +@needs_weasyprint +def test_ats_reads_the_letter_back(): + letter = {"candidate_name": "Sam Okafor", "salutation": "Dear Hiring Manager,", "signoff": "Yours faithfully,", + "paragraphs": ["I build backend services that keep working under uneven load. " * 3, + "Northwind's logistics APIs are the kind of problem I want to work on. " * 3]} + report = doc_check.check_ats("cover_letter", letter) + assert all(c["ok"] for c in report["checks"]), report + + +# ── Generation: skills top-up and the targeted revision ────────────────────────────────────── + +@pytest.fixture +def replies(monkeypatch): + """The model's replies in order (one per call); the prompts it was sent are kept too.""" + import asyncio as _asyncio # noqa: F401 + + class Q: + queue: list[str] = [] + prompts: list[str] = [] + + async def fake_llm(system, user, max_tokens=2048): + Q.prompts.append(system + "\n" + user) + return Q.queue.pop(0) if Q.queue else "{}" + + async def no_research(*args, **kwargs): + return "" + monkeypatch.setattr(agent, "_llm", fake_llm) + monkeypatch.setattr(agent, "_research_company", no_research) + return Q + + +def _model_cv(bullet: str) -> dict: + return { + "name": "Sam Okafor", "headline": "", "contact": {}, + "summary": "Computer Science graduate who builds Python and FastAPI services.", + "skills": [{"category": "Languages", "items": ["Python", "TypeScript", "Java", "SQL"]}, + {"category": "Backend", "items": ["FastAPI", "Node.js", "Spring Boot"]}, + {"category": "Frontend", "items": ["React", "Next.js", "Tailwind CSS"]}, + {"category": "Data", "items": ["PostgreSQL", "pandas"]}, + {"category": "Testing", "items": ["pytest", "Jest", "Playwright"]}, + {"category": "Tools", "items": ["Git", "GitHub Actions", "CI/CD"]}], + "experience": [{"title": "Software Engineering Intern", "employer": "Brightwave Analytics", + "start_date": "06/2024", "end_date": "09/2024", "bullets": [bullet]}], + "education": [], "certifications": [], + "projects": [{"name": "Pantry Planner", "description": "A meal planner matching 12,000 recipes.", "tech": ["FastAPI"]}], + } + + +def _text_reply(bullet: str) -> str: + return json.dumps({"summary": "Computer Science graduate who builds Python and FastAPI services.", + "experience": [{"title": "Software Engineering Intern", "employer": "Brightwave Analytics", + "bullets": [bullet]}], + "projects": [{"name": "Pantry Planner", "description": "A meal planner matching 12,000 recipes.", + "tech": ["FastAPI"]}]}) + + +def _generate_cv() -> dict: + return asyncio.run(agent.execute_tool("generate_tailored_cv", {"job": AD, "persona": GRADUATE}))["cv"] + + +def test_skills_the_ad_wants_and_the_candidate_has_are_added(replies): + replies.queue = [json.dumps(_model_cv("Built a FastAPI service ingesting 40k readings a day into PostgreSQL."))] + cv = _generate_cv() + rows = {g["category"]: g["items"] for g in cv["skills"]} + # The ad asks for Docker and Redis; the profile lists both (Cloud, Data), the model left them out. + assert "Redis" in rows["Data"] + assert any("Docker" in items for items in rows.values()) + assert not any("Kubernetes" in items or "Kafka" in items for items in rows.values()) # real gaps stay out + assert not any("scikit-learn" in items for items in rows.values()) # still the model's choice, topped up + assert len(replies.prompts) == 1 # nothing flagged, so no revision call + + +def test_an_invented_figure_is_revised_away(replies): + replies.queue = [json.dumps(_model_cv("Cut API latency by 35% with Redis caching.")), + _text_reply("Cut dashboard load time from 6 s to 1.5 s with Redis caching.")] + cv = _generate_cv() + assert cv["experience"][0]["bullets"] == ["Cut dashboard load time from 6 s to 1.5 s with Redis caching."] + assert "35%" in replies.prompts[1] and "isn't in your persona" in replies.prompts[1] + + +def test_a_revision_that_invents_something_new_is_dropped(replies): + replies.queue = [json.dumps(_model_cv("Cut API latency by 35% with Redis caching.")), + _text_reply("Cut API latency with Redis caching across 9 services.")] + cv = _generate_cv() + assert cv["experience"][0]["bullets"] == ["Cut API latency by 35% with Redis caching."] # original kept + + +def test_a_revision_in_the_wrong_shape_is_dropped(replies): + replies.queue = [json.dumps(_model_cv("Cut API latency by 35% with Redis caching.")), json.dumps({"summary": "x"})] + assert _generate_cv()["experience"][0]["bullets"] == ["Cut API latency by 35% with Redis caching."] + + +def test_a_letter_with_a_flagged_word_is_revised(replies): + first = {"salutation": "Dear Hiring Manager,", "signoff": "Yours faithfully,", "headline": "Graduate", + "paragraphs": ["I leverage FastAPI daily.", "Your team of 40 engineers ships daily."]} + replies.queue = [json.dumps(first), + json.dumps({"paragraphs": ["I use FastAPI daily.", "Your team of 40 engineers ships daily."]})] + letter = asyncio.run(agent.execute_tool("generate_cover_letter", {"job": AD, "persona": GRADUATE}))["letter"] + assert letter["paragraphs"] == ["I use FastAPI daily.", "Your team of 40 engineers ships daily."] diff --git a/frontend/e2e/api.ts b/frontend/e2e/api.ts index 0977d55..c859389 100644 --- a/frontend/e2e/api.ts +++ b/frontend/e2e/api.ts @@ -1,6 +1,8 @@ import { expect, type Page, type Request } from "@playwright/test"; type Reply = { status?: number; json: unknown }; +/** A server-sent event stream: each object is sent as one `data:` line. */ +type Stream = { sse: object[] }; /** The JSON body, { status, json }, or a function of the request returning either. */ type Mock = ((request: Request) => unknown) | object; @@ -22,6 +24,10 @@ export async function mockApi(page: Page, handlers: Record) { } const mock = handlers[key]; const reply = typeof mock === "function" ? mock(request) : mock; + if (typeof reply === "object" && reply !== null && "sse" in reply) { + const body = (reply as Stream).sse.map((ev) => `data: ${JSON.stringify(ev)}\n\n`).join(""); + return route.fulfill({ status: 200, headers: { "content-type": "text/event-stream" }, body }); + } const isReply = typeof reply === "object" && reply !== null && "json" in reply; await route.fulfill(isReply ? { status: (reply as Reply).status ?? 200, json: (reply as Reply).json } : { status: 200, json: reply }); diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index 351479b..482f0cc 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -216,3 +216,45 @@ test("the theme switch applies dark mode and remembers it", async ({ page }) => await expect(page.locator("html")).not.toHaveClass(/\bdark\b/); api.expectAllMocked(); }); + +test("the job workspace tailors a CV and checks it against the persona", async ({ page }) => { + await signIn(page); + const job = { ...JOBS[0], description: "Python and FastAPI services on PostgreSQL. Docker a plus." }; + await page.addInitScript((j) => sessionStorage.setItem("job_1", JSON.stringify(j)), job); + const cv = { + name: "Sam Okafor", contact: { email: "sam@example.com" }, summary: "Graduate who builds Python services.", + skills: [{ category: "Backend", items: ["Python", "FastAPI"] }], + experience: [{ title: "Intern", employer: "Brightwave", bullets: ["Cut latency by 35%."] }], + education: [], projects: [], certifications: [], + }; + const checked: unknown[] = []; + const api = await mockApi(page, { + ...SHELL, + "GET /api/jobs/1/sponsorship": { sponsorship: "offered", evidence: "", clearance: "", licensed_sponsor: false, sponsor_name: "", mark: "" }, + "POST /api/jobs/analyze": { sse: [{ type: "job_fit", ranked_jobs: [{ fit_score: 84, level_match: "strong", + matched_skills: ["Python"], skill_gaps: ["Kubernetes"], reasoning: "Strong backend overlap." }] }] }, + "POST /api/jobs/tailored-cv": { sse: [{ type: "tailored_cv", cv }] }, + "POST /api/documents/preview": { html: "CV" }, + "POST /api/documents/check": (request) => { + checked.push(request.postDataJSON()); + return { + keywords: { matched: ["FastAPI", "Python"], missing_have: ["Docker"], gaps: ["PostgreSQL"], in_ad: 4, score: 67 }, + facts: [{ kind: "number", field: "experience.0.bullets.0", label: "Experience: Intern at Brightwave", + value: "35%", message: "“35%” isn't in your persona" }], + style: [], ats: { pages: 1, checks: [{ check: "Text can be read", ok: true, detail: "212 words" }] }, + }; + }, + }); + await page.goto("/jobs/1"); + await expect(page.getByRole("heading", { name: "Strong fit" })).toBeVisible(); + await page.getByRole("button", { name: "Continue to tailored CV" }).click(); + + const checks = page.getByRole("button", { name: /^Checks/ }); + await expect(checks).toContainText("1 thing to check"); + await expect(checks).toContainText("Keywords 2 of 3 you have"); + await checks.click(); + await expect(page.getByText("“35%” isn't in your persona")).toBeVisible(); + await expect(page.getByText("Docker", { exact: true })).toBeVisible(); + expect(checked[0]).toMatchObject({ type: "cv", doc: { name: "Sam Okafor" }, job: { title: job.title } }); + api.expectAllMocked(); +}); diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx index 11c78c6..3580db9 100644 --- a/frontend/src/app/jobs/[id]/page.tsx +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -9,6 +9,7 @@ import SponsorshipPanel, { useSponsorship, VERDICT_TONE, viewVerdict } from "@/c import DocEditor, { CVDoc, LetterDoc, letterToText } from "@/components/DocEditor"; import ReviewLayout, { AgentNotes, type LogLine } from "@/components/ReviewLayout"; import JobDescription from "@/components/JobDescription"; +import DocChecks from "@/components/DocChecks"; import Icon from "@/components/ui/Icon"; import TopBar, { Page } from "@/components/ui/TopBar"; import StepTracker from "@/components/ui/StepTracker"; @@ -533,6 +534,7 @@ export default function JobDetailPage() { {docToolbar("cv")} + {cvDoc && !streamingCv && } {streamingCv ? docSkeleton("Tailoring your CV for this role…") : cvDoc ? ( { setCvDoc(c); setCvDocId(null); }} /> ) : ( @@ -560,6 +562,7 @@ export default function JobDetailPage() { {docToolbar("cover_letter")} + {letterDoc && !streamingLetter && } {streamingLetter ? docSkeleton("Writing your cover letter…") : letterDoc ? ( { setLetterDoc(l); setLetterDocId(null); }} /> diff --git a/frontend/src/components/DocChecks.tsx b/frontend/src/components/DocChecks.tsx new file mode 100644 index 0000000..f59ee1d --- /dev/null +++ b/frontend/src/components/DocChecks.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; +import { authFetch } from "@/app/api"; +import Icon from "./ui/Icon"; +import { Chip, cx, Spinner } from "./ui/kit"; +import type { Job } from "@/lib/types"; + +interface Finding { kind: string; field: string; label: string; value: string; message: string } +interface AtsCheck { check: string; ok: boolean; detail: string } +export interface DocReport { + keywords: { matched: string[]; missing_have: string[]; gaps: string[]; in_ad: number; score: number | null }; + facts: Finding[]; + style: Finding[]; + ats: { pages: number; checks: AtsCheck[] } | null; +} + +function Pill({ tone, children }: { tone: "ok" | "warn" | "neutral"; children: React.ReactNode }) { + return ( + + {tone === "ok" ? : tone === "warn" ? : null} + {children} + + ); +} + +/** + * The document check for a tailored CV or cover letter: the ad's keywords it shows (CV only), + * claims your persona doesn't back, machine-sounding words, and the PDF read back as an applicant + * tracking system reads it. Re-runs a moment after each edit. + */ +export default function DocChecks({ kind, doc, job, templateId = "classic" }: { + kind: "cv" | "cover_letter"; doc: object; job: Job; templateId?: string; +}) { + const [report, setReport] = useState(null); + const [checking, setChecking] = useState(false); + const [failed, setFailed] = useState(false); + const [open, setOpen] = useState(false); + const panelId = useId(); + + useEffect(() => { + let live = true; + const t = setTimeout(async () => { + setChecking(true); + try { + const res = await authFetch("/api/documents/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: kind, doc, template_id: templateId, + job: { title: job.title, company: job.company, description: job.description ?? "" } }), + }); + if (!live) return; + if (res.ok) { + setReport(await res.json()); + setFailed(false); + } else { + setFailed(true); + } + } catch { + if (live) setFailed(true); + } finally { + if (live) setChecking(false); + } + }, 1200); + return () => { live = false; clearTimeout(t); }; + }, [kind, doc, job.title, job.company, job.description, templateId]); + + if (!report) { + return ( +
+ {failed ? "The checks couldn't run. Your document is unaffected." : <> Checking the document…} +
+ ); + } + + const kw = report.keywords; + const claims = [...report.facts, ...report.style]; + const atsFails = report.ats?.checks.filter((c) => !c.ok) ?? []; + const showKeywords = kind === "cv" && kw.in_ad > 0; + + return ( +
+ + + {open && ( +
+ {showKeywords && ( +
+

Keywords from the ad

+ {kw.matched.length > 0 && ( + + {kw.matched.map((t) => {t})} + + )} + {kw.missing_have.length > 0 && ( + <> + In your persona but not on this CV. Add them where they’re true: + + {kw.missing_have.map((t) => {t})} + + + )} + {kw.gaps.length > 0 && ( + <> + Not in your persona, so left out: + + {kw.gaps.map((t) => {t})} + + + )} +
+ )} +
+

Claims and wording

+ {claims.length === 0 ? ( +

Every number, technology, employer and project is in your persona, and no + words read as machine-written.

+ ) : ( +
    + {claims.map((f, i) => ( +
  • + + {f.label}: {f.message} +
  • + ))} +
+ )} +
+ {report.ats && ( +
+

Read back from the PDF

+
    + {report.ats.checks.map((c) => ( +
  • + + {c.check} · {c.detail} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +}