Initial commit
This commit is contained in:
		
							
								
								
									
										278
									
								
								app/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								app/main.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,278 @@
 | 
			
		||||
from fastapi import FastAPI, HTTPException
 | 
			
		||||
from fastapi.responses import FileResponse
 | 
			
		||||
from fastapi.staticfiles import StaticFiles
 | 
			
		||||
from pydantic import BaseModel
 | 
			
		||||
from typing import List, Dict, Optional
 | 
			
		||||
import random
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# -----------------
 | 
			
		||||
# Models / Schemas
 | 
			
		||||
# -----------------
 | 
			
		||||
 | 
			
		||||
class Question(BaseModel):
 | 
			
		||||
    index: int  # 1-based question number
 | 
			
		||||
    a: int
 | 
			
		||||
    b: int
 | 
			
		||||
    options: List[int]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StartResponse(BaseModel):
 | 
			
		||||
    session_id: str
 | 
			
		||||
    total_questions: int
 | 
			
		||||
    duration_seconds: int
 | 
			
		||||
    remaining_seconds: int
 | 
			
		||||
    question: Question
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AnswerRequest(BaseModel):
 | 
			
		||||
    answer: int
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Score(BaseModel):
 | 
			
		||||
    correct: int
 | 
			
		||||
    total_answered: int
 | 
			
		||||
    total: int
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AnswerResponse(BaseModel):
 | 
			
		||||
    correct: bool
 | 
			
		||||
    finished: bool
 | 
			
		||||
    reason: Optional[str] = None  # "completed" | "timeout"
 | 
			
		||||
    remaining_seconds: int
 | 
			
		||||
    score: Score
 | 
			
		||||
    question: Optional[Question] = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# -----------------
 | 
			
		||||
# Session management
 | 
			
		||||
# -----------------
 | 
			
		||||
 | 
			
		||||
TOTAL_QUESTIONS = 20
 | 
			
		||||
