Files
sena_db_api_layer/controllers/grammarController.js
silverpro89 2c7b4675a7 update
2026-01-26 20:23:08 +07:00

949 lines
29 KiB
JavaScript

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
});
}
};