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
2 changes: 1 addition & 1 deletion backend/openedx_ai_extensions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 20 additions & 20 deletions backend/openedx_ai_extensions/templates/admin/debug_thread.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,28 @@

{% block content %}
<style>
.debug-session { margin-bottom: 2em; border: 1px solid #ddd; border-radius: 4px; }
.debug-session-header { background: #f8f8f8; padding: 12px 16px; border-bottom: 1px solid #ddd; }
.debug-session-header h3 { margin: 0 0 8px 0; }
.debug-session-header .meta { color: #666; font-size: 0.9em; }
.debug-session { margin-bottom: 2em; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.debug-session-header { background: #f0f0f0; padding: 12px 16px; border-bottom: 1px solid #ccc; }
.debug-session-header h3 { margin: 0 0 8px 0; color: #000; }
.debug-session-header .meta { color: #333; font-size: 0.9em; }
.debug-session-header .meta span { margin-right: 16px; }
.debug-thread { padding: 16px; }
.debug-msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; max-width: 80%; }
.debug-msg.user { background: #e3f2fd; margin-left: auto; }
.debug-msg.assistant { background: #f5f5f5; }
.debug-msg.system { background: #fff8e1; border-left: 3px solid #ffc107; font-size: 0.85em; }
.debug-msg.reasoning { background: #f3e5f5; border-left: 3px solid #9c27b0; font-size: 0.85em; font-style: italic; }
.debug-msg.tool_call { background: #e8f5e9; border-left: 3px solid #4caf50; font-size: 0.85em; font-family: monospace; }
.debug-msg.error { background: #ffebee; border-left: 3px solid #d32f2f; }
.debug-msg.unknown { background: #eceff1; border-left: 3px solid #90a4ae; }
.debug-msg .role { font-weight: bold; font-size: 0.8em; text-transform: uppercase; color: #555; margin-bottom: 4px; }
.debug-msg .content { white-space: pre-wrap; word-break: break-word; }
.debug-msg .msg-meta { font-size: 0.75em; color: #999; margin-top: 4px; }
.debug-error { color: #d32f2f; padding: 16px; }
.debug-empty { color: #999; padding: 16px; font-style: italic; }
.debug-thread { padding: 16px; background: #fff; }
.debug-msg { margin-bottom: 12px; padding: 10px 14px; border-radius: 6px; max-width: 85%; border: 1px solid transparent; color: #000; }
.debug-msg.user { background: #e3f2fd; margin-left: auto; border-color: #bbdefb; color: #0d47a1; }
.debug-msg.assistant { background: #f5f5f5; border-color: #e0e0e0; color: #212121; }
.debug-msg.system { background: #fffde7; border-left: 4px solid #fbc02d; border-right: 1px solid #fff59d; border-top: 1px solid #fff59d; border-bottom: 1px solid #fff59d; font-size: 0.85em; color: #333; }
.debug-msg.reasoning { background: #f3e5f5; border-left: 4px solid #9c27b0; border-right: 1px solid #e1bee7; border-top: 1px solid #e1bee7; border-bottom: 1px solid #e1bee7; font-size: 0.85em; font-style: italic; color: #4a148c; }
.debug-msg.tool_call { background: #e8f5e9; border-left: 4px solid #4caf50; border-right: 1px solid #c8e6c9; border-top: 1px solid #c8e6c9; border-bottom: 1px solid #c8e6c9; font-size: 0.85em; font-family: monospace; color: #1b5e20; }
.debug-msg.error { background: #ffebee; border-left: 4px solid #d32f2f; border-right: 1px solid #ffcdd2; border-top: 1px solid #ffcdd2; border-bottom: 1px solid #ffcdd2; color: #b71c1c; }
.debug-msg.unknown { background: #eceff1; border-left: 4px solid #90a4ae; border-right: 1px solid #cfd8dc; border-top: 1px solid #cfd8dc; border-bottom: 1px solid #cfd8dc; color: #263238; }
.debug-msg .role { font-weight: bold; font-size: 0.85em; text-transform: uppercase; color: inherit; opacity: 0.8; margin-bottom: 6px; border-bottom: 1px solid rgba(0,0,0,0.1); padding-bottom: 2px; }
.debug-msg .content { white-space: pre-wrap; word-break: break-word; font-size: 1em; line-height: 1.4; }
.debug-msg .msg-meta { font-size: 0.75em; color: inherit; opacity: 0.6; margin-top: 8px; border-top: 1px solid rgba(0,0,0,0.05); padding-top: 4px; }
.debug-error { color: #d32f2f; padding: 16px; background: #fff5f5; border-bottom: 1px solid #ffcdd2; }
.debug-empty { color: #666; padding: 16px; font-style: italic; }
.debug-json-toggle { margin: 16px 0; }
.debug-json { display: none; background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; }
.debug-json pre { margin: 0; font-size: 0.85em; }
.debug-json { display: none; background: #f8f9fa; padding: 16px; border: 1px solid #ddd; border-radius: 4px; overflow-x: auto; }
.debug-json pre { margin: 0; font-size: 0.85em; color: #333; }
</style>

<h2>Debug Thread</h2>
Expand Down
20 changes: 20 additions & 0 deletions backend/openedx_ai_extensions/workflows/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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 []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to return a message like

                "role": "system",
                "content": f"your {classname} orchestrator does not support debug messages",
                "source": "system",

so that when the orchestrator does not override this, the user gets the right info?


@classmethod
def get_orchestrator(cls, *, workflow, user, context):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move this out to the top? something as basic as json should not conflict


# 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")

Expand Down
87 changes: 87 additions & 0 deletions backend/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading