diff --git a/backend/openedx_ai_extensions/processors/llm/educator_assistant_processor.py b/backend/openedx_ai_extensions/processors/llm/educator_assistant_processor.py index a576c242..15f1f886 100644 --- a/backend/openedx_ai_extensions/processors/llm/educator_assistant_processor.py +++ b/backend/openedx_ai_extensions/processors/llm/educator_assistant_processor.py @@ -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) diff --git a/backend/openedx_ai_extensions/processors/llm/llm_processor.py b/backend/openedx_ai_extensions/processors/llm/llm_processor.py index 69365387..082f9e8d 100644 --- a/backend/openedx_ai_extensions/processors/llm/llm_processor.py +++ b/backend/openedx_ai_extensions/processors/llm/llm_processor.py @@ -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 @@ -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", + } diff --git a/backend/openedx_ai_extensions/prompts/default_generate_flashcards.txt b/backend/openedx_ai_extensions/prompts/default_generate_flashcards.txt new file mode 100644 index 00000000..2256d01e --- /dev/null +++ b/backend/openedx_ai_extensions/prompts/default_generate_flashcards.txt @@ -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. diff --git a/backend/openedx_ai_extensions/response_schemas/flashcards.json b/backend/openedx_ai_extensions/response_schemas/flashcards.json new file mode 100644 index 00000000..b737b516 --- /dev/null +++ b/backend/openedx_ai_extensions/response_schemas/flashcards.json @@ -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 + } + } +} diff --git a/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py b/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py new file mode 100644 index 00000000..d3e38333 --- /dev/null +++ b/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py @@ -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 diff --git a/backend/openedx_ai_extensions/workflows/profiles/experimental/fashcards.json b/backend/openedx_ai_extensions/workflows/profiles/experimental/fashcards.json new file mode 100644 index 00000000..a5bc8607 --- /dev/null +++ b/backend/openedx_ai_extensions/workflows/profiles/experimental/fashcards.json @@ -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", +}