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

204 lines
6.8 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):
"""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 _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)
@require_json_object
@clamp_company_to_resume
@clamp_education_to_resume
@clamp_linkedin_url
@prefer_extracted_phone
@clamp_phone
@clamp_skills
@clamp_years_experience
def parse_employment_response(data,resume_text=""):
"""Pull company, education, title, linkedin_url, phone, 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"),
"skills":data.get("skills") if isinstance(data.get("skills"),list) else [],
"years_experience":data.get("years_experience"),
}