diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..624f6c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copie para .env (server/.env). NUNCA commite o .env real. +# LiteLLM proxy local (gratuito, dentro da assinatura Token Plan) +LITELLM_BASE_URL=http://10.8.0.1:4000/v1 +LITELLM_API_KEY=coloqu…aqui + +# Modelos +CHAT_MODEL=qwen3.8-max # juiz de feedback / geracao de missões +FAST_MODEL=qwen3.8-flash # drills e tarefas leves +OMNI_MODEL=qwen3-omni-flash # transcricao de audio do aluno (ASR tolerante a sotaque) + +# Porta do server +PORT=8787 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd84e28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build/ +.env +.env.local +*.log +.DS_Store +Thumbs.db +server/cache/ +app/dist-ssr/ +coverage/ diff --git a/README.md b/README.md index a5bd3ee..91f006f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,151 @@ -# cursoitaliano +# 🇮🇹 Italiano Vivo +App de italiano **situacional-first** com voz livre e scoring de inteligibilidade. +Aprenda como uma criança: errando mas se comunicando. + +## Visão + +- **Scoring em 3 eixos**: Sentido transmitido / Naturalidade / Precisão gramatical +- **Tolerância estilo criança**: erro gramatical que não impede entendimento = sucesso comunicativo +- **Voz livre**: grave, receba feedback estruturado, nunca "Errado" +- **Salvavidas (Salvavita)**: treine estratégias de comunicação quando a palavra some +- **SRS de chunks**: revisão espaçada de frases, não palavras isoladas +- **PWA offline**: catálogo, áudio e SRS funcionam sem conexão +- **CEFR can-do**: badges baseados em competências reais ("você consegue reclamar de um item com defeito") + +## Como Rodar + +### Pré-requisitos +- Node.js ≥ 20 +- LiteLLM proxy rodando (ou configure `LITELLM_BASE_URL` para outro endpoint OpenAI-compatible) + +### Instalação + +```bash +# Instalar dependências do monorepo +npm install + +# Configurar variáveis de ambiente +cp .env.example server/.env +# Edite server/.env com sua chave da API +``` + +### Desenvolvimento + +```bash +# Roda app + server simultaneamente +npm run dev +``` + +- App: http://localhost:5173 +- Server: http://localhost:8787 +- Health check: http://localhost:8787/health + +### Build + +```bash +npm run build # Build server + app +npm run typecheck # Verificação de tipos +``` + +## Endpoints do Server + +| Método | Rota | Descrição | +|--------|------|-----------| +| GET | `/health` | Status do servidor | +| POST | `/api/judge` | Avalia fala do aluno em 3 eixos (sentido, naturalidade, gramática) | +| POST | `/api/transcribe` | Transcreve áudio via modelo omni (ASR tolerante a sotaque) | +| GET | `/api/tts-proxy?text=...&voice=elsa\|diego` | Serve áudio TTS pré-gerado do cache local | + +### POST /api/judge + +**Entrada:** +```json +{ + "missionId": "bambino-m01", + "scenario": "Al bar", + "learnerGoalPt": "Pedir um café", + "expectedIt": "Vorrei un caffè, per favore", + "utteranceText": "io volere caffe", + "transcriptRaw": "io volere caffe", + "audioBase64": "..." +} +``` + +**Saída:** +```json +{ + "meaning_conveyed": "full", + "naturalness": 45, + "natural_version": "Vorrei un caffè, per favore", + "grammar": [ + { + "it_said": "io volere", + "problem_pt": "Infinitivo em vez de conjugação", + "fix_it": "vorrei / voglio", + "severity": "minor" + } + ], + "next_move": { + "italian": "Certo! Un caffè. Lo vuole al banco o al tavolo?", + "portuguese": "Claro! Um café. Quer no balcão ou na mesa?" + }, + "praise_or_push": "Quase lá! Um italiano diria: Vorrei un caffè, per favore" +} +``` + +## Estrutura de Pastas + +``` +cursoitaliano/ +├── app/ # PWA React + Vite + TypeScript +│ ├── public/ # manifest.webmanifest, ícones +│ └── src/ +│ ├── db/db.ts # IndexedDB via Dexie +│ ├── engine/srs.ts # SM-2 simplificado +│ ├── types/content.ts # Tipos do conteúdo JSON +│ ├── App.tsx # Componente principal +│ ├── main.tsx # Entry point +│ └── styles.css # Design system +├── server/ # Node + Express + TypeScript +│ ├── cache/audio/ # Áudios TTS pré-gerados (offline) +│ └── src/ +│ ├── lib/llm.ts # Cliente LiteLLM singleton +│ ├── routes/ +│ │ ├── judge.ts # Motor de feedback 3 eixos +│ │ ├── transcribe.ts # ASR via omni model +│ │ └── tts.ts # Proxy de áudio TTS +│ └── index.ts # Express app +├── content/ # JSONs versionados (gerados por pipeline) +├── audio/ # Assets de áudio por missão +├── SPEC.md # Especificação completa do produto +├── .env.example # Template de variáveis de ambiente +└── package.json # Monorepo root (npm workspaces) +``` + +## Modelos Utilizados + +| Modelo | Uso | Variável | +|--------|-----|----------| +| qwen3.8-max | Juiz de feedback, geração de missões | `CHAT_MODEL` | +| qwen3.8-flash | Drills rápidos, fallback de quota | `FAST_MODEL` | +| qwen3-omni-flash | Transcrição de áudio (ASR) | `OMNI_MODEL` | + +Todos acessados via LiteLLM proxy (`LITELLM_BASE_URL`). + +## Roadmap + +- [x] Esqueleto do monorepo (app PWA + server LiteLLM) +- [ ] Geração de conteúdo: Trilha Bambino (12 unidades) + Viaggio M1–M6 +- [ ] Pipeline de geração de áudio TTS (edge-tts) +- [ ] Service Worker para modo offline completo +- [ ] Minijogo "descreva sem nomear" (circunlóquio) +- [ ] Modo carro/shadowing com medição de lag +- [ ] Trilhas restantes (Viaggio M7–M15, Vivere completa) +- [ ] Preparatório CILS/CELI B1 cidadania +- [ ] Wrapper Android (Capacitor) +- [ ] Calibração do judge com votos "isso estava certo" + +## Licença + +Privado — uso pessoal. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..4626130 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,90 @@ +# Italiano Vivo — Especificação do Produto (v1) + +App de italiano **situacional-first**, com voz livre, scoring de **inteligibilidade** ("errado mas entendido" conta como sucesso), treino de **estratégias de comunicação** (circunlóquio, pedir a palavra que falta) e **modo viagem offline**. Do extremo básico (A0/Pre-A1) ao avançado (C1), ancorado nos *can-do statements* do CEFR / ALTE Social & Tourist Can-Dos. + +## 1. Por que este app existe (lacunas comprovadas na pesquisa) + +| Dor | Estado do mercado | Nossa resposta | +|---|---|---| +| Avaliação binária ❌/✅ | Babbel/Duolingo/Speakly marcam fala correta como errada; nenhum separa precisão de sentido | Scoring em 3 eixos: **Sentido transmitido / Naturalidade / Precisão gramatical**. Saída padrão: *"Quase lá — o italiano diria: …"*, nunca "Errado" | +| Vocabulário temático solto | Babbel começa com queijo de cabra; Duolingo prende o aluno no path | Dia 1: aluno escolhe as próprias situações-âncora ("quero pedir um lanche", "preciso reclamar do aquecedor") | +| Falta de estratégia quando a palavra some | Zero apps de italiano treinam circunlóquio (evidência: Coluzzi 2022; Dörnyei 1995) | Módulo dedicado **"Salvavita"**: *Come si dice…?*, *Può ripetere più lentamente?*, descrever sem nomear. Penaliza **abandonar a mensagem**, não o erro | +| Syllabus gramatical (presente→congiuntivo) | Todos os grandes | Unidades = **missões comunicativas** (aeroporto, farmácia, hotel…). Gramática entra como insumo da missão, nunca como capítulo | +| Voz livre sem memória | Apps de IA conversacional: "no vocab list, no grammar log, no review schedule" | Conversa livre → extrai as frases que quebraram → rebuild → reaparecem espaçadas **dentro de uma situação nova** | +| Offline confiável + áudio-only | Só Pimsleur é hands-free, e 9% das reviews negativas citam offline quebrado | PWA com Service Worker + catálogo 100% local; TTS/áudio empacotados; IA só quando online, com degradação suave | +| ASR hostil ao aprendiz | Monossílabos ignorados, correções circulares | ASR via LLM multimodal (tolerante a sotaque) + botão **"isso estava certo"** que vira dado de re-treino; sensível a geminadas (*pala/balla*, *nono/nonna*) | +| Billing predatório | Queixa nº1 (renovação silenciosa, expiração antecipada) | **Compra única por trilha** ou lifetime. Sem streak-guilt, sem conteúdo removido | + +## 2. Persona e escopo +- **Henrique viajante**: foi à Itália, travou no balcão. Quer o básico de criança ("io voglio / io ho bisogno / mi scusi, non so come si dice") encaixado em contexto real, progredindo até conversa adulta. +- Escopo **tudo**: viajante + expat/burocracia (permesso, ASL, banca, affitto), porque há exigência legal objetiva (B1 cidadania CILS/CELI) e disposição a pagar por resultado mensurável. + +## 3. Metodologia implementada (mecânica de app) + +| Princípio | Mecânica | +|---|---| +| Aquisição natural (como criança) | Fase 1 só áudio+imagem, sem escrita; texto desbloqueado após N exposições; produção cedo porém curta | +| Comprehensible input i+1 | Diálogos com % de cognatos controlado pelo nível; missão seguinte libera com comprehension ≥ ~80% | +| Chunks / lexical approach | Unidade mínima = bloco pronto (`Mi scusi, non ho capito — può ripetere più lentamente?`), nunca palavra isolada | +| CLT / Task-Based Learning | Cada missão tem **critério de sucesso comunicativo** ("marque uma visita médica sem inglês"). O app avalia a meta atingida, depois expõe os gaps | +| Output practice (Swain) | Após conversa livre: extrair 3 frases tentadas-e-quebradas → rebuild → re-gravar → reencontrar 3 dias depois | +| SRS | Revisão de **frases-chunks** (SM-2 simplificado), reintroduzidas dentro de diálogo novo, não como flashcard morto | +| Shadowing | Modo carro/corrida: áudio nativo em velocidade real, gravação sobreposta, medição de lag; protocolo Arguelles (blind → transcript → read+shadow) | +| Estratégias de comunicação (CS) | Minijogo "descreva sem nomear" cronometrado; cartões de resgate; auto-repair pontuado positivamente | +| CEFR can-do | Badge = **"Você agora consegue: reclamar de um item com defeito"**, com verificação por voz. Pre-A1 → B2/C1 | + +## 4. Trilhas e missões (espinha dorsal) + +**Trilha Bambino (A0–A1)** — núcleo da ideia: quero/não quero, tenho/preciso, gosto, dói, é grande/pequeno, mais/menos, isto/aquilo, números, sim/não/forse, e principalmente os **salvavidas**: *come si dice…? / non capisco / ripeta piano / come si scrive? / scusi, non conosco la parola*. + +**Trilha Viaggio (A1–B2)** — aeroporto e alfândega · check-in de voo · táxi/ônibus/trem · hotel (checar, reclamar do quarto, pedir extra) · bar e restaurante (pedir, alergia, conta, gorjeta, reserva) · supermercado/padaria/lanche ("vorrei un panino con…" + descrever o que não sei o nome) · farmácia e médico (sintomas, dor, dosagem) · banco e câmbio · aluguel de carro · compras e devolução · reclamar/negociar/recusar · fazer amizade/small talk · emergência e polícia · telefone/online burocrático. + +**Trilha Vivere (B1–C1)** — questura/permesso, ASL/sanità, SPID, comune/anagrafe, casa e landlord, scuola dei figli, lavoro, preparazione CILS/CELI B1 cittadinanza. + +Cada missão = `scenario` com: objetivos can-do, chunks essenciais, diálogos-âncora (áudio), armadilhas reais (tu vs Lei, cortesia italiana, "magari/dai/vabbè"), role-play de voz livre, e checklist de sobrevivência offline. + +## 5. Motor de feedback (o diferencial) + +Pipeline por tentativa de fala: +1. **ASR** (qwen omni-flash, tolerante a sotaque) → transcrição + confusão provável. +2. **Judge** (qwen3.8-max) produz JSON estruturado: + - `meaning_conveyed`: full | partial | failed + - `naturalness`: score + versão que um italiano diria + - `grammar`: erros classificados (grave=gera mal-entendido / leve / cosmético) + - `next_move`: continuação do diálogo como italiano faria + - `rescue_card`: se o aluno travou por falta de palavra → ensina o salvavita aplicável +3. **Regra de ouro:** `meaning_conveyed=full` ⇒ missão avança mesmo com erros graves leves; feedback sempre termina com a frase reconstruída para repetir. +4. Botão "isto estava certo" → registra dissidência (dataset futuro de calibração). + +## 6. Arquitetura técnica + +``` +cursoitaliano/ + app/ # PWA React+Vite+TS (IndexedDB/Dexie, Workbox SW, MediaRecorder, Web Speech playback) + src/lib/ai.ts # cliente OpenAI-compatible -> LiteLLM (http://10.8.0.1:4000/v1) + src/engine/ # judge, scoring, srs, mission graph + src/content/ # JSONs gerados (missões, chunks, diálogos, salvavidas) + server/ # Node TS: /transcribe /judge /tts /generate-mission (cache + rate-limit + fallback chain) + content/ # pipeline de geração (Node) -> JSON versionado no repo + audio/ # assets TTS pré-gerados (por missão) + SPEC.md README.md .env.example +``` + +- **IA**: tudo via LiteLLM local (sem custo PAYG): chat/judge `qwen3.8-max`, drills rápidos `qwen3.8-flash`, voz/ASR `qwen3-omni-flash` (audio base64), TTS via Edge TTS vozes `it-IT-ElsaNeural`/`it-IT-DiegoNeural` (geração em lote, pacote offline; fallback Web Speech API). +- **Offline**: catálogo, chunks, diálogos e áudio empacotados no build + IndexedDB; role-play de voz exige rede; **phrasebook de resgate e SRS funcionam 100% offline**; fila de tentativas para julgamento quando voltar a conexão. +- **Segurança**: chave do proxy fora do bundle — `.env` no `server/`, app fala só com o próprio server; `.env` no .gitignore; `.env.example` sem segredo. + +## 7. MVP (o que está sendo construído agora) +1. Repo inicializado, config, scripts. +2. Conteúdo gerado: trilha Bambino completa (12 unidades) + Viaggio M1–M6 (aeroporto, táxi/trem, hotel, restaurante, bar/lanche, farmácia/médico), cada uma com can-dos, 12–20 chunks, 2 diálogos, role-play prompts, salvavidas. +3. Server com `/judge` (3-eixos), `/transcribe` (omni), `/tts`. +4. App PWA: home por missões, player de diálogo com áudio, **cabine de voz** (gravar → judge → feedback "quase lá"), minijogo "descreva sem nomear", SRS de chunks, modo resgate offline. +5. README com rodar-local, arquitetura e roadmap (trilhas restantes, shadowing mode, CILS prep, Android nativo). + +## 8. Roadmap pós-MVP +- Missões 7–15 da trilha Viaggio + trilha Vivere inteira (geração pelo mesmo pipeline). +- Modo carro/shadowing com medição de lag, análise fonêmica de geminadas. +- Export de erro como currículo (log de interlíngua + revisão adaptativa). +- Trilha CILS/CELI B1 (cidadania) com simulado. +- Wrapper Android (Capacitor) com áudio offline por pacote. +- Calibração do judge com os votos "isto estava certo". diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..4139cf4 --- /dev/null +++ b/app/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Italiano Vivo + + +
+ + + diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..89a9138 --- /dev/null +++ b/app/package.json @@ -0,0 +1,26 @@ +{ + "name": "italiano-vivo-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "dexie": "^4.0.11", + "dexie-react-hooks": "^1.1.7", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/app/public/manifest.webmanifest b/app/public/manifest.webmanifest new file mode 100644 index 0000000..f51d622 --- /dev/null +++ b/app/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Italiano Vivo", + "short_name": "ItalianoVivo", + "description": "Aprenda italiano com voz livre — situacional-first, tolerante a erros", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#009246", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/app/public/sw.js b/app/public/sw.js new file mode 100644 index 0000000..bd6fb3a --- /dev/null +++ b/app/public/sw.js @@ -0,0 +1,91 @@ +/** + * Service Worker — Italiano Vivo + * Estratégia: + * - Shell (HTML/CSS/JS do app): cache-first + * - /content/* e /audio/*: cache-first com network fallback + runtime caching + * - /api/*: network-only (nunca cachear) + * + * Objetivo: modo resgate offline — frases + áudio já baixados funcionam no avião. + */ + +const CACHE_NAME = "italiano-vivo-v1"; +const SHELL_ASSETS = ["/", "/index.html"]; + +// ─── Install: pré-cache do shell ─── +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)), + ); + self.skipWaiting(); +}); + +// ─── Activate: limpar caches antigos ─── +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE_NAME) + .map((k) => caches.delete(k)), + ), + ), + ); + self.clients.claim(); +}); + +// ─── Fetch: roteamento por padrão de URL ─── +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + const pathname = url.pathname; + + // API: sempre network-only + if (pathname.startsWith("/api/")) { + return; // deixa o browser fazer o fetch normal + } + + // Content e Audio: cache-first com network fallback + runtime caching + if (pathname.startsWith("/content/") || pathname.startsWith("/audio/")) { + event.respondWith(cacheFirstWithRuntimeCache(event.request)); + return; + } + + // Shell e demais assets: cache-first + event.respondWith(cacheFirst(event.request)); +}); + +/** + * Cache-first: tenta cache, se não achar vai na rede (sem salvar no cache). + * Usado para shell/assets que são pré-cachados no install. + */ +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + return fetch(request); +} + +/** + * Cache-first com runtime caching: tenta cache, se não achar vai na rede + * e salva a resposta no cache para uso offline futuro. + * Usado para /content/* e /audio/* que são baixados sob demanda. + */ +async function cacheFirstWithRuntimeCache(request) { + const cached = await caches.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + // Só cachear respostas OK (não cachear 404/500) + if (response.ok) { + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch (_err) { + // Offline e sem cache: retorna erro genérico + return new Response("Offline — Italiano Vivo", { + status: 503, + statusText: "Service Unavailable", + headers: { "Content-Type": "text/plain" }, + }); + } +} diff --git a/app/src/App.tsx b/app/src/App.tsx new file mode 100644 index 0000000..a687b13 --- /dev/null +++ b/app/src/App.tsx @@ -0,0 +1,664 @@ +/** + * App principal do Italiano Vivo. + * Roteamento simples por estado (sem react-router). + * Componentes funcionais com CSS próprio. + */ +import { useState, useEffect, useRef, useCallback } from "react"; +import type { ContentIndex, Mission, MissionChunk, DialogueLine } from "./types/content.js"; +import { db, type AttemptRecord } from "./db/db.js"; +import { useLiveQuery } from "dexie-react-hooks"; +import { review as srsReview, verdictToQuality, createCard, getDueCards } from "./engine/srs.js"; +import type { SRSCard } from "./db/db.js"; + +// ─── Tipos de veredito do judge ─── +interface JudgeVerdict { + meaning_conveyed: "full" | "partial" | "failed"; + naturalness: number; + natural_version: string; + grammar: Array<{ + it_said: string; + problem_pt: string; + fix_it: string; + severity: "blocks_understanding" | "minor" | "cosmetic"; + }>; + rescue_card?: { + come_si_dice: string; + frase_it: string; + explicacao_pt: string; + }; + next_move: { + italian: string; + portuguese: string; + }; + praise_or_push: string; +} + +type ViewState = "home" | "mission" | "review"; + +// ─── Flag italiana ─── +function ItalianFlag() { + return ( +
+ + + +
+ ); +} + +// ─── Home: lista de missões por trilha ─── +function HomeView({ onSelectMission }: { onSelectMission: (id: string) => void }) { + const [index, setIndex] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const res = await fetch("/content/index.json"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as ContentIndex; + if (!cancelled) setIndex(data); + } catch (e) { + if (!cancelled) { + console.warn("Falha ao carregar conteúdo, usando array vazio:", e); + setIndex({ schemaVersion: 1, missions: [] }); + setError("Conteúdo ainda não gerado. As missões aparecerão após o pipeline de geração."); + } + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { cancelled = true; }; + }, []); + + if (loading) return
🇮🇹 Carregando missões...
; + + const trails = ["bambino", "viaggio", "vivere"] as const; + + return ( +
+ {error &&
{error}
} + + {trails.map((trail) => { + const missions = index?.missions.filter((m) => m.trail === trail) ?? []; + if (missions.length === 0) return null; + + return ( +
+

+ Trilha {trail.charAt(0).toUpperCase() + trail.slice(1)} +

+ {missions.map((m) => ( +
onSelectMission(m.id)}> +
+

{m.titleIt}

+ {trail} +
+

{m.titlePt}

+

+ CEFR: {m.cefr} · {m.canDos.length} objetivos +

+
+ ))} +
+ ); + })} + + {(!index || index.missions.length === 0) && !error && ( +
+

Nenhuma missão disponível ainda.

+
+ )} +
+ ); +} + +// ─── Player de áudio ─── +function AudioPlayer({ audioKey, label }: { audioKey: string; label?: string }) { + const [playing, setPlaying] = useState(false); + const audioRef = useRef(null); + + const play = useCallback(() => { + if (audioRef.current) { + audioRef.current.currentTime = 0; + audioRef.current.play().catch(() => {}); + } + }, []); + + return ( + <> + +