Compare commits

14 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
42 changed files with 5585 additions and 1192 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: пассивный залог, косвенная речь, модальные глаголы).
---
+5 -5
View File
@@ -251,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,
@@ -367,10 +367,10 @@ async def warmup_ollama_model() -> bool:
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}")
@@ -567,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,
@@ -576,7 +576,7 @@ async def generate_lesson_ai_debrief(
try:
client = get_ollama_client()
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=25.0)
resp = await client.post(f"{config.OLLAMA_BASE_URL}/api/generate", json=payload, timeout=10.0)
if resp.status_code == 200:
raw_response = resp.json().get("response", "{}")
# Parse JSON
+1
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)
+128 -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,11 +294,132 @@ async def lookup_term(query: str) -> Dict[str, Any]:
except Exception:
pass
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
is_cyrillic = bool(re.search(r"[\u0400-\u04FF]", query_clean))
words = query_clean.split()
is_sentence = len(words) > 4
# -----------------------------------------------------------------------
# Reverse Translation (Russian -> English)
# -----------------------------------------------------------------------
if is_cyrillic:
matched_term = None
phonetic = ""
pos = ""
clean_trans = query_clean
tr_list = [query_clean]
is_single = len(words) == 1
# 1. Search offline dictionary for exact reverse matches
if not is_sentence:
try:
with get_db() as conn:
cur = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE translation LIKE ?",
(f"%{query_clean}%",)
)
rows = cur.fetchall()
exact_matches = []
for r in rows:
raw_t = r["translation"] or ""
parts = [p.strip().lower() for p in raw_t.split(",")]
if query_clean.lower() in parts:
w_cnt = len(r["term"].split())
is_multi = 1 if (is_single and w_cnt > 1) else 0
is_inflection = 1 if " от " in raw_t else 0
exact_matches.append((is_multi, is_inflection, w_cnt, len(r["term"]), r))
if exact_matches:
exact_matches.sort(key=lambda x: (x[0], x[1], x[2], x[3]))
# Prefer single-word offline match if query was single word
if exact_matches[0][0] == 0:
best_row = exact_matches[0][4]
matched_term = best_row["term"]
phonetic = best_row["phonetic"] or ""
pos = best_row["pos"] or ""
clean_trans = clean_russian_translation(best_row["translation"] or "")
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
except Exception:
pass
# 2. If no direct offline match, query MyMemory with langpair="ru|en"
http_client = get_http_client()
if not matched_term:
try:
mm_data = await fetch_mymemory(query_clean, langpair="ru|en", client=http_client)
raw_cands = []
if mm_data.get("translation"):
raw_cands.append(mm_data["translation"])
for m in mm_data.get("matches", []):
if m and m not in raw_cands and m.lower() != query_clean.lower():
raw_cands.append(m)
cleaned_cands = []
for c in raw_cands:
c_clean = re.sub(r"^(?:to|an?|the)\s+", "", c.strip(), flags=re.IGNORECASE).strip(" *.,!?\"'")
if c_clean and c_clean not in cleaned_cands and not re.search(r"[\u0400-\u04FF]", c_clean):
cleaned_cands.append(c_clean)
if cleaned_cands:
matched_term = cleaned_cands[0]
# Enrich from offline dictionary if candidate exists
with get_db() as conn:
for cand in cleaned_cands:
off_row = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(cand,)
).fetchone()
if off_row:
matched_term = off_row["term"]
phonetic = off_row["phonetic"] or ""
pos = off_row["pos"] or ""
if not clean_trans or clean_trans == query_clean:
clean_trans = clean_russian_translation(off_row["translation"] or "") or query_clean
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
break
# If phonetic still missing and candidate is a single word, fetch from free dictionary API
if not phonetic and len(matched_term.split()) == 1:
fd = await fetch_free_dictionary_api(matched_term, client=http_client)
if fd.get("phonetic"):
phonetic = fd["phonetic"]
except Exception:
pass
if not matched_term:
matched_term = query_clean
res = {
"query": query_clean,
"term": matched_term,
"english_term": matched_term,
"russian_query": query_clean,
"is_sentence": is_sentence,
"is_reverse": True,
"translation": clean_trans,
"translations_list": tr_list if tr_list else [clean_trans],
"translation_ru": clean_trans,
"phonetic": phonetic,
"audio_file": "",
"audio_url": "",
"part_of_speech": pos or ("Sentence" if is_sentence else "Word"),
"definitions": [],
"examples": [],
"source": "reverse_dictionary",
}
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO dictionary_cache (query, response_json) VALUES (?, ?)",
(query_clean, json.dumps(res, ensure_ascii=False))
)
return res
# -----------------------------------------------------------------------
# Forward Translation (English -> Russian)
# -----------------------------------------------------------------------
# Check offline dictionary first (Zero Daemon, < 0.1ms via SQLite mmap)
if not is_sentence:
try:
with get_db() as conn:
off_row = conn.execute(
"SELECT term, translation, phonetic, pos FROM offline_dictionary WHERE term = ? COLLATE NOCASE",
(query_clean,)
@@ -329,7 +450,9 @@ async def lookup_term(query: str) -> Dict[str, Any]:
tr_list = [t.strip() for t in clean_trans.split(",") if t.strip()]
res = {
"query": query_clean,
"term": off_row["term"] or query_clean,
"is_sentence": False,
"is_reverse": False,
"translation": clean_trans,
"translations_list": tr_list,
"translation_ru": clean_trans,
@@ -349,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": "",
+48 -14
View File
@@ -51,7 +51,7 @@ CURATED_MURPHY_UNITS = _LazyUnitsList()
def seed_grammar_units(conn: sqlite3.Connection) -> int:
"""
Populates grammar_units table with curated Murphy units.
Safe and idempotent (INSERT OR IGNORE).
Safe, idempotent upsert (ON CONFLICT DO UPDATE).
"""
inserted = 0
for u in CURATED_MURPHY_UNITS:
@@ -59,10 +59,22 @@ def seed_grammar_units(conn: sqlite3.Connection) -> int:
exercises_json = json.dumps(u["exercises"], ensure_ascii=False)
cursor = conn.execute(
"""
INSERT OR IGNORE INTO grammar_units (
INSERT INTO grammar_units (
unit_num, level, category, title, subtitle, golden_formula,
cheatsheet_table, detailed_notes,
contrast_explanation_json, journaling_prompt, exercises_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(unit_num) DO UPDATE SET
level = excluded.level,
category = excluded.category,
title = excluded.title,
subtitle = excluded.subtitle,
golden_formula = excluded.golden_formula,
cheatsheet_table = excluded.cheatsheet_table,
detailed_notes = excluded.detailed_notes,
contrast_explanation_json = excluded.contrast_explanation_json,
journaling_prompt = excluded.journaling_prompt,
exercises_json = excluded.exercises_json
""",
(
u["unit_num"],
@@ -71,6 +83,8 @@ def seed_grammar_units(conn: sqlite3.Connection) -> int:
u["title"],
u.get("subtitle", ""),
u["golden_formula"],
u.get("cheatsheet_table", ""),
u.get("detailed_notes", ""),
contrast_json,
u["journaling_prompt"],
exercises_json,
@@ -117,18 +131,22 @@ def get_grammar_units_list(conn: sqlite3.Connection, user_id: int) -> List[Dict[
def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int) -> Optional[Dict[str, Any]]:
"""
Returns full content of a unit for lesson mode.
Supports looking up by database PK or unit_num.
"""
row = conn.execute(
"""
SELECT
u.id, u.unit_num, u.level, u.category, u.title, u.subtitle,
u.golden_formula, u.contrast_explanation_json, u.journaling_prompt, u.exercises_json,
u.golden_formula, u.cheatsheet_table, u.detailed_notes,
u.contrast_explanation_json, u.journaling_prompt, u.exercises_json,
p.is_completed, p.score, p.handwritten_logged, p.completed_at
FROM grammar_units u
LEFT JOIN grammar_progress p ON u.id = p.unit_id AND p.user_id = ?
WHERE u.id = ?
WHERE u.id = ? OR u.unit_num = ?
ORDER BY (u.id = ?) DESC
LIMIT 1
""",
(user_id, unit_id),
(user_id, unit_id, unit_id, unit_id),
).fetchone()
if not row:
@@ -162,9 +180,12 @@ def get_grammar_unit_detail(conn: sqlite3.Connection, user_id: int, unit_id: int
"level": row["level"],
"category": row["category"],
"title": row["title"],
"subtitle": row["subtitle"],
"subtitle": row["subtitle"] or "",
"golden_formula": row["golden_formula"],
"cheatsheet_table": row["cheatsheet_table"] or "",
"detailed_notes": row["detailed_notes"] or "",
"contrasts": contrasts,
"contrast_explanation": contrasts,
"journaling_prompt": row["journaling_prompt"],
"exercises": exercises,
"is_completed": bool(row["is_completed"]),
@@ -187,7 +208,8 @@ def process_grammar_answer(
with tags 'grammar, murphy-unit-{unit_num}' and srs_state='learning'.
"""
unit_row = conn.execute(
"SELECT * FROM grammar_units WHERE id = ?", (unit_id,)
"SELECT * FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
(unit_id, unit_id, unit_id),
).fetchone()
if not unit_row:
@@ -293,9 +315,15 @@ def record_grammar_completion(
"""
Saves unit completion score and handwritten journaling status.
"""
u_row = conn.execute(
"SELECT id FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
(unit_id, unit_id, unit_id),
).fetchone()
real_unit_id = u_row["id"] if u_row else unit_id
prog_row = conn.execute(
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
(user_id, unit_id),
(user_id, real_unit_id),
).fetchone()
hw_val = 1 if handwritten_logged else 0
@@ -320,10 +348,10 @@ def record_grammar_completion(
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at, completed_at
) VALUES (?, ?, 1, ?, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, CURRENT_TIMESTAMP)
""",
(user_id, unit_id, score, hw_val, hw_val),
(user_id, real_unit_id, score, hw_val, hw_val),
)
return {"status": "ok", "unit_id": unit_id, "score": score, "handwritten_logged": handwritten_logged}
return {"status": "ok", "unit_id": real_unit_id, "score": score, "handwritten_logged": handwritten_logged}
def update_handwritten_status(
@@ -335,10 +363,16 @@ def update_handwritten_status(
"""
Toggles handwritten paper notebook checkbox for a unit.
"""
u_row = conn.execute(
"SELECT id FROM grammar_units WHERE id = ? OR unit_num = ? ORDER BY (id = ?) DESC LIMIT 1",
(unit_id, unit_id, unit_id),
).fetchone()
real_unit_id = u_row["id"] if u_row else unit_id
hw_val = 1 if handwritten else 0
prog_row = conn.execute(
"SELECT id FROM grammar_progress WHERE user_id = ? AND unit_id = ?",
(user_id, unit_id),
(user_id, real_unit_id),
).fetchone()
if prog_row:
@@ -358,7 +392,7 @@ def update_handwritten_status(
user_id, unit_id, is_completed, score, handwritten_logged, handwritten_at
) VALUES (?, ?, 0, 0, ?, CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END)
""",
(user_id, unit_id, hw_val, hw_val),
(user_id, real_unit_id, hw_val, hw_val),
)
return {"status": "ok", "unit_id": unit_id, "handwritten_logged": handwritten}
return {"status": "ok", "unit_id": real_unit_id, "handwritten_logged": handwritten}
+1
View File
@@ -27,6 +27,7 @@ from app.routers.grammar import GrammarAnswerRequest, GrammarCompleteRequest, Gr
from app.routers.tasks import MineBooksRequest
from app.routers.listening import ListeningCheckRequest
from app.routers.lessons import SavePresetRequest, LessonDebriefRequest
from app.routers.feedback import FeedbackCreateRequest, FeedbackStatusUpdateRequest
@asynccontextmanager
+5 -1
View File
@@ -1182,6 +1182,10 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL | re.IGNORECASE)
body = re.sub(r'<noscript\b[^>]*>.*?</noscript>', '', body, flags=re.DOTALL | re.IGNORECASE)
# Pre-strip MediaWiki Parsoid metadata attributes (data-mw, data-parsoid, about)
# These contain raw template JSON with <ref> tags and literal '>' characters that break tag regexes
body = re.sub(r'''\s+(?:data-mw|data-parsoid|about)=(?:"[^"]*"|'[^']*')''', '', body, flags=re.DOTALL)
# 3. Strip unwanted wiki elements
# Remove references: <sup class="reference">...</sup> and bracketed reference markers
body = re.sub(r'<sup\b[^>]*class=["\x27][^"\x27]*reference[^"\x27]*["\x27][^>]*>.*?</sup>', '', body, flags=re.DOTALL | re.IGNORECASE)
@@ -1229,7 +1233,7 @@ def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[s
if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE):
return ""
cleaned_tbl = re.sub(
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=["\x27][^"\x27]*["\x27]',
r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=(?:"[^"]*"|\'[^\']*\')',
'',
tbl_html,
flags=re.IGNORECASE
+3
View File
@@ -9,6 +9,7 @@ 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,
@@ -22,6 +23,7 @@ all_routers = [
grammar_router,
tasks_router,
lessons_router,
feedback_router,
]
__all__ = [
@@ -37,4 +39,5 @@ __all__ = [
"grammar_router",
"tasks_router",
"lessons_router",
"feedback_router",
]
+6 -2
View File
@@ -38,11 +38,15 @@ async def api_lookup(
raise HTTPException(status_code=400, detail="Query term cannot be empty")
data = await lookup_term(query_str)
# Ensure term is always present in response
target_term = data.get("term") or data.get("english_term") or query_str
data["term"] = target_term
# Check if this term already exists in user's dictionary
with get_db() as conn:
existing = conn.execute(
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND lower(trim(term)) = lower(trim(?))",
(user["id"], query_str),
"SELECT id, term, translation, context_sentence, tags, collection, srs_state, srs_reps FROM vocabulary_cards WHERE user_id = ? AND (lower(trim(term)) = lower(trim(?)) OR lower(trim(term)) = lower(trim(?)))",
(user["id"], target_term, query_str),
).fetchone()
if existing:
data["in_library"] = True
+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}
+18 -2
View File
@@ -24,6 +24,7 @@ async def api_srs_queue(
tag: Optional[str] = None,
limit: int = 25,
mix: bool = False,
exclude_grammar: bool = True,
user: dict = Depends(require_user),
):
"""
@@ -46,6 +47,13 @@ async def api_srs_queue(
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(
@@ -117,6 +125,7 @@ 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),
):
"""
@@ -132,15 +141,22 @@ async def api_srs_quiz(
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 all user cards for generating smart distractors
# 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(
"SELECT id, term, translation, part_of_speech FROM vocabulary_cards WHERE user_id = ?",
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]
+474
View File
@@ -40,6 +40,17 @@
}
}
@keyframes slideUpSheet {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.auth-header,
.login-header {
text-align: center;
@@ -336,6 +347,25 @@
will-change: transform;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.app-drawer::-webkit-scrollbar {
width: 6px;
}
.app-drawer::-webkit-scrollbar-track {
background: transparent;
}
.app-drawer::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 9999px;
}
.app-drawer::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
.app-drawer.active {
@@ -673,9 +703,11 @@ body.drawer-open {
border-radius: var(--radius-lg);
width: 100%;
max-width: 520px;
max-height: min(90vh, 90dvh);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
@@ -684,6 +716,7 @@ body.drawer-open {
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.modal-header h3 {
@@ -696,6 +729,19 @@ body.drawer-open {
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.modal-body::-webkit-scrollbar {
width: 6px;
}
.modal-body::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 9999px;
}
.modal-footer {
@@ -704,12 +750,44 @@ body.drawer-open {
gap: 10px;
padding: 14px 20px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.modal-sm {
max-width: 420px;
}
/* Audio Control Group & Slow Speed Controls */
.audio-ctrl-group {
display: inline-flex;
align-items: center;
gap: 4px;
vertical-align: middle;
}
.audio-slow-btn {
font-size: 11px !important;
font-weight: 600;
padding: 2px 7px !important;
height: 24px;
line-height: 1;
border-radius: 4px;
background: var(--bg-hover);
border: 1px solid var(--border-color);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 2px;
}
.audio-slow-btn:hover {
background: var(--bg-surface-elevated);
color: var(--text-main);
border-color: var(--primary);
}
/* Navigation Additions */
.nav-center {
display: flex;
@@ -1145,3 +1223,399 @@ body.drawer-open {
}
}
/* ==========================================================================
ChipDip-Style Floating Feedback & Dev Bug Report Widget
========================================================================== */
.feedback-container {
position: relative;
z-index: 9995;
}
/* Floating Launcher Button */
.feedback-trigger-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
color: #ffffff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(99, 102, 241, 0.42), 0 2px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, opacity 0.2s ease;
z-index: 9995;
user-select: none;
}
.feedback-trigger-btn:hover {
transform: scale(1.08) translateY(-2px);
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.55), 0 3px 8px rgba(0, 0, 0, 0.3);
}
.feedback-trigger-btn:active {
transform: scale(0.96);
}
.feedback-trigger-icon {
font-size: 22px;
line-height: 1;
}
.feedback-badge {
position: absolute;
top: -4px;
right: -4px;
background: #ef4444;
color: #ffffff;
font-size: 11px;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--bg-main);
}
/* Floating Chat Window */
.feedback-widget {
position: fixed;
bottom: 84px;
right: 24px;
width: 380px;
max-width: calc(100vw - 32px);
height: 520px;
max-height: calc(100dvh - 120px);
background: var(--bg-surface);
border: 1px solid var(--border-focus);
border-radius: 18px;
box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 9998;
animation: fadeInScale 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.feedback-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(99, 102, 241, 0.08));
border-bottom: 1px solid var(--border-color);
}
.feedback-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.feedback-header-icon {
font-size: 20px;
}
.feedback-header-title {
font-size: 14px;
font-weight: 600;
color: var(--text-main);
line-height: 1.2;
}
.feedback-header-sub {
font-size: 11px;
color: var(--text-muted);
}
.feedback-close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: background 0.15s ease, color 0.15s ease;
}
.feedback-close-btn:hover {
background: var(--bg-hover);
color: var(--text-main);
}
/* Category Switcher Tabs */
.feedback-category-bar {
display: flex;
gap: 6px;
padding: 8px 14px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
}
.feedback-cat-chip {
flex: 1;
padding: 5px 8px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-full);
border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
}
.feedback-cat-chip:hover {
color: var(--text-main);
border-color: var(--border-focus);
}
.feedback-cat-chip.active {
background: rgba(99, 102, 241, 0.16);
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
/* Context Indicator */
.feedback-context-indicator {
padding: 5px 14px;
background: rgba(0, 0, 0, 0.15);
font-size: 11px;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border-color);
}
/* Chat Messages List */
.feedback-messages-list {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-main);
}
.feedback-empty-state {
margin: auto;
text-align: center;
padding: 24px 12px;
color: var(--text-muted);
}
.feedback-empty-state .empty-icon {
font-size: 32px;
display: block;
margin-bottom: 8px;
}
.feedback-empty-state p {
font-size: 13px;
font-weight: 500;
color: var(--text-main);
margin-bottom: 4px;
}
.feedback-empty-state small {
font-size: 11.5px;
color: var(--text-muted);
line-height: 1.4;
display: block;
}
/* Message Card in Feed */
.feedback-msg-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 10px 12px;
font-size: 13px;
line-height: 1.45;
animation: fadeIn 0.15s ease-out;
}
.feedback-msg-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 8px;
}
.feedback-msg-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: var(--radius-full);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.feedback-msg-badge.bug {
background: rgba(239, 68, 68, 0.14);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.feedback-msg-badge.feature {
background: rgba(168, 85, 247, 0.14);
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.3);
}
.feedback-msg-badge.note {
background: rgba(59, 130, 246, 0.14);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.feedback-msg-status {
font-size: 10.5px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
}
.feedback-msg-status.new {
color: #facc15;
}
.feedback-msg-status.in_progress {
color: #38bdf8;
}
.feedback-msg-status.resolved {
color: #4ade80;
}
.feedback-msg-text {
color: var(--text-main);
white-space: pre-wrap;
word-break: break-word;
}
.feedback-msg-meta {
margin-top: 6px;
font-size: 10.5px;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: space-between;
}
/* Input Form */
.feedback-input-form {
padding: 10px 14px 12px;
background: var(--bg-surface);
border-top: 1px solid var(--border-color);
}
.feedback-textarea {
width: 100%;
box-sizing: border-box;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-main);
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
resize: none;
outline: none;
transition: border-color 0.15s ease;
}
.feedback-textarea:focus {
border-color: var(--border-focus);
}
.feedback-form-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
}
.feedback-hint {
font-size: 10.5px;
color: var(--text-dim);
}
.feedback-send-btn {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--primary);
color: #ffffff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, transform 0.15s ease;
}
.feedback-send-btn:hover {
background: var(--primary-hover);
transform: scale(1.05);
}
.feedback-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Zen mode override: completely hide feedback button & widget */
body.zen-mode-active #feedback-container,
body.zen-mode-active .feedback-trigger-btn,
body.zen-mode-active .feedback-widget {
display: none !important;
}
/* Mobile responsive */
@media (max-width: 768px) {
.feedback-trigger-btn {
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
right: 16px;
width: 44px;
height: 44px;
}
.feedback-trigger-icon {
font-size: 20px;
}
.feedback-widget {
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
right: 10px;
left: 10px;
width: auto;
max-width: none;
height: calc(85dvh - 70px);
border-radius: 18px;
}
}
+50 -52
View File
@@ -757,56 +757,6 @@
}
/* 7. Bottom Sheet for Word Translation Popup on Mobile */
.reader-word-popup {
position: fixed !important;
top: auto !important;
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
max-width: none !important;
border-radius: 18px !important;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5) !important;
border: 1px solid var(--border-focus) !important;
background-color: var(--bg-surface) !important;
z-index: 10005 !important;
padding: 14px 16px !important;
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.reader-settings-panel {
position: fixed !important;
top: auto !important;
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
max-height: calc(85dvh - 60px) !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
border-radius: 20px !important;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.55) !important;
border: 1px solid var(--border-focus) !important;
background-color: var(--bg-surface) !important;
color: var(--text-main) !important;
padding: 16px 18px 24px !important;
z-index: 10006 !important;
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
@keyframes slideUpSheet {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* --- Responsive Mobile Overrides (max-width: 768px) --- */
@media (max-width: 768px) {
/* 4. Dictionary & Search */
@@ -924,6 +874,54 @@
.card-bottom {
font-size: 11px;
}
}
/* Grammar Hint Badge & Audio buttons in Cards */
.grammar-hint-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
padding: 2px 7px;
border-radius: 9999px;
font-size: 11px;
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
color: var(--text-muted, #94a3b8);
cursor: pointer;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
transition: all 0.2s ease;
user-select: none;
}
.grammar-hint-badge:hover {
background: var(--bg-active, rgba(59, 130, 246, 0.15));
color: var(--primary, #38bdf8);
border-color: var(--primary, #38bdf8);
}
.grammar-hint-badge .badge-body {
display: none;
font-style: italic;
color: var(--text-primary, #f1f5f9);
}
.grammar-hint-badge.active .badge-body,
.grammar-hint-badge:hover .badge-body {
display: inline;
}
.grammar-hint-badge.active .badge-label {
opacity: 0.6;
}
.card-context .play-context-audio-btn {
padding: 1px 4px;
font-size: 12px;
vertical-align: middle;
margin-left: 6px;
opacity: 0.7;
}
.card-context .play-context-audio-btn:hover {
opacity: 1;
}
+148
View File
@@ -1688,3 +1688,151 @@
}
}
/* ==========================================================================
FEAT-02: Murphy Cheatsheet Tables & Rich Detailed Notes
========================================================================== */
.grammar-table-wrap {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
background: var(--bg-card);
box-shadow: var(--shadow-sm);
margin: 12px 0 16px 0;
}
.grammar-cheatsheet-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
line-height: 1.5;
text-align: left;
}
.grammar-cheatsheet-table thead th {
background: var(--bg-surface-hover);
color: var(--text-main);
font-weight: 700;
padding: 10px 14px;
border-bottom: 2px solid var(--border-color);
white-space: nowrap;
}
.grammar-cheatsheet-table tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
color: var(--text-main);
vertical-align: top;
}
.grammar-cheatsheet-table tbody tr:last-child td {
border-bottom: none;
}
.grammar-cheatsheet-table tbody tr:hover {
background: rgba(99, 102, 241, 0.04);
}
.grammar-inline-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12.5px;
background: var(--bg-input);
color: var(--color-primary);
padding: 2px 5px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.grammar-notes-box {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px 24px;
color: var(--text-main);
font-size: 14px;
line-height: 1.65;
box-shadow: var(--shadow-sm);
}
.grammar-notes-box .grammar-note-h3 {
font-size: 16px;
font-weight: 800;
color: var(--text-main);
margin: 16px 0 8px 0;
letter-spacing: -0.01em;
}
.grammar-notes-box .grammar-note-h4 {
font-size: 15px;
font-weight: 700;
color: var(--color-primary);
margin: 16px 0 8px 0;
}
.grammar-notes-box .grammar-note-h5 {
font-size: 13.5px;
font-weight: 700;
color: var(--text-muted);
margin: 12px 0 6px 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.grammar-notes-box .grammar-note-p {
margin: 0 0 10px 0;
}
.grammar-notes-box .grammar-note-quote {
border-left: 3px solid var(--color-primary);
background: rgba(99, 102, 241, 0.06);
padding: 8px 14px;
margin: 10px 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
font-style: italic;
color: var(--text-main);
}
.grammar-notes-list {
margin: 0 0 12px 0;
padding-left: 20px;
}
.grammar-notes-list li {
margin-bottom: 6px;
line-height: 1.55;
}
/* Collapsible Practice Cheatsheet Drawer */
.practice-cheatsheet-toggle-wrap {
margin-bottom: 16px;
}
.practice-cheatsheet-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
padding: 6px 12px;
border-radius: var(--radius-md);
background: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
}
.practice-cheatsheet-btn:hover {
color: var(--text-main);
background: var(--bg-surface-hover);
border-color: var(--color-primary);
}
.practice-cheatsheet-drawer {
margin-top: 10px;
animation: fadeIn 0.2s ease-out;
}
+240 -3
View File
@@ -1549,9 +1549,11 @@
background-color: var(--bg-surface);
border: 1px solid var(--border-focus);
border-radius: var(--radius-lg);
box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.5);
width: 320px;
max-width: calc(100vw - 32px);
box-shadow: 0 12px 28px -4px rgba(0, 0, 0, 0.55);
width: 340px;
max-width: 360px;
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 16px;
animation: fadeInScale 0.15s ease-out;
}
@@ -2062,5 +2064,240 @@
line-height: 1.65;
}
/* Mobile Bottom Sheet for Word Translation Popup */
.reader-word-popup {
position: fixed !important;
top: auto !important;
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
max-width: none !important;
max-height: calc(85dvh - 60px) !important;
overflow-y: auto !important;
border-radius: 18px !important;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5) !important;
border: 1px solid var(--border-focus) !important;
background-color: var(--bg-surface) !important;
z-index: 10005 !important;
padding: 14px 16px !important;
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
body.zen-mode-active .reader-word-popup {
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
}
/* Mobile Bottom Sheet for Reader Settings */
.reader-settings-panel {
position: fixed !important;
top: auto !important;
bottom: calc(58px + env(safe-area-inset-bottom, 0px)) !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
max-width: none !important;
max-height: calc(85dvh - 60px) !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
border-radius: 20px !important;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.55) !important;
border: 1px solid var(--border-focus) !important;
background-color: var(--bg-surface) !important;
color: var(--text-main) !important;
padding: 16px 18px 24px !important;
z-index: 10006 !important;
animation: slideUpSheet 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
body.zen-mode-active .reader-settings-panel {
bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
}
}
/* ==========================================================================
FEAT-01: Focus Mode in Reader (Zen Reader — 100% vh & 100% vw)
========================================================================== */
.reader-zen-btn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-main);
border-color: var(--border-color);
transition: var(--transition);
}
.reader-zen-btn:hover {
border-color: var(--primary);
color: var(--primary);
background-color: var(--primary-dim);
}
/* Fullscreen / Zen Viewport Override */
body.zen-mode-active {
overflow: hidden !important;
}
body.zen-mode-active #app-header,
body.zen-mode-active #bottom-tab-bar,
body.zen-mode-active .reader-toolbar {
display: none !important;
}
body.zen-mode-active .reader-view {
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
body.zen-mode-active #reader-interface {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100dvh !important;
z-index: 9999 !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
background-color: var(--reader-bg) !important;
overflow: hidden !important;
}
body.zen-mode-active #reader-scroll-area {
width: 100vw !important;
height: 100dvh !important;
padding-top: max(24px, env(safe-area-inset-top)) !important;
padding-bottom: max(60px, env(safe-area-inset-bottom)) !important;
box-sizing: border-box !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
/* Floating Minimal HUD in Zen Mode */
.reader-zen-hud {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: max(20px, env(safe-area-inset-right));
z-index: 10002;
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
pointer-events: auto;
}
.reader-zen-hud.hud-idle {
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
}
.zen-hud-content {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius-full);
padding: 6px 14px;
font-size: 13px;
color: #e2e8f0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.zen-hud-title {
max-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
color: #f8fafc;
}
.zen-hud-sep {
color: rgba(255, 255, 255, 0.3);
font-size: 11px;
}
.zen-hud-chapter {
font-size: 12px;
color: #cbd5e1;
white-space: nowrap;
}
.zen-hud-progress {
font-weight: 700;
color: #38bdf8;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.zen-hud-exit-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #cbd5e1;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
padding: 0;
margin-left: 2px;
}
.zen-hud-exit-btn:hover {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(239, 68, 68, 0.6);
color: #fca5a5;
transform: scale(1.1);
}
/* Theme-adaptive HUD styling for Sepia & Light themes */
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-content {
background: rgba(247, 237, 214, 0.88);
border-color: rgba(146, 64, 14, 0.25);
color: #453422;
box-shadow: 0 4px 20px rgba(70, 50, 30, 0.18);
}
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-title {
color: #291e14;
}
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-progress {
color: #d97706;
}
.reader-scroll-area.reader-theme-sepia ~ #reader-zen-hud .zen-hud-exit-btn {
background: rgba(146, 64, 14, 0.12);
border-color: rgba(146, 64, 14, 0.2);
color: #453422;
}
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-content {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(0, 0, 0, 0.12);
color: #1e293b;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-title {
color: #0f172a;
}
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-progress {
color: #0284c7;
}
.reader-scroll-area.reader-theme-light ~ #reader-zen-hud .zen-hud-exit-btn {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.15);
color: #475569;
}
+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.7">
<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;
+2 -1
View File
@@ -1,7 +1,7 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
const CACHE_NAME = 'notes-v6.7';
const CACHE_NAME = 'notes-v6.9';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
@@ -22,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',
];
+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
+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
+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