import json import random import re from typing import Any SALARY_PATTERNS = [ r"ral\s*(?:lorda|annua|annuale)?\s*[:\-]?\s*", r"retribuzione\s*(?:annua\s*)?lorda\s*[:\-]?\s*", r"stipendio\s*(?:annuo|lordo)?\s*[:\-]?\s*", r"compensation\s*[:\-]?\s*", r"salary\s*(?:range)?\s*[:\-]?\s*", r"ctc\s*[:\-]?\s*", r"package\s*[:\-]?\s*", ] NUMBER = r"(?:€|eur|euro)?\s*(\d{1,3}(?:[.\s]\d{3})+|\d{2,6})\s*(?:€|eur|k|mila)?" RANGE = ( r"(?:tra|from|between)?\s*" + NUMBER + r"\s*(?:-|–|—|/|e|and|to|a)\s*" + NUMBER ) BANDS = [ (0, 25000, "Less than 25k"), (25000, 30000, "Between 25k and 30k"), (30000, 35000, "Between 30k and 35k"), (35000, 40000, "Between 35k and 40k"), (40000, 45000, "Between 40k and 45k"), (45000, 50000, "Between 45k and 50k"), (50000, 55000, "Between 50k and 55k"), (55000, 60000, "Between 55k and 60k"), (60000, 70000, "Between 60k and 70k"), (70000, 80000, "Between 70k and 80k"), (80000, 90000, "Between 80k and 90k"), (90000, 100000, "Between 90k and 100k"), (100000, 120000, "Between 100k and 120k"), (120000, 150000, "Between 120k and 150k"), (150000, 10**9, "Above 150k"), ] def parse_amount(raw: str) -> int: compact = raw.lower().replace(" ", "").replace("€", "").replace("eur", "") multiplier = 1000 if compact.endswith("k") or compact.endswith("mila") else 1 compact = compact.replace("k", "").replace("mila", "").replace(".", "") digits = re.sub(r"[^\d]", "", compact) if not digits: return 0 value = int(digits) if multiplier == 1000 and value < 1000: value *= 1000 if 1000 <= value < 10000 and not raw.lower().endswith(("k", "mila")): # 35.000 style already handled; 35000 stays pass return value def extract_salary(text: str) -> dict[str, int | None]: blob = text.lower().replace("\xa0", " ") ral_min = ral_max = ral_exact = None for match in re.finditer(RANGE, blob, flags=re.I): a, b = parse_amount(match.group(1)), parse_amount(match.group(2)) if 15000 <= a <= 400000 and 15000 <= b <= 400000: ral_min, ral_max = min(a, b), max(a, b) break if ral_min is None: labeled = re.search( r"(?:ral|retribuzione|salary|stipendio|package)[^\n]{0,40}" + NUMBER, blob, flags=re.I, ) if labeled: value = parse_amount(labeled.group(1)) if 15000 <= value <= 400000: ral_exact = value if ral_min is None and ral_exact is None: above = re.search(r"(?:above|oltre|maggiore di|>)\s*" + NUMBER, blob, flags=re.I) if above: value = parse_amount(above.group(1)) if 15000 <= value <= 400000: ral_min = value ral_max = value + 20000 less = re.search(r"(?:less than|fino a|inferiore a|<)\s*" + NUMBER, blob, flags=re.I) if less and ral_min is None: value = parse_amount(less.group(1)) if 15000 <= value <= 400000: ral_min = max(15000, value - 15000) ral_max = value return {"ral_min": ral_min, "ral_max": ral_max, "ral_exact": ral_exact} def hide_salary(text: str) -> str: cleaned = text replacements = [ RANGE, r"(?:ral|retribuzione\s*annua\s*lorda|stipendio|salary|compensation|ctc)\s*[:\-]?\s*(?:€|eur|euro)?\s*\d[\d.\sKkmila€euro\-–toaeand/]{1,48}", r"(?:€|eur)\s*\d{2,3}(?:[.\s]\d{3})+(?:\s*(?:-|–|to|a)\s*(?:€|eur)?\s*\d{2,3}(?:[.\s]\d{3})+)?", r"\b\d{2,3}\s*k(?:\s*(?:-|–|to|a)\s*\d{2,3}\s*k)?\b", r"\b\d{5,6}\s*(?:€|eur|euro)?\b", ] for pattern in replacements: cleaned = re.sub(pattern, "[RAL HIDDEN]", cleaned, flags=re.I) cleaned = re.sub(r"(\[RAL HIDDEN\]\s*){2,}", "[RAL HIDDEN] ", cleaned) return cleaned.strip() def midpoint(job: Any) -> int: if job.ral_exact: return job.ral_exact if job.ral_min and job.ral_max: return (job.ral_min + job.ral_max) // 2 return job.ral_min or job.ral_max or 40000 def correct_band(value: int) -> tuple[int, int, str]: for low, high, label in BANDS: if low <= value < high: return low, high, label return BANDS[-1] def generate_options(job: Any) -> tuple[list[dict], str]: value = midpoint(job) idx = next(i for i, (low, high, _) in enumerate(BANDS) if low <= value < high) start = max(0, idx - 2) end = min(len(BANDS), start + 4) start = max(0, end - 4) chosen = BANDS[start:end] if len(chosen) < 4: chosen = BANDS[:4] options = [] correct = "" for low, high, label in chosen: if high >= 10**9: key = f"above_{low}" text = f"Above {low // 1000}k" elif low == 0: key = f"less_{high}" text = f"Less than {high // 1000}k" else: key = f"between_{low}_{high}" text = f"Between {low // 1000}k and {high // 1000}k" is_correct = low <= value < high if is_correct: correct = key options.append({"key": key, "label": text}) random.shuffle(options) return options, correct def option_payload(job: Any) -> str: options, correct = generate_options(job) return json.dumps({"options": options, "correct": correct}) def detect_work_type(text: str) -> str: blob = text.lower() if any(w in blob for w in ["full remote", "fully remote", "100% remote", "full-remote", "da remoto"]): return "remote" if any(w in blob for w in ["hybrid", "ibrido", "smart working"]): return "hybrid" if any(w in blob for w in ["on-site", "onsite", "in sede", "in office", "presenza"]): return "onsite" return "hybrid" def detect_subject(title: str, description: str) -> str: blob = f"{title} {description}".lower() mapping = { "technology": ["developer", "engineer", "software", "data", "devops", "it ", "python", "java", "cloud"], "financial": ["bank", "finance", "fintech", "accounting", "auditor", "investment"], "business": ["sales", "account manager", "business", "marketing", "product manager"], "healthcare": ["nurse", "medical", "pharma", "clinic", "health"], "legal": ["lawyer", "legal", "compliance", "avvocato"], "hr": ["recruiter", "human resources", "hr ", "people"], "design": ["designer", "ux", "ui", "graphic"], } for subject, words in mapping.items(): if any(w in blob for w in words): return subject return "other"