feat(vida): ideias do dia com inspiração da internet e "olha o que eu aprendi"

Todo dia, a partir das 9h, o Carinha cria 3 atividades novas (inspiracao.py):
- sinais de fontes abertas: tempo da cidade (Open-Meteo), datas
  comemorativas da semana (lista fixa + feriados da BrasilAPI), "neste dia"
  da Wikipédia e assuntos em alta no Google Trends Brasil;
- o Gemini escolhe ideias leves (descarta tragédia, política e afins) e
  cada uma vira atividade pelo estúdio (Criacao.gerar), com motivo e uma
  curiosidade;
- até 3 tentativas por dia, 30 min entre elas; botão "Criar mais agora";
- as atividades criadas entram na rotina do modo vida (as de hoje com 40%
  de chance) e, com alguém perto, ele conta a curiosidade: legenda e voz
  de robô (ordem "aprendi", até 2x por dia cada);
- painel: cartão "Ideias do dia" no estúdio (Mostrar, Contar, o que ele
  viu na internet, ajustes: quantas, horário, cidade, fontes) e tópico no
  guia.

Correções achadas no caminho:
- WebView de fábrica do S10 (Chrome 102) não tem AbortSignal.timeout: o
  celular nunca mandava o status e a placa o via offline;
- arquivos estáticos com Cache-Control no-cache e o app com LOAD_NO_CACHE:
  o WebView seguia com uma carinha.html velha depois de deploy;
