You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

174 lines
6.6 KiB
TypeScript

/**
* 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";
import { pronunciationNotesBlock } from "../lib/pronuncia.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<string, unknown>;
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<string, unknown>;
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}"
${[pronunciationNotesBlock(transcript), input.expectedIt ? pronunciationNotesBlock(input.expectedIt) : ""]
.filter(Boolean)
.join("\n\n")}
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);
}
});