188 lines
5.9 KiB
Python
188 lines
5.9 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 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"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
|
|
_DIGIT_GROUP=re.compile(r"\+?\d+")
|
|
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
|
|
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
|
|
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
|
|
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
|
|
|
|
|
|
def _normalize_phone_text(text:str) -> 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")}
|