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