diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html
index 8853a5b..0ab791e 100644
--- a/android/app/assets/carinha.html
+++ b/android/app/assets/carinha.html
@@ -540,11 +540,31 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
-function atividade(nome, segundos) {
+function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
- if (window.MotorRosto.atividade(nome, segundos) && nome === "cantar") tocarSom("musica");
+ if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
+}
+
+/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
+ peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
+function corDaReceita(nome) {
+ return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
+}
+function tocarExpressaoCriada(texto) {
+ let rc;
+ try { rc = JSON.parse(texto); } catch (e) { return; }
+ if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
+ if (r.dormindo) r.dormindo = false;
+ r.fila = rc.passos.slice(0, 12).map((p) => [{
+ forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
+ dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
+ }, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
+ proximo();
+ const total = r.fila.reduce((a, passo) => a + passo[1], 0);
+ if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
+ if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
@@ -689,6 +709,10 @@ function trata(ordem) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
+ } else if (ordem.indexOf("expressao_criada ") === 0) {
+ tocarExpressaoCriada(ordem.slice(17));
+ } else if (ordem.indexOf("atividade_criada ") === 0) {
+ try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
diff --git a/android/app/assets/rosto-motor.js b/android/app/assets/rosto-motor.js
index d80c85b..887447a 100644
--- a/android/app/assets/rosto-motor.js
+++ b/android/app/assets/rosto-motor.js
@@ -568,15 +568,16 @@
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
+ receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
- const ativ = { nome: null, t0: 0, dur: 0, fim: 0 };
+ const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
- function iniciarAtividade(nome, segundos) {
- if (!ATIVIDADES[nome]) { pararAtividade(); return false; }
+ function iniciarAtividade(nome, segundos, receita) {
+ if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
- ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0;
+ ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
@@ -620,6 +621,19 @@
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
+ } else if (ativ.nome === 'receita') {
+ const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
+ const o = (R.objetos || [])[0];
+ if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
+ else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
+ else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
+ else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
+ else { // segue o primeiro objeto
+ info.x = lado * 14 + Math.sin(k * w) * 5;
+ info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
+ }
+ if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
+ if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
@@ -765,7 +779,112 @@
c.globalAlpha = alfa;
}
+ // ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
+ function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
+ c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
+ c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
+ if (tipo === 'bola') {
+ const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
+ c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
+ for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
+ c.closePath(); c.fill();
+ } else if (tipo === 'livro') {
+ const lw = s * 0.62, lh = s * 0.5;
+ for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
+ } else if (tipo === 'xicara') {
+ xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
+ } else if (tipo === 'nota') {
+ c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
+ } else if (tipo === 'estrela') {
+ c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
+ } else if (tipo === 'coracao') {
+ c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
+ } else if (tipo === 'zzz') {
+ zzz(c, 0, 0, s, k + j, cor);
+ } else if (tipo === 'chama') {
+ chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
+ } else if (tipo === 'bolha') {
+ const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
+ c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
+ c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
+ } else if (tipo === 'halter') {
+ c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
+ for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
+ } else if (tipo === 'microfone') {
+ c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
+ c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
+ } else if (tipo === 'controle') {
+ caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
+ c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
+ } else if (tipo === 'pincel') {
+ c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
+ c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
+ } else if (tipo === 'flor') {
+ c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
+ c.fillStyle = css(ROSA_);
+ for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
+ c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
+ }
+ c.restore();
+ }
+
+ /** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
+ function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
+ const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
+ for (let j = 0; j < q; j++) {
+ let ox, oy;
+ if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
+ else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
+ else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
+ else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
+ else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
+ else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
+ if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
+ let giro = 0;
+ c.globalAlpha = alfa;
+ if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
+ else if (o.movimento === 'girar') giro = k * 4 + j;
+ else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
+ else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
+ else if (o.movimento === 'subir') {
+ const f = (k * 0.35 + j / q) % 1;
+ ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
+ c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
+ } else if (o.movimento === 'orbitar') {
+ const a = k * 1.6 + (j / q) * Math.PI * 2;
+ ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
+ }
+ desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
+ }
+ c.globalAlpha = alfa;
+ }
+
const CENAS = {
+ // Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
+ // o bonequinho, poses de braços/pernas e os objetos das outras cenas.
+ receita(c, x, y, s, k, cor) {
+ const R = ativ.receita || {};
+ const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
+ const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
+ const rad = (g) => (g * Math.PI) / 180;
+ const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
+ const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
+ const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
+ const cot = (R.cotovelos || 0) * 1.4;
+ let pE = [-0.08, 0], pD = [0.08, 0];
+ if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
+ else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
+ else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
+ else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
+ const sentado = R.postura === 'sentado';
+ const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
+ inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
+ olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
+ bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
+ }), cor, k);
+ (R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
+ },
+
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
@@ -1268,7 +1387,7 @@
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
- atividade: (nome, segundos) => (nome ? iniciarAtividade(nome, segundos) : (pararAtividade(), false)),
+ atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
diff --git a/carinha.html b/carinha.html
index 8853a5b..0ab791e 100644
--- a/carinha.html
+++ b/carinha.html
@@ -540,11 +540,31 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
-function atividade(nome, segundos) {
+function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
- if (window.MotorRosto.atividade(nome, segundos) && nome === "cantar") tocarSom("musica");
+ if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
+}
+
+/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
+ peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
+function corDaReceita(nome) {
+ return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
+}
+function tocarExpressaoCriada(texto) {
+ let rc;
+ try { rc = JSON.parse(texto); } catch (e) { return; }
+ if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
+ if (r.dormindo) r.dormindo = false;
+ r.fila = rc.passos.slice(0, 12).map((p) => [{
+ forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
+ dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
+ }, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
+ proximo();
+ const total = r.fila.reduce((a, passo) => a + passo[1], 0);
+ if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
+ if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
@@ -689,6 +709,10 @@ function trata(ordem) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
+ } else if (ordem.indexOf("expressao_criada ") === 0) {
+ tocarExpressaoCriada(ordem.slice(17));
+ } else if (ordem.indexOf("atividade_criada ") === 0) {
+ try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
diff --git a/criacao.py b/criacao.py
new file mode 100644
index 0000000..cca94e6
--- /dev/null
+++ b/criacao.py
@@ -0,0 +1,517 @@
+"""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'.*?', '', 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)')
+
+ @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 _criar(self, tipo, pedido, ia, base):
+ try:
+ 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) VALUES (?,?,?,?,?,?,?)',
+ (time.time(), tipo, receita['nome'], receita['emoji'],
+ json.dumps(receita, ensure_ascii=False), ia, pedido)).lastrowid
+ db.execute('DELETE FROM criacoes WHERE id NOT IN (SELECT id FROM criacoes ORDER BY id DESC LIMIT ?)', (MAX_CRIACOES,))
+ self.tocar(ident) # 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 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])
+
+ 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
diff --git a/deploy.ps1 b/deploy.ps1
index 8c24a6c..6e59e98 100644
--- a/deploy.ps1
+++ b/deploy.ps1
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
- 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py',
+ 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
diff --git a/kiosk-server.py b/kiosk-server.py
index 5e9028e..6e26921 100644
--- a/kiosk-server.py
+++ b/kiosk-server.py
@@ -42,6 +42,7 @@ from treinamento import Treinamento
from api_config import ConfigAPI
from historico import Historico
from vida import Vida
+from criacao import Criacao
TRAVA = threading.Lock()
@@ -75,7 +76,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
-PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/",
+PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -247,7 +248,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self):
s = self.server
- return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "treinamento", "conversa", "cerebro",
+ return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)]
def estado(self):
@@ -467,7 +468,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor):
- for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida"):
+ for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -494,6 +495,11 @@ def main():
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.historico = Historico(Path.cwd())
servidor.vida = vida = Vida(Path.cwd() / 'vida.json')
+ # Estúdio de criação: expressões, atividades e figurinhas por IA (Gemini, IA local ou Qwen do S10)
+ servidor.criacao = Criacao(Path.cwd() / 'criacoes.db', Path.cwd() / 'criacao.json')
+ servidor.criacao.enfileirar = enfileirar
+ servidor.criacao.treinamento = servidor.treinamento
+ servidor.criacao.s10 = lambda: (servidor.cerebro.endpoint, servidor.cerebro.cabecalhos())
def registrar_conversa(papel, texto):
servidor.treinamento.registrar(papel, texto)
diff --git a/rosto-motor.js b/rosto-motor.js
index d80c85b..887447a 100644
--- a/rosto-motor.js
+++ b/rosto-motor.js
@@ -568,15 +568,16 @@
skate: { nome: 'Andando de skate', emoji: '🛹', dur: [18, 30] },
computador: { nome: 'No computador', emoji: '💻', dur: [25, 45] },
pescar: { nome: 'Pescando', emoji: '🎣', dur: [30, 50] },
+ receita: { nome: 'Criada por IA', emoji: '✨', dur: [20, 40] }, // receita do estúdio de criação
};
- const ativ = { nome: null, t0: 0, dur: 0, fim: 0 };
+ const ativ = { nome: null, t0: 0, dur: 0, fim: 0, receita: null };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
- function iniciarAtividade(nome, segundos) {
- if (!ATIVIDADES[nome]) { pararAtividade(); return false; }
+ function iniciarAtividade(nome, segundos, receita) {
+ if (!ATIVIDADES[nome] || (nome === 'receita' && !receita)) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
- ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0;
+ ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0; ativ.receita = receita || null;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
@@ -620,6 +621,19 @@
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
+ } else if (ativ.nome === 'receita') {
+ const R = ativ.receita || {}, w = Math.PI * 2 * (R.ritmo || 0.8);
+ const o = (R.objetos || [])[0];
+ if (R.olhar === 'cima') { info.x = lado * 12; info.y = -10; }
+ else if (R.olhar === 'baixo') { info.x = lado * 10; info.y = 8; }
+ else if (R.olhar === 'lado') { info.x = lado * 20; info.y = 2; }
+ else if (R.olhar === 'frente') { info.x = 4 * Math.sin(k * 0.7); info.y = 0; }
+ else { // segue o primeiro objeto
+ info.x = lado * 14 + Math.sin(k * w) * 5;
+ info.y = o && o.movimento === 'pular' ? 8 - Math.abs(Math.sin(k * w * 0.5)) * 16 : o && o.movimento === 'subir' ? -6 : 3;
+ }
+ if (R.forma && R.forma !== 'aberto') info.forma = R.forma;
+ if (R.boca && R.boca !== 'neutra') info.boca = R.boca;
} else if (ativ.nome === 'dormir') {
info.forma = 'dormir'; info.boca = 'sono'; info.x = lado * 6; info.y = 4;
} else if (ativ.nome === 'cafe') {
@@ -765,7 +779,112 @@
c.globalAlpha = alfa;
}
+ // ---- objetos que as receitas podem usar (mesmos desenhos das cenas feitas à mão)
+ function desenharObjeto(c, tipo, ox, oy, s, k, cor, giro, j) {
+ c.save(); c.translate(ox, oy); if (giro) c.rotate(giro);
+ c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
+ if (tipo === 'bola') {
+ const r = s * 0.28; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
+ c.fillStyle = css(cor.map((v) => v * 0.3)); c.beginPath();
+ for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); }
+ c.closePath(); c.fill();
+ } else if (tipo === 'livro') {
+ const lw = s * 0.62, lh = s * 0.5;
+ for (const lado of [-1, 1]) { c.beginPath(); c.moveTo(0, lh * 0.55); c.lineTo(lado * lw, lh * 0.45); c.lineTo(lado * lw, -lh * 0.45); c.lineTo(0, -lh * 0.35); c.closePath(); c.fill(); c.stroke(); }
+ } else if (tipo === 'xicara') {
+ xicara(c, 0, 0, s, cor); vapor(c, 0, -s * 0.3, s, k, cor);
+ } else if (tipo === 'nota') {
+ c.fillStyle = css(cor); nota(c, 0, 0, s * 0.7);
+ } else if (tipo === 'estrela') {
+ c.fillStyle = css(j % 2 ? AMARELO_ : cor); estrela(c, 0, 0, s * 0.16);
+ } else if (tipo === 'coracao') {
+ c.fillStyle = css(ROSA_); coracao(c, 0, 0, s * 0.22);
+ } else if (tipo === 'zzz') {
+ zzz(c, 0, 0, s, k + j, cor);
+ } else if (tipo === 'chama') {
+ chama(c, 0, s * 0.15, s * (0.35 + 0.08 * Math.sin(k * 17 + j)), s);
+ } else if (tipo === 'bolha') {
+ const r = s * (0.13 + 0.1 * rnd(j, 4)); c.fillStyle = css(cor, 0.1);
+ c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
+ c.strokeStyle = claro(cor, 1); c.beginPath(); c.arc(0, 0, r * 0.65, -2.4, -1.6); c.stroke();
+ } else if (tipo === 'halter') {
+ c.fillRect(-s * 0.25, -s * 0.035, s * 0.5, s * 0.07);
+ for (const lado of [-1, 1]) { caixa(c, lado * s * 0.28 - s * 0.07, -s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
+ } else if (tipo === 'microfone') {
+ c.fillStyle = escuro(cor); c.beginPath(); capsula(c, 0, 0, -s * 0.1, -s * 0.4, s * 0.07); c.fill(); c.stroke();
+ c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(-s * 0.12, -s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
+ } else if (tipo === 'controle') {
+ caixa(c, -s * 0.42, -s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
+ c.fillStyle = css(cor.map((v) => v * 0.3)); c.fillRect(-s * 0.3, -s * 0.02, s * 0.16, s * 0.05); c.fillRect(-s * 0.245, -s * 0.08, s * 0.05, s * 0.17);
+ } else if (tipo === 'pincel') {
+ c.beginPath(); c.moveTo(-s * 0.1, s * 0.08); c.lineTo(s * 0.4, -s * 0.12); c.stroke();
+ c.fillStyle = css([ROSA_, AMARELO_, VERDE_][Math.floor(k) % 3]); c.beginPath(); c.arc(s * 0.43, -s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
+ } else if (tipo === 'flor') {
+ c.strokeStyle = css(VERDE_); c.beginPath(); c.moveTo(0, s * 0.5); c.lineTo(0, 0); c.stroke();
+ c.fillStyle = css(ROSA_);
+ for (let i = 0; i < 5; i++) { const a = i * 1.2566 + k * 0.3; c.beginPath(); c.arc(Math.cos(a) * s * 0.14, Math.sin(a) * s * 0.14, s * 0.1, 0, Math.PI * 2); c.fill(); }
+ c.fillStyle = css(AMARELO_); c.beginPath(); c.arc(0, 0, s * 0.08, 0, Math.PI * 2); c.fill();
+ }
+ c.restore();
+ }
+
+ /** Onde fica cada objeto da receita e como ele se mexe (pular, girar, subir, orbitar, piscar, balançar). */
+ function objetoReceita(c, o, i, b, x, y, s, k, cor, w) {
+ const q = Math.max(1, Math.min(5, o.quantidade || 1)), alfa = c.globalAlpha;
+ for (let j = 0; j < q; j++) {
+ let ox, oy;
+ if (o.onde === 'mao_direita') { ox = b.mD.x; oy = b.mD.y; }
+ else if (o.onde === 'mao_esquerda') { ox = b.mE.x; oy = b.mE.y; }
+ else if (o.onde === 'maos') { ox = (b.mE.x + b.mD.x) / 2; oy = (b.mE.y + b.mD.y) / 2; }
+ else if (o.onde === 'chao') { ox = x + s * (0.9 + i * 0.5); oy = y - s * 0.3; }
+ else if (o.onde === 'acima') { ox = b.cabeca.x + s * (0.8 + j * 0.5); oy = b.cabeca.y - s * 0.6; }
+ else { const a = (j / q) * Math.PI * 2 + i; ox = b.quadril.x + Math.cos(a) * s * 1.5; oy = b.quadril.y - s * 1.2 + Math.sin(a) * s * 1.1; }
+ if (q > 1 && (o.onde === 'mao_direita' || o.onde === 'mao_esquerda' || o.onde === 'maos' || o.onde === 'chao')) ox += (j - (q - 1) / 2) * s * 0.4;
+ let giro = 0;
+ c.globalAlpha = alfa;
+ if (o.movimento === 'pular') oy -= Math.abs(Math.sin(k * w * 0.5 + j)) * s * 1.6;
+ else if (o.movimento === 'girar') giro = k * 4 + j;
+ else if (o.movimento === 'balancar') ox += Math.sin(k * w + j) * s * 0.3;
+ else if (o.movimento === 'piscar') c.globalAlpha = alfa * (0.25 + 0.75 * Math.abs(Math.sin(k * 3 + j * 1.7)));
+ else if (o.movimento === 'subir') {
+ const f = (k * 0.35 + j / q) % 1;
+ ox += f * s * 0.8 + Math.sin(k * 2 + j) * s * 0.12; oy -= f * s * 1.8;
+ c.globalAlpha = alfa * Math.min(1, f / 0.12) * (1 - f);
+ } else if (o.movimento === 'orbitar') {
+ const a = k * 1.6 + (j / q) * Math.PI * 2;
+ ox = b.quadril.x + Math.cos(a) * s * 1.6; oy = b.quadril.y - s * 1.3 + Math.sin(a) * s * 0.9;
+ }
+ desenharObjeto(c, o.tipo, ox, oy, s, k, cor, giro, j);
+ }
+ c.globalAlpha = alfa;
+ }
+
const CENAS = {
+ // Atividade criada por IA: uma receita (criacao.py) montada com as peças que já existem aqui:
+ // o bonequinho, poses de braços/pernas e os objetos das outras cenas.
+ receita(c, x, y, s, k, cor) {
+ const R = ativ.receita || {};
+ const w = Math.PI * 2 * (R.ritmo || 0.8), onda = Math.sin(k * w);
+ const f1 = (onda + 1) / 2, f2 = R.alternados ? 1 - f1 : f1;
+ const rad = (g) => (g * Math.PI) / 180;
+ const ang = (b, f) => rad((b ? b.de : 20) + ((b ? b.ate : 40) - (b ? b.de : 20)) * f);
+ const pulo = (R.pulo || 0) * Math.max(0, onda), sobe = pulo * s * 0.5;
+ const balanco = Math.sin(k * w * 0.5) * (R.balanco || 0);
+ const cot = (R.cotovelos || 0) * 1.4;
+ let pE = [-0.08, 0], pD = [0.08, 0];
+ if (R.pernas === 'marchando') { pE = [-0.08 - 0.25 * f1, 0.45 * f1]; pD = [0.08 + 0.25 * (1 - f1), -0.45 * (1 - f1)]; }
+ else if (R.pernas === 'chutando') { const chute = Math.pow(Math.max(0, onda), 2); pD = [0.08 + chute * 0.9, -chute * 0.6]; }
+ else if (R.pernas === 'dancando') { pE = [-0.1 - f1 * 0.25, f1 * 0.4]; pD = [0.1 + (1 - f1) * 0.25, -(1 - f1) * 0.4]; }
+ else if (R.pernas === 'pulando') { pE = [-0.06, pulo * 0.3]; pD = [0.06, -pulo * 0.3]; }
+ const sentado = R.postura === 'sentado';
+ const b = boneco(c, x + balanco * s * 0.3, y - sobe, s, Object.assign(sentado ? Object.assign({}, SENTADO) : { pE, pD }, {
+ inclina: balanco * 0.25, cabeca: -balanco * 0.3, fechados: R.forma === 'dormir',
+ olhoY: R.olhar === 'cima' ? -1 : R.olhar === 'baixo' ? 0.8 : 0, olhoX: R.olhar === 'lado' ? 1 : 0,
+ bE: [-ang(R.braco_esquerdo, f1), cot], bD: [ang(R.braco_direito, f2), -cot],
+ }), cor, k);
+ (R.objetos || []).forEach((o, i) => objetoReceita(c, o, i, b, x, y, s, k, cor, w));
+ },
+
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
@@ -1268,7 +1387,7 @@
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
- atividade: (nome, segundos) => (nome ? iniciarAtividade(nome, segundos) : (pararAtividade(), false)),
+ atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
diff --git a/tests/test_criacao.py b/tests/test_criacao.py
new file mode 100644
index 0000000..72fe2a0
--- /dev/null
+++ b/tests/test_criacao.py
@@ -0,0 +1,152 @@
+"""Estúdio de criação: receitas validadas, prompt com exemplos/base, IA local (servidor falso) e tocar."""
+import json
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+import criacao as mod # noqa: E402
+from criacao import ATIVIDADES_BASE, EXPRESSOES_BASE, Criacao, montar_prompt, validar_atividade, validar_expressao # noqa: E402
+
+RESPOSTAS = {} # o servidor falso devolve o que o teste colocar aqui
+PEDIDOS = []
+
+
+class IaFalsa(BaseHTTPRequestHandler):
+ def do_POST(self):
+ corpo = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
+ PEDIDOS.append(dict(corpo=corpo, auth=self.headers.get('Authorization')))
+ tipo = 'expressao' if 'passos' in corpo['response_format']['json_schema']['schema']['properties'] else 'atividade'
+ texto = 'pensando...\n```json\n' + json.dumps(RESPOSTAS[tipo]) + '\n```'
+ dados = json.dumps(dict(choices=[dict(message=dict(content=texto))])).encode()
+ self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(dados)))
+ self.end_headers(); self.wfile.write(dados)
+
+ def do_GET(self):
+ dados = json.dumps(dict(data=[dict(id='qwen-teste')])).encode()
+ self.send_response(200); self.send_header('Content-Length', str(len(dados))); self.end_headers(); self.wfile.write(dados)
+
+ def log_message(self, *a):
+ pass
+
+
+class CriacaoTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.c = Criacao(Path(self.tmp.name) / 'c.db', Path(self.tmp.name) / 'c.json')
+ self.ordens = []
+ self.c.enfileirar = self.ordens.append
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def esperar(self):
+ for _ in range(100):
+ if not self.c.ocupado:
+ return
+ time.sleep(0.05)
+ self.fail('criação não terminou')
+
+ def test_valida_e_limita_expressao(self):
+ r = validar_expressao(dict(nome='x' * 80, emoji='
{#each lista as g, i (g)}
- {@const info = GESTOS_INFO[g]}
+ {@const info = infoDe(g)!}
-