- status do celular recusado vai para o journal (1 linha por minuto);
- legenda mais estreita para não cobrir a bateria.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-08 08:13:03 -03:00
co-authored by Claude Opus 5.5
parent 43770c6d67
commit 4777ceb023
19 changed files with 1179 additions and 30 deletions
+204
View File
@@ -0,0 +1,204 @@
"""Ideias do dia: sinais da internet (falsos), filtro pela IA, criação das atividades, horário e "aprendi"."""
import json
import random
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from criacao import ATIVIDADES_BASE, Criacao # noqa: E402
from inspiracao import Inspiracao, descrever_tempo # noqa: E402
from vida import Vida # noqa: E402
TEMPO = dict(current=dict(temperature_2m=12.4, weather_code=63, is_day=1),
daily=dict(temperature_2m_max=[15.2], temperature_2m_min=[9.8], precipitation_probability_max=[80],
weather_code=[63]))
FERIADOS = [dict(date='2026-10-12', name='Nossa Senhora Aparecida', type='national')]
HISTORIA = dict(selected=[dict(year=1957, text='A União Soviética lança o Sputnik 1, o primeiro satélite artificial.')],
events=[dict(year=1871, text='Começa o grande incêndio de Chicago.')])
ALTA = ('<?xml version="1.0"?><rss><channel><item><title>semana mundial do espaço</title></item>'
'<item><title>eleição municipal</title></item></channel></rss>')
# 8 de outubro de 2026, 10h (horário de Brasília)
AGORA = time.mktime((2026, 10, 8, 10, 0, 0, 0, 0, -1))
def baixar_falso(url, timeout=10):
if 'open-meteo.com/v1/forecast' in url:
return json.dumps(TEMPO).encode()
if 'brasilapi' in url:
return json.dumps(FERIADOS).encode()
if 'onthisday' in url:
return json.dumps(HISTORIA).encode()
if 'trends.google' in url:
return ALTA.encode()
if 'geocoding' in url:
return json.dumps(dict(results=[dict(name='Curitiba', admin1='Paraná', latitude=-25.4284, longitude=-49.2733)])).encode()
raise OSError('sem internet')
class InspiracaoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
pasta = Path(self.tmp.name)
self.c = Criacao(pasta / 'c.db', pasta / 'c.json')
self.prompts = []
ideias = dict(ideias=[
dict(pedido='bonequinho de astronauta pulando com estrelas em volta', motivo='Semana Mundial do Espaço',
fonte='alta', aprendi='Sabia que o Sputnik foi o primeiro satélite, em 1957?'),
dict(pedido='sentado lendo um livro com uma xícara, chuva lá fora', motivo='Chuva em Londrina', fonte='tempo',
aprendi='A chuva enche os rios e rega as plantas!'),
dict(pedido='dançando com estrelas para o Dia das Crianças', motivo='Dia das Crianças chegando', fonte='datas',
aprendi='O Dia das Crianças no Brasil é em 12 de outubro.')])
def chamar(ia, prompt, schema):
self.prompts.append(prompt)
if 'ideias' in schema['properties']:
return ideias
return dict(ATIVIDADES_BASE['dancar'], nome='Atividade %d' % len(self.prompts))
self.c.chamar = chamar
self.i = Inspiracao(pasta / 'i.json', self.c, iniciar=False)
self.i.baixar = baixar_falso
self.i.pode_gemini = lambda: True
self.ordens, self.diario = [], []
self.i.enfileirar = self.ordens.append
self.i.anotar = lambda tipo, texto, agora=None: self.diario.append((tipo, texto))
def tearDown(self):
self.tmp.cleanup()
def test_sinais_das_quatro_fontes(self):
s = self.i.coletar(AGORA)
self.assertIn('12 °C e chuva', s['tempo'])
self.assertIn('80% de chance de chuva', s['tempo'])
self.assertTrue(any('Nossa Senhora Aparecida' in d for d in s['datas']))
self.assertIn('em 4 dias (12/10): Dia das Crianças', s['datas'])
self.assertTrue(any('Sputnik' in f for f in s['historia']))
self.assertEqual(s['alta'], ['semana mundial do espaço', 'eleição municipal'])
self.assertEqual(descrever_tempo(95), 'tempestade com trovões')
def test_fonte_fora_do_ar_nao_impede_as_outras_e_fontes_desligadas(self):
def so_tempo(url, timeout=10):
if 'forecast' in url:
return json.dumps(TEMPO).encode()
raise OSError('fora do ar')
self.i.baixar = so_tempo
self.assertEqual(list(self.i.coletar(AGORA)), ['tempo', 'datas']) # as datas fixas não dependem da internet
self.i.baixar = baixar_falso
self.i.configurar(dict(fontes=dict(alta=False, historia=False)))
self.assertEqual(sorted(self.i.coletar(AGORA)), ['datas', 'tempo'])
with self.assertRaises(ValueError):
self.i.configurar(dict(fontes=dict(tempo=False, datas=False))) # sobraria nenhuma
def test_cria_as_ideias_do_dia_na_hora_certa(self):
cedo = AGORA - 3 * 3600 # 7h: antes das 9h não cria
self.assertFalse(self.i.passo(cedo))
self.assertTrue(self.i.passo(AGORA))
self.assertEqual(len(self.i.hoje['ids']), 3)
itens = self.i.itens_hoje()
self.assertEqual([i['origem'] for i in itens], ['dia'] * 3)
self.assertEqual(itens[0]['motivo'], 'Semana Mundial do Espaço')
self.assertIn('Sputnik', itens[0]['aprendi'])
# o prompt pede só temas leves e leva os sinais e o que ele já tem
prompt = self.prompts[0]
self.assertIn('política', prompt)
self.assertIn('Londrina', prompt)
self.assertIn('Dançando', prompt)
self.assertIn('quinta-feira, 8 de outubro de 2026', prompt)
self.assertTrue(any(t == 'ideia' for t, _ in self.diario))
self.assertFalse(self.i.passo(AGORA + 60)) # já fez as de hoje
# dia seguinte: começa de novo
self.assertTrue(self.i.passo(AGORA + 86400))
self.assertEqual(len(self.i.hoje['ids']), 3)
def test_sem_internet_tenta_de_novo_depois_e_desiste_no_dia(self):
self.i.baixar = lambda url, timeout=10: (_ for _ in ()).throw(OSError('sem rede'))
chamadas = []
def ia_fora(ia, prompt, schema):
chamadas.append(prompt)
raise ValueError('Gemini indisponível')
self.c.chamar = ia_fora
self.assertTrue(self.i.passo(AGORA))
self.assertIn('Gemini indisponível', self.i.hoje['erro'])
self.assertFalse(self.i.passo(AGORA + 60)) # espera 30 min
self.assertTrue(self.i.passo(AGORA + 31 * 60))
self.assertTrue(self.i.passo(AGORA + 62 * 60))
self.assertFalse(self.i.passo(AGORA + 93 * 60)) # 3 tentativas por dia
self.assertEqual(len(chamadas), 3)
self.assertEqual(self.i.hoje['ids'], [])
def test_sem_gemini_ou_desligado_nao_cria(self):
self.i.pode_gemini = lambda: False
self.assertFalse(self.i.passo(AGORA))
self.i.pode_gemini = lambda: True
self.i.configurar(dict(ligado=False))
self.assertFalse(self.i.passo(AGORA))
def test_aprendi_hoje_conta_ate_duas_vezes(self):
self.i.passo(AGORA)
item = self.i.pendente()
self.assertIsNotNone(item)
self.i.contar(item['id'])
self.i.contar(item['id'])
self.assertTrue(self.ordens[0].startswith('aprendi Sabia que o Sputnik'))
self.assertNotEqual(self.i.pendente()['id'], item['id']) # já contou 2x: passa para a próxima
self.i.configurar(dict(aprendi=False))
self.assertIsNone(self.i.pendente())
def test_cidade_pelo_nome_e_validacao(self):
st = self.i.configurar(dict(cidade='curitiba', por_dia=2, hora=14))
self.assertEqual(st['config']['cidade'], dict(nome='Curitiba (Paraná)', lat=-25.4284, lon=-49.2733))
self.assertEqual((st['config']['por_dia'], st['config']['hora']), (2, 14))
for ruim in (dict(por_dia=9), dict(hora=23), dict(ligado='sim'), dict(outra=1)):
with self.assertRaises(ValueError):
self.i.configurar(ruim)
outra = Inspiracao(Path(self.tmp.name) / 'i.json', self.c, iniciar=False)
self.assertEqual(outra.config['por_dia'], 2)
class VidaComNovidadesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(3))
self.ordens = []
self.v.enfileirar = self.ordens.append
self.v.celular_online = lambda: True
self.v.celular_dormindo = lambda: False
self.v.madrugada = lambda agora: False
self.nova = dict(id=7, nome='Astronauta', emoji='🚀', motivo='Semana do Espaço', aprendi='Sabia que...',
receita=dict(ATIVIDADES_BASE['dancar'], nome='Astronauta'))
self.v.novidades = lambda: [self.nova]
self.v.do_dia = lambda: [7]
def tearDown(self):
self.tmp.cleanup()
def test_ideias_de_hoje_entram_na_rotina(self):
escolhas = [self.v.escolher_atividade() for _ in range(200)]
self.assertGreater(escolhas.count('criado:7'), 40)
ato = self.v.comecar_atividade(time.time(), 'criado:7', 150)
self.assertEqual(ato, 'atividade criado:7')
ordem = self.ordens[-1]
self.assertTrue(ordem.startswith('atividade_criada '))
self.assertEqual(json.loads(ordem[17:])['duracao'], 150)
self.assertIn('Astronauta (Semana do Espaço)', self.v.diario[0]['texto'])
# não repete a mesma em seguida
self.v.ultima_atividade = 'criado:7'
self.assertNotIn('criado:7', [self.v.escolher_atividade() for _ in range(50)])
def test_conta_o_que_aprendeu_com_gente_perto(self):
contadas = []
self.v.aprendi_pendente = lambda: self.nova
self.v.contar_aprendi = lambda ident, agora=None: contadas.append(ident)
self.v.alguem_perto = lambda: True
self.v.atividade_em = time.time() # sem folga para atividades
for _ in range(30):
if self.v.agir(time.time()) == 'aprendi':
break
self.assertEqual(contadas, [7])
if __name__ == '__main__':
unittest.main()