Skip to content

feat: foto vira conversa (sub-projeto 3) - #6

Merged
lucasjosee merged 13 commits into
feat/motor-de-conversafrom
feat/foto-vira-conversa
Sep 13, 2026
Merged

lucasjosee merged 13 commits into
feat/motor-de-conversafrom
feat/foto-vira-conversa

Conversation

@lucasjosee

Copy link
Copy Markdown
Owner

Sub-projeto 3 de 7 da reformulação: a foto deixa de ser uma tela de resultado e vira a primeira mensagem de uma conversa.

⚠️ Empilhado. Base é feat/motor-de-conversa (#5), que empilha em feat/abandonar-web (#4). Ordem de merge: #4 → #5 → este.

Spec: CropAI-docs/especificacoes/2026-09-12-foto-vira-conversa-design.md
Plano: CropAI-docs/planos/2026-09-12-foto-vira-conversa.md
Registro de execução, com as 12 decisões tomadas e o custo de cada uma se estiver errada: CropAI-docs/historico/execucoes/2026-09-13-foto-vira-conversa-ledger.md

O que muda

O obturador roda a inferência em memória e mostra o veredito para confirmação. Nada é gravado até o produtor tocar em "Analisar" — 3 a 5 tentativas até um bom quadro é o normal em campo, e cada tentativa persistida custaria uma sessão no histórico, um PUT no S3 e uma chamada paga ao modelo. Confirmada a foto, nascem diagnóstico, sessão e primeira mensagem numa operação só, e a tela navega para a conversa.

Na conversa, o diagnóstico é renderizado como card dentro da bolha da foto — a partir do banco, sem depender de modelo de linguagem nenhum. É o que mantém RF01 e RF02 vivos quando o modelo local não está instalado e não há sinal. A segunda opinião do modelo de nuvem roda em paralelo e atualiza o card quando chega; os botões de feedback (RF06) passam a nomear as duas hipóteses quando há divergência. O aviso legal da Lei 7.802 passa a existir na tela de conversa, que até aqui não avisava nada.

app/camera.tsx: 1.995 → 510 linhas. A ponte provisória que o sub-projeto 2 deixou (diagnosticContext), o buildDiagnosticChatContext e o diagnosticDraftService inteiro deixam de existir — os problemas que eles resolviam não existem mais.

Nenhuma migração de banco. O schema do sub-projeto 2 já bastava.

Três defeitos que a execução encontrou

1. A câmera não funcionava em aparelho — e isso é anterior a este PR.
O código chamava nativeCameraRef.current.takePhoto(...), método que não existe na react-native-vision-camera@5.0.11 instalada. Passou pelo typecheck porque o arquivo declarava let NativeCamera: any e useRef<any>, e o require condicional (que existia só para o bundler de web) apagava a checagem daquele caminho. Mesma classe do bug loadModel/loadTensorflowModel registrado no sub-projeto 2. A reescrita corrige: import estático e tipado, captura por usePhotoOutput() + capturePhoto() + saveToTemporaryFileAsync().

2. A compensação não compensava.
chat_sessions.origin_diagnostic_local_id é FK para fila_diagnosticos sem ON DELETE, o banco roda com PRAGMA foreign_keys = ON, e cada execute é autocommit. Apagar o diagnóstico primeiro era recusado pelo SQLite, e o .catch mudo engolia a recusa — sobrava o órfão mais uma conversa vazia apontando para ele, que o JOIN do mapa exibiria como análise real. A limpeza agora vai de filho para pai: mensagens, sessão, diagnóstico.

3. "Um PUT por foto" só valia no caminho concorrente.
Se a resposta do motor falhasse depois de a segunda opinião já ter subido a imagem, a chave nunca chegava ao anexo da mensagem — só o onDone a grava ali. Na abertura seguinte o disparo automático subia a foto de novo: segundo PUT pago, com chave diferente da que o servidor já registrou. O upload passa a ler fila_diagnosticos.image_s3_key antes de mintar URL nova.

Verificação

Frontend 141 → 175 testes verdes, 21 arquivos
Backend 71 verdes, intocado pela branch
Typecheck limpo nos dois pacotes
Migrações nenhuma (db/sqlite.ts sem diff)
Código de web em camera.tsx zero ocorrências

Cada uma das 11 tarefas passou por revisão independente própria; duas tiveram rodadas de correção com re-revisão escopada. A revisão da branch inteira saiu "com correções", e as correções foram aplicadas e re-revisadas.

O que este PR não prova

Nada aqui tocou um aparelho. Como o defeito nº 1 mostra, a captura nunca funcionou em dispositivo, então nenhum dos três sub-projetos foi homologado de fato. Quatro itens ficam para a homologação de campo:

  1. Com o .gguf ausente e o aparelho offline, o card aparece completo — doença, severidade, sintomas, defensivo, dosagem, carência e aviso legal — e só a prosa falta. É a afirmação central do desenho.
  2. Tocar em "Repetir" cinco vezes não deixa linha nenhuma em fila_diagnosticos nem em chat_sessions.
  3. Fechar e reabrir a conversa mostra o mesmo card e não pede o feedback de novo.
  4. O painel de debug sob __DEV__ leva a imagem mock até o card, com a segunda opinião em SKIPPED.

Decisões que valem sua revisão

  • Trailer dos commits credita "Claude Sonnet 5" (quem escreveu o código), não Opus 5. Cada implementador seguia o reminder da própria sessão; forçar o contrário custaria uma correção por tarefa para creditar um modelo que não escreveu nada.
  • A segunda opinião de uma foto capturada offline continua nascendo SKIPPED. Deixá-la pendente para rodar quando o sinal voltasse seria melhor para o produtor, mas o cliente só sincroniza linhas PENDING e o servidor protege o veredito por TERMINAL_CV_STATUSES — o veredito tardio nunca chegaria lá, e cliente e servidor divergiriam em silêncio. Fica para o sub-projeto 4, que constrói o caminho de re-sync.
  • Armadilha latente não corrigida: cv.llmDoencaNome é lido dentro de enviar no FeedbackPanel mas não está nas dependências daquele useCallback. Não é bug vivo — onSubmitted é recriado a cada render e força o recompute —, mas acorda se alguém estabilizar aquele prop.

🤖 Generated with Claude Code

https://claude.ai/code/session_015dTz4Z51NvTgHWGrisq2oy

lucasjosee and others added 13 commits September 12, 2026 21:33
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
@lucasjosee
lucasjosee merged commit 04da555 into feat/motor-de-conversa 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