llm-local — servidor de mock com LLM de verdade (codex / GPT) para o pet e para avaliar resposta escrita ======================================================================================================= Fora do jogo e fora do repositório. Só biblioteca padrão do Python. Precisa do `codex` logado nesta máquina. SERVIR UM MOCK POR ELE (no lugar do `python3 -m http.server`) python3 /home/oda/xcool-mocks/llm-local/servidor.py --raiz --porta (bind 0.0.0.0 por padrão: abre também em http://192.168.15.3:/ ; --host 127.0.0.1 para só local) No ar agora: a demo em http://192.168.15.3:8785/ (parar: pkill -f 'servidor.py --raiz /home/oda/xcool-mocks/llm-local/demo') Variáveis: LLM_MODEL (padrão gpt-5.6-luna), LLM_EFFORT (low), LLM_TIMEOUT (25 s). Log de toda chamada (entrada, prompt, resposta bruta, latência, classificação): llm-local/log.jsonl (ADR-XC-020 item 7). Prova com latências e controles negativos: prova.md (refazer: python3 prova.py com o servidor na 8785). CONTRATO — POST /llm, JSON, mesma origem da página Pet: {modo:"pet", pedido:"comentar"|"ajuda_1"|"ajuda_2"|"ajuda_3", tarefa, estado, historico, historico_crianca, memorias_compartilhadas, idade, persona_pet, persona_npc} → {ok:true, fala, nivel_ajuda:"nenhuma"|"provocacao"|"pista"|"quase_solucao"|"resolveu", latencia_ms, fonte:"llm"} Avaliar: {modo:"avaliar", enunciado, criterio, resposta_da_crianca, idade, persona, historico_crianca} → {ok:true, atende:true|false, comentario, latencia_ms, fonte:"llm"|"servidor"} Falha, timeout ou JSON inválido → {ok:false, erro}: a página usa a fala roteirizada. O que vai em cada campo (texto livre ou objeto; tudo vira dado no prompt, nunca instrução): - tarefa: a tarefa inteira — o que ensina, objetivo do encontro, passos, o que conta como certo. - estado: o que está na tela AGORA (etapa, papel, peças, seleção, último erro). O pet não afirma nada fora daqui. - historico: o que a criança fez e errou NESTE encontro. - historico_crianca: resumo pedagógico de encontros anteriores, da matriz (player_skill_state por habilidade: observações e status discovering/developing/met; últimas skill_evidence: habilidade, acerto, ajuda, peso) e o que já funcionou com ela. Só dado pedagógico: nada de nome, idade exata, escola, local ou texto pessoal (CDC-001). Serve para calibrar a dica; o pet nunca comenta desempenho nem compara. - memorias_compartilhadas: momentos vividos juntos no jogo (lugares, NPCs, tarefas e como terminaram, algo engraçado), em linguagem de lembrança e nunca de desempenho. O pet só lembra do que está aqui. - idade (padrão "10 a 11 anos, 5º ano"), persona_pet (padrão Pingo, o shiba), persona_npc (quem é o NPC da cena). - Aceita também os nomes antigos: persona, contexto_da_tarefa, estado_atual, degrau. Regras que o servidor garante: nivel_ajuda fora do enum vira "resolveu" (assistência máxima, ADR-XC-020); fala com mais de 282 caracteres é falha; resposta vazia no avaliar é atende:false sem chamar a LLM. A LLM só diz se atende (DEC-037): concluir, pagar tokens e gravar evidência é da página, nunca do /llm. Latência medida: 5 a 10 s na maioria, picos de 20 s. ESPERA — componente comum, obrigatório para quem integrar o pet (o servidor entrega esse arquivo em qualquer --raiz) const r = await XcoolPensando.pedirPet(corpoSemModo, { conversa: elementoDaConversa, // entra "Pingo: •••" animado, trocado pela fala quando chega alvo: canvasOuElementoDoPet, // balão de pensamento animado sobre a cabeça do pet ponto: () => ({x, y}), // cabeça do pet dentro do alvo, em px CSS (canvas: calcule do sprite) nome: "Pingo", roteirizada: "fala do roteiro para o fallback", aoDemorar: () => {/* passou de 8 s: anime o sprite (coçar a cabeça); o balão já muda sozinho */}, aoTerminar: (r) => {} }); // r.fonte === "llm" → use r.nivel_ajuda na evidência; r.fonte === "roteiro" → a fala roteirizada entrou, sem aviso de erro. A espera nunca bloqueia a área de trabalho: nada é desabilitado e o balão não captura clique. Para o avaliar, ou para outra voz: XcoolPensando.iniciar({conversa, alvo, nome}) → h.resolver(texto). VOZ — POST /tts (Google Cloud Text-to-Speech), desde 2026-09-25. Detalhes, vozes, SSML, limites e a prova: TTS.md {texto:"café"} ou {ssml:"…", voz?:"pt-BR-Neural2-B", velocidade?:0.25–2.0} → audio/mpeg (MP3). Chave lida de ~/.config/xcool/google-tts.key, só no cabeçalho; nunca na página nem em log. Cache em cache-tts/ pela hash de (texto/ssml, voz, velocidade): a mesma fala nunca é gerada duas vezes. Log em log-tts.jsonl. Erro → JSON {ok:false, erro} (400 entrada, 502 Google): a página segue sem som. QUEM TEM /tts: a demo (8785, reiniciada) e o robô-acento (8787). Os servidores da 8783 (canteiro), 8784 (conserto) e 8786 (mapa-foto) só ganham /tts quando forem reiniciados — o código é o mesmo arquivo, o processo é que é antigo. TELEMETRIA — componente comum, obrigatório em todo mock de tarefa e em todo minijogo (desde 2026-09-25) Código: telemetria.js (a página) + telemetria_receptor.py (validação, gravação, painel). O servidor.py importa o receptor; na VPS ele roda sozinho atrás do nginx (DEPLOY-TELEMETRIA.md). Um código só, um catálogo só. Contrato: 1. Página: → window.Tele (tarefas) e window.Jogo (minijogos). Nenhuma chamada lança erro nem trava a página. Destino: "/telemetria" por padrão; Tele.config({endpoint}) ou data-endpoint no Tele.inicio({ tarefa: "canteiro", nivel: T.nivel(), parametros: encontroGerado, habilidades: ["EF05MA08"], conversa: "#conversa" }); Tele.acao("soltar_peca", { peca: 12 }, certo); // em cada gesto que o motor julga Tele.fala("ajuda_1", r.fonte, r.fala, r.latencia_ms); // onde a fala entra na conversa (XcoolPensando.aoTerminar) Tele.ajuda(degrau, custoEmTokens, r.fonte); // quando a criança pede ajuda Tele.fim(outcome, weight, tokens, [linhaDeEvidencia]); // onde a evidência é registrada e os tokens pagos Tele.erro(tipo, detalhe) é só para erro que não é gesto (resposta escrita recusada, tempo esgotado); gesto errado é Tele.acao(..., false). O painel soma os dois em "erros". Trecho de integração — JOGO (publicado em jogo.xcool.cc/midia/previa//; no mock local, src="/_llm/telemetria.js" e sem data-endpoint): Jogo.inicio({ jogo: "space-glider", fase: 1, estilo: "desafio", parametros: { velocidade } }); // ao começar a partida e a cada "jogar de novo"; estilo desafio|corrida|livre Jogo.acao("pular"); // no handler de tecla/toque: só conta, não envia Jogo.fase(novaFase, pontos); // ao mudar de fase/nível Jogo.fim("perdeu", pontos, fase); // ganhou | perdeu | saiu | terminou; fechar a aba vira jogo.abandono sozinho Jogo.fim("terminou", pontos, fase, { colocacao: 2, participantes: 4 }); // corrida com pódio (1º lugar pode ser "ganhou") Jogo.fim("saiu"); // brincar livre: a criança fechou por vontade própria DISPUTA — componente comum (DEC-036), desde 2026-09-25; servido em /_llm/disputa.js → window.Disputa 1. Força escondida por habilidade (Elo, começa em 1000), guardada no navegador (localStorage "xcool.disputa.v1"); ?novo zera. 2. Disputa.nivelDoEncontro(hab) → nível 1–6 do próximo encontro: adversário em força − 191 ± 50 (ela vence ~3 em 4). ?nivel=N na URL, ou o seletor dos bastidores, fixa o nível SEM mexer na força guardada. 3. Disputa.registrar(hab, "pass"|"partial"|"fail", nivelDoEncontro) no fim: força += K × (resultado − chance); resultado 1/½/0 (o weight fica na matriz); K adaptativo = 32 + 308·e^(−obs/8) → 6 vitórias levam do nível 1 ao 4. Devolve {antes, depois, K, E}. 4. Disputa.painel(el) desenha nos bastidores o seletor "nível (teste)" e as forças; Disputa.forca(hab) só para bastidores/telemetria. 5. A página gera o encontro PELO NÍVEL (tamanho, padrões, perturbação); nunca mostra a força. Números a calibrar. Trecho: · const nv = Disputa.nivelDoEncontro("EF05MA08"); …montar encontro de nível nv… ao fim: Disputa.registrar("EF05MA08", outcome, nv); nos bastidores: Disputa.painel("#painel-disputa"). Exemplo: canteiro/jogo.js. SATÉLITE — GET /_llm/satelite?lat=&lon=&zoom=&tam=WxH[&scale=2] (desde 2026-09-26, pedido pelo mock mapa-foto) Devolve a imagem do Google Static Maps (maptype=satellite) daquele centro e zoom, em Web Mercator, buscada com a chave de ~/.config/xcool/google-tts.key, que NUNCA vai para o navegador. Cache em disco: llm-local/satelite-cache/. Erro → JSON {ok:false, erro}. A página que usar tem de mostrar "Imagens © Google" junto da foto. Precisa da Maps Static API habilitada na chave.