Spaces:
Running
Running
| 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 |