feat(vida): jeito de gente e console com o porquê de cada decisão

Console (console.py + tela Console no painel):
- cada parte registra o que fez e o motivo: modo vida (com necessidades
  e humor), sentidos, celular (ordens e o que o rosto fez), IA local,
  aprendizado e ideias do dia; últimas 400 linhas, ao vivo pelo SSE;
- tela com o "agora" (humor, necessidades, o que está fazendo, próxima
  decisão, última decisão, jeito), filtros, detalhes, pausa e busca.

Jeito de gente:
- saudade: a festa cresce com o tempo fora (aceno, "Oi! Você voltou!",
  "Senti sua falta!"); bom dia lembrando de ontem;
- pedidos com motivo ("Brinca comigo?", "Me faz um carinho?", mostrar a
  figurinha nova), no máximo 1 a cada 20 min;
- larga atividade no meio quando fica chato; personalidade (curiosidade,
  brincadeira, timidez) que pesa nos gestos e amadurece com o convívio;
- no rosto: acordar grogue, sonhar com algo do dia (balão no motor),
  hesitar antes de gestos, errar na atividade e ficar sem graça, olhar
  nos olhos e desviar, sorrir de volta (FaceLandmarker no rastreador do
  painel) e reagir à luz (sensor de luz no app 0.10.0);
