Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions back/bots/migrations/0035_deck_flashcard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 6.0.3 on 2026-04-16 05:30

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


class Migration(migrations.Migration):

dependencies = [
('bots', '0034_profile_oauth_email'),
]

operations = [
migrations.CreateModel(
name='Deck',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('deck_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('chat', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='decks', to='bots.chat')),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='decks', to='bots.profile')),
],
),
migrations.CreateModel(
name='Flashcard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flashcard_id', models.UUIDField(default=uuid.uuid4, unique=True)),
('front', models.TextField()),
('back', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deck', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='flashcards', to='bots.deck')),
],
options={
'indexes': [models.Index(fields=['deck', 'order'], name='bots_flashc_deck_id_a95ed4_idx')],
'constraints': [models.UniqueConstraint(fields=('deck', 'order'), name='unique_flashcard_order_per_deck')],
},
),
]
5 changes: 4 additions & 1 deletion back/bots/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .usage_limit_hit import UsageLimitHit
from .ai_model import AiModel
from .device import Device
from .flashcard import Deck, Flashcard

__all__ = [
'Chat',
Expand All @@ -16,5 +17,7 @@
'UsageLimitHit',
'AiModel',
'Device',
'RevenueCatWebhookEvent'
'RevenueCatWebhookEvent',
'Deck',
'Flashcard',
]
74 changes: 72 additions & 2 deletions back/bots/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .profile import Profile
from .bot import Bot
from .ai_model import AiModel
from .flashcard import Deck, Flashcard

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -114,17 +115,82 @@ def web_search(query: str) -> str:
logger.error(f"🔍 WEB_SEARCH_ERROR: {str(e)}")
return f"Error during search: {str(e)}"

# Create flashcard tools outside the web_search if-block for reuse
@tool
def create_flashcard_deck(name: str, description: str = "", flashcards: list = None) -> str:
"""Create a new flashcard deck with flashcards. Use this when the user wants to create flashcards for studying.

Args:
name: The name of the deck (e.g., "Biology Test Terms")
description: Optional description of the deck
flashcards: Optional list of flashcards, each with 'front' and 'back' keys
"""
logger.info(f"🃏 CREATE_FLASHCARD_DECK_TOOL_INVOKED: name='{name}'")
try:
deck = Deck.objects.create(
profile=self.profile,
chat=self,
name=name,
description=description or ""
)
created_cards = 0
if flashcards:
for i, card in enumerate(flashcards):
Flashcard.objects.create(
deck=deck,
front=card.get('front', ''),
back=card.get('back', ''),
order=i
)
created_cards += 1
logger.info(f"🃏 CREATE_FLASHCARD_DECK_SUCCESS: deck_id={deck.deck_id}, cards={created_cards}")
return f"Created deck '{name}' with {created_cards} flashcards. Deck ID: {deck.deck_id}"
except Exception as e:
logger.error(f"🃏 CREATE_FLASHCARD_DECK_ERROR: {str(e)}")
return f"Error creating deck: {str(e)}"

@tool
def create_flashcard(deck_name: str, front: str, back: str) -> str:
"""Add a single flashcard to an existing deck or create a new deck. Use this when the user wants to add flashcards to study.

Args:
deck_name: The name of the deck to add the card to
front: The front of the flashcard (question/term)
back: The back of the flashcard (answer/definition)
"""
logger.info(f"🃏 CREATE_FLASHCARD_TOOL_INVOKED: deck_name='{deck_name}'")
try:
deck = Deck.objects.filter(profile=self.profile, name=deck_name).first()
if not deck:
deck = Deck.objects.create(
profile=self.profile,
chat=self,
name=deck_name,
description=""
)
max_order = Flashcard.objects.filter(deck=deck).aggregate(models.Max('order'))['order__max'] or -1
Flashcard.objects.create(
deck=deck,
front=front,
back=back,
order=max_order + 1
)
logger.info(f"🃏 CREATE_FLASHCARD_SUCCESS: deck={deck.name}")
return f"Added flashcard to deck '{deck_name}'. Deck ID: {deck.deck_id}"
except Exception as e:
logger.error(f"🃏 CREATE_FLASHCARD_ERROR: {str(e)}")
return f"Error creating flashcard: {str(e)}"

