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.
169 lines
6.4 KiB
TypeScript
169 lines
6.4 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";
|
|
|
|
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}"
|
|
|
|
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);
|
|
}
|
|
});
|