diff --git a/apps/backend/app/llm.py b/apps/backend/app/llm.py index 10b6d6a20..46c9b4b2a 100644 --- a/apps/backend/app/llm.py +++ b/apps/backend/app/llm.py @@ -916,6 +916,7 @@ def _calculate_timeout( "openai": 1.0, "anthropic": 1.2, "openrouter": 1.5, # More variable latency + "deepseek": 1.3, # DeepSeek API can be slower than OpenAI "groq": 1.0, "ollama": 2.0, # Local models can be slower } diff --git a/apps/backend/app/prompts/templates.py b/apps/backend/app/prompts/templates.py index ea8431dab..2110ad274 100644 --- a/apps/backend/app/prompts/templates.py +++ b/apps/backend/app/prompts/templates.py @@ -466,18 +466,22 @@ def _build_truthfulness_rules(rule_7: str) -> str: 7. Generate all new text in {output_language} 8. Do not use em dash characters 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 +10. 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 -- "additional.technicalSkills" — reorder the skills list (action: "reorder") or add one verified skill (action: "add_skill") - -Do NOT target: personalInfo, dates/years, company names, education, customSections. +- "personalProjects[i].description" — append a new project bullet (action: "append") +- "education[i].description[j]" — a specific education description bullet +- "education[i].description" — append a new education bullet (action: "append") +- "additional.technicalSkills" — reorder the skills list (action: "reorder") +- "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 degree/institution/years, customSections. Keywords to emphasize (only if already supported by resume content): {job_keywords} diff --git a/apps/backend/app/services/improver.py b/apps/backend/app/services/improver.py index 501264d69..1ff94a7bc 100644 --- a/apps/backend/app/services/improver.py +++ b/apps/backend/app/services/improver.py @@ -81,7 +81,11 @@ 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+\])?$"), + re.compile(r"^education\[\d+\]\.description(\[\d+\])?$"), 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 +134,9 @@ def _is_path_blocked(path: str) -> bool: return True if path.startswith("education"): + # Allow education description modifications; block all other education fields + if re.match(r"^education\[\d+\]\.description(\[\d+\])?$", path): + return False return True return False @@ -1255,7 +1262,40 @@ def calculate_resume_diff( confidences=confidences, ) - # 4. Compare certifications (order changes are intentionally ignored) + # 4. Compare education descriptions (string, not list) + original_education = original.get("education", []) + improved_education = improved.get("education", []) + max_education_len = max(len(original_education), len(improved_education)) + for idx in range(max_education_len): + original_entry = ( + original_education[idx] if idx < len(original_education) else None + ) + improved_entry = ( + improved_education[idx] if idx < len(improved_education) else None + ) + if not isinstance(original_entry, dict) and not isinstance(improved_entry, dict): + continue + orig_desc = str(original_entry.get("description", "")).strip() if isinstance(original_entry, dict) else "" + impr_desc = str(improved_entry.get("description", "")).strip() if isinstance(improved_entry, dict) else "" + if orig_desc != impr_desc: + 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="description", + change_type=change_type, + original_value=orig_desc or None, + new_value=impr_desc or None, + confidence="medium", + ) + ) + + # 5. Compare certifications (order changes are intentionally ignored) orig_certs = _build_string_index( original.get("additional", {}).get("certificationsTraining", []), "additional.certificationsTraining", @@ -1284,7 +1324,65 @@ def calculate_resume_diff( confidence="medium" )) - # 5. Compare added/removed/modified entries + # 6. 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", + ) + orig_lang_keys = set(orig_langs) + new_lang_keys = set(new_langs) + for lang_key in new_lang_keys - orig_lang_keys: + 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 orig_lang_keys - new_lang_keys: + changes.append(ResumeFieldDiff( + field_path="additional.languages", + field_type="language", + change_type="removed", + original_value=orig_langs[lang_key], + confidence="medium" + )) + + # 7. 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", + ) + orig_award_keys = set(orig_awards) + new_award_keys = set(new_awards) + for award_key in new_award_keys - orig_award_keys: + 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 orig_award_keys - new_award_keys: + changes.append(ResumeFieldDiff( + field_path="additional.awards", + field_type="award", + change_type="removed", + original_value=orig_awards[award_key], + confidence="medium" + )) + + # 8. Compare added/removed/modified entries # Descriptions are diffed separately; ignore them when detecting entry-level changes. _append_entry_changes( changes, @@ -1312,7 +1410,7 @@ def calculate_resume_diff( _format_project_entry, ) - # 6. Build summary + # 9. Build summary summary = ResumeDiffSummary( total_changes=len(changes), skills_added=len([c for c in changes if c.field_type == "skill" and c.change_type == "added"]), diff --git a/apps/backend/tests/unit/test_apply_diffs.py b/apps/backend/tests/unit/test_apply_diffs.py index b52fb0941..e85f23f4b 100644 --- a/apps/backend/tests/unit/test_apply_diffs.py +++ b/apps/backend/tests/unit/test_apply_diffs.py @@ -563,3 +563,100 @@ 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: + """Tests for newly allowed paths (Issue #805 fix).""" + + def test_replace_education_description(self, sample_resume): + """Education description should be replaceable.""" + original_desc = sample_resume["education"][0]["description"] + changes = [ + ResumeChange( + path="education[0].description", + action="replace", + original=original_desc, + value="Graduated with honors, focused on distributed systems", + reason="test", + ) + ] + 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_reorder_languages(self, sample_resume): + """Languages list should be reorderable.""" + original_langs = sample_resume["additional"]["languages"] + changes = [ + ResumeChange( + path="additional.languages", + action="reorder", + original=None, + value=list(reversed(original_langs)), + 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_langs)) + + def test_reorder_awards(self, sample_resume): + """Awards list should be reorderable.""" + original_awards = sample_resume["additional"]["awards"] + changes = [ + ResumeChange( + path="additional.awards", + action="reorder", + original=None, + value=original_awards, + 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_awards + + def test_reject_education_degree(self, sample_resume): + """Education degree should still be blocked.""" + changes = [ + ResumeChange( + path="education[0].degree", + action="replace", + original="B.S. Computer Science", + value="M.S. Computer Science", + reason="test", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(rejected) == 1 + + def test_reject_education_institution(self, sample_resume): + """Education institution should still be blocked.""" + changes = [ + ResumeChange( + path="education[0].institution", + action="replace", + original="MIT", + value="Stanford", + reason="test", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(rejected) == 1 + + def test_reject_education_years(self, sample_resume): + """Education years should still be blocked.""" + changes = [ + ResumeChange( + path="education[0].years", + action="replace", + original="2014 - 2018", + value="2014 - 2020", + reason="test", + ) + ] + result, applied, rejected = apply_diffs(sample_resume, changes) + assert len(rejected) == 1 diff --git a/apps/frontend/components/builder/forms/additional-form.tsx b/apps/frontend/components/builder/forms/additional-form.tsx index 8e9398e80..37f2726da 100644 --- a/apps/frontend/components/builder/forms/additional-form.tsx +++ b/apps/frontend/components/builder/forms/additional-form.tsx @@ -17,7 +17,7 @@ export const AdditionalForm: React.FC = ({ data, onChange } // Helper to handle array conversions (text -> string[]) const handleArrayChange = (field: keyof AdditionalInfo, value: string) => { // Split by newlines only (preserving spaces within items) - const items = value.split('\n').filter((item) => item.trim() !== ''); + const items = value.split('\n'); onChange({ ...data, [field]: items, 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/resume/resume-modern-two-column.tsx b/apps/frontend/components/resume/resume-modern-two-column.tsx index 030cb09a8..f6981e237 100644 --- a/apps/frontend/components/resume/resume-modern-two-column.tsx +++ b/apps/frontend/components/resume/resume-modern-two-column.tsx @@ -306,7 +306,7 @@ export const ResumeModernTwoColumn: React.FC = ({

{headingFallbacks.certifications}

    - {additional.certificationsTraining.map((cert, index) => ( + {additional.certificationsTraining.filter((s) => s.trim()).map((cert, index) => (
  • •  {cert} @@ -371,7 +371,7 @@ export const ResumeModernTwoColumn: React.FC = ({ {headingFallbacks.skills}
    - {additional.technicalSkills.map((skill, index) => ( + {additional.technicalSkills.filter((s) => s.trim()).map((skill, index) => ( {skill} @@ -390,7 +390,7 @@ export const ResumeModernTwoColumn: React.FC = ({ > {headingFallbacks.languages} -

    {additional.languages.join(' • ')}

    +

    {additional.languages.filter((s) => s.trim()).join(' • ')}

    )} @@ -403,7 +403,7 @@ export const ResumeModernTwoColumn: React.FC = ({ {headingFallbacks.awards}
      - {additional.awards.map((award, index) => ( + {additional.awards.filter((s) => s.trim()).map((award, index) => (
    • {award}
    • diff --git a/apps/frontend/components/resume/resume-modern.tsx b/apps/frontend/components/resume/resume-modern.tsx index 33be6e532..31180c6ab 100644 --- a/apps/frontend/components/resume/resume-modern.tsx +++ b/apps/frontend/components/resume/resume-modern.tsx @@ -396,25 +396,25 @@ const AdditionalSection: React.FC<{ {technicalSkills.length > 0 && (
      {mergedLabels.technicalSkills} - {technicalSkills.join(', ')} + {technicalSkills.filter((s) => s.trim()).join(', ')}
      )} {languages.length > 0 && (
      {mergedLabels.languages} - {languages.join(', ')} + {languages.filter((s) => s.trim()).join(', ')}
      )} {certificationsTraining.length > 0 && (
      {mergedLabels.certifications} - {certificationsTraining.join(', ')} + {certificationsTraining.filter((s) => s.trim()).join(', ')}
      )} {awards.length > 0 && (
      {mergedLabels.awards} - {awards.join(', ')} + {awards.filter((s) => s.trim()).join(', ')}
      )}
diff --git a/apps/frontend/components/resume/resume-single-column.tsx b/apps/frontend/components/resume/resume-single-column.tsx index 39a7f5bc9..9f4785cc4 100644 --- a/apps/frontend/components/resume/resume-single-column.tsx +++ b/apps/frontend/components/resume/resume-single-column.tsx @@ -380,10 +380,10 @@ const AdditionalSection: React.FC<{ }; const hasContent = - technicalSkills.length > 0 || - languages.length > 0 || - certificationsTraining.length > 0 || - awards.length > 0; + technicalSkills.filter((s) => s.trim()).length > 0 || + languages.filter((s) => s.trim()).length > 0 || + certificationsTraining.filter((s) => s.trim()).length > 0 || + awards.filter((s) => s.trim()).length > 0; if (!hasContent) return null; @@ -391,28 +391,28 @@ const AdditionalSection: React.FC<{

{displayName}

- {technicalSkills.length > 0 && ( + {technicalSkills.filter((s) => s.trim()).length > 0 && (
{mergedLabels.technicalSkills} - {technicalSkills.join(', ')} + {technicalSkills.filter((s) => s.trim()).join(', ')}
)} - {languages.length > 0 && ( + {languages.filter((s) => s.trim()).length > 0 && (
{mergedLabels.languages} - {languages.join(', ')} + {languages.filter((s) => s.trim()).join(', ')}
)} - {certificationsTraining.length > 0 && ( + {certificationsTraining.filter((s) => s.trim()).length > 0 && (
{mergedLabels.certifications} - {certificationsTraining.join(', ')} + {certificationsTraining.filter((s) => s.trim()).join(', ')}
)} - {awards.length > 0 && ( + {awards.filter((s) => s.trim()).length > 0 && (
{mergedLabels.awards} - {awards.join(', ')} + {awards.filter((s) => s.trim()).join(', ')}
)}
diff --git a/apps/frontend/components/resume/resume-two-column.tsx b/apps/frontend/components/resume/resume-two-column.tsx index d7e37d145..2e392212f 100644 --- a/apps/frontend/components/resume/resume-two-column.tsx +++ b/apps/frontend/components/resume/resume-two-column.tsx @@ -337,13 +337,13 @@ export const ResumeTwoColumn: React.FC = ({ {/* Certifications/Training - Main column */} {isSectionVisible('additional') && additional?.certificationsTraining && - additional.certificationsTraining.length > 0 && ( + additional.certificationsTraining.filter((s) => s.trim()).length > 0 && (

{headingFallbacks.certifications}

    - {additional.certificationsTraining.map((cert, index) => ( + {additional.certificationsTraining.filter((s) => s.trim()).map((cert, index) => (
  • •  {cert} @@ -398,11 +398,11 @@ export const ResumeTwoColumn: React.FC = ({ {/* Skills Section */} {isSectionVisible('additional') && additional?.technicalSkills && - additional.technicalSkills.length > 0 && ( + additional.technicalSkills.filter((s) => s.trim()).length > 0 && (

    {headingFallbacks.skills}

    - {additional.technicalSkills.map((skill, index) => ( + {additional.technicalSkills.filter((s) => s.trim()).map((skill, index) => ( {skill} @@ -414,21 +414,21 @@ export const ResumeTwoColumn: React.FC = ({ {/* Languages Section */} {isSectionVisible('additional') && additional?.languages && - additional.languages.length > 0 && ( + additional.languages.filter((s) => s.trim()).length > 0 && (

    {headingFallbacks.languages}

    -

    {additional.languages.join(' • ')}

    +

    {additional.languages.filter((s) => s.trim()).join(' • ')}

    )} {/* Awards Section */} - {isSectionVisible('additional') && additional?.awards && additional.awards.length > 0 && ( + {isSectionVisible('additional') && additional?.awards && additional.awards.filter((s) => s.trim()).length > 0 && (

    {headingFallbacks.awards}

      - {additional.awards.map((award, index) => ( + {additional.awards.filter((s) => s.trim()).map((award, index) => (
    • {award}
    • 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 6c4dae0e4..bd8d3e896 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 7ec30c4d5..0217f6f63 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 a9f81d457..c0c5d4530 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": "确认并保存" },