@saulwade/swl-ses 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CLAUDE.md +4 -3
  2. package/README.md +15 -14
  3. package/agentes/nemesis-auditor-swl.md +161 -0
  4. package/bin/swl-mcp-server.js +187 -187
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/contribuir.md +233 -233
  7. package/comandos/swl/nemesis.md +122 -0
  8. package/comandos/swl/salud.md +34 -0
  9. package/comandos/swl/verificar.md +45 -0
  10. package/gateway/lib/event-channel.js +191 -191
  11. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  12. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  13. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  14. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  15. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  16. package/habilidades/eval-framework/SKILL.md +212 -212
  17. package/habilidades/feynman-auditor-swl/SKILL.md +123 -0
  18. package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -0
  19. package/habilidades/harness-claude-code/SKILL.md +299 -299
  20. package/habilidades/infra-github-actions/SKILL.md +166 -166
  21. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  22. package/habilidades/manejo-errores/.evolved.json +8 -8
  23. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  24. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  25. package/habilidades/patrones-python/SKILL.md +229 -229
  26. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  27. package/habilidades/planear-fase/SKILL.md +319 -319
  28. package/habilidades/release-semver/.evolved.json +8 -8
  29. package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -0
  30. package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -0
  31. package/habilidades/testing-python/SKILL.md +340 -340
  32. package/habilidades/web-fetcher-routing/SKILL.md +75 -0
  33. package/hooks/claudemd-bloat-detector.js +161 -161
  34. package/hooks/lib/agent-routing.js +107 -107
  35. package/hooks/lib/auto-consolidator.js +335 -335
  36. package/hooks/lib/error-classifier.js +308 -308
  37. package/hooks/lib/merkle-audit.js +96 -96
  38. package/hooks/lib/provenance-tracker.js +191 -191
  39. package/hooks/lib/rate-limit-tracker.js +253 -253
  40. package/hooks/lib/resource-quota.js +122 -122
  41. package/hooks/lib/retry-jitter.js +165 -165
  42. package/hooks/lib/security-net.js +201 -0
  43. package/hooks/lib/skill-auditor.js +588 -588
  44. package/hooks/lib/sync-status.js +228 -228
  45. package/hooks/lib/taint-tracker.js +107 -107
  46. package/hooks/lib/text-similarity.js +241 -241
  47. package/hooks/lib/toon-compressor.js +245 -245
  48. package/hooks/registro-turnos.js +209 -209
  49. package/hooks/sugerir-regenerar-inventario.js +170 -170
  50. package/hooks/validar-formato-post-subagente.js +140 -140
  51. package/hooks/validar-memoria-hook.js +218 -218
  52. package/instintos/prompt-appendices.yaml +57 -57
  53. package/manifiestos/agent-output-schemas.json +57 -57
  54. package/manifiestos/modulos.json +41 -6
  55. package/manifiestos/perfiles.json +2 -1
  56. package/manifiestos/skills-lock.json +30 -9
  57. package/package.json +2 -2
  58. package/plantillas/auditor-veto-template.md +105 -105
  59. package/plantillas/github-workflows/README.md +47 -47
  60. package/plantillas/github-workflows/release-please.yml +44 -44
  61. package/plantillas/github-workflows/swl-ci.yml +107 -107
  62. package/plantillas/github-workflows/swl-security.yml +51 -51
  63. package/plugin.json +10 -2
  64. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  65. package/reglas/arreglar-al-detectar.md +147 -147
  66. package/reglas/fragmentos-compartidos.md +152 -152
  67. package/reglas/harness-claude-code.md +213 -213
  68. package/reglas/usar-context7.md +226 -226
  69. package/schemas/diary-entry.schema.json +80 -80
  70. package/scripts/audit-tools/audit-history.js +330 -0
  71. package/scripts/audit-tools/bundle-tracker.js +290 -0
  72. package/scripts/audit-tools/canary-monitor.js +352 -0
  73. package/scripts/audit-tools/code-profiler.js +605 -0
  74. package/scripts/audit-tools/dep-doctor.js +320 -0
  75. package/scripts/audit-tools/env-validator.js +206 -0
  76. package/scripts/audit-tools/lib/fs-walk.js +48 -0
  77. package/scripts/audit-tools/lib/output.js +23 -0
  78. package/scripts/audit-tools/migration-checker.js +392 -0
  79. package/scripts/audit-tools/pentest-scanner.js +1436 -0
  80. package/scripts/benchmark-memoria.js +167 -167
  81. package/scripts/configurar-branch-protection.js +418 -418
  82. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  83. package/scripts/field-report.js +199 -199
  84. package/scripts/generar-checklists-consolidados.js +273 -273
  85. package/scripts/generar-inventario.js +420 -420
  86. package/scripts/generar-matriz-lenguajes.js +271 -271
  87. package/scripts/lib/artefactos-python.js +43 -43
  88. package/scripts/lib/benchmark-metrics.js +160 -160
  89. package/scripts/lib/budget-enforcer.js +252 -252
  90. package/scripts/lib/configurar-ci.js +380 -380
  91. package/scripts/lib/contadores-inventario.js +217 -217
  92. package/scripts/lib/detectar-stack-detallado.js +307 -307
  93. package/scripts/lib/diary-entry.js +234 -234
  94. package/scripts/lib/eval-metrics-store.js +218 -218
  95. package/scripts/lib/eval-quality.js +171 -171
  96. package/scripts/lib/eval-schemas.js +144 -144
  97. package/scripts/lib/eval-self-correct.js +106 -106
  98. package/scripts/lib/eval-validator.js +185 -185
  99. package/scripts/lib/jaccard-similarity.js +98 -98
  100. package/scripts/lib/longmemeval-runner.js +125 -125
  101. package/scripts/lib/manifiestos.js +42 -1
  102. package/scripts/lib/npm-version.js +261 -261
  103. package/scripts/lib/paquetes-conocidos.js +50 -50
  104. package/scripts/lib/prompt-builder.js +264 -264
  105. package/scripts/lib/rrf-fusion.js +175 -175
  106. package/scripts/lib/scoring-instintos.js +277 -277
  107. package/scripts/lib/semantic-search.js +252 -252
  108. package/scripts/limpiar-artefactos-python.js +131 -131
  109. package/scripts/mcp-server/README.md +128 -128
  110. package/scripts/mcp-server/handlers.js +206 -206
  111. package/scripts/migrar-csv-a-array.js +168 -168
  112. package/scripts/migrar-fase-dominio.js +201 -201
  113. package/scripts/publicar.js +511 -511
  114. package/scripts/run-eval.js +141 -141
  115. package/scripts/validar-manifest.js +231 -195
  116. package/scripts/validar-userland-vacio.js +110 -110
