Bússola · Docs

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.

Hackathon CEFIS 2026Solo (Felipe Benevides)Deadline: 26/05/2026Premiação: R$ 10.000

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érioPesoOnde a Bússola joga
Funcionalidade30Fluxo principal /login → /plano → /tutor online em Vercel
Integração CEFIS25Auth real, catálogo via /api/v3, deep-link no segundo
Qualidade da IA20RAG + structured output + fallback graceful
Inovação15Deep-link no segundo exato + tutor no WhatsApp
UX10Shell 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

  1. Transcrições .vtt são parseadas preservando timestamps por linha (cue).
  2. Cues são agrupados em chunks de ~800 caracteres, cada chunk guarda start_seconds e end_seconds.
  3. O embedding do chunk vai para pgvector.
  4. No tutor, o RAG retorna chunks com timestamp. O modelo cita o momento (mm:ss) e a UI renderiza um cartão ▶ Abrir na CEFIS com 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

CamadaEscolhaPor quê
FrameworkNext.js 16 (App Router, RSC, src/)Streaming nativo, RSC para reduzir JS no cliente, integração 1-clique com Vercel
UITailwind v4 + componentes shadcn-like própriosVisual profissional sem dependência pesada; tema escuro grátis
IAVercel AI SDK + OpenRouter (primário) + OpenAI (fallback)OpenRouter destrava trocar modelo ao vivo durante demo; OpenAI cobre Whisper/TTS/embeddings
EmbeddingsOpenAI text-embedding-3-small (ou Google gemini-embedding-001 a 1536d)1536 dims casam com pgvector; Google opcional via /admin
BancoSupabase Postgres + pgvector (schema dedicado bussola)Postgres + vetores + Storage + Auth em um único provider
Persistência de chatTabela tutor_messages com citations jsonbHistórico opcional, best-effort (não bloqueia resposta)
WhatsAppServiço Bun separado em Railway → Evolution API v2Workload long-running (webhooks contínuos) separado do Next.js stateless
DeployVercel (web) + Railway (services/wa)Edge-friendly + cron/long-running ao lado

Decisões de modelo

  • Chat: gpt-4o-mini via 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

  1. Form HTML simples (email + senha CEFIS).
  2. POST /api/auth/cefis-login CefisClient.login() → API CEFIS v1.
  3. Upsert em bussola.users com cefis_user_id, cefis_api_key, etc.
  4. Set cookie bussola_session (HttpOnly, Secure, SameSite=Lax) com o UUID interno do user.
  5. Redirect para /onboarding ou /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 aprendizado
  • available_minutes_per_day
  • learning_style (visual / auditory / kinesthetic / mixed)
  • Lacuna crítica → grava skill_assessment com status='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

  1. Sample local em trascriptions_sample/output/: details.json por curso + .vtt por aula (quando existe).
  2. parseVtt() em src/lib/vtt.ts converte cues do WebVTT em { startSeconds, endSeconds, text }.
  3. chunkCues() agrupa em blocos de ~800 caracteres (max 1200), quebrando preferencialmente em fim de sentença e preservando start/end no menor cue.
  4. embed() (OpenAI ou Google, conforme app_settings) gera vetores 1536d.
  5. Insert em cefis_lesson_chunks com lesson_id, chunk_index, chunk_text, start_seconds, end_seconds, embedding.
  6. Metadados do curso (title + summary + goals + keywords) viram um único embedding em cefis_course_embeddings para 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.

