HR-ATS-Portal/backend/employment_agent/plugins.py

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")}