Skip to content

Motor de conversa e persistência (sub-projeto 2 da reformulação) - #5

Merged
lucasjosee merged 17 commits into
feat/abandonar-webfrom
feat/motor-de-conversa
Sep 13, 2026
Merged

lucasjosee merged 17 commits into
feat/abandonar-webfrom
feat/motor-de-conversa

Conversation

@lucasjosee

Copy link
Copy Markdown
Owner

Segunda branch do sub-projeto. Empilha sobre o PR #4 (abandonar o target web) — mergear o #4 primeiro.

Entrega o núcleo da reformulação: um único motor de conversa com duas implementações, de forma que nenhuma tela saiba qual está ativo, e conversas que persistem no SQLite em vez de morrerem ao fechar o app.

O desenho

tela  →  engineRouter.respond()
            ├─ FIELD ................ LocalEngine  (SLM, janela de 10)
            ├─ ONLINE / DEGRADED .... CloudEngine  (LLM multimodal, janela de 20)
            └─ falhou antes do 1º token? → local assume em silêncio

EngineRouter é o único módulo do app que conhece os dois motores. A tela grava o source da mensagem a partir de EngineDoneMeta.kind — o motor que efetivamente respondeu, inclusive depois de um fallback.

O fallback por requisição passa a existir. A documentação do projeto o descrevia como o mecanismo mais importante para a experiência do produtor (10s sem token e a SLM assume), mas ele nunca esteve no código — o serviço antigo só disparava timeout e parava.

O que muda

Tabelas chat_sessions e chat_messages (migração v7) conversas sobrevivem ao fechamento do app; sessões antigas de fila_slm_logs são migradas
lib/engine/ ConversationEngine + LocalEngine + CloudEngine + EngineRouter
lib/chatRepository.ts única porta para as tabelas de conversa — nenhuma tela executa SQL
lib/catalogContext.ts um construtor de contexto, por doenca_id, idêntico para os dois motores
app/chat/[sessionId].tsx a conversa; sem if (isField)
/chat/stream recebe catalog_context pronto; o servidor deixa de consultar o banco

Três dívidas fechadas de brinde

Contexto por nome virou contexto por id. O servidor buscava a doença pelo nome_comum vindo do cliente e, ao não encontrar, respondia sem embasamento nenhum, em silêncio. Agora o contexto é construído por doenca_id no cliente e enviado pronto — os dois motores recebem grounding idêntico.

As duas stacks HTTP viraram uma. O chat lia o token direto do SecureStore e não renovava em 401. O CloudEngine usa o mesmo mutex de refreshAccessToken do axios — dois refreshes concorrentes apresentariam o mesmo token e o servidor revogaria todas as sessões do usuário.

A bula entrou no contexto. O construtor antigo ignorava modo_de_acao e epoca_aplicacao, que já estavam no aparelho. Agora entram, dentro de um orçamento verificado por teste (≤ 2.000 caracteres, contra o n_ctx: 2048 da SLM).

Bugs encontrados pelas revisões

Cada tarefa teve implementador e revisor independentes. Seis precisaram de rodada de correção, e vários achados eram defeitos do plano, transcritos fielmente:

  • Migração v7 abortava com interactions_json válido mas não-array ("{}", "null") — o aparelho ficava na v6, sem as tabelas de conversa, permanentemente.
  • Corrida no CloudEngine: um abort() durante o refresh de 401 abria uma conexão nova para um fluxo já cancelado, sem nunca reportar ABORTED.
  • Orçamento de contexto errado por 70%: 3 defensivos × 300 caracteres davam ~2.600, não os ~1.500 estimados. Campos truncados em 200 e o teto virou propriedade asserida por teste.
  • Teste que custava dinheiro: um teste de rota alcançava o LLM real a cada npm test local. Trocado por verificação do schema — cobertura maior, sem rede.
  • Mensagem que mentia: ao falhar a gravação, a tela dizia que a resposta sumiria "ao sair da tela", mas ela sumia na hora. Agora fica visível, rotulada "não salva".

Verificação

Antes Depois
Testes frontend 54 141
Testes backend 65 71
Typecheck limpo limpo

Critérios de aceite da spec §8 conferidos por varredura: nenhuma tela contém if (isField); os 8 símbolos do §7 não existem mais; mensagem enviada offline fica PENDING; a mesma chamada respond() cai em motor diferente conforme o modo.

Nota de processo

A re-revisão escopada da última onda de correção não foi feita por um revisor independente — o subagente bateu no limite de sessão. Verifiquei os quatro itens eu mesmo (renomeação sem chamadores órfãos, tela disparando só para foto, teste do mutex provando liberação-e-nova-tentativa, suítes e typecheck). O diff dessa onda tem 14 KB e é o commit af92e63; vale um olhar seu.

Fora de escopo, por desenho

app/camera.tsx não foi reescrito — é o sub-projeto 3. Por isso nada ainda cria uma mensagem com foto, e o disparo automático não tem quem o acione no fluxo atual. fila_slm_logs permanece até o sub-projeto 4; a migração copiou as linhas para as tabelas novas e deixou as originais, então o sub-projeto 4 precisa evitar subida dupla.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH

lucasjosee and others added 17 commits September 12, 2026 08:46
chat_sessions e chat_messages no padrão de fila que já existe. Linhas de
fila_slm_logs viram sessões e mensagens, todas PENDING — as tabelas de
conversa no servidor só nascem no sub-projeto 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
JSON válido mas não-array ("{}", "null") escapava do try/catch do parse e
lançava TypeError, abortando a migração inteira — o aparelho ficava em v6
sem as tabelas de conversa. Cai na mesma regra de "sem interações": pula.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Única porta para chat_sessions e chat_messages. Nenhuma tela executa SQL de
conversa. Inclui a consulta do mapa (join com fila_diagnosticos) e a busca da
foto sem resposta, gatilho do disparo automático.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Substitui as duas construções (cliente por JOIN, servidor por nome). Usa a
bula_resumida, que o contexto antigo ignorava, dentro do orçamento do n_ctx
de 2048 da SLM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
…acteres

A estimativa de ~1.500 caracteres estava errada: 3 defensivos × 2 campos ×
300 davam 1.800 só de bula, ~2.600 no total, encostando no n_ctx de 2048
da SLM. Campos truncados em 200 e o teto de 2.000 caracteres passa a ser
asserido por teste.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
ConversationEngine, EngineInput, EngineCallbacks e EngineError. O contexto
que a câmera entrega passa a carregar doenca_id e diagnostic_local_id, para a
sessão de conversa apontar ao diagnóstico em vez de depender do nome.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
…artilhada

Único caminho de refresh do app. Dois refreshes concorrentes apresentariam o
mesmo refresh token duas vezes e o servidor revogaria todas as sessões do
usuário. Substitui a fila manual isRefreshing + failedRequestsQueue.

Usa `await import('../store/useAuthStore')` em vez de require() lazy para
quebrar o ciclo api.ts <-> useAuthStore.ts: sob Vitest 4, require() em runtime
não passa pelo grafo de módulos SSR (usa createRequire real do Node), então
não resolve .ts sem extensão nem respeita vi.mock. Import dinâmico preserva a
mesma semântica de carregamento tardio e é compatível com o teste.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Janela de 10 mensagens, preâmbulo com o achado do CV quando há foto, e
MODEL_NOT_LOADED como erro nomeado para o .gguf ausente.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Substitui cloudChatService. Janela de 20, nota de origem local, upload da
foto quando ainda não está no S3, contrato novo do /chat/stream, e renovação
de token em 401 pelo mutex compartilhado do api.ts — fecha a dívida das duas
stacks HTTP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
close() remove o listener de abort antes do refresh, e o novo só é
registrado na tentativa seguinte — como AbortSignal dispara uma única vez,
um cancelamento nessa janela abria uma conexão nova para um fluxo morto e
nunca reportava ABORTED. Também fixa por teste os ramos de erro no corpo do
SSE e a decisão de considerar o histórico inteiro na nota de origem local.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
…a do modelo

Política por modo de rede, fallback silencioso da cloud para a local só antes
do primeiro token, pré-carga em DEGRADED, espera bounded em PROBING e
histerese de 60s antes de descarregar o .gguf. O fallback por requisição
existia só na documentação; agora existe no código.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
O histórico sai da memória e passa a viver em chat_messages. Sobram estado de
streaming, sessão ativa, resposta em voo, progresso do modelo e a ponte com a
câmera. Saem condenseForSlm, expandForCloud, logSlmInteraction e as janelas
por motor — tudo virou interno aos motores.

Remove também store/chatSlmLog.test.ts: testava exclusivamente logSlmInteraction,
que não foi migrada para nenhum motor (LocalEngine/CloudEngine não têm
fila_slm_logs) — funcionalidade removida, não realocada, confirmando o texto
desta mesma mensagem de commit no brief da Tarefa 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
O servidor deixa de montar contexto por nome — o miss era silencioso e o
modelo respondia sem embasamento. Recebe o contexto construído no cliente
por doenca_id, idêntico ao que a SLM local usa, mais o achado do CV. Schema
strict: o contrato antigo falha com 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
O teste de rota alcançava o provider real (o controller constrói ChatService
no escopo do módulo), então todo `npm test` local com chave real disparava
uma chamada paga. A aceitação do contrato é propriedade do schema, que é
exportado — verificá-la ali cobre mais casos (doenca_id nulo, limite de
4000) sem rede. Na rota fica só a rejeição com 400, que curto-circuita na
validação.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
/chat cria a sessão e redireciona; /chat/[sessionId] carrega do SQLite, chama
o roteador e não sabe qual motor responde. Foto sem resposta ao abrir a
sessão dispara sozinha (spec §3.3). Texto sem resposta oferece tentar de
novo. Erros mapeados por EngineError; parcial exibido sem persistir.
buildCatalogContext é envolvido em try/catch na tela — falha de catálogo
não impede a resposta, só perde o grounding com um console.warn.

Typecheck limpo pela primeira vez desde a Task 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
A mensagem dizia que o texto sumiria ao sair da tela, mas resetStreaming()
zerava o conteúdo na hora — a resposta desaparecia antes de o produtor
conseguir lê-la. Reaproveita o mesmo caminho do parcial para mantê-la
visível.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Mensagem de texto sem resposta agora oferece "gerar resposta" ao reabrir a
sessão, como a spec §3.3 exige — antes só a foto se recuperava, e uma
pergunta interrompida ficava muda sem caminho na interface. A busca no
repositório foi generalizada para a última mensagem do usuário não
respondida, e quem chama decide entre disparar (foto) e oferecer (texto).

Em refreshAccessToken, o await import passou para dentro do try, para o
finally que libera o mutex cobrir também esse caminho — se o import
rejeitasse, toda autenticação travava até reiniciar o app.

Fecha também dois vãos de teste apontados na revisão: o ramo assíncrono de
MODEL_NOT_LOADED no LocalEngine e updateMessageAttachment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
@lucasjosee
lucasjosee merged commit e65c452 into feat/abandonar-web Sep 13, 2026
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