From 9c23c25ebb4ed9edee4fbf7fc76608ac799c0453 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 5 Feb 2026 13:40:11 -0500 Subject: [PATCH 001/101] Add macOS Desktop Services Store file to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d2cdbd62..984178dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ config/env/* !config/env/*.example -.idea/ \ No newline at end of file +.idea/ +.DS_Store \ No newline at end of file From 59b40f0eeff3b87b207a8598dbb9a79023afd01b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 15:51:21 -0500 Subject: [PATCH 002/101] REFACTOR Pull apart get_closest_embeddings to make testing easier --- server/api/services/embedding_services.py | 161 +++++++++++++++------- 1 file changed, 112 insertions(+), 49 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index e35f7965..0720a9c8 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -11,18 +11,17 @@ logger = logging.getLogger(__name__) -def get_closest_embeddings( - user, message_data, document_name=None, guid=None, num_results=10 -): + +def build_query(user, embedding_vector, document_name=None, guid=None, num_results=10): """ - Find the closest embeddings to a given message for a specific user. + Build an unevaluated QuerySet for the closest embeddings. Parameters ---------- user : User The user whose uploaded documents will be searched - message_data : str - The input message to find similar embeddings for + embedding_vector : array-like + Pre-computed embedding vector to compare against document_name : str, optional Filter results to a specific document name guid : str, optional @@ -32,59 +31,52 @@ def get_closest_embeddings( Returns ------- - list[dict] - List of dictionaries containing embedding results with keys: - - name: document name - - text: embedded text content - - page_number: page number in source document - - chunk_number: chunk number within the document - - distance: L2 distance from query embedding - - file_id: GUID of the source file + QuerySet + Unevaluated Django QuerySet ordered by L2 distance, sliced to num_results """ - - encoding_start = time.time() - transformerModel = TransformerModel.get_instance().model - embedding_message = transformerModel.encode(message_data) - encoding_time = time.time() - encoding_start - - db_query_start = time.time() - # Django QuerySets are lazily evaluated if user.is_authenticated: # User sees their own files + files uploaded by superusers - closest_embeddings_query = ( - Embeddings.objects.filter( - Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) - ) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) - ) - .order_by("distance") + queryset = Embeddings.objects.filter( + Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) ) else: # Unauthenticated users only see superuser-uploaded files - closest_embeddings_query = ( - Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) - ) - .order_by("distance") - ) + queryset = Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) + + queryset = ( + queryset + .annotate(distance=L2Distance("embedding_sentence_transformers", embedding_vector)) + .order_by("distance") + ) # Filtering to a document GUID takes precedence over a document name if guid: - closest_embeddings_query = closest_embeddings_query.filter( - upload_file__guid=guid - ) + queryset = queryset.filter(upload_file__guid=guid) elif document_name: - closest_embeddings_query = closest_embeddings_query.filter(name=document_name) + queryset = queryset.filter(name=document_name) # Slicing is equivalent to SQL's LIMIT clause - closest_embeddings_query = closest_embeddings_query[:num_results] + return queryset[:num_results] + + +def format_results(queryset): + """ + Evaluate a QuerySet and return a list of result dicts. + + Parameters + ---------- + queryset : iterable + Iterable of Embeddings objects (or any objects with the expected attributes) + Returns + ------- + list[dict] + List of dicts with keys: name, text, page_number, chunk_number, distance, file_id + """ # Iterating evaluates the QuerySet and hits the database # TODO: Research improving the query evaluation performance - results = [ + return [ { "name": obj.name, "text": obj.text, @@ -93,13 +85,36 @@ def get_closest_embeddings( "distance": obj.distance, "file_id": obj.upload_file.guid if obj.upload_file else None, } - for obj in closest_embeddings_query + for obj in queryset ] - db_query_time = time.time() - db_query_start +def log_search_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time +): + """ + Create a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + + Parameters + ---------- + results : list[dict] + The search results, each containing a "distance" key + message_data : str + The original search query text + user : User + The user who performed the search + guid : str or None + Document GUID filter used in the search + document_name : str or None + Document name filter used in the search + num_results : int + Number of results requested + encoding_time : float + Time in seconds to encode the query + db_query_time : float + Time in seconds for the database query + """ try: - # Handle user having no uploaded docs or doc filtering returning no matches if results: distances = [r["distance"] for r in results] SemanticSearchUsage.objects.create( @@ -113,11 +128,10 @@ def get_closest_embeddings( num_results_returned=len(results), max_distance=max(distances), median_distance=median(distances), - min_distance=min(distances) + min_distance=min(distances), ) else: logger.warning("Semantic search returned no results") - SemanticSearchUsage.objects.create( query_text=message_data, user=user if (user and user.is_authenticated) else None, @@ -129,9 +143,58 @@ def get_closest_embeddings( num_results_returned=0, max_distance=None, median_distance=None, - min_distance=None + min_distance=None, ) except Exception as e: logger.error(f"Failed to create semantic search usage database record: {e}") + +def get_closest_embeddings( + user, message_data, document_name=None, guid=None, num_results=10 +): + """ + Find the closest embeddings to a given message for a specific user. + + Parameters + ---------- + user : User + The user whose uploaded documents will be searched + message_data : str + The input message to find similar embeddings for + document_name : str, optional + Filter results to a specific document name + guid : str, optional + Filter results to a specific document GUID (takes precedence over document_name) + num_results : int, default 10 + Maximum number of results to return + + Returns + ------- + list[dict] + List of dictionaries containing embedding results with keys: + - name: document name + - text: embedded text content + - page_number: page number in source document + - chunk_number: chunk number within the document + - distance: L2 distance from query embedding + - file_id: GUID of the source file + + Notes + ----- + Creates a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + """ + encoding_start = time.time() + model = TransformerModel.get_instance().model + embedding_vector = model.encode(message_data) + encoding_time = time.time() - encoding_start + + db_query_start = time.time() + queryset = build_query(user, embedding_vector, document_name, guid, num_results) + results = format_results(queryset) + db_query_time = time.time() - db_query_start + + log_search_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time + ) + return results From 3ffb74af318cc927d9c11ba37e63a3093d5ecfc6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 16:12:49 -0500 Subject: [PATCH 003/101] ADD Add infra required to run pytest --- .github/workflows/python-app.yml | 5 +++++ server/pytest.ini | 3 +++ server/requirements.txt | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 server/pytest.ini diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index dcb7a4bb..41bc74d9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,3 +27,8 @@ jobs: run: pipx install ruff - name: Lint code with Ruff run: ruff check --output-format=github --target-version=py39 + - name: Install test dependencies + run: pip install -r server/requirements.txt + # Discover and run all files matching test_*.py or *_test.py under server/ + - name: Run tests + run: pytest server/ -v diff --git a/server/pytest.ini b/server/pytest.ini new file mode 100644 index 00000000..235b9752 --- /dev/null +++ b/server/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = balancer_backend.settings +pythonpath = . diff --git a/server/requirements.txt b/server/requirements.txt index bbaf7bc9..001708e9 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,6 @@ sentence_transformers PyMuPDF==1.24.0 Pillow pytesseract -anthropic \ No newline at end of file +anthropic +pytest +pytest-django \ No newline at end of file From 12b09a733a1dc79b8fbec63d358d4354764a5116 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 16:33:41 -0500 Subject: [PATCH 004/101] ADD Start adding tests for embedding_services" --- server/api/services/embedding_services.py | 8 +- .../api/services/test_embedding_services.py | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 server/api/services/test_embedding_services.py diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0720a9c8..3fa9bb68 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -60,7 +60,7 @@ def build_query(user, embedding_vector, document_name=None, guid=None, num_resul return queryset[:num_results] -def format_results(queryset): +def evaluate_query(queryset): """ Evaluate a QuerySet and return a list of result dicts. @@ -89,7 +89,7 @@ def format_results(queryset): ] -def log_search_usage( +def log_usage( results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time ): """ @@ -190,10 +190,10 @@ def get_closest_embeddings( db_query_start = time.time() queryset = build_query(user, embedding_vector, document_name, guid, num_results) - results = format_results(queryset) + results = evaluate_query(queryset) db_query_time = time.time() - db_query_start - log_search_usage( + log_usage( results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time ) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py new file mode 100644 index 00000000..677c1e7b --- /dev/null +++ b/server/api/services/test_embedding_services.py @@ -0,0 +1,85 @@ +from unittest.mock import MagicMock, patch + +from api.services.embedding_services import evaluate_query, log_usage + + +def test_evaluate_query_maps_fields(): + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 3 + obj.chunk_number = 1 + obj.distance = 0.42 + obj.upload_file.guid = "abc-123" + + results = evaluate_query([obj]) + + assert results == [ + { + "name": "doc.pdf", + "text": "some text", + "page_number": 3, + "chunk_number": 1, + "distance": 0.42, + "file_id": "abc-123", + } + ] + + +def test_evaluate_query_none_upload_file(): + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 1 + obj.chunk_number = 0 + obj.distance = 1.0 + obj.upload_file = None + + results = evaluate_query([obj]) + + assert results[0]["file_id"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_computes_distance_stats(mock_create): + results = [{"distance": 1.0}, {"distance": 3.0}, {"distance": 2.0}] + user = MagicMock(is_authenticated=True) + + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["min_distance"] == 1.0 + assert kwargs["max_distance"] == 3.0 + assert kwargs["median_distance"] == 2.0 + assert kwargs["num_results_returned"] == 3 + + +@patch( + "api.services.embedding_services.SemanticSearchUsage.objects.create", + side_effect=Exception("DB error"), +) +def test_log_usage_swallows_exceptions(mock_create): + results = [{"distance": 1.0}] + user = MagicMock(is_authenticated=True) + + # pytest fails the test if it catches unhandled Exception + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) From 2d2ccd10911291061d23bcdf9b56855590477494 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 16 Feb 2026 16:33:54 -0500 Subject: [PATCH 005/101] Fix duplicate healthcheck key for db service build --- docker-compose.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9182cdb6..7a6e7fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,6 @@ services: networks: app_net: ipv4_address: 192.168.0.2 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] - interval: 5s - timeout: 5s - retries: 5 pgadmin: image: dpage/pgadmin4 From 01ccf9ab0995645e0ad0c0782eb9bc5efb6ed595 Mon Sep 17 00:00:00 2001 From: Akhil Bolla <129509874+AkhilRB0204@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:01:40 -0500 Subject: [PATCH 006/101] Enhance input sanitization and normalize pronouns Updated the sanitizer function to improve input sanitization by removing style tags, normalizing pronouns, and increasing the maximum length limit. --- server/api/views/assistant/sanitizer.py | 62 ++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/server/api/views/assistant/sanitizer.py b/server/api/views/assistant/sanitizer.py index bdbbc77f..fd851df6 100644 --- a/server/api/views/assistant/sanitizer.py +++ b/server/api/views/assistant/sanitizer.py @@ -1,26 +1,76 @@ import re import logging + logger = logging.getLogger(__name__) def sanitize_input(user_input:str) -> str: """ Sanitize user input to prevent injection attacks and remove unwanted characters. + Args: user_input (str): The raw input string from the user. + Returns: str: The sanitized input string. """ try: - # Remove any script tags - sanitized = re.sub(r'.*?', '', user_input, flags=re.IGNORECASE) - # Remove any HTML tags + sanitized = user_input + + # Remove any style tags + sanitized = re.sub(r'.*?', '', sanitized, flags=re.IGNORECASE) + + # Remove any HTML/script tags sanitized = re.sub(r'<.*?>', '', sanitized) + + # Remove Phone Numbers + sanitized = re.sub(r'\+?\d[\d -]{8,}\d', '[Phone Number]', sanitized) + + # Remove Email Addresses + sanitized = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[Email Address]', sanitized) + + # Remove Medical Record Numbers (simple pattern) + sanitized = re.sub(r'\bMRN[:\s]*\d+\b', '[Medical Record Number]', sanitized, flags=re.IGNORECASE) + + # Normalize pronouns + sanitized = normalize_pronouns(sanitized) + # Escape special characters - sanitized = re.sub(r'["\'\\]', '', sanitized) + sanitized = re.sub(r'\s+', '', sanitized) + # Limit length to prevent buffer overflow attacks - max_length = 1000 + max_length = 5000 if len(sanitized) > max_length: sanitized = sanitized[:max_length] + return sanitized.strip() except Exception as e: logger.error(f"Error sanitizing input: {e}") - return "" \ No newline at end of file + return "" + +def normalize_pronouns(text:str) -> str: + """ + Normalize first and second person pronouns to third person clinical language. + + Converts patient centric pronouns to a more neutral form. + Args: + text (str): The input text containing pronouns. + Returns: + str: The text with normalized pronouns. + """ + # Normalize first person possessives: I, me, my, mine -> the patient + text = re.sub(r'\bMy\b', 'The patient\'s', text) + text = re.sub(r'\bmy\b', 'the patient\'s', text) + + # First person subject: I -> the patient + text = re.sub(r'\bI\b', 'the patient', text) + + # First person object: me -> the patient + text = re.sub(r'\bme\b', 'the patient', text) + + # First person reflexive: myself -> the patient + text = re.sub(r'\bmyself\b', 'the patient', text) + + # Second person: you, your -> the clinician + text = re.sub(r'\bYour\b', 'the clinician', text) + return text + + From da9afaa905a43218e1f947360aa141662359e1aa Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 17 Feb 2026 14:40:13 -0500 Subject: [PATCH 007/101] DOC Add a note about running pytest in the README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f1cea06b..15018d37 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` +- Backend tests can be run using `pytest` by running the below command inside the running backend container: + +``` +docker compose exec backend pytest api/ -v +``` ## Local Kubernetes Deployment From b08152f94a079b24681e79203476dcbbbdcb0d83 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 18 Feb 2026 14:18:12 -0500 Subject: [PATCH 008/101] fix: changed link to direct to balancer github page --- frontend/src/components/Footer/Footer.tsx | 2 +- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/About/About.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index d656f5ad..2f68ec49 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -62,7 +62,7 @@ function Footer() { > Leave feedback - diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index cbbd2c93..a0edc859 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -165,7 +165,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Leave Feedback diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 5a8d5bce..00d45f55 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -120,7 +120,7 @@ const MdNavBar = (props: LoginFormProps) => {
  • - diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index c50f6705..e1c7242e 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -77,7 +77,7 @@ function About() {
    - + From b94e998d8c355eb19153b2d8af5527c3fa17ff14 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Fri, 20 Feb 2026 15:33:35 -0500 Subject: [PATCH 009/101] Fix error 1, added unit tests and more logging --- server/api/views/uploadFile/test_title.py | 30 +++++++++++++++++++++++ server/api/views/uploadFile/title.py | 3 ++- server/api/views/uploadFile/views.py | 4 +++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 69979620..5391ed05 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -67,3 +67,33 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): title.generate_title(doc) self.assertTrue(mock_openAI.called) + + @patch("api.services.openai_services.openAIServices.openAI") + def test_strips_quotes_from_openai_title(self, mock_openAI): + doc = MagicMock() + doc.metadata = {"title": None} + doc.get_text.return_value = [] + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' + mock_openAI.return_value = mock_response + + result = title.generate_title(doc) + + self.assertEqual(result, "Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder") + + @patch("api.services.openai_services.openAIServices.openAI") + def test_truncates_long_openai_title(self, mock_openAI): + doc = MagicMock() + doc.metadata = {"title": None} + doc.get_text.return_value = [] + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "A" * 300 + mock_openAI.return_value = mock_response + + result = title.generate_title(doc) + + self.assertLessEqual(len(result), 255) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 06e0ce0c..b3f8aded 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -58,4 +58,5 @@ def summarize_pdf(pdf: fitz.Document) -> str: prompt = "Please provide a title for this document. The title should be less than 256 characters and will be displayed on a webpage." response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) - return response.choices[0].message.content + title = response.choices[0].message.content.strip().strip('"').strip("'") + return title[:255] diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 69dfb996..58bd8752 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -12,6 +12,9 @@ import fitz from django.db import transaction from .title import generate_title +import logging + +logger = logging.getLogger(__name__) class UploadFileView(APIView): @@ -124,6 +127,7 @@ def post(self, request, format=None): ) except Exception as e: # Handle potential errors + logger.exception("File upload failed for '%s': %s", uploaded_file.name, e) return Response({"message": f"Error processing file and embeddings: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) From 530b90a17afa6136e6c9de9abd472794c4058193 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 23 Feb 2026 12:19:50 -0500 Subject: [PATCH 010/101] Changed button text from "donate" to "Support Developoment" --- frontend/src/components/Footer/Footer.tsx | 4 ++-- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/About/About.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 2f68ec49..977c59d4 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -64,9 +64,9 @@ function Footer() { - Donate + Support Development = ({ isAuthenticated, isSuperuser }) => { target="_blank" className="header-nav-item" > - Donate + Support Development {isAuthenticated && isSuperuser && (
    { target="_blank" className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline" > - Donate + Support Development
  • {isAuthenticated && diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index e1c7242e..9481c74d 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -79,7 +79,7 @@ function About() {
    From f96606d18b0f9fa2aca25033ba26079c290b54ac Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 23 Feb 2026 16:46:38 -0500 Subject: [PATCH 011/101] Fix 401 by using adminApi instead of raw axios --- frontend/src/pages/DocumentManager/UploadFile.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index 2ee7b5db..32b727e8 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from "react"; -import axios from "axios"; +import { adminApi } from "../../api/apiClient"; import TypingAnimation from "../../components/Header/components/TypingAnimation.tsx"; import Layout from "../Layout/Layout.tsx"; @@ -22,14 +22,9 @@ const UploadFile: React.FC = () => { formData.append("file", file); try { - const response = await axios.post( + const response = await adminApi.post( `/api/v1/api/uploadFile`, formData, - { - headers: { - "Content-Type": "multipart/form-data" - }, - } ); console.log("File uploaded successfully", response.data); } catch (error) { From bbf1034d065774a252bee3cae07300584e84fbe6 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 15:48:51 -0500 Subject: [PATCH 012/101] Fixed wrong API url path in handleDownload --- frontend/src/pages/Files/ListOfFiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index b6fff4ee..cac0836c 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -61,7 +61,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); From 128418b5cc05a867c0419d504be42cdbd6b7cf51 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 16:22:45 -0500 Subject: [PATCH 013/101] Fixed API URL in handleOpen as well --- frontend/src/pages/Files/ListOfFiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index cac0836c..37bd459a 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -82,7 +82,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); From 332af9fca17085196bdad7db35d7fbe46c981274 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 16:57:36 -0500 Subject: [PATCH 014/101] drf-spectacular configuration --- server/balancer_backend/settings.py | 8 ++++++++ server/requirements.txt | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 9f917a94..cfa90dce 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -51,6 +51,7 @@ "corsheaders", "rest_framework", "djoser", + 'drf_spectacular', ] MIDDLEWARE = [ @@ -195,8 +196,15 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } +SPECTACULAR_SETTINGS = { + 'TITLE': 'Balancer API', + 'DESCRIPTION': 'API for the Balancer medication decision support tool', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} SIMPLE_JWT = { "AUTH_HEADER_TYPES": ("JWT",), diff --git a/server/requirements.txt b/server/requirements.txt index bbaf7bc9..880500c6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,5 @@ sentence_transformers PyMuPDF==1.24.0 Pillow pytesseract -anthropic \ No newline at end of file +anthropic +drf-spectacular \ No newline at end of file From a34a9f8ceda2358cebd8607c9dcb04136e590cb3 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 17:10:03 -0500 Subject: [PATCH 015/101] Added URL routes for API docs generation --- server/balancer_backend/urls.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index c8bd290d..55bd2032 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -6,6 +6,9 @@ # Import TemplateView for rendering templates from django.views.generic import TemplateView import importlib # Import the importlib module for dynamic module importing +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + + # Define a list of URL patterns for the application # Keep admin outside /api/ prefix @@ -50,6 +53,9 @@ # Wrap all API routes under /api/ prefix urlpatterns += [ path("api/", include(api_urlpatterns)), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] import os From fe660d2eb03db708b30b782f4fab787610640a90 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 17:26:44 -0500 Subject: [PATCH 016/101] Added OpenAPI security scheme --- server/balancer_backend/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index cfa90dce..a4ccaaae 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -204,6 +204,10 @@ 'DESCRIPTION': 'API for the Balancer medication decision support tool', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, + 'SECURITY': [{'jwtAuth': []}], + 'SWAGGER_UI_SETTINGS': { + 'persistAuthorization': True, + }, } SIMPLE_JWT = { From 3c83abdc2a32cc6c300fc833671e8f7a937ba88e Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 18:12:52 -0500 Subject: [PATCH 017/101] Added extend_schema and serializer_class to endpoints that drf-spectacular missed with default settings --- server/api/views/ai_promptStorage/views.py | 3 ++ server/api/views/ai_settings/views.py | 2 + server/api/views/assistant/views.py | 17 +++++++ server/api/views/conversations/views.py | 31 ++++++++++++ server/api/views/embeddings/embeddingsView.py | 23 ++++++++- server/api/views/feedback/views.py | 1 + server/api/views/listMeds/views.py | 48 ++++++++++++++++++- server/api/views/medRules/serializers.py | 2 + server/api/views/medRules/views.py | 25 +++++++++- .../api/views/risk/views_riskWithSources.py | 25 +++++++++- server/api/views/text_extraction/views.py | 29 +++++++++++ server/api/views/uploadFile/views.py | 46 +++++++++++++++++- server/api/views/version/views.py | 7 +++ 13 files changed, 254 insertions(+), 5 deletions(-) diff --git a/server/api/views/ai_promptStorage/views.py b/server/api/views/ai_promptStorage/views.py index 7354feb3..cc50f22e 100644 --- a/server/api/views/ai_promptStorage/views.py +++ b/server/api/views/ai_promptStorage/views.py @@ -1,10 +1,12 @@ from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_PromptStorage from .serializers import AI_PromptStorageSerializer +@extend_schema(request=AI_PromptStorageSerializer, responses={201: AI_PromptStorageSerializer}) @api_view(['POST']) # @permission_classes([IsAuthenticated]) def store_prompt(request): @@ -21,6 +23,7 @@ def store_prompt(request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@extend_schema(responses={200: AI_PromptStorageSerializer(many=True)}) @api_view(['GET']) def get_all_prompts(request): """ diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 349b9fd9..9ee6aad7 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -2,10 +2,12 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_Settings from .serializers import AISettingsSerializer +@extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) def settings_view(request): diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index f31ab475..e3e8d6f7 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -10,6 +10,8 @@ from rest_framework.permissions import AllowAny from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers from openai import OpenAI @@ -113,6 +115,21 @@ def invoke_functions_from_response( class Assistant(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer(name='AssistantRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to send to the assistant'), + 'previous_response_id': drf_serializers.CharField(required=False, allow_null=True, help_text='ID of previous response for conversation continuity'), + }), + responses={ + 200: inline_serializer(name='AssistantResponse', fields={ + 'response_output_text': drf_serializers.CharField(), + 'final_response_id': drf_serializers.CharField(), + }), + 500: inline_serializer(name='AssistantError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): try: user = request.user diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index eeb68809..de927cf1 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -16,6 +16,8 @@ from .models import Conversation, Message from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers @csrf_exempt @@ -95,6 +97,21 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema( + request=inline_serializer(name='ContinueConversationRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to continue the conversation'), + 'page_context': drf_serializers.CharField(required=False, help_text='Optional page context'), + }), + responses={ + 200: inline_serializer(name='ContinueConversationResponse', fields={ + 'response': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='ContinueConversationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['post']) def continue_conversation(self, request, pk=None): conversation = self.get_object() @@ -123,6 +140,20 @@ def continue_conversation(self, request, pk=None): return Response({"response": chatgpt_response, "title": conversation.title}) + @extend_schema( + request=inline_serializer(name='UpdateTitleRequest', fields={ + 'title': drf_serializers.CharField(help_text='New conversation title'), + }), + responses={ + 200: inline_serializer(name='UpdateTitleResponse', fields={ + 'status': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='UpdateTitleBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['patch']) def update_title(self, request, pk=None): conversation = self.get_object() diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index d0bdd8ca..ebcf0774 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,8 +1,9 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from django.http import StreamingHttpResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter from ...services.embedding_services import get_closest_embeddings from ...services.conversions_services import convert_uuids from ...services.openai_services import openAIServices @@ -15,6 +16,26 @@ class AskEmbeddingsAPIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=False, description='Optional file GUID to filter embeddings'), + OpenApiParameter(name='stream', type=bool, location=OpenApiParameter.QUERY, required=False, description='Enable streaming response'), + ], + request=inline_serializer(name='AskEmbeddingsRequest', fields={ + 'message': drf_serializers.CharField(help_text='Question to ask against embedded documents'), + }), + responses={ + 200: inline_serializer(name='AskEmbeddingsResponse', fields={ + 'question': drf_serializers.CharField(), + 'llm_response': drf_serializers.CharField(), + 'embeddings_info': drf_serializers.CharField(), + 'sent_to_llm': drf_serializers.CharField(), + }), + 400: inline_serializer(name='AskEmbeddingsBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request, *args, **kwargs): try: user = request.user diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index d0f0e1da..424e0758 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -9,6 +9,7 @@ class FeedbackView(APIView): permission_classes = [AllowAny] + serializer_class = FeedbackSerializer def post(self, request, *args, **kwargs): serializer = FeedbackSerializer(data=request.data) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index fcd0edf2..1b199a7e 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,7 +1,8 @@ -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer from .models import Diagnosis, Medication, Suggestion from .serializers import MedicationSerializer @@ -24,6 +25,33 @@ class GetMedication(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer( + name='GetMedicationRequest', + fields={ + 'state': drf_serializers.CharField(help_text='Diagnosis state, e.g. "depressed", "manic"'), + 'suicideHistory': drf_serializers.BooleanField(default=False), + 'kidneyHistory': drf_serializers.BooleanField(default=False), + 'liverHistory': drf_serializers.BooleanField(default=False), + 'bloodPressureHistory': drf_serializers.BooleanField(default=False), + 'weightGainConcern': drf_serializers.BooleanField(default=False), + 'priorMedications': drf_serializers.CharField(required=False, default='', help_text='Comma-separated medication names'), + } + ), + responses={ + 200: inline_serializer( + name='GetMedicationResponse', + fields={ + 'first': drf_serializers.ListField(child=drf_serializers.DictField()), + 'second': drf_serializers.ListField(child=drf_serializers.DictField()), + 'third': drf_serializers.ListField(child=drf_serializers.DictField()), + } + ), + 404: inline_serializer(name='GetMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data state_query = data.get('state', '') @@ -75,6 +103,7 @@ def post(self, request): class ListOrDetailMedication(APIView): permission_classes = [AllowAny] + serializer_class = MedicationSerializer def get(self, request): name_query = request.query_params.get('name', None) @@ -98,6 +127,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + serializer_class = MedicationSerializer def post(self, request): data = request.data @@ -129,6 +159,22 @@ class DeleteMedication(APIView): API endpoint to delete medication if medication in database. """ + @extend_schema( + request=inline_serializer(name='DeleteMedicationRequest', fields={ + 'name': drf_serializers.CharField(), + }), + responses={ + 200: inline_serializer(name='DeleteMedicationSuccess', fields={ + 'success': drf_serializers.CharField(), + }), + 400: inline_serializer(name='DeleteMedicationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def delete(self, request): data = request.data name = data.get('name', '').strip() diff --git a/server/api/views/medRules/serializers.py b/server/api/views/medRules/serializers.py index df5e3663..e0d7d3f3 100644 --- a/server/api/views/medRules/serializers.py +++ b/server/api/views/medRules/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field from ...models.model_medRule import MedRule, MedRuleSource from ..listMeds.serializers import MedicationSerializer from ...models.model_embeddings import Embeddings @@ -30,6 +31,7 @@ class Meta: "medication_sources", ] + @extend_schema_field(MedicationWithSourcesSerializer(many=True)) def get_medication_sources(self, obj): medrule_sources = MedRuleSource.objects.filter(medrule=obj).select_related( "medication", "embedding" diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2fae140b..2f80f8f3 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,9 +1,10 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer from ...models.model_medRule import MedRule from .serializers import MedRuleSerializer # You'll need to create this from ..listMeds.models import Medication @@ -13,6 +14,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): permission_classes = [IsAuthenticated] + serializer_class = MedRuleSerializer def get(self, request, format=None): # Get all med rules @@ -29,6 +31,27 @@ def get(self, request, format=None): return Response(data, status=status.HTTP_200_OK) + @extend_schema( + request=inline_serializer(name='MedRuleCreateRequest', fields={ + 'rule_type': drf_serializers.CharField(help_text='INCLUDE or EXCLUDE'), + 'history_type': drf_serializers.CharField(help_text='e.g. DIAGNOSIS_DEPRESSED, DIAGNOSIS_MANIC'), + 'reason': drf_serializers.CharField(), + 'label': drf_serializers.CharField(), + 'explanation': drf_serializers.CharField(), + 'medication_names': drf_serializers.ListField(child=drf_serializers.CharField()), + 'chunk_ids': drf_serializers.ListField(child=drf_serializers.IntegerField()), + 'file_guid': drf_serializers.CharField(), + }), + responses={ + 201: MedRuleSerializer, + 400: inline_serializer(name='MedRuleCreateBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='MedRuleCreateNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index c02908fc..26cad9f8 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,7 +1,8 @@ from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from drf_spectacular.utils import extend_schema, inline_serializer from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai @@ -11,6 +12,28 @@ class RiskWithSourcesView(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer(name='RiskWithSourcesRequest', fields={ + 'drug': drf_serializers.CharField(help_text='Medication name'), + 'source': drf_serializers.CharField(required=False, help_text='One of: include, diagnosis, diagnosis_depressed, diagnosis_manic, diagnosis_hypomanic, diagnosis_euthymic'), + }), + responses={ + 200: inline_serializer(name='RiskWithSourcesResponse', fields={ + 'benefits': drf_serializers.ListField(child=drf_serializers.CharField()), + 'risks': drf_serializers.ListField(child=drf_serializers.CharField()), + 'sources': drf_serializers.ListField(child=drf_serializers.DictField()), + 'medrules_found': drf_serializers.IntegerField(required=False), + 'source_type': drf_serializers.CharField(required=False), + 'note': drf_serializers.CharField(required=False), + }), + 400: inline_serializer(name='RiskWithSourcesBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='RiskWithSourcesNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): openai.api_key = os.environ.get("OPENAI_API_KEY") diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index e4122851..020740ad 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -9,6 +9,8 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt import anthropic +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter +from rest_framework import serializers as drf_serializers from ...services.openai_services import openAIServices from api.models.model_embeddings import Embeddings @@ -97,6 +99,20 @@ class RuleExtractionAPIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionResponse', fields={ + 'texts': drf_serializers.CharField(), + 'cited_texts': drf_serializers.CharField(), + }), + 500: inline_serializer(name='RuleExtractionError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: @@ -141,6 +157,19 @@ def openai_extraction(content_chunks, user_prompt): class RuleExtractionAPIOpenAIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionOpenAIResponse', fields={ + 'rules': drf_serializers.ListField(child=drf_serializers.DictField()), + }), + 500: inline_serializer(name='RuleExtractionOpenAIError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: user_prompt = """ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 69dfb996..b9f0cd3d 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,8 +1,9 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse import pdfplumber from .models import UploadFile # Import your UploadFile model from .serializers import UploadFileSerializer @@ -15,6 +16,8 @@ class UploadFileView(APIView): + serializer_class = UploadFileSerializer + def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access @@ -28,6 +31,23 @@ def get(self, request, format=None): serializer = UploadFileSerializer(files, many=True) return Response(serializer.data) + @extend_schema( + request={'multipart/form-data': inline_serializer( + name='UploadFileRequest', + fields={ + 'file': drf_serializers.FileField(help_text='PDF file to upload'), + } + )}, + responses={ + 201: inline_serializer(name='UploadFileSuccess', fields={ + 'message': drf_serializers.CharField(), + 'file_id': drf_serializers.IntegerField(), + }), + 400: inline_serializer(name='UploadFileBadRequest', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def post(self, request, format=None): print(request.auth) print(f"UploadFileView post called. Path: {request.path}") @@ -127,6 +147,22 @@ def post(self, request, format=None): return Response({"message": f"Error processing file and embeddings: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + @extend_schema( + request=inline_serializer(name='DeleteFileRequest', fields={ + 'guid': drf_serializers.CharField(help_text='GUID of file to delete'), + }), + responses={ + 200: inline_serializer(name='DeleteFileSuccess', fields={ + 'message': drf_serializers.CharField(), + }), + 403: inline_serializer(name='DeleteFileForbidden', fields={ + 'message': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def delete(self, request, format=None): guid = request.data.get('guid') if not guid: @@ -157,6 +193,14 @@ def delete(self, request, format=None): class RetrieveUploadFileView(APIView): permission_classes = [AllowAny] + @extend_schema( + responses={ + (200, 'application/pdf'): OpenApiResponse(description='PDF file binary content'), + 404: inline_serializer(name='RetrieveFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def get(self, request, guid, format=None): try: file = UploadFile.objects.get(guid=guid) diff --git a/server/api/views/version/views.py b/server/api/views/version/views.py index b79d6577..af59e9e0 100644 --- a/server/api/views/version/views.py +++ b/server/api/views/version/views.py @@ -3,11 +3,18 @@ from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework.response import Response +from rest_framework import serializers as drf_serializers +from drf_spectacular.utils import extend_schema, inline_serializer class VersionView(APIView): permission_classes = [AllowAny] + @extend_schema( + responses={200: inline_serializer(name='VersionResponse', fields={ + 'version': drf_serializers.CharField(), + })} + ) def get(self, request, *args, **kwargs): version = os.environ.get("VERSION") or "dev" return Response({"version": version}) From 5ce77823c25f0a45fbcdc828e13214b515496bc2 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 27 Feb 2026 16:09:05 -0500 Subject: [PATCH 018/101] Preload SentenceTransformer model at Django startup before traffic is routed to the application instance --- server/api/apps.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/api/apps.py b/server/api/apps.py index 66656fd2..4d502cba 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -4,3 +4,7 @@ class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + from .services.sentencetTransformer_model import TransformerModel + TransformerModel.get_instance() From 7085aa0c71f210bbe450034deb5ae5af1ef6cbef Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 11:59:48 -0500 Subject: [PATCH 019/101] Requested changes: fix patch decorators to point to where openAI is used, not where it's defined --- server/api/views/uploadFile/test_title.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 5391ed05..07e1b1ba 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -53,7 +53,7 @@ def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(se expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} @@ -68,7 +68,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): self.assertTrue(mock_openAI.called) - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} @@ -83,7 +83,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): self.assertEqual(result, "Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder") - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_truncates_long_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} From e6754df366d1762a1b2028f0ec296c2bbd5eb3b6 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 12:07:57 -0500 Subject: [PATCH 020/101] Requested changes: added comments explaining title truncation --- server/api/views/uploadFile/test_title.py | 1 + server/api/views/uploadFile/title.py | 1 + 2 files changed, 2 insertions(+) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 07e1b1ba..d5945da8 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -96,4 +96,5 @@ def test_truncates_long_openai_title(self, mock_openAI): result = title.generate_title(doc) + # Ensure the title is truncated to fit the UploadFile model's title field (max_length=255), since OpenAI responses may exceed this limit self.assertLessEqual(len(result), 255) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index b3f8aded..17f52a74 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -59,4 +59,5 @@ def summarize_pdf(pdf: fitz.Document) -> str: response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) title = response.choices[0].message.content.strip().strip('"').strip("'") + # Truncate to fit UploadFile model's max_length=255 title field as a final safeguard return title[:255] From 4b4d7275ed7d580c9ee7b7d51287cdb99a78b9bc Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 12:15:32 -0500 Subject: [PATCH 021/101] Fix mock setups to match how generate_title accesses title --- server/api/views/uploadFile/test_title.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index d5945da8..0ec9e1bc 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -57,7 +57,7 @@ def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(se def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -72,7 +72,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): def test_strips_quotes_from_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -87,7 +87,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): def test_truncates_long_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] From e8b0fc1de6be8f89c500a117a4533ad26e632200 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 18:56:12 -0500 Subject: [PATCH 022/101] fix: treat openAIServices.openAI() return value as string --- server/api/views/uploadFile/title.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 17f52a74..41e67757 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -58,6 +58,6 @@ def summarize_pdf(pdf: fitz.Document) -> str: prompt = "Please provide a title for this document. The title should be less than 256 characters and will be displayed on a webpage." response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) - title = response.choices[0].message.content.strip().strip('"').strip("'") + title = response.strip().strip('"').strip("'") # Truncate to fit UploadFile model's max_length=255 title field as a final safeguard return title[:255] From e0b7c23bae2a1284202a23a19b16293cecdf4411 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 19:01:34 -0500 Subject: [PATCH 023/101] fix mock test setup to return string instead of mocked response object --- server/api/views/uploadFile/test_title.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 0ec9e1bc..6d95905c 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -59,10 +59,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = "A Study Regarding The Efficacy of Drugs" - mock_openAI.return_value = mock_response + mock_openAI.return_value = "A Study Regarding The Efficacy of Drugs" title.generate_title(doc) @@ -74,10 +71,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' - mock_openAI.return_value = mock_response + mock_openAI.return_value = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' result = title.generate_title(doc) @@ -89,10 +83,7 @@ def test_truncates_long_openai_title(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = "A" * 300 - mock_openAI.return_value = mock_response + mock_openAI.return_value = "A" * 300 result = title.generate_title(doc) From d68fa62b03e9890cab17b46164a4bda6511a5c2f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 19:05:46 -0500 Subject: [PATCH 024/101] fix to make test_falls_back_to_chatgpt_if_no_title_found more robust --- server/api/views/uploadFile/test_title.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 6d95905c..e57ee507 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -61,9 +61,10 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): mock_openAI.return_value = "A Study Regarding The Efficacy of Drugs" - title.generate_title(doc) + result = title.generate_title(doc) self.assertTrue(mock_openAI.called) + self.assertEqual(result, "A Study Regarding The Efficacy of Drugs") @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): From 03b7639bcddf837a78f542ee3eda29c75bc669c3 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:25:50 -0500 Subject: [PATCH 025/101] Replace block-position title extraction with font-size-based approach The old "scan first couple pages" logic used get_text("blocks") and picked the first block matching a title regex, which frequently selected preambles, journal names, and article headers instead of the actual title. The new approach uses get_text("dict") to find the largest font size across the first few pages and collects contiguous runs of text at that size, since research paper titles are typically the largest font. --- server/api/views/uploadFile/title.py | 91 +++++++++++++++++++++------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 17f52a74..76afbfd3 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -15,35 +15,80 @@ def generate_title(pdf: fitz.Document) -> str | None: document_metadata_title = pdf.metadata["title"] if document_metadata_title is not None and document_metadata_title != "": if title_regex.match(document_metadata_title): - print("suitable title was found in metadata") return document_metadata_title.strip() - else: - print("metadata title did not match regex") - print("Looking for title in first page text") - first_page = pdf[0] - first_page_blocks = first_page.get_text("blocks") - text_blocks = [ - block[4].strip().replace("\n", " ") - for block in first_page_blocks - if block[6] == 0 # only include text blocks. - ] - - # For some reason, extracted PDF text has extra spaces. Collapse them here. - regex = r"\s{2,}" - text_blocks = [re.sub(regex, " ", text) for text in text_blocks] - - if len(text_blocks) != 0: - for text in text_blocks: - if title_regex.match(text): - return text - - print( - "no suitable title found in first page text. Using GPT-4 to summarize the PDF") + font_title = extract_title_by_font_size(pdf) + if font_title: + return font_title + gpt_title = summarize_pdf(pdf) return gpt_title or None +def extract_title_by_font_size(pdf: fitz.Document, max_pages: int = 3) -> str | None: + """ + Extract the title by finding the largest font size across the first few pages + and collecting contiguous runs of text at that size. + """ + pages_to_scan = min(max_pages, len(pdf)) + + # First pass: collect all spans with their font size, and find the max font size. + all_spans = [] + max_font_size = 0.0 + + for page_idx in range(pages_to_scan): + page_dict = pdf[page_idx].get_text("dict") + for block in page_dict["blocks"]: + if block.get("type") != 0: + continue + for line in block["lines"]: + for span in line["spans"]: + text = span["text"].strip() + size = span["size"] + if len(text) < 2 or size < 6.0: + continue + all_spans.append({"text": text, "size": size}) + if size > max_font_size: + max_font_size = size + + if max_font_size == 0.0: + return None + + # Second pass: gather contiguous runs of spans at the max font size. + # Runs continue across block boundaries so multi-block titles (e.g., + # "BIPOLAR DISORDER IN PRIMARY CARE:" in one block and "DIAGNOSIS AND + # MANAGEMENT" in the next) are joined into a single candidate. + # A run only ends when a non-max-size span interrupts it. + candidates = [] + current_run = [] + + for span in all_spans: + if span["size"] == max_font_size: + current_run.append(span["text"]) + else: + if current_run: + candidates.append(" ".join(current_run)) + current_run = [] + + if current_run: + candidates.append(" ".join(current_run)) + + # Collapse extra whitespace, validate against title regex, and pick the longest match. + # Longest wins because real titles are typically longer than section headers + # (e.g., "About the Author") that may share the same max font size. + best = None + for candidate in candidates: + cleaned = re.sub(r"\s{2,}", " ", candidate).strip() + if title_regex.match(cleaned): + if best is None or len(cleaned) > len(best): + best = cleaned + + if best: + return best[:255] + + return None + + def summarize_pdf(pdf: fitz.Document) -> str: """ Summarize a PDF document using OpenAI's GPT-4 model. From 2a822f60f86857672af5a8d5f688ed3ee5208326 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:26:27 -0500 Subject: [PATCH 026/101] loosens the title regex to allow years, question marks, apostrophes, and non-breaking spaces in titles. --- server/api/views/uploadFile/title.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 76afbfd3..f4e562c1 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -6,9 +6,9 @@ # regular expression to match common research white paper titles. Created by Chat-gpt -# requires at least 3 words, no dates, no version numbers. +# requires at least 3 words, no version numbers. title_regex = re.compile( - r'^(?=(?:\b\w+\b[\s:,\-\(\)]*){3,})(?!.*\b(?:19|20)\d{2}\b)(?!.*\bv\d+\b)[A-Za-z0-9][\w\s:,\-\(\)]*[A-Za-z\)]$', re.IGNORECASE) + r"^(?=(?:\b\w+\b[^A-Za-z0-9]*){3,})(?!.*\bv\d+\b)[A-Za-z0-9].+[A-Za-z\)?!]$", re.IGNORECASE) def generate_title(pdf: fitz.Document) -> str | None: From edf1eb64a77f4dd153a0c075933693831dfaed9f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:26:58 -0500 Subject: [PATCH 027/101] Update tests for font-size-based title extraction Refactor test helpers to use get_text("dict") structure instead of get_text("blocks"). Add tests for multi-span joining, short span filtering, regex rejection, and multi-page title detection. --- server/api/views/uploadFile/test_title.py | 153 +++++++++++++++------- 1 file changed, 108 insertions(+), 45 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 0ec9e1bc..531a3f07 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -4,6 +4,39 @@ from . import title +def make_page_dict(blocks): + """Helper to build a get_text("dict") return value from a simple list of blocks. + Each block is a list of (text, font_size) tuples representing spans. + """ + dict_blocks = [] + for spans in blocks: + dict_blocks.append({ + "type": 0, + "lines": [{ + "spans": [{"text": text, "size": size} for text, size in spans] + }] + }) + return {"blocks": dict_blocks} + + +def make_mock_doc(pages_data, metadata=None): + """Build a mock fitz.Document. + pages_data: list of block lists, one per page. Each block is a list of (text, size) tuples. + """ + doc = MagicMock() + doc.metadata = metadata or {"title": None} + doc.__len__ = lambda self: len(pages_data) + + mock_pages = [] + for page_blocks in pages_data: + page = MagicMock() + page.get_text.return_value = make_page_dict(page_blocks) + mock_pages.append(page) + + doc.__getitem__ = lambda self, idx: mock_pages[idx] + return doc + + class TestGenerateTitle(unittest.TestCase): def test_prefers_metadata_title_if_valid(self): doc = MagicMock() @@ -11,53 +44,35 @@ def test_prefers_metadata_title_if_valid(self): self.assertEqual( "A Study Regarding The Efficacy of Drugs", title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_is_empty(self): - doc = MagicMock() - doc.metadata = {"title": ""} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_is_empty(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": ""}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(self): - doc = MagicMock() - doc.metadata = {"title": "abcd1234"} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_does_not_match_regex(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": "abcd1234"}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) @patch("api.views.uploadFile.title.openAIServices.openAI") def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc( + pages_data=[[]] # no blocks at all + ) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -70,9 +85,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc(pages_data=[[]]) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -85,9 +98,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): @patch("api.views.uploadFile.title.openAIServices.openAI") def test_truncates_long_openai_title(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc(pages_data=[[]]) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -98,3 +109,55 @@ def test_truncates_long_openai_title(self, mock_openAI): # Ensure the title is truncated to fit the UploadFile model's title field (max_length=255), since OpenAI responses may exceed this limit self.assertLessEqual(len(result), 255) + + def test_font_size_joins_adjacent_spans_in_same_block(self): + """A title split across multiple spans in the same block should be joined.""" + doc = make_mock_doc( + pages_data=[[ + [("Author Name", 10.0)], + [("Advances in Mood Disorder", 18.0), ("Pharmacotherapy", 18.0)], + [("Some journal info", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_ignores_short_spans(self): + """Superscript markers and other tiny spans should be filtered out.""" + doc = make_mock_doc( + pages_data=[[ + [("Advances in Mood Disorder Pharmacotherapy", 18.0), ("*", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + # The "*" span is < 2 chars, so it should be ignored; title is just the real text + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_returns_none_when_no_regex_match(self): + """If the largest-font text doesn't match the title regex, return None.""" + doc = make_mock_doc( + pages_data=[[ + # Only 2 words — regex requires at least 3 + [("Psychiatry Research", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertIsNone(result) + + def test_font_size_finds_title_on_later_page(self): + """Title on page 2 should still be found if it has the largest font.""" + doc = make_mock_doc( + pages_data=[ + [ # page 1: cover page with smaller text + [("Some preamble text here", 12.0)], + ], + [ # page 2: actual title in larger font + [("Advances in Mood Disorder Pharmacotherapy", 18.0)], + [("Author Name et al.", 10.0)], + ], + ], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") From 4bae7460cc4a2f4805a3e49286dc49d340fd8163 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 10 Mar 2026 12:28:50 -0400 Subject: [PATCH 028/101] update documentation to include instructions about how to use the API docs --- CLAUDE.md | 10 ++++++++++ README.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 712082e7..b9f417e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,16 @@ Each module contains: - Auth endpoints via Djoser: `/auth/` - JWT token lifetime: 60 minutes (access), 1 day (refresh) +#### API Documentation +- Auto-generated using **drf-spectacular** (OpenAPI 3.0) +- **Swagger UI**: `http://localhost:8000/api/docs/` — interactive API explorer +- **ReDoc**: `http://localhost:8000/api/redoc/` — readable reference docs +- **Raw schema**: `http://localhost:8000/api/schema/` +- Configuration in `SPECTACULAR_SETTINGS` in `settings.py` +- Views use `@extend_schema` decorators and `serializer_class` attributes for schema generation +- JWT auth is configured in the schema — use `JWT ` (not `Bearer`) in Swagger UI's Authorize dialog +- To document a new endpoint: add `serializer_class` to the view if it has one, or add `@extend_schema` with `inline_serializer` for views returning raw dicts + #### Key Data Models - **Medication** (`api.views.listMeds.models`) - Medication catalog with benefits/risks - **MedRule** (`api.models.model_medRule`) - Include/Exclude rules for medications based on patient history diff --git a/README.md b/README.md index e5a246b1..8988ea75 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` +## API Documentation + +Interactive API docs are auto-generated using [drf-spectacular](https://drf-spectacular.readthedocs.io/) and available at: + +- **Swagger UI**: [http://localhost:8000/api/docs/](http://localhost:8000/api/docs/) — interactive explorer with "Try it out" functionality +- **ReDoc**: [http://localhost:8000/api/redoc/](http://localhost:8000/api/redoc/) — clean, readable reference docs +- **Raw schema**: [http://localhost:8000/api/schema/](http://localhost:8000/api/schema/) — OpenAPI 3.0 JSON/YAML + +### Testing authenticated endpoints + +Most endpoints require JWT authentication. To test them in Swagger UI: + +1. **Get a token**: Find the `POST /auth/jwt/create/` endpoint in Swagger UI, click **Try it out**, enter an authorized `email` and `password`, and click **Execute**. Copy the `access` token from the response. +2. **Authorize**: Click the **Authorize** button (lock icon) at the top of the page. Enter `JWT ` in the value field. The prefix must be `JWT`, not `Bearer`. +3. **Test endpoints**: All subsequent requests will include your token. Use **Try it out** on any protected endpoint. +4. **Token refresh**: Access tokens expire after 60 minutes. Use `POST /auth/jwt/refresh/` with your `refresh` token, or repeat step 1. + ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: From 6f0deedb5505abf5917320daa9539167adda0447 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 10 Mar 2026 19:40:16 -0400 Subject: [PATCH 029/101] update site links on README The links were for the old site and needed an update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5a246b1..4603df49 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage -You can view the current build of the website here: [https://balancertestsite.com](https://balancertestsite.com/) +You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) ## Contributing @@ -53,7 +53,7 @@ The application supports connecting to PostgreSQL databases via: See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. **Local Development:** -- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) +- Download a sample of papers to upload from [https://balancerproject.org/](https://balancerproject.org/) - The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` - The first time you use `pgAdmin` after building the Docker containers you will need to register the server. - The `Host name/address` is the Postgres server service name in the Docker Compose file From 795f21885e766de9f6379319c5555ef7db48d989 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 11 Mar 2026 13:09:14 -0400 Subject: [PATCH 030/101] Run python-app workflow on pushes and PRs to develop branch --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 41bc74d9..a6c07075 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,9 @@ name: Python application on: push: - branches: [ "listOfMed" ] + branches: [ "develop" ] pull_request: - branches: [ "listOfMed" ] + branches: [ "develop" ] permissions: contents: read From d498a0057008b1b2c80bdb176e5940940899639b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Mar 2026 13:15:47 -0400 Subject: [PATCH 031/101] =?UTF-8?q?Pytest=20won=E2=80=99t=20automatically?= =?UTF-8?q?=20discover=20config=20files=20in=20subdirectories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a6c07075..2afa2828 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,6 +29,6 @@ jobs: run: ruff check --output-format=github --target-version=py39 - name: Install test dependencies run: pip install -r server/requirements.txt - # Discover and run all files matching test_*.py or *_test.py under server/ + # Pytest won’t automatically discover config files in subdirectories - name: Run tests - run: pytest server/ -v + run: pytest -c server/pytest.ini server/ -v From 3824d81ae0fb722383f7e80df07ca7ba28d0c4e6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Mar 2026 13:47:52 -0400 Subject: [PATCH 032/101] Suppress E402 import violations --- evaluation/evals.py | 16 ++++++++-------- server/balancer_backend/urls.py | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 8eb7e9e6..5110076f 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -21,18 +21,18 @@ # Ensure the parent directory is in the path to import ModelFactory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import argparse -import logging -import asyncio -import time +import argparse # noqa: E402 +import logging # noqa: E402 +import asyncio # noqa: E402 +import time # noqa: E402 -import pandas as pd +import pandas as pd # noqa: E402 # lighteval depends on `sentencepiece` and it only has prebuilt wheels for Python 3.11 or below -from lighteval.tasks.requests import Doc -from lighteval.metrics.metrics_sample import Extractiveness +from lighteval.tasks.requests import Doc # noqa: E402 +from lighteval.metrics.metrics_sample import Extractiveness # noqa: E402 -from server.api.services.llm_services import ModelFactory +from server.api.services.llm_services import ModelFactory # noqa: E402 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 55bd2032..cdb92dbb 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -58,9 +58,9 @@ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] -import os -from django.conf import settings -from django.http import HttpResponseNotFound +import os # noqa: E402 +from django.conf import settings # noqa: E402 +from django.http import HttpResponseNotFound # noqa: E402 def spa_fallback(request): From 46e9969dade55777098286cd6316bc18444e5b1f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 12:00:44 -0400 Subject: [PATCH 033/101] Add build_query tests and document coverage gaps in embedding_services --- server/api/services/embedding_services.py | 1 + .../api/services/test_embedding_services.py | 202 +++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 3fa9bb68..aca99133 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,6 +2,7 @@ import logging from statistics import median +# filter() only does ADD logic from django.db.models import Q from pgvector.django import L2Distance diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index 677c1e7b..ea322645 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -1,9 +1,175 @@ from unittest.mock import MagicMock, patch -from api.services.embedding_services import evaluate_query, log_usage +from django.db.models import Q +from api.services.embedding_services import build_query, evaluate_query, log_usage + +# --------------------------------------------------------------------------- +# build_query tests +# +# build_query only constructs a lazy Django QuerySet — it never evaluates it +# (no iteration, .get(), .exists(), etc.), so no database is needed. +# +# We patch Embeddings.objects so every chained ORM call (.filter, .annotate, +# .order_by, __getitem__) returns a MagicMock instead of hitting the DB. +# All assertions inspect which methods were called with which arguments. +# --------------------------------------------------------------------------- + +# Only forwarded to L2Distance +EMBEDDING_VECTOR = [0.1, 0.2, 0.3] + +# Test authenticated/unauthenticated user access control + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_authenticated_uses_or_filter(mock_objects): + # An authenticated user should see their own files OR files uploaded by a + # superuser. The initial filter must use an OR-connected Q expression. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Q objects support equality comparison in pure Python — no DB needed. + expected_q = Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) + actual_q = mock_objects.filter.call_args.args[0] + assert actual_q == expected_q + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): + # An unauthenticated user may only see files uploaded by superusers. + # The OR branch for the user's own files must NOT be present. + user = MagicMock(is_authenticated=False) + + build_query(user, EMBEDDING_VECTOR) + + expected_q = Q(upload_file__uploaded_by__is_superuser=True) + actual_q = mock_objects.filter.call_args.args[0] + assert actual_q == expected_q + +# Test application of annotate and order_by + +# TODO: Strengthen test_build_query_annotates_and_orders_by_distance to also +# assert the *arguments* to annotate — specifically that it receives +# distance=L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR). +# Currently only the call count is checked, so a wrong field name or a +# dropped vector would go undetected. + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_annotates_and_orders_by_distance(mock_objects): + # Regardless of other arguments, annotate(distance=L2Distance(...)) and + # order_by("distance") must always be applied to the queryset. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Retrieve the mock chain that .filter() returned, then check its methods. + filtered_qs = mock_objects.filter.return_value + filtered_qs.annotate.assert_called_once() + filtered_qs.annotate.return_value.order_by.assert_called_once_with("distance") + +# Test guid-over-document precedence logic + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_no_document_filter_when_both_none(mock_objects): + # When neither guid nor document_name is provided, only the access-control + # filter should fire — no secondary filter call for a document. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name=None, guid=None) + + # Exactly one filter call: the auth/access-control filter. + assert mock_objects.filter.call_count == 1 + + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_takes_precedence_over_document_name(mock_objects): + # When both guid and document_name are provided, the guid branch runs and + # the document_name branch is skipped entirely (only two filter calls total). + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="abc-123", document_name="study.pdf") + + # Two calls: auth filter + guid filter. No third call for document_name. + assert mock_objects.filter.call_count == 2 + + # The second filter must use upload_file__guid, not name. + # We follow the mock chain to the queryset that .annotate().order_by() returned. + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="abc-123") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_filter_applied(mock_objects): + # When only guid is given, a second filter on upload_file__guid is applied. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="doc-guid-456") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="doc-guid-456") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_document_name_filter_applied(mock_objects): + # When only document_name is given (guid is None), a second filter on + # name is applied instead of upload_file__guid. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name="study.pdf", guid=None) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="study.pdf") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_empty_string_guid_falls_back_to_document_name(mock_objects): + # An empty-string guid is falsy in Python, so it should not trigger the + # guid branch. The document_name filter should fire instead. This guards + # against callers passing guid="" from an unset form field. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="", document_name="fallback.pdf") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="fallback.pdf") + +# Cover LIMIT slicing + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_respects_num_results(mock_objects): + # num_results controls the SQL LIMIT via queryset slicing. Verify that a + # non-default value propagates correctly to the __getitem__ call. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, num_results=5) + + # Django translates qs[:5] into qs.__getitem__(slice(None, 5, None)). + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.__getitem__.assert_called_once_with(slice(None, 5, None)) + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_returns_unevaluated_queryset(mock_objects): + # build_query must NOT evaluate the queryset (no list(), no iteration). + # The return value should be the mock produced by the final __getitem__ call. + user = MagicMock(is_authenticated=True) + + result = build_query(user, EMBEDDING_VECTOR) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + assert result is ordered_qs.__getitem__.return_value + assert not isinstance(result, list) + + +# --------------------------------------------------------------------------- +# evaluate_query tests +# --------------------------------------------------------------------------- + +# TODO: Add test for empty queryset — evaluate_query([]) should return []. def test_evaluate_query_maps_fields(): + # Verify that each Embeddings model attribute is mapped to the correct + # output dict key. Note the rename: obj.page_num -> result["page_number"]. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -27,6 +193,8 @@ def test_evaluate_query_maps_fields(): def test_evaluate_query_none_upload_file(): + # When upload_file is None (e.g. the FK was deleted), file_id must be None + # rather than raising an AttributeError on None.guid. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -39,9 +207,26 @@ def test_evaluate_query_none_upload_file(): assert results[0]["file_id"] is None +# --------------------------------------------------------------------------- +# log_usage tests +# --------------------------------------------------------------------------- + +# TODO: Add test for empty results list — log_usage([]) hits the else branch and +# should call SemanticSearchUsage.objects.create with num_results_returned=0 +# and max_distance=None, median_distance=None, min_distance=None. + +# TODO: Add test for unauthenticated user — user.is_authenticated=False should +# result in user=None being stored in the SemanticSearchUsage record. + +# TODO: Add test for user=None — passing None directly as the user argument +# should also store user=None (the expression `user if (user and +# user.is_authenticated) else None` handles both cases, but only the +# authenticated path is currently exercised). @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") def test_log_usage_computes_distance_stats(mock_create): + # Verify min, max, and median are computed correctly from the distance + # values in the results list and forwarded to the DB record. results = [{"distance": 1.0}, {"distance": 3.0}, {"distance": 2.0}] user = MagicMock(is_authenticated=True) @@ -69,10 +254,12 @@ def test_log_usage_computes_distance_stats(mock_create): side_effect=Exception("DB error"), ) def test_log_usage_swallows_exceptions(mock_create): + # log_usage must not propagate exceptions — a logging failure should never + # interrupt the caller's search flow. + # pytest fails the test if it catches unhandled Exception results = [{"distance": 1.0}] user = MagicMock(is_authenticated=True) - # pytest fails the test if it catches unhandled Exception log_usage( results, message_data="test query", @@ -83,3 +270,14 @@ def test_log_usage_swallows_exceptions(mock_create): encoding_time=0.1, db_query_time=0.2, ) + + +# --------------------------------------------------------------------------- +# get_closest_embeddings tests +# --------------------------------------------------------------------------- + +# TODO: Add smoke test for get_closest_embeddings verifying the wiring between +# its three steps: encode → build_query → evaluate_query → log_usage. +# Patch TransformerModel.get_instance, build_query, evaluate_query, and +# log_usage. Assert that evaluate_query receives the queryset returned by +# build_query, and that the function returns evaluate_query's result. From 64a19ef56dad988d1a2ea61a0b409c7878d0370c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 12:39:51 -0400 Subject: [PATCH 034/101] Fill test gaps in test_embedding_services --- server/api/services/embedding_services.py | 2 +- .../api/services/test_embedding_services.py | 149 ++++++++++++++---- 2 files changed, 119 insertions(+), 32 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index aca99133..dada28a2 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,7 +2,7 @@ import logging from statistics import median -# filter() only does ADD logic +# Django filter() only does ADD logic from django.db.models import Q from pgvector.django import L2Distance diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index ea322645..8cbc1be9 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -1,20 +1,21 @@ from unittest.mock import MagicMock, patch from django.db.models import Q +from pgvector.django import L2Distance -from api.services.embedding_services import build_query, evaluate_query, log_usage +from api.services.embedding_services import ( + build_query, + evaluate_query, + get_closest_embeddings, + log_usage, +) # --------------------------------------------------------------------------- # build_query tests -# -# build_query only constructs a lazy Django QuerySet — it never evaluates it -# (no iteration, .get(), .exists(), etc.), so no database is needed. -# -# We patch Embeddings.objects so every chained ORM call (.filter, .annotate, -# .order_by, __getitem__) returns a MagicMock instead of hitting the DB. -# All assertions inspect which methods were called with which arguments. # --------------------------------------------------------------------------- +# All assertions inspect which methods and arguments were called on Embeddings.objects + # Only forwarded to L2Distance EMBEDDING_VECTOR = [0.1, 0.2, 0.3] @@ -48,12 +49,6 @@ def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): # Test application of annotate and order_by -# TODO: Strengthen test_build_query_annotates_and_orders_by_distance to also -# assert the *arguments* to annotate — specifically that it receives -# distance=L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR). -# Currently only the call count is checked, so a wrong field name or a -# dropped vector would go undetected. - @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_annotates_and_orders_by_distance(mock_objects): # Regardless of other arguments, annotate(distance=L2Distance(...)) and @@ -67,6 +62,12 @@ def test_build_query_annotates_and_orders_by_distance(mock_objects): filtered_qs.annotate.assert_called_once() filtered_qs.annotate.return_value.order_by.assert_called_once_with("distance") + # L2Distance is a Django Func subclass, which implements __eq__ by comparing + # class and source expressions — so we can assert the exact field name and + # vector without patching L2Distance itself. + actual_distance_expr = filtered_qs.annotate.call_args.kwargs["distance"] + assert actual_distance_expr == L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR) + # Test guid-over-document precedence logic @patch("api.services.embedding_services.Embeddings.objects") @@ -165,7 +166,10 @@ def test_build_query_returns_unevaluated_queryset(mock_objects): # evaluate_query tests # --------------------------------------------------------------------------- -# TODO: Add test for empty queryset — evaluate_query([]) should return []. +def test_evaluate_query_empty_queryset(): + # An empty iterable should return an empty list, not raise an exception. + assert evaluate_query([]) == [] + def test_evaluate_query_maps_fields(): # Verify that each Embeddings model attribute is mapped to the correct @@ -193,8 +197,8 @@ def test_evaluate_query_maps_fields(): def test_evaluate_query_none_upload_file(): - # When upload_file is None (e.g. the FK was deleted), file_id must be None - # rather than raising an AttributeError on None.guid. + # When upload_file is None, file_id must be None rather than raising + # an AttributeError on None.guid. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -211,17 +215,71 @@ def test_evaluate_query_none_upload_file(): # log_usage tests # --------------------------------------------------------------------------- -# TODO: Add test for empty results list — log_usage([]) hits the else branch and -# should call SemanticSearchUsage.objects.create with num_results_returned=0 -# and max_distance=None, median_distance=None, min_distance=None. +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_empty_results(mock_create): + # Empty results hits the else branch. The record should still be created + # with num_results_returned=0 and all distance fields set to None. + user = MagicMock(is_authenticated=True) + + log_usage( + [], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["num_results_returned"] == 0 + assert kwargs["max_distance"] is None + assert kwargs["median_distance"] is None + assert kwargs["min_distance"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_unauthenticated_user_stored_as_none(mock_create): + # An unauthenticated user should be stored as None in the DB record, not as + # the user object itself, so the FK constraint is not violated. + user = MagicMock(is_authenticated=False) + + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_none_user_stored_as_none(mock_create): + # Passing user=None directly (e.g. from an anonymous request) should also + # store None — the expression `user if (user and user.is_authenticated)` + # short-circuits on the falsy None before accessing .is_authenticated. + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=None, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) -# TODO: Add test for unauthenticated user — user.is_authenticated=False should -# result in user=None being stored in the SemanticSearchUsage record. + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None -# TODO: Add test for user=None — passing None directly as the user argument -# should also store user=None (the expression `user if (user and -# user.is_authenticated) else None` handles both cases, but only the -# authenticated path is currently exercised). @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") def test_log_usage_computes_distance_stats(mock_create): @@ -276,8 +334,37 @@ def test_log_usage_swallows_exceptions(mock_create): # get_closest_embeddings tests # --------------------------------------------------------------------------- -# TODO: Add smoke test for get_closest_embeddings verifying the wiring between -# its three steps: encode → build_query → evaluate_query → log_usage. -# Patch TransformerModel.get_instance, build_query, evaluate_query, and -# log_usage. Assert that evaluate_query receives the queryset returned by -# build_query, and that the function returns evaluate_query's result. +@patch("api.services.embedding_services.log_usage") +@patch("api.services.embedding_services.evaluate_query") +@patch("api.services.embedding_services.build_query") +@patch("api.services.embedding_services.TransformerModel") +def test_get_closest_embeddings_wiring(mock_transformer, mock_build, mock_evaluate, mock_log): + # Smoke test verifying that get_closest_embeddings correctly wires together + # encode → build_query → evaluate_query → log_usage and returns the results. + user = MagicMock(is_authenticated=True) + + # Simulate the model encoding the message to a vector. + fake_vector = [0.1, 0.2, 0.3] + mock_transformer.get_instance.return_value.model.encode.return_value = fake_vector + + # build_query returns a queryset; evaluate_query turns it into a results list. + fake_queryset = MagicMock() + mock_build.return_value = fake_queryset + fake_results = [{"name": "doc.pdf", "distance": 0.5}] + mock_evaluate.return_value = fake_results + + result = get_closest_embeddings(user, "some query", document_name="doc.pdf", guid=None, num_results=5) + + # The encoded vector must be forwarded to build_query. + mock_build.assert_called_once_with(user, fake_vector, "doc.pdf", None, 5) + + # evaluate_query must receive the queryset that build_query returned. + mock_evaluate.assert_called_once_with(fake_queryset) + + # log_usage must be called with the results and original parameters. + mock_log.assert_called_once() + log_kwargs = mock_log.call_args.args + assert log_kwargs[0] is fake_results + + # The function must return evaluate_query's result unchanged. + assert result is fake_results From dec3c12a71c1fefc81f30768fe2aec8e48df2fb8 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 14:05:17 -0400 Subject: [PATCH 035/101] Fix incorrect build_query test assertions --- .../api/services/test_embedding_services.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index 8cbc1be9..dcbb2fc7 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -38,14 +38,13 @@ def test_build_query_authenticated_uses_or_filter(mock_objects): @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): # An unauthenticated user may only see files uploaded by superusers. - # The OR branch for the user's own files must NOT be present. + # The source uses a plain kwarg here (not a positional Q object), so the + # value lives in call_args.kwargs, not call_args.args. user = MagicMock(is_authenticated=False) build_query(user, EMBEDDING_VECTOR) - expected_q = Q(upload_file__uploaded_by__is_superuser=True) - actual_q = mock_objects.filter.call_args.args[0] - assert actual_q == expected_q + assert mock_objects.filter.call_args.kwargs == {"upload_file__uploaded_by__is_superuser": True} # Test application of annotate and order_by @@ -86,16 +85,18 @@ def test_build_query_no_document_filter_when_both_none(mock_objects): @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_guid_takes_precedence_over_document_name(mock_objects): # When both guid and document_name are provided, the guid branch runs and - # the document_name branch is skipped entirely (only two filter calls total). + # the document_name branch is skipped entirely. user = MagicMock(is_authenticated=True) build_query(user, EMBEDDING_VECTOR, guid="abc-123", document_name="study.pdf") - # Two calls: auth filter + guid filter. No third call for document_name. - assert mock_objects.filter.call_count == 2 + # The auth filter fires on mock_objects.filter (call_count == 1). + # The document filter fires on the chained ordered_qs.filter — a different + # mock object — so mock_objects.filter.call_count stays at 1. + assert mock_objects.filter.call_count == 1 - # The second filter must use upload_file__guid, not name. - # We follow the mock chain to the queryset that .annotate().order_by() returned. + # The document filter must use upload_file__guid, not name, and must be + # called exactly once (confirming document_name branch was skipped). ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value ordered_qs.filter.assert_called_once_with(upload_file__guid="abc-123") From 25a16b99475f12675be137d25e19e92013f36cc1 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:07:48 -0400 Subject: [PATCH 036/101] feat: migrate manifests from Ingress to Gateway API (HTTPRoute) --- deploy/manifests/balancer/base/httproute.yaml | 18 +++++++++++++++ deploy/manifests/balancer/base/ingress.yaml | 23 ------------------- .../balancer/base/kustomization.yaml | 2 +- deploy/manifests/balancer/base/service.yaml | 2 +- .../balancer/overlays/dev/kustomization.yaml | 10 ++------ 5 files changed, 22 insertions(+), 33 deletions(-) create mode 100644 deploy/manifests/balancer/base/httproute.yaml delete mode 100644 deploy/manifests/balancer/base/ingress.yaml diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml new file mode 100644 index 00000000..f9ca0df7 --- /dev/null +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -0,0 +1,18 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: balancer +spec: + parentRefs: + - name: main-gateway + namespace: envoy-gateway-system + hostnames: + - HOSTNAME_PLACEHOLDER + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: balancer + port: 80 diff --git a/deploy/manifests/balancer/base/ingress.yaml b/deploy/manifests/balancer/base/ingress.yaml deleted file mode 100644 index fc98305b..00000000 --- a/deploy/manifests/balancer/base/ingress.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: balancer - annotations: {} -spec: - ingressClassName: nginx - tls: - - hosts: - - HOSTNAME_PLACEHOLDER - secretName: balancer-tls - rules: - - host: HOSTNAME_PLACEHOLDER - http: - paths: - # All traffic routes to balancer service (which serves both API and frontend) - - path: / - pathType: Prefix - backend: - service: - name: balancer - port: - number: 8000 diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..ad95f5b1 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -5,4 +5,4 @@ resources: - namespace.yaml - deployment.yaml - service.yaml - - ingress.yaml + - httproute.yaml diff --git a/deploy/manifests/balancer/base/service.yaml b/deploy/manifests/balancer/base/service.yaml index 8f294d53..2c839248 100644 --- a/deploy/manifests/balancer/base/service.yaml +++ b/deploy/manifests/balancer/base/service.yaml @@ -7,7 +7,7 @@ metadata: spec: ports: - name: http - port: 8000 + port: 80 targetPort: 8000 selector: app: balancer diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yaml b/deploy/manifests/balancer/overlays/dev/kustomization.yaml index d3975eb2..f3e287fe 100644 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/dev/kustomization.yaml @@ -12,15 +12,9 @@ images: patches: - target: - kind: Ingress + kind: HTTPRoute name: balancer patch: |- - - op: add - path: /metadata/annotations/cert-manager.io~1cluster-issuer - value: letsencrypt-staging - op: replace - path: /spec/tls/0/hosts/0 - value: localhost - - op: replace - path: /spec/rules/0/host + path: /spec/hostnames/0 value: localhost From 1ea88947332a6f9e254ff589e09d083ccf71a788 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:09:09 -0400 Subject: [PATCH 037/101] feat: configure HTTPRoute for sandbox.balancerproject.org --- deploy/manifests/balancer/base/httproute.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index f9ca0df7..437d5d37 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -7,7 +7,7 @@ spec: - name: main-gateway namespace: envoy-gateway-system hostnames: - - HOSTNAME_PLACEHOLDER + - sandbox.balancerproject.org rules: - matches: - path: From 1484428bac3e67a655c1a35e4196da56d1af094b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:38:48 -0400 Subject: [PATCH 038/101] feat: add sandbox and production overlays for HTTPRoute --- deploy/manifests/balancer/base/httproute.yaml | 2 +- .../overlays/production/kustomization.yaml | 20 +++++++++++++++++++ .../overlays/sandbox/kustomization.yaml | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/overlays/production/kustomization.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index 437d5d37..f9ca0df7 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -7,7 +7,7 @@ spec: - name: main-gateway namespace: envoy-gateway-system hostnames: - - sandbox.balancerproject.org + - HOSTNAME_PLACEHOLDER rules: - matches: - path: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml new file mode 100644 index 00000000..859020ae --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: latest + +patches: + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: replace + path: /spec/hostnames/0 + value: balancerproject.org diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..c609dc8b --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: latest + +patches: + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: replace + path: /spec/hostnames/0 + value: sandbox.balancerproject.org From 6f5088792bcd7bb3826b7b7889b021fe539a27bf Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:41:44 -0400 Subject: [PATCH 039/101] cleanup: remove unused dev overlay --- .../balancer/overlays/dev/kustomization.yaml | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 deploy/manifests/balancer/overlays/dev/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yaml b/deploy/manifests/balancer/overlays/dev/kustomization.yaml deleted file mode 100644 index f3e287fe..00000000 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: balancer - -resources: - - ../../base - -images: - - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest - -patches: - - target: - kind: HTTPRoute - name: balancer - patch: |- - - op: replace - path: /spec/hostnames/0 - value: localhost From f9e890a21a3c716fe2bcfb17b8dfef92fcebb905 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 23 Mar 2026 14:43:39 -0400 Subject: [PATCH 040/101] Guard TransformerModel preload to runserver processes only --- server/api/apps.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/api/apps.py b/server/api/apps.py index 4d502cba..d8b9eaa7 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -6,5 +6,28 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): + import os + import sys + + # ready() runs in every Django process: migrate, test, shell, runserver, etc. + # Only preload the model when we're actually going to serve requests. + # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. + # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. + # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing + # runserver — the guard below correctly skips model loading for those commands too. + if sys.argv[1:2] != ['runserver']: + return + + # Dev's autoreloader spawns two processes: a parent file-watcher and a child + # server. ready() runs in both, but only the child (RUN_MAIN=true) serves + # requests. Skip the parent to avoid loading the model twice on each file change. + # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. + if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: + return + + # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first + # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. + # That cache is ephemeral — every container rebuild re-downloads the model unless + # a volume is mounted at that path. from .services.sentencetTransformer_model import TransformerModel TransformerModel.get_instance() From 67176a8541be4d2862d213783873e1651f6fe761 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 25 Mar 2026 16:42:20 -0400 Subject: [PATCH 041/101] Revert GitHub Workflow changes --- .github/workflows/python-app.yml | 9 ++------- evaluation/evals.py | 16 ++++++++-------- server/balancer_backend/urls.py | 6 +++--- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 2afa2828..dcb7a4bb 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,9 @@ name: Python application on: push: - branches: [ "develop" ] + branches: [ "listOfMed" ] pull_request: - branches: [ "develop" ] + branches: [ "listOfMed" ] permissions: contents: read @@ -27,8 +27,3 @@ jobs: run: pipx install ruff - name: Lint code with Ruff run: ruff check --output-format=github --target-version=py39 - - name: Install test dependencies - run: pip install -r server/requirements.txt - # Pytest won’t automatically discover config files in subdirectories - - name: Run tests - run: pytest -c server/pytest.ini server/ -v diff --git a/evaluation/evals.py b/evaluation/evals.py index 5110076f..8eb7e9e6 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -21,18 +21,18 @@ # Ensure the parent directory is in the path to import ModelFactory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import argparse # noqa: E402 -import logging # noqa: E402 -import asyncio # noqa: E402 -import time # noqa: E402 +import argparse +import logging +import asyncio +import time -import pandas as pd # noqa: E402 +import pandas as pd # lighteval depends on `sentencepiece` and it only has prebuilt wheels for Python 3.11 or below -from lighteval.tasks.requests import Doc # noqa: E402 -from lighteval.metrics.metrics_sample import Extractiveness # noqa: E402 +from lighteval.tasks.requests import Doc +from lighteval.metrics.metrics_sample import Extractiveness -from server.api.services.llm_services import ModelFactory # noqa: E402 +from server.api.services.llm_services import ModelFactory logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index cdb92dbb..55bd2032 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -58,9 +58,9 @@ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] -import os # noqa: E402 -from django.conf import settings # noqa: E402 -from django.http import HttpResponseNotFound # noqa: E402 +import os +from django.conf import settings +from django.http import HttpResponseNotFound def spa_fallback(request): From d27392190c45e465dc81bf363f7bb381ec9c7aad Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 26 Mar 2026 14:35:35 -0400 Subject: [PATCH 042/101] Add section header comments to all four test groups in test_embedding_services.py --- .../api/services/test_embedding_services.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index dcbb2fc7..d1095366 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -12,10 +12,14 @@ # --------------------------------------------------------------------------- # build_query tests +# +# build_query is responsible for access control, annotate/order, document filter +# and slicing and only constructs a lazy Django QuerySet without evaluating it +# +# We can test build_query by patching Embeddings.objects and inspecting which +# methods and arguments were called on Embeddings.objects # --------------------------------------------------------------------------- -# All assertions inspect which methods and arguments were called on Embeddings.objects - # Only forwarded to L2Distance EMBEDDING_VECTOR = [0.1, 0.2, 0.3] @@ -165,6 +169,13 @@ def test_build_query_returns_unevaluated_queryset(mock_objects): # --------------------------------------------------------------------------- # evaluate_query tests +# +# evaluate_query is responsible for iterating the queryset and mapping each +# Embeddings object's attributes to a result dict, including the rename +# page_num -> page_number and the None-safe file_id lookup +# +# We can test evaluate_query by passing plain MagicMock objects directly as +# the iterable and asserting on the shape and values of the returned list # --------------------------------------------------------------------------- def test_evaluate_query_empty_queryset(): @@ -214,6 +225,13 @@ def test_evaluate_query_none_upload_file(): # --------------------------------------------------------------------------- # log_usage tests +# +# log_usage is responsible for computing distance stats, storing the correct +# user (None for unauthenticated), handling empty results, and swallowing +# exceptions so search is never interrupted +# +# We can test log_usage by patching SemanticSearchUsage.objects.create and +# inspecting the keyword arguments it was called with # --------------------------------------------------------------------------- @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") @@ -333,6 +351,12 @@ def test_log_usage_swallows_exceptions(mock_create): # --------------------------------------------------------------------------- # get_closest_embeddings tests +# +# get_closest_embeddings is responsible for wiring together encode, +# build_query, evaluate_query, and log_usage and returning the results +# +# We can test get_closest_embeddings by patching all four collaborators and +# asserting that each is called with the correct arguments in the correct order # --------------------------------------------------------------------------- @patch("api.services.embedding_services.log_usage") From 8198574dc63e04e03d0c7198dfa2186c458c206e Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 26 Mar 2026 15:24:12 -0400 Subject: [PATCH 043/101] Document why tests are split by responsibility --- server/api/services/test_embedding_services.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index d1095366..b1e5d5a7 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -10,6 +10,11 @@ log_usage, ) +# Each function is tested one responsibility at a time. One test for the whole +# function collapses all responsibilities into a single assertion block — when +# it fails you know something is broken but not which responsibility. You have +# to debug to find out. + # --------------------------------------------------------------------------- # build_query tests # From 5d8c8b37403ccc98e0b02dc0e491f0f30a61bd9c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:00:08 -0400 Subject: [PATCH 044/101] Improve logging and comments --- server/api/services/embedding_services.py | 6 +-- .../api/services/test_embedding_services.py | 2 +- server/balancer_backend/settings.py | 48 +++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index dada28a2..213519e5 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,7 +2,7 @@ import logging from statistics import median -# Django filter() only does ADD logic +# Use Q objects to express OR conditions in Django queries from django.db.models import Q from pgvector.django import L2Distance @@ -146,8 +146,8 @@ def log_usage( median_distance=None, min_distance=None, ) - except Exception as e: - logger.error(f"Failed to create semantic search usage database record: {e}") + except Exception: + logger.exception("Failed to create semantic search usage database record") def get_closest_embeddings( diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index b1e5d5a7..e43c0d74 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- # Only forwarded to L2Distance -EMBEDDING_VECTOR = [0.1, 0.2, 0.3] +EMBEDDING_VECTOR = [0.1, 0.2, 0.3] # Test authenticated/unauthenticated user access control diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..c56a3435 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -254,27 +254,27 @@ # Logging configuration -# LOGGING = { -# "version": 1, -# "disable_existing_loggers": False, -# "formatters": { -# "verbose": { -# "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", -# "style": "{", -# }, -# "simple": { -# "format": "{levelname} {message}", -# "style": "{", -# }, -# }, -# "handlers": { -# "console": { -# "class": "logging.StreamHandler", -# "formatter": "verbose", -# }, -# }, -# "root": { -# "handlers": ["console"], -# "level": "INFO", -# }, -# } +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} From 31498dcb62874899302acc72bfc9031d06800f05 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:06:03 -0400 Subject: [PATCH 045/101] Fall back to lazy load using try except block --- server/api/apps.py | 54 +++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/server/api/apps.py b/server/api/apps.py index d8b9eaa7..c7eccc53 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -6,28 +6,32 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): - import os - import sys - - # ready() runs in every Django process: migrate, test, shell, runserver, etc. - # Only preload the model when we're actually going to serve requests. - # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. - # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. - # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing - # runserver — the guard below correctly skips model loading for those commands too. - if sys.argv[1:2] != ['runserver']: - return - - # Dev's autoreloader spawns two processes: a parent file-watcher and a child - # server. ready() runs in both, but only the child (RUN_MAIN=true) serves - # requests. Skip the parent to avoid loading the model twice on each file change. - # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. - if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: - return - - # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first - # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. - # That cache is ephemeral — every container rebuild re-downloads the model unless - # a volume is mounted at that path. - from .services.sentencetTransformer_model import TransformerModel - TransformerModel.get_instance() + + try: + import os + import sys + + # ready() runs in every Django process: migrate, test, shell, runserver, etc. + # Only preload the model when we're actually going to serve requests. + # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. + # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. + # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing + # runserver — the guard below correctly skips model loading for those commands too. + if sys.argv[1:2] != ['runserver']: + return + + # Dev's autoreloader spawns two processes: a parent file-watcher and a child + # server. ready() runs in both, but only the child (RUN_MAIN=true) serves + # requests. Skip the parent to avoid loading the model twice on each file change. + # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. + if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: + return + + # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first + # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. + # That cache is ephemeral — every container rebuild re-downloads the model unless + # a volume is mounted at that path. + from .services.sentencetTransformer_model import TransformerModel + TransformerModel.get_instance() + except Exception: + logger.exception("Failed to preload the embedding model at startup") From a39d33c75564b564b3740134223eaa09333331b6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:17:04 -0400 Subject: [PATCH 046/101] Revert settings.py to develop state --- server/balancer_backend/settings.py | 48 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index c56a3435..a4ccaaae 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -254,27 +254,27 @@ # Logging configuration -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "verbose": { - "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", - "style": "{", - }, - "simple": { - "format": "{levelname} {message}", - "style": "{", - }, - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "formatter": "verbose", - }, - }, - "root": { - "handlers": ["console"], - "level": "INFO", - }, -} +# LOGGING = { +# "version": 1, +# "disable_existing_loggers": False, +# "formatters": { +# "verbose": { +# "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", +# "style": "{", +# }, +# "simple": { +# "format": "{levelname} {message}", +# "style": "{", +# }, +# }, +# "handlers": { +# "console": { +# "class": "logging.StreamHandler", +# "formatter": "verbose", +# }, +# }, +# "root": { +# "handlers": ["console"], +# "level": "INFO", +# }, +# } From fe1eecaf133c8e82f5df765fd2161874a0def2a7 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 12:27:29 -0400 Subject: [PATCH 047/101] Manually test fall back to lazy loading --- server/api/apps.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/api/apps.py b/server/api/apps.py index c7eccc53..13977850 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -7,7 +7,7 @@ class ApiConfig(AppConfig): def ready(self): - try: + try: import os import sys @@ -34,4 +34,8 @@ def ready(self): from .services.sentencetTransformer_model import TransformerModel TransformerModel.get_instance() except Exception: + # TransformerModel._instance stays None on failure, so the first actual request + # that calls get_instance() will attempt to load the model again. + import logging + logger = logging.getLogger(__name__) logger.exception("Failed to preload the embedding model at startup") From b4d70f53f1b03e212e9326d565df724dca9f45b9 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:25:16 -0400 Subject: [PATCH 048/101] fix isSuperuser hardcoded to true on page reload --- frontend/src/services/reducers/auth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..9cc5d278 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -68,12 +68,15 @@ const initialState: StateType = { export default function authReducer(state = initialState, action: ActionType): StateType { switch(action.type) { - case AUTHENTICATED_SUCCESS: + case AUTHENTICATED_SUCCESS: { + const token = localStorage.getItem('access'); + const decoded: TokenClaims = token ? jwtDecode(token) : { is_superuser: false }; return { ...state, isAuthenticated: true, - isSuperuser: true + isSuperuser: decoded.is_superuser } + } case LOGIN_SUCCESS: case GOOGLE_AUTH_SUCCESS: case FACEBOOK_AUTH_SUCCESS:{ From 23a045b3e9994eb8404128aa92e2be736d490777 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:53:22 -0400 Subject: [PATCH 049/101] add IsSuperuser permission class and apply to admin-only endpoints --- server/api/permissions.py | 6 ++++++ server/api/views/ai_settings/views.py | 4 ++-- server/api/views/listMeds/views.py | 3 +++ server/api/views/medRules/views.py | 4 ++-- server/api/views/text_extraction/views.py | 6 +++--- server/api/views/uploadFile/views.py | 7 ++++--- 6 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 server/api/permissions.py diff --git a/server/api/permissions.py b/server/api/permissions.py new file mode 100644 index 00000000..0dbe0597 --- /dev/null +++ b/server/api/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_superuser) diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 9ee6aad7..7f453200 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -1,6 +1,6 @@ from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from drf_spectacular.utils import extend_schema from .models import AI_Settings @@ -9,7 +9,7 @@ @extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsSuperUser]) def settings_view(request): if request.method == 'GET': settings = AI_Settings.objects.all() diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 1b199a7e..4321615d 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,5 +1,6 @@ from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema, inline_serializer @@ -127,6 +128,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + permission_classes = [IsSuperUser] serializer_class = MedicationSerializer def post(self, request): @@ -158,6 +160,7 @@ class DeleteMedication(APIView): """ API endpoint to delete medication if medication in database. """ + permission_classes = [IsSuperUser] @extend_schema( request=inline_serializer(name='DeleteMedicationRequest', fields={ diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2f80f8f3..7e4ecae5 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers +from api.permissions import IsSuperUser from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from drf_spectacular.utils import extend_schema, inline_serializer @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = MedRuleSerializer def get(self, request, format=None): diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 020740ad..35abe976 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -97,7 +97,7 @@ def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chun @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ @@ -155,7 +155,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index eda43b76..6da092ce 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,6 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView @@ -24,7 +25,7 @@ class UploadFileView(APIView): def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access - return [IsAuthenticated()] # Auth required for other methods + return [IsSuperUser()] # Superuser required for write methods def get(self, request, format=None): print("UploadFileView, get list") @@ -217,7 +218,7 @@ def get(self, request, guid, format=None): class EditFileMetadataView(UpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = UploadFileSerializer lookup_field = 'guid' From 21a42058624901963ca2bd037942d80a4f082080 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:13:21 -0400 Subject: [PATCH 050/101] add AdminRoute component and applly to admin-only pages --- .../components/ProtectedRoute/AdminRoute.tsx | 38 +++++++++++++++++++ frontend/src/routes/routes.tsx | 13 ++++--- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ProtectedRoute/AdminRoute.tsx diff --git a/frontend/src/components/ProtectedRoute/AdminRoute.tsx b/frontend/src/components/ProtectedRoute/AdminRoute.tsx new file mode 100644 index 00000000..61195cb8 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/AdminRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface AdminRouteProps { + children: ReactNode; +} + +const AdminRoute = ({ children }: AdminRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + if (isAuthenticated === null) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isSuperuser) { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..9dd99e97 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -19,6 +19,7 @@ import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; +import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; const routes = [ { @@ -28,17 +29,17 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", @@ -86,11 +87,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +99,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; From 8879f34d71eedccce2d467be39d4bf8d01fa9af2 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:35:51 -0400 Subject: [PATCH 051/101] lock down CORS to environment-driven allowlist (only dev right now) --- config/env/dev.env.example | 1 + server/balancer_backend/settings.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 4b40294b..59713f64 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -31,6 +31,7 @@ SQL_PORT=5432 # SQL_SSL_MODE=require LOGIN_REDIRECT_URL= +CORS_ALLOWED_ORIGINS=http://localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..fdd31c10 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,7 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") TEMPLATES = [ { From 06ce32e5d43a33fa14d74a35f7dbec9a91fab879 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 13:52:52 -0400 Subject: [PATCH 052/101] configure console email backend for local development --- server/balancer_backend/settings.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index fdd31c10..1eb68d0c 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -139,12 +139,15 @@ "default": db_config, } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = True +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +else: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "smtp.gmail.com" + EMAIL_PORT = 587 + EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") + EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + EMAIL_USE_TLS = True # Password validation From e53291515d1a46cd71a3f08da3f3dc96c4c5930f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:19:23 -0400 Subject: [PATCH 053/101] wire up signup form and email activation Redux actions --- frontend/src/api/endpoints.ts | 3 + frontend/src/services/actions/auth.tsx | 106 ++++++++++++------------- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3f8585f0..edc044b0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -19,6 +19,9 @@ export const AUTH_ENDPOINTS = { USER_ME: `${API_BASE}/auth/users/me/`, RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, + USERS_CREATE: `${API_BASE}/auth/users/`, + USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, + USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, } as const; /** diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index a6a30ff3..43c95fd7 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -233,64 +233,58 @@ export const reset_password_confirm = } }; -// export const signup = -// (first_name, last_name, email, password, re_password) => -// async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; - -// const body = JSON.stringify({ -// first_name, -// last_name, -// email, -// password, -// re_password, -// }); - -// try { -// const res = await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/`, -// body, -// config -// ); +export const signup = + (first_name: string, last_name: string, email: string, password: string, re_password: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// dispatch({ -// type: SIGNUP_SUCCESS, -// payload: res.data, -// }); -// } catch (err) { -// dispatch({ -// type: SIGNUP_FAIL, -// }); -// } -// }; + const body = JSON.stringify({ first_name, last_name, email, password, re_password }); -// export const verify = -// (uid, token) => async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; + try { + const res = await axios.post(AUTH_ENDPOINTS.USERS_CREATE, body, config); + dispatch({ + type: SIGNUP_SUCCESS, + payload: res.data, + }); + } catch (err) { + let errorMessage = "Registration failed"; + if (isAxiosError(err) && err.response) { + const messages = Object.values(err.response.data as Record).flat(); + if (messages.length > 0) errorMessage = messages.join(" "); + } + dispatch({ + type: SIGNUP_FAIL, + payload: errorMessage, + }); + throw err; + } + }; -// const body = JSON.stringify({ uid, token }); +export const verify = + (uid: string, token: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// try { -// await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/activation/`, -// body, -// config -// ); + const body = JSON.stringify({ uid, token }); -// dispatch({ -// type: ACTIVATION_SUCCESS, -// }); -// } catch (err) { -// dispatch({ -// type: ACTIVATION_FAIL, -// }); -// } -// }; + try { + await axios.post(AUTH_ENDPOINTS.USERS_ACTIVATION, body, config); + dispatch({ + type: ACTIVATION_SUCCESS, + payload: "", + }); + } catch (err) { + dispatch({ + type: ACTIVATION_FAIL, + }); + throw err; + } + }; From 99ac0777f6bcbda8bca8f56c4c517746a9aeda76 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:37:27 -0400 Subject: [PATCH 054/101] build registration form with validation and email sent success state --- .../src/pages/Register/RegistrationForm.tsx | 256 ++++++++++++++---- 1 file changed, 198 insertions(+), 58 deletions(-) diff --git a/frontend/src/pages/Register/RegistrationForm.tsx b/frontend/src/pages/Register/RegistrationForm.tsx index c1745b3d..8134c521 100644 --- a/frontend/src/pages/Register/RegistrationForm.tsx +++ b/frontend/src/pages/Register/RegistrationForm.tsx @@ -1,71 +1,211 @@ import { useFormik } from "formik"; +import * as Yup from "yup"; import { Link } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { signup, AppDispatch } from "../../services/actions/auth"; +import { RootState } from "../../services/actions/types"; +import { useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + email: Yup.string().email("Enter a valid email").required("Email is required"), + password: Yup.string() + .min(8, "Password must be at least 8 characters") + .required("Password is required"), + re_password: Yup.string() + .oneOf([Yup.ref("password")], "Passwords must match") + .required("Please confirm your password"), +}); + +const RegistrationForm = () => { + const dispatch = useDispatch(); + const signupError = useSelector((state: RootState) => state.auth.error); + const [submitted, setSubmitted] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); + + const { handleSubmit, handleChange, handleBlur, values, errors, touched, isSubmitting } = + useFormik({ + initialValues: { + first_name: "", + last_name: "", + email: "", + password: "", + re_password: "", + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch(signup(values.first_name, values.last_name, values.email, values.password, values.re_password)); + setSubmittedEmail(values.email); + setSubmitted(true); + } catch { + // error is stored in Redux state and displayed via signupError + } finally { + setSubmitting(false); + } + }, + }); + + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.USERS_RESEND_ACTIVATION, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + + if (submitted) { + return ( +
    +
    +

    + Check your email +

    +

    + We sent an activation link to {submittedEmail}. Click the link to activate your account. +

    +
    + + Go to log in + + +
    +
    +
    + ); + } -const LoginForm = () => { - const { handleSubmit, handleChange, values } = useFormik({ - initialValues: { - email: "", - password: "", - }, - onSubmit: (values) => { - console.log("values", values); - // make registration post request here. - }, - }); return ( - <> -
    -

    - Register +
    +
    +

    + Create account

    - -
    - - -
    -
    - - -
    - -
    -
    -

    + {signupError && ( +

    {signupError}

    + )} + +
    + + + {touched.first_name && errors.first_name && ( +

    {errors.first_name}

    + )} +
    + +
    + + + {touched.last_name && errors.last_name && ( +

    {errors.last_name}

    + )} +
    + +
    + + + {touched.email && errors.email && ( +

    {errors.email}

    + )} +
    + +
    + + + {touched.password && errors.password && ( +

    {errors.password}

    + )} +
    + +
    + + + {touched.re_password && errors.re_password && ( +

    {errors.re_password}

    + )} +
    + + + +

    Already have an account?{" "} - {" "} - Login here. + Log in

    - +

    ); }; -export default LoginForm; +export default RegistrationForm; From 3d64d76aba32c197510bd59900d838941697336c Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 15:30:52 -0400 Subject: [PATCH 055/101] build email activation page and add route --- config/env/dev.env.example | 2 + frontend/src/pages/Activate/Activate.tsx | 76 ++++++++++++++++++++++++ frontend/src/routes/routes.tsx | 5 ++ server/balancer_backend/settings.py | 6 ++ 4 files changed, 89 insertions(+) create mode 100644 frontend/src/pages/Activate/Activate.tsx diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 59713f64..b8e195cf 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -32,6 +32,8 @@ SQL_PORT=5432 LOGIN_REDIRECT_URL= CORS_ALLOWED_ORIGINS=http://localhost:3000 +# Domain used by Djoser for activation and password reset email links (should be the frontend URL) +FRONTEND_DOMAIN=localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/frontend/src/pages/Activate/Activate.tsx b/frontend/src/pages/Activate/Activate.tsx new file mode 100644 index 00000000..391ec04b --- /dev/null +++ b/frontend/src/pages/Activate/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { verify, AppDispatch } from "../../services/actions/auth"; +import Layout from "../Layout/Layout"; +import Spinner from "../../components/LoadingSpinner/LoadingSpinner"; + +const Activate = () => { + const { uid, token } = useParams<{ uid: string; token: string }>(); + const dispatch = useDispatch(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + if (!uid || !token) { + setStatus("error"); + return; + } + + (async () => { + try { + await dispatch(verify(uid, token)); + setStatus("success"); + } catch { + setStatus("error"); + } + })(); + }, [dispatch, uid, token]); + + if (status === "loading") { + return ( + + + + ); + } + + if (status === "error") { + return ( + +
    +
    +

    + Activation failed +

    +

    + This activation link is invalid or has already been used. Please register again or request a new activation email. +

    + + Back to register + +
    +
    +
    + ); + } + + return ( + +
    +
    +

    + Email verified +

    +

    + Your account has been activated. You can now log in. +

    + + Continue to log in + +
    +
    +
    + ); +}; + +export default Activate; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 9dd99e97..b94cb64f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -20,6 +20,7 @@ import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; +import Activate from "../pages/Activate/Activate.tsx"; const routes = [ { @@ -49,6 +50,10 @@ const routes = [ path: "register", element: , }, + { + path: "activate/:uid/:token", + element: , + }, { path: "login", element: , diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 1eb68d0c..070ac581 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -221,6 +221,12 @@ "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } +# Domain used by Djoser to build activation and password reset links in emails. +# Should point to the frontend, not the backend, since the frontend handles these routes. +# Override in production via environment variable. +DOMAIN = os.environ.get("FRONTEND_DOMAIN", "localhost:3000") +SITE_NAME = "Balancer" + DJOSER = { "LOGIN_FIELD": "email", "USER_CREATE_PASSWORD_RETYPE": True, From bf2a6013c1359334cebe72f5174406c8d27b598f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 12:59:25 -0400 Subject: [PATCH 056/101] update login page for non-admin users login page no longer says "This login page is for Code for philly administrators" page now includes options to reset password and create an account --- frontend/src/pages/Login/LoginForm.tsx | 32 ++++++++------------------ 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d0d08184..1d27aac5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -6,7 +6,6 @@ import { RootState } from "../../services/actions/types"; import { useState, useEffect } from "react"; import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; -import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { isAuthenticated: boolean | null; @@ -60,19 +59,9 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" >
    - {/* {errorMessage &&
    {errorMessage}
    } */}

    - Welcome + Log in

    - -
    -
    - -
    -
    -

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    -
    -
    @@ -113,18 +102,17 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Sign In
    +
    + + Don't have an account? Sign up + + + Forgot password? + +
    - { loading && } - - {/*

    - Don't have an account?{" "} - - {" "} - Register here - - . -

    */} + { loading && } ); } From f55c1c375e4da88fdb443b14f16433ff4d020dea Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:02:27 -0400 Subject: [PATCH 057/101] add success states to password reset flow --- frontend/src/pages/Login/ResetPassword.tsx | 112 ++++++++----- .../src/pages/Login/ResetPasswordConfirm.tsx | 147 ++++++++++-------- 2 files changed, 161 insertions(+), 98 deletions(-) diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index 61345aa8..34ffc44b 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -1,9 +1,11 @@ import { useFormik } from "formik"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { reset_password, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; import { useEffect, useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { @@ -14,6 +16,8 @@ function ResetPassword(props: ResetPasswordProps) { const { isAuthenticated } = props; const dispatch = useDispatch(); const [requestSent, setRequestSent] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); const navigate = useNavigate(); @@ -29,49 +33,86 @@ function ResetPassword(props: ResetPasswordProps) { }, onSubmit: (values) => { dispatch(reset_password(values.email)); + setSubmittedEmail(values.email); setRequestSent(true); }, }); + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.RESET_PASSWORD, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + if (requestSent) { - navigate("/"); - } - return ( - <> + return ( -
    -

    - Reset Password -

    -
    -
    - - -
    -
    -
    -
    +
    - + ); + } + + return ( + +
    +
    +

    + Reset password +

    +
    + + +
    + +
    + + Back to log in + +
    +
    +
    +
    ); } @@ -79,8 +120,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant const ConnectedResetPassword = connect(mapStateToProps)(ResetPassword); - -// Export the named constant export default ConnectedResetPassword; diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 533669bb..80f36a63 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, Link } from "react-router-dom"; import { reset_password_confirm, AppDispatch, @@ -17,7 +17,8 @@ const ResetPasswordConfirm: React.FC = ({ isAuthenticated, }) => { const dispatch = useDispatch(); - const [requestSent, setRequestSent] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); const { uid, token } = useParams<{ uid: string; token: string }>(); const navigate = useNavigate(); @@ -33,66 +34,94 @@ const ResetPasswordConfirm: React.FC = ({ new_password: "", re_new_password: "", }, - onSubmit: (values) => { - dispatch( - reset_password_confirm( - uid!, - token!, - values.new_password, - values.re_new_password - ) - ); - setRequestSent(true); + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch( + reset_password_confirm( + uid!, + token!, + values.new_password, + values.re_new_password + ) + ); + setSuccess(true); + } catch { + setError("This reset link is invalid or has expired. Please request a new one."); + } finally { + setSubmitting(false); + } }, }); - if (requestSent) { - navigate("/"); - } - return ( - <> + if (success) { + return ( -
    -

    - Reset Password -

    -
    -
    - - - -
    -
    - -
    -
    +
    +
    +

    + Password updated +

    +

    + Your password has been reset. You can now log in with your new password. +

    + + Log in now + +
    - + ); + } + + return ( + +
    +
    +

    + Set new password +

    + {error &&

    {error}

    } +
    + + +
    +
    + + +
    + +
    +
    +
    ); }; @@ -100,9 +129,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant -const ConnectedResetPasswordConfirm = - connect(mapStateToProps)(ResetPasswordConfirm); - -// Export the named constant +const ConnectedResetPasswordConfirm = connect(mapStateToProps)(ResetPasswordConfirm); export default ConnectedResetPasswordConfirm; From fca027a30c7eeafa83371c5e2833fe9c2a882e89 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:10:01 -0400 Subject: [PATCH 058/101] add "Log In" button to header for unauthenticated users --- frontend/src/components/Header/Header.tsx | 9 ++++++++- frontend/src/components/Header/MdNavBar.tsx | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c2fe3cfc..488920d8 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -207,7 +207,14 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer - {isAuthenticated && authLinks()} + {isAuthenticated ? authLinks() : ( + + Log In + + )} diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index ccd06fcd..550b74d2 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -127,15 +127,25 @@ const MdNavBar = (props: LoginFormProps) => { Support Development - {isAuthenticated && + {isAuthenticated ? (
  • Sign Out + to="/logout" + className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline" + > + Sign Out + +
  • + ) : ( +
  • + + Log In
  • - } + )} From 52c9efb748377d73d2d4de77d7c8b8250d938c29 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:43:34 -0400 Subject: [PATCH 059/101] add token refresh interceptor to adminApi On 401 responses, attempts a silent token refresh using the refresh token from localStorage. On success, retries the original request. On failure (expired or missing refresh token), clears tokens and redirects to /login. Uses a queue to handle concurrent requests during refresh. --- frontend/src/api/apiClient.ts | 65 +++++++++++++++++++++++++++++++++++ frontend/src/api/endpoints.ts | 1 + 2 files changed, 66 insertions(+) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 856f78a9..545ce5d4 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -4,6 +4,7 @@ import { Conversation } from "../components/Header/Chat"; import { V1_API_ENDPOINTS, CONVERSATION_ENDPOINTS, + AUTH_ENDPOINTS, endpoints, } from "./endpoints"; @@ -31,6 +32,70 @@ adminApi.interceptors.request.use( (error) => Promise.reject(error), ); +// Response interceptor to handle token refresh on 401 +let isRefreshing = false; +let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }[] = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +adminApi.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `JWT ${token}`; + return adminApi(originalRequest); + }).catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh"); + + if (!refreshToken) { + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const response = await axios.post(AUTH_ENDPOINTS.JWT_REFRESH, { refresh: refreshToken }); + const newAccessToken = response.data.access; + localStorage.setItem("access", newAccessToken); + processQueue(null, newAccessToken); + originalRequest.headers.Authorization = `JWT ${newAccessToken}`; + return adminApi(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + }, +); + const handleSubmitFeedback = async ( feedbackType: FormValues["feedbackType"], name: FormValues["name"], diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index edc044b0..8e43a239 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -22,6 +22,7 @@ export const AUTH_ENDPOINTS = { USERS_CREATE: `${API_BASE}/auth/users/`, USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, + JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`, } as const; /** From 88adfb1cac637a1f2913857918e64924f44c23ee Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 14 Apr 2026 14:33:16 -0400 Subject: [PATCH 060/101] Add issue template --- .github/ISSUE_TEMPLATE/issue.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/issue.md diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..70fca447 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,23 @@ +## Title + + +## Background + + +## Existing Behavior + + +## Acceptance Criteria +- [] + +## Approach + + +## References + + +## Risks and Rollback + + +## Screenshots / Recordings + \ No newline at end of file From 55acb2ead6cd75b407501b43742645379e93da97 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 14 Apr 2026 15:01:34 -0400 Subject: [PATCH 061/101] Update README --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d7d531a..34947225 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) +You can view the website in a sandbox here: [https://sandbox.balancerproject.org/](https://sandbox.balancerproject.org/) ## Contributing @@ -31,11 +32,9 @@ Get the code using git by either forking or cloning `CodeForPhilly/balancer-main ``` 2. (Optional) Add your API keys to `config/env/dev.env`: - `OpenAI API` - - `Anthropic API` Tools used for development: 1. `Docker`: Install Docker Desktop -2. `Postman`: Ask to get invited to the Balancer Postman team `balancer_dev` 3. `npm`: In the terminal run 1) 'cd frontend' 2) 'npm install' 3) 'cd ..' ### Running Balancer for development @@ -91,6 +90,14 @@ Most endpoints require JWT authentication. To test them in Swagger UI: 3. **Test endpoints**: All subsequent requests will include your token. Use **Try it out** on any protected endpoint. 4. **Token refresh**: Access tokens expire after 60 minutes. Use `POST /auth/jwt/refresh/` with your `refresh` token, or repeat step 1. +### Deployment + +1. Merging your PR into develop automatically triggers a GitHub Release +2. The release triggers a container build workflow that builds and pushes the Docker image +3. [Go to GitHub Packages](https://github.com/CodeForPhilly/balancer-main/pkgs/container/balancer-main%2Fapp) to find the new image tag +4. Update newTag in kustomization.yaml [in the cluster repo](https://github.com/CodeForPhilly/cfp-live-cluster/blob/main/balancer/kustomization.yaml) +5. Open a PR to [cfp-sandbox-cluster](https://github.com/CodeForPhilly/cfp-sandbox-cluster) (or [cfp-live-cluster](https://github.com/CodeForPhilly/cfp-live-cluster)) + ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: From cedc58ad2ba8008787ea1a902f63452febf284db Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Tue, 14 Apr 2026 15:04:38 -0400 Subject: [PATCH 062/101] Add sandbox link to README usage section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b0480844..fe765910 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) + You can view the website in a sandbox here: [https://sandbox.balancerproject.org/](https://sandbox.balancerproject.org/) ## Contributing From e46017b92cd33404b535d9a1f45610cecb05a070 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 17 Apr 2026 12:13:35 -0400 Subject: [PATCH 063/101] Initial commit --- server/api/views/assistant/assistant_services.py | 0 server/api/views/assistant/eval_assistant.py | 0 server/api/views/assistant/review.ipynb | 0 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 server/api/views/assistant/assistant_services.py create mode 100644 server/api/views/assistant/eval_assistant.py create mode 100644 server/api/views/assistant/review.ipynb diff --git a/server/api/views/assistant/assistant_services.py b/server/api/views/assistant/assistant_services.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/review.ipynb b/server/api/views/assistant/review.ipynb new file mode 100644 index 00000000..e69de29b From 4cbb38070b68f6fdb8625219ba4d36c7ff1b2d24 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:56:21 -0400 Subject: [PATCH 064/101] feat(gateway): implement self-service TLS via ListenerSet This change migrates the Gateway API configuration to use ListenerSets instead of attaching HTTPRoutes directly to the Gateway. This enables the self-service TLS model where the application manages its own listeners and certificates. --- .../balancer/base/gateway-listeners.yaml | 26 +++++++++++++++++++ deploy/manifests/balancer/base/httproute.yaml | 7 +++-- .../balancer/base/kustomization.yaml | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 deploy/manifests/balancer/base/gateway-listeners.yaml diff --git a/deploy/manifests/balancer/base/gateway-listeners.yaml b/deploy/manifests/balancer/base/gateway-listeners.yaml new file mode 100644 index 00000000..1572a1dd --- /dev/null +++ b/deploy/manifests/balancer/base/gateway-listeners.yaml @@ -0,0 +1,26 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: balancer-listeners + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + parentRef: + name: main-gateway + namespace: envoy-gateway-system + group: gateway.networking.k8s.io + kind: Gateway + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: HOSTNAME_PLACEHOLDER + - name: https + protocol: HTTPS + port: 443 + hostname: HOSTNAME_PLACEHOLDER + tls: + mode: Terminate + certificateRefs: + - name: balancer-tls + kind: Secret diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index f9ca0df7..1a16ce66 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -4,10 +4,9 @@ metadata: name: balancer spec: parentRefs: - - name: main-gateway - namespace: envoy-gateway-system - hostnames: - - HOSTNAME_PLACEHOLDER + - name: balancer-listeners + kind: ListenerSet + group: gateway.networking.k8s.io rules: - matches: - path: diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index ad95f5b1..13658e20 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -5,4 +5,5 @@ resources: - namespace.yaml - deployment.yaml - service.yaml + - gateway-listeners.yaml - httproute.yaml From cc3fbe9820810cb1e3710f13062b81c2cd3844ae Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:56:30 -0400 Subject: [PATCH 065/101] fix(gateway): update overlays to patch ListenerSet hostnames This updates the sandbox and production overlays to correctly patch the hostnames on both the ListenerSet and the HTTPRoute. --- .../overlays/production/kustomization.yaml | 16 +++++++++++++--- .../balancer/overlays/sandbox/kustomization.yaml | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 859020ae..1a7ce9fa 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -12,9 +12,19 @@ images: patches: - target: - kind: HTTPRoute - name: balancer + kind: ListenerSet + name: balancer-listeners patch: |- - op: replace - path: /spec/hostnames/0 + path: /spec/listeners/0/hostname + value: balancerproject.org + - op: replace + path: /spec/listeners/1/hostname value: balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: add + path: /spec/hostnames + value: ["balancerproject.org"] diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index c609dc8b..bdcf6ae8 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -12,9 +12,19 @@ images: patches: - target: - kind: HTTPRoute - name: balancer + kind: ListenerSet + name: balancer-listeners patch: |- - op: replace - path: /spec/hostnames/0 + path: /spec/listeners/0/hostname + value: sandbox.balancerproject.org + - op: replace + path: /spec/listeners/1/hostname value: sandbox.balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: add + path: /spec/hostnames + value: ["sandbox.balancerproject.org"] From b9c95913e656b99296d44af4735e3aba68413c48 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:59:05 -0400 Subject: [PATCH 066/101] feat(gateway): implement HTTP to HTTPS redirect via ListenerSet This adds a redirect HTTPRoute targeting the port 80 listener of the ListenerSet, ensuring all HTTP traffic is upgraded to HTTPS. The main HTTPRoute is now pinned to the port 443 listener. --- deploy/manifests/balancer/base/httproute.yaml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index 1a16ce66..c8fef0fe 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -1,5 +1,24 @@ apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute +metadata: + name: balancer-redirect +spec: + parentRefs: + - name: balancer-listeners + kind: ListenerSet + group: gateway.networking.k8s.io + sectionName: http + hostnames: + - HOSTNAME_PLACEHOLDER + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute metadata: name: balancer spec: @@ -7,6 +26,9 @@ spec: - name: balancer-listeners kind: ListenerSet group: gateway.networking.k8s.io + sectionName: https + hostnames: + - HOSTNAME_PLACEHOLDER rules: - matches: - path: From fb7b270c8fde8fcbf9c5dcf529f5b5ef04a26e4e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:59:11 -0400 Subject: [PATCH 067/101] fix(gateway): patch hostname for redirect HTTPRoute in overlays Updates the sandbox and production overlays to ensure the hostname is also patched on the new balancer-redirect HTTPRoute. --- .../balancer/overlays/production/kustomization.yaml | 7 +++++++ .../manifests/balancer/overlays/sandbox/kustomization.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 1a7ce9fa..1386e907 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -28,3 +28,10 @@ patches: - op: add path: /spec/hostnames value: ["balancerproject.org"] + - target: + kind: HTTPRoute + name: balancer-redirect + patch: |- + - op: add + path: /spec/hostnames + value: ["balancerproject.org"] diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index bdcf6ae8..c9e9b4ea 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -28,3 +28,10 @@ patches: - op: add path: /spec/hostnames value: ["sandbox.balancerproject.org"] + - target: + kind: HTTPRoute + name: balancer-redirect + patch: |- + - op: add + path: /spec/hostnames + value: ["sandbox.balancerproject.org"] From b1e4918e88c70fe393fe3ccc3a077b19a6f99b6a Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:09:32 -0400 Subject: [PATCH 068/101] Configure CORS Allowed Origins via ConfigMaps --- deploy/manifests/balancer/base/deployment.yaml | 2 ++ deploy/manifests/balancer/base/kustomization.yaml | 3 +++ .../manifests/balancer/overlays/production/configmap.yaml | 6 ++++++ .../balancer/overlays/production/kustomization.yaml | 8 ++++++++ deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 6 ++++++ .../balancer/overlays/sandbox/kustomization.yaml | 8 ++++++++ server/balancer_backend/settings.py | 5 ++++- 7 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/overlays/production/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/production/kustomization.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/base/deployment.yaml b/deploy/manifests/balancer/base/deployment.yaml index c50012c2..10bcfc93 100644 --- a/deploy/manifests/balancer/base/deployment.yaml +++ b/deploy/manifests/balancer/base/deployment.yaml @@ -21,6 +21,8 @@ spec: envFrom: - secretRef: name: balancer-config + - configMapRef: + name: balancer-config ports: - containerPort: 8000 readinessProbe: diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..38e12e47 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,6 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + +configMapGenerator: + - name: balancer-config diff --git a/deploy/manifests/balancer/overlays/production/configmap.yaml b/deploy/manifests/balancer/overlays/production/configmap.yaml new file mode 100644 index 00000000..47253b20 --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-config +data: + CORS_ALLOWED_ORIGINS: "https://balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml new file mode 100644 index 00000000..c0cf6eb0 --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: configmap.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml new file mode 100644 index 00000000..ad9528e9 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-config +data: + CORS_ALLOWED_ORIGINS: "https://sandbox.balancertestsite.com" diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..c0cf6eb0 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: configmap.yaml diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..c1424fc7 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,10 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +# CORS configuration +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") +# Ensure no empty strings if input was empty or trailing comma +CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] TEMPLATES = [ { From 00250efac2166452f383b433008e7dda5ca9c022 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:12:57 -0400 Subject: [PATCH 069/101] Update sandbox domain for CORS --- deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml index ad9528e9..ff7ec0a0 100644 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -3,4 +3,4 @@ kind: ConfigMap metadata: name: balancer-config data: - CORS_ALLOWED_ORIGINS: "https://sandbox.balancertestsite.com" + CORS_ALLOWED_ORIGINS: "https://sandbox.balancerproject.org" From 8262de577ab12215f72c712ea0207c5e01ea9dd1 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:13:28 -0400 Subject: [PATCH 070/101] Refine ConfigMap overlays using env files and correct sandbox domain --- deploy/manifests/balancer/base/balancer.env | 0 deploy/manifests/balancer/base/kustomization.yaml | 2 ++ deploy/manifests/balancer/overlays/production/balancer.env | 1 + .../manifests/balancer/overlays/production/configmap.yaml | 6 ------ .../balancer/overlays/production/kustomization.yaml | 7 +++++-- deploy/manifests/balancer/overlays/sandbox/balancer.env | 1 + deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 6 ------ .../manifests/balancer/overlays/sandbox/kustomization.yaml | 7 +++++-- 8 files changed, 14 insertions(+), 16 deletions(-) create mode 100644 deploy/manifests/balancer/base/balancer.env create mode 100644 deploy/manifests/balancer/overlays/production/balancer.env delete mode 100644 deploy/manifests/balancer/overlays/production/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/balancer.env delete mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml diff --git a/deploy/manifests/balancer/base/balancer.env b/deploy/manifests/balancer/base/balancer.env new file mode 100644 index 00000000..e69de29b diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 38e12e47..1bc2cc1b 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -9,3 +9,5 @@ resources: configMapGenerator: - name: balancer-config + envs: + - balancer.env diff --git a/deploy/manifests/balancer/overlays/production/balancer.env b/deploy/manifests/balancer/overlays/production/balancer.env new file mode 100644 index 00000000..1fa5802e --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://balancerproject.org diff --git a/deploy/manifests/balancer/overlays/production/configmap.yaml b/deploy/manifests/balancer/overlays/production/configmap.yaml deleted file mode 100644 index 47253b20..00000000 --- a/deploy/manifests/balancer/overlays/production/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: balancer-config -data: - CORS_ALLOWED_ORIGINS: "https://balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index c0cf6eb0..7ecd6659 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -4,5 +4,8 @@ kind: Kustomization resources: - ../../base -patches: - - path: configmap.yaml +configMapGenerator: + - name: balancer-config + behavior: merge + envs: + - balancer.env diff --git a/deploy/manifests/balancer/overlays/sandbox/balancer.env b/deploy/manifests/balancer/overlays/sandbox/balancer.env new file mode 100644 index 00000000..55d82ecc --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://sandbox.balancerproject.org diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml deleted file mode 100644 index ff7ec0a0..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: balancer-config -data: - CORS_ALLOWED_ORIGINS: "https://sandbox.balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index c0cf6eb0..7ecd6659 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -4,5 +4,8 @@ kind: Kustomization resources: - ../../base -patches: - - path: configmap.yaml +configMapGenerator: + - name: balancer-config + behavior: merge + envs: + - balancer.env From d97f0c9b56b6b68e975ee91df9e627a98730381b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:24:34 -0400 Subject: [PATCH 071/101] feat: add balancer database cluster manifest --- deploy/manifests/balancer/base/db.yaml | 19 +++++++++++++++++++ .../balancer/base/kustomization.yaml | 1 + 2 files changed, 20 insertions(+) create mode 100644 deploy/manifests/balancer/base/db.yaml diff --git a/deploy/manifests/balancer/base/db.yaml b/deploy/manifests/balancer/base/db.yaml new file mode 100644 index 00000000..c1b0226b --- /dev/null +++ b/deploy/manifests/balancer/base/db.yaml @@ -0,0 +1,19 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: balancer-db + namespace: balancer +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.1 + storage: + size: 10Gi + bootstrap: + initdb: + database: balancer + owner: balancer + secret: + name: balancer-db-credentials + postgresql: + parameters: + shared_preload_libraries: 'vector' diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..892e7891 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,4 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + - db.yaml From 92fd46dae4a1b7f04469cc272968921dc2f402ac Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:28:48 -0400 Subject: [PATCH 072/101] remove redundant cluster manifest (moved to infra repo) --- deploy/manifests/balancer/base/db.yaml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 deploy/manifests/balancer/base/db.yaml diff --git a/deploy/manifests/balancer/base/db.yaml b/deploy/manifests/balancer/base/db.yaml deleted file mode 100644 index c1b0226b..00000000 --- a/deploy/manifests/balancer/base/db.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: balancer-db - namespace: balancer -spec: - instances: 3 - imageName: ghcr.io/cloudnative-pg/postgresql:16.1 - storage: - size: 10Gi - bootstrap: - initdb: - database: balancer - owner: balancer - secret: - name: balancer-db-credentials - postgresql: - parameters: - shared_preload_libraries: 'vector' From cf324fb30d4253b89c8398daa2a87483e429bc89 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:28:57 -0400 Subject: [PATCH 073/101] remove db.yaml from kustomization resources --- deploy/manifests/balancer/base/kustomization.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 892e7891..c7d2dcd1 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,4 +6,3 @@ resources: - deployment.yaml - service.yaml - ingress.yaml - - db.yaml From c6f001db970fadf03f44a32a2d17a159988d5164 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:33:15 -0400 Subject: [PATCH 074/101] feat: add sandbox overlay with shared db connection details --- .../overlays/sandbox/kustomization.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..0490789b --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +patches: + - target: + kind: Deployment + name: balancer + patch: |- + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: SQL_HOST + value: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + - name: SQL_PORT + value: "5432" + - name: SQL_DATABASE + value: balancer + - name: SQL_USER + value: balancer + - name: SQL_ENGINE + value: django.db.backends.postgresql From 76af69fe82f243a709d72c6dcac64bd23b79bc3e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:08 -0400 Subject: [PATCH 075/101] feat: update sandbox overlay to use shared db password from secret --- .../manifests/balancer/overlays/sandbox/kustomization.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 0490789b..82ce3278 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -22,5 +22,10 @@ patches: value: balancer - name: SQL_USER value: balancer + - name: SQL_PASSWORD + valueFrom: + secretKeyRef: + name: balancer-db-credentials + key: password - name: SQL_ENGINE value: django.db.backends.postgresql From e48e52d1561cebade285af3a5bcff5bc6885f676 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:52 -0400 Subject: [PATCH 076/101] feat: add configmap for database settings in sandbox overlay --- .../manifests/balancer/overlays/sandbox/configmap.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml new file mode 100644 index 00000000..aa421eac --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-db-config +data: + SQL_HOST: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + SQL_PORT: "5432" + SQL_DATABASE: balancer + SQL_USER: balancer + SQL_ENGINE: django.db.backends.postgresql From d725fe0a9c3616991da9805938c5d1b6fd785f35 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:57 -0400 Subject: [PATCH 077/101] feat: update kustomization to use configmap and secret for db settings --- .../balancer/overlays/sandbox/kustomization.yaml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 82ce3278..4ccedd16 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -5,27 +5,23 @@ namespace: balancer resources: - ../../base + - configmap.yaml patches: - target: kind: Deployment name: balancer patch: |- + - op: add + path: /spec/template/spec/containers/0/envFrom/- + value: + configMapRef: + name: balancer-db-config - op: add path: /spec/template/spec/containers/0/env value: - - name: SQL_HOST - value: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local - - name: SQL_PORT - value: "5432" - - name: SQL_DATABASE - value: balancer - - name: SQL_USER - value: balancer - name: SQL_PASSWORD valueFrom: secretKeyRef: name: balancer-db-credentials key: password - - name: SQL_ENGINE - value: django.db.backends.postgresql From 2ad1024cc60b1497055ad47b7a32ac47a7469563 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 20 Apr 2026 13:46:27 -0400 Subject: [PATCH 078/101] Scaffold implementation --- .../api/views/assistant/assistant_prompts.py | 38 +++ .../api/views/assistant/assistant_services.py | 60 ++++ server/api/views/assistant/eval_assistant.py | 15 + server/api/views/assistant/review.ipynb | 1 + .../assistant/test_assistant_services.py | 0 .../views/assistant/test_eval_assistant.py | 0 .../api/views/assistant/test_tool_services.py | 0 server/api/views/assistant/test_views.py | 0 server/api/views/assistant/tool_services.py | 150 +++++++++ server/api/views/assistant/views.py | 310 ++---------------- 10 files changed, 292 insertions(+), 282 deletions(-) create mode 100644 server/api/views/assistant/assistant_prompts.py create mode 100644 server/api/views/assistant/test_assistant_services.py create mode 100644 server/api/views/assistant/test_eval_assistant.py create mode 100644 server/api/views/assistant/test_tool_services.py create mode 100644 server/api/views/assistant/test_views.py create mode 100644 server/api/views/assistant/tool_services.py diff --git a/server/api/views/assistant/assistant_prompts.py b/server/api/views/assistant/assistant_prompts.py new file mode 100644 index 00000000..44bf9b9b --- /dev/null +++ b/server/api/views/assistant/assistant_prompts.py @@ -0,0 +1,38 @@ +INSTRUCTIONS = """ +You are an AI assistant that helps users find and understand information about bipolar disorder +from your internal library of bipolar disorder research sources using semantic search. + +IMPORTANT CONTEXT: +- You have access to a library of sources that the user CANNOT see +- The user did not upload these sources and doesn't know about them +- You must explain what information exists in your sources and provide clear references + +TOPIC RESTRICTIONS: +When a prompt is received that is unrelated to bipolar disorder, mental health treatment, +or psychiatric medications, respond by saying you are limited to bipolar-specific conversations. + +SEMANTIC SEARCH STRATEGY: +- Always perform semantic search using the search_documents function when users ask questions +- Use conceptually related terms and synonyms, not just exact keyword matches +- Search for the meaning and context of the user's question, not just literal words +- Consider medical terminology, lay terms, and related conditions when searching + +FUNCTION USAGE: +- When a user asks about information that might be in your source library, ALWAYS use the search_documents function first +- Perform semantic searches using concepts, symptoms, treatments, and related terms from the user's question +- Only provide answers based on information found through your source searches + +RESPONSE FORMAT: +After gathering information through semantic searches, provide responses that: +1. Answer the user's question directly using only the found information +2. Structure responses with clear sections and paragraphs +3. Explain what information you found in your sources and provide context +4. Include citations using this exact format: [Name {name}, Page {page_number}] +5. Only cite information that directly supports your statements + +If no relevant information is found in your source library, clearly state that the information +is not available in your current sources. + +REMEMBER: You are working with an internal library of bipolar disorder sources that the user +cannot see. Always search these sources first, explain what you found, and provide proper citations. +""" \ No newline at end of file diff --git a/server/api/views/assistant/assistant_services.py b/server/api/views/assistant/assistant_services.py index e69de29b..65d1dce0 100644 --- a/server/api/views/assistant/assistant_services.py +++ b/server/api/views/assistant/assistant_services.py @@ -0,0 +1,60 @@ + +def run_assistant(): + client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + + tools = [ + { + "type": "function", + "name": "search_documents", + "description": TOOL_DESCRIPTION, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": TOOL_PROPERTY_DESCRIPTION, + } + }, + "required": ["query"], + }, + } + ] + + + MODEL_DEFAULTS = { + "instructions": INSTRUCTIONS, + "model": "gpt-5-nano", # 400,000 token context window + # A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. + "reasoning": {"effort": "low", "summary": None}, + "tools": tools, + } + + # We fetch a response and then kick off a loop to handle the response + + + + # TODO: Track total duration, cost metrics, and tool_calls_made count + # and return them from run_assistant for use in eval_assistant.py CSV output + + if not previous_response_id: + response = client.responses.create( + input=[ + {"type": "message", "role": "user", "content": str(message)} + ], + **MODEL_DEFAULTS, + ) + else: + response = client.responses.create( + input=[ + {"type": "message", "role": "user", "content": str(message)} + ], + previous_response_id=str(previous_response_id), + **MODEL_DEFAULTS, + ) + + + + final_response_output_text, final_response_id = handle_tool_calls_with_reasoning() + + + diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py index e69de29b..4b22f8ba 100644 --- a/server/api/views/assistant/eval_assistant.py +++ b/server/api/views/assistant/eval_assistant.py @@ -0,0 +1,15 @@ +# uv script (or plain Python) to generate results to CSV, run from the terminal + +import asyncio + +# Set of representative questions + + +# Read model and INSTRUCTIONS from the source file or add a lightweight config endpoint to the backend + + +async def main(): + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/server/api/views/assistant/review.ipynb b/server/api/views/assistant/review.ipynb index e69de29b..e6da83d0 100644 --- a/server/api/views/assistant/review.ipynb +++ b/server/api/views/assistant/review.ipynb @@ -0,0 +1 @@ +# notebook to review and compare the two CSVs \ No newline at end of file diff --git a/server/api/views/assistant/test_assistant_services.py b/server/api/views/assistant/test_assistant_services.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/test_eval_assistant.py b/server/api/views/assistant/test_eval_assistant.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/test_tool_services.py b/server/api/views/assistant/test_tool_services.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/test_views.py b/server/api/views/assistant/test_views.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/views/assistant/tool_services.py b/server/api/views/assistant/tool_services.py new file mode 100644 index 00000000..a90441a3 --- /dev/null +++ b/server/api/views/assistant/tool_services.py @@ -0,0 +1,150 @@ + +TOOL_DESCRIPTION = """ +Search the user's uploaded documents for information relevant to answering their question. +Call this function when you need to find specific information from the user's documents +to provide an accurate, citation-backed response. Always search before answering questions +about document content. +""" + +TOOL_PROPERTY_DESCRIPTION = """ +A specific search query to find relevant information in the user's documents. +Use keywords, phrases, or questions related to what the user is asking about. +Be specific rather than generic - use terms that would appear in the relevant documents. +""" + +def search_documents(query: str, user=user) -> str: + """ + Search through user's uploaded documents using semantic similarity. + + This function performs vector similarity search against the user's document corpus + and returns formatted results with context information for the LLM to use. + + Parameters + ---------- + query : str + The search query string + user : User + The authenticated user whose documents to search + + Returns + ------- + str + Formatted search results containing document excerpts with metadata + + Raises + ------ + Exception + If embedding search fails + """ + + try: + embeddings_results = get_closest_embeddings( + user=user, message_data=query.strip() + ) + embeddings_results = convert_uuids(embeddings_results) + + if not embeddings_results: + return "No relevant documents found for your query. Please try different search terms or upload documents first." + + # Format results with clear structure and metadata + prompt_texts = [ + f"[Document {i + 1} - File: {obj['file_id']}, Name: {obj['name']}, Page: {obj['page_number']}, Chunk: {obj['chunk_number']}, Similarity: {1 - obj['distance']:.3f}]\n{obj['text']}\n[End Document {i + 1}]" + for i, obj in enumerate(embeddings_results) + ] + + return "\n\n".join(prompt_texts) + + except Exception as e: + return f"Error searching documents: {str(e)}. Please try again if the issue persists." + +def handle_tool_calls_with_reasoning(): + # Open AI Cookbook: Handling Function Calls with Reasoning Models + # https://cookbook.openai.com/examples/reasoning_function_calls + while True: + # Mapping of the tool names we tell the model about and the functions that implement them + function_responses = invoke_functions_from_response( + response, tool_mapping={"search_documents": search_documents} + ) + if len(function_responses) == 0: # We're done reasoning + logger.info("Reasoning completed") + final_response_output_text = response.output_text + final_response_id = response.id + logger.info(f"Final response: {final_response_output_text}") + break + else: + logger.info("More reasoning required, continuing...") + response = client.responses.create( + input=function_responses, + previous_response_id=response.id, + **MODEL_DEFAULTS, + ) + # # Accumulate token usage from reasoning iterations + # if hasattr(response, "usage"): + # total_token_usage["input_tokens"] += getattr( + # response.usage, "input_tokens", 0 + # ) + # total_token_usage["output_tokens"] += getattr( + # response.usage, "output_tokens", 0 + # ) + + + + + + +# Open AI Cookbook: Handling Function Calls with Reasoning Models +# https://cookbook.openai.com/examples/reasoning_function_calls +def invoke_functions_from_response( + response, tool_mapping: dict[str, Callable] +) -> list[dict]: + """Extract all function calls from the response, look up the corresponding tool function(s) and execute them. + (This would be a good place to handle asynchroneous tool calls, or ones that take a while to execute.) + This returns a list of messages to be added to the conversation history. + + Parameters + ---------- + response : OpenAI Response + The response object from OpenAI containing output items that may include function calls + tool_mapping : dict[str, Callable] + A dictionary mapping function names (as strings) to their corresponding Python functions. + Keys should match the function names defined in the tools schema. + + Returns + ------- + list[dict] + List of function call output messages formatted for the OpenAI conversation. + Each message contains: + - type: "function_call_output" + - call_id: The unique identifier for the function call + - output: The result returned by the executed function (string or error message) + """ + intermediate_messages = [] + for response_item in response.output: + if response_item.type == "function_call": + target_tool = tool_mapping.get(response_item.name) + if target_tool: + try: + arguments = json.loads(response_item.arguments) + logger.info( + f"Invoking tool: {response_item.name} with arguments: {arguments}" + ) + tool_output = target_tool(**arguments) + logger.info(f"Tool {response_item.name} completed successfully") + except Exception as e: + msg = f"Error executing function call: {response_item.name}: {e}" + tool_output = msg + logger.error(msg, exc_info=True) + else: + msg = f"ERROR - No tool registered for function call: {response_item.name}" + tool_output = msg + logger.error(msg) + intermediate_messages.append( + { + "type": "function_call_output", + "call_id": response_item.call_id, + "output": tool_output, + } + ) + elif response_item.type == "reasoning": + logger.info(f"Reasoning step: {response_item.summary}") + return intermediate_messages \ No newline at end of file diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index e3e8d6f7..6240cd72 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -21,94 +21,37 @@ # Configure logging logger = logging.getLogger(__name__) -GPT_5_NANO_PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.05, "output": 0.40} +# TODO: OpenAI API Dashboard has total duration and cost metrics +# GPT_5_NANO_PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.05, "output": 0.40} -def calculate_cost_metrics(token_usage: dict, pricing: dict) -> dict: - """ - Calculate cost metrics based on token usage and pricing +# def calculate_cost_metrics(token_usage: dict, pricing: dict) -> dict: +# """ +# Calculate cost metrics based on token usage and pricing - Args: - token_usage: Dictionary containing input_tokens and output_tokens - pricing: Dictionary containing input and output pricing per million tokens +# Args: +# token_usage: Dictionary containing input_tokens and output_tokens +# pricing: Dictionary containing input and output pricing per million tokens - Returns: - Dictionary containing input_cost, output_cost, and total_cost in USD - """ - TOKENS_PER_MILLION = 1_000_000 +# Returns: +# Dictionary containing input_cost, output_cost, and total_cost in USD +# """ +# TOKENS_PER_MILLION = 1_000_000 - # Pricing is in dollars per million tokens - input_cost_dollars = (pricing["input"] / TOKENS_PER_MILLION) * token_usage.get( - "input_tokens", 0 - ) - output_cost_dollars = (pricing["output"] / TOKENS_PER_MILLION) * token_usage.get( - "output_tokens", 0 - ) - total_cost_dollars = input_cost_dollars + output_cost_dollars - - return { - "input_cost": input_cost_dollars, - "output_cost": output_cost_dollars, - "total_cost": total_cost_dollars, - } +# # Pricing is in dollars per million tokens +# input_cost_dollars = (pricing["input"] / TOKENS_PER_MILLION) * token_usage.get( +# "input_tokens", 0 +# ) +# output_cost_dollars = (pricing["output"] / TOKENS_PER_MILLION) * token_usage.get( +# "output_tokens", 0 +# ) +# total_cost_dollars = input_cost_dollars + output_cost_dollars - -# Open AI Cookbook: Handling Function Calls with Reasoning Models -# https://cookbook.openai.com/examples/reasoning_function_calls -def invoke_functions_from_response( - response, tool_mapping: dict[str, Callable] -) -> list[dict]: - """Extract all function calls from the response, look up the corresponding tool function(s) and execute them. - (This would be a good place to handle asynchroneous tool calls, or ones that take a while to execute.) - This returns a list of messages to be added to the conversation history. - - Parameters - ---------- - response : OpenAI Response - The response object from OpenAI containing output items that may include function calls - tool_mapping : dict[str, Callable] - A dictionary mapping function names (as strings) to their corresponding Python functions. - Keys should match the function names defined in the tools schema. - - Returns - ------- - list[dict] - List of function call output messages formatted for the OpenAI conversation. - Each message contains: - - type: "function_call_output" - - call_id: The unique identifier for the function call - - output: The result returned by the executed function (string or error message) - """ - intermediate_messages = [] - for response_item in response.output: - if response_item.type == "function_call": - target_tool = tool_mapping.get(response_item.name) - if target_tool: - try: - arguments = json.loads(response_item.arguments) - logger.info( - f"Invoking tool: {response_item.name} with arguments: {arguments}" - ) - tool_output = target_tool(**arguments) - logger.info(f"Tool {response_item.name} completed successfully") - except Exception as e: - msg = f"Error executing function call: {response_item.name}: {e}" - tool_output = msg - logger.error(msg, exc_info=True) - else: - msg = f"ERROR - No tool registered for function call: {response_item.name}" - tool_output = msg - logger.error(msg) - intermediate_messages.append( - { - "type": "function_call_output", - "call_id": response_item.call_id, - "output": tool_output, - } - ) - elif response_item.type == "reasoning": - logger.info(f"Reasoning step: {response_item.summary}") - return intermediate_messages +# return { +# "input_cost": input_cost_dollars, +# "output_cost": output_cost_dollars, +# "total_cost": total_cost_dollars, +# } @method_decorator(csrf_exempt, name="dispatch") @@ -133,209 +76,12 @@ class Assistant(APIView): def post(self, request): try: user = request.user - - client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - - TOOL_DESCRIPTION = """ - Search the user's uploaded documents for information relevant to answering their question. - Call this function when you need to find specific information from the user's documents - to provide an accurate, citation-backed response. Always search before answering questions - about document content. - """ - - TOOL_PROPERTY_DESCRIPTION = """ - A specific search query to find relevant information in the user's documents. - Use keywords, phrases, or questions related to what the user is asking about. - Be specific rather than generic - use terms that would appear in the relevant documents. - """ - - tools = [ - { - "type": "function", - "name": "search_documents", - "description": TOOL_DESCRIPTION, - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": TOOL_PROPERTY_DESCRIPTION, - } - }, - "required": ["query"], - }, - } - ] - - def search_documents(query: str, user=user) -> str: - """ - Search through user's uploaded documents using semantic similarity. - - This function performs vector similarity search against the user's document corpus - and returns formatted results with context information for the LLM to use. - - Parameters - ---------- - query : str - The search query string - user : User - The authenticated user whose documents to search - - Returns - ------- - str - Formatted search results containing document excerpts with metadata - - Raises - ------ - Exception - If embedding search fails - """ - - try: - embeddings_results = get_closest_embeddings( - user=user, message_data=query.strip() - ) - embeddings_results = convert_uuids(embeddings_results) - - if not embeddings_results: - return "No relevant documents found for your query. Please try different search terms or upload documents first." - - # Format results with clear structure and metadata - prompt_texts = [ - f"[Document {i + 1} - File: {obj['file_id']}, Name: {obj['name']}, Page: {obj['page_number']}, Chunk: {obj['chunk_number']}, Similarity: {1 - obj['distance']:.3f}]\n{obj['text']}\n[End Document {i + 1}]" - for i, obj in enumerate(embeddings_results) - ] - - return "\n\n".join(prompt_texts) - - except Exception as e: - return f"Error searching documents: {str(e)}. Please try again if the issue persists." - - INSTRUCTIONS = """ - You are an AI assistant that helps users find and understand information about bipolar disorder - from your internal library of bipolar disorder research sources using semantic search. - - IMPORTANT CONTEXT: - - You have access to a library of sources that the user CANNOT see - - The user did not upload these sources and doesn't know about them - - You must explain what information exists in your sources and provide clear references - - TOPIC RESTRICTIONS: - When a prompt is received that is unrelated to bipolar disorder, mental health treatment, - or psychiatric medications, respond by saying you are limited to bipolar-specific conversations. - - SEMANTIC SEARCH STRATEGY: - - Always perform semantic search using the search_documents function when users ask questions - - Use conceptually related terms and synonyms, not just exact keyword matches - - Search for the meaning and context of the user's question, not just literal words - - Consider medical terminology, lay terms, and related conditions when searching - - FUNCTION USAGE: - - When a user asks about information that might be in your source library, ALWAYS use the search_documents function first - - Perform semantic searches using concepts, symptoms, treatments, and related terms from the user's question - - Only provide answers based on information found through your source searches - - RESPONSE FORMAT: - After gathering information through semantic searches, provide responses that: - 1. Answer the user's question directly using only the found information - 2. Structure responses with clear sections and paragraphs - 3. Explain what information you found in your sources and provide context - 4. Include citations using this exact format: [Name {name}, Page {page_number}] - 5. Only cite information that directly supports your statements - - If no relevant information is found in your source library, clearly state that the information - is not available in your current sources. - - REMEMBER: You are working with an internal library of bipolar disorder sources that the user - cannot see. Always search these sources first, explain what you found, and provide proper citations. - """ - - MODEL_DEFAULTS = { - "instructions": INSTRUCTIONS, - "model": "gpt-5-nano", # 400,000 token context window - # A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. - "reasoning": {"effort": "low", "summary": None}, - "tools": tools, - } - - # We fetch a response and then kick off a loop to handle the response - + message = request.data.get("message", None) previous_response_id = request.data.get("previous_response_id", None) + + = run_assistant() - # Track total duration and cost metrics - start_time = time.time() - total_token_usage = {"input_tokens": 0, "output_tokens": 0} - - if not previous_response_id: - response = client.responses.create( - input=[ - {"type": "message", "role": "user", "content": str(message)} - ], - **MODEL_DEFAULTS, - ) - else: - response = client.responses.create( - input=[ - {"type": "message", "role": "user", "content": str(message)} - ], - previous_response_id=str(previous_response_id), - **MODEL_DEFAULTS, - ) - - # Accumulate token usage from initial response - if hasattr(response, "usage"): - total_token_usage["input_tokens"] += getattr( - response.usage, "input_tokens", 0 - ) - total_token_usage["output_tokens"] += getattr( - response.usage, "output_tokens", 0 - ) - - # Open AI Cookbook: Handling Function Calls with Reasoning Models - # https://cookbook.openai.com/examples/reasoning_function_calls - while True: - # Mapping of the tool names we tell the model about and the functions that implement them - function_responses = invoke_functions_from_response( - response, tool_mapping={"search_documents": search_documents} - ) - if len(function_responses) == 0: # We're done reasoning - logger.info("Reasoning completed") - final_response_output_text = response.output_text - final_response_id = response.id - logger.info(f"Final response: {final_response_output_text}") - break - else: - logger.info("More reasoning required, continuing...") - response = client.responses.create( - input=function_responses, - previous_response_id=response.id, - **MODEL_DEFAULTS, - ) - # Accumulate token usage from reasoning iterations - if hasattr(response, "usage"): - total_token_usage["input_tokens"] += getattr( - response.usage, "input_tokens", 0 - ) - total_token_usage["output_tokens"] += getattr( - response.usage, "output_tokens", 0 - ) - - # Calculate total duration and cost metrics - total_duration = time.time() - start_time - cost_metrics = calculate_cost_metrics( - total_token_usage, GPT_5_NANO_PRICING_DOLLARS_PER_MILLION_TOKENS - ) - - # Log cost and duration metrics - logger.info( - f"Request completed: " - f"Duration: {total_duration:.2f}s, " - f"Input tokens: {total_token_usage['input_tokens']}, " - f"Output tokens: {total_token_usage['output_tokens']}, " - f"Total cost: ${cost_metrics['total_cost']:.6f}" - ) return Response( { From 2a9434a39cb98639e1eef0081e9d9f5fd8356139 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 23 Apr 2026 14:27:02 -0400 Subject: [PATCH 079/101] Extract logic so it can be called without going through HTTP endpoint --- .../api/views/assistant/assistant_services.py | 76 ++++++---- server/api/views/assistant/tool_services.py | 140 +++++++++++++----- server/api/views/assistant/views.py | 49 +----- 3 files changed, 152 insertions(+), 113 deletions(-) diff --git a/server/api/views/assistant/assistant_services.py b/server/api/views/assistant/assistant_services.py index 65d1dce0..ac339b9f 100644 --- a/server/api/views/assistant/assistant_services.py +++ b/server/api/views/assistant/assistant_services.py @@ -1,40 +1,57 @@ +import os +import logging -def run_assistant(): - client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) +from openai import OpenAI + +from .assistant_prompts import INSTRUCTIONS +from .tool_services import ( + SEARCH_TOOLS_SCHEMA, + make_search_tool_mapping, + handle_tool_calls_with_reasoning, +) + +logger = logging.getLogger(__name__) + + +def run_assistant( + message: str, + user, + previous_response_id: str | None = None, +) -> tuple[str, str]: + """Wire together the OpenAI client, retrieval, and the agentic reasoning loop. - tools = [ - { - "type": "function", - "name": "search_documents", - "description": TOOL_DESCRIPTION, - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": TOOL_PROPERTY_DESCRIPTION, - } - }, - "required": ["query"], - }, - } - ] + Parameters + ---------- + message : str + The user's input message. + user : User + The Django user object used for document access control in search_documents. + previous_response_id : str | None + ID of a prior response for multi-turn conversation continuity. + Returns + ------- + tuple[str, str] + (final_response_output_text, final_response_id) + """ + # TODO: Track total duration, cost metrics, and tool_calls_made count + # and return them from run_assistant for use in eval_assistant.py CSV output + + client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) MODEL_DEFAULTS = { "instructions": INSTRUCTIONS, "model": "gpt-5-nano", # 400,000 token context window # A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. "reasoning": {"effort": "low", "summary": None}, - "tools": tools, + "tools": SEARCH_TOOLS_SCHEMA, } - # We fetch a response and then kick off a loop to handle the response - - - - # TODO: Track total duration, cost metrics, and tool_calls_made count - # and return them from run_assistant for use in eval_assistant.py CSV output + # TOOLS_SCHEMA tells the model what tools exist and what arguments to generate. + # tool_mapping wires those tool names to the Python functions that execute them. + # They are separate because the model generates arguments (schema concern) but + # cannot supply request-time values like user (mapping concern). + tool_mapping = make_search_tool_mapping(user) if not previous_response_id: response = client.responses.create( @@ -52,9 +69,4 @@ def run_assistant(): **MODEL_DEFAULTS, ) - - - final_response_output_text, final_response_id = handle_tool_calls_with_reasoning() - - - + return handle_tool_calls_with_reasoning(response, client, MODEL_DEFAULTS, tool_mapping) diff --git a/server/api/views/assistant/tool_services.py b/server/api/views/assistant/tool_services.py index a90441a3..0fb96cef 100644 --- a/server/api/views/assistant/tool_services.py +++ b/server/api/views/assistant/tool_services.py @@ -1,3 +1,11 @@ +import json +import logging +from typing import Callable + +from ...services.embedding_services import get_closest_embeddings +from ...services.conversions_services import convert_uuids + +logger = logging.getLogger(__name__) TOOL_DESCRIPTION = """ Search the user's uploaded documents for information relevant to answering their question. @@ -12,7 +20,55 @@ Be specific rather than generic - use terms that would appear in the relevant documents. """ -def search_documents(query: str, user=user) -> str: +# SEARCH_TOOLS_SCHEMA defines the search_documents tool for the OpenAI API. +# The model reads this schema to know what tools are available and what +# arguments to generate — it can only generate arguments declared here. +SEARCH_TOOLS_SCHEMA = [ + { + "type": "function", + "name": "search_documents", + "description": TOOL_DESCRIPTION, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": TOOL_PROPERTY_DESCRIPTION, + } + }, + "required": ["query"], + }, + } +] + + +# TODO: Add get_tools_schema() and make_tool_mapping(user) aggregation functions +# that combine all tool schemas and mappings so assistant_services.py never needs +# to change when a new tool is added — only tool_services.py does. + +def make_search_tool_mapping(user) -> dict[str, Callable]: + # make_search_tool_mapping binds user to search_documents at call time. + # user is a request-time value the model cannot generate, so it must be + # captured here and kept out of the schema. + """Return a tool mapping with search_documents bound to the given user. + + Parameters + ---------- + user : User + The Django user object used for document access control. + + Returns + ------- + dict[str, Callable] + Tool mapping ready to pass to invoke_functions_from_response. + """ + def bound_search(query: str) -> str: + return search_documents(query, user) + + return {"search_documents": bound_search} + + +def search_documents(query: str, user) -> str: """ Search through user's uploaded documents using semantic similarity. @@ -57,43 +113,7 @@ def search_documents(query: str, user=user) -> str: except Exception as e: return f"Error searching documents: {str(e)}. Please try again if the issue persists." -def handle_tool_calls_with_reasoning(): - # Open AI Cookbook: Handling Function Calls with Reasoning Models - # https://cookbook.openai.com/examples/reasoning_function_calls - while True: - # Mapping of the tool names we tell the model about and the functions that implement them - function_responses = invoke_functions_from_response( - response, tool_mapping={"search_documents": search_documents} - ) - if len(function_responses) == 0: # We're done reasoning - logger.info("Reasoning completed") - final_response_output_text = response.output_text - final_response_id = response.id - logger.info(f"Final response: {final_response_output_text}") - break - else: - logger.info("More reasoning required, continuing...") - response = client.responses.create( - input=function_responses, - previous_response_id=response.id, - **MODEL_DEFAULTS, - ) - # # Accumulate token usage from reasoning iterations - # if hasattr(response, "usage"): - # total_token_usage["input_tokens"] += getattr( - # response.usage, "input_tokens", 0 - # ) - # total_token_usage["output_tokens"] += getattr( - # response.usage, "output_tokens", 0 - # ) - - - - - -# Open AI Cookbook: Handling Function Calls with Reasoning Models -# https://cookbook.openai.com/examples/reasoning_function_calls def invoke_functions_from_response( response, tool_mapping: dict[str, Callable] ) -> list[dict]: @@ -118,6 +138,10 @@ def invoke_functions_from_response( - call_id: The unique identifier for the function call - output: The result returned by the executed function (string or error message) """ + + # Open AI Cookbook: Handling Function Calls with Reasoning Models + # https://cookbook.openai.com/examples/reasoning_function_calls + intermediate_messages = [] for response_item in response.output: if response_item.type == "function_call": @@ -147,4 +171,44 @@ def invoke_functions_from_response( ) elif response_item.type == "reasoning": logger.info(f"Reasoning step: {response_item.summary}") - return intermediate_messages \ No newline at end of file + return intermediate_messages + +def handle_tool_calls_with_reasoning( + response, client, model_defaults: dict, tool_mapping: dict[str, Callable] +) -> tuple[str, str]: + """Run the agentic loop until the model stops emitting function calls. + + Parameters + ---------- + response : OpenAI Response + The initial response from the model. + client : OpenAI + The OpenAI client instance. + model_defaults : dict + Keyword arguments forwarded to every client.responses.create call. + tool_mapping : dict[str, Callable] + Maps function names to their implementations. + + Returns + ------- + tuple[str, str] + (final_response_output_text, final_response_id) + """ + # Open AI Cookbook: Handling Function Calls with Reasoning Models + # https://cookbook.openai.com/examples/reasoning_function_calls + while True: + # Mapping of the tool names we tell the model about and the functions that implement them + function_responses = invoke_functions_from_response(response, tool_mapping) + if len(function_responses) == 0: # We're done reasoning + logger.info("Reasoning completed") + final_response_output_text = response.output_text + final_response_id = response.id + logger.info(f"Final response: {final_response_output_text}") + return final_response_output_text, final_response_id + else: + logger.info("More reasoning required, continuing...") + response = client.responses.create( + input=function_responses, + previous_response_id=response.id, + **model_defaults, + ) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 6240cd72..d7b10a4b 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -1,8 +1,4 @@ -import os -import json import logging -import time -from typing import Callable from rest_framework.views import APIView from rest_framework.response import Response @@ -13,47 +9,11 @@ from drf_spectacular.utils import extend_schema, inline_serializer from rest_framework import serializers as drf_serializers -from openai import OpenAI +from .assistant_services import run_assistant -from ...services.embedding_services import get_closest_embeddings -from ...services.conversions_services import convert_uuids - -# Configure logging logger = logging.getLogger(__name__) -# TODO: OpenAI API Dashboard has total duration and cost metrics -# GPT_5_NANO_PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.05, "output": 0.40} - -# def calculate_cost_metrics(token_usage: dict, pricing: dict) -> dict: -# """ -# Calculate cost metrics based on token usage and pricing - -# Args: -# token_usage: Dictionary containing input_tokens and output_tokens -# pricing: Dictionary containing input and output pricing per million tokens - -# Returns: -# Dictionary containing input_cost, output_cost, and total_cost in USD -# """ -# TOKENS_PER_MILLION = 1_000_000 - -# # Pricing is in dollars per million tokens -# input_cost_dollars = (pricing["input"] / TOKENS_PER_MILLION) * token_usage.get( -# "input_tokens", 0 -# ) -# output_cost_dollars = (pricing["output"] / TOKENS_PER_MILLION) * token_usage.get( -# "output_tokens", 0 -# ) -# total_cost_dollars = input_cost_dollars + output_cost_dollars - -# return { -# "input_cost": input_cost_dollars, -# "output_cost": output_cost_dollars, -# "total_cost": total_cost_dollars, -# } - - @method_decorator(csrf_exempt, name="dispatch") class Assistant(APIView): permission_classes = [AllowAny] @@ -80,8 +40,11 @@ def post(self, request): message = request.data.get("message", None) previous_response_id = request.data.get("previous_response_id", None) - = run_assistant() - + final_response_output_text, final_response_id = run_assistant( + message=message, + user=user, + previous_response_id=previous_response_id, + ) return Response( { From 31e22ffc96f9464f83c9e88007d868511ed435eb Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 24 Apr 2026 14:55:47 -0400 Subject: [PATCH 080/101] Scaffold eval_assistant.py to run_assistant concurrently via ThreadPoolExecutor inside the container --- server/api/views/assistant/eval_assistant.py | 136 ++++++++++++++++++- 1 file changed, 131 insertions(+), 5 deletions(-) diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py index 4b22f8ba..e0d3c969 100644 --- a/server/api/views/assistant/eval_assistant.py +++ b/server/api/views/assistant/eval_assistant.py @@ -1,15 +1,141 @@ -# uv script (or plain Python) to generate results to CSV, run from the terminal +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = "==3.11.11" +# dependencies = [ +# "pandas==2.2.3", +# "openai", +# "django", +# ] +# /// -import asyncio +# uv script (or plain Python) to generate results to CSV, run from the terminal +# Run from inside the container: docker compose exec backend python eval_assistant.py +# -# Set of representative questions +import os +import sys +import logging +import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed + +# Django setup must come before any imports that touch the ORM +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../"))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "balancer_backend.settings") + +import django +django.setup() + +import pandas as pd +from django.contrib.auth import get_user_model + +from api.views.assistant.assistant_services import run_assistant +from api.views.assistant.assistant_prompts import INSTRUCTIONS + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) # Read model and INSTRUCTIONS from the source file or add a lightweight config endpoint to the backend +# Read model and INSTRUCTIONS from the source file +# INSTRUCTIONS is imported from assistant_prompts.py +# MODEL is read from assistant_services.py MODEL_DEFAULTS +MODEL = "gpt-5-nano" + +# Set of representative questions to evaluate the assistant +QUESTIONS = [ + "What medications are recommended for bipolar depression?", + "What are the risks of lithium for patients with kidney disease?", + "Which mood stabilizers are safe during pregnancy?", + "What is the evidence for quetiapine in bipolar disorder?", + "How does valproate compare to lithium for mania?", +] + + +def run_one(question: str, user, branch: str) -> dict: + """Run the assistant for a single question and return a result row. + + Uses ThreadPoolExecutor (not asyncio.gather + await run_assistant) for concurrency. + + Concurrency approach comparison: + - ThreadPoolExecutor (this implementation): + - run_assistant stays sync — views.py and the WSGI web app are unaffected + - Each question runs in a thread pool worker, blocking on OpenAI + DB I/O + - Django DB safe when run via `docker compose exec backend python eval_assistant.py`: + this is a synchronous Django process context. Each ThreadPoolExecutor worker + is a real OS thread with its own threading.local() storage, so each thread + gets its own DB connection created lazily on first use. There is no shared + event loop thread, so connections cannot clash or bleed between questions. + The connection isolation concern only arises in ASGI contexts where multiple + coroutines share one thread and therefore one threading.local() connection — + which is not the case here. + - Runtime: bottlenecked by OpenAI rate limits, not thread overhead + - asyncio.gather + await run_assistant (alternative): + - run_assistant becomes async — requires async def post in views.py, + AsyncOpenAI client, and async handle_tool_calls_with_reasoning + - Django DB unsafe if get_closest_embeddings is called directly in an async + context without wrapping: get_closest_embeddings is a sync function that + hits the ORM, so calling it on the event loop thread blocks all other + coroutines until the DB responds. The fix is sync_to_async(get_closest_embeddings), + which runs it in a dedicated worker thread with its own threading.local() + connection. Bare await does not work at all — Django ORM querysets are not + awaitables and raise TypeError immediately. + - Under WSGI (manage.py runserver), async views run in a new event loop + per request — adds overhead to every web request for no benefit + - Cleaner call site in eval_assistant.py but wrong trade-off given WSGI + """ + try: + response_text, response_id = run_assistant(message=question, user=user) + return { + "branch": branch, + "model": MODEL, + "question": question, + "response_output_text": response_text, + "error": None, + } + except Exception as e: + logger.error(f"Error evaluating question '{question}': {e}") + return { + "branch": branch, + "model": MODEL, + "question": question, + "response_output_text": None, + "error": str(e), + } + + +def main(): + branch = os.environ.get("EVAL_BRANCH", "develop") + + User = get_user_model() + user = User.objects.filter(is_superuser=True).first() + if not user: + raise RuntimeError("No superuser found. Create one with manage.py createsuperuser.") + + logger.info(f"Starting evaluation: branch={branch}, model={MODEL}, questions={len(QUESTIONS)}") + + # ThreadPoolExecutor runs questions concurrently — see run_one docstring + # for trade-off discussion vs asyncio.gather + await run_assistant. + # max_workers=5 stays safely under OpenAI rate limits for gpt-5-nano. + results = [] + with ThreadPoolExecutor(max_workers=5) as pool: + futures = { + pool.submit(run_one, question, user, branch): question + for question in QUESTIONS + } + for future in as_completed(futures): + results.append(future.result()) + + df = pd.DataFrame(results) + + results_dir = os.path.join(os.path.dirname(__file__), "results") + os.makedirs(results_dir, exist_ok=True) + timestamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%S") + output_path = os.path.join(results_dir, f"{branch}-{timestamp}.csv") + df.to_csv(output_path, index=False) -async def main(): + logger.info(f"Results saved to {output_path}") if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + main() From 64da31c02d3fe7ce88483d9af7db5851ab1262ba Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 1 May 2026 14:29:13 -0400 Subject: [PATCH 081/101] Add modifications to assistant tests --- .../assistant/test_assistant_services.py | 129 +++++++++++ .../api/views/assistant/test_tool_services.py | 210 ++++++++++++++++++ server/api/views/assistant/test_views.py | 66 ++++++ 3 files changed, 405 insertions(+) diff --git a/server/api/views/assistant/test_assistant_services.py b/server/api/views/assistant/test_assistant_services.py index e69de29b..3cb70534 100644 --- a/server/api/views/assistant/test_assistant_services.py +++ b/server/api/views/assistant/test_assistant_services.py @@ -0,0 +1,129 @@ +from unittest.mock import MagicMock, patch + + +def _make_terminal_response(output_text="Final answer.", response_id="resp-1"): + response = MagicMock() + response.output = [] + response.output_text = output_text + response.id = response_id + return response + + +# --------------------------------------------------------------------------- +# run_assistant tests +# +# run_assistant is responsible for wiring together the OpenAI client, +# make_search_tool_mapping (which binds user to search_documents), and +# handle_tool_calls_with_reasoning. +# +# We patch the OpenAI client and handle_tool_calls_with_reasoning to test +# that run_assistant correctly assembles and forwards its arguments. +# --------------------------------------------------------------------------- + +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_returns_text_and_id(mock_openai_cls, mock_handle): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("Final answer.", "resp-1") + + from api.views.assistant.assistant_services import run_assistant + + user = MagicMock() + text, resp_id = run_assistant(message="What is lithium?", user=user) + + assert text == "Final answer." + assert resp_id == "resp-1" + + +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_sends_message_as_user_input(mock_openai_cls, mock_handle): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("answer", "resp-1") + + from api.views.assistant.assistant_services import run_assistant + + run_assistant(message="Tell me about valproate.", user=MagicMock()) + + call_kwargs = mock_client.responses.create.call_args + input_messages = call_kwargs.kwargs.get("input") or call_kwargs.args[0] + assert any( + item.get("role") == "user" and "valproate" in item.get("content", "") + for item in input_messages + ) + + +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_passes_previous_response_id(mock_openai_cls, mock_handle): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("answer", "resp-2") + + from api.views.assistant.assistant_services import run_assistant + + run_assistant(message="More info.", user=MagicMock(), previous_response_id="resp-1") + + call_kwargs = mock_client.responses.create.call_args.kwargs + assert call_kwargs.get("previous_response_id") == "resp-1" + + +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_omits_previous_response_id_when_none(mock_openai_cls, mock_handle): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("answer", "resp-1") + + from api.views.assistant.assistant_services import run_assistant + + run_assistant(message="First message.", user=MagicMock(), previous_response_id=None) + + call_kwargs = mock_client.responses.create.call_args.kwargs + assert "previous_response_id" not in call_kwargs + + +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_passes_search_tools_schema_to_model(mock_openai_cls, mock_handle): + from api.views.assistant.assistant_services import run_assistant + from api.views.assistant.tool_services import SEARCH_TOOLS_SCHEMA + + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("answer", "resp-1") + + run_assistant(message="query", user=MagicMock()) + + call_kwargs = mock_client.responses.create.call_args.kwargs + assert call_kwargs.get("tools") == SEARCH_TOOLS_SCHEMA + + +@patch("api.views.assistant.tool_services.search_documents") +@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") +@patch("api.views.assistant.assistant_services.OpenAI") +def test_run_assistant_binds_user_to_search_documents(mock_openai_cls, mock_handle, mock_search): + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.responses.create.return_value = _make_terminal_response() + mock_handle.return_value = ("answer", "resp-1") + + from api.views.assistant.assistant_services import run_assistant + + user = MagicMock() + run_assistant(message="query", user=user) + + # Extract the tool_mapping passed to handle_tool_calls_with_reasoning + tool_mapping = mock_handle.call_args.kwargs.get("tool_mapping") or mock_handle.call_args.args[3] + bound_search = tool_mapping["search_documents"] + + # Calling the bound function should forward user to search_documents + bound_search(query="test query") + mock_search.assert_called_once_with("test query", user) diff --git a/server/api/views/assistant/test_tool_services.py b/server/api/views/assistant/test_tool_services.py index e69de29b..ef78a383 100644 --- a/server/api/views/assistant/test_tool_services.py +++ b/server/api/views/assistant/test_tool_services.py @@ -0,0 +1,210 @@ +import json +from unittest.mock import MagicMock, patch + +from api.views.assistant.tool_services import ( + invoke_functions_from_response, + handle_tool_calls_with_reasoning, + make_search_tool_mapping, +) + + +# --------------------------------------------------------------------------- +# make_search_tool_mapping tests +# +# make_search_tool_mapping is responsible for binding user to search_documents +# so the tool dispatcher can call it with only the query argument the model +# generates. We test the shape of the returned mapping and that user is +# forwarded correctly to search_documents. +# --------------------------------------------------------------------------- + +@patch("api.views.assistant.tool_services.search_documents") +def test_make_search_tool_mapping_returns_search_documents_key(mock_search): + user = MagicMock() + mapping = make_search_tool_mapping(user) + assert "search_documents" in mapping + + +@patch("api.views.assistant.tool_services.search_documents") +def test_make_search_tool_mapping_bound_fn_forwards_user(mock_search): + mock_search.return_value = "results" + user = MagicMock() + mapping = make_search_tool_mapping(user) + + mapping["search_documents"](query="lithium") + + mock_search.assert_called_once_with("lithium", user) + + +@patch("api.views.assistant.tool_services.search_documents") +def test_make_search_tool_mapping_different_users_are_independent(mock_search): + # Each call to make_search_tool_mapping should capture its own user, + # so two mappings created with different users do not share state. + user_a = MagicMock() + user_b = MagicMock() + mapping_a = make_search_tool_mapping(user_a) + mapping_b = make_search_tool_mapping(user_b) + + mapping_a["search_documents"](query="q") + mapping_b["search_documents"](query="q") + + calls = mock_search.call_args_list + assert calls[0] == ((("q", user_a),), {}) + assert calls[1] == ((("q", user_b),), {}) + + +# --------------------------------------------------------------------------- +# invoke_functions_from_response tests +# --------------------------------------------------------------------------- + +def _make_function_call_item(name, arguments, call_id): + item = MagicMock() + item.type = "function_call" + item.name = name + item.arguments = json.dumps(arguments) + item.call_id = call_id + return item + + +def _make_reasoning_item(summary="reasoning summary"): + item = MagicMock() + item.type = "reasoning" + item.summary = summary + return item + + +def _make_response(output_items): + response = MagicMock() + response.output = output_items + return response + + +def test_invoke_returns_empty_list_when_no_function_calls(): + response = _make_response([_make_reasoning_item()]) + result = invoke_functions_from_response(response, tool_mapping={}) + assert result == [] + + +def test_invoke_calls_tool_and_returns_output(): + mock_tool = MagicMock(return_value="search result") + item = _make_function_call_item("search_documents", {"query": "lithium"}, "call-1") + response = _make_response([item]) + + result = invoke_functions_from_response( + response, tool_mapping={"search_documents": mock_tool} + ) + + mock_tool.assert_called_once_with(query="lithium") + assert result == [ + {"type": "function_call_output", "call_id": "call-1", "output": "search result"} + ] + + +def test_invoke_returns_error_message_when_tool_not_registered(): + item = _make_function_call_item("unknown_tool", {"query": "x"}, "call-2") + response = _make_response([item]) + + result = invoke_functions_from_response(response, tool_mapping={}) + + assert result[0]["call_id"] == "call-2" + assert "ERROR" in result[0]["output"] + + +def test_invoke_returns_error_message_when_tool_raises(): + mock_tool = MagicMock(side_effect=Exception("tool exploded")) + item = _make_function_call_item("search_documents", {"query": "x"}, "call-3") + response = _make_response([item]) + + result = invoke_functions_from_response( + response, tool_mapping={"search_documents": mock_tool} + ) + + assert "Error executing function call" in result[0]["output"] + + +def test_invoke_handles_multiple_function_calls(): + mock_tool = MagicMock(return_value="result") + items = [ + _make_function_call_item("search_documents", {"query": "q1"}, "call-4"), + _make_function_call_item("search_documents", {"query": "q2"}, "call-5"), + ] + response = _make_response(items) + + result = invoke_functions_from_response( + response, tool_mapping={"search_documents": mock_tool} + ) + + assert len(result) == 2 + assert mock_tool.call_count == 2 + + +# --------------------------------------------------------------------------- +# handle_tool_calls_with_reasoning tests +# --------------------------------------------------------------------------- + +def _make_terminal_response(output_text, response_id): + """A response with no function calls — terminates the loop.""" + response = MagicMock() + response.output = [] + response.output_text = output_text + response.id = response_id + return response + + +def _make_tool_call_response(response_id, query="lithium"): + """A response with one function call — continues the loop.""" + response = MagicMock() + response.output = [_make_function_call_item("search_documents", {"query": query}, "call-loop")] + response.id = response_id + return response + + +def test_handle_terminates_immediately_when_no_tool_calls(): + response = _make_terminal_response("Final answer.", "resp-1") + client = MagicMock() + + text, resp_id = handle_tool_calls_with_reasoning( + response, client, model_defaults={}, tool_mapping={} + ) + + assert text == "Final answer." + assert resp_id == "resp-1" + client.responses.create.assert_not_called() + + +def test_handle_calls_tool_then_terminates(): + mock_search = MagicMock(return_value="doc content") + first_response = _make_tool_call_response("resp-1") + second_response = _make_terminal_response("Final answer.", "resp-2") + + client = MagicMock() + client.responses.create.return_value = second_response + + text, resp_id = handle_tool_calls_with_reasoning( + first_response, + client, + model_defaults={}, + tool_mapping={"search_documents": mock_search}, + ) + + mock_search.assert_called_once_with(query="lithium") + assert text == "Final answer." + assert resp_id == "resp-2" + + +def test_handle_passes_previous_response_id_on_followup(): + mock_search = MagicMock(return_value="doc content") + first_response = _make_tool_call_response("resp-1") + second_response = _make_terminal_response("Done.", "resp-2") + + client = MagicMock() + client.responses.create.return_value = second_response + + handle_tool_calls_with_reasoning( + first_response, + client, + model_defaults={}, + tool_mapping={"search_documents": mock_search}, + ) + + call_kwargs = client.responses.create.call_args.kwargs + assert call_kwargs["previous_response_id"] == "resp-1" diff --git a/server/api/views/assistant/test_views.py b/server/api/views/assistant/test_views.py index e69de29b..a9511b42 100644 --- a/server/api/views/assistant/test_views.py +++ b/server/api/views/assistant/test_views.py @@ -0,0 +1,66 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class AssistantViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.url = "/v1/api/assistant/" + + @patch("api.views.assistant.views.run_assistant") + def test_returns_200_with_response_fields(self, mock_run_assistant): + mock_run_assistant.return_value = ("Lithium is recommended.", "resp-abc-123") + + response = self.client.post( + self.url, + {"message": "What medications help with bipolar depression?"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["response_output_text"], "Lithium is recommended.") + self.assertEqual(response.data["final_response_id"], "resp-abc-123") + + @patch("api.views.assistant.views.run_assistant") + def test_passes_message_and_user_to_run_assistant(self, mock_run_assistant): + mock_run_assistant.return_value = ("Some response.", "resp-xyz") + + self.client.post( + self.url, + {"message": "Tell me about lithium."}, + format="json", + ) + + call_kwargs = mock_run_assistant.call_args.kwargs + self.assertEqual(call_kwargs["message"], "Tell me about lithium.") + self.assertIn("user", call_kwargs) + self.assertIsNone(call_kwargs["previous_response_id"]) + + @patch("api.views.assistant.views.run_assistant") + def test_passes_previous_response_id_when_provided(self, mock_run_assistant): + mock_run_assistant.return_value = ("Follow-up response.", "resp-456") + + self.client.post( + self.url, + {"message": "Tell me more.", "previous_response_id": "resp-123"}, + format="json", + ) + + call_kwargs = mock_run_assistant.call_args.kwargs + self.assertEqual(call_kwargs["previous_response_id"], "resp-123") + + @patch("api.views.assistant.views.run_assistant", side_effect=Exception("OpenAI error")) + def test_returns_500_on_exception(self, mock_run_assistant): + response = self.client.post( + self.url, + {"message": "What is lithium?"}, + format="json", + ) + + self.assertEqual(response.status_code, 500) + self.assertIn("error", response.data) From 99523c934037bf765c9297057bee1bfe0e406410 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 1 May 2026 14:32:30 -0400 Subject: [PATCH 082/101] feat: move database manifest to base and update SQL_HOST for shared-cluster --- deploy/manifests/balancer/base/database.yaml | 10 ++++++++++ deploy/manifests/balancer/base/kustomization.yaml | 10 ++++++++++ .../manifests/balancer/overlays/sandbox/configmap.yaml | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/base/database.yaml diff --git a/deploy/manifests/balancer/base/database.yaml b/deploy/manifests/balancer/base/database.yaml new file mode 100644 index 00000000..4676c6b8 --- /dev/null +++ b/deploy/manifests/balancer/base/database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: balancer + namespace: cloudnative-pg +spec: + name: balancer + owner: balancer + cluster: + name: shared-cluster diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..37d8cba2 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,13 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + - database.yaml + +patches: + - target: + kind: Database + name: balancer + patch: |- + - op: replace + path: /metadata/namespace + value: cloudnative-pg diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml index aa421eac..8ba92568 100644 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -3,7 +3,7 @@ kind: ConfigMap metadata: name: balancer-db-config data: - SQL_HOST: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + SQL_HOST: shared-cluster-rw.cloudnative-pg.svc.cluster.local SQL_PORT: "5432" SQL_DATABASE: balancer SQL_USER: balancer From 79a32327875f3f17f6b3aa168459b3ab1b5f8764 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 1 May 2026 15:22:30 -0400 Subject: [PATCH 083/101] Add YAML front matter to issue template --- .github/ISSUE_TEMPLATE/issue.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md index 70fca447..f2820548 100644 --- a/.github/ISSUE_TEMPLATE/issue.md +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -1,3 +1,12 @@ +--- +name: Issue template +about: Help push the project forward +title: '' +labels: '' +assignees: '' + +--- + ## Title From 06359c566c0ad863d81efa67af74a14391d2669e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:34:55 -0400 Subject: [PATCH 084/101] fix: remove redundant namespace patch on database.yaml database.yaml already has namespace: cloudnative-pg hardcoded. The kustomize patch was a no-op replacing the same value. --- deploy/manifests/balancer/base/kustomization.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 43db6731..44516542 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -13,12 +13,3 @@ configMapGenerator: - name: balancer-config envs: - balancer.env - -patches: - - target: - kind: Database - name: balancer - patch: |- - - op: replace - path: /metadata/namespace - value: cloudnative-pg From 1af2d3002ea0a0a4b9dfceb0535a53a40e65d912 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:51:13 -0400 Subject: [PATCH 085/101] fix: move Database CRD to cfp-sandbox-cluster infra The Database resource must live in the cloudnative-pg namespace alongside the Cluster CRD. Kustomize's namespace field overrides all resources, making it impossible to keep a cross-namespace resource in the app overlay. Moved to infra/cloudnative-pg/balancer-database.yaml in cfp-sandbox-cluster. --- deploy/manifests/balancer/base/database.yaml | 10 ---------- deploy/manifests/balancer/base/kustomization.yaml | 1 - 2 files changed, 11 deletions(-) delete mode 100644 deploy/manifests/balancer/base/database.yaml diff --git a/deploy/manifests/balancer/base/database.yaml b/deploy/manifests/balancer/base/database.yaml deleted file mode 100644 index 4676c6b8..00000000 --- a/deploy/manifests/balancer/base/database.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: balancer - namespace: cloudnative-pg -spec: - name: balancer - owner: balancer - cluster: - name: shared-cluster diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 44516542..f6ed71b4 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -7,7 +7,6 @@ resources: - service.yaml - gateway-listeners.yaml - httproute.yaml - - database.yaml configMapGenerator: - name: balancer-config From e8bfa66034e668ce0850f3135ee95073281cc7e6 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:58:17 -0400 Subject: [PATCH 086/101] fix: move Database CRD to sandbox-only CNPG kustomization Kustomize's namespace field overrides ALL resources, making it impossible to keep the Database in cloudnative-pg namespace within the main overlay. Created a separate kustomization at overlays/sandbox/cnpg/ without the namespace field, so the Database stays in cloudnative-pg namespace. This keeps sandbox and production databases separate - production overlay has no CNPG resources. --- .../balancer/overlays/sandbox/cnpg/database.yaml | 10 ++++++++++ .../balancer/overlays/sandbox/cnpg/kustomization.yaml | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml new file mode 100644 index 00000000..4676c6b8 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: balancer + namespace: cloudnative-pg +spec: + name: balancer + owner: balancer + cluster: + name: shared-cluster diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml new file mode 100644 index 00000000..f971acab --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - database.yaml From 7a0b6adc333d12826a20c3aa6ca22eba4ce3982e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:32:19 -0400 Subject: [PATCH 087/101] =?UTF-8?q?fix:=20remove=20redundant=20balancer-re?= =?UTF-8?q?direct=20HTTPRoute=20(covered=20by=20global=20HTTP=E2=86=92HTTP?= =?UTF-8?q?S=20redirect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/manifests/balancer/base/httproute.yaml | 20 +------------------ .../overlays/production/kustomization.yaml | 8 +------- .../overlays/sandbox/kustomization.yaml | 8 +------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index c8fef0fe..e818b73a 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -1,22 +1,4 @@ -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: balancer-redirect -spec: - parentRefs: - - name: balancer-listeners - kind: ListenerSet - group: gateway.networking.k8s.io - sectionName: http - hostnames: - - HOSTNAME_PLACEHOLDER - rules: - - filters: - - type: RequestRedirect - requestRedirect: - scheme: https - statusCode: 301 ---- + apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 0bb0bfd3..34dcd9bb 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -34,10 +34,4 @@ patches: - op: add path: /spec/hostnames value: ["balancerproject.org"] - - target: - kind: HTTPRoute - name: balancer-redirect - patch: |- - - op: add - path: /spec/hostnames - value: ["balancerproject.org"] + diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index e2086d37..894ea0d3 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -35,13 +35,7 @@ patches: - op: add path: /spec/hostnames value: ["sandbox.balancerproject.org"] - - target: - kind: HTTPRoute - name: balancer-redirect - patch: |- - - op: add - path: /spec/hostnames - value: ["sandbox.balancerproject.org"] + - target: kind: Deployment name: balancer From 96c961262d619c43e4e04236416d5e75a7616922 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:51:27 -0400 Subject: [PATCH 088/101] fix: simplify balancer kustomize overlays and add common labels --- .../balancer/base/gateway-listeners.yaml | 1 + deploy/manifests/balancer/base/httproute.yaml | 2 ++ .../balancer/base/kustomization.yaml | 10 +++++++ .../overlays/production/kustomization.yaml | 22 +++++++++++----- .../overlays/sandbox/kustomization.yaml | 26 ++++++++++++------- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/deploy/manifests/balancer/base/gateway-listeners.yaml b/deploy/manifests/balancer/base/gateway-listeners.yaml index 1572a1dd..4085d38a 100644 --- a/deploy/manifests/balancer/base/gateway-listeners.yaml +++ b/deploy/manifests/balancer/base/gateway-listeners.yaml @@ -4,6 +4,7 @@ metadata: name: balancer-listeners annotations: cert-manager.io/cluster-issuer: letsencrypt-prod + hostname: HOSTNAME_PLACEHOLDER spec: parentRef: name: main-gateway diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index e818b73a..8877b798 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -3,6 +3,8 @@ apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: balancer + annotations: + hostname: HOSTNAME_PLACEHOLDER spec: parentRefs: - name: balancer-listeners diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index f6ed71b4..04d76f2a 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -8,6 +8,16 @@ resources: - gateway-listeners.yaml - httproute.yaml +labels: + - pairs: + app.kubernetes.io/name: balancer + app.kubernetes.io/part-of: balancer + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/component: web + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + configMapGenerator: - name: balancer-config envs: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 34dcd9bb..3f6332c0 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,19 +8,28 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest + newTag: v1.0.2 configMapGenerator: - name: balancer-config behavior: merge + literals: + - HOSTNAME=balancerproject.org envs: - balancer.env +labels: + - includeSelectors: true + pairs: + environment: production + app.kubernetes.io/instance: balancer-production + app.kubernetes.io/version: v1.0.2 + patches: - target: kind: ListenerSet name: balancer-listeners - patch: |- + patch: | - op: replace path: /spec/listeners/0/hostname value: balancerproject.org @@ -30,8 +39,7 @@ patches: - target: kind: HTTPRoute name: balancer - patch: |- - - op: add - path: /spec/hostnames - value: ["balancerproject.org"] - + patch: | + - op: replace + path: /spec/hostnames/0 + value: balancerproject.org \ No newline at end of file diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 894ea0d3..058f5a60 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -9,19 +9,28 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest + newTag: v1.1.5 configMapGenerator: - name: balancer-config behavior: merge + literals: + - HOSTNAME=sandbox.balancerproject.org envs: - balancer.env +labels: + - includeSelectors: true + pairs: + environment: sandbox + app.kubernetes.io/instance: balancer-sandbox + app.kubernetes.io/version: v1.1.5 + patches: - target: kind: ListenerSet name: balancer-listeners - patch: |- + patch: | - op: replace path: /spec/listeners/0/hostname value: sandbox.balancerproject.org @@ -31,15 +40,14 @@ patches: - target: kind: HTTPRoute name: balancer - patch: |- - - op: add - path: /spec/hostnames - value: ["sandbox.balancerproject.org"] - + patch: | + - op: replace + path: /spec/hostnames/0 + value: sandbox.balancerproject.org - target: kind: Deployment name: balancer - patch: |- + patch: | - op: add path: /spec/template/spec/containers/0/envFrom/- value: @@ -52,4 +60,4 @@ patches: valueFrom: secretKeyRef: name: balancer-db-credentials - key: password + key: password \ No newline at end of file From f71f943d2aa83e4b6e714a85e61a7c2d308561f2 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:53:12 -0400 Subject: [PATCH 089/101] fix: update production to v1.1.5 --- .../manifests/balancer/overlays/production/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 3f6332c0..eae9a35c 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,7 +8,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.0.2 + newTag: v1.1.5 configMapGenerator: - name: balancer-config @@ -23,7 +23,7 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: v1.0.2 + app.kubernetes.io/version: v1.1.5 patches: - target: From e0ca2df243386b8d951f0ff4ba7d9d19e24d4d4a Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:54:12 -0400 Subject: [PATCH 090/101] fix: use image tags without v prefix (1.1.5) --- .../manifests/balancer/overlays/production/kustomization.yaml | 2 +- deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index eae9a35c..b1e940e5 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,7 +8,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.1.5 + newTag: 1.1.5 configMapGenerator: - name: balancer-config diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 058f5a60..f5721214 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.1.5 + newTag: 1.1.5 configMapGenerator: - name: balancer-config From 1072a9826f4bf94b39fbdfcdb39578736ab05fce Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:55:51 -0400 Subject: [PATCH 091/101] fix: align version label with image tag format (1.1.5) --- .../manifests/balancer/overlays/production/kustomization.yaml | 2 +- deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index b1e940e5..7c1c3ced 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -23,7 +23,7 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: v1.1.5 + app.kubernetes.io/version: "1.1.5" patches: - target: diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index f5721214..4cce6f6e 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -24,7 +24,7 @@ labels: pairs: environment: sandbox app.kubernetes.io/instance: balancer-sandbox - app.kubernetes.io/version: v1.1.5 + app.kubernetes.io/version: "1.1.5" patches: - target: From 524d0229c064f5def6d966d1121370aaf853a2ef Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:57:02 -0400 Subject: [PATCH 092/101] fix: remove version label, use image tag as source of truth --- deploy/manifests/balancer/overlays/production/kustomization.yaml | 1 - deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 7c1c3ced..c664e118 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -23,7 +23,6 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: "1.1.5" patches: - target: diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 4cce6f6e..89c27bd0 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -24,7 +24,6 @@ labels: pairs: environment: sandbox app.kubernetes.io/instance: balancer-sandbox - app.kubernetes.io/version: "1.1.5" patches: - target: From a0332981364f8c05f80871e5265249e4a9f78b95 Mon Sep 17 00:00:00 2001 From: C Tineo Date: Mon, 18 May 2026 13:22:13 -0400 Subject: [PATCH 093/101] feat(sandbox): add balancer-db-credentials SealedSecret for CNPG Adds the balancer-db-credentials SealedSecret to the sandbox CNPG overlay so cnpg can set the balancer role password. The sealed secret uses the cluster's sealed-secrets controller cert and targets the cloudnative-pg namespace. --- .../cnpg/balancer-db-credentials-sealed.yaml | 15 +++++++++++++++ .../overlays/sandbox/cnpg/kustomization.yaml | 1 + 2 files changed, 16 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml new file mode 100644 index 00000000..dbcf1290 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: balancer-db-credentials + namespace: cloudnative-pg +spec: + encryptedData: + password: AgAMJuKE3t/cs/B52t27B7NdH+Qo2O+og3keqQny/rNDNDEU+6K7Ogqg1IBMNfuT+wXXjMoqcdoyOmXw+pIdOz50Seum+9XWwjFhNnKOnrH6/P9wsqWsKtn3hDSwoPXnNa6IqFTGnaWkuO33VHXMN5NPr+iJMedESZapvoUj0Ftypw4m2GLIpG9QDcUNBa7BYz6WkDKU7Bdff6BQSVfFW8NCNWbkJf44/rsNZ/Ab72skNVM1iRH7U2QgM2751ARBsZapER3m3OnKXHTFGoOCmomYbaGKSOiqPWkL72AHVwH15ygCxBSKY40tyzYw16IiWZ+e2VhyILes1DrhXeQQDrC5PAVm3495WCDYLemQtKbnFegSeUC3TEgbFTgFwGFW1Morhiu2pSiyAXaSEw+WgJM3rHh4MRT+i1LHqJWD+Somc54JpGqO7AwKfnY6N3NvtPf2X5DRcyYwbAmbYErJuJtFd2JP3FjB7bnMnR1dAO227V+JOEPQR8StjQk5cEb4VnExq9O2YpAGD4xmte+IjqxYz3mRs8vXbhCfCLrp6QpdDC3IaeY+pUlg+XGRJFVE8kg0ConyvfAPZS//9BlqAyhiryCJmCzmZQjWwQ12SIFmhMnIr+z+nJOmB5qaGTynxhVtZeMRTtW3TdcOwW0P9u3RaCgPJRiQ6cFBQPHmuY1FxXWY+LrIjwoChdsgA7It+A0oai2vo3Zj7fZwS9tOHcEiBdh1x/lDKBMCDINS5ET/Yhp8rDkrpCF3M9C79g== + username: AgB6qtZcbgMbYwQsNL0degfED1t/tkb/SujFzVSAet84XaW5A1/2vWdZW/s+YQ4a02bkzGEHPJ7uw/OKqLtdtlvBr1bGpYICEo+jl36H8wEeWPhS/zAd6kQ4Qrw5EsEvSvZjQcyT5yhCCWUb6iKAYp3K/3BM9B4BjYDc97/rmqZiwRqUn618HWSnThe1zpe54kw+szQTG/ndI323b3AMMRlZoyYz8puVKh6ySRnm6Sd+I//A/19jCJzcEMKImMEu4CXNjoyxO2Z7bvzYcvazqzp2/jD/yzYo+Y5Y7QGL6+c1REvju1F/Nq/AOuanRBEaC8nbBTsyGA2z42CDDoimlesgSU+pChh0ixofHq4gbC85Ps43B54hBIoJQDLEpY7nCuOPr5TnzhyRvaFQ/iB594AG92MgPT1+xykWC9XEw3idccth8gRY/FBamu/uqWhGgtszFUr8dtkdWHVGqEdJ1MRseosWFqqXrey3wKAr41rXNo0f+QR6T1cIhemo44uhwdCeQz4w2f0Xk+eGkAzaUVtAwlgG6pFzmW/+Zqq5nbsuQeCCU5/MRmBLD+LGe3W0+JsZRHNqamzV7TKxK1GrR48Cj9h2EaR0ABbUcs0zcVkFJXcilOzjP1ElTPv7xBt8GhjctY0RoKIq2oGAlwWrlUYbETdeJCVWS0ZSHrpbQJnoMQB5ojMYXRUYhOB6LtLecz4rAXejkhKuGA== + template: + metadata: + name: balancer-db-credentials + namespace: cloudnative-pg + type: kubernetes.io/basic-auth diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml index f971acab..9d8024c4 100644 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml @@ -3,3 +3,4 @@ kind: Kustomization resources: - database.yaml + - balancer-db-credentials-sealed.yaml From 6249ed363e12f5a0ec5ca44dc1bac2dffc4f55b0 Mon Sep 17 00:00:00 2001 From: C Tineo Date: Mon, 18 May 2026 13:30:09 -0400 Subject: [PATCH 094/101] =?UTF-8?q?chore(sandbox):=20remove=20cnpg=20overl?= =?UTF-8?q?ay=20=E2=80=94=20CNPG=20resources=20belong=20in=20cfp-sandbox-c?= =?UTF-8?q?luster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Database CR and SealedSecret should live in cfp-sandbox-cluster under the cloudnative-pg namespace, not in the app overlay. --- .../cnpg/balancer-db-credentials-sealed.yaml | 15 --------------- .../balancer/overlays/sandbox/cnpg/database.yaml | 10 ---------- .../overlays/sandbox/cnpg/kustomization.yaml | 6 ------ 3 files changed, 31 deletions(-) delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml deleted file mode 100644 index dbcf1290..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: bitnami.com/v1alpha1 -kind: SealedSecret -metadata: - name: balancer-db-credentials - namespace: cloudnative-pg -spec: - encryptedData: - password: AgAMJuKE3t/cs/B52t27B7NdH+Qo2O+og3keqQny/rNDNDEU+6K7Ogqg1IBMNfuT+wXXjMoqcdoyOmXw+pIdOz50Seum+9XWwjFhNnKOnrH6/P9wsqWsKtn3hDSwoPXnNa6IqFTGnaWkuO33VHXMN5NPr+iJMedESZapvoUj0Ftypw4m2GLIpG9QDcUNBa7BYz6WkDKU7Bdff6BQSVfFW8NCNWbkJf44/rsNZ/Ab72skNVM1iRH7U2QgM2751ARBsZapER3m3OnKXHTFGoOCmomYbaGKSOiqPWkL72AHVwH15ygCxBSKY40tyzYw16IiWZ+e2VhyILes1DrhXeQQDrC5PAVm3495WCDYLemQtKbnFegSeUC3TEgbFTgFwGFW1Morhiu2pSiyAXaSEw+WgJM3rHh4MRT+i1LHqJWD+Somc54JpGqO7AwKfnY6N3NvtPf2X5DRcyYwbAmbYErJuJtFd2JP3FjB7bnMnR1dAO227V+JOEPQR8StjQk5cEb4VnExq9O2YpAGD4xmte+IjqxYz3mRs8vXbhCfCLrp6QpdDC3IaeY+pUlg+XGRJFVE8kg0ConyvfAPZS//9BlqAyhiryCJmCzmZQjWwQ12SIFmhMnIr+z+nJOmB5qaGTynxhVtZeMRTtW3TdcOwW0P9u3RaCgPJRiQ6cFBQPHmuY1FxXWY+LrIjwoChdsgA7It+A0oai2vo3Zj7fZwS9tOHcEiBdh1x/lDKBMCDINS5ET/Yhp8rDkrpCF3M9C79g== - username: AgB6qtZcbgMbYwQsNL0degfED1t/tkb/SujFzVSAet84XaW5A1/2vWdZW/s+YQ4a02bkzGEHPJ7uw/OKqLtdtlvBr1bGpYICEo+jl36H8wEeWPhS/zAd6kQ4Qrw5EsEvSvZjQcyT5yhCCWUb6iKAYp3K/3BM9B4BjYDc97/rmqZiwRqUn618HWSnThe1zpe54kw+szQTG/ndI323b3AMMRlZoyYz8puVKh6ySRnm6Sd+I//A/19jCJzcEMKImMEu4CXNjoyxO2Z7bvzYcvazqzp2/jD/yzYo+Y5Y7QGL6+c1REvju1F/Nq/AOuanRBEaC8nbBTsyGA2z42CDDoimlesgSU+pChh0ixofHq4gbC85Ps43B54hBIoJQDLEpY7nCuOPr5TnzhyRvaFQ/iB594AG92MgPT1+xykWC9XEw3idccth8gRY/FBamu/uqWhGgtszFUr8dtkdWHVGqEdJ1MRseosWFqqXrey3wKAr41rXNo0f+QR6T1cIhemo44uhwdCeQz4w2f0Xk+eGkAzaUVtAwlgG6pFzmW/+Zqq5nbsuQeCCU5/MRmBLD+LGe3W0+JsZRHNqamzV7TKxK1GrR48Cj9h2EaR0ABbUcs0zcVkFJXcilOzjP1ElTPv7xBt8GhjctY0RoKIq2oGAlwWrlUYbETdeJCVWS0ZSHrpbQJnoMQB5ojMYXRUYhOB6LtLecz4rAXejkhKuGA== - template: - metadata: - name: balancer-db-credentials - namespace: cloudnative-pg - type: kubernetes.io/basic-auth diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml deleted file mode 100644 index 4676c6b8..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: balancer - namespace: cloudnative-pg -spec: - name: balancer - owner: balancer - cluster: - name: shared-cluster diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml deleted file mode 100644 index 9d8024c4..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - database.yaml - - balancer-db-credentials-sealed.yaml From 5d740f681ddb2f47ddbbb7d8a88a4bebaf25c3b1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Sat, 23 May 2026 15:26:46 -0400 Subject: [PATCH 095/101] Delete architectural issue template --- .github/ISSUE_TEMPLATE/architectural-issue.md | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/architectural-issue.md diff --git a/.github/ISSUE_TEMPLATE/architectural-issue.md b/.github/ISSUE_TEMPLATE/architectural-issue.md deleted file mode 100644 index 266f6868..00000000 --- a/.github/ISSUE_TEMPLATE/architectural-issue.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Architectural issue -about: For issues related to alteration of project architecture -title: '' -labels: '' -assignees: '' - ---- - -# USER STORY HERE - -## Purpose: TLDR of why we need this? -### Description -Is your feature request related to a problem? Please describe. - -## Functionality: TLDR of what will this change? -### Description -A clear and concise description of what you will happen. - -## Affects: Where will this change? - -## Pros/Cons -Describe alternatives you've considered -A clear and concise description of any alternative solutions or features you've considered. - -## Additional context -Add any other context or screenshots about the feature request here. From ab498c0846324e2ca8784c1e9173d01a18da27d6 Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:37:55 -0400 Subject: [PATCH 096/101] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 40422a99..15dfbfd2 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,14 +67,12 @@ ROOT_URLCONF = "balancer_backend.urls" -<<<<<<< auth-login-ux-token-refresh -CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") -======= + # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] ->>>>>>> develop + TEMPLATES = [ { From 8dd740a93cf8d92ddd6621b81243383b96ee2ffe Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:38:47 -0400 Subject: [PATCH 097/101] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 15dfbfd2..7c2c9e67 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,13 +67,11 @@ ROOT_URLCONF = "balancer_backend.urls" - # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] - TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", From fc7b7233d6adbe901de2c82c3918fecafee26732 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Jun 2026 09:58:07 -0400 Subject: [PATCH 098/101] Make AI-eval scaffolding mergeable: valid notebook + run_one tests --- server/api/views/assistant/eval_assistant.py | 6 ++ server/api/views/assistant/review.ipynb | 59 ++++++++++++++++++- .../views/assistant/test_eval_assistant.py | 34 +++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py index e0d3c969..3a315e48 100644 --- a/server/api/views/assistant/eval_assistant.py +++ b/server/api/views/assistant/eval_assistant.py @@ -26,10 +26,15 @@ import django django.setup() +# TODO: pandas is only used in main(), but importing it at module top means +# importing this module (e.g. from test_eval_assistant.py) fails if pandas is +# not installed in the test environment. Move this import into main() to make +# run_one importable without pandas. import pandas as pd from django.contrib.auth import get_user_model from api.views.assistant.assistant_services import run_assistant +# TODO: remove unused import or use INSTRUCTIONS to record an instructions_hash column from api.views.assistant.assistant_prompts import INSTRUCTIONS logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") @@ -40,6 +45,7 @@ # Read model and INSTRUCTIONS from the source file # INSTRUCTIONS is imported from assistant_prompts.py # MODEL is read from assistant_services.py MODEL_DEFAULTS +# TODO: import a shared MODEL_NAME constant from assistant_services instead of hardcoding MODEL = "gpt-5-nano" # Set of representative questions to evaluate the assistant diff --git a/server/api/views/assistant/review.ipynb b/server/api/views/assistant/review.ipynb index e6da83d0..dadc8d3b 100644 --- a/server/api/views/assistant/review.ipynb +++ b/server/api/views/assistant/review.ipynb @@ -1 +1,58 @@ -# notebook to review and compare the two CSVs \ No newline at end of file +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Assistant eval review\n", + "\n", + "Load two result CSVs produced by `eval_assistant.py` (one per branch) and compare the\n", + "assistant's responses side by side.\n", + "\n", + "Set `DEVELOP_CSV` and `FEATURE_CSV` below to the two files in `results/` you want to compare." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "DEVELOP_CSV = \"results/develop-.csv\"\n", + "FEATURE_CSV = \"results/-.csv\"\n", + "\n", + "develop_df = pd.read_csv(DEVELOP_CSV)\n", + "feature_df = pd.read_csv(FEATURE_CSV)\n", + "\n", + "# Outer join on question so questions missing from either run stay visible.\n", + "comparison = develop_df.merge(\n", + " feature_df, on=\"question\", how=\"outer\", suffixes=(\"_develop\", \"_feature\")\n", + ")\n", + "comparison[[\"question\", \"response_output_text_develop\", \"response_output_text_feature\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## TODO (follow-up branch)\n", + "\n", + "Once `eval_assistant.py` records per-row metrics (`tool_calls_made`, token counts,\n", + "`cost_usd`, `duration_s`, `instructions_hash`), extend the comparison to:\n", + "\n", + "- Flag rows where `instructions_hash` differs between branches (prompt changed).\n", + "- Summarize cost/token totals grouped by branch.\n", + "- Highlight rows where `tool_calls_made` differs (retrieval used differently)." + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/server/api/views/assistant/test_eval_assistant.py b/server/api/views/assistant/test_eval_assistant.py index e69de29b..f88b2d8f 100644 --- a/server/api/views/assistant/test_eval_assistant.py +++ b/server/api/views/assistant/test_eval_assistant.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# run_one tests +# +# run_one wraps run_assistant and shapes its output into a result row for the +# eval CSV. We patch run_assistant (no live OpenAI, no DB) and check that both +# the success and error paths produce a well-formed row. +# --------------------------------------------------------------------------- + +@patch("api.views.assistant.eval_assistant.run_assistant") +def test_run_one_returns_result_row_on_success(mock_run_assistant): + mock_run_assistant.return_value = ("answer", "resp-1") + + from api.views.assistant.eval_assistant import run_one + + row = run_one("What is lithium?", user=MagicMock(), branch="develop") + + assert row["branch"] == "develop" + assert row["question"] == "What is lithium?" + assert row["response_output_text"] == "answer" + assert row["error"] is None + + +@patch("api.views.assistant.eval_assistant.run_assistant", side_effect=Exception("boom")) +def test_run_one_captures_error(mock_run_assistant): + from api.views.assistant.eval_assistant import run_one + + row = run_one("query", user=MagicMock(), branch="feature") + + assert row["branch"] == "feature" + assert row["response_output_text"] is None + assert "boom" in row["error"] From a3027dda961a7cab01a09296a7f5eee3e087e69b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Jun 2026 11:49:03 -0400 Subject: [PATCH 099/101] Tighten the assistant test suite so it covers only logic we wrote --- server/api/views/assistant/eval_assistant.py | 10 +-- .../assistant/test_assistant_services.py | 54 +++------------ .../views/assistant/test_eval_assistant.py | 30 +++------ .../api/views/assistant/test_tool_services.py | 36 ++++++---- server/api/views/assistant/test_views.py | 66 ------------------- 5 files changed, 43 insertions(+), 153 deletions(-) delete mode 100644 server/api/views/assistant/test_views.py diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py index 3a315e48..26feb247 100644 --- a/server/api/views/assistant/eval_assistant.py +++ b/server/api/views/assistant/eval_assistant.py @@ -26,11 +26,6 @@ import django django.setup() -# TODO: pandas is only used in main(), but importing it at module top means -# importing this module (e.g. from test_eval_assistant.py) fails if pandas is -# not installed in the test environment. Move this import into main() to make -# run_one importable without pandas. -import pandas as pd from django.contrib.auth import get_user_model from api.views.assistant.assistant_services import run_assistant @@ -132,6 +127,11 @@ def main(): for future in as_completed(futures): results.append(future.result()) + # Import pandas here, not at module top, so that importing this module (e.g. + # run_one from test_eval_assistant.py) does not require pandas. It is only + # needed for the CSV output below, when this script is run directly. + import pandas as pd + df = pd.DataFrame(results) results_dir = os.path.join(os.path.dirname(__file__), "results") diff --git a/server/api/views/assistant/test_assistant_services.py b/server/api/views/assistant/test_assistant_services.py index 3cb70534..9d911920 100644 --- a/server/api/views/assistant/test_assistant_services.py +++ b/server/api/views/assistant/test_assistant_services.py @@ -1,3 +1,11 @@ +# Tests for run_assistant (assistant_services.py): the orchestrator that wires the +# OpenAI client, the search tool mapping, and the agentic loop together. +# +# The OpenAI client and handle_tool_calls_with_reasoning are mocked, so these +# tests cover only logic run_assistant owns: how it builds the user input message, +# its decision to include vs. omit previous_response_id, and that it binds the +# request user into the search tool. No live OpenAI calls and no database. + from unittest.mock import MagicMock, patch @@ -8,35 +16,6 @@ def _make_terminal_response(output_text="Final answer.", response_id="resp-1"): response.id = response_id return response - -# --------------------------------------------------------------------------- -# run_assistant tests -# -# run_assistant is responsible for wiring together the OpenAI client, -# make_search_tool_mapping (which binds user to search_documents), and -# handle_tool_calls_with_reasoning. -# -# We patch the OpenAI client and handle_tool_calls_with_reasoning to test -# that run_assistant correctly assembles and forwards its arguments. -# --------------------------------------------------------------------------- - -@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") -@patch("api.views.assistant.assistant_services.OpenAI") -def test_run_assistant_returns_text_and_id(mock_openai_cls, mock_handle): - mock_client = MagicMock() - mock_openai_cls.return_value = mock_client - mock_client.responses.create.return_value = _make_terminal_response() - mock_handle.return_value = ("Final answer.", "resp-1") - - from api.views.assistant.assistant_services import run_assistant - - user = MagicMock() - text, resp_id = run_assistant(message="What is lithium?", user=user) - - assert text == "Final answer." - assert resp_id == "resp-1" - - @patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") @patch("api.views.assistant.assistant_services.OpenAI") def test_run_assistant_sends_message_as_user_input(mock_openai_cls, mock_handle): @@ -89,23 +68,6 @@ def test_run_assistant_omits_previous_response_id_when_none(mock_openai_cls, moc assert "previous_response_id" not in call_kwargs -@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") -@patch("api.views.assistant.assistant_services.OpenAI") -def test_run_assistant_passes_search_tools_schema_to_model(mock_openai_cls, mock_handle): - from api.views.assistant.assistant_services import run_assistant - from api.views.assistant.tool_services import SEARCH_TOOLS_SCHEMA - - mock_client = MagicMock() - mock_openai_cls.return_value = mock_client - mock_client.responses.create.return_value = _make_terminal_response() - mock_handle.return_value = ("answer", "resp-1") - - run_assistant(message="query", user=MagicMock()) - - call_kwargs = mock_client.responses.create.call_args.kwargs - assert call_kwargs.get("tools") == SEARCH_TOOLS_SCHEMA - - @patch("api.views.assistant.tool_services.search_documents") @patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning") @patch("api.views.assistant.assistant_services.OpenAI") diff --git a/server/api/views/assistant/test_eval_assistant.py b/server/api/views/assistant/test_eval_assistant.py index f88b2d8f..5853d340 100644 --- a/server/api/views/assistant/test_eval_assistant.py +++ b/server/api/views/assistant/test_eval_assistant.py @@ -1,32 +1,18 @@ -from unittest.mock import MagicMock, patch - - -# --------------------------------------------------------------------------- -# run_one tests +# Tests for run_one (eval_assistant.py): the helper that runs the assistant for a +# single eval question and shapes the outcome into a result row. # -# run_one wraps run_assistant and shapes its output into a result row for the -# eval CSV. We patch run_assistant (no live OpenAI, no DB) and check that both -# the success and error paths produce a well-formed row. -# --------------------------------------------------------------------------- - -@patch("api.views.assistant.eval_assistant.run_assistant") -def test_run_one_returns_result_row_on_success(mock_run_assistant): - mock_run_assistant.return_value = ("answer", "resp-1") +# run_assistant is mocked, so this covers the logic run_one owns — specifically +# that a raising question is captured as an error row (error text recorded, +# response left None) instead of aborting the whole eval batch. - from api.views.assistant.eval_assistant import run_one - - row = run_one("What is lithium?", user=MagicMock(), branch="develop") +from unittest.mock import MagicMock, patch - assert row["branch"] == "develop" - assert row["question"] == "What is lithium?" - assert row["response_output_text"] == "answer" - assert row["error"] is None +from api.views.assistant.eval_assistant import run_one +# TODO: add coverage for main()'s CSV output. @patch("api.views.assistant.eval_assistant.run_assistant", side_effect=Exception("boom")) def test_run_one_captures_error(mock_run_assistant): - from api.views.assistant.eval_assistant import run_one - row = run_one("query", user=MagicMock(), branch="feature") assert row["branch"] == "feature" diff --git a/server/api/views/assistant/test_tool_services.py b/server/api/views/assistant/test_tool_services.py index ef78a383..86e57eed 100644 --- a/server/api/views/assistant/test_tool_services.py +++ b/server/api/views/assistant/test_tool_services.py @@ -1,6 +1,24 @@ +# Tests for tool_services.py: the retrieval tooling and the agentic reasoning loop. +# +# Covers the logic this module owns, with mocked tools (no DB, no OpenAI): +# - make_search_tool_mapping: the closure that binds the request user to +# search_documents, including per-call user independence. +# - invoke_functions_from_response: dispatching the model's function calls — +# the call/no-call branch, output shaping, and the unregistered-tool and +# tool-raises error paths. +# - handle_tool_calls_with_reasoning: the while-loop that keeps calling the +# model until it stops emitting tool calls, including loop continuity via +# previous_response_id. + import json from unittest.mock import MagicMock, patch +# TODO: add coverage for search_documents itself (formatting of embeddings +# results, the empty-results message, and the exception path). No DB needed: +# search_documents only calls get_closest_embeddings and convert_uuids, so +# mocking those two (like the rest of the suite mocks collaborators) covers all +# three paths as fast, DB-free unit tests. + from api.views.assistant.tool_services import ( invoke_functions_from_response, handle_tool_calls_with_reasoning, @@ -10,20 +28,8 @@ # --------------------------------------------------------------------------- # make_search_tool_mapping tests -# -# make_search_tool_mapping is responsible for binding user to search_documents -# so the tool dispatcher can call it with only the query argument the model -# generates. We test the shape of the returned mapping and that user is -# forwarded correctly to search_documents. # --------------------------------------------------------------------------- -@patch("api.views.assistant.tool_services.search_documents") -def test_make_search_tool_mapping_returns_search_documents_key(mock_search): - user = MagicMock() - mapping = make_search_tool_mapping(user) - assert "search_documents" in mapping - - @patch("api.views.assistant.tool_services.search_documents") def test_make_search_tool_mapping_bound_fn_forwards_user(mock_search): mock_search.return_value = "results" @@ -47,9 +53,11 @@ def test_make_search_tool_mapping_different_users_are_independent(mock_search): mapping_a["search_documents"](query="q") mapping_b["search_documents"](query="q") + # bound_search calls search_documents(query, user) positionally, so each + # recorded call is (args, kwargs) == (("q", user), {}). calls = mock_search.call_args_list - assert calls[0] == ((("q", user_a),), {}) - assert calls[1] == ((("q", user_b),), {}) + assert calls[0] == (("q", user_a), {}) + assert calls[1] == (("q", user_b), {}) # --------------------------------------------------------------------------- diff --git a/server/api/views/assistant/test_views.py b/server/api/views/assistant/test_views.py deleted file mode 100644 index a9511b42..00000000 --- a/server/api/views/assistant/test_views.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import patch - -from django.test import TestCase -from django.contrib.auth import get_user_model -from rest_framework.test import APIClient - -User = get_user_model() - - -class AssistantViewTest(TestCase): - def setUp(self): - self.client = APIClient() - self.url = "/v1/api/assistant/" - - @patch("api.views.assistant.views.run_assistant") - def test_returns_200_with_response_fields(self, mock_run_assistant): - mock_run_assistant.return_value = ("Lithium is recommended.", "resp-abc-123") - - response = self.client.post( - self.url, - {"message": "What medications help with bipolar depression?"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["response_output_text"], "Lithium is recommended.") - self.assertEqual(response.data["final_response_id"], "resp-abc-123") - - @patch("api.views.assistant.views.run_assistant") - def test_passes_message_and_user_to_run_assistant(self, mock_run_assistant): - mock_run_assistant.return_value = ("Some response.", "resp-xyz") - - self.client.post( - self.url, - {"message": "Tell me about lithium."}, - format="json", - ) - - call_kwargs = mock_run_assistant.call_args.kwargs - self.assertEqual(call_kwargs["message"], "Tell me about lithium.") - self.assertIn("user", call_kwargs) - self.assertIsNone(call_kwargs["previous_response_id"]) - - @patch("api.views.assistant.views.run_assistant") - def test_passes_previous_response_id_when_provided(self, mock_run_assistant): - mock_run_assistant.return_value = ("Follow-up response.", "resp-456") - - self.client.post( - self.url, - {"message": "Tell me more.", "previous_response_id": "resp-123"}, - format="json", - ) - - call_kwargs = mock_run_assistant.call_args.kwargs - self.assertEqual(call_kwargs["previous_response_id"], "resp-123") - - @patch("api.views.assistant.views.run_assistant", side_effect=Exception("OpenAI error")) - def test_returns_500_on_exception(self, mock_run_assistant): - response = self.client.post( - self.url, - {"message": "What is lithium?"}, - format="json", - ) - - self.assertEqual(response.status_code, 500) - self.assertIn("error", response.data) From 84f64c4a64b5caddb47f8f10090f1c9db3a66cdd Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Jun 2026 13:37:34 -0400 Subject: [PATCH 100/101] Fix pytest collection error in uploadFile test_title --- server/api/views/uploadFile/test_title.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index ef694e14..c85efba7 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import MagicMock, patch -from . import title +from api.views.uploadFile import title def make_page_dict(blocks): From ecd9a7244be3153f7ec24e53b02a465a56a17a6c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Jun 2026 14:07:45 -0400 Subject: [PATCH 101/101] Documentation-only follow-ups from the branch review --- server/api/views/assistant/eval_assistant.py | 9 ++++++++- server/api/views/assistant/views.py | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/api/views/assistant/eval_assistant.py b/server/api/views/assistant/eval_assistant.py index 26feb247..b44a2174 100644 --- a/server/api/views/assistant/eval_assistant.py +++ b/server/api/views/assistant/eval_assistant.py @@ -9,7 +9,8 @@ # /// # uv script (or plain Python) to generate results to CSV, run from the terminal -# Run from inside the container: docker compose exec backend python eval_assistant.py +# Run from inside the container (working dir is /usr/src/server): +# docker compose exec backend python api/views/assistant/eval_assistant.py # @@ -20,6 +21,12 @@ from concurrent.futures import ThreadPoolExecutor, as_completed # Django setup must come before any imports that touch the ORM +# NOTE: from api/views/assistant/, "../../../../" resolves four levels up to +# /usr/src (not /usr/src/server, where balancer_backend lives). So this insert +# alone does not put the settings package on sys.path — running the script +# relies on the container already having /usr/src/server on PYTHONPATH. Sanity- +# check this the first time the eval is run for real; the path depth may need +# adjusting (e.g. "../../../"). sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../"))) os.environ.setdefault("DJANGO_SETTINGS_MODULE", "balancer_backend.settings") diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index d7b10a4b..74bee8f6 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -37,6 +37,12 @@ def post(self, request): try: user = request.user + # TODO: validate message and return a 400 when it is omitted or blank. + # @extend_schema documents message as required, but that schema is not + # enforced at runtime, so a missing/empty message reaches run_assistant + # and becomes the literal string "None" (str(None)) in the model input — + # producing confusing model behavior. Add a 400 to the responses schema + # when implementing. message = request.data.get("message", None) previous_response_id = request.data.get("previous_response_id", None)