update
This commit is contained in:
948
controllers/grammarController.js
Normal file
948
controllers/grammarController.js
Normal file
@@ -0,0 +1,948 @@
|
||||
const { Grammar, GrammarMapping, GrammarMediaStory, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* CREATE: Add new grammar rule with mappings and media stories
|
||||
*/
|
||||
exports.createGrammar = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const {
|
||||
grammar_code,
|
||||
title,
|
||||
translation,
|
||||
structure,
|
||||
instructions,
|
||||
difficulty_score,
|
||||
category,
|
||||
tags,
|
||||
mappings = [],
|
||||
media_stories = []
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!grammar_code || !title || !structure) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: grammar_code, title, structure'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate structure has formula
|
||||
if (!structure.formula) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'structure.formula is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if grammar_code already exists
|
||||
const existing = await Grammar.findOne({ where: { grammar_code } });
|
||||
if (existing) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Grammar code "${grammar_code}" already exists`
|
||||
});
|
||||
}
|
||||
|
||||
// Create main grammar entry
|
||||
const grammar = await Grammar.create({
|
||||
grammar_code,
|
||||
title,
|
||||
translation,
|
||||
structure,
|
||||
instructions,
|
||||
difficulty_score,
|
||||
category,
|
||||
tags
|
||||
}, { transaction });
|
||||
|
||||
// Create mappings if provided
|
||||
if (mappings.length > 0) {
|
||||
const mappingData = mappings.map(m => ({
|
||||
grammar_id: grammar.id,
|
||||
book_id: m.book_id,
|
||||
grade: m.grade,
|
||||
unit: m.unit,
|
||||
lesson: m.lesson,
|
||||
context_note: m.context_note
|
||||
}));
|
||||
await GrammarMapping.bulkCreate(mappingData, { transaction });
|
||||
}
|
||||
|
||||
// Create media stories if provided
|
||||
if (media_stories.length > 0) {
|
||||
const storyData = media_stories.map(s => ({
|
||||
grammar_id: grammar.id,
|
||||
story_id: s.story_id,
|
||||
title: s.title,
|
||||
type: s.type || 'story',
|
||||
url: s.url,
|
||||
thumbnail: s.thumbnail,
|
||||
description: s.description,
|
||||
duration_seconds: s.duration_seconds,
|
||||
min_grade: s.min_grade
|
||||
}));
|
||||
await GrammarMediaStory.bulkCreate(storyData, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fetch complete grammar with associations
|
||||
const result = await Grammar.findByPk(grammar.id, {
|
||||
include: [
|
||||
{ model: GrammarMapping, as: 'mappings' },
|
||||
{ model: GrammarMediaStory, as: 'mediaStories' }
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Grammar rule created successfully',
|
||||
data: result
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error creating grammar:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create grammar rule',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get all grammars with pagination and filters
|
||||
*/
|
||||
exports.getAllGrammars = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
category,
|
||||
grade,
|
||||
book_id,
|
||||
difficulty_min,
|
||||
difficulty_max,
|
||||
search,
|
||||
include_media = 'false'
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const where = { is_active: true };
|
||||
|
||||
// Apply filters
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (difficulty_min || difficulty_max) {
|
||||
where.difficulty_score = {};
|
||||
if (difficulty_min) where.difficulty_score[Op.gte] = parseInt(difficulty_min);
|
||||
if (difficulty_max) where.difficulty_score[Op.lte] = parseInt(difficulty_max);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ title: { [Op.like]: `%${search}%` } },
|
||||
{ translation: { [Op.like]: `%${search}%` } },
|
||||
{ grammar_code: { [Op.like]: `%${search}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
// Build include array
|
||||
const include = [
|
||||
{
|
||||
model: GrammarMapping,
|
||||
as: 'mappings',
|
||||
required: false,
|
||||
where: {}
|
||||
}
|
||||
];
|
||||
|
||||
// Add grade/book_id filter to mappings
|
||||
if (grade) {
|
||||
include[0].where.grade = parseInt(grade);
|
||||
include[0].required = true;
|
||||
}
|
||||
if (book_id) {
|
||||
include[0].where.book_id = book_id;
|
||||
include[0].required = true;
|
||||
}
|
||||
|
||||
// Include media stories if requested
|
||||
if (include_media === 'true') {
|
||||
include.push({
|
||||
model: GrammarMediaStory,
|
||||
as: 'mediaStories',
|
||||
required: false
|
||||
});
|
||||
}
|
||||
|
||||
const { count, rows } = await Grammar.findAndCountAll({
|
||||
where,
|
||||
include,
|
||||
limit: parseInt(limit),
|
||||
offset,
|
||||
order: [['difficulty_score', 'ASC'], ['createdAt', 'DESC']],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching grammars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch grammars',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get grammar by ID or grammar_code
|
||||
*/
|
||||
exports.getGrammarById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Determine if id is numeric or string (grammar_code)
|
||||
const where = isNaN(id)
|
||||
? { grammar_code: id, is_active: true }
|
||||
: { id: parseInt(id), is_active: true };
|
||||
|
||||
const grammar = await Grammar.findOne({
|
||||
where,
|
||||
include: [
|
||||
{ model: GrammarMapping, as: 'mappings' },
|
||||
{ model: GrammarMediaStory, as: 'mediaStories' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!grammar) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Grammar rule not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: grammar
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching grammar:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch grammar rule',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UPDATE: Update grammar rule
|
||||
*/
|
||||
exports.updateGrammar = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
title,
|
||||
translation,
|
||||
structure,
|
||||
instructions,
|
||||
difficulty_score,
|
||||
category,
|
||||
tags,
|
||||
mappings,
|
||||
media_stories
|
||||
} = req.body;
|
||||
|
||||
// Find grammar
|
||||
const where = isNaN(id)
|
||||
? { grammar_code: id, is_active: true }
|
||||
: { id: parseInt(id), is_active: true };
|
||||
|
||||
const grammar = await Grammar.findOne({ where });
|
||||
|
||||
if (!grammar) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Grammar rule not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Update main grammar fields
|
||||
const updateData = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (translation !== undefined) updateData.translation = translation;
|
||||
if (structure !== undefined) {
|
||||
if (!structure.formula) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'structure.formula is required'
|
||||
});
|
||||
}
|
||||
updateData.structure = structure;
|
||||
}
|
||||
if (instructions !== undefined) updateData.instructions = instructions;
|
||||
if (difficulty_score !== undefined) updateData.difficulty_score = difficulty_score;
|
||||
if (category !== undefined) updateData.category = category;
|
||||
if (tags !== undefined) updateData.tags = tags;
|
||||
|
||||
await grammar.update(updateData, { transaction });
|
||||
|
||||
// Update mappings if provided
|
||||
if (mappings !== undefined) {
|
||||
await GrammarMapping.destroy({ where: { grammar_id: grammar.id }, transaction });
|
||||
if (mappings.length > 0) {
|
||||
const mappingData = mappings.map(m => ({
|
||||
grammar_id: grammar.id,
|
||||
book_id: m.book_id,
|
||||
grade: m.grade,
|
||||
unit: m.unit,
|
||||
lesson: m.lesson,
|
||||
context_note: m.context_note
|
||||
}));
|
||||
await GrammarMapping.bulkCreate(mappingData, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
// Update media stories if provided
|
||||
if (media_stories !== undefined) {
|
||||
await GrammarMediaStory.destroy({ where: { grammar_id: grammar.id }, transaction });
|
||||
if (media_stories.length > 0) {
|
||||
const storyData = media_stories.map(s => ({
|
||||
grammar_id: grammar.id,
|
||||
story_id: s.story_id,
|
||||
title: s.title,
|
||||
type: s.type || 'story',
|
||||
url: s.url,
|
||||
thumbnail: s.thumbnail,
|
||||
description: s.description,
|
||||
duration_seconds: s.duration_seconds,
|
||||
min_grade: s.min_grade
|
||||
}));
|
||||
await GrammarMediaStory.bulkCreate(storyData, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fetch updated grammar
|
||||
const result = await Grammar.findByPk(grammar.id, {
|
||||
include: [
|
||||
{ model: GrammarMapping, as: 'mappings' },
|
||||
{ model: GrammarMediaStory, as: 'mediaStories' }
|
||||
]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Grammar rule updated successfully',
|
||||
data: result
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error updating grammar:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update grammar rule',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE: Soft delete grammar
|
||||
*/
|
||||
exports.deleteGrammar = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const where = isNaN(id)
|
||||
? { grammar_code: id, is_active: true }
|
||||
: { id: parseInt(id), is_active: true };
|
||||
|
||||
const grammar = await Grammar.findOne({ where });
|
||||
|
||||
if (!grammar) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Grammar rule not found'
|
||||
});
|
||||
}
|
||||
|
||||
await grammar.update({ is_active: false });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Grammar rule deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting grammar:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete grammar rule',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get grammars by curriculum mapping
|
||||
*/
|
||||
exports.getGrammarsByCurriculum = async (req, res) => {
|
||||
try {
|
||||
const { book_id, grade, unit, lesson } = req.query;
|
||||
|
||||
if (!book_id && !grade) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'At least one of book_id or grade is required'
|
||||
});
|
||||
}
|
||||
|
||||
const mappingWhere = {};
|
||||
if (book_id) mappingWhere.book_id = book_id;
|
||||
if (grade) mappingWhere.grade = parseInt(grade);
|
||||
if (unit) mappingWhere.unit = parseInt(unit);
|
||||
if (lesson) mappingWhere.lesson = parseInt(lesson);
|
||||
|
||||
const grammars = await Grammar.findAll({
|
||||
where: { is_active: true },
|
||||
include: [
|
||||
{
|
||||
model: GrammarMapping,
|
||||
as: 'mappings',
|
||||
where: mappingWhere,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
model: GrammarMediaStory,
|
||||
as: 'mediaStories',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['difficulty_score', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: grammars,
|
||||
count: grammars.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching grammars by curriculum:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch grammars by curriculum',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get grammar statistics
|
||||
*/
|
||||
exports.getGrammarStats = async (req, res) => {
|
||||
try {
|
||||
const totalGrammars = await Grammar.count({ where: { is_active: true } });
|
||||
|
||||
const byCategory = await Grammar.findAll({
|
||||
where: { is_active: true },
|
||||
attributes: [
|
||||
'category',
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'count']
|
||||
],
|
||||
group: ['category']
|
||||
});
|
||||
|
||||
const byDifficulty = await Grammar.findAll({
|
||||
where: { is_active: true },
|
||||
attributes: [
|
||||
'difficulty_score',
|
||||
[sequelize.fn('COUNT', sequelize.col('id')), 'count']
|
||||
],
|
||||
group: ['difficulty_score'],
|
||||
order: [['difficulty_score', 'ASC']]
|
||||
});
|
||||
|
||||
const byGrade = await Grammar.findAll({
|
||||
where: { is_active: true },
|
||||
include: [{
|
||||
model: GrammarMapping,
|
||||
as: 'mappings',
|
||||
attributes: []
|
||||
}],
|
||||
attributes: [
|
||||
[sequelize.col('mappings.grade'), 'grade'],
|
||||
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('Grammar.id'))), 'count']
|
||||
],
|
||||
group: [sequelize.col('mappings.grade')],
|
||||
raw: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: totalGrammars,
|
||||
by_category: byCategory,
|
||||
by_difficulty: byDifficulty,
|
||||
by_grade: byGrade
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching grammar stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch grammar statistics',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET GUIDE: Comprehensive guide for AI to create grammar rules
|
||||
*/
|
||||
exports.getGrammarGuide = async (req, res) => {
|
||||
try {
|
||||
const guide = {
|
||||
guide_version: "1.0.0",
|
||||
last_updated: new Date().toISOString(),
|
||||
|
||||
data_structure: {
|
||||
required_fields: {
|
||||
grammar_code: {
|
||||
type: "string(100)",
|
||||
format: "gram-{sequence}-{identifier}",
|
||||
example: "gram-001-present-cont",
|
||||
description: "Unique identifier for grammar rule"
|
||||
},
|
||||
title: {
|
||||
type: "string(200)",
|
||||
example: "Present Continuous",
|
||||
description: "Grammar rule name in English"
|
||||
},
|
||||
structure: {
|
||||
type: "JSON object",
|
||||
required_properties: {
|
||||
formula: {
|
||||
type: "string",
|
||||
example: "S + am/is/are + V-ing + (a/an) + O + Adv",
|
||||
description: "Formula showing sentence structure"
|
||||
},
|
||||
pattern_logic: {
|
||||
type: "array of objects",
|
||||
description: "Defines how to pick words from Vocabulary table",
|
||||
item_structure: {
|
||||
slot_id: "Unique slot identifier (e.g., S_01, V_01)",
|
||||
role: "Word role from vocab.syntax (is_subject, is_verb, is_object, etc.)",
|
||||
semantic_filter: "Array of semantic types to filter (e.g., ['human', 'animal'])",
|
||||
use_form: "Which vocab form to use (v1, v_ing, v2, n_singular, etc.)",
|
||||
dependency: "Slot ID this depends on for grammar agreement",
|
||||
is_optional: "Boolean - whether this slot can be skipped",
|
||||
position: "Where to place in sentence (start, middle, end)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
optional_fields: {
|
||||
translation: {
|
||||
type: "string(200)",
|
||||
example: "Thì hiện tại tiếp diễn",
|
||||
description: "Vietnamese translation"
|
||||
},
|
||||
instructions: {
|
||||
type: "JSON object",
|
||||
properties: {
|
||||
vi: "Vietnamese instruction text",
|
||||
hint: "Short hint for students"
|
||||
},
|
||||
example: {
|
||||
vi: "Dùng để nói về hành động đang diễn ra.",
|
||||
hint: "Cấu trúc: Be + V-ing"
|
||||
}
|
||||
},
|
||||
difficulty_score: {
|
||||
type: "integer",
|
||||
range: "1-10",
|
||||
default: 1,
|
||||
description: "1 = easiest, 10 = hardest"
|
||||
},
|
||||
category: {
|
||||
type: "string(100)",
|
||||
examples: ["Tenses", "Modal Verbs", "Questions", "Conditionals", "Passive Voice"],
|
||||
description: "Grammar category"
|
||||
},
|
||||
tags: {
|
||||
type: "JSON array",
|
||||
example: ["present", "continuous", "action"],
|
||||
description: "Tags for search and categorization"
|
||||
},
|
||||
mappings: {
|
||||
type: "array of objects",
|
||||
description: "Curriculum mapping - where this grammar appears in textbooks",
|
||||
item_structure: {
|
||||
book_id: "Book identifier (e.g., 'global-success-2')",
|
||||
grade: "Grade level (integer)",
|
||||
unit: "Unit number (integer, optional)",
|
||||
lesson: "Lesson number (integer, optional)",
|
||||
context_note: "Additional context (string, optional)"
|
||||
}
|
||||
},
|
||||
media_stories: {
|
||||
type: "array of objects",
|
||||
description: "Stories/videos demonstrating this grammar",
|
||||
item_structure: {
|
||||
story_id: "Unique story identifier (e.g., 'st-01')",
|
||||
title: "Story title",
|
||||
type: "Media type: 'story', 'video', 'animation', 'audio'",
|
||||
url: "Single URL to complete media file (string, required)",
|
||||
thumbnail: "Thumbnail image URL (optional)",
|
||||
description: "Story description (optional)",
|
||||
duration_seconds: "Duration in seconds (integer, optional)",
|
||||
min_grade: "Minimum grade level (integer, optional)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
pattern_logic_roles: {
|
||||
description: "Available roles to query from Vocab table syntax field",
|
||||
roles: [
|
||||
{ role: "is_subject", description: "Can be used as sentence subject", vocab_example: "I, cat, teacher" },
|
||||
{ role: "is_verb", description: "Action or state verb", vocab_example: "eat, run, sleep" },
|
||||
{ role: "is_object", description: "Can be used as object", vocab_example: "apple, book, water" },
|
||||
{ role: "is_be", description: "Be verb (am/is/are)", vocab_example: "am, is, are" },
|
||||
{ role: "is_adj", description: "Adjective", vocab_example: "happy, big, red" },
|
||||
{ role: "is_adv", description: "Adverb", vocab_example: "quickly, slowly, happily" },
|
||||
{ role: "is_article", description: "Article (a/an)", vocab_example: "a, an" },
|
||||
{ role: "is_pronoun", description: "Pronoun", vocab_example: "I, you, he, she" },
|
||||
{ role: "is_preposition", description: "Preposition", vocab_example: "in, on, at" }
|
||||
]
|
||||
},
|
||||
|
||||
semantic_filters: {
|
||||
description: "Semantic types from vocab.semantics.can_be_subject_type or can_take_object_type",
|
||||
common_types: [
|
||||
"human", "animal", "object", "food", "plant", "place",
|
||||
"abstract", "emotion", "action", "state", "container", "tool"
|
||||
],
|
||||
usage: "Use to ensure semantic compatibility (e.g., only humans can 'think', only food can be 'eaten')"
|
||||
},
|
||||
|
||||
form_keys_reference: {
|
||||
description: "Available form keys from VocabForm table",
|
||||
verb_forms: ["v1", "v_s_es", "v_ing", "v2", "v3"],
|
||||
noun_forms: ["n_singular", "n_plural"],
|
||||
adjective_forms: ["adj_base", "adj_comparative", "adj_superlative"],
|
||||
adverb_forms: ["adv_manner", "adv_frequency", "adv_time"],
|
||||
pronoun_forms: ["pron_subject", "pron_object", "pron_possessive"]
|
||||
},
|
||||
|
||||
rules: {
|
||||
grammar_code_format: {
|
||||
pattern: "gram-{3-digit-sequence}-{kebab-case-identifier}",
|
||||
valid: ["gram-001-present-cont", "gram-002-past-simple", "gram-015-modal-can"],
|
||||
invalid: ["gram-1-test", "GRAM-001-TEST", "present-continuous"]
|
||||
},
|
||||
|
||||
formula_format: {
|
||||
description: "Human-readable formula showing sentence structure",
|
||||
tips: [
|
||||
"Use S for Subject, V for Verb, O for Object, Adv for Adverb",
|
||||
"Show alternatives with slash: am/is/are",
|
||||
"Mark optional elements with parentheses: (Adv)",
|
||||
"Keep it simple and educational"
|
||||
],
|
||||
examples: [
|
||||
"S + V + O",
|
||||
"S + am/is/are + V-ing",
|
||||
"S + can/will/must + V1 + O",
|
||||
"Wh-word + am/is/are + S + V-ing?"
|
||||
]
|
||||
},
|
||||
|
||||
pattern_logic_workflow: {
|
||||
description: "How Grammar Engine uses pattern_logic to generate sentences",
|
||||
steps: [
|
||||
"1. Loop through each slot in pattern_logic array (in order)",
|
||||
"2. Query Vocab table filtering by role (e.g., WHERE syntax->>'is_verb' = true)",
|
||||
"3. Apply semantic_filter if specified (e.g., WHERE semantics->>'can_be_subject_type' @> '[\"human\"]')",
|
||||
"4. If use_form specified, fetch that specific form from VocabForm table",
|
||||
"5. If dependency specified, apply grammar agreement rules (e.g., match be verb with subject)",
|
||||
"6. Randomly select one matching word",
|
||||
"7. If is_optional = true and randomly skip, continue to next slot",
|
||||
"8. Concatenate all selected words according to formula"
|
||||
]
|
||||
},
|
||||
|
||||
dependency_rules: {
|
||||
be_verb_agreement: {
|
||||
description: "Match am/is/are with subject",
|
||||
logic: "If S_01 is 'I' → use 'am', if singular → 'is', if plural → 'are'"
|
||||
},
|
||||
article_selection: {
|
||||
description: "Choose a/an based on next word's phonetic",
|
||||
logic: "If O_01 starts with vowel sound → 'an', else → 'a'"
|
||||
},
|
||||
semantic_matching: {
|
||||
description: "Ensure verb can take the object type",
|
||||
logic: "If V_01.semantics.can_take_object_type includes O_01.semantics.word_type → valid"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
examples: {
|
||||
present_continuous: {
|
||||
grammar_code: "gram-001-present-cont",
|
||||
title: "Present Continuous",
|
||||
translation: "Thì hiện tại tiếp diễn",
|
||||
structure: {
|
||||
formula: "S + am/is/are + V-ing + (a/an) + O + Adv",
|
||||
pattern_logic: [
|
||||
{
|
||||
slot_id: "S_01",
|
||||
role: "is_subject",
|
||||
semantic_filter: ["human", "animal"]
|
||||
},
|
||||
{
|
||||
slot_id: "BE_01",
|
||||
role: "is_be",
|
||||
dependency: "S_01"
|
||||
},
|
||||
{
|
||||
slot_id: "V_01",
|
||||
role: "is_verb",
|
||||
use_form: "v_ing",
|
||||
semantic_filter: ["action"]
|
||||
},
|
||||
{
|
||||
slot_id: "ART_01",
|
||||
role: "is_article",
|
||||
dependency: "O_01",
|
||||
is_optional: true
|
||||
},
|
||||
{
|
||||
slot_id: "O_01",
|
||||
role: "is_object",
|
||||
semantic_match: "V_01"
|
||||
},
|
||||
{
|
||||
slot_id: "ADV_01",
|
||||
role: "is_adv",
|
||||
is_optional: true,
|
||||
position: "end"
|
||||
}
|
||||
]
|
||||
},
|
||||
instructions: {
|
||||
vi: "Dùng để nói về hành động đang diễn ra.",
|
||||
hint: "Cấu trúc: Be + V-ing"
|
||||
},
|
||||
difficulty_score: 2,
|
||||
category: "Tenses",
|
||||
tags: ["present", "continuous", "action"],
|
||||
mappings: [
|
||||
{
|
||||
book_id: "global-success-2",
|
||||
grade: 2,
|
||||
unit: 5,
|
||||
lesson: 1
|
||||
}
|
||||
],
|
||||
media_stories: [
|
||||
{
|
||||
story_id: "st-01",
|
||||
title: "The Greedy Cat",
|
||||
type: "story",
|
||||
url: "https://cdn.sena.tech/stories/the-greedy-cat-full.mp4",
|
||||
thumbnail: "https://cdn.sena.tech/thumbs/greedy-cat.jpg",
|
||||
description: "A story about a cat who loves eating everything.",
|
||||
duration_seconds: 180,
|
||||
min_grade: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
simple_question: {
|
||||
grammar_code: "gram-010-yes-no-question",
|
||||
title: "Yes/No Questions with Be",
|
||||
translation: "Câu hỏi Yes/No với động từ Be",
|
||||
structure: {
|
||||
formula: "Am/Is/Are + S + Adj?",
|
||||
pattern_logic: [
|
||||
{
|
||||
slot_id: "BE_01",
|
||||
role: "is_be",
|
||||
position: "start"
|
||||
},
|
||||
{
|
||||
slot_id: "S_01",
|
||||
role: "is_subject",
|
||||
semantic_filter: ["human", "animal", "object"]
|
||||
},
|
||||
{
|
||||
slot_id: "ADJ_01",
|
||||
role: "is_adj"
|
||||
}
|
||||
]
|
||||
},
|
||||
instructions: {
|
||||
vi: "Dùng để hỏi về trạng thái hoặc tính chất.",
|
||||
hint: "Đảo Be lên đầu câu"
|
||||
},
|
||||
difficulty_score: 1,
|
||||
category: "Questions",
|
||||
tags: ["question", "be-verb", "beginner"],
|
||||
mappings: [
|
||||
{
|
||||
book_id: "global-success-1",
|
||||
grade: 1,
|
||||
unit: 3,
|
||||
lesson: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
modal_can: {
|
||||
grammar_code: "gram-020-modal-can",
|
||||
title: "Modal Verb Can",
|
||||
translation: "Động từ khiếm khuyết Can",
|
||||
structure: {
|
||||
formula: "S + can + V1 + O",
|
||||
pattern_logic: [
|
||||
{
|
||||
slot_id: "S_01",
|
||||
role: "is_subject",
|
||||
semantic_filter: ["human", "animal"]
|
||||
},
|
||||
{
|
||||
slot_id: "V_01",
|
||||
role: "is_verb",
|
||||
use_form: "v1",
|
||||
semantic_filter: ["action", "state"]
|
||||
},
|
||||
{
|
||||
slot_id: "O_01",
|
||||
role: "is_object",
|
||||
semantic_match: "V_01",
|
||||
is_optional: true
|
||||
}
|
||||
]
|
||||
},
|
||||
instructions: {
|
||||
vi: "Dùng để nói về khả năng hoặc sự cho phép.",
|
||||
hint: "Modal + V1 (không chia)"
|
||||
},
|
||||
difficulty_score: 3,
|
||||
category: "Modal Verbs",
|
||||
tags: ["modal", "ability", "permission"],
|
||||
mappings: [
|
||||
{
|
||||
book_id: "global-success-3",
|
||||
grade: 3,
|
||||
unit: 7,
|
||||
lesson: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
validation_checklist: [
|
||||
"✓ grammar_code follows format: gram-XXX-identifier",
|
||||
"✓ structure.formula is clear and educational",
|
||||
"✓ pattern_logic has at least one slot",
|
||||
"✓ Each slot has valid role from pattern_logic_roles",
|
||||
"✓ use_form values match form_keys_reference",
|
||||
"✓ semantic_filter types are consistent with vocab semantics",
|
||||
"✓ Dependencies reference valid slot_ids",
|
||||
"✓ Media story URLs are accessible",
|
||||
"✓ difficulty_score is between 1-10",
|
||||
"✓ At least one mapping exists for curriculum tracking"
|
||||
],
|
||||
|
||||
common_mistakes: [
|
||||
{
|
||||
mistake: "Using invalid role in pattern_logic",
|
||||
example: { role: "is_noun" },
|
||||
fix: { role: "is_object" },
|
||||
explanation: "Use is_object for nouns that can be sentence objects"
|
||||
},
|
||||
{
|
||||
mistake: "Missing formula in structure",
|
||||
example: { structure: { pattern_logic: [] } },
|
||||
fix: { structure: { formula: "S + V + O", pattern_logic: [] } },
|
||||
explanation: "formula is required for human readability"
|
||||
},
|
||||
{
|
||||
mistake: "Invalid dependency reference",
|
||||
example: { slot_id: "V_01", dependency: "INVALID_SLOT" },
|
||||
fix: { slot_id: "V_01", dependency: "S_01" },
|
||||
explanation: "dependency must reference an existing slot_id"
|
||||
},
|
||||
{
|
||||
mistake: "Wrong form key for verb",
|
||||
example: { role: "is_verb", use_form: "n_singular" },
|
||||
fix: { role: "is_verb", use_form: "v_ing" },
|
||||
explanation: "Verbs use v1, v_s_es, v_ing, v2, v3 forms"
|
||||
},
|
||||
{
|
||||
mistake: "Semantic filter mismatch",
|
||||
example: { role: "is_verb", semantic_filter: ["object"] },
|
||||
fix: { role: "is_verb", semantic_filter: ["action"] },
|
||||
explanation: "Verbs should filter by action/state, not object types"
|
||||
}
|
||||
],
|
||||
|
||||
ai_tips: {
|
||||
efficiency: "Create grammar rules in order of difficulty (easy → hard)",
|
||||
accuracy: "Always validate pattern_logic against actual vocab entries to ensure matches exist",
|
||||
completeness: "Include mappings for curriculum tracking and media_stories for engagement",
|
||||
testing: "After creating, test sentence generation with sample vocab to verify logic",
|
||||
documentation: "Use clear formula and instructions for human teachers to understand"
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: guide
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating grammar guide:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to generate grammar guide',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
858
controllers/learningContentController.js
Normal file
858
controllers/learningContentController.js
Normal file
@@ -0,0 +1,858 @@
|
||||
const { Lesson, Chapter, Subject, Vocab, Grammar, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* CREATE: Add new lesson
|
||||
*/
|
||||
exports.createLesson = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const {
|
||||
chapter_id,
|
||||
lesson_number,
|
||||
lesson_title,
|
||||
lesson_type = 'json_content',
|
||||
lesson_description,
|
||||
content_json,
|
||||
content_url,
|
||||
content_type,
|
||||
lesson_content_type,
|
||||
duration_minutes,
|
||||
is_published = false,
|
||||
is_free = false,
|
||||
display_order = 0,
|
||||
thumbnail_url
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!chapter_id || !lesson_number || !lesson_title) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: chapter_id, lesson_number, lesson_title'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate content based on lesson_type
|
||||
if (lesson_type === 'json_content' && !content_json) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'content_json is required when lesson_type is json_content'
|
||||
});
|
||||
}
|
||||
|
||||
if (lesson_type === 'url_content' && !content_url) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'content_url is required when lesson_type is url_content'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if chapter exists
|
||||
const chapter = await Chapter.findByPk(chapter_id);
|
||||
if (!chapter) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Chapter not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Create lesson
|
||||
const lesson = await Lesson.create({
|
||||
chapter_id,
|
||||
lesson_number,
|
||||
lesson_title,
|
||||
lesson_type,
|
||||
lesson_description,
|
||||
content_json,
|
||||
content_url,
|
||||
content_type,
|
||||
lesson_content_type,
|
||||
duration_minutes,
|
||||
is_published,
|
||||
is_free,
|
||||
display_order,
|
||||
thumbnail_url
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Lesson created successfully',
|
||||
data: lesson
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error creating lesson:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create lesson',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get all lessons with pagination and filters
|
||||
*/
|
||||
exports.getAllLessons = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
chapter_id,
|
||||
lesson_content_type,
|
||||
lesson_type,
|
||||
is_published,
|
||||
is_free,
|
||||
search
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const where = {};
|
||||
|
||||
if (chapter_id) where.chapter_id = chapter_id;
|
||||
if (lesson_content_type) where.lesson_content_type = lesson_content_type;
|
||||
if (lesson_type) where.lesson_type = lesson_type;
|
||||
if (is_published !== undefined) where.is_published = is_published === 'true';
|
||||
if (is_free !== undefined) where.is_free = is_free === 'true';
|
||||
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ lesson_title: { [Op.like]: `%${search}%` } },
|
||||
{ lesson_description: { [Op.like]: `%${search}%` } }
|
||||
];
|
||||
}
|
||||
|
||||
const { count, rows } = await Lesson.findAndCountAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: Chapter,
|
||||
as: 'chapter',
|
||||
attributes: ['id', 'chapter_title', 'chapter_number'],
|
||||
include: [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'subject',
|
||||
attributes: ['id', 'subject_name', 'subject_code']
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset,
|
||||
order: [['display_order', 'ASC'], ['lesson_number', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching lessons:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch lessons',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get lesson by ID
|
||||
*/
|
||||
exports.getLessonById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const lesson = await Lesson.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: Chapter,
|
||||
as: 'chapter',
|
||||
include: [
|
||||
{
|
||||
model: Subject,
|
||||
as: 'subject'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: lesson
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching lesson:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch lesson',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UPDATE: Update lesson
|
||||
*/
|
||||
exports.updateLesson = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
const lesson = await Lesson.findByPk(id);
|
||||
|
||||
if (!lesson) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson not found'
|
||||
});
|
||||
}
|
||||
|
||||
await lesson.update(updateData, { transaction });
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lesson updated successfully',
|
||||
data: lesson
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error updating lesson:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update lesson',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE: Delete lesson
|
||||
*/
|
||||
exports.deleteLesson = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const lesson = await Lesson.findByPk(id);
|
||||
|
||||
if (!lesson) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Lesson not found'
|
||||
});
|
||||
}
|
||||
|
||||
await lesson.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lesson deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting lesson:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete lesson',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get lessons by chapter
|
||||
*/
|
||||
exports.getLessonsByChapter = async (req, res) => {
|
||||
try {
|
||||
const { chapter_id } = req.params;
|
||||
|
||||
const lessons = await Lesson.findAll({
|
||||
where: { chapter_id },
|
||||
order: [['display_order', 'ASC'], ['lesson_number', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: lessons,
|
||||
count: lessons.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching lessons by chapter:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch lessons by chapter',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET GUIDE: Comprehensive guide for AI to create learning content
|
||||
*/
|
||||
exports.getLearningContentGuide = async (req, res) => {
|
||||
try {
|
||||
const guide = {
|
||||
guide_version: "1.0.0",
|
||||
last_updated: new Date().toISOString(),
|
||||
|
||||
hierarchy: {
|
||||
description: "Learning content hierarchy: Subject → Chapter → Lesson",
|
||||
structure: {
|
||||
subject: "Top-level course/curriculum (e.g., 'English Grade 1')",
|
||||
chapter: "Main topic within subject (e.g., 'Unit 1: My Family')",
|
||||
lesson: "Individual learning activity (e.g., 'Lesson 1: Family Vocabulary')"
|
||||
}
|
||||
},
|
||||
|
||||
subject_structure: {
|
||||
description: "Subject represents a complete course or curriculum",
|
||||
required_fields: {
|
||||
subject_code: {
|
||||
type: "string(20)",
|
||||
example: "ENG-G1",
|
||||
description: "Unique code for subject"
|
||||
},
|
||||
subject_name: {
|
||||
type: "string(100)",
|
||||
example: "English Grade 1",
|
||||
description: "Subject name"
|
||||
}
|
||||
},
|
||||
optional_fields: {
|
||||
subject_name_en: "English name",
|
||||
description: "Subject description",
|
||||
is_active: "Active status (default: true)",
|
||||
is_premium: "Premium content flag",
|
||||
is_training: "Training content flag",
|
||||
is_public: "Public self-learning flag"
|
||||
}
|
||||
},
|
||||
|
||||
chapter_structure: {
|
||||
description: "Chapter represents a major topic within a subject",
|
||||
required_fields: {
|
||||
subject_id: {
|
||||
type: "UUID",
|
||||
example: "550e8400-e29b-41d4-a716-446655440000",
|
||||
description: "Parent subject ID"
|
||||
},
|
||||
chapter_number: {
|
||||
type: "integer",
|
||||
example: 1,
|
||||
description: "Sequential chapter number"
|
||||
},
|
||||
chapter_title: {
|
||||
type: "string(200)",
|
||||
example: "Unit 1: My Family",
|
||||
description: "Chapter title"
|
||||
}
|
||||
},
|
||||
optional_fields: {
|
||||
chapter_description: "Chapter description",
|
||||
duration_minutes: "Estimated duration",
|
||||
is_published: "Published status (default: false)",
|
||||
display_order: "Custom display order"
|
||||
}
|
||||
},
|
||||
|
||||
lesson_structure: {
|
||||
description: "Lesson represents individual learning activity",
|
||||
required_fields: {
|
||||
chapter_id: {
|
||||
type: "UUID",
|
||||
example: "550e8400-e29b-41d4-a716-446655440000",
|
||||
description: "Parent chapter ID"
|
||||
},
|
||||
lesson_number: {
|
||||
type: "integer",
|
||||
example: 1,
|
||||
description: "Sequential lesson number"
|
||||
},
|
||||
lesson_title: {
|
||||
type: "string(200)",
|
||||
example: "Family Vocabulary",
|
||||
description: "Lesson title"
|
||||
},
|
||||
lesson_type: {
|
||||
type: "enum",
|
||||
options: ["json_content", "url_content"],
|
||||
default: "json_content",
|
||||
description: "Content type: JSON or URL"
|
||||
}
|
||||
},
|
||||
optional_fields: {
|
||||
lesson_description: "Lesson description",
|
||||
lesson_content_type: "Content category: vocabulary, grammar, phonics, review, mixed",
|
||||
content_json: "JSON content structure (see lesson_content_types below)",
|
||||
content_url: "URL for external content",
|
||||
content_type: "URL content type: video, audio, pdf, etc.",
|
||||
duration_minutes: "Estimated duration",
|
||||
is_published: "Published status",
|
||||
is_free: "Free trial access",
|
||||
display_order: "Custom display order",
|
||||
thumbnail_url: "Lesson thumbnail"
|
||||
}
|
||||
},
|
||||
|
||||
lesson_content_types: {
|
||||
vocabulary_lesson: {
|
||||
description: "Vocabulary learning lesson with word list and exercises. System will lookup words in Vocab table when needed.",
|
||||
structure: {
|
||||
type: "vocabulary",
|
||||
words: {
|
||||
type: "array of strings OR array of objects",
|
||||
description: "List of vocabulary words. System will search in Vocab table by base_word. Can be simple array of words or detailed objects.",
|
||||
example_simple: ["mother", "father", "sister", "brother"],
|
||||
example_detailed: [
|
||||
{
|
||||
word: "mother",
|
||||
translation: "mẹ",
|
||||
image: "https://cdn.sena.tech/vocab/mother.jpg",
|
||||
audio: "https://cdn.sena.tech/audio/mother.mp3",
|
||||
phonetic: "/ˈmʌð.ər/"
|
||||
}
|
||||
],
|
||||
note: "Use simple array for words already in Vocab table. Use detailed objects for new/custom vocabulary."
|
||||
},
|
||||
exercises: {
|
||||
type: "array of objects",
|
||||
description: "Practice exercises",
|
||||
example: [
|
||||
{
|
||||
type: "match",
|
||||
question: "Match the words with pictures",
|
||||
items: [
|
||||
{ word: "mother", image: "..." },
|
||||
{ word: "father", image: "..." }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "fill_blank",
|
||||
question: "This is my ___",
|
||||
answer: "mother",
|
||||
options: ["mother", "father", "sister"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
example: {
|
||||
type: "vocabulary",
|
||||
words: ["mother", "father", "sister", "brother"],
|
||||
exercises: [
|
||||
{
|
||||
type: "match",
|
||||
question: "Match family members",
|
||||
items: [
|
||||
{ word: "mother", image: "https://cdn.sena.tech/img/mother.jpg" },
|
||||
{ word: "father", image: "https://cdn.sena.tech/img/father.jpg" }
|
||||
]
|
||||
}
|
||||
],
|
||||
note: "System will lookup these words in Vocab table when rendering lesson"
|
||||
}
|
||||
},
|
||||
|
||||
grammar_lesson: {
|
||||
description: "Grammar lesson with sentences and examples. System will lookup grammar patterns when needed.",
|
||||
structure: {
|
||||
type: "grammar",
|
||||
grammar_points: {
|
||||
type: "array of strings OR array of objects",
|
||||
description: "Grammar points to teach. Can be grammar names or sentence patterns. System will search in Grammar table.",
|
||||
example_simple: ["Present Simple", "Present Continuous"],
|
||||
example_sentences: ["I eat an apple", "She eats an apple", "They are eating"],
|
||||
note: "Use grammar names (e.g., 'Present Simple') or example sentences. System will find matching patterns in Grammar table."
|
||||
},
|
||||
sentences: {
|
||||
type: "array of strings OR array of objects",
|
||||
description: "Example sentences for this lesson",
|
||||
example: ["I eat an apple", "She drinks water", "He plays football"]
|
||||
},
|
||||
examples: {
|
||||
type: "array of objects",
|
||||
description: "Example sentences",
|
||||
example: [
|
||||
{
|
||||
sentence: "I eat an apple.",
|
||||
translation: "Tôi ăn một quả táo.",
|
||||
audio: "https://cdn.sena.tech/audio/example1.mp3"
|
||||
}
|
||||
]
|
||||
},
|
||||
exercises: {
|
||||
type: "array of objects",
|
||||
description: "Practice exercises",
|
||||
example: [
|
||||
{
|
||||
type: "fill_blank",
|
||||
question: "She ___ (eat) an apple every day.",
|
||||
answer: "eats",
|
||||
options: ["eat", "eats", "eating"]
|
||||
},
|
||||
{
|
||||
type: "arrange_words",
|
||||
question: "Arrange: apple / eat / I / an",
|
||||
answer: "I eat an apple"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
example: {
|
||||
type: "grammar",
|
||||
grammar_points: ["Present Simple"],
|
||||
sentences: [
|
||||
"I eat an apple.",
|
||||
"She eats an apple.",
|
||||
"They eat apples."
|
||||
],
|
||||
exercises: [
|
||||
{
|
||||
type: "fill_blank",
|
||||
question: "She ___ an apple.",
|
||||
answer: "eats",
|
||||
options: ["eat", "eats", "eating"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
phonics_lesson: {
|
||||
description: "Phonics/pronunciation lesson with IPA and sound practice",
|
||||
structure: {
|
||||
type: "phonics",
|
||||
phonics_rules: {
|
||||
type: "array of objects",
|
||||
description: "Phonics rules with IPA and example words",
|
||||
example: [
|
||||
{
|
||||
ipa: "/æ/",
|
||||
sound_name: "short a",
|
||||
description: "As in 'cat', 'hat'",
|
||||
audio: "https://cdn.sena.tech/phonics/ae.mp3",
|
||||
words: [
|
||||
{ word: "cat", phonetic: "/kæt/", audio: "..." },
|
||||
{ word: "hat", phonetic: "/hæt/", audio: "..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
exercises: {
|
||||
type: "array of objects",
|
||||
description: "Pronunciation practice",
|
||||
example: [
|
||||
{
|
||||
type: "listen_repeat",
|
||||
question: "Listen and repeat",
|
||||
audio: "https://cdn.sena.tech/audio/cat.mp3",
|
||||
word: "cat"
|
||||
},
|
||||
{
|
||||
type: "identify_sound",
|
||||
question: "Which word has the /æ/ sound?",
|
||||
options: ["cat", "cut", "cot"],
|
||||
answer: "cat"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
example: {
|
||||
type: "phonics",
|
||||
phonics_rules: [
|
||||
{
|
||||
ipa: "/æ/",
|
||||
sound_name: "short a",
|
||||
audio: "https://cdn.sena.tech/phonics/ae.mp3",
|
||||
words: [
|
||||
{ word: "cat", phonetic: "/kæt/" },
|
||||
{ word: "hat", phonetic: "/hæt/" }
|
||||
]
|
||||
}
|
||||
],
|
||||
exercises: [
|
||||
{
|
||||
type: "listen_repeat",
|
||||
audio: "https://cdn.sena.tech/audio/cat.mp3",
|
||||
word: "cat"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
review_lesson: {
|
||||
description: "Review lesson combining vocabulary, grammar, and phonics",
|
||||
structure: {
|
||||
type: "review",
|
||||
sections: {
|
||||
type: "array of objects",
|
||||
description: "Array containing vocabulary, grammar, and/or phonics sections",
|
||||
example: [
|
||||
{
|
||||
section_type: "vocabulary",
|
||||
title: "Family Vocabulary Review",
|
||||
vocabulary_ids: [],
|
||||
exercises: []
|
||||
},
|
||||
{
|
||||
section_type: "grammar",
|
||||
title: "Present Simple Review",
|
||||
grammar_ids: [],
|
||||
exercises: []
|
||||
},
|
||||
{
|
||||
section_type: "phonics",
|
||||
title: "Short A Sound Review",
|
||||
phonics_rules: [],
|
||||
exercises: []
|
||||
}
|
||||
]
|
||||
},
|
||||
overall_exercises: {
|
||||
type: "array of objects",
|
||||
description: "Mixed exercises covering all sections",
|
||||
example: [
|
||||
{
|
||||
type: "mixed_quiz",
|
||||
questions: [
|
||||
{ type: "vocabulary", question: "...", answer: "..." },
|
||||
{ type: "grammar", question: "...", answer: "..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
example: {
|
||||
type: "review",
|
||||
sections: [
|
||||
{
|
||||
section_type: "vocabulary",
|
||||
title: "Family Words",
|
||||
words: ["mother", "father", "sister", "brother"]
|
||||
},
|
||||
{
|
||||
section_type: "grammar",
|
||||
title: "Present Simple",
|
||||
grammar_points: ["Present Simple"],
|
||||
sentences: ["I eat an apple", "She eats an apple"]
|
||||
},
|
||||
{
|
||||
section_type: "phonics",
|
||||
title: "Short A Sound",
|
||||
phonics_rules: [
|
||||
{
|
||||
ipa: "/æ/",
|
||||
words: ["cat", "hat", "bat"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
overall_exercises: [
|
||||
{
|
||||
type: "mixed_quiz",
|
||||
questions: [
|
||||
{ type: "vocabulary", question: "What is 'mẹ' in English?", answer: "mother" },
|
||||
{ type: "grammar", question: "She ___ an apple", answer: "eats" }
|
||||
]
|
||||
}
|
||||
],
|
||||
note: "System will lookup words and grammar patterns from database when rendering"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
exercise_types: {
|
||||
description: "Common exercise types across all lesson types",
|
||||
types: {
|
||||
match: "Match items (words to images, words to translations)",
|
||||
fill_blank: "Fill in the blank",
|
||||
multiple_choice: "Multiple choice question",
|
||||
arrange_words: "Arrange words to form sentence",
|
||||
listen_repeat: "Listen and repeat (for phonics)",
|
||||
identify_sound: "Identify the correct sound/word",
|
||||
true_false: "True or false question",
|
||||
mixed_quiz: "Mixed questions from different types"
|
||||
}
|
||||
},
|
||||
|
||||
api_workflow: {
|
||||
description: "Step-by-step workflow to create learning content. No need to create Vocab/Grammar entries first - just use word lists and sentences directly.",
|
||||
steps: [
|
||||
{
|
||||
step: 1,
|
||||
action: "Create Subject",
|
||||
endpoint: "POST /api/subjects",
|
||||
example: {
|
||||
subject_code: "ENG-G1",
|
||||
subject_name: "English Grade 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
action: "Create Chapter",
|
||||
endpoint: "POST /api/chapters",
|
||||
example: {
|
||||
subject_id: "<subject_uuid>",
|
||||
chapter_number: 1,
|
||||
chapter_title: "Unit 1: My Family"
|
||||
}
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
action: "Create Vocabulary Lesson (using word list)",
|
||||
endpoint: "POST /api/learning-content/lessons",
|
||||
example: {
|
||||
chapter_id: "<chapter_uuid>",
|
||||
lesson_number: 1,
|
||||
lesson_title: "Family Vocabulary",
|
||||
lesson_type: "json_content",
|
||||
lesson_content_type: "vocabulary",
|
||||
content_json: {
|
||||
type: "vocabulary",
|
||||
words: ["mother", "father", "sister", "brother"],
|
||||
exercises: []
|
||||
}
|
||||
},
|
||||
note: "System will search for these words in Vocab table when rendering lesson"
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
action: "Create Grammar Lesson (using sentences)",
|
||||
endpoint: "POST /api/learning-content/lessons",
|
||||
example: {
|
||||
chapter_id: "<chapter_uuid>",
|
||||
lesson_number: 2,
|
||||
lesson_title: "Present Simple",
|
||||
lesson_type: "json_content",
|
||||
lesson_content_type: "grammar",
|
||||
content_json: {
|
||||
type: "grammar",
|
||||
grammar_points: ["Present Simple"],
|
||||
sentences: ["I eat an apple", "She eats an apple"],
|
||||
exercises: []
|
||||
}
|
||||
},
|
||||
note: "System will find matching grammar patterns from Grammar table"
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
action: "Create Review Lesson",
|
||||
endpoint: "POST /api/learning-content/lessons",
|
||||
example: {
|
||||
chapter_id: "<chapter_uuid>",
|
||||
lesson_number: 3,
|
||||
lesson_title: "Unit Review",
|
||||
lesson_type: "json_content",
|
||||
lesson_content_type: "review",
|
||||
content_json: {
|
||||
type: "review",
|
||||
sections: [
|
||||
{ section_type: "vocabulary", words: ["mother", "father"] },
|
||||
{ section_type: "grammar", sentences: ["I eat an apple"] }
|
||||
],
|
||||
overall_exercises: []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
validation_checklist: [
|
||||
"✓ Subject, Chapter, Lesson hierarchy is maintained",
|
||||
"✓ lesson_type matches content (json_content has content_json, url_content has content_url)",
|
||||
"✓ lesson_content_type is set correctly (vocabulary, grammar, phonics, review)",
|
||||
"✓ content_json structure matches lesson_content_type",
|
||||
"✓ For vocabulary lessons: words array is provided (system will lookup in Vocab table)",
|
||||
"✓ For grammar lessons: sentences or grammar_points are provided (system will lookup in Grammar table)",
|
||||
"✓ Exercise types are valid",
|
||||
"✓ URLs are accessible (audio, images, videos)",
|
||||
"✓ Lesson numbers are sequential within chapter"
|
||||
],
|
||||
|
||||
common_mistakes: [
|
||||
{
|
||||
mistake: "Missing parent references",
|
||||
example: { lesson_title: "Vocab" },
|
||||
fix: { chapter_id: "<uuid>", lesson_title: "Vocab" },
|
||||
explanation: "Always provide chapter_id for lessons"
|
||||
},
|
||||
{
|
||||
mistake: "Wrong content type",
|
||||
example: { lesson_type: "json_content", content_url: "..." },
|
||||
fix: { lesson_type: "url_content", content_url: "..." },
|
||||
explanation: "lesson_type must match content field"
|
||||
},
|
||||
{
|
||||
mistake: "Using old vocabulary_ids format",
|
||||
example: { vocabulary_ids: ["uuid1", "uuid2"] },
|
||||
fix: { words: ["mother", "father", "sister"] },
|
||||
explanation: "Use words array instead of vocabulary_ids. System will lookup words in Vocab table."
|
||||
},
|
||||
{
|
||||
mistake: "Using old grammar_ids format",
|
||||
example: { grammar_ids: ["uuid1"] },
|
||||
fix: { grammar_points: ["Present Simple"], sentences: ["I eat an apple"] },
|
||||
explanation: "Use grammar_points or sentences instead of grammar_ids. System will find patterns in Grammar table."
|
||||
},
|
||||
{
|
||||
mistake: "Missing content_json type",
|
||||
example: { content_json: { exercises:[]} },
|
||||
fix: { content_json: { type: "vocabulary", exercises: [] } },
|
||||
explanation: "content_json must have type field"
|
||||
}
|
||||
],
|
||||
|
||||
ai_tips: {
|
||||
planning: "Plan subject → chapter → lesson hierarchy before creating",
|
||||
word_lists: "Use simple word arrays like ['mother', 'father']. System will automatically lookup in Vocab table when rendering.",
|
||||
sentences: "Use sentence arrays like ['I eat an apple', 'She eats an apple']. System will find matching grammar patterns.",
|
||||
consistency: "Keep lesson_content_type and content_json.type consistent",
|
||||
exercises: "Include diverse exercise types for better engagement",
|
||||
multimedia: "Provide audio and images for better learning experience",
|
||||
no_ids_needed: "No need to create Vocab/Grammar entries first - just use word lists and sentences directly!"
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: guide
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating learning content guide:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to generate learning content guide',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
642
controllers/storyController.js
Normal file
642
controllers/storyController.js
Normal file
@@ -0,0 +1,642 @@
|
||||
const { Story, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* CREATE: Add new story
|
||||
*/
|
||||
exports.createStory = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
logo,
|
||||
vocabulary = [],
|
||||
context = [],
|
||||
grade = [],
|
||||
tag = []
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
await transaction.rollback();
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required field: name'
|
||||
});
|
||||
}
|
||||
|
||||
// Create story
|
||||
const story = await Story.create({
|
||||
name,
|
||||
logo,
|
||||
vocabulary,
|
||||
context,
|
||||
grade,
|
||||
tag
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Story created successfully',
|
||||
data: story
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error creating story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create story',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get all stories with pagination and filters
|
||||
*/
|
||||
exports.getAllStories = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search,
|
||||
grade_filter,
|
||||
tag_filter,
|
||||
sort_by = 'created_at',
|
||||
sort_order = 'DESC'
|
||||
} = req.query;
|
||||
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const where = {};
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
where.name = { [Op.like]: `%${search}%` };
|
||||
}
|
||||
|
||||
// Grade filter
|
||||
if (grade_filter) {
|
||||
where.grade = {
|
||||
[Op.contains]: [grade_filter]
|
||||
};
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (tag_filter) {
|
||||
where.tag = {
|
||||
[Op.contains]: [tag_filter]
|
||||
};
|
||||
}
|
||||
|
||||
const { count, rows } = await Story.findAndCountAll({
|
||||
where,
|
||||
limit: parseInt(limit),
|
||||
offset,
|
||||
order: [[sort_by, sort_order.toUpperCase()]],
|
||||
attributes: ['id', 'name', 'logo', 'grade', 'tag', 'created_at', 'updated_at']
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: rows,
|
||||
pagination: {
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching stories:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch stories',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get story by ID
|
||||
*/
|
||||
exports.getStoryById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const story = await Story.findByPk(id);
|
||||
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: story
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch story',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* UPDATE: Update story
|
||||
*/
|
||||
exports.updateStory = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name,
|
||||
logo,
|
||||
vocabulary,
|
||||
context,
|
||||
grade,
|
||||
tag
|
||||
} = req.body;
|
||||
|
||||
const story = await Story.findByPk(id);
|
||||
|
||||
if (!story) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Update fields
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (logo !== undefined) updateData.logo = logo;
|
||||
if (vocabulary !== undefined) updateData.vocabulary = vocabulary;
|
||||
if (context !== undefined) updateData.context = context;
|
||||
if (grade !== undefined) updateData.grade = grade;
|
||||
if (tag !== undefined) updateData.tag = tag;
|
||||
|
||||
await story.update(updateData, { transaction });
|
||||
await transaction.commit();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Story updated successfully',
|
||||
data: story
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error updating story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update story',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE: Delete story
|
||||
*/
|
||||
exports.deleteStory = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const story = await Story.findByPk(id);
|
||||
|
||||
if (!story) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Story not found'
|
||||
});
|
||||
}
|
||||
|
||||
await story.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Story deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting story:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete story',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get stories by grade
|
||||
*/
|
||||
exports.getStoriesByGrade = async (req, res) => {
|
||||
try {
|
||||
const { grade } = req.query;
|
||||
|
||||
if (!grade) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Grade parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const stories = await Story.findAll({
|
||||
where: {
|
||||
grade: {
|
||||
[Op.contains]: [grade]
|
||||
}
|
||||
},
|
||||
order: [['created_at', 'DESC']],
|
||||
attributes: ['id', 'name', 'logo', 'grade', 'tag', 'created_at']
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stories,
|
||||
count: stories.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching stories by grade:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch stories by grade',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get stories by tag
|
||||
*/
|
||||
exports.getStoriesByTag = async (req, res) => {
|
||||
try {
|
||||
const { tag } = req.query;
|
||||
|
||||
if (!tag) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Tag parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const stories = await Story.findAll({
|
||||
where: {
|
||||
tag: {
|
||||
[Op.contains]: [tag]
|
||||
}
|
||||
},
|
||||
order: [['created_at', 'DESC']],
|
||||
attributes: ['id', 'name', 'logo', 'grade', 'tag', 'created_at']
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stories,
|
||||
count: stories.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching stories by tag:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch stories by tag',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* READ: Get story statistics
|
||||
*/
|
||||
exports.getStoryStats = async (req, res) => {
|
||||
try {
|
||||
const totalStories = await Story.count();
|
||||
|
||||
// Get all stories to analyze grades and tags
|
||||
const allStories = await Story.findAll({
|
||||
attributes: ['grade', 'tag']
|
||||
});
|
||||
|
||||
// Count by grade
|
||||
const gradeStats = {};
|
||||
allStories.forEach(story => {
|
||||
if (story.grade && Array.isArray(story.grade)) {
|
||||
story.grade.forEach(g => {
|
||||
gradeStats[g] = (gradeStats[g] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Count by tag
|
||||
const tagStats = {};
|
||||
allStories.forEach(story => {
|
||||
if (story.tag && Array.isArray(story.tag)) {
|
||||
story.tag.forEach(t => {
|
||||
tagStats[t] = (tagStats[t] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total: totalStories,
|
||||
by_grade: Object.entries(gradeStats).map(([grade, count]) => ({ grade, count })),
|
||||
by_tag: Object.entries(tagStats).map(([tag, count]) => ({ tag, count }))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching story stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch story statistics',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET GUIDE: Comprehensive guide for AI to create stories
|
||||
*/
|
||||
exports.getStoryGuide = async (req, res) => {
|
||||
try {
|
||||
const guide = {
|
||||
guide_version: "1.0.0",
|
||||
last_updated: new Date().toISOString(),
|
||||
|
||||
data_structure: {
|
||||
required_fields: {
|
||||
name: {
|
||||
type: "string(255)",
|
||||
example: "The Greedy Cat",
|
||||
description: "Story title/name"
|
||||
}
|
||||
},
|
||||
|
||||
optional_fields: {
|
||||
logo: {
|
||||
type: "text (URL)",
|
||||
example: "https://cdn.sena.tech/thumbs/greedy-cat.jpg",
|
||||
description: "URL to story logo/thumbnail image"
|
||||
},
|
||||
vocabulary: {
|
||||
type: "JSON array",
|
||||
example: ["cat", "eat", "apple", "happy"],
|
||||
description: "Array of vocabulary words used in the story"
|
||||
},
|
||||
context: {
|
||||
type: "JSON array of objects",
|
||||
description: "Array of story context objects with images, text, audio data",
|
||||
item_structure: {
|
||||
image: "URL to context image",
|
||||
text: "Story text for this context",
|
||||
audio: "URL to audio narration",
|
||||
order: "Sequence number (integer)"
|
||||
},
|
||||
example: [
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/scene1.jpg",
|
||||
text: "Once upon a time, there was a greedy cat.",
|
||||
audio: "https://cdn.sena.tech/audio/scene1.mp3",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/scene2.jpg",
|
||||
text: "The cat loved eating apples.",
|
||||
audio: "https://cdn.sena.tech/audio/scene2.mp3",
|
||||
order: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
grade: {
|
||||
type: "JSON array",
|
||||
example: ["Grade 1", "Grade 2", "Grade 3"],
|
||||
description: "Array of grade levels this story is suitable for"
|
||||
},
|
||||
tag: {
|
||||
type: "JSON array",
|
||||
example: ["adventure", "friendship", "animals", "food"],
|
||||
description: "Array of tags for categorization and filtering"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
context_structure: {
|
||||
description: "Each context object represents a scene/page in the story",
|
||||
required_properties: {
|
||||
text: "Story text content (required)",
|
||||
order: "Sequence number starting from 1 (required)"
|
||||
},
|
||||
optional_properties: {
|
||||
image: "URL to scene image",
|
||||
audio: "URL to audio narration"
|
||||
},
|
||||
best_practices: [
|
||||
"Keep text concise and age-appropriate",
|
||||
"Use order numbers sequentially (1, 2, 3, ...)",
|
||||
"Provide audio for better engagement",
|
||||
"Images should be high quality and relevant"
|
||||
]
|
||||
},
|
||||
|
||||
vocabulary_guidelines: {
|
||||
description: "Link vocabulary words to existing vocab entries",
|
||||
format: "Array of strings",
|
||||
tips: [
|
||||
"Use vocab_code from Vocab table for better tracking",
|
||||
"Include only words that appear in the story",
|
||||
"Order by frequency or importance",
|
||||
"Can reference vocab IDs or codes"
|
||||
],
|
||||
example: ["vocab-001-cat", "vocab-002-eat", "vocab-015-apple"]
|
||||
},
|
||||
|
||||
grade_levels: {
|
||||
description: "Supported grade levels",
|
||||
options: [
|
||||
"Pre-K",
|
||||
"Kindergarten",
|
||||
"Grade 1",
|
||||
"Grade 2",
|
||||
"Grade 3",
|
||||
"Grade 4",
|
||||
"Grade 5",
|
||||
"Grade 6"
|
||||
],
|
||||
tips: [
|
||||
"Can assign multiple grades",
|
||||
"Consider vocabulary difficulty",
|
||||
"Match with curriculum standards"
|
||||
]
|
||||
},
|
||||
|
||||
tag_categories: {
|
||||
description: "Common tag categories for story classification",
|
||||
categories: {
|
||||
themes: ["adventure", "friendship", "family", "courage", "honesty", "kindness"],
|
||||
subjects: ["animals", "nature", "food", "school", "home", "travel"],
|
||||
skills: ["reading", "listening", "vocabulary", "grammar", "phonics"],
|
||||
emotions: ["happy", "sad", "excited", "scared", "surprised"],
|
||||
genres: ["fiction", "non-fiction", "fantasy", "realistic", "fable"]
|
||||
},
|
||||
tips: [
|
||||
"Use 3-7 tags per story",
|
||||
"Mix different category types",
|
||||
"Keep tags consistent across stories",
|
||||
"Use lowercase for consistency"
|
||||
]
|
||||
},
|
||||
|
||||
examples: {
|
||||
complete_story: {
|
||||
name: "The Greedy Cat",
|
||||
logo: "https://cdn.sena.tech/thumbs/greedy-cat.jpg",
|
||||
vocabulary: ["cat", "eat", "apple", "happy", "greedy"],
|
||||
context: [
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/gc-scene1.jpg",
|
||||
text: "Once upon a time, there was a greedy cat.",
|
||||
audio: "https://cdn.sena.tech/audio/gc-scene1.mp3",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/gc-scene2.jpg",
|
||||
text: "The cat loved eating apples every day.",
|
||||
audio: "https://cdn.sena.tech/audio/gc-scene2.mp3",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/gc-scene3.jpg",
|
||||
text: "One day, the cat ate too many apples and felt sick.",
|
||||
audio: "https://cdn.sena.tech/audio/gc-scene3.mp3",
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
image: "https://cdn.sena.tech/story/gc-scene4.jpg",
|
||||
text: "The cat learned to eat just enough and was happy again.",
|
||||
audio: "https://cdn.sena.tech/audio/gc-scene4.mp3",
|
||||
order: 4
|
||||
}
|
||||
],
|
||||
grade: ["Grade 1", "Grade 2"],
|
||||
tag: ["animals", "food", "lesson", "health", "fiction"]
|
||||
},
|
||||
|
||||
minimal_story: {
|
||||
name: "My Pet Dog",
|
||||
context: [
|
||||
{
|
||||
text: "I have a pet dog.",
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
text: "My dog is brown.",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
text: "I love my dog.",
|
||||
order: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
validation_checklist: [
|
||||
"✓ name is provided and descriptive",
|
||||
"✓ context array has at least 1 scene",
|
||||
"✓ Each context has text and order",
|
||||
"✓ order numbers are sequential (1, 2, 3...)",
|
||||
"✓ vocabulary words match story content",
|
||||
"✓ grade levels are appropriate for content",
|
||||
"✓ tags are relevant and descriptive",
|
||||
"✓ URLs are accessible (logo, images, audio)",
|
||||
"✓ Text is age-appropriate",
|
||||
"✓ Story has clear beginning, middle, end"
|
||||
],
|
||||
|
||||
common_mistakes: [
|
||||
{
|
||||
mistake: "Missing context order",
|
||||
example: { context: [{ text: "Some text" }] },
|
||||
fix: { context: [{ text: "Some text", order: 1 }] },
|
||||
explanation: "Every context must have an order number"
|
||||
},
|
||||
{
|
||||
mistake: "Non-sequential order numbers",
|
||||
example: { context: [{ order: 1 }, { order: 3 }, { order: 2 }] },
|
||||
fix: { context: [{ order: 1 }, { order: 2 }, { order: 3 }] },
|
||||
explanation: "Order should be sequential for proper story flow"
|
||||
},
|
||||
{
|
||||
mistake: "Invalid grade format",
|
||||
example: { grade: "Grade 1" },
|
||||
fix: { grade: ["Grade 1"] },
|
||||
explanation: "grade must be an array"
|
||||
},
|
||||
{
|
||||
mistake: "Mismatched vocabulary",
|
||||
example: { name: "The Cat", vocabulary: ["dog", "bird"] },
|
||||
fix: { name: "The Cat", vocabulary: ["cat"] },
|
||||
explanation: "Vocabulary should match words in the story"
|
||||
},
|
||||
{
|
||||
mistake: "Missing story name",
|
||||
example: { context: [/* context items */] },
|
||||
fix: { name: "My Story", context: [/* context items */] },
|
||||
explanation: "name is required"
|
||||
}
|
||||
],
|
||||
|
||||
ai_tips: {
|
||||
content_creation: "Generate age-appropriate, engaging stories with clear moral lessons",
|
||||
vocabulary_integration: "Reference existing vocabulary entries from Vocab table",
|
||||
multimedia: "Always provide audio URLs for better learning engagement",
|
||||
sequencing: "Ensure context order is logical and sequential",
|
||||
testing: "Test story flow by reading context in order",
|
||||
consistency: "Use consistent naming conventions for URLs and vocabulary"
|
||||
},
|
||||
|
||||
api_usage: {
|
||||
create: "POST /api/stories",
|
||||
get_all: "GET /api/stories?page=1&limit=20&grade_filter=Grade 1&tag_filter=animals",
|
||||
get_one: "GET /api/stories/:id",
|
||||
update: "PUT /api/stories/:id",
|
||||
delete: "DELETE /api/stories/:id",
|
||||
by_grade: "GET /api/stories/grade?grade=Grade 1",
|
||||
by_tag: "GET /api/stories/tag?tag=adventure",
|
||||
stats: "GET /api/stories/stats",
|
||||
guide: "GET /api/stories/guide"
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: guide
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating story guide:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to generate story guide',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
1146
controllers/vocabController.js
Normal file
1146
controllers/vocabController.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user