AgenteEndpointInput → OutputResponsabilidade
Onboarding/api/onboardingConversa turn-by-turn → user_profile + 1 skill_assessmentExtrai goal, minutes/day, deadline, learning_style, weak_area em até 4 perguntas
Diagnóstico/api/diagnosticGoal → 4-6 sub-skills com self-scoresDecompõe objetivo em sub-habilidades, classifica em domina/lacuna parcial/lacuna crítica
Curador/api/curator/generate-planPerfil + skills + RAG → study_plan + 4-7 plan_itemsPlano de 1 semana adaptado ao learning_style (visual→vídeo, auditory→podcast, kinesthetic→quiz). Modes: auto, course, custom
Tutor (killer)/api/tutorQuery → answer + citations[] com deep-link mm:ssRAG no pgvector + LLM cita aulas CEFIS no segundo exato. Aceita planId pra contextualizar
Quick-Learn/api/quick-learn{ topic, minutes } → highlights + takeawayResumo calibrado pelo tempo disponível: 1min→1 bullet, 30min→6 bullets profundos
Gerador de Conteúdo/api/generate-content{ planItemId | topic, kind } → markdown ou quizMaterializa generated_summary (markdown) ou generated_quiz (3-5 questões)
Estudo da Aula (especializado)/api/plan-item-studyplanItemId → markdown 800-1500 palavras estruturadoConteú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

TabelaFunção
app_settingsSingleton (id=1) com chaves de API + modelos + flags
usersEspelha conta CEFIS — cefis_user_id, name, avatar + journey_xp cache
user_profileSaída do onboarding (6 perguntas): goal, minutos/dia, learning_style, deadline, professional_experience, phone E.164 (fonte canônica do WhatsApp)
skill_assessmentSub-skills com score/status/importance — onboarding + /diagnostico
study_planPlano semanal (title, total_weeks, active, rationale) — vários por user
plan_itemsItems por dia (source, source_ref, cefis_course/lesson/track_id, duration_minutes, status)
cefis_coursesCache local dos cursos indexados (metadados + cefis_url)
cefis_lessonsAulas dos cursos indexados
cefis_lesson_embeddingsChunks de transcrição com start/end seconds + embedding(1536)
cefis_course_embeddingsEmbedding único por curso (metadados) — RAG light
tutor_messagesMensagens persistidas (best-effort) com citations jsonb — base de XP
whatsapp_link_codesOTP 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_whatsappFonte 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_messagesLog raw in/out direction, kind, content, citations, evolution_message_id
progress_logEventos por plan_item (started, completed, …)
generated_contentConteúdo IA materializado — kind (summary/quiz/pdf/podcast), plan_item_id FK pra estudo extenso
study_groupsGrupos 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_id UNIQUE — idempotência no upsert pós-login.
  • study_plan(user_id, active) — busca rápida do plano ativo.

Reference

Rotas e API

Páginas

RotaTipoFunção
/RSCLanding — CTAs para login, tutor e PWA install
/loginClientForm CEFIS (email + senha)
/onboardingRSC + ClientChat curto que monta perfil
/diagnosticoRSC + ClientQuiz adaptativo de sub-skills com sliders
/planoRSCPlano ativo + switcher de N planos + cards com Estudar/Revisar/ChannelToggle
/plano?id=<uuid>RSCCarrega plano específico (ownership via loadPlan)
/tutorRSC + ClientShell WhatsApp-style com sidebar de cursos, planos, Plano Empresarial, Jornada do Herói, engrenagem de preferências
/tutor?planId=&q=ClientAuto-submete pergunta com contexto do plano (vindo do botão Estudar agora)
/agentesRSCPágina visual com os 6 agentes — cards + flow diagram
/sobreRSCPortfólio + descrição da Bússola
/adminClientBackoffice protegido por senha (env)
/docsRSCEsta página

API routes

EndpointMétodoFunção
/api/auth/cefis-loginPOSTAuth real contra CEFIS v1, set cookie
/api/auth/logoutPOSTLimpa cookie de sessão
/api/auth/meGETDevolve user atual (ou 401)
/api/profileGET/PATCHLê/atualiza user_profile (gate CEFIS login)
/api/journeyGETSnapshot da Jornada do Herói (XP, level, streak, ladder, recentDays)
/api/onboardingPOSTAgente Onboarding turn-by-turn (structured)
/api/diagnosticPOSTphase=start gera sub-skills; phase=submit grava skill_assessment
/api/curator/generate-planPOSTCurador — body { mode: auto|course|custom, courseId?, customName?, customGoal? }
/api/tutorPOSTRAG + resposta com citações; aceita planId pra biasar
/api/quick-learnPOSTResumo IA calibrado por { topic, minutes }
/api/generate-contentPOSTMaterializa generated_summary ou generated_quiz a partir de plan_item ou tópico
/api/plan-item-studyGET/POSTGET retorna estudo salvo (markdown 800-1500 palavras); POST gera+persiste
/api/plan/activeGETPlano ativo do user
/api/plan/listGETTodos os planos do user (active + arquivados)
/api/plan/[id]GETPlano por ID (ownership check)
/api/coursesGETCursos indexados + disponíveis no sample
/api/courses/ingestPOSTIndexa curso do sample (VTTs + embeddings)
/api/whatsapp/groupPOST/GETCria grupo no zap (até 5 participantes, expira 7d). Beta: anônimo OK
/api/whatsapp/group/add-participantPOSTAdiciona até esgotar quota de 5 num grupo existente
/api/whatsapp/invitePOSTBot inicia conversa com o número informado (rate-limit 3/15min)
/api/whatsapp/processPOSTEndpoint interno chamado pelo service Bun (HMAC)
/api/whatsapp/webhook*Deprecated — 410. Webhook agora vive em services/wa
/api/cron/remindersGETVercel Cron (0,30 11-23 UTC) → lembretes WhatsApp baseados em disponibilidade
/api/admin/settingsGET/POSTLê/grava app_settings (auth por senha)
/api/admin/ingest-sampleGET/POSTDry-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étodoEndpointUso
login(email, pass)POST v1 /api/v1/loginPega key + user — salva em users
me()GET v1 /api/v1/user/meValida key ainda válida
listCourses(params)GET v3 /coursesCatálogo paginado (search, categories, filter)
getCourse(id)GET v3 /courses/{id}Detalhe de curso (summary, goals, keywords)
listLessons(courseId)GET v3 /courses/{id}/lessonsAulas + stream_sources
listTracks(params)GET v3 /tracksTrilhas curadas pela CEFIS
getTrack(id)GET v3 /tracks/{id}Detalhe + cursos da trilha
listCertificates(params)GET v3 /performance/certificatesHistó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

  1. 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 mesmo WhatsappModal.
  2. Modal bootstrap — ao montar, faz GET /api/whatsapp/link/status pra checar se já existe vínculo (user_whatsapp row pro user logado):
    • Anônimo → CTA "Entrar com CEFIS" pra /login.
    • Logado + não pareado POST /api/whatsapp/link gera OTP via crypto.randomBytes(3).toString("hex").toUpperCase() (6 chars A-F0-9), TTL 10min, grava em whatsapp_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.
  3. 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.
  4. Inbound — Evolution → /v1/evolution/webhook (Bun) → HMAC relay → /api/whatsapp/process. O handler detecta OTP_REGEX = /^[A-F0-9]{6}$/, chama tryLinkCode(code, phone): marca whatsapp_link_codes.used_at, cria/atualiza user_whatsapp (phone ↔ user_id ↔ linked_at), e envia mensagem de boas-vindas pelo zap.
  5. Polling — enquanto OTP está visível, modal polla /api/whatsapp/link/status a cada 3s. Quando paired=trueaparece, modal troca pra estado "Vinculado!" com confirmação.
  6. Mensagens futuras — após pareado, qualquer pergunta do aluno no zap entra pelo mesmo webhook. findUserByPhone() resolve user_id via user_whatsapp.phone e roda askTutor().

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

  1. Evolution → POST /v1/evolution/webhook?secret=... no Bun
  2. Bun valida secret, ack rápido (<5s), normaliza phone + extrai text/audio
  3. Bun encaminha via HMAC POST pro Next.js /api/whatsapp/process
  4. Next.js detecta saudação (oi/menu/ajuda) → menu numerado; ou comando 1/2/3 → ação; ou texto livre → tutor
  5. Resposta gerada → POST /v1/send/text no 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 /> (em src/components/channel-toggle.tsx) renderiza uma pill “🌐 App → 💬 WhatsApp”. Clicando no lado WhatsApp, abre wa.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 (via formatTutorForWhatsApp()). 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)

