128 lines
4.1 KiB
Python
128 lines
4.1 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_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):
|
|
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("/")
|
|
return url
|
|
|
|
|
|
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"),
|
|
}
|