- ordem "dizer" com legenda e voz de robô.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-11 03:37:07 -03:00
co-authored by Claude Opus 5.5
parent a99aa897c4
commit 2933407b9e
27 changed files with 1248 additions and 33 deletions
+207 -8
View File
@@ -43,6 +43,8 @@ ESTIMULOS = {
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
}
NOME_ESTIMULO = {'pir': 'O sensor de presença viu alguém', 'movimento': 'A câmera viu movimento',
'som': 'Ouviu um barulho', 'conversa': 'Alguém conversou com ele'}
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
@@ -112,6 +114,16 @@ PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícar
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
MAX_DIARIO = 60
# Personalidade: traços de 0 a 1 que mudam o jeito de tudo (0,5 = neutro) e amadurecem com o convívio.
TRACOS = ('curiosidade', 'brincadeira', 'timidez')
GESTOS_TRACO = {'brincadeira': {'rir', 'comemorar', 'alegre', 'musica', 'piscadinha'},
'curiosidade': {'olhar_esq', 'olhar_dir', 'pensar', 'espiar', 'confuso'},
'timidez': {'timido', 'esconder', 'espiar'}}
# Reencontro: quanto mais tempo a pessoa ficou fora, maior a festa.
AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600
INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro
DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
@@ -149,6 +161,17 @@ class Vida:
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
self.contar_aprendi = lambda ident, agora=None: None
self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py)
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None # console do painel
self.dia_anterior = lambda: None # resumo de ontem (aprendizado): para lembrar no bom dia
# sorteios dos comportamentos "humanos" ficam à parte: não mexem na sequência do rng principal
self.acaso = random.Random() if rng is None else random.Random(7)
self.porque = '' # motivo da última decisão (vai para o console)
self.ultima = None # {horario, texto, porque}: a última decisão de fato
self.perto_antes = False
self.ultimo_perto = time.time()
self.pediu_em = 0.0
self.desistencia = None # (quando, o que) se largou a atividade no meio
self.figurinha_mostrada = 0.0
agora = time.time()
self.config = dict(PADRAO)
@@ -157,6 +180,10 @@ class Vida:
self.diario = []
self.emojis = dict(dia='', total=0, ultimo=0.0)
self.dormindo_proprio = False
# cada Carinha nasce com um jeito um pouco diferente (nos testes, neutro)
self.personalidade = ({k: 0.5 for k in TRACOS} if rng is not None else
{k: round(self.acaso.uniform(0.35, 0.65), 2) for k in TRACOS})
self.saudou_dia = ''
try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
@@ -165,6 +192,11 @@ class Vida:
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
self.nascimento = float(salvo.get('nascimento', agora))
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
if isinstance(salvo.get('personalidade'), dict):
for k in TRACOS:
if isinstance(salvo['personalidade'].get(k), (int, float)):
self.personalidade[k] = max(0.0, min(1.0, float(salvo['personalidade'][k])))
self.saudou_dia = str(salvo.get('saudou_dia', ''))
if isinstance(salvo.get('emojis'), dict):
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
except (OSError, ValueError, TypeError, AttributeError):
@@ -222,7 +254,8 @@ class Vida:
def salvar(self):
with self.lock:
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
diario=self.diario, emojis=self.emojis)
diario=self.diario, emojis=self.emojis, personalidade=self.personalidade,
saudou_dia=self.saudou_dia)
self.salvo_em = time.time()
try:
gravar_atomico(self.caminho, dados)
@@ -274,7 +307,9 @@ class Vida:
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
zanga=self.zanga(), sono_profundo=self.sono_profundo,
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30])
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30],
personalidade=dict(self.personalidade), porque=self.porque, ultima=self.ultima,
proximo_ato_em=max(0, round(self.proximo_ato - agora)))
def avisar(self):
self.publicado_em = time.time()
@@ -323,6 +358,10 @@ class Vida:
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
if self.aprendizado:
self.aprendizado.resposta(tipo, agora)
self.amadurecer(tipo)
self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo),
', '.join('%s %+d' % (k, v) for k, v in efeito.items()),
'detalhe' if tipo in ('som', 'movimento') else 'info', agora)
if gesto and not self.bloqueado():
if self.dormindo():
self.acordar(agora, None)
@@ -390,12 +429,108 @@ class Vida:
self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora)
return 'atividade ' + nome
nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho
if nome != 'dormir' and segundos > 60 and self.acaso.random() < DESISTE * (1.3 - self.personalidade['curiosidade']):
segundos = segundos * self.acaso.uniform(0.3, 0.5) # vai largar no meio: ficou chato
self.desistencia = (agora + segundos, NOME_ATIVIDADE.get(nome, 'a atividade'))
self.enfileirar('atividade %s %d' % (nome, segundos))
self.atividade_em = agora
self.ultima_atividade = nome
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
return 'atividade ' + nome
def dizer(self, texto, gesto=None):
"""Uma fala curta: o gesto e a frase em legenda, com a voz de robô."""
if gesto:
self.enfileirar(gesto)
self.enfileirar('dizer ' + texto)
def resumo(self):
n = self.necessidades
return 'energia %d · alegria %d · social %d · diversão %d · %s' % (
n['energia'], n['alegria'], n['social'], n['diversao'], HUMORES[self.humor()][1].lower())
@staticmethod
def tempo(seg):
return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60)
def saudacao(self, agora):
"""Bom dia com uma lembrança de ontem (do que ele viveu, sem IA)."""
hora = time.localtime(agora).tm_hour
prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite'
fatos = []
ontem = None
try:
ontem = self.dia_anterior()
except Exception:
pass
if isinstance(ontem, dict):
pres = ontem.get('presenca') or []
tarde = [hh for hh in range(0, 5) if hh < len(pres) and pres[hh] is not None and pres[hh] >= 0.3]
if tarde:
fatos.append('Ontem a gente ficou acordado até as %dh, hein!' % (max(tarde) + 1))
elif len(pres) == 24 and pres[23] is not None and pres[23] >= 0.3:
fatos.append('Ontem a gente ficou até tarde, hein!')
if (ontem.get('respostas') or 0) >= 5:
fatos.append('Ontem foi um dia cheio de carinho. Obrigado!')
elif (ontem.get('atos') or 0) >= 5 and not ontem.get('respostas'):
fatos.append('Ontem ninguém brincou comigo... hoje vai ser diferente?')
if not fatos:
fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!')
return '%s! %s' % (prefixo, self.acaso.choice(fatos))
def reencontro(self, agora, ausencia):
"""Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é)."""
if self.dormindo() or self.madrugada(agora) or self.bloqueado():
return None
n, tim = self.necessidades, self.personalidade['timidez']
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
motivo = 'ninguém por perto havia ' + self.tempo(ausencia)
if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO:
self.saudou_dia = dia
frase = self.saudacao(agora)
self.dizer(frase, 'timido' if tim > 0.65 else 'acenar')
ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo
elif ausencia >= AUSENCIA_SAUDADE:
self.dizer('Senti sua falta!', 'comemorar')
self.enfileirar('coracao')
ganho, texto = 20, 'Fez festa: estava com saudade'
n['alegria'] = _limitar(n['alegria'] + 10)
elif ausencia >= AUSENCIA_OI:
self.dizer('Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar')
ganho, texto = 10, 'Comemorou que alguém voltou'
elif ausencia >= AUSENCIA_ACENO:
self.enfileirar('acenar')
ganho, texto = 4, 'Acenou para quem voltou'
else:
return None
with self.lock:
n['social'] = _limitar(n['social'] + ganho)
self.anotar('social', texto, agora)
self.console('vida', texto, motivo, agora=agora)
self.avisar()
return texto
def pedir_algo(self, agora):
"""Iniciativa com motivo: pede o que está faltando (ou mostra o que fez)."""
n = self.necessidades
if n['social'] < 35:
frase, gesto, porque = 'Tô carente... me faz um carinho?', 'coracao', 'social %d' % n['social']
elif n['diversao'] < 40:
frase, gesto, porque = 'Brinca comigo?', 'acenar', 'diversão %d' % n['diversao']
elif (float(self.emojis.get('ultimo') or 0) > self.figurinha_mostrada and agora - float(self.emojis['ultimo']) < 3 * 3600
and self.tocar_reacao(agora, anotar=False)):
self.figurinha_mostrada = float(self.emojis['ultimo'])
frase, gesto, porque = 'Olha o que eu desenhei!', None, 'criou uma figurinha e ainda não tinha mostrado'
elif n['energia'] < 35:
frase, gesto, porque = 'Tô com soninho...', 'bocejar', 'energia %d' % n['energia']
else:
return None
self.pediu_em = agora
self.dizer(frase, gesto)
self.anotar('pedido', 'Pediu: "%s"' % frase, agora)
self.porque = 'alguém por perto e ' + porque
return 'pedido'
def pir_acorda(self, agora=None):
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
agora = agora or time.time()
@@ -423,8 +558,27 @@ class Vida:
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
return self.aprendizado.peso(nome) if self.aprendizado else 1.0
def jeito(self, gesto):
"""Quanto a personalidade puxa um gesto (1 = neutro): brincalhão ri mais, tímido se esconde mais."""
f = 1.0
for traco, gestos in GESTOS_TRACO.items():
if gesto in gestos:
f *= 1 + (self.personalidade[traco] - 0.5) * 1.2
return f
def amadurecer(self, tipo):
"""O convívio muda o jeito devagar: carinho deixa menos tímido, brincar deixa mais brincalhão."""
p = self.personalidade
with self.lock:
if tipo in ('carinho', 'conversa', 'brincar'):
p['timidez'] = round(max(0.1, p['timidez'] - 0.003), 3)
if tipo == 'brincar':
p['brincadeira'] = round(min(0.95, p['brincadeira'] + 0.002), 3)
if tipo == 'conversa':
p['curiosidade'] = round(min(0.95, p['curiosidade'] + 0.001), 3)
def escolher(self, opcoes):
opcoes = [(g, p * self.gosto(g), t) for g, p, t in opcoes]
opcoes = [(g, p * self.gosto(g) * self.jeito(g), t) for g, p, t in opcoes]
total = sum(p for _, p, _ in opcoes)
x = self.rng.uniform(0, total)
for gesto, peso, texto in opcoes:
@@ -467,42 +621,48 @@ class Vida:
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
return True
def tocar_reacao(self, agora):
def tocar_reacao(self, agora, anotar=True):
try:
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
except Exception:
return False
if not reacoes:
return False
r = self.rng.choice(reacoes[:40])
r = self.rng.choice(reacoes[:40]) if anotar else reacoes[0] # para mostrar: a mais nova
try:
self.treinamento.testar(r['id'])
except ValueError:
return False
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
if anotar:
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
return True
def agir(self, agora):
"""Um ato: o que um bichinho com essas necessidades faria agora."""
n = self.necessidades
perto = self.alguem_perto()
self.porque = ''
if self.dormindo():
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado
if n['energia'] >= DESCANSADO and not self.madrugada(agora):
self.acordar(agora, 'Acordou descansado')
self.porque = 'energia %d: descansou' % n['energia']
return 'acordar'
# aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
and self.aprendizado.chega_logo(agora)):
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora)
self.porque = self.aprendizado.porque_chegada(agora)
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.porque, agora)
return 'acordar'
return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
self.dormir(agora, 'Ficou tarde, foi dormir')
self.porque = 'madrugada e ninguém interagiu nos últimos 10 min'
return 'dormir'
if n['energia'] < SONO:
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
self.porque = 'energia %d: abaixo de %d, sono pesado até descansar' % (n['energia'], SONO)
return 'dormir'
humor = self.humor()
if self.celular_atividade():
@@ -511,9 +671,11 @@ class Vida:
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra
# cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado)
if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
self.porque = 'energia %d: abaixo de %d, um cochilo' % (n['energia'], COCHILO)
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
self.porque = 'diversão %d: entediado (abaixo de %d)' % (n['diversao'], TEDIO)
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300))
# ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
if perto:
@@ -523,8 +685,9 @@ class Vida:
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
segundos = self.rng.uniform(600, 1200)
self.porque = self.aprendizado.porque_vazia(agora)
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
self.aprendizado.porque_vazia(agora), agora)
self.porque, agora)
return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo':
@@ -533,26 +696,41 @@ class Vida:
self.contar_aprendi(item['id'], agora)
with self.lock:
n['social'] = _limitar(n['social'] + 5)
self.porque = 'alguém por perto e aprendeu "%s" hoje' % item.get('nome', '?')
return 'aprendi'
# iniciativa: com gente por perto, pede o que está faltando (tímido pede menos)
if (dia and perto and not sozinho and agora - self.pediu_em > INTERVALO_PEDIDO
and self.acaso.random() < 0.25 * (1.3 - self.personalidade['timidez'])):
pedido = self.pedir_algo(agora)
if pedido:
return pedido
if dia and folga and sozinho and self.rng.random() < 0.4:
self.porque = 'ninguém por perto há %s: vai cuidar da vida dele' % self.tempo(agora - self.visto_em)
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180))
# bem: de vez em quando, uma atividade curtinha por vontade própria
if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
self.porque = 'de bem (%s): vontade própria' % humor
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
if self.criar_figurinha(agora, humor):
self.porque = 'teve uma ideia (até %d figurinhas por dia)' % self.config['max_emojis_dia']
return 'figurinha'
# às vezes lembra de uma reação da biblioteca
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
if self.tocar_reacao(agora):
with self.lock:
n['diversao'] = _limitar(n['diversao'] + 8)
self.porque = 'humor %s: lembrou de uma reação da biblioteca' % humor
return 'reacao'
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
if sozinho: # sem plateia: nada de acenar ou ficar tímido
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista)
self.porque = 'humor %s%s%s' % (
humor, ' com gente por perto' if perto else ', sozinho' if sozinho else '',
'; gosto %.1f×, jeito %.1f×' % (self.gosto(gesto), self.jeito(gesto))
if (self.gosto(gesto), round(self.jeito(gesto), 1)) != (1.0, 1.0) else '')
if self.aprendizado and perto:
self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto)
@@ -588,6 +766,21 @@ class Vida:
self.sono_profundo = False
self.enfileirar('sono leve')
self.anotar('sono', 'Acordou antes de descansar', agora)
perto = self.alguem_perto()
if perto:
if not self.perto_antes:
self.reencontro(agora, agora - self.ultimo_perto)
self.ultimo_perto = agora
self.perto_antes = perto
if self.desistencia and agora >= self.desistencia[0]:
_, oque = self.desistencia
self.desistencia = None
if not self.dormindo():
self.enfileirar('bocejar')
acao = oque[4:] if oque.startswith('Foi ') else 'a atividade'
texto = 'Largou no meio: cansou de ' + acao
self.anotar('acao', texto, agora)
self.console('vida', texto, 'perdeu a graça (curiosidade %.2f)' % self.personalidade['curiosidade'], agora=agora)
if not self.bloqueado():
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
zanga = self.zanga()
@@ -600,8 +793,14 @@ class Vida:
self.humor_enviado = (humor, agora)
self.zanga_enviada = zanga
if agora >= self.proximo_ato:
antes = self.diario[0]['horario'] if self.diario else None
ato = self.agir(agora)
self.aprender_ato(ato, agora)
if ato:
novo = self.diario[0] if self.diario and self.diario[0]['horario'] != antes else None
self.ultima = dict(horario=agora, texto=novo['texto'] if novo else ato, porque=self.porque)
self.console('vida', novo['texto'] if novo else ato,
(self.porque + ' · ' if self.porque else '') + self.resumo(), agora=agora)
lo, hi = RITMOS[self.config['ritmo']]
self.proximo_ato = agora + self.rng.uniform(lo, hi)
if ato or agora - self.publicado_em > 30 or humor != self.humor():