talently-api / services /date_parser.py
SantiCan's picture
feat: date parser, seniority por reglas y extracción de skills mejorada
b8dfcb6
"""
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