607 lines
28 KiB
Python
607 lines
28 KiB
Python
import json
|
||
import logging
|
||
import re
|
||
from typing import Optional, Dict, Any
|
||
import httpx
|
||
from app import config
|
||
from app.database import get_db, get_cached_sentence_translation, save_sentence_translation
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_ollama_client: Optional[httpx.AsyncClient] = None
|
||
|
||
|
||
def get_ollama_client() -> httpx.AsyncClient:
|
||
"""
|
||
Returns a shared singleton httpx.AsyncClient instance for Ollama
|
||
with connection pooling and Keep-Alive.
|
||
"""
|
||
global _ollama_client
|
||
if _ollama_client is None or _ollama_client.is_closed:
|
||
limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=120.0)
|
||
timeout = httpx.Timeout(connect=3.0, read=60.0, write=5.0, pool=5.0)
|
||
_ollama_client = httpx.AsyncClient(limits=limits, timeout=timeout)
|
||
return _ollama_client
|
||
|
||
|
||
async def close_ollama_client():
|
||
"""Closes the shared singleton Ollama HTTP client on application shutdown."""
|
||
global _ollama_client
|
||
if _ollama_client is not None:
|
||
client = _ollama_client
|
||
_ollama_client = None
|
||
if not client.is_closed:
|
||
try:
|
||
await client.aclose()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
COMMON_STOPWORDS = {
|
||
"a", "an", "the", "in", "on", "at", "to", "for", "of", "with", "by", "from",
|
||
"up", "about", "into", "over", "after", "beneath", "under", "above", "is", "am",
|
||
"are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
|
||
"did", "and", "but", "if", "or", "because", "as", "until", "while", "this", "that",
|
||
"these", "those", "i", "you", "he", "she", "it", "we", "they", "them", "him", "her",
|
||
"us", "me", "my", "your", "his", "its", "our", "their", "not", "no", "nor", "can",
|
||
"could", "will", "would", "shall", "should", "may", "might", "must", "there", "here",
|
||
"when", "where", "why", "how", "all", "any", "both", "each", "few", "more", "most",
|
||
"other", "some", "such", "than", "too", "very", "so", "s", "t", "d", "ll", "ve", "m"
|
||
}
|
||
|
||
|
||
def clean_dict_translation(raw: str) -> str:
|
||
"""Extracts first 1-2 clean Cyrillic meanings, stripping phonetic / Latin noise."""
|
||
if not raw:
|
||
return ""
|
||
from app.dictionary import clean_russian_translation
|
||
cleaned = clean_russian_translation(raw)
|
||
parts = [p.strip() for p in cleaned.split(",") if p.strip()]
|
||
return ", ".join(parts[:2]) if parts else raw[:50].strip()
|
||
|
||
|
||
def get_dictionary_hint(term: str) -> str:
|
||
"""Retrieves verified offline dictionary translation hints for term."""
|
||
clean = term.strip()
|
||
if not clean:
|
||
return ""
|
||
try:
|
||
with get_db() as conn:
|
||
row = conn.execute(
|
||
"SELECT translation FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||
(clean,),
|
||
).fetchone()
|
||
if row and row["translation"]:
|
||
return clean_dict_translation(row["translation"])
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def get_sentence_dictionary_hints(sentence: str, target_word: str = "") -> str:
|
||
"""Extracts verified offline dictionary translations for key words and phrases in sentence."""
|
||
if not sentence:
|
||
return ""
|
||
words = re.findall(r"\b[a-zA-Z\'-]+\b", sentence)
|
||
if not words:
|
||
return ""
|
||
|
||
candidates = set()
|
||
# Check 2-word contiguous phrases (collocations/idioms)
|
||
for i in range(len(words) - 1):
|
||
phrase = f"{words[i]} {words[i+1]}".lower()
|
||
candidates.add(phrase)
|
||
|
||
# Check content words
|
||
for w in words:
|
||
wl = w.lower()
|
||
if len(wl) > 2 and wl not in COMMON_STOPWORDS:
|
||
candidates.add(wl)
|
||
|
||
if not candidates:
|
||
return ""
|
||
|
||
hints = []
|
||
special_guidance = {
|
||
"owl": "owl — это сова/неясыть (ни в коем случае НЕ орёл!)",
|
||
"tawny owl": "tawny owl — это сова/неясыть (птица семейства совиных, ни в коем случае НЕ орёл!)",
|
||
}
|
||
|
||
try:
|
||
with get_db() as conn:
|
||
placeholders = ",".join(["?"] * len(candidates))
|
||
query = f"SELECT term, translation FROM offline_dictionary WHERE term IN ({placeholders}) COLLATE NOCASE"
|
||
rows = conn.execute(query, list(candidates)).fetchall()
|
||
|
||
matched_dict = {}
|
||
for r in rows:
|
||
term_lower = r["term"].lower()
|
||
clean_trans = clean_dict_translation(r["translation"])
|
||
if clean_trans:
|
||
matched_dict[term_lower] = clean_trans
|
||
|
||
seen_terms = set()
|
||
for cand in sorted(matched_dict.keys(), key=lambda x: (0 if " " in x else 1, 0 if x == target_word.lower() else 1)):
|
||
if cand in seen_terms:
|
||
continue
|
||
seen_terms.add(cand)
|
||
trans = matched_dict[cand]
|
||
guidance = special_guidance.get(cand, "")
|
||
if guidance:
|
||
hints.append(f"- {cand}: {trans} (ВНИМАНИЕ: {guidance})")
|
||
else:
|
||
hints.append(f"- {cand}: {trans}")
|
||
|
||
if len(hints) >= 8:
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"Error extracting sentence dictionary hints: {e}")
|
||
|
||
return "\n".join(hints)
|
||
|
||
|
||
def build_translation_prompt(
|
||
sentence: str,
|
||
target_word: str = "",
|
||
context: str = "",
|
||
dict_hint: str = "",
|
||
sentence_hints: str = "",
|
||
) -> str:
|
||
clean_sentence = sentence.strip()
|
||
clean_word = target_word.strip()
|
||
clean_context = context.strip()
|
||
|
||
context_section = ""
|
||
if clean_context and clean_context != clean_sentence:
|
||
short_ctx = clean_context[:500].strip()
|
||
context_section = f'Контекст абзаца:\n"{short_ctx}"\n\n'
|
||
|
||
target_section = ""
|
||
if clean_word:
|
||
target_section = f'Целевое слово/выражение: "{clean_word}"\n'
|
||
if dict_hint:
|
||
target_section += f'Возможные словарные значения: {dict_hint}\n'
|
||
target_section += "\n"
|
||
|
||
hints_section = ""
|
||
if sentence_hints:
|
||
hints_section = f"Словарные подсказки по ключевым словам предложения:\n{sentence_hints}\n\n"
|
||
|
||
return (
|
||
"Ты — профессиональный литературный переводчик художественной и научно-популярной литературы на русский язык.\n"
|
||
"Твоя задача — перевести указанное английское предложение на красивый, естественный, богатый и грамматически безупречный русский язык, идеально вписывающийся в повествование книги.\n\n"
|
||
f"{context_section}"
|
||
f'Предложение для перевода:\n"{clean_sentence}"\n\n'
|
||
f"{target_section}"
|
||
f"{hints_section}"
|
||
"Обязательные правила перевода:\n"
|
||
"1. ПОЛНОТА И СВЯЗНОСТЬ: Переводи абсолютно ВСЕ смысловые части, обороты и определения без пропусков! Обращай особое внимание на цепочки прилагательных и определений (например: 'a large, tawny owl' -> 'крупная неясыть' или 'большая рыжевато-бурая сова' — ни в коем случае не пропускай 'large'!). Также переводи бессоюзные определительные обороты (zero relative clauses, например: 'Tuesday our story starts' -> 'вторник, с которого начинается наша история'). Ничего не выбрасывай!\n"
|
||
"2. БЕЗ ГАЛЛЮЦИНАЦИЙ И ТОЧНОСТЬ ЗНАЧЕНИЙ: Фауна, флора и объекты должны переводиться строго точно по смыслу и словарю (например: 'owl' — это сова/неясыть, ни в коем случае НЕ орёл!). Никаких смысловых подмен.\n"
|
||
"3. БЕЗ ДОСЛОВНОГО КАЛЬКИРОВАНИЯ: Никаких топорных калек предлогов ('on Tuesday' -> 'во вторник', а не 'на вторнике'). Парные эпитеты ('dull, gray') переводи богатыми русскими синонимами ('пасмурный, серый'), не повторяя одно и то же слово.\n"
|
||
"4. ИМЕНА И ТИТУЛЫ: Имена персонажей и фамилии передавай полностью на русском языке (кириллицей), без латинских букв (Mr. and Mrs. Dursley -> мистер и миссис Дурсли). Сохраняй титулы: Mr. -> мистер, Mrs. -> миссис, Dr. -> доктор, Prof. -> профессор.\n"
|
||
"5. РУССКИЙ СИНТАКСИС: Сохраняй естественный русский порядок слов (обстоятельства времени и места ставь перед сказуемым: 'вскоре по всей стране начнут происходить...').\n"
|
||
f'6. В поле "word_meaning" укажи только точный перевод слова "{clean_word}" на русский язык в контексте этого предложения (выбрав лучший вариант из подсказок).\n\n'
|
||
"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON:\n"
|
||
"{\n"
|
||
' "sentence_ru": "полный качественный литературный перевод всего предложения без пропусков",\n'
|
||
f' "word_meaning": "{"русский перевод слова в данном контексте" if clean_word else ""}"\n'
|
||
"}"
|
||
)
|
||
|
||
|
||
async def translate_sentence_ai(
|
||
sentence: str,
|
||
target_word: str = "",
|
||
context: str = ""
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Translates a sentence using local AI (Ollama), with SQLite instant-cache lookup.
|
||
Incorporates paragraph context and offline dictionary hints for maximum naturalness.
|
||
Returns:
|
||
{
|
||
"original_sentence": "...",
|
||
"sentence_ru": "...",
|
||
"target_word": "...",
|
||
"word_meaning": "...",
|
||
"source": "ollama:model" | "cache" | "fallback",
|
||
"cached": bool
|
||
}
|
||
"""
|
||
clean_sentence = sentence.strip()
|
||
clean_word = target_word.strip()
|
||
clean_context = context.strip()
|
||
|
||
if not clean_sentence:
|
||
return {
|
||
"original_sentence": "",
|
||
"sentence_ru": "",
|
||
"target_word": clean_word,
|
||
"word_meaning": "",
|
||
"source": "none",
|
||
"cached": False,
|
||
}
|
||
|
||
# 1. Fast SQLite Cache Lookup (0.01 ms)
|
||
cached = get_cached_sentence_translation(clean_sentence, clean_word)
|
||
if cached:
|
||
return {
|
||
"original_sentence": clean_sentence,
|
||
"sentence_ru": cached["translated_text"],
|
||
"target_word": clean_word,
|
||
"word_meaning": cached.get("word_meaning") or "",
|
||
"source": cached.get("source") or "cache",
|
||
"cached": True,
|
||
}
|
||
|
||
# 2. Retrieve offline dictionary hints to steer LLM towards authentic meanings
|
||
dict_hint = get_dictionary_hint(clean_word) if clean_word else ""
|
||
sentence_hints = get_sentence_dictionary_hints(clean_sentence, clean_word)
|
||
|
||
# 3. Local AI Generation (Ollama)
|
||
prompt = build_translation_prompt(
|
||
sentence=clean_sentence,
|
||
target_word=clean_word,
|
||
context=clean_context,
|
||
dict_hint=dict_hint,
|
||
sentence_hints=sentence_hints,
|
||
)
|
||
|
||
payload = {
|
||
"model": config.OLLAMA_MODEL,
|
||
"prompt": prompt,
|
||
"stream": False,
|
||
"format": "json",
|
||
"keep_alive": config.OLLAMA_KEEP_ALIVE,
|
||
"options": {
|
||
"temperature": 0.2,
|
||
"num_predict": 300,
|
||
},
|
||
}
|
||
|
||
sentence_ru = ""
|
||
word_meaning = ""
|
||
source = f"ollama:{config.OLLAMA_MODEL}"
|
||
|
||
try:
|
||
client = get_ollama_client()
|
||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
raw_response = data.get("response", "{}")
|
||
try:
|
||
parsed = json.loads(raw_response)
|
||
except Exception:
|
||
# In case LLM emitted markdown wrapper ```json ... ```
|
||
clean_raw = raw_response.strip()
|
||
if clean_raw.startswith("```json"):
|
||
clean_raw = clean_raw[7:]
|
||
if clean_raw.startswith("```"):
|
||
clean_raw = clean_raw[3:]
|
||
if clean_raw.endswith("```"):
|
||
clean_raw = clean_raw[:-3]
|
||
try:
|
||
parsed = json.loads(clean_raw.strip())
|
||
except Exception:
|
||
parsed = {}
|
||
|
||
sentence_ru = str(parsed.get("sentence_ru", "")).strip()
|
||
|
||
# Extract word meaning flexibly
|
||
raw_meaning = (
|
||
parsed.get("word_meaning")
|
||
or parsed.get(f"{clean_word}_meaning")
|
||
or parsed.get("meaning")
|
||
or ""
|
||
)
|
||
raw_meaning = str(raw_meaning).strip()
|
||
|
||
# Clean up format like "briefcase: портфель" or "портфель (сущ.)"
|
||
if ":" in raw_meaning:
|
||
raw_meaning = raw_meaning.split(":", 1)[1].strip()
|
||
|
||
# If model returned the English term back, fallback to dictionary hint
|
||
if raw_meaning.lower() == clean_word.lower() or not raw_meaning:
|
||
if dict_hint:
|
||
raw_meaning = dict_hint.split(",")[0].strip()
|
||
else:
|
||
raw_meaning = ""
|
||
|
||
word_meaning = raw_meaning
|
||
|
||
else:
|
||
logger.warning(f"Ollama returned HTTP {resp.status_code}: {resp.text}")
|
||
except Exception as e:
|
||
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||
logger.warning(f"Ollama connection error ({err_msg})")
|
||
|
||
# 4. Graceful Fallback if Ollama is unreachable
|
||
if not sentence_ru:
|
||
sentence_ru = "(Локальный ИИ временно недоступен)"
|
||
source = "fallback"
|
||
|
||
# Save to SQLite Cache if valid translation obtained
|
||
if sentence_ru and not sentence_ru.startswith("("):
|
||
save_sentence_translation(
|
||
sentence=clean_sentence,
|
||
translated_text=sentence_ru,
|
||
target_word=clean_word,
|
||
word_meaning=word_meaning,
|
||
source=source,
|
||
)
|
||
|
||
return {
|
||
"original_sentence": clean_sentence,
|
||
"sentence_ru": sentence_ru,
|
||
"target_word": clean_word,
|
||
"word_meaning": word_meaning,
|
||
"source": source,
|
||
"cached": False,
|
||
}
|
||
|
||
|
||
async def get_ollama_status() -> Dict[str, Any]:
|
||
"""Checks Ollama server connectivity and lists installed models."""
|
||
try:
|
||
client = get_ollama_client()
|
||
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags", timeout=3.0)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
models = [m.get("name") for m in data.get("models", [])]
|
||
return {
|
||
"online": True,
|
||
"models": models,
|
||
"active_model": config.OLLAMA_MODEL,
|
||
}
|
||
except Exception:
|
||
pass
|
||
return {
|
||
"online": False,
|
||
"models": [],
|
||
"active_model": config.OLLAMA_MODEL,
|
||
}
|
||
|
||
|
||
async def warmup_ollama_model() -> bool:
|
||
"""Preloads the configured Ollama model into RAM with keep_alive to eliminate cold-start latency."""
|
||
try:
|
||
client = get_ollama_client()
|
||
resp = await client.post(
|
||
f"{config.OLLAMA_BASE_URL}/api/generate",
|
||
json={"model": config.OLLAMA_MODEL, "keep_alive": config.OLLAMA_KEEP_ALIVE},
|
||
)
|
||
if resp.status_code == 200:
|
||
logger.info(f"Ollama model '{config.OLLAMA_MODEL}' warmed up successfully (keep_alive={config.OLLAMA_KEEP_ALIVE}).")
|
||
return True
|
||
else:
|
||
logger.warning(f"Ollama warmup returned HTTP {resp.status_code}: {resp.text}")
|
||
except Exception as e:
|
||
err_msg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
|
||
logger.warning(f"Ollama warmup deferred ({err_msg})")
|
||
return False
|
||
|
||
|
||
def generate_deterministic_debrief(
|
||
session_data: Dict[str, Any],
|
||
history_data: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Fast rule-based analytical debrief engine with Murphy pedagogic insight.
|
||
Ensures zero server delay and 100% reliable fallback if Ollama is offline.
|
||
"""
|
||
history_data = history_data or {}
|
||
total_score = int(session_data.get("total_score", 0))
|
||
errors = session_data.get("errors", [])
|
||
steps = session_data.get("steps", [])
|
||
|
||
if total_score >= 90:
|
||
grade = "A+"
|
||
headline = "Превосходная сессия! Отличная концентрация и высокая точность."
|
||
elif total_score >= 75:
|
||
grade = "A"
|
||
headline = "Отличная работа! Большинство заданий выполнено уверенно."
|
||
elif total_score >= 60:
|
||
grade = "B"
|
||
headline = "Хороший результат! Есть отдельные нюансы для закрепления."
|
||
else:
|
||
grade = "C"
|
||
headline = "Урок завершён! Рекомендуем уделить внимание повторению разобранных тем."
|
||
|
||
# Identify strengths
|
||
strengths = []
|
||
for s in steps:
|
||
st_type = s.get("type", "")
|
||
st_score = s.get("score", 100)
|
||
if st_score >= 80:
|
||
if st_type == "vocab":
|
||
strengths.append(f"Уверенное воспроизведение словарного запаса ({s.get('correct', 0)} из {s.get('total', 0)} слов).")
|
||
elif st_type == "grammar_review":
|
||
strengths.append("Успешное закрепление ранее изученных грамматических правил.")
|
||
elif st_type == "grammar_new":
|
||
strengths.append("Быстрое освоение новой грамматической темы Мерфи.")
|
||
elif st_type == "listening":
|
||
strengths.append("Отличное распознавание английской речи и ключевых деталей на слух.")
|
||
elif st_type == "reading":
|
||
words_saved = s.get("words_saved", 0)
|
||
strengths.append(f"Качественная словарная работа с текстом (+{words_saved} новых слов в словарь).")
|
||
|
||
if not strengths:
|
||
strengths.append("Вы успешно завершили все запланированные этапы комплексного урока.")
|
||
|
||
# Identify error patterns
|
||
error_patterns = []
|
||
chronic_vocab = set(history_data.get("chronic_vocab_errors", []))
|
||
past_grammar = set(history_data.get("past_grammar_errors", []))
|
||
|
||
for err in errors[:5]:
|
||
err_type = err.get("type", "")
|
||
if err_type == "grammar":
|
||
title = err.get("unit_title", "Грамматика")
|
||
unit_num = err.get("unit_num", 0)
|
||
formula = err.get("formula", "")
|
||
exp = err.get("explanation", "")
|
||
is_repeating = unit_num in past_grammar
|
||
repeat_tag = " [Повторяющаяся ошибка]" if is_repeating else ""
|
||
msg = f"{title}{repeat_tag}: {exp}" if exp else f"{title}{repeat_tag}: обратите внимание на формулу {formula}."
|
||
if msg not in error_patterns:
|
||
error_patterns.append(msg)
|
||
elif err_type == "vocab":
|
||
term = err.get("term", "")
|
||
trans = err.get("translation", "")
|
||
is_chronic = term.lower() in chronic_vocab
|
||
tag = " [Трудное слово — уже встречалась ошибка]" if is_chronic else ""
|
||
error_patterns.append(f"Слово «{term}» ({trans}){tag}: требует повторного прогона в тренажёре.")
|
||
elif err_type == "listening":
|
||
q = err.get("question", "")
|
||
ideal = err.get("ideal_answer", "")
|
||
error_patterns.append(f"Аудирование («{q}»): диктор обращал внимание на ответ «{ideal}».")
|
||
|
||
if not error_patterns:
|
||
error_patterns.append("Ошибок в этой сессии не зафиксировано! Все тестовые задания решены верно.")
|
||
|
||
# Tomorrow focus
|
||
tomorrow_focus = []
|
||
grammar_errors = [e for e in errors if e.get("type") == "grammar"]
|
||
vocab_errors = [e for e in errors if e.get("type") == "vocab"]
|
||
|
||
if grammar_errors:
|
||
u_name = grammar_errors[0].get("unit_title", "пройденную тему")
|
||
tomorrow_focus.append(f"Закрепить правило: {u_name}.")
|
||
if vocab_errors:
|
||
terms_str = ", ".join([f"«{e.get('term')}»" for e in vocab_errors[:3]])
|
||
tomorrow_focus.append(f"Повторить проблемные слова: {terms_str}.")
|
||
if not tomorrow_focus:
|
||
tomorrow_focus.append("Продолжить движение по учебнику Мерфи и расширять словарный запас.")
|
||
|
||
return {
|
||
"grade": grade,
|
||
"score": total_score,
|
||
"headline": headline,
|
||
"strengths": strengths[:3],
|
||
"error_patterns": error_patterns[:4],
|
||
"tomorrow_focus": tomorrow_focus[:3],
|
||
"source": "fallback:deterministic",
|
||
}
|
||
|
||
|
||
async def generate_lesson_ai_debrief(
|
||
session_data: Dict[str, Any],
|
||
history_data: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Generates a personalized pedagogic debrief using local LLM (Qwen 2.5 3B).
|
||
Blends deterministic fact verification with natural narrative mentorship.
|
||
Gracefully falls back to deterministic analysis if LLM is offline or delayed.
|
||
"""
|
||
history_data = history_data or {}
|
||
total_score = int(session_data.get("total_score", 0))
|
||
preset_name = session_data.get("preset_name", "Комплексный урок")
|
||
duration_min = round(session_data.get("duration_seconds", 0) / 60)
|
||
errors = session_data.get("errors", [])
|
||
steps = session_data.get("steps", [])
|
||
|
||
# Format steps summary
|
||
steps_lines = []
|
||
for s in steps:
|
||
steps_lines.append(f"- {s.get('title', s.get('type', 'Блок'))}: результат {s.get('score', 100)}%")
|
||
steps_summary_text = "\n".join(steps_lines) if steps_lines else "Все блоки пройдены"
|
||
|
||
# Format errors summary
|
||
errors_lines = []
|
||
for e in errors:
|
||
e_type = e.get("type", "")
|
||
if e_type == "grammar":
|
||
errors_lines.append(
|
||
f"- [Грамматика {e.get('unit_title', '')}]: ответ студента: '{e.get('user_answer', '')}', "
|
||
f"правильно: '{e.get('correct_answer', '')}'. Формула: {e.get('formula', '')}. {e.get('explanation', '')}"
|
||
)
|
||
elif e_type == "vocab":
|
||
errors_lines.append(
|
||
f"- [Слово]: '{e.get('term', '')}' ({e.get('translation', '')}) — ошибка в карточке."
|
||
)
|
||
elif e_type == "listening":
|
||
errors_lines.append(
|
||
f"- [Аудирование]: Вопрос '{e.get('question', '')}', ответ студента: '{e.get('user_answer', '')}', "
|
||
f"правильный ответ: '{e.get('ideal_answer', '')}'"
|
||
)
|
||
errors_text = "\n".join(errors_lines) if errors_lines else "Ошибок нет, 100% правильных ответов."
|
||
|
||
# Format historical context
|
||
chronic_vocab = history_data.get("chronic_vocab_errors", [])
|
||
past_grammar = history_data.get("past_grammar_errors", [])
|
||
history_lines = []
|
||
if chronic_vocab:
|
||
history_lines.append(f"- Слова с частыми ошибками в прошлом: {', '.join(chronic_vocab[:5])}")
|
||
if past_grammar:
|
||
history_lines.append(f"- Юниты грамматики с низкой точностью в прошлом: {', '.join(map(str, past_grammar[:3]))}")
|
||
history_text = "\n".join(history_lines) if history_lines else "История чистая или ошибок ранее не зафиксировано."
|
||
|
||
prompt = (
|
||
"Ты — чуткий, высококвалифицированный преподаватель английского языка по методике Raymond Murphy.\n"
|
||
"Студент только что завершил комплексный урок. Проанализируй его результаты, свяжи ошибки с грамматическими формулами "
|
||
"и составь лаконичный, мотивирующий и практически полезный педагогический разбор на русском языке.\n\n"
|
||
f"Урок: {preset_name}\n"
|
||
f"Длительность: {duration_min} мин\n"
|
||
f"Итоговый балл: {total_score}%\n\n"
|
||
f"Пройденные блоки:\n{steps_summary_text}\n\n"
|
||
f"Ошибки в этой сессии:\n{errors_text}\n\n"
|
||
f"История прошлых трудностей:\n{history_text}\n\n"
|
||
"Требования к ответу:\n"
|
||
"1. Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON.\n"
|
||
"2. Поле 'headline': краткий, воодушевляющий заголовок (1 предложение).\n"
|
||
"3. Поле 'strengths': список из 1-3 пунктов, что получилось лучше всего.\n"
|
||
"4. Поле 'error_patterns': список из 1-3 пунктов с объяснением допущенных ошибок (если были) через контраст и формулы Мерфи. Если ошибок не было — похвали за точность.\n"
|
||
"5. Поле 'tomorrow_focus': список из 2-3 конкретных рекомендаций на завтра.\n\n"
|
||
"Формат JSON:\n"
|
||
"{\n"
|
||
f' "grade": "{"A+" if total_score >= 90 else ("A" if total_score >= 75 else ("B" if total_score >= 60 else "C"))}",\n'
|
||
f' "score": {total_score},\n'
|
||
' "headline": "заголовок",\n'
|
||
' "strengths": ["пункт 1", "пункт 2"],\n'
|
||
' "error_patterns": ["разбор ошибки 1", "разбор ошибки 2"],\n'
|
||
' "tomorrow_focus": ["совет 1", "совет 2"]\n'
|
||
"}"
|
||
)
|
||
|
||
payload = {
|
||
"model": config.OLLAMA_MODEL,
|
||
"prompt": prompt,
|
||
"stream": False,
|
||
"format": "json",
|
||
"keep_alive": config.OLLAMA_KEEP_ALIVE,
|
||
"options": {
|
||
"temperature": 0.3,
|
||
"num_predict": 450,
|
||
},
|
||
}
|
||
|
||
try:
|
||
client = get_ollama_client()
|
||
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=10.0)
|
||
if resp.status_code == 200:
|
||
raw_response = resp.json().get("response", "{}")
|
||
# Parse JSON
|
||
clean_raw = raw_response.strip()
|
||
if clean_raw.startswith("```json"):
|
||
clean_raw = clean_raw[7:]
|
||
if clean_raw.startswith("```"):
|
||
clean_raw = clean_raw[3:]
|
||
if clean_raw.endswith("```"):
|
||
clean_raw = clean_raw[:-3]
|
||
data = json.loads(clean_raw.strip())
|
||
|
||
return {
|
||
"grade": data.get("grade", "A" if total_score >= 75 else "B"),
|
||
"score": total_score,
|
||
"headline": data.get("headline", "Отличная сессия!"),
|
||
"strengths": data.get("strengths", ["Хорошая работа на уроке"]),
|
||
"error_patterns": data.get("error_patterns", []),
|
||
"tomorrow_focus": data.get("tomorrow_focus", ["Продолжайте ежедневные занятия"]),
|
||
"source": f"ollama:{config.OLLAMA_MODEL}",
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"Ollama debrief generation deferred to deterministic engine: {e}")
|
||
|
||
# Fallback to deterministic debrief
|
||
return generate_deterministic_debrief(session_data, history_data)
|
||
|