diff --git a/aprendizado.py b/aprendizado.py index c7aaf63..112a583 100644 --- a/aprendizado.py +++ b/aprendizado.py @@ -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)) diff --git a/kiosk-server.py b/kiosk-server.py index c01b542..af00cf1 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -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" diff --git a/tests/test_aprendizado.py b/tests/test_aprendizado.py index d09130e..a9140b7 100644 --- a/tests/test_aprendizado.py +++ b/tests/test_aprendizado.py @@ -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: diff --git a/vida.py b/vida.py index fe28345..2ab4640 100644 --- a/vida.py +++ b/vida.py @@ -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() diff --git a/web/src/lib/aprendizado.svelte.ts b/web/src/lib/aprendizado.svelte.ts index 2f6c949..79d5ad3 100644 --- a/web/src/lib/aprendizado.svelte.ts +++ b/web/src/lib/aprendizado.svelte.ts @@ -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) { 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/config', { ligado }); + estado.aprendizado = await postar('/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/esquecer', {}); - avisos.ok('Ele esqueceu o que tinha aprendido e começa do zero'); + estado.aprendizado = await postar(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'); diff --git a/web/src/lib/componentes/AprendizadoCartao.svelte b/web/src/lib/componentes/AprendizadoCartao.svelte index c36f64f..f9d15a8 100644 --- a/web/src/lib/componentes/AprendizadoCartao.svelte +++ b/web/src/lib/componentes/AprendizadoCartao.svelte @@ -1,20 +1,45 @@ {#if !a}

Carregando…

{:else} {#snippet acessorio()} - configurarAprendizado(l)} /> + configurarAprendizado({ ligado: l })} /> {/snippet} {#if a.config.ligado} -
-

Rotina da casa {temRotina ? resumoRotina : 'aparece depois da primeira meia-noite'}

-