# Patrones avanzados — Python

Recurso de profundidad cargado bajo demanda desde `SKILL.md`. Contiene 7 patrones
que aparecen al integrar Python con pipelines reales: conversores de documentos,
clientes heterogéneos, cachés determinísticos, soft imports, detectores de
texto y duplicación deliberada de lógica.

## Índice

1. [Normalizadores: colapsar al formato canónico del proyecto](#normalizadores)
2. [kwargs opcionales entre clientes hermanos: try/except TypeError](#kwargs-opcionales)
3. [Caché content-addressable por SHA256](#cache-sha256)
4. [F401 en archivos con soft imports es intencional](#f401-soft-imports)
5. [Detectores regex multi-pattern: extender scope sin refinar](#regex-multi-pattern)
6. [Tracer/replicador paralelo del motor: marca SYNC obligatoria](#tracer-sync)
7. [Fixtures con datos pre-procesados NO ejercen el path crudo](#fixtures-crudos)

---

<a id="normalizadores"></a>
## 1. Normalizadores: colapsar al formato canónico del PROYECTO, no al estándar genérico

### SIEMPRE: el normalizador debe conocer la convención del proyecto

**Cuándo aplicar**: cuando un módulo externo (MarkItDown, Pandoc, mammoth, pdfminer) produce artefactos de conversión y hay que limpiarlos antes del pipeline interno.

**Problema**: es tentador colapsar los artefactos al formato "más estándar" según la spec de CommonMark / JSON / el estándar de turno. Pero si el proyecto ya tiene una convención distinta (ej. puntuación FUERA del bold en vez de dentro), el normalizador debe colapsar a ESA convención, no al genérico.

**Regla**: antes de escribir un normalizador, verificar la convención real del proyecto en docs internos o en un fixture "canónico" existente. Si la convención del proyecto contradice la intuición genérica, registrar en el docstring del normalizador POR QUÉ se eligió esa dirección.

```python
# MAL — colapsa al estándar genérico sin verificar la convención del proyecto
def _normalizar(texto: str) -> str:
    # "Mover puntuación al interior del bold" (parece "más correcto")
    return re.sub(r"\*\*([^*]+?)\*\*([:;.,])", r"**\1\2**", texto)
# Resultado: `**ACTIVIDAD**:` → `**ACTIVIDAD:**`  ← pero el proyecto usa el primero

# BIEN — conoce la convención: "puntuación terminal FUERA del bold"
def _normalizar(texto: str) -> str:
    """Normaliza a la convención del proyecto: puntuación fuera del bold.

    CommonMark genérico prefiere mover la puntuación al interior del bold,
    pero algunos proyectos adoptan la convención opuesta para preservar
    la semántica visual del documento de origen (DOCX, PDF). Cubrir el
    caso con un test de ground truth antes de modificar el regex.
    """
    # Artefacto `**ACTIVIDAD****:**` → `**ACTIVIDAD**:` (puntuación fuera)
    return re.sub(
        r"\*\*([^\n*]+?)\*{2,}([:;,.!?\-—)])\*{2,}",
        r"**\1**\2",
        texto,
    )
```

**Verificación previa obligatoria**: en una PR que introduzca un normalizador, agregar un test con la convención canónica del proyecto como ground truth. Si no existe fixture, preguntarle al autor del proyecto antes de inventar la dirección.

---

<a id="kwargs-opcionales"></a>
## 2. kwargs opcionales entre clientes hermanos: try/except TypeError

### Patrón: propagar kwargs a implementaciones heterogéneas con degradación silenciosa

**Cuándo aplicar**: cuando dos o más clientes implementan la misma interfaz de alto nivel (ej. `OllamaClient.generate()` y `NIMClient.generate()`) pero uno acepta un kwarg nuevo y el otro aún no. Migración incremental sin versionar la interfaz.

**Problema**: si el wrapper de alto nivel pasa siempre el kwarg, falla con `TypeError` en el cliente que aún no lo acepta. Si el wrapper nunca lo pasa, los clientes que sí lo aceptan pierden la funcionalidad.

**Solución idiomática**: crear la coroutine (o call sync) dentro de `try`; capturar `TypeError` solo cuando ocurre al CONSTRUIR la llamada (signature mismatch); en el `except` re-llamar sin el kwarg.

```python
# BIEN — degradación silenciosa compatible con clientes heterogéneos
async def llamar_con_timeout_opcional(cliente, modelo, prompt, timeout_s):
    try:
        # Cliente moderno (acepta `timeout` kwarg)
        call = cliente.generate(model=modelo, prompt=prompt, timeout=timeout_s)
    except TypeError:
        # Cliente legacy (no acepta `timeout`); fallback silencioso
        call = cliente.generate(model=modelo, prompt=prompt)
    return await asyncio.wait_for(call, timeout=timeout_s)
```

**Detalle importante para async**: en `async def f(**kwargs)` los kwargs se aceptan siempre. El `TypeError` solo ocurre con signature fija (`async def f(model, prompt, ...)`) y se lanza en la creación de la coroutine, ANTES del `await`. Probar con mocks de signature fija (no `MagicMock(**kwargs)`).

**Aplicabilidad**: migraciones de librerías incrementales, plugins con versiones heterogéneas, strategy pattern donde las implementaciones evolucionan a distinto ritmo.

---

<a id="cache-sha256"></a>
## 3. Caché content-addressable por SHA256 para llamadas determinísticas costosas

### Patrón

Cuando una función pura (mismo input → mismo output) es costosa (>1 s, llamada
externa, IO pesado) y se invoca repetidamente con la misma entrada (pipelines
de ingesta, suites E2E, recompilación incremental), cachear el resultado por
el **SHA256 del contenido** del input convierte re-ejecuciones en operaciones
prácticamente gratuitas y hace el pipeline resumible.

### Por qué SHA256 del contenido y no `(path, mtime, size)`

- `mtime` se rompe al copiar archivos entre sistemas (rsync, git checkout).
- `size` colisiona trivialmente con archivos distintos del mismo tamaño.
- `path` cambia al reorganizar el dataset.

El SHA256 del **contenido** identifica la entrada de forma reproducible aunque
se mueva, renombre o clone.

### Implementación canónica

```python
import hashlib
import os
from pathlib import Path


def _flag_off(env_var: str) -> bool:
    """True si la env var está en {1, true, yes, on} (case-insensitive)."""
    return os.environ.get(env_var, "").lower() in {"1", "true", "yes", "on"}


def sha256_bytes(contenido: bytes) -> str:
    return hashlib.sha256(contenido).hexdigest()


def sha256_archivo(ruta: Path) -> str:
    """SHA256 del contenido completo en bloques de 64KB."""
    h = hashlib.sha256()
    with ruta.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def procesar_con_cache(
    contenido: bytes,
    dir_cache: Path,
    procesar,
    *,
    bypass_env: str = "CACHE_OFF",
    dimension: str | None = None,
) -> str:
    """Ejecuta `procesar(contenido)` solo si el resultado no está en caché.

    Layout en disco con sharding de 2 chars: `dir_cache/[dim/]ab/abcdef...txt`.
    El sharding evita carpetas con 10k+ archivos en NTFS/ext4.
    """
    sha = sha256_bytes(contenido)
    base = dir_cache if dimension is None else dir_cache / dimension
    ruta_cache = base / sha[:2] / f"{sha}.txt"

    if ruta_cache.exists() and ruta_cache.stat().st_size > 0 and not _flag_off(bypass_env):
        return ruta_cache.read_text(encoding="utf-8")

    # Recalcular (operación costosa: OCR, LLM, transcripción, build, etc.)
    resultado = procesar(contenido)

    # Escritura atómica: temp + rename para evitar entradas parciales por kill
    ruta_cache.parent.mkdir(parents=True, exist_ok=True)
    tmp = ruta_cache.with_suffix(".tmp")
    tmp.write_text(resultado, encoding="utf-8")
    tmp.rename(ruta_cache)

    return resultado
```

### Reglas de diseño

- **Sharding de 2 chars** (`{sha[:2]}/{sha}.txt`) evita degradación del filesystem
  cuando el caché crece a decenas de miles de entradas (NTFS, ext4, APFS).
- **Un directorio por tipo de operación**: `cache/ocr/`, `cache/vision/`,
  `cache/transcripcion/`. Mezclar tipos hace imposible invalidar uno solo.
- **Escritura atómica obligatoria**: `tmp → rename` previene que un kill del proceso
  deje entradas parciales que se interpretan como éxito en la próxima corrida.
- **Verificar tamaño > 0 al leer**: archivos de 0 bytes son ejecuciones abortadas,
  no "resultado vacío válido".
- **Bypass por variable de entorno** (`CACHE_OFF=1` aceptando `1/true/yes/on`):
  crítico para tests que validan el procesador real, no el caché. Sin bypass,
  imposible reproducir bugs del extractor real durante pruebas.
- **Key multi-dimensión cuando aplica**: si la función es determinística sobre
  `(input, modelo)` —caso típico de síntesis LLM con distinto modelo— usar el
  parámetro `dimension` para particionar el caché:
  `cache/sintesis/{modelo_safe}/{sha[:2]}/{sha}.txt`. Sin esto, cambiar de modelo
  devuelve resultados del modelo viejo con semántica nueva.
- **Versionar el caché cuando cambia el procesamiento**: si actualizas el modelo de
  OCR o la versión del prompt LLM, bumpear `dimension` (`v1` → `v2`) o invalidar
  el directorio completo.
- **No cachear errores**: si `procesar()` lanza excepción, el caché queda vacío
  y la siguiente corrida reintenta. Cachear el error convierte errores transitorios
  en permanentes.
- **Idempotencia natural**: la escritura siempre puede sobreescribir porque el SHA
  garantiza mismo contenido por construcción. No requiere lock entre procesos.

### Tests obligatorios

Toda implementación de caché content-addressable debe cubrir:

1. **Roundtrip write→read**: invocar dos veces con mismo input verifica que
   la segunda lectura no llama al procesador (mock con counter).
2. **Bypass por flag**: con `CACHE_OFF=1` siempre llama al procesador aunque exista entrada.
3. **Keys distintas para inputs distintos**: dos inputs con SHA distinto no se pisan.
4. **Fixture aislado**: `monkeypatch.setenv("CACHE_DIR", str(tmp_path))` evita
   contaminación entre tests.
5. **Atomicidad**: simular kill durante escritura (interrupción del `tmp.write_text`)
   y verificar que la siguiente corrida no lee un archivo parcial.

### Aplicabilidad

- Pipelines de ingesta de documentos (PDF → texto, imagen → OCR, audio → transcripción).
- Extracción con LLM costoso (vision, clasificación, extracción estructurada).
- Suites E2E donde el dataset es fijo y se ejecutan repetidamente —típicamente
  reduce el tiempo de la corrida en 70-90% si la función cacheada domina.
- Compilaciones incrementales (aunque los build systems ya tienen este patrón).
- Deduplicación en walkers resumables (ver `testing-python`: "tests de
  idempotencia requieren 2 ejecuciones + diff").

---

<a id="f401-soft-imports"></a>
## 4. F401 en archivos con soft imports es intencional, no ruido

### Contexto

`ruff --select=F401` (y `flake8 --select=F401`) reportan "imported but unused"
cuando un módulo se importa pero no se usa. En archivos con **soft imports**
(intentar importar una dependencia opcional dentro de `try/except ImportError`)
el import **debe quedarse** aunque lint lo marque como no usado — es parte del
patrón de detección de disponibilidad.

### Patrón de soft import canónico

```python
# El módulo puede funcionar con o sin la dependencia opcional.
# La presencia del símbolo habilita un code path; su ausencia cambia al fallback.

try:
    from markitdown import MarkItDown  # noqa: F401 — soft import
    _MARKITDOWN_DISPONIBLE = True
except ImportError:
    _MARKITDOWN_DISPONIBLE = False


def extraer_texto(ruta: Path) -> str:
    if _MARKITDOWN_DISPONIBLE:
        from markitdown import MarkItDown   # re-import local, ya sabemos que existe
        return MarkItDown().convert(str(ruta)).text_content
    # Fallback: usar parser básico
    return _extraer_con_parser_basico(ruta)
```

### Regla

- Agregar `# noqa: F401` en la línea del import top-level dentro del `try`.
- Alternativa: configurar `ruff.toml` / `pyproject.toml` con `per-file-ignores` para los archivos de soft import si son muchos:
  ```toml
  [tool.ruff.per-file-ignores]
  "core/adapters/*.py" = ["F401"]  # todos los adapters usan soft imports
  ```
- NO importar dentro del `try` sin usar: usar el símbolo (`MarkItDown`) como sonda de disponibilidad es el patrón correcto; el warning F401 es el ruido.
- NO reemplazar con `importlib.util.find_spec("markitdown")` salvo que la librería tenga side effects al importar — `find_spec` no valida que la versión instalada exponga los símbolos esperados.

### Anti-patrón

```python
# MAL — el F401 se "soluciona" pero la detección se rompe
try:
    import markitdown  # noqa — "no se usa, pero quiero saber si está"
    _DISPONIBLE = True
except ImportError:
    _DISPONIBLE = False
```

El problema: si `markitdown` se instala pero la API cambió (el símbolo
`MarkItDown` ya no existe), el import sigue pasando y `_DISPONIBLE = True`
pero el code path fallará más tarde al usar el símbolo ausente. Importar
el símbolo específico que vas a usar es más robusto que importar el módulo
y esperar que todo lo demás funcione.

---

<a id="regex-multi-pattern"></a>
## 5. Detectores regex multi-pattern: extender scope sin refinar genera falsos positivos

### NUNCA: agregar un nuevo input a un detector multi-pattern sin re-evaluar la sensibilidad de cada pattern individual

**Problema**: tienes una función `detectar(texto)` que aplica una lista de regex
con OR (`any(p.search(texto) for p in PATTERNS)`). Originalmente operaba sobre
texto curado (título, etiquetas, campos cortos). Ahora extiendes el scope a un
texto más ruidoso (prosa larga, descripciones libres, contenido de usuario).
Patterns genéricos que funcionaban en el texto curado empiezan a generar falsos
positivos en el ruidoso.

```python
# Caso típico: detector de "enumeración múltiple de items relacionados"
PATTERNS = [
    re.compile(r"(?:,\s+\w[\w\s]{3,45}){3,}"),       # P1 generico: 3+ comas
    re.compile(r"(?:^|\n)\s*[•\-]\s+.{10,}", re.M),  # P2 generico: bullets
    re.compile(r"(?:anexos|evidencias|listas){2,}", re.I),  # P3 especifico
]

# MAL — extender scope sin discriminar patterns
def enumera_multiples(registro):
    texto = " ".join([
        registro.titulo, registro.resumen,    # texto curado
        registro.descripcion_libre,            # texto ruidoso (prosa larga)
    ])
    return any(p.search(texto) for p in PATTERNS)
# → P1 (3+ comas) matchea series de citas o referencias en prosa larga
#   ("art. 5, art. 10, art. 23, art. 138 y art. 141") → falso positivo
#   regresión típica observada: baja precisión sobre el dataset golden.

# BIEN — patterns por scope, refinados según ruido tolerado
def enumera_multiples(registro):
    texto_curado = " ".join([registro.titulo, registro.resumen])
    if any(p.search(texto_curado) for p in PATTERNS):
        return True
    # Sobre texto ruidoso, solo el pattern especifico (P3)
    texto_ruidoso = " ".join([registro.descripcion_libre, registro.notas_libres])
    return PATTERNS[2].search(texto_ruidoso) is not None
```

### Regla operativa

Cuando se extiende un detector regex a un nuevo scope textual, agregar un test de
regresión específico que verifique que NO se introducen falsos positivos sobre
muestras del nuevo scope que NO deberían matchear. Si los patterns genéricos
hacen FP sobre el scope nuevo, **discriminarlos por scope** (no aplicarlos al
scope ruidoso) en lugar de intentar refinarlos en una sola lista global.

**Aplicabilidad**: clasificadores de texto, detectores de PII, filtros de spam,
sistemas de moderación, extracción de entidades cuando el scope crece de campos
estructurados a contenido libre.

---

<a id="tracer-sync"></a>
## 6. Tracer/replicador paralelo del motor: marca SYNC obligatoria en cada cambio

### Patrón: cuando duplicas lógica del original, marca el SYNC y agrega test de paridad

**Problema**: en sistemas con motores complejos (cascadas de reglas, clasificadores
con prioridad, motores de decisión), suele crearse un "tracer" que replica la
lógica paso a paso para producir telemetría, explicación o shadow runs. El tracer
es **duplicación** del motor — si cambias el motor sin tocar el tracer, los
outputs divergen silenciosamente y los tests del tracer pasan con datos viejos
mientras el motor real ya cambió.

```python
# Motor real (motor_decision.py)
def resolver_resultado(self, registro):
    resultado = self._resolver_core(registro)
    # Ajuste 1: suavización por condición A
    if cond_a(registro): return "ACEPTADO"
    # Ajustes 2-4: endurecimientos
    if cond_b(registro): return "RECHAZADO"
    # Ajuste 5 (NUEVO): endurecimiento por condición compuesta
    if resultado == "ACEPTADO" and registro._compuesta:
        return "RECHAZADO"
    return resultado

# Tracer paralelo (motor_decision_tracer.py)
def replicar_resultado(...):
    # SYNC con: motor_decision.py::resolver_resultado
    # Si agregas un Ajuste, AGREGARLO TAMBIÉN aquí en el orden correcto.
    if cond_a(registro): ...
    if cond_b(registro): ...
    # Olvidé sincronizar Ajuste 5 aquí → tracer reporta ACEPTADO
    # pero motor real reporta RECHAZADO → tests ground truth fallan
    # silenciosamente porque el tracer es lo que se compara.
```

### Reglas obligatorias para código duplicado deliberado

1. **Marca de SYNC**: comentario `# SYNC con: <archivo>:<función>` visible en la
   cabecera del replicador. La búsqueda `grep -rn "SYNC con:"` lista todos los
   puntos de duplicación del repositorio en una corrida.
2. **Test de paridad**: para casos representativos del dataset golden, comparar
   `motor.resolver(x) == tracer.resolver(x)` y fallar si difieren. Es el único
   gate que detecta la divergencia sin esperar a producción.
3. **Convención de naming**: el archivo que duplica la lógica se nombra explícitamente
   como derivado (`_tracer.py`, `_replicador.py`, `_explainer.py`, `_shadow.py`).
   NUNCA esconderlo bajo nombre genérico — el nombre es la primera línea de defensa
   contra que alguien lo edite sin saber que es duplicación.
4. **Owner único**: el motor y su tracer cambian en el mismo PR. Code review rechaza
   PRs que tocan el motor sin tocar el tracer (o que justifiquen explícitamente
   por qué la divergencia es intencional, ej. "el tracer no necesita el ajuste de
   performance").

**Aplicabilidad**: sistemas con explainability, dual-track production+shadow,
motores de reglas con logging detallado, A/B testing de algoritmos, migración
incremental de un motor legacy a uno nuevo donde ambos corren en paralelo.

---

<a id="fixtures-crudos"></a>
## 7. Fixtures de test con datos pre-procesados NO ejercen el path de datos crudos

### Anti-patrón: fixtures construidos a mano que reflejan la salida del extractor, no su entrada

**Problema**: el sistema en producción procesa input crudo (PDFs, HTMLs, JSON
externos, mensajes raw) que pasa por extractores que producen un dict condensado.
Los fixtures de test se construyen "a mano" copiando lo que el extractor produce
(o aproximándolo). Resultado: cualquier path del motor que solo se activa con
campos del input crudo (presentes solo cuando hay extractor real arriba) NO se
ejercita por los tests, y los bugs aparecen únicamente en producción.

```python
# Fixture típico hecho a mano (apariencia inocente)
{
  "id": "REG-001",
  "registro": {
    "titulo": "Falta de evidencia documental",
    "descripcion_corta": "El operador manifiesta que el oficio acredita...",  # condensada
    # NOTAR: no hay `descripcion_original`
  }
}

# Motor con fix nuevo
def _clasificar_postura(registro):
    # Fix: prefiere texto crudo cuando está disponible
    texto = (
        registro.get("descripcion_original")
        or registro.get("descripcion_corta")
    )
    return any(p in texto.lower() for p in patrones_subsanadores)

# Test del fixture pasa "por casualidad" (descripcion_corta condensada
# casualmente contiene "se anexa") → falsa confianza.
# En producción, descripcion_original sería 5000 chars con verbos
# canónicos y descripcion_corta sería paráfrasis sin esos verbos.
# Los tests no ejercitan el path real.
```

### Defensa

Cuando se introduce un nuevo campo opcional que el motor prefiere leer
(`descripcion_original`, `metadata_completa`, `raw_input`, `body_unparsed`,
etc.), agregar **al menos un fixture** que tenga ese campo poblado con una
muestra representativa del input crudo real — idealmente extraída del caché de
un pipeline E2E ejecutado, no inventada a mano.

```python
# Mejorado: fixture con ambos campos, con datos representativos del scope crudo
{
  "registro": {
    "descripcion_corta": "El operador manifiesta...",  # condensada (lo que el extractor produce)
    "descripcion_original": (
        "OFICIO 12345/2026 — DEPARTAMENTO DE OPERACIONES ... "
        "se anexa el oficio mediante correo institucional ... "
        "[texto crudo extraído del PDF, 5000 chars con jerga canónica]"
    ),
  }
}
```

### Regla operativa

- **Dos niveles de fixtures**: condensados (rápidos, para lógica de negocio) y
  crudos (lentos, para paths que dependen del input real). Marcar cada fixture
  con su nivel: `nombre.condensado.json` vs `nombre.crudo.json`.
- **Snapshot del extractor real**: si el extractor es determinístico, ejecutarlo
  una vez sobre datos reales y persistir su output como fixture crudo. No inventarlo.
- **Test de "campo prioritario nuevo"**: cada vez que el motor agrega un campo
  con prioridad sobre uno existente, agregar un test que verifique el comportamiento
  con AMBOS campos poblados con valores **divergentes** (que producen resultados
  distintos). Si el test pasa con valores iguales, no prueba la priorización.

**Aplicabilidad**: pipelines de ingesta con extractores upstream, sistemas con
adaptadores que normalizan inputs heterogéneos, motores que prefieren campos
crudos sobre derivados, tests de regresión de migraciones de schema.