DURATION_SECONDS = 60
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Session:
 | 
			
		||||
    def __init__(self, total_questions: int = TOTAL_QUESTIONS, duration: int = DURATION_SECONDS):
 | 
			
		||||
        self.id = str(uuid.uuid4())
 | 
			
		||||
        self.total_questions = total_questions
 | 
			
		||||
        self.duration = duration
 | 
			
		||||
        self.start_time = time.time()
 | 
			
		||||
        self.questions = self._generate_questions(total_questions)
 | 
			
		||||
        self.current_index = 0  # 0-based
 | 
			
		||||
        self.correct_count = 0
 | 
			
		||||
        self.answered = 0
 | 
			
		||||
        self.finished = False
 | 
			
		||||
        self.reason: Optional[str] = None
 | 
			
		||||
 | 
			
		||||
    def remaining_seconds(self) -> int:
 | 
			
		||||
        remaining = int(self.duration - (time.time() - self.start_time))
 | 
			
		||||
        return max(0, remaining)
 | 
			
		||||
 | 
			
		||||
    def time_over(self) -> bool:
 | 
			
		||||
        return self.remaining_seconds() <= 0
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _generate_questions(n: int) -> List[Dict]:
 | 
			
		||||
        # Build unique unordered pairs (avoid mirrored duplicates like 6×8 and 8×6)
 | 
			
		||||
        all_pairs = [(a, b) for a in range(2, 10) for b in range(a, 10)]  # a <= b
 | 
			
		||||
        count = min(n, len(all_pairs))
 | 
			
		||||
        picked = random.sample(all_pairs, count)
 | 
			
		||||
 | 
			
		||||
        questions: List[Dict] = []
 | 
			
		||||
        for a0, b0 in picked:
 | 
			
		||||
            # Randomize order for variety while keeping uniqueness by unordered pair
 | 
			
		||||
            if random.random() < 0.5:
 | 
			
		||||
                a, b = a0, b0
 | 
			
		||||
            else:
 | 
			
		||||
                a, b = b0, a0
 | 
			
		||||
            correct = a * b
 | 
			
		||||
            options = Session._generate_options(a, b, correct)
 | 
			
		||||
            questions.append({
 | 
			
		||||
                "a": a,
 | 
			
		||||
                "b": b,
 | 
			
		||||
                "correct": correct,
 | 
			
		||||
                "options": options,
 | 
			
		||||
            })
 | 
			
		||||
        return questions
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _generate_options(a: int, b: int, correct: int) -> List[int]:
 | 
			
		||||
        # Generate plausible distractors based on nearby multiplication results
 | 
			
		||||
        candidates = set()
 | 
			
		||||
        # Nearby products
 | 
			
		||||
        for da, db in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
 | 
			
		||||
            na = a + da
 | 
			
		||||
            nb = b + db
 | 
			
		||||
            if 1 <= na <= 10 and 1 <= nb <= 10:
 | 
			
		||||
                candidates.add(na * nb)
 | 
			
		||||
        # Small offsets
 | 
			
		||||
        for off in [-3, -2, -1, 1, 2, 3, 4]:
 | 
			
		||||
            if correct + off > 0:
 | 
			
		||||
                candidates.add(correct + off)
 | 
			
		||||
        # Random other table values
 | 
			
		||||
        while len(candidates) < 20:
 | 
			
		||||
            candidates.add(random.randint(2, 10) * random.randint(2, 10))
 | 
			
		||||
 | 
			
		||||
        # Remove the correct answer and pick three unique distractors
 | 
			
		||||
        candidates.discard(correct)
 | 
			
		||||
        distractors = random.sample(list(candidates), 3)
 | 
			
		||||
        opts = distractors + [correct]
 | 
			
		||||
        random.shuffle(opts)
 | 
			
		||||
        return opts
 | 
			
		||||
 | 
			
		||||
    def current_question(self) -> Optional[Question]:
 | 
			
		||||
        if self.current_index >= self.total_questions:
 | 
			
		||||
            return None
 | 
			
		||||
        q = self.questions[self.current_index]
 | 
			
		||||
        return Question(index=self.current_index + 1, a=q["a"], b=q["b"], options=q["options"])
 | 
			
		||||
 | 
			
		||||
    def submit_answer(self, answer: int) -> bool:
 | 
			
		||||
        q = self.questions[self.current_index]
 | 
			
		||||
        is_correct = (answer == q["correct"])
 | 
			
		||||
        if is_correct:
 | 
			
		||||
            self.correct_count += 1
 | 
			
		||||
        self.answered += 1
 | 
			
		||||
        self.current_index += 1
 | 
			
		||||
        if self.current_index >= self.total_questions:
 | 
			
		||||
            self.finished = True
 | 
			
		||||
            self.reason = "completed"
 | 
			
		||||
        return is_correct
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
SESSIONS: Dict[str, Session] = {}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# -------------
 | 
			
		||||
# FastAPI setup
 | 
			
		||||
# -------------
 | 
			
		||||
 | 
			
		||||
app = FastAPI(title="Таблица умножения — тренажёр")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
 | 
			
		||||
static_dir = os.path.abspath(static_dir)
 | 
			
		||||
