job-processor / services /cv_chunker.py
Eng-Musa's picture
optimize cv extraction
448c597
Raw
History Blame Contribute Delete
32.2 kB
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Set, Tuple
from services.taxonomy_client import (
DEFAULT_SKILL_ALIASES,
DEFAULT_CATEGORY_SKILLS,
SOFT_SKILL_KEYS,
_TECH_LOC_BLACKLIST,
_normalize,
_extract_skills,
_infer_category,
_extract_years,
compute_years_from_experience,
_detect_seniority,
refine_seniority_with_years,
_extract_cv_title,
)
# ---------------------------------------------------------------------------
# Markdown / HTML cleanup helpers
# ---------------------------------------------------------------------------
_MD_INLINE_RE = re.compile(
r"\*{1,3}([^*\n]*?)\*{1,3}"
r"|_{1,2}([^_\n]*?)_{1,2}"
r"|`([^`\n]+)`"
r"|\[([^\]]*)\]\([^)]*\)"
r"|\[([^\]]*)\]\[[^\]]*\]"
)
def _strip_md(text: str) -> str:
def _repl(m: re.Match) -> str:
return next((g for g in m.groups() if g is not None), "")
return re.sub(r"\s+", " ", _MD_INLINE_RE.sub(_repl, text)).strip()
def _strip_html(text: str) -> str:
text = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE)
return re.sub(r"<[^>]+>", " ", text)
def _clean_line(raw: str) -> str:
line = re.sub(r"^#+\s*", "", raw)
line = _strip_html(line)
line = _strip_md(line)
return re.sub(r"\s+", " ", line).strip()
_BULLET_RE = re.compile(r"^[-β€’*β–ͺβ–Ίβ–Έ>]\s+")
# ---------------------------------------------------------------------------
# Date helpers
# ---------------------------------------------------------------------------
_MONTH_PAT = r"(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)"
_YEAR_PAT = r"\d{4}"
_DATE_PAT = rf"(?:{_MONTH_PAT}\s+)?{_YEAR_PAT}"
_PERIOD_RE = re.compile(
rf"({_DATE_PAT})\s*[-–—to]+\s*({_DATE_PAT}|Present|Current|Now|Till\s+date|To\s+date|Ongoing)",
re.IGNORECASE,
)
_YEAR_ONLY_RE = re.compile(
rf"({_YEAR_PAT})\s*[-–—to]+\s*({_YEAR_PAT}|Present|Current|Now|Ongoing)",
re.IGNORECASE,
)
def _extract_period(text: str) -> Optional[str]:
m = _PERIOD_RE.search(text)
if m:
return f"{m.group(1)} - {m.group(2)}"
m = _YEAR_ONLY_RE.search(text)
if m:
return f"{m.group(1)} - {m.group(2)}"
return None
def _strip_period(text: str) -> str:
text = _PERIOD_RE.sub("", text)
text = _YEAR_ONLY_RE.sub("", text)
return re.sub(r"[|Β·β€’]\s*$", "", text).strip(" |·‒–—-,()")
# ---------------------------------------------------------------------------
# Section splitter
# ---------------------------------------------------------------------------
_SECTION_KEYWORDS: Dict[str, List[str]] = {
"summary": [
"summary", "profile", "about me", "objective", "career summary",
"professional summary", "executive summary", "personal statement",
"about", "bio", "career objective", "professional profile",
],
"contact": [
"contact", "contact information", "contact details", "personal details",
"personal information", "details", "reach me",
],
"links": [
"links", "online profiles", "social media", "web presence",
"profiles", "social profiles", "online presence", "web profiles",
],
"skills": [
"skills", "technical skills", "core skills", "competencies",
"key skills", "areas of expertise", "technologies", "tech stack",
"tools", "expertise", "capabilities", "core competencies",
"technical expertise", "skills & tools", "professional skills",
"key competencies",
],
"experience": [
"experience", "work experience", "professional experience", "employment",
"work history", "career history", "career", "employment history",
"relevant experience", "professional background", "internship",
"internships", "volunteer", "volunteering", "work",
],
"education": [
"education", "academic background", "academic history", "qualifications",
"educational background", "academics", "training", "certifications",
"degrees", "academic qualifications", "schools", "certificates",
],
"projects": [
"projects", "personal projects", "key projects", "notable projects",
"open source", "portfolio", "side projects", "selected projects",
"technical projects", "case studies",
],
"awards": [
"awards", "honors", "honours", "achievements", "accomplishments",
"recognition", "publications", "research", "accolades",
],
"languages": [
"languages spoken", "spoken languages", "human languages",
"language proficiency",
],
}
def _detect_section(stripped_line: str, raw_line: str) -> Optional[str]:
"""
Return the section key if this line is a section header, else None.
Guards applied (in order):
- Bullet lines β†’ never headers
- Table rows (β‰₯2 pipe chars, line starts with |) β†’ never headers.
This prevents markdown table cells like "| VOLUNTEER & LEADERSHIP |"
from being mistaken for section headers and redirecting the parser.
- Lines starting with digits β†’ never headers
- Lines containing date ranges β†’ never headers
- Lines longer than 60 normalised chars β†’ too long to be a header
- Lines with > 7 words β†’ likely prose
"""
if _BULLET_RE.match(raw_line.strip()):
return None
# ── Table-row guard (root cause of volunteer entries bleeding into experience)
raw_stripped = raw_line.strip()
if raw_stripped.startswith("|") and raw_stripped.count("|") >= 2:
return None
line_norm = _normalize(_strip_md(_strip_html(stripped_line)))
if not line_norm:
return None
# If header contains separated single letters (e.g., "c a r e e r s u m m a r y"), collapse them
if re.match(r"^(?:[a-z]\s+)+[a-z]$", line_norm):
line_norm = line_norm.replace(" ", "")
if re.match(r"^\d", line_norm):
return None
if _PERIOD_RE.search(stripped_line):
return None
if _YEAR_ONLY_RE.search(stripped_line):
return None
if len(line_norm) > 60:
return None
if len(line_norm.split()) > 7:
return None
if not re.search(r"[a-z]", line_norm):
return None
for section, keywords in _SECTION_KEYWORDS.items():
for kw in keywords:
if (
line_norm == kw
or line_norm.replace(" ", "") == kw.replace(" ", "")
or kw in line_norm
):
return section
return None
def _parse_cv_sections(cv_text: str) -> Dict[str, str]:
current = "summary"
sections: Dict[str, List[str]] = {k: [] for k in _SECTION_KEYWORDS}
for raw_line in cv_text.splitlines():
stripped = re.sub(r"^#+\s*", "", raw_line).strip()
if not stripped:
continue
section = _detect_section(stripped, raw_line)
if section:
current = section
continue
sections[current].append(raw_line)
return {k: "\n".join(v).strip() for k, v in sections.items()}
# ---------------------------------------------------------------------------
# Experience parser
# ---------------------------------------------------------------------------
_ROLE_WORDS = {
"engineer", "developer", "manager", "designer", "analyst", "intern",
"officer", "lead", "head", "director", "consultant", "assistant",
"specialist", "coordinator", "technician", "architect", "programmer",
"researcher", "executive", "trainer", "scientist", "strategist",
"advisor", "supervisor", "associate", "fellow",
# Healthcare
"nurse", "doctor", "pharmacist", "therapist", "teacher", "lecturer",
"instructor", "physiotherapist", "midwife", "clinician",
# Finance
"auditor", "accountant",
# Sales / other
"recruiter", "representative", "salesperson", "buyer",
# Intern variants
"attachment", "placement", "apprentice",
}
def _split_role_company(text: str) -> Tuple[str, str]:
for pat in [r"\s+(?:at|@|with)\s+", r"\s*\|\s*", r"\s*[—–,]\s+"]:
parts = re.split(pat, text, maxsplit=1)
if len(parts) == 2:
a, b = parts[0].strip().rstrip(","), parts[1].strip()
a_is_role = any(w in a.lower() for w in _ROLE_WORDS)
b_is_role = any(w in b.lower() for w in _ROLE_WORDS)
if a_is_role and not b_is_role:
return a, b
if b_is_role and not a_is_role:
return b, a
return a, b
return "", text
def _parse_experience(raw: str) -> List[Dict[str, Any]]:
entries: List[Dict[str, Any]] = []
def _new() -> Dict[str, Any]:
return {"role": "", "company": "", "period": None, "responsibilities": []}
current: Optional[Dict[str, Any]] = None
for raw_line in raw.splitlines():
stripped_raw = raw_line.strip()
if re.match(r"^\|[\s\-|:]+\|$", stripped_raw):
continue
if stripped_raw.startswith("|") and stripped_raw.count("|") >= 2:
cells = [_clean_line(c) for c in stripped_raw.strip("|").split("|")]
cells = [c for c in cells if c]
if not cells:
continue
line = cells[0]
extra_period = _extract_period(cells[-1]) if len(cells) > 1 else None
else:
line = _clean_line(raw_line)
extra_period = None
if not line:
continue
if re.match(r"^[\w\s]+:\s*$", line) and len(line) < 40:
continue
period = _extract_period(line) or extra_period
is_bullet = bool(_BULLET_RE.match(line))
clean = _strip_period(_BULLET_RE.sub("", line)).strip()
if is_bullet:
if current is not None and clean:
current["responsibilities"].append(clean)
continue
if period and not clean:
if current is not None and not current["period"]:
current["period"] = period
continue
if period and clean:
role, company = _split_role_company(clean)
if (
current is not None
and not current["period"]
and (current["role"] or current["company"])
):
if not current["role"] and role:
current["role"] = role
if not current["company"] and company:
current["company"] = company
current["period"] = period
else:
if current:
entries.append(current)
current = _new()
current["role"] = role
current["company"] = company
current["period"] = period
continue
is_role_line = any(w in clean.lower() for w in _ROLE_WORDS)
if current is None:
role, company = _split_role_company(clean)
current = _new()
current["role"] = role
current["company"] = company
elif not current["role"] and not current["company"]:
role, company = _split_role_company(clean)
current["role"] = role
current["company"] = company
elif (not current["role"] or not current["company"]) and not current["period"]:
if not current["role"] and is_role_line:
current["role"] = clean
elif not current["company"] and not is_role_line:
current["company"] = clean
else:
entries.append(current)
role, company = _split_role_company(clean)
current = _new()
current["role"] = role
current["company"] = company
else:
entries.append(current)
role, company = _split_role_company(clean)
current = _new()
current["role"] = role
current["company"] = company
if current:
entries.append(current)
return [e for e in entries if e.get("company") or e.get("role")]
# ---------------------------------------------------------------------------
# Education parser
# ---------------------------------------------------------------------------
_DEGREE_RE = re.compile(
r"\b(bachelor(?:'s)?|master(?:'s)?|phd|ph\.d|doctorate|diploma|certificate|"
r"degree|bsc|msc|b\.sc|m\.sc|ba|ma|mba|hnd|hons|honours|kcse|kcpe|btec|"
r"associate(?:'s)?|advanced\s+diploma|form|grade)\b",
re.IGNORECASE,
)
_INSTITUTION_RE = re.compile(
r"\b(university|college|school|institute|polytechnic|academy|"
r"institution|faculty|campus|seminary)\b",
re.IGNORECASE,
)
_EDU_SKIP_RE = re.compile(
r"^(volunteer|leadership|activities|honors?|awards?|publications?|"
r"extracurricular|memberships?|certifications?)\\b",
re.IGNORECASE,
)
_TABLE_SEP_RE = re.compile(r"^\|[\s\-|:]+\|$")
def _parse_education(raw: str) -> List[Dict[str, str]]:
entries: List[Dict[str, str]] = []
current: Dict[str, str] = {}
def _flush() -> None:
if current.get("institution") or current.get("degree"):
entries.append({
"institution": current.get("institution", "").strip(),
"degree": current.get("degree", "").strip(),
"period": current.get("period", "").strip(),
})
current.clear()
for raw_line in raw.splitlines():
stripped_raw = raw_line.strip()
if _TABLE_SEP_RE.match(stripped_raw):
continue
if stripped_raw.startswith("|") and stripped_raw.endswith("|"):
cells = [_clean_line(c) for c in stripped_raw.strip("|").split("|")]
cells = [c for c in cells if c]
if not cells:
continue
# Skip volunteer/leadership table rows
if re.match(r"^(volunteer|leadership)", cells[0], re.IGNORECASE):
_flush()
continue
line = cells[0]
extra_period = _extract_period(cells[-1]) if len(cells) > 1 else None
else:
line = _clean_line(raw_line)
extra_period = None
line = _BULLET_RE.sub("", line).strip()
if not line:
continue
if _EDU_SKIP_RE.match(line) and len(line) < 60:
_flush()
continue
period = _extract_period(line) or extra_period
clean = _strip_period(line).strip()
if not clean:
if period and current:
current["period"] = period
continue
has_degree = bool(_DEGREE_RE.search(clean))
has_inst = bool(_INSTITUTION_RE.search(clean))
if has_degree and has_inst:
deg_m = _DEGREE_RE.search(clean)
institution = clean[:deg_m.start()].strip(" ,–—|") if deg_m else ""
degree = clean[deg_m.start():].strip() if deg_m else clean
_flush()
current["institution"] = institution
current["degree"] = degree
current["period"] = period or ""
elif has_degree:
if current.get("institution") and not current.get("degree"):
current["degree"] = clean
if period:
current["period"] = period
else:
_flush()
current["degree"] = clean
current["institution"] = ""
current["period"] = period or ""
elif has_inst:
if current.get("degree") and not current.get("institution"):
current["institution"] = clean
if period:
current["period"] = period
else:
_flush()
current["institution"] = clean
current["degree"] = ""
current["period"] = period or ""
elif period:
if current:
current["period"] = period
if clean and not current.get("degree"):
current["degree"] = clean
else:
current["institution"] = clean
current["period"] = period
else:
if current and not current.get("institution") and not has_degree:
current["institution"] = clean
elif current and not current.get("degree"):
current["degree"] = clean
else:
_flush()
current["institution"] = clean
current["period"] = ""
_flush()
return [e for e in entries if e.get("institution") or e.get("degree")]
# ---------------------------------------------------------------------------
# Contact parser
# ---------------------------------------------------------------------------
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}")
_PHONE_RE = re.compile(r"(?:\+?[\d][\d\s().\-]{6,}\d)")
_LOCATION_RE = re.compile(
r"\b([A-Z][a-zA-Z]{2,}(?:\s[A-Z][a-zA-Z]{2,})*)\s*,\s*([A-Z][a-zA-Z]{2,}(?:\s[A-Z][a-zA-Z]{2,})*)\b"
)
_LABELLED_RE = re.compile(
r"\b(?:location|address|city|based\s+in|residing\s+in|located\s+in)\s*[:\-]?\s*([^\n|β€’Β·,]+(?:,\s*[^\n|β€’Β·]+)?)",
re.IGNORECASE,
)
def _parse_contact(raw: str, cv_full: str) -> Dict[str, Any]:
search_text = raw if raw.strip() else cv_full[:600]
email_m = _EMAIL_RE.search(search_text)
phone_m = _PHONE_RE.search(search_text)
if raw.strip():
loc_text = raw
else:
header_lines = [ln for ln in cv_full.splitlines() if ln.strip()][:8]
loc_text = "\n".join(header_lines)
location = ""
lbl_m = _LABELLED_RE.search(loc_text)
if lbl_m:
location = lbl_m.group(1).strip()
else:
# Require location text to be in close proximity to contact info or explicit header to prevent matching technical skills text
for line in loc_text.splitlines():
line_str = line.strip()
if not line_str or re.search(r"languages|frameworks|tools|databases|skills|developer|engineer|experience", line_str, re.IGNORECASE):
continue
loc_m = _LOCATION_RE.search(line_str)
if loc_m:
w1 = (loc_m.group(1) or "").lower()
w2 = (loc_m.group(2) or "").lower()
if w1 not in _TECH_LOC_BLACKLIST and w2 not in _TECH_LOC_BLACKLIST:
location = loc_m.group(0).strip()
break
return {
"email": email_m.group(0).strip() if email_m else "",
"phone": phone_m.group(0).strip() if phone_m else "",
"location": location,
}
# ---------------------------------------------------------------------------
# Links parser
# ---------------------------------------------------------------------------
_URL_RE = re.compile(r"(?:https?://|www\.)[^\s,;\"'>()\]]+", re.IGNORECASE)
_BARE_DOMAIN_RE = re.compile(
r"(?:github\.com|linkedin\.com|behance\.net|dribbble\.com|medium\.com"
r"|dev\.to|twitter\.com|x\.com|gitlab\.com|bitbucket\.org)"
r"[^\s,;\"'>()\]]*",
re.IGNORECASE,
)
_PLATFORM_MAP: List[Tuple[str, str]] = [
("github.com", "GitHub"),
("gitlab.com", "GitLab"),
("bitbucket.org", "Bitbucket"),
("linkedin.com", "LinkedIn"),
("behance.net", "Behance"),
("dribbble.com", "Dribbble"),
("medium.com", "Medium"),
("dev.to", "Dev.to"),
("twitter.com", "Twitter"),
("x.com", "X (Twitter)"),
("vercel.app", "Portfolio"),
("netlify.app", "Portfolio"),
("github.io", "Portfolio"),
]
_LABEL_HINTS = {
"github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket",
"linkedin": "LinkedIn", "behance": "Behance", "dribbble": "Dribbble",
"medium": "Medium", "twitter": "Twitter", "portfolio": "Portfolio",
"website": "Website", "blog": "Blog", "personal": "Portfolio",
}
def _label_url(url: str, context_line: str) -> str:
url_lower = url.lower()
for domain, label in _PLATFORM_MAP:
if domain in url_lower:
return label
escaped = re.escape(url.split("?")[0].rstrip("/"))
md_m = re.search(r"\[([^\]]+)\]\s*\(" + escaped, context_line, re.IGNORECASE)
if md_m:
label_text = md_m.group(1).lower()
for hint, label in _LABEL_HINTS.items():
if hint in label_text:
return label
ctx = context_line.lower()
for hint, label in _LABEL_HINTS.items():
if hint in ctx:
return label
return "Website"
def _parse_links(raw: str, cv_full: str) -> List[Dict[str, str]]:
search_text = raw if raw.strip() else cv_full[:1500]
results: List[Dict[str, str]] = []
seen: Set[str] = set()
for line in search_text.splitlines():
found: List[str] = []
for m in _URL_RE.finditer(line):
found.append(m.group(0).rstrip(".,:;)\"'"))
for m in _BARE_DOMAIN_RE.finditer(line):
url = m.group(0).rstrip(".,:;)\"'")
if not any(url in u for u in found):
found.append(url)
for url in found:
normalised = url if url.startswith("http") else f"https://{url}"
if normalised in seen:
continue
seen.add(normalised)
results.append({"label": _label_url(normalised, line), "url": normalised})
return results
# ---------------------------------------------------------------------------
# Skills parser β€” returns technical / soft split
# ---------------------------------------------------------------------------
_SKILL_PREFIX_RE = re.compile(r"^\*{0,2}[\w][\w\s&/()+\-]+:\*{0,2}\s*")
def _parse_skills(raw: str, cv_full: str) -> Dict[str, Any]:
canonical = sorted(_extract_skills(cv_full, DEFAULT_SKILL_ALIASES))
technical = [s for s in canonical if s not in SOFT_SKILL_KEYS]
soft = [s for s in canonical if s in SOFT_SKILL_KEYS]
raw_items: List[str] = []
for line in raw.splitlines():
line = _clean_line(line)
line = _BULLET_RE.sub("", line).strip()
line = _SKILL_PREFIX_RE.sub("", line).strip()
for item in re.split(r"[,|;β€’Β·/]", line):
item = re.sub(r"^\W+|\W+$", "", item).strip()
if (
item
and 1 < len(item) < 60
and not re.match(r"^\d+$", item)
and not re.search(r"@|http|www\.", item)
):
raw_items.append(item)
seen_items: Set[str] = set()
deduped: List[str] = []
for item in raw_items:
key = item.lower()
if key not in seen_items:
seen_items.add(key)
deduped.append(item)
return {
"items": deduped,
"canonical": canonical,
"technical": technical,
"soft": soft,
}
# ---------------------------------------------------------------------------
# Projects parser β†’ [{name, description, tech_stack, url}]
# ---------------------------------------------------------------------------
def _parse_projects(raw: str) -> List[Dict[str, Any]]:
entries: List[Dict[str, Any]] = []
def _new_proj() -> Dict[str, Any]:
return {"name": "", "description": "", "tech_stack": [], "url": ""}
current: Optional[Dict[str, Any]] = None
for raw_line in raw.splitlines():
line = _clean_line(raw_line)
if not line:
continue
is_bullet = bool(_BULLET_RE.match(line))
clean = _BULLET_RE.sub("", line).strip()
url_m = _URL_RE.search(raw_line) or _BARE_DOMAIN_RE.search(raw_line)
url = url_m.group(0).rstrip(".,:;)\"'") if url_m else ""
if url and not url.startswith("http"):
url = f"https://{url}"
if is_bullet or current is None:
if current and current["name"]:
entries.append(current)
current = _new_proj()
tech_m = re.search(r"\(([^)]{2,80})\)", clean)
if tech_m:
current["tech_stack"] = [
t.strip() for t in tech_m.group(1).split(",") if t.strip()
]
clean = clean.replace(tech_m.group(0), "").strip()
for sep in [":", "–", "β€”", " - "]:
if sep in clean:
parts = clean.split(sep, 1)
current["name"] = parts[0].strip()
current["description"] = parts[1].strip()
break
else:
clean_no_url = re.sub(r"(?:https?://|www\.)\S+", "", clean).strip()
current["name"] = clean_no_url or clean
current["url"] = url
elif current is not None:
if not current["description"] and clean:
current["description"] = clean
if url and not current["url"]:
current["url"] = url
if current and current["name"]:
entries.append(current)
return entries
# ---------------------------------------------------------------------------
# Awards parser β†’ [{title, year, issuer}]
# ---------------------------------------------------------------------------
def _parse_awards(raw: str) -> List[Dict[str, str]]:
entries: List[Dict[str, str]] = []
for raw_line in raw.splitlines():
line = _clean_line(raw_line)
line = _BULLET_RE.sub("", line).strip()
if not line:
continue
period = _extract_period(line)
year = ""
if period:
year_m = re.search(r"\d{4}", period)
year = year_m.group(0) if year_m else ""
clean = _strip_period(line).strip()
if not clean:
continue
issuer = ""
title = clean
for sep in [" by ", " from ", ", ", " β€” ", " – ", " - "]:
if sep.lower() in clean.lower():
idx = clean.lower().index(sep.lower())
title = clean[:idx].strip()
issuer = clean[idx + len(sep):].strip()
break
entries.append({"title": title, "year": year, "issuer": issuer})
return entries
# ---------------------------------------------------------------------------
# Completeness score
# ---------------------------------------------------------------------------
def _compute_completeness(
cv_title: str,
years: int,
contact: Dict[str, Any],
skills_chunk: Dict[str, Any],
experience: List[Dict[str, Any]],
education: List[Dict[str, Any]],
summary: str,
links: List[Dict[str, str]],
) -> int:
"""Returns 0–100 score indicating how complete the parsed CV is."""
score = 0
if summary and len(summary) > 50: score += 20
if experience: score += 25
if len(skills_chunk.get("items", [])) >= 3: score += 15
if education: score += 15
if contact.get("email"): score += 10
if years > 0: score += 5
if links: score += 5
if cv_title and cv_title != "Unknown": score += 5
return min(100, score)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def chunk_cv(cv_markdown: str, embedder=None) -> Dict[str, Any]:
"""
Parse any CV markdown string into structured, cleaned chunks.
Parameters
----------
cv_markdown : str
Raw markdown output from CVConverter.
embedder : callable, optional
A function ``(text: str) -> np.ndarray`` (e.g. ``matcher._embed``).
When provided, section-aware embeddings are computed once here and
returned under ``section_embeddings`` so ``/match-cv`` can reuse
them without re-encoding.
Returns
-------
dict with keys:
cv_title, seniority, years_experience, category,
completeness_score, chunks{...},
section_embeddings (only when embedder is given)
"""
sections = _parse_cv_sections(cv_markdown)
# ── Title ──────────────────────────────────────────────────────────────
cv_title = _extract_cv_title(cv_markdown, clean_line_fn=_clean_line)
# ── Skills & category ──────────────────────────────────────────────────
cv_skills = _extract_skills(cv_markdown, DEFAULT_SKILL_ALIASES)
cv_category = _infer_category(cv_markdown, cv_skills, DEFAULT_CATEGORY_SKILLS)
# ── Parse structured sections ─────────────────────────────────────────
contact_chunk = _parse_contact(sections.get("contact", ""), cv_markdown)
links_chunk = _parse_links(sections.get("links", ""), cv_markdown)
skills_chunk = _parse_skills(sections.get("skills", ""), cv_markdown)
if not skills_chunk["items"]:
skills_chunk["items"] = sorted(list(cv_skills))
experience_entries = _parse_experience(sections.get("experience", ""))
education_entries = _parse_education(sections.get("education", ""))
projects_entries = _parse_projects(sections.get("projects", ""))
awards_entries = _parse_awards(sections.get("awards", ""))
# ── Years of experience: date-ranges primary, text mention fallback ────
computed_years = compute_years_from_experience(experience_entries)
text_years = _extract_years(cv_markdown)
cv_years = computed_years if computed_years > 0 else text_years
# ── Seniority: keyword detection then refine with years ────────────────
seniority_text = (
cv_title + "\n"
+ sections.get("experience", "")
+ "\n" + cv_markdown[:600]
)
cv_seniority = _detect_seniority(seniority_text, is_cv=True)
cv_seniority = refine_seniority_with_years(cv_seniority, cv_years)
# ── Completeness ───────────────────────────────────────────────────────
completeness = _compute_completeness(
cv_title, cv_years, contact_chunk, skills_chunk,
experience_entries, education_entries,
sections.get("summary", ""), links_chunk,
)
result: Dict[str, Any] = {
"cv_title": cv_title,
"seniority": cv_seniority,
"years_experience": cv_years,
"category": cv_category,
"completeness_score": completeness,
"chunks": {
"summary": sections.get("summary", "").strip(),
"contact": contact_chunk,
"links": links_chunk,
"skills": skills_chunk,
"experience": experience_entries,
"education": education_entries,
"projects": projects_entries,
"awards": awards_entries,
},
}
# ── Optional: section-aware ML embeddings ─────────────────────────────
if embedder is not None:
summary_text = sections.get("summary", "").strip()
skills_text = " ".join(skills_chunk["items"])[:2000]
exp_text = " ".join(
f"{e.get('role', '')} at {e.get('company', '')} "
+ " ".join(e.get("responsibilities", []))
for e in experience_entries
)[:3000]
result["section_embeddings"] = {
"summary": embedder(summary_text).tolist() if summary_text else None,
"skills": embedder(skills_text).tolist() if skills_text else None,
"experience": embedder(exp_text).tolist() if exp_text else None,
}
return result