Skip to content

Commit 0a4a8da

Browse files
Refactor GenAI backend and improve testability
1 parent 566f433 commit 0a4a8da

File tree

11 files changed

+631
-556
lines changed

11 files changed

+631
-556
lines changed

.github/workflows/ci-tests.yml

Lines changed: 7 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -254,66 +254,12 @@ jobs:
254254
microservices/document-service/build/reports/tests/test/
255255
microservices/document-service/build/test-results/test/
256256
257-
# Job 5: GenAI Service Tests (Kotlin/Spring Boot)
258-
genai-service-tests:
259-
name: GenAI Service Tests
260-
runs-on: ubuntu-latest
261-
timeout-minutes: 15
262-
263-
services:
264-
postgres:
265-
image: postgres:15
266-
env:
267-
POSTGRES_DB: genai_test
268-
POSTGRES_USER: test
269-
POSTGRES_PASSWORD: test
270-
ports:
271-
- 5432:5432
272-
options: >-
273-
--health-cmd "pg_isready -U test -d genai_test"
274-
--health-interval 10s
275-
--health-timeout 5s
276-
--health-retries 5
277-
278-
steps:
279-
- name: Checkout code
280-
uses: actions/checkout@v4
281-
282-
- name: Setup Java
283-
uses: actions/setup-java@v4
284-
with:
285-
java-version: ${{ env.JAVA_VERSION }}
286-
distribution: 'temurin'
287-
cache: 'gradle'
288-
289-
- name: Make gradlew executable
290-
run: chmod +x microservices/genai-service/gradlew
291-
292-
- name: Run tests
293-
env:
294-
SPRING_PROFILES_ACTIVE: test
295-
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/genai_test
296-
SPRING_DATASOURCE_USERNAME: test
297-
SPRING_DATASOURCE_PASSWORD: test
298-
run: |
299-
cd microservices/genai-service
300-
./gradlew test --info --stacktrace
301-
302-
- name: Archive test results
303-
uses: actions/upload-artifact@v4
304-
if: always()
305-
with:
306-
name: genai-service-test-results
307-
path: |
308-
microservices/genai-service/build/reports/tests/test/
309-
microservices/genai-service/build/test-results/test/
310-
311-
# Job 6: Integration Tests
257+
# Job 5: Integration Tests
312258
integration-tests:
313259
name: Integration Tests
314260
runs-on: ubuntu-latest
315261
timeout-minutes: 30
316-
needs: [client-tests, genai-tests, auth-service-tests, document-service-tests, genai-service-tests]
262+
needs: [client-tests, genai-tests, auth-service-tests, document-service-tests]
317263

318264
services:
319265
postgres:
@@ -384,9 +330,6 @@ jobs:
384330
chmod +x gradlew
385331
nohup ./gradlew bootRun &
386332
387-
cd ../genai-service
388-
chmod +x gradlew
389-
nohup ./gradlew bootRun &
390333
391334
# Wait for services to start
392335
sleep 30
@@ -408,9 +351,8 @@ jobs:
408351
curl -f http://localhost:8081/health || echo "GenAI service not ready"
409352
curl -f http://localhost:8086/actuator/health || echo "Auth service not ready"
410353
curl -f http://localhost:8084/actuator/health || echo "Document service not ready"
411-
curl -f http://localhost:8085/actuator/health || echo "GenAI service not ready"
412354
413-
# Job 7: Security Tests
355+
# Job 6: Security Tests
414356
security-tests:
415357
name: Security Tests
416358
runs-on: ubuntu-latest
@@ -441,7 +383,7 @@ jobs:
441383
base: main
442384
head: HEAD
443385

