ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Extrae vocabulario en inglés de un PDF con la API de Claude y lo guarda para repasarlo con repetición espaciada.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Extrae vocabulario en inglés de un PDF con la API de Claude y lo guarda para repasarlo con repetición espaciada. Aplicación de un solo usuario, sin registro ni cuentas: se entra con una única contraseña.查看完整 README ↗
Extrae vocabulario en inglés de un PDF con la API de Claude y lo guarda para repasarlo con repetición espaciada. Aplicación de un solo usuario, sin registro ni cuentas: se entra con una única contraseña.
Diseño completo: docs/superpowers/specs/2026-09-06-app-vocabulario-design.md
claude-opus-5), que
devuelve vocabulario, verbos frasales y expresiones con traducción,
contexto original y un ejemplo de uso.La pantalla de extracción vive en /extraer, la biblioteca en /biblioteca,
el repaso en /repaso y la búsqueda en el diccionario en /diccionario. Las
cuatro requieren sesión iniciada; / redirige directamente a /extraer.
La pantalla /repaso muestra, una tarjeta a la vez, las palabras que tocan
hoy, en tres grupos y en este orden: las que están en curso (falladas hace
minutos, en aprendizaje o reaprendizaje), las ya aprendidas que vencen hoy,
y por último las nuevas hasta el tope diario. Se pulsa (o se pulsa la barra espaciadora) para ver la traducción
y el ejemplo, y se valora con uno de cuatro botones — Otra vez / Difícil /
Bien / Fácil, también accesibles con las teclas 1-4 — que alimentan el
algoritmo de repetición espaciada FSRS (paquete ts-fsrs) y fijan cuándo
vuelve a tocar esa palabra.
Esta pantalla no consume la API de Claude en ningún momento. Sus dos rutas
— GET /api/repaso/cola y POST /api/repaso/respuesta — solo leen y escriben
en Postgres; no hacen ninguna llamada a Anthropic. Lo único que tiene coste en
toda la aplicación es la extracción de vocabulario desde un PDF (ver
"Coste" más abajo).
Cada respuesta se envía con un identificador propio (answerId) que la
convierte en idempotente: si la app se cierra a mitad de sesión y una
respuesta se reintenta al volver, no se duplica en el historial ni se cuenta
dos veces. El resultado queda escrito en la base de datos en el momento de
responder, así que cerrar la app y reabrirla no hace reaparecer una tarjeta
ya respondida en esa misma cola. (Si una tarjeta valorada "Otra vez" vuelve a
salir al cabo de unos minutos, es el comportamiento normal de FSRS —un paso
de aprendizaje corto—, no una tarjeta que haya "olvidado" su respuesta.)
La tabla settings (una sola fila) guarda los tres ajustes que el usuario
controla. Se tocan desde la pantalla previa del repaso — no hay una pantalla de
ajustes aparte. newCardsPerDay se guarda al salir de su campo, porque es un
ajuste permanente; sessionSize y sessionMode son la elección de esta sesión
y solo se guardan al empezar si "Recordar esta elección" está marcada.
sessionMode, además, solo se guarda si el usuario ha pulsado uno de los tres
botones de colección: cuando el modo guardado se queda sin material hoy, la
pantalla se cae al primero que sí lo tenga, y eso es una circunstancia del día,
no una elección que deba sobrescribir la de mañana.
newCardsPerDay (20 por defecto): cuántas palabras nuevas entran al
día cuando el tamaño de sesión es 0. Es diario: se cuentan las
introducciones ya registradas hoy (review_logs con state = 0 desde la
medianoche de Madrid) y se resta. Con un tamaño de sesión explícito este
tope queda sin efecto (ver sessionSize).sessionSize (0 por defecto): cuántas palabras entran en total en
cada sesión, contando juntas en curso, aprendidas y nuevas. 0 significa
"las que toquen hoy", así que por defecto la app no recorta nada por su
cuenta y manda el tope diario de nuevas de arriba. Con cualquier otro
número manda ese número, incluso por encima del tope diario: pedir 30 da
30, aunque newCardsPerDay solo dejara entrar 5 hoy.sessionMode (mezcla por defecto): de cuál colección salen las
palabras. Los valores válidos son no-aprendidas (en curso y nuevas),
aprendidas (ya aprendidas que venzan) o mezcla (en curso, aprendidas
vencidas y nuevas). Con un tamaño de sesión mayor que 0, aprendidas y
mezcla rellenan además lo que falte adelantando aprendidas que aún no
vencían, la más próxima primero (nunca al azar, para no repetir lo mismo si
se adelanta dos días seguidos); con 0 no se adelanta nada.Cuando vencen más palabras aprendidas de las que caben en la sesión, se sortean: cuáles entran se decide al azar, no por antigüedad ni por id, de modo que ninguna palabra queda sistemáticamente al final de la cola. El sorteo solo actúa cuando hay una decisión que tomar; si caben todas, el orden no se toca.
Dos garantías del recorte:
0 (el valor de fábrica) entran todas
seguro; con un tamaño explícito cuentan, como cualquier otra tarjeta, contra
ese total.app/manifest.ts define un manifiesto de aplicación web (nombre "Vocably",
modo standalone, icono en app/icon.png). Desde un navegador móvil se puede
añadir a la pantalla de inicio y abrirse como una app independiente, sin la
barra del navegador. El start_url del manifiesto es /repaso: abrir la
app desde el icono lleva directamente al repaso del día, no a la extracción.
Extraer de un PDF no es la única forma de meter vocabulario. La pantalla
/diccionario es la segunda puerta: se busca una palabra suelta y se añade a
la biblioteca con un botón, con la misma estructura que ya usa la app —
palabra, significado, ejemplo— y el nivel del MCER puesto a mano (sin nivel
elegido, el botón no guarda).
La búsqueda recorre cuatro escalones y se para en el primero que responde:
| # | Dónde busca | Tarda | Cuesta |
|---|---|---|---|
| 1 | La biblioteca del usuario | instantáneo | 0 € |
| 2 | La tabla del diccionario (cargada de Wikcionario, ver más abajo) | instantáneo | 0 € |
| 3 | El traductor MyMemory, solo si a la entrada le falta el español | ~1 s (5 s como mucho), y se guarda si la acepción es única | 0 € |
| 4 | Claude, solo si se pulsa "Afinar con IA" | ~3 s | unos céntimos |
Si el término ya está en la biblioteca, la pantalla abre con un bloque "Ya
en tu repaso" —la traducción guardada, el nivel y cuándo vuelve a tocar— y
esa acepción no se ofrece para añadirla otra vez; las demás sí. Una palabra
guardada que Wikcionario no trae sale ahí igualmente: decir que "no está en el
diccionario" algo que el usuario ya tiene sería mentira. GET /api/diccionario resuelve los escalones 1
a 3; no llama a Claude nunca. El escalón 4 vive aparte, en
POST /api/diccionario/afinar, precisamente para que se vea de un vistazo que
ninguna otra ruta del diccionario toca la API.
Una palabra puede tener más de un significado que interesa guardar por
separado: bank es la orilla de un río y también donde se guarda el dinero.
El diccionario permite guardar las dos acepciones del mismo término,
distinguidas por una pista —su significado en inglés— guardada en la columna
senseHint de terms. El índice único de terms es ahora sobre
(term_normalized, sense_hint), no solo sobre el término.
En el repaso, esa pista aparece pequeña bajo la palabra, en la cara delantera de la tarjeta y recortada a una línea: dice lo bastante para distinguir bank → orilla de bank → banco sin chivar la traducción al español antes de tiempo.
Las palabras añadidas a mano desde /diccionario cuelgan de una fuente
llamada "Diccionario", con 0 páginas y coste 0 €, creada la primera vez
que hace falta (POST /api/terms, vía anadirDesdeDiccionario). Así la
biblioteca puede filtrar entre lo buscado a mano y lo extraído de un PDF, sin
tener que tocar el esquema de sources para distinguirlos.
Solo lo buscado a mano. Extraer sin IA pasa por la misma función, pero manda el título y el rango de páginas que eligió el usuario y abre su propia fuente, como hace la extracción con IA: colgarlo del "Diccionario" borraría justamente la distinción que esa fuente existe para mantener.
El diccionario en sí (ver "Cargar el diccionario" más abajo) trae español en muy pocas entradas: solo el 1,8 % de lo cargado. El resto se traduce con MyMemory, un servicio gratuito. Se manda solo el término suelto, nunca pegado a su definición: probado a mano, pegarlos rompe la traducción ("come across: to give an impression" vuelve como "venir a través: para dar una impresión", partiendo el verbo frasal en dos).
La traducción se guarda en su fila del diccionario solo cuando el término tiene una única acepción sin español. Ahí, y solo ahí, la respuesta es de verdad de esa acepción: al traductor se le manda el término suelto, así que lo que vuelve es la traducción de la palabra, no la de un significado concreto. Si hay varias acepciones sin español —bank tiene siete entradas—, la traducción se enseña en todas pero no se cachea en ninguna: escribir "banco" en la acepción de orilla y darla por buena la dejaría mal para siempre, porque una fila cacheada no se vuelve a consultar. Preguntar otra vez es gratis; equivocarse en la base, no.
La llamada se corta a los cinco segundos (AbortSignal.timeout). Un servicio
caído ya estaba cubierto, pero uno colgado dejaba esperando a la búsqueda
entera, que para entonces ya tiene el significado y el ejemplo listos para
enseñar. Si MyMemory no contesta, la ficha sale igual sin español y quedan dos
salidas: escribir la traducción a mano en la propia ficha, que es gratis y
es la que manda si se usa, o el botón "Afinar con IA", que cuesta unos
céntimos.
La tabla dictionary_entries sale de un volcado de Wikcionario en inglés (vía
kaikki.org), filtrado a las categorías útiles —sustantivo, verbo, adjetivo,
adverbio, expresión, verbo frasal, interjección— y a las 50.000 palabras
sueltas más frecuentes del inglés; las expresiones de varias palabras
(verbos frasales, idioms) entran todas, sin filtro de frecuencia porque no
aparecen en ninguna lista de frecuencia. Resultado: 181.103 entradas, unos
36 MB una vez en Postgres.
Cargarla es un paso manual que hace una persona una vez, igual que
drizzle-kit push: no forma parte del arranque de la app ni se repite en cada
despliegue, porque el diccionario no cambia. El fichero de datos
(dicc_todo.jsonl.gz) vive fuera de este repositorio.
DATABASE_URL='...' npm run cargar:diccionario -- ~/Vocably-diccionario/dicc_todo.jsonl.gz
El script vacía la tabla y la vuelve a llenar entera: para 181.103 filas es más simple y más seguro que intentar fusionar fila a fila, y el diccionario es material de consulta, no datos del usuario, así que no hay nada que perder al recargarlo.
Los significados en español salen del volcado del Wikcionario español, que es
otro fichero distinto del inglés. Se produce con ~/Vocably-diccionario/filtrar_es.py,
que filtra el volcado de kaikki.org mientras se descarga (los 95 MB no se guardan):
curl -s https://kaikki.org/dictionary/downloads/es/es-extract.jsonl.gz | gunzip | python3 filtrar_es.py
Y se carga con:
DATABASE_URL='...' npm run cargar:espanol -- ~/Vocably-diccionario/dicc_es.jsonl.gz
Es idempotente: volver a cargarlo actualiza en vez de duplicar, y no toca las traducciones que ya trajo MyMemory.
El nivel de cada palabra sale del CEFR-J Vocabulary Profile 1.5, compilado por Yukio Tono (Tokyo University of Foreign Studies), más el Octanove Vocabulary Profile C1/C2 1.0 de Octanove Labs, publicados juntos en Open Language Profiles.
El CEFR-J se puede usar con fines comerciales y no comerciales sin coste, citándolo; el copyright es de Tono Laboratory. El complemento Octanove está bajo CC BY-SA 4.0.
Se obtienen y se cargan así:
cd ~/Vocably-diccionario
curl -sO https://raw.githubusercontent.com/openlanguageprofiles/olp-en-cefrj/master/cefrj-vocabulary-profile-1.5.csv
curl -sO https://raw.githubusercontent.com/openlanguageprofiles/olp-en-cefrj/master/octanove-vocabulary-profile-c1c2-1.0.csv
cat cefrj-vocabulary-profile-1.5.csv octanove-vocabulary-profile-c1c2-1.0.csv > cefr.csv
DATABASE_URL='...' npm run cargar:niveles -- ~/Vocably-diccionario/cefr.csv
Son unas 9.900 entradas. Es idempotente: recargarlo actualiza en vez de duplicar.
npm install
cp .env.example .env.local # y rellenar las cuatro variables (ver más abajo)
DATABASE_URL='...' npx drizzle-kit push # crea las tablas en tu Postgres
npm run dev # http://localhost:3000
drizzle-kit push no lee .env.local automáticamente (el proyecto no usa
dotenv), así que hay que pasarle DATABASE_URL explícitamente en el mismo
comando, tanto en local como contra producción.
Todas son solo de servidor (no hay ninguna variable pública NEXT_PUBLIC_*).
Están descritas en .env.example:
| Variable | Para qué sirve |
|---|---|
ANTHROPIC_API_KEY | Autentica las llamadas a la API de Claude que hacen la extracción. |
DATABASE_URL | Cadena de conexión de Postgres (Neon). |
APP_PASSWORD | La única contraseña con la que se entra a la app. |
SESSION_SECRET | Firma la cookie de sesión. Debe ser una cadena larga y aleatoria. |
SESSION_SECRET no tiene valor por defecto, a propósito: si falta, la app
debe fallar de forma ruidosa en vez de arrancar con una firma predecible. Ahora
mismo ese fallo es un error genérico de Node, no un mensaje claro — si al
arrancar algo revienta de forma rara, comprueba primero que SESSION_SECRET
está definido.
.env.local está en .gitignore; nunca debe llegar a un commit.
La comprobación de APP_PASSWORD al entrar es una comparación normal de
cadenas (!==), que no es de tiempo constante, y no hay ningún límite de
intentos: nada impide probar contraseñas una detrás de otra. Para una app de un
solo usuario se acepta a cambio de no añadir estado ni dependencias, pero
obliga a que APP_PASSWORD sea larga y aleatoria (por ejemplo, la salida de
openssl rand -hex 24), no una contraseña adivinable.
npm test
Ninguna prueba llama a la API de Claude ni toca la base de datos real: usan
respuestas grabadas y un Postgres en memoria (PGlite). Comandos útiles además
de npm test:
npx tsc --noEmit # comprobación de tipos
npx eslint . # estilo y errores comunes
proxy.ts, no en middleware.ts. Es
intencional: en esta versión de Next.js, middleware.ts se ejecuta siempre
en el runtime Edge, que no soporta node:crypto, y la firma de la sesión lo
necesita. Renombrarlo a middleware.ts rompería el login.vitest.config.mts (con extensión .mts, no
.ts). Es a propósito: mantiene la salida de npm test libre de avisos.neon-serverless (con Pool), no
neon-http. No son intercambiables aquí: saveExtraction necesita una
transacción real para fusionar términos repetidos de forma atómica, y eso
solo lo da neon-serverless.Primer despliegue (base de datos nueva): crear una base gratuita en
Neon y copiar su cadena de conexión, importar este
repositorio en Vercel, definir en el proyecto las cuatro variables de
.env.example y aplicar npx drizzle-kit push una vez antes o justo después
del primer despliegue para crear todas las tablas.
Para desplegar esta fase (fase 2) sobre una base de datos que ya existe y tiene vocabulario real, seguir estos pasos en orden:
Generar una API key nueva de Anthropic y una contraseña nueva para la base de datos de Neon. Configurar ambas:
ANTHROPIC_API_KEY y DATABASE_URL..env.local para desarrollo local, con los mismos valores nuevos..env.local está en .gitignore y no debe llegar nunca a un commit.
La migración de esta fase añade a review_logs una columna answer_id que
es NOT NULL y no tiene valor por defecto. Si esa tabla ya tuviera filas,
la migración fallaría a mitad de camino. Antes de tocar nada, conectar con la
base de producción (por ejemplo desde el editor SQL de Neon, o con psql) y
ejecutar:
SELECT count(*) FROM review_logs;
Debe devolver 0. Si devuelve cualquier otro número, parar aquí y preguntar antes de seguir: significa que ya hay historial de repasos guardado y la migración, tal como está escrita, lo rompería.
De paso, anotar también cuántos términos hay en la biblioteca, para poder comprobar después de la migración que no se ha perdido ninguno:
SELECT count(*) FROM terms;
Con la base de datos de Neon ya usando la contraseña nueva del paso 1:
DATABASE_URL='<cadena de Neon>' npx drizzle-kit push
Esto aplica las dos migraciones pendientes de una vez:
drizzle/0001_adorable_boomerang.sql crea la tabla settings y añade
learning_steps a card_states y answer_id a review_logs;
drizzle/0002_little_yellowjacket.sql añade reviews_per_session a
settings.
drizzle-kit push no lee .env.local, así que hay que pasarle
DATABASE_URL explícitamente en el propio comando, con la cadena de conexión
real de Neon.
Después llegaron drizzle/0003_lame_cassandra_nova.sql y
drizzle/0004_youthful_zarda.sql (las tablas del diccionario, ya aplicadas), y
la de la pantalla previa del repaso: drizzle/0005_organic_scorpion.sql, que
añade session_size y session_mode a settings y borra
reviews_per_session.
drizzle-kit push no ejecuta esos ficheros .sql. Compara db/schema.ts
con la base de datos real y propone él mismo las sentencias, preguntando por
cada una que pueda perder datos. El orden cuidadoso de
0005_organic_scorpion.sql —los dos ADD COLUMN primero y el DROP COLUMN al
final— protege a la suite de pruebas, que sí ejecuta el fichero, y no a
producción.
Por eso hay que leer lo que pregunta en vez de ir aceptando. Tres columnas
cambian a la vez y push puede ofrecer interpretarlo como un renombrado:
si se acepta renombrar reviews_per_session a session_mode, esa columna se
queda con tipo integer donde el esquema espera text y sin session_size
ninguno, y getAjustes falla en todas las peticiones. Las respuestas correctas
son crear dos columnas nuevas y borrar la vieja: tres cambios, ningún
renombrado.
La migración no pierde datos. Las dos columnas nuevas llegan con valor por
defecto (session_size a 0, session_mode a 'mezcla'), así que una fila ya
existente las recibe rellenas y una tabla vacía sigue vacía. Lo único que puede
perderse es el valor de reviews_per_session, y su defecto era 0, exactamente
el defecto del nuevo session_size: quien no lo hubiera tocado —en producción
la tabla settings estaba vacía— no pierde nada en absoluto. Vale igual haya
fila o no.
Comprobar que las columnas y la tabla nuevas existen:
SELECT column_name FROM information_schema.columns
WHERE table_name = 'card_states' AND column_name = 'learning_steps';
SELECT column_name FROM information_schema.columns
WHERE table_name = 'review_logs' AND column_name = 'answer_id';
SELECT to_regclass('public.settings');
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'settings' AND column_name IN ('session_size', 'session_mode');
Las tres primeras deben devolver una fila (la tercera, el nombre settings en
vez de NULL). La cuarta debe devolver dos, y con los tipos exactos:
session_size integer y session_mode text. Si session_mode sale
integer, se aceptó el renombrado del paso 3: hay que borrar esa columna y
volver a pasar push antes de desplegar.
Y comprobar que la columna vieja ya no está:
SELECT column_name FROM information_schema.columns
WHERE table_name = 'settings' AND column_name = 'reviews_per_session';
Esta es la única de la lista que se comprueba al revés: debe devolver cero
filas. Mientras siga ahí, push no ha aplicado la migración entera.
Y comprobar que el vocabulario existente sigue intacto, comparando con el número anotado en el paso 2:
SELECT count(*) FROM terms;
Con las variables de entorno del paso 1 ya actualizadas en Vercel, desplegar esta rama (redeploy manual, o el push/merge que dispare el despliegue automático).
Dos operaciones de la app tienen coste, porque son las únicas que llaman a la
API de Claude: la extracción de vocabulario desde un PDF, y el botón "Afinar
con IA" de /diccionario, que el usuario pulsa a voluntad. Todo lo demás —
el repaso en /repaso (cargar la cola, responder tarjetas, cambiar los
ajustes) y la búsqueda en /diccionario (la biblioteca, la tabla del
diccionario y el traductor MyMemory, sus tres primeros escalones)— no llama a
Anthropic nunca y no añade coste, por muchas veces que se use.
Cada extracción muestra en la interfaz su coste real, calculado a partir de
los tokens que ha consumido esa llamada. El modelo es claude-opus-5: 5 $ por
millón de tokens de entrada y 25 $ por millón de tokens de salida.
Medición real (primera extracción contra un PDF propio, 2026-09-06): 26 términos por unos 0,07 €, es decir alrededor de 0,003 € por término. Mil términos en la biblioteca costarían menos de 3 €. El diseño estimaba entre 15 y 30 céntimos por 7-8 páginas; la realidad ha salido aproximadamente la mitad de cara.
"Afinar con IA" usa el mismo modelo y la misma fórmula de coste, pero por palabra suelta en vez de por lote de páginas; en la pantalla no se muestra un importe calculado, solo el aviso fijo "Afinar cuesta unos céntimos. Todo lo demás de esta pantalla es gratis."
El botón desaparece en cuanto la acepción está guardada: afinar reescribe la
caché del diccionario, no la ficha ya creada en terms, así que pulsarlo
entonces cobraría por un cambio que la tarjeta de repaso no llegaría a ver.
La fase 1 no se considera terminada hasta comprobar, contra el despliegue real y con un PDF propio, todo lo siguiente:
La fase 2 no se considera terminada hasta que, en el móvil y en días distintos (esto no se puede comprobar en una sola sesión de un tirón), se verifique todo lo siguiente:
/repaso con su pantalla previa —qué colección y cuántas palabras, antes de
ver la primera tarjeta—, algoritmo FSRS, los tres ajustes de la tabla
settings (tope diario de palabras nuevas, tamaño de sesión y colección que
se repasa), manifiesto para instalar la app en el móvil con start_url en
/repaso. No consume la API de Claude en ningún momento (ver "Coste").
Pendiente solo la prueba de aceptación de más arriba, que hace el usuario en
su móvil.environment: "node",
sin jsdom ni Testing Library, así que ninguna prueba puede pulsar un botón
ni comprobar qué se pinta. Lo que se prueba de la interfaz son sus funciones
puras (lib/review-session.ts, guardarAjuste). Dos fallos reales de esta
fase —el campo del tope que se quedaba deshabilitado y el bloque de ajustes
que desaparecía al terminar la sesión— solo aparecieron conduciendo un
navegador a mano.SESSION_SECRET falta, el fallo al arrancar es un error genérico de
Node, no un mensaje claro pensado para esto.