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"", " ", 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