FHIR gerado por IA passa no validador da HL7? Testei 3 modelos

Três modelos abertos geraram FHIR a partir de 30 notas sintéticas. O melhor acertou 211 de 211 sinais vitais, e nenhum dos 90 Bundles passou no validador oficial da HL7.

Rogério Rodrigues

Rogério Rodrigues

Engenheiro de IA · mestrando em Informática em Saúde (UFSC)

Terminal escuro: Qwen3.8 27B com 211/211 sinais vitais certos e 0/30 no validador HL7, erro mais comum magic LOINC 2708-6 required. Texto: Acertou o dado. Errou o FHIR. 3 modelos, 90 Bundles, 0 aprovados.

Fala pessoal,

A RNDS só conversa em FHIR. Quem integra sistema de saúde no Brasil já pensou no atalho: pedir pra IA montar o Bundle direto da nota clínica. Ou passar a mensagem HL7 v2 antiga num conversor e mandar o resultado.

Eu rodei os dois caminhos e passei tudo na mesma régua. Dos 90 Bundles gerados por IA, 0 passaram no validador oficial da HL7. A resposta curta:

  • Os dois modelos maiores acertam o conteúdo e erram a forma. O Qwen3.8 27B acertou 211 de 211 sinais vitais (valor, código LOINC e unidade). O validador oficial da HL7 reprovou 30 de 30 Bundles dele. Somando os três modelos, foram 90 Bundles e 0 aprovados.
  • O conversor acerta a forma e erra o conteúdo. O hl7gen 0.2.0 saiu com 0 de 100 aprovados. Depois que acrescentei uma linha (o fullUrl de cada entrada), passou pra 100 de 100. Mas nenhuma das 250 Observations e Encounters aponta pro paciente.
  • Uma rodada de conserto muda o placar. Devolvendo ao modelo os erros do validador, uma vez só, o gpt-oss-120b foi de 0 pra 11 aprovados nas 16 notas em que a rodada coube. O Qwen foi pra 6 de 9.

Passar no validador não quer dizer que o dado serve. E dado certo que não passa no validador não entra na RNDS. Precisa das duas réguas.

Diagrama do teste: nota sintética passa por três modelos de IA e mensagem HL7 v2 passa pelo hl7gen; os Bundles FHIR das duas fontes vão para o validador oficial da HL7 e para a checagem de fidelidade contra o gabarito.

O que exatamente eu medi?

Duas fontes de FHIR, as mesmas duas réguas.

Fonte 1, IA. 30 notas clínicas sintéticas em português (nome inventado, CNS fictício, sinais vitais sorteados), cada uma com gabarito. Três modelos abertos no Groq, todos com o mesmo prompt congelado e temperatura 0: gpt-oss-20b, gpt-oss-120b e Qwen3.8 27B. O prompt pede um Bundle FHIR R4 com Patient, Encounter e uma Observation por sinal vital, com LOINC e UCUM.

Fonte 2, conversor. 100 mensagens HL7 v2 sintéticas (50 ADT_A01, 50 ORU_R01) geradas e convertidas pelo hl7gen 0.2.0, sem IA no meio.

Régua 1, o validador oficial. O validator_cli da HL7 (versão 6.3.11), o mesmo motor que o pessoal do FHIR usa pra validar Implementation Guide. Ele confere estrutura, tipo, cardinalidade e os perfis do core. Não confere se o dado bate com a nota.

Régua 2, fidelidade. Um script meu que compara cada Bundle com o gabarito da nota: o sinal vital está lá, com o código LOINC certo, o valor certo e a unidade UCUM certa? A Observation aponta pro paciente?

O prompt, literal:

Você é um sistema de integração hospitalar. Converta a nota clínica abaixo em um único
Bundle FHIR R4 em JSON (type "collection") com:
- 1 Patient (nome, sexo, data de nascimento, identificador CNS com system
  "https://saude.gov.br/fhir/sid/cns");
