ia + saúde

Como impedir que um LLM invente a dose do remédio

· Vinícius Levi

No EntreSentidos, uma pessoa cega encosta o celular na caixa do remédio e pergunta, por voz, o que precisa saber. Se a resposta errar a dose, o erro chega com a voz confiante de um assistente. Por isso o projeto foi desenhado em torno de uma regra: na dúvida, o sistema não responde com as próprias palavras.

O problema com "é só mandar a bula para o modelo"

Colar a bula no prompt e pedir "responda só com base no texto" funciona na maior parte das vezes. O problema é o resto. O modelo pode misturar "2 comprimidos" com "2 doses" de outro trecho, inverter uma contraindicação ou completar com o que "costuma ser" verdade para remédios parecidos. Instrução no prompt é uma intenção; eu precisava de uma garantia.

O pipeline em cinco etapas

  1. Triagem de emergência. "Tomei demais" ou "falta de ar" pulam todo o resto: a resposta começa pelo SAMU (192) e pelo Disque-Intoxicação, e depois lê a seção de superdose da bula.
  2. Recuperação. A pergunta busca trechos da bula do medicamento identificado (BM25 sobre as 9 seções da Anvisa). Se nada relevante aparece, a resposta é "não encontrei na bula", sem chamar o LLM.
  3. Geração restrita. O Claude recebe só os trechos e devolve JSON (can_answer, answer, used_passages), podendo recusar.
  4. Verificação. Cada frase da resposta é conferida contra os trechos, em código, sem depender do modelo.
  5. Resposta segura. Se a verificação reprova duas vezes, ou se o LLM está fora do ar, o app lê o trecho literal da bula.

A verificação, frase por frase

A parte que mais importa é deliberadamente simples. Para cada frase da resposta, três checagens:

# 1) números e doses precisam existir nos trechos, com a mesma unidade
if not all(quantity_supported(q, source_quantities) for q in quantities(sentence)):
    unsupported.append(sentence)

# 2) cobertura lexical: quase toda palavra de conteúdo tem que estar na fonte
coverage = sum(1 for s in stem_set if s in source_stems) / len(stem_set)
if coverage < threshold:  # 0,75
    unsupported.append(sentence)

# 3) polaridade: resposta com negação exige fonte com negação (e vice-versa)
if _has_negation(sentence) != _has_negation(source_match):
    unsupported.append(sentence)

O primeiro item é o que pega os erros perigosos. Um número solto não vale nada: "2" casa com qualquer coisa. Por isso cada número é extraído junto com a unidade, e "2 comprimidos" só passa se a bula disser "2 comprimidos". O terceiro item pega a frase que troca "não tome com álcool" por "tome com álcool", que teria quase 100% de cobertura de palavras.

Existe ainda um segundo juiz opcional, outra chamada ao modelo que confere cada afirmação. Mas ele é complemento, não base: a camada determinística roda sempre e não tem como ser convencida por um texto bem escrito.

Fail-closed como regra de projeto

Toda falha leva ao mesmo lugar: a resposta mais conservadora disponível. Reprovou na verificação? Uma nova tentativa, com as frases reprovadas como feedback. Reprovou de novo? Trecho literal. API do LLM caiu? Trecho literal. Não achou nada? "Não encontrei na bula, consulte um farmacêutico."

Isso tem um custo: às vezes a resposta fica menos natural do que poderia. Para conteúdo médico, é a troca certa. Tanto que o sistema roda sem chave de API nenhuma, no que chamei de modo extrativo: só devolve frases da bula. É assim que ele está no ar hoje, e dá para testar na página do projeto.

Rastreabilidade

Cada resposta volta com a versão e o hash da bula usada, e os trechos que a sustentam. As bulas são versionadas por hash: reimportar o mesmo PDF não muda nada, e um texto novo vira a versão N+1 sem apagar a anterior. Se alguém questionar uma resposta daqui a seis meses, dá para saber exatamente de qual texto ela saiu.

O que eu levaria para outros projetos

  • Separe geração de verificação. O modelo escreve; código verifica. Pedir ao mesmo modelo para "ter cuidado" não é verificação.
  • Verifique o que é perigoso, não o que é fácil. Aqui eram números com unidade e negações. Em outro domínio podem ser datas, valores ou nomes.
  • Decida antes qual é a resposta segura. Quando a falha já tem destino, o resto do sistema fica mais simples.

O código está em entresentidos-backend, com a verificação em app/services/verification.py.