From 24625453f7befe3b392732fc537834511394c8bf Mon Sep 17 00:00:00 2001 From: julio Date: Fri, 9 Oct 2026 10:13:23 -0300 Subject: [PATCH] feat(vida): aprendizado com o dia a dia (gosto, rotina da casa e sono) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- aprendizado.py | 363 ++++++++++++++++++ deploy.ps1 | 2 +- kiosk-server.py | 15 +- tests/test_aprendizado.py | 236 ++++++++++++ vida.py | 38 +- web/src/lib/aprendizado.svelte.ts | 25 ++ .../lib/componentes/AprendizadoCartao.svelte | 283 ++++++++++++++ web/src/lib/componentes/VidaCartao.svelte | 3 + web/src/lib/estado.svelte.ts | 7 +- web/src/lib/guia.ts | 17 +- web/src/lib/tipos.ts | 18 +- web/src/telas/AoVivo.svelte | 2 + 12 files changed, 1000 insertions(+), 9 deletions(-) create mode 100644 aprendizado.py create mode 100644 tests/test_aprendizado.py create mode 100644 web/src/lib/aprendizado.svelte.ts create mode 100644 web/src/lib/componentes/AprendizadoCartao.svelte diff --git a/aprendizado.py b/aprendizado.py new file mode 100644 index 0000000..c7aaf63 --- /dev/null +++ b/aprendizado.py @@ -0,0 +1,363 @@ +"""Aprendizado: o Carinha melhora as interações com o que vive no dia a dia. + +Sem IA e sem internet: só conta o que acontece em casa e ajusta o modo vida aos poucos. +- Gosto: cada gesto ou atividade feito com alguém por perto vira uma tentativa; um carinho, uma + brincadeira ou uma conversa logo depois conta como "gostaram". Os que agradam saem mais, os + ignorados saem menos (no máximo o dobro ou a metade da chance). +- Rotina: em que horas costuma ter gente por perto. Um pouco antes da hora de sempre, ele acorda + de um cochilo para estar de pé quando a pessoa chegar. +- Sono: quando o sensor acorda o rosto e ninguém fica nem interage, foi à toa. Se isso se repete + na mesma hora, ele passa a pedir mais movimento para acordar nesse horário. +À meia-noite ele fecha o dia: o que aprendeu vira lição no diário e o que ficou para trás pesa +menos (o gosto das pessoas muda). "Esquecer" zera tudo. +""" +import json +import threading +import time +from pathlib import Path + +from comum import gravar_atomico, ler_json + +PADRAO = dict(ligado=True) +JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele +PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5} +ESQUECE = 0.85 # todo dia, o que aprendeu antes pesa um pouco menos +MEMORIA_ROTINA = 0.3 # quanto um dia novo pesa na rotina aprendida +MIN_TENTATIVAS = 3 # com menos que isso, ainda não tem opinião sobre um ato +FAIXA = (0.5, 2.0) # quanto o gosto pode mudar a chance de um ato +FALSO_ALARME = 180 # s que esperar para ver se acordar pelo sensor valeu a pena +PRESENCA_FICOU = 0.3 # fração da espera com alguém por perto para o acordar ter valido +MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotina +ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3 +AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo +MAX_LICOES = 30 + +NOMES = { + 'alegre': 'saltitar', 'musica': 'cantarolar', 'feliz': 'sorrir', 'piscadinha': 'piscadinha', + 'coracao': 'coraçãozinho', 'rir': 'dar risada', 'comemorar': 'comemorar', 'olhar_esq': 'olhar em volta', + 'olhar_dir': 'olhar em volta', 'piscar2': 'piscar devagar', 'pensar': 'ficar pensativo', 'timido': 'ficar tímido', + 'espiar': 'espiar', 'bocejar': 'bocejar', 'confuso': 'ficar confuso', 'triste': 'ficar tristinho', + 'olhar_baixo': 'olhar para baixo', 'acenar': 'acenar', 'surpreso': 'fazer cara de "oi!"', + 'esconder': 'esconder o rosto', 'bravo': 'ficar emburrado', 'tonto': 'ficar tonto', + 'ler': 'ler', 'cafe': 'tomar café', 'meditar': 'meditar', 'telescopio': 'olhar as estrelas', 'pescar': 'pescar', + 'computador': 'mexer no computador', 'regar': 'regar a plantinha', 'bola': 'jogar bola', 'malhar': 'malhar', + 'corda': 'pular corda', 'skate': 'andar de skate', 'dancar': 'dançar', 'pipa': 'soltar pipa', 'pintar': 'pintar', + 'violao': 'tocar violão', 'cantar': 'cantar', 'cozinhar': 'cozinhar', 'bolhas': 'fazer bolhas de sabão', + 'videogame': 'jogar videogame', 'dormir': 'cochilar', +} + + +def _dia(agora): + return time.strftime('%Y-%m-%d', time.localtime(agora)) + + +def _hora(agora): + return time.localtime(agora).tm_hour + + +def _faixas(horas): + """[18, 19, 20, 22] -> [(18, 21), (22, 23)]: horas seguidas viram uma faixa (fim aberto).""" + faixas = [] + for h in sorted(horas): + if faixas and faixas[-1][1] == h: + faixas[-1] = (faixas[-1][0], h + 1) + else: + faixas.append((h, h + 1)) + return faixas + + +def _texto_faixas(faixas): + partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas] + return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1] + + +class Aprendizado: + def __init__(self, caminho, nomes=None): + self.caminho = Path(caminho) + self.lock = threading.RLock() + self.publicar = lambda tipo, dados: None + self.anotar = lambda tipo, texto, agora=None: None # diário do modo vida + self.nomes = nomes or (lambda ident: None) # nome das atividades criadas por IA + self.config = dict(PADRAO) + self.itens = {} # nome -> {'t': tentativas, 's': agradou} + self.rotina = [None] * 24 # chance de ter gente por perto em cada hora + self.falsos = [0.0] * 24 # acordou à toa nessa hora (média dos dias) + self.dias = 0 + self.licoes = [] + self.rotina_avisada = [] + self.hoje = self._dia_vazio(_dia(time.time())) + self.pendentes = [] # atos esperando resposta: (nome, horario) + self.acordou_em = None # acordou pelo sensor: esperando para ver se valeu + self.presenca_depois = [0, 0] # (com gente, total) nas observações depois de acordar + self.minuto = None + self.salvo_em = 0.0 + try: + salvo = json.loads(self.caminho.read_text(encoding='utf-8')) + self.config.update(self.validar(salvo.get('config', {}))) + self.itens = {k: dict(t=float(v['t']), s=float(v['s'])) for k, v in salvo.get('itens', {}).items()} + if len(salvo.get('rotina', [])) == 24: + self.rotina = [None if v is None else float(v) for v in salvo['rotina']] + if len(salvo.get('falsos', [])) == 24: + self.falsos = [float(v) for v in salvo['falsos']] + self.dias = int(salvo.get('dias', 0)) + self.licoes = [l for l in salvo.get('licoes', []) if isinstance(l, dict)][:MAX_LICOES] + self.rotina_avisada = list(salvo.get('rotina_avisada', [])) + if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'): + self.hoje.update(salvo['hoje']) + except (OSError, ValueError, TypeError, KeyError, AttributeError): + pass + + @staticmethod + def _dia_vazio(dia): + return dict(dia=dia, observado=[0] * 24, presenca=[0] * 24, falsos=[0] * 24, acordou=[0] * 24, + atos=0, respostas={}, contagem={}) + + @staticmethod + def validar(dados): + if not isinstance(dados, dict): + raise ValueError('Esperava um objeto') + for chave, valor in dados.items(): + if chave not in PADRAO: + raise ValueError('Ajuste desconhecido: ' + chave) + if not isinstance(valor, bool): + raise ValueError(chave + ' precisa ser verdadeiro ou falso') + return dict(dados) + + def configurar(self, dados): + limpo = self.validar(dados) + with self.lock: + self.config.update(limpo) + self.salvar() + self.avisar() + return self.status() + + def esquecer(self): + with self.lock: + self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24 + self.dias, self.licoes, self.rotina_avisada = 0, [], [] + self.hoje = self._dia_vazio(_dia(time.time())) + self.pendentes, self.acordou_em = [], None + self.anotar('aprendizado', 'Esqueceu tudo o que tinha aprendido e começou do zero') + self.salvar() + self.avisar() + return self.status() + + def salvar(self): + with self.lock: + dados = dict(config=self.config, itens=self.itens, rotina=self.rotina, falsos=self.falsos, dias=self.dias, + licoes=self.licoes, rotina_avisada=self.rotina_avisada, hoje=self.hoje) + self.salvo_em = time.time() + try: + gravar_atomico(self.caminho, dados) + except OSError: + pass + + # ------------------------------------------------------------------ o que acontece no dia + def ligado(self): + return self.config['ligado'] + + def observar(self, agora, perto): + """A cada tick do modo vida: conta os minutos com gente por perto e fecha o dia à meia-noite.""" + if not self.ligado(): + return + self.virar_dia(agora) + with self.lock: + self.pendentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA] + if self.acordou_em is not None: + self.presenca_depois[0] += 1 if perto else 0 + self.presenca_depois[1] += 1 + if agora - self.acordou_em >= FALSO_ALARME: + com_gente, total = self.presenca_depois + if total and com_gente / total < PRESENCA_FICOU: + self.hoje['falsos'][_hora(self.acordou_em)] += 1 + self.acordou_em = None + minuto = int(agora // 60) + if minuto == self.minuto: + return + self.minuto = minuto + h = _hora(agora) + self.hoje['observado'][h] += 1 + if perto: + self.hoje['presenca'][h] += 1 + if agora - self.salvo_em > 300: + self.salvar() + + def ato(self, nome, agora, perto): + """Ele fez um gesto ou uma atividade. Só conta com plateia: sozinho ninguém pode gostar.""" + if not self.ligado() or not perto or not nome: + return + with self.lock: + self.pendentes.append((nome, agora)) + self.hoje['atos'] += 1 + item = self.itens.setdefault(nome, dict(t=0.0, s=0.0)) + item['t'] += 1 + c = self.hoje['contagem'].setdefault(nome, [0, 0.0]) + c[0] += 1 + + def resposta(self, tipo, agora): + """Carinho, brincadeira, conversa...: o último ato de pouco antes agradou.""" + if not self.ligado() or tipo not in PESO_RESPOSTA: + return + with self.lock: + self.hoje['respostas'][tipo] = self.hoje['respostas'].get(tipo, 0) + 1 + if self.acordou_em is not None: # alguém quis ele acordado: valeu a pena + self.acordou_em = None + recentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA] + if not recentes: + return + nome, _ = recentes[-1] + self.pendentes = [p for p in self.pendentes if p[0] != nome] # cada ato agrada uma vez + item = self.itens.setdefault(nome, dict(t=1.0, s=0.0)) + item['s'] = min(item['t'], item['s'] + PESO_RESPOSTA[tipo]) + c = self.hoje['contagem'].setdefault(nome, [1, 0.0]) + c[1] += PESO_RESPOSTA[tipo] + + def acordou_pelo_sensor(self, agora): + if not self.ligado(): + return + with self.lock: + self.hoje['acordou'][_hora(agora)] += 1 + self.acordou_em = agora + self.presenca_depois = [0, 0] + + # ------------------------------------------------------------------ o que muda no comportamento + def peso(self, nome): + """Multiplica a chance de um ato: acima de 1 se costuma agradar, abaixo se é ignorado.""" + if not self.ligado(): + return 1.0 + with self.lock: + item = self.itens.get(nome) + if not item or item['t'] < MIN_TENTATIVAS: + return 1.0 + total_t = sum(i['t'] for i in self.itens.values()) + total_s = sum(i['s'] for i in self.itens.values()) + media = (total_s + 1) / (total_t + 2) + taxa = (item['s'] + 1) / (item['t'] + 2) + return round(max(FAIXA[0], min(FAIXA[1], taxa / media)), 2) + + def extra_pir(self, agora): + """Disparos a mais do PIR para acordar nesta hora (acordou à toa aqui outras vezes).""" + return self._extra(self.falsos[_hora(agora)]) if self.ligado() else 0 + + @staticmethod + def _extra(falsos): + return 2 if falsos >= 1.5 else 1 if falsos >= 0.5 else 0 + + def chega_logo(self, agora): + """Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?""" + if not self.ligado(): + return False + t = time.localtime(agora) + prox, atual = self.rotina[(t.tm_hour + 1) % 24], self.rotina[t.tm_hour] + falta = 3600 - (t.tm_min * 60 + t.tm_sec) + return (prox is not None and prox >= ROTINA_CHEIA and (atual is None or atual < ROTINA_VAZIA) + and falta <= AVISO_CHEGADA) + + # ------------------------------------------------------------------ fechar o dia + def virar_dia(self, agora): + dia = _dia(agora) + if self.hoje['dia'] == dia: + return + with self.lock: + licoes = self.fechar_dia() + self.hoje = self._dia_vazio(dia) + self.pendentes, self.acordou_em = [], None + for texto in licoes: + self.anotar('aprendizado', texto, agora) + self.salvar() + self.avisar() + + def nome(self, ident): + if ident.startswith('criado:'): + try: + nome = self.nomes(int(ident[7:])) + except (ValueError, TypeError): + nome = None + return 'fazer ' + nome if nome else 'fazer uma atividade nova' + return NOMES.get(ident, ident) + + def fechar_dia(self): + """Junta o dia na memória e devolve as lições (frases curtas, em primeira pessoa).""" + hoje, licoes = self.hoje, [] + # rotina: média móvel da fração de minutos com gente em cada hora + for h in range(24): + if hoje['observado'][h] >= MIN_MINUTOS_HORA: + frac = hoje['presenca'][h] / hoje['observado'][h] + antes = self.rotina[h] + self.rotina[h] = round(frac if antes is None else antes + (frac - antes) * MEMORIA_ROTINA, 3) + self.falsos[h] = round(self.falsos[h] + (hoje['falsos'][h] - self.falsos[h]) * MEMORIA_ROTINA, 3) + observou = sum(hoje['observado']) >= 60 + if observou: + self.dias += 1 + # gosto: o que agradou e o que foi ignorado hoje + contagem = hoje['contagem'] + gostou = sorted(((s, n) for n, (t, s) in contagem.items() if s >= 1), reverse=True) + if gostou: + nomes = [self.nome(n) for _, n in gostou[:2]] + licoes.append('Aprendi que vocês gostam de me ver ' + ' e '.join(nomes) + '. Vou fazer mais!') + ignorado = sorted(((t, n) for n, (t, s) in contagem.items() if t >= 4 and s == 0), reverse=True) + if ignorado and sum(hoje['respostas'].values()): + licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0])) + if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()): + licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.') + # rotina: quando passa a ter um padrão (ou ele muda), conta + if self.dias >= 2: + cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA] + if cheias and cheias != self.rotina_avisada: + licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.' + % _texto_faixas(_faixas(cheias))) + self.rotina_avisada = cheias + # sono: acordou à toa várias vezes na mesma hora + piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True) + if piores: + n, h = piores[0] + licoes.append('Acordei à toa %d vezes por volta das %dh. Nessa hora vou precisar de mais movimento para acordar.' + % (n, h)) + # o gosto muda: o passado pesa menos a cada dia + for nome in list(self.itens): + item = self.itens[nome] + item['t'] = round(item['t'] * ESQUECE, 3) + item['s'] = round(item['s'] * ESQUECE, 3) + if item['t'] < 0.5: + del self.itens[nome] + for texto in licoes: + self.licoes.insert(0, dict(dia=hoje['dia'], texto=texto)) + del self.licoes[MAX_LICOES:] + return licoes + + # ------------------------------------------------------------------ painel + def status(self): + with self.lock: + gosto = [] + for nome, item in self.itens.items(): + if item['t'] >= MIN_TENTATIVAS: + gosto.append(dict(id=nome, nome=self.nome(nome), tentativas=round(item['t'], 1), + agradou=round(item['s'], 1), peso=self.peso(nome))) + gosto.sort(key=lambda g: -g['peso']) + hoje = self.hoje + return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina), + sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos], + gosto=gosto, licoes=self.licoes[:12], + hoje=dict(dia=hoje['dia'], atos=hoje['atos'], respostas=dict(hoje['respostas']), + acordou=sum(hoje['acordou']), a_toa=sum(hoje['falsos']))) + + def avisar(self): + self.publicar('aprendizado', self.status()) + + def rotas(self, h, metodo): + rota = h.path.split('?', 1)[0] + if not rota.startswith('/aprendizado/'): + return False + try: + if metodo == 'GET' and rota == '/aprendizado/status': + resultado = self.status() + elif metodo == 'POST' and rota == '/aprendizado/config': + resultado = self.configurar(ler_json(h, 1024)) + elif metodo == 'POST' and rota == '/aprendizado/esquecer': + resultado = self.esquecer() + else: + raise ValueError('Rota inválida') + h.enviar(json.dumps(resultado, ensure_ascii=False)) + except (ValueError, TypeError) as e: + h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400) + return True diff --git a/deploy.ps1 b/deploy.ps1 index b850d68..53b4c61 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). $arquivos = @( 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', - 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', + 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'aprendizado.py', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' ) diff --git a/kiosk-server.py b/kiosk-server.py index 8f3fdd1..c01b542 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -44,6 +44,7 @@ from historico import Historico from vida import Vida from criacao import Criacao from inspiracao import Inspiracao +from aprendizado import Aprendizado TRAVA = threading.Lock() @@ -77,7 +78,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} -PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/", +PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/", "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") @@ -263,7 +264,7 @@ class Handler(SimpleHTTPRequestHandler): def modulos(self): s = self.server - return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", + return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", "braco", "servos", "controle") if hasattr(s, nome)] def estado(self): @@ -291,6 +292,8 @@ class Handler(SimpleHTTPRequestHandler): dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca)) if hasattr(s, "vida"): dados["vida"] = s.vida.status() + if hasattr(s, "aprendizado"): + dados["aprendizado"] = s.aprendizado.status() return dados def do_GET(self): @@ -483,7 +486,7 @@ class Servidor(ThreadingHTTPServer): def ligar_eventos(servidor): - for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao"): + for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado"): if hasattr(servidor, nome): getattr(servidor, nome).publicar = EVENTOS.publicar @@ -553,6 +556,12 @@ def main(): vida.do_dia = lambda: inspiracao.hoje['ids'] vida.aprendi_pendente = inspiracao.pendente vida.contar_aprendi = lambda ident, agora=None: inspiracao.contar(ident, agora) + # Aprendizado: com o dia a dia (carinhos, conversas, presença, sensor) ele ajusta o que faz e quando + def nome_criada(ident): + return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None) + servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada) + aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar()) + vida.aprendizado = aprendizado # E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente" diff --git a/tests/test_aprendizado.py b/tests/test_aprendizado.py new file mode 100644 index 0000000..d09130e --- /dev/null +++ b/tests/test_aprendizado.py @@ -0,0 +1,236 @@ +"""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() diff --git a/vida.py b/vida.py index e5fb960..fe28345 100644 --- a/vida.py +++ b/vida.py @@ -112,6 +112,7 @@ 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 +GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos} def _limitar(v): @@ -147,6 +148,7 @@ class Vida: self.do_dia = lambda: [] # ids das ideias de hoje 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) agora = time.time() self.config = dict(PADRAO) @@ -319,6 +321,8 @@ class Vida: self.anotar('cuidado', CUIDADOS[tipo], agora) if tipo in CUIDADOS or tipo == 'conversa': self.interagiu_em = agora # alguém quis ele acordado (sensor não conta) + if self.aprendizado: + self.aprendizado.resposta(tipo, agora) if gesto and not self.bloqueado(): if self.dormindo(): self.acordar(agora, None) @@ -369,7 +373,10 @@ class Vida: else: lista = ATIVAS + CRIATIVAS * 2 + CALMAS opcoes = [a for a in lista if a != self.ultima_atividade] or lista - return self.rng.choice(opcoes) + pesos = [self.gosto(a) for a in opcoes] + if all(p == 1.0 for p in pesos): + return self.rng.choice(opcoes) + return self.rng.choices(opcoes, weights=pesos)[0] # as que agradam saem mais def comecar_atividade(self, agora, nome, segundos): if nome.startswith('criado:'): @@ -394,8 +401,12 @@ class Vida: agora = agora or time.time() self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora] precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1 + if self.aprendizado and self.config['ligado']: + precisa += self.aprendizado.extra_pir(agora) # nesta hora já acordou à toa outras vezes if len(self.pir_eventos) >= precisa: self.pir_eventos = [] + if self.aprendizado and self.config['ligado']: + self.aprendizado.acordou_pelo_sensor(agora) return True return False @@ -406,7 +417,12 @@ class Vida: return hora >= ini or hora < fim # ------------------------------------------------------------------ decidir o que fazer + def gosto(self, nome): + """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 escolher(self, opcoes): + opcoes = [(g, p * self.gosto(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: @@ -473,6 +489,11 @@ class Vida: if n['energia'] >= DESCANSADO and not self.madrugada(agora): self.acordar(agora, 'Acordou descansado') 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') + 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') @@ -529,11 +550,25 @@ class Vida: self.anotar('acao', texto, agora) return gesto + def aprender_ato(self, ato, agora): + """Gestos e atividades feitos com plateia entram no aprendizado (para ver se agradam).""" + if not self.aprendizado or not ato: + return + if ato.startswith('atividade '): + nome = ato[len('atividade '):] + elif ato in GESTOS_VIDA: + nome = ato + else: + return + self.aprendizado.ato(nome, agora, self.alguem_perto()) + def tick(self, agora=None): agora = agora or time.time() self.passar_tempo(agora) if not self.config['ligado']: return None + if self.aprendizado: + self.aprendizado.observar(agora, self.alguem_perto()) ato = None humor = self.humor() # acordaram ele de propósito (toque, painel) antes de descansar: o sono pesado acaba aqui @@ -554,6 +589,7 @@ class Vida: self.zanga_enviada = zanga if agora >= self.proximo_ato: ato = self.agir(agora) + self.aprender_ato(ato, 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(): diff --git a/web/src/lib/aprendizado.svelte.ts b/web/src/lib/aprendizado.svelte.ts new file mode 100644 index 0000000..2f6c949 --- /dev/null +++ b/web/src/lib/aprendizado.svelte.ts @@ -0,0 +1,25 @@ +/** Aprendizado com o dia a dia: ligar/desligar e esquecer. O estado chega pelo SSE 'aprendizado'. */ +import { postar } from './api'; +import { avisos } from './avisos.svelte'; +import { estado } from './estado.svelte'; +import type { Aprendizado } from './tipos'; + +export async function configurarAprendizado(ligado: boolean) { + const antes = estado.aprendizado; + if (antes) estado.aprendizado = { ...antes, config: { ...antes.config, ligado } }; + try { + estado.aprendizado = await postar('/aprendizado/config', { ligado }); + } catch (e) { + estado.aprendizado = antes; + avisos.erro(e); + } +} + +export async function esquecerAprendizado() { + try { + estado.aprendizado = await postar('/aprendizado/esquecer', {}); + avisos.ok('Ele esqueceu o que tinha aprendido e começa do zero'); + } catch (e) { + avisos.erro(e); + } +} diff --git a/web/src/lib/componentes/AprendizadoCartao.svelte b/web/src/lib/componentes/AprendizadoCartao.svelte new file mode 100644 index 0000000..c36f64f --- /dev/null +++ b/web/src/lib/componentes/AprendizadoCartao.svelte @@ -0,0 +1,283 @@ + + + + {#if !a} +