444-
# Job 8: Performance Tests
386+
# Job 7: Performance Tests
445387
performance-tests:
446388
name: Performance Tests
447389
runs-on: ubuntu-latest
@@ -492,11 +434,11 @@ jobs:
492434
# Run load test (will fail without running services, but validates config)
493435
k6 run --vus 1 --duration 10s load-test.js || true
494436
495-
# Job 9: Test Summary
437+
# Job 8: Test Summary
496438
test-summary:
497439
name: Test Summary
498440
runs-on: ubuntu-latest
499-
needs: [client-tests, genai-tests, auth-service-tests, document-service-tests, genai-service-tests, integration-tests, security-tests]
441+
needs: [client-tests, genai-tests, auth-service-tests, document-service-tests, integration-tests, security-tests]
500442
if: always()
501443

502444
steps:
@@ -535,11 +477,6 @@ jobs:
535477
echo "❌ Document Service Tests: FAILED" >> test-summary.md
536478
fi
537479
538-
if [ "${{ needs.genai-service-tests.result }}" = "success" ]; then
539-
echo "✅ GenAI Service Tests: PASSED" >> test-summary.md
540-
else
541-
echo "❌ GenAI Service Tests: FAILED" >> test-summary.md
542-
fi
543480
544481
if [ "${{ needs.integration-tests.result }}" = "success" ]; then
545482
echo "✅ Integration Tests: PASSED" >> test-summary.md
@@ -581,7 +518,7 @@ jobs:
581518
body: summary
582519
});
583520
584-
# Job 10: Cleanup
521+
# Job 9: Cleanup
585522
cleanup:
586523
name: Cleanup
587524
runs-on: ubuntu-latest

genAi/chains.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,18 @@
1212
from langchain.output_parsers import PydanticOutputParser
1313
from langchain.chains.combine_documents.reduce import ReduceDocumentsChain
1414

15+
1516
class FlashcardChain:
1617
"""
1718
Custom chain for generating flashcards from a summary.
1819
"""
20+
1921
def __init__(self, llm):
2022
llm = llm.with_structured_output(FlashcardResponse)
2123

22-
self.map_chain = ChatPromptTemplate.from_template(
23-
"""
24+
self.map_chain = (
25+
ChatPromptTemplate.from_template(
26+
"""
2427
Generate 3 flashcards from the following content.
2528
2629
Each flashcard is an object with:
@@ -31,13 +34,18 @@ def __init__(self, llm):
3134
Content:
3235
{doc}
3336
"""
34-
) | llm
37+
)
38+
| llm
39+
)
40+
41+
self.reduce_chain = (
42+
ChatPromptTemplate.from_template(
43+
"Given the below list of flashcards, clean and deduplicate them. Return a final list of flashcards.\n\n"
44+
"Flashcards:\n{flashcards}\n"
45+
)
46+
| llm
47+
)
3548

36-
self.reduce_chain = ChatPromptTemplate.from_template(
37-
"Given the below list of flashcards, clean and deduplicate them. Return a final list of flashcards.\n\n"
38-
"Flashcards:\n{flashcards}\n"
39-
) | llm
40-
4149
async def invoke(self, documents: List[Document]):
4250
"""
4351
Generate flashcards from the provided documents.
@@ -49,28 +57,32 @@ async def invoke(self, documents: List[Document]):
4957
FlashcardResponse: The generated flashcards in structured format.
5058
"""
5159
contents = [doc.page_content for doc in documents]
60+
5261
async def process(content: str):
5362
return (await self.map_chain.ainvoke({"doc": content})).flashcards
54-
63+
5564
# Map in parallel
5665
results = await asyncio.gather(*[process(c) for c in contents])
5766
all_flashcards = [fc for group in results for fc in group]
5867

5968
# Reduce
6069
joined = "\n".join(str(fc) for fc in all_flashcards)
6170
reduced = await self.reduce_chain.ainvoke({"flashcards": joined})
62-
71+
6372
return reduced
64-
73+
74+
6575
class QuizChain:
6676
"""
6777
Custom chain for generating a quiz from a summary.
6878
"""
79+
6980
def __init__(self, llm):
7081
llm = llm.with_structured_output(QuizResponse)
7182

72-
self.map_chain = ChatPromptTemplate.from_template(
73-
"""
83+
self.map_chain = (
84+
ChatPromptTemplate.from_template(
85+
"""
7486
Generate a mini (3-4 question) Quiz (MCQ and Short Answer) from the content below.
7587
7688
Each question should include:
@@ -83,15 +95,20 @@ def __init__(self, llm):
8395
Content:
8496
{doc}
8597
"""
86-
) | llm
87-
88-
self.reduce_chain = ChatPromptTemplate.from_template(
89-
"Given the below list of quiz questions, consolidate, clean, and deduplicate them into a single whole quiz.\n"
90-
"Ensure a realistic distribution of question types and difficulty levels.\n"
91-
"Return a quiz with the selected questions.\n\n"
92-
"Questions:\n{questions}\n"
93-
) | llm
94-
98+
)
99+
| llm
100+
)
101+
102+
self.reduce_chain = (
103+
ChatPromptTemplate.from_template(
104+
"Given the below list of quiz questions, consolidate, clean, and deduplicate them into a single whole quiz.\n"
105+
"Ensure a realistic distribution of question types and difficulty levels.\n"
106+
"Return a quiz with the selected questions.\n\n"
107+
"Questions:\n{questions}\n"
108+
)
109+
| llm
110+
)
111+
95112
async def invoke(self, documents: List[Document]):
96113
"""
97114
Generate a quiz from the provided documents.
@@ -103,16 +120,17 @@ async def invoke(self, documents: List[Document]):
103120
QuizResponse: The generated quiz in structured format.
104121
"""
105122
contents = [doc.page_content for doc in documents]
123+
106124
async def process(content: str):
107125
mini_quiz: QuizResponse = await self.map_chain.ainvoke({"doc": content})
108126
return mini_quiz.questions
109-
127+
110128
# Map in parallel
111129
results = await asyncio.gather(*[process(c) for c in contents])
112130
all_questions = [q for group in results for q in group]
113131

