Enhance exercise generation for family conversations and feelings & affection

- Updated multiple choice exercises to include randomized wrong options for improved engagement and challenge.
- Added new exercise types for reading aloud and speaking from memory, enhancing interactive learning experiences.
- Improved gap fill exercises with clearer instructions and multiple variants for better user understanding.
- Enhanced the vocabulary service to support new exercise types, ensuring robust answer checking and feedback mechanisms.
- Updated localization files to include new instructions and messages related to the new exercise types.
This commit is contained in:
Torsten Schulz (local)
2026-01-20 14:30:19 +01:00
parent e5d4a5f95f
commit 8d32d704b5
8 changed files with 612 additions and 105 deletions

View File

@@ -330,38 +330,72 @@ function createFamilyConversationExercises(nativeLanguageName) {
let exerciseNum = 1; let exerciseNum = 1;
// Multiple Choice: Übersetze Bisaya-Satz in Muttersprache // Multiple Choice: Übersetze Bisaya-Satz in Muttersprache (alle Gespräche)
conversations.forEach((conv, idx) => { conversations.forEach((conv, idx) => {
if (idx < 4) { // Erste 4 als Multiple Choice // Erstelle für jedes Gespräch eine Multiple Choice Übung
const wrongOptions = conversations
.filter((c, i) => i !== idx)
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.map(c => c.native);
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
const correctIndex = options.indexOf(conv.native);
exercises.push({
exerciseTypeId: 2, // multiple_choice
exerciseNumber: exerciseNum++,
title: `Familien-Gespräch ${idx + 1} - Übersetzung`,
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName,
questionData: JSON.stringify({
type: 'multiple_choice',
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`,
options: options
}),
answerData: JSON.stringify({
type: 'multiple_choice',
correctAnswer: correctIndex
}),
explanation: conv.explanation
});
});
// Multiple Choice: Rückwärts-Übersetzung (Was bedeutet dieser Satz?)
conversations.forEach((conv, idx) => {
if (idx < 6) { // Erste 6 als Rückwärts-Übersetzung
const wrongOptions = conversations
.filter((c, i) => i !== idx)
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.map(c => c.native);
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
const correctIndex = options.indexOf(conv.native);
exercises.push({ exercises.push({
exerciseTypeId: 2, // multiple_choice exerciseTypeId: 2, // multiple_choice
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: `Familien-Gespräch ${idx + 1} - Übersetzung`, title: `Familien-Gespräch ${idx + 1} - Was bedeutet dieser Satz?`,
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName, instruction: 'Was bedeutet dieser Bisaya-Satz?',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'multiple_choice', type: 'multiple_choice',
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`, question: `Was bedeutet "${conv.bisaya}"?`,
options: [ options: options
conv.native,
conversations[(idx + 1) % conversations.length].native,
conversations[(idx + 2) % conversations.length].native,
conversations[(idx + 3) % conversations.length].native
]
}), }),
answerData: JSON.stringify({ answerData: JSON.stringify({
type: 'multiple_choice', type: 'multiple_choice',
correctAnswer: 0 correctAnswer: correctIndex
}), }),
explanation: conv.explanation explanation: conv.explanation
}); });
} }
}); });
// Gap Fill: Vervollständige Familiengespräche // Gap Fill: Vervollständige Familiengespräche (mehrere Varianten)
exercises.push({ exercises.push({
exerciseTypeId: 1, // gap_fill exerciseTypeId: 1, // gap_fill
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: 'Familien-Gespräch vervollständigen', title: 'Familien-Gespräch 1 - Vervollständigen',
instruction: 'Vervollständige das Gespräch mit den richtigen Bisaya-Wörtern.', instruction: 'Vervollständige das Gespräch mit den richtigen Bisaya-Wörtern.',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'gap_fill', type: 'gap_fill',
@@ -375,44 +409,40 @@ function createFamilyConversationExercises(nativeLanguageName) {
explanation: '"Nanay" ist "Mama" und "Maayo ko" bedeutet "Mir geht es gut"' explanation: '"Nanay" ist "Mama" und "Maayo ko" bedeutet "Mir geht es gut"'
}); });
// Transformation: Übersetze Muttersprache-Satz nach Bisaya
exercises.push({ exercises.push({
exerciseTypeId: 3, // transformation exerciseTypeId: 1, // gap_fill
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: 'Familien-Gespräch - Übersetzung nach Bisaya', title: 'Familien-Gespräch 2 - Vervollständigen',
instruction: 'Übersetze den Satz ins Bisaya.', instruction: 'Vervollständige das Gespräch mit den richtigen Bisaya-Wörtern.',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'transformation', type: 'gap_fill',
text: conversations[0].native text: 'Person A: {gap} si Tatay? (Wo ist)\nPerson B: {gap} siya sa balay. (Er ist)',
gaps: 2
}), }),
answerData: JSON.stringify({ answerData: JSON.stringify({
type: 'transformation', type: 'gap_fill',
correctAnswer: conversations[0].bisaya answers: ['Asa', 'Naa']
}), }),
explanation: `"${conversations[0].bisaya}" bedeutet "${conversations[0].native}" auf Bisaya. ${conversations[0].explanation}` explanation: '"Asa" bedeutet "wo" und "Naa" bedeutet "ist/sein"'
}); });
// Weitere Multiple Choice: Rückwärts-Übersetzung // Transformation: Übersetze Muttersprache-Satz nach Bisaya (mehrere Varianten)
exercises.push({ conversations.slice(0, 4).forEach((conv, idx) => {
exerciseTypeId: 2, // multiple_choice exercises.push({
exerciseNumber: exerciseNum++, exerciseTypeId: 3, // transformation
title: 'Familien-Gespräch - Was bedeutet dieser Satz?', exerciseNumber: exerciseNum++,
instruction: 'Was bedeutet dieser Bisaya-Satz?', title: `Familien-Gespräch ${idx + 1} - Übersetzung nach Bisaya`,
questionData: JSON.stringify({ instruction: 'Übersetze den Satz ins Bisaya.',
type: 'multiple_choice', questionData: JSON.stringify({
question: `Was bedeutet "${conversations[2].bisaya}"?`, type: 'transformation',
options: [ text: conv.native
conversations[2].native, }),
conversations[3].native, answerData: JSON.stringify({
conversations[4].native, type: 'transformation',
conversations[5].native correctAnswer: conv.bisaya
] }),
}), explanation: `"${conv.bisaya}" bedeutet "${conv.native}" auf Bisaya. ${conv.explanation}`
answerData: JSON.stringify({ });
type: 'multiple_choice',
correctAnswer: 0
}),
explanation: conversations[2].explanation
}); });
return exercises; return exercises;