if not os.path.isdir(static_dir):
 | 
			
		||||
    os.makedirs(static_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.get("/")
 | 
			
		||||
def index():
 | 
			
		||||
    index_path = os.path.join(static_dir, "index.html")
 | 
			
		||||
    if not os.path.exists(index_path):
 | 
			
		||||
        raise HTTPException(status_code=404, detail="index.html not found")
 | 
			
		||||
    return FileResponse(index_path)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.post("/api/session/start", response_model=StartResponse)
 | 
			
		||||
def start_session():
 | 
			
		||||
    session = Session()
 | 
			
		||||
    SESSIONS[session.id] = session
 | 
			
		||||
    if session.time_over():
 | 
			
		||||
        session.finished = True
 | 
			
		||||
        session.reason = "timeout"
 | 
			
		||||
        raise HTTPException(status_code=400, detail="Session immediately timed out")
 | 
			
		||||
    q = session.current_question()
 | 
			
		||||
    return StartResponse(
 | 
			
		||||
        session_id=session.id,
 | 
			
		||||
        total_questions=session.total_questions,
 | 
			
		||||
        duration_seconds=session.duration,
 | 
			
		||||
        remaining_seconds=session.remaining_seconds(),
 | 
			
		||||
        question=q,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_session(session_id: str) -> Session:
 | 
			
		||||
    session = SESSIONS.get(session_id)
 | 
			
		||||
    if not session:
 | 
			
		||||
        raise HTTPException(status_code=404, detail="Session not found")
 | 
			
		||||
    return session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.post("/api/session/{session_id}/answer", response_model=AnswerResponse)
 | 
			
		||||
def submit_answer(session_id: str, req: AnswerRequest):
 | 
			
		||||
    session = _get_session(session_id)
 | 
			
		||||
    if session.finished:
 | 
			
		||||
        return AnswerResponse(
 | 
			
		||||
            correct=False,
 | 
			
		||||
            finished=True,
 | 
			
		||||
            reason=session.reason or "completed",
 | 
			
		||||
            remaining_seconds=session.remaining_seconds(),
 | 
			
		||||
            score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions),
 | 
			
		||||
            question=None,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if session.time_over():
 | 
			
		||||
        session.finished = True
 | 
			
		||||
        session.reason = "timeout"
 | 
			
		||||
        return AnswerResponse(
 | 
			
		||||
            correct=False,
 | 
			
		||||
            finished=True,
 | 
			
		||||
            reason="timeout",
 | 
			
		||||
            remaining_seconds=0,
 | 
			
		||||
            score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions),
 | 
			
		||||
            question=None,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # Must have a current question
 | 
			
		||||
    current_q = session.current_question()
 | 
			
		||||
    if current_q is None:
 | 
			
		||||
        session.finished = True
 | 
			
		||||
        session.reason = "completed"
 | 
			
		||||
        return AnswerResponse(
 | 
			
		||||
            correct=False,
 | 
			
		||||
            finished=True,
 | 
			
		||||
            reason="completed",
 | 
			
		||||
            remaining_seconds=session.remaining_seconds(),
 | 
			
		||||
            score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions),
 | 
			
		||||
            question=None,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    is_correct = session.submit_answer(req.answer)
 | 
			
		||||
 | 
			
		||||
    if session.time_over() and not session.finished:
 | 
			
		||||
        session.finished = True
 | 
			
		||||
        session.reason = "timeout"
 | 
			
		||||
 | 
			
		||||
    next_q = session.current_question() if not session.finished else None
 | 
			
		||||
 | 
			
		||||
    return AnswerResponse(
 | 
			
		||||
        correct=is_correct,
 | 
			
		||||
        finished=session.finished,
 | 
			
		||||
        reason=session.reason,
 | 
			
		||||
        remaining_seconds=session.remaining_seconds(),
 | 
			
		||||
        score=Score(correct=session.correct_count, total_answered=session.answered, total=session.total_questions),
 | 
			
		||||
        question=next_q,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StateResponse(BaseModel):
 | 
			
		||||
    finished: bool
 | 
			
		||||
    reason: Optional[str]
 | 
			
		||||
    remaining_seconds: int
 | 
			
		||||
    current_index: int
 | 
			
		||||
    total_questions: int
 | 
			
		||||
    correct: int
 | 
			
		||||
    answered: int
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@app.get("/api/session/{session_id}/state", response_model=StateResponse)
 | 
			
		||||
def get_state(session_id: str):
 | 
			
		||||
    session = _get_session(session_id)
 | 
			
		||||
    if session.time_over() and not session.finished:
 | 
			
		||||
        session.finished = True
 | 
			
		||||
        session.reason = "timeout"
 | 
			
		||||
    return StateResponse(
 | 
			
		||||
        finished=session.finished,
 | 
			
		||||
        reason=session.reason,
 | 
			
		||||
        remaining_seconds=session.remaining_seconds(),
 | 
			
		||||
        current_index=min(session.current_index + 1, session.total_questions),
 | 
			
		||||
        total_questions=session.total_questions,
 | 
			
		||||
        correct=session.correct_count,
 | 
			
		||||
        answered=session.answered,
 | 
			
		||||
    )
 | 
			
		||||
		Reference in New Issue
	
	Block a user