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:
+287
-10
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user