Skip to content
Merged
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
28 changes: 28 additions & 0 deletions backend/openedx_ai_extensions/processors/llm/llm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,34 @@ def _extract_output_items(resp):
items.append({"type": "reasoning", "role": "reasoning", "content": summary_text})
return items

def suggest_content_improvements(self):
"""
Suggest content improvements per unit based on course structure.

Accepts an optional `extra_instructions` string in input_data — author-provided
guidelines (e.g. "Always write in third person") that the LLM checks the course
content against, in addition to its own analysis.
"""
prompt = load_prompt("suggest_content_improvements")
extra_instructions = (self.input_data or {}).get("extra_instructions") or ""
prompt = prompt.replace("{{EXTRA_INSTRUCTIONS}}", extra_instructions)

self.input_data = None

result = self._call_completion_wrapper(system_role=prompt)

if "error" in result:
return result

response = json.loads(result["response"])

return {
"response": response,
"usage": self.usage,
"model_used": self.extra_params.get("model", "unknown"),
"status": "success",
}

def generate_flashcards(self):
"""Example method showing how to generate flashcards from content."""
prompt_file_path = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ def no_context(self, *args, **kwargs):
"Get published Open edX course content. "
"This function reads the content of course unit(s) and "
"converts it into a structured format suitable for LLM processing. "
"Can retrieve just the current unit, units up to the current one, or the entire sequence."
"Can retrieve just the current unit, units up to the current one, or the entire sequence. "
"When no location is available, a course ID can be given instead to retrieve the "
"content of the whole course (sections -> subsections -> units)."
),
"parameters": {
"type": "object",
Expand All @@ -67,6 +69,13 @@ def no_context(self, *args, **kwargs):
"if not provided uses the current location"
)
},
"course_id": {
"type": "string",
"description": (
"The course ID. Only used when no location ID is available: "
"returns the whole course's content. Defaults to the current course."
)
},
"retrieval_mode": {
"type": "string",
"enum": ["unit", "up_to_current_unit", "sequence"],
Expand All @@ -81,18 +90,23 @@ def no_context(self, *args, **kwargs):
}
}
})
def get_location_content(self, location_id=None, retrieval_mode=None):
"""Extract unit or sequence content from Open edX modulestore based on configuration"""
def get_location_content(self, location_id=None, course_id=None, retrieval_mode=None):
"""Extract unit, sequence or whole-course content from Open edX modulestore"""
try:
# pylint: disable=import-error,import-outside-toplevel
from xmodule.modulestore.django import modulestore

# Get char_limit from config. Useful during development
char_limit = self.config.get("char_limit", None)
location_id = location_id or self.location_id
store = modulestore()

# No location at all: fall back to the whole course's content
if location_id is None:
course_key = CourseKey.from_string(course_id or self.course_id)
return self._get_course_content(store, course_key, char_limit)

unit_key = UsageKey.from_string(location_id)
store = modulestore()

# Get retrieval_mode from arg or config, default to 'unit'
retrieval_mode = retrieval_mode or self.config.get("retrieval_mode", "unit")
Expand Down Expand Up @@ -127,6 +141,36 @@ def get_location_content(self, location_id=None, retrieval_mode=None):
except Exception as exc: # pylint: disable=broad-exception-caught
return {"error": f"Error accessing content: {str(exc)}"}

def _get_course_content(self, store, course_key, char_limit=None):
"""Extract content for every unit in a course, keeping the outline structure."""
course = store.get_course(course_key)
sections = []
for chapter in (store.get_item(key) for key in getattr(course, "children", [])):
if chapter.category != "chapter":
continue
subsections = []
for sequential in (store.get_item(key) for key in getattr(chapter, "children", [])):
if sequential.category != "sequential":
continue
subsections.append({
"location_id": str(sequential.location),
"display_name": sequential.display_name,
"units": [
self._get_unit_data(store, unit_key, char_limit)
for unit_key in getattr(sequential, "children", [])
],
})
sections.append({
"location_id": str(chapter.location),
"display_name": chapter.display_name,
"subsections": subsections,
})
return {
"course_id": str(course_key),
"display_name": course.display_name,
"sections": sections,
}

def _get_unit_data(self, store, unit_key, char_limit=None):
"""Extract content for a single unit"""
unit = store.get_item(unit_key)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
- Role & Purpose

