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:
- Un árbol de contenido, tres renderizadores — sitio, portal, libros.
data/curriculum/phase_meta.yamles la columna de navegación compartida.- Los quizzes fluyen YAML → seeder → DB; las notas se calculan, nunca se escriben a mano.
- Tres bases de datos SQLite separadas, mantenidas aparte a propósito.
- Los libros se construyen una vez y se consumen en todas partes.
- Las credenciales son tokens firmados sin estado — no se guarda nada en el servidor.
- 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:
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 bundlewindow.LYNX_STUDYque hay detrás del planificador de estudio interactivo (selector de ritmo + Gantt + tarjetas).build_glossary_data— el bundlewindow.LYNX_GLOSSARYque alimenta los tooltips de conceptos al pasar el cursor.build_lang_pairs— el mapa de URLs EN↔ESwindow.LYNX_LANG_PAIRSque 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 (endist/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.pylos siembra enportal.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 books → mkdocs build --strict → wrangler 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¶
- Guía de inicio — prepara el entorno y corre el sitio en local.
- Estudiar cualquier capítulo — el índice de referencia que
genera
build_phase_meta, para que puedas saltar a cualquier punto del currículo. - Descargar (PDF y EPUB) — los libros offline descritos en §3.