Skip to content

Feat/agent navigation mentor - #16

Open
Duannee wants to merge 40 commits into
mainfrom
feat/agent-navigation-mentor
Open

Duannee wants to merge 40 commits into
mainfrom
feat/agent-navigation-mentor

Conversation

@Duannee

@Duannee Duannee commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

Duannee and others added 30 commits September 11, 2026 15:56
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) <noreply@anthropic.com>
Seção 9: toda pergunta fica no nosso banco estendendo `agent_traces`
(ADR-0013) com lesson_id, program_slug, lesson_coverage e o embedding da
pergunta já calculado para a busca. "Não respondida" vira medida, não
inferência — dois limiares que rotulam sem bloquear, porque o mentor não
recusa. Daí saem as duas leituras: backlog de conteúdo escrito pelos
alunos e sinal de retenção por citação, numa aba do /ops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O segredo interno aparecia com nome diferente em cada lado (§6 e §7) sem
dizer que é o mesmo valor, e o header não estava nomeado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ingestão (7 tasks): tabelas, chunking com timestamp, repositórios, cliente
das rotas internas, áudio/transcrição, máquina de estados e mentor:ingest.
Modo (7 tasks): busca com escopo de aula, tool search_lesson, mode no grafo
e no prompt, entitlement fail-closed, trace como ativo, prontidão e /ops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cria o bounded context lessons: enum TranscriptStatus, entidades Lesson/
LessonChunk/TranscriptSegment, models LessonModel/LessonChunkModel (HNSW
cosine em lesson_chunks.embedding) e os mappers correspondentes. Migration
0012_mentor_lessons cria lessons e lesson_chunks. Acrescenta o bloco de
settings do mentor de aula.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A migration importava settings.EMBEDDING_DIM ao vivo, ao contrário do
precedente da própria 0001_documents_pgvector.py, que hardcoda o valor com
comentário explicando o motivo: manter a migration determinística e
reproduzível independente do valor atual de settings. Se EMBEDDING_DIM mudar
no futuro, reexecutar esta migration do zero produziria silenciosamente uma
dimensão de vetor diferente da vigente na autoria. LessonChunkModel continua
usando settings.EMBEDDING_DIM normalmente, como DocumentChunkModel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Implementa TranscriptChunkingService que agrupa segmentos de fala (de
speech-to-text) em chunks preservando janelas de tempo. Pausa segmentos
consecutivos até alcançar o tamanho máximo; segmentos maiores que o limite
viram chunks próprios sem corte (para manter âncora temporal). Permite
pequeno excesso do limite (~10%) para evitar fragmentação excessiva de
segmentos curtos.

- src/domain/lessons/services/transcript_chunking_service.py: serviço puro
- src/domain/lessons/services/__init__.py: arquivo de inicialização
- tests/unit/domain/lessons/test_transcript_chunking_service.py: 7 testes

Testes: 467 passed (7 novos).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
O caso 15/15/5 com size=20 esperava 2 chunks, mas 15+1+5=21 estoura o
limite: o algoritmo de referência ("até size", <= estrito) devolve 3. O
teste passa a exercitar o limite inclusivo (15+1+4=20) e um caso a mais
provando que 1 caractere acima quebra o chunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reverte introdução de heurística de 10% de overflow que violava a spec
(§4: "até MENTOR_CHUNK_SIZE caracteres"). O limite é teto duro: candidate > size
quebra imediatamente, sem exceções. O teste original esperava comportamento
relaxado; foi substituído por dois testes que validam:

- test_packing_fills_exactly_to_the_limit_then_breaks: chunk de exatamente
  size caracteres é aceito (limite inclusivo); próximo segmento que o ultrapassar
  em 1+ caractere força quebra.
- test_one_char_over_the_limit_breaks_the_chunk: confirma teto duro com um
  caso simples (15+1+5=21 > 20, sem tolerância).

Docstring já descrevia regra correta; mantida como está.

Testes: 8 passed (de 7); suite: 468 passed (460 baseline + 8 novos).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adiciona LessonRepository (upsert de catálogo sem tocar em estado de
transcrição, get por platform_video_id, save, list_pending com claim
via transcript_status/attempts) e LessonChunkRepository (replace
idempotente por lesson_id, count_for_lesson). Teste de integração
segue o padrão de test_chunk_repository_nearest.py (fixture db_session);
teste unitário adicional cobre com sessão fake o SQL de list_pending,
o isolamento do estado de transcrição no upsert e a ordem DELETE-antes-
de-ADD do replace_for_lesson — sem depender de PostgreSQL.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rror

Alinha o snippet do plano à hierarquia de erros do repo, que os
exception_handlers da API traduzem para HTTP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
O brief tinha um ValueError cru no save(); o padrão do projeto usa a
hierarquia de erros de domínio (src/support/core/exceptions.py),
traduzida a HTTP pelo exception_handlers e já seguida por
GetConversationAction e GetTurnTraceAction. Mesma mensagem, só troca a
classe da exceção. Adiciona teste unitário cobrindo o caminho de
"aula não existe".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cliente HTTP para /api/internal/programs/:slug/lessons e
/api/internal/videos/:id/media, autenticado por X-Internal-Secret
(BORDERLESS_INTERNAL_SECRET). Consumido pela ingestão offline do mentor
(Task 6/7): lista o catálogo de aulas de um programa e resolve a URL de
mídia por vídeo. O segredo viaja só no header, nunca em URL ou log.
Extração por campo (por linha da listagem e do corpo de mídia) também
cai em LessonCatalogUnavailableError quando o contrato não bate, mesmo
com 200 — mesmo padrão de BorderlessNavigationClient.resolve().

Adiciona placeholder de BORDERLESS_INTERNAL_SECRET em .env.example.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oletos

Revisão da Task 6 achou dois buracos no desenho: o save do caminho de
falha podia estourar e matar o lote, e uma aula em transcribing após kill
do processo ficava presa. Spec §7 e plano ganham MENTOR_CLAIM_STALE_MINUTES.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…im obsoleto

Guarda o save() do caminho de falha em execute() para que um banco fora do
ar no momento de gravar FAILED não propague exceção e derrube o lote —
mantém o motivo original da falha. Adiciona MENTOR_CLAIM_STALE_MINUTES e
ensina list_pending a recuperar aulas presas em TRANSCRIBING há mais tempo
que isso (claim de um processo que morreu), respeitando o teto de
tentativas nos dois ramos. Cobre offsets de múltiplas janelas de áudio no
nível da action.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sincroniza o catálogo de aulas de um programa (SyncProgramLessonsAction) e
transcreve as pendentes (IngestLessonAction, já pronta desde a Task 6). A
seleção do alvo segue a Controller Ruling B1: list_pending só enxerga
pending/failed (+ transcribing obsoleto), então uma aula já ready nunca
aparece nele — --lesson X --force precisa olhar o catálogo sincronizado
inteiro, não list_pending, para encontrar a aula e reprocessá-la. A seleção
foi isolada em _select_targets, função pura sem sessão, testável sem banco.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e config

Revisão final da ingestão apontou: o plano deixou cair o "commit imediato"
do claim; a spec citava embed_documents (é embed); o glossário não pode usar
descrição (a rota interna não expõe); a lista de settings estava incompleta.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oolkit de áudio

Fix wave sobre a revisão da ingestão do mentor (branch aprovada "com correções"):

- CRITICAL: claim (TRANSCRIBING/attempts) agora comita imediatamente após o
  save() — sem isso nenhuma linha committed marcava `transcribing`, permitindo
  processamento duplicado e deixando a recuperação de claim obsoleto morta.
- IMPORTANT: claim movido para dentro do try/except — falha no save/commit do
  claim devolve FAILED sem propagar exceção.
- IMPORTANT: URL de mídia assinada redigida (<media-url>) antes de entrar em
  failure_reason/logs; decode com errors="replace".
- IMPORTANT: timeout (MENTOR_SUBPROCESS_TIMEOUT_SECONDS=900) no ffmpeg/ffprobe,
  com kill do processo ao estourar.
- IMPORTANT: comentário do .env.example movido para linha própria (o valor
  inline virava parte do segredo); BorderlessLessonsClient falha cedo se
  BORDERLESS_INTERNAL_SECRET estiver vazio.
- IMPORTANT: list_pending desempata created_at por module_slug/video_slug
  (--limit N determinístico).
- MINOR: uuid7() em vez de uuid4() nas PKs de Lesson/LessonChunk criadas em
  Action; --limit 0 agora processa zero aulas (extraído _apply_limit); guarda
  contra ffprobe devolvendo "N/A"; extract_audio valida esquema http(s) da URL.
- MINOR: testes de integração de list_pending com program_slug único, vetor de
  embedding alinhado ao conftest, e caso de claim obsoleto incluído/fresco
  excluído (escrito e validado só via --collect-only — sem PostgreSQL aqui).

Suíte unitária: 506 -> 518 (12 testes novos), saída limpa. Integração
domain/lessons e o restante da suíte de integração seguem coletando sem erro.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adiciona a tool do mentor: `lesson_id` vem do config (o modelo só passa a
query), então um aluno não induz o modelo a ler uma aula que não comprou
(spec §2.1). A tool registra cada distância em `lesson_distances`, o
embedding da última busca em `question_embedding` (mutado in-place — o
`config["configurable"]` que a tool recebe é uma cópia rasa da do chamador,
via `ensure_config` do LangChain; só sobrevive reassign de chave se ela já
era a mesma lista mutável dos dois lados, daí a pré-semeadura como lista,
igual `lesson_distances`), estende `citations`, e devolve
`(nenhum trecho disponível nesta aula)` ou `(falha ao buscar na aula: ...)`
sem derrubar o streaming.

`tool_node_tools()` passa a incluir `search_lesson` (o ToolNode precisa saber
executá-la), mas o modelo comum de chat/navegação não deve vê-la — separado
em `model_bound_tools()`, que preserva exatamente o bind de antes desta
tarefa (R12). `_answer_model` (nodes.py) passa a usar `model_bound_tools()`
em vez de `tool_node_tools()`/`build_tools()` direto.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
route_entry pula o gate em mode="mentor" (sem recusa, spec §3); prompts.py
ganha MENTOR_PROMPT dedicado (nunca herda a RESPOSTA PADRÃO do oráculo);
_answer_messages omite o bloco de contexto da base nesse modo (chega via
search_lesson) e cita a aula em foco quando lesson_id está no state;
_answer_model liga só search_lesson ao modelo em modo mentor. StreamInput
aceita mode="mentor" e lesson_id opcional. lesson_id (id da Platform) passa
a viajar de TurnGraphPort.run()/TurnGraphRunner.run() até o state inicial do
grafo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 4: CheckLessonAccessAction nega acesso a aula fail-closed (aula
desconhecida, plataforma nega, ou entitlement indisponível), consultando
BorderlessLessonAccessClient (rota real da Platform, bearer do aluno —
cliente separado do BorderlessLessonsClient, que usa segredo interno).
O controller do /conversations/ask passa a checar entitlement ANTES de
montar o grafo em mode="mentor": 400 sem lesson_id, 403 em
LessonAccessDeniedError. Em sucesso, o platform_video_id vai ao state
(lesson_id) e o uuid interno vai ao extra_config da tool (as duas
identidades nunca se misturam).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 5: lesson_id, program_slug, lesson_coverage e question_embedding
propagam pelas quatro camadas do trace (model, entity, draft, mapper).
classify_coverage rotula a distância (covered/partial/gap, limiares
inclusivos) sem bloquear a resposta. apply_mentor_signals preenche o
draft a partir do configurable do turno mentor, sob try/except próprio
(ADR-0013) — trace nunca derruba a resposta.

Migration 0013 e o teste de integração de persistência foram escritos e
validados offline (py_compile, alembic history, --collect-only): sem
Postgres neste ambiente. Suíte unit passa de 558 para 572 (14 testes
novos), pristine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET /lessons/{platform_video_id}/status: a aba Mentor precisa saber se uma
aula já foi indexada antes de abrir o composer, para não deixar o aluno
perguntar numa aula sem transcrição. Aula não encontrada devolve 200
"unknown" (não 404) — é estado, não erro.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GetMentorInsightsAction lê agent_traces (intent="mentor") e produz duas
leituras: backlog (perguntas sem cobertura, mais recentes primeiro) e
retenção por aula (turnos, alunos distintos, citações/turno, % gap). O
mapeamento linha→DTO foi fatorado em funções puras para testar sem banco.
GET /ops/mentor nasce sob o require_admin já existente no router. No
frontend, MentorPanel some as duas seções ao lado das já existentes na
OpsPage, reusando client/hook/CSS já em uso pela página.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g ganha teste que exclui

MentorPanel explodia (`TypeError: gaps is not iterable`) com qualquer
resposta que não fosse {gaps, engagement} — cenário real, já que getJSON
não valida shape e o stub de App.test.tsx caía no fallback []. Agora
gaps/engagement são normalizados para array antes de iterar, e o stub
ganhou um caso /ops/mentor explícito.

O filtro program_slug nunca era exercido de fato: o único teste que passava
program_slug="base" batia mesmo sem o filtro, porque o default do fixture
seed_trace já é "base". _scope(program_slug, since) foi fatorado da Action
para dar um lugar DB-free de testar a condição via SQL compilado, e o
teste de integração ganhou um caso com dois programas divergentes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… switch

Wave de correções da revisão do branch inteiro (feat/speech-to-text, modo
mentor), aplicando todos os findings do relatório:

