From 50246effffc39ef58990bb2830c54c31738477b3 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 12 Jun 2025 17:45:05 -0400 Subject: [PATCH 01/92] Add Github Actions Python workflow --- .github/workflows/python-app.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..dcb7a4bb --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,29 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "listOfMed" ] + pull_request: + branches: [ "listOfMed" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install the code linting and formatting tool Ruff + run: pipx install ruff + - name: Lint code with Ruff + run: ruff check --output-format=github --target-version=py39 From 4ab75531b8ed58c0286f53388c47918559d19d5a Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 16 Jun 2025 16:30:17 -0400 Subject: [PATCH 02/92] Clean up unused imports and duplicate definitions across API modules --- server/api/admin.py | 4 +--- server/api/models.py | 1 - server/api/models/__init__.py | 2 +- server/api/models/model_embeddings.py | 1 - server/api/models/model_medRule.py | 1 - server/api/tests.py | 1 - server/api/views/ai_promptStorage/serializers.py | 1 - server/api/views/ai_promptStorage/views.py | 6 ++---- server/api/views/ai_settings/urls.py | 3 +-- server/api/views/conversations/views.py | 8 +------- server/api/views/embeddings/embeddingsView.py | 1 - server/api/views/feedback/urls.py | 1 - server/api/views/feedback/views.py | 8 -------- server/api/views/listMeds/views.py | 5 +---- server/api/views/medRules/views.py | 1 - server/api/views/uploadFile/views.py | 2 -- 16 files changed, 7 insertions(+), 39 deletions(-) diff --git a/server/api/admin.py b/server/api/admin.py index 930e86a4..5b92d838 100644 --- a/server/api/admin.py +++ b/server/api/admin.py @@ -4,8 +4,6 @@ from .models.authUser import UserAccount from .views.ai_settings.models import AI_Settings from .views.ai_promptStorage.models import AI_PromptStorage -from .views.ai_settings.models import AI_Settings -from .views.ai_promptStorage.models import AI_PromptStorage from .models.model_embeddings import Embeddings from .views.feedback.models import Feedback from .models.model_medRule import MedRule @@ -24,7 +22,7 @@ class MedicationAdmin(admin.ModelAdmin): @admin.register(Medication) -class MedicationAdmin(admin.ModelAdmin): +class MedicationAdmin(admin.ModelAdmin): # noqa: F811 list_display = ['name', 'benefits', 'risks'] diff --git a/server/api/models.py b/server/api/models.py index 71a83623..35e0d648 100644 --- a/server/api/models.py +++ b/server/api/models.py @@ -1,3 +1,2 @@ -from django.db import models # Create your models here. diff --git a/server/api/models/__init__.py b/server/api/models/__init__.py index 9f00b1ee..2b73b64a 100644 --- a/server/api/models/__init__.py +++ b/server/api/models/__init__.py @@ -1 +1 @@ -from .authUser import UserAccount +from .authUser import UserAccount # noqa: F401 diff --git a/server/api/models/model_embeddings.py b/server/api/models/model_embeddings.py index 2a1d4032..ed61f2fb 100644 --- a/server/api/models/model_embeddings.py +++ b/server/api/models/model_embeddings.py @@ -1,5 +1,4 @@ from django.db import models -from django.conf import settings from pgvector.django import VectorField import uuid from ..views.uploadFile.models import UploadFile diff --git a/server/api/models/model_medRule.py b/server/api/models/model_medRule.py index 9212330e..46712c38 100644 --- a/server/api/models/model_medRule.py +++ b/server/api/models/model_medRule.py @@ -1,6 +1,5 @@ from django.db import models from ..views.listMeds.models import Medication -from django.db.models import CASCADE from ..models.model_embeddings import Embeddings diff --git a/server/api/tests.py b/server/api/tests.py index f6eac93b..baf59b4e 100644 --- a/server/api/tests.py +++ b/server/api/tests.py @@ -1,4 +1,3 @@ -from django.test import TestCase import unittest from .services.tools.tools import validate_tool_inputs, execute_tool diff --git a/server/api/views/ai_promptStorage/serializers.py b/server/api/views/ai_promptStorage/serializers.py index ebbd2d4b..0358f8a5 100644 --- a/server/api/views/ai_promptStorage/serializers.py +++ b/server/api/views/ai_promptStorage/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers from .models import AI_PromptStorage -from django.conf import settings class AI_PromptStorageSerializer(serializers.ModelSerializer): diff --git a/server/api/views/ai_promptStorage/views.py b/server/api/views/ai_promptStorage/views.py index e49c21c1..7354feb3 100644 --- a/server/api/views/ai_promptStorage/views.py +++ b/server/api/views/ai_promptStorage/views.py @@ -1,17 +1,15 @@ from rest_framework import status -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import api_view from rest_framework.response import Response from .models import AI_PromptStorage from .serializers import AI_PromptStorageSerializer -from django.views.decorators.csrf import csrf_exempt @api_view(['POST']) # @permission_classes([IsAuthenticated]) def store_prompt(request): print(request.user) - data = request.data.copy() + data = request.data.copy() # noqa: F841 print(request.user) serializer = AI_PromptStorageSerializer( data=request.data, context={'request': request}) diff --git a/server/api/views/ai_settings/urls.py b/server/api/views/ai_settings/urls.py index abe3c990..2266ed6e 100644 --- a/server/api/views/ai_settings/urls.py +++ b/server/api/views/ai_settings/urls.py @@ -1,5 +1,4 @@ -from django.urls import path, include -from rest_framework.routers import DefaultRouter +from django.urls import path from api.views.ai_settings import views urlpatterns = [ diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index ffe60e65..d46f8222 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,6 +1,4 @@ -from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.decorators import api_view from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated @@ -14,14 +12,10 @@ import os import json import logging -from api.views.ai_settings.models import AI_Settings -from api.views.ai_promptStorage.models import AI_PromptStorage from django.views.decorators.csrf import csrf_exempt -from django.db import transaction, connection from .models import Conversation, Message -from .serializers import ConversationSerializer, MessageSerializer +from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool -from ...services.tools.database import get_database_info @csrf_exempt diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index 550caaa3..9469bb09 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -3,7 +3,6 @@ from rest_framework.response import Response from rest_framework import status from django.http import StreamingHttpResponse -import os from ...services.embedding_services import get_closest_embeddings from ...services.conversions_services import convert_uuids from ...services.openai_services import openAIServices diff --git a/server/api/views/feedback/urls.py b/server/api/views/feedback/urls.py index 41925665..2c0eab29 100644 --- a/server/api/views/feedback/urls.py +++ b/server/api/views/feedback/urls.py @@ -1,5 +1,4 @@ from django.urls import path -from api.views.feedback import views from .views import FeedbackView urlpatterns = [ diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index cf39c9bf..dcbef992 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -1,18 +1,10 @@ from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.decorators import api_view from rest_framework import status -from django.http import JsonResponse, HttpRequest -from django import forms -import requests -import json -import os -from .models import Feedback from .serializers import FeedbackSerializer # XXX: remove csrf_exempt usage before production -from django.views.decorators.csrf import csrf_exempt class FeedbackView(APIView): diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 5a19dfa2..0e7fe584 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,11 +1,8 @@ from rest_framework import status -from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.views import APIView from .models import Diagnosis, Medication, Suggestion -from .serializers import DiagnosisSerializer, MedicationSerializer, SuggestionSerializer -import json -from django.views.decorators.csrf import csrf_exempt +from .serializers import MedicationSerializer # Constants for medication inclusion and exclusion MEDS_INCLUDE = {'suicideHistory': ['Lithium']} MED_EXCLUDE = { diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index f8ff39e1..2fae140b 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -7,7 +7,6 @@ from ...models.model_medRule import MedRule from .serializers import MedRuleSerializer # You'll need to create this from ..listMeds.models import Medication -from ..listMeds.serializers import MedicationSerializer from ..uploadFile.models import UploadFile from ...models.model_embeddings import Embeddings diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 89b68f17..fb843324 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -7,8 +7,6 @@ from django.views.decorators.csrf import csrf_exempt import pdfplumber from .models import UploadFile # Import your UploadFile model -from django.core.files.base import ContentFile -import os from .serializers import UploadFileSerializer from django.http import HttpResponse from ...services.sentencetTransformer_model import TransformerModel From 3a174576d21156f99f79e0bde40c9d48360399bf Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 18 Jun 2025 17:47:22 -0400 Subject: [PATCH 03/92] GPT41 Nano prompting --- server/api/services/llm_services.py | 323 ++++++++++++++++++++++ server/api/views/text_extraction/views.py | 25 +- 2 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 server/api/services/llm_services.py diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py new file mode 100644 index 00000000..7ab9c176 --- /dev/null +++ b/server/api/services/llm_services.py @@ -0,0 +1,323 @@ +""" +This module contains functions to interact with different AI models +""" + +import os +import time +import logging +from abc import ABC, abstractmethod + +import anthropic +import openai + + +class BaseModelHandler(ABC): + @abstractmethod + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + pass + + +class ClaudeHaiku35CitationsHandler(BaseModelHandler): + MODEL = "claude-3-5-haiku-20241022" + # Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing + PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.80, "output": 4.00} + + def __init__(self) -> None: + self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + """ + Handles the request to the Claude Haiku 3.5 model with citations enabled + + Args: + query: The user query to be processed + context: The context or document content to be used for citations + + """ + + start_time = time.time() + # TODO: Add error handling for API requests and invalid responses + message = self.client.messages.create( + model=self.MODEL, + max_tokens=1024, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": query}, + { + "type": "document", + "source": {"type": "content", "content": context}, + "citations": {"enabled": True}, + }, + ], + } + ], + ) + duration = time.time() - start_time + + # Response Structure: https://docs.anthropic.com/en/docs/build-with-claude/citations#response-structure + + text = [] + cited_text = [] + for content in message.to_dict()["content"]: + text.append(content["text"]) + if "citations" in content.keys(): + text.append( + " ".join( + [ + f"<{citation['start_block_index']} - {citation['end_block_index']}>" + for citation in content["citations"] + ] + ) + ) + cited_text.append( + " ".join( + [ + f"<{citation['start_block_index']} - {citation['end_block_index']}> {citation['cited_text']}" + for citation in content["citations"] + ] + ) + ) + + full_text = " ".join(text) + + return ( + full_text, + message.usage, + self.PRICING_DOLLARS_PER_MILLION_TOKENS, + duration, + ) + + +class ClaudeHaiku3Handler(BaseModelHandler): + MODEL = "claude-3-haiku-20240307" + # Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing + PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.25, "output": 1.25} + + def __init__(self) -> None: + self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) + + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + """ + Handles the request to the Claude Haiku 3 model with citations disabled + + Args: + query: The user query to be processed + context: The context or document content to be used + + """ + + start_time = time.time() + # TODO: Add error handling for API requests and invalid responses + message = self.client.messages.create( + model=self.MODEL, + max_tokens=1024, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": query}, + { + "type": "document", + "source": {"type": "content", "content": context}, + "citations": {"enabled": False}, + }, + ], + } + ], + ) + duration = time.time() - start_time + + text = [] + for content in message.to_dict()["content"]: + text.append(content["text"]) + + full_text = " ".join(text) + + return ( + full_text, + message.usage, + self.PRICING_DOLLARS_PER_MILLION_TOKENS, + duration, + ) + + +class GPT4OMiniHandler(BaseModelHandler): + MODEL = "gpt-4o-mini" + # Model Pricing: https://platform.openai.com/docs/pricing + PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.15, "output": 0.60} + + def __init__(self) -> None: + self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + """ + Handles the request to the GPT-4o Mini model + + Args: + query: The user query to be processed + context: The context or document content to be used + + """ + start_time = time.time() + # TODO: Add error handling for API requests and invalid responses + response = self.client.responses.create( + model=self.MODEL, + instructions=query, + input=context, + ) + duration = time.time() - start_time + + return ( + response.output_text, + response.usage, + self.PRICING_DOLLARS_PER_MILLION_TOKENS, + duration, + ) + + +class GPT41NanoHandler(BaseModelHandler): + MODEL = "gpt-4.1-nano" + # Model Pricing: https://platform.openai.com/docs/pricing + PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.10, "output": 0.40} + + def __init__(self) -> None: + self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + """ + Handles the request to the GPT-4.1 Nano model + + Args: + query: The user query to be processed + context: The context or document content to be used + + """ + start_time = time.time() + # TODO: Add error handling for API requests and invalid responses + + # GPT 4.1 Prompting Guide: https://cookbook.openai.com/examples/gpt4-1_prompting_guide + + # Long context performance can degrade as more items are required to be retrieved, + # or perform complex reasoning that requires knowledge of the state of the entire context + + """ + + # Role and Objective + + - You are a seasoned physician or medical professional who treats patients with bipolar disorder + - You are analyzing medical research by processing peer-reviewed papers to extract key details + + # Instructions + + - Identify rules for medication inclusion or exclusion based on medical history or concerns + + - Only use the documents in the provided External Context to answer the User Query. + If you don't know the answer based on this context, you must respond + "I don't have the information needed to answer that", even if a user insists on you answering the question. + + - Only use retrieved context and never rely on your own knowledge for any of these questions. + + - Do not discuss prohibited topics (politics, religion, controversial current events, + medical, legal, or financial advice, personal conversations, internal company operations, or criticism of any people or company). + + - Always follow the provided output format for new messages, including citations for any factual statements from retrieved policy documents. + + # Output Format + + The rule is history of suicide attempts. The type of rule is "INCLUDE". The reason is lithium is the + only medication on the market that has been proven to reduce suicidality in patients with bipolar disorder. + The medications for this rule are lithium. + + The rule is weight gain concerns. The type of rule is "EXCLUDE". The reason is Seroquel, Risperdal, Abilify, and + Zyprexa are known for causing weight gain. The medications for this rule are Quetiapine, Aripiprazole, Olanzapine, Risperidone + + For each rule you find, return a JSON object using the following format: + + { + "rule": "", + "type": "INCLUDE" or "EXCLUDE", + "reason": "", + "medications": ["", "", ...], + "source": "" + } + + - When providing factual information from retrieved context, always include citations immediately after the relevant statement(s). + Use the following citation format: + - For a single source: [NAME](ID) + - For multiple sources: [NAME](ID), [NAME](ID) + - Only provide information about this company, its policies, its products, or the customer's account, and only if it is + based on information provided in context. Do not answer questions outside this scope. + + + # Examples + + + # Context + + ID: 1 | TITLE: The Fox | CONTENT: The quick brown fox jumps over the lazy dog + + # Final instructions and prompt to think step by step + + - Identify rules for medication inclusion or exclusion based on medical history or concerns + + - Only use the documents in the provided External Context to answer the User Query. + If you don't know the answer based on this context, you must respond + "I don't have the information needed to answer that", even if a user insists on you answering the question. + + """ + + + + response = self.client.responses.create( + model=self.MODEL, + instructions=query, + input=context, + ) + duration = time.time() - start_time + + return ( + response.output_text, + response.usage, + self.PRICING_DOLLARS_PER_MILLION_TOKENS, + duration, + ) + + +class ModelFactory: + HANDLERS = { + "CLAUDE_HAIKU_3_5_CITATIONS": ClaudeHaiku35CitationsHandler, + "CLAUDE_HAIKU_3": ClaudeHaiku3Handler, + "GPT_4O_MINI": GPT4OMiniHandler, + "GPT_41_NANO": GPT41NanoHandler, + } + + # HANDLERS doesn't vary per instance so we can use a class method + @classmethod + def get_handler(cls, model_name: str) -> BaseModelHandler | None: + """ + Factory method to get the appropriate model handler based on the model name + + Args: + model_name (str): The name of the model for which to get the handler. + Returns: + BaseModelHandler: An instance of the appropriate model handler class. + """ + + handler_class = cls.HANDLERS.get(model_name) + if handler_class: + return handler_class() + else: + logging.error(f"Unsupported model: {model_name}") + return None diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 92b34c9c..79b916ab 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -1,5 +1,7 @@ import os -from ...services.openai_services import openAIServices +import json +import re + from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -7,16 +9,28 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt import anthropic -import json -import re + +from ...services.openai_services import openAIServices from api.models.model_embeddings import Embeddings -# TODO: Add docstrings and type hints -def anthropic_citations(client, content_chunks, user_prompt): +def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chunks: list) -> tuple: """ + Sends a message to Anthropic Citations and extract and format the response + + Parameters + ---------- + client: An instance of the Anthropic API client used to make the request + user_prompt: The user's question or instruction to be processed by the model + content_chunks: A list of text chunks that provide context for the model to use during generation + + Returns + ------- + tuple + """ + message = client.messages.create( model="claude-3-5-haiku-20241022", max_tokens=1024, @@ -93,6 +107,7 @@ def get(self, request): query = Embeddings.objects.filter(upload_file__guid=guid) + # TODO: Format into the Anthropic API"s expected input format in the anthropic_citations function chunks = [{"type": "text", "text": chunk.text} for chunk in query] texts, cited_texts = anthropic_citations( From 9574e191b8f34c6b74a9e47384e7b29872fd5799 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Jun 2025 13:17:00 -0400 Subject: [PATCH 04/92] Update the README --- README.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1f4e8f01..a9ef29cd 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,28 @@ Tools used for development: ### Running Balancer for development -Running Balancer: -- Start Docker Desktop and run `docker compose up --build` -- The email and password are set in `server/api/management/commands/createsu.py` -- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) +Start the Postgres, Django REST, and React services by starting Docker Desktop and running `docker compose up --build` -Running pgAdmin: +#### Postgres +- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) - 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 first time you use `pgAdmin` after building the Docker containers you will need to register the server. The `Host name/address`, `Username` and `Password` are specified in `balancer-main/docker-compose.yml` +- You can use the below code snippet to query the database from a Jupyter notebook: + +``` +from sqlalchemy import create_engine +import pandas as pd + +engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") + +query = "SELECT * FROM api_embeddings;" + +df = pd.read_sql(query, engine) +``` + +#### Django REST +- The email and password are set in `server/api/management/commands/createsu.py` ## Architecture From d47606726c9628eeb01a774b3c374c6e808f3efa Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Jun 2025 13:26:11 -0400 Subject: [PATCH 05/92] HOTFIX ModuleNotFoundError --- 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 637607a2..93453607 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -2,7 +2,7 @@ import fitz -from server.api.services.openai_services import openAIServices +from ...services.openai_services import openAIServices # regular expression to match common research white paper titles. Created by Chat-gpt # requires at least 3 words, no dates, no version numbers. From b18391980bc75b76572d94442cbfe87267df8e4c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Jun 2025 19:38:19 -0400 Subject: [PATCH 06/92] Refactor evaluation script and GPT-4.1 Nano handler for cleaner logic and improved defaults evaluation/evals.py: Removed unused Reference column from input requirements and function signature. Added TODOs to improve flexibility and support defaults for instructions. Suggested running the script with uv for dependency management. llm_services.py: Refactored GPT41NanoHandler: Moved long prompt instructions into a class-level INSTRUCTIONS string. Simplified and cleaned up the handle_request method. Added default fallback behavior: uses INSTRUCTIONS if no query is provided. Removed duplicate and commented-out prompt scaffolding. --- evaluation/evals.py | 9 ++- server/api/services/llm_services.py | 102 +++++++++------------------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index f6e9bb3d..a263d3bc 100644 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -2,6 +2,8 @@ Evaluate LLM outputs using multiple metrics and compute associated costs """ +#TODO: Run this script with uv to manage dependencies + # TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs import sys @@ -25,7 +27,7 @@ def evaluate_response( - model_name: str, query: str, context: str, reference: str + model_name: str, query: str, context: str ) -> pd.DataFrame: """ Evaluates the response of a model to a given query and context, computes extractiveness metrics, token usage, and cost @@ -95,6 +97,7 @@ def evaluate_response( df_config.columns = df_config.columns.str.strip() # Check if the required columns are present + # TODO: Make this more flexible by allowing the user to use default instructions required_columns = ["Model Name", "Query"] if not all(col in df_config.columns for col in required_columns): raise ValueError( @@ -117,7 +120,7 @@ def evaluate_response( # Remove the trailing whitespace from column names df_reference.columns = df_reference.columns.str.strip() # Check if the required columns are present - required_columns = ["Context", "Reference"] + required_columns = ["Context"] if not all(col in df_reference.columns for col in required_columns): raise ValueError( f"Reference DataFrame must contain the following columns: {required_columns}" @@ -133,7 +136,7 @@ def evaluate_response( [ df_evals, evaluate_response( - row["Model Name"], row["Query"], row["Context"], row["Reference"] + row["Model Name"], row["Query"], row["Context"] ), ], axis=0, diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 7ab9c176..049451f1 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -187,98 +187,58 @@ def handle_request( class GPT41NanoHandler(BaseModelHandler): MODEL = "gpt-4.1-nano" + # Model Pricing: https://platform.openai.com/docs/pricing PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.10, "output": 0.40} - def __init__(self) -> None: - self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - - def handle_request( - self, query: str, context: str - ) -> tuple[str, dict[str, int], dict[str, float], float]: - """ - Handles the request to the GPT-4.1 Nano model - - Args: - query: The user query to be processed - context: The context or document content to be used - - """ - start_time = time.time() - # TODO: Add error handling for API requests and invalid responses - - # GPT 4.1 Prompting Guide: https://cookbook.openai.com/examples/gpt4-1_prompting_guide - - # Long context performance can degrade as more items are required to be retrieved, - # or perform complex reasoning that requires knowledge of the state of the entire context - - """ - - # Role and Objective - - - You are a seasoned physician or medical professional who treats patients with bipolar disorder - - You are analyzing medical research by processing peer-reviewed papers to extract key details - - # Instructions - - - Identify rules for medication inclusion or exclusion based on medical history or concerns - - - Only use the documents in the provided External Context to answer the User Query. - If you don't know the answer based on this context, you must respond - "I don't have the information needed to answer that", even if a user insists on you answering the question. - - - Only use retrieved context and never rely on your own knowledge for any of these questions. + # GPT 4.1 Prompting Guide: https://cookbook.openai.com/examples/gpt4-1_prompting_guide - - Do not discuss prohibited topics (politics, religion, controversial current events, - medical, legal, or financial advice, personal conversations, internal company operations, or criticism of any people or company). + # Long context performance can degrade as more items are required to be retrieved, + # or perform complex reasoning that requires knowledge of the state of the entire context - - Always follow the provided output format for new messages, including citations for any factual statements from retrieved policy documents. - - # Output Format - - The rule is history of suicide attempts. The type of rule is "INCLUDE". The reason is lithium is the - only medication on the market that has been proven to reduce suicidality in patients with bipolar disorder. - The medications for this rule are lithium. + INSTRUCTIONS = """ - The rule is weight gain concerns. The type of rule is "EXCLUDE". The reason is Seroquel, Risperdal, Abilify, and - Zyprexa are known for causing weight gain. The medications for this rule are Quetiapine, Aripiprazole, Olanzapine, Risperidone + # Role and Objective - For each rule you find, return a JSON object using the following format: + - You are a seasoned physician or medical professional who treats patients with bipolar disorder + - You are analyzing medical research by processing peer-reviewed papers to extract key details - { - "rule": "", - "type": "INCLUDE" or "EXCLUDE", - "reason": "", - "medications": ["", "", ...], - "source": "" - } + # Instructions - - When providing factual information from retrieved context, always include citations immediately after the relevant statement(s). - Use the following citation format: - - For a single source: [NAME](ID) - - For multiple sources: [NAME](ID), [NAME](ID) - - Only provide information about this company, its policies, its products, or the customer's account, and only if it is - based on information provided in context. Do not answer questions outside this scope. + - Identify rules for medication inclusion or exclusion based on medical history or concerns + - Only use retrieved context and never rely on your own knowledge for any of these questions. + - Always follow the provided output format for new messages including citations for any factual statements - # Examples + # Output Format + - When providing factual information from retrieved context, always include citations immediately after the relevant statement(s). - # Context - ID: 1 | TITLE: The Fox | CONTENT: The quick brown fox jumps over the lazy dog + """ - # Final instructions and prompt to think step by step + def __init__(self) -> None: + self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - - Identify rules for medication inclusion or exclusion based on medical history or concerns + def handle_request( + self, query: str, context: str + ) -> tuple[str, dict[str, int], dict[str, float], float]: + """ + Handles the request to the GPT-4.1 Nano model - - Only use the documents in the provided External Context to answer the User Query. - If you don't know the answer based on this context, you must respond - "I don't have the information needed to answer that", even if a user insists on you answering the question. + Args: + query: The user query to be processed + context: The context or document content to be used """ + # If no query is provided, use the default instructions + if not query: + query = self.INSTRUCTIONS + + start_time = time.time() + # TODO: Add error handling for API requests and invalid responses response = self.client.responses.create( model=self.MODEL, From 5ccb6a760c3904416f2688adf5787f58c18d84c4 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 26 Jun 2025 18:28:02 -0400 Subject: [PATCH 07/92] Add documentation on registering the Postgres server in pgAdmin --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9ef29cd..ff48725c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Start the Postgres, Django REST, and React services by starting Docker Desktop a - Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) - 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`, `Username` and `Password` are specified in `balancer-main/docker-compose.yml` + - The `Host name/address` is the Postgres server service name in the Docker Compose file + - The `Username` and `Password` are the Postgres server environment variables in the Docker Compose file - You can use the below code snippet to query the database from a Jupyter notebook: ``` From 4517f1c36319bd609b93010e78241394e26015ff Mon Sep 17 00:00:00 2001 From: Chris Annunziato Date: Mon, 30 Jun 2025 15:45:52 -0400 Subject: [PATCH 08/92] Added handleForm to MdNavBar so Balancer logo triggers the function --- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/Header/MdNavBar.tsx | 244 ++++++++++---------- 2 files changed, 125 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 30c704cd..4bc4df48 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -196,7 +196,7 @@ const Header: React.FC = ({ {/* */} {isAuthenticated ? authLinks() : guestLinks()} - + ); }; diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 10a5c1c8..30334fb3 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -1,146 +1,150 @@ -import { useState } from "react"; -import { Link } from "react-router-dom"; +import {useState} from "react"; +import {Link} from "react-router-dom"; // import { useLocation } from "react-router-dom"; import Chat from "./Chat"; // import logo from "../../assets/balancer.png"; import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; -import { useDispatch } from "react-redux"; -import { logout, AppDispatch } from "../../services/actions/auth"; +import {useDispatch} from "react-redux"; +import {logout, AppDispatch} from "../../services/actions/auth"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean; + handleForm: () => void; } const MdNavBar = (props: LoginFormProps) => { - const [nav, setNav] = useState(true); - // const { pathname } = useLocation(); - const [showChat, setShowChat] = useState(false); - const { isAuthenticated } = props; - const handleNav = () => { - setNav(!nav); - }; + const [nav, setNav] = useState(true); + // const { pathname } = useLocation(); + const [showChat, setShowChat] = useState(false); + const {isAuthenticated, handleForm} = props; + const handleNav = () => { + setNav(!nav); + }; - const dispatch = useDispatch(); + const dispatch = useDispatch(); - const logout_user = () => { - dispatch(logout()); - }; + const logout_user = () => { + dispatch(logout()); + }; - return ( -
- +
+ {nav && ( + logo + )} +
+
+
Balancer -
- {!nav && ( - logo +
+ {!nav && ( + logo + )} +
+
+ +
    +
  • + + About Balancer + +
  • +
  • + + Help + +
  • +
  • + + Medical Suggester + +
  • +
  • + + Medications List + +
  • +
  • + + Chat + +
  • +
  • + + Leave Feedback + +
  • + {isAuthenticated && +
  • + + Sign Out + +
  • + } +
+
+ {isAuthenticated && ( + )} -
- -
    -
  • - - About Balancer - -
  • -
  • - - Help - -
  • -
  • - - Medical Suggester - -
  • -
  • - - Medications List - -
  • -
  • - - Chat - -
  • -
  • - - Leave Feedback - -
  • - {isAuthenticated && -
  • - - Sign Out - -
  • - } -
- - {isAuthenticated && ( - - )} - - ); + ); }; export default MdNavBar; From 6c592be5f9430762bb19049da1cbc15650cfd489 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 3 Jul 2025 13:21:19 -0400 Subject: [PATCH 09/92] Update evaluation README with example scripts and remove obsolete Claude API client code from service module. --- evaluation/README.md | 169 ++++++++++++++++++++++++- server/api/services/llm_services.py | 187 +++++++--------------------- 2 files changed, 210 insertions(+), 146 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index a1d0ad70..5e088880 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -9,9 +9,10 @@ It supports batch evalaution via a configuration CSV and produces a detailed met ### Usage -This script evaluates LLM outputs using the `lighteval` library: https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks +This script evaluates LLM outputs using the `lighteval` library: +https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks -Ensure you have the `lighteval` library and any model SDKs (e.g., OpenAI, Anthropic) configured properly. +Ensure you have the `lighteval` library and any model SDKs (e.g., OpenAI) configured properly. ```bash @@ -21,17 +22,175 @@ python evals.py --config path/to/config.csv --reference path/to/reference.csv -- The arguments to the script are: - Path to the config CSV file: Must include the columns "Model Name" and "Query" + +``` +import pandas as pd + +# Define the data +data = [ + + { + "Model Name": "GPT_4O_MINI", + "Query": """ + You're analyzing medical text from multiple sources. Each chunk is labeled [chunk-X]. + + Act as a seasoned physician or medical professional who treats patients with bipolar disorder. + + Identify rules for medication inclusion or exclusion based on medical history or concerns. + + For each rule you find, return a JSON object using the following format: + + { + "rule": "", + "type": "INCLUDE" or "EXCLUDE", + "reason": "", + "medications": ["", "", ...], + "source": "" + } + + Only include rules that are explicitly stated or strongly implied in the chunk. + + Only use the chunks provided. If no rule is found in a chunk, skip it. + + Return the entire output as a JSON array. + """ + }, + + { + "Model Name": "GPT_41_NANO", + "Query": """ + + # Role and Objective + + - You are a seasoned physician or medical professional who is developing a bipolar disorder treatment algorithim + + - You are extracting bipolar medication decision points from a research paper that is chunked into multiple parts each labeled with an ID + + # Instructions + + - Identify decision points for bipolar medications + + - For each decision point you find, return a JSON object using the following format: + + { + "criterion": "", + "decision": "INCLUDE" or "EXCLUDE", + "medications": ["", "", ...], + "reason": "", + "sources": [""] + } + + + - Only extract bipolar medication decision points that are explicitly stated or strongly implied in the context and never rely on your own knowledge + + # Output Format + + - Return the extracted bipolar medication decision points as a JSON array and if no decision points are found in the context return an empty array + + # Example + + [ + { + "criterion": "History of suicide attempts", + "decision": "INCLUDE", + "medications": ["Lithium"], + "reason": "Lithium is the only medication on the market that has been proven to reduce suicidality in patients with bipolar disorder", + "sources": ["ID-0"] + }, + { + "criterion": "Weight gain concerns", + "decision": "EXCLUDE", + "medications": ["Quetiapine", "Aripiprazole", "Olanzapine", "Risperidone"], + "reason": "Seroquel, Risperdal, Abilify, and Zyprexa are known for causing weight gain", + "sources": ["ID-0", "ID-1", "ID-2"] + } + ] + + """ + + }, +] + +# Create DataFrame from records +df = pd.DataFrame.from_records(data) + +# Write to CSV +df.to_csv("~/Desktop/evals_config.csv", index=False) +``` + + - Path to the reference CSV file: Must include the columns "Context" and "Reference" + +``` +from sqlalchemy import create_engine +import pandas as pd + +engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") +# Filter out papers that shouldn't be used from local database +query = "SELECT * FROM api_embeddings WHERE date_of_upload > '2025-03-14';" +df = pd.read_sql(query, engine) + +df['formatted_chunk'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) +# Ensure the chunks are joined in order of chunk_number by sorting the DataFrame before grouping and joining +df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) +df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() +df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) +df_grouped.to_csv('~/Desktop/formatted_chunks.csv', index=False) +``` + - Path where the evaluation resuls will be saved +import pandas as pd +import matplotlib.pyplot as plt +import numpy as np + + +df = pd.read_csv("~/Desktop/evals_out-20250702.csv") + +# Define the metrics of interest +extractiveness_cols = ['Extractiveness Coverage', 'Extractiveness Density', 'Extractiveness Compression'] +token_cols = ['Input Token Usage', 'Output Token Usage'] +other_metrics = ['Cost (USD)', 'Duration (s)'] +all_metrics = extractiveness_cols + token_cols + other_metrics + +# Metric histograms by model +plt.style.use('default') +fig, axes = plt.subplots(len(all_metrics), 1, figsize=(12, 4 * len(all_metrics))) + +models = df['Model Name'].unique() +colors = plt.cm.Set3(np.linspace(0, 1, len(models))) + +for i, metric in enumerate(all_metrics): + ax = axes[i] if len(all_metrics) > 1 else axes + + # Create histogram for each model + for j, model in enumerate(models): + model_data = df[df['Model Name'] == model][metric] + ax.hist(model_data, alpha=0.7, label=model, bins=min(8, len(model_data)), + color=colors[j], edgecolor='black', linewidth=0.5) + + ax.set_title(f'{metric} Distribution by Model', fontsize=14, fontweight='bold') + ax.set_xlabel(metric, fontsize=12) + ax.set_ylabel('Frequency', fontsize=12) + ax.legend(title='Model', bbox_to_anchor=(1.05, 1), loc='upper left') + ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.show() + +#TODO: Compute count, min, quantiles and max by model +#TODO: Calculate efficiency metrics: Totel Token Usage, Cost per Token, Tokens per Second, Cost per Second + + + The script outputs a CSV with the following columns: * Evaluates LLM outputs for: - * Extractiveness Coverage - * Extractiveness Density - * Extractiveness Compression + * Extractiveness Coverage: Percentage of words in the summary that are part of an extractive fragment with the article + * Extractiveness Density: Average length of the extractive fragement to which each word in the summary belongs + * Extractiveness Compression: Word ratio between the article and the summary * Computes: diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 049451f1..4e55690d 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -7,10 +7,8 @@ import logging from abc import ABC, abstractmethod -import anthropic import openai - class BaseModelHandler(ABC): @abstractmethod def handle_request( @@ -18,136 +16,7 @@ def handle_request( ) -> tuple[str, dict[str, int], dict[str, float], float]: pass - -class ClaudeHaiku35CitationsHandler(BaseModelHandler): - MODEL = "claude-3-5-haiku-20241022" - # Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing - PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.80, "output": 4.00} - - def __init__(self) -> None: - self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) - - def handle_request( - self, query: str, context: str - ) -> tuple[str, dict[str, int], dict[str, float], float]: - """ - Handles the request to the Claude Haiku 3.5 model with citations enabled - - Args: - query: The user query to be processed - context: The context or document content to be used for citations - - """ - - start_time = time.time() - # TODO: Add error handling for API requests and invalid responses - message = self.client.messages.create( - model=self.MODEL, - max_tokens=1024, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": query}, - { - "type": "document", - "source": {"type": "content", "content": context}, - "citations": {"enabled": True}, - }, - ], - } - ], - ) - duration = time.time() - start_time - - # Response Structure: https://docs.anthropic.com/en/docs/build-with-claude/citations#response-structure - - text = [] - cited_text = [] - for content in message.to_dict()["content"]: - text.append(content["text"]) - if "citations" in content.keys(): - text.append( - " ".join( - [ - f"<{citation['start_block_index']} - {citation['end_block_index']}>" - for citation in content["citations"] - ] - ) - ) - cited_text.append( - " ".join( - [ - f"<{citation['start_block_index']} - {citation['end_block_index']}> {citation['cited_text']}" - for citation in content["citations"] - ] - ) - ) - - full_text = " ".join(text) - - return ( - full_text, - message.usage, - self.PRICING_DOLLARS_PER_MILLION_TOKENS, - duration, - ) - - -class ClaudeHaiku3Handler(BaseModelHandler): - MODEL = "claude-3-haiku-20240307" - # Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing - PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.25, "output": 1.25} - - def __init__(self) -> None: - self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY")) - - def handle_request( - self, query: str, context: str - ) -> tuple[str, dict[str, int], dict[str, float], float]: - """ - Handles the request to the Claude Haiku 3 model with citations disabled - - Args: - query: The user query to be processed - context: The context or document content to be used - - """ - - start_time = time.time() - # TODO: Add error handling for API requests and invalid responses - message = self.client.messages.create( - model=self.MODEL, - max_tokens=1024, - messages=[ - { - "role": "user", - "content": [ - {"type": "text", "text": query}, - { - "type": "document", - "source": {"type": "content", "content": context}, - "citations": {"enabled": False}, - }, - ], - } - ], - ) - duration = time.time() - start_time - - text = [] - for content in message.to_dict()["content"]: - text.append(content["text"]) - - full_text = " ".join(text) - - return ( - full_text, - message.usage, - self.PRICING_DOLLARS_PER_MILLION_TOKENS, - duration, - ) - + # Anthropic Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing class GPT4OMiniHandler(BaseModelHandler): MODEL = "gpt-4o-mini" @@ -174,6 +43,7 @@ def handle_request( model=self.MODEL, instructions=query, input=context, + temperature=0.0 ) duration = time.time() - start_time @@ -196,24 +66,55 @@ class GPT41NanoHandler(BaseModelHandler): # Long context performance can degrade as more items are required to be retrieved, # or perform complex reasoning that requires knowledge of the state of the entire context + # + INSTRUCTIONS = """ # Role and Objective + + - You are a seasoned physician or medical professional who is developing a bipolar disorder treatment algorithim - - You are a seasoned physician or medical professional who treats patients with bipolar disorder - - You are analyzing medical research by processing peer-reviewed papers to extract key details + - You are extracting bipolar medication decision points from a research paper that is chunked into multiple parts each labeled with an ID # Instructions - - Identify rules for medication inclusion or exclusion based on medical history or concerns + - Identify decision points for bipolar medications - - Only use retrieved context and never rely on your own knowledge for any of these questions. - - Always follow the provided output format for new messages including citations for any factual statements + - For each decision point you find, return a JSON object using the following format: + + { + "criterion": "", + "decision": "INCLUDE" or "EXCLUDE", + "medications": ["", "", ...], + "reason": "", + "sources": [""] + } - # Output Format - - When providing factual information from retrieved context, always include citations immediately after the relevant statement(s). + - Only extract bipolar medication decision points that are explicitly stated or strongly implied in the context and never rely on your own knowledge + + # Output Format + - Return the extracted bipolar medication decision points as a JSON array and if no decision points are found in the context return an empty array + + # Example + + [ + { + "criterion": "History of suicide attempts", + "decision": "INCLUDE", + "medications": ["Lithium"], + "reason": "Lithium is the only medication on the market that has been proven to reduce suicidality in patients with bipolar disorder", + "sources": ["ID-0"] + }, + { + "criterion": "Weight gain concerns", + "decision": "EXCLUDE", + "medications": ["Quetiapine", "Aripiprazole", "Olanzapine", "Risperidone"], + "reason": "Seroquel, Risperdal, Abilify, and Zyprexa are known for causing weight gain", + "sources": ["ID-0", "ID-1", "ID-2"] + } + ] """ @@ -244,6 +145,7 @@ def handle_request( model=self.MODEL, instructions=query, input=context, + temperature=0.0 ) duration = time.time() - start_time @@ -256,9 +158,12 @@ def handle_request( class ModelFactory: + + #TODO: Define structured fields to extract from unstructured input data + #https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses&example=structured-data#examples + + HANDLERS = { - "CLAUDE_HAIKU_3_5_CITATIONS": ClaudeHaiku35CitationsHandler, - "CLAUDE_HAIKU_3": ClaudeHaiku3Handler, "GPT_4O_MINI": GPT4OMiniHandler, "GPT_41_NANO": GPT41NanoHandler, } From 6e41cf7dc23a47c19575526d3b9837c51553b97a Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 3 Jul 2025 13:22:57 -0400 Subject: [PATCH 10/92] Refactor README.md: add TODOs --- evaluation/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 5e088880..aab19564 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -179,13 +179,13 @@ plt.tight_layout() plt.show() #TODO: Compute count, min, quantiles and max by model -#TODO: Calculate efficiency metrics: Totel Token Usage, Cost per Token, Tokens per Second, Cost per Second - - +#TODO: Calculate efficiency metrics: Total Token Usage, Cost per Token, Tokens per Second, Cost per Second The script outputs a CSV with the following columns: +#TODO: Summarize https://aclanthology.org/N18-1065.pdf + * Evaluates LLM outputs for: * Extractiveness Coverage: Percentage of words in the summary that are part of an extractive fragment with the article @@ -196,4 +196,4 @@ The script outputs a CSV with the following columns: * Token usage (input/output) * Estimated cost in USD - * Duration (in seconds) + * Duration (in seconds) \ No newline at end of file From c483e693a78990f2fbb6f86a410eb41c1e21df27 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 7 Jul 2025 21:00:44 -0400 Subject: [PATCH 11/92] DOC Add TODO items, update comments and improve code comments for clarity --- evaluation/README.md | 35 +++++++++++++++++++++++++++++ server/api/services/llm_services.py | 8 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/evaluation/README.md b/evaluation/README.md index aab19564..f38097e6 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -1,6 +1,8 @@ # Evaluations +#TODO: Open AI evals documentaiton: https://platform.openai.com/docs/guides/evals + ## LLM Output Evaluator The `evals` script evaluates the outputs of Large Language Models (LLMs) and estimates the associated token usage and cost. @@ -12,6 +14,8 @@ It supports batch evalaution via a configuration CSV and produces a detailed met This script evaluates LLM outputs using the `lighteval` library: https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks +##TODO: Use uv to execute scripts without manually manging enviornments https://docs.astral.sh/uv/guides/scripts/ + Ensure you have the `lighteval` library and any model SDKs (e.g., OpenAI) configured properly. @@ -138,6 +142,37 @@ df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks' df_grouped.to_csv('~/Desktop/formatted_chunks.csv', index=False) ``` +``` +echo 'export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc + +createdb backupDBBalancer07012025 +pg_restore -v -d backupDBBalancer07012025 ~/Downloads/backupDBBalancer07012025.sql + +pip install psycopg2-binary + +from sqlalchemy import create_engine +import pandas as pd + +# Alternative: Standard psycopg2 connection (if you get psycopg2 working) +# engine = create_engine("postgresql://sahildshah@localhost:5432/backupDBBalancer07012025") + +# Fixed the variable name (was "database query", now "query") +query = "SELECT * FROM api_embeddings;" + +# Execute the query and load into DataFrame +df = pd.read_sql(query, engine) + +df['formatted_chunk'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) +# Ensure the chunks are joined in order of chunk_number by sorting the DataFrame before grouping and joining +df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) +df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() +df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) +df_grouped.to_csv('~/Desktop/formatted_chunks.csv', index=False) +``` + + + - Path where the evaluation resuls will be saved import pandas as pd diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 4e55690d..55627cf1 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -16,6 +16,8 @@ def handle_request( ) -> tuple[str, dict[str, int], dict[str, float], float]: pass +# LLM Pricing Calculator: https://www.llm-prices.com/ + # Anthropic Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing class GPT4OMiniHandler(BaseModelHandler): @@ -78,7 +80,7 @@ class GPT41NanoHandler(BaseModelHandler): # Instructions - - Identify decision points for bipolar medications + - Identify decision points for bipolar medications #TODO: "pharmacological and procedurl interventions" - For each decision point you find, return a JSON object using the following format: @@ -88,11 +90,15 @@ class GPT41NanoHandler(BaseModelHandler): "medications": ["", "", ...], "reason": "", "sources": [""] + "hierarchy": Primary: Contraindictions for allergies + "override" Exclude for allergy } - Only extract bipolar medication decision points that are explicitly stated or strongly implied in the context and never rely on your own knowledge + - TODO: Test against medication indication file + # Output Format - Return the extracted bipolar medication decision points as a JSON array and if no decision points are found in the context return an empty array From dcb3b5a8f4d1be0586c6b502cc34e0c6f8019dfb Mon Sep 17 00:00:00 2001 From: ChrisTineo Date: Tue, 8 Jul 2025 08:44:10 -0400 Subject: [PATCH 12/92] Update deployment.yml --- deploy/manifests/balancer/base/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/manifests/balancer/base/deployment.yml b/deploy/manifests/balancer/base/deployment.yml index 54efeedf..919d10c9 100644 --- a/deploy/manifests/balancer/base/deployment.yml +++ b/deploy/manifests/balancer/base/deployment.yml @@ -17,7 +17,7 @@ spec: app: balancer spec: containers: - - image: chrissst/balancer + - image: ghcr.io/codeforphilly/balancer-main/backend name: balancer envFrom: - configMapRef: From eafa48d1d43335fed3afcd2887d11cca7ed83b0c Mon Sep 17 00:00:00 2001 From: ChrisTineo Date: Tue, 8 Jul 2025 08:44:38 -0400 Subject: [PATCH 13/92] Update kustomization.yml --- deploy/manifests/balancer/overlays/dev/kustomization.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yml b/deploy/manifests/balancer/overlays/dev/kustomization.yml index 46197140..92a6001b 100644 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yml +++ b/deploy/manifests/balancer/overlays/dev/kustomization.yml @@ -5,7 +5,7 @@ resources: - "../../base" images: - - name: chrissst/balancer - newTag: v1 - + - name: ghcr.io/codeforphilly/balancer-main/backend + newTag: "1.0.2" + namespace: balancer From c03d990a21fd60fc219fba5a3d9c3b7c4d98e2b5 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 9 Jul 2025 16:27:17 -0400 Subject: [PATCH 14/92] Update README with detailed usage instructions and enhance evals.py to include environment setup and dependencies --- evaluation/README.md | 193 +++++++++------------------- evaluation/evals.py | 18 ++- server/api/services/llm_services.py | 4 +- 3 files changed, 80 insertions(+), 135 deletions(-) mode change 100644 => 100755 evaluation/evals.py diff --git a/evaluation/README.md b/evaluation/README.md index f38097e6..5e95aaab 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -1,31 +1,36 @@ - # Evaluations -#TODO: Open AI evals documentaiton: https://platform.openai.com/docs/guides/evals - ## LLM Output Evaluator -The `evals` script evaluates the outputs of Large Language Models (LLMs) and estimates the associated token usage and cost. +The `evals` script evaluates the outputs of Large Language Models (LLMs) and estimates the associated token usage and cost -It supports batch evalaution via a configuration CSV and produces a detailed metrics report in CSV format. +This script helps teams compare LLM outputs using extractiveness metrics, token usage, and cost. It is especially useful for evaluating multiple models over a batch of queries and reference answers. -### Usage +It supports batch evaluation via a configuration CSV and produces a detailed metrics report in CSV format. -This script evaluates LLM outputs using the `lighteval` library: -https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks +### Usage -##TODO: Use uv to execute scripts without manually manging enviornments https://docs.astral.sh/uv/guides/scripts/ +Execute [using `uv` to manage depenendices](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: -Ensure you have the `lighteval` library and any model SDKs (e.g., OpenAI) configured properly. +```sh +uv run evals.py --config path/to/ --reference path/to/ --output path/to/ +``` +Execute without using uv run by ensuring it is executable: -```bash -python evals.py --config path/to/config.csv --reference path/to/reference.csv --output path/to/results.csv +```sh +./evals.py --config path/to/ --reference path/to/ --output path/to/ ``` The arguments to the script are: - Path to the config CSV file: Must include the columns "Model Name" and "Query" +- Path to the reference CSV file: Must include the columns "Context" and "Reference" +- Path where the evaluation results will be saved + +### Configuration File + +Generate the config CSV file: ``` import pandas as pd @@ -34,84 +39,13 @@ import pandas as pd data = [ { - "Model Name": "GPT_4O_MINI", - "Query": """ - You're analyzing medical text from multiple sources. Each chunk is labeled [chunk-X]. - - Act as a seasoned physician or medical professional who treats patients with bipolar disorder. - - Identify rules for medication inclusion or exclusion based on medical history or concerns. - - For each rule you find, return a JSON object using the following format: - - { - "rule": "", - "type": "INCLUDE" or "EXCLUDE", - "reason": "", - "medications": ["", "", ...], - "source": "" - } - - Only include rules that are explicitly stated or strongly implied in the chunk. - - Only use the chunks provided. If no rule is found in a chunk, skip it. - - Return the entire output as a JSON array. - """ + "Model Name": "", + "Query": """""" }, { - "Model Name": "GPT_41_NANO", - "Query": """ - - # Role and Objective - - - You are a seasoned physician or medical professional who is developing a bipolar disorder treatment algorithim - - - You are extracting bipolar medication decision points from a research paper that is chunked into multiple parts each labeled with an ID - - # Instructions - - - Identify decision points for bipolar medications - - - For each decision point you find, return a JSON object using the following format: - - { - "criterion": "", - "decision": "INCLUDE" or "EXCLUDE", - "medications": ["", "", ...], - "reason": "", - "sources": [""] - } - - - - Only extract bipolar medication decision points that are explicitly stated or strongly implied in the context and never rely on your own knowledge - - # Output Format - - - Return the extracted bipolar medication decision points as a JSON array and if no decision points are found in the context return an empty array - - # Example - - [ - { - "criterion": "History of suicide attempts", - "decision": "INCLUDE", - "medications": ["Lithium"], - "reason": "Lithium is the only medication on the market that has been proven to reduce suicidality in patients with bipolar disorder", - "sources": ["ID-0"] - }, - { - "criterion": "Weight gain concerns", - "decision": "EXCLUDE", - "medications": ["Quetiapine", "Aripiprazole", "Olanzapine", "Risperidone"], - "reason": "Seroquel, Risperdal, Abilify, and Zyprexa are known for causing weight gain", - "sources": ["ID-0", "ID-1", "ID-2"] - } - ] - - """ - + "Model Name": "", + "Query": """""" }, ] @@ -119,68 +53,79 @@ data = [ df = pd.DataFrame.from_records(data) # Write to CSV -df.to_csv("~/Desktop/evals_config.csv", index=False) +df.to_csv("", index=False) ``` -- Path to the reference CSV file: Must include the columns "Context" and "Reference" +### Reference File + +Generate the reference file by connecting to a database of references + +Connect to the Postgres database of your local Balancer instance: ``` from sqlalchemy import create_engine -import pandas as pd engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") -# Filter out papers that shouldn't be used from local database -query = "SELECT * FROM api_embeddings WHERE date_of_upload > '2025-03-14';" -df = pd.read_sql(query, engine) - -df['formatted_chunk'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) -# Ensure the chunks are joined in order of chunk_number by sorting the DataFrame before grouping and joining -df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) -df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() -df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) -df_grouped.to_csv('~/Desktop/formatted_chunks.csv', index=False) ``` +Connect to the Postgres database of the production Balancer instance using a SQL file: + ``` +# Install Postgres.app and add binaries to the PATH echo 'export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH"' >> ~/.zshrc -source ~/.zshrc -createdb backupDBBalancer07012025 -pg_restore -v -d backupDBBalancer07012025 ~/Downloads/backupDBBalancer07012025.sql +createdb +pg_restore -v -d .sql -pip install psycopg2-binary +engine = create_engine("postgresql://@localhost:5432/") +``` -from sqlalchemy import create_engine -import pandas as pd +Generate the reference CSV file: -# Alternative: Standard psycopg2 connection (if you get psycopg2 working) -# engine = create_engine("postgresql://sahildshah@localhost:5432/backupDBBalancer07012025") +``` +import pandas as pd -# Fixed the variable name (was "database query", now "query") query = "SELECT * FROM api_embeddings;" - -# Execute the query and load into DataFrame df = pd.read_sql(query, engine) df['formatted_chunk'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) + # Ensure the chunks are joined in order of chunk_number by sorting the DataFrame before grouping and joining df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() + df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) -df_grouped.to_csv('~/Desktop/formatted_chunks.csv', index=False) +df_grouped.to_csv('', index=False) ``` +### Output File + +The script outputs a CSV with the following columns: + +Extractiveness Metrics based on the methodology from: https://aclanthology.org/N18-1065.pdf + +* Evaluates LLM outputs for: + + * Extractiveness Coverage: Percentage of words in the summary that are part of an extractive fragment with the article + * Extractiveness Density: Average length of the extractive fragment to which each word in the summary belongs + * Extractiveness Compression: Word ratio between the article and the summary + +* Computes: + * Token usage (input/output) + * Estimated cost in USD + * Duration (in seconds) -- Path where the evaluation resuls will be saved +Exploratory data analysis: + +``` import pandas as pd import matplotlib.pyplot as plt import numpy as np - -df = pd.read_csv("~/Desktop/evals_out-20250702.csv") +df = pd.read_csv("") # Define the metrics of interest extractiveness_cols = ['Extractiveness Coverage', 'Extractiveness Density', 'Extractiveness Compression'] @@ -213,22 +158,6 @@ for i, metric in enumerate(all_metrics): plt.tight_layout() plt.show() -#TODO: Compute count, min, quantiles and max by model #TODO: Calculate efficiency metrics: Total Token Usage, Cost per Token, Tokens per Second, Cost per Second - -The script outputs a CSV with the following columns: - -#TODO: Summarize https://aclanthology.org/N18-1065.pdf - -* Evaluates LLM outputs for: - - * Extractiveness Coverage: Percentage of words in the summary that are part of an extractive fragment with the article - * Extractiveness Density: Average length of the extractive fragement to which each word in the summary belongs - * Extractiveness Compression: Word ratio between the article and the summary - -* Computes: - - * Token usage (input/output) - * Estimated cost in USD - * Duration (in seconds) \ No newline at end of file +``` \ No newline at end of file diff --git a/evaluation/evals.py b/evaluation/evals.py old mode 100644 new mode 100755 index a263d3bc..9e597d3f --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -1,10 +1,24 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = "==3.11.11" +# dependencies = [ +# "pandas==2.2.3", +# "lighteval==0.10.0", +# "openai==1.83.0" +# ] +# /// + """ Evaluate LLM outputs using multiple metrics and compute associated costs """ -#TODO: Run this script with uv to manage dependencies +#This script evaluates LLM outputs using the `lighteval` library +#https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks + +#This script uses Python 3.11 where prebuilt wheels for `sentencepiece` exist + -# TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs +#TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs import sys import os diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 55627cf1..7137f026 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -17,11 +17,13 @@ def handle_request( pass # LLM Pricing Calculator: https://www.llm-prices.com/ +# TODO: Add support for more models and their pricing - # Anthropic Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing +# Anthropic Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing class GPT4OMiniHandler(BaseModelHandler): MODEL = "gpt-4o-mini" + # TODO: Get the latest model pricing from OpenAI's API or documentation # Model Pricing: https://platform.openai.com/docs/pricing PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.15, "output": 0.60} From d012e23a2c88a22984f932d8a536ebaf99bcba51 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Thu, 10 Jul 2025 22:56:05 -0400 Subject: [PATCH 15/92] ADD COLUMN for med sources table --- server/api/admin.py | 3 +- ...rule_medications_medrulesource_and_more.py | 48 +++++++++++++++++++ server/api/models/model_medRule.py | 17 ++++++- server/entrypoint.sh | 4 +- 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py diff --git a/server/api/admin.py b/server/api/admin.py index 5b92d838..4f1edbdd 100644 --- a/server/api/admin.py +++ b/server/api/admin.py @@ -12,7 +12,6 @@ @admin.register(MedRule) class MedRuleAdmin(admin.ModelAdmin): list_display = ['rule_type', 'history_type', 'label'] - filter_horizontal = ['medications', 'sources'] search_fields = ['label', 'history_type', 'reason'] @@ -22,7 +21,7 @@ class MedicationAdmin(admin.ModelAdmin): @admin.register(Medication) -class MedicationAdmin(admin.ModelAdmin): # noqa: F811 +class MedicationAdmin(admin.ModelAdmin): # noqa: F811 list_display = ['name', 'benefits', 'risks'] diff --git a/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py b/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py new file mode 100644 index 00000000..92ba18af --- /dev/null +++ b/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py @@ -0,0 +1,48 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0011_embeddings_publication_embeddings_publication_date_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='MedRuleSource', + fields=[ + ('id', models.BigAutoField(auto_created=True, + primary_key=True, serialize=False, verbose_name='ID')), + ('embedding', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.embeddings')), + ('medication', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.medication')), + ('medrule', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.medrule')), + ], + options={ + 'db_table': 'api_medrule_sources', + 'unique_together': {('medrule', 'embedding', 'medication')}, + }, + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AlterField( + model_name='medrule', + name='sources', + field=models.ManyToManyField( + blank=True, + related_name='med_rules', + through='api.MedRuleSource', + to='api.embeddings' + ), + ), + migrations.RemoveField( + model_name='medrule', + name='medications', + ), + ] + ), + ] diff --git a/server/api/models/model_medRule.py b/server/api/models/model_medRule.py index 46712c38..9b69b2da 100644 --- a/server/api/models/model_medRule.py +++ b/server/api/models/model_medRule.py @@ -11,12 +11,14 @@ class MedRule(models.Model): history_type = models.CharField(max_length=255) reason = models.TextField(blank=True, null=True) label = models.CharField(max_length=255, blank=True, null=True) - medications = models.ManyToManyField(Medication, related_name='med_rules') + sources = models.ManyToManyField( Embeddings, related_name='med_rules', - blank=True + blank=True, + through='MedRuleSource' ) + explanation = models.TextField(blank=True, null=True) class Meta: @@ -25,3 +27,14 @@ class Meta: def __str__(self): return f"{self.rule_type} - {self.label}" + + +class MedRuleSource(models.Model): + medrule = models.ForeignKey(MedRule, on_delete=models.CASCADE) + embedding = models.ForeignKey(Embeddings, on_delete=models.CASCADE) + medication = models.ForeignKey( + Medication, on_delete=models.CASCADE) + + class Meta: + db_table = 'api_medrule_sources' + unique_together = ('medrule', 'embedding', 'medication') diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 1651e065..20db6c2e 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -11,8 +11,8 @@ then echo "PostgreSQL started" fi -python manage.py makemigrations api --no-input -# python manage.py flush --no-input +# python manage.py makemigrations api +# # python manage.py flush --no-input python manage.py migrate # create superuser for postgre admin on start up python manage.py createsu From f64367b586543379e2f4635e8031347499982fe8 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Thu, 10 Jul 2025 23:55:31 -0400 Subject: [PATCH 16/92] hoping this fix all the errors this table is causing. --- ...rule_medications_medrulesource_and_more.py | 37 ++++++++++-------- .../migrations/0013_medrule_medications.py | 26 +++++++++++++ server/api/models/model_medRule.py | 38 ++++++++++++------- 3 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 server/api/migrations/0013_medrule_medications.py diff --git a/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py b/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py index 92ba18af..281271ed 100644 --- a/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py +++ b/server/api/migrations/0012_remove_medrule_medications_medrulesource_and_more.py @@ -9,22 +9,27 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='MedRuleSource', - fields=[ - ('id', models.BigAutoField(auto_created=True, - primary_key=True, serialize=False, verbose_name='ID')), - ('embedding', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='api.embeddings')), - ('medication', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='api.medication')), - ('medrule', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='api.medrule')), - ], - options={ - 'db_table': 'api_medrule_sources', - 'unique_together': {('medrule', 'embedding', 'medication')}, - }, + migrations.SeparateDatabaseAndState( + database_operations=[], # Don't create DB table + state_operations=[ + migrations.CreateModel( + name='MedRuleSource', + fields=[ + ('id', models.BigAutoField(auto_created=True, + primary_key=True, serialize=False, verbose_name='ID')), + ('medrule', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.medrule')), + ('embedding', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.embeddings')), + ('medication', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to='api.medication')), + ], + options={ + 'db_table': 'api_medrule_sources', + 'unique_together': {('medrule', 'embedding', 'medication')}, + }, + ), + ] ), migrations.SeparateDatabaseAndState( database_operations=[], diff --git a/server/api/migrations/0013_medrule_medications.py b/server/api/migrations/0013_medrule_medications.py new file mode 100644 index 00000000..15dea0ed --- /dev/null +++ b/server/api/migrations/0013_medrule_medications.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.3 on 2025-07-11 03:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_remove_medrule_medications_medrulesource_and_more'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name='medrule', + name='medications', + field=models.ManyToManyField( + related_name='med_rules', + to='api.medication', + ), + ), + ] + ), + ] diff --git a/server/api/models/model_medRule.py b/server/api/models/model_medRule.py index 9b69b2da..3a48c2bc 100644 --- a/server/api/models/model_medRule.py +++ b/server/api/models/model_medRule.py @@ -4,37 +4,47 @@ class MedRule(models.Model): - rule_type = models.CharField( - max_length=7, - choices=[('INCLUDE', 'Include'), ('EXCLUDE', 'Exclude')] - ) + RULE_TYPE_CHOICES = [ + ('INCLUDE', 'Include'), + ('EXCLUDE', 'Exclude'), + ] + + rule_type = models.CharField(max_length=7, choices=RULE_TYPE_CHOICES) history_type = models.CharField(max_length=255) reason = models.TextField(blank=True, null=True) label = models.CharField(max_length=255, blank=True, null=True) + explanation = models.TextField(blank=True, null=True) + + medications = models.ManyToManyField( + Medication, + related_name='med_rules' + ) sources = models.ManyToManyField( Embeddings, related_name='med_rules', blank=True, - through='MedRuleSource' + through='api.MedRuleSource' # Correct fully-qualified through model reference ) - explanation = models.TextField(blank=True, null=True) - class Meta: db_table = 'api_medrule' - unique_together = ['rule_type', 'history_type'] + # list of tuples is preferred + unique_together = [('rule_type', 'history_type')] def __str__(self): - return f"{self.rule_type} - {self.label}" + return f"{self.rule_type} - {self.label or 'Unnamed'}" class MedRuleSource(models.Model): - medrule = models.ForeignKey(MedRule, on_delete=models.CASCADE) - embedding = models.ForeignKey(Embeddings, on_delete=models.CASCADE) - medication = models.ForeignKey( - Medication, on_delete=models.CASCADE) + medrule = models.ForeignKey('api.MedRule', on_delete=models.CASCADE) + embedding = models.ForeignKey('api.Embeddings', on_delete=models.CASCADE) + medication = models.ForeignKey('api.Medication', on_delete=models.CASCADE) class Meta: db_table = 'api_medrule_sources' - unique_together = ('medrule', 'embedding', 'medication') + # list of tuples + unique_together = [('medrule', 'embedding', 'medication')] + + def __str__(self): + return f"Rule {self.medrule_id} | Embedding {self.embedding_id} | Medication {self.medication_id}" From e0c04795ea56f0d53cea57ca714e965461aa2234 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Fri, 11 Jul 2025 00:32:35 -0400 Subject: [PATCH 17/92] update --- .../src/pages/RulesManager/RulesManager.tsx | 31 ++++++++---- server/api/views/medRules/serializers.py | 49 +++++++++++++------ 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index 8f2de0b1..be4980d4 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -11,15 +11,19 @@ interface Medication { risks: string; } +interface MedicationSource { + medication: Medication; + sources: any[]; +} + interface MedRule { id: number; rule_type: string; history_type: string; reason: string; label: string; - medications: Medication[]; - sources: any[]; explanation: string | null; + medication_sources: MedicationSource[]; } interface MedRulesResponse { @@ -96,8 +100,11 @@ function RulesManager() { return newSet; }); }; - - const renderMedicationDetails = (medication: Medication, rule: MedRule) => { + const renderMedicationDetails = ( + medication: Medication, + rule: MedRule, + sources: any[] + ) => { if (!medication) return null; const medKey = `${rule.id}-${medication.name}`; @@ -171,9 +178,9 @@ function RulesManager() {
Sources:
- {rule.sources && rule.sources.length > 0 ? ( + {sources && sources.length > 0 ? (
    - {rule.sources.map((source, index) => ( + {sources.map((source, index) => (
  • @@ -228,10 +235,14 @@ function RulesManager() { Medications:
    - {Array.isArray(rule.medications) && - rule.medications.length > 0 ? ( - rule.medications.map((med) => - renderMedicationDetails(med, rule) + {Array.isArray(rule.medication_sources) && + rule.medication_sources.length > 0 ? ( + rule.medication_sources.map((medSrc) => + renderMedicationDetails( + medSrc.medication, + rule, + medSrc.sources + ) ) ) : (

    diff --git a/server/api/views/medRules/serializers.py b/server/api/views/medRules/serializers.py index da09f65c..7638013c 100644 --- a/server/api/views/medRules/serializers.py +++ b/server/api/views/medRules/serializers.py @@ -1,8 +1,9 @@ from rest_framework import serializers -from ...models.model_medRule import MedRule +from ...models.model_medRule import MedRule, MedRuleSource +from ..listMeds.models import Medication from ..listMeds.serializers import MedicationSerializer from ...models.model_embeddings import Embeddings -from ..listMeds.models import Medication + class EmbeddingsSerializer(serializers.ModelSerializer): class Meta: @@ -10,21 +11,39 @@ class Meta: fields = ['guid', 'name', 'text', 'page_num', 'chunk_number'] +class MedicationWithSourcesSerializer(serializers.Serializer): + medication = MedicationSerializer() + sources = EmbeddingsSerializer(many=True) + + class MedRuleSerializer(serializers.ModelSerializer): - medications = MedicationSerializer(many=True, read_only=True) - medication_ids = serializers.PrimaryKeyRelatedField( - many=True, write_only=True, queryset=Medication.objects.all(), source='medications' - ) - sources = EmbeddingsSerializer(many=True, read_only=True) - source_ids = serializers.PrimaryKeyRelatedField( - many=True, write_only=True, queryset=Embeddings.objects.all(), source='sources' - ) + medication_sources = serializers.SerializerMethodField() class Meta: model = MedRule fields = [ - 'id', 'rule_type', 'history_type', 'reason', 'label', - 'medications', 'medication_ids', - 'sources', 'source_ids', - 'explanation' - ] \ No newline at end of file + 'id', 'rule_type', 'history_type', 'reason', 'label', 'explanation', + 'medication_sources' + ] + + def get_medication_sources(self, obj): + + medrule_sources = MedRuleSource.objects.filter( + medrule=obj).select_related('medication', 'embedding') + + med_to_sources = {} + for ms in medrule_sources: + if ms.medication.id not in med_to_sources: + med_to_sources[ms.medication.id] = { + 'medication': ms.medication, + 'sources': [] + } + med_to_sources[ms.medication.id]['sources'].append(ms.embedding) + + return [ + { + 'medication': MedicationSerializer(data['medication']).data, + 'sources': EmbeddingsSerializer(data['sources'], many=True).data + } + for data in med_to_sources.values() + ] From 4f8cbad2803381fbd9c0078b55a5a0ba43ca1fc9 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 11 Jul 2025 12:04:13 -0400 Subject: [PATCH 18/92] Update README to clarify the purpose and usage of the script, --- evaluation/README.md | 123 ++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 65 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 5e95aaab..c06353f4 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -1,63 +1,27 @@ # Evaluations -## LLM Output Evaluator +## `evals`: LLM evaluations to test and improve model outputs -The `evals` script evaluates the outputs of Large Language Models (LLMs) and estimates the associated token usage and cost +LLM evals test a prompt with a set of test data by scoring each item in the data set -This script helps teams compare LLM outputs using extractiveness metrics, token usage, and cost. It is especially useful for evaluating multiple models over a batch of queries and reference answers. +To test Balancer's structured text extraction of medication rules, `evals` computes: -It supports batch evaluation via a configuration CSV and produces a detailed metrics report in CSV format. +[Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): -### Usage +* Extractiveness Coverage: + - Percentage of words in the summary that are part of an extractive fragment with the article +* Extractiveness Density: + - Average length of the extractive fragment to which each word in the summary belongs +* Extractiveness Compression: + - Word ratio between the article and the summary -Execute [using `uv` to manage depenendices](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: - -```sh -uv run evals.py --config path/to/ --reference path/to/ --output path/to/ -``` - -Execute without using uv run by ensuring it is executable: - -```sh -./evals.py --config path/to/ --reference path/to/ --output path/to/ -``` - -The arguments to the script are: - -- Path to the config CSV file: Must include the columns "Model Name" and "Query" -- Path to the reference CSV file: Must include the columns "Context" and "Reference" -- Path where the evaluation results will be saved - -### Configuration File - -Generate the config CSV file: - -``` -import pandas as pd - -# Define the data -data = [ - - { - "Model Name": "", - "Query": """""" - }, - - { - "Model Name": "", - "Query": """""" - }, -] - -# Create DataFrame from records -df = pd.DataFrame.from_records(data) - -# Write to CSV -df.to_csv("", index=False) -``` +API usage: +* Token usage (input/output) +* Estimated cost in USD +* Duration (in seconds) -### Reference File +### Test Data: Generate the reference file by connecting to a database of references @@ -77,7 +41,10 @@ echo 'export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH createdb pg_restore -v -d .sql +``` +``` +from sqlalchemy import create_engine engine = create_engine("postgresql://@localhost:5432/") ``` @@ -99,26 +66,54 @@ df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks' df_grouped.to_csv('', index=False) ``` -### Output File -The script outputs a CSV with the following columns: +### Running an Evaluation -Extractiveness Metrics based on the methodology from: https://aclanthology.org/N18-1065.pdf +#### Test Input: Bulk model and prompt experimentation -* Evaluates LLM outputs for: +Compare the results of many different prompts and models at once - * Extractiveness Coverage: Percentage of words in the summary that are part of an extractive fragment with the article - * Extractiveness Density: Average length of the extractive fragment to which each word in the summary belongs - * Extractiveness Compression: Word ratio between the article and the summary +``` +import pandas as pd -* Computes: +# Define the data +data = [ - * Token usage (input/output) - * Estimated cost in USD - * Duration (in seconds) + { + "Model Name": "", + "Query": """""" + }, + { + "Model Name": "", + "Query": """""" + }, +] -Exploratory data analysis: +# Create DataFrame from records +df = pd.DataFrame.from_records(data) + +# Write to CSV +df.to_csv("", index=False) +``` + + +#### Execute on the command line + + +Execute [using `uv` to manage depenendices](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: + +```sh +uv run evals.py --config path/to/ --reference path/to/ --output path/to/ +``` + +Execute without using uv run by ensuring it is executable: + +```sh +./evals.py --config path/to/ --reference path/to/ --output path/to/ +``` + +### Analyzing Test Results ``` import pandas as pd @@ -158,6 +153,4 @@ for i, metric in enumerate(all_metrics): plt.tight_layout() plt.show() -#TODO: Calculate efficiency metrics: Total Token Usage, Cost per Token, Tokens per Second, Cost per Second - ``` \ No newline at end of file From fe302b5ef5b6c254adcd9b9065e3552563b8403d Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 11 Jul 2025 12:07:07 -0400 Subject: [PATCH 19/92] ADD TODOs --- evaluation/evals.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 9e597d3f..e13e79c8 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -3,7 +3,7 @@ # requires-python = "==3.11.11" # dependencies = [ # "pandas==2.2.3", -# "lighteval==0.10.0", +# "lighteval==0.10.0", # "openai==1.83.0" # ] # /// @@ -12,13 +12,13 @@ Evaluate LLM outputs using multiple metrics and compute associated costs """ -#This script evaluates LLM outputs using the `lighteval` library -#https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks +# This script evaluates LLM outputs using the `lighteval` library +# https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks -#This script uses Python 3.11 where prebuilt wheels for `sentencepiece` exist +# This script uses Python 3.11 where prebuilt wheels for `sentencepiece` exist -#TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs +# TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs import sys import os @@ -40,9 +40,7 @@ ) -def evaluate_response( - model_name: str, query: str, context: str -) -> pd.DataFrame: +def evaluate_response(model_name: str, query: str, context: str) -> pd.DataFrame: """ Evaluates the response of a model to a given query and context, computes extractiveness metrics, token usage, and cost @@ -91,6 +89,8 @@ def evaluate_response( if __name__ == "__main__": + # TODO: Add test evaluation argument to run on the first 10 rows of the config file + # TODO: Add CLI argument to specify the metrics to be computed parser = argparse.ArgumentParser( description="Evaluate LLM outputs using multiple metrics and compute associated costs" @@ -149,9 +149,7 @@ def evaluate_response( df_evals = pd.concat( [ df_evals, - evaluate_response( - row["Model Name"], row["Query"], row["Context"] - ), + evaluate_response(row["Model Name"], row["Query"], row["Context"]), ], axis=0, ) From 3c9a1c9127cabc560fde3507c687c4d39e74d576 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 11 Jul 2025 15:30:52 -0400 Subject: [PATCH 20/92] Update README for clearer instructions, refactor evals.py for better error handling, column validation, and batch processing --- evaluation/README.md | 21 ++--- evaluation/evals.py | 220 ++++++++++++++++++++++++------------------- 2 files changed, 133 insertions(+), 108 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index c06353f4..9e8cfa5d 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -21,9 +21,9 @@ API usage: * Estimated cost in USD * Duration (in seconds) -### Test Data: +### Test Data -Generate the reference file by connecting to a database of references +Generate the dataset file by connecting to a database of references Connect to the Postgres database of your local Balancer instance: @@ -48,7 +48,7 @@ from sqlalchemy import create_engine engine = create_engine("postgresql://@localhost:5432/") ``` -Generate the reference CSV file: +Generate the dataset CSV file: ``` import pandas as pd @@ -63,7 +63,7 @@ df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) -df_grouped.to_csv('', index=False) +df_grouped.to_csv('', index=False) ``` @@ -94,7 +94,7 @@ data = [ df = pd.DataFrame.from_records(data) # Write to CSV -df.to_csv("", index=False) +df.to_csv("", index=False) ``` @@ -104,13 +104,13 @@ df.to_csv("", index=False) Execute [using `uv` to manage depenendices](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: ```sh -uv run evals.py --config path/to/ --reference path/to/ --output path/to/ +uv run evals.py --experiments path/to/ --dataset path/to/ --results path/to/ ``` Execute without using uv run by ensuring it is executable: ```sh -./evals.py --config path/to/ --reference path/to/ --output path/to/ +./evals.py --experiments path/to/ --dataset path/to/ --results path/to/ ``` ### Analyzing Test Results @@ -120,7 +120,7 @@ import pandas as pd import matplotlib.pyplot as plt import numpy as np -df = pd.read_csv("") +df = pd.read_csv("") # Define the metrics of interest extractiveness_cols = ['Extractiveness Coverage', 'Extractiveness Density', 'Extractiveness Compression'] @@ -132,7 +132,7 @@ all_metrics = extractiveness_cols + token_cols + other_metrics plt.style.use('default') fig, axes = plt.subplots(len(all_metrics), 1, figsize=(12, 4 * len(all_metrics))) -models = df['Model Name'].unique() +models = df['MODEL'].unique() colors = plt.cm.Set3(np.linspace(0, 1, len(models))) for i, metric in enumerate(all_metrics): @@ -140,7 +140,7 @@ for i, metric in enumerate(all_metrics): # Create histogram for each model for j, model in enumerate(models): - model_data = df[df['Model Name'] == model][metric] + model_data = df[df['MODEL'] == model][metric] ax.hist(model_data, alpha=0.7, label=model, bins=min(8, len(model_data)), color=colors[j], edgecolor='black', linewidth=0.5) @@ -152,5 +152,4 @@ for i, metric in enumerate(all_metrics): plt.tight_layout() plt.show() - ``` \ No newline at end of file diff --git a/evaluation/evals.py b/evaluation/evals.py index e13e79c8..9c2a30b7 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -12,14 +12,6 @@ Evaluate LLM outputs using multiple metrics and compute associated costs """ -# This script evaluates LLM outputs using the `lighteval` library -# https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks - -# This script uses Python 3.11 where prebuilt wheels for `sentencepiece` exist - - -# TODO: Add tests on a small dummy dataset to confirm it handles errors gracefully and produces expected outputs - import sys import os @@ -30,6 +22,8 @@ import logging 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 from lighteval.metrics.metrics_sample import Extractiveness @@ -40,129 +34,161 @@ ) -def evaluate_response(model_name: str, query: str, context: str) -> pd.DataFrame: +def evaluate_response(model: str, instructions: str, input: str) -> pd.DataFrame: + """ + Test a prompt with a set of test data by scoring each item in the data set """ - Evaluates the response of a model to a given query and context, computes extractiveness metrics, token usage, and cost - Args: - model_name (str): The name of the model to be used for evaluation. - query (str): The user query to be processed. - context (str): The context or document content to be used. - reference (str): The reference text for comparison (not used in this function, but can be used for further evaluations). + try: + handler = ModelFactory.get_handler(model) - Returns: - pd.DataFrame: A DataFrame containing the output text, extractiveness metrics, token usage, cost, and duration. - """ + generated_text, token_usage, pricing, duration = handler.handle_request( + instructions, input + ) - handler = ModelFactory.get_handler(model_name) + doc = Doc(query="", choices=[], gold_index=0, specific={"text": input}) + extractiveness = Extractiveness().compute( + formatted_doc=doc, predictions=[generated_text] + ) - # TODO: Add error handling for unsupported models + cost_metrics = calculate_cost_metrics(token_usage, pricing) - output_text, token_usage, pricing, duration = handler.handle_request(query, context) + result = pd.DataFrame( + [ + { + "Generated Text": generated_text, + "Extractiveness Coverage": extractiveness["summarization_coverage"], + "Extractiveness Density": extractiveness["summarization_density"], + "Extractiveness Compression": extractiveness[ + "summarization_compression" + ], + "Input Token Usage": token_usage.input_tokens, + "Output Token Usage": token_usage.output_tokens, + "Cost (USD)": cost_metrics["total_cost"], + "Duration (s)": duration, + } + ] + ) - doc = Doc(query="", choices=[], gold_index=0, specific={"text": context}) - extractiveness = Extractiveness().compute( - formatted_doc=doc, predictions=[output_text] - ) + except Exception as e: + logging.error(f"Error evaluating response for model {model}: {e}") + result = pd.DataFrame( + [ + { + "Generated Text": None, + "Extractiveness Coverage": None, + "Extractiveness Density": None, + "Extractiveness Compression": None, + "Input Token Usage": None, + "Output Token Usage": None, + "Cost (USD)": None, + "Duration (s)": None, + } + ] + ) + + return result + + +def calculate_cost_metrics(token_usage: dict, pricing: dict) -> dict: + """ + Calculate cost metrics based on token usage and pricing + """ - input_cost_dollars = (pricing["input"] / 1000000) * token_usage.input_tokens - output_cost_dollars = (pricing["output"] / 1000000) * token_usage.output_tokens + TOKENS_PER_MILLION = 1_000_000 + # Pricing is in dollars per million tokens + input_cost_dollars = ( + pricing["input"] / TOKENS_PER_MILLION + ) * token_usage.input_tokens + output_cost_dollars = ( + pricing["output"] / TOKENS_PER_MILLION + ) * token_usage.output_tokens total_cost_dollars = input_cost_dollars + output_cost_dollars - return pd.DataFrame( - [ - { - "Output Text": output_text, - "Extractiveness Coverage": extractiveness["summarization_coverage"], - "Extractiveness Density": extractiveness["summarization_density"], - "Extractiveness Compression": extractiveness[ - "summarization_compression" - ], - "Input Token Usage": token_usage.input_tokens, - "Output Token Usage": token_usage.output_tokens, - "Cost (USD)": total_cost_dollars, - "Duration (s)": duration, - } - ] - ) + return { + "input_cost": input_cost_dollars, + "output_cost": output_cost_dollars, + "total_cost": total_cost_dollars, + } -if __name__ == "__main__": - # TODO: Add test evaluation argument to run on the first 10 rows of the config file +def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: + """ + Load a CSV file and validate that it contains the required columns - # TODO: Add CLI argument to specify the metrics to be computed - parser = argparse.ArgumentParser( - description="Evaluate LLM outputs using multiple metrics and compute associated costs" - ) - parser.add_argument("--config", "-c", required=True, help="Path to config CSV file") - parser.add_argument( - "--reference", "-r", required=True, help="Path to reference CSV file" - ) - parser.add_argument("--output", "-o", required=True, help="Path to output CSV file") + Args: + file_path (str): Path to the CSV file + required_columns (list): List of required column names - args = parser.parse_args() + Returns: + pd.DataFrame + """ + + df = pd.read_csv(file_path) - df_config = pd.read_csv(args.config) - logging.info(f"Config DataFrame shape: {df_config.shape}") - logging.info(f"Config DataFrame columns: {df_config.columns.tolist()}") + # Remove trailing whitespace from column names + df.columns = df.columns.str.strip() - # Remove the trailing whitespace from column names - df_config.columns = df_config.columns.str.strip() + # Uppercase the column names to match the expected format + df.columns = df.columns.str.upper() # Check if the required columns are present - # TODO: Make this more flexible by allowing the user to use default instructions - required_columns = ["Model Name", "Query"] - if not all(col in df_config.columns for col in required_columns): + if not all(col in df.columns for col in required_columns): raise ValueError( - f"Config DataFrame must contain the following columns: {required_columns}" + f"{file_path} must contain the following columns: {required_columns}" ) - # Check if all models in the config are supported by ModelFactory + return df + + +if __name__ == "__main__": + # TODO: Add test evaluation argument to run on the first 10 rows of the dataset file + + parser = argparse.ArgumentParser() + parser.add_argument( + "--experiments", "-e", required=True, help="Path to experiments CSV file" + ) + parser.add_argument( + "--dataset", "-d", required=True, help="Path to dataset CSV file" + ) + parser.add_argument( + "--results", "-r", required=True, help="Path to results CSV file" + ) + + args = parser.parse_args() + + df_experiment = load_csv( + args.experiments, required_columns=["MODEL", "INSTRUCTIONS"] + ) + # Check if all models are supported by ModelFactory if not all( model in ModelFactory.HANDLERS.keys() - for model in df_config["Model Name"].unique() + for model in df_experiment["MODEL"].unique() ): raise ValueError( - f"Unsupported model(s) found in config: {set(df_config['Model Name'].unique()) - set(ModelFactory.HANDLERS.keys())}" - ) - - df_reference = pd.read_csv(args.reference) - logging.info(f"Reference DataFrame shape: {df_reference.shape}") - logging.info(f"Reference DataFrame columns: {df_reference.columns.tolist()}") - - # Remove the trailing whitespace from column names - df_reference.columns = df_reference.columns.str.strip() - # Check if the required columns are present - required_columns = ["Context"] - if not all(col in df_reference.columns for col in required_columns): - raise ValueError( - f"Reference DataFrame must contain the following columns: {required_columns}" + f"Unsupported model(s) found: {set(df_experiment['MODEL'].unique()) - set(ModelFactory.HANDLERS.keys())}" ) + df_dataset = load_csv(args.dataset, required_columns=["INPUT"]) - # Cross join the config and reference DataFrames - df_in = df_config.merge(df_reference, how="cross") + # Bulk model and prompt experimentation: Cross join the experiment and dataset DataFrames + df_in = df_experiment.merge(df_dataset, how="cross") - # TODO: Parallelize the evaluation process for each row in df_in using concurrent.futures or similar libraries - df_evals = pd.DataFrame() - for index, row in df_in.iterrows(): - df_evals = pd.concat( - [ - df_evals, - evaluate_response(row["Model Name"], row["Query"], row["Context"]), - ], - axis=0, - ) + # Evaluate each row in the input DataFrame + results = [] + for index, row in enumerate(df_in.itertuples(index=False)): + result = evaluate_response(row.MODEL, row.INSTRUCTIONS, row.INPUT) + results.append(result) + # TODO: Use tqdm or similar library to show progress bar logging.info(f"Processed row {index + 1}/{len(df_in)}") - # Concatenate the input and evaluations DataFrames + df_evals = pd.concat(results, axis=0, ignore_index=True) + # Concatenate the input and evaluations DataFrames df_out = pd.concat( [df_in.reset_index(drop=True), df_evals.reset_index(drop=True)], axis=1 ) - - df_out.to_csv(args.output, index=False) - logging.info(f"Output DataFrame shape: {df_out.shape}") - logging.info(f"Results saved to {args.output}") + df_out.to_csv(args.results, index=False) + logging.info(f"Results saved to {args.results}") logging.info("Evaluation completed successfully.") From 905c2e0625ce6f2519407499afa12460cb4d5a7d Mon Sep 17 00:00:00 2001 From: Chris Annunziato Date: Tue, 15 Jul 2025 10:09:44 -0400 Subject: [PATCH 21/92] Standardized icon and Balancer logo sizes in the sidebar --- .../src/pages/Layout/Layout_V2_Sidebar.tsx | 194 +++++++++--------- 1 file changed, 97 insertions(+), 97 deletions(-) diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index a54a5c2a..9d661afc 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -1,117 +1,117 @@ -import React, { useState, useEffect } from "react"; -import { Link, useNavigate, useLocation } from "react-router-dom"; -import { ChevronLeft, ChevronRight, File, Loader2 } from "lucide-react"; +import React, {useState, useEffect} from "react"; +import {Link, useNavigate, useLocation} from "react-router-dom"; +import {ChevronLeft, ChevronRight, File, Loader2} from "lucide-react"; import axios from "axios"; interface File { - id: number; - guid: string; - file_name: string; - title: string | null; + id: number; + guid: string; + file_name: string; + title: string | null; } const Sidebar: React.FC = () => { - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [files, setFiles] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const navigate = useNavigate(); - const location = useLocation(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); - const toggleSidebar = () => { - setSidebarCollapsed(!sidebarCollapsed); - }; - - useEffect(() => { - const fetchFiles = async () => { - try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - }); - if (Array.isArray(response.data)) { - setFiles(response.data); - } - } catch (error) { - console.error("Error fetching files", error); - } finally { - setIsLoading(false); - } + const toggleSidebar = () => { + setSidebarCollapsed(!sidebarCollapsed); }; - fetchFiles(); - }, []); + useEffect(() => { + const fetchFiles = async () => { + try { + const baseUrl = import.meta.env.VITE_API_BASE_URL; + const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { + headers: { + Authorization: `JWT ${localStorage.getItem("access")}`, + }, + }); + if (Array.isArray(response.data)) { + setFiles(response.data); + } + } catch (error) { + console.error("Error fetching files", error); + } finally { + setIsLoading(false); + } + }; - const handleFileClick = (guid: string) => { - const params = new URLSearchParams(location.search); - const currentGuid = params.get("guid"); + fetchFiles(); + }, []); - if (guid !== currentGuid) { - navigate(`/drugsummary?guid=${guid}&page=1`); - } else { - navigate( - `/drugsummary?guid=${guid}${params.has("page") ? `&page=${params.get("page")}` : ""}` - ); - } - }; + const handleFileClick = (guid: string) => { + const params = new URLSearchParams(location.search); + const currentGuid = params.get("guid"); - return ( -

    -
    - {!sidebarCollapsed && ( - -

    - Balancer -

    - - )} - -
    + if (guid !== currentGuid) { + navigate(`/drugsummary?guid=${guid}&page=1`); + } else { + navigate( + `/drugsummary?guid=${guid}${params.has("page") ? `&page=${params.get("page")}` : ""}` + ); + } + }; - {/* File List Section */} -
    - {isLoading ? ( -
    - -
    - ) : ( -
      - {files.map((file) => ( -
    • + return ( +
      +
      + {!sidebarCollapsed && ( + +

      + Balancer +

      + + )} +
      + + {/* File List Section */} +
      + {isLoading ? ( +
      + +
      + ) : ( +
        + {files.map((file) => ( +
      • + -
      • - ))} -
      - )} -
      -
      - ); + )} + +
    • + ))} +
    + )} +
    +
    + ); }; export default Sidebar; From d1dd75c2c9277e612473df049a2e482821d659b0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 15 Jul 2025 10:25:10 -0400 Subject: [PATCH 22/92] Update evaluation README with metrics and API usage details, and add 'Contributing' section --- evaluation/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 9e8cfa5d..48686950 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -2,12 +2,12 @@ ## `evals`: LLM evaluations to test and improve model outputs -LLM evals test a prompt with a set of test data by scoring each item in the data set - -To test Balancer's structured text extraction of medication rules, `evals` computes: +### Metrics [Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): +Natural Language Generation Performance: + * Extractiveness Coverage: - Percentage of words in the summary that are part of an extractive fragment with the article * Extractiveness Density: @@ -15,10 +15,10 @@ To test Balancer's structured text extraction of medication rules, `evals` compu * Extractiveness Compression: - Word ratio between the article and the summary -API usage: +API Performance: -* Token usage (input/output) -* Estimated cost in USD +* Token Usage (input/output) +* Estimated Cost in USD * Duration (in seconds) ### Test Data @@ -152,4 +152,7 @@ for i, metric in enumerate(all_metrics): plt.tight_layout() plt.show() -``` \ No newline at end of file + +``` + +### Contributing From eef2a29d117e40f619a3dcac1f073aeee2555c6f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 15 Jul 2025 10:54:50 -0400 Subject: [PATCH 23/92] Update evaluation instructions, improve dataset generation section, and clarify external tools --- evaluation/README.md | 45 +++++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 48686950..ddaf12c9 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -2,12 +2,12 @@ ## `evals`: LLM evaluations to test and improve model outputs -### Metrics - -[Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): +### Evaluation Metrics Natural Language Generation Performance: +[Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): + * Extractiveness Coverage: - Percentage of words in the summary that are part of an extractive fragment with the article * Extractiveness Density: @@ -23,7 +23,7 @@ API Performance: ### Test Data -Generate the dataset file by connecting to a database of references +Generate the dataset file by connecting to a database of research papers: Connect to the Postgres database of your local Balancer instance: @@ -36,72 +36,63 @@ engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/b Connect to the Postgres database of the production Balancer instance using a SQL file: ``` -# Install Postgres.app and add binaries to the PATH +# Add Postgres.app binaries to the PATH echo 'export PATH="/Applications/Postgres.app/Contents/Versions/latest/bin:$PATH"' >> ~/.zshrc createdb pg_restore -v -d .sql ``` -``` -from sqlalchemy import create_engine -engine = create_engine("postgresql://@localhost:5432/") -``` - Generate the dataset CSV file: ``` +from sqlalchemy import create_engine import pandas as pd +engine = create_engine("postgresql://@localhost:5432/") + query = "SELECT * FROM api_embeddings;" df = pd.read_sql(query, engine) -df['formatted_chunk'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) +df['INPUT'] = df.apply(lambda row: f"ID: {row['chunk_number']} | CONTENT: {row['text']}", axis=1) # Ensure the chunks are joined in order of chunk_number by sorting the DataFrame before grouping and joining df = df.sort_values(by=['name', 'upload_file_id', 'chunk_number']) -df_grouped = df.groupby(['name', 'upload_file_id'])['formatted_chunk'].apply(lambda chunks: "\n".join(chunks)).reset_index() +df_grouped = df.groupby(['name', 'upload_file_id'])['INPUT'].apply(lambda chunks: "\n".join(chunks)).reset_index() -df_grouped = df_grouped.rename(columns={'formatted_chunk': 'concatenated_chunks'}) df_grouped.to_csv('', index=False) ``` - ### Running an Evaluation -#### Test Input: Bulk model and prompt experimentation +#### Bulk Model and Prompt Experimentation Compare the results of many different prompts and models at once ``` import pandas as pd -# Define the data data = [ - { - "Model Name": "", - "Query": """""" + "MODEL": "", + "INSTRUCTIONS": """""" }, - { - "Model Name": "", - "Query": """""" + "MODEL": "", + "INSTRUCTIONS": """""" }, ] -# Create DataFrame from records df = pd.DataFrame.from_records(data) -# Write to CSV df.to_csv("", index=False) ``` -#### Execute on the command line +#### Execute on the Command Line -Execute [using `uv` to manage depenendices](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: +Execute [using `uv` to manage dependencies](https://docs.astral.sh/uv/guides/scripts/) without manually managing enviornments: ```sh uv run evals.py --experiments path/to/ --dataset path/to/ --results path/to/ @@ -156,3 +147,5 @@ plt.show() ``` ### Contributing + +You're welcome to add LLM models to test in `server/api/services/llm_services` \ No newline at end of file From ff18a3094b4d95454409cb4e8d6b474132ac0e98 Mon Sep 17 00:00:00 2001 From: Chris Annunziato Date: Tue, 15 Jul 2025 11:38:38 -0400 Subject: [PATCH 24/92] Tailored the fix to the File icon specifically instead of the span, with flex-shrink-0, to prevent future issues if the ui were to change around the icon. --- frontend/src/pages/Layout/Layout_V2_Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index 9d661afc..e1c26f1b 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -98,9 +98,9 @@ const Sidebar: React.FC = () => { sidebarCollapsed ? "justify-center" : "" }`} > - + {!sidebarCollapsed && ( - + {file.title || file.file_name.replace(/\.[^/.]+$/, "")} )} From ffa86f7d99f8e1df4dcf2dbedae9a56e7efe20dc Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 15 Jul 2025 11:57:32 -0400 Subject: [PATCH 25/92] Update dependencies list in and correct comment syntax in --- evaluation/evals.py | 5 ++++- server/api/services/llm_services.py | 28 +++++++++------------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 9c2a30b7..08eda2bc 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -4,7 +4,10 @@ # dependencies = [ # "pandas==2.2.3", # "lighteval==0.10.0", -# "openai==1.83.0" +# "openai==1.83.0", +# "spacy==3.8.7", +# "pip" +# # ] # /// diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 7137f026..18c6e58f 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -9,6 +9,7 @@ import openai + class BaseModelHandler(ABC): @abstractmethod def handle_request( @@ -16,11 +17,13 @@ def handle_request( ) -> tuple[str, dict[str, int], dict[str, float], float]: pass + # LLM Pricing Calculator: https://www.llm-prices.com/ # TODO: Add support for more models and their pricing # Anthropic Model Pricing: https://docs.anthropic.com/en/docs/about-claude/pricing#model-pricing + class GPT4OMiniHandler(BaseModelHandler): MODEL = "gpt-4o-mini" # TODO: Get the latest model pricing from OpenAI's API or documentation @@ -44,10 +47,7 @@ def handle_request( start_time = time.time() # TODO: Add error handling for API requests and invalid responses response = self.client.responses.create( - model=self.MODEL, - instructions=query, - input=context, - temperature=0.0 + model=self.MODEL, instructions=query, input=context, temperature=0.0 ) duration = time.time() - start_time @@ -67,7 +67,7 @@ class GPT41NanoHandler(BaseModelHandler): # GPT 4.1 Prompting Guide: https://cookbook.openai.com/examples/gpt4-1_prompting_guide - # Long context performance can degrade as more items are required to be retrieved, + # Long context performance can degrade as more items are required to be retrieved, # or perform complex reasoning that requires knowledge of the state of the entire context # @@ -82,7 +82,7 @@ class GPT41NanoHandler(BaseModelHandler): # Instructions - - Identify decision points for bipolar medications #TODO: "pharmacological and procedurl interventions" + - Identify decision points for bipolar medications - For each decision point you find, return a JSON object using the following format: @@ -92,15 +92,11 @@ class GPT41NanoHandler(BaseModelHandler): "medications": ["", "", ...], "reason": "", "sources": [""] - "hierarchy": Primary: Contraindictions for allergies - "override" Exclude for allergy } - Only extract bipolar medication decision points that are explicitly stated or strongly implied in the context and never rely on your own knowledge - - TODO: Test against medication indication file - # Output Format - Return the extracted bipolar medication decision points as a JSON array and if no decision points are found in the context return an empty array @@ -145,15 +141,11 @@ def handle_request( if not query: query = self.INSTRUCTIONS - start_time = time.time() # TODO: Add error handling for API requests and invalid responses response = self.client.responses.create( - model=self.MODEL, - instructions=query, - input=context, - temperature=0.0 + model=self.MODEL, instructions=query, input=context, temperature=0.0 ) duration = time.time() - start_time @@ -166,10 +158,8 @@ def handle_request( class ModelFactory: - - #TODO: Define structured fields to extract from unstructured input data - #https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses&example=structured-data#examples - + # TODO: Define structured fields to extract from unstructured input data + # https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses&example=structured-data#examples HANDLERS = { "GPT_4O_MINI": GPT4OMiniHandler, From 42a494951a9c83f6f421c74ee70d6c6e7f0e6a36 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 15 Jul 2025 20:30:28 -0400 Subject: [PATCH 26/92] Update README.md --- evaluation/README.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/evaluation/README.md b/evaluation/README.md index ddaf12c9..6e1a1cf2 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -9,11 +9,16 @@ Natural Language Generation Performance: [Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): * Extractiveness Coverage: + - Extent to which a summary is derivative of a text - Percentage of words in the summary that are part of an extractive fragment with the article * Extractiveness Density: + - How well the word sequence can be described as series of extractions + - A summary might contain many individual words from the article and therefore have a high coverage. + - However, if arranged in a new order, the words of the summary could still be used to convey ideas not present in the article - Average length of the extractive fragment to which each word in the summary belongs * Extractiveness Compression: - Word ratio between the article and the summary + - Summarizing with higher compression is challenging as it requires capturing more precisely the critical aspects of the article text. API Performance: @@ -119,7 +124,7 @@ token_cols = ['Input Token Usage', 'Output Token Usage'] other_metrics = ['Cost (USD)', 'Duration (s)'] all_metrics = extractiveness_cols + token_cols + other_metrics -# Metric histograms by model +# Metric Histograms by Model plt.style.use('default') fig, axes = plt.subplots(len(all_metrics), 1, figsize=(12, 4 * len(all_metrics))) @@ -144,6 +149,36 @@ for i, metric in enumerate(all_metrics): plt.tight_layout() plt.show() +# Metric Statistics by Model +for metric in all_metrics: + print(f"\n{metric.upper()}:") + desc_stats = df.groupby('MODEL')[metric].agg([ + 'count', 'mean', 'std', 'min', 'median','max' + ]) + + print(desc_stats) + + +# Calculate Efficiency Metrics By model +df_analysis = df.copy() +df_analysis['Total Token Usage'] = df_analysis['Input Token Usage'] + df_analysis['Output Token Usage'] +df_analysis['Cost per Token'] = df_analysis['Cost (USD)'] / df_analysis['Total Token Usage'] +df_analysis['Tokens per Second'] = df_analysis['Total Token Usage'] / df_analysis['Duration (s)'] +df_analysis['Cost per Second'] = df_analysis['Cost (USD)'] / df_analysis['Duration (s)'] + +efficiency_metrics = ['Cost per Token', 'Tokens per Second', 'Cost per Second'] + +for metric in efficiency_metrics: + print(f"\n{metric.upper()}:") + eff_stats = df_analysis.groupby('MODEL')[metric].agg([ + 'count', 'mean', 'std', 'min', 'median', 'max' + ]) + + for col in ['mean', 'std', 'min', 'median', 'max']: + eff_stats[col] = eff_stats[col].apply(lambda x: f"{x:.3g}") + print(eff_stats) + + ``` ### Contributing From 0e2893b413ba2b318b2e43ff0177c36107cd4c7a Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 15 Jul 2025 20:59:26 -0400 Subject: [PATCH 27/92] Update README.md --- evaluation/README.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 6e1a1cf2..669141d8 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -8,17 +8,9 @@ Natural Language Generation Performance: [Extractiveness](https://huggingface.co/docs/lighteval/en/metric-list#automatic-metrics-for-generative-tasks): -* Extractiveness Coverage: - - Extent to which a summary is derivative of a text - - Percentage of words in the summary that are part of an extractive fragment with the article -* Extractiveness Density: - - How well the word sequence can be described as series of extractions - - A summary might contain many individual words from the article and therefore have a high coverage. - - However, if arranged in a new order, the words of the summary could still be used to convey ideas not present in the article - - Average length of the extractive fragment to which each word in the summary belongs -* Extractiveness Compression: - - Word ratio between the article and the summary - - Summarizing with higher compression is challenging as it requires capturing more precisely the critical aspects of the article text. +* Extractiveness Coverage: Extent to which a summary is derivative of a text +* Extractiveness Density: How well the word sequence can be described as series of extractions +* Extractiveness Compression: Word ratio between the article and the summary API Performance: From 739533bd454c8f82dc0ce6e86815c9c3cd1cb452 Mon Sep 17 00:00:00 2001 From: Chris Annunziato Date: Wed, 16 Jul 2025 18:58:56 -0400 Subject: [PATCH 28/92] Reworked the Admin Portal UI, preventing overflows and generally improved the UX. In addition to fixing mobile to close the sidebar by default. --- .../src/pages/AdminPortal/AdminPortal.tsx | 226 ++++++++++-------- .../src/pages/Layout/Layout_V2_Sidebar.tsx | 14 ++ 2 files changed, 135 insertions(+), 105 deletions(-) diff --git a/frontend/src/pages/AdminPortal/AdminPortal.tsx b/frontend/src/pages/AdminPortal/AdminPortal.tsx index c214270a..da93974a 100644 --- a/frontend/src/pages/AdminPortal/AdminPortal.tsx +++ b/frontend/src/pages/AdminPortal/AdminPortal.tsx @@ -1,115 +1,131 @@ //import Welcome from "../../components/Welcome/Welcome.tsx"; -import { Link } from "react-router-dom"; +import {Link} from "react-router-dom"; import pencilSVG from "../../assets/pencil.svg"; import uploadSVG from "../../assets/upload.svg"; import Layout_V2_Main from "../Layout/Layout_V2_Main"; +import React from "react"; const AdminPortal = () => { - return ( - <> - -
    -
    -
    -
    - Let's take a look inside Balancer's brain. You can manage the - brain from this portal. -
    -
    - {/*
    */} -
    - -
    -
    - - Manage Files - - - Manage the files stored in the Balancer's brain - -
    - Description of SVG -
    - - -
    -
    - - Upload PDF - - - Add to Balancer's brain - -
    - Description of SVG -
    - - -
    -
    - - Ask General Questions - - - Get answers from Balancer's brain - -
    - Description of SVG -
    - - -
    -
    - - Rules Manager for Medication Suggester - - - Manage and view the rules for the Medication Suggester - -
    - Description of SVG -
    - - -
    -
    - - Medications Database Manager - - - Manager the Medications store currently inside Balancer's - brain - -
    - Description of SVG + return ( + <> + +
    +
    +
    +
    + Let's take a look inside Balancer's brain. You can manage the + brain from this portal. +
    +
    + {/*
    */} +
    + + + +
    + + Manage Files + + + Manage the files stored in the Balancer's brain + +
    + Description of SVG +
    + + + + +
    + + Upload PDF + + + Add to Balancer's brain + +
    + Description of SVG + +
    + + + +
    + + Ask General Questions + + + Get answers from Balancer's brain + +
    + Description of SVG +
    + + + +
    + + Rules Manager + + + Manage and view the rules for the Medication Suggester + +
    + Description of SVG +
    + + + +
    + + Medications Database + + + Manager the Medications store currently inside Balancer's + brain + +
    + Description of SVG +
    + + +
    +
    - -
    -
    -
    - - - ); + + + ); }; export default AdminPortal; + + +const AdminDashboardItemWrapper = ({children}: { children: React.ReactNode }) => { + return ( +
    + {children} +
    + ) +} \ No newline at end of file diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index e1c26f1b..19163290 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -56,6 +56,20 @@ const Sidebar: React.FC = () => { } }; + useEffect(() => { + const handleResize = () => { + if (window.innerWidth < 640) { + setSidebarCollapsed(true) + } else { + setSidebarCollapsed(false) + } + } + + handleResize() + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + return (
    Date: Tue, 22 Jul 2025 18:33:51 -0400 Subject: [PATCH 29/92] fixed typo under medications database --- frontend/src/pages/AdminPortal/AdminPortal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/AdminPortal/AdminPortal.tsx b/frontend/src/pages/AdminPortal/AdminPortal.tsx index da93974a..e0aaa5ab 100644 --- a/frontend/src/pages/AdminPortal/AdminPortal.tsx +++ b/frontend/src/pages/AdminPortal/AdminPortal.tsx @@ -98,7 +98,7 @@ const AdminPortal = () => { Medications Database - Manager the Medications store currently inside Balancer's + Manage the Medications store currently inside Balancer's brain
    From 946d6c4c420e12a9ca183dbbb2821e1076171f2a Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 23 Jul 2025 12:36:58 -0400 Subject: [PATCH 30/92] Update evaluation README --- evaluation/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evaluation/README.md b/evaluation/README.md index 669141d8..f597e389 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -72,11 +72,11 @@ import pandas as pd data = [ { "MODEL": "", - "INSTRUCTIONS": """""" + "INSTRUCTIONS": """""" }, { "MODEL": "", - "INSTRUCTIONS": """""" + "INSTRUCTIONS": """""" }, ] From 36e9e2d46e16ba25d3446b813ec4370779c97982 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 23 Jul 2025 13:12:00 -0400 Subject: [PATCH 31/92] Optimize evaluation performance using AsyncOpenAI for concurrent processing --- evaluation/evals.py | 26 ++++++++++++++++---------- server/api/services/llm_services.py | 16 ++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 08eda2bc..2d88bf65 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -23,6 +23,7 @@ import argparse import logging +import asyncio import pandas as pd @@ -37,7 +38,7 @@ ) -def evaluate_response(model: str, instructions: str, input: str) -> pd.DataFrame: +async def evaluate_response(model: str, instructions: str, input: str) -> pd.DataFrame: """ Test a prompt with a set of test data by scoring each item in the data set """ @@ -45,7 +46,7 @@ def evaluate_response(model: str, instructions: str, input: str) -> pd.DataFrame try: handler = ModelFactory.get_handler(model) - generated_text, token_usage, pricing, duration = handler.handle_request( + generated_text, token_usage, pricing, duration = await handler.handle_request( instructions, input ) @@ -145,7 +146,7 @@ def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: return df -if __name__ == "__main__": +async def main(): # TODO: Add test evaluation argument to run on the first 10 rows of the dataset file parser = argparse.ArgumentParser() @@ -177,14 +178,15 @@ def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: # Bulk model and prompt experimentation: Cross join the experiment and dataset DataFrames df_in = df_experiment.merge(df_dataset, how="cross") - # Evaluate each row in the input DataFrame - results = [] - for index, row in enumerate(df_in.itertuples(index=False)): - result = evaluate_response(row.MODEL, row.INSTRUCTIONS, row.INPUT) - results.append(result) + # Evaluate each row in the input DataFrame concurrently + logging.info(f"Starting evaluation of {len(df_in)} rows") + tasks = [ + evaluate_response(row.MODEL, row.INSTRUCTIONS, row.INPUT) + for row in df_in.itertuples(index=False) + ] - # TODO: Use tqdm or similar library to show progress bar - logging.info(f"Processed row {index + 1}/{len(df_in)}") + results = await asyncio.gather(*tasks) + logging.info(f"Completed evaluation of {len(results)} rows") df_evals = pd.concat(results, axis=0, ignore_index=True) @@ -195,3 +197,7 @@ def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: df_out.to_csv(args.results, index=False) logging.info(f"Results saved to {args.results}") logging.info("Evaluation completed successfully.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/server/api/services/llm_services.py b/server/api/services/llm_services.py index 18c6e58f..69df8172 100644 --- a/server/api/services/llm_services.py +++ b/server/api/services/llm_services.py @@ -7,12 +7,12 @@ import logging from abc import ABC, abstractmethod -import openai +from openai import AsyncOpenAI class BaseModelHandler(ABC): @abstractmethod - def handle_request( + async def handle_request( self, query: str, context: str ) -> tuple[str, dict[str, int], dict[str, float], float]: pass @@ -31,9 +31,9 @@ class GPT4OMiniHandler(BaseModelHandler): PRICING_DOLLARS_PER_MILLION_TOKENS = {"input": 0.15, "output": 0.60} def __init__(self) -> None: - self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + self.client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - def handle_request( + async def handle_request( self, query: str, context: str ) -> tuple[str, dict[str, int], dict[str, float], float]: """ @@ -46,7 +46,7 @@ def handle_request( """ start_time = time.time() # TODO: Add error handling for API requests and invalid responses - response = self.client.responses.create( + response = await self.client.responses.create( model=self.MODEL, instructions=query, input=context, temperature=0.0 ) duration = time.time() - start_time @@ -123,9 +123,9 @@ class GPT41NanoHandler(BaseModelHandler): """ def __init__(self) -> None: - self.client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + self.client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY")) - def handle_request( + async def handle_request( self, query: str, context: str ) -> tuple[str, dict[str, int], dict[str, float], float]: """ @@ -144,7 +144,7 @@ def handle_request( start_time = time.time() # TODO: Add error handling for API requests and invalid responses - response = self.client.responses.create( + response = await self.client.responses.create( model=self.MODEL, instructions=query, input=context, temperature=0.0 ) duration = time.time() - start_time From ac4a09e9095ef17838759898c55aaf9b324a3ee7 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 23 Jul 2025 16:01:05 -0400 Subject: [PATCH 32/92] Add comprehensive pytest test suite for evaluation module --- evaluation/test_evals.py | 239 ++++++++++++++++++++++++++++++++------- 1 file changed, 199 insertions(+), 40 deletions(-) diff --git a/evaluation/test_evals.py b/evaluation/test_evals.py index f41817c6..e6d0916d 100644 --- a/evaluation/test_evals.py +++ b/evaluation/test_evals.py @@ -1,53 +1,212 @@ - -from unittest.mock import patch, MagicMock +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = "==3.11.11" +# dependencies = [ +# "pandas==2.2.3", +# "lighteval==0.10.0", +# "openai==1.83.0", +# "spacy==3.8.7", +# "pytest==8.3.3", +# "pytest-asyncio==0.24.0", +# "pip" +# ] +# /// import pytest import pandas as pd +from unittest.mock import Mock, patch, AsyncMock +import tempfile +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from evaluation.evals import evaluate_response, calculate_cost_metrics, load_csv + -from evals import evaluate_response +@pytest.fixture +def mock_token_usage(): + token_usage = Mock() + token_usage.input_tokens = 100 + token_usage.output_tokens = 50 + return token_usage -class MockTokenUsage: - def __init__(self, input_tokens, output_tokens): - self.input_tokens = input_tokens - self.output_tokens = output_tokens -@patch("evals.ModelFactory.get_handler") -@patch("evals.Extractiveness.compute") -def test_evaluate_response(mock_extractiveness_compute, mock_get_handler): +@pytest.fixture +def temp_csv(): + def _create_csv(content): + with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f: + f.write(content) + return f.name - # Mock BaseModelHandler - mock_handler = MagicMock() - mock_handler.handle_request.return_value = ( - "This is a summary.", - MockTokenUsage(input_tokens=100, output_tokens=50), - {"input": 15.0, "output": 30.0}, # $15 and $30 per 1M tokens - 1.23, # duration + return _create_csv + + +class TestCalculateCostMetrics: + @pytest.mark.parametrize( + "input_tokens,output_tokens,input_price,output_price,expected_input,expected_output,expected_total", + [ + (1000, 500, 5.0, 15.0, 0.005, 0.0075, 0.0125), + (0, 0, 5.0, 15.0, 0.0, 0.0, 0.0), + (1_000_000, 2_000_000, 10.0, 30.0, 10.0, 60.0, 70.0), + ], ) + def test_calculate_cost_metrics( + self, + input_tokens, + output_tokens, + input_price, + output_price, + expected_input, + expected_output, + expected_total, + ): + token_usage = Mock(input_tokens=input_tokens, output_tokens=output_tokens) + pricing = {"input": input_price, "output": output_price} + + result = calculate_cost_metrics(token_usage, pricing) - mock_get_handler.return_value = mock_handler + assert pytest.approx(result["input_cost"]) == expected_input + assert pytest.approx(result["output_cost"]) == expected_output + assert pytest.approx(result["total_cost"]) == expected_total - mock_extractiveness_compute.return_value = { - "summarization_coverage": 0.8, - "summarization_density": 1.5, - "summarization_compression": 2.0, - } - df = evaluate_response( - model_name="mock-model", - query="What is the summary?", - context="This is a long article about something important.", - reference="This is a reference summary.", +class TestLoadCsv: + @pytest.mark.parametrize( + "csv_content,required_columns,expected_len", + [ + ( + "model,instructions\ngpt-4,Test prompt\ngpt-3.5,Another prompt\n", + ["MODEL", "INSTRUCTIONS"], + 2, + ), + ( + " model , instructions \ngpt-4,Test prompt\n", + ["MODEL", "INSTRUCTIONS"], + 1, + ), + ("input\nTest input 1\nTest input 2\n", ["INPUT"], 2), + ], ) + def test_load_csv_valid( + self, temp_csv, csv_content, required_columns, expected_len + ): + temp_path = temp_csv(csv_content) + try: + df = load_csv(temp_path, required_columns) + assert len(df) == expected_len + assert list(df.columns) == required_columns + finally: + os.unlink(temp_path) + + @pytest.mark.parametrize( + "csv_content,required_columns", + [ + ("model,prompt\ngpt-4,Test prompt\n", ["MODEL", "INSTRUCTIONS"]), + ("wrong,columns\nval1,val2\n", ["MODEL", "INSTRUCTIONS"]), + ], + ) + def test_load_csv_missing_columns(self, temp_csv, csv_content, required_columns): + temp_path = temp_csv(csv_content) + try: + with pytest.raises(ValueError, match="must contain the following columns"): + load_csv(temp_path, required_columns) + finally: + os.unlink(temp_path) + + def test_load_csv_nonexistent_file(self): + with pytest.raises(FileNotFoundError): + load_csv("nonexistent_file.csv", ["MODEL"]) + + +class TestEvaluateResponse: + @pytest.mark.asyncio + async def test_evaluate_response_success(self, mock_token_usage): + mock_handler = AsyncMock() + mock_handler.handle_request.return_value = ( + "Generated response text", + mock_token_usage, + {"input": 5.0, "output": 15.0}, + 1.5, + ) + + mock_extractiveness = Mock() + mock_extractiveness.compute.return_value = { + "summarization_coverage": 0.8, + "summarization_density": 0.6, + "summarization_compression": 0.4, + } + + with ( + patch( + "evaluation.evals.ModelFactory.get_handler", return_value=mock_handler + ), + patch("evaluation.evals.Extractiveness", return_value=mock_extractiveness), + ): + result = await evaluate_response("gpt-4", "Test instructions", "Test input") + + assert isinstance(result, pd.DataFrame) + assert len(result) == 1 + row = result.iloc[0] + assert row["Generated Text"] == "Generated response text" + assert row["Extractiveness Coverage"] == 0.8 + assert row["Input Token Usage"] == 100 + assert row["Output Token Usage"] == 50 + assert row["Duration (s)"] == 1.5 + + @pytest.mark.parametrize( + "exception_side_effect", ["get_handler", "handle_request", "extractiveness"] + ) + @pytest.mark.asyncio + async def test_evaluate_response_exceptions( + self, mock_token_usage, exception_side_effect + ): + if exception_side_effect == "get_handler": + with patch( + "evaluation.evals.ModelFactory.get_handler", + side_effect=Exception("Test error"), + ): + result = await evaluate_response( + "invalid-model", "Test instructions", "Test input" + ) + + elif exception_side_effect == "handle_request": + mock_handler = AsyncMock() + mock_handler.handle_request.side_effect = Exception("Handler error") + with patch( + "evaluation.evals.ModelFactory.get_handler", return_value=mock_handler + ): + result = await evaluate_response( + "gpt-4", "Test instructions", "Test input" + ) + + elif exception_side_effect == "extractiveness": + mock_handler = AsyncMock() + mock_handler.handle_request.return_value = ( + "text", + mock_token_usage, + {"input": 5.0, "output": 15.0}, + 1.5, + ) + mock_extractiveness = Mock() + mock_extractiveness.compute.side_effect = Exception("Extractiveness error") + + with ( + patch( + "evaluation.evals.ModelFactory.get_handler", + return_value=mock_handler, + ), + patch( + "evaluation.evals.Extractiveness", return_value=mock_extractiveness + ), + ): + result = await evaluate_response( + "gpt-4", "Test instructions", "Test input" + ) + + assert isinstance(result, pd.DataFrame) + assert len(result) == 1 + assert pd.isna(result.iloc[0]["Generated Text"]) + - assert isinstance(df, pd.DataFrame) - assert df.shape == (1, 8) - assert df["Output Text"].iloc[0] == "This is a summary." - assert df["Extractiveness Coverage"].iloc[0] == 0.8 - assert df["Extractiveness Density"].iloc[0] == 1.5 - assert df["Extractiveness Compression"].iloc[0] == 2.0 - assert df["Input Token Usage"].iloc[0] == 100 - assert df["Output Token Usage"].iloc[0] == 50 - - expected_cost = (15.0 / 1_000_000) * 100 + (30.0 / 1_000_000) * 50 - assert pytest.approx(df["Cost (USD)"].iloc[0], rel=1e-4) == expected_cost - assert pytest.approx(df["Duration (s)"].iloc[0], rel=1e-4) == 1.23 \ No newline at end of file +if __name__ == "__main__": + pytest.main([__file__]) From f95ed69632e56f43602e33f385d3ef8b9504bc1d Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 23 Jul 2025 16:32:18 -0400 Subject: [PATCH 33/92] Expand documentation for adding evaluation metrics and LLM models, including testing instructions --- evaluation/README.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/evaluation/README.md b/evaluation/README.md index f597e389..a32df682 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -175,4 +175,32 @@ for metric in efficiency_metrics: ### Contributing -You're welcome to add LLM models to test in `server/api/services/llm_services` \ No newline at end of file +#### Adding Evaluation Metrics + +To add new evaluation metrics, modify the `evaluate_response()` function in `evaluation/evals.py`: + +**Update dependencies** in script header and ensure exception handling includes new metrics with `None` values. + +#### Adding New LLM Models + +To add a new LLM model for evaluation, implement a handler in `server/api/services/llm_services.py`: + +1. **Create a handler class** inheriting from `BaseModelHandler`: +2. **Register in ModelFactory** by adding to the `HANDLERS` dictionary: +3. **Use in experiments** by referencing the handler key in your experiments CSV: + +The evaluation system will automatically use your handler through the Factory Method pattern. + + +#### Running Tests + +The evaluation module includes comprehensive tests for all core functions. Run the test suite using: + +```sh +uv run test_evals.py +``` + +The tests cover: +- **Cost calculation** with various token usage and pricing scenarios +- **CSV loading** with validation and error handling +- **Response evaluation** including async operations and exception handling From 252071ecb0597d41e9afd597d1ebd403cc948593 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 29 Jul 2025 12:10:44 -0400 Subject: [PATCH 34/92] ADD Add support for limiting CSV rows during test execution and include timing for evaluation --- evaluation/evals.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 2d88bf65..8eb7e9e6 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -24,6 +24,7 @@ import argparse import logging import asyncio +import time import pandas as pd @@ -117,19 +118,22 @@ def calculate_cost_metrics(token_usage: dict, pricing: dict) -> dict: } -def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: +def load_csv(file_path: str, required_columns: list, nrows: int = None) -> pd.DataFrame: """ Load a CSV file and validate that it contains the required columns Args: file_path (str): Path to the CSV file required_columns (list): List of required column names - + nrows (int): Number of rows to read from the CSV file Returns: pd.DataFrame """ - df = pd.read_csv(file_path) + if nrows is not None: + logging.info(f"Test mode enabled: Reading first {nrows} rows of {file_path}") + + df = pd.read_csv(file_path, nrows=nrows) # Remove trailing whitespace from column names df.columns = df.columns.str.strip() @@ -147,8 +151,6 @@ def load_csv(file_path: str, required_columns: list) -> pd.DataFrame: async def main(): - # TODO: Add test evaluation argument to run on the first 10 rows of the dataset file - parser = argparse.ArgumentParser() parser.add_argument( "--experiments", "-e", required=True, help="Path to experiments CSV file" @@ -159,34 +161,35 @@ async def main(): parser.add_argument( "--results", "-r", required=True, help="Path to results CSV file" ) + parser.add_argument( + "--test", "-t", type=int, help="Run evaluation on first n rows of dataset only" + ) args = parser.parse_args() + # Load the experiment DataFrame df_experiment = load_csv( args.experiments, required_columns=["MODEL", "INSTRUCTIONS"] ) - # Check if all models are supported by ModelFactory - if not all( - model in ModelFactory.HANDLERS.keys() - for model in df_experiment["MODEL"].unique() - ): - raise ValueError( - f"Unsupported model(s) found: {set(df_experiment['MODEL'].unique()) - set(ModelFactory.HANDLERS.keys())}" - ) - df_dataset = load_csv(args.dataset, required_columns=["INPUT"]) + + # Load the dataset DataFrame + df_dataset = load_csv(args.dataset, required_columns=["INPUT"], nrows=args.test) # Bulk model and prompt experimentation: Cross join the experiment and dataset DataFrames df_in = df_experiment.merge(df_dataset, how="cross") # Evaluate each row in the input DataFrame concurrently logging.info(f"Starting evaluation of {len(df_in)} rows") + start_time = time.time() tasks = [ evaluate_response(row.MODEL, row.INSTRUCTIONS, row.INPUT) for row in df_in.itertuples(index=False) ] results = await asyncio.gather(*tasks) - logging.info(f"Completed evaluation of {len(results)} rows") + end_time = time.time() + duration = end_time - start_time + logging.info(f"Completed evaluation of {len(results)} rows in {duration} seconds") df_evals = pd.concat(results, axis=0, ignore_index=True) From f4590903ba18e06b3e391e2be1108b03510b3d37 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 29 Jul 2025 14:22:41 -0400 Subject: [PATCH 35/92] F401 imported but unused --- server/api/views/medRules/serializers.py | 28 ++++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/server/api/views/medRules/serializers.py b/server/api/views/medRules/serializers.py index 7638013c..df5e3663 100644 --- a/server/api/views/medRules/serializers.py +++ b/server/api/views/medRules/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers from ...models.model_medRule import MedRule, MedRuleSource -from ..listMeds.models import Medication from ..listMeds.serializers import MedicationSerializer from ...models.model_embeddings import Embeddings @@ -8,7 +7,7 @@ class EmbeddingsSerializer(serializers.ModelSerializer): class Meta: model = Embeddings - fields = ['guid', 'name', 'text', 'page_num', 'chunk_number'] + fields = ["guid", "name", "text", "page_num", "chunk_number"] class MedicationWithSourcesSerializer(serializers.Serializer): @@ -22,28 +21,33 @@ class MedRuleSerializer(serializers.ModelSerializer): class Meta: model = MedRule fields = [ - 'id', 'rule_type', 'history_type', 'reason', 'label', 'explanation', - 'medication_sources' + "id", + "rule_type", + "history_type", + "reason", + "label", + "explanation", + "medication_sources", ] def get_medication_sources(self, obj): - - medrule_sources = MedRuleSource.objects.filter( - medrule=obj).select_related('medication', 'embedding') + medrule_sources = MedRuleSource.objects.filter(medrule=obj).select_related( + "medication", "embedding" + ) med_to_sources = {} for ms in medrule_sources: if ms.medication.id not in med_to_sources: med_to_sources[ms.medication.id] = { - 'medication': ms.medication, - 'sources': [] + "medication": ms.medication, + "sources": [], } - med_to_sources[ms.medication.id]['sources'].append(ms.embedding) + med_to_sources[ms.medication.id]["sources"].append(ms.embedding) return [ { - 'medication': MedicationSerializer(data['medication']).data, - 'sources': EmbeddingsSerializer(data['sources'], many=True).data + "medication": MedicationSerializer(data["medication"]).data, + "sources": EmbeddingsSerializer(data["sources"], many=True).data, } for data in med_to_sources.values() ] From 4e73f1b190445fd7e3fc80d72fd8b46a56229bf9 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 10 Aug 2025 08:33:17 -0400 Subject: [PATCH 36/92] this is to display the sources in the reults and also fix the PDF auto scroller --- frontend/src/api/apiClient.ts | 17 +- frontend/src/pages/DrugSummary/PDFViewer.tsx | 139 +- .../pages/PatientManager/NewPatientForm.tsx | 1567 +++++++++-------- .../pages/PatientManager/PatientManager.tsx | 201 +-- .../pages/PatientManager/PatientSummary.tsx | 1040 ++++++----- .../src/pages/PatientManager/PatientTypes.ts | 13 +- server/api/models/model_medRule.py | 7 +- server/api/views/conversations/views.py | 48 +- server/api/views/listMeds/views.py | 33 +- server/api/views/risk/urls.py | 6 +- server/api/views/risk/views.py | 5 +- .../api/views/risk/views_riskWithSources.py | 303 ++++ 12 files changed, 1916 insertions(+), 1463 deletions(-) create mode 100644 server/api/views/risk/views_riskWithSources.py diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 451c1c41..73b74caf 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -67,6 +67,18 @@ const handleRuleExtraction = async (guid: string) => { } }; +const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { + try { + const response = await api.post(`/v1/api/riskWithSources`, { + drug: medication, + source: source, + }); + return response.data; + } catch (error) { + console.error("Error fetching risk data: ", error); + throw error; + } +}; interface StreamCallbacks { onContent?: (content: string) => void; @@ -165,7 +177,6 @@ const handleSendDrugSummaryStream = async ( } }; - // Legacy function for backward compatibility const handleSendDrugSummaryStreamLegacy = async ( message: string, @@ -256,7 +267,6 @@ const updateConversationTitle = async ( } }; - export { handleSubmitFeedback, handleSendDrugSummary, @@ -268,5 +278,6 @@ export { deleteConversation, updateConversationTitle, handleSendDrugSummaryStream, - handleSendDrugSummaryStreamLegacy + handleSendDrugSummaryStreamLegacy, + fetchRiskDataWithSources }; \ No newline at end of file diff --git a/frontend/src/pages/DrugSummary/PDFViewer.tsx b/frontend/src/pages/DrugSummary/PDFViewer.tsx index d0cd8636..c2e610c7 100644 --- a/frontend/src/pages/DrugSummary/PDFViewer.tsx +++ b/frontend/src/pages/DrugSummary/PDFViewer.tsx @@ -11,6 +11,8 @@ interface DocumentLoadSuccess { numPages: number; } +const PAGE_INIT_DELAY = 800; + const PDFViewer = () => { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(1); @@ -24,14 +26,12 @@ const PDFViewer = () => { null ); - const manualScrollInProgress = useRef(false); - const PAGE_INIT_DELAY = 800; - const headerRef = useRef(null); const containerRef = useRef(null); const contentRef = useRef(null); const pageRefs = useRef>({}); - const initializationRef = useRef(false); + const prevGuidRef = useRef(null); + const isFetchingRef = useRef(false); const location = useLocation(); const navigate = useNavigate(); @@ -39,66 +39,83 @@ const PDFViewer = () => { const guid = params.get("guid"); const pageParam = params.get("page"); - const baseURL = import.meta.env.VITE_API_BASE_URL; - const pdfUrl = useMemo( - () => (guid ? `${baseURL}/v1/api/uploadFile/${guid}` : null), - [guid, baseURL] - ); + const baseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; + + const pdfUrl = useMemo(() => { + const url = guid && baseURL ? `${baseURL}/v1/api/uploadFile/${guid}` : null; + + return url; + }, [guid, baseURL]); useEffect(() => { - pageRefs.current = {}; - setIsDocumentLoaded(false); - initializationRef.current = false; + const nextPage = pageParam ? parseInt(pageParam, 10) : 1; + const guidChanged = guid !== prevGuidRef.current; + + if (guidChanged) { + pageRefs.current = {}; + setIsDocumentLoaded(false); + setNumPages(null); + setPdfData(null); + setPageNumber(1); + } - if (pageParam) { - const page = parseInt(pageParam, 10); - if (!isNaN(page) && page > 0) setTargetPageAfterLoad(page); + if (!isNaN(nextPage) && nextPage > 0) { + setTargetPageAfterLoad(nextPage); } else { setTargetPageAfterLoad(1); } - }, [guid, pageParam]); + + prevGuidRef.current = guid; + }, [guid, pageParam, location.pathname, location.search]); const scrollToPage = useCallback( (page: number) => { - if (page < 1 || !numPages || page > numPages) return; + if (!numPages || page < 1 || page > numPages) { + return; + } + const targetRef = pageRefs.current[page]; - if (!targetRef) return; - - manualScrollInProgress.current = true; - targetRef.scrollIntoView({ behavior: "smooth", block: "start" }); - - const observer = new IntersectionObserver( - (entries, obs) => { - const entry = entries[0]; - if (entry?.isIntersecting) { - manualScrollInProgress.current = false; - obs.disconnect(); - } - }, - { threshold: 0.5 } - ); - observer.observe(targetRef); + if (!targetRef) { + setTimeout(() => scrollToPage(page), 100); + return; + } - const newParams = new URLSearchParams(location.search); - newParams.set("page", String(page)); - navigate(`${location.pathname}?${newParams.toString()}`, { - replace: true, + targetRef.scrollIntoView({ + behavior: "smooth", + block: "start", + inline: "nearest", }); + + const newParams = new URLSearchParams(location.search); + const oldPage = newParams.get("page"); + if (oldPage !== String(page)) { + newParams.set("page", String(page)); + const newUrl = `${location.pathname}?${newParams.toString()}`; + navigate(newUrl, { replace: true }); + } + setPageNumber(page); }, - [numPages, navigate, location.pathname, location.search] + [numPages, location.pathname, location.search, navigate, pageNumber] ); + // Preload-aware navigation: if not loaded yet, just remember target page. const goToPage = useCallback( (page: number) => { if (typeof page !== "number" || isNaN(page)) return; - if (page < 1) page = 1; - else if (numPages && page > numPages) page = numPages; - setPageNumber(page); - scrollToPage(page); + const clamped = Math.max(1, numPages ? Math.min(page, numPages) : page); + + if (!isDocumentLoaded || !numPages) { + setTargetPageAfterLoad(clamped); + return; + } + + if (clamped === pageNumber) return; + setPageNumber(clamped); + scrollToPage(clamped); }, - [numPages, scrollToPage] + [isDocumentLoaded, numPages, pageNumber, scrollToPage] ); useEffect(() => { @@ -115,21 +132,16 @@ const PDFViewer = () => { }, [goToPage]); useEffect(() => { - if ( - isDocumentLoaded && - numPages && - targetPageAfterLoad && - Object.keys(pageRefs.current).length > 0 - ) { + if (isDocumentLoaded && numPages && targetPageAfterLoad) { const validPage = Math.min(Math.max(1, targetPageAfterLoad), numPages); setPageNumber(validPage); - const timeoutId = setTimeout(() => { + const timer = setTimeout(() => { scrollToPage(validPage); setTargetPageAfterLoad(null); }, PAGE_INIT_DELAY); - return () => clearTimeout(timeoutId); + return () => clearTimeout(timer); } }, [isDocumentLoaded, numPages, targetPageAfterLoad, scrollToPage]); @@ -169,7 +181,13 @@ const PDFViewer = () => { const fetchPdf = useCallback(async () => { if (!pdfUrl) return; + if (isFetchingRef.current) { + console.log("⏳ fetchPdf already in progress, skipping duplicate call"); + return; + } + try { + isFetchingRef.current = true; setLoading(true); setError(null); const token = localStorage.getItem("access"); @@ -193,6 +211,7 @@ const PDFViewer = () => { setPdfData(null); } finally { setLoading(false); + isFetchingRef.current = false; } }, [pdfUrl, isPDF]); @@ -230,13 +249,11 @@ const PDFViewer = () => { ← - Page {pageNumber} of {numPages || "-"} + Page {pageNumber} of {numPages ?? "-"} +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    + Select patient characteristics +

    +
    +
    + Currently psychotic +
    + +
    +
    + handleRadioChange(e, "Psychotic")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "Psychotic")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> +
    - )} - {enterNewPatient && ( -
    -
    -
    -

    - {isEditing - ? `Edit Patient ${patientInfo.ID} Details` - : "Enter Patient Details"} - {/* Details */} -

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

    - Select patient characteristics -

    -
    -
    - Currently psychotic -
    - -
    -
    - handleRadioChange(e, "Psychotic")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "Psychotic")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - History of suicide attempt(s) - +
    +
    +
    +
    + History of suicide attempt(s) + info - -
    - -
    -
    - handleRadioChange(e, "Suicide")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "Suicide")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - History or risk of kidney disease - + +
    + +
    +
    + handleRadioChange(e, "Suicide")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "Suicide")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    +
    +
    + History or risk of kidney disease + info - -
    -
    -
    - handleRadioChange(e, "Kidney")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "Kidney")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - History or risk of liver disease - + +
    +
    +
    + handleRadioChange(e, "Kidney")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "Kidney")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    +
    +
    + History or risk of liver disease + info - -
    -
    -
    - handleRadioChange(e, "Liver")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "Liver")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    - -
    -
    - - History or risk of low blood pressure, or concern for - falls - + +
    +
    +
    + handleRadioChange(e, "Liver")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "Liver")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    + +
    +
    + + History or risk of low blood pressure, or concern for + falls + info - -
    - -
    -
    - handleRadioChange(e, "blood_pressure")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "blood_pressure")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - Has weight gain concerns - + +
    + +
    +
    + handleRadioChange(e, "blood_pressure")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "blood_pressure")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    +
    +
    + Has weight gain concerns + info - -
    - -
    -
    - handleRadioChange(e, "weight_gain")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "weight_gain")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - Wants to conceive in next 2 years - + +
    + +
    +
    + handleRadioChange(e, "weight_gain")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "weight_gain")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    +
    +
    + Wants to conceive in next 2 years + info - -
    - -
    -
    - handleRadioChange(e, "risk_pregnancy")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - - handleRadioChange(e, "risk_pregnancy")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    -
    - Any possibility of becoming pregnant - + +
    + +
    +
    + handleRadioChange(e, "risk_pregnancy")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + + handleRadioChange(e, "risk_pregnancy")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    +
    +
    +
    + Any possibility of becoming pregnant + info - -
    -
    -
    - handleRadioChange(e, "any_pregnancy")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - - handleRadioChange(e, "any_pregnancy")} - className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" - /> - -
    -
    -
    -
    - - {/*
    + + +
    +
    + handleRadioChange(e, "any_pregnancy")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + + handleRadioChange(e, "any_pregnancy")} + className="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-600" + /> + +
    +
    + +
    + + {/*
    */} -
    -
    - +
    +
    + med.name)} + value={ + (newPatientInfo.PriorMedications && + newPatientInfo.PriorMedications?.split(",")) || + [] + } + placeholder="Start typing..." + label="" + onChange={(chips) => + setNewPatientInfo({ + ...newPatientInfo, + PriorMedications: chips.join(","), + }) + } + /> +
    +
    + +
    +
    + +
    +
    - - ); + ) : ( +

    + {isEditing ? "Edit Form" : "Submit"} +

    + )} + +
    + +
    + )} +
    +
    + + ); }; export default NewPatientForm; diff --git a/frontend/src/pages/PatientManager/PatientManager.tsx b/frontend/src/pages/PatientManager/PatientManager.tsx index a0b6e46a..2cb8ede3 100644 --- a/frontend/src/pages/PatientManager/PatientManager.tsx +++ b/frontend/src/pages/PatientManager/PatientManager.tsx @@ -1,19 +1,45 @@ -import {useState} from "react"; -import {Link} from "react-router-dom"; +import { useState } from "react"; +import { Link } from "react-router-dom"; import NewPatientForm from "./NewPatientForm.tsx"; import PatientHistory from "./PatientHistory.tsx"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import PatientSummary from "./PatientSummary.tsx"; -import {Diagnosis, PatientInfo} from "./PatientTypes.ts"; -import {copy} from "../../assets/index.js"; +import { Diagnosis, PatientInfo } from "./PatientTypes.ts"; +import { copy } from "../../assets/index.js"; import Welcome from "../../components/Welcome/Welcome.tsx"; -import {useGlobalContext} from "../../contexts/GlobalContext.tsx"; +import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; const PatientManager = () => { + const [patientInfo, setPatientInfo] = useState({ + ID: "", + Diagnosis: Diagnosis.Manic, + OtherDiagnosis: "", + Description: "", + CurrentMedications: "", + PriorMedications: "", + Depression: "", + Hypomania: "", + Mania: "", + Psychotic: "", + Suicide: "", + Kidney: "", + Liver: "", + blood_pressure: "", + weight_gain: "", + Reproductive: "", + risk_pregnancy: "", + PossibleMedications: { + first: [], + second: [], + third: [], + }, + any_pregnancy: "", + }); - - const [patientInfo, setPatientInfo] = useState({ + const handlePatientDeleted = (deletedId: string) => { + if (patientInfo.ID === deletedId) { + setPatientInfo({ ID: "", Diagnosis: Diagnosis.Manic, OtherDiagnosis: "", @@ -31,106 +57,83 @@ const PatientManager = () => { weight_gain: "", Reproductive: "", risk_pregnancy: "", + any_pregnancy: "", PossibleMedications: { - first: "", - second: "", - third: "", + first: [], + second: [], + third: [], }, - any_pregnancy: "" - }); - - const handlePatientDeleted = (deletedId: string) => { - if (patientInfo.ID === deletedId) { - setPatientInfo({ - ID: "", - Diagnosis: Diagnosis.Manic, - OtherDiagnosis: "", - Description: "", - CurrentMedications: "", - PriorMedications: "", - Depression: "", - Hypomania: "", - Mania: "", - Psychotic: "", - Suicide: "", - Kidney: "", - Liver: "", - blood_pressure: "", - weight_gain: "", - Reproductive: "", - risk_pregnancy: "", - any_pregnancy: "" - }); + }); - setIsPatientDeleted(true); - } - }; + setIsPatientDeleted(true); + } + }; - const [allPatientInfo, setAllPatientInfo] = useState([]); - const [isPatientDeleted, setIsPatientDeleted] = useState(false); - const { - showSummary, - setShowSummary, - enterNewPatient, - setEnterNewPatient, - isEditing, - setIsEditing - } = useGlobalContext(); + const [allPatientInfo, setAllPatientInfo] = useState([]); + const [isPatientDeleted, setIsPatientDeleted] = useState(false); + const { + showSummary, + setShowSummary, + enterNewPatient, + setEnterNewPatient, + isEditing, + setIsEditing, + } = useGlobalContext(); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - // TODO: add error and loading state guards + // TODO: add error and loading state guards - const descriptionEl = ( -
    - Use our tool to get medication suggestions for bipolar disorder based on - patient characteristics.{" "} - - Read about where we get our data. - -
    - ); + const descriptionEl = ( +
    + Use our tool to get medication suggestions for bipolar disorder based on + patient characteristics.{" "} + + Read about where we get our data. + +
    + ); - return ( -
    - -
    - - - -
    -
    - ); + return ( +
    + +
    + + + +
    +
    + ); }; export default PatientManager; diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 4e9c44b0..2e9e1f61 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -1,522 +1,626 @@ -import React, {useState, useEffect, useRef} from "react"; -import axios from "axios"; -import {PatientInfo} from "./PatientTypes"; +import React, { useState, useEffect, useRef } from "react"; +import { PatientInfo } from "./PatientTypes"; import Tooltip from "../../components/Tooltip"; import TypingAnimation from "../../components/Header/components/TypingAnimation.tsx"; -import {FaPencilAlt, FaPrint, FaMinus, FaRegThumbsDown} from "react-icons/fa"; +import { FaPencilAlt, FaPrint, FaMinus, FaRegThumbsDown } from "react-icons/fa"; import FeedbackForm from "../Feedback/FeedbackForm"; import Modal from "../../components/Modal/Modal"; -import {EllipsisVertical} from "lucide-react"; - +import { EllipsisVertical } from "lucide-react"; +import { fetchRiskDataWithSources } from "../../api/apiClient.ts"; interface PatientSummaryProps { - showSummary: boolean; - setShowSummary: (state: boolean) => void; - setEnterNewPatient: (isEnteringNewPatient: boolean) => void; - setIsEditing: (isEditing: boolean) => void; - patientInfo: PatientInfo; - isPatientDeleted: boolean; - setPatientInfo: React.Dispatch>; + showSummary: boolean; + setShowSummary: (state: boolean) => void; + setEnterNewPatient: (isEnteringNewPatient: boolean) => void; + setIsEditing: (isEditing: boolean) => void; + patientInfo: PatientInfo; + isPatientDeleted: boolean; + setPatientInfo: React.Dispatch>; } +type SourceItem = { + title: string | null; + publication: string | null; + text: string; + rule_type?: "INCLUDE" | "EXCLUDE" | "include" | "exclude"; + history_type?: string; + guid?: string | null; + page?: number | null; + link_url?: string | null; +}; type RiskData = { - benefits: string[]; - risks: string[]; + benefits: string[]; + risks: string[]; + source?: string; + sources?: SourceItem[]; }; +type MedicationWithSource = { + name: string; + source: "include" | "diagnosis"; +}; + +const truncate = (s = "", n = 220) => + s.length > n ? s.slice(0, n).trim() + "…" : s; +const badge = (label: string) => ( + + {label} + +); const MedicationItem = ({ - medication, - isClicked, - riskData, - loading, - onClick, - }: { - medication: string; - isClicked: boolean; - riskData: RiskData | null; - loading: boolean; - onClick: () => void; + medication, + source, + isClicked, + riskData, + loading, + onClick, +}: { + medication: string; + source: string; + isClicked: boolean; + riskData: RiskData | null; + loading: boolean; + onClick: () => void; }) => { - if (medication === "None") { - return ( -
  • -
    -
    - {medication} -
    -
    -
  • - ); - } - + if (medication === "None") { return ( -
    -
  • -
    -
    - {medication} - {loading && isClicked && ( -
    - -
    - )} -
    -
    -
    - +
  • +
    +
    + {medication} +
    +
    +
  • + ); + } + + return ( +
    +
  • +
    +
    + + {medication} + ({source}) + + {loading && isClicked && ( +
    + +
    + )} +
    +
    +
    + + Sources + +
    +
    + Benefits and risks -
    -
  • - - {isClicked && riskData && ( -
    -
    -
    -

    - Benefits: -

    -
      - {riskData.benefits.map((benefit, index) => ( -
    • - {benefit} -
    • - ))} -
    -
    -
    -

    - Risks: -

    -
      - {riskData.risks.map((risk, index) => ( -
    • - {risk} -
    • - ))} -
    -
    +
    + + + {isClicked && riskData && ( +
    +
    +
    +

    + Benefits: +

    +
      + {riskData.benefits.map((b, i) => ( +
    • + {b} +
    • + ))} +
    +
    +
    +

    + Risks: +

    +
      + {riskData.risks.map((r, i) => ( +
    • + {r} +
    • + ))} +
    +
    +
    + + {/* SOURCES */} + {!!riskData.sources?.length && ( +
    +
    +

    Sources

    + {riskData.source && badge(riskData.source.toUpperCase())} +
    + +
      + {riskData.sources.map((s, idx) => ( +
    • +
      + {s.rule_type && badge(s.rule_type)} + {s.history_type && badge(s.history_type)}
      -
    - )} + +
    + {s.title || "Untitled source"} + + {s.link_url && ( + + View PDF + + )} +
    + + {s.publication && ( +
    + {s.publication} +
    + )} + +

    + {truncate(s.text)} +

    + + {s.page && ( +
    + Page {s.page} +
    + )} + + ))} +
+ + )} - ); + )} + + ); }; const MedicationTier = ({ - title, - medications, - clickedMedication, - riskData, - loading, - onMedicationClick, - }: { - title: string; - medications: string[]; - clickedMedication: string | null; - riskData: RiskData | null; - loading: boolean; - onMedicationClick: (medication: string) => void; + title, + medications, + clickedMedication, + riskData, + loading, + onMedicationClick, +}: { + title: string; + medications: MedicationWithSource[]; + clickedMedication: string | null; + riskData: RiskData | null; + loading: boolean; + onMedicationClick: (medication: MedicationWithSource) => void; }) => ( - <> -
- {title}: -
-
    - {medications.map((medication) => ( - onMedicationClick(medication)} - /> - ))} -
- + <> +
+ {title}: +
+
    + {medications.map((medicationObj) => ( + onMedicationClick(medicationObj)} + /> + ))} +
+ ); const PatientSummary = ({ - showSummary, - setShowSummary, - setEnterNewPatient, - setIsEditing, - patientInfo, - isPatientDeleted, - }: PatientSummaryProps) => { - const [loading, setLoading] = useState(false); - const [riskData, setRiskData] = useState(null); - const [clickedMedication, setClickedMedication] = useState( - null - ); + showSummary, + setShowSummary, + setEnterNewPatient, + setIsEditing, + patientInfo, + isPatientDeleted, +}: PatientSummaryProps) => { + const [loading, setLoading] = useState(false); + const [riskData, setRiskData] = useState(null); + const [clickedMedication, setClickedMedication] = useState( + null + ); - const [isModalOpen, setIsModalOpen] = useState({status: false, id: ""}); + const [isModalOpen, setIsModalOpen] = useState({ status: false, id: "" }); - const handleOpenModal = (id: string, event: React.MouseEvent) => { - event.stopPropagation(); - setIsModalOpen({status: true, id: id}); - }; + const handleOpenModal = (id: string, event: React.MouseEvent) => { + event.stopPropagation(); + setIsModalOpen({ status: true, id: id }); + }; - const handleCloseModal = (event: React.MouseEvent) => { - event.stopPropagation(); - setIsModalOpen({status: false, id: ""}); - }; + const handleCloseModal = (event: React.MouseEvent) => { + event.stopPropagation(); + setIsModalOpen({ status: false, id: "" }); + }; - useEffect(() => { - if (isPatientDeleted) { - setShowSummary(true); - setLoading(false); - setRiskData(null); - setClickedMedication(null); - } - }, [isPatientDeleted]); - - useEffect(() => { - setRiskData(null); - setClickedMedication(null); - }, [patientInfo]); - - const handleClickSummary = () => { - setShowSummary(!showSummary); - }; + useEffect(() => { + if (isPatientDeleted) { + setShowSummary(true); + setLoading(false); + setRiskData(null); + setClickedMedication(null); + } + }, [isPatientDeleted, setShowSummary]); - const handleMedicationClick = async (medication: string) => { - if (clickedMedication === medication) { - setClickedMedication(null); - setRiskData(null); - return; - } - - setClickedMedication(medication); - setLoading(true); - try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.post(`${baseUrl}/chatgpt/risk`, { - diagnosis: medication, - }); - setRiskData(response.data); - } catch (error) { - console.error("Error fetching data: ", error); - } finally { - setLoading(false); - } - }; + useEffect(() => { + setRiskData(null); + setClickedMedication(null); + }, [patientInfo]); - const handlePatientEdit = () => { - setIsEditing(true); - setEnterNewPatient(true); - handleClickSummary(); - console.log({editingPatient: patientInfo}); - }; + const handleClickSummary = () => { + setShowSummary(!showSummary); + }; + const handleMedicationClick = async (medicationObj: MedicationWithSource) => { + const { name: medication, source } = medicationObj; - const handlePatientPrint = (e: any) => { - e.preventDefault(); - window.print(); - }; + if (clickedMedication === medication) { + setClickedMedication(null); + setRiskData(null); + return; + } - const [isMobileDropDownOpen, setIsMobileDropDownOpen] = useState(false) - const mobileMenuRef = useRef(null); + setClickedMedication(medication); + setLoading(true); - const handleMobileDropDownMenu = () => { - setIsMobileDropDownOpen(!isMobileDropDownOpen) + try { + const data = await fetchRiskDataWithSources(medication, source); + setRiskData(data as RiskData); + } catch (error) { + console.error("Error fetching risk data: ", error); + setRiskData(null); + } finally { + setLoading(false); } + }; + + const handlePatientEdit = () => { + setIsEditing(true); + setEnterNewPatient(true); + handleClickSummary(); + console.log({ editingPatient: patientInfo }); + }; + + const handlePatientPrint = (e: any) => { + e.preventDefault(); + window.print(); + }; - const MobileMenuItem = ({item, onClick}: { item: string, onClick: (e: React.MouseEvent) => void }) => { - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(e); - setIsMobileDropDownOpen(false) - } - return (
- {item}
) + const [isMobileDropDownOpen, setIsMobileDropDownOpen] = useState(false); + const mobileMenuRef = useRef(null); + + const handleMobileDropDownMenu = () => { + setIsMobileDropDownOpen(!isMobileDropDownOpen); + }; + + const MobileMenuItem = ({ + item, + onClick, + }: { + item: string; + onClick: (e: React.MouseEvent) => void; + }) => { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + setIsMobileDropDownOpen(false); + }; + return ( +
+ {item} +
+ ); + }; + + useEffect(() => { + const handleClickOutsideMenu = (event: MouseEvent) => { + if ( + mobileMenuRef.current && + !mobileMenuRef.current.contains(event.target as Node) + ) { + setIsMobileDropDownOpen(false); + } + }; + if (isMobileDropDownOpen) { + document.addEventListener("mousedown", handleClickOutsideMenu); } + return () => { + document.removeEventListener("mousedown", handleClickOutsideMenu); + }; + }, [isMobileDropDownOpen]); + const renderMedicationsSection = () => ( +
+
+ Possible Medications: +
+
+ {patientInfo.PossibleMedications && ( + <> + +
+ +
+
+ +
+ + )} +
+
+ ); - useEffect(() => { - const handleClickOutsideMenu = (event: MouseEvent) => { - if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) { - setIsMobileDropDownOpen(false) - } - } - if (isMobileDropDownOpen) { - document.addEventListener('mousedown', handleClickOutsideMenu); - } - return () => { - document.removeEventListener('mousedown', handleClickOutsideMenu); - }; - }, [isMobileDropDownOpen]); - - const renderMedicationsSection = () => ( -
-
- Possible Medications: -
-
- {patientInfo.PossibleMedications && ( - <> - +
+
+ {patientInfo.ID && ( + <> +
+ {!showSummary && ( +
+
+

+ Patient Summary +

+
+ + -
- +
+
+
+ )} + {showSummary && ( +
+
+
+

+ Summary +

+ {isMobileDropDownOpen ? ( +
+
+ -
-
- + { + if (patientInfo.ID) { + handleOpenModal(patientInfo.ID, event); + } + }} + /> +
+
+
+ +
+
+ × +
+
- - )} -
-
- ); - - return ( -
-
-
- {patientInfo.ID && ( - <> -
- {!showSummary && ( -
-
-

- Patient Summary -

-
- - - -
-
-
+ ) : ( +
+
+ +
+
+ × +
+
+ )} + +
+
+
+

+ {" "} + {patientInfo.ID} +

+

+ Patient details and application +

+
+
+
+
+
+
+ Current State: +
+
+ {patientInfo.Diagnosis} +
+
+
+
+
+ Risk Assessment: +
+
+
    + {patientInfo.Psychotic === "Yes" && ( +
  • + Currently psychotic +
  • )} - {showSummary && ( -
    -
    -
    -

    - Summary -

    - {isMobileDropDownOpen ? ( -
    -
    - - - { - if (patientInfo.ID) { - handleOpenModal(patientInfo.ID, event); - } - }} - /> -
    -
    -
    - -
    -
    - × -
    -
    -
    - ) : ( -
    -
    - -
    -
    - × -
    -
    - )} - -
    -
    -
    -

    - {" "} - {patientInfo.ID} -

    -

    - Patient details and application -

    -
    -
    -
    -
    -
    -
    - Current State: -
    -
    - {patientInfo.Diagnosis} -
    -
    -
    -
    -
    - Risk Assessment: -
    -
    -
      - {/* Risk Assessment Items */} - {patientInfo.Psychotic === "Yes" && ( -
    • - Currently psychotic -
    • - )} - {patientInfo.Suicide === "Yes" && ( -
    • - - Patient has a history of suicide attempts - + {patientInfo.Suicide === "Yes" && ( +
    • + + Patient has a history of suicide attempts + info - -
    • - )} - {/* Add other risk assessment items similarly */} -
    -
    -
    -
    -
    -
    -
+
+
+
+
+
+ -
- {patientInfo.PriorMedications?.split(",").join( - ", " - )} -
-
-
- {renderMedicationsSection()} -
-
- + + +
+ {patientInfo.PriorMedications?.split(",").join( + ", " )} +
- - - - - )} + + {renderMedicationsSection()} + + + + )} -
- ); + + + + + )} + + + ); }; export default PatientSummary; diff --git a/frontend/src/pages/PatientManager/PatientTypes.ts b/frontend/src/pages/PatientManager/PatientTypes.ts index 0216bf27..26e40d01 100644 --- a/frontend/src/pages/PatientManager/PatientTypes.ts +++ b/frontend/src/pages/PatientManager/PatientTypes.ts @@ -1,3 +1,8 @@ +export type MedicationWithSource = { + name: string; + source: "include" | "diagnosis"; +}; + export interface PatientInfo { ID?: string; Diagnosis?: Diagnosis; @@ -8,10 +13,10 @@ export interface PatientInfo { Mania?: string; CurrentMedications?: string; PriorMedications?: string; - PossibleMedications?: { - first?: string; - second?: string; - third?: string; + PossibleMedications: { + first: MedicationWithSource[]; + second: MedicationWithSource[]; + third: MedicationWithSource[]; }; Psychotic: string; Suicide: string; diff --git a/server/api/models/model_medRule.py b/server/api/models/model_medRule.py index 3a48c2bc..b8bf4d1b 100644 --- a/server/api/models/model_medRule.py +++ b/server/api/models/model_medRule.py @@ -8,28 +8,24 @@ class MedRule(models.Model): ('INCLUDE', 'Include'), ('EXCLUDE', 'Exclude'), ] - rule_type = models.CharField(max_length=7, choices=RULE_TYPE_CHOICES) history_type = models.CharField(max_length=255) reason = models.TextField(blank=True, null=True) label = models.CharField(max_length=255, blank=True, null=True) explanation = models.TextField(blank=True, null=True) - medications = models.ManyToManyField( Medication, related_name='med_rules' ) - sources = models.ManyToManyField( Embeddings, related_name='med_rules', blank=True, - through='api.MedRuleSource' # Correct fully-qualified through model reference + through='api.MedRuleSource' ) class Meta: db_table = 'api_medrule' - # list of tuples is preferred unique_together = [('rule_type', 'history_type')] def __str__(self): @@ -43,7 +39,6 @@ class MedRuleSource(models.Model): class Meta: db_table = 'api_medrule_sources' - # list of tuples unique_together = [('medrule', 'embedding', 'medication')] def __str__(self): diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index d46f8222..342033ab 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -7,7 +7,7 @@ from bs4 import BeautifulSoup from nltk.stem import PorterStemmer import requests -import openai +from openai import OpenAI, OpenAIError import tiktoken import os import json @@ -25,7 +25,7 @@ def extract_text(request: str) -> JsonResponse: Currently only uses the first 3500 tokens. """ - openai.api_key = os.environ.get("OPENAI_API_KEY") + OpenAI.api_key = os.environ.get("OPENAI_API_KEY") data = json.loads(request.body) webpage_url = data["webpage_url"] @@ -42,7 +42,7 @@ def extract_text(request: str) -> JsonResponse: tokens = get_tokens(text_contents, "cl100k_base") - ai_response = openai.ChatCompletion.create( + ai_response = OpenAI.ChatCompletion.create( model="gpt-3.5-turbo", messages=[ { @@ -64,6 +64,7 @@ def get_tokens(string: str, encoding_name: str) -> str: output_string = encoding.decode(tokens) return output_string + class OpenAIAPIException(APIException): """Custom exception for OpenAI API errors.""" status_code = status.HTTP_500_INTERNAL_SERVER_ERROR @@ -77,6 +78,7 @@ def __init__(self, detail=None, code=None): self.detail = {"error": self.default_detail} self.status_code = code or self.status_code + class ConversationViewSet(viewsets.ModelViewSet): serializer_class = ConversationSerializer permission_classes = [IsAuthenticated] @@ -135,15 +137,15 @@ def update_title(self, request, pk=None): return Response({"status": "Title updated successfully", "title": conversation.title}) def get_chatgpt_response(self, conversation, user_message, page_context=None): + client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) messages = [{ - "role": "system", + "role": "system", "content": "You are a knowledgeable assistant. Balancer is a powerful tool for selecting bipolar medication for patients. We are open-source and available for free use. Your primary role is to assist licensed clinical professionals with information related to Balancer and bipolar medication selection. If applicable, use the supplied tools to assist the professional." }] if page_context: context_message = f"If applicable, please use the following content to ask questions. If not applicable, please answer to the best of your ability: {page_context}" messages.append({"role": "system", "content": context_message}) - for msg in conversation.messages.all(): role = "user" if msg.is_user else "assistant" messages.append({"role": role, "content": msg.content}) @@ -151,7 +153,7 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): messages.append({"role": "user", "content": user_message}) try: - response = openai.ChatCompletion.create( + response = client.chat.completions.create( model="gpt-3.5-turbo", messages=messages, tools=tools, @@ -159,43 +161,45 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): ) response_message = response.choices[0].message - tool_calls = response_message.get('tool_calls', []) + tool_calls = getattr(response_message, "tool_calls", []) + + tool_calls = response_message.model_dump().get("tool_calls", []) if not tool_calls: return response_message['content'] - # Handle tool calls # Add the assistant's message with tool calls to the conversation messages.append({ "role": "assistant", - "content": response_message.get('content', ''), + "content": response_message.content or "", "tool_calls": tool_calls }) - + # Process each tool call for tool_call in tool_calls: tool_call_id = tool_call['id'] tool_function_name = tool_call['function']['name'] - tool_arguments = json.loads(tool_call['function'].get('arguments', '{}')) - + tool_arguments = json.loads( + tool_call['function'].get('arguments', '{}')) + # Execute the tool results = execute_tool(tool_function_name, tool_arguments) - + # Add the tool response message messages.append({ "role": "tool", "content": str(results), # Convert results to string "tool_call_id": tool_call_id }) - + # Final API call with tool results - final_response = openai.ChatCompletion.create( + final_response = client.chat.completions.create( model="gpt-3.5-turbo", - messages=messages - ) - return final_response.choices[0].message['content'] - except openai.error.OpenAIError as e: + messages=messages + ) + return final_response.choices[0].message.content + except OpenAI.error.OpenAIError as e: logging.error("OpenAI API Error: %s", str(e)) raise OpenAIAPIException(detail=str(e)) except Exception as e: @@ -203,12 +207,12 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): raise OpenAIAPIException(detail="An unexpected error occurred.") def generate_title(self, conversation): - # Get the first two messages messages = conversation.messages.all()[:2] context = "\n".join([msg.content for msg in messages]) prompt = f"Based on the following conversation, generate a short, descriptive title (max 6 words):\n\n{context}" - response = openai.ChatCompletion.create( + client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) + response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[ {"role": "system", "content": "You are a helpful assistant that generates short, descriptive titles."}, @@ -216,4 +220,4 @@ def generate_title(self, conversation): ] ) - return response.choices[0].message['content'].strip() + return response.choices[0].message.content.strip() diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 0e7fe584..36ae478f 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -32,21 +32,26 @@ def post(self, request): if diag_query.count() <= 0: return Response({'error': 'Diagnosis not found'}, status=status.HTTP_404_NOT_FOUND) diagnosis = diag_query[0] - meds = {'first': '', 'second': '', 'third': ''} + meds = {'first': [], 'second': [], 'third': []} + + included_set = set(include_result) + excluded_set = set(exclude_result) + for med in include_result: - meds['first'] += med + ", " - for i, line in enumerate(['first', 'second', 'third']): - for suggestion in Suggestion.objects.filter(diagnosis=diagnosis, tier=(i + 1)): - to_exclude = False - for med in exclude_result: - if med in suggestion.medication.name: - to_exclude = True - break - if i > 0 and suggestion.medication.name in include_result: - to_exclude = True - if not to_exclude: - meds[line] += suggestion.medication.name + ", " - meds[line] = meds[line][:-2] if meds[line] else 'None' + meds['first'].append({'name': med, 'source': 'include'}) + + for i, tier_label in enumerate(['first', 'second', 'third']): + suggestions = Suggestion.objects.filter( + diagnosis=diagnosis, tier=i+1) + for suggestion in suggestions: + med_name = suggestion.medication.name + if med_name in excluded_set: + continue + if i > 0 and med_name in included_set: + continue + meds[tier_label].append( + {'name': med_name, 'source': 'diagnosis'}) + return Response(meds) diff --git a/server/api/views/risk/urls.py b/server/api/views/risk/urls.py index 30e53424..8f0c8dd4 100644 --- a/server/api/views/risk/urls.py +++ b/server/api/views/risk/urls.py @@ -1,6 +1,8 @@ from django.urls import path from api.views.risk import views +from api.views.risk.views_riskWithSources import RiskWithSourcesView urlpatterns = [ - path("chatgpt/risk", views.medication, name="risk") -] + path("chatgpt/risk", views.medication, name="risk"), + path("v1/api/riskWithSources", RiskWithSourcesView.as_view()), +] diff --git a/server/api/views/risk/views.py b/server/api/views/risk/views.py index ea1a77a8..99327a8d 100644 --- a/server/api/views/risk/views.py +++ b/server/api/views/risk/views.py @@ -14,10 +14,11 @@ def medication(request): data = json.loads(request.body) if data is not None: - diagnosis = data["diagnosis"] # the variable name is diagnosis but this variable contain the medication name + # the variable name is diagnosis but this variable contain the medication name + diagnosis = data["diagnosis"] else: return JsonResponse({"error": "Diagnosis not found. Request must include diagnosis."}) - + try: med = Medication.objects.get(name=diagnosis) benefits = [f'- {benefit}' for benefit in med.benefits.split(', ')] diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py new file mode 100644 index 00000000..93795274 --- /dev/null +++ b/server/api/views/risk/views_riskWithSources.py @@ -0,0 +1,303 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from api.views.listMeds.models import Medication, Diagnosis, Suggestion +from api.models.model_medRule import MedRule, MedRuleSource +import openai +import os + + +class RiskWithSourcesView(APIView): + def post(self, request): + openai.api_key = os.environ.get("OPENAI_API_KEY") + + drug = request.data.get("drug") + if not drug: + return Response({"error": "Drug not found. Request must include 'drug'."}, status=status.HTTP_400_BAD_REQUEST) + + source = request.data.get("source") + if source not in ["include", "diagnosis"]: + return Response({"error": "Source must be either 'include' or 'diagnosis'."}, status=status.HTTP_400_BAD_REQUEST) + + # Handle diagnosis source by linking to medrules + if source == "diagnosis": + return self._handle_diagnosis_source(drug) + + if source == "include": + return self._handle_include_source(drug) + + # Handle include source (existing logic) + try: + med = Medication.objects.get(name=drug) + benefits = [f'- {b.strip()}' for b in med.benefits.split(',')] + risks = [f'- {r.strip()}' for r in med.risks.split(',')] + return Response({ + 'benefits': benefits, + 'risks': risks + }) + + except Medication.DoesNotExist: + prompt = ( + f"You are to provide a concise list of 5 key benefits and 5 key risks " + f"for the medication suggested when taking it for Bipolar. Each point should be short, " + f"clear and be kept under 10 words. Begin the benefits section with !!!benefits!!! and " + f"the risks section with !!!risk!!!. Please provide this information for the medication: {drug}." + ) + + try: + ai_response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=[{"role": "system", "content": prompt}] + ) + except Exception as e: + return Response({"error": f"OpenAI request failed: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + content = ai_response['choices'][0]['message']['content'] + + if '!!!benefits!!!' not in content or '!!!risks!!!' not in content: + return Response({"error": "Unexpected format in OpenAI response."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + benefits_raw = content.split('!!!risks!!!')[0].replace( + '!!!benefits!!!', '').strip() + risks_raw = content.split('!!!risks!!!')[1].strip() + + benefits = [line.strip() + for line in benefits_raw.split('\n') if line.strip()] + risks = [line.strip() + for line in risks_raw.split('\n') if line.strip()] + + return Response({ + 'benefits': benefits, + 'risks': risks + }) + + def _handle_include_source(self, drug): + """Handle include source by looking up medrules for the medication""" + try: + # Get the medication + medication = Medication.objects.get(name=drug) + + print( + f"Found medication '{medication.name}' for '{drug}' with ID {medication.id}") + + # Find medrules that include this medication + medrule_ids = MedRuleSource.objects.filter( + medication=medication, + medrule__rule_type='INCLUDE' + ).values_list('medrule_id', flat=True).distinct() + + medrules = MedRule.objects.filter(id__in=medrule_ids) + print(f"Found {medrules.count()} medrules for {drug}") + benefits = [] + risks = [] + sources_info = [] + + # Extract benefits and sources + for medrule in medrules: + if medrule.explanation: + benefits.append(f"- {medrule.explanation}") + + # Get associated sources through MedRuleSource + medrule_sources = MedRuleSource.objects.filter( + medrule=medrule, + medication=medication + ) + print( + f"Found {medrule_sources.count()} sources for medrule {medrule.id}") + + for source_link in medrule_sources: + embedding = source_link.embedding + + source_info = { + 'title': getattr(embedding, 'title', 'Unknown source'), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + # Add link data for PDF navigation + 'upload_fileid': getattr(embedding, 'upload_file_id', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding) + } + + sources_info.append(source_info) + + # Check EXCLUDE rules for risks + exclude_rules = MedRule.objects.filter( + medications=medication, + rule_type='EXCLUDE' + ) + + for rule in exclude_rules: + if rule.explanation: + risks.append(f"- {rule.explanation}") + + if not benefits and not risks: + basic_benefits = [ + f'- {b.strip()}' for b in medication.benefits.split(',')] + basic_risks = [ + f'- {r.strip()}' for r in medication.risks.split(',')] + + return Response({ + 'benefits': basic_benefits, + 'risks': basic_risks, + 'source': 'include', + 'note': 'No specific medrule sources found, showing general medication information' + }) + + return Response({ + 'benefits': [f'- {b.strip()}' for b in medication.benefits.split(',')], + 'risks': risks if risks else [f'- {r.strip()}' for r in medication.risks.split(',')], + 'source': 'include', + 'sources': sources_info, + 'medrules_found': len(medrules) + len(exclude_rules) + }) + + except Medication.DoesNotExist: + return Response({"error": f"Medication '{drug}' not found."}, status=status.HTTP_404_NOT_FOUND) + + def _handle_diagnosis_source(self, drug): + """Handle diagnosis source by looking up medrules for the medication""" + try: + # Get the medication + medication = Medication.objects.get(name=drug) + + # Find medrules that include this medication + medrules = MedRule.objects.filter( + medications=medication, + rule_type='INCLUDE' + ) + + benefits = [] + risks = [] + sources_info = [] + + # Extract information from medrules and their sources + for medrule in medrules: + if medrule.explanation: + benefits.append(f"- {medrule.explanation}") + + # Get associated sources through MedRuleSource + medrule_sources = MedRuleSource.objects.filter( + medrule=medrule, + medication=medication + ) + + for source_link in medrule_sources: + embedding = source_link.embedding + source_info = { + 'title': getattr(embedding, 'title', 'Unknown source'), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + # Add link data for PDF navigation + 'guid': getattr(embedding, 'guid', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding) + } + sources_info.append(source_info) + + # Also check for exclude rules (risks) + exclude_rules = MedRule.objects.filter( + medications=medication, + rule_type='EXCLUDE' + ) + + for rule in exclude_rules: + if rule.explanation: + risks.append(f"- {rule.explanation}") + + # If no medrule data found, fall back to basic medication data + if not benefits and not risks: + basic_benefits = [ + f'- {b.strip()}' for b in medication.benefits.split(',')] + basic_risks = [ + f'- {r.strip()}' for r in medication.risks.split(',')] + + return Response({ + 'benefits': basic_benefits, + 'risks': basic_risks, + 'source': 'diagnosis', + 'note': 'No specific medrule sources found, showing general medication information' + }) + + return Response({ + 'benefits': benefits if benefits else [f'- {b.strip()}' for b in medication.benefits.split(',')], + 'risks': risks if risks else [f'- {r.strip()}' for r in medication.risks.split(',')], + 'source': 'diagnosis', + 'sources': sources_info, + 'medrules_found': len(medrules) + len(exclude_rules) + }) + + except Medication.DoesNotExist: + # If medication not in database, use AI fallback with diagnosis context + return self._get_ai_response_for_diagnosis(drug) + + def _build_pdf_link(self, embedding): + """Build the PDF viewer link URL by getting the document GUID from UploadFile""" + try: + # Get the upload_fileid from the embedding + upload_fileid = getattr(embedding, 'upload_file_id', None) + page = getattr(embedding, 'page_num', None) + + if not upload_fileid: + return None + + from api.views.uploadFile.models import UploadFile + + # Get the UploadFile record to get the document GUID + upload_file = UploadFile.objects.get(id=upload_fileid) + document_guid = upload_file.guid + + if document_guid: + base_url = "/drugsummary" + if page: + return f"{base_url}?guid={document_guid}&page={page}" + else: + return f"{base_url}?guid={document_guid}" + + except Exception as e: + print(f"Error building PDF link: {e}") + return None + + return None + + def _get_ai_response_for_diagnosis(self, drug): + """Get AI response with diagnosis-specific context""" + prompt = ( + f"You are providing medication information from a diagnosis/clinical perspective. " + f"Provide a concise list of 5 key benefits and 5 key risks for the medication {drug} " + f"when prescribed for Bipolar disorder, focusing on clinical evidence and diagnostic considerations. " + f"Each point should be short, clear and be kept under 10 words. " + f"Begin the benefits section with !!!benefits!!! and the risks section with !!!risk!!!." + ) + + try: + ai_response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=[{"role": "system", "content": prompt}] + ) + except Exception as e: + return Response({"error": f"OpenAI request failed: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + content = ai_response['choices'][0]['message']['content'] + + if '!!!benefits!!!' not in content or '!!!risks!!!' not in content: + return Response({"error": "Unexpected format in OpenAI response."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + benefits_raw = content.split('!!!risks!!!')[0].replace( + '!!!benefits!!!', '').strip() + risks_raw = content.split('!!!risks!!!')[1].strip() + + benefits = [line.strip() + for line in benefits_raw.split('\n') if line.strip()] + risks = [line.strip() + for line in risks_raw.split('\n') if line.strip()] + + return Response({ + 'benefits': benefits, + 'risks': risks, + 'source': 'diagnosis', + 'note': 'Generated from AI with diagnosis context - medication not found in database' + }) From 0add944e28e003db5e6d0a6cffead7d5fdd220f8 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 10 Aug 2025 08:35:28 -0400 Subject: [PATCH 37/92] delete libraries not used --- server/api/views/conversations/views.py | 2 +- server/api/views/risk/views_riskWithSources.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index 342033ab..d5921eaf 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -7,7 +7,7 @@ from bs4 import BeautifulSoup from nltk.stem import PorterStemmer import requests -from openai import OpenAI, OpenAIError +from openai import OpenAI import tiktoken import os import json diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index 93795274..d1c01615 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from api.views.listMeds.models import Medication, Diagnosis, Suggestion +from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai import os From 6f3c8a34db1447b9c14cecf79ffac403efec4936 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Wed, 13 Aug 2025 22:36:19 -0400 Subject: [PATCH 38/92] feat: #335 - edit wording of header banner --- frontend/src/components/Header/Header.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 4bc4df48..3b383067 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -102,10 +102,21 @@ const Header: React.FC = ({

- This app is currently in its beta testing phase. The information and - tools provided herein are intended for general informational purposes - only and should NOT be construed as medical advice, diagnosis, or - treatment. + Welcome to Balancer’s first release! Found a bug or have feedback? Let us know {" "} + + here + {" "} + or email {" "} + + balancerteam@codeforphilly.org + .

From 097570b44911531ab5dcec04a0be4cdf037bc922 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Wed, 13 Aug 2025 22:48:03 -0400 Subject: [PATCH 39/92] feat: #337 - add 'Donate' button to the About page --- frontend/src/pages/About/About.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index 4d4a14a6..03e1ca24 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -110,8 +110,14 @@ function About() {
-
- +
+ + + + + From 21f0d90a12110ab898d7335a47a036be26817567 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Wed, 13 Aug 2025 23:10:57 -0400 Subject: [PATCH 40/92] feat: #339 - Add donate link to top nav, sidenav, and footer; restructure footer to stack vertically on mobile --- frontend/src/components/Footer/Footer.tsx | 10 ++++++++-- frontend/src/components/Header/Header.tsx | 10 ++++++++-- frontend/src/components/Header/MdNavBar.tsx | 8 ++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index eeb01935..34820b9b 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -28,11 +28,11 @@ function Footer() { return ( //
-
+
{/*
*/}{" "} {/* Added mt-5 and mr-5 */}
diff --git a/frontend/src/pages/PatientManager/PatientManager.tsx b/frontend/src/pages/PatientManager/PatientManager.tsx index 2cb8ede3..a8c10f0e 100644 --- a/frontend/src/pages/PatientManager/PatientManager.tsx +++ b/frontend/src/pages/PatientManager/PatientManager.tsx @@ -87,14 +87,17 @@ const PatientManager = () => { const descriptionEl = (
- Use our tool to get medication suggestions for bipolar disorder based on - patient characteristics.{" "} - - Read about where we get our data. - +

Use our tool to explore medication options for bipolar disorder based on patient characteristics.

+

+ + Read about where we get our data. + +

+

Balancer is an educational resource designed to support —never replace— the judgment of licensed U.S. clinicians.

+

Final prescribing decisions must always be made by the treating clinician.

); From d077d9837d259b2349fa4c605c1ccbb0bb419b6f Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 17 Aug 2025 15:15:59 -0400 Subject: [PATCH 51/92] update dockerfile because of needing new image --- server/Dockerfile | 5 +- server/api/views/listMeds/views.py | 73 ++++++++++++++---------------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 4b11bb05..e410c505 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,4 @@ -# pull official base image -FROM python:3.11.4-slim-buster +FROM python:3.11.4-slim-bullseye # set work directory WORKDIR /usr/src/server @@ -24,4 +23,4 @@ COPY . /usr/src/server RUN sed -i 's/\r$//' /usr/src/server/entrypoint.sh && chmod +x /usr/src/server/entrypoint.sh # run entrypoint.sh -ENTRYPOINT ["/usr/src/server/entrypoint.sh"] +ENTRYPOINT ["/usr/src/server/entrypoint.sh"] \ No newline at end of file diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 36ae478f..796d9b17 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,14 +1,21 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView + from .models import Diagnosis, Medication, Suggestion from .serializers import MedicationSerializer # Constants for medication inclusion and exclusion -MEDS_INCLUDE = {'suicideHistory': ['Lithium']} +MEDS_INCLUDE = { + 'suicideHistory': ['Lithium'] +} + MED_EXCLUDE = { 'kidneyHistory': ['Lithium'], 'liverHistory': ['Valproate'], - 'bloodPressureHistory': ['Asenapine', 'Lurasidone', 'Olanzapine', 'Paliperidone', 'Quetiapine', 'Risperidone', 'Ziprasidone', 'Aripiprazole', 'Cariprazine'], + 'bloodPressureHistory': [ + 'Asenapine', 'Lurasidone', 'Olanzapine', 'Paliperidone', + 'Quetiapine', 'Risperidone', 'Ziprasidone', 'Aripiprazole', 'Cariprazine' + ], 'weightGainConcern': ['Quetiapine', 'Risperidone', 'Aripiprazole', 'Olanzapine'] } @@ -26,12 +33,13 @@ def post(self, request): if data.get(condition, False): # Remove any medication from include list that is in the exclude list include_result = [ - med for med in include_result if med not in MED_EXCLUDE[condition]] + med for med in include_result if med not in MED_EXCLUDE[condition] + ] exclude_result.extend(MED_EXCLUDE[condition]) - diag_query = Diagnosis.objects.filter(state=state_query) - if diag_query.count() <= 0: + try: + diagnosis = Diagnosis.objects.get(state=state_query) + except Diagnosis.DoesNotExist: return Response({'error': 'Diagnosis not found'}, status=status.HTTP_404_NOT_FOUND) - diagnosis = diag_query[0] meds = {'first': [], 'second': [], 'third': []} included_set = set(include_result) @@ -42,15 +50,18 @@ def post(self, request): for i, tier_label in enumerate(['first', 'second', 'third']): suggestions = Suggestion.objects.filter( - diagnosis=diagnosis, tier=i+1) + diagnosis=diagnosis, tier=i+1 + ) for suggestion in suggestions: med_name = suggestion.medication.name if med_name in excluded_set: continue if i > 0 and med_name in included_set: continue - meds[tier_label].append( - {'name': med_name, 'source': 'diagnosis'}) + meds[tier_label].append({ + 'name': med_name, + 'source': 'diagnosis' + }) return Response(meds) @@ -71,20 +82,7 @@ def get(self, request): return Response(serializer.data) def post(self, request): - # Implement logic for adding new medications (if needed) - # If adding medications, you would check if the medication already exists before creating it - data = request.data - name = data.get('name', '') - if not name: - return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) - if Medication.objects.filter(name=name).exists(): - return Response({'error': 'Medication already exists'}, status=status.HTTP_400_BAD_REQUEST) - # Assuming Medication model has `name`, `benefits`, `risks` as fields - serializer = MedicationSerializer(data=data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': 'Use AddMedication endpoint for creating medications'}, status=status.HTTP_405_METHOD_NOT_ALLOWED) class AddMedication(APIView): @@ -97,13 +95,15 @@ def post(self, request): name = data.get('name', '').strip() benefits = data.get('benefits', '').strip() risks = data.get('risks', '').strip() - # Validate the inputs + + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) if not benefits: return Response({'error': 'Medication benefits are required'}, status=status.HTTP_400_BAD_REQUEST) if not risks: return Response({'error': 'Medication risks are required'}, status=status.HTTP_400_BAD_REQUEST) + # Check if medication already exists if Medication.objects.filter(name=name).exists(): return Response({'error': f'Medication "{name}" already exists'}, status=status.HTTP_400_BAD_REQUEST) @@ -116,25 +116,22 @@ def post(self, request): class DeleteMedication(APIView): - "API endpoint to delete medication if medication in database" + """ + API endpoint to delete medication if medication in database. + """ def delete(self, request): data = request.data name = data.get('name', '').strip() - print("ok vin") - # Validate the inputs + + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) - # Check if medication exists - if Medication.objects.filter(name=name).exists(): - # return f'Medication "{name}" exists' - # Get the medication object + + # Check if medication exists and delete + try: medication = Medication.objects.get(name=name) - # Delete the medication medication.delete() - return Response({'success': "medication exists and will now be deleted"}, status=status.HTTP_201_CREATED) - else: - return Response({'error': 'Medication does not exist'}, status=status.HTTP_400_BAD_REQUEST) - # ask user if sure to delete? - # delete med from database - # Medication.objects.filter(name=name) + return Response({'success': f'Medication "{name}" has been deleted'}, status=status.HTTP_200_OK) + except Medication.DoesNotExist: + return Response({'error': f'Medication "{name}" does not exist'}, status=status.HTTP_404_NOT_FOUND) From 9c403dd5726a9992d9262693d45d59e44b411c05 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 17 Aug 2025 17:12:39 -0400 Subject: [PATCH 52/92] update zoom in the pdf reader --- frontend/src/components/Header/Header.tsx | 419 ++++++++++--------- frontend/src/pages/DrugSummary/PDFViewer.tsx | 326 ++++++++++----- frontend/src/pages/DrugSummary/ZoomMenu.tsx | 167 ++++++++ 3 files changed, 614 insertions(+), 298 deletions(-) create mode 100644 frontend/src/pages/DrugSummary/ZoomMenu.tsx diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 0df73a25..32039605 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,231 +1,254 @@ -import {useState, useRef, useEffect, Fragment} from "react"; +import { useState, useRef, useEffect, Fragment } from "react"; // import { useState, Fragment } from "react"; import accountLogo from "../../assets/account.svg"; -import {Link, useNavigate, useLocation} from "react-router-dom"; +import { Link, useNavigate, useLocation } from "react-router-dom"; import LoginMenuDropDown from "./LoginMenuDropDown"; import "../../components/Header/header.css"; import Chat from "./Chat"; -import {FeatureMenuDropDown} from "./FeatureMenuDropDown"; +import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; import MdNavBar from "./MdNavBar"; -import {connect, useDispatch} from "react-redux"; -import {RootState} from "../../services/actions/types"; -import {logout, AppDispatch} from "../../services/actions/auth"; -import {HiChevronDown} from "react-icons/hi"; -import {useGlobalContext} from "../../contexts/GlobalContext.tsx"; +import { connect, useDispatch } from "react-redux"; +import { RootState } from "../../services/actions/types"; +import { logout, AppDispatch } from "../../services/actions/auth"; +import { HiChevronDown } from "react-icons/hi"; +import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; - isSuperuser: boolean; + isAuthenticated: boolean; + isSuperuser: boolean; } -const Header: React.FC = ({ - isAuthenticated, - isSuperuser, - }) => { - const navigate = useNavigate(); - const [showFeaturesMenu, setShowFeaturesMenu] = useState(false); - const dropdownRef = useRef(null); - let delayTimeout: number | null = null; - const [showChat, setShowChat] = useState(false); - const [showLoginMenu, setShowLoginMenu] = useState(false); - const [redirect, setRedirect] = useState(false); - const {setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing} = useGlobalContext() - - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - setRedirect(false); - }; - - const guestLinks = () => ( - + ); - const authLinks = () => ( - + ); + + const handleLoginMenu = () => { + setShowLoginMenu(!showLoginMenu); + }; + + const handleMouseEnter = () => { + if (delayTimeout !== null) { + clearTimeout(delayTimeout); + } + setShowFeaturesMenu(true); + }; + + const handleMouseLeave = () => { + delayTimeout = setTimeout(() => { + setShowFeaturesMenu(false); + }, 300) as unknown as number; // Adjust the delay time as needed + }; + + useEffect(() => { + return () => { + if (delayTimeout !== null) { + clearTimeout(delayTimeout); + } }; - - const handleMouseEnter = () => { - if (delayTimeout !== null) { - clearTimeout(delayTimeout); + }, [delayTimeout]); + + useEffect(() => { + window.scrollTo(0, 0); + }, [navigate]); + + const handleForm = () => { + setIsEditing(false); + triggerFormReset(); + setEnterNewPatient(true); + setShowSummary(false); + navigate("/"); + }; + + const location = useLocation(); + const currentPath = location.pathname; + + return ( +
+
+

+ This app is currently in its beta testing phase. The information and + tools provided herein are intended for general informational purposes + only and should NOT be construed as medical advice, diagnosis, or + treatment.{" "} + + here + {" "} + or email{" "} + + balancerteam@codeforphilly.org + + . +

+
+
+ App is in beta; Do not use info as medical advice. +
+ + )} + + {redirect ? navigate("/") : } + + + + {isAuthenticated && ( + + )} + {/* */} + {isAuthenticated ? authLinks() : guestLinks()} +
+ +
+ ); }; const mapStateToProps = (state: RootState) => ({ - isAuthenticated: state.auth.isAuthenticated, - isSuperuser: state.auth.isSuperuser, + isAuthenticated: state.auth.isAuthenticated, + isSuperuser: state.auth.isSuperuser, }); const ConnectedLayout = connect(mapStateToProps)(Header); -export default ConnectedLayout; \ No newline at end of file +export default ConnectedLayout; diff --git a/frontend/src/pages/DrugSummary/PDFViewer.tsx b/frontend/src/pages/DrugSummary/PDFViewer.tsx index c2e610c7..267fd1ab 100644 --- a/frontend/src/pages/DrugSummary/PDFViewer.tsx +++ b/frontend/src/pages/DrugSummary/PDFViewer.tsx @@ -1,9 +1,18 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { + useState, + useEffect, + useMemo, + useCallback, + useRef, + useTransition, + useDeferredValue, +} from "react"; import { Document, Page, pdfjs } from "react-pdf"; import { useLocation, useNavigate } from "react-router-dom"; import axios from "axios"; import "react-pdf/dist/esm/Page/AnnotationLayer.css"; import "react-pdf/dist/esm/Page/TextLayer.css"; +import ZoomMenu from "./ZoomMenu"; pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`; @@ -17,6 +26,10 @@ const PDFViewer = () => { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1.0); + const [uiScalePct, setUiScalePct] = useState(100); + const deferredScale = useDeferredValue(scale); + const [isPending, startTransition] = useTransition(); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [pdfData, setPdfData] = useState(null); @@ -42,11 +55,11 @@ const PDFViewer = () => { const baseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; const pdfUrl = useMemo(() => { - const url = guid && baseURL ? `${baseURL}/v1/api/uploadFile/${guid}` : null; - - return url; + return guid && baseURL ? `${baseURL}/v1/api/uploadFile/${guid}` : null; }, [guid, baseURL]); + useEffect(() => setUiScalePct(Math.round(scale * 100)), [scale]); + useEffect(() => { const nextPage = pageParam ? parseInt(pageParam, 10) : 1; const guidChanged = guid !== prevGuidRef.current; @@ -59,20 +72,73 @@ const PDFViewer = () => { setPageNumber(1); } - if (!isNaN(nextPage) && nextPage > 0) { - setTargetPageAfterLoad(nextPage); - } else { - setTargetPageAfterLoad(1); - } - + setTargetPageAfterLoad(!isNaN(nextPage) && nextPage > 0 ? nextPage : 1); prevGuidRef.current = guid; }, [guid, pageParam, location.pathname, location.search]); + const updateCurrentPageFromScroll = useCallback(() => { + if (!numPages || !contentRef.current) return; + + const container = contentRef.current; + const containerRectTop = container.getBoundingClientRect().top; + const containerCenter = containerRectTop + container.clientHeight / 2; + + let bestPage = 1; + let bestDist = Infinity; + + for (let i = 1; i <= (numPages ?? 0); i++) { + const el = pageRefs.current[i]; + if (!el) continue; + const r = el.getBoundingClientRect(); + const pageCenter = r.top + r.height / 2; + const dist = Math.abs(pageCenter - containerCenter); + if (dist < bestDist) { + bestDist = dist; + bestPage = i; + } + } + + if (bestPage !== pageNumber) { + setPageNumber(bestPage); + const newParams = new URLSearchParams(location.search); + newParams.set("page", String(bestPage)); + navigate(`${location.pathname}?${newParams.toString()}`, { + replace: true, + }); + } + }, [numPages, pageNumber, location.pathname, location.search, navigate]); + + useEffect(() => { + const container = contentRef.current; + if (!container) return; + + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + updateCurrentPageFromScroll(); + ticking = false; + }); + }; + + container.addEventListener("scroll", onScroll, { passive: true }); + return () => container.removeEventListener("scroll", onScroll); + }, [updateCurrentPageFromScroll]); + + useEffect(() => { + updateCurrentPageFromScroll(); + }, [ + numPages, + deferredScale, + containerSize.width, + containerSize.height, + updateCurrentPageFromScroll, + ]); + const scrollToPage = useCallback( (page: number) => { - if (!numPages || page < 1 || page > numPages) { - return; - } + if (!numPages || page < 1 || page > numPages) return; const targetRef = pageRefs.current[page]; if (!targetRef) { @@ -90,28 +156,27 @@ const PDFViewer = () => { const oldPage = newParams.get("page"); if (oldPage !== String(page)) { newParams.set("page", String(page)); - const newUrl = `${location.pathname}?${newParams.toString()}`; - navigate(newUrl, { replace: true }); + navigate(`${location.pathname}?${newParams.toString()}`, { + replace: true, + }); } setPageNumber(page); }, - [numPages, location.pathname, location.search, navigate, pageNumber] + [numPages, location.pathname, location.search, navigate] ); - // Preload-aware navigation: if not loaded yet, just remember target page. const goToPage = useCallback( (page: number) => { if (typeof page !== "number" || isNaN(page)) return; const clamped = Math.max(1, numPages ? Math.min(page, numPages) : page); - if (!isDocumentLoaded || !numPages) { setTargetPageAfterLoad(clamped); return; } - if (clamped === pageNumber) return; + setPageNumber(clamped); scrollToPage(clamped); }, @@ -146,20 +211,39 @@ const PDFViewer = () => { }, [isDocumentLoaded, numPages, targetPageAfterLoad, scrollToPage]); useEffect(() => { - const calculateSize = () => { - if (containerRef.current && headerRef.current && contentRef.current) { - const headerHeight = headerRef.current.offsetHeight; - const contentPadding = 32; - const availableHeight = - containerRef.current.clientHeight - headerHeight - contentPadding; - const availableWidth = contentRef.current.clientWidth - contentPadding; - - setContainerSize({ width: availableWidth, height: availableHeight }); - } + const calc = () => { + if (!containerRef.current || !headerRef.current || !contentRef.current) + return; + const headerHeight = headerRef.current.offsetHeight; + const contentPadding = 32; + const availableHeight = + containerRef.current.clientHeight - headerHeight - contentPadding; + const availableWidth = contentRef.current.clientWidth - contentPadding; + setContainerSize({ + width: Math.max(0, availableWidth), + height: Math.max(0, availableHeight), + }); + }; + + const id = requestAnimationFrame(calc); + + let ro: ResizeObserver | null = null; + if ("ResizeObserver" in window && contentRef.current) { + ro = new ResizeObserver(() => calc()); + ro.observe(contentRef.current); + } else { + const onResize = () => requestAnimationFrame(calc); + window.addEventListener("resize", onResize); + return () => { + cancelAnimationFrame(id); + window.removeEventListener("resize", onResize); + }; + } + + return () => { + cancelAnimationFrame(id); + if (ro && contentRef.current) ro.disconnect(); }; - calculateSize(); - window.addEventListener("resize", calculateSize); - return () => window.removeEventListener("resize", calculateSize); }, []); const pdfOptions = useMemo( @@ -180,11 +264,7 @@ const PDFViewer = () => { }, []); const fetchPdf = useCallback(async () => { - if (!pdfUrl) return; - if (isFetchingRef.current) { - console.log("⏳ fetchPdf already in progress, skipping duplicate call"); - return; - } + if (!pdfUrl || isFetchingRef.current) return; try { isFetchingRef.current = true; @@ -231,6 +311,9 @@ const PDFViewer = () => { if (!guid) return
No document specified.
; + const baseWidth = Math.max(0, (containerSize.width || 0) - 50); + const readyToRender = !!file && containerSize.width > 0; + return (
{ >
-
+
+ setUiScalePct(pct)} + onSelectPct={(pct) => { + setUiScalePct(pct); + startTransition(() => setScale(pct / 100)); + }} + onPageFit={() => { + const pct = 100; + setUiScalePct(pct); + startTransition(() => setScale(pct / 100)); + }} + /> +
+ +
- - Page {pageNumber} of {numPages ?? "-"} - + +
+ {pageNumber} + of {numPages || "-"} +
+
-
- - {Math.round(scale * 100)}% - + +
+ {isPending && ( +
+ Rendering… +
+ )}
+
{ Retry
- ) : ( - file && ( -
+ setError(err.message)} + options={pdfOptions} > - setError(err.message)} - options={pdfOptions} - > -
- {Array.from({ length: numPages || 0 }, (_, index) => { - const pageNum = index + 1; - return ( -
{ - pageRefs.current[pageNum] = el; - }} - className="mb-4 w-full" - data-page={pageNum} - > - -
- Page {pageNum} of {numPages} -
+
+ {Array.from({ length: numPages || 0 }, (_, index) => { + const pageNum = index + 1; + return ( +
{ + pageRefs.current[pageNum] = el; + }} + className="mb-4 w-full" + data-page={pageNum} + > + +
+ Page {pageNum} of {numPages}
- ); - })} -
- -
- ) +
+ ); + })} +
+
+
+ ) : ( +
)}
diff --git a/frontend/src/pages/DrugSummary/ZoomMenu.tsx b/frontend/src/pages/DrugSummary/ZoomMenu.tsx new file mode 100644 index 00000000..2e51b227 --- /dev/null +++ b/frontend/src/pages/DrugSummary/ZoomMenu.tsx @@ -0,0 +1,167 @@ +// ZoomMenu.tsx +import { useEffect, useMemo, useRef, useState } from "react"; + +type Props = { + valuePct: number; + onSelectPct: (pct: number) => void; + onDeferPct?: (pct: number) => void; + onPageFit?: () => void; // +}; + +const ZOOM_STEPS = [50, 75, 100, 125, 150, 200, 300, 400]; + +export default function ZoomMenu({ + valuePct, + onSelectPct, + onDeferPct, + onPageFit, +}: Props) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const popRef = useRef(null); + + // nearest step helpers + const stepIndex = useMemo(() => { + let idx = 0, + bestDiff = Infinity; + ZOOM_STEPS.forEach((s, i) => { + const d = Math.abs(s - valuePct); + if (d < bestDiff) { + bestDiff = d; + idx = i; + } + }); + return idx; + }, [valuePct]); + + const dec = () => onSelectPct(ZOOM_STEPS[Math.max(0, stepIndex - 1)]); + const inc = () => + onSelectPct(ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, stepIndex + 1)]); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!popRef.current || !anchorRef.current) return; + const t = e.target as Node; + if (!popRef.current.contains(t) && !anchorRef.current.contains(t)) + setOpen(false); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onEsc); + return () => { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onEsc); + }; + }, [open]); + + return ( +
+ {/* Trigger */} + + + {/* Popover */} + {open && ( +
+ {/* header: current % + -/+ */} +
+
+ {valuePct}% +
+
+ + +
+
+ + {/* options */} +
    +
  • + +
  • + {ZOOM_STEPS.map((pct) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} From 08656ba6f1e7dad650c55ae6ad9a5adc7b8f1127 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 17 Aug 2025 19:36:51 -0400 Subject: [PATCH 53/92] feat: #357 - Content updates to About page --- frontend/src/App.css | 5 ++ frontend/src/pages/About/About.tsx | 131 ++++++++++------------------- 2 files changed, 49 insertions(+), 87 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 1944da14..718ef1ad 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -130,6 +130,11 @@ This is the wording logo in the nav bar @apply bg-gradient-to-r from-blue-600 via-blue-700 to-blue-600 bg-clip-text text-transparent; } +/* Using logo-like styles outside of the header */ +.body_logo { + @apply bg-gradient-to-r from-blue-500 via-blue-700 to-blue-300 bg-clip-text font-quicksand font-bold text-transparent +} + /* Tailwind Styles */ /* diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index 03e1ca24..10035a73 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -6,102 +6,60 @@ import image from "./OIP2.png"; function About() { return ( -
- {/* Top section */} -
-
-
- A tool that makes it easier to research medications for bipolar - disorder. -
-
- It can take two to 10 years—and three to 30 medications—for people - with bipolar disorder to find the right medication combination. - Balancer is designed to help physicians shorten this journey for - their patients. -
-
- about image -
- - {/* Middle section */} -
-
-
- Get accurate, helpful information on bipolar medications fast -
-
- Powered by innovative AI technology, Balancer is a tool that aids - in providing personalized medication recommendations for patients - with bipolar disorder in any state, including mania, depression, - hypomania and mixed. Our platform utilizes machine learning to - give you the latest, most up-to-date information on medications - and active clinical trials to treat bipolar disorder.{" "} -
-
- Balancer automates medication decision support by offering - tailored medication recommendations and comprehensive risk-benefit - assessments based on a patient's diagnosis, symptom severity, - treatment goals and individual characteristics.{" "} -
-
Our mission
-
-
- Bipolar disorder affects approximately 5.7 million adult - Americans{" "} - - every year - - . Delays in the correct diagnosis and proper treatment of - bipolar disorder may result in social, occupational, and - economic burdens, as well as{" "} - - an increase in completed suicides - - . +
+ {/* Making it easier to research bipolar medications */} +
+
+
+
+ Making it easier to research bipolar medications
-
- The team behind Balancer believes that building a searchable, - interactive and user-friendly research tool for bipolar - medications has the potential to improve the health and - well-being of people with bipolar disorder. +
+ It can take two to 10 years—and three to 30 medications—for people with bipolar disorder to find the right medication combination. Balancer is designed to help prescribers speed up that process by making research faster and more accessible.
+ about image
- {/*
-
-
44 million
-
Transactions every 24 hours
-
$119 million
-
Assets under holding
-
46,000
-
New users annually
-
-
*/}
- + {/* How Balancer works */} +
+
+ How Balancer works +
+
    +
  • + Medication Suggestions (rules-based): +

    When you enter patient characteristics, Balancer suggests first-line, second-line, and third-line options. The recommendations follow a consistent framework developed from interviews with psychiatrists, psychiatry residents, nurse practitioners, and other prescribers. This part is not powered by AI.

    +
  • +
  • + Explanations & Research (AI-assisted): +

    For each suggestion, you can click to see supporting journal articles. Here, Balancer uses AI to search our database of medical research and highlight relevant sources for further reading.

    +
  • +
+

Together, these features help prescribers get reliable starting points quickly—without replacing professional judgment.

+
+ {/* Important disclaimer */} +
+
+ Important disclaimer +
+

Balancer is a free, open-source research tool built by volunteers at Code for Philly. It is for licensed U.S. prescribers and researchers only.

+
    +
  • Balancer does not provide medical advice.

  • +
  • It does not determine treatment or replace clinical judgment.

  • +
  • Clinical decisions should always be based on the prescriber's expertise, knowledge of the patient, and official medical guidelines.

  • +
+
{/* Support Us section */} -
+
Support Us
-
-
- Balancer is a not-for-profit, civic-minded, open-source project +
+
+ Balancer is a not-for-profit, civic-minded, open-source project sponsored by{" "} Code for Philly @@ -109,7 +67,6 @@ function About() { .
-
diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index 1802e832..b53874bf 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; +import { api } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -30,17 +30,17 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const [downloading, setDownloading] = useState(null); const [opening, setOpening] = useState(null); + const baseUrl = import.meta.env.VITE_API_BASE_URL; + useEffect(() => { const fetchFiles = async () => { try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - }); - if (Array.isArray(response.data)) { - setFiles(response.data); + const url = `${baseUrl}/v1/api/uploadFile`; + + const { data } = await api.get(url); + + if (Array.isArray(data)) { + setFiles(data); } } catch (error) { console.error("Error fetching files", error); @@ -50,7 +50,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ }; fetchFiles(); - }, []); + }, [baseUrl]); const updateFileName = (guid: string, updatedFile: Partial) => { setFiles((prevFiles) => @@ -63,15 +63,9 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile/${guid}`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - responseType: "blob", - }); + const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); - const url = window.URL.createObjectURL(new Blob([response.data])); + const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); link.href = url; link.setAttribute("download", fileName); @@ -90,15 +84,9 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile/${guid}`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - responseType: "arraybuffer", - }); + const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); - const file = new Blob([response.data], { type: 'application/pdf' }); + const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); window.open(fileURL); } catch (error) { @@ -118,17 +106,24 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ { Header: 'Date Published', accessor: 'publication_date' }, { Header: '', accessor: 'file_open' }, ]; + + const formatUTCDate = (dateStr: string | null) => { + if (!dateStr) return "N/A"; + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric" + }); + const formattedDate = formatter.format(new Date(dateStr)); + return formattedDate; + } + const data = files.map((file) => ( { file_name: file?.title || file.file_name.replace(/\.[^/.]+$/, ""), publication: file?.publication || '', - publication_date: file.publication_date - ? new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit" - }).format(new Date(file.publication_date)) - : "", + publication_date: formatUTCDate(file.publication_date), file_open: Date: Mon, 18 Aug 2025 14:32:51 -0400 Subject: [PATCH 55/92] Add assistant urls and views --- server/api/views/assistant/urls.py | 5 + server/api/views/assistant/views.py | 217 ++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 server/api/views/assistant/urls.py create mode 100644 server/api/views/assistant/views.py diff --git a/server/api/views/assistant/urls.py b/server/api/views/assistant/urls.py new file mode 100644 index 00000000..4c68f952 --- /dev/null +++ b/server/api/views/assistant/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from .views import Assistant + +urlpatterns = [path("v1/api/assistant", Assistant.as_view(), name="assistant")] diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py new file mode 100644 index 00000000..cb67a480 --- /dev/null +++ b/server/api/views/assistant/views.py @@ -0,0 +1,217 @@ +import os +import json +import logging +from typing import Callable + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt + +from openai import OpenAI + +from ...services.embedding_services import get_closest_embeddings +from ...services.conversions_services import convert_uuids + +# Configure logging +logger = logging.getLogger(__name__) + + +# 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.debug(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.debug("Reasoning step") + return intermediate_messages + + +@method_decorator(csrf_exempt, name="dispatch") +class Assistant(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + user = request.user + + client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + + TOOL_DESCRIPTION = """ + Search through the user's uploaded documents using semantic similarity matching. + This function finds the most relevant document chunks based on the input query and + returns contextual information including page numbers, chunk locations, and similarity scores. + Use this to answer the user's questions. + """ + + TOOL_PROPERTY_DESCRIPTION = """ + The search query to find semantically similar content in uploaded documents. + Should be a natural language question or keyword phrase. + """ + + 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']}, 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." + + MODEL_DEFAULTS = { + "model": "gpt-5-nano", # 400,000 token context window + "reasoning": {"effort": "medium"}, + "tools": tools, + } + + # We fetch a response and then kick off a loop to handle the response + + request_data = request.data.get("message", None) + if not request_data: + return Response( + {"error": "Message data is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + message = str(request_data) + + response = client.responses.create( + input=[{"type": "text", "text": message}], **MODEL_DEFAULTS + ) + + # 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(f"Reasoning completed for user {user.id}") + final_response = response.output_text + logger.debug( + f"Final response length: {len(final_response)} characters" + ) + break + else: + logger.debug("More reasoning required, continuing...") + response = client.responses.create( + input=function_responses, + previous_response_id=response.id, + **MODEL_DEFAULTS, + ) + + return Response({"response": final_response}, status=status.HTTP_200_OK) + + except Exception as e: + logger.error( + f"Unexpected error in Assistant view for user {request.user.id if hasattr(request, 'user') else 'unknown'}: {e}", + exc_info=True, + ) + return Response( + {"error": "An unexpected error occurred. Please try again later."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) From 674c8c33d7a0ff9e8f29561cf423b2bbe03ff42a Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 18 Aug 2025 14:36:03 -0400 Subject: [PATCH 56/92] Add docstrings and minor formatting improvements for code clarity --- server/api/services/conversions_services.py | 17 ++++ server/api/services/embedding_services.py | 56 +++++++++--- server/api/services/tools/tools.py | 43 ++++----- server/api/views/conversations/urls.py | 9 +- server/api/views/conversations/views.py | 97 ++++++++++++--------- server/api/views/text_extraction/urls.py | 17 ++-- 6 files changed, 158 insertions(+), 81 deletions(-) diff --git a/server/api/services/conversions_services.py b/server/api/services/conversions_services.py index d134ff49..71931f17 100644 --- a/server/api/services/conversions_services.py +++ b/server/api/services/conversions_services.py @@ -2,6 +2,23 @@ def convert_uuids(data): + """ + Recursively convert UUID objects to strings in nested data structures. + + Traverses dictionaries, lists, and other data structures to find UUID objects + and converts them to their string representation for serialization. + + Parameters + ---------- + data : any + The data structure to process (dict, list, UUID, or any other type) + + Returns + ------- + any + The data structure with all UUID objects converted to strings. + Structure and types are preserved except for UUID -> str conversion. + """ if isinstance(data, dict): return {key: convert_uuids(value) for key, value in data.items()} elif isinstance(data, list): diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 5aacab38..6fd34d35 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,29 +1,63 @@ # services/embedding_services.py + +from pgvector.django import L2Distance + from .sentencetTransformer_model import TransformerModel + # Adjust import path as needed from ..models.model_embeddings import Embeddings -from pgvector.django import L2Distance -def get_closest_embeddings(user, message_data, document_name=None, guid=None, num_results=10): +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 + """ + # transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) # Start building the query based on the message's embedding - closest_embeddings_query = Embeddings.objects.filter( - upload_file__uploaded_by=user - ).annotate( - distance=L2Distance( - 'embedding_sentence_transformers', embedding_message) - ).order_by('distance') + closest_embeddings_query = ( + Embeddings.objects.filter(upload_file__uploaded_by=user) + .annotate( + distance=L2Distance("embedding_sentence_transformers", embedding_message) + ) + .order_by("distance") + ) # Filter by GUID if provided, otherwise filter by document name if provided if guid: closest_embeddings_query = closest_embeddings_query.filter( - upload_file__guid=guid) + upload_file__guid=guid + ) elif document_name: - closest_embeddings_query = closest_embeddings_query.filter( - name=document_name) + closest_embeddings_query = closest_embeddings_query.filter(name=document_name) # Slice the results to limit to num_results closest_embeddings_query = closest_embeddings_query[:num_results] diff --git a/server/api/services/tools/tools.py b/server/api/services/tools/tools.py index f9fa14c8..03ef1fd3 100644 --- a/server/api/services/tools/tools.py +++ b/server/api/services/tools/tools.py @@ -1,6 +1,8 @@ -from django.db import connection from typing import Dict, Any, Callable, List from dataclasses import dataclass + +from django.db import connection + from .database import ask_database, get_database_info database_schema_dict = get_database_info(connection) @@ -11,6 +13,7 @@ ] ) + @dataclass class ToolFunction: name: str @@ -18,6 +21,7 @@ class ToolFunction: description: str parameters: Dict[str, Any] + def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: return { "type": "function", @@ -28,10 +32,11 @@ def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: "type": "object", "properties": tool.parameters, "required": list(tool.parameters.keys()), - } - } + }, + }, } + TOOL_FUNCTIONS = [ ToolFunction( name="ask_database", @@ -56,60 +61,58 @@ def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: SQL should be written using this database schema: {database_schema_string} The query should be returned in plain text, not in JSON. - """ + """, } - } + }, ), ] # Automatically generate the tool_functions dictionary and tools list -tool_functions: Dict[str, Callable] = { - tool.name: tool.func for tool in TOOL_FUNCTIONS -} +tool_functions: Dict[str, Callable] = {tool.name: tool.func for tool in TOOL_FUNCTIONS} + +tools: List[Dict[str, Any]] = [create_tool_dict(tool) for tool in TOOL_FUNCTIONS] -tools: List[Dict[str, Any]] = [ - create_tool_dict(tool) for tool in TOOL_FUNCTIONS -] def validate_tool_inputs(tool_function_name, tool_arguments): """Validate the inputs for the execute_tool function.""" if not isinstance(tool_function_name, str) or not tool_function_name: raise ValueError("Invalid tool function name") - + if not isinstance(tool_arguments, dict): raise ValueError("Tool arguments must be a dictionary") - + # Check if the tool_function_name exists in the tools tool = next((t for t in tools if t["function"]["name"] == tool_function_name), None) if not tool: raise ValueError(f"Tool function '{tool_function_name}' does not exist") - + # Validate the tool arguments based on the tool's parameters parameters = tool["function"].get("parameters", {}) required_params = parameters.get("required", []) for param in required_params: if param not in tool_arguments: raise ValueError(f"Missing required parameter: {param}") - + # Check if the parameter types match the expected types properties = parameters.get("properties", {}) for param, prop in properties.items(): - expected_type = prop.get('type') + expected_type = prop.get("type") if param in tool_arguments: - if expected_type == 'string' and not isinstance(tool_arguments[param], str): + if expected_type == "string" and not isinstance(tool_arguments[param], str): raise ValueError(f"Parameter '{param}' must be of type string") - + + def execute_tool(function_name: str, arguments: Dict[str, Any]) -> str: """ Execute the appropriate function based on the function name. - + :param function_name: The name of the function to execute :param arguments: A dictionary of arguments to pass to the function :return: The result of the function execution """ # Validate tool inputs validate_tool_inputs(function_name, arguments) - + try: return tool_functions[function_name](**arguments) except Exception as e: diff --git a/server/api/views/conversations/urls.py b/server/api/views/conversations/urls.py index f10d6814..8896d964 100644 --- a/server/api/views/conversations/urls.py +++ b/server/api/views/conversations/urls.py @@ -1,13 +1,12 @@ from django.urls import path, include -from api.views.conversations import views from rest_framework.routers import DefaultRouter -# from views import ConversationViewSet + +from api.views.conversations import views router = DefaultRouter() -router.register(r'conversations', views.ConversationViewSet, - basename='conversation') +router.register(r"conversations", views.ConversationViewSet, basename="conversation") urlpatterns = [ path("chatgpt/extract_text/", views.extract_text, name="post_web_text"), - path("chatgpt/", include(router.urls)) + path("chatgpt/", include(router.urls)), ] diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index d5921eaf..ac7f1ba2 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,3 +1,7 @@ +import os +import json +import logging + from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.decorators import action @@ -9,10 +13,8 @@ import requests from openai import OpenAI import tiktoken -import os -import json -import logging from django.views.decorators.csrf import csrf_exempt + from .models import Conversation, Message from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool @@ -67,6 +69,7 @@ def get_tokens(string: str, encoding_name: str) -> str: class OpenAIAPIException(APIException): """Custom exception for OpenAI API errors.""" + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR default_detail = "An error occurred while communicating with the OpenAI API." default_code = "openai_api_error" @@ -95,26 +98,29 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def continue_conversation(self, request, pk=None): conversation = self.get_object() - user_message = request.data.get('message') - page_context = request.data.get('page_context') + user_message = request.data.get("message") + page_context = request.data.get("page_context") if not user_message: return Response({"error": "Message is required"}, status=400) # Save user message - Message.objects.create(conversation=conversation, - content=user_message, is_user=True) + Message.objects.create( + conversation=conversation, content=user_message, is_user=True + ) # Get ChatGPT response chatgpt_response = self.get_chatgpt_response( - conversation, user_message, page_context) + conversation, user_message, page_context + ) # Save ChatGPT response - Message.objects.create(conversation=conversation, - content=chatgpt_response, is_user=False) + Message.objects.create( + conversation=conversation, content=chatgpt_response, is_user=False + ) # Generate or update title if it's the first message or empty if conversation.messages.count() <= 2 or not conversation.title: @@ -123,25 +129,31 @@ def continue_conversation(self, request, pk=None): return Response({"response": chatgpt_response, "title": conversation.title}) - @action(detail=True, methods=['patch']) + @action(detail=True, methods=["patch"]) def update_title(self, request, pk=None): conversation = self.get_object() - new_title = request.data.get('title') + new_title = request.data.get("title") if not new_title: - return Response({"error": "New title is required"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "New title is required"}, status=status.HTTP_400_BAD_REQUEST + ) conversation.title = new_title conversation.save() - return Response({"status": "Title updated successfully", "title": conversation.title}) + return Response( + {"status": "Title updated successfully", "title": conversation.title} + ) def get_chatgpt_response(self, conversation, user_message, page_context=None): client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - messages = [{ - "role": "system", - "content": "You are a knowledgeable assistant. Balancer is a powerful tool for selecting bipolar medication for patients. We are open-source and available for free use. Your primary role is to assist licensed clinical professionals with information related to Balancer and bipolar medication selection. If applicable, use the supplied tools to assist the professional." - }] + messages = [ + { + "role": "system", + "content": "You are a knowledgeable assistant. Balancer is a powerful tool for selecting bipolar medication for patients. We are open-source and available for free use. Your primary role is to assist licensed clinical professionals with information related to Balancer and bipolar medication selection. If applicable, use the supplied tools to assist the professional.", + } + ] if page_context: context_message = f"If applicable, please use the following content to ask questions. If not applicable, please answer to the best of your ability: {page_context}" @@ -157,7 +169,7 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): model="gpt-3.5-turbo", messages=messages, tools=tools, - tool_choice="auto" + tool_choice="auto", ) response_message = response.choices[0].message @@ -166,37 +178,41 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): tool_calls = response_message.model_dump().get("tool_calls", []) if not tool_calls: - return response_message['content'] + return response_message["content"] # Handle tool calls # Add the assistant's message with tool calls to the conversation - messages.append({ - "role": "assistant", - "content": response_message.content or "", - "tool_calls": tool_calls - }) + messages.append( + { + "role": "assistant", + "content": response_message.content or "", + "tool_calls": tool_calls, + } + ) # Process each tool call for tool_call in tool_calls: - tool_call_id = tool_call['id'] - tool_function_name = tool_call['function']['name'] + tool_call_id = tool_call["id"] + tool_function_name = tool_call["function"]["name"] tool_arguments = json.loads( - tool_call['function'].get('arguments', '{}')) + tool_call["function"].get("arguments", "{}") + ) # Execute the tool results = execute_tool(tool_function_name, tool_arguments) # Add the tool response message - messages.append({ - "role": "tool", - "content": str(results), # Convert results to string - "tool_call_id": tool_call_id - }) + messages.append( + { + "role": "tool", + "content": str(results), # Convert results to string + "tool_call_id": tool_call_id, + } + ) # Final API call with tool results final_response = client.chat.completions.create( - model="gpt-3.5-turbo", - messages=messages + model="gpt-3.5-turbo", messages=messages ) return final_response.choices[0].message.content except OpenAI.error.OpenAIError as e: @@ -215,9 +231,12 @@ def generate_title(self, conversation): response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[ - {"role": "system", "content": "You are a helpful assistant that generates short, descriptive titles."}, - {"role": "user", "content": prompt} - ] + { + "role": "system", + "content": "You are a helpful assistant that generates short, descriptive titles.", + }, + {"role": "user", "content": prompt}, + ], ) return response.choices[0].message.content.strip() diff --git a/server/api/views/text_extraction/urls.py b/server/api/views/text_extraction/urls.py index bdf6244f..d0af979e 100644 --- a/server/api/views/text_extraction/urls.py +++ b/server/api/views/text_extraction/urls.py @@ -1,11 +1,16 @@ from django.urls import path -from .views import RuleExtractionAPIView, RuleExtractionAPIOpenAIView +from .views import RuleExtractionAPIView, RuleExtractionAPIOpenAIView urlpatterns = [ - - path('v1/api/rule_extraction', RuleExtractionAPIView.as_view(), - name='rule_extraction'), - path('v1/api/rule_extraction_openai', RuleExtractionAPIOpenAIView.as_view(), - name='rule_extraction_openai') + path( + "v1/api/rule_extraction", + RuleExtractionAPIView.as_view(), + name="rule_extraction", + ), + path( + "v1/api/rule_extraction_openai", + RuleExtractionAPIOpenAIView.as_view(), + name="rule_extraction_openai", + ), ] From 1e71158f5c7974545496d9a29d2bef7d51ffa176 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 18 Aug 2025 14:50:11 -0400 Subject: [PATCH 57/92] Update balancer_backend/urls.py --- server/balancer_backend/urls.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 1c5bad8b..f0772721 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -1,6 +1,8 @@ from django.contrib import admin # Import Django's admin interface module + # Import functions for URL routing and including other URL configs from django.urls import path, include, re_path + # Import TemplateView for rendering templates from django.views.generic import TemplateView import importlib # Import the importlib module for dynamic module importing @@ -10,25 +12,37 @@ # Map 'admin/' URL to the Django admin interface path("admin/", admin.site.urls), # Include Djoser's URL patterns under 'auth/' for basic auth - path('auth/', include('djoser.urls')), + path("auth/", include("djoser.urls")), # Include Djoser's JWT auth URL patterns under 'auth/' - path('auth/', include('djoser.urls.jwt')), + path("auth/", include("djoser.urls.jwt")), # Include Djoser's social auth URL patterns under 'auth/' - path('auth/', include('djoser.social.urls')), + path("auth/", include("djoser.social.urls")), ] # List of application names for which URL patterns will be dynamically added -urls = ['conversations', 'feedback', 'listMeds', 'risk', - 'uploadFile', 'ai_promptStorage', 'ai_settings', 'embeddings', 'medRules', 'text_extraction'] +urls = [ + "conversations", + "feedback", + "listMeds", + "risk", + "uploadFile", + "ai_promptStorage", + "ai_settings", + "embeddings", + "medRules", + "text_extraction", + "assistants", +] # Loop through each application name and dynamically import and add its URL patterns for url in urls: # Dynamically import the URL module for each app - url_module = importlib.import_module(f'api.views.{url}.urls') + url_module = importlib.import_module(f"api.views.{url}.urls") # Append the URL patterns from each imported module - urlpatterns += getattr(url_module, 'urlpatterns', []) + urlpatterns += getattr(url_module, "urlpatterns", []) # Add a catch-all URL pattern for handling SPA (Single Page Application) routing # Serve 'index.html' for any unmatched URL urlpatterns += [ - re_path(r'^.*$', TemplateView.as_view(template_name='index.html')),] + re_path(r"^.*$", TemplateView.as_view(template_name="index.html")), +] From 3342224349f4f97a73348832194465bac66c4881 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 18 Aug 2025 15:17:18 -0400 Subject: [PATCH 58/92] Fix typo in balancer_backend urls --- server/balancer_backend/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index f0772721..56f307e4 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -31,7 +31,7 @@ "embeddings", "medRules", "text_extraction", - "assistants", + "assistant", ] # Loop through each application name and dynamically import and add its URL patterns From 68f65d47e54f42a1cd27e40df575be9618b1b994 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Mon, 18 Aug 2025 16:01:53 -0400 Subject: [PATCH 59/92] feat: #359 - Add animation to chat button --- frontend/src/components/Header/Chat.tsx | 7 ++----- frontend/src/components/Header/chat.css | 19 +++++++++++++++++++ frontend/tailwind.config.js | 4 ++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 54e965f8..df11f68b 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -475,12 +475,9 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { ) : (
setShowChat(true)} - className="fixed bottom-9 left-10 h-16 w-16 inline-block cursor-pointer flex items-center justify-center rounded-full bg-blue-500 object-contain hover:cursor-pointer hover:bg-blue-300 md:bottom-20 md:right-20 no-print" + className="chat_button no-print" > - -
- Any questions? Click here to to chat! -
+
)} diff --git a/frontend/src/components/Header/chat.css b/frontend/src/components/Header/chat.css index 0d0d1f8e..65e5e07f 100644 --- a/frontend/src/components/Header/chat.css +++ b/frontend/src/components/Header/chat.css @@ -1,3 +1,11 @@ +.chat_button { + @apply fixed bottom-9 left-10 h-16 w-16 cursor-pointer rounded-full bg-blue-500 hover:cursor-pointer hover:bg-blue-300 md:bottom-20 md:right-20 flex items-center justify-center animate-pulse-bounce; +} + +.chat_button_animations { + @apply flex justify-center items-center; +} + .inside_chat { @apply grow px-2 py-2 bg-neutral-100 overflow-y-auto overflow-x-hidden; } @@ -87,4 +95,15 @@ ul.chat_suggestion_list { .scroll_down { @apply z-40 absolute bottom-[90px] left-[45%] text-3xl text-gray-400 hover:text-blue-500 rounded-full border-2 border-white bg-white; +} + +@keyframes pulse-bounce { + 0%, 100% { + opacity: 1; + transform: translateY(0); + } + 50% { + opacity: .5; + transform: translateY(-25%); + } } \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 7ca214a8..bcc1e693 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -10,6 +10,10 @@ export default { lora: "'Lora', serif", 'quicksand': ['Quicksand', 'sans-serif'] }, + animation: { + 'pulse-bounce': 'pulse-bounce 2s infinite', // Adjust duration and iteration as needed + }, + plugins: [], }, }, plugins: [], From fef2899fea1f338ad8349c2235902be45ca755e5 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 18 Aug 2025 18:27:37 -0400 Subject: [PATCH 60/92] Improve logging, documentation, and response handling in assistant views and settings configuration --- server/api/views/assistant/views.py | 87 +++++++++---- server/balancer_backend/settings.py | 182 ++++++++++++++++------------ 2 files changed, 167 insertions(+), 102 deletions(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index cb67a480..f8db1632 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -73,7 +73,7 @@ def invoke_functions_from_response( } ) elif response_item.type == "reasoning": - logger.debug("Reasoning step") + logger.debug(f"Reasoning step: {response_item.summary}") return intermediate_messages @@ -88,15 +88,16 @@ def post(self, request): client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) TOOL_DESCRIPTION = """ - Search through the user's uploaded documents using semantic similarity matching. - This function finds the most relevant document chunks based on the input query and - returns contextual information including page numbers, chunk locations, and similarity scores. - Use this to answer the user's questions. + 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 = """ - The search query to find semantically similar content in uploaded documents. - Should be a natural language question or keyword phrase. + 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 = [ @@ -119,7 +120,7 @@ def post(self, request): def search_documents(query: str, user=user) -> str: """ - Search through user's uploaded documents using semantic similarity. + 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. @@ -162,25 +163,58 @@ 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." + INSTRUCTIONS = """ + You are an AI assistant that helps users find and understand information about bipolar disorder + from their uploaded bipolar disorder research documents using semantic search. + + 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 their documents 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 document 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. Include citations after EACH sentence using this exact format: ***[File ID {file_id}, Page {page_number}, Chunk {chunk_number}]*** + 4. Only cite information that directly supports your statements + + If no relevant information is found in the documents, clearly state that the information is not available in the uploaded documents. + """ + MODEL_DEFAULTS = { + "instructions": INSTRUCTIONS, "model": "gpt-5-nano", # 400,000 token context window - "reasoning": {"effort": "medium"}, + "reasoning": {"effort": "medium", "summary": "auto"}, "tools": tools, } # We fetch a response and then kick off a loop to handle the response - request_data = request.data.get("message", None) - if not request_data: - return Response( - {"error": "Message data is required."}, - status=status.HTTP_400_BAD_REQUEST, - ) - message = str(request_data) + message = request.data.get("message", None) + previous_response_id = request.data.get("previous_response_id", None) - response = client.responses.create( - input=[{"type": "text", "text": message}], **MODEL_DEFAULTS - ) + 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, + ) # Open AI Cookbook: Handling Function Calls with Reasoning Models # https://cookbook.openai.com/examples/reasoning_function_calls @@ -190,10 +224,11 @@ def search_documents(query: str, user=user) -> str: response, tool_mapping={"search_documents": search_documents} ) if len(function_responses) == 0: # We're done reasoning - logger.info(f"Reasoning completed for user {user.id}") - final_response = response.output_text + logger.info("Reasoning completed") + final_response_output_text = response.output_text + final_response_id = response.id logger.debug( - f"Final response length: {len(final_response)} characters" + f"Final response length: {len(final_response_output_text)} characters" ) break else: @@ -204,7 +239,13 @@ def search_documents(query: str, user=user) -> str: **MODEL_DEFAULTS, ) - return Response({"response": final_response}, status=status.HTTP_200_OK) + return Response( + { + "response_output_text": final_response_output_text, + "final_response_id": final_response_id, + }, + status=status.HTTP_200_OK, + ) except Exception as e: logger.error( diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 91a53a8b..0b4a204c 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,57 +29,56 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split() +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. -if ALLOWED_HOSTS == ['*'] or ALLOWED_HOSTS == ['']: - ALLOWED_HOSTS = ['*'] +if ALLOWED_HOSTS == ["*"] or ALLOWED_HOSTS == [""]: + ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'balancer_backend', - 'api', - 'corsheaders', - 'rest_framework', - 'djoser', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "balancer_backend", + "api", + "corsheaders", + "rest_framework", + "djoser", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'corsheaders.middleware.CorsMiddleware', - + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", ] -ROOT_URLCONF = 'balancer_backend.urls' +ROOT_URLCONF = "balancer_backend.urls" CORS_ALLOW_ALL_ORIGINS = True TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'build')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "build")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -89,7 +88,7 @@ # Change this to your desired URL LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL") -WSGI_APPLICATION = 'balancer_backend.wsgi.application' +WSGI_APPLICATION = "balancer_backend.wsgi.application" # Database @@ -106,8 +105,8 @@ } } -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.gmail.com' +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", "") @@ -119,25 +118,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -147,64 +146,89 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'build/static'), + os.path.join(BASE_DIR, "build/static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATIC_ROOT = os.path.join(BASE_DIR, "static") AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", ), } SIMPLE_JWT = { - 'AUTH_HEADER_TYPES': ('JWT',), - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'TOKEN_OBTAIN_SERIALIZER': 'api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer', - 'AUTH_TOKEN_CLASSES': ( - 'rest_framework_simplejwt.tokens.AccessToken', - ) + "AUTH_HEADER_TYPES": ("JWT",), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "TOKEN_OBTAIN_SERIALIZER": "api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } DJOSER = { - 'LOGIN_FIELD': 'email', - 'USER_CREATE_PASSWORD_RETYPE': True, - 'USERNAME_CHANGED_EMAIL_CONFIRMATION': True, - 'PASSWORD_CHANGED_EMAIL_CONFIRMATION': True, - 'SEND_CONFIRMATION_EMAIL': True, - 'SET_USERNAME_RETYPE': True, - 'SET_PASSWORD_RETYPE': True, - 'PASSWORD_RESET_CONFIRM_URL': 'password/reset/confirm/{uid}/{token}', - 'USERNAME_RESET_CONFIRM_URL': 'email/reset/confirm/{uid}/{token}', - 'ACTIVATION_URL': 'activate/{uid}/{token}', - 'SEND_ACTIVATION_EMAIL': True, - 'SOCIAL_AUTH_TOKEN_STRATEGY': 'djoser.social.token.jwt.TokenStrategy', - 'SOCIAL_AUTH_ALLOWED_REDIRECT_URIS': ['http://localhost:8000/google', 'http://localhost:8000/facebook'], - 'SERIALIZERS': { - 'user_create': 'api.models.serializers.UserCreateSerializer', - 'user': 'api.models.serializers.UserCreateSerializer', - 'current_user': 'api.models.serializers.UserCreateSerializer', - 'user_delete': 'djoser.serializers.UserDeleteSerializer', - } + "LOGIN_FIELD": "email", + "USER_CREATE_PASSWORD_RETYPE": True, + "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, + "SEND_CONFIRMATION_EMAIL": True, + "SET_USERNAME_RETYPE": True, + "SET_PASSWORD_RETYPE": True, + "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", + "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", + "ACTIVATION_URL": "activate/{uid}/{token}", + "SEND_ACTIVATION_EMAIL": True, + "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", + "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [ + "http://localhost:8000/google", + "http://localhost:8000/facebook", + ], + "SERIALIZERS": { + "user_create": "api.models.serializers.UserCreateSerializer", + "user": "api.models.serializers.UserCreateSerializer", + "current_user": "api.models.serializers.UserCreateSerializer", + "user_delete": "djoser.serializers.UserDeleteSerializer", + }, } # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "api.UserAccount" -AUTH_USER_MODEL = 'api.UserAccount' +# 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": "DEBUG", + }, +} From 4376600b1796b4a3c3168c226b432ca7450a191f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 19 Aug 2025 17:19:19 -0400 Subject: [PATCH 61/92] change debug logs to info logs for better visibility in production --- server/api/views/assistant/views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index f8db1632..8c75a207 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -56,7 +56,7 @@ def invoke_functions_from_response( f"Invoking tool: {response_item.name} with arguments: {arguments}" ) tool_output = target_tool(**arguments) - logger.debug(f"Tool {response_item.name} completed successfully") + 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 @@ -73,7 +73,7 @@ def invoke_functions_from_response( } ) elif response_item.type == "reasoning": - logger.debug(f"Reasoning step: {response_item.summary}") + logger.info(f"Reasoning step: {response_item.summary}") return intermediate_messages @@ -227,12 +227,12 @@ def search_documents(query: str, user=user) -> str: logger.info("Reasoning completed") final_response_output_text = response.output_text final_response_id = response.id - logger.debug( + logger.info( f"Final response length: {len(final_response_output_text)} characters" ) break else: - logger.debug("More reasoning required, continuing...") + logger.info("More reasoning required, continuing...") response = client.responses.create( input=function_responses, previous_response_id=response.id, From ad099debf6a6d1937fb4c552dbb668acffefad87 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 19 Aug 2025 17:39:34 -0400 Subject: [PATCH 62/92] Add cost metrics and duration logging --- server/api/views/assistant/views.py | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 8c75a207..e80f2333 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -1,6 +1,7 @@ import os import json import logging +import time from typing import Callable from rest_framework.views import APIView @@ -18,6 +19,37 @@ # Configure logging logger = logging.getLogger(__name__) +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, + } + # Open AI Cookbook: Handling Function Calls with Reasoning Models # https://cookbook.openai.com/examples/reasoning_function_calls @@ -200,6 +232,10 @@ def search_documents(query: str, user=user) -> str: message = request.data.get("message", None) previous_response_id = request.data.get("previous_response_id", None) + # 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=[ @@ -216,6 +252,15 @@ def search_documents(query: str, user=user) -> str: **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: @@ -238,6 +283,29 @@ def search_documents(query: str, user=user) -> str: 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 a592916b15d9c2ed3094a734c873bcfff6804065 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 19 Aug 2025 18:39:20 -0400 Subject: [PATCH 63/92] feat: #357 - Add mission section to about page --- frontend/src/pages/About/About.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index 10035a73..b8170333 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -50,6 +50,14 @@ function About() {
  • Clinical decisions should always be based on the prescriber's expertise, knowledge of the patient, and official medical guidelines.

  • + {/* Our mission */} +
    +
    + Our mission +
    +

    Bipolar disorder affects approximately 5.7 million adult Americans every year. Delays in the correct diagnosis and proper treatment of bipolar disorder may result in social, occupational, and economic burdens, as well as an increase in completed suicides.

    +

    The team behind Balancer believes that building a searchable, interactive and user-friendly research tool for bipolar medications has the potential to improve the health and well-being of people with bipolar disorder.

    +
    {/* Support Us section */}
    Support Us
    From 6161f5f4df0cacbf26225c8a4cc5b783a06aea67 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 19 Aug 2025 19:39:38 -0400 Subject: [PATCH 64/92] Change root logging level from DEBUG to INFO --- server/balancer_backend/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 0b4a204c..175ca6ab 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -229,6 +229,6 @@ }, "root": { "handlers": ["console"], - "level": "DEBUG", + "level": "INFO", }, } From c90e95e561e05898d12a85c9fd4380e5eab84574 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 20 Aug 2025 15:13:22 -0400 Subject: [PATCH 65/92] Update Docker Compose configuration for PostgreSQL and frontend services --- docker-compose.yml | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d8a8ca75..f36517ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,18 @@ services: db: - build: - context: ./db - dockerfile: Dockerfile - volumes: - - postgres_data:/var/lib/postgresql/data/ - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: + image: pgvector/pgvector:pg15 + volumes: + - postgres_data:/var/lib/postgresql/data/ + - ./init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + ports: - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 + networks: + app_net: + ipv4_address: 192.168.0.2 pgadmin: container_name: pgadmin4 image: dpage/pgadmin4 @@ -52,13 +51,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -72,4 +71,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 \ No newline at end of file + gateway: 192.168.0.1 From ff13ad4d5fc5c97c6e77b1023a93e46e108f41a3 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 20 Aug 2025 15:28:37 -0400 Subject: [PATCH 66/92] Update reasoning effort level and improve final response logging --- server/api/views/assistant/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index e80f2333..fd49e5e5 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -223,7 +223,7 @@ def search_documents(query: str, user=user) -> str: MODEL_DEFAULTS = { "instructions": INSTRUCTIONS, "model": "gpt-5-nano", # 400,000 token context window - "reasoning": {"effort": "medium", "summary": "auto"}, + "reasoning": {"effort": "low", "summary": "auto"}, "tools": tools, } @@ -272,9 +272,7 @@ def search_documents(query: str, user=user) -> str: logger.info("Reasoning completed") final_response_output_text = response.output_text final_response_id = response.id - logger.info( - f"Final response length: {len(final_response_output_text)} characters" - ) + logger.info(f"Final response: {final_response_output_text}") break else: logger.info("More reasoning required, continuing...") From e6d66a26141335eb36be7c5dbd541ebf7f7e8450 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 20 Aug 2025 19:09:46 -0400 Subject: [PATCH 67/92] Simpler stateless chat interface that focuses on document-based assistance - Replace conversation-based chat system with direct assistant API calls - Remove ConversationList component and related conversation state management - Switch from continueConversation to sendAssistantMessage API endpoint - Track current messages and response IDs instead of full conversations - Remove conversation creation, deletion, and title updating functionality - Simplify UI by removing conversation list toggle and management buttons - Update welcome message to focus on document-based Q&A instead of bipolar treatment - Maintain backward compatibility by keeping Conversation interface export - Remove DOM content extraction and page content tracking - Streamline message handling with direct assistant responses --- frontend/src/api/apiClient.ts | 17 +- frontend/src/components/Header/Chat.tsx | 382 +++++++++--------------- 2 files changed, 156 insertions(+), 243 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 73b74caf..3e672f1e 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -267,6 +267,20 @@ const updateConversationTitle = async ( } }; +// Assistant API functions +const sendAssistantMessage = async (message: string, previousResponseId?: string) => { + try { + const response = await api.post(`/v1/api/assistant`, { + message, + previous_response_id: previousResponseId, + }); + return response.data; + } catch (error) { + console.error("Error(s) during sendAssistantMessage: ", error); + throw error; + } +}; + export { handleSubmitFeedback, handleSendDrugSummary, @@ -279,5 +293,6 @@ export { updateConversationTitle, handleSendDrugSummaryStream, handleSendDrugSummaryStreamLegacy, - fetchRiskDataWithSources + fetchRiskDataWithSources, + sendAssistantMessage }; \ No newline at end of file diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index df11f68b..674ff438 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -4,12 +4,10 @@ import "../../components/Header/chat.css"; import { useState, useEffect, useRef } from "react"; import TypingAnimation from "./components/TypingAnimation"; import ErrorMessage from "../ErrorMessage"; -import ConversationList from "./ConversationList"; -import { extractContentFromDOM } from "../../services/domExtraction"; +import ParseStringWithLinks from "../../services/parsing/ParseWithSource"; import axios from "axios"; import { FaPlus, - FaMinus, FaTimes, FaComment, FaComments, @@ -19,13 +17,7 @@ import { FaExpandAlt, FaExpandArrowsAlt, } from "react-icons/fa"; -import { - fetchConversations, - continueConversation, - newConversation, - updateConversationTitle, - deleteConversation, -} from "../../api/apiClient"; +import { sendAssistantMessage } from "../../api/apiClient"; interface ChatLogItem { is_user: boolean; @@ -33,12 +25,14 @@ interface ChatLogItem { timestamp: string; // EX: 2025-01-16T16:21:14.981090Z } +// Keep interface for backward compatibility with existing imports export interface Conversation { title: string; messages: ChatLogItem[]; id: string; } + interface ChatDropDownProps { showChat: boolean; setShowChat: React.Dispatch>; @@ -47,12 +41,9 @@ interface ChatDropDownProps { const Chat: React.FC = ({ showChat, setShowChat }) => { const CHATBOT_NAME = "JJ"; const [inputValue, setInputValue] = useState(""); - const [chatLog, setChatLog] = useState([]); // Specify the type as ChatLogItem[] + const [currentMessages, setCurrentMessages] = useState([]); + const [currentResponseId, setCurrentResponseId] = useState(undefined); const [isLoading, setIsLoading] = useState(false); - const [showConversationList, setShowConversationList] = useState(false); - const [conversations, setConversations] = useState([]); - const [activeConversation, setActiveConversation] = - useState(null); const [error, setError] = useState(null); const suggestionPrompts = [ @@ -63,25 +54,8 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { "Risks associated with Lithium.", "What medications could cause liver issues?", ]; - const [pageContent, setPageContent] = useState(""); const chatContainerRef = useRef(null); - useEffect(() => { - const observer = new MutationObserver(() => { - const content = extractContentFromDOM(); - setPageContent(content); - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true, - }); - - const extractedContent = extractContentFromDOM(); - // console.log(extractedContent); - setPageContent(extractedContent); - }, []); const [bottom, setBottom] = useState(false); @@ -96,7 +70,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const [expandChat, setExpandChat] = useState(false); useEffect(() => { - if (chatContainerRef.current && activeConversation) { + if (chatContainerRef.current) { const chatContainer = chatContainerRef.current; // Use setTimeout to ensure the new message has been rendered setTimeout(() => { @@ -107,17 +81,8 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { ); }, 0); } - }, [activeConversation?.messages]); + }, [currentMessages]); - const loadConversations = async () => { - try { - const data = await fetchConversations(); - setConversations(data); - // setLoading(false); - } catch (error) { - console.error("Error loading conversations: ", error); - } - }; const scrollToBottom = (element: HTMLElement) => element.scroll({ top: element.scrollHeight, behavior: "smooth" }); @@ -141,68 +106,39 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { ) => { event.preventDefault(); + const messageContent = (inputValue || suggestion) ?? ""; + if (!messageContent.trim()) return; + const newMessage = { - content: (inputValue || suggestion) ?? "", + content: messageContent, is_user: true, timestamp: new Date().toISOString(), }; - const newMessages = [...chatLog, newMessage]; + try { + setIsLoading(true); + setError(null); - setChatLog(newMessages); + // Add user message to current conversation + const updatedMessages = [...currentMessages, newMessage]; + setCurrentMessages(updatedMessages); - // sendMessage(newMessages); - try { - let conversation = activeConversation; - let conversationCreated = false; - - // Create a new conversation if none exists - if (!conversation) { - conversation = await newConversation(); - setActiveConversation(conversation); - setShowConversationList(false); - conversationCreated = true; - } + // Call assistant API with previous response ID for continuity + const data = await sendAssistantMessage(messageContent, currentResponseId); - // Update the conversation with the new user message - const updatedMessages = [...conversation.messages, newMessage]; - setActiveConversation({ - ...conversation, - title: "Asking JJ...", - messages: updatedMessages, - }); + // Create assistant response message + const assistantMessage = { + content: data.response_output_text, + is_user: false, + timestamp: new Date().toISOString(), + }; - setIsLoading(true); + // Update messages and store new response ID for next message + setCurrentMessages(prev => [...prev, assistantMessage]); + setCurrentResponseId(data.final_response_id); - // Continue the conversation and update with the bot's response - const data = await continueConversation( - conversation.id, - newMessage.content, - pageContent - ); - - // Update the ConversationList component after previous function creates a title - if (conversationCreated) loadConversations(); // Note: no 'await' so this can occur in the background - - setActiveConversation((prevConversation: any) => { - if (!prevConversation) return null; - - return { - ...prevConversation, - messages: [ - ...prevConversation.messages, - { - is_user: false, - content: data.response, - timestamp: new Date().toISOString(), - }, - ], - title: data.title, - }; - }); - setError(null); } catch (error) { - console.error("Error(s) handling conversation:", error); + console.error("Error handling message:", error); let errorMessage = "Error submitting message"; if (error instanceof Error) { errorMessage = error.message; @@ -222,25 +158,8 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { } }; - const handleSelectConversation = (id: Conversation["id"]) => { - const selectedConversation = conversations.find( - (conversation: any) => conversation.id === id - ); - - if (selectedConversation) { - setActiveConversation(selectedConversation); - setShowConversationList(false); - } - }; - - const handleNewConversation = () => { - setActiveConversation(null); - setShowConversationList(false); - }; - useEffect(() => { if (showChat) { - loadConversations(); const resizeObserver = new ResizeObserver((entries) => { if (!entries || entries.length === 0) return; @@ -278,53 +197,34 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { className=" mx-auto flex h-full flex-col overflow-auto rounded " >
    - - -
    - {activeConversation !== null && !showConversationList ? ( - activeConversation.title - ) : ( - <> - - Ask {CHATBOT_NAME} - - )} -
    +
    + + Ask {CHATBOT_NAME}
    + + + - {showConversationList ? ( -
    - -
    - ) : ( -
    - {activeConversation === null || - activeConversation.messages.length === 0 ? ( - <> -
    -
    Hi there, I'm {CHATBOT_NAME}!
    -

    - You can ask me all your bipolar disorder treatment - questions. -

    - - Learn more about my sources. - +
    + {currentMessages.length === 0 ? ( + <> +
    +
    Hi there, I'm {CHATBOT_NAME}!
    +

    + You can ask me questions about your uploaded documents. + I'll search through them to provide accurate, cited answers. +

    + + Learn more about my sources. + +
    +
    +
    + +
    Explore a medication
    -
    -
    - -
    Explore a medication
    -
    -
      - {suggestionPrompts.map((suggestion, index) => ( -
    • - -
    • - ))} -
    +
      + {suggestionPrompts.map((suggestion, index) => ( +
    • + +
    • + ))} +
    +
    +
    +
    + +
    Refresh your memory
    -
    -
    - -
    Refresh your memory
    +
      + {refreshPrompts.map((suggestion, index) => ( +
    • + +
    • + ))} +
    +
    + + ) : ( + currentMessages + .slice() + .sort( + (a, b) => + new Date(a.timestamp).getTime() - + new Date(b.timestamp).getTime() + ) + .map((message, index) => ( +
    +
    +
    -
      - {refreshPrompts.map((suggestion, index) => ( -
    • - -
    • - ))} -
    -
    - - ) : ( - activeConversation.messages - .slice() - .sort( - (a, b) => - new Date(a.timestamp).getTime() - - new Date(b.timestamp).getTime() - ) - .map((message, index) => ( -
    -
    - {message.is_user ? ( - - ) : ( - - )} -
    -
    +
    + {message.is_user ? (
     = ({ showChat, setShowChat }) => {
                                 >
                                   {message.content}
                                 
    -
    + ) : ( +
    + +
    + )}
    - )) - )} - {isLoading && ( -
    -
    -
    + )) + )} + {isLoading && ( +
    +
    +
    - )} - {error && } -
    - )} +
    + )} + {error && } +
    Date: Thu, 21 Aug 2025 14:37:35 -0400 Subject: [PATCH 68/92] Simplify message rendering and update citation format in views --- frontend/src/components/Header/Chat.tsx | 34 +++++++------------------ server/api/views/assistant/views.py | 4 +-- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 674ff438..95189a10 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -4,7 +4,6 @@ import "../../components/Header/chat.css"; import { useState, useEffect, useRef } from "react"; import TypingAnimation from "./components/TypingAnimation"; import ErrorMessage from "../ErrorMessage"; -import ParseStringWithLinks from "../../services/parsing/ParseWithSource"; import axios from "axios"; import { FaPlus, @@ -321,30 +320,15 @@ const Chat: React.FC = ({ showChat, setShowChat }) => {
    - {message.is_user ? ( -
    -                              {message.content}
    -                            
    - ) : ( -
    - -
    - )} +
    +                            {message.content}
    +                          
    )) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index fd49e5e5..ca65f335 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -186,7 +186,7 @@ def search_documents(query: str, user=user) -> str: # Format results with clear structure and metadata prompt_texts = [ - f"[Document {i + 1} - File: {obj['file_id']}, Page: {obj['page_number']}, Chunk: {obj['chunk_number']}, Similarity: {1 - obj['distance']:.3f}]\n{obj['text']}\n[End Document {i + 1}]" + 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) ] @@ -214,7 +214,7 @@ def search_documents(query: str, user=user) -> str: 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. Include citations after EACH sentence using this exact format: ***[File ID {file_id}, Page {page_number}, Chunk {chunk_number}]*** + 3. Include citations using this exact format: ***[Name {name}, Page {page_number}]*** 4. Only cite information that directly supports your statements If no relevant information is found in the documents, clearly state that the information is not available in the uploaded documents. From da4dacc3a5d8862e75744b928ec853fbaf3889f2 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 21 Aug 2025 15:03:00 -0400 Subject: [PATCH 69/92] Restore files that only have linting changes --- server/api/services/tools/tools.py | 43 +++++------ server/api/views/conversations/urls.py | 9 ++- server/api/views/conversations/views.py | 97 ++++++++++-------------- server/api/views/text_extraction/urls.py | 17 ++--- 4 files changed, 70 insertions(+), 96 deletions(-) diff --git a/server/api/services/tools/tools.py b/server/api/services/tools/tools.py index 03ef1fd3..f9fa14c8 100644 --- a/server/api/services/tools/tools.py +++ b/server/api/services/tools/tools.py @@ -1,8 +1,6 @@ +from django.db import connection from typing import Dict, Any, Callable, List from dataclasses import dataclass - -from django.db import connection - from .database import ask_database, get_database_info database_schema_dict = get_database_info(connection) @@ -13,7 +11,6 @@ ] ) - @dataclass class ToolFunction: name: str @@ -21,7 +18,6 @@ class ToolFunction: description: str parameters: Dict[str, Any] - def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: return { "type": "function", @@ -32,11 +28,10 @@ def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: "type": "object", "properties": tool.parameters, "required": list(tool.parameters.keys()), - }, - }, + } + } } - TOOL_FUNCTIONS = [ ToolFunction( name="ask_database", @@ -61,58 +56,60 @@ def create_tool_dict(tool: ToolFunction) -> Dict[str, Any]: SQL should be written using this database schema: {database_schema_string} The query should be returned in plain text, not in JSON. - """, + """ } - }, + } ), ] # Automatically generate the tool_functions dictionary and tools list -tool_functions: Dict[str, Callable] = {tool.name: tool.func for tool in TOOL_FUNCTIONS} - -tools: List[Dict[str, Any]] = [create_tool_dict(tool) for tool in TOOL_FUNCTIONS] +tool_functions: Dict[str, Callable] = { + tool.name: tool.func for tool in TOOL_FUNCTIONS +} +tools: List[Dict[str, Any]] = [ + create_tool_dict(tool) for tool in TOOL_FUNCTIONS +] def validate_tool_inputs(tool_function_name, tool_arguments): """Validate the inputs for the execute_tool function.""" if not isinstance(tool_function_name, str) or not tool_function_name: raise ValueError("Invalid tool function name") - + if not isinstance(tool_arguments, dict): raise ValueError("Tool arguments must be a dictionary") - + # Check if the tool_function_name exists in the tools tool = next((t for t in tools if t["function"]["name"] == tool_function_name), None) if not tool: raise ValueError(f"Tool function '{tool_function_name}' does not exist") - + # Validate the tool arguments based on the tool's parameters parameters = tool["function"].get("parameters", {}) required_params = parameters.get("required", []) for param in required_params: if param not in tool_arguments: raise ValueError(f"Missing required parameter: {param}") - + # Check if the parameter types match the expected types properties = parameters.get("properties", {}) for param, prop in properties.items(): - expected_type = prop.get("type") + expected_type = prop.get('type') if param in tool_arguments: - if expected_type == "string" and not isinstance(tool_arguments[param], str): + if expected_type == 'string' and not isinstance(tool_arguments[param], str): raise ValueError(f"Parameter '{param}' must be of type string") - - + def execute_tool(function_name: str, arguments: Dict[str, Any]) -> str: """ Execute the appropriate function based on the function name. - + :param function_name: The name of the function to execute :param arguments: A dictionary of arguments to pass to the function :return: The result of the function execution """ # Validate tool inputs validate_tool_inputs(function_name, arguments) - + try: return tool_functions[function_name](**arguments) except Exception as e: diff --git a/server/api/views/conversations/urls.py b/server/api/views/conversations/urls.py index 8896d964..f10d6814 100644 --- a/server/api/views/conversations/urls.py +++ b/server/api/views/conversations/urls.py @@ -1,12 +1,13 @@ from django.urls import path, include -from rest_framework.routers import DefaultRouter - from api.views.conversations import views +from rest_framework.routers import DefaultRouter +# from views import ConversationViewSet router = DefaultRouter() -router.register(r"conversations", views.ConversationViewSet, basename="conversation") +router.register(r'conversations', views.ConversationViewSet, + basename='conversation') urlpatterns = [ path("chatgpt/extract_text/", views.extract_text, name="post_web_text"), - path("chatgpt/", include(router.urls)), + path("chatgpt/", include(router.urls)) ] diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index ac7f1ba2..d5921eaf 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,7 +1,3 @@ -import os -import json -import logging - from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.decorators import action @@ -13,8 +9,10 @@ import requests from openai import OpenAI import tiktoken +import os +import json +import logging from django.views.decorators.csrf import csrf_exempt - from .models import Conversation, Message from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool @@ -69,7 +67,6 @@ def get_tokens(string: str, encoding_name: str) -> str: class OpenAIAPIException(APIException): """Custom exception for OpenAI API errors.""" - status_code = status.HTTP_500_INTERNAL_SERVER_ERROR default_detail = "An error occurred while communicating with the OpenAI API." default_code = "openai_api_error" @@ -98,29 +95,26 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=["post"]) + @action(detail=True, methods=['post']) def continue_conversation(self, request, pk=None): conversation = self.get_object() - user_message = request.data.get("message") - page_context = request.data.get("page_context") + user_message = request.data.get('message') + page_context = request.data.get('page_context') if not user_message: return Response({"error": "Message is required"}, status=400) # Save user message - Message.objects.create( - conversation=conversation, content=user_message, is_user=True - ) + Message.objects.create(conversation=conversation, + content=user_message, is_user=True) # Get ChatGPT response chatgpt_response = self.get_chatgpt_response( - conversation, user_message, page_context - ) + conversation, user_message, page_context) # Save ChatGPT response - Message.objects.create( - conversation=conversation, content=chatgpt_response, is_user=False - ) + Message.objects.create(conversation=conversation, + content=chatgpt_response, is_user=False) # Generate or update title if it's the first message or empty if conversation.messages.count() <= 2 or not conversation.title: @@ -129,31 +123,25 @@ def continue_conversation(self, request, pk=None): return Response({"response": chatgpt_response, "title": conversation.title}) - @action(detail=True, methods=["patch"]) + @action(detail=True, methods=['patch']) def update_title(self, request, pk=None): conversation = self.get_object() - new_title = request.data.get("title") + new_title = request.data.get('title') if not new_title: - return Response( - {"error": "New title is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "New title is required"}, status=status.HTTP_400_BAD_REQUEST) conversation.title = new_title conversation.save() - return Response( - {"status": "Title updated successfully", "title": conversation.title} - ) + return Response({"status": "Title updated successfully", "title": conversation.title}) def get_chatgpt_response(self, conversation, user_message, page_context=None): client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) - messages = [ - { - "role": "system", - "content": "You are a knowledgeable assistant. Balancer is a powerful tool for selecting bipolar medication for patients. We are open-source and available for free use. Your primary role is to assist licensed clinical professionals with information related to Balancer and bipolar medication selection. If applicable, use the supplied tools to assist the professional.", - } - ] + messages = [{ + "role": "system", + "content": "You are a knowledgeable assistant. Balancer is a powerful tool for selecting bipolar medication for patients. We are open-source and available for free use. Your primary role is to assist licensed clinical professionals with information related to Balancer and bipolar medication selection. If applicable, use the supplied tools to assist the professional." + }] if page_context: context_message = f"If applicable, please use the following content to ask questions. If not applicable, please answer to the best of your ability: {page_context}" @@ -169,7 +157,7 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): model="gpt-3.5-turbo", messages=messages, tools=tools, - tool_choice="auto", + tool_choice="auto" ) response_message = response.choices[0].message @@ -178,41 +166,37 @@ def get_chatgpt_response(self, conversation, user_message, page_context=None): tool_calls = response_message.model_dump().get("tool_calls", []) if not tool_calls: - return response_message["content"] + return response_message['content'] # Handle tool calls # Add the assistant's message with tool calls to the conversation - messages.append( - { - "role": "assistant", - "content": response_message.content or "", - "tool_calls": tool_calls, - } - ) + messages.append({ + "role": "assistant", + "content": response_message.content or "", + "tool_calls": tool_calls + }) # Process each tool call for tool_call in tool_calls: - tool_call_id = tool_call["id"] - tool_function_name = tool_call["function"]["name"] + tool_call_id = tool_call['id'] + tool_function_name = tool_call['function']['name'] tool_arguments = json.loads( - tool_call["function"].get("arguments", "{}") - ) + tool_call['function'].get('arguments', '{}')) # Execute the tool results = execute_tool(tool_function_name, tool_arguments) # Add the tool response message - messages.append( - { - "role": "tool", - "content": str(results), # Convert results to string - "tool_call_id": tool_call_id, - } - ) + messages.append({ + "role": "tool", + "content": str(results), # Convert results to string + "tool_call_id": tool_call_id + }) # Final API call with tool results final_response = client.chat.completions.create( - model="gpt-3.5-turbo", messages=messages + model="gpt-3.5-turbo", + messages=messages ) return final_response.choices[0].message.content except OpenAI.error.OpenAIError as e: @@ -231,12 +215,9 @@ def generate_title(self, conversation): response = client.chat.completions.create( model="gpt-3.5-turbo", messages=[ - { - "role": "system", - "content": "You are a helpful assistant that generates short, descriptive titles.", - }, - {"role": "user", "content": prompt}, - ], + {"role": "system", "content": "You are a helpful assistant that generates short, descriptive titles."}, + {"role": "user", "content": prompt} + ] ) return response.choices[0].message.content.strip() diff --git a/server/api/views/text_extraction/urls.py b/server/api/views/text_extraction/urls.py index d0af979e..bdf6244f 100644 --- a/server/api/views/text_extraction/urls.py +++ b/server/api/views/text_extraction/urls.py @@ -1,16 +1,11 @@ from django.urls import path - from .views import RuleExtractionAPIView, RuleExtractionAPIOpenAIView + urlpatterns = [ - path( - "v1/api/rule_extraction", - RuleExtractionAPIView.as_view(), - name="rule_extraction", - ), - path( - "v1/api/rule_extraction_openai", - RuleExtractionAPIOpenAIView.as_view(), - name="rule_extraction_openai", - ), + + path('v1/api/rule_extraction', RuleExtractionAPIView.as_view(), + name='rule_extraction'), + path('v1/api/rule_extraction_openai', RuleExtractionAPIOpenAIView.as_view(), + name='rule_extraction_openai') ] From 261634ceb4b07fcdc2a89928985478a455138884 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 21 Aug 2025 15:17:55 -0400 Subject: [PATCH 70/92] Restore files that had changes for local dev --- docker-compose.yml | 37 +++--- server/balancer_backend/settings.py | 182 ++++++++++++---------------- 2 files changed, 98 insertions(+), 121 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f36517ef..d8a8ca75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,19 @@ services: db: - image: pgvector/pgvector:pg15 - volumes: - - postgres_data:/var/lib/postgresql/data/ - - ./init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: + build: + context: ./db + dockerfile: Dockerfile + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + ports: - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 + networks: + app_net: + ipv4_address: 192.168.0.2 pgadmin: container_name: pgadmin4 image: dpage/pgadmin4 @@ -51,13 +52,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -71,4 +72,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 + gateway: 192.168.0.1 \ No newline at end of file diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 175ca6ab..91a53a8b 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,56 +29,57 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() +ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. -if ALLOWED_HOSTS == ["*"] or ALLOWED_HOSTS == [""]: - ALLOWED_HOSTS = ["*"] +if ALLOWED_HOSTS == ['*'] or ALLOWED_HOSTS == ['']: + ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "balancer_backend", - "api", - "corsheaders", - "rest_framework", - "djoser", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'balancer_backend', + 'api', + 'corsheaders', + 'rest_framework', + 'djoser', ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'corsheaders.middleware.CorsMiddleware', + ] -ROOT_URLCONF = "balancer_backend.urls" +ROOT_URLCONF = 'balancer_backend.urls' CORS_ALLOW_ALL_ORIGINS = True TEMPLATES = [ { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(BASE_DIR, "build")], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'build')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', ], }, }, @@ -88,7 +89,7 @@ # Change this to your desired URL LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL") -WSGI_APPLICATION = "balancer_backend.wsgi.application" +WSGI_APPLICATION = 'balancer_backend.wsgi.application' # Database @@ -105,8 +106,8 @@ } } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" +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", "") @@ -118,25 +119,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ -LANGUAGE_CODE = "en-us" +LANGUAGE_CODE = 'en-us' -TIME_ZONE = "UTC" +TIME_ZONE = 'UTC' USE_I18N = True @@ -146,89 +147,64 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = "/static/" +STATIC_URL = '/static/' STATICFILES_DIRS = [ - os.path.join(BASE_DIR, "build/static"), + os.path.join(BASE_DIR, 'build/static'), ] -STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_ROOT = os.path.join(BASE_DIR, 'static') AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", + 'django.contrib.auth.backends.ModelBackend', ] REST_FRAMEWORK = { - "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ], + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', ), } SIMPLE_JWT = { - "AUTH_HEADER_TYPES": ("JWT",), - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), - "TOKEN_OBTAIN_SERIALIZER": "api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer", - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + 'AUTH_HEADER_TYPES': ('JWT',), + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'TOKEN_OBTAIN_SERIALIZER': 'api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer', + 'AUTH_TOKEN_CLASSES': ( + 'rest_framework_simplejwt.tokens.AccessToken', + ) } DJOSER = { - "LOGIN_FIELD": "email", - "USER_CREATE_PASSWORD_RETYPE": True, - "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, - "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, - "SEND_CONFIRMATION_EMAIL": True, - "SET_USERNAME_RETYPE": True, - "SET_PASSWORD_RETYPE": True, - "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", - "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", - "ACTIVATION_URL": "activate/{uid}/{token}", - "SEND_ACTIVATION_EMAIL": True, - "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", - "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [ - "http://localhost:8000/google", - "http://localhost:8000/facebook", - ], - "SERIALIZERS": { - "user_create": "api.models.serializers.UserCreateSerializer", - "user": "api.models.serializers.UserCreateSerializer", - "current_user": "api.models.serializers.UserCreateSerializer", - "user_delete": "djoser.serializers.UserDeleteSerializer", - }, + 'LOGIN_FIELD': 'email', + 'USER_CREATE_PASSWORD_RETYPE': True, + 'USERNAME_CHANGED_EMAIL_CONFIRMATION': True, + 'PASSWORD_CHANGED_EMAIL_CONFIRMATION': True, + 'SEND_CONFIRMATION_EMAIL': True, + 'SET_USERNAME_RETYPE': True, + 'SET_PASSWORD_RETYPE': True, + 'PASSWORD_RESET_CONFIRM_URL': 'password/reset/confirm/{uid}/{token}', + 'USERNAME_RESET_CONFIRM_URL': 'email/reset/confirm/{uid}/{token}', + 'ACTIVATION_URL': 'activate/{uid}/{token}', + 'SEND_ACTIVATION_EMAIL': True, + 'SOCIAL_AUTH_TOKEN_STRATEGY': 'djoser.social.token.jwt.TokenStrategy', + 'SOCIAL_AUTH_ALLOWED_REDIRECT_URIS': ['http://localhost:8000/google', 'http://localhost:8000/facebook'], + 'SERIALIZERS': { + 'user_create': 'api.models.serializers.UserCreateSerializer', + 'user': 'api.models.serializers.UserCreateSerializer', + 'current_user': 'api.models.serializers.UserCreateSerializer', + 'user_delete': 'djoser.serializers.UserDeleteSerializer', + } } # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -AUTH_USER_MODEL = "api.UserAccount" -# 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", - }, -} +AUTH_USER_MODEL = 'api.UserAccount' From 5c3185f7ef37b7ec232d838c403c92c0e06731f0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 21 Aug 2025 15:26:10 -0400 Subject: [PATCH 71/92] Revert "Restore files that had changes for local dev" This reverts commit 261634ceb4b07fcdc2a89928985478a455138884. --- docker-compose.yml | 37 +++--- server/balancer_backend/settings.py | 182 ++++++++++++++++------------ 2 files changed, 121 insertions(+), 98 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d8a8ca75..f36517ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,18 @@ services: db: - build: - context: ./db - dockerfile: Dockerfile - volumes: - - postgres_data:/var/lib/postgresql/data/ - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: + image: pgvector/pgvector:pg15 + volumes: + - postgres_data:/var/lib/postgresql/data/ + - ./init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + ports: - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 + networks: + app_net: + ipv4_address: 192.168.0.2 pgadmin: container_name: pgadmin4 image: dpage/pgadmin4 @@ -52,13 +51,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -72,4 +71,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 \ No newline at end of file + gateway: 192.168.0.1 diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 91a53a8b..175ca6ab 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,57 +29,56 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split() +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. -if ALLOWED_HOSTS == ['*'] or ALLOWED_HOSTS == ['']: - ALLOWED_HOSTS = ['*'] +if ALLOWED_HOSTS == ["*"] or ALLOWED_HOSTS == [""]: + ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'balancer_backend', - 'api', - 'corsheaders', - 'rest_framework', - 'djoser', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "balancer_backend", + "api", + "corsheaders", + "rest_framework", + "djoser", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'corsheaders.middleware.CorsMiddleware', - + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", ] -ROOT_URLCONF = 'balancer_backend.urls' +ROOT_URLCONF = "balancer_backend.urls" CORS_ALLOW_ALL_ORIGINS = True TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'build')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "build")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -89,7 +88,7 @@ # Change this to your desired URL LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL") -WSGI_APPLICATION = 'balancer_backend.wsgi.application' +WSGI_APPLICATION = "balancer_backend.wsgi.application" # Database @@ -106,8 +105,8 @@ } } -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.gmail.com' +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", "") @@ -119,25 +118,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -147,64 +146,89 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'build/static'), + os.path.join(BASE_DIR, "build/static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATIC_ROOT = os.path.join(BASE_DIR, "static") AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", ), } SIMPLE_JWT = { - 'AUTH_HEADER_TYPES': ('JWT',), - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'TOKEN_OBTAIN_SERIALIZER': 'api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer', - 'AUTH_TOKEN_CLASSES': ( - 'rest_framework_simplejwt.tokens.AccessToken', - ) + "AUTH_HEADER_TYPES": ("JWT",), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "TOKEN_OBTAIN_SERIALIZER": "api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } DJOSER = { - 'LOGIN_FIELD': 'email', - 'USER_CREATE_PASSWORD_RETYPE': True, - 'USERNAME_CHANGED_EMAIL_CONFIRMATION': True, - 'PASSWORD_CHANGED_EMAIL_CONFIRMATION': True, - 'SEND_CONFIRMATION_EMAIL': True, - 'SET_USERNAME_RETYPE': True, - 'SET_PASSWORD_RETYPE': True, - 'PASSWORD_RESET_CONFIRM_URL': 'password/reset/confirm/{uid}/{token}', - 'USERNAME_RESET_CONFIRM_URL': 'email/reset/confirm/{uid}/{token}', - 'ACTIVATION_URL': 'activate/{uid}/{token}', - 'SEND_ACTIVATION_EMAIL': True, - 'SOCIAL_AUTH_TOKEN_STRATEGY': 'djoser.social.token.jwt.TokenStrategy', - 'SOCIAL_AUTH_ALLOWED_REDIRECT_URIS': ['http://localhost:8000/google', 'http://localhost:8000/facebook'], - 'SERIALIZERS': { - 'user_create': 'api.models.serializers.UserCreateSerializer', - 'user': 'api.models.serializers.UserCreateSerializer', - 'current_user': 'api.models.serializers.UserCreateSerializer', - 'user_delete': 'djoser.serializers.UserDeleteSerializer', - } + "LOGIN_FIELD": "email", + "USER_CREATE_PASSWORD_RETYPE": True, + "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, + "SEND_CONFIRMATION_EMAIL": True, + "SET_USERNAME_RETYPE": True, + "SET_PASSWORD_RETYPE": True, + "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", + "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", + "ACTIVATION_URL": "activate/{uid}/{token}", + "SEND_ACTIVATION_EMAIL": True, + "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", + "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [ + "http://localhost:8000/google", + "http://localhost:8000/facebook", + ], + "SERIALIZERS": { + "user_create": "api.models.serializers.UserCreateSerializer", + "user": "api.models.serializers.UserCreateSerializer", + "current_user": "api.models.serializers.UserCreateSerializer", + "user_delete": "djoser.serializers.UserDeleteSerializer", + }, } # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +AUTH_USER_MODEL = "api.UserAccount" -AUTH_USER_MODEL = 'api.UserAccount' +# 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", + }, +} From 5660b9f9895a2a26a6957da98416c9a55bc6d43a Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 24 Aug 2025 21:38:44 -0400 Subject: [PATCH 72/92] update the pdf reader --- .../src/pages/DrugSummary/DrugSummaryForm.tsx | 14 +--- frontend/src/pages/DrugSummary/PDFViewer.tsx | 84 ++++++++++++------- .../pages/PatientManager/PatientSummary.tsx | 47 ++++------- server/Dockerfile.prod | 2 +- 4 files changed, 73 insertions(+), 74 deletions(-) diff --git a/frontend/src/pages/DrugSummary/DrugSummaryForm.tsx b/frontend/src/pages/DrugSummary/DrugSummaryForm.tsx index 46bb8647..d0a00f92 100644 --- a/frontend/src/pages/DrugSummary/DrugSummaryForm.tsx +++ b/frontend/src/pages/DrugSummary/DrugSummaryForm.tsx @@ -27,7 +27,6 @@ const DrugSummaryForm = () => { const { search } = useLocation(); const params = new URLSearchParams(search); const guid = params.get("guid") || ""; - const pageParam = params.get("page"); const textareaRef = useRef(null); useEffect(() => { @@ -49,17 +48,6 @@ const DrugSummaryForm = () => { useEffect(() => setHasPDF(!!guid), [guid]); - useEffect(() => { - if (pageParam && hasPDF) { - const page = parseInt(pageParam, 10); - if (!isNaN(page) && page > 0) { - window.dispatchEvent( - new CustomEvent("navigateToPdfPage", { detail: { pageNumber: page } }) - ); - } - } - }, [pageParam, hasPDF]); - useEffect(() => { if (!isStreaming && !isLoading && textareaRef.current) { textareaRef.current.focus(); @@ -184,7 +172,7 @@ const DrugSummaryForm = () => {
    {hasPDF && (
    - +
    )}
    diff --git a/frontend/src/pages/DrugSummary/PDFViewer.tsx b/frontend/src/pages/DrugSummary/PDFViewer.tsx index 267fd1ab..39ddfbfc 100644 --- a/frontend/src/pages/DrugSummary/PDFViewer.tsx +++ b/frontend/src/pages/DrugSummary/PDFViewer.tsx @@ -20,8 +20,6 @@ interface DocumentLoadSuccess { numPages: number; } -const PAGE_INIT_DELAY = 800; - const PDFViewer = () => { const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(1); @@ -45,13 +43,13 @@ const PDFViewer = () => { const pageRefs = useRef>({}); const prevGuidRef = useRef(null); const isFetchingRef = useRef(false); + const isAutoScrollingRef = useRef(false); const location = useLocation(); const navigate = useNavigate(); const params = new URLSearchParams(location.search); const guid = params.get("guid"); const pageParam = params.get("page"); - const baseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; const pdfUrl = useMemo(() => { @@ -65,20 +63,37 @@ const PDFViewer = () => { const guidChanged = guid !== prevGuidRef.current; if (guidChanged) { - pageRefs.current = {}; setIsDocumentLoaded(false); setNumPages(null); setPdfData(null); - setPageNumber(1); + setTargetPageAfterLoad(null); + const validPage = !isNaN(nextPage) && nextPage > 0 ? nextPage : 1; + setPageNumber(validPage); + setTargetPageAfterLoad(validPage); + prevGuidRef.current = guid; + return; + } + + const desired = !isNaN(nextPage) && nextPage > 0 ? nextPage : 1; + if (pageNumber !== desired) { + if (isDocumentLoaded && numPages) { + const clampedPage = Math.max(1, Math.min(desired, numPages)); + setPageNumber(clampedPage); + scrollToPage(clampedPage); + } else { + setPageNumber(desired); + setTargetPageAfterLoad(desired); + } } - setTargetPageAfterLoad(!isNaN(nextPage) && nextPage > 0 ? nextPage : 1); prevGuidRef.current = guid; - }, [guid, pageParam, location.pathname, location.search]); + }, [guid, pageParam, pageNumber, isDocumentLoaded, numPages]); const updateCurrentPageFromScroll = useCallback(() => { if (!numPages || !contentRef.current) return; + if (isAutoScrollingRef.current) return; + const container = contentRef.current; const containerRectTop = container.getBoundingClientRect().top; const containerCenter = containerRectTop + container.clientHeight / 2; @@ -86,7 +101,7 @@ const PDFViewer = () => { let bestPage = 1; let bestDist = Infinity; - for (let i = 1; i <= (numPages ?? 0); i++) { + for (let i = 1; i <= numPages; i++) { const el = pageRefs.current[i]; if (!el) continue; const r = el.getBoundingClientRect(); @@ -99,6 +114,9 @@ const PDFViewer = () => { } if (bestPage !== pageNumber) { + console.log( + `Scroll detected: updating page from ${pageNumber} to ${bestPage}` + ); setPageNumber(bestPage); const newParams = new URLSearchParams(location.search); newParams.set("page", String(bestPage)); @@ -126,16 +144,6 @@ const PDFViewer = () => { return () => container.removeEventListener("scroll", onScroll); }, [updateCurrentPageFromScroll]); - useEffect(() => { - updateCurrentPageFromScroll(); - }, [ - numPages, - deferredScale, - containerSize.width, - containerSize.height, - updateCurrentPageFromScroll, - ]); - const scrollToPage = useCallback( (page: number) => { if (!numPages || page < 1 || page > numPages) return; @@ -146,6 +154,7 @@ const PDFViewer = () => { return; } + isAutoScrollingRef.current = true; targetRef.scrollIntoView({ behavior: "smooth", block: "start", @@ -153,17 +162,25 @@ const PDFViewer = () => { }); const newParams = new URLSearchParams(location.search); - const oldPage = newParams.get("page"); - if (oldPage !== String(page)) { + if (newParams.get("page") !== String(page)) { newParams.set("page", String(page)); navigate(`${location.pathname}?${newParams.toString()}`, { replace: true, }); } - setPageNumber(page); + setTimeout(() => { + isAutoScrollingRef.current = false; + updateCurrentPageFromScroll(); + }, 500); }, - [numPages, location.pathname, location.search, navigate] + [ + numPages, + location.pathname, + location.search, + navigate, + updateCurrentPageFromScroll, + ] ); const goToPage = useCallback( @@ -171,10 +188,13 @@ const PDFViewer = () => { if (typeof page !== "number" || isNaN(page)) return; const clamped = Math.max(1, numPages ? Math.min(page, numPages) : page); + if (!isDocumentLoaded || !numPages) { setTargetPageAfterLoad(clamped); + setPageNumber(clamped); return; } + if (clamped === pageNumber) return; setPageNumber(clamped); @@ -197,16 +217,19 @@ const PDFViewer = () => { }, [goToPage]); useEffect(() => { - if (isDocumentLoaded && numPages && targetPageAfterLoad) { - const validPage = Math.min(Math.max(1, targetPageAfterLoad), numPages); - setPageNumber(validPage); + if (isDocumentLoaded && numPages && targetPageAfterLoad !== null) { + const validPage = Math.max(1, Math.min(targetPageAfterLoad, numPages)); - const timer = setTimeout(() => { + console.log(`Navigating to page ${validPage} after document load`); + const timeoutId = setTimeout(() => { scrollToPage(validPage); - setTargetPageAfterLoad(null); - }, PAGE_INIT_DELAY); - return () => clearTimeout(timer); + setTimeout(() => { + setTargetPageAfterLoad(null); + }, 600); + }, 100); + + return () => clearTimeout(timeoutId); } }, [isDocumentLoaded, numPages, targetPageAfterLoad, scrollToPage]); @@ -301,6 +324,7 @@ const PDFViewer = () => { const onDocumentLoadSuccess = useCallback( ({ numPages }: DocumentLoadSuccess) => { + console.log(`Document loaded with ${numPages} pages`); setNumPages(numPages); setError(null); setIsDocumentLoaded(true); @@ -441,7 +465,7 @@ const PDFViewer = () => { data-page={pageNum} > s.length > n ? s.slice(0, n).trim() + "…" : s; -const badge = (label: string) => ( - - {label} - -); + const MedicationItem = ({ medication, - source, + isClicked, riskData, loading, @@ -79,10 +75,7 @@ const MedicationItem = ({
  • - - {medication} - ({source}) - + {medication} {loading && isClicked && (
    @@ -144,17 +137,11 @@ const MedicationItem = ({

    Sources

    - {riskData.source && badge(riskData.source.toUpperCase())}
      {riskData.sources.map((s, idx) => (
    • -
      - {s.rule_type && badge(s.rule_type)} - {s.history_type && badge(s.history_type)} -
      -
      {s.title || "Untitled source"} @@ -217,23 +204,23 @@ const MedicationTier = ({
      {title}:
      - { medications.length ? + {medications.length ? (
        {medications.map((medicationObj) => ( - onMedicationClick(medicationObj)} - /> - )) - } -
      : + onMedicationClick(medicationObj)} + /> + ))} +
    + ) : ( {`Patient's other health concerns may contraindicate typical ${tier} line options.`} - } + )} ); diff --git a/server/Dockerfile.prod b/server/Dockerfile.prod index 6555ed6f..97b2c142 100644 --- a/server/Dockerfile.prod +++ b/server/Dockerfile.prod @@ -1,5 +1,5 @@ # pull official base image -FROM python:3.11.4-slim-buster +FROM python:3.11.4-slim-bullseye # set work directory From 9e8040d3f122e0a34805b786345b64c306c1ddec Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 25 Aug 2025 11:40:02 -0400 Subject: [PATCH 73/92] Add HIPPA Compliance and PHI Disclaimer --- frontend/src/components/Header/Chat.tsx | 61 +++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 95189a10..6bad2e16 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -31,7 +31,6 @@ export interface Conversation { id: string; } - interface ChatDropDownProps { showChat: boolean; setShowChat: React.Dispatch>; @@ -41,7 +40,9 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const CHATBOT_NAME = "JJ"; const [inputValue, setInputValue] = useState(""); const [currentMessages, setCurrentMessages] = useState([]); - const [currentResponseId, setCurrentResponseId] = useState(undefined); + const [currentResponseId, setCurrentResponseId] = useState< + string | undefined + >(undefined); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -55,7 +56,6 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { ]; const chatContainerRef = useRef(null); - const [bottom, setBottom] = useState(false); const handleScroll = (event: React.UIEvent) => { @@ -76,18 +76,17 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { chatContainer.scrollTop = chatContainer.scrollHeight; setBottom( chatContainer.scrollHeight - chatContainer.scrollTop === - chatContainer.clientHeight + chatContainer.clientHeight, ); }, 0); } }, [currentMessages]); - const scrollToBottom = (element: HTMLElement) => element.scroll({ top: element.scrollHeight, behavior: "smooth" }); const handleScrollDown = ( - event: React.MouseEvent + event: React.MouseEvent, ) => { event.preventDefault(); const element = document.getElementById("inside_chat"); @@ -101,7 +100,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { event: | React.FormEvent | React.MouseEvent, - suggestion?: string + suggestion?: string, ) => { event.preventDefault(); @@ -123,7 +122,10 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { setCurrentMessages(updatedMessages); // Call assistant API with previous response ID for continuity - const data = await sendAssistantMessage(messageContent, currentResponseId); + const data = await sendAssistantMessage( + messageContent, + currentResponseId, + ); // Create assistant response message const assistantMessage = { @@ -133,9 +135,8 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { }; // Update messages and store new response ID for next message - setCurrentMessages(prev => [...prev, assistantMessage]); + setCurrentMessages((prev) => [...prev, assistantMessage]); setCurrentResponseId(data.final_response_id); - } catch (error) { console.error("Error handling message:", error); let errorMessage = "Error submitting message"; @@ -212,7 +213,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { > - + - +
    +
    +
    + ⚠️ IMPORTANT NOTICE +
    +

    + Balancer is NOT configured for use with Protected Health Information (PHI) as defined under HIPAA. + You must NOT enter any patient-identifiable information including names, addresses, dates of birth, + medical record numbers, or any other identifying information. Your queries may be processed by + third-party AI services that retain data for up to 30 days for abuse monitoring. By using Balancer, + you certify that you understand these restrictions and will not enter any PHI. +

    +
    @@ -305,7 +328,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { .sort( (a, b) => new Date(a.timestamp).getTime() - - new Date(b.timestamp).getTime() + new Date(b.timestamp).getTime(), ) .map((message, index) => (
    = ({ showChat, setShowChat }) => { )) )} {isLoading && ( -
    +
    @@ -355,10 +381,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => {
    ) : ( -
    setShowChat(true)} - className="chat_button no-print" - > +
    setShowChat(true)} className="chat_button no-print">
    )} From 1d5a1f72ffb446b9bc130aa0433d5c515145e8b1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 25 Aug 2025 14:42:06 -0400 Subject: [PATCH 74/92] Add conversation management to the Chat component using session storage --- frontend/src/components/Header/Chat.tsx | 52 ++++++++++++++++++++++++- frontend/src/services/actions/auth.tsx | 3 ++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 6bad2e16..0edee74a 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -18,7 +18,7 @@ import { } from "react-icons/fa"; import { sendAssistantMessage } from "../../api/apiClient"; -interface ChatLogItem { +export interface ChatLogItem { is_user: boolean; content: string; timestamp: string; // EX: 2025-01-16T16:21:14.981090Z @@ -46,6 +46,28 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // Session storage functions for conversation management + const saveConversationToStorage = (messages: ChatLogItem[], responseId?: string) => { + const conversationData = { + messages, + responseId, + timestamp: new Date().toISOString(), + }; + sessionStorage.setItem('currentConversation', JSON.stringify(conversationData)); + }; + + const loadConversationFromStorage = () => { + const stored = sessionStorage.getItem('currentConversation'); + if (stored) { + try { + return JSON.parse(stored); + } catch (error) { + console.error('Error parsing stored conversation:', error); + } + } + return null; + }; + const suggestionPrompts = [ "What are the side effects of Latuda?", "Why is cariprazine better than valproate for a pregnant patient?", @@ -58,6 +80,24 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const [bottom, setBottom] = useState(false); + // Load conversation from sessionStorage on component mount + useEffect(() => { + const storedConversation = loadConversationFromStorage(); + if (storedConversation) { + setCurrentMessages(storedConversation.messages || []); + setCurrentResponseId(storedConversation.responseId); + } + }, []); + + // Save conversation to sessionStorage when component unmounts + useEffect(() => { + return () => { + if (currentMessages.length > 0) { + saveConversationToStorage(currentMessages, currentResponseId); + } + }; + }, [currentMessages, currentResponseId]); + const handleScroll = (event: React.UIEvent) => { const target = event.target as HTMLElement; const bottom = @@ -120,6 +160,9 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { // Add user message to current conversation const updatedMessages = [...currentMessages, newMessage]; setCurrentMessages(updatedMessages); + + // Save user message immediately to prevent loss + saveConversationToStorage(updatedMessages, currentResponseId); // Call assistant API with previous response ID for continuity const data = await sendAssistantMessage( @@ -135,8 +178,12 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { }; // Update messages and store new response ID for next message - setCurrentMessages((prev) => [...prev, assistantMessage]); + const finalMessages = [...updatedMessages, assistantMessage]; + setCurrentMessages(finalMessages); setCurrentResponseId(data.final_response_id); + + // Save conversation to sessionStorage + saveConversationToStorage(finalMessages, data.final_response_id); } catch (error) { console.error("Error handling message:", error); let errorMessage = "Error submitting message"; @@ -207,6 +254,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { onClick={() => { setCurrentMessages([]); setCurrentResponseId(undefined); + sessionStorage.removeItem('currentConversation'); }} className="flex items-center justify-center" title="New Conversation" diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 3a29bc38..2573c223 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -169,6 +169,9 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { + // Clear chat conversation data on logout for security + sessionStorage.removeItem('currentConversation'); + dispatch({ type: LOGOUT, }); From 6f1161c688e3c8329e23e4868543d131c1eab64d Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 25 Aug 2025 16:35:41 -0400 Subject: [PATCH 75/92] =?UTF-8?q?Prevent=20the=20=E2=80=9Cre-save=20after?= =?UTF-8?q?=20logout=E2=80=9D=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Header/Chat.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 0edee74a..2f4f92ab 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -89,10 +89,13 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { } }, []); + // Save conversation to sessionStorage when component unmounts useEffect(() => { return () => { - if (currentMessages.length > 0) { + // Only save if the user hasn't logged out + const isLoggingOut = !localStorage.getItem("access"); + if (!isLoggingOut && currentMessages.length > 0) { saveConversationToStorage(currentMessages, currentResponseId); } }; From 3a64a478c0078b0c081bada3c04aeb453c669e4f Mon Sep 17 00:00:00 2001 From: hruday97 Date: Mon, 25 Aug 2025 21:18:16 -0400 Subject: [PATCH 76/92] Fix ChipsInput bug --- frontend/src/components/ChipsInput/ChipsInput.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ChipsInput/ChipsInput.tsx b/frontend/src/components/ChipsInput/ChipsInput.tsx index 7fd03b4d..32e4e332 100644 --- a/frontend/src/components/ChipsInput/ChipsInput.tsx +++ b/frontend/src/components/ChipsInput/ChipsInput.tsx @@ -59,7 +59,6 @@ const ChipsInput: React.FC = ({ const handleSuggestionClick = (selected: string) => { onChange([...value, selected]); - setInputFocused(false); // Close dropdown after selection setInputValue(""); }; @@ -131,7 +130,10 @@ const ChipsInput: React.FC = ({ {filteredSuggestions.map((item, idx) => (
  • handleSuggestionClick(item)} + onMouseDown={(e) => { + e.preventDefault(); + handleSuggestionClick(item) + }} className="px-3 py-2 cursor-pointer hover:bg-gray-100 text-sm" > {item} From 55913941e56b74444e42a2179a3f27f1b872099a Mon Sep 17 00:00:00 2001 From: hruday97 Date: Mon, 25 Aug 2025 21:18:48 -0400 Subject: [PATCH 77/92] Exclude PriorMedications --- server/api/views/listMeds/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 796d9b17..58a09b5c 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -23,6 +23,7 @@ class GetMedication(APIView): def post(self, request): data = request.data + print(data) state_query = data.get('state', '') include_result = [] exclude_result = [] @@ -42,6 +43,9 @@ def post(self, request): return Response({'error': 'Diagnosis not found'}, status=status.HTTP_404_NOT_FOUND) meds = {'first': [], 'second': [], 'third': []} + priorMeds = data.get('priorMedications', "").split(',') + exclude_result.extend([med.strip() for med in priorMeds if med.strip()]) + print(exclude_result) included_set = set(include_result) excluded_set = set(exclude_result) From 4a8a96cfbbca7f2c0261d991a5d47050c3ad9685 Mon Sep 17 00:00:00 2001 From: hruday97 Date: Mon, 25 Aug 2025 21:20:11 -0400 Subject: [PATCH 78/92] Remove unneccessary print --- server/api/views/listMeds/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 58a09b5c..f331eb9b 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -23,7 +23,6 @@ class GetMedication(APIView): def post(self, request): data = request.data - print(data) state_query = data.get('state', '') include_result = [] exclude_result = [] From 3db4409e86921e3d907f2095acdbf49eb7bfe1fa Mon Sep 17 00:00:00 2001 From: hruday97 Date: Mon, 25 Aug 2025 21:20:57 -0400 Subject: [PATCH 79/92] Remove unneccessary print --- server/api/views/listMeds/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index f331eb9b..72a7a310 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -44,7 +44,6 @@ def post(self, request): priorMeds = data.get('priorMedications', "").split(',') exclude_result.extend([med.strip() for med in priorMeds if med.strip()]) - print(exclude_result) included_set = set(include_result) excluded_set = set(exclude_result) From fa35396ac93ced9104e049de528479d0a8adfcf4 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 26 Aug 2025 14:17:04 -0400 Subject: [PATCH 80/92] Remove line from the disclosure --- frontend/src/components/Header/Chat.tsx | 70 ++++++++++++++++--------- 1 file changed, 44 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index 2f4f92ab..c6315068 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -47,22 +47,28 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const [error, setError] = useState(null); // Session storage functions for conversation management - const saveConversationToStorage = (messages: ChatLogItem[], responseId?: string) => { + const saveConversationToStorage = ( + messages: ChatLogItem[], + responseId?: string, + ) => { const conversationData = { messages, responseId, timestamp: new Date().toISOString(), }; - sessionStorage.setItem('currentConversation', JSON.stringify(conversationData)); + sessionStorage.setItem( + "currentConversation", + JSON.stringify(conversationData), + ); }; const loadConversationFromStorage = () => { - const stored = sessionStorage.getItem('currentConversation'); + const stored = sessionStorage.getItem("currentConversation"); if (stored) { try { return JSON.parse(stored); } catch (error) { - console.error('Error parsing stored conversation:', error); + console.error("Error parsing stored conversation:", error); } } return null; @@ -89,12 +95,11 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { } }, []); - // Save conversation to sessionStorage when component unmounts useEffect(() => { return () => { // Only save if the user hasn't logged out - const isLoggingOut = !localStorage.getItem("access"); + const isLoggingOut = !localStorage.getItem("access"); if (!isLoggingOut && currentMessages.length > 0) { saveConversationToStorage(currentMessages, currentResponseId); } @@ -163,7 +168,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { // Add user message to current conversation const updatedMessages = [...currentMessages, newMessage]; setCurrentMessages(updatedMessages); - + // Save user message immediately to prevent loss saveConversationToStorage(updatedMessages, currentResponseId); @@ -184,7 +189,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { const finalMessages = [...updatedMessages, assistantMessage]; setCurrentMessages(finalMessages); setCurrentResponseId(data.final_response_id); - + // Save conversation to sessionStorage saveConversationToStorage(finalMessages, data.final_response_id); } catch (error) { @@ -257,7 +262,7 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { onClick={() => { setCurrentMessages([]); setCurrentResponseId(undefined); - sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem("currentConversation"); }} className="flex items-center justify-center" title="New Conversation" @@ -313,25 +318,38 @@ const Chat: React.FC = ({ showChat, setShowChat }) => { Learn more about my sources.
  • -
    -
    +
    +
    ⚠️ IMPORTANT NOTICE
    -

    - Balancer is NOT configured for use with Protected Health Information (PHI) as defined under HIPAA. - You must NOT enter any patient-identifiable information including names, addresses, dates of birth, - medical record numbers, or any other identifying information. Your queries may be processed by - third-party AI services that retain data for up to 30 days for abuse monitoring. By using Balancer, - you certify that you understand these restrictions and will not enter any PHI. +

    + Balancer is NOT configured for use with Protected Health + Information (PHI) as defined under HIPAA. You must NOT + enter any patient-identifiable information including + names, addresses, dates of birth, medical record + numbers, or any other identifying information. By using + Balancer, you certify that you understand these + restrictions and will not enter any PHI.

    From 3b233e97da68e3a35892cdd118481164ebc233de Mon Sep 17 00:00:00 2001 From: taichan03 Date: Mon, 1 Sep 2025 11:18:36 -0400 Subject: [PATCH 81/92] re do sources so that it has buttons --- frontend/src/components/Header/Header.tsx | 15 -- .../pages/PatientManager/PatientSummary.tsx | 171 ++++++++++++------ 2 files changed, 114 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 32039605..477cc499 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -106,21 +106,6 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { tools provided herein are intended for general informational purposes only and should NOT be construed as medical advice, diagnosis, or treatment.{" "} - - here - {" "} - or email{" "} - - balancerteam@codeforphilly.org - - .

    diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 02ad9688..788672dc 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -49,14 +49,18 @@ const MedicationItem = ({ isClicked, riskData, loading, - onClick, + onSourcesClick, + onBenefitsRisksClick, + activePanel, }: { medication: string; source: string; isClicked: boolean; riskData: RiskData | null; loading: boolean; - onClick: () => void; + onSourcesClick: () => void; + onBenefitsRisksClick: () => void; + activePanel: "sources" | "benefits-risks" | null; }) => { if (medication === "None") { return ( @@ -85,25 +89,29 @@ const MedicationItem = ({
    Sources
    Benefits and risks
    - {isClicked && riskData && ( + {isClicked && riskData && activePanel === "benefits-risks" && (
    @@ -131,51 +139,54 @@ const MedicationItem = ({
    +
    + )} - {/* SOURCES */} - {!!riskData.sources?.length && ( -
    -
    -

    Sources

    -
    - -
    + ) : ( +

    + No sources available for this medication. +

    )}
    )} @@ -190,7 +201,9 @@ const MedicationTier = ({ clickedMedication, riskData, loading, - onMedicationClick, + onSourcesClick, + onBenefitsRisksClick, + activePanel, }: { title: string; tier: string; @@ -198,7 +211,9 @@ const MedicationTier = ({ clickedMedication: string | null; riskData: RiskData | null; loading: boolean; - onMedicationClick: (medication: MedicationWithSource) => void; + onSourcesClick: (medication: MedicationWithSource) => void; + onBenefitsRisksClick: (medication: MedicationWithSource) => void; + activePanel: "sources" | "benefits-risks" | null; }) => ( <>
    @@ -214,7 +229,9 @@ const MedicationTier = ({ isClicked={medicationObj.name === clickedMedication} riskData={riskData} loading={loading} - onClick={() => onMedicationClick(medicationObj)} + onSourcesClick={() => onSourcesClick(medicationObj)} + onBenefitsRisksClick={() => onBenefitsRisksClick(medicationObj)} + activePanel={activePanel} /> ))} @@ -237,6 +254,9 @@ const PatientSummary = ({ const [clickedMedication, setClickedMedication] = useState( null ); + const [activePanel, setActivePanel] = useState< + "sources" | "benefits-risks" | null + >(null); const [isModalOpen, setIsModalOpen] = useState({ status: false, id: "" }); @@ -256,27 +276,58 @@ const PatientSummary = ({ setLoading(false); setRiskData(null); setClickedMedication(null); + setActivePanel(null); } }, [isPatientDeleted, setShowSummary]); useEffect(() => { setRiskData(null); setClickedMedication(null); + setActivePanel(null); }, [patientInfo]); const handleClickSummary = () => { setShowSummary(!showSummary); }; - const handleMedicationClick = async (medicationObj: MedicationWithSource) => { + const handleSourcesClick = async (medicationObj: MedicationWithSource) => { + const { name: medication, source } = medicationObj; + + if (clickedMedication === medication && activePanel === "sources") { + setClickedMedication(null); + setActivePanel(null); + setRiskData(null); + return; + } + + setClickedMedication(medication); + setActivePanel("sources"); + setLoading(true); + + try { + const data = await fetchRiskDataWithSources(medication, source); + setRiskData(data as RiskData); + } catch (error) { + console.error("Error fetching risk data: ", error); + setRiskData(null); + } finally { + setLoading(false); + } + }; + + const handleBenefitsRisksClick = async ( + medicationObj: MedicationWithSource + ) => { const { name: medication, source } = medicationObj; - if (clickedMedication === medication) { + if (clickedMedication === medication && activePanel === "benefits-risks") { setClickedMedication(null); + setActivePanel(null); setRiskData(null); return; } setClickedMedication(medication); + setActivePanel("benefits-risks"); setLoading(true); try { @@ -362,7 +413,9 @@ const PatientSummary = ({ clickedMedication={clickedMedication} riskData={riskData} loading={loading} - onMedicationClick={handleMedicationClick} + onSourcesClick={handleSourcesClick} + onBenefitsRisksClick={handleBenefitsRisksClick} + activePanel={activePanel} />
    @@ -383,7 +438,9 @@ const PatientSummary = ({ clickedMedication={clickedMedication} riskData={riskData} loading={loading} - onMedicationClick={handleMedicationClick} + onSourcesClick={handleSourcesClick} + onBenefitsRisksClick={handleBenefitsRisksClick} + activePanel={activePanel} />
    From 50f7cc51ec52aab8835adbadb472f1071d50e585 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Mon, 1 Sep 2025 11:38:31 -0400 Subject: [PATCH 82/92] fixed the paitant panel where other info selected was not being diaplyed --- .../pages/PatientManager/NewPatientForm.tsx | 4 +- .../pages/PatientManager/PatientSummary.tsx | 67 ++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 774ebcb3..8310263a 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -388,7 +388,7 @@ const NewPatientForm = ({

    Select patient characteristics

    -
    + {/*
    Currently psychotic
    @@ -428,7 +428,7 @@ const NewPatientForm = ({
    - + */}
    History of suicide attempt(s) diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 788672dc..058d0957 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -621,11 +621,11 @@ const PatientSummary = ({ role="list" className="border border-gray-200 divide-y divide-gray-100 rounded-md" > - {patientInfo.Psychotic === "Yes" && ( + {/* {patientInfo.Psychotic === "Yes" && (
  • Currently psychotic
  • - )} + )} */} {patientInfo.Suicide === "Yes" && (
  • @@ -636,6 +636,69 @@ const PatientSummary = ({
  • )} + {patientInfo.Kidney === "Yes" && ( +
  • + + Patient has a history or risk of kidney + disease + + info + + +
  • + )} + {patientInfo.Liver === "Yes" && ( +
  • + + Patient has a history or risk of liver disease + + info + + +
  • + )} + {patientInfo.blood_pressure === "Yes" && ( +
  • + + Patient has a history or risk of low blood + pressure, or concern for falls + + info + + +
  • + )} + {patientInfo.weight_gain === "Yes" && ( +
  • + + PatienthHas weight gain concerns + + info + + +
  • + )} + + {patientInfo.risk_pregnancy === "Yes" && ( +
  • + + Patient wants to conceive in next 2 years + + info + + +
  • + )} + {patientInfo.any_pregnancy === "Yes" && ( +
  • + + Patient has a possibility of becoming pregnant + + info + + +
  • + )}
    From c2da354b65fe93b6694ab568bf940a16a4033646 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Sep 2025 19:39:10 -0400 Subject: [PATCH 83/92] Restore docker-compose.yml --- docker-compose.yml | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f36517ef..d8a8ca75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,19 @@ services: db: - image: pgvector/pgvector:pg15 - volumes: - - postgres_data:/var/lib/postgresql/data/ - - ./init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: + build: + context: ./db + dockerfile: Dockerfile + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + ports: - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 + networks: + app_net: + ipv4_address: 192.168.0.2 pgadmin: container_name: pgadmin4 image: dpage/pgadmin4 @@ -51,13 +52,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -71,4 +72,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 + gateway: 192.168.0.1 \ No newline at end of file From 839a9245009467d29ba3283e63282128692bb9ae Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Sep 2025 19:44:47 -0400 Subject: [PATCH 84/92] Logging configuration --- server/balancer_backend/settings.py | 204 ++++++++++++++-------------- 1 file changed, 103 insertions(+), 101 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 175ca6ab..9c73577b 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,56 +29,57 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() +ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. -if ALLOWED_HOSTS == ["*"] or ALLOWED_HOSTS == [""]: - ALLOWED_HOSTS = ["*"] +if ALLOWED_HOSTS == ['*'] or ALLOWED_HOSTS == ['']: + ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "balancer_backend", - "api", - "corsheaders", - "rest_framework", - "djoser", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'balancer_backend', + 'api', + 'corsheaders', + 'rest_framework', + 'djoser', ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'corsheaders.middleware.CorsMiddleware', + ] -ROOT_URLCONF = "balancer_backend.urls" +ROOT_URLCONF = 'balancer_backend.urls' CORS_ALLOW_ALL_ORIGINS = True TEMPLATES = [ { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(BASE_DIR, "build")], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'build')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', ], }, }, @@ -88,7 +89,7 @@ # Change this to your desired URL LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL") -WSGI_APPLICATION = "balancer_backend.wsgi.application" +WSGI_APPLICATION = 'balancer_backend.wsgi.application' # Database @@ -105,8 +106,8 @@ } } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" +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", "") @@ -118,25 +119,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ -LANGUAGE_CODE = "en-us" +LANGUAGE_CODE = 'en-us' -TIME_ZONE = "UTC" +TIME_ZONE = 'UTC' USE_I18N = True @@ -146,89 +147,90 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = "/static/" +STATIC_URL = '/static/' STATICFILES_DIRS = [ - os.path.join(BASE_DIR, "build/static"), + os.path.join(BASE_DIR, 'build/static'), ] -STATIC_ROOT = os.path.join(BASE_DIR, "static") +STATIC_ROOT = os.path.join(BASE_DIR, 'static') AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", + 'django.contrib.auth.backends.ModelBackend', ] REST_FRAMEWORK = { - "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ], + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', ), } SIMPLE_JWT = { - "AUTH_HEADER_TYPES": ("JWT",), - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), - "TOKEN_OBTAIN_SERIALIZER": "api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer", - "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + 'AUTH_HEADER_TYPES': ('JWT',), + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'TOKEN_OBTAIN_SERIALIZER': 'api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer', + 'AUTH_TOKEN_CLASSES': ( + 'rest_framework_simplejwt.tokens.AccessToken', + ) } DJOSER = { - "LOGIN_FIELD": "email", - "USER_CREATE_PASSWORD_RETYPE": True, - "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, - "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, - "SEND_CONFIRMATION_EMAIL": True, - "SET_USERNAME_RETYPE": True, - "SET_PASSWORD_RETYPE": True, - "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", - "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", - "ACTIVATION_URL": "activate/{uid}/{token}", - "SEND_ACTIVATION_EMAIL": True, - "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", - "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [ - "http://localhost:8000/google", - "http://localhost:8000/facebook", - ], - "SERIALIZERS": { - "user_create": "api.models.serializers.UserCreateSerializer", - "user": "api.models.serializers.UserCreateSerializer", - "current_user": "api.models.serializers.UserCreateSerializer", - "user_delete": "djoser.serializers.UserDeleteSerializer", - }, + 'LOGIN_FIELD': 'email', + 'USER_CREATE_PASSWORD_RETYPE': True, + 'USERNAME_CHANGED_EMAIL_CONFIRMATION': True, + 'PASSWORD_CHANGED_EMAIL_CONFIRMATION': True, + 'SEND_CONFIRMATION_EMAIL': True, + 'SET_USERNAME_RETYPE': True, + 'SET_PASSWORD_RETYPE': True, + 'PASSWORD_RESET_CONFIRM_URL': 'password/reset/confirm/{uid}/{token}', + 'USERNAME_RESET_CONFIRM_URL': 'email/reset/confirm/{uid}/{token}', + 'ACTIVATION_URL': 'activate/{uid}/{token}', + 'SEND_ACTIVATION_EMAIL': True, + 'SOCIAL_AUTH_TOKEN_STRATEGY': 'djoser.social.token.jwt.TokenStrategy', + 'SOCIAL_AUTH_ALLOWED_REDIRECT_URIS': ['http://localhost:8000/google', 'http://localhost:8000/facebook'], + 'SERIALIZERS': { + 'user_create': 'api.models.serializers.UserCreateSerializer', + 'user': 'api.models.serializers.UserCreateSerializer', + 'current_user': 'api.models.serializers.UserCreateSerializer', + 'user_delete': 'djoser.serializers.UserDeleteSerializer', + } } # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' -AUTH_USER_MODEL = "api.UserAccount" +AUTH_USER_MODEL = 'api.UserAccount' # 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 871706fa7264ac454a029b93dc1a3f80feaa0d50 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Sep 2025 19:47:03 -0400 Subject: [PATCH 85/92] Linting and formating --- server/balancer_backend/settings.py | 157 ++++++++++++++-------------- 1 file changed, 78 insertions(+), 79 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 9c73577b..df62d198 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,57 +29,56 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split() +ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. -if ALLOWED_HOSTS == ['*'] or ALLOWED_HOSTS == ['']: - ALLOWED_HOSTS = ['*'] +if ALLOWED_HOSTS == ["*"] or ALLOWED_HOSTS == [""]: + ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'balancer_backend', - 'api', - 'corsheaders', - 'rest_framework', - 'djoser', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "balancer_backend", + "api", + "corsheaders", + "rest_framework", + "djoser", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'corsheaders.middleware.CorsMiddleware', - + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", ] -ROOT_URLCONF = 'balancer_backend.urls' +ROOT_URLCONF = "balancer_backend.urls" CORS_ALLOW_ALL_ORIGINS = True TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'build')], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "build")], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, @@ -89,7 +88,7 @@ # Change this to your desired URL LOGIN_REDIRECT_URL = os.environ.get("LOGIN_REDIRECT_URL") -WSGI_APPLICATION = 'balancer_backend.wsgi.application' +WSGI_APPLICATION = "balancer_backend.wsgi.application" # Database @@ -106,8 +105,8 @@ } } -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.gmail.com' +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", "") @@ -119,25 +118,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/4.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -147,69 +146,69 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'build/static'), + os.path.join(BASE_DIR, "build/static"), ] -STATIC_ROOT = os.path.join(BASE_DIR, 'static') +STATIC_ROOT = os.path.join(BASE_DIR, "static") AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework_simplejwt.authentication.JWTAuthentication", ), } SIMPLE_JWT = { - 'AUTH_HEADER_TYPES': ('JWT',), - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'TOKEN_OBTAIN_SERIALIZER': 'api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer', - 'AUTH_TOKEN_CLASSES': ( - 'rest_framework_simplejwt.tokens.AccessToken', - ) + "AUTH_HEADER_TYPES": ("JWT",), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "TOKEN_OBTAIN_SERIALIZER": "api.models.TokenObtainPairSerializer.MyTokenObtainPairSerializer", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } DJOSER = { - 'LOGIN_FIELD': 'email', - 'USER_CREATE_PASSWORD_RETYPE': True, - 'USERNAME_CHANGED_EMAIL_CONFIRMATION': True, - 'PASSWORD_CHANGED_EMAIL_CONFIRMATION': True, - 'SEND_CONFIRMATION_EMAIL': True, - 'SET_USERNAME_RETYPE': True, - 'SET_PASSWORD_RETYPE': True, - 'PASSWORD_RESET_CONFIRM_URL': 'password/reset/confirm/{uid}/{token}', - 'USERNAME_RESET_CONFIRM_URL': 'email/reset/confirm/{uid}/{token}', - 'ACTIVATION_URL': 'activate/{uid}/{token}', - 'SEND_ACTIVATION_EMAIL': True, - 'SOCIAL_AUTH_TOKEN_STRATEGY': 'djoser.social.token.jwt.TokenStrategy', - 'SOCIAL_AUTH_ALLOWED_REDIRECT_URIS': ['http://localhost:8000/google', 'http://localhost:8000/facebook'], - 'SERIALIZERS': { - 'user_create': 'api.models.serializers.UserCreateSerializer', - 'user': 'api.models.serializers.UserCreateSerializer', - 'current_user': 'api.models.serializers.UserCreateSerializer', - 'user_delete': 'djoser.serializers.UserDeleteSerializer', - } + "LOGIN_FIELD": "email", + "USER_CREATE_PASSWORD_RETYPE": True, + "USERNAME_CHANGED_EMAIL_CONFIRMATION": True, + "PASSWORD_CHANGED_EMAIL_CONFIRMATION": True, + "SEND_CONFIRMATION_EMAIL": True, + "SET_USERNAME_RETYPE": True, + "SET_PASSWORD_RETYPE": True, + "PASSWORD_RESET_CONFIRM_URL": "password/reset/confirm/{uid}/{token}", + "USERNAME_RESET_CONFIRM_URL": "email/reset/confirm/{uid}/{token}", + "ACTIVATION_URL": "activate/{uid}/{token}", + "SEND_ACTIVATION_EMAIL": True, + "SOCIAL_AUTH_TOKEN_STRATEGY": "djoser.social.token.jwt.TokenStrategy", + "SOCIAL_AUTH_ALLOWED_REDIRECT_URIS": [ + "http://localhost:8000/google", + "http://localhost:8000/facebook", + ], + "SERIALIZERS": { + "user_create": "api.models.serializers.UserCreateSerializer", + "user": "api.models.serializers.UserCreateSerializer", + "current_user": "api.models.serializers.UserCreateSerializer", + "user_delete": "djoser.serializers.UserDeleteSerializer", + }, } # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -AUTH_USER_MODEL = 'api.UserAccount' +AUTH_USER_MODEL = "api.UserAccount" # Logging configuration + # LOGGING = { # "version": 1, # "disable_existing_loggers": False, From 26f4f29f1cb48bf55e0c2646b467723f2e395681 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 4 Sep 2025 10:17:52 -0400 Subject: [PATCH 86/92] BUGFIX: OpenAI model reasoning summary requires org verification --- server/api/views/assistant/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index ca65f335..32089c58 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -223,7 +223,8 @@ def search_documents(query: str, user=user) -> str: MODEL_DEFAULTS = { "instructions": INSTRUCTIONS, "model": "gpt-5-nano", # 400,000 token context window - "reasoning": {"effort": "low", "summary": "auto"}, + # 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, } From 8d9439a04341e72eedc522dcb859af5682529214 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sat, 25 Oct 2025 15:41:32 -0400 Subject: [PATCH 87/92] save changes to insert sources in patent summary --- docker-compose.yml | 62 ++--- frontend/src/api/apiClient.ts | 2 +- .../pages/PatientManager/PatientSummary.tsx | 19 +- .../0014_alter_medrule_rule_type.py | 18 ++ server/api/models/model_medRule.py | 2 +- server/api/views/embeddings/embeddingsView.py | 2 +- server/api/views/listMeds/views.py | 14 +- .../api/views/risk/views_riskWithSources.py | 214 +++++++++++++++--- server/api/views/uploadFile/views.py | 7 +- server/entrypoint.sh | 2 +- 10 files changed, 267 insertions(+), 75 deletions(-) create mode 100644 server/api/migrations/0014_alter_medrule_rule_type.py diff --git a/docker-compose.yml b/docker-compose.yml index d8a8ca75..aea1993b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,34 @@ services: db: - build: - context: ./db - dockerfile: Dockerfile - volumes: - - postgres_data:/var/lib/postgresql/data/ - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: - - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 - pgadmin: - container_name: pgadmin4 - image: dpage/pgadmin4 + build: + context: ./db + dockerfile: Dockerfile + volumes: + - postgres_data:/var/lib/postgresql/data/ environment: - PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org - PGADMIN_DEFAULT_PASSWORD: balancer - # PGADMIN_LISTEN_PORT = 80 - # volumes: - # - ./pgadmin-data:/var/lib/pgadmin - # # PGADMIN_LISTEN_PORT = 80 + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev ports: - - "5050:80" + - "5433:5432" networks: app_net: - ipv4_address: 192.168.0.4 + ipv4_address: 192.168.0.2 + # pgadmin: + # container_name: pgadmin4 + # image: dpage/pgadmin4 + # environment: + # PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org + # PGADMIN_DEFAULT_PASSWORD: balancer + # # PGADMIN_LISTEN_PORT = 80 + # # volumes: + # # - ./pgadmin-data:/var/lib/pgadmin + # # # PGADMIN_LISTEN_PORT = 80 + # ports: + # - "5050:80" + # networks: + # app_net: + # ipv4_address: 192.168.0.4 backend: image: balancer-backend build: ./server @@ -52,13 +52,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -72,4 +72,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 \ No newline at end of file + gateway: 192.168.0.1 diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 3e672f1e..26a6ab8a 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -67,7 +67,7 @@ const handleRuleExtraction = async (guid: string) => { } }; -const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { +const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" | "diagnosis_depressed" = "include") => { try { const response = await api.post(`/v1/api/riskWithSources`, { drug: medication, diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 058d0957..5550558b 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -304,7 +304,16 @@ const PatientSummary = ({ setLoading(true); try { - const data = await fetchRiskDataWithSources(medication, source); + // Map source based on patient's diagnosis + let apiSource: "include" | "diagnosis" | "diagnosis_depressed" = source; + if (source === "diagnosis" && patientInfo.Diagnosis === "Depressed") { + apiSource = "diagnosis_depressed"; + } + + const data = await fetchRiskDataWithSources(medication, apiSource); + console.log("Risk data received for", medication, "with source", apiSource, ":", data); + console.log("Sources array:", data.sources); + console.log("Sources length:", data.sources?.length); setRiskData(data as RiskData); } catch (error) { console.error("Error fetching risk data: ", error); @@ -331,7 +340,13 @@ const PatientSummary = ({ setLoading(true); try { - const data = await fetchRiskDataWithSources(medication, source); + // Map source based on patient's diagnosis + let apiSource: "include" | "diagnosis" | "diagnosis_depressed" = source; + if (source === "diagnosis" && patientInfo.Diagnosis === "Depressed") { + apiSource = "diagnosis_depressed"; + } + + const data = await fetchRiskDataWithSources(medication, apiSource); setRiskData(data as RiskData); } catch (error) { console.error("Error fetching risk data: ", error); diff --git a/server/api/migrations/0014_alter_medrule_rule_type.py b/server/api/migrations/0014_alter_medrule_rule_type.py new file mode 100644 index 00000000..7d43fcd9 --- /dev/null +++ b/server/api/migrations/0014_alter_medrule_rule_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2025-10-25 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0013_medrule_medications'), + ] + + operations = [ + migrations.AlterField( + model_name='medrule', + name='rule_type', + field=models.CharField(choices=[('INCLUDE', 'Include'), ('EXCLUDE', 'Exclude')], max_length=500), + ), + ] diff --git a/server/api/models/model_medRule.py b/server/api/models/model_medRule.py index b8bf4d1b..272e0bb9 100644 --- a/server/api/models/model_medRule.py +++ b/server/api/models/model_medRule.py @@ -8,7 +8,7 @@ class MedRule(models.Model): ('INCLUDE', 'Include'), ('EXCLUDE', 'Exclude'), ] - rule_type = models.CharField(max_length=7, choices=RULE_TYPE_CHOICES) + rule_type = models.CharField(max_length=500, choices=RULE_TYPE_CHOICES) history_type = models.CharField(max_length=255) reason = models.TextField(blank=True, null=True) label = models.CharField(max_length=255, blank=True, null=True) diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index 9469bb09..d0bdd8ca 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -90,7 +90,7 @@ def stream_generator(): return Response({ "question": message, "llm_response": answer, - "embeddings_info": embeddings_results, + "embeddings_info": listOfEmbeddings, "sent_to_llm": prompt_text, }, status=status.HTTP_200_OK) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 72a7a310..1976458e 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -24,6 +24,7 @@ class GetMedication(APIView): def post(self, request): data = request.data state_query = data.get('state', '') + print(state_query) include_result = [] exclude_result = [] for condition in MEDS_INCLUDE: @@ -43,7 +44,8 @@ def post(self, request): meds = {'first': [], 'second': [], 'third': []} priorMeds = data.get('priorMedications', "").split(',') - exclude_result.extend([med.strip() for med in priorMeds if med.strip()]) + exclude_result.extend([med.strip() + for med in priorMeds if med.strip()]) included_set = set(include_result) excluded_set = set(exclude_result) @@ -62,7 +64,7 @@ def post(self, request): continue meds[tier_label].append({ 'name': med_name, - 'source': 'diagnosis' + 'source': 'diagnosis_' + state_query.lower() }) return Response(meds) @@ -97,7 +99,7 @@ def post(self, request): name = data.get('name', '').strip() benefits = data.get('benefits', '').strip() risks = data.get('risks', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) @@ -105,7 +107,7 @@ def post(self, request): return Response({'error': 'Medication benefits are required'}, status=status.HTTP_400_BAD_REQUEST) if not risks: return Response({'error': 'Medication risks are required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication already exists if Medication.objects.filter(name=name).exists(): return Response({'error': f'Medication "{name}" already exists'}, status=status.HTTP_400_BAD_REQUEST) @@ -125,11 +127,11 @@ class DeleteMedication(APIView): def delete(self, request): data = request.data name = data.get('name', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication exists and delete try: medication = Medication.objects.get(name=name) diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index d1c01615..0be43dbb 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -14,17 +14,36 @@ def post(self, request): drug = request.data.get("drug") if not drug: return Response({"error": "Drug not found. Request must include 'drug'."}, status=status.HTTP_400_BAD_REQUEST) - source = request.data.get("source") - if source not in ["include", "diagnosis"]: - return Response({"error": "Source must be either 'include' or 'diagnosis'."}, status=status.HTTP_400_BAD_REQUEST) + print(f"Requested source: {source}") + + # If source is provided, validate it + valid_sources = ["include", "diagnosis", "diagnosis_depressed", "diagnosis_manic", "diagnosis_hypomanic", "diagnosis_euthymic"] + if source and source not in valid_sources: + return Response({"error": f"Source must be one of: {', '.join(valid_sources)}."}, status=status.HTTP_400_BAD_REQUEST) + + # If no source is provided, return all sources + if not source: + try: + return self._handle_all_sources(drug) + except Exception as e: + print(f"Error in _handle_all_sources: {str(e)}") + return Response({"error": f"Failed to retrieve all sources data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Handle diagnosis source by linking to medrules - if source == "diagnosis": - return self._handle_diagnosis_source(drug) + if source in ["diagnosis", "diagnosis_depressed", "diagnosis_manic", "diagnosis_hypomanic", "diagnosis_euthymic"]: + try: + return self._handle_diagnosis_source(drug, source) + except Exception as e: + print(f"Error in _handle_diagnosis_source: {str(e)}") + return Response({"error": f"Failed to retrieve diagnosis data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) if source == "include": - return self._handle_include_source(drug) + try: + return self._handle_include_source(drug) + except Exception as e: + print(f"Error in _handle_include_source: {str(e)}") + return Response({"error": f"Failed to retrieve include data: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) # Handle include source (existing logic) try: @@ -108,8 +127,14 @@ def _handle_include_source(self, drug): for source_link in medrule_sources: embedding = source_link.embedding + # Get filename from upload_file if available + filename = None + if hasattr(embedding, 'upload_file') and embedding.upload_file: + filename = embedding.upload_file.file_name + source_info = { - 'title': getattr(embedding, 'title', 'Unknown source'), + 'filename': filename, + 'title': getattr(embedding, 'title', None), 'publication': getattr(embedding, 'publication', ''), 'text': getattr(embedding, 'text', ''), 'rule_type': medrule.rule_type, @@ -141,14 +166,13 @@ def _handle_include_source(self, drug): return Response({ 'benefits': basic_benefits, 'risks': basic_risks, - 'source': 'include', + 'sources': 'include', 'note': 'No specific medrule sources found, showing general medication information' }) return Response({ 'benefits': [f'- {b.strip()}' for b in medication.benefits.split(',')], 'risks': risks if risks else [f'- {r.strip()}' for r in medication.risks.split(',')], - 'source': 'include', 'sources': sources_info, 'medrules_found': len(medrules) + len(exclude_rules) }) @@ -156,59 +180,106 @@ def _handle_include_source(self, drug): except Medication.DoesNotExist: return Response({"error": f"Medication '{drug}' not found."}, status=status.HTTP_404_NOT_FOUND) - def _handle_diagnosis_source(self, drug): + def _handle_diagnosis_source(self, drug, source): """Handle diagnosis source by looking up medrules for the medication""" try: # Get the medication medication = Medication.objects.get(name=drug) - # Find medrules that include this medication - medrules = MedRule.objects.filter( - medications=medication, - rule_type='INCLUDE' - ) + print( + f"Found medication '{medication.name}' for '{drug}' with ID {medication.id}") + + # Map source parameter to history_type value + source_to_history_type = { + "diagnosis_depressed": "DIAGNOSIS_DEPRESSED", + "diagnosis_manic": "DIAGNOSIS_MANIC", + "diagnosis_hypomanic": "DIAGNOSIS_HYPOMANIC", + "diagnosis_euthymic": "DIAGNOSIS_EUTHYMIC", + "diagnosis": "DIAGNOSIS_DEPRESSED" # default to depressed for backward compatibility + } + + history_type = source_to_history_type.get(source, "DIAGNOSIS_DEPRESSED") + print(f"Using history_type: {history_type} for source: {source}") + + # Find medrules that include this medication with the specified history type + medrule_ids = MedRuleSource.objects.filter( + medication=medication, + medrule__history_type=history_type + ).values_list('medrule_id', flat=True).distinct() + print(medrule_ids) + + medrules = MedRule.objects.filter(id__in=medrule_ids) + print(f"Found {medrules.count()} medrules for {drug}") benefits = [] risks = [] sources_info = [] - # Extract information from medrules and their sources + # Extract benefits and sources for medrule in medrules: - if medrule.explanation: + print( + f"Medrule {medrule.id}: rule_type={medrule.rule_type}, explanation='{medrule.explanation}'") + + # Only add INCLUDE rules to benefits + if medrule.rule_type == 'INCLUDE' and medrule.explanation: benefits.append(f"- {medrule.explanation}") + elif medrule.rule_type == 'EXCLUDE' and medrule.explanation: + # Add EXCLUDE rules to risks + risks.append(f"- {medrule.explanation}") + else: + print( + f"Medrule {medrule.id} has no explanation or is not INCLUDE/EXCLUDE, skipping") # Get associated sources through MedRuleSource medrule_sources = MedRuleSource.objects.filter( medrule=medrule, medication=medication ) + print( + f"Found {medrule_sources.count()} sources for medrule {medrule.id}") for source_link in medrule_sources: embedding = source_link.embedding + + # Get filename from upload_file if available + filename = None + if hasattr(embedding, 'upload_file') and embedding.upload_file: + filename = embedding.upload_file.file_name + source_info = { - 'title': getattr(embedding, 'title', 'Unknown source'), + 'filename': filename, + 'title': getattr(embedding, 'title', None), 'publication': getattr(embedding, 'publication', ''), 'text': getattr(embedding, 'text', ''), 'rule_type': medrule.rule_type, 'history_type': medrule.history_type, # Add link data for PDF navigation - 'guid': getattr(embedding, 'guid', None), + 'upload_fileid': getattr(embedding, 'upload_file_id', None), 'page': getattr(embedding, 'page_num', None), 'link_url': self._build_pdf_link(embedding) } + sources_info.append(source_info) - # Also check for exclude rules (risks) + # Check EXCLUDE rules for risks with the specified history type exclude_rules = MedRule.objects.filter( medications=medication, - rule_type='EXCLUDE' + rule_type='EXCLUDE', + history_type=history_type ) + print(f"Found {exclude_rules.count()} exclude rules for {drug}") for rule in exclude_rules: + print( + f"Exclude rule {rule.id}: explanation='{rule.explanation}'") if rule.explanation: risks.append(f"- {rule.explanation}") + else: + print( + f"Exclude rule {rule.id} has no explanation, skipping risks") - # If no medrule data found, fall back to basic medication data + print( + f"Total benefits collected: {len(benefits)}, Total risks collected: {len(risks)}") if not benefits and not risks: basic_benefits = [ f'- {b.strip()}' for b in medication.benefits.split(',')] @@ -218,21 +289,112 @@ def _handle_diagnosis_source(self, drug): return Response({ 'benefits': basic_benefits, 'risks': basic_risks, - 'source': 'diagnosis', + 'sources': sources_info, 'note': 'No specific medrule sources found, showing general medication information' }) return Response({ 'benefits': benefits if benefits else [f'- {b.strip()}' for b in medication.benefits.split(',')], 'risks': risks if risks else [f'- {r.strip()}' for r in medication.risks.split(',')], - 'source': 'diagnosis', 'sources': sources_info, 'medrules_found': len(medrules) + len(exclude_rules) }) except Medication.DoesNotExist: - # If medication not in database, use AI fallback with diagnosis context - return self._get_ai_response_for_diagnosis(drug) + return Response({"error": f"Medication '{drug}' not found."}, status=status.HTTP_404_NOT_FOUND) + + def _handle_all_sources(self, drug): + """Handle request with no source specified - return all sources""" + try: + # Get the medication + medication = Medication.objects.get(name=drug) + + print( + f"Found medication '{medication.name}' for '{drug}' with ID {medication.id}") + + # Find all medrules that are related to this medication via MedRuleSource + medrule_ids = MedRuleSource.objects.filter( + medication=medication + ).values_list('medrule_id', flat=True).distinct() + + medrules = MedRule.objects.filter(id__in=medrule_ids) + print( + f"Found {medrules.count()} total medrules for {drug} across all sources") + + benefits = [] + risks = [] + sources_info = [] + + # Extract benefits, risks, and sources from all medrules + for medrule in medrules: + print( + f"Medrule {medrule.id}: rule_type={medrule.rule_type}, history_type={medrule.history_type}") + + # Add INCLUDE rules to benefits + if medrule.rule_type == 'INCLUDE' and medrule.explanation: + benefits.append(f"- {medrule.explanation}") + # Add EXCLUDE rules to risks + elif medrule.rule_type == 'EXCLUDE' and medrule.explanation: + risks.append(f"- {medrule.explanation}") + + # Get associated sources through MedRuleSource + medrule_sources = MedRuleSource.objects.filter( + medrule=medrule, + medication=medication + ) + print( + f"Found {medrule_sources.count()} sources for medrule {medrule.id}") + + for source_link in medrule_sources: + embedding = source_link.embedding + + # Get filename from upload_file if available + filename = None + if hasattr(embedding, 'upload_file') and embedding.upload_file: + filename = embedding.upload_file.file_name + + source_info = { + 'filename': filename, + 'title': getattr(embedding, 'title', None), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + # Add link data for PDF navigation + 'upload_fileid': getattr(embedding, 'upload_file_id', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding) + } + + sources_info.append(source_info) + + print( + f"Total benefits collected: {len(benefits)}, Total risks collected: {len(risks)}, Total sources: {len(sources_info)}") + + # If no medrule-based benefits or risks, fall back to basic medication info + if not benefits and not risks: + basic_benefits = [ + f'- {b.strip()}' for b in medication.benefits.split(',')] + basic_risks = [ + f'- {r.strip()}' for r in medication.risks.split(',')] + + return Response({ + 'benefits': basic_benefits, + 'risks': basic_risks, + 'sources': sources_info, + 'note': 'No specific medrule sources found, showing general medication information' + }) + + return Response({ + 'benefits': benefits if benefits else [f'- {b.strip()}' for b in medication.benefits.split(',')], + 'risks': risks if risks else [f'- {r.strip()}' for r in medication.risks.split(',')], + 'sources': sources_info, + 'medrules_found': len(medrules), + 'source_type': 'all' + }) + + except Medication.DoesNotExist: + return Response({"error": f"Medication '{drug}' not found."}, status=status.HTTP_404_NOT_FOUND) def _build_pdf_link(self, embedding): """Build the PDF viewer link URL by getting the document GUID from UploadFile""" diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 8989dbc3..6904e061 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -3,8 +3,6 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.generics import UpdateAPIView -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import csrf_exempt import pdfplumber from .models import UploadFile # Import your UploadFile model from .serializers import UploadFileSerializer @@ -16,9 +14,7 @@ from .title import generate_title -@method_decorator(csrf_exempt, name='dispatch') class UploadFileView(APIView): - permission_classes = [IsAuthenticated] def get(self, request, format=None): print("UploadFileView, get list") @@ -116,6 +112,7 @@ def post(self, request, format=None): Embeddings.objects.create( upload_file=new_file, name=new_file.file_name, # You may adjust the naming convention + title=title, # Set the title from the document text=chunk, chunk_number=i, page_num=page_num, # Store the page number here @@ -158,7 +155,6 @@ def delete(self, request, format=None): return Response({"message": f"Error deleting file and embeddings: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) -@method_decorator(csrf_exempt, name='dispatch') class RetrieveUploadFileView(APIView): permission_classes = [IsAuthenticated] @@ -174,7 +170,6 @@ def get(self, request, guid, format=None): return Response({"message": "No file found or access denied."}, status=status.HTTP_404_NOT_FOUND) -@method_decorator(csrf_exempt, name='dispatch') class EditFileMetadataView(UpdateAPIView): permission_classes = [IsAuthenticated] serializer_class = UploadFileSerializer diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 20db6c2e..2d2c872f 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -11,7 +11,7 @@ then echo "PostgreSQL started" fi -# python manage.py makemigrations api +python manage.py makemigrations api # # python manage.py flush --no-input python manage.py migrate # create superuser for postgre admin on start up From 1309567028b404aabe25840a9d091c510355b6f0 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 28 Oct 2025 17:39:05 -0400 Subject: [PATCH 88/92] feat: #395 - Add balancer email to Data Sources page --- frontend/src/pages/Help/DataSources.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Help/DataSources.tsx b/frontend/src/pages/Help/DataSources.tsx index 8d08d188..29478bd8 100644 --- a/frontend/src/pages/Help/DataSources.tsx +++ b/frontend/src/pages/Help/DataSources.tsx @@ -14,7 +14,8 @@ const data: DataProps = { "publicly available peer-reviewed medical research, as well as " + "through extensive interviews with physicians and medical professionals " + "who treat patients with bipolar disorder." + - " You may use this table to view all of our data sources." + " You may use this table to view all of our data sources." + + " To help us build our database, send your recommended publicly-available sources to " ], }; @@ -32,6 +33,9 @@ function HowTo() {

    {data.paragraph[0]} + + balancerteam@codeforphilly.org + .

    From 185584d7af726686e0f2e9f407d65cb13df6af18 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 28 Oct 2025 17:48:06 -0400 Subject: [PATCH 89/92] feat: #335 - Edit wording on notice to introduce first release; remove hidden class on mobile so it's always displayed --- frontend/src/components/Header/Header.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 477cc499..f696b614 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -100,17 +100,22 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { return (
    -
    +

    - This app is currently in its beta testing phase. The information and - tools provided herein are intended for general informational purposes - only and should NOT be construed as medical advice, diagnosis, or - treatment.{" "} + Welcome to Balancer’s first release! Found a bug or have feedback? Let us know {" "} + + here {" "} + + or email {" "} + + balancerteam@codeforphilly.org + + .

    -
    - App is in beta; Do not use info as medical advice. -
    diff --git a/frontend/src/pages/PatientManager/PatientHistory.tsx b/frontend/src/pages/PatientManager/PatientHistory.tsx index 8dbf5e64..f8dc14a6 100644 --- a/frontend/src/pages/PatientManager/PatientHistory.tsx +++ b/frontend/src/pages/PatientManager/PatientHistory.tsx @@ -86,7 +86,7 @@ const PatientHistory = ({
    - Current State: + Current or Most recent state
    {item.Diagnosis} diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 5550558b..16966360 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -620,7 +620,7 @@ const PatientSummary = ({
    - Current State: + Current or Most recent state
    {patientInfo.Diagnosis} From f4adf0f6e1fbab3bbec3cf1c4e334de10ef3dd9a Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 16 Nov 2025 12:09:03 -0500 Subject: [PATCH 92/92] feat(ci): update containers-publish workflow --- .github/workflows/containers-publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 9cd5fcce..b178b0b2 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -1,8 +1,8 @@ name: "Containers: Publish" on: - push: - tags: ["v*"] + release: + types: [published] permissions: packages: write @@ -12,10 +12,10 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Login to ghcr.io Docker registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }}