Motor de conversa e persistência (sub-projeto 2 da reformulação) - #5
Merged
Merged
Conversation
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
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
EngineRouteré o único módulo do app que conhece os dois motores. A tela grava osourceda mensagem a partir deEngineDoneMeta.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
chat_sessionsechat_messages(migração v7)fila_slm_logssão migradaslib/engine/ConversationEngine+LocalEngine+CloudEngine+EngineRouterlib/chatRepository.tslib/catalogContext.tsdoenca_id, idêntico para os dois motoresapp/chat/[sessionId].tsxif (isField)/chat/streamcatalog_contextpronto; o servidor deixa de consultar o bancoTrês dívidas fechadas de brinde
Contexto por nome virou contexto por id. O servidor buscava a doença pelo
nome_comumvindo do cliente e, ao não encontrar, respondia sem embasamento nenhum, em silêncio. Agora o contexto é construído pordoenca_idno 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
CloudEngineusa o mesmo mutex derefreshAccessTokendo 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_acaoeepoca_aplicacao, que já estavam no aparelho. Agora entram, dentro de um orçamento verificado por teste (≤ 2.000 caracteres, contra on_ctx: 2048da 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:
interactions_jsonválido mas não-array ("{}","null") — o aparelho ficava na v6, sem as tabelas de conversa, permanentemente.CloudEngine: umabort()durante o refresh de 401 abria uma conexão nova para um fluxo já cancelado, sem nunca reportarABORTED.npm testlocal. Trocado por verificação do schema — cobertura maior, sem rede.Verificação
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 ficaPENDING; a mesma chamadarespond()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.tsxnã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_logspermanece 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