# Create chat model with tool binding
chat_model = ChatBedrock(model_id=self.ai.model_id)
tools = [web_search]
tools = [web_search, create_flashcard_deck, create_flashcard]

# Bind tools to the model for proper tool calling
model_with_tools = chat_model.bind_tools(tools)

# Use the full message_list which already contains conversation history
logger.info(f"Invoking agent with full context ({len(message_list)} messages)")
logger.info("🤖 AGENT_INVOKE_START: web_search tool available")
logger.info("🤖 AGENT_INVOKE_START: web_search and flashcard tools available")

# Build and run the agent loop manually with full conversation context
messages = message_list
Expand Down Expand Up @@ -155,6 +221,10 @@ def web_search(query: str) -> str:
# Execute the tool
if tool_name == "web_search":
tool_result = web_search.invoke(tool_args)
elif tool_name == "create_flashcard_deck":
tool_result = create_flashcard_deck.invoke(tool_args)
elif tool_name == "create_flashcard":
tool_result = create_flashcard.invoke(tool_args)
else:
tool_result = f"Unknown tool: {tool_name}"

Expand Down
30 changes: 30 additions & 0 deletions back/bots/models/flashcard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.db import models
import uuid


class Deck(models.Model):
deck_id = models.UUIDField(default=uuid.uuid4, unique=True)
profile = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='decks')
chat = models.ForeignKey('Chat', on_delete=models.SET_NULL, null=True, blank=True, related_name='decks')
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)


class Flashcard(models.Model):
flashcard_id = models.UUIDField(default=uuid.uuid4, unique=True)
deck = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name='flashcards')
front = models.TextField()
back = models.TextField()
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
constraints = [
models.UniqueConstraint(fields=["deck", "order"], name="unique_flashcard_order_per_deck")
]
indexes = [
models.Index(fields=["deck", "order"])
]
4 changes: 4 additions & 0 deletions back/bots/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .ai_model_serializer import AiModelSerializer
from .chat_serializer import ChatSerializer, ChatListSerializer
from .message_serializer import MessageSerializer
from .flashcard_serializer import FlashcardSerializer, DeckSerializer, DeckListSerializer

__all__ = [
'BotSerializer',
Expand All @@ -14,4 +15,7 @@
'ChatSerializer',
'ChatListSerializer',
'MessageSerializer',
'FlashcardSerializer',
'DeckSerializer',
'DeckListSerializer',
]
31 changes: 31 additions & 0 deletions back/bots/serializers/flashcard_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from rest_framework import serializers
from bots.models import Deck, Flashcard


class FlashcardSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Flashcard
fields = ['id', 'flashcard_id', 'deck', 'front', 'back', 'order', 'created_at', 'updated_at']


class DeckSerializer(serializers.HyperlinkedModelSerializer):
flashcards = FlashcardSerializer(many=True, read_only=True)
card_count = serializers.SerializerMethodField()

class Meta:
model = Deck
fields = ['id', 'deck_id', 'profile', 'chat', 'name', 'description', 'flashcards', 'card_count', 'created_at', 'updated_at']

def get_card_count(self, obj):
return obj.flashcards.count()


class DeckListSerializer(serializers.HyperlinkedModelSerializer):
card_count = serializers.SerializerMethodField()

class Meta:
model = Deck
fields = ['id', 'deck_id', 'name', 'description', 'card_count', 'created_at', 'updated_at']

def get_card_count(self, obj):
return obj.flashcards.count()
3 changes: 2 additions & 1 deletion back/bots/viewsets/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .chat_viewset import ChatViewSet
from .flashcard_viewset import DeckViewSet, FlashcardViewSet

__all__ = ['ChatViewSet']
__all__ = ['ChatViewSet', 'DeckViewSet', 'FlashcardViewSet']
105 changes: 105 additions & 0 deletions back/bots/viewsets/flashcard_viewset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from rest_framework import viewsets
from django.db.models import Count, Max
import uuid
from bots.models import Deck, Flashcard, Profile
from bots.permissions import IsOwner
from bots.serializers import FlashcardSerializer, DeckSerializer, DeckListSerializer