@@ -1,469 +1,469 @@
1
- # Patrones avanzados — Python
2
-
3
- Recurso de profundidad cargado bajo demanda desde `SKILL.md`. Contiene 7 patrones
4
- que aparecen al integrar Python con pipelines reales: conversores de documentos,
5
- clientes heterogéneos, cachés determinísticos, soft imports, detectores de
6
- texto y duplicación deliberada de lógica.
7
-
8
- ## Índice
9
-
10
- 1. [Normalizadores: colapsar al formato canónico del proyecto](#normalizadores)
11
- 2. [kwargs opcionales entre clientes hermanos: try/except TypeError](#kwargs-opcionales)
12
- 3. [Caché content-addressable por SHA256](#cache-sha256)
13
- 4. [F401 en archivos con soft imports es intencional](#f401-soft-imports)
14
- 5. [Detectores regex multi-pattern: extender scope sin refinar](#regex-multi-pattern)
15
- 6. [Tracer/replicador paralelo del motor: marca SYNC obligatoria](#tracer-sync)
16
- 7. [Fixtures con datos pre-procesados NO ejercen el path crudo](#fixtures-crudos)
17
-
18
- ---
19
-
20
- <a id="normalizadores"></a>
21
- ## 1. Normalizadores: colapsar al formato canónico del PROYECTO, no al estándar genérico
22
-
23
- ### SIEMPRE: el normalizador debe conocer la convención del proyecto
24
-
25
- **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.
26
-
27
- **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.
28
-
29
- **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.
30
-
31
- ```python
32
- # MAL — colapsa al estándar genérico sin verificar la convención del proyecto
33
- def _normalizar(texto: str) -> str:
34
- # "Mover puntuación al interior del bold" (parece "más correcto")
35
- return re.sub(r"\*\*([^*]+?)\*\*([:;.,])", r"**\1\2**", texto)
36
- # Resultado: `**ACTIVIDAD**:` → `**ACTIVIDAD:**` ← pero el proyecto usa el primero
37
-
38
- # BIEN — conoce la convención: "puntuación terminal FUERA del bold"
39
- def _normalizar(texto: str) -> str:
40
- """Normaliza a la convención del proyecto: puntuación fuera del bold.
41
-
42
- CommonMark genérico prefiere mover la puntuación al interior del bold,
43
- pero algunos proyectos adoptan la convención opuesta para preservar
44
- la semántica visual del documento de origen (DOCX, PDF). Cubrir el
45
- caso con un test de ground truth antes de modificar el regex.
46
- """
47
- # Artefacto `**ACTIVIDAD****:**` → `**ACTIVIDAD**:` (puntuación fuera)
48
- return re.sub(
49
- r"\*\*([^\n*]+?)\*{2,}([:;,.!?\-—)])\*{2,}",
50
- r"**\1**\2",
51
- texto,
52
- )
53
- ```
54
-
55
- **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.
56
-
57
- ---
58
-
59
- <a id="kwargs-opcionales"></a>
60
- ## 2. kwargs opcionales entre clientes hermanos: try/except TypeError
61
-
62
- ### Patrón: propagar kwargs a implementaciones heterogéneas con degradación silenciosa
63
-
64
- **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.
65
-
66
- **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.
67
-
68
- **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.
69
-
70
- ```python
71
- # BIEN — degradación silenciosa compatible con clientes heterogéneos
72
- async def llamar_con_timeout_opcional(cliente, modelo, prompt, timeout_s):
73
- try:
74
- # Cliente moderno (acepta `timeout` kwarg)
75
- call = cliente.generate(model=modelo, prompt=prompt, timeout=timeout_s)
76
- except TypeError:
77
- # Cliente legacy (no acepta `timeout`); fallback silencioso
78
- call = cliente.generate(model=modelo, prompt=prompt)
79
- return await asyncio.wait_for(call, timeout=timeout_s)
80
- ```
81
-
82
- **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)`).
83
-
84
- **Aplicabilidad**: migraciones de librerías incrementales, plugins con versiones heterogéneas, strategy pattern donde las implementaciones evolucionan a distinto ritmo.
85
-
86
- ---
87
-
88
- <a id="cache-sha256"></a>
89
- ## 3. Caché content-addressable por SHA256 para llamadas determinísticas costosas
90
-
91
- ### Patrón
92
-
93
- Cuando una función pura (mismo input → mismo output) es costosa (>1 s, llamada
94
- externa, IO pesado) y se invoca repetidamente con la misma entrada (pipelines
95
- de ingesta, suites E2E, recompilación incremental), cachear el resultado por
96
- el **SHA256 del contenido** del input convierte re-ejecuciones en operaciones
97
- prácticamente gratuitas y hace el pipeline resumible.
98
-
99
- ### Por qué SHA256 del contenido y no `(path, mtime, size)`
100
-
101
- - `mtime` se rompe al copiar archivos entre sistemas (rsync, git checkout).
102
- - `size` colisiona trivialmente con archivos distintos del mismo tamaño.
103
- - `path` cambia al reorganizar el dataset.
104
-
105
- El SHA256 del **contenido** identifica la entrada de forma reproducible aunque
106
- se mueva, renombre o clone.
107
-
108
- ### Implementación canónica
109
-
110
- ```python
111
- import hashlib
112
- import os
113
- from pathlib import Path
114
-
115
-
116
- def _flag_off(env_var: str) -> bool:
117
- """True si la env var está en {1, true, yes, on} (case-insensitive)."""
118
- return os.environ.get(env_var, "").lower() in {"1", "true", "yes", "on"}
119
-
120
-
121
- def sha256_bytes(contenido: bytes) -> str:
122
- return hashlib.sha256(contenido).hexdigest()
123
-
124
-
125
- def sha256_archivo(ruta: Path) -> str:
126
- """SHA256 del contenido completo en bloques de 64KB."""
127
- h = hashlib.sha256()
128
- with ruta.open("rb") as f:
129
- for chunk in iter(lambda: f.read(65536), b""):
130
- h.update(chunk)
131
- return h.hexdigest()
132
-
133
-
134
- def procesar_con_cache(
135
- contenido: bytes,
136
- dir_cache: Path,
137
- procesar,
138
- *,
139
- bypass_env: str = "CACHE_OFF",
140
- dimension: str | None = None,
141
- ) -> str:
142
- """Ejecuta `procesar(contenido)` solo si el resultado no está en caché.
143
-
144
- Layout en disco con sharding de 2 chars: `dir_cache/[dim/]ab/abcdef...txt`.
145
- El sharding evita carpetas con 10k+ archivos en NTFS/ext4.
146
- """
147
- sha = sha256_bytes(contenido)
148
- base = dir_cache if dimension is None else dir_cache / dimension
149
- ruta_cache = base / sha[:2] / f"{sha}.txt"
150
-
151
- if ruta_cache.exists() and ruta_cache.stat().st_size > 0 and not _flag_off(bypass_env):
152
- return ruta_cache.read_text(encoding="utf-8")
153
-
154
- # Recalcular (operación costosa: OCR, LLM, transcripción, build, etc.)
155
- resultado = procesar(contenido)
156
-
157
- # Escritura atómica: temp + rename para evitar entradas parciales por kill
158
- ruta_cache.parent.mkdir(parents=True, exist_ok=True)
159
- tmp = ruta_cache.with_suffix(".tmp")
160
- tmp.write_text(resultado, encoding="utf-8")
161
- tmp.rename(ruta_cache)
162
-
163
- return resultado
164
- ```
165
-
166
- ### Reglas de diseño
167
-
168
- - **Sharding de 2 chars** (`{sha[:2]}/{sha}.txt`) evita degradación del filesystem
169
- cuando el caché crece a decenas de miles de entradas (NTFS, ext4, APFS).
170
- - **Un directorio por tipo de operación**: `cache/ocr/`, `cache/vision/`,
171
- `cache/transcripcion/`. Mezclar tipos hace imposible invalidar uno solo.
172
- - **Escritura atómica obligatoria**: `tmp → rename` previene que un kill del proceso
173
- deje entradas parciales que se interpretan como éxito en la próxima corrida.
174
- - **Verificar tamaño > 0 al leer**: archivos de 0 bytes son ejecuciones abortadas,
175
- no "resultado vacío válido".
176
- - **Bypass por variable de entorno** (`CACHE_OFF=1` aceptando `1/true/yes/on`):
177
- crítico para tests que validan el procesador real, no el caché. Sin bypass,
178
- imposible reproducir bugs del extractor real durante pruebas.
179
- - **Key multi-dimensión cuando aplica**: si la función es determinística sobre
180
- `(input, modelo)` —caso típico de síntesis LLM con distinto modelo— usar el
181
- parámetro `dimension` para particionar el caché:
182
- `cache/sintesis/{modelo_safe}/{sha[:2]}/{sha}.txt`. Sin esto, cambiar de modelo
183
- devuelve resultados del modelo viejo con semántica nueva.
184
- - **Versionar el caché cuando cambia el procesamiento**: si actualizas el modelo de
185
- OCR o la versión del prompt LLM, bumpear `dimension` (`v1` → `v2`) o invalidar
186
- el directorio completo.
187
- - **No cachear errores**: si `procesar()` lanza excepción, el caché queda vacío
188
- y la siguiente corrida reintenta. Cachear el error convierte errores transitorios
189
- en permanentes.
190
- - **Idempotencia natural**: la escritura siempre puede sobreescribir porque el SHA
191
- garantiza mismo contenido por construcción. No requiere lock entre procesos.
192
-
193
- ### Tests obligatorios
194
-
195
- Toda implementación de caché content-addressable debe cubrir:
196
-
197
- 1. **Roundtrip write→read**: invocar dos veces con mismo input verifica que
198
- la segunda lectura no llama al procesador (mock con counter).
199
- 2. **Bypass por flag**: con `CACHE_OFF=1` siempre llama al procesador aunque exista entrada.
200
- 3. **Keys distintas para inputs distintos**: dos inputs con SHA distinto no se pisan.
201
- 4. **Fixture aislado**: `monkeypatch.setenv("CACHE_DIR", str(tmp_path))` evita
202
- contaminación entre tests.
203
- 5. **Atomicidad**: simular kill durante escritura (interrupción del `tmp.write_text`)
204
- y verificar que la siguiente corrida no lee un archivo parcial.
205
-
206
- ### Aplicabilidad
207
-
208
- - Pipelines de ingesta de documentos (PDF → texto, imagen → OCR, audio → transcripción).
209
- - Extracción con LLM costoso (vision, clasificación, extracción estructurada).
210
- - Suites E2E donde el dataset es fijo y se ejecutan repetidamente —típicamente
211
- reduce el tiempo de la corrida en 70-90% si la función cacheada domina.
212
- - Compilaciones incrementales (aunque los build systems ya tienen este patrón).
213
- - Deduplicación en walkers resumables (ver `testing-python`: "tests de
214
- idempotencia requieren 2 ejecuciones + diff").
215
-
216
- ---
217
-
218
- <a id="f401-soft-imports"></a>
219
- ## 4. F401 en archivos con soft imports es intencional, no ruido
220
-
221
- ### Contexto
222
-
223
- `ruff --select=F401` (y `flake8 --select=F401`) reportan "imported but unused"
224
- cuando un módulo se importa pero no se usa. En archivos con **soft imports**
225
- (intentar importar una dependencia opcional dentro de `try/except ImportError`)
226
- el import **debe quedarse** aunque lint lo marque como no usado — es parte del
227
- patrón de detección de disponibilidad.
228
-
229
- ### Patrón de soft import canónico
230
-
231
- ```python
232
- # El módulo puede funcionar con o sin la dependencia opcional.
233
- # La presencia del símbolo habilita un code path; su ausencia cambia al fallback.
234
-
235
- try:
236
- from markitdown import MarkItDown # noqa: F401 — soft import
237
- _MARKITDOWN_DISPONIBLE = True
238
- except ImportError:
239
- _MARKITDOWN_DISPONIBLE = False
240
-
241
-
242
- def extraer_texto(ruta: Path) -> str:
243
- if _MARKITDOWN_DISPONIBLE:
244
- from markitdown import MarkItDown # re-import local, ya sabemos que existe
245
- return MarkItDown().convert(str(ruta)).text_content
246
- # Fallback: usar parser básico
247
- return _extraer_con_parser_basico(ruta)
248
- ```
249
-
250
- ### Regla
251
-
252
- - Agregar `# noqa: F401` en la línea del import top-level dentro del `try`.
253
- - Alternativa: configurar `ruff.toml` / `pyproject.toml` con `per-file-ignores` para los archivos de soft import si son muchos:
254
- ```toml
255
- [tool.ruff.per-file-ignores]
256
- "core/adapters/*.py" = ["F401"] # todos los adapters usan soft imports
257
- ```
258
- - 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.
259
- - 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.
260
-
261
- ### Anti-patrón
262
-
263
- ```python
264
- # MAL — el F401 se "soluciona" pero la detección se rompe
265
- try:
266
- import markitdown # noqa — "no se usa, pero quiero saber si está"
267
- _DISPONIBLE = True
268
- except ImportError:
269
- _DISPONIBLE = False
270
- ```
271
-
272
- El problema: si `markitdown` se instala pero la API cambió (el símbolo
273
- `MarkItDown` ya no existe), el import sigue pasando y `_DISPONIBLE = True`
274
- pero el code path fallará más tarde al usar el símbolo ausente. Importar
275
- el símbolo específico que vas a usar es más robusto que importar el módulo
276
- y esperar que todo lo demás funcione.
277
-
278
- ---
279
-
280
- <a id="regex-multi-pattern"></a>
281
- ## 5. Detectores regex multi-pattern: extender scope sin refinar genera falsos positivos
282
-
283
- ### NUNCA: agregar un nuevo input a un detector multi-pattern sin re-evaluar la sensibilidad de cada pattern individual
284
-
285
- **Problema**: tienes una función `detectar(texto)` que aplica una lista de regex
286
- con OR (`any(p.search(texto) for p in PATTERNS)`). Originalmente operaba sobre
287
- texto curado (título, etiquetas, campos cortos). Ahora extiendes el scope a un
288
- texto más ruidoso (prosa larga, descripciones libres, contenido de usuario).
289
- Patterns genéricos que funcionaban en el texto curado empiezan a generar falsos
290
- positivos en el ruidoso.
291
-
292
- ```python
293
- # Caso típico: detector de "enumeración múltiple de items relacionados"
294
- PATTERNS = [
295
- re.compile(r"(?:,\s+\w[\w\s]{3,45}){3,}"), # P1 generico: 3+ comas
296
- re.compile(r"(?:^|\n)\s*[•\-]\s+.{10,}", re.M), # P2 generico: bullets
297
- re.compile(r"(?:anexos|evidencias|listas){2,}", re.I), # P3 especifico
298
- ]
299
-
300
- # MAL — extender scope sin discriminar patterns
301
- def enumera_multiples(registro):
302
- texto = " ".join([
303
- registro.titulo, registro.resumen, # texto curado
304
- registro.descripcion_libre, # texto ruidoso (prosa larga)
305
- ])
306
- return any(p.search(texto) for p in PATTERNS)
307
- # → P1 (3+ comas) matchea series de citas o referencias en prosa larga
308
- # ("art. 5, art. 10, art. 23, art. 138 y art. 141") → falso positivo
309
- # regresión típica observada: baja precisión sobre el dataset golden.
310
-
311
- # BIEN — patterns por scope, refinados según ruido tolerado
312
- def enumera_multiples(registro):
313
- texto_curado = " ".join([registro.titulo, registro.resumen])
314
- if any(p.search(texto_curado) for p in PATTERNS):
315
- return True
316
- # Sobre texto ruidoso, solo el pattern especifico (P3)
317
- texto_ruidoso = " ".join([registro.descripcion_libre, registro.notas_libres])
318
- return PATTERNS[2].search(texto_ruidoso) is not None
319
- ```
320
-
321
- ### Regla operativa
322
-
323
- Cuando se extiende un detector regex a un nuevo scope textual, agregar un test de
324
- regresión específico que verifique que NO se introducen falsos positivos sobre
325
- muestras del nuevo scope que NO deberían matchear. Si los patterns genéricos
326
- hacen FP sobre el scope nuevo, **discriminarlos por scope** (no aplicarlos al
327
- scope ruidoso) en lugar de intentar refinarlos en una sola lista global.
328
-
329
- **Aplicabilidad**: clasificadores de texto, detectores de PII, filtros de spam,
330
- sistemas de moderación, extracción de entidades cuando el scope crece de campos
331
- estructurados a contenido libre.
332
-
333
- ---
334
-
335
- <a id="tracer-sync"></a>
336
- ## 6. Tracer/replicador paralelo del motor: marca SYNC obligatoria en cada cambio
337
-
338
- ### Patrón: cuando duplicas lógica del original, marca el SYNC y agrega test de paridad
339
-
340
- **Problema**: en sistemas con motores complejos (cascadas de reglas, clasificadores
341
- con prioridad, motores de decisión), suele crearse un "tracer" que replica la
342
- lógica paso a paso para producir telemetría, explicación o shadow runs. El tracer
343
- es **duplicación** del motor — si cambias el motor sin tocar el tracer, los
344
- outputs divergen silenciosamente y los tests del tracer pasan con datos viejos
345
- mientras el motor real ya cambió.
346
-
347
- ```python
348
- # Motor real (motor_decision.py)
349
- def resolver_resultado(self, registro):
350
- resultado = self._resolver_core(registro)
351
- # Ajuste 1: suavización por condición A
352
- if cond_a(registro): return "ACEPTADO"
353
- # Ajustes 2-4: endurecimientos
354
- if cond_b(registro): return "RECHAZADO"
355
- # Ajuste 5 (NUEVO): endurecimiento por condición compuesta
356
- if resultado == "ACEPTADO" and registro._compuesta:
357
- return "RECHAZADO"
358
- return resultado
359
-
360
- # Tracer paralelo (motor_decision_tracer.py)
361
- def replicar_resultado(...):
362
- # SYNC con: motor_decision.py::resolver_resultado
363
- # Si agregas un Ajuste, AGREGARLO TAMBIÉN aquí en el orden correcto.
364
- if cond_a(registro): ...
365
- if cond_b(registro): ...
366
- # Olvidé sincronizar Ajuste 5 aquí → tracer reporta ACEPTADO
367
- # pero motor real reporta RECHAZADO → tests ground truth fallan
368
- # silenciosamente porque el tracer es lo que se compara.
369
- ```
370
-
371
- ### Reglas obligatorias para código duplicado deliberado
372
-
373
- 1. **Marca de SYNC**: comentario `# SYNC con: <archivo>:<función>` visible en la
374
- cabecera del replicador. La búsqueda `grep -rn "SYNC con:"` lista todos los
375
- puntos de duplicación del repositorio en una corrida.
376
- 2. **Test de paridad**: para casos representativos del dataset golden, comparar
377
- `motor.resolver(x) == tracer.resolver(x)` y fallar si difieren. Es el único
378
- gate que detecta la divergencia sin esperar a producción.
379
- 3. **Convención de naming**: el archivo que duplica la lógica se nombra explícitamente
380
- como derivado (`_tracer.py`, `_replicador.py`, `_explainer.py`, `_shadow.py`).
381
- NUNCA esconderlo bajo nombre genérico — el nombre es la primera línea de defensa
382
- contra que alguien lo edite sin saber que es duplicación.
383
- 4. **Owner único**: el motor y su tracer cambian en el mismo PR. Code review rechaza
384
- PRs que tocan el motor sin tocar el tracer (o que justifiquen explícitamente
385
- por qué la divergencia es intencional, ej. "el tracer no necesita el ajuste de
386
- performance").
387
-
388
- **Aplicabilidad**: sistemas con explainability, dual-track production+shadow,
389
- motores de reglas con logging detallado, A/B testing de algoritmos, migración
390
- incremental de un motor legacy a uno nuevo donde ambos corren en paralelo.
391
-
392
- ---
393
-
394
- <a id="fixtures-crudos"></a>
395
- ## 7. Fixtures de test con datos pre-procesados NO ejercen el path de datos crudos
396
-
397
- ### Anti-patrón: fixtures construidos a mano que reflejan la salida del extractor, no su entrada
398
-
399
- **Problema**: el sistema en producción procesa input crudo (PDFs, HTMLs, JSON
400
- externos, mensajes raw) que pasa por extractores que producen un dict condensado.
401
- Los fixtures de test se construyen "a mano" copiando lo que el extractor produce
402
- (o aproximándolo). Resultado: cualquier path del motor que solo se activa con
403
- campos del input crudo (presentes solo cuando hay extractor real arriba) NO se
404
- ejercita por los tests, y los bugs aparecen únicamente en producción.
405
-
406
- ```python
407
- # Fixture típico hecho a mano (apariencia inocente)
408
- {
409
- "id": "REG-001",
410
- "registro": {
411
- "titulo": "Falta de evidencia documental",
412
- "descripcion_corta": "El operador manifiesta que el oficio acredita...", # condensada
413
- # NOTAR: no hay `descripcion_original`
414
- }
415
- }
416
-
417
- # Motor con fix nuevo
418
- def _clasificar_postura(registro):
419
- # Fix: prefiere texto crudo cuando está disponible
420
- texto = (
421
- registro.get("descripcion_original")
422
- or registro.get("descripcion_corta")
423
- )
424
- return any(p in texto.lower() for p in patrones_subsanadores)
425
-
426
- # Test del fixture pasa "por casualidad" (descripcion_corta condensada
427
- # casualmente contiene "se anexa") → falsa confianza.
428
- # En producción, descripcion_original sería 5000 chars con verbos
429
- # canónicos y descripcion_corta sería paráfrasis sin esos verbos.
430
- # Los tests no ejercitan el path real.
431
- ```
432
-
433
- ### Defensa
434
-
435
- Cuando se introduce un nuevo campo opcional que el motor prefiere leer
436
- (`descripcion_original`, `metadata_completa`, `raw_input`, `body_unparsed`,
437
- etc.), agregar **al menos un fixture** que tenga ese campo poblado con una
438
- muestra representativa del input crudo real — idealmente extraída del caché de
439
- un pipeline E2E ejecutado, no inventada a mano.
440
-
441
- ```python
442
- # Mejorado: fixture con ambos campos, con datos representativos del scope crudo
443
- {
444
- "registro": {
445
- "descripcion_corta": "El operador manifiesta...", # condensada (lo que el extractor produce)
446
- "descripcion_original": (
447
- "OFICIO 12345/2026 — DEPARTAMENTO DE OPERACIONES ... "
448
- "se anexa el oficio mediante correo institucional ... "
449
- "[texto crudo extraído del PDF, 5000 chars con jerga canónica]"
450
- ),
451
- }
452
- }
453
- ```
454
-
455
- ### Regla operativa
456
-
457
- - **Dos niveles de fixtures**: condensados (rápidos, para lógica de negocio) y
458
- crudos (lentos, para paths que dependen del input real). Marcar cada fixture
459
- con su nivel: `nombre.condensado.json` vs `nombre.crudo.json`.
460
- - **Snapshot del extractor real**: si el extractor es determinístico, ejecutarlo
461
- una vez sobre datos reales y persistir su output como fixture crudo. No inventarlo.
462
- - **Test de "campo prioritario nuevo"**: cada vez que el motor agrega un campo
463
- con prioridad sobre uno existente, agregar un test que verifique el comportamiento
464
- con AMBOS campos poblados con valores **divergentes** (que producen resultados
465
- distintos). Si el test pasa con valores iguales, no prueba la priorización.
466
-
467
- **Aplicabilidad**: pipelines de ingesta con extractores upstream, sistemas con
468
- adaptadores que normalizan inputs heterogéneos, motores que prefieren campos
469
- crudos sobre derivados, tests de regresión de migraciones de schema.
1
+ # Patrones avanzados — Python
2
+
3
+ Recurso de profundidad cargado bajo demanda desde `SKILL.md`. Contiene 7 patrones
4
+ que aparecen al integrar Python con pipelines reales: conversores de documentos,
5
+ clientes heterogéneos, cachés determinísticos, soft imports, detectores de
6
+ texto y duplicación deliberada de lógica.
7
+
8
+ ## Índice
9
+
10
+ 1. [Normalizadores: colapsar al formato canónico del proyecto](#normalizadores)
11
+ 2. [kwargs opcionales entre clientes hermanos: try/except TypeError](#kwargs-opcionales)
12
+ 3. [Caché content-addressable por SHA256](#cache-sha256)
13
+ 4. [F401 en archivos con soft imports es intencional](#f401-soft-imports)
14
+ 5. [Detectores regex multi-pattern: extender scope sin refinar](#regex-multi-pattern)
15
+ 6. [Tracer/replicador paralelo del motor: marca SYNC obligatoria](#tracer-sync)
16
+ 7. [Fixtures con datos pre-procesados NO ejercen el path crudo](#fixtures-crudos)
17
+
18
+ ---
19
+
20
+ <a id="normalizadores"></a>
21
+ ## 1. Normalizadores: colapsar al formato canónico del PROYECTO, no al estándar genérico
22
+
23
+ ### SIEMPRE: el normalizador debe conocer la convención del proyecto
24
+
25
+ **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.
26
+
27
+ **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.
28
+
29
+ **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.
30
+
31
+ ```python
32
+ # MAL — colapsa al estándar genérico sin verificar la convención del proyecto
33
+ def _normalizar(texto: str) -> str:
34
+ # "Mover puntuación al interior del bold" (parece "más correcto")
35
+ return re.sub(r"\*\*([^*]+?)\*\*([:;.,])", r"**\1\2**", texto)
36
+ # Resultado: `**ACTIVIDAD**:` → `**ACTIVIDAD:**` ← pero el proyecto usa el primero
37
+
38
+ # BIEN — conoce la convención: "puntuación terminal FUERA del bold"
39
+ def _normalizar(texto: str) -> str:
40
+ """Normaliza a la convención del proyecto: puntuación fuera del bold.
41
+
42
+ CommonMark genérico prefiere mover la puntuación al interior del bold,
43
+ pero algunos proyectos adoptan la convención opuesta para preservar
44
+ la semántica visual del documento de origen (DOCX, PDF). Cubrir el
45
+ caso con un test de ground truth antes de modificar el regex.
46
+ """
47
+ # Artefacto `**ACTIVIDAD****:**` → `**ACTIVIDAD**:` (puntuación fuera)
48
+ return re.sub(
49
+ r"\*\*([^\n*]+?)\*{2,}([:;,.!?\-—)])\*{2,}",
50
+ r"**\1**\2",
51
+ texto,
52
+ )
53
+ ```
54
+
55
+ **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.
56
+
57
+ ---
58
+
59
+ <a id="kwargs-opcionales"></a>
60
+ ## 2. kwargs opcionales entre clientes hermanos: try/except TypeError
61
+
62
+ ### Patrón: propagar kwargs a implementaciones heterogéneas con degradación silenciosa
63
+
64
+ **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.
65
+
66
+ **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.
67
+
68
+ **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.
69
+
70
+ ```python
71
+ # BIEN — degradación silenciosa compatible con clientes heterogéneos
72
+ async def llamar_con_timeout_opcional(cliente, modelo, prompt, timeout_s):
73
+ try:
74
+ # Cliente moderno (acepta `timeout` kwarg)
75
+ call = cliente.generate(model=modelo, prompt=prompt, timeout=timeout_s)
76
+ except TypeError:
77
+ # Cliente legacy (no acepta `timeout`); fallback silencioso
78
+ call = cliente.generate(model=modelo, prompt=prompt)
79
+ return await asyncio.wait_for(call, timeout=timeout_s)
80
+ ```
81
+
82
+ **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)`).
83
+
84
+ **Aplicabilidad**: migraciones de librerías incrementales, plugins con versiones heterogéneas, strategy pattern donde las implementaciones evolucionan a distinto ritmo.
85
+
86
+ ---
87
+
88
+ <a id="cache-sha256"></a>
89
+ ## 3. Caché content-addressable por SHA256 para llamadas determinísticas costosas
90
+
91
+ ### Patrón
92
+
93
+ Cuando una función pura (mismo input → mismo output) es costosa (>1 s, llamada
94
+ externa, IO pesado) y se invoca repetidamente con la misma entrada (pipelines
95
+ de ingesta, suites E2E, recompilación incremental), cachear el resultado por
96
+ el **SHA256 del contenido** del input convierte re-ejecuciones en operaciones
97
+ prácticamente gratuitas y hace el pipeline resumible.
98
+
99
+ ### Por qué SHA256 del contenido y no `(path, mtime, size)`
100
+
101
+ - `mtime` se rompe al copiar archivos entre sistemas (rsync, git checkout).
102
+ - `size` colisiona trivialmente con archivos distintos del mismo tamaño.
103
+ - `path` cambia al reorganizar el dataset.
104
+
105
+ El SHA256 del **contenido** identifica la entrada de forma reproducible aunque
106
+ se mueva, renombre o clone.
107
+
108
+ ### Implementación canónica
109
+
110
+ ```python
111
+ import hashlib
112
+ import os
113
+ from pathlib import Path
114
+
115
+
116
+ def _flag_off(env_var: str) -> bool:
117
+ """True si la env var está en {1, true, yes, on} (case-insensitive)."""
118
+ return os.environ.get(env_var, "").lower() in {"1", "true", "yes", "on"}
119
+
120
+
121
+ def sha256_bytes(contenido: bytes) -> str:
122
+ return hashlib.sha256(contenido).hexdigest()
123
+
124
+
125
+ def sha256_archivo(ruta: Path) -> str:
126
+ """SHA256 del contenido completo en bloques de 64KB."""
127
+ h = hashlib.sha256()
128
+ with ruta.open("rb") as f:
129
+ for chunk in iter(lambda: f.read(65536), b""):
130
+ h.update(chunk)
131
+ return h.hexdigest()
132
+
133
+
134
+ def procesar_con_cache(
135
+ contenido: bytes,
136
+ dir_cache: Path,
137
+ procesar,
138
+ *,
139
+ bypass_env: str = "CACHE_OFF",
140
+ dimension: str | None = None,
141
+ ) -> str:
142
+ """Ejecuta `procesar(contenido)` solo si el resultado no está en caché.
143
+
144
+ Layout en disco con sharding de 2 chars: `dir_cache/[dim/]ab/abcdef...txt`.
145
+ El sharding evita carpetas con 10k+ archivos en NTFS/ext4.
146
+ """
147
+ sha = sha256_bytes(contenido)
148
+ base = dir_cache if dimension is None else dir_cache / dimension
149
+ ruta_cache = base / sha[:2] / f"{sha}.txt"
150
+
151
+ if ruta_cache.exists() and ruta_cache.stat().st_size > 0 and not _flag_off(bypass_env):
152
+ return ruta_cache.read_text(encoding="utf-8")
153
+
154
+ # Recalcular (operación costosa: OCR, LLM, transcripción, build, etc.)
155
+ resultado = procesar(contenido)
156
+
157
+ # Escritura atómica: temp + rename para evitar entradas parciales por kill
158
+ ruta_cache.parent.mkdir(parents=True, exist_ok=True)
159
+ tmp = ruta_cache.with_suffix(".tmp")
160
+ tmp.write_text(resultado, encoding="utf-8")
161
+ tmp.rename(ruta_cache)
162
+
163
+ return resultado
164
+ ```
165
+
166
+ ### Reglas de diseño
167
+
168
+ - **Sharding de 2 chars** (`{sha[:2]}/{sha}.txt`) evita degradación del filesystem
169
+ cuando el caché crece a decenas de miles de entradas (NTFS, ext4, APFS).
170
+ - **Un directorio por tipo de operación**: `cache/ocr/`, `cache/vision/`,
171
+ `cache/transcripcion/`. Mezclar tipos hace imposible invalidar uno solo.
172
+ - **Escritura atómica obligatoria**: `tmp → rename` previene que un kill del proceso
173
+ deje entradas parciales que se interpretan como éxito en la próxima corrida.
174
+ - **Verificar tamaño > 0 al leer**: archivos de 0 bytes son ejecuciones abortadas,
175
+ no "resultado vacío válido".
176
+ - **Bypass por variable de entorno** (`CACHE_OFF=1` aceptando `1/true/yes/on`):
177
+ crítico para tests que validan el procesador real, no el caché. Sin bypass,
178
+ imposible reproducir bugs del extractor real durante pruebas.
179
+ - **Key multi-dimensión cuando aplica**: si la función es determinística sobre
180
+ `(input, modelo)` —caso típico de síntesis LLM con distinto modelo— usar el
181
+ parámetro `dimension` para particionar el caché:
182
+ `cache/sintesis/{modelo_safe}/{sha[:2]}/{sha}.txt`. Sin esto, cambiar de modelo
183
+ devuelve resultados del modelo viejo con semántica nueva.
184
+ - **Versionar el caché cuando cambia el procesamiento**: si actualizas el modelo de
185
+ OCR o la versión del prompt LLM, bumpear `dimension` (`v1` → `v2`) o invalidar
186
+ el directorio completo.
187
+ - **No cachear errores**: si `procesar()` lanza excepción, el caché queda vacío
188
+ y la siguiente corrida reintenta. Cachear el error convierte errores transitorios
189
+ en permanentes.
190
+ - **Idempotencia natural**: la escritura siempre puede sobreescribir porque el SHA
191
+ garantiza mismo contenido por construcción. No requiere lock entre procesos.
192
+
193
+ ### Tests obligatorios
194
+
195
+ Toda implementación de caché content-addressable debe cubrir:
196
+
197
+ 1. **Roundtrip write→read**: invocar dos veces con mismo input verifica que
198
+ la segunda lectura no llama al procesador (mock con counter).
199
+ 2. **Bypass por flag**: con `CACHE_OFF=1` siempre llama al procesador aunque exista entrada.
200
+ 3. **Keys distintas para inputs distintos**: dos inputs con SHA distinto no se pisan.
201
+ 4. **Fixture aislado**: `monkeypatch.setenv("CACHE_DIR", str(tmp_path))` evita
202
+ contaminación entre tests.
203
+ 5. **Atomicidad**: simular kill durante escritura (interrupción del `tmp.write_text`)
204
+ y verificar que la siguiente corrida no lee un archivo parcial.
205
+
206
+ ### Aplicabilidad
207
+
208
+ - Pipelines de ingesta de documentos (PDF → texto, imagen → OCR, audio → transcripción).
209
+ - Extracción con LLM costoso (vision, clasificación, extracción estructurada).
210
+ - Suites E2E donde el dataset es fijo y se ejecutan repetidamente —típicamente
211
+ reduce el tiempo de la corrida en 70-90% si la función cacheada domina.
212
+ - Compilaciones incrementales (aunque los build systems ya tienen este patrón).
213
+ - Deduplicación en walkers resumables (ver `testing-python`: "tests de
214
+ idempotencia requieren 2 ejecuciones + diff").
215
+
216
+ ---
217
+
218
+ <a id="f401-soft-imports"></a>
219
+ ## 4. F401 en archivos con soft imports es intencional, no ruido
220
+
221
+ ### Contexto
222
+
223
+ `ruff --select=F401` (y `flake8 --select=F401`) reportan "imported but unused"
224
+ cuando un módulo se importa pero no se usa. En archivos con **soft imports**
225
+ (intentar importar una dependencia opcional dentro de `try/except ImportError`)
226
+ el import **debe quedarse** aunque lint lo marque como no usado — es parte del
227
+ patrón de detección de disponibilidad.
228
+
229
+ ### Patrón de soft import canónico
230
+
231
+ ```python
232
+ # El módulo puede funcionar con o sin la dependencia opcional.
233
+ # La presencia del símbolo habilita un code path; su ausencia cambia al fallback.
234
+
235
+ try:
236
+ from markitdown import MarkItDown # noqa: F401 — soft import
237
+ _MARKITDOWN_DISPONIBLE = True
238
+ except ImportError:
239
+ _MARKITDOWN_DISPONIBLE = False
240
+
241
+
242
+ def extraer_texto(ruta: Path) -> str:
243
+ if _MARKITDOWN_DISPONIBLE:
244
+ from markitdown import MarkItDown # re-import local, ya sabemos que existe
245
+ return MarkItDown().convert(str(ruta)).text_content
246
+ # Fallback: usar parser básico
247
+ return _extraer_con_parser_basico(ruta)
248
+ ```
249
+
250
+ ### Regla
251
+
252
+ - Agregar `# noqa: F401` en la línea del import top-level dentro del `try`.
253
+ - Alternativa: configurar `ruff.toml` / `pyproject.toml` con `per-file-ignores` para los archivos de soft import si son muchos:
254
+ ```toml
255
+ [tool.ruff.per-file-ignores]
256
+ "core/adapters/*.py" = ["F401"] # todos los adapters usan soft imports
257
+ ```
258
+ - 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.
259
+ - 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.
260
+
261
+ ### Anti-patrón
262
+
263
+ ```python
264
+ # MAL — el F401 se "soluciona" pero la detección se rompe
265
+ try:
266
+ import markitdown # noqa — "no se usa, pero quiero saber si está"
267
+ _DISPONIBLE = True
268
+ except ImportError:
269
+ _DISPONIBLE = False
270
+ ```
271
+
272
+ El problema: si `markitdown` se instala pero la API cambió (el símbolo
273
+ `MarkItDown` ya no existe), el import sigue pasando y `_DISPONIBLE = True`
274
+ pero el code path fallará más tarde al usar el símbolo ausente. Importar
275
+ el símbolo específico que vas a usar es más robusto que importar el módulo
276
+ y esperar que todo lo demás funcione.
277
+
278
+ ---
279
+
280
+ <a id="regex-multi-pattern"></a>
281
+ ## 5. Detectores regex multi-pattern: extender scope sin refinar genera falsos positivos
282
+
283
+ ### NUNCA: agregar un nuevo input a un detector multi-pattern sin re-evaluar la sensibilidad de cada pattern individual
284
+
285
+ **Problema**: tienes una función `detectar(texto)` que aplica una lista de regex
286
+ con OR (`any(p.search(texto) for p in PATTERNS)`). Originalmente operaba sobre
287
+ texto curado (título, etiquetas, campos cortos). Ahora extiendes el scope a un
288
+ texto más ruidoso (prosa larga, descripciones libres, contenido de usuario).
289
+ Patterns genéricos que funcionaban en el texto curado empiezan a generar falsos
290
+ positivos en el ruidoso.
291
+
292
+ ```python
293
+ # Caso típico: detector de "enumeración múltiple de items relacionados"
294
+ PATTERNS = [
295
+ re.compile(r"(?:,\s+\w[\w\s]{3,45}){3,}"), # P1 generico: 3+ comas
296
+ re.compile(r"(?:^|\n)\s*[•\-]\s+.{10,}", re.M), # P2 generico: bullets
297
+ re.compile(r"(?:anexos|evidencias|listas){2,}", re.I), # P3 especifico
298
+ ]
299
+
300
+ # MAL — extender scope sin discriminar patterns
301
+ def enumera_multiples(registro):
302
+ texto = " ".join([
303
+ registro.titulo, registro.resumen, # texto curado
304
+ registro.descripcion_libre, # texto ruidoso (prosa larga)
305
+ ])
306
+ return any(p.search(texto) for p in PATTERNS)
307
+ # → P1 (3+ comas) matchea series de citas o referencias en prosa larga
308
+ # ("art. 5, art. 10, art. 23, art. 138 y art. 141") → falso positivo
309
+ # regresión típica observada: baja precisión sobre el dataset golden.
310
+
311
+ # BIEN — patterns por scope, refinados según ruido tolerado
312
+ def enumera_multiples(registro):
313
+ texto_curado = " ".join([registro.titulo, registro.resumen])
314
+ if any(p.search(texto_curado) for p in PATTERNS):
315
+ return True
316
+ # Sobre texto ruidoso, solo el pattern especifico (P3)
317
+ texto_ruidoso = " ".join([registro.descripcion_libre, registro.notas_libres])
318
+ return PATTERNS[2].search(texto_ruidoso) is not None
319
+ ```
320
+
321
+ ### Regla operativa
322
+
323
+ Cuando se extiende un detector regex a un nuevo scope textual, agregar un test de
324
+ regresión específico que verifique que NO se introducen falsos positivos sobre
325
+ muestras del nuevo scope que NO deberían matchear. Si los patterns genéricos
326
+ hacen FP sobre el scope nuevo, **discriminarlos por scope** (no aplicarlos al
327
+ scope ruidoso) en lugar de intentar refinarlos en una sola lista global.
328
+
329
+ **Aplicabilidad**: clasificadores de texto, detectores de PII, filtros de spam,
330
+ sistemas de moderación, extracción de entidades cuando el scope crece de campos
331
+ estructurados a contenido libre.
332
+
333
+ ---
334
+
335
+ <a id="tracer-sync"></a>
336
+ ## 6. Tracer/replicador paralelo del motor: marca SYNC obligatoria en cada cambio
337
+
338
+ ### Patrón: cuando duplicas lógica del original, marca el SYNC y agrega test de paridad
339
+
340
+ **Problema**: en sistemas con motores complejos (cascadas de reglas, clasificadores
341
+ con prioridad, motores de decisión), suele crearse un "tracer" que replica la
342
+ lógica paso a paso para producir telemetría, explicación o shadow runs. El tracer
343
+ es **duplicación** del motor — si cambias el motor sin tocar el tracer, los
344
+ outputs divergen silenciosamente y los tests del tracer pasan con datos viejos
345
+ mientras el motor real ya cambió.
346
+
347
+ ```python
348
+ # Motor real (motor_decision.py)
349
+ def resolver_resultado(self, registro):
350
+ resultado = self._resolver_core(registro)
351
+ # Ajuste 1: suavización por condición A
352
+ if cond_a(registro): return "ACEPTADO"
353
+ # Ajustes 2-4: endurecimientos
354
+ if cond_b(registro): return "RECHAZADO"
355
+ # Ajuste 5 (NUEVO): endurecimiento por condición compuesta
356
+ if resultado == "ACEPTADO" and registro._compuesta:
357
+ return "RECHAZADO"
358
+ return resultado
359
+
360
+ # Tracer paralelo (motor_decision_tracer.py)
361
+ def replicar_resultado(...):
362
+ # SYNC con: motor_decision.py::resolver_resultado
363
+ # Si agregas un Ajuste, AGREGARLO TAMBIÉN aquí en el orden correcto.
364
+ if cond_a(registro): ...
365
+ if cond_b(registro): ...
366
+ # Olvidé sincronizar Ajuste 5 aquí → tracer reporta ACEPTADO
367
+ # pero motor real reporta RECHAZADO → tests ground truth fallan
368
+ # silenciosamente porque el tracer es lo que se compara.
369
+ ```
370
+
371
+ ### Reglas obligatorias para código duplicado deliberado
372
+
373
+ 1. **Marca de SYNC**: comentario `# SYNC con: <archivo>:<función>` visible en la
374
+ cabecera del replicador. La búsqueda `grep -rn "SYNC con:"` lista todos los
375
+ puntos de duplicación del repositorio en una corrida.
376
+ 2. **Test de paridad**: para casos representativos del dataset golden, comparar
377
+ `motor.resolver(x) == tracer.resolver(x)` y fallar si difieren. Es el único
378
+ gate que detecta la divergencia sin esperar a producción.
379
+ 3. **Convención de naming**: el archivo que duplica la lógica se nombra explícitamente
380
+ como derivado (`_tracer.py`, `_replicador.py`, `_explainer.py`, `_shadow.py`).
381
+ NUNCA esconderlo bajo nombre genérico — el nombre es la primera línea de defensa
382
+ contra que alguien lo edite sin saber que es duplicación.
383
+ 4. **Owner único**: el motor y su tracer cambian en el mismo PR. Code review rechaza
384
+ PRs que tocan el motor sin tocar el tracer (o que justifiquen explícitamente
385
+ por qué la divergencia es intencional, ej. "el tracer no necesita el ajuste de
386
+ performance").
387
+
388
+ **Aplicabilidad**: sistemas con explainability, dual-track production+shadow,
389
+ motores de reglas con logging detallado, A/B testing de algoritmos, migración
390
+ incremental de un motor legacy a uno nuevo donde ambos corren en paralelo.
391
+
392
+ ---
393
+
394
+ <a id="fixtures-crudos"></a>
395
+ ## 7. Fixtures de test con datos pre-procesados NO ejercen el path de datos crudos
396
+
397
+ ### Anti-patrón: fixtures construidos a mano que reflejan la salida del extractor, no su entrada
398
+
399
+ **Problema**: el sistema en producción procesa input crudo (PDFs, HTMLs, JSON
400
+ externos, mensajes raw) que pasa por extractores que producen un dict condensado.
401
+ Los fixtures de test se construyen "a mano" copiando lo que el extractor produce
402
+ (o aproximándolo). Resultado: cualquier path del motor que solo se activa con
403
+ campos del input crudo (presentes solo cuando hay extractor real arriba) NO se
404
+ ejercita por los tests, y los bugs aparecen únicamente en producción.
405
+
406
+ ```python
407
+ # Fixture típico hecho a mano (apariencia inocente)
408
+ {
409
+ "id": "REG-001",
410
+ "registro": {
411
+ "titulo": "Falta de evidencia documental",
412
+ "descripcion_corta": "El operador manifiesta que el oficio acredita...", # condensada
413
+ # NOTAR: no hay `descripcion_original`
414
+ }
415
+ }
416
+
417
+ # Motor con fix nuevo
418
+ def _clasificar_postura(registro):
419
+ # Fix: prefiere texto crudo cuando está disponible
420
+ texto = (
421
+ registro.get("descripcion_original")
422
+ or registro.get("descripcion_corta")
423
+ )
424
+ return any(p in texto.lower() for p in patrones_subsanadores)
425
+
426
+ # Test del fixture pasa "por casualidad" (descripcion_corta condensada
427
+ # casualmente contiene "se anexa") → falsa confianza.
428
+ # En producción, descripcion_original sería 5000 chars con verbos
429
+ # canónicos y descripcion_corta sería paráfrasis sin esos verbos.
430
+ # Los tests no ejercitan el path real.
431
+ ```
432
+
433
+ ### Defensa
434
+
435
+ Cuando se introduce un nuevo campo opcional que el motor prefiere leer
436
+ (`descripcion_original`, `metadata_completa`, `raw_input`, `body_unparsed`,
437
+ etc.), agregar **al menos un fixture** que tenga ese campo poblado con una
438
+ muestra representativa del input crudo real — idealmente extraída del caché de
439
+ un pipeline E2E ejecutado, no inventada a mano.
440
+
441
+ ```python
442
+ # Mejorado: fixture con ambos campos, con datos representativos del scope crudo
443
+ {
444
+ "registro": {
445
+ "descripcion_corta": "El operador manifiesta...", # condensada (lo que el extractor produce)
446
+ "descripcion_original": (
447
+ "OFICIO 12345/2026 — DEPARTAMENTO DE OPERACIONES ... "
448
+ "se anexa el oficio mediante correo institucional ... "
449
+ "[texto crudo extraído del PDF, 5000 chars con jerga canónica]"
450
+ ),
451
+ }
452
+ }
453
+ ```
454
+
455
+ ### Regla operativa
456
+
457
+ - **Dos niveles de fixtures**: condensados (rápidos, para lógica de negocio) y
458
+ crudos (lentos, para paths que dependen del input real). Marcar cada fixture
459
+ con su nivel: `nombre.condensado.json` vs `nombre.crudo.json`.
460
+ - **Snapshot del extractor real**: si el extractor es determinístico, ejecutarlo
461
+ una vez sobre datos reales y persistir su output como fixture crudo. No inventarlo.
462
+ - **Test de "campo prioritario nuevo"**: cada vez que el motor agrega un campo
463
+ con prioridad sobre uno existente, agregar un test que verifique el comportamiento
464
+ con AMBOS campos poblados con valores **divergentes** (que producen resultados
465
+ distintos). Si el test pasa con valores iguales, no prueba la priorización.
466
+
467
+ **Aplicabilidad**: pipelines de ingesta con extractores upstream, sistemas con
468
+ adaptadores que normalizan inputs heterogéneos, motores que prefieren campos
469
+ crudos sobre derivados, tests de regresión de migraciones de schema.