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

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

View File

@@ -242,6 +242,103 @@
</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 -->
<div v-else class="unknown-exercise">
<p>Übungstyp: {{ getExerciseType(exercise) }}</p>
@@ -347,6 +444,12 @@ export default {
isCheckingLessonCompletion: false, // Flag um Endlosschleife zu verhindern
isNavigatingToNext: false, // Flag um mehrfache Navigation zu verhindern
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,
showCompletionDialog: false,
showErrorDialog: false,
@@ -633,7 +736,9 @@ export default {
3: 'sentence_building',
4: 'transformation',
5: 'conjugation',
6: 'declension'
6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
@@ -710,6 +815,9 @@ export default {
} else if (exerciseType === 'transformation') {
// Transformation: String
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 });
@@ -1066,10 +1174,124 @@ export default {
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() {
// Prüfe Speech Recognition Support
this.initSpeechRecognition();
await this.loadLesson();
},
beforeUnmount() {
// Stoppe alle aktiven Recognition-Instanzen
Object.keys(this.activeRecognition).forEach(exerciseId => {
this.stopRecognition(exerciseId);
});
}
};
</script>
@@ -1626,6 +1848,142 @@ export default {
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-overlay {
position: fixed;