From 79dd2b1b0602194f7713d94462481f91f23eb61a Mon Sep 17 00:00:00 2001 From: Duanne Moraes Date: Fri, 11 Sep 2026 15:56:23 -0400 Subject: [PATCH 01/40] docs(mentor): spec de design do mentor de aula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mentor de IA na página da aula do programa Base: terceiro `mode` do turno do Oracle, com domain `lessons` (pgvector próprio, chunks com timestamp), tool `search_lesson` de escopo injetado, ingestão speech-to-text via comando de console e aba Mentor na Platform reusando o fio de navigation. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-09-11-mentor-de-aula-design.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-mentor-de-aula-design.md diff --git a/docs/superpowers/specs/2026-09-11-mentor-de-aula-design.md b/docs/superpowers/specs/2026-09-11-mentor-de-aula-design.md new file mode 100644 index 0000000..2db91ab --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-mentor-de-aula-design.md @@ -0,0 +1,240 @@ +# Mentor de aula — design + +**Data:** 2026-09-11 +**Repos afetados:** `oracle-borderless`, `borderless-api`, `borderless-platform` (branch `feat/speech-to-text` em cada um, já com `feat/agent-navigation` mergeada) +**Status:** aprovado em brainstorm; aguardando revisão do documento +**Origem:** conversa "Agente de plataforma — speech-to-text, embeddings, e busca por similaridade" (Granola, 2026-09-11) +**Depende de:** `2026-09-08-agent-navigation-design.md` — o mentor é um terceiro `mode` do mesmo turno + +## 1. Objetivo + +Dar ao mentorado do programa **Mentoria Base** um mentor de IA dentro da própria página da aula: um chat onde ele pergunta qualquer coisa sobre aquela aula ou sobre o conceito técnico que foi falado nela, e recebe resposta ancorada no que o professor disse, com o ponto do vídeo onde aquilo aparece. + +O alvo imediato é um **MVP para apresentar ao time**. O mentor começa só no Base; a expansão para os outros programas vem depois da aprovação, e o desenho abaixo é escolhido para que essa expansão seja configuração, não reescrita. + +O cérebro é o **Oracle Borderless**, que já tem tudo que um RAG precisa em produção: `document_chunks` com pgvector e índice HNSW cosine, `EmbeddingsClient`, `ChunkingService`, grafo LangGraph com tool loop, streaming SSE de `StreamEvent`s e autenticação em ponte com a Platform. O que falta é a transcrição das aulas e um escopo de busca por aula. + +## 2. Decisões já tomadas + +| Decisão | Escolha | +| --- | --- | +| Postura do mentor | **Ensina, ancorado na aula.** Usa a transcrição como contexto principal e pode complementar com conhecimento próprio, separando explicitamente o que a aula disse do que é complemento | +| Origem do texto | **Speech-to-text sobre o áudio da aula.** Não depende de legenda automática do provider nem do plano contratado | +| Onde mora o cérebro | Oracle, como **terceiro `mode`** do turno já existente (`navigate`, `chat`, `mentor`) | +| Superfície na Platform | **Terceira aba "Mentor"** na página da aula, ao lado de `Visão geral` e `Discussão` | +| Abrangência do MVP | **Programa Base inteiro** — custo de transcrição na ordem de US$10, uma vez | +| Escopo da tool | `lesson_id` **injetado pelo `config.configurable`**, não passado pelo modelo | +| Persistência dos chunks | Tabelas próprias (`lessons`, `lesson_chunks`), **separadas de `documents`** | + +### 2.1 Divergência consciente da conversa de origem + +Na gravação, o modelo passa o `aula_id` como parâmetro da tool. Aqui o `lesson_id` é injetado pelo runtime, e a tool recebe só `query`. Motivo: o modelo não pode inventar o id de uma aula que o aluno não comprou, e o aluno não pode induzi-lo a isso por prompt. Quando o mentor se expandir para responder sobre um programa inteiro, a tool ganha um `lesson_id` opcional **restrito às aulas daquele programa** às quais o usuário tem acesso. + +## 3. Arquitetura e fluxo de um turno + +``` +Platform (Next.js) Oracle (FastAPI + LangGraph) borderless-api (Fastify) +────────────────── ──────────────────────────── ──────────────────────── +Aba Mentor (página da aula) + │ POST /api/oracle/conversations/ask + │ {input:{question, mode:"mentor", lesson_id, locale}, config:{run_id, configurable:{thread_id}}} + ▼ +Route handler (proxy — já existe, allowlist já cobre conversations/ask) + │ lê cookie borderless_access_token + │ Authorization: Bearer ──────────▶ require_user (bearer) + │ │ + │ ├─ entitlement da aula (Bearer do usuário) ──▶ GET /api/programs/.../videos/... + │ │ sem acesso → 403, o grafo não roda ◀── access.hasAccess + │ ▼ + │ grafo: START → answer ⇄ tools(search_lesson) + │ │ search_lesson(query) + │ │ embed(query) → lesson_chunks WHERE lesson_id = + │ │ ORDER BY cosine_distance LIMIT top_k + │ ◀── SSE: on_chat_model_stream (resposta) │ + │ ◀── SSE: citations (source_type="lesson") ▼ + ▼ persiste message.sources + trace +oracle-message.tsx renderiza +a citação como link /programs/base//?t=750 +``` + +1. A aba envia `mode: "mentor"` mais o `lesson_id` (o `video.id` canônico da Platform) e o `locale` atual. +2. O proxy do Next.js injeta o bearer da Platform e devolve o corpo SSE em streaming. O caminho do turno **não muda nada** no proxy: `POST conversations/ask` já está no allowlist. A única entrada nova no allowlist é o `GET lessons/` de prontidão da seção 8. +3. O Oracle valida o bearer reaproveitando a cache de 60 s e o fail-open de 10 min já existentes. +4. Antes de montar o grafo, o Oracle confirma na `borderless-api`, **com o bearer do próprio usuário**, que ele tem acesso àquela aula. Sem acesso, responde 403 e não chama modelo nenhum. +5. `route_entry` desvia `mode == "mentor"` do gate, como já faz com `navigate`. Isso é deliberado: o gate existe para recusar o que está fora da base aprovada, e um mentor que recusa é o pior comportamento possível para um aluno que perguntou com outro fraseado. +6. O modelo chama `search_lesson(query)` quantas vezes precisar dentro do tool loop. O `lesson_id` vem do `configurable`, não do modelo. +7. Cada trecho recuperado vira uma `Citation` com `source_type="lesson"` e `url` apontando para a própria aula com o timestamp do chunk. + +**Escopo do `thread_id`.** Um thread por montagem da aba: o aluno mantém o contexto enquanto conversa naquela aula, e sair da página começa conversa nova. As mensagens continuam persistidas em `conversations`/`messages` como em qualquer turno, então nada se perde do lado do Oracle — o que fica de fora do MVP é **restaurar** o histórico ao reabrir a aula. É a escolha mais barata que não mente para o aluno, e a restauração cabe depois sem mudar o modelo de dados. + +## 4. Oracle — domain `lessons` + +Bounded context novo em `src/domain/lessons/`, seguindo o padrão do repo (Entity ≠ Model, Actions sem facade). + +**Por que não reusar `documents`.** A tabela `documents` é moldada para o Notion — `notion_page_id` unique, `kb_root_page_id`, `kb_section`, `status = "approved"` — e o `DocumentChunkRepository.search_similar` define o escopo da base de conhecimento geral do oráculo. Enfiar aulas ali contaminaria as respostas do assistente de navegação com trechos soltos de aula, e obrigaria a inventar valores para colunas que não fazem sentido. Tabelas próprias mantêm as duas buscas independentes e deixam a fronteira legível. + +``` +lessons + uuid PK (uuid7, mixin HasUUID) + platform_video_id String(64) UNIQUE INDEX ← Video.id da borderless-api + program_slug String(255) INDEX + module_slug String(255) + video_slug String(255) + title String(512) + duration_seconds Integer NULL + provider String(32) ← VIMEO | PANDA_VIDEO + provider_ref String(255) + transcript_text Text NULL + transcript_status String(20) INDEX ← pending | transcribing | ready | failed + content_hash String(64) NULL ← sha256 do transcript_text + transcribed_at DateTime NULL + attempts Integer DEFAULT 0 + failure_reason Text NULL + (HasTimestamps) + +lesson_chunks + uuid PK + lesson_id FK → lessons.uuid ON DELETE CASCADE, INDEX + ordinal Integer + content Text + start_seconds Float ← sustenta o "por volta de 12:30" + end_seconds Float + embedding Vector(settings.EMBEDDING_DIM) NULL + INDEX ix_lesson_chunks_embedding_hnsw USING hnsw (embedding vector_cosine_ops) + (HasTimestamps) +``` + +O índice HNSW é criado na migration por SQL cru e **declarado no `__table_args__`** do model, pelo mesmo motivo documentado em `DocumentChunkModel`: sem isso o `alembic check` o vê como índice a remover. + +**Repositório.** `LessonChunkRepository.search_similar(lesson_id, embedding, top_k)` espelha o de documents, com duas diferenças: filtra por `lesson_id` e não aplica `RAG_MAX_DISTANCE`. O limiar existe em `documents` para que pergunta fora do assunto não recupere vizinhos ruins; aqui o escopo já é uma aula só, e cortar por distância recriaria a recusa que a decisão da seção 2 eliminou. `replace_for_lesson(lesson_id, chunks)` reusa o padrão de `replace_for_document`. + +**`TranscriptChunkingService`** (novo, em `src/domain/lessons/services/`). O `ChunkingService` existente corta por heading markdown; transcrição não tem heading nenhum, então cairia direto no fallback de janela de caractere e **perderia os timestamps**. O serviço novo recebe os segmentos do speech-to-text e os agrupa em blocos de até `MENTOR_CHUNK_SIZE` caracteres (default 800), sem quebrar segmento no meio, propagando `start` do primeiro e `end` do último. Puro, sem I/O, testável isolado. + +**`Citation`.** `source_type` passa de `Literal["notion", "web"]` para `Literal["notion", "web", "lesson"]`. Para uma citação de aula, `title` é o título da aula, `url` é `/programs///