feat: Implement moderadora preferences management and enhance CSV cleaning rules for COP/MOD separation. Introduce new GUI components for moderadora settings, including default values and user input handling. Update application preferences to support new extraction models and improve overall user experience in data processing.

This commit is contained in:
erik
2026-05-13 01:55:32 -05:00
parent e2536380d4
commit 6ee6df5469
17 changed files with 1031 additions and 54 deletions
+84 -1
View File
@@ -3,16 +3,99 @@ from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, Optional
from .config import DEFAULT_ALLOWED_DOCUMENT_TYPES
from .csv_cleaning import DEFAULT_MODERADORA_BY_YEAR, normalize_moderadora_by_year_dict
logger = logging.getLogger(__name__)
PREFS_FILE = Path(__file__).resolve().parent.parent / "app_prefs.json"
def moderadora_by_year_to_json_dict(
data: dict[int, set[int]],
) -> dict[str, list[int]]:
"""Serializa año → conjunto de cuotas a JSON (claves str, listas ordenadas)."""
out: dict[str, list[int]] = {}
for y in sorted(data.keys()):
out[str(y)] = sorted(data[y])
return out
def default_moderadora_by_year_payload() -> dict[str, list[int]]:
"""Valores por defecto persistibles para cuota moderadora."""
return moderadora_by_year_to_json_dict(DEFAULT_MODERADORA_BY_YEAR)
def merge_moderadora_prefs(raw: Any) -> dict[str, list[int]]:
"""Fusiona prefs guardadas con defaults; devuelve año(str) → lista."""
base = default_moderadora_by_year_payload()
if not isinstance(raw, dict):
return base
parsed = normalize_moderadora_by_year_dict(raw)
for y, vals in parsed.items():
base[str(y)] = sorted(vals)
return base
def format_moderadora_text_from_prefs(payload: dict[str, list[int]]) -> str:
"""Una línea por año: `2024: 4500, 18200, 47700`."""
lines: list[str] = []
for ks in sorted(payload.keys(), key=lambda x: int(x)):
nums = payload[ks]
lines.append(f"{ks}: {', '.join(str(n) for n in nums)}")
return "\n".join(lines)
def parse_moderadora_text(text: str) -> dict[str, list[int]]:
"""Parsea líneas `año: n1, n2` o `año = n1, n2`. Ignora vacías y errores."""
out: dict[str, list[int]] = {}
for line in (text or "").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^\s*(\d{4})\s*[:=]\s*(.+)$", line)
if not m:
continue
yk = m.group(1)
rest = m.group(2)
nums: list[int] = []
for part in re.split(r"[,;\s]+", rest):
part = part.strip()
if not part:
continue
try:
nums.append(int(round(float(part))))
except (TypeError, ValueError):
continue
if nums:
out[yk] = nums
return out
def load_cop_mod_preferences() -> tuple[dict[str, list[int]], str]:
"""Lee prefs para COP/MOD: mapa año→lista y nombre sugerido columna fecha."""
prefs = load_app_prefs()
merged = merge_moderadora_prefs(prefs.get("moderadora_by_year"))
fecha_default = str(prefs.get("fecha_servicio_column_default") or "").strip()
return merged, fecha_default
def build_cop_mod_config_for_apply(
moderadora_payload: Optional[dict[str, list[int]]] = None,
) -> dict[str, Any]:
"""Construye el dict opcional para `csv_cleaning.apply_rules(..., cop_mod_config=)`."""
if moderadora_payload is None:
prefs = load_app_prefs()
merged = merge_moderadora_prefs(prefs.get("moderadora_by_year"))
else:
merged = merge_moderadora_prefs(moderadora_payload)
return {"moderadora_by_year": merged}
def load_app_prefs() -> Dict[str, Any]:
"""Carga preferencias desde disco si existen."""
try:
+3 -1
View File
@@ -21,7 +21,7 @@ from application.config import settings
from application.app_prefs import get_allowed_document_types
from application.tracking_manager import TrackingManager
from core.utils import collect_radicado_docs, find_first_matching_file, collect_documents_flat
from core.csv_utils import rows_from_data
from core.csv_utils import merge_verbatim_long_strings, rows_from_data
logger = logging.getLogger(__name__)
@@ -320,6 +320,7 @@ class BatchProcessor:
raw_data,
selected_paths_for_type,
)
merge_verbatim_long_strings(raw_data, normalized_obj, selected_paths_for_type)
self._accumulate_usage(agg_normalize_usage, n_usage)
fused_data[tname] = normalized_obj or {}
@@ -508,6 +509,7 @@ class BatchProcessor:
raw_data,
selected_fields,
)
merge_verbatim_long_strings(raw_data, data, selected_fields)
data['radicado'] = entry['radicado']
data["_source_pdf"] = entry["path"]
+17
View File
@@ -28,6 +28,8 @@ class ApiSettings(BaseSettings):
api_key: str = os.getenv("EXTRACTOR_API_KEY", "ollama")
model: str = os.getenv("EXTRACTOR_MODEL", "gpt-5.4")
request_timeout_s: int = int(os.getenv("EXTRACTOR_TIMEOUT_S", 120))
grok_api_key: str = os.getenv("GROK_API_KEY", "")
grok_base_url: str = os.getenv("GROK_BASE_URL", "https://api.x.ai/v1")
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@@ -66,6 +68,21 @@ class AppSettings(BaseSettings):
settings = AppSettings()
def extractor_credentials_for_model(model: str) -> tuple[str, str]:
"""Devuelve (api_key, base_url) para el extractor según el id de modelo."""
m = (model or "").strip()
api = settings.api
if m.lower().startswith("grok-"):
return (str(api.grok_api_key or "").strip(), str(api.grok_base_url or "").strip() or "https://api.x.ai/v1")
return (api.api_key, api.base_url)
def is_grok_extractor_model(model: str) -> bool:
"""True si el modelo de extracción debe usar la API de xAI (Grok)."""
return (model or "").strip().lower().startswith("grok-")
# Tipos documentales válidos por defecto para el campo especial `documento_forzate`.
# NIT se excluye explícitamente porque no debe tratarse como tipo de documento personal.
DEFAULT_ALLOWED_DOCUMENT_TYPES: Tuple[str, ...] = (
+5 -5
View File
@@ -12,11 +12,6 @@ MODEL_PRICING = {
"cached_input": 0.10,
"output": 1.60,
},
"gpt-5.2": {
"input": 1.25,
"cached_input": 0.125,
"output": 10.00,
},
"gpt-5.4": {
"input": 2.50,
"cached_input": 0.25,
@@ -32,6 +27,11 @@ MODEL_PRICING = {
"cached_input": 0.02,
"output": 0.80,
},
"grok-4.3": {
"input": 1.25,
"cached_input": 0.0,
"output": 2.50,
},
}
# Legacy mapping o fase por defecto (si no se especifica modelo)
+296 -6
View File
@@ -26,6 +26,157 @@ import pandas as pd
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# CUOTA MODERADORA (COP/MOD)
# ---------------------------------------------------------------------------
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}')"
)
# ---------------------------------------------------------------------------
# MODELO DE DATOS
# ---------------------------------------------------------------------------
@@ -166,7 +317,8 @@ def _split_date_time(value: str) -> tuple[str, Optional[str]]:
if ampm and not (1 <= hh <= 12):
return s, None
date_part = s[: m.start()].rstrip(" T")
# 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
@@ -997,19 +1149,21 @@ 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
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 + [
@@ -1018,6 +1172,8 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]:
"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).
@@ -1027,6 +1183,8 @@ def _applicable_rules_for_type(inferred_type: str) -> list[str]:
"doc_id_split_tipo_numero",
"doc_type_homologate",
"date_to_ddmmyyyy",
"cop_mod_split",
"nota_paciente_autorizacion",
]
@@ -1576,6 +1734,74 @@ def _apply_doc_id_split_columns(
)
# --- Texto tipo nota (Paciente / Autorización) --------------------------------
_PACIENTE_DOC_NOTA_RE = re.compile(r"(?i)Paciente:\s*(\d+)\s*-\s*")
_AUTORIZACION_NOTA_RE = re.compile(r"(?i)Autorizaci[oó]n\s*No\.?\s*(\d+)")
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"
)
# Registro de reglas
CLEANING_RULES: list[dict[str, Any]] = [
{
@@ -1661,6 +1887,26 @@ CLEANING_RULES: list[dict[str, Any]] = [
),
"fn": _rule_doc_type_homologate,
},
{
"id": "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": _rule_passthrough_series,
},
{
"id": "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": _rule_passthrough_series,
},
]
_RULE_BY_ID: dict[str, Callable[[pd.Series], pd.Series]] = {
@@ -1686,21 +1932,30 @@ def get_rule(rule_id: str) -> Optional[dict[str, Any]]:
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`. Devuelve el DataFrame resultante y un log.
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] = []
for col, rule_ids in rules_per_column.items():
moderadora_years = merge_moderadora_config(cop_mod_config)
def _run_rules_for_column(col: str, rule_ids: list[str]) -> None:
if not rule_ids:
continue
return
if col not in result.columns:
log.append(f"[WARN] Columna no encontrada: {col}")
continue
return
split_has_homologation = (
"doc_id_split_tipo_numero" in rule_ids and "doc_type_homologate" in rule_ids
)
@@ -1714,6 +1969,13 @@ def apply_rules(
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 = _RULE_BY_ID.get(rule_id)
@@ -1727,6 +1989,34 @@ def apply_rules(
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
+1 -1
View File
@@ -43,7 +43,7 @@ class CSVManager:
file_exists = os.path.exists(csv_path) and os.path.getsize(csv_path) > 0
with self._lock:
with open(csv_path, "a", encoding="utf-8", newline="") as f:
with open(csv_path, "a", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=header, extrasaction="ignore", delimiter="|")
if not file_exists:
writer.writeheader()
+14 -2
View File
@@ -223,6 +223,7 @@ class LLMClient:
response_format: str = "json_object",
timeout_s: Optional[float] = None,
component: str = "llm_client",
max_completion_tokens: Optional[int] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""Devuelve (objeto JSON parseado, usage)."""
import json
@@ -231,11 +232,16 @@ class LLMClient:
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
completion_kwargs: Dict[str, Any] = {
"response_format": {"type": response_format},
}
if max_completion_tokens is not None:
completion_kwargs["max_completion_tokens"] = max_completion_tokens
chat_completion = self._create_completion(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
response_format={"type": response_format},
**completion_kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
@@ -256,6 +262,7 @@ class LLMClient:
response_format: str = "json_object",
timeout_s: Optional[float] = None,
component: str = "llm_client",
max_completion_tokens: Optional[int] = None,
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""Devuelve (objeto JSON parseado, usage) de forma asíncrona."""
import json
@@ -264,11 +271,16 @@ class LLMClient:
self._log_start(component=component, model=eff_model, timeout_s=eff_timeout, messages_count=len(messages or []))
start = time.time()
try:
completion_kwargs: Dict[str, Any] = {
"response_format": {"type": response_format},
}
if max_completion_tokens is not None:
completion_kwargs["max_completion_tokens"] = max_completion_tokens
chat_completion = await self._create_completion_async(
messages=messages,
model=eff_model,
timeout_s=eff_timeout,
response_format={"type": response_format},
**completion_kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = self._extract_usage(chat_completion)
+1
View File
@@ -123,6 +123,7 @@ def normalize_to_schema(
response_format="json_object",
timeout_s=float(eff_timeout),
component="normalizer",
max_completion_tokens=16384,
)
except Exception:
normalized_obj = None
+6 -2
View File
@@ -75,7 +75,9 @@ class PromptBuilder:
"Eres un asistente que normaliza un JSON a un ESQUEMA CANÓNICO dado. "
"Debes devolver EXCLUSIVAMENTE un JSON que respete exactamente la estructura y nombres del 'template'. "
"Reglas: (1) No inventes claves; (2) No uses sinónimos ni variantes; (3) Respeta listas (si 'items' es lista, devuelve un array de objetos); "
"(4) Si falta un valor, usa null o [] según corresponda; (5) No incluyas texto extra ni comentarios."
"(4) Si falta un valor, usa null o [] según corresponda; (5) No incluyas texto extra ni comentarios. "
"(6) Los campos de texto largo (notas, observaciones, leyendas, pie de página, etc.): copia el texto COMPLETO "
"del source_json sin resumir, sin omitir tramos ni acortar; respeta tildes y caracteres tal cual en el valor de origen."
)
user_obj = {
"perfil": profile_name,
@@ -83,7 +85,9 @@ class PromptBuilder:
"source_json": source_json,
"instrucciones": (
"Mapea los valores del 'source_json' al 'template'. Si hay claves equivalentes en el source, "
"elige siempre los nombres del template. No devuelvas rutas con '/', sino objetos anidados."
"elige siempre los nombres del template. No devuelvas rutas con '/', sino objetos anidados. "
"Para cualquier cadena larga del documento (p. ej. notas legales o bloques de observación), "
"transcribe el contenido literal completo; no uses elipsis ni versiones abreviadas."
),
}
return [
+83
View File
@@ -69,6 +69,89 @@ def _get_at_path(obj: Any, path: str) -> Any:
return cur
def set_at_path(root: Dict[str, Any], path: str, value: Any) -> None:
"""Asigna ``value`` en ``root`` siguiendo la ruta con segmentos ``/`` (crea dicts intermedios)."""
parts = [p for p in path.split('/') if p]
if not parts:
return
cur: Any = root
for p in parts[:-1]:
if not isinstance(cur, dict):
return
nxt = cur.get(p)
if not isinstance(nxt, dict):
cur[p] = {}
nxt = cur[p]
cur = nxt
if isinstance(cur, dict):
cur[parts[-1]] = value
def _resolve_raw_string_for_scalar_path(raw: Dict[str, Any], path: str) -> tuple[Optional[str], bool]:
"""Resuelve un string en ``raw`` para la ruta canónica ``path``.
Devuelve ``(valor, ambiguous)``. Si ``ambiguous`` es True, no debe usarse ningún valor
(varias claves aplanadas comparten el último segmento del path).
"""
direct = _get_at_path(raw, path)
if isinstance(direct, str) and direct.strip():
return direct, False
parts = [p for p in path.split('/') if p]
if not parts:
return None, False
last = parts[-1]
raw_flat = flatten_non_list_fields(raw)
same_last: List[tuple[str, str]] = []
for k, v in raw_flat.items():
if not isinstance(v, str) or not v.strip():
continue
segs = [s for s in k.split('/') if s]
if not segs:
continue
if segs[-1] == last:
same_last.append((k, v))
if len(same_last) == 1:
return same_last[0][1], False
if len(same_last) > 1:
return None, True
return None, False
def merge_verbatim_long_strings(
raw: Dict[str, Any],
normalized: Dict[str, Any],
paths: Optional[List[str]],
) -> Dict[str, Any]:
"""Sustituye en ``normalized`` cadenas truncadas por la versión más larga del ``raw`` (misma ruta o sufijo único).
Solo actúa sobre rutas escalares (sin ``*``). Evita sustituciones cuando hay varias claves
candidatas con el mismo último segmento o cuando el texto normalizado no parece un prefijo
del crudo (salvo cadena vacía o solo espacios).
"""
if not isinstance(normalized, dict) or not isinstance(raw, dict):
return normalized
for path in paths or []:
if not path or '*' in path:
continue
norm_val = _get_at_path(normalized, path)
if not isinstance(norm_val, str):
continue
raw_val, ambiguous = _resolve_raw_string_for_scalar_path(raw, path)
if ambiguous or raw_val is None:
continue
if len(raw_val) <= len(norm_val):
continue
nt = norm_val.strip()
if nt:
if nt not in raw_val:
lim = min(80, len(nt))
if lim == 0 or not raw_val.startswith(nt[:lim]):
continue
set_at_path(normalized, path, raw_val)
return normalized
def aggregate_list_fields(data: Dict[str, Any], aggregate_paths: List[str]) -> Dict[str, Any]:
aggregated: Dict[str, Any] = {}
for base_path in aggregate_paths:
+87 -19
View File
@@ -13,12 +13,19 @@ import os
from core.utils import parse_comma_separated, collect_radicado_docs, collect_documents_flat
from application.logging_config import configure_logging
from application.app_prefs import load_app_prefs, save_app_prefs, normalize_document_types
from application.app_prefs import (
build_cop_mod_config_for_apply,
load_app_prefs,
merge_moderadora_prefs,
normalize_document_types,
parse_moderadora_text,
save_app_prefs,
)
from gui.field_selector import FieldSelectorDialog
from application.preclassification import PreclassificationService
from application.strategies import OllamaExtractor
from application.database import DatabaseManager
from application.config import settings
from application.config import settings, extractor_credentials_for_model, is_grok_extractor_model
from application.costs import MODEL_PRICING
from application import postprocess_agent
from application import csv_cleaning
@@ -28,6 +35,21 @@ import pandas as pd
logger = logging.getLogger(__name__)
# Etiquetas antiguas del combo → etiquetas actuales (prefs / estado previo)
_LEGACY_SETTINGS_MODEL_LABEL = {
"5.4": "gpt-5.4",
"5.4-mini": "gpt-5.4-mini",
"5.4-nano": "gpt-5.4-nano",
"5.2": "gpt-5.4",
"gpt-5.2": "gpt-5.4",
"4.1-mini": "gpt-4.1-mini",
}
def _canonical_settings_model_label(name: str) -> str:
s = (name or "").strip()
return _LEGACY_SETTINGS_MODEL_LABEL.get(s, s)
class AppController:
"""Controlador principal de la GUI que orquesta la vista y el servicio.
@@ -123,11 +145,11 @@ class AppController:
# Mapeo de nombres de UI a IDs de modelo (y viceversa)
self.ui_to_model = {
"5.4": "gpt-5.4",
"5.4-mini": "gpt-5.4-mini",
"5.4-nano": "gpt-5.4-nano",
"5.2": "gpt-5.2",
"4.1-mini": "gpt-4.1-mini",
"gpt-5.4": "gpt-5.4",
"gpt-5.4-mini": "gpt-5.4-mini",
"gpt-5.4-nano": "gpt-5.4-nano",
"gpt-4.1-mini": "gpt-4.1-mini",
"grok-4.3": "grok-4.3",
}
self.model_to_ui = {v: k for k, v in self.ui_to_model.items()}
@@ -168,6 +190,25 @@ class AppController:
results.sort(key=lambda x: x["input"], reverse=True)
return results
def _apply_extractor_model(self, real_model_id: str, *, interactive: bool = False) -> bool:
"""Aplica modelo y credenciales del extractor (Grok usa GROK_API_KEY / GROK_BASE_URL)."""
if not hasattr(self.service.extractor, "update_config"):
return True
if is_grok_extractor_model(real_model_id):
api_key, base_url = extractor_credentials_for_model(real_model_id)
if not api_key:
msg = "Define GROK_API_KEY en el entorno o en .env para usar modelos Grok."
if interactive:
mb.showwarning("Configuración", msg)
else:
logger.warning("Modelo persistido %s no aplicado: %s", real_model_id, msg)
return False
self.service.extractor.update_config(api_key=api_key, base_url=base_url, model=real_model_id)
return True
api_key, base_url = extractor_credentials_for_model(real_model_id)
self.service.extractor.update_config(api_key=api_key, base_url=base_url, model=real_model_id)
return True
def on_save_settings(self):
"""Actualiza la configuración del servicio (modelo LLM y concurrencia)."""
selected_name = self.view.get_selected_model()
@@ -182,19 +223,19 @@ class AppController:
return
# Traducir nombre UI -> ID modelo real
real_model_id = self.ui_to_model.get(selected_name, selected_name)
canonical_ui = _canonical_settings_model_label(selected_name)
real_model_id = self.ui_to_model.get(canonical_ui, canonical_ui)
try:
# Actualizar extractor
if hasattr(self.service.extractor, "update_config"):
self.service.extractor.update_config(model=real_model_id)
if not self._apply_extractor_model(real_model_id, interactive=True):
return
# Actualizar settings globales
settings.processing.concurrency_limit = concurrency
self.view.set_allowed_document_types_hint(self.view.get_allowed_document_types())
self._save_preferences()
mb.showinfo("Configuración", f"Configuración actualizada.\nModelo: {selected_name}\nConcurrencia: {concurrency}")
mb.showinfo("Configuración", f"Configuración actualizada.\nModelo: {canonical_ui}\nConcurrencia: {concurrency}")
self.view.append_output_safe(f"Configuración actualizada: Modelo={real_model_id}, Concurrencia={concurrency}")
except Exception as e:
logger.exception("Error actualizando configuración")
@@ -514,6 +555,8 @@ class AppController:
"root_path": self.view.get_root_path(),
"csv_path": self.view.get_csv_path(),
"doc_types": doc_types,
"moderadora_by_year": parse_moderadora_text(self.view.get_moderadora_cuotas_text()),
"fecha_servicio_column_default": self.view.get_fecha_servicio_column_hint(),
}
def _save_preferences(self) -> None:
@@ -529,10 +572,10 @@ class AppController:
try:
selected_model = str(prefs.get("selected_model") or "").strip()
if selected_model:
self.view.set_selected_model(selected_model)
real_model_id = self.ui_to_model.get(selected_model, selected_model)
if hasattr(self.service.extractor, "update_config"):
self.service.extractor.update_config(model=real_model_id)
canonical_ui = _canonical_settings_model_label(selected_model)
self.view.set_selected_model(canonical_ui)
real_model_id = self.ui_to_model.get(canonical_ui, canonical_ui)
self._apply_extractor_model(real_model_id, interactive=False)
except Exception:
logger.exception("No se pudo restaurar el modelo persistido")
@@ -581,6 +624,15 @@ class AppController:
except Exception:
pass
try:
merged = merge_moderadora_prefs(prefs.get("moderadora_by_year"))
self.view.set_moderadora_cuotas_ui(
merged,
str(prefs.get("fecha_servicio_column_default") or ""),
)
except Exception:
pass
def on_app_close(self):
"""Guarda preferencias antes de cerrar la ventana."""
try:
@@ -1246,8 +1298,8 @@ class AppController:
pass
def _finish_cleaning_csv_load(self, df: pd.DataFrame, summaries):
self.view.populate_cleaning_columns(summaries)
self.view.set_cleaning_preview(df)
self.view.populate_cleaning_columns(summaries)
self.view.set_cleaning_status(
f"CSV cargado: {len(df):,} filas x {len(df.columns)} columnas."
)
@@ -1281,6 +1333,15 @@ class AppController:
)
return
fecha_map = self.view.get_cleaning_cop_mod_fecha_columns()
for col_name, rule_ids in rules.items():
if "cop_mod_split" in rule_ids and col_name not in fecha_map:
mb.showwarning(
"COP/MOD",
f"Marque «Separa COP/MOD» y elija la columna «Fecha Servicio» para «{col_name}».",
)
return
self.view.enable_cleaning_controls(False)
self.view.enable_cleaning_save(False)
self.view.set_cleaning_status("Aplicando reglas...")
@@ -1290,7 +1351,14 @@ class AppController:
def worker():
try:
result_df, log = csv_cleaning.apply_rules(df, rules)
cop_cfg = build_cop_mod_config_for_apply()
fecha_map = self.view.get_cleaning_cop_mod_fecha_columns()
result_df, log = csv_cleaning.apply_rules(
df,
rules,
cop_mod_fecha_column=fecha_map or None,
cop_mod_config=cop_cfg,
)
self._cleaning_result_df = result_df
ok_count = sum(1 for line in log if line.startswith("[OK]"))
err_count = sum(1 for line in log if line.startswith("[ERROR]"))
+125
View File
@@ -17,6 +17,7 @@ from application.csv_cleaning import (
ColumnSummary,
applicable_rule_ids_for_type,
)
from application.app_prefs import load_cop_mod_preferences
from gui.styles import AppStyles
@@ -67,6 +68,8 @@ _RULE_SHORT_LABELS: dict[str, str] = {
"round_decimals_custom": "Aprox. decimales (>0.5 sube)",
"doc_id_split_tipo_numero": "Dividir doc (tipo + cifras)",
"doc_type_homologate": "Homologar tipo doc",
"cop_mod_split": "Separa COP/MOD",
"nota_paciente_autorizacion": "Extraer doc. y autorización (notas)",
}
@@ -96,6 +99,9 @@ class _ColumnUI:
rule_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]]
summary: ColumnSummary
expanded: bool = True
cop_mod_fecha_frame: Optional[ctk.CTkFrame] = None
cop_mod_fecha_menu: Optional[ctk.CTkOptionMenu] = None
cop_mod_fecha_var: Optional[ctk.StringVar] = None
class CleaningTab:
@@ -109,6 +115,7 @@ class CleaningTab:
self._column_uis: dict[str, _ColumnUI] = {}
self._search_var: Optional[ctk.StringVar] = None
self._csv_column_names: list[str] = []
# Tipo de dato forzado por el usuario (id interno) por columna; vacío = inferido
self._type_overrides: dict[str, str] = {}
self._syncing_rule_vars = False
@@ -473,6 +480,7 @@ class CleaningTab:
def set_preview(self, df: Optional[pd.DataFrame]):
"""Renderiza el DataFrame original en la tabla de vista previa."""
if df is None or len(df) == 0:
self._csv_column_names = []
self._render_table_placeholder(
self.preview_table_holder, "(sin datos)",
)
@@ -489,6 +497,7 @@ class CleaningTab:
)
self.chip_rows.configure(text=f"Filas: {len(df):,}")
self.chip_cols.configure(text=f"Columnas: {len(df.columns)}")
self._csv_column_names = list(df.columns)
def set_result(self, df: Optional[pd.DataFrame], info_text: str = ""):
"""Renderiza el DataFrame resultante en la tabla de resultado."""
@@ -574,6 +583,56 @@ class CleaningTab:
self.chip_cols.configure(text=f"Columnas: {len(summaries)}")
self._update_global_count()
self._refresh_cop_mod_menus()
def get_cop_mod_fecha_columns(self) -> dict[str, str]:
"""Mapa columna de importe -> columna Fecha Servicio (solo si COP/MOD está marcado)."""
out: dict[str, str] = {}
for col_name, ui in self._column_uis.items():
if ui.cop_mod_fecha_var is None:
continue
cop_on = any(
rid == "cop_mod_split" and bool(var.get())
for rid, _chk, var in ui.rule_vars
)
if not cop_on:
continue
sel = str(ui.cop_mod_fecha_var.get() or "").strip()
if sel:
out[col_name] = sel
return out
def _date_columns_for_cop_mod_menu(self) -> list[str]:
cols = self._csv_column_names or []
return cols if cols else [""]
def _suggest_fecha_column_option(self) -> str:
cols = self._csv_column_names or []
if not cols:
return ""
_, hint = load_cop_mod_preferences()
if hint and hint in cols:
return hint
for c in cols:
low = c.lower().replace("_", " ")
if "fecha" in low and "servicio" in low:
return c
return cols[0]
def _refresh_cop_mod_menus(self) -> None:
cols = self._date_columns_for_cop_mod_menu()
suggest = self._suggest_fecha_column_option()
for ui in self._column_uis.values():
if ui.cop_mod_fecha_menu is None or ui.cop_mod_fecha_var is None:
continue
cur = ui.cop_mod_fecha_var.get()
ui.cop_mod_fecha_menu.configure(values=cols)
if cur and cur in cols:
ui.cop_mod_fecha_var.set(cur)
elif suggest and suggest in cols:
ui.cop_mod_fecha_var.set(suggest)
elif cols and cols[0]:
ui.cop_mod_fecha_var.set(cols[0])
def get_selected_rules(self) -> dict[str, list[str]]:
"""Devuelve un mapa columna -> lista de rule_ids en el orden visual."""
@@ -700,6 +759,30 @@ class CleaningTab:
col_in_row = 0
row_in_rules += 1
fec_frame = None
fec_menu = None
fec_var = None
if "cop_mod_split" in ordered_ids:
fec_frame = ctk.CTkFrame(body, fg_color="transparent")
fec_frame.grid(row=4, column=0, sticky="ew", padx=4, pady=(4, 0))
fec_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
fec_frame,
text="Fecha Servicio (columna):",
font=self.fonts["italic_small"],
text_color=self.colors["gray_dark"],
).grid(row=0, column=0, sticky="w", padx=(0, 8))
fec_var = ctk.StringVar(value=self._suggest_fecha_column_option())
fec_menu = ctk.CTkOptionMenu(
fec_frame,
values=self._date_columns_for_cop_mod_menu(),
variable=fec_var,
font=self.fonts["ui"],
width=260,
)
fec_menu.grid(row=0, column=1, sticky="ew")
fec_menu.configure(state="disabled")
ui = _ColumnUI(
card=card,
body=body,
@@ -710,6 +793,9 @@ class CleaningTab:
rules_frame=rules_frame,
rule_vars=vars_list,
summary=summary,
cop_mod_fecha_frame=fec_frame,
cop_mod_fecha_menu=fec_menu,
cop_mod_fecha_var=fec_var,
)
toggle_btn.configure(command=lambda c=summary.name: self._toggle_column(c))
return ui
@@ -910,6 +996,39 @@ class CleaningTab:
col_in_row = 0
row_in_rules += 1
ui.rule_vars = new_vars
if ui.cop_mod_fecha_frame is not None:
try:
ui.cop_mod_fecha_frame.destroy()
except Exception:
pass
ui.cop_mod_fecha_frame = None
ui.cop_mod_fecha_menu = None
ui.cop_mod_fecha_var = None
ordered_after = [r["id"] for r in CLEANING_RULES if r["id"] in applic]
if "cop_mod_split" in ordered_after:
fec_frame = ctk.CTkFrame(ui.body, fg_color="transparent")
fec_frame.grid(row=4, column=0, sticky="ew", padx=4, pady=(4, 0))
fec_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
fec_frame,
text="Fecha Servicio (columna):",
font=self.fonts["italic_small"],
text_color=self.colors["gray_dark"],
).grid(row=0, column=0, sticky="w", padx=(0, 8))
fec_var = ctk.StringVar(value=self._suggest_fecha_column_option())
fec_menu = ctk.CTkOptionMenu(
fec_frame,
values=self._date_columns_for_cop_mod_menu(),
variable=fec_var,
font=self.fonts["ui"],
width=260,
)
fec_menu.grid(row=0, column=1, sticky="ew")
fec_menu.configure(state="disabled")
ui.cop_mod_fecha_frame = fec_frame
ui.cop_mod_fecha_menu = fec_menu
ui.cop_mod_fecha_var = fec_var
self._refresh_cop_mod_menus()
self.set_status(f"Columna «{col_name}»: tipo «{_TYPE_LABELS.get(eff, eff)}».")
self._on_rule_toggle(col_name)
@@ -954,6 +1073,12 @@ class CleaningTab:
f"Columna «{col_name}»: se activó homologación de tipo de documento."
)
break
if ui.cop_mod_fecha_menu is not None:
cop_on = any(
rid == "cop_mod_split" and bool(var.get())
for rid, _chk, var in ui.rule_vars
)
ui.cop_mod_fecha_menu.configure(state="normal" if cop_on else "disabled")
selected = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get()))
text = f"{selected} regla{'s' if selected != 1 else ''}"
color = self.colors["success"] if selected else self.colors["gray_light"]
+123 -16
View File
@@ -1,7 +1,14 @@
"""Pestaña de Configuración de la aplicación GUI."""
import customtkinter as ctk
from gui.styles import AppStyles
from application.app_prefs import get_allowed_document_types, normalize_document_types
from application.app_prefs import (
format_moderadora_text_from_prefs,
get_allowed_document_types,
load_cop_mod_preferences,
normalize_document_types,
parse_moderadora_text,
default_moderadora_by_year_payload,
)
class SettingsTab:
@@ -22,16 +29,25 @@ class SettingsTab:
self._create_api_section()
self._create_processing_section()
self._create_document_types_section()
self._create_cop_mod_section()
self._create_save_button()
def _setup_layout(self):
"""Configura el layout base de la pestaña."""
"""Configura el layout: área con scroll y barra inferior fija con Guardar."""
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(0, weight=1)
self.settings_container = ctk.CTkFrame(self.tab, fg_color="transparent")
self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=20)
self.tab.grid_rowconfigure(1, weight=0)
self.settings_container = ctk.CTkScrollableFrame(
self.tab,
fg_color="transparent",
)
self.settings_container.grid(row=0, column=0, sticky="nsew", padx=20, pady=(20, 8))
self.settings_container.grid_columnconfigure(0, weight=1)
self.save_footer = ctk.CTkFrame(self.tab, fg_color="transparent")
self.save_footer.grid(row=1, column=0, sticky="ew", padx=20, pady=(4, 16))
self.save_footer.grid_columnconfigure(0, weight=1)
def _create_api_section(self):
"""Crea la sección de configuración del modelo LLM."""
@@ -50,25 +66,32 @@ class SettingsTab:
# Selector de modelo
lbl_model = ctk.CTkLabel(
self.frame_api,
text="Modelo (Ollama):",
text="Modelo (extracción):",
font=self.fonts["ui"]
)
lbl_model.grid(row=1, column=0, padx=15, pady=10, sticky="w")
self.combo_model = ctk.CTkComboBox(
self.frame_api,
values=["5.4", "5.4-mini", "5.4-nano", "5.2", "4.1-mini"],
values=[
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-4.1-mini",
"grok-4.3",
],
width=250,
font=self.fonts["ui"]
)
self.combo_model.grid(row=1, column=1, padx=15, pady=10, sticky="w")
self.combo_model.set("5.4")
self.combo_model.set("gpt-5.4")
# Texto de ayuda
help_text = (
"ℹ 5.4: Modelo predeterminado\n"
"ℹ 5.4-mini / 4.1-mini: opciones más rápidas\n"
"ℹ 5.4-nano: opción más económica"
"ℹ gpt-5.4: Modelo predeterminado (OpenAI / compatible)\n"
"ℹ gpt-5.4-mini / gpt-4.1-mini: opciones más rápidas\n"
"ℹ gpt-5.4-nano: opción más económica\n"
"ℹ grok-4.3: API xAI (requiere GROK_API_KEY en .env)"
)
lbl_help = ctk.CTkLabel(
self.frame_api,
@@ -151,16 +174,84 @@ class SettingsTab:
)
self.allowed_doc_types_entry.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 10))
self.set_allowed_document_types(list(get_allowed_document_types()))
def _create_cop_mod_section(self):
"""Cuotas moderadora por año (regla Separa COP/MOD)."""
self.frame_cop_mod = ctk.CTkFrame(self.settings_container)
self.frame_cop_mod.grid(row=3, column=0, sticky="ew", padx=5, pady=5)
self.frame_cop_mod.grid_columnconfigure(0, weight=1)
lbl_title = ctk.CTkLabel(
self.frame_cop_mod,
text="Cuota moderadora (COP/MOD)",
font=self.fonts["section"],
)
lbl_title.grid(row=0, column=0, sticky="w", padx=15, pady=(10, 4))
lbl_help = ctk.CTkLabel(
self.frame_cop_mod,
text=(
"Una línea por año: 2024: 4500, 18200, 47700. "
"La columna «Fecha Servicio» se elige en Proceso de Limpieza al activar la regla."
),
font=self.fonts["italic_small"],
text_color=self.colors["gray_light"],
justify="left",
)
lbl_help.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 6))
merged, fecha_def = load_cop_mod_preferences()
initial_txt = format_moderadora_text_from_prefs(merged)
self.moderadora_text = ctk.CTkTextbox(
self.frame_cop_mod,
font=self.fonts["mono_small"],
height=140,
)
self.moderadora_text.grid(row=2, column=0, sticky="ew", padx=15, pady=(0, 8))
self.moderadora_text.insert("1.0", initial_txt)
row_fe = ctk.CTkFrame(self.frame_cop_mod, fg_color="transparent")
row_fe.grid(row=3, column=0, sticky="ew", padx=15, pady=(0, 6))
row_fe.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
row_fe,
text="Sugerencia nombre columna fecha (opcional):",
font=self.fonts["ui"],
).grid(row=0, column=0, padx=(0, 10), sticky="w")
self.fecha_column_default_var = ctk.StringVar(value=fecha_def)
self.fecha_column_default_entry = ctk.CTkEntry(
row_fe,
textvariable=self.fecha_column_default_var,
font=self.fonts["ui"],
placeholder_text="Fecha Servicio",
)
self.fecha_column_default_entry.grid(row=0, column=1, sticky="ew")
self.btn_moderadora_defaults = ctk.CTkButton(
self.frame_cop_mod,
text="Restaurar valores por defecto",
font=self.fonts["ui"],
command=self._on_restore_moderadora_defaults,
)
self.btn_moderadora_defaults.grid(row=4, column=0, sticky="w", padx=15, pady=(0, 12))
def _on_restore_moderadora_defaults(self) -> None:
txt = format_moderadora_text_from_prefs(default_moderadora_by_year_payload())
self.moderadora_text.delete("1.0", "end")
self.moderadora_text.insert("1.0", txt)
def _create_save_button(self):
"""Crea el botón de guardar configuración."""
"""Barra inferior fija: siempre visible aunque el formulario sea largo."""
self.btn_save_settings = ctk.CTkButton(
self.settings_container,
text="Aplicar Configuración",
self.save_footer,
text="Guardar cambios",
font=self.fonts["ui"],
height=35
height=38,
)
self.btn_save_settings.grid(row=3, column=0, padx=15, pady=15, sticky="ew")
self.btn_save_settings.grid(row=0, column=0, sticky="ew")
# --- API Pública ---
@@ -197,6 +288,19 @@ class SettingsTab:
"""Establece la lista de tipos documentales válidos."""
self.allowed_doc_types_entry.delete(0, "end")
self.allowed_doc_types_entry.insert(0, ", ".join(normalize_document_types(values)))
def get_moderadora_cuotas_text(self) -> str:
"""Texto multilínea año: valores."""
return self.moderadora_text.get("1.0", "end").strip()
def set_moderadora_cuotas_ui(self, payload: dict[str, list[int]], fecha_hint: str) -> None:
"""Establece cuotas y sugerencia de columna fecha desde prefs."""
self.moderadora_text.delete("1.0", "end")
self.moderadora_text.insert("1.0", format_moderadora_text_from_prefs(payload))
self.fecha_column_default_var.set(fecha_hint or "")
def get_fecha_servicio_column_hint(self) -> str:
return str(self.fecha_column_default_var.get() or "").strip()
def enable_controls(self, enabled: bool):
"""Habilita o deshabilita los controles."""
@@ -204,4 +308,7 @@ class SettingsTab:
self.combo_model.configure(state=state)
self.concurrency_entry.configure(state=state)
self.allowed_doc_types_entry.configure(state=state)
self.moderadora_text.configure(state=state)
self.fecha_column_default_entry.configure(state=state)
self.btn_moderadora_defaults.configure(state=state)
self.btn_save_settings.configure(state=state)
+12
View File
@@ -119,6 +119,15 @@ class InvoiceAppView(ctk.CTk):
def set_allowed_document_types(self, values: list[str]) -> None:
self.settings_tab.set_allowed_document_types(values)
def get_moderadora_cuotas_text(self) -> str:
return self.settings_tab.get_moderadora_cuotas_text()
def set_moderadora_cuotas_ui(self, payload: dict, fecha_hint: str) -> None:
self.settings_tab.set_moderadora_cuotas_ui(payload, fecha_hint)
def get_fecha_servicio_column_hint(self) -> str:
return self.settings_tab.get_fecha_servicio_column_hint()
def set_on_run_preclassification(self, callback):
self.preclass_tab.set_on_run(callback)
@@ -387,6 +396,9 @@ class InvoiceAppView(ctk.CTk):
def get_cleaning_selected_rules(self) -> dict[str, list[str]]:
return self.cleaning_tab.get_selected_rules()
def get_cleaning_cop_mod_fecha_columns(self) -> dict[str, str]:
return self.cleaning_tab.get_cop_mod_fecha_columns()
def set_cleaning_result(self, df, info_text: str = ""):
self.cleaning_tab.set_result(df, info_text)
+114
View File
@@ -209,6 +209,15 @@ def test_apply_rules_keep_date_seps_preserves_time_then_normalizes() -> None:
assert result["fecha"].tolist() == ["30/04/2024"]
def test_apply_rules_us_style_date_comma_then_am_pm_strips_time() -> None:
"""Formato tipo Excel/locale US: mes/día/año, coma y hora — solo fecha DD/MM/AAAA."""
df = pd.DataFrame({"fecha": ["12/27/24, 12:00 AM"]})
result, _log = apply_rules(df, {"fecha": ["date_to_ddmmyyyy"]})
assert result["fecha"].tolist() == ["27/12/2024"]
def test_apply_rules_unified_date_rule_noise_and_unparseable_values() -> None:
df = pd.DataFrame(
{
@@ -231,3 +240,108 @@ def test_apply_rules_unified_date_rule_noise_and_unparseable_values() -> None:
"no es fecha",
"31-febrero-2026",
]
def test_apply_rules_cop_mod_moderadora_matches_year() -> None:
df = pd.DataFrame(
{"Fecha Servicio": ["01/01/2024"], "Importe": [4500]},
)
result, _log = apply_rules(
df,
{"Importe": ["cop_mod_split"]},
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
)
assert result["Importe_moderadora"].tolist() == [4500]
assert result["Importe_copago"].tolist() == [0]
def test_apply_rules_cop_mod_non_moderadora_to_copago() -> None:
df = pd.DataFrame(
{"Fecha Servicio": ["01/01/2024"], "Importe": [4982]},
)
result, _log = apply_rules(
df,
{"Importe": ["cop_mod_split"]},
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
)
assert result["Importe_copago"].tolist() == [4982]
assert result["Importe_moderadora"].tolist() == [0]
def test_apply_rules_cop_mod_two_phase_dirty_date() -> None:
df = pd.DataFrame(
{"Fecha Servicio": ["15/jun/2024"], "Importe": [4500]},
)
result, _log = apply_rules(
df,
{
"Fecha Servicio": ["date_to_ddmmyyyy"],
"Importe": ["cop_mod_split"],
},
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
cop_mod_config={"moderadora_by_year": {"2024": [4500, 18200, 47700]}},
)
assert result["Fecha Servicio"].iloc[0] == "15/06/2024"
assert result["Importe_moderadora"].iloc[0] == 4500
def test_apply_rules_cop_mod_year_not_in_table_goes_copago() -> None:
df = pd.DataFrame(
{"Fecha Servicio": ["01/01/2030"], "Importe": [4500]},
)
result, _log = apply_rules(
df,
{"Importe": ["cop_mod_split"]},
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
cop_mod_config={"moderadora_by_year": {"2024": [4500]}},
)
assert result["Importe_copago"].tolist() == [4500]
assert result["Importe_moderadora"].tolist() == [0]
def test_apply_rules_cop_mod_null_amount_zeros() -> None:
df = pd.DataFrame({"Fecha Servicio": ["01/01/2024"], "Importe": [pd.NA]})
result, _log = apply_rules(
df,
{"Importe": ["cop_mod_split"]},
cop_mod_fecha_column={"Importe": "Fecha Servicio"},
cop_mod_config={"moderadora_by_year": {"2024": [4500]}},
)
assert result["Importe_copago"].tolist() == [0]
assert result["Importe_moderadora"].tolist() == [0]
def test_apply_rules_nota_extract_one_line_sin_autorizacion() -> None:
texto = (
"Las retenciones son a nivel informativo no afecta el total a pagar. "
"Paciente: 1063146603 - VILLA BARRIOS NORMA JUDITH Atención No: 63728; "
"Contrato No. URGENCIAS. Valor en Letras: CIENTO OCHENTA Y SIETE MIL "
"QUINIENTOS TREINTA Y SIETE PESOS Fecha ingreso 19/11/2023 fecha egreso 19/11/2023"
)
df = pd.DataFrame({"notas": [texto]})
result, log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
assert result["notas_nota_paciente_doc"].iloc[0] == "1063146603"
assert pd.isna(result["notas_nota_autorizacion"].iloc[0])
assert any("Extraer datos de notas" in line for line in log)
def test_apply_rules_nota_extract_multiline_con_autorizacion() -> None:
texto = (
"Las retenciones son a nivel informativo no afecta el total a pagar.\n"
"Paciente: 30662603 - LLORENTE HERNANDEZ CARMEN LUCIA Atención No: 69426;\n"
"Autorización No. 227490546; Contrato No. 118. Valor en Letras: CIENTO NOVENTA Y OCHO\n"
"MIL SEISCIENTOS DIEZ PESOS Fecha ingreso 23/01/2024 fecha egreso 23/01/2024"
)
df = pd.DataFrame({"notas": [texto]})
result, _log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
assert result["notas_nota_paciente_doc"].iloc[0] == "30662603"
assert result["notas_nota_autorizacion"].iloc[0] == "227490546"
def test_apply_rules_nota_extract_sin_patrones() -> None:
df = pd.DataFrame({"notas": ["sin datos relevantes aquí"]})
result, _log = apply_rules(df, {"notas": ["nota_paciente_autorizacion"]})
assert pd.isna(result["notas_nota_paciente_doc"].iloc[0])
assert pd.isna(result["notas_nota_autorizacion"].iloc[0])
+1 -1
View File
@@ -47,6 +47,6 @@ def test_append_rows_one_physical_line_per_record() -> None:
{"radicado": "2", "descripcion": "ok"},
]
mgr.append_rows(str(path), rows, header)
text = path.read_text(encoding="utf-8")
text = path.read_text(encoding="utf-8-sig")
physical_lines = text.splitlines()
assert len(physical_lines) == 3
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from core.csv_utils import merge_verbatim_long_strings, set_at_path
def test_merge_replaces_truncated_string_same_path() -> None:
raw = {
"Notas": (
"Las retenciones son informativas. Paciente: 1 - NOMBRE "
"Contrato No. X. Valor en Letras: MIL PESOS Fecha ingreso 01/01/2024"
),
}
norm = {"Notas": "Las retenciones son informativas. Paciente: 1 - NOMBRE "}
merge_verbatim_long_strings(raw, norm, ["Notas"])
assert norm["Notas"] == raw["Notas"]
def test_merge_nested_path() -> None:
raw = {"cabecera": {"Notas": "A" * 200}}
norm = {"cabecera": {"Notas": "A" * 40}}
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
assert norm["cabecera"]["Notas"] == "A" * 200
def test_merge_resolves_by_unique_last_segment() -> None:
raw = {"Notas": "texto completo desde raíz cuando el normalizado usa cabecera/Notas"}
norm = {"cabecera": {"Notas": "texto completo"}}
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
assert norm["cabecera"]["Notas"] == raw["Notas"]
def test_merge_skips_ambiguous_same_last_segment() -> None:
raw = {
"a": {"Notas": "primera nota " + "x" * 50},
"b": {"Notas": "segunda nota " + "y" * 50},
}
norm = {"cabecera": {"Notas": "primera nota"}}
merge_verbatim_long_strings(raw, norm, ["cabecera/Notas"])
assert norm["cabecera"]["Notas"] == "primera nota"
def test_merge_skips_when_normalized_not_prefix_of_raw() -> None:
raw = {"Notas": "completamente distinto " + "z" * 80}
norm = {"Notas": "otro texto corto"}
merge_verbatim_long_strings(raw, norm, ["Notas"])
assert norm["Notas"] == "otro texto corto"
def test_merge_skips_paths_with_wildcard() -> None:
raw = {"items": [{"x": "long " * 20}]}
norm = {"items": [{"x": "long "}]}
merge_verbatim_long_strings(raw, norm, ["items/*/x"])
assert norm["items"][0]["x"] == "long "
def test_set_at_path_creates_intermediates() -> None:
root: dict = {}
set_at_path(root, "a/b/c", 1)
assert root == {"a": {"b": {"c": 1}}}