"""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_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 digits=re.sub(r"\D","",text) if digits.startswith("00"): digits=digits[2:] if len(digits)<10 or len(digits)>15: return None if (resume_text or "").strip(): haystack=re.sub(r"\D","",resume_text) if digits not in haystack: 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_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) @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 def parse_employment_response(data,resume_text=""): """Pull company, education, title, linkedin_url, phone, city, skills, and years from the agent JSON. skills and years_experience default to []/None when the key is absent, so a model reply predating the extended prompt still parses — the inbox match path reads the other five keys and must not break on a partial response. """ def as_str(key): value=data.get(key) return value.strip() if isinstance(value,str) else "" return { "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"), }