CreaRack-SL

Lint Bibliotecario en 2 capas: bulk sin LLM + contradictions con Haiku

ADR — Lint Bibliotecario en 2 capas: bulk sin LLM + contradictions con Haiku

Contexto

El sistema Supercontexto produce y mantiene un corpus de páginas wiki (concept, feature, entity, decision, runbook, incident). En estado operativo tiene del orden de 90-200 pages activas en D1, con bib_wiki_pages como tabla canónica y los .md en src/content/wiki/ como fuente reproducible.

El corpus se degrada con el tiempo de tres maneras independientes:

  1. Drift estructural: pages con last_verified vencido, pages huérfanas (sin backlinks vía related[] ni vía documents edges), pages con sources que apuntan a archivos de código que ya no existen en el repo.
  2. Drift semántico: dos pages activas afirman cosas incompatibles sobre el mismo tema (un ADR dice “siempre async”, una feature dice “siempre sync”; un concept reporta una cifra distinta a la del entity correspondiente).
  3. Drift de cobertura: pages que existen en .md pero no llegaron a D1, o entradas de D1 sin .md correspondiente.

Cada tipo de drift requiere un detector con coste, latencia y frecuencia muy distintos. Los dos primeros caen en el alcance de Lint y son el objeto de esta decisión. El tercero pertenece al ADR de reconcile D1↔repo.

Problema

Una sola implementación de Lint que cubra los tres detectores estructurales (stale, orphans, broken_sources) y además el detector semántico (contradicciones) tiene perfiles de coste incompatibles. Los detectores estructurales son SQL puro sobre D1: ejecutables a diario sin coste de proveedor IA. La detección de contradicciones requiere comparar pages por pares dentro de la misma community o tipo y pedir a un LLM un veredicto, lo que escala como O(N²) sobre la community y consume tokens reales en cada par.

Si todo va por el mismo workflow con la misma cadencia:

  • Si la cadencia es diaria y cubre contradicciones full pairwise: el coste Anthropic se dispara y el budget de subrequests de CF Workers (50 en plan Free, ~1000 en paid) revienta cuando el corpus crece.
  • Si la cadencia es semanal y sólo entonces se detectan también stales y orphans: las pages que vencen en mitad de la semana quedan estancadas hasta 7 días, y los broken_sources tras refactores grandes pueden sobrevivir días sin alerta.

Además, durante la sesión 10 (commit 2319b6c) un primer intento monolítico contra 60+ pages reventó CF Workers con Too many subrequests by single Worker invocation (60 queries D1 orphan-check + M fetches GitHub) y D1_ERROR: LIKE or GLOB pattern too complex (búsqueda de backlinks con WHERE related LIKE '%"<slug>"%'). Esto exigió rediseñar la parte estructural antes de poder añadir la parte semántica.

Opciones consideradas

1. Lint single-tier sólo bulk (sin LLM) — un único wiki_lint_bulk diario que detecta drift estructural. Cubre stale, orphans y broken_sources. Descartada: deja sin cobertura las contradicciones semánticas, que el AUDIT inicial ya había marcado como deuda explícita (“No hay Lint semántico: detectamos stale pero no contradicciones”).

2. Lint single-tier todo por Haiku — un único workflow que para cada page evalúa todo, incluida la consistencia frente a sus peers, llamando a Haiku N×N veces. Descartada: coste lineal con el corpus al cuadrado, supera el budget de subrequests CF Workers en una invocación, y la mayor parte del trabajo no necesita LLM (un last_verified vencido se ve con una resta de fechas).

3. Lint adversarial / abogado del diablo — un agente LLM que ataca el corpus generando hipótesis de contradicción y validándolas contra pages reales. Descartada por complejidad: añade un componente generativo difícil de acotar en coste y de evaluar en precisión, y el corpus aún no tiene tamaño suficiente para justificar esa sofisticación.

4. Lint two-tier: bulk diario barato + contradictions semanal Haiku — separar los dos perfiles de coste en dos handlers MCP distintos (wiki_lint_bulk, wiki_lint_contradictions) ejecutados por dos crons distintos con cadencias distintas. Seleccionada.

Decisión

Lint dividido en dos capas con responsabilidades disjuntas, dos handlers MCP y dos workflows:

Capa 1 — wiki_lint_bulk (diario, sin LLM)

Handler en functions/api/mcp/handlers/wiki.ts (wikiLintBulk). 6 pasos en una sola invocación CF Workers, sin llamadas a proveedores IA:

  • Pass 1: 1 query D1 que trae metadata de todas las pages active.
  • Pass 2: reverse-index de backlinks construido en JS sobre el resultado (0 subrequests adicionales).
  • Pass 3: code refs en bulk con IN chunked a 90 bind params (footgun D1: límite 100 bind params).
  • Pass 4: evaluación de staleness y orphans en memoria. Distingue stale real (last_verified expirado, o last_verified IS NULL y created_at > 2× stale_days) de never_verified_recent (page nueva sin oportunidad de verificarse aún) — este matiz evitó el falso positivo masivo “61/61 pages stale” detectado en sesión 10.
  • Pass 5 (opcional, off por defecto): check_sources con fetch GitHub limitado por max_source_fetches para no exceder subrequests.
  • Pass 6: si apply_stale=true, marcado bulk con db.batch().

