diff --git a/app/routers/feedback.py b/app/routers/feedback.py index 8789931..e816bb2 100644 --- a/app/routers/feedback.py +++ b/app/routers/feedback.py @@ -12,7 +12,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"]) -# Path to workspace memory file for seamless Antigravity AI reading +# 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") @@ -97,8 +98,13 @@ def sync_reports_to_markdown(conn): lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*") lines.append("") - MEMORY_REPORTS_FILE.parent.mkdir(parents=True, exist_ok=True) - MEMORY_REPORTS_FILE.write_text("\n".join(lines), encoding="utf-8") + 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}") diff --git a/app/routers/srs.py b/app/routers/srs.py index a628077..f32b0f2 100644 --- a/app/routers/srs.py +++ b/app/routers/srs.py @@ -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] diff --git a/app/static/css/components.css b/app/static/css/components.css index 7453cba..eb28707 100644 --- a/app/static/css/components.css +++ b/app/static/css/components.css @@ -347,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 { @@ -684,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 { @@ -695,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 { @@ -707,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 { @@ -715,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; diff --git a/app/static/index.html b/app/static/index.html index 1fbacc2..bee9a9b 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -322,7 +322,10 @@
Контекст: - + + + +

@@ -2017,9 +2020,12 @@ - + -
- 💡 - Значение: - ${escapeHtml(q.clue)} +
+ +
@@ -491,6 +503,17 @@ function renderCurrentQuestion() { `; + // 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 = ` -
✓ Верно! Отличный выбор.
-
+
✓ Верно! ${pts === 0.8 ? 'Получено: +0.8 балла (с подсказкой)' : 'Получено: +1.0 балл'}
+
${escapeHtml(res.correct_option)} ${res.phonetic ? `${escapeHtml(res.phonetic)}` : ''} + + + +
${res.source_book_title ? `
📖 Пример из книги «${escapeHtml(res.source_book_title)}»${res.source_chapter_title ? ` (${escapeHtml(res.source_chapter_title)})` : ''}
` : ''} ${res.explanation ? `

${escapeHtml(res.explanation)}

` : ''} ${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 ? `(подсказок: ${sessionStats.hintsUsed})` : ''}`; if (sumIngested) sumIngested.textContent = ingested; if (sumLead) { diff --git a/app/static/js/modules/dictionary.js b/app/static/js/modules/dictionary.js index 6f456ad..2f6dcf5 100644 --- a/app/static/js/modules/dictionary.js +++ b/app/static/js/modules/dictionary.js @@ -167,8 +167,7 @@ export function initDictionary() { audioPlayBtn.addEventListener('click', () => { const term = currentLookupData?.term || currentLookupData?.english_term; if (!term) return; - const url = currentLookupData.audio_url || `/api/tts?text=${encodeURIComponent(term)}&lang=en`; - playAudio(url); + playTextSpeech(term, 'en'); }); } @@ -402,7 +401,10 @@ export function initDictionary() { ${c.context_sentence ? `
“${escapeHtml(c.context_sentence)}” - + + + +
` : ''}
@@ -416,13 +418,17 @@ export function initDictionary() { // Audio buttons cardEl.querySelector('.play-term-audio-btn')?.addEventListener('click', (e) => { e.stopPropagation(); - if (c.audio_url) playAudio(c.audio_url); - else playTextSpeech(c.term, 'en'); + playTextSpeech(c.term, 'en', 1.0); }); cardEl.querySelector('.play-context-audio-btn')?.addEventListener('click', (e) => { e.stopPropagation(); - playTextSpeech(c.context_sentence, 'en'); + 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 diff --git a/app/static/js/modules/reader.js b/app/static/js/modules/reader.js index 71a2e35..b9703c6 100644 --- a/app/static/js/modules/reader.js +++ b/app/static/js/modules/reader.js @@ -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 @@ -2251,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'); @@ -2265,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'); }); } @@ -2275,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); }); } @@ -2286,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); }); } diff --git a/app/static/js/modules/srs.js b/app/static/js/modules/srs.js index 96aaf0d..5ccb47a 100644 --- a/app/static/js/modules/srs.js +++ b/app/static/js/modules/srs.js @@ -3,7 +3,7 @@ // ======================================================================= import { api } from './api.js'; -import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio, cleanPromptTranslation } 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'); @@ -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); }); } @@ -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); }); } @@ -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) { @@ -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) { @@ -640,7 +649,7 @@ export function initSrs() { 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(); diff --git a/data/USER_REPORTS.md b/data/USER_REPORTS.md new file mode 100644 index 0000000..e89e935 --- /dev/null +++ b/data/USER_REPORTS.md @@ -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)* diff --git a/scripts/sync_reports.py b/scripts/sync_reports.py new file mode 100644 index 0000000..fb69c4e --- /dev/null +++ b/scripts/sync_reports.py @@ -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() diff --git a/tests/test_api.py b/tests/test_api.py index 84d4a59..4a10fff 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2572,67 +2572,74 @@ def test_feedback_endpoints_workflow(): login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"}) assert login_resp.status_code == 200 - # 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" + 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"] + # 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 + # 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()) + # 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" + # 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()) + # 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 + # 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 + # 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