feat(vida): horário de dormir acompanha a rotina da casa e lição honesta

- A lição da rotina separa o que cai na madrugada: "das 10h às 23h vou
  estar acordado; das 23h à 1h é a minha hora de dormir, fico quietinho
  a não ser que alguém me chame" (antes prometia ficar acordado de
  madrugada, o que nunca acontecia).
- Com 5+ dias de rotina, na virada do dia ele mexe sozinho no horário
  da madrugada: atrasa o início se a primeira hora costuma ter gente,
  adianta o fim se a casa acorda cedo; 1 h por noite, no máximo 3 h
  depois / 2 h antes do que a pessoa escolheu, nunca menos de 5 h de
  madrugada, e volta quando as horas esvaziam. Mudança à mão vira a
  nova base. Fica no diário e no "Dia a dia"; liga/desliga no cartão.
- Faixas que passam da meia-noite viram uma só ("das 23h à 1h").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-11 02:47:26 -03:00
co-authored by Claude Opus 5.5
parent 7b0a05435f
commit a99aa897c4
5 changed files with 191 additions and 7 deletions
+94 -6
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from comum import gravar_atomico, ler_json 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') IAS = ('gemini', 'local', 's10')
JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele 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} 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 ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
MAX_LICOES = 30 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 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 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'] 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) faixas[-1] = (faixas[-1][0], h + 1)
else: else:
faixas.append((h, h + 1)) 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 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): 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] 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.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
self.sono_avisado = 0.0 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: try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8')) salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {}))) self.config.update(self.validar(salvo.get('config', {})))
@@ -151,6 +165,9 @@ class Aprendizado:
self.analise['em'] = salvo['analise_em'] self.analise['em'] = salvo['analise_em']
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'): if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
self.hoje.update(salvo['hoje']) 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): except (OSError, ValueError, TypeError, KeyError, AttributeError):
pass pass
@@ -200,7 +217,8 @@ class Aprendizado:
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia, licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
hoje=self.hoje, semana=self.semana, hoje=self.hoje, semana=self.semana,
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao, 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() self.salvo_em = time.time()
try: try:
gravar_atomico(self.caminho, dados) 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])) 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()): if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.') 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: 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] 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 ( 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) cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
if cheias and mudou: if cheias and mudou:
licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.' noite = self.noite()
% _texto_faixas(_faixas(cheias))) 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 self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias
# sono: acordou à toa várias vezes na mesma hora # 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) 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:] del self.licoes[MAX_LICOES:]
return 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 # ------------------------------------------------------------------ semana: IA e plano
@staticmethod @staticmethod
def validar_plano(bruto, origem, criado=None): def validar_plano(bruto, origem, criado=None):
@@ -691,6 +778,7 @@ class Aprendizado:
gosto.sort(key=lambda g: -g['peso']) gosto.sort(key=lambda g: -g['peso'])
hoje = self.hoje hoje = self.hoje
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina), 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], sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
gosto=gosto, licoes=self.licoes[:12], gosto=gosto, licoes=self.licoes[:12],
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)] semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
+10
View File
@@ -565,6 +565,16 @@ def main():
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10) # a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
aprendizado.ia = servidor.criacao.chamar aprendizado.ia = servidor.criacao.chamar
aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto')) 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 # 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" vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
+72
View File
@@ -136,6 +136,78 @@ class AprendizadoTest(unittest.TestCase):
self.assertEqual(outro.peso('rir'), 1.0) self.assertEqual(outro.peso('rir'), 1.0)
self.assertEqual(Aprendizado(self.caminho).itens, {}) 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): def test_validar(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim')) self.a.configurar(dict(ligado='sim'))
@@ -81,6 +81,14 @@
vista = 'plano'; 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';
});
</script> </script>
<Grupo <Grupo
@@ -148,6 +156,10 @@
{:else if a.sono.some((n) => n > 0)} {:else if a.sono.some((n) => n > 0)}
<p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p> <p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
{/if} {/if}
<div class="ajustes-ia">
<Interruptor rotulo="Ajustar o horário de dormir pela rotina" checked={a.config.ajustar_sono ?? true} onchange={(l) => configurarAprendizado({ ajustar_sono: l })} />
<span>{sonoTexto}</span>
</div>
</section> </section>
<section class="bloco" aria-labelledby="ap-rotina"> <section class="bloco" aria-labelledby="ap-rotina">
+3 -1
View File
@@ -120,7 +120,9 @@ export interface PlanoSemana {
} }
export interface Aprendizado { 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). */ /** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
semana: (number | null)[][]; semana: (number | null)[][];
historico_dias: number; historico_dias: number;