Feature: 40 bilingual questions, random 10 game run, difficulty levels, and full i18n support

This commit is contained in:
kobold-ai
2026-09-21 12:33:34 +02:00
parent eafa1f833e
commit cf89cd31c7
15 changed files with 1369 additions and 138 deletions
+87 -26
View File
@@ -16,7 +16,7 @@ from app.models import Question, QuizSession, AdminUser
from app.matcher import is_answer_correct, levenshtein_distance, normalize_text
from app.auth import verify_password
from app.seed import seed_db
from app.schemas import GameCheckRequest, SuggestionCreateRequest
from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest
load_dotenv()
@@ -29,25 +29,33 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
def format_question_for_client(q: Question, number: int, total: int) -> dict:
def format_question_for_client(q: Question, number: int, total: int, lang: str = "de") -> dict:
options_list = None
if q.question_type == "multiple_choice" and q.options:
if lang == "en" and q.question_en:
q_text = q.question_en
opts_raw = q.options_en or q.options
else:
q_text = q.question
opts_raw = q.options
if q.question_type == "multiple_choice" and opts_raw:
try:
parsed = json.loads(q.options)
parsed = json.loads(opts_raw)
if isinstance(parsed, list):
options_list = list(parsed)
random.shuffle(options_list)
except Exception:
options_list = [opt.strip() for opt in q.options.split(",") if opt.strip()]
options_list = [opt.strip() for opt in opts_raw.split(",") if opt.strip()]
random.shuffle(options_list)
return {
"question_number": number,
"total_questions": total,
"category": q.category,
"question": q.question,
"question": q_text,
"question_type": q.question_type or "text",
"options": options_list
"options": options_list,
"difficulty": q.difficulty or "medium"
}
@app.on_event("startup")
@@ -66,13 +74,26 @@ async def index(request: Request, db: Session = Depends(get_db)):
)
@app.post("/api/game/start")
async def start_game(db: Session = Depends(get_db)):
# Fetch all active questions in order
questions = db.query(Question).filter(Question.status == "active").order_by(Question.sort_order.asc(), Question.id.asc()).all()
if not questions:
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
async def start_game(payload: GameStartRequest = None, db: Session = Depends(get_db)):
lang = payload.lang if payload and payload.lang in ["de", "en"] else "de"
difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all"
question_ids = [q.id for q in questions]
# Fetch active questions matching difficulty (or all active)
query = db.query(Question).filter(Question.status == "active")
if difficulty != "all":
query = query.filter(Question.difficulty == difficulty)
pool = query.all()
if not pool:
# Fallback to all active if filtered pool empty
pool = db.query(Question).filter(Question.status == "active").all()
if not pool:
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
# Randomly sample 10 questions (or all if fewer)
sample_size = min(10, len(pool))
selected_questions = random.sample(pool, sample_size)
question_ids = [q.id for q in selected_questions]
session_id = str(uuid.uuid4())
new_session = QuizSession(
@@ -81,18 +102,22 @@ async def start_game(db: Session = Depends(get_db)):
current_question_idx=0,
question_ids=json.dumps(question_ids),
attempts_current=0,
is_finished=False
is_finished=False,
lang=lang,
difficulty=difficulty
)
db.add(new_session)
db.commit()
first_q = questions[0]
res = format_question_for_client(first_q, 1, len(question_ids))
first_q = selected_questions[0]
res = format_question_for_client(first_q, 1, len(question_ids), lang=lang)
res.update({
"session_id": session_id,
"score": 0,
"attempts_used": 0,
"max_attempts": 3
"max_attempts": 3,
"lang": lang,
"difficulty": difficulty
})
return res
@@ -102,6 +127,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
if not session or session.is_finished:
raise HTTPException(status_code=400, detail="Ungültige oder beendete Spiel-Session.")
session_lang = session.lang or "de"
question_ids = json.loads(session.question_ids)
if session.current_question_idx >= len(question_ids):
session.is_finished = True
@@ -120,7 +146,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
if not current_q:
raise HTTPException(status_code=500, detail="Frage nicht in Datenbank gefunden.")
correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers)
correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers, current_q.answer_en)
if correct:
session.score += 1
@@ -138,7 +164,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q:
next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids))
next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang)
return {
"correct": True,
@@ -154,8 +180,12 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
attempts_left = max(0, 3 - session.attempts_current)
if attempts_left == 0:
# 3 failed attempts: move to next question, reveal answer so player learns it
revealed = current_q.canonical_answer
# 3 failed attempts: move to next question, reveal answer
if session_lang == "en" and current_q.answer_en:
revealed = current_q.answer_en
else:
revealed = current_q.canonical_answer
session.current_question_idx += 1
session.attempts_current = 0
@@ -170,7 +200,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
if not is_finished:
next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first()
if next_q:
next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids))
next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang)
return {
"correct": False,
@@ -199,20 +229,29 @@ async def get_hint(session_id: str, db: Session = Depends(get_db)):
if not session or session.is_finished:
raise HTTPException(status_code=400, detail="Ungültige Session.")
session_lang = session.lang or "de"
question_ids = json.loads(session.question_ids)
if session.current_question_idx >= len(question_ids):
return {"hint": "Kein Hinweis verfügbar."}
return {"hint": "No hint available." if session_lang == "en" else "Kein Hinweis verfügbar."}
current_q_id = question_ids[session.current_question_idx]
current_q = db.query(Question).filter(Question.id == current_q_id).first()
return {"hint": current_q.hint if current_q and current_q.hint else "Kein Tipp verfügbar."}
if not current_q:
return {"hint": "No hint available." if session_lang == "en" else "Kein Hinweis verfügbar."}
if session_lang == "en":
hint_text = current_q.hint_en if current_q.hint_en else (current_q.hint or "No hint available.")
else:
hint_text = current_q.hint or "Kein Tipp verfügbar."
return {"hint": hint_text}
@app.get("/finale", response_class=HTMLResponse)
async def finale(request: Request, score: int = 0, total: int = 15):
async def finale(request: Request, score: int = 0, total: int = 10, lang: str = "de"):
return templates.TemplateResponse(
request=request,
name="finale.html",
context={"score": score, "total": total}
context={"score": score, "total": total, "lang": lang}
)
@app.get("/vorschlag", response_class=HTMLResponse)
@@ -238,6 +277,8 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe
opts.append(opt.strip())
options_json = json.dumps(opts, ensure_ascii=False)
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "medium"
new_suggestion = Question(
category="community",
question=payload.frage.strip(),
@@ -245,6 +286,7 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe
hint=payload.tipp.strip() if payload.tipp else None,
question_type=q_type,
options=options_json,
difficulty=diff,
status="needs_review", # User Question Need Test / Review
submitted_by=payload.name.strip() if payload.name else "Gast",
sort_order=999
@@ -327,6 +369,10 @@ async def admin_add_question(
hint: str = Form(None),
category: str = Form("harry_potter"),
question_type: str = Form("text"),
difficulty: str = Form("medium"),
question_en: str = Form(None),
answer_en: str = Form(None),
hint_en: str = Form(None),
wrong_option_1: str = Form(None),
wrong_option_2: str = Form(None),
wrong_option_3: str = Form(None),
@@ -344,6 +390,8 @@ async def admin_add_question(
opts.append(w.strip())
options_json = json.dumps(opts, ensure_ascii=False)
diff = difficulty.strip() if difficulty and difficulty.strip() in ["easy", "medium", "hard"] else "medium"
new_q = Question(
category=category.strip(),
question=question.strip(),
@@ -351,6 +399,10 @@ async def admin_add_question(
hint=hint.strip() if hint else None,
question_type=question_type.strip(),
options=options_json,
difficulty=diff,
question_en=question_en.strip() if question_en else None,
answer_en=answer_en.strip() if answer_en else None,
hint_en=hint_en.strip() if hint_en else None,
status="active",
sort_order=sort_order,
submitted_by=f"Admin ({request.session['admin_user']})"
@@ -402,6 +454,10 @@ async def admin_edit_question(
canonical_answer: str = Form(...),
hint: str = Form(None),
question_type: str = Form("text"),
difficulty: str = Form("medium"),
question_en: str = Form(None),
answer_en: str = Form(None),
hint_en: str = Form(None),
options_raw: str = Form(None),
status_val: str = Form("active"),
sort_order: int = Form(0),
@@ -418,6 +474,11 @@ async def admin_edit_question(
q.canonical_answer = canonical_answer.strip()
q.hint = hint.strip() if hint else None
q.question_type = question_type.strip()
if difficulty and difficulty.strip() in ["easy", "medium", "hard"]:
q.difficulty = difficulty.strip()
q.question_en = question_en.strip() if question_en else None
q.answer_en = answer_en.strip() if answer_en else None
q.hint_en = hint_en.strip() if hint_en else None
if options_raw and options_raw.strip():
val = options_raw.strip()