1147 lines
34 KiB
JavaScript
1147 lines
34 KiB
JavaScript
const { Vocab, VocabMapping, VocabForm, VocabRelation } = require('../models');
|
||
const { Op } = require('sequelize');
|
||
|
||
/**
|
||
* @swagger
|
||
* components:
|
||
* schemas:
|
||
* Vocab:
|
||
* type: object
|
||
* required:
|
||
* - vocab_code
|
||
* - base_word
|
||
* - translation
|
||
* properties:
|
||
* vocab_id:
|
||
* type: integer
|
||
* description: Auto-generated vocab ID
|
||
* vocab_code:
|
||
* type: string
|
||
* description: Unique vocabulary code (e.g., vocab-001-eat)
|
||
* base_word:
|
||
* type: string
|
||
* description: Base form of the word
|
||
* translation:
|
||
* type: string
|
||
* description: Vietnamese translation
|
||
* difficulty_score:
|
||
* type: integer
|
||
* description: Difficulty level (1-10)
|
||
* category:
|
||
* type: string
|
||
* description: Category (e.g., Action Verbs, Nouns)
|
||
* images:
|
||
* type: array
|
||
* items:
|
||
* type: string
|
||
* description: Array of image URLs
|
||
* tags:
|
||
* type: array
|
||
* items:
|
||
* type: string
|
||
* description: Array of tags
|
||
* VocabComplete:
|
||
* allOf:
|
||
* - $ref: '#/components/schemas/Vocab'
|
||
* - type: object
|
||
* properties:
|
||
* mappings:
|
||
* type: array
|
||
* items:
|
||
* type: object
|
||
* properties:
|
||
* book_id:
|
||
* type: string
|
||
* grade:
|
||
* type: integer
|
||
* unit:
|
||
* type: integer
|
||
* lesson:
|
||
* type: integer
|
||
* form_key:
|
||
* type: string
|
||
* forms:
|
||
* type: object
|
||
* additionalProperties:
|
||
* type: object
|
||
* properties:
|
||
* text:
|
||
* type: string
|
||
* phonetic:
|
||
* type: string
|
||
* audio:
|
||
* type: string
|
||
* min_grade:
|
||
* type: integer
|
||
* relations:
|
||
* type: object
|
||
* properties:
|
||
* synonyms:
|
||
* type: array
|
||
* items:
|
||
* type: string
|
||
* antonyms:
|
||
* type: array
|
||
* items:
|
||
* type: string
|
||
*/
|
||
|
||
/**
|
||
* Create a new vocabulary entry with all related data
|
||
*/
|
||
exports.createVocab = async (req, res) => {
|
||
const transaction = await Vocab.sequelize.transaction();
|
||
|
||
try {
|
||
const {
|
||
vocab_code,
|
||
base_word,
|
||
translation,
|
||
attributes = {},
|
||
mappings = [],
|
||
forms = {},
|
||
relations = {},
|
||
syntax = {},
|
||
semantics = {},
|
||
constraints = {}
|
||
} = req.body;
|
||
|
||
// 1. Create main vocab entry
|
||
const vocab = await Vocab.create({
|
||
vocab_code,
|
||
base_word,
|
||
translation,
|
||
difficulty_score: attributes.difficulty_score || 1,
|
||
category: attributes.category,
|
||
images: attributes.images || [],
|
||
tags: attributes.tags || [],
|
||
syntax: syntax,
|
||
semantics: semantics,
|
||
constraints: constraints
|
||
}, { transaction });
|
||
|
||
// 2. Create curriculum mappings
|
||
if (mappings.length > 0) {
|
||
const mappingData = mappings.map(m => ({
|
||
vocab_id: vocab.vocab_id,
|
||
book_id: m.book_id,
|
||
grade: m.grade,
|
||
unit: m.unit,
|
||
lesson: m.lesson,
|
||
form_key: m.form_key,
|
||
context_note: m.context_note
|
||
}));
|
||
await VocabMapping.bulkCreate(mappingData, { transaction });
|
||
}
|
||
|
||
// 3. Create word forms
|
||
if (Object.keys(forms).length > 0) {
|
||
const formData = Object.entries(forms).map(([form_key, formInfo]) => ({
|
||
vocab_id: vocab.vocab_id,
|
||
form_key,
|
||
text: formInfo.text,
|
||
phonetic: formInfo.phonetic,
|
||
audio_url: formInfo.audio,
|
||
min_grade: formInfo.min_grade || 1,
|
||
description: formInfo.description
|
||
}));
|
||
await VocabForm.bulkCreate(formData, { transaction });
|
||
}
|
||
|
||
// 4. Create relations (synonyms and antonyms)
|
||
const relationData = [];
|
||
if (relations.synonyms && relations.synonyms.length > 0) {
|
||
relations.synonyms.forEach(word => {
|
||
relationData.push({
|
||
vocab_id: vocab.vocab_id,
|
||
relation_type: 'synonym',
|
||
related_word: word
|
||
});
|
||
});
|
||
}
|
||
if (relations.antonyms && relations.antonyms.length > 0) {
|
||
relations.antonyms.forEach(word => {
|
||
relationData.push({
|
||
vocab_id: vocab.vocab_id,
|
||
relation_type: 'antonym',
|
||
related_word: word
|
||
});
|
||
});
|
||
}
|
||
if (relationData.length > 0) {
|
||
await VocabRelation.bulkCreate(relationData, { transaction });
|
||
}
|
||
|
||
await transaction.commit();
|
||
|
||
// Fetch complete vocab data
|
||
const completeVocab = await getCompleteVocab(vocab.vocab_id);
|
||
|
||
res.status(201).json({
|
||
message: 'Vocabulary created successfully',
|
||
data: completeVocab
|
||
});
|
||
|
||
} catch (error) {
|
||
await transaction.rollback();
|
||
console.error('Error creating vocab:', error);
|
||
res.status(500).json({
|
||
message: 'Error creating vocabulary',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get all vocabulary entries with pagination and filters
|
||
*/
|
||
exports.getAllVocabs = async (req, res) => {
|
||
try {
|
||
const {
|
||
page = 1,
|
||
limit = 20,
|
||
category,
|
||
grade,
|
||
book_id,
|
||
difficulty_min,
|
||
difficulty_max,
|
||
search,
|
||
include_relations = 'false'
|
||
} = req.query;
|
||
|
||
const offset = (page - 1) * 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] = [
|
||
{ base_word: { [Op.like]: `%${search}%` } },
|
||
{ translation: { [Op.like]: `%${search}%` } },
|
||
{ vocab_code: { [Op.like]: `%${search}%` } }
|
||
];
|
||
}
|
||
|
||
// Build include array
|
||
const include = [
|
||
{
|
||
model: VocabMapping,
|
||
as: 'mappings',
|
||
required: false
|
||
},
|
||
{
|
||
model: VocabForm,
|
||
as: 'forms',
|
||
required: false
|
||
}
|
||
];
|
||
|
||
if (include_relations === 'true') {
|
||
include.push({
|
||
model: VocabRelation,
|
||
as: 'relations',
|
||
required: false
|
||
});
|
||
}
|
||
|
||
// Apply grade or book_id filter through mappings
|
||
if (grade || book_id) {
|
||
const mappingWhere = {};
|
||
if (grade) mappingWhere.grade = parseInt(grade);
|
||
if (book_id) mappingWhere.book_id = book_id;
|
||
|
||
include[0].where = mappingWhere;
|
||
include[0].required = true;
|
||
}
|
||
|
||
const { count, rows } = await Vocab.findAndCountAll({
|
||
where,
|
||
include,
|
||
limit: parseInt(limit),
|
||
offset: parseInt(offset),
|
||
order: [['vocab_code', 'ASC']],
|
||
distinct: true
|
||
});
|
||
|
||
// Format response
|
||
const formattedVocabs = rows.map(vocab => formatVocabResponse(vocab));
|
||
|
||
res.json({
|
||
message: 'Vocabularies retrieved successfully',
|
||
data: formattedVocabs,
|
||
pagination: {
|
||
total: count,
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
totalPages: Math.ceil(count / limit)
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error getting vocabs:', error);
|
||
res.status(500).json({
|
||
message: 'Error retrieving vocabularies',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get single vocabulary by ID or code
|
||
*/
|
||
exports.getVocabById = async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
// Check if id is numeric (vocab_id) or string (vocab_code)
|
||
const where = isNaN(id)
|
||
? { vocab_code: id, is_active: true }
|
||
: { vocab_id: parseInt(id), is_active: true };
|
||
|
||
const vocab = await Vocab.findOne({
|
||
where,
|
||
include: [
|
||
{ model: VocabMapping, as: 'mappings' },
|
||
{ model: VocabForm, as: 'forms' },
|
||
{ model: VocabRelation, as: 'relations' }
|
||
]
|
||
});
|
||
|
||
if (!vocab) {
|
||
return res.status(404).json({
|
||
message: 'Vocabulary not found'
|
||
});
|
||
}
|
||
|
||
const formattedVocab = formatVocabResponse(vocab);
|
||
|
||
res.json({
|
||
message: 'Vocabulary retrieved successfully',
|
||
data: formattedVocab
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error getting vocab:', error);
|
||
res.status(500).json({
|
||
message: 'Error retrieving vocabulary',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Update vocabulary entry
|
||
*/
|
||
exports.updateVocab = async (req, res) => {
|
||
const transaction = await Vocab.sequelize.transaction();
|
||
|
||
try {
|
||
const { id } = req.params;
|
||
const {
|
||
base_word,
|
||
translation,
|
||
attributes,
|
||
mappings,
|
||
forms,
|
||
relations,
|
||
syntax,
|
||
semantics,
|
||
constraints
|
||
} = req.body;
|
||
|
||
// Find vocab
|
||
const where = isNaN(id) ? { vocab_code: id } : { vocab_id: parseInt(id) };
|
||
const vocab = await Vocab.findOne({ where });
|
||
|
||
if (!vocab) {
|
||
await transaction.rollback();
|
||
return res.status(404).json({
|
||
message: 'Vocabulary not found'
|
||
});
|
||
}
|
||
|
||
// 1. Update main vocab entry
|
||
const updateData = {};
|
||
if (base_word) updateData.base_word = base_word;
|
||
if (translation) updateData.translation = translation;
|
||
if (attributes) {
|
||
if (attributes.difficulty_score !== undefined) updateData.difficulty_score = attributes.difficulty_score;
|
||
if (attributes.category !== undefined) updateData.category = attributes.category;
|
||
if (attributes.images !== undefined) updateData.images = attributes.images;
|
||
if (attributes.tags !== undefined) updateData.tags = attributes.tags;
|
||
}
|
||
if (syntax !== undefined) updateData.syntax = syntax;
|
||
if (semantics !== undefined) updateData.semantics = semantics;
|
||
if (constraints !== undefined) updateData.constraints = constraints;
|
||
|
||
if (Object.keys(updateData).length > 0) {
|
||
await vocab.update(updateData, { transaction });
|
||
}
|
||
|
||
// 2. Update mappings (replace all)
|
||
if (mappings !== undefined) {
|
||
await VocabMapping.destroy({ where: { vocab_id: vocab.vocab_id }, transaction });
|
||
if (mappings.length > 0) {
|
||
const mappingData = mappings.map(m => ({
|
||
vocab_id: vocab.vocab_id,
|
||
book_id: m.book_id,
|
||
grade: m.grade,
|
||
unit: m.unit,
|
||
lesson: m.lesson,
|
||
form_key: m.form_key,
|
||
context_note: m.context_note
|
||
}));
|
||
await VocabMapping.bulkCreate(mappingData, { transaction });
|
||
}
|
||
}
|
||
|
||
// 3. Update forms (replace all)
|
||
if (forms !== undefined) {
|
||
await VocabForm.destroy({ where: { vocab_id: vocab.vocab_id }, transaction });
|
||
if (Object.keys(forms).length > 0) {
|
||
const formData = Object.entries(forms).map(([form_key, formInfo]) => ({
|
||
vocab_id: vocab.vocab_id,
|
||
form_key,
|
||
text: formInfo.text,
|
||
phonetic: formInfo.phonetic,
|
||
audio_url: formInfo.audio,
|
||
min_grade: formInfo.min_grade || 1,
|
||
description: formInfo.description
|
||
}));
|
||
await VocabForm.bulkCreate(formData, { transaction });
|
||
}
|
||
}
|
||
|
||
// 4. Update relations (replace all)
|
||
if (relations !== undefined) {
|
||
await VocabRelation.destroy({ where: { vocab_id: vocab.vocab_id }, transaction });
|
||
const relationData = [];
|
||
if (relations.synonyms && relations.synonyms.length > 0) {
|
||
relations.synonyms.forEach(word => {
|
||
relationData.push({
|
||
vocab_id: vocab.vocab_id,
|
||
relation_type: 'synonym',
|
||
related_word: word
|
||
});
|
||
});
|
||
}
|
||
if (relations.antonyms && relations.antonyms.length > 0) {
|
||
relations.antonyms.forEach(word => {
|
||
relationData.push({
|
||
vocab_id: vocab.vocab_id,
|
||
relation_type: 'antonym',
|
||
related_word: word
|
||
});
|
||
});
|
||
}
|
||
if (relationData.length > 0) {
|
||
await VocabRelation.bulkCreate(relationData, { transaction });
|
||
}
|
||
}
|
||
|
||
await transaction.commit();
|
||
|
||
// Fetch updated vocab
|
||
const updatedVocab = await getCompleteVocab(vocab.vocab_id);
|
||
|
||
res.json({
|
||
message: 'Vocabulary updated successfully',
|
||
data: updatedVocab
|
||
});
|
||
|
||
} catch (error) {
|
||
await transaction.rollback();
|
||
console.error('Error updating vocab:', error);
|
||
res.status(500).json({
|
||
message: 'Error updating vocabulary',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Delete vocabulary (soft delete)
|
||
*/
|
||
exports.deleteVocab = async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
const where = isNaN(id) ? { vocab_code: id } : { vocab_id: parseInt(id) };
|
||
const vocab = await Vocab.findOne({ where });
|
||
|
||
if (!vocab) {
|
||
return res.status(404).json({
|
||
message: 'Vocabulary not found'
|
||
});
|
||
}
|
||
|
||
await vocab.update({ is_active: false });
|
||
|
||
res.json({
|
||
message: 'Vocabulary deleted successfully'
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error deleting vocab:', error);
|
||
res.status(500).json({
|
||
message: 'Error deleting vocabulary',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get vocabularies by curriculum (book, grade, unit, lesson)
|
||
*/
|
||
exports.getVocabsByCurriculum = async (req, res) => {
|
||
try {
|
||
const { book_id, grade, unit, lesson } = req.query;
|
||
|
||
if (!book_id && !grade) {
|
||
return res.status(400).json({
|
||
message: 'At least 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 vocabs = await Vocab.findAll({
|
||
where: { is_active: true },
|
||
include: [
|
||
{
|
||
model: VocabMapping,
|
||
as: 'mappings',
|
||
where: mappingWhere,
|
||
required: true
|
||
},
|
||
{
|
||
model: VocabForm,
|
||
as: 'forms'
|
||
},
|
||
{
|
||
model: VocabRelation,
|
||
as: 'relations'
|
||
}
|
||
],
|
||
order: [['vocab_code', 'ASC']]
|
||
});
|
||
|
||
const formattedVocabs = vocabs.map(vocab => formatVocabResponse(vocab));
|
||
|
||
res.json({
|
||
message: 'Vocabularies retrieved successfully',
|
||
data: formattedVocabs,
|
||
count: formattedVocabs.length
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error getting vocabs by curriculum:', error);
|
||
res.status(500).json({
|
||
message: 'Error retrieving vocabularies',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get vocabulary statistics
|
||
*/
|
||
exports.getVocabStats = async (req, res) => {
|
||
try {
|
||
const { sequelize } = Vocab;
|
||
|
||
// Total vocabs
|
||
const totalVocabs = await Vocab.count({ where: { is_active: true } });
|
||
|
||
// By category
|
||
const byCategory = await Vocab.findAll({
|
||
where: { is_active: true },
|
||
attributes: [
|
||
'category',
|
||
[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count']
|
||
],
|
||
group: ['category'],
|
||
raw: true
|
||
});
|
||
|
||
// By difficulty
|
||
const byDifficulty = await Vocab.findAll({
|
||
where: { is_active: true },
|
||
attributes: [
|
||
'difficulty_score',
|
||
[sequelize.fn('COUNT', sequelize.col('vocab_id')), 'count']
|
||
],
|
||
group: ['difficulty_score'],
|
||
order: [['difficulty_score', 'ASC']],
|
||
raw: true
|
||
});
|
||
|
||
// By grade (from mappings)
|
||
const byGrade = await VocabMapping.findAll({
|
||
attributes: [
|
||
'grade',
|
||
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('vocab_id'))), 'count']
|
||
],
|
||
group: ['grade'],
|
||
order: [['grade', 'ASC']],
|
||
raw: true
|
||
});
|
||
|
||
res.json({
|
||
message: 'Vocabulary statistics retrieved successfully',
|
||
data: {
|
||
total: totalVocabs,
|
||
by_category: byCategory,
|
||
by_difficulty: byDifficulty,
|
||
by_grade: byGrade
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error getting vocab stats:', error);
|
||
res.status(500).json({
|
||
message: 'Error retrieving statistics',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Get comprehensive guide for AI to create vocabulary entries
|
||
*/
|
||
exports.getVocabGuide = async (req, res) => {
|
||
try {
|
||
const guide = {
|
||
guide_version: "2.0.0",
|
||
last_updated: "2026-01-26",
|
||
description: "Comprehensive guide for AI to understand and create vocabulary entries for Grammar Engine",
|
||
|
||
data_structure: {
|
||
required_fields: {
|
||
vocab_code: {
|
||
type: "string",
|
||
format: "vocab-{sequence}-{base_word}",
|
||
example: "vocab-001-eat",
|
||
rule: "Must be unique, use 3-digit sequence number"
|
||
},
|
||
base_word: {
|
||
type: "string",
|
||
example: "eat",
|
||
rule: "Base form of the word in English"
|
||
},
|
||
translation: {
|
||
type: "string",
|
||
example: "ăn",
|
||
rule: "Vietnamese translation"
|
||
}
|
||
},
|
||
|
||
optional_fields: {
|
||
attributes: {
|
||
difficulty_score: {
|
||
type: "integer",
|
||
range: "1-10",
|
||
default: 1,
|
||
guide: "1-2: Basic, 3-4: Intermediate, 5-6: Advanced, 7-8: Difficult, 9-10: Expert"
|
||
},
|
||
category: {
|
||
type: "string",
|
||
options: ["Action Verbs", "Nouns", "Adjectives", "Adverbs", "Articles", "Pronouns", "Prepositions", "Conjunctions"],
|
||
example: "Action Verbs"
|
||
},
|
||
images: {
|
||
type: "array",
|
||
item_type: "string (URL)",
|
||
example: ["https://cdn.sena.tech/img/eat-main.png"]
|
||
},
|
||
tags: {
|
||
type: "array",
|
||
item_type: "string",
|
||
example: ["daily-routine", "verb", "food"]
|
||
}
|
||
},
|
||
|
||
mappings: {
|
||
type: "array of objects",
|
||
description: "Curriculum mapping - where this word appears in textbooks",
|
||
fields: {
|
||
book_id: { type: "string", example: "global-success-1" },
|
||
grade: { type: "integer", example: 1 },
|
||
unit: { type: "integer", example: 2 },
|
||
lesson: { type: "integer", example: 3 },
|
||
form_key: { type: "string", example: "v1", description: "Which form to use at this point" },
|
||
context_note: { type: "string", optional: true }
|
||
}
|
||
},
|
||
|
||
forms: {
|
||
type: "object",
|
||
description: "Different grammatical forms of the word",
|
||
structure: {
|
||
"{form_key}": {
|
||
text: { type: "string", example: "eat" },
|
||
phonetic: { type: "string", format: "IPA", example: "/iːt/" },
|
||
audio: { type: "string", format: "URL", example: "https://cdn.sena.tech/audio/eat_v1.mp3" },
|
||
min_grade: { type: "integer", example: 1, description: "Minimum grade to unlock" },
|
||
description: { type: "string", optional: true }
|
||
}
|
||
}
|
||
},
|
||
|
||
relations: {
|
||
type: "object",
|
||
fields: {
|
||
synonyms: { type: "array", example: ["consume", "dine"] },
|
||
antonyms: { type: "array", example: ["fast", "starve"] },
|
||
related: { type: "array", example: ["food", "meal"], optional: true }
|
||
}
|
||
},
|
||
|
||
syntax: {
|
||
type: "object",
|
||
description: "Syntax roles for Grammar Engine",
|
||
critical: true,
|
||
fields: {
|
||
is_subject: { type: "boolean", description: "Can be used as subject" },
|
||
is_verb: { type: "boolean", description: "Is a verb" },
|
||
is_object: { type: "boolean", description: "Can be used as object" },
|
||
is_be: { type: "boolean", description: "Is 'be' verb" },
|
||
is_adj: { type: "boolean", description: "Is adjective" },
|
||
is_adv: { type: "boolean", description: "Is adverb" },
|
||
is_article: { type: "boolean", description: "Is article (a/an/the)" },
|
||
is_pronoun: { type: "boolean", description: "Is pronoun" },
|
||
is_preposition: { type: "boolean", description: "Is preposition" },
|
||
verb_type: { type: "string", options: ["transitive", "intransitive", "linking"], when: "is_verb=true" },
|
||
article_type: { type: "string", options: ["definite", "indefinite"], when: "is_article=true" },
|
||
adv_type: { type: "string", options: ["manner", "frequency", "degree", "time", "place"], when: "is_adv=true" },
|
||
position: { type: "string", description: "Word position in sentence" },
|
||
priority: { type: "integer", description: "Selection priority for Grammar Engine" },
|
||
person: { type: "string", options: ["first", "second", "third"], when: "is_pronoun=true" },
|
||
number: { type: "string", options: ["singular", "plural"], when: "is_pronoun=true" }
|
||
}
|
||
},
|
||
|
||
semantics: {
|
||
type: "object",
|
||
description: "Semantic constraints to ensure meaningful sentences",
|
||
critical: true,
|
||
fields: {
|
||
can_be_subject_type: {
|
||
type: "array",
|
||
options: ["human", "animal", "object", "food", "plant", "abstract", "place", "time"],
|
||
when: "is_verb=true",
|
||
description: "What types can be subject with this verb"
|
||
},
|
||
can_take_object_type: {
|
||
type: "array",
|
||
options: ["human", "animal", "object", "food", "plant", "abstract", "place", "time"],
|
||
when: "verb_type=transitive",
|
||
description: "What types this verb can take as object"
|
||
},
|
||
can_modify: {
|
||
type: "array",
|
||
options: ["action_verb", "stative_verb", "be_verb", "adjective", "adverb", "noun"],
|
||
when: "is_adv=true",
|
||
description: "What this adverb can modify"
|
||
},
|
||
cannot_modify: {
|
||
type: "array",
|
||
options: ["action_verb", "stative_verb", "be_verb", "adjective", "adverb", "noun"],
|
||
when: "is_adv=true",
|
||
description: "What this adverb cannot modify"
|
||
},
|
||
word_type: {
|
||
type: "string",
|
||
options: ["action", "state", "entity", "property", "concept", "relation"],
|
||
required: true,
|
||
description: "Semantic type of the word"
|
||
},
|
||
is_countable: {
|
||
type: "boolean",
|
||
when: "is_object=true",
|
||
description: "Can this noun be counted"
|
||
},
|
||
person_type: {
|
||
type: "string",
|
||
options: ["1st", "2nd", "3rd"],
|
||
when: "is_pronoun=true"
|
||
}
|
||
}
|
||
},
|
||
|
||
constraints: {
|
||
type: "object",
|
||
description: "Grammar constraints for word combination",
|
||
fields: {
|
||
followed_by: {
|
||
type: "string",
|
||
options: ["vowel_sound", "consonant_sound", "any"],
|
||
when: "is_article=true",
|
||
description: "What sound type must follow"
|
||
},
|
||
match_subject: {
|
||
type: "object",
|
||
when: "is_be=true",
|
||
example: { "I": "am", "he": "is", "you": "are" },
|
||
description: "Subject-verb agreement rules"
|
||
},
|
||
match_with: {
|
||
type: "string",
|
||
description: "Must match with specific word type"
|
||
},
|
||
position_rules: {
|
||
type: "array",
|
||
description: "Possible positions in sentence"
|
||
},
|
||
requires_object: {
|
||
type: "boolean",
|
||
when: "verb_type=transitive"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
form_keys_reference: {
|
||
verbs: {
|
||
v1: "Base form (eat, run)",
|
||
v_s_es: "Third person singular (eats, runs)",
|
||
v_ing: "Present participle (eating, running)",
|
||
v2: "Past simple (ate, ran)",
|
||
v3: "Past participle (eaten, run)"
|
||
},
|
||
nouns: {
|
||
n_singular: "Singular form (cat, apple)",
|
||
n_plural: "Plural form (cats, apples)"
|
||
},
|
||
adjectives: {
|
||
adj_base: "Base form (big, happy)",
|
||
adj_comparative: "Comparative (bigger, happier)",
|
||
adj_superlative: "Superlative (biggest, happiest)"
|
||
},
|
||
adverbs: {
|
||
adv_base: "Base form (quickly, slowly)",
|
||
adv_comparative: "Comparative (more quickly)",
|
||
adv_superlative: "Superlative (most quickly)"
|
||
},
|
||
pronouns: {
|
||
subject: "Subject pronoun (I, you, he)",
|
||
object: "Object pronoun (me, you, him)"
|
||
}
|
||
},
|
||
|
||
rules: {
|
||
vocab_code_format: "Must follow pattern: vocab-{3-digit-number}-{base_word}",
|
||
phonetic_format: "Use IPA notation enclosed in /slashes/",
|
||
audio_url_format: "Must be valid HTTPS URL pointing to MP3 or OGG file",
|
||
|
||
semantic_compatibility: {
|
||
rule: "Ensure semantic constraints create meaningful sentences",
|
||
examples: {
|
||
valid: "human eats food (human can eat, food can be eaten)",
|
||
invalid: "table eats book (table cannot eat, book is not food)"
|
||
}
|
||
},
|
||
|
||
article_selection: {
|
||
rule: "Use phonetic to determine a/an",
|
||
algorithm: "Check first phoneme: if vowel sound use 'an', else use 'a'",
|
||
examples: {
|
||
an: "apple (/ˈæp.əl/ starts with /æ/)",
|
||
a: "cat (/kæt/ starts with /k/)"
|
||
}
|
||
},
|
||
|
||
be_verb_agreement: {
|
||
rule: "Match be verb form with subject",
|
||
mapping: {
|
||
"I": "am",
|
||
"you": "are",
|
||
"he/she/it": "is",
|
||
"we/they": "are"
|
||
}
|
||
}
|
||
},
|
||
|
||
examples: {
|
||
transitive_verb: {
|
||
vocab_code: "vocab-001-eat",
|
||
base_word: "eat",
|
||
translation: "ăn",
|
||
attributes: {
|
||
difficulty_score: 1,
|
||
category: "Action Verbs",
|
||
tags: ["verb", "action", "daily-routine"]
|
||
},
|
||
forms: {
|
||
v1: { text: "eat", phonetic: "/iːt/", min_grade: 1 },
|
||
v_s_es: { text: "eats", phonetic: "/iːts/", min_grade: 2 },
|
||
v_ing: { text: "eating", phonetic: "/ˈiː.tɪŋ/", min_grade: 2 },
|
||
v2: { text: "ate", phonetic: "/eɪt/", min_grade: 3 }
|
||
},
|
||
syntax: {
|
||
is_verb: true,
|
||
verb_type: "transitive",
|
||
is_subject: false,
|
||
is_object: false
|
||
},
|
||
semantics: {
|
||
can_be_subject_type: ["human", "animal"],
|
||
can_take_object_type: ["food", "plant"],
|
||
word_type: "action"
|
||
},
|
||
constraints: {
|
||
requires_object: true
|
||
}
|
||
},
|
||
|
||
article: {
|
||
vocab_code: "vocab-art-01",
|
||
base_word: "a",
|
||
translation: "một (mạo từ bất định)",
|
||
attributes: {
|
||
difficulty_score: 1,
|
||
category: "Articles",
|
||
tags: ["article", "function-word", "grammar"]
|
||
},
|
||
forms: {
|
||
base: { text: "a", phonetic: "/ə/", min_grade: 1 }
|
||
},
|
||
syntax: {
|
||
is_article: true,
|
||
article_type: "indefinite",
|
||
priority: 1
|
||
},
|
||
constraints: {
|
||
followed_by: "consonant_sound"
|
||
}
|
||
},
|
||
|
||
adverb_manner: {
|
||
vocab_code: "vocab-adv-01",
|
||
base_word: "quickly",
|
||
translation: "nhanh chóng",
|
||
attributes: {
|
||
difficulty_score: 2,
|
||
category: "Adverbs",
|
||
tags: ["adverb", "manner"]
|
||
},
|
||
forms: {
|
||
base: { text: "quickly", phonetic: "/ˈkwɪk.li/", min_grade: 2 }
|
||
},
|
||
syntax: {
|
||
is_adv: true,
|
||
adv_type: "manner",
|
||
position: "after_verb"
|
||
},
|
||
semantics: {
|
||
can_modify: ["action_verb"],
|
||
cannot_modify: ["stative_verb", "be_verb"],
|
||
word_type: "property"
|
||
}
|
||
},
|
||
|
||
noun: {
|
||
vocab_code: "vocab-200-apple",
|
||
base_word: "apple",
|
||
translation: "quả táo",
|
||
attributes: {
|
||
difficulty_score: 1,
|
||
category: "Nouns",
|
||
tags: ["noun", "food", "fruit"]
|
||
},
|
||
forms: {
|
||
n_singular: { text: "apple", phonetic: "/ˈæp.əl/", min_grade: 1 },
|
||
n_plural: { text: "apples", phonetic: "/ˈæp.əlz/", min_grade: 1 }
|
||
},
|
||
syntax: {
|
||
is_subject: true,
|
||
is_object: true,
|
||
is_verb: false
|
||
},
|
||
semantics: {
|
||
word_type: "entity",
|
||
is_countable: true
|
||
}
|
||
},
|
||
|
||
pronoun: {
|
||
vocab_code: "vocab-pron-01",
|
||
base_word: "I",
|
||
translation: "tôi",
|
||
attributes: {
|
||
difficulty_score: 1,
|
||
category: "Pronouns",
|
||
tags: ["pronoun", "personal"]
|
||
},
|
||
forms: {
|
||
subject: { text: "I", phonetic: "/aɪ/", min_grade: 1 },
|
||
object: { text: "me", phonetic: "/miː/", min_grade: 1 }
|
||
},
|
||
syntax: {
|
||
is_subject: true,
|
||
is_pronoun: true,
|
||
pronoun_type: "personal",
|
||
person: "first",
|
||
number: "singular"
|
||
},
|
||
semantics: {
|
||
person_type: "1st",
|
||
word_type: "entity"
|
||
},
|
||
constraints: {
|
||
match_subject: { "I": "am" }
|
||
}
|
||
}
|
||
},
|
||
|
||
validation_checklist: {
|
||
before_submit: [
|
||
"✓ vocab_code follows format vocab-XXX-{word}",
|
||
"✓ All phonetic notations use IPA format with /slashes/",
|
||
"✓ At least one form is defined in 'forms' object",
|
||
"✓ syntax object has at least one role set to true",
|
||
"✓ semantics.word_type is specified",
|
||
"✓ If is_verb=true, verb_type is specified",
|
||
"✓ If verb_type=transitive, can_take_object_type is specified",
|
||
"✓ If is_article=true, constraints.followed_by is specified",
|
||
"✓ If is_adv=true, can_modify array is specified"
|
||
]
|
||
},
|
||
|
||
common_mistakes: [
|
||
{
|
||
mistake: "Not setting any syntax role",
|
||
fix: "Set at least one is_{role} to true"
|
||
},
|
||
{
|
||
mistake: "Using 'a' before vowel sound words",
|
||
fix: "Check phonetic - if starts with vowel sound, use 'an'"
|
||
},
|
||
{
|
||
mistake: "Transitive verb without can_take_object_type",
|
||
fix: "Specify what types this verb can take as object"
|
||
},
|
||
{
|
||
mistake: "Missing word_type in semantics",
|
||
fix: "Always specify: action, state, entity, property, concept, or relation"
|
||
},
|
||
{
|
||
mistake: "Incorrect phonetic format",
|
||
fix: "Use IPA notation: /iːt/ not 'eet' or 'eat'"
|
||
}
|
||
],
|
||
|
||
ai_tips: {
|
||
efficiency: "Create related words together (eat, eats, eating) to maintain consistency",
|
||
accuracy: "Double-check phonetic transcription using Cambridge Dictionary or similar",
|
||
completeness: "Fill all relevant fields - more data means better Grammar Engine performance",
|
||
testing: "After creating words, test sentence generation to verify semantic constraints work",
|
||
documentation: "Use descriptive context_note in mappings to help future AI understand usage"
|
||
}
|
||
};
|
||
|
||
res.json({
|
||
message: 'Vocabulary guide retrieved successfully',
|
||
data: guide
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error getting vocab guide:', error);
|
||
res.status(500).json({
|
||
message: 'Error retrieving guide',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
// Helper functions
|
||
|
||
/**
|
||
* Get complete vocabulary data with all relations
|
||
*/
|
||
async function getCompleteVocab(vocab_id) {
|
||
const vocab = await Vocab.findByPk(vocab_id, {
|
||
include: [
|
||
{ model: VocabMapping, as: 'mappings' },
|
||
{ model: VocabForm, as: 'forms' },
|
||
{ model: VocabRelation, as: 'relations' }
|
||
]
|
||
});
|
||
return formatVocabResponse(vocab);
|
||
}
|
||
|
||
/**
|
||
* Format vocabulary response to match the expected structure
|
||
*/
|
||
function formatVocabResponse(vocab) {
|
||
const vocabJson = vocab.toJSON();
|
||
|
||
// Format attributes
|
||
const attributes = {
|
||
difficulty_score: vocabJson.difficulty_score,
|
||
category: vocabJson.category,
|
||
images: vocabJson.images || [],
|
||
tags: vocabJson.tags || []
|
||
};
|
||
|
||
// Format forms as object keyed by form_key
|
||
const forms = {};
|
||
if (vocabJson.forms) {
|
||
vocabJson.forms.forEach(form => {
|
||
forms[form.form_key] = {
|
||
text: form.text,
|
||
phonetic: form.phonetic,
|
||
audio: form.audio_url,
|
||
min_grade: form.min_grade,
|
||
description: form.description
|
||
};
|
||
});
|
||
}
|
||
|
||
// Format relations grouped by type
|
||
const relations = {
|
||
synonyms: [],
|
||
antonyms: [],
|
||
related: []
|
||
};
|
||
if (vocabJson.relations) {
|
||
vocabJson.relations.forEach(rel => {
|
||
if (rel.relation_type === 'synonym') {
|
||
relations.synonyms.push(rel.related_word);
|
||
} else if (rel.relation_type === 'antonym') {
|
||
relations.antonyms.push(rel.related_word);
|
||
} else if (rel.relation_type === 'related') {
|
||
relations.related.push(rel.related_word);
|
||
}
|
||
});
|
||
}
|
||
|
||
return {
|
||
id: vocabJson.vocab_code,
|
||
vocab_id: vocabJson.vocab_id,
|
||
base_word: vocabJson.base_word,
|
||
translation: vocabJson.translation,
|
||
attributes,
|
||
mappings: vocabJson.mappings || [],
|
||
forms,
|
||
relations,
|
||
syntax: vocabJson.syntax || {},
|
||
semantics: vocabJson.semantics || {},
|
||
constraints: vocabJson.constraints || {}
|
||
};
|
||
}
|