feat(vida): semana da casa com análise por IA e rotina contada

O aprendizado passa a entender a logística da semana, não só a hora:
- presença por dia da semana × hora (7×24, média móvel por dia) e
  histórico dos últimos 42 dias;
- depois da meia-noite, com 7 dias observados, a IA escolhida (Gemini,
  IA local ou Qwen do S10, via Criacao.chamar) cruza os dias e devolve
  padrões em frases e um plano da semana (casa / fora / dormindo);
- o plano da IA só é adotado se conferir com 70% das horas medidas;
  senão fica como sugestão ("Usar este plano");
- "Conte sua rotina": texto livre vira plano na hora e vale sobre o da
  IA; o painel mostra o quanto confere com o que os sensores veem;
- o plano guia o comportamento: acorda do cochilo 15 min antes da
  chegada naquele dia da semana e, com a casa vazia, tira cochilos
  longos para guardar energia;
- lição da rotina só quando muda 3+ horas ou depois de uma semana, e
  some quando já existe plano da semana;
- painel: quadro da semana (o que eu vi / plano), a rotina entendida,
  sugestão, contar rotina, analisar agora e a IA da análise diária.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 10:36:32 -03:00
co-authored by Claude Opus 5.5
parent 24625453f7
commit 935bf8d586
8 changed files with 804 additions and 103 deletions
+287 -10
View File
@@ -8,17 +8,24 @@ Sem IA e sem internet: só conta o que acontece em casa e ajusta o modo vida aos
de um cochilo para estar de pé quando a pessoa chegar.
- Sono: quando o sensor acorda o rosto e ninguém fica nem interage, foi à toa. Se isso se repete
na mesma hora, ele passa a pedir mais movimento para acordar nesse horário.
- Semana: a presença também é guardada por dia da semana × hora, com o histórico dos últimos dias.
Uma vez por dia uma IA (Gemini, IA local ou o S10) cruza esse quadro, acha os padrões ("fora de
terça a quinta, em casa na segunda e na sexta") e propõe um plano da semana. O plano só é usado
se bater com o que os sensores mediram; senão fica como sugestão. A pessoa também pode contar a
rotina com as próprias palavras, e os dados vão dizendo o quanto ela confere.
À meia-noite ele fecha o dia: o que aprendeu vira lição no diário e o que ficou para trás pesa
menos (o gosto das pessoas muda). "Esquecer" zera tudo.
"""
import json
import threading
import time
from datetime import date
from pathlib import Path
from comum import gravar_atomico, ler_json
PADRAO = dict(ligado=True)
PADRAO = dict(ligado=True, analise_ia=True, ia='gemini')
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}
ESQUECE = 0.85 # todo dia, o que aprendeu antes pesa um pouco menos
@@ -31,6 +38,21 @@ 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
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
MAX_HISTORICO = 42 # dias guardados para a IA cruzar
MIN_DIAS_ANALISE = 7 # análise automática só com uma semana inteira
MIN_DIAS_MANUAL = 3
MIN_HORAS_CONFERIR = 24 # horas com dados para dizer se um plano bate
CONCORDA = 0.7 # plano da IA só vale se bater com 70% das horas medidas
TIPOS = ['casa', 'fora', 'dormindo']
SCHEMA_PLANO = dict(type='object', required=['padroes', 'dias'], properties=dict(
padroes=dict(type='array', items=dict(type='string')),
dias=dict(type='array', items=dict(type='object', required=['dia', 'periodos'], properties=dict(
dia=dict(type='integer'),
periodos=dict(type='array', items=dict(type='object', required=['ini', 'fim', 'tipo'], properties=dict(
ini=dict(type='integer'), fim=dict(type='integer'), tipo=dict(type='string', enum=TIPOS)))))))))
NOMES = {
'alegre': 'saltitar', 'musica': 'cantarolar', 'feliz': 'sorrir', 'piscadinha': 'piscadinha',
@@ -85,12 +107,21 @@ class Aprendizado:
self.dias = 0
self.licoes = []
self.rotina_avisada = []
self.rotina_avisada_dia = 0
self.hoje = self._dia_vazio(_dia(time.time()))
self.pendentes = [] # atos esperando resposta: (nome, horario)
self.acordou_em = None # acordou pelo sensor: esperando para ver se valeu
self.presenca_depois = [0, 0] # (com gente, total) nas observações depois de acordar
self.minuto = None
self.salvo_em = 0.0
self.semana = [[None] * 24 for _ in range(7)] # presença por dia da semana × hora
self.semana_n = [[0] * 24 for _ in range(7)] # quantos dias mediram cada célula
self.historico = [] # [{dia, sem, presenca[24], respostas, atos}]
self.plano = None # {origem, criado, padroes, dias[7][...]}
self.sugestao = None # plano da IA que não bateu com os dados
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
try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
@@ -102,6 +133,17 @@ class Aprendizado:
self.dias = int(salvo.get('dias', 0))
self.licoes = [l for l in salvo.get('licoes', []) if isinstance(l, dict)][:MAX_LICOES]
self.rotina_avisada = list(salvo.get('rotina_avisada', []))
self.rotina_avisada_dia = int(salvo.get('rotina_avisada_dia', 0))
if len(salvo.get('semana', [])) == 7:
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:]
if salvo.get('plano'):
self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado'))
if salvo.get('sugestao'):
self.sugestao = self.validar_plano(salvo['sugestao'], 'ia', salvo['sugestao'].get('criado'))
if isinstance(salvo.get('analise_em'), (int, float)):
self.analise['em'] = salvo['analise_em']
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
self.hoje.update(salvo['hoje'])
except (OSError, ValueError, TypeError, KeyError, AttributeError):
@@ -119,7 +161,10 @@ class Aprendizado:
for chave, valor in dados.items():
if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave)
if not isinstance(valor, bool):
if chave == 'ia':
if valor not in IAS:
raise ValueError('IA: gemini, local ou s10')
elif not isinstance(valor, bool):
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
return dict(dados)
@@ -134,7 +179,9 @@ class Aprendizado:
def esquecer(self):
with self.lock:
self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24
self.dias, self.licoes, self.rotina_avisada = 0, [], []
self.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.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
self.anotar('aprendizado', 'Esqueceu tudo o que tinha aprendido e começou do zero')
@@ -145,7 +192,10 @@ class Aprendizado:
def salvar(self):
with self.lock:
dados = dict(config=self.config, itens=self.itens, rotina=self.rotina, falsos=self.falsos, dias=self.dias,
licoes=self.licoes, rotina_avisada=self.rotina_avisada, hoje=self.hoje)
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'])
self.salvo_em = time.time()
try:
gravar_atomico(self.caminho, dados)
@@ -243,15 +293,32 @@ class Aprendizado:
def _extra(falsos):
return 2 if falsos >= 1.5 else 1 if falsos >= 0.5 else 0
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."""
t = time.localtime(agora)
if self.plano:
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]
if v is None:
return None
return 'casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None
def chega_logo(self, agora):
"""Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?"""
if not self.ligado():
return False
t = time.localtime(agora)
prox, atual = self.rotina[(t.tm_hour + 1) % 24], self.rotina[t.tm_hour]
falta = 3600 - (t.tm_min * 60 + t.tm_sec)
return (prox is not None and prox >= ROTINA_CHEIA and (atual is None or atual < ROTINA_VAZIA)
and falta <= AVISO_CHEGADA)
return falta <= AVISO_CHEGADA and self.esperado(agora) != 'casa' and self.esperado(agora + falta + 1) == 'casa'
def casa_vazia(self, agora):
"""Nesta hora a casa costuma ficar vazia: dá para descansar e guardar energia para quando chegarem."""
return self.ligado() and self.esperado(agora) == 'fora'
# ------------------------------------------------------------------ fechar o dia
def virar_dia(self, agora):
@@ -266,6 +333,8 @@ class Aprendizado:
self.anotar('aprendizado', texto, agora)
self.salvar()
self.avisar()
if self.config['analise_ia'] and len(self.historico) >= MIN_DIAS_ANALISE and self.ia and self.pode_ia(self.config['ia']):
self.rodar('analise', self.analisar)
def nome(self, ident):
if ident.startswith('criado:'):
@@ -289,6 +358,17 @@ class Aprendizado:
observou = sum(hoje['observado']) >= 60
if observou:
self.dias += 1
sem = date.fromisoformat(hoje['dia']).weekday()
fracs = [round(hoje['presenca'][h] / hoje['observado'][h], 2) if hoje['observado'][h] >= MIN_MINUTOS_HORA
else None for h in range(24)]
for h, frac in enumerate(fracs):
if frac is not None:
antes = self.semana[sem][h]
self.semana[sem][h] = round(frac if antes is None else antes + (frac - antes) * MEMORIA_SEMANA, 3)
self.semana_n[sem][h] += 1
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]
# 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)
@@ -301,12 +381,14 @@ class Aprendizado:
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.')
# rotina: quando passa a ter um padrão (ou ele muda), conta
if self.dias >= 2:
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]
if cheias and cheias != self.rotina_avisada:
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)))
self.rotina_avisada = cheias
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)
if piores:
@@ -325,6 +407,172 @@ class Aprendizado:
del self.licoes[MAX_LICOES:]
return licoes
# ------------------------------------------------------------------ semana: IA e plano
@staticmethod
def validar_plano(bruto, origem, criado=None):
"""Plano vindo da IA (ou salvo): 7 dias de períodos casa/fora/dormindo, horas de 0 a 24."""
if not isinstance(bruto, dict) or not isinstance(bruto.get('dias'), list):
raise ValueError('A IA não devolveu um plano da semana')
dias = [[] for _ in range(7)]
for i, d in enumerate(bruto['dias'][:7]):
if isinstance(d, list): # formato salvo: lista por dia
dia, periodos = i, d
elif isinstance(d, dict):
dia, periodos = d.get('dia'), d.get('periodos')
else:
continue
if isinstance(dia, bool) or not isinstance(dia, int) or not 0 <= dia <= 6 or not isinstance(periodos, list):
continue
for p in periodos[:8]:
if not isinstance(p, dict) or p.get('tipo') not in TIPOS:
continue
try:
ini, fim = max(0, min(23, int(p['ini']))), max(1, min(24, int(p['fim'])))
except (KeyError, TypeError, ValueError):
continue
if ini < fim:
dias[dia].append(dict(ini=ini, fim=fim, tipo=p['tipo']))
for periodos in dias:
periodos.sort(key=lambda p: p['ini'])
for a, b in zip(periodos, periodos[1:]): # sem sobrepor: o anterior termina antes
a['fim'] = min(a['fim'], b['ini'])
periodos[:] = [p for p in periodos if p['ini'] < p['fim']]
if not any(dias):
raise ValueError('O plano veio vazio')
padroes = [str(t).strip()[:160] for t in bruto.get('padroes') or [] if str(t).strip()][:5]
return dict(origem=origem, criado=criado or time.time(), padroes=padroes, dias=dias)
def conferir(self, plano):
"""Quanto o plano bate com o que os sensores mediram (0 a 1), ou None com poucos dados."""
if not plano:
return None
certas = total = 0
for sem in range(7):
for p in plano['dias'][sem]:
for h in range(p['ini'], p['fim']):
v = self.semana[sem][h]
if v is None or not self.semana_n[sem][h]:
continue
total += 1
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 tabela_semana(self):
linhas = []
for sem in range(7):
celulas = ['%dh:%s' % (h, '—' if v is None else '%d%%' % round(v * 100)) for h, v in enumerate(self.semana[sem])]
linhas.append('%s (%d dias medidos): %s' % (DIAS_SEMANA[sem], max(self.semana_n[sem]), ' '.join(celulas)))
return '\n'.join(linhas)
def resumo_dias(self):
linhas = []
for d in self.historico[-21:]:
horas = [h for h, v in enumerate(d['presenca']) if v is not None and v >= ROTINA_VAZIA]
casa = _texto_faixas(_faixas(horas)) if horas else 'ninguém por perto'
linhas.append('%s (%s): gente %s; %d carinhos/conversas' % (d['dia'], DIAS_SEMANA[d['sem']], casa, d['respostas']))
return '\n'.join(linhas)
def montar_prompt(self, pedido=None):
base = (
'Você é o Carinha, um robô de mesa que observa a casa por um sensor de presença. Analise a rotina da '
'casa e responda em português do Brasil.\n\n'
'Presença por dia da semana e hora (% do tempo com alguém perto do robô; — = sem dados):\n'
+ self.tabela_semana() + '\n\nÚltimos dias, um por linha:\n' + (self.resumo_dias() or '(nenhum ainda)') + '\n\n')
regras = (
'"dias": para cada dia da semana (dia 0 = segunda ... 6 = domingo) dê períodos que cubram de 0 a 24h sem '
'sobrepor, com tipo "casa" (tem gente acordada por perto), "fora" (casa vazia) ou "dormindo" (noite, '
'gente em casa dormindo). Horas inteiras, ini de 0 a 23, fim de 1 a 24.')
if pedido:
return base + (
'A pessoa contou a rotina dela com as próprias palavras:\n"' + pedido + '"\n\n'
'Transforme isso no plano da semana. Vale o que a pessoa disse; use os dados só para completar o que '
'ela não falou (por exemplo, o horário de dormir). ' + regras + '\n"padroes": até 4 frases curtas, em '
'primeira pessoa, resumindo a rotina que você entendeu (ex.: "Você fica fora de terça a quinta, das 8h às 18h").')
return base + (
'Cruze os dias: procure dias da semana parecidos entre si (dias úteis iguais, fim de semana diferente, um '
'dia que foge da regra), horários de chegada e de saída, e quando a casa fica vazia. Só afirme o que os '
'dados mostram; para dias sem dados, repita o padrão dos dias parecidos.\n'
'"padroes": até 5 frases curtas, em primeira pessoa, sobre o que você percebeu (ex.: "Percebi que de terça a '
'quinta a casa fica vazia das 8h às 18h, mas na segunda e na sexta tem gente o dia todo"). ' + regras)
def rodar(self, tipo, funcao, *args):
"""Uma análise por vez, em segundo plano (a IA local pode levar minutos)."""
with self.lock:
if self.analise['ocupado']:
raise ValueError('Já estou analisando, espere terminar')
self.analise.update(ocupado=True, erro='', tipo=tipo)
self.avisar()
def tarefa():
try:
funcao(*args)
except Exception as e:
with self.lock:
self.analise['erro'] = str(e)[:200]
finally:
with self.lock:
self.analise['ocupado'] = False
self.salvar()
self.avisar()
threading.Thread(target=tarefa, daemon=True).start()
def chamar_ia(self, pedido=None):
provedor = self.config['ia']
if not self.ia or not self.pode_ia(provedor):
raise ValueError('IA indisponível: confira a chave do Gemini ou a IA local nos Ajustes')
return self.ia(provedor, self.montar_prompt(pedido), SCHEMA_PLANO)
def analisar(self, agora=None):
"""A IA cruza a semana e propõe um plano. Só vale se bater com os dados; senão vira sugestão."""
agora = agora or time.time()
plano = self.validar_plano(self.chamar_ia(), 'ia')
nota = self.conferir(plano)
with self.lock:
self.analise['em'] = agora
usar = nota is not None and nota >= CONCORDA and (not self.plano or self.plano['origem'] == 'ia')
if usar:
self.plano, self.sugestao = plano, None
else:
self.sugestao = plano
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)
with self.lock:
self.licoes.insert(0, dict(dia=_dia(agora), texto=texto))
del self.licoes[MAX_LICOES:]
return plano
def informar(self, texto, agora=None):
"""A pessoa contou a rotina: vira o plano na hora (os dados conferem depois)."""
texto = str(texto or '').strip()
if not 5 <= len(texto) <= 600:
raise ValueError('Conte a rotina em uma ou duas frases (até 600 letras)')
plano = self.validar_plano(self.chamar_ia(texto), 'voce')
with self.lock:
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)
def chamar_ia_pronta(self):
if not self.ia or not self.pode_ia(self.config['ia']):
raise ValueError('IA indisponível: confira a chave do Gemini ou a IA local nos Ajustes')
def usar_sugestao(self):
with self.lock:
if not self.sugestao:
raise ValueError('Não há sugestão para usar')
self.plano, self.sugestao = dict(self.sugestao, origem='voce'), None
self.salvar()
self.avisar()
return self.status()
def apagar_plano(self):
with self.lock:
self.plano = None
self.salvar()
self.avisar()
return self.status()
# ------------------------------------------------------------------ painel
def status(self):
with self.lock:
@@ -338,6 +586,13 @@ class Aprendizado:
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina),
sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
gosto=gosto, licoes=self.licoes[:12],
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),
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']),
minimo=MIN_DIAS_MANUAL),
hoje=dict(dia=hoje['dia'], atos=hoje['atos'], respostas=dict(hoje['respostas']),
acordou=sum(hoje['acordou']), a_toa=sum(hoje['falsos'])))
@@ -355,6 +610,28 @@ class Aprendizado:
resultado = self.configurar(ler_json(h, 1024))
elif metodo == 'POST' and rota == '/aprendizado/esquecer':
resultado = self.esquecer()
elif metodo == 'POST' and rota == '/aprendizado/analisar':
ler_json(h, 64)
if len(self.historico) < MIN_DIAS_MANUAL:
raise ValueError('Preciso de pelo menos %d dias observados para analisar' % MIN_DIAS_MANUAL)
self.chamar_ia_pronta()
self.rodar('analise', self.analisar)
resultado = self.status()
elif metodo == 'POST' and rota == '/aprendizado/rotina':
texto = ler_json(h, 4096).get('texto')
if not 5 <= len(str(texto or '').strip()) <= 600:
raise ValueError('Conte a rotina em uma ou duas frases (até 600 letras)')
self.chamar_ia_pronta()
self.rodar('rotina', self.informar, texto)
resultado = self.status()
elif metodo == 'POST' and rota == '/aprendizado/plano':
acao = ler_json(h, 256).get('acao')
if acao == 'usar_sugestao':
resultado = self.usar_sugestao()
elif acao == 'apagar':
resultado = self.apagar_plano()
else:
raise ValueError('Ação: usar_sugestao ou apagar')
else:
raise ValueError('Rota inválida')
h.enviar(json.dumps(resultado, ensure_ascii=False))
+3
View File
@@ -562,6 +562,9 @@ def main():
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar())
vida.aprendizado = aprendizado
# 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'))
# 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"
+137
View File
@@ -8,6 +8,7 @@ import tempfile
import threading
import time
import unittest
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@@ -201,6 +202,142 @@ class VidaComAprendizadoTest(unittest.TestCase):
self.assertIsNone(self.v.agir(em(6, 17, 50)))
def semana_tipica(a, semanas=2):
"""Segunda, sexta e fim de semana em casa (7h-23h); terça a quinta fora das 8h às 18h. Começa em 05/10 (segunda)."""
inicio = date(2026, 10, 5)
for i in range(semanas * 7):
d = inicio + timedelta(days=i)
a.hoje = a._dia_vazio(d.isoformat())
for h in range(24):
for m in range(0, 60, 2):
fora = d.weekday() in (1, 2, 3) and 8 <= h < 18
perto = 7 <= h < 23 and not fora and m < 40
a.minuto = None
a.observar(time.mktime((d.year, d.month, d.day, h, m, 0, 0, 0, -1)), perto)
fim = d + timedelta(days=1)
a.observar(time.mktime((fim.year, fim.month, fim.day, 0, 0, 30, 0, 0, -1)), False)
def plano_certo():
dias = []
for dia in range(7):
if dia in (1, 2, 3):
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=8, tipo='casa'),
dict(ini=8, fim=18, tipo='fora'), dict(ini=18, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
else:
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
dias.append(dict(dia=dia, periodos=periodos))
return dict(padroes=['Percebi que de terça a quinta a casa fica vazia das 8h às 18h'], dias=dias)
class SemanaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append(texto)
self.pedidos = []
self.resposta = plano_certo()
def ia(provedor, prompt, schema):
self.pedidos.append((provedor, prompt))
return self.resposta
self.a.ia, self.a.pode_ia = ia, lambda provedor: True
self.a.config['analise_ia'] = False # nos testes a análise é chamada direto
def tearDown(self):
self.tmp.cleanup()
def test_quadro_da_semana_separa_os_dias(self):
semana_tipica(self.a)
self.assertEqual(len(self.a.historico), 14)
self.assertLess(self.a.semana[2][10], 0.3) # quarta 10h: fora
self.assertGreater(self.a.semana[0][10], 0.5) # segunda 10h: em casa
self.assertGreater(self.a.semana[4][10], 0.5) # sexta 10h: em casa
self.assertEqual(self.a.esperado(em(14, 10)), 'fora') # 14/10 é quarta
self.assertEqual(self.a.esperado(em(12, 10)), 'casa') # 12/10 é segunda
self.assertTrue(self.a.chega_logo(em(14, 17, 50))) # quarta: chegam às 18h
self.assertFalse(self.a.chega_logo(em(12, 17, 50))) # segunda: já tem gente em casa
self.assertTrue(self.a.casa_vazia(em(14, 10)))
def test_plano_da_ia_que_bate_com_os_dados_e_usado(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertIn('quarta', self.pedidos[0][1])
self.assertIn('sexta (2 dias medidos)', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'ia')
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.assertIsNone(self.a.sugestao)
self.assertEqual(self.a.esperado(em(14, 10)), 'fora')
self.assertEqual(self.a.esperado(em(14, 2)), 'dormindo')
self.assertTrue(any('terça a quinta' in t for t in self.diario))
def test_plano_da_ia_que_nao_bate_vira_so_sugestao(self):
semana_tipica(self.a)
errado = plano_certo()
for d in errado['dias']:
d['periodos'] = [dict(ini=0, fim=24, tipo='fora')] # "a casa vive vazia": falso
self.resposta = errado
self.a.analisar(em(19, 1))
self.assertIsNone(self.a.plano)
self.assertIsNotNone(self.a.sugestao)
self.assertLess(self.a.conferir(self.a.sugestao), 0.7)
self.assertTrue(any(t.startswith('Acho que') for t in self.diario))
self.a.usar_sugestao()
self.assertEqual(self.a.plano['origem'], 'voce')
def test_rotina_contada_vale_na_hora_e_a_ia_nao_atropela(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.assertIn('Fico fora de terça a quinta', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'voce')
self.assertIsNone(self.a.conferir(self.a.plano)) # ainda sem dados para conferir
semana_tipica(self.a)
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.plano['origem'], 'voce') # o que a pessoa disse continua valendo
self.assertIsNotNone(self.a.sugestao)
def test_validar_plano_conserta_o_que_a_ia_erra(self):
p = Aprendizado.validar_plano(dict(padroes=['ok'] * 9, dias=[
dict(dia=0, periodos=[dict(ini=8, fim=20, tipo='casa'), dict(ini=18, fim=30, tipo='fora'),
dict(ini=5, fim=5, tipo='casa'), dict(ini=1, fim=3, tipo='festa')]),
dict(dia=9, periodos=[dict(ini=0, fim=24, tipo='casa')]), 'lixo']), 'ia')
self.assertEqual(p['dias'][0], [dict(ini=8, fim=18, tipo='casa'), dict(ini=18, fim=24, tipo='fora')])
self.assertEqual(len(p['padroes']), 5)
with self.assertRaises(ValueError):
Aprendizado.validar_plano(dict(padroes=[], dias=[]), 'ia')
def test_analise_roda_sozinha_a_meia_noite_com_uma_semana(self):
self.a.config['analise_ia'] = True
semana_tipica(self.a, semanas=1)
for _ in range(50):
if not self.a.analise['ocupado'] and (self.a.plano or self.a.sugestao):
break
time.sleep(0.05)
self.assertEqual(len(self.pedidos), 1) # só na 7ª meia-noite
self.assertIsNotNone(self.a.plano or self.a.sugestao)
def test_persistencia_da_semana_e_do_plano(self):
semana_tipica(self.a, semanas=1)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.a.salvar()
outro = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.assertEqual(outro.semana, self.a.semana)
self.assertEqual(outro.plano['dias'], self.a.plano['dias'])
self.assertEqual(len(outro.historico), 7)
def test_casa_vazia_faz_ele_cochilar_para_guardar_pique(self):
v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(5))
v.aprendizado = self.a
v.enfileirar = lambda ordem: None
v.celular_online = lambda: True
v.necessidades.update(energia=70, diversao=80, social=80, alegria=80)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
v.visto_em = em(14, 9)
atos = [v.agir(em(14, 10) + i * 120) for i in range(10)]
self.assertIn('atividade dormir', atos)
class RotasTest(unittest.TestCase):
def test_status_config_e_esquecer(self):
with tempfile.TemporaryDirectory() as tmp:
+4
View File
@@ -516,6 +516,10 @@ class Vida:
if perto:
self.visto_em = agora
sozinho = agora - self.visto_em > SOZINHO
# 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))
# 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()
+16 -7
View File
@@ -1,25 +1,34 @@
/** Aprendizado com o dia a dia: ligar/desligar e esquecer. O estado chega pelo SSE 'aprendizado'. */
/** Aprendizado com o dia a dia: ajustes, análise da semana com IA e a rotina contada por você.
* O estado chega pelo SSE 'aprendizado'. */
import { postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { Aprendizado } from './tipos';
export async function configurarAprendizado(ligado: boolean) {
export async function configurarAprendizado(parcial: Partial<Aprendizado['config']>) {
const antes = estado.aprendizado;
if (antes) estado.aprendizado = { ...antes, config: { ...antes.config, ligado } };
if (antes) estado.aprendizado = { ...antes, config: { ...antes.config, ...parcial } };
try {
estado.aprendizado = await postar<Aprendizado>('/aprendizado/config', { ligado });
estado.aprendizado = await postar<Aprendizado>('/aprendizado/config', parcial);
} catch (e) {
estado.aprendizado = antes;
avisos.erro(e);
}
}
export async function esquecerAprendizado() {
async function pedir(rota: string, corpo: object, sucesso?: string) {
try {
estado.aprendizado = await postar<Aprendizado>('/aprendizado/esquecer', {});
avisos.ok('Ele esqueceu o que tinha aprendido e começa do zero');
estado.aprendizado = await postar<Aprendizado>(rota, corpo);
if (sucesso) avisos.ok(sucesso);
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
export const esquecerAprendizado = () => pedir('/aprendizado/esquecer', {}, 'Ele esqueceu o que tinha aprendido e começa do zero');
export const analisarSemana = () => pedir('/aprendizado/analisar', {});
export const contarRotina = (texto: string) => pedir('/aprendizado/rotina', { texto });
export const usarSugestao = () => pedir('/aprendizado/plano', { acao: 'usar_sugestao' }, 'Plano da IA em uso');
export const apagarPlano = () => pedir('/aprendizado/plano', { acao: 'apagar' }, 'Plano apagado: ele volta a seguir só o que mede');
+331 -81
View File
@@ -1,20 +1,45 @@
<script lang="ts">
import { GraduationCap } from '@lucide/svelte';
import { configurarAprendizado, esquecerAprendizado } from '../aprendizado.svelte';
import { CalendarClock, GraduationCap } from '@lucide/svelte';
import {
analisarSemana,
apagarPlano,
configurarAprendizado,
contarRotina,
esquecerAprendizado,
usarSugestao,
} from '../aprendizado.svelte';
import { estado } from '../estado.svelte';
import type { PlanoSemana, TipoPeriodo } from '../tipos';
import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte';
import Linha from './Linha.svelte';
// O que ele aprendeu com o dia a dia: rotina da casa (24 h), do que gostam, lições e o sono.
// O que ele aprendeu com o dia a dia: a semana da casa (medida e planejada), do que gostam,
// as lições e o sono. A IA cruza a semana; você também pode contar a rotina.
const a = $derived(estado.aprendizado);
let confirmar = $state(false);
let vista = $state<'visto' | 'plano'>('visto');
let rotina = $state('');
const horaAgora = $derived(new Date(estado.agora).getHours());
const temRotina = $derived(!!a && a.rotina.some((v) => v !== null));
const DIAS = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom'];
const NOME_TIPO: Record<TipoPeriodo, string> = { casa: 'em casa', fora: 'casa vazia', dormindo: 'dormindo' };
const IAS = [
{ valor: 'gemini', nome: 'Gemini' },
{ valor: 'local', nome: 'IA local' },
{ valor: 's10', nome: 'Qwen do S10' },
] as const;
const agora = $derived(new Date(estado.agora));
const hojeSemana = $derived((agora.getDay() + 6) % 7); // 0 = segunda, como na placa
const horaAgora = $derived(agora.getHours());
const temDados = $derived(!!a && a.semana.some((l) => l.some((v) => v !== null)));
const plano = $derived(a?.plano ?? null);
const verPlano = $derived(vista === 'plano' && !!plano);
const faz_mais = $derived(a ? a.gosto.filter((g) => g.peso > 1.05).slice(0, 4) : []);
const faz_menos = $derived(a ? [...a.gosto].reverse().filter((g) => g.peso < 0.95).slice(0, 3) : []);
const respostas = $derived(a ? Object.values(a.hoje.respostas).reduce((s, n) => s + n, 0) : 0);
const ocupado = $derived(!!a?.analise.ocupado);
const faltamDias = $derived(a ? Math.max(0, a.analise.minimo - a.historico_dias) : 0);
const detalhe = $derived.by(() => {
if (!a) return '';
@@ -23,12 +48,24 @@
return `${dias} · hoje: ${a.hoje.atos} ${a.hoje.atos === 1 ? 'gesto' : 'gestos'} com alguém perto, ${respostas} ${respostas === 1 ? 'carinho ou conversa' : 'carinhos ou conversas'}`;
});
const resumoRotina = $derived.by(() => {
if (!a) return '';
const cheias = a.rotina.flatMap((v, h) => (v !== null && v >= 0.5 ? [h] : []));
return cheias.length ? 'Costuma ter gente às ' + cheias.map((h) => h + 'h').join(', ') : 'Ainda sem um horário marcado';
});
function tipoNoPlano(p: PlanoSemana, dia: number, h: number): TipoPeriodo | null {
return p.dias[dia]?.find((x) => x.ini <= h && h < x.fim)?.tipo ?? null;
}
function rotuloCelula(dia: number, h: number) {
if (verPlano && plano) {
const t = tipoNoPlano(plano, dia, h);
return `${DIAS[dia]} ${h}h: ${t ? NOME_TIPO[t] : 'sem plano'}`;
}
const v = a?.semana[dia][h];
return `${DIAS[dia]} ${h}h: ${v == null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}`;
}
function confere(p: PlanoSemana) {
return p.confere === null ? 'ainda conferindo com o que eu vejo' : `confere ${Math.round(p.confere * 100)}% com o que eu vi`;
}
function quando(s: number | null) {
if (!s) return '';
return new Date(s * 1000).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function dia(iso: string) {
const [, m, d] = iso.split('-');
return `${d}/${m}`;
@@ -36,46 +73,145 @@
function vezes(peso: number) {
return '×' + peso.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
}
async function enviarRotina(e: SubmitEvent) {
e.preventDefault();
if (await contarRotina(rotina.trim())) {
rotina = '';
vista = 'plano';
}
}
</script>
<Grupo
titulo="O que ele aprendeu"
ajuda="aprendizado"
rodape="Ele conta gestos feitos com alguém por perto e o que veio logo depois (carinho, conversa). À meia-noite junta o dia e escreve as lições."
rodape="Ele conta o que acontece em casa: quando tem gente, o que agrada e quando acorda à toa. À meia-noite junta o dia, escreve as lições e a IA cruza a semana."
>
{#if !a}
<p class="vazio">Carregando…</p>
{:else}
<Linha icone={GraduationCap} cor="#30b0c7" rotulo="Aprender com o dia a dia" {detalhe}>
{#snippet acessorio()}
<Interruptor rotulo="Aprender com o dia a dia" checked={a.config.ligado} onchange={(l) => configurarAprendizado(l)} />
<Interruptor rotulo="Aprender com o dia a dia" checked={a.config.ligado} onchange={(l) => configurarAprendizado({ ligado: l })} />
{/snippet}
</Linha>
{#if a.config.ligado}
<section class="bloco" aria-labelledby="ap-rotina">
<h3 id="ap-rotina">Rotina da casa <span>{temRotina ? resumoRotina : 'aparece depois da primeira meia-noite'}</span></h3>
<div class="rotina" role="img" aria-label={resumoRotina}>
{#each a.rotina as v, h (h)}
<span
class="hora"
class:agora={h === horaAgora}
class:sem={v === null}
title={`${h}h: ${v === null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}${a.sono[h] ? ' · acordou à toa aqui' : ''}`}
>
<span class="barra" style="--v: {v ?? 0}"></span>
{#if a.sono[h]}<span class="lua" aria-hidden="true">☾</span>{/if}
<section class="bloco" aria-labelledby="ap-semana">
<div class="cabeca">
<h3 id="ap-semana">Semana da casa</h3>
{#if plano}
<span class="segmentos" role="radiogroup" aria-label="Mostrar">
<button role="radio" aria-checked={vista === 'visto'} class:ativo={vista === 'visto'} onclick={() => (vista = 'visto')}>O que eu vi</button>
<button role="radio" aria-checked={vista === 'plano'} class:ativo={vista === 'plano'} onclick={() => (vista = 'plano')}>Plano</button>
</span>
{/if}
</div>
<div class="quadro-rolagem">
<div class="quadro" class:modo-plano={verPlano} role="img" aria-label={verPlano ? 'Plano da semana' : 'Presença medida por dia da semana e hora'}>
{#each DIAS as nomeDia, d (d)}
<span class="dia" class:hoje={d === hojeSemana}>{nomeDia}</span>
{#each a.semana[d] as v, h (h)}
{@const t = verPlano && plano ? tipoNoPlano(plano, d, h) : null}
<span
class="celula {t ?? ''}"
class:sem={!verPlano && v === null}
class:agora={d === hojeSemana && h === horaAgora}
style={!verPlano && v !== null ? `--v: ${v}` : ''}
title={rotuloCelula(d, h)}
></span>
{/each}
{/each}
<span></span>
{#each Array(24) as _, h (h)}
<span class="hora mono" aria-hidden="true">{h % 6 === 0 ? h + 'h' : ''}</span>
{/each}
</div>
<div class="eixo mono" aria-hidden="true"><span>0h</span><span>6h</span><span>12h</span><span>18h</span><span>24h</span></div>
{#if a.sono.some((n) => n > 0)}
<p class="nota"><span aria-hidden="true">☾</span> Horas em que acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
</div>
{#if verPlano}
<p class="legenda">
<span class="ponto casa"></span> em casa <span class="ponto fora"></span> casa vazia <span class="ponto dormindo"></span> dormindo
</p>
{:else if !temDados}
<p class="nota">Cada dia aparece aqui depois da meia-noite. Com 2 ou 3 semanas ele separa bem um dia do outro.</p>
{: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>
{/if}
</section>
<section class="bloco" aria-labelledby="ap-rotina">
<h3 id="ap-rotina"><CalendarClock size={13} /> A rotina que ele entendeu</h3>
{#if plano}
<div class="plano">
<span class="selo" class:voce={plano.origem === 'voce'}>{plano.origem === 'voce' ? 'Você contou' : 'A IA percebeu'}</span>
<span class="confere">{confere(plano)}</span>
{#if plano.padroes.length}
<ul class="padroes">
{#each plano.padroes as p (p)}<li>{p}</li>{/each}
</ul>
{/if}
<button class="btn fantasma pequeno" onclick={apagarPlano}>Apagar plano</button>
</div>
{:else}
<p class="nota">Sem plano ainda: ele segue só o que mede em cada dia da semana.</p>
{/if}
{#if a.sugestao}
<div class="plano sugestao">
<span class="selo">Sugestão da IA</span>
<span class="confere">{confere(a.sugestao)}</span>
<ul class="padroes">
{#each a.sugestao.padroes as p (p)}<li>{p}</li>{/each}
</ul>
<button class="btn pequeno" onclick={usarSugestao}>Usar este plano</button>
</div>
{/if}
<form class="contar" onsubmit={enviarRotina}>
<label for="ap-contar">Conte sua rotina</label>
<textarea
id="ap-contar"
rows="2"
maxlength="600"
placeholder="Ex.: fico fora de terça a quinta, das 8h às 18h. Segunda, sexta e fim de semana fico em casa."
bind:value={rotina}
></textarea>
<div class="acoes">
<button class="btn pequeno" type="submit" disabled={ocupado || rotina.trim().length < 5 || !a.analise.pode}>Ensinar</button>
<button
class="btn fantasma pequeno"
type="button"
onclick={analisarSemana}
disabled={ocupado || faltamDias > 0 || !a.analise.pode}
title={faltamDias > 0 ? `Faltam ${faltamDias} dias observados` : ''}
>
{ocupado && a.analise.tipo === 'analise' ? 'Analisando…' : 'Analisar a semana agora'}
</button>
{#if ocupado && a.analise.tipo === 'rotina'}<span class="nota">Entendendo sua rotina…</span>{/if}
</div>
{#if a.analise.erro}
<p class="erro" role="alert">{a.analise.erro}</p>
{:else if !a.analise.pode}
<p class="nota">A IA escolhida não está pronta. Confira a chave do Gemini ou a IA local nos Ajustes.</p>
{:else if faltamDias > 0}
<p class="nota">A análise da IA libera com {a.analise.minimo} dias observados (faltam {faltamDias}). Contar a rotina já funciona.</p>
{:else if a.analise.em}
<p class="nota">Última análise: {quando(a.analise.em)}</p>
{/if}
</form>
<div class="ajustes-ia">
<Interruptor rotulo="Analisar todo dia com IA" checked={a.config.analise_ia} onchange={(l) => configurarAprendizado({ analise_ia: l })} />
<span>Analisar todo dia com</span>
<select aria-label="IA da análise" value={a.config.ia} onchange={(e) => configurarAprendizado({ ia: e.currentTarget.value as any })}>
{#each IAS as ia (ia.valor)}<option value={ia.valor}>{ia.nome}</option>{/each}
</select>
</div>
</section>
{#if faz_mais.length || faz_menos.length}
<section class="bloco gosto" aria-labelledby="ap-gosto">
<section class="bloco" aria-labelledby="ap-gosto">
<h3 id="ap-gosto">Do que vocês gostam</h3>
<div class="colunas">
<ul>
@@ -114,11 +250,11 @@
<div class="esquecer">
{#if confirmar}
<span>Apagar rotina, gostos e lições?</span>
<span>Apagar semana, plano, gostos e lições?</span>
<button class="btn pequeno perigo" onclick={() => { confirmar = false; esquecerAprendizado(); }}>Esquecer tudo</button>
<button class="btn fantasma pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!a.dias && !a.gosto.length && !a.licoes.length}>
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!a.dias && !a.gosto.length && !a.licoes.length && !plano}>
Esquecer o que aprendeu
</button>
{/if}
@@ -136,17 +272,28 @@
.vazio {
padding: var(--e-3) var(--e-4);
}
.erro {
margin: 0;
color: var(--perigo);
font-size: var(--t-xs);
}
.bloco {
display: grid;
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
border-top: 1px solid var(--borda);
}
h3 {
.cabeca {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--e-2);
}
h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: var(--t-xs);
font-weight: 600;
@@ -154,56 +301,164 @@
text-transform: uppercase;
color: var(--texto-3);
}
h3 span {
font-weight: 400;
letter-spacing: 0;
text-transform: none;
.segmentos {
display: inline-flex;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.rotina {
.segmentos button {
min-height: 26px;
padding: 0 var(--e-2);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.segmentos button.ativo {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.quadro-rolagem {
overflow-x: auto;
}
.quadro {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
align-items: end;
grid-template-columns: 2.2em repeat(24, minmax(9px, 1fr));
gap: 2px;
height: 44px;
min-width: 280px;
align-items: center;
}
.hora {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
border-radius: 3px;
.dia {
font-size: 10px;
color: var(--texto-3);
}
.dia.hoje {
color: var(--texto);
font-weight: 600;
}
.celula {
height: 14px;
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;
}
.modo-plano .celula {
background: var(--superficie-2);
}
.barra {
display: block;
width: 100%;
height: calc(var(--v) * 100%);
min-height: 2px;
border-radius: 3px;
.modo-plano .celula.casa,
.ponto.casa {
background: #30b0c7;
transition: height 600ms ease;
}
.sem .barra {
background: transparent;
.modo-plano .celula.fora,
.ponto.fora {
background: color-mix(in srgb, var(--texto-3) 35%, var(--superficie-2));
}
.agora {
outline: 1.5px solid var(--texto-2);
.modo-plano .celula.dormindo,
.ponto.dormindo {
background: #5e5ce6;
}
.celula.agora {
outline: 1.5px solid var(--texto);
outline-offset: 1px;
}
.lua {
position: absolute;
top: 1px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
line-height: 1;
color: #5e5ce6;
}
.eixo {
display: flex;
justify-content: space-between;
.hora {
font-size: 9px;
color: var(--texto-3);
font-size: 10px;
white-space: nowrap;
}
.legenda {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--e-2);
margin: 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.ponto {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.plano {
display: grid;
gap: 6px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
justify-items: start;
}
.plano.sugestao {
background: transparent;
border: 1px dashed var(--borda);
}
.selo {
font-size: var(--t-xs);
font-weight: 600;
color: #30b0c7;
}
.selo.voce {
color: var(--texto);
}
.confere {
font-size: var(--t-xs);
color: var(--texto-3);
}
.padroes {
display: grid;
gap: 4px;
margin: 0;
padding-left: 1.1em;
font-size: var(--t-sm);
color: var(--texto);
}
.contar {
display: grid;
gap: 6px;
}
.contar label {
font-size: var(--t-xs);
color: var(--texto-2);
}
.contar textarea {
width: 100%;
box-sizing: border-box;
padding: var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie);
color: var(--texto);
font: var(--t-sm) var(--fonte);
resize: vertical;
}
.acoes,
.ajustes-ia {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--e-2);
}
.ajustes-ia {
font-size: var(--t-sm);
color: var(--texto-2);
}
.ajustes-ia select {
width: auto;
flex: none;
min-height: 28px;
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie);
color: var(--texto);
font: var(--t-sm) var(--fonte);
}
.colunas {
display: grid;
@@ -224,21 +479,21 @@
gap: var(--e-2);
color: var(--texto-2);
}
.colunas .titulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.colunas b {
font-weight: 600;
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.mais b {
color: var(--ok, #34c759);
color: var(--ok);
}
.menos b {
color: var(--texto-3);
}
.colunas .titulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.nada {
color: var(--texto-3);
font-size: var(--t-xs);
@@ -275,9 +530,4 @@
.esquecer span {
padding-left: var(--e-1);
}
@media (prefers-reduced-motion: reduce) {
.barra {
transition: none;
}
}
</style>
+6 -3
View File
@@ -60,13 +60,16 @@ export const TOPICOS: Record<string, TopicoGuia> = {
emoji: '🎓',
onde: 'Ao vivo › O que ele aprendeu (com o modo vida ligado)',
resumo:
'Ele observa o próprio dia e melhora aos poucos, sem IA e sem internet: conta o que acontece em casa e ajusta o modo vida. À meia-noite fecha o dia, escreve as lições no diário e o que ficou para trás passa a pesar menos, porque o gosto das pessoas muda.',
'Ele observa o próprio dia e melhora aos poucos: conta o que acontece em casa, ajusta o modo vida e, uma vez por dia, uma IA cruza a semana para entender a rotina. À meia-noite fecha o dia, escreve as lições no diário e o que ficou para trás passa a pesar menos, porque o gosto das pessoas muda.',
itens: [
{ emoji: '❤️', nome: 'Do que vocês gostam', texto: 'Cada gesto ou atividade que ele faz com alguém por perto é uma tentativa. Carinho, brincadeira ou conversa até 1 minuto depois contam como "gostaram" (cutucada conta meio). Os que agradam passam a sair até 2 vezes mais; os ignorados, até a metade. Precisa de pelo menos 3 tentativas para ele formar opinião.' },
{ emoji: '🏠', nome: 'Rotina da casa', texto: 'Em que horas costuma ter gente por perto (pelo PIR e pela câmera). As 24 barras mostram cada hora. Quando a próxima hora costuma ser movimentada e a atual vazia, ele acorda do cochilo 15 minutos antes para estar de pé quando você chegar. Sono pesado e madrugada continuam respeitados.' },
{ emoji: '🗓️', nome: 'Semana da casa', texto: 'Quanto do tempo tem gente por perto (pelo PIR e pela câmera) em cada dia da semana e hora: quanto mais forte a cor, mais gente. Cada dia da semana aparece uma vez por semana, então com 2 ou 3 semanas ele separa bem uma segunda de uma terça.' },
{ emoji: '🧠', nome: 'Análise com IA', texto: 'Depois da meia-noite, com uma semana observada, a IA escolhida (Gemini, IA local ou o Qwen do S10) cruza os dias: quais são parecidos, quando a casa fica vazia, que dia foge da regra. Ela escreve o que percebeu e propõe um plano da semana. O plano só é usado se conferir com pelo menos 70% do que os sensores mediram; senão fica como sugestão, e você decide.' },
{ emoji: '✍️', nome: 'Conte sua rotina', texto: 'Escreva do seu jeito ("fico fora de terça a quinta, das 8h às 18h") e a IA transforma em plano na hora, sem esperar semanas. O que você contou vale sobre o que a IA acha, e o cartão mostra o quanto confere com o que ele vê.' },
{ 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: 'Lições', texto: 'O que ele concluiu em cada dia, em primeira pessoa. Também aparecem no diário do cartão Vida.' },
{ emoji: '🧽', nome: 'Esquecer', texto: 'Apaga rotina, gostos e lições, e ele começa do zero. Desligar o aprendizado só para de usar o que aprendeu, sem apagar.' },
{ 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.',
},
+19 -1
View File
@@ -108,8 +108,26 @@ export interface Vida {
}
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
export type TipoPeriodo = 'casa' | 'fora' | 'dormindo';
/** Plano da semana: 7 dias (0 = segunda) com períodos. confere = quanto bate com o medido (null = poucos dados). */
export interface PlanoSemana {
origem: 'ia' | 'voce';
criado: number;
padroes: string[];
dias: { ini: number; fim: number; tipo: TipoPeriodo }[][];
confere: number | null;
}
export interface Aprendizado {
config: { ligado: boolean };
config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10' };
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
semana: (number | null)[][];
historico_dias: number;
plano: PlanoSemana | null;
/** Plano da IA que não bateu com os dados (ou que não substitui o que você contou). */
sugestao: PlanoSemana | null;
analise: { ocupado: boolean; erro: string; em: number | null; tipo: '' | 'analise' | 'rotina'; pode: boolean; minimo: number };
/** Dias inteiros observados. */
dias: number;
/** 24 horas: chance de ter gente por perto (null = ainda sem dados nessa hora). */