"""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_COMPANY,NO_LINKEDIN,NO_PHONE 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 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) @require_json_object @clamp_company_to_resume @clamp_education_to_resume @clamp_linkedin_url @prefer_extracted_phone @clamp_phone def parse_employment_response(data,resume_text=""): """Pull company, education, title, linkedin_url, and phone from the agent JSON.""" 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"), }