Compare commits

20 Commits
Author SHA1 Message Date
Antigravity 789960dc74 docs(architecture): document specialized Qwen2.5-0.5B + CTranslate2 fine-tuning architecture 2026-10-09 15:34:06 +03:00
Antigravity 7871191c90 fix & feat: resolve user reports #1 through #8 (TTS voice & speed, modals, scrollbar, CEFR hint, SRS grammar exclusion, global word lookup) 2026-10-08 12:37:41 +03:00
Antigravity 8cffae46fa feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync 2026-10-08 11:53:18 +03:00
Antigravity e1cacde543 fix(reader): keep word translation popup as compact block on desktop without full-width stretch 2026-10-08 11:46:44 +03:00
Antigravity f10965eff5 feat(reader): implement FEAT-01 Focus Zen Mode (100% vh/vw, auto-hiding HUD, hotkeys F/Esc, native fullscreen) 2026-10-08 10:45:09 +03:00
Antigravity 9bbc9473de fix: resolve bugs 01 through 08 and set Ollama TTL to 1h 2026-10-07 19:19:49 +03:00
Antigravity a01bf64445 docs: update BUG-08 with 1h TTL keep-alive proposal 2026-10-07 19:05:12 +03:00
Antigravity b43f34fdfd docs: register BUG-07, BUG-08 and FEAT-02..FEAT-05 in roadmap 2026-10-07 19:03:42 +03:00
Antigravity c433156646 docs: add Focus Mode (Zen Reader) feature to roadmap 2026-10-07 16:36:26 +03:00
Antigravity 84ce77d35f docs: register BUG-05 and BUG-06 in bug backlog 2026-10-07 16:25:14 +03:00
Antigravity ef3e9b0866 docs: register BUG-03 and BUG-04 in bug backlog 2026-10-07 16:21:50 +03:00
Antigravity 3488e35bba docs: record BUG-01 (edit modal) and BUG-02 (dictionary audio) in TODO ledger 2026-10-07 16:14:03 +03:00
Antigravity 3b1cb85ba2 fix(ai): configure OLLAMA_KEEP_ALIVE=5m to automatically release RAM after inactivity 2026-10-07 15:30:36 +03:00
Antigravity 03c692fe43 docs: update README, PLAN, TODO, MENTOR_CONTEXT with full implemented functionality and architecture 2026-10-07 15:20:44 +03:00
Antigravity c89b0d6238 chore: add deploy_beta and rollback_to_main management scripts 2026-10-07 14:57:19 +03:00
Antigravity 6fb086a878 refactor(stage-5): modularize style.css into domain stylesheets and update service worker precache 2026-10-07 14:52:45 +03:00
Antigravity dde87e5d6c refactor(stage-4): partition monolithic main.py into modular APIRouters 2026-10-07 14:50:21 +03:00
Antigravity 4e1f809018 refactor(stage-3): add Keep-Alive connection pool for Ollama, implement offline lemma resolution in dictionary 2026-10-07 14:45:05 +03:00
Antigravity 9072fc365a refactor(stage-2): extract hardcoded curriculum datasets into data/seeds/*.json (-3100 lines) 2026-10-07 14:43:06 +03:00
Antigravity 20eef2c684 refactor(stage-1): eliminate dead parse_text, simplify task progress SQL, remove prompt hacks 2026-10-07 14:39:53 +03:00
65 changed files with 21707 additions and 14555 deletions
+1
View File
@@ -19,6 +19,7 @@ data/audio_cache/
data/*.db
data/*.db-wal
data/*.db-shm
data/*.backup*
# Environment & Secret variables
.env
+15
View File
@@ -161,3 +161,18 @@ CREATE TABLE IF NOT EXISTS user_grammar_weaknesses (
- [ ] Оффлайн-кэширование аудио предложений в `data/audio_cache/` со сжатием в Opus и автоочисткой по TTL 7 дней (Zero Bloat).
- [ ] Фоновый экспорт главы в аудиокнигу для дороги и прогулок.
- [ ] Интерактивный Shadowing (прослушивание $\rightarrow$ повтор в микрофон $\rightarrow$ клиентская проверка).
---
### Этап 8: 🧠 Специализированная микро-модель перевода (Qwen2.5-0.5B + CTranslate2)
- [ ] Полная архитектурная спецификация: `docs/architecture/specialized-translation-model.md`
- [ ] **Цель**: Разгрузить сервер, снизить задержку читалки до 100–150 мс и избавиться от сетевого оверхеда Ollama при чтении.
- [ ] **Разделение ролей**:
- Читалка (контекстный перевод предложений + целевое слово в JSON): `Qwen2.5-0.5B-Instruct` через CTranslate2 (in-process в Python, ~350 МБ RAM).
- Итоги урока (Debrief): `Qwen2.5-3B` on-demand (Ollama `keep_alive: 5m`) или детерминированный движок Мерфи.
- [ ] **Пайплайн реализации**:
- [ ] Сборка синтетического датасета `train.jsonl` (1500–3000 пар `(sentence, word, context, hints) -> JSON`).
- [ ] LoRA-дообучение на CPU VPS (AMD Ryzen 9 5950X, ~25–45 мин).
- [ ] Слияние и конвертация в CTranslate2 `int8` (`ct2-transformers-converter`).
- [ ] Прямая интеграция `ctranslate2.Generator` в `app/ai.py` с сохранением кэша SQLite.
+5 -3
View File
@@ -1,11 +1,13 @@
# Workspace Context & English Mentor Rules
# Workspace Context & English Mentor Rules: study.qpid.pro
> This file is automatically discovered by the Antigravity agent in this workspace.
## English Mentor & Tech Pair Programmer Role
- The assistant acts as a dedicated **English Language Mentor** and **Senior Software Engineer** working on `notes.qpid.pro`.
- Full mentor instructions, pedagogical framework, and project session context are maintained in [MENTOR_CONTEXT.md](file:///home/kopri/notes.qpid.pro/MENTOR_CONTEXT.md), [PEDAGOGY.md](file:///home/kopri/notes.qpid.pro/PEDAGOGY.md), and [PLAN.md](file:///home/kopri/notes.qpid.pro/PLAN.md).
- The assistant acts as a dedicated **English Language Mentor** and **Senior Software Engineer** working on `study.qpid.pro`.
- Full mentor instructions, pedagogical framework, and project session context are maintained in [MENTOR_CONTEXT.md](MENTOR_CONTEXT.md), [PEDAGOGY.md](PEDAGOGY.md), [PLAN.md](PLAN.md), and [README.md](README.md).
- When discussing vocabulary or practicing English:
- Focus on the **Lexical Approach** (chunks, collocations, idiomatic usage).
- Provide IPA transcriptions, pronunciation tips, and realistic contextual examples.
- Give constructive, encouraging feedback and natural phrasing improvements.
- When working on codebase:
- Follow the **Ponytail principles**: minimal code, standard library & native platform features first, 100% test passing (`pytest tests/test_api.py`), offline-first resilience.
+23 -47
View File
@@ -1,15 +1,15 @@
# 🎓 Mentor Context & Session State: English & notes.qpid.pro
# 🎓 Mentor Context & Session State: English & study.qpid.pro
> **Файл контекста сессии и роли ментора по английскому языку**
> Ссылка на проект: [notes.qpid.pro](https://notes.qpid.pro)
> Директория проекта: `/home/kopri/notes.qpid.pro`
> Ссылка на проект: [study.qpid.pro](https://study.qpid.pro)
> Директория проекта: `/home/ai-projects/services/study.qpid.pro`
> Назначение файла: хранение роли ментора, педагогических принципов, архитектурного контекста и прогресса студента.
---
## 🧑‍🏫 Роль: Твой персональный ментор по английскому языку
Ты выступаешь в роли **опытного, поддерживающего и практико-ориентированного ментора по английскому языку** с сильным фокусом на IT/Tech и живую разговорную речь.
Ты выступаешь в роли **опытного, поддерживающего и практико-ориентированного ментора по английскому языку** с сильным фокусом на IT/Tech, живую разговорную речь и методику *Comprehensible Input*.
### Ключевые функции ментора:
1. **Разбор лексики и устойчивых выражений (The Lexical Approach)**:
@@ -22,31 +22,32 @@
4. **Тактичная коррекция ошибок (Constructive Feedback)**:
- Если пользователь пишет на английском с ошибкой, мягко показать естественный вариант (natural phrasing) и объяснить причину.
5. **Интерактивные тренировки**:
- Генерировать карточки, упражнения с пропусками (Cloze deletions), квизы на идиомы и фразовые глаголы для добавления в сервис `notes.qpid.pro`.
- Генерировать карточки, упражнения с пропусками (Cloze deletions), квизы на идиомы и фразовые глаголы для добавления в сервис `study.qpid.pro`.
---
## 📱 О проекте `notes.qpid.pro`
## 📱 О проекте `study.qpid.pro`
- **Что это**: Защищённый персональный тренажёр и словарь английского языка (PWA / Web-сервис).
- **Сайт**: [https://notes.qpid.pro](https://notes.qpid.pro) (IP: `31.77.203.213`)
- **Что это**: Защищённый персональный тренажёр, читалка и словарь английского языка (PWA / Docker / Web-сервис).
- **Сайт**: [https://study.qpid.pro](https://study.qpid.pro)
- **Репозиторий**: [ai.qpid.pro/gitea/Antigravity/notes-qpid-pro](https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)
- **Техстек**:
- Backend: Python 3.12 (FastAPI, Uvicorn, SQLite WAL)
- Frontend: Vanilla JS / Modern CSS / HTML5 PWA (без тяжёлых фреймворков)
- Сервер: Nginx Reverse Proxy + SSL Let's Encrypt + Systemd
- Безопасность: PBKDF2 пароли, HttpOnly Cookies, CSRF/Rate Limiter
- Интеграции: Free Dictionary API (IPA + audio), MyMemory API
- Backend: Python 3.12 (FastAPI, Uvicorn, SQLite WAL, 11 модульных APIRouters)
- Frontend: Vanilla JS / Modern CSS (7 предметных таблиц) / HTML5 PWA (Service Worker v6.7)
- Сервер: Docker, Nginx Reverse Proxy, SSL Let's Encrypt
- Локальный ИИ: Ollama + Qwen 2.5 3B (контекстный перевод предложений, педагогический дебрифинг уроков)
- Озвучка: Piper Neural TTS (чистый нативный голос VITS без внешних API)
- Тестирование: 44/44 проходящих интеграционных теста в Pytest.
---
## 🧠 5 Золотых педагогических принципов проекта
1. **Лексические блоки (Chunks)**: Учить готовые связки, а не отдельные слова.
2. **Контекстный якорь**: Каждое слово сохраняется в предложении из реальной ситуации.
3. **Фонетический якорь (IPA + Audio)**: Закрепление звучания на слух, исключение субвокализации с ошибками.
4. **Интервальные повторения (SRS / SM-2)**: Повторение по кривой забывания Эббингауза (1, 2, 5, 12, 28, 65 дней).
5. **Активное припоминание (Active Recall)**: Двусторонние карточки + упражнения с пропусками.
2. **Контекстный якорь**: Каждое слово сохраняется в предложении из реальной ситуации (книга, статья, фильм).
3. **Фонетический якорь (IPA + Neural Audio)**: Закрепление звучания на слух, исключение субвокализации с ошибками.
4. **Интервальные повторения (SRS / SM-2)**: Повторение по кривой забывания Эббингауза (1, 6, $I \times EF$ дней) с интерливингом (Smart Daily Mix).
5. **Активное припоминание (Active Recall)**: Двусторонние 3D-карточки, умный квиз, активный набор текста руками и упражнения Cloze Tap.
---
@@ -55,7 +56,7 @@
Когда ты начинаешь новый диалог или хочешь активировать этот режим, просто напиши:
- `Вспомни контекст ментора MENTOR_CONTEXT.md` или `Работай по MENTOR_CONTEXT.md`
- `Проверь мое предложение на английском: "..."`
- `Разбери слово/идиому "..." (транскрипция, примеры, связки, карточка для notes.qpid.pro)`
- `Разбери слово/идиому "..." (транскрипция, примеры, связки, карточка для study.qpid.pro)`
- `Дай 5 полезных phrasal verbs для IT/dev на сегодня`
- `Давай попрактикуем диалог на английском на тему ...`
@@ -66,32 +67,7 @@
| Дата | Тема / Фокус | Ключевые концепции | Статус |
| :--- | :--- | :--- | :--- |
| *Инициализация* | Запуск проекта notes.qpid.pro & MVP | SRS, Lexical approach, Contextual anchoring | Выполнено |
| `2026-09-23` | Стратегия начитки тех. текстов (Extensive Reading) & Умные тесты | Чтение без упора на грамматику (85-90% Comprehensible input), выжимка связок (chunks), умный микс в тестах (Новые + Повтор + Проблемные/Leeches), активный ввод Ru->En | Зафиксировано, переход к чтению |
---
## 🎯 Сформулированные требования к умному тренажеру (Smart Daily Quiz)
1. **Сбалансированная сессия на день (Smart Mix)**:
- ~10 новых добавленных слов (New)
- ~10 слов по интервальной очереди (Review / Due SRS)
- ~5 проблемных слов (Leeches / High-error rate)
- Случайное перемешивание (shuffled) в одной тренировке.
2. **Проблемные слова (Leech Detection)**:
- Автоматическая маркировка карточек с частыми ошибками и повышение частоты их появления.
3. **Режим активного ввода (Active Typing / Production)**:
- Показ русского значения / контекста с пропуском $\rightarrow$ набор правильного английского слова руками для моторной памяти и спеллинга.
4. **Контроль дубликатов при пополнении словаря (Duplicate Prevention & Merging)**:
- При добавлении слова/фразы проверять наличие в словаре.
- Вместо создания дубликата предлагать дополнить существующую карточку новым примером контекста/значением или отредактировать её.
5. **Строгая оценка уровня владения (Active CEFR Vocabulary Weighting)** *(В планах)*:
- Считать уровень не по общему числу карточек, а по реальному активному объёму: слова в статусах `Mastered` (интервал > 21 дня) и `Review` с весовыми коэффициентами.
- Учитывать Retention Rate % как множитель уверенности.
- Показывать индикатор прогресса (сколько слов осталось выучить до следующего уровня).
6. **Интерактивная читалка и авто-тегирование источников (Reader & Source-scoped Learning)** *(В планах)*:
- Автоматическое присвоение тегов при сохранении из читалки: `#название-книги`, `#жанр`, `#автор`.
- Запуск любых SRS-тренировок и квизов с фильтром по тегу источника (например, повторить только слова из *Fight Club* или *Clean Code*).
- Подсветка в тексте книги уже изучаемых и выученных слов, перевод фраз/chunks при выделении.
| `2026-09-23` | Стратегия начитки тех. текстов (Extensive Reading) & Умные тесты | Чтение без упора на грамматику (85-90% Comprehensible input), выжимка связок (chunks), умный микс в тестах (Новые + Повтор + Leeches), активный ввод Ru->En | Выполнено |
| `2026-10-06` | Интеграция читалки (EPUB/PDF/RFC/Wikipedia) & Piper TTS | Офлайн-читалка, IETF RFC стандарты, Wikipedia статьи, локальная нейро-озвучка Piper | Выполнено |
| `2026-10-07` | Murphy Grammar, CEFR Cloze Tap & Комплексный урок | 15 юнитов Мерфи, тесты A1–B2, gap-ингестия, конструктор уроков, сессионный раннер, ИИ-дебрифинг Qwen 2.5 3B | Выполнено |
| `2026-10-07` | Полный рефакторинг архитектуры (5 этапов) | Модуляризация бэкенда (11 роутеров), вынос датасетов в JSON (-3100 строк), модульные CSS, Keep-Alive пул Ollama | Выкачено на прод study.qpid.pro |
+92 -156
View File
@@ -1,175 +1,111 @@
# План развития проекта Notes (notes.qpid.pro)
# План развития и архитектурная дорожная карта: Study Qpid (study.qpid.pro)
> **Принцип проектирования**: *«Работает как автомат Калашникова»* — сверхнадёжно, мгновенно, защищено для публичного интернета, без раздутых зависимостей и постоянного ручного обслуживания.
> **Принцип проектирования**: *«Работает как автомат Калашникова»* — сверхнадёжно, мгновенно, полностью автономно, защищено для публичного интернета, без раздутых зависимостей и без привязки к платным внешним API.
---
## 1. Архитектура и защита системы
## 1. Архитектура системы
Сервис разворачивается на публичном сервере с белым IP (`31.77.203.213`). Вся система проектируется с учётом сетевой безопасности и изоляции личных данных.
Сервис разворачивается в Docker-окружении на защищённом сервере за Nginx с SSL Let's Encrypt (`study.qpid.pro`).
```
┌─────────────────────────────────────────────────────────────┐
│ notes.qpid.pro │
│ study.qpid.pro │
│ Nginx (HTTPS / Let's Encrypt / Security Headers) │
└──────────────────────────────┬──────────────────────────────┘
│ proxy_pass :8002
│ proxy_pass 127.0.0.1:8002
┌──────────────────────────────▼──────────────────────────────┐
│ Слой безопасности (Auth) │
│ • Аутентификация: сессионные токены (HTTP-only cookies) │
│ • Хэширование паролей: PBKDF2-HMAC-SHA256 (соль + 100k) │
│ • Защита от перебора (Rate Limiter: макс 5 попыток/мин) │
│ • Закрытый доступ: все эндпоинты требуют авторизации │
│ Docker Container: study-qpid-pro │
│ │
│ • FastAPI Lifespan & Security Middlewares (GZip, Headers) │
│ • Модульные APIRouters (auth, cards, srs, reader, etc.) │
│ • SQLite WAL + mmap I/O (транзакции, 0.01ms чтение) │
│ • Локальный Piper Neural TTS (чистый нативный голос VITS) │
│ • Вынесенные JSON-датасеты (data/seeds/) │
│ • Модульные CSS-стили (7 предметных таблиц + PWA SW v6.7) │
└──────────────────────────────┬──────────────────────────────┘
│
│ http://ollama:11434 (Keep-Alive 120s)
┌──────────────────────────────▼──────────────────────────────┐
│ Backend: Python 3.12 │
│ FastAPI / Uvicorn + SQLite (WAL mode) + Dictionary APIs │
└──────────────────────────────┬──────────────────────────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Внешние открытые API │ │ Локальная БД SQLite │
│ • Free Dictionary API │ │ • users (аккаунт, пароль) │
│ (IPA, audio, definitions) │ │ • vocabulary_cards │
│ • MyMemory / Translation │ │ • sessions (активные входы) │
│ (русский перевод фразы) │ │ • tags & categories │
└─────────────────────────────┘ └─────────────────────────────┘
│ Docker Container: study-ollama │
│ • Qwen 2.5 3B Instruct (контекстный перевод предложений │
│ и педагогический дебрифинг уроков) │
└─────────────────────────────────────────────────────────────┘
```
### Выбор технологий:
- **Безопасность**: Защищённые сессии (Signed HTTP-only cookie `SameSite=Lax`, `Secure`). Защита от CSRF, XSS и брутфорс-атак через встроенный rate limiter.
- **Backend**: Python 3.12 + FastAPI (асинхронный, легковесный, моментальный старт).
- **База данных**: SQLite в режиме `WAL` (Write-Ahead Logging). Идеальная транзакционная надежность, автономность, бэкап в один файл.
- **Frontend**: Чистый Vanilla ES6+ / Modern CSS / HTML5. Мгновенная загрузка (< 50 мс), оффлайн-кэш, адаптивность (смартфон + десктоп), поддержка хоткеев.
- **Внешние API (Zero-cost & Open)**:
1. *Free Dictionary API* (`api.dictionaryapi.dev`): фонетическая транскрипция IPA, аудио-произношение нативных дикторов (US/UK), части речи, толкования и примеры.
2. *MyMemory Translation API*: литературный перевод фраз, идиом и предложений на русский язык.
---
## 2. Реализованные этапы развития (Completed Roadmap)
### ✅ Этап 1: Защищённое ядро (MVP) и базовый словарь
- [x] Авторизация на сессионных куках (`HttpOnly`, `SameSite=Lax`, `Secure`).
- [x] Хэширование паролей `PBKDF2-HMAC-SHA256` с уникальной солью на пользователя.
- [x] Rate limiting для предотвращения атак перебором.
- [x] Офлайн-словарь (140k+ записей) с каскадным переводом.
- [x] Сохранение слов с контекстным предложением и горячими клавишами (`Ctrl+Enter`).
- [x] Журнал личного словаря, поиск, фильтрация по тегам и коллекциям.
### ✅ Этап 2: Организация, экспорт и PWA
- [x] Коллекции и теги, разбивка слов по категориям.
- [x] Пакетные операции над карточками (массовое перемещение, удаление, добавление тегов).
- [x] Экспорт данных в форматы: CSV, JSON, Anki TSV и Markdown.
- [x] PWA-манифест и Service Worker для офлайн-доступа к приложению.
### ✅ Этап 3: Алгоритм интервальных повторений (SRS SM-2) и тренажер
- [x] Реализация математической модели SuperMemo SM-2 (Ease Factor, Repetitions, Intervals).
- [x] 3D-флип карточки с градацией оценок (Снова, Трудно, Хорошо, Легко).
- [x] Режим Smart Quiz (выбор из 4 вариантов с дистракторами из той же части речи).
- [x] Режим активного ввода (Active Typing) с набором английского слова по русскому переводу.
- [x] Smart Daily Mix: сбалансированная подборка (10 новых + 10 повторов + 5 проблемных слов).
- [x] Детекция проблемных слов (Leeches) и повторный фокус на них.
- [x] Аналитика: календарь активности (Heatmap 180 дней), стрик дней, процент удержания (Retention Rate), оценка уровня CEFR.
### ✅ Этап 4: Интерактивная читалка и книжная полка
- [x] Поддержка книг и документов: EPUB, PDF, TXT, Markdown (`.md`).
- [x] Автоматический сборщик стандартов IETF RFC по номеру или URL.
- [x] Интеграция с Wikipedia: автономная загрузка статей с очисткой верстки под тайм-бюджет.
- [x] Интерактивный клик-перевод каждого слова с выводом контекста.
- [x] Добавление слова и цитаты в словарь в 1 клик прямо из текста.
- [x] Непрерывное чтение (continuous scroll) с карточками перехода между главами.
- [x] Темы оформления (Light, Dark, Sepia, Night, Parchment, Hacker) и типографика.
### ✅ Этап 5: Автономный локальный ИИ (Ollama) и нейро-озвучка (Piper)
- [x] Развертывание локального контейнера Ollama с моделью `qwen2.5:3b`.
- [x] Контекстный перевод предложений с учетом целевого слова и сохранением в кэш SQLite.
- [x] Локальный VITS-синтез речи Piper TTS (US Lessac, RU Dmitri) с нулевой задержкой.
- [x] Фоновое пред-кэширование произношения слов и предложений при добавлении в словарь.
- [x] Тренажёр аудирования (Listening) с проверкой понимания.
### ✅ Этап 6: Грамматика Murphy & Тестирование CEFR
- [x] 260 интерактивных заданий Cloze Tap по международным уровням A1–B2.
- [x] Автоматическая gap-ингестия: отправка ошибочных слов из тестов в активный тренажёр SRS.
- [x] Фоновый воркер майнинга тестов из загруженной литературы.
- [x] 15 базовых юнитов грамматики Murphy с формулами и теорией.
- [x] Синхронизация с рукописным бумажным конспектом.
### ✅ Этап 7: Конструктор комплексного урока & Сессионный раннер
- [x] Единый конструктор ежедневного урока: блоки слов (Flashcards/Quiz/Typing/Dictation), грамматики, аудирования, чтения.
- [x] Встроенные готовые шаблоны (Экспресс, Сбалансированный, Глубокий, Аудио+Чтение).
- [x] Адаптивный подбор темы грамматики («Самая слабая тема по ошибкам», «Предыдущая тема»).
- [x] Сквозная телеметрия ошибок сессии и интеллектуальный ИИ-дебрифинг от Qwen 2.5 3B.
- [x] Детерминированный фолбэк для мгновенного завершения сессии при выключенной Ollama.
### ✅ Этап 8: Глубокий технический рефакторинг и оптимизация (Beta-refactor)
- [x] **Этап 1 рефакторинга**: Удаление мертвого кода (`parse_text`), оптимизация SQL в фоновых задачах.
- [x] **Этап 2 рефакторинга**: Вынос 3 100+ строк захардкоженных датасетов в `data/seeds/*.json`.
- [x] **Этап 3 рефакторинга**: Keep-Alive пул соединений для Ollama (120s), офлайн-лемматизация в словаре.
- [x] **Этап 4 рефакторинга**: Модуляризация бэкенда (`app/main.py` сокращён с 2 240 до 142 строк, создано 11 APIRouters).
- [x] **Этап 5 рефакторинга**: Модуляризация CSS (`style.css` 8 746 строк разделен на 7 предметных файлов, обновлён прекэш SW v6.7).
- [x] 100% покрытие авто-тестами: 44/44 тестов проходят в Docker.
---
## 2. Этапы реализации (Roadmap)
### Этап 1 (Текущая итерация): Безопасное ядро (MVP) — Общий английский, перевод и фиксация
*Цель: создать защищённый, сверхбыстрый и надёжный персональный словарь для повседневного и профессионального английского языка.*
- [x] **1.1. Модуль защиты и авторизации**:
- Страница авторизации (минималистичный дизайн, вход по логину и паролю).
- Защита всех API эндпоинтов: неавторизованные запросы блокируются (`401 Unauthorized`).
- Надежное хранение паролей в SQLite (`PBKDF2-HMAC-SHA256` с уникальной солью).
- Rate Limiter на эндпоинт `/api/login` (не более 5 неудачных попыток в минуту с IP).
- Сессии через зашифрованные `HttpOnly` cookie.
- Кнопка «Выйти из аккаунта» в шапке сервиса.
- [x] **1.2. Быстрый поиск и каскадный перевод (General & Idiomatic English)**:
- Строка поиска в фокусе сразу при открытии страницы.
- Поддержка любых форм языка:
- Отдельные слова (существительные, глаголы, прилагательные);
- Фразовые глаголы (*run into*, *figure out*, *call off*);
- Идиомы и разговорные фразы (*once in a blue moon*, *take for granted*);
- Предложения целиком (из книг, сериалов, документации).
- Автоматическое получение:
- Фонетической транскрипции (IPA);
- Нативного аудио-произношения (US/UK);
- Частей речи и толкования на английском;
- Русского перевода.
- Кэширование словарных запросов в локальной базе для мгновенных повторных ответов.
- [x] **1.3. Контекстное сохранение слова в 1 клик**:
- Горячие клавиши:
- `Enter` — мгновенный перевод;
- `Ctrl + Enter` (или `Cmd + Enter`) — мгновенное сохранение в словарь;
- `Esc` — очистить ввод;
- `Ctrl + P` или клик на динамик — прослушать произношение.
- Возможность подправить перевод перед сохранением под свой контекст.
- Поле для ввода контекстного предложения (где встретилось слово/выражение).
- Выбор/ввод тегов (`#general`, `#phrasal-verb`, `#idiom`, `#daily`, `#networking`).
- [x] **1.4. Журнал личного словаря (History & Words List)**:
- Хронологическая лента всех сохраненных слов с датой и временем.
- Живой поиск по английским словам, русскому переводу и контексту.
- Фильтр по тегам в один клик.
- Воспроизведение аудио прямо из списка.
- Удаление / редактирование карточек.
- [x] **1.5. Инфраструктурное развертывание**:
- Конфигурация Nginx для `notes.qpid.pro` с proxy на бэкенд (`127.0.0.1:8002`).
- Systemd-сервис для автозапуска и перезапуска при сбоях.
---
### Этап 2: Организация, теги, экспорт и PWA
*Цель: гибкое управление растущим словарным запасом и удобство на мобильных.*
- [x] **2.1. Расширенная организация и контроль качества**:
- Коллекции и папки (например, «Разговорный», «Работа», «Книги/Сериалы», «Сети/IT»).
- Быстрое массовое тегирование и групповые действия (удаление, перемещение).
- **Контроль и предотвращение дубликатов (Duplicate Prevention & Merging)**:
- Проверка при вводе и сохранении (case-insensitive trim).
- Уведомление, если слово/фраза уже есть в словаре.
- Возможность дописать новый контекстный пример / значение к существующей карточке вместо пложения дублей или перейти к её редактированию.
- [x] **2.2. Экспорт данных**:
- Экспорт базы в CSV (UTF-8 BOM), JSON, Markdown.
- Экспорт в колоду Anki (`.tsv`) с аудио, контекстными предложениями и поддержкой Cloze deletion (`{{c1::word}}`).
- [x] **2.3. PWA (Progressive Web App)**:
- Установка на экран смартфона (iOS / Android) как нативное приложение.
- Service Worker, Web Manifest, оффлайн-кэш статики.
---
### Этап 3: Встроенный тренажер интервальных повторений (SRS / SM-2)
*Цель: перевод слов из пассивного словаря в активную долговременную память.*
- [x] **3.1. Движок SuperMemo-2 (SM-2) & Умная выборка**:
- Расчет интервалов забывания Эббингауза: `Again`, `Hard`, `Good`, `Easy`.
- **Умный микс дня (Daily Quiz Mix)**: баланс сессии (до 10 новых + 10 интервальных повторений + 5 проблемных слов в разнобой).
- **Детектор трудных слов (Leeches)**: автоматическая маркировка слов с частыми ошибками и их приоритетное подмешивание.
- [x] **3.2. Режим Flashcards**:
- Двусторонняя карточка En $\rightarrow$ Ru и Ru $\rightarrow$ EN с нативной озвучкой, подсказкой контекста и горячими клавишами `Space`, `1..4`.
- [x] **3.3. Режим Multiple Choice Quiz (4 варианта ответа)**:
- Выбор правильного перевода среди 4 вариантов со случайными дистракторами из базы.
- Мгновенная проверка, подсветка цветом (зеленый/красный) и горячие клавиши `1..4`.
- [x] **3.4. Режим активного набора (Active Typing / Ru $\rightarrow$ En)**:
- Видишь русский перевод / контекст с пропуском $\rightarrow$ вводишь английское слово с клавиатуры (прокачка моторной памяти и спеллинга).
- [x] **3.5. Режим Cloze Deletion (Контекстные пропуски)**:
- Заполнение пропущенного слова прямо в живом предложении.
- [x] **3.6. Аудио-тренажёр (Dictation)**:
- Слушаем слово $\rightarrow$ печатаем правильный спеллинг.
---
### Этап 4: Интеллектуальный помощник и аналитика
*Цель: отслеживание динамики и поддержка мотивации.*
- [x] **4.1. Статистика, Daily Streak и Календарь активности**:
- Сетка активности за 180 дней с градацией интенсивности (серый, салатовый, зеленый, изумрудный).
- Учёт непрерывных дней занятий (Daily Streak 🔥) и рекорда серии.
- Сегментированный прогресс-бар стадий освоения (Новые, В процессе, Закрепление, Выучено).
- Оценка уровня владения языком (A1 - C1) по объёму активного словаря.
- Процент точности запоминания (Retention Rate %).
- [ ] **4.2. Быстрый захват из браузера**:
- Букмарклет для выделения текста на любой странице и отправки в словарь.
- [x] **4.3. Честный и строгий расчёт уровня CEFR (Active Vocabulary & Retention Weighting)**:
- Расчёт уровня владения исключительно по **активным выученным словам** (стадии `Mastered` и `Review` в алгоритме SM-2), исключая пассивные добавленные карточки, которые ещё не прошли интервальные повторения.
- Корректирующий коэффициент точности: учёт Retention Rate %, чтобы уровень отражал реальную память, а не просто количество выписанных слов.
- Индикатор пути к следующему уровню (например: «*До уровня B2 осталось закрепить 142 слова*»).
---
### Этап 5: Интерактивная Читалка & Библиотека (Interactive Reader & Source-based Learning)
*Цель: чтение живых книг, статей и документации с мгновенным клик-переводом и авто-тегированием источников.*
- [x] **5.1. Хранилище текстов и парсер форматов (Bookshelf & Document Engine)**:
- Поддержка форматов: вставка текста из буфера, `.txt`, `.md`, электронные книги `.epub` (с разбивкой по главам и сохранением позиции чтения).
- Умная очистка веб-статей от лишней верстки и рекламы (Reader view).
- [x] **5.2. Автоматическое тегирование источников (Source & Metadata Auto-tagging)**:
- Каждое слово, сохранённое из книги/статьи, автоматически получает теги источника:
- Название произведения (например, `#fight-club`, `#the-martian`, `#designing-data-intensive-apps`).
- Тип/жанр материала (например, `#novel`, `#tech-article`, `#documentation`, `#cybersecurity`).
- Автор / Источник (например, `#author:chuck-palahniuk`, `#cloudflare-blog`).
- Автоматический захват предложения-контекста, в котором слово встретилось в книге.
- [x] **5.3. Запуск тестов и тренировок по источникам и тегам (Tag-scoped SRS Quizzes)**:
- Возможность запустить любую тренировку (Карточки, 4 варианта ответа, Набор слова, Диктант) **строго по словам конкретной книги, статьи или автора**.
- Быстрый запуск из читалки: *«Повторить все 35 новых слов из Fight Club»* прямо во время или после прочтения главы.
- [x] **5.4. Интерактивный клик-перевод и подсветка (Interactive Reader Layer)**:
- Клик по любому слову $\rightarrow$ моментальный перевод, транскрипция, нативное аудио и кнопка «+ В словарь» в 1 клик.
- Выделение фраз/связок (chunks) мышкой или тапом с литературным переводом всего выражения.
- Подсветка в тексте книги уже выученных (`mastered`) и изучаемых (`learning`) слов.
## 3. Ближайшие планы и вектор развития (Future Enhancements)
1. **Браузерный букмарклет для захвата слов**:
- Минималистичный JS-скрипт для браузера, позволяющий по клику отправить выделенное слово и контекст страницы в личный словарь `study.qpid.pro`.
2. **HTML5 Web Speech API fallback**:
- Дополнительный запасной канал озвучивания через встроенный `window.speechSynthesis` при мобильном режиме без связи.
3. **Расширение библиотеки Murphy**:
- Постепенное расширение учебных юнитов грамматики с 15 до 30+ тем (Passive Voice, Reported Speech, Phrasal Verbs in Context).
4. **Адаптивные интервалы по времени суток**:
- Учёт утренних и вечерних окон повторения в сессионном раннере для максимальной концентрации.
+202 -29
View File
@@ -1,46 +1,219 @@
# Notes (notes.qpid.pro)
# Study Qpid (study.qpid.pro)
> **Защищённый персональный тренажёр и словарь английского языка.**
> Создан для быстрого и надёжного расширения словарного запаса (общий английский, фразовые глаголы, идиомы, профессиональные термины) без отвлечения от чтения и просмотра контента.
> **Автономный персональный тренажёр и интерактивная языковая среда английского языка.**
> Создан для быстрого и надёжного расширения активного словарного запаса, автоматизации грамматики и беглого чтения технической и художественной литературы с поддержкой локального искусственного интеллекта (Ollama) и локального синтеза речи (Piper TTS) без внешних платных API.
---
## 🎯 Концепция и методика
Чтение статей, книг или просмотр видео на английском часто тормозится незнакомыми словами и выражениями. Обычный переводчик даёт мгновенный ответ, но слово сразу забывается. Ручные списки превращаются в «кладбище информации».
## 🎯 Концепция и философия системы
Проект решает эту проблему по принципу **«автомата Калашникова»**:
1. **Безопасность**: сервис работает на белом IP, защищён авторизацией (сессии, хэширование паролей, защита от брутфорса).
2. **Мгновенный поиск и перевод**: фокус на строке ввода, каскадный перевод (словарная статья + толкование + русский перевод).
3. **Контекстный якорь**: сохранение слова вместе с живым предложением (где встретилось выражение).
4. **Фонетическое закрепление**: нативное аудио-произношение (US/UK) и транскрипция IPA.
5. **Интервальные повторения (SRS / SM-2)**: встроенные карточки для перевода слов в долговременную память.
Традиционное изучение языка разбивается о разрозненность: словарь в одном приложении, карточки в другом, книги в третьем, а грамматика в тетрадке.
**Study Qpid** объединяет весь цикл в единый закрытый контур по принципу **«автомата Калашникова»**:
1. **100% автономность и приватность**: веб-сервис развернут в Docker, защищён строгой авторизацией (сессии HttpOnly, хэширование PBKDF2-HMAC-SHA256, rate limiting). Локальный ИИ (Qwen 2.5 3B) и синтез речи (Piper) работают полностью на собственном сервере без передачи данных сторонним компаниям.
2. **Контекстный якорь и лексический подход**: слова изучаются в готовых блоках (*chunks*) и живых предложениях из прочитанных книг и документации.
3. **Многомодальный интервальный тренажер (SRS SM-2)**: 5 различных режимов работы с карточками (3D Flashcards, умная викторина с дистракторами по частям речи, активный ввод Typing, аудио-диктант, аудирование Q&A).
4. **Интерактивная читалка**: мгновенный перевод кликом по слову, захват предложений в карточки в 1 клик, поддержка форматов EPUB, PDF, TXT, Markdown, спецификаций IETF RFC и статей Wikipedia.
5. **Грамматический трек Murphy & CEFR**: 15 базовых юнитов грамматики с формулами, связка с бумажной рабочей тетрадью, 260 интерактивных тестов Cloze Tap и автоматическая отправка ошибок в активную очередь повторений.
6. **Конструктор комплексного урока**: единый сессионный раннер, собирающий ежедневный урок из слов, грамматики, аудирования, чтения и завершающийся педагогическим разбором ошибок от локальной нейросети.
---
## 📂 Документация проекта
## 🚀 Основной реализованный функционал
- 📚 [**Источники бесплатных книг (BOOKS_SOURCES.md)**](BOOKS_SOURCES.md) — проверенные каталоги адаптированных книг (A1–C1) и оригинальной классики в EPUB/TXT для читалки.
- 📋 [**План функционала и архитектура (PLAN.md)**](PLAN.md) — детальный план итераций: от защищённого MVP до умных интервальных тренажёров.
- 🎓 [**Методология и педагогика (PEDAGOGY.md)**](PEDAGOGY.md) — методические правила эффективного освоения языка: лексические связки (chunks), контекст, фонетика, кривая забывания Эббингауза.
- ✅ [**Очередь задач и бэклог (TODO.md)**](TODO.md) — текущий статус реализации оптимизаций и фичей.
### 1. 🔍 Контекстный словарь и поиск
* **Локальная словарная база**: более 140 000 словарных статей в SQLite, дополненных морфологическим анализатором (лемматизация инфлексий `starts` → `start`, `studying` → `study`).
* **Детектор фразовых глаголов и идиом**: при поиске слова в контексте предложения система автоматически определяет устойчивые связки (*look forward to*, *run into*, *figure out*) и предлагает сохранить именно целостное выражение.
* **Каскадный перевод**: офлайн-словарь + транскрипция IPA + толкование на английском + русский перевод.
* **Управление базой**: разбивка по коллекциям (*General*, *IT/Tech*, *Phrasal Verbs* и др.), система тегов, быстрый поиск и фильтрация, пакетные операции (перемещение, тегирование, удаление).
### 2. 🧠 Локальный ИИ (Ollama & Qwen 2.5 3B)
* **Контекстный перевод предложений**: перевод предложения с фокусом на точное значение целевого слова в данном контексте через локальную LLM (`qwen2.5:3b`).
* **Кэширование переводов**: все сгенерированные переводы сохраняются в SQLite (`sentence_translations`) с моментальной отдачей при повторных запросах.
* **Педагогический дебрифинг урока**: анализ ошибок сессии, определение паттернов (путаница времен, предлогов), рекомендации и выставление объективной оценки сессии.
* **Отказоустойчивость**: постоянный пул соединений с Keep-Alive (120s) в FastAPI lifespan; при недоступности Ollama система мгновенно переключается на детерминированный математический отчёт без зависаний.
### 3. 🔊 Автономная нейро-озвучка (Piper TTS)
* **Локальный VITS-инференс**: чистый нативный звук дикторов без внешних запросов к облакам (`en_US-lessac`, `ru_RU-dmitri` + поддержка альтернативных дикторов).
* **Проактивное фоновое пред-кэширование**: при добавлении слова в словарь аудиозапись слова и контекста синтезируется сервером в фоне, обеспечивая нулевую задержку при повторении в тренажере.
* **Аудио в читалке**: озвучивание отдельных слов, целых предложений оригинала и русского перевода.
### 4. 🎓 Тренажёр интервальных повторений (SRS SM-2)
* **Алгоритм SuperMemo SM-2**: адаптивный расчёт интервалов (1, 6, $I \times EF$ дней) на основе оценок пользователя (Снова, Трудно, Хорошо, Легко).
* **5 специализированных режимов повторений**:
1. *3D Flashcards* — классические двусторонние карточки с 3D-анимацией переворота.
2. *Smart Quiz* — множественный выбор из 4 вариантов с автоматическим подбором правдоподобных дистракторов из той же части речи.
3. *Active Typing* — активная продукция: печать английского слова по русскому переводу и контексту с пропуском.
4. *Audio Dictation* — аудиотренажёр: прослушивание произношения через Piper TTS и ввод слова на слух.
5. *Listening Comprehension* — интерактивные аудио-сценарии с вопросами на проверку понимания.
* **Smart Daily Mix**: сбалансированная ежедневная тренировка (10 новых слов + 10 плановых повторений + 5 проблемных слов-личей, перемешанных для интерливинга).
* **Аналитика и визуализация**: GitHub-style календарь активности за 180 дней, подсчёт непрерывного стрика, распределение по статусам (New, Learning, Review, Mastered), расчёт Retention Rate и оценка словарного уровня по CEFR.
### 5. 📚 Интерактивная читалка и библиотека
* **Поддержка форматов**: загрузка книг в форматах EPUB, PDF, TXT, Markdown (`.md`).
* **Импорт RFC-спецификаций**: загрузка технических стандартов IETF RFC по номеру или URL с автоматической разбивкой на структурированные главы.
* **Интеграция с Wikipedia**: автономный импорт статей по названию, теме и доступному бюджету времени на чтение.
* **Интерактивный клик-перевод**: каждое слово кликабельно с показом всплывающей карточки (на десктопе — всплывающее окно у курсора, на смартфоне — удобный Bottom Sheet).
* **Непрерывное чтение**: бесконечный скролл с навигационными карточками завершения глав, автоматическое сохранение позиции чтения (`current_chapter`, `current_position`).
* **Типографика и темы**: 6 цветовых схем (Light, Dark, Sepia, Night, Parchment, Hacker), регулировка ширины страницы, межстрочного интервала и шрифтов (Georgia, Inter, JetBrains Mono).
### 6. 🏆 Тесты CEFR (A1–B2) & Автоматическая Gap-ингестия
* **260 интерактивных заданий Cloze Tap**, распределённых по международным уровням владения языком (A1, A2, B1, B2).
* **Автоматическое устранение пробелов (Gap Ingestion)**: любая допущенная ошибка в тесте мгновенно конвертируется в словарную карточку и отправляется в активную очередь SRS со статусом `learning`.
* **Фоновый майнер тестов из книг**: асинхронный фоновый воркер для автоматического извлечения тестовых заданий из загруженных пользователем книг с фильтрацией по целевому уровню CEFR.
### 7. 📖 Грамматический трек Murphy & Тетрадь
* **15 базовых юнитов** классической методики Raymond Murphy (*Essential Grammar in Use*).
* **Структура каждого юнита**:
- Карточка юнита со статусом освоения и баллом.
- Полноэкранный теоретический модуль с формулами образования, русскими объяснениями и маркерами времени.
- 5 интерактивных упражнений Cloze Tap с мгновенной проверкой.
* **Интеграция с бумажным конспектом**: чекбокс ручной фиксации упражнений в физической тетради для задействования моторной памяти.
### 8. ⚡ Конструктор комплексного урока & Сессионный раннер
* **Гибкая структура занятия**: настройка порядка этапов, переключение тумблеров активности, выбор объема слов и упражнений.
* **Готовые пресеты**:
- ⚡ *Утренний экспресс (10 мин)*
- 🎯 *Сбалансированный урок (20 мин)*
- 📖 *Глубокое погружение (30 мин)*
- 🎧 *Аудио + Чтение (15 мин)*
- Возможность сохранения собственных шаблонов в базу SQLite.
* **Умный подбор грамматики**: адаптивный выбор темы грамматики на основе истории ошибок («Самая слабая тема», «Предыдущая тема» или ручной выбор).
* **Сквозной раннер**: бесшовный интерфейс с таймером, прогресс-баром и финальным ИИ-дебрифингом.
---
## 🛠 Технический стек
## 🛠 Техническая архитектура и стек
- **Backend**: Python 3.12 (FastAPI / Uvicorn)
- **Database**: SQLite (WAL mode — надёжность, автономность, бэкап в один файл)
- **Безопасность**: Защищённые сессии (HttpOnly cookies), PBKDF2-HMAC-SHA256, Rate Limiting
- **Frontend**: Vanilla HTML5 / Modern CSS / ES6+ JavaScript (PWA, горячие клавиши, мгновенный отклик)
- **Внешние API**: Free Dictionary API (IPA + audio), MyMemory Translation API
- **Веб-сервер**: Nginx Reverse Proxy + SSL Let's Encrypt (`notes.qpid.pro`)
- **Репозиторий проекта**: [Gitea (ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)](https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro)
```
https://study.qpid.pro
│
┌──────────────────▼──────────────────┐
│ Nginx (Host SSL / Reverse Proxy) │
└──────────────────┬──────────────────┘
│ :8002
┌─────────────────────────────────────────▼────────────────────────────────────────┐
│ Docker Container: study-qpid-pro │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ FastAPI Application (app/main.py) │ │
│ │ • GZipMiddleware (≥1000B) • Security Headers Middleware │ │
│ │ • Persistent Lifespan Pool • Service Worker Static Precache (v6.7) │ │
│ └─────────────────────────────────────┬────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────▼────────────────────────────────────┐ │
│ │ Модульные APIRouters │ │
│ │ • auth.py • cards.py • dictionary.py • srs.py │ │
│ │ • reader.py • cefr.py • grammar.py • lessons.py │ │
│ │ • listening.py • tasks.py • export.py │ │
│ └─────────────────┬───────────────────┬──────────────────┬─────────────────┘ │
│ │ │ │ │
│ ┌─────────────────▼─────────┐ ┌───────▼────────┐ ┌───────▼─────────────────┐ │
│ │ SQLite WAL (data/notes) │ │ Piper TTS │ │ Seed Datasets (JSON) │ │
│ │ • Memory-Mapped I/O │ │ • Offline VITS │ │ • cefr_questions.json │ │
│ │ • Full Schema Integrity │ │ • Zero-Latency │ │ • grammar_units.json │ │
│ │ • Sub-millisecond reads │ │ • Voice Cache │ │ • phrasal_verbs.json │ │
│ └───────────────────────────┘ └────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────┬────────────────────────────────────────┘
│ http://ollama:11434
┌─────────────────────────────────────────▼────────────────────────────────────────┐
│ Docker Container: study-ollama │
│ • Model: Qwen 2.5 3B Instruct (GGUF) │
│ • Connection Pool with Keep-Alive (120s) │
└──────────────────────────────────────────────────────────────────────────────────┘
```
* **Backend**: Python 3.12, FastAPI, Uvicorn, Pydantic v2.
* **База данных**: SQLite в режиме `WAL` (Write-Ahead Logging) с `mmap_size = 256MB` и индексами по внешним ключам и выражениям.
* **Frontend**: Vanilla ES6+ модули, HTML5 PWA, CSS-переменные (7 предметных таблиц стилей), поддержка offline через Service Worker.
* **AI & TTS**: Ollama (`qwen2.5:3b`), Piper Neural TTS (локальный бинарник).
* **Тестирование**: Pytest + TestClient (44 автоматических интеграционных теста).
---
## 🌐 Информация об окружении
## 📁 Структура проекта
- **URL сервиса**: [https://notes.qpid.pro](https://notes.qpid.pro)
- **IP сервера**: `31.77.203.213`
- **Каталог проекта**: [`/home/kopri/notes.qpid.pro`](file:///home/kopri/notes.qpid.pro)
- **Git Remote**: `http://127.0.0.1:3000/Antigravity/notes-qpid-pro.git`
```
study.qpid.pro/
├── app/
│ ├── main.py # Лаконичная точка входа FastAPI (lifespan, middleware, роутеры)
│ ├── auth.py # Безопасность, хэширование PBKDF2, сессии, rate limiting
│ ├── config.py # Переменные окружения и системные пути
│ ├── database.py # Инициализация SQLite WAL, индексы, миграции
│ ├── dictionary.py # Поиск в офлайн-словаре, лемматизация, каскадные запросы
│ ├── ai.py # Интеграция с Ollama (Keep-Alive пул, промпты, дебрифинг)
│ ├── tts.py # Синтез речи Piper TTS и управление кэшем аудио
│ ├── srs.py # SM-2 интервалы, викторины, аналитика, тепловая карта
│ ├── reader.py # Парсеры EPUB, PDF, TXT, Markdown, RFC и Wikipedia
│ ├── cefr.py # Уровневое тестирование A1–B2 и gap-ингестия
│ ├── grammar.py # Движок Murphy Grammar и журнал конспектов
│ ├── lessons.py # Конструктор уроков, пресеты, история сессий
│ ├── listening.py # Сценарии аудирования и оценка ответов
│ ├── miner.py # Фоновый майнинг предложений из литературы
│ ├── tasks.py # Управление фоновыми задачами SQLite
│ ├── export.py # Экспорт базы (CSV, JSON, Anki, Markdown)
│ ├── routers/ # Модульные роутеры FastAPI (11 файлов)
│ └── static/ # Статика веб-приложения
│ ├── index.html # Одностраничное приложение (PWA Shell)
│ ├── sw.js # Service Worker (кэширование v6.7)
│ ├── manifest.json # PWA-манифест
│ ├── css/ # Модульная таблица стилей
│ │ ├── style.css # Мастер-файл со структурой и @import
│ │ ├── base.css # Токены, темы, сброс, safe-areas
│ │ ├── components.css # Навбар, меню, кнопки, модалки, таб-бар
│ │ ├── dictionary.css # Поиск, карточки слов, коллекции, теги
│ │ ├── srs.css # Карточки 3D, квизы, ввод, аналитика
│ │ ├── reader.css # Читалка, темы оформления, Wikipedia
│ │ ├── grammar_cefr.css # CEFR и юниты Murphy
│ │ └── lesson.css # Конфигуратор и раннер уроков
│ └── js/ # ES6 модули фронтенда
│ ├── app.js # Точка входа SPA, роутинг, хоткеи
│ └── modules/ # Модули логики (ui, api, dictionary, srs, reader, cefr, grammar, lesson)
├── data/
│ ├── notes.db # Основная база данных SQLite
│ ├── offline_dict.tsv.gz # Локальный словарь (140k+ записей)
│ ├── audio_cache/ # Кэш предсинтезированных WAV-файлов
│ └── seeds/ # Кураторские JSON-датасеты (CEFR, Murphy, Phrasal, Articles)
├── scripts/
│ ├── deploy_beta.sh # Развертывание бета-ветки в 1 клик
│ ├── rollback_to_main.sh # Мгновенный откат на стабильный main
│ └── build_offline_dict.py # Сборщик офлайн-словаря
├── tests/
│ └── test_api.py # Набор интеграционных тестов (44 теста)
├── docker-compose.yml # Декларация контейнеров study-web и ollama
└── Dockerfile # Образ веб-приложения на Python 3.12-slim
```
---
## 🚀 Развёртывание и запуск
### Запуск через Docker Compose (Рекомендуемый)
```bash
# Клонирование репозитория
git clone https://ai.qpid.pro/gitea/Antigravity/notes-qpid-pro.git
cd notes-qpid-pro
# Запуск связки веб-сервера и локальной Ollama
DOCKER_HOST=tcp://127.0.0.1:2375 docker compose up -d
# Запуск тестов внутри контейнера
DOCKER_HOST=tcp://127.0.0.1:2375 docker exec -i -e PYTHONPATH=. study-qpid-pro pytest tests/test_api.py
```
### Управление ветками и откат:
```bash
# Быстрый переход на бета-версию:
./scripts/deploy_beta.sh
# Мгновенный откат на стабильную ветку main:
./scripts/rollback_to_main.sh
```
---
## 📄 Лицензия и статус проекта
* Проект разрабатывается в активной фазе для повседневного практического освоения языка.
* 100% открытый исходный код без проприетарных платных облачных зависимостей.
+95 -6
View File
@@ -447,16 +447,105 @@
---
## ⏳ Coming Soon
### 20. 🏗️ Комплексный технический рефакторинг и оптимизация архитектуры (Completed)
- [x] **Этап 1: Очистка мертвого кода и оптимизация SQL**:
- Удалена старая незавершенная функция `parse_text` в `app/reader.py` (строки 290–326).
- Функция `update_task_progress` в `app/tasks.py` переписана с цикла на единый параметризованный SQL-запрос `UPDATE tasks SET progress = ?, current_item = ?, ...`.
- [x] **Этап 2: Вынос датасетов в JSON (`data/seeds/*.json`)**:
- Из исходников извлечено более 3 100 строк статических данных: `cefr_questions.json` (260 вопросов), `grammar_units.json` (15 юнитов), `phrasal_verbs.json` (157 глаголов), `reading_articles.json` (8 статей).
- Модули `app/cefr.py`, `app/grammar.py`, `app/phrasal.py`, `app/lessons.py` переведены на динамическую ленивую загрузку с сохранением полной обратной совместимости.
- [x] **Этап 3: Оптимизация сети и локального ИИ**:
- Внедрен постоянный пул соединений `httpx.AsyncClient` с Keep-Alive (120s) для локальной Ollama в `app/ai.py`, управляемый в жизненном цикле приложения (`lifespan`).
- Интегрирована встроенная офлайн-лемматизация (`lemmatize_word`) в `lookup_term` (`app/dictionary.py`), предотвращающая лишние сетевые запросы при поиске словоформ (`starts` -> `start`, `studying` -> `study`).
- [x] **Этап 4: Модуляризация бэкенда FastAPI**:
- Монолитный `app/main.py` сокращен с 2 240 до 142 строк.
- Создан пакет `app/routers/` с 11 тематическими APIRouter: `auth.py`, `cards.py`, `dictionary.py`, `srs.py`, `export.py`, `reader.py`, `cefr.py`, `grammar.py`, `tasks.py`, `lessons.py`, `listening.py`.
- [x] **Этап 5: Модуляризация CSS и Service Worker**:
- Монолитный `style.css` (8 746 строк) разделен на 7 предметных таблиц в `app/static/css/`: `base.css`, `components.css`, `dictionary.css`, `srs.css`, `reader.css`, `grammar_cefr.css`, `lesson.css`.
- Мастер-файл `style.css` загружает стили через `@import` и содержит полную архитектурную карту.
- Обновлен Service Worker (`notes-v6.7`) с добавлением всех CSS в `PRECACHE_URLS` для полной офлайн-работы PWA.
- [x] **Инфраструктура быстрого переключения и отката**:
- Добавлены скрипты `scripts/deploy_beta.sh` и `scripts/rollback_to_main.sh` для мгновенного отката или развертывания в 1 клик.
- Создан резервный бэкап базы данных `data/notes.db.backup-main-pre-beta`.
- [x] **100% успешное тестирование**:
- Все 44 интеграционных теста `pytest tests/test_api.py` успешно проходят в Docker-контейнере.
---
### 🐛 Устранённые баги (Решены и протестированы)
- [x] **BUG-01: Не открывается окно редактирования слова в словаре на главной**:
- **Симптом**: при клике на иконку ✏️ у карточки слова в списке словаря ничего не происходило.
- **Решение**: исправлен селектор ID в `app/static/js/modules/dictionary.js` с `'edit-card-modal'` на `'edit-modal'`. Модальное окно теперь стабильно открывается.
- **Статус**: [x] Решено.
- [x] **BUG-02: Отсутствует возможность прослушать слово и предложение из словаря**:
- **Симптом**: в списке карточек словаря на главной странице не было кнопок 🔊 для воспроизведения аудио слова и контекстного предложения.
- **Решение**: добавлены кнопки озвучки 🔊 в `app/static/js/modules/dictionary.js` для `term` (`playAudio`) и `context_sentence` (`playTextSpeech`) с адаптивными стилями в `dictionary.css`.
- **Статус**: [x] Решено.
- [x] **BUG-03: Спойлер корня слова в переводе грамматических форм в тестах (например, `(прош. вр. от explain)`)**:
- **Симптом**: при добавлении словоформ в словарь (например, `explained`) в переводе сохранялись пометки: `(прош. вр. от explain)`, спойлеря правильный ответ во время тестов.
- **Решение**: в `app/static/js/modules/ui.js` добавлены утилиты `cleanPromptTranslation` (вырезает подсказки корня в квизах, тайпинге, клоуз-тестах и диктантах в `srs.js` и `lesson.js`) и `formatTranslationWithSpoiler` (оборачивает пометку в интерактивный кликабельный бейдж-спойлер `[+ грамматика]` в карточках словаря).
- **Статус**: [x] Решено.
- [x] **BUG-04: В квизе «4 варианта» правильный ответ всегда находится на 1-м месте**:
- **Симптом**: в комплексном уроке и SRS при выборе режима «4 варианта» правильный ответ всегда гарантированно оказывался первым.
- **Решение**: в `app/static/js/modules/ui.js` функция `shuffleArray` переведена в мутирующий режим in-place, а в `lesson.js` и `srs.js` вызовы обновлены на явное переприсваивание `options = shuffleArray(options)`. Правильный ответ теперь распределяется равномерно случайно.
- **Статус**: [x] Решено.
- [x] **BUG-05: Поломка верстки и отображение сырых HTML-тегов/JSON при импорте статей Википедии (например, Linux)**:
- **Симптом**: при открытии статьи «Linux» отображался сырой JSON шаблонов MediaWiki (`"},"title":{"wt":"Linux"}...`) и сломанные теги `<tdTimes New Roman...`.
- **Решение**: в `app/reader.py` внедрена предварительная очистка атрибутов MediaWiki Parsoid (`data-mw`, `data-parsoid`, `about`), содержащих вложенные `<ref>` и литеральные `>`, ломавшие регулярные выражения тегов. В `clean_data_table` исправлен regex для поддержки вложенных кавычек `(?:"[^"]*"|\'[^\']*\')`. Статья 19 (Linux) в `data/notes.db` перепарсена и сохранена в идеально чистом виде. Добавлен регрессионный тест в `tests/test_api.py`.
- **Статус**: [x] Решено.
- [x] **BUG-06: Не работает поиск и обратный перевод с русского на английский в словаре**:
- **Симптом**: при вводе в поле поиска словаря русского слова перевод не находился, возвращался пустой результат.
- **Решение**: в `app/dictionary.py` реализовано автоматическое определение кириллицы `[\u0400-\u04FF]`. При русском вводе выполняется обратный поиск в `offline_dictionary` с приоритизацией одиночных слов и запрос MyMemory с `langpair="ru|en"`, обогащаемый фонетикой и частью речи. В `app/routers/dictionary.py` и `dictionary.js` поддержано заполнение карточки в обратном направлении (найденный английский термин в `term`, русский запрос в `translation`). Добавлен регрессионный тест в `tests/test_api.py`.
- **Статус**: [x] Решено.
- [x] **BUG-07: Ошибка загрузки этапа аудирования: `undefined is not an object (evaluating 'scenario.topic')`**:
- **Симптом**: при переходе к шагу аудирования в уроке возникала ошибка загрузки этапа из-за чтения `scenario.topic`.
- **Решение**: в `app/static/js/modules/lesson.js` строка 1659 обновлена на безопасное извлечение `const scenario = (data && (data.scenario || data)) || {};`, поддерживающее как прямой, так и вложенный формат ответа.
- **Статус**: [x] Решено.
- [x] **BUG-08: Бесконечный спиннер («Анализ ИИ длится бесконечно») на этапе дебрифинга урока + TTL 1h**:
- **Симптом**: после прохождения урока анализ ИИ висел бесконечно долго из-за холодного старта Ollama и обрыва HTTP-соединения.
- **Решение**:
1. Увеличен TTL модели в памяти с `5m` до `1h` (`OLLAMA_KEEP_ALIVE=1h` в `config.py` и `docker-compose.yml`) — модель остается теплой всю учебную сессию.
2. В `app/ai.py` таймаут к Ollama ограничен 10 секундами с мгновенным автоматическим фолбэком на детерминированный локальный разбор `generate_deterministic_debrief` (< 1 мс).
3. В `app/static/js/modules/api.js` добавлен жесткий 12-секундный клиентский таймаут с `AbortController`.
- **Статус**: [x] Решено.
---
## ⏳ Очередь новых функций (Feature Backlog)
- [x] **FEAT-01: 🧘 Фокус-режим в читалке (Focus Mode / Zen Reader — 100% vh & 100% vw)**:
- **Концепция**: чистый дзен-режим чтения на весь экран без отвлекающих панелей с сохранением 100% интерактивного перевода.
- **Интерфейс и верстка**:
- Скрытие шапки сайта (`navbar`), тулбара читалки, мобильной панели вкладок (`bottom tab bar`) и рамок.
- Текстовый контейнер расширяется на `100vw` и `100vh` (с учётом `env(safe-area-inset-*)`).
- Сохраняются все пользовательские настройки типографики (`Aa`): размер шрифта, гарнитура (Sans/Serif/Mono), ширина колонки и темы оформления (Dark, Sepia, Light, OLED).
- **Интерфейс перевода (Zero Loss)**:
- Клик / тап по любому слову по-прежнему открывает всплывающее окно перевода (десктопный попап или мобильный Bottom Sheet).
- Доступны контекстный ИИ-перевод предложений, словарные статьи и быстрое добавление `+ В словарь`.
- **Управление и HUD**:
- Иконка активации `🧘` в тулбаре читалки и поддержка горячей клавиши `F`.
- Ненавязчивый полупрозрачный плавающий оверлей в углу с индикатором прогресса чтения и кнопкой выхода `✕`.
- Выход по нажатию клавиши `Escape`, клику на `✕` или свайпу/тапу по верхнему краю экрана.
- Поддержка нативного Fullscreen API (`document.documentElement.requestFullscreen()`) для браузеров на десктопе и Android.
- [ ] **FEAT-02: 📝 Подробные конспекты для тетради и шпаргалки с таблицами (Murphy Detailed Notes & Markdown Tables)**:
- **Полноценные Markdown-таблицы**: отображение наглядных сравнительных таблиц в теории грамматики и подсказках (спряжения глаголов, формы неправильных глаголов, маркеры времени, предлоги места/времени).
- **Глубокая структурированная теория без воды**: развернутые объяснения правил по методике Raymond Murphy с живыми примерами и контрастными парами (вместо сжатых 1-2 предложений), готовые для записи в рабочую тетрадь.
- [ ] **FEAT-03: 🤖 Интеграция с Google NotebookLM для генерации инфографики и карточек (Низкий приоритет)**:
- Использование возможностей Google NotebookLM или мультимодальных моделей для автоматической генерации наглядной инфографики и подробных обучающих карточек, «разжевывающих» грамматические нюансы и идиомы.
- [ ] **FEAT-04: 🎯 Адаптивный объем тренировки (доведение до автоматизма) и умные рекомендации**:
- Настраиваемый объем заданий в блоках урока: выбор между 5, 10, 15, 20 заданиями или режим «тренировка до 90% точности / безошибочной серии».
- Персональные рекомендации при низком балле (< 75-80%): алгоритм анализирует ошибки и автоматически предлагает, какие конкретно статьи в читалке изучить и какие юниты грамматики Мерфи повторить.
- [ ] **FEAT-05: ✍️ Режим активного составления предложений (Sentence Builder / Production Mode)**:
- Тренировка активного словарного запаса: студенту предлагается слово из личного словаря (например, *«установленный» / «installed»*), на которое нужно составить и записать предложение на английском.
- Грамматические модификаторы сложности: задание условий по времени и конструкции (например: *«составь предложение в Past Simple»*, *«используй Future Continuous»*, *«составь вопрос с предлогом»*).
- Проверка через локальную языковую модель с мгновенным разбором правильности, стиля и грамматических ошибок.
- [ ] **🌐 Быстрый захват из браузера (Bookmarklet)**:
- Создать JavaScript-букмарклет для добавления слова/фразы из любого окна браузера в личный словарь [`study.qpid.pro`](https://study.qpid.pro) (низший приоритет).
- [ ] **Нативная автономная озвучка (HTML5 Web Speech API / `speechSynthesis`)**:
- Использовать встроенный в браузер синтезатор речи `window.speechSynthesis` (en-US, en-GB).
- 0 сетевых запросов к серверу, 100% оффлайн-работа.
- Поддержка озвучивания отдельных слов, фраз, идиом и предложений-контекстов читалки.
- [ ] **Возврат режима «Диктант» в SRS**:
- Возобновление работы аудиотренажёра на базе нативного браузерного синтезатора речи без зависимости от внешних аудиофайлов.
- Использовать встроенный в браузер синтезатор речи `window.speechSynthesis` (en-US, en-GB) как дополнительный резервный фолбэк при работе на мобильных без связи.
- [ ] **Расширение библиотеки Murphy**:
- Добавление следующих 15 юнитов грамматики (юниты 16–30: пассивный залог, косвенная речь, модальные глаголы).
---
+38 -10
View File
@@ -8,6 +8,34 @@ from app.database import get_db, get_cached_sentence_translation, save_sentence_
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",
@@ -223,7 +251,7 @@ async def translate_sentence_ai(
"prompt": prompt,
"stream": False,
"format": "json",
"keep_alive": "24h",
"keep_alive": config.OLLAMA_KEEP_ALIVE,
"options": {
"temperature": 0.2,
"num_predict": 300,
@@ -235,7 +263,7 @@ async def translate_sentence_ai(
source = f"ollama:{config.OLLAMA_MODEL}"
try:
async with httpx.AsyncClient(timeout=60.0) as client:
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()
@@ -314,8 +342,8 @@ async def translate_sentence_ai(
async def get_ollama_status() -> Dict[str, Any]:
"""Checks Ollama server connectivity and lists installed models."""
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"{config.OLLAMA_BASE_URL}/api/tags")
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", [])]
@@ -336,13 +364,13 @@ async def get_ollama_status() -> Dict[str, Any]:
async def warmup_ollama_model() -> bool:
"""Preloads the configured Ollama model into RAM with keep_alive to eliminate cold-start latency."""
try:
async with httpx.AsyncClient(timeout=60.0) as client:
client = get_ollama_client()
resp = await client.post(
f"{config.OLLAMA_BASE_URL}/api/generate",
json={"model": config.OLLAMA_MODEL, "keep_alive": "24h"},
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=24h).")
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}")
@@ -539,7 +567,7 @@ async def generate_lesson_ai_debrief(
"prompt": prompt,
"stream": False,
"format": "json",
"keep_alive": "24h",
"keep_alive": config.OLLAMA_KEEP_ALIVE,
"options": {
"temperature": 0.3,
"num_predict": 450,
@@ -547,8 +575,8 @@ async def generate_lesson_ai_debrief(
}
try:
async with httpx.AsyncClient(timeout=25.0) as client:
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload)
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
+22
View File
@@ -4,6 +4,7 @@ import secrets
import time
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import Request, HTTPException, status
from app import config
from app.database import get_db
@@ -136,3 +137,24 @@ def ensure_admin_user():
if count == 0 and config.ADMIN_PASSWORD:
create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
def get_optional_user(request: Request) -> Optional[dict]:
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token:
return get_user_from_session(token)
return None
def require_user(request: Request) -> dict:
user = get_optional_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return user
+26 -1836
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -43,4 +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", "1h")
+32
View File
@@ -331,6 +331,8 @@ def _migrate(conn: sqlite3.Connection):
title TEXT NOT NULL,
subtitle TEXT DEFAULT '',
golden_formula TEXT NOT NULL,
cheatsheet_table TEXT DEFAULT '',
detailed_notes TEXT DEFAULT '',
contrast_explanation_json TEXT NOT NULL,
journaling_prompt TEXT NOT NULL,
exercises_json TEXT NOT NULL,
@@ -340,6 +342,18 @@ def _migrate(conn: sqlite3.Connection):
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_num ON grammar_units(unit_num);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_grammar_units_level ON grammar_units(level);")
# Safe migration: add cheatsheet_table and detailed_notes columns if not present
existing_grammar_cols = {
row[1] for row in conn.execute("PRAGMA table_info(grammar_units)").fetchall()
}
grammar_migrations = [
("cheatsheet_table", "TEXT DEFAULT ''"),
("detailed_notes", "TEXT DEFAULT ''"),
]
for col, definition in grammar_migrations:
if col not in existing_grammar_cols:
conn.execute(f"ALTER TABLE grammar_units ADD COLUMN {col} {definition}")
conn.execute("""
CREATE TABLE IF NOT EXISTS grammar_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -419,6 +433,24 @@ def _migrate(conn: sqlite3.Connection):
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_sess_user_date ON lesson_sessions(user_id, completed_at DESC);")
# Ensure feedback_reports table exists (User In-App Bug & Feature Tracker)
conn.execute("""
CREATE TABLE IF NOT EXISTS feedback_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
category TEXT NOT NULL DEFAULT 'bug',
message TEXT NOT NULL,
context_url TEXT DEFAULT '',
context_data_json TEXT DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'new',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback_reports(user_id);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_status ON feedback_reports(status);")
# Repair legacy cards with placeholder blanks or mismatched context
repair_mismatched_cefr_contexts(conn)
+148 -4
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,22 +294,165 @@ async def lookup_term(query: str) -> Dict[str, Any]:
except Exception:
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()
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,)
).fetchone()
lemma_found = None
if not off_row and len(words) == 1:
try:
from app.phrasal import lemmatize_word
for cand in lemmatize_word(query_clean):
if cand.lower() != query_clean.lower():
row = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(cand,)
).fetchone()
if row:
off_row = row
lemma_found = row["term"]
break
except Exception:
pass
if off_row:
raw_trans = off_row["translation"] or ""
clean_trans = clean_russian_translation(raw_trans)
if lemma_found and lemma_found.lower() != query_clean.lower():
clean_trans = f"{clean_trans} (от {lemma_found})"
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,
@@ -329,10 +472,11 @@ async def lookup_term(query: str) -> Dict[str, Any]:
except Exception:
pass
result: Dict[str, Any] = {
"query": query_clean,
"term": query_clean,
"is_sentence": is_sentence,
"is_reverse": False,
"translation": "",
"translations_list": [],
"phonetic": "",
+72 -926
View File
File diff suppressed because it is too large Load Diff
+30 -190
View File
@@ -13,196 +13,36 @@ from datetime import datetime
# -----------------------------------------------------------------------------
# Curated High-Yield Bite-Sized Reading Articles & Facts
# -----------------------------------------------------------------------------
CURATED_ARTICLES: List[Dict[str, Any]] = [
{
"id": "art-01",
"title": "The James Webb Space Telescope: Peering into the Cosmic Dawn",
"title_ru": "Космический телескоп Джеймс Уэбб: взгляд в космический рассвет",
"topic": "Science",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Operating in the cold vacuum of deep space, the James Webb Space Telescope observes the universe in high-resolution infrared light. Its massive gold-coated beryllium mirror captures faint photons emitted by the earliest galaxies over thirteen billion years ago.",
"Unlike optical telescopes, infrared astronomy allows scientists to pierce through dense cosmic dust clouds where stars and planetary systems are born. Recently, the observatory discovered complex organic molecules in distant stellar nurseries, challenging previous models of early cosmic chemistry."
],
"paragraphs_ru": [
"Работая в холодном вакууме глубокого космоса, космический телескоп «Джеймс Уэбб» наблюдает за Вселенной в инфракрасном диапазоне высокого разрешения. Его массивное бериллиевое зеркало с золотым покрытием улавливает слабые фотоны, испущенные самыми ранними галактиками более тринадцати миллиардов лет назад.",
"В отличие от оптических телескопов, инфракрасная астрономия позволяет учёным проникать сквозь плотные облака космической пыли, где рождаются звёзды и планетные системы. Недавно обсерватория обнаружила сложные органические молекулы в далёких звёздных колыбелях, что поставило под сомнение прежние модели ранней космической химии."
],
"vocabulary_highlights": [
{"term": "vacuum", "trans": "вакуум, пустота"},
{"term": "beryllium", "trans": "бериллий"},
{"term": "faint", "trans": "слабый, едва заметный"},
{"term": "photons", "trans": "фотоны"},
{"term": "stellar nurseries", "trans": "звёздные колыбели"},
{"term": "pierce through", "trans": "проникать сквозь, протыкать"}
]
},
{
"id": "art-02",
"title": "The Hidden Architecture of Neural Networks",
"title_ru": "Скрытая архитектура нейронных сетей",
"topic": "Technology",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Modern artificial neural networks are loosely inspired by the biological connections in the human cortex. Each artificial neuron computes a weighted sum of its inputs and applies a non-linear activation function before propagating the signal onward.",
"During the backpropagation process, the network calculates gradient vectors to incrementally adjust billions of internal parameters. This iterative mathematical tuning enables language models to perceive subtle nuances of syntax, sentiment, and context without explicit hard-coded rules."
],
"paragraphs_ru": [
"Современные искусственные нейронные сети отдалённо вдохновлены биологическими связями в коре головного мозга человека. Каждый искусственный нейрон вычисляет взвешенную сумму своих входов и применяет нелинейную функцию активации перед передачей сигнала дальше.",
"В процессе обратного распространения ошибки сеть вычисляет векторы градиента, чтобы постепенно корректировать миллиарды внутренних параметров. Эта итеративная математическая настройка позволяет языковым моделям улавливать тонкие нюансы синтаксиса, тональности и контекста без явных жестко заданных правил."
],
"vocabulary_highlights": [
{"term": "loosely inspired", "trans": "отдалённо вдохновлённый"},
{"term": "cortex", "trans": "кора (головного мозга)"},
{"term": "weighted sum", "trans": "взвешенная сумма"},
{"term": "propagating", "trans": "распространяющий, передающий"},
{"term": "gradient vectors", "trans": "векторы градиента"},
{"term": "subtle nuances", "trans": "тонкие нюансы"}
]
},
{
"id": "art-03",
"title": "The Remarkable Intelligence of Crows and Ravens",
"title_ru": "Удивительный интеллект ворон и воронов",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Corvids, the bird family that includes ravens and crows, display cognitive abilities rivaling those of primates. Laboratory experiments reveal that crows can fashion specialized hooks from wire to retrieve food containers placed out of beak reach.",
"Furthermore, field observations demonstrate that wild crows remember specific human faces for several years and communicate warnings about hostile individuals to the rest of the flock across generations."
],
"paragraphs_ru": [
"Врановые — семейство птиц, включающее воронов и ворон, демонстрируют когнитивные способности, соперничающие со способностями приматов. Лабораторные эксперименты показывают, что вороны могут изготавливать специальные крючки из проволоки, чтобы доставать контейнеры с едой, находящиеся вне досягаемости клюва.",
"Более того, полевые наблюдения доказывают, что дикие вороны помнят конкретные человеческие лица в течение нескольких лет и передают предупреждения о недружелюбных особях остальной стае через поколения."
],
"vocabulary_highlights": [
{"term": "corvids", "trans": "врановые (птицы)"},
{"term": "cognitive abilities", "trans": "когнитивные способности"},
{"term": "rivaling", "trans": "соперничающий с"},
{"term": "fashion hooks", "trans": "изготавливать крючки"},
{"term": "hostile individuals", "trans": "враждебные особи, недоброжелатели"},
{"term": "flock", "trans": "стая"}
]
},
{
"id": "art-04",
"title": "Why We Sleep: The Science of Memory Consolidation",
"title_ru": "Зачем мы спим: наука о консолидации памяти",
"topic": "Health",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"While the body rests during deep sleep, the brain is engaged in vigorous maintenance work. The hippocampus transfers newly acquired information into the neocortex for long-term storage, effectively converting fragile short-term memories into enduring knowledge.",
"Simultaneously, the glymphatic system flushes away metabolic waste and toxic proteins that accumulate between neurons during waking hours. Sleep deprivation severely impairs this cleaning cycle, making rapid vocabulary retention far more difficult."
],
"paragraphs_ru": [
"Пока тело отдыхает во время глубокого сна, мозг занят активной работой по техобслуживанию. Гиппокамп переносит вновь полученную информацию в неокортекс для долговременного хранения, эффективно преобразуя хрупкие кратковременные воспоминания в прочные знания.",
"Одновременно с этим глимфатическая система вымывает метаболические отходы и токсичные белки, которые накапливаются между нейронами в часы бодрствования. Недосыпание серьёзно нарушает этот цикл очистки, делая быстрое запоминание словарного запаса намного более трудным."
],
"vocabulary_highlights": [
{"term": "vigorous maintenance", "trans": "активное техобслуживание / поддержка"},
{"term": "fragile memories", "trans": "хрупкие воспоминания"},
{"term": "enduring knowledge", "trans": "прочные, долговечные знания"},
{"term": "flushes away", "trans": "смывает, вымывает"},
{"term": "sleep deprivation", "trans": "депривация сна, недосыпание"},
{"term": "vocabulary retention", "trans": "удержание словарного запаса"}
]
},
{
"id": "art-05",
"title": "The Antikythera Mechanism: Ancient Greek Analog Computer",
"title_ru": "Антикитерский механизм: древнегреческий аналоговый компьютер",
"topic": "History",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Discovered by sponge divers inside a Roman shipwreck near the Greek island of Antikythera in 1901, this corroded bronze device bewildered historians for decades. Modern micro-computed tomography revealed an intricate clockwork mechanism consisting of over thirty precision gear wheels.",
"Constructed around the second century BC, the mechanism accurately tracked the astronomical positions of the Sun, Moon, and five known planets. It could even predict solar and lunar eclipses years in advance, showcasing a technological sophistication unmatched until fourteenth-century mechanical clocks."
],
"paragraphs_ru": [
"Обнаруженное ловцами губок внутри римского кораблекрушения возле греческого острова Антикитера в 1901 году, это корродированное бронзовое устройство приводило историков в замешательство на протяжении десятилетий. Современная микрокомпьютерная томография выявила сложнейший часовой механизм, состоящий из более чем тридцати прецизионных зубчатых колёс.",
"Созданный около второго века до нашей эры, механизм точно отслеживал астрономические положения Солнца, Луны и пяти известных планет. Он мог даже предсказывать солнечные и лунные затмения на годы вперёд, демонстрируя технологическую сложность, которой не было равных вплоть до механических часов XIV века."
],
"vocabulary_highlights": [
{"term": "sponge divers", "trans": "ловцы губок"},
{"term": "corroded bronze", "trans": "подвергшаяся коррозии бронза"},
{"term": "bewildered", "trans": "сбивал с толку, озадачивал"},
{"term": "gear wheels", "trans": "зубчатые колёса, шестерни"},
{"term": "lunar eclipses", "trans": "лунные затмения"},
{"term": "sophistication", "trans": "утончённость, сложность конструкции"}
]
},
{
"id": "art-06",
"title": "Underground Fungal Networks: The Wood Wide Web",
"title_ru": "Подземные грибные сети: лесная паутина",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Beneath the floor of an ancient forest lies an astonishing symbiotic network of mycorrhizal fungi. These microscopic fungal threads, known as mycelium, weave through tree roots to exchange mineral nutrients and water for sugars generated through photosynthesis.",
"Remarkably, trees utilize this subterranean internet to transmit chemical distress signals when attacked by pests, allowing neighboring trees to synthesize defensive tannins before the infestation spreads."
],
"paragraphs_ru": [
"Под пологом древнего леса скрывается поразительная симбиотическая сеть микоризных грибов. Эти микроскопические грибные нити, известные как мицелий, оплетают корни деревьев, обменивая минеральные питательные вещества и воду на сахара, вырабатываемые в процессе фотосинтеза.",
"Примечательно, что деревья используют этот подземный интернет для передачи химических сигналов бедствия при нападении вредителей, позволяя соседним деревьям синтезировать защитные танины до того, как заражение распространится."
],
"vocabulary_highlights": [
{"term": "mycorrhizal fungi", "trans": "микоризные грибы"},
{"term": "mycelium", "trans": "мицелий, грибница"},
{"term": "subterranean", "trans": "подземный"},
{"term": "distress signals", "trans": "сигналы бедствия"},
{"term": "infestation", "trans": "заражение вредителями, нашествие"}
]
},
{
"id": "art-07",
"title": "Quantum Superposition and the Threshold of Reality",
"title_ru": "Квантовая суперпозиция и порог реальности",
"topic": "Science",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"In quantum mechanics, subatomic particles exist simultaneously in multiple probable states until an observation forces the wave function to collapse into a single definitive outcome. This fundamental counterintuitive principle is known as quantum superposition.",
"Quantum computers harness this phenomenon by employing qubits instead of classical binary bits. While a traditional bit can only register as a zero or a one, a qubit exists in a superposition of both states, granting exponential computational scaling for cryptographic and molecular simulation algorithms."
],
"paragraphs_ru": [
"В квантовой механике субатомные частицы существуют одновременно во множестве вероятностных состояний до тех пор, пока наблюдение не заставляет волновую функцию коллапсировать в один определённый исход. Этот фундаментальный парадоксальный принцип известен как квантовая суперпозиция.",
"Квантовые компьютеры используют этот феномен, применяя кубиты вместо классических двоичных битов. В то время как традиционный бит может принимать значение только ноль или единица, кубит существует в суперпозиции обоих состояний, обеспечивая экспоненциальное масштабирование вычислений для алгоритмов криптографии и моделирования молекул."
],
"vocabulary_highlights": [
{"term": "subatomic particles", "trans": "субатомные частицы"},
{"term": "counterintuitive", "trans": "парадоксальный, нелогичный на первый взгляд"},
{"term": "harness", "trans": "укрощать, эффективно использовать"},
{"term": "qubits", "trans": "кубиты"},
{"term": "exponential scaling", "trans": "экспоненциальное масштабирование"}
]
},
{
"id": "art-08",
"title": "The Evolution of Coffee Culture and the Age of Enlightenment",
"title_ru": "Эволюция кофейной культуры и Эпоха Просвещения",
"topic": "Culture",
"level": "A2",
"read_time_min": 2,
"paragraphs": [
"During the seventeenth century, European coffee houses emerged as lively hubs for intellectual debate, political discussion, and commercial trade. In London, these establishments were nicknamed 'penny universities' because for the price of a cup of coffee, any customer could participate in philosophical discourse.",
"Scientists like Isaac Newton and economists like Adam Smith regularly met in coffee houses to draft treatises and debate ideas that laid the foundations of the scientific revolution and modern commerce."
],
"paragraphs_ru": [
"В XVII веке европейские кофейни стали оживлёнными центрами интеллектуальных дебатов, политических дискуссий и коммерческой торговли. В Лондоне эти заведения называли «пенни-университетами», потому что по цене чашки кофе любой посетитель мог поучаствовать в философских рассуждениях.",
"Учёные вроде Исаака Ньютона и экономисты вроде Адама Смита регулярно собирались в кофейнях, чтобы составлять трактаты и обсуждать идеи, заложившие основы научной революции и современной коммерции."
],
"vocabulary_highlights": [
{"term": "lively hubs", "trans": "оживлённые центры"},
{"term": "philosophical discourse", "trans": "философские рассуждения / дискуссии"},
{"term": "draft treatises", "trans": "составлять трактаты"},
{"term": "laid the foundations", "trans": "заложили основы"}
]
}
]
from pathlib import Path
from app import config
_CURATED_ARTICLES_CACHE = None
def get_curated_articles_list():
global _CURATED_ARTICLES_CACHE
if _CURATED_ARTICLES_CACHE is None:
seeds_file = config.BASE_DIR / 'data' / 'seeds' / 'reading_articles.json'
if seeds_file.is_file():
try:
with open(seeds_file, 'r', encoding='utf-8') as f:
_CURATED_ARTICLES_CACHE = json.load(f)
except Exception:
_CURATED_ARTICLES_CACHE = []
else:
_CURATED_ARTICLES_CACHE = []
return _CURATED_ARTICLES_CACHE
class _LazyArticlesList(list):
def __iter__(self):
return iter(get_curated_articles_list())
def __len__(self):
return len(get_curated_articles_list())
def __getitem__(self, idx):
return get_curated_articles_list()[idx]
CURATED_ARTICLES = _LazyArticlesList()
# -----------------------------------------------------------------------------
# Default Lesson Presets (Ready-to-use Configurations)
+29 -2137
View File
File diff suppressed because it is too large Load Diff
+23 -194
View File
@@ -56,200 +56,29 @@ PHRASAL_PARTICLES = {
"to", "together", "under", "up", "upon", "with"
}
# Curated high-frequency English phrasal verbs (CEFR A2-B2) with clean translations
CURATED_PHRASAL_VERBS: Dict[str, str] = {
# take
"take off": "взлетать; снимать (одежду); быстро уходить",
"take up": "браться за (хобби/дело); занимать (место, время)",
"take on": "брать на себя (ответственность); нанимать",
"take over": "захватывать контроль; вступать во владение",
"take in": "усваивать; приютить; обманывать",
"take out": "вынимать; приглашать (в ресторан); устранять",
"take back": "возвращать (вещь); брать свои слова назад",
"take after": "быть похожим на (родителей)",
"take apart": "разбирать на части",
# give
"give up": "сдаваться; бросать (привычку)",
"give in": "уступать, сдаваться под натиском",
"give away": "раздавать даром; выдавать (секрет)",
"give out": "раздавать; иссякать, отказывать",
"give back": "возвращать",
# get
"get along with": "ладить, быть в хороших отношениях с кем-то",
"get along": "ладить; справляться",
"get away": "убегать, уезжать в отпуск",
"get away with": "выходить сухим из воды",
"get back": "возвращаться",
"get by": "сводить концы с концами, выживать",
"get off": "выходить (из автобуса/поезда); отделываться",
"get on": "садиться (на транспорт); ладить; продолжать",
"get over": "преодолевать, оправляться (от болезни/потери)",
"get rid of": "избавляться от",
"get through": "справляться; дозваниваться",
"get up": "вставать (с постели); подниматься",
# go
"go on": "продолжать; происходить",
"go out": "выходить в свет; встречаться; гаснуть (об огне/свете)",
"go through": "проходить через, испытывать (трудности); просматривать",
"go back": "возвращаться",
"go off": "звонить (о будильнике); взрываться; портиться (о еде)",
"go over": "проверять, внимательно просматривать; повторять",
"go ahead": "начинать, продолжать; давать добро",
"go with": "сочетаться с, подходить к",
"go down": "спускаться, снижаться; входить в историю",
"go up": "подниматься, расти (о цене)",
# come
"come up with": "придумывать, предлагать (идею, решение)",
"come across": "случайно натолкнуться на, производить впечатление",
"come back": "возвращаться",
"come in": "входить",
"come out": "выходить (в свет); выясняться, обнаруживаться",
"come over": "приходить в гости; охватывать",
"come on": "давай!; появляться; начинаться",
"come up": "возникать, всплывать (о вопросе); подходить",
"come down to": "сводиться к",
# look
"look forward to": "ждать с нетерпением, предвкушать",
"look for": "искать",
"look after": "присматривать за, заботиться",
"look into": "исследовать, изучать, разбираться",
"look up": "искать (в словаре/справочнике); улучшаться",
"look up to": "уважать, брать пример с",
"look down on": "смотреть свысока, презирать",
"look out": "остерегаться, быть начеку",
"look around": "осматриваться",
# turn
"turn off": "выключать (прибор, свет, воду)",
"turn on": "включать (прибор, свет, воду)",
"turn up": "прибавлять громкость; неожиданно объявляться",
"turn down": "убавлять громкость; отклонять, отвергать",
"turn out": "оказываться; оборачиваться (результатом)",
"turn into": "превращаться в",
"turn back": "поворачивать назад, разворачиваться",
"turn over": "переворачивать; передавать",
# set
"set up": "устанавливать, организовывать, подготавливать",
"set off": "отправляться в путь; вызывать (реакцию)",
"set out": "отправляться в путь; намереваться",
# put
"put on": "надевать (одежду); включать; прибавлять в весе",
"put off": "откладывать (на потом); вызывать отвращение",
"put out": "тушить (огонь); выпускать (продукцию)",
"put up with": "мириться с, терпеть",
"put away": "убирать на место",
"put forward": "выдвигать (идею/предложение)",
# run
"run out of": "заканчиваться, исчерпать запасы",
"run out": "иссякать, заканчиваться",
"run into": "случайно встретить; сталкиваться",
"run away": "убегать, сбегать",
"run over": "переехать, сбить; повторить бегло",
# bring
"bring up": "воспитывать (детей); поднимать (тему в разговоре)",
"bring about": "вызывать, приводить к",
"bring back": "возвращать, вызывать воспоминания",
"bring in": "привлекать, вводить (закон/доход)",
# call
"call off": "отменять (встречу/мероприятие)",
"call back": "перезванивать; отзывать",
"call on": "навещать; призывать к ответу",
"call for": "требовать, предусматривать",
# carry
"carry out": "выполнять, приводить в исполнение (план/исследование)",
"carry on": "продолжать (дело/разговор)",
# break
"break down": "ломаться (о технике); терять самообладание",
"break up": "расставаться (об отношениях); распадаться",
"break into": "вламываться, незаконно проникать",
"break out": "разразиться (о войне/пожаре); сбегать (из тюрьмы)",
# fall
"fall apart": "разваливаться на куски, терпеть крах",
"fall behind": "отставать (в графике/учёбе)",
"fall for": "попасться на удочку; влюбиться по уши",
"fall out": "ссориться; выпадать",
# keep
"keep up with": "поспевать за, держаться наравне с",
"keep up": "поддерживать на том же уровне",
"keep on": "упорно продолжать",
"keep away": "держаться подальше, избегать",
# hold
"hold on": "подождать (на линии); крепко держаться",
"hold up": "задерживать; грабить",
"hold back": "сдерживать (эмоции/прогресс)",
# point
"point out": "указывать, обращать внимание на",
# figure
"figure out": "выяснить, понять, разобраться",
# find
"find out": "узнавать, выяснять (информацию)",
# show
"show up": "появляться, приходить (неожиданно)",
"show off": "выпендриваться, хвастаться",
# work
"work out": "тренироваться; решать проблему; складываться успешно",
# catch
"catch up with": "догонять, нагонять (упущенное/кого-то)",
"catch up": "догонять; наверстывать упущенное",
# check
"check in": "регистрироваться (в отеле/аэропорту)",
"check out": "выселяться; проверять, заценить",
# drop
"drop in": "заглянуть в гости без предупреждения",
"drop by": "зайти мимоходом",
"drop off": "подвезти (на машине); заснуть",
# end
"end up": "оказаться в итоге (где-то или в каком-то состоянии)",
# stand
"stand out": "выделяться, бросаться в глаза",
"stand for": "означать; выступать в защиту",
# cut
"cut down on": "сокращать потребление (сахара/расходов)",
"cut down": "срубать; сокращать",
"cut off": "отрезать, прерывать (связь)",
# fill
"fill in": "заполнять (пропуски/форму); вводить в курс дела",
"fill out": "заполнять (анкету/бланк)",
# hang
"hang out": "проводить время, тусоваться",
"hang up": "вешать телефонную трубку",
# pass
"pass away": "умирать, уходить из жизни",
"pass out": "терять сознание, отключаться",
# pick
"pick up": "подбирать; заезжать за кем-то; учить на лету",
# pull
"pull through": "справляться, выкарабкиваться (из кризиса/болезни)",
"pull over": "съехать на обочину, притормозить",
# other essentials
"back up": "поддерживать; создавать резервную копию",
"clear up": "проясняться (о погоде); убирать, наводить порядок",
"slow down": "замедляться",
"speed up": "ускоряться",
"switch on": "включать",
"switch off": "выключать",
"throw away": "выбрасывать (мусор)",
"try on": "примерять (одежду)",
"try out": "пробовать, испытывать",
"watch out": "остерегаться, беречься",
"write down": "записывать на бумаге",
"calm down": "успокаиваться",
"cheer up": "приободряться, поднимать настроение",
"make up for": "компенсировать, заглаживать вину",
"make up": "мириться; сочинять (историю); краситься",
"settle down": "остепениться; успокоиться",
"sort out": "улаживать, разбираться с проблемами",
"blow up": "взрываться; выходить из себя",
"burn down": "сгорать дотла",
"tear down": "сносить (здание)",
"wear out": "изнашиваться; утомлять",
"stick to": "придерживаться (плана/правил)",
"kick off": "начинать, давать старт",
"lock up": "запирать на замок",
"pay off": "окупаться; расплачиваться с долгами",
"count on": "рассчитывать на кого-то",
"stay up": "бодрствовать, не ложиться спать",
"speak up": "говорить громче; высказываться смело",
}
import json
from pathlib import Path
from app import config
_CURATED_PHRASAL_VERBS_CACHE: Optional[Dict[str, str]] = None
def get_curated_phrasal_verbs() -> Dict[str, str]:
global _CURATED_PHRASAL_VERBS_CACHE
if _CURATED_PHRASAL_VERBS_CACHE is None:
seeds_file = config.BASE_DIR / "data" / "seeds" / "phrasal_verbs.json"
if seeds_file.is_file():
try:
with open(seeds_file, "r", encoding="utf-8") as f:
_CURATED_PHRASAL_VERBS_CACHE = json.load(f)
except Exception:
_CURATED_PHRASAL_VERBS_CACHE = {}
else:
_CURATED_PHRASAL_VERBS_CACHE = {}
return _CURATED_PHRASAL_VERBS_CACHE
CURATED_PHRASAL_VERBS: Dict[str, str] = get_curated_phrasal_verbs()
def lemmatize_word(w: str) -> List[str]:
+5 -39
View File
@@ -286,44 +286,6 @@ def split_long_text(text: str, target_words: int = 3000) -> List[str]:
return chunks
def parse_text(content: str, title: str = "Untitled", author: str = "") -> Tuple[str, str, List[Dict[str, str]]]:
"""
Parses plain text or Markdown into readable chapters.
Splits by explicit chapter markers (e.g. 'Chapter X', '# Chapter') or chunks of ~3000 words.
"""
content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not content:
return title, author, [{"title": title if title != "Untitled" else "Chapter 1", "content": ""}]
lines = content.split("\n")
first_line = lines[0].strip()
# Extract title from first line or # Heading / <h1...> if default or generic README
if title == "Untitled" or title.lower().startswith("readme"):
if first_line.startswith("# "):
title = first_line.lstrip("# ").strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
elif first_line.lower().startswith("title:"):
title = first_line.split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
else:
h1_match = re.search(r"<h1[^>]*>(.*?)</h1>", content[:1500], re.IGNORECASE)
if h1_match:
detected = re.sub(r"<[^>]+>", "", h1_match.group(1)).strip()
if detected:
title = detected
# Extract author from next line if missing
if not author:
lines = content.split("\n")
if lines and lines[0].lower().startswith(("author:", "by:")):
author = lines[0].split(":", 1)[1].strip()
if len(lines) > 1:
content = "\n".join(lines[1:]).strip()
def detect_text_headings(content: str) -> List[Dict]:
"""
Detects section and chapter headings with high accuracy across Markdown,
@@ -1220,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)
@@ -1267,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
+43
View File
@@ -0,0 +1,43 @@
from app.routers.auth import router as auth_router
from app.routers.dictionary import router as dictionary_router
from app.routers.listening import router as listening_router
from app.routers.cards import router as cards_router
from app.routers.srs import router as srs_router
from app.routers.export import router as export_router
from app.routers.reader import router as reader_router
from app.routers.cefr import router as cefr_router
from app.routers.grammar import router as grammar_router
from app.routers.tasks import router as tasks_router
from app.routers.lessons import router as lessons_router
from app.routers.feedback import router as feedback_router
all_routers = [
auth_router,
dictionary_router,
listening_router,
cards_router,
srs_router,
export_router,
reader_router,
cefr_router,
grammar_router,
tasks_router,
lessons_router,
feedback_router,
]
__all__ = [
"all_routers",
"auth_router",
"dictionary_router",
"listening_router",
"cards_router",
"srs_router",
"export_router",
"reader_router",
"cefr_router",
"grammar_router",
"tasks_router",
"lessons_router",
"feedback_router",
]
+94
View File
@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel, Field
from app import config
from app.auth import (
authenticate_user,
create_session,
delete_session,
check_login_rate_limit,
record_login_failure,
record_login_success,
change_user_password,
require_user,
)
router = APIRouter(tags=["Auth"])
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=6)
@router.post("/api/login")
async def api_login(req: LoginRequest, request: Request, response: Response):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
ip = forwarded.split(",")[0].strip()
else:
ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown")
if not check_login_rate_limit(ip):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Please try again in a minute.",
)
user = authenticate_user(req.username, req.password)
if not user:
record_login_failure(ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
record_login_success(ip)
user_agent = request.headers.get("User-Agent", "")
session_token = create_session(user["id"], user_agent=user_agent, ip=ip)
is_secure = request.headers.get("x-forwarded-proto", "").lower() == "https" or request.url.scheme == "https"
response.set_cookie(
key=config.SESSION_COOKIE_NAME,
value=session_token,
max_age=config.SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=is_secure,
)
return {
"status": "ok",
"user": {"id": user["id"], "username": user["username"]},
"token": session_token,
}
@router.post("/api/logout")
async def api_logout(request: Request, response: Response):
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if token:
delete_session(token)
response.delete_cookie(key=config.SESSION_COOKIE_NAME)
return {"status": "ok"}
@router.get("/api/me")
async def api_me(user: dict = Depends(require_user)):
return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}}
@router.post("/api/change-password")
@router.post("/api/auth/change-password")
async def api_change_password(req: ChangePasswordRequest, user: dict = Depends(require_user)):
ok, msg = change_user_password(user["id"], req.old_password, req.new_password)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=msg or "Current password does not match",
)
return {"status": "ok", "message": "Password changed successfully"}
+349
View File
@@ -0,0 +1,349 @@
import asyncio
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.tts import get_audio_hash, prewarm_card_audio
router = APIRouter(tags=["Cards & Collections"])
class CardCreateRequest(BaseModel):
term: str = Field(..., min_length=1)
translation: str = Field(..., min_length=1)
context_sentence: Optional[str] = ""
phonetic: Optional[str] = ""
audio_url: Optional[str] = ""
part_of_speech: Optional[str] = ""
definition_en: Optional[str] = ""
tags: Optional[str] = "general"
notes: Optional[str] = ""
collection: Optional[str] = "General"
class CardUpdateRequest(BaseModel):
term: Optional[str] = None
translation: Optional[str] = None
context_sentence: Optional[str] = None
phonetic: Optional[str] = None
audio_url: Optional[str] = None
part_of_speech: Optional[str] = None
definition_en: Optional[str] = None
tags: Optional[str] = None
notes: Optional[str] = None
collection: Optional[str] = None
class BatchActionRequest(BaseModel):
card_ids: List[int] = Field(..., min_length=1)
action: str = Field(..., pattern="^(set_collection|add_tags|delete)$")
value: Optional[str] = ""
@router.get("/api/collections")
async def api_get_collections(user: dict = Depends(require_user)):
"""Returns all collections for the current user with card count."""
with get_db() as conn:
rows = conn.execute(
"""
SELECT collection, COUNT(*) as count
FROM vocabulary_cards
WHERE user_id = ?
GROUP BY collection
ORDER BY count DESC, collection ASC
""",
(user["id"],),
).fetchall()
return [{"name": r["collection"], "collection": r["collection"], "count": r["count"]} for r in rows]
@router.get("/api/cards/check-duplicate")
async def api_check_duplicate(term: str = Query(..., min_length=1), user: dict = Depends(require_user)):
clean_term = term.strip().lower()
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = ?",
(user["id"], clean_term),
).fetchone()
if row:
return {"exists": True, "card": dict(row)}
return {"exists": False, "card": None}
@router.get("/api/cards")
async def api_get_cards(
search: Optional[str] = None,
tag: Optional[str] = None,
collection: Optional[str] = None,
srs_state: Optional[str] = None,
limit: int = 100,
offset: int = 0,
user: dict = Depends(require_user),
):
with get_db() as conn:
query = """
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection,
srs_reps, srs_interval, srs_ease, srs_next_review, srs_last_review, srs_state, error_count,
created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ?
"""
params = [user["id"]]
if search:
s_clean = f"%{search.strip()}%"
query += " AND (term LIKE ? OR translation LIKE ? OR context_sentence LIKE ? OR notes LIKE ?)"
params.extend([s_clean, s_clean, s_clean, s_clean])
if tag:
t_clean = f"%{tag.strip()}%"
query += " AND tags LIKE ?"
params.append(t_clean)
if collection:
query += " AND collection = ?"
params.append(collection.strip())
if srs_state:
query += " AND srs_state = ?"
params.append(srs_state.strip())
# Total count before limit
count_query = f"SELECT COUNT(*) FROM ({query})"
total = conn.execute(count_query, params).fetchone()[0]
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
cards = []
for r in rows:
c = dict(r)
if not c.get("audio_url"):
c["audio_url"] = f"/api/audio/{get_audio_hash(c['term'], 'en', 1.0)}.wav"
if c.get("context_sentence"):
c["context_audio_url"] = f"/api/audio/{get_audio_hash(c['context_sentence'], 'en', 1.0)}.wav"
cards.append(c)
return {"total": total, "cards": cards}
@router.post("/api/cards")
async def api_create_card(card: CardCreateRequest, user: dict = Depends(require_user)):
clean_term = card.term.strip()
with get_db() as conn:
existing = conn.execute(
"SELECT * FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], clean_term),
).fetchone()
if existing:
# Word already exists in library!
existing_row = dict(existing)
new_ctx = (card.context_sentence or "").strip()
old_ctx = (existing_row.get("context_sentence") or "").strip()
updated_ctx = old_ctx
if new_ctx and new_ctx not in old_ctx:
updated_ctx = f"{old_ctx}\n\n{new_ctx}" if old_ctx else new_ctx
# Merge tags
old_tags = set([t.strip() for t in (existing_row.get("tags") or "").split(",") if t.strip()])
new_tags = [t.strip() for t in (card.tags or "").split(",") if t.strip()]
for t in new_tags:
old_tags.add(t)
updated_tags = ", ".join(sorted(old_tags)) if old_tags else "general"
conn.execute(
"""
UPDATE vocabulary_cards
SET context_sentence = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(updated_ctx, updated_tags, existing_row["id"]),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (existing_row["id"],)).fetchone()
res = dict(updated_row)
if not res.get("audio_url"):
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
if res.get("context_sentence"):
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
res["already_exists"] = True
# Pre-warm word and context audio in background
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
return res
audio_url_val = (card.audio_url or "").strip()
if not audio_url_val:
audio_url_val = f"/api/audio/{get_audio_hash(clean_term, 'en', 1.0)}.wav"
cursor = conn.execute(
"""
INSERT INTO vocabulary_cards (
user_id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection,
srs_reps, srs_interval, srs_ease, srs_state, error_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 2.5, 'new', 0)
""",
(
user["id"],
clean_term,
card.translation.strip(),
(card.context_sentence or "").strip(),
(card.phonetic or "").strip(),
audio_url_val,
(card.part_of_speech or "").strip(),
(card.definition_en or "").strip(),
(card.tags or "general").strip(),
(card.notes or "").strip(),
(card.collection or "General").strip(),
),
)
new_id = cursor.lastrowid
row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (new_id,)).fetchone()
res = dict(row)
if not res.get("audio_url"):
res["audio_url"] = f"/api/audio/{get_audio_hash(res['term'], 'en', 1.0)}.wav"
if res.get("context_sentence"):
res["context_audio_url"] = f"/api/audio/{get_audio_hash(res['context_sentence'], 'en', 1.0)}.wav"
# Pre-warm word and context audio in background
asyncio.create_task(prewarm_card_audio(clean_term, card.context_sentence))
return res
@router.get("/api/cards/{card_id}")
async def api_get_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
return dict(row)
@router.put("/api/cards/{card_id}")
async def api_update_card(card_id: int, update: CardUpdateRequest, user: dict = Depends(require_user)):
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
current = dict(row)
def pick(new_val, current_val):
return new_val.strip() if new_val is not None else current_val
conn.execute(
"""
UPDATE vocabulary_cards
SET term = ?, translation = ?, context_sentence = ?, phonetic = ?,
audio_url = ?, part_of_speech = ?, definition_en = ?, tags = ?,
notes = ?, collection = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(
pick(update.term, current["term"]),
pick(update.translation, current["translation"]),
pick(update.context_sentence, current.get("context_sentence", "")),
pick(update.phonetic, current.get("phonetic", "")),
pick(update.audio_url, current.get("audio_url", "")),
pick(update.part_of_speech, current.get("part_of_speech", "")),
pick(update.definition_en, current.get("definition_en", "")),
pick(update.tags, current.get("tags", "")),
pick(update.notes, current.get("notes", "")),
pick(update.collection, current.get("collection", "General")),
card_id,
user["id"],
),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (card_id,)).fetchone()
return dict(updated_row)
@router.delete("/api/cards/{card_id}")
async def api_delete_card(card_id: int, user: dict = Depends(require_user)):
with get_db() as conn:
cursor = conn.execute(
"DELETE FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(card_id, user["id"]),
)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Card not found")
return {"status": "ok", "deleted_id": card_id}
@router.post("/api/cards/batch")
async def api_batch_action(req: BatchActionRequest, user: dict = Depends(require_user)):
if not req.card_ids:
raise HTTPException(status_code=400, detail="No card IDs provided")
with get_db() as conn:
placeholders = ",".join("?" * len(req.card_ids))
params_base = list(req.card_ids) + [user["id"]]
if req.action == "delete":
cursor = conn.execute(
f"DELETE FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
params_base,
)
return {"status": "ok", "action": "delete", "affected": cursor.rowcount}
elif req.action == "set_collection":
collection_val = (req.value or "General").strip()
cursor = conn.execute(
f"UPDATE vocabulary_cards SET collection = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND user_id = ?",
[collection_val] + params_base,
)
return {"status": "ok", "action": "set_collection", "affected": cursor.rowcount}
elif req.action == "add_tags":
new_tags_raw = req.value or ""
new_tags = [t.strip() for t in new_tags_raw.split(",") if t.strip()]
if not new_tags:
return {"status": "ok", "action": "add_tags", "affected": 0}
rows = conn.execute(
f"SELECT id, tags FROM vocabulary_cards WHERE id IN ({placeholders}) AND user_id = ?",
params_base,
).fetchall()
affected = 0
for r in rows:
existing = [t.strip() for t in (r["tags"] or "").split(",") if t.strip()]
combined = existing + [t for t in new_tags if t not in existing]
conn.execute(
"UPDATE vocabulary_cards SET tags = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(", ".join(combined), r["id"]),
)
affected += 1
return {"status": "ok", "action": "add_tags", "affected": affected}
@router.get("/api/tags")
async def api_get_tags(user: dict = Depends(require_user)):
with get_db() as conn:
rows = conn.execute(
"SELECT tags FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchall()
tag_counts = {}
for r in rows:
if r["tags"]:
for t in r["tags"].split(","):
clean = t.strip()
if clean:
tag_counts[clean] = tag_counts.get(clean, 0) + 1
sorted_tags = sorted(
[{"tag": k, "count": v} for k, v in tag_counts.items()],
key=lambda x: x["count"],
reverse=True,
)
return sorted_tags
+96
View File
@@ -0,0 +1,96 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.auth import require_user
from app.database import get_db
from app.cefr import (
get_cefr_level_summaries,
get_cefr_quiz_batch,
process_cefr_answer,
reset_user_cefr_level,
ingest_cefr_word,
)
router = APIRouter(tags=["CEFR Testing & Mining"])
class CefrAnswerRequest(BaseModel):
question_id: int
selected_option: str
class CefrResetRequest(BaseModel):
level: str
class CefrAddWordRequest(BaseModel):
level: str
term: str
context_sentence: Optional[str] = ""
@router.get("/api/cefr/levels")
async def api_cefr_levels(user: dict = Depends(require_user)):
"""
Returns progress summaries for all CEFR levels (A1, A2, B1, B2).
"""
with get_db() as conn:
summaries = get_cefr_level_summaries(conn, user["id"])
return {"levels": summaries}
@router.get("/api/cefr/quiz")
async def api_cefr_quiz(
level: str = Query("A1"),
count: int = Query(10, ge=1, le=30),
mode: str = Query("all"),
user: dict = Depends(require_user),
):
"""
Returns next batch of Cloze Tap test questions for level (A1..B2).
"""
with get_db() as conn:
quiz_items = get_cefr_quiz_batch(conn, user["id"], level, count, mode)
return {"level": level.upper(), "count": len(quiz_items), "quiz": quiz_items}
@router.post("/api/cefr/answer")
async def api_cefr_answer(
req: CefrAnswerRequest,
user: dict = Depends(require_user),
):
"""
Submits an answer for a CEFR question.
If wrong, automatically ingests into user's SRS active vocabulary with status 'learning'.
"""
with get_db() as conn:
result = process_cefr_answer(conn, user["id"], req.question_id, req.selected_option)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@router.post("/api/cefr/reset")
async def api_cefr_reset(
req: CefrResetRequest,
user: dict = Depends(require_user),
):
"""
Resets user testing progress for a given CEFR level.
"""
with get_db() as conn:
deleted = reset_user_cefr_level(conn, user["id"], req.level)
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
@router.post("/api/cefr/add-word")
async def api_cefr_add_word(
req: CefrAddWordRequest,
user: dict = Depends(require_user),
):
"""
Adds a word from a CEFR test options list directly into user's SRS active vocabulary.
"""
with get_db() as conn:
return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence)
+169
View File
@@ -0,0 +1,169 @@
import asyncio
import re
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.dictionary import lookup_term
from app.phrasal import detect_phrasal_verb
from app.ai import translate_sentence_ai, get_ollama_status
from app.tts import (
synthesize_speech,
prewarm_card_audio,
AUDIO_CACHE_DIR,
get_available_voices,
)
router = APIRouter(tags=["Dictionary & TTS"])
class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1)
target_word: Optional[str] = ""
context: Optional[str] = ""
@router.get("/api/lookup")
async def api_lookup(
term: Optional[str] = None,
q: Optional[str] = None,
context: Optional[str] = None,
user: dict = Depends(require_user),
):
query_str = (term or q or "").strip()
if not query_str:
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(?)) OR lower(trim(term)) = lower(trim(?)))",
(user["id"], target_term, query_str),
).fetchone()
if existing:
data["in_library"] = True
data["library_card"] = dict(existing)
else:
data["in_library"] = False
data["library_card"] = None
# Multi-Word & Phrasal Verb Detection
data["phrasal_verb"] = None
if context:
pv = detect_phrasal_verb(
sentence=context,
target_word=query_str,
user_id=user["id"],
conn=conn,
)
if pv and pv["term"].lower() != query_str.lower():
data["phrasal_verb"] = pv
return data
@router.get("/api/detect-phrasal-verb")
async def api_detect_phrasal_verb(
sentence: str = Query(..., min_length=1),
word: str = Query(..., min_length=1),
user: dict = Depends(require_user),
):
"""
Analyzes sentence context around word to detect phrasal verbs, idioms, and collocations.
"""
with get_db() as conn:
pv = detect_phrasal_verb(sentence=sentence, target_word=word, user_id=user["id"], conn=conn)
return {"detected": bool(pv), "phrasal_verb": pv}
@router.api_route("/api/audio", methods=["GET", "HEAD"])
async def api_audio_proxy():
# Audio feature disabled
return Response(status_code=204)
@router.post("/api/translate-sentence")
async def api_translate_sentence(
req: SentenceTranslateRequest,
user: dict = Depends(require_user),
):
"""
Translates a sentence using local AI with in-context meaning of the target word.
Uses SQLite sentence_translations cache for sub-millisecond retrieval on repeat queries.
"""
clean_s = req.sentence.strip()
if not clean_s:
raise HTTPException(status_code=400, detail="Sentence cannot be empty")
# Pre-warm neural audio for sentence and target word in background
asyncio.create_task(prewarm_card_audio(req.target_word or "", clean_s))
import sys
main_mod = sys.modules.get("app.main")
translator = getattr(main_mod, "translate_sentence_ai", translate_sentence_ai) if main_mod else translate_sentence_ai
return await translator(clean_s, req.target_word or "", req.context or "")
@router.get("/api/ai/status")
async def api_ai_status(user: dict = Depends(require_user)):
"""
Returns local AI status and available models.
"""
return await get_ollama_status()
@router.get("/api/audio/{hash_val}.wav")
async def api_get_cached_audio(hash_val: str):
"""
Streams pre-warmed / cached audio file directly with immutable cache headers.
"""
clean_hash = hash_val.lower().strip()
if not re.match(r"^[a-f0-9]{32}$", clean_hash):
raise HTTPException(status_code=400, detail="Invalid audio hash")
audio_path = AUDIO_CACHE_DIR / f"{clean_hash}.wav"
if not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@router.get("/api/tts")
async def api_tts(
text: str = Query(..., min_length=1, max_length=1000),
lang: str = Query("en"),
speed: float = Query(1.0, ge=0.5, le=2.0),
voice: Optional[str] = Query(None),
user: dict = Depends(require_user),
):
"""
Synthesizes speech on-demand using local Piper TTS.
Instantly returns cached audio if previously synthesized for this voice, speed, and text.
"""
clean_text = text.strip()
if not clean_text:
raise HTTPException(status_code=400, detail="Text cannot be empty")
audio_path = await synthesize_speech(clean_text, lang=lang, speed=speed, voice=voice)
if not audio_path or not audio_path.is_file():
raise HTTPException(status_code=500, detail="TTS synthesis failed or unavailable")
return FileResponse(
path=str(audio_path),
media_type="audio/wav",
headers={"Cache-Control": "public, max-age=604800, immutable"},
)
@router.get("/api/tts/status")
async def api_tts_status(user: dict = Depends(require_user)):
"""Returns status of local Piper TTS and all available voice models."""
return get_available_voices()
+79
View File
@@ -0,0 +1,79 @@
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, Response
from app.auth import require_user
from app.database import get_db
from app.export import export_csv, export_json, export_anki_tsv, export_markdown
router = APIRouter(tags=["Export"])
def _fetch_all_cards(user_id: int, collection: Optional[str] = None) -> List[Dict[str, Any]]:
with get_db() as conn:
if collection:
rows = conn.execute(
"""
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection, created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ? AND collection = ?
ORDER BY collection ASC, created_at DESC
""",
(user_id, collection),
).fetchall()
else:
rows = conn.execute(
"""
SELECT id, term, translation, context_sentence, phonetic, audio_url,
part_of_speech, definition_en, tags, notes, collection, created_at, updated_at
FROM vocabulary_cards
WHERE user_id = ?
ORDER BY collection ASC, created_at DESC
""",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
@router.get("/api/export/csv")
async def api_export_csv(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_csv(cards)
return Response(
content=data,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="vocabulary.csv"'},
)
@router.get("/api/export/json")
async def api_export_json(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_json(cards)
return Response(
content=data,
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="vocabulary.json"'},
)
@router.get("/api/export/anki")
async def api_export_anki(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_anki_tsv(cards)
return Response(
content=data,
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="vocabulary_anki.txt"'},
)
@router.get("/api/export/markdown")
async def api_export_markdown(collection: Optional[str] = None, user: dict = Depends(require_user)):
cards = _fetch_all_cards(user["id"], collection)
data = export_markdown(cards)
return Response(
content=data,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="vocabulary.md"'},
)
+240
View File
@@ -0,0 +1,240 @@
import json
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
# Paths to markdown reports files for seamless Antigravity AI reading
DATA_REPORTS_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "USER_REPORTS.md"
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
class FeedbackCreateRequest(BaseModel):
category: str = Field(default="bug", description="Category: 'bug', 'feature', 'note'")
message: str = Field(..., min_length=1, max_length=5000)
context_url: Optional[str] = Field(default="", max_length=500)
context_data: Optional[Dict[str, Any]] = None
class FeedbackStatusUpdateRequest(BaseModel):
status: str = Field(..., pattern="^(new|in_progress|resolved|closed)$")
def sync_reports_to_markdown(conn):
"""
Synchronizes feedback_reports from SQLite to /home/ai-projects/memory/USER_REPORTS.md.
This enables Antigravity and AI agents to instantly inspect user-reported bugs and feature requests.
"""
try:
rows = conn.execute("""
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
ORDER BY f.id DESC
""").fetchall()
lines = [
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
"",
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
"",
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
"",
"## 🔴 Активные репорты (Требуют внимания / В работе)",
"",
]
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
if not active_rows:
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
lines.append("")
else:
for r in active_rows:
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
ctx_extra = ""
if r["context_data_json"]:
try:
c_dict = json.loads(r["context_data_json"])
parts = []
if "screen" in c_dict: parts.append(f"Экран: {c_dict['screen']}")
if "device" in c_dict: parts.append(f"Устройство: {c_dict['device']}")
if parts: ctx_extra = f" ({', '.join(parts)})"
except Exception:
pass
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
lines.append(f"- **Пользователь**: `{r['username']}`")
if r["context_url"]:
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
lines.append(f"- **Сообщение**:\n> {r['message'].strip()}")
lines.append("")
lines.extend([
"---",
"",
"## ✅ Решенные / Закрытые задачи",
"",
])
if not resolved_rows:
lines.append("*Пока нет закрытых репортов.*")
lines.append("")
else:
for r in resolved_rows:
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
lines.append("")
content = "\n".join(lines)
for target in [DATA_REPORTS_FILE, MEMORY_REPORTS_FILE]:
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
except Exception as e:
logger.debug(f"Could not write to {target}: {e}")
except Exception as e:
logger.warning(f"Could not sync reports to markdown: {e}")
@router.get("", response_model=List[Dict[str, Any]])
def list_feedback(
category: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
user: dict = Depends(require_user),
):
"""Lists feedback reports. Admins see all reports; regular users see their own."""
is_admin = bool(user.get("is_admin"))
with get_db() as conn:
query = """
SELECT f.id, f.user_id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
WHERE 1=1
"""
params = []
if not is_admin:
query += " AND f.user_id = ?"
params.append(user["id"])
if category:
query += " AND f.category = ?"
params.append(category)
if status_filter:
query += " AND f.status = ?"
params.append(status_filter)
query += " ORDER BY f.id DESC LIMIT 100"
rows = conn.execute(query, params).fetchall()
result = []
for r in rows:
ctx_data = {}
if r["context_data_json"]:
try:
ctx_data = json.loads(r["context_data_json"])
except Exception:
pass
result.append({
"id": r["id"],
"user_id": r["user_id"],
"username": r["username"],
"category": r["category"],
"message": r["message"],
"context_url": r["context_url"],
"context_data": ctx_data,
"status": r["status"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
})
return result
@router.post("", status_code=status.HTTP_201_CREATED)
def create_feedback(
payload: FeedbackCreateRequest,
user: dict = Depends(require_user),
):
"""Submits a new bug, feature request, or note from the website chat widget."""
allowed_categories = {"bug", "feature", "note"}
category = payload.category if payload.category in allowed_categories else "bug"
ctx_json = json.dumps(payload.context_data or {}, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO feedback_reports (user_id, category, message, context_url, context_data_json, status)
VALUES (?, ?, ?, ?, ?, 'new')
""",
(user["id"], category, payload.message.strip(), payload.context_url or "", ctx_json),
)
report_id = cursor.lastrowid
sync_reports_to_markdown(conn)
return {
"ok": True,
"id": report_id,
"category": category,
"status": "new",
"message": "Репорт успешно отправлен и передан в систему задач",
}
@router.patch("/{report_id}")
def update_feedback_status(
report_id: int,
payload: FeedbackStatusUpdateRequest,
user: dict = Depends(require_user),
):
"""Updates the status of a report (e.g. 'in_progress', 'resolved')."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute(
"UPDATE feedback_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(payload.status, report_id),
)
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id, "status": payload.status}
@router.delete("/{report_id}")
def delete_feedback(
report_id: int,
user: dict = Depends(require_user),
):
"""Deletes a report."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute("DELETE FROM feedback_reports WHERE id = ?", (report_id,))
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id}
+97
View File
@@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.grammar import (
get_grammar_units_list,
get_grammar_unit_detail,
process_grammar_answer,
record_grammar_completion,
update_handwritten_status,
)
router = APIRouter(tags=["Murphy Grammar"])
class GrammarAnswerRequest(BaseModel):
unit_id: int
exercise_idx: int
selected_option: str
class GrammarCompleteRequest(BaseModel):
unit_id: int
score: int = Field(..., ge=0, le=100)
handwritten_logged: bool = False
class GrammarHandwrittenRequest(BaseModel):
unit_id: int
handwritten: bool
@router.get("/api/grammar/units")
async def api_grammar_units(user: dict = Depends(require_user)):
"""
Returns the list of all curated Murphy grammar units with user progress.
"""
with get_db() as conn:
units = get_grammar_units_list(conn, user["id"])
return {"units": units}
@router.get("/api/grammar/units/{unit_id}")
async def api_grammar_unit_detail(unit_id: int, user: dict = Depends(require_user)):
"""
Returns full content of a unit for lesson mode.
"""
with get_db() as conn:
unit = get_grammar_unit_detail(conn, user["id"], unit_id)
if not unit:
raise HTTPException(status_code=404, detail="Unit not found")
return unit
@router.post("/api/grammar/answer")
async def api_grammar_answer(
req: GrammarAnswerRequest,
user: dict = Depends(require_user),
):
"""
Checks answer for a Cloze Tap grammar exercise.
If wrong, performs automated gap ingestion into active SRS vocabulary.
"""
with get_db() as conn:
result = process_grammar_answer(
conn, user["id"], req.unit_id, req.exercise_idx, req.selected_option
)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
return result
@router.post("/api/grammar/complete")
async def api_grammar_complete(
req: GrammarCompleteRequest,
user: dict = Depends(require_user),
):
"""
Records completion of a grammar unit and user's score.
"""
with get_db() as conn:
return record_grammar_completion(
conn, user["id"], req.unit_id, req.score, req.handwritten_logged
)
@router.post("/api/grammar/handwritten")
async def api_grammar_handwritten(
req: GrammarHandwrittenRequest,
user: dict = Depends(require_user),
):
"""
Toggles handwritten paper notebook checkbox for a unit.
"""
with get_db() as conn:
return update_handwritten_status(conn, user["id"], req.unit_id, req.handwritten)
+193
View File
@@ -0,0 +1,193 @@
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.auth import require_user
from app.database import get_db
from app.lessons import (
get_user_presets,
save_user_preset,
delete_user_preset,
get_grammar_recommendations,
get_curated_articles,
record_lesson_session,
get_lesson_history,
)
from app.ai import generate_lesson_ai_debrief
router = APIRouter(tags=["Lessons"])
class SavePresetRequest(BaseModel):
name: str
steps: List[Dict[str, Any]]
icon: Optional[str] = "🚀"
description: Optional[str] = ""
estimated_min: Optional[int] = 15
class LessonDebriefRequest(BaseModel):
preset_name: str
duration_seconds: int
total_score: int
steps: List[Dict[str, Any]]
errors: List[Dict[str, Any]]
@router.get("/api/lessons/presets")
async def api_lessons_get_presets(user: dict = Depends(require_user)):
"""
Returns available lesson presets (defaults + user-custom).
"""
with get_db() as conn:
presets = get_user_presets(conn, user["id"])
return {"presets": presets}
@router.post("/api/lessons/presets")
async def api_lessons_save_preset(
req: SavePresetRequest,
user: dict = Depends(require_user),
):
"""
Saves a custom user lesson configuration preset.
"""
if not req.name.strip():
raise HTTPException(status_code=400, detail="Название шаблона не может быть пустым")
with get_db() as conn:
saved = save_user_preset(
conn,
user_id=user["id"],
name=req.name,
steps=req.steps,
icon=req.icon or "🚀",
description=req.description or "",
estimated_min=req.estimated_min or 15,
)
return {"status": "ok", "preset": saved}
@router.delete("/api/lessons/presets/{preset_id}")
async def api_lessons_delete_preset(
preset_id: int,
user: dict = Depends(require_user),
):
"""
Deletes a custom user lesson preset.
"""
with get_db() as conn:
deleted = delete_user_preset(conn, user["id"], preset_id)
if not deleted:
raise HTTPException(status_code=404, detail="Пользовательский шаблон не найден")
return {"status": "ok", "deleted": True}
@router.get("/api/lessons/grammar-recommendations")
async def api_lessons_grammar_recommendations(user: dict = Depends(require_user)):
"""
Calculates intelligent grammar suggestions: weakest topic, last studied topic,
next in syllabus, and full units list.
"""
with get_db() as conn:
recs = get_grammar_recommendations(conn, user["id"])
return recs
@router.get("/api/lessons/articles")
async def api_lessons_articles(
topic: Optional[str] = None,
user: dict = Depends(require_user),
):
"""
Returns curated high-yield reading articles/facts for the lesson reading block.
"""
articles = get_curated_articles(topic=topic)
return {"articles": articles}
@router.post("/api/lessons/debrief")
async def api_lessons_debrief(
req: LessonDebriefRequest,
user: dict = Depends(require_user),
):
"""
Generates a pedagogic AI debrief for the completed lesson session,
enriches with historical error context, and records the session.
"""
# 1. Fetch user error history for pedagogical pattern detection
with get_db() as conn:
chronic_rows = conn.execute(
"""
SELECT v.term, count(*) as err_cnt
FROM srs_logs l
JOIN vocabulary_cards v ON v.id = l.card_id
WHERE l.user_id = ? AND l.rating = 1
GROUP BY v.term
HAVING err_cnt > 1
ORDER BY err_cnt DESC
LIMIT 10
""",
(user["id"],),
).fetchall()
chronic_vocab = [r["term"] for r in chronic_rows]
prog_rows = conn.execute(
"""
SELECT u.unit_num
FROM grammar_progress p
JOIN grammar_units u ON u.id = p.unit_id
WHERE p.user_id = ? AND p.score < 80
""",
(user["id"],),
).fetchall()
past_grammar = [r["unit_num"] for r in prog_rows]
history_data = {
"chronic_vocab_errors": chronic_vocab,
"past_grammar_errors": past_grammar,
}
session_payload = {
"preset_name": req.preset_name,
"duration_seconds": req.duration_seconds,
"total_score": req.total_score,
"steps": req.steps,
"errors": req.errors,
}
# 2. Generate AI or deterministic debrief
ai_report = await generate_lesson_ai_debrief(session_payload, history_data)
# 3. Save completed session to SQLite
with get_db() as conn:
session_id = record_lesson_session(
conn=conn,
user_id=user["id"],
preset_name=req.preset_name,
duration_seconds=req.duration_seconds,
total_score=req.total_score,
grade=ai_report.get("grade", "A"),
steps_summary=req.steps,
errors_list=req.errors,
ai_report=ai_report,
)
return {
"session_id": session_id,
"ai_report": ai_report,
"total_score": req.total_score,
"grade": ai_report.get("grade", "A"),
}
@router.get("/api/lessons/history")
async def api_lessons_history(
limit: int = 15,
user: dict = Depends(require_user),
):
"""
Returns history of completed comprehensive lessons and overall learning metrics.
"""
with get_db() as conn:
data = get_lesson_history(conn, user["id"], limit=limit)
return data
+64
View File
@@ -0,0 +1,64 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from app.auth import require_user
from app.database import get_db
from app.listening import get_listening_exercise, evaluate_listening_answer
router = APIRouter(tags=["Listening"])
class ListeningCheckRequest(BaseModel):
scenario_id: str
user_answer: str
story: Optional[str] = ""
ideal_answer: Optional[str] = ""
@router.get("/api/listening/exercise")
async def api_listening_exercise(
topic: Optional[str] = Query("all"),
level: Optional[str] = Query("B1"),
voice: Optional[str] = Query(None),
user: dict = Depends(require_user),
):
"""
Returns a listening comprehension exercise (topic / level / vocabulary context)
with pre-synthesized audio URLs for the story and question.
"""
user_cards = []
if topic == "cards":
with get_db() as conn:
rows = conn.execute(
"""
SELECT id, term, translation, context_sentence
FROM vocabulary_cards
WHERE user_id = ? AND length(context_sentence) > 20
ORDER BY RANDOM() LIMIT 20
""",
(user["id"],),
).fetchall()
user_cards = [dict(r) for r in rows]
ex = await get_listening_exercise(topic=topic, level=level, user_cards=user_cards, voice=voice)
# Strip keywords and ideal_answer from client response to prevent inspection
safe_ex = {k: v for k, v in ex.items() if k not in ("keywords", "ideal_answer")}
return safe_ex
@router.post("/api/listening/check")
async def api_listening_check(
req: ListeningCheckRequest,
user: dict = Depends(require_user),
):
"""
Evaluates user's answer to the listening question.
Returns score, pedagogic feedback, full story transcription, and ideal answer.
"""
return evaluate_listening_answer(
scenario_id=req.scenario_id,
user_answer=req.user_answer,
story=req.story or "",
ideal_answer=req.ideal_answer or "",
)
+487
View File
@@ -0,0 +1,487 @@
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from pydantic import BaseModel
from app.auth import require_user
from app.database import get_db
from app.reader import (
parse_epub,
parse_text,
parse_pdf,
count_words,
extract_rfc_number,
fetch_and_parse_rfc,
fetch_and_parse_wikipedia,
extract_wikipedia_title_and_lang,
)
router = APIRouter(tags=["Reader & Bookshelf"])
class BookCreateRequest(BaseModel):
title: Optional[str] = "Untitled"
author: Optional[str] = ""
content: Optional[str] = ""
source_type: Optional[str] = "text"
class BookProgressRequest(BaseModel):
current_chapter: Optional[int] = None
current_position: Optional[float] = None
chapter: Optional[int] = None
position: Optional[float] = None
class RfcFetchRequest(BaseModel):
rfc: str
force: Optional[bool] = False
class WikipediaFetchRequest(BaseModel):
query: Optional[str] = ""
lang: Optional[str] = "simple"
time_budget: Optional[str] = "10-15"
topic: Optional[str] = "random"
force: Optional[bool] = False
@router.get("/api/books")
async def api_get_books(user: dict = Depends(require_user)):
"""
Lists books for current user (id, title, author, source_type, chapters_count,
total_words, current_chapter, current_position, created_at, updated_at).
Excludes full chapters_json for efficiency.
"""
with get_db() as conn:
rows = conn.execute(
"""
SELECT id, user_id, title, author, source_type,
json_array_length(chapters_json) AS chapters_count,
total_words, current_chapter, current_position, cover_url,
created_at, updated_at
FROM books
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC
""",
(user["id"],),
).fetchall()
return [dict(r) for r in rows]
@router.post("/api/books")
async def api_create_book(
request: Request,
user: dict = Depends(require_user),
):
"""
Creates or uploads a book.
Accepts:
- JSON: {"title": "...", "author": "...", "content": "...", "source_type": "text"}
- Multipart file upload: file (UploadFile for .epub, .txt, .md) and optional title, author
"""
content_type = request.headers.get("content-type", "")
title = "Untitled"
author = ""
source_type = "text"
chapters = []
if "multipart/form-data" in content_type:
form = await request.form()
file_obj = form.get("file")
if not file_obj or not hasattr(file_obj, "filename") or not file_obj.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
filename = file_obj.filename
file_bytes = await file_obj.read()
if not file_bytes:
raise HTTPException(status_code=400, detail="Uploaded file is empty")
form_title = (form.get("title") or "").strip()
form_author = (form.get("author") or "").strip()
stem = Path(filename).stem
lower_name = filename.lower()
try:
if lower_name.endswith(".epub"):
source_type = "epub"
parsed_title, parsed_author, chapters = parse_epub(file_bytes)
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
author = form_author or parsed_author
elif lower_name.endswith(".pdf"):
source_type = "pdf"
parsed_title, parsed_author, chapters = parse_pdf(file_bytes, filename=filename)
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
author = form_author or parsed_author
elif lower_name.endswith(".md") or lower_name.endswith(".txt"):
source_type = "md" if lower_name.endswith(".md") else "txt"
text_str = file_bytes.decode("utf-8", errors="replace")
parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author)
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
author = form_author or parsed_author
else:
source_type = "text"
text_str = file_bytes.decode("utf-8", errors="replace")
parsed_title, parsed_author, chapters = parse_text(text_str, title=form_title or "Untitled", author=form_author)
title = form_title or (parsed_title if parsed_title != "Untitled" else "") or stem
author = form_author or parsed_author
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
else:
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON or missing body")
raw_content = body.get("content") or ""
title_in = (body.get("title") or "").strip()
author_in = (body.get("author") or "").strip()
source_type = body.get("source_type") or "text"
if not raw_content and not title_in:
raise HTTPException(status_code=400, detail="Book content is required")
parsed_title, parsed_author, chapters = parse_text(
raw_content, title=title_in or "Untitled", author=author_in
)
title = title_in or parsed_title
author = author_in or parsed_author
total_words = sum(count_words(ch.get("content", "")) for ch in chapters)
chapters_json = json.dumps(chapters, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO books (
user_id, title, author, source_type, chapters_json,
current_chapter, current_position, total_words
) VALUES (?, ?, ?, ?, ?, 0, 0.0, ?)
""",
(user["id"], title, author, source_type, chapters_json, total_words),
)
new_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books WHERE id = ?
""",
(new_id,),
).fetchone()
res = dict(row)
res["chapters"] = chapters
return res
@router.post("/api/books/fetch-rfc")
async def api_fetch_rfc(
req: RfcFetchRequest,
user: dict = Depends(require_user),
):
"""
Fetches an RFC specification by number or URL from RFC Editor & Datatracker,
parses sections into readable chapters, and saves to the user's bookshelf.
If the RFC already exists on user's shelf and force=False, returns the existing book.
"""
raw_query = (req.rfc or "").strip()
if not raw_query:
raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC")
try:
rfc_num = extract_rfc_number(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
# Check if user already loaded this RFC
if not req.force:
with get_db() as conn:
existing = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books
WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?)
ORDER BY id DESC LIMIT 1
""",
(user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"),
).fetchone()
if existing:
res = dict(existing)
try:
res["chapters"] = json.loads(res["chapters_json"])
except Exception:
res["chapters"] = []
res["already_existed"] = True
return res
try:
import sys
main_mod = sys.modules.get("app.main")
rfc_parser = getattr(main_mod, "fetch_and_parse_rfc", fetch_and_parse_rfc) if main_mod else fetch_and_parse_rfc
title, author, chapters, rfc_num, total_words = rfc_parser(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}")
chapters_json = json.dumps(chapters, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO books (
user_id, title, author, source_type, chapters_json,
current_chapter, current_position, total_words
) VALUES (?, ?, ?, 'rfc', ?, 0, 0.0, ?)
""",
(user["id"], title, author, chapters_json, total_words),
)
new_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books WHERE id = ?
""",
(new_id,),
).fetchone()
res = dict(row)
res["chapters"] = chapters
res["already_existed"] = False
return res
@router.post("/api/books/fetch-wikipedia")
async def api_fetch_wikipedia(
req: WikipediaFetchRequest,
user: dict = Depends(require_user),
):
"""
Fetches a Wikipedia article (by title/URL or random topic & duration),
parses sections into readable chapters with media and tables,
and stores autonomously in the user's SQLite bookshelf (source_type = 'wikipedia').
"""
raw_query = (req.query or "").strip()
target_lang = (req.lang or "simple").strip()
time_budget = (req.time_budget or "10-15").strip()
topic = (req.topic or "random").strip()
# If specific title or URL provided and force is False, check for existing book
if raw_query and not req.force:
title_cand, lang_cand = extract_wikipedia_title_and_lang(raw_query, default_lang=target_lang)
search_title = title_cand.replace("_", " ")
with get_db() as conn:
existing = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books
WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?)
ORDER BY id DESC LIMIT 1
""",
(user["id"], search_title),
).fetchone()
if existing:
res = dict(existing)
try:
res["chapters"] = json.loads(res["chapters_json"])
except Exception:
res["chapters"] = []
res["already_existed"] = True
return res
try:
title, author, chapters, cover_url, total_words, wiki_url = fetch_and_parse_wikipedia(
title_or_url=raw_query,
lang=target_lang,
time_budget=time_budget,
topic=topic,
)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка загрузки статьи Wikipedia: {e}")
# Check again by resolved title if not forcing
if not req.force:
with get_db() as conn:
existing = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books
WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?)
ORDER BY id DESC LIMIT 1
""",
(user["id"], title),
).fetchone()
if existing:
res = dict(existing)
try:
res["chapters"] = json.loads(res["chapters_json"])
except Exception:
res["chapters"] = []
res["already_existed"] = True
return res
chapters_json = json.dumps(chapters, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO books (
user_id, title, author, source_type, chapters_json,
current_chapter, current_position, total_words, cover_url
) VALUES (?, ?, ?, 'wikipedia', ?, 0, 0.0, ?, ?)
""",
(user["id"], title, author, chapters_json, total_words, cover_url),
)
new_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books WHERE id = ?
""",
(new_id,),
).fetchone()
res = dict(row)
res["chapters"] = chapters
res["already_existed"] = False
return res
@router.get("/api/books/{book_id}")
async def api_get_book(book_id: int, user: dict = Depends(require_user)):
"""
Returns full book details including chapters_json.
"""
with get_db() as conn:
row = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books
WHERE id = ? AND user_id = ?
""",
(book_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Book not found")
data = dict(row)
try:
data["chapters"] = json.loads(data["chapters_json"])
except Exception:
data["chapters"] = []
return data
@router.put("/api/books/{book_id}/progress")
@router.patch("/api/books/{book_id}/progress")
async def api_update_book_progress(
book_id: int,
req: Optional[BookProgressRequest] = None,
current_chapter: Optional[int] = Query(None),
current_position: Optional[float] = Query(None),
request: Request = None,
user: dict = Depends(require_user),
):
"""
Updates reading progress (current_chapter, current_position) for a book.
"""
ch = None
pos = None
if req is not None:
ch = req.current_chapter if req.current_chapter is not None else req.chapter
pos = req.current_position if req.current_position is not None else req.position
if ch is None:
ch = current_chapter
if pos is None:
pos = current_position
if ch is None or pos is None:
if request:
try:
body = await request.json()
if ch is None:
ch = body.get("current_chapter", body.get("chapter"))
if pos is None:
pos = body.get("current_position", body.get("position"))
except Exception:
pass
if ch is None and pos is None:
raise HTTPException(status_code=400, detail="Missing current_chapter or current_position")
with get_db() as conn:
row = conn.execute(
"SELECT id, current_chapter, current_position FROM books WHERE id = ? AND user_id = ?",
(book_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Book not found")
new_ch = int(ch) if ch is not None else row["current_chapter"]
new_pos = float(pos) if pos is not None else row["current_position"]
conn.execute(
"""
UPDATE books
SET current_chapter = ?, current_position = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(new_ch, new_pos, book_id, user["id"]),
)
updated_row = conn.execute(
"""
SELECT id, user_id, title, author, source_type, chapters_json,
json_array_length(chapters_json) AS chapters_count,
current_chapter, current_position, total_words, cover_url,
created_at, updated_at
FROM books
WHERE id = ?
""",
(book_id,),
).fetchone()
res = dict(updated_row)
try:
res["chapters"] = json.loads(res["chapters_json"])
except Exception:
res["chapters"] = []
return res
@router.delete("/api/books/{book_id}")
async def api_delete_book(book_id: int, user: dict = Depends(require_user)):
"""
Deletes a book for the current user.
"""
with get_db() as conn:
cursor = conn.execute(
"DELETE FROM books WHERE id = ? AND user_id = ?",
(book_id, user["id"]),
)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Book not found")
return {"status": "ok", "deleted_id": book_id}
+379
View File
@@ -0,0 +1,379 @@
import random
from typing import Optional, Dict
from datetime import datetime, timezone, date
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level
from app.tts import get_audio_hash
router = APIRouter(tags=["SRS & Stats"])
class SrsReviewRequest(BaseModel):
card_id: int
rating: int = Field(..., ge=1, le=4)
@router.get("/api/srs/queue")
@router.get("/api/srs/due")
async def api_srs_queue(
collection: Optional[str] = None,
tag: Optional[str] = None,
limit: int = 25,
mix: bool = False,
exclude_grammar: bool = True,
user: dict = Depends(require_user),
):
"""
Returns queue of cards ready for study.
If mix=True (Smart Daily Mix):
- up to 10 new cards
- up to 10 review due cards
- up to 5 leeches / problematic cards (error_count > 0 or low ease)
Shuffled for interleaved recall.
"""
with get_db() as conn:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
col_filter = ""
col_params = []
if collection:
col_filter += " AND collection = ?"
col_params.append(collection.strip())
if tag:
col_filter += " AND tags LIKE ?"
col_params.append(f"%{tag.strip()}%")
# Exclude grammar cards from standard SRS vocabulary training unless grammar collection/tag is specifically requested
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
(tag and "grammar" in tag.lower())
if exclude_grammar and not wants_grammar:
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
if mix:
# 1. New cards (up to 10)
new_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND srs_state = 'new'{col_filter}
ORDER BY created_at DESC LIMIT 10
""",
[user["id"]] + col_params,
).fetchall()
# 2. Due review cards (up to 10)
due_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND srs_state IN ('learning', 'review', 'mastered')
AND (srs_next_review <= ? OR srs_next_review IS NULL){col_filter}
ORDER BY srs_next_review ASC LIMIT 10
""",
[user["id"], now_str] + col_params,
).fetchall()
# 3. Problematic leeches (up to 5 with highest error count)
used_ids = {r["id"] for r in new_rows} | {r["id"] for r in due_rows}
leech_rows = conn.execute(
f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0){col_filter}
ORDER BY error_count DESC, srs_ease ASC LIMIT 10
""",
[user["id"]] + col_params,
).fetchall()
leech_filtered = [r for r in leech_rows if r["id"] not in used_ids][:5]
combined = [dict(r) for r in new_rows] + [dict(r) for r in due_rows] + [dict(r) for r in leech_filtered]
random.shuffle(combined)
cards = combined[:limit]
else:
# Standard SRS due queue: Due cards first, then new cards
query = f"""
SELECT * FROM vocabulary_cards
WHERE user_id = ?{col_filter}
AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL)
ORDER BY
CASE WHEN srs_state != 'new' AND (srs_next_review <= ? OR srs_next_review IS NULL) THEN 0 ELSE 1 END,
srs_next_review ASC,
created_at DESC
LIMIT ?
"""
params = [user["id"]] + col_params + [now_str, now_str, limit]
rows = conn.execute(query, params).fetchall()
cards = [dict(r) for r in rows]
enhanced_cards = []
for c in cards:
cd = dict(c)
if not cd.get("audio_url"):
cd["audio_url"] = f"/api/audio/{get_audio_hash(cd['term'], 'en', 1.0)}.wav"
if cd.get("context_sentence"):
cd["context_audio_url"] = f"/api/audio/{get_audio_hash(cd['context_sentence'], 'en', 1.0)}.wav"
enhanced_cards.append(cd)
return {"count": len(enhanced_cards), "cards": enhanced_cards}
@router.get("/api/srs/quiz")
async def api_srs_quiz(
collection: Optional[str] = None,
tag: Optional[str] = None,
count: int = 10,
exclude_grammar: bool = True,
user: dict = Depends(require_user),
):
"""
Generates a Multiple-Choice Quiz (4 options per question).
"""
with get_db() as conn:
col_filter = ""
col_params = []
if collection:
col_filter += " AND collection = ?"
col_params.append(collection.strip())
if tag:
col_filter += " AND tags LIKE ?"
col_params.append(f"%{tag.strip()}%")
wants_grammar = (collection and ("грамматика" in collection.lower() or "grammar" in collection.lower())) or \
(tag and "grammar" in tag.lower())
if exclude_grammar and not wants_grammar:
col_filter += " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%'))"
col_filter += " AND (tags IS NULL OR tags NOT LIKE '%grammar%')"
rows = conn.execute(
f"SELECT * FROM vocabulary_cards WHERE user_id = ?{col_filter} ORDER BY RANDOM() LIMIT ?",
[user["id"]] + col_params + [count],
).fetchall()
target_cards = [dict(r) for r in rows]
# Fetch pool of user cards for generating smart distractors (exclude grammar)
distractor_filter = " AND (collection IS NULL OR (collection != 'Грамматика Мерфи' AND collection NOT LIKE '%grammar%')) AND (tags IS NULL OR tags NOT LIKE '%grammar%')" if exclude_grammar and not wants_grammar else ""
all_rows = conn.execute(
f"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?{distractor_filter}",
(user["id"],),
).fetchall()
all_cards = [dict(r) for r in all_rows]
quiz_items = []
for card in target_cards:
options = generate_quiz_options(card, all_cards, count=4)
quiz_items.append({
"card": card,
"options": options,
})
return {"count": len(quiz_items), "quiz": quiz_items}
@router.post("/api/srs/review")
@router.post("/api/srs/review/{card_id_path}")
async def api_srs_review(
req: Optional[SrsReviewRequest] = None,
card_id_path: Optional[int] = None,
rating_param: Optional[int] = Query(None, alias="rating"),
request: Request = None,
user: dict = Depends(require_user),
):
"""
Records a review rating (1..4) and updates SM-2 state & activity logs.
"""
target_card_id = req.card_id if req else card_id_path
target_rating = req.rating if req else rating_param
if target_card_id is None:
try:
body = await request.json()
target_card_id = body.get("card_id")
target_rating = body.get("rating")
except Exception:
pass
if target_card_id is None or target_rating is None:
raise HTTPException(status_code=400, detail="Missing card_id or rating")
if target_rating not in (1, 2, 3, 4):
raise HTTPException(status_code=422, detail="Rating must be between 1 and 4")
with get_db() as conn:
row = conn.execute(
"SELECT * FROM vocabulary_cards WHERE id = ? AND user_id = ?",
(target_card_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Card not found")
current = dict(row)
new_srs = calculate_sm2(
rating=target_rating,
reps=current.get("srs_reps", 0),
interval=current.get("srs_interval", 0),
ease=current.get("srs_ease", 2.5),
state=current.get("srs_state", "new"),
error_count=current.get("error_count", 0),
)
conn.execute(
"""
UPDATE vocabulary_cards
SET srs_reps = ?, srs_interval = ?, srs_ease = ?, srs_state = ?,
srs_last_review = ?, srs_next_review = ?, error_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
""",
(
new_srs["srs_reps"],
new_srs["srs_interval"],
new_srs["srs_ease"],
new_srs["srs_state"],
new_srs["srs_last_review"],
new_srs["srs_next_review"],
new_srs["error_count"],
target_card_id,
user["id"],
),
)
# Record review log
conn.execute(
"""
INSERT INTO srs_logs (user_id, card_id, rating, interval, ease, reviewed_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(user["id"], target_card_id, target_rating, new_srs["srs_interval"], new_srs["srs_ease"]),
)
updated_row = conn.execute("SELECT * FROM vocabulary_cards WHERE id = ?", (target_card_id,)).fetchone()
return {"status": "ok", "card": dict(updated_row)}
@router.get("/api/srs/stats")
async def api_srs_stats(user: dict = Depends(require_user)):
"""
Returns full learning analytics, SRS states, retention rate, streaks.
"""
with get_db() as conn:
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Total cards
total_cards = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchone()[0]
# By state
states = conn.execute(
"""
SELECT srs_state, COUNT(*) as count
FROM vocabulary_cards
WHERE user_id = ?
GROUP BY srs_state
""",
(user["id"],),
).fetchall()
state_counts = {r["srs_state"]: r["count"] for r in states}
new_cards = state_counts.get("new", 0)
learning = state_counts.get("learning", 0)
review = state_counts.get("review", 0)
mastered = state_counts.get("mastered", 0)
# Due now
due_cards = conn.execute(
"""
SELECT COUNT(*) FROM vocabulary_cards
WHERE user_id = ? AND (srs_state = 'new' OR srs_next_review <= ? OR srs_next_review IS NULL)
""",
(user["id"], now_str),
).fetchone()[0]
# Leeches
leech_count = conn.execute(
"""
SELECT COUNT(*) FROM vocabulary_cards
WHERE user_id = ? AND (error_count > 0 OR srs_ease < 2.0)
""",
(user["id"],),
).fetchone()[0]
# Total reviews and retention
review_logs = conn.execute(
"SELECT rating, date(reviewed_at) as rev_date FROM srs_logs WHERE user_id = ?",
(user["id"],),
).fetchall()
total_reviews = len(review_logs)
good_reviews = sum(1 for r in review_logs if r["rating"] in (3, 4))
retention_rate = round((good_reviews / total_reviews * 100), 1) if total_reviews > 0 else 100.0
# Daily review aggregation for streak
date_counts: Dict[str, int] = {}
for r in review_logs:
d = r["rev_date"]
if d:
date_counts[d] = date_counts.get(d, 0) + 1
heatmap_info = build_activity_heatmap(date_counts, days=180)
# Today's activity
today_str = date.today().strftime("%Y-%m-%d")
today_added = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')",
(user["id"],),
).fetchone()[0]
today_reviewed = date_counts.get(today_str, 0)
cefr_info = calculate_cefr_level(mastered, review, retention_rate, total_cards)
return {
"total_cards": total_cards,
"new_cards": new_cards,
"learning": learning,
"review": review,
"mastered": mastered,
"due_cards": due_cards,
"leech_count": leech_count,
"total_reviews": total_reviews,
"retention_rate": retention_rate,
"streak_days": heatmap_info["current_streak"],
"longest_streak": heatmap_info["longest_streak"],
"today_added": today_added,
"today_reviewed": today_reviewed,
"cefr": cefr_info,
}
@router.get("/api/srs/activity-heatmap")
async def api_srs_activity_heatmap(days: int = 180, user: dict = Depends(require_user)):
"""
Returns GitHub-style daily activity heatmap data.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT date(reviewed_at) as rev_date, COUNT(*) as cnt FROM srs_logs WHERE user_id = ? GROUP BY date(reviewed_at)",
(user["id"],),
).fetchall()
date_counts = {r["rev_date"]: r["cnt"] for r in rows if r["rev_date"]}
heatmap_data = build_activity_heatmap(date_counts, days=days)
return heatmap_data
@router.get("/api/stats")
async def api_stats(user: dict = Depends(require_user)):
"""Legacy summary stats endpoint."""
with get_db() as conn:
total = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ?",
(user["id"],),
).fetchone()[0]
today_added = conn.execute(
"SELECT COUNT(*) FROM vocabulary_cards WHERE user_id = ? AND date(created_at) = date('now')",
(user["id"],),
).fetchone()[0]
return {"total_cards": total, "today_added": today_added}
+117
View File
@@ -0,0 +1,117 @@
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
from app.tasks import (
create_task,
get_task_status,
get_active_task,
cancel_task,
)
from app.miner import run_mining_task_background
router = APIRouter(tags=["Background Tasks"])
class MineBooksRequest(BaseModel):
book_id: Optional[int] = None
level: str = "A1"
count: int = Field(15, ge=1, le=50)
@router.post("/api/tasks/mine-books")
async def api_tasks_mine_books(
req: MineBooksRequest,
user: dict = Depends(require_user),
):
"""
Starts an asynchronous background task to mine CEFR Cloze Tap questions
from user's uploaded books (EPUB, TXT, PDF).
"""
level = req.level.upper().strip()
if level not in ["A1", "A2", "B1", "B2", "ALL"]:
raise HTTPException(status_code=400, detail="Invalid CEFR level")
count = max(1, min(req.count, 50))
with get_db() as conn:
active = get_active_task(conn, user["id"], task_type="mine_books")
if active:
return {
"status": "already_running",
"message": "Сборка уже выполняется в фоне",
"task": active,
}
task_id = create_task(
conn,
user_id=user["id"],
task_type="mine_books",
params={
"book_id": req.book_id,
"level": level,
"count": count,
},
total=count,
)
# Launch background asyncio task
asyncio.create_task(
run_mining_task_background(
task_id=task_id,
user_id=user["id"],
book_id=req.book_id,
target_level=level,
target_count=count,
)
)
return {
"status": "ok",
"task_id": task_id,
"message": f"Запущена фоновая сборка тестов ({level})",
}
@router.get("/api/tasks/active")
async def api_tasks_active(
task_type: Optional[str] = Query(None),
user: dict = Depends(require_user),
):
"""
Returns current user's active background task (if any).
"""
with get_db() as conn:
task = get_active_task(conn, user["id"], task_type=task_type)
return {"task": task}
@router.get("/api/tasks/{task_id}")
async def api_tasks_status(
task_id: int,
user: dict = Depends(require_user),
):
"""
Returns status and progress of a background task.
"""
with get_db() as conn:
task = get_task_status(conn, task_id, user_id=user["id"])
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return {"task": task}
@router.post("/api/tasks/{task_id}/cancel")
async def api_tasks_cancel(
task_id: int,
user: dict = Depends(require_user),
):
"""
Cancels a running background task.
"""
with get_db() as conn:
cancelled = cancel_task(conn, task_id, user_id=user["id"])
return {"status": "ok" if cancelled else "not_found", "cancelled": cancelled}
+148
View File
@@ -0,0 +1,148 @@
/* ==========================================================================
1. Base Tokens, Typography, Reset & Global Safe Areas
========================================================================== */
/* Modern CSS Design System for Notes App */
:root {
--bg-main: #0f172a;
--bg-surface: #1e293b;
--bg-surface-hover: #334155;
--bg-card: #1e293b;
--bg-input: #0f172a;
--border-color: #334155;
--border-focus: #38bdf8;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--primary: #38bdf8;
--primary-hover: #0ea5e9;
--primary-dim: rgba(56, 189, 248, 0.15);
--success: #10b981;
--success-hover: #059669;
--success-dim: rgba(16, 185, 129, 0.15);
--danger: #ef4444;
--danger-hover: #dc2626;
--danger-dim: rgba(239, 68, 68, 0.15);
--tag-bg: #293548;
--tag-text: #7dd3fc;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--transition: all 0.18s ease-in-out;
--sat: env(safe-area-inset-top, 0px);
--sar: env(safe-area-inset-right, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
--navbar-height: 60px;
}
[data-theme="light"] {
--bg-main: #f1f5f9;
--bg-surface: #ffffff;
--bg-surface-hover: #f8fafc;
--bg-card: #ffffff;
--bg-input: #f8fafc;
--border-color: #cbd5e1;
--border-focus: #0284c7;
--text-main: #0f172a;
--text-muted: #475569;
--text-dim: #94a3b8;
--primary: #0284c7;
--primary-hover: #0369a1;
--primary-dim: rgba(2, 132, 199, 0.1);
--success: #059669;
--success-hover: #047857;
--success-dim: rgba(5, 150, 105, 0.1);
--tag-bg: #e2e8f0;
--tag-text: #0369a1;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
overflow-x: clip;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: var(--font-family);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
min-height: 100dvh;
}
.hidden {
display: none !important;
}
/* Main Content */
.main-content {
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 32px 20px;
display: flex;
flex-direction: column;
gap: 36px;
}
/* --- Responsive Mobile Overrides (max-width: 768px) --- */
@media (max-width: 768px) {
/* 1. iOS Safari Auto-Zoom Fix (< 16px triggers zoom) */
input, select, textarea,
.input-styled, .search-input-box input, .form-group input,
.search-mini-box input, .reader-select {
font-size: 16px !important;
}
/* 2. Global Mobile App Container & Safe Areas */
html, body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 100dvh;
}
.app-shell {
min-height: 100dvh;
overflow-x: hidden;
}
.main-content,
.srs-view,
.reader-view {
padding-bottom: calc(74px + env(safe-area-inset-bottom, 0px)) !important;
}
}
File diff suppressed because it is too large Load Diff
+927
View File
@@ -0,0 +1,927 @@
/* ==========================================================================
3. Dictionary, Search, Word Cards, Collections & Tags
========================================================================== */
/* Search Box */
.search-box-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.search-input-box {
display: flex;
align-items: center;
background-color: var(--bg-surface);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
padding: 6px 10px 6px 16px;
transition: var(--transition);
box-shadow: var(--shadow-sm);
}
.search-input-box:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 4px var(--primary-dim);
}
.search-icon {
font-size: 18px;
color: var(--text-muted);
margin-right: 12px;
}
.search-input-box input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 17px;
color: var(--text-main);
padding: 8px 0;
}
.clear-btn {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 50%;
}
.clear-btn:hover {
color: var(--text-main);
}
.translate-action-btn {
margin-left: 8px;
}
.hotkey-hints {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 4px 8px;
font-size: 12px;
color: var(--text-dim);
}
.hotkey-hints kbd {
background-color: var(--bg-surface-hover);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
font-size: 11px;
color: var(--text-muted);
}
/* Live Result Card */
.result-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-md);
animation: fadeIn 0.25s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.result-header {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.term-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.res-term {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.5px;
color: var(--text-main);
}
.badge-pill {
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 600;
}
.res-phonetic {
background-color: var(--primary-dim);
color: var(--primary);
font-family: monospace;
}
.res-pos {
background-color: var(--bg-surface-hover);
color: var(--text-muted);
}
.audio-btn {
background-color: var(--primary-dim);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: var(--radius-full);
padding: 4px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: var(--transition);
}
.audio-btn:hover {
background-color: var(--primary);
color: #0f172a;
}
.audio-xs-btn {
background-color: var(--primary-dim);
border: 1px solid var(--primary);
color: var(--primary);
border-radius: var(--radius-full);
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: var(--transition);
}
.audio-xs-btn:hover {
background-color: var(--primary);
color: #0f172a;
}
.popup-sentence-audio-btn {
border-color: var(--border-color);
color: var(--text-muted);
}
.popup-sentence-audio-btn:hover {
border-color: var(--primary);
color: var(--primary);
background-color: var(--primary-dim);
}
.definition-box {
background-color: var(--bg-main);
border-left: 3px solid var(--primary);
padding: 12px 16px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-bottom: 20px;
}
.def-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
color: var(--primary);
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.def-text {
font-size: 14px;
color: var(--text-main);
}
.card-edit-grid {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.input-styled {
width: 100%;
padding: 10px 14px;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-main);
font-size: 14px;
outline: none;
transition: var(--transition);
}
.input-styled:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--primary-dim);
}
.field-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 5px;
}
.field-group .req {
color: var(--danger);
}
.field-row {
display: flex;
gap: 16px;
}
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
.suggestions-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.suggestion-chip {
font-size: 12px;
background-color: var(--bg-surface-hover);
color: var(--text-muted);
padding: 2px 8px;
border-radius: var(--radius-full);
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
}
.suggestion-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.quick-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.tag-chip {
font-size: 11px;
background-color: var(--tag-bg);
color: var(--tag-text);
padding: 2px 8px;
border-radius: var(--radius-full);
cursor: pointer;
user-select: none;
transition: var(--transition);
}
.tag-chip:hover, .tag-chip.active {
background-color: var(--primary);
color: #0f172a;
font-weight: 600;
}
.result-actions {
display: flex;
justify-content: flex-end;
}
/* Vocabulary Section */
.vocabulary-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.section-title-wrap {
display: flex;
align-items: baseline;
gap: 8px;
}
.section-title-wrap h3 {
font-size: 20px;
font-weight: 700;
}
.cards-count-label {
font-size: 13px;
color: var(--text-dim);
}
.list-controls {
display: flex;
align-items: center;
gap: 8px;
}
.batch-mode-btn {
white-space: nowrap;
flex-shrink: 0;
height: 35px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.search-mini-box {
display: flex;
align-items: center;
}
.search-mini-box input {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-main);
font-size: 13px;
outline: none;
min-width: 260px;
height: 35px;
box-sizing: border-box;
transition: var(--transition);
}
.search-mini-box input:focus {
border-color: var(--border-focus);
}
/* Tags Filter Bar */
.tags-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-chip {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-full);
cursor: pointer;
transition: var(--transition);
}
.filter-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.filter-chip.active {
background-color: var(--primary);
border-color: var(--primary);
color: #0f172a;
}
/* Cards List */
.cards-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.vocab-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 16px 20px;
box-shadow: var(--shadow-sm);
transition: var(--transition);
display: flex;
flex-direction: column;
gap: 10px;
}
.vocab-card:hover {
border-color: var(--border-focus);
box-shadow: var(--shadow-md);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-term-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.card-term {
font-size: 18px;
font-weight: 700;
color: var(--text-main);
}
.card-phonetic {
font-size: 13px;
color: var(--primary);
font-family: monospace;
}
.card-pos {
font-size: 11px;
background-color: var(--bg-surface-hover);
color: var(--text-dim);
padding: 2px 6px;
border-radius: 4px;
}
.card-actions {
display: flex;
gap: 6px;
}
.card-translation {
font-size: 16px;
font-weight: 600;
color: var(--text-main);
}
.card-context {
font-size: 14px;
color: var(--text-muted);
font-style: italic;
background-color: var(--bg-main);
padding: 8px 12px;
border-radius: var(--radius-sm);
border-left: 2px solid var(--border-color);
}
.card-context mark {
background-color: var(--primary-dim);
color: var(--primary);
font-weight: 600;
padding: 0 4px;
border-radius: 2px;
}
.card-bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.card-tag {
background-color: var(--tag-bg);
color: var(--tag-text);
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 11px;
}
.card-notes {
font-size: 12px;
color: #fbbf24;
display: flex;
align-items: center;
gap: 4px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 48px 24px;
background-color: var(--bg-surface);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-muted);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-state h4 {
font-size: 16px;
color: var(--text-main);
margin-bottom: 6px;
}
.empty-state p {
font-size: 13px;
color: var(--text-dim);
}
/* Collections & Filters */
.collections-filter-wrap, .tags-filter-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
.filter-title {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-chips-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.col-chip {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-full);
cursor: pointer;
transition: var(--transition);
}
.col-chip:hover {
border-color: #a855f7;
color: #c084fc;
}
.col-chip.active {
background-color: #a855f7;
border-color: #a855f7;
color: #ffffff;
font-weight: 700;
}
.card-collection-badge {
font-size: 11px;
font-weight: 600;
background-color: rgba(168, 85, 247, 0.15);
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.3);
padding: 2px 8px;
border-radius: var(--radius-full);
}
/* Batch Selection */
.batch-mode-btn.active {
background-color: var(--primary-dim);
border-color: var(--primary);
color: var(--primary);
}
.card-select-cell {
display: none;
margin-right: 12px;
align-items: center;
}
.batch-active .card-select-cell {
display: flex;
}
.card-select-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary);
}
.vocab-card.is-selected {
border-color: var(--border-focus);
background-color: rgba(56, 189, 248, 0.05);
}
.batch-action-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 32px);
max-width: 760px;
background: rgba(30, 41, 59, 0.96);
backdrop-filter: blur(12px);
border: 1px solid var(--border-focus);
border-radius: var(--radius-lg);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.6), 0 0 15px rgba(56, 189, 248, 0.2);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 900;
animation: slideUp 0.22s ease-out;
}
@keyframes slideUp {
from { transform: translate(-50%, 40px); opacity: 0; }
to { transform: translate(-50%, 0); opacity: 1; }
}
.batch-left {
display: flex;
align-items: center;
gap: 10px;
}
.batch-check-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-main);
cursor: pointer;
}
.batch-master-checkbox {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
.batch-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-secondary {
background-color: var(--bg-surface-hover);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: var(--border-color);
}
.btn-danger {
background-color: var(--danger);
color: #ffffff;
}
.btn-danger:hover {
background-color: var(--danger-hover);
}
.btn-ghost {
background: transparent;
border: none;
color: var(--primary);
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.btn-ghost:hover {
background-color: var(--primary-dim);
}
.btn-xs {
padding: 2px 8px;
font-size: 11px;
}
/* Duplicate Warning Banner */
.duplicate-warning-box {
margin-top: 10px;
padding: 10px 14px;
background: rgba(234, 179, 8, 0.12);
border: 1px solid rgba(234, 179, 8, 0.35);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: fadeIn 0.2s ease-in-out;
}
.dup-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #facc15;
}
.dup-icon {
font-size: 16px;
}
/* --- Responsive Mobile Overrides (max-width: 768px) --- */
@media (max-width: 768px) {
/* 4. Dictionary & Search */
.main-content {
padding: 14px 12px calc(76px + env(safe-area-inset-bottom, 0px));
gap: 20px;
}
.search-input-box {
padding: 6px 8px 6px 12px;
}
.search-input-box input {
padding: 8px 0;
min-width: 0;
}
.translate-action-btn {
padding: 8px 14px;
font-size: 14px;
white-space: nowrap;
}
.hotkey-hints {
display: none !important;
}
.lookup-card {
padding: 16px 14px;
}
.lookup-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.meta-inputs-grid .field-row {
flex-direction: column;
gap: 12px;
}
.result-actions .btn {
width: 100%;
justify-content: center;
min-height: 48px;
}
.result-actions .btn-subtext {
display: none;
}
/* Horizontal scrollable chips for collections and tags */
.filter-chips-row {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
scrollbar-width: none;
}
.filter-chips-row::-webkit-scrollbar {
display: none;
}
.col-chip, .filter-chip {
flex-shrink: 0;
}
.list-controls {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.section-header {
flex-direction: column;
align-items: stretch;
}
.search-mini-box {
flex: 1;
min-width: 0;
}
.search-mini-box input {
width: 100%;
min-width: 0;
}
/* Cards */
.vocab-card {
padding: 14px;
gap: 8px;
}
.card-top {
gap: 8px;
}
.card-term {
font-size: 17px;
}
.card-actions .icon-btn {
min-width: 36px;
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.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;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+32 -8743
View File
File diff suppressed because it is too large Load Diff
+109 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Notes — Персональный тренажер и словарь английского языка</title>
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
<link rel="stylesheet" href="/static/css/style.css?v=6.4">
<link rel="stylesheet" href="/static/css/style.css?v=6.9">
<!-- PWA meta and icons -->
<link rel="manifest" href="/manifest.json">
@@ -322,7 +322,10 @@
<div class="fc-back-detail-box" id="fc-back-context-box">
<div class="fc-detail-header-row" style="display: flex; align-items: center; justify-content: space-between;">
<span class="detail-label">Контекст:</span>
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить предложение контекста">🔊</button>
<span class="audio-ctrl-group">
<button type="button" id="fc-back-context-audio-btn" class="icon-btn audio-xs-btn" title="Озвучить пример (1.0x)">🔊</button>
<button type="button" id="fc-back-context-audio-slow-btn" class="icon-btn audio-xs-btn audio-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>
<p id="fc-back-context" class="detail-text"></p>
</div>
@@ -768,6 +771,10 @@
<span style="font-weight: 700; font-family: serif; font-size: 13px;">Aa</span>
<span class="btn-subtext-inline">Шрифт</span>
</button>
<button id="reader-zen-btn" class="btn btn-xs btn-outline reader-zen-btn" title="Фокус-режим чтения (Zen Mode, клавиша F)">
<span>🧘</span>
<span class="btn-subtext-inline">Фокус</span>
</button>
<button id="reader-toggle-highlight-btn" class="btn btn-xs btn-outline" title="Вкл/Выкл подсветку изучаемых слов">✨ Подсветка</button>
<button id="reader-train-btn" class="btn btn-xs btn-trainer" title="Тренировать слова из этой книги">🎓 Слова книги</button>
</div>
@@ -777,6 +784,18 @@
</div>
</header>
<!-- Floating Zen HUD (Discreet, auto-fading, fixed in top-right) -->
<div id="reader-zen-hud" class="reader-zen-hud hidden">
<div class="zen-hud-content">
<span id="zen-hud-title" class="zen-hud-title">Книга</span>
<span class="zen-hud-sep">•</span>
<span id="zen-hud-chapter" class="zen-hud-chapter">Глава 1</span>
<span class="zen-hud-sep">•</span>
<span id="zen-hud-progress" class="zen-hud-progress">0%</span>
<button type="button" id="zen-hud-exit-btn" class="zen-hud-exit-btn" title="Выйти из фокус-режима (Esc или F)">✕</button>
</div>
</div>
<!-- Reading Container -->
<div class="reader-scroll-area" id="reader-scroll-area">
<div class="reader-content-body" id="reader-content-body">
@@ -946,6 +965,18 @@
<p id="grammar-lesson-sub" class="lesson-sub-title">Unit Subtitle</p>
</div>
<!-- Cheatsheet Markdown Table -->
<div id="grammar-cheatsheet-section" class="grammar-section-block">
<h3 class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица)</h3>
<div id="grammar-cheatsheet-content"></div>
</div>
<!-- Detailed Murphy Notes -->
<div id="grammar-detailed-notes-section" class="grammar-section-block">
<h3 class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy)</h3>
<div id="grammar-detailed-notes-content"></div>
</div>
<!-- Contrast Explanation Cards -->
<div class="grammar-section-block">
<h3 class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять)</h3>
@@ -999,6 +1030,15 @@
<div id="grammar-practice-dots" class="practice-dots"></div>
</div>
<!-- Practice Mode Cheatsheet Toggle & Drawer -->
<div class="practice-cheatsheet-toggle-wrap">
<button type="button" id="grammar-practice-cheatsheet-toggle" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
<span>📋 Шпаргалка по теме</span>
<i class="ph-caret-down"></i>
</button>
<div id="grammar-practice-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden"></div>
</div>
<div class="grammar-cloze-box">
<div id="grammar-cloze-sentence" class="cloze-sentence-display"></div>
<div id="grammar-cloze-clue" class="cloze-clue-display"></div>
@@ -1980,9 +2020,12 @@
<button type="button" id="popup-translate-sentence-btn" class="btn btn-xs btn-outline popup-sentence-btn" title="Перевести всё предложение через локальный ИИ">
🌐 Перевести предложение
</button>
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение нейросетью">
<button type="button" id="popup-audio-sentence-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn" title="Озвучить всё предложение (1.0x)">
🔊 Озвучить
</button>
<button type="button" id="popup-audio-sentence-slow-btn" class="btn btn-xs btn-outline popup-sentence-audio-btn audio-slow-btn" title="Озвучить предложение медленно (0.7x)">
🐢 0.7x
</button>
<span id="popup-ai-source-badge" class="popup-ai-source-badge hidden">⚡ Local AI</span>
</div>
<div id="popup-sentence-loading" class="popup-sentence-loading hidden">
@@ -2009,10 +2052,72 @@
</div>
</div>
<!-- ChipDip-style Floating Feedback & Bug Report Widget -->
<div id="feedback-container" class="feedback-container">
<!-- Floating Launcher Button -->
<button id="feedback-trigger-btn" class="feedback-trigger-btn" title="Сообщить о баге или предложить идею (Dev Chat)" aria-label="Обратная связь">
<span class="feedback-trigger-icon">💬</span>
<span id="feedback-badge" class="feedback-badge hidden">0</span>
</button>
<!-- Floating Chat Window -->
<div id="feedback-widget" class="feedback-widget hidden" role="dialog" aria-labelledby="feedback-widget-title">
<div class="feedback-widget-header">
<div class="feedback-header-left">
<span class="feedback-header-icon">💬</span>
<div>
<div id="feedback-widget-title" class="feedback-header-title">Dev Feedback & Баги</div>
<div class="feedback-header-sub">Синхронизируется с AI-ассистентом</div>
</div>
</div>
<button type="button" id="feedback-close-btn" class="feedback-close-btn" title="Закрыть (Esc)">✕</button>
</div>
<!-- Category Selector Tabs -->
<div class="feedback-category-bar">
<button type="button" class="feedback-cat-chip active" data-category="bug">🐛 Баг</button>
<button type="button" class="feedback-cat-chip" data-category="feature">💡 Фича</button>
<button type="button" class="feedback-cat-chip" data-category="note">📝 Заметка</button>
</div>
<!-- Auto Context Indicator -->
<div class="feedback-context-indicator" id="feedback-context-indicator">
<span class="context-icon">📍</span>
<span id="feedback-context-text" class="context-text">Определение контекста...</span>
</div>
<!-- Chat / Reports Feed -->
<div id="feedback-messages-list" class="feedback-messages-list">
<div class="feedback-empty-state" id="feedback-empty-state">
<span class="empty-icon">📝</span>
<p>Нашли баг или есть идея для фичи?</p>
<small>Напишите сообщение ниже — оно сразу попадет в систему задач AI-разработчика.</small>
</div>
</div>
<!-- Input Area -->
<form id="feedback-form" class="feedback-input-form">
<textarea
id="feedback-input"
class="feedback-textarea"
placeholder="Опишите баг или идею... (Enter для отправки, Shift+Enter для новой строки)"
rows="2"
required
></textarea>
<div class="feedback-form-bottom">
<span class="feedback-hint">Enter — отправить</span>
<button type="submit" id="feedback-send-btn" class="feedback-send-btn" title="Отправить репорт">
<span class="send-icon">➤</span>
</button>
</div>
</form>
</div>
</div>
<!-- Audio Element for native streaming -->
<audio id="global-audio-player" preload="none"></audio>
<script type="module" src="/static/js/app.js?v=6.4"></script>
<script type="module" src="/static/js/app.js?v=6.6"></script>
</body>
</html>
+65 -3
View File
@@ -7,10 +7,11 @@ import { api } from './modules/api.js';
import { initTheme, openModal, closeModal, showToast, playTextSpeech, switchView } from './modules/ui.js';
import { initDictionary, loadInitialData } from './modules/dictionary.js';
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
import { initReader, loadBookshelf, openBook, openWordPopup, extractContextSentence } from './modules/reader.js';
import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js';
import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js';
import { initLessonModule, openLessonConfigurator } from './modules/lesson.js';
import { initFeedbackWidget } from './modules/feedback.js';
let currentUser = null;
let isRouting = false;
@@ -415,11 +416,71 @@ export function initNavigationDrawer() {
if (drawerCloseBtn) drawerCloseBtn.addEventListener('click', closeDrawer);
if (drawerBackdrop) drawerBackdrop.addEventListener('click', closeDrawer);
// Escape Key Support
// Global Escape Key & Modal Dismiss Support
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawer && drawer.classList.contains('active')) {
if (e.key === 'Escape') {
// 1. If navigation drawer is open, close it
if (drawer && drawer.classList.contains('active')) {
closeDrawer();
return;
}
// 2. Close any open modal backdrop (topmost visible modal)
const openModals = Array.from(document.querySelectorAll('.modal-backdrop:not(.hidden)'));
if (openModals.length > 0) {
const topModal = openModals[openModals.length - 1];
topModal.classList.add('hidden');
}
}
});
// Dismiss modals when clicking on dark backdrop outside modal window
document.addEventListener('click', (e) => {
if (e.target && e.target.classList && e.target.classList.contains('modal-backdrop')) {
e.target.classList.add('hidden');
}
});
// -----------------------------------------------------------------------
// Global Text Selection Lookup & Add-to-Dictionary (Anywhere on Page)
// -----------------------------------------------------------------------
function handleGlobalTextSelection(e) {
if (e && e.target && e.target.closest('#reader-word-popup, button, input, textarea, select, .modal-window, #feedback-widget, .icon-btn, .opt-btn, .tab-item, .drawer-nav-item')) {
return;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const raw = selection.toString().trim();
if (!raw || raw.length < 2 || raw.length > 50) return;
// Clean punctuation / quotes
const clean = raw.replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
// Require Latin characters (English vocabulary)
if (!clean || !/^[A-Za-z\s'-]+$/.test(clean)) return;
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const containerElem = range.startContainer?.nodeType === 1 ? range.startContainer : range.startContainer?.parentElement;
// Avoid re-triggering if already inside reader word span (which handles its own click)
if (containerElem?.closest('.reader-word')) return;
const fullText = containerElem?.innerText || containerElem?.textContent || '';
const contextSentence = extractContextSentence(fullText, clean);
const virtualTarget = {
getBoundingClientRect: () => range.getBoundingClientRect(),
isConnected: true
};
openWordPopup(clean, contextSentence, virtualTarget, fullText);
}
document.addEventListener('mouseup', handleGlobalTextSelection);
document.addEventListener('touchend', (e) => {
setTimeout(() => handleGlobalTextSelection(e), 150);
});
// Touch Swipe Left to Dismiss (Mobile & Tablet)
@@ -686,6 +747,7 @@ document.addEventListener('DOMContentLoaded', () => {
initCefr();
initGrammar();
initLessonModule();
initFeedbackWidget();
// 5. Global Navigation & Header Actions
const navBrandHome = document.getElementById('nav-brand-home');
+52 -1
View File
@@ -492,19 +492,70 @@ export const api = {
throw new Error(`Failed to get articles: ${resp.status}`);
},
async submitDebrief(payload) {
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)
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}`);
if (resp.ok) return await resp.json();
throw new Error(`Failed to get lesson history: ${resp.status}`);
}
},
// In-app Feedback & Bug Tracker
feedback: {
async getAll(params = {}) {
let url = '/api/feedback';
const qs = new URLSearchParams();
if (params.category) qs.set('category', params.category);
if (params.status) qs.set('status', params.status);
if (qs.toString()) url += `?${qs.toString()}`;
const resp = await fetch(url);
if (resp.ok) return await resp.json();
return [];
},
async create(category, message, contextUrl = '', contextData = {}) {
const resp = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category,
message,
context_url: contextUrl,
context_data: contextData
})
});
if (resp.ok) return await resp.json();
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Не удалось отправить репорт');
},
async updateStatus(id, status) {
const resp = await fetch(`/api/feedback/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (resp.ok) return await resp.json();
throw new Error('Не удалось обновить статус');
},
async delete(id) {
const resp = await fetch(`/api/feedback/${id}`, { method: 'DELETE' });
if (resp.ok) return await resp.json();
throw new Error('Не удалось удалить');
}
}
};
+53 -7
View File
@@ -4,19 +4,22 @@
// =======================================================================
import { api } from './api.js';
import { showToast, escapeHtml } from './ui.js';
import { showToast, escapeHtml, playTextSpeech } from './ui.js';
let currentLevel = 'A1';
let currentMode = 'all';
let currentQuiz = [];
let currentQuestionIdx = 0;
let isAnswered = false;
let hintUsedThisQuestion = false;
const sessionStats = {
total: 0,
correct: 0,
mistakes: 0,
ingested: 0,
score: 0,
hintsUsed: 0,
};
// -----------------------------------------------------------------------
@@ -391,6 +394,8 @@ export async function startCefrQuiz(level = 'A1', mode = 'all') {
sessionStats.correct = 0;
sessionStats.mistakes = 0;
sessionStats.ingested = 0;
sessionStats.score = 0;
sessionStats.hintsUsed = 0;
showCefrScreen('quiz');
@@ -459,6 +464,8 @@ function renderCurrentQuestion() {
const cardContainer = document.getElementById('cefr-cloze-card');
if (!cardContainer) return;
hintUsedThisQuestion = false;
// Stylize the sentence blank: replace ___ with a visible blank node
const formattedSentence = escapeHtml(q.sentence).replace(/___/g, '<span class="cefr-blank" id="cefr-blank-target">_____</span>');
@@ -473,11 +480,16 @@ function renderCurrentQuestion() {
<p class="cefr-sentence-text">${formattedSentence}</p>
</div>
<div class="cefr-clue-box">
<div class="cefr-clue-box" id="cefr-clue-box">
<button type="button" class="btn btn-xs btn-outline cefr-hint-btn" id="cefr-hint-btn" title="Показать подсказку с переводом слова (-0.2 балла)">
💡 Подсказка (-0.2 балла)
</button>
<div id="cefr-hint-content" class="cefr-hint-content hidden">
<span class="clue-icon">💡</span>
<span class="clue-label">Значение:</span>
<strong class="clue-term">${escapeHtml(q.clue)}</strong>
</div>
</div>
<div class="cefr-options-grid" id="cefr-options-grid">
${q.options.map((opt, idx) => `
@@ -491,6 +503,17 @@ function renderCurrentQuestion() {
<div id="cefr-feedback-box" class="cefr-feedback-box hidden"></div>
`;
// Hook up hint toggle button
const hintBtn = cardContainer.querySelector('#cefr-hint-btn');
const hintContent = cardContainer.querySelector('#cefr-hint-content');
if (hintBtn && hintContent) {
hintBtn.addEventListener('click', () => {
hintUsedThisQuestion = true;
hintBtn.classList.add('hidden');
hintContent.classList.remove('hidden');
});
}
// Attach click listeners to option buttons
cardContainer.querySelectorAll('.cefr-opt-btn').forEach(btn => {
btn.addEventListener('click', () => {
@@ -524,6 +547,10 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
if (res.correct) {
sessionStats.correct++;
const pts = hintUsedThisQuestion ? 0.8 : 1.0;
sessionStats.score += pts;
if (hintUsedThisQuestion) sessionStats.hintsUsed++;
clickedBtn.classList.add('opt-correct');
if (blankTarget) {
blankTarget.textContent = res.correct_option;
@@ -533,16 +560,28 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
if (feedbackBox) {
feedbackBox.className = 'cefr-feedback-box feedback-correct';
feedbackBox.innerHTML = `
<div class="fb-title">✓ Верно! Отличный выбор.</div>
<div class="fb-meta">
<div class="fb-title">✓ Верно! ${pts === 0.8 ? 'Получено: <strong>+0.8 балла</strong> (с подсказкой)' : 'Получено: <strong>+1.0 балл</strong>'}</div>
<div class="fb-meta" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<strong class="fb-term">${escapeHtml(res.correct_option)}</strong>
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
<span class="audio-ctrl-group">
<button type="button" class="icon-btn audio-xs-btn play-cefr-word-audio-btn" title="Озвучить слово (1.0x)">🔊</button>
<button type="button" class="icon-btn audio-xs-btn audio-slow-btn play-cefr-word-slow-btn" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>
${res.source_book_title ? `<div class="fb-source-citation">📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}</div>` : ''}
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
${renderOptionsBreakdown(res.options_breakdown)}
`;
feedbackBox.classList.remove('hidden');
feedbackBox.querySelector('.play-cefr-word-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 1.0);
});
feedbackBox.querySelector('.play-cefr-word-slow-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(res.correct_option, 'en', 0.75);
});
attachBreakdownListeners(feedbackBox, sentence);
}
} else {
@@ -550,6 +589,12 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
// Two words were ingested: the correct target word and the clicked distractor
sessionStats.ingested += 2;
// Automatically reveal clue on mistake so user sees the meaning
const hBtn = document.getElementById('cefr-hint-btn');
const hCnt = document.getElementById('cefr-hint-content');
if (hBtn) hBtn.classList.add('hidden');
if (hCnt) hCnt.classList.remove('hidden');
clickedBtn.classList.add('opt-wrong');
// Highlight the true correct option
@@ -623,7 +668,8 @@ function renderQuizSummary() {
const correct = sessionStats.correct;
const mistakes = sessionStats.mistakes;
const ingested = sessionStats.ingested;
const pct = Math.round((correct / total) * 100);
const earnedScore = Math.round(sessionStats.score * 10) / 10;
const pct = Math.round((earnedScore / total) * 100);
const sumScore = document.getElementById('cefr-sum-score');
const sumLead = document.getElementById('cefr-sum-lead');
@@ -633,8 +679,8 @@ function renderQuizSummary() {
const sumSrsBtn = document.getElementById('cefr-sum-srs-btn');
if (sumScore) sumScore.textContent = `${pct}%`;
if (sumCorrect) sumCorrect.textContent = `${correct} / ${total}`;
if (sumMistakes) sumMistakes.textContent = mistakes;
if (sumCorrect) sumCorrect.textContent = `${earnedScore} / ${total} баллов (${correct} верных)`;
if (sumMistakes) sumMistakes.innerHTML = `${mistakes} ${sessionStats.hintsUsed > 0 ? `<span style="font-size:12px;opacity:0.8;">(подсказок: ${sessionStats.hintsUsed})</span>` : ''}`;
if (sumIngested) sumIngested.textContent = ingested;
if (sumLead) {
+37 -11
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,9 +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`;
playAudio(url);
const term = currentLookupData?.term || currentLookupData?.english_term;
if (!term) return;
playTextSpeech(term, 'en');
});
}
@@ -192,7 +192,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 +210,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 +222,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 +249,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 +390,22 @@ 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)}”
<span class="audio-ctrl-group">
<button class="icon-btn audio-play-btn play-context-audio-btn" data-id="${c.id}" title="Озвучить пример (1.0x)">🔊</button>
<button class="icon-btn audio-slow-btn play-context-slow-btn" data-id="${c.id}" title="Озвучить медленно (0.7x)">🐢 0.7x</button>
</span>
</div>` : ''}
<div class="card-bottom">
<div class="card-tags">
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
@@ -405,6 +415,22 @@ export function initDictionary() {
</div>
`;
// Audio buttons
cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.term, 'en', 1.0);
});
cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.context_sentence, 'en', 1.0);
});
cardEl.querySelector('.play-context-slow-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
playTextSpeech(c.context_sentence, 'en', 0.75);
});
// Edit button
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
e.stopPropagation();
+262
View File
@@ -0,0 +1,262 @@
// =======================================================================
// Notes Application - Feedback & Dev Bug Tracker Widget (Native ES Module)
// ChipDip-style floating chat for instant bug reporting & feature requests
// =======================================================================
import { api } from './api.js';
import { escapeHtml, showToast } from './ui.js';
let activeCategory = 'bug';
let isWidgetOpen = false;
function getCurrentContext() {
const hash = window.location.hash || '#dictionary';
const width = window.innerWidth;
const height = window.innerHeight;
const isMobile = width <= 768;
let friendlyName = 'Главная';
if (hash.startsWith('#reader/book/')) {
const titleEl = document.getElementById('reader-book-title');
const bookTitle = titleEl ? titleEl.textContent : 'Книга';
friendlyName = `Читалка: ${bookTitle}`;
} else if (hash.startsWith('#reader')) {
friendlyName = 'Полка книг';
} else if (hash.startsWith('#srs')) {
friendlyName = 'SRS Тренировка';
} else if (hash.startsWith('#grammar')) {
friendlyName = 'Грамматика';
} else if (hash.startsWith('#cefr')) {
friendlyName = 'CEFR Тестирование';
} else if (hash.startsWith('#lesson')) {
friendlyName = 'Урок';
} else if (hash.startsWith('#dictionary')) {
friendlyName = 'Словарь & Поиск';
}
return {
url: hash,
friendlyName: friendlyName,
data: {
screen: `${width}×${height}`,
device: isMobile ? 'Mobile' : 'Desktop',
userAgent: navigator.userAgent.slice(0, 150),
timestamp: new Date().toISOString()
}
};
}
function updateContextDisplay() {
const indicator = document.getElementById('feedback-context-text');
if (!indicator) return;
const ctx = getCurrentContext();
indicator.textContent = `${ctx.friendlyName} • ${ctx.data.device} (${ctx.data.screen})`;
}
function formatRelativeTime(isoString) {
if (!isoString) return '';
try {
const date = new Date(isoString);
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return isoString;
}
}
function createReportCard(r) {
const card = document.createElement('div');
card.className = 'feedback-msg-card';
card.dataset.reportId = r.id;
const catLabels = {
bug: { label: '🐛 Баг', cls: 'bug' },
feature: { label: '💡 Фича', cls: 'feature' },
note: { label: '📝 Заметка', cls: 'note' }
};
const cat = catLabels[r.category] || catLabels.bug;
const statusLabels = {
new: { label: '🆕 Новый', cls: 'new' },
in_progress: { label: '⏳ В работе', cls: 'in_progress' },
resolved: { label: '✅ Исправлено', cls: 'resolved' },
closed: { label: '✕ Закрыт', cls: 'resolved' }
};
const st = statusLabels[r.status] || statusLabels.new;
const timeStr = formatRelativeTime(r.created_at);
const contextStr = r.context_url ? escapeHtml(r.context_url) : '';
card.innerHTML = `
<div class="feedback-msg-header">
<span class="feedback-msg-badge ${cat.cls}">${cat.label}</span>
<span class="feedback-msg-status ${st.cls}">${st.label}</span>
</div>
<div class="feedback-msg-text">${escapeHtml(r.message)}</div>
<div class="feedback-msg-meta">
<span>${timeStr}</span>
${contextStr ? `<span>${contextStr}</span>` : ''}
</div>
`;
return card;
}
export async function loadFeedbackList() {
const listEl = document.getElementById('feedback-messages-list');
const badgeEl = document.getElementById('feedback-badge');
if (!listEl) return;
try {
const reports = await api.feedback.getAll();
const activeCount = reports.filter(r => r.status === 'new' || r.status === 'in_progress').length;
if (badgeEl) {
if (activeCount > 0) {
badgeEl.textContent = activeCount;
badgeEl.classList.remove('hidden');
} else {
badgeEl.classList.add('hidden');
}
}
listEl.innerHTML = '';
if (reports.length === 0) {
listEl.innerHTML = `
<div class="feedback-empty-state">
<span class="empty-icon">📝</span>
<p>Нашли баг или есть идея для фичи?</p>
<small>Напишите сообщение ниже — оно сразу синхронизируется с AI-разработчиком.</small>
</div>
`;
return;
}
reports.forEach(r => {
listEl.appendChild(createReportCard(r));
});
// Scroll to bottom
listEl.scrollTop = listEl.scrollHeight;
} catch (err) {
console.error('Failed to load feedback reports:', err);
}
}
export function openFeedbackWidget() {
const widget = document.getElementById('feedback-widget');
const input = document.getElementById('feedback-input');
if (!widget) return;
isWidgetOpen = true;
widget.classList.remove('hidden');
updateContextDisplay();
loadFeedbackList();
setTimeout(() => {
if (input) input.focus();
}, 100);
}
export function closeFeedbackWidget() {
const widget = document.getElementById('feedback-widget');
if (!widget) return;
isWidgetOpen = false;
widget.classList.add('hidden');
}
export function toggleFeedbackWidget() {
if (isWidgetOpen) {
closeFeedbackWidget();
} else {
openFeedbackWidget();
}
}
export function initFeedbackWidget() {
const triggerBtn = document.getElementById('feedback-trigger-btn');
const closeBtn = document.getElementById('feedback-close-btn');
const widget = document.getElementById('feedback-widget');
const form = document.getElementById('feedback-form');
const input = document.getElementById('feedback-input');
const sendBtn = document.getElementById('feedback-send-btn');
const catChips = document.querySelectorAll('.feedback-cat-chip');
if (triggerBtn) {
triggerBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleFeedbackWidget();
});
}
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeFeedbackWidget();
});
}
// Category chips selection
catChips.forEach(chip => {
chip.addEventListener('click', () => {
catChips.forEach(c => c.classList.remove('active'));
chip.classList.add('active');
activeCategory = chip.dataset.category || 'bug';
if (input) input.focus();
});
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isWidgetOpen) {
closeFeedbackWidget();
}
});
// Enter to send, Shift+Enter for new line
if (input) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (form) form.requestSubmit();
}
});
}
// Form submit
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input ? input.value.trim() : '';
if (!text) return;
const ctx = getCurrentContext();
if (sendBtn) sendBtn.disabled = true;
try {
await api.feedback.create(activeCategory, text, ctx.url, ctx.data);
showToast('✅ Репорт отправлен в бэклог AI-разработчику!', 'success');
if (input) input.value = '';
await loadFeedbackList();
} catch (err) {
console.error('Feedback submit error:', err);
showToast(err.message || 'Ошибка отправки репорта', 'error');
} finally {
if (sendBtn) sendBtn.disabled = false;
if (input) input.focus();
}
});
}
// Hash change updates context indicator if open
window.addEventListener('hashchange', () => {
if (isWidgetOpen) updateContextDisplay();
});
// Initial check of badge count
loadFeedbackList();
}
+63 -1
View File
@@ -5,7 +5,7 @@
// =======================================================================
import { api } from './api.js';
import { showToast, escapeHtml } from './ui.js';
import { showToast, escapeHtml, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
let allUnits = [];
let currentFilter = 'all';
@@ -29,6 +29,18 @@ export function initGrammar() {
const sumUnitsBtn = document.getElementById('grammar-sum-units-btn');
const sumSrsBtn = document.getElementById('grammar-sum-srs-btn');
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
const cheatsheetToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
const cheatsheetDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
if (cheatsheetToggle && cheatsheetDrawer) {
cheatsheetToggle.addEventListener('click', () => {
const isNowHidden = !cheatsheetDrawer.classList.contains('hidden');
cheatsheetDrawer.classList.toggle('hidden', isNowHidden);
cheatsheetToggle.innerHTML = isNowHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
@@ -314,6 +326,50 @@ function renderUnitTheory() {
handwrittenToggle.checked = Boolean(currentUnit.handwritten_logged);
}
// Cheatsheet Markdown Table
const cheatsheetSec = document.getElementById('grammar-cheatsheet-section');
const cheatsheetBox = document.getElementById('grammar-cheatsheet-content');
if (cheatsheetSec && cheatsheetBox) {
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
cheatsheetBox.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
cheatsheetSec.classList.remove('hidden');
} else {
cheatsheetBox.innerHTML = '';
cheatsheetSec.classList.add('hidden');
}
}
// Detailed Murphy Notes
const notesSec = document.getElementById('grammar-detailed-notes-section');
const notesBox = document.getElementById('grammar-detailed-notes-content');
if (notesSec && notesBox) {
if (currentUnit.detailed_notes && currentUnit.detailed_notes.trim()) {
notesBox.innerHTML = renderMarkdownNotes(currentUnit.detailed_notes);
notesSec.classList.remove('hidden');
} else {
notesBox.innerHTML = '';
notesSec.classList.add('hidden');
}
}
// Practice Cheatsheet Drawer content
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
const practiceToggleWrap = document.querySelector('.practice-cheatsheet-toggle-wrap');
if (practiceDrawer && practiceToggleWrap) {
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
practiceDrawer.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
practiceToggleWrap.classList.remove('hidden');
} else {
practiceDrawer.innerHTML = '';
practiceToggleWrap.classList.add('hidden');
}
practiceDrawer.classList.add('hidden');
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
if (practiceToggle) {
practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
}
}
// Golden formula
const formulaEl = document.getElementById('grammar-golden-formula-text');
if (formulaEl) {
@@ -368,6 +424,12 @@ function startUnitPractice() {
sessionCorrect = 0;
sessionIngestedCards = [];
// Ensure cheatsheet drawer is collapsed at practice start
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
if (practiceDrawer) practiceDrawer.classList.add('hidden');
if (practiceToggle) practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
renderExercise();
}
+95 -12
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, renderMarkdownTable, renderMarkdownNotes } 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">
@@ -1332,6 +1332,18 @@ async function runGrammarReviewStep(step, container) {
<span class="badge-formula">${escapeHtml(unitDetail.golden_formula || '')}</span>
</div>
${unitDetail.cheatsheet_table ? `
<div class="practice-cheatsheet-toggle-wrap mt-2">
<button type="button" id="review-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
<span>📋 Шпаргалка по теме</span>
<i class="ph-caret-down"></i>
</button>
<div id="review-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
</div>
` : ''}
<div class="grammar-question-box mt-3">
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
@@ -1350,6 +1362,18 @@ async function runGrammarReviewStep(step, container) {
</div>
`;
const reviewCsBtn = document.getElementById('review-cheatsheet-toggle-btn');
const reviewCsDrawer = document.getElementById('review-cheatsheet-drawer');
if (reviewCsBtn && reviewCsDrawer) {
reviewCsBtn.addEventListener('click', () => {
const isHidden = reviewCsDrawer.classList.contains('hidden');
reviewCsDrawer.classList.toggle('hidden', !isHidden);
reviewCsBtn.innerHTML = !isHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
btn.addEventListener('click', async () => {
// Disable all options
@@ -1432,7 +1456,11 @@ async function runGrammarNewStep(step, container) {
let stage = 'theory'; // theory -> exercises
function renderTheory() {
const contrasts = unitDetail.contrast_explanation || [];
const contrasts = unitDetail.contrasts || unitDetail.contrast_explanation || [];
const hasCheatsheet = Boolean(unitDetail.cheatsheet_table && unitDetail.cheatsheet_table.trim());
const hasNotes = Boolean(unitDetail.detailed_notes && unitDetail.detailed_notes.trim());
const hasPrompt = Boolean(unitDetail.journaling_prompt && unitDetail.journaling_prompt.trim());
container.innerHTML = `
<div class="step-card-box">
<div class="step-mini-header">
@@ -1449,9 +1477,28 @@ async function runGrammarNewStep(step, container) {
<pre class="formula-code">${escapeHtml(unitDetail.golden_formula)}</pre>
</div>
<!-- Cheatsheet Markdown Table -->
${hasCheatsheet ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">📊</span> Наглядная шпаргалка и правила (Таблица):</div>
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
` : ''}
<!-- Detailed Notes -->
${hasNotes ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">📖</span> Подробный конспект темы (Raymond Murphy):</div>
${renderMarkdownNotes(unitDetail.detailed_notes)}
</div>
` : ''}
<!-- Contrast Situations -->
${contrasts.length > 0 ? `
<div class="lesson-grammar-section mt-4">
<div class="section-title"><span class="title-icon">⚖️</span> Метод контрастов (В чём суть и когда применять):</div>
${contrasts.map(c => `
<div class="contrast-card mt-3">
<div class="contrast-card mt-2">
<div class="contrast-row">
<div class="contrast-col a">
<div class="col-title">${escapeHtml(c.situation_a)}</div>
@@ -1467,11 +1514,23 @@ async function runGrammarNewStep(step, container) {
<div class="contrast-diff mt-2">💡 <strong>Ключевая разница:</strong> ${escapeHtml(c.key_difference)}</div>
</div>
`).join('')}
</div>
` : ''}
<!-- Handwritten Journaling Prompt -->
${hasPrompt ? `
<div class="grammar-journaling-card mt-4">
<div class="journaling-prompt-box">
<div class="formula-label">📓 Персональный дневник от руки (Motor Memory):</div>
<p class="journaling-prompt-text mt-1">${escapeHtml(unitDetail.journaling_prompt)}</p>
</div>
</div>
` : ''}
<!-- Handwritten Journaling Checkbox -->
<div class="journaling-box mt-4">
<label class="checkbox-container">
<input type="checkbox" id="hw-checkbox">
<input type="checkbox" id="hw-checkbox" ${unitDetail.handwritten_logged ? 'checked' : ''}>
<span class="checkmark"></span>
<span class="hw-text">✍️ Я записал формулу и пример в бумажную тетрадь</span>
</label>
@@ -1541,6 +1600,18 @@ async function runGrammarNewStep(step, container) {
<span class="badge">Практика темы (${exIdx + 1} из ${selectedExercises.length})</span>
</div>
${unitDetail.cheatsheet_table ? `
<div class="practice-cheatsheet-toggle-wrap mt-2">
<button type="button" id="lesson-cheatsheet-toggle-btn" class="btn btn-sm btn-ghost practice-cheatsheet-btn">
<span>📋 Шпаргалка по теме</span>
<i class="ph-caret-down"></i>
</button>
<div id="lesson-cheatsheet-drawer" class="practice-cheatsheet-drawer hidden">
${renderMarkdownTable(unitDetail.cheatsheet_table)}
</div>
</div>
` : ''}
<div class="grammar-question-box mt-3">
<p class="grammar-sentence-prompt">${escapeHtml(ex.sentence)}</p>
${ex.clue ? `<div class="clue-tag">💡 Подсказка: ${escapeHtml(ex.clue)}</div>` : ''}
@@ -1559,6 +1630,18 @@ async function runGrammarNewStep(step, container) {
</div>
`;
const lessonCsBtn = document.getElementById('lesson-cheatsheet-toggle-btn');
const lessonCsDrawer = document.getElementById('lesson-cheatsheet-drawer');
if (lessonCsBtn && lessonCsDrawer) {
lessonCsBtn.addEventListener('click', () => {
const isHidden = lessonCsDrawer.classList.contains('hidden');
lessonCsDrawer.classList.toggle('hidden', !isHidden);
lessonCsBtn.innerHTML = !isHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
container.querySelectorAll('.grammar-opt-btn').forEach(btn => {
btn.addEventListener('click', async () => {
container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true);
@@ -1656,7 +1739,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">
+171 -17
View File
@@ -3,7 +3,7 @@
// =======================================================================
import { api } from './api.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio } from './ui.js';
import { escapeHtml, escapeRegex, slugify, showToast, playAudio, playTextSpeech } from './ui.js';
import { getAllCards, fetchCards } from './dictionary.js';
// Reader State & Appearance Settings
@@ -28,6 +28,13 @@ let popupCurrentData = null;
let readerScrollSaveTimer = null;
let popupScrollRaf = null;
let isZenMode = false;
let zenHudIdleTimer = null;
export function getIsZenMode() {
return isZenMode;
}
export function getCurrentBook() {
return currentBook;
}
@@ -134,6 +141,7 @@ function createBookCard(b) {
}
export async function loadBookshelf() {
if (isZenMode) toggleZenMode(false);
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const bookshelfGrid = document.getElementById('bookshelf-grid');
@@ -1301,31 +1309,40 @@ export function positionWordPopup(targetElement) {
if (window.innerWidth <= 768) {
readerWordPopup.style.top = '';
readerWordPopup.style.left = '';
readerWordPopup.style.right = '';
readerWordPopup.style.bottom = '';
readerWordPopup.style.width = '';
return;
}
const rect = targetElement.getBoundingClientRect();
const popupWidth = 320;
const popupWidth = (readerWordPopup.offsetWidth && readerWordPopup.offsetWidth >= 280 && readerWordPopup.offsetWidth <= 380)
? readerWordPopup.offsetWidth
: 340;
const popupHeight = readerWordPopup.offsetHeight || 220;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
if (left + popupWidth > viewportWidth - 12) {
left = viewportWidth - popupWidth - 12;
if (left + popupWidth > viewportWidth - 16) {
left = viewportWidth - popupWidth - 16;
}
if (left < 12) {
left = 12;
if (left < 16) {
left = 16;
}
const minTop = isZenMode ? 16 : 65;
let top = rect.bottom + 8;
if (top + popupHeight > viewportHeight - 12) {
if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) {
top = Math.max(65, rect.top - popupHeight - 8);
if (top + popupHeight > viewportHeight - 16) {
if (rect.top - popupHeight - 8 >= minTop || rect.top > viewportHeight / 2) {
top = Math.max(minTop, rect.top - popupHeight - 8);
}
}
readerWordPopup.style.top = `${Math.round(top)}px`;
readerWordPopup.style.left = `${Math.round(left)}px`;
readerWordPopup.style.right = 'auto';
readerWordPopup.style.bottom = 'auto';
readerWordPopup.style.width = `${popupWidth}px`;
}
export async function openWordPopup(term, contextSentence, targetElement, paragraphContext = '') {
@@ -1610,6 +1627,26 @@ export function updateReaderProgressBar() {
} else if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
}
// Update Zen HUD elements if present
const zenHudTitle = document.getElementById('zen-hud-title');
const zenHudChapter = document.getElementById('zen-hud-chapter');
const zenHudProgress = document.getElementById('zen-hud-progress');
if (zenHudTitle && currentBook) {
zenHudTitle.textContent = currentBook.title || 'Книга';
zenHudTitle.title = currentBook.title || 'Книга';
}
if (zenHudChapter && currentBook && currentBook.chapters) {
const totalCh = currentBook.chapters.length;
const ch = currentBook.chapters[currentChapterIndex];
const chTitle = ch ? (ch.title || `Глава ${currentChapterIndex + 1}`) : `Глава ${currentChapterIndex + 1}`;
zenHudChapter.textContent = totalCh > 1 ? `Гл. ${currentChapterIndex + 1}/${totalCh}` : chTitle;
zenHudChapter.title = chTitle;
}
if (zenHudProgress) {
zenHudProgress.textContent = `${pct}%`;
}
}
export function handleReaderScroll() {
@@ -1836,6 +1873,62 @@ export function positionSettingsPanel() {
readerSettingsPanel.style.maxHeight = `${maxH}px`;
}
// -----------------------------------------------------------------------
// Zen Focus Mode
// -----------------------------------------------------------------------
export function wakeZenHud() {
if (!isZenMode) return;
const hud = document.getElementById('reader-zen-hud');
if (!hud) return;
hud.classList.remove('hud-idle');
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
}
zenHudIdleTimer = setTimeout(() => {
if (isZenMode && hud) {
hud.classList.add('hud-idle');
}
}, 3000);
}
export function toggleZenMode(forceState = null) {
const newState = (forceState !== null) ? Boolean(forceState) : !isZenMode;
if (isZenMode === newState) return;
isZenMode = newState;
const hud = document.getElementById('reader-zen-hud');
const zenBtn = document.getElementById('reader-zen-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
if (isZenMode) {
document.body.classList.add('zen-mode-active');
if (zenBtn) zenBtn.classList.add('active');
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
if (hud) {
hud.classList.remove('hidden');
hud.classList.remove('hud-idle');
wakeZenHud();
}
if (!document.fullscreenElement && document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch(() => {});
}
} else {
document.body.classList.remove('zen-mode-active');
if (zenBtn) zenBtn.classList.remove('active');
if (hud) {
hud.classList.add('hidden');
hud.classList.remove('hud-idle');
}
if (zenHudIdleTimer) {
clearTimeout(zenHudIdleTimer);
zenHudIdleTimer = null;
}
if (document.fullscreenElement && document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
}
}
// -----------------------------------------------------------------------
// Reader Module Initialization
// -----------------------------------------------------------------------
@@ -1865,6 +1958,62 @@ export function initReader() {
const readerBackBtn = document.getElementById('reader-back-btn');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const readerZenBtn = document.getElementById('reader-zen-btn');
const zenHudExitBtn = document.getElementById('zen-hud-exit-btn');
if (readerZenBtn) {
readerZenBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode();
});
}
if (zenHudExitBtn) {
zenHudExitBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleZenMode(false);
});
}
// Wake HUD on user activity during zen mode
['mousemove', 'touchstart', 'scroll'].forEach(evt => {
window.addEventListener(evt, () => {
if (isZenMode) wakeZenHud();
}, { passive: true });
});
// Keyboard shortcut for Zen Mode (F / f / а / А) and exit on Escape
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'textarea' || (e.target && e.target.isContentEditable)) {
return;
}
const readerView = document.getElementById('reader-view');
const readerInterface = document.getElementById('reader-interface');
const isReaderActive = readerView && !readerView.classList.contains('hidden') &&
readerInterface && !readerInterface.classList.contains('hidden');
if (!isReaderActive) return;
if (e.key === 'Escape' && isZenMode) {
e.preventDefault();
toggleZenMode(false);
return;
}
if ((e.key === 'f' || e.key === 'F' || e.key === 'а' || e.key === 'А') && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
toggleZenMode();
}
});
// Native fullscreen sync
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && isZenMode) {
toggleZenMode(false);
}
});
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupSaveBtn = document.getElementById('popup-save-btn');
@@ -2102,6 +2251,7 @@ export function initReader() {
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupAudioSentenceBtn = document.getElementById('popup-audio-sentence-btn');
const popupAudioSentenceSlowBtn = document.getElementById('popup-audio-sentence-slow-btn');
const popupAudioRuBtn = document.getElementById('popup-audio-ru-btn');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
@@ -2116,9 +2266,7 @@ export function initReader() {
popupAudioBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.term) return;
const term = popupCurrentData.term;
const url = popupCurrentData.audioUrl || `/api/tts?text=${encodeURIComponent(term)}&lang=en`;
playAudio(url);
playTextSpeech(popupCurrentData.term, 'en');
});
}
@@ -2126,9 +2274,15 @@ export function initReader() {
popupAudioSentenceBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const sentence = popupCurrentData.contextSentence;
const url = `/api/tts?text=${encodeURIComponent(sentence)}&lang=en`;
playAudio(url);
playTextSpeech(popupCurrentData.contextSentence, 'en', 1.0);
});
}
if (popupAudioSentenceSlowBtn) {
popupAudioSentenceSlowBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
playTextSpeech(popupCurrentData.contextSentence, 'en', 0.75);
});
}
@@ -2137,8 +2291,7 @@ export function initReader() {
e.stopPropagation();
const ruText = (popupSentenceRu && popupSentenceRu.textContent || '').trim();
if (!ruText || ruText.startsWith('(') || ruText.startsWith('Ошибка')) return;
const url = `/api/tts?text=${encodeURIComponent(ruText)}&lang=ru`;
playAudio(url);
playTextSpeech(ruText, 'ru', 1.0);
});
}
@@ -2450,6 +2603,7 @@ export function initReader() {
if (readerBackBtn) {
readerBackBtn.addEventListener('click', () => {
if (isZenMode) toggleZenMode(false);
window.location.hash = '#reader';
});
}
+31 -22
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, playTextSpeech, cleanPromptTranslation } from './ui.js';
import { getAllCards } from './dictionary.js';
let srsQueue = [];
@@ -53,6 +53,7 @@ export function initSrs() {
const fcBackContextBox = document.getElementById('fc-back-context-box');
const fcBackContext = document.getElementById('fc-back-context');
const fcBackContextAudioBtn = document.getElementById('fc-back-context-audio-btn');
const fcBackContextAudioSlowBtn = document.getElementById('fc-back-context-audio-slow-btn');
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
const fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
@@ -330,7 +331,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';
@@ -352,6 +353,9 @@ export function initSrs() {
if (fcBackContextAudioBtn) {
fcBackContextAudioBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
if (fcBackContextAudioSlowBtn) {
fcBackContextAudioSlowBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
}
}
if (fcFrontAudioBtn) {
@@ -359,8 +363,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -369,8 +372,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -379,8 +381,16 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
const url = card.context_audio_url || `/api/tts?text=${encodeURIComponent(card.context_sentence)}&lang=en`;
playAudio(url);
playTextSpeech(card.context_sentence, 'en', 1.0);
});
}
if (fcBackContextAudioSlowBtn) {
fcBackContextAudioSlowBtn.addEventListener('click', (e) => {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card || !card.context_sentence) return;
playTextSpeech(card.context_sentence, 'en', 0.75);
});
}
@@ -447,18 +457,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 = '';
@@ -491,7 +501,7 @@ export function initSrs() {
}
});
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
if (isCorrect) {
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
@@ -523,8 +533,7 @@ export function initSrs() {
e.stopPropagation();
const card = srsQueue[srsCurrentIndex];
if (!card) return;
const url = card.audio_url || `/api/tts?text=${encodeURIComponent(card.term)}&lang=en`;
playAudio(url);
playTextSpeech(card.term, 'en', 1.0);
});
}
@@ -543,7 +552,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');
@@ -565,7 +574,7 @@ export function initSrs() {
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
typingInput.disabled = true;
if (isMatch) {
@@ -590,7 +599,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');
@@ -611,7 +620,7 @@ export function initSrs() {
const target = card.term.trim().toLowerCase();
const isMatch = userInput === target;
if (card.audio_url) playAudio(card.audio_url);
playTextSpeech(card.term, 'en');
clozeInput.disabled = true;
if (isMatch) {
@@ -635,12 +644,12 @@ 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;
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
const play = () => { playTextSpeech(card.term, 'en'); };
dictationBigPlayBtn.onclick = play;
dictationReplayBtn.onclick = play;
play();
+133 -4
View File
@@ -27,12 +27,131 @@ 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>`;
}
// -----------------------------------------------------------------------
// Lightweight Native Markdown Parsers (Tables & Notes for Murphy Grammar)
// Zero-dependency, safe escaping first, pure ES6
// -----------------------------------------------------------------------
export function renderMarkdownInline(text) {
if (!text) return '';
let str = escapeHtml(text);
// Bold: **text**
str = str.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Inline code: `text`
str = str.replace(/`([^`]+)`/g, '<code class="grammar-inline-code">$1</code>');
// Italic: *text* (excluding already formatted strong)
str = str.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
return str;
}
export function renderMarkdownTable(mdText) {
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
const rawLines = mdText.trim().split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
const tableLines = rawLines.filter(l => l.startsWith('|') && l.endsWith('|'));
if (tableLines.length < 2) {
return `<div class="grammar-table-raw"><pre>${escapeHtml(mdText)}</pre></div>`;
}
const splitRow = (rowStr) => {
const inner = rowStr.replace(/^\|/, '').replace(/\|$/, '');
return inner.split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
};
const headers = splitRow(tableLines[0]);
const alignRow = splitRow(tableLines[1]);
const alignments = alignRow.map(col => {
const tc = col.trim();
if (tc.startsWith(':') && tc.endsWith(':')) return 'center';
if (tc.endsWith(':')) return 'right';
return 'left';
});
const rows = [];
for (let r = 2; r < tableLines.length; r++) {
rows.push(splitRow(tableLines[r]));
}
let html = '<div class="grammar-table-wrap"><table class="grammar-cheatsheet-table"><thead><tr>';
headers.forEach((h, idx) => {
const align = alignments[idx] || 'left';
html += `<th style="text-align: ${align}">${renderMarkdownInline(h)}</th>`;
});
html += '</tr></thead><tbody>';
rows.forEach(row => {
html += '<tr>';
row.forEach((cell, idx) => {
const align = alignments[idx] || 'left';
html += `<td style="text-align: ${align}">${renderMarkdownInline(cell)}</td>`;
});
html += '</tr>';
});
html += '</tbody></table></div>';
return html;
}
export function renderMarkdownNotes(mdText) {
if (!mdText || typeof mdText !== 'string' || !mdText.trim()) return '';
const lines = mdText.split(/\r?\n/);
const out = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
if (!inList) {
out.push('<ul class="grammar-notes-list">');
inList = true;
}
const itemText = trimmed.replace(/^[-*]\s+/, '');
out.push(`<li>${renderMarkdownInline(itemText)}</li>`);
continue;
} else if (inList) {
out.push('</ul>');
inList = false;
}
if (trimmed.startsWith('#### ')) {
out.push(`<h5 class="grammar-note-h5">${renderMarkdownInline(trimmed.replace(/^####\s+/, ''))}</h5>`);
} else if (trimmed.startsWith('### ')) {
out.push(`<h4 class="grammar-note-h4">${renderMarkdownInline(trimmed.replace(/^###\s+/, ''))}</h4>`);
} else if (trimmed.startsWith('## ')) {
out.push(`<h3 class="grammar-note-h3">${renderMarkdownInline(trimmed.replace(/^##\s+/, ''))}</h3>`);
} else if (trimmed.startsWith('> ')) {
out.push(`<blockquote class="grammar-note-quote">${renderMarkdownInline(trimmed.replace(/^>\s+/, ''))}</blockquote>`);
} else if (trimmed === '') {
// blank line separator
} else {
out.push(`<p class="grammar-note-p">${renderMarkdownInline(trimmed)}</p>`);
}
}
if (inList) {
out.push('</ul>');
}
return `<div class="grammar-notes-box">${out.join('\n')}</div>`;
}
// -----------------------------------------------------------------------
@@ -154,6 +273,11 @@ export function closeModal(modalElem) {
// View Switcher Helper
// -----------------------------------------------------------------------
export function switchView(targetViewId) {
if (targetViewId !== 'reader-view') {
document.body.classList.remove('zen-mode-active');
const hud = document.getElementById('reader-zen-hud');
if (hud) hud.classList.add('hidden');
}
const allViews = [
'dictionary-view',
'srs-view',
@@ -270,6 +394,11 @@ export const ui = {
updateHeaderStats,
playAudio,
playTextSpeech,
cleanPromptTranslation,
formatTranslationWithSpoiler,
renderMarkdownInline,
renderMarkdownTable,
renderMarkdownNotes,
};
export default ui;
+9 -1
View File
@@ -1,12 +1,19 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
const CACHE_NAME = 'notes-v6.6';
const CACHE_NAME = 'notes-v6.9';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
'/',
'/static/css/style.css',
'/static/css/base.css',
'/static/css/components.css',
'/static/css/dictionary.css',
'/static/css/srs.css',
'/static/css/reader.css',
'/static/css/grammar_cefr.css',
'/static/css/lesson.css',
'/static/js/app.js',
'/static/js/modules/api.js',
'/static/js/modules/ui.js',
@@ -15,6 +22,7 @@ const PRECACHE_URLS = [
'/static/js/modules/reader.js',
'/static/js/modules/cefr.js',
'/static/js/modules/grammar.js',
'/static/js/modules/feedback.js',
'/manifest.json',
];
+15 -45
View File
@@ -49,54 +49,24 @@ def update_task_progress(
"""
Updates the progress and status of a running task.
"""
if total is not None and message is not None:
conn.execute(
"""
set_clauses = ["status = 'running'", "progress_current = ?", "updated_at = CURRENT_TIMESTAMP"]
params: List[Any] = [current]
if total is not None:
set_clauses.append("progress_total = ?")
params.append(total)
if message is not None:
set_clauses.append("message = ?")
params.append(message)
params.append(task_id)
sql = f"""
UPDATE background_tasks
SET status = 'running',
progress_current = ?,
progress_total = ?,
message = ?,
updated_at = CURRENT_TIMESTAMP
SET {', '.join(set_clauses)}
WHERE id = ? AND status != 'cancelled'
""",
(current, total, message, task_id),
)
elif total is not None:
conn.execute(
"""
UPDATE background_tasks
SET status = 'running',
progress_current = ?,
progress_total = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status != 'cancelled'
""",
(current, total, task_id),
)
elif message is not None:
conn.execute(
"""
UPDATE background_tasks
SET status = 'running',
progress_current = ?,
message = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status != 'cancelled'
""",
(current, message, task_id),
)
else:
conn.execute(
"""
UPDATE background_tasks
SET status = 'running',
progress_current = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status != 'cancelled'
""",
(current, task_id),
)
conn.execute(sql, params)
def complete_task(
+23
View File
@@ -0,0 +1,23 @@
# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)
> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.
> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.
**Всего репортов:** 8 | **Последняя синхронизация:** сейчас
## 🔴 Активные репорты (Требуют внимания / В работе)
🎉 *Активных багов или задач нет! Все известные репорты закрыты.*
---
## ✅ Решенные / Закрытые задачи
- [x] **#8 💡 (2026-10-08 09:28:40)**: сделать кнопки при озвучивании предложения или текста 0.7-1 скорость. Предложения иногда неудобно слушать, так как диктор может сказать быстро, хочется замедлить *(Страница: #dictionary)*
- [x] **#7 🐛 (2026-10-08 09:22:52)**: Проверить, верно ли применяется голос озвучки, после смены его в меню настройки. Такое чувство, что не везде применяется озвучка *(Страница: #lesson)*
- [x] **#6 💡 (2026-10-08 09:21:19)**: в SRS тестах чтобы не было упражнений по грамматике, там должны быть карточки исключительно касающиеся слов. *(Страница: #dictionary)*
- [x] **#5 💡 (2026-10-08 09:19:07)**: В тестах не показывать подсказку (русский перевод) но сделать кнопку "подсказка", тогда перевод показывается нужного слова, но за это снимается бал. Например за вопрос дается 1 бал, так я получу с подсказкой 0.8 или около того *(Страница: #cefr/A1)*
- [x] **#4 💡 (2026-10-08 09:14:12)**: Чтобы можно было нажать на любой текст, будь то в тесте или еще где, (кроме элементов управление) и добавить в словарь. А то иногда встречаются места, где читаешь предложение и не понимаешь, что за слово там ... *(Страница: #cefr/A1)*
- [x] **#3 💡 (2026-10-08 09:04:56)**: В меню боковом измени скролбар на нестандартный. В текущей цветовой палитре и оформлении, стандартный скролбар сильно выделяется *(Страница: #dictionary)*
- [x] **#2 🐛 (2026-10-08 09:01:46)**: Вкладка "мой словарь", карандишик (изменить слово), окно открывается слишком большое, без масштабирования вкладки его невозможно закрыть. Еще бы добавить функцию закрытия окна по пустому месту или по кнопке Esc *(Страница: #dictionary)*
- [x] **#1 🐛 (2026-10-08 09:00:18)**: Вкладка "мой словарь", звук слов не воспроизводится, но предложения озвучиваются без проблем. *(Страница: #dictionary)*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
{
"take off": "взлетать; снимать (одежду); быстро уходить",
"take up": "браться за (хобби/дело); занимать (место, время)",
"take on": "брать на себя (ответственность); нанимать",
"take over": "захватывать контроль; вступать во владение",
"take in": "усваивать; приютить; обманывать",
"take out": "вынимать; приглашать (в ресторан); устранять",
"take back": "возвращать (вещь); брать свои слова назад",
"take after": "быть похожим на (родителей)",
"take apart": "разбирать на части",
"give up": "сдаваться; бросать (привычку)",
"give in": "уступать, сдаваться под натиском",
"give away": "раздавать даром; выдавать (секрет)",
"give out": "раздавать; иссякать, отказывать",
"give back": "возвращать",
"get along with": "ладить, быть в хороших отношениях с кем-то",
"get along": "ладить; справляться",
"get away": "убегать, уезжать в отпуск",
"get away with": "выходить сухим из воды",
"get back": "возвращаться",
"get by": "сводить концы с концами, выживать",
"get off": "выходить (из автобуса/поезда); отделываться",
"get on": "садиться (на транспорт); ладить; продолжать",
"get over": "преодолевать, оправляться (от болезни/потери)",
"get rid of": "избавляться от",
"get through": "справляться; дозваниваться",
"get up": "вставать (с постели); подниматься",
"go on": "продолжать; происходить",
"go out": "выходить в свет; встречаться; гаснуть (об огне/свете)",
"go through": "проходить через, испытывать (трудности); просматривать",
"go back": "возвращаться",
"go off": "звонить (о будильнике); взрываться; портиться (о еде)",
"go over": "проверять, внимательно просматривать; повторять",
"go ahead": "начинать, продолжать; давать добро",
"go with": "сочетаться с, подходить к",
"go down": "спускаться, снижаться; входить в историю",
"go up": "подниматься, расти (о цене)",
"come up with": "придумывать, предлагать (идею, решение)",
"come across": "случайно натолкнуться на, производить впечатление",
"come back": "возвращаться",
"come in": "входить",
"come out": "выходить (в свет); выясняться, обнаруживаться",
"come over": "приходить в гости; охватывать",
"come on": "давай!; появляться; начинаться",
"come up": "возникать, всплывать (о вопросе); подходить",
"come down to": "сводиться к",
"look forward to": "ждать с нетерпением, предвкушать",
"look for": "искать",
"look after": "присматривать за, заботиться",
"look into": "исследовать, изучать, разбираться",
"look up": "искать (в словаре/справочнике); улучшаться",
"look up to": "уважать, брать пример с",
"look down on": "смотреть свысока, презирать",
"look out": "остерегаться, быть начеку",
"look around": "осматриваться",
"turn off": "выключать (прибор, свет, воду)",
"turn on": "включать (прибор, свет, воду)",
"turn up": "прибавлять громкость; неожиданно объявляться",
"turn down": "убавлять громкость; отклонять, отвергать",
"turn out": "оказываться; оборачиваться (результатом)",
"turn into": "превращаться в",
"turn back": "поворачивать назад, разворачиваться",
"turn over": "переворачивать; передавать",
"set up": "устанавливать, организовывать, подготавливать",
"set off": "отправляться в путь; вызывать (реакцию)",
"set out": "отправляться в путь; намереваться",
"put on": "надевать (одежду); включать; прибавлять в весе",
"put off": "откладывать (на потом); вызывать отвращение",
"put out": "тушить (огонь); выпускать (продукцию)",
"put up with": "мириться с, терпеть",
"put away": "убирать на место",
"put forward": "выдвигать (идею/предложение)",
"run out of": "заканчиваться, исчерпать запасы",
"run out": "иссякать, заканчиваться",
"run into": "случайно встретить; сталкиваться",
"run away": "убегать, сбегать",
"run over": "переехать, сбить; повторить бегло",
"bring up": "воспитывать (детей); поднимать (тему в разговоре)",
"bring about": "вызывать, приводить к",
"bring back": "возвращать, вызывать воспоминания",
"bring in": "привлекать, вводить (закон/доход)",
"call off": "отменять (встречу/мероприятие)",
"call back": "перезванивать; отзывать",
"call on": "навещать; призывать к ответу",
"call for": "требовать, предусматривать",
"carry out": "выполнять, приводить в исполнение (план/исследование)",
"carry on": "продолжать (дело/разговор)",
"break down": "ломаться (о технике); терять самообладание",
"break up": "расставаться (об отношениях); распадаться",
"break into": "вламываться, незаконно проникать",
"break out": "разразиться (о войне/пожаре); сбегать (из тюрьмы)",
"fall apart": "разваливаться на куски, терпеть крах",
"fall behind": "отставать (в графике/учёбе)",
"fall for": "попасться на удочку; влюбиться по уши",
"fall out": "ссориться; выпадать",
"keep up with": "поспевать за, держаться наравне с",
"keep up": "поддерживать на том же уровне",
"keep on": "упорно продолжать",
"keep away": "держаться подальше, избегать",
"hold on": "подождать (на линии); крепко держаться",
"hold up": "задерживать; грабить",
"hold back": "сдерживать (эмоции/прогресс)",
"point out": "указывать, обращать внимание на",
"figure out": "выяснить, понять, разобраться",
"find out": "узнавать, выяснять (информацию)",
"show up": "появляться, приходить (неожиданно)",
"show off": "выпендриваться, хвастаться",
"work out": "тренироваться; решать проблему; складываться успешно",
"catch up with": "догонять, нагонять (упущенное/кого-то)",
"catch up": "догонять; наверстывать упущенное",
"check in": "регистрироваться (в отеле/аэропорту)",
"check out": "выселяться; проверять, заценить",
"drop in": "заглянуть в гости без предупреждения",
"drop by": "зайти мимоходом",
"drop off": "подвезти (на машине); заснуть",
"end up": "оказаться в итоге (где-то или в каком-то состоянии)",
"stand out": "выделяться, бросаться в глаза",
"stand for": "означать; выступать в защиту",
"cut down on": "сокращать потребление (сахара/расходов)",
"cut down": "срубать; сокращать",
"cut off": "отрезать, прерывать (связь)",
"fill in": "заполнять (пропуски/форму); вводить в курс дела",
"fill out": "заполнять (анкету/бланк)",
"hang out": "проводить время, тусоваться",
"hang up": "вешать телефонную трубку",
"pass away": "умирать, уходить из жизни",
"pass out": "терять сознание, отключаться",
"pick up": "подбирать; заезжать за кем-то; учить на лету",
"pull through": "справляться, выкарабкиваться (из кризиса/болезни)",
"pull over": "съехать на обочину, притормозить",
"back up": "поддерживать; создавать резервную копию",
"clear up": "проясняться (о погоде); убирать, наводить порядок",
"slow down": "замедляться",
"speed up": "ускоряться",
"switch on": "включать",
"switch off": "выключать",
"throw away": "выбрасывать (мусор)",
"try on": "примерять (одежду)",
"try out": "пробовать, испытывать",
"watch out": "остерегаться, беречься",
"write down": "записывать на бумаге",
"calm down": "успокаиваться",
"cheer up": "приободряться, поднимать настроение",
"make up for": "компенсировать, заглаживать вину",
"make up": "мириться; сочинять (историю); краситься",
"settle down": "остепениться; успокоиться",
"sort out": "улаживать, разбираться с проблемами",
"blow up": "взрываться; выходить из себя",
"burn down": "сгорать дотла",
"tear down": "сносить (здание)",
"wear out": "изнашиваться; утомлять",
"stick to": "придерживаться (плана/правил)",
"kick off": "начинать, давать старт",
"lock up": "запирать на замок",
"pay off": "окупаться; расплачиваться с долгами",
"count on": "рассчитывать на кого-то",
"stay up": "бодрствовать, не ложиться спать",
"speak up": "говорить громче; высказываться смело"
}
+322
View File
@@ -0,0 +1,322 @@
[
{
"id": "art-01",
"title": "The James Webb Space Telescope: Peering into the Cosmic Dawn",
"title_ru": "Космический телескоп Джеймс Уэбб: взгляд в космический рассвет",
"topic": "Science",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Operating in the cold vacuum of deep space, the James Webb Space Telescope observes the universe in high-resolution infrared light. Its massive gold-coated beryllium mirror captures faint photons emitted by the earliest galaxies over thirteen billion years ago.",
"Unlike optical telescopes, infrared astronomy allows scientists to pierce through dense cosmic dust clouds where stars and planetary systems are born. Recently, the observatory discovered complex organic molecules in distant stellar nurseries, challenging previous models of early cosmic chemistry."
],
"paragraphs_ru": [
"Работая в холодном вакууме глубокого космоса, космический телескоп «Джеймс Уэбб» наблюдает за Вселенной в инфракрасном диапазоне высокого разрешения. Его массивное бериллиевое зеркало с золотым покрытием улавливает слабые фотоны, испущенные самыми ранними галактиками более тринадцати миллиардов лет назад.",
"В отличие от оптических телескопов, инфракрасная астрономия позволяет учёным проникать сквозь плотные облака космической пыли, где рождаются звёзды и планетные системы. Недавно обсерватория обнаружила сложные органические молекулы в далёких звёздных колыбелях, что поставило под сомнение прежние модели ранней космической химии."
],
"vocabulary_highlights": [
{
"term": "vacuum",
"trans": "вакуум, пустота"
},
{
"term": "beryllium",
"trans": "бериллий"
},
{
"term": "faint",
"trans": "слабый, едва заметный"
},
{
"term": "photons",
"trans": "фотоны"
},
{
"term": "stellar nurseries",
"trans": "звёздные колыбели"
},
{
"term": "pierce through",
"trans": "проникать сквозь, протыкать"
}
]
},
{
"id": "art-02",
"title": "The Hidden Architecture of Neural Networks",
"title_ru": "Скрытая архитектура нейронных сетей",
"topic": "Technology",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Modern artificial neural networks are loosely inspired by the biological connections in the human cortex. Each artificial neuron computes a weighted sum of its inputs and applies a non-linear activation function before propagating the signal onward.",
"During the backpropagation process, the network calculates gradient vectors to incrementally adjust billions of internal parameters. This iterative mathematical tuning enables language models to perceive subtle nuances of syntax, sentiment, and context without explicit hard-coded rules."
],
"paragraphs_ru": [
"Современные искусственные нейронные сети отдалённо вдохновлены биологическими связями в коре головного мозга человека. Каждый искусственный нейрон вычисляет взвешенную сумму своих входов и применяет нелинейную функцию активации перед передачей сигнала дальше.",
"В процессе обратного распространения ошибки сеть вычисляет векторы градиента, чтобы постепенно корректировать миллиарды внутренних параметров. Эта итеративная математическая настройка позволяет языковым моделям улавливать тонкие нюансы синтаксиса, тональности и контекста без явных жестко заданных правил."
],
"vocabulary_highlights": [
{
"term": "loosely inspired",
"trans": "отдалённо вдохновлённый"
},
{
"term": "cortex",
"trans": "кора (головного мозга)"
},
{
"term": "weighted sum",
"trans": "взвешенная сумма"
},
{
"term": "propagating",
"trans": "распространяющий, передающий"
},
{
"term": "gradient vectors",
"trans": "векторы градиента"
},
{
"term": "subtle nuances",
"trans": "тонкие нюансы"
}
]
},
{
"id": "art-03",
"title": "The Remarkable Intelligence of Crows and Ravens",
"title_ru": "Удивительный интеллект ворон и воронов",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Corvids, the bird family that includes ravens and crows, display cognitive abilities rivaling those of primates. Laboratory experiments reveal that crows can fashion specialized hooks from wire to retrieve food containers placed out of beak reach.",
"Furthermore, field observations demonstrate that wild crows remember specific human faces for several years and communicate warnings about hostile individuals to the rest of the flock across generations."
],
"paragraphs_ru": [
"Врановые — семейство птиц, включающее воронов и ворон, демонстрируют когнитивные способности, соперничающие со способностями приматов. Лабораторные эксперименты показывают, что вороны могут изготавливать специальные крючки из проволоки, чтобы доставать контейнеры с едой, находящиеся вне досягаемости клюва.",
"Более того, полевые наблюдения доказывают, что дикие вороны помнят конкретные человеческие лица в течение нескольких лет и передают предупреждения о недружелюбных особях остальной стае через поколения."
],
"vocabulary_highlights": [
{
"term": "corvids",
"trans": "врановые (птицы)"
},
{
"term": "cognitive abilities",
"trans": "когнитивные способности"
},
{
"term": "rivaling",
"trans": "соперничающий с"
},
{
"term": "fashion hooks",
"trans": "изготавливать крючки"
},
{
"term": "hostile individuals",
"trans": "враждебные особи, недоброжелатели"
},
{
"term": "flock",
"trans": "стая"
}
]
},
{
"id": "art-04",
"title": "Why We Sleep: The Science of Memory Consolidation",
"title_ru": "Зачем мы спим: наука о консолидации памяти",
"topic": "Health",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"While the body rests during deep sleep, the brain is engaged in vigorous maintenance work. The hippocampus transfers newly acquired information into the neocortex for long-term storage, effectively converting fragile short-term memories into enduring knowledge.",
"Simultaneously, the glymphatic system flushes away metabolic waste and toxic proteins that accumulate between neurons during waking hours. Sleep deprivation severely impairs this cleaning cycle, making rapid vocabulary retention far more difficult."
],
"paragraphs_ru": [
"Пока тело отдыхает во время глубокого сна, мозг занят активной работой по техобслуживанию. Гиппокамп переносит вновь полученную информацию в неокортекс для долговременного хранения, эффективно преобразуя хрупкие кратковременные воспоминания в прочные знания.",
"Одновременно с этим глимфатическая система вымывает метаболические отходы и токсичные белки, которые накапливаются между нейронами в часы бодрствования. Недосыпание серьёзно нарушает этот цикл очистки, делая быстрое запоминание словарного запаса намного более трудным."
],
"vocabulary_highlights": [
{
"term": "vigorous maintenance",
"trans": "активное техобслуживание / поддержка"
},
{
"term": "fragile memories",
"trans": "хрупкие воспоминания"
},
{
"term": "enduring knowledge",
"trans": "прочные, долговечные знания"
},
{
"term": "flushes away",
"trans": "смывает, вымывает"
},
{
"term": "sleep deprivation",
"trans": "депривация сна, недосыпание"
},
{
"term": "vocabulary retention",
"trans": "удержание словарного запаса"
}
]
},
{
"id": "art-05",
"title": "The Antikythera Mechanism: Ancient Greek Analog Computer",
"title_ru": "Антикитерский механизм: древнегреческий аналоговый компьютер",
"topic": "History",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"Discovered by sponge divers inside a Roman shipwreck near the Greek island of Antikythera in 1901, this corroded bronze device bewildered historians for decades. Modern micro-computed tomography revealed an intricate clockwork mechanism consisting of over thirty precision gear wheels.",
"Constructed around the second century BC, the mechanism accurately tracked the astronomical positions of the Sun, Moon, and five known planets. It could even predict solar and lunar eclipses years in advance, showcasing a technological sophistication unmatched until fourteenth-century mechanical clocks."
],
"paragraphs_ru": [
"Обнаруженное ловцами губок внутри римского кораблекрушения возле греческого острова Антикитера в 1901 году, это корродированное бронзовое устройство приводило историков в замешательство на протяжении десятилетий. Современная микрокомпьютерная томография выявила сложнейший часовой механизм, состоящий из более чем тридцати прецизионных зубчатых колёс.",
"Созданный около второго века до нашей эры, механизм точно отслеживал астрономические положения Солнца, Луны и пяти известных планет. Он мог даже предсказывать солнечные и лунные затмения на годы вперёд, демонстрируя технологическую сложность, которой не было равных вплоть до механических часов XIV века."
],
"vocabulary_highlights": [
{
"term": "sponge divers",
"trans": "ловцы губок"
},
{
"term": "corroded bronze",
"trans": "подвергшаяся коррозии бронза"
},
{
"term": "bewildered",
"trans": "сбивал с толку, озадачивал"
},
{
"term": "gear wheels",
"trans": "зубчатые колёса, шестерни"
},
{
"term": "lunar eclipses",
"trans": "лунные затмения"
},
{
"term": "sophistication",
"trans": "утончённость, сложность конструкции"
}
]
},
{
"id": "art-06",
"title": "Underground Fungal Networks: The Wood Wide Web",
"title_ru": "Подземные грибные сети: лесная паутина",
"topic": "Nature",
"level": "B1",
"read_time_min": 2,
"paragraphs": [
"Beneath the floor of an ancient forest lies an astonishing symbiotic network of mycorrhizal fungi. These microscopic fungal threads, known as mycelium, weave through tree roots to exchange mineral nutrients and water for sugars generated through photosynthesis.",
"Remarkably, trees utilize this subterranean internet to transmit chemical distress signals when attacked by pests, allowing neighboring trees to synthesize defensive tannins before the infestation spreads."
],
"paragraphs_ru": [
"Под пологом древнего леса скрывается поразительная симбиотическая сеть микоризных грибов. Эти микроскопические грибные нити, известные как мицелий, оплетают корни деревьев, обменивая минеральные питательные вещества и воду на сахара, вырабатываемые в процессе фотосинтеза.",
"Примечательно, что деревья используют этот подземный интернет для передачи химических сигналов бедствия при нападении вредителей, позволяя соседним деревьям синтезировать защитные танины до того, как заражение распространится."
],
"vocabulary_highlights": [
{
"term": "mycorrhizal fungi",
"trans": "микоризные грибы"
},
{
"term": "mycelium",
"trans": "мицелий, грибница"
},
{
"term": "subterranean",
"trans": "подземный"
},
{
"term": "distress signals",
"trans": "сигналы бедствия"
},
{
"term": "infestation",
"trans": "заражение вредителями, нашествие"
}
]
},
{
"id": "art-07",
"title": "Quantum Superposition and the Threshold of Reality",
"title_ru": "Квантовая суперпозиция и порог реальности",
"topic": "Science",
"level": "B2",
"read_time_min": 2,
"paragraphs": [
"In quantum mechanics, subatomic particles exist simultaneously in multiple probable states until an observation forces the wave function to collapse into a single definitive outcome. This fundamental counterintuitive principle is known as quantum superposition.",
"Quantum computers harness this phenomenon by employing qubits instead of classical binary bits. While a traditional bit can only register as a zero or a one, a qubit exists in a superposition of both states, granting exponential computational scaling for cryptographic and molecular simulation algorithms."
],
"paragraphs_ru": [
"В квантовой механике субатомные частицы существуют одновременно во множестве вероятностных состояний до тех пор, пока наблюдение не заставляет волновую функцию коллапсировать в один определённый исход. Этот фундаментальный парадоксальный принцип известен как квантовая суперпозиция.",
"Квантовые компьютеры используют этот феномен, применяя кубиты вместо классических двоичных битов. В то время как традиционный бит может принимать значение только ноль или единица, кубит существует в суперпозиции обоих состояний, обеспечивая экспоненциальное масштабирование вычислений для алгоритмов криптографии и моделирования молекул."
],
"vocabulary_highlights": [
{
"term": "subatomic particles",
"trans": "субатомные частицы"
},
{
"term": "counterintuitive",
"trans": "парадоксальный, нелогичный на первый взгляд"
},
{
"term": "harness",
"trans": "укрощать, эффективно использовать"
},
{
"term": "qubits",
"trans": "кубиты"
},
{
"term": "exponential scaling",
"trans": "экспоненциальное масштабирование"
}
]
},
{
"id": "art-08",
"title": "The Evolution of Coffee Culture and the Age of Enlightenment",
"title_ru": "Эволюция кофейной культуры и Эпоха Просвещения",
"topic": "Culture",
"level": "A2",
"read_time_min": 2,
"paragraphs": [
"During the seventeenth century, European coffee houses emerged as lively hubs for intellectual debate, political discussion, and commercial trade. In London, these establishments were nicknamed 'penny universities' because for the price of a cup of coffee, any customer could participate in philosophical discourse.",
"Scientists like Isaac Newton and economists like Adam Smith regularly met in coffee houses to draft treatises and debate ideas that laid the foundations of the scientific revolution and modern commerce."
],
"paragraphs_ru": [
"В XVII веке европейские кофейни стали оживлёнными центрами интеллектуальных дебатов, политических дискуссий и коммерческой торговли. В Лондоне эти заведения называли «пенни-университетами», потому что по цене чашки кофе любой посетитель мог поучаствовать в философских рассуждениях.",
"Учёные вроде Исаака Ньютона и экономисты вроде Адама Смита регулярно собирались в кофейнях, чтобы составлять трактаты и обсуждать идеи, заложившие основы научной революции и современной коммерции."
],
"vocabulary_highlights": [
{
"term": "lively hubs",
"trans": "оживлённые центры"
},
{
"term": "philosophical discourse",
"trans": "философские рассуждения / дискуссии"
},
{
"term": "draft treatises",
"trans": "составлять трактаты"
},
{
"term": "laid the foundations",
"trans": "заложили основы"
}
]
}
]
+2 -1
View File
@@ -18,6 +18,7 @@ services:
- DB_PATH=/app/data/notes.db
- OLLAMA_BASE_URL=http://ollama:11434
- OLLAMA_MODEL=qwen2.5:3b
- OLLAMA_KEEP_ALIVE=1h
depends_on:
- ollama
@@ -30,4 +31,4 @@ services:
volumes:
- ./data/ollama:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_KEEP_ALIVE=1h
@@ -0,0 +1,95 @@
# 🧠 Специализированная модель перевода и архитектура ИИ (Qwen2.5-0.5B + CTranslate2)
Архитектурное решение по переходу на узкоспециализированную модель перевода для интерактивной читалки книг и разделению ролей ИИ в платформе Study Qpid Pro (`study.qpid.pro`).
---
## 🎯 Архитектурный выбор: Qwen2.5-0.5B-Instruct vs MarianMT (Helsinki-NLP)
Исследованы два класса узких моделей для локального размещения на сервере (AMD Ryzen 9 5950X, 6 vCPU, без GPU, ~7.6 GB RAM доступно):
| Критерий | Helsinki-NLP/opus-mt-en-ru (77M) | Qwen2.5-0.5B-Instruct (~490M) |
| :--- | :--- | :--- |
| **Архитектура** | Seq2Seq (Encoder-Decoder) | Causal LLM (Decoder-only) |
| **Выходной формат** | Только сплошной текст | Строгий JSON (`sentence_ru` + `word_meaning`) |
| **Выделение значения слова** | Требует внешнего aligner / словаря | Выделяет контекстное значение напрямую |
| **Поддержка контекста абзаца**| Слабая (обучена по предложениям) | Отличная (учитывает промпт и контекст) |
| **Инференс на CPU (CTranslate2)**| 10–20 мс | 100–150 мс |
| **Потребление RAM** | ~150 МБ | ~350–400 МБ |
**Выбор**: **`Qwen2.5-0.5B-Instruct`** с дообучением под задачу контекстного перевода предложений и извлечения целевых слов в структурированный JSON.
---
## 🏗️ Разделение ответственности компонентов (Clean Separation of Concerns)
1. **Читалка книг (На лету)**:
- **Модель**: Дообученная `Qwen2.5-0.5B-Instruct`.
- **Движок**: **CTranslate2** (`int8`), загруженный in-process прямо в Python-память FastAPI (`app/ai.py`).
- **Задержка**: ~100–150 мс (без оверхеда на HTTP/сокеты/Docker).
- **Задача**: `(sentence, target_word, context, dict_hints) -> JSON {"sentence_ru", "word_meaning"}`.
2. **Итоговый разбор урока (Debrief)**:
- **Модель**: `Qwen2.5-3B` через Ollama с параметром `keep_alive: "5m"` (подгружается по запросу раз в 20–30 минут, после чего выгружается из памяти).
- **Резерв**: Мгновенный детерминированный движок `generate_deterministic_debrief()` (0 мс, по формулам Мерфи).
3. **Синтез речи (Озвучка)**:
- **Движок**: Piper Neural TTS (ONNX VITS).
- **Статус**: 100% автономен, не зависит от LLM, задержка 20–40 мс.
4. **Генерация и проверка тестов**:
- **Принцип**: 100% алгоритмический детерминизм (каталог 142 юнитов Мерфи, алгоритм SM-2/FSRS в SRS, алгоритмический майнер предложений из книг). Нейросети к проверке тестов намеренно не привлекаются для исключения галлюцинаций.
---
## ⚡ Преимущества CTranslate2 над HTTP-демоном Ollama
1. **In-Process инференс**: `import ctranslate2` прямо в бэкенде. Ноль оверхеда на сеть, TCP-сокеты и сериализацию между контейнерами.
2. **Низкоуровневая оптимизация**: Скомпилирован под AVX2 / FMA для AMD Ryzen.
3. **Квантование int8**: Минимальный расход RAM (~350 МБ) при сохранении 99% точности.
---
## 🛠️ Пайплайн локального дообучения (Fine-Tuning) на VPS
Поскольку модель имеет всего ~490M параметров, её можно дообучить прямо на процессоре сервера без GPU:
### 1. Формат обучающего датасета (`train.jsonl`, 1500–3000 примеров)
```json
{
"messages": [
{
"role": "system",
"content": "Ты — переводчик художественной литературы. Переведи указанное предложение и выдели значение целевого слова. Ответь строго JSON: {\"sentence_ru\": \"...\", \"word_meaning\": \"...\"}"
},
{
"role": "user",
"content": "Контекст абзаца: Mr. Dursley stopped dead. Fear flooded him.\nПредложение: He stared at the people in cloaks.\nЦелевое слово: cloaks\nСловарные подсказки: cloak — плащ, мантия"
},
{
"role": "assistant",
"content": "{\"sentence_ru\": \"Он уставился на людей в мантиях.\", \"word_meaning\": \"мантиях\"}"
}
]
}
```
### 2. Дообучение через PyTorch CPU + PEFT (LoRA)
- Инструменты: `torch (cpu)`, `transformers`, `peft`, `trl`.
- Параметры: `r=16`, `lora_alpha=32`, `target_modules=["q_proj", "k_proj", "v_proj", "o_proj"]`.
- Время обучения на 6 ядрах Ryzen 9 5950X: **~25–45 минут**.
- Расход RAM в процессе: **~3.5–4.5 ГБ** (на сервере доступно ~7.6 ГБ).
### 3. Конвертация в CTranslate2
```bash
# 1. Слияние адаптера LoRA с базовой моделью:
python3 merge_lora.py --base Qwen/Qwen2.5-0.5B-Instruct --adapter ./final_adapter --output ./qwen-0.5b-merged
# 2. Конвертация в int8 CTranslate2 модель:
ct2-transformers-converter --model ./qwen-0.5b-merged \
--output_dir /home/ai-projects/services/study.qpid.pro/models/qwen05b-ct2 \
--quantization int8
```
### 4. Подключение в `app/ai.py`
Заменяем сетевой HTTP-клиент к Ollama для читалки на прямой вызов `ctranslate2.Generator`, сохранив кэширование SQLite.
+1
View File
@@ -16,3 +16,4 @@ typing-inspection==0.4.4
typing_extensions==4.16.0
uvicorn==0.53.0
pypdf>=5.0.0
pytest>=8.0.0
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_DIR"
echo "=== Deploying beta-refactor branch to study.qpid.pro ==="
git checkout beta-refactor
echo "Restarting study-qpid-pro container..."
DOCKER_HOST=tcp://127.0.0.1:2375 docker restart study-qpid-pro
echo "=== Deployment complete! System is running on beta-refactor ==="
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_DIR"
echo "=== Rolling back study.qpid.pro to main branch ==="
git checkout main
if [ -f "data/notes.db.backup-main-pre-beta" ]; then
echo "Note: Database backup available at data/notes.db.backup-main-pre-beta"
fi
echo "Restarting study-qpid-pro container..."
DOCKER_HOST=tcp://127.0.0.1:2375 docker restart study-qpid-pro
echo "=== Rollback complete! System is running on main branch ==="
+90
View File
@@ -0,0 +1,90 @@
import sqlite3
import json
from pathlib import Path
DB_PATH = Path("/home/ai-projects/services/study.qpid.pro/data/notes.db")
TARGET_MD = Path("/home/ai-projects/services/study.qpid.pro/data/USER_REPORTS.md")
MEMORY_MD = Path("/home/ai-projects/memory/USER_REPORTS.md")
def sync():
if not DB_PATH.exists():
print("Database not found")
return
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
ORDER BY f.id DESC
""").fetchall()
lines = [
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
"",
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
"",
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
"",
"## 🔴 Активные репорты (Требуют внимания / В работе)",
"",
]
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
if not active_rows:
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
lines.append("")
else:
for r in active_rows:
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
ctx_extra = ""
if r["context_data_json"]:
try:
c_dict = json.loads(r["context_data_json"])
parts = []
if "screen" in c_dict:
parts.append(f"Экран: {c_dict['screen']}")
if "device" in c_dict:
parts.append(f"Устройство: {c_dict['device']}")
if parts:
ctx_extra = f" ({', '.join(parts)})"
except Exception:
pass
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
lines.append(f"- **Пользователь**: `{r['username']}`")
if r["context_url"]:
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
lines.append(f"- **Текст**:\n> {r['message'].strip()}")
lines.append("")
lines.extend([
"---",
"",
"## ✅ Решенные / Закрытые задачи",
"",
])
if not resolved_rows:
lines.append("*Пока нет закрытых репортов.*")
lines.append("")
else:
for r in resolved_rows:
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
lines.append("")
content = "\n".join(lines)
TARGET_MD.write_text(content, encoding="utf-8")
MEMORY_MD.write_text(content, encoding="utf-8")
print(f"Synced {len(rows)} reports successfully!")
if __name__ == "__main__":
sync()
+147
View File
@@ -1511,8 +1511,18 @@ def test_grammar_engine_and_handwritten_journaling():
assert len(u_data["contrasts"]) >= 1
assert "journaling_prompt" in u_data
assert "тетрадь" in u_data["journaling_prompt"].lower()
assert "cheatsheet_table" in u_data
assert "|" in u_data["cheatsheet_table"]
assert "detailed_notes" in u_data
assert len(u_data["detailed_notes"]) > 100
assert "###" in u_data["detailed_notes"]
assert len(u_data["exercises"]) == 5
# Check lookup by unit_num directly
num_res = client.get("/api/grammar/units/1")
assert num_res.status_code == 200
assert num_res.json()["id"] == unit_1_id
ex0 = u_data["exercises"][0]
assert len(ex0["options"]) == 4
@@ -2497,6 +2507,143 @@ def test_lessons_debrief_and_history():
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
def test_feedback_endpoints_workflow():
from fastapi.testclient import TestClient
from pathlib import Path
# 1. Unauthenticated access blocked
unauth_client = TestClient(app)
resp = unauth_client.get("/api/feedback")
assert resp.status_code == 401
resp = unauth_client.post("/api/feedback", json={"message": "Test bug"})
assert resp.status_code == 401
# 2. Authenticated login
login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
assert login_resp.status_code == 200
try:
# 3. Create a bug report
bug_payload = {
"category": "bug",
"message": "Кнопка в читалке не нажимается на мобилке",
"context_url": "#reader/book/1/ch/2",
"context_data": {"screen": "390x844", "device": "Mobile"}
}
create_resp = client.post("/api/feedback", json=bug_payload)
assert create_resp.status_code == 201
c_data = create_resp.json()
assert c_data["ok"] is True
report_id = c_data["id"]
assert c_data["category"] == "bug"
assert c_data["status"] == "new"
# 4. Create a feature request
feat_payload = {
"category": "feature",
"message": "Добавить темную тему OLED",
"context_url": "#reader",
"context_data": {"screen": "1920x1080", "device": "Desktop"}
}
create_resp2 = client.post("/api/feedback", json=feat_payload)
assert create_resp2.status_code == 201
report_id2 = create_resp2.json()["id"]
# 5. List reports
list_resp = client.get("/api/feedback")
assert list_resp.status_code == 200
reports = list_resp.json()
assert len(reports) >= 2
r_ids = [r["id"] for r in reports]
assert report_id in r_ids
assert report_id2 in r_ids
# 6. Filter by category
bugs_resp = client.get("/api/feedback?category=bug")
assert bugs_resp.status_code == 200
assert all(r["category"] == "bug" for r in bugs_resp.json())
# 7. Update status to resolved
patch_resp = client.patch(f"/api/feedback/{report_id}", json={"status": "resolved"})
assert patch_resp.status_code == 200
assert patch_resp.json()["status"] == "resolved"
# Verify updated
get_updated = client.get("/api/feedback?status=resolved")
assert get_updated.status_code == 200
assert any(r["id"] == report_id and r["status"] == "resolved" for r in get_updated.json())
# 8. Delete report
del_resp = client.delete(f"/api/feedback/{report_id2}")
assert del_resp.status_code == 200
assert del_resp.json()["ok"] is True
# 9. Verify markdown sync file exists and has content
reports_md = Path("/home/ai-projects/memory/USER_REPORTS.md")
assert reports_md.exists()
content = reports_md.read_text(encoding="utf-8")
assert "Пользовательские репорты" in content
assert "Кнопка в читалке не нажимается" in content
finally:
try:
from scripts.sync_reports import sync
sync()
except Exception:
pass