Workflow bibliotecario-lint.yml, cron diario 30 4 * * *, dry_run por defecto.

Capa 2 — wiki_lint_contradictions (semanal full, diario incremental)

Handler wikiLintContradictions. Agrupa pages active por community (community_id si existe, si no fallback a type como pseudo-community). Para cada par dentro del grupo invoca Haiku con dos snippets de 2000 chars y le pide JSON estructurado {verdict, subject, claim_a, claim_b, reason}. Si verdict=contradict, inserta en bib_wiki_contradictions de forma idempotente.

Dos modos:

  • Incremental: sólo evalúa pares donde al menos una page es reciente (recent_days, default 7). Cron diario dentro del mismo bibliotecario-lint.yml tras el bulk.
  • Full: evalúa todos los pares respetando max_pairs_per_community. Cron semanal bibliotecario-lint-consolidation.yml (0 3 * * 0). Por el límite de subrequests CF Workers, el handler procesa un batch_size por invocación y devuelve next_offset; el workflow pagina con un loop bash hasta next_offset === null (resuelto en sesión 17, commit de47c6f).

Optimizaciones internas: pre-fetch de snippets en paralelo chunked (SNIPPET_CONCURRENCY=10) y cache por slug para no refetchar GitHub.

Toggle de pausa

Ambos workflows respetan la variable BIBLIOTECARIO_PAUSADO. El bulk no se pausa porque es SQL puro y no consume Haiku; el step de contradictions sí. El permite frenar consumo Anthropic durante planes intensivos sin desactivar el cron entero.

Consecuencias

  • Coste predecible: bulk $0/día (sólo SQL D1). Contradictions incremental ~$0,05-0,30/día según pages recientes. Contradictions semanal full ~$0,50-2/run según tamaño del corpus y max_pairs_per_community. Sin la separación, una versión monolítica diaria habría costado decenas de dólares al mes y reventado el budget CF Workers.
  • Latencia mixta aceptada como trade-off: el bulk detecta drift estructural en menos de 24 h (catch rápido para stales y broken_sources). Las contradicciones semánticas tienen latencia de hasta 7 días en el peor caso (contradicción introducida lunes, full scan domingo); el modo incremental diario mitiga este caso si al menos una de las dos pages enfrentadas es reciente.
  • El bulk no detecta contradicciones: limitación explícita asumida. Si una page lleva años en el corpus y otra page lleva años contradiciéndola sin que ninguna se toque en recent_days, sólo el full semanal las cazará.
  • Footgun CF Workers subrequest budget: resuelto en sesión 17 con paginación + chunking. Sigue siendo limitante para corpus muy grandes (>500 pages); habrá que subir MAX_BATCHES o pasar a plan paid si el corpus crece más allá.
  • Inserción idempotente: bib_wiki_contradictions usa pair ordenado + subject como clave para no duplicar contradicciones detectadas en runs sucesivos. Permite ejecutar con apply=true repetidamente sin contaminar la tabla.
  • Default conservador: ambos workflows arrancan con dry_run=true. La promoción a apply=true es decisión manual tras 3-7 días de observación estable.
  • Discrepancia conocida: el handler cuenta backlinks sólo desde related[] YAML en D1, no desde [[wikilinks]] del body. Obsidian lee al revés. Mantener alineados es responsabilidad operativa documentada en NEXT.md.

Status

Accepted. Implementado en sesión 10 (Fase 5 Supercontexto, 2026-04-22), refactor anti-N+1 y anti-LIKE en commit 2319b6c. Refinado en sesión 17 con paginación (de47c6f) y pre-fetch chunked (fc6f0b0). En dry_run por defecto; pendiente subir a apply=true cuando se acumulen 3-7 días de runs estables y el corpus tenga community_id poblado para minimizar falsos positivos del agrupamiento por type.

Véase también

  • [[feature—supercontext—bibliotecario-lint-fase-5]] — feature page de la Fase 5 que implementa este ADR
  • [[feature—biblioteca—wiki-lint-contradictions-pagination]] — refinamiento de paginación que resolvió “Too many subrequests”
  • [[entity—biblioteca—tool—wiki-lint-contradictions]] — entity page del tool MCP
  • [[concept—biblioteca—supercontexto]] — concepto raíz del sistema
  • [[decision—20260424—supercontexto-operativo]] — ADR paralelo: cierre del plan y paso a modo operativo
  • [[decision—20260422—ingest-3-tier]] — ADR paralelo: pipeline de ingest con triage Haiku, complementario al Lint
  • [[decision—20260403—multi-tenancy-rls]] — ADR paralelo: contexto de seguridad sobre el que el Lint opera