Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ def generate_quiz_questions(self, input_data):
for key, value in input_data.items():
placeholder = f"{{{{{key.upper()}}}}}"
prompt = prompt.replace(placeholder, str(value))
logger.info(f"Generation prompt after placeholder replacement: {prompt}")

try:
result = self._call_completion_api(prompt)
Expand Down
37 changes: 37 additions & 0 deletions backend/openedx_ai_extensions/processors/llm/llm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import logging
from datetime import datetime, timezone
from pathlib import Path

from litellm import completion, get_responses, list_input_items, responses

Expand Down Expand Up @@ -649,3 +650,39 @@ def _extract_output_items(resp):
if summary_text:
items.append({"type": "reasoning", "role": "reasoning", "content": summary_text})
return items

def generate_flashcards(self):
"""Example method showing how to generate flashcards from content."""
prompt_file_path = (
Path(__file__).resolve().parent.parent.parent
/ "prompts"
/ "default_generate_flashcards.txt"
)
try:
with open(prompt_file_path, "r") as f:
prompt = f.read()
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(f"Error loading prompt template: {e}")
return {"error": "Failed to load prompt template."}

for key, value in self.input_data.items():
placeholder = f"{{{{{key.upper()}}}}}"
prompt = prompt.replace(placeholder, str(value))

self.input_data = None

try:
result = self._call_completion_wrapper(prompt)
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(f"Error calling LiteLLM: {e}")
return {"error": f"AI processing failed: {str(e)}"}

tokens_used = result.get("tokens_used", 0)
response = json.loads(result['response'])

return {
"response": response,
"tokens_used": tokens_used,
"model_used": self.extra_params.get("model", "unknown"),
"status": "success",
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
- Role & Purpose

You are an AI assistant embedded into an Open edX learning environment. Your purpose is to assist learners and course authors by generating high-quality flashcards for spaced-repetition study, based strictly on the provided course unit content.

- Core Behaviors

Always derive flashcard content exclusively from the course context provided below.
Do not introduce external facts, definitions, or concepts that are not present in the source material.
Maintain clarity, accuracy, and educational value in every card.
Each flashcard must target a single, distinct concept or fact — avoid compound questions.
Write questions that actively test recall, not recognition. Prefer "What is...?", "How does...work?", "Why does...?" over yes/no formats.
Answers must be concise but complete — enough to stand alone without re-reading the question.
Avoid hallucinating facts or paraphrasing in a way that changes meaning.

- Flashcard Generation Rules

Generate exactly {{NUM_CARDS}} flashcards from the course content.
Cover a diverse range of concepts from the material — do not repeat the same idea across multiple cards.
Each card must have a unique `id` in the format "card-1", "card-2", etc.

- Question Writing Guidelines

Good question patterns:
- "What is the definition of [concept]?"
- "What are the [N] steps/phases of [process]?"
- "How does [mechanism] achieve [outcome]?"
- "What distinguishes [X] from [Y]?"
- "Why is [principle] important in the context of [topic]?"
- "What happens when [condition] in [system]?"

Avoid:
- Trick questions or pedantic edge cases not covered in the material.
- Questions whose answers require multi-paragraph explanations.
- Questions that give away the answer in the phrasing (e.g., "The process that does X is called?").

- Answer Writing Guidelines

Keep answers between one sentence and three short sentences.
Use precise terminology from the course material.
If the answer is a list, use a comma-separated format or a short numbered list (2–5 items max).
Do not prefix answers with "The answer is" or similar redundant phrasing.

- Course Content

The following is the source course unit content. Generate all flashcards strictly from this material.
41 changes: 41 additions & 0 deletions backend/openedx_ai_extensions/response_schemas/flashcards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"type": "json_schema",
"json_schema": {
"name": "FlashcardGeneration",
"strict": true,
"schema": {
"type": "object",
"properties": {
"cards": {
"type": "array",
"description": "The list of flashcards generated from the course content.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "A unique identifier for the flashcard (e.g., 'card-1', 'card-2'). Must be unique within the set."
},
"question": {
"type": "string",
"description": "The front of the flashcard. A clear, concise question or prompt derived from the course content."
},
"answer": {
"type": "string",
"description": "The back of the flashcard. The correct and complete answer to the question. Can include brief explanations."
}
},
"required": [
"id",
"question",
"answer"
],
"additionalProperties": false
}
}
},
"required": ["cards"],
"additionalProperties": false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
Orchestrators for handling different AI workflow patterns in Open edX.
"""
import json
import random
from pathlib import Path

from openedx_ai_extensions.processors import LLMProcessor, OpenEdXProcessor
from openedx_ai_extensions.xapi.constants import EVENT_NAME_WORKFLOW_COMPLETED

from .session_based_orchestrator import SessionBasedOrchestrator


class FlashCardsOrchestrator(SessionBasedOrchestrator):
"""
Orchestrator for flashcards generation using LLM.
Does a single call to an LLM and gives a response.
"""

@property
def _schema_path(self):
return (
Path(__file__).resolve().parent.parent.parent
/ "response_schemas"
/ "flashcards.json"
)

def run(self, input_data):
"""
Executes the content fetching, LLM processing, and handles streaming
or structured response return.
"""

# --- 1. Process with OpenEdX processor (Content Fetching) ---
openedx_processor = OpenEdXProcessor(
processor_config=self.profile.processor_config,
location_id=self.location_id,
course_id=self.course_id,
user=self.user,
)
content_result = openedx_processor.process()

# Early return on error during content fetching
if content_result and 'error' in content_result:
return {
'error': content_result['error'],
'status': 'OpenEdXProcessor error'
}

# Convert fetched content to a string format suitable for the LLM
llm_input_content = str(content_result)

if input_data.get('num_cards', None) is None:
# Generate random number of cards between 1 and 25 if num_cards is not provided or is None
input_data['num_cards'] = random.randint(1, 25)
with open(self._schema_path, 'r', encoding='utf-8') as f:
llm_processor = LLMProcessor(
config=self.profile.processor_config,
extra_params={"response_format": json.load(f)}
)
llm_result = llm_processor.process(
context=llm_input_content,
input_data=input_data,
)

if llm_result and 'error' in llm_result:
return {
'error': llm_result['error'],
'status': 'LLMProcessor error'
}

self._emit_workflow_event(EVENT_NAME_WORKFLOW_COMPLETED)

response_obj = llm_result.get('response')
cards = []
if isinstance(response_obj, dict):
potential_cards = response_obj.get('cards')
if isinstance(potential_cards, list):
cards = potential_cards
elif isinstance(response_obj, list):
cards = response_obj

if cards is not None:
for card in cards:
if isinstance(card, dict):
card['nextReviewTime'] = 0
card['interval'] = 1
card['easeFactor'] = 2.5
card['repetitions'] = 0
card['lastReviewedAt'] = None

if isinstance(response_obj, dict):
enriched_response = dict(response_obj)
enriched_response['cards'] = cards
else:
enriched_response = cards

response_data = {
'response': enriched_response,
'status': 'completed',
'metadata': {
'tokens_used': llm_result.get('tokens_used'),
'model_used': llm_result.get('model_used')
}
}
return response_data

def save(self, input_data):
"""
Saves the generated flashcards to the database or a file.
This is a placeholder implementation and should be replaced with actual saving logic.
"""
self.session.metadata['cards'] = input_data.get('cards') or input_data.get('card_stack')
self.session.save(update_fields=['metadata'])
return {
'status': 'flashcards_saved',
}

def get_current_session_response(self, _):
"""
Retrieve the current session state.

- If flashcards were generated but not yet saved: return them for review.
- Otherwise: return None.
"""
metadata = self.session.metadata or {}
if "cards" in metadata:
return metadata["cards"]
return None
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
Use an AI workflow to generate and study flashcards
starting from the current course content unit.
Works only in Studio UI and over the CMS service_variant.
*/
{
"orchestrator_class": "openedx_ai_extensions.workflows.orchestrators.flashcards_orchestrator.FlashCardsOrchestrator",
"processor_config": {
"OpenEdXProcessor": {
"function": "get_location_content"
},
"LLMProcessor": {
"function": "generate_flashcards",
"provider": "openai",
"cache": true
}
},
"actuator_config": {
"UIComponents": {
"request": {
"component": "FlashcardCreator",
"config": {
"buttonText": "Start",
"customMessage": "",
"preloadPreviousSession": true
}
},
"response": {
"component": "FlashcardStudyResponse",
"config": {
"customMessage": "Assistance completed successfully",
}
}
}
},
"schema_version": "1.0",
}
Loading