diff --git a/backend-lehrer/learning_units_api.py b/backend-lehrer/learning_units_api.py index a853122..7418e89 100644 --- a/backend-lehrer/learning_units_api.py +++ b/backend-lehrer/learning_units_api.py @@ -348,3 +348,29 @@ def api_get_next_review(unit_id: str, limit: int = 5): except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + +class StoryGeneratePayload(BaseModel): + vocabulary: List[Dict[str, Any]] + language: str = "en" + grade_level: str = "5-8" + + +@router.post("/{unit_id}/generate-story") +def api_generate_story(unit_id: str, payload: StoryGeneratePayload): + """Generate a short story using vocabulary words.""" + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + try: + from story_generator import generate_story + result = generate_story( + vocabulary=payload.vocabulary, + language=payload.language, + grade_level=payload.grade_level, + ) + return result + except Exception as e: + logger.error(f"Story generation failed for {unit_id}: {e}") + raise HTTPException(status_code=500, detail=f"Story-Generierung fehlgeschlagen: {e}") + diff --git a/backend-lehrer/main.py b/backend-lehrer/main.py index 0ac8252..7af04f7 100644 --- a/backend-lehrer/main.py +++ b/backend-lehrer/main.py @@ -106,6 +106,10 @@ app.include_router(correction_router, prefix="/api") from learning_units_api import router as learning_units_router app.include_router(learning_units_router, prefix="/api") +# --- 4b. Learning Progress --- +from progress_api import router as progress_router +app.include_router(progress_router, prefix="/api") + from unit_api import router as unit_router app.include_router(unit_router) # Already has /api/units prefix diff --git a/backend-lehrer/progress_api.py b/backend-lehrer/progress_api.py new file mode 100644 index 0000000..9b1c7d8 --- /dev/null +++ b/backend-lehrer/progress_api.py @@ -0,0 +1,131 @@ +""" +Progress API — Tracks student learning progress per unit. + +Stores coins, crowns, streak data, and exercise completion stats. +Uses JSON file storage (same pattern as learning_units.py). +""" + +import os +import json +import logging +from datetime import datetime, date +from typing import Dict, Any, Optional, List +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/progress", + tags=["progress"], +) + +PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress") + + +def _ensure_dir(): + os.makedirs(PROGRESS_DIR, exist_ok=True) + + +def _progress_path(unit_id: str) -> Path: + return Path(PROGRESS_DIR) / f"{unit_id}.json" + + +def _load_progress(unit_id: str) -> Dict[str, Any]: + path = _progress_path(unit_id) + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + return { + "unit_id": unit_id, + "coins": 0, + "crowns": 0, + "streak_days": 0, + "last_activity": None, + "exercises": { + "flashcards": {"completed": 0, "correct": 0, "incorrect": 0}, + "quiz": {"completed": 0, "correct": 0, "incorrect": 0}, + "type": {"completed": 0, "correct": 0, "incorrect": 0}, + "story": {"generated": 0}, + }, + "created_at": datetime.now().isoformat(), + } + + +def _save_progress(unit_id: str, data: Dict[str, Any]): + _ensure_dir() + path = _progress_path(unit_id) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +class RewardPayload(BaseModel): + exercise_type: str # flashcards, quiz, type, story + correct: bool = True + first_try: bool = True + + +@router.get("/{unit_id}") +def get_progress(unit_id: str): + """Get learning progress for a unit.""" + return _load_progress(unit_id) + + +@router.post("/{unit_id}/reward") +def add_reward(unit_id: str, payload: RewardPayload): + """Record an exercise result and award coins.""" + progress = _load_progress(unit_id) + + # Update exercise stats + ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0}) + ex["completed"] = ex.get("completed", 0) + 1 + if payload.correct: + ex["correct"] = ex.get("correct", 0) + 1 + else: + ex["incorrect"] = ex.get("incorrect", 0) + 1 + progress["exercises"][payload.exercise_type] = ex + + # Award coins + if payload.correct: + coins = 3 if payload.first_try else 1 + else: + coins = 0 + progress["coins"] = progress.get("coins", 0) + coins + + # Update streak + today = date.today().isoformat() + last = progress.get("last_activity") + if last != today: + if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None: + progress["streak_days"] = progress.get("streak_days", 0) + 1 + elif last != today: + progress["streak_days"] = 1 + progress["last_activity"] = today + + # Award crowns for milestones + total_correct = sum( + e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict) + ) + progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers + + _save_progress(unit_id, progress) + + return { + "coins_awarded": coins, + "total_coins": progress["coins"], + "crowns": progress["crowns"], + "streak_days": progress["streak_days"], + } + + +@router.get("/") +def list_all_progress(): + """List progress for all units.""" + _ensure_dir() + results = [] + for f in Path(PROGRESS_DIR).glob("*.json"): + with open(f, "r", encoding="utf-8") as fh: + results.append(json.load(fh)) + return results diff --git a/backend-lehrer/story_generator.py b/backend-lehrer/story_generator.py new file mode 100644 index 0000000..9b7cf36 --- /dev/null +++ b/backend-lehrer/story_generator.py @@ -0,0 +1,108 @@ +""" +Story Generator — Creates short stories using vocabulary words. + +Generates age-appropriate mini-stories (3-5 sentences) that incorporate +the given vocabulary words, marked with tags for highlighting. + +Uses Ollama (local LLM) for generation. +""" + +import os +import json +import logging +import requests +from typing import List, Dict, Any, Optional + +logger = logging.getLogger(__name__) + +OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") +STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b") + + +def generate_story( + vocabulary: List[Dict[str, str]], + language: str = "en", + grade_level: str = "5-8", + max_words: int = 5, +) -> Dict[str, Any]: + """ + Generate a short story incorporating vocabulary words. + + Args: + vocabulary: List of dicts with 'english' and 'german' keys + language: 'en' for English story, 'de' for German story + grade_level: Target grade level + max_words: Maximum vocab words to include (to keep story short) + + Returns: + Dict with 'story_html', 'story_text', 'vocab_used', 'language' + """ + # Select subset of vocabulary + words = vocabulary[:max_words] + word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words] + word_list = [w for w in word_list if w.strip()] + + if not word_list: + return {"story_html": "", "story_text": "", "vocab_used": [], "language": language} + + lang_name = "English" if language == "en" else "German" + words_str = ", ".join(word_list) + + prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student. +The story MUST use these vocabulary words: {words_str} + +Rules: +1. The story should be fun and age-appropriate +2. Each vocabulary word must appear at least once +3. Keep sentences simple and clear +4. The story should make sense and be engaging + +Write ONLY the story, nothing else. No title, no introduction.""" + + try: + resp = requests.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": STORY_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.8, "num_predict": 300}, + }, + timeout=30, + ) + resp.raise_for_status() + story_text = resp.json().get("response", "").strip() + except Exception as e: + logger.error(f"Story generation failed: {e}") + # Fallback: simple template story + story_text = _fallback_story(word_list, language) + + # Mark vocabulary words in the story + story_html = story_text + vocab_found = [] + for word in word_list: + if word.lower() in story_html.lower(): + # Case-insensitive replacement preserving original case + import re + pattern = re.compile(re.escape(word), re.IGNORECASE) + story_html = pattern.sub( + lambda m: f'{m.group()}', + story_html, + count=1, + ) + vocab_found.append(word) + + return { + "story_html": story_html, + "story_text": story_text, + "vocab_used": vocab_found, + "vocab_total": len(word_list), + "language": language, + } + + +def _fallback_story(words: List[str], language: str) -> str: + """Simple fallback when LLM is unavailable.""" + if language == "de": + return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen." + return f"Today I learned new words: {', '.join(words)}. It was a great day for learning." diff --git a/studio-v2/app/learn/[unitId]/story/page.tsx b/studio-v2/app/learn/[unitId]/story/page.tsx new file mode 100644 index 0000000..0fe8235 --- /dev/null +++ b/studio-v2/app/learn/[unitId]/story/page.tsx @@ -0,0 +1,184 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useTheme } from '@/lib/ThemeContext' +import { AudioButton } from '@/components/learn/AudioButton' + +function getBackendUrl() { + if (typeof window === 'undefined') return 'http://localhost:8001' + const { hostname, protocol } = window.location + if (hostname === 'localhost') return 'http://localhost:8001' + return `${protocol}//${hostname}:8001` +} + +function getKlausurApiUrl() { + if (typeof window === 'undefined') return 'http://localhost:8086' + const { hostname, protocol } = window.location + if (hostname === 'localhost') return 'http://localhost:8086' + return `${protocol}//${hostname}/klausur-api` +} + +export default function StoryPage() { + const { unitId } = useParams<{ unitId: string }>() + const router = useRouter() + const { isDark } = useTheme() + + const [story, setStory] = useState<{ story_html: string; story_text: string; vocab_used: string[]; language: string } | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [language, setLanguage] = useState<'en' | 'de'>('en') + + const glassCard = isDark + ? 'bg-white/10 backdrop-blur-xl border border-white/10' + : 'bg-white/80 backdrop-blur-xl border border-black/5' + + const generateStory = async () => { + setIsLoading(true) + setError(null) + + try { + // First get the QA data to extract vocabulary + const qaResp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/qa`) + let vocabulary: { english: string; german: string }[] = [] + + if (qaResp.ok) { + const qaData = await qaResp.json() + // Convert QA items to vocabulary format + vocabulary = (qaData.qa_items || []).map((item: any) => ({ + english: item.question, + german: item.answer, + })) + } + + if (vocabulary.length === 0) { + setError('Keine Vokabeln gefunden.') + setIsLoading(false) + return + } + + // Generate story + const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/generate-story`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vocabulary, language, grade_level: '5-8' }), + }) + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + setStory(data) + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } + } + + return ( +
+ {/* Header */} +
+
+ +

+ Minigeschichte +

+ +
+
+ + {/* Content */} +
+
+ {story ? ( + <> + {/* Story Card */} +
+
+ + {story.language === 'en' ? 'English Story' : 'Deutsche Geschichte'} + + +
+
+ +
+ + {/* Vocab used */} +
+ Vokabeln verwendet: {story.vocab_used.length} / {story.vocab_used.length > 0 ? story.vocab_used.join(', ') : '-'} +
+ + {/* New Story Button */} + + + ) : ( +
+
📖
+

+ Minigeschichte +

+

+ Die KI schreibt eine kurze Geschichte mit deinen Vokabeln. + Die Vokabelwoerter werden farbig hervorgehoben. +

+ + {error && ( +

{error}

+ )} + + +
+ )} +
+
+
+ ) +} diff --git a/studio-v2/components/gamification/CoinAnimation.tsx b/studio-v2/components/gamification/CoinAnimation.tsx new file mode 100644 index 0000000..1532720 --- /dev/null +++ b/studio-v2/components/gamification/CoinAnimation.tsx @@ -0,0 +1,91 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' + +interface CoinAnimationProps { + amount: number + trigger: number // increment to trigger animation +} + +interface FloatingCoin { + id: number + x: number + delay: number +} + +export function CoinAnimation({ amount, trigger }: CoinAnimationProps) { + const [coins, setCoins] = useState([]) + const [total, setTotal] = useState(0) + const [showBounce, setShowBounce] = useState(false) + + useEffect(() => { + if (trigger === 0) return + + // Create floating coins + const newCoins: FloatingCoin[] = Array.from({ length: Math.min(amount, 5) }, (_, i) => ({ + id: Date.now() + i, + x: Math.random() * 60 - 30, + delay: i * 100, + })) + setCoins(newCoins) + setShowBounce(true) + + // Update total after animation + setTimeout(() => { + setTotal((prev) => prev + amount) + setShowBounce(false) + }, 800) + + // Clean up coins + setTimeout(() => setCoins([]), 1500) + }, [trigger, amount]) + + return ( +
+ {/* Coin icon + total */} +
+ 🪙 + {total} +
+ + {/* Floating coins animation */} + {coins.map((coin) => ( + + 🪙 + + ))} + + +
+ ) +} + +/** Hook to manage coin rewards */ +export function useCoinRewards() { + const [totalCoins, setTotalCoins] = useState(0) + const [triggerCount, setTriggerCount] = useState(0) + const [lastReward, setLastReward] = useState(0) + + const awardCoins = useCallback((amount: number) => { + setLastReward(amount) + setTriggerCount((c) => c + 1) + setTotalCoins((t) => t + amount) + }, []) + + return { totalCoins, triggerCount, lastReward, awardCoins } +} diff --git a/studio-v2/components/gamification/CrownBadge.tsx b/studio-v2/components/gamification/CrownBadge.tsx new file mode 100644 index 0000000..c473378 --- /dev/null +++ b/studio-v2/components/gamification/CrownBadge.tsx @@ -0,0 +1,35 @@ +'use client' + +import React from 'react' + +interface CrownBadgeProps { + crowns: number + size?: 'sm' | 'md' | 'lg' + showLabel?: boolean +} + +export function CrownBadge({ crowns, size = 'md', showLabel = true }: CrownBadgeProps) { + const sizeClasses = { + sm: 'text-base', + md: 'text-xl', + lg: 'text-3xl', + } + + const isGold = crowns >= 3 + const isSilver = crowns >= 1 + + return ( +
+ + {isGold ? '👑' : isSilver ? '🥈' : '⭐'} + + {showLabel && ( + + {crowns} + + )} +
+ ) +} diff --git a/studio-v2/components/gamification/ProgressRing.tsx b/studio-v2/components/gamification/ProgressRing.tsx new file mode 100644 index 0000000..70baff6 --- /dev/null +++ b/studio-v2/components/gamification/ProgressRing.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' + +interface ProgressRingProps { + progress: number // 0-100 + size?: number + strokeWidth?: number + label: string + value: string + color?: string + isDark?: boolean +} + +export function ProgressRing({ + progress, + size = 80, + strokeWidth = 6, + label, + value, + color = '#60a5fa', + isDark = true, +}: ProgressRingProps) { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const offset = circumference - (Math.min(progress, 100) / 100) * circumference + + return ( +
+
+ + {/* Background circle */} + + {/* Progress circle */} + + + {/* Center text */} +
+ + {value} + +
+
+ + {label} + +
+ ) +} diff --git a/studio-v2/components/learn/MicrophoneInput.tsx b/studio-v2/components/learn/MicrophoneInput.tsx new file mode 100644 index 0000000..8295ef0 --- /dev/null +++ b/studio-v2/components/learn/MicrophoneInput.tsx @@ -0,0 +1,140 @@ +'use client' + +import React, { useState, useRef, useCallback } from 'react' + +interface MicrophoneInputProps { + expectedText: string + lang: 'en' | 'de' + onResult: (transcript: string, correct: boolean) => void + isDark: boolean +} + +export function MicrophoneInput({ expectedText, lang, onResult, isDark }: MicrophoneInputProps) { + const [isListening, setIsListening] = useState(false) + const [transcript, setTranscript] = useState('') + const [feedback, setFeedback] = useState<'correct' | 'wrong' | null>(null) + const recognitionRef = useRef(null) + + const startListening = useCallback(() => { + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition + if (!SpeechRecognition) { + setTranscript('Spracherkennung nicht verfuegbar') + return + } + + const recognition = new SpeechRecognition() + recognition.lang = lang === 'de' ? 'de-DE' : 'en-GB' + recognition.interimResults = false + recognition.maxAlternatives = 3 + recognition.continuous = false + + recognition.onresult = (event: any) => { + const results = event.results[0] + let bestMatch = '' + let isCorrect = false + + // Check all alternatives for a match + for (let i = 0; i < results.length; i++) { + const alt = results[i].transcript.trim().toLowerCase() + if (alt === expectedText.trim().toLowerCase()) { + bestMatch = results[i].transcript + isCorrect = true + break + } + if (!bestMatch) bestMatch = results[i].transcript + } + + setTranscript(bestMatch) + setFeedback(isCorrect ? 'correct' : 'wrong') + setIsListening(false) + + setTimeout(() => { + onResult(bestMatch, isCorrect) + setFeedback(null) + setTranscript('') + }, isCorrect ? 1000 : 2500) + } + + recognition.onerror = (event: any) => { + console.error('Speech recognition error:', event.error) + setIsListening(false) + if (event.error === 'no-speech') { + setTranscript('Kein Ton erkannt. Nochmal versuchen.') + } else if (event.error === 'not-allowed') { + setTranscript('Mikrofon-Zugriff nicht erlaubt.') + } + } + + recognition.onend = () => { + setIsListening(false) + } + + recognitionRef.current = recognition + recognition.start() + setIsListening(true) + setTranscript('') + setFeedback(null) + }, [lang, expectedText, onResult]) + + const stopListening = useCallback(() => { + recognitionRef.current?.stop() + setIsListening(false) + }, []) + + return ( +
+ {/* Microphone Button */} + + + {/* Status Text */} +

+ {isListening + ? 'Sprich jetzt...' + : transcript + ? transcript + : 'Tippe auf das Mikrofon'} +

+ + {/* Feedback */} + {feedback === 'correct' && ( +

+ Richtig ausgesprochen! +

+ )} + {feedback === 'wrong' && ( +
+

+ Erkannt: "{transcript}" +

+

+ Erwartet: "{expectedText}" +

+
+ )} +
+ ) +} diff --git a/studio-v2/components/learn/SyllableBow.tsx b/studio-v2/components/learn/SyllableBow.tsx new file mode 100644 index 0000000..6af1cc7 --- /dev/null +++ b/studio-v2/components/learn/SyllableBow.tsx @@ -0,0 +1,117 @@ +'use client' + +import React, { useMemo } from 'react' + +interface SyllableBowProps { + word: string + syllables: string[] + onSyllableClick?: (syllable: string, index: number) => void + isDark: boolean + size?: 'sm' | 'md' | 'lg' +} + +/** + * SyllableBow — Renders a word with SVG arcs under each syllable. + * + * Uses pyphen syllable data from the backend. + * Each syllable is clickable (triggers TTS for that syllable). + */ +export function SyllableBow({ word, syllables, onSyllableClick, isDark, size = 'md' }: SyllableBowProps) { + const fontSize = size === 'sm' ? 20 : size === 'md' ? 32 : 44 + const charWidth = fontSize * 0.6 + const bowHeight = size === 'sm' ? 12 : size === 'md' ? 18 : 24 + const gap = 4 + + const layout = useMemo(() => { + let x = 0 + return syllables.map((syl) => { + const width = syl.length * charWidth + const entry = { syllable: syl, x, width } + x += width + gap + return entry + }) + }, [syllables, charWidth]) + + const totalWidth = layout.length > 0 + ? layout[layout.length - 1].x + layout[layout.length - 1].width + : 0 + + const svgHeight = bowHeight + 6 + + return ( +
+ {/* Letters */} +
+ {layout.map((item, idx) => ( + onSyllableClick?.(item.syllable, idx)} + className={`font-bold cursor-pointer select-none transition-colors ${ + onSyllableClick + ? (isDark ? 'hover:text-blue-300' : 'hover:text-blue-600') + : '' + } ${isDark ? 'text-white' : 'text-slate-900'}`} + style={{ fontSize: `${fontSize}px`, letterSpacing: '0.02em' }} + > + {item.syllable} + + ))} +
+ + {/* SVG Bows */} + + {layout.map((item, idx) => { + const cx = item.x + item.width / 2 + const startX = item.x + 2 + const endX = item.x + item.width - 2 + const controlY = svgHeight - 2 + + return ( + + ) + })} + +
+ ) +} + +/** + * Simple client-side syllable splitting fallback. + * For accurate results, use the backend pyphen endpoint. + */ +export function simpleSyllableSplit(word: string): string[] { + // Very basic vowel-based heuristic for display purposes + const vowels = /[aeiouyäöü]/i + const chars = word.split('') + const syllables: string[] = [] + let current = '' + + for (let i = 0; i < chars.length; i++) { + current += chars[i] + if ( + vowels.test(chars[i]) && + i < chars.length - 1 && + current.length >= 2 + ) { + // Check if next char starts a new consonant cluster + if (!vowels.test(chars[i + 1]) && i + 2 < chars.length && vowels.test(chars[i + 2])) { + syllables.push(current) + current = '' + } + } + } + if (current) syllables.push(current) + return syllables.length > 0 ? syllables : [word] +} diff --git a/studio-v2/components/learn/UnitCard.tsx b/studio-v2/components/learn/UnitCard.tsx index ee20180..826e0f8 100644 --- a/studio-v2/components/learn/UnitCard.tsx +++ b/studio-v2/components/learn/UnitCard.tsx @@ -25,6 +25,7 @@ const exerciseTypes = [ { key: 'flashcards', label: 'Karteikarten', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', color: 'from-amber-500 to-orange-500' }, { key: 'quiz', label: 'Quiz', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', color: 'from-purple-500 to-pink-500' }, { key: 'type', label: 'Eintippen', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', color: 'from-blue-500 to-cyan-500' }, + { key: 'story', label: 'Geschichte', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253', color: 'from-amber-500 to-yellow-500' }, ] export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {