diff --git a/backend/openedx_ai_extensions/admin.py b/backend/openedx_ai_extensions/admin.py index 7a99403e..a28a958a 100644 --- a/backend/openedx_ai_extensions/admin.py +++ b/backend/openedx_ai_extensions/admin.py @@ -516,7 +516,7 @@ def debug_thread_view(self, request): session_data["remote_thread_error"] = str(e) try: - session_data["combined_thread"] = session.get_combined_thread() + session_data["combined_thread"] = session.get_debug_thread() except Exception as e: # pylint: disable=broad-exception-caught _logger.exception( "Error building combined thread for session %s", session.id diff --git a/backend/openedx_ai_extensions/templates/admin/debug_thread.html b/backend/openedx_ai_extensions/templates/admin/debug_thread.html index fbdd2bc5..95a3d601 100644 --- a/backend/openedx_ai_extensions/templates/admin/debug_thread.html +++ b/backend/openedx_ai_extensions/templates/admin/debug_thread.html @@ -5,28 +5,28 @@ {% block content %}

Debug Thread

diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index 06a867ed..ffe57586 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -571,3 +571,23 @@ def get_combined_thread(self): # pylint: disable=too-many-statements combined.insert(insert_at, entry) return combined + + def get_debug_thread(self): + """ + Fetch the debug thread for this session by delegating to its orchestrator. + + This allows different orchestrator types (threaded vs one-shot) to + provide their own relevant debug information. + """ + context = { + "course_id": str(self.course_id) if self.course_id else None, + "location_id": str(self.location_id) if self.location_id else None, + } + + orchestrator = BaseOrchestrator.get_orchestrator( + workflow=self.scope, + user=self.user, + context=context, + ) + + return orchestrator.get_debug_messages() diff --git a/backend/openedx_ai_extensions/workflows/orchestrators/base_orchestrator.py b/backend/openedx_ai_extensions/workflows/orchestrators/base_orchestrator.py index b65eeb57..11180be2 100644 --- a/backend/openedx_ai_extensions/workflows/orchestrators/base_orchestrator.py +++ b/backend/openedx_ai_extensions/workflows/orchestrators/base_orchestrator.py @@ -94,6 +94,16 @@ def _emit_workflow_event(self, event_name: str) -> None: def run(self, input_data): raise NotImplementedError("Subclasses must implement run method") + def get_debug_messages(self) -> list: + """ + Return a list of messages for debugging this orchestrator's execution. + + Default implementation returns an empty list. Subclasses (especially + session-based ones) should override this to provide relevant + interaction history or state. + """ + return [] + @classmethod def get_orchestrator(cls, *, workflow, user, context): """ diff --git a/backend/openedx_ai_extensions/workflows/orchestrators/direct_orchestrator.py b/backend/openedx_ai_extensions/workflows/orchestrators/direct_orchestrator.py index 4b31d36f..667d4b4a 100644 --- a/backend/openedx_ai_extensions/workflows/orchestrators/direct_orchestrator.py +++ b/backend/openedx_ai_extensions/workflows/orchestrators/direct_orchestrator.py @@ -311,3 +311,28 @@ def save(self, input_data): 'status': 'completed', 'response': collection_url, } + + def get_debug_messages(self) -> list: + """ + Return debug messages including question slots data. + """ + messages = super().get_debug_messages() + + metadata = self.session.metadata or {} + question_slots = metadata.get("question_slots") + if question_slots: + messages.append({ + "role": "assistant", + "content": f"Stored Question Slots: {json.dumps(question_slots, indent=2)}", + "source": "metadata", + }) + + collection_url = metadata.get("collection_url") + if collection_url: + messages.append({ + "role": "assistant", + "content": f"Saved Collection URL: {collection_url}", + "source": "metadata", + }) + + return messages diff --git a/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py b/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py index 4e717f1a..82574b28 100644 --- a/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py +++ b/backend/openedx_ai_extensions/workflows/orchestrators/flashcards_orchestrator.py @@ -162,3 +162,20 @@ def get_current_session_response(self, _): 'cards': None, 'status': 'no_flashcards', } + + def get_debug_messages(self) -> list: + """ + Return debug messages including flashcard data. + """ + messages = super().get_debug_messages() + + metadata = self.session.metadata or {} + cards = metadata.get("cards") + if cards: + messages.append({ + "role": "assistant", + "content": f"Stored Flashcards: {json.dumps(cards, indent=2)}", + "source": "metadata", + }) + + return messages diff --git a/backend/openedx_ai_extensions/workflows/orchestrators/session_based_orchestrator.py b/backend/openedx_ai_extensions/workflows/orchestrators/session_based_orchestrator.py index b6b43df0..459ab18b 100644 --- a/backend/openedx_ai_extensions/workflows/orchestrators/session_based_orchestrator.py +++ b/backend/openedx_ai_extensions/workflows/orchestrators/session_based_orchestrator.py @@ -135,6 +135,60 @@ def _get_submission_processor(self): self.profile.processor_config, self.session ) + def get_debug_messages(self) -> list: + """ + Return debug messages from the session's thread and metadata. + """ + import json # pylint: disable=import-outside-toplevel + + # 1. Start with the combined thread if available (for threaded workflows) + messages = self.session.get_combined_thread() or [] + + # 2. Enrich with task status and results from metadata (for non-threaded workflows) + metadata = self.session.metadata or {} + + # Add task status if present and not already represented + task_status = metadata.get("task_status") + if task_status and not any(m.get("role") == "system" and task_status in m.get("content", "") for m in messages): + messages.append({ + "role": "system", + "content": f"Task Status: {task_status}", + "source": "metadata", + }) + + # Add task status message if present + task_status_msg = metadata.get("task_status_message") + if task_status_msg: + messages.append({ + "role": "system", + "content": f"Status Message: {task_status_msg}", + "source": "metadata", + }) + + # Add task result if present and not already represented + task_result = metadata.get("task_result") + if task_result: + # Check if task_result content is already in the thread to avoid duplication + # (though for non-threaded it likely won't be) + result_str = json.dumps(task_result, indent=2) + if not any(result_str[:200] in str(m.get("content", "")) for m in messages): + messages.append({ + "role": "assistant", + "content": result_str, + "source": "metadata", + }) + + # Add task error if present + task_error = metadata.get("task_error") + if task_error: + messages.append({ + "role": "error", + "content": task_error, + "source": "metadata", + }) + + return messages + def run(self, input_data): raise NotImplementedError("Subclasses must implement run method") diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 3385c390..799ec20c 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -559,6 +559,93 @@ def test_combined_thread_function_call_fields_propagated( # pylint: disable=unu # content is still present as a human-readable fallback assert "get_weather" in item["content"] + @patch("openedx_ai_extensions.workflows.orchestrators.BaseOrchestrator.get_orchestrator") + def test_get_debug_thread_delegates_to_orchestrator(self, mock_get_orchestrator, session_with_ids): + """Verify that get_debug_thread calls the orchestrator's get_debug_messages.""" + mock_orchestrator = Mock() + mock_orchestrator.get_debug_messages.return_value = [{"role": "system", "content": "test"}] + mock_get_orchestrator.return_value = mock_orchestrator + + result = session_with_ids.get_debug_thread() + + assert result == [{"role": "system", "content": "test"}] + mock_get_orchestrator.assert_called_once() + mock_orchestrator.get_debug_messages.assert_called_once() + + def test_session_based_orchestrator_debug_messages_with_metadata(self, session_no_ids): + """Test that SessionBasedOrchestrator synthesizes messages from metadata.""" + # pylint: disable=import-outside-toplevel + from openedx_ai_extensions.workflows.orchestrators.session_based_orchestrator import SessionBasedOrchestrator + + session_no_ids.metadata = { + "task_status": "completed", + "task_result": {"answer": "42"}, + "task_status_message": "All done" + } + session_no_ids.save() + + orchestrator = SessionBasedOrchestrator( + workflow=session_no_ids.scope, + user=session_no_ids.user, + context={} + ) + # Ensure it uses the session we just updated + orchestrator.session = session_no_ids + + messages = orchestrator.get_debug_messages() + + # Should have messages for status, status message, and result + assert any("Task Status: completed" in m["content"] for m in messages) + assert any("Status Message: All done" in m["content"] for m in messages) + assert any("42" in m["content"] for m in messages) + assert all(m["source"] == "metadata" for m in messages) + + def test_flashcards_orchestrator_debug_messages(self, session_no_ids): + """Test that FlashCardsOrchestrator includes cards in debug messages.""" + # pylint: disable=import-outside-toplevel + from openedx_ai_extensions.workflows.orchestrators.flashcards_orchestrator import FlashCardsOrchestrator + + session_no_ids.metadata = { + "cards": [{"question": "Q1", "answer": "A1"}] + } + session_no_ids.save() + + orchestrator = FlashCardsOrchestrator( + workflow=session_no_ids.scope, + user=session_no_ids.user, + context={} + ) + orchestrator.session = session_no_ids + + messages = orchestrator.get_debug_messages() + + assert any("Stored Flashcards" in m["content"] for m in messages) + assert any("Q1" in m["content"] for m in messages) + + def test_educator_assistant_orchestrator_debug_messages(self, session_no_ids): + """Test that EducatorAssistantOrchestrator includes question slots.""" + # pylint: disable=import-outside-toplevel + from openedx_ai_extensions.workflows.orchestrators.direct_orchestrator import EducatorAssistantOrchestrator + + session_no_ids.metadata = { + "question_slots": [{"versions": [{"q": "v1"}]}], + "collection_url": "http://example.com" + } + session_no_ids.save() + + orchestrator = EducatorAssistantOrchestrator( + workflow=session_no_ids.scope, + user=session_no_ids.user, + context={} + ) + orchestrator.session = session_no_ids + + messages = orchestrator.get_debug_messages() + + assert any("Stored Question Slots" in m["content"] for m in messages) + assert any("Saved Collection URL" in m["content"] for m in messages) + assert any("http://example.com" in m["content"] for m in messages) + # ========================================================================== # AIWorkflowScope Resolution (multi-scope per location)