IA Aplicada a Testes de Software
Usar IA para testar, e testar produtos que são eles próprios IA.
Por Telmo Silva
Ir para o teu progresso ↓Porque escrevi este curso
Os três primeiros cursos testam sempre contra uma resposta certa fixa: um status code, um valor numa base de dados, um elemento visível ou não. Este muda a pergunta em duas direções. A primeira metade usa IA como ferramenta de trabalho, a gerar casos de teste e a resumir falhas. A segunda metade testa produtos que são eles próprios IA, um chatbot, uma feature que resume texto, onde já não há uma string única certa para comparar.
É também onde entra o erro mais traiçoeiro que vais encontrar em automação: uma alucinação não parece um erro. Não há exceção, não há stack trace. O texto lê-se bem e está errado.
Uma honestidade sobre o que aqui está
É o quarto de quatro cursos que escrevi; pressupõe o Curso 2 feito (requests, JSON, pytest básico). Os exercícios deste curso chamam mesmo a API da Anthropic: precisas de uma chave ANTHROPIC_API_KEY tua, criada em platform.claude.com, conta paga por utilização. Custa poucos cêntimos correr os dois módulos a sério, e cada exercício está desenhado para correr e se testar quase por inteiro sem gastares nada: só a chamada final ao modelo depende da chave.
Serve para quem já testa dados e UI e quer somar IA às ferramentas, e para quem vai ter de avaliar um produto de IA e ainda não sabe por onde começar a tornar isso testável.
Pergunta a um chatbot o mesmo duas vezes e é normal vires com duas respostas diferentes, ambas certas. Nenhuma assertion exata apanha isso.
Por onde começar, consoante a tua situação
Índice
9 capítulos, com código a sério em cada um. Pressupõe o Curso 2 feito.
01Bibliotecas anthropic / openai em Python
Desde o Curso 2 que sabes pedir dados a uma API: uma biblioteca faz o pedido, um servidor responde, normalmente em JSON. anthropic e openai são as bibliotecas oficiais em Python para esse mesmo padrão de pedido/resposta, só que agora, do outro lado, não está uma base de dados nem uma aplicação com lógica de negócio fixa. Está um modelo de linguagem (LLM), um sistema treinado a prever texto, capaz de gerar, resumir, classificar ou explicar praticamente qualquer coisa que lhe descrevas em linguagem natural.
bash
pip install anthropicO cliente cria-se sem a chave a aparecer em lado nenhum do código:
python
import anthropic
client = anthropic.Anthropic()Anthropic() sem argumentos procura sozinho a variável de ambiente ANTHROPIC_API_KEY. Essa chave é a tua credencial: identifica-te junto da Anthropic e determina quem paga por cada pedido. Cria-se em platform.claude.com, e nunca deve aparecer escrita diretamente num ficheiro .py: define-a no ambiente e esquece-a depois disso.
python
resposta = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system="Respondes sempre em português europeu, de forma direta.",
messages=[
{"role": "user", "content": "Explica em uma frase o que é um teste de regressão."}
],
)
print(resposta.content[0].text)- model: a versão do modelo. claude-sonnet-5 é um bom ponto de partida: equilíbrio entre custo e qualidade. Há modelos maiores para trabalho mais exigente (Opus) e modelos mais pequenos e baratos para tarefas simples (Haiku); a escolha é sempre um trade-off, não "o maior é sempre melhor".
- max_tokens: obrigatório, sem valor por omissão. Um token é a unidade em que o modelo processa texto, e também a unidade de custo. É um teto para a resposta, não uma garantia de a atingir.
- system: a instrução geral que molda o comportamento do modelo ao longo de todo o pedido, separada da pergunta em si.
- messages: uma lista de turnos, cada um com role ("user" ou "assistant") e content, o prompt. Para os exercícios deste módulo, um único turno "user" chega.
A resposta não é uma string direta: resposta.content é uma lista de blocos, porque o modelo pode devolver mais do que texto simples dependendo de como o pedido é configurado. Para uma resposta de texto como a de cima, o que interessa está em resposta.content[0].text.
Erros de comunicação com a API têm classes próprias, tal como já viste com requests.exceptions no Curso 2: anthropic.AuthenticationError para uma chave inválida, anthropic.RateLimitError quando excedes o limite de pedidos, anthropic.APIConnectionError para falhas de rede. Vale sempre a pena apanhá-los explicitamente.
A biblioteca openai, para os modelos GPT, segue a mesma lógica com nomes ligeiramente diferentes; vale a pena reconhecer a forma, mesmo sem a usares nos exercícios deste módulo:
python
from openai import OpenAI
client = OpenAI() # lê OPENAI_API_KEY do ambiente
resposta = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Respondes sempre em português europeu, de forma direta."},
{"role": "user", "content": "Explica em uma frase o que é um teste de regressão."},
],
)
print(resposta.choices[0].message.content)Duas diferenças a reter, não mais
A openai não tem um parâmetro system à parte: a instrução geral entra como mais uma mensagem, com role="system", dentro da mesma lista; e o texto da resposta vive em resposta.choices[0].message.content, não em resposta.content[0].text. Fora isso, é o mesmo padrão: cliente, um método para enviar mensagens, uma resposta estruturada a interpretar.
02Prompt Engineering Básico para QA
Prompt engineering é a prática de escrever instruções claras para um modelo de IA obter a resposta que precisas, em vez de algo vago, incompleto, ou num formato que depois não dá para processar em código. Não é um truque nem uma linguagem à parte: é a mesma disciplina que já aplicas a escrever um bug report claro ou uma assertion específica, aplicada agora a texto em linguagem natural em vez de código.
A diferença entre um prompt vago e um bem escrito não é subtil. Repara neste pedido, do tipo que é tentador escrever à pressa:
python
prompt_vago = "Cria testes para o login."Isto compila, chega ao modelo, e o modelo responde, com alguma coisa. Mas não sabes quantos casos vais receber, se cobrem só o caminho principal ou também erros, em que formato vem o texto, nem se o teu código a seguir consegue processar a resposta sem um humano a reler tudo à mão primeiro. É o equivalente, em prompting, a um teste sem assertion nenhuma; corre, mas não verifica nada com precisão.
Um prompt bem escrito remove essa ambiguidade em três frentes: dá contexto (que papel o modelo deve assumir), é específico sobre o que queres (quantos casos, que tipos de caso), e fixa o formato da resposta, para o teu código conseguir processá-la sem adivinhar:
python
prompt_especifico = """És um QA engineer sénior. Lê a user story abaixo e gera casos de teste.
User story:
\"\"\"
Como utilizador registado, quero fazer login com o meu email e password,
para aceder à minha conta.
\"\"\"
Gera entre 3 e 5 casos de teste que cubram: pelo menos um caminho principal,
pelo menos um caso negativo, e um caso de limite quando fizer sentido.
Responde APENAS com um array JSON válido, sem texto antes ou depois, seguindo
este formato: [{"titulo": "...", "tipo": "positivo | negativo | limite",
"passos": ["..."], "resultado_esperado": "..."}]"""Nada disto é acaso. "És um QA engineer sénior" ancora o modelo num papel e num vocabulário. "Entre 3 e 5" evita tanto uma resposta com um único caso óbvio como uma lista interminável. "Pelo menos um caso negativo" força cobertura que um pedido vago costuma saltar; o modelo, tal como uma pessoa apressada, tende para o caminho feliz se não lhe pedires explicitamente o contrário. E a última instrução, o formato exato de saída, é a que mais interessa a QA automation: transforma uma resposta em prosa, que só um humano lê, numa estrutura que o teu próprio script interpreta com json.loads() e usa a seguir sem intervenção manual.
Pedir um formato estruturado (JSON, uma lista com campos fixos, um schema) é provavelmente a técnica de prompt engineering com maior retorno para automação. Um modelo que devolve texto livre é útil para leres tu; um modelo que devolve JSON previsível é uma peça que encaixa num pipeline.
Duas armadilhas comuns
Pedir o formato não garante recebê-lo sempre exatamente assim; os exercícios deste módulo já assumem isso, e validam a resposta antes de confiar nela. E um prompt mais longo e mais específico não é automaticamente melhor: é melhor até ao ponto em que continua a corresponder ao que realmente precisas. Contexto a mais, sem relação com a tarefa, dilui o que importa tanto como contexto a menos.
03Self-Healing em Testes Automatizados
Self-healing, aplicado a um teste automatizado, é quando esse teste se adapta sozinho a uma pequena mudança na aplicação, em vez de simplesmente falhar. Não é IA a decidir o que testar: é uma camada de resiliência à volta de algo que já conheces bem do Curso 3, o seletor CSS ou XPath que aponta para um elemento específico da página.
Lembra-te do problema que motivou o Page Object Model: um seletor .btn-submit-v2 parte no dia em que alguém do design muda uma classe CSS, mesmo que o botão continue exatamente no mesmo sítio, com o mesmo texto, a fazer exatamente a mesma coisa. O teste falha, não porque a funcionalidade esteja partida, mas porque a forma de a apontar ficou desatualizada.
Uma estratégia de self-healing ataca isto com um princípio simples: nunca depender de uma única forma de encontrar um elemento. Em vez de um seletor CSS único e frágil, tenta-se uma sequência de estratégias, da mais específica para a mais resiliente a mudanças de implementação, e só se desiste se todas falharem:
python
from playwright.sync_api import Page
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
def clicar_resiliente(page: Page, seletor_css: str, role: str, nome_acessivel: str):
"""Tenta clicar num elemento por ordem de estratégias, da mais específica
para a mais genérica e resiliente a mudanças de UI."""
estrategias = [
("seletor CSS", lambda: page.locator(seletor_css)),
("role + nome acessível", lambda: page.get_by_role(role, name=nome_acessivel)),
("texto visível", lambda: page.get_by_text(nome_acessivel)),
]
for nome_estrategia, obter_localizador in estrategias:
try:
localizador = obter_localizador()
localizador.click(timeout=3000)
print(f"OK [{nome_estrategia}] clique feito com sucesso")
return
except PlaywrightTimeoutError:
print(f"FALHA [{nome_estrategia}] elemento não encontrado, a tentar a próxima estratégia")
raise PlaywrightTimeoutError(
f"nenhuma estratégia encontrou o elemento (seletor='{seletor_css}', role='{role}', nome='{nome_acessivel}')"
)Repara que isto não usa IA nenhuma: é lógica normal, try/except à volta de estratégias alternativas do Playwright que já conheces do Curso 3. É a base sobre a qual ferramentas de self-healing comerciais constroem: as mais avançadas acrescentam um modelo a decidir qual a alternativa mais provável quando várias falham em simultâneo, mas o princípio, várias formas independentes de chegar ao mesmo elemento, é o mesmo com ou sem IA.
Self-healing esconde uma falha real tanto quanto evita uma falsa
Se um botão de "Eliminar conta" muda de sítio ou de texto por engano, um localizador demasiado permissivo pode continuar a encontrá-lo por role e clicar na mesma, e o teste passa a verde num cenário que merecia ser investigado. Self-healing serve bem para absorver mudanças cosméticas; não substitui uma pessoa a rever o que mudou quando um teste começa a apanhar o elemento por uma estratégia de recurso, e não pela principal.
04Prática: Gerador de Casos de Teste com IA
Antes de começares, cria um ficheiro user_stories.txt na mesma pasta, com estas 4 user stories (uma por parágrafo, separadas por uma linha em branco):
Como utilizador registado, quero fazer login com o meu email e password, para aceder à minha conta e ao histórico de encomendas.
Como cliente, quero adicionar produtos ao carrinho e ver o total atualizado automaticamente, para saber quanto vou pagar antes de avançar para o checkout.
Como utilizador que esqueceu a password, quero pedir a recuperação através do meu email, para poder definir uma password nova sem contactar o suporte.
Como cliente, quero filtrar os produtos por categoria e intervalo de preço, para encontrar mais depressa o que procuro sem percorrer o catálogo inteiro.Gerar casos de teste automaticamente a partir de user stories
Objetivo: Escrever gerador_casos_teste.py, um script que lê um ficheiro com várias user stories, pede ao modelo casos de teste estruturados para cada uma, e imprime o resultado de forma legível.
- ler_user_stories(caminho): lê o ficheiro e devolve uma lista de strings, uma por user story (separadas por linha em branco).
- construir_prompt(user_story): devolve o texto do prompt a enviar ao modelo, com contexto (papel de QA sénior), quantidade específica de casos (entre 3 e 5), cobertura explícita (positivo, negativo, limite), e formato de saída fixo em JSON.
- pedir_casos_teste_ao_modelo(client, user_story): chama client.messages.create(...) com o prompt construído, e devolve o texto da resposta.
- extrair_casos_teste(texto_resposta): interpreta a resposta como JSON e devolve uma lista de dicionários. Confirma que cada caso tem titulo, tipo, passos e resultado_esperado; levanta ValueError se faltar algum. Esta função não faz nenhuma chamada de rede: recebe uma string, devolve uma lista.
- main(): cria o cliente, lê as user stories, e para cada uma pede os casos de teste, extrai-os e imprime-os.
Dica: Para confirmares extrair_casos_teste sem chave nenhuma, chama-a diretamente com uma string JSON escrita à mão, tal como farias a mockar a resposta de uma API no Curso 3, só que aqui nem precisas de unittest.mock, porque a função já recebe só uma string.
Ver solução
import json
import os
import anthropic
FICHEIRO_USER_STORIES = "user_stories.txt"
MODELO = "claude-sonnet-5"
def ler_user_stories(caminho):
with open(caminho, encoding="utf-8") as ficheiro:
conteudo = ficheiro.read()
return [bloco.strip() for bloco in conteudo.split("\n\n") if bloco.strip()]
def construir_prompt(user_story):
return f"""És um QA engineer sénior. A tua tarefa é ler a user story abaixo e gerar casos de teste.
User story:
\"\"\"
{user_story}
\"\"\"
Gera entre 3 e 5 casos de teste que cubram, ao todo: pelo menos um caminho principal (happy path),
pelo menos um caso negativo (input inválido ou rejeitado) e, quando fizer sentido, um caso de limite
(valor extremo ou vazio).
Responde APENAS com um array JSON válido, sem texto antes ou depois, sem markdown, seguindo
exatamente este formato:
[
{{
"titulo": "string curta a descrever o caso",
"tipo": "positivo | negativo | limite",
"passos": ["passo 1", "passo 2"],
"resultado_esperado": "string"
}}
]"""
def pedir_casos_teste_ao_modelo(client, user_story):
resposta = client.messages.create(
model=MODELO,
max_tokens=1024,
messages=[{"role": "user", "content": construir_prompt(user_story)}],
)
return resposta.content[0].text
def extrair_casos_teste(texto_resposta):
"""Interpreta o texto devolvido pelo modelo como uma lista de casos de teste.
Não faz nenhuma chamada de rede: recebe uma string, devolve uma lista."""
casos = json.loads(texto_resposta)
for caso in casos:
for campo in ("titulo", "tipo", "passos", "resultado_esperado"):
if campo not in caso:
raise ValueError(f"caso de teste sem o campo obrigatório '{campo}': {caso}")
return casos
def imprimir_casos_teste(user_story, casos):
print(f"\nUser story: {user_story}")
for indice, caso in enumerate(casos, start=1):
print(f"\n Caso {indice} [{caso['tipo']}]: {caso['titulo']}")
for numero, passo in enumerate(caso["passos"], start=1):
print(f" {numero}. {passo}")
print(f" Resultado esperado: {caso['resultado_esperado']}")
def main():
if not os.environ.get("ANTHROPIC_API_KEY"):
print("Falta a variável de ambiente ANTHROPIC_API_KEY: define a tua chave antes de correr isto.")
return
client = anthropic.Anthropic() # lê ANTHROPIC_API_KEY do ambiente
user_stories = ler_user_stories(FICHEIRO_USER_STORIES)
for user_story in user_stories:
try:
texto_resposta = pedir_casos_teste_ao_modelo(client, user_story)
except anthropic.AuthenticationError:
print("A chave de API foi rejeitada. Confirma que ANTHROPIC_API_KEY está correta.")
return
except anthropic.APIError as erro:
print(f"Erro a comunicar com a API para esta user story: {erro}")
continue
casos = extrair_casos_teste(texto_resposta)
imprimir_casos_teste(user_story, casos)
if __name__ == "__main__":
main()Sem chave de API definida, o script corre até ao ponto do pedido e para com uma mensagem clara, sem traceback feio: "Falta a variável de ambiente ANTHROPIC_API_KEY...". Confirma só que a parte que não depende da API (ler o ficheiro, construir o prompt, interpretar uma resposta) está correta, mesmo sem gastares um cêntimo em créditos. Com uma chave válida, o main() corre até ao fim e pede casos de teste a sério ao modelo.
05Prática: Analisador de Falhas
Analisar e resumir falhas de testes automaticamente
Objetivo: Escrever analisador_falhas.py, um script que pega na saída de uma corrida pytest que falhou e pede ao modelo um resumo em linguagem simples: o que correu mal, onde procurar, e uma sugestão concreta de correção.
- extrair_nome_do_teste_falhado(saida_pytest): recebe a saída completa do pytest como string, e devolve o nome do primeiro teste falhado (lido da linha que começa por FAILED), ou None se não houver nenhuma falha. Não depende da API.
- construir_prompt_analise(saida_pytest): devolve o prompt a enviar ao modelo, a pedir três partes específicas: causa provável, onde procurar (ficheiro/função), e uma sugestão de correção concreta, não um genérico "verifica o código".
- pedir_analise_ao_modelo(client, saida_pytest): chama a API com esse prompt e devolve o texto da resposta.
- main(): usa uma saída de exemplo (uma falha real de pytest, já incluída no script), extrai o nome do teste falhado, pede a análise ao modelo, e imprime tudo.
Dica: A linha que interessa começa sempre por 'FAILED ', seguida do caminho do ficheiro, ::, o nome do teste, um espaço, um traço, e o tipo de erro. str.split(" - ") já te separa o nome do teste do resto.
Ver solução
import os
import anthropic
MODELO = "claude-sonnet-5"
# Saída real de uma corrida pytest -v contra uma suite com um bug propositado.
FALHA_EXEMPLO = """\
============================= test session starts =============================
collected 1 item
test_utilizadores.py::test_obter_utilizador_por_id_como_string FAILED [100%]
================================== FAILURES ===================================
__________________ test_obter_utilizador_por_id_como_string ___________________
def test_obter_utilizador_por_id_como_string():
utilizadores = [{"id": 1, "nome": "Ana"}, {"id": 2, "nome": "Bruno"}]
resultado = obter_utilizador(utilizadores, "1")
> assert resultado["nome"] == "Ana"
^^^^^^^^^^^^^^^^^
E TypeError: 'NoneType' object is not subscriptable
test_utilizadores.py:10: TypeError
=========================== short test summary info ===========================
FAILED test_utilizadores.py::test_obter_utilizador_por_id_como_string - TypeError: 'NoneType' object is not subscriptable
============================== 1 failed in 0.16s ===============================
"""
def construir_prompt_analise(saida_pytest):
return f"""És um QA engineer sénior a rever o resultado de uma suite pytest que falhou.
Saída do pytest:
\"\"\"
{saida_pytest}
\"\"\"
Responde em português (pt-PT), em três partes curtas, para alguém que não vai ler o traceback
linha a linha:
1. Causa provável: o que correu mal, em linguagem simples.
2. Onde procurar: que ficheiro/função é provavelmente responsável.
3. Sugestão de correção: uma hipótese concreta, não só "verifica o código"."""
def pedir_analise_ao_modelo(client, saida_pytest):
resposta = client.messages.create(
model=MODELO,
max_tokens=512,
messages=[{"role": "user", "content": construir_prompt_analise(saida_pytest)}],
)
return resposta.content[0].text
def extrair_nome_do_teste_falhado(saida_pytest):
"""Devolve o nome do primeiro teste falhado, lido da linha 'FAILED' do pytest.
Não faz nenhuma chamada de rede: é só análise de texto."""
for linha in saida_pytest.splitlines():
if linha.startswith("FAILED "):
resto = linha.removeprefix("FAILED ").strip()
return resto.split(" - ")[0]
return None
def main():
nome_teste = extrair_nome_do_teste_falhado(FALHA_EXEMPLO)
print(f"Teste falhado identificado: {nome_teste}\n")
if not os.environ.get("ANTHROPIC_API_KEY"):
print("Falta a variável de ambiente ANTHROPIC_API_KEY: define a tua chave antes de correr isto.")
return
client = anthropic.Anthropic() # lê ANTHROPIC_API_KEY do ambiente
try:
resumo = pedir_analise_ao_modelo(client, FALHA_EXEMPLO)
print(resumo)
except anthropic.AuthenticationError:
print("A chave de API foi rejeitada. Confirma que ANTHROPIC_API_KEY está correta.")
if __name__ == "__main__":
main()Corrido sem chave de API definida, a parte determinística corre e imprime-se na mesma; só a chamada ao modelo é que para com uma mensagem clara. Com uma chave válida, a última linha dá lugar a um resumo real: a função obter_utilizador compara o id com ==, mas a lista guarda ids como número inteiro e o teste procura pela string "1"; a comparação nunca bate certo, a função devolve None por não ter um return explícito para esse caso, e resultado["nome"] a seguir rebenta.
06LLM-as-a-Judge
Até este ponto, testar sempre significou ter uma resposta certa fixa para comparar: o status code é 200 ou não é; o valor na base de dados bate certo com o que a aplicação mostra, ou não bate. Este capítulo inverte o problema. Em vez de usares IA para testar, testas agora um produto que é ele próprio IA: um chatbot, uma feature que resume um documento, um assistente que responde a perguntas de apoio ao cliente. E estes sistemas geram texto novo a cada pedido, sem uma string única "certa" para uma assertion comparar.
Pergunta "qual é o prazo de entrega?" duas vezes ao mesmo chatbot e é bem possível receberes duas respostas com palavras diferentes, ambas corretas. resposta == "As encomendas chegam em 2 a 4 dias úteis." falha na segunda vez só por causa de uma vírgula fora do sítio, mesmo que o conteúdo esteja perfeitamente certo.
A técnica que resolve isto chama-se LLM-as-a-judge: usar um modelo de IA para avaliar a qualidade da resposta de outro sistema de IA, quando não há uma resposta certa fixa para comparar. Em vez de comparares strings, pedes a um segundo modelo (o "juiz") que leia a pergunta, a resposta a avaliar, e um conjunto de critérios que tu defines, e devolva um veredito estruturado.
python
def construir_prompt_juiz(pergunta, resposta, criterios):
"""criterios é um dicionário {nome_do_criterio: descrição_em_texto}."""
lista_criterios = "\n".join(
f"- {nome}: {descricao}" for nome, descricao in criterios.items()
)
return f"""És um avaliador rigoroso de respostas de um chatbot de apoio ao cliente.
Pergunta do cliente:
\"\"\"{pergunta}\"\"\"
Resposta a avaliar:
\"\"\"{resposta}\"\"\"
Avalia a resposta segundo estes critérios, cada um independente dos outros:
{lista_criterios}
Responde APENAS com um objeto JSON, sem texto antes ou depois, no formato
{{"nome_do_criterio": true ou false, ...}}, um booleano por critério."""
criterios = {
"relevancia": "a resposta responde mesmo à pergunta feita, sem desviar de assunto",
"tom_profissional": "a resposta mantém um tom cordial e profissional",
}
resposta_juiz = client.messages.create(
model="claude-sonnet-5",
max_tokens=256,
messages=[{"role": "user", "content": construir_prompt_juiz(pergunta, resposta, criterios)}],
)
veredito = json.loads(resposta_juiz.content[0].text)Repara que o resultado já não é texto livre para um humano ler: é {"relevancia": true, "tom_profissional": true}, uma estrutura que o teu código interpreta com json.loads(), e sobre a qual dá para escrever lógica de aprovação normal (all(veredito.values()), por exemplo). É essa transformação, de "achas que esta resposta é boa?" para um conjunto fixo de perguntas de sim/não, que torna a avaliação de um produto de IA testável em vez de uma opinião.
O juiz não é um oráculo perfeito
O juiz é, ele próprio, um modelo de IA: pode ser inconsistente entre duas corridas com o mesmo input, pode ter viés, e pode simplesmente errar o veredito. Duas coisas ajudam: critérios específicos e binários em vez de um pedido vago de opinião; e, sempre que possível, dar ao juiz algo concreto para comparar em vez de pedir-lhe para avaliar só pela fluência do texto.
07Deteção de Alucinações
Uma alucinação é quando um modelo de IA responde com informação inventada, apresentada como se fosse certa. Não é um modelo a admitir "não sei"; é um modelo a inventar um facto plausível e a afirmá-lo com a mesma confiança fluente de uma resposta correta. É o tipo de erro mais perigoso em IA aplicada, precisamente porque não se parece com um erro: não há exceção, não há stack trace, não há um valor obviamente errado a saltar à vista. O texto lê-se bem, soa a especialista, e está errado.
- Um chatbot de apoio ao cliente que inventa um prazo de devolução mais generoso do que a política real da empresa, ou uma taxa que nunca existiu, porque "60 dias" e "5€ de taxa" soam a resposta de apoio ao cliente perfeitamente normal, mesmo sendo inventados.
- Um modelo a gerar casos de teste (como no capítulo 4) que referenciam um método ou endpoint que não existe mesmo no código: client.get_utilizador_por_email() quando a função real se chama obter_utilizador() e nem aceita email como argumento.
- Um resumo de um documento que atribui uma afirmação ao documento original, mas que na verdade não lá está: o modelo "preenche" uma lacuna com o que seria plausível o documento dizer.
Como apanhas isto programaticamente? Por ordem de fiabilidade:
- Com uma fonte de verdade para comparar: se tens acesso ao documento, política ou base de conhecimento que devia ter fundamentado a resposta, pedes ao juiz especificamente: "cada facto concreto nesta resposta está apoiado neste contexto, sem acrescentos?" Isto transforma deteção de alucinação de "isto soa bem?" para "isto está mesmo aqui?", e é a abordagem que vais usar no exercício deste módulo.
- Sem fonte de verdade disponível: mais fraco, mas às vezes é o que há: pedes ao juiz para assinalar afirmações demasiado específicas (números exatos, datas, nomes) sem apoio visível, ou comparas duas respostas independentes à mesma pergunta e assinalas divergências para investigação manual.
- Heurísticas determinísticas baratas: antes sequer de gastar uma chamada a um modelo. Se tens contexto disponível, uma verificação simples já apanha o caso mais óbvio: números inventados que não aparecem em lado nenhum da fonte.
python
import re
def numeros_nao_suportados(resposta_chatbot, contexto_oficial):
"""Deteção heurística e barata: extrai os números da resposta e confirma
que cada um aparece também no contexto oficial. Não percebe significado,
mas apanha de graça o caso mais comum de alucinação num chatbot: um valor
inventado que não está em lado nenhum da fonte."""
numeros_resposta = set(re.findall(r"\d+(?:[.,]\d+)?", resposta_chatbot))
numeros_contexto = set(re.findall(r"\d+(?:[.,]\d+)?", contexto_oficial))
return numeros_resposta - numeros_contextoContra uma resposta que inventa um prazo de devolução de 60 dias, uma taxa de 5€ e um reembolso em 24 horas quando o contexto oficial só fala em 30 dias, numeros_nao_suportados() devolve {'60', '5', '24'}: três valores que não aparecem em lado nenhum da fonte, sinal suficiente para pôr essa resposta em cima da mesa antes mesmo de chamares um juiz. Não substitui o juiz (não apanha uma alucinação sem números), mas é gratuito, instantâneo, e vale a pena correr sempre como primeira linha de defesa.
08Biblioteca LangChain
LangChain é uma biblioteca Python que facilita construir e avaliar aplicações que usam modelos de IA. O problema que resolve é o de encadear várias etapas que envolvem modelos e prompts (por exemplo, obter contexto, gerar uma resposta com base nesse contexto, e depois avaliar essa resposta com um juiz) sem teres de escrever à mão a canalização entre cada etapa. Oferece prompt templates reutilizáveis, uma interface comum (.invoke()) que funciona da mesma forma seja qual for o modelo por trás, e parsers de saída, incluindo suporte para forçar uma resposta a seguir um schema fixo.
bash
pip install langchain langchain-anthropicA composição de etapas usa o operador |, chama-se LCEL (LangChain Expression Language), para ligar um prompt a um modelo e a um parser, como se fosse um pipe do terminal:
python
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
modelo = ChatAnthropic(model="claude-sonnet-5", max_tokens=512)
prompt = ChatPromptTemplate.from_messages([
("system", "És um QA engineer sénior a avaliar respostas de um chatbot."),
("user", "{pergunta_de_avaliacao}"),
])
cadeia = prompt | modelo | StrOutputParser()
resultado = cadeia.invoke({"pergunta_de_avaliacao": "..."})cadeia.invoke(...) faz exatamente o mesmo pedido HTTP, ao mesmo endpoint da Anthropic, que client.messages.create(...) faria diretamente: ChatAnthropic usa a biblioteca anthropic por baixo, não é um caminho paralelo. LangChain também tem modelo.with_structured_output(schema), que devolve diretamente um objeto já validado contra um schema.
Vale a pena aqui?
Instalar langchain e langchain-anthropic traz consigo mais de quinze pacotes adicionais, a maior parte dos quais nunca vais tocar diretamente só para pedir uma avaliação. Para uma única chamada, como a do exercício deste módulo, chamar anthropic.Anthropic().messages.create() diretamente é mais simples: menos dependências, menos camadas de abstração. LangChain começa a compensar o custo quando tens várias etapas encadeadas de verdade, quando precisas de trocar de fornecedor de modelo sem reescrever pontos de chamada espalhados pelo código, ou quando a tua equipa já a adotou como padrão. Por isso o exercício a seguir usa a biblioteca anthropic diretamente, mas reconheceres a forma de uma cadeia LangChain (prompt | modelo | parser, .invoke()) vale a pena, porque é isto que vais encontrar se entrares numa equipa que já a usa.
09Prática: Suite de Avaliação de Respostas de IA
Antes de começares, cria respostas_chatbot.json na mesma pasta: 6 pares pergunta/resposta de um chatbot de apoio ao cliente fictício, cada um com o contexto oficial que devia ter fundamentado a resposta. Duas das seis respostas alucinam de propósito, para teres o que apanhar.
json
{
"respostas": [
{
"id": 1,
"pergunta": "Qual é o prazo de entrega para encomendas em Portugal Continental?",
"contexto_oficial": "As encomendas para Portugal Continental são entregues em 2 a 4 dias úteis após a confirmação do pagamento.",
"resposta_chatbot": "As encomendas para Portugal Continental chegam normalmente em 2 a 4 dias úteis, a contar da confirmação do pagamento."
},
{
"id": 2,
"pergunta": "Posso devolver um artigo se já não tiver a embalagem original?",
"contexto_oficial": "Aceitamos devoluções até 30 dias após a receção do artigo, desde que devolvido na embalagem original e sem sinais de uso.",
"resposta_chatbot": "Sim, podes devolver até 60 dias, mesmo sem a embalagem original, nesse caso aplicamos apenas uma pequena taxa de reposição de 5€ e o reembolso é processado em 24 horas."
},
{
"id": 3,
"pergunta": "Que métodos de pagamento são aceites?",
"contexto_oficial": "Aceitamos cartão de crédito/débito (Visa e Mastercard), MB WAY e transferência bancária.",
"resposta_chatbot": "Podes pagar com cartão de crédito ou débito (Visa e Mastercard), MB WAY ou por transferência bancária."
},
{
"id": 4,
"pergunta": "O vosso site tem aplicação para telemóvel?",
"contexto_oficial": "De momento não existe aplicação móvel dedicada; o site é responsivo e funciona bem em navegadores móveis.",
"resposta_chatbot": "Podes acompanhar o estado da tua encomenda a qualquer momento na secção \"As Minhas Encomendas\", depois de iniciares sessão."
},
{
"id": 5,
"pergunta": "Qual é o custo da subscrição Premium?",
"contexto_oficial": "A subscrição Premium custa 4,99€/mês ou 49€/ano, e inclui envio grátis em todas as encomendas.",
"resposta_chatbot": "A Premium custa 4,99€ por mês, ou 49€ se pagares o ano inteiro de uma vez, e dá-te envio grátis em todas as compras."
},
{
"id": 6,
"pergunta": "Vendem produtos recondicionados?",
"contexto_oficial": "Não vendemos produtos recondicionados. Todos os artigos do catálogo são novos e selados de fábrica.",
"resposta_chatbot": "Sim, temos uma secção de produtos recondicionados com até 40% de desconto, todos com garantia de 6 meses."
}
]
}Avaliar respostas de um chatbot segundo critérios definidos
Objetivo: Escrever avaliador_respostas_ia.py, um script que lê um conjunto de pares pergunta/resposta de um chatbot, pede a um modelo-juiz para avaliar cada resposta segundo três critérios (rigor factual, relevância, e ausência de alucinação face a um contexto oficial), e produz um relatório agregado de quantas respostas passam em todos os critérios.
- carregar_respostas_chatbot(caminho): lê o ficheiro JSON e devolve a lista de pares (pergunta, resposta_chatbot, contexto_oficial).
- construir_prompt_avaliacao(par): devolve o prompt a enviar ao modelo-juiz, a pedir avaliação explícita dos três critérios (rigor_factual, relevancia, sem_alucinacao), com resposta em JSON estrito, incluindo uma justificacao.
- pedir_avaliacao_ao_modelo(client, par): chama client.messages.create(...) com o prompt construído, e devolve o texto da resposta.
- extrair_avaliacao(texto_resposta): interpreta a resposta como JSON e devolve o dicionário. Confirma que os três critérios estão presentes e são True/False, e que justificacao está presente; levanta ValueError caso contrário. Não faz nenhuma chamada de rede.
- aprovado(avaliacao): devolve True só se os três critérios forem True.
- calcular_resumo(pares_avaliados): agrega os resultados num dicionário {total, aprovados, reprovados, detalhe_reprovados}, cada item de detalhe_reprovados com id, pergunta, motivos e justificacao. Também não faz nenhuma chamada de rede.
- imprimir_relatorio(pares_avaliados, resumo): imprime cada par com [APROVADO] ou [REPROVADO], o(s) motivo(s) e a justificação quando reprovado, e a contagem final.
- main(): cria o cliente, carrega os pares, pede a avaliação de cada um ao modelo, agrega e imprime o relatório.
Dica: aprovado() é o caso de uso perfeito para all(): all(avaliacao[c] for c in criterios) devolve True só se todos forem verdadeiros. Para confirmares extrair_avaliacao e calcular_resumo sem chave nenhuma, escreve à mão um pequeno conjunto de respostas de juiz.
Ver solução
import json
import os
import anthropic
FICHEIRO_RESPOSTAS = "respostas_chatbot.json"
MODELO = "claude-sonnet-5"
CRITERIOS = ("rigor_factual", "relevancia", "sem_alucinacao")
def carregar_respostas_chatbot(caminho):
with open(caminho, encoding="utf-8") as ficheiro:
dados = json.load(ficheiro)
return dados["respostas"]
def construir_prompt_avaliacao(par):
return f"""És um QA engineer sénior especializado em avaliar respostas de chatbots de apoio ao cliente.
Pergunta do cliente:
\"\"\"
{par['pergunta']}
\"\"\"
Contexto oficial (a única fonte de verdade: a resposta do chatbot só pode basear-se nisto):
\"\"\"
{par['contexto_oficial']}
\"\"\"
Resposta dada pelo chatbot:
\"\"\"
{par['resposta_chatbot']}
\"\"\"
Avalia a resposta do chatbot segundo três critérios independentes:
1. rigor_factual: todos os factos, números e condições mencionados na resposta correspondem
exatamente ao contexto oficial (nenhum valor errado, nenhuma condição alterada)?
2. relevancia: a resposta responde mesmo à pergunta feita, sem desviar para outro assunto?
3. sem_alucinacao: a resposta não acrescenta nenhum detalhe (número, prazo, taxa, condição)
que não esteja no contexto oficial, mesmo que esse detalhe não contradiga nada diretamente?
Responde APENAS com um objeto JSON válido, sem texto antes ou depois, sem markdown, seguindo
exatamente este formato:
{{
"rigor_factual": true ou false,
"relevancia": true ou false,
"sem_alucinacao": true ou false,
"justificacao": "string curta a explicar a decisão, citando a diferença concreta se houver"
}}"""
def pedir_avaliacao_ao_modelo(client, par):
resposta = client.messages.create(
model=MODELO,
max_tokens=512,
messages=[{"role": "user", "content": construir_prompt_avaliacao(par)}],
)
return resposta.content[0].text
def extrair_avaliacao(texto_resposta):
"""Interpreta o texto devolvido pelo modelo-juiz como uma avaliação estruturada.
Não faz nenhuma chamada de rede: recebe uma string, devolve um dicionário."""
avaliacao = json.loads(texto_resposta)
for criterio in CRITERIOS:
if criterio not in avaliacao:
raise ValueError(f"avaliação sem o campo obrigatório '{criterio}': {avaliacao}")
if not isinstance(avaliacao[criterio], bool):
raise ValueError(
f"campo '{criterio}' devia ser True/False, veio {avaliacao[criterio]!r}"
)
if "justificacao" not in avaliacao:
raise ValueError(f"avaliação sem o campo obrigatório 'justificacao': {avaliacao}")
return avaliacao
def aprovado(avaliacao):
return all(avaliacao[criterio] for criterio in CRITERIOS)
def calcular_resumo(pares_avaliados):
"""Agrega os resultados de todos os pares pergunta/resposta avaliados.
Só depende da estrutura de dados já construída: nenhuma chamada de rede."""
reprovados = []
for par in pares_avaliados:
avaliacao = par["avaliacao"]
if not aprovado(avaliacao):
motivos = [criterio for criterio in CRITERIOS if not avaliacao[criterio]]
reprovados.append(
{
"id": par["id"],
"pergunta": par["pergunta"],
"motivos": motivos,
"justificacao": avaliacao["justificacao"],
}
)
total = len(pares_avaliados)
return {
"total": total,
"aprovados": total - len(reprovados),
"reprovados": len(reprovados),
"detalhe_reprovados": reprovados,
}
def imprimir_relatorio(pares_avaliados, resumo):
print("Resultado da avaliação:\n")
for par in pares_avaliados:
avaliacao = par["avaliacao"]
estado = "APROVADO" if aprovado(avaliacao) else "REPROVADO"
print(f"[{estado}] #{par['id']}: {par['pergunta']}")
if estado == "REPROVADO":
motivos = ", ".join(criterio for criterio in CRITERIOS if not avaliacao[criterio])
print(f" motivo(s): {motivos}")
print(f" justificação: {avaliacao['justificacao']}")
print(f"\n{resumo['aprovados']}/{resumo['total']} respostas aprovadas em todos os critérios.")
def main():
if not os.environ.get("ANTHROPIC_API_KEY"):
print("Falta a variável de ambiente ANTHROPIC_API_KEY: define a tua chave antes de correr isto.")
return
client = anthropic.Anthropic() # lê ANTHROPIC_API_KEY do ambiente
respostas = carregar_respostas_chatbot(FICHEIRO_RESPOSTAS)
pares_avaliados = []
for par in respostas:
try:
texto_resposta = pedir_avaliacao_ao_modelo(client, par)
except anthropic.AuthenticationError:
print("A chave de API foi rejeitada. Confirma que ANTHROPIC_API_KEY está correta.")
return
except anthropic.APIError as erro:
print(f"Erro a comunicar com a API para a pergunta '{par['pergunta']}': {erro}")
continue
avaliacao = extrair_avaliacao(texto_resposta)
par_avaliado = dict(par)
par_avaliado["avaliacao"] = avaliacao
pares_avaliados.append(par_avaliado)
resumo = calcular_resumo(pares_avaliados)
imprimir_relatorio(pares_avaliados, resumo)
if __name__ == "__main__":
main()Sem gastar um único pedido a um modelo, dá para confirmar a lógica de agregação com respostas de juiz escritas à mão: uma resposta que inventa um prazo, uma taxa e um reembolso que não existem no contexto oficial reprova em rigor_factual e sem_alucinacao; uma resposta que não alucina nada mas também não responde ao que foi perguntado reprova só em relevancia. Prova de que os três critérios independentes servem mesmo para distinguir tipos diferentes de problema, não só "bom" ou "mau". Com uma chave ANTHROPIC_API_KEY válida, o main() corre até ao fim e produz o mesmo relatório, com os vereditos vindos do modelo-juiz em vez de escritos à mão.
O teu progresso
Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.
Conclusão
Em duas semanas: usar as bibliotecas anthropic e openai para pedir a um modelo que gere casos de teste e resuma falhas, escrever prompts que devolvem JSON previsível em vez de prosa, entender self-healing como camada de resiliência sobre seletores, e do outro lado, avaliar produtos que são eles próprios IA com LLM-as-a-judge, apanhar alucinações antes de confiares num texto bem escrito, e reconhecer a forma de uma cadeia LangChain.
Se fizeste os três exercícios a sério, tens um gerador de casos de teste, um analisador de falhas, e uma suite de avaliação de IA, os três com uma chave ANTHROPIC_API_KEY tua. É a saída que este curso, e os quatro juntos, prometem.
Se ficares só com uma decisão destes quatro cursos, fica com esta: uma resposta bem escrita não é o mesmo que uma resposta certa, seja ela de um colega, de um modelo, ou de um teste que passou a verde sem ninguém verificar porquê.
Se ainda não fizeste os anteriores: Curso 1, Curso 2 e Curso 3.
Isto ajudou-te?
Fontes, e o que é só observação minha
O resto
- É o quarto e último de quatro cursos que escrevi, a fechar o programa a partir dos Cursos 1, 2 e 3. Não é investigação, é o programa com que ensino automação de testes a sério, calibrado módulo a módulo.