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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- 5433:5433

redis:
image: redis:7.4.4
image: redis:7.4.5
ports:
- 6379:6379

Expand All @@ -46,7 +46,7 @@ jobs:

- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5
with:
python-version: "3.13.3"
python-version: "3.13.5"
cache: "poetry"

- name: Validate lockfile
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.13.3
3.13.5
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.13.3
FROM python:3.13.5
LABEL maintainer "ODL DevOps <[email protected]>"

# Add package files, install updated node and pip
Expand Down
18 changes: 18 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
Release Notes
=============

Version 0.11.0
--------------

- New Canvas-specific syllabus bot endpoint (#238)
- fix(deps): update dependency uvicorn to ^0.35.0 (#247)
- fix(deps): update dependency starlette to v0.47.1 (#246)
- fix(deps): update dependency ruff to v0.12.3 (#245)
- chore(deps): update node.js to v22.17.0 (#244)
- chore(deps): update nginx docker tag to v1.29.0 (#243)
- fix(deps): update dependency next to v15.3.5 (#242)
- fix(deps): update dependency langmem to ^0.0.28 (#241)
- chore(deps): update redis docker tag to v7.4.5 (#240)
- chore(deps): update dependency eslint-config-next to v15.3.5 (#239)
- update open-learning-ai-tutor (#236)
- fix(deps): update django-health-check digest to 5267d8f (#225)
- fix(deps): update python docker tag to v3.13.5 (#231)
- Remove pytz from unit test (#58)

Version 0.10.2 (Released July 09, 2025)
--------------

Expand Down
3 changes: 3 additions & 0 deletions ai_chatbots/chatbots.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,9 @@ class SyllabusAgentState(SummaryState):
course_id: Annotated[list[str], add]
collection_name: Annotated[list[str], add]
related_courses: Annotated[list[str], add]
# str representation of a boolean value, because the
# langgraph JsonPlusSerializer can't handle booleans
exclude_canvas: Annotated[Optional[list[str]], add]


class SyllabusBot(SummarizingChatbot):
Expand Down
1 change: 1 addition & 0 deletions ai_chatbots/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def syllabus_agent_state():
],
course_id=["MITx+10.00.2x", "MITx+6.00.1x"],
collection_name=[None, "vector512"],
exclude_canvas=["True", "True"],
)


Expand Down
19 changes: 19 additions & 0 deletions ai_chatbots/consumers.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,12 @@ def create_chatbot(

def process_extra_state(self, data: dict) -> dict:
"""Process extra state parameters if any"""
user = self.scope.get("user", None)
related_courses = data.get("related_courses", [])
params = {
"course_id": [data.get("course_id")],
"collection_name": [data.get("collection_name")],
"exclude_canvas": [str(not user or user.is_anonymous or not user.is_staff)],
}
if related_courses:
params["related_courses"] = related_courses
Expand Down Expand Up @@ -460,6 +462,23 @@ async def create_checkpointer(
)


class CanvasSyllabusBotHttpConsumer(SyllabusBotHttpConsumer):
"""
Async HTTP consumer for the Canvas syllabus bot.
Inherits from SyllabusBotHttpConsumer to reuse the logic.
"""

ROOM_NAME = "CanvasSyllabusBot"
throttle_scope = "canvas_syllabus_bot"

def process_extra_state(self, data: dict) -> dict:
"""Process extra state parameters if any"""
return {
**super().process_extra_state(data),
"exclude_canvas": [str(False)],
}


class TutorBotHttpConsumer(BaseBotHttpConsumer):
"""
Async HTTP consumer for the tutor bot.
Expand Down
23 changes: 20 additions & 3 deletions ai_chatbots/consumers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,28 @@ async def test_syllabus_create_chatbot(
},
],
)
def test_process_extra_state(request_params):
def test_syllabus_process_extra_state(syllabus_consumer, request_params):
"""Test that the process_extra_state function returns the expected values."""
consumer = consumers.SyllabusBotHttpConsumer()
assert consumer.process_extra_state(request_params) == {

assert syllabus_consumer.process_extra_state(request_params) == {
"course_id": [request_params.get("course_id")],
"collection_name": [request_params.get("collection_name", None)],
"exclude_canvas": ["True"],
}


def test_canvas_process_extra_state(syllabus_consumer, async_user):
"""Test that the canvas syllabus process_extra_state function returns False for exclude_canvas."""
consumer = consumers.CanvasSyllabusBotHttpConsumer()
consumer.scope = {"user": async_user, "cookies": {}, "session": None}
consumer.channel_name = "test_syllabus_channel"

assert consumer.process_extra_state(
{"message": "hello", "course_id": "MITx+6.00.1x"}
) == {
"course_id": ["MITx+6.00.1x"],
"collection_name": [None],
"exclude_canvas": ["False"],
}


Expand Down Expand Up @@ -440,6 +456,7 @@ async def test_consumer_handle(mocker, mock_http_consumer_send, syllabus_consume
extra_state={
"course_id": [payload["course_id"]],
"collection_name": [payload["collection_name"]],
"exclude_canvas": ["True"],
},
)
assert await UserChatSession.objects.filter(
Expand Down
1 change: 1 addition & 0 deletions ai_chatbots/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ class SyllabusAgentStateFactory(factory.Factory):
messages = [factory.SubFactory(HumanMessageFactory)]
course_id = [factory.Faker("uuid4")]
collection_name = [factory.Faker("word")]
exclude_canvas = ["True"]

class Meta:
model = SyllabusAgentState
Expand Down
7 changes: 6 additions & 1 deletion ai_chatbots/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
consumers.SyllabusBotHttpConsumer.as_asgi(),
name="syllabus_agent_sse",
),
re_path(
r"http/canvas_syllabus_agent/",
consumers.CanvasSyllabusBotHttpConsumer.as_asgi(),
name="canvas_syllabus_agent_sse",
),
re_path(
r"http/video_gpt_agent/",
consumers.VideoGPTBotHttpConsumer.as_asgi(),
Expand All @@ -32,5 +37,5 @@
r"http/tutor_agent/",
consumers.TutorBotHttpConsumer.as_asgi(),
name="tutor_agent_sse",
)
),
]
15 changes: 11 additions & 4 deletions ai_chatbots/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,22 @@ class VideoGPTToolSchema(pydantic.BaseModel):
)


def _content_file_search(url, params):
log.debug("Searching MIT API with params: %s", params)
def _content_file_search(url, params, *, exclude_canvas=True):
try:
# Convert the exclude_canvas parameter to a boolean if it is a string
if exclude_canvas and exclude_canvas == "False":
exclude_canvas = False
response = request_with_token(url, params, timeout=30)
response.raise_for_status()
raw_results = response.json().get("results", [])
# Simplify the response to only include the main properties
simplified_results = []
for result in raw_results:
platform = result.get("platform", {}).get("code")
# Currently, canvas contentfiles have blank platform values,
# those from other sources do not.
if exclude_canvas and (not platform or platform == "canvas"):
continue
simplified_result = {
"chunk_content": result.get("chunk_content"),
"run_title": result.get("run_title"),
Expand Down Expand Up @@ -275,15 +282,15 @@ def search_content_files(
url = settings.AI_MIT_SYLLABUS_URL
course_id = state.get("course_id", [None])[-1] or readable_id
collection_name = state.get("collection_name", [None])[-1]
exclude_canvas = state.get("exclude_canvas", ["True"])[-1]
params = {
"q": q,
"resource_readable_id": course_id,
"limit": settings.AI_MIT_CONTENT_SEARCH_LIMIT,
}
if collection_name:
params["collection_name"] = collection_name
log.info("Searching MIT API with params: %s", params)
return _content_file_search(url, params)
return _content_file_search(url, params, exclude_canvas=exclude_canvas)


@tool(args_schema=SearchRelatedCourseContentFilesToolSchema)
Expand Down
46 changes: 41 additions & 5 deletions ai_chatbots/tools_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ def mock_get_resources(mocker, search_results):
)


@pytest.fixture
def mock_get_content_files(mocker, content_chunk_results):
"""Mock resource requests.get for all tests."""
return mocker.patch(
"ai_chatbots.tools.requests.get",
return_value=mocker.Mock(
json=mocker.Mock(return_value=content_chunk_results), status_code=200
),
)


@pytest.mark.parametrize(
"params",
[
Expand Down Expand Up @@ -112,14 +123,14 @@ def test_request_exception(mocker):
@pytest.mark.parametrize("no_collection_name", [True, False])
def test_search_content_files( # noqa: PLR0913
settings,
mock_get_resources,
mock_get_content_files,
syllabus_agent_state,
search_results,
content_chunk_results,
search_url,
limit,
no_collection_name,
):
"""Test that the search_courses tool returns expected results w/expected params."""
"""Test that the search_content_files tool returns expected results w/expected params."""
settings.AI_MIT_SYLLABUS_URL = search_url
settings.AI_MIT_CONTENT_SEARCH_LIMIT = limit
settings.LEARN_ACCESS_TOKEN = "test_token" # noqa: S105
Expand All @@ -136,10 +147,35 @@ def test_search_content_files( # noqa: PLR0913
results = json.loads(
search_content_files.invoke({"q": "main topics", "state": syllabus_agent_state})
)
mock_get_resources.assert_called_once_with(
mock_get_content_files.assert_called_once_with(
search_url,
params=expected_params,
headers={"Authorization": f"Bearer {settings.LEARN_ACCESS_TOKEN}"},
timeout=30,
)
assert len(results["results"]) == len(search_results["results"])
assert len(results["results"]) == len(content_chunk_results["results"])


@pytest.mark.parametrize("exclude_canvas", [True, False])
def test_search_canvas_content_files(
settings, mocker, syllabus_agent_state, content_chunk_results, exclude_canvas
):
"""Test that search_content_files returns canvas results only if exclude_canvas is False."""
settings.LEARN_ACCESS_TOKEN = "test_token" # noqa: S105

syllabus_agent_state["exclude_canvas"] = [str(exclude_canvas)]
for result in content_chunk_results["results"]:
result["platform"]["code"] = "canvas"
mocker.patch(
"ai_chatbots.tools.requests.get",
return_value=mocker.Mock(
json=mocker.Mock(return_value=content_chunk_results), status_code=200
),
)
results = json.loads(
search_content_files.invoke({"q": "main topics", "state": syllabus_agent_state})
)

assert len(results["results"]) == (
len(content_chunk_results["results"]) if not exclude_canvas else 0
)
41 changes: 17 additions & 24 deletions config/apisix/apisix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,24 @@ upstreams:

routes:
- id: 1
name: "websocket"
desc: "Special handling for websocket URLs."
priority: 1
name: "canvas_syllabus_agent"
desc: "Protected route for canvas syllabus agent - requires canvas_token header"
priority: 20
upstream_id: 1
enable_websocket: true
uri: "/http/canvas_syllabus_agent/"
plugins:
openid-connect:
client_id: ${{KEYCLOAK_CLIENT_ID}}
client_secret: ${{KEYCLOAK_CLIENT_SECRET}}
discovery: ${{KEYCLOAK_DISCOVERY_URL}}
realm: ${{KEYCLOAK_REALM}}
scope: ${{KEYCLOAK_SCOPES}}
bearer_only: false
introspection_endpoint_auth_method: "client_secret_post"
ssl_verify: false
session:
secret: ${{APISIX_SESSION_SECRET_KEY}}
logout_path: "/logout"
post_logout_redirect_uri: ${{APISIX_LOGOUT_URL}}
unauth_action: "pass"
key-auth:
header: "canvas_token"
_meta:
disable: false
consumer-restriction:
whitelist:
- "canvas_agent"
cors:
allow_origins: "**"
allow_methods: "**"
allow_headers: "**"
allow_credential: true
response-rewrite:
headers:
set:
Referrer-Policy: "origin"
uris:
- "/ws/*"
- id: 2
name: "passauth"
desc: "Wildcard route that can use auth but doesn't require it."
Expand Down Expand Up @@ -108,4 +95,10 @@ routes:
uris:
- "/admin/login/*"
- "/http/login/"

consumers:
- username: "canvas_agent"
plugins:
key-auth:
key: ${{CANVAS_AI_TOKEN}}
#END
2 changes: 1 addition & 1 deletion docker-compose.apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:

watch:
working_dir: /src
image: node:22.15
image: node:22.17
entrypoint: ["/bin/sh", "-c"]
command:
- |
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ services:
- ./config/postgres:/docker-entrypoint-initdb.d

redis:
image: redis:7.4.4
image: redis:7.4.5
healthcheck:
test: ["CMD", "redis-cli", "ping", "|", "grep", "PONG"]
interval: 3s
Expand Down
1 change: 1 addition & 0 deletions env/backend.env
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ NGINX_UWSGI_PASS=web:8001
# APISIX settings
APISIX_LOGOUT_URL=http://ai.open.odl.local:8003/
APISIX_SESSION_SECRET_KEY=
CANVAS_AI_TOKEN=3f8a7c2e1b9d4e5f6a0b7c8d9e2f1a3b # pragma: allowlist-secret
KEYCLOAK_REALM=ol-local
KEYCLOAK_CLIENT_ID=apisix
# This is not a secret. This is for the pack-in Keycloak, only for local use.
Expand Down
2 changes: 1 addition & 1 deletion frontend-demo/.nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.15.0
22.17.0
4 changes: 2 additions & 2 deletions frontend-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"axios": "^1.7.7",
"better-react-mathjax": "^2.3.0",
"formik": "^2.4.6",
"next": "15.3.4",
"next": "15.3.5",
"react": "19.1.0",
"react-dom": "19.1.0",
"tiny-invariant": "^1.3.3",
Expand All @@ -54,7 +54,7 @@
"@typescript-eslint/typescript-estree": "^8.13.0",
"eslint": "^8",
"eslint-config-mitodl": "^2.1.0",
"eslint-config-next": "15.3.4",
"eslint-config-next": "15.3.5",
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1",
Expand Down
Loading
Loading