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
29 changes: 9 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,35 +36,24 @@ jobs:
- name: Apt install
run: cat Aptfile | sudo xargs apt-get install

- name: Install poetry
uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: 2.1.3
virtualenvs-create: true
virtualenvs-in-project: true
enable-cache: true

- name: Install Python
run: uv python install 3.12

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Install poetry with pip
run: python -m pip install poetry
- name: Validate lockfile
run: poetry check --lock
- name: Set Poetry Python
run: poetry env use python3.12
- name: Install dependencies
run: |
source $(poetry env info --path)/bin/activate
poetry install --no-interaction
run: uv sync --locked

- name: Create test local state
run: ./scripts/test/stub-data.sh

- name: Tests
run: |
poetry run ./manage.py collectstatic --noinput --clear
poetry run ./manage.py check --fail-level WARNING
uv run ./manage.py collectstatic --noinput --clear
uv run ./manage.py check --fail-level WARNING
export MEDIA_ROOT="$(mktemp -d)"
./scripts/test/python_tests.sh
env:
Expand Down
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,17 @@ Run inside Docker containers with `docker compose`:

```bash
# Run all tests (parallel)
docker compose run --rm web poetry run pytest -n logical
docker compose run --rm web uv run pytest -n logical

# Run specific test file
docker compose run --rm web poetry run pytest learning_resources/models_test.py
docker compose run --rm web uv run pytest learning_resources/models_test.py

# Run specific test
docker compose run --rm web poetry run pytest learning_resources/models_test.py::test_name -v
docker compose run --rm web uv run pytest learning_resources/models_test.py::test_name -v

# Lint and format with ruff
docker compose run --rm web poetry run ruff format .
docker compose run --rm web poetry run ruff check . --fix
docker compose run --rm web uv run ruff format .
docker compose run --rm web uv run ruff check . --fix

# Run Django management commands
docker compose run --rm web python manage.py <command>
Expand Down
36 changes: 10 additions & 26 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,17 @@ RUN mkdir /src && \
adduser --disabled-password --gecos "" mitodl && \
mkdir /var/media && chown -R mitodl:mitodl /var/media

FROM system AS poetry
FROM system AS uv

## Set some poetry and python config
## Set some python config
ENV \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=on \
POETRY_NO_INTERACTION=1 \
POETRY_VERSION=2.1.3 \
POETRY_VIRTUALENVS_CREATE=true \
POETRY_CACHE_DIR='/tmp/cache/poetry' \
POETRY_HOME='/home/mitodl/.local' \
VIRTUAL_ENV='/opt/venv'
ENV PATH="$VIRTUAL_ENV/bin:$POETRY_HOME/bin:$PATH"

# Install poetry
RUN pip install --no-cache-dir "poetry==$POETRY_VERSION"

UV_PROJECT_ENVIRONMENT='/opt/venv'
ENV PATH="/opt/venv/bin:$PATH"

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

# Install Chromium (commented out lines illustrate the syntax for getting specific chromium versions)
RUN echo "deb http://deb.debian.org/debian/ sid main" >> /etc/apt/sources.list \
Expand All @@ -61,22 +53,14 @@ RUN apt-get update -qqy \
&& apt-get -qqy install chromium-driver \
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/*
COPY pyproject.toml /src
COPY poetry.lock /src
RUN chown -R mitodl:mitodl /src && \
mkdir ${VIRTUAL_ENV} && chown -R mitodl:mitodl ${VIRTUAL_ENV}
COPY uv.lock /src
RUN mkdir -p /opt/venv && chown -R mitodl:mitodl /src /opt/venv

## Install poetry itself, and pre-create a venv with predictable name
USER mitodl
RUN curl -sSL https://install.python-poetry.org \
| \
POETRY_VERSION=${POETRY_VERSION} \
POETRY_HOME=${POETRY_HOME} \
python3 -q
WORKDIR /src
RUN python3 -m venv $VIRTUAL_ENV
RUN poetry install && rm -rf /tmp/cache
RUN uv sync --frozen --no-install-project

FROM poetry AS code
FROM uv AS code

# Add project
USER root
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile-nb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ USER root

WORKDIR /tmp

RUN pip install --force-reinstall jupyter
RUN uv pip install --force-reinstall jupyter

USER mitodl
WORKDIR /src
7 changes: 7 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Release Notes
=============

Version 0.55.7
--------------

- fix: Add back langchain-litellm dependency
- chore: migrate from poetry/pip to uv for dependency management (#2975)
- Fix contextwindow exceeded error when summarizing large transcripts (#2960)

Version 0.55.6 (Released February 26, 2026)
--------------

Expand Down
2 changes: 2 additions & 0 deletions fixtures/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def warnings_as_errors():
warnings.filterwarnings("ignore", category=InsecureRequestWarning)
warnings.filterwarnings("ignore", category=PytestMockWarning)
warnings.filterwarnings("ignore", category=ResourceWarning)
# PyJWT 2.11+ warns when HMAC key is < 32 bytes (tests use short keys)
warnings.filterwarnings("ignore", module="jwt.*", category=UserWarning)
# Ignore deprecation warnings in third party libraries
warnings.filterwarnings(
"ignore",
Expand Down
6 changes: 5 additions & 1 deletion learning_resources/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,12 @@ def setup_s3_ocw(settings):


@pytest.fixture
def summarizer_configuration():
def summarizer_configuration(mocker):
"""Create a summarizer configuration"""
mocker.patch(
"learning_resources.content_summarizer.truncate_to_tokens", autospec=True
)
mocker.patch("learning_resources.content_summarizer.get_max_tokens", autospec=True)
return ContentSummarizerConfigurationFactory.create()


Expand Down
37 changes: 30 additions & 7 deletions learning_resources/content_summarizer.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import logging
from typing import Annotated

import litellm
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from langchain_community.chat_models import ChatLiteLLM
from langchain_litellm import ChatLiteLLM
from litellm import get_max_tokens
from typing_extensions import TypedDict

from learning_resources.exceptions import (
Expand All @@ -15,9 +17,13 @@
ContentFile,
ContentSummarizerConfiguration,
)
from learning_resources.utils import truncate_to_tokens

logger = logging.getLogger(__name__)

# drop unsupported model params
litellm.drop_params = True


class Flashcard(TypedDict):
"""Flashcard structure model"""
Expand Down Expand Up @@ -232,10 +238,17 @@ def _generate_summary(self, content: str, llm_model: str) -> str:
- str: Generated summary
"""
try:
llm = self._get_llm(model=llm_model, temperature=0.3, max_tokens=1000)
response = llm.invoke(
f"Summarize the key points from this video. Transcript:{content}"
max_output_tokens = 1000
max_input_tokens = get_max_tokens(llm_model)
summarizer_message = truncate_to_tokens(
f"Summarize the key points from this video. Transcript:{content}",
max_input_tokens - max_output_tokens,
llm_model,
)
llm = self._get_llm(
model=llm_model, temperature=0.3, max_tokens=max_output_tokens
)
response = llm.invoke(summarizer_message)
logger.debug("Generating Summary using model: %s", llm)
generated_summary = response.content
logger.debug("Generated summary: %s", generated_summary)
Expand Down Expand Up @@ -263,13 +276,23 @@ def _generate_flashcards(
- list[dict[str, str]]: List of flashcards
"""
try:
llm = self._get_llm(model=llm_model, temperature=0.3, max_tokens=2048)
max_output_tokens = 2048
max_input_tokens = get_max_tokens(llm_model)
llm = self._get_llm(
model=llm_model, temperature=1, max_tokens=max_output_tokens
)
logger.debug("Generating flashcards using model: %s", llm)
structured_llm = llm.with_structured_output(FlashcardsResponse)

response = structured_llm.invoke(
settings.CONTENT_SUMMARIZER_FLASHCARD_PROMPT.format(content=content)
flashcard_prompt = settings.CONTENT_SUMMARIZER_FLASHCARD_PROMPT.format(
content=content
)
flashcard_prompt = truncate_to_tokens(
flashcard_prompt,
max_input_tokens - max_output_tokens,
llm_model,
)
response = structured_llm.invoke(flashcard_prompt)
if response:
generated_flashcards = response.get("flashcards", [])
logger.debug("Generated flashcards: %s", generated_flashcards)
Expand Down
12 changes: 12 additions & 0 deletions learning_resources/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import html2text
import rapidjson
import requests
import tiktoken
import yaml
from botocore.exceptions import ClientError
from django.conf import settings
Expand Down Expand Up @@ -671,3 +672,14 @@ def json_to_markdown(obj, indent=0):
else:
markdown += f"{indent_str}{obj}\n\n"
return markdown


def truncate_to_tokens(text: str, max_tokens: int, model: str = "gpt-4o") -> str:
"""
Truncate text to a maximum number of tokens for a given model.
"""
encoding = tiktoken.encoding_for_model(model)
tokens = encoding.encode(text)
if len(tokens) <= max_tokens:
return text
return encoding.decode(tokens[:max_tokens])
21 changes: 21 additions & 0 deletions learning_resources/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"""

import json
import random
from pathlib import Path

import markdown
import pytest
import yaml
from faker import Faker

from data_fixtures import utils as data_utils
from learning_resources import utils
Expand Down Expand Up @@ -37,6 +39,7 @@
from learning_resources.utils import (
add_parent_topics_to_learning_resource,
transfer_list_resources,
truncate_to_tokens,
)

pytestmark = pytest.mark.django_db
Expand Down Expand Up @@ -619,3 +622,21 @@ def test_json_to_markdown(courses_data):
# strip double newlines for compact fixture
content = rendered_markdown.replace("\n\n", "\n")
assert markdown.markdown(expected_markdown_response) == markdown.markdown(content)


def test_truncate_to_tokens_util(mocker):
class MockEncoding:
def encode(self, text):
return list(text)

def decode(self, tokens):
return "".join(tokens)

mock_encoding = MockEncoding()
mocker.patch("tiktoken.encoding_for_model", return_value=mock_encoding)

model = "gpt-4o"
content = Faker().text() * 100
max_tokens = random.randint(1, 50) # noqa: S311
truncated = truncate_to_tokens(content, max_tokens, model)
assert len(mock_encoding.encode(truncated)) <= max_tokens
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "0.55.6"
VERSION = "0.55.7"

log = logging.getLogger()

Expand Down
Loading
Loading