Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7b569d9
Update: Create AI core for AI service and a basic call API for testing
nat50 Feb 4, 2026
369fa41
Add database configuration for Neon PostgresSQL
Kryslogem Feb 7, 2026
11a888e
Refactor: restructure ai_core with base LLM service and lesson genera…
nat50 Feb 7, 2026
44895ba
Merge pull request #1 from nat50/Kryslogem-patch-1
Kryslogem Feb 7, 2026
b5ea95a
Merge pull request #2 from nat50/ai_core
nat50 Feb 8, 2026
f7f2d58
Add quiz generation service and convert prompts to English
nat50 Feb 11, 2026
24187ad
Merge pull request #3 from nat50/ai_core
nat50 Feb 11, 2026
b5ed671
Switch database from SQLite to PostgreSQL
Kryslogem Feb 14, 2026
560c632
Modify email backend settings in .env.example
Kryslogem Feb 14, 2026
c2fad1c
Add Cloudinary as cloud storage
Kryslogem Feb 14, 2026
22e9924
Integrate Cloudinary for media file storage
Kryslogem Feb 14, 2026
0dba6f5
UI upload for AI, loader and save to database
Ngan-Pham Feb 20, 2026
9d986b9
Merge pull request #5 from nat50/UI_upload_loader
nat50 Feb 21, 2026
cf9b26f
Delete UI upload for AI
Ngan-Pham Feb 21, 2026
e051372
Create UI for generate lesson
Ngan-Pham Feb 21, 2026
7aa7258
Merge pull request #6 from nat50/UI_generate_lesson
nat50 Feb 26, 2026
381499c
Add quiz generate + quiz management
Planterian Feb 26, 2026
de99ac7
Merge pull request #7 from nat50/QuizGenerate-And-QuizEdit
nat50 Feb 28, 2026
a473c5f
Update dependencies: update new version of packages
nat50 Feb 28, 2026
5153de8
feat: Add HTML lesson viewer
nat50 Feb 28, 2026
5554129
fix: vietnamese font
nat50 Feb 28, 2026
32e080b
Chore: separate context and requirement in AI lesson generated
nat50 Feb 28, 2026
c886054
refactor: remove course level field and related logic
nat50 Mar 1, 2026
191e6fd
chore: update gemini 3
nat50 Mar 1, 2026
43adff9
feat): add RAG pipeline for document-based lesson generation
nat50 Mar 1, 2026
6f28496
fix prompt
Ngan-Pham Mar 2, 2026
e7fd223
Merge pull request #8 from nat50/fix-prompt
nat50 Mar 5, 2026
45f3959
Cloudinary_connect
Planterian Mar 7, 2026
2c88041
update env.example
Planterian Mar 7, 2026
ab6c27e
fix: default student level to Bachelor and hide level in UI
nat50 Mar 9, 2026
7050ba7
feat: add AI chatbot floating widget
nat50 Mar 9, 2026
37e0eb3
Update base.html
Ngan-Pham Mar 9, 2026
8b30078
Change Name and Icon
Ngan-Pham Mar 14, 2026
75b14c9
Merge pull request #11 from nat50/update-web-name
nat50 Mar 14, 2026
3ca4fb2
Merge pull request #10 from nat50/remove-chatbot-from-quiz
nat50 Mar 14, 2026
db403e7
Merge branch 'main' into database
Planterian Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
# =============================
# Email config

# For development or quick testing use console as the email backend
# This line is the "magic" fix. It tells Django to print emails to the terminal
# instead of trying to send them through a real server.
EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend"
# For production use smtp, uncomment the below variable
# EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend"

EMAIL_HOST="smtp.gmail.com"
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_FROM_ADDRESS="SkyLearn <youremail@example.com>"
EMAIL_HOST_USER="<youremail@example.com>"
EMAIL_HOST_PASSWORD="<your email password>"

# These two must exist for your config('NAME') calls to work
EMAIL_HOST_USER="youremail@example.com"
EMAIL_HOST_PASSWORD="yourpassword"

# =============================
# Other
# Database Neon - PostgresSQL
DB_ENGINE=django.db.backends.postgresql
DB_NAME=neondb
DB_USER=neondb_owner
DB_PASSWORD=npg_B0Hujnsh8zEv
DB_HOST=ep-gentle-shape-a1unb4h2-pooler.ap-southeast-1.aws.neon.tech
DB_PORT=5432

# =============================
# Cloudinary Storage - Cloudinary
CLOUDINARY_CLOUD_NAME=ddr6xe1vn
CLOUDINARY_API_KEY=781526684325383
CLOUDINARY_API_SECRET=F1IcPCOHWkItagYigXGm-3j6VHc

# =============================
# Other
DEBUG=True
SECRET_KEY="<your_secret_key>"
SECRET_KEY="any-random-string-here"
11 changes: 1 addition & 10 deletions accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,6 @@ class StudentAddForm(UserCreationForm):
),
)

level = forms.CharField(
widget=forms.Select(
choices=LEVEL,
attrs={
"class": "browser-default custom-select form-control",
},
),
)

program = forms.ModelChoiceField(
queryset=Program.objects.all(),
widget=forms.Select(
Expand Down Expand Up @@ -266,7 +257,7 @@ def save(self, commit=True):
user.save()
Student.objects.create(
student=user,
level=self.cleaned_data.get("level"),
level="Bachelor",
program=self.cleaned_data.get("program"),
)

Expand Down
4 changes: 2 additions & 2 deletions accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def profile(request):
student = get_object_or_404(Student, student__pk=request.user.id)
parent = Parent.objects.filter(student=student).first()
courses = TakenCourse.objects.filter(
student__student__id=request.user.id, course__level=student.level
student__student__id=request.user.id
)
context.update(
{
Expand Down Expand Up @@ -146,7 +146,7 @@ def profile_single(request, user_id):
elif user.is_student:
student = get_object_or_404(Student, student__pk=user_id)
courses = TakenCourse.objects.filter(
student__student__id=user_id, course__level=student.level
student__student__id=user_id
)
context.update(
{
Expand Down
Empty file added ai_core/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions ai_core/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions ai_core/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class AiCoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ai_core'
27 changes: 27 additions & 0 deletions ai_core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.0.8 on 2026-02-20 17:01

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='AIGeneration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('topic', models.CharField(blank=True, max_length=255, null=True)),
('prompt', models.TextField(blank=True, null=True)),
('document_file', models.FileField(blank=True, null=True, upload_to='ai_docs/')),
('html_content', models.TextField(blank=True, null=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('SUCCESS', 'Success'), ('FAILED', 'Failed')], default='PENDING', max_length=20)),
('error_message', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]
60 changes: 60 additions & 0 deletions ai_core/migrations/0002_documentchunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Generated by Django 4.2.28 on 2026-03-01 09:04

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("course", "0006_remove_course_level"),
("ai_core", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="DocumentChunk",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"chunk_index",
models.IntegerField(
help_text="Zero-based position of this chunk within the document."
),
),
(
"content",
models.TextField(help_text="Plain text content of this chunk."),
),
(
"embedding",
models.JSONField(
blank=True,
help_text="Embedding vector as a JSON list of floats.",
null=True,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"upload",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="chunks",
to="course.upload",
),
),
],
options={
"ordering": ["upload", "chunk_index"],
"unique_together": {("upload", "chunk_index")},
},
),
]
Empty file added ai_core/migrations/__init__.py
Empty file.
58 changes: 58 additions & 0 deletions ai_core/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from django.db import models

# Create your models here.
class AIGeneration(models.Model):
topic = models.CharField(max_length=255, null=True, blank=True)
prompt = models.TextField(null=True, blank=True)

document_file = models.FileField(upload_to="ai_docs/", null=True, blank=True)

html_content = models.TextField(null=True, blank=True)

status = models.CharField(
max_length=20,
choices=[
("PENDING", "Pending"),
("PROCESSING", "Processing"),
("SUCCESS", "Success"),
("FAILED", "Failed"),
],
default="PENDING"
)

error_message = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)


class DocumentChunk(models.Model):
"""Cached text chunk with embedding vector from an uploaded document.

Each Upload can have many chunks. Chunks are created lazily when a
document is first selected as RAG context for lesson generation.
Deleting the parent Upload cascades to remove all its chunks.
"""

upload = models.ForeignKey(
"course.Upload",
on_delete=models.CASCADE,
related_name="chunks",
)
chunk_index = models.IntegerField(
help_text="Zero-based position of this chunk within the document."
)
content = models.TextField(
help_text="Plain text content of this chunk."
)
embedding = models.JSONField(
null=True,
blank=True,
help_text="Embedding vector as a JSON list of floats.",
)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ["upload", "chunk_index"]
unique_together = ["upload", "chunk_index"]

def __str__(self) -> str:
return f"Chunk {self.chunk_index} of Upload #{self.upload_id}"
7 changes: 7 additions & 0 deletions ai_core/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .base import BaseLLMService
from .gemini import GeminiService
from .lesson import LessonService
from .quiz import QuizGenerationError, QuizService
from .chatbot import ChatbotService
from .document_parser import extract_text, chunk_text
from .rag import embed_and_store_chunks, search_chunks
32 changes: 32 additions & 0 deletions ai_core/services/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Base LLM service interface."""
import logging
from abc import ABC, abstractmethod
from langchain_core.messages import HumanMessage, SystemMessage

logger = logging.getLogger("ai_core")


class BaseLLMService(ABC):
"""Abstract base for all LLM providers."""

@abstractmethod
def _build_llm(self):
"""Return a LangChain chat model instance."""
pass

def __init__(self):
self.llm = self._build_llm()
logger.info(f"{self.__class__.__name__} initialized")

def chat(self, message: str, system_prompt: str = None) -> str:
"""Send a message and return the response text."""
messages = []
if system_prompt:
messages.append(SystemMessage(content=system_prompt))
messages.append(HumanMessage(content=message))

logger.info(f"[REQUEST] {message[:100]}...")
response = self.llm.invoke(messages)
logger.info(f"[RESPONSE] {response.content[:200]}...")

return response.content
63 changes: 63 additions & 0 deletions ai_core/services/chatbot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Chatbot service – conversational AI assistant for the LMS."""
import logging
from .base import BaseLLMService

logger = logging.getLogger("ai_core")

CHATBOT_SYSTEM_PROMPT = (
"You are SkyLearn AI Assistant, a friendly and knowledgeable virtual tutor "
"for an online learning management system called SkyLearn.\n\n"
"RULES:\n"
"1. Answer in the SAME language the user writes in.\n"
"2. Be concise but helpful. Use short paragraphs.\n"
"3. You can help with:\n"
" - Explaining academic concepts across all subjects\n"
" - Answering questions about courses, quizzes, grades\n"
" - Giving study tips and learning strategies\n"
" - Helping with assignment or homework guidance (do NOT give direct answers)\n"
" - General questions about using the SkyLearn platform\n"
"4. Format responses in plain text. Use bullet points or numbered lists when helpful.\n"
"5. If the user asks something harmful, unethical, or completely unrelated to "
"education, politely decline.\n"
"6. Keep responses under 300 words unless more detail is explicitly requested.\n"
"7. Be encouraging and supportive in tone."
)


class ChatbotService:
"""Conversational assistant powered by an LLM provider."""

def __init__(self, llm_service: BaseLLMService):
self.llm_service = llm_service

def reply(self, message: str, history: list[dict] | None = None) -> str:
"""Generate a chatbot reply.

Args:
message: The user's latest message.
history: Optional list of previous messages, each with
``{"role": "user"|"assistant", "content": "..."}``.
Used to build multi-turn context.

Returns:
The assistant's reply as plain text.
"""
prompt = self._build_prompt(message, history)
return self.llm_service.chat(prompt, system_prompt=CHATBOT_SYSTEM_PROMPT)

# ------------------------------------------------------------------

@staticmethod
def _build_prompt(message: str, history: list[dict] | None) -> str:
"""Build the user message with optional conversation history."""
if not history:
return message

# Include recent history (last 10 turns) to stay within token limits
recent = history[-10:]
parts = []
for turn in recent:
role = "User" if turn["role"] == "user" else "Assistant"
parts.append(f"{role}: {turn['content']}")
parts.append(f"User: {message}")
return "\n".join(parts)
Loading