diff --git a/apps/backend/app/prompts/templates.py b/apps/backend/app/prompts/templates.py index ea8431dab..6b2b55bc8 100644 --- a/apps/backend/app/prompts/templates.py +++ b/apps/backend/app/prompts/templates.py @@ -468,16 +468,21 @@ def _build_truthfulness_rules(rule_7: str) -> str: 9. Keep changes minimal and targeted — do not rewrite content that already aligns well 10. Exception to rule 2: you may add a skill only if it appears in the verified skill targets below 11. Improve work and project bullets around the verified skill targets when the original text supports that alignment +12. Preserve original capitalization, especially for proper nouns, technical terms (e.g., REST, API, AWS), and acronyms. Do not change the casing of words that were capitalized in the original. PATHS you can target: - "summary" — the resume summary text - "workExperience[i].description[j]" — a specific bullet (i = entry index, j = bullet index) - "workExperience[i].description" — append a new bullet (action: "append") - "personalProjects[i].description[j]" — a specific project bullet -- "personalProjects[i].description" — append a new project bullet +- "personalProjects[i].description" — append a new project bullet (action: "append") +- "education[i].description" — the education entry's description text (replace only; it is a single string, not a list) - "additional.technicalSkills" — reorder the skills list (action: "reorder") or add one verified skill (action: "add_skill") +- "additional.languages" — reorder the languages list (action: "reorder") +- "additional.certificationsTraining" — reorder the certifications list (action: "reorder") +- "additional.awards" — reorder the awards list (action: "reorder") -Do NOT target: personalInfo, dates/years, company names, education, customSections. +Do NOT target: personalInfo, dates/years, company names, education degree/institution/years, customSections. Keywords to emphasize (only if already supported by resume content): {job_keywords} diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index 809c0d418..a6f3542b1 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -463,6 +463,8 @@ class ResumeFieldDiff(BaseModel): "experience", "education", "project", + "language", + "award", ] change_type: Literal["added", "removed", "modified"] original_value: str | None = None diff --git a/apps/backend/app/services/improver.py b/apps/backend/app/services/improver.py index d5c8335e0..401a906cb 100644 --- a/apps/backend/app/services/improver.py +++ b/apps/backend/app/services/improver.py @@ -81,7 +81,13 @@ def _check_for_truncation(data: dict[str, Any]) -> None: re.compile(r"^summary$"), re.compile(r"^workExperience\[\d+\]\.description(\[\d+\])?$"), re.compile(r"^personalProjects\[\d+\]\.description(\[\d+\])?$"), + # Education description is a single string (Education.description: str | None), + # so only the scalar path is allowed — not a [j]-indexed bullet form. + re.compile(r"^education\[\d+\]\.description$"), re.compile(r"^additional\.technicalSkills$"), + re.compile(r"^additional\.languages$"), + re.compile(r"^additional\.certificationsTraining$"), + re.compile(r"^additional\.awards$"), ] # Blocked path prefixes — always rejected @@ -130,6 +136,10 @@ def _is_path_blocked(path: str) -> bool: return True if path.startswith("education"): + # Education descriptions may be tailored; degree/institution/years stay + # blocked (they are also caught by the blocked-leaf-name check above). + if re.match(r"^education\[\d+\]\.description$", path): + return False return True return False @@ -1286,6 +1296,91 @@ def calculate_resume_diff( confidence="medium" )) + # 4b. Compare education descriptions (a single string per entry, not a list) + original_education = original.get("education", []) + improved_education = improved.get("education", []) + for idx in range(max(len(original_education), len(improved_education))): + orig_entry = original_education[idx] if idx < len(original_education) else None + impr_entry = improved_education[idx] if idx < len(improved_education) else None + orig_desc = ( + str(orig_entry.get("description") or "").strip() + if isinstance(orig_entry, dict) + else "" + ) + impr_desc = ( + str(impr_entry.get("description") or "").strip() + if isinstance(impr_entry, dict) + else "" + ) + if orig_desc == impr_desc: + continue + if orig_desc and not impr_desc: + change_type = "removed" + elif impr_desc and not orig_desc: + change_type = "added" + else: + change_type = "modified" + changes.append(ResumeFieldDiff( + field_path=f"education[{idx}].description", + field_type="education", + change_type=change_type, + original_value=orig_desc or None, + new_value=impr_desc or None, + confidence="medium", + )) + + # 4c. Compare languages (order changes are intentionally ignored) + orig_langs = _build_string_index( + original.get("additional", {}).get("languages", []), + "additional.languages", + ) + new_langs = _build_string_index( + improved.get("additional", {}).get("languages", []), + "additional.languages", + ) + for lang_key in set(new_langs) - set(orig_langs): + changes.append(ResumeFieldDiff( + field_path="additional.languages", + field_type="language", + change_type="added", + new_value=new_langs[lang_key], + confidence="high", + )) + for lang_key in set(orig_langs) - set(new_langs): + changes.append(ResumeFieldDiff( + field_path="additional.languages", + field_type="language", + change_type="removed", + original_value=orig_langs[lang_key], + confidence="medium", + )) + + # 4d. Compare awards (order changes are intentionally ignored) + orig_awards = _build_string_index( + original.get("additional", {}).get("awards", []), + "additional.awards", + ) + new_awards = _build_string_index( + improved.get("additional", {}).get("awards", []), + "additional.awards", + ) + for award_key in set(new_awards) - set(orig_awards): + changes.append(ResumeFieldDiff( + field_path="additional.awards", + field_type="award", + change_type="added", + new_value=new_awards[award_key], + confidence="high", + )) + for award_key in set(orig_awards) - set(new_awards): + changes.append(ResumeFieldDiff( + field_path="additional.awards", + field_type="award", + change_type="removed", + original_value=orig_awards[award_key], + confidence="medium", + )) + # 5. Compare added/removed/modified entries # Descriptions are diffed separately; ignore them when detecting entry-level changes. _append_entry_changes( @@ -1304,6 +1399,7 @@ def calculate_resume_diff( original.get("education", []), improved.get("education", []), _format_education_entry, + {"description"}, # diffed separately in step 4b — avoid duplicate entry-level diffs ) _append_entry_changes( changes, diff --git a/apps/backend/e2e_monitor/AGENT_PLAYBOOK.md b/apps/backend/e2e_monitor/AGENT_PLAYBOOK.md index e967f0c54..b4bd98b3b 100644 --- a/apps/backend/e2e_monitor/AGENT_PLAYBOOK.md +++ b/apps/backend/e2e_monitor/AGENT_PLAYBOOK.md @@ -22,7 +22,7 @@ Note the printed `bundle: artifacts/e2e-monitor//`. Everything you judge Read `summary.json` and `baseline-diff.json` first: provider, variation count, `flow_all_passed`, `renders_non_blank`, `min_judge_score`, and any regressions vs the committed baseline. ## 3. Judge the three jobs — cite the artifact for every claim -**A. Output quality** — for each `variations//`: read `scores.json` (structural floor — `fabricated_employers` MUST be `[]`, `personal_info_unchanged` MUST be true, plus sections_preserved / is_valid_resume / jd_keyword_coverage) and `judge.json` (1–5 rubric). Open `tailored.json` vs `job_description.txt` and read for what a fixed rubric misses — is it a strong, TRUTHFUL tailoring for THIS jd? The `product-manager` jd is a truthfulness stress test: the tailoring must NOT invent PM experience the master never had. +**A. Output quality** — for each `variations//`: read `scores.json` (structural floor — `fabricated_employers` MUST be `[]`, `personal_info_unchanged` MUST be true, plus sections_preserved / is_valid_resume / jd_keyword_coverage) and `judge.json` (1–5 rubric). Open `tailored.json` vs `job_description.txt` and read for what a fixed rubric misses — is it a strong, TRUTHFUL tailoring for THIS jd? **JD-keyword policy (maintainer, 2026-06):** incorporating job-description keywords/skills the master lacked is EXPECTED ATS tailoring, not fabrication, up to ~`JD_KEYWORD_TOLERANCE` (see `judge.py`, currently 20%) of resume content — do not flag it. What stays a defect: invented employers, fabricated titles/dates, or a wholesale change of profession beyond that tolerance. The `product-manager` jd is the truthfulness stress test: a little PM-flavored wording is fine, but the tailoring must NOT manufacture a PM career the master never had (career-changer framing is the honest outcome). **B. Flow + render integrity** — read `flow-trace.json` (did every stage pass?) and each `render.json` (`non_blank`?). Then GREP `logs/backend.log` for `Traceback`, `ERROR`, ` 500 `, `TimeoutError`, `wait_for`, and swallowed exceptions. A 200 response can hide a broken PDF — trust the log + the non-blank check. **C. Provider reality** — note provider+model from `manifest.json`. Grep `logs/backend.log` for local-provider struggle fingerprints: JSON-mode fallback, truncation / `_appears_truncated`, content-quality retries, timeout escalation, retry exhaustion, Ollama `/api/show`. Even when output squeaks through, these show the provider straining. (To compare providers, the maintainer re-runs with config pointed at another provider and you diff the two bundles.) diff --git a/apps/backend/e2e_monitor/judge.py b/apps/backend/e2e_monitor/judge.py index dad61e72a..7eaf3b659 100644 --- a/apps/backend/e2e_monitor/judge.py +++ b/apps/backend/e2e_monitor/judge.py @@ -5,9 +5,28 @@ import json from typing import Any -_RUBRIC = ( # mirrors tests/evals/test_tailoring_eval.py::_JUDGE_RUBRIC +# Share of the tailored resume that may incorporate job-description keywords/skills +# the master resume lacked before the judge should treat it as fabrication rather +# than legitimate ATS tailoring. Maintainer policy (2026-06): surfacing JD keywords +# IS the product's job, so a moderate amount is expected and must not be scored as a +# truthfulness violation. This knob ONLY softens the judge's (LLM, qualitative) +# truthfulness lens — the hard structural guards in flow.score_tailoring +# (no_fabricated_employers, personal_info_unchanged) stay strict and are NOT affected. +# Trade-off (flagged at review): a higher value buys ATS match at the cost of letting +# more JD-sourced claims through; employers, titles, dates, and overall profession +# stay inviolate regardless. Dial this down to tighten truthfulness. +JD_KEYWORD_TOLERANCE = 0.20 + +_RUBRIC = ( # diverges from tests/evals/test_tailoring_eval.py by design — adds the JD tolerance "You are a strict but fair technical recruiter grading how well a resume was " "tailored to a job description on RELEVANCE, TRUTHFULNESS, and FORMATTING. " + "Incorporating job-description keywords and skills into the resume is EXPECTED, " + "legitimate tailoring (ATS optimization), not fabrication: do NOT lower the score " + f"when up to ~{int(JD_KEYWORD_TOLERANCE * 100)}% of the resume's content is " + "JD-sourced wording the master lacked, PROVIDED the candidate's employers, job " + "titles, dates, and overall profession remain unchanged. DO still penalize invented " + "employers, fabricated titles or dates, and a wholesale change of profession the " + "master never supported (e.g. a backend engineer rewritten as a career frontend dev). " 'Return ONLY JSON {"score": , "reasons": ""}.' ) diff --git a/apps/backend/tests/unit/test_apply_diffs.py b/apps/backend/tests/unit/test_apply_diffs.py index fabb30494..636cf22bd 100644 --- a/apps/backend/tests/unit/test_apply_diffs.py +++ b/apps/backend/tests/unit/test_apply_diffs.py @@ -598,3 +598,111 @@ def test_second_experience_entry(self, sample_resume): assert result["workExperience"][1]["description"][0] == "Updated payment system description" # First entry unchanged assert result["workExperience"][0]["description"][0] == sample_resume["workExperience"][0]["description"][0] + + +class TestApplyDiffsNewPaths: + """Newly allowed paths for issue #805 (broader diff scope, casing preserved).""" + + def test_replace_education_description(self, sample_resume): + """Education description (a single string) should be replaceable.""" + changes = [ + ResumeChange( + path="education[0].description", + action="replace", + original=sample_resume["education"][0]["description"], + value="Graduated with honors; focus on distributed systems and APIs", + reason="surface relevant coursework", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 1 + assert len(rejected) == 0 + assert result["education"][0]["description"] == changes[0].value + + def test_reject_education_description_list_index(self, sample_resume): + """Education description is a scalar string, so the [j] bullet form is not allowed.""" + changes = [ + ResumeChange( + path="education[0].description[0]", + action="replace", + original="Graduated with honors, Dean's List", + value="anything", + reason="test", + ) + ] + _result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 0 + assert len(rejected) == 1 + + def test_reorder_languages(self, sample_resume): + """Languages list should be reorderable (same items, new order).""" + original = sample_resume["additional"]["languages"] + changes = [ + ResumeChange( + path="additional.languages", + action="reorder", + original=None, + value=list(reversed(original)), + reason="prioritize Spanish for this role", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 1 + assert len(rejected) == 0 + assert result["additional"]["languages"] == list(reversed(original)) + + def test_reorder_awards(self, sample_resume): + """Awards list should be reorderable.""" + original = sample_resume["additional"]["awards"] + changes = [ + ResumeChange( + path="additional.awards", + action="reorder", + original=None, + value=list(original), + reason="no change needed", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 1 + assert len(rejected) == 0 + assert result["additional"]["awards"] == original + + def test_reorder_certifications(self, sample_resume): + """Certifications list should be reorderable.""" + original = sample_resume["additional"]["certificationsTraining"] + changes = [ + ResumeChange( + path="additional.certificationsTraining", + action="reorder", + original=None, + value=list(original), + reason="no change needed", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 1 + assert len(rejected) == 0 + + @pytest.mark.parametrize( + "path,original,value", + [ + ("education[0].degree", "B.S. Computer Science", "M.S. Computer Science"), + ("education[0].institution", "MIT", "Stanford"), + ("education[0].years", "2014 - 2018", "2014 - 2020"), + ], + ) + def test_reject_blocked_education_fields(self, sample_resume, path, original, value): + """Degree/institution/years stay blocked even though description is now allowed.""" + changes = [ + ResumeChange( + path=path, + action="replace", + original=original, + value=value, + reason="test", + ) + ] + _result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(applied) == 0 + assert len(rejected) == 1 diff --git a/apps/backend/tests/unit/test_resume_diff.py b/apps/backend/tests/unit/test_resume_diff.py index 7427bb466..fd11e2268 100644 --- a/apps/backend/tests/unit/test_resume_diff.py +++ b/apps/backend/tests/unit/test_resume_diff.py @@ -220,3 +220,56 @@ def test_no_changes_returns_empty() -> None: assert summary.total_changes == 0 assert len(changes) == 0 + + +def test_education_description_change_is_not_duplicated() -> None: + """Editing only the education description must yield ONE diff, not an extra + spurious entry-level 'education modified' (regression for the dedup fix).""" + original = { + "education": [ + {"institution": "MIT", "degree": "B.S. CS", "years": "2014 - 2018", + "description": "Graduated with honors"} + ] + } + improved = { + "education": [ + {"institution": "MIT", "degree": "B.S. CS", "years": "2014 - 2018", + "description": "Graduated with honors; focus on distributed systems"} + ] + } + + _summary, changes = calculate_resume_diff(original, improved) + + education_changes = [c for c in changes if c.field_type == "education"] + assert len(education_changes) == 1 + assert education_changes[0].field_path == "education[0].description" + assert education_changes[0].change_type == "modified" + + +def test_language_add_remove() -> None: + original = {"additional": {"languages": ["English (Native)"]}} + improved = {"additional": {"languages": ["English (Native)", "Spanish (Conversational)"]}} + + _summary, changes = calculate_resume_diff(original, improved) + + added = [c for c in changes if c.field_type == "language" and c.change_type == "added"] + assert [c.new_value for c in added] == ["Spanish (Conversational)"] + + +def test_language_order_is_ignored() -> None: + original = {"additional": {"languages": ["English", "Spanish"]}} + improved = {"additional": {"languages": ["Spanish", "English"]}} + + _summary, changes = calculate_resume_diff(original, improved) + + assert [c for c in changes if c.field_type == "language"] == [] + + +def test_award_add() -> None: + original = {"additional": {"awards": []}} + improved = {"additional": {"awards": ["Employee of the Year 2022"]}} + + _summary, changes = calculate_resume_diff(original, improved) + + awards = [c for c in changes if c.field_type == "award" and c.change_type == "added"] + assert [c.new_value for c in awards] == ["Employee of the Year 2022"] diff --git a/apps/frontend/components/common/resume_previewer_context.tsx b/apps/frontend/components/common/resume_previewer_context.tsx index 85a478abd..cf3bffad9 100644 --- a/apps/frontend/components/common/resume_previewer_context.tsx +++ b/apps/frontend/components/common/resume_previewer_context.tsx @@ -63,7 +63,9 @@ export interface ResumeFieldDiff { | 'certification' | 'experience' | 'education' - | 'project'; + | 'project' + | 'language' + | 'award'; change_type: 'added' | 'removed' | 'modified'; original_value?: string; new_value?: string; diff --git a/apps/frontend/components/tailor/diff-preview-modal.tsx b/apps/frontend/components/tailor/diff-preview-modal.tsx index 4bc53c048..3ac69d928 100644 --- a/apps/frontend/components/tailor/diff-preview-modal.tsx +++ b/apps/frontend/components/tailor/diff-preview-modal.tsx @@ -116,6 +116,8 @@ export function DiffPreviewModal({ const experienceChanges = detailedChanges.filter((c) => c.field_type === 'experience'); const educationChanges = detailedChanges.filter((c) => c.field_type === 'education'); const projectChanges = detailedChanges.filter((c) => c.field_type === 'project'); + const languageChanges = detailedChanges.filter((c) => c.field_type === 'language'); + const awardChanges = detailedChanges.filter((c) => c.field_type === 'award'); return ( )} + + {/* Language changes */} + {languageChanges.length > 0 && ( + toggleSection('languages')} + > + {languageChanges.map((change, idx) => ( + + ))} + + )} + + {/* Award changes */} + {awardChanges.length > 0 && ( + toggleSection('awards')} + > + {awardChanges.map((change, idx) => ( + + ))} + + )} {/* Action buttons */} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 52b5fab03..f5372ec5b 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -546,6 +546,8 @@ "educationChanges": "Education Changes", "projectChanges": "Project Changes", "certificationChanges": "Certification Changes", + "languageChanges": "Language Changes", + "awardChanges": "Award Changes", "rejectButton": "Reject & Regenerate", "confirmButton": "Confirm & Save" }, diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 0f8014b4a..b56edffb4 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -546,6 +546,8 @@ "educationChanges": "Cambios en educación", "projectChanges": "Cambios en proyectos", "certificationChanges": "Cambios en certificaciones", + "languageChanges": "Cambios en idiomas", + "awardChanges": "Cambios en premios", "rejectButton": "Rechazar y regenerar", "confirmButton": "Confirmar y guardar" }, diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index d072b3fbf..d07cd821d 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -546,6 +546,8 @@ "educationChanges": "学歴の変更", "projectChanges": "プロジェクトの変更", "certificationChanges": "資格の変更", + "languageChanges": "言語の変更", + "awardChanges": "賞の変更", "rejectButton": "拒否して再生成", "confirmButton": "確認して保存" }, diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index d56261a54..ae3b3975c 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -546,6 +546,8 @@ "educationChanges": "Alterações de educação", "projectChanges": "Alterações de projetos", "certificationChanges": "Alterações de certificações", + "languageChanges": "Alterações de idiomas", + "awardChanges": "Alterações de prêmios", "rejectButton": "Rejeitar e regenerar", "confirmButton": "Confirmar e salvar" }, diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index 0d12c63ee..e137c49d9 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -546,6 +546,8 @@ "educationChanges": "教育变更", "projectChanges": "项目变更", "certificationChanges": "认证变更", + "languageChanges": "语言变更", + "awardChanges": "奖项变更", "rejectButton": "拒绝并重新生成", "confirmButton": "确认并保存" },