- 1 Encounter ambulatorial ligado ao Patient, com a data do atendimento;
- 1 Observation por sinal vital medido na nota, ligada ao Patient e ao Encounter, com código LOINC
  e unidade UCUM. Pressão arterial vai numa Observation só, com componentes sistólica e diastólica.
Responda APENAS com o JSON, sem texto antes ou depois e sem bloco de código.

Nota clínica:
<nota clínica sintética>

O placar

FonteJSON lidoPassa no validadorLigado ao pacienteSinais vitais certosVálido e fiel
hl7gen 0.2.0, como sai100/1000/1000/250sem gabariton/a
hl7gen 0.2.0 + fullUrl100/100100/1000/250sem gabariton/a
gpt-oss-20b17/300/30118/118101/2110/30
gpt-oss-120b30/300/30211/211206/2110/30
Qwen3.8 27B30/300/30211/211211/2110/30
Placar: gpt-oss-20b acertou 101 de 211 sinais vitais, gpt-oss-120b 206 de 211, Qwen3.8 27B 211 de 211; os três tiveram 0 de 30 Bundles aprovados no validador. Com uma rodada de conserto, gpt-oss-120b passou 11 de 16 e Qwen 6 de 9; gpt-oss-20b, 0 de 11.

“Válido e fiel” é o único número que importa pra integração: passou no validador E disse o que a nota diz. Na geração direta, ficou em zero pra todo mundo.

O que o validador pegou?

Quatro erros explicam quase tudo.

1. Saturação sem o código que o perfil exige. 59 dos 60 Bundles do gpt-oss-120b e do Qwen caíram aqui. Os dois codificaram a SpO2 com o LOINC 59408-5 (oximetria de pulso), que é um código correto. Só que o perfil de sinais vitais do core FHIR exige também o 2708-6 na mesma Observation. O validador avisa sem rodeio:

