From 379228792a474f001aa4c9ff790cee62a2825c0c Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Fri, 16 Oct 2026 02:28:18 +0100 Subject: [PATCH 1/2] feat(api): check tailored CVs and letters against the persona and the ad A new doc_check module, with no model calls, reports for a CV or cover letter: - the ad's technical terms the document shows, the ones the candidate has but it leaves out, and real gaps (never added); - numbers, technologies, employers and project names the persona doesn't contain, field by field (a letter may also quote the ad); - words recruiters read as machine-written; - the rendered PDF read back as plain text, as an ATS reads it: name, contact, section headings in order, every skill, role dates, page count. POST /documents/check returns the report for the signed-in user. Generation uses it. A tailored CV gets the ad's terms that are among the candidate's own skills added to the right skills row. When the check flags a claim or a word, one targeted revision runs on the CV text or the letter's paragraphs, and is kept only if it clears findings without adding new ones. On the real model, 5 of 5 CVs came back clean and 2 of 5 letters had one flagged claim, which the revision cleared. --- api/app/agent.py | 186 ++++++++++++++++++- api/app/doc_check.py | 353 ++++++++++++++++++++++++++++++++++++ api/app/main.py | 22 +++ api/tests/test_doc_check.py | 225 +++++++++++++++++++++++ 4 files changed, 780 insertions(+), 6 deletions(-) create mode 100644 api/app/doc_check.py create mode 100644 api/tests/test_doc_check.py 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."] From 7c077aa8d9c32dc4e198890d3a5e3b283f1164a7 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Fri, 16 Oct 2026 02:28:18 +0100 Subject: [PATCH 2/2] feat(frontend): document checks in the job workspace The tailored CV and cover letter steps show a Checks strip that re-runs a moment after each edit: keywords from the ad (CV only), claims to check and whether the PDF reads cleanly in an ATS. Details list the keywords shown, the ones you have but the CV leaves out, real gaps, each flagged claim with where it is, and every ATS read-back check. The e2e mocks can now reply with an event stream, and a new smoke test walks the workspace from fit analysis to a checked tailored CV. --- frontend/e2e/api.ts | 6 + frontend/e2e/smoke.spec.ts | 42 +++++++ frontend/src/app/jobs/[id]/page.tsx | 3 + frontend/src/components/DocChecks.tsx | 169 ++++++++++++++++++++++++++ 4 files changed, 220 insertions(+) create mode 100644 frontend/src/components/DocChecks.tsx 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} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +}