108 lines
2.8 KiB
Python
108 lines
2.8 KiB
Python
"""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")}
|