feat(vida): aprendizado com o dia a dia (gosto, rotina da casa e sono)

O Carinha passa a melhorar as interações com o que vive em casa, sem IA
e sem internet (aprendizado.py):
- gosto: gesto ou atividade feito com alguém por perto é uma tentativa;
  carinho, brincadeira ou conversa até 60 s depois contam como "gostaram"
  (cutucada vale meio). A chance de cada ato vai de 0,5x a 2x, relativa à
  média, e só depois de 3 tentativas;
- rotina: fração de minutos com gente em cada hora (média móvel entre
  dias). Antes de uma hora movimentada, acorda do cochilo 15 min antes;
  sono pesado e madrugada continuam respeitados;
- sono: PIR acordou e ninguém ficou nem interagiu em 3 min = à toa. Se
  repete na mesma hora, ali pede 1 ou 2 disparos a mais para acordar;
- à meia-noite fecha o dia: lições em primeira pessoa no diário e o
  passado pesa 15% menos a cada dia (o gosto muda);
- rotas /aprendizado/status|config|esquecer e SSE 'aprendizado';
- painel: cartão "O que ele aprendeu" no Ao vivo (rotina em 24 barras,
  faz mais / faz menos, lições, esquecer com confirmação) e tópico no
  guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 10:13:23 -03:00
