Pure transform functions (no LLM, no DB) bridging the two resume representations: library struct_json ↔ ResumePayload content fields. Exports library_to_profile_content, profile_to_library, make_auto_backup_name, blank_fields_on_import. 22 tests, all passing.
211 lines
7.7 KiB
Python
211 lines
7.7 KiB
Python
"""
|
|
Resume format transform — library ↔ profile.
|
|
|
|
Converts between:
|
|
- Library format: struct_json produced by resume_parser.parse_resume()
|
|
{name, email, phone, career_summary, experience[{title,company,start_date,end_date,location,bullets[]}],
|
|
education[{institution,degree,field,start_date,end_date}], skills[], achievements[]}
|
|
- Profile content format: ResumePayload content fields (plain_text_resume.yaml)
|
|
{name, surname, email, phone, career_summary,
|
|
experience[{title,company,period,location,industry,responsibilities,skills[]}],
|
|
education[{institution,degree,field,start_date,end_date}],
|
|
skills[], achievements[]}
|
|
|
|
Profile metadata fields (salary, work prefs, self-ID, PII) are never touched here.
|
|
|
|
License: MIT
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Any
|
|
|
|
|
|
_CONTENT_FIELDS = frozenset({
|
|
"name", "surname", "email", "phone", "career_summary",
|
|
"experience", "skills", "education", "achievements",
|
|
})
|
|
|
|
|
|
def library_to_profile_content(struct_json: dict[str, Any]) -> dict[str, Any]:
|
|
"""Transform a library struct_json to ResumePayload content fields.
|
|
|
|
Returns only content fields. Caller is responsible for merging with existing
|
|
metadata fields (salary, preferences, self-ID) so they are not overwritten.
|
|
|
|
Lossy for experience[].industry (always blank — parser does not capture it).
|
|
name is split on first space into name/surname.
|
|
"""
|
|
full_name: str = struct_json.get("name") or ""
|
|
parts = full_name.split(" ", 1)
|
|
name = parts[0]
|
|
surname = parts[1] if len(parts) > 1 else ""
|
|
|
|
experience = []
|
|
for exp in struct_json.get("experience") or []:
|
|
start = (exp.get("start_date") or "").strip()
|
|
end = (exp.get("end_date") or "").strip()
|
|
if start and end:
|
|
period = f"{start} \u2013 {end}"
|
|
elif start:
|
|
period = start
|
|
elif end:
|
|
period = end
|
|
else:
|
|
period = ""
|
|
|
|
bullets: list[str] = exp.get("bullets") or []
|
|
responsibilities = "\n".join(b for b in bullets if b)
|
|
|
|
experience.append({
|
|
"title": exp.get("title") or "",
|
|
"company": exp.get("company") or "",
|
|
"period": period,
|
|
"location": exp.get("location") or "",
|
|
"industry": "", # not captured by parser
|
|
"responsibilities": responsibilities,
|
|
"skills": [],
|
|
})
|
|
|
|
education = []
|
|
for edu in struct_json.get("education") or []:
|
|
education.append({
|
|
"institution": edu.get("institution") or "",
|
|
"degree": edu.get("degree") or "",
|
|
"field": edu.get("field") or "",
|
|
"start_date": edu.get("start_date") or "",
|
|
"end_date": edu.get("end_date") or "",
|
|
})
|
|
|
|
return {
|
|
"name": name,
|
|
"surname": surname,
|
|
"email": struct_json.get("email") or "",
|
|
"phone": struct_json.get("phone") or "",
|
|
"career_summary": struct_json.get("career_summary") or "",
|
|
"experience": experience,
|
|
"skills": list(struct_json.get("skills") or []),
|
|
"education": education,
|
|
"achievements": list(struct_json.get("achievements") or []),
|
|
}
|
|
|
|
|
|
def profile_to_library(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
"""Transform ResumePayload content fields to (plain_text, struct_json).
|
|
|
|
Inverse of library_to_profile_content. The plain_text is a best-effort
|
|
reconstruction for display and re-parsing. struct_json is the canonical
|
|
structured representation stored in the resumes table.
|
|
"""
|
|
name_parts = [payload.get("name") or "", payload.get("surname") or ""]
|
|
full_name = " ".join(p for p in name_parts if p).strip()
|
|
|
|
career_summary = (payload.get("career_summary") or "").strip()
|
|
|
|
lines: list[str] = []
|
|
if full_name:
|
|
lines.append(full_name)
|
|
email = payload.get("email") or ""
|
|
phone = payload.get("phone") or ""
|
|
if email:
|
|
lines.append(email)
|
|
if phone:
|
|
lines.append(phone)
|
|
|
|
if career_summary:
|
|
lines += ["", "SUMMARY", career_summary]
|
|
|
|
experience_structs = []
|
|
for exp in payload.get("experience") or []:
|
|
title = (exp.get("title") or "").strip()
|
|
company = (exp.get("company") or "").strip()
|
|
period = (exp.get("period") or "").strip()
|
|
location = (exp.get("location") or "").strip()
|
|
|
|
# Split period back to start_date / end_date
|
|
sep_period = period.replace("\u2013", "-").replace("\u2014", "-")
|
|
date_parts = [p.strip() for p in sep_period.split("-", 1)]
|
|
start_date = date_parts[0] if date_parts else ""
|
|
end_date = date_parts[1] if len(date_parts) > 1 else ""
|
|
|
|
resp = (exp.get("responsibilities") or "").strip()
|
|
bullets = [b.strip() for b in resp.split("\n") if b.strip()]
|
|
|
|
if title or company:
|
|
header = " | ".join(p for p in [title, company, period] if p)
|
|
lines += ["", header]
|
|
if location:
|
|
lines.append(location)
|
|
for b in bullets:
|
|
lines.append(f"\u2022 {b}")
|
|
|
|
experience_structs.append({
|
|
"title": title,
|
|
"company": company,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
"location": location,
|
|
"bullets": bullets,
|
|
})
|
|
|
|
skills: list[str] = list(payload.get("skills") or [])
|
|
if skills:
|
|
lines += ["", "SKILLS", ", ".join(skills)]
|
|
|
|
education_structs = []
|
|
for edu in payload.get("education") or []:
|
|
institution = (edu.get("institution") or "").strip()
|
|
degree = (edu.get("degree") or "").strip()
|
|
field = (edu.get("field") or "").strip()
|
|
start_date = (edu.get("start_date") or "").strip()
|
|
end_date = (edu.get("end_date") or "").strip()
|
|
if institution or degree:
|
|
label = " ".join(p for p in [degree, field] if p)
|
|
lines.append(f"{label} \u2014 {institution}" if institution else label)
|
|
education_structs.append({
|
|
"institution": institution,
|
|
"degree": degree,
|
|
"field": field,
|
|
"start_date": start_date,
|
|
"end_date": end_date,
|
|
})
|
|
|
|
achievements: list[str] = list(payload.get("achievements") or [])
|
|
|
|
struct_json: dict[str, Any] = {
|
|
"name": full_name,
|
|
"email": email,
|
|
"phone": phone,
|
|
"career_summary": career_summary,
|
|
"experience": experience_structs,
|
|
"skills": skills,
|
|
"education": education_structs,
|
|
"achievements": achievements,
|
|
}
|
|
|
|
plain_text = "\n".join(lines).strip()
|
|
return plain_text, struct_json
|
|
|
|
|
|
def make_auto_backup_name(source_name: str) -> str:
|
|
"""Generate a timestamped auto-backup name.
|
|
|
|
Example: "Auto-backup before Senior Engineer Resume — 2026-04-16"
|
|
"""
|
|
today = date.today().isoformat()
|
|
return f"Auto-backup before {source_name} \u2014 {today}"
|
|
|
|
|
|
def blank_fields_on_import(struct_json: dict[str, Any]) -> list[str]:
|
|
"""Return content field names that will be blank after a library→profile import.
|
|
|
|
Used to warn the user in the confirmation modal so they know what to fill in.
|
|
"""
|
|
blank: list[str] = []
|
|
if struct_json.get("experience"):
|
|
# industry is always blank — parser never captures it
|
|
blank.append("experience[].industry")
|
|
# location may be blank for some entries
|
|
if any(not (e.get("location") or "").strip() for e in struct_json["experience"]):
|
|
blank.append("experience[].location")
|
|
return blank
|