Este projeto contém a implementação de um agente estratégico autônomo para o jogo Nota Secreta (uma variação de Dixit com músicas brasileiras). O sistema é baseado na integração de um modelo de linguagem local (Phi-3.5-mini-instruct) com rotinas de fallback heurístico em camadas, para garantir estabilidade e resiliência em um ambiente distribuído.
- Lucas de Souza Cerveira Pereira
- Mikaelle Costa de Santana
- Michael Willian Pereira Vieira
As instruções de instalação são as mesmas fornecidas na arquitetura base do professor.
- Acesse a pasta do projeto contendo o llm_agent.
- Crie e ative um ambiente virtual:
python3 -m venv venv
source venv/bin/activate - Instale as dependências:
pip install -r requirements.txt- Certifique-se de baixar o modelo LLM (
Phi-3.5-mini-instruct-Q4_K_M.gguf) e colocá-lo em um diretório acessível.
O projeto pode ser executado facilmente através do orquestrador run_game.py:
Para rodar em modo Mock (teste rápido sem carregar o modelo LLM pesado):
python3 run_game.py --force-mockPara rodar a partida oficial (com o modelo real integrado):
python3 run_game.py --model /caminho/absoluto/para/o/Phi-3.5-mini-instruct-Q4_K_M.gguf(O script orquestrará o Game Master, o Serviço LLM, o nosso agente estratégico e 5 agentes aleatórios automaticamente. Para subir 6 agentes estratégicos em vez de 1 estratégico + 5 aleatórios, use --all-strategic.)
A cada execução, run_game.py gera automaticamente um identificador único de partida (run_id, baseado em timestamp) e o repassa ao Game Master e a todos os agentes. Isso garante que os arquivos gerados por execuções diferentes nunca se misturem:
logs/partida_20260704_183012.json <- log completo da partida (Game Master)
logs/decisions_LLMAgent_1_20260704_183012.jsonl <- log interno de decisões do nosso agente
Rodar run_game.py de novo gera um run_id novo, então cada partida tem seu próprio par de arquivos, correlacionáveis pelo sufixo em comum.
Abaixo está o resumo de uma rodada operando normalmente, extraído e formatado visualmente a partir do log JSON da partida gerado pelo Game Master (veja a Seção 6 sobre como gerar esta visualização com o render_log_readable.py):
Letras
id type name card_id title lyrics
-- --------- ------------- ------- ------------------------------- --------------------------------------------------------------------------------
0 strategic LLMAgent_1 139 Linda Juventude Zabelê, Zumbi, Besouro Vespa fabricando mel Guardo teu tesouro Jóia marrom Raça…
254 Camisa Amarela Encontrei o meu pedaço na avenida De camisa amarela Cantando a Florisbela, oi,…
142 Coração Selvagem Meu bem, guarde uma frase pra mim dentro da sua canção Esconda um beijo pra mim…
170 Índios Quem me dera ao menos uma vez Ter de volta todo o ouro que entreguei a quem Con…
1 random RandomAgent_2 85 Berimbau Quem é homem de bem não trai O amor que lhe quer seu bem Quem diz muito que vai…
134 Papel Machê Cores do mar, festa do Sol Vida é fazer Todo o sonho brilhar Ser feliz No teu c…
28 Apesar de Você Hoje você é quem manda Falou, tá falado Não tem discussão A minha gente hoje an…
12 Domingo no Parque No birimbal O rei da brincadeira (ê, José) O rei da confusão (ê, João) Um traba…
2 random RandomAgent_3 180 Rádio Pirata Abordar navios mercantes Invadir, pilhar, tomar o que é nosso Pirataria nas ond…
30 Carcará Carcará Pega, mata e come Carcará num vai morrer de fome Carcará Lá no sertão É…
199 Se Eu Não Te Amasse Tanto Assim Meu coração Sem direção Voando só por voar Sem saber onde chegar Sonhando em te…
145 Sentado à Beira do Caminho Eu não posso mais ficar aqui a esperar Que um dia, de repente, você volte para…
3 random RandomAgent_4 220 Magrelinha O pôr do sol vai renovar brilhar de novo o seu sorriso E libertar da areia pret…
13 Ovelha Negra Levava uma vida sossegada Gostava de sombra E água fresca Meu Deus! Quanto temp…
172 Sá Marina Descendo a rua da ladeira Só quem viu, que pode contar Cheirando a flor de lara…
243 Cigana Não deixe o tempo acabar com nosso amor Eu faço tudo e o impossível e você não…
4 random RandomAgent_5 62 Primeiros Erros Meu caminho é cada manhã Não procure saber onde vou Meu destino não é de ningué…
74 Rosa de Hiroshima Pense nas crianças mudas telepáticas Pense nas meninas cegas inexatas Pense nas…
88 Meu Bem Querer Meu bem-querer É segredo, é sagrado Está sacramentado Em meu coração Meu bem-qu…
2 Detalhes Não adianta nem tentar me esquecer Durante muito tempo em sua vida eu vou viver…
5 random RandomAgent_6 125 Além do Horizonte Além do horizonte deve ter Algum lugar bonito pra viver em paz Onde eu possa en…
32 Pérola Negra Tente passar pelo que estou passando Tente apagar este teu novo engano Tente me…
67 O Canto da Cidade A cor dessa cidade sou eu O canto dessa cidade é meu A cor dessa cidade sou eu…
230 Festa Festa no gueto, Pode vir, pode chegar Misturando o mundo inteiro Vamos ver no q…
round 1
Clue (agent 0 - LLMAgent_1): "Juventude linda cais claro mel besouro"
id card_id title 0 1 2 3 4 5 Pts Acm New id New lyrics
-- ------- ----------------- - - - - - - --- --- ------ ----------------------
0 139 Linda Juventude x x x 3 3 44 Todo Azul do Mar
1 12 Domingo no Parque x x 5 5 107 Expresso 2222
2 30 Carcará x 4 4 248 Fascinação
3 243 Cigana x 1 1 9 Eu Sei Que Vou Te Amar
4 74 Rosa de Hiroshima x x 5 5 218 Ai Se Eu Te Pego
5 32 Pérola Negra x 1 1 135 Já Sei Namorar
[... rodadas seguintes omitidas neste README para brevidade ...]
Final_scores: [0:34, 1:27, 2:23, 3:18, 4:26, 5:22]
Winner: 0 (LLMAgent_1)
Total_rounds: 8
Nossa arquitetura é Híbrida Defensiva: cada tool do agente combina uma chamada à LLM com uma ou mais camadas de fallback puramente algorítmico, de modo que o agente nunca trave a partida nem devolva uma resposta inválida.
choose_card(Narrador):- Heurística
_clueability_score: pontua cada carta da mão usando só o texto dela — diversidade lexical, fração de palavras longas/específicas, tamanho do material disponível, bônus por título informativo, e uma penalidade suave por densidade de clichês de amor ("amor", "coração", "saudade" etc.). A carta com maior pontuação é escolhida; empates são resolvidos por índice, de forma determinística.
- Heurística
send_clue(Narrador):- Prompt (
_build_clue_prompt): envia até 50 palavras da letra e dois exemplos few-shot fixando o estilo (dica curta, abstrata, sem citar a letra nem o título). Tenta até duas vezes, com temperaturas diferentes (0.55 e 0.85). - Saneamento (
_postprocess_clue): remove prefixos ("Dica:", "Resposta:"), marcações markdown, meta-vocabulário e palavras que coincidem com o título. - Validação (
_clue_is_acceptable): rejeita dica vazia, cópia literal da letra, ou sobreposição lexical acima de 60% com a letra. - Heurística de fallback (
_heuristic_clue): se a LLM falhar nas duas tentativas, monta a dica com as palavras mais distintivas (raras e longas) da própria letra, excluindo as do título; se nem isso for possível, usa uma das frases genéricas fixas (ex.: "lembrança distante").
- Prompt (
select_card_by_clue(Melômano):- Prompt: pede à LLM um JSON estrito (
{"c": numero}) com o índice posicional na mão (não o ID do catálogo), para reduzir alucinações. - Camadas de resolução, registradas via
_record_decisioncomo planos "A"/"B"/"C":- A — a LLM devolveu um JSON válido com um índice dentro do intervalo esperado.
- B — o JSON falhou, mas foi possível recuperar um dígito isolado da resposta crua por regex.
- C — nada funcionou (erro, timeout, ou nenhum número válido): escolhe uma carta aleatória.
- Prompt: pede à LLM um JSON estrito (
vote(Melômano):- Heurística inicial (
_find_own_option_index): localiza a própria carta entre as opções (porid, com fallback por título+artista) para nunca votar nela mesma. - Prompt: pede um JSON estrito (
{"v": [num1, num2]}) com os dois melhores índices. - Camadas de resolução, também registradas via
_record_decision, mas como contagem por plano (ex.:{"A": 2}ou{"A": 1, "C": 1}), já que os dois votos de uma mesma chamada podem vir de planos diferentes:- A — voto veio do JSON da LLM.
- B — voto veio do fallback de número isolado via regex.
- C — voto veio do preenchimento aleatório final, usado apenas para completar os votos que faltarem.
- Heurística inicial (
O projeto gera dois tipos de log, com propósitos diferentes, e uma ferramenta para cada um:
| Log | Gerado por | Conteúdo | Ferramenta de leitura |
|---|---|---|---|
logs/partida_<run_id>.json |
Game Master | O resultado da partida: mãos iniciais, dica de cada rodada, quem jogou o quê, quem votou em quê, pontuação e placar final. | render_log_readable.py |
logs/decisions_<agente>_<run_id>.jsonl |
Cada agente (via _record_decision, em base_agent.py) |
O caminho interno que o agente percorreu para decidir cada select_card_by_clue/vote: se a resposta veio da LLM de primeira (plano A) ou de qual camada de fallback (B ou C). |
analyze_decisions.py |
Ou seja: o log do Game Master conta o que aconteceu na partida; o log de decisões conta como o nosso agente chegou lá por dentro — informação que não existe em nenhum lugar do log da partida.
Lê o JSON de uma partida e imprime um relatório tabular fácil de ler (o mesmo formato do exemplo da Seção 4), útil para depurar visualmente o comportamento do agente rodada a rodada.
# imprime no terminal
python3 render_log_readable.py logs/partida_20260704_183012.json
# salva em arquivo de texto
python3 render_log_readable.py logs/partida_20260704_183012.json -o partida_legivel.txt
# ajusta o tamanho do preview das letras e a largura de quebra de linha
python3 render_log_readable.py logs/partida_20260704_183012.json --lyrics-chars 90 --wrap 100Não preserva todos os campos do JSON original — é uma versão resumida, pensada para leitura humana, não para análise programática.
Lê um ou mais arquivos decisions_<agente>_<run_id>.jsonl e resume, por ferramenta (select_card_by_clue, vote), quantas chamadas foram resolvidas por cada plano — ou seja, a taxa de sucesso real da LLM versus cada camada de fallback.
# uma partida específica
python3 analyze_decisions.py logs/decisions_LLMAgent_1_20260704_183012.jsonl
# várias partidas/agentes de uma vez
python3 analyze_decisions.py logs/decisions_*.jsonlSaída típica:
=== select_card_by_clue (40 chamadas) ===
A 34 chamadas (85.0%)
B 4 chamadas (10.0%)
C 2 chamadas (5.0%)
=== vote (40 chamadas) ===
votos via plano A 72 (90.0% dos votos)
votos via plano B 5 (6.2% dos votos)
votos via plano C 3 (3.8% dos votos)
chamadas que precisaram de algum fallback: 6/40 (15.0%)
Use isso para responder perguntas como "em quantas % das chamadas a LLM respondeu algo utilizável de primeira?" sem precisar reler manualmente o log da partida inteira.
- Alucinação de IDs Inexistentes: a LLM frequentemente respondia com IDs do catálogo de músicas que nem sequer estavam na mão do jogador ou na mesa.
- Solução: o prompt de
select_card_by_cluepede o índice posicional na mão, em vez do ID do catálogo. Restringir o espaço de respostas possíveis praticamente zerou as alucinações de índice fora do intervalo.
- Solução: o prompt de
- Quebra de protocolo e respostas "faladoras" da LLM: o modelo frequentemente envolvia o JSON pedido em texto extra (ex.: "Aqui está o JSON: {...}"), quebrando um
json.loadsdireto.- Solução: extração via regex do primeiro bloco
{...}antes de tentar o parse, com um segundo fallback que garimpa qualquer dígito isolado na resposta crua caso o JSON venha malformado.
- Solução: extração via regex do primeiro bloco
- Identificação da própria carta na votação: depender exclusivamente da chave
idenviada pelo Game Master era frágil.- Solução:
_find_own_option_indextenta poridprimeiro e cai para comparação por (título,artista) como rede de segurança, logando um aviso se nem assim conseguir identificar a carta.
- Solução:
- Falta de visibilidade sobre o quanto a LLM realmente ajudava (versus os fallbacks): o log do Game Master mostra o resultado da partida, mas não indica se uma jogada saiu da LLM ou de uma heurística de contingência.
- Solução: instrumentamos
select_card_by_clueevotecom_record_decision, gravando qual plano (A/B/C) resolveu cada chamada emlogs/decisions_<agente>_<run_id>.jsonl, e escrevemos oanalyze_decisions.pypara agregar essas taxas por partida ou por lote de partidas (veja Seção 6).
- Solução: instrumentamos
- Logs de decisão se misturando entre partidas diferentes: como os agentes são reiniciados a cada execução de
run_game.pye recebem sempre o mesmo nome (ex.:LLMAgent_1), o log de decisões (que abre em modo append) acabava acumulando dados de partidas diferentes no mesmo arquivo.- Solução: introduzimos um
run_idúnico gerado uma vez porrun_game.pye repassado ao Game Master e a todos os agentes via--run-id. Isso separa os arquivos por execução (decisions_<agente>_<run_id>.jsonl) e ainda permite correlacionar diretamente com o log da partida correspondente (partida_<run_id>.json).
- Solução: introduzimos um