- C1: search_lesson (tools.py) abre seu próprio async_session_scope() quando
  a action não vem injetada — antes construía o repositório com a sessão do
  escopo 2 já fechada (ADR-0020), vazando conexão. ADR-0020 ganhou parágrafo
  sobre o padrão para tools futuras nessa situação.
- I1: cfg["question_embedding"][:] = ... movida para depois de coletar
  citações/distâncias, com guards — não sabota mais uma busca bem-sucedida.
- I2: MENTOR_ENABLED agora é um kill switch de verdade (404 antes de
  qualquer trabalho em ask() e em LessonsController.status).
- I3: turno mentor não busca mais o catálogo de navegação ao vivo.
- I4: teste de migração para 0013 (colunas/índices de agent_traces).
- T4: docstring precisa sobre maybeAuthenticate/gated vs ungated.
- T5: finalize_mentor_trace(draft, mode, extra_config) extraída e testada.
- T3: enable_tools=False vence mesmo em mode="mentor".
- M1: LessonAccessDeniedError sempre com a mesma mensagem opaca no 403.
- M2: retrieval_top_k = MENTOR_TOP_K no trace; threshold documentado em 0.0.
- M4: GET /ops/mentor?days= com teto (1..365).
- M7: dedupe de citações por url na tool.
- M8: URL da citação com urllib.parse.quote por segmento de slug.

Suíte: uv run pytest tests/unit -q → 597 passed (era 584, sem regressão).
Integração escrita e validada só com --collect-only (sem Postgres neste
ambiente) → 143 tests collected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…janela"

M3: o MentorPanel ainda não usa o WindowPicker da página de ops (item
parqueado desta wave) — o texto de estado vazio agora reflete o `days`
default da API em vez de sugerir uma janela que o painel não respeita.

npm test -- --run → 111 passed (sem regressão)
npx tsc --noEmit → sem erros

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Duannee and others added 10 commits September 11, 2026 21:55
Execução interrompida pelo usuário durante a revisão final do plano D.
Consolida estado dos 4 planos, ordem de retomada, bloqueios de ambiente,
checklist do que rodar com banco/ffmpeg, pendências de decisão e todos os
rulings dos ledgers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…asso é o fix wave

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
O `_FakeMentorChatModel` era um objeto solto com `ainvoke`: devolvia a
AIMessage, mas não emitia `on_chat_model_stream`. Como `text_of`
(src/support/agent/ports.py) monta o texto da resposta só desses eventos, o
controller persistia content=None e a mensagem do assistente nunca era
gravada — o teste caía em NoResultFound.

Agora herda de `ScriptedChatModel` (tests/fakes/scripted_chat_model.py), o
fake que já existe justamente para isso, e o teste passa a afirmar também o
texto persistido, não só a citação da aula.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Os testes montavam `mentor_embedding=[0.1, 0.2, 0.3]`. Contra o Postgres de
verdade o pgvector recusa o INSERT ("expected 1536 dimensions, not 3"), o
savepoint do trace aborta e o teste falha com "esperava 1 trace, achei 0" —
sem dizer por quê. Agora o vetor vem do `FakeEmbeddingsClient` com
`settings.EMBEDDING_DIM` casas, e a asserção conta as dimensões gravadas em
vez de só checar `IS NOT NULL`.

O teste do trace que quebra passa a comparar o conteúdo com `strip()`: o
`FakeTurnRun` emite cada token como `token + " "` (contrato do fake, usado por
outros testes) e o que importa é o texto, não o espaço final.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ruling C9. O teste irmão quebra ANTES do banco (a Action levanta); este cobre
a falha de verdade — `flush()` recusado pelo Postgres dentro do savepoint do
trace (embedding com dimensão errada de propósito), que foi exatamente o que
aconteceu ao rodar a suíte contra o Postgres real.

`_persist_turn` aguenta: o savepoint do trace aborta sozinho e a mensagem do
assistente é gravada no savepoint seguinte. Verificado por mutação — sem o
`begin_nested` do trace, a sessão fica envenenada
(PendingRollbackError) e a resposta se perde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dadas com banco local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cutados e verificados

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
O SDK do MCP 2.x (o pin do pyproject) expõe o campo em snake_case e mantém
isError apenas como alias de serialização, então ler o nome antigo levantava
AttributeError em TODA chamada — a ingestão do Notion estava morta, e a base
de conhecimento não podia ser sincronizada. A decodificação virou função
própria para poder ser testada sem subir o servidor por npx, com os testes
construindo o CallToolResult REAL do SDK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It described local machine state, env values and pending decisions for
resuming work in another context window, not the feature itself. The
spec and plans remain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant