Training works great

This commit is contained in:
2026-06-13 14:48:02 +03:00
parent 388ed84fff
commit 99a00e0743
4 changed files with 88 additions and 63 deletions
+18 -8
View File
@@ -56,7 +56,8 @@ export function calculatedScore(stat: CardStatistics): number {
const diffMs = now - stat.last_open;
const days = diffMs / (1000 * 60 * 60 * 24);
const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days));
return stat.score * multiplier;
const v = stat.score * multiplier;
return v;
}
export function updateStatScore(stat: CardStatistics, status: WordOpenMode): void {
@@ -154,17 +155,25 @@ class SemiRandomSRSModule implements SRSModule {
private initialized = false;
next(set: { words: WordData[]; set: CardStatistics[] }): number {
const maxHistory = this.historyLen(set.set.length);
// Пробуем выбрать индекс не из истории (не более 100 попыток)
for (let attempt = 0; attempt < 100; attempt++) {
const index = this.lastWeights.sample();
if (this.history.includes(index)) {
return this.next(set);
}
if (this.history.length >= this.historyLen(set.set.length)) {
if (!this.history.includes(index) || maxHistory >= set.set.length) {
// Если история может вместить все слова — пропускаем проверку
if (this.history.length >= maxHistory) {
this.history.shift();
}
this.history.push(index);
return index;
}
}
// Fallback: если все индексы в истории, очищаем историю
this.history = [];
const index = this.lastWeights.sample();
this.history.push(index);
return index;
}
@@ -178,11 +187,12 @@ class SemiRandomSRSModule implements SRSModule {
const weights = set.set.map(
(s) => Math.pow(100.0 / Math.max(0.1, calculatedScore(s)), 2.0) * 2.0,
);
this.lastWeights = new WeightedIndex(weights);
}
private historyLen(setLen: number): number {
return Math.min(MAX_HISTORY_LEN, Math.floor(setLen * MAX_HISTORY_LEN_PART));
return Math.min(MAX_HISTORY_LEN, Math.max(0, Math.floor(setLen * MAX_HISTORY_LEN_PART)));
}
}
+18 -2
View File
@@ -25,6 +25,20 @@ export interface CardStatRecord {
last_opened: number;
}
/**
* Парсит last_opened из SQLite.
* SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00",
* хотя в схеме поле INTEGER. Приводим к unix timestamp (секунды).
*/
function parseLastOpened(value: any): number {
if (typeof value === 'number') return value;
if (typeof value === 'string') {
const ts = Date.parse(value);
if (!isNaN(ts)) return Math.floor(ts / 1000);
}
return Math.floor(Date.now() / 1000);
}
/**
* Получает все колоды (card_set).
*/
@@ -101,7 +115,7 @@ export async function getWordsAndStatsForSet(
word_id: r[1],
set_id: r[2],
score: r[3],
last_opened: r[4],
last_opened: parseLastOpened(r[4]),
}));
return { words, stats };
@@ -115,8 +129,10 @@ export async function updateCardStat(
score: number,
lastOpened: number,
): Promise<void> {
// Убеждаемся, что score — число >= 1
const safeScore = Math.max(1, Math.round(score || 1));
await executeVoid(
`UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`,
[score, lastOpened, statId],
[safeScore, lastOpened, statId],
);
}
+2
View File
@@ -193,6 +193,8 @@ async function startTraining(setId: number) {
return;
}
console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей статистики, режим: ${mode}`);
const settings = {
id: set.id,
name: set.name,
+42 -45
View File
@@ -11,11 +11,28 @@ export interface WordItem {
group_id: number;
}
function getFieldValues(more: Record<string, string>, fields: string): string[] {
/**
* Извлекает значения полей из слова.
* Сначала ищет в основных полях (key, value, tags),
* потом в more (JSON-словарь).
* Если поле не найдено — пропускается (возвращается пустая строка, фильтруется).
*/
function getWordFieldValues(word: WordItem, fields: string): string[] {
const directFields: Record<string, string> = {
key: word.key,
value: word.value,
tags: word.tags,
};
return fields
.split(/\s+/)
.filter(Boolean)
.map((f) => more[f] || f);
.map((f) => {
if (f in directFields && directFields[f]) return directFields[f];
if (f in word.more && word.more[f]) return word.more[f];
return '';
})
.filter(Boolean);
}
export interface TrainingCallbacks {
@@ -28,6 +45,8 @@ export class TrainingPage {
private words: WordItem[];
private callbacks: TrainingCallbacks;
private totalCards: number;
private forwardFields: string;
private backwardFields: string;
constructor(
container: HTMLElement,
@@ -39,6 +58,8 @@ export class TrainingPage {
this.container = container;
this.callbacks = callbacks;
this.totalCards = stats.length;
this.forwardFields = settings.forward || 'key';
this.backwardFields = settings.backward || 'value';
const wordData: WordData[] = words.map((w) => ({
id: w.id,
@@ -55,13 +76,12 @@ export class TrainingPage {
}
private render(): void {
const current = this.cardSet.currentWordIndex;
const total = this.totalCards;
this.container.innerHTML = `
<div class="training-header">
<button id="btnTrainingBack" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button>
<span style="font-size:0.85rem;color:var(--text);">${current !== null ? current + 1 : 0} / ${total}</span>
<span style="font-size:0.85rem;color:var(--text);">слов: ${total}</span>
</div>
<div id="training-card" class="training-card">
<div class="training-content" id="training-content">
@@ -83,11 +103,7 @@ export class TrainingPage {
const word = this.words.find((w) => w.id === result.word.id);
if (!word) return;
const settings = (this.cardSet as any).settings as CardSetSettings | undefined;
const forwardFields = settings?.forward || 'key';
const backwardFields = settings?.backward || 'value';
this.renderSide(word, forwardFields, backwardFields, false);
this.renderSide(word, this.forwardFields, this.backwardFields, false);
this.renderActions(false);
}
@@ -97,27 +113,33 @@ export class TrainingPage {
backwardFields: string,
showBack: boolean,
): void {
const more = word.more;
const contentEl = document.getElementById('training-content');
if (!contentEl) return;
if (!showBack) {
const values = getFieldValues(more, forwardFields);
const values = getWordFieldValues(word, forwardFields);
if (values.length === 0) {
contentEl.innerHTML = '<div class="training-field" style="opacity:0.4;">Нет данных для отображения</div>';
return;
}
contentEl.innerHTML = values
.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`)
.join('');
} else {
const fwdVals = getFieldValues(more, forwardFields);
const bwdVals = getFieldValues(more, backwardFields);
const fwdVals = getWordFieldValues(word, forwardFields);
const bwdVals = getWordFieldValues(word, backwardFields);
contentEl.innerHTML = `
<div class="training-side">
${fwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')}
${fwdVals.length > 0
? fwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')
: '<div class="training-field" style="opacity:0.4;">—</div>'}
</div>
<div class="training-divider"></div>
<div class="training-side training-side-back">
${bwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')}
${bwdVals.length > 0
? bwdVals.map((v) => `<div class="training-field">${this.escapeHtml(v)}</div>`).join('')
: '<div class="training-field" style="opacity:0.4;">—</div>'}
</div>
`;
}
@@ -157,11 +179,7 @@ export class TrainingPage {
const word = this.words[current];
if (!word) return;
const settings = (this.cardSet as any).settings as CardSetSettings | undefined;
const forwardFields = settings?.forward || 'key';
const backwardFields = settings?.backward || 'value';
this.renderSide(word, forwardFields, backwardFields, true);
this.renderSide(word, this.forwardFields, this.backwardFields, true);
this.renderActions(true);
}
@@ -172,37 +190,16 @@ export class TrainingPage {
if (current === null) return;
const stat = this.cardSet.set[current];
if (stat) {
try {
await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000));
} catch (err) {
console.error('[Training] Ошибка сохранения:', err);
}
}
if (current < this.totalCards - 1) {
// Бесконечная тренировка — всегда переходим к следующей карте
this.renderCard();
} else {
this.finish();
}
}
private finish(): void {
this.container.innerHTML = `
<div class="training-header">
<button id="btnTrainingFinish" class="btn" style="flex:none; padding:0.4rem 0.8rem;">← Назад</button>
</div>
<div class="training-card" style="display:flex;align-items:center;justify-content:center;">
<div style="text-align:center;">
<div style="font-size:3rem;margin-bottom:1rem;">🎉</div>
<h2 style="margin:0;color:var(--text-h);">Тренировка завершена!</h2>
<p style="color:var(--text);">Все ${this.totalCards} карточек пройдены.</p>
</div>
</div>
<div class="training-actions"></div>
`;
document.getElementById('btnTrainingFinish')?.addEventListener('click', () => {
this.callbacks.onFinish();
});
}
private escapeHtml(text: string): string {