class FlashcardViewSet(viewsets.ModelViewSet):
permission_classes = [IsOwner]
serializer_class = FlashcardSerializer
queryset = Flashcard.objects.all()

def get_queryset(self):
deck_id = self.kwargs['deck_pk']

try:
deck_uuid = uuid.UUID(deck_id)
deck = Deck.objects.get(deck_id=deck_uuid)
except ValueError:
deck = Deck.objects.get(id=deck_id)

self.check_object_permissions(self.request, deck)

return Flashcard.objects.filter(deck=deck).order_by('order')

def perform_create(self, serializer):
deck_id = self.kwargs['deck_pk']
try:
deck_uuid = uuid.UUID(deck_id)
deck = Deck.objects.get(deck_id=deck_uuid)
except ValueError:
deck = Deck.objects.get(id=deck_id)

max_order = Flashcard.objects.filter(deck=deck).aggregate(Max('order'))['order__max'] or -1
serializer.save(deck=deck, order=max_order + 1)


class DeckViewSet(viewsets.ModelViewSet):
permission_classes = [IsOwner]
serializer_class = DeckSerializer
queryset = Deck.objects.all()

def get_queryset(self):
user = self.request.user
profile_id = self.request.query_params.get('profileId')

queryset = Deck.objects.filter(profile__user=user)

if profile_id:
try:
profile_uuid = uuid.UUID(profile_id)
queryset = queryset.filter(profile__profile_id=profile_uuid)
except ValueError:
queryset = queryset.none()

return queryset.annotate(card_count=Count('flashcards')).order_by('-created_at')

def get_serializer_class(self):
if self.action == 'list':
return DeckListSerializer
return DeckSerializer

def get_object(self):
lookup_field_value = self.kwargs[self.lookup_field]

try:
deck_uuid = uuid.UUID(lookup_field_value)
deck = Deck.objects.get(deck_id=deck_uuid)
except ValueError:
deck = Deck.objects.get(id=lookup_field_value)

self.check_object_permissions(self.request, deck)
return deck

def perform_create(self, serializer):
user = self.request.user
profile_id = self.request.data.get('profile')
chat_id = self.request.data.get('chat')

if profile_id:
try:
profile_uuid = uuid.UUID(profile_id)
profile = Profile.objects.get(profile_id=profile_uuid, user=user)
except (ValueError, Profile.DoesNotExist):
profile = Profile.objects.filter(user=user).first()
else:
profile = Profile.objects.filter(user=user).first()

chat = None
if chat_id:
try:
chat_uuid = uuid.UUID(chat_id)
from bots.models import Chat
chat = Chat.objects.get(chat_id=chat_uuid, user=user)
except (ValueError, Chat.DoesNotExist):
chat = None

serializer.save(profile=profile, chat=chat)

def perform_update(self, serializer):
serializer.save()

def perform_destroy(self, instance):
instance.delete()
5 changes: 5 additions & 0 deletions back/server/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from bots.viewsets.bot_viewset import BotViewSet
from bots.viewsets.ai_model_viewset import AiModelViewSet
from bots.viewsets.device_viewset import DeviceViewSet
from bots.viewsets.flashcard_viewset import DeckViewSet, FlashcardViewSet
from bots.views.get_chat_response import get_chat_response
from bots.views.get_jwt import get_jwt, start_web_login
from bots.views.user_account_view import DeleteUserAccountView, user_account_view
Expand All @@ -42,10 +43,14 @@
router.register(r'bots', BotViewSet)
router.register(r'ai_models', AiModelViewSet)
router.register(r'devices', DeviceViewSet)
router.register(r'decks', DeckViewSet)

chats_router = NestedDefaultRouter(router, r'chats', lookup='chat')
chats_router.register(r'messages', MessageViewSet, basename='chat-messages')

decks_router = NestedDefaultRouter(router, r'decks', lookup='deck')
decks_router.register(r'flashcards', FlashcardViewSet, basename='deck-flashcards')


favicon_view = RedirectView.as_view(url='/static/favicon.ico', permanent=True)

Expand Down
Loading
Loading