1164 lines
46 KiB
Python
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"} |