feat: add A2-B1 course expansion features including listening activities and conversation sessions

- Updated VocabCourseView.vue to display CEFR level for courses.
- Enhanced VocabLessonView.vue with new sections for listening activities and conversation labs, including audio controls and transcript toggling.
- Created migration to add new columns to vocab_course and new tables for conversation sessions and listening activities.
- Implemented models for vocab_conversation_session and vocab_listening_activity.
- Developed scripts to create new A2 and B1 bridge courses with structured lesson plans.
- Documented the A2-B1 expansion plan detailing course structure, learning goals, and assessment criteria.
This commit is contained in:
Torsten Schulz (local)
2026-08-27 15:18:16 +02:00
parent d9397f741f
commit f9c884b3cf
16 changed files with 680 additions and 12 deletions

View File

@@ -12,6 +12,7 @@
<div class="course-info surface-card">
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
<span v-if="course.cefrLevel">GER: {{ course.cefrLevel }}</span>
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
<span v-if="course.shareCode && isOwner" class="share-code">
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>

View File

@@ -545,6 +545,40 @@
</div>
</div>
</details>
<section v-if="listeningActivities.length" class="didactic-card listening-lab">
<h4>Hörtraining</h4>
<article v-for="activity in listeningActivities" :key="activity.id" class="listening-lab__activity">
<p><strong>{{ activity.speed === 'natural' ? 'Natürliches Tempo' : activity.speed }}</strong></p>
<audio :src="activity.audioUrl" controls preload="metadata" />
<button type="button" class="button-secondary" @click="toggleTranscript(activity)">
{{ listeningTranscripts[activity.id] ? 'Transkript ausblenden' : 'Transkript anzeigen' }}
</button>
<p v-if="listeningTranscripts[activity.id]" class="listening-lab__transcript">{{ listeningTranscripts[activity.id].transcript }}</p>
</article>
</section>
<section v-if="assistantAvailable" class="didactic-card conversation-lab">
<h4>Gesprächslabor</h4>
<p>Übe ein längeres Rollengespräch. Korrekturen kommen erst nach der Sitzung.</p>
<template v-if="!conversationSession">
<label><span>Rolle der KI</span><input v-model="conversationScenario.role" placeholder="z. B. Nachbarin" /></label>
<label><span>Gesprächsziel</span><input v-model="conversationScenario.goal" placeholder="z. B. einen Termin verschieben" /></label>
<button type="button" @click="startConversation" :disabled="conversationSubmitting">Gespräch starten</button>
</template>
<template v-else>
<div class="language-assistant-chat">
<article v-for="(turn, index) in conversationSession.turns" :key="`conversation-${index}`" class="assistant-message" :class="`assistant-message--${turn.role}`">
<strong>{{ turn.role === 'assistant' ? 'Gesprächspartner' : 'Du' }}</strong><p>{{ turn.content }}</p>
</article>
</div>
<textarea v-model="conversationInput" rows="3" placeholder="Dein Gesprächsbeitrag" />
<button type="button" @click="sendConversationTurn" :disabled="conversationSubmitting || !conversationInput.trim()">Antworten</button>
<button type="button" class="button-secondary" @click="completeConversation" :disabled="conversationSubmitting">Gespräch beenden</button>
<p v-if="conversationSession.summary">{{ conversationSession.summary.nextStep }}</p>
</template>
<p v-if="conversationError" class="form-error">{{ conversationError }}</p>
</section>
</div>
<!-- Übungen-Tab (Kapitel-Prüfung) -->
@@ -1163,6 +1197,13 @@ export default {
assistantWaitElapsedSeconds: 0,
assistantWaitTimer: null,
isAssistantFocused: false,
listeningActivities: [],
listeningTranscripts: {},
conversationSession: null,
conversationInput: '',
conversationScenario: { role: '', goal: '', title: '', level: 'A2', difficulty: 'guided' },
conversationSubmitting: false,
conversationError: '',
nextLessonId: null,
showCompletionDialog: false,
showErrorDialog: false,
@@ -2798,6 +2839,11 @@ export default {
this.assistantMessages = [];
this.assistantInput = '';
this.assistantError = '';
this.listeningActivities = [];
this.listeningTranscripts = {};
this.conversationSession = null;
this.conversationInput = '';
this.conversationError = '';
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exerciseSequentialIndex = 0;
@@ -2842,6 +2888,7 @@ export default {
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
this.lesson = res.data;
await this.loadListeningActivities();
await this.loadCourseLanguageNames();
await this.loadCourseProgressForBoost();
debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
@@ -2963,6 +3010,71 @@ export default {
openLanguageAssistantSettings() {
this.$router.push('/settings/language-assistant');
},
async loadListeningActivities() {
try {
const { data } = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/listening-activities`);
this.listeningActivities = Array.isArray(data) ? data : [];
} catch (e) {
this.listeningActivities = [];
}
},
async toggleTranscript(activity) {
if (this.listeningTranscripts[activity.id]) {
const next = { ...this.listeningTranscripts };
delete next[activity.id];
this.listeningTranscripts = next;
return;
}
try {
const { data } = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/listening-activities`, { params: { includeTranscript: true } });
const full = (data || []).find((item) => item.id === activity.id);
if (full?.transcript) this.listeningTranscripts = { ...this.listeningTranscripts, [activity.id]: full };
} catch (e) {
// Die Lektion bleibt auch ohne Transkript nutzbar.
}
},
async startConversation() {
if (!this.conversationScenario.role.trim() || !this.conversationScenario.goal.trim()) return;
this.conversationSubmitting = true;
this.conversationError = '';
try {
const { data } = await apiClient.post(`/api/vocab/lessons/${this.lessonId}/conversation-sessions`, {
scenario: { ...this.conversationScenario, title: this.lesson?.title || 'Alltagssituation' }
});
this.conversationSession = data;
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
async sendConversationTurn() {
const message = this.conversationInput.trim();
if (!message || !this.conversationSession) return;
this.conversationSubmitting = true;
this.conversationError = '';
try {
const { data } = await apiClient.post(`/api/vocab/conversation-sessions/${this.conversationSession.id}/turns`, { message });
this.conversationSession = data.session;
this.conversationInput = '';
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
async completeConversation() {
if (!this.conversationSession) return;
this.conversationSubmitting = true;
try {
const { data } = await apiClient.post(`/api/vocab/conversation-sessions/${this.conversationSession.id}/complete`);
this.conversationSession = data;
} catch (e) {
this.conversationError = e.response?.data?.error || e.message;
} finally {
this.conversationSubmitting = false;
}
},
buildAssistantPrompt(preset) {
const lessonTitle = this.lesson?.title || this.$t('socialnetwork.vocab.courses.thisLesson');
const firstPattern = this.lessonDidactics.corePatterns?.[0];