Files
carinha-control-panel/tests/test_aprendizado.py
T
julioandClaude Opus 5.5 24625453f7 feat(vida): aprendizado com o dia a dia (gosto, rotina da casa e sono)
O Carinha passa a melhorar as interações com o que vive em casa, sem IA
e sem internet (aprendizado.py):
- gosto: gesto ou atividade feito com alguém por perto é uma tentativa;
  carinho, brincadeira ou conversa até 60 s depois contam como "gostaram"
  (cutucada vale meio). A chance de cada ato vai de 0,5x a 2x, relativa à
  média, e só depois de 3 tentativas;
- rotina: fração de minutos com gente em cada hora (média móvel entre
  dias). Antes de uma hora movimentada, acorda do cochilo 15 min antes;
  sono pesado e madrugada continuam respeitados;
- sono: PIR acordou e ninguém ficou nem interagiu em 3 min = à toa. Se
  repete na mesma hora, ali pede 1 ou 2 disparos a mais para acordar;
- à meia-noite fecha o dia: lições em primeira pessoa no diário e o
  passado pesa 15% menos a cada dia (o gosto muda);
- rotas /aprendizado/status|config|esquecer e SSE 'aprendizado';
- painel: cartão "O que ele aprendeu" no Ao vivo (rotina em 24 barras,
  faz mais / faz menos, lições, esquecer com confirmação) e tópico no
  guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:13:23 -03:00

237 lines
10 KiB
Python

