"""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 regex guts `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, ) _PK_MOBILE=re.compile( r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}" ) _PHONE_SPAN=re.compile( r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d" ) def _phone_digits(raw:str) -> str: digits=re.sub(r"\D","",raw or "") 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 scan_phone(text:str) -> str|None: """Regex scan of CV text — complete numbers only, never a truncated prefix.""" best=None best_score=-1 haystack=text or "" for pattern in (_PK_MOBILE,_PHONE_SPAN): for match in pattern.finditer(haystack): raw=re.sub(r"[\n\r]+"," ",match.group(0)) raw=re.sub(r"[\s\-()]+"," ",raw).strip() score=_phone_score(_phone_digits(raw)) if score>best_score: best_score=score best=raw if best_score>=180: return best return best def prefer_full_phone(*candidates) -> str|None: """Keep the candidate with the most digits (min 10). Truncated regex loses.""" best=None best_n=-1 for raw in candidates: value=(raw or "").strip() if not value: continue n=len(_phone_digits(value)) if n>=10 and n>best_n: best_n=n best=value return best 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")}