Integrar a reformulação: sub-projetos 2 a 6 - #10
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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
Substitui o loadDetailsFromLocalDB da câmera, que fazia três queries e juntava as tabelas à mão em JavaScript. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
Com compensação em vez de transação: o driver não tem uma, e abrir uma enquanto o sync escreve na mesma conexão convida database is locked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
FK ligada (PRAGMA foreign_keys = ON) sem ON DELETE, e sem transação: quando appendMessage falha, chat_sessions já está commitada, e apagar só fila_diagnosticos era recusado em silêncio pelo próprio SQLite. Agora a limpeza desfaz chat_messages, depois chat_sessions, depois fila_diagnosticos — e a falha da própria limpeza vai para console.warn em vez de sumir. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
…opinião Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
…câmera Sobrevive ao app morrer no meio: reabrir a conversa volta a tentar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
O card sai da tela inteira e vira componente puro de props, com a bula recolhida para caber numa conversa. getCrossValidationPriority passa a receber só o status, que é tudo que ela olhava; o único call site em produção, em app/camera.tsx, foi ajustado para o novo formato. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
…vergência Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
De 1.995 linhas para uma tela que roda a inferência em memória e só grava quando o produtor confirma. O card, o feedback, a persistência e a orquestração da segunda opinião já mudaram de casa. O código de web sai junto: o target morreu no sub-projeto 1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
useCameraDevice enumera o hardware sem depender de permissão, então num aparelho recém-instalado `device` já existe com `hasPermission` ainda false: o obturador ficava tocável sob a própria tela de permissão e disparava capturePhoto num output sem nenhum <Camera> montado, caindo num "erro de captura" genérico em vez de pedir a permissão que falta. O obturador continua visível — esconder a affordance principal seria pior do que mostrá-la apagada — mas trava atrás de `podeCapturar` (permissão E sensor), o mesmo botão de flash ao lado já usava essa regra e agora reaproveita a derivada em vez de repetir a condição. De brinde, devolve à aba "Câmera" o accessibilityLabel que a reescrita anterior tinha perdido. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
A segunda opinião passa a ser disparada no mount da conversa, que é quem fica viva durante a chamada. O aviso legal aparece na tela de conversa pela primeira vez. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
diagnosticContext, buildDiagnosticChatContext e diagnosticDraftService existiam só para ligar a câmera ao chat e para limpar o que a câmera gravava antes da hora. Nenhum dos dois problemas existe mais. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
A revisão da branch inteira achou dois furos que o teste de dedupe concorrente não cobria. 1. diagnosticImageUploadService dedupava uploads por um mapa em voo que esvazia assim que a promessa termina — cobre só chamadas concorrentes. No mount da conversa, se a segunda opinião sobe a imagem com sucesso e grava a chave em fila_diagnosticos, mas a resposta do motor (disparada em paralelo) falha depois de já ter resolvido a mesma chave compartilhada, o CloudEngine nunca grava a chave no anexo da mensagem (só grava em onDone, numa resposta completa). Na próxima abertura da conversa o disparo automático roda de novo, resolveImageKey não acha nada em attachment.imageS3Key, e a foto sobe pela segunda vez — um PUT pago a mais no S3, com uma chave diferente da que o servidor já registrou no cross-validate. Agora uploadDiagnosticImage lê fila_diagnosticos.image_s3_key antes de mintar uma URL nova, cobrindo os três chamadores (cloudEngine, crossValidationService, syncService) sem tocar em nenhum deles. 2. Numa divergência com doença fora do catálogo local, o backend devolve llm_doenca_nome mas llm_doenca_id nulo (FK para doencas(id) não comporta doença fora do catálogo). Quando o produtor escolhia a hipótese da LLM nesses casos, o feedback chegava ao servidor sem nenhuma identificação da doença — justamente os casos mais valiosos para melhorar os modelos, porque são doenças que a visão local nem consegue representar. A nota automática do feedback agora carrega o nome da doença quando a doença escolhida foi a da LLM. 174 testes existentes + 1 novo cobrindo o item 1 = 175 verdes. Typecheck limpo. Backend não tocado. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy
A conversa e suas mensagens são a mesma unidade de falha do sync. Sem transação, um erro ao inserir as mensagens deixava uma conversa órfã persistida no Postgre enquanto o item era reportado como falho.
O ramo de update de syncDiagnostics (diagnóstico já existe, criado por /diagnosis/cross-validate antes do sync completo) não religava conversas órfãs — só o ramo de inserção tinha a religação. Cobre o cenário da spec: cross-validate cria a linha, o sync completo chega depois e cai em `existing`, deixando a conversa órfã para sempre sem esse ajuste.
Move sessoes_slm/interacoes_slm para conversas/mensagens antes de a Task 5 tirar as tabelas antigas do schema. Os ids das mensagens (<mobile_session_id>-u<i>/-a<i>) casam com os que a migração v7 do aparelho já fabricou, então a subida do aparelho reencontra a linha em vez de duplicá-la. Corrige dois pontos que o esboço do plano não previa: `interface` não satisfaz a restrição `Record<string, unknown>` de `db.execute<T>`, e o driver node-postgres do Drizzle devolve timestamp como texto sem timezone — sem ancorar em UTC, `new Date()` lê pela hora local de quem roda o script e desloca o timestamp migrado pelo fuso da máquina.
A Task 4 já moveu toda a telemetria histórica de sessoes_slm/interacoes_slm para conversas/mensagens (migrate-slm-to-conversas.ts). Com o dado migrado, o endpoint antigo e as tabelas que ele escrevia saem: rota, controller, schema Zod, service e teste de integração dedicado. drizzle-kit push dropou sessoes_slm e interacoes_slm do banco local (ambas vazias, verificado antes de aplicar). Um teste novo em sync.conversations.test.ts prova que /sync/slm-logs agora devolve 404. O script de migração da Task 4 continua intacto e é o único lugar do repositório que ainda pode citar os nomes antigos — ele lê as tabelas por SQL cru e precisa rodar em produção antes do próximo db:migrate lá.
…do bloco O teste anterior apenas afirmava a ausência de DROP TABLE, passando mesmo com o bloco v8 inteiramente ausente. Agora valida que apenas PRAGMA user_version; é emitido, provando que a guarda funciona e o bloco existe.
Título vazio (herdado da migração v7, que usa `?? 'Conversa'` e não pega string vazia) sobe como 'Conversa': o servidor valida title com min(1) de propósito, e quem cede é o cliente.
…nteira Sessão com mais de 200 mensagens pendentes parte em vários envelopes, e o corte em lotes de 20 conversas não se alinha por sessão: os envelopes irmãos podem cair em requisições diferentes. O código antigo marcava a lista inteira de mensagens da sessão como SYNCED assim que qualquer lote confirmava o session_id, então um envelope ainda não enviado — ou enviado num lote que viria a falhar — tinha suas mensagens dadas como sincronizadas sem nunca terem chegado ao servidor. Acrescenta também um teste que trava a forma das duas consultas de seleção de sessão (automática vs. manual), no padrão que db/sqliteMigrations.test.ts já usa: o fake de sessões decide pelo texto do SQL só via substring, então não pegaria uma regressão que fizesse a consulta automática reincluir sessão FAILED.
Liga as etapas prontas em Tasks 7 e 8 ao runFullSync (na ordem diagnósticos -> feedbacks -> conversas -> segundas opiniões -> catálogo -> limpeza, para que o vínculo diagnostico_id já exista quando a conversa sobe) e aposenta de vez o SLM no cliente: sai syncPendingSlmLogs, sai a linha de fila_slm_logs em cleanupSyncedRecords, e o useSyncStore/Home passam a contar pendingConversations via chat_sessions/chat_messages em vez da fila dropada na v8.
pendingResponseFor era um campo global: a conclusão tardia de uma sessão limpava a flag de outra que ainda respondia, e sair da tela no meio da resposta (o useEffect só abortava o controller) deixava a sessão presa em "respondendo" para sempre — reabri-la travava o campo de texto e tornava o disparo automático um no-op silencioso. Troca por pendingResponses: Record<string, true>, indexado por sessão, com marcarRespostaEmVoo/limparRespostaEmVoo. A limpeza do useEffect agora desmarca a sessão, que é a correção principal.
…za os testes por fuso Três correções que a revisão das Tasks 1 e 2 apontou, e que a execução em paralelo acabou juntando num commit só: - O teste do rename não afirmava nada sobre o valor de `updated_at`. Uma implementação que passasse data fixa anularia a guarda monotônica do servidor e passaria verde. Agora o relógio é congelado e o valor é exato. - As duas subconsultas da prévia ordenavam só por `created_at`, sem desempate: num empate de timestamp, conteúdo e papel podiam vir de linhas diferentes, e a lista mostraria a resposta do Agrônomo com o prefixo "Você: ". Ganham `, m.id DESC`. - As fixtures de data misturavam instante em UTC com leitura em hora local, e o teste da hora falhava em Los Angeles, Tóquio, Auckland e Kiritimati. Agora são construídas por componentes locais, e o de "Ontem" não repete mais a técnica da implementação para montar o valor esperado.
Conversas que falharam 5 vezes em sincronizar nunca chegaram ao servidor, então apagar localmente não deixa órfão. Mesmo raciocínio que já vale para PENDING. Acrescenta teste para o novo predicado.
/chat/novo abre a tela sem criar sessão. reload e requestResponse passam a aceitar um id explícito porque, depois do router.replace, a closure de sendMessage que está em voo ainda enxerga o sentinela 'novo' — sem isso, reload e a resposta operariam sobre uma sessão que não existe. A sessão só nasce (ensureSession) quando o produtor manda a primeira mensagem, mesma regra da câmera: nada é gravado antes de o produtor agir.
O efeito de carregamento re-roda quando o router.replace troca 'novo' pelo id real, e nesse segundo disparo findUnansweredUserMessage podia achar a mensagem que sendMessage acabou de gravar antes de a resposta terminar de chegar — a diferença de tempo entre leituras de SQLite e o streaming do motor é grande o suficiente. O ramo de texto ganhou a mesma guarda que requestResponse já usa (pendingResponses), evitando o banner de erro falso e, pior, uma resposta duplicada se o produtor tocasse "Tentar de novo" depois que a resposta real já tivesse chegado.
Entre appendMessage e a antiga chamada de reload havia uma fresta: a mensagem já estava no banco, mas pendingResponses ainda não sabia da sessão. Se o re-render do router.replace caísse exatamente nesse intervalo, o efeito de carregamento podia achar a mensagem "sem resposta" antes mesmo de requestResponse marcar a sessão como em voo — janela bem menor que a original (duração de um reload local, não do streaming inteiro), mas ainda real logo na primeira mensagem de uma conversa nova. requestResponse já marca a sessão de forma síncrona antes do primeiro await dela e busca o próprio histórico via listMessages, então disparar antes do reload fecha a fresta sem depender de reload ter terminado.
Sub-projeto 6: mapa de análises
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.
Traz para
mainos sub-projetos 2 a 6 da reformulação, que já foram revisados e mergeados individualmente mas nunca chegaram aqui.Por que este PR existe
Os PRs eram empilhados, e a pilha foi mergeada de baixo para cima com cada pai recebendo o merge antes de o filho chegar nele:
abandonar-web→mainmotor-de-conversa→abandonar-webfoto-vira-conversa→motor-de-conversasync-de-conversas→foto-vira-conversahistorico-de-sessoes→sync-de-conversasmapa-de-analises→historico-de-sessoesO #4 chegou em
main26 segundos antes de o #5 chegar emabandonar-web. Cada merge seguinte perdeu a carona do de cima pelo mesmo motivo. Resultado:mainficou só com o sub-projeto 1, e cada branch intermediária ficou com um commit de merge órfão — é o que faz o GitHub sugerir PRs novos nelas.Não é uma nova superfície de revisão
Cada sub-projeto já teve o seu PR, com revisão por tarefa e revisão de branch inteira:
Este PR é integração. O código não muda.
Verificações feitas antes de abrir
feat/historico-de-sessoesem qualquer das quatro branches intermediárias — não há código órfão em nenhuma delas.maintem a mais (70a1a40) já são ancestrais defeat/historico-de-sessoes.git merge-tree): limpo, sem conflito, e a árvore resultante é idêntica à defeat/historico-de-sessoes. Nada se perde.O que entra
A conversa passa a ser a unidade do app: o diagnóstico é a primeira mensagem dela, as conversas sobem para o Postgres, o produtor tem lista e mapa.
Depois do merge
As seis branches de feature podem ser apagadas, e os avisos de "Compare & pull request" nelas desaparecem.
Falta só o sub-projeto 7 — tela principal para fechar a reformulação. Ele herda duas coisas registradas nas Lacunas das specs: ligar as portas de entrada (a rota
/mapaexiste e funciona, mas ninguém chega nela) e fechar o estadoPROBINGno mapa, que hoje é inalcançável justamente por isso.