Files
filwordspl-backend/routers/teacher.py
T
2026-09-20 11:45:58 +00:00

1164 lines
46 KiB
Python

"""
Teacher Router — гибридный чат-учитель.
Контент из БД (бесплатно), AI только для реакций и пробелов.
Типы сообщений:
1. Объяснение — из learning_lessons
2. Вопрос recognition — кнопки A/B/C/D
3. Вопрос production — поле ввода
4. Реакция — шаблон + wrong_because_explanations (без AI)
5. AI-сообщение — только по явному триггеру
Слэш-команды:
- /start — начать урок
- /resume — восстановить сессию
- /answer — проверить ответ
- /explain — AI-объяснение
- /examples — 5 примеров по теме (после теста темы)
- /free-message — свободное сообщение
- /diagnostic-test — тест на определение уровня
- /translate-to-polish — перевод на польский
"""
import logging
import json
import httpx
import re
import unicodedata
from datetime import datetime, timezone
from fastapi import APIRouter, Request, HTTPException
from auth import require_auth
from database import get_pool
from config import TOGETHER_API_KEY, ADMIN_IDS
from .orchestrator import (
get_next_lesson,
get_lesson_by_id,
get_lesson_questions,
get_test_questions,
get_random_reaction,
get_wrong_because_explanation,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/teacher", tags=["teacher"])
TOGETHER_API_URL = "https://api.together.xyz/v1/chat/completions"
TEACHER_MODEL = "MiniMaxAI/MiniMax-M3"
TEACHER_AI_DAILY_LIMIT = 5
def get_ai_limit_message(lang: str) -> str:
"""Возвращает локализованное сообщение о исчерпании AI-лимита."""
messages = {
"pl": "Dziś wykorzystałeś już limit AI-pomocy. Wróć jutro!",
"ru": "Сегодня ты использовал лимит AI-помощи. Возвращайся завтра!",
"uk": "Сьогодні ти використав ліміт AI-допомоги. Повертайся завтра!",
"en": "You've used your AI help limit today. Come back tomorrow!",
}
return messages.get(lang, messages["en"])
def normalize_answer(text: str) -> str:
"""Нормализует ответ: нижний регистр, убирает диакритику и пунктуацию."""
if not text:
return ""
text = text.strip().lower()
text = re.sub(r'[.,!?;:()\[\]{}"\'\-—–…]', '', text)
text = unicodedata.normalize('NFKD', text)
text = ''.join(c for c in text if not unicodedata.combining(c))
text = ' '.join(text.split())
return text
async def get_user_lang(conn, user_id: int) -> str:
"""Получает язык интерфейса пользователя."""
lang = await conn.fetchval("""
SELECT app_language FROM users WHERE telegram_id = $1
""", user_id)
return lang if lang in ["pl", "ru", "uk", "en"] else "pl"
async def get_teacher_ai_usage(conn, user_id: int) -> int:
"""Получает количество AI-команд учителя за сегодня."""
count = await conn.fetchval("""
SELECT COUNT(*) FROM teacher_ai_usage
WHERE user_id = $1 AND usage_date = CURRENT_DATE
""", user_id)
return count or 0
async def increment_teacher_ai_usage(conn, user_id: int) -> None:
"""Увеличивает счётчик AI-команд учителя за сегодня."""
await conn.execute("""
INSERT INTO teacher_ai_usage (user_id, usage_date, command_count)
VALUES ($1, CURRENT_DATE, 1)
ON CONFLICT (user_id, usage_date)
DO UPDATE SET command_count = teacher_ai_usage.command_count + 1
""", user_id)
async def check_ai_limit(conn, user_id: int) -> bool:
"""Проверяет, может ли пользователь использовать AI-команду."""
if user_id in ADMIN_IDS:
return True
usage = await get_teacher_ai_usage(conn, user_id)
return usage < TEACHER_AI_DAILY_LIMIT
async def start_learning_session(conn, user_id: int, level_code: str, topic_id: str, session_type: str) -> int:
"""Создаёт сессию обучения и возвращает session_id."""
row = await conn.fetchrow("""
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
VALUES ($1, $2, $3, $4) RETURNING id
""", user_id, level_code, topic_id, session_type)
return row["id"]
async def save_answer_to_session(conn, user_id: int, session_id: int, question_data: dict, selected_answer: str, correct: bool, time_taken_ms: int = 0, hint_used: bool = False) -> None:
"""Сохраняет ответ в user_learning_answers."""
q = question_data["question"]
wrong_because = None
if not correct:
for opt in question_data.get("options", []):
if opt.get("option_key") == selected_answer and opt.get("wrong_because"):
wrong_because = opt["wrong_because"]
break
await conn.execute("""
INSERT INTO user_learning_answers (
user_id, session_id, level_code, topic_id, lesson_id,
question_id, selected_answer, correct, skill_tag,
question_type, context_type, difficulty, time_taken_ms,
hint_used, wrong_because, is_final_answer
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, TRUE)
""",
user_id, session_id, q.get("level_code", "A1"), q.get("topic_id", ""), q.get("lesson_id"),
q["question_id"], selected_answer, correct, q["skill_tag"],
q["question_type"], q["context_type"], q["difficulty"], time_taken_ms,
hint_used, wrong_because)
async def update_mastery_after_lesson(conn, user_id: int, session_id: int) -> None:
"""Обновляет mastery и прогресс урока после завершения."""
from .learning import update_skill_mastery, update_topic_progress, update_ai_context
skill_tags = await conn.fetch("""
SELECT DISTINCT skill_tag FROM user_learning_answers WHERE session_id = $1
""", session_id)
for st in skill_tags:
await update_skill_mastery(conn, user_id, st["skill_tag"])
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1", session_id)
if session and session["session_type"] == "teacher_lesson":
lesson_row = await conn.fetchrow("""
SELECT DISTINCT lesson_id FROM user_learning_answers
WHERE session_id = $1 AND lesson_id IS NOT NULL
""", session_id)
if lesson_row and lesson_row["lesson_id"]:
lesson_id = lesson_row["lesson_id"]
stats = await conn.fetchrow("""
SELECT COUNT(*) as total,
COALESCE(SUM(CASE WHEN correct THEN 1 ELSE 0 END), 0) as correct_count
FROM user_learning_answers
WHERE session_id = $1 AND is_final_answer = TRUE
""", session_id)
total = stats["total"] if stats["total"] else 0
correct_count = stats["correct_count"] if stats["correct_count"] else 0
score = round((correct_count / total * 100), 2) if total > 0 else 0
await conn.execute("""
INSERT INTO user_lesson_progress (user_id, lesson_id, started_at, completed_at, attempts_count, correct_count, best_score, completed)
VALUES ($1, $2, NOW(), NOW(), 1, $3, $4, TRUE)
ON CONFLICT (user_id, lesson_id) DO UPDATE
SET completed_at = NOW(),
attempts_count = user_lesson_progress.attempts_count + 1,
correct_count = user_lesson_progress.correct_count + $3,
best_score = GREATEST(user_lesson_progress.best_score, $4),
completed = TRUE
""", user_id, lesson_id, correct_count, score)
if session and session["topic_id"]:
if session["session_type"] == "teacher_test":
stats = await conn.fetchrow("""
SELECT COUNT(*) as total,
COALESCE(SUM(CASE WHEN correct THEN 1 ELSE 0 END), 0) as correct_count
FROM user_learning_answers
WHERE session_id = $1 AND is_final_answer = TRUE
""", session_id)
total = stats["total"] if stats["total"] else 0
correct_count = stats["correct_count"] if stats["correct_count"] else 0
test_score = round((correct_count / total * 100), 2) if total > 0 else 0
await conn.execute("""
INSERT INTO user_topic_progress (user_id, topic_id, test_started, test_completed, test_score, completed_at)
VALUES ($1, $2, TRUE, TRUE, $3, NOW())
ON CONFLICT (user_id, topic_id) DO UPDATE
SET test_completed = TRUE,
test_score = $3,
completed_at = NOW()
""", user_id, session["topic_id"], test_score)
await update_topic_progress(conn, user_id, session["topic_id"])
await update_ai_context(conn, user_id)
async def call_ai(prompt: str, system_prompt: str, max_tokens: int = 500) -> str:
"""Вызывает AI и возвращает ответ."""
if not TOGETHER_API_KEY:
return ""
try:
async with httpx.AsyncClient(timeout=90.0) as client:
response = await client.post(
TOGETHER_API_URL,
headers={
"Authorization": f"Bearer {TOGETHER_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": TEACHER_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"max_tokens": max_tokens,
"temperature": 0.7,
},
)
if response.status_code != 200:
logger.error(f"AI API error: {response.status_code}, body={response.text[:200]}")
return ""
data = response.json()
ai_response = data["choices"][0]["message"]["content"].strip()
ai_response = re.sub(r'\s*\([a-z_]+_[a-z_]+\)', '', ai_response)
return ai_response
except Exception as e:
logger.error(f"AI error: {str(e)}")
return ""
async def get_wrong_answer_ai_explanation(telegram_id: int, lang: str, prompt_text: str, selected_answer: str, correct_answer_text: str) -> str:
"""
Получает AI-объяснение для неправильного ответа.
Проверяет лимит и инкрементирует счётчик.
Если лимит исчерпан — возвращает локализованное сообщение.
"""
pool = await get_pool()
async with pool.acquire() as conn:
if not await check_ai_limit(conn, telegram_id):
return get_ai_limit_message(lang)
ai_prompt = f"""Вопрос: {prompt_text}
Ответ ученика: {selected_answer}
Правильный ответ: {correct_answer_text}
Объясни кратко (1-2 предложения), почему правильный ответ именно такой."""
system_prompt = f"""Ты — Марек, учитель польского языка. Объясняй ошибки кратко и понятно.
Отвечай на языке: {lang}. Не используй markdown."""
ai_response = await call_ai(ai_prompt, system_prompt, max_tokens=200)
if ai_response:
async with pool.acquire() as conn:
await increment_teacher_ai_usage(conn, telegram_id)
else:
ai_response = get_ai_limit_message(lang) if not await _check_ai_limit_for_fallback(telegram_id, lang) else ""
return ai_response
async def _check_ai_limit_for_fallback(telegram_id: int, lang: str) -> bool:
"""Проверяет, был ли лимит исчерпан (для fallback-сообщения)."""
pool = await get_pool()
async with pool.acquire() as conn:
return await check_ai_limit(conn, telegram_id)
# ============ ФРАЗА ДНЯ ============
@router.get("/phrase-of-day")
async def get_phrase_of_day(request: Request):
"""
Возвращает фразу дня.
Фраза кэшируется для всех пользователей на 24 часа.
Не тратит AI-лимит пользователя.
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
await conn.execute("""
CREATE TABLE IF NOT EXISTS daily_phrases (
id SERIAL PRIMARY KEY,
phrase_date DATE UNIQUE NOT NULL,
phrase_pl TEXT NOT NULL,
translations JSONB NOT NULL DEFAULT '{}',
grammar_breakdowns JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
)
""")
today = datetime.now(timezone.utc).date()
today_phrase = await conn.fetchrow("""
SELECT * FROM daily_phrases WHERE phrase_date = $1
""", today)
if not today_phrase:
# Список тем для фразы дня
topics_list = [
"приветствие и знакомство",
"семья и друзья",
"еда и напитки",
"покупки и магазины",
"транспорт и путешествия",
"работа и профессии",
"погода и времена года",
"дом и квартира",
"хобби и свободное время",
"здоровье и самочувствие",
"город и местность",
"ежедневная рутина"
]
# Выбираем случайную тему
import random
selected_topic = random.choice(topics_list)
system_prompt = f"""Ты — учитель польского языка. Создай фразу дня для изучения польского.
Тема фразы: {selected_topic}.
Фраза должна быть уровня A2-B1, полезной в повседневной жизни.
Верни ответ строго в таком формате (без markdown, просто текст):
PHRASE: [фраза на польском]
RU: [перевод на русский]
UK: [перевод на украинский]
EN: [перевод на английский]
PL_GRAMMAR: [грамматический разбор на польском, 1-2 предложения]
RU_GRAMMAR: [грамматический разбор на русском, 1-2 предложения]
UK_GRAMMAR: [грамматический разбор на украинском, 1-2 предложения]
EN_GRAMMAR: [грамматический разбор на английском, 1-2 предложения]"""
user_prompt = f"Создай фразу дня на тему: {selected_topic}."
ai_response = await call_ai(user_prompt, system_prompt, max_tokens=1000)
# Fallback значения
phrase_pl = "Lepiej późno niż wcale."
translations = {
"ru": "Лучше поздно, чем никогда.",
"uk": "Краще пізно, ніж ніколи.",
"en": "Better late than never."
}
grammar_breakdowns = {
"pl": "Lepiej - przyslowek w stopniu wyzszym. Pozno - przyslowek.",
"ru": "Lepiej - наречие в сравнительной степени. Pozno - наречие.",
"uk": "Lepiej - прислівник у вищому ступені. Pozno - прислівник.",
"en": "Lepiej - adverb in comparative degree. Pozno - adverb."
}
if ai_response:
# Парсим текстовый ответ построчно
lines = ai_response.strip().split('\n')
for line in lines:
line = line.strip()
if line.startswith('PHRASE:'):
phrase_pl = line.replace('PHRASE:', '').strip()
elif line.startswith('RU:'):
translations["ru"] = line.replace('RU:', '').strip()
elif line.startswith('UK:'):
translations["uk"] = line.replace('UK:', '').strip()
elif line.startswith('EN:'):
translations["en"] = line.replace('EN:', '').strip()
elif line.startswith('PL_GRAMMAR:'):
grammar_breakdowns["pl"] = line.replace('PL_GRAMMAR:', '').strip()
elif line.startswith('RU_GRAMMAR:'):
grammar_breakdowns["ru"] = line.replace('RU_GRAMMAR:', '').strip()
elif line.startswith('UK_GRAMMAR:'):
grammar_breakdowns["uk"] = line.replace('UK_GRAMMAR:', '').strip()
elif line.startswith('EN_GRAMMAR:'):
grammar_breakdowns["en"] = line.replace('EN_GRAMMAR:', '').strip()
await conn.execute("""
INSERT INTO daily_phrases (phrase_date, phrase_pl, translations, grammar_breakdowns)
VALUES ($1, $2, $3::jsonb, $4::jsonb)
ON CONFLICT (phrase_date) DO UPDATE SET
phrase_pl = EXCLUDED.phrase_pl,
translations = EXCLUDED.translations,
grammar_breakdowns = EXCLUDED.grammar_breakdowns
""", today, phrase_pl, json.dumps(translations, ensure_ascii=False), json.dumps(grammar_breakdowns, ensure_ascii=False))
today_phrase = await conn.fetchrow("""
SELECT * FROM daily_phrases WHERE phrase_date = $1
""", today)
translations = today_phrase["translations"]
grammar_breakdowns = today_phrase["grammar_breakdowns"]
if isinstance(translations, str):
translations = json.loads(translations)
if isinstance(grammar_breakdowns, str):
grammar_breakdowns = json.loads(grammar_breakdowns)
result = {
"phrase_pl": today_phrase["phrase_pl"],
"date": today_phrase["phrase_date"].isoformat() if today_phrase["phrase_date"] else None,
}
if lang != "pl":
result["translation"] = translations.get(lang, translations.get("en", ""))
result["grammar_breakdown"] = grammar_breakdowns.get(lang, grammar_breakdowns.get("en", ""))
return result
@router.post("/resume")
async def teacher_resume(request: Request):
"""
Восстанавливает активную сессию.
Проверяет, есть ли незавершённая сессия и возвращает текущий вопрос.
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
session = await conn.fetchrow("""
SELECT * FROM learning_sessions
WHERE user_id = $1 AND is_completed = FALSE
ORDER BY id DESC
LIMIT 1
""", telegram_id)
if not session:
return {"has_active_session": False}
last_answer = await conn.fetchrow("""
SELECT question_id FROM user_learning_answers
WHERE session_id = $1
ORDER BY id DESC
LIMIT 1
""", session["id"])
if not last_answer:
if session["session_type"] == "teacher_test":
questions = await get_test_questions(conn, session["topic_id"])
else:
lesson_id = await conn.fetchval("""
SELECT lesson_id FROM learning_sessions WHERE id = $1
""", session["id"])
if lesson_id:
questions = await get_lesson_questions(conn, lesson_id)
else:
questions = []
if questions:
return {
"has_active_session": True,
"session_id": session["id"],
"session_type": session["session_type"],
"topic_id": session["topic_id"],
"question": questions[0],
"question_index": 0,
"total_questions": len(questions),
}
return {"has_active_session": False}
if session["session_type"] == "teacher_test":
questions = await get_test_questions(conn, session["topic_id"])
else:
lesson_id = await conn.fetchval("""
SELECT lesson_id FROM user_learning_answers
WHERE session_id = $1 AND lesson_id IS NOT NULL
LIMIT 1
""", session["id"])
if lesson_id:
questions = await get_lesson_questions(conn, lesson_id)
else:
questions = []
last_question_id = last_answer["question_id"]
last_index = 0
for i, q in enumerate(questions):
if q["question"]["question_id"] == last_question_id:
last_index = i
break
next_index = last_index + 1
if next_index >= len(questions):
await conn.execute("""
UPDATE learning_sessions SET is_completed = TRUE, completed_at = NOW()
WHERE id = $1
""", session["id"])
await update_mastery_after_lesson(conn, telegram_id, session["id"])
return {"has_active_session": False, "session_completed": True}
next_question = questions[next_index]
return {
"has_active_session": True,
"session_id": session["id"],
"session_type": session["session_type"],
"topic_id": session["topic_id"],
"question": next_question,
"question_index": next_index,
"total_questions": len(questions),
}
@router.post("/start")
async def teacher_start(request: Request):
"""
Начинает или продолжает урок.
Возвращает первое сообщение от учителя.
"""
telegram_id, username = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
first_name = username or "uczniu"
next_lesson = await get_next_lesson(conn, telegram_id)
if next_lesson["type"] == "completed":
return {
"message_type": "completed",
"text": "Wszystkie lekcje ukończone! Świetna robota!" if lang == "pl" else
"Все уроки пройдены! Отличная работа!" if lang == "ru" else
"Всі уроки пройдені! Чудова робота!" if lang == "uk" else
"All lessons completed! Great job!",
}
if next_lesson["type"] == "test":
topic_id = next_lesson.get("topic_id")
test_questions = await get_test_questions(conn, topic_id)
if not test_questions:
return {
"message_type": "error",
"text": "No test questions found.",
}
session_id = await start_learning_session(conn, telegram_id, "A1", topic_id, "teacher_test")
first_question = test_questions[0]
return {
"message_type": "test_start",
"session_id": session_id,
"topic_id": topic_id,
"topic_name": "",
"text": "Czas na test!" if lang == "pl" else
"Время теста!" if lang == "ru" else
"Час тесту!" if lang == "uk" else
"Test time!",
"question": first_question,
"question_index": 0,
"total_questions": len(test_questions),
"has_translate_command": True,
}
lesson_id = next_lesson["lesson"]["lesson_id"]
lesson_data = await get_lesson_by_id(conn, lesson_id)
if not lesson_data:
return {
"message_type": "error",
"text": "Lesson not found.",
}
session_id = await start_learning_session(
conn, telegram_id, "A1", lesson_data["lesson"]["topic_id"], "teacher_lesson"
)
lesson = lesson_data["lesson"]
examples = lesson_data["examples"]
explanation_field = f"explanation_{lang}"
title_field = f"title_{lang}"
explanation_text = lesson.get(explanation_field) or lesson.get("explanation_en") or ""
title_text = lesson.get(title_field) or lesson.get("title_en") or ""
examples_text = ""
for ex in examples:
ex_field = f"text_{lang}"
ex_translation = ex.get(ex_field) or ex.get("text_en") or ""
examples_text += f"🇵🇱 {ex['text_pl']}{ex_translation}\n"
full_text = f"{title_text}\n\n{explanation_text}"
if examples_text:
full_text += f"\n\nPrzykłady:\n{examples_text}"
questions = lesson_data["questions"]
first_question = questions[0] if questions else None
if not first_question:
return {
"message_type": "lesson_completed",
"session_id": session_id,
"text": "Lekcja zakończona!" if lang == "pl" else
"Урок завершён!" if lang == "ru" else
"Урок завершено!" if lang == "uk" else
"Lesson completed!",
}
return {
"message_type": "lesson_explanation",
"session_id": session_id,
"lesson_id": lesson_id,
"topic_id": lesson_data["lesson"]["topic_id"],
"topic_name": title_text,
"text": full_text,
"question": first_question,
"question_index": 0,
"total_questions": len(questions),
"has_explain_command": True,
"has_translate_command": True,
}
@router.post("/answer")
async def teacher_answer(request: Request):
"""
Проверяет ответ пользователя и возвращает реакцию + следующий вопрос.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
session_id = body.get("session_id")
question_id = body.get("question_id")
selected_answer = body.get("answer", "")
time_taken_ms = body.get("time_taken_ms", 0)
if not session_id or not question_id:
raise HTTPException(400, "session_id and question_id required")
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
question_row = await conn.fetchrow("""
SELECT * FROM learning_questions WHERE question_id = $1
""", question_id)
if not question_row:
raise HTTPException(404, "Question not found")
options = await conn.fetch("""
SELECT * FROM question_options WHERE question_id = $1
""", question_id)
correct_answer = question_row["correct_answer"] or ""
is_correct = normalize_answer(selected_answer) == normalize_answer(correct_answer)
wrong_because = None
for opt in options:
if opt["option_key"] == selected_answer and not is_correct and opt.get("wrong_because"):
wrong_because = opt["wrong_because"]
break
question_data = {
"question": dict(question_row),
"options": [dict(o) for o in options],
}
await save_answer_to_session(
conn, telegram_id, session_id, question_data,
selected_answer, is_correct, time_taken_ms
)
if is_correct:
reaction_type = "correct"
else:
reaction_type = "wrong" if wrong_because else "almost"
reaction_text = await get_random_reaction(conn, reaction_type, lang)
db_explanation = ""
if not is_correct and wrong_because:
db_explanation = await get_wrong_because_explanation(conn, wrong_because, lang)
session = await conn.fetchrow("SELECT * FROM learning_sessions WHERE id = $1", session_id)
if not session:
raise HTTPException(404, "Session not found")
if session["session_type"] == "teacher_test":
questions = await get_test_questions(conn, session["topic_id"])
else:
lesson_id = question_row["lesson_id"]
if lesson_id:
questions = await get_lesson_questions(conn, lesson_id)
else:
questions = []
current_index = 0
for i, q in enumerate(questions):
if q["question"]["question_id"] == question_id:
current_index = i
break
next_index = current_index + 1
options_language = question_row.get("options_language") or "pl"
correct_answer_text = ""
for opt in options:
if opt["is_correct"]:
if options_language == "user":
correct_answer_text = opt.get(f"text_{lang}", "") or opt.get("text_pl", "") or ""
else:
correct_answer_text = opt.get("text_pl", "") or opt.get("text_ru", "") or ""
break
prompt_text = question_row.get(f"prompt_{lang}") or question_row.get("prompt_en") or ""
needs_ai_explanation = not is_correct and not db_explanation
session_completed = next_index >= len(questions)
next_question_data = None if session_completed else questions[next_index]
if session_completed:
stats = await conn.fetchrow("""
SELECT COUNT(*) as total,
COALESCE(SUM(CASE WHEN correct THEN 1 ELSE 0 END), 0) as correct_count
FROM user_learning_answers
WHERE session_id = $1 AND is_final_answer = TRUE
""", session_id)
session_stats = {
"total": stats["total"] if stats["total"] else 0,
"correct_count": stats["correct_count"] if stats["correct_count"] else 0,
}
session_type = session["session_type"]
await conn.execute("""
UPDATE learning_sessions SET is_completed = TRUE, completed_at = NOW()
WHERE id = $1
""", session_id)
await update_mastery_after_lesson(conn, telegram_id, session_id)
explanation_text = db_explanation
if needs_ai_explanation:
explanation_text = await get_wrong_answer_ai_explanation(
telegram_id, lang, prompt_text, selected_answer, correct_answer_text
)
if session_completed:
return {
"message_type": "lesson_completed",
"reaction": reaction_text,
"explanation": explanation_text,
"is_correct": is_correct,
"correct_answer": correct_answer_text,
"wrong_because": wrong_because or "",
"session_completed": True,
"session_stats": session_stats,
"session_type": session_type,
}
return {
"message_type": "next_question",
"reaction": reaction_text,
"explanation": explanation_text,
"is_correct": is_correct,
"correct_answer": correct_answer_text,
"wrong_because": wrong_because or "",
"question": next_question_data,
"question_index": next_index,
"total_questions": len(questions),
"has_explain_command": not is_correct,
}
@router.post("/explain")
async def teacher_explain(request: Request):
"""
AI-объяснение текущего урока или ошибки.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
lesson_id = body.get("lesson_id", "")
question_id = body.get("question_id", "")
topic = body.get("topic", "")
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
if not await check_ai_limit(conn, telegram_id):
return {
"response": "",
"limit_exceeded": True,
"message": get_ai_limit_message(lang)
}
content = ""
if lesson_id:
lesson = await conn.fetchrow("""
SELECT * FROM learning_lessons WHERE lesson_id = $1
""", lesson_id)
if lesson:
content = lesson.get(f"explanation_{lang}") or lesson.get("explanation_en") or ""
elif question_id:
question = await conn.fetchrow("""
SELECT * FROM learning_questions WHERE question_id = $1
""", question_id)
if question:
content = question.get(f"prompt_{lang}") or question.get("prompt_en") or ""
else:
content = topic
if not content:
return {"response": "", "error": "No content to explain"}
system_prompt = f"""Ты — учитель польского языка. Объясни материал простыми словами.
Отвечай на языке: {lang}. Не используй markdown."""
ai_response = await call_ai(content, system_prompt)
if ai_response:
async with pool.acquire() as conn:
await increment_teacher_ai_usage(conn, telegram_id)
return {"response": ai_response}
@router.post("/examples")
async def teacher_examples(request: Request):
"""
Даёт 5 примеров предложений по текущей теме.
Формат: 🇵🇱 польская фраза → перевод на язык пользователя.
AI-команда, лимит 5/день.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
topic = body.get("topic", "")
lesson_id = body.get("lesson_id", "")
topic_id = body.get("topic_id", "")
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
if not await check_ai_limit(conn, telegram_id):
return {
"response": "",
"limit_exceeded": True,
"message": get_ai_limit_message(lang)
}
# Если не передан topic — берём по lesson_id
if lesson_id and not topic:
lesson = await conn.fetchrow("""
SELECT title_pl, title_ru, title_uk, title_en
FROM learning_lessons WHERE lesson_id = $1
""", lesson_id)
if lesson:
topic = lesson.get(f"title_{lang}") or lesson.get("title_en") or ""
# Если не передан topic и lesson_id — берём по topic_id
if topic_id and not topic:
topic_row = await conn.fetchrow("""
SELECT name_pl, name_ru, name_uk, name_en
FROM learning_topics WHERE topic_id = $1
""", topic_id)
if topic_row:
topic = topic_row.get(f"name_{lang}") or topic_row.get("name_en") or ""
if not topic:
topic = "polish language"
lang_names = {
"pl": "polskiego",
"ru": "русский",
"uk": "украинский",
"en": "English",
}
lang_name = lang_names.get(lang, "English")
system_prompt = f"""Ты — учитель польского языка. Дай ровно 5 примеров предложений по теме: {topic}.
Каждый пример — простое предложение уровня A2, полезное в повседневной жизни.
Формат ответа (без markdown, каждый пример с новой строки):
🇵🇱 [польское предложение] → [перевод на {lang_name}]
Пример:
🇵🇱 Jestem nauczycielem. → Я учитель.
🇵🇱 Pracuję w biurze. → Я работаю в офисе.
Верни только 5 примеров, без вступлений и пояснений."""
ai_response = await call_ai(topic, system_prompt, max_tokens=500)
if ai_response:
async with pool.acquire() as conn:
await increment_teacher_ai_usage(conn, telegram_id)
return {"response": ai_response}
@router.post("/free-message")
async def teacher_free_message(request: Request):
"""
Обрабатывает свободное сообщение пользователя.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
user_message = body.get("message", "").strip()
lesson_id = body.get("lesson_id", "")
topic_id = body.get("topic_id", "")
topic_name = body.get("topic_name", "")
if not user_message:
raise HTTPException(400, "message is required")
if len(user_message) > 300:
raise HTTPException(400, "Message too long (max 300 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
if not await check_ai_limit(conn, telegram_id):
return {
"response": "",
"limit_exceeded": True,
"message": get_ai_limit_message(lang)
}
context_parts = []
if topic_name:
context_parts.append(f"Текущая тема урока: {topic_name}")
if lesson_id:
lesson = await conn.fetchrow("""
SELECT title_pl, title_ru, title_uk, title_en
FROM learning_lessons WHERE lesson_id = $1
""", lesson_id)
if lesson:
lesson_title = lesson.get(f"title_{lang}") or lesson.get("title_en") or ""
context_parts.append(f"Текущий урок: {lesson_title}")
context_text = "\n".join(context_parts) if context_parts else ""
system_prompt = f"""Ты — Марек, учитель польского языка в приложении Filwords PL.
Ты дружелюбный, поддерживающий и краткий.
Отвечай на языке: {lang}.
Не используй markdown.
{context_text}
Ученик может спросить что угодно: объяснить тему, перевести фразу, дать пример.
Отвечай кратко (1-3 предложения) и по делу."""
ai_response = await call_ai(user_message, system_prompt, max_tokens=300)
if ai_response:
async with pool.acquire() as conn:
await increment_teacher_ai_usage(conn, telegram_id)
return {"response": ai_response}
@router.post("/diagnostic-test")
async def teacher_diagnostic_test(request: Request):
"""
Тест на определение уровня языка.
Берёт случайные вопросы разных тем и уровней.
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
questions = await conn.fetch("""
SELECT * FROM learning_questions
WHERE is_test_question = TRUE
ORDER BY RANDOM()
LIMIT 15
""")
if not questions:
return {"error": "No questions available"}
session_id = await start_learning_session(conn, telegram_id, "A1", "", "diagnostic_test")
questions_data = []
for q in questions:
options = await conn.fetch("""
SELECT * FROM question_options WHERE question_id = $1
""", q["question_id"])
questions_data.append({
"question": dict(q),
"options": [dict(o) for o in options],
})
return {
"session_id": session_id,
"questions": questions_data,
"total_questions": len(questions_data),
}
@router.post("/translate-to-polish")
async def teacher_translate_to_polish(request: Request):
"""
Переводит текст на польский язык.
AI-команда, лимит 5/день.
"""
telegram_id, _ = await require_auth(request)
body = await request.json()
text = body.get("text", "").strip()
if not text:
raise HTTPException(400, "text is required")
if len(text) > 300:
raise HTTPException(400, "Text too long (max 300 characters)")
pool = await get_pool()
async with pool.acquire() as conn:
lang = await get_user_lang(conn, telegram_id)
if not await check_ai_limit(conn, telegram_id):
return {
"response": "",
"limit_exceeded": True,
"message": get_ai_limit_message(lang)
}
system_prompt = f"""Ты — переводчик польского языка. Переведи текст на польский.
Отвечай только переводом без пояснений. Не используй markdown."""
ai_response = await call_ai(text, system_prompt, max_tokens=300)
if ai_response:
async with pool.acquire() as conn:
await increment_teacher_ai_usage(conn, telegram_id)
return {"response": ai_response}
# ============ ПРОГРЕСС ПО УРОВНЯМ ============
@router.get("/progress")
async def teacher_progress(request: Request):
"""
Возвращает прогресс пользователя по всем уровням (A1-C2).
Для каждого уровня: сколько тем пройдено из общего количества.
"""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
levels = await conn.fetch("""
SELECT code, name_pl, name_ru, name_uk, name_en
FROM learning_levels
WHERE is_active = TRUE
ORDER BY sort_order
""")
result = []
for level in levels:
code = level["code"]
topics_total = await conn.fetchval("""
SELECT COUNT(*) FROM learning_topics
WHERE level_code = $1 AND is_active = TRUE
""", code) or 0
topics_completed = await conn.fetchval("""
SELECT COUNT(*) FROM user_topic_progress
WHERE user_id = $1 AND topic_id IN (
SELECT topic_id FROM learning_topics WHERE level_code = $2 AND is_active = TRUE
) AND test_completed = TRUE
""", telegram_id, code) or 0
result.append({
"code": code,
"name_pl": level["name_pl"],
"name_ru": level["name_ru"],
"name_uk": level["name_uk"],
"name_en": level["name_en"],
"topics_total": topics_total,
"topics_completed": topics_completed,
})
return {"levels": result}
@router.post("/history/save")
async def save_teacher_history(request: Request):
"""Сохраняет историю чата учителя на сервере."""
telegram_id, _ = await require_auth(request)
body = await request.json()
messages = body.get("messages", [])
if not isinstance(messages, list):
raise HTTPException(400, "messages must be a list")
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("""
INSERT INTO teacher_chat_history (user_id, messages, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (user_id) DO UPDATE
SET messages = $2::jsonb, updated_at = NOW()
""", telegram_id, json.dumps(messages, ensure_ascii=False))
return {"status": "ok"}
@router.get("/history/load")
async def load_teacher_history(request: Request):
"""Загружает историю чата учителя с сервера."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT messages FROM teacher_chat_history WHERE user_id = $1
""", telegram_id)
if row and row["messages"]:
messages = row["messages"]
if isinstance(messages, str):
messages = json.loads(messages)
return {"messages": messages}
return {"messages": []}
@router.post("/history/clear")
async def clear_teacher_history(request: Request):
"""Очищает историю чата учителя на сервере."""
telegram_id, _ = await require_auth(request)
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("""
DELETE FROM teacher_chat_history WHERE user_id = $1
""", telegram_id)
return {"status": "ok"}