feat(VocabService, VocabPracticeDialog, VocabLessonView): enhance vocabulary training logic and UI feedback
All checks were successful
Deploy to production / deploy (push) Successful in 2m17s

- Introduced methods for improved text analysis and validation in VocabService, including `_wordCount` and `_looksLikeFragmentMismatch`, to better assess learning and reference pairs.
- Updated VocabPracticeDialog to display submitted answers and correct solutions, enhancing user feedback during practice sessions.
- Enhanced VocabLessonView to ensure only trainable vocabulary pairs are processed, improving the quality of vocabulary training.
- Added localization entries for new UI elements in both English and German, ensuring clarity in user interactions.
This commit is contained in:
Torsten Schulz (local)
2026-04-20 08:48:39 +02:00
parent 553f132184
commit e28ed7bdb5
5 changed files with 130 additions and 9 deletions

View File

@@ -92,9 +92,35 @@ export default class VocabService {
return false;
}
if (this._looksLikeFragmentMismatch(learning, reference)) {
return false;
}
return !this._isInstructionLikeText(learning) && !this._isInstructionLikeText(reference);
}
_wordCount(value) {
return String(value || '')
.trim()
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
}
_looksLikeFragmentMismatch(left, right) {
const leftWords = this._wordCount(left);
const rightWords = this._wordCount(right);
const leftText = String(left || '').trim();
const rightText = String(right || '').trim();
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
}
_calculateSrsSchedule(item, { correct, rating = null } = {}) {
const now = new Date();
const previousStage = Math.max(0, Number(item?.stage) || 0);

View File

@@ -42,15 +42,20 @@
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
<div v-else>
{{ $t('socialnetwork.vocab.practice.wrong') }}
<div class="answers">
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.acceptable') }}</div>
<ul>
<li v-for="a in acceptableAnswers" :key="a">{{ a }}</li>
</ul>
</div>
</div>
</div>
<div v-if="answered && !lastCorrect" class="solution-card">
<div v-if="submittedAnswer" class="submitted-answer">
<span>{{ $t('socialnetwork.vocab.practice.yourAnswer') }}</span>
<strong>{{ submittedAnswer }}</strong>
</div>
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.correctSolutions') }}</div>
<ul>
<li v-for="a in visibleCorrectAnswers" :key="a">{{ a }}</li>
</ul>
</div>
<div v-if="showSrsRatingButtons" class="srs-rating">
<div class="srs-rating__title">{{ $t('socialnetwork.vocab.practice.srsRateTitle') }}</div>
<button
@@ -169,6 +174,7 @@ export default {
current: null, // { id, learning, reference }
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
acceptableAnswers: [],
submittedAnswer: '',
choiceOptions: [],
typedAnswer: '',
answered: false,
@@ -211,6 +217,17 @@ export default {
showSrsRatingButtons() {
return this.srsMode && this.answered && !this.locked;
},
visibleCorrectAnswers() {
const answers = Array.isArray(this.acceptableAnswers) ? this.acceptableAnswers.filter(Boolean) : [];
if (answers.length > 0) {
return answers;
}
if (!this.current) {
return [];
}
const fallback = this.direction === 'L2R' ? this.current.reference : this.current.learning;
return this.expandAnswerVariants(fallback);
},
srsRatingOptions() {
if (!this.answered) {
return [];
@@ -429,8 +446,31 @@ export default {
if (!learning || !reference || this.normalize(learning) === this.normalize(reference)) {
return false;
}
if (this.looksLikeFragmentMismatch(learning, reference)) {
return false;
}
return !this.isInstructionLikeText(learning) && !this.isInstructionLikeText(reference);
},
wordCount(value) {
return String(value || '')
.trim()
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
},
looksLikeFragmentMismatch(left, right) {
const leftWords = this.wordCount(left);
const rightWords = this.wordCount(right);
const leftText = String(left || '').trim();
const rightText = String(right || '').trim();
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
},
normalizePool(items = []) {
const seen = new Set();
return (Array.isArray(items) ? items : [])
@@ -458,6 +498,7 @@ export default {
this.current = null;
this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L');
this.acceptableAnswers = [];
this.submittedAnswer = '';
this.choiceOptions = [];
this.typedAnswer = '';
this.answered = false;
@@ -473,6 +514,7 @@ export default {
this.answered = false;
this.lastCorrect = false;
this.typedAnswer = '';
this.submittedAnswer = '';
if (!this.pool || this.pool.length === 0) return;
@@ -746,6 +788,7 @@ export default {
},
submitChoice(opt) {
if (this.locked) return;
this.submittedAnswer = String(opt || '').trim();
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
this.markResult(ok);
this.playSound(ok);
@@ -760,6 +803,7 @@ export default {
},
submitTyped() {
if (this.locked) return;
this.submittedAnswer = String(this.typedAnswer || '').trim();
const ans = this.normalize(this.typedAnswer);
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
this.markResult(ok);
@@ -927,6 +971,25 @@ export default {
background: #ffecec;
border-color: #d33;
}
.solution-card {
margin: 0 0 12px;
padding: 12px;
border: 1px solid #d33;
background: #fff8f8;
}
.solution-card ul {
margin: 6px 0 0;
padding-left: 20px;
}
.submitted-answer {
display: grid;
gap: 3px;
margin-bottom: 10px;
color: #5f554e;
}
.submitted-answer strong {
color: #2b2520;
}
.answersTitle {
margin-top: 6px;
font-weight: bold;

View File

@@ -451,6 +451,8 @@
"correct": "Richtig!",
"wrong": "Falsch.",
"acceptable": "Mögliche richtige Übersetzungen:",
"correctSolutions": "Richtige Antwort:",
"yourAnswer": "Deine Antwort:",
"stats": "Statistik",
"dueToday": "Heute fällig",
"done": "Erledigt",

View File

@@ -451,6 +451,8 @@
"correct": "Correct!",
"wrong": "Wrong.",
"acceptable": "Acceptable answers:",
"correctSolutions": "Correct answer:",
"yourAnswer": "Your answer:",
"stats": "Stats",
"dueToday": "Due today",
"done": "Done",

View File

@@ -1452,7 +1452,7 @@ export default {
const oriented = orientPair(entry?.learning, entry?.reference);
const reference = String(oriented.reference || '').trim();
const learning = String(oriented.learning || '').trim();
if (!reference) return;
if (!this.isTrainableLessonVocabPair(learning, reference)) return;
const key = this.normalizeLessonVocabTerm(reference);
if (!vocabByReference.has(key)) {
const variants = new Set();
@@ -1523,7 +1523,7 @@ export default {
return { from, to, page, pages: totalPages, total: n };
},
trainableLessonVocab() {
return this.lessonVocab.filter((entry) => entry.learning && entry.reference && entry.learning !== entry.reference);
return this.lessonVocab.filter((entry) => this.isTrainableLessonVocabPair(entry.learning, entry.reference));
},
lessonDidactics() {
return this.lesson?.didactics || {
@@ -1610,7 +1610,7 @@ export default {
const oriented = orientPair(target, gloss);
const t = String(oriented.target || '').trim();
const g = String(oriented.gloss || '').trim();
if (!t || !g) return;
if (!this.isTrainableLessonVocabPair(g, t)) return;
const key = `${this.normalizeLessonVocabTerm(t)}|${this.normalizeLessonVocabTerm(g)}`;
if (seen.has(key)) return;
seen.add(key);
@@ -2125,6 +2125,34 @@ export default {
.replace(/^[.,!?;:]+|[.,!?;:]+$/g, '')
.trim();
},
lessonVocabWordCount(value) {
return String(value || '')
.trim()
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
},
looksLikeLessonVocabFragmentMismatch(left, right) {
const leftWords = this.lessonVocabWordCount(left);
const rightWords = this.lessonVocabWordCount(right);
const leftText = String(left || '').trim();
const rightText = String(right || '').trim();
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
},
isTrainableLessonVocabPair(learning, reference) {
const l = String(learning || '').trim();
const r = String(reference || '').trim();
if (!l || !r || this.normalizeLessonVocabTerm(l) === this.normalizeLessonVocabTerm(r)) {
return false;
}
return !this.looksLikeLessonVocabFragmentMismatch(l, r);
},
normalizeCorePatternEntry(p) {
if (p && typeof p === 'object' && p.target) {
return {