feat: Enhance CSV cleaning service with new endpoints for CSV analysis and preview functionality. Introduce support for job management linked to extraction batches, and update the cleaning rules structure. Add new utility functions for handling CSV data and improve documentation for clarity on usage and available endpoints.

This commit is contained in:
erik
2026-07-11 00:27:05 -05:00
parent cf0ebc72ba
commit b770761832
73 changed files with 7614 additions and 2671 deletions
@@ -0,0 +1,459 @@
"""Detección y normalización de fechas."""
from __future__ import annotations
import re
import unicodedata
from datetime import date
from typing import Any, Optional
import pandas as pd
from .nullish import _is_nullish_scalar
_DATE_NUM_RE = re.compile(r"^\s*(\d{1,4})[\s\/\-\.](\d{1,2})[\s\/\-\.](\d{1,4})\s*$")
_DATE_COMPACT_RE = re.compile(r"^\s*(\d{6}|\d{8})\s*$")
# Hora opcional: HH[:MM[:SS]][ AM|PM] al final de la cadena
_TIME_TAIL_RE = re.compile(
r"[\sT]+(\d{1,2})(?::(\d{1,2})(?::(\d{1,2}))?)?\s*([APap]\.?\s*[Mm]\.?)?\s*$"
)
_MONTH_NAME_TO_NUM = {
"enero": 1,
"ene": 1,
"febrero": 2,
"feb": 2,
"marzo": 3,
"mar": 3,
"abril": 4,
"abr": 4,
"mayo": 5,
"may": 5,
"junio": 6,
"jun": 6,
"julio": 7,
"jul": 7,
"agosto": 8,
"ago": 8,
"septiembre": 9,
"setiembre": 9,
"sep": 9,
"sept": 9,
"octubre": 10,
"oct": 10,
"noviembre": 11,
"nov": 11,
"diciembre": 12,
"dic": 12,
}
def _split_date_time(value: str) -> tuple[str, Optional[str]]:
"""Separa la parte de fecha de la parte de hora si está presente.
Devuelve `(date_part, time_part_normalized)`. `time_part_normalized` es
None si no hay hora. La hora se normaliza a `HH:MM[:SS][ AM|PM]`.
"""
if value is None:
return "", None
s = str(value).strip()
if not s:
return "", None
m = _TIME_TAIL_RE.search(s)
if not m:
return s, None
hh = int(m.group(1))
mm = int(m.group(2)) if m.group(2) else 0
ss = m.group(3)
ampm = m.group(4)
if not (0 <= hh <= 23 and 0 <= mm <= 59):
return s, None
if ss is not None:
try:
ssi = int(ss)
if not (0 <= ssi <= 59):
return s, None
except ValueError:
return s, None
# Validar contra AM/PM (con AM/PM, hora 1-12)
if ampm and not (1 <= hh <= 12):
return s, None
# Quitar separadores habituales entre fecha y hora (p. ej. "12/27/24, 12:00 AM").
date_part = s[: m.start()].rstrip(" \t,;T")
if not date_part:
return s, None
parts: list[str] = [f"{hh:02d}:{mm:02d}"]
if ss is not None:
parts[0] += f":{int(ss):02d}"
if ampm:
norm_ampm = ampm.replace(".", "").replace(" ", "").upper()
if norm_ampm in ("AM", "PM"):
parts.append(norm_ampm)
time_norm = " ".join(parts)
return date_part, time_norm
def _extract_date_parts(value: str) -> Optional[tuple[int, int, int, str]]:
"""Intenta extraer (a, b, c, layout) de una fecha en cualquier formato común.
`layout` es uno de: 'sep' (con separadores), 'compact8' (YYYYMMDD o
DDMMYYYY), 'compact6' (DDMMYY o YYMMDD). No interpreta todavía qué
componente es día/mes/año; eso lo decide `detect_date_format` viendo la
columna completa. Acepta valores con hora al final (ignorada para los
componentes de fecha).
"""
if value is None:
return None
s = str(value).strip()
if not s or s.lower() in ("nan", "none", "nat"):
return None
date_part, _ = _split_date_time(s)
if date_part:
s = date_part
m = _DATE_NUM_RE.match(s)
if m:
try:
a, b, c = int(m.group(1)), int(m.group(2)), int(m.group(3))
return (a, b, c, "sep")
except ValueError:
return None
m2 = _DATE_COMPACT_RE.match(s)
if m2:
digits = m2.group(1)
if len(digits) == 8:
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:8]), "compact8")
if len(digits) == 6:
return (int(digits[0:2]), int(digits[2:4]), int(digits[4:6]), "compact6")
return None
def detect_date_format(series: pd.Series, sample_size: int = 500) -> Optional[str]:
"""Analiza la serie y determina el formato de fecha dominante.
Devuelve un string con el layout detectado (p. ej. "DD/MM/YYYY",
"MM/DD/YYYY", "YYYYMMDD", "DDMMYYYY") o None si no parece fechas.
"""
if series is None or len(series) == 0:
return None
non_null = series.dropna()
if non_null.empty:
return None
if len(non_null) > sample_size:
non_null = non_null.sample(sample_size, random_state=42)
layouts = {"sep": 0, "compact8": 0, "compact6": 0}
sep_first_gt12 = 0 # primer componente > 12 → probable día
sep_second_gt12 = 0 # segundo componente > 12 → probable mes solo posible si MM/DD
sep_third_4digits = 0 # tercer componente de 4 dígitos → año al final
sep_first_4digits = 0 # primer componente de 4 dígitos → año al inicio
compact8_year_first = 0 # primeros 4 dígitos > 1900 → YYYYMMDD
compact8_year_last = 0 # últimos 4 dígitos > 1900 → DDMMYYYY
total_parsed = 0
for val in non_null:
parsed = _extract_date_parts(val)
if parsed is None:
continue
total_parsed += 1
a, b, c, layout = parsed
layouts[layout] = layouts.get(layout, 0) + 1
if layout == "sep":
# año puede estar al inicio o al final según cantidad de dígitos
if _digit_count(a) == 4:
sep_first_4digits += 1
if _digit_count(c) == 4:
sep_third_4digits += 1
if a > 12:
sep_first_gt12 += 1
if b > 12:
sep_second_gt12 += 1
elif layout == "compact8":
# a son los primeros 2 dígitos, c son los últimos 4
if c >= 1900:
compact8_year_last += 1
# a concatenado con b son los primeros 4 dígitos
first4 = a * 100 + b
if first4 >= 1900:
compact8_year_first += 1
if total_parsed == 0:
return None
# Requerimos que >=70% de los no nulos parseen como fecha para considerarla
# columna de fechas.
if total_parsed / len(non_null) < 0.7:
return None
dominant = max(layouts, key=lambda k: layouts[k])
if dominant == "compact8":
if compact8_year_first > compact8_year_last:
return "YYYYMMDD"
return "DDMMYYYY"
if dominant == "compact6":
return "DDMMYY"
# Caso con separadores
if sep_first_4digits > sep_third_4digits:
return "YYYY/MM/DD"
# año al final
if sep_first_gt12 > 0 and sep_second_gt12 == 0:
return "DD/MM/YYYY"
if sep_second_gt12 > 0 and sep_first_gt12 == 0:
return "MM/DD/YYYY"
# ambiguo → preferencia hispana
return "DD/MM/YYYY"
def _digit_count(n: int) -> int:
return len(str(abs(n)))
def _parse_date_with_format(value: str, fmt: str) -> Optional[tuple[int, int, int]]:
"""Convierte un valor de fecha a (día, mes, año) según el layout detectado."""
parsed = _extract_date_parts(value)
if parsed is None:
return None
a, b, c, layout = parsed
if fmt == "DD/MM/YYYY":
d, m, y = a, b, c
elif fmt == "MM/DD/YYYY":
m, d, y = a, b, c
elif fmt == "YYYY/MM/DD":
y, m, d = a, b, c
elif fmt == "YYYYMMDD" and layout == "compact8":
# reconstruir: primer4 = a*100 + b, luego mes y día
y = a * 100 + b
m = c // 100
d = c % 100
elif fmt == "DDMMYYYY" and layout == "compact8":
d = a
m = b
y = c
elif fmt == "DDMMYY" and layout == "compact6":
d = a
m = b
y = 2000 + c if c < 50 else 1900 + c
else:
# intentar como sep genérico
d, m, y = a, b, c
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
# año en 2 dígitos
if y < 100:
y = 2000 + y if y < 50 else 1900 + y
if not (1 <= m <= 12 and 1 <= d <= 31 and 1000 <= y <= 9999):
return None
return (d, m, y)
def _normalize_date_text(value: Any) -> str:
text = unicodedata.normalize("NFKD", str(value or ""))
text = "".join(c for c in text if not unicodedata.combining(c))
return text.lower()
def _valid_date_tuple(d: int, m: int, y: int) -> Optional[tuple[int, int, int]]:
if y < 100:
y = 2000 + y if y < 50 else 1900 + y
if not (1000 <= y <= 9999):
return None
try:
date(y, m, d)
except ValueError:
return None
return (d, m, y)
def _split_glued_year_suffix_hhmm(digits: str) -> Optional[tuple[int, int, int]]:
"""Detecta `YYYY` + `HHMM` en una sola tira de 8 cifras (p. ej. `20241344`).
Tras quitar separadores puede quedar pegado al mes (p. ej. `15may20241344`).
Devuelve (año, hora, minuto) si la parte horaria es válida; si no, None.
"""
if len(digits) != 8 or not digits.isdigit():
return None
y = int(digits[:4])
hh = int(digits[4:6])
mm = int(digits[6:8])
if not (1000 <= y <= 9999 and 0 <= hh <= 23 and 0 <= mm <= 59):
return None
return (y, hh, mm)
def _parse_text_month_candidate(
first: tuple[int, str],
month: int,
second: tuple[int, str],
*,
layout: Literal["around", "after", "before"],
) -> Optional[tuple[int, int, int]]:
a, astr = first
b, bstr = second
if layout == "around":
if len(astr) == 4:
return _valid_date_tuple(b, month, a)
if len(bstr) == 4:
return _valid_date_tuple(a, month, b)
return _valid_date_tuple(a, month, b)
if layout == "after":
if len(astr) == 4:
return _valid_date_tuple(b, month, a)
return _valid_date_tuple(a, month, b)
if len(astr) == 4:
return _valid_date_tuple(b, month, a)
return _valid_date_tuple(a, month, b)
def _parse_date_with_text_month(raw: Any) -> Optional[tuple[int, int, int]]:
if _is_nullish_scalar(raw):
return None
date_part, _ = _split_date_time(str(raw))
normalized = _normalize_date_text(date_part or raw)
# `\d{8}` antes que `\d{1,4}` para no partir año+hora pegados tras strip agresivo.
tokens = re.findall(r"[a-z]+|\d{8}|\d{1,4}", normalized)
if not tokens:
return None
for i, token in enumerate(tokens):
month = _MONTH_NAME_TO_NUM.get(token)
if month is None:
continue
nums_before = [
(int(t), t) for t in tokens[:i] if t.isdigit()
]
nums_after = [
(int(t), t) for t in tokens[i + 1 :] if t.isdigit()
]
candidates: list[Optional[tuple[int, int, int]]] = []
if nums_before and len(nums_after) == 1:
glued = _split_glued_year_suffix_hhmm(nums_after[0][1])
if glued is not None:
y, _hh, _mm = glued
candidates.append(
_parse_text_month_candidate(
nums_before[-1], month, (y, str(y)), layout="around"
)
)
if nums_before and nums_after:
candidates.append(
_parse_text_month_candidate(
nums_before[-1], month, nums_after[0], layout="around"
)
)
if len(nums_after) >= 2:
candidates.append(
_parse_text_month_candidate(
nums_after[0], month, nums_after[1], layout="after"
)
)
if len(nums_before) >= 2:
candidates.append(
_parse_text_month_candidate(
nums_before[-2], month, nums_before[-1], layout="before"
)
)
for parsed in candidates:
if parsed is not None:
return parsed
return None
def _detect_compact_date_formats(
series: pd.Series,
) -> tuple[Optional[str], Optional[str]]:
cser = series.map(_normalize_messy_date_to_compact)
c8s = pd.Series(
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 8]
)
c6s = pd.Series(
[x for x in cser.tolist() if isinstance(x, str) and len(x) == 6]
)
fmt8 = detect_date_format(c8s) if not c8s.empty else None
fmt6 = detect_date_format(c6s) if not c6s.empty else None
return fmt8, fmt6
def _parse_any_date_to_tuple(
raw: Any,
*,
sep_fmt: Optional[str],
compact8_fmt: Optional[str],
compact6_fmt: Optional[str],
) -> Optional[tuple[int, int, int]]:
parsed_text = _parse_date_with_text_month(raw)
if parsed_text is not None:
return parsed_text
if _is_nullish_scalar(raw):
return None
raw_text = str(raw)
date_part, _ = _split_date_time(raw_text)
target = date_part if date_part else raw_text
if sep_fmt:
parsed = _parse_date_with_format(target, sep_fmt)
if parsed is not None:
return parsed
for fallback_fmt in ("DD/MM/YYYY", "MM/DD/YYYY", "YYYY/MM/DD"):
if fallback_fmt == sep_fmt:
continue
parsed = _parse_date_with_format(target, fallback_fmt)
if parsed is not None:
return parsed
compact = _normalize_messy_date_to_compact(raw)
if not isinstance(compact, str):
return None
if len(compact) == 8:
for fmt in (compact8_fmt, "DDMMYYYY", "YYYYMMDD"):
if not fmt:
continue
parsed = _parse_date_with_format(compact, fmt)
if parsed is not None:
return parsed
if len(compact) == 6:
parsed = _parse_date_with_format(compact, compact6_fmt or "DDMMYY")
if parsed is not None:
return parsed
return None
def _normalize_messy_date_to_compact(raw: Any) -> Optional[str]:
"""Quita ruido (* + _ / espacios, etc.); deja 6 o 8 dígitos o None.
Soporta separadores entremezclados; hora al final vía `_split_date_time`.
Con 9+ dígitos, heurística: tomar 8 iniciales (p. ej. 9.º = 0 colado) y
validar luego con `_parse_date_with_format`; con más de 8 sobrantes,
8 iniciales.
"""
if _is_nullish_scalar(raw):
return None
s = str(raw).strip()
if s.lower() in ("nan", "none", "nat", "<na>"):
return None
date_part, _ = _split_date_time(s)
body = date_part if date_part else s
d = re.sub(r"[^\d]", "", body)
if not d:
return None
n = len(d)
if n in (6, 8):
return d
if n < 6 or n == 7:
return None
# 9+ dígitos: 8 iniciales (p. ej. 9.º 0 sobrante, o ruido al final)
return d[:8]