"""Aprendizado com o dia a dia: gosto pelos atos, rotina da casa, sono e o fechamento do dia."""
import http.client
import importlib.util
import json
import random
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from aprendizado import Aprendizado # noqa: E402
from vida import Vida # noqa: E402
spec = importlib.util.spec_from_file_location('kiosk_aprendizado', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
def em(dia, hora, minuto=0, segundo=0):
return time.mktime((2026, 10, dia, hora, minuto, segundo, 0, 0, -1))
class AprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.caminho = Path(self.tmp.name) / 'aprendizado.json'
self.a = Aprendizado(self.caminho)
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append((tipo, texto))
def tearDown(self):
self.tmp.cleanup()
def fazer(self, nome, vezes, resposta=None, inicio=None):
t = inicio or em(6, 15)
for i in range(vezes):
self.a.ato(nome, t + i * 120, perto=True)
if resposta:
self.a.resposta(resposta, t + i * 120 + 20)
def test_gosto_sobe_com_carinho_e_desce_quando_ignorado(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 16))
self.assertGreater(self.a.peso('rir'), 1.0)
self.assertLess(self.a.peso('bocejar'), 1.0)
self.assertLessEqual(self.a.peso('rir'), 2.0)
self.assertGreaterEqual(self.a.peso('bocejar'), 0.5)
def test_sem_opiniao_com_poucas_tentativas_ou_sozinho(self):
self.fazer('rir', 2, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
for i in range(10):
self.a.ato('pensar', em(6, 15) + i, perto=False) # sem plateia ninguém pode gostar
self.assertEqual(self.a.peso('pensar'), 1.0)
self.assertNotIn('pensar', self.a.itens)
def test_resposta_fora_da_janela_nao_conta(self):
for i in range(4):
self.a.ato('acenar', em(6, 15) + i * 300, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 300 + 200) # 200 s depois: já era outra coisa
self.assertEqual(self.a.itens['acenar']['s'], 0)
def test_cada_ato_agrada_uma_vez(self):
self.a.ato('rir', em(6, 15), perto=True)
for _ in range(5):
self.a.resposta('carinho', em(6, 15) + 10)
self.assertEqual(self.a.itens['rir']['s'], 1.0)
def test_fechar_o_dia_gera_licoes_e_esquece_um_pouco(self):
self.fazer('rir', 4, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17))
t_antes = self.a.itens['rir']['t']
self.a.observar(em(7, 0, 1), perto=False) # meia-noite: fecha o dia 6
textos = [t for _, t in self.diario]
self.assertTrue(any('gostam de me ver dar risada' in t for t in textos), textos)
self.assertTrue(any('Tentei bocejar 5 vezes e ninguém ligou' in t for t in textos), textos)
self.assertAlmostEqual(self.a.itens['rir']['t'], t_antes * 0.85, places=2)
self.assertEqual(self.a.hoje['dia'], '2026-10-07')
self.assertTrue(self.caminho.exists())
def test_rotina_da_casa_e_acordar_antes_de_chegarem(self):
for dia in (6, 7, 8):
for minuto in range(60):
self.a.observar(em(dia, 17, minuto), perto=False) # 17h: casa vazia
self.a.observar(em(dia, 18, minuto), perto=True) # 18h: chegou gente
self.a.observar(em(9, 0, 1), perto=False)
self.assertGreaterEqual(self.a.rotina[18], 0.5)
self.assertLess(self.a.rotina[17], 0.3)
self.assertTrue(any('costumam estar por perto das 18h às 19h' in t for _, t in self.diario), self.diario)
self.assertTrue(self.a.chega_logo(em(9, 17, 50)))
self.assertFalse(self.a.chega_logo(em(9, 17, 10))) # ainda falta muito
self.assertFalse(self.a.chega_logo(em(9, 12, 50)))
def test_acordar_a_toa_pede_mais_movimento_naquela_hora(self):
for dia in (6, 7, 8, 9):
for vez in range(2):
t = em(dia, 22, vez * 20)
self.a.acordou_pelo_sensor(t)
for s in range(0, 200, 5):
self.a.observar(t + s, perto=s < 10) # mexeu e sumiu: foi à toa
self.a.observar(em(dia + 1, 0, 1), perto=False)
self.assertGreaterEqual(self.a.extra_pir(em(10, 22, 30)), 1)
self.assertEqual(self.a.extra_pir(em(10, 15)), 0)
self.assertTrue(any('Acordei à toa 2 vezes por volta das 22h' in t for _, t in self.diario), self.diario)
def test_acordar_com_carinho_valeu_a_pena(self):
t = em(6, 22)
self.a.acordou_pelo_sensor(t)
self.a.observar(t + 5, perto=True)
self.a.resposta('carinho', t + 30)
for s in range(35, 200, 5):
self.a.observar(t + s, perto=False)
self.assertEqual(self.a.hoje['falsos'][22], 0)
def test_desligado_nao_aprende_nem_muda_nada(self):
self.a.configurar(dict(ligado=False))
self.fazer('rir', 6, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
self.assertEqual(self.a.extra_pir(em(6, 22)), 0)
self.assertFalse(self.a.chega_logo(em(6, 17, 50)))
def test_esquecer_e_persistencia(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17)) # o gosto é relativo: precisa de algo menos querido
self.a.salvar()
outro = Aprendizado(self.caminho)
self.assertGreater(outro.peso('rir'), 1.0)
outro.anotar = lambda *a, **k: None
outro.esquecer()
self.assertEqual(outro.peso('rir'), 1.0)
self.assertEqual(Aprendizado(self.caminho).itens, {})
def test_validar(self):
with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim'))
with self.assertRaises(ValueError):
self.a.configurar(dict(outro=True))
class VidaComAprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'aprendizado.json')
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(3))
self.v.aprendizado = self.a
self.ordens = []
self.v.enfileirar = self.ordens.append
self.v.ultimo_tick = em(6, 15)
def tearDown(self):
self.tmp.cleanup()
def test_gesto_que_agrada_sai_mais(self):
for i in range(8):
self.a.ato('rir', em(6, 15) + i * 100, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 100 + 5)
self.a.ato('feliz', em(6, 15) + i * 100 + 50, perto=True)
opcoes = [('rir', 1, ''), ('feliz', 1, '')]
saiu = [self.v.escolher(opcoes)[0] for _ in range(400)]
self.assertGreater(saiu.count('rir'), saiu.count('feliz') * 2)
def test_carinho_e_atos_com_plateia_chegam_ao_aprendizado(self):
self.v.alguem_perto = lambda: True
self.v.celular_online = lambda: True
self.v.proximo_ato = 0
self.v.necessidades.update(energia=90, social=90, diversao=90, alegria=90)
ato = None
for i in range(30):
ato = self.v.tick(em(6, 15) + i * 200)
if ato in self.a.itens:
break
self.assertIn(ato, self.a.itens)
self.v.estimulo('carinho', em(6, 15) + i * 200 + 10)
self.assertGreater(self.a.itens[ato]['s'], 0)
def test_pir_pede_mais_quando_acordou_a_toa_naquela_hora(self):
self.a.falsos[22] = 1.0
self.assertFalse(self.v.pir_acorda(em(6, 22)))
self.assertTrue(self.v.pir_acorda(em(6, 22) + 10))
self.assertEqual(self.a.acordou_em, em(6, 22) + 10)
self.assertTrue(self.v.pir_acorda(em(6, 15)))
def test_acorda_do_cochilo_antes_de_chegarem(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(6, 17, 50)), 'acordar')
self.assertIn('acordar', self.ordens)
def test_sono_profundo_nao_e_interrompido_pela_rotina(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.sono_profundo = True
self.v.necessidades['energia'] = 70
self.assertIsNone(self.v.agir(em(6, 17, 50)))
class RotasTest(unittest.TestCase):
def test_status_config_e_esquecer(self):
with tempfile.TemporaryDirectory() as tmp:
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'a.json')
s = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
s.aprendizado = Aprendizado(Path(tmp) / 'ap.json')
kiosk.ligar_eventos(s)
threading.Thread(target=s.serve_forever, daemon=True).start()
try:
def pedir(metodo, rota, corpo=None):
c = http.client.HTTPConnection('127.0.0.1', s.server_port, timeout=5)
dados = json.dumps(corpo).encode() if corpo is not None else None
c.request(metodo, rota, body=dados, headers={'Content-Type': 'application/json'} if dados else {})
r = c.getresponse()
return r.status, json.loads(r.read())
cod, st = pedir('GET', '/aprendizado/status')
self.assertEqual(cod, 200)
self.assertEqual(len(st['rotina']), 24)
cod, st = pedir('POST', '/aprendizado/config', dict(ligado=False))
self.assertEqual((cod, st['config']['ligado']), (200, False))
cod, st = pedir('POST', '/aprendizado/config', dict(ligado='x'))
self.assertEqual(cod, 400)
cod, st = pedir('POST', '/aprendizado/esquecer', {})
self.assertEqual((cod, st['dias']), (200, 0))
cod, est = pedir('GET', '/estado')
self.assertIn('aprendizado', est)
finally:
s.shutdown()
kiosk.EVENTOS.fechar()
if __name__ == '__main__':
unittest.main()