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
9 changes: 6 additions & 3 deletions bertopic/representation/_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions tests/test_representation/test_openai.py
Original file line number Diff line number Diff line change
@@ -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)]}
Loading