Skip to content

Commit 143674b

Browse files
committed
Add /api/v2/ayah-claude endpoint using AnsariClaude
- New endpoint with same features as /api/v2/ayah but using AnsariClaude - Maintains API key authentication with QURAN_DOT_COM_API_KEY - Uses ayah-specific system prompt for specialized responses - Implements database caching for responses - Supports augment_question parameter for enhanced queries - Includes comprehensive unit tests
1 parent b3d14c5 commit 143674b

File tree

2 files changed

+381
-0
lines changed

2 files changed

+381
-0
lines changed

src/ansari/app/main_api.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,3 +1078,93 @@ async def answer_ayah_question(
10781078
except Exception:
10791079
logger.error("Error in answer_ayah_question", exc_info=True)
10801080
raise HTTPException(status_code=500, detail="Internal server error")
1081+
1082+
1083+
@app.post("/api/v2/ayah-claude")
1084+
async def answer_ayah_question_claude(
1085+
req: AyahQuestionRequest,
1086+
settings: Settings = Depends(get_settings),
1087+
db: AnsariDB = Depends(lambda: AnsariDB(get_settings())),
1088+
):
1089+
"""Answer questions about specific Quranic verses using AnsariClaude.
1090+
1091+
This endpoint provides similar functionality to /api/v2/ayah but uses AnsariClaude
1092+
for more advanced reasoning and citation capabilities while maintaining:
1093+
- API key authentication
1094+
- Ayah-specific system prompt
1095+
- Database caching for responses
1096+
- Tafsir search with ayah filtering
1097+
"""
1098+
if req.apikey != settings.QURAN_DOT_COM_API_KEY.get_secret_value():
1099+
raise HTTPException(status_code=401, detail="Unauthorized")
1100+
1101+
try:
1102+
ayah_id = req.surah * 1000 + req.ayah
1103+
1104+
# Check if the answer is already stored in the database
1105+
if req.use_cache:
1106+
stored_answer = db.get_quran_answer(req.surah, req.ayah, req.question)
1107+
if stored_answer:
1108+
return {"response": stored_answer}
1109+
1110+
# Create AnsariClaude instance with ayah-specific system prompt
1111+
logger.debug(f"Creating AnsariClaude instance for {req.surah}:{req.ayah}")
1112+
1113+
# Load the ayah-specific system prompt
1114+
system_prompt_path = os.path.join(
1115+
os.path.dirname(__file__),
1116+
"..",
1117+
"system_prompts",
1118+
settings.AYAH_SYSTEM_PROMPT_FILE_NAME
1119+
)
1120+
1121+
with open(system_prompt_path, "r") as f:
1122+
ayah_system_prompt = f.read()
1123+
1124+
# Initialize AnsariClaude with the ayah-specific system prompt
1125+
ansari_claude = AnsariClaude(
1126+
settings,
1127+
system_prompt=ayah_system_prompt,
1128+
source_type=SourceType.WEB # Using WEB for now, could add QURAN_COM if needed
1129+
)
1130+
1131+
# Prepare the context with ayah information
1132+
ayah_context = f"Question about Surah {req.surah}, Ayah {req.ayah}"
1133+
1134+
# Build the search query with metadata filter for the specific ayah
1135+
search_context = {
1136+
"tool_name": "search_tafsir",
1137+
"metadata_filter": f"part.from_ayah_int<={ayah_id} AND part.to_ayah_int>={ayah_id}",
1138+
}
1139+
1140+
# Create a message that includes the context and triggers appropriate searches
1141+
enhanced_question = f"{ayah_context}\n\n{req.question}"
1142+
1143+
# If augment_question is enabled, add instructions for query enhancement
1144+
if req.augment_question:
1145+
enhanced_question += "\n\nPlease search relevant tafsir sources and provide a comprehensive answer."
1146+
1147+
# Prepare messages for AnsariClaude
1148+
messages = [
1149+
{
1150+
"role": "user",
1151+
"content": enhanced_question
1152+
}
1153+
]
1154+
1155+
# Generate response using AnsariClaude
1156+
response_generator = ansari_claude.replace_message_history(messages)
1157+
1158+
# Collect the full response (since we need to return JSON, not stream)
1159+
full_response = ""
1160+
for chunk in response_generator:
1161+
full_response += chunk
1162+
1163+
# Store the answer in the database
1164+
db.store_quran_answer(req.surah, req.ayah, req.question, full_response)
1165+
1166+
return {"response": full_response}
1167+
1168+
except Exception as e:
1169+
logger.error(f"Error in answer_ayah_question_claude: {e}", exc_info=True)
1170+
raise HTTPException(status_code=500, detail="Internal server error")
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"""Unit tests for the /api/v2/ayah-claude endpoint."""
2+
3+
import pytest
4+
from fastapi.testclient import TestClient
5+
from unittest.mock import MagicMock, patch, mock_open
6+
import json
7+
8+
9+
@pytest.fixture
10+
def client():
11+
"""Create a test client for the FastAPI app."""
12+
from src.ansari.app.main_api import app
13+
return TestClient(app)
14+
15+
16+
@pytest.fixture
17+
def mock_settings():
18+
"""Mock settings with test API key."""
19+
with patch("src.ansari.app.main_api.get_settings") as mock:
20+
settings = MagicMock()
21+
settings.QURAN_DOT_COM_API_KEY.get_secret_value.return_value = "test-api-key"
22+
settings.AYAH_SYSTEM_PROMPT_FILE_NAME = "ayah_system_prompt.md"
23+
settings.MONGO_URL = "mongodb://test:27017"
24+
settings.MONGO_DB_NAME = "test_db"
25+
mock.return_value = settings
26+
yield settings
27+
28+
29+
@pytest.fixture
30+
def mock_db():
31+
"""Mock database for testing."""
32+
with patch("src.ansari.app.main_api.AnsariDB") as mock_class:
33+
db_instance = MagicMock()
34+
db_instance.get_quran_answer = MagicMock()
35+
db_instance.store_quran_answer = MagicMock()
36+
mock_class.return_value = db_instance
37+
yield db_instance
38+
39+
40+
@pytest.fixture
41+
def mock_ansari_claude():
42+
"""Mock AnsariClaude for testing."""
43+
with patch("src.ansari.app.main_api.AnsariClaude") as mock:
44+
instance = MagicMock()
45+
# Mock the generator response
46+
def mock_generator():
47+
yield "This is a test response "
48+
yield "about the ayah "
49+
yield "with citations."
50+
instance.replace_message_history.return_value = mock_generator()
51+
mock.return_value = instance
52+
yield mock
53+
54+
55+
class TestAyahClaudeEndpoint:
56+
"""Test cases for the /api/v2/ayah-claude endpoint."""
57+
58+
def test_endpoint_exists(self, client):
59+
"""Test that the endpoint is registered."""
60+
response = client.post(
61+
"/api/v2/ayah-claude",
62+
json={
63+
"surah": 1,
64+
"ayah": 1,
65+
"question": "What is the meaning?",
66+
"apikey": "wrong-key"
67+
}
68+
)
69+
# Should not return 404
70+
assert response.status_code != 404
71+
72+
def test_authentication_required(self, client, mock_settings):
73+
"""Test that API key authentication is enforced."""
74+
# Test with wrong API key
75+
response = client.post(
76+
"/api/v2/ayah-claude",
77+
json={
78+
"surah": 1,
79+
"ayah": 1,
80+
"question": "What is the meaning?",
81+
"apikey": "wrong-api-key"
82+
}
83+
)
84+
assert response.status_code == 401
85+
assert response.json()["detail"] == "Unauthorized"
86+
87+
def test_successful_request_with_valid_key(self, client, mock_settings, mock_db, mock_ansari_claude):
88+
"""Test successful request with valid API key."""
89+
# Mock the file reading for system prompt
90+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
91+
# Mock that no cached answer exists
92+
mock_db.get_quran_answer.return_value = None
93+
94+
response = client.post(
95+
"/api/v2/ayah-claude",
96+
json={
97+
"surah": 1,
98+
"ayah": 1,
99+
"question": "What is the meaning?",
100+
"apikey": "test-api-key",
101+
"use_cache": True
102+
}
103+
)
104+
105+
assert response.status_code == 200
106+
assert "response" in response.json()
107+
assert response.json()["response"] == "This is a test response about the ayah with citations."
108+
109+
def test_cache_retrieval(self, client, mock_settings, mock_db):
110+
"""Test that cached answers are returned when available."""
111+
# Mock a cached answer
112+
cached_answer = "This is a cached response"
113+
mock_db.get_quran_answer.return_value = cached_answer
114+
115+
response = client.post(
116+
"/api/v2/ayah-claude",
117+
json={
118+
"surah": 1,
119+
"ayah": 1,
120+
"question": "What is the meaning?",
121+
"apikey": "test-api-key",
122+
"use_cache": True
123+
}
124+
)
125+
126+
assert response.status_code == 200
127+
assert response.json()["response"] == cached_answer
128+
# Verify that get_quran_answer was called
129+
mock_db.get_quran_answer.assert_called_once_with(1, 1, "What is the meaning?")
130+
131+
def test_cache_disabled(self, client, mock_settings, mock_db, mock_ansari_claude):
132+
"""Test that cache is bypassed when use_cache is False."""
133+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
134+
# Even if cache has an answer, it shouldn't be used
135+
mock_db.get_quran_answer.return_value = "Cached answer"
136+
137+
response = client.post(
138+
"/api/v2/ayah-claude",
139+
json={
140+
"surah": 2,
141+
"ayah": 255,
142+
"question": "Explain this verse",
143+
"apikey": "test-api-key",
144+
"use_cache": False
145+
}
146+
)
147+
148+
assert response.status_code == 200
149+
# Should not return cached answer
150+
assert response.json()["response"] != "Cached answer"
151+
# get_quran_answer should not be called when cache is disabled
152+
mock_db.get_quran_answer.assert_not_called()
153+
154+
def test_augment_question_feature(self, client, mock_settings, mock_db, mock_ansari_claude):
155+
"""Test that augment_question adds enhancement instructions."""
156+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
157+
mock_db.get_quran_answer.return_value = None
158+
159+
response = client.post(
160+
"/api/v2/ayah-claude",
161+
json={
162+
"surah": 3,
163+
"ayah": 14,
164+
"question": "What does this mean?",
165+
"apikey": "test-api-key",
166+
"augment_question": True,
167+
"use_cache": False
168+
}
169+
)
170+
171+
assert response.status_code == 200
172+
# Verify that AnsariClaude was called
173+
mock_ansari_claude.assert_called_once()
174+
# Verify the message passed included enhancement
175+
call_args = mock_ansari_claude.return_value.replace_message_history.call_args
176+
messages = call_args[0][0]
177+
assert "search relevant tafsir sources" in messages[0]["content"]
178+
179+
def test_database_storage(self, client, mock_settings, mock_db, mock_ansari_claude):
180+
"""Test that responses are stored in the database."""
181+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
182+
mock_db.get_quran_answer.return_value = None
183+
184+
response = client.post(
185+
"/api/v2/ayah-claude",
186+
json={
187+
"surah": 4,
188+
"ayah": 34,
189+
"question": "Explain the context",
190+
"apikey": "test-api-key",
191+
"use_cache": True
192+
}
193+
)
194+
195+
assert response.status_code == 200
196+
# Verify that store_quran_answer was called
197+
mock_db.store_quran_answer.assert_called_once_with(
198+
4, 34, "Explain the context",
199+
"This is a test response about the ayah with citations."
200+
)
201+
202+
def test_ayah_specific_system_prompt(self, client, mock_settings, mock_db, mock_ansari_claude):
203+
"""Test that ayah-specific system prompt is loaded."""
204+
system_prompt_content = "Special ayah system prompt"
205+
206+
with patch("builtins.open", mock_open(read_data=system_prompt_content)):
207+
mock_db.get_quran_answer.return_value = None
208+
209+
response = client.post(
210+
"/api/v2/ayah-claude",
211+
json={
212+
"surah": 5,
213+
"ayah": 3,
214+
"question": "What is the significance?",
215+
"apikey": "test-api-key"
216+
}
217+
)
218+
219+
assert response.status_code == 200
220+
# Verify AnsariClaude was initialized with the system prompt
221+
mock_ansari_claude.assert_called_once()
222+
call_args = mock_ansari_claude.call_args
223+
assert call_args[1]["system_prompt"] == system_prompt_content
224+
225+
def test_ayah_id_calculation(self, client, mock_settings, mock_db, mock_ansari_claude):
226+
"""Test that ayah_id is calculated correctly for tafsir filtering."""
227+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
228+
mock_db.get_quran_answer.return_value = None
229+
230+
# Test with Surah 2, Ayah 255 (Ayat al-Kursi)
231+
# Expected ayah_id = 2 * 1000 + 255 = 2255
232+
response = client.post(
233+
"/api/v2/ayah-claude",
234+
json={
235+
"surah": 2,
236+
"ayah": 255,
237+
"question": "Explain Ayat al-Kursi",
238+
"apikey": "test-api-key"
239+
}
240+
)
241+
242+
assert response.status_code == 200
243+
# The ayah_id should be used in the context
244+
call_args = mock_ansari_claude.return_value.replace_message_history.call_args
245+
messages = call_args[0][0]
246+
assert "Surah 2, Ayah 255" in messages[0]["content"]
247+
248+
def test_error_handling(self, client, mock_settings, mock_db):
249+
"""Test that errors are handled gracefully."""
250+
with patch("src.ansari.app.main_api.AnsariClaude") as mock_claude:
251+
# Make AnsariClaude raise an exception
252+
mock_claude.side_effect = Exception("Test error")
253+
mock_db.get_quran_answer.return_value = None
254+
255+
with patch("builtins.open", mock_open(read_data="Test system prompt")):
256+
response = client.post(
257+
"/api/v2/ayah-claude",
258+
json={
259+
"surah": 1,
260+
"ayah": 1,
261+
"question": "Test question",
262+
"apikey": "test-api-key"
263+
}
264+
)
265+
266+
assert response.status_code == 500
267+
assert response.json()["detail"] == "Internal server error"
268+
269+
def test_request_validation(self, client, mock_settings):
270+
"""Test that request validation works correctly."""
271+
# Missing required fields
272+
response = client.post(
273+
"/api/v2/ayah-claude",
274+
json={
275+
"surah": 1,
276+
# Missing ayah, question, and apikey
277+
}
278+
)
279+
assert response.status_code == 422 # Unprocessable Entity
280+
281+
# Invalid data types
282+
response = client.post(
283+
"/api/v2/ayah-claude",
284+
json={
285+
"surah": "not-a-number",
286+
"ayah": 1,
287+
"question": "Test",
288+
"apikey": "test-key"
289+
}
290+
)
291+
assert response.status_code == 422

0 commit comments

Comments
 (0)