Documentação técnica
🧭 Bússola — Tutor de IA CEFIS
Tutor conversacional que combina catálogo CEFIS + RAG sobre transcrições + geração sob demanda. Cada citação abre o player no segundo exato da aula.
O produto
Visão geral
A Bússola é uma tutora de IA construída sobre o catálogo da CEFIS. O aluno conversa em português natural, e o agente responde citando aulas reais — cada citação vem com um link que abre o player CEFIS no segundo exato da explicação.
O que ela entrega na demo
- /login — auth real contra a API CEFIS (cookie HttpOnly).
- /onboarding — chat curto (4-5 perguntas) que salva perfil + lacuna crítica.
- /plano — semana de estudos gerada por um Curador IA combinando aulas reais + reforço gerado.
- /tutor — chat estilo WhatsApp que responde com RAG sobre as transcrições, com sidebar de cursos e indexação on-demand.
- WhatsApp — a mesma experiência de tutor disponível via zap (serviço Bun separado em Railway).
Persona da demo
Contador querendo melhorar negociação
A persona original (contador → tributário) foi pivotada porque o sample público de transcrições só tinha VTT do curso 1132 — Negociação e Gestão de Conflitos (Metodologia Harvard). Mantemos o universo CEFIS contabilista, mas a lacuna crítica vira negociação — encaixa no único conteúdo com transcrição real disponível.
Critérios de avaliação (100 pts)
| Critério | Peso | Onde a Bússola joga |
|---|---|---|
| Funcionalidade | 30 | Fluxo principal /login → /plano → /tutor online em Vercel |
| Integração CEFIS | 25 | Auth real, catálogo via /api/v3, deep-link no segundo |
| Qualidade da IA | 20 | RAG + structured output + fallback graceful |
| Inovação | 15 | Deep-link no segundo exato + tutor no WhatsApp |
| UX | 10 | Shell WhatsApp-dark, streaming, suggested questions |
Diferencial
Killer feature: deep-link no segundo exato
Em vez de espalhar inovação em várias direções (podcast, “X min para Y”, gamificação), o produto foca em uma coisa que ninguém vai pensar: quando o tutor cita uma aula, o link abre o player CEFIS exatamente no momento da explicação.
Como funciona
- Transcrições
.vttsão parseadas preservando timestamps por linha (cue). - Cues são agrupados em chunks de ~800 caracteres, cada chunk guarda
start_secondseend_seconds. - O embedding do chunk vai para
pgvector. - No tutor, o RAG retorna chunks com timestamp. O modelo cita o momento (
mm:ss) e a UI renderiza um cartão▶ Abrir na CEFIScom deep-link?t=<segundos>.
buildLessonDeepLink(courseId, lessonId, startSeconds)
→ https://cefis.com.br/portal/cursos/{courseId}?lesson={lessonId}&t={s}Por que isso ganha pontos
Custa ~1h para implementar (vs ~2h de podcast generator), aproveita a única coisa única do sample (timestamps), entrega um “momento de uau” visual instantâneo (clica e abre no segundo) e nenhum competidor vai pensar nisso primeiro.
Tecnologia
Stack técnica
| Camada | Escolha | Por quê |
|---|---|---|
| Framework | Next.js 16 (App Router, RSC, src/) | Streaming nativo, RSC para reduzir JS no cliente, integração 1-clique com Vercel |
| UI | Tailwind v4 + componentes shadcn-like próprios | Visual profissional sem dependência pesada; tema escuro grátis |
| IA | Vercel AI SDK + OpenRouter (primário) + OpenAI (fallback) | OpenRouter destrava trocar modelo ao vivo durante demo; OpenAI cobre Whisper/TTS/embeddings |
| Embeddings | OpenAI text-embedding-3-small (ou Google gemini-embedding-001 a 1536d) | 1536 dims casam com pgvector; Google opcional via /admin |
| Banco | Supabase Postgres + pgvector (schema dedicado bussola) | Postgres + vetores + Storage + Auth em um único provider |
| Persistência de chat | Tabela tutor_messages com citations jsonb | Histórico opcional, best-effort (não bloqueia resposta) |
| Serviço Bun separado em Railway → Evolution API v2 | Workload long-running (webhooks contínuos) separado do Next.js stateless | |
| Deploy | Vercel (web) + Railway (services/wa) | Edge-friendly + cron/long-running ao lado |
Decisões de modelo
- Chat:
gpt-4o-minivia OpenRouter (openai/gpt-4o-mini). Em falha, fallback transparente para OpenAI direto. - Embeddings: sempre OpenAI ou Google direto — OpenRouter não cobre. Não misture providers (vetores de providers diferentes não são comparáveis e destroem o RAG).
- Whisper / TTS: sempre OpenAI direto.
Sistema
Arquitetura
Dois deploys independentes (web e WhatsApp) compartilham o mesmo Supabase. Toda credencial sensível vive na tabela app_settings; código nunca toca envs de chave de API em produção (exceto fallbacks).
┌──────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ / /login /onboarding /plano │
│ /tutor (shell estilo WhatsApp) /admin /docs │
│ (header "Continuar estudo pelo zap" / footer "Receber no WhatsApp"│
│ └→ WhatsappModal → POST /api/whatsapp/invite) │
└────────────────────────┬─────────────────────────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────────────────────────┐
│ Next.js 16 (Vercel — RSC + Route Handlers) │
│ │
│ /api/auth/* (cefis-login, logout, me) │
│ /api/onboarding (chat curto + save_profile tool) │
│ /api/curator/generate-plan │
│ /api/tutor (RAG + structured output) │
│ /api/courses (lista indexados + sample) │
│ /api/courses/ingest │
│ /api/plan/* (active, by id) │
│ /api/admin/* (settings, ingest-sample) │
└────────────┬───────────────────────────────┬─────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Supabase (Postgres) │ │ AI providers │
│ schema "bussola" │ │ │
│ │ │ • OpenRouter (chat) │
│ • users │ │ • OpenAI (chat/emb/ │
│ • user_profile │ │ whisper/tts) │
│ • study_plan │ │ • Google (emb) │
│ • plan_items │ └──────────────────────┘
│ • cefis_courses │
│ • cefis_lessons │ ┌──────────────────────┐
│ • cefis_lesson_ │ │ CEFIS API │
│ chunks (vector) │ │ │
│ • cefis_course_ │ │ v1: /api/v1/login │
│ embeddings │ │ (auth) │
│ • tutor_messages │ │ v3: catálogo, │
│ • tutor_sessions │ │ cursos, aulas, │
│ • app_settings │ │ trilhas │
│ • user_profile │ └──────────────────────┘
│ .phone (onboard.) │
│ • whatsapp_messages │
│ • user_whatsapp │
└──────────────────────┘
▲
│
│ (compartilha)
│
┌────────────┴─────────────────────────────────────────────────────┐
│ services/wa — Bun service (Railway) │
│ │
│ POST /v1/evolution/webhook?secret=... │
│ ├─ HMAC check │
│ ├─ Resolve aluno via user_profile.phone (E.164 sem +) │
│ ├─ Chama askTutor() reusando lib do app web │
│ └─ Envia resposta via Evolution API v2 │
└──────────────────────────────────────────────────────────────────┘Princípio de isolamento
Tudo do app vive no schema bussola do Supabase. O cliente (src/lib/supabase.ts) é tipado SupabaseClient<any, "bussola", "bussola">, o que evita colidir com outros projetos hospedados no mesmo Supabase.
UX
Fluxos do usuário
Fluxo principal (caminho feliz)
/ → /login → /onboarding (6 perguntas, inclui WhatsApp) → /plano → /tutor
│ │
│ ├─ header: "Continuar estudo pelo WhatsApp"
└─ phone → user_profile.phone └─ sidebar footer: "Receber no WhatsApp"
│
└─ WhatsappModal → invite (Bússola te chama no zap)/login — auth real CEFIS
- Form HTML simples (email + senha CEFIS).
- POST
/api/auth/cefis-login→CefisClient.login()→ API CEFIS v1. - Upsert em
bussola.userscomcefis_user_id,cefis_api_key, etc. - Set cookie
bussola_session(HttpOnly, Secure, SameSite=Lax) com o UUID interno do user. - Redirect para
/onboardingou/plano.
/onboarding — chat curto
Server pré-carrega o primeiro firstName do user. Componente cliente manda histórico de mensagens para /api/onboarding; rota gera a próxima pergunta com genObject + Zod até ter:
goal— objetivo de aprendizadoavailable_minutes_per_daylearning_style(visual / auditory / kinesthetic / mixed)- Lacuna crítica → grava
skill_assessmentcomstatus='lacuna_critica'
Quando o modelo retorna complete: true, o front mostra o CTA Gerar meu plano, que dispara POST /api/curator/generate-plan.
/plano — semana sob medida
Renderiza o plano ativo (study_plan + plan_items), agrupado por dia, com badges por source (aula CEFIS, trilha, resumo IA…). Cada item com cefis_course_id + cefis_lesson_id ganha um botão ▶ Abrir na CEFIS com buildLessonDeepLink().
/tutor — shell tipo WhatsApp
Layout dark com sidebar (lista de cursos indexados + disponíveis no sample) e área de chat. Selecionar um curso na sidebar passa courseId e courseTitle no payload, escopando o RAG. O botão + Indexar novo curso abre um agente de onboarding que aceita um ID de curso, valida contra o sample local, e dispara /api/courses/ingest com log em tempo real.
Onboarding sem login
O tutor funciona sem auth — getCurrentUserId() retorna null e o histórico não é persistido. Útil para showcasing rápido na banca.
Retrieval-Augmented Generation
Pipeline RAG
Ingestão
- Sample local em
trascriptions_sample/output/:details.jsonpor curso +.vttpor aula (quando existe). parseVtt()emsrc/lib/vtt.tsconverte cues do WebVTT em{ startSeconds, endSeconds, text }.chunkCues()agrupa em blocos de ~800 caracteres (max 1200), quebrando preferencialmente em fim de sentença e preservando start/end no menor cue.embed()(OpenAI ou Google, conformeapp_settings) gera vetores 1536d.- Insert em
cefis_lesson_chunkscomlesson_id, chunk_index, chunk_text, start_seconds, end_seconds, embedding. - Metadados do curso (title + summary + goals + keywords) viram um único embedding em
cefis_course_embeddingspara o RAG de catálogo.
Busca
Dois RPCs Postgres expostos no schema bussola:
match_lesson_chunks(query_embedding, match_threshold, match_count)— RAG profundo (chunks com timestamp).match_courses(query_embedding, match_threshold, match_count)— RAG light (metadados de curso).
Default: threshold 0.7, top-k 5 (ajustável em /admin). Quando o usuário escopa um curso na sidebar, o tutor baixa o threshold para 0.4 e busca 60 candidatos antes de filtrar client-side por course_id.
Generation
O tutor monta o prompt com:
- System prompt fixo (personalidade Bússola + regras).
- Lista numerada de chunks (até 600 chars cada) com
[mm:ss]e similaridade. - Lista de cursos sugeridos.
Resposta sai por genObject() com schema Zod (answer, used_chunk_indices, suggest_course_indices, grounded_in_cefis). A UI usa used_chunk_indices para renderizar cards de citação com deep-link.
Fallback transparente
Se o RAG não devolve nada relevante, o modelo precisa começar com “Esse tópico ainda não está no nosso catálogo indexado, mas posso te explicar:” e setar grounded_in_cefis=false. O front pinta a mensagem com uma nota discreta avisando que é conhecimento geral.
Agentes
Agentes de IA
A Bússola tem hoje 6 agentes ativos + 1 especializado. Cada um com responsabilidade específica e schema estruturado via generateObject (Vercel AI SDK + Zod). Página visual em /agentes.
| Agente | Endpoint | Input → Output | Responsabilidade |
|---|---|---|---|
| Onboarding | /api/onboarding | Conversa turn-by-turn → user_profile + 1 skill_assessment | Extrai goal, minutes/day, deadline, learning_style, weak_area em até 4 perguntas |
| Diagnóstico | /api/diagnostic | Goal → 4-6 sub-skills com self-scores | Decompõe objetivo em sub-habilidades, classifica em domina/lacuna parcial/lacuna crítica |
| Curador | /api/curator/generate-plan | Perfil + skills + RAG → study_plan + 4-7 plan_items | Plano de 1 semana adaptado ao learning_style (visual→vídeo, auditory→podcast, kinesthetic→quiz). Modes: auto, course, custom |
| Tutor (killer) | /api/tutor | Query → answer + citations[] com deep-link mm:ss | RAG no pgvector + LLM cita aulas CEFIS no segundo exato. Aceita planId pra contextualizar |
| Quick-Learn | /api/quick-learn | { topic, minutes } → highlights + takeaway | Resumo calibrado pelo tempo disponível: 1min→1 bullet, 30min→6 bullets profundos |
| Gerador de Conteúdo | /api/generate-content | { planItemId | topic, kind } → markdown ou quiz | Materializa generated_summary (markdown) ou generated_quiz (3-5 questões) |
| Estudo da Aula (especializado) | /api/plan-item-study | planItemId → markdown 800-1500 palavras estruturado | Conteúdo extenso por item: importance, conceitos, exemplos práticos, erros, ações, reflexão. Persistido pra revisar |
Bot WhatsApp (não é agente, é roteador)
/api/whatsapp/processrecebe mensagens via Bun, detecta saudações (oi/menu/ajuda) → mostra menu numerado; comandos 1/2/3 → lista cursos / link app / instrução; mídia (áudio/imagem/vídeo) → tenta Whisper pra áudio com fallback "em construção"; texto livre → delega pro Tutor.
Por que não LangChain / LlamaIndex
Curva de aprendizado alta para um dia. Vercel AI SDK + generateObject com Zod resolve 90% do que precisamos com 1/5 do código. Os agentes compartilham infra: embed() (Google gemini-embedding-001 com fallback OpenAI), genObject() (OpenRouter → OpenAI fallback) e ragSearch().
Banco de dados
Schema do banco
Tudo no schema dedicado bussola. Migrations em supabase/migrations/bussola/*.sql aplicadas em ordem.
Tabelas principais
| Tabela | Função |
|---|---|
app_settings | Singleton (id=1) com chaves de API + modelos + flags |
users | Espelha conta CEFIS — cefis_user_id, name, avatar + journey_xp cache |
user_profile | Saída do onboarding (6 perguntas): goal, minutos/dia, learning_style, deadline, professional_experience, phone E.164 (fonte canônica do WhatsApp) |
skill_assessment | Sub-skills com score/status/importance — onboarding + /diagnostico |
study_plan | Plano semanal (title, total_weeks, active, rationale) — vários por user |
plan_items | Items por dia (source, source_ref, cefis_course/lesson/track_id, duration_minutes, status) |
cefis_courses | Cache local dos cursos indexados (metadados + cefis_url) |
cefis_lessons | Aulas dos cursos indexados |
cefis_lesson_embeddings | Chunks de transcrição com start/end seconds + embedding(1536) |
cefis_course_embeddings | Embedding único por curso (metadados) — RAG light |
tutor_messages | Mensagens persistidas (best-effort) com citations jsonb — base de XP |
whatsapp_link_codes | OTP de pareamento (6 chars A-F0-9, TTL 10min, uso único). Gerado por /api/whatsapp/link gated em login CEFIS, consumido pelo webhook quando o aluno envia o código pelo zap. |
user_whatsapp | Fonte canônica do vínculo phone ↔ user_id (1-1) — criada pelo OTP redeem. Inclui linked_at, last_reminder_sent_at, last_quiz_sent_at pro throttle do cron. |
whatsapp_messages | Log raw in/out direction, kind, content, citations, evolution_message_id |
progress_log | Eventos por plan_item (started, completed, …) |
generated_content | Conteúdo IA materializado — kind (summary/quiz/pdf/podcast), plan_item_id FK pra estudo extenso |
study_groups | Grupos WhatsApp do Plano Empresarial — creator_user_id ou creator_phone, evolution_group_jid, participants jsonb, expires_at +7d |
Migrações aplicadas (ordem)
20260526000000_init.sql Schema base, RLS, RPCs match_* 20260526000001_whatsapp.sql whatsapp_link_codes (legado OTP) + user_whatsapp + whatsapp_messages 20260526000002_enable_rls.sql RLS policies por tabela 20260526000003_openrouter_key.sql app_settings.openrouter_api_key 20260526000004_google_api_key.sql app_settings.google_api_key 20260526000005_groups_and_journey.sql study_groups + journey_xp + last_reminder_sent_at 20260526000006_study_groups_anonymous.sql creator_user_id nullable + creator_phone (beta aberto) 20260526000007_generated_content_plan_item Liga generated_content ao plan_item pra revisar/regerar 20260526000008_grants_refresh.sql service_role ALL + ALTER DEFAULT PRIVILEGES 20260527000000_user_profile_phone.sql user_profile.phone (fonte canônica do WhatsApp — substitui OTP) 20260527000001_user_whatsapp_last_quiz.sql user_whatsapp.last_quiz_sent_at (throttle do cron diário)
RPCs (Postgres functions)
-- RAG profundo: chunks de transcrição com timestamp
match_lesson_chunks(query_embedding vector(1536),
match_threshold float DEFAULT 0.7,
match_count int DEFAULT 5)
RETURNS TABLE(lesson_id, course_id, course_title, lesson_title,
lesson_position, chunk_text, start_seconds, end_seconds,
similarity, course_cefis_url, lesson_cefis_url)
-- RAG light: cursos por metadados
match_courses(query_embedding vector(1536),
match_threshold float DEFAULT 0.5,
match_count int DEFAULT 3)
RETURNS TABLE(course_id, title, subtitle, summary, duration_seconds,
lesson_count, category_ids, cefis_url, similarity)Índices
cefis_lesson_chunks:ivfflat (embedding vector_cosine_ops)+gin (chunk_text gin_trgm_ops)para keyword fallback.users.cefis_user_idUNIQUE — idempotência no upsert pós-login.study_plan(user_id, active)— busca rápida do plano ativo.
Reference
Rotas e API
Páginas
| Rota | Tipo | Função |
|---|---|---|
| / | RSC | Landing — CTAs para login, tutor e PWA install |
| /login | Client | Form CEFIS (email + senha) |
| /onboarding | RSC + Client | Chat curto que monta perfil |
| /diagnostico | RSC + Client | Quiz adaptativo de sub-skills com sliders |
| /plano | RSC | Plano ativo + switcher de N planos + cards com Estudar/Revisar/ChannelToggle |
| /plano?id=<uuid> | RSC | Carrega plano específico (ownership via loadPlan) |
| /tutor | RSC + Client | Shell WhatsApp-style com sidebar de cursos, planos, Plano Empresarial, Jornada do Herói, engrenagem de preferências |
| /tutor?planId=&q= | Client | Auto-submete pergunta com contexto do plano (vindo do botão Estudar agora) |
| /agentes | RSC | Página visual com os 6 agentes — cards + flow diagram |
| /sobre | RSC | Portfólio + descrição da Bússola |
| /admin | Client | Backoffice protegido por senha (env) |
| /docs | RSC | Esta página |
API routes
| Endpoint | Método | Função |
|---|---|---|
| /api/auth/cefis-login | POST | Auth real contra CEFIS v1, set cookie |
| /api/auth/logout | POST | Limpa cookie de sessão |
| /api/auth/me | GET | Devolve user atual (ou 401) |
| /api/profile | GET/PATCH | Lê/atualiza user_profile (gate CEFIS login) |
| /api/journey | GET | Snapshot da Jornada do Herói (XP, level, streak, ladder, recentDays) |
| /api/onboarding | POST | Agente Onboarding turn-by-turn (structured) |
| /api/diagnostic | POST | phase=start gera sub-skills; phase=submit grava skill_assessment |
| /api/curator/generate-plan | POST | Curador — body { mode: auto|course|custom, courseId?, customName?, customGoal? } |
| /api/tutor | POST | RAG + resposta com citações; aceita planId pra biasar |
| /api/quick-learn | POST | Resumo IA calibrado por { topic, minutes } |
| /api/generate-content | POST | Materializa generated_summary ou generated_quiz a partir de plan_item ou tópico |
| /api/plan-item-study | GET/POST | GET retorna estudo salvo (markdown 800-1500 palavras); POST gera+persiste |
| /api/plan/active | GET | Plano ativo do user |
| /api/plan/list | GET | Todos os planos do user (active + arquivados) |
| /api/plan/[id] | GET | Plano por ID (ownership check) |
| /api/courses | GET | Cursos indexados + disponíveis no sample |
| /api/courses/ingest | POST | Indexa curso do sample (VTTs + embeddings) |
| /api/whatsapp/group | POST/GET | Cria grupo no zap (até 5 participantes, expira 7d). Beta: anônimo OK |
| /api/whatsapp/group/add-participant | POST | Adiciona até esgotar quota de 5 num grupo existente |
| /api/whatsapp/invite | POST | Bot inicia conversa com o número informado (rate-limit 3/15min) |
| /api/whatsapp/process | POST | Endpoint interno chamado pelo service Bun (HMAC) |
| /api/whatsapp/webhook | * | Deprecated — 410. Webhook agora vive em services/wa |
| /api/cron/reminders | GET | Vercel Cron (0,30 11-23 UTC) → lembretes WhatsApp baseados em disponibilidade |
| /api/admin/settings | GET/POST | Lê/grava app_settings (auth por senha) |
| /api/admin/ingest-sample | GET/POST | Dry-run ou ingestão completa do sample |
Sobre /api/whatsapp/webhook
O webhook foi movido para services/wa (deploy independente em Railway, runtime Bun). A rota Next.js virou um stub 410 só para a Evolution não fazer retry infinito enquanto o painel não é atualizado.
Integração
Integração CEFIS
Cliente único em src/lib/cefis.ts cobre v1 (auth) e v3 (catálogo). Atenção:
Diferença crítica nos headers
- v1:
Authorization: {key}— sem prefixo Bearer. - v3:
Authorization: Bearer {key}— com Bearer.
| Método | Endpoint | Uso |
|---|---|---|
| login(email, pass) | POST v1 /api/v1/login | Pega key + user — salva em users |
| me() | GET v1 /api/v1/user/me | Valida key ainda válida |
| listCourses(params) | GET v3 /courses | Catálogo paginado (search, categories, filter) |
| getCourse(id) | GET v3 /courses/{id} | Detalhe de curso (summary, goals, keywords) |
| listLessons(courseId) | GET v3 /courses/{id}/lessons | Aulas + stream_sources |
| listTracks(params) | GET v3 /tracks | Trilhas curadas pela CEFIS |
| getTrack(id) | GET v3 /tracks/{id} | Detalhe + cursos da trilha |
| listCertificates(params) | GET v3 /performance/certificates | Histórico de certificados do aluno |
Retry
withRetry(fn, maxAttempts=3) faz backoff exponencial (1s, 2s, 4s) para respostas 429 e 5xx. Outros erros propagam direto.
Deep-link
buildLessonDeepLink(courseId, lessonId, startSeconds)
→ "https://cefis.com.br/portal/cursos/{courseId}?lesson={lessonId}&t={Math.floor(startSeconds)}"
buildCourseDeepLink(courseId)
→ "https://cefis.com.br/portal/cursos/{courseId}"Formato confirmado pelo time CEFIS (2026-05-26). O parâmetro t vai como query extra — se o player suportar, abre no segundo exato; se não, é ignorado e o link continua válido. Ponto único de mudança em src/lib/tutor-agent.ts.
Canal alternativo
WhatsApp via Evolution
O canal de chat via zap mora num serviço separado em services/wa/ (runtime Bun, deploy Railway). A separação foi deliberada:
Por que serviço dedicado
- Webhooks da Evolution são contínuos (long-running) e não casam com Vercel stateless.
- Permite cold-start zero e logging próprio.
- Bun é mais rápido para HMAC + JSON parsing em volume.
- Aponta a Evolution direto, sem hop no Next.js.
Pareamento por OTP (gated por login CEFIS)
O aluno NUNCA conecta na instância Evolution diretamente
Existe uma única instância da Bússola, já conectada e administrada. O aluno nunca escaneia QR, nunca configura número, nunca toca na Evolution. Ele apenas envia um código de 6 caracteres da própria conta dele para o número público do bot — e isso vincula o WhatsApp à conta CEFIS.
O fluxo é gated em login CEFIS: /api/whatsapp/link exige cookie de auth e retorna 401 pra anônimo. Sem CEFIS autenticado não há geração de OTP.
Fluxo end-to-end
- CTA — pílula "Continuar estudo pelo WhatsApp" no header do
ChatHeader(sinaliza retomada de contexto) ou botão "Receber no WhatsApp" no rodapé da sidebar (descoberta inicial). Ambos abrem o mesmoWhatsappModal. - Modal bootstrap — ao montar, faz
GET /api/whatsapp/link/statuspra checar se já existe vínculo (user_whatsapprow pro user logado):- Anônimo → CTA "Entrar com CEFIS" pra
/login. - Logado + não pareado →
POST /api/whatsapp/linkgera OTP viacrypto.randomBytes(3).toString("hex").toUpperCase()(6 chars A-F0-9), TTL 10min, grava emwhatsapp_link_codes. Modal mostra código + telefone do bot + countdown. - Logado + já pareado→ tela "Já vinculado" com número formatado + botão "Receber mensagem agora" (invite direto via
/api/whatsapp/invite) e opção de gerar novo código.
- Anônimo → CTA "Entrar com CEFIS" pra
- Usuário envia OTP — abre o WhatsApp dele, manda o código de 6 chars pro número do bot. Não há wa.me / deep link — copy + manual envio garante que ele realmente possui o telefone.
- Inbound — Evolution →
/v1/evolution/webhook(Bun) → HMAC relay →/api/whatsapp/process. O handler detectaOTP_REGEX = /^[A-F0-9]{6}$/, chamatryLinkCode(code, phone): marcawhatsapp_link_codes.used_at, cria/atualizauser_whatsapp(phone ↔ user_id ↔ linked_at), e envia mensagem de boas-vindas pelo zap. - Polling — enquanto OTP está visível, modal polla
/api/whatsapp/link/statusa cada 3s. Quandopaired=trueaparece, modal troca pra estado "Vinculado!" com confirmação. - Mensagens futuras — após pareado, qualquer pergunta do aluno no zap entra pelo mesmo webhook.
findUserByPhone()resolveuser_idviauser_whatsapp.phonee rodaaskTutor().
Onde mora o phone do aluno
A fonte canônica do vínculo é user_whatsapp (phone ↔ user_id, 1-1, criada pelo OTP redeem). O user_profile.phone coletado no onboarding fica como hint/backup pra envios proativos (cron de quiz/lembretes) — esses não precisam de OTP porque o disparo já é cleo do bot pro aluno e não cria vínculo novo.
Princípio: nunca expor 'Evolution' ao usuário
Em toda copy user-facing (modal, botões, empty states, mock da home) a palavra é sempre "WhatsApp" ou "zap". "Evolution" só aparece em código, comentários, settings keys, /admin e aqui em /docs.
Estrutura do serviço
services/wa/src/ ├── index.ts # bootstrap Bun.serve() + /health expõe queue size ├── env.ts # validação de variáveis ├── middleware.ts # HMAC + body parsing ├── webhook.ts # POST /v1/evolution/webhook ├── hmac.ts # validação de assinatura ├── relay.ts # encaminha pra Next.js /api/whatsapp/process ├── queue.ts # FIFO global, delay 1.5s entre cada envio ├── send.ts # /v1/send/text, /v1/send/audio (enfileira via queue) ├── group.ts # /v1/group/create, /v1/group/add-participant ├── evolution.ts # cliente Evolution v2 (sendText, createGroup, etc.) ├── instance.ts # gerenciamento de instâncias ├── supabase.ts # cliente compartilhado ├── phone.ts # E.164 normalize └── log.ts # log estruturado (sem PII)
Fila FIFO com delay
Todo envio outbound (texto, áudio) passa por uma fila FIFO em memória no Bun com DELAY_MS = 1500 entre o enqueue e o envio efetivo. Garante ordem entre mensagens disparadas em paralelo (ex: tutor + lembrete simultâneos) e evita sensação de bot instantâneo. /v1/send/text retorna 202 { queued: true, position }.
Fluxo de mensagem inbound
- Evolution → POST
/v1/evolution/webhook?secret=...no Bun - Bun valida secret, ack rápido (<5s), normaliza phone + extrai text/audio
- Bun encaminha via HMAC POST pro Next.js
/api/whatsapp/process - Next.js detecta saudação (oi/menu/ajuda) → menu numerado; ou comando 1/2/3 → ação; ou texto livre → tutor
- Resposta gerada → POST
/v1/send/textno Bun → fila → Evolution → usuário
Grupos de estudo (Plano Empresarial)
Endpoint POST /api/whatsapp/group cria grupo no WhatsApp via Evolution (até 5 participantes, expira em 7 dias). Beta aberto: anônimo também pode criar, usando o 1º participante como organizador (creator_phone). Logado usa creator_user_id. Adicionar mais participantes via /api/whatsapp/group/add-participant.
Multi-canal
Continuidade Web ↔ WhatsApp
O aluno pode pular entre web e WhatsApp sem perder contexto:
- Web → WhatsApp: o componente
<ChannelToggle />(emsrc/components/channel-toggle.tsx) renderiza uma pill “🌐 App → 💬 WhatsApp”. Clicando no lado WhatsApp, abrewa.me/{bot}?text={prompt}com mensagem pré-formatada pedindo pra continuar o estudo no zap. Usado dentro do StudyViewer modal (rodapé) — passa o título do plan_item no prompt. - WhatsApp → Web: toda resposta do tutor no zap termina com
🌐 Ver no app: <APP_URL>/tutor(viaformatTutorForWhatsApp()). E o menu numerado (opção 2) entrega o link da landing.
Engajamento
Jornada do Herói (gamificação Duolingo-style)
Modelo inspirado nos pilares de gamificação do Duolingo (XP + streak + ligas + gemas + meta diária + protetor de ofensiva), com narrativa do monomito de Joseph Campbell. Tudo computado em runtime em src/lib/journey.ts a partir das interações reais do aluno — sem evento sintético, sem schema novo.
Endpoint GET /api/journey devolve o snapshot completo.
Níveis (com fala do mascote por fase)
| Slug | Nome | Fase Campbell | Emoji | minXp | nextXp | Mascote diz |
|---|---|---|---|---|---|---|
| aprendiz | Aprendiz | Chamado da Aventura | 🧭 | 0 | 30 | Toda jornada começa com um passo. |
| aventureiro | Aventureiro | Cruzando o Limiar | 🚪 | 30 | 100 | Você cruzou o limiar. |
| estrategista | Estrategista | Provas, Aliados e Inimigos | ⚔️ | 100 | 250 | Você não improvisa — você planeja. |
| mestre | Mestre | Ordália e Recompensa | 🏆 | 250 | 600 | Você domina o que assustava no começo. |
| lenda | Lenda | Retorno com o Elixir | 🌟 | 600 | — | Você é a Bússola de quem está começando. |
Ligas semanais
Definida pelo XP acumulado nos últimos 7 dias (não cumulativo). Funciona como rotatividade — semana de baixa atividade derruba o aluno de liga, semana intensa promove.
| Slug | Nome | Emoji | minWeeklyXp |
|---|---|---|---|
| bronze | Bronze | 🥉 | 0 |
| prata | Prata | 🥈 | 50 |
| ouro | Ouro | 🥇 | 150 |
| esmeralda | Esmeralda | 💚 | 300 |
| diamante | Diamante | 💎 | 500 |
Cálculo de XP, gemas e streak
- +10 XP por pergunta do aluno (count em
tutor_messages.role='user'+whatsapp_messages.direction='in' AND kind='text') - +5 XP + 1 💎 gema por resposta do tutor com citação (heurística: citations jsonb não vazio). Gema premia conteúdo de qualidade — tutor que entregou aula CEFIS no segundo certo.
- Meta diária: 30 XP/dia (
DAILY_XP_GOALem journey.ts). Bater = +1 dia no streak garantido. - Streak: dias consecutivos com pelo menos 1 pergunta nos últimos 30 dias (UTC). Quebra na primeira lacuna.
- Protetor de ofensiva: aluno ganha 1 protetor com streak ≥ 3, +1 a cada múltiplo de 7. Atualmente UI-only (não decrementa real — placeholder pra feature futura de auto-perdão de 1 dia).
- recentDays: array YYYY-MM-DD pro calendar do modal (grid 10×3 nos últimos 30 dias)
UI
<JourneyWidget /> no sidebar do tutor: pill com gradient por nível, barra de XP animada (ease-out 900ms), flame do streak 🔥, pin de gemas 💎 e barra extra de meta diária.
Clique abre JourneyModal com 9 seções: hero header, fala do mascote 🧭, meta de hoje, liga semanal (card com gradient da liga), 4 stats (perguntas/citações/streak/gemas), protetor de ofensiva (quando aplicável), ladder dos 5 níveis, calendário 30 dias, dicas de como ganhar mais XP.
Automação
Lembretes via Vercel Cron
Cron agendado em vercel.json com schedule 0,30 11-23 * * * (UTC) — cobre 8h-20h BRT a cada 30 min. Vercel Cron invoca GET /api/cron/reminders com header Authorization: Bearer {CRON_SECRET}.
Lógica
- Lê
user_whatsappondelast_reminder_sent_at IS NULL OR < now() - 18h - Filtra pelos que têm
user_profile.available_minutes_per_day > 0 - Envia até 25 lembretes por execução (cap pra não estourar timeout do Vercel — 60s)
- Mensagem usa o
first_name+ minutos por dia declarados; passa pela fila do Bun (1.5s) - Atualiza
last_reminder_sent_at+ persiste emwhatsapp_messages
Pré-requisito do usuário
O aluno precisa ter completado o onboarding (pra ter available_minutes_per_day) e pareado o WhatsApp (linha em user_whatsapp). Quem só usou o tutor anônimo na web não recebe lembretes.
Operação
Backoffice /admin
Página única protegida por senha (ADMIN_PASSWORD no .env.local, único secret que mora em env). Toda chave de API real e configuração de modelo vive em app_settings (singleton id=1).
Campos editáveis
| Categoria | Campos |
|---|---|
| Providers de IA | openrouter_api_key, google_api_key, openai_api_key |
| CEFIS | cefis_demo_api_key |
| Modelos | chat_model, embedding_model, whisper_model |
| RAG | rag_match_threshold (0-1), rag_top_k (1-20) |
evolution_api_url, evolution_api_key, evolution_instance, evolution_bot_phone, evolution_webhook_secret |
Demo bonus
Permite trocar o modelo de chat ao vivo durante o pitch (de gpt-4o-mini para anthropic/claude-3.5-sonnet via OpenRouter, por exemplo) sem redeploy. getSettings() tem cache de 60s; invalidateSettingsCache() roda no save.
Regras de segurança
- GET devolve chaves mascaradas (
abc12…wxyz) — nunca plaintext. - POST aceita só whitelist de campos (não
Object.keys(body)direto). - Senha enviada em header
x-admin-password; comparada viatimingSafeEqual. - Inputs vazios mantêm valor atual (não sobrescrevem com
null).
Não negociável
Segurança
Princípio raiz
Nada de secrets em código versionado, e nada de credenciais expostas no client/browser. Mesmo com deadline, atalho de segurança é bloqueio.
Checklist aplicado
- Secrets em DB via /admin, não em
.envversionado..env.localgitignored só guardaADMIN_PASSWORD, conexão Supabase e fallbacks opcionais. .gitignorevalida.env*+ exceção para.env.example.- Mascarar keys no GET admin (
abc12…wxyz); nunca plaintext em JSON nem log. - Whitelist de campos editáveis em POST admin.
- Cookies sempre
httpOnly: true,secure: true(prod),sameSite: "lax". import "server-only"em libs que tocam credenciais (settings.ts,ai.ts,cefis-server.ts,tutor-agent.ts,plan.ts) — impede bundle no client por engano.- Webhooks de terceiros (Evolution) validam HMAC; nunca aceitar payload sem prova de origem.
- OTP de pareamento WhatsApp via
crypto.randomBytes(6 chars hex maiúsculo), TTL 10min, uso único. Geração gated em login CEFIS — anônimo recebe 401 em/api/whatsapp/link. Redeem via webhook consumindo o código enviado pelo próprio aluno garante posse do telefone. - Não logar conteúdo de chaves, senhas, mensagens privadas. Em emergência, log prefixo curto (
sk-abc12…). - PII (telefone, email, chat) tratada como sensível — guardada no banco, nunca em log estruturado.
O produto
Regras de negócio
O que o produto faz
- Diagnóstico em chat, não formulário. 4-5 perguntas naturais.
- Plano de 1 semana (segunda a domingo), respeitando o orçamento declarado em
available_minutes_per_day. - Plano sempre tem 4 a 7 items. Cada item ≤ minutos/dia.
- Sábado e domingo podem ter blocos maiores (até 2× o normal).
- Plano prioriza aulas reais (
source=cefis_lessoncomchunk_index) — só usagenerated_summaryougenerated_quizcomo reforço quando não há aula apropriada. - Curador nunca inventa cursos fora do contexto do RAG.
- Cada citação no tutor abre no segundo exato via
?t=<seconds>. - Resposta sem base no RAG é declarada explicitamente: começa com aviso e seta
grounded_in_cefis=false. - Plano novo desativa o anterior (
active=false) — um plano ativo por user. - Ingest é idempotente: roda de novo sobre o mesmo curso e sobrescreve chunks (não duplica).
O que o produto NÃO faz (escopo cortado)
Cortes deliberados para caber em time solo
- Sem
/dashboardcom progresso histórico (tabelaprogress_logexiste, mas UI ficou de fora). - Sem coach diário / spaced repetition automatizado.
- Sem podcast generator (TTS). Schema preparado, geração não.
- Sem quiz adaptativo (diagnóstico virou 2-3 perguntas no onboarding).
- Sem trilhas curadas pelo aluno — usamos as trilhas oficiais via API CEFIS quando o curador encontra match relevante.
- Multi-tenant: 1 plano ativo por user, sem versionamento nem comparação entre planos.
Tom e personalidade
- Tutor adota postura de professor experiente (referência: Harvard Negotiation Project — Fisher, Ury). Usa conceitos (BATNA, ZOPA, interesses vs. posições) com naturalidade, sem citar bibliografia o tempo todo.
- Exemplos sempre do mundo do contador: honorários, sócio, cliente que atrasa.
- Respostas curtas: 3-6 frases. Sem markdown pesado (funciona em web e WhatsApp).
- Português brasileiro coloquial moderado. Sem "prezado" nem floreio corporativo.
Estado real
Números do sample
Medições reais sobre trascriptions_sample/output/ (2026-05-26):
| Métrica | Valor |
|---|---|
| Cursos totais no sample | 26 |
| Cursos com VTT (transcrição) | 1 (curso 1132 — Negociação Harvard) |
| Cursos só com metadados | 25 |
| Aulas totais | 697 |
| Aulas com transcrição | 15 |
| Cues VTT parseados | 577 |
| Chunks gerados (target 800, max 1200) | 58 |
| Média de chars por chunk | 898 |
Custo de ingestão
58 chunks × ~800 chars ≈ 50k tokens de embedding ≈ US$ 0,001 em text-embedding-3-small. Ingestão completa do sample roda em ~15 segundos com batches default.
Cada chunk carrega startSeconds + endSeconds do WebVTT — é o que destrava o deep-link no player CEFIS.
Trade-offs
Limitações conhecidas
- RAG profundo só no curso 1132. Os outros 25 cursos têm só embedding de metadados (title + summary + goals + keywords). O curador consegue sugerir qualquer curso no plano, mas o tutor só mergulha em negociação.
- Demo precisa cair em tópico de negociação Harvard para mostrar o deep-link. Perguntas fora desse universo entregam respostas baseadas em metadados (sem timestamp). O tutor avisa explicitamente.
- Deep-link presume formato do CEFIS (
?t=<seconds>). Se a plataforma real usar outro parâmetro (e.g.&start=), ajustar embuildLessonDeepLink(). - Cookie de sessão é leve. Só guarda o UUID do user; perfil completo busca via DB. Em escala precisaria virar JWT signed ou session no Redis.
- app_settings é singleton (sem RLS multi-tenant). Plaintext de chave protegida só por senha admin. Aceitável no hackathon; em produção requer KMS / Supabase Vault.
- Progresso não fecha o loop. A tabela
progress_logexiste eplan_items.statusé atualizável, mas não há UI para marcar “feito” nem cálculo de % do plano. - Whisper / TTS provisionados mas não usados na demo. O schema inclui
whisper_modeletts_voice_*nas configurações, mas não há rota de upload de áudio nem geração de podcast.
Operação
Deploy & ambiente
| Componente | Provider | Notas |
|---|---|---|
| Web (Next.js) | Vercel | Build automático no push para main. Free tier suficiente para a demo. |
| Banco | Supabase (managed) | Schema dedicado bussola precisa estar em Settings → API → Exposed schemas. |
| WhatsApp service | Railway (Bun) | Long-running. railway.json em services/wa. Variáveis via dashboard Railway. |
| Evolution API | Self-hosted (terceiros) | Configurar webhook como https://<railway>/v1/evolution/webhook?secret=... |
Setup local
# 1. Dependências
npm install
# 2. Variáveis de ambiente
cp .env.example .env.local
# Preencha NEXT_PUBLIC_SUPABASE_URL, *_ANON_KEY, SUPABASE_SERVICE_KEY,
# ADMIN_PASSWORD (todo resto vai pra /admin)
# 3. Schema do banco
# Aplique migrations em supabase/migrations/bussola/ via SQL Editor
# ou supabase CLI (em ordem cronológica)
# 4. Backoffice
npm run dev
# Abra http://localhost:3000/admin → use ADMIN_PASSWORD
# Cole OpenAI key (e opcionalmente OpenRouter / Google)
# 5. Ingestão sample
curl -X POST http://localhost:3000/api/admin/ingest-sample \
-H "x-admin-password: $ADMIN_PASSWORD"
# 6. Smoke test
# /tutor → pergunte "O que é BATNA?"
# → deve responder citando o curso 1132 com deep-link no segundoVariáveis sensíveis
| Variável | Onde mora | Função |
|---|---|---|
| ADMIN_PASSWORD | .env.local (web) | Único secret no env; libera /admin |
| NEXT_PUBLIC_SUPABASE_URL | .env.local (web) | Conexão Supabase (público) |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | .env.local (web) | Anon key Supabase (público) |
| SUPABASE_SERVICE_KEY | .env.local (web) | Service key — só server-side, nunca exposto |
| OPENAI_API_KEY / OPENROUTER_API_KEY / GOOGLE_API_KEY | .env.local (web, fallback) | Opcional — preferência é gravar via /admin em app_settings |
| EVOLUTION_* | Railway env (services/wa) | Espelhado de app_settings na inicialização do service |