Skip to content

Commit 28932a2

Browse files
authored
Response improvements and refactoring (#2407)
* improvements and refactoring * added prompt checks and tests for it
1 parent 385ca51 commit 28932a2

File tree

10 files changed

+475
-61
lines changed

10 files changed

+475
-61
lines changed

backend/apps/ai/agent/tools/rag/generator.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from typing import Any
66

77
import openai
8+
from django.core.exceptions import ObjectDoesNotExist
9+
10+
from apps.core.models.prompt import Prompt
811

912
logger = logging.getLogger(__name__)
1013

@@ -13,23 +16,6 @@ class Generator:
1316
"""Generates answers to user queries based on retrieved context."""
1417

1518
MAX_TOKENS = 2000
16-
SYSTEM_PROMPT = """
17-
You are a helpful and professional AI assistant for the OWASP Foundation.
18-
Your task is to answer user queries based ONLY on the provided context.
19-
Follow these rules strictly:
20-
1. Base your entire answer on the information given in the "CONTEXT" section. Do not use any
21-
external knowledge unless and until it is about OWASP.
22-
2. Do not mention or refer to the word "context", "based on context", "provided information",
23-
"Information given to me" or similar phrases in your responses.
24-
3. you will answer questions only related to OWASP and within the scope of OWASP.
25-
4. Be concise and directly answer the user's query.
26-
5. Provide the necessary link if the context contains a URL.
27-
6. If there is any query based on location, you need to look for latitude and longitude in the
28-
context and provide the nearest OWASP chapter based on that.
29-
7. You can ask for more information if the query is very personalized or user-centric.
30-
8. after trying all of the above, If the context does not contain the information or you think that
31-
it is out of scope for OWASP, you MUST state: "please ask question related to OWASP."
32-
"""
3319
TEMPERATURE = 0.4
3420

3521
def __init__(self, chat_model: str = "gpt-4o"):
@@ -103,10 +89,15 @@ def generate_answer(self, query: str, context_chunks: list[dict[str, Any]]) -> s
10389
"""
10490

10591
try:
92+
system_prompt = Prompt.get_rag_system_prompt()
93+
if not system_prompt or not system_prompt.strip():
94+
error_msg = "Prompt with key 'rag-system-prompt' not found."
95+
raise ObjectDoesNotExist(error_msg)
96+
10697
response = self.openai_client.chat.completions.create(
10798
model=self.chat_model,
10899
messages=[
109-
{"role": "system", "content": self.SYSTEM_PROMPT},
100+
{"role": "system", "content": system_prompt},
110101
{"role": "user", "content": user_prompt},
111102
],
112103
temperature=self.TEMPERATURE,

backend/apps/ai/common/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""AI app constants."""
22

33
DEFAULT_LAST_REQUEST_OFFSET_SECONDS = 2
4-
DEFAULT_CHUNKS_RETRIEVAL_LIMIT = 5
5-
DEFAULT_SIMILARITY_THRESHOLD = 0.4
4+
DEFAULT_CHUNKS_RETRIEVAL_LIMIT = 8
5+
DEFAULT_SIMILARITY_THRESHOLD = 0.1
66
DELIMITER = "\n\n"
77
GITHUB_REQUEST_INTERVAL_SECONDS = 0.5
88
MIN_REQUEST_INTERVAL_SECONDS = 1.2

backend/apps/core/models/prompt.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,23 @@ def get_owasp_project_summary() -> str:
139139
140140
"""
141141
return Prompt.get_text("owasp-project-summary")
142+
143+
@staticmethod
144+
def get_rag_system_prompt() -> str:
145+
"""Return RAG system prompt.
146+
147+
Returns
148+
str: The RAG system prompt text.
149+
150+
"""
151+
return Prompt.get_text("rag-system-prompt")
152+
153+
@staticmethod
154+
def get_slack_question_detector_prompt() -> str:
155+
"""Return Slack question detector prompt.
156+
157+
Returns
158+
str: The Slack question detector prompt text.
159+
160+
"""
161+
return Prompt.get_text("slack-question-detector-system-prompt")

backend/apps/slack/blocks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from typing import Any
66

7+
from apps.slack.utils import format_links_for_slack
8+
79
DIVIDER = "{{ DIVIDER }}"
810
SECTION_BREAK = "{{ SECTION_BREAK }}"
911

@@ -30,7 +32,7 @@ def markdown(text: str) -> dict:
3032
"""
3133
return {
3234
"type": "section",
33-
"text": {"type": "mrkdwn", "text": text},
35+
"text": {"type": "mrkdwn", "text": format_links_for_slack(text)},
3436
}
3537

3638

backend/apps/slack/common/question_detector.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import re
88

99
import openai
10+
from django.core.exceptions import ObjectDoesNotExist
1011

12+
from apps.core.models.prompt import Prompt
1113
from apps.slack.constants import OWASP_KEYWORDS
1214

1315
logger = logging.getLogger(__name__)
@@ -20,18 +22,6 @@ class QuestionDetector:
2022
TEMPERATURE = 0.1
2123
CHAT_MODEL = "gpt-4o"
2224

23-
SYSTEM_PROMPT = """
24-
You are an expert in cybersecurity and OWASP (Open Web Application Security Project).
25-
Your task is to determine if a given question is related to OWASP, cybersecurity,
26-
web application security, or similar topics.
27-
28-
Key OWASP-related terms: {keywords}
29-
30-
Respond with only "YES" if the question is related to OWASP/cybersecurity,
31-
or "NO" if it's not.
32-
Do not provide any explanation or additional text.
33-
"""
34-
3525
def __init__(self):
3626
"""Initialize the question detector.
3727
@@ -98,7 +88,12 @@ def is_owasp_question_with_openai(self, text: str) -> bool | None:
9888
- None: If the API call fails or the response is unexpected.
9989
10090
"""
101-
system_prompt = self.SYSTEM_PROMPT.format(keywords=", ".join(self.owasp_keywords))
91+
prompt_template = Prompt.get_slack_question_detector_prompt()
92+
if not prompt_template or not prompt_template.strip():
93+
error_msg = "Prompt with key 'slack-question-detector-system-prompt' not found."
94+
raise ObjectDoesNotExist(error_msg)
95+
96+
system_prompt = prompt_template.format(keywords=", ".join(self.owasp_keywords))
10297
user_prompt = f'Question: "{text}"'
10398

10499
try:

backend/apps/slack/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ def escape(content) -> str:
3535
return escape_html(content, quote=False)
3636

3737

38+
def format_links_for_slack(text: str) -> str:
39+
"""Convert Markdown links to Slack markdown link format.
40+
41+
Args:
42+
text (str): The input text that may include Markdown links.
43+
44+
Returns:
45+
str: Text with Markdown links converted to Slack markdown links.
46+
47+
"""
48+
if not text:
49+
return text
50+
51+
markdown_link_pattern = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
52+
return markdown_link_pattern.sub(r"<\2|\1>", text)
53+
54+
3855
@lru_cache
3956
def get_gsoc_projects(year: int) -> list:
4057
"""Get GSoC projects.

backend/tests/apps/ai/agent/tools/rag/generator_test.py

Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import openai
77
import pytest
8+
from django.core.exceptions import ObjectDoesNotExist
89

910
from apps.ai.agent.tools.rag.generator import Generator
1011

@@ -102,6 +103,10 @@ def test_generate_answer_success(self):
102103
with (
103104
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
104105
patch("openai.OpenAI") as mock_openai,
106+
patch(
107+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
108+
return_value="System prompt",
109+
),
105110
):
106111
mock_client = MagicMock()
107112
mock_response = MagicMock()
@@ -128,6 +133,10 @@ def test_generate_answer_with_custom_model(self):
128133
with (
129134
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
130135
patch("openai.OpenAI") as mock_openai,
136+
patch(
137+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
138+
return_value="System prompt",
139+
),
131140
):
132141
mock_client = MagicMock()
133142
mock_response = MagicMock()
@@ -150,6 +159,10 @@ def test_generate_answer_openai_error(self):
150159
with (
151160
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
152161
patch("openai.OpenAI") as mock_openai,
162+
patch(
163+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
164+
return_value="System prompt",
165+
),
153166
):
154167
mock_client = MagicMock()
155168
mock_client.chat.completions.create.side_effect = openai.OpenAIError("API Error")
@@ -167,6 +180,10 @@ def test_generate_answer_with_empty_chunks(self):
167180
with (
168181
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
169182
patch("openai.OpenAI") as mock_openai,
183+
patch(
184+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
185+
return_value="System prompt",
186+
),
170187
):
171188
mock_client = MagicMock()
172189
mock_response = MagicMock()
@@ -184,14 +201,123 @@ def test_generate_answer_with_empty_chunks(self):
184201
assert "No context provided" in call_args[1]["messages"][1]["content"]
185202

186203
def test_system_prompt_content(self):
187-
"""Test that system prompt contains expected content."""
188-
assert "OWASP Foundation" in Generator.SYSTEM_PROMPT
189-
assert "context" in Generator.SYSTEM_PROMPT.lower()
190-
assert "professional" in Generator.SYSTEM_PROMPT.lower()
191-
assert "latitude and longitude" in Generator.SYSTEM_PROMPT.lower()
204+
"""Test that system prompt passed to OpenAI comes from Prompt getter."""
205+
with (
206+
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
207+
patch("openai.OpenAI") as mock_openai,
208+
patch(
209+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
210+
return_value="OWASP Foundation system prompt",
211+
) as mock_prompt_getter,
212+
):
213+
mock_client = MagicMock()
214+
mock_response = MagicMock()
215+
mock_response.choices = [MagicMock()]
216+
mock_response.choices[0].message.content = "Answer"
217+
mock_client.chat.completions.create.return_value = mock_response
218+
mock_openai.return_value = mock_client
219+
220+
generator = Generator()
221+
generator.generate_answer("Q", [])
222+
223+
call_args = mock_client.chat.completions.create.call_args
224+
assert call_args[1]["messages"][0]["role"] == "system"
225+
assert call_args[1]["messages"][0]["content"] == "OWASP Foundation system prompt"
226+
mock_prompt_getter.assert_called_once()
227+
228+
def test_generate_answer_missing_system_prompt(self):
229+
"""Test answer generation when system prompt is missing."""
230+
with (
231+
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
232+
patch("openai.OpenAI") as mock_openai,
233+
patch(
234+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
235+
return_value=None,
236+
),
237+
):
238+
mock_client = MagicMock()
239+
mock_openai.return_value = mock_client
240+
241+
generator = Generator()
242+
243+
chunks = [{"source_name": "Test", "text": "Test content"}]
244+
245+
with pytest.raises(
246+
ObjectDoesNotExist, match="Prompt with key 'rag-system-prompt' not found"
247+
):
248+
generator.generate_answer("Test query", chunks)
249+
250+
def test_generate_answer_empty_system_prompt(self):
251+
"""Test answer generation when system prompt is empty."""
252+
with (
253+
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
254+
patch("openai.OpenAI") as mock_openai,
255+
patch(
256+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
257+
return_value=" ",
258+
),
259+
):
260+
mock_client = MagicMock()
261+
mock_openai.return_value = mock_client
262+
263+
generator = Generator()
264+
265+
chunks = [{"source_name": "Test", "text": "Test content"}]
266+
267+
with pytest.raises(
268+
ObjectDoesNotExist, match="Prompt with key 'rag-system-prompt' not found"
269+
):
270+
generator.generate_answer("Test query", chunks)
271+
272+
def test_generate_answer_empty_openai_response(self):
273+
"""Test answer generation when OpenAI returns empty content."""
274+
with (
275+
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
276+
patch("openai.OpenAI") as mock_openai,
277+
patch(
278+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
279+
return_value="System prompt",
280+
),
281+
):
282+
mock_client = MagicMock()
283+
mock_response = MagicMock()
284+
mock_response.choices = [MagicMock()]
285+
mock_response.choices[0].message.content = ""
286+
mock_client.chat.completions.create.return_value = mock_response
287+
mock_openai.return_value = mock_client
288+
289+
generator = Generator()
290+
291+
chunks = [{"source_name": "Test", "text": "Test content"}]
292+
result = generator.generate_answer("Test query", chunks)
293+
294+
assert result == ""
295+
296+
def test_generate_answer_none_openai_response(self):
297+
"""Test answer generation when OpenAI returns None content."""
298+
with (
299+
patch.dict(os.environ, {"DJANGO_OPEN_AI_SECRET_KEY": "test-key"}),
300+
patch("openai.OpenAI") as mock_openai,
301+
patch(
302+
"apps.core.models.prompt.Prompt.get_rag_system_prompt",
303+
return_value="System prompt",
304+
),
305+
):
306+
mock_client = MagicMock()
307+
mock_response = MagicMock()
308+
mock_response.choices = [MagicMock()]
309+
mock_response.choices[0].message.content = None
310+
mock_client.chat.completions.create.return_value = mock_response
311+
mock_openai.return_value = mock_client
312+
313+
generator = Generator()
314+
315+
chunks = [{"source_name": "Test", "text": "Test content"}]
316+
317+
with pytest.raises(AttributeError):
318+
generator.generate_answer("Test query", chunks)
192319

193320
def test_constants(self):
194321
"""Test class constants have expected values."""
195322
assert Generator.MAX_TOKENS == 2000
196-
assert isinstance(Generator.SYSTEM_PROMPT, str)
197-
assert len(Generator.SYSTEM_PROMPT) > 0
323+
assert Generator.TEMPERATURE == 0.4

backend/tests/apps/ai/agent/tools/rag/rag_tool_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ def test_query_with_defaults(self):
124124
assert result == "Default answer"
125125
mock_retriever.retrieve.assert_called_once_with(
126126
content_types=None,
127-
limit=5,
127+
limit=8,
128128
query="Test question",
129-
similarity_threshold=0.4,
129+
similarity_threshold=0.1,
130130
)
131131

132132
def test_query_empty_content_types(self):
@@ -152,9 +152,9 @@ def test_query_empty_content_types(self):
152152
assert result == "Answer"
153153
mock_retriever.retrieve.assert_called_once_with(
154154
content_types=[],
155-
limit=5,
155+
limit=8,
156156
query="Test question",
157-
similarity_threshold=0.4,
157+
similarity_threshold=0.1,
158158
)
159159

160160
@patch("apps.ai.agent.tools.rag.rag_tool.logger")

0 commit comments

Comments
 (0)