393 lines
14 KiB
JavaScript
393 lines
14 KiB
JavaScript
// Code-Kumpel Learn: flashcards, spaced repetition, style, TF-IDF recall, trust
|
||
(function () {
|
||
const DAY = 864e5;
|
||
const BUG_TAGS = ['bug', 'error', 'fehler', 'fix', 'exception', 'crash', 'debug'];
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
}
|
||
|
||
function tokens(s) {
|
||
return (String(s).toLowerCase().match(/[a-zäöüß0-9#+.]{2,}/g) || []).filter((w) => w.length >= 2);
|
||
}
|
||
|
||
/** Ensure memory has SR + trust fields (idempotent). */
|
||
function ensureMemory(m) {
|
||
if (!m) return m;
|
||
if (m.nextReview == null) m.nextReview = m.created || Date.now();
|
||
if (m.interval == null) m.interval = 0;
|
||
if (m.ease == null) m.ease = 2.5;
|
||
if (m.wrongCount == null) m.wrongCount = 0;
|
||
if (m.trust !== 'sicher' && m.trust !== 'unsicher' && m.trust !== 'auto') m.trust = 'auto';
|
||
return m;
|
||
}
|
||
|
||
function ensureAll(S) {
|
||
if (!S || !S.memories) return;
|
||
S.memories.forEach(ensureMemory);
|
||
if (!S.learn) S.learn = { lastWrong: {}, styleCache: null };
|
||
if (!S.trash) S.trash = [];
|
||
}
|
||
|
||
/** Derive trust label from strength or manual override. */
|
||
function trustOf(m) {
|
||
ensureMemory(m);
|
||
if (m.trust === 'sicher' || m.trust === 'unsicher') return m.trust;
|
||
return (m.strength || 0) >= 2.5 ? 'sicher' : 'unsicher';
|
||
}
|
||
|
||
function setTrust(m, trust) {
|
||
ensureMemory(m);
|
||
m.trust = trust === 'sicher' || trust === 'unsicher' ? trust : 'auto';
|
||
}
|
||
|
||
function markUncertain(m) {
|
||
setTrust(m, 'unsicher');
|
||
m.strength = Math.min(m.strength || 1, 1);
|
||
m.nextReview = Date.now();
|
||
}
|
||
|
||
/**
|
||
* SM-2 lite: quality 0=wrong, 1=half, 2=correct
|
||
*/
|
||
function scheduleReview(m, quality) {
|
||
ensureMemory(m);
|
||
const q = Math.max(0, Math.min(2, quality | 0));
|
||
if (q === 0) {
|
||
m.wrongCount = (m.wrongCount || 0) + 1;
|
||
m.interval = 0;
|
||
m.nextReview = Date.now() + DAY * 0.25;
|
||
m.ease = Math.max(1.3, (m.ease || 2.5) - 0.2);
|
||
m.strength = Math.max(0.2, (m.strength || 1) - 0.5);
|
||
} else if (q === 1) {
|
||
m.interval = Math.max(1, Math.round((m.interval || 1) * 1.2));
|
||
m.nextReview = Date.now() + m.interval * DAY;
|
||
m.strength = (m.strength || 1) + 0.5;
|
||
} else {
|
||
if (!m.interval) m.interval = 1;
|
||
else if (m.interval === 1) m.interval = 3;
|
||
else m.interval = Math.round(m.interval * (m.ease || 2.5));
|
||
m.ease = Math.min(2.8, (m.ease || 2.5) + 0.05);
|
||
m.nextReview = Date.now() + m.interval * DAY;
|
||
m.strength = (m.strength || 1) + 1;
|
||
}
|
||
return m;
|
||
}
|
||
|
||
function dueMemories(S) {
|
||
ensureAll(S);
|
||
const now = Date.now();
|
||
return S.memories
|
||
.filter((m) => (m.nextReview || 0) <= now)
|
||
.sort((a, b) => (a.nextReview || 0) - (b.nextReview || 0) || (a.strength || 0) - (b.strength || 0));
|
||
}
|
||
|
||
/** Pick next flashcard: due first, else weakest. */
|
||
function pickCard(S) {
|
||
ensureAll(S);
|
||
if (!S.memories.length) return null;
|
||
const due = dueMemories(S);
|
||
if (due.length) return due[0];
|
||
return [...S.memories].sort((a, b) => (a.strength || 0) - (b.strength || 0))[0];
|
||
}
|
||
|
||
function groupByTopic(memories) {
|
||
const groups = {};
|
||
(memories || []).forEach((m) => {
|
||
const tags = m.tags && m.tags.length ? m.tags : ['sonstiges'];
|
||
const key = tags[0] || 'sonstiges';
|
||
(groups[key] ||= []).push(m);
|
||
});
|
||
return Object.entries(groups).sort((a, b) => b[1].length - a[1].length);
|
||
}
|
||
|
||
// ---- TF-IDF recall improvement (#19)
|
||
function buildIdf(memories) {
|
||
const df = {};
|
||
const N = Math.max(1, memories.length);
|
||
memories.forEach((m) => {
|
||
const seen = new Set(tokens(m.title + ' ' + m.body + ' ' + (m.tags || []).join(' ')));
|
||
seen.forEach((w) => {
|
||
df[w] = (df[w] || 0) + 1;
|
||
});
|
||
});
|
||
const idf = {};
|
||
Object.keys(df).forEach((w) => {
|
||
idf[w] = Math.log(1 + N / (1 + df[w]));
|
||
});
|
||
return idf;
|
||
}
|
||
|
||
function improvedRecall(q, memories, n, detectTagsFn) {
|
||
const list = memories || [];
|
||
const idf = buildIdf(list);
|
||
const qt = tokens(q);
|
||
const qtf = {};
|
||
qt.forEach((w) => {
|
||
qtf[w] = (qtf[w] || 0) + 1;
|
||
});
|
||
let qtags = [];
|
||
try {
|
||
if (typeof detectTagsFn === 'function') qtags = Object.keys(detectTagsFn(q) || {});
|
||
} catch (e) {}
|
||
const scored = list.map((m) => {
|
||
ensureMemory(m);
|
||
const mt = tokens(m.title + ' ' + m.body + ' ' + (m.tags || []).join(' '));
|
||
const mtf = {};
|
||
mt.forEach((w) => {
|
||
mtf[w] = (mtf[w] || 0) + 1;
|
||
});
|
||
let sc = 0;
|
||
Object.keys(qtf).forEach((w) => {
|
||
if (mtf[w]) sc += qtf[w] * mtf[w] * (idf[w] || 1);
|
||
});
|
||
qtags.forEach((t) => {
|
||
if ((m.tags || []).includes(t)) sc += 4;
|
||
});
|
||
// slight boost for secure/strong memories
|
||
const trustBoost = trustOf(m) === 'sicher' ? 1.15 : 1;
|
||
return { m, sc: sc * trustBoost * (1 + (m.strength || 1) * 0.15) };
|
||
});
|
||
return scored
|
||
.filter((x) => x.sc > 0)
|
||
.sort((a, b) => b.sc - a.sc)
|
||
.slice(0, n || 3)
|
||
.map((x) => x.m);
|
||
}
|
||
|
||
// ---- Coding style (#14)
|
||
function styleProfile(S) {
|
||
ensureAll(S);
|
||
let camel = 0,
|
||
snake = 0,
|
||
pascal = 0,
|
||
kebab = 0;
|
||
const frameworks = {};
|
||
const FW = ['react', 'vue', 'svelte', 'nextjs', 'angular', 'node', 'python', 'typescript', 'tailwind', 'docker', 'firebase', 'unity', 'godot'];
|
||
(S.memories || []).forEach((m) => {
|
||
const text = (m.title || '') + '\n' + (m.body || '');
|
||
const ids = text.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) || [];
|
||
ids.forEach((id) => {
|
||
if (/^[a-z]+([A-Z][a-z0-9]+)+$/.test(id)) camel++;
|
||
else if (/^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(id)) snake++;
|
||
else if (/^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)+$/.test(id)) pascal++;
|
||
else if (/^[a-z]+(-[a-z0-9]+)+$/.test(id)) kebab++;
|
||
});
|
||
(m.tags || []).forEach((t) => {
|
||
if (FW.includes(t)) frameworks[t] = (frameworks[t] || 0) + (m.strength || 1);
|
||
});
|
||
});
|
||
const naming = [
|
||
['camelCase', camel],
|
||
['snake_case', snake],
|
||
['PascalCase', pascal],
|
||
['kebab-case', kebab]
|
||
].sort((a, b) => b[1] - a[1]);
|
||
const topFw = Object.entries(frameworks)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5);
|
||
const preferred = naming[0] && naming[0][1] > 0 ? naming[0][0] : 'unbekannt';
|
||
return { naming, preferred, frameworks: topFw, samples: camel + snake + pascal + kebab };
|
||
}
|
||
|
||
// ---- Bug pattern warnings (#15)
|
||
function bugWarnings(S) {
|
||
ensureAll(S);
|
||
const warnings = [];
|
||
const byTag = {};
|
||
let wrongTotal = 0;
|
||
(S.memories || []).forEach((m) => {
|
||
wrongTotal += m.wrongCount || 0;
|
||
(m.tags || []).forEach((t) => {
|
||
if (BUG_TAGS.includes(String(t).toLowerCase())) byTag[t] = (byTag[t] || 0) + 1;
|
||
});
|
||
if ((m.wrongCount || 0) >= 2) {
|
||
warnings.push({
|
||
level: 'warn',
|
||
text: `„${m.title}“ wurde ${m.wrongCount}× als falsch bewertet – lohnt eine Korrektur.`
|
||
});
|
||
}
|
||
});
|
||
const bugHits = Object.values(byTag).reduce((a, b) => a + b, 0);
|
||
if (bugHits >= 3) {
|
||
warnings.unshift({
|
||
level: 'warn',
|
||
text: `Häufige Bug/Error-Themen (${bugHits} Erinnerungen). Schau die unsicheren Karten an.`
|
||
});
|
||
}
|
||
if (wrongTotal >= 5) {
|
||
warnings.unshift({
|
||
level: 'info',
|
||
text: `Insgesamt ${wrongTotal} Fehlbewertungen beim Üben – Spaced Repetition priorisiert diese Karten.`
|
||
});
|
||
}
|
||
return warnings.slice(0, 6);
|
||
}
|
||
|
||
// ---- Week summary (#13)
|
||
function weekSummary(S) {
|
||
ensureAll(S);
|
||
const weekAgo = Date.now() - 7 * DAY;
|
||
const recent = (S.memories || []).filter((m) => (m.created || 0) >= weekAgo);
|
||
const tagCnt = {};
|
||
recent.forEach((m) =>
|
||
(m.tags || []).forEach((t) => {
|
||
tagCnt[t] = (tagCnt[t] || 0) + 1;
|
||
})
|
||
);
|
||
const topTags = Object.entries(tagCnt)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 8);
|
||
const wrong = recent.filter((m) => (m.wrongCount || 0) > 0);
|
||
const byDay = {};
|
||
for (let i = 6; i >= 0; i--) {
|
||
const d = new Date(Date.now() - i * DAY);
|
||
const key = d.toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' });
|
||
byDay[key] = { memories: 0, xp: 0 };
|
||
}
|
||
recent.forEach((m) => {
|
||
const key = new Date(m.created).toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' });
|
||
if (byDay[key]) byDay[key].memories++;
|
||
});
|
||
// XP from log if present
|
||
(S.log || []).forEach((e) => {
|
||
if (!e || !e.at) return;
|
||
if (e.at < weekAgo) return;
|
||
const key = new Date(e.at).toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' });
|
||
if (byDay[key] && e.xp) byDay[key].xp += e.xp;
|
||
});
|
||
// approximate from quests xpToday only for today
|
||
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' });
|
||
if (S.quests && S.quests.day === today && byDay[today]) {
|
||
byDay[today].xp = Math.max(byDay[today].xp, S.quests.xpToday || 0);
|
||
}
|
||
return {
|
||
count: recent.length,
|
||
topTags,
|
||
wrongCount: wrong.length,
|
||
days: Object.entries(byDay).map(([day, v]) => ({ day, ...v })),
|
||
insecure: (S.memories || []).filter((m) => trustOf(m) === 'unsicher').length
|
||
};
|
||
}
|
||
|
||
function summarizeBody(m) {
|
||
const b = String(m.body || '')
|
||
.replace(/```[\s\S]*?```/g, '[Code]')
|
||
.trim();
|
||
const first = b.split(/(?<=[.!?])\s|\n/)[0] || b;
|
||
return first.slice(0, 160);
|
||
}
|
||
|
||
/** Flashcard HTML for practice tab (#11). */
|
||
function renderCard(m, buddyName) {
|
||
if (!m) return '<p class="hint">Bring mir erst was bei.</p>';
|
||
ensureMemory(m);
|
||
const due = (m.nextReview || 0) <= Date.now();
|
||
const tr = trustOf(m);
|
||
return `<div class="mem learn-card" data-mid="${esc(m.id)}">
|
||
<div class="learn-meta"><span class="tag trust-${tr}">${tr === 'sicher' ? 'sicher' : 'unsicher'}</span>
|
||
${due ? '<span class="tag due">fällig</span>' : ''}
|
||
<span class="meta">Stärke ${(m.strength || 0).toFixed(1)} · Intervall ${m.interval || 0}d</span></div>
|
||
<b>Frage:</b> Was weißt du über „${esc(m.title)}“?
|
||
<p><b>${esc(buddyName || 'Byte')}:</b> Ich glaube: ${esc(summarizeBody(m))}${m.tags && m.tags.length ? ' Das gehört zu ' + esc(m.tags.join(', ')) + '.' : ''}</p>
|
||
<div class="acts">
|
||
<button data-r="2">Richtig</button>
|
||
<button class="ghost" data-r="1">Halb richtig</button>
|
||
<button class="danger" data-r="0">Falsch</button>
|
||
<button class="ghost" data-uncertain="${esc(m.id)}">Als unsicher markieren</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderInsights(S, el) {
|
||
if (!el) return;
|
||
const week = weekSummary(S);
|
||
const style = styleProfile(S);
|
||
const warns = bugWarnings(S);
|
||
const fw = style.frameworks.map(([k, v]) => `${k} (${v.toFixed(1)})`).join(', ') || 'noch keine';
|
||
el.innerHTML = `
|
||
<div class="insight-block">
|
||
<h3>Wochen-Rückblick</h3>
|
||
<p class="hint">${week.count} neue Erinnerungen · ${week.insecure} unsicher · ${week.wrongCount} mit Fehlern beim Üben</p>
|
||
<p>${week.topTags.length ? 'Top-Themen: ' + week.topTags.map(([t, c]) => `<span class="tag">${esc(t)} ×${c}</span>`).join(' ') : 'Noch keine Themen diese Woche.'}</p>
|
||
</div>
|
||
<div class="insight-block">
|
||
<h3>Dein Programmierstil</h3>
|
||
<p class="hint">Bevorzugtes Naming: <b>${esc(style.preferred)}</b> (${style.samples} Identifier erkannt)</p>
|
||
<p class="hint">Frameworks: ${esc(fw)}</p>
|
||
</div>
|
||
<div class="insight-block">
|
||
<h3>Hinweise</h3>
|
||
${warns.length ? warns.map((w) => `<p class="warn-line ${w.level}">⚠ ${esc(w.text)}</p>`).join('') : '<p class="hint">Keine Muster-Warnungen.</p>'}
|
||
</div>`;
|
||
}
|
||
|
||
function renderGroupedMemory(list, opts) {
|
||
opts = opts || {};
|
||
const groups = groupByTopic(list);
|
||
if (!groups.length) return '<p class="hint">Noch leer.</p>';
|
||
return groups
|
||
.map(([topic, mems]) => {
|
||
const items = mems
|
||
.map((m) => {
|
||
ensureMemory(m);
|
||
const tr = trustOf(m);
|
||
return `<div class="mem" data-mid="${esc(m.id)}">
|
||
<b>${esc(m.title)}</b>
|
||
<span class="meta">${esc(m.type)} · ${esc(m.source)} · Stärke ${(m.strength || 0).toFixed(1)}</span>
|
||
<span class="tag trust-${tr}">${tr}</span>
|
||
<br>${(m.tags || []).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}
|
||
<pre>${esc(m.body)}</pre>
|
||
<div class="acts">
|
||
<button class="ghost" data-edit="${esc(m.id)}">Korrigieren</button>
|
||
<button class="ghost" data-uncertain="${esc(m.id)}">Als unsicher</button>
|
||
<button class="ghost" data-secure="${esc(m.id)}">Als sicher</button>
|
||
<button class="danger" data-del="${esc(m.id)}">Vergessen</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join('');
|
||
return `<details class="mem-group" open><summary><b>${esc(topic)}</b> <span class="meta">(${mems.length})</span></summary>${items}</details>`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function filterMemories(S, { q, type, tag, trust }) {
|
||
ensureAll(S);
|
||
let list = S.memories.slice();
|
||
const qq = (q || '').toLowerCase().trim();
|
||
if (qq) list = list.filter((m) => (m.title + m.body + (m.tags || []).join(' ')).toLowerCase().includes(qq));
|
||
if (type && type !== 'all') list = list.filter((m) => m.type === type);
|
||
if (tag && tag !== 'all') list = list.filter((m) => (m.tags || []).includes(tag));
|
||
if (trust && trust !== 'all') list = list.filter((m) => trustOf(m) === trust);
|
||
return list;
|
||
}
|
||
|
||
function allTags(S) {
|
||
const s = new Set();
|
||
(S.memories || []).forEach((m) => (m.tags || []).forEach((t) => s.add(t)));
|
||
return [...s].sort();
|
||
}
|
||
|
||
window.CodeKumpelLearn = {
|
||
ensureMemory,
|
||
ensureAll,
|
||
trustOf,
|
||
setTrust,
|
||
markUncertain,
|
||
scheduleReview,
|
||
dueMemories,
|
||
pickCard,
|
||
groupByTopic,
|
||
improvedRecall,
|
||
styleProfile,
|
||
bugWarnings,
|
||
weekSummary,
|
||
renderCard,
|
||
renderInsights,
|
||
renderGroupedMemory,
|
||
filterMemories,
|
||
allTags,
|
||
summarizeBody
|
||
};
|
||
})();
|