From b4cf13df11f07f55acf52c0cd63438df26bad946 Mon Sep 17 00:00:00 2001 From: julio Date: Fri, 9 Oct 2026 17:34:30 -0300 Subject: [PATCH] =?UTF-8?q?feat(vida):=20p=C3=A1gina=20"Dia=20a=20dia"=20c?= =?UTF-8?q?om=20as=20decis=C3=B5es=20e=20o=20porqu=C3=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- aprendizado.py | 125 ++++++- tests/test_aprendizado.py | 58 +++ vida.py | 14 +- web/src/App.svelte | 2 + .../lib/componentes/AprendizadoCartao.svelte | 12 +- web/src/lib/guia.ts | 3 +- web/src/lib/paleta.svelte.ts | 1 + web/src/lib/rotas.svelte.ts | 3 +- web/src/lib/tipos.ts | 19 + web/src/telas/DiaADia.svelte | 329 ++++++++++++++++++ 10 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 web/src/telas/DiaADia.svelte diff --git a/aprendizado.py b/aprendizado.py index 112a583..de10780 100644 --- a/aprendizado.py +++ b/aprendizado.py @@ -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 AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo 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'] 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 @@ -122,6 +124,8 @@ class Aprendizado: self.analise = dict(ocupado=False, erro='', em=None, tipo='') self.ia = None # (provedor, prompt, schema) -> dict (o main liga) self.pode_ia = lambda provedor: False + self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro + self.sono_avisado = 0.0 try: salvo = json.loads(self.caminho.read_text(encoding='utf-8')) 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_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.decisoes = [d for d in salvo.get('decisoes', []) if isinstance(d, dict)][:MAX_DECISOES] if salvo.get('plano'): self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado')) if salvo.get('sugestao'): @@ -180,7 +185,7 @@ class Aprendizado: with self.lock: 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.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.hoje = self._dia_vazio(_dia(time.time())) 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, 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']) + analise_em=self.analise['em'], decisoes=self.decisoes) self.salvo_em = time.time() try: gravar_atomico(self.caminho, dados) @@ -296,16 +301,58 @@ class Aprendizado: def esperado(self, agora): """'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.""" + return self.esperado_e_fonte(agora)[0] + + def esperado_e_fonte(self, agora): t = time.localtime(agora) 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]: if p['ini'] <= t.tm_hour < p['fim']: - return p['tipo'] - return None - 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] + return p['tipo'], fonte + return None, fonte + 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: - return None - return 'casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None + return None, fonte + 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): """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'], respostas=sum(hoje['respostas'].values()), a_toa=sum(hoje['falsos']))) 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 contagem = hoje['contagem'] 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) 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): linhas = [] for sem in range(7): @@ -534,6 +631,15 @@ class Aprendizado: self.plano, self.sugestao = plano, None else: 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']: texto = plano['padroes'][0] if usar else 'Acho que ' + plano['padroes'][0][0].lower() + plano['padroes'][0][1:] self.anotar('aprendizado', texto, agora) @@ -552,6 +658,7 @@ class Aprendizado: self.plano, self.sugestao = plano, None 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.decidir('voce', 'Passou a seguir a rotina que você contou', '"%s"' % texto[:140], agora) def chamar_ia_pronta(self): 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)] for s, linha in enumerate(self.semana)], 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)), 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']), @@ -606,6 +714,9 @@ class Aprendizado: try: if metodo == 'GET' and rota == '/aprendizado/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': resultado = self.configurar(ler_json(h, 1024)) elif metodo == 'POST' and rota == '/aprendizado/esquecer': diff --git a/tests/test_aprendizado.py b/tests/test_aprendizado.py index a9140b7..f89f1f9 100644 --- a/tests/test_aprendizado.py +++ b/tests/test_aprendizado.py @@ -336,6 +336,64 @@ class SemanaTest(unittest.TestCase): v.visto_em = em(14, 9) atos = [v.agir(em(14, 10) + i * 120) for i in range(10)] 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): diff --git a/vida.py b/vida.py index 2ab4640..a1d023f 100644 --- a/vida.py +++ b/vida.py @@ -401,8 +401,10 @@ 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 + extra = self.aprendizado.extra_pir(agora) if self.aprendizado and self.config['ligado'] else 0 + 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: self.pir_eventos = [] 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 and self.aprendizado.chega_logo(agora)): self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém') + self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora) return 'acordar' return None 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 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): - 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!" if dia and perto and humor != 'bravo': item = self.aprendi_pendente() @@ -547,6 +553,8 @@ class Vida: if sozinho: # sem plateia: nada de acenar ou ficar tímido lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3] gesto, texto = self.escolher(lista) + if self.aprendizado and perto: + self.aprendizado.escolheu(gesto, agora) self.enfileirar(gesto) if humor == 'entediado': with self.lock: diff --git a/web/src/App.svelte b/web/src/App.svelte index 9ffcba8..47681de 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -20,6 +20,7 @@ import Eventos from './telas/Eventos.svelte'; import Guia from './telas/Guia.svelte'; import Criar from './telas/Criar.svelte'; + import DiaADia from './telas/DiaADia.svelte'; import Ajuda from './lib/componentes/Ajuda.svelte'; import { TOPICO_DA_TELA } from './lib/guia'; @@ -33,6 +34,7 @@ rosto: Rosto, guia: Guia, criar: Criar, + 'dia-a-dia': DiaADia, }; const ICONES: Record> = { 'ao-vivo': Video, diff --git a/web/src/lib/componentes/AprendizadoCartao.svelte b/web/src/lib/componentes/AprendizadoCartao.svelte index f9d15a8..b14db86 100644 --- a/web/src/lib/componentes/AprendizadoCartao.svelte +++ b/web/src/lib/componentes/AprendizadoCartao.svelte @@ -1,5 +1,5 @@ + +
+ + + + {#if dados} +
+
+
Com gente por perto
+
{plural(dados.horas_com_gente, 'hora', 'horas')}
+
+
+
Carinhos e conversas
+
{dados.resumo ? dados.resumo.respostas : '—'}
+
+
+
Conferiu com o plano
+
= 0.7} class:ruim={dados.confere !== null && dados.confere < 0.5}> + {dados.confere === null ? '—' : Math.round(dados.confere * 100) + '%'} +
+
+
+ +
+ Viu +
+ {#each dados.medido as v, h (h)} + + {/each} +
+ Esperava +
+ {#each Array(24) as _, h (h)} + {@const t = tipoNaHora(h)} + + {/each} +
+ + +
+

+ gente por perto + em casa + casa vazia + dormindo + {#if !dados.plano}· sem plano ainda: conte sua rotina no cartão "O que ele aprendeu"{/if} +

+ {/if} +
+ + + {#if decisoes.length} +
    + {#each decisoes as d (d.horario + d.texto)} +
  1. + + +
    + {d.texto} + {d.motivo} +
    +
  2. + {/each} +
+ {:else} +

+ 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. +

+ {/if} +
+
+ +