"""CV contact parsers — phone and LinkedIn, decorated by employment_agent.decorators. Pure module: no FastAPI imports and no HTTPException. Call like the rest of the backend: fields=parse_phone({"phone":raw},resume_text) phone=fields["phone"] fields=parse_linkedin({"linkedin_url":raw},resume_text) url=fields["linkedin_url"] `scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the stacked parser cannot recurse into itself. """ from __future__ import annotations import re from employment_agent.decorators import ( clamp_linkedin_url, clamp_phone, prefer_extracted_phone, ) # PDF extraction uses en/em dashes, nbsp, and bullets as digit separators. _DASH_TO_HYPHEN=str.maketrans({ "\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-", "\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-", "\uFF0D":"-", }) _STRIP_INVISIBLE="".join(( "\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff", )) _DIGIT_TO_ASCII=str.maketrans({ **{chr(0x0660+i):str(i) for i in range(10)}, **{chr(0x06F0+i):str(i) for i in range(10)}, **{chr(0xFF10+i):str(i) for i in range(10)}, }) _OCR_O=re.compile(r"(? str: raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN) raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ") raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n") for ch in _STRIP_INVISIBLE: raw=raw.replace(ch,"") return _OCR_O.sub("0",raw) def _digits_only(raw:str) -> str: return re.sub(r"\D","",_normalize_phone_text(raw or "")) def _phone_digits(raw:str) -> str: digits=_digits_only(raw) if digits.startswith("00"): digits=digits[2:] return digits def _phone_score(digits:str) -> int: """Prefer complete PK mobiles; reject CNIC-shaped 13-digit runs.""" n=len(digits) if n<10 or n>15: return -1 if n==13 and not digits.startswith("92"): return -1 if digits.startswith("03") and n==11: return 200 if digits.startswith("923") and n==12: return 190 if digits.startswith("3") and n==10: return 180 return n def _phone_keys(digits:str) -> set[str]: """03XX / +92 3XX / 3XX national forms of the same PK mobile.""" d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "") if d.startswith("00"): d=d[2:] keys={d} if d.startswith("92") and len(d)>=12: rest=d[2:] keys.add(rest) if rest.startswith("3"): keys.add("0"+rest) if d.startswith("0") and len(d)>=11: keys.add(d[1:]) keys.add("92"+d[1:]) if d.startswith("3") and len(d)==10: keys.add("0"+d) keys.add("92"+d) return {k for k in keys if len(k)>=10} def phone_in_resume(digits:str,resume_text:str) -> bool: """True when this number (or its 03 / +92 twin) appears in the CV digits.""" haystack=_digits_only(resume_text) if not haystack: return True return any(key in haystack for key in _phone_keys(digits)) def _tidy_raw(raw:str) -> str: compact=re.sub(r"[\n\r]+"," ",raw or "") compact=re.sub(r"[ \t]+"," ",compact) return compact.strip(" \t-./()[]{},:|•·∙_") def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]: value=_tidy_raw(raw) score=_phone_score(_phone_digits(value)) if score>best_score: return value,score return best,best_score def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]: groups=list(_DIGIT_GROUP.finditer(text)) for i,start_g in enumerate(groups): acc=start_g.group(0) end=start_g.end() best,best_score=_consider(acc,best,best_score) for nxt in groups[i+1:]: gap=text[end:nxt.start()] if not _GAP_OK.match(gap): break nxt_digits=nxt.group(0).lstrip("+") if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10: break combined=_phone_digits(acc+nxt.group(0)) if len(combined)>15: break acc=text[start_g.start():nxt.end()] end=nxt.end() best,best_score=_consider(acc,best,best_score) return best,best_score def scan_phone(text:str) -> str|None: """Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me.""" haystack=_normalize_phone_text(text or "") best,best_score=None,-1 best,best_score=_scan_digit_groups(haystack,best,best_score) for pattern in (_WA_ME,_TEL_URI): for match in pattern.finditer(haystack): best,best_score=_consider(match.group(1),best,best_score) return best def prefer_full_phone(*candidates) -> str|None: """Keep the strongest complete number. Truncated / CNIC-shaped values lose.""" best,best_score=None,-1 for raw in candidates: value=(raw or "").strip() if not value: continue best,best_score=_consider(value,best,best_score) return best if best_score>=0 else None def _as_str(data,key): if not isinstance(data,dict): return "" value=data.get(key) return value.strip() if isinstance(value,str) else "" @prefer_extracted_phone @clamp_phone def parse_phone(data,resume_text=""): """Form/CV phone through clamp_phone + prefer_extracted_phone.""" return {"phone":_as_str(data,"phone")} @clamp_linkedin_url def parse_linkedin(data,resume_text=""): """Stored or pasted LinkedIn URL through clamp_linkedin_url.""" return {"linkedin_url":_as_str(data,"linkedin_url")}