Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/backend/app/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,8 @@ class ResumeFieldDiff(BaseModel):
"experience",
"education",
"project",
"language",
"award",
]
change_type: Literal["added", "removed", "modified"]
original_value: str | None = None
Expand Down
96 changes: 96 additions & 0 deletions apps/backend/app/services/improver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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$"),

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.

P2: These new list targets need path-specific action gating; as written, malformed diffs can append or replace values instead of only reordering them.

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 88:

<comment>These new list targets need path-specific action gating; as written, malformed diffs can append or replace values instead of only reordering them.</comment>

<file context>
@@ -81,7 +81,13 @@ def _check_for_truncation(data: dict[str, Any]) -> 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$"),
</file context>

re.compile(r"^additional\.certificationsTraining$"),
re.compile(r"^additional\.awards$"),
]

# Blocked path prefixes — always rejected
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))):

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.

WARNING: Education descriptions are compared by array index. If an education entry is added or removed at a different position (e.g., a new entry prepended at index 0), descriptions will be compared against the wrong entries — original[1] vs improved[0] — producing false "modified" diffs. The workExperience path avoids this by using SequenceMatcher in _append_list_changes for its description list comparison. Consider using a similar alignment approach or matching entries by a stable key (e.g., institution + degree) before comparing descriptions.

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(

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.

P2: Education description edits are now emitted twice: once in the new education[i].description diff and again by entry-level education diffing. Exclude description from education entry comparisons to avoid duplicate change records.

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 1323:

<comment>Education description edits are now emitted twice: once in the new `education[i].description` diff and again by entry-level education diffing. Exclude `description` from education entry comparisons to avoid duplicate change records.</comment>

<file context>
@@ -1286,6 +1296,91 @@ def calculate_resume_diff(
+            change_type = "added"
+        else:
+            change_type = "modified"
+        changes.append(ResumeFieldDiff(
+            field_path=f"education[{idx}].description",
+            field_type="education",
</file context>

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(
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/e2e_monitor/AGENT_PLAYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Note the printed `bundle: artifacts/e2e-monitor/<run-id>/`. 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/<jd>/`: 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/<jd>/`: 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.)

Expand Down
21 changes: 20 additions & 1 deletion apps/backend/e2e_monitor/judge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": <int 1-5>, "reasons": "<one or two sentences>"}.'
)

Expand Down
108 changes: 108 additions & 0 deletions apps/backend/tests/unit/test_apply_diffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions apps/backend/tests/unit/test_resume_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
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
Loading