- Оптимизирована генерация слов дня: 2 запроса к AI вместо 11 - Добавлена кнопка "Слово дня" в /stats для быстрого доступа - Локализация для ru/en/ja 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1641 lines
77 KiB
Python
1641 lines
77 KiB
Python
import json
|
||
import logging
|
||
import httpx
|
||
from openai import AsyncOpenAI
|
||
from config.settings import settings
|
||
from database.db import async_session_maker
|
||
from database.models import AIProvider
|
||
from typing import Dict, List, Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AIService:
|
||
"""Сервис для работы с AI API (OpenAI и Google)"""
|
||
|
||
def __init__(self):
|
||
self.openai_api_key = settings.openai_api_key
|
||
self.google_api_key = settings.google_api_key
|
||
|
||
# Проверяем, настроен ли Cloudflare AI Gateway
|
||
if settings.cloudflare_account_id:
|
||
# Используем Cloudflare AI Gateway с прямыми HTTP запросами
|
||
self.openai_base_url = (
|
||
f"https://gateway.ai.cloudflare.com/v1/"
|
||
f"{settings.cloudflare_account_id}/"
|
||
f"{settings.cloudflare_gateway_id}/"
|
||
f"openai"
|
||
)
|
||
self.use_cloudflare = True
|
||
logger.info(f"AI Service initialized with Cloudflare Gateway: {self.openai_base_url}")
|
||
else:
|
||
# Прямое подключение к OpenAI
|
||
self.openai_base_url = "https://api.openai.com/v1"
|
||
self.use_cloudflare = False
|
||
logger.info("AI Service initialized with direct OpenAI connection")
|
||
|
||
# Google Gemini API URL (через Cloudflare Gateway или напрямую)
|
||
if settings.cloudflare_account_id:
|
||
self.google_base_url = (
|
||
f"https://gateway.ai.cloudflare.com/v1/"
|
||
f"{settings.cloudflare_account_id}/"
|
||
f"{settings.cloudflare_gateway_id}/"
|
||
f"google-ai-studio/v1"
|
||
)
|
||
else:
|
||
self.google_base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||
|
||
# HTTP клиент для всех запросов
|
||
self.http_client = httpx.AsyncClient(
|
||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
||
)
|
||
|
||
# Кеш активной модели (обновляется при запросах)
|
||
self._cached_model: Optional[str] = None
|
||
self._cached_provider: Optional[AIProvider] = None
|
||
|
||
def _markdown_to_html(self, text: str) -> str:
|
||
"""Конвертировать markdown форматирование в HTML для Telegram."""
|
||
import re
|
||
# **bold** -> <b>bold</b>
|
||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||
# *italic* -> <i>italic</i> (но не внутри уже конвертированных тегов)
|
||
text = re.sub(r'(?<!</[bi]>)\*([^*]+?)\*(?![^<]*</)', r'<i>\1</i>', text)
|
||
# Убираем оставшиеся одиночные * в начале строк (списки)
|
||
text = re.sub(r'^\*\s+', '• ', text, flags=re.MULTILINE)
|
||
return text
|
||
|
||
def _strip_markdown_code_block(self, text: str) -> str:
|
||
"""Удалить markdown обёртку ```json ... ``` из текста."""
|
||
import re
|
||
text = text.strip()
|
||
|
||
# Паттерн для ```json ... ``` или просто ``` ... ```
|
||
pattern = r'^```(?:json)?\s*\n?(.*?)\n?```$'
|
||
match = re.match(pattern, text, re.DOTALL)
|
||
if match:
|
||
return match.group(1).strip()
|
||
|
||
# Альтернативный способ - если начинается с ``` но паттерн не сработал
|
||
if text.startswith('```'):
|
||
lines = text.split('\n')
|
||
# Убираем первую строку (```json или ```)
|
||
lines = lines[1:]
|
||
# Убираем последнюю строку если это ```
|
||
if lines and lines[-1].strip() == '```':
|
||
lines = lines[:-1]
|
||
return '\n'.join(lines).strip()
|
||
|
||
return text
|
||
|
||
async def _get_active_model(self, user_id: Optional[int] = None) -> tuple[str, AIProvider]:
|
||
"""
|
||
Получить активную модель и провайдера из БД.
|
||
|
||
Args:
|
||
user_id: ID пользователя в БД (не telegram_id). Если указан, берёт модель пользователя.
|
||
|
||
Returns:
|
||
tuple[model_name, provider]
|
||
"""
|
||
from services.ai_model_service import AIModelService, DEFAULT_MODEL, DEFAULT_PROVIDER
|
||
|
||
async with async_session_maker() as session:
|
||
if user_id:
|
||
# Получаем модель пользователя (или глобальную если не выбрана)
|
||
model = await AIModelService.get_user_model(session, user_id)
|
||
else:
|
||
# Глобальная активная модель
|
||
model = await AIModelService.get_active_model(session)
|
||
|
||
if model:
|
||
self._cached_model = model.model_name
|
||
self._cached_provider = model.provider
|
||
return model.model_name, model.provider
|
||
|
||
return DEFAULT_MODEL, DEFAULT_PROVIDER
|
||
|
||
async def _make_request(self, messages: list, temperature: float = 0.3, user_id: Optional[int] = None) -> dict:
|
||
"""
|
||
Выполнить запрос к активному AI провайдеру.
|
||
|
||
Args:
|
||
messages: Сообщения для API
|
||
temperature: Температура генерации
|
||
user_id: ID пользователя в БД для получения его модели
|
||
"""
|
||
model_name, provider = await self._get_active_model(user_id)
|
||
|
||
if provider == AIProvider.google:
|
||
return await self._make_google_request(messages, temperature, model_name)
|
||
else:
|
||
return await self._make_openai_request(messages, temperature, model_name)
|
||
|
||
async def _make_google_request(self, messages: list, temperature: float = 0.3, model: str = "gemini-2.5-flash-lite") -> dict:
|
||
"""Выполнить запрос к Google Gemini API (через Cloudflare Gateway или напрямую)"""
|
||
url = f"{self.google_base_url}/models/{model}:generateContent"
|
||
|
||
# Конвертируем формат сообщений OpenAI в формат Google
|
||
# System message добавляем как первое user сообщение
|
||
contents = []
|
||
|
||
for msg in messages:
|
||
role = msg["role"]
|
||
content = msg["content"]
|
||
|
||
if role == "system":
|
||
# Добавляем system как user сообщение в начало
|
||
contents.insert(0, {"role": "user", "parts": [{"text": f"[System instruction]: {content}"}]})
|
||
elif role == "user":
|
||
contents.append({"role": "user", "parts": [{"text": content}]})
|
||
elif role == "assistant":
|
||
contents.append({"role": "model", "parts": [{"text": content}]})
|
||
|
||
payload = {
|
||
"contents": contents,
|
||
"generationConfig": {
|
||
"temperature": temperature
|
||
}
|
||
}
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"x-goog-api-key": self.google_api_key
|
||
}
|
||
|
||
response = await self.http_client.post(url, headers=headers, json=payload)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
# Конвертируем ответ Google в формат OpenAI для совместимости
|
||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||
|
||
# Убираем markdown обёртку если есть (```json ... ``` или ```...```)
|
||
text = self._strip_markdown_code_block(text)
|
||
|
||
return {
|
||
"choices": [{
|
||
"message": {
|
||
"content": text
|
||
}
|
||
}]
|
||
}
|
||
|
||
async def _make_openai_request(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict:
|
||
"""Выполнить запрос к OpenAI API (через Cloudflare или напрямую)"""
|
||
url = f"{self.openai_base_url}/chat/completions"
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {self.openai_api_key}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages
|
||
}
|
||
|
||
# Модели с ограничениями (не поддерживают temperature и json mode)
|
||
limited_models = {"gpt-5-nano", "o1", "o1-mini", "o1-preview", "o3-mini"}
|
||
|
||
if model not in limited_models:
|
||
payload["temperature"] = temperature
|
||
|
||
# JSON mode
|
||
payload["response_format"] = {"type": "json_object"}
|
||
|
||
response = await self.http_client.post(url, headers=headers, json=payload)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
async def translate_word(self, word: str, source_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
|
||
"""
|
||
Перевести слово и получить дополнительную информацию
|
||
|
||
Args:
|
||
word: Слово для перевода
|
||
source_lang: Язык исходного слова (ISO2)
|
||
translation_lang: Язык перевода (ISO2)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с переводом, транскрипцией и примерами
|
||
"""
|
||
prompt = f"""Переведи слово/фразу "{word}" с языка {source_lang} на {translation_lang}.
|
||
|
||
Верни ответ строго в формате JSON:
|
||
{{
|
||
"word": "исходное слово на {source_lang}",
|
||
"translation": "перевод на {translation_lang}",
|
||
"transcription": "транскрипция в IPA (если применимо)",
|
||
"examples": [
|
||
{{"{source_lang}": "пример на языке обучения", "{translation_lang}": "перевод примера"}}
|
||
],
|
||
"category": "категория слова (работа, еда, путешествия и т.д.)",
|
||
"difficulty": "уровень сложности (A1/A2/B1/B2/C1/C2)"
|
||
}}
|
||
|
||
Важно: верни только JSON, без дополнительного текста."""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] translate_word: word='{word}', source='{source_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
logger.info(f"[AI Response] translate_word: success, translation='{result.get('translation', 'N/A')}'")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] translate_word: {type(e).__name__}: {str(e)}")
|
||
# Fallback в случае ошибки
|
||
return {
|
||
"word": word,
|
||
"translation": "Ошибка перевода",
|
||
"transcription": "",
|
||
"examples": [],
|
||
"category": "unknown",
|
||
"difficulty": "A1"
|
||
}
|
||
|
||
async def translate_word_with_contexts(
|
||
self,
|
||
word: str,
|
||
source_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
max_translations: int = 3,
|
||
user_id: Optional[int] = None
|
||
) -> Dict:
|
||
"""
|
||
Перевести слово и получить несколько переводов с контекстами
|
||
|
||
Args:
|
||
word: Слово для перевода
|
||
source_lang: Язык исходного слова (ISO2)
|
||
translation_lang: Язык перевода (ISO2)
|
||
max_translations: Максимальное количество переводов
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с переводами, каждый с примером предложения
|
||
"""
|
||
prompt = f"""Переведи слово/фразу "{word}" с языка {source_lang} на {translation_lang}.
|
||
|
||
Если у слова есть несколько значений в разных контекстах, дай до {max_translations} разных переводов.
|
||
Для каждого перевода дай пример предложения, показывающий это значение.
|
||
|
||
Верни ответ строго в формате JSON:
|
||
{{
|
||
"word": "исходное слово на {source_lang}",
|
||
"transcription": "транскрипция в IPA (если применимо)",
|
||
"category": "основная категория слова",
|
||
"difficulty": "уровень сложности (A1/A2/B1/B2/C1/C2)",
|
||
"translations": [
|
||
{{
|
||
"translation": "перевод 1 на {translation_lang}",
|
||
"context": "пример предложения на {source_lang}, показывающий это значение",
|
||
"context_translation": "перевод примера на {translation_lang}",
|
||
"is_primary": true
|
||
}},
|
||
{{
|
||
"translation": "перевод 2 на {translation_lang} (если есть другое значение)",
|
||
"context": "пример предложения на {source_lang}",
|
||
"context_translation": "перевод примера на {translation_lang}",
|
||
"is_primary": false
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Важно:
|
||
- Первый перевод должен быть самым распространённым (is_primary: true)
|
||
- Давай разные переводы только если слово реально имеет разные значения
|
||
- Примеры должны чётко показывать конкретное значение слова
|
||
- Верни только JSON, без дополнительного текста"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] translate_word_with_contexts: word='{word}', source='{source_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
|
||
|
||
import json
|
||
content = response_data['choices'][0]['message']['content']
|
||
# Убираем markdown обёртку если есть
|
||
if content.startswith('```'):
|
||
content = content.split('\n', 1)[1] if '\n' in content else content[3:]
|
||
if content.endswith('```'):
|
||
content = content[:-3]
|
||
content = content.strip()
|
||
|
||
result = json.loads(content)
|
||
translations_count = len(result.get('translations', []))
|
||
logger.info(f"[AI Response] translate_word_with_contexts: success, {translations_count} translations")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] translate_word_with_contexts: {type(e).__name__}: {str(e)}")
|
||
# Fallback в случае ошибки
|
||
return {
|
||
"word": word,
|
||
"transcription": "",
|
||
"category": "unknown",
|
||
"difficulty": "A1",
|
||
"translations": [{
|
||
"translation": "Ошибка перевода",
|
||
"context": "",
|
||
"context_translation": "",
|
||
"is_primary": True
|
||
}]
|
||
}
|
||
|
||
async def translate_words_batch(
|
||
self,
|
||
words: List[str],
|
||
source_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
user_id: Optional[int] = None
|
||
) -> List[Dict]:
|
||
"""
|
||
Перевести список слов пакетно
|
||
|
||
Args:
|
||
words: Список слов для перевода
|
||
source_lang: Язык исходных слов (ISO2)
|
||
translation_lang: Язык перевода (ISO2)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
List[Dict] с переводами, транскрипциями
|
||
"""
|
||
if not words:
|
||
return []
|
||
|
||
words_list = "\n".join(f"- {w}" for w in words[:50]) # Максимум 50 слов за раз
|
||
|
||
# Добавляем инструкцию для фуриганы если японский
|
||
furigana_instruction = ""
|
||
if source_lang == "ja":
|
||
furigana_instruction = '\n "reading": "чтение хираганой (только для кандзи)",'
|
||
|
||
prompt = f"""Переведи следующие слова/фразы с языка {source_lang} на {translation_lang}:
|
||
|
||
{words_list}
|
||
|
||
Верни ответ строго в формате JSON массива:
|
||
[
|
||
{{
|
||
"word": "исходное слово",
|
||
"translation": "перевод",
|
||
"transcription": "транскрипция (IPA или ромадзи для японского)",{furigana_instruction}
|
||
}},
|
||
...
|
||
]
|
||
|
||
Важно:
|
||
- Верни только JSON массив, без дополнительного текста
|
||
- Сохрани порядок слов как в исходном списке
|
||
- Для каждого слова укажи точный перевод и транскрипцию"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] translate_words_batch: {len(words)} words, {source_lang} -> {translation_lang}")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - помощник для изучения языков. Отвечай только в формате JSON."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
|
||
|
||
import json
|
||
content = response_data['choices'][0]['message']['content']
|
||
# Убираем markdown обёртку если есть
|
||
if content.startswith('```'):
|
||
content = content.split('\n', 1)[1] if '\n' in content else content[3:]
|
||
if content.endswith('```'):
|
||
content = content[:-3]
|
||
content = content.strip()
|
||
|
||
result = json.loads(content)
|
||
|
||
# Если вернулся dict с ключом типа "words" или "translations" — извлекаем список
|
||
if isinstance(result, dict):
|
||
for key in ['words', 'translations', 'result', 'data']:
|
||
if key in result and isinstance(result[key], list):
|
||
result = result[key]
|
||
break
|
||
|
||
if not isinstance(result, list):
|
||
logger.warning(f"[AI Warning] translate_words_batch: unexpected format, got {type(result)}")
|
||
return [{"word": w, "translation": "", "transcription": ""} for w in words]
|
||
|
||
logger.info(f"[AI Response] translate_words_batch: success, got {len(result)} translations")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] translate_words_batch: {type(e).__name__}: {str(e)}")
|
||
# Возвращаем слова без перевода в случае ошибки
|
||
return [{"word": w, "translation": "", "transcription": ""} for w in words]
|
||
|
||
async def check_answer(self, question: str, correct_answer: str, user_answer: str, user_id: Optional[int] = None) -> Dict:
|
||
"""
|
||
Проверить ответ пользователя с помощью ИИ
|
||
|
||
Args:
|
||
question: Вопрос задания
|
||
correct_answer: Правильный ответ
|
||
user_answer: Ответ пользователя
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с результатом проверки и обратной связью
|
||
"""
|
||
prompt = f"""Проверь ответ пользователя на задание по английскому языку.
|
||
|
||
Задание: {question}
|
||
Правильный ответ: {correct_answer}
|
||
Ответ пользователя: {user_answer}
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"is_correct": true/false,
|
||
"feedback": "краткое объяснение (если ответ неверный, объясни ошибку и дай правильный вариант)",
|
||
"score": 0-100
|
||
}}
|
||
|
||
Учитывай возможные вариации ответа. Если смысл передан правильно, даже с небольшими грамматическими неточностями, засчитывай ответ."""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] check_answer: user_answer='{user_answer[:30]}...'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель английского языка. Проверяй ответы справедливо, учитывая контекст."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.3, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
logger.info(f"[AI Response] check_answer: is_correct={result.get('is_correct', False)}, score={result.get('score', 0)}")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] check_answer: {type(e).__name__}: {str(e)}")
|
||
return {
|
||
"is_correct": False,
|
||
"feedback": "Ошибка проверки ответа",
|
||
"score": 0
|
||
}
|
||
|
||
async def generate_fill_in_sentence(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
|
||
"""
|
||
Сгенерировать предложение с пропуском для заданного слова
|
||
|
||
Args:
|
||
word: Слово (на языке обучения), для которого нужно создать предложение
|
||
learning_lang: Язык обучения (ISO2)
|
||
translation_lang: Язык перевода предложения (ISO2)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с предложением и правильным ответом
|
||
"""
|
||
prompt = f"""Создай предложение на языке {learning_lang}, используя слово "{word}".
|
||
Замени это слово на пропуск "___".
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"sentence": "предложение с пропуском ___",
|
||
"answer": "{word}",
|
||
"translation": "перевод предложения на {translation_lang}"
|
||
}}
|
||
|
||
Предложение должно быть простым и естественным. Контекст должен четко подсказывать правильное слово."""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_fill_in_sentence: word='{word}', lang='{learning_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
logger.info(f"[AI Response] generate_fill_in_sentence: success")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_fill_in_sentence: {type(e).__name__}: {str(e)}")
|
||
return {
|
||
"sentence": f"I like to ___ every day.",
|
||
"answer": word,
|
||
"translation": f"Мне нравится {word} каждый день."
|
||
}
|
||
|
||
async def generate_sentence_for_translation(self, word: str, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
|
||
"""
|
||
Сгенерировать предложение для перевода, содержащее заданное слово
|
||
|
||
Args:
|
||
word: Слово (на языке обучения), которое должно быть в предложении
|
||
learning_lang: Язык обучения (ISO2)
|
||
translation_lang: Язык перевода (ISO2)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с предложением и его переводом
|
||
"""
|
||
prompt = f"""Создай простое предложение на языке {learning_lang}, используя слово "{word}".
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"sentence": "предложение на {learning_lang} со словом {word}",
|
||
"translation": "перевод предложения на {translation_lang}"
|
||
}}
|
||
|
||
Предложение должно быть простым (5-10 слов), естественным и подходящим для изучения языка."""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_sentence_for_translation: word='{word}', lang='{learning_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные примеры для практики перевода."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
logger.info(f"[AI Response] generate_sentence_for_translation: success")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_sentence_for_translation: {type(e).__name__}: {str(e)}")
|
||
# Fallback - простое предложение
|
||
return {
|
||
"sentence": f"I use {word} every day.",
|
||
"translation": f"Я использую {word} каждый день."
|
||
}
|
||
|
||
async def generate_task_sentences_batch(
|
||
self,
|
||
tasks_data: List[Dict],
|
||
learning_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
user_id: Optional[int] = None
|
||
) -> List[Dict]:
|
||
"""
|
||
Батч-генерация предложений для заданий за один запрос к AI.
|
||
|
||
Args:
|
||
tasks_data: Список словарей с информацией о заданиях:
|
||
[{"word": "run", "task_type": "fill_blank"}, {"word": "eat", "task_type": "sentence_translate"}]
|
||
learning_lang: Язык обучения
|
||
translation_lang: Язык перевода
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Список результатов в том же порядке
|
||
"""
|
||
if not tasks_data:
|
||
return []
|
||
|
||
# Формируем описание заданий для промпта
|
||
tasks_description = []
|
||
for i, task in enumerate(tasks_data):
|
||
word = task.get('word', '')
|
||
task_type = task.get('task_type', '')
|
||
|
||
if task_type == 'fill_blank':
|
||
tasks_description.append(
|
||
f'{i + 1}. Слово "{word}" - создай предложение с пропуском (замени слово на ___)'
|
||
)
|
||
elif task_type == 'sentence_translate':
|
||
tasks_description.append(
|
||
f'{i + 1}. Слово "{word}" - создай простое предложение для перевода'
|
||
)
|
||
|
||
if not tasks_description:
|
||
return []
|
||
|
||
prompt = f"""Создай предложения на языке {learning_lang} для следующих заданий:
|
||
|
||
{chr(10).join(tasks_description)}
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"results": [
|
||
{{
|
||
"sentence": "предложение (с ___ для fill_blank)",
|
||
"answer": "слово для пропуска (только для fill_blank)",
|
||
"translation": "перевод на {translation_lang}"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Важно:
|
||
- Для fill_blank: замени целевое слово на ___, укажи answer
|
||
- Для sentence_translate: просто предложение со словом, answer не нужен
|
||
- Предложения должны быть простыми (5-10 слов)
|
||
- Контекст должен подсказывать правильное слово
|
||
- Верни результаты В ТОМ ЖЕ ПОРЯДКЕ что и задания"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_task_sentences_batch: {len(tasks_data)} tasks, lang='{learning_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель иностранных языков. Создавай простые и понятные упражнения."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
results = result.get('results', [])
|
||
|
||
logger.info(f"[AI Response] generate_task_sentences_batch: got {len(results)} results")
|
||
|
||
# Дополняем результаты до нужного количества если AI вернул меньше
|
||
while len(results) < len(tasks_data):
|
||
task = tasks_data[len(results)]
|
||
word = task.get('word', 'word')
|
||
results.append({
|
||
"sentence": f"I use {word} every day." if task.get('task_type') != 'fill_blank' else f"I like to ___ every day.",
|
||
"answer": word,
|
||
"translation": f"Fallback предложение"
|
||
})
|
||
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_task_sentences_batch: {type(e).__name__}: {str(e)}")
|
||
# Fallback - простые предложения для всех заданий
|
||
results = []
|
||
for task in tasks_data:
|
||
word = task.get('word', 'word')
|
||
if task.get('task_type') == 'fill_blank':
|
||
results.append({
|
||
"sentence": f"I like to ___ every day.",
|
||
"answer": word,
|
||
"translation": f"Мне нравится {word} каждый день."
|
||
})
|
||
else:
|
||
results.append({
|
||
"sentence": f"I use {word} every day.",
|
||
"translation": f"Я использую {word} каждый день."
|
||
})
|
||
return results
|
||
|
||
async def generate_thematic_words(
|
||
self,
|
||
theme: str,
|
||
level: str = "B1",
|
||
count: int = 10,
|
||
learning_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
exclude_words: List[str] = None,
|
||
user_id: Optional[int] = None
|
||
) -> List[Dict]:
|
||
"""
|
||
Сгенерировать подборку слов по теме
|
||
|
||
Args:
|
||
theme: Тема для подборки слов
|
||
level: Уровень сложности (A1-C2)
|
||
count: Количество слов
|
||
learning_lang: Язык изучения
|
||
translation_lang: Язык перевода
|
||
exclude_words: Список слов для исключения (уже известные)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Список словарей с информацией о словах
|
||
"""
|
||
exclude_instruction = ""
|
||
exclude_words_set = set()
|
||
if exclude_words:
|
||
# Ограничиваем список до 100 слов чтобы не раздувать промпт
|
||
words_sample = exclude_words[:100]
|
||
exclude_words_set = set(w.lower() for w in exclude_words)
|
||
exclude_instruction = f"""
|
||
|
||
⚠️ ЗАПРЕЩЁННЫЕ СЛОВА (НЕ ИСПОЛЬЗОВАТЬ!):
|
||
{', '.join(words_sample)}
|
||
|
||
Эти слова пользователь уже знает. ОБЯЗАТЕЛЬНО выбери ДРУГИЕ слова!"""
|
||
|
||
prompt = f"""Создай подборку из {count} слов на языке {learning_lang} по теме "{theme}" для уровня {level}. Переводы дай на {translation_lang}.
|
||
{exclude_instruction}
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"theme": "{theme}",
|
||
"words": [
|
||
{{
|
||
"word": "слово на {learning_lang}",
|
||
"translation": "перевод на {translation_lang}",
|
||
"transcription": "транскрипция в IPA (если применимо)",
|
||
"example": "пример использования на {learning_lang}",
|
||
"example_translation": "перевод примера на {translation_lang}"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Слова должны быть:
|
||
- Полезными и часто используемыми
|
||
- Соответствовать уровню {level}
|
||
- Связаны с темой "{theme}"
|
||
- Разнообразными (существительные, глаголы, прилагательные)"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_thematic_words: theme='{theme}', level='{level}', count={count}, learn='{learning_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель иностранных языков. Подбирай полезные и актуальные слова."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
words = result.get('words', [])
|
||
|
||
# Фильтруем слова которые AI мог вернуть несмотря на инструкцию
|
||
if exclude_words_set:
|
||
filtered_words = [
|
||
w for w in words
|
||
if w.get('word', '').lower() not in exclude_words_set
|
||
]
|
||
filtered_count = len(words) - len(filtered_words)
|
||
if filtered_count > 0:
|
||
logger.info(f"[AI Response] generate_thematic_words: filtered out {filtered_count} excluded words")
|
||
words = filtered_words
|
||
|
||
logger.info(f"[AI Response] generate_thematic_words: success, generated {len(words)} words")
|
||
return words
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_thematic_words: {type(e).__name__}: {str(e)}")
|
||
return []
|
||
|
||
async def extract_words_from_text(self, text: str, level: str = "B1", max_words: int = 15, learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> List[Dict]:
|
||
"""
|
||
Извлечь ключевые слова из текста для изучения
|
||
|
||
Args:
|
||
text: Текст на языке изучения
|
||
level: Уровень пользователя (A1-C2)
|
||
max_words: Максимальное количество слов для извлечения
|
||
learning_lang: Язык изучения
|
||
translation_lang: Язык перевода
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Список словарей с информацией о словах
|
||
"""
|
||
prompt = f"""Проанализируй следующий текст на языке {learning_lang} и извлеки из него до {max_words} самых полезных слов для изучения на уровне {level}. Переводы дай на {translation_lang}.
|
||
|
||
Текст:
|
||
{text}
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"words": [
|
||
{{
|
||
"word": "слово на {learning_lang} (в базовой форме)",
|
||
"translation": "перевод на {translation_lang}",
|
||
"transcription": "транскрипция в IPA (если применимо)",
|
||
"context": "предложение из текста на {learning_lang}, где используется это слово"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Критерии отбора слов:
|
||
- Выбирай самые важные и полезные слова из текста
|
||
- Слова должны быть интересны для уровня {level}
|
||
- Не включай простейшие слова (a, the, is, и т.д.)
|
||
- Слова должны быть в базовой форме (инфинитив для глаголов, ед.число для существительных)
|
||
- Разнообразие: существительные, глаголы, прилагательные, устойчивые выражения"""
|
||
|
||
try:
|
||
text_preview = text[:100] + "..." if len(text) > 100 else text
|
||
logger.info(f"[AI Request] extract_words_from_text: text_length={len(text)}, level='{level}', max_words={max_words}, learn='{learning_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - преподаватель иностранных языков. Помогаешь извлекать полезные слова для изучения из текстов."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.5, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
words_count = len(result.get('words', []))
|
||
logger.info(f"[AI Response] extract_words_from_text: success, extracted {words_count} words")
|
||
return result.get('words', [])
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] extract_words_from_text: {type(e).__name__}: {str(e)}")
|
||
return []
|
||
|
||
async def start_conversation(self, scenario: str, level: str = "B1", learning_lang: str = "en", translation_lang: str = "ru", user_id: Optional[int] = None) -> Dict:
|
||
"""
|
||
Начать диалоговую практику с AI
|
||
|
||
Args:
|
||
scenario: Сценарий диалога (restaurant, shopping, travel, etc.)
|
||
level: Уровень пользователя (A1-C2)
|
||
learning_lang: Язык изучения
|
||
translation_lang: Язык перевода
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с начальной репликой и контекстом
|
||
"""
|
||
scenarios = {
|
||
"restaurant": "ресторан - заказ еды",
|
||
"shopping": "магазин - покупка одежды",
|
||
"travel": "аэропорт/отель - путешествие",
|
||
"work": "офис - рабочая встреча",
|
||
"doctor": "клиника - визит к врачу",
|
||
"casual": "повседневный разговор"
|
||
}
|
||
|
||
scenario_desc = scenarios.get(scenario, "повседневный разговор")
|
||
|
||
extra_fields = ''
|
||
if learning_lang.lower() == 'ja':
|
||
# Для японского просим версию с фуриганой в скобках ТОЛЬКО для кандзи
|
||
# Не добавляй фуригану к кана или латинским буквам
|
||
extra_fields = ",\n \"message_annotated\": \"фраза на {learning_lang} с фуриганой в скобках ТОЛЬКО к кандзи (Так правильно: いらっしゃいませ!今日は何を注文(ちゅうもん)しますか?, Так неправильно: こんにちは(こんにちは)!今日ははどうですか?); к こんにちは не добовляй фурагану; не добавляй фуригану к катакане, фуригане, хирагане, частице и латинице\""
|
||
|
||
prompt = f"""Ты - собеседник для практики языка {learning_lang} уровня {level}.
|
||
Начни диалог в сценарии: {scenario_desc} на {learning_lang}.
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"message": "твоя первая реплика на {learning_lang}",
|
||
"translation": "перевод на {translation_lang}",
|
||
"context": "краткое описание ситуации на {translation_lang}",
|
||
"suggestions": [
|
||
{{"learn": "подсказка на {learning_lang}", "learn_annotated": "подсказка с фуриганой в скобках ТОЛЬКО к кандзи (Так правильно: いらっしゃいませ!今日は何を注文(ちゅうもん)しますか?, Так неправильно: こんにちは(こんにちは)!今日ははどうですか?); к こんにちは не добовляй фурагану; не добавляй фуригану к катакане, фуригане, хирагане, частице и латинице; {learning_lang})", "trans": "перевод подсказки на {translation_lang}"}},
|
||
{{"learn": "...", "learn_annotated": "...", "trans": "..."}},
|
||
{{"learn": "...", "learn_annotated": "...", "trans": "..."}}
|
||
]{extra_fields}
|
||
}}
|
||
|
||
Требования:
|
||
- Говори естественно, используй уровень {level}
|
||
- Создай интересную ситуацию
|
||
- Задай вопрос или начни разговор
|
||
- Подсказки должны помочь пользователю ответить"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] start_conversation: scenario='{scenario}', level='{level}', learn='{learning_lang}', to='{translation_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - дружелюбный собеседник для практики иностранных языков. Веди естественный диалог."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.8, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
logger.info(f"[AI Response] start_conversation: success, scenario='{scenario}'")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] start_conversation: {type(e).__name__}: {str(e)}")
|
||
return {
|
||
"message": "Hello! How are you today?",
|
||
"translation": "Привет! Как дела сегодня?",
|
||
"context": "Повседневный разговор",
|
||
"suggestions": ["I'm fine, thank you!", "Good, and you?", "Not bad!"]
|
||
}
|
||
|
||
async def continue_conversation(
|
||
self,
|
||
conversation_history: List[Dict],
|
||
user_message: str,
|
||
scenario: str,
|
||
level: str = "B1",
|
||
learning_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
user_id: Optional[int] = None
|
||
) -> Dict:
|
||
"""
|
||
Продолжить диалог и проверить ответ пользователя
|
||
|
||
Args:
|
||
conversation_history: История диалога
|
||
user_message: Сообщение пользователя
|
||
scenario: Сценарий диалога
|
||
level: Уровень пользователя
|
||
learning_lang: Язык изучения
|
||
translation_lang: Язык перевода
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Dict с ответом AI, проверкой и подсказками
|
||
"""
|
||
# Формируем историю для контекста
|
||
history_text = "\n".join([
|
||
f"{'AI' if msg['role'] == 'assistant' else 'User'}: {msg['content']}"
|
||
for msg in conversation_history[-6:] # Последние 6 сообщений
|
||
])
|
||
|
||
extra_fields_resp = ''
|
||
if learning_lang.lower() == 'ja':
|
||
# Для японского просим версию ответа с фуриганой ТОЛЬКО для кандзи
|
||
# Не добавляй фуригану к кана или латинским буквам
|
||
extra_fields_resp = ",\n \"response_annotated\": \"ответ на {learning_lang} с фуриганой ТОЛЬКО для кандзи (напр.: 今日(きょう)); не добавляй фуригану к кана или латинице\""
|
||
|
||
prompt = f"""Ты ведешь диалог на языке {learning_lang} уровня {level} в сценарии "{scenario}".
|
||
|
||
История диалога:
|
||
{history_text}
|
||
User: {user_message}
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"response": "твой ответ на {learning_lang}",
|
||
"translation": "перевод твоего ответа на {translation_lang}",
|
||
"feedback": {{
|
||
"has_errors": true/false,
|
||
"corrections": "исправления ошибок пользователя (если есть)",
|
||
"comment": "краткий комментарий об ответе пользователя"
|
||
}},
|
||
"suggestions": [
|
||
{{"learn": "подсказка на {learning_lang}", "learn_annotated": "подсказка с фуриганой (ТОЛЬКО для кандзи; {learning_lang})", "trans": "перевод подсказки на {translation_lang}"}},
|
||
{{"learn": "...", "learn_annotated": "...", "trans": "..."}}
|
||
]{extra_fields_resp}
|
||
}}
|
||
|
||
Требования:
|
||
- Продолжай естественный диалог
|
||
- Если у пользователя есть грамматические или лексические ошибки, укажи их в corrections
|
||
- Будь дружелюбным и поддерживающим
|
||
- Используй лексику уровня {level}"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] continue_conversation: scenario='{scenario}', level='{level}', history_length={len(conversation_history)}, learn='{learning_lang}', to='{translation_lang}'")
|
||
|
||
# Формируем сообщения для API
|
||
messages = [
|
||
{"role": "system", "content": f"Ты - дружелюбный собеседник для практики языка {learning_lang} уровня {level}. Веди естественный диалог и помогай исправлять ошибки."}
|
||
]
|
||
|
||
# Добавляем историю
|
||
for msg in conversation_history[-6:]:
|
||
messages.append(msg)
|
||
|
||
# Добавляем текущее сообщение пользователя
|
||
messages.append({"role": "user", "content": user_message})
|
||
|
||
# Добавляем инструкцию для форматирования ответа
|
||
messages.append({"role": "user", "content": prompt})
|
||
|
||
response_data = await self._make_request(messages, temperature=0.8, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
has_errors = result.get('feedback', {}).get('has_errors', False)
|
||
logger.info(f"[AI Response] continue_conversation: success, has_errors={has_errors}")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] continue_conversation: {type(e).__name__}: {str(e)}")
|
||
return {
|
||
"response": "I see. Tell me more about that.",
|
||
"translation": "Понятно. Расскажи мне больше об этом.",
|
||
"feedback": {
|
||
"has_errors": False,
|
||
"corrections": "",
|
||
"comment": "Good!"
|
||
},
|
||
"suggestions": ["Sure!", "Well...", "Actually..."]
|
||
}
|
||
|
||
async def generate_level_test(self, learning_language: str = "en", user_id: Optional[int] = None) -> List[Dict]:
|
||
"""
|
||
Сгенерировать тест для определения уровня языка
|
||
|
||
Args:
|
||
learning_language: Язык изучения (en, es, de, fr, ja)
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Список из 7 вопросов разной сложности
|
||
"""
|
||
# Определяем систему уровней и язык для промпта
|
||
if learning_language == "ja":
|
||
level_system = "JLPT (N5-N1)"
|
||
language_name = "японского"
|
||
levels_req = """- Вопросы 1-2: уровень N5 (базовый)
|
||
- Вопросы 3-4: уровень N4-N3 (элементарный-средний)
|
||
- Вопросы 5-6: уровень N2 (продвинутый)
|
||
- Вопрос 7: уровень N1 (профессиональный)"""
|
||
level_example = "N5"
|
||
else:
|
||
level_system = "CEFR (A1-C2)"
|
||
lang_names = {"en": "английского", "es": "испанского", "de": "немецкого", "fr": "французского"}
|
||
language_name = lang_names.get(learning_language, "английского")
|
||
levels_req = """- Вопросы 1-2: уровень A1 (базовый)
|
||
- Вопросы 3-4: уровень A2-B1 (элементарный-средний)
|
||
- Вопросы 5-6: уровень B2-C1 (продвинутый)
|
||
- Вопрос 7: уровень C2 (профессиональный)"""
|
||
level_example = "A1"
|
||
|
||
prompt = f"""Создай тест из 7 вопросов для определения уровня {language_name} языка ({level_system}).
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"questions": [
|
||
{{
|
||
"question": "текст вопроса на изучаемом языке",
|
||
"question_ru": "перевод вопроса на русский",
|
||
"options": ["вариант A", "вариант B", "вариант C", "вариант D"],
|
||
"correct": 0,
|
||
"level": "{level_example}"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Требования:
|
||
{levels_req}
|
||
- Каждый вопрос с 4 вариантами ответа
|
||
- correct - индекс правильного ответа (0-3)
|
||
- Вопросы на грамматику, лексику и понимание"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_level_test: generating 7 questions for {learning_language}")
|
||
|
||
system_msg = f"Ты - эксперт по тестированию уровня {language_name} языка. Создавай объективные тесты."
|
||
messages = [
|
||
{"role": "system", "content": system_msg},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
questions_count = len(result.get('questions', []))
|
||
logger.info(f"[AI Response] generate_level_test: success, generated {questions_count} questions")
|
||
return result.get('questions', [])
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_level_test: {type(e).__name__}: {str(e)}, using fallback questions")
|
||
# Fallback с базовыми вопросами
|
||
if learning_language == "ja":
|
||
return self._get_jlpt_fallback_questions()
|
||
return self._get_cefr_fallback_questions()
|
||
|
||
async def generate_grammar_rule(
|
||
self,
|
||
topic_name: str,
|
||
topic_description: str,
|
||
level: str,
|
||
learning_lang: str = "en",
|
||
ui_lang: str = "ru",
|
||
user_id: Optional[int] = None
|
||
) -> str:
|
||
"""
|
||
Генерация объяснения грамматического правила.
|
||
|
||
Args:
|
||
topic_name: Название темы (например, "Present Simple")
|
||
topic_description: Описание темы (например, "I work, he works")
|
||
level: Уровень пользователя (A1-C2 или N5-N1)
|
||
learning_lang: Язык изучения
|
||
ui_lang: Язык интерфейса для объяснения
|
||
user_id: ID пользователя в БД
|
||
|
||
Returns:
|
||
Текст с объяснением правила
|
||
"""
|
||
if learning_lang == "ja":
|
||
language_name = "японского"
|
||
else:
|
||
language_name = "английского"
|
||
|
||
prompt = f"""Объясни грамматическое правило "{topic_name}" ({topic_description}) для изучающих {language_name} язык.
|
||
|
||
Уровень ученика: {level}
|
||
Язык объяснения: {ui_lang}
|
||
|
||
Требования:
|
||
- Объяснение должно быть кратким и понятным (3-5 предложений)
|
||
- Приведи формулу/структуру правила
|
||
- Дай 2-3 примера с переводом
|
||
- Упомяни типичные ошибки (если есть)
|
||
- Адаптируй сложность под уровень {level}
|
||
|
||
ВАЖНО - форматирование для Telegram (используй ТОЛЬКО HTML теги, НЕ markdown):
|
||
- <b>жирный текст</b> для важного (НЕ **жирный**)
|
||
- <i>курсив</i> для примеров (НЕ *курсив*)
|
||
- НЕ используй звёздочки *, НЕ используй markdown
|
||
- Можно использовать эмодзи"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_grammar_rule: topic='{topic_name}', level='{level}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": f"Ты - опытный преподаватель {language_name} языка. Объясняй правила просто и понятно."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
# Для этого запроса не используем JSON mode
|
||
model_name, provider = await self._get_active_model(user_id)
|
||
|
||
if provider == AIProvider.google:
|
||
response_data = await self._make_google_request_text(messages, temperature=0.5, model=model_name)
|
||
else:
|
||
response_data = await self._make_openai_request_text(messages, temperature=0.5, model=model_name)
|
||
|
||
rule_text = response_data['choices'][0]['message']['content']
|
||
# Конвертируем markdown в HTML на случай если AI использовал звёздочки
|
||
rule_text = self._markdown_to_html(rule_text)
|
||
logger.info(f"[AI Response] generate_grammar_rule: success, {len(rule_text)} chars")
|
||
return rule_text
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_grammar_rule: {type(e).__name__}: {str(e)}")
|
||
return f"📖 <b>{topic_name}</b>\n\n{topic_description}\n\nИзучите это правило и приступайте к упражнениям."
|
||
|
||
async def _make_google_request_text(self, messages: list, temperature: float = 0.3, model: str = "gemini-2.0-flash-lite") -> dict:
|
||
"""Запрос к Google без JSON mode (для текстовых ответов)"""
|
||
url = f"{self.google_base_url}/models/{model}:generateContent"
|
||
|
||
contents = []
|
||
for msg in messages:
|
||
role = msg["role"]
|
||
content = msg["content"]
|
||
if role == "system":
|
||
contents.insert(0, {"role": "user", "parts": [{"text": f"[System instruction]: {content}"}]})
|
||
elif role == "user":
|
||
contents.append({"role": "user", "parts": [{"text": content}]})
|
||
elif role == "assistant":
|
||
contents.append({"role": "model", "parts": [{"text": content}]})
|
||
|
||
payload = {
|
||
"contents": contents,
|
||
"generationConfig": {"temperature": temperature}
|
||
}
|
||
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"x-goog-api-key": self.google_api_key
|
||
}
|
||
|
||
response = await self.http_client.post(url, headers=headers, json=payload)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
text = data["candidates"][0]["content"]["parts"][0]["text"]
|
||
return {"choices": [{"message": {"content": text}}]}
|
||
|
||
async def _make_openai_request_text(self, messages: list, temperature: float = 0.3, model: str = "gpt-4o-mini") -> dict:
|
||
"""Запрос к OpenAI без JSON mode (для текстовых ответов)"""
|
||
url = f"{self.openai_base_url}/chat/completions"
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {self.openai_api_key}",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"temperature": temperature
|
||
}
|
||
|
||
response = await self.http_client.post(url, headers=headers, json=payload)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
|
||
async def generate_grammar_exercise(
|
||
self,
|
||
topic_id: str,
|
||
topic_name: str,
|
||
topic_description: str,
|
||
level: str,
|
||
learning_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
count: int = 3,
|
||
user_id: Optional[int] = None
|
||
) -> List[Dict]:
|
||
"""
|
||
Генерация грамматических упражнений по теме.
|
||
|
||
Args:
|
||
topic_id: ID темы (например, "present_simple")
|
||
topic_name: Название темы (например, "Present Simple")
|
||
topic_description: Описание темы (например, "I work, he works")
|
||
level: Уровень пользователя (A1-C2 или N5-N1)
|
||
learning_lang: Язык изучения
|
||
translation_lang: Язык перевода
|
||
count: Количество упражнений
|
||
user_id: ID пользователя в БД для получения его модели
|
||
|
||
Returns:
|
||
Список упражнений
|
||
"""
|
||
if learning_lang == "ja":
|
||
language_name = "японском"
|
||
else:
|
||
language_name = "английском"
|
||
|
||
prompt = f"""Создай {count} грамматических упражнения на тему "{topic_name}" ({topic_description}).
|
||
|
||
Уровень: {level}
|
||
Язык: {language_name}
|
||
Язык перевода: {translation_lang}
|
||
|
||
Верни ответ в формате JSON:
|
||
{{
|
||
"exercises": [
|
||
{{
|
||
"sentence": "предложение с пропуском ___ на {learning_lang}",
|
||
"translation": "ПОЛНЫЙ перевод предложения на {translation_lang} (без пропусков, с правильным ответом)",
|
||
"correct_answer": "правильный ответ для пропуска",
|
||
"hint": "краткая подсказка на {translation_lang} (1-2 слова)",
|
||
"explanation": "объяснение правила на {translation_lang} (1-2 предложения)"
|
||
}}
|
||
]
|
||
}}
|
||
|
||
Требования:
|
||
- Предложения должны быть естественными и полезными
|
||
- Пропуск обозначай как ___
|
||
- ВАЖНО: translation должен быть ПОЛНЫМ переводом готового предложения (без пропусков), чтобы ученик понимал смысл
|
||
- Подсказка должна направлять к ответу, но не содержать его
|
||
- Объяснение должно быть понятным для уровня {level}
|
||
- Сложность должна соответствовать уровню {level}"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_grammar_exercise: topic='{topic_name}', level='{level}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": f"Ты - преподаватель {language_name} языка. Создавай качественные упражнения. Отвечай только JSON."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
response_data = await self._make_request(messages, temperature=0.7, user_id=user_id)
|
||
|
||
import json
|
||
result = json.loads(response_data['choices'][0]['message']['content'])
|
||
exercises = result.get('exercises', [])
|
||
logger.info(f"[AI Response] generate_grammar_exercise: success, {len(exercises)} exercises generated")
|
||
return exercises
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_grammar_exercise: {type(e).__name__}: {str(e)}")
|
||
# Fallback с простым упражнением
|
||
return [{
|
||
"sentence": f"Example sentence with ___ ({topic_name})",
|
||
"translation": "Пример предложения",
|
||
"correct_answer": "answer",
|
||
"hint": "hint",
|
||
"explanation": f"This exercise is about {topic_name}."
|
||
}]
|
||
|
||
def _get_cefr_fallback_questions(self) -> List[Dict]:
|
||
"""Fallback вопросы для CEFR (английский и европейские языки)"""
|
||
return [
|
||
{
|
||
"question": "What is your name?",
|
||
"question_ru": "Как тебя зовут?",
|
||
"options": ["My name is", "I am name", "Name my is", "Is name my"],
|
||
"correct": 0,
|
||
"level": "A1"
|
||
},
|
||
{
|
||
"question": "I ___ to school every day.",
|
||
"question_ru": "Я ___ в школу каждый день.",
|
||
"options": ["go", "goes", "going", "went"],
|
||
"correct": 0,
|
||
"level": "A1"
|
||
},
|
||
{
|
||
"question": "She ___ been to Paris twice.",
|
||
"question_ru": "Она ___ в Париже дважды.",
|
||
"options": ["have", "has", "had", "having"],
|
||
"correct": 1,
|
||
"level": "A2"
|
||
},
|
||
{
|
||
"question": "If I ___ rich, I would travel the world.",
|
||
"question_ru": "Если бы я был богат, я бы путешествовал по миру.",
|
||
"options": ["am", "was", "were", "be"],
|
||
"correct": 2,
|
||
"level": "B1"
|
||
},
|
||
{
|
||
"question": "The project ___ by next Monday.",
|
||
"question_ru": "Проект ___ к следующему понедельнику.",
|
||
"options": ["will complete", "will be completed", "completes", "is completing"],
|
||
"correct": 1,
|
||
"level": "B2"
|
||
},
|
||
{
|
||
"question": "Had I known about the meeting, I ___ attended.",
|
||
"question_ru": "Если бы я знал о встрече, я бы посетил.",
|
||
"options": ["would have", "will have", "would", "will"],
|
||
"correct": 0,
|
||
"level": "C1"
|
||
},
|
||
{
|
||
"question": "The nuances of his argument were so ___ that few could grasp them.",
|
||
"question_ru": "Нюансы его аргумента были настолько ___, что немногие могли их понять.",
|
||
"options": ["subtle", "obvious", "simple", "clear"],
|
||
"correct": 0,
|
||
"level": "C2"
|
||
}
|
||
]
|
||
|
||
async def generate_words_of_day_batch(
|
||
self,
|
||
language: str,
|
||
levels: List[str],
|
||
translation_lang: str = "ru",
|
||
excluded_words: Dict[str, List[str]] = None
|
||
) -> Optional[Dict[str, Dict]]:
|
||
"""
|
||
Генерация слов дня для всех уровней одного языка за один запрос.
|
||
|
||
Args:
|
||
language: Язык изучения (en/ja)
|
||
levels: Список уровней (A1-C2 или N5-N1)
|
||
translation_lang: Язык перевода
|
||
excluded_words: Dict {level: [excluded_words]} для исключения
|
||
|
||
Returns:
|
||
Dict {level: word_data} или None при ошибке
|
||
"""
|
||
language_names = {"en": "английский", "ja": "японский"}
|
||
language_name = language_names.get(language, "английский")
|
||
|
||
translation_names = {"ru": "русский", "en": "английский", "ja": "японский"}
|
||
translation_name = translation_names.get(translation_lang, "русский")
|
||
|
||
# Формируем список исключений по уровням
|
||
excluded_info = ""
|
||
if excluded_words:
|
||
excluded_parts = []
|
||
for level, words in excluded_words.items():
|
||
if words:
|
||
excluded_parts.append(f"- {level}: {', '.join(words[:15])}")
|
||
if excluded_parts:
|
||
excluded_info = "\n\nНЕ используй эти слова (уже были недавно):\n" + "\n".join(excluded_parts)
|
||
|
||
levels_str = ", ".join(levels)
|
||
|
||
prompt = f"""Сгенерируй "слово дня" для изучающих {language_name} язык на каждом из уровней: {levels_str}.
|
||
|
||
Требования для каждого слова:
|
||
- Слово должно быть полезным и интересным
|
||
- Строго соответствовать указанному уровню сложности
|
||
- Желательно с интересной этимологией или фактом
|
||
- Все слова должны быть РАЗНЫМИ{excluded_info}
|
||
|
||
Верни JSON объект, где ключи - уровни ({levels_str}):
|
||
{{
|
||
"{levels[0]}": {{
|
||
"word": "слово на {language_name}",
|
||
"transcription": "транскрипция (IPA для английского, хирагана для японского)",
|
||
"translation": "перевод на {translation_name}",
|
||
"examples": [
|
||
{{"sentence": "пример предложения", "translation": "перевод примера"}},
|
||
{{"sentence": "второй пример", "translation": "перевод"}}
|
||
],
|
||
"synonyms": "синоним1, синоним2",
|
||
"etymology": "краткий интересный факт (1-2 предложения)"
|
||
}},
|
||
... (для каждого уровня)
|
||
}}"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_words_of_day_batch: lang='{language}', levels={levels}")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - опытный лингвист. Отвечай только валидным JSON без markdown."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
model_name, provider = await self._get_active_model(None)
|
||
|
||
if provider == AIProvider.google:
|
||
response_data = await self._make_google_request(messages, temperature=0.8, model=model_name)
|
||
else:
|
||
response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name)
|
||
|
||
content = response_data['choices'][0]['message']['content']
|
||
content = self._strip_markdown_code_block(content)
|
||
result = json.loads(content)
|
||
|
||
logger.info(f"[AI Response] generate_words_of_day_batch: generated {len(result)} words")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_words_of_day_batch: {type(e).__name__}: {str(e)}")
|
||
return None
|
||
|
||
async def generate_mini_story(
|
||
self,
|
||
genre: str,
|
||
level: str,
|
||
learning_lang: str = "en",
|
||
translation_lang: str = "ru",
|
||
user_id: Optional[int] = None,
|
||
num_questions: int = 5
|
||
) -> Optional[Dict]:
|
||
"""
|
||
Генерация мини-истории для чтения.
|
||
|
||
Args:
|
||
genre: Жанр (dialogue, news, story, letter, recipe)
|
||
level: Уровень (A1-C2 или N5-N1)
|
||
learning_lang: Язык истории
|
||
translation_lang: Язык переводов
|
||
user_id: ID пользователя для выбора модели
|
||
num_questions: Количество вопросов (из настроек пользователя)
|
||
|
||
Returns:
|
||
Dict с полями: title, content, vocabulary, questions, word_count
|
||
"""
|
||
import json
|
||
|
||
language_names = {
|
||
"en": "английский",
|
||
"ja": "японский"
|
||
}
|
||
language_name = language_names.get(learning_lang, "английский")
|
||
|
||
translation_names = {
|
||
"ru": "русский",
|
||
"en": "английский",
|
||
"ja": "японский"
|
||
}
|
||
translation_name = translation_names.get(translation_lang, "русский")
|
||
|
||
genre_descriptions = {
|
||
"dialogue": "разговорный диалог между людьми",
|
||
"news": "короткая новостная статья",
|
||
"story": "художественный рассказ с сюжетом",
|
||
"letter": "email или письмо",
|
||
"recipe": "рецепт блюда с инструкциями"
|
||
}
|
||
genre_desc = genre_descriptions.get(genre, "короткий рассказ")
|
||
|
||
# Определяем длину текста по уровню
|
||
word_counts = {
|
||
"A1": "50-80", "N5": "30-50",
|
||
"A2": "80-120", "N4": "50-80",
|
||
"B1": "120-180", "N3": "80-120",
|
||
"B2": "180-250", "N2": "120-180",
|
||
"C1": "250-350", "N1": "180-250",
|
||
"C2": "300-400"
|
||
}
|
||
word_range = word_counts.get(level, "100-150")
|
||
|
||
# Генерируем примеры вопросов для промпта
|
||
questions_examples = []
|
||
for i in range(num_questions):
|
||
questions_examples.append(f''' {{
|
||
"question": "Вопрос {i + 1} на понимание на {translation_name}",
|
||
"options": ["вариант 1", "вариант 2", "вариант 3"],
|
||
"correct": {i % 3}
|
||
}}''')
|
||
questions_json = ",\n".join(questions_examples)
|
||
|
||
prompt = f"""Создай {genre_desc} на {language_name} языке для уровня {level}.
|
||
|
||
Требования:
|
||
- Длина: {word_range} слов
|
||
- Используй лексику и грамматику подходящую для уровня {level}
|
||
- История должна быть интересной и законченной
|
||
- Выдели 5-8 ключевых слов которые могут быть новыми для изучающего
|
||
- Добавь полный перевод текста на {translation_name} язык
|
||
|
||
Верни JSON:
|
||
{{
|
||
"title": "Название истории на {language_name}",
|
||
"content": "Полный текст истории",
|
||
"translation": "Полный перевод истории на {translation_name}",
|
||
"vocabulary": [
|
||
{{"word": "слово", "translation": "перевод на {translation_name}", "transcription": "транскрипция"}},
|
||
...
|
||
],
|
||
"questions": [
|
||
{questions_json}
|
||
],
|
||
"word_count": число_слов_в_тексте
|
||
}}
|
||
|
||
Важно:
|
||
- Создай ровно {num_questions} вопросов на понимание текста
|
||
- У каждого вопроса ровно 3 варианта ответа
|
||
- correct — индекс правильного ответа (0, 1 или 2)"""
|
||
|
||
try:
|
||
logger.info(f"[AI Request] generate_mini_story: genre='{genre}', level='{level}', lang='{learning_lang}'")
|
||
|
||
messages = [
|
||
{"role": "system", "content": "Ты - автор адаптированных текстов для изучающих иностранные языки."},
|
||
{"role": "user", "content": prompt}
|
||
]
|
||
|
||
model_name, provider = await self._get_active_model(user_id)
|
||
|
||
if provider == AIProvider.google:
|
||
response_data = await self._make_google_request(messages, temperature=0.8, model=model_name)
|
||
else:
|
||
response_data = await self._make_openai_request(messages, temperature=0.8, model=model_name)
|
||
|
||
content = response_data['choices'][0]['message']['content']
|
||
content = self._strip_markdown_code_block(content)
|
||
result = json.loads(content)
|
||
|
||
logger.info(f"[AI Response] generate_mini_story: title='{result.get('title', 'N/A')}', words={result.get('word_count', 0)}")
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"[AI Error] generate_mini_story: {type(e).__name__}: {str(e)}")
|
||
return None
|
||
|
||
def _get_jlpt_fallback_questions(self) -> List[Dict]:
|
||
"""Fallback вопросы для JLPT (японский)"""
|
||
return [
|
||
{
|
||
"question": "これは ___です。",
|
||
"question_ru": "Это ___.",
|
||
"options": ["ほん", "本ん", "ぼん", "もと"],
|
||
"correct": 0,
|
||
"level": "N5"
|
||
},
|
||
{
|
||
"question": "私は毎日学校に___。",
|
||
"question_ru": "Я каждый день хожу в школу.",
|
||
"options": ["いきます", "いくます", "いきす", "いきました"],
|
||
"correct": 0,
|
||
"level": "N5"
|
||
},
|
||
{
|
||
"question": "昨日、映画を___から、今日は勉強します。",
|
||
"question_ru": "Вчера я посмотрел фильм, поэтому сегодня буду учиться.",
|
||
"options": ["見た", "見て", "見る", "見ない"],
|
||
"correct": 0,
|
||
"level": "N4"
|
||
},
|
||
{
|
||
"question": "この本は読み___です。",
|
||
"question_ru": "Эту книгу легко/трудно читать.",
|
||
"options": ["やすい", "にくい", "たい", "そう"],
|
||
"correct": 0,
|
||
"level": "N3"
|
||
},
|
||
{
|
||
"question": "彼の話を聞く___、涙が出てきた。",
|
||
"question_ru": "Слушая его рассказ, у меня потекли слёзы.",
|
||
"options": ["につれて", "にしたがって", "とともに", "うちに"],
|
||
"correct": 0,
|
||
"level": "N2"
|
||
},
|
||
{
|
||
"question": "その計画は実現不可能と___。",
|
||
"question_ru": "Этот план считается невыполнимым.",
|
||
"options": ["言わざるを得ない", "言うまでもない", "言いかねない", "言うに及ばない"],
|
||
"correct": 0,
|
||
"level": "N2"
|
||
},
|
||
{
|
||
"question": "彼の行動は___に堪えない。",
|
||
"question_ru": "Его поведение невозможно понять/вынести.",
|
||
"options": ["理解", "批判", "説明", "弁解"],
|
||
"correct": 0,
|
||
"level": "N1"
|
||
}
|
||
]
|
||
|
||
|
||
# Глобальный экземпляр сервиса
|
||
ai_service = AIService()
|