Files
carinha-control-panel/aprendizado.py
T
julioandClaude Opus 5.5 935bf8d586 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>
2026-10-09 10:36:32 -03:00

641 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Aprendizado: o Carinha melhora as interações com o que vive no dia a dia.
Sem IA e sem internet: só conta o que acontece em casa e ajusta o modo vida aos poucos.
- Gosto: cada gesto ou atividade feito com alguém por perto vira uma tentativa; um carinho, uma
brincadeira ou uma conversa logo depois conta como "gostaram". Os que agradam saem mais, os
ignorados saem menos (no máximo o dobro ou a metade da chance).
- Rotina: em que horas costuma ter gente por perto. Um pouco antes da hora de sempre, ele acorda
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, 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
MEMORIA_ROTINA = 0.3 # quanto um dia novo pesa na rotina aprendida
MIN_TENTATIVAS = 3 # com menos que isso, ainda não tem opinião sobre um ato
FAIXA = (0.5, 2.0) # quanto o gosto pode mudar a chance de um ato
FALSO_ALARME = 180 # s que esperar para ver se acordar pelo sensor valeu a pena
PRESENCA_FICOU = 0.3 # fração da espera com alguém por perto para o acordar ter valido
MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotina
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',
'coracao': 'coraçãozinho', 'rir': 'dar risada', 'comemorar': 'comemorar', 'olhar_esq': 'olhar em volta',
'olhar_dir': 'olhar em volta', 'piscar2': 'piscar devagar', 'pensar': 'ficar pensativo', 'timido': 'ficar tímido',
'espiar': 'espiar', 'bocejar': 'bocejar', 'confuso': 'ficar confuso', 'triste': 'ficar tristinho',
'olhar_baixo': 'olhar para baixo', 'acenar': 'acenar', 'surpreso': 'fazer cara de "oi!"',
'esconder': 'esconder o rosto', 'bravo': 'ficar emburrado', 'tonto': 'ficar tonto',
'ler': 'ler', 'cafe': 'tomar café', 'meditar': 'meditar', 'telescopio': 'olhar as estrelas', 'pescar': 'pescar',
'computador': 'mexer no computador', 'regar': 'regar a plantinha', 'bola': 'jogar bola', 'malhar': 'malhar',
'corda': 'pular corda', 'skate': 'andar de skate', 'dancar': 'dançar', 'pipa': 'soltar pipa', 'pintar': 'pintar',
'violao': 'tocar violão', 'cantar': 'cantar', 'cozinhar': 'cozinhar', 'bolhas': 'fazer bolhas de sabão',
'videogame': 'jogar videogame', 'dormir': 'cochilar',
}
def _dia(agora):
return time.strftime('%Y-%m-%d', time.localtime(agora))
def _hora(agora):
return time.localtime(agora).tm_hour
def _faixas(horas):
"""[18, 19, 20, 22] -> [(18, 21), (22, 23)]: horas seguidas viram uma faixa (fim aberto)."""
faixas = []
for h in sorted(horas):
if faixas and faixas[-1][1] == h:
faixas[-1] = (faixas[-1][0], h + 1)
else:
faixas.append((h, h + 1))
return faixas
def _texto_faixas(faixas):
partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas]
return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1]
class Aprendizado:
def __init__(self, caminho, nomes=None):
self.caminho = Path(caminho)
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.anotar = lambda tipo, texto, agora=None: None # diário do modo vida
self.nomes = nomes or (lambda ident: None) # nome das atividades criadas por IA
self.config = dict(PADRAO)
self.itens = {} # nome -> {'t': tentativas, 's': agradou}
self.rotina = [None] * 24 # chance de ter gente por perto em cada hora
self.falsos = [0.0] * 24 # acordou à toa nessa hora (média dos dias)
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', {})))
self.itens = {k: dict(t=float(v['t']), s=float(v['s'])) for k, v in salvo.get('itens', {}).items()}
if len(salvo.get('rotina', [])) == 24:
self.rotina = [None if v is None else float(v) for v in salvo['rotina']]
if len(salvo.get('falsos', [])) == 24:
self.falsos = [float(v) for v in salvo['falsos']]
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):
pass
@staticmethod
def _dia_vazio(dia):
return dict(dia=dia, observado=[0] * 24, presenca=[0] * 24, falsos=[0] * 24, acordou=[0] * 24,
atos=0, respostas={}, contagem={})
@staticmethod
def validar(dados):
if not isinstance(dados, dict):
raise ValueError('Esperava um objeto')
for chave, valor in dados.items():
if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave)
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)
def configurar(self, dados):
limpo = self.validar(dados)
with self.lock:
self.config.update(limpo)
self.salvar()
self.avisar()
return self.status()
def esquecer(self):
with self.lock:
self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24
self.semana, self.semana_n = [[None] * 24 for _ in range(7)], [[0] * 24 for _ in range(7)]
self.historico, self.plano, self.sugestao = [], None, None
self.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')
self.salvar()
self.avisar()
return self.status()
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, 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)
except OSError:
pass
# ------------------------------------------------------------------ o que acontece no dia
def ligado(self):
return self.config['ligado']
def observar(self, agora, perto):
"""A cada tick do modo vida: conta os minutos com gente por perto e fecha o dia à meia-noite."""
if not self.ligado():
return
self.virar_dia(agora)
with self.lock:
self.pendentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA]
if self.acordou_em is not None:
self.presenca_depois[0] += 1 if perto else 0
self.presenca_depois[1] += 1
if agora - self.acordou_em >= FALSO_ALARME:
com_gente, total = self.presenca_depois
if total and com_gente / total < PRESENCA_FICOU:
self.hoje['falsos'][_hora(self.acordou_em)] += 1
self.acordou_em = None
minuto = int(agora // 60)
if minuto == self.minuto:
return
self.minuto = minuto
h = _hora(agora)
self.hoje['observado'][h] += 1
if perto:
self.hoje['presenca'][h] += 1
if agora - self.salvo_em > 300:
self.salvar()
def ato(self, nome, agora, perto):
"""Ele fez um gesto ou uma atividade. Só conta com plateia: sozinho ninguém pode gostar."""
if not self.ligado() or not perto or not nome:
return
with self.lock:
self.pendentes.append((nome, agora))
self.hoje['atos'] += 1
item = self.itens.setdefault(nome, dict(t=0.0, s=0.0))
item['t'] += 1
c = self.hoje['contagem'].setdefault(nome, [0, 0.0])
c[0] += 1
def resposta(self, tipo, agora):
"""Carinho, brincadeira, conversa...: o último ato de pouco antes agradou."""
if not self.ligado() or tipo not in PESO_RESPOSTA:
return
with self.lock:
self.hoje['respostas'][tipo] = self.hoje['respostas'].get(tipo, 0) + 1
if self.acordou_em is not None: # alguém quis ele acordado: valeu a pena
self.acordou_em = None
recentes = [(n, t) for n, t in self.pendentes if agora - t <= JANELA]
if not recentes:
return
nome, _ = recentes[-1]
self.pendentes = [p for p in self.pendentes if p[0] != nome] # cada ato agrada uma vez
item = self.itens.setdefault(nome, dict(t=1.0, s=0.0))
item['s'] = min(item['t'], item['s'] + PESO_RESPOSTA[tipo])
c = self.hoje['contagem'].setdefault(nome, [1, 0.0])
c[1] += PESO_RESPOSTA[tipo]
def acordou_pelo_sensor(self, agora):
if not self.ligado():
return
with self.lock:
self.hoje['acordou'][_hora(agora)] += 1
self.acordou_em = agora
self.presenca_depois = [0, 0]
# ------------------------------------------------------------------ o que muda no comportamento
def peso(self, nome):
"""Multiplica a chance de um ato: acima de 1 se costuma agradar, abaixo se é ignorado."""
if not self.ligado():
return 1.0
with self.lock:
item = self.itens.get(nome)
if not item or item['t'] < MIN_TENTATIVAS:
return 1.0
total_t = sum(i['t'] for i in self.itens.values())
total_s = sum(i['s'] for i in self.itens.values())
media = (total_s + 1) / (total_t + 2)
taxa = (item['s'] + 1) / (item['t'] + 2)
return round(max(FAIXA[0], min(FAIXA[1], taxa / media)), 2)
def extra_pir(self, agora):
"""Disparos a mais do PIR para acordar nesta hora (acordou à toa aqui outras vezes)."""
return self._extra(self.falsos[_hora(agora)]) if self.ligado() else 0
@staticmethod
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)
falta = 3600 - (t.tm_min * 60 + t.tm_sec)
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):
dia = _dia(agora)
if self.hoje['dia'] == dia:
return
with self.lock:
licoes = self.fechar_dia()
self.hoje = self._dia_vazio(dia)
self.pendentes, self.acordou_em = [], None
for texto in licoes:
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:'):
try:
nome = self.nomes(int(ident[7:]))
except (ValueError, TypeError):
nome = None
return 'fazer ' + nome if nome else 'fazer uma atividade nova'
return NOMES.get(ident, ident)
def fechar_dia(self):
"""Junta o dia na memória e devolve as lições (frases curtas, em primeira pessoa)."""
hoje, licoes = self.hoje, []
# rotina: média móvel da fração de minutos com gente em cada hora
for h in range(24):
if hoje['observado'][h] >= MIN_MINUTOS_HORA:
frac = hoje['presenca'][h] / hoje['observado'][h]
antes = self.rotina[h]
self.rotina[h] = round(frac if antes is None else antes + (frac - antes) * MEMORIA_ROTINA, 3)
self.falsos[h] = round(self.falsos[h] + (hoje['falsos'][h] - self.falsos[h]) * MEMORIA_ROTINA, 3)
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)
if gostou:
nomes = [self.nome(n) for _, n in gostou[:2]]
licoes.append('Aprendi que vocês gostam de me ver ' + ' e '.join(nomes) + '. Vou fazer mais!')
ignorado = sorted(((t, n) for n, (t, s) in contagem.items() if t >= 4 and s == 0), reverse=True)
if ignorado and sum(hoje['respostas'].values()):
licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0]))
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 and not self.plano:
cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA]
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, 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:
n, h = piores[0]
licoes.append('Acordei à toa %d vezes por volta das %dh. Nessa hora vou precisar de mais movimento para acordar.'
% (n, h))
# o gosto muda: o passado pesa menos a cada dia
for nome in list(self.itens):
item = self.itens[nome]
item['t'] = round(item['t'] * ESQUECE, 3)
item['s'] = round(item['s'] * ESQUECE, 3)
if item['t'] < 0.5:
del self.itens[nome]
for texto in licoes:
self.licoes.insert(0, dict(dia=hoje['dia'], texto=texto))
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:
gosto = []
for nome, item in self.itens.items():
if item['t'] >= MIN_TENTATIVAS:
gosto.append(dict(id=nome, nome=self.nome(nome), tentativas=round(item['t'], 1),
agradou=round(item['s'], 1), peso=self.peso(nome)))
gosto.sort(key=lambda g: -g['peso'])
hoje = self.hoje
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'])))
def avisar(self):
self.publicar('aprendizado', self.status())
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/aprendizado/'):
return False
try:
if metodo == 'GET' and rota == '/aprendizado/status':
resultado = self.status()
elif metodo == 'POST' and rota == '/aprendizado/config':
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))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True