feat(admin): add potential fathers retrieval for character management
All checks were successful
Deploy to production / deploy (push) Successful in 2m47s

- Implemented a new method in AdminService to fetch potential fathers for a given character based on existing relationships.
- Updated AdminController to expose this functionality via a new API endpoint.
- Enhanced adminRouter to include the route for retrieving potential fathers.
- Modified frontend components to allow selection of potential fathers during pregnancy and birth management.
- Updated internationalization files to include new translation keys related to father selection.
This commit is contained in:
Torsten Schulz (local)
2026-03-31 08:50:56 +02:00
parent ee11a989a0
commit 9a78bc7c4b
30 changed files with 3907 additions and 45 deletions

View File

@@ -13,6 +13,9 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabCourse from '../models/community/vocab_course.js';
import User from '../models/community/user.js';
import { BISAYA_PHASE3_DIDACTICS } from './bisaya-course-phase3-extension.js';
import { BISAYA_PHASE4_DIDACTICS } from './bisaya-course-phase4-extension.js';
import { BISAYA_PHASE5_DIDACTICS } from './bisaya-course-phase5-extension.js';
function withTypeName(exerciseTypeName, exercise) {
return {
@@ -21,6 +24,356 @@ function withTypeName(exerciseTypeName, exercise) {
};
}
const GENERATED_BISAYA_DIDACTICS = {
...BISAYA_PHASE3_DIDACTICS,
...BISAYA_PHASE4_DIDACTICS,
...BISAYA_PHASE5_DIDACTICS
};
const GENERIC_DISTRACTOR_PATTERNS = Array.from(new Set(
Object.values(GENERATED_BISAYA_DIDACTICS)
.flatMap((entry) => Array.isArray(entry?.corePatterns) ? entry.corePatterns : [])
.map((pattern) => String(pattern || '').trim())
.filter(Boolean)
)).slice(0, 200);
function normalizeText(value) {
return String(value || '')
.trim()
.replace(/\s+/g, ' ');
}
function simpleHash(value) {
return Array.from(String(value || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0);
}
function rotateArray(values, offset) {
if (!Array.isArray(values) || values.length === 0) return [];
const normalizedOffset = ((offset % values.length) + values.length) % values.length;
return values.slice(normalizedOffset).concat(values.slice(0, normalizedOffset));
}
function getLessonDidactics(lesson) {
const staticDidactics = GENERATED_BISAYA_DIDACTICS[lesson.title] || {};
const learningGoals = Array.isArray(lesson.learningGoals) ? lesson.learningGoals : (staticDidactics.learningGoals || []);
const corePatterns = Array.isArray(lesson.corePatterns) ? lesson.corePatterns : (staticDidactics.corePatterns || []);
const grammarFocus = Array.isArray(lesson.grammarFocus) ? lesson.grammarFocus : (staticDidactics.grammarFocus || []);
const speakingPrompts = Array.isArray(lesson.speakingPrompts) ? lesson.speakingPrompts : (staticDidactics.speakingPrompts || []);
const practicalTasks = Array.isArray(lesson.practicalTasks) ? lesson.practicalTasks : (staticDidactics.practicalTasks || []);
return {
learningGoals,
corePatterns: corePatterns.map((entry) => normalizeText(entry)).filter(Boolean),
grammarFocus,
speakingPrompts,
practicalTasks
};
}
function getScenarioPrompt(lesson, didactics) {
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null;
const practicalTask = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks[0] : null;
if (speakingPrompt?.prompt) return speakingPrompt.prompt;
if (practicalTask?.text) return practicalTask.text;
if (lesson.description) return lesson.description;
return `Reagiere passend in einer Situation aus der Lektion "${lesson.title}".`;
}
function getChoiceQuestion(lesson, didactics) {
const scenarioPrompt = getScenarioPrompt(lesson, didactics);
switch (lesson.lessonType) {
case 'conversation':
return `${scenarioPrompt} Welche Bisaya-Formulierung passt am besten?`;
case 'grammar':
return `Welche Formulierung passt als kurze Alltagsstruktur zu "${lesson.title}"?`;
case 'review':
return `Welche Formulierung solltest du aus "${lesson.title}" sicher wiedererkennen?`;
case 'culture':
return `Welcher Ausdruck gehört am ehesten zum kulturellen Schwerpunkt "${lesson.title}"?`;
case 'vocab':
default:
return `Welcher Ausdruck gehört thematisch zu "${lesson.title}"?`;
}
}
function pickDistractors(pattern, allPatterns, count) {
return allPatterns
.filter((entry) => entry !== pattern)
.slice(0, count);
}
function buildChoiceExercise(lesson, didactics, pattern, allPatterns, variant = 0) {
const distractors = pickDistractors(pattern, allPatterns, 3);
if (distractors.length < 3) return null;
const seed = simpleHash(`${lesson.title}:${pattern}:${variant}`);
const options = rotateArray([pattern, ...distractors], seed % 4);
const correctAnswer = options.indexOf(pattern);
return {
exerciseTypeId: 2,
title: `${lesson.title}: Passende Formulierung wählen`,
instruction: 'Wähle die natürlichste Formulierung für die Situation oder den Schwerpunkt der Lektion.',
questionData: {
type: 'multiple_choice',
question: getChoiceQuestion(lesson, didactics),
options
},
answerData: {
type: 'multiple_choice',
correctAnswer
},
explanation: `"${pattern}" ist ein zentrales Muster dieser Lektion.`
};
}
function pickGapTarget(pattern) {
const tokens = normalizeText(pattern)
.split(' ')
.map((token) => token.trim())
.filter(Boolean);
const candidates = tokens
.map((token, index) => ({ token, index, score: token.replace(/[.,?!]/g, '').length }))
.filter(({ token }) => token.replace(/[.,?!]/g, '').length >= 4);
if (candidates.length === 0) {
return null;
}
candidates.sort((left, right) => right.score - left.score);
return candidates[0];
}
function buildGapExercise(lessonTitle, pattern) {
const gapTarget = pickGapTarget(pattern);
if (!gapTarget) return null;
const tokens = normalizeText(pattern).split(' ');
tokens[gapTarget.index] = '{gap}';
return {
exerciseTypeId: 1,
title: `${lessonTitle}: Muster vervollständigen`,
instruction: 'Fülle die Lücke mit dem passenden Bisaya-Ausdruck.',
questionData: {
type: 'gap_fill',
text: tokens.join(' '),
gaps: 1
},
answerData: {
type: 'gap_fill',
answers: [gapTarget.token.replace(/[.,?!]/g, '')]
},
explanation: `Das Kernmuster lautet: "${pattern}".`
};
}
function buildContextGapExercise(lesson, didactics, pattern) {
const gapExercise = buildGapExercise(lesson.title, pattern);
if (!gapExercise) return null;
return {
...gapExercise,
title: `${lesson.title}: Kernmuster ergänzen`,
instruction: `Vervollständige die Formulierung passend zur Situation: ${getScenarioPrompt(lesson, didactics)}`
};
}
function buildSentenceExercise(lessonTitle, pattern) {
const tokens = normalizeText(pattern)
.replace(/[?!]/g, '')
.split(' ')
.filter(Boolean);
if (tokens.length < 2) return null;
return {
exerciseTypeId: 3,
title: `${lessonTitle}: Satzmuster bauen`,
instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.',
questionData: {
type: 'sentence_building',
question: `Baue das Kernmuster aus der Lektion "${lessonTitle}".`,
tokens
},
answerData: {
correct: [normalizeText(pattern)]
},
explanation: `Dieses Kernmuster gehört zur Lektion "${lessonTitle}".`
};
}
function buildTaskSentenceExercise(lesson, didactics, pattern) {
const sentenceExercise = buildSentenceExercise(lesson.title, pattern);
if (!sentenceExercise) return null;
const practicalTask = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks[0] : null;
return {
...sentenceExercise,
title: `${lesson.title}: Satz aus dem Alltag bauen`,
instruction: practicalTask?.text
? `Baue die passende Formulierung für diese Aufgabe: ${practicalTask.text}`
: 'Ordne die Wörter zu einem natürlichen Bisaya-Satz aus dem Alltag.'
};
}
function buildSpeakingExercise(lessonTitle, didactics, fallbackPattern) {
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null;
const expectedText = normalizeText(speakingPrompt?.cue || fallbackPattern);
if (!expectedText) return null;
const keywords = expectedText
.toLowerCase()
.replace(/[.,?!]/g, '')
.split(' ')
.filter((token) => token.length >= 4)
.slice(0, 4);
return {
exerciseTypeId: 8,
title: `${lessonTitle}: Laut sprechen`,
instruction: 'Sprich das zentrale Muster oder den Dialog frei nach.',
questionData: {
type: 'speaking_from_memory',
question: speakingPrompt?.prompt || `Sprich ein zentrales Muster aus der Lektion "${lessonTitle}".`,
expectedText,
keywords
},
answerData: {
type: 'speaking_from_memory'
},
explanation: 'Wichtig sind hier ein flüssiger Abruf und die zentralen Schlüsselwörter.'
};
}
function buildReviewChoiceExercise(lesson, didactics, pattern, allPatterns) {
const choiceExercise = buildChoiceExercise(lesson, didactics, pattern, allPatterns, 1);
if (!choiceExercise) return null;
return {
...choiceExercise,
title: `${lesson.title}: Sicheren Abruf prüfen`,
instruction: 'Wähle die Formulierung, die du aus dem aktiven Wiederholungsblock sicher können solltest.'
};
}
function buildCultureExercise(lesson, didactics, pattern, allPatterns) {
const distractors = pickDistractors(pattern, allPatterns, 3);
if (distractors.length < 3) return null;
const culturalNote = normalizeText(lesson.culturalNotes || '');
const options = rotateArray([pattern, ...distractors], simpleHash(`${lesson.title}:culture`) % 4);
return {
exerciseTypeId: 2,
title: `${lesson.title}: Ausdruck kulturell einordnen`,
instruction: 'Ordne den Ausdruck dem kulturellen Schwerpunkt der Lektion zu.',
questionData: {
type: 'multiple_choice',
question: culturalNote
? `${culturalNote} Welcher Ausdruck passt dazu besonders gut?`
: `Welcher Ausdruck gehört besonders gut zum Schwerpunkt "${lesson.title}"?`,
options
},
answerData: {
type: 'multiple_choice',
correctAnswer: options.indexOf(pattern)
},
explanation: `Der Ausdruck "${pattern}" gehört eng zum kulturellen Schwerpunkt dieser Lektion.`
};
}
function buildSituationalExercise(lessonTitle, didactics, fallbackPattern) {
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null;
const modelAnswer = normalizeText(speakingPrompt?.cue || fallbackPattern);
if (!modelAnswer) return null;
const keywords = modelAnswer
.toLowerCase()
.replace(/[.,?!]/g, '')
.split(' ')
.filter((token) => token.length >= 4)
.slice(0, 4);
return withTypeName('situational_response', {
title: `${lessonTitle}: Situativ reagieren`,
instruction: 'Antworte kurz und passend auf die Situation.',
questionData: {
type: 'situational_response',
question: speakingPrompt?.prompt || `Reagiere passend mit einem Ausdruck aus der Lektion "${lessonTitle}".`,
keywords
},
answerData: {
modelAnswer,
keywords
},
explanation: `Das Kernmuster "${modelAnswer}" passt natürlich zu dieser Situation.`
});
}
function generateExercisesFromDidactics(lesson) {
const didactics = getLessonDidactics(lesson);
const corePatterns = didactics.corePatterns;
if (corePatterns.length === 0) {
return [];
}
const patternA = corePatterns[0];
const patternB = corePatterns[1] || corePatterns[0];
const lessonPool = Array.from(new Set([
...corePatterns,
...GENERIC_DISTRACTOR_PATTERNS
]));
let generated = [];
if (lesson.lessonType === 'conversation') {
generated = [
buildChoiceExercise(lesson, didactics, patternA, lessonPool, 0),
buildContextGapExercise(lesson, didactics, patternA),
buildTaskSentenceExercise(lesson, didactics, patternB),
buildSituationalExercise(lesson.title, didactics, patternA),
buildSpeakingExercise(lesson.title, didactics, patternB)
];
} else if (lesson.lessonType === 'grammar') {
generated = [
buildChoiceExercise(lesson, didactics, patternA, lessonPool, 0),
buildChoiceExercise(lesson, didactics, patternB, lessonPool, 1),
buildContextGapExercise(lesson, didactics, patternA),
buildTaskSentenceExercise(lesson, didactics, patternB),
buildSpeakingExercise(lesson.title, didactics, patternA)
];
} else if (lesson.lessonType === 'review' || lesson.didacticMode === 'intensive_review') {
generated = [
buildReviewChoiceExercise(lesson, didactics, patternA, lessonPool),
buildReviewChoiceExercise(lesson, didactics, patternB, lessonPool),
buildContextGapExercise(lesson, didactics, patternA),
buildTaskSentenceExercise(lesson, didactics, patternB),
buildSituationalExercise(lesson.title, didactics, patternA),
buildSpeakingExercise(lesson.title, didactics, patternB)
];
} else if (lesson.lessonType === 'culture') {
generated = [
buildCultureExercise(lesson, didactics, patternA, lessonPool),
buildContextGapExercise(lesson, didactics, patternA),
buildSpeakingExercise(lesson.title, didactics, patternB)
];
} else {
generated = [
buildChoiceExercise(lesson, didactics, patternA, lessonPool, 0),
buildChoiceExercise(lesson, didactics, patternB, lessonPool, 1),
buildContextGapExercise(lesson, didactics, patternA),
buildTaskSentenceExercise(lesson, didactics, patternB)
];
}
return generated.filter(Boolean);
}
// Bisaya-spezifische Übungen basierend auf Lektionsthemen
const BISAYA_EXERCISES = {
// Lektion 1: Begrüßungen & Höflichkeit
@@ -1489,7 +1842,8 @@ async function findOrCreateSystemUser() {
return systemUser;
}
function getExercisesForLesson(lessonTitle) {
function getExercisesForLesson(lesson) {
const lessonTitle = lesson.title;
// Suche nach exaktem Titel
if (BISAYA_EXERCISES[lessonTitle]) {
return BISAYA_EXERCISES[lessonTitle];
@@ -1501,9 +1855,8 @@ function getExercisesForLesson(lessonTitle) {
return exercises;
}
}
// Keine Übungen für unbekannte Lektionen (statt Dummy-Übungen)
return [];
return generateExercisesFromDidactics(lesson);
}
async function createBisayaCourseContent() {
@@ -1550,7 +1903,7 @@ async function createBisayaCourseContent() {
console.log(` ${lessons.length} Lektionen gefunden\n`);
for (const lesson of lessons) {
const exercises = getExercisesForLesson(lesson.title);
const exercises = getExercisesForLesson(lesson);
if (exercises.length === 0) {
const existingCount = await VocabGrammarExercise.count({ where: { lessonId: lesson.id } });
if (existingCount > 0) {