Error @ Bundle.entry[6].resource/*Observation/obs-spo2*/.code
OxygenSatCode: magic LOINC code 2708-6 required, but not found
(from http://hl7.org/fhir/StructureDefinition/oxygensat|4.3.0).
Note that other Observation codes are allowed in addition to this required magic code

O conserto é uma linha a mais no coding:

{
  "code": {
    "coding": [
      {
        "system": "http://loinc.org",
        "code": "2708-6",
        "display": "Oxygen saturation in Arterial blood"
      },
      {
        "system": "http://loinc.org",
        "code": "59408-5",
        "display": "Oxygen saturation in Arterial blood by Pulse oximetry"
      }
    ]
  }
}

Eu também não lembraria desse detalhe de cabeça. O modelo não tem como adivinhar essa regra; o validador pega.

2. fullUrl que não é UUID. Os modelos escrevem urn:uuid:patient-001. Parece UUID, mas não é. O validador recusa: UUIDs must be valid and lowercase (patient-001). Apareceu nos 30 Bundles do Qwen.

3. Bundle sem fullUrl. O mesmo erro do hl7gen, e também de 13 dos 17 Bundles legíveis do gpt-oss-20b. Num Bundle do tipo collection, toda entrada precisa de fullUrl. É a identidade do recurso dentro do pacote.

4. Unidade inventada. O gpt-oss-20b escreveu beats/min, breaths/min, bpm, irpm e 1/min como código UCUM em 9 dos 17 Bundles legíveis (e o 120b, em 2). O valor estava certo, 93 é 93. Mas o perfil de sinais vitais fixa o código em /min, e o validador acusa: Value is 'beats/min' but must be '/min'. (O 1/min até é UCUM válido, só não é o que o perfil pede.)

// o que o gpt-oss-20b escreveu (nota-007)
"valueQuantity": {"value": 93, "unit": "beats/min",
                  "system": "http://unitsofmeasure.org", "code": "beats/min"}

// o que o perfil exige: code "/min"
"valueQuantity": {"value": 93, "unit": "beats/minute",
                  "system": "http://unitsofmeasure.org", "code": "/min"}

O que o validador não pega?

Tudo abaixo passa quieto no validador (sem servidor de terminologia, como rodei aqui).

Observation que não aponta pro paciente. É o caso do hl7gen: 0 de 250 referências. Você recebe um Bundle “válido” com Observations que não são de ninguém, e com código aleatório sem system. Na régua 1, nem erro dá. Vira um aviso de boa prática (all observations should have a subject), perdido no meio dos outros avisos.

Horário que ninguém mediu. A nota diz só a data do atendimento. O Qwen preencheu a hora como T00:00:00Z (por exemplo, 2026-10-03T00:00:00Z) em 24 dos 30 Bundles. Meia-noite em UTC é 21h do dia anterior em Brasília. O registro muda de dia e o validador acha ótimo.

Código certo da coisa errada. Em 2 Bundles o gpt-oss-120b codificou a glicemia capilar (mg/dL) com o LOINC 14749-6, que é glicose em mmol/L no soro. A unidade diz mg/dL e o código diz outra medida.

E quando o agente devolve o erro pro modelo?

O agente do teste faz isto: gerar, validar, devolver a lista de erros pro modelo e validar de novo. Uma rodada só, sem eu tocar no JSON.

# src/repair.py (trecho simplificado): uma rodada, sem mexer no JSON à mão
erros = sorted({m for s, m in val.get(src.name, []) if is_error(s, m)})
if not erros:          # já passou no validador: não gasta chamada
    dest.write_text(src.read_text()); continue
prompt = REPAIR.format(nota=note["texto"],
                       erros="\n".join(f"- {e}" for e in erros[:25]),
                       bundle=src.read_text())
text, _ = call(model, prompt, max_tokens=budget)
dest.write_text(extract_json(text))   # só se o JSON for válido; quebrado conta como falha

Resultado da rodada de conserto:

Modelo (notas da rodada)JSON lidoPassa no validadorSinais vitais certosVálido e fiel
gpt-oss-20b (11)4/110/1128/800/11
gpt-oss-120b (16)16/1611/16115/11610/16
Qwen3.8 27B (9)6/96/944/666/9

O gpt-oss-120b passou 11 dos 16 Bundles e manteve 115 de 116 sinais vitais certos: consertar a forma não estragou o conteúdo. O Qwen passou os 6 Bundles que voltaram inteiros. Os outros 3 saíram cortados, e aí a culpa é minha: pra caber no limite do Groq gratuito eu baixei o teto de tokens da resposta, e o JSON terminou no meio. O gpt-oss-20b não aprovou nenhum. Dos 11 que tentou, 7 voltaram com o JSON quebrado: 6 já vinham assim e 1, que estava legível, quebrou no conserto.

Ficou um asterisco grande: a rodada parou no meio porque o limite diário de tokens do Groq gratuito acabou (200 mil por dia por modelo). Por isso é 16, 9 e 11 notas, não 30. Rodar as 30 é o primeiro item da próxima versão do repo.

O que não funcionou?

Confesso que boa parte do tempo foi brigando com o ambiente, não com o FHIR.

  • O registro de pacotes da HL7 estava bloqueado na máquina onde rodei. O validador baixa os pacotes do core sozinho, e aqui não conseguiu. Instalei pelo npm (pacotes publicados pelo Grahame Grieve). O hl7.fhir.r4.core no npm é um pacote vazio de segurança, então validei contra o R4B (4.3.0), não contra o R4. Pra Patient, Encounter e Observation as regras usadas aqui são as mesmas, mas é uma troca e fica dita. No repo, quem tem rede valida em R4 com tools/validate.sh <pasta> <saida> 4.0.1.
  • Sem servidor de terminologia. Rodei com -tx n/a. O validador não confere se o código LOINC existe, e acusa erro quando não consegue checar um binding obrigatório de UCUM. Esses erros (39 no Qwen, 39 no 120b, 21 no 20b) são do ambiente, não do modelo, e ficaram fora da conta.
  • O validador novo estourou a memória. A versão 7.0.2 morreu com 6 GB. Voltei pra 6.3.11.
  • O gpt-oss-20b quebrou o JSON em 13 de 30 notas. Fecha chave no lugar errado, no meio do Bundle. O parser não lê e acabou.
  • Limite de 8 mil tokens por minuto no Groq gratuito. A rodada de conserto do Qwen andou devagar por causa disso.

Quando não usar esse resultado

  • n = 30 notas, 3 modelos, 1 prompt. Serve pra mostrar o padrão de erro, não pra ranquear modelo. Outro prompt (por exemplo, colando o perfil de SpO2 no prompt) muda o placar.
  • Nota sintética é mais limpa que nota real. Nota real tem abreviação, erro de digitação e sinal vital escrito de dez jeitos. A fidelidade tende a cair.
  • Não validei contra os perfis da RNDS. Esse é o próximo passo, e é onde fica o “padrão do Ministério”. O validador oficial já roda os perfis; falta carregar o pacote.
  • A régua 2 é minha. Ela confere 8 sinais vitais e a ligação com o paciente. Não confere nome, sexo, CNS nem data.

Fonte e como reproduzir

Repositório: github.com/rogeriorrodrigues/fhir-eval (MIT). Tudo sintético: notas, mensagens HL7 v2 e Bundles.

git clone https://github.com/rogeriorrodrigues/fhir-eval && cd fhir-eval
pip install -r requirements.txt
pytest -q -s tests                       # roda sem rede e sem modelo

# validador oficial da HL7 (Java 17+)
curl -L -o tools/validator_6.3.11.jar \
  https://github.com/hapifhir/org.hl7.fhir.core/releases/download/6.3.11/validator_cli.jar

# gerar com o seu modelo (Groq, Ollama ou qualquer API no formato da OpenAI)
cp .env.example .env && set -a && . ./.env && set +a
python3 src/generate.py data/notes.jsonl out/llm/meu-modelo
tools/validate.sh out/llm/meu-modelo out/llm-meu-modelo-oo.json 4.0.1   # R4
python3 src/semantic.py data/notes.jsonl out/llm/meu-modelo

E vocês, já colocaram IA pra gerar FHIR em produção? Que régua usam além do validador? Quero ler vocês nos comentários.

Summary in English

I asked three open models (gpt-oss-20b, gpt-oss-120b, Qwen3.8 27B) to turn 30 synthetic Portuguese clinical notes into FHIR R4 Bundles, and converted 100 synthetic HL7 v2 messages with hl7gen 0.2.0. The official HL7 validator passed none of the 90 model Bundles (13 were not even valid JSON), mostly for the missing 2708-6 code in the oxygen saturation profile and invalid urn:uuid fullUrls, even when the clinical content was right (Qwen: 211/211 vital signs correct). hl7gen passed after adding fullUrl, but none of its 250 Observations or Encounters references the patient. One repair round feeding validator errors back to the model took gpt-oss-120b from 0 to 11 of 16 valid (the free-tier daily token cap stopped the round halfway). Validation ran against R4B 4.3.0 without a terminology server; code and data at github.com/rogeriorrodrigues/fhir-eval.

Publicado em 16/10/2026. Última revisão: 16/10/2026.

Conhece alguém que precisa ler isso?

Manda pra essa pessoa. Leva dez segundos e é o que mais ajuda esse trabalho a chegar em quem usa IA com dado de verdade.

Rogério Rodrigues

Rogério Rodrigues

Engenheiro de IA há 20 anos em software, mestrando em Informática em Saúde na UFSC. Escrevo o que rodei: IA com dado de saúde, FHIR e LGPD, com código aberto.

Ficou uma dúvida? Discorda de algo? Me chama.

Ver publicações científicas