first commit

This commit is contained in:
2026-03-23 20:11:18 +01:00
commit 4c2df27527
13 changed files with 2049 additions and 0 deletions

170
src/markov.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* Markov chain for generating sentences from learned text.
* Uses trigrams (word triplets) for better grammatical coherence.
*/
interface ChainData {
// Map of "word1 word2" -> possible next words with frequency
transitions: Map<string, Map<string, number>>;
// All sentence start patterns
starts: Map<string, number>;
}
export class MarkovChain {
private order: number;
private chain: ChainData;
constructor(order: number = 2) {
this.order = order;
this.chain = {
transitions: new Map(),
starts: new Map(),
};
}
/**
* Tokenize text into words, preserving sentence boundaries
*/
private tokenize(text: string): string[][] {
const sentences: string[][] = [];
// Split into sentences (rough but works for most cases)
const sentencePatterns = text.split(/[.!?]+/);
for (const sentence of sentencePatterns) {
const words = sentence
.trim()
.toLowerCase()
.replace(/[^\wäöüß\s]/g, '') // Keep German characters
.split(/\s+/)
.filter(w => w.length > 0);
if (words.length > 0) {
sentences.push(words);
}
}
return sentences;
}
/**
* Learn from a text, adding it to the chain
*/
learn(text: string): void {
const sentences = this.tokenize(text);
for (const words of sentences) {
if (words.length < this.order + 1) continue;
// Mark sentence start
const startKey = words.slice(0, this.order).join(' ');
this.chain.starts.set(startKey, (this.chain.starts.get(startKey) || 0) + 1);
// Build transitions
for (let i = 0; i < words.length - this.order; i++) {
const key = words.slice(i, i + this.order).join(' ');
const next = words[i + this.order];
if (!this.chain.transitions.has(key)) {
this.chain.transitions.set(key, new Map());
}
const transitions = this.chain.transitions.get(key)!;
transitions.set(next, (transitions.get(next) || 0) + 1);
}
}
}
/**
* Pick a weighted random item from a map of {item: weight}
*/
private weightedRandom<T>(items: Map<T, number>): T | null {
const entries = Array.from(items.entries());
if (entries.length === 0) return null;
const total = entries.reduce((sum, [, w]) => sum + w, 0);
let random = Math.random() * total;
for (const [item, weight] of entries) {
random -= weight;
if (random <= 0) return item;
}
return entries[0]![0];
}
/**
* Generate a sentence from the learned chain
*/
generate(maxWords: number = 20): string {
if (this.chain.starts.size === 0) {
return 'Ich brauche erst mehr Nachrichten zum Lernen.';
}
// Pick random start
const startKey = this.weightedRandom(this.chain.starts);
if (!startKey) return '...';
const words: string[] = startKey.split(' ');
let currentKey = startKey;
while (words.length < maxWords) {
const transitions = this.chain.transitions.get(currentKey);
if (!transitions || transitions.size === 0) break;
const next = this.weightedRandom(transitions);
if (!next) break;
words.push(next);
// Update key (slide window)
const keyWords = words.slice(-this.order);
currentKey = keyWords.join(' ');
}
// Capitalize first letter
const result = words.join(' ');
return result.charAt(0).toUpperCase() + result.slice(1);
}
/**
* Check if chain has learned anything
*/
hasLearned(): boolean {
return this.chain.starts.size > 0;
}
/**
* Add a single transition (for loading from DB)
*/
addTransition(key: string, nextWord: string, count: number): void {
if (!this.chain.transitions.has(key)) {
this.chain.transitions.set(key, new Map());
}
this.chain.transitions.get(key)!.set(nextWord, count);
}
/**
* Add a single start (for loading from DB)
*/
addStart(key: string, count: number): void {
this.chain.starts.set(key, count);
}
/**
* Export chain data for persistence
*/
export(): { transitions: Record<string, Record<string, number>>; starts: Record<string, number> } {
const transitions: Record<string, Record<string, number>> = {};
for (const [key, words] of this.chain.transitions) {
transitions[key] = Object.fromEntries(words);
}
return {
transitions,
starts: Object.fromEntries(this.chain.starts),
};
}
}