Spaces:
Sleeping
Sleeping
File size: 6,599 Bytes
b8dfcb6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | """
Parser de rangos de fechas en CVs.
Detecta rangos del tipo "June 2019 - current" o "Marzo 2022 - Diciembre 2024"
y calcula los años totales de experiencia, fusionando rangos solapados para
no contar doble cuando el candidato tuvo trabajos simultáneos.
Es una utilidad pura: no sabe nada de CVs, perfiles ni seniority. Recibe un
string, devuelve un float con los años. Esto la hace fácil de testear y
permite que la lógica de seniority viva en otra capa sin acoplarse al parsing.
"""
import re
from dataclasses import dataclass
from datetime import date
# Meses en inglés (full y abreviado) y español (full y abreviado).
# El parser los normaliza a número 1-12. Mantener todo en una sola tabla
# permite que agregar un idioma nuevo sea una sola entrada.
MONTHS = {
# Inglés full
"january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6,
"july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12,
# Inglés abreviado
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "jun": 6, "jul": 7,
"aug": 8, "sep": 9, "sept": 9, "oct": 10, "nov": 11, "dec": 12,
# Español full
"enero": 1, "febrero": 2, "marzo": 3, "abril": 4, "mayo": 5, "junio": 6,
"julio": 7, "agosto": 8, "septiembre": 9, "setiembre": 9,
"octubre": 10, "noviembre": 11, "diciembre": 12,
# Español abreviado
"ene": 1, "abr": 4, "ago": 8, "sept": 9, "set": 9, "dic": 12,
}
# Palabras que significan "hasta hoy". Se mapean a date.today().
PRESENT_WORDS = {
"current", "present", "now", "today",
"actualidad", "actual", "presente", "hoy",
}
# Separadores entre fecha inicio y fecha fin. Cubrimos guion, en-dash, em-dash,
# "to", "a", "hasta", "until". Algunos CVs usan tipografía elegante con –/—.
RANGE_SEPARATORS = r"\s*(?:-|–|—|to|a|hasta|until)\s+"
# Regex para "Mes Año": captura el mes (palabra) y el año (4 dígitos).
# Hacemos el mes opcional para tolerar rangos tipo "2019 - 2024".
MONTH_YEAR = r"(?:([A-Za-zÁÉÍÓÚáéíóúñÑ\.]+)\s+)?(\d{4})"
# Regex para "MM/YYYY" o "MM-YYYY" numérico.
NUMERIC_MONTH_YEAR = r"(\d{1,2})[/\-](\d{4})"
# Regex para "current / present / actualidad / etc"
PRESENT = rf"({'|'.join(PRESENT_WORDS)})"
@dataclass(frozen=True)
class DateRange:
"""Rango de fechas como pares (año, mes) inclusivos en ambos extremos."""
start: date
end: date
def months(self) -> int:
"""Cantidad de meses entre start y end, inclusiva. Mínimo 1."""
delta = (self.end.year - self.start.year) * 12 + (self.end.month - self.start.month)
return max(delta + 1, 1)
# Patrón completo: "Mes Año - Mes Año" o "Mes Año - present".
# Usamos finditer en el texto crudo porque los rangos pueden estar en líneas
# diferentes y no queremos depender de cómo viene formateado.
RANGE_PATTERNS = [
# "June 2019 - current" / "Marzo 2022 - Diciembre 2024" / "2019 - 2024"
re.compile(
rf"{MONTH_YEAR}{RANGE_SEPARATORS}(?:{PRESENT}|{MONTH_YEAR})",
re.IGNORECASE,
),
# "06/2019 - 12/2024" / "06/2019 - current"
re.compile(
rf"{NUMERIC_MONTH_YEAR}{RANGE_SEPARATORS}(?:{PRESENT}|{NUMERIC_MONTH_YEAR})",
re.IGNORECASE,
),
]
def calculate_total_years(text: str) -> float | None:
"""
Extrae todos los rangos de fechas del texto y devuelve los años totales
de experiencia, fusionando rangos solapados.
Devuelve None si no encuentra ningún rango parseable.
"""
ranges = _extract_ranges(text)
if not ranges:
return None
merged = _merge_overlapping(ranges)
total_months = sum(r.months() for r in merged)
return round(total_months / 12, 1)
def _extract_ranges(text: str) -> list[DateRange]:
"""Encuentra todos los rangos parseables en el texto."""
ranges: list[DateRange] = []
today = date.today()
for match in RANGE_PATTERNS[0].finditer(text):
start = _parse_word_date(match.group(1), match.group(2))
end = _parse_end(match, word_end_groups=(4, 5), present_group=3, today=today)
if start and end and start <= end:
ranges.append(DateRange(start, end))
for match in RANGE_PATTERNS[1].finditer(text):
start = _parse_numeric_date(match.group(1), match.group(2))
end = _parse_end_numeric(match, today=today)
if start and end and start <= end:
ranges.append(DateRange(start, end))
return ranges
def _parse_word_date(month_word: str | None, year_str: str) -> date | None:
"""'June' + '2019' → date(2019, 6, 1). Mes ausente → enero."""
year = int(year_str)
if not _valid_year(year):
return None
if month_word is None:
return date(year, 1, 1)
month = MONTHS.get(month_word.lower().strip("."))
if month is None:
return None
return date(year, month, 1)
def _parse_numeric_date(month_str: str, year_str: str) -> date | None:
"""'06' + '2019' → date(2019, 6, 1)."""
month = int(month_str)
year = int(year_str)
if not (1 <= month <= 12) or not _valid_year(year):
return None
return date(year, month, 1)
def _parse_end(
match: re.Match,
word_end_groups: tuple[int, int],
present_group: int,
today: date,
) -> date | None:
"""End del rango: o bien 'current' o bien 'Mes Año'."""
if match.group(present_group):
return today
month_g, year_g = word_end_groups
if match.group(year_g):
return _parse_word_date(match.group(month_g), match.group(year_g))
return None
def _parse_end_numeric(match: re.Match, today: date) -> date | None:
"""Para el patrón numérico: grupo 3 es 'present', grupos 4-5 son MM/YYYY."""
if match.group(3):
return today
if match.group(4) and match.group(5):
return _parse_numeric_date(match.group(4), match.group(5))
return None
def _valid_year(year: int) -> bool:
"""Filtra años absurdos (típicos errores de OCR como 1019 o 9999)."""
return 1950 <= year <= date.today().year + 1
def _merge_overlapping(ranges: list[DateRange]) -> list[DateRange]:
"""
Fusiona rangos que se solapan. Si el candidato trabajó en dos empresas
al mismo tiempo, no contamos esos meses dos veces.
"""
if not ranges:
return []
sorted_ranges = sorted(ranges, key=lambda r: r.start)
merged = [sorted_ranges[0]]
for current in sorted_ranges[1:]:
last = merged[-1]
if current.start <= last.end:
merged[-1] = DateRange(last.start, max(last.end, current.end))
else:
merged.append(current)
return merged
|