diff --git a/aprendizado.py b/aprendizado.py index de10780..009848d 100644 --- a/aprendizado.py +++ b/aprendizado.py @@ -24,7 +24,7 @@ from pathlib import Path from comum import gravar_atomico, ler_json -PADRAO = dict(ligado=True, analise_ia=True, ia='gemini') +PADRAO = dict(ligado=True, analise_ia=True, ia='gemini', ajustar_sono=True) IAS = ('gemini', 'local', 's10') 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} @@ -38,6 +38,9 @@ MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotin 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 +# Horário de dormir pela rotina: anda 1 h por noite, no máximo 3 h depois / 2 h antes do que a pessoa +# escolheu, com pelo menos 5 dias observados, e a madrugada nunca fica menor que 5 h. +SONO_MIN_DIAS, SONO_ATRASO_MAX, SONO_ADIANTO_MAX, SONO_MIN_HORAS = 5, 3, 2, 5 MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo'] @@ -87,11 +90,17 @@ def _faixas(horas): faixas[-1] = (faixas[-1][0], h + 1) else: faixas.append((h, h + 1)) + if len(faixas) > 1 and faixas[0][0] == 0 and faixas[-1][1] == 24: # passa da meia-noite: uma faixa só + faixas = [(faixas[-1][0], faixas[0][1] + 24)] + faixas[1:-1] return faixas +def _na_noite(h, ini, fim): + return ini <= h < fim if ini < fim else (h >= ini or h < fim) + + def _texto_faixas(faixas): - partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas] + partes = ['%s %dh %s %dh' % ('da' if a == 1 else 'das', a, 'à' if b % 24 == 1 else 'às', b % 24) for a, b in faixas] return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1] @@ -126,6 +135,11 @@ class Aprendizado: self.pode_ia = lambda provedor: False self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro self.sono_avisado = 0.0 + # horário da madrugada (Ajustes do celular): o main liga; None = madrugada silenciosa desligada + self.noite = lambda: None + self.mudar_noite = lambda ini, fim: None + self.sono_base = None # (início, fim) que a pessoa escolheu + self.sono_auto = None # (início, fim) que ele mesmo pôs por último try: salvo = json.loads(self.caminho.read_text(encoding='utf-8')) self.config.update(self.validar(salvo.get('config', {}))) @@ -151,6 +165,9 @@ class Aprendizado: self.analise['em'] = salvo['analise_em'] if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'): self.hoje.update(salvo['hoje']) + for chave in ('sono_base', 'sono_auto'): + if isinstance(salvo.get(chave), list) and len(salvo[chave]) == 2: + setattr(self, chave, tuple(int(v) % 24 for v in salvo[chave])) except (OSError, ValueError, TypeError, KeyError, AttributeError): pass @@ -200,7 +217,8 @@ class Aprendizado: licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia, hoje=self.hoje, semana=self.semana, semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao, - analise_em=self.analise['em'], decisoes=self.decisoes) + analise_em=self.analise['em'], decisoes=self.decisoes, sono_base=self.sono_base, + sono_auto=self.sono_auto) self.salvo_em = time.time() try: gravar_atomico(self.caminho, dados) @@ -436,14 +454,27 @@ class Aprendizado: 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 + # sono: a rotina mexe no horário de dormir (antes de contar a rotina, que já usa o novo horário) + licoes += self.ajustar_noite(hoje['dia']) + # rotina: quando passa a ter um padrão (ou ele muda), conta, separando o que cai na madrugada if self.dias >= 2 and not self.plano: cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA] mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or ( cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7) if cheias and mudou: - licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.' - % _texto_faixas(_faixas(cheias))) + noite = self.noite() + dormindo = [h for h in cheias if noite and _na_noite(h, *noite)] + acordado = [h for h in cheias if h not in dormindo] + quieto = 'é a minha hora de dormir: fico quietinho, a não ser que alguém me chame.' + if acordado: + texto = ('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.' + % _texto_faixas(_faixas(acordado))) + if dormindo: + resto = _texto_faixas(_faixas(dormindo)) + ' ' + quieto + texto += ' ' + resto[0].upper() + resto[1:] + else: + texto = 'Percebi gente por perto %s, mas %s' % (_texto_faixas(_faixas(dormindo)), quieto) + licoes.append(texto) self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias # 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) @@ -463,6 +494,62 @@ class Aprendizado: del self.licoes[MAX_LICOES:] return licoes + def ajustar_noite(self, dia): + """Muda o horário da madrugada aos poucos pela rotina da casa. Devolve as lições.""" + noite = self.noite() + if not noite or not self.config['ajustar_sono'] or not self.ligado(): + return [] + if self.sono_base is None or noite != self.sono_auto: + self.sono_base = noite # a pessoa escolheu (ou mudou) o horário: vira a base + if self.dias < SONO_MIN_DIAS: + return [] + ini, fim = noite + base_ini, base_fim = self.sono_base + + def cheia(h): + v = self.rotina[h % 24] + return v is not None and v >= ROTINA_CHEIA + + def vazia(h): + v = self.rotina[h % 24] + return v is not None and v <= ROTINA_VAZIA + + atraso, adianto = (ini - base_ini) % 24, (base_fim - fim) % 24 + novo_ini, novo_fim = ini, fim + if cheia(ini) and atraso < SONO_ATRASO_MAX: + novo_ini = (ini + 1) % 24 # a primeira hora da madrugada costuma ter gente + elif 0 < atraso <= SONO_ATRASO_MAX and vazia(ini - 1): + novo_ini = (ini - 1) % 24 # a casa voltou a dormir cedo: volta para o de antes + if cheia(fim - 1) and adianto < SONO_ADIANTO_MAX: + novo_fim = (fim - 1) % 24 # a casa acorda antes + elif 0 < adianto <= SONO_ADIANTO_MAX and vazia(fim): + novo_fim = (fim + 1) % 24 + if (novo_ini, novo_fim) == tuple(noite) or (novo_fim - novo_ini) % 24 < SONO_MIN_HORAS: + return [] + self.mudar_noite(novo_ini, novo_fim) + self.sono_auto = (novo_ini, novo_fim) + licoes, motivos = [], [] + if novo_ini != ini: + if novo_ini == (ini + 1) % 24: + licoes.append('Vocês têm ficado acordados até mais tarde: agora eu vou dormir às %dh (antes era %dh).' + % (novo_ini, ini)) + motivos.append('às %dh costuma ter gente' % ini) + else: + licoes.append('A casa voltou a dormir mais cedo: volto a dormir às %dh.' % novo_ini) + motivos.append('às %dh a casa costuma estar vazia' % novo_ini) + if novo_fim != fim: + if novo_fim == (fim - 1) % 24: + licoes.append('Vocês têm acordado cedo: agora eu acordo às %dh (antes era %dh).' % (novo_fim, fim)) + motivos.append('às %dh costuma ter gente' % novo_fim) + else: + licoes.append('A casa voltou a acordar mais tarde: volto a acordar às %dh.' % novo_fim) + motivos.append('às %dh a casa costuma estar vazia' % fim) + fim_do_dia = time.mktime(time.strptime(dia + ' 23:59:59', '%Y-%m-%d %H:%M:%S')) + self.decisoes.insert(0, dict( + horario=fim_do_dia, tipo='sono', texto='Mudou o horário de dormir para %dh às %dh' % (novo_ini, novo_fim), + motivo='; '.join(motivos) + ' (o horário escolhido é %dh às %dh)' % tuple(self.sono_base))) + return licoes + # ------------------------------------------------------------------ semana: IA e plano @staticmethod def validar_plano(bruto, origem, criado=None): @@ -691,6 +778,7 @@ class Aprendizado: gosto.sort(key=lambda g: -g['peso']) hoje = self.hoje return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina), + sono_base=self.sono_base, sono_auto=self.sono_auto, sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos], gosto=gosto, licoes=self.licoes[:12], semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)] diff --git a/kiosk-server.py b/kiosk-server.py index af00cf1..8513331 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -565,6 +565,16 @@ def main(): # a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10) aprendizado.ia = servidor.criacao.chamar aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto')) + + # a rotina da casa pode mexer no horário da madrugada (o mesmo dos Ajustes do celular) + def noite_atual(): + c = servidor.controle.config + return (int(c.get('noiteInicio', 23)), int(c.get('noiteFim', 7))) if c.get('noiteSilenciosa', True) else None + + def mudar_noite(ini, fim): + servidor.controle.publicar('app_config', servidor.controle.atualizar({'noiteInicio': ini, 'noiteFim': fim})) + aprendizado.noite = noite_atual + aprendizado.mudar_noite = mudar_noite # 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 index f89f1f9..8866364 100644 --- a/tests/test_aprendizado.py +++ b/tests/test_aprendizado.py @@ -136,6 +136,78 @@ class AprendizadoTest(unittest.TestCase): self.assertEqual(outro.peso('rir'), 1.0) self.assertEqual(Aprendizado(self.caminho).itens, {}) + def ligar_noite(self, ini=23, fim=7): + estado = {'noite': (ini, fim)} + self.a.noite = lambda: estado['noite'] + self.a.mudar_noite = lambda i, f: estado.update(noite=(i, f)) + return estado + + def test_rotina_separa_o_que_cai_na_madrugada(self): + self.ligar_noite(23, 7) + self.a.dias = 3 + for h in list(range(10, 24)) + [0, 2]: + self.a.rotina[h] = 0.8 + self.a.config['ajustar_sono'] = False + licoes = self.a.fechar_dia() + texto = next(t for t in licoes if t.startswith('Percebi')) + self.assertIn('das 10h às 23h. Vou estar acordado nessa hora.', texto) + self.assertIn('Das 23h à 1h e das 2h às 3h é a minha hora de dormir', texto) + # só gente na madrugada: não promete ficar acordado + self.a.rotina = [None] * 24 + self.a.rotina[2], self.a.rotina_avisada = 0.9, [] + texto = next(t for t in self.a.fechar_dia() if t.startswith('Percebi')) + self.assertEqual(texto, 'Percebi gente por perto das 2h às 3h, mas é a minha hora de dormir: ' + 'fico quietinho, a não ser que alguém me chame.') + + def test_horario_de_dormir_acompanha_a_casa_aos_poucos_e_volta(self): + estado = self.ligar_noite(23, 7) + self.a.dias = 2 + self.a.rotina[23], self.a.rotina[0], self.a.rotina[1] = 0.8, 0.7, 0.7 + self.assertEqual(self.a.ajustar_noite('2026-10-06'), []) # poucos dias: ainda não mexe + self.a.dias = 6 + licoes = self.a.ajustar_noite('2026-10-06') + self.assertEqual(estado['noite'], (0, 7)) # 1 h por noite + self.assertIn('agora eu vou dormir às 0h (antes era 23h)', licoes[0]) + self.assertEqual(self.a.decisoes[0]['tipo'], 'sono') + self.a.ajustar_noite('2026-10-07') + self.a.ajustar_noite('2026-10-08') + self.assertEqual(estado['noite'], (2, 7)) # 1h tem gente, 2h já não conta + self.a.rotina[2] = 0.9 + self.a.ajustar_noite('2026-10-09') + self.assertEqual(estado['noite'], (2, 7)) # no máximo 3 h depois do escolhido + # a casa voltou a dormir cedo: volta 1 h por noite até o horário escolhido + self.a.rotina[1] = self.a.rotina[0] = self.a.rotina[23] = 0.1 + self.assertIn('volto a dormir às 1h', self.a.ajustar_noite('2026-10-10')[0]) + self.a.ajustar_noite('2026-10-11') + self.a.ajustar_noite('2026-10-12') + self.assertEqual(estado['noite'], (23, 7)) + self.assertEqual(self.a.ajustar_noite('2026-10-13'), []) + + def test_acordar_mais_cedo_limites_e_horario_escolhido_a_mao(self): + estado = self.ligar_noite(23, 7) + self.a.dias = 6 + self.a.rotina[6], self.a.rotina[5] = 0.8, 0.8 + self.a.ajustar_noite('2026-10-06') + self.a.ajustar_noite('2026-10-07') + self.a.ajustar_noite('2026-10-08') + self.assertEqual(estado['noite'], (23, 5)) # no máximo 2 h antes + # a pessoa muda o horário à mão: vira a nova base e ele não desfaz + estado['noite'] = (22, 8) + self.a.rotina = [None] * 24 + self.assertEqual(self.a.ajustar_noite('2026-10-09'), []) + self.assertEqual(self.a.sono_base, (22, 8)) + # madrugada nunca fica menor que 5 h + estado['noite'] = (2, 7) + self.a.rotina[2], self.a.rotina[6] = 0.9, 0.9 + self.a.ajustar_noite('2026-10-10') + self.assertEqual(estado['noite'], (2, 7)) + # desligado ou sem madrugada: não mexe + self.a.config['ajustar_sono'] = False + self.assertEqual(self.a.ajustar_noite('2026-10-11'), []) + self.a.config['ajustar_sono'] = True + self.a.noite = lambda: None + self.assertEqual(self.a.ajustar_noite('2026-10-12'), []) + def test_validar(self): with self.assertRaises(ValueError): self.a.configurar(dict(ligado='sim')) diff --git a/web/src/lib/componentes/AprendizadoCartao.svelte b/web/src/lib/componentes/AprendizadoCartao.svelte index b14db86..5f63196 100644 --- a/web/src/lib/componentes/AprendizadoCartao.svelte +++ b/web/src/lib/componentes/AprendizadoCartao.svelte @@ -81,6 +81,14 @@ vista = 'plano'; } } + // horário de dormir: o que a pessoa escolheu e o que ele ajustou pela rotina da casa + const sonoTexto = $derived.by(() => { + if (!a) return ''; + if (!(a.config.ajustar_sono ?? true)) return 'Desligado: dorme sempre no horário dos Ajustes'; + const b = a.sono_base, x = a.sono_auto; + if (b && x && (b[0] !== x[0] || b[1] !== x[1])) return `Dorme das ${x[0]}h às ${x[1]}h pela rotina (você escolheu ${b[0]}h às ${b[1]}h)`; + return 'Com 5 dias de rotina, atrasa ou adianta até 3 h / 2 h, uma hora por noite'; + }); n > 0)}

☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.

{/if} +
+ configurarAprendizado({ ajustar_sono: l })} /> + {sonoTexto} +
diff --git a/web/src/lib/tipos.ts b/web/src/lib/tipos.ts index 8fcdb9a..24f0c1c 100644 --- a/web/src/lib/tipos.ts +++ b/web/src/lib/tipos.ts @@ -120,7 +120,9 @@ export interface PlanoSemana { } export interface Aprendizado { - config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10' }; + config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10'; ajustar_sono?: boolean }; + sono_base?: [number, number] | null; + sono_auto?: [number, number] | null; /** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */ semana: (number | null)[][]; historico_dias: number;