feat(vida): página "Dia a dia" com as decisões e o porquê

Cada decisão tomada com base no que ele aprendeu passa a ser guardada
com o motivo (aprendizado.decidir, até 800, persistidas):
- acordou do cochilo antes da hora: "às 18h de quarta costuma ter
  gente, pelo plano que você contou";
- cochilo com a casa vazia, com a duração e o porquê;
- sensor segurado numa hora em que acordou à toa (no máximo 1 nota a
  cada 10 min);
- gesto escolhido porque costuma agradar (chance ≥ ×1,3);
- fechamento do dia: horas com gente, carinhos e quanto o dia conferiu
  com o plano;
- análise da IA (adotada ou sugestão, com a porcentagem) e rotina
  contada por você.

esperado_e_fonte diz de onde veio a expectativa (plano seu, plano da
IA, o que viu naquele dia da semana ou média). GET /aprendizado/dia
devolve um dia: medido × plano por hora, conferência do dia, resumo e
decisões.

Painel: tela "Dia a dia" (#/dia-a-dia) com navegação entre dias, os
números do dia, as faixas "Viu" × "Esperava" e a linha do tempo das
decisões; atalho no cartão "O que ele aprendeu", no Ctrl+K e no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 17:34:30 -03:00
co-authored by Claude Opus 5.5
parent 935bf8d586
commit b4cf13df11
10 changed files with 553 additions and 13 deletions
+118 -7
View File
@@ -38,6 +38,8 @@ 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
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'] DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
CURTO = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom'] CURTO = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom']
MEMORIA_SEMANA = 0.5 # cada dia da semana aparece 1 vez por semana: aprende mais rápido MEMORIA_SEMANA = 0.5 # cada dia da semana aparece 1 vez por semana: aprende mais rápido
@@ -122,6 +124,8 @@ class Aprendizado:
self.analise = dict(ocupado=False, erro='', em=None, tipo='') self.analise = dict(ocupado=False, erro='', em=None, tipo='')
self.ia = None # (provedor, prompt, schema) -> dict (o main liga) self.ia = None # (provedor, prompt, schema) -> dict (o main liga)
self.pode_ia = lambda provedor: False self.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
self.sono_avisado = 0.0
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', {})))
@@ -138,6 +142,7 @@ class Aprendizado:
self.semana = [[None if v is None else float(v) for v in linha] for linha in salvo['semana']] self.semana = [[None if v is None else float(v) for v in linha] for linha in salvo['semana']]
self.semana_n = [[int(v) for v in linha] for linha in salvo['semana_n']] self.semana_n = [[int(v) for v in linha] for linha in salvo['semana_n']]
self.historico = [d for d in salvo.get('historico', []) if isinstance(d, dict)][-MAX_HISTORICO:] self.historico = [d for d in salvo.get('historico', []) if isinstance(d, dict)][-MAX_HISTORICO:]
self.decisoes = [d for d in salvo.get('decisoes', []) if isinstance(d, dict)][:MAX_DECISOES]
if salvo.get('plano'): if salvo.get('plano'):
self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado')) self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado'))
if salvo.get('sugestao'): if salvo.get('sugestao'):
@@ -180,7 +185,7 @@ class Aprendizado:
with self.lock: with self.lock:
self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24 self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24
self.semana, self.semana_n = [[None] * 24 for _ in range(7)], [[0] * 24 for _ in range(7)] self.semana, self.semana_n = [[None] * 24 for _ in range(7)], [[0] * 24 for _ in range(7)]
self.historico, self.plano, self.sugestao = [], None, None self.historico, self.plano, self.sugestao, self.decisoes = [], None, None, []
self.dias, self.licoes, self.rotina_avisada, self.rotina_avisada_dia = 0, [], [], 0 self.dias, self.licoes, self.rotina_avisada, self.rotina_avisada_dia = 0, [], [], 0
self.hoje = self._dia_vazio(_dia(time.time())) self.hoje = self._dia_vazio(_dia(time.time()))
self.pendentes, self.acordou_em = [], None self.pendentes, self.acordou_em = [], None
@@ -195,7 +200,7 @@ 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']) analise_em=self.analise['em'], decisoes=self.decisoes)
self.salvo_em = time.time() self.salvo_em = time.time()
try: try:
gravar_atomico(self.caminho, dados) gravar_atomico(self.caminho, dados)
@@ -296,16 +301,58 @@ class Aprendizado:
def esperado(self, agora): def esperado(self, agora):
"""'casa', 'fora', 'dormindo' ou None nesta hora: pelo plano da semana, senão pelo que mediu """'casa', 'fora', 'dormindo' ou None nesta hora: pelo plano da semana, senão pelo que mediu
naquele dia da semana, senão pela média de todos os dias.""" naquele dia da semana, senão pela média de todos os dias."""
return self.esperado_e_fonte(agora)[0]
def esperado_e_fonte(self, agora):
t = time.localtime(agora) t = time.localtime(agora)
if self.plano: if self.plano:
fonte = 'pelo plano que você contou' if self.plano['origem'] == 'voce' else 'pelo plano da IA'
for p in self.plano['dias'][t.tm_wday]: for p in self.plano['dias'][t.tm_wday]:
if p['ini'] <= t.tm_hour < p['fim']: if p['ini'] <= t.tm_hour < p['fim']:
return p['tipo'] return p['tipo'], fonte
return None return None, fonte
v = self.semana[t.tm_wday][t.tm_hour] if self.semana_n[t.tm_wday][t.tm_hour] >= 2 else self.rotina[t.tm_hour] if self.semana_n[t.tm_wday][t.tm_hour] >= 2:
v, fonte = self.semana[t.tm_wday][t.tm_hour], 'pelo que vi nas %ss' % DIAS_SEMANA[t.tm_wday].replace('-feira', '')
if t.tm_wday >= 5:
fonte = 'pelo que vi nos %ss' % DIAS_SEMANA[t.tm_wday]
else:
v, fonte = self.rotina[t.tm_hour], 'pela média de todos os dias'
if v is None: if v is None:
return None return None, fonte
return 'casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None return ('casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None), fonte
# ------------------------------------------------------------------ decisões (com o porquê)
def decidir(self, tipo, texto, motivo, agora=None):
"""Guarda uma decisão tomada com base no que aprendeu, para o painel mostrar dia a dia."""
with self.lock:
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
del self.decisoes[MAX_DECISOES:]
def porque_chegada(self, agora):
t = time.localtime(agora + 3600)
_, fonte = self.esperado_e_fonte(agora + 3600)
return 'às %dh de %s costuma ter gente, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def porque_vazia(self, agora):
t = time.localtime(agora)
_, fonte = self.esperado_e_fonte(agora)
return 'às %dh de %s a casa costuma ficar vazia, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def segurou_sono(self, agora, precisa, extra):
"""O PIR viu algo, mas nesta hora ele pede mais movimento: anota (sem repetir toda hora)."""
if agora - self.sono_avisado < AVISO_SONO:
return
self.sono_avisado = agora
self.decidir('sono', 'Não acordou com o sensor',
'por volta das %dh já acordou à toa outras vezes: pede %d %s a mais'
% (_hora(agora), extra, 'movimento' if extra == 1 else 'movimentos'), agora)
def escolheu(self, nome, agora=None):
"""Escolheu um ato que costuma agradar: anota quando o gosto pesou de verdade."""
peso = self.peso(nome)
if peso >= 1.3:
self.decidir('gosto', 'Escolheu ' + self.nome(nome),
'costuma agradar: chance ×%s' % ('%.1f' % peso).replace('.', ','), agora)
def chega_logo(self, agora): def chega_logo(self, agora):
"""Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?""" """Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?"""
@@ -369,6 +416,15 @@ class Aprendizado:
self.historico.append(dict(dia=hoje['dia'], sem=sem, presenca=fracs, atos=hoje['atos'], self.historico.append(dict(dia=hoje['dia'], sem=sem, presenca=fracs, atos=hoje['atos'],
respostas=sum(hoje['respostas'].values()), a_toa=sum(hoje['falsos']))) respostas=sum(hoje['respostas'].values()), a_toa=sum(hoje['falsos'])))
del self.historico[:-MAX_HISTORICO] del self.historico[:-MAX_HISTORICO]
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
nota = self.conferir_dia(sem, fracs)
fim_do_dia = time.mktime(time.strptime(hoje['dia'] + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
self.decisoes.insert(0, dict(
horario=fim_do_dia, tipo='dia', texto='Fechou o dia: atualizou o quadro de ' + DIAS_SEMANA[sem],
motivo='%d %s com gente, %d %s%s' % (
horas, 'hora' if horas == 1 else 'horas', sum(hoje['respostas'].values()),
'carinho ou conversa' if sum(hoje['respostas'].values()) == 1 else 'carinhos ou conversas',
'' if nota is None else '; o dia conferiu %d%% com o plano' % round(nota * 100))))
# gosto: o que agradou e o que foi ignorado hoje # gosto: o que agradou e o que foi ignorado hoje
contagem = hoje['contagem'] contagem = hoje['contagem']
gostou = sorted(((s, n) for n, (t, s) in contagem.items() if s >= 1), reverse=True) gostou = sorted(((s, n) for n, (t, s) in contagem.items() if s >= 1), reverse=True)
@@ -457,6 +513,47 @@ class Aprendizado:
certas += (v >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (v < ROTINA_CHEIA) certas += (v >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (v < ROTINA_CHEIA)
return round(certas / total, 2) if total >= MIN_HORAS_CONFERIR else None return round(certas / total, 2) if total >= MIN_HORAS_CONFERIR else None
def conferir_dia(self, sem, fracs, plano=None):
"""Quanto um dia medido bateu com o plano (0 a 1), ou None sem plano ou com poucas horas."""
plano = plano or self.plano
if not plano:
return None
certas = total = 0
for p in plano['dias'][sem]:
for h in range(p['ini'], p['fim']):
if fracs[h] is None:
continue
total += 1
certas += (fracs[h] >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (fracs[h] < ROTINA_CHEIA)
return round(certas / total, 2) if total >= 6 else None
def dia_a_dia(self, dia=None):
"""Um dia para o painel: o que viu, o que esperava pelo plano, quanto conferiu e as decisões."""
hoje = self.hoje['dia']
dia = dia or hoje
try:
d = date.fromisoformat(dia)
except (TypeError, ValueError):
raise ValueError('Dia inválido (use AAAA-MM-DD)') from None
sem = d.weekday()
with self.lock:
if dia == hoje:
h = self.hoje
fracs = [round(h['presenca'][i] / h['observado'][i], 2) if h['observado'][i] >= 5 else None for i in range(24)]
resumo = dict(atos=h['atos'], respostas=sum(h['respostas'].values()), a_toa=sum(h['falsos']))
else:
registro = next((x for x in self.historico if x['dia'] == dia), None)
fracs = registro['presenca'] if registro else [None] * 24
resumo = dict(atos=registro['atos'], respostas=registro['respostas'], a_toa=registro['a_toa']) if registro else None
decisoes = [x for x in self.decisoes if _dia(x['horario']) == dia]
dias = sorted({x['dia'] for x in self.historico} | {_dia(x['horario']) for x in self.decisoes} | {hoje})
plano = self.plano
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
return dict(dia=dia, sem=sem, nome_dia=DIAS_SEMANA[sem], hoje=dia == hoje, medido=fracs,
plano=plano['dias'][sem] if plano else None, origem_plano=plano['origem'] if plano else None,
confere=self.conferir_dia(sem, fracs), horas_com_gente=horas, resumo=resumo,
decisoes=decisoes, dias=dias[-60:])
def tabela_semana(self): def tabela_semana(self):
linhas = [] linhas = []
for sem in range(7): for sem in range(7):
@@ -534,6 +631,15 @@ class Aprendizado:
self.plano, self.sugestao = plano, None self.plano, self.sugestao = plano, None
else: else:
self.sugestao = plano self.sugestao = plano
if usar:
motivo = 'plano adotado: confere %d%% com o que mediu' % round(nota * 100)
elif nota is None:
motivo = 'ficou como sugestão: ainda poucos dados para conferir'
elif self.plano and self.plano['origem'] == 'voce' and nota >= CONCORDA:
motivo = 'ficou como sugestão: a rotina que você contou continua valendo'
else:
motivo = 'ficou como sugestão: confere só %d%% (precisa de %d%%)' % (round(nota * 100), round(CONCORDA * 100))
self.decidir('ia', 'IA analisou %d dias' % len(self.historico), motivo, agora)
if plano['padroes']: if plano['padroes']:
texto = plano['padroes'][0] if usar else 'Acho que ' + plano['padroes'][0][0].lower() + plano['padroes'][0][1:] texto = plano['padroes'][0] if usar else 'Acho que ' + plano['padroes'][0][0].lower() + plano['padroes'][0][1:]
self.anotar('aprendizado', texto, agora) self.anotar('aprendizado', texto, agora)
@@ -552,6 +658,7 @@ class Aprendizado:
self.plano, self.sugestao = plano, None self.plano, self.sugestao = plano, None
self.analise['em'] = agora or time.time() self.analise['em'] = agora or time.time()
self.anotar('aprendizado', 'Você me contou sua rotina. Vou seguir e conferir com o que eu vir.', agora) self.anotar('aprendizado', 'Você me contou sua rotina. Vou seguir e conferir com o que eu vir.', agora)
self.decidir('voce', 'Passou a seguir a rotina que você contou', '"%s"' % texto[:140], agora)
def chamar_ia_pronta(self): def chamar_ia_pronta(self):
if not self.ia or not self.pode_ia(self.config['ia']): if not self.ia or not self.pode_ia(self.config['ia']):
@@ -589,6 +696,7 @@ class Aprendizado:
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)]
for s, linha in enumerate(self.semana)], for s, linha in enumerate(self.semana)],
historico_dias=len(self.historico), historico_dias=len(self.historico),
decisoes_hoje=sum(1 for x in self.decisoes if _dia(x['horario']) == self.hoje['dia']),
plano=self.plano and dict(self.plano, confere=self.conferir(self.plano)), plano=self.plano and dict(self.plano, confere=self.conferir(self.plano)),
sugestao=self.sugestao and dict(self.sugestao, confere=self.conferir(self.sugestao)), sugestao=self.sugestao and dict(self.sugestao, confere=self.conferir(self.sugestao)),
analise=dict(self.analise, pode=bool(self.ia) and self.pode_ia(self.config['ia']), analise=dict(self.analise, pode=bool(self.ia) and self.pode_ia(self.config['ia']),
@@ -606,6 +714,9 @@ class Aprendizado:
try: try:
if metodo == 'GET' and rota == '/aprendizado/status': if metodo == 'GET' and rota == '/aprendizado/status':
resultado = self.status() resultado = self.status()
elif metodo == 'GET' and rota == '/aprendizado/dia':
from urllib.parse import parse_qs, urlsplit
resultado = self.dia_a_dia((parse_qs(urlsplit(h.path).query).get('dia') or [None])[0])
elif metodo == 'POST' and rota == '/aprendizado/config': elif metodo == 'POST' and rota == '/aprendizado/config':
resultado = self.configurar(ler_json(h, 1024)) resultado = self.configurar(ler_json(h, 1024))
elif metodo == 'POST' and rota == '/aprendizado/esquecer': elif metodo == 'POST' and rota == '/aprendizado/esquecer':
+58
View File
@@ -336,6 +336,64 @@ class SemanaTest(unittest.TestCase):
v.visto_em = em(14, 9) v.visto_em = em(14, 9)
atos = [v.agir(em(14, 10) + i * 120) for i in range(10)] atos = [v.agir(em(14, 10) + i * 120) for i in range(10)]
self.assertIn('atividade dormir', atos) self.assertIn('atividade dormir', atos)
cochilo = next(d for d in self.a.decisoes if d['tipo'] == 'cochilo')
self.assertEqual(cochilo['motivo'], 'às 10h de quarta a casa costuma ficar vazia, pelo plano que você contou')
class DiaADiaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.a.ia, self.a.pode_ia = (lambda provedor, prompt, schema: plano_certo()), (lambda provedor: True)
self.a.config['analise_ia'] = False
self.v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(2))
self.v.aprendizado = self.a
self.v.enfileirar = lambda ordem: None
def tearDown(self):
self.tmp.cleanup()
def test_cada_decisao_guarda_o_porque(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.decisoes[0]['tipo'], 'ia')
self.assertIn('plano adotado: confere', self.a.decisoes[0]['motivo'])
# acordar antes da chegada (quarta, 17h50)
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(14, 17, 50)), 'acordar')
self.assertEqual(self.a.decisoes[0]['motivo'], 'às 18h de quarta costuma ter gente, pelo plano da IA')
# sensor segurado numa hora em que acordou à toa
self.a.falsos[22] = 1.0
self.v.pir_acorda(em(14, 22))
self.v.pir_acorda(em(14, 22) + 30) # a 2ª nota em menos de 10 min não repete
notas = [d for d in self.a.decisoes if d['tipo'] == 'sono']
self.assertEqual(len(notas), 1)
self.assertIn('pede 1 movimento a mais', notas[0]['motivo'])
def test_fechar_o_dia_resume_e_confere_com_o_plano(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
semana_tipica(self.a, semanas=1)
fechou = [d for d in self.a.decisoes if d['tipo'] == 'dia']
self.assertEqual(len(fechou), 7)
quarta = next(d for d in fechou if 'quarta' in d['texto'])
self.assertIn('conferiu 100% com o plano', quarta['motivo'])
def test_dia_a_dia_de_um_dia_passado_e_de_hoje(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
d = self.a.dia_a_dia('2026-10-14')
self.assertEqual(d['nome_dia'], 'quarta')
self.assertFalse(d['hoje'])
self.assertLess(d['medido'][10], 0.3)
self.assertEqual(d['plano'][2], dict(ini=8, fim=18, tipo='fora'))
self.assertEqual(d['confere'], 1.0)
self.assertTrue(any(x['tipo'] == 'dia' for x in d['decisoes']))
self.assertIn('2026-10-05', d['dias'])
hoje = self.a.dia_a_dia()
self.assertTrue(hoje['hoje'])
with self.assertRaises(ValueError):
self.a.dia_a_dia('ontem')
class RotasTest(unittest.TestCase): class RotasTest(unittest.TestCase):
+11 -3
View File
@@ -401,8 +401,10 @@ class Vida:
agora = agora or time.time() agora = agora or time.time()
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora] 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 precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
if self.aprendizado and self.config['ligado']: extra = self.aprendizado.extra_pir(agora) if self.aprendizado and self.config['ligado'] else 0
precisa += self.aprendizado.extra_pir(agora) # nesta hora já acordou à toa outras vezes if extra and precisa <= len(self.pir_eventos) < precisa + extra:
self.aprendizado.segurou_sono(agora, precisa, extra)
precisa += extra # nesta hora já acordou à toa outras vezes
if len(self.pir_eventos) >= precisa: if len(self.pir_eventos) >= precisa:
self.pir_eventos = [] self.pir_eventos = []
if self.aprendizado and self.config['ligado']: if self.aprendizado and self.config['ligado']:
@@ -493,6 +495,7 @@ class Vida:
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50 if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
and self.aprendizado.chega_logo(agora)): and self.aprendizado.chega_logo(agora)):
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém') 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)
return 'acordar' return 'acordar'
return None return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO: if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
@@ -519,7 +522,10 @@ class Vida:
# nesta hora a casa costuma ficar vazia: um cochilo longo guarda pique para quando chegarem # nesta hora a casa costuma ficar vazia: um cochilo longo guarda pique para quando chegarem
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85 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): and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(600, 1200)) segundos = self.rng.uniform(600, 1200)
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
self.aprendizado.porque_vazia(agora), agora)
return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!" # alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo': if dia and perto and humor != 'bravo':
item = self.aprendi_pendente() item = self.aprendi_pendente()
@@ -547,6 +553,8 @@ class Vida:
if sozinho: # sem plateia: nada de acenar ou ficar tímido 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] lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista) gesto, texto = self.escolher(lista)
if self.aprendizado and perto:
self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto) self.enfileirar(gesto)
if humor == 'entediado': if humor == 'entediado':
with self.lock: with self.lock:
+2
View File
@@ -20,6 +20,7 @@
import Eventos from './telas/Eventos.svelte'; import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte'; import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte'; import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte'; import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia'; import { TOPICO_DA_TELA } from './lib/guia';
@@ -33,6 +34,7 @@
rosto: Rosto, rosto: Rosto,
guia: Guia, guia: Guia,
criar: Criar, criar: Criar,
'dia-a-dia': DiaADia,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { CalendarClock, GraduationCap } from '@lucide/svelte'; import { CalendarClock, CalendarDays, GraduationCap } from '@lucide/svelte';
import { import {
analisarSemana, analisarSemana,
apagarPlano, apagarPlano,
@@ -9,6 +9,7 @@
usarSugestao, usarSugestao,
} from '../aprendizado.svelte'; } from '../aprendizado.svelte';
import { estado } from '../estado.svelte'; import { estado } from '../estado.svelte';
import { nav } from '../rotas.svelte';
import type { PlanoSemana, TipoPeriodo } from '../tipos'; import type { PlanoSemana, TipoPeriodo } from '../tipos';
import Grupo from './Grupo.svelte'; import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte'; import Interruptor from './Interruptor.svelte';
@@ -97,6 +98,15 @@
</Linha> </Linha>
{#if a.config.ligado} {#if a.config.ligado}
<Linha
icone={CalendarDays}
cor="#5e5ce6"
rotulo="Ver dia a dia"
detalhe={a.decisoes_hoje
? `${a.decisoes_hoje} ${a.decisoes_hoje === 1 ? 'decisão' : 'decisões'} hoje, com o porquê`
: 'O que ele viu, o que esperava e as decisões de cada dia'}
onclick={() => nav.ir('dia-a-dia')}
/>
<section class="bloco" aria-labelledby="ap-semana"> <section class="bloco" aria-labelledby="ap-semana">
<div class="cabeca"> <div class="cabeca">
<h3 id="ap-semana">Semana da casa</h3> <h3 id="ap-semana">Semana da casa</h3>
+2 -1
View File
@@ -69,6 +69,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '🏠', nome: 'O que muda', texto: 'Quando a próxima hora é de gente em casa e a atual não, ele acorda do cochilo 15 minutos antes, para estar de pé quando você chegar. Com a casa vazia, aproveita para tirar cochilos longos e guardar energia. Sono pesado e madrugada continuam respeitados.' }, { emoji: '🏠', nome: 'O que muda', texto: 'Quando a próxima hora é de gente em casa e a atual não, ele acorda do cochilo 15 minutos antes, para estar de pé quando você chegar. Com a casa vazia, aproveita para tirar cochilos longos e guardar energia. 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: '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: '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: 'Dia a dia', texto: 'Página com um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto o dia conferiu, e cada decisão tomada com base no que aprendeu, com o porquê ("acordou às 17h45: às 18h de quarta costuma ter gente"). Abre pelo botão "Ver dia a dia" no cartão ou pelo Ctrl+K.' },
{ emoji: '🧽', nome: 'Esquecer', texto: 'Apaga a semana, o plano, os gostos e as lições, e ele começa do zero. Desligar o aprendizado só para de usar o que aprendeu, sem apagar.' }, { emoji: '🧽', nome: 'Esquecer', texto: 'Apaga a semana, o plano, os gostos e as 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.', 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.',
@@ -360,5 +361,5 @@ export const ORDEM_GUIA = [
/** Tópico de cada tela, para o ⓘ do topo. */ /** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = { export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes', 'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
}; };
+1
View File
@@ -37,6 +37,7 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') }, { id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-dia-a-dia', grupo: 'Ir para', titulo: 'Dia a dia (rotina e decisões do Carinha)', fazer: () => nav.ir('dia-a-dia') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') }, { id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({ ...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id), id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
+2 -1
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -16,6 +16,7 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }, { id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' }, { id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' }, { id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
{ id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
]; ];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
+19
View File
@@ -124,6 +124,7 @@ export interface Aprendizado {
/** 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;
decisoes_hoje: number;
plano: PlanoSemana | null; plano: PlanoSemana | null;
/** Plano da IA que não bateu com os dados (ou que não substitui o que você contou). */ /** Plano da IA que não bateu com os dados (ou que não substitui o que você contou). */
sugestao: PlanoSemana | null; sugestao: PlanoSemana | null;
@@ -139,3 +140,21 @@ export interface Aprendizado {
licoes: { dia: string; texto: string }[]; licoes: { dia: string; texto: string }[];
hoje: { dia: string; atos: number; respostas: Record<string, number>; acordou: number; a_toa: number }; hoje: { dia: string; atos: number; respostas: Record<string, number>; acordou: number; a_toa: number };
} }
/** Um dia visto pelo aprendizado: o que mediu, o que esperava pelo plano e as decisões com o porquê. */
export interface DiaADia {
dia: string;
sem: number;
nome_dia: string;
hoje: boolean;
/** 24 horas: fração do tempo com gente (null = sem dados). */
medido: (number | null)[];
/** Períodos do plano para esse dia da semana (null = sem plano). */
plano: { ini: number; fim: number; tipo: TipoPeriodo }[] | null;
origem_plano: 'ia' | 'voce' | null;
confere: number | null;
horas_com_gente: number;
resumo: { atos: number; respostas: number; a_toa: number } | null;
decisoes: { horario: number; tipo: 'acordar' | 'cochilo' | 'sono' | 'gosto' | 'dia' | 'ia' | 'voce'; texto: string; motivo: string }[];
dias: string[];
}
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import { avisos } from '../lib/avisos.svelte';
import Card from '../lib/componentes/Card.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
import type { DiaADia, TipoPeriodo } from '../lib/tipos';
// Um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto conferiu
// e cada decisão tomada com base no que aprendeu, com o porquê.
let dia = $state<string | null>(null);
let dados = $state<DiaADia | null>(null);
let carregando = $state(false);
const ICONE: Record<DiaADia['decisoes'][number]['tipo'], string> = {
acordar: '☀️', cochilo: '💤', sono: '☾', gosto: '❤️', dia: '📅', ia: '🧠', voce: '✍️',
};
const NOME_TIPO: Record<TipoPeriodo, string> = { casa: 'em casa', fora: 'casa vazia', dormindo: 'dormindo' };
async function carregar(alvo: string | null = dia) {
carregando = true;
try {
dados = await pedir<DiaADia>('/aprendizado/dia' + (alvo ? '?dia=' + alvo : ''));
dia = dados.dia;
} catch (e) {
avisos.erro(e);
} finally {
carregando = false;
}
}
onMount(() => {
carregar(null);
// o dia de hoje vai mudando: recarrega quando o aprendizado avisa
return ouvir('aprendizado', () => {
if (dados?.hoje) carregar(dados.dia);
});
});
const indice = $derived(dados ? dados.dias.indexOf(dados.dia) : -1);
const anterior = $derived(dados && indice > 0 ? dados.dias[indice - 1] : null);
const proximo = $derived(dados && indice >= 0 && indice < dados.dias.length - 1 ? dados.dias[indice + 1] : null);
const decisoes = $derived(dados ? [...dados.decisoes].sort((a, b) => a.horario - b.horario) : []);
const horaAgora = $derived(new Date(estado.agora).getHours());
function titulo(d: DiaADia) {
const [, m, dd] = d.dia.split('-');
return `${d.nome_dia[0].toUpperCase() + d.nome_dia.slice(1)}, ${dd}/${m}`;
}
function hora(s: number) {
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function tipoNaHora(h: number): TipoPeriodo | null {
return dados?.plano?.find((p) => p.ini <= h && h < p.fim)?.tipo ?? null;
}
function plural(n: number, um: string, varios: string) {
return `${n} ${n === 1 ? um : varios}`;
}
</script>
<div class="pagina">
<Card>
<nav class="dias" aria-label="Escolher o dia">
<button class="btn fantasma icone" onclick={() => carregar(anterior)} disabled={!anterior || carregando} aria-label="Dia anterior">
<ChevronLeft size={18} />
</button>
<div class="qual">
{#if dados}
<strong>{titulo(dados)}</strong>
<span>{dados.hoje ? 'hoje, até agora' : 'dia fechado'}</span>
{:else}
<strong>Carregando…</strong>
{/if}
</div>
<button class="btn fantasma icone" onclick={() => carregar(proximo)} disabled={!proximo || carregando} aria-label="Próximo dia">
<ChevronRight size={18} />
</button>
</nav>
{#if dados}
<dl class="numeros">
<div>
<dt>Com gente por perto</dt>
<dd class="mono">{plural(dados.horas_com_gente, 'hora', 'horas')}</dd>
</div>
<div>
<dt>Carinhos e conversas</dt>
<dd class="mono">{dados.resumo ? dados.resumo.respostas : '—'}</dd>
</div>
<div>
<dt>Conferiu com o plano</dt>
<dd class="mono" class:bom={dados.confere !== null && dados.confere >= 0.7} class:ruim={dados.confere !== null && dados.confere < 0.5}>
{dados.confere === null ? '—' : Math.round(dados.confere * 100) + '%'}
</dd>
</div>
</dl>
<section class="faixas" aria-label="O que ele viu e o que esperava, hora a hora">
<span class="rotulo">Viu</span>
<div class="faixa">
{#each dados.medido as v, h (h)}
<span
class="celula"
class:sem={v === null}
class:agora={dados.hoje && h === horaAgora}
style={v === null ? '' : `--v: ${v}`}
title={`${h}h: ${v === null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}`}
></span>
{/each}
</div>
<span class="rotulo">Esperava</span>
<div class="faixa">
{#each Array(24) as _, h (h)}
{@const t = tipoNaHora(h)}
<span class="celula plano {t ?? 'nada'}" title={`${h}h: ${t ? NOME_TIPO[t] : 'sem plano'}`}></span>
{/each}
</div>
<span></span>
<div class="eixo mono" aria-hidden="true">
{#each Array(24) as _, h (h)}<span>{h % 6 === 0 ? h + 'h' : ''}</span>{/each}
</div>
</section>
<p class="legenda">
<span class="ponto visto"></span> gente por perto
<span class="ponto casa"></span> em casa
<span class="ponto fora"></span> casa vazia
<span class="ponto dormindo"></span> dormindo
{#if !dados.plano}<em>· sem plano ainda: conte sua rotina no cartão "O que ele aprendeu"</em>{/if}
</p>
{/if}
</Card>
<Card titulo="Decisões" ajuda="aprendizado" descricao="O que ele fez por causa do que aprendeu, e por quê.">
{#if decisoes.length}
<ol class="decisoes">
{#each decisoes as d (d.horario + d.texto)}
<li class={d.tipo}>
<time class="mono">{hora(d.horario)}</time>
<span class="icone" aria-hidden="true">{ICONE[d.tipo] ?? '•'}</span>
<div>
<strong>{d.texto}</strong>
<span>{d.motivo}</span>
</div>
</li>
{/each}
</ol>
{:else}
<p class="vazio">
Nenhuma decisão neste dia. Elas aparecem quando ele usa o que aprendeu: acordar antes de você chegar, cochilar
com a casa vazia, segurar o sensor numa hora em que acorda à toa, escolher um gesto que vocês gostam. À
meia-noite ele fecha o dia aqui.
</p>
{/if}
</Card>
</div>
<style>
.pagina {
display: grid;
gap: var(--e-4);
max-width: 760px;
}
.dias {
display: flex;
align-items: center;
gap: var(--e-2);
}
.qual {
display: grid;
flex: 1;
text-align: center;
}
.qual strong {
font-size: var(--t-md);
}
.qual span {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--e-2);
margin: var(--e-3) 0 0;
}
.numeros div {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
}
.numeros dt {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros dd {
margin: 0;
font-size: var(--t-md);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.numeros dd.bom {
color: var(--ok);
}
.numeros dd.ruim {
color: var(--perigo);
}
.faixas {
display: grid;
grid-template-columns: 4.6em 1fr;
align-items: center;
gap: 4px var(--e-2);
margin-top: var(--e-3);
}
.rotulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.faixa,
.eixo {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.celula {
height: 18px;
border-radius: 2px;
background: color-mix(in srgb, #30b0c7 calc(var(--v, 0) * 100%), var(--superficie-2));
}
.celula.sem {
background: var(--superficie-2);
opacity: 0.5;
}
.celula.agora {
outline: 1.5px solid var(--texto);
outline-offset: 1px;
}
.celula.plano.casa,
.ponto.casa {
background: #30b0c7;
}
.celula.plano.fora,
.ponto.fora {
background: color-mix(in srgb, var(--texto-3) 35%, var(--superficie-2));
}
.celula.plano.dormindo,
.ponto.dormindo {
background: #5e5ce6;
}
.celula.plano.nada {
background: var(--superficie-2);
opacity: 0.5;
}
.ponto.visto {
background: color-mix(in srgb, #30b0c7 60%, var(--superficie-2));
}
.eixo span {
font-size: 9px;
color: var(--texto-3);
white-space: nowrap;
}
.legenda {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--e-2);
margin: var(--e-2) 0 0;
color: var(--texto-3);
font-size: var(--t-xs);
}
.legenda em {
font-style: normal;
}
.ponto {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.decisoes {
display: grid;
gap: var(--e-3);
margin: 0;
padding: 0;
list-style: none;
}
.decisoes li {
display: grid;
grid-template-columns: 3.2em 1.4em 1fr;
align-items: start;
gap: var(--e-2);
}
.decisoes time {
padding-top: 2px;
color: var(--texto-3);
font-size: var(--t-xs);
}
.decisoes .icone {
text-align: center;
}
.decisoes div {
display: grid;
gap: 2px;
min-width: 0;
}
.decisoes strong {
font-size: var(--t-sm);
font-weight: 600;
}
.decisoes div span {
color: var(--texto-2);
font-size: var(--t-sm);
}
.decisoes li.dia strong,
.decisoes li.ia strong {
color: #30b0c7;
}
.vazio {
margin: 0;
color: var(--texto-3);
font-size: var(--t-sm);
}
@media (max-width: 480px) {
.numeros dd {
font-size: var(--t-sm);
}
}
</style>