114132
# Reduce
115133
joined = "\n".join(str(q) for q in all_questions)
116134
reduced: QuizResponse = await self.reduce_chain.ainvoke({"questions": joined})
117-
118-
return reduced
135+
136+
return reduced

genAi/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import pytest
2+
import os
3+
from unittest.mock import Mock, patch
4+
5+
6+
@pytest.fixture(autouse=True)
7+
def mock_openai_environment():
8+
"""Mock OpenAI environment variables and LLM initialization"""
9+
with patch.dict(
10+
os.environ,
11+
{
12+
"OPEN_WEBUI_API_KEY_CHAT": "test-key-chat",
13+
"OPEN_WEBUI_API_KEY_GEN": "test-key-gen",
14+
"OPENAI_API_KEY": "test-key",
15+
},
16+
):
17+
with patch("llm.ChatOpenAI") as mock_chat_openai:
18+
# Create mock LLM instances
19+
mock_llm = Mock()
20+
mock_chat_openai.return_value = mock_llm
21+
22+
yield mock_llm
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def clear_llm_instances():
27+
"""Clear LLM instances before each test"""
28+
from main import llm_instances
29+
30+
llm_instances.clear()
31+
yield
32+
llm_instances.clear()

genAi/helpers.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,27 @@ def save_document(document_name: str, document_base64: str) -> str:
1515

1616
# Decode the base64 document
1717
document_bytes = base64.b64decode(document_base64)
18-
18+
1919
# Create a unique file name
2020
file_path = os.path.join(documents_dir, document_name)
2121

2222
# Save the document to a file
2323
with open(file_path, "wb") as file:
2424
file.write(document_bytes)
25-
25+
2626
return file_path
2727

28+
2829
def delete_document(document_path: str):
2930
"""
3031
Delete the document file.
3132
"""
32-
if '/example/' in document_path:
33+
if "/example/" in document_path:
3334
# Skip deletion for example documents
3435
return
35-
36+
3637
if os.path.exists(document_path):
3738
os.remove(document_path)
3839
logging.info(f"Document {document_path} deleted successfully.")
3940
else:
40-
logging.warning(f"Document {document_path} does not exist.")
41+
logging.warning(f"Document {document_path} does not exist.")

0 commit comments

Comments
 (0)