Skip to content

Integrar a reformulação: sub-projetos 2 a 6 - #10

Merged
lucasjosee merged 57 commits into
mainfrom
feat/historico-de-sessoes
Sep 14, 2026
Merged

lucasjosee merged 57 commits into
mainfrom
feat/historico-de-sessoes

Conversation

@lucasjosee

Copy link
Copy Markdown
Owner

Traz para main os 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:

09-13 00:03:33 #4 abandonar-web → main
09-13 00:03:59 #5 motor-de-conversa → abandonar-web
09-13 10:56 #6 foto-vira-conversa → motor-de-conversa
09-14 01:09 #7 sync-de-conversas → foto-vira-conversa
09-14 09:14 #8 historico-de-sessoes → sync-de-conversas
09-14 09:54 #9 mapa-de-analises → historico-de-sessoes

O #4 chegou em main 26 segundos antes de o #5 chegar em abandonar-web. Cada merge seguinte perdeu a carona do de cima pelo mesmo motivo. Resultado: main ficou 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

  • Zero commits não-merge existem fora de feat/historico-de-sessoes em qualquer das quatro branches intermediárias — não há código órfão em nenhuma delas.
  • Os dois pais do commit de merge que main tem a mais (70a1a40) já são ancestrais de feat/historico-de-sessoes.
  • Merge a seco (git merge-tree): limpo, sem conflito, e a árvore resultante é idêntica à de feat/historico-de-sessoes. Nada se perde.

O que entra

Testes 82 backend, 226 frontend
Typecheck limpo nos dois pacotes

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 /mapa existe e funciona, mas ninguém chega nela) e fechar o estado PROBING no mapa, que hoje é inalcançável justamente por isso.

lucasjosee and others added 30 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
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
…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.
@lucasjosee
lucasjosee merged commit 3c63733 into main Sep 14, 2026
2 checks passed
@lucasjosee
lucasjosee deleted the feat/historico-de-sessoes branch September 14, 2026 10:03
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