fix: resolve bugs 01 through 08 and set Ollama TTL to 1h

This commit is contained in:
2026-10-07 19:19:49 +03:00
parent a01bf64445
commit 9bbc9473de
14 changed files with 368 additions and 110 deletions
+1 -1
View File
@@ -576,7 +576,7 @@ async def generate_lesson_ai_debrief(
try:
client = get_ollama_client()
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=25.0)
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
+1 -1
View File
@@ -43,5 +43,5 @@ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b")
OLLAMA_KEEP_ALIVE = os.getenv("OLLAMA_KEEP_ALIVE", "5m")
OLLAMA_KEEP_ALIVE = os.getenv("OLLAMA_KEEP_ALIVE", "1h")
+132 -8
View File
@@ -171,11 +171,11 @@ async def fetch_wiktionary_definitions(word: str, client: Optional[httpx.AsyncCl
return {"parts_of_speech": [], "definitions": [], "examples": []}
async def fetch_mymemory(query: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]:
async def fetch_mymemory(query: str, langpair: str = "en|ru", client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]:
url = "https://api.mymemory.translated.net/get"
params = {
"q": query,
"langpair": "en|ru",
"langpair": langpair,
"de": config.MYMEMORY_EMAIL
}
try:
@@ -294,11 +294,132 @@ async def lookup_term(query: str) -> Dict[str, Any]:
except Exception:
pass
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
words = query_clean.split()
is_sentence = len(words) > 4
is_cyrillic = bool(re.search(r"[\u0400-\u04FF]", query_clean))
words = query_clean.split()
is_sentence = len(words) > 4
# -----------------------------------------------------------------------
# Reverse Translation (Russian -> English)
# -----------------------------------------------------------------------
if is_cyrillic:
matched_term = None
phonetic = ""
pos = ""
clean_trans = query_clean
tr_list = [query_clean]
is_single = len(words) == 1
# 1. Search offline dictionary for exact reverse matches
if not is_sentence:
try:
with get_db() as conn:
cur = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE translation LIKE ?",
(f"%{query_clean}%",)
)
rows = cur.fetchall()
exact_matches = []
for r in rows:
raw_t = r["translation"] or ""
parts = [p.strip().lower() for p in raw_t.split(",")]
if query_clean.lower() in parts:
w_cnt = len(r["term"].split())
is_multi = 1 if (is_single and w_cnt > 1) else 0
is_inflection = 1 if " от " in raw_t else 0
exact_matches.append((is_multi, is_inflection, w_cnt, len(r["term"]), r))
if exact_matches:
exact_matches.sort(key=lambda x: (x[0], x[1], x[2], x[3]))
# Prefer single-word offline match if query was single word
if exact_matches[0][0] == 0:
best_row = exact_matches[0][4]
matched_term = best_row["term"]
phonetic = best_row["phonetic"] or ""
pos = best_row["pos"] or ""
clean_trans = clean_russian_translation(best_row["translation"] or "")
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
except Exception:
pass
# 2. If no direct offline match, query MyMemory with langpair="ru|en"
http_client = get_http_client()
if not matched_term:
try:
mm_data = await fetch_mymemory(query_clean, langpair="ru|en", client=http_client)
raw_cands = []
if mm_data.get("translation"):
raw_cands.append(mm_data["translation"])
for m in mm_data.get("matches", []):
if m and m not in raw_cands and m.lower() != query_clean.lower():
raw_cands.append(m)
cleaned_cands = []
for c in raw_cands:
c_clean = re.sub(r"^(?:to|an?|the)\s+", "", c.strip(), flags=re.IGNORECASE).strip(" *.,!?\"'")
if c_clean and c_clean not in cleaned_cands and not re.search(r"[\u0400-\u04FF]", c_clean):
cleaned_cands.append(c_clean)
if cleaned_cands:
matched_term = cleaned_cands[0]
# Enrich from offline dictionary if candidate exists
with get_db() as conn:
for cand in cleaned_cands:
off_row = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(cand,)
).fetchone()
if off_row:
matched_term = off_row["term"]
phonetic = off_row["phonetic"] or ""
pos = off_row["pos"] or ""
if not clean_trans or clean_trans == query_clean:
clean_trans = clean_russian_translation(off_row["translation"] or "") or query_clean
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
break
# If phonetic still missing and candidate is a single word, fetch from free dictionary API
if not phonetic and len(matched_term.split()) == 1:
fd = await fetch_free_dictionary_api(matched_term, client=http_client)
if fd.get("phonetic"):
phonetic = fd["phonetic"]
except Exception:
pass
if not matched_term:
matched_term = query_clean
res = {
"query": query_clean,
"term": matched_term,
"english_term": matched_term,
"russian_query": query_clean,
"is_sentence": is_sentence,
"is_reverse": True,
"translation": clean_trans,
"translations_list": tr_list if tr_list else [clean_trans],
"translation_ru": clean_trans,
"phonetic": phonetic,
"audio_file": "",
"audio_url": "",
"part_of_speech": pos or ("Sentence" if is_sentence else "Word"),
"definitions": [],
"examples": [],
"source": "reverse_dictionary",
}
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO dictionary_cache (query, response_json) VALUES (?, ?)",
(query_clean, json.dumps(res, ensure_ascii=False))
)
return res
# -----------------------------------------------------------------------
# Forward Translation (English -> Russian)
# -----------------------------------------------------------------------
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
if not is_sentence:
try:
with get_db() as conn:
off_row = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(query_clean,)
@@ -329,7 +450,9 @@ async def lookup_term(query: str) -> Dict[str, Any]:
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
res = {
"query": query_clean,
"term": off_row["term"] or query_clean,
"is_sentence": False,
"is_reverse": False,
"translation": clean_trans,
"translations_list": tr_list,
"translation_ru": clean_trans,
@@ -346,13 +469,14 @@ async def lookup_term(query: str) -> Dict[str, Any]:
(query_clean, json.dumps(res, ensure_ascii=False))
)
return res
except Exception:
pass
except Exception:
pass
result: Dict[str, Any] = {
"query": query_clean,
"term": query_clean,
"is_sentence": is_sentence,
"is_reverse": False,
"translation": "",
"translations_list": [],
"phonetic": "",
+5 -1
View File
@@ -1182,6 +1182,10 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL | re.IGNORECASE)
body = re.sub(r'<noscript\b[^>]*>.*?</noscript>', '', body, flags=re.DOTALL | re.IGNORECASE)
# Pre-strip MediaWiki Parsoid metadata attributes (data-mw, data-parsoid, about)
# These contain raw template JSON with <ref> tags and literal '>' characters that break tag regexes
body = re.sub(r'''\s+(?:data-mw|data-parsoid|about)=(?:"[^"]*"|'[^']*')''', '', body, flags=re.DOTALL)
# 3. Strip unwanted wiki elements
# Remove references: <sup class="reference">...</sup> and bracketed reference markers
body = re.sub(r'<sup\b[^>]*class=["\x27][^"\x27]*reference[^"\x27]*["\x27][^>]*>.*?</sup>', '', body, flags=re.DOTALL | re.IGNORECASE)
@@ -1229,7 +1233,7 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE):
return ""
cleaned_tbl = re.sub(
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=["\x27][^"\x27]*["\x27]',
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=(?:"[^"]*"|\'[^\']*\')',
'',
tbl_html,
flags=re.IGNORECASE
+6 -2
View File
@@ -38,11 +38,15 @@ async def api_lookup(
raise HTTPException(status_code=400, detail="Query term cannot be empty")
data = await lookup_term(query_str)
# Ensure term is always present in response
target_term = data.get("term") or data.get("english_term") or query_str
data["term"] = target_term
# Check if this term already exists in user's dictionary
with get_db() as conn:
existing = conn.execute(
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], query_str),
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND (lower(trim(term)) = lower(trim(?)) OR lower(trim(term)) = lower(trim(?)))",
(user["id"], target_term, query_str),
).fetchone()
if existing:
data["in_library"] = True
+50 -2
View File
@@ -924,6 +924,54 @@
.card-bottom {
font-size: 11px;
}
}
/* Grammar Hint Badge & Audio buttons in Cards */
.grammar-hint-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
padding: 2px 7px;
border-radius: 9999px;
font-size: 11px;
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
color: var(--text-muted, #94a3b8);
cursor: pointer;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
transition: all 0.2s ease;
user-select: none;
}
.grammar-hint-badge:hover {
background: var(--bg-active, rgba(59, 130, 246, 0.15));
color: var(--primary, #38bdf8);
border-color: var(--primary, #38bdf8);
}
.grammar-hint-badge .badge-body {
display: none;
font-style: italic;
color: var(--text-primary, #f1f5f9);
}
.grammar-hint-badge.active .badge-body,
.grammar-hint-badge:hover .badge-body {
display: inline;
}
.grammar-hint-badge.active .badge-label {
opacity: 0.6;
}
.card-context .play-context-audio-btn {
padding: 1px 4px;
font-size: 12px;
vertical-align: middle;
margin-left: 6px;
opacity: 0.7;
}
.card-context .play-context-audio-btn:hover {
opacity: 1;
}
+16 -7
View File
@@ -492,13 +492,22 @@ export const api = {
throw new Error(`Failed to get articles: ${resp.status}`);
},
async submitDebrief(payload) {
const resp = await fetch('/api/lessons/debrief', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) return await resp.json();
throw new Error(`Failed to submit debrief: ${resp.status}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 12000);
try {
const resp = await fetch('/api/lessons/debrief', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
if (resp.ok) return await resp.json();
throw new Error(`Failed to submit debrief: ${resp.status}`);
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
},
async getHistory(limit = 15) {
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
+30 -10
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, showToast, openModal, closeModal, playAudio } from './ui.js';
import { escapeHtml, showToast, openModal, closeModal, playAudio, playTextSpeech, formatTranslationWithSpoiler } from './ui.js';
export let allCards = [];
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
@@ -67,7 +67,7 @@ export function initDictionary() {
const batchCancelBtn = document.getElementById('batch-cancel-btn');
// Elements - Modals
const editModal = document.getElementById('edit-card-modal');
const editModal = document.getElementById('edit-modal') || document.getElementById('edit-card-modal');
const closeModalBtn = document.getElementById('close-modal-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const modalSaveBtn = document.getElementById('modal-save-btn');
@@ -165,8 +165,9 @@ export function initDictionary() {
if (audioPlayBtn) {
audioPlayBtn.addEventListener('click', () => {
if (!currentLookupData || !currentLookupData.term) return;
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(currentLookupData.term)}&lang=en`;
const term = currentLookupData?.term || currentLookupData?.english_term;
if (!term) return;
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
});
}
@@ -192,7 +193,7 @@ export function initDictionary() {
}
function displayLookupResult(data) {
resTerm.textContent = data.term;
resTerm.textContent = data.term || data.english_term || data.query || '';
resPhonetic.textContent = data.phonetic || '';
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
resPos.textContent = data.part_of_speech || 'Phrase';
@@ -210,6 +211,8 @@ export function initDictionary() {
resInLibrary.classList.toggle('hidden', !inLib);
}
const targetTrans = (data.is_reverse ? (data.russian_query || data.translation) : data.translation) || '';
if (inLib) {
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
const lib = data.library_card || foundDuplicateCard;
@@ -220,11 +223,11 @@ export function initDictionary() {
if (lib.tags) editTags.value = lib.tags;
if (lib.notes) editNotes.value = lib.notes;
} else {
editTranslation.value = data.translation || '';
editTranslation.value = targetTrans;
}
} else {
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
editTranslation.value = data.translation || '';
editTranslation.value = targetTrans;
editContext.value = '';
editNotes.value = '';
editCollection.value = selectedCollection || 'General';
@@ -247,7 +250,7 @@ export function initDictionary() {
async function saveNewCard() {
if (!currentLookupData) return;
const term = currentLookupData.term.trim();
const term = (currentLookupData.term || currentLookupData.english_term || currentLookupData.query || '').trim();
const translation = editTranslation.value.trim();
if (!translation) {
alert('Пожалуйста, укажите русский перевод');
@@ -388,14 +391,19 @@ export function initDictionary() {
<span class="card-term">${escapeHtml(c.term)}</span>
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
<button class="icon-btn audio-play-btn play-term-audio-btn" data-id="${c.id}" title="Озвучить слово">🔊</button>
</div>
<div class="card-actions">
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
</div>
</div>
<div class="card-translation">${escapeHtml(c.translation)}</div>
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</div>` : ''}
<div class="card-translation">${formatTranslationWithSpoiler(c.translation)}</div>
${c.context_sentence ? `
<div class="card-context">
“${escapeHtml(c.context_sentence)}”
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример">🔊</button>
</div>` : ''}
<div class="card-bottom">
<div class="card-tags">
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
@@ -405,6 +413,18 @@ export function initDictionary() {
</div>
`;
// Audio buttons
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
if (c.audio_url) playAudio(c.audio_url);
else playTextSpeech(c.term, 'en');
});
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.context_sentence, 'en');
});
// Edit button
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
e.stopPropagation();
+9 -9
View File
@@ -4,7 +4,7 @@
// =======================================================================
import { api } from './api.js';
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech } from './ui.js';
import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech, cleanPromptTranslation } from './ui.js';
let presets = [];
let currentPreset = null;
@@ -762,20 +762,20 @@ async function runVocabStep(step, container) {
// Mode A: 4 Options Quiz
// -----------------------------------------------------------------------
function renderQuizCard(card) {
const correctText = card.translation;
const correctText = cleanPromptTranslation(card.translation);
const candidates = [
...cards.filter(c => c.id !== card.id && c.translation).map(c => c.translation),
...distractorPool.filter(t => t && t !== correctText)
...cards.filter(c => c.id !== card.id && c.translation).map(c => cleanPromptTranslation(c.translation)),
...distractorPool.filter(t => t && cleanPromptTranslation(t) !== correctText).map(t => cleanPromptTranslation(t))
];
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
shuffleArray(uniqueCandidates);
const options = [correctText, ...uniqueCandidates.slice(0, 3)];
let options = [correctText, ...uniqueCandidates.slice(0, 3)];
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
while (options.length < 4) {
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
options.push(f);
}
shuffleArray(options);
options = shuffleArray(options);
let answered = false;
@@ -1050,7 +1050,7 @@ async function runVocabStep(step, container) {
<div class="step-prompt-display text-center mt-3">
<span class="text-muted text-sm">Переведите на английский:</span>
<h2 class="display-term display-prompt">${escapeHtml(card.translation)}</h2>
<h2 class="display-term display-prompt">${escapeHtml(cleanPromptTranslation(card.translation))}</h2>
</div>
${card.context_sentence ? `
@@ -1171,7 +1171,7 @@ async function runVocabStep(step, container) {
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
🔊 Прослушать слово (Пробел)
</button>
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(card.translation)}</p>` : ''}
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(cleanPromptTranslation(card.translation))}</p>` : ''}
</div>
<form id="lesson-dictation-form" class="mt-4">
@@ -1656,7 +1656,7 @@ async function runListeningStep(step, container) {
}
const data = await api.listening.getExercise(step.topic || 'all');
const scenario = data.scenario;
const scenario = (data && (data.scenario || data)) || {};
container.innerHTML = `
<div class="step-card-box">
+10 -10
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio } from './ui.js';
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } from './ui.js';
import { getAllCards } from './dictionary.js';
let srsQueue = [];
@@ -330,7 +330,7 @@ export function initSrs() {
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
} else {
fcFrontDirBadge.textContent = 'RU ➔ EN';
fcFrontMain.textContent = card.translation;
fcFrontMain.textContent = cleanPromptTranslation(card.translation);
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPhoneticWrap.style.display = 'none';
fcHintBtn.style.display = 'none';
@@ -447,18 +447,18 @@ export function initSrs() {
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
const correctText = card.translation;
const correctText = cleanPromptTranslation(card.translation);
const all = getAllCards();
const candidates = all
.filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
.map(c => c.translation);
.filter(c => c.id !== card.id && c.translation && cleanPromptTranslation(c.translation) !== correctText)
.map(c => cleanPromptTranslation(c.translation));
shuffleArray(candidates);
const options = [correctText, ...candidates.slice(0, 3)];
let options = [correctText, ...candidates.slice(0, 3)];
while (options.length < 4) {
options.push(`Вариант ${options.length + 1}`);
}
shuffleArray(options);
options = shuffleArray(options);
quizCurrentOptions = options;
quizOptionsGrid.innerHTML = '';
@@ -543,7 +543,7 @@ export function initSrs() {
typingRatingBar.classList.add('hidden');
typingPos.textContent = card.part_of_speech || '';
typingPromptRu.textContent = card.translation;
typingPromptRu.textContent = cleanPromptTranslation(card.translation);
if (card.context_sentence) {
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
typingContextHint.classList.remove('hidden');
@@ -590,7 +590,7 @@ export function initSrs() {
clozeRatingBar.classList.add('hidden');
clozePos.textContent = card.part_of_speech || '';
clozeTargetTranslation.textContent = card.translation;
clozeTargetTranslation.textContent = cleanPromptTranslation(card.translation);
const context = card.context_sentence || `The word is ${card.term}.`;
const re = new RegExp(escapeRegex(card.term), 'gi');
@@ -635,7 +635,7 @@ export function initSrs() {
dictationDetailsBox.classList.add('hidden');
dictationRatingBar.classList.add('hidden');
dictationHintText.classList.add('hidden');
dictationHintText.textContent = card.translation;
dictationHintText.textContent = cleanPromptTranslation(card.translation);
dictationInput.value = '';
dictationInput.disabled = false;
+20 -4
View File
@@ -27,12 +27,26 @@ export function slugify(text) {
export function shuffleArray(array) {
if (!array || !Array.isArray(array)) return [];
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
[array[i], array[j]] = [array[j], array[i]];
}
return arr;
return array;
}
export function cleanPromptTranslation(text) {
if (!text || typeof text !== 'string') return '';
// Strip grammatical inflection notes that contain Latin words to prevent spoilers in tests
return text.replace(/\s*\([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+\)/gi, '').trim();
}
export function formatTranslationWithSpoiler(text) {
if (!text || typeof text !== 'string') return '';
const noteMatch = text.match(/\s*\(([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+)\)/i);
if (!noteMatch) return escapeHtml(text);
const cleanText = text.replace(noteMatch[0], '').trim();
const noteText = noteMatch[1];
return `${escapeHtml(cleanText)} <span class="grammar-hint-badge" title="Нажмите, чтобы показать форму" onclick="this.classList.toggle('active')"><span class="badge-label">ℹ️ форма</span><span class="badge-body">${escapeHtml(noteText)}</span></span>`;
}
// -----------------------------------------------------------------------
@@ -270,6 +284,8 @@ export const ui = {
updateHeaderStats,
playAudio,
playTextSpeech,
cleanPromptTranslation,
formatTranslationWithSpoiler,
};
export default ui;