281 lines
9.5 KiB
Python
281 lines
9.5 KiB
Python
"""Employment response decorators for `parse_employment_response`.
|
|
|
|
Pure module: no FastAPI imports, no HTTPException, and no module-level state.
|
|
Mirrors job/candidate/decorators.py — stacked wrappers that clean LLM output
|
|
before the task persists it:
|
|
|
|
parse_employment_response -> clamp_phone -> prefer_extracted_phone
|
|
-> clamp_linkedin_url -> clamp_education_to_resume
|
|
-> clamp_company_to_resume
|
|
|
|
Generic factories (`clamp_field`, `clamp_in_resume`) bind a field name; the
|
|
assigned aliases below are what call sites stack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from functools import wraps
|
|
|
|
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_NAME,NO_PHONE
|
|
from global_cities import CITY_BY_KEY,CITY_RE
|
|
|
|
_CITY_SENTINELS=frozenset({
|
|
NO_CITY.lower(),"none","null","n/a","-","na","n.a.","n.a",
|
|
})
|
|
_CITY_DROP=frozenset({
|
|
"dha","cantt","cantonment","cant","phase","sector","area","district",
|
|
"tehsil","division","housing","society","scheme","block","street","house",
|
|
"near","colony","neighborhood","neighbourhood","suburb",
|
|
})
|
|
_SECTOR_RE=re.compile(r"^(?:[a-z]-?\d+[a-z]?|\d+[a-z]?)$",re.I)
|
|
|
|
|
|
def require_json_object(func):
|
|
"""Reject non-dict LLM payloads before field parsing runs."""
|
|
|
|
@wraps(func)
|
|
def wrapper(data,resume_text="",*args,**kwargs):
|
|
if not isinstance(data,dict):
|
|
raise RuntimeError(f"model did not return a JSON object: {data!r}")
|
|
return func(data,resume_text,*args,**kwargs)
|
|
|
|
return wrapper
|
|
|
|
|
|
def clamp_field(key,clean):
|
|
"""Run `clean(value, resume_text)` on one dict key; leave the rest alone."""
|
|
|
|
def decorator(func):
|
|
@wraps(func)
|
|
def wrapper(data,resume_text="",*args,**kwargs):
|
|
fields=func(data,resume_text,*args,**kwargs)
|
|
fields[key]=clean(fields.get(key),resume_text)
|
|
return fields
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def clamp_in_resume(key,sentinel):
|
|
"""Keep the field only when it appears in resume_text; else `sentinel`."""
|
|
|
|
def clean(value,resume_text):
|
|
text=(value or "").strip()
|
|
if not text or text.lower()==sentinel.lower():
|
|
return sentinel
|
|
haystack=(resume_text or "").lower()
|
|
if text.lower() not in haystack:
|
|
return sentinel
|
|
return text
|
|
return clamp_field(key,clean)
|
|
|
|
|
|
def _clean_linkedin(value,resume_text):
|
|
"""Keep a LinkedIn URL only when the CV evidences it. Sentinel / invented → None."""
|
|
url=(value or "").strip()
|
|
if not url or url.lower() in (NO_LINKEDIN.lower(),"none","null","n/a","-"):
|
|
return None
|
|
lowered=url.lower()
|
|
if "linkedin.com/company/" in lowered:
|
|
return None
|
|
if "linkedin.com" not in lowered and "lnkd.in" not in lowered:
|
|
return None
|
|
if not lowered.startswith("http://") and not lowered.startswith("https://"):
|
|
url="https://"+url.lstrip("/")
|
|
text=(resume_text or "").strip()
|
|
if not text:
|
|
return url
|
|
from linkedin_utils import slug_from_url,slugs_from_text
|
|
agent_slug=slug_from_url(url)
|
|
if agent_slug:
|
|
return url if agent_slug in slugs_from_text(text) else None
|
|
if "lnkd.in" in lowered:
|
|
from linkedin_utils import profile_url_from_text
|
|
evidenced=profile_url_from_text(text)
|
|
if evidenced and "lnkd.in" in evidenced.lower():
|
|
return evidenced
|
|
return None
|
|
|
|
|
|
def _clean_phone(value,resume_text):
|
|
text=(value or "").strip()
|
|
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
|
return None
|
|
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
|
digits=_phone_digits(text)
|
|
if _phone_score(digits)<0:
|
|
return None
|
|
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
|
return None
|
|
return text
|
|
|
|
|
|
def canonical_city(text):
|
|
"""Write-time only: messy locality → one proper city name, or None.
|
|
|
|
Looks up `global_cities.Countries` (every country, Pakistan included).
|
|
"Karachi(Malir)" / "London(Westminster)" / "DHA Karachi" / "Wah Cantt"
|
|
map to the listed city. Sentinels and blanks are None. Never rejects a CV.
|
|
"""
|
|
raw=(text or "").strip()
|
|
if not raw or raw.lower() in _CITY_SENTINELS:
|
|
return None
|
|
known=CITY_BY_KEY.get(raw.lower())
|
|
if known:
|
|
return known
|
|
normalised=re.sub(r"[()\[\]{}]"," ",raw)
|
|
normalised=re.sub(r"[,/;|]+"," ",normalised)
|
|
normalised=re.sub(r"\s+"," ",normalised).strip()
|
|
if not normalised:
|
|
return None
|
|
known=CITY_BY_KEY.get(normalised.lower())
|
|
if known:
|
|
return known
|
|
match=CITY_RE.search(normalised.lower())
|
|
if match:
|
|
return CITY_BY_KEY[match.group(0)]
|
|
leftover=[]
|
|
for token in normalised.split():
|
|
lowered=token.lower()
|
|
if lowered in _CITY_DROP or _SECTOR_RE.fullmatch(token):
|
|
continue
|
|
leftover.append(token)
|
|
if not leftover:
|
|
return None
|
|
cleaned=" ".join(leftover)
|
|
known=CITY_BY_KEY.get(cleaned.lower())
|
|
if known:
|
|
return known
|
|
if len(cleaned)>40 or len(leftover)>3:
|
|
return leftover[0][:1].upper()+leftover[0][1:]
|
|
return " ".join(t[:1].upper()+t[1:] for t in leftover)
|
|
|
|
|
|
def _clean_city(value,resume_text):
|
|
"""Optional residence city. Sentinel / blank → None. Never rejects the CV.
|
|
|
|
After the employment-agent JSON is parsed, clamp to a proper city name so
|
|
a model that still returns "Karachi(Malir)" is stored as "Karachi".
|
|
"""
|
|
return canonical_city(value)
|
|
|
|
|
|
def _clean_skills(value,resume_text):
|
|
"""Keep only skills the resume actually contains, deduplicated, capped at 30.
|
|
|
|
Same discipline as the company/education clamps: the model is asked for the
|
|
resume's own spelling, so anything absent from the text is an invention. A
|
|
skill chip is read as "this is in the CV", and the bank filters on it.
|
|
|
|
Deduplication runs BEFORE the ceiling so a model that returns 31 near-
|
|
duplicates collapses under the limit instead of losing real skills.
|
|
"""
|
|
if not isinstance(value,list):
|
|
return []
|
|
haystack=(resume_text or "").lower()
|
|
kept=[]
|
|
seen=set()
|
|
for entry in value:
|
|
if not isinstance(entry,str):
|
|
continue
|
|
text=entry.strip()
|
|
if not text or len(text)>60:
|
|
continue
|
|
lowered=text.lower()
|
|
if lowered in seen:
|
|
continue
|
|
if haystack and lowered not in haystack:
|
|
continue
|
|
seen.add(lowered)
|
|
kept.append(text)
|
|
return kept[:30]
|
|
|
|
|
|
def _clean_name(value,resume_text):
|
|
"""Full name from the resume header. Invented / email-shaped values drop."""
|
|
text=(value or "").strip()
|
|
if not text or text.lower() in {NO_NAME.lower(),"none","null","n/a","-"}:
|
|
return ""
|
|
if "@" in text or len(text)>120:
|
|
return ""
|
|
haystack=(resume_text or "").lower()
|
|
first=text.split()[0].lower()
|
|
if haystack and first not in haystack:
|
|
return ""
|
|
return text
|
|
|
|
|
|
def _clean_years(value,resume_text):
|
|
"""Whole years of experience, bounded 0-60. Anything else is None.
|
|
|
|
Seniority language is not a duration, so an unparseable value has to read
|
|
as "unknown" rather than 0 — 0 would sort as a junior candidate.
|
|
"""
|
|
if isinstance(value,bool):
|
|
return None
|
|
if isinstance(value,(int,float)):
|
|
years=int(value)
|
|
elif isinstance(value,str):
|
|
digits=re.search(r"\d+",value)
|
|
if not digits:
|
|
return None
|
|
years=int(digits.group())
|
|
else:
|
|
return None
|
|
return years if 0<=years<=60 else None
|
|
|
|
|
|
def prefer_extracted_phone(func):
|
|
"""Merge CV regex phone with the LLM value; keep the longer complete number."""
|
|
|
|
@wraps(func)
|
|
def wrapper(data,resume_text="",*args,**kwargs):
|
|
fields=func(data,resume_text,*args,**kwargs)
|
|
from employment_agent.plugins import prefer_full_phone,scan_phone
|
|
fields["phone"]=prefer_full_phone(fields.get("phone"),scan_phone(resume_text))
|
|
return fields
|
|
return wrapper
|
|
|
|
|
|
clamp_company_to_resume=clamp_in_resume("current_employment",NO_COMPANY)
|
|
clamp_education_to_resume=clamp_in_resume("education",EDUCATION)
|
|
clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
|
|
clamp_phone=clamp_field("phone",_clean_phone)
|
|
clamp_skills=clamp_field("skills",_clean_skills)
|
|
clamp_years_experience=clamp_field("years_experience",_clean_years)
|
|
clamp_city=clamp_field("city",_clean_city)
|
|
clamp_candidate_name=clamp_field("candidate_name",_clean_name)
|
|
|
|
|
|
@require_json_object
|
|
@clamp_company_to_resume
|
|
@clamp_education_to_resume
|
|
@clamp_linkedin_url
|
|
@prefer_extracted_phone
|
|
@clamp_phone
|
|
@clamp_skills
|
|
@clamp_years_experience
|
|
@clamp_city
|
|
@clamp_candidate_name
|
|
def parse_employment_response(data,resume_text=""):
|
|
"""Pull name, company, education, title, linkedin_url, phone, city, skills, and years
|
|
from the agent JSON.
|
|
|
|
skills, years_experience, and candidate_name default to []/None/"" when the
|
|
key is absent, so a model reply predating the extended prompt still parses.
|
|
"""
|
|
def as_str(key):
|
|
value=data.get(key)
|
|
return value.strip() if isinstance(value,str) else ""
|
|
return {
|
|
"candidate_name":as_str("candidate_name"),
|
|
"current_employment":as_str("current_employment"),
|
|
"education":as_str("education"),
|
|
"current_title":as_str("current_title"),
|
|
"linkedin_url":as_str("linkedin_url"),
|
|
"phone":as_str("phone"),
|
|
"city":as_str("city"),
|
|
"skills":data.get("skills") if isinstance(data.get("skills"),list) else [],
|
|
"years_experience":data.get("years_experience"),
|
|
}
|