code-kumpel/learn.js
2026-10-24 04:51:23 +02:00

393 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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
};
})();