From 17d7b1ee54234089444dbcbc4507f2326dc59265 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sun, 26 Jul 2026 18:32:07 +0700 Subject: [PATCH] Handle None content in OpenAI representation The OpenAI/AzureOpenAI representation guarded the response with `hasattr(response.choices[0].message, "content")` before calling `.content.strip()`. The `content` field is always present, so `hasattr` is always True, but its value can be `None` (e.g. when the response is blocked by the content filter and `finish_reason='content_filter'`). This raised `AttributeError: 'NoneType' object has no attribute 'strip'`. Read `content` first and check it against `None` explicitly, falling back to the existing "No label returned" placeholder. Normal (non-None) responses keep their current behavior. Add unit tests that drive `extract_topics` with a mocked OpenAI client returning `content=None` (asserting the fallback label) and normal content (asserting stripping and prefix removal). The tests use `pytest.importorskip("openai")`, and `openai` is added to the test extra so they run in CI. Fixes #2353 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01G8b9dEaJtpvydzz4ttM7JH --- bertopic/representation/_openai.py | 9 ++-- pyproject.toml | 1 + tests/test_representation/test_openai.py | 67 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/test_representation/test_openai.py diff --git a/bertopic/representation/_openai.py b/bertopic/representation/_openai.py index 71eb8c9a..b7c9f3e3 100644 --- a/bertopic/representation/_openai.py +++ b/bertopic/representation/_openai.py @@ -229,9 +229,12 @@ def extract_topics( # Check whether content was actually generated # Addresses #1570 for potential issues with OpenAI's content filter - # Addresses #2176 for potential issues when openAI returns a None type object - if response and hasattr(response.choices[0].message, "content"): - label = response.choices[0].message.content.strip().replace("topic: ", "") + # Addresses #2176 and #2353 for potential issues when OpenAI returns a None + # type object. The ``content`` field is always present but can be ``None`` + # (e.g. when ``finish_reason='content_filter'``), so ``hasattr`` is not enough. + content = response.choices[0].message.content if response else None + if content is not None: + label = content.strip().replace("topic: ", "") else: label = "No label returned" diff --git a/pyproject.toml b/pyproject.toml index d3019893..07d18bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ spacy = [ "spacy>=3.0.1", ] test = [ + "openai>=1.0.0", "pytest>=5.4.3", "pytest-cov>=2.6.1", "ruff~=0.14.0", diff --git a/tests/test_representation/test_openai.py b/tests/test_representation/test_openai.py new file mode 100644 index 00000000..140caf5e --- /dev/null +++ b/tests/test_representation/test_openai.py @@ -0,0 +1,67 @@ +"""Unit tests for the OpenAI representation model.""" + +from unittest.mock import MagicMock + +import pytest + +# The OpenAI representation imports the ``openai`` package at module load time, +# so skip these tests entirely when it is not installed. +pytest.importorskip("openai") + +from bertopic.representation._openai import OpenAI + + +def _make_client(content): + """Build a mock OpenAI client whose chat completion returns ``content``.""" + message = MagicMock() + message.content = content + choice = MagicMock() + choice.message = message + response = MagicMock() + response.choices = [choice] + + client = MagicMock() + client.chat.completions.create.return_value = response + return client + + +def _make_topic_model(): + """Build a mock topic model returning a single topic with representative docs.""" + topic_model = MagicMock() + topic_model.verbose = False + topic_model._extract_representative_docs.return_value = ( + {0: ["A document about pets.", "Another document about pets."]}, + None, + None, + None, + ) + return topic_model + + +def test_openai_extract_topics_handles_none_content(): + """Regression test for #2353. + + When OpenAI returns a message whose ``content`` is ``None`` (for example when the + response is blocked by the content filter and ``finish_reason='content_filter'``), + the ``content`` field is still present, so the previous ``hasattr`` guard passed + and ``None.strip()`` raised ``AttributeError``. The representation must instead + fall back to a placeholder label. + """ + representation = OpenAI(_make_client(content=None)) + + updated_topics = representation.extract_topics( + _make_topic_model(), documents=None, c_tf_idf=None, topics={0: [("pets", 0.9)]} + ) + + assert updated_topics == {0: [("No label returned", 1)]} + + +def test_openai_extract_topics_strips_normal_content(): + """A normal (non-None) response is stripped and the ``topic: `` prefix removed.""" + representation = OpenAI(_make_client(content="topic: Pets\n")) + + updated_topics = representation.extract_topics( + _make_topic_model(), documents=None, c_tf_idf=None, topics={0: [("pets", 0.9)]} + ) + + assert updated_topics == {0: [("Pets", 1)]}