View File

@@ -330,38 +330,72 @@ function createFeelingsAffectionExercises(nativeLanguageName) {
let exerciseNum = 1; let exerciseNum = 1;
// Multiple Choice: Übersetze Bisaya-Satz in Muttersprache // Multiple Choice: Übersetze Bisaya-Satz in Muttersprache (alle Gespräche)
conversations.forEach((conv, idx) => { conversations.forEach((conv, idx) => {
if (idx < 4) { // Erste 4 als Multiple Choice // Erstelle für jedes Gespräch eine Multiple Choice Übung
const wrongOptions = conversations
.filter((c, i) => i !== idx)
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.map(c => c.native);
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
const correctIndex = options.indexOf(conv.native);
exercises.push({
exerciseTypeId: 2, // multiple_choice
exerciseNumber: exerciseNum++,
title: `Gefühle & Zuneigung ${idx + 1} - Übersetzung`,
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName,
questionData: JSON.stringify({
type: 'multiple_choice',
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`,
options: options
}),
answerData: JSON.stringify({
type: 'multiple_choice',
correctAnswer: correctIndex
}),
explanation: conv.explanation
});
});
// Multiple Choice: Rückwärts-Übersetzung (Was bedeutet dieser Satz?)
conversations.forEach((conv, idx) => {
if (idx < 6) { // Erste 6 als Rückwärts-Übersetzung
const wrongOptions = conversations
.filter((c, i) => i !== idx)
.sort(() => Math.random() - 0.5)
.slice(0, 3)
.map(c => c.native);
const options = [conv.native, ...wrongOptions].sort(() => Math.random() - 0.5);
const correctIndex = options.indexOf(conv.native);
exercises.push({ exercises.push({
exerciseTypeId: 2, // multiple_choice exerciseTypeId: 2, // multiple_choice
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: `Gefühle & Zuneigung ${idx + 1} - Übersetzung`, title: `Gefühle & Zuneigung ${idx + 1} - Was bedeutet dieser Satz?`,
instruction: 'Übersetze den Bisaya-Satz ins ' + nativeLanguageName, instruction: 'Was bedeutet dieser Bisaya-Satz?',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'multiple_choice', type: 'multiple_choice',
question: `Wie sagt man "${conv.bisaya}" auf ${nativeLanguageName}?`, question: `Was bedeutet "${conv.bisaya}"?`,
options: [ options: options
conv.native,
conversations[(idx + 1) % conversations.length].native,
conversations[(idx + 2) % conversations.length].native,
conversations[(idx + 3) % conversations.length].native
]
}), }),
answerData: JSON.stringify({ answerData: JSON.stringify({
type: 'multiple_choice', type: 'multiple_choice',
correctAnswer: 0 correctAnswer: correctIndex
}), }),
explanation: conv.explanation explanation: conv.explanation
}); });
} }
}); });
// Gap Fill: Vervollständige Gefühlsausdrücke // Gap Fill: Vervollständige Gefühlsausdrücke (mehrere Varianten)
exercises.push({ exercises.push({
exerciseTypeId: 1, // gap_fill exerciseTypeId: 1, // gap_fill
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: 'Gefühle & Zuneigung vervollständigen', title: 'Gefühle & Zuneigung 1 - Vervollständigen',
instruction: 'Vervollständige den Satz mit den richtigen Bisaya-Wörtern.', instruction: 'Vervollständige den Satz mit den richtigen Bisaya-Wörtern.',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'gap_fill', type: 'gap_fill',
@@ -375,44 +409,40 @@ function createFeelingsAffectionExercises(nativeLanguageName) {
explanation: '"Gihigugma" bedeutet "lieben" und wird wiederholt, um "auch" auszudrücken' explanation: '"Gihigugma" bedeutet "lieben" und wird wiederholt, um "auch" auszudrücken'
}); });
// Transformation: Übersetze Muttersprache-Satz nach Bisaya
exercises.push({ exercises.push({
exerciseTypeId: 3, // transformation exerciseTypeId: 1, // gap_fill
exerciseNumber: exerciseNum++, exerciseNumber: exerciseNum++,
title: 'Gefühle & Zuneigung - Übersetzung nach Bisaya', title: 'Gefühle & Zuneigung 2 - Vervollständigen',
instruction: 'Übersetze den Satz ins Bisaya.', instruction: 'Vervollständige den Satz mit den richtigen Bisaya-Wörtern.',
questionData: JSON.stringify({ questionData: JSON.stringify({
type: 'transformation', type: 'gap_fill',
text: conversations[0].native text: 'Person A: {gap} ko nga nakita ka. (Ich bin glücklich)\nPerson B: {gap} ko pud. (Ich auch)',
gaps: 2
}), }),
answerData: JSON.stringify({ answerData: JSON.stringify({
type: 'transformation', type: 'gap_fill',
correctAnswer: conversations[0].bisaya answers: ['Nalipay', 'Nalipay']
}), }),
explanation: `"${conversations[0].bisaya}" bedeutet "${conversations[0].native}" auf Bisaya. ${conversations[0].explanation}` explanation: '"Nalipay" bedeutet "glücklich sein"'
}); });
// Weitere Multiple Choice: Rückwärts-Übersetzung // Transformation: Übersetze Muttersprache-Satz nach Bisaya (mehrere Varianten)
exercises.push({ conversations.slice(0, 4).forEach((conv, idx) => {
exerciseTypeId: 2, // multiple_choice exercises.push({
exerciseNumber: exerciseNum++, exerciseTypeId: 3, // transformation
title: 'Gefühle & Zuneigung - Was bedeutet dieser Satz?', exerciseNumber: exerciseNum++,
instruction: 'Was bedeutet dieser Bisaya-Satz?', title: `Gefühle & Zuneigung ${idx + 1} - Übersetzung nach Bisaya`,
questionData: JSON.stringify({ instruction: 'Übersetze den Satz ins Bisaya.',
type: 'multiple_choice', questionData: JSON.stringify({
question: `Was bedeutet "${conversations[2].bisaya}"?`, type: 'transformation',
options: [ text: conv.native
conversations[2].native, }),
conversations[3].native, answerData: JSON.stringify({
conversations[4].native, type: 'transformation',
conversations[5].native correctAnswer: conv.bisaya
] }),
}), explanation: `"${conv.bisaya}" bedeutet "${conv.native}" auf Bisaya. ${conv.explanation}`
answerData: JSON.stringify({ });
type: 'multiple_choice',
correctAnswer: 0
}),
explanation: conversations[2].explanation
}); });
return exercises; return exercises;

View File

@@ -1421,6 +1421,15 @@ export default class VocabService {
? answerData.answers.join(', ') ? answerData.answers.join(', ')
: answerData.answers; : answerData.answers;
} }
// Für Reading Aloud: Extrahiere den erwarteten Text
else if (questionData.type === 'reading_aloud') {
correctAnswer = questionData.text || answerData.expectedText || '';
}
// Für Speaking From Memory: Extrahiere erwarteten Text oder Schlüsselwörter
else if (questionData.type === 'speaking_from_memory') {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
// Fallback: Versuche correct oder correctAnswer // Fallback: Versuche correct oder correctAnswer
else { else {
correctAnswer = Array.isArray(answerData.correct) correctAnswer = Array.isArray(answerData.correct)
@@ -1438,6 +1447,11 @@ export default class VocabService {
}; };
} }
async _getExerciseTypeIdByName(typeName) {
const type = await VocabGrammarExerciseType.findOne({ where: { name: typeName } });
return type ? type.id : null;
}
_checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) { _checkAnswer(answerData, questionData, userAnswer, exerciseTypeId) {
// Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden // Vereinfachte Antwortprüfung - kann je nach Übungstyp erweitert werden
if (!answerData || userAnswer === undefined || userAnswer === null) return false; if (!answerData || userAnswer === undefined || userAnswer === null) return false;
@@ -1476,6 +1490,32 @@ export default class VocabService {
} }
} }
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
const normalizedExpected = normalize(expectedText);
const normalizedUser = normalize(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') {
// Exakter Vergleich (kann später mit Levenshtein erweitert werden)
return normalizedUser === normalizedExpected;
}
// Für speaking_from_memory: Flexibler Vergleich (Schlüsselwörter)
if (parsedQuestionData.type === 'speaking_from_memory') {
const keywords = parsedQuestionData.keywords || [];
if (keywords.length === 0) {
// Fallback: Exakter Vergleich
return normalizedUser === normalizedExpected;
}
// Prüfe ob alle Schlüsselwörter vorhanden sind
return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
}
}
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden) // Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
const normalize = (str) => String(str || '').trim().toLowerCase(); const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || []; const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];

View File

@@ -0,0 +1,16 @@
-- ============================================
-- Neue Übungstypen für Sprachproduktion hinzufügen
-- ============================================
-- Führe diese Queries direkt auf dem Server aus
-- Neue Übungstypen hinzufügen
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('reading_aloud', 'Laut vorlesen - Übung zur Verbesserung der Aussprache'),
('speaking_from_memory', 'Aus dem Kopf sprechen - Übung zur aktiven Sprachproduktion')
ON CONFLICT (name) DO NOTHING;
-- ============================================
-- Hinweis:
-- - reading_aloud: Text wird angezeigt, User liest vor, Speech Recognition prüft
-- - speaking_from_memory: Prompt wird angezeigt, User spricht frei, manuelle/automatische Bewertung
-- ============================================

View File

@@ -392,7 +392,20 @@
"allLessonsCompleted": "Alle Lektionen abgeschlossen!", "allLessonsCompleted": "Alle Lektionen abgeschlossen!",
"startExercises": "Zur Kapitel-Prüfung", "startExercises": "Zur Kapitel-Prüfung",
"correctAnswer": "Richtige Antwort", "correctAnswer": "Richtige Antwort",
"alternatives": "Alternative Antworten" "alternatives": "Alternative Antworten",
"notStarted": "Nicht begonnen",
"readingAloudInstruction": "Lies den Text laut vor. Klicke auf 'Aufnahme starten' und beginne zu sprechen.",
"speakingFromMemoryInstruction": "Sprich frei aus dem Kopf. Verwende die angezeigten Schlüsselwörter.",
"startRecording": "Aufnahme starten",
"stopRecording": "Aufnahme stoppen",
"startSpeaking": "Sprechen starten",
"recording": "Aufnahme läuft",
"listening": "Höre zu...",
"recordingStopped": "Aufnahme beendet",
"recordingError": "Aufnahme-Fehler",
"recognizedText": "Erkannter Text",
"speechRecognitionNotSupported": "Speech Recognition wird von diesem Browser nicht unterstützt. Bitte verwende Chrome oder Edge.",
"keywords": "Schlüsselwörter"
} }
} }
} }

View File

@@ -392,7 +392,20 @@
"allLessonsCompleted": "All lessons completed!", "allLessonsCompleted": "All lessons completed!",
"startExercises": "Start Chapter Test", "startExercises": "Start Chapter Test",
"correctAnswer": "Correct Answer", "correctAnswer": "Correct Answer",
"alternatives": "Alternative Answers" "alternatives": "Alternative Answers",
"notStarted": "Not Started",
"readingAloudInstruction": "Read the text aloud. Click 'Start Recording' and begin speaking.",
"speakingFromMemoryInstruction": "Speak freely from memory. Use the displayed keywords.",
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",
"startSpeaking": "Start Speaking",
"recording": "Recording...",
"listening": "Listening...",
"recordingStopped": "Recording stopped",
"recordingError": "Recording error",
"recognizedText": "Recognized Text",
"speechRecognitionNotSupported": "Speech Recognition is not supported by this browser. Please use Chrome or Edge.",
"keywords": "Keywords"
} }
} }
} }

View File

@@ -40,11 +40,11 @@
<span v-if="getLessonProgress(lesson.id)?.completed" class="badge completed"> <span v-if="getLessonProgress(lesson.id)?.completed" class="badge completed">
{{ $t('socialnetwork.vocab.courses.completed') }} {{ $t('socialnetwork.vocab.courses.completed') }}
</span> </span>
<span v-if="getLessonProgress(lesson.id)?.score" class="score"> <span v-else-if="getLessonProgress(lesson.id)?.score" class="score">
{{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id).score }}% {{ $t('socialnetwork.vocab.courses.score') }}: {{ getLessonProgress(lesson.id).score }}%
</span> </span>
<span v-else-if="!getLessonProgress(lesson.id)" class="status-new"> <span v-else class="status-new">
Nicht begonnen {{ $t('socialnetwork.vocab.courses.notStarted') }}
</span> </span>
</td> </td>
<td class="lesson-actions"> <td class="lesson-actions">
@@ -296,7 +296,7 @@ export default {
.lessons-table td { .lessons-table td {
padding: 15px; padding: 15px;
vertical-align: top; vertical-align: middle;
} }
.lesson-number { .lesson-number {
@@ -328,6 +328,8 @@ export default {
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
align-items: flex-start; align-items: flex-start;
justify-content: center;
min-height: 60px;
} }
.badge.completed { .badge.completed {
@@ -356,37 +358,42 @@ export default {
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-start;
} }
.btn-start { .btn-start {
padding: 8px 16px; padding: 8px 16px;
background: #007bff; background: #F9A22C;
color: white; color: #000000;
border: none; border: 1px solid #F9A22C;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9em; font-size: 0.9em;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s ease; transition: background 0.05s;
} }
.btn-start:hover { .btn-start:hover {
background: #0056b3; background: #fdf1db;
color: #7E471B;
border: 1px solid #7E471B;
} }
.btn-edit { .btn-edit {
padding: 6px 12px; padding: 6px 12px;
background: #ffc107; background: #F9A22C;
color: #333; color: #000000;
border: none; border: 1px solid #F9A22C;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.85em; font-size: 0.85em;
transition: background-color 0.2s ease; transition: background 0.05s;
} }
.btn-edit:hover { .btn-edit:hover {
background: #e0a800; background: #fdf1db;
color: #7E471B;
border: 1px solid #7E471B;
} }
.btn-delete { .btn-delete {

View File

@@ -242,6 +242,103 @@
</div> </div>
</div> </div>
<!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p class="exercise-instruction">{{ $t('socialnetwork.vocab.courses.readingAloudInstruction') }}</p>
<div class="reading-aloud-controls">
<button
v-if="!isRecording(exercise.id)"
@click="startReadingAloud(exercise.id)"
class="btn-record"
:disabled="!isSpeechRecognitionSupported"
>
{{ $t('socialnetwork.vocab.courses.startRecording') }}
</button>
<button
v-else
@click="stopReadingAloud(exercise.id)"
class="btn-stop-record"
>
{{ $t('socialnetwork.vocab.courses.stopRecording') }}
</button>
</div>
<div v-if="recordingStatus[exercise.id]" class="recording-status" :class="{ 'recording': isRecording(exercise.id) }">
<span v-if="isRecording(exercise.id)">{{ $t('socialnetwork.vocab.courses.recording') }}...</span>
<span v-else>{{ recordingStatus[exercise.id] }}</span>
</div>
<div v-if="recognizedText[exercise.id]" class="recognized-text">
<strong>{{ $t('socialnetwork.vocab.courses.recognizedText') }}:</strong>
<p>{{ recognizedText[exercise.id] }}</p>
</div>
<button
v-if="recognizedText[exercise.id] && !isRecording(exercise.id)"
@click="checkAnswer(exercise.id)"
class="btn-check"
>
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
<div v-if="!isSpeechRecognitionSupported" class="speech-not-supported">
<p>{{ $t('socialnetwork.vocab.courses.speechRecognitionNotSupported') }}</p>
</div>
</div>
<!-- Speaking From Memory Übung -->
<div v-else-if="getExerciseType(exercise) === 'speaking_from_memory'" class="speaking-from-memory-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p class="exercise-instruction">{{ $t('socialnetwork.vocab.courses.speakingFromMemoryInstruction') }}</p>
<div v-if="getQuestionData(exercise)?.keywords" class="keywords-hint">
<strong>{{ $t('socialnetwork.vocab.courses.keywords') }}:</strong>
<span v-for="(keyword, idx) in getQuestionData(exercise).keywords" :key="idx" class="keyword-tag">{{ keyword }}</span>
</div>
<div class="speaking-controls">
<button
v-if="!isRecording(exercise.id)"
@click="startSpeakingFromMemory(exercise.id)"
class="btn-record"
:disabled="!isSpeechRecognitionSupported"
>
{{ $t('socialnetwork.vocab.courses.startSpeaking') }}
</button>
<button
v-else
@click="stopSpeakingFromMemory(exercise.id)"
class="btn-stop-record"
>
{{ $t('socialnetwork.vocab.courses.stopRecording') }}
</button>
</div>
<div v-if="recordingStatus[exercise.id]" class="recording-status" :class="{ 'recording': isRecording(exercise.id) }">
<span v-if="isRecording(exercise.id)">{{ $t('socialnetwork.vocab.courses.recording') }}...</span>
<span v-else>{{ recordingStatus[exercise.id] }}</span>
</div>
<div v-if="recognizedText[exercise.id]" class="recognized-text">
<strong>{{ $t('socialnetwork.vocab.courses.recognizedText') }}:</strong>
<p>{{ recognizedText[exercise.id] }}</p>
</div>
<button
v-if="recognizedText[exercise.id] && !isRecording(exercise.id)"
@click="checkAnswer(exercise.id)"
class="btn-check"
>
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
<div v-if="!isSpeechRecognitionSupported" class="speech-not-supported">
<p>{{ $t('socialnetwork.vocab.courses.speechRecognitionNotSupported') }}</p>
</div>
</div>
<!-- Fallback für unbekannte Typen --> <!-- Fallback für unbekannte Typen -->
<div v-else class="unknown-exercise"> <div v-else class="unknown-exercise">
<p>Übungstyp: {{ getExerciseType(exercise) }}</p> <p>Übungstyp: {{ getExerciseType(exercise) }}</p>
@@ -347,6 +444,12 @@ export default {
isCheckingLessonCompletion: false, // Flag um Endlosschleife zu verhindern isCheckingLessonCompletion: false, // Flag um Endlosschleife zu verhindern
isNavigatingToNext: false, // Flag um mehrfache Navigation zu verhindern isNavigatingToNext: false, // Flag um mehrfache Navigation zu verhindern
showNextLessonDialog: false, showNextLessonDialog: false,
// Speech Recognition für Reading Aloud und Speaking From Memory
speechRecognition: null,
activeRecognition: {}, // { [exerciseId]: SpeechRecognition instance }
recognizedText: {}, // { [exerciseId]: string }
recordingStatus: {}, // { [exerciseId]: string }
isSpeechRecognitionSupported: false,
nextLessonId: null, nextLessonId: null,
showCompletionDialog: false, showCompletionDialog: false,
showErrorDialog: false, showErrorDialog: false,
@@ -633,7 +736,9 @@ export default {
3: 'sentence_building', 3: 'sentence_building',
4: 'transformation', 4: 'transformation',
5: 'conjugation', 5: 'conjugation',
6: 'declension' 6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory'
}; };
return typeMap[exercise.exerciseTypeId] || 'unknown'; return typeMap[exercise.exerciseTypeId] || 'unknown';
}, },
@@ -710,6 +815,9 @@ export default {
} else if (exerciseType === 'transformation') { } else if (exerciseType === 'transformation') {
// Transformation: String // Transformation: String
answer = String(answer || '').trim(); answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim();
} }
const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer }); const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer });
@@ -1066,10 +1174,124 @@ export default {
this.vocabTrainerAnswer = ''; this.vocabTrainerAnswer = '';
}); });
} }
},
initSpeechRecognition() {
// Prüfe Browser-Support für Speech Recognition
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
this.isSpeechRecognitionSupported = false;
console.warn('Speech Recognition wird von diesem Browser nicht unterstützt');
return;
}
this.isSpeechRecognitionSupported = true;
},
isRecording(exerciseId) {
return !!this.activeRecognition[exerciseId];
},
startReadingAloud(exerciseId) {
const exercise = this.lesson.grammarExercises.find(e => e.id === exerciseId);
if (!exercise) return;
const qData = this.getQuestionData(exercise);
const expectedText = qData.text || '';
this.startRecognition(exerciseId, expectedText);
},
stopReadingAloud(exerciseId) {
this.stopRecognition(exerciseId);
},
startSpeakingFromMemory(exerciseId) {
const exercise = this.lesson.grammarExercises.find(e => e.id === exerciseId);
if (!exercise) return;
const qData = this.getQuestionData(exercise);
const expectedText = qData.expectedText || qData.text || '';
this.startRecognition(exerciseId, expectedText);
},
stopSpeakingFromMemory(exerciseId) {
this.stopRecognition(exerciseId);
},
startRecognition(exerciseId, expectedText) {
if (!this.isSpeechRecognitionSupported) {
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.speechRecognitionNotSupported');
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
// Konfiguriere Recognition
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'de-DE'; // Kann später dynamisch basierend auf Kurs-Sprache gesetzt werden
let finalTranscript = '';
recognition.onresult = (event) => {
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interimTranscript += transcript;
}
}
// Aktualisiere erkannten Text
this.recognizedText[exerciseId] = finalTranscript.trim() || interimTranscript;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.listening');
};
recognition.onerror = (event) => {
console.error('Speech Recognition Fehler:', event.error);
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingError') + ': ' + event.error;
this.stopRecognition(exerciseId);
};
recognition.onend = () => {
// Speichere finalen Text in exerciseAnswers
if (finalTranscript.trim()) {
this.exerciseAnswers[exerciseId] = finalTranscript.trim();
}
this.activeRecognition[exerciseId] = null;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingStopped');
};
// Starte Recognition
try {
recognition.start();
this.activeRecognition[exerciseId] = recognition;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recording');
this.recognizedText[exerciseId] = '';
} catch (error) {
console.error('Fehler beim Starten der Speech Recognition:', error);
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingError') + ': ' + error.message;
}
},
stopRecognition(exerciseId) {
if (this.activeRecognition[exerciseId]) {
try {
this.activeRecognition[exerciseId].stop();
} catch (error) {
console.error('Fehler beim Stoppen der Speech Recognition:', error);
}
this.activeRecognition[exerciseId] = null;
}
} }
}, },
async mounted() { async mounted() {
// Prüfe Speech Recognition Support
this.initSpeechRecognition();
await this.loadLesson(); await this.loadLesson();
},
beforeUnmount() {
// Stoppe alle aktiven Recognition-Instanzen
Object.keys(this.activeRecognition).forEach(exerciseId => {
this.stopRecognition(exerciseId);
});
} }
}; };
</script> </script>
@@ -1626,6 +1848,142 @@ export default {
background: #0056b3; background: #0056b3;
} }
/* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise,
.speaking-from-memory-exercise {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.reading-aloud-controls,
.speaking-controls {
margin: 20px 0;
display: flex;
gap: 10px;
align-items: center;
}
.btn-record,
.btn-stop-record {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
}
.btn-record {
background: #28a745;
color: white;
}
.btn-record:hover:not(:disabled) {
background: #218838;
}
.btn-record:disabled {
background: #6c757d;
cursor: not-allowed;
}
.btn-stop-record {
background: #dc3545;
color: white;
}
.btn-stop-record:hover {
background: #c82333;
}
.btn-check {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
margin-top: 15px;
}
.btn-check:hover {
background: #0056b3;
}
.recording-status {
margin: 15px 0;
padding: 10px;
border-radius: 4px;
font-weight: 500;
}
.recording-status.recording {
background: #fff3cd;
color: #856404;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.recognized-text {
margin: 20px 0;
padding: 15px;
background: white;
border: 2px solid #dee2e6;
border-radius: 6px;
}
.recognized-text strong {
display: block;
margin-bottom: 10px;
color: #495057;
}
.recognized-text p {
margin: 0;
font-size: 1.1em;
color: #212529;
line-height: 1.6;
}
.keywords-hint {
margin: 15px 0;
padding: 12px;
background: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
}
.keywords-hint strong {
display: block;
margin-bottom: 8px;
color: #0056b3;
}
.keyword-tag {
display: inline-block;
padding: 4px 10px;
margin: 4px 4px 0 0;
background: #007bff;
color: white;
border-radius: 12px;
font-size: 0.9em;
}
.speech-not-supported {
margin-top: 15px;
padding: 12px;
background: #f8d7da;
border-left: 4px solid #dc3545;
border-radius: 4px;
color: #721c24;
}
/* Dialog Styles */ /* Dialog Styles */
.dialog-overlay { .dialog-overlay {
position: fixed; position: fixed;