fix: resolve bugs 01 through 08 and set Ollama TTL to 1h
This commit is contained in:
1 parent
a01bf64445
commit
9bbc9473de
14 files changed
+366
-108
No files matched your search
@@ -472,60 +472,43 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐛 Зафиксированные баги (Ждут команды на исправление)
|
### 🐛 Устранённые баги (Решены и протестированы)
|
||||||
|
|
||||||
- [ ] **BUG-01: Не открывается окно редактирования слова в словаре на главной**:
|
- [x] **BUG-01: Не открывается окно редактирования слова в словаре на главной**:
|
||||||
- **Симптом**: при клике на иконку ✏️ у карточки слова в списке словаря ничего не происходит.
|
- **Симптом**: при клике на иконку ✏️ у карточки слова в списке словаря ничего не происходило.
|
||||||
- **Диагностика**: в `app/static/js/modules/dictionary.js` функция `openEditModal` ищет элемент по id `'edit-card-modal'` (`document.getElementById('edit-card-modal')`), в то время как в `app/static/index.html` модальное окно объявлено с id `'edit-modal'` (`<div id="edit-modal" class="modal-backdrop hidden">`). Из-за несовпадения ID селектор возвращает `null`, и вызов `openModal(null)` тихо игнорируется.
|
- **Решение**: исправлен селектор ID в `app/static/js/modules/dictionary.js` с `'edit-card-modal'` на `'edit-modal'`. Модальное окно теперь стабильно открывается.
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
- **Статус**: [x] Решено.
|
||||||
- [ ] **BUG-02: Отсутствует возможность прослушать слово и предложение из словаря**:
|
- [x] **BUG-02: Отсутствует возможность прослушать слово и предложение из словаря**:
|
||||||
- **Симптом**: в списке карточек словаря на главной странице нет кнопок 🔊 для воспроизведения аудио слова и контекстного предложения.
|
- **Симптом**: в списке карточек словаря на главной странице не было кнопок 🔊 для воспроизведения аудио слова и контекстного предложения.
|
||||||
- **Диагностика**: в шаблоне рендеринга `renderCardsList` (`app/static/js/modules/dictionary.js`) внутри `.card-top` и `.card-context` не отрисовываются кнопки аудиовызова с вызовом `playAudio()` / `playTextSpeech()`, хотя модуль `ui.js` полностью поддерживает их воспроизведение.
|
- **Решение**: добавлены кнопки озвучки 🔊 в `app/static/js/modules/dictionary.js` для `term` (`playAudio`) и `context_sentence` (`playTextSpeech`) с адаптивными стилями в `dictionary.css`.
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
- **Статус**: [x] Решено.
|
||||||
- [ ] **BUG-03: Спойлер корня слова в переводе грамматических форм в тестах (например, `(прош. вр. от explain)`)**:
|
- [x] **BUG-03: Спойлер корня слова в переводе грамматических форм в тестах (например, `(прош. вр. от explain)`)**:
|
||||||
- **Симптом**: при добавлении словоформ в словарь (например, `explained`) в переводе сохраняются пометки словообразования: `объяснять, объяснить (прош. вр. от explain)`. Во время теста (SRS / Урок) отображение этого перевода напрямую спойлерит английское слово `explain`.
|
- **Симптом**: при добавлении словоформ в словарь (например, `explained`) в переводе сохранялись пометки: `(прош. вр. от explain)`, спойлеря правильный ответ во время тестов.
|
||||||
- **Диагностика**: в `app/dictionary.py` функция `clean_russian_translation` (строки 235–277) намеренно защищает грамматические пометки вида `(прош. вр. от ...)`, и они попадают в поле `card.translation`. Во время тестирования в `srs.js` и `lesson.js` эта строка выводится в вопросе теста целиком без фильтрации.
|
- **Решение**: в `app/static/js/modules/ui.js` добавлены утилиты `cleanPromptTranslation` (вырезает подсказки корня в квизах, тайпинге, клоуз-тестах и диктантах в `srs.js` и `lesson.js`) и `formatTranslationWithSpoiler` (оборачивает пометку в интерактивный кликабельный бейдж-спойлер `[+ грамматика]` в карточках словаря).
|
||||||
- **Варианты решения**: по умолчанию скрывать пометку под интерактивный кликабельный спойлер `[+ грамматика]` или вырезать английский корень из вопроса теста через regex `/\s*\([^)]*от\s+[a-zA-Z\x27-]+\)/gi`.
|
- **Статус**: [x] Решено.
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
- [x] **BUG-04: В квизе «4 варианта» правильный ответ всегда находится на 1-м месте**:
|
||||||
- [ ] **BUG-04: В квизе «4 варианта» правильный ответ всегда находится на 1-м месте**:
|
- **Симптом**: в комплексном уроке и SRS при выборе режима «4 варианта» правильный ответ всегда гарантированно оказывался первым.
|
||||||
- **Симптом**: в комплексном уроке и SRS при выборе режима «4 варианта» правильный ответ всегда гарантированно оказывается первым вариантом.
|
- **Решение**: в `app/static/js/modules/ui.js` функция `shuffleArray` переведена в мутирующий режим in-place, а в `lesson.js` и `srs.js` вызовы обновлены на явное переприсваивание `options = shuffleArray(options)`. Правильный ответ теперь распределяется равномерно случайно.
|
||||||
- **Диагностика**: в `app/static/js/modules/ui.js` (строка 28) функция `shuffleArray(array)` возвращает новый скопированный массив (`return arr;`), не мутируя оригинал на месте. В `app/static/js/modules/lesson.js` (строка 778) и `app/static/js/modules/srs.js` (строка 461) написано `shuffleArray(options);`, но результат возврата игнорируется (`options` не перезаписывается), из-за чего правильный ответ, добавленный первым в `[correctText, ...]`, всегда остаётся на позиции 0.
|
- **Статус**: [x] Решено.
|
||||||
- **Варианты решения**: переписать вызовы на `options = shuffleArray(options);` (или сделать `shuffleArray` мутирующим).
|
- [x] **BUG-05: Поломка верстки и отображение сырых HTML-тегов/JSON при импорте статей Википедии (например, Linux)**:
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
- **Симптом**: при открытии статьи «Linux» отображался сырой JSON шаблонов MediaWiki (`"},"title":{"wt":"Linux"}...`) и сломанные теги `<tdTimes New Roman...`.
|
||||||
- [ ] **BUG-05: Поломка верстки и отображение сырых HTML-тегов/JSON при импорте статей Википедии (например, Linux)**:
|
- **Решение**: в `app/reader.py` внедрена предварительная очистка атрибутов MediaWiki Parsoid (`data-mw`, `data-parsoid`, `about`), содержащих вложенные `<ref>` и литеральные `>`, ломавшие регулярные выражения тегов. В `clean_data_table` исправлен regex для поддержки вложенных кавычек `(?:"[^"]*"|\'[^\']*\')`. Статья 19 (Linux) в `data/notes.db` перепарсена и сохранена в идеально чистом виде. Добавлен регрессионный тест в `tests/test_api.py`.
|
||||||
- **Симптом**: при открытии импортированной из Википедии статьи «Linux» в читалке в 1-й главе («Overview») отображается фрагмент сырого JSON (`"},"title":{"wt":"Linux"}...`), а в главе «Pronunciation» — искажённый тег `<tdTimes New Roman...` и сырые теги цитат `{{ cite newsgroup...`.
|
- **Статус**: [x] Решено.
|
||||||
- **Диагностика**:
|
- [x] **BUG-06: Не работает поиск и обратный перевод с русского на английский в словаре**:
|
||||||
1. В `app/reader.py` (`clean_wiki_html`): MediaWiki Parsoid HTML вставляет метаданные шаблонов в атрибут `data-mw='{"parts":...}'` (например, у `<span class="mw-empty-elt">` и `<table class="infobox">`). Внутри значения этого атрибута содержатся незаэкранированные угловые скобки (например, `<ref name="...">`). Регулярное выражение удаления HTML-тегов `re.sub(r'<[^>]+>', ' ', s)` встречает первый же символ `>` внутри кавычек атрибута, преждевременно обрывает удаление тега, и остаток JSON-строки вываливается в текст статьи.
|
- **Симптом**: при вводе в поле поиска словаря русского слова перевод не находился, возвращался пустой результат.
|
||||||
2. В `clean_data_table` regex удаления стилей `style=["'][^"']*["']` спотыкается о вложенные одинарные кавычки в `style="font-family:'Times New Roman'..."`, оставляя сломанный HTML `<tdTimes New Roman...`.
|
- **Решение**: в `app/dictionary.py` реализовано автоматическое определение кириллицы `[\u0400-\u04FF]`. При русском вводе выполняется обратный поиск в `offline_dictionary` с приоритизацией одиночных слов и запрос MyMemory с `langpair="ru|en"`, обогащаемый фонетикой и частью речи. В `app/routers/dictionary.py` и `dictionary.js` поддержано заполнение карточки в обратном направлении (найденный английский термин в `term`, русский запрос в `translation`). Добавлен регрессионный тест в `tests/test_api.py`.
|
||||||
3. В `app/static/js/modules/reader.js` повреждённые теги не распознаются парсером таблиц и выводятся как обычный экранированный текст.
|
- **Статус**: [x] Решено.
|
||||||
- **Варианты решения**: предварительно полностью вырезать атрибуты `data-mw` и `about` регулярным выражением или через санитизацию до удаления тегов, улучшить regex очистки стилей таблиц и перезагрузить повреждённые статьи.
|
- [x] **BUG-07: Ошибка загрузки этапа аудирования: `undefined is not an object (evaluating 'scenario.topic')`**:
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
- **Симптом**: при переходе к шагу аудирования в уроке возникала ошибка загрузки этапа из-за чтения `scenario.topic`.
|
||||||
- [ ] **BUG-06: Не работает поиск и обратный перевод с русского на английский в словаре**:
|
- **Решение**: в `app/static/js/modules/lesson.js` строка 1659 обновлена на безопасное извлечение `const scenario = (data && (data.scenario || data)) || {};`, поддерживающее как прямой, так и вложенный формат ответа.
|
||||||
- **Симптом**: при попытке ввести в поле перевода словаря русское слово (например, «собака» или «яблоко») перевод не находится, возвращается пустой результат или ошибка.
|
- **Статус**: [x] Решено.
|
||||||
- **Диагностика**:
|
- [x] **BUG-08: Бесконечный спиннер («Анализ ИИ длится бесконечно») на этапе дебрифинга урока + TTL 1h**:
|
||||||
1. В `app/dictionary.py` функция `lookup_term` жестко закодирована только под направление «English -> Russian»:
|
- **Симптом**: после прохождения урока анализ ИИ висел бесконечно долго из-за холодного старта Ollama и обрыва HTTP-соединения.
|
||||||
- В локальной БД поиск ведется только по полю английского слова `WHERE term = ? COLLATE NOCASE` (русские переводы хранятся в поле `translation` и по ним поиск не производится).
|
- **Решение**:
|
||||||
- Во внешнем сервисе `fetch_mymemory` жестко зашит параметр `langpair="en|ru"`, из-за чего сервис пытается переводить русское слово как английское и возвращает ошибку или пустую строку.
|
1. Увеличен TTL модели в памяти с `5m` до `1h` (`OLLAMA_KEEP_ALIVE=1h` в `config.py` и `docker-compose.yml`) — модель остается теплой всю учебную сессию.
|
||||||
- Запросы к Wiktionary и FreeDictionaryAPI идут в англоязычные эндпоинты (`en.wiktionary.org`, `api.dictionaryapi.dev`), где нет русских словарных статей.
|
2. В `app/ai.py` таймаут к Ollama ограничен 10 секундами с мгновенным автоматическим фолбэком на детерминированный локальный разбор `generate_deterministic_debrief` (< 1 мс).
|
||||||
2. В интерфейсе [`app/static/js/modules/dictionary.js`](file:///home/ai-projects/services/study.qpid.pro/app/static/js/modules/dictionary.js) результат выводится в поле карточки без учёта обратного направления перевода (когда найденный английский эквивалент должен стать `term`, а введенное русское слово — `translation`).
|
3. В `app/static/js/modules/api.js` добавлен жесткий 12-секундный клиентский таймаут с `AbortController`.
|
||||||
- **Варианты решения**: автоматически определять язык ввода по наличию кириллицы `[\u0400-\u04FF]`. При кириллице: переключать `langpair` MyMemory на `"ru|en"`, выполнять обратный поиск в `offline_dictionary` по `translation`, и корректно заполнять форму добавления карточки (найденное английское слово в `term`, русский запрос в `translation`).
|
- **Статус**: [x] Решено.
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
|
||||||
- [ ] **BUG-07: Ошибка загрузки этапа аудирования: `undefined is not an object (evaluating 'scenario.topic')`**:
|
|
||||||
- **Симптом**: при переходе к шагу аудирования в комплексном уроке возникает ошибка: `Ошибка загрузки этапа: undefined is not an object (evaluating 'scenario.topic')`, шаг невозможно пройти.
|
|
||||||
- **Диагностика**: в `app/static/js/modules/lesson.js` (строка 1658) после вызова `api.listening.getExercise()` клиент ожидает поле `data.scenario` (`const scenario = data.scenario;`), в то время как эндпоинт `app/routers/listening.py` (строка 47) возвращает объект упражнения напрямую (`safe_ex`). Из-за этого `scenario` равен `undefined`, и чтение `scenario.topic` выбрасывает исключение `TypeError`.
|
|
||||||
- **Варианты решения**: использовать `const scenario = data.scenario || data;` с опциональной цепочкой (`scenario?.topic`).
|
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
|
||||||
- [ ] **BUG-08: Бесконечный спиннер («Анализ ИИ длится бесконечно») на этапе дебрифинга урока**:
|
|
||||||
- **Симптом**: после прохождения всех этапов урока экран «🤖 Локальный ИИ Qwen 2.5 формирует ваш персональный разбор...» висит бесконечно долго без отображения результата.
|
|
||||||
- **Диагностика**:
|
|
||||||
1. Холодный запуск Ollama для модели Qwen 2.5 3B (после разгрузки памяти) занимает ~18.5 секунд только на загрузку весов в RAM. Генерация 450 токенов JSON на CPU суммарно требует 45-60 секунд.
|
|
||||||
2. В `app/ai.py` (строка 579) таймаут httpx к Ollama выставлен в 25 секунд (`timeout=25.0`), из-за чего запрос рвется, а во фронтенде в `api.lessons.submitDebrief` нет клиентского таймаута с аварийным выходом.
|
|
||||||
3. Короткий TTL (`keep_alive: 5m`) приводит к тому, что к моменту окончания 10–15 минутного урока модель гарантированно выгружается из памяти, вызывая холодный старт прямо на финальном шаге.
|
|
||||||
- **Варианты решения**:
|
|
||||||
1. Увеличить TTL процесса нейросети с `5m` до `1h` (`OLLAMA_KEEP_ALIVE=1h` в `docker-compose.yml`, `config.py` и `ai.py`) — во время активной учебной сессии модель всегда «горячая» в памяти и отвечает за доли секунды, а при простое > 1 часа память автоматически освобождается.
|
|
||||||
2. Добавить жесткий клиентский таймаут (8-10 сек) на фронтенде с мгновенным отображением сверхбыстрого детерминированного отчета (`generate_deterministic_debrief`, работающего за 1 мс).
|
|
||||||
3. Рассмотреть использование легкой модели `qwen2.5:1.5b` для ультрабыстрого ответа.
|
|
||||||
- **Статус**: зафиксировано, ожидает команды на исправление.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ async def generate_lesson_ai_debrief(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = get_ollama_client()
|
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:
|
if resp.status_code == 200:
|
||||||
raw_response = resp.json().get("response", "{}")
|
raw_response = resp.json().get("response", "{}")
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
|
|||||||
+1
-1
@@ -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_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:3b")
|
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
@@ -171,11 +171,11 @@ async def fetch_wiktionary_definitions(word: str, client: Optional[httpx.AsyncCl
|
|||||||
return {"parts_of_speech": [], "definitions": [], "examples": []}
|
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"
|
url = "https://api.mymemory.translated.net/get"
|
||||||
params = {
|
params = {
|
||||||
"q": query,
|
"q": query,
|
||||||
"langpair": "en|ru",
|
"langpair": langpair,
|
||||||
"de": config.MYMEMORY_EMAIL
|
"de": config.MYMEMORY_EMAIL
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
@@ -294,11 +294,132 @@ async def lookup_term(query: str) -> Dict[str, Any]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
|
is_cyrillic = bool(re.search(r"[\u0400-\u04FF]", query_clean))
|
||||||
words = query_clean.split()
|
words = query_clean.split()
|
||||||
is_sentence = len(words) > 4
|
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:
|
if not is_sentence:
|
||||||
try:
|
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(
|
off_row = conn.execute(
|
||||||
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
|
||||||
(query_clean,)
|
(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()]
|
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
|
||||||
res = {
|
res = {
|
||||||
"query": query_clean,
|
"query": query_clean,
|
||||||
|
"term": off_row["term"] or query_clean,
|
||||||
"is_sentence": False,
|
"is_sentence": False,
|
||||||
|
"is_reverse": False,
|
||||||
"translation": clean_trans,
|
"translation": clean_trans,
|
||||||
"translations_list": tr_list,
|
"translations_list": tr_list,
|
||||||
"translation_ru": clean_trans,
|
"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))
|
(query_clean, json.dumps(res, ensure_ascii=False))
|
||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
"query": query_clean,
|
"query": query_clean,
|
||||||
|
"term": query_clean,
|
||||||
"is_sentence": is_sentence,
|
"is_sentence": is_sentence,
|
||||||
|
"is_reverse": False,
|
||||||
"translation": "",
|
"translation": "",
|
||||||
"translations_list": [],
|
"translations_list": [],
|
||||||
"phonetic": "",
|
"phonetic": "",
|
||||||
|
|||||||
+5
-1
@@ -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'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL | re.IGNORECASE)
|
||||||
body = re.sub(r'<noscript\b[^>]*>.*?</noscript>', '', 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
|
# 3. Strip unwanted wiki elements
|
||||||
# Remove references: <sup class="reference">...</sup> and bracketed reference markers
|
# 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)
|
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):
|
if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE):
|
||||||
return ""
|
return ""
|
||||||
cleaned_tbl = re.sub(
|
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,
|
tbl_html,
|
||||||
flags=re.IGNORECASE
|
flags=re.IGNORECASE
|
||||||
|
|||||||
@@ -38,11 +38,15 @@ async def api_lookup(
|
|||||||
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
raise HTTPException(status_code=400, detail="Query term cannot be empty")
|
||||||
data = await lookup_term(query_str)
|
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
|
# Check if this term already exists in user's dictionary
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
existing = conn.execute(
|
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(?))",
|
"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"], query_str),
|
(user["id"], target_term, query_str),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
data["in_library"] = True
|
data["in_library"] = True
|
||||||
|
|||||||
@@ -924,6 +924,54 @@
|
|||||||
.card-bottom {
|
.card-bottom {
|
||||||
font-size: 11px;
|
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;
|
||||||
}
|
}
|
||||||
@@ -492,13 +492,22 @@ export const api = {
|
|||||||
throw new Error(`Failed to get articles: ${resp.status}`);
|
throw new Error(`Failed to get articles: ${resp.status}`);
|
||||||
},
|
},
|
||||||
async submitDebrief(payload) {
|
async submitDebrief(payload) {
|
||||||
const resp = await fetch('/api/lessons/debrief', {
|
const controller = new AbortController();
|
||||||
method: 'POST',
|
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try {
|
||||||
body: JSON.stringify(payload)
|
const resp = await fetch('/api/lessons/debrief', {
|
||||||
});
|
method: 'POST',
|
||||||
if (resp.ok) return await resp.json();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
throw new Error(`Failed to submit debrief: ${resp.status}`);
|
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) {
|
async getHistory(limit = 15) {
|
||||||
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
|
const resp = await fetch(`/api/lessons/history?limit=${limit}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
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 allCards = [];
|
||||||
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
||||||
@@ -67,7 +67,7 @@ export function initDictionary() {
|
|||||||
const batchCancelBtn = document.getElementById('batch-cancel-btn');
|
const batchCancelBtn = document.getElementById('batch-cancel-btn');
|
||||||
|
|
||||||
// Elements - Modals
|
// 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 closeModalBtn = document.getElementById('close-modal-btn');
|
||||||
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
||||||
const modalSaveBtn = document.getElementById('modal-save-btn');
|
const modalSaveBtn = document.getElementById('modal-save-btn');
|
||||||
@@ -165,8 +165,9 @@ export function initDictionary() {
|
|||||||
|
|
||||||
if (audioPlayBtn) {
|
if (audioPlayBtn) {
|
||||||
audioPlayBtn.addEventListener('click', () => {
|
audioPlayBtn.addEventListener('click', () => {
|
||||||
if (!currentLookupData || !currentLookupData.term) return;
|
const term = currentLookupData?.term || currentLookupData?.english_term;
|
||||||
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(currentLookupData.term)}&lang=en`;
|
if (!term) return;
|
||||||
|
const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
|
||||||
playAudio(url);
|
playAudio(url);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -192,7 +193,7 @@ export function initDictionary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayLookupResult(data) {
|
function displayLookupResult(data) {
|
||||||
resTerm.textContent = data.term;
|
resTerm.textContent = data.term || data.english_term || data.query || '';
|
||||||
resPhonetic.textContent = data.phonetic || '';
|
resPhonetic.textContent = data.phonetic || '';
|
||||||
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
||||||
resPos.textContent = data.part_of_speech || 'Phrase';
|
resPos.textContent = data.part_of_speech || 'Phrase';
|
||||||
@@ -210,6 +211,8 @@ export function initDictionary() {
|
|||||||
resInLibrary.classList.toggle('hidden', !inLib);
|
resInLibrary.classList.toggle('hidden', !inLib);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetTrans = (data.is_reverse ? (data.russian_query || data.translation) : data.translation) || '';
|
||||||
|
|
||||||
if (inLib) {
|
if (inLib) {
|
||||||
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
const lib = data.library_card || foundDuplicateCard;
|
const lib = data.library_card || foundDuplicateCard;
|
||||||
@@ -220,11 +223,11 @@ export function initDictionary() {
|
|||||||
if (lib.tags) editTags.value = lib.tags;
|
if (lib.tags) editTags.value = lib.tags;
|
||||||
if (lib.notes) editNotes.value = lib.notes;
|
if (lib.notes) editNotes.value = lib.notes;
|
||||||
} else {
|
} else {
|
||||||
editTranslation.value = data.translation || '';
|
editTranslation.value = targetTrans;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||||
editTranslation.value = data.translation || '';
|
editTranslation.value = targetTrans;
|
||||||
editContext.value = '';
|
editContext.value = '';
|
||||||
editNotes.value = '';
|
editNotes.value = '';
|
||||||
editCollection.value = selectedCollection || 'General';
|
editCollection.value = selectedCollection || 'General';
|
||||||
@@ -247,7 +250,7 @@ export function initDictionary() {
|
|||||||
|
|
||||||
async function saveNewCard() {
|
async function saveNewCard() {
|
||||||
if (!currentLookupData) return;
|
if (!currentLookupData) return;
|
||||||
const term = currentLookupData.term.trim();
|
const term = (currentLookupData.term || currentLookupData.english_term || currentLookupData.query || '').trim();
|
||||||
const translation = editTranslation.value.trim();
|
const translation = editTranslation.value.trim();
|
||||||
if (!translation) {
|
if (!translation) {
|
||||||
alert('Пожалуйста, укажите русский перевод');
|
alert('Пожалуйста, укажите русский перевод');
|
||||||
@@ -388,14 +391,19 @@ export function initDictionary() {
|
|||||||
<span class="card-term">${escapeHtml(c.term)}</span>
|
<span class="card-term">${escapeHtml(c.term)}</span>
|
||||||
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</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>` : ''}
|
${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>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
|
<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>
|
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-translation">${escapeHtml(c.translation)}</div>
|
<div class="card-translation">${formatTranslationWithSpoiler(c.translation)}</div>
|
||||||
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</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-bottom">
|
||||||
<div class="card-tags">
|
<div class="card-tags">
|
||||||
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
|
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
|
||||||
@@ -405,6 +413,18 @@ export function initDictionary() {
|
|||||||
</div>
|
</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
|
// Edit button
|
||||||
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
|
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
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 presets = [];
|
||||||
let currentPreset = null;
|
let currentPreset = null;
|
||||||
@@ -762,20 +762,20 @@ async function runVocabStep(step, container) {
|
|||||||
// Mode A: 4 Options Quiz
|
// Mode A: 4 Options Quiz
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
function renderQuizCard(card) {
|
function renderQuizCard(card) {
|
||||||
const correctText = card.translation;
|
const correctText = cleanPromptTranslation(card.translation);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...cards.filter(c => c.id !== card.id && c.translation).map(c => c.translation),
|
...cards.filter(c => c.id !== card.id && c.translation).map(c => cleanPromptTranslation(c.translation)),
|
||||||
...distractorPool.filter(t => t && t !== correctText)
|
...distractorPool.filter(t => t && cleanPromptTranslation(t) !== correctText).map(t => cleanPromptTranslation(t))
|
||||||
];
|
];
|
||||||
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
||||||
shuffleArray(uniqueCandidates);
|
shuffleArray(uniqueCandidates);
|
||||||
const options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
let options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
||||||
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
||||||
while (options.length < 4) {
|
while (options.length < 4) {
|
||||||
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
||||||
options.push(f);
|
options.push(f);
|
||||||
}
|
}
|
||||||
shuffleArray(options);
|
options = shuffleArray(options);
|
||||||
|
|
||||||
let answered = false;
|
let answered = false;
|
||||||
|
|
||||||
@@ -1050,7 +1050,7 @@ async function runVocabStep(step, container) {
|
|||||||
|
|
||||||
<div class="step-prompt-display text-center mt-3">
|
<div class="step-prompt-display text-center mt-3">
|
||||||
<span class="text-muted text-sm">Переведите на английский:</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
${card.context_sentence ? `
|
${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 id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
|
||||||
🔊 Прослушать слово (Пробел)
|
🔊 Прослушать слово (Пробел)
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<form id="lesson-dictation-form" class="mt-4">
|
<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 data = await api.listening.getExercise(step.topic || 'all');
|
||||||
const scenario = data.scenario;
|
const scenario = (data && (data.scenario || data)) || {};
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="step-card-box">
|
<div class="step-card-box">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// =======================================================================
|
// =======================================================================
|
||||||
|
|
||||||
import { api } from './api.js';
|
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';
|
import { getAllCards } from './dictionary.js';
|
||||||
|
|
||||||
let srsQueue = [];
|
let srsQueue = [];
|
||||||
@@ -330,7 +330,7 @@ export function initSrs() {
|
|||||||
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
||||||
} else {
|
} else {
|
||||||
fcFrontDirBadge.textContent = 'RU ➔ EN';
|
fcFrontDirBadge.textContent = 'RU ➔ EN';
|
||||||
fcFrontMain.textContent = card.translation;
|
fcFrontMain.textContent = cleanPromptTranslation(card.translation);
|
||||||
fcFrontPos.textContent = card.part_of_speech || '';
|
fcFrontPos.textContent = card.part_of_speech || '';
|
||||||
fcFrontPhoneticWrap.style.display = 'none';
|
fcFrontPhoneticWrap.style.display = 'none';
|
||||||
fcHintBtn.style.display = 'none';
|
fcHintBtn.style.display = 'none';
|
||||||
@@ -447,18 +447,18 @@ export function initSrs() {
|
|||||||
|
|
||||||
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
|
if (quizAudioBtn) quizAudioBtn.classList.remove('hidden');
|
||||||
|
|
||||||
const correctText = card.translation;
|
const correctText = cleanPromptTranslation(card.translation);
|
||||||
const all = getAllCards();
|
const all = getAllCards();
|
||||||
const candidates = all
|
const candidates = all
|
||||||
.filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
|
.filter(c => c.id !== card.id && c.translation && cleanPromptTranslation(c.translation) !== correctText)
|
||||||
.map(c => c.translation);
|
.map(c => cleanPromptTranslation(c.translation));
|
||||||
|
|
||||||
shuffleArray(candidates);
|
shuffleArray(candidates);
|
||||||
const options = [correctText, ...candidates.slice(0, 3)];
|
let options = [correctText, ...candidates.slice(0, 3)];
|
||||||
while (options.length < 4) {
|
while (options.length < 4) {
|
||||||
options.push(`Вариант ${options.length + 1}`);
|
options.push(`Вариант ${options.length + 1}`);
|
||||||
}
|
}
|
||||||
shuffleArray(options);
|
options = shuffleArray(options);
|
||||||
quizCurrentOptions = options;
|
quizCurrentOptions = options;
|
||||||
|
|
||||||
quizOptionsGrid.innerHTML = '';
|
quizOptionsGrid.innerHTML = '';
|
||||||
@@ -543,7 +543,7 @@ export function initSrs() {
|
|||||||
typingRatingBar.classList.add('hidden');
|
typingRatingBar.classList.add('hidden');
|
||||||
|
|
||||||
typingPos.textContent = card.part_of_speech || '';
|
typingPos.textContent = card.part_of_speech || '';
|
||||||
typingPromptRu.textContent = card.translation;
|
typingPromptRu.textContent = cleanPromptTranslation(card.translation);
|
||||||
if (card.context_sentence) {
|
if (card.context_sentence) {
|
||||||
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
|
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
|
||||||
typingContextHint.classList.remove('hidden');
|
typingContextHint.classList.remove('hidden');
|
||||||
@@ -590,7 +590,7 @@ export function initSrs() {
|
|||||||
clozeRatingBar.classList.add('hidden');
|
clozeRatingBar.classList.add('hidden');
|
||||||
|
|
||||||
clozePos.textContent = card.part_of_speech || '';
|
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 context = card.context_sentence || `The word is ${card.term}.`;
|
||||||
const re = new RegExp(escapeRegex(card.term), 'gi');
|
const re = new RegExp(escapeRegex(card.term), 'gi');
|
||||||
@@ -635,7 +635,7 @@ export function initSrs() {
|
|||||||
dictationDetailsBox.classList.add('hidden');
|
dictationDetailsBox.classList.add('hidden');
|
||||||
dictationRatingBar.classList.add('hidden');
|
dictationRatingBar.classList.add('hidden');
|
||||||
dictationHintText.classList.add('hidden');
|
dictationHintText.classList.add('hidden');
|
||||||
dictationHintText.textContent = card.translation;
|
dictationHintText.textContent = cleanPromptTranslation(card.translation);
|
||||||
|
|
||||||
dictationInput.value = '';
|
dictationInput.value = '';
|
||||||
dictationInput.disabled = false;
|
dictationInput.disabled = false;
|
||||||
|
|||||||
@@ -27,12 +27,26 @@ export function slugify(text) {
|
|||||||
|
|
||||||
export function shuffleArray(array) {
|
export function shuffleArray(array) {
|
||||||
if (!array || !Array.isArray(array)) return [];
|
if (!array || !Array.isArray(array)) return [];
|
||||||
const arr = [...array];
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
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,
|
updateHeaderStats,
|
||||||
playAudio,
|
playAudio,
|
||||||
playTextSpeech,
|
playTextSpeech,
|
||||||
|
cleanPromptTranslation,
|
||||||
|
formatTranslationWithSpoiler,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ui;
|
export default ui;
|
||||||
+2
-2
@@ -18,7 +18,7 @@ services:
|
|||||||
- DB_PATH=/app/data/notes.db
|
- DB_PATH=/app/data/notes.db
|
||||||
- OLLAMA_BASE_URL=http://ollama:11434
|
- OLLAMA_BASE_URL=http://ollama:11434
|
||||||
- OLLAMA_MODEL=qwen2.5:3b
|
- OLLAMA_MODEL=qwen2.5:3b
|
||||||
- OLLAMA_KEEP_ALIVE=5m
|
- OLLAMA_KEEP_ALIVE=1h
|
||||||
depends_on:
|
depends_on:
|
||||||
- ollama
|
- ollama
|
||||||
|
|
||||||
@@ -31,4 +31,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/ollama:/root/.ollama
|
- ./data/ollama:/root/.ollama
|
||||||
environment:
|
environment:
|
||||||
- OLLAMA_KEEP_ALIVE=5m
|
- OLLAMA_KEEP_ALIVE=1h
|
||||||
@@ -2497,6 +2497,56 @@ def test_lessons_debrief_and_history():
|
|||||||
assert last_session["errors_count"] == 2
|
assert last_session["errors_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_dictionary_lookup():
|
||||||
|
"""
|
||||||
|
BUG-06 regression test: Searching Russian words should perform reverse lookup,
|
||||||
|
returning the corresponding English term, phonetics, and Russian meaning.
|
||||||
|
"""
|
||||||
|
from app.auth import _login_failures
|
||||||
|
_login_failures.clear()
|
||||||
|
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
|
||||||
|
assert login_resp.status_code == 200
|
||||||
|
token = login_resp.json()["token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
resp = client.get("/api/lookup?q=собака", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_reverse"] is True
|
||||||
|
assert data["term"].lower() == "dog"
|
||||||
|
assert "собака" in data["translation"].lower()
|
||||||
|
assert data["phonetic"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_wikipedia_parsoid_html_cleaning():
|
||||||
|
"""
|
||||||
|
BUG-05 regression test: Wikipedia Parsoid HTML containing data-mw attributes with
|
||||||
|
embedded <ref> and literal '>' chars should be cleanly stripped without leaking into text.
|
||||||
|
"""
|
||||||
|
from app.reader import clean_wiki_html
|
||||||
|
|
||||||
|
sample_html = (
|
||||||
|
'<body>'
|
||||||
|
'<span class="mw-empty-elt" about="#mwt1" typeof="mw:Transclusion" '
|
||||||
|
'data-mw=\'{"parts":[{"template":{"target":{"wt":"Infobox"},"params":{"title":{"wt":"Test<ref>RefText</ref>"}}}]}\'>'
|
||||||
|
'</span>'
|
||||||
|
'<p>Linux is a Unix-like operating system.</p>'
|
||||||
|
'<table style="font-family:\'Times New Roman\', serif;"><tr><td>Clean Cell</td></tr></table>'
|
||||||
|
'</body>'
|
||||||
|
)
|
||||||
|
chapters, cover, words = clean_wiki_html(sample_html, "Test Article")
|
||||||
|
assert len(chapters) >= 1
|
||||||
|
content = chapters[0]["content"]
|
||||||
|
assert "data-mw" not in content
|
||||||
|
assert "{" not in content
|
||||||
|
assert "Infobox" not in content
|
||||||
|
assert "RefText" not in content
|
||||||
|
assert "Linux is a Unix-like operating system." in content
|
||||||
|
assert "Times New Roman" not in content # stripped cleanly from table
|
||||||
|
assert "Clean Cell" in content
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user