feat(cefr): dual mistake ingestion, 1-tap options breakdown, and 260 curated questions
This commit is contained in:
1 parent
a00b330f74
commit
a2eb26ced5
7 files changed
+1275
-93
No files matched your search
@@ -314,16 +314,23 @@
|
|||||||
- Подсказка значения слова на русском языке и часть речи.
|
- Подсказка значения слова на русском языке и часть речи.
|
||||||
- 4 сбалансированных варианта ответа одинаковой части речи и уровня сложности (исключены грамматические подсказки).
|
- 4 сбалансированных варианта ответа одинаковой части речи и уровня сложности (исключены грамматические подсказки).
|
||||||
- Мгновенная тактильная реакция: зеленая/красная подсветка, автоподстановка правильного слова в пропуск, раскрытие транскрипции IPA и пояснения.
|
- Мгновенная тактильная реакция: зеленая/красная подсветка, автоподстановка правильного слова в пропуск, раскрытие транскрипции IPA и пояснения.
|
||||||
- [x] **Автоматический захват пробелов (Automated Gap Ingestion)**:
|
- [x] **Автоматический двухсторонний захват пробелов (Dual Gap Ingestion)**:
|
||||||
- При правильном ответе $\rightarrow$ вопрос отмечается как освоенный в `cefr_progress`.
|
- При ошибке $\rightarrow$ **автоматически в словарь SRS со статусом «В процессе» (`learning`) добавляются ОБА слова**:
|
||||||
- При ошибке $\rightarrow$ слово/оборот **автоматически создаётся (или обновляется) как карточка в личном словаре со статусом `learning`** и немедленным сроком повторения (`srs_next_review = now`), коллекцией `Уровни CEFR ({level})` и тегами `#level-{level}`, `#cefr-test`, `#test-mistake`.
|
1. Истинное целевое слово (правильный ответ).
|
||||||
- [x] Проблемные слова сразу попадают в утренний пул интервальных повторений SRS тренажера.
|
2. Выбранный дистрактор (слово, на которое кликнул пользователь, с его переводом и транскрипцией).
|
||||||
|
- Срочный срок повторения (`srs_next_review = now`), коллекция `Уровни CEFR ({level})` и теги `#level-{level}`, `#cefr-test`, `#test-mistake`.
|
||||||
|
- [x] **Интерактивный разбор всех вариантов (Options Breakdown & 1-Tap Add)**:
|
||||||
|
- Карточки всех 4 вариантов ответа с переводом из оффлайн-словаря и частью речи.
|
||||||
|
- Автоматическая маркировка добавленных слов (`✓ В словаре (SRS)` / `✓ В библиотеке`).
|
||||||
|
- Быстрая кнопка `+ В словарь` для любых оставшихся незнакомых вариантов вопроса в 1 тап.
|
||||||
|
- [x] **Расширенная база вопросов (Oxford 3000/5000)**:
|
||||||
|
- Банк расширен со 160 до **260 эталонных вопросов** (по 65 вопросов на каждый уровень A1, A2, B1, B2).
|
||||||
- [x] **Наглядный прогресс-трекер**:
|
- [x] **Наглядный прогресс-трекер**:
|
||||||
- Экран обзора со шкалами освоения каждого уровня (A1 Beginner, A2 Elementary, B1 Intermediate, B2 Upper-Intermediate).
|
- Экран обзора со шкалами освоения каждого уровня (A1 Beginner, A2 Elementary, B1 Intermediate, B2 Upper-Intermediate).
|
||||||
- Счётчики: процент закрытия уровня, освоено, ошибок, слов в тренировке SRS.
|
- Счётчики: процент закрытия уровня, освоено, ошибок, слов в тренировке SRS.
|
||||||
- Режим «Работа над ошибками» (быстрый прогон только ошибочных вопросов).
|
- Режим «Работа над ошибками» (быстрый прогон только ошибочных вопросов).
|
||||||
- Возможность сброса прогресса уровня.
|
- Возможность сброса прогресса уровня.
|
||||||
- [x] Добавлен регрессионный тест `test_cefr_testing_and_gap_ingestion` в [`tests/test_api.py`](file:///home/ai-projects/services/study.qpid.pro/tests/test_api.py) (28/28 тестов успешно, 100% green).
|
- [x] Добавлен регрессионный тест `test_cefr_testing_and_gap_ingestion` в [`tests/test_api.py`](file:///home/ai-projects/services/study.qpid.pro/tests/test_api.py) (29/29 тестов успешно, 100% green).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+935
-74
File diff suppressed because it is too large.
Load diff
+21
@@ -36,6 +36,7 @@ from app.cefr import (
|
|||||||
get_cefr_quiz_batch,
|
get_cefr_quiz_batch,
|
||||||
process_cefr_answer,
|
process_cefr_answer,
|
||||||
reset_user_cefr_level,
|
reset_user_cefr_level,
|
||||||
|
ingest_cefr_word,
|
||||||
)
|
)
|
||||||
from app.grammar import (
|
from app.grammar import (
|
||||||
get_grammar_units_list,
|
get_grammar_units_list,
|
||||||
@@ -185,6 +186,13 @@ class CefrResetRequest(BaseModel):
|
|||||||
level: str
|
level: str
|
||||||
|
|
||||||
|
|
||||||
|
class CefrAddWordRequest(BaseModel):
|
||||||
|
level: str
|
||||||
|
term: str
|
||||||
|
context_sentence: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GrammarAnswerRequest(BaseModel):
|
class GrammarAnswerRequest(BaseModel):
|
||||||
unit_id: int
|
unit_id: int
|
||||||
exercise_idx: int
|
exercise_idx: int
|
||||||
@@ -1466,6 +1474,19 @@ async def api_cefr_reset(
|
|||||||
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
|
return {"status": "ok", "level": req.level.upper(), "reset_count": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cefr/add-word")
|
||||||
|
async def api_cefr_add_word(
|
||||||
|
req: CefrAddWordRequest,
|
||||||
|
user: dict = Depends(require_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Adds a word from a CEFR test options list directly into user's SRS active vocabulary.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
return ingest_cefr_word(conn, user["id"], req.level, req.term, req.context_sentence)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Task 16: Murphy Grammar Engine & Handwritten Journaling Endpoints
|
# Task 16: Murphy Grammar Engine & Handwritten Journaling Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4664,6 +4664,175 @@ body {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dual Gap Ingestion (Correct + Chosen Mistake) */
|
||||||
|
.cefr-dual-ingested-box {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-ingested-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-ingested-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingested-chip {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingested-chip.chip-target {
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingested-chip.chip-chosen {
|
||||||
|
border-left: 3px solid #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingested-chip .chip-label {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Words Breakdown (All 4 Options with In-library/Add buttons) */
|
||||||
|
.cefr-words-breakdown {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-breakdown-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-hint {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.words-breakdown-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-breakdown-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 9px 11px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-breakdown-card.is-target {
|
||||||
|
border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
background: rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-breakdown-card.is-chosen-mistake {
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-card-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-term {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-pos {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-trans {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-action-wrap {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-badge.badge-auto-added {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-badge.badge-in-lib,
|
||||||
|
.wb-badge.badge-just-added {
|
||||||
|
color: #10b981;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-option-word {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Next Button Bar */
|
/* Next Button Bar */
|
||||||
.cefr-next-bar {
|
.cefr-next-bar {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|||||||
@@ -247,6 +247,19 @@ export const api = {
|
|||||||
});
|
});
|
||||||
if (resp.ok) return await resp.json();
|
if (resp.ok) return await resp.json();
|
||||||
throw new Error(`Failed to reset level: ${resp.status}`);
|
throw new Error(`Failed to reset level: ${resp.status}`);
|
||||||
|
},
|
||||||
|
async addWord(level, term, contextSentence = '') {
|
||||||
|
const resp = await fetch('/api/cefr/add-word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
level: level,
|
||||||
|
term: term,
|
||||||
|
context_sentence: contextSentence
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (resp.ok) return await resp.json();
|
||||||
|
throw new Error(`Failed to add word: ${resp.status}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -381,14 +381,15 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
|
${renderOptionsBreakdown(res.options_breakdown)}
|
||||||
`;
|
`;
|
||||||
feedbackBox.classList.remove('hidden');
|
feedbackBox.classList.remove('hidden');
|
||||||
|
attachBreakdownListeners(feedbackBox, q.sentence);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionStats.mistakes++;
|
sessionStats.mistakes++;
|
||||||
if (res.card_created || res.card_updated) {
|
// Two words were ingested: the correct target word and the clicked distractor
|
||||||
sessionStats.ingested++;
|
sessionStats.ingested += 2;
|
||||||
}
|
|
||||||
|
|
||||||
clickedBtn.classList.add('opt-wrong');
|
clickedBtn.classList.add('opt-wrong');
|
||||||
|
|
||||||
@@ -413,14 +414,26 @@ async function handleOptionTap(questionId, selectedOption, clickedBtn) {
|
|||||||
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
${res.phonetic ? `<span class="res-phonetic">${escapeHtml(res.phonetic)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
${res.explanation ? `<p class="fb-explanation">${escapeHtml(res.explanation)}</p>` : ''}
|
||||||
<div class="cefr-gap-ingested-badge">
|
<div class="cefr-dual-ingested-box">
|
||||||
|
<div class="dual-ingested-header">
|
||||||
<span class="badge-icon">📥</span>
|
<span class="badge-icon">📥</span>
|
||||||
<div class="badge-text">
|
<strong>В ваш словарь SRS («В процессе») добавлены оба слова:</strong>
|
||||||
<strong>Авто-захват в словарь:</strong> слово добавлено со статусом «В процессе» для закрепления в утреннем SRS!
|
</div>
|
||||||
|
<div class="dual-ingested-chips">
|
||||||
|
<div class="ingested-chip chip-target">
|
||||||
|
<span class="chip-label">Верный ответ:</span>
|
||||||
|
<strong>${escapeHtml(res.correct_option)}</strong> — ${escapeHtml(res.translation || '')}
|
||||||
|
</div>
|
||||||
|
<div class="ingested-chip chip-chosen">
|
||||||
|
<span class="chip-label">Ваш выбор:</span>
|
||||||
|
<strong>${escapeHtml(res.selected_option)}</strong> — ${escapeHtml(res.selected_translation || '')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
${renderOptionsBreakdown(res.options_breakdown)}
|
||||||
`;
|
`;
|
||||||
feedbackBox.classList.remove('hidden');
|
feedbackBox.classList.remove('hidden');
|
||||||
|
attachBreakdownListeners(feedbackBox, q.sentence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,3 +491,73 @@ function renderQuizSummary() {
|
|||||||
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
|
sumSrsBtn.classList.toggle('hidden', ingested === 0 && mistakes === 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Render Options Breakdown (All 4 options with 1-tap SRS Ingestion)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
function renderOptionsBreakdown(breakdown) {
|
||||||
|
if (!breakdown || breakdown.length === 0) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="cefr-words-breakdown">
|
||||||
|
<div class="words-breakdown-header">
|
||||||
|
<span class="wb-title"><i class="ph-books"></i> Слова из этого вопроса:</span>
|
||||||
|
<span class="wb-hint">Добавьте в словарь слова, перевод которых хотите запомнить</span>
|
||||||
|
</div>
|
||||||
|
<div class="words-breakdown-grid">
|
||||||
|
${breakdown.map(b => {
|
||||||
|
const isTarget = b.is_correct;
|
||||||
|
const wasChosenMistake = b.was_selected && !b.is_correct;
|
||||||
|
const inLib = b.in_library || b.auto_added;
|
||||||
|
let actionHtml = '';
|
||||||
|
|
||||||
|
if (b.auto_added) {
|
||||||
|
actionHtml = `<span class="wb-badge badge-auto-added"><i class="ph-check-circle-fill"></i> В словаре (SRS)</span>`;
|
||||||
|
} else if (inLib) {
|
||||||
|
actionHtml = `<span class="wb-badge badge-in-lib"><i class="ph-check"></i> В библиотеке</span>`;
|
||||||
|
} else {
|
||||||
|
actionHtml = `<button type="button" class="btn btn-xs btn-outline btn-add-option-word" data-term="${escapeHtml(b.term)}">+ В словарь</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="word-breakdown-card ${isTarget ? 'is-target' : ''} ${wasChosenMistake ? 'is-chosen-mistake' : ''}">
|
||||||
|
<div class="wb-card-top">
|
||||||
|
<strong class="wb-term">${escapeHtml(b.term)}</strong>
|
||||||
|
${b.pos ? `<span class="wb-pos">${escapeHtml(b.pos)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="wb-trans">${escapeHtml(b.translation || '—')}</div>
|
||||||
|
<div class="wb-action-wrap">${actionHtml}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachBreakdownListeners(container, contextSentence) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-add-option-word').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const term = btn.dataset.term;
|
||||||
|
if (!term) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.cefr.addWord(currentLevel, term, contextSentence || '');
|
||||||
|
sessionStats.ingested++;
|
||||||
|
btn.outerHTML = '<span class="wb-badge badge-just-added"><i class="ph-check-circle-fill"></i> Добавлено!</span>';
|
||||||
|
showToast(`Слово «${term}» добавлено в утренний тренажёр SRS!`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '+ В словарь';
|
||||||
|
showToast(`Ошибка: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
+36
-8
@@ -1376,10 +1376,10 @@ def test_cefr_testing_and_gap_ingestion():
|
|||||||
assert len(levels) == 4
|
assert len(levels) == 4
|
||||||
level_map = {l["level"]: l for l in levels}
|
level_map = {l["level"]: l for l in levels}
|
||||||
assert "A1" in level_map and "A2" in level_map and "B1" in level_map and "B2" in level_map
|
assert "A1" in level_map and "A2" in level_map and "B1" in level_map and "B2" in level_map
|
||||||
assert level_map["A1"]["total_questions"] == 40
|
assert level_map["A1"]["total_questions"] >= 65
|
||||||
assert level_map["A2"]["total_questions"] == 40
|
assert level_map["A2"]["total_questions"] >= 65
|
||||||
assert level_map["B1"]["total_questions"] == 40
|
assert level_map["B1"]["total_questions"] >= 65
|
||||||
assert level_map["B2"]["total_questions"] == 40
|
assert level_map["B2"]["total_questions"] >= 65
|
||||||
|
|
||||||
# 4. Fetch quiz batch
|
# 4. Fetch quiz batch
|
||||||
quiz_res = client.get("/api/cefr/quiz?level=A2&count=5")
|
quiz_res = client.get("/api/cefr/quiz?level=A2&count=5")
|
||||||
@@ -1393,8 +1393,12 @@ def test_cefr_testing_and_gap_ingestion():
|
|||||||
assert "clue" in q1
|
assert "clue" in q1
|
||||||
assert len(q1["options"]) == 4
|
assert len(q1["options"]) == 4
|
||||||
|
|
||||||
# 5. Answer INCORRECTLY -> Automated Gap Ingestion into SRS!
|
# 5. Answer INCORRECTLY -> Automated Dual Gap Ingestion (target + selected option)
|
||||||
wrong_opt = "__completely_wrong_option__"
|
# Pick a distractor from the question's actual options
|
||||||
|
# Find option that is not correct (we test with the first distractor)
|
||||||
|
# Let's inspect options and find one that is wrong
|
||||||
|
wrong_opt = [opt for opt in q1["options"] if opt.lower() != q1.get("term", "").lower()][0]
|
||||||
|
|
||||||
ans_wrong = client.post("/api/cefr/answer", json={
|
ans_wrong = client.post("/api/cefr/answer", json={
|
||||||
"question_id": q1["id"],
|
"question_id": q1["id"],
|
||||||
"selected_option": wrong_opt
|
"selected_option": wrong_opt
|
||||||
@@ -1402,12 +1406,15 @@ def test_cefr_testing_and_gap_ingestion():
|
|||||||
assert ans_wrong.status_code == 200
|
assert ans_wrong.status_code == 200
|
||||||
w_data = ans_wrong.json()
|
w_data = ans_wrong.json()
|
||||||
assert w_data["correct"] is False
|
assert w_data["correct"] is False
|
||||||
assert w_data["card_created"] is True
|
assert w_data["card_created"] is True or w_data["card_updated"] is True
|
||||||
assert w_data["card_id"] is not None
|
assert w_data["card_id"] is not None
|
||||||
assert w_data["correct_option"] != ""
|
assert w_data["correct_option"] != ""
|
||||||
assert w_data["explanation"] != ""
|
assert w_data["explanation"] != ""
|
||||||
|
assert w_data["selected_card_id"] is not None
|
||||||
|
assert w_data["selected_card_term"] == wrong_opt
|
||||||
|
assert len(w_data["options_breakdown"]) == 4
|
||||||
|
|
||||||
# Verify the card was actually created in vocabulary_cards with state 'learning' and level tag
|
# Verify target word card in SRS
|
||||||
card_res = client.get(f"/api/cards/{w_data['card_id']}")
|
card_res = client.get(f"/api/cards/{w_data['card_id']}")
|
||||||
assert card_res.status_code == 200
|
assert card_res.status_code == 200
|
||||||
ingested_card = card_res.json()
|
ingested_card = card_res.json()
|
||||||
@@ -1415,6 +1422,27 @@ def test_cefr_testing_and_gap_ingestion():
|
|||||||
assert "level-a2" in ingested_card["tags"]
|
assert "level-a2" in ingested_card["tags"]
|
||||||
assert "cefr-test" in ingested_card["tags"]
|
assert "cefr-test" in ingested_card["tags"]
|
||||||
|
|
||||||
|
# Verify selected distractor card in SRS
|
||||||
|
sel_card_res = client.get(f"/api/cards/{w_data['selected_card_id']}")
|
||||||
|
assert sel_card_res.status_code == 200
|
||||||
|
sel_card = sel_card_res.json()
|
||||||
|
assert sel_card["srs_state"] == "learning"
|
||||||
|
assert "level-a2" in sel_card["tags"]
|
||||||
|
assert "cefr-test" in sel_card["tags"]
|
||||||
|
|
||||||
|
# Test manual ingestion of remaining option via /api/cefr/add-word
|
||||||
|
other_opt = [opt for opt in q1["options"] if opt != w_data["correct_option"] and opt != wrong_opt][0]
|
||||||
|
add_word_res = client.post("/api/cefr/add-word", json={
|
||||||
|
"level": "A2",
|
||||||
|
"term": other_opt,
|
||||||
|
"context_sentence": q1["sentence"]
|
||||||
|
})
|
||||||
|
assert add_word_res.status_code == 200
|
||||||
|
add_word_data = add_word_res.json()
|
||||||
|
assert add_word_data["status"] == "ok"
|
||||||
|
assert add_word_data["card_id"] is not None
|
||||||
|
assert add_word_data["term"] == other_opt
|
||||||
|
|
||||||
# 6. Check that mode='mistakes' returns this question
|
# 6. Check that mode='mistakes' returns this question
|
||||||
mistakes_res = client.get("/api/cefr/quiz?level=A2&mode=mistakes")
|
mistakes_res = client.get("/api/cefr/quiz?level=A2&mode=mistakes")
|
||||||
assert mistakes_res.status_code == 200
|
assert mistakes_res.status_code == 200
|
||||||
|
|||||||
Reference in new issue
Block a user