Skip to content
1 change: 1 addition & 0 deletions apps/backend/app/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 10 additions & 6 deletions apps/backend/app/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
104 changes: 101 additions & 3 deletions apps/backend/app/services/improver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: New field_type values (language, award) used in ResumeFieldDiff construction are not accepted by the schema's Literal type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/services/improver.py, line 1341:

<comment>New `field_type` values (`language`, `award`) used in `ResumeFieldDiff` construction are not accepted by the schema's `Literal` type.</comment>

<file context>
@@ -1284,7 +1324,65 @@ def calculate_resume_diff(
+    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],
</file context>

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,
Expand Down Expand Up @@ -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"]),
Expand Down
97 changes: 97 additions & 0 deletions apps/backend/tests/unit/test_apply_diffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion apps/frontend/components/builder/forms/additional-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const AdditionalForm: React.FC<AdditionalFormProps> = ({ 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,
Expand Down
4 changes: 3 additions & 1 deletion apps/frontend/components/common/resume_previewer_context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions apps/frontend/components/resume/resume-modern-two-column.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export const ResumeModernTwoColumn: React.FC<ResumeModernTwoColumnProps> = ({
<div className={baseStyles['resume-section']}>
<h3 className={styles.sectionTitleAccent}>{headingFallbacks.certifications}</h3>
<ul className={`ml-4 ${baseStyles['resume-list']} ${baseStyles['resume-text-xs']}`}>
{additional.certificationsTraining.map((cert, index) => (
{additional.certificationsTraining.filter((s) => s.trim()).map((cert, index) => (
<li key={index} className="flex">
<span className="mr-1.5 flex-shrink-0">•&nbsp;</span>
<span>{cert}</span>
Expand Down Expand Up @@ -371,7 +371,7 @@ export const ResumeModernTwoColumn: React.FC<ResumeModernTwoColumnProps> = ({
{headingFallbacks.skills}
</h3>
<div className="flex flex-wrap gap-1">
{additional.technicalSkills.map((skill, index) => (
{additional.technicalSkills.filter((s) => s.trim()).map((skill, index) => (
<span key={index} className={baseStyles['resume-skill-pill']}>
{skill}
</span>
Expand All @@ -390,7 +390,7 @@ export const ResumeModernTwoColumn: React.FC<ResumeModernTwoColumnProps> = ({
>
{headingFallbacks.languages}
</h3>
<p className={baseStyles['resume-text-xs']}>{additional.languages.join(' • ')}</p>
<p className={baseStyles['resume-text-xs']}>{additional.languages.filter((s) => s.trim()).join(' • ')}</p>
</div>
)}

Expand All @@ -403,7 +403,7 @@ export const ResumeModernTwoColumn: React.FC<ResumeModernTwoColumnProps> = ({
{headingFallbacks.awards}
</h3>
<ul className={baseStyles['resume-list']}>
{additional.awards.map((award, index) => (
{additional.awards.filter((s) => s.trim()).map((award, index) => (
<li key={index} className={baseStyles['resume-text-xs']}>
{award}
</li>
Expand Down
8 changes: 4 additions & 4 deletions apps/frontend/components/resume/resume-modern.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,25 +396,25 @@ const AdditionalSection: React.FC<{
{technicalSkills.length > 0 && (
<div className="flex">
<span className="font-bold w-32 shrink-0">{mergedLabels.technicalSkills}</span>
<span>{technicalSkills.join(', ')}</span>
<span>{technicalSkills.filter((s) => s.trim()).join(', ')}</span>
</div>
)}
{languages.length > 0 && (
<div className="flex">
<span className="font-bold w-32 shrink-0">{mergedLabels.languages}</span>
<span>{languages.join(', ')}</span>
<span>{languages.filter((s) => s.trim()).join(', ')}</span>
</div>
)}
{certificationsTraining.length > 0 && (
<div className="flex">
<span className="font-bold w-32 shrink-0">{mergedLabels.certifications}</span>
<span>{certificationsTraining.join(', ')}</span>
<span>{certificationsTraining.filter((s) => s.trim()).join(', ')}</span>
</div>
)}
{awards.length > 0 && (
<div className="flex">
<span className="font-bold w-32 shrink-0">{mergedLabels.awards}</span>
<span>{awards.join(', ')}</span>
<span>{awards.filter((s) => s.trim()).join(', ')}</span>
</div>
)}
</div>
Expand Down
Loading