Files
srbhr--resume-matcher/apps/backend/app/services/improver.py
T
wehub-resource-sync 5bdf4cc89a
Publish Docker Image / publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:36 +08:00

1515 lines
54 KiB
Python

"""Resume improvement service using LLM."""
import copy
import json
import logging
import re
from difflib import SequenceMatcher
from dataclasses import dataclass
from typing import Any, Callable
from app.llm import complete_json
from app.prompts import (
CRITICAL_TRUTHFULNESS_RULES,
DEFAULT_IMPROVE_PROMPT_ID,
DIFF_IMPROVE_PROMPT,
DIFF_STRATEGY_INSTRUCTIONS,
EXTRACT_KEYWORDS_PROMPT,
IMPROVE_RESUME_PROMPTS,
SKILL_TARGET_PLAN_PROMPT,
get_language_name,
)
from app.prompts.templates import IMPROVE_SCHEMA_EXAMPLE
from app.schemas import ResumeData, ResumeFieldDiff, ResumeDiffSummary
from app.schemas.models import ImproveDiffResult, ResumeChange
logger = logging.getLogger(__name__)
# LLM-011: Prompt injection patterns to sanitize
_INJECTION_PATTERNS = [
r"ignore\s+(all\s+)?previous\s+instructions",
r"disregard\s+(all\s+)?above",
r"forget\s+(everything|all)",
r"new\s+instructions?:",
r"system\s*:",
r"<\s*/?\s*system\s*>",
r"\[\s*INST\s*\]",
r"\[\s*/\s*INST\s*\]",
]
@dataclass(frozen=True)
class DiffConfidence:
added: str
removed: str
modified: str
def _sanitize_user_input(text: str) -> str:
"""LLM-011: Sanitize user input to prevent prompt injection.
Removes or redacts common injection patterns that could manipulate LLM behavior.
"""
sanitized = text
for pattern in _INJECTION_PATTERNS:
sanitized = re.sub(pattern, "[REDACTED]", sanitized, flags=re.IGNORECASE)
return sanitized
def _check_for_truncation(data: dict[str, Any]) -> None:
"""LLM-006: Log warnings for obvious truncation signs before Pydantic validation.
Note: personalInfo is intentionally excluded — the improve prompts tell the
LLM to skip it, and _preserve_personal_info() restores it from the original.
"""
# Check for suspiciously empty required arrays
if "workExperience" in data and data["workExperience"] == []:
logger.warning(
"Resume has empty workExperience - possible truncation or unusual resume"
)
# ---------------------------------------------------------------------------
# Diff-based improvement: path resolution, applier, verifier, LLM generator
# ---------------------------------------------------------------------------
_PATH_SEGMENT_RE = re.compile(r"([a-zA-Z_]+)(?:\[(\d+)\])?")
# Allowed path patterns — only these can be modified by diffs
_ALLOWED_PATH_PATTERNS = [
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
_BLOCKED_PATH_PREFIXES = frozenset({
"personalInfo",
"customSections",
"sectionMeta",
})
# Blocked field names — rejected when they appear as the leaf of a path
_BLOCKED_FIELD_NAMES = frozenset({
"years",
"company",
"institution",
"title",
"degree",
"name",
"role",
"github",
"website",
"location",
"id",
})
_METRIC_RE = re.compile(r"\d+%|\d+x|\$\d+")
def _is_path_allowed(path: str) -> bool:
"""Check if a path is in the allowed whitelist."""
return any(p.match(path) for p in _ALLOWED_PATH_PATTERNS)
def _is_path_blocked(path: str) -> bool:
"""Check if a path matches any blocked pattern."""
for prefix in _BLOCKED_PATH_PREFIXES:
if path == prefix or path.startswith(prefix + ".") or path.startswith(prefix + "["):
return True
# Check if the leaf field is blocked
segments = path.split(".")
if segments:
last_segment = segments[-1]
field_name = re.sub(r"\[\d+\]$", "", last_segment)
# "description" is the one allowed field that shares a name pattern
if field_name in _BLOCKED_FIELD_NAMES and field_name != "description":
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
def _resolve_path(data: dict[str, Any], path: str) -> tuple[Any, bool]:
"""Resolve a dot+bracket path to a value in the data dict.
Returns:
(value, success). On failure returns (None, False).
"""
current: Any = data
for segment_match in _PATH_SEGMENT_RE.finditer(path):
key = segment_match.group(1)
index_str = segment_match.group(2)
if not isinstance(current, dict) or key not in current:
return None, False
current = current[key]
if index_str is not None:
index = int(index_str)
if not isinstance(current, list) or index < 0 or index >= len(current):
return None, False
current = current[index]
return current, True
def _set_at_path(data: dict[str, Any], path: str, value: Any) -> bool:
"""Set a value at the given path. Returns True on success."""
segments = list(_PATH_SEGMENT_RE.finditer(path))
if not segments:
return False
# Navigate to parent of the target
current: Any = data
for seg in segments[:-1]:
key = seg.group(1)
index_str = seg.group(2)
if not isinstance(current, dict) or key not in current:
return False
current = current[key]
if index_str is not None:
index = int(index_str)
if not isinstance(current, list) or index < 0 or index >= len(current):
return False
current = current[index]
# Set on the final segment
last = segments[-1]
key = last.group(1)
index_str = last.group(2)
if index_str is not None:
if not isinstance(current, dict) or key not in current:
return False
target = current[key]
index = int(index_str)
if not isinstance(target, list) or index < 0 or index >= len(target):
return False
target[index] = value
else:
if not isinstance(current, dict):
return False
current[key] = value
return True
def _verify_original_matches(actual: Any, expected: str | list[str] | None) -> bool:
"""Verify that the original text from the diff matches the actual value."""
if expected is None:
return True # no original provided (e.g. append) — nothing to verify
if not isinstance(expected, str):
return False # a non-str original on a text action is malformed — reject
if not isinstance(actual, str):
return False
return actual.strip().casefold() == expected.strip().casefold()
def apply_diffs(
original: dict[str, Any],
changes: list[ResumeChange],
allowed_skill_targets: list[dict[str, Any] | str] | None = None,
) -> tuple[dict[str, Any], list[ResumeChange], list[ResumeChange]]:
"""Apply verified diffs to original resume.
Each change goes through 4 gates:
1. Path is in allowed whitelist
2. Path is not in blocked list
3. Path resolves to an actual value in the original
4. Original text matches (for replace actions)
For reorder: validates the new list contains exactly the same items.
Args:
original: The original resume data (ResumeData-compatible dict)
changes: List of changes from the LLM
allowed_skill_targets: Verified skill targets allowed for add_skill actions
Returns:
(result_dict, applied_changes, rejected_changes)
"""
result = copy.deepcopy(original)
applied: list[ResumeChange] = []
rejected: list[ResumeChange] = []
allowed_skill_keys = _build_allowed_skill_target_keys(allowed_skill_targets)
for change in changes:
path = change.path
action = change.action
# Gate 1: Path must be in allowed whitelist
if not _is_path_allowed(path):
logger.info("Diff rejected (not in allowed list): %s", path)
rejected.append(change)
continue
# Gate 2: Path must not be blocked
if _is_path_blocked(path):
logger.info("Diff rejected (blocked path): %s", path)
rejected.append(change)
continue
# Gate 3: Path must resolve to a real value
actual_value, resolved = _resolve_path(result, path)
if not resolved:
logger.info("Diff rejected (path not found): %s", path)
rejected.append(change)
continue
if action == "replace":
# Gate 4: Original text must match what's actually there
if not _verify_original_matches(actual_value, change.original):
logger.info(
"Diff rejected (original mismatch): path=%s expected=%r actual=%r",
path,
change.original,
actual_value,
)
rejected.append(change)
continue
# Replace must use a string value (not list)
if not isinstance(change.value, str):
logger.info("Diff rejected (replace with non-string value): %s", path)
rejected.append(change)
continue
if not _set_at_path(result, path, change.value):
rejected.append(change)
continue
applied.append(change)
elif action == "append":
if not isinstance(actual_value, list):
logger.info("Diff rejected (append to non-list): %s", path)
rejected.append(change)
continue
# Append must use a non-empty string (not list, to avoid nested lists)
if not isinstance(change.value, str) or not change.value.strip():
logger.info("Diff rejected (append non-string or empty value): %s", path)
rejected.append(change)
continue
actual_value.append(change.value)
applied.append(change)
elif action == "reorder":
if not isinstance(actual_value, list) or not isinstance(change.value, list):
rejected.append(change)
continue
orig_set = sorted(s.casefold() for s in actual_value if isinstance(s, str))
new_set = sorted(s.casefold() for s in change.value if isinstance(s, str))
reordered: list[str] = []
if orig_set == new_set:
# Pure permutation: map the new order back to original casing.
casefold_to_originals: dict[str, list[str]] = {}
for item in actual_value:
if isinstance(item, str):
casefold_to_originals.setdefault(item.casefold(), []).append(item)
for item in change.value:
if isinstance(item, str):
originals = casefold_to_originals.get(item.casefold(), [])
reordered.append(originals.pop(0) if originals else item)
else:
# Salvage (issue #736): the LLM folded new/removed items into a
# reorder. Rather than dropping the whole change, apply the SAFE
# subset *in the requested order*: walk the proposed list, placing
# each existing item where the model put it (so prioritized JD
# skills stay near the top) and — for the skills list only —
# inserting new items that pass the SAME verified gate as
# add_skill. Originals the model omitted are appended at the end
# so a real item is never silently lost. Other lists
# (languages/certs/awards) have no verifier, so new items are
# dropped to avoid fabrication.
casefold_to_originals: dict[str, list[str]] = {}
for item in actual_value:
if isinstance(item, str):
casefold_to_originals.setdefault(item.casefold(), []).append(item)
original_cfs = set(casefold_to_originals)
is_skills = path == "additional.technicalSkills"
added_new: set[str] = set()
for item in change.value:
if not isinstance(item, str):
continue
cf = item.casefold()
if cf in original_cfs:
bucket = casefold_to_originals[cf]
if bucket: # place original in requested position (dupes preserved)
reordered.append(bucket.pop(0))
# else: a duplicate of an already-placed original — skip
elif is_skills and cf not in added_new:
skill = item.strip()
if skill and _normalize_skill_key(skill) in allowed_skill_keys:
reordered.append(skill) # verified new skill, requested position
added_new.add(cf)
else:
logger.info("Reorder salvage dropped unverified skill: %s", skill)
# else: non-skills new item → dropped (no verifier to ground it)
for item in actual_value: # append any originals the model omitted
if isinstance(item, str):
bucket = casefold_to_originals[item.casefold()]
if bucket:
reordered.append(bucket.pop(0))
logger.info("Diff reorder salvaged (item-set mismatch): %s", path)
if not _set_at_path(result, path, reordered):
rejected.append(change)
continue
applied.append(change)
elif action == "add_skill":
if path != "additional.technicalSkills":
logger.info("Diff rejected (add_skill outside skills): %s", path)
rejected.append(change)
continue
if not isinstance(actual_value, list):
logger.info("Diff rejected (add_skill to non-list): %s", path)
rejected.append(change)
continue
if not isinstance(change.value, str) or not change.value.strip():
logger.info("Diff rejected (add_skill empty/non-string): %s", path)
rejected.append(change)
continue
new_skill = change.value.strip()
existing = {
item.casefold()
for item in actual_value
if isinstance(item, str)
}
if new_skill.casefold() in existing:
logger.info("Diff rejected (duplicate skill): %s", new_skill)
rejected.append(change)
continue
if _normalize_skill_key(new_skill) not in allowed_skill_keys:
logger.info("Diff rejected (skill not in verified targets): %s", new_skill)
rejected.append(change)
continue
actual_value.append(new_skill)
applied.append(change)
else:
logger.info("Diff rejected (unknown action): %s", action)
rejected.append(change)
return result, applied, rejected
def _count_description_words(data: dict[str, Any]) -> int:
"""Count total words in all description and summary fields."""
total = 0
for key in ("workExperience", "personalProjects"):
for entry in data.get(key, []):
if isinstance(entry, dict):
desc = entry.get("description", [])
if isinstance(desc, list):
total += sum(len(str(d).split()) for d in desc)
elif isinstance(desc, str):
total += len(desc.split())
summary = data.get("summary", "")
if isinstance(summary, str):
total += len(summary.split())
return total
def verify_diff_result(
original: dict[str, Any],
result: dict[str, Any],
applied_changes: list[ResumeChange],
job_keywords: dict[str, Any],
) -> list[str]:
"""Local quality checks on the diff result. Returns list of warnings.
All checks are local (zero LLM cost). Warnings are informational —
they don't block the response.
"""
warnings: list[str] = []
# Check 1: No empty result
if not applied_changes:
warnings.append("No changes were applied — resume returned unchanged")
return warnings
# Check 2: Section counts preserved
for key, label in [
("workExperience", "work experience"),
("education", "education"),
("personalProjects", "project"),
]:
orig_count = len(original.get(key, []))
result_count = len(result.get(key, []))
if orig_count != result_count:
warnings.append(
f"Section count changed: {label} ({orig_count}{result_count})"
)
# Check 3: Identity fields unchanged
for key, id_fields in [
("workExperience", ["company", "title"]),
("education", ["institution", "degree"]),
]:
orig_entries = original.get(key, [])
result_entries = result.get(key, [])
for i, (orig, res) in enumerate(zip(orig_entries, result_entries)):
if not isinstance(orig, dict) or not isinstance(res, dict):
continue
for field in id_fields:
o_val = str(orig.get(field, "")).strip()
r_val = str(res.get(field, "")).strip()
if o_val and o_val != r_val:
warnings.append(
f"Identity field changed: {key}[{i}].{field} "
f"('{o_val}' → '{r_val}')"
)
# Check 4: Word count ratio
orig_words = _count_description_words(original)
result_words = _count_description_words(result)
if orig_words > 0 and result_words > orig_words * 1.8:
warnings.append(
f"Word count increased significantly: "
f"{orig_words}{result_words} ({result_words / orig_words:.1f}x)"
)
# Check 5: Invented metrics (covers both replace and append)
for change in applied_changes:
if change.action in ("replace", "append") and isinstance(change.value, str):
new_metrics = set(_METRIC_RE.findall(change.value))
# For append, original is None — any metric is potentially invented
original_text = change.original or ""
old_metrics = set(_METRIC_RE.findall(original_text))
invented = new_metrics - old_metrics
if invented:
warnings.append(
f"Possible invented metric in {change.path}: "
f"{', '.join(invented)} (not in original)"
)
return warnings
async def generate_resume_diffs(
original_resume: str,
job_description: str,
job_keywords: dict[str, Any],
language: str = "en",
prompt_id: str | None = None,
original_resume_data: dict[str, Any] | None = None,
skill_targets: list[dict[str, Any]] | None = None,
) -> ImproveDiffResult:
"""Generate targeted resume diffs via LLM.
Instead of asking the LLM for the full resume, asks for a list of
targeted changes. Each change specifies a path, action, and new value.
Args:
original_resume: Resume content (markdown)
job_description: Target job description
job_keywords: Extracted job keywords
language: Output language code (en, es, zh, ja)
prompt_id: Strategy id (nudge/keywords/full)
original_resume_data: Structured resume JSON
skill_targets: Verified skill targets from the planning pass
Returns:
ImproveDiffResult with list of changes and strategy notes
"""
keywords_str = _prepare_keywords_for_prompt(job_keywords)
output_language = get_language_name(language)
selected_id = prompt_id or DEFAULT_IMPROVE_PROMPT_ID
if selected_id not in DIFF_STRATEGY_INSTRUCTIONS:
logger.warning(
"Unknown prompt_id '%s'; using default diff strategy.",
selected_id,
)
strategy_instruction = DIFF_STRATEGY_INSTRUCTIONS.get(
selected_id, DIFF_STRATEGY_INSTRUCTIONS[DEFAULT_IMPROVE_PROMPT_ID]
)
# LLM-011: Sanitize job description
sanitized_jd = _sanitize_user_input(job_description)
# Use structured JSON if available with month precision, else markdown
if original_resume_data is not None:
if _has_month_in_dates(original_resume_data):
resume_input = json.dumps(original_resume_data)
else:
resume_input = original_resume
else:
resume_input = original_resume
prompt = DIFF_IMPROVE_PROMPT.format(
strategy_instruction=strategy_instruction,
output_language=output_language,
job_keywords=keywords_str,
skill_targets=_prepare_skill_targets_for_prompt(skill_targets),
job_description=sanitized_jd,
original_resume=resume_input,
)
result = await complete_json(
prompt=prompt,
system_prompt="You are an expert resume editor. Output only valid JSON with targeted changes.",
max_tokens=4096,
schema_type="diff",
)
# Parse result — handle LLM ignoring diff format gracefully
raw_changes = result.get("changes", [])
if not isinstance(raw_changes, list):
logger.warning("LLM returned non-list changes: %s", type(raw_changes))
raw_changes = []
changes: list[ResumeChange] = []
for raw in raw_changes:
if not isinstance(raw, dict):
continue
try:
changes.append(
ResumeChange(
path=str(raw.get("path", "")),
action=raw.get("action", "replace"),
original=raw.get("original"),
value=raw.get("value", ""),
reason=str(raw.get("reason", "")),
)
)
except Exception as e:
logger.warning("Skipping malformed change: %s%s", raw, e)
strategy_notes = str(result.get("strategy_notes", ""))
if not raw_changes and "changes" not in result:
strategy_notes = "LLM output had no changes key — returned zero diffs"
logger.warning("LLM output missing 'changes' key: %s", list(result.keys()))
return ImproveDiffResult(changes=changes, strategy_notes=strategy_notes)
async def extract_job_keywords(job_description: str) -> dict[str, Any]:
"""Extract keywords and requirements from job description.
Args:
job_description: Raw job description text
Returns:
Structured keywords and requirements
"""
# LLM-011: Sanitize job description before using in prompt
sanitized_jd = _sanitize_user_input(job_description)
prompt = EXTRACT_KEYWORDS_PROMPT.format(job_description=sanitized_jd)
return await complete_json(
prompt=prompt,
system_prompt="You are an expert job description analyzer.",
schema_type="keywords",
)
MONTH_PATTERN = re.compile(
r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\b",
re.IGNORECASE,
)
def _has_month_in_dates(data: dict[str, Any]) -> bool:
"""Check whether any years field in the structured data includes a month."""
for section_key in ("workExperience", "education", "personalProjects"):
entries = data.get(section_key, [])
if not isinstance(entries, list):
continue
for entry in entries:
if isinstance(entry, dict):
years = entry.get("years", "")
if isinstance(years, str) and MONTH_PATTERN.search(years):
return True
custom_sections = data.get("customSections", {})
if isinstance(custom_sections, dict):
for section in custom_sections.values():
if isinstance(section, dict) and section.get("sectionType") == "itemList":
items = section.get("items", [])
if not isinstance(items, list):
continue
for item in items:
if isinstance(item, dict):
years = item.get("years", "")
if isinstance(years, str) and MONTH_PATTERN.search(years):
return True
return False
def _prepare_keywords_for_prompt(job_keywords: dict[str, Any]) -> str:
"""Format job keywords as a focused, readable list for the LLM prompt.
Extracts only actionable fields (skills and keywords) and drops
informational fields that add noise without helping the LLM tailor.
"""
sections: list[str] = []
required = job_keywords.get("required_skills", [])
if required:
sections.append("Required skills to emphasize:\n- " + "\n- ".join(str(s) for s in required))
preferred = job_keywords.get("preferred_skills", [])
if preferred:
sections.append(
"Preferred skills (include only if resume supports them):\n- "
+ "\n- ".join(str(s) for s in preferred)
)
keywords = job_keywords.get("keywords", [])
if keywords:
sections.append("Additional keywords to weave in naturally:\n- " + "\n- ".join(str(k) for k in keywords))
return "\n\n".join(sections) if sections else "No specific keywords extracted."
def _normalize_skill_key(skill: str) -> str:
"""Normalize a skill for case-insensitive comparison."""
return re.sub(r"\s+", " ", skill.strip()).casefold()
def _extract_skill_index(items: Any) -> dict[str, str]:
"""Build a normalized skill index from a string list."""
if not isinstance(items, list):
return {}
index: dict[str, str] = {}
for item in items:
if not isinstance(item, str):
continue
skill = item.strip()
if skill:
index.setdefault(_normalize_skill_key(skill), skill)
return index
def _skill_mentioned_in_text(skill: str, text: str) -> bool:
"""Return True when a skill phrase appears as a whole term in text."""
escaped = re.escape(skill.strip().lower())
if not escaped:
return False
return bool(re.search(rf"(?<!\w){escaped}(?!\w)", text.lower()))
def _build_allowed_skill_target_keys(
allowed_skill_targets: list[dict[str, Any] | str] | None,
) -> set[str]:
"""Build normalized keys for skills approved by the planning verifier."""
keys: set[str] = set()
for target in allowed_skill_targets or []:
if isinstance(target, str):
skill = target
elif isinstance(target, dict):
skill = str(target.get("skill", ""))
else:
continue
if skill.strip():
keys.add(_normalize_skill_key(skill))
return keys
def _extract_jd_skill_index(
job_keywords: dict[str, Any],
job_description: str | None = None,
) -> dict[str, str]:
"""Build a normalized index of explicit JD skills."""
index: dict[str, str] = {}
for field in ("required_skills", "preferred_skills"):
values = job_keywords.get(field, [])
if not isinstance(values, list):
continue
for value in values:
if not isinstance(value, str):
continue
skill = value.strip()
if skill and (
job_description is None
or _skill_mentioned_in_text(skill, job_description)
):
index.setdefault(_normalize_skill_key(skill), skill)
return index
def _skill_present_in_resume_text(skill: str, resume_data: dict[str, Any]) -> bool:
"""Return True when a skill phrase already appears in the resume text."""
text = json.dumps(resume_data, ensure_ascii=False)
return _skill_mentioned_in_text(skill, text)
def verify_skill_target_plan(
raw_plan: dict[str, Any],
original_resume_data: dict[str, Any],
job_keywords: dict[str, Any],
job_description: str | None = None,
) -> dict[str, list[dict[str, str]] | str]:
"""Filter and classify LLM-proposed skill targets before diff generation.
Existing resume skills are accepted as low-risk targets. Required and
preferred JD skills are accepted as explicit JD-added targets for user
review. Other skills are accepted only when they already appear in the
resume text.
"""
original_skills = _extract_skill_index(
original_resume_data.get("additional", {}).get("technicalSkills", [])
)
jd_skills = _extract_jd_skill_index(job_keywords, job_description)
raw_targets = raw_plan.get("target_skills", [])
accepted: list[dict[str, str]] = []
rejected: list[dict[str, str]] = []
seen: set[str] = set()
if not isinstance(raw_targets, list):
raw_targets = []
for target in raw_targets:
if isinstance(target, str):
skill = target.strip()
reason = ""
elif isinstance(target, dict):
skill = str(target.get("skill", "")).strip()
reason = str(target.get("reason", "")).strip()
else:
continue
skill_key = _normalize_skill_key(skill)
if not skill or skill_key in seen:
continue
seen.add(skill_key)
if skill_key in original_skills:
accepted.append(
{
"skill": original_skills[skill_key],
"source": "existing",
"reason": reason or "Already present in resume skills",
}
)
elif skill_key in jd_skills:
# JD-required/preferred skills are accepted as targets so the résumé
# can be tailored to actually pass ATS/recruiter screening — adding
# relevant JD skills is the product's purpose. (Truly unsupported
# skills — neither in the JD nor the résumé — are still rejected
# below.) The user reviews additions in the diff preview before save.
accepted.append(
{
"skill": jd_skills[skill_key],
"source": "jd_added",
"reason": reason or "Required or preferred by the job description",
}
)
elif _skill_present_in_resume_text(skill, original_resume_data):
accepted.append(
{
"skill": skill,
"source": "supported_by_resume",
"reason": reason or "Appears in the existing resume content",
}
)
else:
rejected.append(
{
"skill": skill,
"source": "unsupported",
"reason": reason or "Not found in resume or job keywords",
}
)
return {
"accepted": accepted,
"rejected": rejected,
"strategy_notes": str(raw_plan.get("strategy_notes", "")),
}
async def generate_skill_target_plan(
original_resume_data: dict[str, Any],
job_description: str,
job_keywords: dict[str, Any],
language: str = "en",
) -> dict[str, Any]:
"""Ask the LLM for a compact skill target plan before editing diffs."""
output_language = get_language_name(language)
existing_skills = original_resume_data.get("additional", {}).get(
"technicalSkills", []
)
sanitized_jd = _sanitize_user_input(job_description)
prompt = SKILL_TARGET_PLAN_PROMPT.format(
output_language=output_language,
existing_skills=json.dumps(existing_skills, ensure_ascii=False),
job_keywords=_prepare_keywords_for_prompt(job_keywords),
job_description=sanitized_jd,
original_resume=json.dumps(original_resume_data, ensure_ascii=False),
)
result = await complete_json(
prompt=prompt,
system_prompt=(
"You are a resume skill planning agent. Output only valid JSON with "
"target_skills and strategy_notes."
),
max_tokens=2048,
schema_type="diff",
)
raw_targets = result.get("target_skills", [])
target_skills: list[dict[str, str]] = []
if isinstance(raw_targets, list):
for raw in raw_targets:
if isinstance(raw, str):
skill = raw.strip()
reason = ""
elif isinstance(raw, dict):
skill = str(raw.get("skill", "")).strip()
reason = str(raw.get("reason", "")).strip()
else:
continue
if skill:
target_skills.append({"skill": skill, "reason": reason})
else:
logger.warning("Skill target plan returned non-list target_skills")
return {
"target_skills": target_skills,
"strategy_notes": str(result.get("strategy_notes", "")),
}
def _prepare_skill_targets_for_prompt(
skill_targets: list[dict[str, Any]] | None,
) -> str:
"""Format verified skill targets for the diff prompt."""
if not skill_targets:
return "No verified skill targets."
lines: list[str] = []
for target in skill_targets:
skill = str(target.get("skill", "")).strip()
if not skill:
continue
source = str(target.get("source", "unknown")).strip() or "unknown"
reason = str(target.get("reason", "")).strip()
suffix = f": {reason}" if reason else ""
lines.append(f"- {skill} ({source}){suffix}")
return "\n".join(lines) if lines else "No verified skill targets."
async def improve_resume(
original_resume: str,
job_description: str,
job_keywords: dict[str, Any],
language: str = "en",
prompt_id: str | None = None,
original_resume_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Improve resume to better match job description.
Args:
original_resume: Original resume content (markdown)
job_description: Target job description
job_keywords: Extracted job keywords
language: Output language code (en, es, zh, ja)
prompt_id: Which tailor prompt to use
original_resume_data: Structured resume JSON; used instead of
markdown when available for higher-fidelity LLM input
Returns:
Improved resume data matching ResumeData schema
LLM-006: Validates for truncation before Pydantic validation.
LLM-011: Sanitizes job description to prevent prompt injection.
"""
keywords_str = _prepare_keywords_for_prompt(job_keywords)
output_language = get_language_name(language)
selected_prompt_id = prompt_id or DEFAULT_IMPROVE_PROMPT_ID
prompt_template = IMPROVE_RESUME_PROMPTS.get(
selected_prompt_id, IMPROVE_RESUME_PROMPTS[DEFAULT_IMPROVE_PROMPT_ID]
)
if selected_prompt_id not in CRITICAL_TRUTHFULNESS_RULES:
logger.warning(
"Missing truthfulness rules for prompt '%s'; using default rules.",
selected_prompt_id,
)
truthfulness_rules = CRITICAL_TRUTHFULNESS_RULES.get(
selected_prompt_id, CRITICAL_TRUTHFULNESS_RULES[DEFAULT_IMPROVE_PROMPT_ID]
)
# LLM-011: Sanitize job description to prevent prompt injection
sanitized_jd = _sanitize_user_input(job_description)
# Use structured JSON when available for higher-fidelity LLM input,
# but fall back to raw markdown if the structured data has truncated
# (year-only) dates — the markdown preserves months from the original PDF.
if original_resume_data is not None:
if _has_month_in_dates(original_resume_data):
resume_input = json.dumps(original_resume_data)
else:
logger.info(
"Structured resume data has year-only dates; using raw markdown "
"to preserve month precision."
)
resume_input = original_resume
else:
resume_input = original_resume
prompt = prompt_template.format(
job_description=sanitized_jd,
job_keywords=keywords_str,
original_resume=resume_input,
schema=IMPROVE_SCHEMA_EXAMPLE,
output_language=output_language,
critical_truthfulness_rules=truthfulness_rules,
)
result = await complete_json(
prompt=prompt,
system_prompt="You are an expert resume editor. Output only valid JSON.",
max_tokens=8192,
)
# LLM-006: Pre-validation check for truncation signs
_check_for_truncation(result)
# Validate against schema
validated = ResumeData.model_validate(result)
return validated.model_dump()
def _format_entry_label(parts: list[str], fallback: str) -> str:
label = " | ".join([part for part in parts if part])
return label if label else fallback
def _format_experience_entry(entry: dict[str, Any], index: int) -> str:
return _format_entry_label(
[
entry.get("title", ""),
entry.get("company", ""),
entry.get("years", ""),
],
f"Work experience #{index + 1}",
)
def _format_education_entry(entry: dict[str, Any], index: int) -> str:
return _format_entry_label(
[
entry.get("degree", ""),
entry.get("institution", ""),
entry.get("years", ""),
],
f"Education #{index + 1}",
)
def _format_project_entry(entry: dict[str, Any], index: int) -> str:
return _format_entry_label(
[
entry.get("name", ""),
entry.get("role", ""),
entry.get("years", ""),
],
f"Project #{index + 1}",
)
def _normalize_entry(
entry: dict[str, Any],
ignore_keys: set[str] | None,
) -> dict[str, Any]:
"""Return an entry dict with ignored keys removed for diff comparisons.
Ignored keys are excluded so entry-level change detection can skip fields
that are diffed separately (e.g., description lists).
"""
if ignore_keys is None:
return entry
return {key: value for key, value in entry.items() if key not in ignore_keys}
def _append_entry_changes(
changes: list[ResumeFieldDiff],
field_key: str,
field_type: str,
original_items: list[dict[str, Any]],
improved_items: list[dict[str, Any]],
formatter: Callable[[dict[str, Any], int], str],
ignore_keys: set[str] | None = None,
) -> None:
min_len = min(len(original_items), len(improved_items))
for idx in range(min_len):
original_entry = original_items[idx]
improved_entry = improved_items[idx]
if _normalize_entry(original_entry, ignore_keys) != _normalize_entry(
improved_entry, ignore_keys
):
changes.append(
ResumeFieldDiff(
field_path=f"{field_key}[{idx}]",
field_type=field_type,
change_type="modified",
original_value=formatter(original_entry, idx),
new_value=formatter(improved_entry, idx),
confidence="medium",
)
)
for idx in range(min_len, len(improved_items)):
changes.append(
ResumeFieldDiff(
field_path=f"{field_key}[{idx}]",
field_type=field_type,
change_type="added",
new_value=formatter(improved_items[idx], idx),
confidence="high",
)
)
for idx in range(min_len, len(original_items)):
changes.append(
ResumeFieldDiff(
field_path=f"{field_key}[{idx}]",
field_type=field_type,
change_type="removed",
original_value=formatter(original_items[idx], idx),
confidence="medium",
)
)
def _normalize_string_list(value: Any, field_name: str) -> list[str]:
"""Normalize string list values and log any non-string entries.
Accepts lists of strings or objects containing name/label/value keys.
"""
if not isinstance(value, list):
return []
normalized: list[str] = []
invalid_count = 0
for item in value:
if isinstance(item, str):
stripped = item.strip()
if stripped:
normalized.append(stripped)
continue
if isinstance(item, dict):
candidate = item.get("name") or item.get("label") or item.get("value")
if isinstance(candidate, str):
stripped = candidate.strip()
if stripped:
normalized.append(stripped)
else:
invalid_count += 1
else:
invalid_count += 1
continue
if item is None:
continue
invalid_count += 1
if invalid_count:
logger.warning("Skipped non-string entries in %s: %d", field_name, invalid_count)
return normalized
def _build_string_index(value: Any, field_name: str) -> dict[str, str]:
"""Build a case-insensitive index for string list comparisons."""
items = _normalize_string_list(value, field_name)
index: dict[str, str] = {}
for item in items:
key = item.casefold()
if key not in index:
index[key] = item
return index
def _extract_description_list(entry: Any) -> list[str]:
if not isinstance(entry, dict):
return []
return _normalize_string_list(entry.get("description", []), "workExperience.description")
def _append_list_changes(
changes: list[ResumeFieldDiff],
field_path: str,
field_type: str,
original_items: list[str],
improved_items: list[str],
confidences: DiffConfidence,
) -> None:
matcher = SequenceMatcher(a=original_items, b=improved_items, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag == "delete":
for item in original_items[i1:i2]:
changes.append(
ResumeFieldDiff(
field_path=field_path,
field_type=field_type,
change_type="removed",
original_value=item,
confidence=confidences.removed,
)
)
elif tag == "insert":
for item in improved_items[j1:j2]:
changes.append(
ResumeFieldDiff(
field_path=field_path,
field_type=field_type,
change_type="added",
new_value=item,
confidence=confidences.added,
)
)
elif tag == "replace":
original_segment = original_items[i1:i2]
improved_segment = improved_items[j1:j2]
segment_len = max(len(original_segment), len(improved_segment))
for offset in range(segment_len):
original_value = (
original_segment[offset] if offset < len(original_segment) else None
)
new_value = (
improved_segment[offset] if offset < len(improved_segment) else None
)
if original_value is not None and new_value is not None:
changes.append(
ResumeFieldDiff(
field_path=field_path,
field_type=field_type,
change_type="modified",
original_value=original_value,
new_value=new_value,
confidence=confidences.modified,
)
)
elif new_value is not None:
changes.append(
ResumeFieldDiff(
field_path=field_path,
field_type=field_type,
change_type="added",
new_value=new_value,
confidence=confidences.added,
)
)
elif original_value is not None:
changes.append(
ResumeFieldDiff(
field_path=field_path,
field_type=field_type,
change_type="removed",
original_value=original_value,
confidence=confidences.removed,
)
)
def calculate_resume_diff(
original: dict[str, Any],
improved: dict[str, Any],
) -> tuple[ResumeDiffSummary, list[ResumeFieldDiff]]:
"""Compute the diff between original and improved resumes.
Args:
original: Original resume data dict
improved: Improved resume data dict
Returns:
(diff summary, detailed change list)
"""
changes: list[ResumeFieldDiff] = []
# 1. Compare summary
original_summary = (original.get("summary") or "").strip()
improved_summary = (improved.get("summary") or "").strip()
if original_summary != improved_summary:
if original_summary and not improved_summary:
change_type = "removed"
elif improved_summary and not original_summary:
change_type = "added"
else:
change_type = "modified"
changes.append(
ResumeFieldDiff(
field_path="summary",
field_type="summary",
change_type=change_type,
original_value=original_summary or None,
new_value=improved_summary or None,
confidence="medium",
)
)
# 2. Compare skills (order changes are intentionally ignored)
orig_skills = _build_string_index(
original.get("additional", {}).get("technicalSkills", []),
"additional.technicalSkills",
)
new_skills = _build_string_index(
improved.get("additional", {}).get("technicalSkills", []),
"additional.technicalSkills",
)
orig_skill_keys = set(orig_skills)
new_skill_keys = set(new_skills)
for skill_key in new_skill_keys - orig_skill_keys:
changes.append(ResumeFieldDiff(
field_path="additional.technicalSkills",
field_type="skill",
change_type="added",
new_value=new_skills[skill_key],
confidence="high" # Newly added skills are high risk
))
for skill_key in orig_skill_keys - new_skill_keys:
changes.append(ResumeFieldDiff(
field_path="additional.technicalSkills",
field_type="skill",
change_type="removed",
original_value=orig_skills[skill_key],
confidence="medium"
))
# 3. Compare work experience descriptions
original_experiences = original.get("workExperience", [])
improved_experiences = improved.get("workExperience", [])
max_experience_len = max(len(original_experiences), len(improved_experiences))
confidences = DiffConfidence(added="medium", removed="low", modified="medium")
for idx in range(max_experience_len):
original_entry = (
original_experiences[idx] if idx < len(original_experiences) else None
)
improved_entry = (
improved_experiences[idx] if idx < len(improved_experiences) else None
)
if not original_entry and not improved_entry:
continue
_append_list_changes(
changes,
field_path=f"workExperience[{idx}].description",
field_type="description",
original_items=_extract_description_list(original_entry),
improved_items=_extract_description_list(improved_entry),
confidences=confidences,
)
# 4. Compare certifications (order changes are intentionally ignored)
orig_certs = _build_string_index(
original.get("additional", {}).get("certificationsTraining", []),
"additional.certificationsTraining",
)
new_certs = _build_string_index(
improved.get("additional", {}).get("certificationsTraining", []),
"additional.certificationsTraining",
)
orig_cert_keys = set(orig_certs)
new_cert_keys = set(new_certs)
for cert_key in new_cert_keys - orig_cert_keys:
changes.append(ResumeFieldDiff(
field_path="additional.certificationsTraining",
field_type="certification",
change_type="added",
new_value=new_certs[cert_key],
confidence="high"
))
for cert_key in orig_cert_keys - new_cert_keys:
changes.append(ResumeFieldDiff(
field_path="additional.certificationsTraining",
field_type="certification",
change_type="removed",
original_value=orig_certs[cert_key],
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(
changes,
"workExperience",
"experience",
original.get("workExperience", []),
improved.get("workExperience", []),
_format_experience_entry,
{"description"},
)
_append_entry_changes(
changes,
"education",
"education",
original.get("education", []),
improved.get("education", []),
_format_education_entry,
{"description"}, # diffed separately in step 4b — avoid duplicate entry-level diffs
)
_append_entry_changes(
changes,
"personalProjects",
"project",
original.get("personalProjects", []),
improved.get("personalProjects", []),
_format_project_entry,
)
# 6. 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"]),
skills_removed=len([c for c in changes if c.field_type == "skill" and c.change_type == "removed"]),
descriptions_modified=len(
[
c
for c in changes
if c.field_type == "description" and c.change_type == "modified"
]
),
certifications_added=len([c for c in changes if c.field_type == "certification" and c.change_type == "added"]),
high_risk_changes=len([c for c in changes if c.confidence == "high"])
)
return summary, changes
def generate_improvements(job_keywords: dict[str, Any]) -> list[dict[str, Any]]:
"""Generate improvement suggestions based on job keywords.
Args:
job_keywords: Extracted job keywords
Returns:
List of improvement suggestions
"""
improvements = []
# Generate suggestions based on required skills
required_skills = job_keywords.get("required_skills", [])
for skill in required_skills[:3]: # Top 3 required skills
improvements.append(
{
"suggestion": f"Emphasized '{skill}' to match job requirements",
"lineNumber": None,
}
)
# Generate suggestions based on key responsibilities
responsibilities = job_keywords.get("key_responsibilities", [])
for resp in responsibilities[:2]: # Top 2 responsibilities
improvements.append(
{
"suggestion": f"Aligned experience with: {resp}",
"lineNumber": None,
}
)
# Default improvement if none generated
if not improvements:
improvements.append(
{
"suggestion": "Resume content optimized for better keyword alignment with job description",
"lineNumber": None,
}
)
return improvements