SlugNomeFase CampbellEmojiminXpnextXpMascote diz
aprendizAprendizChamado da Aventura🧭030Toda jornada começa com um passo.
aventureiroAventureiroCruzando o Limiar🚪30100Você cruzou o limiar.
estrategistaEstrategistaProvas, Aliados e Inimigos⚔️100250Você não improvisa — você planeja.
mestreMestreOrdália e Recompensa🏆250600Você domina o que assustava no começo.
lendaLendaRetorno com o Elixir🌟600Você é 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.

SlugNomeEmojiminWeeklyXp
bronzeBronze🥉0
prataPrata🥈50
ouroOuro🥇150
esmeraldaEsmeralda💚300
diamanteDiamante💎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_GOAL em 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

  1. user_whatsapp onde last_reminder_sent_at IS NULL OR < now() - 18h
  2. Filtra pelos que têm user_profile.available_minutes_per_day > 0
  3. Envia até 25 lembretes por execução (cap pra não estourar timeout do Vercel — 60s)
  4. Mensagem usa o first_name + minutos por dia declarados; passa pela fila do Bun (1.5s)
  5. Atualiza last_reminder_sent_at + persiste em whatsapp_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

CategoriaCampos
Providers de IAopenrouter_api_key, google_api_key, openai_api_key
CEFIScefis_demo_api_key
Modeloschat_model, embedding_model, whisper_model
RAGrag_match_threshold (0-1), rag_top_k (1-20)
WhatsAppevolution_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 via timingSafeEqual.
  • 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

  1. Secrets em DB via /admin, não em .env versionado. .env.local gitignored só guarda ADMIN_PASSWORD, conexão Supabase e fallbacks opcionais.
  2. .gitignore valida .env* + exceção para .env.example.
  3. Mascarar keys no GET admin (abc12…wxyz); nunca plaintext em JSON nem log.
  4. Whitelist de campos editáveis em POST admin.
  5. Cookies sempre httpOnly: true, secure: true (prod), sameSite: "lax".
  6. 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.
  7. Webhooks de terceiros (Evolution) validam HMAC; nunca aceitar payload sem prova de origem.
  8. 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.
  9. Não logar conteúdo de chaves, senhas, mensagens privadas. Em emergência, log prefixo curto (sk-abc12…).
  10. 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_lesson com chunk_index) — só usa generated_summary ou generated_quiz como 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 /dashboard com progresso histórico (tabela progress_log existe, 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étricaValor
Cursos totais no sample26
Cursos com VTT (transcrição)1 (curso 1132 — Negociação Harvard)
Cursos só com metadados25
Aulas totais697
Aulas com transcrição15
Cues VTT parseados577
Chunks gerados (target 800, max 1200)58
Média de chars por chunk898

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

  1. 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.
  2. 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.
  3. Deep-link presume formato do CEFIS (?t=<seconds>). Se a plataforma real usar outro parâmetro (e.g. &start=), ajustar em buildLessonDeepLink().
  4. 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.
  5. 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.
  6. Progresso não fecha o loop. A tabela progress_log existe e plan_items.status é atualizável, mas não há UI para marcar “feito” nem cálculo de % do plano.
  7. Whisper / TTS provisionados mas não usados na demo. O schema inclui whisper_model e tts_voice_* nas configurações, mas não há rota de upload de áudio nem geração de podcast.

Operação

Deploy & ambiente

ComponenteProviderNotas
Web (Next.js)VercelBuild automático no push para main. Free tier suficiente para a demo.
BancoSupabase (managed)Schema dedicado bussola precisa estar em Settings → API → Exposed schemas.
WhatsApp serviceRailway (Bun)Long-running. railway.json em services/wa. Variáveis via dashboard Railway.
Evolution APISelf-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 segundo

Variáveis sensíveis

VariávelOnde moraFunçã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