co-authored by Claude Opus 5.5
parent 4777ceb023
commit 24625453f7
12 changed files with 1000 additions and 9 deletions
+25
View File
@@ -0,0 +1,25 @@
/** Aprendizado com o dia a dia: ligar/desligar e esquecer. 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) {
const antes = estado.aprendizado;
if (antes) estado.aprendizado = { ...antes, config: { ...antes.config, ligado } };
try {
estado.aprendizado = await postar<Aprendizado>('/aprendizado/config', { ligado });
} catch (e) {
estado.aprendizado = antes;
avisos.erro(e);
}
}
export async function esquecerAprendizado() {
try {
estado.aprendizado = await postar<Aprendizado>('/aprendizado/esquecer', {});
avisos.ok('Ele esqueceu o que tinha aprendido e começa do zero');
} catch (e) {
avisos.erro(e);
}
}
@@ -0,0 +1,283 @@
<script lang="ts">
import { GraduationCap } from '@lucide/svelte';
import { configurarAprendizado, esquecerAprendizado } from '../aprendizado.svelte';
import { estado } from '../estado.svelte';
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.
const a = $derived(estado.aprendizado);
let confirmar = $state(false);
const horaAgora = $derived(new Date(estado.agora).getHours());
const temRotina = $derived(!!a && a.rotina.some((v) => v !== null));
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 detalhe = $derived.by(() => {
if (!a) return '';
if (!a.config.ligado) return 'Desligado: ele faz tudo como veio de fábrica';
const dias = a.dias === 0 ? 'primeiro dia observando' : a.dias === 1 ? '1 dia observado' : `${a.dias} dias observados`;
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 dia(iso: string) {
const [, m, d] = iso.split('-');
return `${d}/${m}`;
}
function vezes(peso: number) {
return '×' + peso.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
}
</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."
>
{#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)} />
{/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}
</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>
{/if}
</section>
{#if faz_mais.length || faz_menos.length}
<section class="bloco gosto" aria-labelledby="ap-gosto">
<h3 id="ap-gosto">Do que vocês gostam</h3>
<div class="colunas">
<ul>
<li class="titulo">Faz mais</li>
{#each faz_mais as g (g.id)}
<li class="mais"><span>{g.nome}</span><b class="mono">{vezes(g.peso)}</b></li>
{:else}
<li class="nada">Nada se destacou ainda</li>
{/each}
</ul>
<ul>
<li class="titulo">Faz menos</li>
{#each faz_menos as g (g.id)}
<li class="menos"><span>{g.nome}</span><b class="mono">{vezes(g.peso)}</b></li>
{:else}
<li class="nada">Nada foi ignorado</li>
{/each}
</ul>
</div>
</section>
{/if}
<section class="bloco" aria-labelledby="ap-licoes">
<h3 id="ap-licoes">Lições</h3>
{#if a.licoes.length}
<ol class="licoes">
{#each a.licoes.slice(0, 5) as l (l.dia + l.texto)}
<li><time class="mono">{dia(l.dia)}</time> <span>{l.texto}</span></li>
{/each}
</ol>
{:else}
<p class="nota">A primeira lição chega à meia-noite. Faça carinho ou converse logo depois de um gesto que você gostou.</p>
{/if}
</section>
{/if}
<div class="esquecer">
{#if confirmar}
<span>Apagar rotina, 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}>
Esquecer o que aprendeu
</button>
{/if}
</div>
{/if}
</Grupo>
<style>
.vazio,
.nota {
margin: 0;
color: var(--texto-3);
font-size: var(--t-xs);
}
.vazio {
padding: var(--e-3) var(--e-4);
}
.bloco {
display: grid;
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
border-top: 1px solid var(--borda);
}
h3 {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--e-2);
margin: 0;
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--texto-3);
}
h3 span {
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.rotina {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
align-items: end;
gap: 2px;
height: 44px;
}
.hora {
position: relative;
display: flex;
align-items: flex-end;
height: 100%;
border-radius: 3px;
background: var(--superficie-2);
}
.barra {
display: block;
width: 100%;
height: calc(var(--v) * 100%);
min-height: 2px;
border-radius: 3px;
background: #30b0c7;
transition: height 600ms ease;
}
.sem .barra {
background: transparent;
}
.agora {
outline: 1.5px solid var(--texto-2);
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;
color: var(--texto-3);
font-size: 10px;
}
.colunas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: var(--e-2) var(--e-4);
}
.colunas ul {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--t-sm);
}
.colunas li {
display: flex;
justify-content: space-between;
gap: var(--e-2);
color: var(--texto-2);
}
.colunas b {
font-weight: 600;
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.mais b {
color: var(--ok, #34c759);
}
.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);
}
.licoes {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
font-size: var(--t-sm);
}
.licoes li {
display: flex;
gap: var(--e-2);
color: var(--texto);
}
.licoes time {
flex: none;
padding-top: 2px;
color: var(--texto-3);
font-size: var(--t-xs);
}
.esquecer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3) var(--e-3);
border-top: 1px solid var(--borda);
font-size: var(--t-sm);
color: var(--texto-2);
}
.esquecer span {
padding-left: var(--e-1);
}
@media (prefers-reduced-motion: reduce) {
.barra {
transition: none;
}
}
</style>
@@ -296,6 +296,9 @@
.diario li.emoji {
color: #af52de;
}
.diario li.aprendizado {
color: #30b0c7;
}
.diario li.cuidado {
color: var(--texto);
}
+5 -2
View File
@@ -4,7 +4,7 @@
*/
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
import type { Aprendizado, AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -34,6 +34,7 @@ class Estado {
treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null);
vida = $state<Vida | null>(null);
aprendizado = $state<Aprendizado | null>(null);
feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
@@ -110,6 +111,7 @@ function aplicarFoto(f: EstadoServidor) {
if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api;
if (f.vida) estado.vida = f.vida;
if (f.aprendizado) estado.aprendizado = f.aprendizado;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
}
@@ -158,6 +160,7 @@ const tratadores: Record<string, (d: any) => void> = {
if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto);
estado.vida = d;
},
aprendizado: (d: Aprendizado) => (estado.aprendizado = d),
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
historico: (d) => {
const n = d?.novo;
@@ -185,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao', 'inspiracao']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+16 -1
View File
@@ -55,6 +55,21 @@ export const TOPICOS: Record<string, TopicoGuia> = {
],
dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.',
},
aprendizado: {
titulo: 'O que ele aprendeu',
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.',
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: '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.' },
],
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.',
},
cuidados: {
titulo: 'Cuidar do Carinha',
emoji: '❤️',
@@ -334,7 +349,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
+17 -1
View File
@@ -83,6 +83,7 @@ export interface EstadoServidor {
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
vida?: Vida;
aprendizado?: Aprendizado;
}
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
@@ -103,5 +104,20 @@ export interface Vida {
sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade'; texto: string }[];
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[];
}
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
export interface Aprendizado {
config: { ligado: boolean };
/** Dias inteiros observados. */
dias: number;
/** 24 horas: chance de ter gente por perto (null = ainda sem dados nessa hora). */
rotina: (number | null)[];
/** 24 horas: disparos a mais do PIR para acordar (0 a 2), por ter acordado à toa ali. */
sono: number[];
/** Atos com opinião formada, do mais querido ao mais ignorado. peso multiplica a chance (0,5 a 2). */
gosto: { id: string; nome: string; tentativas: number; agradou: number; peso: number }[];
licoes: { dia: string; texto: string }[];
hoje: { dia: string; atos: number; respostas: Record<string, number>; acordou: number; a_toa: number };
}
+2
View File
@@ -15,6 +15,7 @@
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
@@ -126,6 +127,7 @@ const pirAtivo = $derived(
<div class="lista">
<VidaCartao />
{#if estado.vida?.config.ligado}<AprendizadoCartao />{/if}
<Grupo titulo="Celular" ajuda="celular">
<Celular />