Carregando…

+ {:else} + + {#snippet acessorio()} + configurarAprendizado(l)} /> + {/snippet} + + + {#if a.config.ligado} +
+

Rotina da casa {temRotina ? resumoRotina : 'aparece depois da primeira meia-noite'}

+ + + {#if a.sono.some((n) => n > 0)} +

Horas em que acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.

+ {/if} +
+ + {#if faz_mais.length || faz_menos.length} +
+

Do que vocês gostam

+
+
    +
  • Faz mais
  • + {#each faz_mais as g (g.id)} +
  • {g.nome}{vezes(g.peso)}
  • + {:else} +
  • Nada se destacou ainda
  • + {/each} +
+
    +
  • Faz menos
  • + {#each faz_menos as g (g.id)} +
  • {g.nome}{vezes(g.peso)}
  • + {:else} +
  • Nada foi ignorado
  • + {/each} +
+
+
+ {/if} + +
+

Lições

+ {#if a.licoes.length} +
    + {#each a.licoes.slice(0, 5) as l (l.dia + l.texto)} +
  1. {l.texto}
  2. + {/each} +
+ {:else} +

A primeira lição chega à meia-noite. Faça carinho ou converse logo depois de um gesto que você gostou.

+ {/if} +
+ {/if} + +
+ {#if confirmar} + Apagar rotina, gostos e lições? + + + {:else} + + {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/componentes/VidaCartao.svelte b/web/src/lib/componentes/VidaCartao.svelte index 2f2d7fb..4cd282b 100644 --- a/web/src/lib/componentes/VidaCartao.svelte +++ b/web/src/lib/componentes/VidaCartao.svelte @@ -296,6 +296,9 @@ .diario li.emoji { color: #af52de; } + .diario li.aprendizado { + color: #30b0c7; + } .diario li.cuidado { color: var(--texto); } diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts index 1f7a328..3ca7102 100644 --- a/web/src/lib/estado.svelte.ts +++ b/web/src/lib/estado.svelte.ts @@ -4,7 +4,7 @@ */ import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { avisos } from './avisos.svelte'; -import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos'; +import type { Aprendizado, AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; @@ -34,6 +34,7 @@ class Estado { treinamento = $state({ ocupado: false, erro: '' }); api = $state(null); vida = $state(null); + aprendizado = $state(null); feed = $state([]); /** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */ pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null); @@ -110,6 +111,7 @@ function aplicarFoto(f: EstadoServidor) { if (f.treinamento) estado.treinamento = f.treinamento; if (f.api) estado.api = f.api; if (f.vida) estado.vida = f.vida; + if (f.aprendizado) estado.aprendizado = f.aprendizado; if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca }; } @@ -158,6 +160,7 @@ const tratadores: Record void> = { if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto); estado.vida = d; }, + aprendizado: (d: Aprendizado) => (estado.aprendizado = d), pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }), historico: (d) => { const n = d?.novo; @@ -185,7 +188,7 @@ function abrirEventos() { if (estado.conexao !== 'token') estado.conexao = 'caiu'; }; for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', - 'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao', 'inspiracao']) { + 'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) { fonte.addEventListener(tipo, (e) => { const dados = JSON.parse((e as MessageEvent).data); tratadores[tipo]?.(dados); diff --git a/web/src/lib/guia.ts b/web/src/lib/guia.ts index a6e77be..083fe02 100644 --- a/web/src/lib/guia.ts +++ b/web/src/lib/guia.ts @@ -55,6 +55,21 @@ export const TOPICOS: Record = { ], dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.', }, + aprendizado: { + titulo: 'O que ele aprendeu', + emoji: '🎓', + onde: 'Ao vivo › O que ele aprendeu (com o modo vida ligado)', + resumo: + 'Ele observa o próprio dia e melhora aos poucos, sem IA e sem internet: conta o que acontece em casa e ajusta o modo vida. À meia-noite fecha o dia, escreve as lições no diário e o que ficou para trás passa a pesar menos, porque o gosto das pessoas muda.', + itens: [ + { emoji: '❤️', nome: 'Do que vocês gostam', texto: 'Cada gesto ou atividade que ele faz com alguém por perto é uma tentativa. Carinho, brincadeira ou conversa até 1 minuto depois contam como "gostaram" (cutucada conta meio). Os que agradam passam a sair até 2 vezes mais; os ignorados, até a metade. Precisa de pelo menos 3 tentativas para ele formar opinião.' }, + { emoji: '🏠', nome: 'Rotina da casa', texto: 'Em que horas costuma ter gente por perto (pelo PIR e pela câmera). As 24 barras mostram cada hora. Quando a próxima hora costuma ser movimentada e a atual vazia, ele acorda do cochilo 15 minutos antes para estar de pé quando você chegar. Sono pesado e madrugada continuam respeitados.' }, + { emoji: '☾', nome: 'Acordar à toa', texto: 'Se o PIR acorda o rosto e ninguém fica nem interage em 3 minutos, foi à toa. Quando isso se repete na mesma hora em dias diferentes, ali ele passa a precisar de 1 ou 2 disparos a mais para acordar.' }, + { emoji: '📖', nome: 'Lições', texto: 'O que ele concluiu em cada dia, em primeira pessoa. Também aparecem no diário do cartão Vida.' }, + { emoji: '🧽', nome: 'Esquecer', texto: 'Apaga rotina, gostos e lições, e ele começa do zero. Desligar o aprendizado só para de usar o que aprendeu, sem apagar.' }, + ], + dica: 'Para ensinar rápido: quando ele fizer algo que você gostou, toque na tela do celular (vale como carinho) ou fale com ele logo em seguida.', + }, cuidados: { titulo: 'Cuidar do Carinha', emoji: '❤️', @@ -334,7 +349,7 @@ export const TOPICOS: Record = { /** Ordem do tutorial (página Guia). */ export const ORDEM_GUIA = [ - 'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia', + 'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia', 'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade', 'reacoes', 'conversa', 'movimento', 'atalhos', ]; diff --git a/web/src/lib/tipos.ts b/web/src/lib/tipos.ts index 7be1c45..c0e6ff5 100644 --- a/web/src/lib/tipos.ts +++ b/web/src/lib/tipos.ts @@ -83,6 +83,7 @@ export interface EstadoServidor { api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; vida?: Vida; + aprendizado?: Aprendizado; } /** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */ @@ -103,5 +104,20 @@ export interface Vida { sono_profundo?: boolean; /** '' quando pode inventar figurinha agora; senão, o motivo. */ pode_criar: string; - diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade'; texto: string }[]; + diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[]; +} + +/** O que ele aprendeu com o dia a dia (aprendizado.py). */ +export interface Aprendizado { + config: { ligado: boolean }; + /** Dias inteiros observados. */ + dias: number; + /** 24 horas: chance de ter gente por perto (null = ainda sem dados nessa hora). */ + rotina: (number | null)[]; + /** 24 horas: disparos a mais do PIR para acordar (0 a 2), por ter acordado à toa ali. */ + sono: number[]; + /** Atos com opinião formada, do mais querido ao mais ignorado. peso multiplica a chance (0,5 a 2). */ + gosto: { id: string; nome: string; tentativas: number; agradou: number; peso: number }[]; + licoes: { dia: string; texto: string }[]; + hoje: { dia: string; atos: number; respostas: Record; acordou: number; a_toa: number }; } diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte index ff03151..69c600e 100644 --- a/web/src/telas/AoVivo.svelte +++ b/web/src/telas/AoVivo.svelte @@ -15,6 +15,7 @@ import PresencaPir from '../lib/componentes/PresencaPir.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import VidaCartao from '../lib/componentes/VidaCartao.svelte'; + import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte'; import { estado } from '../lib/estado.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte'; import { VideoAoVivo } from '../lib/video.svelte'; @@ -126,6 +127,7 @@ const pirAtivo = $derived(
+ {#if estado.vida?.config.ligado}{/if}