Skip to content

Commit 5e85d15

Browse files
committed
v3.0.16
1 parent 37bf2dc commit 5e85d15

5 files changed

Lines changed: 194 additions & 113 deletions

File tree

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ COPY --chmod=555 .env ${WORKDIR}
4141

4242
RUN set -ex && \
4343
mkdir -p "${LOG_DIRECTORY}" && \
44-
uv sync --frozen --no-dev && \
44+
uv sync --frozen --no-dev --no-build && \
4545
uv cache clean && \
4646
chown -R botuser:botuser "${WORKDIR}"
4747

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "DiscordBot"
3-
version = "3.0.15"
3+
version = "3.0.16"
44
description = "A simple Discord bot with OpenAI support and server administration tools"
55
urls.Repository = "https://github.com/ddc/DiscordBot"
66
urls.Homepage = "https://ddc.github.io/DiscordBot"
@@ -44,11 +44,11 @@ dependencies = [
4444

4545
[dependency-groups]
4646
dev = [
47-
"coverage>=7.14.0",
47+
"coverage>=7.14.1",
4848
"poethepoet>=0.46.0",
49-
"pytest-asyncio>=1.3.0",
50-
"ruff>=0.15.14",
51-
"testcontainers[postgres]>=4.14.2",
49+
"pytest-asyncio>=1.4.0",
50+
"ruff>=0.15.15",
51+
"testcontainers[postgres]>=4.15.0rc2",
5252
]
5353

5454
[tool.poe.tasks]

src/bot/cogs/open_ai.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ def __init__(self, bot: Bot) -> None:
1919
self._bot_settings: BotSettings = get_bot_settings()
2020
self._openai_client: AsyncOpenAI = AsyncOpenAI(api_key=self._bot_settings.openai_api_key)
2121
self._effort: ReasoningEffort = "xhigh"
22-
self._instructions: str = (
22+
self._instructions: str = "You are a helpful AI assistant."
23+
self._instructions_web: str = (
2324
"You are a helpful AI assistant. When answering factual questions, use web search and base your "
2425
"answer only on information directly supported by the sources. Do not invent or extrapolate specific "
2526
"numbers, statistics, or breakdowns that the sources do not explicitly state. Cite the source URL(s)."
@@ -28,26 +29,45 @@ def __init__(self, bot: Bot) -> None:
2829
@commands.command()
2930
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
3031
async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
31-
"""Ask OpenAI for assistance with any question or task.
32+
"""Ask OpenAI for a direct answer (no web search).
3233
3334
Usage:
3435
ai What is Python?
3536
ai Write a haiku about programming
3637
ai Explain quantum computing in simple terms
3738
"""
38-
# Reasoning + web search can take a couple of minutes, so show a progress
39+
await self._run_ai(ctx, msg_text, use_web=False)
40+
41+
@commands.command()
42+
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
43+
async def aiweb(self, ctx: commands.Context, *, msg_text: str) -> None:
44+
"""Ask OpenAI to search the web before answering — best for current/factual info.
45+
46+
Usage:
47+
aiweb What's the latest news about <topic>
48+
aiweb How many support gems does Path of Exile 2 have
49+
"""
50+
await self._run_ai(ctx, msg_text, use_web=True)
51+
52+
async def _run_ai(self, ctx: commands.Context, msg_text: str, use_web: bool) -> None:
53+
"""Shared body for the `ai` and `aiweb` commands."""
54+
# Reasoning (and web search) can take a couple of minutes, so show a progress
3955
# message immediately so the user knows the bot is working (not stuck).
56+
progress_text = (
57+
"Please wait, I'm thinking and searching the web for an accurate answer..."
58+
if use_web
59+
else "Please wait, I'm thinking..."
60+
)
4061
progress_embed = discord.Embed(
41-
description="🔄 **Please wait, I'm thinking and searching the web for an accurate answer...** "
42-
"(this may take a moment)",
62+
description=f"🔄 **{progress_text}** (this may take a moment)",
4363
color=discord.Color.blurple(),
4464
)
4565
progress_embed.set_author(name=ctx.author.display_name, icon_url=getattr(ctx.author.avatar, "url", None))
4666
progress_msg = await bot_utils.send_with_retry(ctx, ctx.send, embed=progress_embed)
4767

4868
start = time.monotonic()
4969
try:
50-
response_text = await self._get_ai_response(msg_text)
70+
response_text = await self._get_ai_response(msg_text, use_web=use_web)
5171
color = discord.Color.green()
5272
description = response_text
5373
except Exception as e:
@@ -69,14 +89,21 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
6989
view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id)
7090
await view.send_and_save(ctx)
7191

72-
async def _get_ai_response(self, message: str) -> str:
73-
"""Get response from OpenAI API."""
92+
async def _get_ai_response(self, message: str, use_web: bool) -> str:
93+
"""Get response from OpenAI API.
94+
95+
use_web: when True, enables the built-in web_search tool and uses the
96+
web-grounded instructions. When False, the model answers from its own
97+
knowledge with plain instructions.
98+
"""
99+
instructions = self._instructions_web if use_web else self._instructions
100+
tools: list[WebSearchToolParam] = [WebSearchToolParam(type="web_search")] if use_web else []
74101

75102
response = await self._openai_client.responses.create(
76-
instructions=self._instructions,
103+
instructions=instructions,
77104
model=self._bot_settings.openai_model,
78105
reasoning=Reasoning(effort=self._effort),
79-
tools=[WebSearchToolParam(type="web_search")],
106+
tools=tools,
80107
max_output_tokens=None,
81108
input=message,
82109
)

tests/unit/bot/cogs/test_open_ai.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,39 @@ async def test_ai_command_success(
106106
assert embed.author.name == "TestUser"
107107
assert embed.author.icon_url == "https://example.com/avatar.png"
108108

109+
@pytest.mark.asyncio
110+
@patch("src.bot.cogs.open_ai.get_bot_settings")
111+
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
112+
async def test_aiweb_command_success(
113+
self, mock_send_embed, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings
114+
):
115+
"""`aiweb` runs the shared flow with use_web=True (web search enabled)."""
116+
mock_get_settings.return_value = mock_bot_settings
117+
118+
with patch.object(openai_cog, "_get_ai_response", return_value="web answer") as mock_get_response:
119+
await openai_cog.aiweb.callback(openai_cog, mock_ctx, msg_text="Latest news")
120+
121+
mock_get_response.assert_awaited_once_with("Latest news", use_web=True)
122+
mock_ctx.send.assert_called_once() # progress message
123+
mock_send_embed.assert_called_once()
124+
embed = mock_send_embed.call_args[0][1]
125+
assert embed.description == "web answer"
126+
assert embed.color == discord.Color.green()
127+
128+
@pytest.mark.asyncio
129+
@patch("src.bot.cogs.open_ai.get_bot_settings")
130+
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
131+
async def test_ai_command_uses_plain_path(
132+
self, mock_send_embed, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings
133+
):
134+
"""`ai` routes through _get_ai_response with use_web=False."""
135+
mock_get_settings.return_value = mock_bot_settings
136+
137+
with patch.object(openai_cog, "_get_ai_response", return_value="plain answer") as mock_get_response:
138+
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="What is Python?")
139+
140+
mock_get_response.assert_awaited_once_with("What is Python?", use_web=False)
141+
109142
@pytest.mark.asyncio
110143
@patch("src.bot.cogs.open_ai.get_bot_settings")
111144
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
@@ -141,7 +174,7 @@ async def test_get_ai_response_success(
141174
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
142175
openai_cog._openai_client = mock_client
143176

144-
result = await openai_cog._get_ai_response("What is Python?")
177+
result = await openai_cog._get_ai_response("What is Python?", use_web=False)
145178

146179
assert result == "This is a mock AI response from OpenAI."
147180

@@ -151,11 +184,31 @@ async def test_get_ai_response_success(
151184

152185
assert call_args[1]["model"] == "gpt-3.5-turbo"
153186
assert call_args[1]["max_output_tokens"] is None
187+
# Plain (no web search) uses the plain instructions and no tools
154188
assert call_args[1]["instructions"] == openai_cog._instructions
155189
assert call_args[1]["input"] == "What is Python?"
156-
# Reasoning effort and web search are enabled
157190
assert call_args[1]["reasoning"]["effort"] == "xhigh"
191+
assert call_args[1]["tools"] == []
192+
193+
@pytest.mark.asyncio
194+
@patch("src.bot.cogs.open_ai.get_bot_settings")
195+
async def test_get_ai_response_web_success(
196+
self, mock_get_settings, openai_cog, mock_bot_settings, mock_openai_response
197+
):
198+
"""Web variant: uses web-grounded instructions and the web_search tool."""
199+
mock_get_settings.return_value = mock_bot_settings
200+
201+
mock_client = MagicMock()
202+
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
203+
openai_cog._openai_client = mock_client
204+
205+
result = await openai_cog._get_ai_response("What is Python?", use_web=True)
206+
207+
assert result == "This is a mock AI response from OpenAI."
208+
call_args = mock_client.responses.create.call_args
209+
assert call_args[1]["instructions"] == openai_cog._instructions_web
158210
assert call_args[1]["tools"][0]["type"] == "web_search"
211+
assert call_args[1]["reasoning"]["effort"] == "xhigh"
159212

160213
@pytest.mark.asyncio
161214
@patch("src.bot.cogs.open_ai.get_bot_settings")
@@ -171,7 +224,7 @@ async def test_get_ai_response_with_leading_trailing_spaces(
171224
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
172225
openai_cog._openai_client = mock_client
173226

174-
result = await openai_cog._get_ai_response("Test message")
227+
result = await openai_cog._get_ai_response("Test message", use_web=False)
175228

176229
assert result == "Response with spaces"
177230

@@ -301,7 +354,7 @@ async def test_get_ai_response_system_message_content(
301354
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
302355
openai_cog._openai_client = mock_client
303356

304-
await openai_cog._get_ai_response("Test message")
357+
await openai_cog._get_ai_response("Test message", use_web=False)
305358

306359
call_args = mock_client.responses.create.call_args[1]
307360
assert call_args["instructions"] == openai_cog._instructions
@@ -320,12 +373,12 @@ async def test_get_ai_response_api_parameters(
320373
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
321374
openai_cog._openai_client = mock_client
322375

323-
await openai_cog._get_ai_response("Test message")
376+
await openai_cog._get_ai_response("Test message", use_web=False)
324377

325378
call_args = mock_client.responses.create.call_args[1]
326379
assert call_args["max_output_tokens"] is None
327380
assert call_args["reasoning"]["effort"] == "xhigh"
328-
assert call_args["tools"][0]["type"] == "web_search"
381+
assert call_args["tools"] == [] # plain path has no tools
329382
assert "temperature" not in call_args
330383
assert call_args["model"] == "gpt-3.5-turbo"
331384

@@ -422,7 +475,7 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co
422475
mock_client.responses.create = AsyncMock(return_value=mock_response)
423476
openai_cog._openai_client = mock_client
424477

425-
result = await openai_cog._get_ai_response("Test message")
478+
result = await openai_cog._get_ai_response("Test message", use_web=False)
426479

427480
assert result == "" # Should strip to empty string
428481

0 commit comments

Comments
 (0)