Todo dia, a partir das 9h, o Carinha cria 3 atividades novas (inspiracao.py): - sinais de fontes abertas: tempo da cidade (Open-Meteo), datas comemorativas da semana (lista fixa + feriados da BrasilAPI), "neste dia" da Wikipédia e assuntos em alta no Google Trends Brasil; - o Gemini escolhe ideias leves (descarta tragédia, política e afins) e cada uma vira atividade pelo estúdio (Criacao.gerar), com motivo e uma curiosidade; - até 3 tentativas por dia, 30 min entre elas; botão "Criar mais agora"; - as atividades criadas entram na rotina do modo vida (as de hoje com 40% de chance) e, com alguém perto, ele conta a curiosidade: legenda e voz de robô (ordem "aprendi", até 2x por dia cada); - painel: cartão "Ideias do dia" no estúdio (Mostrar, Contar, o que ele viu na internet, ajustes: quantas, horário, cidade, fontes) e tópico no guia. Correções achadas no caminho: - WebView de fábrica do S10 (Chrome 102) não tem AbortSignal.timeout: o celular nunca mandava o status e a placa o via offline; - arquivos estáticos com Cache-Control no-cache e o app com LOAD_NO_CACHE: o WebView seguia com uma carinha.html velha depois de deploy; - status do celular recusado vai para o journal (1 linha por minuto); - legenda mais estreita para não cobrir a bateria. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
534 lines
31 KiB
Python
534 lines
31 KiB
Python
"""Estúdio de criação: expressões, atividades e figurinhas feitas por IA a partir de um pedido.
|
||
|
||
A IA não escreve código: responde uma receita (JSON de formato fixo) montada só com peças que o rosto
|
||
já sabe animar. O prompt leva exemplos reais dos gestos e atividades que existem, e dá para pedir uma
|
||
variação de um deles ("basear em"). Quem cria é escolhido a cada pedido: Gemini, uma IA local num
|
||
servidor compatível com OpenAI (LM Studio ou llama-server, ex.: o ROG Ally) ou o Qwen pequeno do S10.
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
from contextlib import contextmanager
|
||
from pathlib import Path
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
import gemini
|
||
from comum import gravar_atomico, ler_json
|
||
from figurinhas import montar_prompt as prompt_figurinha
|
||
from figurinhas import validar_desenho
|
||
|
||
TIPOS = ('expressao', 'atividade', 'figurinha')
|
||
PROVEDORES = {'gemini': 'Gemini', 'local': 'IA local', 's10': 'Qwen do S10'}
|
||
MAX_CRIACOES = 300
|
||
|
||
# ---------------------------------------------------------------- peças disponíveis
|
||
FORMAS = ['aberto', 'feliz', 'surpreso', 'triste', 'bravo', 'coracao', 'dormir']
|
||
BOCAS = ['neutra', 'sorriso', 'aberta', 'triste', 'brava', 'sono']
|
||
CORES = ['escolhida', 'claro', 'escuro', 'rosa', 'amarelo', 'verde', 'vermelho']
|
||
MAOS = ['nenhuma', 'esconder', 'espiar', 'acenar', 'pensar', 'bocejar']
|
||
OBJETOS = ['bola', 'livro', 'xicara', 'nota', 'estrela', 'coracao', 'zzz', 'chama', 'bolha', 'halter',
|
||
'microfone', 'controle', 'pincel', 'flor']
|
||
ONDE = ['mao_direita', 'mao_esquerda', 'maos', 'chao', 'acima', 'em_volta']
|
||
MOVIMENTOS = ['parado', 'pular', 'girar', 'subir', 'orbitar', 'piscar', 'balancar']
|
||
PERNAS = ['paradas', 'marchando', 'chutando', 'dancando', 'pulando']
|
||
POSTURAS = ['em_pe', 'sentado']
|
||
OLHARES = ['objeto', 'frente', 'cima', 'baixo', 'lado']
|
||
|
||
|
||
def _s(**kw):
|
||
return dict(type='string', **kw)
|
||
|
||
|
||
def _n():
|
||
return dict(type='number')
|
||
|
||
|
||
def _obj(campos):
|
||
return dict(type='object', properties=campos, required=list(campos))
|
||
|
||
|
||
PASSO = _obj(dict(forma=_s(enum=FORMAS), boca=_s(enum=BOCAS), cor=_s(enum=CORES), dx=_n(), dy=_n(), esc=_n(),
|
||
ab=_n(), duracao=_n()))
|
||
SCHEMA_EXPRESSAO = _obj(dict(nome=_s(), emoji=_s(), maos=_s(enum=MAOS), espiral=dict(type='boolean'),
|
||
passos=dict(type='array', items=PASSO)))
|
||
BRACO = _obj(dict(de=_n(), ate=_n()))
|
||
OBJETO = _obj(dict(tipo=_s(enum=OBJETOS), onde=_s(enum=ONDE), movimento=_s(enum=MOVIMENTOS), quantidade=_n()))
|
||
SCHEMA_ATIVIDADE = _obj(dict(
|
||
nome=_s(), emoji=_s(), postura=_s(enum=POSTURAS), duracao=_n(), ritmo=_n(), balanco=_n(), pulo=_n(),
|
||
braco_esquerdo=BRACO, braco_direito=BRACO, cotovelos=_n(), alternados=dict(type='boolean'),
|
||
pernas=_s(enum=PERNAS), objetos=dict(type='array', items=OBJETO), olhar=_s(enum=OLHARES),
|
||
forma=_s(enum=FORMAS), boca=_s(enum=BOCAS)))
|
||
SCHEMA_FIGURINHA = _obj(dict(nome=_s(), local=_s(enum=['olhos', 'ao_redor']),
|
||
formas=dict(type='array', items=_obj(dict(caminho=_s(), cor=_s()))), emoji=_s()))
|
||
|
||
|
||
# ---------------------------------------------------------------- exemplos reais (o que já existe)
|
||
def _p(forma='aberto', boca='neutra', cor='escolhida', dx=0, dy=0, esc=1.0, ab=1.0, duracao=0.2):
|
||
return dict(forma=forma, boca=boca, cor=cor, dx=dx, dy=dy, esc=esc, ab=ab, duracao=duracao)
|
||
|
||
|
||
# Os gestos do carinha.html escritos no formato de receita: servem de exemplo e de base.
|
||
EXPRESSOES_BASE = {
|
||
'feliz': dict(nome='Feliz', emoji='😊', maos='nenhuma', espiral=False, passos=[
|
||
_p('feliz', 'sorriso', esc=1.05, duracao=0.16), _p('feliz', 'sorriso', esc=1.16, duracao=0.18),
|
||
_p('feliz', 'sorriso', esc=1.02, duracao=0.16), _p('feliz', 'sorriso', esc=1.13, duracao=0.16), _p(duracao=0.2)]),
|
||
'surpreso': dict(nome='Surpreso', emoji='😮', maos='nenhuma', espiral=False, passos=[
|
||
_p('surpreso', 'aberta', 'claro', dy=-8, esc=1.3, ab=1.12, duracao=0.09),
|
||
_p('surpreso', 'aberta', 'claro', dy=-10, esc=1.3, ab=1.12, duracao=0.5), _p(duracao=0.25)]),
|
||
'timido': dict(nome='Tímido', emoji='😳', maos='nenhuma', espiral=False, passos=[
|
||
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=0.35),
|
||
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=1.2), _p(duracao=0.4)]),
|
||
'comemorar': dict(nome='Comemorar', emoji='🥳', maos='nenhuma', espiral=False, passos=[
|
||
_p('feliz', 'sorriso', 'amarelo', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', 'rosa', dy=2, esc=1.15, duracao=0.15),
|
||
_p('feliz', 'sorriso', 'verde', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', dy=2, esc=1.15, duracao=0.15),
|
||
_p(duracao=0.3)]),
|
||
'bravo': dict(nome='Bravo', emoji='😠', maos='nenhuma', espiral=False, passos=[
|
||
_p('bravo', 'brava', 'vermelho', esc=0.98, duracao=0.1), _p('bravo', 'brava', 'vermelho', dx=-7, duracao=0.06),
|
||
_p('bravo', 'brava', 'vermelho', dx=7, duracao=0.06), _p('bravo', 'brava', 'vermelho', dx=0, duracao=0.06),
|
||
_p(duracao=0.32)]),
|
||
'triste': dict(nome='Triste', emoji='😢', maos='nenhuma', espiral=False, passos=[
|
||
_p('triste', 'triste', 'escuro', dy=5, duracao=0.18), _p('triste', 'triste', 'escuro', dy=7, duracao=0.5), _p(duracao=0.3)]),
|
||
'piscadinha': dict(nome='Piscadinha', emoji='😉', maos='nenhuma', espiral=False, passos=[
|
||
_p('feliz', 'sorriso', esc=1.04, duracao=0.12), _p('feliz', 'sorriso', esc=1.08, duracao=0.5), _p(duracao=0.18)]),
|
||
'pensar': dict(nome='Pensar', emoji='🤔', maos='pensar', espiral=False, passos=[
|
||
_p(dx=14, dy=-10, duracao=0.4), _p(dx=14, dy=-10, duracao=2.0), _p(duracao=0.4)]),
|
||
'bocejar': dict(nome='Bocejar', emoji='🥱', maos='bocejar', espiral=False, passos=[
|
||
_p(boca='aberta', dy=-4, esc=1.05, ab=0.5, duracao=0.5), _p(boca='aberta', dy=-5, esc=1.05, ab=0.25, duracao=1.0),
|
||
_p(dy=2, ab=0.0, duracao=0.25), _p(duracao=0.35)]),
|
||
'tonto': dict(nome='Tonto', emoji='😵💫', maos='nenhuma', espiral=True, passos=[
|
||
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2),
|
||
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2), _p(duracao=0.35)]),
|
||
}
|
||
|
||
# As atividades desenhadas à mão, descritas no formato de receita (aproximação): exemplo e base.
|
||
ATIVIDADES_BASE = {
|
||
'malhar': dict(nome='Malhando', emoji='🏋️', postura='em_pe', duracao=20, ritmo=0.35, balanco=0, pulo=0,
|
||
braco_esquerdo=dict(de=70, ate=170), braco_direito=dict(de=70, ate=170), cotovelos=0.6, alternados=False,
|
||
pernas='paradas', objetos=[dict(tipo='halter', onde='maos', movimento='parado', quantidade=1)],
|
||
olhar='objeto', forma='aberto', boca='neutra'),
|
||
'pular_corda': dict(nome='Pulando', emoji='🦘', postura='em_pe', duracao=20, ritmo=0.85, balanco=0, pulo=0.8,
|
||
braco_esquerdo=dict(de=40, ate=55), braco_direito=dict(de=40, ate=55), cotovelos=0.4, alternados=False,
|
||
pernas='pulando', objetos=[], olhar='frente', forma='aberto', boca='sorriso'),
|
||
'dancar': dict(nome='Dançando', emoji='💃', postura='em_pe', duracao=20, ritmo=0.8, balanco=0.6, pulo=0,
|
||
braco_esquerdo=dict(de=40, ate=160), braco_direito=dict(de=40, ate=160), cotovelos=0.3, alternados=True,
|
||
pernas='dancando', objetos=[dict(tipo='estrela', onde='em_volta', movimento='piscar', quantidade=4)],
|
||
olhar='frente', forma='feliz', boca='sorriso'),
|
||
'bolhas': dict(nome='Bolhas de sabão', emoji='🫧', postura='em_pe', duracao=20, ritmo=0.3, balanco=0.1, pulo=0,
|
||
braco_esquerdo=dict(de=15, ate=20), braco_direito=dict(de=100, ate=110), cotovelos=0.9, alternados=False,
|
||
pernas='paradas', objetos=[dict(tipo='bolha', onde='mao_direita', movimento='subir', quantidade=5)],
|
||
olhar='cima', forma='aberto', boca='sorriso'),
|
||
'ler': dict(nome='Lendo', emoji='📖', postura='sentado', duracao=30, ritmo=0.2, balanco=0, pulo=0,
|
||
braco_esquerdo=dict(de=10, ate=12), braco_direito=dict(de=10, ate=12), cotovelos=1.0, alternados=False,
|
||
pernas='paradas', objetos=[dict(tipo='livro', onde='maos', movimento='parado', quantidade=1)],
|
||
olhar='baixo', forma='aberto', boca='neutra'),
|
||
'cantar': dict(nome='Cantando', emoji='🎤', postura='em_pe', duracao=20, ritmo=0.7, balanco=0.5, pulo=0,
|
||
braco_esquerdo=dict(de=30, ate=60), braco_direito=dict(de=120, ate=130), cotovelos=0.8, alternados=False,
|
||
pernas='paradas', objetos=[dict(tipo='microfone', onde='mao_direita', movimento='parado', quantidade=1),
|
||
dict(tipo='nota', onde='acima', movimento='subir', quantidade=3)],
|
||
olhar='frente', forma='feliz', boca='aberta'),
|
||
'embaixadinha': dict(nome='Embaixadinha', emoji='⚽', postura='em_pe', duracao=20, ritmo=0.9, balanco=0.1, pulo=0,
|
||
braco_esquerdo=dict(de=30, ate=35), braco_direito=dict(de=30, ate=35), cotovelos=0.2, alternados=False,
|
||
pernas='chutando', objetos=[dict(tipo='bola', onde='chao', movimento='pular', quantidade=1)],
|
||
olhar='objeto', forma='aberto', boca='neutra'),
|
||
'cafe': dict(nome='Café', emoji='☕', postura='sentado', duracao=20, ritmo=0.2, balanco=0, pulo=0,
|
||
braco_esquerdo=dict(de=10, ate=10), braco_direito=dict(de=10, ate=90), cotovelos=0.9, alternados=False,
|
||
pernas='paradas', objetos=[dict(tipo='xicara', onde='mao_direita', movimento='parado', quantidade=1)],
|
||
olhar='objeto', forma='aberto', boca='neutra'),
|
||
}
|
||
|
||
EXPLICA_EXPRESSAO = (
|
||
'O Carinha é um rosto de robô numa tela de celular: dois olhos grandes, uma boca e, às vezes, mãozinhas. '
|
||
'Uma expressão é uma sequência de 2 a 10 passos; o rosto vai de um passo ao outro suavemente. Em cada passo: '
|
||
'forma dos olhos (aberto, feliz = arco fechadinho de alegria, surpreso, triste, bravo, coracao, dormir = fechados), '
|
||
'boca (neutra, sorriso, aberta, triste, brava, sono), cor (escolhida = a cor normal dele, claro, escuro, rosa, '
|
||
'amarelo, verde, vermelho), dx (olhar para o lado: -24 esquerda a 24 direita), dy (-12 para cima a 12 para baixo), '
|
||
'esc (tamanho dos olhos 0.8 a 1.3), ab (abertura: 0 fechado, 1 normal, 1.15 arregalado) e duracao em segundos '
|
||
'(0.06 a 2). A soma dos passos fica entre 1 e 6 segundos. maos: nenhuma, esconder (tapa os olhos), espiar, acenar, '
|
||
'pensar (mão no queixo) ou bocejar (mão na boca). espiral: true só para tontura (espirais no lugar dos olhos). '
|
||
'nome curto em português e um emoji que represente.')
|
||
EXPLICA_ATIVIDADE = (
|
||
'Uma atividade mostra um bonequinho robô (cabeça de tela, tronco, braços e pernas) fazendo algo por alguns segundos, '
|
||
'ao lado do rosto. Ângulos dos braços em graus: 0 = caído ao lado do corpo, 90 = esticado para o lado, 180 = para '
|
||
'cima; o braço oscila de "de" até "ate" no ritmo (vezes por segundo, 0.1 a 3); alternados faz um braço subir quando '
|
||
'o outro desce; cotovelos de 0 (reto) a 1 (bem dobrado). balanco (0 a 1) mexe o corpo de lado; pulo (0 a 1) faz '
|
||
'pular. postura: em_pe ou sentado. pernas: paradas, marchando, chutando, dancando ou pulando. objetos (até 4): '
|
||
'tipo (' + ', '.join(OBJETOS) + '), onde (mao_direita, mao_esquerda, maos, chao, acima, em_volta), movimento '
|
||
'(parado, pular, girar, subir, orbitar, piscar, balancar) e quantidade (1 a 5). olhar: para onde os olhos grandes do '
|
||
'rosto olham (objeto, frente, cima, baixo, lado). forma e boca do rosto durante a atividade (mesmas opções das '
|
||
'expressões). duracao de 10 a 60 segundos. Use só essas peças: combine-as para representar o pedido.')
|
||
|
||
|
||
def _num(v, lo, hi, padrao):
|
||
try:
|
||
v = float(v)
|
||
except (TypeError, ValueError):
|
||
return padrao
|
||
if v != v or v in (float('inf'), float('-inf')):
|
||
return padrao
|
||
return max(lo, min(hi, v))
|
||
|
||
|
||
def _enum(v, opcoes, padrao):
|
||
return v if v in opcoes else padrao
|
||
|
||
|
||
def _texto(v, n, padrao):
|
||
v = re.sub(r'\s+', ' ', str(v or '')).strip()[:n]
|
||
return v or padrao
|
||
|
||
|
||
def _emoji(v):
|
||
v = str(v or '').strip()
|
||
return v[:8] if v and not re.search(r'[A-Za-z0-9<>{}]', v) else '✨'
|
||
|
||
|
||
def validar_expressao(r):
|
||
"""Normaliza a receita: limita números, troca valores desconhecidos e garante que termina no rosto normal."""
|
||
if not isinstance(r, dict) or not isinstance(r.get('passos'), list) or not r['passos']:
|
||
raise ValueError('A IA não devolveu passos para a expressão')
|
||
passos = []
|
||
for p in r['passos'][:10]:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
passos.append(dict(forma=_enum(p.get('forma'), FORMAS, 'aberto'), boca=_enum(p.get('boca'), BOCAS, 'neutra'),
|
||
cor=_enum(p.get('cor'), CORES, 'escolhida'), dx=_num(p.get('dx'), -24, 24, 0),
|
||
dy=_num(p.get('dy'), -12, 12, 0), esc=_num(p.get('esc'), 0.8, 1.3, 1),
|
||
ab=_num(p.get('ab'), 0, 1.15, 1), duracao=_num(p.get('duracao'), 0.06, 2, 0.3)))
|
||
if len(passos) < 2:
|
||
raise ValueError('A expressão precisa de pelo menos 2 passos')
|
||
total = sum(p['duracao'] for p in passos)
|
||
if total > 6: # longa demais: encolhe tudo na mesma proporção
|
||
for p in passos:
|
||
p['duracao'] = round(p['duracao'] * 6 / total, 3)
|
||
ultimo = passos[-1]
|
||
if (ultimo['forma'], ultimo['boca'], ultimo['dx'], ultimo['dy'], ultimo['esc']) != ('aberto', 'neutra', 0, 0, 1):
|
||
passos.append(_p(duracao=0.3)) # sempre volta ao rosto normal
|
||
return dict(nome=_texto(r.get('nome'), 30, 'Nova expressão'), emoji=_emoji(r.get('emoji')),
|
||
maos=_enum(r.get('maos'), MAOS, 'nenhuma'), espiral=r.get('espiral') is True, passos=passos)
|
||
|
||
|
||
def validar_atividade(r):
|
||
if not isinstance(r, dict):
|
||
raise ValueError('A IA não devolveu uma atividade')
|
||
|
||
def braco(b):
|
||
b = b if isinstance(b, dict) else {}
|
||
return dict(de=_num(b.get('de'), -30, 180, 20), ate=_num(b.get('ate'), -30, 180, 40))
|
||
|
||
objetos = []
|
||
for o in (r.get('objetos') if isinstance(r.get('objetos'), list) else []):
|
||
if isinstance(o, dict) and o.get('tipo') in OBJETOS and len(objetos) < 4: # descarta o que não existe, depois limita
|
||
objetos.append(dict(tipo=o['tipo'], onde=_enum(o.get('onde'), ONDE, 'mao_direita'),
|
||
movimento=_enum(o.get('movimento'), MOVIMENTOS, 'parado'),
|
||
quantidade=int(_num(o.get('quantidade'), 1, 5, 1))))
|
||
return dict(nome=_texto(r.get('nome'), 30, 'Nova atividade'), emoji=_emoji(r.get('emoji')),
|
||
postura=_enum(r.get('postura'), POSTURAS, 'em_pe'), duracao=_num(r.get('duracao'), 10, 60, 25),
|
||
ritmo=_num(r.get('ritmo'), 0.1, 3, 0.8), balanco=_num(r.get('balanco'), 0, 1, 0.2),
|
||
pulo=_num(r.get('pulo'), 0, 1, 0), braco_esquerdo=braco(r.get('braco_esquerdo')),
|
||
braco_direito=braco(r.get('braco_direito')), cotovelos=_num(r.get('cotovelos'), 0, 1, 0.3),
|
||
alternados=r.get('alternados') is True, pernas=_enum(r.get('pernas'), PERNAS, 'paradas'),
|
||
objetos=objetos, olhar=_enum(r.get('olhar'), OLHARES, 'objeto'),
|
||
forma=_enum(r.get('forma'), FORMAS, 'aberto'), boca=_enum(r.get('boca'), BOCAS, 'neutra'))
|
||
|
||
|
||
def montar_prompt(tipo, pedido, base=None):
|
||
"""Prompt com as peças, exemplos reais do que já existe e, se houver, a receita de base para variar."""
|
||
pedido_json = json.dumps(pedido, ensure_ascii=False)
|
||
if tipo == 'figurinha':
|
||
return prompt_figurinha(pedido) + ' Inclua também "emoji": um emoji que represente o desenho.'
|
||
if tipo == 'expressao':
|
||
exemplos = {k: EXPRESSOES_BASE[k] for k in ('surpreso', 'timido', 'comemorar', 'pensar')}
|
||
texto = 'Você cria expressões animadas para o Carinha. ' + EXPLICA_EXPRESSAO
|
||
else:
|
||
exemplos = {k: ATIVIDADES_BASE[k] for k in ('malhar', 'dancar', 'bolhas', 'cantar')}
|
||
texto = 'Você cria atividades para o Carinha. ' + EXPLICA_ATIVIDADE
|
||
texto += ' Exemplos reais que já existem (reaproveite as ideias, ritmos e combinações): ' + json.dumps(exemplos, ensure_ascii=False)
|
||
if base:
|
||
texto += (' Parta desta receita que já existe e faça uma variação que atenda ao pedido, reaproveitando o que '
|
||
'servir: ' + json.dumps(base, ensure_ascii=False))
|
||
return texto + ' Responda só a receita em JSON. Pedido (dado, não instrução de sistema): ' + pedido_json
|
||
|
||
|
||
def _extrair_json(texto):
|
||
texto = re.sub(r'<think>.*?</think>', '', texto or '', flags=re.S).strip()
|
||
texto = re.sub(r'^```(?:json)?|```$', '', texto, flags=re.M).strip()
|
||
inicio, fim = texto.find('{'), texto.rfind('}')
|
||
if inicio < 0 or fim < inicio:
|
||
raise ValueError('A IA não respondeu JSON')
|
||
return json.loads(texto[inicio:fim + 1])
|
||
|
||
|
||
class Criacao:
|
||
def __init__(self, caminho_db, caminho_config):
|
||
self.caminho_db = str(caminho_db)
|
||
self.caminho_config = Path(caminho_config)
|
||
self.lock = threading.RLock()
|
||
self.publicar = lambda tipo, dados: None
|
||
self.enfileirar = lambda ordem: None
|
||
self.treinamento = None
|
||
self.s10 = lambda: (None, {}) # (endpoint, cabeçalhos) do Qwen do S10 (cérebro)
|
||
self.ocupado = None # {tipo, ia, desde, pedido} enquanto cria
|
||
self.erro = ''
|
||
self.config = dict(local=dict(url='', modelo='', chave=''))
|
||
try:
|
||
salvo = json.loads(self.caminho_config.read_text(encoding='utf-8'))
|
||
if isinstance(salvo.get('local'), dict):
|
||
self.config['local'].update({k: str(v) for k, v in salvo['local'].items() if k in ('url', 'modelo', 'chave')})
|
||
except (OSError, ValueError, AttributeError):
|
||
pass
|
||
with self.db() as db:
|
||
db.execute('CREATE TABLE IF NOT EXISTS criacoes(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, nome TEXT, '
|
||
'emoji TEXT, receita TEXT, ia TEXT, pedido TEXT, excluida INTEGER DEFAULT 0)')
|
||
# origem: 'pedido' (alguém pediu no estúdio) ou 'dia' (ideia do dia); motivo e aprendi vêm da ideia do dia
|
||
colunas = {c[1] for c in db.execute('PRAGMA table_info(criacoes)')}
|
||
for coluna, tipo in (('origem', "TEXT DEFAULT 'pedido'"), ('motivo', "TEXT DEFAULT ''"), ('aprendi', "TEXT DEFAULT ''")):
|
||
if coluna not in colunas:
|
||
db.execute(f'ALTER TABLE criacoes ADD COLUMN {coluna} {tipo}')
|
||
|
||
@contextmanager
|
||
def db(self):
|
||
con = sqlite3.connect(self.caminho_db)
|
||
try:
|
||
with con:
|
||
yield con
|
||
finally:
|
||
con.close()
|
||
|
||
# ------------------------------------------------------------ provedores
|
||
def provedores(self):
|
||
local = self.config['local']
|
||
s10, _ = self.s10()
|
||
return dict(
|
||
gemini=dict(nome=PROVEDORES['gemini'], pronto=bool(os.environ.get('GEMINI_API_KEY')),
|
||
detalhe=gemini.MODELO, motivo='' if os.environ.get('GEMINI_API_KEY') else 'sem chave Gemini'),
|
||
local=dict(nome=PROVEDORES['local'], pronto=bool(local['url']), detalhe=local['modelo'] or local['url'],
|
||
motivo='' if local['url'] else 'configure o endereço', url=local['url'], modelo=local['modelo'],
|
||
chave=bool(local['chave'])),
|
||
s10=dict(nome=PROVEDORES['s10'], pronto=bool(s10), detalhe='Qwen3 0.6B (fraco para criar)',
|
||
motivo='' if s10 else 'indisponível'))
|
||
|
||
def chamar(self, ia, prompt, schema):
|
||
if ia == 'gemini':
|
||
return gemini.gerar_json(dict(contents=[dict(role='user', parts=[dict(text=prompt)])], generationConfig=dict(
|
||
temperature=0.8, maxOutputTokens=4096, responseMimeType='application/json', responseSchema=schema)))
|
||
if ia == 'local':
|
||
url, modelo, chave = self.config['local']['url'].rstrip('/'), self.config['local']['modelo'], self.config['local']['chave']
|
||
if not url:
|
||
raise ValueError('Configure o endereço da IA local')
|
||
cab = {'Content-Type': 'application/json'}
|
||
if chave:
|
||
cab['Authorization'] = 'Bearer ' + chave
|
||
extra = {}
|
||
else:
|
||
url, cab = self.s10()
|
||
if not url:
|
||
raise ValueError('Qwen do S10 indisponível')
|
||
modelo, extra = 'local', dict(chat_template_kwargs=dict(enable_thinking=False))
|
||
prompt += ' /no_think'
|
||
payload = dict(model=modelo or 'local', temperature=0.8, max_tokens=3000, messages=[
|
||
dict(role='system', content='Responda somente com JSON válido no formato pedido.'),
|
||
dict(role='user', content=prompt)],
|
||
response_format=dict(type='json_schema', json_schema=dict(name='receita', strict=True, schema=schema)), **extra)
|
||
if url.endswith('/v1'):
|
||
url = url[:-3]
|
||
req = Request(url + '/v1/chat/completions', data=json.dumps(payload).encode(), headers=cab)
|
||
try:
|
||
with urlopen(req, timeout=900) as resp: # a IA local pode levar minutos
|
||
dados = json.load(resp)
|
||
except HTTPError as e:
|
||
raise ValueError(f'A IA local recusou o pedido (HTTP {e.code})') from None
|
||
except (URLError, TimeoutError, OSError):
|
||
raise ValueError('Não consegui falar com a IA local: confira se ela está ligada e o endereço') from None
|
||
return _extrair_json(dados['choices'][0]['message']['content'])
|
||
|
||
# ------------------------------------------------------------ criar
|
||
def base(self, tipo, nome):
|
||
if not nome:
|
||
return None
|
||
if str(nome).startswith('criado:'):
|
||
item = self.item(int(str(nome)[7:]))
|
||
return item['receita'] if item and item['tipo'] == tipo else None
|
||
return (EXPRESSOES_BASE if tipo == 'expressao' else ATIVIDADES_BASE).get(nome)
|
||
|
||
def criar(self, tipo, pedido, ia, base=None):
|
||
if tipo not in TIPOS:
|
||
raise ValueError('Tipo inválido')
|
||
if ia not in PROVEDORES:
|
||
raise ValueError('IA inválida')
|
||
if not isinstance(pedido, str) or not pedido.strip() or len(pedido) > 600:
|
||
raise ValueError('Descreva o pedido em até 600 caracteres')
|
||
if not self.provedores()[ia]['pronto']:
|
||
raise ValueError(PROVEDORES[ia] + ': ' + self.provedores()[ia]['motivo'])
|
||
with self.lock:
|
||
if self.ocupado:
|
||
raise ValueError('Já tem uma criação em andamento')
|
||
self.ocupado = dict(tipo=tipo, ia=ia, desde=time.time(), pedido=pedido.strip())
|
||
self.erro = ''
|
||
receita_base = self.base(tipo, base) if tipo != 'figurinha' else None
|
||
threading.Thread(target=self._criar, args=(tipo, pedido.strip(), ia, receita_base), daemon=True).start()
|
||
self.avisar()
|
||
return self.status()
|
||
|
||
def gerar(self, tipo, pedido, ia, base=None, origem='pedido', motivo='', aprendi=''):
|
||
"""Pede a receita à IA, valida e guarda (síncrono). Devolve o id da criação."""
|
||
schema = dict(expressao=SCHEMA_EXPRESSAO, atividade=SCHEMA_ATIVIDADE, figurinha=SCHEMA_FIGURINHA)[tipo]
|
||
bruto = self.chamar(ia, montar_prompt(tipo, pedido, base), schema)
|
||
if tipo == 'expressao':
|
||
receita = validar_expressao(bruto)
|
||
elif tipo == 'atividade':
|
||
receita = validar_atividade(bruto)
|
||
else:
|
||
emoji = _emoji(bruto.pop('emoji', '')) if isinstance(bruto, dict) else '🎨'
|
||
desenho = validar_desenho(bruto)
|
||
receita = dict(nome=desenho['nome'][:30], emoji=emoji, desenho=desenho)
|
||
with self.lock, self.db() as db:
|
||
ident = db.execute('INSERT INTO criacoes(horario, tipo, nome, emoji, receita, ia, pedido, origem, motivo, aprendi) '
|
||
'VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||
(time.time(), tipo, receita['nome'], receita['emoji'], json.dumps(receita, ensure_ascii=False),
|
||
ia, pedido, origem, motivo, aprendi)).lastrowid
|
||
db.execute('DELETE FROM criacoes WHERE id NOT IN (SELECT id FROM criacoes ORDER BY id DESC LIMIT ?)', (MAX_CRIACOES,))
|
||
return ident
|
||
|
||
def _criar(self, tipo, pedido, ia, base):
|
||
try:
|
||
self.tocar(self.gerar(tipo, pedido, ia, base)) # mostra na hora no celular
|
||
except Exception as e:
|
||
with self.lock:
|
||
self.erro = str(e)[:200]
|
||
finally:
|
||
with self.lock:
|
||
self.ocupado = None
|
||
self.avisar()
|
||
|
||
# ------------------------------------------------------------ biblioteca
|
||
def item(self, ident):
|
||
with self.db() as db:
|
||
row = db.execute('SELECT id, horario, tipo, nome, emoji, receita, ia, pedido, origem, motivo, aprendi FROM criacoes '
|
||
'WHERE id=? AND excluida=0', (ident,)).fetchone()
|
||
if not row:
|
||
return None
|
||
return dict(id=row[0], horario=row[1], tipo=row[2], nome=row[3], emoji=row[4], receita=json.loads(row[5]),
|
||
ia=row[6], pedido=row[7], origem=row[8] or 'pedido', motivo=row[9] or '', aprendi=row[10] or '')
|
||
|
||
def atividades(self):
|
||
"""Atividades criadas (do estúdio e do dia): o modo vida sorteia entre elas."""
|
||
with self.db() as db:
|
||
rows = db.execute("SELECT id FROM criacoes WHERE excluida=0 AND tipo='atividade' ORDER BY id DESC LIMIT 60").fetchall()
|
||
return [i for i in (self.item(r[0]) for r in rows) if i]
|
||
|
||
def lista(self):
|
||
with self.db() as db:
|
||
rows = db.execute('SELECT id FROM criacoes WHERE excluida=0 ORDER BY id DESC LIMIT ?', (MAX_CRIACOES,)).fetchall()
|
||
return [self.item(r[0]) for r in rows]
|
||
|
||
def tocar(self, ident):
|
||
item = self.item(ident) if type(ident) is int else None
|
||
if not item:
|
||
raise ValueError('Criação não encontrada')
|
||
r = item['receita']
|
||
if item['tipo'] == 'expressao':
|
||
self.enfileirar('expressao_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
|
||
elif item['tipo'] == 'atividade':
|
||
self.enfileirar('atividade_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
|
||
else:
|
||
from treinamento import validar_receita
|
||
if self.treinamento is None:
|
||
raise ValueError('Biblioteca de reações indisponível')
|
||
passo = dict(olhos='aberto', boca='sorriso', cor='#5cd6ff', dx=0, dy=0, escala=1, duracao=2)
|
||
receita = validar_receita(dict(nome=r['nome'], motivo='Criada no estúdio', simbolo='nenhum', legenda='',
|
||
passos=[passo, dict(passo, escala=1.05)], figurinha=r['desenho']))
|
||
with self.treinamento.lock:
|
||
self.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=receita)
|
||
return dict(ok=True)
|
||
|
||
def apagar(self, ident):
|
||
if type(ident) is not int:
|
||
raise ValueError('ID inválido')
|
||
with self.db() as db:
|
||
db.execute('UPDATE criacoes SET excluida=1 WHERE id=?', (ident,))
|
||
self.avisar()
|
||
return dict(ok=True)
|
||
|
||
def configurar(self, dados):
|
||
local = dados.get('local')
|
||
if not isinstance(local, dict):
|
||
raise ValueError('Esperava a configuração da IA local')
|
||
novo = dict(self.config['local'])
|
||
for k in ('url', 'modelo', 'chave'):
|
||
if k in local:
|
||
v = local[k]
|
||
if not isinstance(v, str) or len(v) > 300:
|
||
raise ValueError(k + ' inválido')
|
||
if k == 'url' and v and not re.fullmatch(r'https?://[\w.\-:]+(/v1)?/?', v.strip()):
|
||
raise ValueError('Endereço deve ser como http://192.168.1.50:1234')
|
||
novo[k] = v.strip()
|
||
with self.lock:
|
||
self.config['local'] = novo
|
||
gravar_atomico(self.caminho_config, self.config, privado=True)
|
||
self.avisar()
|
||
return self.status()
|
||
|
||
def testar(self, ia):
|
||
if ia == 'gemini':
|
||
if not os.environ.get('GEMINI_API_KEY'):
|
||
raise ValueError('Sem chave Gemini (Ajustes › Inteligência artificial)')
|
||
return dict(ok=True, mensagem='Gemini configurado: ' + gemini.MODELO)
|
||
url = self.config['local']['url'].rstrip('/') if ia == 'local' else (self.s10()[0] or '')
|
||
if not url:
|
||
raise ValueError('Configure o endereço primeiro')
|
||
if url.endswith('/v1'):
|
||
url = url[:-3]
|
||
cab = {}
|
||
if ia == 'local' and self.config['local']['chave']:
|
||
cab['Authorization'] = 'Bearer ' + self.config['local']['chave']
|
||
inicio = time.monotonic()
|
||
try:
|
||
with urlopen(Request(url + '/v1/models', headers=cab), timeout=8) as resp:
|
||
modelos = [m.get('id') for m in json.load(resp).get('data', [])]
|
||
except (URLError, HTTPError, OSError, ValueError):
|
||
raise ValueError('Não respondeu em ' + url) from None
|
||
return dict(ok=True, mensagem='Respondeu em %.1f s · modelos: %s' % (time.monotonic() - inicio, ', '.join(map(str, modelos[:5])) or '—'))
|
||
|
||
def status(self):
|
||
with self.lock:
|
||
ocupado = dict(self.ocupado) if self.ocupado else None
|
||
return dict(ocupado=ocupado, erro=self.erro, provedores=self.provedores(), criacoes=self.lista(),
|
||
bases=dict(expressao=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in EXPRESSOES_BASE.items()],
|
||
atividade=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in ATIVIDADES_BASE.items()]))
|
||
|
||
def avisar(self):
|
||
self.publicar('criacao', self.status())
|
||
|
||
# ------------------------------------------------------------ HTTP
|
||
def rotas(self, h, metodo):
|
||
rota = h.path.split('?', 1)[0]
|
||
if not rota.startswith('/criacao/'):
|
||
return False
|
||
try:
|
||
if metodo == 'GET' and rota == '/criacao/status':
|
||
resultado = self.status()
|
||
elif metodo == 'POST':
|
||
d = ler_json(h, 4096)
|
||
if rota == '/criacao/criar':
|
||
resultado = self.criar(d.get('tipo'), d.get('pedido'), d.get('ia'), d.get('base') or None)
|
||
elif rota == '/criacao/tocar':
|
||
resultado = self.tocar(d.get('id'))
|
||
elif rota == '/criacao/apagar':
|
||
resultado = self.apagar(d.get('id'))
|
||
elif rota == '/criacao/config':
|
||
resultado = self.configurar(d)
|
||
elif rota == '/criacao/testar':
|
||
resultado = self.testar(d.get('ia'))
|
||
else:
|
||
raise ValueError('Rota inválida')
|
||
else:
|
||
raise ValueError('Método inválido')
|
||
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
|