import os import random import time import re import psycopg2 from flask import Flask, render_template, request, jsonify from difflib import SequenceMatcher app = Flask(__name__) def get_db_connection(): max_retries = 10 for i in range(max_retries): try: conn = psycopg2.connect( host=os.environ.get('DB_HOST', 'postgres-lat'), database=os.environ.get('DB_NAME', 'latein'), user=os.environ.get('DB_USER', 'latein'), password=os.environ.get('DB_PASS', 'latein') ) return conn except psycopg2.OperationalError: time.sleep(2) continue raise Exception("DB Connection failed") def init_db(): conn = get_db_connection() cur = conn.cursor() if os.path.exists('init.sql'): with open('init.sql', 'r', encoding='utf-8') as f: cur.execute(f.read()) conn.commit() cur.close() conn.close() init_db() # --- HELPER --- def normalize_latin(text): """Entfernt Makrons für den toleranten Vergleich (ā -> a)""" if not text: return "" replacements = { 'ā': 'a', 'ē': 'e', 'ī': 'i', 'ō': 'o', 'ū': 'u', 'Ā': 'A', 'Ē': 'E', 'Ī': 'I', 'Ō': 'O', 'Ū': 'U', 'ȳ': 'y', 'Ȳ': 'Y' } t = text for k, v in replacements.items(): t = t.replace(k, v) return t def is_middle_relevant(text): if not text: return False signals = [' m', ' f', ' n', 'm.', 'f.', 'n.', 'pl', 'sg', 'gen', 'dat', 'akk', 'abl', 'perf', 'ppp'] t = text.lower() ignore = ['adv', 'präp', 'konj', 'subj', 'interj'] is_signal = any(s in t for s in signals) is_ignore = any(i in t for i in ignore) if is_ignore and not is_signal: return False return True def clean_german_answers(text): text = re.sub(r'\(.*?\)', '', text) text = re.sub(r'\[.*?\]', '', text) parts = re.split(r'[;,]', text) return [p.strip() for p in parts if p.strip()] def check_middle_logic(user_in, correct): # 1. Normalisieren (Striche wegdenken für Vergleich) u_norm = normalize_latin(user_in.strip().lower()) c_norm = normalize_latin(correct.strip().lower()) # 2. Exakter Abgleich (auf Basis der normalisierten Version) if u_norm == c_norm: return 'correct' def get_parts(s): return [x.strip() for x in s.split(',') if x.strip()] u_parts = get_parts(u_norm) c_parts = get_parts(c_norm) if not u_parts: return 'wrong' all_user_parts_correct = all(p in c_parts for p in u_parts) if all_user_parts_correct: if len(u_parts) < len(c_parts): return 'incomplete' return 'correct' ratio = SequenceMatcher(None, u_norm, c_norm).ratio() if ratio > 0.85: return 'typo' return 'wrong' def check_string_match(user_in, correct): u_norm = normalize_latin(user_in.strip().lower()) c_norm = normalize_latin(correct.strip().lower()) if not u_norm: return 'wrong' if u_norm == c_norm: return 'correct' ratio = SequenceMatcher(None, u_norm, c_norm).ratio() if ratio > 0.85: return 'typo' return 'wrong' # --- ROUTES --- @app.route('/') def index(): return render_template('index.html') @app.route('/api/lessons') def get_lessons(): conn = get_db_connection() cur = conn.cursor() cur.execute("SELECT DISTINCT lesson FROM vocabulary") rows = cur.fetchall() cur.close() conn.close() lessons = [r[0] for r in rows] def sort_key(val): if val.isdigit(): return (0, int(val)) if val.startswith("Original"): return (2, val) return (1, val) lessons.sort(key=sort_key) return jsonify(lessons) @app.route('/api/question', methods=['POST']) def get_question(): data = request.json selected_lessons = data.get('lessons', []) ask_middle_setting = data.get('askMiddle', True) conn = get_db_connection() cur = conn.cursor() query = "SELECT id, lesson, latin, middle, german FROM vocabulary WHERE lesson = ANY(%s) ORDER BY RANDOM() LIMIT 1" cur.execute(query, (selected_lessons,)) row = cur.fetchone() cur.close() conn.close() if not row: return jsonify({'error': 'Keine Vokabeln gefunden'}) vid, lesson, latin, middle, german_raw = row german_answers = clean_german_answers(german_raw) middle_req = False hint = "" if middle: if ask_middle_setting and is_middle_relevant(middle): middle_req = True else: hint = middle return jsonify({ 'id': vid, 'latin': latin, 'hint': hint, 'middle_req': middle_req, 'middle_correct': middle if middle_req else "", 'german_count': len(german_answers), 'german_correct': german_answers }) @app.route('/api/check', methods=['POST']) def check(): data = request.json inputs = data.get('inputs', []) attempt_num = data.get('attempt', 1) results = [None] * len(inputs) correct_items = 0 total_items = len(inputs) has_incomplete = False # 1. GRAMMATIK for i, inp in enumerate(inputs): if inp.get('type') == 'middle': status = check_middle_logic(inp.get('value', ''), inp.get('correct', '')) msg = 'Unvollständig! Da fehlt noch etwas.' if status == 'incomplete' else '' if status == 'incomplete': has_incomplete = True results[i] = {'status': status, 'correct': inp.get('correct', ''), 'msg': msg} if status in ['correct', 'typo']: correct_items += 1 # 2. DEUTSCH german_user_inputs = [] german_correct_pool = [] for i, inp in enumerate(inputs): if inp.get('type') == 'german' or inp.get('type') is None: german_user_inputs.append({'index': i, 'value': inp.get('value', '')}) german_correct_pool.append(inp.get('correct', '')) used_pool_indices = set() for u_item in german_user_inputs: idx = u_item['index'] val = u_item['value'] found_match = False for pool_i, pool_val in enumerate(german_correct_pool): if pool_i in used_pool_indices: continue status = check_string_match(val, pool_val) if status in ['correct', 'typo']: results[idx] = {'status': status, 'correct': pool_val} used_pool_indices.add(pool_i) correct_items += 1 found_match = True break if not found_match: original_correct = inputs[idx].get('correct', '') results[idx] = {'status': 'wrong', 'correct': original_correct} score_fraction = 0 if total_items > 0: base_score = correct_items / total_items if attempt_num > 1: base_score = base_score * 0.5 score_fraction = base_score all_correct = (correct_items == total_items) return jsonify({ 'results': results, 'all_correct': all_correct, 'score': score_fraction, 'has_incomplete': has_incomplete }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)