/** * POST /api/judge — Motor de feedback em 3 eixos. * Avalia a fala do aluno com tolerância estilo criança: * erro gramatical que não impede entendimento = sucesso comunicativo. */ import { Router } from "express"; import { z } from "zod"; import { chatJSON, MODELS } from "../lib/llm.js"; export const judgeRouter = Router(); // Schema de entrada const JudgeInputSchema = z.object({ missionId: z.string(), scenario: z.string(), learnerGoalPt: z.string(), expectedIt: z.string().optional(), utteranceText: z.string().optional(), transcriptRaw: z.string().optional(), audioBase64: z.string().optional(), }); // Schema de saída estruturada // Schema leniente: LLM pode retornar campos vazios ou estrutura diferente // Usamos preprocess para normalizar a entrada const GrammarErrorSchema = z.preprocess( (val) => { if (typeof val !== "object" || val === null) return {}; const obj = val as Record; return { it_said: typeof obj.it_said === "string" ? obj.it_said : "", problem_pt: typeof obj.problem_pt === "string" ? obj.problem_pt : "", fix_it: typeof obj.fix_it === "string" ? obj.fix_it : "", severity: ["blocks_understanding", "minor", "cosmetic"].includes(obj.severity as string) ? obj.severity : "minor", }; }, z.object({ it_said: z.string(), problem_pt: z.string(), fix_it: z.string(), severity: z.enum(["blocks_understanding", "minor", "cosmetic"]), }), ); const RescueCardSchema = z.object({ come_si_dice: z.string(), frase_it: z.string(), explicacao_pt: z.string(), }); const JudgeOutputSchema = z.object({ meaning_conveyed: z.enum(["full", "partial", "failed"]), naturalness: z.number().min(0).max(100), natural_version: z.string(), grammar: z.array(GrammarErrorSchema), rescue_card: RescueCardSchema.nullable().optional(), next_move: z.preprocess( (val) => { if (typeof val === "string") return { italian: val, portuguese: "" }; if (typeof val === "object" && val !== null) { const obj = val as Record; return { italian: typeof obj.italian === "string" ? obj.italian : "", portuguese: typeof obj.portuguese === "string" ? obj.portuguese : "", }; } return { italian: "", portuguese: "" }; }, z.object({ italian: z.string(), portuguese: z.string(), }), ), praise_or_push: z.string(), }); /** System prompt do juiz — a regra de ouro do produto */ const JUDGE_SYSTEM_PROMPT = `Você é o avaliador de italiano do app Italiano Vivo. Sua filosofia é TOLERÂNCIA ESTILO CRIANÇA: o aluno está aprendendo como uma criança aprende — errando mas se comunicando. ## Regra de Ouro meaning_conveyed=full significa que a INTENÇÃO COMUNICATIVA foi atingida, MESMO com erros gramaticais graves. Penalize ABANDONAR A MENSAGEM, não errar conjugação. Um italiano na rua entende "io volere acqua" perfeitamente — isso é sucesso comunicativo. ## Os 3 Eixos (sempre avalie todos separadamente) 1. **meaning_conveyed**: "full" | "partial" | "failed" - full = o interlocutor italiano entenderia a intenção sem ambiguidade - partial = parte da mensagem passou, mas algo importante se perdeu - failed = a mensagem não foi transmitida ou foi completamente mal-entendida 2. **naturalness** (0-100) + natural_version - Quão próximo soou de como um italiano nativo diria? - natural_version: reescreva a frase COMO UM ITALIANO DIRIA, natural e coloquial quando apropriado 3. **grammar**: array de erros encontrados - severity "blocks_understanding": erro que GEROU ou GERARIA mal-entendido real - severity "minor": erro gramatical que não impediu entendimento (conjugação, artigo, preposição) - severity "cosmetic": sotaque, pronúncia leve, hesitação - Se não houver erros, retorne array vazio [] ## Sensibilidade Especial - Geminadas italianas: pala/balla, nono/nonna, casa/cassa — marque como minor se o aluno confundiu, NUNCA como blocks_understanding a menos que o contexto mostre ambiguidade real - Sotaque brasileiro: seja generoso. O objetivo é inteligibilidade, não perfeição fonética - Circunlóquio e estratégias de comunicação ("non so la parola, è una cosa che...") são POSITIVOS — mostram competência comunicativa ## rescue_card (opcional) Forneça SOMENTE quando o aluno claramente travou por falta de vocabulário (pausa longa, abandonou a frase, pediu ajuda). Ensine o salvavita aplicável: - come_si_dice: a expressão de resgate em italiano - frase_it: exemplo completo - explicacao_pt: quando usar, em PT-BR simples ## next_move Como o interlocutor italiano responderia NATURALMENTE à fala do aluno. Escreva em italiano + tradução PT. Isso mantém o diálogo vivo. ## praise_or_push UMA frase curta em PT-BR. NUNCA diga "Errado". Use padrões como: - "Quase lá! Um italiano diria: …" - "Boa tentativa! Na Itália você ouviria: …" - "Isso mesmo! Agora tente também: …" - "Você se fez entender! Para soar mais natural: …" Responda SEMPRE em JSON válido seguindo o schema.`; judgeRouter.post("/", async (req, res, next) => { try { const input = JudgeInputSchema.parse(req.body); // Se veio áudio sem transcrição, transcrever primeiro via ASR local (faster-whisper). // NOTA: O proxy LiteLLM NÃO aceita partes de áudio (qwen omni devolve 400), // então usamos faster-whisper local em vez do modelo omni. let transcript = input.transcriptRaw ?? input.utteranceText ?? ""; if (!transcript && input.audioBase64) { const { transcribeAudio } = await import("./transcribe.js"); const result = await transcribeAudio(input.audioBase64, "audio/webm"); transcript = result.text; } if (!transcript) { res.status(400).json({ error: "Forneça utteranceText, transcriptRaw ou audioBase64" }); return; } const userPrompt = `## Contexto da Missão Missão: ${input.missionId} Cenário: ${input.scenario} Objetivo do aluno (PT): ${input.learnerGoalPt} ${input.expectedIt ? `Frase esperada (IT): ${input.expectedIt}` : ""} ## Fala do Aluno Transcrição: "${transcript}" Avalie seguindo os 3 eixos. Lembre-se: tolerância estilo criança.`; const verdict = await chatJSON({ model: MODELS.chat, system: JUDGE_SYSTEM_PROMPT, user: userPrompt, schema: JudgeOutputSchema, enableFallback: true, }); res.json(verdict); } catch (error) { next(error); } });