You are an AI assistant embedded into an Open edX learning environment. Your purpose is to help course authors improve their course by reviewing the course's actual content and structure and proposing concrete content improvements.

- Core Behaviors

Treat the course content provided below (sections -> subsections -> units, including each unit's blocks) as your only source of truth. Do not invent units, sections, IDs, or content that are not present in it.
Each unit has a `unit_id`. When you flag a unit, you MUST reference its exact `unit_id` as given — never a display name, guess, or modified ID.
Only flag units that would genuinely benefit from a content change. It is fine to return zero suggestions if the course looks complete and well organized, or many if there are several real issues.
Base each suggestion on the actual content of the unit's blocks (text, problems, videos) and its place in the course structure (e.g. thin or unclear content, duplicate titles, missing expected topics, inconsistent pacing, ordering issues).

- Suggestion Writing Guidelines

`title`: a short, action-oriented headline (e.g. "Rename this unit", "Add a worked example"), not a restatement of the unit name.
`suggestion`: the full explanation of what to change and why. Keep it to one or two sentences.
`type`: classify as exactly one of `wording`, `structure`, `pedagogy`, or `accessibility` — pick whichever best describes the nature of the issue.
`priority`: `high`, `medium`, or `low`, based on how much the issue is likely to hurt the learner experience if left unaddressed.
Do not repeat the same suggestion verbatim across multiple units.

- The `proposed_change` field — read carefully

`proposed_change` must be `null` unless you have one specific, final, ready-to-use replacement text to offer.
Only set it when you can fill `current` with a real value copied verbatim from the course data provided below (a unit's `display_name` or the actual text of one of its blocks). Never fabricate a `current` value that does not appear in the provided content.
Never invent placeholder or generic text to fill this field just to have something there. An open-ended suggestion with no concrete replacement text (e.g. "add more examples") MUST have `proposed_change: null`.
When you do set it: `field` names which unit attribute it targets (`display_name`, `content_html`, or `summary`), `current` is the exact existing value, and `suggested` is the exact final replacement text — not a description of a change, the actual text itself.

- Author guidelines

The course author may have provided extra guidelines for this review below. If present, treat them as hard constraints: actively check the course content against each one and flag any unit that violates them as its own suggestion, in addition to your own analysis. If none were provided, ignore this section.

----------------------------
{{EXTRA_INSTRUCTIONS}}
----------------------------

- Context

The following is the course content and structure to review.

- Output rules

The response format is enforced by a structured schema — follow it strictly.
`unit_id` must exactly match a `unit_id` found in the provided course content.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"type": "json_schema",
"json_schema": {
"name": "ContentSuggestions",
"strict": true,
"schema": {
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"description": "Units that would benefit from a content change, based on the provided course structure.",
"items": {
"type": "object",
"properties": {
"unit_id": {
"type": "string",
"description": "The exact 'location_id' of the unit this suggestion applies to, copied verbatim from the provided course outline."
},
"unit_display_name": {
"type": "string",
"description": "The display name of the unit, copied from the provided course outline, for readability."
},
"type": {
"type": "string",
"enum": ["wording", "structure", "pedagogy", "accessibility"],
"description": "The category of improvement this suggestion addresses."
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"description": "How important this suggestion is relative to others."
},
"title": {
"type": "string",
"description": "A short, action-oriented headline for this suggestion (e.g. 'Rename this unit', 'Add a worked example')."
},
"suggestion": {
"type": "string",
"description": "The full description of the suggestion, explaining what to change and why."
},
"proposed_change": {
"type": ["object", "null"],
"description": "A concrete, final, ready-to-use replacement text, ONLY when one genuinely applies. Must be null when the suggestion is open-ended (no specific replacement text to give).",
"properties": {
"field": {
"type": "string",
"enum": ["display_name", "content_html", "summary"],
"description": "Which unit field this concrete change applies to."
},
"current": {
"type": "string",
"description": "The current value of that field, copied verbatim from the provided course data."
},
"suggested": {
"type": "string",
"description": "The proposed replacement value, ready to use as-is."
}
},
"required": ["field", "current", "suggested"],
"additionalProperties": false
}
},
"required": [
"unit_id",
"unit_display_name",
"type",
"priority",
"title",
"suggestion",
"proposed_change"
],
"additionalProperties": false
}
}
},
"required": ["suggestions"],
"additionalProperties": false
}
}
}
Loading
Loading