Skip to content

English · Español

Cómo encaja todo

Lynx Cortex parece varios productos — un sitio de documentación, un portal interactivo para el estudiante y un conjunto de libros offline — pero se construye a partir de un único árbol de contenido, con tres renderizadores y se publica por tres carriles de despliegue independientes. Esta página es el mapa: dónde viven las fuentes únicas de verdad, cómo el pipeline de documentación las convierte en un sitio estático, cómo el portal de FastAPI reutiliza el mismo contenido, cómo funcionan las credenciales y cómo despliegan los tres carriles sin pisarse.

Dónde encaja esta página

Esta es la versión publicada y bilingüe de la referencia de ingeniería que se mantiene en ARCHITECTURE.md, en la raíz del repositorio. El archivo de la raíz es la fuente de verdad escueta para quienes contribuyen; esta página es el recorrido legible. Cuando ambos discrepen, manda el archivo de la raíz, pero están pensados para mantenerse sincronizados.

La visión de conjunto

Un único árbol de Markdown y YAML alimenta cada salida. Los generadores son deterministas e idempotentes: las mismas entradas producen siempre el mismo sitio, los mismos datos del portal y los mismos libros.

flowchart TB
  subgraph SOT["Fuentes únicas de verdad"]
    DOCS["docs/phase-NN-*/<br/>README · theory · lab · break · quizzes<br/>(espejos EN + ES)"]
    META["data/curriculum/*.yaml<br/>phase_meta · phase_study_meta · phase_references"]
    QYAML["data/quizzes/*.yaml<br/>data/exams/*.yaml"]
    GLOSS["GLOSSARY.md / .es.md"]
  end

  subgraph GEN["Generadores — just docs-gen"]
    G["build_phase_meta · build_phase_extras<br/>build_study_plan · build_glossary_data<br/>build_lang_pairs"]
    GB["build_books.py"]
  end

  DOCS --> G & GB
  META --> G
  GLOSS --> G

  G --> SITE["mkdocs build --strict → site/"]
  GB --> BOOKS["dist/books/*.{pdf,epub}"]

  SITE -->|deploy-docs.yml| CF["Cloudflare Pages (estático)"]
  BOOKS -->|release-books.yml| GHREL["GitHub Release · tag=books"]
  GHREL -->|gh release download| SITE

  subgraph PORTAL["Portal — FastAPI"]
    CURR["curriculum.py lee docs/ + phase_meta.yaml"]
    DB["portal.db"]
    VAULT["vault.db"]
    SRS["*_review.sqlite"]
  end

  DOCS --> CURR
  META --> CURR
  QYAML -->|portal_seed_quizzes.py| DB
  PORTAL -->|deploy-portal.yml| FLY["Fly.io + volumen persistente<br/>+ Cloudflare delante"]

Siete hechos sostienen todo el sistema:

  1. Un árbol de contenido, tres renderizadores — sitio, portal, libros.
  2. data/curriculum/phase_meta.yaml es la columna de navegación compartida.
  3. Los quizzes fluyen YAML → seeder → DB; las notas se calculan, nunca se escriben a mano.
  4. Tres bases de datos SQLite separadas, mantenidas aparte a propósito.
  5. Los libros se construyen una vez y se consumen en todas partes.
  6. Las credenciales son tokens firmados sin estado — no se guarda nada en el servidor.
  7. Tres carriles de despliegue independientes, filtrados por ruta.

1. Las fuentes únicas de verdad

Todo lo de aguas abajo es una proyección de un pequeño conjunto de entradas escritas a mano. Edita estas; nunca edites los artefactos generados.

Fuente Qué posee Quién la consume
docs/phase-NN-*/ Toda la prosa de fase: README, theory, lab, break, quizzes — espejos EN + ES Sitio, portal, libros
data/curriculum/phase_meta.yaml Columna de navegación entre fases: slug, títulos, resumen, requires (informativo), teaches Generadores del sitio + portal
data/curriculum/phase_study_meta.yaml Modelo de esfuerzo para el planificador de estudio build_study_plan.py
data/curriculum/phase_references.yaml "Lecturas adicionales" por fase build_phase_extras.py
data/quizzes/*.yaml, data/exams/*.yaml Contenido de quizzes y exámenes portal_seed_quizzes.py → DB del portal
GLOSSARY.md / .es.md Términos y definiciones de conceptos build_glossary_data.py

Política bilingüe (§A17)

Cada documento se refleja como X.md (inglés) y X.es.md (español), y ambos son igual de autoritativos. Cuando se edita una fuente en inglés, su espejo en español se actualiza en el mismo commit. El conmutador EN/ES de la cabecera es JavaScript de cliente puro, gobernado por un mapa de URLs generado (window.LYNX_LANG_PAIRS) — no hay ida y vuelta al servidor. Los identificadores de código, las rutas, los comandos de shell y los mensajes de commit se quedan en inglés.

2. El pipeline del sitio de documentación

El sitio es mkdocs-material sin adornos: docs_dir: docs, site_dir: site, overrides del tema en overrides/ y solo el plugin search integrado. Toda la interactividad es JavaScript estático generado — no hay servidor de aplicación detrás de la documentación.

just docs ejecuta el pipeline completo:

just docs   # docs-gen (5 generadores) → asegurar libros → mkdocs build --strict
flowchart LR
  subgraph INPUTS["Entradas escritas a mano"]
    D["docs/*.md (EN + ES)"]
    M["phase_meta.yaml"]
    GL["GLOSSARY.md"]
  end

  subgraph DOCSGEN["just docs-gen — determinista"]
    LP["build_lang_pairs<br/>→ lang-pairs.js"]
    PM["build_phase_meta<br/>→ Requires/Teaches + reference.md<br/>VALIDA slugs · cobertura · aciclicidad"]
    PX["build_phase_extras<br/>→ mapa de conceptos + lecturas"]
    SP["build_study_plan<br/>→ window.LYNX_STUDY + planificador"]
    GD["build_glossary_data<br/>→ tooltips window.LYNX_GLOSSARY"]
  end

  D --> LP & PM & PX & SP & GD
  M --> PM & SP
  GL --> GD

  LP & PM & PX & SP & GD --> BUILD["mkdocs build --strict"]
  BUILD --> S["site/ (estático)"]

Los cinco generadores (todos en scripts/, todos deterministas) inyectan bundles de datos y bloques de Markdown proyectados en el árbol antes de que corra MkDocs:

  • build_phase_meta — proyecta los bloques Requires / Teaches en cada README y construye el índice de referencia. Es además la puerta de integridad del currículo: valida slugs, cobertura y aciclicidad del grafo de prerrequisitos y rompe el build si fallan.
  • build_phase_extras — el widget de mapa de conceptos por fase y los bloques de "lecturas adicionales".
  • build_study_plan — el bundle window.LYNX_STUDY que hay detrás del planificador de estudio interactivo (selector de ritmo + Gantt + tarjetas).
  • build_glossary_data — el bundle window.LYNX_GLOSSARY que alimenta los tooltips de conceptos al pasar el cursor.
  • build_lang_pairs — el mapa de URLs EN↔ES window.LYNX_LANG_PAIRS que usa el conmutador de idioma de la cabecera.

Estricto por diseño

El build corre con --strict, de modo que un enlace interno roto o una entrada de navegación huérfana es un fallo duro, no un aviso. Por eso el grafo de prerrequisitos se valida por adelantado: una errata en un slug falla rápido, en local, antes de poder llegar al despliegue.

3. Los libros offline

El mismo Markdown se convierte en libros descargables en PDF y EPUB — uno por idioma. El generador (scripts/build_books.py) usa a propósito sin pandoc, sin LaTeX, sin Node:

  • WeasyPrint renderiza HTML → PDF con portada, índice y saltos de página.
  • ebooklib escribe el EPUB.
  • matplotlib mathtext convierte cada expresión $…$ en un SVG cacheado (en dist/books/_mathcache) para que las ecuaciones se vean idénticas en cualquier lector sin tener que instalar fuentes.

Cada fase es un capítulo; los archivos de theory, lab, break y quiz son secciones. Y, lo importante, los libros se construyen una sola vez mediante el workflow release-books.yml y se publican en el GitHub Release books — y luego el despliegue de documentación los descarga. Nunca se reconstruyen en el carril rápido de documentación, lo que mantiene ese carril ágil.

Por qué los libros son un artefacto de release, no se versionan

Los libros son regenerables a partir del árbol de contenido, así que versionarlos hincharía la historia de git con binarios que se desincronizan. En su lugar, CI los construye en su propio carril lento y el carril de documentación trae los archivos terminados con gh release download. En local, just docs los construye una vez si faltan.

4. El portal del estudiante

El portal (src/miniportal/) es una aplicación renderizada en servidor con FastAPI + SQLModel + Jinja2 + HTMX — sin single-page app. create_app(config) es el único punto de entrada, válido tanto en producción (config por variables de entorno) como en tests (una config explícita contra una base de datos en memoria).

Reutiliza el currículo en vez de copiarlo: curriculum.py lee el mismo árbol docs/ y el phase_meta.yaml con que se construye el sitio, en solo lectura — el portal nunca escribe de vuelta en docs/. Los prerrequisitos aparecen como insignias informativas, nunca como cierres.

Ruta de la petición y middleware

Cada petición atraviesa una pila de middleware fija. Starlette ejecuta de fuera hacia dentro, así que el orden en que se añaden en create_app es el inverso al de ejecución:

flowchart TB
  REQ([Petición entrante]) --> SEC["SecurityHeaders<br/>(el más externo)"]
  SEC --> OBS["RequestObservability<br/>(/metrics)"]
  OBS --> RL["RateLimiter"]
  RL --> BSL["BodySizeLimit"]
  BSL --> INJ["InjectionFilter<br/>(el más interno)"]
  INJ --> ROUTERS

  subgraph ROUTERS["Routers — cada uno una fábrica build_router"]
    direction LR
    A["auth · dashboard · academic · locale"]
    B["notes · quiz (+SRS) · downloads"]
    C["admin · admin_overrides · exam_engine"]
    D["lab_tracker · capstone_tracker · grading · obs_extended"]
  end

  ROUTERS --> DBS

  subgraph DBS["Tres almacenes SQLite separados"]
    direction LR
    P[("portal.db<br/>almacén principal SQLModel")]
    V[("vault.db<br/>AES-256-GCM · minivault")]
    R[("*_review.sqlite<br/>SRS SM-2 · minireview")]
  end

Tres bases de datos, mantenidas aparte

Los tres almacenes SQLite están separados a propósito para que un compromiso o una corrupción en uno no alcance a los demás:

  • portal.db — el almacén principal SQLModel (usuarios, intentos, notas, apuntes).
  • vault.db — un vault cifrado con AES-256-GCM (minivault).
  • *_review.sqlite — el almacén de repetición espaciada SM-2 (minireview), SQLite en crudo.

Autenticación e i18n

La autenticación es Argon2id (t=3, 64 MiB, p=2) con un pepper de servidor; las sesiones, el CSRF y los tokens de invitación se firman con itsdangerous a partir de cfg.session_secret; las cookies son HttpOnly / Secure / SameSite. La autorización sube por una escalera de dependencias — current_student → require_teacher_or_admin → require_admin — y las rutas que mutan llevan una comprobación CSRF de doble envío. La traducción de la interfaz es un simple diccionario t() de Python (EN + ES), sin gettext.

5. Flujo de contenido y de dónde salen las notas

La regla cardinal: las notas se calculan, nunca se escriben a mano. Nadie teclea una calificación en un archivo; grading/service.py deriva el informe a partir de las filas de intentos reales del estudiante.

flowchart LR
  PROSE["Prosa de fase / theory / labs"] --> DOCS["docs/ (única SoT)"]
  DOCS --> SITE2["Sitio"]
  DOCS --> PORTAL2["Portal"]
  DOCS --> BOOKS2["Libros"]

  QY["data/quizzes · data/exams (*.yaml)"] -->|portal_seed_quizzes.py| PDB[("portal.db")]
  LEARN([Intentos del estudiante]) --> ATT["filas de intentos en portal.db"]
  ATT -->|grading/service.py| REPORT["compute_report → banda de 5 niveles<br/>PASS_MARK = 50"]
  REPORT --> CRED["Credenciales"]
  • La prosa de fase, la teoría y los labs viven una sola vez en docs/ y se renderizan a las tres superficies.
  • Los metadatos entre fases viven una sola vez en phase_meta.yaml, compartidos por los generadores del sitio y el portal.
  • Los quizzes y exámenes se escriben como YAML y portal_seed_quizzes.py los siembra en portal.db.
  • La calificación lee las filas de intentos y produce una banda de 5 niveles con PASS_MARK = 50.

6. Credenciales — sin estado y verificables

Los certificados, expedientes y carnets llevan tokens verificables sin estado. No se guarda nada en el servidor; la verificación es una comprobación HMAC pura.

flowchart LR
  G["grading.compute_report"] --> B["banda + payload"]
  B --> T["credentials.make_token<br/>base64url(json) + HMAC-SHA256(session secret)"]
  T --> DOC["HTML de certificado / expediente / carnet<br/>+ código de huella + URL /verify?token="]
  DOC --> PUB["/verify público"]
  PUB --> CHK{"¿HMAC válido?<br/>(tiempo constante)"}
  CHK -->|sí| OK["Auténtico — renderiza el payload"]
  CHK -->|no| NO["Rechaza"]

El token es JSON en base64url más un HMAC-SHA256 del session secret, incrustado en el HTML de la credencial junto a un código de huella legible por personas y una URL /verify?token=. El endpoint público /verify comprueba el HMAC en tiempo constante; como no se persiste nada, no hay base de datos que manipular. Un certificado está condicionado a nombre legal + versión de los términos aceptada + nota global ≥ 50.

7. Despliegue — tres carriles, cinco workflows

Cinco workflows de GitHub Actions en .github/workflows/, repartidos en tres carriles de despliegue independientes, filtrados por ruta, más dos puertas puras. Un cambio en la documentación nunca dispara un despliegue del portal, y viceversa.

flowchart TB
  subgraph GATES["Puertas — sin despliegue"]
    CI["ci.yml<br/>ruff + mypy(src) + pytest"]
    PT["portal-tests.yml<br/>pytest del portal + checks de Dockerfile/compose"]
  end

  subgraph LANES["Tres carriles de despliegue"]
    DD["deploy-docs.yml<br/>generadores → gh release download books<br/>→ mkdocs build --strict → wrangler pages deploy"]
    RB["release-books.yml<br/>WeasyPrint + mathcache → Release books"]
    DP["deploy-portal.yml<br/>flyctl deploy --remote-only"]
  end

  DD --> CFP["Cloudflare Pages (sitio estático)"]
  RB --> REL["GitHub Release · tag=books"]
  REL -.consumido por.-> DD
  DP --> FLYIO["Fly.io + volumen persistente + Cloudflare delante"]
Workflow Papel
ci.yml Lint (ruff) + tipos (mypy src) + tests (pytest). Una puerta, sin despliegue.
deploy-docs.yml Generadores → gh release download booksmkdocs build --strictwrangler pages deploy site. "GitHub construye, Cloudflare publica."
release-books.yml Construye los cuatro libros (WeasyPrint, mathcache) y publica el Release books.
deploy-portal.yml flyctl deploy --remote-only a Fly.io (necesita FLY_API_TOKEN).
portal-tests.yml Pytest del portal más checks de Dockerfile / compose. Una puerta, sin despliegue.

El contenedor del portal y la invariante de un único escritor

El portal se publica desde docker/portal.Dockerfile: un build en dos etapas que corre como usuario no-root portal (uid 10001), venv en /opt/venv, arrancado con python scripts/portal_run.py. fly.toml despliega la app lynx-cortex-portal en la región cdg (París) con un volumen persistente lynx_data montado en /var/lib/lynx-cortex, scale-to-zero al estar inactivo, y los secretos inyectados mediante scripts/fly-secrets.example.sh.

Por qué una sola máquina es una virtud, no un límite

Un volumen de Fly se enlaza a exactamente una máquina a la vez, lo que impone físicamente la invariante de un único escritor de SQLite. Nunca puede haber dos procesos escribiendo en la misma base de datos, porque nunca puede haber dos máquinas montando el mismo volumen. Las copias de seguridad las gestiona portal_backup.py (API de online-backup de SQLite), con _snapshot_rotate y un _restore protegido. No subas el número de máquinas por encima de uno sin antes salir de SQLite.


A dónde ir ahora