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,64 @@
"""Limpieza de CSV con análisis inteligente local y reglas configurables.
API pública (estable):
- ``analyze_dataframe`` — resumen por columna
- ``CLEANING_RULES`` / ``register_rule`` / ``get_rule`` — catálogo de reglas
- ``apply_rules`` — aplica reglas por columna
- ``load_csv`` / ``load_csv_bytes`` / ``detect_delimiter`` — I/O
Para agregar una regla nueva, ver ``rules/registry.py`` y ``rules/builtins.py``.
"""
from __future__ import annotations
from .analyze import analyze_dataframe, sample_dataframe_rows
from .apply import apply_rules
from .cop_mod import (
DEFAULT_MODERADORA_BY_YEAR,
merge_moderadora_config,
normalize_moderadora_by_year_dict,
)
from .documents import homologate_doc_type, split_doc_tipo_y_numero_texto
from .io import detect_delimiter, load_csv, load_csv_bytes
from .models import (
TYPE_BOOLEAN,
TYPE_CATEGORICAL,
TYPE_DATE,
TYPE_DECIMAL,
TYPE_EMPTY,
TYPE_INTEGER,
TYPE_MIXED,
TYPE_TEXT,
ColumnSummary,
)
from .notas import extract_autorizacion_nota, extract_paciente_documento_nota
from .rules import CLEANING_RULES, get_rule, register_rule
from .type_inference import applicable_rule_ids_for_type
__all__ = [
"CLEANING_RULES",
"ColumnSummary",
"DEFAULT_MODERADORA_BY_YEAR",
"TYPE_BOOLEAN",
"TYPE_CATEGORICAL",
"TYPE_DATE",
"TYPE_DECIMAL",
"TYPE_EMPTY",
"TYPE_INTEGER",
"TYPE_MIXED",
"TYPE_TEXT",
"analyze_dataframe",
"applicable_rule_ids_for_type",
"apply_rules",
"detect_delimiter",
"extract_autorizacion_nota",
"extract_paciente_documento_nota",
"get_rule",
"homologate_doc_type",
"load_csv",
"load_csv_bytes",
"merge_moderadora_config",
"normalize_moderadora_by_year_dict",
"register_rule",
"sample_dataframe_rows",
"split_doc_tipo_y_numero_texto",
]
@@ -0,0 +1,94 @@
"""Análisis de DataFrames: resumen por columna y muestras."""
from __future__ import annotations
import json
import random
from typing import Any
import pandas as pd
from .models import (
TYPE_CATEGORICAL,
TYPE_DATE,
TYPE_DECIMAL,
TYPE_INTEGER,
TYPE_TEXT,
ColumnSummary,
)
from .numbers import _to_numeric_lenient
from .type_inference import _applicable_rules_for_type, _infer_column_type
def analyze_dataframe(df: pd.DataFrame) -> list[ColumnSummary]:
"""Genera un resumen por columna del DataFrame."""
summaries: list[ColumnSummary] = []
total_rows = len(df)
rng = random.Random(42)
for col in df.columns:
series = df[col]
null_count = int(series.isna().sum() + (series.astype(str).str.strip() == "").sum())
# Evitar doble conteo: considerar vacío solo si no-NaN
null_count = int((series.isna() | (series.astype(str).str.strip() == "")).sum())
null_pct = (null_count / total_rows * 100) if total_rows else 0.0
non_null = series.dropna().astype(str)
non_null = non_null[non_null.str.strip() != ""]
example = ""
samples: list[str] = []
if not non_null.empty:
values = non_null.tolist()
k = min(5, len(values))
picked = rng.sample(values, k) if len(values) >= k else list(values)
for raw in picked:
text = str(raw)
if len(text) > 80:
text = text[:77] + "..."
samples.append(text)
example = samples[0] if samples else ""
unique_count = int(non_null.nunique())
unique_pct = (unique_count / total_rows * 100) if total_rows else 0.0
inferred_type, date_fmt = _infer_column_type(series, str(col))
summary = ColumnSummary(
name=str(col),
inferred_type=inferred_type,
example=example,
samples=samples,
null_count=null_count,
null_pct=null_pct,
unique_count=unique_count,
unique_pct=unique_pct,
detected_date_format=date_fmt,
)
if inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
numeric = _to_numeric_lenient(non_null)
if numeric.notna().any():
summary.min = float(numeric.min())
summary.max = float(numeric.max())
summary.mean = float(numeric.mean())
elif inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
if not non_null.empty:
summary.avg_length = float(non_null.str.len().mean())
counts = non_null.value_counts().head(3)
summary.top_values = [(str(v), int(c)) for v, c in counts.items()]
summary.applicable_rules = _applicable_rules_for_type(inferred_type)
summaries.append(summary)
return summaries
def sample_dataframe_rows(df: pd.DataFrame, n: int = 8) -> list[dict[str, Any]]:
"""Devuelve hasta `n` filas aleatorias como dicts JSON-serializables."""
if df.empty or n <= 0:
return []
take = min(n, len(df))
rng = random.Random(42)
indices = rng.sample(list(range(len(df))), take)
subset = df.iloc[indices]
return json.loads(subset.to_json(orient="records", date_format="iso"))
@@ -0,0 +1,105 @@
"""Aplicación de reglas seleccionadas sobre un DataFrame."""
from __future__ import annotations
import logging
from typing import Any, Optional
import pandas as pd
from .cop_mod import _apply_cop_mod_split, merge_moderadora_config
from .documents import _apply_doc_id_split_columns
from .notas import _apply_nota_extract_columns
from .rules import get_rule_fn, get_rule_name
logger = logging.getLogger(__name__)
def apply_rules(
df: pd.DataFrame,
rules_per_column: dict[str, list[str]],
*,
cop_mod_fecha_column: Optional[dict[str, str]] = None,
cop_mod_config: Optional[dict[str, Any]] = None,
) -> tuple[pd.DataFrame, list[str]]:
"""Aplica las reglas seleccionadas a cada columna.
`rules_per_column` es un dict de `columna -> lista de IDs de reglas en el
orden a aplicar`. La regla `cop_mod_split` se difiere a una segunda pasada
tras el resto, para que la columna de fecha pueda normalizarse antes.
`cop_mod_fecha_column`: mapa columna de importe -> nombre de columna Fecha Servicio.
`cop_mod_config`: opcional; puede incluir ``moderadora_by_year`` (dict año → lista).
"""
result = df.copy()
log: list[str] = []
moderadora_years = merge_moderadora_config(cop_mod_config)
def _run_rules_for_column(col: str, rule_ids: list[str]) -> None:
if not rule_ids:
return
if col not in result.columns:
log.append(f"[WARN] Columna no encontrada: {col}")
return
split_has_homologation = (
"doc_id_split_tipo_numero" in rule_ids and "doc_type_homologate" in rule_ids
)
for rule_id in rule_ids:
if rule_id == "doc_id_split_tipo_numero":
try:
_apply_doc_id_split_columns(
result, col, log, homologate_type=split_has_homologation
)
except Exception as exc: # noqa: BLE001
logger.exception("Error aplicando regla %s a %s", rule_id, col)
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
continue
if rule_id == "nota_paciente_autorizacion":
try:
_apply_nota_extract_columns(result, col, log)
except Exception as exc: # noqa: BLE001
logger.exception("Error aplicando regla %s a %s", rule_id, col)
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
continue
if rule_id == "doc_type_homologate" and split_has_homologation:
continue
fn = get_rule_fn(rule_id)
if fn is None:
log.append(f"[WARN] Regla desconocida '{rule_id}' en columna '{col}'")
continue
try:
result[col] = fn(result[col])
log.append(f"[OK] '{col}' <- {get_rule_name(rule_id)}")
except Exception as exc: # noqa: BLE001
logger.exception("Error aplicando regla %s a %s", rule_id, col)
log.append(f"[ERROR] '{col}' regla '{rule_id}': {exc}")
# Fase 1: todas las reglas excepto cop_mod_split
for col, rule_ids in rules_per_column.items():
filtered = [r for r in rule_ids if r != "cop_mod_split"]
_run_rules_for_column(col, filtered)
# Fase 2: Separa COP/MOD sobre el DataFrame ya transformado
fecha_map = cop_mod_fecha_column or {}
for col, rule_ids in rules_per_column.items():
if "cop_mod_split" not in rule_ids:
continue
fecha_col = fecha_map.get(col)
if not fecha_col:
log.append(
f"[WARN] COP/MOD en '{col}': indique la columna «Fecha Servicio» en la tarjeta; se omite."
)
continue
try:
_apply_cop_mod_split(
result,
col,
fecha_col,
log,
moderadora_by_year=moderadora_years,
)
except Exception as exc: # noqa: BLE001
logger.exception("Error aplicando COP/MOD a %s", col)
log.append(f"[ERROR] '{col}' regla 'cop_mod_split': {exc}")
return result, log
@@ -0,0 +1,210 @@
"""Heurísticas por nombre de columna y presentación del contenido."""
from __future__ import annotations
import re
from typing import Optional
import pandas as pd
from .nullish import _is_nullish_scalar
def _column_name_suggests_date_like(name: str) -> bool:
"""Columnas cuyo nombre pide tratar el contenido como fecha a priori."""
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
if not n:
return False
if re.search(
r"\b(fecha|date|año|mes|dia|día|f[_-]?(alta|baja|proc|corte|pago|ini|fin|"
r"desde|hasta|vigenc|vence|venc|caduc|f\s?ec)\b|periodo|anio|ano|expir)",
n,
) or n.startswith("fec_") or n.startswith("fecha"):
return True
return False
def _column_name_suggests_monetary_value(name: str) -> bool:
"""importe, valor, neto, IVA, etc. — no interpretar cifras como fechas.
Encabezados de tipo `fecha_…` no se tratan como moneda salvo mezcla con monto/valor.
"""
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
if not n:
return False
if _column_name_suggests_date_like(name) and not re.search(
r"\b(monto|valor|importe|pago|total|bruto|neto|import|unit|precio|copago)\b", n
):
return False
for key in (
"monto", "importe", "import", "bruto", "neto", "subtotal", "sub total",
"total", "unitario", "unidad", "valor", "precio", "pago", "copago", "sald", "dcto",
"franq", "cuota", "iva", "tarif", "costo", "carg", "reten", "retencion",
):
if key in n:
if key == "iva" and re.search(r"(priva|giva|diva|viva|cliva)\b", n):
continue
if key == "carg" and re.search(r"descarg|recarg|abord", n):
continue
return True
return False
def _column_name_suggests_code_id(name: str) -> bool:
"""Código, ref., radicado, tipoynúm de documento — no forzar 6-8 cifras como fechas (DDMMYY, etc.)."""
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
if not n or _column_name_suggests_date_like(name):
return False
for key in (
"codigo", "código", "codig", "códig", "cód_",
"sku", "ean", "upc", "isbn", "upn",
"ref", "referen", "radic", "nro_fac", "nro fac", "nit", "cédul", "cedul",
"autoriz", "correlat", "secuenc", "línea de", "linea de", "cupo",
"tipy", "tipi", "tipynum", "typnum", "type_n", "n_doc", "n_docu",
"núm doc", "n_docu", "doc_id", "docum", "id_doc",
):
if key in n:
return True
if re.search(
r"tipo[_\s\\-]*(&|/|u|e|y)[_\s\\-]*num|tipo[_\s\\-]+doc|tipo_y_n",
n,
re.IGNORECASE,
) or n.startswith("tip_"):
return True
if n.startswith("cod_") or n.startswith("cód_") or n.startswith("ref_"):
return True
return bool(re.search(r"\b(nro[._]fac|#?\s*autor|#?\s*ref)\b", n, re.IGNORECASE))
def _column_name_suggests_quantity_or_number_not_bool(name: str) -> bool:
"""Nombres típicos de cantidad, importe, fechas: no inferir bool por 0/1."""
n = re.sub(r"[_\s.]+", " ", (name or "")).lower().strip()
if not n:
return False
if _column_name_suggests_monetary_value(name) or _column_name_suggests_code_id(name):
return True
for key in (
"fecha",
"monto",
"importe",
"bruto",
"neto",
"subtotal",
"unitario",
"unidad",
"valor",
"precio",
"pago",
"copago",
"cantid",
"quantity",
"qty",
"nro",
"núm",
"n°",
):
if key in n:
return True
return False
def _raw_strings_suggest_monetary_presentation(non_null: pd.Series) -> bool:
"""$ €, miles 1,234,12 o .00: presentación monetaria aunque el float sea "entero"."""
if non_null is None or len(non_null) == 0:
return False
n_hit = 0
n_seen = 0
for raw in non_null.head(500):
if _is_nullish_scalar(raw):
continue
t = str(raw)
s = t.strip()
n_seen += 1
if re.search(r"[$\u00a3\u20ac\uffe5\uffe6\ufe69\uff04]", t):
n_hit += 1
continue
if re.search(r"\b\d{1,3}(?:[.,\u00a0]\d{3})+\b", s) or re.search(
r"(?<!\d)\d+[.,][0-9]{1,2}\b", s
):
n_hit += 1
if n_seen == 0:
return False
return n_hit / n_seen >= 0.10
def _cell_looks_cifra_con_moneda_iso_al_final(s: str) -> bool:
"""Solo cifra/símbolo (no prefijo de documento) y código de moneda al final.
Excluye filas al estilo `CC12…` (típico alfanumérico).
"""
t = str(s).strip()
u = t.upper()
if not re.search(
r"(?i)(USD|COP|EUR|CLP|ARS|GBP|MXN|PEN|DOP|UYU|VES|CRC|PYG|BRL|BOB|GTQ)\b\s*\Z", u
):
return False
if re.match(
r"^(?i)(CC|CE|NIT|NUIP|TI|PA|MS|RC|DNI|NUIP|CN|SC|AS|PE|PT)\D*[\d0]", u
):
return False
return re.match(
r"^[\$€0-9\s,.\-+%/]", t,
) is not None
def _raw_strings_suggest_alnum_mixed_presentation(non_null: pd.Series) -> bool:
"""Letras y dígitos juntos (p. ej. `CC32…`, `A1-2B`); no es puro 'entero/decimal' lógicamente.
Excluido: columnas con apariencia monetaria, y celdas solo cifra + ISO al final (100 USD).
"""
if _raw_strings_suggest_monetary_presentation(non_null):
return False
n_m = 0
n_s = 0
for raw in non_null.head(500):
if _is_nullish_scalar(raw):
continue
t = str(raw).strip()
n_s += 1
if not re.search(r"\d", t) or not re.search(
r"[A-Za-zÑñ\u00c0-\u024f]", t, re.IGNORECASE
):
continue
if _cell_looks_cifra_con_moneda_iso_al_final(t):
continue
n_m += 1
if n_s == 0:
return False
return n_m / n_s >= 0.20
def _cell_looks_like_documento_id_tipo_nit(s: str) -> bool:
"""Cadenas estilo `CC32324029`, `CE 1.234.567` — no son fechas (evita 8 cifras como DDMM).
Al menos una letra al inicio y 5-16 dígitos en la celda (no `Jan`+`2020` = 4 dígitos).
"""
t = str(s).strip()
if not t or len(t) < 3:
return False
if not re.match(r"^[A-Za-z]{1,3}", t):
return False
dig = re.sub(r"[^\d]", "", t)
if len(dig) < 5 or len(dig) > 16:
return False
return True
def _content_looks_like_doc_id_letter_prefix_column(non_null: pd.Series) -> bool:
"""Muestras con prefijo documental (CC, NIT, …) y ≥5 dígitos → no columna de fecha."""
n_ok = 0
n_seen = 0
for raw in non_null.head(500):
if _is_nullish_scalar(raw):
continue
s = str(raw).strip()
n_seen += 1
if _cell_looks_like_documento_id_tipo_nit(s):
n_ok += 1
if n_seen == 0:
return False
return n_ok / n_seen >= 0.20
@@ -0,0 +1,162 @@
"""Separación de copago / cuota moderadora por año."""
from __future__ import annotations
import re
from typing import Any, Literal, Optional
import numpy as np
import pandas as pd
from .dates import (
_detect_compact_date_formats,
_parse_any_date_to_tuple,
detect_date_format,
)
from .numbers import _infer_decimal_hint_from_column, _parse_lenient_one
DEFAULT_MODERADORA_BY_YEAR: dict[int, set[int]] = {
2023: {4100, 16400, 43000},
2024: {4500, 18200, 47700},
2025: {4700, 19200, 50300},
2026: {5000, 20100, 52800},
}
def normalize_moderadora_by_year_dict(raw: Any) -> dict[int, set[int]]:
"""Normaliza un dict JSON año → lista de enteros (cuotas moderadora)."""
if not isinstance(raw, dict):
return {}
out: dict[int, set[int]] = {}
for k, v in raw.items():
try:
yi = int(k)
except (TypeError, ValueError):
continue
if not isinstance(v, (list, tuple)):
continue
nums: set[int] = set()
for x in v:
try:
nums.add(int(round(float(x))))
except (TypeError, ValueError):
continue
if nums:
out[yi] = nums
return out
def merge_moderadora_config(
cop_mod_config: Optional[dict[str, Any]],
) -> dict[int, set[int]]:
"""Fusiona defaults con `moderadora_by_year` opcional del usuario."""
merged = {y: set(vals) for y, vals in DEFAULT_MODERADORA_BY_YEAR.items()}
if cop_mod_config and isinstance(cop_mod_config.get("moderadora_by_year"), dict):
custom = normalize_moderadora_by_year_dict(
cop_mod_config["moderadora_by_year"]
)
for y, vals in custom.items():
merged[y] = set(vals)
return merged
def _alloc_cop_mod_column_names(
col_name: str, existing: set[str]
) -> tuple[str, str]:
"""Nombres únicos para copago y moderadora."""
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
for i in range(0, 200):
suf = f"_{i}" if i else ""
ccol, mcol = f"{base}{suf}_copago", f"{base}{suf}_moderadora"
if ccol not in existing and mcol not in existing:
return ccol, mcol
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
def _amount_cell_to_optional_int(
val: Any,
decimal_hint: Optional[Literal[",", "."]],
) -> Optional[int]:
try:
f = _parse_lenient_one(val, decimal_hint)
except Exception:
return None
if f is None or (isinstance(f, float) and not np.isfinite(f)) or (
isinstance(f, (float, np.floating)) and np.isnan(f)
):
return None
try:
return int(round(float(f)))
except (TypeError, ValueError, OverflowError):
return None
def _apply_cop_mod_split(
result: pd.DataFrame,
amount_col: str,
fecha_column: str,
log: list[str],
*,
moderadora_by_year: dict[int, set[int]],
) -> None:
"""Añade columnas copago y moderadora según cuotas por año de la fecha."""
if fecha_column not in result.columns:
log.append(
f"[WARN] COP/MOD: columna de fecha '{fecha_column}' no existe; se omite."
)
return
if amount_col not in result.columns:
log.append(f"[WARN] COP/MOD: columna de importe '{amount_col}' no existe; se omite.")
return
fec_ser = result[fecha_column]
amt_ser = result[amount_col]
stripped = fec_ser.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
stripped = stripped.where(fec_ser.notna(), other=pd.NA)
sep_fmt = detect_date_format(fec_ser) or detect_date_format(stripped)
compact8_fmt, compact6_fmt = _detect_compact_date_formats(fec_ser)
amt_hint = _infer_decimal_hint_from_column(amt_ser.astype(str))
ccol, mcol = _alloc_cop_mod_column_names(str(amount_col), set(result.columns))
cop_vals: list[Any] = []
mod_vals: list[Any] = []
for i in range(len(result)):
amt_raw = amt_ser.iloc[i]
fec_raw = fec_ser.iloc[i]
amt_i = _amount_cell_to_optional_int(amt_raw, amt_hint)
if amt_i is None:
cop_vals.append(0)
mod_vals.append(0)
continue
parsed_d = _parse_any_date_to_tuple(
fec_raw,
sep_fmt=sep_fmt,
compact8_fmt=compact8_fmt,
compact6_fmt=compact6_fmt,
)
if parsed_d is None:
cop_vals.append(amt_i)
mod_vals.append(0)
continue
_d, _m, year = parsed_d
tiers = moderadora_by_year.get(year)
if tiers is None:
cop_vals.append(amt_i)
mod_vals.append(0)
continue
if amt_i in tiers:
cop_vals.append(0)
mod_vals.append(amt_i)
else:
cop_vals.append(amt_i)
mod_vals.append(0)
result[ccol] = cop_vals
result[mcol] = mod_vals
log.append(
f"[OK] '{amount_col}' -> '{ccol}', '{mcol}' <- Separa COP/MOD "
f"(fecha: '{fecha_column}')"
)
@@ -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]
@@ -0,0 +1,175 @@
"""Homologación y división de tipo/número de documento."""
from __future__ import annotations
import re
import unicodedata
from typing import Any, Optional
import pandas as pd
from .nullish import _is_nullish_scalar
_DOC_TYPE_ALIASES: dict[str, tuple[str, ...]] = {
"CC": ("CC", "Cedula", "CD", "C", "C.C", "C C", "CCC"),
"TI": ("TI", "Tarjeta", "Tarjeta Identidad", "T1", "T", "TL"),
"CN": ("CN", "Certificado", "Nacido Vivo", "Certificado Nacido Vivo"),
"CE": ("CE", "Cedula de Extranjeria", "Extranjera"),
"ME": ("ME", "Menor sin Identificacion"),
"PS": ("PS", "Pasaporte"),
"PT": ("PT", "Permiso por proteccion Temporal"),
"RC": ("RC", "Registro Civil", "Registro", "TC"),
"SC": ("SC", "Salvo Conducto", "Salvoconducto"),
"PE": ("PE", "Permiso especial de permanencia", "Permiso"),
}
def _ascii_upper(value: Any) -> str:
text = unicodedata.normalize("NFKD", str(value or ""))
text = "".join(c for c in text if not unicodedata.combining(c))
return text.upper()
def _normalize_doc_type_key(value: Any) -> str:
"""Clave comparable: sin acentos, espacios, puntos ni símbolos."""
return re.sub(r"[^A-Z0-9]", "", _ascii_upper(value))
def _clean_doc_type_text(value: Any) -> Optional[str]:
cleaned = re.sub(r"[^A-Z0-9]+", " ", _ascii_upper(value)).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned or None
_DOC_TYPE_ALIAS_BY_KEY: dict[str, str] = {
_normalize_doc_type_key(alias): canonical
for canonical, aliases in _DOC_TYPE_ALIASES.items()
for alias in aliases
}
_DOC_TYPE_ALIAS_DISPLAY_BY_KEY: dict[str, str] = {}
for _canonical, _aliases in _DOC_TYPE_ALIASES.items():
for _alias in _aliases:
_alias_key = _normalize_doc_type_key(_alias)
_alias_display = _clean_doc_type_text(_alias)
if _alias_key and _alias_display:
_DOC_TYPE_ALIAS_DISPLAY_BY_KEY.setdefault(_alias_key, _alias_display)
_DOC_TYPE_ALIAS_KEYS_SORTED: list[str] = sorted(
_DOC_TYPE_ALIAS_BY_KEY,
key=lambda k: (len(k), k),
reverse=True,
)
def homologate_doc_type(value: Any) -> Optional[str]:
"""Homologa un tipo documental a sigla; si no existe, conserva texto limpio."""
if _is_nullish_scalar(value):
return None
key = _normalize_doc_type_key(value)
if not key or key.lower() in ("nan", "none", "nat"):
return None
return _DOC_TYPE_ALIAS_BY_KEY.get(key) or _clean_doc_type_text(value)
def _compact_doc_text(value: Any) -> str:
return _normalize_doc_type_key(value)
def _looks_like_short_alias_prefix(compact: str, alias_key: str) -> bool:
"""Evita que alias de 1-2 chars capturen palabras como CODIGO o TOTAL."""
if not compact.startswith(alias_key):
return False
rest = compact[len(alias_key):]
if not rest:
return True
return bool(rest[0].isdigit())
def _match_known_doc_type_prefix(compact: str) -> Optional[tuple[str, str]]:
for alias_key in _DOC_TYPE_ALIAS_KEYS_SORTED:
if len(alias_key) <= 2:
if not _looks_like_short_alias_prefix(compact, alias_key):
continue
elif not compact.startswith(alias_key):
continue
return _DOC_TYPE_ALIAS_BY_KEY[alias_key], alias_key
return None
def split_doc_tipo_y_numero_texto(
raw: Any,
*,
homologate: bool = True,
) -> tuple[Optional[str], Optional[str]]:
"""Separa tipo documental y número, tolerando nombres largos y símbolos.
Si `homologate` es True, tipos conocidos se devuelven como sigla (CC, TI,
etc.). Los tipos desconocidos se conservan limpios en mayúsculas.
"""
if _is_nullish_scalar(raw) and not (isinstance(raw, str) and not str(raw).strip()):
return (None, None)
t = str(raw).strip()
if t.lower() in ("nan", "none", "nat", "<na>"):
return (None, None)
if t and t[0].isdigit():
return (None, None)
compact = _compact_doc_text(t)
if not compact:
return (None, None)
match = _match_known_doc_type_prefix(compact)
if match is not None:
canonical, alias_key = match
rest = compact[len(alias_key):]
nums = re.sub(r"[^\d]", "", rest)
if homologate:
return (canonical, nums if nums else None)
raw_type = _DOC_TYPE_ALIAS_DISPLAY_BY_KEY.get(alias_key) or canonical
return (raw_type, nums if nums else None)
m = re.match(
r"^([^\d]+?)\s*([0-9].*)\s*$",
t,
)
if m:
to_t = _clean_doc_type_text(m.group(1))
nums = re.sub(r"[^\d]", "", m.group(2))
return (homologate_doc_type(to_t) if homologate else to_t, nums if nums else None)
if not re.search(r"\d", t):
to_t = homologate_doc_type(t) if homologate else _clean_doc_type_text(t)
return (to_t, None)
return (None, None)
def _alloc_doc_split_column_names(
col_name: str, existing: set[str]
) -> tuple[str, str]:
"""Nombres únicos para las dos columnas derivadas."""
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
for i in range(0, 200):
suf = f"_{i}" if i else ""
t, n = f"{base}{suf}_doc_tipo", f"{base}{suf}_doc_numeros"
if t not in existing and n not in existing:
return t, n
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
def _apply_doc_id_split_columns(
result: pd.DataFrame, col: str, log: list[str], *, homologate_type: bool = True
) -> None:
tcol, ncol = _alloc_doc_split_column_names(str(col), set(result.columns))
st_tipo: list[Optional[str]] = []
st_num: list[Any] = []
for v in result[col].tolist():
tt, nn = split_doc_tipo_y_numero_texto(v, homologate=homologate_type)
st_tipo.append(tt)
st_num.append(nn if nn is not None else pd.NA)
result[tcol] = st_tipo
result[ncol] = st_num
log.append(
f"[OK] '{str(col)}' -> columnas '{tcol}' y '{ncol}' <- Dividir tipo y número (documento)"
)
+132
View File
@@ -0,0 +1,132 @@
"""Carga robusta de CSV (delimitador, encoding)."""
from __future__ import annotations
import csv as _csv
from collections import Counter
from typing import Optional
import pandas as pd
_CANDIDATE_DELIMITERS = ["|", ";", "\t", ",", ":"]
def detect_delimiter(filepath: str) -> str:
"""Detecta el delimitador del CSV de forma robusta.
Estrategia:
1. Intentar `csv.Sniffer` con un sample grande.
2. Si falla o el resultado parece incorrecto, contar ocurrencias por línea
de cada delimitador candidato y elegir el que produce el mayor número
de columnas estable entre líneas.
"""
encodings = ("utf-8", "utf-8-sig", "latin-1", "cp1252")
sample = ""
for enc in encodings:
try:
with open(filepath, "r", encoding=enc, errors="replace") as f:
sample = f.read(65536)
break
except Exception:
continue
if not sample:
return ","
try:
dialect = _csv.Sniffer().sniff(sample, delimiters="".join(_CANDIDATE_DELIMITERS))
sniffed = dialect.delimiter
except _csv.Error:
sniffed = None
lines = [ln for ln in sample.splitlines() if ln.strip()]
if not lines:
return sniffed or ","
# Para cada candidato, calcular el número promedio de columnas y la
# consistencia (cuántas líneas tienen el mismo conteo dominante).
scores: dict[str, tuple[int, int]] = {}
for delim in _CANDIDATE_DELIMITERS:
counts = [ln.count(delim) for ln in lines[:50]]
if max(counts) == 0:
continue
# Modo (valor más frecuente)
common = Counter(counts).most_common(1)[0]
mode_count, mode_freq = common
if mode_count == 0:
continue
scores[delim] = (mode_count, mode_freq)
if scores:
# Elegir el delimitador con mayor número de columnas (mode_count) y,
# como desempate, mayor consistencia (mode_freq).
best = max(scores.items(), key=lambda kv: (kv[1][0], kv[1][1]))
best_delim = best[0]
# Si el sniffer dio algo razonable y coincide, prevalece; si difiere y
# nuestro candidato es claramente mejor, usamos el nuestro.
if sniffed and sniffed in scores:
sniffed_score = scores[sniffed]
if sniffed_score[0] >= best[1][0] - 1:
return sniffed
return best_delim
return sniffed or ","
def load_csv(filepath: str) -> tuple[pd.DataFrame, str]:
"""Carga un CSV con detección de delimitador y tolerancia a filas malas.
Devuelve `(df, delimiter)`. Lanza excepción si todos los intentos fallan.
"""
delimiter = detect_delimiter(filepath)
encodings_to_try = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
last_error: Optional[Exception] = None
for enc in encodings_to_try:
try:
df = pd.read_csv(
filepath,
delimiter=delimiter,
dtype=str,
encoding=enc,
engine="python",
on_bad_lines="skip",
)
return df, delimiter
except Exception as exc: # noqa: BLE001
last_error = exc
continue
# Último intento con quoting permisivo y sin separador detectado
try:
df = pd.read_csv(
filepath,
sep=None,
dtype=str,
engine="python",
on_bad_lines="skip",
)
return df, "(auto)"
except Exception as exc: # noqa: BLE001
last_error = exc
raise RuntimeError(f"No se pudo leer el CSV: {last_error}")
def load_csv_bytes(content: bytes, filename: str = "upload.csv") -> tuple[pd.DataFrame, str]:
"""Carga un CSV desde bytes (upload HTTP) vía archivo temporal."""
import os
import tempfile
suffix = os.path.splitext(filename)[1] or ".csv"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
return load_csv(tmp_path)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
@@ -0,0 +1,67 @@
"""Tipos de columna y resumen de análisis."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
TYPE_INTEGER = "integer"
TYPE_DECIMAL = "decimal"
TYPE_DATE = "date"
TYPE_BOOLEAN = "boolean"
TYPE_CATEGORICAL = "categorical"
TYPE_TEXT = "text"
TYPE_MIXED = "mixed"
TYPE_EMPTY = "empty"
@dataclass
class ColumnSummary:
"""Resumen inteligente para una columna del DataFrame."""
name: str
inferred_type: str
example: str = ""
samples: list[str] = field(default_factory=list)
null_count: int = 0
null_pct: float = 0.0
unique_count: int = 0
unique_pct: float = 0.0
min: Optional[float] = None
max: Optional[float] = None
mean: Optional[float] = None
avg_length: Optional[float] = None
top_values: list[tuple[str, int]] = field(default_factory=list)
detected_date_format: Optional[str] = None
applicable_rules: list[str] = field(default_factory=list)
def info_text(self) -> str:
"""Texto humano con información adicional (para subtítulo de la UI)."""
parts: list[str] = []
parts.append(f"nulos: {self.null_count} ({self.null_pct:.0f}%)")
parts.append(f"únicos: {self.unique_count}")
if self.inferred_type in (TYPE_INTEGER, TYPE_DECIMAL):
if self.min is not None:
parts.append(f"min: {_fmt_num(self.min)}")
if self.max is not None:
parts.append(f"max: {_fmt_num(self.max)}")
if self.mean is not None:
parts.append(f"media: {_fmt_num(self.mean)}")
elif self.inferred_type == TYPE_DATE:
if self.detected_date_format:
parts.append(f"formato: {self.detected_date_format}")
elif self.inferred_type in (TYPE_TEXT, TYPE_CATEGORICAL):
if self.avg_length is not None:
parts.append(f"long. prom: {self.avg_length:.1f}")
if self.top_values:
tops = ", ".join(f'"{v}" ({c})' for v, c in self.top_values[:3])
parts.append(f"top: {tops}")
return " · ".join(parts)
def _fmt_num(x: float) -> str:
if x is None:
return ""
if isinstance(x, float) and x.is_integer():
return str(int(x))
if isinstance(x, float):
return f"{x:.2f}"
return str(x)
@@ -0,0 +1,73 @@
"""Extracción de documento y autorización desde notas de texto."""
from __future__ import annotations
import re
from typing import Any, Optional
import pandas as pd
from .nullish import _is_nullish_scalar
def _normalize_nota_text(raw: Any) -> str:
if _is_nullish_scalar(raw):
return ""
try:
if pd.isna(raw):
return ""
except TypeError:
pass
s = str(raw).strip()
if not s or s.lower() in ("nan", "none", "nat", "<na>"):
return ""
return re.sub(r"\s+", " ", s)
def extract_paciente_documento_nota(raw: Any) -> Optional[str]:
"""Número de documento tras ``Paciente:`` y antes del guión del nombre."""
t = _normalize_nota_text(raw)
if not t:
return None
m = _PACIENTE_DOC_NOTA_RE.search(t)
return m.group(1) if m else None
def extract_autorizacion_nota(raw: Any) -> Optional[str]:
"""Número tras ``Autorización No.`` (opcional por fila)."""
t = _normalize_nota_text(raw)
if not t:
return None
m = _AUTORIZACION_NOTA_RE.search(t)
return m.group(1) if m else None
def _alloc_nota_extract_column_names(
col_name: str, existing: set[str]
) -> tuple[str, str]:
"""Nombres únicos para documento paciente y autorización."""
base = re.sub(r"[^\w]+", "_", str(col_name).strip(), flags=re.ASCII) or "col"
for i in range(0, 200):
suf = f"_{i}" if i else ""
dcol, acol = f"{base}{suf}_nota_paciente_doc", f"{base}{suf}_nota_autorizacion"
if dcol not in existing and acol not in existing:
return dcol, acol
raise ValueError("No se pudieron asignar nombres de columnas únicos.")
def _apply_nota_extract_columns(result: pd.DataFrame, col: str, log: list[str]) -> None:
"""Añade columnas con documento (Paciente:) y número de autorización si existe."""
dcol, acol = _alloc_nota_extract_column_names(str(col), set(result.columns))
docs: list[Any] = []
auths: list[Any] = []
for v in result[col].tolist():
dd = extract_paciente_documento_nota(v)
aa = extract_autorizacion_nota(v)
docs.append(dd if dd is not None else pd.NA)
auths.append(aa if aa is not None else pd.NA)
result[dcol] = docs
result[acol] = auths
log.append(
f"[OK] '{str(col)}' -> columnas '{dcol}' y '{acol}' <- Extraer datos de notas"
)
@@ -0,0 +1,24 @@
"""Detección de valores nulos / vacíos."""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
def _is_nullish_scalar(x: Any) -> bool:
if x is None or x is pd.NA:
return True
if isinstance(x, (bool, np.bool_)):
return False
if isinstance(x, (int, float, np.integer, np.floating)) and not isinstance(
x, (bool, np.bool_)
):
if isinstance(x, float) and (np.isnan(x) or pd.isna(x)):
return True
return False
t = str(x).strip().lower()
if not t or t in ("nan", "none", "nat", "null", "<na>"):
return True
return False
@@ -0,0 +1,258 @@
"""Parsing numérico tolerante (miles, decimales, moneda)."""
from __future__ import annotations
import re
from typing import Any, Literal, Optional
import numpy as np
import pandas as pd
from .nullish import _is_nullish_scalar
_HINT_MAJORITY = 0.6
_MIN_HINT_VOTES = 1
def _strip_currency(s: str) -> str:
t = s.replace("\u00a0", " ").replace("\u202f", " ")
t = re.sub(r"[\s]+", "", t)
t = t.strip()
if not t or t.lower() in ("nan", "none", "nat"):
return ""
neg = False
if t[0:1] == "-":
neg, t = not neg, t[1:].lstrip()
if t[0:1] == "(" and t.endswith(")"):
neg, t = not neg, t[1:-1]
t = re.sub(
r"^[\$€\u00a3\u0e3f\uffe5\uffe6\uff04\ufe69]+",
"",
t,
count=1,
)
t = re.sub(r"[\$€%]+$", "", t)
if not t and neg:
return "-0"
return ("-" if neg else "") + t
def _extract_numeric_chunk(s: str) -> str:
t = s.strip()
t = t.replace("$", "").replace("€", "").replace("\u00a3", "")
t = t.replace(" ", "")
cands1 = re.findall(
r"[+\-]?(?:\d{1,3}(?:[.,]\d{3})*|\d+)(?:[.,]\d+)?", t
)
cands2 = re.findall(
r"[+\-]?\d[.,\d ]*\d", t
)
cands = cands1 + cands2
if not cands:
m = re.search(r"[\d.,\-]+", t)
if m:
c = m.group(0).replace("(", "-").replace(")", "")
return c.lstrip("+")
return ""
best = max(cands, key=len) if cands else ""
best = best.replace(" ", "").replace("(", "-").replace(")", "")
if not re.search(r"\d", best):
return ""
return best.lstrip("+")
def _as_float_s(s: str) -> Optional[float]:
if not s:
return None
try:
f = float(s)
if not np.isfinite(f) or abs(f) > 1e100:
return None
return f
except (ValueError, TypeError, OverflowError):
return None
def _parse_both_separators(body: str) -> Optional[float]:
"""El separador más a la DERECHA entre `,` y `.` es el decimal; el otro, miles."""
if "," not in body or "." not in body:
return None
t = body.strip()
if re.search(r"[^0-9,.\-]", t):
t2 = re.sub(r"[^0-9,.\-]", "", t)
t = t2
if not t:
return None
neg = t[0:1] == "-"
b = t[1:].lstrip() if neg else t
if not b:
return None
i_comma, i_dot = b.rfind(","), b.rfind(".")
if min(i_comma, i_dot) < 0:
return None
dpos = max(i_comma, i_dot)
th = "," if b[dpos] == "." else "."
intp, fr = b[:dpos], b[dpos + 1 :]
if not re.fullmatch(r"\d{1,4}$", fr):
return None
intc = intp.replace(th, "")
if not intc.isdigit():
return None
v = _as_float_s(f"{intc}.{fr}")
if v is None:
return None
if neg and v and v is not None:
return -abs(v)
return v
def _only_commas(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
neg = w[0:1] == "-"
wn = w[1:].lstrip() if neg else w
if wn and re.search(r"[^0-9,]", wn):
wn = re.sub(r"[^0-9,]", "", wn)
n = wn.count(",")
if n == 0 and wn.isdigit():
v = float(wn)
return -v if neg else v
if n == 0 or "." in wn:
return None
if n == 1:
i = wn.rfind(",")
h, t = wn[:i], wn[i + 1 :]
if not t.isdigit() or not h.isdigit():
return None
if len(t) in (1, 2):
v = _as_float_s(f"{h}.{t}")
if v is None:
return None
return -v if neg else v
if len(t) == 3:
if dec_hint is None or dec_hint == ",":
v = _as_float_s(f"{h}{t}")
if v is None:
return None
return -v if neg else v
if dec_hint == ".":
v = _as_float_s(wn.replace(",", ""))
if v is None:
return None
return -v if neg else v
v = _as_float_s(wn.replace(",", ""))
if v is None:
return None
return -v if neg else v
def _only_dots(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
neg = w[0:1] == "-"
wn = w[1:].lstrip() if neg else w
if wn and re.search(r"[^0-9.]", wn):
wn = re.sub(r"[^0-9.]", "", wn)
n = wn.count(".")
if n == 0 and wn.isdigit():
v = float(wn)
return -v if neg else v
if n == 0 or "," in wn:
return None
if n == 1:
i = wn.rfind(".")
h, t = wn[:i], wn[i + 1 :]
if not t.isdigit() or not h.isdigit():
return None
if len(t) in (1, 2):
v = _as_float_s(f"{h}.{t}")
if v is None:
return None
return -v if neg else v
if len(t) == 3:
if dec_hint is None or dec_hint == ".":
v = _as_float_s(f"{h}{t}")
if v is None:
return None
return -v if neg else v
if dec_hint == ",":
v = _as_float_s(wn.replace(".", ""))
if v is None:
return None
return -v if neg else v
v = _as_float_s(wn.replace(".", ""))
if v is None:
return None
return -v if neg else v
def _parse_lenient_one(
raw: Any,
decimal_hint: Optional[Literal[",", "."]] = None,
) -> float:
if _is_nullish_scalar(raw):
return float("nan")
t = _strip_currency(str(raw))
if not t or t == "-":
return float("nan")
c = _extract_numeric_chunk(t) or t
c = c.strip()
if not c or c == "-":
return float("nan")
n_comma, n_dot = c.count(","), c.count(".")
if n_comma and n_dot:
f = _parse_both_separators(c)
elif n_comma and not n_dot:
f = _only_commas(c, decimal_hint)
elif n_dot and not n_comma:
f = _only_dots(c, decimal_hint)
else:
c2 = re.sub(r"[^0-9.\-]", "", c)
f = _as_float_s(c2) if c2 and re.fullmatch(r"-?\d+$", c2) else None
if f is not None and np.isfinite(f):
return f
return float("nan")
def _infer_decimal_hint_from_column(
series: pd.Series,
) -> Optional[Literal[",", "."]]:
"""Voto con celdas que traen `,` y `.`: gana el de más a la derecha = decimal."""
votes: list[Literal[",", "."]] = []
for raw in series.dropna():
if _is_nullish_scalar(raw):
continue
t = _strip_currency(str(raw))
c = _extract_numeric_chunk(t) or t
c = c.strip()
if "," not in c or "." not in c or _parse_both_separators(c) is None:
continue
if c.rfind(",") > c.rfind("."):
votes.append(",")
else:
votes.append(".")
n = len(votes)
if n < _MIN_HINT_VOTES:
return None
a = sum(1 for v in votes if v == ",")
b = sum(1 for v in votes if v == ".")
if a + b == 0:
return None
if a / n >= _HINT_MAJORITY and a > b:
return ","
if b / n >= _HINT_MAJORITY and b > a:
return "."
return None
def _to_numeric_lenient(
series: pd.Series,
decimal_hint: Optional[Literal[",", "."]] = None,
) -> pd.Series:
"""Convierte a flotante; pista de columna por voto (si `decimal_hint` es None)."""
hint = decimal_hint if decimal_hint is not None else _infer_decimal_hint_from_column(
series
)
def _cell(x: Any) -> float:
if _is_nullish_scalar(x):
return float("nan")
return _parse_lenient_one(x, hint)
return series.map(_cell)
@@ -0,0 +1,13 @@
"""Reglas de limpieza registradas."""
from __future__ import annotations
from . import builtins as _builtins # noqa: F401 — registra reglas al importar
from .registry import CLEANING_RULES, get_rule, get_rule_fn, get_rule_name, register_rule
__all__ = [
"CLEANING_RULES",
"get_rule",
"get_rule_fn",
"get_rule_name",
"register_rule",
]
@@ -0,0 +1,135 @@
"""Registro de todas las reglas built-in.
Añadir una regla nueva: implementa la función y llama a register_rule abajo.
"""
from __future__ import annotations
from . import dates as date_rules
from . import documents as doc_rules
from . import numeric as num_rules
from . import special
from . import text as text_rules
from .registry import rebuild_cleaning_rules, register_rule
register_rule(
"strip_special_spaces",
name="Eliminar caracteres especiales y espacios",
description=(
"Deja solo caracteres alfanuméricos (A-Z, a-z, 0-9). No usar antes de "
"«Normalizar fecha» en columnas con fecha y hora: pega cifras y puede impedir "
"interpretar el valor (aplique primero la normalización de fecha o use la regla "
"que conserva separadores)."
),
fn=text_rules._rule_strip_special_spaces,
)
register_rule(
"strip_special_keep_date_seps",
name="Quitar símbolos; conserva /, -, : y espacios (fechas o texto con fechas)",
description=(
"Quita comas, $, comillas, etc. y deja letras, números, /, -, : (hora) y espacios. "
"Adecuado p. ej. en columna 'Fecha Servicio'."
),
fn=text_rules._rule_strip_special_keep_date_seps,
)
register_rule(
"strip_spaces",
name="Eliminar únicamente espacios",
description="Elimina todos los caracteres de espacio en blanco.",
fn=text_rules._rule_strip_spaces,
)
register_rule(
"to_integer",
name="Convertir a Entero",
description=(
"Convierte a enteros con detección de miles/decimal (`,`/`.`) y moneda; "
"redondea decimales."
),
fn=num_rules._rule_to_integer,
)
register_rule(
"date_to_ddmmyyyy",
name="Normalizar fecha → DD/MM/AAAA",
description=(
"Normaliza fechas numéricas, con separadores, con ruido o con meses en "
"texto (ej. 26-abril-2026) al formato DD/MM/AAAA."
),
fn=date_rules._rule_date_to_ddmmyyyy,
)
register_rule(
"date_numeric_to_ddmmyyyy",
name="Conversión de formato numérico (Fechas) → DD/MM/AAAA",
description=(
"Detecta el formato numérico predominante (YYYYMMDD, DDMMYYYY, etc.) "
"y normaliza a DD/MM/AAAA."
),
fn=date_rules._rule_date_numeric_to_ddmmyyyy,
)
register_rule(
"date_alpha_to_ddmmyyyy",
name="Conversión de formato alfanumérico (Fechas) → DD/MM/AAAA",
description=(
"Detecta el formato con separadores predominante "
"(DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD, etc.) y normaliza a DD/MM/AAAA."
),
fn=date_rules._rule_date_alpha_to_ddmmyyyy,
)
register_rule(
"date_messy_text_to_ddmmyyyy",
name="Texto a fecha (ruido en fechas) → DD/MM/AAAA",
description=(
"Quita ruido entre cifras (*, +, _, /, etc.), detecta el orden en la "
"columna (DDMMYYYY, YYYYMMDD, DDMMYY) y unifica a DD/MM/AAAA."
),
fn=date_rules._rule_date_messy_text_to_ddmmyyyy,
)
register_rule(
"round_decimals_custom",
name="Aproximación Decimales (>0,5 sube, ≤0,5 baja)",
description=(
"Usa el mismo análisis de miles, decimales y moneda que «Entero». Tras leer "
"el número, si la parte fraccionaria de |valor| es mayor a 0,5 se aproxima al "
"entero hacia lejos de cero; en caso contrario, hacia cero. El 0,5 exacto se baja."
),
fn=num_rules._rule_round_decimals_custom,
)
register_rule(
"doc_id_split_tipo_numero",
name="Dividir tipo y número (documento)",
description=(
"Añade dos columnas (nombres únicos p. ej. TipoID_doc_tipo, TipoID_doc_numeros): "
"prefijo con letras (NIT, CC, CE, …) y cifras sin separadores. No modifica la "
"columna de origen."
),
fn=special._rule_passthrough_series,
)
register_rule(
"doc_type_homologate",
name="Homologar tipo de documento",
description=(
"Convierte tipos de documento escritos como nombre largo, sigla o texto con "
"símbolos a su abreviatura estándar (CC, TI, CE, RC, etc.)."
),
fn=doc_rules._rule_doc_type_homologate,
)
register_rule(
"cop_mod_split",
name="Separa COP/MOD (copago / moderadora)",
description=(
"Según el año en la columna Fecha Servicio que elija en la tarjeta, clasifica "
"el importe en cuota moderadora (valores por año en Configuración) o copago. "
"Se ejecuta al final del proceso de limpieza para usar fechas ya normalizadas."
),
fn=special._rule_passthrough_series,
)
register_rule(
"nota_paciente_autorizacion",
name="Extraer datos de notas (documento y autorización)",
description=(
"Desde texto tipo nota hospitalaria, extrae el número tras «Paciente:» (antes del "
"guión del nombre) y, si existe, el de «Autorización No.». Añade dos columnas; "
"no modifica el texto original."
),
fn=special._rule_passthrough_series,
)
rebuild_cleaning_rules()
@@ -0,0 +1,141 @@
"""Reglas de normalización de fechas a DD/MM/AAAA."""
from __future__ import annotations
from typing import Any, Optional
import pandas as pd
from ..dates import (
_detect_compact_date_formats,
_normalize_messy_date_to_compact,
_parse_any_date_to_tuple,
_parse_date_with_format,
_parse_date_with_text_month,
detect_date_format,
)
from ..nullish import _is_nullish_scalar
def _rule_date_to_ddmmyyyy(series: pd.Series) -> pd.Series:
"""Normaliza fechas numéricas, alfanuméricas, con meses en texto y con ruido."""
stripped = series.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
stripped = stripped.where(series.notna(), other=pd.NA)
sep_fmt = detect_date_format(series) or detect_date_format(stripped)
compact8_fmt, compact6_fmt = _detect_compact_date_formats(series)
def _fn(x: Any):
if pd.isna(x) or str(x).strip() == "":
return x
parsed = _parse_any_date_to_tuple(
x,
sep_fmt=sep_fmt,
compact8_fmt=compact8_fmt,
compact6_fmt=compact6_fmt,
)
if parsed is None:
return x
d, m, y = parsed
return f"{d:02d}/{m:02d}/{y:04d}"
return series.map(_fn)
def _rule_date_numeric_to_ddmmyyyy(series: pd.Series) -> pd.Series:
"""Entrada: fechas en formato numérico (ej. 20240315 o 15032024).
Salida: "DD/MM/AAAA" tras detectar el formato predominante.
"""
# Para esta regla consideramos los valores "numéricos" (quitamos separadores
# comunes si los hay para detectar un YYYYMMDD/DDMMYYYY confiable).
stripped = series.astype(str).str.replace(r"[\s\/\-\.]", "", regex=True)
stripped = stripped.where(series.notna(), other=pd.NA)
fmt = detect_date_format(stripped)
if fmt is None:
# fallback: intentar con detector normal
fmt = detect_date_format(series)
return _format_dates_to_ddmmyyyy(series, fmt)
def _rule_date_alpha_to_ddmmyyyy(series: pd.Series) -> pd.Series:
"""Entrada: fechas en formato alfanumérico con separadores.
Salida: "DD/MM/AAAA".
"""
fmt = detect_date_format(series)
return _format_dates_to_ddmmyyyy(series, fmt)
def _format_dates_to_ddmmyyyy(series: pd.Series, fmt: Optional[str]) -> pd.Series:
"""Convierte cada valor al formato DD/MM/AAAA usando `fmt` como layout de
entrada detectado. La parte de hora (si existe) se descarta. Mantiene el
valor original si no puede parsear la fecha.
"""
if fmt is None:
return series.copy()
def _fn(x):
if pd.isna(x) or str(x).strip() == "":
return x
raw = str(x)
date_part, _time_part = _split_date_time(raw)
target = date_part if date_part else raw
parsed = _parse_date_with_format(target, fmt)
if parsed is None:
return x
d, m, y = parsed
return f"{d:02d}/{m:02d}/{y:04d}"
return series.map(_fn)
def _rule_date_messy_text_to_ddmmyyyy(series: pd.Series) -> pd.Series:
"""Fechas con símbolos y basura entre cifras → DD/MM/AAAA.
Normaliza a solo 6/8 dígitos, vota el formato con `detect_date_format` por
separado en compact8 y compact6; si el detector no devuelve nada, usa
`DDMMYYYY` / `DDMMYY` (alineado con la preferencia hispana en fechas
numéricas).
"""
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]
)
if c8s.empty and c6s.empty:
return series.copy()
fmt8: Optional[str] = None
fmt6: Optional[str] = None
if not c8s.empty:
fmt8 = detect_date_format(c8s) or "DDMMYYYY"
if not c6s.empty:
fmt6 = detect_date_format(c6s) or "DDMMYY"
def _one(x: Any, c: Any) -> Any:
if _is_nullish_scalar(x):
return x
if not isinstance(c, str) or len(c) not in (6, 8):
return x
f = (fmt8 or "DDMMYYYY") if len(c) == 8 else (fmt6 or "DDMMYY")
t = _parse_date_with_format(c, f)
if t is None and len(c) == 8:
t = _parse_date_with_format(c, "DDMMYYYY") or _parse_date_with_format(
c, "YYYYMMDD"
)
if t is None and len(c) == 6:
t = _parse_date_with_format(c, "DDMMYY")
if t is None:
return x
d, m, y = t
return f"{d:02d}/{m:02d}/{y:04d}"
return pd.Series(
[
_one(v, cser.iloc[i] if i < len(cser) else None) for i, v in enumerate(series)
],
index=series.index,
dtype=object,
)
@@ -0,0 +1,23 @@
"""Reglas de documento (homologación; split se aplica en apply.py)."""
from __future__ import annotations
from typing import Any
import pandas as pd
from ..documents import homologate_doc_type, split_doc_tipo_y_numero_texto
from ..nullish import _is_nullish_scalar
def _rule_doc_type_homologate(series: pd.Series) -> pd.Series:
"""Homologa una columna que contiene tipos de documento."""
def _fn(x: Any):
if _is_nullish_scalar(x):
return x
tipo, _num = split_doc_tipo_y_numero_texto(x, homologate=True)
if tipo is not None:
return tipo
return homologate_doc_type(x)
return series.map(_fn)
@@ -0,0 +1,57 @@
"""Reglas numéricas (entero, redondeo)."""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
from ..nullish import _is_nullish_scalar
from ..numbers import _to_numeric_lenient
def _rule_to_integer(series: pd.Series) -> pd.Series:
"""Convierte a entero (Int64 nullable) con parser tolerante."""
numeric = _to_numeric_lenient(series.astype(str))
return numeric.round().astype("Int64")
def _round_to_int_half_tie_le(x: float) -> int:
"""Fracc. > 0,5 hacia arriba; 0,5 o menos hacia abajo. Banda 1e-9 en 0,5 (float).
Misma noción que en la descripción de la regla. Trabaja sobre |x| y
recompone el signo.
"""
sign = -1 if x < 0 else 1
if x == 0.0 or x == -0.0:
return 0
ax = abs(float(x))
ip = math.trunc(ax)
frac = ax - ip
if frac < 0.5 - 1e-9:
out = ip
elif frac > 0.5 + 1e-9:
out = ip + 1
else:
out = ip
return -out if sign < 0 else out
def _rule_round_decimals_custom(series: pd.Series) -> pd.Series:
"""Mismo parser tolerante que convertir a entero; aprox. con fracc. >0,5 arriba."""
numeric = _to_numeric_lenient(series.astype(str))
def _fn(x):
if _is_nullish_scalar(x) or (isinstance(x, (float, np.floating)) and (not np.isfinite(x) or np.isnan(x))):
return pd.NA
try:
f = float(x)
except (TypeError, ValueError, OverflowError):
return pd.NA
if not np.isfinite(f):
return pd.NA
if f == 0.0 or f == -0.0:
return 0
return _round_to_int_half_tie_le(f)
return numeric.map(_fn).astype("Int64")
@@ -0,0 +1,80 @@
"""Registro de reglas de limpieza.
Para agregar una regla nueva:
1. Implementa ``fn(series: pd.Series) -> pd.Series`` (o passthrough si es especial).
2. Llama a ``register_rule(...)`` en ``rules/builtins.py``.
3. Si la regla es especial (añade columnas, usa config), maneja el id en ``apply.py``.
4. Incluye el id en ``_applicable_rules_for_type`` (type_inference.py) si debe sugerirse.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable, Optional
import pandas as pd
RuleFn = Callable[[pd.Series], pd.Series]
@dataclass(frozen=True)
class RuleSpec:
id: str
name: str
description: str
fn: RuleFn
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"description": self.description,
"fn": self.fn,
}
_REGISTRY: dict[str, RuleSpec] = {}
_ORDER: list[str] = []
def register_rule(
rule_id: str,
*,
name: str,
description: str,
fn: RuleFn,
) -> RuleSpec:
"""Registra una regla. Si el id ya existe, lo reemplaza manteniendo el orden."""
spec = RuleSpec(id=rule_id, name=name, description=description, fn=fn)
if rule_id not in _REGISTRY:
_ORDER.append(rule_id)
_REGISTRY[rule_id] = spec
return spec
def get_rule(rule_id: str) -> Optional[dict[str, Any]]:
spec = _REGISTRY.get(rule_id)
return spec.as_dict() if spec else None
def get_rule_fn(rule_id: str) -> Optional[RuleFn]:
spec = _REGISTRY.get(rule_id)
return spec.fn if spec else None
def get_rule_name(rule_id: str) -> str:
spec = _REGISTRY.get(rule_id)
return spec.name if spec else rule_id
def list_rules() -> list[dict[str, Any]]:
return [_REGISTRY[rid].as_dict() for rid in _ORDER if rid in _REGISTRY]
# Compat: lista mutable usada por la API (se rellena al importar builtins).
CLEANING_RULES: list[dict[str, Any]] = []
def rebuild_cleaning_rules() -> list[dict[str, Any]]:
CLEANING_RULES.clear()
CLEANING_RULES.extend(list_rules())
return CLEANING_RULES
@@ -0,0 +1,8 @@
"""Marcadores passthrough para reglas que no transforman la serie in-place."""
from __future__ import annotations
import pandas as pd
def _rule_passthrough_series(s: pd.Series) -> pd.Series:
return s.copy()
@@ -0,0 +1,40 @@
"""Reglas de limpieza de texto / espacios."""
from __future__ import annotations
import re
import pandas as pd
def _rule_strip_special_spaces(series: pd.Series) -> pd.Series:
"""Elimina todo lo que no sea alfanumérico (incluye espacios)."""
pattern = re.compile(r"[^A-Za-z0-9]", flags=re.UNICODE)
def _fn(x):
if pd.isna(x):
return x
return pattern.sub("", str(x))
return series.map(_fn)
def _rule_strip_special_keep_date_seps(series: pd.Series) -> pd.Series:
"""Conserva alfanuméricos, separadores de fecha '/', '-' y hora ':', y espacio."""
pattern = re.compile(r"[^A-Za-z0-9\/\-: ]", flags=re.UNICODE)
def _fn(x):
if pd.isna(x):
return x
return pattern.sub("", str(x))
return series.map(_fn)
def _rule_strip_spaces(series: pd.Series) -> pd.Series:
"""Elimina únicamente espacios (incluidos tabs y saltos de línea)."""
def _fn(x):
if pd.isna(x):
return x
return re.sub(r"\s+", "", str(x))
return series.map(_fn)
@@ -0,0 +1,143 @@
"""Inferencia de tipo de columna y reglas aplicables."""
from __future__ import annotations
from typing import Optional
import pandas as pd
from .column_hints import (
_column_name_suggests_code_id,
_column_name_suggests_monetary_value,
_column_name_suggests_quantity_or_number_not_bool,
_content_looks_like_doc_id_letter_prefix_column,
_raw_strings_suggest_alnum_mixed_presentation,
_raw_strings_suggest_monetary_presentation,
)
from .dates import _normalize_messy_date_to_compact, detect_date_format
from .models import (
TYPE_BOOLEAN,
TYPE_CATEGORICAL,
TYPE_DATE,
TYPE_DECIMAL,
TYPE_EMPTY,
TYPE_INTEGER,
TYPE_MIXED,
TYPE_TEXT,
)
from .numbers import _to_numeric_lenient
_BOOLEAN_VALUES = {"true", "false", "si", "sí", "no", "1", "0", "yes"}
def _detect_messy_text_date_format_guess(non_null: pd.Series) -> Optional[str]:
"""Fechas con ruido: compacta a dígitos; si hay consenso, reutiliza el detector.
(Depende de `_normalize_messy_date_to_compact`, definida más abajo en el módulo.)
"""
# resolución en tiempo de ejecución
norm = non_null.map(_normalize_messy_date_to_compact) # type: ignore[name-defined]
compacts: list[str] = []
for c in norm:
if isinstance(c, str) and len(c) in (6, 8):
compacts.append(c)
n = max(len(non_null), 1)
if not compacts or len(compacts) < max(2, int(0.25 * n)):
return None
s_compact = pd.Series(compacts, dtype=object)
return detect_date_format(s_compact)
def _infer_column_type(
series: pd.Series, column_name: str = ""
) -> tuple[str, Optional[str]]:
"""Devuelve (tipo, formato_fecha detectado; solo si aplica a tipo fecha)."""
non_null = series.dropna().astype(str).str.strip()
non_null = non_null[non_null != ""]
if non_null.empty:
return TYPE_EMPTY, None
cname = str(column_name or "")
# Boolean: no aplicar 0/1/ sí-no a columnas cuyo nombre pide importe, cantid., fecha…
if not _column_name_suggests_quantity_or_number_not_bool(cname):
lowered = non_null.str.lower()
if lowered.isin(_BOOLEAN_VALUES).all() and lowered.nunique() <= 3:
return TYPE_BOOLEAN, None
is_money = _column_name_suggests_monetary_value(cname) or _raw_strings_suggest_monetary_presentation(
non_null
)
is_code = _column_name_suggests_code_id(cname)
is_doc_pattern = _content_looks_like_doc_id_letter_prefix_column(non_null)
# Fecha: no forzar en importe/código, ni con datos tipo `CC`+cifra (6–8 dígitos sueltos)
# ni cuando el contenido parece moneda (p. ej. 113,400.00 → no es DDMMYYYY).
if not is_money and not is_code and not is_doc_pattern:
date_fmt = detect_date_format(non_null)
if date_fmt:
return (TYPE_DATE, date_fmt)
messy_date_fmt = _detect_messy_text_date_format_guess(non_null)
if messy_date_fmt:
return (TYPE_DATE, messy_date_fmt)
numeric_candidate = _to_numeric_lenient(non_null, decimal_hint=None)
numeric_ratio = numeric_candidate.notna().mean()
if numeric_ratio >= 0.9:
if _raw_strings_suggest_alnum_mixed_presentation(non_null):
return (TYPE_MIXED, None)
is_all_integer = (numeric_candidate.dropna() % 1 == 0).all()
if is_all_integer and not _raw_strings_suggest_monetary_presentation(non_null):
return (TYPE_INTEGER, None)
return (TYPE_DECIMAL, None)
unique_ratio = non_null.nunique() / len(non_null)
if non_null.nunique() <= 15 and unique_ratio < 0.3:
return TYPE_CATEGORICAL, None
if 0.1 < numeric_ratio < 0.9:
return TYPE_MIXED, None
return TYPE_TEXT, None
def _applicable_rules_for_type(inferred_type: str) -> list[str]:
"""Identificadores de reglas a mostrar según el tipo inferido (sin redundancias)."""
base = ["strip_special_spaces", "strip_special_keep_date_seps", "strip_spaces"]
if inferred_type == TYPE_INTEGER:
return base + ["cop_mod_split"]
if inferred_type == TYPE_DECIMAL:
return base + [
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"doc_type_homologate",
"cop_mod_split",
]
if inferred_type == TYPE_DATE:
return base + [
"doc_id_split_tipo_numero",
"doc_type_homologate",
"date_to_ddmmyyyy",
"nota_paciente_autorizacion",
]
if inferred_type == TYPE_MIXED:
return base + [
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"doc_type_homologate",
"date_to_ddmmyyyy",
"cop_mod_split",
"nota_paciente_autorizacion",
]
# Texto / categórico / boolean / empty → todas las de limpieza de texto + las
# conversoras (el usuario sabrá si aplica).
return base + [
"to_integer",
"round_decimals_custom",
"doc_id_split_tipo_numero",
"doc_type_homologate",
"date_to_ddmmyyyy",
"cop_mod_split",
"nota_paciente_autorizacion",
]
applicable_rule_ids_for_type = _applicable_rules_for_type